Skip to content

perf(gpu): transpose the fused commit's LDE in place, one LDE-sized buffer live - #956

Merged
MauroToscano merged 1 commit into
per-table-gpufrom
pt/one-lde-buffer
Sep 7, 2026
Merged

perf(gpu): transpose the fused commit's LDE in place, one LDE-sized buffer live#956
MauroToscano merged 1 commit into
per-table-gpufrom
pt/one-lde-buffer

Conversation

@MauroToscano

Copy link
Copy Markdown
Contributor

Summary

The fused row-major R1 commit allocated the row-major LDE for all columns (lde.rs expand_row_major_on_stream, alloc_zeros::<u64>(lde_size * total_cols)) and then, while it was still live, a SECOND full-size column-major buffer for the transpose (launch_row_to_col_major), from both coset_lde_row_major_inner and coset_lde_row_major_split_trees. Peak ≈ 2·LDE + trace snapshot + tree: 24.9 GiB for a 2^21×316 table at blowup 2 by the memory model, 44.7 GiB at blowup 4 and 34.2 GiB for 436 columns — neither of which a 32 GiB card can hold.

The LDE is now transposed inside its own allocation, over the same matrix_transpose_strided kernel:

  1. Block pass — the rows × cols matrix is blocks row blocks of rows_per_block rows; each block is transposed into cols runs (one column, rows_per_block consecutive rows), ping-ponging through ONE spare block of scratch (block 0 → scratch, block b → slot b−1, scratch → last slot).
  2. Run pass — the runs are ordered (slot, column); column-major wants (column, block). That permutation of whole runs is followed cycle by cycle in place, with the scratch runs as parking space, and issued as cuMemcpyBatchAsync batches (about 2·blocks + 3·cycles driver calls — 136 at 2^22×316).

Scratch = (cols + 1) · rows_per_block · 8 bytes, capped at 256 MiB (2^16 rows per block for 316 or 436 columns), instead of a second LDE. The trace-snapshot transpose (n×C into its own small buffer) is unchanged. LAMBDA_VM_LDE_TRANSPOSE_UNBATCHED=1 issues the run pass as single D2D copies — a measurement/diagnostic knob, same bytes.

Only crypto/math-cuda/src/lde.rs changes (plus the new test file); no kernel, dispatch, caller or match hash line moves.

Why the bytes cannot change

Nothing computes: both passes only move runs of already-computed values, under exactly the old kernel's map dst[c·L + r] = src[r·C + c] (block b, row r′, column c lands at c·L + b·R + r′). Leaves are hashed before the transpose; the row-major host D2H (retain_host_lde) is enqueued on the same stream ahead of it, so the host copy sees row-major bytes; the ready event is recorded after, as before. Same kernels, same values, same roots, same proof bytes — and the measurement below confirms the root byte for byte, old vs new.

Tests

  • lde.rs unit tests (cargo test -p math-cuda --lib inplace_transpose): geometry invariants over 2^1..2^27 rows × {1..65535} cols; a host model of the run pass asserting every batch is independent (distinct destinations, no destination aliasing a source) and that every run lands at its column-major position, for 15 (blocks, cols) shapes incl. 64×316, 64×436, 128×612.
  • New tests/one_lde_buffer.rs: for 12 shapes × {keccak, blake3}, the handle's column-major device bytes equal (raw u64) the row-major host copy the same call returns — base, ext3 and split-tree entry points, plus the trace snapshot. vram_arm (#[ignore]) is the production-shape VRAM/time arm for the sampler (env LAMBDA_VM_VRAM_ARM_{LOG_N,COLS,BLOWUP,ITERS,HASH,PREDEV}).

Pre-registered predictions (written before any measurement)

Model = LDE + snapshot + tree + scratch + twiddles/weights; GiB = 2^30 bytes. The sampler adds the CUDA context (0.51 GiB on the box) to old AND new.

shape (LFM_HASH @ 2^21 rows) today (model) new (model) Δ brief target measured new (in-process peak, incl. context)
316 cols, blowup 2 24.88 15.16 −9.72 ≤ 15.5 15.67 (= 15.16 + 0.51) ✓
316 cols, blowup 4 44.78 25.19 −19.60 ≤ 25.5 25.67 (= 25.16 + 0.51) ✓
436 cols (RPO), blowup 2 34.25 20.84 −13.41 ≤ 21 21.36 (= 20.85 + 0.51) ✓

Time, pre-registered separately: transpose stage +30 ms (b2) / +60 (b4) / +42 (436) batched; +55 / +115 / +80 ms unbatched; commit-level ≤ +5% with host input.

Gate (box A, at d5ba828, verbatim)

  • cargo test -p math-cuda --release --lib inplace_transpose: test result: ok. 2 passed; 0 failed ... finished in 0.19s
  • cargo test -p math-cuda --release --test one_lde_buffer: ok. 3 passed; 0 failed; 1 ignored ... 0.93s
  • blake3_fused_parity: ok. 3 passed · merkle_root_parity: ok. 3 passed · comp_poly_tree: ok. 4 passed
  • make test-cuda-integration: ok. 7 passed ... 13.50s · make test-cuda-d1: ok. 1 passed ... 2.57s
  • make lint: exit 0

VRAM / time arm (vram_arm, host input, 5 iterations, LAMBDA_VM_MEMPOOL_RELEASE_MB=0, 10 Hz nvidia-smi sampler)

arm ms per iteration root in-process peak csv max
NEW 316 b2 1187.2 / 1066.0 / 1064.8 / 1065.1 / 1066.4 c9cb18a4…7a75b096 15.67 GiB 16048 MiB
NEW 316 b4 1678.6 / 1522.4 / 1522.5 / 1523.0 / 1521.9 0c2148ef…1df2a40 25.67 GiB 26288 MiB
NEW 436 b2 1590.4 / 1469.0 / 1467.9 / 1468.7 / 1469.0 9daf865a…fdfd3a06 21.36 GiB 21872 MiB
NEW 316 b2, LAMBDA_VM_LDE_TRANSPOSE_UNBATCHED=1 1218.9 / 1098.1 / 1096.7 / 1099.1 / 1095.7 c9cb18a4… (same) 15.67 GiB
OLD (64e1610 + this test file) 316 b2 1086.6 / 941.7 / 942.4 / 941.7 / 943.7 c9cb18a4… (same) 25.39 GiB 26000 MiB
OLD 316 b4 panic: allocation failed (two-buffer 44.69 GiB > 31.4 available)
OLD 436 b2 panic: allocation failed (34.19 GiB > 31.4 available)

VRAM: every prediction met within the context term; peak −9.72 GiB at 316/b2 (25.39 → 15.67), and the two shapes the old code cannot commit at all now run.

Time: the miss, stated plainly

Commit-level time at 316/b2 is +123 ms per iteration (+13%, 1065 vs 942 ms), above the pre-registered ±5%. Batched is ~30 ms faster than unbatched, so the run pass is bound by per-copy cost (~20k run copies of 512 KiB ≈ 5 µs each), not by bandwidth; the block pass is bandwidth-bound as predicted. b4 and 436 have no old comparison (the old code cannot allocate them).

Block-level projection (? INFERRED from the campaign's cell counts, not measured on a block): +123 ms per 10.6 GB of committed LDE ≈ +11.6 ms per GB for wide tables. Aggregation layer: 6 proofs per block × ≈19 GiB of LDE (LFM_HASH 9.9 + the ten other chips ≈ 8.9) ≈ +1.3 s. Base layer: 5 epochs × ≈64 GiB (≈8B cells/epoch at blowup 2) ≈ +3.7 s. Block ≈ +5 s ≈ +0.2% of the 42.6-minute record. Narrower tables pay less per byte (longer runs, fewer copies).

Lever if it matters: the copy count is rows · cols / rows_per_block. Doubling the run to 2^17 rows halves it (≈ −50 ms at 316/b2) for +0.16 GiB (316 cols) / +0.21 GiB (436 cols) of scratch, which would put the RPO shape 0.05 GiB over its ≤ 21 GiB model target. The real fix is a one-launch run-permutation gather kernel in place of the per-copy engine ops, which makes the run pass bandwidth-bound (≈ 15–20 ms) with no extra scratch — a kernel-file change outside this PR.

Notes

  • math-cuda has no cuda cargo feature; its tests are cargo test -p math-cuda --release (Makefile test-math-cuda).
  • Lane D: no match hash line touched. Lane K phase 2: the row-major leaf kernels stay the production layout.
  • The same change is prepared for main on pt/one-lde-buffer-main (hash-independent win for keccak and BLAKE3 today).

…uffer live

The fused row-major R1 commit allocated the row-major LDE for all columns
and then, while it was still live, a second full-size column-major buffer
for the transpose: peak ~ 2*LDE + trace + tree (24.8 GiB for a 2^21 x 316
table at blowup 2 by the memory model; 44.7 GiB at blowup 4, which no
32 GiB card holds).

The transpose now happens inside the one allocation, over the same tiled
kernel: a block pass transposes each row block through one spare block of
scratch, then the resulting column runs are permuted into column-major
order cycle by cycle, issued as batched device copies. Scratch is one block
plus one run, capped at 256 MiB, instead of a second LDE.

Bytes are unchanged: the passes only move runs of already-computed values,
the row-major host D2H is queued ahead of them on the same stream, the
trace snapshot transpose is untouched, and no kernel changes. New tests pin
the handle's column-major bytes against the row-major host copy the same
call returns (base, ext3, split trees), plus an ignored production-shape
VRAM/time arm for the sampler. LAMBDA_VM_LDE_TRANSPOSE_UNBATCHED=1 issues
the run pass as single copies for measurement.
@MauroToscano
MauroToscano merged commit fc9f1e1 into per-table-gpu Sep 7, 2026
8 checks passed
@MauroToscano
MauroToscano deleted the pt/one-lde-buffer branch September 7, 2026 19:11
MauroToscano added a commit that referenced this pull request Sep 7, 2026
… on device aborts, no resident-aux host downgrade

Prover side of the width-aware admission (#961).

crypto/stark/src/device_set.rs (new, cfg-free)
- The device-set arithmetic moves out of `gpu_lde` (cuda-only) so the
  scheduler's throttle, which runs on every build, reads the model the
  dispatch layer admits against: `commit_device_set` (one LDE + snapshot +
  tree + scratch), the new `table_device_set` for rounds 2-4 (aux LDE and
  resident aux trace, H and the parts, the R3/R4 inverted denominators, DEEP
  and the FRI chain, each with its tree), and the pure `admit_bytes`.
  `gpu_lde` re-exports them. Tests pin LFM_HASH under RPO at 2^21 x 449
  (21.4 GiB R1, 23.1 GiB whole table: fits alone, where the retired model
  said 28.3 GiB) and a 2^22 BALU chunk at 4.9 GiB (five concurrent).

crypto/stark/src/prover.rs
- `estimate_table_vram_bytes` (two LDE buffers + 256 B per LDE row, stale
  since #956) is replaced: R1 admits on the commit set, the fused rounds on
  the table set, both read off the AIR and the domain.
- `commit_main_trace` and the aux commit sites pass `air.name()` into the
  R1 dispatch entry points, so a device abort names its table at the site;
  `run_admitted` prefixes any string panic payload from a table task with
  `table <name>: ` before re-raising it.
- The resident-aux host downgrade is trimmed: after the drain-and-retry
  declines, the aux commit returns `ProvingError::DevicePath` with the
  table, the shape and the live device posture. Host RAM is a cache, not a
  compute path. `materialize_aux_trace_host` is deleted;
  `GPU_RESIDENT_AUX_DOWNGRADES` is retired at zero (accessor kept for the
  integration assertion).

prover/src/lfm/chunking.rs
- The BALU sizing doc and its test read `stark::device_set::table_device_set`
  instead of restating the arithmetic: a 2^27 table is 150 GiB, a 2^22
  chunk 4.9 GiB, a 2^24 chunk 19 GiB; LFM_LANES at 2^24 is 23 GiB.
MauroToscano added a commit that referenced this pull request Sep 8, 2026
… on device aborts, no resident-aux host downgrade

Prover side of the width-aware admission (#961).

crypto/stark/src/device_set.rs (new, cfg-free)
- The device-set arithmetic moves out of `gpu_lde` (cuda-only) so the
  scheduler's throttle, which runs on every build, reads the model the
  dispatch layer admits against: `commit_device_set` (one LDE + snapshot +
  tree + scratch), the new `table_device_set` for rounds 2-4 (aux LDE and
  resident aux trace, H and the parts, the R3/R4 inverted denominators, DEEP
  and the FRI chain, each with its tree), and the pure `admit_bytes`.
  `gpu_lde` re-exports them. Tests pin LFM_HASH under RPO at 2^21 x 449
  (21.4 GiB R1, 23.1 GiB whole table: fits alone, where the retired model
  said 28.3 GiB) and a 2^22 BALU chunk at 4.9 GiB (five concurrent).

crypto/stark/src/prover.rs
- `estimate_table_vram_bytes` (two LDE buffers + 256 B per LDE row, stale
  since #956) is replaced: R1 admits on the commit set, the fused rounds on
  the table set, both read off the AIR and the domain.
- `commit_main_trace` and the aux commit sites pass `air.name()` into the
  R1 dispatch entry points, so a device abort names its table at the site;
  `run_admitted` prefixes any string panic payload from a table task with
  `table <name>: ` before re-raising it.
- The resident-aux host downgrade is trimmed: after the drain-and-retry
  declines, the aux commit returns `ProvingError::DevicePath` with the
  table, the shape and the live device posture. Host RAM is a cache, not a
  compute path. `materialize_aux_trace_host` is deleted;
  `GPU_RESIDENT_AUX_DOWNGRADES` is retired at zero (accessor kept for the
  integration assertion).

prover/src/lfm/chunking.rs
- The BALU sizing doc and its test read `stark::device_set::table_device_set`
  instead of restating the arithmetic: a 2^27 table is 150 GiB, a 2^22
  chunk 4.9 GiB, a 2^24 chunk 19 GiB; LFM_LANES at 2^24 is 23 GiB.
MauroToscano added a commit that referenced this pull request Sep 8, 2026
…del gates, it does not schedule

The device-set model this branch introduces is a size model: it says what a
stage puts on the card, so the gate can decide whether it fits. It is not a
schedule, and sorting the table walk by it cost 5.9 GiB of host peak.

The walk and the gate are now two different functions, and only the gate reads
the model. The walk goes back to exactly the key it used before this branch:
`2·lde·(8·main + 24·aux) + 256·lde`, aux width zero for the R1 main-commit walk
and the AIR's aux width for the fused rounds walk, two separate walks as there
have always been. The arithmetic is restored verbatim, so the order is
byte-for-byte the one that measured well; the function is renamed
`table_walk_weight` and its constants renamed with it, because the numbers are
kept for the order they produce and are no longer a claim about bytes (the
factor of two assumed the second LDE buffer #956's in-place transpose removed).

Measured on the q=20 wrap (2^22, blowup 4, RTX 5090, TABLE_PARALLELISM=1),
`/usr/bin/time -v` max RSS, prove time and proof bytes identical throughout:

  this weight, before the device-set model    47,307,284 kB = 45.1 GiB
  this weight, under the device-set gate      47,365,192 kB = 45.2 GiB
  the device-set model's own order            53,472,980 kB = 51.0 GiB
  a fused-phase host-transient order          53,453,344 kB = 51.0 GiB

The last row is why this is a restoration and not a re-derivation: an order
justified by a truer quantity is still the wrong order. At q=41 the effect is
absent, so the mechanism — why reordering only the small tables moves host peak
when one table is resident at a time — is open and tracked separately.

The prover prints the walk each phase took, one line per phase, so a run that
moves host peak can say which order it ran.
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