qdldl: replay numeric refactorization from a precomputed schedule - #230
qdldl: replay numeric refactorization from a precomputed schedule#230batterseapower wants to merge 2 commits into
Conversation
After the first factorization the sparsity pattern of L is fixed, but _factor_inner re-derives the per-column update lists on every refactor: an elimination-tree walk with marker arrays, a work buffer and a reversal step. Interior-point use refactors the same pattern once per iteration, so this control flow is pure overhead. This change records the discovered control flow on the first refactor as a flat list of (column, position) update steps, then replays the identical floating-point operations in the identical order on subsequent refactorizations. L, D, Dinv, the inertia count and the dynamic-regularization decisions are bit-for-bit identical to the original path -- enforced by new tests that compare a refactorization against a fresh factorization bitwise, including a case where dynamic regularization fires. This is the standard symbolic/numeric phase separation of sparse direct solvers (T. Davis, "Direct Methods for Sparse Linear Systems", SIAM 2006, ch. 4). The schedule is built lazily on the first refactor() call, so one-shot factorizations pay nothing. The build self-verifies against the existing pattern of L (every recorded position must satisfy Li[pos] == k, and the step count must equal nnz(L)); on any mismatch, or if indices would not fit the u32 storage used to halve replay memory traffic, refactorization falls back to the original path. Measured on an interleaved A/B benchmark (Apple M-series, min of 3 rounds x 2 reps, objectives bitwise-equal throughout): -2.1% total wall time over nine portfolio-rebalance QP/SOCPs (n=9.5k-18k), and -3.9% total over seven Netlib-Kennington/Mittelmann/Maros-Meszaros/ conic problems (all 16 problems individually faster, -1.2% to -5.4%). The win is modest because these factors are flop-bound; the removed overhead grows in relative terms the sparser the factor. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CqGdm2vZZ6HA5HT8DsRePa
|
Correction to the benchmark evidence above, before you spend time on this. The 14 problems I timed were selected as the slowest band of a larger corpus, and that selection flattered the result. Re-running on 67 public problems (every small- and medium-band problem across Netlib, Netlib-Kennington, Maros–Mészáros, Mittelmann, SDPLIB and structured conic — SDPLIB was missing before because my harness lacked the
So the aggregate is a large win, but it is not uniform: the second commit is bimodal. It is worth −51% on In wall-clock terms the 17 problems that regress in this PR as a whole total 2.1s against a 21s saving, so nothing here is large in absolute terms. But they are reproducible at 4 rounds × 3 reps, they are bit-identical in output (so this is pure kernel overhead, not a different iteration path), and 12 of the 17 are Netlib LPs that anyone evaluating this would run. I also want to retract the explanation I would have offered: the effect does not track run length. Problems regress at flop-weighted mean run lengths up to 12.2 and improve from 4.0, so there is no clean threshold on that statistic and I cannot currently gate the kernel on it honestly. The first commit, by contrast, looks like what a bit-identical overhead removal should look like: 61 of 67 faster, one problem 2.5% slower, and it changes no floating-point result. I would suggest treating the first commit as the reviewable change here and letting me withdraw the second until I understand its regression properly — happy to restructure the PR that way, or to close and resubmit, whichever you prefer. Apologies for putting an under-powered benchmark in front of you. |
The dominant loop of the numeric factorization scatters into the sparse
accumulator, one indexed load and one indexed store per entry:
for j in range { y[Li[j]] -= Lx[j] * y_c }
Row indices within a column of L are often long blocks of consecutive
integers -- a set of dense-ish rows, which AMD orders last, produces a
trailing trapezoid shared by many columns. Replaying such a block as a
contiguous slice update drops the per-entry index load and lets the
compiler vectorize:
y[rs..rs+len] -= Lx[p..p+len] * y_c
That is worth up to 55% of solve time on factors built from long blocks.
But it costs bookkeeping per *block* -- the bounds, the clip against the
end of the update range, and the inner loop's own setup -- and on factors
whose blocks are two or three entries long that is not amortized: measured
across public problems, replaying every column block-wise costs 10-18% on
small sparse LPs. (A per-block fast path does not help: for a short block
the contiguous and indexed inner loops do the same work, and the cost is
the block loop itself.)
Since the trade is a property of each column rather than of the matrix, it
is decided per column. A column's blocks are recorded only if the block
containing a typical entry of that column reaches RUN_MIN_LEN entries --
entry-weighted mean block length, Sum(len^2) / Sum(len) -- and columns
failing the test are replayed entry-wise. A column with no recorded blocks
takes the entry-wise path, so the decision doubles as its own storage and
needs no extra array. Factors of mixed structure, which are the common
case, then take the contiguous path on exactly the columns that benefit.
RUN_MIN_LEN is 16, a vectorization threshold: eight iterations of a
two-wide double-precision loop, comfortably past its prologue and epilogue,
whereas at two or four entries the setup dominates.
Both paths perform the same operations on the same values in the same
order, so the factorization is bit-identical whichever is chosen. Two
tests assert that against a fresh factorization on matrices sitting on
opposite sides of the decision, and a third pins that the decision does go
both ways, so neither path can rot untested.
Measured on 80 public problems (Netlib, Netlib-Kennington, Maros-Meszaros,
Mittelmann, SDPLIB, structured conic), interleaved against the schedule
replay alone:
67 small and medium problems: -29.2% total, median -10.0%, 54 faster,
12 unchanged, one slower by 2.3%
13 large problems (>10s), held out from the calibration of RUN_MIN_LEN:
-35.7% total, all 13 faster
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CqGdm2vZZ6HA5HT8DsRePa
adfcb7c to
096ae97
Compare
|
Revised. The description above is now the complete picture; this note records what changed since the correction I posted earlier, so the thread reads in order. The regressions are gone, and they came from one specific decision. Isolating each commit on the 67-problem set showed the schedule replay was a near-universal win (61 of 67 faster, one 2.5% slower) while replaying every column block-wise was bimodal: worth up to 55% on long-block factors, costing 10–18% on small sparse LPs. A whole-matrix threshold was the wrong fix. I first gated the block path on a matrix-level statistic — ≥80% of flop-weighted update work in blocks of ≥16 rows — which looked convincing on the 67 (−27.1%, zero regressions, and the boundary sat in a gap in the data). The held-out large problems then showed why it was wrong: they have mixed structure, scored below the threshold, and lost their speedups entirely — Deciding per column fixes both ends and, on the mixed problems, beats replaying everything block-wise:
because within one matrix the short-block columns now take the cheaper path too. It also removed the fitted 80% constant: what remains is Final: −29.2% across the 67 small/medium problems with one +2.3% outlier, and −35.7% across the 13 large problems with all 13 faster. Bit-identical output throughout, so no status, iteration count or objective changes anywhere in the 80. |
Two commits, reviewable independently. Both are bit-identical to current
main: they change how a numeric refactorization is executed, never what it computes.Motivation
The numeric factorization dominates solve time. Profiling stock Clarabel with
#[inline(never)]on the candidate kernels, so inlined frames cannot be misattributed:mm_exdatakennington_cre_b_factor_innerTwo properties of interior-point use make it improvable without touching the arithmetic.
Commit 1 — replay a precomputed schedule
The sparsity pattern of
Lis fixed after the first factorization, yet_factor_innerre-derives, for every column, the list of prior columns that update it: an elimination-tree walk with marker arrays, a work buffer and a reversal step. Clarabel refactors the same pattern once per iteration, so that control flow is pure overhead.This records the discovered control flow once as a flat list of
(source column, write position)steps and replays it. The update range of a step isLp[cidx]..pos, because a column holds exactly its rows< kwhen that step runs, andposis fixed once the pattern is. It is the standard symbolic/numeric phase separation of sparse direct solvers (T. Davis, Direct Methods for Sparse Linear Systems, SIAM 2006, ch. 4); QDLDL's single-phase design suits one-shot factorization, which interior-point use inverts.Built lazily on the first
refactor(), so single-factorization users pay nothing. The build self-verifies every recorded position against the existing pattern ofL(Li[pos] == k, and the step count must equalnnz(L)); on mismatch, or if the pattern exceeds theu32index storage, refactorization falls back to_factor_inner.Measured alone on 67 public problems: −2.1% total, median −3.5%, 61 of 67 faster, one slower by 2.5%.
Commit 2 — replay updates block-wise on the columns where it pays
Row indices within a column are often long blocks of consecutive integers, so replaying a block as a contiguous slice update drops the per-entry index load and vectorizes:
That is worth up to 55% on factors built from long blocks, but it costs bookkeeping per block — bounds, the clip against the end of the update range, the inner loop's setup — which a two- or three-entry block does not amortize. Applied unconditionally it costs 10–18% on small sparse LPs. (A per-block fast path does not help: for a short block the contiguous and indexed inner loops do identical work; the cost is the block loop itself.)
The trade is a property of each column, not of the matrix, so it is decided per column: a column's blocks are recorded only if the block containing a typical entry of that column reaches
RUN_MIN_LENentries — entry-weighted mean block length,Σlen²/Σlen— and the remaining columns are replayed entry-wise. A column with no recorded blocks takes the entry path, so the decision doubles as its own storage and needs no extra array. Factors of mixed structure, which are the common case, take the contiguous path on exactly the columns that benefit.RUN_MIN_LENis 16: eight iterations of a two-wide double-precision loop, comfortably past its prologue and epilogue, where two or four entries would be dominated by setup. It is the only constant introduced, and deciding per column is what removed the need for any whole-matrix threshold.Correctness
Both commits perform the same floating-point operations in the same order as
_factor_inner, and both replay paths in commit 2 do likewise, soL,D,Dinv, the inertia count and every dynamic-regularization decision are bit-for-bit unchanged. Asserted, not argued:test_refactor_matches_fresh_factor_exactly— a refactorization after updating every value equals a fresh factorization bitwise (L.nzval,D,Dinv,positive_inertia,regularize_count), checked on the first replay and a later one, and asserting the replay path was engaged rather than silently falling back.test_refactor_matches_fresh_factor_with_regularization— the same on a matrix built so dynamic regularization fires (asserted non-zero).test_both_replay_paths_agree_with_a_fresh_factor— the same for both paths of commit 2, on a banded matrix and a sparse quasidefinite one, chosen to sit on opposite sides of the per-column decision.test_run_selection_follows_block_structure— pins that the decision goes both ways, so neither path can rot untested.Full suite green (
cargo test, 20 binaries);cargo clippyclean on the touched file.Benchmarks
Interleaved A/B — the two binaries run alternately problem-by-problem within each round, so machine drift affects both equally — minimum over rounds, single-threaded release build, one machine. Problems are every small-, medium- and large-band problem of a locally converted public corpus, i.e. selected by size rather than by where the change was expected to help.
67 small and medium problems (Netlib 19, Netlib-Kennington 14, Maros–Mészáros 13, SDPLIB 19, structured conic 11, Mittelmann 2), 2 rounds × 2 reps:
conic_pnorm15_winered_pow)Largest gains
mm_exdata−52.3%,sdplib_qap7−46.7%,sdplib_theta1−45.3%.13 large problems (>10s), held out from the calibration of
RUN_MIN_LEN, 1 rep:All 13 faster, none slower.
Regression gate: across all 80 problems, zero status changes, zero iteration-count changes and zero objective changes — required by bit-identity, and verified rather than assumed. The single timing regression is
conic_pnorm15_winered_powat +2.3%, close to this machine's noise floor.Cost
nnz(L)×8B for the step schedule plus(n+1)×4B, and a block list for the qualifying columns only, allocated on first refactor. About 5MB on the largest problem measured here.