Skip to content

fix(gpu): survive transient VRAM pressure — recover resident-table declines, close an R2 corruption race - #914

Open
ColoCarletti wants to merge 11 commits into
mainfrom
gpu-vram-correctness-fixes
Open

fix(gpu): survive transient VRAM pressure — recover resident-table declines, close an R2 corruption race#914
ColoCarletti wants to merge 11 commits into
mainfrom
gpu-vram-correctness-fixes

Conversation

@ColoCarletti

Copy link
Copy Markdown
Collaborator

Main today fails ~1/3 of real-block proves at epoch 2^22 on a 32 GB GPU (reproduced: 2/6 runs on 8b88a8d), and under sustained VRAM pressure can silently emit an invalid proof (~1/15 runs). This PR fixes the three failure modes without touching the perf envelope.

Hard failures under transient CUDA OOM. The resident-aux LDE and the device R2 path had no fallback for a device-only table: the aux decline was a hard prove error ("resident aux LDE failed; host aux trace is empty") and an R2 device miss was a hard abort that kills the prover thread. Now the aux decline drains the device and retries once (the transient peak is usually gone, so the table stays resident); if it still declines, the resident aux trace and the main LDE are downloaded and the table continues host-backed. The R2 miss recovers the same way (materialize the resident LDEs, continue on the host evaluator). Both paths log the downgrade with the table name.

Silent invalid proofs. Concurrent device R2 windows under VRAM pressure can transiently produce a fully wrong composition H for one or two tables while every resident input stays correct (rerunning the same chain matches the host recompute), yielding a proof that fails the verifier's composition check. Serializing only the constraint-eval + decompose window across tables eliminates it (30/30 clean cycles vs ~5 failures/30 without); commits and host arms stay parallel and the windows overlap rarely enough that the lock is near-free (~0% on real block, ~1-2% on small synthetic workloads). LAMBDA_VM_GPU_SERIALIZE_R2=0 lifts the lock. The underlying race is still being hunted; the lock is the safe default until then.

Device-only gate tightening. The device R2 path only exists for the d=2 composition split; tables with any other bound (DECODE proves with num_parts == 1) now never enter device-only mode instead of aborting downstream. Mixed states (GPU aux commit with a CPU main commit) are also excluded.

Diagnostics (env-gated, zero cost when off). LAMBDA_VM_GPU_XCHECK=1 runs the verifier's composition consistency check inside the prover per table (~µs) and, on a failure, a post-mortem that recomputes each device stage on host, reports the corruption shape and reruns the device chain to distinguish a transient race from corrupted resident inputs. LAMBDA_VM_GPU_FORCE_DOWNGRADE=1 exercises the recovery paths end to end. These are how the fixes above were found and validated.

Validation on a 5090 (32 GB): 30/30 real-block e22 prove+verify cycles under reduced VRAM budget with zero corruption and all transient OOMs recovered (vs main failing 2/6 and the pre-fix branch failing ~5/30); full GPU test suite green (898 tests).

… on an R2 miss

The device-only gate is a static predicate over a dynamic dispatch: it cannot
mirror every reason the device R2 path might decline (parts count, kernel
eligibility, transient errors, shapes a new workload brings), and each miss
was a hard abort that deadlocked the epoch pipeline — DECODE on the synthetic
workload, then a second table on the real-block bench. Instead of excluding
tables one by one, treat the resident handles as the source of truth: on a
miss, download the main/aux LDEs back to host, clear the device-only flag,
and continue on the host path. Slower for that table, never wrong; the abort
remains only when the handles themselves cannot serve the data.
gpu_device_only_downgrades() counts recoveries so a persistently-missing
condition still gets mirrored into the gate.
…declines

A transient CUDA OOM on the resident aux LDE was a hard prove failure:
the resident build leaves no host aux trace to fall back to. A device
drain releases the concurrent VRAM peaks, so one retry usually keeps the
table fully resident; if it still declines, download the resident aux
trace (and the main LDE when the table is device-only) and continue
host-backed. The drain before dropping the resident buffer also keeps
kernels enqueued by the failed attempt from reading pool memory reused
by a concurrent table.
…ption race

Concurrent device R2 windows under VRAM pressure can transiently produce
a fully wrong H for one or two tables while every input stays correct
(rerunning the same chain on the same resident inputs matches the host),
yielding a proof that fails the composition check. Serializing only the
constraint-eval + decompose window across tables eliminates it; commits
and host arms stay parallel, and the windows overlap rarely enough that
the lock is near-free. LAMBDA_VM_GPU_SERIALIZE_R2=0 lifts the lock to
bisect further or once the underlying race is found.
The device R2 path only exists for the d=2 quotient split; a table with
any other composition bound (DECODE proves with num_parts == 1) would
skip it entirely and hard-abort on its device-only trace.
@ColoCarletti
ColoCarletti force-pushed the gpu-vram-correctness-fixes branch from b5e3e52 to 8f62d7b Compare August 7, 2026 18:23
A mixed state (one commit fell back to CPU while the other stayed
device-only) left the recovery refusing to proceed: it treated a missing
device handle as fatal even when that side already had a valid host
copy. Only the missing side is downloaded now, and the R3 host-arm
guards check the buffer they are about to read instead of the
table-wide flag.
download_ext3_columns came along in a cherry-pick but its only consumer
(the cross-check post-mortem) ships separately; dead code under the cuda
feature.
@ColoCarletti

Copy link
Copy Markdown
Collaborator Author

/ai-review

@github-actions

Copy link
Copy Markdown

Codex Code Review

  • Lowgpu_lde.rs:88: reset_all_gpu_call_counters() does not reset the newly added GPU_DEVICE_ONLY_DOWNGRADES. Measurements after warm-up can therefore report stale downgrade counts. Add GPU_DEVICE_ONLY_DOWNGRADES.store(0, Ordering::Relaxed).

@github-actions

Copy link
Copy Markdown

AI Review

PR #914 · 3 changed files

Findings

Status Sev Location Finding Found by
confirmed medium crypto/stark/src/gpu_lde.rs:69 R2 serialization guard uses .unwrap() and could poison on panic kimi
openrouter/moonshotai/kimi-k2.7-code
confirmed medium crypto/stark/src/gpu_lde.rs:88 New GPU_DEVICE_ONLY_DOWNGRADES counter not reset in reset_all_gpu_call_counters kimi
openrouter/moonshotai/kimi-k2.7-code
confirmed medium crypto/stark/src/gpu_lde.rs:1480 GPU_DEVICE_ONLY_DOWNGRADES counter double-counts per table nemotron
openrouter/nvidia/nemotron-3-ultra-550b-a55b
confirmed low crypto/stark/src/gpu_lde.rs:1504 materialize_lde_trace_host aux slab download skips the length validation that the main path performs glm
openrouter/z-ai/glm-5.2
confirmed low crypto/stark/src/gpu_lde.rs:1601 materialize_aux_trace_host mutates trace.aux_table/num_aux_column before the final device-sync check, then returns false on sync failure glm
openrouter/z-ai/glm-5.2
confirmed low crypto/stark/src/trace.rs:535 set_num_rows doc comment was hijacked by the inserted set_host_data glm
openrouter/z-ai/glm-5.2
kimi
openrouter/moonshotai/kimi-k2.7-code

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

AI-004: R2 serialization guard uses .unwrap() and could poison on panic
  • Status: confirmed
  • Severity: medium
  • Location: crypto/stark/src/gpu_lde.rs:69
  • Found by: kimi:openrouter/moonshotai/kimi-k2.7-code
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

r2_serialize_guard returns Some(LOCK.lock().unwrap()). If a thread panics while holding this static mutex, the mutex becomes poisoned and subsequent calls will panic on unwrap(). The PR's fallback/recovery logic is designed to degrade gracefully, but a single panic in one table's R2 window would kill the whole process on the next table's R2 attempt.

Evidence

Line 69: Some(LOCK.lock().unwrap()). No poison handling. Given the PR adds recovery paths for transient GPU errors rather than hard aborts, a panic from a different source (e.g. an unrelated expect) while holding this lock would turn a recoverable table failure into a process-wide crash.

Suggested fix

Use LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) to ignore poison and continue serialization, or document that the lock is not expected to be held across panic boundaries.

AI-005: New GPU_DEVICE_ONLY_DOWNGRADES counter not reset in reset_all_gpu_call_counters
  • Status: confirmed
  • Severity: medium
  • Location: crypto/stark/src/gpu_lde.rs:88
  • Found by: kimi:openrouter/moonshotai/kimi-k2.7-code
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

The PR adds a new public counter GPU_DEVICE_ONLY_DOWNGRADES and accessor gpu_device_only_downgrades(), but reset_all_gpu_call_counters does not reset it. Tests/benches that call reset_all_gpu_call_counters() between warm-up and profiled passes will see stale downgrade counts, leading to misleading metrics and possible test failures if assertions expect zero at test start.

Evidence

Line 1439 declares pub(crate) static GPU_DEVICE_ONLY_DOWNGRADES. Line 88-103 reset_all_gpu_call_counters resets GPU_LDE_CALLS, GPU_EXTEND_HALVES_CALLS, etc., but there is no GPU_DEVICE_ONLY_DOWNGRADES.store(0, ...). The public accessor at line 1440 also implies the counter is meant to be observable and resettable like the others.

Suggested fix

Add GPU_DEVICE_ONLY_DOWNGRADES.store(0, Ordering::Relaxed); to reset_all_gpu_call_counters.

AI-006: GPU_DEVICE_ONLY_DOWNGRADES counter double-counts per table
  • Status: confirmed
  • Severity: medium
  • Location: crypto/stark/src/gpu_lde.rs:1480
  • Found by: nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

The GPU_DEVICE_ONLY_DOWNGRADES counter increments in both materialize_lde_trace_host() and materialize_aux_trace_host(), but a single table downgrade can trigger both (main + aux), counting one table as two downgrades.

Evidence

materialize_lde_trace_host() calls GPU_DEVICE_ONLY_DOWNGRADES.fetch_add(1) at line 1480. materialize_aux_trace_host() calls the same at line 1547. In prover.rs aux stage (line 3470-3485), when aux materialize succeeds and device_only was true, it also calls download_main_lde_row_major which triggers materialize_lde_trace_host logic, causing both counters to increment for the same table.

Suggested fix

Either rename counter to track sides not tables, or use a per-table flag to count only once per table. Simplest: increment only in materialize_lde_trace_host since that handles both sides, and remove increment from materialize_aux_trace_host.

AI-011: materialize_lde_trace_host aux slab download skips the length validation that the main path performs
  • Status: confirmed
  • Severity: low
  • Location: crypto/stark/src/gpu_lde.rs:1504
  • Found by: glm:openrouter/z-ai/glm-5.2
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

The aux arm of materialize_lde_trace_host indexes slabs with slabs[(c*3+k+1)*lde..] without first checking slabs.len() == m * 3 * lde, unlike download_main_lde_row_major which guards col_major.len() != m * lde. If the device handle's buffer length invariant ever drifts from {m, lde_size}, this panics with an index-out-of-bounds instead of returning false (the graceful failure this recovery path promises).

Evidence

download_main_lde_row_major (line 1553) checks if col_major.len() != m * lde { return None; } before the transpose loop. The aux path (lines 1504-1519) has no equivalent check on slabs before the nested loop. The invariant h.buf.len() == m*3*lde_size holds by construction today (coset_lde_ext3_row_major_with_merkle_tree_keep_dev, lde.rs:893), so this is a defensive gap, not an active bug.

Suggested fix

After let Ok(slabs) = stream.clone_dtoh(h.buf.as_ref()) else { return false; }; add if slabs.len() != m * 3 * lde { return false; } (with m, lde in scope) before the de-interleave loop, mirroring download_main_lde_row_major.

AI-014: materialize_aux_trace_host mutates trace.aux_table/num_aux_column before the final device-sync check, then returns false on sync failure
  • Status: confirmed
  • Severity: low
  • Location: crypto/stark/src/gpu_lde.rs:1601
  • Found by: glm:openrouter/z-ai/glm-5.2
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

materialize_aux_trace_host assigns trace.aux_table and trace.num_aux_column (lines 1601-1602) before verifying be.ctx.synchronize() (line 1609). If that sync returns Err, the function returns false even though trace has already been partially overwritten (aux_table populated, aux_resident still Some). This is benign in the current caller because !recovered makes the prover return Err and drop the trace, but it leaves an inconsistent intermediate state if the caller ever changes to continue on partial recovery.

Evidence

Lines 1600-1611: let data = u64_to_ext3_vec::<E>(&raw); trace.aux_table = Table::new(data, cols); trace.num_aux_column = cols; ... if be.ctx.synchronize().is_err() { return false; } — the trace mutation precedes the only failure point that still returns false.

Suggested fix

Perform be.ctx.synchronize() (and its error check) before assigning trace.aux_table/trace.num_aux_column, so a failing sync returns false with the trace untouched.

AI-015: set_num_rows doc comment was hijacked by the inserted set_host_data
  • Status: confirmed
  • Severity: low
  • Location: crypto/stark/src/trace.rs:535
  • Found by: glm:openrouter/z-ai/glm-5.2, kimi:openrouter/moonshotai/kimi-k2.7-code
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

The pre-existing doc comment describing set_num_rows ("Override the LDE row count. Needed on the device-only path...") now sits above the newly inserted set_host_data, leaving set_num_rows (line 561) with no doc comment and set_host_data with two stacked doc comments. The #[cfg(feature = "cuda")] attribute at line 539 also now applies to set_host_data's doc rather than the function it was meant for.

Evidence

Lines 535-538 are the original set_num_rows doc comment. Lines 540-558 insert set_host_data between that comment and set_num_rows (line 561), so the rustdoc now attaches lines 535-538 to set_host_data, not set_num_rows.

Suggested fix

Move the set_host_data definition (with its own doc comment) to a position that does not separate set_num_rows's doc comment from its fn declaration, e.g. insert it after set_num_rows or before the set_host_trace_empty doc block.

Reviewer Lanes

Lane Model Prompt Status Findings
glm openrouter/z-ai/glm-5.2 general success 3
kimi openrouter/moonshotai/kimi-k2.7-code general success 8
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 5

Verification Lanes

Lane Model Status Confirmed Rejected Uncertain
deepseek-verifier openrouter/deepseek/deepseek-v4-pro success 6 9 0

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

Discarded candidates (9) — rejected by the verifier
  • materialize_aux_trace_host reassigns trace.aux_table while kernels may still read the old resident buffer (crypto/stark/src/gpu_lde.rs:1594, found by kimi:openrouter/moonshotai/kimi-k2.7-code) — be.ctx.synchronize() calls cuCtxSynchronize() which blocks until ALL work in the CUDA context completes across EVERY stream — not just the default stream. The claim that it only drains the default stream is factually incorrect about CUDA semantics. Additionally, the comment at lines 1603-1608 explicitly states the owning stream is 'long idle' because the resident LDE kernels completed long ago — the concern is about the pool reusing freed memory while other streams still reference it, and ctx.synchronize() correctly drains the entire device.
  • Device-only downgrade path mutates LDETraceTable without synchronizing concurrent host readers (crypto/stark/src/prover.rs:1657, found by kimi:openrouter/moonshotai/kimi-k2.7-code) — The mutation occurs in round_2_compute_composition_polynomial which runs within a single-threaded fused-per-table context. The PR adds r2_serialize_guard (a global mutex) around the device R2 window, and the recovery path runs inside that same function. No other thread can concurrently observe the lde_trace's host_trace_empty flag or buffers during this window. The claim speculates about concurrent access that does not exist in the current architecture.
  • R3 assertion still aborts on valid mixed state after partial recovery (crypto/stark/src/trace.rs:810, found by kimi:openrouter/moonshotai/kimi-k2.7-code) — The PR changed the assertions from a single table-wide host_trace_empty() check to per-buffer checks (line 810: num_main_cols()==0 || !main_data.is_empty(); line 869: num_aux_cols()==0 || !aux_data.is_empty()). This is precisely the fix for the mixed-state scenario. The claim's logic is inverted: the old code would abort incorrectly on mixed states; the new code correctly allows mixed states where one side has data and the other doesn't. The assertions work correctly for every case.
  • TraceTable::aux_resident ownership and mutation contract unclear after materialize_aux_trace_host (crypto/stark/src/gpu_lde.rs:1578, found by kimi:openrouter/moonshotai/kimi-k2.7-code) — The caller at prover.rs line 3473 calls materialize_aux_trace_host(trace) only AFTER the retry loop at lines 3440-3456. All expect() calls on aux_resident() (lines 3440, 3455) execute before the call that clears aux_resident. After materialization, the code sets device_only = false (line 3511) and falls through to the standard fused GPU path and ultimately the CPU path — none of which call aux_resident().expect(). There is no current code path where the expect would panic. The claim is about hypothetical future API misuse, not a real bug.
  • device_only_for gate now rejects non-d=2 AIRs but still relies on downstream dispatch for R3/R4 paths (crypto/stark/src/prover.rs:1038, found by kimi:openrouter/moonshotai/kimi-k2.7-code) — device_only_gate() at gpu_lde.rs:226-227 checks lde_size >= gpu_lde_threshold() and n >= gpu_bary_threshold(). The same thresholds are used in R3 dispatches. If a user changes the env var, the process must restart (OnceLock caches on first access), and both the gate and the R3/R4 dispatchers read the same cached value. The PR adds the d=2 check AND the recovery paths (materialize_lde_trace_host) as a safety net — which is exactly the correct approach. The gate and downstream checks are consistent. No violation exists.
  • Global R2 serialization mutex may become bottleneck under high concurrency (crypto/stark/src/gpu_lde.rs:45, found by nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b) — The global mutex is intentionally introduced with explicit documentation at lines 57-63 acknowledging that 'windows rarely overlap' and the serialization 'eliminates it at negligible cost.' It's a deliberate design trade-off, not a defect. The finding flags a potential performance concern, not a correctness issue, and the comment pre-documents the tradeoff.
  • GPU handles not cleared after host materialization leaves inconsistent state (crypto/stark/src/gpu_lde.rs:1450, found by nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b) — After materialize_lde_trace_host calls set_host_data(), host_trace_empty becomes false and the host buffers are populated. Having both host data and GPU handles is the NORMAL state for non-device-only tables. The host_trace_empty flag is the single decision point: when false, host readers use host data; device paths check for handles independently. This is exactly the 'mixed state' that the PR documents as valid and expected after a downgrade. There is no ambiguity.
  • Unsafe transmutes in download_main_lde_row_major and materialize_aux_trace_host rely on FieldElement memory layout (crypto/stark/src/gpu_lde.rs:1510, found by nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b) — The PR introduces two new unsafe blocks (lines 1522-1529 and 1564-1571) using Vec::from_raw_parts with ManuallyDrop. Both are gated by runtime TypeId checks (line 1546 and the tower check at line 1465/1583). This is a well-established pattern in the codebase (the existing code already uses transmute_copy for similar conversions). The runtime TypeId guards provide safety, and the comments document the repr(transparent) assumptions. Not a defect.
  • Context-wide synchronize in materialize_aux_trace_host is heavy for recovery path (crypto/stark/src/gpu_lde.rs:1545, found by nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b) — The code comment at lines 1603-1608 explicitly acknowledges this is a 'rare recovery path' and explains the need for a full device drain to prevent use-after-free. The tradeoff is intentional and documented. The finding flags a performance concern on a path that by definition only executes when the device aux LDE has already failed — performance is not the priority on a recovery path. This is by design.

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

Comment thread crypto/stark/src/trace.rs
/// `main_data.len()` — the caller supplies it from the device handle's
/// `lde_size` instead.
#[cfg(feature = "cuda")]
/// Install downloaded host buffers on a device-only table and clear the

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.

Medium — misplaced insertion: the new fn was pasted between set_num_rows's doc comment / #[cfg(feature = "cuda")] and its signature. Two consequences:

  • set_host_data now carries the "Override the LDE row count…" doc (and a duplicated #[cfg]).
  • set_num_rows loses its #[cfg(feature = "cuda")] and is compiled unconditionally on non-cuda builds.

It still compiles, but the gating is accidental. Move the new function below set_num_rows (or above the /// Override the LDE row count block) and drop the duplicated attribute.

// decompose, where H is born) eliminates a transient whole-buffer
// H corruption seen under concurrent R2 windows on VRAM pressure.
// The commit and every host arm run outside the lock.
let _r2_serial_guard = crate::gpu_lde::r2_serialize_guard();

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.

Medium (perf) — the host fallback runs inside the global lock. The guard covers the whole block, so the None => arm (download_comp_h_to_field D2H of the full H, then Self::decompose_and_extend_d2, a host iFFT + two LDEs over lde_size) is serialized across all tables. That arm is exactly the path taken under VRAM pressure — the case this PR targets — so the "windows rarely overlap, lock is near-free" measurement won't hold there.

Suggest keeping only the device window under the lock, e.g. capture h_dev/the decompose result inside the block, drop(_r2_serial_guard) (or end the block) before the download + host decompose.

Minor, same spot: the guard is taken before number_of_parts == 2 is evaluated, so tables that never enter the device R2 path (num_parts == 1) still queue on the lock. Cheap to reorder the check ahead of the acquire.

Comment thread crypto/stark/src/gpu_lde.rs Outdated
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
if *ENABLED.get_or_init(|| !std::env::var("LAMBDA_VM_GPU_SERIALIZE_R2").is_ok_and(|v| v == "0"))
{
Some(LOCK.lock().unwrap())

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 — lock poisoning masks the first failure. The critical section contains assert!s and expects (and per-table prove threads are joined via thread::scope). If one table panics while holding this lock, every other table then panics with a PoisonError from this unwrap(), burying the original cause under N unrelated panics. Since the guarded state is (), poisoning carries no information:

Suggested change
Some(LOCK.lock().unwrap())
Some(LOCK.lock().unwrap_or_else(|e| e.into_inner()))

/// [`materialize_lde_trace_host`]). Nonzero values mean the device-only gate
/// admitted a table some dispatch later declined — correct but slower, and
/// worth mirroring the missing condition into the gate.
pub(crate) static GPU_DEVICE_ONLY_DOWNGRADES: AtomicU64 = AtomicU64::new(0);

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 — counter is write-only. GPU_DEVICE_ONLY_DOWNGRADES isn't added to reset_all_gpu_call_counters() (every other counter is), and gpu_device_only_downgrades() has no caller, so as it stands it's dead API.

Two small follow-ups make it earn its keep: add it to the reset list, and assert gpu_device_only_downgrades() == 0 in prover/tests/cuda_path_integration.rs::gpu_device_only_residency_fires_and_verifies — that test already resets the counters and is the natural place to catch a gate/dispatch drift that silently starts downgrading every table (correct proof, lost residency win, no signal today).

// empty together for this table.
#[cfg(feature = "cuda")]
let device_only = Self::device_only_for(*air, domain);
let mut device_only = Self::device_only_for(*air, domain)

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 — stale docs around this now-asymmetric gate. The aux gate is no longer the same value as the main commit's, which invalidates two comments:

  • the line just above ("Same gate as the Round 1 main commit"), and
  • device_only_for's doc (~L1014): "Derived purely from the AIR + domain so the round-1 main-commit and aux-commit closures compute the identical value" — the aux side now also ANDs in the presence of the main device handle.

Worth restating the new contract: aux may be more conservative than main (never less), which is what keeps the mixed GPU-aux/CPU-main state out.

Also stale after this PR: try_expand_leaf_and_tree_ext3_row_major_keep_dev's eprintln comment in gpu_lde.rs ("This path has no CPU fallback … so the caller hard-aborts") — the caller now retries and downgrades.

@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review — GPU VRAM-pressure recovery + R2 serialization

Read the three changed files against the surrounding device paths (math-cuda's GpuLdeBase/GpuLdeExt3 layouts, coset_lde_row_major_inner, ResidentAux, run_admitted/VramGate). The core of the change looks correct; findings are mostly around the lock scope, one misplaced insertion, and doc drift. Details inline, summary below.

What I checked and believe is right

  • Buffer layouts. download_main_lde_row_major (col_major[c*lde + r] -> row_major[r*m + c]) matches what launch_row_to_col_major produces and what the host lde_out from coset_lde_row_major_inner looks like. The aux de-interleave (slabs[(c*3+k)*lde] -> interleaved[(r*m+c)*3+k]) matches pack_ext3_to_pinned_slabs/unpack_pinned_slabs_to_ext3. materialize_aux_trace_host reading ResidentAux.buf as row-major interleaved matches its documented layout.
  • Unsafe reinterprets. FieldElement<F> is #[repr(transparent)], both sites are TypeId-gated on the Goldilocks/ext3 tower, and vec![0u64; n] yields capacity == len, so dividing both len and capacity by 3 holds. Same pattern already in use at gpu_lde.rs:~1592.
  • No new deadlock from the R2 lock. run_admitted acquires the VRAM permit once before the task and never re-acquires, so a thread blocked on the R2 mutex while holding a permit cannot starve the lock holder. Per-table cells are single-owner, so there is no lock-order cycle.
  • The relaxed R3 asserts (num_main_cols() == 0 || !main_data.is_empty()) are strictly safer than the flag-level check for mixed states, and stay a hard abort on the case that matters.
  • The d != 2 gate tightening uses the same composition_poly_degree_bound(n) / n expression as R2's number_of_parts, so gate and dispatch agree.

Findings (inline)

  1. Medium — trace.rs: set_host_data was inserted between set_num_rows's doc/#[cfg(cuda)] and its signature; set_num_rows silently loses its cfg gate and set_host_data inherits the wrong doc.
  2. Medium (perf) — prover.rs: the R2 host fallback (full H D2H + decompose_and_extend_d2) runs inside the global serialize lock. That is the degraded path this PR exists for, so the 'lock is near-free' measurement does not cover it. The guard is also taken before the number_of_parts == 2 check.
  3. Low — r2_serialize_guard: LOCK.lock().unwrap() poisons on the first panicking table and buries the real cause under N PoisonError panics.
  4. Low — GPU_DEVICE_ONLY_DOWNGRADES is missing from reset_all_gpu_call_counters() and its getter has no caller; wiring an == 0 assert into cuda_path_integration.rs would make it a real regression guard.
  5. Low — stale comments: 'Same gate as the Round 1 main commit', device_only_for's 'identical value' doc, and try_expand_leaf_and_tree_ext3_row_major_keep_dev's 'the caller hard-aborts'.

Minor, not blocking

  • materialize_lde_trace_host: the main arm length-checks the D2H (col_major.len() != m*lde -> None) but the aux arm does not; a short slabs would index-panic instead of returning false into the caller's diagnostic abort. Cheap to make symmetric.
  • Same function: the aux interleave could build the u64 vec and hand it to the existing safe u64_to_ext3_vec, dropping one unsafe block for one extra copy on a cold path.
  • download_main_lde_row_major's transpose is a serial strided loop; on a large table the main LDE is O(GB) and every store lands on a fresh cache line. It is a recovery path so this is fine, but it runs when the prove is already degraded — a par_chunks_mut over rows (the crate has crate::par) would be a one-liner.
  • Optional: the R1 retry-after-drain does a full ctx.synchronize() — correct and documented, just worth noting it stalls every other table's in-flight streams, so the retry is not free when many tables are running.

The LAMBDA_VM_GPU_SERIALIZE_R2 escape hatch and the 'underlying race still being hunted' framing seem like the right call for a correctness-over-throughput default.

The fallback arm (download H, host iFFT + LDEs) executed under the
serialization lock, so under VRAM pressure — exactly when that arm runs
— it serialized every other table's device window behind pure CPU work.
The lock now covers only the device eval + decompose + the H download;
the host decompose and every host arm run outside it. The lock is also
acquired only for d=2 tables (the others never enter the device path).
set_host_data had been inserted between set_num_rows' doc and its
signature, stealing its doc comment and un-gating it from the cuda
feature; the serialization lock now recovers from poisoning instead of
cascading PoisonErrors over the original panic; the downgrade counter
joins reset_all_gpu_call_counters and the device-only residency test
asserts it stays at zero on the happy path; stale comments about the
aux gate mirroring the main gate rewritten with the actual contract.
Comment on lines +65 to +75
static ENABLED: OnceLock<bool> = OnceLock::new();
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
if *ENABLED.get_or_init(|| !std::env::var("LAMBDA_VM_GPU_SERIALIZE_R2").is_ok_and(|v| v == "0"))
{
// The guarded state is (), so a panic while holding the lock carries
// no information — recover instead of burying the original panic
// under a cascade of PoisonErrors from every other table.
Some(LOCK.lock().unwrap_or_else(|e| e.into_inner()))
} else {
None
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Consulting the env var is not that expensive, and unless we set it inside the code it won't change during the lifetime of the binary:

Suggested change
static ENABLED: OnceLock<bool> = OnceLock::new();
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
if *ENABLED.get_or_init(|| !std::env::var("LAMBDA_VM_GPU_SERIALIZE_R2").is_ok_and(|v| v == "0"))
{
// The guarded state is (), so a panic while holding the lock carries
// no information — recover instead of burying the original panic
// under a cascade of PoisonErrors from every other table.
Some(LOCK.lock().unwrap_or_else(|e| e.into_inner()))
} else {
None
}
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
if std::env::var("LAMBDA_VM_GPU_SERIALIZE_R2") != Ok("0") {
// The guarded state is (), so a panic while holding the lock carries
// no information — recover instead of burying the original panic
// under a cascade of PoisonErrors from every other table.
Some(LOCK.lock().unwrap_or_else(|e| e.into_inner()))
} else {
None
}

…ansposes (#921)

* fix(gpu): harden the downgrade download recovery

Three fixes on the device-only downgrade path, all in the graceful
degradation function whose whole point is to avoid a hard abort.

- The aux branch of `materialize_lde_trace_host` sliced the downloaded
  slabs without checking their length, so a short download would panic
  inside the recovery instead of degrading. Both sibling download paths
  already validate (`download_main_lde_row_major` checks
  `col_major.len() != m * lde`, `materialize_aux_trace_host` checks
  `raw.len() != rows * cols * 3`); this adds the matching check.

- Restore the `len/capacity % 3` guard the other two ext3
  `from_raw_parts` sites carry, spelled `is_multiple_of` because
  clippy's `manual_is_multiple_of` rejects the older form here.

- The failure error claimed "host aux trace is empty" on a path where
  that is false: when the aux download succeeded and the follow-up
  main-LDE download failed, the host aux trace had just been populated.
  Track which recovery step failed and name it. Control flow unchanged.

* perf(gpu): parallelize the downgrade recovery transposes

Both conversions in the recovery path were single-threaded nested loops
over the full LDE: the col-major -> row-major main transpose in
`download_main_lde_row_major`, and the de-interleaved-slabs -> row-major
interleaved aux conversion in `materialize_lde_trace_host`. For MEMW at
LDE 2^20 those are a 411 MB and a 327 MB buffer respectively, walked with
a strided access on one core.

Both now follow the existing idiom in `trace.rs` ("Parallel col-major ->
row-major transpose"): parallelize over OUTPUT row chunks with
`par_chunks_exact_mut`, so every element is still written exactly once
and no unsafe is involved. The index math is unchanged -- chunk `r` of
width `m` is `row_major[r * m + c]`, and chunk `r` of width `m * 3`
sub-chunked by 3 is `interleaved[(r * m + c) * 3 + k]` -- because the
layout was verified against the kernels. Gated on the `parallel` feature
with the sequential loop kept for builds without it, and skipped when
`m == 0` since `chunks_exact_mut(0)` panics.

These loops run on a scheduler driver thread holding no locks, so rayon
is safe here, unlike the pinned-staging unpack in math-cuda.
…antics (#920)

This branch turned two of the device-only hard-aborts into downloads that
recover and continue host-backed, but the surrounding docs still describe
the old contract: "every host read hard-aborts", "the prove aborts loudly",
"a mis-gate panics one of the guards".

Rewrite those to say what the code now does — R2 and the R1 resident-aux
commit recover and bump GPU_DEVICE_ONLY_DOWNGRADES, R3/R4 still abort, and
the R3 guards check the individual buffer so mixed states are legal. Also
correct the R2 lock comment (it serializes submission, not execution, for
device-only tables), note that the numeric gate is not the complete
predicate on its own, broaden the downgrade counter's doc to cover
resident-aux declines on tables that were never device-only, and drop the
false "only" from materialize_lde_trace_host's failure list.

Comments, doc comments, two assertion message strings and one doc-comment
run command (--test-threads=1, matching the Makefile target). No behavior
changes.

@diegokingston diegokingston left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We should check whether it makes sense to land 911 before

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.

4 participants