The Bridge Across Two Worlds: How a Single FFI Declaration Unlocked 17% GPU Throughput

In the midst of implementing Phase 8 of the cuzk SNARK proving engine—a dual-worker GPU interlock designed to eliminate GPU idle gaps—the assistant sent a message that, on its surface, appears almost trivial. Message [msg 2182] reads:

Now add the FFI declarations and wrapper in supraseal-c2/src/lib.rs: [edit] /home/theuser/curio/extern/supraseal-c2/src/lib.rs Edit applied successfully.

Seven words of commentary, one file path, a confirmation. Yet this message represents the resolution of a subtle and critical design problem that threatened to derail the entire Phase 8 implementation. Understanding why this message exists—and what it accomplishes—requires tracing the assistant's reasoning through a chain of realizations about the fundamental incompatibility between Rust's and C++'s concurrency primitives, and the creative engineering required to bridge them across a foreign function interface.

The Problem That Preceded the Message

The Phase 8 design, documented in the 567-line c2-optimization-proposal-8.md ([msg 2145]), called for replacing the coarse static std::mutex inside the C++ function generate_groth16_proofs_c with a finer-grained lock that would cover only the CUDA kernel region (NTT+MSM, batch additions, tail MSMs). CPU preprocessing and the b_g2_msm computation would run outside the lock, allowing two GPU worker tasks per device to interleave their work—one doing CPU preparation while the other runs CUDA kernels on the same physical GPU.

The design document had already selected Option 4: Passed Mutex as the recommended approach. The idea was straightforward: pass a std::mutex* from Rust into the C++ function, acquire it only around the GPU kernel region, and release it before CPU-only work. The Rust engine would create one mutex per GPU and share it between two worker tasks.

By the time the assistant reached message [msg 2181], it had already completed four of the five implementation steps outlined in the design document:

  1. C++ mutex refactor in groth16_cuda.cu ([msg 2151][msg 2154]) — removing the static mutex, adding a std::mutex* parameter, narrowing the lock scope
  2. FFI plumbing in supraseal-c2/src/lib.rs ([msg 2156][msg 2159]) — threading the mutex pointer parameter through the FFI boundary
  3. Bellperson changes in supraseal.rs ([msg 2161][msg 2167]) — adding the gpu_mutex parameter to prove_from_assignments
  4. Pipeline changes in pipeline.rs ([msg 2168][msg 2179]) — updating gpu_prove and all its callers Then came Step 5: the engine changes. And that's where the assistant hit a wall.

The Realization: A Mutex Is Not a Mutex

In message [msg 2181], the assistant began reasoning about how to create the per-GPU mutex in the Rust engine:

The engine needs to: 1. Create one std::sync::Mutex<()> per GPU... Wait — I need to reconsider. The C++ side expects a std::mutex* (C++ type). Rust's std::sync::Mutex has a completely different layout.

This is the critical insight. The design document's plan had assumed that the engine could simply create a Rust Mutex and pass a raw pointer to it through FFI. But std::sync::Mutex<()> and std::mutex are entirely different types with different internal representations. A std::mutex in libstdc++ is typically a thin wrapper around a pthread_mutex_t (on Linux), while Rust's std::sync::Mutex is built on top of a different abstraction layer. Passing a pointer to one where the other is expected would cause undefined behavior—likely crashes, data corruption, or silently broken synchronization.

The assistant considered several alternatives. One was to use Box::new(0u8) as opaque storage of the appropriate size, but immediately recognized this as fragile. Another was to use a Rust-level semaphore instead, but that would require restructuring the C++ code to accept a callback pair—a more invasive change.

The solution the assistant arrived at was elegant: add two small C++ helper functions to the .cu file that would create and destroy std::mutex objects on the C++ side, and expose them through FFI. This way, Rust could allocate a C++ std::mutex by calling alloc_gpu_mutex(), receive an opaque pointer, pass it through the FFI chain into generate_groth16_proofs_c, and eventually free it with free_gpu_mutex().

The assistant executed this solution in message [msg 2181], editing the .cu file to add the helper functions. But that was only half the work.## The Message Itself: Completing the Bridge

Message [msg 2182] is the second half of that solution. After adding the C++ helper functions in the .cu file, the assistant now needed to expose them to Rust through FFI declarations in supraseal-c2/src/lib.rs. This is the message: a single edit operation that adds the extern "C" declarations for alloc_gpu_mutex and free_gpu_mutex, along with safe Rust wrapper functions.

The edit touched the same file that had already been modified in Step 2 ([msg 2156][msg 2159]) to thread the mutex pointer through the existing FFI calls. Now it needed to add the creation and destruction side of the mutex lifecycle. The pattern is standard for FFI boundaries: declare the C functions as extern "C", define safe Rust wrappers that handle null-pointer checks and type safety, and export them as public API for the engine to use.

What makes this message noteworthy is what it reveals about the assistant's decision-making process. The assistant had a choice: it could have attempted to use a Rust mutex and cast its pointer, which would have been simpler but incorrect. It could have used a Rust AtomicBool or semaphore as a makeshift lock, which would have worked but introduced subtle semantic mismatches. Instead, it correctly identified that the only safe approach was to let C++ manage its own mutex type and expose it through opaque pointers.

This decision reflects a deep understanding of FFI semantics. When crossing language boundaries, you cannot assume that two types with the same name and purpose have the same representation. std::mutex in C++ is not guaranteed to have the same layout as Rust's std::sync::Mutex, even on the same platform and compiler. The only safe approach is to let each language manage its own types and communicate through opaque pointers or C-compatible interfaces.

Assumptions and Their Validity

The assistant made several assumptions in this message and the surrounding work:

  1. That std::mutex is safe to allocate on the heap and pass as an opaque pointer. This is correct—std::mutex is designed to be movable and its address is stable after construction. The alloc_gpu_mutex helper uses new std::mutex() which returns a heap-allocated object whose address remains valid until delete is called.
  2. That two GPU workers sharing the same mutex pointer will correctly serialize GPU kernel access. This is the core assumption of Phase 8. The mutex is a std::mutex, which provides mutual exclusion between threads. As long as both workers are in the same process (which they are—they're tokio tasks spawned from the same engine), the mutex will correctly synchronize them.
  3. That the C++ side's std::mutex lock/unlock operations are safe to call from threads created by Rust's tokio::task::spawn_blocking. This is correct because spawn_blocking runs the closure on a thread pool managed by tokio, and those threads are standard POSIX threads. std::mutex works correctly across any threads in the same process, regardless of which language created them.
  4. That the SendableGpuMutex wrapper (introduced in bellperson) is necessary for Rust's type system. The raw *mut c_void pointer is neither Send nor Sync in Rust. The assistant created a wrapper type that explicitly implements these traits, acknowledging that the pointer is safe to share because the underlying C++ mutex provides its own synchronization guarantees. One potential mistake: the assistant did not verify that the C++ std::mutex destructor (free_gpu_mutex) is called correctly when the engine shuts down. If a worker task is still holding the mutex when free_gpu_mutex is called, the behavior is undefined (destroying a locked mutex). The design document's risk assessment ([msg 2145]) mentions this only implicitly through the "Background dealloc threads" risk. In practice, the engine's shutdown sequence must ensure all GPU workers have finished before freeing the mutexes.

The Knowledge Pipeline

This message both consumes and produces knowledge across several layers:

Input knowledge required:

The Broader Significance

Message [msg 2182] is a hinge point in the Phase 8 implementation. Before it, the assistant had refactored the C++ code to accept a mutex pointer and threaded the parameter through the Rust layers—but there was no mutex to pass. After this message, the engine can create C++ mutexes, pass them to workers, and those workers can use them to interleave their GPU access.

The benchmark results that followed ([msg 2200][msg 2210]) confirmed the approach: single-proof GPU efficiency hit 100.0%, multi-proof throughput improved 13.2% (from 50.7s to 44.0s per proof at c=5 j=3) and 17.2% (from 59.8s to 49.5s at c=5 j=2). These improvements came directly from the architectural change that this message enabled—two workers sharing a mutex, overlapping CPU and GPU work.

What appears as a routine "add FFI declarations" edit is actually the resolution of a subtle cross-language design problem. The assistant recognized that a seemingly simple task—"create one mutex per GPU"—required careful consideration of type compatibility across language boundaries. The solution it chose—heap-allocating C++ mutexes and exposing them through opaque pointers—is the standard pattern for this situation, but recognizing when to apply it requires deep knowledge of both languages' runtime models.

In the end, this message is a testament to the fact that in systems programming, the most impactful changes are often not the flashy algorithm redesigns but the careful, correct bridging of language boundaries. A mutex is just a mutex—until it crosses an FFI boundary, where it becomes a minefield of undefined behavior.