The Use-After-Free That Almost Wasn't: Fixing a Concurrency Bug in Phase 12's Split GPU Proving API
Introduction
In the high-stakes world of Filecoin proof generation, where a single Groth16 proof consumes ~200 GiB of memory and takes nearly 40 seconds to compute, every optimization walks a knife's edge between performance and correctness. The Phase 12 split GPU proving API was designed to shave seconds off each proof by offloading the b_g2_msm computation from the GPU worker's critical path. But as the assistant discovered in message [msg 3018], this optimization harbored a subtle and dangerous concurrency bug: a use-after-free in the C++ CUDA code that could corrupt memory, crash the daemon, or silently produce invalid proofs.
This article examines that single message — a brief but pivotal moment where the assistant recognized the bug, understood its root cause, and designed the fix. Though the message itself is only a few lines, it represents the culmination of a deep debugging chain spanning dozens of messages, and it reveals the intricate reasoning required to build correct concurrent systems at the intersection of Rust, C++, and CUDA.
The Subject Message
The message reads in full:
Good —Assignmentis a trivially copyable POD struct (just pointers and sizes). Copying astd::vector<Assignment<fr_t>>is cheap.
>
Now let me fix the C++ code. I need to:
>
1. Addstd::vector<Assignment<fr_t>> provers_owned;togroth16_pending_proof2. Early ingenerate_groth16_proofs_start_c, copy provers intopp->provers_owned3. Create a local aliasauto& provers_local = pp->provers_owned;and use it everywhere the thread or post-unlock code referencesprovers
>
[edit] /home/theuser/curio/extern/supraseal-c2/cuda/groth16_cuda.cu Edit applied successfully.
Deceptively simple. But behind these three numbered steps lies a sophisticated understanding of C++ lifetime semantics, Rust FFI memory management, and the asynchronous execution model of the split API.
The Context: A Debugging Chain
To understand why this message was written, we must trace the chain of reasoning that led to it. The assistant had just implemented Phase 12's split API — a design that separates Groth16 proof generation into a start phase (which launches the GPU kernels and spawns a background thread for b_g2_msm) and a finish phase (which joins the thread and assembles the final proof points). The goal was to allow the GPU worker to begin processing the next partition without waiting for b_g2_msm to complete.
Initial benchmarking showed a promising 37.1s/proof throughput. But when the assistant attempted to increase synthesis parallelism from pw=10 to pw=12, the daemon crashed with out-of-memory errors, RSS peaking at 668 GiB on a 755 GiB system. This triggered a memory investigation.
While tracing the memory buildup, the assistant noticed something alarming. In message [msg 3010], it observed that the C++ prep_msm_thread (the background thread running b_g2_msm) captured [&, num_circuits] — a lambda that captures all local variables by reference. One of those local variables was provers, a C-style array parameter of type const Assignment<fr_t> provers[]. In C++, this decays to a pointer stored on the stack. When generate_groth16_proofs_start_c returns, that stack frame is destroyed, and the background thread is left holding a dangling reference.
The assistant traced through the code in messages [msg 3011] through [msg 3017], confirming that:
- The
prep_msm_threadreads fromprovers[c].inp_assignment_data,provers[c].aux_assignment_data, and density bit-vectors — all through the dangling reference. - The main body of the function also reads
proversafter the thread is spawned (forcudaHostUnregistercalls at lines 1175-1177). - The Rust side's
start_groth16_prooffunction builds a localVec<Assignment>that is passed to C++ by pointer — and this Vec is dropped when the Rust function returns. This was a textbook use-after-free, though it had likely been "working" by coincidence because the freed memory hadn't been reused yet.
The Reasoning: Why This Bug Existed
The bug was introduced because the Phase 12 split API fundamentally changed the execution model. In the original synchronous API (generate_groth16_proofs_c), the function called generate_groth16_proofs_start_c and then immediately called finalize_groth16_proof_c, which joined the background thread. The provers array was guaranteed to be alive for the entire function call because the function didn't return until the thread was joined.
Phase 12 broke this invariant. generate_groth16_proofs_start_c now returns immediately, handing a groth16_pending_proof handle to the caller. The background thread continues executing, reading from provers — but the function's stack frame is gone, and the Rust Vec that backed the provers pointer is also freed.
The assistant's key insight was recognizing that the provers array needed to be owned by the groth16_pending_proof struct, which lives on the heap and persists until finalize is called. The fix was to copy the array into a std::vector<Assignment<fr_t>> provers_owned field of the pending proof struct.
Assumptions and Their Validity
The assistant made several assumptions in crafting this fix:
Assumption 1: Assignment<fr_t> is trivially copyable. This was verified by reading the struct definition — it contains only raw pointers (const uint64_t*, const fr_t*) and size fields (size_t). No destructor, no copy constructor, no virtual methods. A shallow copy via memcpy (or std::vector's element-wise copy) is safe because the underlying data these pointers reference is managed by the Rust PendingProofHandle, which outlives the C++ pending proof struct.
Assumption 2: The Rust PendingProofHandle outlives the C++ background thread. This is a critical assumption. The Rust side holds input_assignments and aux_assignments (large heap-allocated vectors) in the PendingProofHandle. The C++ Assignment structs contain pointers into these vectors. If the Rust handle were dropped before the C++ finalizer joined the thread, the pointers would dangle. The assistant implicitly trusts that the Rust ownership model ensures this — PendingProofHandle is consumed by finish_pending_proof, which calls the C++ finalizer (which joins the thread) before dropping the Rust data.
Assumption 3: A local reference alias auto& provers_local = pp->provers_owned; is safe for lambda capture. The assistant actually second-guesses this in the next message ([msg 3021]), reasoning through the C++ semantics: when a reference variable is captured by [&], the lambda captures the referent, not the reference itself. Since pp->provers_owned lives on the heap (in the groth16_pending_proof struct allocated with new), the reference remains valid even after the function returns. This is correct, though the assistant's analysis reveals the subtlety.
The Thinking Process
What makes this message remarkable is the depth of reasoning compressed into its few lines. The assistant had to:
- Understand the C++ lambda capture semantics —
[&]captures by reference, meaning the thread holds a reference to the stack variableprovers(a pointer), not a copy of the pointer value. - Trace the lifetime of
proversacross the Rust/C++ boundary — Theproversparameter in C++ points into a RustVec<Assignment>that is dropped whenstart_groth16_proofreturns. The C++ stack variable (the pointer itself) is also destroyed whengenerate_groth16_proofs_start_creturns. - Distinguish between the pointer and the pointee — The
Assignmentstructs contain pointers to Rust-owned memory (the actual assignment data). These pointers remain valid as long as the RustPendingProofHandleis alive. But theAssignmentstructs themselves (the array of structs) need to be stable. - Design a minimal fix — Rather than restructuring the entire concurrency model, the assistant chose to copy the
Assignmentarray into the heap-allocated pending proof struct. This is a localized change that preserves the existing code structure while fixing the lifetime issue. - Verify the fix's completeness — The assistant notes that all post-thread-spawn code (like
cudaHostUnregisterat lines 1175-1177) also readsprovers, so the local alias must be used everywhere, not just in the thread lambda.
Input Knowledge Required
To understand this message, one needs:
- C++ lambda capture semantics: Understanding the difference between
[&](capture by reference),[=](capture by value), and how reference variables interact with capture. - C++ object lifetimes: Stack vs. heap allocation, when destructors run, and what "dangling reference" means.
- Rust FFI conventions: How Rust passes
Vecdata to C++ as raw pointers, and when that memory is freed. - CUDA programming model: Understanding that
cudaHostRegisterpins host memory for GPU DMA, and that the pinned memory must remain valid untilcudaHostUnregister. - The Phase 12 split API design: Understanding why
generate_groth16_proofs_start_creturns before the background thread completes, and what thegroth16_pending_proofstruct contains. - The
Assignmentstruct layout: Knowing that it's a POD of pointers and sizes, making shallow copy safe.
Output Knowledge Created
This message produced:
- A concrete fix plan: Three numbered steps that were immediately applied via the
[edit]tool. - A documented understanding of the bug: The assistant's reasoning chain (spanning messages [msg 3010] through [msg 3018]) serves as documentation of why the bug existed and why the fix is correct.
- A pattern for future split-API designs: The lesson that any data accessed by background threads must be owned by a heap-allocated struct that outlives the thread, not by the stack frame that spawned it.
Broader Significance
This bug is a classic example of how asynchronous refactoring can introduce subtle concurrency bugs. The original synchronous code was correct by construction — the function didn't return until the thread was joined. Phase 12's split API broke this invariant, and the bug was invisible because:
- The freed stack memory wasn't immediately reused (the stack frame's data remained in cache)
- The Rust
Vec's heap buffer wasn't immediately reclaimed (glibc's malloc holds onto freed memory) - The background thread's access pattern was read-only, so no corruption was written The bug could have manifested as:
- Silent data corruption: If the background thread read stale data from a reused stack frame, the
b_g2_msmcomputation would produce wrong results, leading to invalid proofs. - Sporadic crashes: If the freed memory was unmapped or the stack frame was overwritten, the thread would segfault.
- Intermittent failures: The bug's manifestation depended on timing and memory pressure, making it extremely hard to reproduce and diagnose. The assistant's discovery of this bug during a memory investigation was fortunate. It's the kind of bug that could have shipped to production, causing intermittent proof failures that would be nearly impossible to trace back to their root cause.
Conclusion
Message [msg 3018] is a masterclass in concurrent systems debugging. In a few lines, the assistant identified a use-after-free bug, understood its root cause in the interaction between C++ lambda capture semantics and Rust FFI lifetimes, and designed a minimal, correct fix. The message's brevity belies the depth of reasoning required — reasoning that spanned C++ object lifetimes, Rust memory management, CUDA programming patterns, and the asynchronous execution model of the split API.
The fix — copying the provers array into the heap-allocated pending proof struct — is elegant in its simplicity. It preserves the existing code structure, adds minimal overhead (the Assignment structs are POD and cheap to copy), and ensures that the background thread always accesses stable memory. It's the kind of fix that, once explained, seems obvious — but only after hours of tracing through code, reading struct definitions, and reasoning about lifetimes across language boundaries.