Skip to content

STARK recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 60.18 s - #1009

Draft
MauroToscano wants to merge 1106 commits into
mainfrom
stark-recursion-rpx
Draft

MauroToscano wants to merge 1106 commits into
mainfrom
stark-recursion-rpx

Conversation

@MauroToscano

@MauroToscano MauroToscano commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Draft. The STARK pipeline's best configuration, complete on top of main. It contains:

  • the per-table GPU recursion;
  • the shared recursion improvements of the WHIR line;
  • the ZisK-style proof-format levers, with one-row openings on;
  • the column-major LDE engine;
  • the batch of fixes to the gap against ZisK that reach this pipeline: leaner recursion programs, less idle time
    around the base, three faster RPX kernel paths and row-wise DEEP/OOD inversion;
  • grinding only before the queries in the WHIR chains (P2-W, from WHIR recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 32.62 s #1010), which changes no STARK proof;
  • the STARK wraps attesting their program id host-side (R1b);
  • less idle card in the base and the tree's lead-in (F-SIDLE);
  • the RPX MDS compiled the same way in every build, and the tree's host phases at a lower CPU priority (NICE v2);
  • compiled constraint-composition kernels on the card, on by default;
  • main, merged.

Block 25368371 proves in 60.18 s, with the device memory pool retained (the code's default).

What made it fast, ranked

These are the optimizations that took block 25368371 from 104.2 minutes to 60.18 s on this pipeline (35.95 s on the
WHIR one, #1010), ranked by the speedup each measured when it landed. Each row is its own before/after at that
time, so the rows do not add up.

# optimization landed measured speedup
1 proving on the GPU, one STARK per table, instead of the batched CPU pipeline 7–11 Sep 104.2 min → 21.8 min¹ 4.8×
2 the gap fixes: WHIR stack 27, the RPX limb permutation, the work-queue grind, base prep ahead of the prover thread, BITWISE only where used, Merkle tops per half-warp, the level-0 lead-in, and eight smaller 27–28 Sep WHIR 99.85 → 60.20 s · STARK 107.55 → 78.80 s 1.66× · 1.36×
3 a tree level's sibling proofs proved concurrently 14 Sep 418.5 → 252.7 s 1.66×
4 the proof-of-work grind on the GPU 11 Sep 21.8 → 13.6 min² 1.60×
5 FRI folds by 2^d per committed layer, one challenge each, with a verifier-side schedule (Haböck, eprint 2022/1216, Protocol 1): the recursion's FRI proofs lose about a third of their cells 24 Sep STARK 158.65 → 129.80 s · WHIR 128.20 → 120.85 s 1.22× · 1.06×
6 three WHIR tuning rounds: the VRAM budget read from the driver, evictable leaf-layer retention, tree fan-in 3 18–21 Sep 150.8 → 127.9 s 1.18×
7 less idle card in the STARK base (F-SIDLE), and the wraps attesting their program host-side (R1b) 29 Sep STARK 76.90 → 66.65 s 1.15×
8 the column-major LDE engine 24 Sep WHIR 106.80 → 100.65 s · STARK 118.60 → 103.90 s 1.06× · 1.14×
9 pure WHIR recursion: every recursion proof a WHIR proof, level 1 verifying the epochs directly 29 Sep 51.40 → 45.80 s 1.12×
10 the argue on the GPU: challenge tables (A2+A3), short wide tables (A1), the big batches' rounds on demand (N1′) 28–29 Sep 59.65 → 54.45 s · −1.00 s · −1.15 s 1.10× · 1.02× · 1.03×
11 the RPX MDS compiled the same way in every build, and NICE v2 (STARK) 29 Sep STARK 79.30 → 73.35 s, then 73.75 → 72.20 s · WHIR −0.50 s 1.08× · 1.02× · 1.01×
12 a six-variable first WHIR fold, schedule [6,4,4,4,4,3]: one round and three grinds fewer per chain, so fewer base commits, rebuilds and grinds 24 Sep WHIR 126.85 → 117.55 s 1.08×
13 one-row openings with a committed FRI input (STARK only; +3.20 s on WHIR, so off there), and Merkle caps (c ≤ 3) on the STARK and WHIR trees 24–25 Sep one-row: STARK 157.45 → 149.45 s · caps: WHIR −1.85 s (STARK trees) and −0.95 s (WHIR trees) 1.05× · 1.01× each
14 the last scheduling and shape levers: grinding only before the queries (P2-W), fan-in 5, the card permit after the host prep with fan-in 4, the base's head ahead 28–29 Sep −2.30 · −1.85 · −1.15 · −0.70 s 1.02–1.05× each
15 compiled constraint-composition kernels: one straight-line CUDA kernel per hot constraint program instead of the interpreter (STARK) 30 Sep STARK 61.05 → 60.18 s (8 arms, pooled) 1.01×

¹ The move also changed the hardware, from a CPU box to one RTX 5090 on a Ryzen 9950X host.
² On a Ryzen 9950X + RTX 5090 box. The base alone fell from 156.3 to 67.9 s.

  • The proof formats together (rows 5, 12 and 13, each against the legacy format on one binary): WHIR 128.00 →
    107.45 s, STARK 157.45 → 121.65 s. On STARK the caps add no wall on top of the 2^d folds, though they remove
    another 1.4 M permutations.
  • WHIR against STARK: the WHIR pipeline was 1.07× faster than the STARK one at its first version (150.8 against
    161.4 s, 18 Sep), and is 1.67× faster today (35.95 against 60.18 s). Rows 6, 9, 10 and 12, and most of 14, are
    WHIR-only; rows 7 and 15 and the one-row openings are STARK-only.
  • Where this PR stands: 11.2× ZisK (5.37 s) and 6.7× SP1 (8.92 s, one compressed proof) on the same card, from
    14.7× and 8.8× on 28 Sep.

The number

Block 25368371 on the FAST box (Ryzen 9 9950X, RTX 5090 32 GB), RPX commitments, 15 epochs at 2^21, fan-in 2. At this
head, cc411aa2c, the compiled kernels' four arms in the last two A/Bs read 60.4, 60.1, 60.4 and 59.8 s (mean
60.18 s). They were measured at 422c4b2ac with LAMBDA_VM_GPU_COMPILED_CONSTRAINTS=1, the code this head runs by
default. The record launchers leave the device memory pool at the code's default, retained (open decision 4). Host
peak 20.6 GiB.

Each step below is its own ABBA on one binary: two arms per setting, alternated.

step before after Δ
legacy format → this PR's default format (cap=auto fri=dp one_row=auto) 157.45 s (157.6, 157.3), 44.3 GiB, 19.62 M permutations 121.65 s (121.5, 121.8), 30.8 GiB, 10.18 M permutations −35.80 s (−22.7 %)
per-level LDE → column-major LDE engine 118.60 s (118.5, 118.7), 31.0 GiB 103.90 s (104.3, 103.5), 30.8 GiB −14.70 s (−12.4 %)
every gap fix's opt-out set → the defaults at 946ca6045 107.55 s (107.4, 107.7), 30.7 GiB 78.80 s (78.7, 78.9), 26.3 GiB −28.75 s (−26.7 %)
R1b's and F-SIDLE's opt-outs → the defaults at f2967e199 (pool retained in both arms) 76.90 s (76.6, 77.2), 26.2 GiB 66.65 s (66.7, 66.6), 19.8 GiB −10.25 s (−13.3 %)
f2967e199 → the MDS fix and NICE v2 (37d819add)¹ 66.10 s (65.8, 66.4), 20.1 GiB 60.95 s (60.5, 61.4), 20.2 GiB −5.15 s (−7.8 %)
the interpreter → the compiled constraint kernels (this head)² 61.05 s (60.9, 61.2, 60.9, 61.2), 20.6 GiB 60.18 s (60.4, 60.1, 60.4, 59.8), 20.6 GiB −0.88 s (−1.4 %)

¹ Two builds, alternated X Y Y X: the MDS fix has no knob.
² Two ABBAs on one binary at 422c4b2ac (jobs 260 and 261), pooled; the knob's setting is the only difference.

In the gap-fix row's A arms, every fix of the next table is switched off by its opt-out. Their program ids and census equal
those before the batch (a9defee79). The B arms' ids equal those of the arms that first measured the BITWISE drop and
the LFM_HASH split together. Census, arm 1 and root: 8,175,510,048 → 6,054,930,976 cells (−26 %).

Measured against the code before the batch, in one job, the batch is −24.45 s. That job alternated three arms:

  • the previous head with every gap-fix opt-out: 103.45 s;
  • the batch's head (946ca6045) with its opt-out set: 106.70 s;
  • that head's defaults: 79.00 s.

The previous head's A arms and that head's B arms, taken from the two ABBAs, give −25.35 s. The gap-fix row's −28.75 s
overstates the batch, because its A arms run that head's build, which is 3.25 s slower than the previous head's with
the same fixes off:

  • The whole gap is at level 0, in host-side work: the tree's own checks of every epoch proof and every wrap proof, which
    the measured wall includes, and the replay. Each is 15–25 % slower per item. The base, the global proof and the
    interior proofs take the same time.
  • This head's defaults run those host steps just as slowly, so it is not one of the opt-outs.
  • No source on that path changed. Across this campaign's builds, those host steps run at one of two costs about 20 %
    apart, and every arm of a build sits at the same one. This head's build is at the higher.
  • It is a property of the build, not of level 0's concurrency: with level 0 running one wrap at a time, the ratios
    stay 1.20. Code placement is the likeliest mechanism [inferred].

A fifth arm, the defaults with only the level-0 lead-in off, read 81.7 s. So the lead-in is worth −2.90 s here, net of
the 3.4 s it adds to the base. The WHIR PR (#1010) measured the same batch at −39.65 s (99.85 → 60.20 s).

The gap fixes on this pipeline

Each fix was measured first in its own ABBA, on the base before the engine. Those rows do not add up to the cumulative
−28.75 s; the gap-fix row above is the measurement.

fix what changes opt-out its own ABBA
RPX limb permutation (K5) every RPX kernel runs the permutation with 32-bit limb multiplies and squarings unrolled by four, instead of the 64-bit multiply LAMBDA_VM_RPX_LIMB_PERMUTE=0 −7.95 s
per-transfer pinned staging (I6) each row-major commit transfer is staged through a pinned pair of its own, instead of a shared slab whose mutex serialised uploads and downloads LAMBDA_VM_STAGING_SHARED_SLAB=1 −5.80 s, host peak −4.4 GiB
BITWISE only where used the nodes, the global parent and the root drop the fixed 2^20-row table, 26.2 M cells a proof. The 15 wraps keep it: their program_id fold is a keccak permutation, which sends it lookups LAMBDA_VM_LFM_KEEP_BITWISE=1 −5.30 s
level-0 lead-in (I7) two helpers build level 0's first wrap prologues in the base's tail LFM_TREE_PROLOGUES_AT_LEVEL0=1 −2.65 s, net of +3.4 s in the base; −2.90 s at 946ca6045
LFM_HASH split, on here a recursion program's hash table is split in two when that saves ≥ 2^15 padded rows. 13 of the 15 wraps and 3 of the 4 L2 nodes split (−931.8 M and −253.6 M cells); the L3 and L4 nodes and the global parent grow by 90 M, re-verifying split children LAMBDA_VM_LFM_HASH_SPLIT=0 −1.90 s on top of the BITWISE drop; the two together −7.20 s
base prep ahead of the prover thread each epoch's host preparation runs on its trace builder, and the global proof's on the producer LAMBDA_VM_BASE_PREP_ON_PROVER=1 −1.75 s
RPX Merkle tops per half-warp (K3) a Merkle level of up to 16,384 pairs hashes one parent per half-warp, and the last 64 pairs run in one block LAMBDA_VM_RPX_WARP_MERKLE=0 −1.00 s
row-wise DEEP/OOD inversion (K6) the DEEP and OOD denominators are inverted row-wise, the DEEP kernel inverts its own, and the single-point OOD sums are row-chunked LAMBDA_VM_DEEP_INV_LEGACY=1 −0.70 s, device peak −2.35 GiB
RPX work-queue grind (K4) the proof-of-work search claims nonces 32 at a time from a work queue on a card-filling grid, instead of striding over a fixed grid; it finds the same smallest nonce LAMBDA_VM_RPX_GRIND_QUEUE=0 −0.50 s: its grinds run at 0.67× their time, but inside the table-parallel region
level 0 reuses the base's DECODE level 0's EpochConstants::load takes the DECODE commitment the base already derived LFM_TREE_REDERIVE_DECODE=1 −0.45 s

The rest of the batch is in the code but does not run on this pipeline:

  • the 27-variable WHIR stack;
  • the WHIR memory kernels and the room;
  • the lean WHIR coset fold;
  • the WHIR encoding through the engine;
  • the NTT grid split (the STARK transforms stay far below the limit).

The WHIR PR (#1010) describes them. The LFM_HASH split is the one per-pipeline default: it costs +2.80 s on the WHIR
pipeline, so #1010 keeps it off.

The wraps attest their program id host-side (R1b)

What changed. A STARK wrap used to fold its program id in-guest with keccak over the epoch's attested inputs. It
now takes the id computed when the program is emitted and asserts every input it consumes equal to a program constant.

  • The wraps drop four sub-proofs (LFM_KECCAK, a KECCAK_RND chunk, KECCAK_RC and BITWISE), and the recursion census falls
    by 1,051 M cells.
  • STARK wraps become specific to the guest ELF, as the WHIR wraps already are.
  • Measured alone (e413989e1): −4.15 s against a pre-registered −4.2 s.

Soundness (prover/src/lfm/SOUNDNESS.md §6.9). Every value the fold attested is now a program constant the wrap
asserts. A forged ELF digest, pc_start or DECODE constant has no wrap execution, and a tampered cell is refused
(tests). The opt-out, LAMBDA_VM_STARK_WRAP_FOLD=1, restores the in-guest fold and today's program ids byte for byte.

Less idle card in the base and the lead-in (F-SIDLE)

What changed. Three scheduling changes. No proof byte moves.

  • The base's head runs ahead: two helpers start beside the producer. One initialises the device, commits DECODE
    there (the same root; multi_prove refuses one that differs) and prewarms the twiddles and staging buffers.
    LAMBDA_VM_BASE_HEAD_AHEAD=0 opts out.
  • The device-only envelope starts at LDE 2^16 instead of 2^19: those tables keep no host LDE copy, which their
    device paths never read. LAMBDA_VM_GPU_DEVICE_ONLY_THRESHOLD=524288 opts out.
  • The tree's lead-in prologues run in pools of their own, 8 threads a helper, so they never queue in front of the
    base's work, and one helper's prologue cannot run nested inside the other's wait. LFM_TREE_TAIL_THREADS=0 gives the
    global pool.
  • Measured: −1.45 s, −1.40 s and −2.20 s alone; −5.05 s together in the confirming ABBA (920c849e5). One pool per
    helper against one shared pool read no effect (+0.30 s); it was kept because the shared pool can stall.

MDS fix and NICE v2

What changed.

  • The RPX MDS now compiles the same way in every build.
    • Both RPX implementations compute the MDS over a compile-time circulant, with plain loops instead of a
      core::array::from_fn closure. They are the block path's lfm::rpo::Rpo256::mds and crypto::hash::rpx::mds.
    • The closure's wrapper was inlined only when rustc's codegen-unit partitioning happened to place it in mds's own
      unit. Otherwise every lane was an out-of-line call that recomputed (j − i) mod 12 with a 64-bit multiply per term:
      about +12 % instructions and +24 % multiplies per permutation.
    • That was the "fast build / slow build" split the tree's host verify showed from one head to the next: about 20 % on
      production, wrap and node verify and on the replay, decided by unrelated edits.
  • NICE v2 is the default.
    • The tree's host-only phases run on a pool of the calling thread's own, its threads at nice 10, so the proof holding
      the card keeps the CPU. Those phases are the reconstruct, emit and harvest, and every LFM prove's execute and fill.
    • It is one pool per calling thread: a shared pool nested one worker's phase inside another's.
  • The same merge brings two more level-0 knobs, both off by default: LAMBDA_VM_GAP_PREP_SCOPE and _AHEAD.

Measured on block 25368371 (FAST, one job per row):

A/B A B Δ
the MDS fix alone, at 8934b59 (X XF X XF, ds890–893) 79.30 s (79.4, 79.2) 73.35 s (72.9, 73.8) −5.95 s
NICE v2 on the fixed build bbdac70 (A B B A, ds894–897) 73.75 s (73.8, 73.7) 72.20 s (72.4, 72.0) −1.55 s
37d819add against f2967e199 (X Y Y X, ds940–943) 66.10 s (65.8, 66.4) 60.95 s (60.5, 61.4) −5.15 s
  • Where the gain lands at 37d819add: level 0 −4.35 s, interior −0.75 s, base −0.10 s. F-SIDLE's lead-in already
    took most of the base window's verify work off the critical path.
  • The mechanism. The host-verify minima fall to 0.75–0.88 of STARK recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 60.18 s #1009's; production verify goes from 0.74 s to
    0.60 s. A single-thread probe of the RPX permutation, run inside each binary, reads:
    • 2,350 ns in the builds that missed the inlining;
    • 1,945 ns in the one that got it;
    • 1,891–1,900 ns in every build with the fix.
  • NICE's cost: the card waits a little longer for the next wrap's host work (no hold +0.69 s at 37d819add). The
    level-0 holds shrink by more: the build holds go from 6.14 s to 4.15 s.

Soundness: nothing a proof commits to changes.

  • The MDS computes the same values. The miden RPO known-answer vectors and the RPX per-table host vectors pin it,
    along with seven FB rounds = RPO256, the two implementations' agreement test, and a new test against the circulant's
    definition. Transposing the matrix fails six of them.
  • NICE moves where the host phases run, not what they write
    (trace_identity_tests::execute_and_fill_on_the_host_phase_pool_are_byte_identical).
  • The 30 program ids are equal across A and B in all three A/Bs.
  • The card permit stays mutual exclusion: max holders 1 in every arm.

Opt-out.

  • LAMBDA_VM_GAP_PREP_NICE=0 restores the schedule before the knob: host phases run where they are called.
  • LAMBDA_VM_GAP_PREP_NICE=1..=19 picks another nice value.
  • The MDS fix has no knob; it is value-identical.

Compiled constraint-composition kernels

What changed.

  • Each hot constraint program gets its own straight-line CUDA kernel for the composition step, generated from the
    program's IR (crypto/stark/src/constraint_ir/codegen.rs → crypto/math-cuda/kernels/constraint_compiled.cu, 36
    programs, keyed by a structural hash of the program).
    • Why the interpreter was slow. It reads every node from memory and keeps every operand and result in a
      per-thread slot file in global memory. That file caps its grid at 65,536 threads, 0.30 waves on the 5090, and binds
      it on L2 (ncu: 77 % of L2, 30 % issue).
    • What the compiled kernel does instead. It is the same node walk, emitted as straight-line code over registers,
      with a grid that fills the card.
  • Covered: every VM table except KECCAK_RND, ECDAS, ECSM and KECCAK; L2G (5 structural variants over 64 labels);
    every LFM chip except LFM_KECCAK and LFM_BLAKE3. Any program without a compiled kernel keeps the interpreter. The
    largest compiled program is LFM_HASH (3,905 nodes).
  • Default on. LAMBDA_VM_GPU_COMPILED_CONSTRAINTS=0 restores the interpreter for every program, and any value
    other than 0 or 1 stops the run. A banner names the setting, and each distinct program prints one line saying
    whether it ran compiled or on the interpreter.

Kernel speed (2^20 LDE rows, median of 5, interpreter ÷ compiled; FAST jobs 247 and 260):

program nodes ms, interpreter → compiled speedup
CPU 542 4.52 → 1.20 3.76×
CPU32 468 4.29 → 1.19 3.62×
DVRM 482 4.21 → 1.19 3.53×
MEMW_R / MEMW_A 170 / 354 1.39 → 0.42 / 2.86 → 0.87 3.30×
LFM_HASH 3,905 23.53 → 8.63 2.73×
MEMW 514 4.37 → 2.23 1.96×
LFM_BITDEC, HALT (register-bound) 1,464 / 761 12.67 → 10.70 / 6.58 → 6.13 1.18× / 1.07×

Measured on block 25368371 (FAST, arms A B B A, one binary at 422c4b2ac; A = the interpreter, B = compiled):

run A B Δ whole base level 0 interior
job 260 (ds960–963) 61.05 s (60.9, 61.2) 60.25 s (60.4, 60.1) −0.80 s −0.70 +0.10 −0.20
job 261, the replication (ds964–967) 61.05 s (60.9, 61.2) 60.10 s (60.4, 59.8) −0.95 s −0.65 −0.15 −0.15
pooled (4 + 4 arms) 61.05 s 60.18 s −0.88 s −0.68 −0.03 −0.18
  • Every B arm is faster than every A arm in both jobs: the slowest B took 60.4 s, the fastest A 60.9 s.
  • The gain is half the paper estimate (−1.6 s pre-registered, band [−2.6, −0.6]). The kernels run 2–3.8× faster,
    but composition is a small share of the card's time. Most of the gain lands in the base (−0.68 s).
  • The verdict rule was fixed before the replication: EFFECTIVE ⇔ the pooled Δ ≤ −0.8 s, AND job 261's own
    Δ < 0 with every B arm below every A arm. Both hold.
  • Job 260 read exactly −0.80 s. Its reader printed NO EFFECT on a float difference of −0.7999999999999972; the
    reader now rounds to 0.01 s before testing the threshold. That boundary reading is why the replication was run.

Soundness: the proofs are byte-identical. A compiled kernel computes the interpreter's H bit for bit: the same
field operations, in the same order, on the same operands.

  • Host parity. Both kernels are built as host C++ and run over random full-range inputs: 36 programs × 2 seeds,
    all equal limb for limb.
    • Three deliberate generator faults each fail all 72 runs: the last root subtracted, aux reads at the wrong frame
      row, and no roots added.
  • Device parity on the card. Every compiled kernel's H equals the interpreter's on the 5090: 4,096 rows, frame
    step 4, two seeds, every program.
  • Proof bytes. add.elf is proved from one set of traces at grinding 0.
    • The control: two interpreter proofs, byte-equal (3,705,032 bytes). One set of traces is needed because the RV64
      trace builders order some tables' rows by HashMap iteration, so two separate trace builds never give equal proofs.
    • The compiled proof: byte-equal to them, and it verifies.
    • The mutation control: BITWISE's kernel is swapped for a copy with one extra statement. The proof changes and
      fails verification.
    • Coverage. At the default dispatch floor, add.elf sends only one table (BITWISE) to the card, so this test
      compares one compiled kernel inside a proof. The gates rerun it with the floor lowered to 16 LDE rows: 8 compiled
      kernels (CPU, DECODE, LT, MEMW_R, MEMW_A, BITWISE, REGISTER, KECCAK_RC) and one interpreted program give proofs
      byte-equal to the interpreter's (see "Gate and CI"). The device parity test covers all 36 kernels.
  • In production. The 30 program identities are equal in all 8 arms. Each B arm ran 547 program lines compiled
    and 63 on the interpreter.
  • Freshness. A test regenerates the kernels from the live IR and fails if the checked-in source differs, so a
    constraint change without a regeneration cannot ship a stale kernel.

What is in the branch

  • Per-table GPU recursion, this PR's original content: per-table STARK proofs of each epoch on the device, LFM wraps
    and nodes, one root for the block.
  • Everything the WHIR line added on top of this PR's original head, including main's Feat/skip empty tables #977 empty-leg elision and
    the WHIR pipeline itself, which the STARK driver does not use.
  • Proof-format levers, taken from ZisK's recursion. One ZfFormat (prover/src/zf_format.rs) parses six
    LAMBDA_VM_ZF_* knobs once and prints one ZF FORMAT: banner. The default here is cap=auto whir_cap=auto fri=dp one_row=auto whir_folds=first6 whir_stack=27. Each lever alone, ABBA against legacy:
    • Merkle caps on every STARK tree, c ≤ 3 on the cost law; the cap rides in the first path, so the proof structs
      are unchanged: −15.35 s.
    • FRI folds by 2^d with a verifier-side DP schedule: −28.85 s. Together with caps: −28.55 s.
    • One-row trace openings with a committed FRI input (Plonky3's layout), chosen per table from the AIR widths:
      −8.00 s and 7–8 GiB of host memory on their own. They cost +3.2 s on the WHIR pipeline, which keeps them off
      (WHIR recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 32.62 s #1010).
    • whir_stack is the WHIR pipeline's lever; only the WHIR layouts read it, so no STARK proof depends on it.
    • Every knob keeps its off value, and ZfFormat::LEGACY stays pinned by a golden test. The RV64 recursion guest
      verifies only the legacy format.
  • Column-major LDE engine (crypto/math-cuda/src/lde_cm.rs, kernels/ntt_cm.cu), shared with the WHIR PR (WHIR recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 32.62 s #1010).
    • A device LDE used to take about 33 whole-matrix DRAM passes. The engine computes the coset LDE of as many columns
      as fit in 60 % of L2 at a time, 4 to 8 butterfly levels per launch, so a 2^22 transform is three passes. Its output
      is column-major, so the commits lose their transpose.
    • Every field value, leaf and root is the legacy one.
    • The main, preprocessed, auxiliary, composition and batch LDEs go through it; LDEs that keep a host copy (below 2^19
      rows) stay on the old path.
    • LAMBDA_VM_LDE_LEGACY=1 sends every LDE back to the per-level pipeline.
  • The gap-fix batch. WHIR recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 32.62 s #1010's head (d1dc45514) is merged in, over three signed merges of its candidates: C2
    (7d416688a), C3 (41549ebad) and C4 (d1dc45514).
    • One commit after the first merge turns the LFM_HASH split on for this pipeline
      (chunking::HASH_SPLIT_DEFAULT = true).
    • The first merge's one conflict was in zf_format.rs: this PR's one_row=auto default and the new whir_stack
      lever, both kept. The other two merged clean.
  • The landing merge (f2967e199, all signed): WHIR recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 32.62 s #1010's P2-W (b4506b719, merged as ebbba834d; one conflict in
    zf_format.rs, resolved by keeping this PR's one_row=auto and adding whir_grind=query), R1b (add21183c, merged
    as 13d926774) and F-SIDLE (d1d443b65, merged as f2967e199).
  • The MDS fix and NICE v2: fix2/prep-ahead (bbdac70b2), merged as faddcfe27, and 37d819add (NICE v2 the
    default).
  • The compiled constraint kernels, fast-forwarded on top: e2ab35935 (the generator, the 36 kernels behind
    LAMBDA_VM_GPU_COMPILED_CONSTRAINTS, default off), 422c4b2ac (the proof-bytes test from one set of traces, with a
    mutation control) and cc411aa2c (compiled by default; =0 opts out): this head.
  • main: perf(alloc): compile jemalloc's never-purge policy into the binary #996.

Soundness

Query counts, grinding bits and blowup are unchanged.

The format levers

  • Caps. The root is still the commitment. The cap is hashed to the root once per tree, and each path must reach the
    cap node the query index selects. Path lengths are checked exactly, including at c = 0.
  • FRI folds by 2^d. This is Haböck (eprint 2022/1216) Protocol 1 / Theorem 2 with reduction factors 2^d. Only Σaᵢ
    changes, in a term that stays more than 50 bits below the dominant one.
  • One-row openings. This is batched FRI with the DEEP codeword committed before the first fold challenge. The query
    index is uniform over the whole domain. Preprocessed tables use one-row static roots at blowup 4; a missing root is a
    proving error.

The gap fixes

  • BITWISE only where used. BITWISE only receives lookups, with prover-chosen multiplicities. In a program with no
    sender, its honest multiplicities are all zero and the table constrains nothing.
    • The dangerous direction, a sender without its receiver, cannot be built. The mask is derived from every
      instantiated chip's interactions, stored in the artifacts and folded into program_id, and the verifier re-checks
      it against the mask it was handed. No proof supplies it.
    • Tests refuse a forged mask and a drop under a byte-lookup family.
    • LAMBDA_VM_LFM_KEEP_BITWISE=1 reproduces the legacy registry digests. The re-blessed registry rows hold under this
      PR's one_row=auto default too.
  • The LFM_HASH split is program shape: committed per chunk, bound into program_id and never read from a proof.
    Tests refuse a forged tail root, the single-table door and a wrong chunk root.
  • The base prep and DECODE reuse are byte-identical: the same derivations, on another thread or reused.
  • The later fixes are byte-identical too:
    • the staging (I6): the same bytes through another pinned buffer, by round trips across chunk boundaries and commit
      parity through either staging, on the card;
    • the lead-in (I7): the same prologue, built earlier; a lead-in prologue equals the one built from the bundle (test);
    • the RPX kernels (K3, K4, K5): the same digests, roots and smallest grind nonce;
    • the DEEP/OOD inversion (K6): the same field elements, by parity of each part against the legacy path and a CPU
      reference, and the fault suite under both settings;
    • the compiled constraint kernels: the interpreter's H, bit for bit — host parity on 36 programs × 2 seeds, device
      parity on the card, proofs byte-equal to the interpreter's with 8 kernels in one proof, and a one-node mutant that
      the verifier rejects (see their section).
  • K5 is a different implementation of the same permutation: 32-bit limb multiplies with carry chains instead of
    the 64-bit multiply. Its bytes were shown equal three ways:
    • a host known-answer test runs every permutation variant against the RPX oracle (raw states and chained probes,
      with a failing control). It also replays the cooperative kernels lane by lane (the half-warp Merkle kernels and
      the queue grind) against the shipped ones. CI runs it on every PR;
    • on the card, each switch's two paths agree byte for byte (nodes, nonces, raw permutation states), path against
      path and, where cheap, against the host oracle (rpx_device_paths);
    • in its own ABBAs, every setting proved the same program ids and census.

Security level

Under pil2-proofman's accounting (BCHKS25 Johnson-bound bounds, minimum over phases), every phase of every proof in the
block was ≥ 128 bits. The weakest was the batching phase of the fan-in-2 interior nodes, at 128.009 bits. That audit ran
before this batch and with one-row openings off. The BITWISE drop and the LFM_HASH split only remove or shrink tables
and change no query count, grinding or blowup; they were not re-audited. The later fixes, the compiled kernels
included, change no proof byte.

Fixed along the way

  • One-row verify. The verifier's Phase-A transcript replay absorbed the row-pair root of one-row preprocessed
    tables. It rejected honest one-row proofs that publish values.
  • Concurrent census panels no longer interleave in a log. Each panel is printed in one write.
  • The prove split's device-grind count now includes RPX grinds. Under RPX it read 0 on every table.
  • Device byte-parity tests now run on a card. Vector proofs and LFM proofs are byte-identical between CPU and GPU.
  • Comments are self-contained. The format code's comments point at nothing outside the repository.

Gate and CI

The compiled constraint kernels were gated at this head, cc411aa2c, on the FAST2 box, in two jobs: every step green
but one of job 171's, which job 172 settled:

  • job 171: the standard steps (math-cuda 268, RPX device parity 11, stark 409, crypto 164, the lib suite 1,629 / 0), the
    switch's unit tests, stark's suite on the card build (408), freshness, coverage and device parity, the proof bytes at
    the default floor, the whole cuda_path_integration suite at the default (7 passed, the compiled banner, 56
    compiled-program lines), the opt-out (the interpreter banner, no compiled line), and the fallback, force-downgrade and
    d=1 suites. Its one red step reran the bytes test at LAMBDA_VM_GPU_LDE_THRESHOLD=128: the test passed, but only 4
    kernels ran compiled, below the ≥ 5 floor set before any count (add.elf has only those tables at ≥ 128 LDE rows);
  • job 172, the supplement: the bytes test at a floor of 16, the lowest that keeps every admitted table at ≥ 4 trace
    rows (at 2 or 4, HALT's 1-row trace would reach the device NTT, which refuses log n = 0). It read the pre-registered
    8 compiled kernels (CPU, DECODE, LT, MEMW_R, MEMW_A, BITWISE, REGISTER, KECCAK_RC), the compiled proof byte-equal to
    the interpreter's and verified, and the mutant caught.

The MDS fix and NICE v2 were gated at 37d819add, on the FAST2 box: 10 steps, all green (the lib suite 1,627 / 0 / 90, crypto 164), with the NICE opt-out end to end, the cuda default and device paths, and the RPX suites.

The landing merge before it was gated at f2967e199, on the FAST2 box (the second RTX 5090): 26 steps, all green
(the lib suite 1,607 / 0 / 90). Besides the standard steps, they ran R1b's lines (the shape and attestation tests, both
settings of the leaf node, the inner node and the block root over real children) and F-SIDLE's ten.

The batch was gated at 946ca6045, on the FAST box: 81 steps, every one at its exact pre-registered count,
the same counts as #1010's gate. The standard steps:

  • guest artifacts 266 / 266
  • math-cuda 268 / 0 / 16
  • RPX device parity 11
  • stark 397 / 0 / 6
  • crypto 163
  • the lib suite 1578 / 0 / 90

The 75 targeted lines are #1010's, except that one of them reads this pipeline's LFM_HASH split default as on. For each
switch of the last two rounds, a gate line runs each setting and reads the banner its process printed. The cumulative
ABBA in the first table ran after the gate.

In CI at 37d819add, these pass: lint, the host known-answer tests, the prover test build, the stark cuda-feature tests,
and the CLI and executor tests. The spec structure check fails on a key the spec tooling does not know
(spec/src/blake3.toml: constants), as it does on the WHIR PR. The prover shards were still running when this was
written.

Open decisions

  1. Merging. This PR carries the WHIR PR's (WHIR recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 32.62 s #1010) code up to P2-W (b4506b719). WHIR recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 32.62 s #1010 has since added the argue's
    GPU tables (A1, A2+A3), pure WHIR recursion, N1′, the permit after the prep, small blocks and fan-in 5, which reach
    this PR at the next sync. Both PRs carry the MDS fix. The shared defaults differ in
    one_row (auto here, off in WHIR recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 32.62 s #1010) and the LFM_HASH split (on here, off in WHIR recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 32.62 s #1010). A per-pipeline default would let one PR carry both.
  2. The STARK wraps attesting their program id host-side (R1b): decided and in (see above).
  3. Security margin. The margin is 0.009 bits at the interior nodes, and the verifier takes each table's height from
    the proof. A few bits of proof-of-work before the DEEP batching challenge would add margin, at negligible cost
    (parked).
  4. Reporting configuration (I1): decided. The STARK record launchers leave the device memory pool retained, the
    code's default: −2.00 s on this pipeline. The WHIR pipeline measured −0.45 s, inside noise, and keeps the release.
  5. The lead-in's base cost on this pipeline. The two helpers slow the base's epoch proofs by 3.4 s. That was
    measured twice: in the lead-in's own ABBA, and in this ABBA's fifth arm (base 30.9 → 34.3 s). They likely compete
    with the prover's host work in the shared rayon pool [inferred]. A dedicated pool or a later start could recover up
    to 3.4 s. Not built.
  6. Host verification speed differed between builds: fixed at 37d819add (see "MDS fix and NICE v2").
  7. Protocol changes. W3 (WHIR query carry-over) and W4 (the WHIR paper's rate schedule) are analysed, not built.
  8. An LFM lookup chip would let larger caps pay.
  9. RV64 proof bytes are not reproducible across processes, because six table builders order rows by HashMap
    iteration. Sorting their rows by key makes them reproducible (built and gated: two proves byte-equal, FAST2 173), but
    its A/B read +0.50 s wall, outside its null band, and it was dropped (fix2/1009-canonical-rows @ 9322032fc, kept
    as a reference).

…ormula

Part 1 charged each page a hand-written three-term form: one eq, two prefix
indicators, one shared Sub, 103 rows. Two readers then checked that form and
each found a term the other's lacked — three more per-column terms in
stacked_verify_cost outside weight_at_rows, and an absorb of three coordinates
per column into a THREADED sponge whose row cost depends on where the previous
columns left the buffer. A number two careful readings disagree about is not a
closed form, and the sponge term means no hand-written one can be exact. A
folded correction would have been worse than the understatement it fixed: still
missing that term, and now looking complete.

So the marginal is GENESIS_PAGE_MARGINAL_ROWS, a literal beside
PREPARED_LEG_ROWS, measured at MARGINAL_MEASURED_AT_VARS = 24, the height the
block's thirty genesis pages give. The three-term derivation stays as its doc,
explaining the magnitude and naming what it cannot account for. It is marked
UNPINNED and a FLOOR: the pin that turns it into a measurement lives in lfm and
belongs to the lane that owns the cost form, and nothing else may assert
equality with it. One literal is also why the three parties agree — they read
the same number rather than evaluating the same formula correctly, which is
stronger than the set-independence the fixed height bought.

Two ruled quantities move at 109 and the tests now derive rather than hard-code
them. Tau is 6, not 5: it holds at 5 only for a marginal in [90, 107], and the
only page reclassified carries five nonzero genesis bytes, which neither the
block nor any fixture does. The lone-page pair (9,730 sparse, 9,731 dense)
holds only for a marginal in [92, 109] and stands on ONE ROW at 109 — the page
just over it clears the chain by a single row — so a pin above 109 moves the
pair to (9,731, 9,732). The tests assert densest_sparse_entries() and one more,
and a band sweep states each consequence's exact band: the block's three pages
hold across [19, 2,100,474], the fixture's refusal everywhere, the two shared
pages across [1, 2,484].

The emitter test is rescoped to a LOWER BOUND. weight_at_rows is a partial view
of the cost form, so an equality against it would contradict the real pin the
day it lands. It now reads the two terms that form does account for, 102 across
one more page in one bracket, and requires the literal to be at least that plus
the amortised Sub.
…es today

The literal shipped at 109, built as the three-term reading plus the six
per-column rows the cost form pays outside weight_at_rows. Two things were
wrong with that. The 103 it was built on carried a shared Sub charged at one
per page, and that term is per-POLYNOMIAL: differenced across one more page at
one stack height it contributes ZERO, so the deterministic marginal is 102 + 6
= 108, not 109. And the threaded-sponge term is unmeasured, so 109 was 108 plus
a guess at it — wrong in an unprincipled direction.

103 is what the rule actually charges today. The literal is then a faithful
record of current behaviour, every consequence documented against it is true on
the day it lands, and it is wrong in a direction the doc states: it understates
the deterministic reading by five, which makes part 1 that much too eager until
the pin lands.

Consequences restored to their ruled values: tau is 5 again, the block's
savings 10,248,261, the fixture's 1,931, two pages of 5,000 saving 89,915 apiece
and 179,830 together. The lone-page pair is unchanged at (9,730, 9,731) — it
holds for any marginal in [92, 109], so 103 sits with six rows of headroom
rather than on the edge.

The pin's expected reading is now pre-registered as an executed table rather
than a claim: a measurement of 108 or 109 moves tau to 6 and leaves the pair
alone; 110 or more moves the pair to (9,731, 9,732). The band sweep asserts
both, so when the pin lands the consequence is already written down. The point
the retired immateriality test made is kept as one point of that sweep.

The weight-term test is renamed to say what it bounds and its doc now explains
why the difference is 102 and not 103: the shared Sub differences to zero, so
requiring the literal to be at least 102 + 1 is exactly the statement that it
contains every term that form can see.
`cargo clippy -D warnings` refuses `prepared_claims`' return type as too
complex, on the library target and on six of the nine lint steps. The type grew
when a prepared opening stopped naming one table: it gathers a point per
settled column and a value per settled column, and both are vectors of vectors.

A type alias, which is clippy's own suggestion and not an allow. It carries no
bound: a bound on a type alias is not enforced, and `FieldElement`'s own is
checked at every use — the form `sumcheck::RoundGroup` and
`batch::ResidentProof` already take in this workspace.

A pure type alias is a name for a type that already existed, so no signature,
no layout and no byte of any proof moves.
… maximum

There is no single per-page marginal. The prepared leg's sponge is threaded:
every column absorbs three coordinates into one buffer and a single squeeze
follows, costing div_ceil(4) + div_ceil(8) + 1. A page is two columns, so six
felts, which divides neither 4 nor 8. Differencing across one more page gives
s = [2, 3, 1, 3] repeating with period 4, and the marginal is 109, 110 or 111
depending on which page is added. An equality against one number is
unsatisfiable for a cost that has three.

The literal becomes the MAXIMUM of that spread. Part 1 then asks a page to
beat the dearest position it could occupy and part 2 understates its savings,
so both parts are conservative, while all three parties still read one number
which is what the single literal was for. An average would charge some pages
less than they cost.

Derived, not measured, by two independent readings that agree, and the doc says
so along with the assumption it rests on. It also says what it does not bound:
the indicator term grows two rows per prefix bit, so a stack one bracket taller
costs up to 113 a page. Raising it there would move neither consequence, since
both bands reach past it.

Two derived quantities move and the tests name them rather than hiding them.
The threshold in nonzero entries is 6, holding across a marginal of [108, 125].
The lone-page pair is (9,731, 9,732), holding across [110, 127]. The sparse-leg
cap's bound moves with it, which the ruling's list did not mention and which
would have reddened the gate. The band sweep now walks the whole spread and
shows the threshold invariant across it, so the choice of maximum over midpoint
costs exactly one entry on one quantity.

Five clippy errors that the alias commit let clippy reach, fixed as its own
suggestions and never an allow. The weight-term bound becomes a strict
inequality. The threshold's floor assertion becomes a const block, so falling
under the deterministic floor stops the tree compiling rather than failing a
test nobody ran. A manual modulo becomes is_multiple_of. A test helper's
five-tuple gets a named alias.

`genesis_stack` becomes pub(crate) rather than its return type becoming public:
that type's own field is a vector of another crate-private type, so widening
would have published two types' fields, and every caller is in this crate.
… draft

The alias commit let clippy reach the newer commits and the gate found five
errors, each fixed as clippy's own suggestion and never an allow. The weight
term's bound becomes a strict inequality. The marginal's floor assertion
becomes a const block, so breaking it stops the tree compiling rather than
failing a test nobody ran, and it now asserts the one bound that holds whatever
that number becomes: it must exceed the 102 rows the weight closure alone bills.
A manual modulo becomes is_multiple_of. A test helper's five-tuple gets a named
alias.

genesis_stack becomes pub(crate) rather than its return type becoming public.
Widening the type cascades, because its own field is a vector of another
crate-private type, so two types' fields would have become public API; every
caller is in this crate, and widening later is trivial where un-publishing is
not.

The same gate failed two assertions, and both were about a draft rather than
about the rule. A margin asserting the zero pages sit a factor of six under the
threshold was true while the marginal was 109, false at 103, and true again at
111: a margin stated as a fixed multiple of a number that moves cannot survive
that number moving. It is now the ratio it is, printed, floored at the weakest
value any candidate gives. And the threshold band was tabled as five on one
range and six above it, which is not a band; the sweep ran past the end of what
had been written down and found seven. All three sub-bands are asserted now,
with both edges.

Where the literal falls is computed rather than named. Every earlier version of
that test named the band it expected, so each time the number moved the test
reddened on the naming instead of on the finding. It asserts only that the
literal lies in the band for its own value, which holds at the current figure,
at the form that is coming, and at the higher one a taller stack costs.

The marginal's doc records why three shapes have been tried and why the
arithmetic was never the problem: a formula wrong for omitted terms, a literal
wrong because the quantity is not constant, and a formula again whose one
irreducible term is a measured bound. The value stays where it is; the form and
its measurement belong to the pin.
An `assert_eq!` message is a FORMAT STRING, so `{109, 110, 111}` in it is
read as a format argument and rustc refuses the file: "invalid format
string: python's numeric grouping ',' is not supported in rust format
strings". The test crate does not compile at 52c6cc1 or at 8a3f549
for that one line, and nothing else stands between this branch and its
merge.

`{{…}}` is rustc's own hint, and the rendered message is unchanged.

⚠ THE TRAP IS THAT THE LINE LOOKS LIKE PROSE. It sits inside a
backslash-continued string and carries no quote of its own, so a reader
scanning for string literals skips it and a search anchored on a quote
misses it. Two independent sweeps of this branch's six commits agree on
the count: over `60075209f..8a3f549`, the brace-on-a-digit class has
exactly ONE occurrence and this is it; the brace-enclosed-list class has
six raw hits, five of them inside doc comments where braces are inert,
plus this one. Both sweeps were calibrated against this known line
before being trusted, because a pattern that cannot match the one hit
you already have returns a confident zero.

The author of these commits has retired; this lands on their branch
under the lead's authorisation, and carries nothing else — the shape
change the marginal is getting belongs to the commit on the merged tip.
Brings W1j's gated tip 644b7de (eighteen commits over 60d7903) under the
harness stage, the root, the main sync and V1j's constant-pool forms. With it
the branch carries every half of the WHIR pipeline that exists: the base, the
level-0 wraps, the cross-epoch stage, the shared interior and the root.

NO CONFLICTS. The file intersection against this branch is TWO —
`continuation.rs` and `tests/multilinear_bench_tests.rs`, both moved on this
side by the main sync — and `git merge-tree --write-tree` wrote
7c4ef1c for the pair before the merge ran.
Both instruments carry their own control: the intersection was taken with a
two-sided positive control (the same search shown able to return a hit against
each list), because a search reporting nothing to merge is not evidence until
it has been shown capable of producing one.

The semantic checks, over W1j's whole diff since the base: no statement tag and
no fixed-part constant moved, and the single hit on the table-kind class is
`Error::InvalidTableCounts` — a variant whose NAME contains the string, not a
construction or a destructure. So the three facts the main sync rests on
survive: the elision still does not reach the cross-epoch AIR set, the
cross-epoch statement's tag and its 134-byte fixed part are unmoved, and
nothing in this lineage builds or destructures a `TableCounts`.

⛔ WHAT THIS MERGE BRINGS THAT THE HARNESS CANNOT YET CONSUME. W1j's
`prove_global` hands `multi_prove` a genesis opening, so a run with a genesis
stack now produces a cross-epoch proof whose `preprocessed` is `Some` — and
`whir_global_arena` still opens by asserting that it is `None`. Three sites
answer it, in one commit and not this one: drop the assert, extend the arena
with the opening's words, and teach the program to hint and verify that chain.
⚠ No fixture here reaches it. The laptop fixture's only page is the
private-input one, which carries no genesis, so the arm stays green while the
block — thirty of thirty-five pages on OFFSET+INIT — would fail at the global
stage. The dense-genesis arm written for that commit is what closes the gap.

The asm guest count goes 220 to 221, so the gate's ELF expectation is 266, and
it is a recorded check rather than a printed number: a 265 after this merge is
exactly the symptom of the new guest failing to build.

Nothing is compiled here. This tip's first build is its gate.
…acket, and the cross-epoch program emits the prepared opening

PART 1's term stops being a literal. `marginal_stacked_rows(num_vars,
n_fixed)` is the rows one more carried page adds to the prepared leg,
evaluated at the height THIS run's genesis page count puts the stack at:
101 at a lone page's bracket, 111 at the block's, 113 one bracket up.

Three shapes were tried and the arithmetic was never the problem. A
FORMULA, wrong because it omitted terms — two careful readings each found
a term the other's lacked. A LITERAL, wrong because the quantity is not
constant: it takes three values at one bracket and three more one bracket
up, and a literal carries its bracket only in prose, where prose goes
stale. A FORMULA again, whose one irreducible term is a proven BOUND.
⇒ when a cost splits into a deterministic part and a bounded
nondeterministic one, write BOTH: a literal hides the split, and a form
that omits the bound looks complete while being wrong.

`MAX_SPONGE_MARGINAL = 3` is that bound, proven rather than measured: the
wrapper absorbs three coordinates per column into one threaded buffer and
squeezes once, a page is two columns, and six felts move `ceil(f/4)` by 1
or 2 and `ceil(f/8)` by 0 or 1.

SET-INDEPENDENCE SURVIVES, which is what the single literal protected.
`n_fixed` is `fixed_stack_vars` of the GENESIS PAGE COUNT — a quantity
prover, verifier and emitter each derive from the ELF and the touched page
list before any routing decision exists. It is never `n_dense`, which is
the rule's own output.

TWO DOCUMENTED CONSEQUENCES MOVE, and both are the form following the run.
A lone page is charged at its own bracket, so its boundary is (9,730
sparse, 9,731 dense) with nine rows of margin over the chain, while a page
inside the block's thirty faces (9,731, 9,732); both are asserted, each
naming its bracket. And τ is not invariant across brackets — 5 while the
marginal is under 108 and 6 from there, crossing at nine genesis pages —
so the band sweep walks the brackets and asserts exactly one crossing.
⚠ Every such figure here is PREDICTED from the source, not measured: the
box reads them at this tip.

THE PIN, `whir_stacked_tests::the_marginal_the_routing_rule_charges_is_
the_one_the_stack_bills`, is where the form meets the cost form. It walks
both single-polynomial brackets, guards each page count (one polynomial,
the expected height), differences `stacked_verify_cost` across one more
page, and asserts the form equals the DEAREST page the stack bills, the
sponge term inside its bound at every position and the bound reached, and
τ invariant across the spread. It also walks the same brackets under a
different blowup, folding, security level and grind and asserts the
marginals are identical — the claim that every chain term is
per-polynomial, executed rather than argued. It needs no hash posture: it
proves nothing and harvests nothing.

THE CROSS-EPOCH PROGRAM NOW EMITS THE PREPARED OPENING, which is the half
of the hybrid the emitter owes. The stack's roots are interned and
absorbed in the roots block where `absorb_roots_and_challenge` puts them;
the opening's chains are hinted after every group's; and the leg is
`emit_stacked_verify` over one point per column, each page's own columns
gathered at that page's own reduced point — the same gather
`prepared_claims` performs, so the opening proves the pinned commitment
takes exactly the values those tables settled on, with no separate
equality anybody has to remember to write.

⛔ THE DENSE SET IS READ OFF THE OPENING, NEVER RE-DECIDED. Re-running the
threshold in the emitter is the one way this leg goes silently unsound: a
program could then skip a `check_preprocessed` the opening does not cover.
`GlobalPlan::build` reads `GlobalPrepared.at` and refuses any shape it
cannot mirror — a run that is not a genesis page's whole preprocessed
prefix, a table visited twice or out of stack order, a settled table the
route table calls a bookend or a private page.

⛔⛔ AND EVERY PREPROCESSED COLUMN IS COVERED EXACTLY ONCE — settled by the
stack XOR checked by a closed form — asserted by a pass OUTSIDE the match
that produces the routing. Written inside it, the check would restate its
own expression and could not fail. The failure it exists for is a page
that falls between the two routes: no value is wrong, the program is
merely shorter, and every value gate stays green because there is simply
no check.

The F1 gains the prepared leg and its constants, and its body becomes a
helper so the DENSE bundle gets the same comparison — without it the three
forms the prepared path adds would be written and never read, which is the
shape of defect that left seventeen tests green over a deleted
preprocessed leg.

Also here: W1h's dense-genesis arm, the only fixture that reaches the
opening at all, with an anti-vacuity check on the state it exists to
reach; the guest comment rewritten around the two-part rule, naming the
tree it was read at; and the cross-epoch driver's module header, which
said the proof carries no prepared opening and went stale because of this
change.

The sparse path is unmoved by construction: every new cost term is zero
when the proof carries no opening, which is every fixture but
`dense_data_page_touch`.
`a_candidate_the_chain_cannot_be_paid_for_stays_sparse` asserted the same
quantity twice: once derived, `plan.savings == 2_034 - BLOCK_MARGINAL`,
and once as the bare literal `1_931`. The form charges 111 where the
retired literal charged 103, so the derived side moved to 1,923 and the
bare one did not. Gate-2 read `left: 1923 / right: 1931`.

⛔ THE LITERAL NAMED NO SYMBOL, WHICH IS WHY IT SURVIVED. Re-pointing the
rule at the form was done by sweeping for `GENESIS_PAGE_MARGINAL_ROWS` and
`MARGINAL_MEASURED_AT_VARS`, and this line mentions neither: it is
`2034 - 103` with the subtraction already done. A symbol sweep cannot see
a number that has been folded, and that is the lesson worth keeping —
after the sweep, sweep again BY VALUE, recomputing each candidate from the
new form.

That second sweep is now run and recorded: over `continuation.rs`,
`whir_chain_tests.rs`, `tests/multilinear_continuation_tests.rs` and
`lfm/preprocessed.rs`, every quantity the retired 103 could have produced
was recomputed under the form and searched for in all three spellings
(plain, Rust underscores, prose commas). The 112-entry savings is the ONLY
stale one, in this assertion and in the doc sentence above it. The
5,000-entry savings, the block's savings, the 65,652 savings and the
densest-sparse bound are clean — they were re-pointed with the rule. Every
hit on 9,730 is the LONE page's pair, which the form leaves where it was.

⇒ THE FIX IS ONE DERIVATION WITH THE VALUE IN THE MESSAGE, not a corrected
literal. A number a reader wants is a message; a second assertion of the
same quantity is a thing that drifts, and drifts silently until the day
the first one moves.

The doc sentence above the test paired a 111-row marginal with a 1,931-row
saving, which could not both be true; it reads 1,923 now.
`the_block_bundle_builds_its_cross_epoch_program` built the cross-epoch
program at the block's shape and printed its size, but nothing in its
output said WHICH ROUTE the genesis took — the reading the hybrid's
ruling is actually quoted by. A box run could only infer it from the
instruction count: sparse-only would be over 10 M in INIT alone and the
emit-time cap would have refused the build outright, so a number inside
the ruled band implied the stack had been taken. An inference from an
absence is not a reading.

The line now carries `prepared <rows> over <n> dense pages at n_stack
<vars>`. Nonzero rows mean the opening was taken; zero means every
genesis page went to the closed form, which is every fixture but
`dense_data_page_touch`.

⚠ READ OFF THE DRIVER'S OWN RECORD, NEVER RE-DERIVED. The page count and
the stack height come from `WhirRealGlobal::prepared`, which is what the
VERIFICATION consumed. Evaluating the threshold a second time here would
be a second opinion about a decision already made, and the two could
disagree with nothing in the output to say which one was the run's.

A println in an `#[ignore]`d box arm: no program text moves, no proof
moves, and no fixture-scale gate can see it.
`two_pages_worth_less_than_the_chain_apiece_share_one` held
`assert_eq!(plan.savings, 179_830)` one line under the derived
`assert_eq!(plan.savings, 2 * alone)`. 179,830 is 2 × 89,915 — the
retired constant's `alone` DOUBLED — so re-pointing `alone` to 89,907
left its double behind and the box read `left: 179814 / right: 179830`.

⛔ THE CLASS IS THE SAME AS THE REFUSED CANDIDATE'S AND THE SWEEP THAT
CAUGHT THAT ONE COULD NOT SEE THIS ONE. That sweep recomputed every BASE
quantity the retired 103 could produce and searched for its stale value.
A MULTIPLE of a base quantity is a different number: 89,915 appears
nowhere here, 179,830 does. ⇒ after a form moves, sweep its SUMS AND
PRODUCTS too, not only its terms.

The extended sweep is now run — every base quantity times one through
four, and every pair-sum, in three spellings, over the four files that
name the rule — and this is the ONLY further hit. Two families were
checked rather than assumed: the lone page's boundary reads 9,730 in
several places and is CORRECT, because at that bracket the form charges
101 and the pair genuinely is (9,730, 9,731); and 180,036 = 2 × 90,018 is
two SPARSE LEGS with no marginal term in it, so it does not move at any
value of the form and stays as the retired rule's contrast.

The fix is the same shape as the first: the literal goes, and the sum
rides in the surviving assertion's message beside the per-page figure it
is twice.
…ready named it

The WHIR base prints one number for fifteen epochs - 57% of the block's
wall with nothing under it - and there is not a timer, span or print
between `multilinear_continuation::prove_continuation` and the bottom of
the chain. Every optimisation round so far has moved that number without
anyone being able to say which part of it moved.

The knob is not new. The LFM tree launcher has exported
`LAMBDA_VM_BASE_SPLIT=1` on the WHIR arm since that arm existed, 'byte
identical to the D-S exports', and it reached nothing: the WHIR base does
not go through `continuation::prove_continuation`, where the STARK
instrument lives. An inert knob printed as if it mattered is worse than a
missing one, because the export is the evidence a reader uses to believe
the breakdown was taken. The same name now means the same thing on both
pipelines, in the same line format.

The stages partition their own thread's wall: execute/collect/build/
handoff on the producer, prep/absorb/commit/prove on the prover, and
challenge/argue/open_groups/open_prepared inside the argument. The two
threads run concurrently, so their sums must never be added - what the
pair says is which of them set the wall, and `handoff` is the one stage
that can answer it, being a blocking send on an unbuffered channel.

`check_closure` is a pure function over the records, so the arms can be
fed manufactured omissions rather than only whatever a real run produces:
a missing prover stage reddens arm A naming the epoch, a missing inner
slot reddens arm B (the four stages still close without it), a missing
producer stage reddens arm C, and a zeroed tolerance reddens a run
carrying real timer cost. It deliberately does not assert that the two
sums equal the base wall; that identity is false on a correct instrument
and a check that reddens honestly gets widened until it cannot fail.

Cost when off: `mark` returns None and no clock is read.

Two defects the wiring itself surfaced. `prove_epoch` receives `label`,
not the epoch index, and `epoch_label(i) = i + 1` - keying the prover
records on it would have joined the producer's epoch 0 to the prover's
epoch 1 across the whole table. And a committed table carries no name, so
the argument can only see an index; the names are sent down from the layer
that holds the AIRs.
…ly the production one

The read-back landed in the production WHIR tree arm's base window, which
is the arm that needs a block ELF, a census and a card. The gate that is
supposed to prove the instrument closes cannot afford that arm, and ran
the production one by name: it refused in 0.00s with its own guard -
'LFM_CENSUS_ELF must name a file: this composes the PRODUCTION WHIR tree,
and a silent fixture fallback would report a fixture number under a
production name' - which is the harness being right and the gate being
wrong. An instrument checked only in the arm nothing can afford is an
instrument nothing gates.

The fixture arm keeps its own base window, so this is the same call in
the second place rather than a shared helper growing a caller.

The base wall is taken where the base ends, not recomputed lower down:
`t_all` runs for the whole tree, so a second `elapsed()` would hand the
split a denominator including level 0 and the interior, and every stage's
share would read far too small.
…n it

`open_groups` is 43.8% of the WHIR base and 24.9% of the block's whole
wall, and wt12 printed it as one number. These six resolve the round
loop: the three 20-bit grinds, the opening sumcheck, the fold, the fresh
successor commit, the out-of-domain block, and the query openings that
rebuild the tree on device per batch.

Six and not the four the round obviously has: `factors.rounds` and the
out-of-domain block are neither grind nor fold nor commit nor query, and
leaving them out would have made the closure arm redden on a correct
instrument - which is how a tolerance gets widened until it cannot fail.

Arm E asserts the six close `open_groups` per record, and it caught two
real defects in this instrument before either reached a block run.

The first: the slots are process-global, and reading them at the window's
close alone attributes to this group loop whatever ran the chain earlier
in the process. They are now cleared at the window's OPEN, so 'the group
openings only' is a property of the window and not an assumption about
callers.

The second: the out-of-domain window spanned its own grind, and the grind
was separately added to GRIND - so that time was counted twice and the six
summed to MORE than the wall containing them. Slots that partition must
not nest; the two out-of-domain windows now abut the grind instead. The
fix is visible in the slot that moved: ood 3.83s -> 0.04s.

A negative remainder is therefore not drift. It means the parts are not
parts, and it has two causes - a window that is too wide, and windows that
overlap. The message says so rather than reporting a percentage.

The prepared opening keeps its wall and no breakdown: it is 2.5% of the
base, and six more fields would not move a ranking. The success line names
arms A-E, because a line that under-names what it checked reads exactly
like a check that never ran.
…e posture in the pin's identity

Runs lb17 and lb18 measured the transcript pins at two tips of this lineage and
read the same four deltas at both: +77 absorbs, +200 squeezes and +17 states on
BOTH sides, and one device commit the model did not account for. The pins had
not actually been measured on this lineage since V3's tip -- the "unmoved"
readings from W1h's v4/v5 gate were greps matching the tuples the two
should_panic tests print, which appear on any machine and touch no guest -- so
the move is the lineage's, not any one commit's. An A/B across the two tips read
identical counters, which is what says so.

Two causes, both now derived rather than re-measured.

THE POSTURE. The constants were taken with LAMBDA_VM_MAX_ROWS_LOG2 unset, where
MaxRowsConfig::default returns the production per-table caps and an epoch
carries 34 tables; every run of record is at the uniform 2^21, where the same
block's epoch carries 27. pin_applies took the sha, the length and the epoch
size, so a run at a posture nobody pinned was the pinned configuration by the
pin's own identity. The cap is now part of that identity, read through
max_rows_log2_override -- extracted out of MaxRowsConfig::default so the posture
the pin checks is by construction the posture the epochs were chunked at -- and
the bases are the record posture's, measured at 892c7d1. A run at any other
cap skips and names both caps; another posture is not a defect, it is a
different measurement. This held on the ladder branch only; this commit is what
makes it true on this lineage.

THE STACK. The stacked INIT polynomial of the dense genesis pages entered the
cross-epoch statement after the pin's constants were taken. Its cost is now a
function of the plan the run used -- read through the verifier's own
global_airs_for(..).genesis_stack(), so no threshold is re-decided here -- and
of the chain config that proof argues at:

  absorbs   one root per stacked polynomial in the cross-epoch roots block,
            one claimed value per stacked column, then the chain
  squeezes  the batching challenge, then the chain's own draws
  states    3R - 1: three grinds a round, with no out-of-domain one on the last

At the block's six columns of 2^18 -- one stacked polynomial at n_stack 21, six
rounds at fold width four -- that is 1 + 6 + 70 = 77, 1 + 199 = 200 and 17,
which are the measured deltas exactly. A run with no dense page adds nothing,
and the unit pin evaluates the same form at n_stack 19 as well so a wrong term
cannot be flat across both shapes.

The stack costs the run once and not once per epoch, and it moves both sides
equally: it lives in the cross-epoch proof, which has no `owed` replay, so
unlike DECODE's derived root it is absorbed once on each side and owed is
unmoved. The measurement confirms that at 160 = 145 + 15 absorbs and 30 = 2 x 15
squeezes.

THE DEVICE COMMIT. commits() walked a flat list of proofs, so the stack's five
fold commits were picked up the moment it landed while its held commitment was
not: the held term was a find_map, which stops at the first proof carrying a
prepared opening, and the epoch proofs come first in the list the bench builds.
The two held commitments have different scopes -- DECODE's is a function of the
ELF and is held across every epoch, the stack's is built once per prove_global
call and belongs to that one proof -- so they are now two arguments and two
terms, and neither can be inferred from slice order. 1106 + 80 + 2 = 1188, which
is the counter's reading.

The carried bases compose with the derived terms onto the lb17/lb18 measurement
on all six numbers with no residue, which is what makes carrying them a verified
move rather than a new literal; the arithmetic is written out in the doc
comment. owed's carried half is no longer only a constant either: it is checked
against the run's own proofs -- the sum of roots.len() over the bundle's fifteen
epochs, one root per chain -- so a posture that moves the chain count reddens by
name instead of arriving as "the counts moved".

Also: make lint gains a TENTH step. Everything under cfg(all(cuda,
hash-metrics)) -- check_device_pins and transcript_pin::commits, which is the
whole device-commit model -- was compiled by no pass in the matrix: the cuda
pass carries no hash-metrics and both hash-metrics passes carry no cuda, so a
box run was that code's first compiler. From this sha the lineage's lint is ten
steps run separately, not nine, and the tenth needs no GPU.
… lint step needs the parity allow

Two fixes to the commit before this one, both found by running the gate rather
than by reading it.

then_some. `global_stack` built its Option with `.then(|| StackShape { .. })` on
a struct literal with no side effects, which `clippy::unnecessary_lazy_evaluations`
rejects under -D warnings. Lint step 9 caught it; the commit before this one had
been made with that step red, because the gate script committed unconditionally
between the test run and the mutations. The script now refuses to commit while
any step above it is red -- a script that commits on a red is the same class of
defect as a gate whose verdict is not read.

The tenth lint step. It was added without `-A clippy::op_ref`, which every one
of the other nine carries; without it the step reports 156 op_ref errors from
code the workspace writes that way by design, so it was a step that could not go
green on any sha. The line is now

  cargo clippy -p lambda-vm-prover --all-targets --features cuda,hash-metrics -- -D warnings -A clippy::op_ref

and with it the step reads exit 0 with zero error lines, which is what makes the
device pin's cfg(all(cuda, hash-metrics)) code covered rather than merely
mentioned. From this sha the lineage's lint is ten steps run separately.

One finding recorded and NOT fixed here, because it is outside this lane: a
clippy pass with --all-features -- a posture the Makefile's matrix never runs --
fails on crypto/stark/src/prover.rs:1544, `too_many_arguments` (8/7) on
`commit_main_trace`. Nothing in this branch touches that file.
The six chain slots closed to +1.4% on the laptop and +5.1% on the box's
card-free fixture. Widening the tolerance to admit 5% would have made the
arm unable to fail, and it would have been wrong about the cause.

The cause is not the round loop's bookkeeping. `Factors::from_shares` runs
in `prove_shared`, and `stacked_eval::prove` builds the weights and the
stacked polys, all inside `open_groups` and outside the round loop
entirely; `config.schedule` and `domain.clone()` sit before the first
round. That is a setup phase - the same class of miss as the
out-of-domain grind - and it is roughly fixed per epoch, so its share
grows as the window shrinks on a faster machine.

So the loop's own wall is measured, and what was one unattributed gap
becomes two NAMED terms: `round_other` is the loop's bookkeeping between
windows, `setup_tail` is everything outside the loop. The reading says
which owns the gap rather than a comment asserting it. On the fixture it
is unambiguous: round_other 0.00 on every record, setup_tail 0.20 / 0.17
/ 0.16 / 0.01.

Arm E had to change with it. `Sigma(six) + round_other + setup_tail =
open_groups` is an IDENTITY once the wall is measured - the remainders are
defined as the differences - so asserting it would be a check that cannot
fail. It now asserts what can: both remainders NON-NEGATIVE. A negative
one is not drift; it means the parts are not parts, and the two bounds
separate the two causes - the six overlapping or escaping the loop, and
the loop escaping the opening. Those are the shapes the two real defects
took.

A slot merely reading small is therefore a READING, not an error: its
time lands in a named remainder. One unit case exists to assert the arm
does NOT redden there, so it cannot drift back into asserting an
identity.
The seventh slot fixed a false red and removed the check's power to see
a missing timer. Asserting only that the two remainders are non-negative
meant an omitted slot shrank Sigma(six), so round_other = round_wall -
Sigma(six) GREW - positive, allowed, invisible. The gate proved it: the
mutation arm E caught before the round wall existed sailed straight
through after it.

The identity was never the thing to remove; ASSERTING the identity was.
round_other is the loop's own bookkeeping and reads 0.00 on every record
of a correct instrument, so an upper bound at 3% of the loop's wall is
enormous headroom honestly and trips on any omitted slot above it.
setup_tail keeps >= 0 only: it is legitimately un-slotted work outside
the loop, and bounding it would assert a size nobody measured.

Two more defects surfaced while fixing it, both from the unit run rather
than from reasoning.

The honest() fixture carried a 5.9% remainder and tripped the very bound
it was written to test. A fixture that is not itself a correct instrument
makes every arm built on it meaningless, so the wall now models what real
records show: Sigma(six) plus a hair.

And the checks ran in the wrong order. A loop wall that escapes its
opening also leaves a large positive round_other, so with the accounting
check first it was reported as 'a slot is not being added' - the wrong
defect, named confidently. Containment is checked before arithmetic.

The new unit case has a twin that must NOT redden: a slot genuinely
small, where the wall shrinks with it. Without it, queries reading 0.00
on any card-free fixture would become a permanent red and the next lane
would widen the bound to silence it.
…ck, the cap in the pin's identity, the tenth lint step
…t it is not

`LAMBDA_VM_GRIND_SCAN_FACTOR` (default 8 — the record posture, unmoved)
replaces the literal 8 at the one site that sizes a device grind's launch
block. Read once per process through a `OnceLock`, refused outside 1..=64 with
the offending value named, and printed as `★ GRIND SCAN FACTOR: n` on the
first device grind, so a log that quotes the factor can be shown to have read
it rather than assumed it.

⛔ The knob is NOT the lever it was ruled to be, and the doc comment now says
why. Both grind kernels carry `if (nonce >= *result) break;` against a
`volatile` result the `atomicMin` writes through L2, and the stride walk gives
every nonce in `[base, base+count)` exactly one owner — so the scan stops at
the first hit. The permutations executed are `h + stride` whatever the block
size, and the launches before the hitting one cover exactly the part of
`[0, h)` below it. The factor buys only the probability that one launch
suffices, `1 - e^-k`. Lowering it removes no permutations (they were never
executed) and adds `1/(1 - e^-k)` expected round trips.

The same reading says the returned nonce is the globally smallest valid one at
any factor — which `tests/grinding.rs::gpu_grind_returns_smallest_valid_nonce`
already pins — so sweeping the knob moves no proof byte.

`prover/tests/rpx_grind_bench.rs` is the arm that settles this on the card in
seconds rather than in four tree runs: ms/grind and the full nonce list at one
scan factor per process. Flat means the block is a ceiling; halving means the
scan dominates; an identical nonce list across the arms is the byte control.
…ead off the driver

`LAMBDA_VM_GRIND_GRID` joins `LAMBDA_VM_GRIND_SCAN_FACTOR` in one module, both
read once, both defaulting to today's exact values (8 and 1024) so the record
posture is byte-unchanged. ONE line prints both AND the stride each arm gets —
`★ GRIND KNOBS: scan 8 · grid 1024 · stride rpx 131072 / keccak 262144` — because
the stride is the mechanism and a reader should not have to multiply it back
out. The two block dims stay constants: they are tuned per kernel against
register pressure, which belongs to the kernel body, and moving them would
change what an occupancy reading means.

Why the GRID is the candidate lever now that the scan factor is not. The
kernels stop at the first hit, so a search executes `h + stride` permutations
and `stride = grid × block_dim` is the term left behind — the overshoot is
`stride/h`, 12.5% at the default. That gives the knob two opposite edges: while
the card is not filled a wider grid raises throughput faster than overshoot,
and once it is filled the surplus blocks only queue and the wider stride is
pure added work. The sweep therefore has to run BOTH ways.

`device_fill()` answers which edge the default sits on by READING the driver —
SM count, max threads per SM, the kernel's registers per thread, and the
occupancy the driver will actually grant — instead of estimating residency from
a block dim. A grid above the resident-block ceiling buys no parallelism.

`search` now takes its knobs as a parameter, so `generate_nonce_{gpu,rpx_gpu}_at`
can sweep them inside ONE process. That is not a convenience: the knobs cache in
a `OnceLock`, so comparing settings through the environment would need a process
per arm, and four processes are four device contexts, four cubin loads and four
clock domains compared across an exponential spread of hit distances. Paired
arms on identical seeds make the ratios exact instead.

`prover/tests/rpx_grind_bench.rs` runs the nine arms that way — scan 8/4/2/1 at
grid 1024 and grid 256/512/1024/2048/4096 at scan 8, the 8/1024 arm shared — over
256 seeds at the production factor, reporting mean, median and `ns/perm`. The
nonce IS the hit distance, so the permutations a launch executed are known
exactly and `ns/perm` is the seed-independent throughput the grid question turns
on. Three controls travel with it: the environment path is exercised and
asserted to agree with the explicit one, every arm's nonce list must be
identical, and the measured ms/grind is projected over the base's 3,428 grinds
against the window wt14 read (15.73-18.03 s) and reported in or out.

`crypto/math-cuda/tests/grinding.rs` gains the card-side twin: the nonce is the
same, and still the smallest, at every scan factor and every grid.
…anism

The first run read a monotone fall down the scan column — ratios of 1.000,
0.953, 0.872 and 0.800 at scan factors 8, 4, 2 and 1 — which the kernels say
cannot exist. Both stop at the first hit, so the executed permutations are
`h + stride` whatever the block size, and for the median seed, whose hit falls
inside even the narrowest block here, the two launches are the same kernel
doing the same rounds. There is nothing for the knob to change.

The arms ran in one fixed order in one process, so that fall is confounded with
drift. Pairing on seeds cancels the seed spread; it does not cancel a boosting
clock. Four changes to the procedure, none to the measurement:

Every seed now runs every arm in a rotating order, so each arm sits in every
position of the rotation equally often and drift pairs out too. The control is
repeated as a final arm with identical knobs: its ratio is the noise floor,
measured rather than assumed, and no arm may claim less than it. Statistics are
per-seed and paired — the median of the per-seed ratios and the count of seeds
the arm actually beat, because a real twenty percent shows on most of 256 seeds
while drift shows as a trend a rotation destroys. And the combined arms run, in
case the two effects are real and additive.

★ The split that can falsify a mechanism. The returned nonce IS the hit
distance, so every seed can be labelled by whether its hit fell inside the
arm's block. Seeds inside take one launch and run the identical kernel at every
arm, so no knob can touch them; seeds outside are the only ones that miss and
relaunch. An arm whose gain is the same on both groups is not the knob, it is
the procedure. A gain living only in the outside group is a real miss-path
effect and owes a mechanism from the kernel before it is priced.
… survives a zero

QUERIES is the largest slot in the WHIR chain and, like `open_groups` before
it, one number. Four slots partition it — `QUERY_SAMPLE`, `TREE_REBUILD`,
`COSET_GATHER`, `OPEN_ASSEMBLE` — counted on EVERY `open_many` call, which is
twice per non-final round because `whir_round::prove` opens the current
commitment and its successor, and once in the final round.

⛔ The boundary is `open_many`, not `paths()`. On the device arm `paths()` is a
range check, ONE device call and a `map` into `Proof`, so splitting inside it
would weigh the rebuild against host bookkeeping over a hundred kilobyte-sized
paths and read ~100% every time. What competes with the rebuild is the coset
gather, which sits beside `paths()` rather than inside it and would otherwise
stay in QUERIES as an unnamed remainder — the same shape as the setup gap the
seventh slot was added to name.

`queries_other` is bounded above as well as below, and the bound carries an
ABSOLUTE allowance beside the relative one. Card-free the fixture's query
openings read 0.00 s, and three percent of two milliseconds is below the glue
between the windows and below the clock itself, so a purely relative bound
would fire on the honest path at the shape the gate actually runs.

★ Arm F is the arm that survives that shape: `rebuild_calls` must equal
`2·round_count − chain_count`, every term counted by the run rather than read
off the source. The durations vanish card-free; the calls do not.

⛔ And the term is CHAINS, not groups. `stacked_eval::prove` runs one chain per
COMMITMENT in the stacked commitment, so a group can open several, and an
identity written over groups would have been red on the honest path the first
time one did. Its guard is "any of the three counters is nonzero" rather than
"the rounds are", because guarding on the rounds alone makes a dropped ROUND
counter invisible — the same blind spot the seventh slot opened in arm E.

The harness sums the four over the epoch records and prints `tree_rebuild`'s
SHARE of the query openings, which is round 3's kill condition: retention
removes the rebuilds and nothing else, so if they are not the bulk of QUERIES
the lever is dead before any lifetime code is written.

The closure line now names arms A-F, because a line that under-names what it
checked reads exactly like a check that never ran.

★ The new arm found a defect in the existing fixture on its first run: the
"must NOT redden" twin zeroed the QUERIES slot while leaving the four inside it
at their honest values, which models four parts summing to more than their
whole. The fixture was wrong, not the bound.
…es the mean

⛔ The launcher read the repeated control's ratio by COLUMN POSITION, and that
arm's label is two words, so it read the throughput column instead. It reported
the procedure as 327% unstable on a run whose ratio column read 1.000 — a check
that could not pass, in a script that reads every other verdict by name.

The fix is not a better column index. The bench now prints the floor on its own
named line, so nothing downstream has to count spaces to find the truth.

And two readings the means still owe. The per-seed median ratios are flat while
the means fall, which is a statement about a distribution, so the distribution
is now printed: the deciles of the per-seed ratio per arm, and the twenty seeds
that move the mean most against the control.

Each of those twenty carries its hit distance and its launch count at both
arms. The launch count is DERIVED rather than instrumented — the search
advances its base by one block per miss and returns on the block containing the
hit, so the count is the hit distance over the block plus one, exactly. It is
the only quantity that differs between two arms for one seed, which makes it
the discriminator: if the twenty are the largest-hit-distance seeds and their
launch counts exceed one, the effect lives in the miss-and-relaunch path or in
what a long sustained launch costs under the board power limiter, and the card
drew its full power on that run. If they are ordinary seeds, neither survives.
…nd is the only thing left

v4's top-20 killed three of the four candidates for the tail effect. The seeds
that move the mean are SMALL-h (258k-512k, inside 2^20), take ONE launch at both
arms, and it is the CONTROL that is slow by 5-11 ms while scan 1 costs what
`h + stride` predicts. That rules out the power limiter (these are not the long
sustained launches), the miss-and-relaunch path (one launch either way) and the
volatile load's per-iteration cost (the same iterations either way).

What is left is readable from the code. For a one-launch seed `search` does
nothing that scales with `count` — one 8-byte sentinel, a launch at a grid the
knob fixes, 8 bytes back, a synchronize — and inside the kernel `count` reaches
exactly one thing, the loop bound `i < count`. Work is `h + stride` ONLY IF the
early exit stops every thread; a thread that never observes the atomicMin runs
to `count` and wastes in proportion to it.

So sweep the knob UP instead of down. Scan 16, 32 and 64 join the arms, and the
new COUNT SLOPE section prints each arm's excess over the tightest cap in the
sweep, normalised to the control, BESIDE its prediction (k-1)/7. Count-bound
reads 2.14 / 4.43 / 9.00 at k = 16 / 32 / 64; saturated reads ~1.00 from k = 8
up. The two branches are a factor of eight apart at k = 64, which no clock ramp,
thermal drift, ordering or seed spread produces.

The grid pair is run at BOTH caps (grid 4096 at scan 8 and at scan 1) so the
same defect can be tested from the block-count side: if wide grids make
stragglers worse by contending the atomicMin's line, a tight `count` should mask
it. Equal damage at both caps refuses that unification.

The verdict is printed on a named line and the section is read by its name, not
by column position or section order: `scan 64` also begins a row in THE ARMS and
in the DECILE tables, so the launcher anchors the read to the COUNT SLOPE
section itself. That is v3's lesson, which cost this file a noise floor that
could not pass.

No default moves. Scan 16/32/64 exist to make waste visible by exaggerating it
and are candidates for nothing; the record posture stays scan 8 / grid 1024.
This measures wasted work, never a wrong answer: the nonce control asserts all
twelve arms return identical nonce lists before any timing is read.
Stage A swept the scan factor upward and killed the last timing-only
hypothesis: threads do not run to the loop bound. The excess saturates above
count = 2^23 instead of growing with it, reading 1.05 at scan 16, 32 and 64
against a prediction of 2.14, 4.43 and 9.00.

It does not saturate immediately either. In iterations per thread the excess
fits T·(1 - e^(-(N-8)/tau)) with T = 1.06 ms and tau = 19 on three independent
points, which is the shape of a thread scanning for a bounded TIME after the
answer is known rather than to a bound. One iteration at grid 1024 is 131,072
permutations, about 0.56 ms, so 19 iterations is about 10.6 ms -- where the
earlier top movers sat. That is a fit, not a reading, and this commit replaces
it with a reading.

`rpx_grind_search_counted` is the shipped search with three device counters:
the permutations its threads ran, the deepest thread's iteration count, and how
many threads left by the loop bound rather than by the early exit. So
executed - (h + stride) becomes a number per search. The counters reduce inside
the warp and hit memory four times a warp rather than once a thread -- 131,072
serialised updates of one L2 line would be the same order as the effect under
measurement, and an instrument that manufactures its own signal answers a
different question.

The twin is device-only. The host KAT compiles this file through a shim that
supplies gridDim, blockDim and atomicMin but not the shuffle, and a KAT has no
answer to check for a kernel that produces no digest. Its agreement with the
shipped kernel is asserted instead where it can be: on the device, seed by
seed, on the nonce.

Nothing on a proving path launches it, no default moves, and the shipped kernel
is not touched. The bench pairs every counted search with a shipped one on the
same seed and refuses to draw a conclusion from any arm where the two disagree
on milliseconds beyond the noise floor -- a counted kernel with different
register pressure has different occupancy and measures a different kernel.

Both branches are written into the file before the run: a stale poll means real
extra permutations, bounded by time and therefore the same at scan 8 and scan
64, with ran_to_end near zero at scan 64 and nonzero at scan 1; no overrun
means the slow launches run the same permutations more slowly and the cost is
outside this loop. The cross-arm verdict is computed and named in the test, not
left for the log's reader.

Sized before the first cubin, by this file's own rule: one more call site for
`permute`, not one more inlined copy, so the entry is of order 150-250 PTX
lines against a file of 6,241. The shuffles take `unsigned long long` rather
than `uint64_t` because the intrinsics have no overload for `unsigned long`.
H4 kept the whole Merkle node array so an opening would not re-hash the leaves,
measured it on the card, and lost about 15 s: the retention is one object per
commitment IN THE GROUP, because every chain's commitment is built before any
query index is drawn, and ten of those put the device at 96% -- after which
allocations fail, commits fall back to the host, and the host grows about
1.5 GiB per fallen-back chain. That finding stands and is not edited away.

What changes is which object. A tree is 2*num_leaves - 1 nodes; its leaf layer
is num_leaves of them, half the bytes -- C * 2^(5-k) at fold width k, a quarter
of a base codeword at the production k = 4 where H4 held half of one. And the
leaf pass is the expensive part: a leaf absorbs a whole 2^k coset, two
permutations on a base codeword and six on an extension one, against one per
inner node. So the layer carries two thirds of a base tree's work and six
sevenths of an extension tree's, and rebuilding the inner levels from it is the
cheap third. Half the memory for most of the saving is a different trade from
the one H4 measured.

It can also decline, which H4 could not. The capture asks
DeviceReservation::grow -- which already existed, unused, with a doc comment
describing exactly this case -- and a refusal costs one leaf pass and nothing
else, the behaviour of this file before the change. Allocate, then promise,
then give the promise back if the allocation failed, so neither direction
leaks. The commit cannot fail because of the cache, so commit_stacked's device
attempt cannot start returning None, so the fallback cliff is unreachable
rather than unmeasured.

The key is part of the object. A leaf is the 2^log_folding coset that folds
onto one position, so a layer is valid only for the width it was built at and
the hash that built it; paths() takes the width as a parameter and the cache
test opens one codeword at two widths on purpose. Served across widths this
would hand back authentication paths that are internally consistent and wrong.
The predicate is a free function so it takes unit cases on a machine with no
device -- it is the one part of this whose failure is not slowness.

Counters diverge where they used to agree: tree_builds counts trees assembled,
leaf_hash_calls counts passes actually paid, and the two together assert the
retention in both directions. The group-scale memory test is now two-sided
against the form -- the layers must be held, and a whole tree must still never
be -- where the old one-sided bound sat on its own edge. The base split prints
admitted against refused retentions on every run, including the runs that
retain nothing, because a refusal path that is silent is indistinguishable from
a lever that never fired.

No proof byte moves: the same leaves give the same tree, the same root and the
same paths.
…issed

`executed - ideal` came back NEGATIVE on the two arms where searches miss --
scan 1 read -4.17 strides -- and an overrun is not a quantity that can be
negative. The cause is the model, not the counters.

`ideal` added `(launches - 1) * block` on the reasoning that a missed block
costs its whole `count`. It does, but `nonce` is ABSOLUTE: those nonces are
already inside it, so the term counted them twice. The model is `nonce +
stride`, full stop -- every nonce below the hit, plus one stride round for the
threads that were mid-permutation when it landed.

Scan 8 and scan 64 are untouched, because at those block sizes every seed in
this bench hits on its first launch and the extra term was zero. Those are the
two arms the verdict is written over, so the stage-B reading stands: the
overrun is the same at both, and `ran_to_end` is 0 at scan 64.
`the_process_wide_counter_tracks_the_same_passes` asserted that a tree and a
leaf pass are the same event -- `tree_builds() == leaf_hash_calls()`, and that
an opening moves the global counter by one. With the leaf layer retained they
are no longer the same event by design, so this test would have reddened on the
box for the one reason the gate was not looking for: an invariant that expired
when the code under it changed, in a file whose other tests were rewritten and
this one was not.

It now asserts the relationship that replaced it, in deltas because the three
counters are process-wide and diverge on purpose: a commit assembles one tree
and pays one pass; an opening assembles a tree and pays NOTHING, recording one
saving instead; and over any window, trees == passes + savings. That identity
fails in both directions -- a tree that skipped its pass without recording a
saving breaks it, and so does a saving recorded for a tree never assembled --
where the old equality could only fail in one.
`run_grind` declared its result `uint64_t` and handed the address to the kernel
as `volatile unsigned long long *`. Those are the same type on Darwin/arm64 and
different types of the same width on LP64 glibc, so on Linux the cast
type-punned; with `#include "rpx.cu"` putting the whole kernel in this
translation unit, GCC 13.3 at -O2 was free under TBAA to assume a write through
`unsigned long long *` could not touch an `unsigned long`, and to keep `result`
in a register across the inlined call.

It did. `run_grind` returned UINT64_MAX for every input, so layer 8's two checks
per vector that expect the SENTINEL passed VACUOUSLY while the two that expect a
found nonce failed. Six rows, at every sha back to the gated base c00342c, on
a target that passes on a clang/arm64 laptop where the two types coincide.

Measured on the box at this sha:
  -O2                        6 FAILURE(S)
  -O2 -fno-strict-aliasing   ALL HOST KAT CHECKS PASS
  -O0                        ALL HOST KAT CHECKS PASS

None of it was ever a statement about the device grind. This file is a HOST
replay of the kernel source through `cuda_host_shim.h` — no nvcc, no cubin, no
device — so the defect was in the harness holding the result, not in the kernel
it tests. The production path re-validates every device nonce with the host
predicate, and the block pins read `host fallbacks 0` throughout.

`crypto/math-cuda/tests/host_kat/` holds exactly one instance of the pattern and
this is it: the shim's `atomicMin` takes `unsigned long long *` as a parameter,
and the kernels' casts there only drop `volatile` from an already-matching type.

Test-only; no production code and no proof bytes move. It also unblocks this
lineage's CI `host-kat` job, which has been failing since the device grind
landed.
Two checks for a device build, so a host fallback cannot pass as the device:

- the head helper's device stamp says whether the device took the warm-up
  (`BASE HEAD: device warm-up` or `... declined`), since a decline is
  silent by design (the prove then builds the same state on demand);
- `the_head_decode_root_is_committed_on_the_device` (cuda): the 4,097-
  instruction DECODE map, 8,192 rows at blowup 2 and so above the device's
  commit floor, moves the device group counter and still gives the host
  root. The counter is process-wide, so the test is run on its own.
The ZfFormat::DEFAULT doc quoted a block timing for whir_grind=query from an
earlier measurement. A measured number in a comment goes stale, so the doc
now says what the lever does and why it loses no proven bits.

The same reasoning in whir_chain's module header, GrindBits::query_only and
WhirGrind gave the out-of-domain grind's reason as "it follows the
out-of-domain point". That is half of it: the grind sits right before the
batching challenge and does guard it. Dropping it costs nothing because the
batching challenge has far more bits than the target without any grind.

Comments only.
the_inner_node_verifies_two_leaf_nodes needs FAN_IN^2 = 4 epochs and took
FIXTURE_EPOCH_LOG2 - 1. Since 8f9aef1 moved the fixture to the 48-cycle
continuation-fixture at FIXTURE_EPOCH_LOG2 = 5, that is 16-cycle epochs and
three of them, so this box-tier test has stopped at its own epoch-count
assert in setup, before proving anything, under either wrap attestation. A
pre-existing fixture drift, found by the gates at e413989.

Two below the shared constant is safe by an assertion the suite already
holds: the_fixture_guest_commits_in_an_intermediate_epoch keeps the guest
above one shared epoch and within two, so 8-cycle epochs give at least five
(six today), where 16-cycle ones give three or four.
…le v2)

Job 202's NICE arms lost 9.65 s at STARK level 0 while their mechanism
moved the right way (level-0 held time -5.4 s). The cause was the one
host-phase pool shared by the six level-0 workers: every whole host
phase was an injected job there, and a rayon thread blocked in a join
runs injected jobs before it returns to its own (rayon-core 1.13
wait_until_cold). A thread waiting inside one wrap's reconstruct ran
other wraps' whole phases nested on its stack, so the reconstructs that
started first finished last (2-3 s became up to 22 s) and the card sat
with no holder for 17 s of the level.

host_phase now installs into a pool of the CALLING thread's own, built
at its first host phase and dropped with the thread. A worker runs one
phase at a time, so its pool only ever holds that phase's jobs; a
caller that is already a rayon worker runs the phase inline. Each pool
prints one line naming its caller and how many of its threads took the
nice value, counted after every thread has started. Knob unset: inline,
as before.

Tests: two callers' phases never share a thread (with the shared-pool
design as the control, which puts both on the same threads); a caller
reuses its own pool; a rayon worker runs a phase inline; unset runs on
the caller; a new pool reports every thread.
A table outside the device-only envelope downloads its whole main and aux
LDE inside its commit, on the driver that would otherwise submit its next
table. At the 2^19 default the STARK block's base downloaded 39.74 GB that
way (8.08 s of driver time), most of it KECCAK_RND and ECDAS tables of 1,480
and 521 columns whose device paths already run: they sat outside the envelope
by row count alone. At 2^16, with the barycentric floor (trace >= 2^14 rows)
below it, the base downloads 7.27 GB and the block proves 1.40 s faster
(FAST job 206, ds850-861, two arms each: wall -1.40 s, base -1.10 s; program
ids unchanged, the root verified).

`LAMBDA_VM_GPU_DEVICE_ONLY_THRESHOLD=524288` restores the 2^19 envelope. The
default is process-wide, so the WHIR pipeline's recursion proofs (FRI STARKs
through the same gate) take it too; that pipeline was not measured.
The head helpers (DECODE committed on the device, the first prove's domain,
twiddle and staging state built beside epoch 0) become the default:
`LAMBDA_VM_BASE_HEAD_AHEAD` unset, empty or `1` runs the head ahead, and `0`
is the named opt-out that runs the serial head as before. FAST job 206
(ds850-861, two arms each against the serial head): the head 2.4 -> 1.0 s, the
first prepass 0.37 -> 0.00 s, the base -2.00 s, the block -1.45 s; program ids
unchanged, the root verified.

The switch's test now pins the new reading (ahead unless exactly `0`); the
equivalence test still proves both heads by parameter.
… by default

The level-0 lead-in's prologues put parallel work on whatever pool they run
in while the base still proves its last epochs, and the base's per-table
drivers, plain OS threads, queue their own parallel iterators behind it in the
global pool: the host-only OOD absorb summed 5.2-5.3 s over split 9's tables
in all three G1 arms. In a pool of 16 threads of their own (FAST job 206,
ds850-861, two arms each against the global pool) the base's worst split
absorb fell to 0.04 s, the base by 2.35 s and the block by 2.20 s, level 0
+0.05 s; program ids unchanged.

The pool now belongs to each `LeadIn`, sized by its caller: the STARK tree
defaults to `STARK_TAIL_THREADS` (16); the WHIR tree keeps the global pool,
its tail not having been measured in one. `LFM_TREE_TAIL_THREADS` overrides
either (`0` is the global pool, the named opt-out; a count, a pool of that
size), and the line naming the choice is printed where the lead-in starts.
The rationale no longer says the prologues' verify fans out over the pool;
what is established is that isolating them removed the stalls.

Tests: the switch over each pipeline's default; a lead-in with a pool of 3
builds its prologues on that pool's threads and one without a pool does not
(routing the prologue around the pool fails it).
…ries

the_inner_node_verifies_two_leaf_nodes checked the composition property,
that a node's published schema does not change with its level, as
inner_layout.total() == leaf_layouts[0].total(). A node publishes its last
child's output halves (emit_node_publishes), so that compare also required
the FIRST leaf's carried output to be as long as the LAST leaf's: a fact
about which epoch committed, not about the level. It held while neither
leaf's last epoch committed. At the 8-cycle epochs the gate now runs at,
the fixture commits in epoch 1, the first leaf's last, so that leaf
publishes 146 words against the inner node's 144, under either STARK wrap
attestation.

Compare the words the two proofs published instead, the inner node against
the last leaf, whose output halves it carries. The compare of two layouts
built from the same count could not fail; the published counts can.
LFM_TREE_TAIL_THREADS now also takes `per-helper:<n>`: each lead-in helper
builds its prologues in a rayon pool of n threads of its own. The default
does not change (one shared pool of 16 for the STARK tree, the global pool
for the WHIR tree), and `0` is still the global pool.

Why: each helper installs a whole prologue, with joins inside, into its
pool. A pool thread waiting in a join also takes injected jobs, so in a
pool two helpers share it can run the other helper's whole prologue nested
on its stack and finish its own only after that one; F-PREP measured that
inversion on level 0. With one pool per helper, each pool has one caller
and the inversion cannot happen.

A per-helper pool of no threads is refused (rayon would read 0 as every
core). A test checks that two helpers building at once run on two pools,
each prologue on one pool of the per-helper width. Routing every helper to
the first pool fails it.
Conflict in prover/src/zf_format.rs resolved by keeping both defaults:
one_row=auto (the STARK pipeline's measured setting) and whir_grind=query
(P2-W, which changes no STARK proof). The default banner is now
cap=auto whir_cap=auto fri=dp one_row=auto whir_folds=first6 whir_stack=27 whir_grind=query.
…elper by default

The STARK tree's default for LFM_TREE_TAIL_THREADS is now one pool of 8
threads per helper, the same 16 threads the shared pool had.
LFM_TREE_TAIL_THREADS=16 is the named way back to one shared pool, and 0
is still the global pool. The WHIR tree keeps the global pool.

A pool per helper has one caller, so a pool thread waiting inside one
prologue can no longer run the other helper's whole prologue nested and
finish its own late. FAST job 2115 (ds884-887, S P P S, two arms each)
measured the per-helper pools against the shared pool:
- wall +0.30 s, base -0.20 s, level 0 +0.70 s;
- all inside the 0.8 s noise, and program ids unchanged.
The only prologue it slows is the lead-in's last, which runs alone:
5.4-5.5 s on 8 threads against 3.9-4.1 s on the shared 16.
…by default

STARK block ABBA: -4.15 s (EFFECTIVE). LAMBDA_VM_STARK_WRAP_FOLD=1 restores the
in-guest fold and today's program ids byte for byte. Also repairs the box-tier
inner-node test (a quarter-epoch fixture tree; the compare against the leaf it
carries), whose failure was pre-existing at 8934b59.
…ad-in

Head ahead (LAMBDA_VM_BASE_HEAD_AHEAD), the device-only envelope from LDE 2^16
(LAMBDA_VM_GPU_DEVICE_ONLY_THRESHOLD) and the tree's lead-in prologues in their
own pools, 8 threads per helper (LFM_TREE_TAIL_THREADS). STARK block ABBA:
-5.05 s (confirmed); per-helper pools NO EFFECT against one shared pool.
Both RPX implementations (prover lfm::rpo::Rpo256::mds, used by the block
path's RpxStarkHash, and crypto::hash::rpx::mds) built each output lane with
a core::array::from_fn closure. The closure's generic from_fn wrapper is
placed in a codegen unit of rustc's choosing and is inlined into mds only
when that unit happens to be mds's own. When it is not, every lane is an
out-of-line call that recomputes (j - i) mod 12 with a 64-bit multiply per
term: about a fifth more instructions per permutation.

That is the two-speed host verify on the STARK tree. A Linux x86-64 cross
build of the prover test crate at 8934b59, ef6d4be and 3fd644e shows
mds inlined (2499 B, no calls) only at ef6d4be, the one FAST build, and
twelve closure calls at the other two, the SLOW builds. Crypto's mds makes
the twelve calls in its current partitioning too.

Loops over a precomputed circulant compile the same way in every build. The
permutation's values are unchanged: the RPO and RPX known-answer vectors, the
two-implementation agreement test and a new test against the circulant
definition all pass.
LAMBDA_VM_GAP_PREP_NICE now defaults to 10: the tree driver's reconstruct,
emit and harvest and every LFM prove's execute and fill run on a pool of the
calling thread's own, its threads at nice 10, so the proof holding the card
keeps the CPU. LAMBDA_VM_GAP_PREP_NICE=0 is the opt-out and restores the
schedule before the knob; 1..=19 picks another value.

Measured at bbdac70 on FAST (F-PREP job 224, ABBA, one binary): the STARK
tree took 1.55 s less (A 73.8 / 73.7 s, B 72.4 / 72.0 s). Level-0 card holds
shrank 4.15 s; the card waits 2.49 s longer for the next wrap's host work.
No wrap's reconstruct slowed past the stall guard (3.27 s against 6.26 s).

Proofs are unchanged: a host phase moves where execute and fill run, not what
they write (trace_identity_tests::execute_and_fill_on_the_host_phase_pool_are_byte_identical).
The lead-in's prologues are untouched: they run in F-SIDLE's per-helper pools
and never go through host_phase.
@MauroToscano MauroToscano changed the title STARK recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 78.80 s STARK recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 66.65 s Sep 29, 2026
@MauroToscano MauroToscano changed the title STARK recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 66.65 s STARK recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 60.95 s Sep 29, 2026
…GPU_COMPILED_CONSTRAINTS)

The STARK quotient's constraint_composition_kernel is an interpreter. For
every node of every row it:
- loads the node;
- decodes its operands;
- reads them from, and writes the result to, a per-thread slot file in
  global memory.

The slot file caps its grid at 65,536 threads, which is 0.30 waves on the
5090. G5's ncu of a base launch reads 77 % of L2, 30 % issue and 26 %
occupancy: memory- and latency-bound on the interpreter's own traffic. The
kernel takes 1.53 s of the STARK base's wall and 1.23 s of the recursion's
(G1).

This adds a straight-line twin of that kernel for every production program
of at most ~4 k nodes:
- the VM tables, the per-epoch local-to-global table and the LFM chips;
- 36 kernels in all;
- KECCAK_RND, ECDAS, ECSM and KECCAK stay interpreted.

Each node becomes the call eval_program_row makes for its op, operand kinds
and result class, over local variables, with no slot file and a grid that
fills the card. The transition sum adds the roots in root order, and the
boundary tail is the interpreter's own. Goldilocks values on the device are
non-canonical u64s, so this exact mirroring is what makes H bit-identical.

stark::constraint_ir::codegen emits the kernels, keyed by a structural hash
of the lowered program. The prover crate's tests::compiled_constraints
generates crypto/math-cuda/kernels/constraint_compiled.cu and its key table,
and fails when the committed source is stale. math-cuda loads the module on
first use. A program with no kernel, or a module that cannot load, runs the
interpreter.

LAMBDA_VM_GPU_COMPILED_CONSTRAINTS=1 turns it on. Unset or 0 keeps the
interpreter, the default. Anything else stops the run.

Evidence:
- The host build of both CUDA sources
  (crypto/math-cuda/kernels/tools/ccomp_host_check.cpp) agrees limb for limb
  on all 36 programs, over random full-range inputs.
- Three deliberate generator faults each fail every one of the 72 runs.
- The freshness test fails on a one-character edit of the generated source.
- Device tests compare the two kernels on the card and prove a program both
  ways, byte for byte.
…aces, with a mutation control

`the_compiled_kernels_prove_the_same_bytes` proved add.elf three times through
`prove_with_options`, and its control (two interpreter proofs) failed on the
card: two builds of one program's traces differ. The LT, BRANCH, MUL, DVRM, EQ
and BYTEWISE builders deduplicate through a std HashMap and lay rows out in its
iteration order. On the laptop, two builds of add.elf's traces give different
LT rows and different proof bytes.

The test now builds the traces once and proves copies of them (`FixedTraces`,
`prove_with_options`' prove step on a clone), at grinding 0:
- the control: two interpreter proofs, byte-equal;
- the compiled proof: byte-equal to them, verified, with compiled compositions
  counted;
- the mutation control: BITWISE's kernel swapped for a mutant whose last root
  is off by one. The proof must change and fail to verify, or the prover must
  refuse it, and the mutant must have run.

`one_set_of_traces_proves_the_same_bytes` runs the control alone on the path
the build proves on (ignored: two proves at blowup 4).

Supporting changes:
- `Traces` derives Clone in this crate's tests only.
- codegen: `mutant_composition_kernel` (the kernel plus one wrong statement)
  and `mutant_kernel_name`.
- The generated source carries BITWISE's mutant last. It is not in the key
  table, so no program runs it.
- gpu_interp: `substitute_compiled_kernel` and a call counter, compiled only
  under stark's `test-utils`, launch a named kernel in another's place.

The 36 production kernels and the key table are unchanged.
LAMBDA_VM_GPU_COMPILED_CONSTRAINTS unset, empty or 1 now evaluates every
composition that has a compiled kernel with it; 0 keeps the interpreter for
every program. The banner names the setting either way.

FAST job 260 (block 25368371, A B B A at 422c4b2): whole run 61.05 s with
the interpreter, 60.25 s compiled (-0.80 s; base -0.70 s), identities equal
in all four arms; the kernels run 2-3.8x faster than the interpreter per
program at 2^20 rows. The proofs are byte-identical: the device parity test
and the proof-bytes test with its mutation control passed on the card.
@MauroToscano MauroToscano changed the title STARK recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 60.95 s STARK recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 60.18 s Sep 30, 2026
MauroToscano added a commit that referenced this pull request Sep 30, 2026
…st the epoch base

A script for the counter-enabled RTX 5090 machine (run by its owner, text
back only), forked from mauro-ncu.sh. One build at the recommit-span commit
runs #1009's epoch base (its control test) and the no-epoch block proof with
packing admission: an unprofiled reference of each; Nsight Systems with GPU
metrics, per-stage tables from the prover's NVTX ranges (head, main commit,
fused, the recommit), the kernels each range launched, CUDA API seconds per
category, thread and stage, and a time series; Nsight Compute config and
window passes on the RPX leaf and Merkle, NTT, compiled constraint, DEEP and
FRI kernels for both workloads. The bundle is scrubbed, self-checked text
with a SUMMARY.md. NP_DRY=1 validates everything but ncu on a box whose
counters are closed.
MauroToscano added a commit that referenced this pull request Oct 1, 2026
…st the epoch base

A script for the counter-enabled RTX 5090 machine (run by its owner, text
back only), forked from mauro-ncu.sh. One build at the recommit-span commit
runs #1009's epoch base (its control test) and the no-epoch block proof with
packing admission: an unprofiled reference of each; Nsight Systems with GPU
metrics, per-stage tables from the prover's NVTX ranges (head, main commit,
fused, the recommit), the kernels each range launched, CUDA API seconds per
category, thread and stage, and a time series; Nsight Compute config and
window passes on the RPX leaf and Merkle, NTT, compiled constraint, DEEP and
FRI kernels for both workloads. The bundle is scrubbed, self-checked text
with a SUMMARY.md. NP_DRY=1 validates everything but ncu on a box whose
counters are closed.
MauroToscano added a commit that referenced this pull request Oct 1, 2026
…st the epoch base

A script for the counter-enabled RTX 5090 machine (run by its owner, text
back only), forked from mauro-ncu.sh. One build at the recommit-span commit
runs #1009's epoch base (its control test) and the no-epoch block proof with
packing admission: an unprofiled reference of each; Nsight Systems with GPU
metrics, per-stage tables from the prover's NVTX ranges (head, main commit,
fused, the recommit), the kernels each range launched, CUDA API seconds per
category, thread and stage, and a time series; Nsight Compute config and
window passes on the RPX leaf and Merkle, NTT, compiled constraint, DEEP and
FRI kernels for both workloads. The bundle is scrubbed, self-checked text
with a SUMMARY.md. NP_DRY=1 validates everything but ncu on a box whose
counters are closed.
MauroToscano added a commit that referenced this pull request Oct 1, 2026
…t TABLE_PARALLELISM=8

The current noepoch/stark head (kept top levels rebuilt in parallel, still
behind its knob) and i-noepoch's latest arm posture: nm-ab.sh's base env with
TABLE_PARALLELISM=8 plus packing admission for the no-epoch arm; the epoch
control stays at 4, #1009's record.
…s root check

Ported from #1013 (1b4bb4a's stark half, and a4f1cb9's choice of the
last column for the recommit fault hook), so #1009's cross-epoch global proof
can run under it.

Round 1 commits every table on the device and keeps only its root: the device
LDE, tree and trace snapshot are freed inside the admitted region, so no
table's buffers outlive its admission. At the top of each table's fused task
the trace is committed on the device again, with Retain's function and
device-only gate, and the prover refuses the proof with
RecomputedCommitmentMismatch unless the new root (and the precomputed root)
equals the absorbed one. From there the task is Retain's, so the proof is the
same bytes. Tables the device declines in Round 1 keep their host tree and
recompute on the host, as RecomputeLde does. Retain and RecomputeLde are
unchanged; the PROVE SPLIT line gains recommit[Σ] only when a recommit ran.

Tests: byte identity with Retain and the aux release on any build; on the card
(ignored), every table recommitted and a moved trace refused for each table.
The global proof has one table per epoch and one per touched page. Under
Retain every table's Round-1 LDE, tree and trace snapshot stay on the card
until its fused task, outside the VRAM gate, and from 108 epochs Round 1 ran
the card out (the BIG block ladder: a CUDA OOM in this proof's main commit at
108, 135, 142 and 143 epochs, with no host path). Under RecomputeLdeDevice
Round 1 keeps only each root, and each fused task recommits its table on the
device under the gate, its root checked equal to the absorbed one. The proof
is the same bytes.

LAMBDA_VM_GLOBAL_RESIDENCY=retain restores Retain (the A arm of the A/B, and
the way back); unset, empty or `device` is the new default; anything else
stops the run. The mode is named on stderr once per process.

Tests: the setting's parse (host). On the GPU box, from one execution's
boundaries at the block's options: two Retain global proofs equal (the
control), the RecomputeLdeDevice proof equal to them, recommitted on the
device and verifying, and the largest table's trace moved before its
recommit refused with RecomputedCommitmentMismatch. A block-base arm for the
A/B prints the global proof's sha256 and verifies the bundle.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant