The Critical Seam: Wiring Phase 12's Split API Across the C++/Rust FFI Boundary
Message: [edit] /home/theuser/curio/extern/bellperson/src/groth16/prover/supraseal.rs — "Edit applied successfully."
At first glance, the subject message is deceptively simple. It is a single line confirming that an edit to a Rust source file was applied successfully. There is no analysis, no debugging output, no architectural diagram — just a tool result. Yet this message, <msg id=2892>, sits at the fulcrum of one of the most consequential architectural changes in the entire optimization campaign: the Phase 12 split API. Understanding why this particular edit matters requires tracing the reasoning that led to it, the debugging journey that preceded it, and the cross-language complexity it navigates.
The Problem That Demanded a Split
The Phase 12 split API was born from a detailed memory-bandwidth bottleneck analysis conducted during Phase 11. The assistant and user had spent weeks iterating through optimization proposals, benchmarking interventions, and analyzing waterfall timing diagrams. By Phase 11, they had identified that the GPU worker's critical path was being blocked by CPU post-processing — specifically, the b_g2_msm computation. This ~1.7-second operation (with 32 threads) runs after the GPU lock is released, yet it still blocks the worker from picking up the next synthesis job. The worker cannot loop back to accept new work until the entire proof generation pipeline completes, including the CPU-only epilogue.
The insight was elegant: if the GPU kernel execution and the CPU post-processing could be decoupled, the worker could release its GPU resources, hand off the pending proof to a background finalization task, and immediately begin processing the next circuit. This is classic latency hiding — the same principle that motivates out-of-order execution in CPUs and double-buffering in graphics pipelines. But implementing it in a production Groth16 prover, spread across C++ CUDA kernels and Rust orchestration code, is anything but trivial.
The C++ Foundation
Before this Rust edit could exist, the assistant had already completed a substantial C++ refactoring across approximately twenty messages ([msg 2861] through [msg 2886]). The core idea was to modify generate_groth16_proofs — the monolithic C++ entry point that handles everything from GPU kernel launch to proof serialization — into a split interface: generate_groth16_proofs_start_c and finalize_groth16_proof.
The C++ refactoring was fraught with subtle memory-safety issues. The original function declared local variables like msm_results results{num_circuits} and batch_add_results batch_add_res{num_circuits} that were captured by reference in both the GPU worker threads (per_gpu) and the CPU prep_msm_thread. These threads ran concurrently, with prep_msm_thread writing to results.b_g2[c] even after the GPU threads had joined. The assistant initially tried to move these local variables into the pending handle after thread completion, but quickly realized this would create dangling references ([msg 2864]):
"Theresultsandbatch_add_resare declared locally in the function and captured by reference in the threads. If I move them into the pending handle, the references fromprep_msm_thread(still running) become dangling!"
The solution was to allocate the groth16_pending_proof struct on the heap before spawning any threads, and alias all shared state (split vectors, tail MSM bases, results, batch add results, split flags, and even the caught_exception atomic) into the handle's fields. This ensured stable memory addresses that outlived all threads. The assistant then encountered and fixed two compilation errors: an ordering issue where pp was referenced before allocation ([msg 2882]), and a mult_pippenger type mismatch caused by const/non-const pointer ambiguity in a ternary expression ([msg 2884]). After these fixes, the C++ compiled cleanly ([msg 2886]).
The Rust FFI Boundary
With the C++ foundation in place, the assistant turned to the Rust side. The first step was declaring the new C functions in supraseal-c2/src/lib.rs ([msg 2888]), adding generate_groth16_proofs_start_c and finalize_groth16_proof as extern "C" declarations. This is the FFI seam — the point where Rust's type system meets C++'s memory model.
The subject message ([msg 2892]) represents the second step: adding the Rust wrapper functions in bellperson/src/groth16/prover/supraseal.rs. This file is the high-level Rust interface that the rest of the Curio codebase calls. The edit added:
PendingProofHandle— an opaque Rust wrapper around the C++groth16_pending_proof*pointer, ensuring proper lifetime management and implementingSendfor cross-thread transfer.prove_start— a function that callsgenerate_groth16_proofs_start_c, packages the returned opaque handle into aPendingProofHandle, and returns it to the caller. This is the "fast path" that the GPU worker calls before looping back.prove_finish— a function that callsfinalize_groth16_proof, waits for theb_g2_msmthread to complete, runs the epilogue, and returns the finalProof<Bls12>. This is the "slow path" that runs in a background task. The design decision to use an opaque handle rather than copying data across the FFI boundary was driven by performance: the pending proof contains large vectors (split MSM bases, tail MSM bases, results arrays) that would be expensive to marshal. Instead, the C++ heap-allocated struct serves as shared state, with Rust holding a pointer and promising not to access it until finalization.
Assumptions and Their Risks
The split API makes several assumptions that are worth examining:
Assumption 1: The GPU worker can safely release the GPU lock before b_g2_msm completes. This was validated during Phase 11's analysis — b_g2_msm is a CPU-only operation that reads from host memory already transferred from the GPU. The GPU lock only protects CUDA kernel execution and device memory allocation.
Assumption 2: The pending handle's memory is stable across the split. The C++ refactoring ensured this by allocating the handle early and aliasing all state into it. But the Rust side must also ensure that the handle is not dropped or moved while the finalization thread is still running.
Assumption 3: The b_g2_msm thread can be joined asynchronously. The C++ finalize_groth16_proof function joins the prep_msm_thread (which runs b_g2_msm) as part of its epilogue. This means the Rust caller must ensure that prove_finish is called exactly once and that the handle is not used concurrently.
Assumption 4: The split API does not introduce new failure modes. If prove_finish is never called, the pending handle leaks. If it is called twice, the thread join fails. The Rust wrapper must enforce correct usage through its type system.
The Knowledge Flow
This message both consumes and produces knowledge. The input knowledge required to understand it includes: the Groth16 proof generation pipeline (synthesis, NTT, MSM, b_g2_msm, epilogue), the CUDA GPU interlock design from earlier phases, the memory-bandwidth bottleneck analysis from Phase 11, the C++ groth16_pending_proof struct and its memory layout, and the existing Rust FFI patterns in supraseal.rs.
The output knowledge created by this message is the Rust-side split API that enables the GPU worker loop restructuring. This is the seam that connects the low-level C++ optimization to the high-level Curio engine orchestration. Subsequent messages ([msg 2893]–[msg 2896]) will export the new types from the groth16 module and integrate them into pipeline.rs and engine.rs, ultimately restructuring the GPU worker loop to call gpu_prove_start, spawn a tokio task for finalization, and immediately loop back for the next job.
A Pivot Point in the Optimization Campaign
The Phase 12 split API represents a shift in strategy. Earlier phases focused on reducing memory bandwidth contention (Phase 11's three interventions), optimizing PCIe transfers (Phase 9), and improving GPU kernel efficiency (Phase 8's dual-worker interlock). These were all "within-function" optimizations — they made the existing monolithic pipeline faster without changing its structure.
Phase 12 is different. It changes the architecture of the proving pipeline, introducing asynchronous decomposition at the cost of increased complexity. The assistant's willingness to undertake this refactoring reflects a pragmatic assessment: the ~1.7-second b_g2_msm latency was a hard wall that could not be optimized away within the monolithic design. The only way to hide it was to restructure the pipeline around it.
This is the kind of architectural insight that separates deep optimization from surface-level tuning. The assistant did not just ask "how can I make b_g2_msm faster?" — it asked "how can I make the pipeline not wait for b_g2_msm?" The answer required coordinated changes across C++ memory management, CUDA thread synchronization, Rust FFI declarations, and application-level worker orchestration. The subject message, for all its brevity, is the moment that Rust-side coordination was wired into place.