Skip to content

compute the two residual products in one pass over A - #11

Open
batterseapower wants to merge 1 commit into
mainfrom
perf/fused-residual-gemv
Open

compute the two residual products in one pass over A#11
batterseapower wants to merge 1 commit into
mainfrom
perf/fused-residual-gemv

Conversation

@batterseapower

@batterseapower batterseapower commented Jul 20, 2026

Copy link
Copy Markdown
Owner

Independent of the rest of the series — this touches residuals.rs and the CSC kernels, which no other open PR modifies, so it can land in any order.

What

The interior-point residual update needs both sparse products on every iteration:

rx_inf = -A' z
rz_inf =  s + A x

They are two separate gemv calls, and each one streams all of A's row indices and values. Since both traverse the same nonzeros, one pass can serve both: for each nonzero (i,j,v) the same loads of v and rowval[k] feed the column accumulation of A'z and the scatter of A x.

On the portfolio problems nnz(A) is 271k–538k and this runs once per iteration, so it was a measurable share of runtime — profiling with #[inline(never)] put _csc_axpby_T at 5.95% and _csc_axpby_N at 1.31% of wall clock, together the largest remaining cost outside the factorization, the refinement residual and the triangular solves.

Why it is correct

Both existing kernels visit columns in order and entries within a column in order. The fused loop does the same, so each output accumulates in exactly the sequence it did before — the results are bit-identical, not merely equivalent. test_csc_neg_At_and_A_matches_separate_gemvs asserts that against the two separate calls on a matrix with empty and multi-entry columns.

No tunable constants.

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

  • test_csc_neg_At_and_A_matches_separate_gemvs — the fused kernel vs _csc_axpby_T + _csc_axpby_N, compared bitwise on both outputs.

Performance: before → after

Interleaved, min of 3 rounds × 2 reps. Objectives bitwise-equal, and statuses and iteration counts identical, on every problem — the solver follows exactly the same trajectory.

Portfolio-rebalance set (9 problems)

problem before after delta
20260101_full 1.524s 1.486s −2.5%
20260102_full 1.698s 1.654s −2.6%
20260102_screened 0.971s 0.918s −5.5%
20260105_full 2.851s 2.773s −2.7%
20260105_screened 0.758s 0.727s −4.1%
20260115_full 1.602s 1.510s −5.8%
20260115_screened 0.969s 0.926s −4.4%
20260116_full 1.397s 1.348s −3.5%
20260116_screened 0.827s 0.787s −4.8%
total 12.598s 12.130s −3.7%

Public corpus

Measured twice, on a narrow and then a wider set, because the first result was small enough to be sampling noise.

14 timing-relevant problems, 3 rounds × 2 reps (the trustworthy measurement):

before after delta
total 39.517s 39.038s −1.2%

7-problem subset, 2 rounds × 2 reps (reported for completeness): 16.153s → 16.255s, +0.6% — three faster, four marginally slower. On the wider set with more rounds the aggregate is a small win rather than a small loss, which is the honest summary: the effect on the public corpus is real but close to noise, roughly −1%, while on the portfolio class it is a consistent −3.7%.

The mechanism is cache behaviour, not arithmetic — fusing trades one traversal of A for two indirect accesses to different vectors inside the same loop, which pays when z and rz_inf stay resident. Since the result is bit-identical either way, there is no numerical risk in either direction, and I did not add a size heuristic to choose between them because any threshold would be fitted to these corpora rather than derived.

Status/objective regression gate

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

🤖 Generated with Claude Code

https://claude.ai/code/session_01CqGdm2vZZ6HA5HT8DsRePa

The interior-point residual update needs both sparse products on every
iteration:

    rx_inf = -A' z
    rz_inf =  s + A x

Each is a separate gemv, and each streams all of A's row indices and
values.  Since both traverse the same nonzeros, one pass can serve both:
for each nonzero (i,j,v) the same loads of v and rowval[k] feed the
column accumulation of A'z and the scatter of A x.

Both existing kernels visit columns in order and entries within a column
in order, and the fused loop does the same, so each output accumulates
in exactly the sequence it did before and the results are bit-identical.
A test asserts that against the two separate calls.

Measured (interleaved A/B, min of 3 rounds x 2 reps, objectives
bitwise-equal throughout):

  nine portfolio-rebalance SOCPs: -3.7% total wall clock, every problem
    faster (-2.5% to -5.8%)
  seven Netlib-Kennington / Mittelmann / Maros-Meszaros / conic
    problems: +0.6% total, between -2.0% and +1.6%

The difference is cache behaviour, not arithmetic: fusing trades one
traversal of A for two indirect accesses to different vectors inside the
same loop, which pays when z and rz_inf stay resident and is a wash when
they do not.  It is bit-identical either way, so the choice is purely
about speed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CqGdm2vZZ6HA5HT8DsRePa
@batterseapower

Copy link
Copy Markdown
Owner Author

Held back from the upstream submission. Public profiling puts the two products at roughly 1% of runtime on public problems (0.73% on kennington_cre_b, below the noise floor on mm_exdata), so the measured −1.2% corpus gain is close to measurement uncertainty and does not justify reviewer attention alongside the two larger changes (upstream oxfordcontrol#230 and oxfordcontrol#231). Bit-identical and ready if a maintainer wants it.

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