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:
+33
-10
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user