The Raw Pointer That Wouldn't Cross the Async Boundary: Debugging Rust's Send Constraint in a GPU Proving Engine
Introduction
In the midst of implementing Phase 8: Dual-Worker GPU Interlock for the cuzk SNARK proving engine, a seemingly straightforward build command produced a compilation error that exposed a subtle tension between Rust's type system and the realities of cross-language FFI. Message <msg id=2211> captures this moment: the assistant runs cargo build --release -p cuzk-daemon and is greeted by a truncated compiler error pointing at a tokio::spawn call, where the async block fails the Send bound. The error message trails off into an ellipsis, but the problem is already clear to anyone following the conversation: a raw C++ mutex pointer, wrapped in a Send-marked newtype, is leaking through Rust's concurrency abstractions and violating the type system's guarantees.
This message is a snapshot of a debugging process that spans five rapid iterations ([msg 2208] through [msg 2213]), each attempting to thread a *mut c_void pointer through increasingly nested layers of asynchronous Rust without triggering the compiler's Send checker. To understand why this single build output matters, we must trace the path that led to it — a path that begins in C++ CUDA kernels, crosses three FFI boundaries, and ends in a tokio::spawn call deep inside the engine's GPU worker loop.
The Architecture Behind the Error
The Phase 8 dual-worker GPU interlock was designed to solve a specific performance problem. In earlier phases of the cuzk proving engine, a static std::mutex inside the C++ generate_groth16_proofs_c function serialized all GPU access, creating idle gaps where one GPU worker held the lock while performing CPU-side preprocessing, and other workers waited. The insight of Phase 8 was to narrow the mutex scope to cover only the CUDA kernel region (NTT, MSM, batch additions, tail MSMs), allowing CPU preprocessing and the b_g2_msm computation to run outside the lock. With two workers per GPU, one could perform CPU work while the other held the mutex for kernel execution, achieving perfect interleaving.
This architectural change required threading a C++ std::mutex* pointer through the entire call chain:
- C++ layer (
groth16_cuda.cu): The static mutex was removed, and astd::mutex*parameter was added to the kernel entry point. Two helper functions —alloc_gpu_mutexanddestroy_gpu_mutex— were added to create and destroy mutexes on the C++ side. - FFI layer (
supraseal-c2/src/lib.rs): The extern C declarations and Rust wrapper functions were updated to accept and pass the opaque*mut c_voidpointer. - Bellperson layer: The
prove_from_assignmentsandcreate_proof_batch_priority_innerfunctions gained agpu_mutexparameter. - Pipeline layer (
pipeline.rs): Thegpu_provefunction and its internal callers were updated. - Engine layer (
engine.rs): The GPU worker spawn loop was rewritten to create per-GPU mutexes and spawn multiple workers sharing the same mutex. TheSendableGpuMutexwrapper type was introduced to make the raw pointer usable across threads:
#[derive(Clone)]
pub struct SendableGpuMutex(pub *mut c_void);
// Safety: the mutex is accessed under exclusive C++ locking
unsafe impl Send for SendableGpuMutex {}
unsafe impl Sync for SendableGpuMutex {}
This is a classic FFI pattern: the Rust compiler cannot know whether a raw pointer is safe to transfer between threads, so the programmer asserts safety via unsafe impl Send. The assumption is that the underlying C++ std::mutex provides its own thread safety, and the pointer itself is merely a handle.
The Two-Front Send Battle
The build error in <msg id=2211> reveals that this assumption was not sufficient. The compiler error points to line 1251 of engine.rs:
1251 | tokio::spawn(async move {
| ------------ ^---------
| | |
| _________________|____________within this `{async block@...}`
| | |
| | required by a bound introduced by this call
1252 | | info!(worker_id = worker_id, gpu = gpu_ordinal, sub_id = worker_sub_id, "pipeline GPU worker started");
1253 | | ...
The tokio::spawn function requires the future it receives to implement Send, because the spawned task may be moved to a different thread by the Tokio runtime. The async move block captures gpu_mutex — a SendableGpuMutex that contains a *mut c_void. Even though the wrapper type is explicitly marked Send, the compiler still sees the raw pointer field inside the closure's capture set, and the error propagates.
This is a subtle point of Rust's type system: marking a struct Send makes the struct itself Send, but when the struct is captured by value in a closure, the compiler generates an anonymous type for the closure's capture set. The compiler looks at the fields of the captured struct, not just the struct's trait impl, when determining whether the closure is Send. In some cases — particularly with async move blocks and nested closures — the compiler's analysis can "see through" the wrapper and flag the inner raw pointer.
The assistant's first fix attempt ([msg 2208]) tried to move the entire SendableGpuMutex into the inner spawn_blocking closure rather than extracting the raw pointer. But this failed ([msg 2209]) because the inner closure also required Send, and the raw pointer inside the wrapper still caused the same issue at a deeper nesting level.
The second fix ([msg 2210]) converted the pointer to a usize (which is trivially Send) and cast it back inside the closure:
let gpu_mtx_val = gpu_mutex.0 as usize;
// ... inside spawn_blocking:
let gpu_mutex_ptr = gpu_mtx_val as *mut c_void;
But this only addressed the inner spawn_blocking closure. The outer tokio::spawn(async move { ... }) block still captured the SendableGpuMutex directly, triggering the error shown in <msg id=2211>.
The Reasoning Process
The assistant's thinking, visible across messages [msg 2208] through [msg 2213], reveals a methodical debugging process driven by compiler feedback:
- Observation: The
spawn_blockingclosure capturesgpu_mtx_ptr(a raw*mut c_void), which isn'tSend. - Hypothesis 1: Moving the whole
SendableGpuMutex(which isSend) into the closure will fix it, because the raw pointer is never extracted outside the closure. - Test: Build fails — the closure still captures the raw pointer field.
- Hypothesis 2: Convert the pointer to
usizebefore capture, cast back inside. - Test: Build fails again — but now at the outer
tokio::spawn, not the inner closure. - Refined understanding: The
async moveblock itself capturesSendableGpuMutex, and the compiler sees through the wrapper. - Hypothesis 3: Convert to
usizebefore entering the async block, so the async block captures only ausize. - Test: Success — both the outer and inner closures now capture only integer values. This is textbook Rust debugging: each compiler error reveals one more layer of the type-checking onion. The assistant correctly identifies that the
Sendimpl on the wrapper struct is not enough — the compiler's closure capture analysis is field-level, not type-level, for the purposes of auto-trait derivation on the anonymous closure type.
Assumptions and Corrections
Several assumptions were made during this debugging session, and each was corrected by the compiler:
| Assumption | Reality | Correction | |---|---|---| | SendableGpuMutex marked Send makes closures capturing it Send | The compiler sees through the wrapper to the raw pointer field | Convert to usize before capture | | Only the inner spawn_blocking closure has the issue | The outer tokio::spawn async block has the same problem | Apply the same usize conversion at the outermost capture point | | The usize conversion fix for the inner closure is sufficient | The outer async block captures SendableGpuMutex first | Move the conversion before the async block |
The key insight is that Rust's auto-trait derivation for closure types does not simply check whether captured values implement Send. For structs with manual unsafe impl Send, the compiler may still inspect the struct's fields when determining the auto-trait impls of the anonymous closure type. This is a known edge case in Rust's type system, documented in the language reference as "auto-trait leakage" — the compiler can see through newtype wrappers for the purposes of auto-trait satisfaction on generated types.
Input Knowledge Required
To understand this message, a reader needs:
- Rust's
Sendtrait: Understanding thatSendindicates a type can be safely transferred between threads, and thattokio::spawnrequiresSendfutures. - FFI and raw pointers: Knowledge that
*mut c_voidis the Rust representation of an opaque C/C++ pointer, and that raw pointers are neitherSendnorSyncby default. - The cuzk architecture: Awareness that the proving engine uses a multi-layer FFI chain (C++ → supraseal-c2 → bellperson → cuzk-core) and that a C++
std::mutexmust be shared across threads. - Async Rust patterns: Understanding
tokio::spawn,async moveblocks, andspawn_blockingfor running blocking code in async contexts. - Closure capture semantics: Knowing that
moveclosures capture variables by value, and that the compiler generates anonymous types for capture sets.
Output Knowledge Created
This message produces several forms of knowledge:
- A failed build artifact: The compiler error serves as a concrete record of a type-checking failure, documenting the exact location and nature of the problem.
- Evidence of a multi-layer Send issue: The error at line 1251 (outer
tokio::spawn) combined with the earlier error at line 1354 (innerspawn_blocking) proves that the Send constraint must be satisfied at two nesting levels. - A debugging trajectory: The sequence of build attempts ([msg 2209], [msg 2211], [msg 2214]) creates a trail of compiler feedback that guides the assistant toward the correct fix.
- A reusable pattern: The
as usize/as *mut c_voidcast pattern for threading raw pointers through async boundaries becomes a documented technique for future FFI work in this codebase.
The Resolution
The fix that ultimately succeeds ([msg 2212]–[msg 2213]) converts the mutex pointer to a usize before entering the async move block, so the async block captures only a plain integer:
let gpu_mutex_val = gpu_mutex.0 as usize;
// ...
tokio::spawn(async move {
// gpu_mutex_val is a usize — trivially Send
// ...
let gpu_mutex_ptr = gpu_mutex_val as *mut c_void;
// ...
});
With this change, both the outer tokio::spawn and the inner spawn_blocking capture only usize values, which are trivially Send. The build succeeds ([msg 2214]), and the daemon and bench tool both compile cleanly ([msg 2215]). The Phase 8 implementation is ready for benchmarking.
Conclusion
Message <msg id=2211> is a small but revealing moment in a larger engineering effort. It captures the exact point where a carefully designed FFI abstraction meets the unforgiving reality of Rust's type system. The SendableGpuMutex wrapper was a reasonable attempt to make a C++ mutex pointer usable across threads, but it failed to account for the compiler's field-level analysis of closure capture sets. The debugging process that follows — five rapid iterations of hypothesis, test, and refinement — demonstrates a systematic approach to type-level debugging that is characteristic of experienced Rust developers.
More broadly, this message illustrates a recurring theme in systems programming: the boundary between safe and unsafe code is never as clean as we hope. Every unsafe impl is a promise to the compiler, but the compiler may hold us to that promise in unexpected ways. The usize cast pattern that resolves this issue is itself a form of "lying to the compiler" — we erase the type information and restore it later — but it is a controlled, local lie that makes the larger truth (the C++ mutex is genuinely thread-safe) expressible within Rust's type system.
The build error in <msg id=2211> is not a failure. It is the compiler doing its job, enforcing the safety guarantees that make Rust suitable for high-performance, concurrent systems programming. And the assistant's response — methodical, compiler-driven, and ultimately successful — is exactly the kind of engineering discipline that such systems demand.