Skip to content

perf(groupby): unify scatter kernel over numpy and dask via apply_ufunc - #802

Merged
FabianHofmann merged 18 commits into
PyPSA:masterfrom
fluxopt:perf/groupby-sum-apply-ufunc
Jul 14, 2026
Merged

perf(groupby): unify scatter kernel over numpy and dask via apply_ufunc#802
FabianHofmann merged 18 commits into
PyPSA:masterfrom
fluxopt:perf/groupby-sum-apply-ufunc

Conversation

@FBumann

@FBumann FBumann commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

I use apply_ufunc to make this dask capable. As we dont have the reference unstack implementation anymore, i introduced quite a heavy testing part (fast though), as I find the apply_ufunc version harder to understand personally. Happy to strip it down.

Note

The following content was generated by AI.

Stacked on #793. Until #793 merges, this PR's diff includes its commit too — review only the top commit perf(groupby): unify scatter kernel ....

What this does

#793 split groupby-sum into a numpy kernel and a dask unstack fallback. This
collapses them into a single kernel (_grouped_sum) wrapped in
xarray.apply_ufunc:

  • numpy-backed data scatters terms into the padded result arrays as before;
  • chunked (dask) data runs the same scatter lazily via dask="parallelized",
    after gathering the grouped dimension into a single chunk (which unstacking
    required too).

This removes the last pd.MultiIndex/unstack usage in groupby-sum, drops the
numpy-vs-dask branch in sum(), and keeps peak memory at input + result on both
backends. Multi-key / DataFrame grouping and its MultiIndex result are
unaffected — that logic sits above the kernel (existing tests cover it).

Tests

The kernel is verified from first principles — for every group and every
slice over the non-grouped dims, the result's live terms must equal the multiset
of its members' terms and the constant their NaN-skipping sum — across every
case shape on both numpy and dask backends. Three explicit anchors pin the
exact padded layout (member order, fill position, (nterm, max_size)
interleaving, and the _factor axis) for the linear, multidim and quadratic
cases.

Benchmark (300k elem × 8 dim × 1000 groups, numpy)
kernel mean time peak memory
_sum_by_scatter (#793) 46.5 ms 114.6 MiB
_sum_by_unstack (#793 dask path) 49.9 ms 254.8 MiB
apply_ufunc (this PR) 48.3 ms 114.6 MiB

The unified kernel matches the scatter kernel's memory and time; the old dask
path cost 2.2× peak.

Notes

  • Touches only linopy/expressions.py and the groupby kernel tests; full
    test_linear_expression + test_quadratic_expression pass (366), broader
    suite green.
  • Gathering group_dim into one chunk is unavoidable for a scatter (a group's
    members can sit in any chunk) and is exactly what the old unstack path forced.

FBumann and others added 5 commits June 29, 2026 09:58
The fast path of LinearExpression.groupby(...).sum() used
ds.unstack(group_dim, fill_value=...) followed by a stack, which
materializes 2-3 intermediate copies of the padded result
(n_groups x max_group_size x nterm) and goes through pandas MultiIndex
machinery sized by the number of elements.

Instead, factorize the groups and scatter coeffs/vars directly into the
preallocated padded result arrays; constants are group-summed with
np.add.at. Peak memory drops to input + result (the minimum for the
padded layout) and the grouping itself gets considerably faster.

The result is unchanged: same dims, coords, term ordering and padding.
The unstack-based implementation is kept as _sum_by_unstack and still
used for chunked (dask-backed) data, which cannot be scattered into
numpy arrays. NaN group labels now raise an informative ValueError
instead of failing inside unstack.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a test for grouping over an empty group dimension, which the scatter
fast path handles cleanly but the unstack fallback cannot. Trim comments
that duplicated the helper docstrings.
Relax the groupby-sum scatter gate to a pure numpy/dask check: auxiliary
coordinates on the grouped dimension no longer force the slow unstack
path. Summing over groups collapses that dimension, so both kernels drop
every coordinate tied to it — the scatter result is identical, just
cheaper. The unstack kernel now serves only chunked (dask) data, and a
debug log records when that fallback is taken.

Inline the now-trivial predicate into the dispatch and consolidate the
kernel tests into a TestGroupbySumScatterKernel class: a one-line case
table over a shared fixture, with added coverage for combined structures,
auxiliary coords, and a MultiIndex grouped dimension.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@codspeed-hq

codspeed-hq Bot commented Jun 30, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by ×2.1

⚡ 11 improved benchmarks
✅ 162 untouched benchmarks
⏩ 173 skipped benchmarks1

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Memory test_to_lp[nodal_balance-severity=100] 17.9 MB 6 MB ×3
Memory test_to_lp[nodal_balance-severity=50] 9.2 MB 3.1 MB ×3
Memory test_to_lp[nodal_balance-severity=0] 385.3 KB 138.4 KB ×2.8
Memory test_build[nodal_balance-severity=100] 32 MB 12.8 MB ×2.5
Memory test_build[nodal_balance-severity=50] 16.8 MB 7 MB ×2.4
Memory test_op[expr_groupby_sum] 606.6 KB 277.9 KB ×2.2
Memory test_to_solver[highs-nodal_balance-severity=100] 24.9 MB 13.3 MB +87.47%
Memory test_to_solver[gurobi-nodal_balance-severity=100] 25.1 MB 13.5 MB +86.1%
Memory test_to_solver[highs-nodal_balance-severity=50] 12.9 MB 7.1 MB +81.68%
Memory test_to_solver[gurobi-nodal_balance-severity=50] 13.1 MB 7.3 MB +79.32%
Memory test_build[nodal_balance-severity=0] 1.4 MB 1.2 MB +19.61%

Tip

Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.


Comparing fluxopt:perf/groupby-sum-apply-ufunc (e5b10a0) with master (125a7c3)

Open in CodSpeed

Footnotes

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

@FBumann
FBumann force-pushed the perf/groupby-sum-apply-ufunc branch 3 times, most recently from 06ab9af to 2abf407 Compare July 1, 2026 06:16
Replace the previous numpy-scatter / dask-unstack split with a single kernel
(`_grouped_sum`) wrapped in `xarray.apply_ufunc`. It scatters terms into the
padded result arrays for numpy-backed data and runs the same scatter lazily on
chunked (dask) data via `dask="parallelized"`, after gathering the grouped and
term dimensions (the scatter's core dims) into single chunks. This removes the
last `pd.MultiIndex`/`unstack` usage in groupby-sum, drops the numpy-vs-dask
branch in `sum()`, and keeps peak memory at input + result on both backends.
Multi-key / DataFrame grouping and its `MultiIndex` result are unaffected — that
logic sits above the kernel.

Tests verify the kernel from first principles (each group's terms and constant
must match its members) across every case shape on both numpy and dask, plus
explicit anchors pinning the exact padded layout — member order, fill position,
term interleaving and the factor axis — for the linear, multidim and quadratic
cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@FBumann
FBumann force-pushed the perf/groupby-sum-apply-ufunc branch from 2abf407 to ac8ec47 Compare July 1, 2026 06:25
@FBumann

FBumann commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author

I ran the benchmark locally with pytets-benchmem to also check timing. Small improvement there too!

Note

The following content was generated by AI.

Local verification of the performance claim on the nodal_balance reference model from benchmarks/ (the groupby(bus).sum() idiom), comparing master (fe798b1, pre-#793) against this branch. Measured with pytest-benchmark + pytest-benchmem (--benchmark-memory).

test_build[nodal_balance] time: master → branch peak mem: master → branch
severity=0 10.24 → 9.78 ms (−5%) 1.43 → 1.30 MiB
severity=50 11.16 → 10.14 ms (−9%) 16.60 → 6.92 MiB (×2.4)
severity=100 11.70 → 10.39 ms (−13%) 31.82 → 12.72 MiB (×2.5)

Both peak memory (~2.4–2.5× lower) and build time (~5–13% faster) improve, and the gain grows with group skew — the pathological case the scatter targets. Consistent with the CodSpeed report (×2–3 memory on build/to_lp/to_solver).

Method

Each version's linopy selected via PYTHONPATH (master checked out in a throwaway worktree; branch from this checkout), same benchmarks/ suite, then:

benchmem compare bench_master.json bench_branch.json --columns time,peak --stat mean

A benchmem sweep linopy <master-ref> <branch-ref> --suite benchmarks/ --memory reproduces this in isolated uv venvs.

@FabianHofmann

Copy link
Copy Markdown
Collaborator

apply_ufunc is of course master class :) this implementation is quite complex and I would run it on pypsa tests first. I would say this should have priority over #793 and should directly go into master. @coroa is it realistic for you to take a look?

@coroa

coroa commented Jul 4, 2026

Copy link
Copy Markdown
Member

I'll put in on my stack, but no promises this week. I also think this is not time critical. The actual use of dask, is non-existent in my expectation.

@coroa coroa left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Cool, the core stuff is very good. I find the tests a bit bloated and have a few small comments.

Great work

Comment thread linopy/expressions.py
Comment thread linopy/expressions.py Outdated
Comment thread linopy/expressions.py Outdated
Comment thread linopy/expressions.py Outdated
Comment thread linopy/expressions.py Outdated
Comment thread linopy/expressions.py Outdated
Comment thread linopy/expressions.py Outdated
Comment thread linopy/expressions.py Outdated
Comment thread test/test_linear_expression.py
Comment thread test/test_linear_expression.py Outdated
Comment on lines +2217 to +2237
@pytest.mark.parametrize("backend", ["numpy", "dask"])
@pytest.mark.parametrize(
"build",
GROUPBY_SUM_CASES.values(),
ids=GROUPBY_SUM_CASES.keys(),
)
def test_grouped_sum_correct(
self,
build: Callable[[SimpleNamespace], tuple[LinearExpression, pd.Series]],
groupby_ctx: SimpleNamespace,
backend: str,
) -> None:
"""
Each group's terms and constant must match its members, from first
principles, on both numpy and dask backends. See ``GROUPBY_SUM_CASES``
for the structures covered.
"""
if backend == "dask":
pytest.importorskip("dask")
expr, groups = build(groupby_ctx)
_assert_grouped_sum_correct(expr, groups, chunked=backend == "dask")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Wouldn't the simpler pattern to compare fallback to scatter implementation, like the skewed one above, be the better comparison. Didn't check in detail what assert_grouped_sum_correct actually does, but it looks too complicated.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The fallback errors on quadratics (no _factor handling) and diverges on masked and auxiliary-coordinate cases, so it cannot validate the scatter kernel in general.

But we should split the cases up i think!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Wouldn't it be easier then to do a simpler test example for quadratics, and fix the divergences by re-ordering or whatever it would take?

I don't understand the test and where it would fail and find this concerning. I agree with large test coverage is good. With claude i often can't tell whether the tests actually test what we would want them to

@FBumann

FBumann commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

@coroa Im done here. About the bloated tests. I agree, but thought its better to over cover for now. If you agree, im open to removing some tests.

  1. test_grouped_sum_matches_fallback's dask backend
  2. test_exact_padded_layout
  3. test_chunked_runs_lazily
  4. test_skewed_unsorted_groups

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

I would keep the tests as they are. they cover some edge cases, but with the upcoming changes in v1 I prefer a high test coverage. feel free to pull in I'd say

@FBumann

FBumann commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator Author

@coroa I would merge this tomorrow morning if you have no objections to my latest changes.

Comment thread linopy/expressions.py
Comment thread linopy/expressions.py
Comment on lines -362 to +367
return self.map(func, **kwargs, shortcut=True)
return self.map(func)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@FabianHofmann Can you clarify what kwargs were meant to be passed here? Shortcuts was dead code, so fine to remove.

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.

yes, it was a no-op that was mainly there for mirroring the signature of the internal xarray groupby.map function. it was never explicitly used as I (guess I) wanted to tackle this later which never happened. so fine to remove. the kwargs seems to be dead anyway and setting it would have let to errors. so this is fixing it

Comment thread linopy/expressions.py Outdated
Comment thread test/test_linear_expression.py Outdated
Comment on lines +2217 to +2237
@pytest.mark.parametrize("backend", ["numpy", "dask"])
@pytest.mark.parametrize(
"build",
GROUPBY_SUM_CASES.values(),
ids=GROUPBY_SUM_CASES.keys(),
)
def test_grouped_sum_correct(
self,
build: Callable[[SimpleNamespace], tuple[LinearExpression, pd.Series]],
groupby_ctx: SimpleNamespace,
backend: str,
) -> None:
"""
Each group's terms and constant must match its members, from first
principles, on both numpy and dask backends. See ``GROUPBY_SUM_CASES``
for the structures covered.
"""
if backend == "dask":
pytest.importorskip("dask")
expr, groups = build(groupby_ctx)
_assert_grouped_sum_correct(expr, groups, chunked=backend == "dask")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Wouldn't it be easier then to do a simpler test example for quadratics, and fix the divergences by re-ordering or whatever it would take?

I don't understand the test and where it would fail and find this concerning. I agree with large test coverage is good. With claude i often can't tell whether the tests actually test what we would want them to

FBumann and others added 2 commits July 14, 2026 09:54
Drop the first-principles multiset oracle (_term_multisets,
_assert_grouped_sum_correct) and its all-cases sweep: it was hard to
review and gave no way to see, by hand, what it actually checked.

Cover the cases the old fallback engine cannot reproduce (masked
multi-dim vars, coords on the grouped dim, mixed quadratic + linear +
const) with concrete, hand-pinned layout tests instead, run on both the
numpy and the lazy dask kernel via a small _grouped_sum_on_backend
helper. test_grouped_sum_matches_fallback stays as the equivalence
guard for the cases the old engine does reproduce.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Read results through .vars/.coeffs/.const/.coords/.sizes instead of the
internal .data Dataset, and chunk with expr.chunk(...) rather than
reconstructing LinearExpression(expr.data.chunk(...)). Lazy dask results
realise when their values are read, so the manual .compute() round-trip
is gone too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@FBumann

FBumann commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator Author

@coroa I changed the testing from the generic but hard to read oracle to a few hand rolled cases. I dropped the oracle. We could also keep the oracle if you value the test surface, but I think its fine like this.

@FabianHofmann

Copy link
Copy Markdown
Collaborator

@FBumann I had a another view now. I think it's fine. let's pull this in

@coroa

coroa commented Jul 16, 2026

Copy link
Copy Markdown
Member

Sorry for not keeping up with review speed.

Yes, thanks, the hand-rolled cases are clearer to me.

Both: But, I would appreciate if you waited for me once i am reviewing things until i give the ok.

FabianHofmann added a commit that referenced this pull request Jul 21, 2026
* test: pin groupby-sum to preserve grouped-dim position (xfail)

groupby-sum moves the group dim to the trailing position instead of
keeping the grouped dim's slot, diverging from xarray's native
groupby-reduce and linopy <= 0.8.0 (regressed in #802). Existing tests
use assert_linequal, which aligns dims and misses the reorder.

Strict xfail: remove the marker once grouping preserves dim position.

* fix(groupby): preserve grouped-dim position in groupby-sum

groupby-sum moved the group dim to the trailing position; both the
apply_ufunc scatter kernel and the fallback append it there, and the
LinearExpression constructor's broadcast then locks that order.

Restore the grouped dim's original slot via _restore_group_dim_position:
transpose the terms and reorder the dimension coordinates so the
constructor's canonical dim order matches. MultiIndex group coords are
left untouched (multikey/observed results stay order-agnostic).

Drops the strict xfail; adds a release note.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* refactor(groupby): fold insertion flag into new_dims sentinel

* perf(groupby): make reordered terms C-contiguous to keep export cheap

The dim-position transpose left coeffs/vars as non-contiguous views, so
LP/solver export had to materialise a contiguous copy on ravel (CodSpeed
flagged ~4x export memory on the nodal_balance padding benchmark).

Force numpy-backed terms C-contiguous after the reorder so downstream
flattening ravels a view. ascontiguousarray is a no-op when no reorder
moved data; dask arrays are left lazy (chunks preserved).

* perf(groupby): scatter into target axis order in one contiguous alloc

Make _grouped_sum emit the grouped dimension in place directly: pass all
surviving dims as core dims so apply_ufunc's output order is fully chosen,
and build the padded array with the group axis in its target slot. The
result is contiguous in a single allocation, so the downstream reorder and
contiguity coercion become no-ops (no copy).

Removes the build-memory regression from the previous force-contiguous
approach: build and export peak now match master (~10MB / ~3MB) on
nodal_balance instead of 19MB / 3MB.

Trade-off: every dim is now a core dim, so dask inputs collapse to a
single chunk (no per-surviving-dim parallelism). Kept lazy.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* perf(groupby): sum const before term scatters to restore peak-memory parity

Inlining group_sum(data.const) into the Dataset literal made it evaluate
after the coeffs/vars scatters, stacking its nan-masking temporaries on
top of the retained padded arrays (+31% peak in expr_groupby_sum).
Reducing const first frees them before the padded arrays exist, restoring
the exact peak of master (verified with memray: 171 KB vs 275 KB).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(groupby): keep dim position when the grouper reuses the grouped dim's name

Restoring the group dim's position inferred the consumed dims as those
absent from the result, so a grouper named like the dim it groups (e.g.
relabelling 'name' to a coarser 'name') shadowed the consumed dim, the
restore early-returned and the dim drifted to the trailing position on
both paths. Pass the grouper's own dims (DataArray/IndexVariable dims,
Series/DataFrame index name) through _grouper_dims instead; unknown
grouper types (grouper mappings, multi-key lists under use_fallback)
keep the inference.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: FBumann <117816358+FBumann@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
FabianHofmann added a commit that referenced this pull request Jul 24, 2026
Trace each previously unreferenced entry in the Upcoming Version
section to its introducing PR and append the linked short-ref,
so every bullet is traceable (#718, #566, #780, #783, #790,
#801, #802, #824, #860).
FabianHofmann added a commit that referenced this pull request Jul 24, 2026
* docs: harmonize PR/issue refs in release notes

Convert bare GitHub URLs and mixed RST link forms to a single
anonymous linked short-ref form (`#NNN <url>`__) across the Upcoming
and 0.8.0 sections, linkify plain #580 mentions, and fix a stray
Markdown link in the 0.1.4 notes.

* docs: add PR refs to remaining upcoming release-note entries

Trace each previously unreferenced entry in the Upcoming Version
section to its introducing PR and append the linked short-ref,
so every bullet is traceable (#718, #566, #780, #783, #790,
#801, #802, #824, #860).
FBumann added a commit to fluxopt/fluxopt that referenced this pull request Jul 31, 2026
> [!NOTE]
> This PR was prepared by AI (Claude Code) at the maintainer's request.

Manual equivalent of the monthly Dependabot bump for
`benchmark/uv.lock`: `linopy` 0.8.0 → 0.9.0.

#269 pinned `linopy==0.9.0` at the repo root, but the benchmark env is a
separate project resolved from its own committed lock with `uv sync
--frozen` — which never re-reads the root requirement. CodSpeed
therefore ran **0.8.0 on both sides** of that PR's comparison, which is
why it reported "will not alter performance". This bump is what actually
puts 0.9.0 in front of the instruments.

linopy 0.9.0 carries several changes we should feel — we use
`groupby().sum()` in `src/fluxopt/constraints/sparse.py` and
`src/fluxopt/model.py`:

- PyPSA/linopy#802 — `perf(groupby)`: unified scatter kernel over numpy
and dask via `apply_ufunc`
- PyPSA/linopy#566 — int32 label arrays (~25% memory reduction)
- PyPSA/linopy#815, PyPSA/linopy#816 — drop/prune explicit zeros from
the constraint matrix

Labelled `trigger:benchmark` so the walltime job runs alongside memory —
this is a dependency step change on the dashboard baseline, so it is
worth having both instruments record it on the same commit.

<details>
<summary>Local verification</summary>

```
$ cd benchmark && uv lock --upgrade-package linopy
Resolved 59 packages in 574ms
Updated linopy v0.8.0 -> v0.9.0

$ uv sync --frozen && uv run --no-sync python -c "import linopy; print(linopy.__version__)"
0.9.0

$ uv run --no-sync pytest . --benchmark-disable -q
41 passed, 185 warnings in 22.51s
```

</details>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

3 participants