Skip to content

merge of ragged expressions is the peak allocation in PyPSA model builds #749

Description

@FBumann

Note: This issue was drafted by an AI agent (Claude Code) during a profiling/investigation session with @FBumann. The measurements (memray C-level profiling, benchmarks, the 284× / 132 MB figures) and the git-history archaeology are real and reproducible, but the framing and proposed directions are a starting point for discussion — please sanity-check before acting on them.

merge (expressions.py, merge(...)) concatenates several LinearExpressions along a new/shared dimension by aligning their _term axes. Because the _term axis is a dense rectangle, alignment pads every block to the global maximum term count before concatenation, and the concatenation itself allocates the full padded result.

Evidence

Memray on a full SciGRID-DE create_model() (585 buses, 24 snapshots, 59,640 vars, 142,968 cons) — peak C-level memory 351 MB, and the single largest live allocation at the high-water mark:

132.6 MB  concatenate (xarray duck_array_ops)
          <- concat (xarray)
          <- merge (linopy/expressions.py)
          <- define_kirchhoff_voltage_constraints (pypsa/.../constraints.py)

So merge is the peak operation of the whole build — i.e. the allocation that actually sets the OOM ceiling for large networks, more than groupby (#745) on realistic group-size distributions.

Two contributing factors:

  1. The inputs are already densified (see the KVL @ issue) — merge inherits that bloat.
  2. merge pads each block to the global-max _term before concat; blocks with few terms waste the rest.

Why it matters

PyPSA works around exactly this by splitting the nodal-balance constraint into two (strongly- vs weakly-meshed buses) so each merge/groupby operates on a bucket of similar term counts — see #745 for that evidence. A merge that handled ragged _term (or operated in long format) would remove the need for that manual bucketing.

Possible directions

Sibling of #745 and the KVL @ issue — all three are the dense-_term representation surfacing at different ops.

Activity

  1. FBumann commented on Jun 4, 2026

    @FBumann
    CollaboratorAuthor

    Note: Same session (AI agent + @FBumann); env as in the #745 comment (linopy 0.7.0 / pypsa 1.2.2.post1.dev2+g30e4ed0ef / xarray 2026.4.0, build-only).

    Clarification on which merges the "pad to global-max _term" cost applies to — two distinct merge shapes in PyPSA with different fix locations:

    So merge is the common materialization site, but "merge pads to global-max" is specific to the cross-dim concat (KVL); the nodal-balance merge just concatenates _term. A ragged/long-format merge helps the former — the latter is fixed upstream in #745/#748.

  2. added
    performanceThis improves performance while not (meaningfully) altering behaviour for users
    on Jun 4, 2026
  3. FabianHofmann commented on Sep 6, 2026

    @FabianHofmann
    Collaborator

    The sparse operations needed for the math-spec lane is not complete yet, complementing this issue here.

    Note

    The following content was generated by AI.

    Revisiting this after #870 (CSR-backed sparse groupby/+/-, merged). The
    nodal-balance merge is now the concrete blocker, and it is a same_grid
    limitation rather than _term padding.

    Post-#870, +/- route through merge → try_csr_merge
    (expressions.py:3211), which keeps the result
    sparse only when every payload is same_grid: identical index values on
    every grid dimension
    (sparse_expression.py:164). A nodal
    balance sums grouped terms that live on different subsets of the same grid
    (generator buses, link bus0, line bus1, load buses, …), so same_grid is
    False, try_csr_merge returns None, and the first + in the chain
    densifies. From there the whole chain is dense and freeze only compresses the
    already-materialized cube (constraints.py:2104),
    so the add_constraints CSR fast path (model.py:1367)
    never fires for the balance.

    Repro — two sparse grouped sums, combined across vs within one grid:

    import linopy, pandas as pd
    
    def nodes(items, prefix):
        return pd.Series([f"{prefix}{k % 100}" for k in range(len(items))],
                         index=items, name="node")
    
    def combined(left, right):
        m = linopy.Model()
        items = pd.Index([f"i{k}" for k in range(10000)], name="item")
        x = m.add_variables(coords=[items], name="x")
        y = m.add_variables(coords=[items], name="y")
        gx = x.groupby(nodes(items, left)).sum()   # sparse (payload-backed)
        gy = y.groupby(nodes(items, right)).sum()  # sparse (payload-backed)
        lhs = gx.add(gy, join="outer")
        return lhs._payload is not None
    
    with linopy.options:
        linopy.options(semantics="v1", sparse_groupby=True)
        print("cross-grid (a* + b*) stays sparse:", combined("a", "b"))
        print("same-grid  (a* + a*) stays sparse:", combined("a", "a"))
    cross-grid (a* + b*) stays sparse: False   # densified at the add
    same-grid  (a* + a*) stays sparse: True
    

    Suggested direction, in line with #756: align payloads onto the union row
    grid
    before merging along the term axis (reindex each CSR onto the combined
    per-dim index, absent rows contribute nothing), instead of bailing when grids
    differ. That lets a summed-components constraint stay sparse end to end and hit
    the existing freeze fast path.

    Risk: the reindex must reproduce dense outer-join semantics exactly, keeping
    a cell with only explicit-zero coefficients distinct from an empty cell.
    CSRPayload.add already preserves this via COO on the same-grid path
    (sparse_expression.py:169).

    Measured impact (build-only, no solve)

    A ~2,200-bus network, one nodal_balance constraint, built via a spec lowering.
    Peak is ru_maxrss, resident is after gc.collect(). linopy
    0.9.1.post1.dev34+g4875f8f29 (PR #870 merged), sparse_groupby=True +
    freeze_constraints=True.

    horizon  build peak   resident   stored constraint (CSR)
    short     8,942 MiB     590 MiB      50 MiB
    long     30,167 MiB   1,018 MiB     174 MiB
    

    The finished model needs ~174 MiB of constraints; the 30 GiB is the transient
    cube, fully released. It is [bus=2164, snapshot, _term=1801] at 0.56 % fill
    (median 3 terms per row, one hub row at 1801) — i.e. the balance never took the
    sparse path.

  4. added
    sparseSparse / CSR-backed expressions and constraints
    on Sep 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    performanceThis improves performance while not (meaningfully) altering behaviour for userssparseSparse / CSR-backed expressions and constraints

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions