Skip to content

feat(expressions): sparse @/dot for CSR-backed expressions - #961

Merged
FabianHofmann merged 2 commits into
masterfrom
feat/sparse-matmul
Sep 23, 2026
Merged

FabianHofmann merged 2 commits into
masterfrom
feat/sparse-matmul

Conversation

@FabianHofmann

Copy link
Copy Markdown
Collaborator

Closes #756.

Note

The following content was generated by AI.

Changes proposed in this Pull Request

Under v1 semantics, expr @ C / expr.dot(C) against a constant now runs as one sparse matrix product instead of (self * other).sum(dim). The old path materialised a cells(self) x cells(other's kept dims) x nterm broadcast before a single zero was dropped, so peak memory scaled with the intermediate rather than with the result.

  • New kernel. CSRLinearExpression.contracted(matrix, contracted_dims, new_indexes) in linopy/csr.py evaluates (I_kept ⊗ Cᵀ) @ A in chunks of 64 kept cells, so the operator cost is O(chunk · nnz(C)) and independent of the number of kept cells. A single unchunked Kronecker product was measured at ~3.7x the result size in peak memory and was rejected, as was a COO-join formulation.
  • New branch. LinearExpression._sparse_matmul runs before the operand conversion touches self.coords, which has no CSR branch and would densify. A CSR-backed expression (from groupby(...).sum(sparse=True)) therefore stays CSR-backed through @, and (g @ C) + (h @ C) still merges sparsely.
  • Alignment parity. _matmul_operand_to_matrix in linopy/alignment.py reuses the primitives _apply_constant_op_v1 uses, so the §5 (user NaN), §8 (label mismatch / reorder) and §11 (auxiliary-coordinate conflict) errors are byte-identical to those of *. A test asserts that equality directly against the sparse path.
  • Fallbacks. The branch returns to the unchanged dense path for a dimensionless expression, a zero-size grid, non-unique or MultiIndex grid labels, a MultiIndex operand, an unlabelled output dimension, and an operand sharing no dimension with the grid. Legacy semantics and QuadraticExpression are untouched.

Term layout

The sparse branch returns the compact canonical form: duplicate variables summed, terms ordered by variable label, explicit zeros pruned, padded to the widest cell. The dense path keeps one term per contracted member when C holds no zeros, so nterm may differ between the two; the values agree. v1 does not promise term layout. Cell activeness is carried by const alone, unlike CSRLinearExpression.added, which preserves explicit zeros through COO. See #925.

Measured on master, before this change

branch cycle snapshot density result cells peak RSS
300 100 200 0.02 260k 205 MB
1000 300 100 0.01 570k 1021 MB
300 300 200 1.0 18M 613 MB

The sparse case peaked higher than the fully dense one of similar output size, because the intermediate is snapshot x branch x cycle x nterm, not the result. A tracemalloc guard on the 1000x300x100 case now asserts a peak below a quarter of the dense rectangle; it measures 28.4 MB against a 120 MB bound.

Review

The change was reviewed by four independent reviewer agents plus a verification pass. They found three crashes on degenerate shapes, where the sparse path raised but the dense path returned a result: a zero-length kept dimension, a zero-length contracted dimension, and a dimensionless expression. All three are fixed by bailing to the dense path, and each has a regression test that fails with its guard reverted. The reviewers also showed, by stubbing _sparse_matmul to return None, that the first version of the dense/CSR test parametrisation passed with the feature disabled; the tests now assert which path ran, and 8 of them fail under that stub.

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.

Contract `expr @ C` as one sparse matrix product instead of the dense
broadcast intermediate, so peak memory scales with nnz(C) x nterm. CSR
backing survives `@`; the result is the compact canonical form.
@github-actions

github-actions Bot commented Sep 22, 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.07788 (1.09)   │   15.03 (1.00) 
 (v1)        0.07135 (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.08881 (1.10)   │   12.04 (1.00) 
 (v1)        0.08041 (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.03356 (1.10)   │   15.20 (1.0) 
 (v1)         0.0304 (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.05598 (1.13)   │   44.93 (1.0) 
 (v1)        0.04956 (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.0385 (1.11)   │   11.51 (1.0) 
 (v1)       0.03465 (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.08684 (1.06)   │   24.34 (1.06) 
 (v1)        0.08184 (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.1001 (1.07)   │   16.12 (1.00) 
 (v1)       0.09354 (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.02106 (1.09)   │   752.18 (1.10) 
 (v1)        0.01931 (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.02046 (1.07)   │   3.12 (1.33) 
 (v1)        0.01908 (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.07194 (1.26)   │   126.16 (1.44) 
 (v1)        0.05707 (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.07158 (1.30)   │   126.16 (1.44) 
 (v1)        0.05524 (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.07116 (1.28)   │   126.16 (1.44) 
 (v1)        0.05575 (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.04747 (1.03)   │    715.12 (1.0) 
 (v1)        0.04629 (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.04595 (1.10)   │   4.54 (1.27) 
 (v1)        0.04166 (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.3258 (1.05)   │   704.12 (1.09) 
 (v1)         0.309 (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.3424 (1.06)   │   18.34 (1.0) 
 (v1)        0.3244 (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.3385 (1.07)   │   9.54 (1.0) 
 (v1)        0.3176 (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.06552 (1.10)   │   3.77 (1.12) 
 (v1)        0.05978 (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.06725 (1.12)   │   216.59 (1.10) 
 (v1)        0.05989 (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.03354 (1.12)   │   938.49 (1.0) 
 (v1)        0.03006 (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.0356 (1.12)   │   9.66 (1.0) 
 (v1)       0.03189 (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.03513 (1.12)   │   5.32 (1.0) 
 (v1)        0.03125 (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.01925 (1.03)   │   1.47 (1.0) 
 (v1)        0.01865 (1.0)   │   1.47 (1.0) 

benchmarks/drivers/test_build.py::test_build[nodal_balance_sparse-severity=100]
                  time (s)       peak (MiB) 
 name                 mean   │         mean 
────────────────────────────────────────────
 (legacy)   0.01909 (1.04)   │   1.47 (1.0) 
 (v1)        0.01835 (1.0)   │   1.47 (1.0) 

benchmarks/drivers/test_build.py::test_build[nodal_balance_sparse-severity=50]
                  time (s)       peak (MiB) 
 name                 mean   │         mean 
────────────────────────────────────────────
 (legacy)   0.01928 (1.04)   │   1.47 (1.0) 
 (v1)        0.01847 (1.0)   │   1.47 (1.0) 

benchmarks/drivers/test_build.py::test_build[piecewise-n=1000]
                 time (s)          peak (KiB) 
 name                mean   │            mean 
──────────────────────────────────────────────
 (legacy)   0.1649 (1.07)   │   946.85 (1.06) 
 (v1)        0.1537 (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.161 (1.06)   │   12.01 (1.00) 
 (v1)       0.1512 (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.04275 (1.11)   │   147.70 (1.06) 
 (v1)        0.03865 (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.04364 (1.13)   │   2.60 (1.09) 
 (v1)        0.03853 (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.03423 (1.11)   │   696.75 (1.03) 
 (v1)        0.03095 (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.1022 (1.04)   │   137.97 (1.0) 
 (v1)       0.09857 (1.0)   │   137.97 (1.0) 

benchmarks/drivers/test_build.py::test_build[rolling-severity=50]
                  time (s)        peak (MiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.06369 (1.10)   │   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.03992 (1.13)   │   402.33 (1.00) 
 (v1)        0.03521 (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.04014 (1.15)   │   3.19 (1.19) 
 (v1)        0.03486 (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.04351 (1.10)   │   29.00 (1.54) 
 (v1)        0.03956 (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.05792 (1.16)   │   37.95 (1.43) 
 (v1)        0.04988 (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.08251 (1.00)   │    410.93 (1.0) 
 (v1)        0.08227 (1.0)   │   427.84 (1.04) 

benchmarks/drivers/test_build.py::test_build[storage-n=250]
                  time (s)         peak (MiB) 
 name                 mean   │           mean 
──────────────────────────────────────────────
 (legacy)    0.09086 (1.0)   │     9.94 (1.0) 
 (v1)       0.09216 (1.01)   │   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.

@codspeed

codspeed Bot commented Sep 22, 2026

Copy link
Copy Markdown

Merging this PR will regress 1 benchmark

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 4 improved benchmarks
❌ 1 regressed benchmark
✅ 176 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[storage-n=250] 30.8 MB 38 MB -18.88%
test_to_lp[sparse_network-n=10] 1,343.2 KB 718.3 KB +86.99%
test_to_lp[expression_arithmetic-n=10] 1,339.2 KB 718.2 KB +86.46%
test_to_lp[qp-n=1000] 2.6 MB 2 MB +32.14%
test_to_lp[nodal_balance_sparse-severity=100] 3.7 MB 3.1 MB +19.83%

Tip

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


Comparing feat/sparse-matmul (fb783a9) with master (b0841a1)

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.

`dot`/`@` accept DataArrays on both the sparse and dense paths, but the
annotation still said `ndarray`. Also guard the `_csr` access in the
aux-coords test.
@FabianHofmann
FabianHofmann merged commit 82e7993 into master Sep 23, 2026
23 of 25 checks passed
@FabianHofmann
FabianHofmann deleted the feat/sparse-matmul branch September 23, 2026 08:48
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.

Umbrella: long-format / sparse _term kernel (dense-_term memory cluster)

1 participant