diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index 73749de4a..dd6964d13 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -10,7 +10,7 @@ Plan: `.claude/plans/d-relalg-unified-v1.md`. Decisions recorded there: ADMISSIO | **D-RU-3** | cross-rule CSE in crepe-plan-probe (256-rule benchmark, separate from the hop benchmark) | Queued (after D-RU-2) | answers == crepe and ascent; traversals and materializations measured | | **D-RU-4** | co-addressed join operator over `JoinDomain` | Queued (after D-RU-2) | parity with the star path; no position taken from another table's values | | **D-RU-5** | Quack physical pins (dense vs sorted, materialize vs recompute) | Queued (after D-RU-3/4) | can-fire and can-stay-silent per pin | -| **D-RU-6** | recursion driver over BIND tiles (256-hop benchmark) | Queued/Lab (slot assignment must be argued as an attested frozen-codebook lookup under ADMISSION = B). Lab probe MEASURED (`crepe-plan-probe/examples/walk_tile.rs`, `entries/2026-10-21-d-ru-6-tile-walk-probe.md`): tuple sets equal ascent, crepe and walk_mask; execution gather-free; binding witness probe-local, so ADMISSION B is NOT demonstrated; count-only 1.0–9.3× slower than walk_mask, end to end 1.1–2.0× slower | least fixpoint == datafrog; ≥ 200 iterations asserted; cycles terminate. Benchmark requirement (operator, 2026-10-21; refs D-RMAP-2 §5b, #1642): tuple materialization, count-only evaluation, memory growth, address binding and value-derived reads by origin reported separately; tuple-set equality with crepe and ascent wherever they run | +| **D-RU-6** | recursion driver over BIND tiles (256-hop benchmark) | Queued/Lab (slot assignment must be argued as an attested frozen-codebook lookup under ADMISSION = B). Lab probe MEASURED (`crepe-plan-probe/examples/walk_tile.rs`, `entries/2026-10-21-d-ru-6-tile-walk-probe.md`): tuple sets equal ascent, crepe and walk_mask; execution gather-free; binding witness probe-local, so ADMISSION B is NOT demonstrated; count-only 1.0–9.3× slower than walk_mask, end to end 1.1–2.0× slower. Block-sparse arm C MEASURED (`examples/walk_sparse.rs`, `entries/2026-10-21-d-ru-6-sparse-compute.md`): faster than the dense tile path on every fixture (0.11–0.96× count-only); against walk_mask end to end it wins only on deep low-fan-out recursion (chain(230) 0.82×, chain(700) 0.59× UNCHECKED), is near parity on sparse graphs (1.03–1.07×) and loses on dense, cyclic, clustered and mixed-length graphs (1.20–2.43×) | least fixpoint == datafrog; ≥ 200 iterations asserted; cycles terminate. Benchmark requirement (operator, 2026-10-21; refs D-RMAP-2 §5b, #1642): tuple materialization, count-only evaluation, memory growth, address binding and value-derived reads by origin reported separately; tuple-set equality with crepe and ascent wherever they run | | **D-RU-7** | prefetch and staging of Arm B ranges | Queued (after D-RU-6 or a non-recursive multi-stage plan) | critical-path overlap measured; bytes read unchanged | | **D-RU-8** | Wikidata- and SAP-shaped fixtures with completeness refusals | Queued (after D-RU-6/7) | `!R` refused under an incomplete scope | diff --git a/.claude/board/entries/2026-10-21-d-ru-6-sparse-compute.md b/.claude/board/entries/2026-10-21-d-ru-6-sparse-compute.md new file mode 100644 index 000000000..328308d86 --- /dev/null +++ b/.claude/board/entries/2026-10-21-d-ru-6-sparse-compute.md @@ -0,0 +1,101 @@ +# 2026-10-21 — D-RU-6-LAB-2: block-sparse tile recursion (arm C) against walk_mask and the dense tile path + +**Status:** MEASURED, lab only. D-RU-6 stays **Queued/Lab**. Nothing is admitted. Builds on D-RU-6-LAB-1 (`entries/2026-10-21-d-ru-6-tile-walk-probe.md`, #1647). References: D-RMAP-2 §5b, #1642. + +## Question + +D-RU-6-LAB-1 found the dense tile product (arm B) 1.0–9.3× slower than walk_mask (arm A) count-only. Does a product that skips empty work, still addressed only by loop coordinates, close that gap anywhere end to end? + +## What landed + +`crates/crepe-plan-probe/examples/walk_sparse.rs`. It compiles `walk_tile.rs` as a module (which itself compiles `walk_mask.rs`), so all three arms run on the same graphs with the same oracles. `walk_tile.rs` only gains `pub(crate)` on reused items and a rustfmt pass; its 24 tests are unchanged. + +Arm C reuses B's binding, witness and projection unchanged, and changes the product: +1. A tile triple runs only when its edge tile and its delta tile both exist. The delta holds only the facts that are new this iteration (semi-naive). +2. Each delta tile carries an occupancy map of `m × m` blocks (`m` ∈ {16, 32, 64, 256}; 64 for the main tables, fixed before any run). An empty block is skipped with one bit test. +3. Inside a block, a row OR runs only where the delta bit `(y, z)` and the precomputed row-occupancy bit of row `y` of the edge tile are both set. Output tiles are allocated on the first OR. +4. There is no broadcast, no spread and no per-coordinate `Ternlog`. mask-risc runs the tile update (`fresh = new & !walk`, `walk |= fresh`), two calls per changed tile. `admit` refuses `Gather` in both programs. +5. Row occupancy of the edge tiles is prepared once, outside the iteration loop. Binding is never redone. + +**Address audit (Rust indexing, not only `MaskOp::Gather`).** Every index in the execution path is a loop coordinate: tile triples `(I, K, J)`, block cell `zb * nb + yb`, delta row `z`, inner coordinate `y = w * 64 + shift + b` from the word, shift and bit loops. There is no bit scan (`trailing_zeros`): the bit loop visits every coordinate of a non-empty segment and tests it. An occupancy bit only decides whether work runs. + +**What that does not mean.** The set of row ORs C executes is exactly the set a bit scan would find. C differs from A in how a position is obtained, not in which work is done. Whether that satisfies the intent of ADMISSION B is an operator ruling, not something this probe shows. + +**Tests (38, of which 24 are B's and A's):** tuple-set equality with ascent, crepe, A and B at every microtile side, on mixed lengths with cycles, alternative path lengths, a zero-length 200-ring with over-length edges, a 300-node chain across two foci (≥ 200 iterations), 255/256/257/300 nodes and a three-focus graph, edges at every 16/32/64 block boundary, and clustered, dense, cyclic and benchmark-generator fixtures; a row-OR bound that holds only for a semi-naive delta; refusals (stale witness, escaping scope, focus cap, gather in either update program); a wrong path length detected by the oracle comparison; segment coverage. + +**Disable runs** (committed first, each reverted; all red): + +| Disable | Red | +|---|---| +| delta block occupancy never refreshed | 9 tests | +| block skip inverted | 9 tests | +| inner coordinate off by one (`y ^ 1`) | 9 tests | +| row occupancy read from the wrong word | 9 tests | +| length bound made exclusive | 6 tests | +| witness check removed | the stale test | +| `admit` removed | the gather test | +| escape refusal removed | the scope test | +| delta keeps re-derived facts (naive) | the row-OR bound test. Correctness is unaffected, so only the bound catches it; a first version on a chain stayed green, because a chain has no alternative derivations | + +CI runs `cargo test --example walk_sparse`; the benchmark is not run in CI. + +## Measured (release, 4 vCPU, load ≈ 1.2, medians of 5) + +Count-only includes attestation, binding, projection, preparation, recursion and counting. End to end replaces counting with decoding to the canonical tuple sets. + +| fixture | nodes | foci | A count | B count | C count | C/A | C/B | A e2e | B e2e | C e2e | C/A e2e | oracle | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---| +| sparse(512), 1 out-edge | 512 | 2 | 22.0 | 94.9 | 20.1 | 0.91× | 0.21× | 64.9 | 146 | 69.5 | 1.07× | equal | +| clustered(512) | 512 | 2 | 21.8 | 213 | 48.8 | 2.24× | 0.23× | 323 | 554 | 388 | 1.20× | equal | +| dense(128), 10 % | 128 | 1 | 2.72 | 8.40 | 5.76 | 2.12× | 0.69× | 110 | 147 | 162 | 1.47× | equal | +| cyclic(300) | 300 | 2 | 181 | 3,828 | 679 | 3.75× | 0.18× | 788 | 4,530 | 1,917 | 2.43× | equal | +| chain(230) | 230 | 1 | 5.39 | 35.2 | 3.70 | **0.69×** | 0.11× | 12.3 | 41.6 | 10.1 | **0.82×** | equal | +| chain(700) | 700 | 3 | 105 | 270 | 31.3 | **0.30×** | 0.12× | 174 | 338 | 102 | **0.59×** | UNCHECKED (A = B = C) | +| mixed(256) | 256 | 1 | 9.09 | 95.0 | 91.5 | 10.1× | 0.96× | 341 | 552 | 538 | 1.58× | equal | +| edges(512) | 512 | 2 | 3.20 | 9.09 | 4.18 | 1.31× | 0.46× | 70.8 | 76.8 | 72.9 | 1.03× | equal | +| edges(1024) | 1024 | 4 | 14.3 | 70.0 | 29.9 | 2.10× | 0.43× | 310 | 378 | 331 | 1.07× | UNCHECKED (A = B = C) | + +(ms; "equal" = A = B = C = ascent = crepe on the full tuple sets.) + +**Phases of C** (ms): binding (attest + bind + project) is 0.02–3.1 ms and preparation 0.002–0.012 ms on every fixture; recursion is the rest of count-only. Decoding (shared by every arm, BTreeSet) is 6–1,205 ms and dominates end to end everywhere except the chains. + +**Work counts.** C runs the same tile triples as B (e.g. 165,279 on cyclic(300)) but 2.3–474× fewer mask-risc calls (64,907 against 6,150,342 on cyclic(300)); C's calls are tile updates, B's are per-coordinate products, so the counts are of different operations. On sparse(512) and the chains, C's row ORs equal B's contributing coordinates exactly (7,158; 26,335; 225,650): neither does redundant inner work there, and C's gain is the per-coordinate broadcast and Ternlog it no longer runs. + +**Microtile side** (recursion ms): m = 32 or 64 is best on 6 of 9 fixtures; m = 16 on edges(512) and edges(1024) (2.95 against 3.55; 24.6 against 26.3); m = 256 on mixed(256), by 0.4 ms. m = 256 (no block skipping) is worst on 4 of 9, up to 2.4× slower than the best (sparse(512)); m = 16 is worst on the other 5. + +**Memory** (resident VmHWM growth in a fresh process, MiB; count-only arms bind, project and recurse without building tuples): + +| fixture | A count | B count | C count | A + decode | B + decode | C + decode | +|---|---:|---:|---:|---:|---:|---:| +| sparse(512) | 4.89 | 3.95 | 3.75 | 9.67 | 8.62 | 8.54 | +| chain(230) | 0.31 | 0.37 | 0.46 | 0.89 | 1.00 | 1.08 | +| chain(700) | 9.12 | 2.02 | 1.91 | 20.0 | 12.8 | 12.7 | +| edges(1024) | 14.2 | 2.21 | 2.29 | 50.5 | 44.8 | 44.9 | +| cyclic(300) | 1.23 | 4.96 | 5.10 | 122 | 122 | 122 | +| dense(128) | 0.04 | 0.80 | 0.75 | 20.2 | 20.7 | 20.8 | + +C's count-only memory equals B's within noise; it is below A where A's dense per-length planes are mostly empty, and above A on small dense or cyclic graphs. + +**Value-derived reads.** Execution: A 98,012–5,375,274 (delta row at a scanned adjacency bit); B 0; C 0. Preparation for B and C: one codebook lookup per edge endpoint (458–83,846). Decoding: two codebook decodes per output tuple (105,800–9,180,000), the same in every arm. + +## Reading + +- **C against B:** faster on every fixture, 0.11–0.96× count-only. Skipping inactive triples, blocks and rows removes most of B's cost; the largest gains are on sparse, chained and cyclic graphs, the smallest on mixed(256), where most blocks are occupied. +- **C against A, end to end (the decision gate):** C wins on deep, low-fan-out recursion (chain(230) 0.82×, oracle-checked; chain(700) 0.59×, UNCHECKED), is near parity on sparse and benchmark-generator graphs (1.03–1.07×), and loses on dense, cyclic, clustered and mixed-length graphs (1.20–2.43×). +- **Why chains:** A sweeps its dense per-length planes every iteration, about 230–510 iterations here, while C touches only tiles and blocks that changed in the last iteration. That is the "unchanged frontier" skip (operator item 1), and it is where C's advantage comes from. +- **Why C loses on cyclic and dense graphs:** the frontier stays large, so C does A's work (the same row ORs) with extra tile, block and update bookkeeping. +- **Binding and preparation are not where the cost is** (≤ 3.1 ms everywhere, including the codebook lookups). The open cost of B and C is the attestation contract, not the measured binding time. +- **Decoding dominates end to end** for every arm outside the chains and is not addressed by any arm. + +## Against the operator's gates + +- A measured end-to-end region exists, preparation included, but it is narrow: long chains and other deep, low-fan-out recursion. Of the two fixtures in it, only chain(230) is oracle-checked; chain(700) is UNCHECKED. +- Outside that region C loses, so this is not a general replacement for walk_mask. +- Per the gate, the next step for this region would be a contract-level attestation of the frozen codebook (sorted-key and version witnesses, maintenance and invalidation accounting). Whether a region this narrow justifies that work is the operator's call; it is not started here. +- D-RU-6 stays Queued/Lab. No production admission. + +## Open + +- How wide the winning region is: a sweep over chain length, fan-out and number of foci, with oracles where they run. +- Whether the unchanged-frontier skip alone, ported into walk_mask (skipping delta planes and rows that did not change), gives A the same chain win without tiles or a codebook. That would remove the case for C in its only winning region. +- Decoding cost, shared by all arms. diff --git a/.claude/board/entries/README.md b/.claude/board/entries/README.md index 192eb50ee..35c5fa127 100644 --- a/.claude/board/entries/README.md +++ b/.claude/board/entries/README.md @@ -25,11 +25,12 @@ 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. -398 entries, 2026-08-06 .. 2026-10-21. +399 entries, 2026-08-06 .. 2026-10-21. | date | entry id | finding | file | |---|---|---|---| | 2026-10-21 | `D-RU-6-LAB-1` | | [2026-10-21-d-ru-6-tile-walk-probe.md](2026-10-21-d-ru-6-tile-walk-probe.md) | +| 2026-10-21 | `D-RU-6-LAB-2` | | [2026-10-21-d-ru-6-sparse-compute.md](2026-10-21-d-ru-6-sparse-compute.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) | | 2026-10-20 | `ADR-2026-10-20-SIX` | | [2026-10-20-six-decisions.md](2026-10-20-six-decisions.md) | diff --git a/.claude/plans/d-relalg-unified-v1.md b/.claude/plans/d-relalg-unified-v1.md index 4fea50142..cbad31659 100644 --- a/.claude/plans/d-relalg-unified-v1.md +++ b/.claude/plans/d-relalg-unified-v1.md @@ -202,7 +202,7 @@ This is the `walk_mask` loop. To be lawful it needs the step "rule fragment appl **Benchmark requirement.** At least one input must need deep propagation. A chain or a ladder of length ≥ 200 forces the loop to run that many iterations (asserted, not assumed), plus a cyclic graph to check termination. -**Benchmark requirement, D-RU-6 (operator, 2026-10-21).** Every comparison against `walk_mask` (#1642) reports separately: tuple materialization, count-only evaluation, memory growth, address binding (attestation, witness validation, identity resolution and focus binding, projection), and value-derived reads by origin. Correctness is the full `Walk` / `NoWalk` tuple sets against crepe and ascent wherever they run, never Boolean reachability alone. D-RMAP-2 §5b's split of preparation, execution and maintenance applies. First lab measurement: `entries/2026-10-21-d-ru-6-tile-walk-probe.md` (execution gather-free; binding not shown to satisfy ADMISSION B; the tile path loses on time and wins on count-only memory). +**Benchmark requirement, D-RU-6 (operator, 2026-10-21).** Every comparison against `walk_mask` (#1642) reports separately: tuple materialization, count-only evaluation, memory growth, address binding (attestation, witness validation, identity resolution and focus binding, projection), and value-derived reads by origin. Correctness is the full `Walk` / `NoWalk` tuple sets against crepe and ascent wherever they run, never Boolean reachability alone. D-RMAP-2 §5b's split of preparation, execution and maintenance applies. First lab measurement: `entries/2026-10-21-d-ru-6-tile-walk-probe.md` (execution gather-free; binding not shown to satisfy ADMISSION B; the tile path loses on time and wins on count-only memory). Second: `entries/2026-10-21-d-ru-6-sparse-compute.md` (a block-sparse product with loop-coordinate addressing; it beats walk_mask end to end only on deep, low-fan-out recursion). ## 9. Physical planning in Quack diff --git a/.github/workflows/crepe-plan-probe.yml b/.github/workflows/crepe-plan-probe.yml index b52baa283..2e27a3f84 100644 --- a/.github/workflows/crepe-plan-probe.yml +++ b/.github/workflows/crepe-plan-probe.yml @@ -59,6 +59,9 @@ jobs: # D-RU-6 lab probe: the same program over RMAP tiles, its oracle # comparisons and refusals. Its benchmark is not run in CI either. - run: cargo test --example walk_tile + # D-RU-6 arm C: the block-sparse product, against arms A and B and the + # oracles, its refusals and the semi-naive bound. + - run: cargo test --example walk_sparse # relalg is its own workspace and patches ndarray to a sibling checkout # (`../../../ndarray` from its directory); no other job runs its tests. - name: relalg tests and clippy diff --git a/crates/crepe-plan-probe/examples/walk_sparse.rs b/crates/crepe-plan-probe/examples/walk_sparse.rs new file mode 100644 index 000000000..1e94daa63 --- /dev/null +++ b/crates/crepe-plan-probe/examples/walk_sparse.rs @@ -0,0 +1,1043 @@ +//! D-RU-6 lab probe, arm C: the `graph_walking` program over the same RMAP +//! foci and tiles as arm B (`walk_tile.rs`, #1647), with execution sparsity. +//! +//! STATUS: LAB ONLY. D-RU-6 stays Queued/Lab; nothing here is admitted. +//! +//! ```text +//! cargo run --release --example walk_sparse # arms A, B and C +//! cargo test --example walk_sparse # correctness and refusals +//! ``` +//! +//! # The three arms +//! +//! * **A**, `walk_mask`: dense bit planes; the hop reads a delta row at a +//! position found by scanning an adjacency row (a gather). Unchanged. +//! * **B**, `walk_tile`: the same RMAP binding and tiles; the hop is a dense +//! predicated tile product, a full-tile broadcast, a spread and one mask-risc +//! `Ternlog` per contributing coordinate. Unchanged. +//! * **C**, this file: the same binding and projection as B; the product skips +//! work instead of predicating it over the whole tile. +//! +//! # What C changes, against B +//! +//! 1. **Inactive tile products** are skipped: a product runs only for tile +//! triples whose edge tile and delta tile both exist, and the delta holds +//! only the bits that are new this iteration (semi-naive). +//! 2. **Inactive inner coordinates**: a row OR runs only where the delta bit +//! `(y, z)` and the static row-occupancy bit of `A`'s row `y` are both set. +//! An output tile is allocated only when the first OR into it happens. +//! 3. **Microtiles**: each delta tile carries an occupancy map of `m x m` +//! blocks (`m` = 16, 32, 64 or 256 in the sweep), so an empty block of the +//! inner coordinate is skipped with one bit test. +//! 4. **No broadcast or spread** is built, and there is no `Ternlog` per +//! coordinate. mask-risc runs the per-tile update instead +//! (`fresh = new & !walk`, `walk |= fresh`), two programs per changed tile. +//! 5. **Prepared metadata**: the row occupancy of each edge tile is built once, +//! in preparation, and read by every iteration. The binding is never redone. +//! +//! # The address rule, audited at the Rust level +//! +//! Positions read during execution come from loop coordinates; an occupancy +//! bit decides whether work runs and is never used as an index: +//! +//! | Read | Index | From | +//! |---|---|---| +//! | edge tile `(I, K)`, delta tile `(K, J)`, output `(I, J)` | `i * pages + k`, … | the tile-triple loop | +//! | delta occupancy cell | `zb * nb + yb` | the block loops | +//! | delta row `z` | `z * ROW_WORDS + w` | the `z` loop inside the block | +//! | `A`'s row `y`, `A`'s row occupancy | `y = w * 64 + shift + b` | the word, shift and bit loops of the block | +//! | output row `z` | `z * ROW_WORDS + k` | the `z` loop | +//! +//! There is no `trailing_zeros` or bit scan in the execution path: the `b` loop +//! visits every coordinate of a non-empty segment and tests it. Note what this +//! does and does not mean. The set of row ORs C executes is exactly the set a +//! bit scan would find; C differs from arm A in how a position is obtained (a +//! loop coordinate, tested), not in which work is done. Whether that +//! distinction satisfies the intent of ADMISSION B is for the operator to +//! rule; this probe only shows that the rule as written holds. +//! +//! Measured results: `.claude/board/entries/2026-10-21-d-ru-6-sparse-compute.md`. + +use std::hint::black_box; +use std::time::{Duration, Instant}; + +use lance_graph_mask_risc::{ + Foreign, MaskOp, Operand, Out, Planes, Program, Scratch, Terminal, execute_into, words_for, +}; +use mork_pathmap_probe::rmap::{Refusal, SLOTS, TILE_ROWS, Witness}; + +// Arm B (and, inside it, arm A and the oracles), compiled from its own file. +#[allow(dead_code)] +#[path = "walk_tile.rs"] +mod tile; + +use tile::baseline::{Tuples, agree, ascent_tuples, crepe_tuples, edges, mask_walk}; +use tile::{ + Bound, Edge, LENS, Projection, Refuse, Run, admit, attest, bind, chain, counts, mib, mixed, ms, + project, tile_eval, tuples, width, +}; + +const REPS: usize = 5; +const ORACLE_MAX_NODES: usize = 512; +const ROW_WORDS: usize = SLOTS / 64; +/// The microtile side used by the main comparison, fixed before any run. +const DEFAULT_M: usize = 64; +const MICROTILES: [usize; 4] = [16, 32, 64, 256]; + +// ---- prepared metadata --------------------------------------------------------- + +/// Bit `y` set when row `y` of a tile has any bit. +type RowOcc = [u64; ROW_WORDS]; + +fn row_occ(t: &[u64]) -> RowOcc { + let mut o = [0u64; ROW_WORDS]; + for y in 0..SLOTS { + if t[y * ROW_WORDS..(y + 1) * ROW_WORDS] + .iter() + .any(|&w| w != 0) + { + o[y >> 6] |= 1 << (y & 63); + } + } + o +} + +/// The edge tiles with their row occupancy, built once. +struct Prepared { + by_len: Vec<(usize, Vec>)>, +} + +fn prepare(p: &Projection) -> Prepared { + Prepared { + by_len: p + .by_len + .iter() + .map(|(l, blocks)| { + ( + *l, + blocks.iter().map(|t| t.as_deref().map(row_occ)).collect(), + ) + }) + .collect(), + } +} + +/// The `m`-bit segment `[yb * m, (yb + 1) * m)` of a 256-bit row, as +/// `(word, shift, len)` pieces of at most one word each. +fn segments(yb: usize, m: usize) -> impl Iterator { + let lo = yb * m; + let hi = lo + m; + (lo / 64..hi.div_ceil(64)).map(move |w| { + let a = lo.max(w * 64); + let b = hi.min((w + 1) * 64); + (w, a - w * 64, b - a) + }) +} + +fn low(len: usize) -> u64 { + if len == 64 { + u64::MAX + } else { + (1u64 << len) - 1 + } +} + +/// Occupancy of the `m x m` blocks of a delta tile: cell `zb * nb + yb` set +/// when rows `zb * m ..` have a bit in columns `yb * m ..`. +fn block_occ(t: &[u64], m: usize) -> Vec { + let nb = SLOTS / m; + let mut occ = vec![0u64; (nb * nb).div_ceil(64)]; + for zb in 0..nb { + for yb in 0..nb { + let hit = (zb * m..(zb + 1) * m).any(|z| { + segments(yb, m).any(|(w, sh, len)| (t[z * ROW_WORDS + w] >> sh) & low(len) != 0) + }); + if hit { + let c = zb * nb + yb; + occ[c >> 6] |= 1 << (c & 63); + } + } + } + occ +} + +// ---- execution ------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, Default)] +struct Counters { + /// Tile triples whose edge and delta tiles both exist. + tile_products: u64, + /// Occupied delta blocks visited. + blocks: u64, + /// Row ORs executed (one 256-bit OR each). + row_ors: u64, + /// mask-risc calls (the per-tile update). + calls: u64, +} + +/// The update programs and their buffers. +struct Exec { + fresh_prog: Program, + or_prog: Program, + scratch: Scratch<'static>, + buf: Box<[u64]>, +} + +fn update_programs() -> (Program, Program) { + let keep = Terminal::Keep { + mask: Operand::Scratch(0), + }; + ( + Program::new( + vec![MaskOp::AndNot { + a: Operand::Plane(0), + b: Operand::Plane(1), + dst: 0, + }], + keep, + ), + Program::new( + vec![MaskOp::Or { + a: Operand::Plane(0), + b: Operand::Plane(1), + dst: 0, + }], + keep, + ), + ) +} + +impl Exec { + fn with_programs(fresh_prog: Program, or_prog: Program) -> Result { + admit(&fresh_prog)?; + admit(&or_prog)?; + Ok(Exec { + fresh_prog, + or_prog, + scratch: Scratch::new(words_for(TILE_ROWS), 1), + buf: tile::tile(), + }) + } + + fn new() -> Exec { + let (f, o) = update_programs(); + Exec::with_programs(f, o).expect("the update programs have no gather") + } + + /// `out = prog(a, b)` over one tile. + fn run(&mut self, prog_is_fresh: bool, a: &[u64], b: &[u64], n: &mut Counters) -> Box<[u64]> { + let masks: [&[u64]; 2] = [a, b]; + let pl = Planes { + n_rows: TILE_ROWS, + masks: &masks, + lanes: &[], + }; + let prog = if prog_is_fresh { + &self.fresh_prog + } else { + &self.or_prog + }; + execute_into( + prog, + &pl, + &Foreign::NONE, + &mut self.scratch, + Out::Mask(&mut self.buf), + ) + .expect("the update program is valid"); + n.calls += 1; + std::mem::replace(&mut self.buf, tile::tile()) + } +} + +/// `c |= a ∘ d` over occupied blocks only. `a` is `A(x, y)` (row `y`), `d` is +/// `D(y, z)` (row `z`), `c` is `C(x, z)` (row `z`). Every index is a loop +/// coordinate; see the module docs. +#[allow(clippy::too_many_arguments)] +fn product_into( + a: &[u64], + a_rows: &RowOcc, + d: &[u64], + d_occ: &[u64], + m: usize, + c: &mut Option>, + n: &mut Counters, +) { + n.tile_products += 1; + let nb = SLOTS / m; + for zb in 0..nb { + for yb in 0..nb { + let cell = zb * nb + yb; + if d_occ[cell >> 6] >> (cell & 63) & 1 == 0 { + continue; + } + n.blocks += 1; + for z in zb * m..(zb + 1) * m { + for (w, sh, len) in segments(yb, m) { + let live = ((d[z * ROW_WORDS + w] & a_rows[w]) >> sh) & low(len); + if live == 0 { + continue; + } + for b in 0..len { + if live >> b & 1 == 0 { + continue; + } + let y = w * 64 + sh + b; + let out = c.get_or_insert_with(tile::tile); + for k in 0..ROW_WORDS { + out[z * ROW_WORDS + k] |= a[y * ROW_WORDS + k]; + } + n.row_ors += 1; + } + } + } + } + } +} + +struct Sparse { + run: Run, + counters: Counters, +} + +fn recurse( + b: &Bound, + p: &Projection, + prep: &Prepared, + current: &Witness, + m: usize, + ex: &mut Exec, +) -> Result { + assert!(SLOTS.is_multiple_of(m), "microtile side divides 256"); + b.witness.check(current)?; + p.witness.check(current)?; + if !p.escaped.is_empty() { + return Err(Refusal::IncompleteFocus.into()); + } + let pages = b.pages(); + let tiles = pages * pages; + let zero = || vec![vec![None; tiles]; LENS]; + let mut walk: Vec = zero(); + let mut delta: Vec = zero(); + for i in 0..pages { + let mut t = tile::tile(); + for x in 0..b.page_len(i) { + tile::set( + &mut t, + mork_pathmap_probe::rmap::tile_addr(x as u8, x as u8), + ); + } + walk[0][i * pages + i] = Some(t.clone()); + delta[0][i * pages + i] = Some(t); + } + let mut d_occ: Vec>>> = vec![vec![None; tiles]; LENS]; + d_occ[0] = delta[0] + .iter() + .map(|t| t.as_deref().map(|t| block_occ(t, m))) + .collect(); + let mut live = vec![false; LENS]; + live[0] = true; + let mut n = Counters::default(); + let mut iterations = 0usize; + let bound = LENS * b.nodes() * b.nodes() + 1; + loop { + iterations += 1; + assert!(iterations <= bound, "fixpoint did not terminate"); + let mut new: Vec = zero(); + for (l1, edge) in &prep.by_len { + let edge_tiles = &p + .by_len + .iter() + .find(|(l, _)| l == l1) + .expect("same lengths") + .1; + for l2 in (0..LENS).filter(|&l2| live[l2]) { + let l = l1 + l2; + if l >= LENS { + continue; + } + for i in 0..pages { + for j in 0..pages { + for k in 0..pages { + let (Some(a), Some(a_rows), Some(dt), Some(occ)) = ( + &edge_tiles[i * pages + k], + &edge[i * pages + k], + &delta[l2][k * pages + j], + &d_occ[l2][k * pages + j], + ) else { + continue; + }; + product_into(a, a_rows, dt, occ, m, &mut new[l][i * pages + j], &mut n); + } + } + } + } + } + let mut next_live = vec![false; LENS]; + let mut next_occ: Vec>>> = vec![vec![None; tiles]; LENS]; + for l in 0..LENS { + for t in 0..tiles { + let Some(nt) = new[l][t].take() else { + continue; + }; + let w = walk[l][t].get_or_insert_with(tile::tile); + let fresh = ex.run(true, &nt, w, &mut n); + if fresh.iter().all(|&x| x == 0) { + continue; + } + *w = ex.run(false, w, &fresh, &mut n); + next_occ[l][t] = Some(block_occ(&fresh, m)); + new[l][t] = Some(fresh); + next_live[l] = true; + } + } + if !next_live.iter().any(|&x| x) { + break; + } + delta = new; + d_occ = next_occ; + live = next_live; + } + Ok(Sparse { + run: Run { + walk, + iterations, + counters: tile::Counters::default(), + }, + counters: n, + }) +} + +// ---- the whole arm C ----------------------------------------------------------- + +struct Report { + attest: Duration, + bind: Duration, + project: Duration, + prepare: Duration, + recurse: Duration, + count: Duration, + decode: Duration, + iterations: usize, + counters: Counters, + pages: usize, + lookups: u64, + decodes: u64, + counts: (usize, usize), + tuples: Tuples, +} + +impl Report { + fn e2e_count(&self) -> Duration { + self.attest + self.bind + self.project + self.prepare + self.recurse + self.count + } + fn e2e(&self) -> Duration { + self.attest + self.bind + self.project + self.prepare + self.recurse + self.decode + } +} + +fn sparse_eval(e: &[Edge], m: usize, max_pages: usize) -> Result { + let s = Instant::now(); + let facts = attest(e); + let attest_t = s.elapsed(); + let s = Instant::now(); + let b = bind(e, facts, 1, None, max_pages)?; + let bind_t = s.elapsed(); + let s = Instant::now(); + let p = project(&b, e); + let project_t = s.elapsed(); + let s = Instant::now(); + let prep = prepare(&p); + let prepare_t = s.elapsed(); + let mut ex = Exec::new(); + let s = Instant::now(); + let r = recurse(&b, &p, &prep, &b.witness, m, &mut ex)?; + let recurse_t = s.elapsed(); + let s = Instant::now(); + let c = counts(&b, &r.run); + let count_t = s.elapsed(); + let s = Instant::now(); + let (t, decodes) = tuples(&b, &r.run); + let decode_t = s.elapsed(); + Ok(Report { + attest: attest_t, + bind: bind_t, + project: project_t, + prepare: prepare_t, + recurse: recurse_t, + count: count_t, + decode: decode_t, + iterations: r.run.iterations, + counters: r.counters, + pages: b.pages(), + lookups: p.lookups, + decodes, + counts: c, + tuples: t, + }) +} + +// ---- fixtures ------------------------------------------------------------------ + +/// splitmix64. +fn rng(seed: u64) -> impl FnMut() -> u64 { + let mut s = seed; + move || { + s = s.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = s; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } +} + +/// `deg` random out-edges per node, lengths 1, 2, 3, 5 or 7. +fn sparse(n: i32, deg: usize, seed: u64) -> Vec { + let mut r = rng(seed); + let lens = [1, 2, 3, 5, 7]; + let mut e = Vec::new(); + for x in 0..n { + for _ in 0..deg { + e.push((x, (r() % n as u64) as i32, lens[(r() % 5) as usize])); + } + } + e +} + +/// Communities of 32 nodes, 4 internal edges per node (lengths 1..=3), and +/// one cross edge per community (length 5). +fn clustered(n: i32, seed: u64) -> Vec { + let mut r = rng(seed); + let mut e = Vec::new(); + for x in 0..n { + let base = x / 32 * 32; + for _ in 0..4 { + let y = base + (r() % 32) as i32; + e.push((x, y.min(n - 1), 1 + (r() % 3) as u32)); + } + } + for c in (0..n).step_by(32) { + e.push((c, (c + 32 + (r() % 32) as i32) % n, 5)); + } + e +} + +/// Every ordered pair with probability `pct` %, length 1 or 2. +fn dense(n: i32, pct: u64, seed: u64) -> Vec { + let mut r = rng(seed); + let mut e = Vec::new(); + for x in 0..n { + for y in 0..n { + if r() % 100 < pct { + e.push((x, y, 1 + (r() % 2) as u32)); + } + } + } + e +} + +/// A zero-length ring, a length-1 ring and chords of length 7. +fn cyclic(n: i32) -> Vec { + let mut e: Vec = (0..n).map(|i| (i, (i + 1) % n, 0)).collect(); + e.extend((0..n).map(|i| (i, (i + 2) % n, 1))); + e.extend((0..n).step_by(17).map(|i| (i, (i + n / 2) % n, 7))); + e +} + +fn fixtures() -> Vec<(&'static str, Vec)> { + vec![ + ("sparse(512)", sparse(512, 1, 1)), + ("clustered(512)", clustered(512, 2)), + ("dense(128)", dense(128, 10, 3)), + ("cyclic(300)", cyclic(300)), + ("chain(230)", chain(230)), + ("chain(700)", chain(700)), + ("mixed(256)", mixed(256, 7)), + ("edges(512)", edges(512)), + ("edges(1024)", edges(1024)), + ] +} + +// ---- benchmark ------------------------------------------------------------------- + +fn median(mut v: Vec) -> Duration { + v.sort(); + v[v.len() / 2] +} + +fn status_kib(field: &str) -> usize { + std::fs::read_to_string("/proc/self/status") + .expect("Linux /proc") + .lines() + .find_map(|l| l.strip_prefix(field)) + .and_then(|v| v.trim().trim_end_matches("kB").trim().parse().ok()) + .expect("field present") +} + +/// Child mode: one arm on one fixture, resident high-water growth in KiB. +fn child(arm: &str, fixture: &str) { + let (_, e) = fixtures() + .into_iter() + .find(|(n, _)| *n == fixture) + .expect("known fixture"); + std::fs::write("/proc/self/clear_refs", "5").expect("clear_refs"); + let before = status_kib("VmRSS:"); + match arm { + "a-count" => { + black_box(mask_walk(width(&e), &e).counts()); + } + "a" => { + let p = mask_walk(width(&e), &e); + black_box((p.counts(), p.tuples())); + } + "b-count" => { + let b = bind(&e, attest(&e), 1, None, usize::MAX).expect("binds"); + let p = project(&b, &e); + let r = tile::recurse(&b, &p, &b.witness, &mut tile::Kernel::new()).expect("answers"); + black_box(counts(&b, &r)); + } + "b" => { + black_box(tile_eval(&e, usize::MAX).expect("answers")); + } + "c-count" => { + let b = bind(&e, attest(&e), 1, None, usize::MAX).expect("binds"); + let p = project(&b, &e); + let prep = prepare(&p); + let r = + recurse(&b, &p, &prep, &b.witness, DEFAULT_M, &mut Exec::new()).expect("answers"); + black_box(counts(&b, &r.run)); + } + "c" => { + black_box(sparse_eval(&e, DEFAULT_M, usize::MAX).expect("answers")); + } + _ => panic!("unknown arm {arm}"), + } + println!("{}", status_kib("VmHWM:").saturating_sub(before)); +} + +fn resident_kib(arm: &str, fixture: &str) -> usize { + let out = std::process::Command::new(std::env::current_exe().expect("exe")) + .args(["--resident", arm, fixture]) + .output() + .expect("child runs"); + assert!(out.status.success(), "child failed: {out:?}"); + String::from_utf8(out.stdout) + .expect("utf8") + .trim() + .parse() + .expect("a number") +} + +fn main() { + let args: Vec = std::env::args().collect(); + if args.get(1).map(String::as_str) == Some("--resident") { + child(&args[2], &args[3]); + return; + } + println!( + "# A / B / C, count-only and end to end (ms, median of {REPS}; C uses {DEFAULT_M} x {DEFAULT_M} microtiles)\n" + ); + println!( + "| fixture | nodes | foci | A count | B count | C count | C/A | C/B | A e2e | B e2e | C e2e | C/A | oracle |" + ); + println!("|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|"); + let mut rows = Vec::new(); + for (name, e) in fixtures() { + let n = width(&e); + let mut ac = Vec::new(); + let mut ad = Vec::new(); + let mut row_reads = 0; + let mut a_tuples = None; + for _ in 0..REPS { + let s = Instant::now(); + let p = mask_walk(n, &e); + black_box(p.counts()); + ac.push(s.elapsed()); + row_reads = p.row_reads; + let s = Instant::now(); + a_tuples = Some(black_box(p.tuples())); + ad.push(s.elapsed()); + } + let a_tuples = a_tuples.expect("REPS > 0"); + let b_reps: Vec<_> = (0..REPS) + .map(|_| tile_eval(&e, usize::MAX).expect("B answers")) + .collect(); + let c_reps: Vec<_> = (0..REPS) + .map(|_| sparse_eval(&e, DEFAULT_M, usize::MAX).expect("C answers")) + .collect(); + let a_count = median(ac); + let a_e2e = a_count + median(ad); + let b_count = median(b_reps.iter().map(|r| r.e2e_count()).collect()); + let b_e2e = median(b_reps.iter().map(|r| r.e2e()).collect()); + let c_count = median(c_reps.iter().map(|r| r.e2e_count()).collect()); + let c_e2e = median(c_reps.iter().map(|r| r.e2e()).collect()); + let phase = |f: fn(&Report) -> Duration| median(c_reps.iter().map(f).collect()); + let phases = [ + phase(|r| r.attest), + phase(|r| r.bind), + phase(|r| r.project), + phase(|r| r.prepare), + phase(|r| r.recurse), + phase(|r| r.count), + phase(|r| r.decode), + ]; + let b_last = b_reps.into_iter().last().expect("REPS > 0"); + let c_last = c_reps.into_iter().last().expect("REPS > 0"); + let nodes = c_last + .tuples + .walk + .iter() + .filter(|w| w.0 == w.1 && w.2 == 0) + .count(); + assert_eq!(c_last.counts, c_last.tuples.counts(), "{name}: C counts"); + agree(&c_last.tuples, &b_last.tuples).unwrap_or_else(|d| panic!("{name}: C vs B {d}")); + agree(&c_last.tuples, &a_tuples).unwrap_or_else(|d| panic!("{name}: C vs A {d}")); + let oracle = if nodes <= ORACLE_MAX_NODES { + agree(&c_last.tuples, &ascent_tuples(&e)).unwrap_or_else(|d| panic!("{name}: {d}")); + agree(&c_last.tuples, &crepe_tuples(&e)).unwrap_or_else(|d| panic!("{name}: {d}")); + "A = B = C = ascent = crepe" + } else { + "A = B = C; no Datalog oracle (UNCHECKED)" + }; + let r = |x: Duration, y: Duration| format!("{:.2}×", x.as_secs_f64() / y.as_secs_f64()); + println!( + "| {name} | {nodes} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {oracle} |", + c_last.pages, + ms(a_count), + ms(b_count), + ms(c_count), + r(c_count, a_count), + r(c_count, b_count), + ms(a_e2e), + ms(b_e2e), + ms(c_e2e), + r(c_e2e, a_e2e), + ); + let sweep: Vec = MICROTILES + .iter() + .map(|&m| { + median( + (0..REPS) + .map(|_| sparse_eval(&e, m, usize::MAX).expect("C answers").recurse) + .collect(), + ) + }) + .collect(); + rows.push((name, phases, c_last, b_last.counters, row_reads, sweep)); + } + println!("\n# arm C by phase (ms) and work counts\n"); + println!( + "| fixture | attest | bind | project | prepare | recurse | count | decode | iterations | C tile products | C blocks | C row ORs | C mask-risc calls | B tile products | B mask-risc calls |" + ); + println!("|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|"); + for (name, t, c, bc, _, _) in &rows { + println!( + "| {name} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |", + ms(t[0]), + ms(t[1]), + ms(t[2]), + ms(t[3]), + ms(t[4]), + ms(t[5]), + ms(t[6]), + c.iterations, + c.counters.tile_products, + c.counters.blocks, + c.counters.row_ors, + c.counters.calls, + bc.tile_products, + bc.calls, + ); + } + println!("\n# arm C recursion by microtile side (ms)\n"); + println!("| fixture | m=16 | m=32 | m=64 | m=256 |"); + println!("|---|---:|---:|---:|---:|"); + for (name, _, _, _, _, s) in &rows { + println!( + "| {name} | {} | {} | {} | {} |", + ms(s[0]), + ms(s[1]), + ms(s[2]), + ms(s[3]) + ); + } + println!("\n# resident memory (MiB, VmHWM growth in a fresh process)\n"); + println!("| fixture | A count | B count | C count | A + decode | B + decode | C + decode |"); + println!("|---|---:|---:|---:|---:|---:|---:|"); + for (name, ..) in &rows { + let k = + ["a-count", "b-count", "c-count", "a", "b", "c"].map(|a| resident_kib(a, name) * 1024); + println!( + "| {name} | {} | {} | {} | {} | {} | {} |", + mib(k[0]), + mib(k[1]), + mib(k[2]), + mib(k[3]), + mib(k[4]), + mib(k[5]) + ); + } + println!("\n# value-derived reads by origin\n"); + println!( + "| fixture | A exec: delta row at a scanned adjacency bit | B and C prep: codebook lookup per endpoint | B exec | C exec | decode: codebook decodes |" + ); + println!("|---|---:|---:|---:|---:|---:|"); + for (name, _, c, _, row_reads, _) in &rows { + println!( + "| {name} | {row_reads} | {} | 0 | 0 | {} |", + c.lookups, c.decodes + ); + } +} + +// ---- tests ----------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + use tile::baseline::tests::random_graph; + + /// C's tuples, at every microtile side, equal ascent's, crepe's, A's and + /// B's. Returns the report at the default side. + fn check(e: &[Edge]) -> Report { + let a = ascent_tuples(e); + let c = crepe_tuples(e); + let mask = mask_walk(width(e), e).tuples(); + let b = tile_eval(e, usize::MAX).expect("B answers"); + let mut out = None; + for m in MICROTILES { + let r = sparse_eval(e, m, usize::MAX).expect("C answers"); + assert_eq!(r.counts, r.tuples.counts(), "m={m}: counts vs tuples"); + agree(&r.tuples, &a).unwrap_or_else(|d| panic!("m={m} ascent: {d}")); + agree(&r.tuples, &c).unwrap_or_else(|d| panic!("m={m} crepe: {d}")); + agree(&r.tuples, &mask).unwrap_or_else(|d| panic!("m={m} A: {d}")); + agree(&r.tuples, &b.tuples).unwrap_or_else(|d| panic!("m={m} B: {d}")); + if m == DEFAULT_M { + out = Some(r); + } + } + out.expect("DEFAULT_M is swept") + } + + #[test] + fn mixed_lengths_with_cycles() { + let r = check(&random_graph(65, 2, 0x5EED_0001)); + let lens: BTreeSet = r.tuples.walk.iter().map(|w| w.2).collect(); + assert_eq!(lens.len(), LENS); + } + + #[test] + fn alternative_path_lengths() { + let r = check(&[(0, 1, 1), (0, 2, 2), (2, 1, 3), (1, 3, 7), (0, 3, 5)]); + for w in [(0, 1, 1), (0, 1, 5), (0, 3, 8), (0, 3, 12)] { + assert!(r.tuples.walk.contains(&w), "{w:?}"); + } + } + + #[test] + fn zero_length_cycle_and_over_length_edges() { + let mut e: Vec = (0..200).map(|i| (i, (i + 1) % 200, 0)).collect(); + e.push((0, 100, 7)); + e.push((5, 6, 51)); + let r = check(&e); + assert!(r.iterations >= 150); + assert!(r.tuples.walk.contains(&(150, 30, 49))); + assert!(!r.tuples.walk.iter().any(|w| w.2 > 49 && w.2 != 50)); + } + + #[test] + fn deep_propagation_terminates_across_foci() { + // 300 nodes: two foci, and the chain crosses the boundary at 255/256. + let r = check(&chain(300)); + assert!(r.iterations >= 200, "only {} iterations", r.iterations); + assert_eq!(r.pages, 2); + } + + #[test] + fn each_delta_bit_is_expanded_once() { + // Semi-naive: every Walk fact is new in exactly one iteration, so it + // feeds at most one row OR per edge length and per source focus. A + // delta that kept re-derived facts (naive evaluation) stays correct + // but breaks this bound wherever facts have alternative derivations. + for e in [ + cyclic(80), + dense(48, 10, 3), + random_graph(65, 2, 0x5EED_0001), + ] { + let r = check(&e); + let lens = e.iter().map(|x| x.2).collect::>().len() as u64; + let bound = r.tuples.walk.len() as u64 * lens * r.pages as u64; + assert!( + r.counters.row_ors <= bound, + "{} row ORs > {bound}", + r.counters.row_ors + ); + } + } + + #[test] + fn cross_focus_completeness() { + for n in [255usize, 256, 257, 300] { + let mut e = random_graph(n, 1, 0xB0B0 + n as u64); + e.push((255.min(n as i32 - 1), 0, 2)); + e.push((0, n as i32 - 1, 1)); + let r = check(&e); + assert_eq!(r.pages, n.div_ceil(SLOTS)); + } + let r = check(&random_graph(600, 1, 0x3F0C1)); + assert_eq!(r.pages, 3); + } + + #[test] + fn microtile_boundaries() { + // Edges into and out of the first and last coordinate of every 16, + // 32 and 64 block, inside one focus. + let ids = [0, 15, 16, 31, 32, 63, 64, 127, 128, 191, 192, 255]; + let mut e = Vec::new(); + for (k, &a) in ids.iter().enumerate() { + e.push((a, ids[(k + 1) % ids.len()], (k % 3) as u32)); + e.push((a, ids[(k + 5) % ids.len()], 4)); + } + check(&e); + } + + #[test] + fn the_shared_fixtures() { + check(&clustered(96, 2)); + check(&dense(48, 10, 3)); + check(&cyclic(80)); + check(&edges(64)); + } + + // ---- refusals ---- + + #[test] + fn a_stale_witness_is_refused() { + let e = edges(64); + let b = bind(&e, attest(&e), 1, None, usize::MAX).expect("binds"); + let p = project(&b, &e); + let prep = prepare(&p); + let mut ex = Exec::new(); + assert!(recurse(&b, &p, &prep, &b.witness, DEFAULT_M, &mut ex).is_ok()); + let w = Witness { + focus: 2, + ..b.witness + }; + assert_eq!( + recurse(&b, &p, &prep, &w, DEFAULT_M, &mut ex).err(), + Some(Refuse::Rmap(Refusal::FocusChanged)) + ); + let w = Witness { + facts: b.witness.facts ^ 1, + ..b.witness + }; + assert_eq!( + recurse(&b, &p, &prep, &w, DEFAULT_M, &mut ex).err(), + Some(Refuse::Rmap(Refusal::FactsChanged)) + ); + } + + #[test] + fn an_incomplete_scope_and_too_many_foci_are_refused() { + let e = random_graph(120, 2, 9); + let scope: Vec = (0..100).chain([9_000]).collect(); + let b = bind(&e, attest(&e), 1, Some(&scope), usize::MAX).expect("binds"); + let p = project(&b, &e); + assert!(!p.escaped.is_empty()); + assert_eq!( + recurse( + &b, + &p, + &prepare(&p), + &b.witness, + DEFAULT_M, + &mut Exec::new() + ) + .err(), + Some(Refuse::Rmap(Refusal::IncompleteFocus)) + ); + assert_eq!( + sparse_eval(&random_graph(300, 1, 3), DEFAULT_M, 1).err(), + Some(Refuse::Rmap(Refusal::IncompleteFocus)) + ); + } + + #[test] + fn a_scope_with_extra_ids_keeps_exact_parity() { + let e: Vec = vec![(0, 1, 1), (1, 2, 0), (2, 0, 3), (2, 3, 2)]; + let scope = [0, 1, 2, 3, 77, 900]; + let b = bind(&e, attest(&e), 1, Some(&scope), usize::MAX).expect("binds"); + assert_eq!(b.nodes(), 4); + let p = project(&b, &e); + let r = recurse( + &b, + &p, + &prepare(&p), + &b.witness, + DEFAULT_M, + &mut Exec::new(), + ) + .expect("answers"); + let (t, _) = tuples(&b, &r.run); + agree(&t, &ascent_tuples(&e)).unwrap_or_else(|d| panic!("{d}")); + } + + #[test] + fn a_wrong_path_length_is_detected() { + let e = random_graph(40, 2, 11); + let r = check(&e); + let mut bad = Tuples { + walk: r.tuples.walk.clone(), + no_walk: r.tuples.no_walk.clone(), + }; + let w = *bad + .walk + .iter() + .find(|w| w.2 > 0 && w.2 < 50 && !bad.walk.contains(&(w.0, w.1, w.2 + 1))) + .expect("a shiftable tuple"); + bad.walk.remove(&w); + bad.walk.insert((w.0, w.1, w.2 + 1)); + assert_eq!(bad.counts(), r.tuples.counts()); + assert!(agree(&bad, &ascent_tuples(&e)).is_err()); + } + + #[test] + fn a_gather_is_refused_before_execution() { + let gather = Program::new( + vec![MaskOp::Gather { + lane: 0, + foreign: 0, + under: None, + dst: 0, + }], + Terminal::Keep { + mask: Operand::Scratch(0), + }, + ); + let (f, o) = update_programs(); + assert_eq!( + Exec::with_programs(gather.clone(), o).err(), + Some(Refuse::UnauthorizedGather) + ); + assert_eq!( + Exec::with_programs(f, gather).err(), + Some(Refuse::UnauthorizedGather) + ); + } + + #[test] + fn segments_cover_each_block_exactly() { + for m in MICROTILES { + for yb in 0..SLOTS / m { + let mut bits = 0usize; + for (w, sh, len) in segments(yb, m) { + assert!(sh + len <= 64); + assert_eq!(w * 64 + sh, yb * m + bits); + bits += len; + } + assert_eq!(bits, m); + } + } + } +} diff --git a/crates/crepe-plan-probe/examples/walk_tile.rs b/crates/crepe-plan-probe/examples/walk_tile.rs index f211deb85..aebf8e3db 100644 --- a/crates/crepe-plan-probe/examples/walk_tile.rs +++ b/crates/crepe-plan-probe/examples/walk_tile.rs @@ -82,23 +82,23 @@ use mork_pathmap_probe::rmap::{Focus, Refusal, SLOTS, TILE_ROWS, TILE_WORDS, Wit // does not call are dead code here. #[allow(dead_code)] #[path = "walk_mask.rs"] -mod baseline; +pub(crate) mod baseline; use baseline::{MAX_PATH_LEN, Tuples, agree, ascent_tuples, crepe_tuples, edges, mask_walk}; -const LENS: usize = MAX_PATH_LEN as usize + 1; -const REPS: usize = 7; -const ORACLE_MAX_NODES: usize = 512; +pub(crate) const LENS: usize = MAX_PATH_LEN as usize + 1; +pub(crate) const REPS: usize = 7; +pub(crate) const ORACLE_MAX_NODES: usize = 512; /// Words per tile row (256 bits). -const ROW_WORDS: usize = SLOTS / 64; +pub(crate) const ROW_WORDS: usize = SLOTS / 64; -type Edge = (i32, i32, u32); +pub(crate) type Edge = (i32, i32, u32); // ---- refusals ---------------------------------------------------------------- /// Why the tile path refuses to answer. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Refuse { +pub(crate) enum Refuse { /// A witness check failed, or the answer would depend on identities /// outside the bound foci. Rmap(Refusal), @@ -115,7 +115,7 @@ impl From for Refuse { /// Admit a program to the execution phase: no op may read at a position /// taken from a lane's value. `MaskOp::Gather` is the only such op in /// mask-risc's IR, and `Pred` reads lanes at the row's own position. -fn admit(p: &Program) -> Result<(), Refuse> { +pub(crate) fn admit(p: &Program) -> Result<(), Refuse> { if p.ops.iter().any(|op| matches!(op, MaskOp::Gather { .. })) { return Err(Refuse::UnauthorizedGather); } @@ -125,7 +125,7 @@ fn admit(p: &Program) -> Result<(), Refuse> { // ---- phase 1: attestation ---------------------------------------------------- /// FNV-1a over a byte stream. -fn fnv(bytes: impl IntoIterator) -> u64 { +pub(crate) fn fnv(bytes: impl IntoIterator) -> u64 { let mut h = 0xcbf2_9ce4_8422_2325u64; for b in bytes { h ^= u64::from(b); @@ -136,7 +136,7 @@ fn fnv(bytes: impl IntoIterator) -> u64 { /// The facts digest: the edge set in canonical order. A probe-local stand-in /// for an attested sorted key (see the module docs). -fn attest(e: &[Edge]) -> u64 { +pub(crate) fn attest(e: &[Edge]) -> u64 { let mut s = e.to_vec(); s.sort_unstable(); s.dedup(); @@ -151,33 +151,33 @@ fn attest(e: &[Edge]) -> u64 { // ---- phase 2: identity resolution and focus binding --------------------------- /// The frozen codebook (sorted node ids) and one [`Focus`] per 256 of them. -struct Bound { - witness: Witness, - codebook: Vec, - foci: Vec, +pub(crate) struct Bound { + pub(crate) witness: Witness, + pub(crate) codebook: Vec, + pub(crate) foci: Vec, } impl Bound { - fn pages(&self) -> usize { + pub(crate) fn pages(&self) -> usize { self.foci.len() } - fn nodes(&self) -> usize { + pub(crate) fn nodes(&self) -> usize { self.codebook.len() } - fn page_len(&self, p: usize) -> usize { + pub(crate) fn page_len(&self, p: usize) -> usize { self.foci[p].len() } /// Global id of slot `s` of page `p`: a codebook decode. - fn id(&self, p: usize, s: usize) -> i32 { + pub(crate) fn id(&self, p: usize, s: usize) -> i32 { self.foci[p].id(s as u8) as i32 } /// `(page, slot)` of a node id: a codebook lookup keyed by a value. `None` /// when the id is not bound. - fn resolve(&self, id: i32) -> Option<(usize, u8)> { + pub(crate) fn resolve(&self, id: i32) -> Option<(usize, u8)> { let p = self .foci .partition_point(|f| f.id(0) as i32 <= id) @@ -189,7 +189,7 @@ impl Bound { /// Bind every endpoint of `e` (all nodes) or, with `scope`, only the given /// ids. Refuses more than `max_pages` foci: a global answer is never /// truncated to the foci that fit. -fn bind( +pub(crate) fn bind( e: &[Edge], facts: u64, generation: u64, @@ -236,33 +236,33 @@ fn bind( /// A relation over the bound nodes: `pages x pages` tiles, row-major by page, /// `None` for an all-zero tile. Tile `(I, J)` holds `R(x, z)` for `x` in page /// `I`, `z` in page `J`, at `tile_addr(x, z)`: row `z`, bit `x`. -type Blocks = Vec>>; +pub(crate) type Blocks = Vec>>; -fn tile() -> Box<[u64]> { +pub(crate) fn tile() -> Box<[u64]> { vec![0u64; TILE_WORDS].into_boxed_slice() } -fn set(t: &mut [u64], i: usize) { +pub(crate) fn set(t: &mut [u64], i: usize) { t[i >> 6] |= 1 << (i & 63); } -fn get(t: &[u64], i: usize) -> bool { +pub(crate) fn get(t: &[u64], i: usize) -> bool { t[i >> 6] >> (i & 63) & 1 == 1 } /// The edge relation, one block matrix per edge length. -struct Projection { - witness: Witness, - by_len: Vec<(usize, Blocks)>, +pub(crate) struct Projection { + pub(crate) witness: Witness, + pub(crate) by_len: Vec<(usize, Blocks)>, /// Ids an edge reaches that no focus binds (only under a `scope`). - escaped: BTreeSet, + pub(crate) escaped: BTreeSet, /// Edges whose endpoints sit in different foci (crossed by paging). - cross_page_edges: u64, + pub(crate) cross_page_edges: u64, /// Codebook lookups keyed by an edge value. - lookups: u64, + pub(crate) lookups: u64, } -fn project(b: &Bound, e: &[Edge]) -> Projection { +pub(crate) fn project(b: &Bound, e: &[Edge]) -> Projection { let pages = b.pages(); let mut by_len: Vec> = vec![None; LENS]; let mut escaped = BTreeSet::new(); @@ -308,16 +308,16 @@ fn project(b: &Bound, e: &[Edge]) -> Projection { /// The tile product kernel: broadcast and spread buffers, the mask-risc /// program and its scratch, reused across calls. -struct Kernel { - bcast: Box<[u64]>, - spread: Box<[u64]>, - out: Box<[u64]>, - prog: Program, - scratch: Scratch<'static>, +pub(crate) struct Kernel { + pub(crate) bcast: Box<[u64]>, + pub(crate) spread: Box<[u64]>, + pub(crate) out: Box<[u64]>, + pub(crate) prog: Program, + pub(crate) scratch: Scratch<'static>, } /// `dst = c | (p & q)`: `Ternlog` with `(a, b, c) = (C, P, Q)`, table `0xF8`. -fn product_program() -> Program { +pub(crate) fn product_program() -> Program { Program::new( vec![MaskOp::Ternlog { imm: 0xF8, @@ -333,16 +333,16 @@ fn product_program() -> Program { } #[derive(Clone, Copy, Debug, Default)] -struct Counters { - tile_products: u64, +pub(crate) struct Counters { + pub(crate) tile_products: u64, /// Coordinates `y` that contributed (both operands non-empty at `y`). - steps: u64, + pub(crate) steps: u64, /// mask-risc calls. - calls: u64, + pub(crate) calls: u64, } impl Kernel { - fn with_program(prog: Program) -> Result { + pub(crate) fn with_program(prog: Program) -> Result { admit(&prog)?; Ok(Kernel { bcast: tile(), @@ -353,14 +353,20 @@ impl Kernel { }) } - fn new() -> Kernel { + pub(crate) fn new() -> Kernel { Kernel::with_program(product_program()).expect("the product program has no gather") } /// `c |= a ∘ b`: `C(x, z) = ∃y A(x, y) ∧ B(y, z)`. Row `z` of `C` is the /// OR over `y` of row `y` of `A`, taken when bit `y` of row `z` of `B` is /// set. Every read is at a loop coordinate. - fn product_into(&mut self, a: &[u64], b: &[u64], c: &mut Box<[u64]>, n: &mut Counters) { + pub(crate) fn product_into( + &mut self, + a: &[u64], + b: &[u64], + c: &mut Box<[u64]>, + n: &mut Counters, + ) { n.tile_products += 1; for y in 0..SLOTS { let ay = &a[y * ROW_WORDS..(y + 1) * ROW_WORDS]; @@ -401,18 +407,23 @@ impl Kernel { } } -fn zero(pages: usize) -> Vec { +pub(crate) fn zero(pages: usize) -> Vec { vec![vec![None; pages * pages]; LENS] } /// The least fixpoint over tiles. -struct Run { - walk: Vec, - iterations: usize, - counters: Counters, +pub(crate) struct Run { + pub(crate) walk: Vec, + pub(crate) iterations: usize, + pub(crate) counters: Counters, } -fn recurse(b: &Bound, p: &Projection, current: &Witness, k: &mut Kernel) -> Result { +pub(crate) fn recurse( + b: &Bound, + p: &Projection, + current: &Witness, + k: &mut Kernel, +) -> Result { b.witness.check(current)?; p.witness.check(current)?; if !p.escaped.is_empty() { @@ -499,7 +510,7 @@ fn recurse(b: &Bound, p: &Projection, current: &Witness, k: &mut Kernel) -> Resu // ---- phase 5: decoding --------------------------------------------------------- /// Per-pair reach over all lengths, tile by tile. -fn reach(r: &Run, t: usize) -> Box<[u64]> { +pub(crate) fn reach(r: &Run, t: usize) -> Box<[u64]> { let mut out = tile(); for l in 0..LENS { if let Some(w) = &r.walk[l][t] { @@ -511,12 +522,12 @@ fn reach(r: &Run, t: usize) -> Box<[u64]> { out } -fn popcount(t: &[u64]) -> usize { +pub(crate) fn popcount(t: &[u64]) -> usize { t.iter().map(|w| w.count_ones() as usize).sum() } /// `(|Walk|, |NoWalk|)` without building a tuple. -fn counts(b: &Bound, r: &Run) -> (usize, usize) { +pub(crate) fn counts(b: &Bound, r: &Run) -> (usize, usize) { let pages = b.pages(); let walk = r.walk.iter().flatten().flatten().map(|t| popcount(t)).sum(); let reached: usize = (0..pages * pages).map(|t| popcount(&reach(r, t))).sum(); @@ -524,7 +535,7 @@ fn counts(b: &Bound, r: &Run) -> (usize, usize) { } /// The tuple sets, with every slot decoded through the codebook. -fn tuples(b: &Bound, r: &Run) -> (Tuples, u64) { +pub(crate) fn tuples(b: &Bound, r: &Run) -> (Tuples, u64) { let pages = b.pages(); let mut walk = BTreeSet::new(); let mut no_walk = BTreeSet::new(); @@ -558,45 +569,45 @@ fn tuples(b: &Bound, r: &Run) -> (Tuples, u64) { } /// Bytes held by the tiles of a run (walk planes only). -fn run_bytes(r: &Run) -> usize { +pub(crate) fn run_bytes(r: &Run) -> usize { r.walk.iter().flatten().flatten().count() * TILE_WORDS * 8 } // ---- the whole tile path --------------------------------------------------------- /// Every phase of one tile evaluation, timed. -struct TileReport { - attest: Duration, - validate: Duration, - bind: Duration, - project: Duration, - recurse: Duration, - count: Duration, - decode: Duration, - lookups: u64, - decodes: u64, - cross_page_edges: u64, - pages: usize, - iterations: usize, - counters: Counters, - tile_bytes: usize, - counts: (usize, usize), - tuples: Tuples, +pub(crate) struct TileReport { + pub(crate) attest: Duration, + pub(crate) validate: Duration, + pub(crate) bind: Duration, + pub(crate) project: Duration, + pub(crate) recurse: Duration, + pub(crate) count: Duration, + pub(crate) decode: Duration, + pub(crate) lookups: u64, + pub(crate) decodes: u64, + pub(crate) cross_page_edges: u64, + pub(crate) pages: usize, + pub(crate) iterations: usize, + pub(crate) counters: Counters, + pub(crate) tile_bytes: usize, + pub(crate) counts: (usize, usize), + pub(crate) tuples: Tuples, } impl TileReport { /// Every phase up to the counts: no tuple is built. - fn e2e_count(&self) -> Duration { + pub(crate) fn e2e_count(&self) -> Duration { self.attest + self.validate + self.bind + self.project + self.recurse + self.count } /// Every phase up to the decoded tuples. - fn e2e(&self) -> Duration { + pub(crate) fn e2e(&self) -> Duration { self.attest + self.validate + self.bind + self.project + self.recurse + self.decode } } -fn tile_eval(e: &[Edge], max_pages: usize) -> Result { +pub(crate) fn tile_eval(e: &[Edge], max_pages: usize) -> Result { { let s = Instant::now(); let facts = attest(e); @@ -643,23 +654,23 @@ fn tile_eval(e: &[Edge], max_pages: usize) -> Result { // ---- benchmark ------------------------------------------------------------------- -fn median(mut v: Vec) -> Duration { +pub(crate) fn median(mut v: Vec) -> Duration { v.sort(); v[v.len() / 2] } -fn ms(t: Duration) -> String { +pub(crate) fn ms(t: Duration) -> String { format!("{:.3}", t.as_secs_f64() * 1e3) } -fn mib(b: usize) -> String { +pub(crate) fn mib(b: usize) -> String { format!("{:.2}", b as f64 / (1 << 20) as f64) } /// A chain `0 -> 1 -> ... -> n-1`: length 0, except every 10th edge has /// length 1, so a walk is at most `n / 10` long and the fixpoint needs about /// `n` iterations. -fn chain(n: i32) -> Vec { +pub(crate) fn chain(n: i32) -> Vec { (0..n - 1) .map(|i| (i, i + 1, u32::from(i % 10 == 9))) .collect() @@ -667,7 +678,7 @@ fn chain(n: i32) -> Vec { /// Every id in `0..n` with two out-edges of length 1, 2, 3, 5 or 7 /// (splitmix64, fixed seed), plus a ring. -fn mixed(n: i32, seed: u64) -> Vec { +pub(crate) fn mixed(n: i32, seed: u64) -> Vec { let mut s = seed; let mut next = || { s = s.wrapping_add(0x9E37_79B9_7F4A_7C15); @@ -687,7 +698,7 @@ fn mixed(n: i32, seed: u64) -> Vec { e } -fn fixtures() -> Vec<(&'static str, Vec)> { +pub(crate) fn fixtures() -> Vec<(&'static str, Vec)> { vec![ ("edges(128)", edges(128)), ("edges(256)", edges(256)), @@ -699,12 +710,12 @@ fn fixtures() -> Vec<(&'static str, Vec)> { } /// walk_mask's three plane sets (`walk`, `delta`, `new`). -fn baseline_plane_bytes(e: &[Edge]) -> usize { +pub(crate) fn baseline_plane_bytes(e: &[Edge]) -> usize { 3 * mask_walk(width(e), e).plane_bytes() } /// The largest node id plus one: walk_mask's plane width. -fn width(e: &[Edge]) -> usize { +pub(crate) fn width(e: &[Edge]) -> usize { e.iter() .flat_map(|&(a, b, _)| [a, b]) .max() @@ -712,7 +723,7 @@ fn width(e: &[Edge]) -> usize { } /// A `kB` field of `/proc/self/status`. -fn status_kib(field: &str) -> usize { +pub(crate) fn status_kib(field: &str) -> usize { std::fs::read_to_string("/proc/self/status") .expect("Linux /proc") .lines() @@ -724,7 +735,7 @@ fn status_kib(field: &str) -> usize { /// Child mode: build the fixture, reset the resident high-water mark, run one /// engine end to end (tuples decoded), print the growth of the high-water mark /// over the resident set before the run, in KiB. -fn child(engine: &str, fixture: &str) { +pub(crate) fn child(engine: &str, fixture: &str) { let (_, e) = fixtures() .into_iter() .find(|(n, _)| *n == fixture) @@ -757,7 +768,7 @@ fn child(engine: &str, fixture: &str) { /// Resident high-water growth of one engine on one fixture, in a fresh /// process (jemalloc keeps freed pages, so in-process numbers would depend on /// what ran before). -fn resident_kib(engine: &str, fixture: &str) -> usize { +pub(crate) fn resident_kib(engine: &str, fixture: &str) -> usize { let out = std::process::Command::new(std::env::current_exe().expect("exe")) .args(["--resident", engine, fixture]) .output()