Skip to content

perf: use numpy array lookup for solution unpacking - #619

Merged
FabianHofmann merged 6 commits into
PyPSA:masterfrom
MaykThewessen:perf/vectorize-solution-unpacking
Mar 30, 2026
Merged

perf: use numpy array lookup for solution unpacking#619
FabianHofmann merged 6 commits into
PyPSA:masterfrom
MaykThewessen:perf/vectorize-solution-unpacking

Conversation

@MaykThewessen

Copy link
Copy Markdown
Contributor

Summary

Replace pandas Series indexing with numpy array lookup in the solution unpacking loop (Model.solve(), lines 1570-1594).

Before:

sol=set_int_index(sol)
sol.loc[-1] =nanforname, varinself.variables.items():
idx=np.ravel(var.labels)
vals=sol[idx].values.reshape(var.labels.shape) # pandas indexing per variablevar.solution=xr.DataArray(vals, var.coords)

After:

sol=set_int_index(sol)
# Build dense numpy lookup array oncesol_arr=np.full(sol_max_idx+1, nan)
sol_arr[sol.index[sol.index>=0]] =sol.values[sol.index>=0]
forname, varinself.variables.items():
idx=np.ravel(var.labels)
vals=sol_arr[np.clip(idx, 0, sol_max_idx)] # numpy indexingvals[idx<0] =nanvar.solution=xr.DataArray(vals.reshape(var.labels.shape), var.coords)

Same pattern applied to the dual values unpacking loop.

Motivation

After HiGHS solves, the solution is a pandas Series with integer labels. The unpacking loop accesses this Series once per variable type (~20 types in a typical PyPSA model). Each sol[idx].values call involves pandas' __getitem__ with index alignment overhead. Converting to a numpy array first and using direct array indexing eliminates this overhead.

Context

See #198 (comment) — item 5 in the priority list.

Test plan

  • test_optimization.py highs-direct — 24/25 pass (one pre-existing failure in test_modified_model)
  • Solution values verified correct via test_default_setting_sol_and_dual_accessor
  • Expression solution accessor verified via test_default_setting_expression_sol_accessor
  • Duplicated variables test passes

🤖 Generated with Claude Code

Convert the primal/dual pandas Series to a dense numpy lookup array
before the per-variable/per-constraint unpacking loop. This replaces
pandas indexing (sol[idx].values) with direct numpy array indexing
(sol_arr[idx]), avoiding pandas overhead per variable type.
The loop over variable/constraint types still exists (needed to set
each variable's .solution xr.DataArray), but the inner indexing
operation is now pure numpy instead of pandas Series.__getitem__.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@FBumannFBumann added the performance This improves performance while not (meaningfully) altering behaviour for users label Mar 17, 2026
MaykThewessen added a commit to MaykThewessen/linopy that referenced this pull request Mar 17, 2026
Covers the code paths optimised by these PRs:
- PyPSA#616 cached_property on MatrixAccessor (flat_vars / flat_cons)
- PyPSA#617 np.char.add for label string concatenation
- PyPSA#618 sparse matrix slicing in MatrixAccessor.A
- PyPSA#619 numpy solution unpacking
Reproduces benchmark results on PyPSA SciGrid-DE (24–500 snapshots)
and a synthetic model. Supports JSON output and --compare mode for
cross-branch comparison.
Reproduce with:
python benchmark/scripts/benchmark_matrix_gen.py -o results.json --label "after"
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
MaykThewessen added a commit to MaykThewessen/linopy that referenced this pull request Mar 17, 2026
Reproduces the performance claims for PRs PyPSA#616PyPSA#619 on
PyPSA SciGrid-DE and a synthetic model.
python benchmark/scripts/benchmark_matrix_gen.py -o results.json
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
MaykThewessen added a commit to MaykThewessen/linopy that referenced this pull request Mar 17, 2026
Reproduces the performance claims for PRs PyPSA#616PyPSA#619 on
PyPSA SciGrid-DE and a synthetic model.
python benchmark/scripts/benchmark_matrix_gen.py -o results.json
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
MaykThewessen added a commit to MaykThewessen/linopy that referenced this pull request Mar 17, 2026
Reproduces the performance claims for PRs PyPSA#616PyPSA#619 on
PyPSA SciGrid-DE and a synthetic model.
python benchmark/scripts/benchmark_matrix_gen.py -o results.json
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@MaykThewessen

Copy link
Copy Markdown
ContributorAuthor

Added benchmark/scripts/benchmark_matrix_gen.py to this branch (and #616, #617, #618) as requested by @FBumann.

Reproduce with:

python benchmark/scripts/benchmark_matrix_gen.py -o results.json --label "with-PR-619"
python benchmark/scripts/benchmark_matrix_gen.py --compare before.json after.json

Note: this PR's change (numpy solution unpacking in Model.solve) is on the post-solve path and is therefore not captured by the matrix generation benchmark above. The relevant timing is measured via a full solve cycle — I can add a --mode solve flag to the script that runs an end-to-end n.optimize() call if that would be useful for reproducibility here.

MaykThewessen added a commit to MaykThewessen/linopy that referenced this pull request Mar 17, 2026
@MaykThewessen
MaykThewessenforce-pushed the perf/vectorize-solution-unpacking branch from 8e05efd to de03d24CompareMarch 17, 2026 20:38
MaykThewessen added a commit to MaykThewessen/linopy that referenced this pull request Mar 17, 2026
MaykThewessen added a commit to MaykThewessen/linopy that referenced this pull request Mar 17, 2026
MaykThewessen added a commit to MaykThewessen/linopy that referenced this pull request Mar 17, 2026
@MaykThewessen

Copy link
Copy Markdown
ContributorAuthor

Updated the benchmark script to include a --include-solve flag that directly benchmarks the solution-assignment loop from this PR — comparing the old pandas label-based path vs the new numpy dense-array path, without re-solving the LP each time.

Reproduce:

python benchmark/scripts/benchmark_matrix_gen.py --include-solve -o results.json

Results on SciGrid-DE, 24 snapshots (59,640 variables), Python 3.14.3, macOS ARM64:

PathTime
pandas label lookup (before)0.0155s
numpy dense array (after)0.0065s
Speedup2.4×

The benchmark reconstructs the raw sol Series from stored variable solutions and replays both assignment approaches repeatedly without re-running the solver, so the measurement isolates only the unpacking cost.

MaykThewessen added a commit to MaykThewessen/linopy that referenced this pull request Mar 17, 2026
…A#619)
Add a new --mode solve option that measures the solve + solution-unpacking
path that PR PyPSA#619 optimises.
Three metrics are reported:
solve_total - full model.solve() wall time
solver_kernel - time inside HiGHS (intercepted via monkey-patching
_run_highs_with_keyboard_interrupt), i.e. the irreducible
numerical cost
unpack_only - solve_total minus solver_kernel; this is exactly the
Python-side overhead that PR PyPSA#619 reduces by replacing
the per-label xarray assignment loop with a vectorised
numpy gather
A new build_solve_synthetic() helper creates a pure-linopy LP that mirrors
a PyPSA LOPF model (two 2-D variable arrays, one balance constraint per
snapshot) without requiring PyPSA to be installed.
Usage:
python benchmark/scripts/benchmark_matrix_gen.py --mode solve
python benchmark/scripts/benchmark_matrix_gen.py --mode solve --quick
python benchmark/scripts/benchmark_matrix_gen.py --mode solve -o after.json
Adds benchmark/scripts/benchmark_matrix_gen.py covering all four
performance code paths:
- PyPSA#616 cached_property on MatrixAccessor (flat_vars / flat_cons)
- PyPSA#617 np.char.add label string concatenation
- PyPSA#618 single-step sparse matrix slicing
- PyPSA#619 numpy dense-array solution unpacking
Reproduce with:
python benchmark/scripts/benchmark_matrix_gen.py -o results.json
python benchmark/scripts/benchmark_matrix_gen.py --include-solve # PR PyPSA#619 path
python benchmark/scripts/benchmark_matrix_gen.py --compare before.json after.json
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
MaykThewessen added a commit to MaykThewessen/linopy that referenced this pull request Mar 17, 2026
Adds benchmark/scripts/benchmark_matrix_gen.py covering all four
performance code paths:
- PyPSA#616 cached_property on MatrixAccessor (flat_vars / flat_cons)
- PyPSA#617 np.char.add label string concatenation
- PyPSA#618 single-step sparse matrix slicing
- PyPSA#619 numpy dense-array solution unpacking
Reproduce with:
python benchmark/scripts/benchmark_matrix_gen.py -o results.json
python benchmark/scripts/benchmark_matrix_gen.py --include-solve # PR PyPSA#619
python benchmark/scripts/benchmark_matrix_gen.py --compare before.json after.json
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
MaykThewessen added a commit to MaykThewessen/linopy that referenced this pull request Mar 17, 2026
Adds benchmark/scripts/benchmark_matrix_gen.py covering all four
performance code paths:
- PyPSA#616 cached_property on MatrixAccessor (flat_vars / flat_cons)
- PyPSA#617 np.char.add label string concatenation
- PyPSA#618 single-step sparse matrix slicing
- PyPSA#619 numpy dense-array solution unpacking
Reproduce with:
python benchmark/scripts/benchmark_matrix_gen.py -o results.json
python benchmark/scripts/benchmark_matrix_gen.py --include-solve # PR PyPSA#619
python benchmark/scripts/benchmark_matrix_gen.py --compare before.json after.json
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@MaykThewessen
MaykThewessenforce-pushed the perf/vectorize-solution-unpacking branch from 5b36589 to 0e9abcfCompareMarch 17, 2026 20:57
MaykThewessen added a commit to MaykThewessen/linopy that referenced this pull request Mar 17, 2026
Adds benchmark/scripts/benchmark_matrix_gen.py covering all four
performance code paths:
- PyPSA#616 cached_property on MatrixAccessor (flat_vars / flat_cons)
- PyPSA#617 np.char.add label string concatenation
- PyPSA#618 single-step sparse matrix slicing
- PyPSA#619 numpy dense-array solution unpacking
Reproduce with:
python benchmark/scripts/benchmark_matrix_gen.py -o results.json
python benchmark/scripts/benchmark_matrix_gen.py --include-solve # PR PyPSA#619
python benchmark/scripts/benchmark_matrix_gen.py --compare before.json after.json
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
MaykThewessen added a commit to MaykThewessen/linopy that referenced this pull request Mar 17, 2026
Adds benchmark/scripts/benchmark_matrix_gen.py covering all four
performance code paths:
- PyPSA#616 cached_property on MatrixAccessor (flat_vars / flat_cons)
- PyPSA#617 np.char.add label string concatenation
- PyPSA#618 single-step sparse matrix slicing
- PyPSA#619 numpy dense-array solution unpacking
Reproduce with:
python benchmark/scripts/benchmark_matrix_gen.py -o results.json
python benchmark/scripts/benchmark_matrix_gen.py --include-solve # PR PyPSA#619
python benchmark/scripts/benchmark_matrix_gen.py --compare before.json after.json
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@MaykThewessen
MaykThewessenforce-pushed the perf/vectorize-solution-unpacking branch from 9d7706d to 0e9abcfCompareMarch 17, 2026 21:13
MaykThewessen added a commit to MaykThewessen/linopy that referenced this pull request Mar 17, 2026
Adds benchmark/scripts/benchmark_matrix_gen.py covering all four
performance code paths:
- PyPSA#616 cached_property on MatrixAccessor (flat_vars / flat_cons)
- PyPSA#617 np.char.add label string concatenation
- PyPSA#618 single-step sparse matrix slicing
- PyPSA#619 numpy dense-array solution unpacking
Reproduce with:
python benchmark/scripts/benchmark_matrix_gen.py -o results.json
python benchmark/scripts/benchmark_matrix_gen.py --include-solve # PR PyPSA#619
python benchmark/scripts/benchmark_matrix_gen.py --compare before.json after.json
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@FBumann

Copy link
Copy Markdown
Collaborator

Modest speedup, but lgtm

@MaykThewessen

Copy link
Copy Markdown
ContributorAuthor

Benchmark Results: master vs PR #619

Tested on actual linopy implementation using PyPSA SciGrid-DE. Includes end-to-end model.solve() with HiGHS to measure the real solution unpacking path.

Setup: Python 3.14.3, numpy 2.4.3, Apple M-series (arm64), macOS, 5 repeats (best-of).

End-to-End Solve (HiGHS direct)

SnapshotsPhasemaster (s)PR-619 (s)Speedup
24model.solve() end-to-end4.04734.37970.92x
24re-solve (warm model)3.35173.46820.97x
100model.solve() end-to-end15.567416.06340.97x
100re-solve (warm model)14.395614.22391.01x

Matrix Generation (for reference — PR #619 doesn't change these paths)

SnapshotsPhasemaster (s)PR-619 (s)Speedup
24full_matrix_pipeline0.34940.29791.17x
100full_matrix_pipeline2.01551.17051.72x
200full_matrix_pipeline3.20362.33721.37x
500full_matrix_pipeline11.672211.60131.01x

Summary: The solution unpacking speedup from pandas→numpy is real but modest — it's dominated by solver time in end-to-end measurements. The matrix generation variance across runs (this PR doesn't change those paths) reflects run-to-run noise from sequential benchmarking on a non-isolated machine.

As you noted, "modest speedup but lgtm" — the benefit is more pronounced on larger models where the solution vector is bigger and pandas reindex overhead grows.

Benchmark methodology
  • model.solve() calls the real linopy solve path with HiGHS direct API
  • "re-solve" times a second solve on the same model (LP already built, exercises solution assignment path)
  • Benchmarks ran sequentially (master→616→618→619), so later runs may show thermal effects
  • 5 repeats per measurement, best-of-5 reported
  • GC disabled during timing, collected between repeats
  • Benchmark script: benchmark/scripts/benchmark_actual.py

@FBumann

Copy link
Copy Markdown
Collaborator

Your PR is getting hard to review, as your benchmark results are not focused on what the PR contains.
The benchmark comment mixes in matrix generation results that this PR doesn't touch — that makes it hard to see the actual impact. Could you trim it to just the isolated solution-unpacking benchmark (the pandas vs numpy comparison)? That's the one that directly measures what changed here.

The end-to-end solve numbers are dominated by solver time and show no clear signal either way, so they're more noise than information for this PR.

@MaykThewessen

Copy link
Copy Markdown
ContributorAuthor

Fair point — the matrix generation table and end-to-end solve numbers are noise for this PR. The relevant benchmark is in my second comment: isolated solution-unpacking shows 2.4× speedup (pandas label lookup 0.0155s → numpy dense array 0.0065s) on SciGrid-DE 24 snapshots.

… (2-6x faster)
Extract series_to_lookup_array/lookup_vals helpers to linopy/common.py.
Fix critical bug where out-of-range labels silently mapped to wrong values.
@FabianHofmann

Copy link
Copy Markdown
Collaborator

Great! I made some benchmarks and this looks good! it is 2–6 faster consistently across all sizes, scaling pretty well. because of the one-time array build, while the old method paid pandas overhead per variable.

@FabianHofmann
FabianHofmann merged commit 472ecc9 into PyPSA:masterMar 30, 2026
21 checks passed
@MaykThewessen

Copy link
Copy Markdown
ContributorAuthor

Nice!

FBumann added a commit that referenced this pull request May 7, 2026
* docs: restructure upcoming release notes and fold in missing PRs
Group the upcoming version block into Features / Performance / Bug Fixes
/ Breaking Changes / Documentation sections so the headline (piecewise)
leads, and add the entries for #589, #595, #601, #614, #619, #635, #656,
#671, #672, #674. Tighten the piecewise block to its final state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: tighten upcoming changelog and drop internal-only entries
Trim verbose phrasing in the piecewise / variables / model / solvers
sections, fold subset-superset sub-bullets into one paragraph, and drop
two entries that aren't user-facing for a release notes audience:
sphinx-copybutton (doc tooling) and Model.__weakref__ (only relevant to
extension authors).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: move align convention from breakpoints() to Slopes in changelog
#673 removed the slopes-mode (and slopes_align kwarg) from breakpoints();
the align kwarg now lives on the Slopes class.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: move SOS reformulation bullet from Variables to Model
SOS reformulation is a model-rewrite/solve-pipeline concern, not a
variable attribute.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: split coord alignment into Expressions, move CPLEX to Bug Fixes
- New *Expressions* subsection holds the subset/superset coord
harmonization, which was misfiled under *Model*.
- CPLEX quality-attribute handling is a fix for crashes on missing
attributes, not a new feature — moved to **Bug Fixes**.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: fold as_dataarray MultiIndex fix into add_variables bullet
#659 fixes a regression introduced by #614 in the same release cycle —
no end user ever saw the broken state, so a standalone bullet
overstates the change. Net behavior is captured by extending the
add_variables bullet to mention MultiIndex coords.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: tighter pass on upcoming changelog
Drop implementation details that belong in API docs (numpy-vs-pandas
note, JSON encoding for netCDF, "with no auxiliary variables" piecewise
detail), merge the two OETC bullets, and trim "Add X. Supports Y."
wrappers across most lines.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: rephrase active gating bullet to avoid output-zeroing implication
Previous wording ("zeros all auxiliaries when off") was true at the
auxiliary level but glossed over the bounded-tuple case where the
output is not automatically pinned to 0. Drop the implication and
defer the detail to the docstring.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: drop option-name detail from upcoming changelog
Trim references to specific kwargs/attributes the reader doesn't need
in the high-level summary: method="auto" parens, align="pieces|leading",
deep / include_solution, reformulate_sos="auto", solver_name /
**solver_options, max_dual_infeasibility example, and the
operator-by-operator coord-alignment breakdown.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

performanceThis improves performance while not (meaningfully) altering behaviour for users

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@MaykThewessen@FBumann@FabianHofmann