Skip to content

Fix/result production performance - #1551

Merged
mgovers merged 1 commit into
PowerGridModel:mainfrom
marhofmann:fix/result-production-performance
Aug 25, 2026
Merged

Fix/result production performance#1551
mgovers merged 1 commit into
PowerGridModel:mainfrom
marhofmann:fix/result-production-performance

Conversation

@marhofmann

Copy link
Copy Markdown
Contributor

While looking into something else entirely, I got curious about where the time actually goes when a
batch calculation writes out its results. I assumed it would be spread fairly evenly over writing
the node, branch and appliance records. It wasn't. Nearly all of it sat in one loop, and at a rate
that didn't add up: adding a single complex number onto a node was costing something like sixty
times more than computing and writing that same appliance's entire output record.

That seemed odd enough to chase down. It turned out to be two separate things, one big and one
small.

The big one: the whole result vector gets copied for every single value read

get_component_output() returns one element out of one of the solver's result containers. It picks
which container using a small lambda:

auto const& component_type_output = [&solver_output] {
    ...
    return solver_output.load_gen;   // a std::vector
}();
return component_type_output[math_id.pos];

The lambda has no explicit return type, so auto deduction drops the reference and that return
copy-constructs the entire vector. The auto const& on the outside then binds to the copy and
extends its lifetime, which looks like it is avoiding a copy but isn't — the copy has already
happened.

So every call allocates, copies the whole container, reads one element out of the copy, and throws
it away. Its only caller runs once per appliance while totalling injections onto nodes, so with N
appliances each scenario performs N copies of an N-element vector. Quadratic, where plain indexing
was obviously the intent.

The fix is just to write the return type out — auto const& on the lambda and on the function. What
comes back is then a reference into math_output.solver_output[group], which the caller owns and
which outlives the call.

Since the waste grew with the square of the appliance count, larger models were quietly paying much
more than the grid I measured on.

The small one: a heap allocation per node, per scenario

With that out of the way, what remained was the step that builds the per-node output vectors. Each
node gets a vector holding one value per user node — but a node only ever holds more than one when
links have merged several nodes into one. In a grid without links, every single one of them holds
exactly one number.

So the code was asking the allocator for room to store one complex number, once per node, for every
scenario. In the benchmark grid that is 2605 allocate-and-free pairs per scenario, roughly 2.6
million across a 1000-scenario batch. The ~37 ns per node I measured is a malloc, not arithmetic.

boost::container::small_vector<ComplexValue<sym>, 1> keeps room for one value inside the object
itself and only goes to the heap beyond that. That's why the inline size is 1: it covers the
ordinary node exactly, and merged nodes still work, they just allocate as before. Boost is already a
required dependency here (supernodes.hpp and topology.hpp use Boost.Graph) and small_vector is
header-only.

Why I believe this is safe

The second change alters a member's type, so that's the one worth being suspicious of. I tried
fairly hard to prove it wrong before accepting it.

There is exactly one place in the tree that constructs this member. Everywhere else only indexes
into it, and [i] behaves identically for a std::vector and a small_vector. Worth mentioning
that three unrelated things in this codebase are called bus_injection, and most of the search hits
belong to SolverOutput, which this doesn't touch at all.

The same function has a second branch for short-circuit output, and that specialisation of the
struct has no bus_injection member in the first place, so there is nothing there to affect.

And if I had missed a site somewhere, it would not quietly do the wrong thing: small_vector has no
converting constructor from std::vector, so anything left over would be a hard compile error
rather than a silent conversion. I checked that directly instead of assuming it. The project builds
with warnings as errors, so there was nowhere for a missed site to hide.

The case I was most worried about is a node holding more than one value, since that is where the
inline capacity spills to the heap. The handling-of-links validation cases cover exactly that, and
they pass unchanged.

The first change I'd argue is safe more simply: the same element is read either way, just without
the copy wrapped around it.

Numbers

tests/benchmark_cpp, radial grid, 2605 nodes, batch of 1000 scenarios, symmetric Newton-Raphson,
MSVC /O2. Clean builds of main and of this branch with no measuring code in either, medians of
four runs:

median min max
main 3.636 s 3.392 s 3.742 s
this branch 2.232 s 2.181 s 2.293 s

That's 1.63x faster, 38.6 % less total time.

I also ran a separate build with temporary timers inside result production to confirm the saving
really came from these two places rather than from something else moving. Those timers are not part
of this PR. They attribute 1.33 s to these two steps against the 1.40 s measured end to end, and a
neighbouring step that neither change touches stayed flat across both, so I don't think this is just
the machine having a good day.

Testing

All ten C++ test suites pass, 177415 assertions, built with warnings as errors. That includes the
validation data set and the link cases mentioned above.
tests/cpp_unit_tests/main_core/test_topological_node_output.cpp covers this code directly and
passes unchanged.

Happy to split this into two PRs if you'd prefer them reviewed separately — the first commit is a
bug fix and the second is an optimisation, and they're independent.

Note for maintainers

This needs a label to satisfy check-pr-labels, and I can't add one myself. The first commit is a
bug, the second an improvement.

Assisted-by: Claude (Anthropic)

get_component_output selects the right solver output container with an
immediately-invoked lambda whose return type is deduced with plain `auto`.
That strips the reference, so every call copy-constructs the entire
container just to read one element out of it and then destroys it again.
The `auto const&` on the outside lifetime-extends that temporary rather
than avoiding it.

The only caller, add_appliance_injection, invokes it once per appliance
while accumulating injections onto topological nodes. With N appliances in
a math group that is N copies of an N-element vector per scenario: O(N^2)
work and N heap allocations where O(1) indexing was intended.

Give the lambda and the function an explicit `auto const&` return type. The
referent is math_output.solver_output[group], owned by the caller, so it
outlives the call.

Measured on tests/benchmark_cpp (radial grid, 2605 nodes, 1000-scenario
symmetric Newton-Raphson batch), MSVC /O2. Step-level figures come from
temporary timers around the parts of produce_output, which are not part of
this commit:

  accumulate appliance injections   1.265 s -> 0.0055 s   228x
  solve_topological_nodes           1.383 s -> 0.107 s     13x

The cost is quadratic in appliances per math group, so larger models pay
disproportionately more than this grid does.

Behaviour is unchanged: the same element is read, only without the
surrounding copy.

Signed-off-by: Martin Hofmann <martin.hofmann-3@ei.thm.de>

@mgovers mgovers left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @marhofmann,

Thank you for the great finding and fixes. This indeed is a significant performance regression we should not have.

Regarding the "small improvement", can you please scope it out to a separate PR so that we can fast-track the big fix?

@nitbharambe nitbharambe added the improvement Improvement on internal implementation label Aug 25, 2026
@mgovers

mgovers commented Aug 25, 2026

Copy link
Copy Markdown
Member

Given your extensive benchmark, it would be very useful for us to have some more fine-grained numbers. Would you be willing to have a look at it?

@marhofmann
marhofmann force-pushed the fix/result-production-performance branch from 431e042 to c763f48 Compare August 25, 2026 14:18

@mgovers mgovers left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Let's fast-track this

@mgovers
mgovers enabled auto-merge August 25, 2026 14:24
@mgovers

mgovers commented Aug 25, 2026

Copy link
Copy Markdown
Member

Copy-pasting the details of the thread #1551 (comment) so it doesn't get lost in the resolved comments.

mgovers
14 minutes ago
Member
Since this introduces a new usage of boost, this is something I would like to take to the team first. Are you OK with scoping this change to a separate PR? that way, the big performance regression fix can be rolled out ASAP.

@nitbharambe
nitbharambe
6 minutes ago
Member
We have been restricting usage of boost to only larger utilities like graphs etc. We implement very simpler ones ourselves even if its available in boost.
I dont know how small_vector works / implemented. But if its a minor implementation, maybe you can do it in our repo.

@marhofmann

Copy link
Copy Markdown
Contributor Author

Thanks, that all makes sense! I've scoped it out and #1551 contains only the get_component_output fix

@marhofmann

Copy link
Copy Markdown
Contributor Author

Given your extensive benchmark, it would be very useful for us to have some more fine-grained numbers. Would you be willing to have a look at it?

Follow-up: fine-grained numbers

Happy to. The single figure in the PR description understates this, because the cost is quadratic in the number of injections in a math group, not constant. So the scaling is the interesting part.

Scaling

tests/benchmark_cpp with its natural grid shape, varying only n_node_total_specified. 50-scenario symmetric NR batch, sequential, MSVC /O2. Stock timers only. Both columns come from the same build directory with only the three-line fix toggled, so everything else is identical. All grids converged.

Injections = sources + loads + generators, i.e. what the accumulation loop actually walks.

nodes injections Produce output main Produce output fixed speed-up total main total fixed speed-up
202 201 0.002 s 0.001 s 1.9× 0.008 s 0.008 s 1.09×
4 207 2 196 0.150 s 0.023 s 6.4× 0.325 s 0.205 s 1.59×
5 809 2 994 0.275 s 0.031 s 8.8× 0.509 s 0.266 s 1.91×
21 829 10 974 3.905 s 0.106 s 36.9× 4.812 s 0.967 s 4.97×
53 068 26 535 20.396 s 0.275 s 74× 22.645 s 2.448 s 9.25×

The speed-up grows with grid size because the complexity changes, not the constant factor.

It is O(N²) before and O(N) after

Dividing by the appropriate power of N gives a near-constant on each side (202-node point excluded; fixed overheads dominate there):

injections main t / N² fixed t / N
2 196 3.106e-08 1.066e-05
2 994 3.070e-08 1.044e-05
10 974 3.243e-08 9.645e-06
26 535 2.897e-08 1.038e-05

Over a 12.1× range of N: t/N² varies by 1.12× on main, t/N by 1.11× on the fixed build.

The clearest single view

Share of batch runtime spent producing output:

nodes main fixed
202 18.4 % 10.8 %
4 207 46.1 % 11.4 %
5 809 54.1 % 11.8 %
21 829 81.2 % 10.9 %
53 068 90.1 % 11.3 %

On main the share climbs with grid size; on the fixed build it is flat at ~11 %, which is what output writing should cost.

At 53 068 nodes, unmodified main spends 20.4 s producing output and 1.8 s solving the power flow — the solver is 8.0 % of the run.

Where the time sits inside result production

Default grid (2605 nodes, 1000-scenario batch), with temporary timers around the parts of produce_output. Single run each, same instrumented build, fix toggled:

step before after
summing appliance injections onto nodes 1.2654 s 0.0056 s (225×)
building the per-node output vectors 0.1168 s 0.1168 s
writing node results 0.0556 s 0.0552 s
writing branch results 0.0477 s 0.0500 s
writing appliance results 0.0204 s 0.0205 s
Produce output total 1.5128 s 0.2519 s (6.0×)
Math Calculation 1.6366 s 1.5768 s
whole batch run 3.5377 s 2.1747 s (1.63×)

Writing every node, branch and appliance record is 0.124 s — 3.5 % of runtime. Everything else in Produce output was the accumulation loop.

Two rows are controls: building the per-node vectors is untouched by this change and measures 1.000× across it, and Math Calculation is flat. So the gain is localised, not a general shift.

What made me look at that loop in the first place: summing injections cost 61.9× more than writing every appliance's complete output record — strictly less work, far more time.

How to reproduce the scaling table

No instrumentation needed — only main() changes, to loop over sizes and read the existing Produce output timer:

power_grid_model::Idx constexpr batch_size = 50;
std::array<power_grid_model::Idx, 5> const sizes{1000, 2000, 8000, 24000, 48000};

for (auto const n_spec : sizes) {
    power_grid_model::benchmark::PowerGridBenchmark benchmarker{};
    power_grid_model::benchmark::Option option{};
    option.n_node_total_specified = n_spec;
    option.n_mv_feeder = 20;
    option.n_node_per_mv_feeder = 10;
    option.n_lv_feeder = 10;
    option.n_connection_per_lv_feeder = 40;
    option.has_measurements = false;
    option.has_fault = false;
    option.has_tap_changer = false;
    option.has_mv_ring = false;
    option.has_lv_ring = false;
    benchmarker.run_benchmark(option,
                              {.calculation_type = power_flow,
                               .calculation_symmetry = symmetric,
                               .calculation_method = newton_raphson},
                              batch_size);
}

The per-step breakdown needs extra timers, which are not in this PR. Happy to share that patch — Produce output is currently one timer covering supernode resolution through to the last record written, so a finer split might be worth having permanently.

Caveats
  • Scaling table is one run per point. The effect is orders of magnitude larger than run-to-run drift, but the 202-node row is small enough that fixed overheads dominate and its ratios mean little.
  • Breakdown table is one run per side. The 225× row is far outside drift; the sub-0.1 s rows are within noise of each other,d not change.
  • Windows, MSVC /O2, sequential throughout. I have not checked GCC or Clang. The copy is observable behaviour on a std::vector, so I would not expect elision, but I have not verified it.
  • Incidental find while scripting this: fictional_grid_generator.hpp:178 computes n_parallel_hv_mv_transformer from option.n_mv_feeder (the caller's value) rather than option_.n_mv_feeder (the adjusted one). Harmless with normal inputs; with unusual ones it produces absurd transformer counts. Happy to raise separately.

@mgovers
mgovers added this pull request to the merge queue Aug 25, 2026
Merged via the queue into PowerGridModel:main with commit 3c9c4b4 Aug 25, 2026
32 checks passed
@petersalemink95

Copy link
Copy Markdown
Member

Hi @marhofmann,

Thanks for finding and fixing this!

Would you be open to present your findings in one of the community meetings?
It would be interesting for the community to learn how you realized there was a bug and how you pinpointed the problem.

We hold community meetings every 8 weeks.
The next will take place on Wednesday 2 September, 16:00-17:00 CEST

@mgovers mgovers added bug Something isn't working and removed improvement Improvement on internal implementation labels Aug 26, 2026
@mgovers

mgovers commented Aug 26, 2026

Copy link
Copy Markdown
Member

Hi @marhofmann,

Your fix was merged into main and released in the currently latest version v1.13.150, released on all platforms. I updated the label to bug to indicate that we really do consider this a bugfix to a performance regression, not "merely" an improvement.

Regarding your proposal to use boost::container::small_vector, we decided to go with it. Can you please create another PR with your proposed change? We can then review and merge it quickly as well, providing maximal value to our users.

@marhofmann

Copy link
Copy Markdown
Contributor Author

Hi @mgovers,

Nice, thanks for getting it merged so fast! I'll put the small_vector PR up shortly.

One thing to watch out for: it clashes with #1536, which assigns a ComplexValueVector straight to bus_injection, so whichever one goes in second will need a small fix.

@marhofmann

Copy link
Copy Markdown
Contributor Author

Hi @marhofmann,

Thanks for finding and fixing this!

Would you be open to present your findings in one of the community meetings? It would be interesting for the community to learn how you realized there was a bug and how you pinpointed the problem.

We hold community meetings every 8 weeks. The next will take place on Wednesday 2 September, 16:00-17:00 CEST

Thanks! I'd really like to do that, sounds like a nice opportunity. Unfortunately I can't make 2 September. Could I join one of the later meetings instead?

@mgovers

mgovers commented Aug 26, 2026

Copy link
Copy Markdown
Member

Hi @mgovers,

Nice, thanks for getting it merged so fast! I'll put the small_vector PR up shortly.

One thing to watch out for: it clashes with #1536, which assigns a ComplexValueVector straight to bus_injection, so whichever one goes in second will need a small fix.

No worries, I also already resolved the merge conflict with #1551 as well, doing another one will be minor. I expect your PR to be fast-tracked.

I do foresee many other places in our code being able to benefit from the small_vector as well. Applying that it's probably a bit more involved, so let's scope that out to yet another PR, but with that potential future improvement in mind, can you please implement the small_vector declaration in the following generic way?

  • in power_grid_model_c/power_grid_model/include/power_grid_model/common, create a new file small_vector.hpp, similar to what we do for the three_phase_tensor.hpp. It can be a very small wrapper just like in your original change proposal, consisting only of:
    • license header
    • the correct namespace
    • the correct using declaration that exposes small_vector as a generic type.
  • in the location where the BusInjection was declared, you can then use the generic small_vector

@petersalemink95

Copy link
Copy Markdown
Member

Hi @marhofmann,
Thanks for finding and fixing this!
Would you be open to present your findings in one of the community meetings? It would be interesting for the community to learn how you realized there was a bug and how you pinpointed the problem.
We hold community meetings every 8 weeks. The next will take place on Wednesday 2 September, 16:00-17:00 CEST

Thanks! I'd really like to do that, sounds like a nice opportunity. Unfortunately I can't make 2 September. Could I join one of the later meetings instead?

Great! Joining a later meeting is possible of course. I'll send you an email to discuss further.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants