Make sparsity fills output-driven in block left-multiply and csr@csc matmul#102
Make sparsity fills output-driven in block left-multiply and csr@csc matmul#102Transurgeon wants to merge 3 commits into
Conversation
…matmul block_left_multiply_fill_sparsity tested every row of A against each (column, block) via has_overlap — O(n_vars x n_blocks x A_rows) at Jacobian-init time. Gather instead: build a CSC view of A once, walk only the columns hit by the block's child entries, dedup rows with a generation-stamp workspace and sort ascending. Output is byte-identical (same predicate, same order), so fill_values is untouched. csr_csc_matmul_alloc had the same full row-times-column scan; same treatment via a one-time CSR view of B. Tests: dedup and interleaved-order cases that fail without the stamp or the sort, plus randomized cross-checks against the original scan kept as a reference implementation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both output-driven sparsity fills used the same stamp/gather/sort loop (the symbolic phase of Gustavson's sparse matmul); factor it into sorted_pattern_union and condense the call-site comments accordingly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
80adc69 to
48f130a
Compare
Brace the single-statement bodies in the block reference implementation and drop a redundant iwork_size indirection in the random cross-check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@dance858 another PR for you to review. I've also added two correctness tests which 1. compare with the old code and 2. have both a unsorted and duplicated row gathers to ensure it is deduplicating and sorting the output. |
What changed
block_left_multiply_fill_sparsityandcsr_csc_matmul_allocbuild the outputsparsity pattern of
C = A @ B. Previously they were input-driven: everycandidate output entry
(i, j)ran ahas_overlapmerge-scan of rowiof Aagainst column
jof B —m x pscans regardless of how sparse the operandsare. They are now output-driven (the symbolic phase of Gustavson's sparse
matmul): an output row's pattern is the union of the operand index lists its
entries select, so we only walk index lists that exist. Candidates are
deduplicated with a generation-stamped workspace and sorted, so the produced
pattern is byte-identical to the old code's. The shared gather lives in one
helper,
sorted_pattern_union. Each function builds one transposed view of theconstant operand up front; both functions run once per problem at derivative-
structure initialization, so that cost is amortized. The per-iteration
*_fill_valuespaths are untouched.Complexity per output row goes from O(m) (resp. O(p)) overlap scans to
O(flops for that row + sort of its pattern).
Correctness
same ascending order). Unit tests include hand-built cases where a missing
dedup or missing sort would each fail deterministically, plus randomized
cross-checks against the original
has_overlapimplementation (kept verbatimin the test file as a reference): identical
p/iarrays asserted overrandom trials for both functions.
nlp_testssuite and DNLPdoc examples were run through the cvxpy -> sparsediffpy path against two
builds identical except for this file. Objective, gradient, constraint
values, and the full Jacobian and Hessian sparsity patterns and values
are exactly equal (
np.array_equal) on every problem, in two independentruns. (Comparison is NaN-aware: two division-by-quad_form formulations
evaluate to NaN at the harness's arbitrary evaluation point — identically,
in the same positions, in both builds.)
Benchmarks (DNLP suite, end-to-end through cvxpy)
"kernel" = wall time inside the two rewritten functions, measured with
CLOCK_MONOTONICaccumulators compiled identically into both builds(benchmark-only instrumentation, not part of this PR). "deriv. init" =
problem_init_jacobian_coo+problem_init_hessian_coo_lower_triangular.Times are min over 2 independent runs x 3 in-process repetitions.
A @ x == b, sparse A 5000x5000 (0.2%)convolve(k, x), kernel 100, signal 2000A @ x == b, sparse A 1000x1000 (1%)min |X @ Y - C|_F^2, 40x20x30t == sum(X @ Y)(test suite, 5x7x11)sum(entr(A @ q)), dense A 100x100trace(exp(A @ X)), dense A 10x10 (stress suite)sum(exp(X @ A)), dense A 10x10 (stress suite)Reading the table honestly:
init-phase bottleneck (e.g. 97% of signal-recovery init) and are now
negligible; where init speedup is below the kernel speedup, the remaining
time is in other init work (e.g. Hessian assembly) that this PR does not
touch.
only sparse constant matmuls (block fill) and
convolve/ power flow(csr@csc). Most suite problems at their natural unit-test sizes use dense
data, which takes the
permuted_densepath (0 calls) — on all of those,outputs are identical and timings differ only by noise (0.99x-1.05x).
X @ Yproblems also made 0 calls: the matmul atom'scsr_csc_matmul_allocis on its composite-children Hessian path, and thesetests use leaf variables.
log_sum_expx2,huber,sum_largest) couldnot run on the direct C-problem harness because those atoms are lowered by
the dnlp2smooth reduction before reaching the engine in real solves; their
lowered forms use the same dense-path atoms as the rows above.
of the largest eval (bivariate
X @ YHessian) shows the new build withinnoise (marginally faster: 157-159 ms vs 162-164 ms).
PROFILE_ONLYsuite agrees:profile_left_matmulJacobianinit 0.238 s -> 0.028 s;
profile_memoryderivative-structure phase2.22 s -> 1.91 s; everything else unchanged.
Known trade-off
Kernel-level microbenchmarks (old vs new on random matrices, outputs verified
identical in every case) show the win grows as operands get sparser (230x at
0.1% density, 11-23x at 1%, size-independent), but when both operands are
dense-ish the old scan is faster: at 20% density old wins ~2x, and on fully
dense inputs ~70x (its
has_overlapearly-exits on the first match, O(1) perpair, while the gather does full O(n) work per pair). This regime is not
reachable through cvxpy for constant matmuls —
SPARSE_DENSITY_THRESHOLD = 0.05routes denser constants to the dense (permuted_dense) path — and denseoperands should use that path anyway. If we ever want insurance, a cheap
per-row guard (sum the selected index-list lengths; fall back to the
has_overlapscan when it exceeds the output width) is possible as follow-up.Environment
Intel Core i7-8850H (2.6 GHz), macOS 15.7.7, Apple clang 15, Release (-O3,
-DNDEBUG), Python 3.14 / cvxpy nlp branch. Benchmark harness: two sparsediffpy
builds identical except
src/utils/linalg_sparse_matmuls.c(old =main,new = this branch), swapped via PYTHONPATH.