Repository navigation
Umbrella: long-format / sparse _term kernel (dense-_term memory cluster) #756
Description
Activity
- addedperformanceThis improves performance while not (meaningfully) altering behaviour for usersThis improves performance while not (meaningfully) altering behaviour for users
on Jun 4, 2026 - added a commit that references this issue
on Jul 13, 2026 Note
The following content was generated by AI (Claude Code), from a profiling
session on the math-spec lowering lane with @FBumann, and reviewed by a second
agent. Measurements are reproducible; the sequencing is a proposal for
discussion.Status update on this cluster after #870 (CSR groupby + freeze fast path,
merged), from profiling a realfrom_specbuild (carbon_management, ~2,200
buses). Finished models are already fine — spec-lane resident ~1.0 GiB vs ~1.1
GiB native, constraints compress to 174 MiB CSR. The problem is a transient
build peak of ~30 GiB, entirely one constraint (nodal_balance) as a dense
[bus=2164, snapshot, _term=1801]cube at 0.56% fill.Where sparsity is actually lost
The CSR fast path (
model.py:1367) fires for 0 of 98 constraints in this
build. Two independent reasons, and the first was a surprise:-
Payload dropped before
mergeeven runs.groupby(g).sum()keeps the
CSR payload, but the very next ops in the spec builder'sgrouped_sum
(spec/operators.py:68-69) —reindex(...)then.fillna(0.0)— have no
payload branch, so.datamaterializes and drops it
(expressions.py:1580-1582). Verified:groupby(Series) payload=True reindex payload=False fillna payload=False rename payload=False sel / sum(dim) payload=False merge same grid payload=True merge cross grid payload=FalseSo each summand is already a dense padded rectangle before the
+;merge
sees no payloads and concatenates dense into the 1801-wide cube. Fixing the
cross-gridmerge(mergeof ragged expressions is the peak allocation in PyPSA model builds #749) alone changes nothing here untilreindex/fillna
preserve the payload. -
Mask gate. Most constraints pass an explicit
mask, barred at
mask is Nonebefore the fast path. Narrow, so no memory harm — but PyPSA's
nativenodal_balanceis masked too (pypsa .../constraints.py:1559), so
the mask gate is real on the largest native constraint, not latent.
Revised plan
Layer 0 — correctness (gates making
freeze/sparse_groupbydefault):- CSRConstraint: use label columns; frozen constraints break after later variable add/remove #926 —
CSRConstraint.from_payloadstores dense variable positions
(constraints.py:1227); use label columns.CSRPayloadis already
label-based, so the fix is contained. Gates the default flip because PyPSA
extra_functionalityadds variables after constraints. - v1: sparse groupby path turns an absent cell into an active zero row #928 — v1 absence divergence: the sparse groupby path turns an absent
.wherecell into an active zero row (_from_scatterzero-fills NaN const,
sparse_expression.py:157). A wrong answer, not memory. Ship with CSRConstraint: use label columns; frozen constraints break after later variable add/remove #926.
Layer 1 — the shared primitive, then the two ops:
- New first deliverable:
CSRPayload.reindexed(indexes)— remap COO row
coords onto a new flat grid, rebuild CSR. This is the real unblock for the
nodal balance, and it is not tracked today. Payload-preservingreindex,
fillna(const-only),rename,has_termsfall out of it. mergeof ragged expressions is the peak allocation in PyPSA model builds #749 (cross-gridmerge) becomes a thin layer on that primitive. Prereq:
try_csr_mergeignoresjoinandfill_valuetoday
(sparse_expression.py:230,expressions.py:3211) — safe only while
same_gridholds. Gate the union path on explicitjoin="outer"; bail on
override(legacy positional),ABSENTfill, non-unique indexes, or a
summand missing a grid dim.@/dotagainst a sparse matrix densifies the result to full_term#748 (KVL@/dot) is not independent: it needs a payload-producing
matmul (buildable in parallel) and merge-along-a-coordinate-dim, which is
the same reindexed primitive. Its end-to-end win lands only after the
primitive.
Layer 2: #757 multi-key groupby (small —
factorizeon MultiIndex codes).Cross-cutting:
- No test asserts a payload survives a pipeline, which is why
reindex
regressed silently. Add a groupby → reindex → outer-merge → freeze test plus a
tracemallocpeak guard before any further sparse PR. - Zero culling forces model rebuilding #925 (zero-coefficient culling) must settle the explicit-zero storage policy
beforefreezebecomes default: frozen CSR culls zeros at freeze
(constraints.py:1232) and cannot un-cull. - QuadraticExpression stays dense (
mergebails,expressions.py:3208);
out of scope here.
Sequencing
#926 + #928 first (correctness). Then
CSRPayload.reindexedas the first
memory-moving deliverable — not #749, since #748 and #749 both build on it.
Then #749 and #748 in parallel on top of the primitive, #757 after.-
- addedenhancementNew feature or requestNew feature or requestsparseSparse / CSR-backed expressions and constraintsSparse / CSR-backed expressions and constraints
on Sep 18, 2026
This issue collects information about how to handle sparsity in linopy, while keeping the convenience of xarray broadcasting and backwards compat.
Note
Generated with AI
Root cause
A
LinearExpressionstorescoeffs/varsover a densecoord_dims × _termrectangle. Every op that combines terms re-pads that axis — which is the peak allocation in real PyPSA builds. The same root cause surfaces at four entry points:@/dotagainst a sparse matrix densifies the result to full_term#748 —@/dotvs a sparse matrixgroupby().sum()pads to the largest groupmergeof ragged expressions is the peak allocation in PyPSA model builds #749 —mergeof ragged expressions (the build peak)groupby([names])densifies to a cartesian grid (the fast-path half, Make multi-key LinearExpression.groupby([names]) fast and flat (align with xarray's grouper API) #753, shipped in fix(groupby): group by non-dimension coordinate names; fast multi-key grouping by names (#750, #753) #751)Context: #740 (polar-high benchmark — the motivation), #712/#713 (absent/NaN semantics a sparse store must keep), #744 (flat dims — prerequisite for a tabular core), #630 (
CSRConstraint— existing compressed path, export-side only).Direction
Two backends were weighed (full note below):
sparse.COOunder xarray (all-or-nothing; densifies at the first unsupported op) vs. a Polars long-format kernel (the formto_polarsalready emits). Recommendation: incremental long-format on the hot ops, behind the existing dense xarray API — convert back to dense_termonly at the boundary, validate bit-for-bit, ship one op at a time:__matmul__/dot(@/dotagainst a sparse matrix densifies the result to full_term#748) — most self-contained, biggest single wingroupby().sum()(groupby-sum pads every group to the largest group size #745) — transient win first (FBumann/linopy#25), then the inherent paddingmerge(mergeof ragged expressions is the peak allocation in PyPSA model builds #749) — concat long, rectangularize onceA full tabular core or
sparse.COOremain open later, but are larger bets (the core is gated on #744).(Earlier siblings referred to "#740" as the kernel — that was a mis-reference to the polar-high benchmark; this issue is the kernel.)
Full investigation note
Sparsity support in linopy — investigation
What the issue tracker already says
A tight, well-profiled cluster of open issues all trace to one root cause:
the dense
_termrectangle inLinearExpression.@/dotvs sparse matrix_termvs 3 needed → 284× cells, the single largest alloc (132 MB) in a SciGRID-DE buildgroupby().sum()mergeof ragged exprs_termThere is already a partial sparse path: #630 /
freeze_constraints=True→CSRConstraint(scipy CSR + label arrays, no denseDataset;constraints.py:476). Measured at ~32% leaner constraint storage on a realnetwork. But it only engages at the export/storage boundary — it does not
touch the build phase where the peak actually lives.
The actual data model, and where density is forced
A
LinearExpressionis an xarrayDatasetof three arrays overcoord_dims × _term:coeffs(float, fill0),vars(int labels, fill-1= absent),const(float, no_term).The
_termaxis is a dense rectangle. Every operation that combines termsre-rectangularizes:
_sum(dim)(expressions.py:1488) →stack({_term: [_term]+dim})— dense product.groupby().sum()(expressions.py:262) →unstack(group_dim, fill_value=...)— pads to largest group.merge(expressions.py:2476) →xr.concatwithfill_value, aligning_termaxes.__matmul__(expressions.py:1729) → literally(self * other).sum(common_dims)— never inspects whetherotheris zero.One subtlety: even
to_polarsdensifies first —to_polars(
common.py:194) doesbroadcast(ds)[0]then.values.reshape(-1)beforefilter_nulls_polars. So today's "tabular" output is a dense→long dump at thevery end; it does not relieve the build-phase peak (consistent with #749
placing the peak at
merge, upstream of export). The sparse story must befixed in the build ops, not at export.
Backend A — sparse xarray (
sparse.COOduck-arrays)Idea: back
coeffs/varswith pydatasparse.COOinstead of numpy;zero-coeff terms cost nothing, the public xarray API stays.
Attractive because: minimal API change —
LinearExpressionstays an xarrayDataset; in principle every op "just works" and all four issues dissolve atonce (#740's last comment makes exactly this point).
Riskier than it looks:
stack/unstack(in_sumandgroupby-sum),
reindex-with-fill andconcat-with-fill (inmerge),groupby— are precisely the weakest-supported operations onsparse.COOunder xarray.
unstackon COO is notoriously unsupported/slow. You densifyat the first gap and get no win while paying COO overhead everywhere else.
coeffsis sparse-around-0;varsissparse-around-
-1. COO supports a non-zerofill_value, but the twoarrays must stay co-indexed through every broadcast/align, and xarray
broadcasting across two COO arrays with differing fill values does not
compose cleanly.
.flat/.values,np.add.atinmatrices.py, scipy interop,repr,simplify'sbincount. Each needs aCOO branch.
structural zero in COO, the opposite of what An absent variable is indistinguishable from a zero variable #712 wants (absent must stay
distinguishable from a real zero).
Verdict: elegant on paper, invasive and fragile in reality. All-or-nothing —
the payoff requires every hot op to have a real COO path.
Backend B — tabular / long-format (Polars / COO triplets)
Idea: represent the term-carrying part of an expression as a long relation
—
(…coord cols…, coeffs, vars)— i.e. exactly the COO/tidy formto_polarsalready emits, and exactly polar-high's architecture (#740).
In this representation the pathological ops become cheap and natural:
groupby(k).sum()→group_by(k).agg— no padding, output is observed groups only.expr @ sparse→ a join on the contracted dim over nonzeros only —@/dotagainst a sparse matrix densifies the result to full_term#748's bit-equivalent prototype is literally "explodeCto(branch, cycle, value)thengroupby(cycle).sum()".merge→ verticalconcat— no_termalignment, no global-max padding.sum(dim)→ drop the dim from the key — no restack.Attractive because: Polars is already a required dependency
(
pyproject.toml:38) and already the IO kernel (to_polarson every object,the LP writer streams Polars). The seam exists.
Limitations:
Cheap to land incrementally, but a round-trip only pays off if the op stays
long end-to-end; isolated conversions can cost more than they save.
rewrite. You'd re-implement xarray's label-alignment, broadcasting,
.sel/.where/.shift, masks, coord bookkeeping, and printing on acolumnar table. A large fraction of
expressions.py/variables.py/alignment.pyis exactly that machinery.(snapshot)constant against(snapshot, branch)becomes an explicit join —works, but you're reimplementing xarray.
table; first-class
pd.MultiIndexdoes not. The Decision before v1: first-class MultiIndex support vs. flat dims + auxiliary level coords #744 thread is alreadyleaning toward dropping MI, which conveniently de-risks an eventual tabular
core.
Feasibility & recommendation
Don't replace xarray, and don't go COO-under-xarray. Go incremental
long-format on the hot ops (B1), behind the existing dense API — the "only the
critical operations" instinct. In order of measured peak:
__matmul__/dotagainst a constant/sparse operand (@/dotagainst a sparse matrix densifies the result to full_term#748) — mostself-contained, biggest single win. Detect zeros in
other, build the COOproduct, group-sum the contracted dim in long form, return a compact-
_termexpression. No public API change;
@/dotagainst a sparse matrix densifies the result to full_term#748 already has a bit-equivalent reference.groupby().sum()(groupby-sum pads every group to the largest group size #745) — two separable wins: the transient(
FBumann/linopy#25scatter-instead-of-unstack prototype: ~32× faster, fitsthe current dense model as a drop-in) and the inherent padding (needs long
output). Do the transient first.
mergealong a non-_termdim (mergeof ragged expressions is the peak allocation in PyPSA model builds #749) — concat in long form,rectangularize once to per-result max instead of per-input-then-global.
Each is independently shippable, validated bit-for-bit against the dense path,
and each stays long end-to-end if it feeds the next op or
to_polars/ thematrix export (where it would also let you skip the densifying
broadcastinto_polars). TheCSRConstraintpath (#630) is the proof that a compressedrepresentation can coexist with the dense one — extend that pattern leftward
into expression building.
Backend A and the full tabular core (B2) are both "one architecture fixes
everything" stories, but they are large, all-or-nothing bets. The incremental
long-format route recovers most of the measured memory op by op, each step
verifiable — and leaves the door open to B2 later (gated on #744 landing as
flat-dim).
Tracking
This issue is that long-format/sparse
_termkernel tracker — linking #748 /#745 / #749 (+ #757) and the incremental plan above. (Earlier siblings pointed
at "#740" for the kernel; #740 is actually the polar-high benchmark.)