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 — Assignment is a trivially copyable POD struct (just pointers and sizes). Copying a std::vector<Assignment<fr_t>> is cheap.

>

Now let me fix the C++ code. I need to:

>

1. Add std::vector<Assignment<fr_t>> provers_owned; to groth16_pending_proof 2. Early in generate_groth16_proofs_start_c, copy provers into pp->provers_owned 3. Create a local alias auto& provers_local = pp->provers_owned; and use it everywhere the thread or post-unlock code references provers

>

[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:

  1. The prep_msm_thread reads from provers[c].inp_assignment_data, provers[c].aux_assignment_data, and density bit-vectors — all through the dangling reference.
  2. The main body of the function also reads provers after the thread is spawned (for cudaHostUnregister calls at lines 1175-1177).
  3. The Rust side's start_groth16_proof function builds a local Vec<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:

  1. Understand the C++ lambda capture semantics[&] captures by reference, meaning the thread holds a reference to the stack variable provers (a pointer), not a copy of the pointer value.
  2. Trace the lifetime of provers across the Rust/C++ boundary — The provers parameter in C++ points into a Rust Vec<Assignment> that is dropped when start_groth16_proof returns. The C++ stack variable (the pointer itself) is also destroyed when generate_groth16_proofs_start_c returns.
  3. Distinguish between the pointer and the pointee — The Assignment structs contain pointers to Rust-owned memory (the actual assignment data). These pointers remain valid as long as the Rust PendingProofHandle is alive. But the Assignment structs themselves (the array of structs) need to be stable.
  4. Design a minimal fix — Rather than restructuring the entire concurrency model, the assistant chose to copy the Assignment array into the heap-allocated pending proof struct. This is a localized change that preserves the existing code structure while fixing the lifetime issue.
  5. Verify the fix's completeness — The assistant notes that all post-thread-spawn code (like cudaHostUnregister at lines 1175-1177) also reads provers, so the local alias must be used everywhere, not just in the thread lambda.

Input Knowledge Required

To understand this message, one needs:

Output Knowledge Created

This message produced:

  1. A concrete fix plan: Three numbered steps that were immediately applied via the [edit] tool.
  2. 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.
  3. 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:

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.