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
85 lines
2.2 KiB
TOML
85 lines
2.2 KiB
TOML
[package]
|
|
name = "trueskill-tt"
|
|
version = "0.8.0"
|
|
edition = "2024"
|
|
rust-version = "1.85"
|
|
description = "TrueSkill Through Time: Bayesian skill rating that tracks how skill evolves over time, via Gaussian message passing"
|
|
repository = "https://git.aceofba.se/logaritmisk/trueskill-tt"
|
|
authors = ["Anders Olsson"]
|
|
# Publishing is restricted to the private kellnr registry; this also makes
|
|
# an accidental `cargo publish` to crates.io a hard error rather than a
|
|
# irreversible mistake. Index is declared in `.cargo/config.toml`.
|
|
publish = ["kellnr"]
|
|
readme = "README.md"
|
|
keywords = ["trueskill", "rating", "bayesian", "elo", "skill"]
|
|
categories = ["algorithms", "science", "game-development"]
|
|
license = "MIT OR Apache-2.0"
|
|
# `examples/atp.csv` is a 48 MB tennis dataset — 99% of the packaged crate,
|
|
# for a library whose source is 312 KB. `examples/atp.rs` opens it by
|
|
# relative path at runtime, so excluding the data still compiles; the
|
|
# example just needs the file fetched from the repo to run.
|
|
exclude = [
|
|
"/docs",
|
|
"/benches/*.txt",
|
|
"/temp",
|
|
"/.gitea",
|
|
"/examples/atp.csv",
|
|
]
|
|
|
|
[lib]
|
|
bench = false
|
|
|
|
[[bench]]
|
|
name = "batch"
|
|
harness = false
|
|
|
|
[[bench]]
|
|
name = "history_converge"
|
|
harness = false
|
|
|
|
[[bench]]
|
|
name = "scored"
|
|
harness = false
|
|
|
|
[[bench]]
|
|
name = "ingest"
|
|
harness = false
|
|
|
|
[dependencies]
|
|
approx = { version = "0.5.1", optional = true }
|
|
feral-amd = "0.2"
|
|
libm = "0.2.16"
|
|
rayon = { version = "1", optional = true }
|
|
smallvec = "1"
|
|
|
|
[features]
|
|
approx = ["dep:approx"]
|
|
# Exposes the joint sparsity pattern for the #52 measurement. Test-only.
|
|
measure-sparsity = []
|
|
rayon = ["dep:rayon"]
|
|
|
|
[dev-dependencies]
|
|
criterion = "0.5"
|
|
plotters = { version = "0.3", default-features = false, features = ["svg_backend", "all_elements", "all_series"] }
|
|
plotters-backend = "0.3"
|
|
proptest = "1.11.0"
|
|
time = { version = "0.3", features = ["parsing"] }
|
|
trueskill-tt = { path = ".", features = ["approx"] }
|
|
|
|
# Debug symbols in release are for `just flame` (cargo-flamegraph), which needs
|
|
# them to symbolicate. Profile settings in a library are ignored by downstream
|
|
# consumers, so these only affect local builds — this is deliberate, not an
|
|
# oversight.
|
|
[profile.release]
|
|
debug = true
|
|
|
|
[profile.bench]
|
|
debug = true
|
|
|
|
[profile.dev]
|
|
debug = true
|
|
|
|
[[bench]]
|
|
name = "joint"
|
|
harness = false
|