From b39cbac1f7a246344c302b68232c14a142dce71a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 08:12:59 +0000 Subject: [PATCH 1/3] =?UTF-8?q?mask-risc:=20D-CMR-2b=20probe=20=E2=80=94?= =?UTF-8?q?=20a=20scratch-free=20Pred=20fold=20does=20not=20pay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lab example only; ir.rs and exec.rs are unchanged. pred_scratch_probe compares today's tiled execute_extent (scratch reused, and scratch allocated per call) with a fold that compares into chunks and counts them in one mask_ternlog_popcount, with fresh and with reused chunks. Every configuration is checked against a bit-serial oracle and arm A first; timing is interleaved per rep with a null arm. Result: at most ~5% at one tile on the best shape, 0–4% from 1M rows, inside the null's spread; slower where no pass is saved. Phase 2 (a Pred lowering) is not supported. Board: entry, STATUS and plan rows for D-CMR-2b, LATEST_STATE. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013z2e1YXkm6o6HNrDn5BPWA --- .claude/board/LATEST_STATE.md | 4 + .claude/board/STATUS_BOARD.md | 1 + .../2026-10-21-d-cmr-2b-scratch-probe.md | 22 + .claude/board/entries/README.md | 3 +- .../d-cmr-concept-chain-normalization-v1.md | 1 + .../examples/pred_scratch_probe.rs | 388 ++++++++++++++++++ 6 files changed, 418 insertions(+), 1 deletion(-) create mode 100644 .claude/board/entries/2026-10-21-d-cmr-2b-scratch-probe.md create mode 100644 crates/lance-graph-mask-risc/examples/pred_scratch_probe.rs diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index 82f73c451..62007be80 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -1,3 +1,7 @@ +## 2026-10-21 — D-CMR-2b measured: a scratch-free `Pred` fold does not pay (in PR) + +- **mask-risc (lab example only, no library change):** `examples/pred_scratch_probe.rs`. Against today's tiled path with reused scratch, a fused compare-and-count fold gains at most ~5 % at one tile and 0–4 % from 1M rows, inside a null arm's spread; it is slower where no pass is saved. Phase 2 (a `Pred` lowering in `ir.rs` / `exec.rs`) is not supported. Entry `entries/2026-10-21-d-cmr-2b-scratch-probe.md`. Contract inventory: unchanged. + ## 2026-10-21 — main after #1654 (merged `d754128`) - **On main:** #1652, #1653, #1654 (D-RA-2 repoint of relalg `symbolic` onto `contract::set_rules`; `Rule::{EmptyMeasure, CountAll}`; #1651 closeout). Contract inventory delta: two `set_rules::Rule` variants (see the D-RA-2 section below). diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index 71c00a051..e57e52733 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -256,6 +256,7 @@ Plan: `.claude/plans/d-cmr-concept-chain-normalization-v1.md`. Parent: D-CMR-RES | **D-CMR-0** | Plan, identity matrix, inventory, board rows | Shipped #1532 (docs) | docs only; every inventory row cites code | | **D-CMR-1** | Set-theory IR core in the contract: `Runs` (∩ ∪ ∖, complement), residual `Res`, measures; pure | Shipped (#1644, `c05a313`): `contract::set_algebra::{SetExpr, Res, Runs, normalize}`; measures not yet (`entries/2026-10-20-d-cmr-1-set-algebra.md`) | algebra laws vs materialized bitsets as oracle | | **D-CMR-2** | Lowering: runs → extents, residual → predicates per run; measurement levels geometry / optics / etch | Shipped (#1650, `f031089`): `lance_graph::fold::set_lower::{SetPlan, SortedBasis, Level, Measured}`; geometry reads nothing, optics/etch run the residual per run via `execute_extent`. The scratch-free `Pred` fold in mask-risc is D-CMR-2b, not built (`entries/2026-10-21-d-cmr-2-lowering.md`) | == row oracle; `Count` without residual reads zero rows | +| **D-CMR-2b** | Scratch-free `Pred` fold in mask-risc (lab first) | Measured, phase 2 not supported (`examples/pred_scratch_probe.rs`; `entries/2026-10-21-d-cmr-2b-scratch-probe.md`): best ~5 % at one tile on S3, 0–4 % from 1M rows, inside the null's spread; slower where no pass is saved | answers == oracle and arm A first; interleaved paired timing with a null arm | | **D-CMR-3** | Quack frontend: `Filter` → set IR (extent hoisting falls out) | Queued | identical answers on the quack suite; other-basis range stays residual | | **D-CMR-4** | Two dimensions: `Rect` over composite coordinates, row-major or Morton runs | Queued | rectangle ∩ == bitset oracle | | **D-CMR-5** | **BLOCKED: lawful design proposed (operator 2026-10-21).** Preimage `f⁻¹(S)` as a co-addressed transposed tile fold over a version-witnessed projection of `f`, built in preparation by a sequential same-table scan with frozen-codebook endpoint translation (ADMISSION B; reusable across queries). `S` enters only as a membership plane translated into the same frozen coordinates. No seek or load of `f` keyed by `S`'s values or a previous fold (Q1 = C), no `Gather`, no scatter terminal. R1 (`rmap` `rebind` / `discover` escape-driven seeks) stays option C. ⊘ Was: "VIOLATION — GATHER. DO NOT BUILD" (entry `2026-10-21-d-cmr-5-rescope.md`) | BLOCKED — needs a contract-level attested witness for the frozen codebook (`rmap::Witness` is a probe-local stand-in); no preimage operator exists; not admitted, not ready to build | == row oracle; program checked Gather-free with a silence twin; attest / projection / query costs reported apart; a many-to-one `f` gives a valid distinct-source preimage (tested) and a bag-sensitive Count/Sum over it is refused (tested); refusal on a stale or missing witness | diff --git a/.claude/board/entries/2026-10-21-d-cmr-2b-scratch-probe.md b/.claude/board/entries/2026-10-21-d-cmr-2b-scratch-probe.md new file mode 100644 index 000000000..fa223268c --- /dev/null +++ b/.claude/board/entries/2026-10-21-d-cmr-2b-scratch-probe.md @@ -0,0 +1,22 @@ +# 2026-10-21 — D-CMR-2b phase 1: a scratch-free `Pred` fold does not pay + +**Status:** MEASURED (lab probe, no production change). **Basis:** D-CMR-2's open item (`entries/2026-10-21-d-cmr-2-lowering.md`); operator authorization 2026-10-21; conditions of `gpt-ada-01/007`; claim EXO-50 `session_013z2e1YXkm6o6HNrDn5BPWA/023`. + +- **Dependencies:** ndarray sibling at `origin/master` `37a3b59` (workspace `[patch]`); `ndarray::simd` has every compare→mask primitive (`*_i32_to_mask[_under]`, `*_u32_to_mask[_under]`), `mask_ternlog_popcount`, `popcount_batch_u64`; no fused compare-and-count primitive. mask-risc lib 47/47 against it. Host AVX-512 (Xeon @ 2.10 GHz, 4 cores), `target-cpu=native`; the probe prints the tier. +- **What scratch is today:** caller-owned, `slots × TILE_WORDS` (256 words = 2 KiB per slot), allocated once per `Scratch::for_program`, never population-sized. So the question was per-op tile write-back / re-read and the separate `And` + count passes, not heap memory. +- **Probe:** `crates/lance-graph-mask-risc/examples/pred_scratch_probe.rs`. Shapes `S1` Count(a), `S2` Count(a∧b), `S2u` b gated `under` a, `S3` Count(a∧b∧plane) over `i32` lanes; 16k / 1M / 4M rows; ~1 / 50 / 99 % selectivity. Arms: **A** `execute_extent` with reused scratch; **A+alloc** with `Scratch::for_program` per call (what `fold::set_lower` does); **B** compares into fresh stack chunks + one `mask_ternlog_popcount`; **B′** the same fold over reused chunks. Every configuration asserted against a bit-serial oracle and arm A first. Timing interleaved per rep with rotating order, median of 101 per-rep ratios, plus a null arm (A timed twice). +- **Result (runs 2–4; run 1 used non-interleaved timing and is discarded as noise — S1's B/A moved 0.78–1.13 with selectivity, which S1's work does not depend on):** + + | | 16k rows (1 tile) | 1M | 4M | + |---|---|---|---| + | null A′/A | 0.985–0.999 | 0.963–1.047 | 0.981–1.011 | + | A+alloc / A | 1.009–1.098 | 0.969–1.018 | 0.990–1.017 | + | B′/A, S3 (2 `And` passes saved) | 0.946–0.982 | 0.957–0.996 | 0.984–0.998 | + | B′/A, S2 (1 saved) | 0.958–0.996 | 0.957–1.021 | 0.980–0.998 | + | B′/A, S1 / S2u (none saved) | 0.968–1.051 | 0.988–1.033 | 0.993–1.033 | + | B/A (fresh stack chunks) | 0.973–1.094 | 0.960–1.045 | 0.982–1.043 | + +- **Reading:** the best case is ~5 % at one tile on S3; from 1M rows the effect is 0–4 %, inside the null's spread. Where no pass is saved the fused count is slower (it reads three operands where `popcount` reads one). Fresh stack chunks cost 2–5 % at one tile, so "scratch-free" is never better than the reused caller scratch the executor already takes. The only measurable cost of today's path is the per-call `Scratch::for_program` at one tile (~1–10 %), which vanishes from 1M rows. +- **Decision input:** phase 2 (a `Lowering` arm for `Pred` in `ir.rs` / `exec.rs`) is not supported by this measurement. Not done. + +OPEN: none of D-CMR-2b's own. Not measured: `u32` / strided lanes, more than two comparisons, `Keep` terminals, other tiers (AVX2, NEON). diff --git a/.claude/board/entries/README.md b/.claude/board/entries/README.md index 4f8798545..b5018bf36 100644 --- a/.claude/board/entries/README.md +++ b/.claude/board/entries/README.md @@ -25,7 +25,7 @@ index row, (3) no duplicate entry id. Checks 1 and 2 are deliberately opposite directions; the stranding this convention prevents shows up in exactly one of them, never both. -406 entries, 2026-08-06 .. 2026-10-21. +407 entries, 2026-08-06 .. 2026-10-21. | date | entry id | finding | file | |---|---|---|---| @@ -37,6 +37,7 @@ exactly one of them, never both. | 2026-10-21 | `D-RA-2` | | [2026-10-21-d-ra-2-relalg-symbolic-repoint.md](2026-10-21-d-ra-2-relalg-symbolic-repoint.md) | | 2026-10-21 | `D-CMR-6` | | [2026-10-21-d-cmr-6-rule-catalogue.md](2026-10-21-d-cmr-6-rule-catalogue.md) | | 2026-10-21 | `D-CMR-5` | | [2026-10-21-d-cmr-5-rescope.md](2026-10-21-d-cmr-5-rescope.md) | +| 2026-10-21 | `D-CMR-2b` | | [2026-10-21-d-cmr-2b-scratch-probe.md](2026-10-21-d-cmr-2b-scratch-probe.md) | | 2026-10-21 | `D-CMR-2` | | [2026-10-21-d-cmr-2-lowering.md](2026-10-21-d-cmr-2-lowering.md) | | 2026-10-20 | `D-RU-1` | | [2026-10-20-u1-jc-batch.md](2026-10-20-u1-jc-batch.md) | | 2026-10-20 | `thought-git-replay-api-write-rubicon` | | [2026-10-20-thought-git-replay-api-write-rubicon.md](2026-10-20-thought-git-replay-api-write-rubicon.md) | diff --git a/.claude/plans/d-cmr-concept-chain-normalization-v1.md b/.claude/plans/d-cmr-concept-chain-normalization-v1.md index d7dd8525d..95d82031f 100644 --- a/.claude/plans/d-cmr-concept-chain-normalization-v1.md +++ b/.claude/plans/d-cmr-concept-chain-normalization-v1.md @@ -211,6 +211,7 @@ when the first floating-point or quantized column arrives, not before. |---|---|---| | D-CMR-1 | **Set IR core** in the contract: `Runs` with `∩ ∪ ∖` and complement, `Res`, measures; pure, no lowering | algebra laws (closure, distributivity, De Morgan, `∅`) checked against materialized bitsets as oracle on random and edge-case runs | | D-CMR-2 | **Lowering** to mask-risc: runs → extents, residual → predicates within each run, the three measurement levels. Level 2 for residual comparisons needs predicate fusion in mask-risc (`fused_ternlog` declines `Pred` today) | == row oracle; a `Count` with no residual reads zero rows (instrumented); disabling level 1 or 2 keeps answers | +| D-CMR-2b | **Scratch-free `Pred` fold** (lab first, phase 2 only on a measured win) | interleaved paired timing against today's tiled `execute_extent` with a null arm; measured 2026-10-21: no win worth a `Lowering` arm (`entries/2026-10-21-d-cmr-2b-scratch-probe.md`) | | D-CMR-3 | **Quack normalization pass:** over `Filter`, computing set values (ranges on a proven basis → `Runs`, other leaves → `Res`, `sql_where` first). Extent hoisting falls out of this (Experiment C) | answers identical to today's `lower` path on the quack suite; a range on another basis stays residual (negative control) | | D-CMR-4 | **Two dimensions:** `Rect` over composite coordinates, linearized row-major or in Morton order | rectangle `∩` == bitset oracle; run count within the stated bound | | D-CMR-5 | **BLOCKED: lawful design proposed (operator 2026-10-21).** Preimage `f⁻¹(S)` as a co-addressed transposed tile fold over a version-witnessed projection of `f`, built in preparation by a sequential same-table scan with frozen-codebook endpoint translation (ADMISSION B; reusable across queries). `S` enters only as a membership plane translated into the same frozen coordinates. No seek or load of `f` keyed by `S`'s values or a previous fold (Q1 = C), no `Gather`, no scatter terminal. R1 (`rmap` `rebind` / `discover` escape-driven seeks) stays option C. ⊘ Was: "VIOLATION — GATHER. DO NOT BUILD" (entry `2026-10-21-d-cmr-5-rescope.md`) | == row oracle; program checked Gather-free with a silence twin; attest / projection / query costs reported apart; a many-to-one `f` gives a valid distinct-source preimage (tested) and a bag-sensitive Count/Sum over it is refused (tested); refusal on a stale or missing witness | diff --git a/crates/lance-graph-mask-risc/examples/pred_scratch_probe.rs b/crates/lance-graph-mask-risc/examples/pred_scratch_probe.rs new file mode 100644 index 000000000..826c24efb --- /dev/null +++ b/crates/lance-graph-mask-risc/examples/pred_scratch_probe.rs @@ -0,0 +1,388 @@ +//! D-CMR-2b, phase 1 (lab only): what does a residual comparison's scratch +//! actually cost, and does a scratch-free fold win? +//! +//! `fused_ternlog` declines `Pred`, so a program with a comparison runs tiled: +//! each op writes its destination slot for one tile ([`TILE_WORDS`] words), +//! later ops re-read it, and the terminal counts the last slot. Scratch is +//! caller-owned and tile-sized (`slots × TILE_WORDS` words), never +//! population-sized, so the question is not heap memory. It is whether the +//! per-op tile write-back and re-read, and the separate `And` + count passes, +//! cost anything measurable against a fold that compares into stack chunks and +//! counts them in one fused `mask_ternlog_popcount` call. +//! +//! Arms, on the same data and the same programs: +//! +//! - **A** `execute_extent` with a `Scratch` built once outside the timing +//! (what a caller that reuses its scratch pays). +//! - **A+alloc** the same call with `Scratch::for_program` inside the timing +//! (what `fold::set_lower` pays per call today). +//! - **B** a lab fold: per tile, each comparison into a stack chunk via the +//! same `ndarray::simd` compare primitive the executor uses, then ONE +//! `mask_ternlog_popcount` over the chunks (and a resident plane). No heap +//! scratch, no `And` pass, no count pass of its own. +//! +//! Shapes (`a = lane0 > ta`, `b = lane1 < tb`, `p = plane 0`): +//! +//! - `S1` Count(a) +//! - `S2` Count(a ∧ b) +//! - `S2u` Count(a ∧ b) with `b` gated `under` `a` (the executor's own fused +//! compare-under form; arm B uses `*_to_mask_under` likewise) +//! - `S3` Count(a ∧ b ∧ p) +//! +//! Every configuration is checked against a bit-serial row oracle and arm A +//! before anything is timed. Reported: scratch bytes, the op passes each arm +//! makes per tile, median ns of 101 and the B/A ratio. +//! +//! `cargo run --release -p lance-graph-mask-risc --example pred_scratch_probe` + +use std::time::Instant; + +use lance_graph_mask_risc::exec::{execute_extent, Scratch, TILE_WORDS}; +use lance_graph_mask_risc::{ + words_for, Foreign, LaneRef, MaskOp, Operand, Out, Planes, Pred, Program, Terminal, Value, +}; +use ndarray::simd::{gt_i32_to_mask, lt_i32_to_mask, lt_i32_to_mask_under, mask_ternlog_popcount}; + +/// `a & b & c` in the VPTERNLOG input convention. +const AND3: i32 = 0x80; + +fn lcg(seed: &mut u64) -> u64 { + *seed = seed + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + *seed >> 11 +} + +fn time(f: &mut impl FnMut() -> T) -> f64 { + let t = Instant::now(); + std::hint::black_box(f()); + t.elapsed().as_nanos() as f64 +} + +fn med(mut v: Vec) -> f64 { + v.sort_by(|a, b| a.total_cmp(b)); + v[v.len() / 2] +} + +/// Interleaved, paired timing: every rep runs A, A again (the null arm), +/// A+alloc, B and B' back to back, in an order that rotates per rep, so drift +/// and frequency changes hit all arms alike. Returns the median ns of A and +/// the medians of the per-rep ratios A'/A (the method's own noise), +/// A+alloc/A, B/A and B'/A. +fn paired( + reps: usize, + mut a: impl FnMut() -> usize, + mut aa: impl FnMut() -> usize, + mut b: impl FnMut() -> usize, + mut b2: impl FnMut() -> usize, +) -> (f64, [f64; 4]) { + let mut ta = vec![]; + let mut ratios: [Vec; 4] = Default::default(); + for r in 0..reps { + let mut t = [0f64; 5]; + for k in 0..5 { + let i = (k + r) % 5; + t[i] = match i { + 0 | 1 => time(&mut a), + 2 => time(&mut aa), + 3 => time(&mut b), + _ => time(&mut b2), + }; + } + ta.push(t[0]); + for j in 0..4 { + ratios[j].push(t[j + 1] / t[0]); + } + } + let [n, al, b, b2] = ratios; + (med(ta), [med(n), med(al), med(b), med(b2)]) +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Shape { + S1, + S2, + S2u, + S3, +} + +fn program(shape: Shape, ta: i32, tb: i32) -> Program { + let a = Pred::GtI32 { lane: 0, t: ta }; + let b = Pred::LtI32 { lane: 1, t: tb }; + let (ops, last) = match shape { + Shape::S1 => ( + vec![MaskOp::Pred { + pred: a, + under: None, + dst: 0, + }], + 0, + ), + Shape::S2 => ( + vec![ + MaskOp::Pred { + pred: a, + under: None, + dst: 0, + }, + MaskOp::Pred { + pred: b, + under: None, + dst: 1, + }, + MaskOp::And { + a: Operand::Scratch(0), + b: Operand::Scratch(1), + dst: 2, + }, + ], + 2, + ), + Shape::S2u => ( + vec![ + MaskOp::Pred { + pred: a, + under: None, + dst: 0, + }, + MaskOp::Pred { + pred: b, + under: Some(Operand::Scratch(0)), + dst: 1, + }, + ], + 1, + ), + Shape::S3 => ( + vec![ + MaskOp::Pred { + pred: a, + under: None, + dst: 0, + }, + MaskOp::Pred { + pred: b, + under: None, + dst: 1, + }, + MaskOp::And { + a: Operand::Scratch(0), + b: Operand::Scratch(1), + dst: 2, + }, + MaskOp::And { + a: Operand::Scratch(2), + b: Operand::Plane(0), + dst: 3, + }, + ], + 3, + ), + }; + Program::new( + ops, + Terminal::Count { + mask: Operand::Scratch(last), + }, + ) +} + +/// The bit-serial reference. +fn oracle(shape: Shape, l0: &[i32], l1: &[i32], plane: &[u64], ta: i32, tb: i32) -> usize { + (0..l0.len()) + .filter(|&r| { + let a = l0[r] > ta; + let b = l1[r] < tb; + let p = plane[r / 64] >> (r % 64) & 1 == 1; + match shape { + Shape::S1 => a, + Shape::S2 | Shape::S2u => a && b, + Shape::S3 => a && b && p, + } + }) + .count() +} + +/// Arm B: compare into fresh stack chunks, one fused count per tile. +fn fold_b(shape: Shape, l0: &[i32], l1: &[i32], plane: &[u64], ta: i32, tb: i32) -> usize { + let mut ca = [0u64; TILE_WORDS]; + let mut cb = [0u64; TILE_WORDS]; + fold_into(shape, l0, l1, plane, ta, tb, &mut ca, &mut cb) +} + +/// The fold itself, over chunks the caller provides. Arm B' calls it with +/// chunks reused across calls (as arm A reuses its scratch), which isolates +/// the value of the fused count from the cost of initialising the chunks. +#[allow(clippy::too_many_arguments)] +fn fold_into( + shape: Shape, + l0: &[i32], + l1: &[i32], + plane: &[u64], + ta: i32, + tb: i32, + ca: &mut [u64; TILE_WORDS], + cb: &mut [u64; TILE_WORDS], +) -> usize { + let n = l0.len(); + let words = words_for(n); + let mut total = 0u64; + let mut w = 0; + while w < words { + let tw = TILE_WORDS.min(words - w); + let rows = w * 64..((w + tw) * 64).min(n); + let (ca, cb) = (&mut ca[..tw], &mut cb[..tw]); + gt_i32_to_mask(&l0[rows.clone()], ta, ca); + total += match shape { + Shape::S1 => mask_ternlog_popcount::(ca, ca, ca), + Shape::S2 => { + lt_i32_to_mask(&l1[rows], tb, cb); + mask_ternlog_popcount::(ca, cb, ca) + } + Shape::S2u => { + lt_i32_to_mask_under(&l1[rows], tb, ca, cb); + mask_ternlog_popcount::(cb, cb, cb) + } + Shape::S3 => { + lt_i32_to_mask(&l1[rows], tb, cb); + mask_ternlog_popcount::(ca, cb, &plane[w..w + tw]) + } + }; + w += tw; + } + total as usize +} + +/// Op passes over one tile: (arm A, arm B). +fn passes(shape: Shape) -> (usize, usize) { + match shape { + // A: compare, count. B: compare, fused count. + Shape::S1 => (2, 2), + // A: compare, compare, and, count. B: compare, compare, fused count. + Shape::S2 => (4, 3), + // A: compare, compare-under, count. B: the same three. + Shape::S2u => (3, 3), + // A: compare, compare, and, and, count. B: compare, compare, fused count. + Shape::S3 => (5, 3), + } +} + +fn main() { + let tier = if cfg!(target_feature = "avx512f") { + "avx512f" + } else if cfg!(target_feature = "avx2") { + "avx2" + } else { + "scalar/other" + }; + println!("D-CMR-2b phase 1 probe; build tier: {tier}; TILE_WORDS = {TILE_WORDS}"); + println!( + "{:>9} {:>4} {:>5} | {:>10} {:>8} | {:>6} | {:>11} | {:>7} {:>9} {:>7} {:>7}", + "n_rows", + "shp", + "sel%", + "scratch_B", + "stack_B", + "pass", + "A_ns", + "null", + "alloc/A", + "B/A", + "B'/A" + ); + let mut seed = 0x9E37_79B9_7F4A_7C15u64; + for &n in &[16_384usize, 1 << 20, 4 << 20] { + let l0: Vec = (0..n).map(|_| (lcg(&mut seed) % 1000) as i32).collect(); + let l1: Vec = (0..n).map(|_| (lcg(&mut seed) % 1000) as i32).collect(); + let mut plane: Vec = (0..words_for(n)) + .map(|_| lcg(&mut seed) ^ lcg(&mut seed) << 21) + .collect(); + if n % 64 != 0 { + let last = plane.len() - 1; + plane[last] &= (1u64 << (n % 64)) - 1; + } + let masks: [&[u64]; 1] = [&plane]; + let lanes = [LaneRef::I32(&l0), LaneRef::I32(&l1)]; + let planes = Planes { + n_rows: n, + masks: &masks, + lanes: &lanes, + }; + // Thresholds giving roughly 1 %, 50 % and 99 % per comparison. + for &(ta, tb, sel) in &[(989, 10, 1), (499, 500, 50), (9, 990, 99)] { + for shape in [Shape::S1, Shape::S2, Shape::S2u, Shape::S3] { + let p = program(shape, ta, tb); + let want = oracle(shape, &l0, &l1, &plane, ta, tb); + let mut scratch = Scratch::for_program(&p, n).expect("scratch"); + let run_a = |s: &mut Scratch<'_>| match execute_extent( + &p, + &planes, + &Foreign::NONE, + s, + Out::None, + 0..n, + ) + .expect("execute") + { + Value::Count(c) => c, + v => panic!("unexpected {v:?}"), + }; + assert_eq!(run_a(&mut scratch), want, "A {shape:?} n={n} sel={sel}"); + assert_eq!( + fold_b(shape, &l0, &l1, &plane, ta, tb), + want, + "B {shape:?} n={n} sel={sel}" + ); + + let reps = 101; + let scratch = std::cell::RefCell::new(scratch); + let chunks = std::cell::RefCell::new(([0u64; TILE_WORDS], [0u64; TILE_WORDS])); + { + let (ca, cb) = &mut *chunks.borrow_mut(); + assert_eq!( + fold_into(shape, &l0, &l1, &plane, ta, tb, ca, cb), + want, + "B' {shape:?}" + ); + } + let (a_ns, [null, alloc, ratio, ratio2]) = paired( + reps, + || run_a(&mut scratch.borrow_mut()), + || { + let mut s = Scratch::for_program(&p, n).expect("scratch"); + run_a(&mut s) + }, + || fold_b(shape, &l0, &l1, &plane, ta, tb), + || { + let (ca, cb) = &mut *chunks.borrow_mut(); + fold_into(shape, &l0, &l1, &plane, ta, tb, ca, cb) + }, + ); + let slots = p.scratch_slots as usize; + let tw = TILE_WORDS.min(words_for(n)); + let scratch_b = slots * tw * 8; + let stack_b = match shape { + Shape::S1 => 1, + _ => 2, + } * TILE_WORDS + * 8; + let (pa, pb) = passes(shape); + println!( + "{:>9} {:>4} {:>5} | {:>10} {:>8} | {:>2}/{:<3} | {:>11.0} | {:>7.3} {:>9.3} {:>7.3} {:>7.3}", + n, + format!("{shape:?}"), + sel, + scratch_b, + stack_b, + pa, + pb, + a_ns, + null, + alloc, + ratio, + ratio2 + ); + } + } + } +} From aded309dd7b95003dc63817c0b16b543e92c62f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 08:22:17 +0000 Subject: [PATCH 2/3] benches: move the D-CMR-2b probe to lance-graph-benches; add set_lower's run-shaped workload Codex review of #1656: benchmarks belong in lance-graph-benches (AGENTS.md), and set_lower calls execute_extent once per run over short, unaligned extents, which whole-extent timing does not cover. The probe now also times K unaligned runs of 200, 3,000 and 40,000 rows. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013z2e1YXkm6o6HNrDn5BPWA --- crates/lance-graph-benches/Cargo.toml | 4 + .../examples/pred_scratch_probe.rs | 410 ++++++++++++++++++ .../examples/pred_scratch_probe.rs | 388 ----------------- 3 files changed, 414 insertions(+), 388 deletions(-) create mode 100644 crates/lance-graph-benches/examples/pred_scratch_probe.rs delete mode 100644 crates/lance-graph-mask-risc/examples/pred_scratch_probe.rs diff --git a/crates/lance-graph-benches/Cargo.toml b/crates/lance-graph-benches/Cargo.toml index 35c1ecf2f..79eead2ac 100644 --- a/crates/lance-graph-benches/Cargo.toml +++ b/crates/lance-graph-benches/Cargo.toml @@ -24,6 +24,10 @@ datafusion = { workspace = true } serde_json = "1" lance-graph-quack = { path = "../lance-graph-quack" } lance-graph-mask-risc = { path = "../lance-graph-mask-risc" } +# `examples/pred_scratch_probe.rs` (D-CMR-2b) calls the `ndarray::simd` +# compare and fold primitives directly for its scratch-free arm. Same package +# as mask-risc's git coordinate through the workspace `[patch]`. +ndarray = { path = "../../../ndarray", default-features = false, features = ["std"] } [[bench]] name = "graph_execution" diff --git a/crates/lance-graph-benches/examples/pred_scratch_probe.rs b/crates/lance-graph-benches/examples/pred_scratch_probe.rs new file mode 100644 index 000000000..e7455c5f1 --- /dev/null +++ b/crates/lance-graph-benches/examples/pred_scratch_probe.rs @@ -0,0 +1,410 @@ +//! D-CMR-2b, phase 1 (lab only): what does a residual comparison's scratch +//! actually cost, and does a scratch-free fold win? +//! +//! `fused_ternlog` declines `Pred`, so a program with a comparison runs tiled: +//! each op writes its destination slot for one tile (`TILE_WORDS` words), +//! later ops re-read it, and the terminal counts the last slot. Scratch is +//! caller-owned and tile-sized (`slots × TILE_WORDS` words), never +//! population-sized, so the question is not heap memory. It is whether the +//! per-op tile write-back and re-read, and the separate `And` + count passes, +//! cost anything measurable against a fold that compares into chunks and +//! counts them in one fused `mask_ternlog_popcount` call. +//! +//! Arms, on the same data and the same programs: +//! +//! - **A** `execute_extent` with a `Scratch` built once outside the timing. +//! - **A+alloc** the same with `Scratch::for_program` once per call, inside the +//! timing (what `fold::set_lower`'s `SetPlan::count` does: one scratch per +//! call, then one `execute_extent` per run). +//! - **B** a lab fold: per chunk of at most `TILE_WORDS` words, each comparison +//! into a fresh stack chunk via the same `ndarray::simd` compare primitive +//! the executor uses, then ONE `mask_ternlog_popcount` over the chunks (and +//! a resident plane, shifted into a window when a run starts mid-word). +//! - **B′** the same fold over chunks reused across calls, which separates the +//! value of the fused count from the cost of initialising the chunks. +//! +//! Shapes (`a = lane0 > ta`, `b = lane1 < tb`, `p = plane 0`): +//! +//! - `S1` Count(a) +//! - `S2` Count(a ∧ b) +//! - `S2u` Count(a ∧ b) with `b` gated `under` `a` (the executor's own fused +//! compare-under form; arm B uses `*_to_mask_under` likewise) +//! - `S3` Count(a ∧ b ∧ p) +//! +//! Two workloads: +//! +//! - **whole**: one extent `0..n` (n = 16k, 1M, 4M). +//! - **runs**: `set_lower`'s shape. Over n = 1M, K non-overlapping runs of +//! length L (200, 3,000, 40,000 rows) at unaligned starts, together about a +//! quarter of the rows. Arm A calls `execute_extent` once per run; arm B folds +//! each run. +//! +//! Every configuration is checked against a bit-serial row oracle and arm A +//! before anything is timed. Timing is interleaved per rep in a rotating +//! order, the median of 101 per-rep ratios is reported, and a null arm (A timed +//! twice) gives the method's own noise. +//! +//! `cargo run --release -p lance-graph-benches --example pred_scratch_probe` + +use std::cell::RefCell; +use std::ops::Range; +use std::time::Instant; + +use lance_graph_mask_risc::exec::{execute_extent, Scratch, TILE_WORDS}; +use lance_graph_mask_risc::{ + words_for, Foreign, LaneRef, MaskOp, Operand, Out, Planes, Pred, Program, Terminal, Value, +}; +use ndarray::simd::{gt_i32_to_mask, lt_i32_to_mask, lt_i32_to_mask_under, mask_ternlog_popcount}; + +/// `a & b & c` in the VPTERNLOG input convention. +const AND3: i32 = 0x80; + +fn lcg(seed: &mut u64) -> u64 { + *seed = seed + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + *seed >> 11 +} + +fn time(f: &mut impl FnMut() -> T) -> f64 { + let t = Instant::now(); + std::hint::black_box(f()); + t.elapsed().as_nanos() as f64 +} + +fn med(mut v: Vec) -> f64 { + v.sort_by(|a, b| a.total_cmp(b)); + v[v.len() / 2] +} + +/// Interleaved, paired timing: every rep runs A, A again (the null arm), +/// A+alloc, B and B′ back to back, in an order that rotates per rep, so drift +/// and frequency changes hit all arms alike. Returns the median ns of A and +/// the medians of the per-rep ratios A′/A, A+alloc/A, B/A and B′/A. +fn paired( + reps: usize, + mut a: impl FnMut() -> usize, + mut aa: impl FnMut() -> usize, + mut b: impl FnMut() -> usize, + mut b2: impl FnMut() -> usize, +) -> (f64, [f64; 4]) { + let mut ta = vec![]; + let mut ratios: [Vec; 4] = Default::default(); + for r in 0..reps { + let mut t = [0f64; 5]; + for k in 0..5 { + let i = (k + r) % 5; + t[i] = match i { + 0 | 1 => time(&mut a), + 2 => time(&mut aa), + 3 => time(&mut b), + _ => time(&mut b2), + }; + } + ta.push(t[0]); + for j in 0..4 { + ratios[j].push(t[j + 1] / t[0]); + } + } + let [n, al, b, b2] = ratios; + (med(ta), [med(n), med(al), med(b), med(b2)]) +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Shape { + S1, + S2, + S2u, + S3, +} + +const SHAPES: [Shape; 4] = [Shape::S1, Shape::S2, Shape::S2u, Shape::S3]; + +fn program(shape: Shape, ta: i32, tb: i32) -> Program { + let a = Pred::GtI32 { lane: 0, t: ta }; + let b = Pred::LtI32 { lane: 1, t: tb }; + let pred = |pred, under, dst| MaskOp::Pred { pred, under, dst }; + let and = |a, b, dst| MaskOp::And { a, b, dst }; + let (ops, last) = match shape { + Shape::S1 => (vec![pred(a, None, 0)], 0), + Shape::S2 => ( + vec![ + pred(a, None, 0), + pred(b, None, 1), + and(Operand::Scratch(0), Operand::Scratch(1), 2), + ], + 2, + ), + Shape::S2u => ( + vec![pred(a, None, 0), pred(b, Some(Operand::Scratch(0)), 1)], + 1, + ), + Shape::S3 => ( + vec![ + pred(a, None, 0), + pred(b, None, 1), + and(Operand::Scratch(0), Operand::Scratch(1), 2), + and(Operand::Scratch(2), Operand::Plane(0), 3), + ], + 3, + ), + }; + Program::new( + ops, + Terminal::Count { + mask: Operand::Scratch(last), + }, + ) +} + +/// The bit-serial reference over the rows of `runs`. +fn oracle( + shape: Shape, + l0: &[i32], + l1: &[i32], + plane: &[u64], + ta: i32, + tb: i32, + runs: &[Range], +) -> usize { + runs.iter() + .flat_map(|r| r.clone()) + .filter(|&r| { + let a = l0[r] > ta; + let b = l1[r] < tb; + let p = plane[r / 64] >> (r % 64) & 1 == 1; + match shape { + Shape::S1 => a, + Shape::S2 | Shape::S2u => a && b, + Shape::S3 => a && b && p, + } + }) + .count() +} + +/// Chunks a fold works in: two comparison masks and a plane window. +struct Chunks { + ca: [u64; TILE_WORDS], + cb: [u64; TILE_WORDS], + cp: [u64; TILE_WORDS], +} + +impl Chunks { + fn new() -> Self { + Chunks { + ca: [0; TILE_WORDS], + cb: [0; TILE_WORDS], + cp: [0; TILE_WORDS], + } + } +} + +/// `plane`'s bits `r0..r0 + 64·out.len()`, shifted to start at bit 0. +fn window(plane: &[u64], r0: usize, out: &mut [u64]) { + let (w0, s) = (r0 / 64, r0 % 64); + for (i, o) in out.iter_mut().enumerate() { + let lo = plane.get(w0 + i).copied().unwrap_or(0) >> s; + let hi = if s == 0 { + 0 + } else { + plane.get(w0 + i + 1).copied().unwrap_or(0) << (64 - s) + }; + *o = lo | hi; + } +} + +/// Arm B / B′: the fold over the rows of `runs`, in chunks of at most +/// `TILE_WORDS` words. A comparison mask has zero bits past its chunk's rows, +/// so the plane window needs no tail masking under `AND3`. +#[allow(clippy::too_many_arguments)] +fn fold( + shape: Shape, + l0: &[i32], + l1: &[i32], + plane: &[u64], + ta: i32, + tb: i32, + runs: &[Range], + c: &mut Chunks, +) -> usize { + let mut total = 0u64; + for run in runs { + let mut r0 = run.start; + while r0 < run.end { + let r1 = (r0 + TILE_WORDS * 64).min(run.end); + let tw = words_for(r1 - r0); + let (ca, cb) = (&mut c.ca[..tw], &mut c.cb[..tw]); + gt_i32_to_mask(&l0[r0..r1], ta, ca); + total += match shape { + Shape::S1 => mask_ternlog_popcount::(ca, ca, ca), + Shape::S2 => { + lt_i32_to_mask(&l1[r0..r1], tb, cb); + mask_ternlog_popcount::(ca, cb, ca) + } + Shape::S2u => { + lt_i32_to_mask_under(&l1[r0..r1], tb, ca, cb); + mask_ternlog_popcount::(cb, cb, cb) + } + Shape::S3 => { + lt_i32_to_mask(&l1[r0..r1], tb, cb); + if r0 % 64 == 0 { + mask_ternlog_popcount::(ca, cb, &plane[r0 / 64..r0 / 64 + tw]) + } else { + let cp = &mut c.cp[..tw]; + window(plane, r0, cp); + mask_ternlog_popcount::(ca, cb, cp) + } + } + }; + r0 = r1; + } + } + total as usize +} + +/// Op passes over one chunk: (arm A, arm B). +fn passes(shape: Shape) -> (usize, usize) { + match shape { + Shape::S1 => (2, 2), + Shape::S2 => (4, 3), + Shape::S2u => (3, 3), + Shape::S3 => (5, 3), + } +} + +/// K non-overlapping runs of length `len` at unaligned starts over `0..n`, +/// together about a quarter of the rows. +fn runs_for(n: usize, len: usize, seed: &mut u64) -> Vec> { + let k = (n / 4 / len).max(1); + let slot = n / k; + (0..k) + .map(|i| { + let start = i * slot + (lcg(seed) as usize % (slot - len)) | 1; + start..(start + len).min(n) + }) + .collect() +} + +struct Data { + n: usize, + l0: Vec, + l1: Vec, + plane: Vec, +} + +fn data(n: usize, seed: &mut u64) -> Data { + let l0 = (0..n).map(|_| (lcg(seed) % 1000) as i32).collect(); + let l1 = (0..n).map(|_| (lcg(seed) % 1000) as i32).collect(); + let mut plane: Vec = (0..words_for(n)) + .map(|_| lcg(seed) ^ lcg(seed) << 21) + .collect(); + if n % 64 != 0 { + let last = plane.len() - 1; + plane[last] &= (1u64 << (n % 64)) - 1; + } + Data { n, l0, l1, plane } +} + +/// Check every arm against the oracle, then time them; print one row. +fn measure(label: &str, d: &Data, runs: &[Range], shape: Shape, ta: i32, tb: i32, sel: u32) { + let masks: [&[u64]; 1] = [&d.plane]; + let lanes = [LaneRef::I32(&d.l0), LaneRef::I32(&d.l1)]; + let planes = Planes { + n_rows: d.n, + masks: &masks, + lanes: &lanes, + }; + let p = program(shape, ta, tb); + let want = oracle(shape, &d.l0, &d.l1, &d.plane, ta, tb, runs); + let run_a = |s: &mut Scratch<'_>| -> usize { + runs.iter() + .map(|r| { + match execute_extent(&p, &planes, &Foreign::NONE, s, Out::None, r.clone()) + .expect("execute") + { + Value::Count(c) => c, + v => panic!("unexpected {v:?}"), + } + }) + .sum() + }; + let scratch = RefCell::new(Scratch::for_program(&p, d.n).expect("scratch")); + let chunks = RefCell::new(Chunks::new()); + let fold_fresh = || { + let mut c = Chunks::new(); + fold(shape, &d.l0, &d.l1, &d.plane, ta, tb, runs, &mut c) + }; + let fold_reused = || { + fold( + shape, + &d.l0, + &d.l1, + &d.plane, + ta, + tb, + runs, + &mut chunks.borrow_mut(), + ) + }; + assert_eq!( + run_a(&mut scratch.borrow_mut()), + want, + "A {label} {shape:?} sel={sel}" + ); + assert_eq!(fold_fresh(), want, "B {label} {shape:?} sel={sel}"); + assert_eq!(fold_reused(), want, "B' {label} {shape:?} sel={sel}"); + + let (a_ns, [null, alloc, b, b2]) = paired( + 101, + || run_a(&mut scratch.borrow_mut()), + || { + let mut s = Scratch::for_program(&p, d.n).expect("scratch"); + run_a(&mut s) + }, + fold_fresh, + fold_reused, + ); + let scratch_b = p.scratch_slots as usize * TILE_WORDS.min(words_for(d.n)) * 8; + let (pa, pb) = passes(shape); + println!( + "{label:>14} {:>4} {sel:>4} | {scratch_b:>9} | {pa:>2}/{pb:<2} | {a_ns:>11.0} | {null:>6.3} {alloc:>8.3} {b:>6.3} {b2:>6.3}", + format!("{shape:?}") + ); +} + +fn main() { + let tier = if cfg!(target_feature = "avx512f") { + "avx512f" + } else if cfg!(target_feature = "avx2") { + "avx2" + } else { + "scalar/other" + }; + println!("D-CMR-2b phase 1 probe; build tier: {tier}; TILE_WORDS = {TILE_WORDS}"); + println!( + "{:>14} {:>4} {:>4} | {:>9} | {:>5} | {:>11} | {:>6} {:>8} {:>6} {:>6}", + "workload", "shp", "sel%", "scratch_B", "pass", "A_ns", "null", "alloc/A", "B/A", "B'/A" + ); + // Thresholds giving roughly 1 %, 50 % and 99 % per comparison. + let sels = [(989, 10, 1), (499, 500, 50), (9, 990, 99)]; + let mut seed = 0x9E37_79B9_7F4A_7C15u64; + for &n in &[16_384usize, 1 << 20, 4 << 20] { + let d = data(n, &mut seed); + let whole = [0..n]; + for &(ta, tb, sel) in &sels { + for shape in SHAPES { + measure(&format!("whole {n}"), &d, &whole, shape, ta, tb, sel); + } + } + } + let d = data(1 << 20, &mut seed); + for &len in &[200usize, 3_000, 40_000] { + let runs = runs_for(d.n, len, &mut seed); + let label = format!("{}x{len}", runs.len()); + for &(ta, tb, sel) in &sels { + for shape in SHAPES { + measure(&label, &d, &runs, shape, ta, tb, sel); + } + } + } +} diff --git a/crates/lance-graph-mask-risc/examples/pred_scratch_probe.rs b/crates/lance-graph-mask-risc/examples/pred_scratch_probe.rs deleted file mode 100644 index 826c24efb..000000000 --- a/crates/lance-graph-mask-risc/examples/pred_scratch_probe.rs +++ /dev/null @@ -1,388 +0,0 @@ -//! D-CMR-2b, phase 1 (lab only): what does a residual comparison's scratch -//! actually cost, and does a scratch-free fold win? -//! -//! `fused_ternlog` declines `Pred`, so a program with a comparison runs tiled: -//! each op writes its destination slot for one tile ([`TILE_WORDS`] words), -//! later ops re-read it, and the terminal counts the last slot. Scratch is -//! caller-owned and tile-sized (`slots × TILE_WORDS` words), never -//! population-sized, so the question is not heap memory. It is whether the -//! per-op tile write-back and re-read, and the separate `And` + count passes, -//! cost anything measurable against a fold that compares into stack chunks and -//! counts them in one fused `mask_ternlog_popcount` call. -//! -//! Arms, on the same data and the same programs: -//! -//! - **A** `execute_extent` with a `Scratch` built once outside the timing -//! (what a caller that reuses its scratch pays). -//! - **A+alloc** the same call with `Scratch::for_program` inside the timing -//! (what `fold::set_lower` pays per call today). -//! - **B** a lab fold: per tile, each comparison into a stack chunk via the -//! same `ndarray::simd` compare primitive the executor uses, then ONE -//! `mask_ternlog_popcount` over the chunks (and a resident plane). No heap -//! scratch, no `And` pass, no count pass of its own. -//! -//! Shapes (`a = lane0 > ta`, `b = lane1 < tb`, `p = plane 0`): -//! -//! - `S1` Count(a) -//! - `S2` Count(a ∧ b) -//! - `S2u` Count(a ∧ b) with `b` gated `under` `a` (the executor's own fused -//! compare-under form; arm B uses `*_to_mask_under` likewise) -//! - `S3` Count(a ∧ b ∧ p) -//! -//! Every configuration is checked against a bit-serial row oracle and arm A -//! before anything is timed. Reported: scratch bytes, the op passes each arm -//! makes per tile, median ns of 101 and the B/A ratio. -//! -//! `cargo run --release -p lance-graph-mask-risc --example pred_scratch_probe` - -use std::time::Instant; - -use lance_graph_mask_risc::exec::{execute_extent, Scratch, TILE_WORDS}; -use lance_graph_mask_risc::{ - words_for, Foreign, LaneRef, MaskOp, Operand, Out, Planes, Pred, Program, Terminal, Value, -}; -use ndarray::simd::{gt_i32_to_mask, lt_i32_to_mask, lt_i32_to_mask_under, mask_ternlog_popcount}; - -/// `a & b & c` in the VPTERNLOG input convention. -const AND3: i32 = 0x80; - -fn lcg(seed: &mut u64) -> u64 { - *seed = seed - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - *seed >> 11 -} - -fn time(f: &mut impl FnMut() -> T) -> f64 { - let t = Instant::now(); - std::hint::black_box(f()); - t.elapsed().as_nanos() as f64 -} - -fn med(mut v: Vec) -> f64 { - v.sort_by(|a, b| a.total_cmp(b)); - v[v.len() / 2] -} - -/// Interleaved, paired timing: every rep runs A, A again (the null arm), -/// A+alloc, B and B' back to back, in an order that rotates per rep, so drift -/// and frequency changes hit all arms alike. Returns the median ns of A and -/// the medians of the per-rep ratios A'/A (the method's own noise), -/// A+alloc/A, B/A and B'/A. -fn paired( - reps: usize, - mut a: impl FnMut() -> usize, - mut aa: impl FnMut() -> usize, - mut b: impl FnMut() -> usize, - mut b2: impl FnMut() -> usize, -) -> (f64, [f64; 4]) { - let mut ta = vec![]; - let mut ratios: [Vec; 4] = Default::default(); - for r in 0..reps { - let mut t = [0f64; 5]; - for k in 0..5 { - let i = (k + r) % 5; - t[i] = match i { - 0 | 1 => time(&mut a), - 2 => time(&mut aa), - 3 => time(&mut b), - _ => time(&mut b2), - }; - } - ta.push(t[0]); - for j in 0..4 { - ratios[j].push(t[j + 1] / t[0]); - } - } - let [n, al, b, b2] = ratios; - (med(ta), [med(n), med(al), med(b), med(b2)]) -} - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -enum Shape { - S1, - S2, - S2u, - S3, -} - -fn program(shape: Shape, ta: i32, tb: i32) -> Program { - let a = Pred::GtI32 { lane: 0, t: ta }; - let b = Pred::LtI32 { lane: 1, t: tb }; - let (ops, last) = match shape { - Shape::S1 => ( - vec![MaskOp::Pred { - pred: a, - under: None, - dst: 0, - }], - 0, - ), - Shape::S2 => ( - vec![ - MaskOp::Pred { - pred: a, - under: None, - dst: 0, - }, - MaskOp::Pred { - pred: b, - under: None, - dst: 1, - }, - MaskOp::And { - a: Operand::Scratch(0), - b: Operand::Scratch(1), - dst: 2, - }, - ], - 2, - ), - Shape::S2u => ( - vec![ - MaskOp::Pred { - pred: a, - under: None, - dst: 0, - }, - MaskOp::Pred { - pred: b, - under: Some(Operand::Scratch(0)), - dst: 1, - }, - ], - 1, - ), - Shape::S3 => ( - vec![ - MaskOp::Pred { - pred: a, - under: None, - dst: 0, - }, - MaskOp::Pred { - pred: b, - under: None, - dst: 1, - }, - MaskOp::And { - a: Operand::Scratch(0), - b: Operand::Scratch(1), - dst: 2, - }, - MaskOp::And { - a: Operand::Scratch(2), - b: Operand::Plane(0), - dst: 3, - }, - ], - 3, - ), - }; - Program::new( - ops, - Terminal::Count { - mask: Operand::Scratch(last), - }, - ) -} - -/// The bit-serial reference. -fn oracle(shape: Shape, l0: &[i32], l1: &[i32], plane: &[u64], ta: i32, tb: i32) -> usize { - (0..l0.len()) - .filter(|&r| { - let a = l0[r] > ta; - let b = l1[r] < tb; - let p = plane[r / 64] >> (r % 64) & 1 == 1; - match shape { - Shape::S1 => a, - Shape::S2 | Shape::S2u => a && b, - Shape::S3 => a && b && p, - } - }) - .count() -} - -/// Arm B: compare into fresh stack chunks, one fused count per tile. -fn fold_b(shape: Shape, l0: &[i32], l1: &[i32], plane: &[u64], ta: i32, tb: i32) -> usize { - let mut ca = [0u64; TILE_WORDS]; - let mut cb = [0u64; TILE_WORDS]; - fold_into(shape, l0, l1, plane, ta, tb, &mut ca, &mut cb) -} - -/// The fold itself, over chunks the caller provides. Arm B' calls it with -/// chunks reused across calls (as arm A reuses its scratch), which isolates -/// the value of the fused count from the cost of initialising the chunks. -#[allow(clippy::too_many_arguments)] -fn fold_into( - shape: Shape, - l0: &[i32], - l1: &[i32], - plane: &[u64], - ta: i32, - tb: i32, - ca: &mut [u64; TILE_WORDS], - cb: &mut [u64; TILE_WORDS], -) -> usize { - let n = l0.len(); - let words = words_for(n); - let mut total = 0u64; - let mut w = 0; - while w < words { - let tw = TILE_WORDS.min(words - w); - let rows = w * 64..((w + tw) * 64).min(n); - let (ca, cb) = (&mut ca[..tw], &mut cb[..tw]); - gt_i32_to_mask(&l0[rows.clone()], ta, ca); - total += match shape { - Shape::S1 => mask_ternlog_popcount::(ca, ca, ca), - Shape::S2 => { - lt_i32_to_mask(&l1[rows], tb, cb); - mask_ternlog_popcount::(ca, cb, ca) - } - Shape::S2u => { - lt_i32_to_mask_under(&l1[rows], tb, ca, cb); - mask_ternlog_popcount::(cb, cb, cb) - } - Shape::S3 => { - lt_i32_to_mask(&l1[rows], tb, cb); - mask_ternlog_popcount::(ca, cb, &plane[w..w + tw]) - } - }; - w += tw; - } - total as usize -} - -/// Op passes over one tile: (arm A, arm B). -fn passes(shape: Shape) -> (usize, usize) { - match shape { - // A: compare, count. B: compare, fused count. - Shape::S1 => (2, 2), - // A: compare, compare, and, count. B: compare, compare, fused count. - Shape::S2 => (4, 3), - // A: compare, compare-under, count. B: the same three. - Shape::S2u => (3, 3), - // A: compare, compare, and, and, count. B: compare, compare, fused count. - Shape::S3 => (5, 3), - } -} - -fn main() { - let tier = if cfg!(target_feature = "avx512f") { - "avx512f" - } else if cfg!(target_feature = "avx2") { - "avx2" - } else { - "scalar/other" - }; - println!("D-CMR-2b phase 1 probe; build tier: {tier}; TILE_WORDS = {TILE_WORDS}"); - println!( - "{:>9} {:>4} {:>5} | {:>10} {:>8} | {:>6} | {:>11} | {:>7} {:>9} {:>7} {:>7}", - "n_rows", - "shp", - "sel%", - "scratch_B", - "stack_B", - "pass", - "A_ns", - "null", - "alloc/A", - "B/A", - "B'/A" - ); - let mut seed = 0x9E37_79B9_7F4A_7C15u64; - for &n in &[16_384usize, 1 << 20, 4 << 20] { - let l0: Vec = (0..n).map(|_| (lcg(&mut seed) % 1000) as i32).collect(); - let l1: Vec = (0..n).map(|_| (lcg(&mut seed) % 1000) as i32).collect(); - let mut plane: Vec = (0..words_for(n)) - .map(|_| lcg(&mut seed) ^ lcg(&mut seed) << 21) - .collect(); - if n % 64 != 0 { - let last = plane.len() - 1; - plane[last] &= (1u64 << (n % 64)) - 1; - } - let masks: [&[u64]; 1] = [&plane]; - let lanes = [LaneRef::I32(&l0), LaneRef::I32(&l1)]; - let planes = Planes { - n_rows: n, - masks: &masks, - lanes: &lanes, - }; - // Thresholds giving roughly 1 %, 50 % and 99 % per comparison. - for &(ta, tb, sel) in &[(989, 10, 1), (499, 500, 50), (9, 990, 99)] { - for shape in [Shape::S1, Shape::S2, Shape::S2u, Shape::S3] { - let p = program(shape, ta, tb); - let want = oracle(shape, &l0, &l1, &plane, ta, tb); - let mut scratch = Scratch::for_program(&p, n).expect("scratch"); - let run_a = |s: &mut Scratch<'_>| match execute_extent( - &p, - &planes, - &Foreign::NONE, - s, - Out::None, - 0..n, - ) - .expect("execute") - { - Value::Count(c) => c, - v => panic!("unexpected {v:?}"), - }; - assert_eq!(run_a(&mut scratch), want, "A {shape:?} n={n} sel={sel}"); - assert_eq!( - fold_b(shape, &l0, &l1, &plane, ta, tb), - want, - "B {shape:?} n={n} sel={sel}" - ); - - let reps = 101; - let scratch = std::cell::RefCell::new(scratch); - let chunks = std::cell::RefCell::new(([0u64; TILE_WORDS], [0u64; TILE_WORDS])); - { - let (ca, cb) = &mut *chunks.borrow_mut(); - assert_eq!( - fold_into(shape, &l0, &l1, &plane, ta, tb, ca, cb), - want, - "B' {shape:?}" - ); - } - let (a_ns, [null, alloc, ratio, ratio2]) = paired( - reps, - || run_a(&mut scratch.borrow_mut()), - || { - let mut s = Scratch::for_program(&p, n).expect("scratch"); - run_a(&mut s) - }, - || fold_b(shape, &l0, &l1, &plane, ta, tb), - || { - let (ca, cb) = &mut *chunks.borrow_mut(); - fold_into(shape, &l0, &l1, &plane, ta, tb, ca, cb) - }, - ); - let slots = p.scratch_slots as usize; - let tw = TILE_WORDS.min(words_for(n)); - let scratch_b = slots * tw * 8; - let stack_b = match shape { - Shape::S1 => 1, - _ => 2, - } * TILE_WORDS - * 8; - let (pa, pb) = passes(shape); - println!( - "{:>9} {:>4} {:>5} | {:>10} {:>8} | {:>2}/{:<3} | {:>11.0} | {:>7.3} {:>9.3} {:>7.3} {:>7.3}", - n, - format!("{shape:?}"), - sel, - scratch_b, - stack_b, - pa, - pb, - a_ns, - null, - alloc, - ratio, - ratio2 - ); - } - } - } -} From 68cf619f9302caee5556ec0a0321fd621e0bc81f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 08:59:06 +0000 Subject: [PATCH 3/3] benches: D-CMR-2b probe gains a compile-once arm; record the run-shaped result Adds arm Ac (Program::compile once + execute_compiled per run) to separate execute_extent's per-call recognition cost from the tile work, and fixes the three clippy lints the moved example carried. Measured over 3 runs: no win for the fold on whole extents or 40k-row runs; on set_lower's 200-row runs per-run execute_extent is 2.5-4x slower than the fold, and compiling once cuts it by only 5-18 %. Remedy not decided. Entry, STATUS, plan and LATEST_STATE updated to the new path and result. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013z2e1YXkm6o6HNrDn5BPWA --- .claude/board/LATEST_STATE.md | 4 +- .claude/board/STATUS_BOARD.md | 2 +- .../2026-10-21-d-cmr-2b-scratch-probe.md | 29 ++++--- .../d-cmr-concept-chain-normalization-v1.md | 2 +- .../examples/pred_scratch_probe.rs | 86 ++++++++++++++----- 5 files changed, 83 insertions(+), 40 deletions(-) diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index 62007be80..5f203008e 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -1,6 +1,6 @@ -## 2026-10-21 — D-CMR-2b measured: a scratch-free `Pred` fold does not pay (in PR) +## 2026-10-21 — D-CMR-2b measured: no win on whole extents; short runs pay per-run cost (in PR) -- **mask-risc (lab example only, no library change):** `examples/pred_scratch_probe.rs`. Against today's tiled path with reused scratch, a fused compare-and-count fold gains at most ~5 % at one tile and 0–4 % from 1M rows, inside a null arm's spread; it is slower where no pass is saved. Phase 2 (a `Pred` lowering in `ir.rs` / `exec.rs`) is not supported. Entry `entries/2026-10-21-d-cmr-2b-scratch-probe.md`. Contract inventory: unchanged. +- **lance-graph-benches (lab example only, no library change):** `examples/pred_scratch_probe.rs`. On whole extents and 40k-row runs a fused compare-and-count fold does not beat today's tiled `execute_extent`. On `set_lower`'s run shape with 200-row runs, per-run `execute_extent` is 2.5–4× slower than the fold; compiling once cuts it by only 5–18 %. Remedy (fewer edge-tile passes, a run-batched entry point, or a short-run fold) not decided, not built. Entry `entries/2026-10-21-d-cmr-2b-scratch-probe.md`. Contract inventory: unchanged. ## 2026-10-21 — main after #1654 (merged `d754128`) diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index e57e52733..46baede51 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -256,7 +256,7 @@ Plan: `.claude/plans/d-cmr-concept-chain-normalization-v1.md`. Parent: D-CMR-RES | **D-CMR-0** | Plan, identity matrix, inventory, board rows | Shipped #1532 (docs) | docs only; every inventory row cites code | | **D-CMR-1** | Set-theory IR core in the contract: `Runs` (∩ ∪ ∖, complement), residual `Res`, measures; pure | Shipped (#1644, `c05a313`): `contract::set_algebra::{SetExpr, Res, Runs, normalize}`; measures not yet (`entries/2026-10-20-d-cmr-1-set-algebra.md`) | algebra laws vs materialized bitsets as oracle | | **D-CMR-2** | Lowering: runs → extents, residual → predicates per run; measurement levels geometry / optics / etch | Shipped (#1650, `f031089`): `lance_graph::fold::set_lower::{SetPlan, SortedBasis, Level, Measured}`; geometry reads nothing, optics/etch run the residual per run via `execute_extent`. The scratch-free `Pred` fold in mask-risc is D-CMR-2b, not built (`entries/2026-10-21-d-cmr-2-lowering.md`) | == row oracle; `Count` without residual reads zero rows | -| **D-CMR-2b** | Scratch-free `Pred` fold in mask-risc (lab first) | Measured, phase 2 not supported (`examples/pred_scratch_probe.rs`; `entries/2026-10-21-d-cmr-2b-scratch-probe.md`): best ~5 % at one tile on S3, 0–4 % from 1M rows, inside the null's spread; slower where no pass is saved | answers == oracle and arm A first; interleaved paired timing with a null arm | +| **D-CMR-2b** | Scratch-free `Pred` fold in mask-risc (lab first) | Measured (`lance-graph-benches/examples/pred_scratch_probe.rs`; `entries/2026-10-21-d-cmr-2b-scratch-probe.md`): no win on whole extents or 40k-row runs; on 200-row runs per-run `execute_extent` is 2.5–4× slower than the fold, compile-once cuts A by 5–18 %. Remedy not decided, not built | answers == oracle before timing; interleaved paired timing with a null arm | | **D-CMR-3** | Quack frontend: `Filter` → set IR (extent hoisting falls out) | Queued | identical answers on the quack suite; other-basis range stays residual | | **D-CMR-4** | Two dimensions: `Rect` over composite coordinates, row-major or Morton runs | Queued | rectangle ∩ == bitset oracle | | **D-CMR-5** | **BLOCKED: lawful design proposed (operator 2026-10-21).** Preimage `f⁻¹(S)` as a co-addressed transposed tile fold over a version-witnessed projection of `f`, built in preparation by a sequential same-table scan with frozen-codebook endpoint translation (ADMISSION B; reusable across queries). `S` enters only as a membership plane translated into the same frozen coordinates. No seek or load of `f` keyed by `S`'s values or a previous fold (Q1 = C), no `Gather`, no scatter terminal. R1 (`rmap` `rebind` / `discover` escape-driven seeks) stays option C. ⊘ Was: "VIOLATION — GATHER. DO NOT BUILD" (entry `2026-10-21-d-cmr-5-rescope.md`) | BLOCKED — needs a contract-level attested witness for the frozen codebook (`rmap::Witness` is a probe-local stand-in); no preimage operator exists; not admitted, not ready to build | == row oracle; program checked Gather-free with a silence twin; attest / projection / query costs reported apart; a many-to-one `f` gives a valid distinct-source preimage (tested) and a bag-sensitive Count/Sum over it is refused (tested); refusal on a stale or missing witness | diff --git a/.claude/board/entries/2026-10-21-d-cmr-2b-scratch-probe.md b/.claude/board/entries/2026-10-21-d-cmr-2b-scratch-probe.md index fa223268c..12f9675e0 100644 --- a/.claude/board/entries/2026-10-21-d-cmr-2b-scratch-probe.md +++ b/.claude/board/entries/2026-10-21-d-cmr-2b-scratch-probe.md @@ -1,22 +1,23 @@ -# 2026-10-21 — D-CMR-2b phase 1: a scratch-free `Pred` fold does not pay +# 2026-10-21 — D-CMR-2b phase 1: a scratch-free `Pred` fold does not pay on whole extents; on `set_lower`'s short runs, per-run `execute_extent` is 2.5–4× slower **Status:** MEASURED (lab probe, no production change). **Basis:** D-CMR-2's open item (`entries/2026-10-21-d-cmr-2-lowering.md`); operator authorization 2026-10-21; conditions of `gpt-ada-01/007`; claim EXO-50 `session_013z2e1YXkm6o6HNrDn5BPWA/023`. - **Dependencies:** ndarray sibling at `origin/master` `37a3b59` (workspace `[patch]`); `ndarray::simd` has every compare→mask primitive (`*_i32_to_mask[_under]`, `*_u32_to_mask[_under]`), `mask_ternlog_popcount`, `popcount_batch_u64`; no fused compare-and-count primitive. mask-risc lib 47/47 against it. Host AVX-512 (Xeon @ 2.10 GHz, 4 cores), `target-cpu=native`; the probe prints the tier. - **What scratch is today:** caller-owned, `slots × TILE_WORDS` (256 words = 2 KiB per slot), allocated once per `Scratch::for_program`, never population-sized. So the question was per-op tile write-back / re-read and the separate `And` + count passes, not heap memory. -- **Probe:** `crates/lance-graph-mask-risc/examples/pred_scratch_probe.rs`. Shapes `S1` Count(a), `S2` Count(a∧b), `S2u` b gated `under` a, `S3` Count(a∧b∧plane) over `i32` lanes; 16k / 1M / 4M rows; ~1 / 50 / 99 % selectivity. Arms: **A** `execute_extent` with reused scratch; **A+alloc** with `Scratch::for_program` per call (what `fold::set_lower` does); **B** compares into fresh stack chunks + one `mask_ternlog_popcount`; **B′** the same fold over reused chunks. Every configuration asserted against a bit-serial oracle and arm A first. Timing interleaved per rep with rotating order, median of 101 per-rep ratios, plus a null arm (A timed twice). -- **Result (runs 2–4; run 1 used non-interleaved timing and is discarded as noise — S1's B/A moved 0.78–1.13 with selectivity, which S1's work does not depend on):** +- **Probe:** `crates/lance-graph-benches/examples/pred_scratch_probe.rs`, run with `cargo run --release -p lance-graph-benches --example pred_scratch_probe`. Shapes `S1` Count(a), `S2` Count(a∧b), `S2u` b gated `under` a, `S3` Count(a∧b∧plane) over `i32` lanes; ~1 / 50 / 99 % selectivity. Two workloads: **whole** (one extent `0..n`, n = 16k / 1M / 4M) and **runs** (`set_lower`'s shape: over n = 1M, K non-overlapping runs of 200, 3,000 or 40,000 rows at odd starts, about a quarter of the rows). Arms: **A** `execute_extent` per extent with reused scratch; **A+alloc** with `Scratch::for_program` once per call (what `SetPlan::count` does); **Ac** A with the program compiled once (`Program::compile` + `execute_compiled`); **B** compares into fresh chunks + one `mask_ternlog_popcount`, plane shifted into a window when a run starts mid-word; **B′** the same over reused chunks. Every configuration asserted against a bit-serial oracle before timing. Timing interleaved per rep with rotating order, median of 101 per-rep ratios, plus a null arm (A timed twice). +- **Result (3 runs; ranges over the 3 selectivities × 3 runs):** - | | 16k rows (1 tile) | 1M | 4M | - |---|---|---|---| - | null A′/A | 0.985–0.999 | 0.963–1.047 | 0.981–1.011 | - | A+alloc / A | 1.009–1.098 | 0.969–1.018 | 0.990–1.017 | - | B′/A, S3 (2 `And` passes saved) | 0.946–0.982 | 0.957–0.996 | 0.984–0.998 | - | B′/A, S2 (1 saved) | 0.958–0.996 | 0.957–1.021 | 0.980–0.998 | - | B′/A, S1 / S2u (none saved) | 0.968–1.051 | 0.988–1.033 | 0.993–1.033 | - | B/A (fresh stack chunks) | 0.973–1.094 | 0.960–1.045 | 0.982–1.043 | + | workload | null A′/A | A+alloc/A | Ac/A | B′/A | + |---|---|---|---|---| + | whole 16k (1 tile) | 0.989–0.999 | 1.026–1.090 | 0.979–1.005 | S1 1.40–1.46, S2/S2u/S3 1.06–1.26 | + | whole 1M | 0.957–1.012 | 0.971–1.027 | 0.971–1.033 | S1 1.04–1.20, S2/S2u/S3 0.95–1.04 | + | whole 4M | 0.984–1.014 | 0.989–1.023 | 0.987–1.011 | 0.970–1.028 | + | 1,310 runs × 200 | 0.952–1.033 | 0.981–1.023 | 0.821–0.948 | **0.236–0.524** (S3 0.24–0.27, S2 0.30–0.33) | + | 87 runs × 3,000 | 0.971–0.997 | 0.989–1.023 | 0.905–1.027 | S2/S3 0.67–0.80, S2u 0.86–0.93, S1 0.74–1.06 | + | 6 runs × 40,000 | 0.971–0.999 | 0.985–1.029 | 0.993–1.029 | 1.00–1.45 | -- **Reading:** the best case is ~5 % at one tile on S3; from 1M rows the effect is 0–4 %, inside the null's spread. Where no pass is saved the fused count is slower (it reads three operands where `popcount` reads one). Fresh stack chunks cost 2–5 % at one tile, so "scratch-free" is never better than the reused caller scratch the executor already takes. The only measurable cost of today's path is the per-call `Scratch::for_program` at one tile (~1–10 %), which vanishes from 1M rows. -- **Decision input:** phase 2 (a `Lowering` arm for `Pred` in `ir.rs` / `exec.rs`) is not supported by this measurement. Not done. +- **Reading, whole extents and long runs:** no win for the fold; it is equal or slower. The earlier version of this probe (same fold, whole extents only, before the move to `lance-graph-benches`) measured B′/A 0.95–1.05 at 16k; this version measures 1.06–1.46 with the same A. The cause is not found. 64-byte alignment of the chunks was tried and does not change it. Either way the fold does not beat A there. +- **Reading, short runs:** at 200 rows per run, per-run `execute_extent` costs about 170–520 ns a run (S1 → S3) and the fold is 2.5–4× faster. Compiling once (`Ac`) cuts A by only 5–18 %, so re-deriving the lowering per call is a small part of the gap. From the code (not attributed by measurement): `extent_tiles` executes an unaligned extent as a head-edge word, a body and a tail-edge word, each running every op and the terminal, and `execute_compiled` validates on every call. Per-call allocation (`A+alloc`) costs nothing measurable. +- **Decision input:** a `Pred` `Lowering` arm for whole extents (phase 2 as first framed) is not supported. The measured gap is per-run cost on short runs, which is what `set_lower` produces at optics/etch. Not decided which remedy (fewer passes per edge tile in `execute_extent`, a run-batched entry point, or a short-run fold); not built. -OPEN: none of D-CMR-2b's own. Not measured: `u32` / strided lanes, more than two comparisons, `Keep` terminals, other tiers (AVX2, NEON). +OPEN: (1) where the per-run cost of `execute_extent` goes on a 200-row run — not attributed between edge-tile passes, validation and op dispatch; (2) why this probe's fold is slower at full tiles than the earlier version. Not measured: `u32` / strided lanes, more than two comparisons, `Keep` terminals, other tiers (AVX2, NEON), real `set_lower` run distributions. diff --git a/.claude/plans/d-cmr-concept-chain-normalization-v1.md b/.claude/plans/d-cmr-concept-chain-normalization-v1.md index 95d82031f..da34c45ac 100644 --- a/.claude/plans/d-cmr-concept-chain-normalization-v1.md +++ b/.claude/plans/d-cmr-concept-chain-normalization-v1.md @@ -211,7 +211,7 @@ when the first floating-point or quantized column arrives, not before. |---|---|---| | D-CMR-1 | **Set IR core** in the contract: `Runs` with `∩ ∪ ∖` and complement, `Res`, measures; pure, no lowering | algebra laws (closure, distributivity, De Morgan, `∅`) checked against materialized bitsets as oracle on random and edge-case runs | | D-CMR-2 | **Lowering** to mask-risc: runs → extents, residual → predicates within each run, the three measurement levels. Level 2 for residual comparisons needs predicate fusion in mask-risc (`fused_ternlog` declines `Pred` today) | == row oracle; a `Count` with no residual reads zero rows (instrumented); disabling level 1 or 2 keeps answers | -| D-CMR-2b | **Scratch-free `Pred` fold** (lab first, phase 2 only on a measured win) | interleaved paired timing against today's tiled `execute_extent` with a null arm; measured 2026-10-21: no win worth a `Lowering` arm (`entries/2026-10-21-d-cmr-2b-scratch-probe.md`) | +| D-CMR-2b | **Scratch-free `Pred` fold** (lab first, phase 2 only on a measured win) | interleaved paired timing against today's tiled `execute_extent` with a null arm; measured 2026-10-21: no win on whole extents; on `set_lower`'s 200-row runs per-run `execute_extent` is 2.5–4× slower, remedy open (`entries/2026-10-21-d-cmr-2b-scratch-probe.md`) | | D-CMR-3 | **Quack normalization pass:** over `Filter`, computing set values (ranges on a proven basis → `Runs`, other leaves → `Res`, `sql_where` first). Extent hoisting falls out of this (Experiment C) | answers identical to today's `lower` path on the quack suite; a range on another basis stays residual (negative control) | | D-CMR-4 | **Two dimensions:** `Rect` over composite coordinates, linearized row-major or in Morton order | rectangle `∩` == bitset oracle; run count within the stated bound | | D-CMR-5 | **BLOCKED: lawful design proposed (operator 2026-10-21).** Preimage `f⁻¹(S)` as a co-addressed transposed tile fold over a version-witnessed projection of `f`, built in preparation by a sequential same-table scan with frozen-codebook endpoint translation (ADMISSION B; reusable across queries). `S` enters only as a membership plane translated into the same frozen coordinates. No seek or load of `f` keyed by `S`'s values or a previous fold (Q1 = C), no `Gather`, no scatter terminal. R1 (`rmap` `rebind` / `discover` escape-driven seeks) stays option C. ⊘ Was: "VIOLATION — GATHER. DO NOT BUILD" (entry `2026-10-21-d-cmr-5-rescope.md`) | == row oracle; program checked Gather-free with a silence twin; attest / projection / query costs reported apart; a many-to-one `f` gives a valid distinct-source preimage (tested) and a bag-sensitive Count/Sum over it is refused (tested); refusal on a stale or missing witness | diff --git a/crates/lance-graph-benches/examples/pred_scratch_probe.rs b/crates/lance-graph-benches/examples/pred_scratch_probe.rs index e7455c5f1..1edac7e39 100644 --- a/crates/lance-graph-benches/examples/pred_scratch_probe.rs +++ b/crates/lance-graph-benches/examples/pred_scratch_probe.rs @@ -22,6 +22,9 @@ //! a resident plane, shifted into a window when a run starts mid-word). //! - **B′** the same fold over chunks reused across calls, which separates the //! value of the fused count from the cost of initialising the chunks. +//! - **Ac** arm A with the program compiled once outside the timing +//! (`Program::compile` + `execute_compiled`), which separates the per-call +//! recognition cost of `execute_extent` from the tile work itself. //! //! Shapes (`a = lane0 > ta`, `b = lane1 < tb`, `p = plane 0`): //! @@ -50,7 +53,7 @@ use std::cell::RefCell; use std::ops::Range; use std::time::Instant; -use lance_graph_mask_risc::exec::{execute_extent, Scratch, TILE_WORDS}; +use lance_graph_mask_risc::exec::{execute_compiled, execute_extent, Scratch, TILE_WORDS}; use lance_graph_mask_risc::{ words_for, Foreign, LaneRef, MaskOp, Operand, Out, Planes, Pred, Program, Terminal, Value, }; @@ -78,36 +81,38 @@ fn med(mut v: Vec) -> f64 { } /// Interleaved, paired timing: every rep runs A, A again (the null arm), -/// A+alloc, B and B′ back to back, in an order that rotates per rep, so drift -/// and frequency changes hit all arms alike. Returns the median ns of A and -/// the medians of the per-rep ratios A′/A, A+alloc/A, B/A and B′/A. +/// A+alloc, B, B′ and Ac back to back, in an order that rotates per rep, so +/// drift and frequency changes hit all arms alike. Returns the median ns of A +/// and the medians of the per-rep ratios A′/A, A+alloc/A, B/A, B′/A and Ac/A. fn paired( reps: usize, mut a: impl FnMut() -> usize, mut aa: impl FnMut() -> usize, mut b: impl FnMut() -> usize, mut b2: impl FnMut() -> usize, -) -> (f64, [f64; 4]) { + mut ac: impl FnMut() -> usize, +) -> (f64, [f64; 5]) { let mut ta = vec![]; - let mut ratios: [Vec; 4] = Default::default(); + let mut ratios: [Vec; 5] = Default::default(); for r in 0..reps { - let mut t = [0f64; 5]; - for k in 0..5 { - let i = (k + r) % 5; + let mut t = [0f64; 6]; + for k in 0..6 { + let i = (k + r) % 6; t[i] = match i { 0 | 1 => time(&mut a), 2 => time(&mut aa), 3 => time(&mut b), - _ => time(&mut b2), + 4 => time(&mut b2), + _ => time(&mut ac), }; } ta.push(t[0]); - for j in 0..4 { + for j in 0..5 { ratios[j].push(t[j + 1] / t[0]); } } - let [n, al, b, b2] = ratios; - (med(ta), [med(n), med(al), med(b), med(b2)]) + let [n, al, b, b2, ac] = ratios; + (med(ta), [med(n), med(al), med(b), med(b2), med(ac)]) } #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -247,7 +252,7 @@ fn fold( } Shape::S3 => { lt_i32_to_mask(&l1[r0..r1], tb, cb); - if r0 % 64 == 0 { + if r0.is_multiple_of(64) { mask_ternlog_popcount::(ca, cb, &plane[r0 / 64..r0 / 64 + tw]) } else { let cp = &mut c.cp[..tw]; @@ -279,7 +284,7 @@ fn runs_for(n: usize, len: usize, seed: &mut u64) -> Vec> { let slot = n / k; (0..k) .map(|i| { - let start = i * slot + (lcg(seed) as usize % (slot - len)) | 1; + let start = (i * slot + lcg(seed) as usize % (slot - len)) | 1; start..(start + len).min(n) }) .collect() @@ -298,7 +303,7 @@ fn data(n: usize, seed: &mut u64) -> Data { let mut plane: Vec = (0..words_for(n)) .map(|_| lcg(seed) ^ lcg(seed) << 21) .collect(); - if n % 64 != 0 { + if !n.is_multiple_of(64) { let last = plane.len() - 1; plane[last] &= (1u64 << (n % 64)) - 1; } @@ -328,6 +333,19 @@ fn measure(label: &str, d: &Data, runs: &[Range], shape: Shape, ta: i32, }) .sum() }; + let compiled = p.compile(); + let run_ac = |s: &mut Scratch<'_>| -> usize { + runs.iter() + .map(|r| { + match execute_compiled(&compiled, &planes, &Foreign::NONE, s, Out::None, r.clone()) + .expect("execute") + { + Value::Count(c) => c, + v => panic!("unexpected {v:?}"), + } + }) + .sum() + }; let scratch = RefCell::new(Scratch::for_program(&p, d.n).expect("scratch")); let chunks = RefCell::new(Chunks::new()); let fold_fresh = || { @@ -351,10 +369,15 @@ fn measure(label: &str, d: &Data, runs: &[Range], shape: Shape, ta: i32, want, "A {label} {shape:?} sel={sel}" ); + assert_eq!( + run_ac(&mut scratch.borrow_mut()), + want, + "Ac {label} {shape:?} sel={sel}" + ); assert_eq!(fold_fresh(), want, "B {label} {shape:?} sel={sel}"); assert_eq!(fold_reused(), want, "B' {label} {shape:?} sel={sel}"); - let (a_ns, [null, alloc, b, b2]) = paired( + let (a_ns, [null, alloc, b, b2, ac]) = paired( 101, || run_a(&mut scratch.borrow_mut()), || { @@ -363,11 +386,12 @@ fn measure(label: &str, d: &Data, runs: &[Range], shape: Shape, ta: i32, }, fold_fresh, fold_reused, + || run_ac(&mut scratch.borrow_mut()), ); let scratch_b = p.scratch_slots as usize * TILE_WORDS.min(words_for(d.n)) * 8; let (pa, pb) = passes(shape); println!( - "{label:>14} {:>4} {sel:>4} | {scratch_b:>9} | {pa:>2}/{pb:<2} | {a_ns:>11.0} | {null:>6.3} {alloc:>8.3} {b:>6.3} {b2:>6.3}", + "{label:>14} {:>4} {sel:>4} | {scratch_b:>9} | {pa:>2}/{pb:<2} | {a_ns:>11.0} | {null:>6.3} {alloc:>8.3} {b:>6.3} {b2:>6.3} {ac:>6.3}", format!("{shape:?}") ); } @@ -382,18 +406,36 @@ fn main() { }; println!("D-CMR-2b phase 1 probe; build tier: {tier}; TILE_WORDS = {TILE_WORDS}"); println!( - "{:>14} {:>4} {:>4} | {:>9} | {:>5} | {:>11} | {:>6} {:>8} {:>6} {:>6}", - "workload", "shp", "sel%", "scratch_B", "pass", "A_ns", "null", "alloc/A", "B/A", "B'/A" + "{:>14} {:>4} {:>4} | {:>9} | {:>5} | {:>11} | {:>6} {:>8} {:>6} {:>6} {:>6}", + "workload", + "shp", + "sel%", + "scratch_B", + "pass", + "A_ns", + "null", + "alloc/A", + "B/A", + "B'/A", + "Ac/A" ); // Thresholds giving roughly 1 %, 50 % and 99 % per comparison. let sels = [(989, 10, 1), (499, 500, 50), (9, 990, 99)]; let mut seed = 0x9E37_79B9_7F4A_7C15u64; for &n in &[16_384usize, 1 << 20, 4 << 20] { let d = data(n, &mut seed); - let whole = [0..n]; + let whole = 0..n; for &(ta, tb, sel) in &sels { for shape in SHAPES { - measure(&format!("whole {n}"), &d, &whole, shape, ta, tb, sel); + measure( + &format!("whole {n}"), + &d, + std::slice::from_ref(&whole), + shape, + ta, + tb, + sel, + ); } } }