Skip to content

Add fast sort!/sort for SparseMatrixCSC - #784

Open
ViralBShah wants to merge 2 commits into
mainfrom
fast-sparse-matrix-sort
Open

Add fast sort!/sort for SparseMatrixCSC#784
ViralBShah wants to merge 2 commits into
mainfrom
fast-sparse-matrix-sort

Conversation

@ViralBShah

Copy link
Copy Markdown
Member

Fixes #302 (the matrix half; #303 already covered vectors).

The problem

There is no sort! method for SparseMatrixCSC, so sort!(A; dims) falls back to the generic AbstractArray path, which reads and writes slices through getindex/setindex!. That densifies the structure and is much slower than sorting the equivalent dense matrix — the benchmark from the issue:

dense          747.921 μs (3 allocations: 1.66 KiB)
sparse k=.03   3.763 ms (12 allocations: 249.07 KiB)
sparse k=.003  923.806 μs (14 allocations: 62.23 KiB)
dense          766.291 μs (4 allocations: 3.25 KiB)
sparse k=.03   976.657 μs (812 allocations: 196.37 KiB)
sparse k=.003  375.270 μs (808 allocations: 104.07 KiB)

The fix

Sorting a column only permutes its stored entries. Under any ordering, all the structural zeros compare equal, so they stay contiguous: the stored values that sort before zero(eltype(A)) end up at the top of the column, the rest at the bottom, and the zeros fill the gap. A column can therefore be sorted in place by sorting its stored values and rewriting its row indices — exactly the trick sort! already uses for sparse vectors in #303.

dims = 1 does that directly. dims = 2 sorts the columns of a transposed copy and transposes the result back, so it stays O(nnz + m + n) and reuses the existing halfperm! machinery.

Same benchmark after:

dense          751.151 μs (3 allocations: 1.66 KiB)
sparse k=.03   12.780 μs (2 allocations: 1.62 KiB)
sparse k=.003  1.511 μs (2 allocations: 1.62 KiB)
dense          763.242 μs (4 allocations: 3.25 KiB)
sparse k=.03   19.071 μs (10 allocations: 20.02 KiB)
sparse k=.003  4.110 μs (8 allocations: 4.62 KiB)

That is 294x / 612x for dims = 1 and 51x / 91x for dims = 2, and sparse is now well ahead of dense rather than behind it.

Two behaviour changes worth calling out

  • nnz is preserved instead of growing. The old path stored the zeros it wrote into the slices, so sort! on a sparse matrix used to (partially) densify it, the same way the vector case did before Optimize and improve dispatch to sort* #303.
  • sort(A; dims = 1) now returns a SparseMatrixCSC. The generic fallback went through A[:]/reshape and returned a Base.ReshapedArray{Float64, 2, SparseVector{Float64, Int64}, Tuple{}}. dims = 2 already returned a SparseMatrixCSC via permutedims.

searchsortedfirst_discard_keywords moved from sparsevector.jl to sparsematrix.jl (which is included first) since both sorting implementations use it; it is unchanged otherwise.

Tests

Added a sort/sort! testset in test/sparsematrix_ops.jl covering both dims against dense references across sizes (including empty and single row/column), densities, and keyword combinations (rev, by, lt, alg, scratch), plus index-type preservation, nnz preservation, explicitly stored zeros, and the error paths for a bad or missing dims. Aqua reports the same ambiguity/piracy/unbound counts as main.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PddHRZV2UXjpTPRkoQoM8j

The generic `Base.sort!(A; dims)` fallback sorts a sparse matrix through
`getindex`/`setindex!` on slices, which densifies the structure and is
orders of magnitude slower than sorting the equivalent dense matrix.

Sorting a column only permutes its stored entries: the values sorting
before `zero(eltype(A))` end up at the top of the column, the rest at the
bottom, and the structural zeros stay contiguous in between. So each
column can be sorted in place by sorting its stored values and rewriting
its row indices, exactly as `sort!` already does for sparse vectors
(#303). Rows are sorted the same way, on a transposed copy that is
transposed back afterwards.

`nnz` is now preserved instead of growing, and `sort` returns a
`SparseMatrixCSC` for `dims = 1` rather than a `ReshapedArray` wrapping a
`SparseVector`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PddHRZV2UXjpTPRkoQoM8j
@codecov

codecov Bot commented Sep 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 92.09%. Comparing base (12c198f) to head (d2200ce).
⚠️ Report is 4 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #784      +/-   ##
==========================================
- Coverage   92.32%   92.09%   -0.24%     
==========================================
  Files          12       12              
  Lines        8377     8410      +33     
==========================================
+ Hits         7734     7745      +11     
- Misses        643      665      +22     

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

`Base.sort` on a zero-size dense matrix throws `ArgumentError: step cannot
be zero`, so the size-parametrised testset compares those cases against
the input rather than against a dense reference. Pin down what the sparse
methods actually do there instead of leaving it implicit: 0xn, mx0 and
0x0 round-trip unchanged with the right size, element/index types and
`nnz == 0`, and structurally empty matrices of non-zero size still match
dense and leave the column pointers alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PddHRZV2UXjpTPRkoQoM8j
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.

sort! is very slow for SparseMatrixCSC

1 participant