Skip to content

feat: accept sparse (pydata/sparse-backed) operands in @ without densifying - #1119

Merged
FabianHofmann merged 5 commits into
masterfrom
feat/sparse-matmul-operand
Nov 4, 2026
Merged

FabianHofmann merged 5 commits into
masterfrom
feat/sparse-matmul-operand

Conversation

@FabianHofmann

Copy link
Copy Markdown
Collaborator

Closes # (if applicable).

Changes proposed in this Pull Request

Note

The following content was generated by AI.

Variable @ C, LinearExpression @ C and .dot(C) already contract a constant as one sparse product, but _matmul_operand_to_matrix densified the constant first (.values then csr_array(...)). A link-by-bus incidence of a PyPSA sector network (6830 x 2164) is about 118 MB dense per port, so PyPSA could not use @.

This PR lets C be a labelled xarray.DataArray whose data is a pydata/sparse array (sparse.COO, also GCXS/DOK via sparse.as_coo) with fill value 0:

  • linopy/alignment.py: is_sparse_backed (checks sys.modules, never imports sparse); _matmul_operand_to_matrix transposes and reshapes the sparse array and builds the CSR matrix with coo.to_scipy_sparse(). The NaN check reads coo.fill_value and coo.data, runs before the label checks like the dense path, and raises the same §5 error. The §8 shared-dim label checks and the §11 aux-coord checks are unchanged, since they only look at coordinates. A non-zero fill value raises a ValueError, because every implicit entry would become a coefficient.
  • Dense fallback: if _sparse_matmul returns a Reason (no shared dim, zero-dim expression, non-unique labels, coordinates the grid cannot hold), and also for QuadraticExpression.__matmul__, the operand is densified through densify_sparse_operand. This emits PerformanceWarning("Sparse (pydata/sparse) operand densified: <reason>."). Without it, pydata/sparse would raise its own RuntimeError deep inside xarray.
  • Docstrings of LinearExpression.__matmul__, BaseExpression.dot, Variable.__matmul__ and Variable.dot. A release note under 1.0.0 Features.
  • sparse added to the dev extra only. It stays an optional dependency, and the new tests use pytest.importorskip("sparse").

Open design questions

  1. Bare scipy.sparse matrices are not accepted (they still raise TypeError as before). They are unlabelled, so they would have to pair axes by size like numpy (v1 convention: pair unlabeled operands with dimensions by size, raise on ambiguity #736). That works only in 2-D, and it is ambiguous when the contracted and new dims have the same size. The new dim would also get no labels, which takes the "unlabelled output dim" fallback and densifies anyway. Wrapping in xr.DataArray(sparse.COO.from_scipy_sparse(m), coords=...) is explicit and cheap. Should we accept scipy matrices anyway?
  2. Warning default: the operand-densify warning is on by default, also in a dense model, and only warn_on_densify=False turns it off. This differs from the CSR densify notice, which defaults to model.sparse. The reasoning is that a user who passes a sparse array wants it kept sparse. Should it follow the same model.sparse default instead, or should it raise?
  3. Scope: only @/dot are covered. expr * C_sparse and other arithmetic with a sparse-backed DataArray still fail with pydata/sparse's RuntimeError ("Cannot convert a sparse array to dense automatically"). Should that be a follow-up?
  4. A non-zero fill value raises instead of densifying with a warning. Is that the right call?
Tests and checks

New test/test_sparse_operand.py (43 tests, skipped without sparse):

  • A sparse operand gives the same result as the dense one (assert_equivalent) for Variable and LinearExpression receivers, in dense and sparse models. Cases: one contracted dim, two contracted dims, two contracted dims plus a new dim, one contracted dim plus two new dims. Same for .dot. No PerformanceWarning is emitted, and the CSR backing follows model.sparse.
  • Aux coords on kept dims are kept, and GCXS is accepted.
  • Label mismatch, reordered labels and aux conflicts give the same error message as the dense operand.
  • A stored NaN or a NaN fill value gives the same error as dense. A non-zero fill value raises.
  • Fallback cases (no shared dim, zero-dim expression, non-unique labels, quadratic) densify with a PerformanceWarning and match dense. The warning can be silenced.
  • A 5000 x 3000 operand with 5000 non-zeros runs with sparse.COO.todense patched to raise. Peak traced memory is under a tenth of the 120 MB dense array, runtime is under 10 s, and the coefficient sum is correct.

Results:

  • pytest test -q: 5300 passed, 117 skipped
  • ruff check --fix .: all checks passed
  • ruff format .: unchanged
  • mypy linopy: no issues in 46 source files

Checklist

  • AI-generated content is marked (see AGENTS.md).
  • Code changes are sufficiently documented; i.e. new functions contain docstrings and further explanations may be given in doc.
  • Unit tests for new features were added (if applicable).
  • A note for the release notes doc/release_notes.rst of the upcoming release is included.
  • I consent to the release of this PR's code under the MIT license.

Generated by Claude Code

…ifying

A labelled xarray.DataArray whose data is a pydata/sparse array (e.g.
sparse.COO, fill value 0) now goes through the existing sparse
contraction of LinearExpression.__matmul__ / Variable.__matmul__ / dot
without being densified: it is transposed and reshaped as a sparse array
and handed to scipy via to_scipy_sparse(). Label checks (§8), aux coords
(§11) and NaN handling (§5, stored or as fill value) match the dense
operand; a non-zero fill value raises.

Where the sparse product does not apply (and for QuadraticExpression),
the operand is densified with a PerformanceWarning, silenced by
options["warn_on_densify"] = False.

sparse stays optional: it is never imported unless the operand is a
sparse array, it is added to the dev extra, and the tests skip without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F4nx8GJ8mSNvkTagja9dMj
@codspeed

codspeed Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 181 untouched benchmarks
⏩ 181 skipped benchmarks1


Comparing feat/sparse-matmul-operand (449b900) with master (cfaea93)

Open in CodSpeed

Footnotes

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

claude and others added 3 commits November 3, 2026 14:25
…pact helpers and tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F4nx8KhK8QzSzHhpYKvcSx
A pydata/sparse operand is always passed on purpose, so its densify
fallback warns under the default warn_on_densify=None in a dense model
too, unlike the CSR densify notice; only warn_on_densify=False silences
it. Tests cover the default, True and False settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F4nx8JJokf4NAtRpcKAuov
@FabianHofmann
FabianHofmann marked this pull request as ready for review November 4, 2026 07:38
@FabianHofmann
FabianHofmann merged commit d5d20fe into master Nov 4, 2026
23 checks passed
@FabianHofmann
FabianHofmann deleted the feat/sparse-matmul-operand branch November 4, 2026 08:55
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