Skip to content

perf(gpu): grind the proof-of-work nonce on the GPU - #936

Open
ColoCarletti wants to merge 1 commit into
mainfrom
feat/gpu-grinding
Open

perf(gpu): grind the proof-of-work nonce on the GPU#936
ColoCarletti wants to merge 1 commit into
mainfrom
feat/gpu-grinding

Conversation

@ColoCarletti

@ColoCarletti ColoCarletti commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Grinding (generate_nonce) runs a ~2^grinding_factor parallel Keccak search per table per epoch and is the prover's dominant CPU cost — 64.7% of on-CPU time in a 100tx flamegraph, on the 16 cores while the GPU sits ~66% idle.

Add a keccak nonce-search kernel (each thread strides a nonce block, atomicMin keeps the smallest valid nonce), a math-cuda wrapper that searches in expanding blocks from 0, and a stark dispatch that computes the inner hash on the host and falls back to the CPU search on any device miss. Result-valid: the verifier only checks is_valid_nonce, so any valid nonce works. LAMBDA_VM_NO_GPU_GRIND forces the CPU path; below a minimum grinding factor the GPU launch is skipped (tiny factors are faster on the CPU), and GPU_GRIND_CALLS counts the dispatches.

100tx e20 (ABBA, same binary): 18.89s -> 13.10s = -30.6%.

@ColoCarletti

Copy link
Copy Markdown
Collaborator Author

/bench-gpu

@ColoCarletti

Copy link
Copy Markdown
Collaborator Author

/ai-review

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown

GPU Benchmark (ABBA) — 5a895bc6e0 vs main (14 pairs)

RTX 5090 · Vast.ai datacenter @ $0.5369444444444444/hr · prover/cuda · ethrex real block, continuations · drift-free A/B/B/A

❌ Run failed. Last log lines:


@github-actions

Copy link
Copy Markdown

Codex Code Review

  • Medium — Invalid GPU nonces are accepted in release builds. grinding.rs validates the handwritten CUDA result only with debug_assert!. Any kernel defect or device computation error therefore produces an invalid proof instead of triggering CPU fallback. Perform is_valid_nonce unconditionally and fall back when it fails.

Comment thread crypto/stark/src/grinding.rs Outdated
Comment thread crypto/math-cuda/src/grinding.rs Outdated
Comment on lines +36 to +39
let expected = 1u64.checked_shl(grinding_factor as u32).unwrap_or(u64::MAX);
let count = expected
.saturating_mul(8)
.clamp(1 << 18, 1 << 28);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low (perf) — no minimum-factor gate, so small factors pay a launch to replace ~2 hashes.

ProofOptions::default_test_options() and MIN_PROOF_OPTIONS both use grinding_factor: 1, i.e. limit = 1 << 63, where the CPU finds a valid nonce at nonce 0 or 1 in ~100 ns. On that path this function still does 2 H2D allocs + a 2^18-thread launch + D2H + stream.synchronize() on a shared pool stream — tens of µs, per table per epoch, and the sync blocks whatever else a rayon peer had queued on that stream. Pure loss, and it's the configuration every non-GPU-benchmark test uses.

The repo already gates other dispatches this way (GPU_LOGUP_MIN_ROWS, LAMBDA_VM_GPU_LDE_THRESHOLD). Suggest an early if grinding_factor < GRIND_MIN_FACTOR { return None; } (something like 12–16, where the CPU search is still sub-millisecond) alongside the existing range check at line 23.

Comment thread crypto/math-cuda/src/grinding.rs Outdated
Comment thread crypto/math-cuda/kernels/keccak.cu
Comment thread crypto/stark/src/grinding.rs Outdated
@claude

claude Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Review: GPU proof-of-work grinding

I verified the kernel derivation against the host predicate and it checks out: st[0..3] = from_le_bytes of the inner hash matches Keccak's LE lane read; st[4] = bswap64(nonce) is exactly the LE read of nonce.to_be_bytes(); padding at st[5] ^= 0x01 / st[16] ^= 0x80 << 56 is right for a 40-byte message (rate_pos 40 → lane 5, no intermediate permute); bswap64(st[0]) equals from_be_bytes(digest[..8]). The atomicMin + early-break logic does return the smallest valid nonce in the block — break only skips nonces that are already >= the running minimum — and since blocks are scanned from 0 in order, the first hit is the global minimum. Search-from-0-in-blocks with a CPU fallback is a clean design, and the clone_dtohsynchronize ordering matches the rest of the crate.

Findings, none blocking correctness of the happy path:

Medium

  • crypto/stark/src/grinding.rs:116 — the debug_assert! is the only validation of the GPU nonce, so in release (i.e. every real prover) an incorrect kernel result becomes an unverifiable proof rather than a CPU fallback. Two hashes against a 2^20-hash search; make it a real check.

Low

  • crypto/math-cuda/src/grinding.rs:36 — no minimum-factor gate. At grinding_factor: 1 (default_test_options, MIN_PROOF_OPTIONS) the GPU path spends a launch + synchronize() on a shared pool stream to replace ~2 CPU hashes.
  • crypto/math-cuda/kernels/keccak.cu:172 — non-volatile read of the atomic; the compiler may hoist it or L1 may serve it stale, silently disabling the early exit.
  • crypto/math-cuda/src/grinding.rs:49 — rvalue temporary as an async H2D source (the only such call in the crate), plus a device realloc per loop iteration.
  • crypto/stark/src/grinding.rs:128 — the new test is #[ignore]d and no Makefile target runs -p stark --ignored, so it never executes. crypto/math-cuda/tests/ is the convention that the merge-queue GPU job actually runs.

Nits

  • Kill-switch naming diverges from the established convention (LAMBDA_VM_DISABLE_GPU_COMPOSITION=1, LAMBDA_VM_DISABLE_DEVICE_ONLY=1, LAMBDA_VM_NO_GPU_LOGUP). LAMBDA_VM_NO_GPU_GRIND (presence-based, like the logup one) would fit; =0 semantics is a new dialect.
  • No GPU_*_CALLS dispatch counter. Every other GPU path has one and reset_all_gpu_call_counters / cuda_path_integration assert on them; without one there is no way to tell a firing GPU grind from a permanently silent fallback (all errors are swallowed by .ok()? with no log).
  • base + i in the kernel can wrap past u64::MAX in the final block, re-searching from 0 instead of stopping. Unreachable in practice (~2^36 launches) — mentioning only because the checked_add on line 68 suggests the intent was to bound the range.

@github-actions

Copy link
Copy Markdown

AI Review

PR #936 · 6 changed files

Findings

Status Sev Location Finding Found by
confirmed medium crypto/stark/src/grinding.rs:116 GPU nonce validity only checked in debug builds kimi
openrouter/moonshotai/kimi-k2.7-code
glm
openrouter/z-ai/glm-5.2
confirmed low crypto/math-cuda/kernels/keccak.cu:172 Non-atomic read of atomic result for early exit optimization nemotron
openrouter/nvidia/nemotron-3-ultra-550b-a55b
confirmed low crypto/math-cuda/src/grinding.rs:49 Repeated device allocation in generate_nonce_gpu loop nemotron
openrouter/nvidia/nemotron-3-ultra-550b-a55b

Status column reflects the verdict from the verifier: deepseek-verifier (openrouter/deepseek/deepseek-v4-pro).

AI-001: GPU nonce validity only checked in debug builds
  • Status: confirmed
  • Severity: medium
  • Location: crypto/stark/src/grinding.rs:116
  • Found by: kimi:openrouter/moonshotai/kimi-k2.7-code, glm:openrouter/z-ai/glm-5.2
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

In release builds, an invalid nonce returned by the GPU kernel would be accepted and used in the proof, producing an invalid proof. The GPU result is only validated by a debug_assert!, which is stripped in release.

Evidence

crypto/stark/src/grinding.rs lines 115-120: after generate_nonce_gpu returns a nonce, the code runs debug_assert!(is_valid_nonce(seed, nonce, grinding_factor), ...). debug_assert! is compiled out in release, so any GPU-side bug (kernel regression, driver/hardware issue) would silently yield an invalid nonce. The check costs only ~2 Keccak hashes versus the ~2^grinding_factor hashes already performed, so there is no performance justification for disabling it in release.

Suggested fix

Either make the check a real runtime if !is_valid_nonce(seed, nonce, grinding_factor) { /* fall through to CPU */ } (the cost is a single host Keccak hash, trivial vs. the GPU grind), or assert! so a kernel regression surfaces loudly instead of corrupting the transcript.

AI-003: Non-atomic read of atomic result for early exit optimization
  • Status: confirmed
  • Severity: low
  • Location: crypto/math-cuda/kernels/keccak.cu:172
  • Found by: nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

The early-exit check if (nonce &gt;= (uint64_t)*result) break; reads the atomic result variable without atomic operations. While the comment notes correctness doesn't depend on it, this creates a benign data race (TSan would flag it) and may cause threads to do slightly more work than necessary.

Evidence

Line 172 reads *result directly. The variable is written via atomicMin on line 187. This is a classic data race pattern, though harmless for this optimization.

Suggested fix

Use atomicLoad or __ldg to read the value, or simply remove the optimization since the grid-stride loop already bounds work. If kept, annotate with // NOLINT or similar to suppress race detectors.

AI-005: Repeated device allocation in generate_nonce_gpu loop
  • Status: confirmed
  • Severity: low
  • Location: crypto/math-cuda/src/grinding.rs:49
  • Found by: nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

A new result_dev buffer is allocated via clone_htod(&[u64::MAX]) on every loop iteration, causing unnecessary allocation overhead when multiple grid launches are needed (e.g., for high grinding factors).

Evidence

Line 49: let mut result_dev = stream.clone_htod(&[u64::MAX]).ok()?; inside the loop at line 48. The buffer is only used to receive the atomicMin result and could be allocated once before the loop and reused.

Suggested fix

Move the result_dev allocation before the loop and reuse it by resetting to u64::MAX each iteration (e.g., via stream.memset or a tiny kernel).

Reviewer Lanes

Lane Model Prompt Status Findings
glm openrouter/z-ai/glm-5.2 general success 1
kimi openrouter/moonshotai/kimi-k2.7-code general success 2
minimax minimax/MiniMax-M3 general error: opencode failed (provider/auth/runtime error) and no findings were submitted 0
moonmath zro/minimax-m3 general error: opencode failed (provider/auth/runtime error) and no findings were submitted 0
nemotron openrouter/nvidia/nemotron-3-ultra-550b-a55b general success 3

Verification Lanes

Lane Model Status Confirmed Rejected Uncertain
deepseek-verifier openrouter/deepseek/deepseek-v4-pro success 3 2 0

Native Codex and Claude reviews run separately and post their own comments. They are not included in this structured provenance report.

Discarded candidates (2) — rejected by the verifier
  • Misleading/out-of-bounds comment in grind_search message layout (crypto/math-cuda/kernels/keccak.cu:144, found by kimi:openrouter/moonshotai/kimi-k2.7-code) — The comment at keccak.cu lines 144-145 uses 'inner_hash[32] || nonce.to_be_bytes()[8]' as informal size notation (a 32-byte array concatenated with an 8-byte array), not as C array indexing. This is pseudo-code in a doc comment, not executable code, and the actual kernel below it (st[0..3]=h0..h3, st[4]=bswap64(nonce)) correctly implements the 40-byte message. Not a bug; purely pedantic.
  • atomicMin on 64-bit integer requires compute capability 6.0+ (crypto/math-cuda/kernels/keccak.cu:187, found by nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b) — The underlying CUDA fact (64-bit atomicMin on unsigned long long requires compute capability 6.0+) is correct, but the finding is speculative as applied here. build.rs (lines 17-60, 115-120) AOT-compiles the cubin only for the host's auto-detected real arch via nvidia-smi (or a CUDARC_NVCC_ARCH override), with documented examples sm_86 (RTX 3090) and sm_120 (RTX 5090) — all well above CC 6.0. There is no evidence the project targets pre-Pascal GPUs, and for a pre-6.0 arch nvcc would reject the code at compile time rather than 'fail at runtime' as claimed.

Raw lane outputs, candidates, final issues, and model metrics are uploaded as workflow artifacts.

@ColoCarletti
ColoCarletti force-pushed the feat/gpu-grinding branch 2 times, most recently from 7ee4d16 to 0b3ab88 Compare August 18, 2026 19:17
@MauroToscano

Copy link
Copy Markdown
Contributor

/bench-gpu

Grinding (generate_nonce) runs a ~2^grinding_factor parallel Keccak search
per table per epoch and is the prover's dominant CPU cost — 64.7% of on-CPU
time in a 100tx flamegraph, on the 16 cores while the GPU sits ~66% idle.

Add a keccak nonce-search kernel (each thread strides a nonce block, atomicMin
keeps the smallest valid nonce), a math-cuda wrapper that searches in expanding
blocks from 0, and a stark dispatch that computes the inner hash on the host,
validates the device result unconditionally, and falls back to the CPU search
on any device miss or invalid nonce. Result-valid: the verifier only checks
is_valid_nonce, so any valid nonce works. A device launch is skipped below a
minimum grinding factor (tiny factors are faster on the CPU), and
LAMBDA_VM_NO_GPU_GRIND forces the CPU path. GPU_GRIND_CALLS counts the
dispatches so a silent fallback is caught by the integration test.

100tx e20 (ABBA, same binary): 18.89s -> 13.10s = -30.6%.
@ColoCarletti

Copy link
Copy Markdown
Collaborator Author

/bench-gpu

1 similar comment
@ColoCarletti

Copy link
Copy Markdown
Collaborator Author

/bench-gpu

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.

2 participants