The Split API Design: Decoupling b_g2_msm from the GPU Worker's Critical Path

In the relentless pursuit of throughput for Filecoin's SUPRASEAL_C2 Groth16 proof generation pipeline, every millisecond matters. Message [msg 2849] represents a pivotal design moment in the optimization journey — the point where the assistant, having exhaustively characterized memory bandwidth bottlenecks in Phase 11, turns its attention to a new class of latency: the CPU-bound b_g2_msm computation that blocks the GPU worker from picking up its next job. This message is the design document for Phase 12's split API, a surgical intervention to hide ~1.7 seconds of post-GPU processing by restructuring the C++/Rust FFI boundary.

The Context: Why This Message Exists

To understand why this message was written, one must trace the optimization arc that preceded it. The assistant had just completed Phase 11, a set of three memory-bandwidth interventions targeting TLB shootdowns, L3 thrashing, and DDR5 contention. The best result was a 3.4% improvement (36.7 s/proof vs. 38.0 s baseline), achieved by reducing groth16_pool from 192 to 32 threads. But the user, unsatisfied with incremental gains, posed a direct question: could b_g2_msm — a ~1.7-second CPU multi-scalar multiplication on the G2 curve — be shipped to a separate thread to unblock the GPU worker more quickly?

This question landed in a mind already steeped in the pipeline's internals. The assistant had spent the previous messages ([msg 2834] through [msg 2840]) tracing the dependency chain inside generate_groth16_proofs_c, the monolithic C++ function that orchestrates the entire proof generation. It had discovered a crucial fact: b_g2_msm runs after the GPU mutex is released but before the function returns. The GPU worker in engine.rs calls this function synchronously, meaning it cannot loop back to pick up the next synthesized partition until b_g2_msm and the epilogue (final point additions that assemble the proof) complete. With per-partition proving (one circuit per call), the GPU kernel takes ~1.8 seconds, b_g2_msm takes ~1.7 seconds, and the epilogue is negligible (~1 ms). The worker's cycle is: acquire lock → GPU kernels → unlock → b_g2_msm + epilogue → loop. The ~1.7 seconds of b_g2_msm is dead time from the worker's perspective — it holds no GPU resources, yet it cannot proceed.

Timing data from the Phase 11 benchmark confirmed the problem. The assistant had extracted logs showing gpu_total_ms (the GPU kernel portion) ranging from 1,348 ms to 5,814 ms, while b_g2_msm_ms ranged from 1,521 ms to 4,569 ms. In many cases, b_g2_msm finished after the GPU kernels, meaning the worker was literally waiting for CPU computation while the GPU sat idle. The cold-start partition showed gpu_total_ms=5,814 vs. b_g2_msm_ms=4,569 (GPU was slower), but subsequent partitions showed the reverse: gpu_total_ms=1,348 vs. b_g2_msm_ms=1,521 — a 173 ms stall, or gpu_total_ms=1,759 vs. b_g2_msm_ms=2,069 — a 310 ms stall. These gaps, while individually small, compound across ten partitions per proof, and they represent a structural inefficiency: CPU post-processing is serialized on the GPU worker's critical path.

The Design Space: Three Approaches Considered

Message [msg 2849] opens with a meticulous catalog of the epilogue's dependencies. The assistant reads lines 990-1037 of groth16_cuda.cu and enumerates exactly what data the final proof assembly needs:

The Reasoning: Why This Design Wins

The assistant's choice reflects a deep understanding of the system's constraints. The critical insight is stated explicitly: "we don't need to split the C API. We just need the engine's GPU worker to not wait for b_g2_msm." The C++ function's internal structure is fine — the problem is that the Rust worker calls it synchronously. By introducing a two-phase API, the assistant decouples the caller's blocking behavior from the function's internal parallelism.

The design preserves several important properties:

  1. Memory stability: The handle struct in C++ holds all the state needed for finalization. By allocating it early (before GPU work begins), the assistant ensures that memory addresses referenced by GPU kernels remain valid even after the "start" function returns. This is critical because the GPU kernels may still be writing to device buffers that the handle's pointers reference.
  2. Thread safety: The b_g2_msm thread is spawned inside C++ and joined during finalization. The Rust side never touches it directly. This avoids cross-language thread management issues and keeps the FFI boundary simple.
  3. Error handling: The existing RustError return type is preserved. If the GPU kernel fails, the start function returns an error before any handle is created. If b_g2_msm fails, the finalize function returns the error.
  4. Minimal Rust refactoring: The GPU worker loop changes from a single synchronous call to a two-step pattern, but the result-processing logic (tracker updates, partition assembly, error handling) is preserved. The assistant later designs helper functions (process_partition_result, process_monolithic_result) to encapsulate this logic and avoid code duplication.

Assumptions and Potential Blind Spots

The design makes several assumptions worth examining:

That b_g2_msm is worth hiding. The assistant's timing analysis shows stalls of 173-310 ms per partition, but the overall throughput impact depends on whether the GPU worker would otherwise be waiting for synthesis jobs. If the synthesis pipeline produces partitions slower than the GPU worker consumes them, hiding b_g2_msm gains nothing — the worker would idle anyway. The assistant implicitly assumes that synthesis is not the bottleneck, or that the pipeline is balanced such that any reduction in GPU worker cycle time translates to throughput. The later benchmarking of Phase 12 would validate or refute this assumption.

That the handle's memory is safe. The groth16_pending_proof struct must be allocated with a stable address before GPU kernels start, because those kernels write to buffers that the handle references. If the handle is allocated on the stack or in a temporary that gets moved, dangling pointers result. The assistant recognizes this risk and designs for early allocation.

That the two-phase API doesn't introduce excessive complexity. The Rust engine worker loop, already complex with error handling, tracker updates, and partition assembly, now must manage pending handles and finalization tasks. The assistant mitigates this with helper functions, but the risk of subtle bugs (e.g., forgetting to finalize a handle on error) is real.

That the GPU unlock point is the right boundary. The "start" function returns after GPU unlock, but before b_g2_msm completes. This means the handle's b_g2 field is not yet populated. The finalize function must handle the case where b_g2_msm is still running (join the thread) or has already completed (fast path). The assistant doesn't explicitly discuss this race condition, but the thread join semantics handle it naturally.

The Thinking Process: A Window into Iterative Design

What makes this message remarkable is the visible thinking process. The assistant doesn't arrive at the split API immediately — it works through alternatives, discarding each for concrete reasons. The reasoning is structured as a series of refinements:

  1. Dependency mapping: "The epilogue (lines 990-1037) needs..." — a precise enumeration of what data flows where.
  2. First design thought: "The simplest split: instead of moving all of this to Rust, keep the epilogue in C++ but have the function return immediately after GPU unlock, with a handle to the pending b_g2_msm."
  3. Self-correction: "Actually, even simpler — let me think about what the user asked." The assistant re-centers on the user's framing: unblock the GPU worker, not redesign the C API.
  4. The cleanest approach crystallizes: A three-point plan emerges: (1) C++ returns opaque handle, (2) separate FFI call finalizes, (3) Rust worker calls first function, sends handle to finalizer thread, loops back.
  5. Implementation begins: "Let me implement this. The handle struct in C++ will hold everything needed for finalization" — followed by a read command to examine the C++ source. This pattern — analyze, propose, correct, refine, implement — is characteristic of expert systems design. The assistant is not just writing code; it's navigating a complex dependency graph with real-world constraints (FFI boundaries, memory safety, thread lifetimes) and making architectural decisions that balance performance against maintainability.

Input and Output Knowledge

To understand this message, the reader needs:

Conclusion

Message [msg 2849] is a masterclass in systems-level optimization design. It demonstrates how to identify a structural inefficiency (CPU post-processing serialized on the GPU worker's critical path), analyze its root cause (the synchronous FFI call), and design a minimally invasive fix (the two-phase split API). The assistant's thinking process — cycling through alternatives, testing each against the system's constraints, and settling on the approach that maximizes benefit while minimizing risk — is the kind of reasoning that separates effective optimization from mere tinkering.

The split API would go on to be implemented across C++, Rust FFI, and the engine worker loop in the subsequent chunks of Segment 29. Its effectiveness would be measured in the benchmarks that followed, but the design itself — captured in this single message — represents the moment the assistant saw the path forward. In a pipeline already squeezed for every microsecond, shaving ~1.7 seconds off the GPU worker's cycle time was not just an optimization; it was a re-architecting of how the proving engine thinks about work.