feat(v1): CSR-backed sparse groupby-sum - skew-independent build memory (6-9x on the #745 hub case)#870
Draft
FBumann wants to merge 5 commits into
Draft
feat(v1): CSR-backed sparse groupby-sum - skew-independent build memory (6-9x on the #745 hub case)#870FBumann wants to merge 5 commits into
FBumann wants to merge 5 commits into
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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. Stacked on #717 (
feat/arithmetic-convention) — draft until that lands.🤖 Generated with Claude Code