Skip to content
Merged
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
90 changes: 90 additions & 0 deletions .claude/board/hop-memoisation-cold-vs-warm.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
warning: missing documentation for a method
--> src/rowstore.rs:306:5
|
306 | pub fn bytes_arc(&self) -> Arc<[u8]> {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
note: the lint level is defined here
--> src/lib.rs:48:9
|
48 | #![warn(missing_docs)]
| ^^^^^^^^^^^^

warning: `lgj-abi` (lib) generated 1 warning
gather-vs-sweep crossover — spread frontier, edge_classid=0, gate=0x0, radius=25, reps=7 (median)

== n_rows = 1024 ==
frontier density sweep_us gather_us memo_cold memo_warm
1 0.01% 88.8 0.2 85.8 0.1
1 0.05% 85.9 0.2 85.9 0.1
1 0.10% 86.2 0.2 85.8 0.1
2 0.25% 90.9 0.2 85.8 0.1
5 0.50% 86.3 0.3 86.2 0.1
10 1.00% 85.9 0.5 86.1 0.2
20 2.00% 86.1 0.7 86.1 0.2
51 5.00% 91.8 1.8 86.3 0.3
102 10.00% 86.8 3.6 86.6 0.7
256 25.00% 87.5 11.5 93.6 1.7
512 50.00% 89.1 21.9 92.2 3.2
1024 100.00% 92.4 42.6 92.2 7.2

== n_rows = 4096 ==
frontier density sweep_us gather_us memo_cold memo_warm
1 0.01% 360.0 0.3 374.9 0.2
2 0.05% 356.2 0.3 369.7 0.2
4 0.10% 364.8 0.4 352.1 0.2
10 0.25% 360.5 0.6 362.7 0.3
20 0.50% 353.8 0.9 362.5 0.3
40 1.00% 367.4 1.5 374.9 0.5
81 2.00% 372.4 4.0 355.7 0.8
204 5.00% 369.3 7.3 354.7 1.5
409 10.00% 391.4 15.0 368.5 3.0
1024 25.00% 370.8 66.9 360.2 7.0
2048 50.00% 369.7 113.3 369.5 16.5
4096 100.00% 399.8 226.5 401.7 38.9

== n_rows = 16384 ==
frontier density sweep_us gather_us memo_cold memo_warm
1 0.01% 1462.3 0.7 1440.6 0.7
8 0.05% 1482.5 1.0 1433.6 0.7
16 0.10% 1463.9 1.3 1465.3 0.7
40 0.25% 1453.4 1.9 1484.0 0.8
81 0.50% 1806.4 3.5 1445.9 1.5
163 1.00% 1524.3 6.4 1468.8 2.3
327 2.00% 1488.7 12.2 1465.4 3.0
819 5.00% 1480.9 65.1 1452.3 6.5
1638 10.00% 1512.8 135.1 1715.4 13.9
4096 25.00% 1577.5 347.5 1886.0 53.2
8192 50.00% 1989.1 723.8 2005.5 196.7
16384 100.00% 2168.9 1300.8 1766.9 377.2

== n_rows = 65536 ==
frontier density sweep_us gather_us memo_cold memo_warm
6 0.01% 6825.9 2.7 6807.8 2.5
32 0.05% 6852.4 3.5 6888.2 2.6
65 0.10% 6903.4 4.6 6746.7 2.7
163 0.25% 6640.5 8.0 6657.8 3.9
327 0.50% 6568.4 16.4 6543.4 6.4
655 1.00% 6621.9 34.5 6494.1 10.1
1310 2.00% 6680.3 87.4 6603.5 13.2
3276 5.00% 6577.3 315.5 6628.6 53.6
6553 10.00% 6774.9 637.1 6545.0 114.6
16384 25.00% 7103.5 2956.9 6993.5 255.2
32768 50.00% 8149.6 4085.3 8022.1 502.3
65536 100.00% 8976.4 5395.0 8750.1 1187.2

== n_rows = 262144 ==
frontier density sweep_us gather_us memo_cold memo_warm
26 0.01% 27023.0 11.0 27437.4 12.1
131 0.05% 26563.9 14.5 27427.6 10.7
262 0.10% 27627.0 23.0 27072.7 13.3
655 0.25% 26848.8 71.1 27130.8 16.6
1310 0.50% 27214.8 109.1 27295.0 36.0
2621 1.00% 27149.7 296.2 27302.1 92.4
5242 2.00% 28268.7 624.3 27541.3 123.7
13107 5.00% 28767.9 3415.7 27980.9 262.5
26214 10.00% 31007.4 6630.5 29938.3 550.4
65536 25.00% 30465.1 14134.6 31286.5 2895.9
131072 50.00% 34825.0 17754.2 32914.5 5605.5
262144 100.00% 38079.8 22036.3 37304.5 10067.9

65 changes: 59 additions & 6 deletions native/lgj-abi/examples/hop_gather_vs_sweep.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,38 @@ fn hop_gather(store: &RowStore, src: &[u64], effective: u32, n_words: usize) ->
out
}

/// MEMOISED — the shipped shape. `facet_bits` is one 32-bit MASK per row, so
/// the participation test is an AND. Passing the mask in lets the caller time
/// the WARM case; `RowStore::facet_bits` builds and caches it on first ask.
fn hop_memoised(
store: &RowStore,
src: &[u64],
effective: u32,
n_words: usize,
facet_bits: &[u32],
) -> Vec<u64> {
let bytes = store.as_bytes();
let mut out = vec![0u64; n_words];
for (w, &sw) in src.iter().enumerate() {
let mut bits = sw;
while bits != 0 {
let bit = bits.trailing_zeros();
bits &= bits - 1;
let row = (w as u64) * 64 + u64::from(bit);
if row >= store.n_rows {
continue;
}
let mut fb = facet_bits[row as usize] & effective;
while fb != 0 {
let facet = fb.trailing_zeros();
fb &= fb - 1;
decode_into(bytes, store.n_rows, row, facet, &mut out);
}
}
}
out
}

/// A frontier of `count` rows spread across the whole population by stride —
/// deliberately scattered, because a real BFS frontier after one hop is. A
/// contiguous prefix would hand `gather` an unrepresentatively cache-friendly
Expand Down Expand Up @@ -162,8 +194,8 @@ fn main() {

println!("== n_rows = {n_rows} ==");
println!(
"{:>10} {:>9} {:>12} {:>12} {:>10} faster",
"frontier", "density", "sweep_us", "gather_us", "ratio"
"{:>10} {:>9} {:>12} {:>12} {:>12} {:>12}",
"frontier", "density", "sweep_us", "gather_us", "memo_cold", "memo_warm"
);

for &pct in &[
Expand All @@ -190,11 +222,32 @@ fn main() {
std::hint::black_box(hop_gather(&store, &src, effective, n_words));
});

let ratio = sweep / gather;
let faster = if ratio > 1.0 { "gather" } else { "sweep" };
let fb = store.facet_bits(EDGE_CLASSID);
let memo_warm = time_us(|| {
std::hint::black_box(hop_memoised(&store, &src, effective, n_words, &fb));
});
// COLD: the O(n) mask build plus one hop -- what the FIRST hop on
// a fresh (store, classid) actually pays. Timed by building the
// mask from scratch each rep rather than reading the cache.
let memo_cold = time_us(|| {
let mut built = vec![0u32; store.n_rows as usize];
lgj_abi::kernels::simd_rowstore_facet_match(
&store.bytes_arc(),
store.n_rows as usize,
EDGE_CLASSID,
&mut built,
);
std::hint::black_box(hop_memoised(&store, &src, effective, n_words, &built));
});
assert_eq!(
hop_memoised(&store, &src, effective, n_words, &fb),
a,
"memoised disagrees at n_rows={n_rows} pct={pct}"
);

println!(
"{:>10} {:>8.2}% {:>12.1} {:>12.1} {:>9.2}x {faster}",
count, pct, sweep, gather, ratio
"{:>10} {:>8.2}% {:>12.1} {:>12.1} {:>12.1} {:>12.1}",
count, pct, sweep, gather, memo_cold, memo_warm
);
}
println!();
Expand Down
54 changes: 29 additions & 25 deletions native/lgj-abi/src/exports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1613,24 +1613,31 @@ pub extern "C" fn lgj_hop(
let mut out = vec![0u64; n_words];
let bytes = rowstore.as_bytes();

// GATHER: touch only the rows `src` names.
// AND OVER MASKS, on a memoised per-facet mask.
//
// The previous shape swept the whole population into an
// `n`-element `facet_bits` buffer and then intersected with `src`.
// Measured by `examples/hop_gather_vs_sweep.rs` across four
// populations x twelve densities, with byte-identical output
// asserted at every point: the gather wins EVERY configuration --
// 2612x at 0.01% density and still 1.7x at 100%.
// `facet_bits(classid)[row]` is a 32-bit MASK of which facets of that
// row carry the edge class, so the participation test is one AND --
// `facet_bits[row] & effective` -- and not 32 byte compares. Built
// once per (store, classid) through the sanctioned `ndarray::simd`
// kernel and shared by refcount thereafter; the store is immutable, so
// it never needs invalidation.
//
// No crossover, so no threshold and no dispatch. The reason it
// holds even at full density is that the sweep materialises a
// per-row intermediate each row reads exactly once: it does
// everything this loop does, PLUS allocate, zero, write and re-read
// `n` u32s. Strictly more work everywhere.
// This is the shape the substrate's own currency asks for, and it took
// three tries to get here honestly:
//
// This loop is scalar and beats the vectorised sweep. The win is in
// not doing the work, not in the vector width -- see the doc
// comment above for why no `ndarray::simd` primitive is missing.
// 1. 32 full-width classid sweeps per hop -- mask-shaped, but the
// AND was computed over the whole population EVERY hop. 24.8 ms.
// 2. gather: skip the mask entirely, read each src row's facets
// inline. 34 us -- 720x faster, and measured to beat a sweep at
// every density (no crossover exists), but it traded the algebra
// away: the classid predicate stopped being a mask at all.
// 3. this: keep the mask, pay for it ONCE. The AND is what the
// earlier sweeps were doing; memoising is what makes it cheap.
//
// Only the scatter stays outside mask algebra, and irreducibly so: the
// destination index is DECODED from the row's payload, so it is a
// data-dependent scatter, not a set operation.
let facet_bits = rowstore.facet_bits(edge_classid);
let effective_facets = effective as u32;
for (w, &sw) in src_snapshot.iter().enumerate() {
let mut bits = sw;
Expand All @@ -1645,16 +1652,13 @@ pub extern "C" fn lgj_hop(
if row >= n_rows {
continue;
}
let row_base = (row * crate::rowstore::ROW_BYTES) as usize;
for facet in 0..crate::rowstore::ROW_FACETS {
if (effective_facets >> facet) & 1 == 0 {
continue;
}
let base = row_base + facet as usize * crate::rowstore::FACET_BYTES as usize;
let classid = u32::from_le_bytes(bytes[base..base + 4].try_into().unwrap());
if classid != edge_classid {
continue;
}
let mut fb = facet_bits[row as usize] & effective_facets;
while fb != 0 {
let facet = fb.trailing_zeros();
fb &= fb - 1;
let base = (row * crate::rowstore::ROW_BYTES
+ u64::from(facet) * crate::rowstore::FACET_BYTES)
as usize;
let payload_hi32 =
u32::from_le_bytes(bytes[base + 12..base + 16].try_into().unwrap());
if payload_hi32 != 0 {
Expand Down
66 changes: 66 additions & 0 deletions native/lgj-abi/src/rowstore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,29 @@ pub struct RowStore {
/// The seed the buffer was generated from.
pub seed: u64,
bytes: Arc<[u8]>,
/// Memoised per-row facet-match masks, keyed by classid.
///
/// `facet_bits(c)[row]` has bit `f` set iff facet `f` of that row carries
/// classid `c` — i.e. one 32-bit MASK per row, which is what makes a hop
/// an AND rather than a byte compare.
///
/// **No invalidation, by construction.** `RowStore` exposes no `&mut self`
/// method: the buffer is built once in `generate`/`generate_with_edges`
/// and is immutable for the store's whole life. So a cached answer can
/// never go stale, and this needs none of the machinery a mutable-store
/// cache would.
facet_cache: std::sync::RwLock<Vec<(u32, Arc<[u32]>)>>,
}

/// How many distinct classids keep a memoised mask per store.
///
/// Each entry costs `n_rows * 4` bytes (1 MiB at 262 144 rows), so this is
/// bounded rather than unbounded-by-classid. Four covers the shapes measured
/// here — a traversal reuses one edge class far more often than it rotates
/// between many — and eviction is oldest-first rather than LRU because at this
/// size the bookkeeping would cost more than the miss it avoids.
const FACET_CACHE_SLOTS: usize = 4;

impl std::fmt::Debug for RowStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RowStore")
Expand Down Expand Up @@ -131,6 +152,7 @@ impl RowStore {
n_rows,
seed,
bytes: Arc::from(bytes),
facet_cache: std::sync::RwLock::new(Vec::new()),
})
}

Expand Down Expand Up @@ -226,6 +248,7 @@ impl RowStore {
n_rows,
seed,
bytes: Arc::from(bytes),
facet_cache: std::sync::RwLock::new(Vec::new()),
})
}

Expand All @@ -235,6 +258,49 @@ impl RowStore {
&self.bytes
}

/// The memoised per-row facet-match mask for `classid`, built on first
/// ask and shared thereafter.
///
/// This is the hop's mask half. `out[row]` is a 32-bit mask of which
/// facets of that row carry `classid`, so a hop is
/// `facet_bits[row] & participation` — an AND over masks — rather than 32
/// byte compares per row. Building it is one `MultiLaneColumn` pass
/// through the sanctioned `ndarray::simd` kernel; the point of memoising
/// is that a traversal pays that O(n) pass ONCE and every subsequent hop
/// on the same `(store, classid)` is the AND alone.
///
/// Returns `Arc<[u32]>` deliberately: the caller shares the buffer, never
/// copies it. A cache hit is a refcount bump.
///
/// Concurrency: the read lock is dropped before any build, so two threads
/// racing on a cold classid may both build. That is a wasted pass, never a
/// wrong answer — the store is immutable, so both compute the identical
/// buffer — and it is preferred to holding a write lock across an O(n)
/// SIMD sweep.
pub fn facet_bits(&self, classid: u32) -> Arc<[u32]> {
if let Ok(g) = self.facet_cache.read() {
if let Some((_, bits)) = g.iter().find(|(c, _)| *c == classid) {
return Arc::clone(bits);
}
}

let n = self.n_rows as usize;
let mut built = vec![0u32; n];
crate::kernels::simd_rowstore_facet_match(&self.bytes, n, classid, &mut built);
let bits: Arc<[u32]> = Arc::from(built);

if let Ok(mut g) = self.facet_cache.write() {
// Another thread may have won the race; keep one entry per classid.
if !g.iter().any(|(c, _)| *c == classid) {
if g.len() >= FACET_CACHE_SLOTS {
g.remove(0);
}
g.push((classid, Arc::clone(&bits)));
}
}
bits
}

/// A cheap shared handle to the same bytes — what the kernels wrap in a
/// `MultiLaneColumn` without copying.
pub fn bytes_arc(&self) -> Arc<[u8]> {
Expand Down