Specialize Diagonal products and dot for adjoint/transpose of sparse matrices - #770
Open
ViralBShah wants to merge 5 commits into
Open
ViralBShah wants to merge 5 commits into
ViralBShah wants to merge 5 commits into
Conversation
…rse matrices Fixes #619: `A' * D` and `D * A'` for a sparse `A` and `Diagonal` `D` fell through to the generic `AbstractMatrix` product, ~300x slower than `A * D` on Julia 1.11. Materialize the adjoint (O(nnz)) and reuse the existing CSC-times-Diagonal kernels, mirroring how `A' * B` is handled for sparse `B`. Fixes #627: `dot(A', B)` for sparse `A`, `B` walked the stored entries of `B` and did a binary search into `A'` for each, ~50x slower than `dot(copy(A'), B)` on Julia 1.11. Add a merge that walks the columns of `B` in order while keeping one cursor per column of `parent(A)`, so it runs in O(nnz(A) + nnz(B) + n) time with O(n) extra memory and no O(nnz) temporary. `dot(B, A')` reaches the same kernel through the existing `conj(dot(A', B))`. Tests cover both wrappers, real and complex eltypes, mixed eltypes, stored zeros, empty columns, non-square shapes, dimension errors, and timing guards. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LgBHUw9Hp7YW5ub29B4R5y
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #770 +/- ##
==========================================
+ Coverage 92.60% 92.62% +0.01%
==========================================
Files 12 12
Lines 8630 8693 +63
==========================================
+ Hits 7992 8052 +60
- Misses 638 641 +3 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Main split the `dot` tests out of test/linalg.jl into test/linalg_products.jl, so the new adjoint/transpose `dot` tests move there; the `Diagonal` product tests stay in test/linalg.jl. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6EdE4F3CCGE3vxr8gQYKf
Contributor
There was a problem hiding this comment.
🟢 Approval recommended
The implementations are consistent with existing sparse kernels and comprehensively tested.
Pull request overview
Adds sparse specializations for lazy adjoint/transpose operations, resolving performance regressions in diagonal multiplication and Frobenius dot products.
Changes:
- Materializes sparse wrappers before diagonal scaling.
- Implements a linear-time cursor-based sparse
dotkernel. - Adds correctness, edge-case, and performance tests.
File summaries
| File | Description |
|---|---|
src/linalg.jl |
Adds specialized multiplication and dot methods. |
test/linalg.jl |
Tests diagonal products and performance. |
test/linalg_products.jl |
Tests sparse wrapper dot products and edge cases. |
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 0
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…iagonal` The `*` overloads only patched the symptom: LinearAlgebra's `Diagonal` kernel visits every element of the destination, so `mul!(C, S', D)` and the 5-argument forms stayed O(m*n). Replace the overloads with 5-argument `mul!` methods that form the adjoint directly in `C` with one `halfperm!` and scale it in place, falling back to a materialized copy when `beta` is nonzero or when `C` aliases the parent, is fixed, or has another index type. `*` reaches these through `matop_dest`, which now hands the adjoint of a sparse matrix an empty writable destination and a fixed sparse matrix a fixed destination with its own structure, so `D * F` and `D * F'` for a fixed `F` no longer fail writing read-only indices. `dot(A', B)` now walks the sparser operand and keeps cursors over the columns of the other, so it is no longer 6-30x slower than `dot(copy(A'), B)` when `B` is much denser than `parent(A)`; it returns early when either operand has no stored entries and uses a binary search per entry instead of the cursor array when the columns outnumber the stored entries, so a huge nearly empty operand no longer costs O(n) time and memory. Tests: the wall-clock guards are replaced by a multiplication-counting eltype (as in #781): the kernels perform exactly `nnz` multiplications, and `dot` only multiplies where both operands store an entry, from either side of the walk. Two `dot` assertions that exercised the pre-existing dense wrapper path are dropped. Fixed operands, the 3- and 5-argument `mul!` forms, an aliased or differently indexed destination, and the no-cursor path are covered. Measured on nightly, min of 7: `mul!(C, S', D)` 220 ms -> 75 µs at n=4000, 1.8 s -> 370 µs and 3.3 s -> 2.6 ms at n=10^4; `*` unchanged; `dot(P', B)` with nnz(P)=2e3 and nnz(B)=1e7: 17.9 ms -> 0.38 ms; with two nearly empty 10^7-row operands: 1.5 ms / 78 MiB -> 0 / 0. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6EdE4F3CCGE3vxr8gQYKf
ViralBShah
marked this pull request as draft
September 9, 2026 13:09
ViralBShah
marked this pull request as ready for review
September 10, 2026 09:25
…ias check by storage The cursor walk in `dot(A', B)` visits every stored entry of the operand it keeps cursors over, so it lost to the previous binary search once that operand was much denser: `dot(Bd', P)` with nnz(P)=2e3 and nnz(Bd)=5e5 took 138 µs against 26 µs on main. Switch to a binary search per entry when the other operand holds over 32x more entries or columns (measured crossover 20-50x); `dot(P', Bd)` drops from 139 µs to 24 µs and `dot(Bd', P)` is back at 26 µs. `_adjtrans_direct` compared the destination with the parent by identity, so a destination built on the parent's `nonzeros` array was transposed in place and came out wrong, where the existing CSC kernel handles it. Use `Base.mightalias`. Share the entry functions of `copy(::Adjoint)`/`copy(::Transpose)` with the `mul!` kernels instead of repeating the lambdas. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VF52nADauBDqAQaUHjoNV
ViralBShah
added this pull request to stack #815
September 11, 2026 10:39
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #619 and #627. Both are cases where a lazy
Adjoint/Transposeof aSparseMatrixCSCfell through to a generic method that ignores sparsity.#619:
mul!of an adjoint/transpose of a sparse matrix with aDiagonalLinearAlgebra's
Diagonalkernel visits every element of the destination, somul!(C, S', D),mul!(C, D, S')and their 5-argument forms were O(m·n). New 5-argumentmul!methods form the adjoint directly inCwith onehalfperm!and scale it in place, falling back to a materialized copy whenbetais nonzero or whenCshares storage with the parent, is fixed, or has another index type.*reaches these throughmatop_dest, which now hands the adjoint of a sparse matrix an empty writable destination (with a size hint) and a fixed sparse matrix a fixed destination with its own structure. As a resultD * FandF' * Dfor a fixedFno longer fail with "Can't change ReadOnly";F * DandD * F'already worked.On nightly (
N=1000, density 0.1, min of 7):mul!(C, A', D)mul!(C, D, A')mul!(C, A', D, 2, 3)A' * D,D * A'*is unchanged on nightly, where the generic path already materializes the adjoint; the explicit methods make it independent of LinearAlgebra internals.#627:
dot(A', B)for sparseA,BThe existing wrapper method walks the stored entries of
Band does a binary search intoA'for each one. The new method walks the sparser operand and keeps one cursor per column of the other, so it runs in O(nnz(A) + nnz(B) + n) time with O(n) extra memory and no O(nnz) temporary. Since the cursors sweep every stored entry of the other operand, it switches to a binary search per entry once the other operand holds over 32x more entries (or columns) than the walked one; the measured crossover is a ratio of 20-50. It returns early when either operand has no stored entries, so two nearly empty operands with 10^7 rows cost nothing.dot(B, A')reaches the same kernel through the existingconj(dot(A', B)).N=1000, min of 7)dot(A', B), both density 0.1dot(copy(A'), B)dot(P', Bd), nnz(P)=2e3, nnz(Bd)=5e5dot(Bd', P)Tests
Wall-clock guards are replaced by a multiplication-counting eltype (
test/util/mulcount.jl): the kernels perform exactlynnzmultiplications, anddotonly multiplies where both operands store an entry, from either side of the walk. Also covered: fixed operands, the 3- and 5-argumentmul!forms, a destination that aliases or shares storage with the parent, another index type, bothdotbranches, stored zeros, empty columns, non-square shapes and dimension errors.Test.detect_ambiguities(SparseArrays; recursive=true)is empty.🤖 Generated with Claude Code
https://claude.ai/code/session_015VF52nADauBDqAQaUHjoNV