The Third Time's the Charm: Debugging Rust's Async Send Constraints in a GPU Proving Pipeline
A Single Message That Resolved a Stubborn Compilation Error
In the course of implementing Phase 8: Dual-Worker GPU Interlock for the cuzk SNARK proving engine, the assistant encountered a subtle and stubborn Rust compilation error involving the Send trait. The subject message — message index 2212 in the conversation — is the third and final attempt to fix this error, and it represents a moment of precise diagnosis after two prior attempts had failed. The message reads in full:
Theasync moveblock itself capturesgpu_mutex(typeSendableGpuMutex), which contains*mut c_void. Even thoughSendableGpuMutexis markedSend, the compiler still sees the raw pointer inside.
>
The cleanest fix: capture the mutex address as a usize BEFORE entering the async block:
>
[edit] /home/theuser/curio/extern/cuzk/cuzk-core/src/engine.rs Edit applied successfully.
This short message — barely four sentences — encapsulates the resolution of a multi-step debugging process that reveals deep truths about Rust's type system, the challenges of bridging C++ and Rust concurrency models, and the importance of understanding exactly where and how the compiler enforces its safety guarantees.
The Architectural Context: Phase 8 Dual-Worker GPU Interlock
To understand why this message exists, one must understand the architecture being built. The cuzk SNARK proving engine generates Groth16 proofs for Filecoin's Proof-of-Replication (PoRep) protocol. The proving pipeline involves both CPU-intensive preprocessing and GPU-intensive kernel execution (NTT, MSM, batch additions, tail MSMs). Prior to Phase 8, the C++ CUDA code used a static std::mutex inside generate_groth16_proofs_c that locked the entire function, preventing any overlap between CPU and GPU work for concurrent proofs.
Phase 8 refactored this: the static mutex was removed, and instead a per-GPU mutex is allocated in C++, passed through FFI as a raw *mut c_void pointer, and shared between multiple Rust worker tasks. The key insight is that the mutex now only guards the CUDA kernel region (NTT+MSM, batch additions, tail MSMs), while CPU preprocessing and the b_g2_msm computation run outside the lock. This allows two GPU workers per device to interleave — one worker runs CPU preprocessing while the other holds the GPU mutex and executes kernels.
The implementation spans seven files and roughly 195 lines of changes across C++ CUDA code, FFI bindings in supraseal-c2, Rust wrapper code in bellperson, and the engine orchestration in cuzk-core. The engine spawns gpu_workers_per_device workers (default 2) per GPU, all sharing the same C++ mutex pointer.
The Debugging Journey: Three Attempts to Satisfy the Compiler
The subject message is the culmination of a three-step debugging process. Each step reveals a progressively deeper understanding of where the Send constraint is actually checked.
Attempt 1: Move the Wrapper into the Closure
The first build failure (msg 2206) produced an error about the spawn_blocking closure not being Send. The assistant diagnosed this in msg 2207: the code was extracting gpu_mutex.0 (a *mut c_void raw pointer) and capturing that raw pointer in the spawn_blocking closure. Raw pointers are not Send. The fix in msg 2208 was to move the entire SendableGpuMutex wrapper into the closure instead of extracting the raw pointer.
But this failed. The SendableGpuMutex wrapper, despite being explicitly marked Send, contains a *mut c_void internally. The compiler, when seeing the closure capture the struct and then access its .0 field, still detects the raw pointer and rejects the closure.
Attempt 2: Convert to usize Inside the Async Block
The second attempt (msg 2210) took a different approach: convert the raw pointer to a usize (which is Send) and cast it back inside the closure. The reasoning was sound — usize is a plain integer with no ownership or aliasing concerns, so it freely implements Send.
But this also failed. The error now pointed at the async move block itself, not the inner spawn_blocking closure. The compiler error in msg 2211 showed:
1251 | tokio::spawn(async move {
| ------------ ^---------
| | |
| _________________|____________within this `{async block@...}`
| | |
| | required by a bound introduced by this call
This was the crucial clue: tokio::spawn requires the future it receives to be Send, and the async move block captures gpu_mutex (type SendableGpuMutex), which contains *mut c_void. Even though the conversion to usize happened inside the async block, the gpu_mutex variable itself was captured by the async move block, and the compiler checked Send on the captured set.
Attempt 3: Extract Before the Async Block (The Subject Message)
The subject message represents the correct diagnosis. The key insight is that tokio::spawn requires the entire future to be Send. The async move block captures gpu_mutex by value. Even though the code inside the block converts the pointer to usize, the captured gpu_mutex (which transitively contains a *mut c_void) is part of the future's captured state. The compiler sees the raw pointer in the captured variables and rejects the future.
The fix is elegant: extract the usize address before entering the async move block. This way, the async move block captures a usize (which is Send) instead of a SendableGpuMutex containing a raw pointer. The raw pointer never enters the async boundary.
Why This Matters: Rust's Send Semantics at the Async Boundary
This debugging episode illuminates a subtle aspect of Rust's concurrency model. The Send trait is checked on the entire set of captured variables when a closure or async block crosses a thread boundary. tokio::spawn sends the future to the tokio runtime, which may execute it on any thread. Therefore, the future must be Send — meaning all captured state must be Send.
The SendableGpuMutex wrapper is a deliberate workaround: it wraps a *mut c_void and explicitly implements Send (and presumably Sync). This is a common pattern in Rust FFI code where raw pointers must cross thread boundaries. However, the compiler's analysis is structural: when a closure captures a SendableGpuMutex, the compiler looks at the struct's fields to determine if the closure is Send. Even though the struct implements Send, the compiler's field-level analysis for closure auto-trait derivation can still flag the raw pointer.
The critical lesson is that auto-trait derivation for closures works differently than for structs. A struct can explicitly implement Send even if it contains raw pointers, because the programmer asserts that the pointer is used safely. But a closure's Send implementation is auto-derived from its captured variables — and the derivation looks at the types of captured variables, not at whether those types implement Send. Wait — actually, that's not quite right. If SendableGpuMutex implements Send, then capturing it should make the closure Send. The issue is more nuanced.
Let me re-examine. The SendableGpuMutex type is defined as a newtype wrapper: pub struct SendableGpuMutex(pub *mut c_void). If it implements Send, then capturing it in a closure should make the closure Send. The problem in the second attempt (msg 2210) was that the code inside the async block did let gpu_mtx_ptr = gpu_mutex.0 as usize; — this extracts the raw pointer from the wrapper. But the gpu_mutex variable itself (the SendableGpuMutex) is still captured by the async block. The compiler should accept this if SendableGpuMutex: Send.
The fact that it didn't suggests one of two things: either SendableGpuMutex was not actually marked Send (perhaps only SendableGpuMutex was marked Send but the compiler still rejected it due to the raw pointer field), or there was something else in the captured set that wasn't Send. The subject message states: "Even though SendableGpuMutex is marked Send, the compiler still sees the raw pointer inside." This implies that the compiler's auto-derivation for the closure's Send impl does a structural decomposition even when the captured type explicitly implements Send. This is a known subtlety: when a closure captures a variable, the compiler generates an anonymous struct type for the closure's environment. The auto-derivation of Send for this anonymous struct checks each field. If a field's type is SendableGpuMutex, which implements Send, the field should pass. But perhaps the compiler's analysis goes deeper — or perhaps the issue was that SendableGpuMutex was defined in a different crate and the auto-derivation didn't see the explicit Send impl.
Regardless of the exact compiler behavior, the pragmatic fix is clear: don't let the raw pointer cross the async boundary at all. Extract the usize before the async move block, and let the async block capture a plain integer.
Assumptions and Mistakes
The debugging process reveals several assumptions that turned out to be incorrect:
- Assumption that
SendableGpuMutexwould transparently satisfySend: The wrapper was designed precisely for this purpose — to make a raw pointerSend. But the compiler's closure analysis didn't honor this in the way expected. - Assumption about where the
Sendcheck occurs: The first fix targeted thespawn_blockingclosure, but the actual constraint was on the outerasync moveblock passed totokio::spawn. The second fix addressed the inner closure but not the outer async block. - Assumption that converting inside the async block would suffice: The conversion to
usizeinside the async block should have worked if the compiler only checked the types of variables actually used in the closure. But the compiler checks all captured variables, even if they're immediately destructured. The mistake was not in the Rust code's logic but in the mental model of how Rust's auto-trait derivation works for closures and async blocks. The compiler doesn't optimize away captured variables that are only used for field extraction — it sees the whole captured struct.
Input and Output Knowledge
Input knowledge required to understand this message includes:
- Rust's
Sendtrait and its role in async concurrency - The
tokio::spawnAPI and its requirement that futures beSend - How closures capture variables and how auto-trait derivation works
- The concept of FFI raw pointers (
*mut c_void) and why they're notSend - The architecture of the Phase 8 dual-worker GPU interlock
- The
SendableGpuMutexwrapper type and its purpose Output knowledge created by this message includes: - A working fix for the compilation error, allowing the build to proceed
- A reusable pattern for crossing async boundaries with raw pointers: extract to
usizebefore the async block - Documentation of a subtle Rust compiler behavior around closure
Sendderivation - The completed Phase 8 implementation, which went on to achieve 100% GPU efficiency and 13-17% throughput improvement
The Thinking Process Visible in the Message
The subject message is remarkably concise, but it reveals a clear thinking process. The assistant first identifies the exact problem: "The async move block itself captures gpu_mutex (type SendableGpuMutex), which contains *mut c_void." This shows the assistant has traced the error to its root cause — not the inner closure, but the outer async block.
The next sentence — "Even though SendableGpuMutex is marked Send, the compiler still sees the raw pointer inside" — shows the assistant grappling with an unexpected compiler behavior. The wrapper was supposed to solve this, but it didn't. Rather than fighting the compiler or trying to understand why the explicit Send impl wasn't honored, the assistant pragmatically accepts the compiler's behavior and finds a workaround.
The proposed fix — "capture the mutex address as a usize BEFORE entering the async block" — is the third iteration of the solution, and it's the simplest and most robust. By extracting the integer representation before the async boundary, the raw pointer never enters the async context. This is a pattern that could be generalized: when crossing thread boundaries with FFI pointers, convert to an integer first, then convert back on the other side.
Conclusion
This single message, though brief, represents the culmination of a multi-step debugging process that touched on fundamental aspects of Rust's type system, async concurrency model, and FFI patterns. It demonstrates that even carefully designed wrapper types like SendableGpuMutex can interact unexpectedly with Rust's closure auto-trait derivation, and that the pragmatic solution is often to restructure the code to avoid crossing trait boundaries with complex types. The fix enabled the Phase 8 dual-worker GPU interlock to compile successfully, leading to benchmark results of 100% GPU efficiency and 13-17% throughput improvement — a significant achievement for the cuzk SNARK proving engine.