Skip to content

feat(v1): CSR-backed sparse groupby-sum - skew-independent build memory (6-9x on the #745 hub case) - #870

Merged
FabianHofmann merged 8 commits into
masterfrom
feat/v1-sparse-groupby
Sep 4, 2026
Merged

feat(v1): CSR-backed sparse groupby-sum - skew-independent build memory (6-9x on the #745 hub case)#870
FabianHofmann merged 8 commits into
masterfrom
feat/v1-sparse-groupby

Conversation

@FBumann

@FBumann FBumann commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

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 hub generators, 100 on each other bus; per-benchmark allocator peak of the build):

hub generators dense groupby build peak sparse=True + freeze=True median build time
8,000 277.1 MiB 43.5 MiB (6.4×) 19.4 ms → 12.9 ms
16,000 546.1 MiB 61.0 MiB (9.0×) 38.5 ms → 16.5 ms

Doubling 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 a LinearExpression stores terms in a dense cells × _term rectangle. The padded cells are pure intermediate waste for constraint-bound results — the LP/matrix export drops them again.

expr.groupby(g).sum(sparse=True) (or linopy.options["sparse_groupby"] = True) builds the grouped sum in CSR form behind the unchanged LinearExpression type — modeled on dask-backed xarray objects: same public class, different backing, no new public primitive. The payload (linopy/csr.py, CSRPayload) stores the expression as A @ x + c: one CSR row per result cell, one column per variable label, plus a dense per-cell constant. The _term axis is ragged by construction, so group-size padding has no analog, and the operations between a groupby and its constraint become sparse linear algebra:

  • grouping scatters members into group rows (conceptually G @ A with a 0/1 grouping matrix),
  • merge along _term — and therefore +/- — is sparse matrix addition,
  • unary minus / scalar multiplication scale values,
  • == rhs is carried as a pending payload on the Constraint,
  • Model.add_constraints(..., freeze=True) (or Model.freeze_constraints) staples sign and rhs on and registers a CSRConstraint directly — 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 .data property 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 requires options["semantics"] = "v1" — under legacy, sparse=True raises 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_balance pattern (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-memory
build peak (KiB) severity 0 50 100
nodal_balance (dense) 951 5,460 9,909
nodal_balance_sparse 1,524 1,524 1,524
Standalone repro (single pytest file — the headline table above is its output)
# repro745.py — the #745 hub scenario, dense vs sparse (CSR) groupby-sum.
# run:  pytest repro745.py --benchmark-memory
import numpy as np
import pandas as pd
import pytest
import xarray as xr

import linopy


def build_balance(sparse: bool, hub: int) -> linopy.Model:
    linopy.options["semantics"] = "v1"
    n_bus, n_snap = 120, 24
    buses = pd.RangeIndex(n_bus, name="bus")
    gen_of_bus = np.repeat(np.arange(n_bus), [hub] + [100] * (n_bus - 1))
    gens = pd.RangeIndex(len(gen_of_bus), name="gen")
    snaps = pd.RangeIndex(n_snap, name="snapshot")

    m = linopy.Model()
    gen_p = m.add_variables(coords=[gens, snaps], name="gen_p")
    load = xr.DataArray(np.ones((n_bus, n_snap)), coords=[buses, snaps])

    grouper = pd.Series(gen_of_bus, index=gens, name="bus")
    supply = (1 * gen_p).groupby(grouper).sum(sparse=sparse)
    m.add_constraints(supply == load, name="balance", freeze=sparse)
    return m


@pytest.mark.parametrize("hub", [8000, 16000])
@pytest.mark.parametrize("sparse", [False, True], ids=["dense", "sparse"])
def test_hub_balance_build(benchmark, sparse, hub):
    benchmark(build_balance, sparse, hub)

Needs pytest-benchmark and pytest-benchmem (both in the benchmarks extra). 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

  • Single-key groupers over an existing dimension; multi-key / multidim groupers, use_fallback and observed take the eager path.
  • The sparse branches cover the constraint-building chain (neg, scalar mul, merge/+/- on the same grid, comparison with a constant rhs); everything else materializes canonically.
  • freeze=False falls back to a dense Constraint (mathematically equal, canonical layout).
  • Quadratic expressions are untouched.

Natural follow-ups on this seam (not in this PR): dot as 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

@FBumann FBumann added this to the Post v1 milestone Jul 24, 2026
@FBumann FBumann changed the title feat(v1): CSR-backed sparse groupby-sum — skew-independent build memory (851→216 MB on the #745 hub case) feat(v1): CSR-backed sparse groupby-sum - skew-independent build memory (6-9x on the #745 hub case) Jul 24, 2026
Base automatically changed from feat/arithmetic-convention to master August 19, 2026 06:06
FBumann and others added 5 commits September 4, 2026 11:44
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>
@codspeed-hq

codspeed-hq Bot commented Sep 4, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 9.1%

⚠️ 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

⚡ 1 improved benchmark
❌ 2 regressed benchmarks
✅ 172 untouched benchmarks
🆕 6 new benchmarks
⏩ 175 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.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)

Open in CodSpeed

Footnotes

  1. 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.

@FabianHofmann
FabianHofmann marked this pull request as ready for review September 4, 2026 09:49
@github-actions

github-actions Bot commented Sep 4, 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.07746 (1.08)   │   15.03 (1.00) 
 (v1)        0.07148 (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.08456 (1.09)   │   12.04 (1.00) 
 (v1)        0.07727 (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.03362 (1.08)   │   15.20 (1.0) 
 (v1)         0.0311 (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.04906 (1.09)   │   44.93 (1.0) 
 (v1)        0.04486 (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.03657 (1.06)   │   11.51 (1.0) 
 (v1)        0.03455 (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.08662 (1.07)   │   24.34 (1.06) 
 (v1)         0.0812 (1.0)   │    23.05 (1.0) 

benchmarks/drivers/test_build.py::test_build[expression_arithmetic-n=250]
                  time (s)         peak (MiB) 
 name                 mean   │           mean 
──────────────────────────────────────────────
 (legacy)   0.09494 (1.06)   │   16.12 (1.00) 
 (v1)        0.08986 (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.02067 (1.06)   │   752.18 (1.10) 
 (v1)        0.01944 (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.02025 (1.08)   │   3.12 (1.33) 
 (v1)        0.01881 (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.05748 (1.22)   │   126.16 (1.44) 
 (v1)        0.04698 (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.05636 (1.23)   │   126.16 (1.44) 
 (v1)        0.04601 (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.05705 (1.23)   │   126.16 (1.44) 
 (v1)        0.04636 (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.047 (1.01)   │    715.14 (1.0) 
 (v1)       0.04647 (1.0)   │   787.74 (1.10) 

benchmarks/drivers/test_build.py::test_build[masked-n=10]
                  time (s)        peak (KiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.04622 (1.11)   │   4.55 (1.27) 
 (v1)        0.04181 (1.0)   │    3.59 (1.0) 

benchmarks/drivers/test_build.py::test_build[merge_balance-severity=0]
                 time (s)          peak (KiB) 
 name                mean   │            mean 
──────────────────────────────────────────────
 (legacy)   0.3242 (1.04)   │   704.12 (1.09) 
 (v1)        0.3123 (1.0)   │    647.38 (1.0) 

benchmarks/drivers/test_build.py::test_build[merge_balance-severity=100]
                 time (s)        peak (MiB) 
 name                mean   │          mean 
────────────────────────────────────────────
 (legacy)   0.3408 (1.05)   │   18.34 (1.0) 
 (v1)        0.3255 (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.3376 (1.04)   │   9.54 (1.0) 
 (v1)        0.3249 (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.06496 (1.08)   │   3.77 (1.12) 
 (v1)        0.06023 (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.06562 (1.09)   │   216.59 (1.10) 
 (v1)        0.06016 (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.03339 (1.09)   │   938.49 (1.0) 
 (v1)        0.03072 (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.03463 (1.09)   │   9.66 (1.0) 
 (v1)         0.0319 (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.03412 (1.09)   │   5.32 (1.0) 
 (v1)        0.03126 (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.01841 (1.02)   │   1.41 (1.0) 
 (v1)        0.01806 (1.0)   │   1.41 (1.0) 

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

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

benchmarks/drivers/test_build.py::test_build[piecewise-n=1000]
                 time (s)          peak (KiB) 
 name                mean   │            mean 
──────────────────────────────────────────────
 (legacy)   0.1626 (1.04)   │   946.85 (1.06) 
 (v1)         0.157 (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.1594 (1.03)   │   12.01 (1.0) 
 (v1)        0.1545 (1.0)   │   12.01 (1.0) 

benchmarks/drivers/test_build.py::test_build[qp-n=1000]
                  time (s)          peak (KiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.04145 (1.06)   │   147.70 (1.06) 
 (v1)        0.03902 (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.04123 (1.07)   │   2.60 (1.09) 
 (v1)        0.03856 (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.03418 (1.07)   │   696.75 (1.03) 
 (v1)        0.03188 (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.07293 (1.00)   │   137.97 (1.0) 
 (v1)        0.07263 (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.05332 (1.08)   │   69.22 (1.0) 
 (v1)        0.04936 (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.03964 (1.09)   │   402.33 (1.00) 
 (v1)        0.03621 (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.03883 (1.09)   │   3.19 (1.19) 
 (v1)        0.03573 (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.04294 (1.06)   │   29.00 (1.54) 
 (v1)        0.04041 (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.05312 (1.12)   │   37.95 (1.43) 
 (v1)        0.04727 (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.08254 (1.0)   │    410.93 (1.0) 
 (v1)       0.08417 (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.08814 (1.0)   │     9.94 (1.0) 
 (v1)       0.09119 (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.

… 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
@FabianHofmann

Copy link
Copy Markdown
Collaborator

Note

The following content was generated by AI (Claude Code), reviewed by @FabianHofmann.

Follow-up once #926 (label columns for CSRConstraint) lands

Parts of this PR are a stopgap for the fact that CSRConstraint stores dense variable positions. With label columns, the following is deleted or simplified, nothing else moves:

  • Delete Constraint._from_pending, the lazy branch in Constraint.data, and extract_pending (linopy/constraints.py). LinearExpression.to_constraint on a payload-backed expression returns an unassigned CSRConstraint (cindex=None) directly, so no pending (lhs, sign, rhs) state is needed.
  • Simplify CSRConstraint.from_payload: drop the label-to-position mapping and the label-index dependency. It becomes a thin constructor from payload, sign and rhs; the has_terms / eliminate_zeros ordering stays.
  • Reduce the dispatch in Model.add_constraints to "if the incoming constraint is an unassigned CSRConstraint, assign labels", which is the step the dense path already performs.

Unchanged: linopy/sparse_expression.py (CSRPayload, try_csr_merge), the _payload hooks on LinearExpression, from_payload as the sparse sibling of from_mutable, and _rhs_grid_values.

Note that frozen constraints produced here inherit the bug in #926: adding or removing variables after a sparse freeze=True constraint fails at matrix assembly, exactly as for the pre-existing dense freeze=True path.

- 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 FabianHofmann left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

highly needed for the math-spec integration in linopy (for pypsa networks with loads of connections). gave it a push. merging now

@FabianHofmann
FabianHofmann merged commit b71e9a9 into master Sep 4, 2026
25 of 26 checks passed
@FabianHofmann
FabianHofmann deleted the feat/v1-sparse-groupby branch September 4, 2026 11: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.

2 participants