Skip to content

Specialize Diagonal products and dot for adjoint/transpose of sparse matrices - #770

Open
ViralBShah wants to merge 5 commits into
mainfrom
vs/adjoint-diagonal-dot
Open

ViralBShah wants to merge 5 commits into
mainfrom
vs/adjoint-diagonal-dot

Conversation

@ViralBShah

@ViralBShah ViralBShah commented Sep 8, 2026

Copy link
Copy Markdown
Member

Fixes #619 and #627. Both are cases where a lazy Adjoint/Transpose of a SparseMatrixCSC fell through to a generic method that ignores sparsity.

#619: mul! of an adjoint/transpose of a sparse matrix with a Diagonal

LinearAlgebra's Diagonal kernel visits every element of the destination, so mul!(C, S', D), mul!(C, D, S') and their 5-argument forms were O(m·n). New 5-argument mul! methods 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 shares storage with 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 (with a size hint) and a fixed sparse matrix a fixed destination with its own structure. As a result D * F and F' * D for a fixed F no longer fail with "Can't change ReadOnly"; F * D and D * F' already worked.

On nightly (N=1000, density 0.1, min of 7):

Call main this PR
mul!(C, A', D) 43.8 ms 0.19 ms
mul!(C, D, A') 44.7 ms 0.23 ms
mul!(C, A', D, 2, 3) 49.6 ms 0.55 ms
A' * D, D * A' ~0.5 ms ~0.5 ms

* 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 sparse A, B

The existing wrapper method walks the stored entries of B and does a binary search into A' 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 existing conj(dot(A', B)).

Call (N=1000, min of 7) main this PR
dot(A', B), both density 0.1 2.27 ms 1.06 ms
dot(copy(A'), B) 1.1-1.5 ms unchanged
dot(P', Bd), nnz(P)=2e3, nnz(Bd)=5e5 4.4 ms 24 µs
dot(Bd', P) 26 µs 26 µs

Tests

Wall-clock guards are replaced by a multiplication-counting eltype (test/util/mulcount.jl): the kernels perform exactly nnz multiplications, and dot only multiplies where both operands store an entry, from either side of the walk. Also covered: fixed operands, the 3- and 5-argument mul! forms, a destination that aliases or shares storage with the parent, another index type, both dot branches, 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

…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

codecov Bot commented Sep 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.18750% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.62%. Comparing base (dcfeb16) to head (494fdf3).

Files with missing lines Patch % Lines
src/linalg.jl 91.93% 5 Missing ⚠️
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.
📢 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.

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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 dot kernel.
  • 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
ViralBShah marked this pull request as draft September 9, 2026 13:09
@ViralBShah
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
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.

Missing specialization of adjoint sparse matrix times Diagonal

2 participants