Skip to content

qdldl: replay factor updates over consecutive-row runs - #9

Open
batterseapower wants to merge 1 commit into
perf/qdldl-scheduled-refactorfrom
perf/qdldl-dense-runs
Open

qdldl: replay factor updates over consecutive-row runs#9
batterseapower wants to merge 1 commit into
perf/qdldl-scheduled-refactorfrom
perf/qdldl-dense-runs

Conversation

@batterseapower

@batterseapower batterseapower commented Jul 20, 2026

Copy link
Copy Markdown
Owner

Stacked on #6 (perf/qdldl-scheduled-refactor) — it reuses that PR's refactorization schedule. Review #6 first; this diff is +70/−14 on top of it.

The observation

The dominant loop of the numeric factorization scatters into the sparse accumulator:

for j in range { y[Li[j]] -= Lx[j] * y_c }

one indexed load and one indexed store per element — the compiler cannot vectorize it. But KKT factors from block-structured problems place most of their arithmetic in columns whose row indices are long blocks of consecutive integers: a set of dense-ish rows, which AMD orders last, produces a trailing trapezoid shared by thousands of columns.

Measured on the portfolio KKT systems (flop-weighted, i.e. each entry weighted by its column's height, since a column's update range is walked once per dependent column):

entries in runs ≥ 8 entries in a single terminal run average run length
n=18k full problem 95.7% 58.2% 5.64
n=9.5k screened problem 95.5% 66.7%

The change

Record each L column's decomposition into maximal consecutive-row runs alongside the schedule, and replay the update range run by run:

y[rs..rs+len] -= Lx[p..p+len] * y_c

Same independent elementwise operations, same values, same order ⇒ bit-identical L, D, Dinv, inertia and regularization decisions. No per-element index load, contiguous slices, so the compiler vectorizes. Factorization only — triangular solves untouched (see "what was tried and rejected"). Runs stored as u32 pairs with the same overflow fallback as #6.

No tunable constants. The run decomposition is exact and data-derived; the "≥ 8" above appears only in the measurement that motivated the change, never in the code.

Why this instead of a bordered/Schur factorization

The natural plan for this problem class was an OOPS-style block factorization exploiting diagonal P plus the dense border (Gondzio & Grothey, EJOR 181(3):1019–1029, 2007). Flop accounting first: AMD already achieves zero fill-in on these systems (nnz(L) = 592,846 vs nnz(K) = 611,653) and orders the dense border last, so a bespoke block factorization would perform the same ~42M flops. The opportunity was never fewer flops — it was executing them at vector rate instead of scalar-gather rate, which this does in ~70 lines instead of a new KKT solver. Generic supernodal codes (faer) miss it because the ~2000 tall columns have differing sparse upper patterns and so do not form supernodes; only their dense-border tails coincide, which is exactly what the run decomposition captures.

Validation suites used throughout this series

All timing is on one Apple M-series core, single-threaded, --release with
debug symbols, and every comparison is interleaved: the two binaries are
run alternately problem-by-problem within each round, so thermal drift and
machine noise affect both sides equally. Reported times are the minimum over
rounds. (Session-to-session noise on this machine is large — up to 20% between
identical binaries at different times — so non-interleaved comparisons are not
trustworthy and none are quoted.)

1. Unit / integration tests. cargo test — 20 test binaries, all passing,
plus the specific new tests listed per PR below. cargo clippy clean on the
touched files.

2. Portfolio-rebalance set (9 problems, the motivating workload).
Real conic problems from a production portfolio-rebalance backtest:
n = 9,448–18,424, m = 22,329–43,273, nnz(A) = 271k–538k, diagonal P, two
large nonnegative cones and one second-order cone of dimension 3,279–6,271
(a factor-model risk constraint: ~156 dense-ish factor rows plus a diagonal
idiosyncratic block). Used for the headline timings.

3. In-sample public corpus (224 problems). Every tiny/small/medium
non-PSD problem in a locally converted corpus: 101 Maros–Mészáros QPs,
85 Netlib LPs, 24 structured conic problems (SOCP/EXP/POW built from UCI
data), 12 Netlib-Kennington LPs, 2 Mittelmann LPs. Each has an
independently verified reference objective. Used as the status/objective
regression gate for every change.

4. Out-of-sample suites (92 problems, fetched fresh from the internet).
Because suite 3 became a tuning gate, two further suites were added that
were never used to guide any decision:

  • MIPLIB 2017 benchmark set as LP relaxations — 45 instances. Downloaded
    from miplib.zib.de; the MPS reader ignores INTORG/INTEND markers, so
    reading a MIPLIB file yields exactly the continuous relaxation. These are
    substantially harder for an interior-point method than the portfolio
    problems. 90s time limit per solve.

  • CBLIB (Conic Benchmark Library) — 47 instances, sampled across families
    from cblib.zib.de, including the DIMACS classics nb, nb_L1, nb_L2,
    nql30/60/180, qssp30/60, sched_*. Converted with a new CBF reader;
    integer instances are taken as continuous relaxations, and rotated
    quadratic cones are mapped to second-order cones by the orthogonal
    rotation u=(x1+x2)/√2, v=(x1−x2)/√2.

    Conversion validated independently: nb solves to −5.0703094644e-2,
    matching its published DIMACS optimum (−0.05070309), and HiGHS (installed
    for the purpose) agrees on the LP-only conversions, e.g. gen_ip054
    6765.209042728 vs Clarabel 6765.2090428.

Tests specific to this PR

No new tests: #6's test_refactor_matches_fresh_factor_exactly and ..._with_regularization already assert bitwise equality of a refactorization against a fresh factorization, which is exactly the invariant this change must preserve, and they cover the new code path unchanged.

Performance: before → after

Interleaved against #6's branch, min of 3 rounds × 2 reps. Objectives bitwise-equal and iteration counts identical on every problem below.

Portfolio-rebalance set (9 problems)

problem before (#6) after delta
20260101_full 1.571s 1.314s −16.4%
20260102_full 1.619s 1.405s −13.2%
20260102_screened 0.953s 0.864s −9.4%
20260105_full 2.789s 2.409s −13.6%
20260105_screened 0.755s 0.701s −7.1%
20260115_full 1.581s 1.338s −15.4%
20260115_screened 0.947s 0.875s −7.6%
20260116_full 1.419s 1.222s −13.9%
20260116_screened 0.821s 0.742s −9.6%
total 12.455s 10.869s −12.7%

Public corpus (7 problems)

problem before (#6) after delta
kennington_cre_b 5.155s 4.163s −19.2%
mm_q25fv47 0.265s 0.226s −14.5%
kennington_ken_13 0.925s 0.887s −4.2%
kennington_pds_06 3.515s 3.408s −3.0%
conic_logreg_winewhite_exp 0.321s 0.319s −0.8%
mitt_rail582 2.158s 2.192s +1.6%
kennington_osa_30 3.752s 3.940s +5.0%
total 16.091s 15.134s −5.9%

The public-corpus spread is expected and honest: the gain tracks how much of a factor's work sits in long runs, and two problems regress slightly where it does not. Since results are bit-identical, a run-density gate could suppress those, but I did not add one — the threshold would be fitted to this corpus rather than derived, and the aggregate is clearly positive.

Status/objective regression gate

224-problem in-sample corpus: 0 status changes, 0 iteration changes, 0 objective changes (guaranteed by bit-identity, verified anyway).

What was tried and rejected

Extending the same run decomposition to the triangular solves (_lsolve/_dltsolve, ~21% of wall). It is also bit-identical there — the runs partition each column in position order and are traversed in increasing position, so even the Lᵀ dot product accumulates in the original order — and it gave −2.2% on the portfolio set. But it was +5.4% slower on the public corpus (mitt_rail582 +17%), so it is not included. The reason is a clean asymmetry: factor updates are flop-weighted, dominated by tall long-run columns, whereas the solves are entry-weighted (every column is visited exactly once), so plain average run length governs — 5.64 on the portfolio problems versus 1.37 on rail582, and below ~3–4 entries per run the per-run loop overhead exceeds the one indexed load it saves.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CqGdm2vZZ6HA5HT8DsRePa

@batterseapower

Copy link
Copy Markdown
Owner Author

Public-corpus timing, measured per track

Added because the earlier corpus timings in this description used a narrow 7-problem set. Re-measured on 14 timing-relevant public problems (Netlib-Kennington cre_b/cre_d/pds_06/osa_07/osa_14/osa_30/ken_11/ken_13, Mittelmann cont1/rail582, Maros–Mészáros exdata, Netlib pilot/d2q06c, structured conic pnorm15_winewhite_pow), interleaved, 3 rounds × 2 reps:

track before after delta
#6 + #9 (factor: schedule + run kernels) 39.991s 31.277s −21.8% — bit-identical throughout
#8 + #10 (refinement: backend loop + symmetric residual) 39.563s 37.663s −4.8%
#11 (fused residual products) 39.517s 39.038s −1.2%
all five together 39.530s 29.134s −26.3%

All 14 problems individually faster with the full stack (−6.9% to −56.3%; mm_exdata 8.178s → 3.575s), 12 of 14 with bitwise-identical objectives.

Worth stating explicitly since the original motivation was a portfolio workload: this is not a workload-specific optimisation. The public corpus gains (−26.3%) are of the same order as the portfolio set (−42.6%), and the largest single component (#6 + #9, −21.8%) changes no floating-point result at all.

@batterseapower

Copy link
Copy Markdown
Owner Author

Submitted upstream as oxfordcontrol#230 (both commits of this track together, since a cross-repo PR must target upstream main and so cannot be stacked). Keeping this open as the fork-side record.

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
@batterseapower
batterseapower force-pushed the perf/qdldl-dense-runs branch from adfcb7c to 096ae97 Compare July 21, 2026 08:33
@batterseapower

Copy link
Copy Markdown
Owner Author

Superseded by a broader benchmark; see the upstream PR for the current state. Summary of what changed:

Timing this on the 14 slowest corpus problems was a biased sample. On all 80 small/medium/large public problems (with the sdp feature enabled, so SDPLIB is actually included rather than silently skipped) the run-kernel change was slower on 26 of 54 problems, up to +18%, while being worth up to −51% on others.

Cause: the block-wise update trades one indexed load per entry for bookkeeping per block, which a two- or three-entry block does not amortize. Fixed by deciding per column — a column's blocks are recorded only if the block containing a typical entry reaches 16 entries — which removes the regressions and, on mixed-structure problems, beats replaying everything block-wise (kennington_pds_20 −28.1% versus −22.7%).

Final: −29.2% across the 67 small/medium problems (one +2.3% outlier) and −35.7% across the 13 large ones (all faster), bit-identical throughout.

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.

1 participant