Skip to content

perf(expressions): sparse @/dot drops zero-coeff terms; vectorise densify_terms (#748) - #939

Merged
FabianHofmann merged 2 commits into
masterfrom
fix/748-sparse-matmul-densify
Sep 7, 2026
Merged

perf(expressions): sparse @/dot drops zero-coeff terms; vectorise densify_terms (#748)#939
FabianHofmann merged 2 commits into
masterfrom
fix/748-sparse-matmul-densify

Conversation

@FabianHofmann

Copy link
Copy Markdown
Collaborator

Closes #748.

The sparse @/dot path was blowing up the term dimension on PyPSA's KVL constraint, and densify_terms itself was quadratic. This fixes both.

Note

The following content was generated by AI.

Changes proposed in this Pull Request

Sparse @/dot no longer densifies (#748). LinearExpression.__matmul__ computed (self * other).sum(dim=common_dims), which stacks every contracted member into the term dimension, even where the constant matrix is zero. Against a sparse constant (mostly zeros) this produced one term per member. The matmul now compacts the zero-coefficient terms via densify_terms whenever the constant operand contains zeros, under both legacy and v1 semantics. A zero-free constant is untouched, so dense matmuls carry no overhead.

  • On PyPSA's Kirchhoff Voltage Law constraint (cycle matrix, ~3 branches per cycle) the expression drops from 852 terms to 3 — 284x fewer cells — which shrinks the downstream merge.

densify_terms rewritten. The old implementation counted non-zero positions with a Python loop that scaled quadratically in the number of non-zero terms, and allocated the compacted output at the full original term width. It is now fully vectorised, allocates only the compacted width, and returns the expression unchanged when it holds no zeros.

  • ~127 s → ~3 ms for a (2000 x 60) expression.
  • Fixes a latent IndexError on expressions without coordinate dimensions (expr.sum(drop_zeros=True) over all dimensions), which the new sparse @ path made reachable via an all-zero constant.
  • Now also correct on QuadraticExpression, where it previously indexed the _factor axis as the term axis.

Tests. New cases in test/test_linear_expression.py cover: sparse operand compacts, dense operand untouched, all-zero operand yields the zero expression, in-order row compaction, no-op when zero-free, scalar/all-zero drop_zeros, and the quadratic path.

Checklist

  • AI-generated content is marked (see AGENTS.md).
  • Code changes are sufficiently documented; i.e. new functions contain docstrings and further explanations may be given in doc.
  • Unit tests for new features were added (if applicable).
  • A note for the release notes doc/release_notes.rst of the upcoming release is included.
  • I consent to the release of this PR's code under the MIT license.

@codspeed-hq

codspeed-hq Bot commented Sep 7, 2026

Copy link
Copy Markdown

Merging this PR will regress 1 benchmark

⚡ 1 improved benchmark
❌ 1 regressed benchmark
✅ 179 untouched benchmarks
⏩ 181 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
test_to_lp[qp-n=1000] 2 MB 2.6 MB -23.8%
test_to_lp[sparse_network-n=10] 1,343.2 KB 719.7 KB +86.63%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing fix/748-sparse-matmul-densify (da73f9b) with master (da76f30)

Open in CodSpeed

Footnotes

  1. 181 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown

Build cost — v1 vs legacy

v1 build peak & time relative to legacy, on this commit — not a comparison against master (that is CodSpeed).

peak — v1 / legacy time — v1 / legacy
peak v1/legacy time v1/legacy
Full table (time + peak, mean)
benchmarks/drivers/test_build.py::test_build[basic-n=10]
                  time (s)         peak (KiB) 
 name                 mean   │           mean 
──────────────────────────────────────────────
 (legacy)   0.08828 (1.08)   │   15.03 (1.00) 
 (v1)        0.08194 (1.0)   │    15.00 (1.0) 

benchmarks/drivers/test_build.py::test_build[basic-n=250]
                  time (s)         peak (MiB) 
 name                 mean   │           mean 
──────────────────────────────────────────────
 (legacy)   0.09625 (1.09)   │   12.04 (1.00) 
 (v1)        0.08821 (1.0)   │    12.04 (1.0) 

benchmarks/drivers/test_build.py::test_build[cumsum-severity=0]
                  time (s)        peak (KiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.03861 (1.08)   │   15.20 (1.0) 
 (v1)         0.0356 (1.0)   │   15.20 (1.0) 

benchmarks/drivers/test_build.py::test_build[cumsum-severity=100]
                  time (s)        peak (MiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.05614 (1.12)   │   44.93 (1.0) 
 (v1)        0.05019 (1.0)   │   44.93 (1.0) 

benchmarks/drivers/test_build.py::test_build[cumsum-severity=50]
                  time (s)        peak (MiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.04227 (1.09)   │   11.51 (1.0) 
 (v1)        0.03896 (1.0)   │   11.51 (1.0) 

benchmarks/drivers/test_build.py::test_build[expression_arithmetic-n=10]
                  time (s)         peak (KiB) 
 name                 mean   │           mean 
──────────────────────────────────────────────
 (legacy)   0.09953 (1.06)   │   24.34 (1.06) 
 (v1)        0.09394 (1.0)   │    23.04 (1.0) 

benchmarks/drivers/test_build.py::test_build[expression_arithmetic-n=250]
                 time (s)         peak (MiB) 
 name                mean   │           mean 
─────────────────────────────────────────────
 (legacy)   0.1101 (1.07)   │   16.12 (1.00) 
 (v1)        0.1032 (1.0)   │    16.12 (1.0) 

benchmarks/drivers/test_build.py::test_build[knapsack-n=10000]
                  time (s)          peak (KiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.02393 (1.07)   │   752.18 (1.10) 
 (v1)        0.02226 (1.0)   │    685.15 (1.0) 

benchmarks/drivers/test_build.py::test_build[knapsack-n=100]
                  time (s)        peak (KiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.02353 (1.07)   │   3.12 (1.33) 
 (v1)        0.02192 (1.0)   │    2.34 (1.0) 

benchmarks/drivers/test_build.py::test_build[kvl_cycles-severity=0]
                  time (s)          peak (MiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.06505 (1.20)   │   126.16 (1.44) 
 (v1)        0.05407 (1.0)   │     87.71 (1.0) 

benchmarks/drivers/test_build.py::test_build[kvl_cycles-severity=100]
                 time (s)          peak (MiB) 
 name                mean   │            mean 
──────────────────────────────────────────────
 (legacy)   0.0641 (1.22)   │   126.16 (1.44) 
 (v1)       0.05258 (1.0)   │     87.71 (1.0) 

benchmarks/drivers/test_build.py::test_build[kvl_cycles-severity=50]
                  time (s)          peak (MiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.06426 (1.20)   │   126.16 (1.44) 
 (v1)        0.05351 (1.0)   │     87.71 (1.0) 

benchmarks/drivers/test_build.py::test_build[masked-n=100]
                  time (s)          peak (KiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.05589 (1.03)   │    715.12 (1.0) 
 (v1)        0.05436 (1.0)   │   787.73 (1.10) 

benchmarks/drivers/test_build.py::test_build[masked-n=10]
                  time (s)        peak (KiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.05385 (1.10)   │   4.54 (1.27) 
 (v1)        0.04882 (1.0)   │    3.57 (1.0) 

benchmarks/drivers/test_build.py::test_build[merge_balance-severity=0]
                 time (s)          peak (KiB) 
 name                mean   │            mean 
──────────────────────────────────────────────
 (legacy)   0.3759 (1.05)   │   704.12 (1.09) 
 (v1)        0.3575 (1.0)   │    643.85 (1.0) 

benchmarks/drivers/test_build.py::test_build[merge_balance-severity=100]
                 time (s)        peak (MiB) 
 name                mean   │          mean 
────────────────────────────────────────────
 (legacy)   0.3985 (1.03)   │   18.34 (1.0) 
 (v1)        0.3852 (1.0)   │   18.34 (1.0) 

benchmarks/drivers/test_build.py::test_build[merge_balance-severity=50]
                 time (s)       peak (MiB) 
 name                mean   │         mean 
───────────────────────────────────────────
 (legacy)   0.3908 (1.04)   │   9.54 (1.0) 
 (v1)        0.3742 (1.0)   │   9.54 (1.0) 

benchmarks/drivers/test_build.py::test_build[milp-n=10]
                  time (s)        peak (KiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.07733 (1.11)   │   3.77 (1.12) 
 (v1)        0.06952 (1.0)   │    3.37 (1.0) 

benchmarks/drivers/test_build.py::test_build[milp-n=50]
                  time (s)          peak (KiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.07628 (1.09)   │   216.59 (1.10) 
 (v1)        0.06998 (1.0)   │    196.23 (1.0) 

benchmarks/drivers/test_build.py::test_build[nodal_balance-severity=0]
                  time (s)         peak (KiB) 
 name                 mean   │           mean 
──────────────────────────────────────────────
 (legacy)   0.03865 (1.07)   │   938.49 (1.0) 
 (v1)        0.03607 (1.0)   │   938.49 (1.0) 

benchmarks/drivers/test_build.py::test_build[nodal_balance-severity=100]
                  time (s)       peak (MiB) 
 name                 mean   │         mean 
────────────────────────────────────────────
 (legacy)   0.03999 (1.08)   │   9.66 (1.0) 
 (v1)        0.03704 (1.0)   │   9.66 (1.0) 

benchmarks/drivers/test_build.py::test_build[nodal_balance-severity=50]
                  time (s)       peak (MiB) 
 name                 mean   │         mean 
────────────────────────────────────────────
 (legacy)   0.03949 (1.09)   │   5.32 (1.0) 
 (v1)        0.03637 (1.0)   │   5.32 (1.0) 

benchmarks/drivers/test_build.py::test_build[nodal_balance_sparse-severity=0]
                  time (s)       peak (MiB) 
 name                 mean   │         mean 
────────────────────────────────────────────
 (legacy)    0.02289 (1.0)   │   1.46 (1.0) 
 (v1)       0.02319 (1.01)   │   1.46 (1.0) 

benchmarks/drivers/test_build.py::test_build[nodal_balance_sparse-severity=100]
                  time (s)       peak (MiB) 
 name                 mean   │         mean 
────────────────────────────────────────────
 (legacy)    0.02281 (1.0)   │   1.46 (1.0) 
 (v1)       0.02338 (1.02)   │   1.46 (1.0) 

benchmarks/drivers/test_build.py::test_build[nodal_balance_sparse-severity=50]
                  time (s)       peak (MiB) 
 name                 mean   │         mean 
────────────────────────────────────────────
 (legacy)    0.02296 (1.0)   │   1.46 (1.0) 
 (v1)       0.02315 (1.01)   │   1.46 (1.0) 

benchmarks/drivers/test_build.py::test_build[piecewise-n=1000]
                 time (s)          peak (KiB) 
 name                mean   │            mean 
──────────────────────────────────────────────
 (legacy)   0.1919 (1.06)   │   946.85 (1.06) 
 (v1)        0.1814 (1.0)   │    891.54 (1.0) 

benchmarks/drivers/test_build.py::test_build[piecewise-n=10]
                time (s)         peak (KiB) 
 name               mean   │           mean 
────────────────────────────────────────────
 (legacy)   0.186 (1.05)   │   12.01 (1.00) 
 (v1)        0.177 (1.0)   │    11.99 (1.0) 

benchmarks/drivers/test_build.py::test_build[qp-n=1000]
                 time (s)          peak (KiB) 
 name                mean   │            mean 
──────────────────────────────────────────────
 (legacy)   0.0478 (1.07)   │   147.70 (1.06) 
 (v1)       0.04487 (1.0)   │    139.87 (1.0) 

benchmarks/drivers/test_build.py::test_build[qp-n=10]
                  time (s)        peak (KiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.04735 (1.07)   │   2.60 (1.09) 
 (v1)        0.04442 (1.0)   │    2.38 (1.0) 

benchmarks/drivers/test_build.py::test_build[rolling-severity=0]
                  time (s)          peak (KiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.03928 (1.05)   │   696.75 (1.03) 
 (v1)        0.03732 (1.0)   │    673.70 (1.0) 

benchmarks/drivers/test_build.py::test_build[rolling-severity=100]
                  time (s)         peak (MiB) 
 name                 mean   │           mean 
──────────────────────────────────────────────
 (legacy)    0.08491 (1.0)   │   137.97 (1.0) 
 (v1)       0.08519 (1.00)   │   137.97 (1.0) 

benchmarks/drivers/test_build.py::test_build[rolling-severity=50]
                  time (s)        peak (MiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.06125 (1.06)   │   69.22 (1.0) 
 (v1)        0.05788 (1.0)   │   69.22 (1.0) 

benchmarks/drivers/test_build.py::test_build[sos-n=1000]
                  time (s)          peak (KiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.04565 (1.11)   │   402.33 (1.00) 
 (v1)        0.04122 (1.0)   │    402.30 (1.0) 

benchmarks/drivers/test_build.py::test_build[sos-n=10]
                  time (s)        peak (KiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.04491 (1.10)   │   3.19 (1.19) 
 (v1)        0.04074 (1.0)   │    2.69 (1.0) 

benchmarks/drivers/test_build.py::test_build[sparse_network-n=10]
                  time (s)         peak (KiB) 
 name                 mean   │           mean 
──────────────────────────────────────────────
 (legacy)   0.04976 (1.06)   │   29.00 (1.54) 
 (v1)        0.04688 (1.0)   │    18.84 (1.0) 

benchmarks/drivers/test_build.py::test_build[sparse_network-n=250]
                  time (s)         peak (MiB) 
 name                 mean   │           mean 
──────────────────────────────────────────────
 (legacy)   0.06027 (1.10)   │   37.95 (1.43) 
 (v1)         0.0547 (1.0)   │    26.51 (1.0) 

benchmarks/drivers/test_build.py::test_build[storage-n=10]
                  time (s)          peak (KiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)     0.0942 (1.0)   │    410.93 (1.0) 
 (v1)       0.09576 (1.02)   │   427.84 (1.04) 

benchmarks/drivers/test_build.py::test_build[storage-n=250]
                 time (s)         peak (MiB) 
 name                mean   │           mean 
─────────────────────────────────────────────
 (legacy)    0.1005 (1.0)   │     9.94 (1.0) 
 (v1)       0.1035 (1.03)   │   10.22 (1.03) 

📊 Interactive plots + CSV: download the semantics-report-v1-vs-legacy artifact from this run.

Report-only · not a gate · refreshed on every push · obsolete once legacy is dropped.

…e densify_terms (#748)

LinearExpression.__matmul__ against a constant matrix that holds zeros
densified the result to one term per contracted member. The bare @ now
compacts the term dimension to the widest non-zero cell via densify_terms,
under both legacy and v1 semantics; a zero-free constant is untouched. On
PyPSA's Kirchhoff Voltage Law constraint this cuts 852 terms to 3.

densify_terms is rewritten: it counted non-zero positions with a Python
loop that scaled quadratically and allocated the output at the full term
width. It is now vectorised, allocates only the compacted width, returns
self when no zeros are present, works on QuadraticExpression, and no longer
raises on expressions without coordinate dimensions.
…action zero operand (#748)

Rename the __matmul__ local `is_constant` to `other_is_const` so it no longer
shadows the imported `is_constant` helper and the `LinearExpression.is_constant`
property, which carry the unrelated "no variables present" meaning.

Add a regression test for `variable @ vector` that contracts away every coord
dim with a zero-containing operand, the scalar-dot shape the other matmul tests
did not exercise.
@FabianHofmann
FabianHofmann force-pushed the fix/748-sparse-matmul-densify branch from 5386ff9 to da73f9b Compare September 7, 2026 07:55
@FabianHofmann
FabianHofmann merged commit 856d77b into master Sep 7, 2026
24 of 25 checks passed
@FabianHofmann
FabianHofmann deleted the fix/748-sparse-matmul-densify branch September 7, 2026 08:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

@/dot against a sparse matrix densifies the result to full _term

1 participant