Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions ext/crates/algebra/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ maybe-rayon = { path = "../maybe-rayon" }
once = { path = "../once" }

anyhow = "1.0.98"
arc-swap = "1.7.1"
auto_impl = "1.3.0"
hashbrown = "0.15.4"
itertools = { version = "0.14.0", default-features = false, features = [
Expand Down Expand Up @@ -59,3 +60,7 @@ harness = false
[[bench]]
name = "motivic"
harness = false

[[bench]]
name = "seqno"
harness = false
41 changes: 41 additions & 0 deletions ext/crates/algebra/EXPERIMENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# algebra experiment log

What we tried, what it gained, and why the rejected alternatives were rejected. This is the place
for that record — the code comments are not, and neither is the README, which describes the crate
as it is rather than how it got there.

## Hash-free Milnor basis indexing ("seqno")

`MilnorAlgebra::basis_element_to_index` is a `HashMap<MilnorBasisElement, usize>` lookup. The
table-based alternative, `MilnorAlgebra::seqno`, ranks a `p_part` by summing differences of a
precomputed `g` array, with no hash.

Which one wins depends on the degree, and the crossover is a cache effect. The hashmap is
per-degree, so its working set grows with the dimension of that degree and eventually falls out of
cache. The `g` table is shared across degrees and grows only linearly in the degree, so it stays
resident and its cost is flat. The hashmap wins while it is cache-resident and loses once it is
not; `benches/seqno.rs` sweeps a range of degrees that spans the crossover.

Because of that, `compute_basis` deliberately does not build the seqno tables: a resolution that
never reaches the crossover should not pay for them. Callers that want the hash-free index — a GPU
backend, or a high-degree CPU run — call `compute_seqno_tables` themselves.

### Rejected: `OnceVec<Vec<_>>` storage

**Rejected.** The first version stored the tables in a `OnceVec<Vec<usize>>`. That paid two atomics
*per table access*, which was enough to make the table lose to the hashmap at every degree measured.
Storing one flat, row-major `Vec` behind an `arc_swap::ArcSwapOption` reduced a read to a single
guard load followed by direct indexing.

### Rejected: re-deriving the degree inside `rank`

**Rejected.** `rank` could recover the degree as `Σ rᵢ·ξᵢ` instead of taking it as an argument, but
every caller already knows it, and the hashmap it competes with reads it straight off the basis
element. Re-deriving it would have put a loop in the measurement that the competing path does not
pay. It survives as a `debug_assert!`.

### Hoisting the `arc_swap` guard

**Kept.** `seqno` acquires the guard on every call, which is one atomic per lookup and pure overhead
in a loop that ranks many elements. `seqno_ranker` hoists the acquisition out of the loop; the
`seqno` vs `seqno_naive` gap in `benches/seqno.rs` is what that is worth.
71 changes: 71 additions & 0 deletions ext/crates/algebra/benches/seqno.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//! A/B benchmark: the hash-free table index against the basis hashmap.

use algebra::{Algebra, MilnorAlgebra};
use criterion::{Criterion, Throughput, black_box, criterion_group, criterion_main};
use fp::prime::TWO;

/// Degrees to sample, spanning the point where the table index overtakes the hashmap (see

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this could use a bit of expansion here. We have two approaches: this and hashmap. Hashmap good in low degrees, seqno good in high degree.

/// `EXPERIMENTS.md`).
Comment on lines +7 to +8

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// Degrees to sample, spanning the point where the table index overtakes the hashmap (see
/// `EXPERIMENTS.md`).
/// Degrees to sample, spanning the point where the table index overtakes the hashmap.

///
/// `compute_basis` builds every degree below the maximum, so raising the top of this range costs
/// memory as well as time.
const DEGREES: &[i32] = &[32, 64, 128, 192, 256, 300, 340];

/// Time both indices over every basis element of each degree in [`DEGREES`].
fn seqno(c: &mut Criterion) {
let algebra = MilnorAlgebra::new(TWO, false);
let max_degree = *DEGREES.iter().max().unwrap();
algebra.compute_basis(max_degree);
algebra.compute_seqno_tables(max_degree);

let mut g = c.benchmark_group("seqno");

for &degree in DEGREES {
let dim = algebra.dimension(degree);
if dim == 0 {
continue;
}
// Snapshot the basis so neither index pays to walk the algebra's storage while timed.
let basis: Vec<_> = (0..dim)
.map(|i| algebra.basis_element_from_index(degree, i))
.collect();

g.throughput(Throughput::Elements(dim as u64));

g.bench_function(format!("hashmap/deg{degree}"), |b| {
b.iter(|| {
for elt in &basis {
black_box(algebra.basis_element_to_index(elt));
}
});
});

g.bench_function(format!("seqno/deg{degree}"), |b| {
let ranker = algebra.seqno_ranker();
b.iter(|| {
for elt in &basis {
black_box(ranker.rank(elt.p_part, degree));
}
});
});

// `seqno` hoists the table guard out of the loop as a hot caller would; the gap against
// `seqno_naive`, which re-acquires it per call, is what that hoisting is worth.
g.bench_function(format!("seqno_naive/deg{degree}"), |b| {
b.iter(|| {
for elt in &basis {
black_box(algebra.seqno(elt.p_part, degree));
}
});
});
}

g.finish();
}

criterion_group! {
name = benches;
config = Criterion::default();
targets = seqno
}
criterion_main!(benches);
Loading