Skip to content

Give the board one denominator, and name the layer nobody has measured - #192

Open
defenwycke wants to merge 11 commits into
mainfrom
docs/sixteen-card-plan
Open

Give the board one denominator, and name the layer nobody has measured#192
defenwycke wants to merge 11 commits into
mainfrom
docs/sixteen-card-plan

Conversation

@defenwycke

Copy link
Copy Markdown
Contributor

The board carries many levers quoted in units that do not compose — execute-mode cycles, one-card wall-clock, per-chunk factors, block-level bounds — so nobody can say whether any of them reaches a stated target. This adds docs/SIXTEEN_CARD_PLAN.md: the target (a near-tip block on 16 L40S), the single denominator every lever must be priced in, and the arithmetic that follows from it.

The arithmetic

All known zero-fidelity levers, multiplied, move a block from 18.2 min to 16.6 min against a 10.0 min target. Tier 0 codegen is 1.012x MEASURED; the efficiency axis is ≤1.09x (below). That is the whole remaining board, and it closes about a fifth of the gap in seconds and none of it in fidelity terms.

Two corrections, from E7's evidence rather than from argument

  • The GPU is not 65% idle. Two full po2-22 proves on hazync-l40s4 measure 91.5% and 91.7% mean utilisation, nvidia-smi sampled once per second, with the only zeros in the first 8 s — execute, before proving starts. PERF_INVESTIGATION_2026-08-26.md §4.2 asserts 65% idle and §1 of the same file asserts 65% busy; the vmstat evidence behind the former shows the host is single-threaded and says nothing about the card. E6's ceiling drops to ~1.09x, and E9 should close — it proposes a fork of a dormant upstream, carrying a CUDA deadlock we have already hit in CUDA prove-chunk hung for 76 minutes with the GPU idle and produced no output at all #147/Make proving divide: segment distribution and a balanced join tree #148, to chase 1.09x.
  • nvidia-smi reports kernel residency, not efficiency. So the above kills the idle-gap framing and says nothing about what the card is doing while it is busy. That question has never been asked here: grep -riE "nsight|ncu|nsys|occupancy" returns zero hits across the repo.

Which reframes the loudest signal on the board

Three architectures land within 9% of each other across a 4x difference in memory bandwidth (H100 0.95x, B200 0.91x). FLEET_SIZING.md reads that as "the card axis is closed", which is the right operational conclusion and the wrong diagnostic one. Performance invariant to bandwidth and to two architecture generations is not a fact about cards — it is a fact about our kernels. And kernel work moves neither METHOD_ID nor fidelity, in a risc0-sys we already vendor (#182) against an upstream §E10 establishes is dormant.

The method

A four-layer profile — guest cycles, risc0 phases, host, device — that must reconcile to the same 399 seconds. If the layers do not sum to the same wall, the profile is wrong and no lever derived from it is trustworthy. L1 is done and exhausted at 1.16%; L2, L3 and L4 have never been run. §5.1 gives the four hypotheses one ncu run separates, and each is decision-relevant: three of them open a zero-fidelity lever class, and the fourth closes the efficiency axis and settles the #139 argument by elimination.

Plus a rule that would have caught two known misses: rank levers against the profile that will exist after the others land. The packer refit — worth 2.36x, a prerequisite for #139 rather than a follow-up — was found late for exactly this reason, and the aggregate is 9.6% of cost today and 43% after #139, still unprofiled, with HAZYNC_AGG_EXECUTE=1 sitting there costing nothing to run.

Also priced here for the first time

prove_chunk takes no previous receipt — the chain dependency is add_assumption(prev) in prove_step (main.rs:396). So only the per-block fold is sequential, and blocks may be proved overlapped at a bounded lag, which overlaps consecutive serial floors and drops the bar from 1.95x to 1.69x for nothing.

Docs only. scripts/check-versions.sh passes.

The levers on this board are quoted in units that do not compose -- execute cycles,
one-card wall, per-chunk factors, block-level bounds -- so no one can say whether any
of them reaches a stated target. This adds the target (16 L40S), the denominator every
lever must be priced in, and the arithmetic that follows.

The arithmetic is uncomfortable and it is the point: all known zero-fidelity levers,
multiplied, take a block from 18.2 min to 16.6 min against a 10.0 min target.

Two corrections come out of E7's evidence rather than from argument:

- The GPU is NOT 65% idle. Two full po2-22 proves measure 91.5% and 91.7% mean
  utilisation, with the only zeros in the first 8 s (execute). E6's ceiling drops to
  ~1.09x and E9 -- a fork of a dormant upstream, carrying a CUDA deadlock we have
  already hit -- should close by measurement.
- `nvidia-smi` reports kernel residency, not efficiency, so that says nothing about
  what the card is doing while it is busy. Nobody has ever asked: `nsight`, `ncu`,
  `nsys` and `occupancy` return zero hits across the whole repo.

Which reframes the loudest signal on the board. Three architectures land within 9% of
each other across a 4x difference in memory bandwidth. That is not a fact about cards,
it is a fact about our kernels -- and kernel work moves neither `METHOD_ID` nor
fidelity, in a `risc0-sys` we already vendor against an upstream that is dormant.

So the method is a four-layer profile that must reconcile to the same 399 seconds, and
a rule that ranks levers against the profile that will exist after the others land --
the miss that made the packer refit a late discovery and leaves the aggregate, 43% of
post-#139 cost, still unprofiled.
`agg_chunks()` verifies every chunk receipt against `METHOD_ID` before it reaches the
execute-mode branch, so execute mode still needs sixteen receipts from the current
guest -- which needs a GPU. Checked rather than assumed: the only sixteen-receipt set
we hold is `b62d2a60`, superseded on 2026-08-24; the stale dist binary is `4722cec8`;
main and v0.19.0 are `1d6c3792` and no receipts exist for it. `HAZYNC_AGG_EXECUTE` has
never been run by anyone, and it moves to the card day, where the session's own chunk
proves supply exactly the receipts it has been blocked on.

Arithmetic cannot stand in for it either. The recorded `~1,137 M` aggregate cycles come
to 1,162 s of the 1,566 s total at the measured rate, but the same composition on the
pre-re-baseline `3,636.4 M` predicts ~3,500 s against a measured total above 3,300 s --
it overshoots the whole run. Aggregate segments do not prove at the chunk rate, so the
validation/resolution split stays UNKNOWN until the run happens.

What replaces it tonight is better anyway: the block's real cycle count. `14.34 G` is
MODELLED, `FLEET_SIZING.md` §4 lists that as reason #3 not to trust the card count, and
every figure in §1 and §2 divides by it.

And one risk to this document was checked and holds: the 1,565.9 s aggregate is current,
not stale. The re-baseline that made the aggregate 3.20x cheaper landed in `2d9a636`,
and `git merge-base` confirms the v0.19.0 tag contains it.
…nnot fail

`FLEET_SIZING.md` §4 lists "14.34 G is modelled" as reason #3 not to trust the card
count, and every figure in the sixteen-card plan divides by it. Measured now, on a
laptop with no GPU and no chunk receipts: **14.057 G**, so the model was +2.0% high.
Chunk work on one L40S is 14,367 s rather than 14,656, the one-card total is 15,933 s,
and the bar for sixteen cards moves 1.95x to 1.92x. The conclusion is unchanged, which
is the useful part -- the denominator now rests on a measurement.

Two things fell out of the same run.

Cost-packing is worth **1.18x on the slowest chunk** -- which is what a block's
wall-clock actually is -- for a total-cycles cost of +0.06%. That trade was argued from
a model; it is measured now, and `reproduce/METHOD_ID`'s "slightly MORE" is quantified.

And the packer's own balance metric is computed from PREDICTIONS. It prints 1.00x for a
partition that measures 1.059x, so it reports perfect balance whatever the guest does.
The per-chunk error is signed rather than noisy and it tracks payload size: the three
highest-byte chunks measure -12.3%, -12.7% and -6.2% against prediction, so the byte
term is still over-charging after #136 and #137 -- the fourth time a stale packer
coefficient has done measurable harm. Neither is a lever on the sixteen-card scale, but
#190 is adding a curve dimension to this packer and the refit is a #139 prerequisite
worth 2.36x, so these 32 measured points are what it should be fitted against.
…is per-input

Workstream B, following the observation that this project's three largest wins were
all in the plumbing and none in consensus code.

`write_chunk_inputs` coalesces consecutive inputs sharing a `tx_idx` and writes each
transaction and prevout blob ONCE per group. `input_costs` prices the same work for
EVERY input at full transaction size. Summed over block 962,000 the per-input charge
counts 53.40 MB where each transaction counted once is 1.53 MB -- 34.9x apart -- and
the measured consequence is already on record: the three highest-byte chunks come in
12.3%, 12.7% and 6.2% under prediction, and the predicted straggler reads 1.001x
against a measured 1.059x.

It would be wrong to call this a stale constant, and I nearly did. `git log -S` puts
the last change to `COST_PER_INPUT_BYTE = 6` in the same commit that introduced
grouping. The value was chosen with grouping in view.

The defect is a missing dimension. The byte term carries marshalling, which grouping
made per-GROUP, and `input_bind`'s re-hash, which is still per-INPUT, and one constant
cannot express both scalings -- the comment above it already says ~2.5 of the 6 is the
re-hash. So the fix is a second term rather than a refit, and it is the same shape of
defect as the missing type dimension #190 is adding a curve dimension for. Teach the
packer both at once.

The re-hash itself prices at ~133 M cycles, 0.95% of the block -- small, though 87% of
those bytes sit in 10% of the inputs, so it lands on the straggler rather than spreading.

One thing to stop quoting until it is re-derived: `pack_after_139.py` amortises the byte
term across a transaction's inputs while the real packer charges it in full per input.
That script is where the 2.36x packer-refit figure comes from.
…alisation

`host vb-stages` on the current guest, block 962,000, execute mode, no GPU. Of a
3,223 M-cycle run, reading the witness is 2,519 M -- 78.2%. Everything else together
is under 22%: output leaves, the in-block coin map, the entire input loop, utreexo
deletes and adds, the merkle root, wtxids.

The witness is 7,256,592 bytes, so the read costs 347 cycles per byte.

That is #136's finding sitting unfixed in the path #136 did not touch. #136 measured
`env::read` of the CHUNK payload at ~147 cycles/byte -- "50.9% of a chunk's entire cycle
count before any Bitcoin logic ran" -- and replaced it with `write_slice`/`read_slice`.
`write_aggregate_env` and `vb_stages_cmd` both still do a plain `b.write(&w)` over the
whole `BlockWitness`. Same disease, 2.4x worse per byte, and never profiled because
nothing ever profiled this path. In absolute terms it is 17.9% of the block's entire
measured chunk work, paid once per block.

It also gets worse toward the tip. Across four blocks the cost per byte rises from 49.3
to 347.2 -- about 7x. It fits `bytes^1.35` overall, but the local exponents are 1.15,
2.34 and 1.04, so no exponent should be quoted from four points on blocks that differ in
era as well as size. The direction is what matters and the direction is bad.

Two things this contradicts, both recorded rather than resolved. `vb_stages_cmd`'s own
comment says the input loop is 73% of the total; it measures 4.5%, so the comment
describes a guest that no longer exists. And `reproduce/METHOD_ID` puts the aggregate at
~1,137 M cycles, which is less than half this read alone -- yet the aggregate reads the
same witness plus sixteen journals. `HAZYNC_AGG_EXECUTE` settles that, and the sixteen
chunk proves are what unblock it, which makes them the highest-value item on the card
day: they decide whether a ~2.5 G-cycle inefficiency is real and sitting in the term
that becomes 43% of cost after #139.
A finding that reads as "78% of block validation" is not a large lever until it is
converted into factor on divisible card-seconds, which is what §1 exists to force. The
aggregate is 9.8% of the one-card total, so removing 78% of its validation half is worth
1.06x today -- robust across the 300-500 s uncertainty in what assumption resolution
costs -- and 1.30-1.37x once #139 makes the aggregate 43% of the total.

So it does not close the 1.92x gap and it changes nothing about §2's conclusion. It is
still the best-value item on the board by cost to fix: the technique is already written
and shipped in #136, it costs no fidelity, and because the host->guest environment
encoding is not guest source it may not move `METHOD_ID` at all -- which no other lever
of this size can claim, and which should be checked before anything else about it.

It is also worth more later than now, which is the re-rank-forward rule working. A board
ranked on today's profile would have put this eighth.
An earlier draft of that section reasoned that the host->guest environment encoding is
not guest source, and so might land without a re-baseline. That is wrong. The guest
reads it with `let w: BlockWitness = env::read()` at guest main.rs:1186 and :1291, which
makes it guest source like any other guest edit.

The lever's value is unchanged. Where it sits in the order is not: it batches with Tier
0's codegen and #139 rather than landing on its own, exactly as ACCELERATION.md's
"METHOD_ID constraint" section requires.
@defenwycke
defenwycke force-pushed the docs/sixteen-card-plan branch from b4d0dfd to 3d71767 Compare August 27, 2026 00:44
`HAZYNC_WITNESS_SIZES` reports four categories that together account for 34.5% of block
962,000's witness. Measuring the residual across four blocks shows it scales per input at
roughly 600-870 bytes, so the `inputs` vector is about 65% of the witness while the
transaction bytes everyone would assume dominate are 21%.

`BlockInput` is seven scalars plus two `WireProof`s, each carrying a 32-byte leaf, a u64
position and a siblings vector, and sibling counts here are about one per proof -- so the
bulk is struct encoding, consistent with hashes and scalars occupying ~4x their raw size
in risc0's word stream.

This redirects the fix. The obvious reading of the previous section is "pack the
transaction bytes", and those are already only a fifth of it. Anyone acting on it should
instrument `to_vec` per sub-structure first, because the numbers above are a residual
rather than a direct measurement.

One more stale comment while here: guest main.rs:898 says the proofs clone ~28x32 B of
siblings per input. Measured, it is ~66 B. Do not size anything from it.
§4 called the CUDA layer the loudest unexplained signal on the board and §4.1 noted it is
exempt from `METHOD_ID` and fidelity. §8 is that layer, measured on an L40S.

**The kernels are nowhere near roofline.** `eval_check` (49.5% of GPU time) runs at 15.2%
of peak and `par_stepExec` (19.8%) at 3.7%, both at **255 registers/thread** — one block
per SM, ~16% occupancy. H4 holds only for `_poseidon2_rows`, which is 8.1%.

**They already spill**: 76-81% of all memory traffic on both is register spill (3.26:1 and
4.17:1 against real data). That invalidates the obvious reading of `eval_check`'s 58.7% of
DRAM peak — most of that traffic should not exist, so the ceiling is HIGHER than the
1.70x a roofline reading implies, not lower. The power rail agrees independently: 147 W of
350 W during chunk proving against 276 W during the aggregate, no throttling, same card.
And `_poseidon2_rows` is the control that settles blame — same BabyBear arithmetic, same
card, **78.3% of peak** at 60 registers. The field arithmetic is not the constraint.

**The `>3,300 s` aggregate is stale.** Measured: 117.3 s at N=4, and **1,574.9 s** for the
N=16 tip aggregate, GPU never idle. The string predates #148/#153/#157/#161 and is still
hardcoded in `report_agg_execute_only`, so every execute run reprints a number measured
before its own fix. ⇒ The measurement confirms `FLEET_SIZING.md`'s 1,565.9 s to **0.6%**,
so the correction lands on the session notes, not on this plan — §1's 16.6 min stands, and
§8.5 now explains where it comes from: the **chunk side** binds, at 980 s for the slowest
of sixteen (measured straggler 1.05x against a predicted 1.00x).

⛔ Also downgrades #139 on the chunk side to ~1.1x: bytes do not drive chunk cost (86x the
bytes, 4% LESS time) and neither do inputs (3.6x the inputs, 4% more time). Chunks cost EC
verifies — Core's libsecp256k1 — which fidelity rightly forbids touching.

⚠ Three things are NOT established and are marked as such in §8.8: whether resolution
scales with N (two points, and the block moved between them — a power law through two
points fits by construction), whether the distributed aggregate works (claimed by
#153/#157/#161, unexercised), and whether the spill is removable at all.
§8.9's first item, run on an L40S: every arm is slower, monotonically. Capping to 64
registers raised occupancy **3.6x** — exactly the mechanism the item hoped for — and
**halved throughput**, for +32.3% wall-clock. 128 and 96 have identical occupancy, so 96
bought no warps and merely added 21% more spill.

⇒ Occupancy is not the constraint; spill traffic is. The cap delivers the warps it promises
and loses anyway, because it buys them by manufacturing more of the bottleneck. And no flag
can fix this: **255 regs/thread is the architectural maximum**, so stock is already at the
ceiling and the compiler is spilling by force.

The sweep still pays for itself, because it yields a model. Fitting the three capped arms
gives **wall ∝ spill^0.21**, so cutting spill 4x buys 1.34x and 32x buys ~2.08x.
`_poseidon2_rows` corroborates from the opposite direction: 5.1x the throughput at ~no
spill, which the same fit places at a ~32x spill cut.

⇒ The relationship is SUBLINEAR, which re-ranks the remaining work. Halving spill buys 16%.
Only splitting `eval_check` into passes (item 3) has the shape to nearly eliminate it, and
its traffic budget is favourable: 7.16 G global against 23.36 G spill today, so 4 passes is
roughly flat on total sectors while converting scattered local into coalesced global.

Control: vendored `stock` arm reproduced the pre-vendoring binary to 0.2% on wall-clock and
to the sector on spill counters; `METHOD_ID` identical on all four arms.
…arrangement

Five arms on the same segment, median of 9, `METHOD_ID` identical throughout. Every one is
slower than stock:

    stock        255 regs  1 blk/SM  15.21%  23.36 G local  4,115 ms
    __noinline__ 255       1         14.85%  23.85          4,154 ms  +0.9%
    -dlto        255       1         22.24%  45.72          4,260 ms  +3.5%
    -maxrreg 128 128       2         11.74%  40.89          4,552 ms  +10.6%
    -maxrreg 64   64       4          7.78%  67.13          5,446 ms  +32.3%

`-dlto` settles it. LTO genuinely inlined across the translation units — throughput rose
**46%**, which nothing else explains — and local traffic still **doubled**. It removed
`poly_fp`'s 6,952-byte ABI frame and the twenty per-call frames, and the union live set then
overflowed 255 registers by more than the frames had cost.

⇒ The live set does not fit under ANY arrangement. Registers pin at 255 — the architectural
maximum — in all five arms. The compiler offers exactly three degrees of freedom here: call
it (ABI frames), inline it (register spill), cap it (more register spill). All three are now
measured, and nvcc's default is the cheapest.

⇒ The 1.3-2.1x kernel lever is not reachable by any means available to this project. Getting
it needs the constraint polynomial restructured in RISC0's circuit generator — upstream, in
a repo dormant on two filed issues. The board returns to fan-out (§8.6: chunk cost tracks EC
verifies, which divide by N), which makes §8.8's resolution-scaling question the highest
-value open item.

⚠ The `unity` arm was staged and deliberately NOT run — it reaches the same mechanism as
`-dlto` via the compiler rather than the linker, and would have re-measured a settled
question.
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