Skip to content

perf: cache MatrixAccessor properties to avoid redundant recomputation - #616

Closed
MaykThewessen wants to merge 3 commits into
PyPSA:masterfrom
MaykThewessen:perf/cache-matrix-accessor-properties
Closed

perf: cache MatrixAccessor properties to avoid redundant recomputation#616
MaykThewessen wants to merge 3 commits into
PyPSA:masterfrom
MaykThewessen:perf/cache-matrix-accessor-properties

Conversation

@MaykThewessen

Copy link
Copy Markdown
Contributor

Summary

  • Change vlabels, clabels, A, c, b, sense, lb, ub, vtypes, and Q from @property to @cached_property in MatrixAccessor
  • Add all new cached properties to clean_cached_properties() for proper invalidation
  • Add test verifying caching behavior and cache invalidation

Motivation

During to_highspy() (and other direct API exporters), MatrixAccessor properties are accessed multiple times — both directly and indirectly. For example, M.A internally accesses M.clabels and M.vlabels, and M.c accesses M.flat_vars. Each access recomputes from scratch, including rebuilding sparse matrices and flattening DataFrames.

For large models (~593K variables, ~1.38M constraints), this redundant recomputation adds measurable overhead per solve call. Caching eliminates this since the underlying model data doesn't change between property accesses within a single solve.

The cache is already properly invalidated at the start of Model.solve() and Model._mock_solve() via existing clean_cached_properties() calls.

Context

See discussion in #198 (comment) for profiling data from a real-world 52-chunk DC-OPF optimization.

Test plan

  • Existing test_matrices.py tests pass (verify shapes, values, masked models)
  • New test_matrices_properties_are_cached verifies identity caching and invalidation
  • test_optimization.py highs-direct tests pass (24/25 — one pre-existing failure in test_modified_model)
  • test_io.py tests pass

Note: test_modified_model fails on upstream master as well — it's a pre-existing issue unrelated to this change.

🤖 Generated with Claude Code

@FBumannFBumann mentioned this pull request Mar 14, 2026
3 tasks
@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 #617, #618, #619) as requested by @FBumann.

Reproduce with:

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

The benchmark times flat_vars, flat_cons, vlabels+clabels, A_matrix, and the full get_matrix_data pipeline on PyPSA SciGrid-DE (24–500 snapshots) and a synthetic model. This PR's caching benefit is most visible when the pipeline is called multiple times per solve (3–4× in the HiGHS direct path), which the script's full_pipeline phase reflects via clean_cached_properties() + re-run.

@MaykThewessen
MaykThewessenforce-pushed the perf/cache-matrix-accessor-properties branch from 3373a1b to bc3b49eCompareMarch 17, 2026 20:38
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/cache-matrix-accessor-properties branch from f499edd to e639bb6CompareMarch 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 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>
MaykThewessenand others added 2 commits March 17, 2026 22:13
Converts vlabels, vtypes, lb, ub, clabels, A, sense, b, c, Q from
@Property to @cached_property, and extends clean_cached_properties()
to clear all of them. Avoids recomputing expensive matrix operations
(e.g. flat DataFrame flattening, sparse matrix slicing) on each access.
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/cache-matrix-accessor-properties branch from 9f21863 to 4985f9aCompareMarch 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

@MaykThewessen Can you provide the results you got with the benchmarks? Compare master vs feature branch.
Its best to see your results (summary table), and then see if we can replicate them if needed. This also serves as permanent documentation on the PR. Also applies to #617#618#619
Also in general: The benchmark should test the actual implementation, not a mock up inside the benchmark file if possible. This ensures that the benchmark captures side effects.

@FBumann

Copy link
Copy Markdown
Collaborator

@MaykThewessen THis PR in general looks good. We need to take care with cache cleanup though. Ill look into it.

@FBumann

Copy link
Copy Markdown
Collaborator

If you want you can share your thoughts on #622, which will hopefully cover your workflow even better.

@MaykThewessen

Copy link
Copy Markdown
ContributorAuthor

Benchmark Results: master vs PR #616

Tested on actual linopy implementation (not mocked) using PyPSA SciGrid-DE at multiple problem sizes.
Each phase is called directly on model.matrices — the real code path used by solvers.

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

ModelSnapshotsPhasemaster (s)PR-616 (s)Speedup
scigrid24flat_vars0.00550.00511.08x
scigrid24flat_cons0.15100.13591.11x
scigrid24vlabels+clabels0.16060.13781.17x
scigrid24A_matrix0.16490.13631.21x
scigrid24full_matrix_pipeline0.34940.29731.18x
scigrid100flat_vars0.01410.01231.14x
scigrid100flat_cons0.73050.52681.39x
scigrid100vlabels+clabels0.66930.58781.14x
scigrid100A_matrix0.74670.62731.19x
scigrid100full_matrix_pipeline2.01551.25501.61x
scigrid200flat_vars0.02310.01921.20x
scigrid200flat_cons2.23601.32431.69x
scigrid200vlabels+clabels2.17351.23561.76x
scigrid200A_matrix1.70331.19361.43x
scigrid200full_matrix_pipeline3.20362.57091.25x
scigrid500flat_vars0.04490.04461.01x
scigrid500flat_cons5.98895.09791.17x
scigrid500vlabels+clabels5.61565.12361.10x
scigrid500A_matrix5.39825.31021.02x
scigrid500full_matrix_pipeline11.672210.55741.11x

Summary: Consistent 1.1–1.8x speedup across matrix generation phases, with larger improvements at medium problem sizes (100–200 snapshots). The caching benefit is most visible on flat_cons and vlabels+clabels where redundant recomputation is eliminated.

Benchmark methodology
  • Each phase calls the actual model.matrices property (e.g., matrices.flat_vars, matrices.A)
  • Cache is cleared with matrices.clean_cached_properties() before each measurement
  • full_matrix_pipeline accesses all properties solvers use: vlabels, clabels, lb, ub, A, b, c, sense
  • 5 repeats per measurement, best-of-5 reported
  • GC disabled during timing, collected between repeats
  • Benchmark script: benchmark/scripts/benchmark_actual.py

@MaykThewessen

Copy link
Copy Markdown
ContributorAuthor

@FBumann Thanks for reviewing! Happy to help with the cache cleanup — let me know if you'd like me to look at specific scenarios where the cache needs invalidation, or if you'd prefer to handle it yourself.

@FBumann

Copy link
Copy Markdown
Collaborator

@MaykThewessen Thinking about those scenarios is the actual work. So if you can, a list of such would help. The actual implementation is easy then.

@FBumann

Copy link
Copy Markdown
Collaborator

@MaykThewessen As is said in #619, your Benchmark does not really isolate the Content if this PR. And its not properly measuring the caching improvement, as you call clear_cached_properties in places where you should not...

I can merge this anyway as im convinced that its an improvement, but please take your time and check that your benchmark actually measures what its supposed to when publishing results next time.

@MaykThewessen

Copy link
Copy Markdown
ContributorAuthor

You're right about the benchmark — calling clean_cached_properties() before each measurement measures the uncached path every time, defeating the purpose of testing caching. Will be more careful with methodology next time.

On cache invalidation scenarios:

MutationProperties to invalidate
model.add_variables() / remove_variables()flat_vars, vlabels, lb, ub, vtypes, c, A, Q
model.add_constraints() / remove_constraints()flat_cons, clabels, A, b, sense
Variable bounds modified (var.lower = ..., var.upper = ...)lb, ub
Constraint RHS modified (con.rhs = ...)b
Constraint sign modifiedsense
Objective coefficients modified (model.add_objective())c, Q
Constraint coefficients modified (if supported in-place)A

The first two (add/remove variables/constraints) are the critical ones since they change the shape of the matrix. Bounds/RHS/objective modifications only change values within an existing structure.

One approach: blanket clean_cached_properties() on any structural mutation (add/remove), and targeted per-property invalidation for value-only changes. Or just blanket-invalidate everything on any mutation — simpler and safe given these properties are cheap relative to solver time.

@FabianHofmannFabianHofmann left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thank you for your pull request. while the cached_property is a appealing approach, it comes with the risk that when modifying the model these modifications do not make it in the downstream objects which is why I decided against caching in the first place when writing the matrix accessor. Hence, I am closing this. The good news is that there is promising performace improvements in that regard coming in #630

"""Return a PyPSA Network (SciGrid-DE) with extended snapshots, without building the model."""
import pypsa

n = pypsa.examples.scigrid_de()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

for such secondary deps it is a good practice to add a installation option to pyproject.toml to be able to run this out of the box.

FabianHofmann added a commit that referenced this pull request Mar 30, 2026
* perf: use numpy array lookup for solution unpacking
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>
* Add reproducible benchmark script for PRs #616#619
Adds benchmark/scripts/benchmark_matrix_gen.py covering all four
performance code paths:
- #616 cached_property on MatrixAccessor (flat_vars / flat_cons)
- #617 np.char.add label string concatenation
- #618 single-step sparse matrix slicing
- #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 #619 path
python benchmark/scripts/benchmark_matrix_gen.py --compare before.json after.json
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Delete benchmark/scripts/benchmark_matrix_gen.py
* Replace pandas-based solution unpacking with numpy dense array lookup (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.
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: FBumann <117816358+FBumann@users.noreply.github.com>
Co-authored-by: Fabian Hofmann <fab.hof@gmx.de>
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