Bridging the Gap: Implementing the Phase 12 Split API Across the C++/Rust FFI Boundary
In the iterative optimization of the cuzk SNARK proving engine for Filecoin's Curio stack, message <msg id=2891> represents a pivotal transition point. After completing the C++ side of the Phase 12 split API and updating the raw FFI declarations in lib.rs, the assistant pauses to read existing Rust code before adding the wrapper layer that will bridge the gap between low-level C++ pointers and the high-level Rust proving pipeline. This single message — a file read followed by a planning statement — encapsulates the delicate craftsmanship required when refactoring across a cross-language FFI boundary, where a single logical change (splitting a function) demands coordinated modifications across three layers: C++ CUDA kernels, Rust FFI declarations, and application-level orchestration.
The Motivation: Why Split the API?
The Phase 12 split API was born from the detailed memory-bandwidth analysis conducted during Phase 11. The assistant and user had identified that b_g2_msm — a multi-scalar multiplication on the G2 curve that takes approximately 1.7 seconds with 32 threads — was blocking the GPU worker's critical path. Although b_g2_msm runs after the GPU lock is released, it still prevents the worker from picking up the next synthesis job. The insight was elegant: if the proving function could be split into a "start" phase (GPU work, lock acquisition/release) and a "finish" phase (CPU post-processing including b_g2_msm, epilogue, and proof serialization), the GPU worker could loop back to pick up the next job immediately, while a separate tokio task handled the finalization asynchronously.
This is a classic latency-hiding pattern, but implementing it in a system that spans C++ CUDA code, Rust FFI bindings, and a tokio-based async engine introduces substantial complexity. The C++ side had already been refactored in messages <msg id=2861> through <msg id=2886>, creating a groth16_pending_proof heap-allocated struct that holds all intermediate state (results vectors, split flags, tail MSM bases, the prep_msm_thread handle) and two entry points: generate_groth16_proofs_start_c and finalize_groth16_proof. The Rust FFI declarations were added in <msg id=2888>. What remained was the Rust wrapper layer — the prove_start and prove_finish functions that would manage the opaque pointer, handle error conversion, and present a clean interface to the proving pipeline.
Reading the Landscape: What Message 2891 Reveals
The message itself is deceptively simple. The assistant states: "Now let me add prove_start and prove_finish functions, plus a PendingProof wrapper:" and then reads the file bellperson/src/groth16/prover/supraseal.rs around line 404. The content shown reveals the existing DEALLOC_MTX pattern — a static Mutex<()> used to serialize asynchronous deallocation of proving resources. This is the code the assistant is studying before writing the new split API functions.
The choice of reading location is significant. Line 404 is near the end of the existing prove_from_assignments function, where the deallocation logic lives. The assistant is examining how the existing code handles resource cleanup — the std::thread::spawn(move || { let _guard = DEALLOC_MTX.lock().unwrap(); drop(provers); ... }) pattern — because the new prove_finish function will need to perform similar cleanup after the finalization completes. The PendingProof wrapper must ensure that the C++ heap-allocated handle is properly freed, and the deallocation pattern provides a template.
This reading-before-writing discipline is a hallmark of careful engineering. Rather than blindly inserting code, the assistant first surveys the existing terrain to understand conventions, resource ownership patterns, and potential pitfalls. The DEALLOC_MTX pattern, for instance, reveals that deallocation is intentionally serialized across threads to avoid memory pressure — a constraint that the new split API must respect.
The Reasoning Process: Design Decisions in the Making
Although message <msg id=2891> does not contain explicit reasoning text, the reasoning is implicit in what the assistant is about to do. Several design decisions are being weighed:
Ownership and lifetime management. The PendingProof wrapper must own the opaque *mut c_void handle returned by generate_groth16_proofs_start_c. When prove_finish is called, it must pass the handle to finalize_groth16_proof and then free it. The wrapper's Drop implementation must handle the case where prove_finish was never called (e.g., due to an error) — a classic RAII concern.
Error handling across the boundary. The C++ functions return RustError (a C-compatible struct with an error code and message). The Rust wrapper must convert this to a Result type, propagating errors without leaking the C++ handle. If generate_groth16_proofs_start_c fails, the wrapper must free the handle immediately rather than leaving a dangling pointer.
Thread safety. The PendingProof handle is created on one thread (the GPU worker) and consumed on another (the tokio finalization task). The wrapper must ensure that the handle is Send — movable between threads — and that concurrent access is impossible by design (the handle is moved, not shared).
Integration with the existing proving pipeline. The prove_start/prove_finish functions must match the interface expected by pipeline.rs and engine.rs. The assistant had already designed gpu_prove_start and gpu_prove_finish wrappers in the pipeline layer, and the engine's worker loop would be restructured to call gpu_prove_start, spawn a tokio task for finalization, and loop back immediately.
Assumptions Underlying the Approach
The assistant makes several assumptions in this message, some explicit and some implicit:
That the C++ side is correct and stable. The assistant assumes that generate_groth16_proofs_start_c and finalize_groth16_proof have been correctly implemented and will not change. This is a reasonable assumption given that the C++ code compiled successfully in <msg id=2886>, but it means any latent bugs in the C++ side will surface only during benchmarking.
That the PendingProof wrapper can be a simple ownership struct. The assistant assumes that wrapping an opaque pointer in a Rust struct with Drop is sufficient to manage the C++ heap allocation. This is true for the basic case, but the groth16_pending_proof struct in C++ contains a std::thread (the prep_msm_thread), which must be joined before the struct can be safely freed. The Rust wrapper must ensure that prove_finish is called before the wrapper is dropped, or handle the thread join explicitly.
That the deallocation pattern from the existing code applies. The assistant assumes that the DEALLOC_MTX serialization pattern is appropriate for the new split API. This is likely correct, but the split API introduces a new resource (the pending handle) that may have different deallocation characteristics than the proving resources managed by the existing code.
That the tokio-based finalization will integrate cleanly. The assistant assumes that spawning a tokio task for prove_finish from within the GPU worker loop will work without introducing deadlocks or excessive latency. This depends on the tokio runtime being available and the finalization task not blocking on resources held by the worker.
The Broader Context: Phase 12 in the Optimization Journey
To fully appreciate message <msg id=2891>, one must understand where it fits in the larger optimization narrative. The cuzk proving engine had been through eleven phases of optimization, each targeting a specific bottleneck. Phase 8 introduced a dual-worker GPU interlock that improved throughput by 13-17%. Phase 9 optimized PCIe transfers, yielding 14.2% improvement in single-worker mode but revealing PCIe bandwidth contention in dual-worker mode. Phase 10 attempted a two-lock architecture but was abandoned after discovering fundamental CUDA device-global synchronization conflicts. Phase 11 implemented three memory-bandwidth interventions, with Intervention 2 (reducing the groth16 pool from 192 to 32 threads) delivering a 3.4% improvement.
The Phase 12 split API represents a shift in strategy. Earlier phases focused on reducing contention and improving utilization within the existing monolithic proving function. Phase 12 instead restructures the proving function itself, decoupling GPU work from CPU post-processing to hide latency. This is a more invasive change — it touches C++ structs, CUDA kernel orchestration, Rust FFI bindings, and the engine's worker loop — but it has the potential to unlock further gains by keeping the GPU worker continuously busy.
Output Knowledge and Dependencies
Message <msg id=2891> creates no direct output — it is a reading step that precedes the actual edit in <msg id=2892>. However, it produces knowledge: the assistant now understands the existing code structure around line 404 of supraseal.rs, including the DEALLOC_MTX pattern, the resource ownership conventions, and the surrounding function boundaries. This knowledge directly informs the implementation of prove_start, prove_finish, and PendingProof that follows.
The input knowledge required to understand this message is substantial. One must know:
- The Phase 11 memory-bandwidth analysis that motivated the split API
- The C++ refactoring that created
groth16_pending_proofand the start/finish entry points - The FFI declarations added in
lib.rs - The existing
prove_from_assignmentsfunction and its deallocation pattern - The planned restructuring of the engine worker loop in
engine.rsThis message is a hinge point — the moment when the assistant transitions from C++ implementation to Rust integration, from low-level CUDA concerns to high-level pipeline orchestration. It is a moment of synthesis, where the assistant must hold the entire architecture in mind and ensure that the new split API threads correctly through every layer of the stack.