feat(v1): CSR-backed sparse groupby-sum - skew-independent build memory (6-9x on the #745 hub case) - #870
Conversation
Prototype for #745/#756 option 1 (deferred groupby): hold (expr, grouper) unmaterialized and realize the balance constraint straight from ungrouped long triplets via scipy COO->CSR (duplicate summation == the group sum). No padded _term rectangle ever exists; CSRConstraint plugs into the existing LP/matrix export unchanged. Equivalence: identical polars term rows and LP files vs the dense groupby path (incl. permuted group order). memray peaks on the #745 hub scenario (120 buses, 24 snapshots, build-only, setup baseline ~102MB): hub gens dense deferred 8000 846.6MB 196.8MB (constraint part: ~745MB vs ~95MB) 16000 1213MB 223.0MB dev-scripts is gitignored; files force-added to preserve the prototype on this branch only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Internal-state redesign of the deferred-groupby prototype: no new public primitive. groupby(g).sum(lazy=True) (or options['lazy_groupby'] under v1) returns an ordinary LinearExpression whose payload is a LazyGroupSum (ungrouped parts + groupers) instead of the materialized dense dataset, modeled on dask-backed xarray. The .data property materializes through today's kernel, so any operation without a lazy branch transparently falls back to exactly today's result. Lazy branches: neg, scalar mul, merge along _term (covers +/-), comparison with a constant rhs, and Model.add_constraints with freeze=True, which realizes the constraint directly as a CSRConstraint from long triplets (COO->CSR duplicate summation is the group sum) - the #745 padded rectangle never exists. Gated behind v1 semantics; under legacy, lazy=True raises and the option is ignored. v1 parity kept: NaN rhs raises (par.5), reordered rhs raises (par.8), absent const rows realize as masked (par.12). memray, #745 hub scenario (120 buses, 24 snapshots, setup ~107MB): hub gens eager lazy 8000 851.9MB 208.4MB 16000 1219MB 223.3MB Full suite: 6336 passed, 557 skipped (test/remote failures pre-exist on the branch). Includes test/test_lazy_groupby.py (10 cases x legacy/v1). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the LazyGroupSum recipe payload with CSRPayload (linopy/csr.py): groupby(g).sum(sparse=True) (or options['sparse_groupby'] under v1) now builds the grouped sum eagerly in CSR form — rows are flat grid cells, columns raw variable labels, duplicate variables summed, terms label-ordered — behind the unchanged LinearExpression type. Operations become sparse linear algebra: grouping scatters into rows (G @ A), merge along _term (and thus +/-) is sparse addition, neg/scalar-mul scale values, and add_constraints(freeze=True) staples sign/rhs on to form a CSRConstraint directly. Anything else expands to the dense rectangle in canonical form via .data (mathematically identical, term layout canonicalized — the reason the feature stays v1-gated). Vs the recipe: ops are real algebra with immediate errors instead of a deferred parts list, chains stay compact, and the payload is the natural seam for future sparse ops (dot #748, ragged merge #749). Cost: the bit-identical fallback is replaced by the canonical-form contract. memray, #745 hub scenario (120 buses, 24 snapshots, setup ~107MB): hub gens eager sparse (CSR) [recipe was] 8000 850.9MB 216.2MB 208.4MB 16000 1219MB 234.1MB 223.3MB Full suite: 6336 passed, 557 skipped (unchanged). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace dev-scripts/proto_deferred_bench.py with a suite entry: nodal_balance_sparse builds the identical balance via sum(sparse=True) + freeze=True under v1 (phases: build/matrices/to_lp). Paired with the existing nodal_balance severity sweep, the padding cost becomes CI-visible: pytest benchmarks/ -k 'nodal_balance and build' --benchmark-memory severity 0 50 100 dense (KiB) 951 5,460 9,909 sparse (KiB) 1,524 1,524 1,524 (and ~1.6x faster builds) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DRY the payload module (shared rhs-alignment helper, comprehension-based assembly), fold inline comments into docstrings, shrink the module and kwarg docs. No behavior change: sparse suite, nodal_balance benchmarks smoke and core expression/constraint tests unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
c207a6b to
a20b4e6
Compare
Merging this PR will degrade performance by 9.1%
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| ❌ | test_to_lp[qp-n=1000] |
2 MB | 2.6 MB | -23.35% |
| ❌ | test_to_lp[nodal_balance-severity=0] |
3.3 MB | 3.7 MB | -11.45% |
| ⚡ | test_to_lp[expression_arithmetic-n=250] |
45.3 MB | 40.9 MB | +10.66% |
| 🆕 | test_build[nodal_balance_sparse-severity=0] |
N/A | 1.5 MB | N/A |
| 🆕 | test_build[nodal_balance_sparse-severity=100] |
N/A | 1.5 MB | N/A |
| 🆕 | test_build[nodal_balance_sparse-severity=50] |
N/A | 1.5 MB | N/A |
| 🆕 | test_to_lp[nodal_balance_sparse-severity=0] |
N/A | 3.9 MB | N/A |
| 🆕 | test_to_lp[nodal_balance_sparse-severity=100] |
N/A | 3.7 MB | N/A |
| 🆕 | test_to_lp[nodal_balance_sparse-severity=50] |
N/A | 3.7 MB | N/A |
Tip
Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.
Comparing feat/v1-sparse-groupby (13f6681) with master (6b133d5)
Footnotes
-
175 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. ↩
Build cost — v1 vs legacyv1 build peak & time relative to legacy, on this commit — not a comparison against master (that is CodSpeed).
Full table (time + peak, mean)📊 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. |
… sparse=True, type-check clean Explicit zeros survive grouping/merge (COO-based add) so all-zero cells stay active constraints as on the dense path. sparse=True now raises for groupers the CSR path does not support; the option form still falls back. Adds a bool|None overload for add_constraints and annotates the tests for mypy.
…ze constraints via CSRConstraint.from_payload
|
Note The following content was generated by AI (Claude Code), reviewed by @FabianHofmann. Follow-up once #926 (label columns for
|
- fold the private CSR builder into CSRPayload._from_scatter, next to its from_grouper/from_expression callers - rename extract_pending -> extract_csr_pending to match try_csr_merge - simplify the nterm guard in materialize via max(initial=0)
FabianHofmann
left a comment
There was a problem hiding this comment.
highly needed for the math-spec integration in linopy (for pypsa networks with loads of connections). gave it a push. merging now
This is a drive by PR done with CLAUDE FABLE. Its here to explore how we can better ahdnle sparsity.
Not sure where this goes yet.
Note
The following content was generated by AI (Claude Code), prompted and reviewed by @FBumann.
pytest-benchmem, the #745 hub scenario (nodal balance, 120 buses × 24 snapshots, one hub bus with
hubgenerators, 100 on each other bus; per-benchmark allocator peak of the build):sparse=True+freeze=TrueDoubling the skew doubles the dense peak but only grows the sparse one by its actual new terms: the sparse path is O(nnz) and independent of the group-size distribution, so the padding cost that PyPSA's meshed-bus banding exists to contain (see #745) is gone rather than capped. Constraint export also gets cheaper, because the result already is the export representation.
What this adds
Addresses the inherent half of #745 (and #757) on the v1 line, following the umbrella plan in #756:
groupby(g).sum()must pad every group to the largest group's term count because aLinearExpressionstores terms in a densecells × _termrectangle. The padded cells are pure intermediate waste for constraint-bound results — the LP/matrix export drops them again.expr.groupby(g).sum(sparse=True)(orlinopy.options["sparse_groupby"] = True) builds the grouped sum in CSR form behind the unchangedLinearExpressiontype — modeled on dask-backed xarray objects: same public class, different backing, no new public primitive. The payload (linopy/csr.py,CSRPayload) stores the expression asA @ x + c: one CSR row per result cell, one column per variable label, plus a dense per-cell constant. The_termaxis is ragged by construction, so group-size padding has no analog, and the operations between a groupby and its constraint become sparse linear algebra:G @ Awith a 0/1 grouping matrix),mergealong_term— and therefore+/-— is sparse matrix addition,== rhsis carried as a pending payload on theConstraint,Model.add_constraints(..., freeze=True)(orModel.freeze_constraints) staples sign and rhs on and registers aCSRConstraintdirectly — the existing frozen backend (perf: matrix accessor rewrite #630), so the LP writer and matrix export work unchanged.Anything without a sparse branch transparently expands to the dense rectangle through the
.dataproperty and proceeds exactly as today, so compatibility is a fallback, not a constraint.The contract, and why it is v1-gated
The CSR form is canonical: duplicate variables within a cell are summed (
2x + 3x → 5x) and terms are ordered by variable label. Expanding back to the dense form therefore yields the mathematically identical expression in canonical term layout, not the eager kernel's exact positional layout. Term layout is non-contractual under v1 (linopy already treats it as such at export: zero-coeff filtering,maybe_group_terms_polars,densify_terms), which is why the feature requiresoptions["semantics"] = "v1"— under legacy,sparse=Trueraises and the option is ignored. Equivalence is asserted at the level that matters: identical polars constraint rows and identical LP files (the test suite includes an end-to-end LP diff, term order within a row canonicalized).v1 parity is kept in the direct realization: NaN in the rhs raises (§5), a reordered/differing rhs index raises with the standard alignment message (§8), and rows whose constraint is absent realize as masked (§12). Labels and the constraint grid bit-match the dense path.
Reproduce
The suite gains a sparse sibling of the existing
nodal_balancepattern (the #745 severity sweep), so the padding cost is CI-visible on CodSpeed and one command locally:pytest benchmarks/ -k "nodal_balance and build" --benchmark-memorynodal_balance(dense)nodal_balance_sparseStandalone repro (single pytest file — the headline table above is its output)
Needs
pytest-benchmarkandpytest-benchmem(both in thebenchmarksextra). Measured on this branch, macOS arm64, python 3.13. Both paths produce identical constraints — the test suite asserts identical polars term rows and identical LP files for this construction.Scope and current limitations
use_fallbackandobservedtake the eager path.+/-on the same grid, comparison with a constant rhs); everything else materializes canonically.freeze=Falsefalls back to a denseConstraint(mathematically equal, canonical layout).Natural follow-ups on this seam (not in this PR):
dotas a payload op (the v1-side counterpart of #867, which stays the master-era #748 fix) and ragged/KVL-style merge (#749).History
Two commits kept deliberately: the first implements the same public behavior with a deferred-recipe payload (ungrouped parts + groupers, bit-identical fallback via replaying the eager kernel); the second swaps the payload to CSR. The recipe variant measured within ~5 % of the CSR numbers but accumulates unbounded part lists, holds all input expressions alive until realization, and every future op would need its own replay logic — CSR is the representation the rest of #756 (dot, merge) composes on. The swap trades the recipe's bit-identical fallback for the canonical-form contract above.
Full suite: 6336 passed, 557 skipped — unchanged from the base branch. #717 (
feat/arithmetic-convention) has landed on master, so this branch is now rebased directly onto master and is no longer stacked.🤖 Generated with Claude Code