Opt-in sparse results for reductions along a dimension; reduce column-range views through the sparse kernels - #798
ViralBShah wants to merge 3 commits into
Conversation
…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 Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
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
|
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 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. |
|
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
|
Made the sparse result opt-in, as suggested. |
Fixes #43. Fixes #377.
sum(A; dims)and the other reductions along a dimension of aSparseMatrixCSCkeep returning a denseMatrix, 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:This works for
sum!,prod!,maximum!,minimum!,count!,any!,all!andBase.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 formapreduce(x -> x + 1, +, A; dims)), in which case it is fully stored. A destination that stores only zeros, as a reusedsum!destination does after itsfill!, folds like an empty one, so preallocating once and callingsum!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 the1 x nresult 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:
sum(A; dims=2), 10^7 x 150, 151 entriessum!(spzeros(10^7, 1), A): 2.7 µs, 14.6 KiBsum!(R, A)sum(B; dims=1), 10^4 x 10^4, 1e-2sum(B; dims=2), 10^4 x 10^4, 1e-2sum!(spzeros(10^4, 1), B): 1.08 msViews 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'ssimilaron aSubArraydefers to the sparse parent. The kernels above only need the column pointers, row indices and values, which aSparseMatrixCSCViewalready exposes off the parent's storage, so they now acceptSparseMatrixCSCUnion, andreducedim_initarraycovers the view so it returns the same denseMatrixas its copy.nnzof 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:sum(x; dims=1)sum(x; dims=2)maximum(x; dims=2)sum(x)count(>(0.5), x)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), andsum!/mapreducedim!into dense, empty, partially and fully stored destinations. The#27836test asserts aMatrixagain. 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)withx != 0throws "step cannot be zero" in_fillnonzero!, whichprod!orall!into a 0-row sparse destination reach, andminimum(spzeros(3, 0); dims = 1)returns a sparse1 x 0result through Base'smapover an empty view.🤖 Generated with Claude Code
https://claude.ai/code/session_01DGaTC2P39YrE2aAU5es55x