perf!: sparse Cholesky with an AMD ordering for the joint

745 ms -> 1.11 ms on the fixture #52 was opened about.

The joint precision matrix is 0.19% dense at scale and gets sparser as
the history grows. We allocated all n^2 entries — 31 MB at n = 1976,
128 MB at ustat's ~4000 appearances — filled 99.8% of it with zeros, and
ran an O(n^3) factorisation over the whole thing.

Two measurements shaped the fix, and the first killed the plan #52
proposed.

**Ordering alone does nothing to a dense factorisation.** Its inner
loops run over every k whether the entry is zero or not, so a
permutation changes which entries are zero and not how many
multiplications happen. A 700x700 banded matrix at 0.43% density:
30.196 ms in band order, 29.544 ms under a scramble that destroyed the
band. Identical, as the flop count says it must be. #52's step 1 —
"reorder with AMD, keep our own Cholesky, and measure" — could not have
worked, and measuring said so before any of it was written.

**Sparsity and AMD together are worth four orders of magnitude.**
Symbolic factorisation on the n = 1976 fixture, against 2.572e9 dense
flops: sparse in natural order needs 5.597e7 (46x), sparse under AMD
needs 8.656e4 — 29,710x. AMD is worth 646x on top of sparsity and
nothing without it. Natural order fills in badly for exactly the reason
#52 predicted about bandwidth: nnz(L) is 292,437 against A's 7,504,
because a competitor idle from slice 0 to slice 75 links across the
whole matrix.

Measured end to end, factorising through `History::joint`:

    n =  480     215 us   (bench: 9.11 ms -> 167 us, 54x)
    n = 1976    1.112 ms  (was ~745 ms, 670x)
    n = 7800    4.616 ms  (dense would be 1.58e11 flops)

Scaling is near-linear now rather than cubic: 16x the variables costs
21x the time, where dense would cost 4096x.

The factorisation is the up-looking sparse Cholesky of Davis's *Direct
Methods for Sparse Linear Systems*, written here rather than taken from
a crate. The scouting in #52 still holds and got one addition: `feral`
itself pulls `pulp`, so it has the same runtime CPU-dispatch problem
that ruled out `faer` — results could differ between an AVX-512 host and
an AVX2 one, the drift the libm-over-std decision was made to avoid.
`sprs-ldl` is still LGPL and `nalgebra-sparse` still disclaims
fill-reduction in its own docs. Only the ordering is a dependency:
`feral-amd`, two crates, both `#![forbid(unsafe_code)]`.

The matrix is accumulated into a `BTreeMap`, not a hash map: the
iteration order becomes the summation order, and a hash map's varies per
process. `tests/cross_process_determinism.rs` exists because that has
bitten before.

`whiten` returns its result in the permuted order and leaves it there —
a dot product does not care, as long as both operands were permuted the
same way — so `bilinear` is unchanged.

Correctness: the existing analytic goldens are 2x2 and 3x3, too small to
permute or fill in, so they could not have caught a symbolic-pass bug.
`agrees_with_a_dense_reference_on_random_sparse_systems` checks every
bilinear form against a deliberately naive dense factorisation that
shares no code with the thing it is checking, on chain-plus-long-range
matrices up to n = 60.

Closes #52.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
This commit is contained in:
2026-09-10 07:08:18 +02:00
co-authored by Claude Opus 5
parent d36d125e52
commit 695bb822ef
4 changed files with 619 additions and 57 deletions
+33 -10
View File
@@ -386,8 +386,8 @@ pub(crate) struct CompetitorConfig {
/// The joint precision over a history's appearances, with the maps needed to
/// address a competitor either at their latest appearance or at a given slice.
struct TimeExpanded {
/// Row-major precision matrix over appearances.
lambda: Vec<f64>,
/// Precision matrix over appearances, accumulated sparsely.
lambda: crate::joint::SymmetricBuilder,
/// `(row, slice)` of each competitor's latest appearance.
latest: HashMap<Index, (usize, usize)>,
/// Row of each `(competitor, slice)` appearance.
@@ -1415,6 +1415,23 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
prior_variance * SQRT_EPSILON
}
/// Test-only: the joint's sparsity pattern, for the #52 measurement.
///
/// Returns `(n, adjacency)` where `adjacency[i]` holds the off-diagonal
/// nonzero columns of row `i`.
#[cfg(feature = "measure-sparsity")]
pub fn joint_pattern_for_measurement(&self) -> (usize, Vec<std::collections::HashSet<usize>>) {
let te = self.time_expanded_joint();
let n = te.width;
let mut adj = vec![std::collections::HashSet::new(); n];
for (i, j) in te.lambda.pattern() {
if i != j {
adj[i].insert(j);
}
}
(n, adj)
}
fn time_expanded_joint(&self) -> TimeExpanded {
let mut latest: HashMap<Index, (usize, usize)> = HashMap::new();
let mut at_slice: HashMap<(Index, usize), usize> = HashMap::new();
@@ -1455,17 +1472,23 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
}
}
let mut lambda = vec![0.0; n * n];
// Accumulated sparsely: this matrix is ~0.19% dense at scale, and the
// dense form was 31 MB at n = 1976 and 128 MB at ustat's ~4000
// appearances, of which 99.8% was zeros. See `joint.rs` and #52.
let mut lambda = crate::joint::SymmetricBuilder::new();
for (row, competitor) in first_rows {
lambda[row * n + row] +=
1.0 / self.competitors[competitor].rating.prior.sigma().powi(2);
lambda.add(
row,
row,
1.0 / self.competitors[competitor].rating.prior.sigma().powi(2),
);
}
for (a, b, drift) in drift_links {
lambda[a * n + a] += 1.0 / drift;
lambda[b * n + b] += 1.0 / drift;
lambda[a * n + b] -= 1.0 / drift;
lambda[b * n + a] -= 1.0 / drift;
lambda.add(a, a, 1.0 / drift);
lambda.add(b, b, 1.0 / drift);
lambda.add(a, b, -1.0 / drift);
lambda.add(b, a, -1.0 / drift);
}
for (slice_idx, slice) in self.time_slices.iter().enumerate() {
for (contrast, noise) in slice.scored_contrasts(&self.competitors) {
@@ -1473,7 +1496,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
let ra = at_slice[&(*ia, slice_idx)];
for (ib, cb) in &contrast {
let rb = at_slice[&(*ib, slice_idx)];
lambda[ra * n + rb] += ca * cb / noise;
lambda.add(ra, rb, ca * cb / noise);
}
}
}