Skip to content

Opt-in sparse results for reductions along a dimension; reduce column-range views through the sparse kernels - #798

Open
ViralBShah wants to merge 3 commits into
mainfrom
vs/sparse-reductions
Open

ViralBShah wants to merge 3 commits into
mainfrom
vs/sparse-reductions

Conversation

@ViralBShah

@ViralBShah ViralBShah commented Sep 9, 2026

Copy link
Copy Markdown
Member

Fixes #43. Fixes #377.

sum(A; dims) and the other reductions along a dimension of a SparseMatrixCSC keep returning a dense Matrix, as they do today: the result has one dimension fewer and is usually dense, and downstream code expects it that way (see the discussion below). The sparse result asked for in #43 is opt-in by reducing into a sparse destination:

sum!(spzeros(size(A, 1), 1), A)                    # row sums, stored only for rows of A that store something
Base.mapreducedim!(abs2, +, spzeros(1, size(A, 2)), A)

This works for sum!, prod!, maximum!, minimum!, count!, any!, all! and Base.mapreducedim!. The result stores an entry only for the rows or columns that store one themselves, unless the reduction of a structurally empty slice is nonzero (as for mapreduce(x -> x + 1, +, A; dims)), in which case it is fully stored. A destination that stores only zeros, as a reused sum! destination does after its fill!, folds like an empty one, so preallocating once and calling sum! repeatedly stays on the fast path. The manual gets a short paragraph on this.

How. A _mapreducedim! for a sparse destination reduces a fully stored one as the dense array its values form, fills an empty one without touching the slices that store nothing, and sends anything in between through the existing element-wise kernel. Row reductions build the 1 x n result column by column. Column reductions use the result's value vector as a dense workspace compressed in place when there are enough stored entries, and otherwise sort the stored entries by row so that only rows storing something are visited.

Measured on the 1.14-DEV build, min over BenchmarkTools samples:

case dense default opt-in
sum(A; dims=2), 10^7 x 150, 151 entries 15 ms, 152 MiB sum!(spzeros(10^7, 1), A): 2.7 µs, 14.6 KiB
same, reused destination sum!(R, A) 2.3 µs, 2.5 KiB
sum(B; dims=1), 10^4 x 10^4, 1e-2 0.11 ms
sum(B; dims=2), 10^4 x 10^4, 1e-2 1.07 ms sum!(spzeros(10^4, 1), B): 1.08 ms

Views of a column range (fixes #377). sum(view(A, :, j:k); dims) and the other reductions of such a view went through Base's element-wise fallback, indexing the parent once per element, and returned a sparse result because Base's similar on a SubArray defers to the sparse parent. The kernels above only need the column pointers, row indices and values, which a SparseMatrixCSCView already exposes off the parent's storage, so they now accept SparseMatrixCSCUnion, and reducedim_initarray covers the view so it returns the same dense Matrix as its copy. nnz of such a view is O(1) instead of a pass over its columns. The issue's example, 101 x 99 at density 0.01, columns 5:11:

case copy view
sum(x; dims=1) 80 ns 88 ns
sum(x; dims=2) 113 ns 190 ns
maximum(x; dims=2) 680 ns 700 ns
sum(x) 5 ns 6 ns
count(>(0.5), x) 3 ns 6 ns

Tests compare every reduction against the dense result over shapes, densities and dims = 1, 2, (1, 2), 3, for the matrix, its column-range view (same type and values as the copy) and into sparse destinations; check the stored pattern, empty dimensions, the hypersparse path (an allocation bound on a 10^6-row matrix with a reused destination), and sum!/mapreducedim! into dense, empty, partially and fully stored destinations. The #27836 test asserts a Matrix again. The full suite passes locally on 1.14-DEV.

A local sweep over the exported in-place reductions, shapes, densities, index types, stored and negative zeros, column-range views and reused destinations found the opt-in results agreeing with dense up to summation order, and the default results matching dense in type and value. Two pre-existing corners it hit are left alone: fill!(spzeros(0, n), x) with x != 0 throws "step cannot be zero" in _fillnonzero!, which prod! or all! into a 0-row sparse destination reach, and minimum(spzeros(3, 0); dims = 1) returns a sparse 1 x 0 result through Base's map over an empty view.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DGaTC2P39YrE2aAU5es55x

…atrix

Fixes #43. `sum(A; dims)` and the other dimensional reductions of a
`SparseMatrixCSC` returned a dense `Matrix`, which for a hypersparse
matrix costs O(size) time and memory for a result with a handful of
entries. They now return a `SparseMatrixCSC` of the reduced shape that
stores an entry only for the rows or columns that store one themselves,
unless the reduction of a structurally empty slice is nonzero, in which
case the result is fully stored. Results without a `zero`, such as the
tuples of `extrema`, stay dense.

`reducedim_initarray` provides a structurally empty destination when the
initial value is zero and a fully stored one otherwise. A new
`_mapreducedim!` for a sparse destination reduces a fully stored one as
the dense array its values form, fills an empty one without touching the
slices that store nothing, and sends anything in between through the
element-wise kernel. Row reductions build the `1 x n` result column by
column. Column reductions use the result's value vector as a dense
workspace compressed in place when there are enough stored entries, and
otherwise sort the stored entries by row so that only rows storing
something are visited.

Measured on nightly against main, min of 9: `sum(A; dims=2)` for a
10^7 x 150 matrix with 151 entries goes from 6.5 ms and 156 MiB to
2.3 µs and 16 KiB; for 10^4 x 10^4 at 1e-3 and 1e-2 density the
reductions are within noise, with the result's extra index vectors as
the only added allocation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6EdE4F3CCGE3vxr8gQYKf
@codecov

codecov Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 92.82%. Comparing base (59bbb55) to head (567bfdf).
⚠️ Report is 24 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #798      +/-   ##
==========================================
+ Coverage   92.53%   92.82%   +0.28%     
==========================================
  Files          12       12              
  Lines        8404     8702     +298     
==========================================
+ Hits         7777     8078     +301     
+ Misses        627      624       -3     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@ViralBShah
ViralBShah marked this pull request as draft September 9, 2026 14:50
@ViralBShah
ViralBShah marked this pull request as ready for review September 10, 2026 09:25
Fixes #377.

`sum(view(A, :, j:k); dims)` and the other reductions of a column-range
view went through Base's element-wise fallback, indexing the parent once
per element. The reduction kernels only need the column pointers, row
indices and values, which a `SparseMatrixCSCView` already exposes off
the parent's storage, so they now accept `SparseMatrixCSCUnion`. The two
fully-stored fast paths index through the column pointers rather than
assuming the values start at one, and the hypersparse column reduction
sorts the view's stored range. `nnz` of such a view is now O(1).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DzbyurS9gH2pdqWJrWaPxD
@rasmushenningsson

Copy link
Copy Markdown

I'm coming from a field where the sparse matrices are not hyper-sparse and this obviously feels like a strange default to me.

The only case where I see a big benefit of the proposal is when you have many more rows than columns, very low density, and reduce with dims=2. In all other cases a dense output is the natural choice because when we lose when dimension, we have orders of magnitude fewer elements, but the fraction of non-zeros easily goes to 1. And for downstream operations, you don't want to end up with sparse arrays used as input when the data is dense.

Instead, the natural choice to me would be to opt-in to this behavior, because in a limited number of situations, it is super useful.

@ViralBShah

Copy link
Copy Markdown
Member Author

Agree - and the CSC storage format is not a good fit for hypersparse anyways.

Reductions along a dimension of a sparse matrix return a dense `Matrix` again, as
before this PR and as for dense input: the result has one dimension fewer and is
usually dense, and downstream code expects it dense. The sparse result is now
opt-in by reducing into a sparse destination, `sum!(spzeros(size(A, 1), 1), A)` or
`Base.mapreducedim!` and the other in-place reductions, which keeps the hypersparse
kernels and their cost proportional to the stored entries plus the length of the
result. A destination that stores only zeros, as a reused `sum!` destination does
after its `fill!`, folds like an empty one so that reuse stays on the fast path.
Column-range views keep reducing off the parent's storage and now return the same
dense result as their copy, where Base's `similar` gave them a sparse one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DGaTC2P39YrE2aAU5es55x
@ViralBShah ViralBShah changed the title Return sparse results from reductions along a dimension of a sparse matrix Opt-in sparse results for reductions along a dimension; reduce column-range views through the sparse kernels Sep 11, 2026
@ViralBShah

Copy link
Copy Markdown
Member Author

Made the sparse result opt-in, as suggested. sum(A; dims) and friends return a dense Matrix again. To get the sparse result, reduce into a sparse destination: sum!(spzeros(size(A, 1), 1), A), or Base.mapreducedim! and the other in-place reductions with a sparse destination. That keeps the hypersparse kernels for the cases where they pay off (the 10^7-row example goes from 15 ms and 152 MiB to 2.7 µs) without changing the default anyone relies on. Column-range views now return the same dense result as their copy, where Base's similar gave them a sparse one before. Description updated.

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.

New method for sum for SubArray of SparseMatrix sum(sparse) -> dense?

2 participants