Phase 9 PCIe Transfer Optimization: A Deep Dive into GPU Memory Management, CUDA Pool Asymmetry, and the 14.2% Breakthrough

Introduction

In the relentless pursuit of faster Filecoin proof generation, the cuzk SNARK proving engine had already undergone eight optimization phases before reaching a critical plateau. Phase 8 had achieved GPU-boundedness at 37.4 seconds per proof, but careful analysis revealed two root causes of GPU idle gaps that no amount of scheduling optimization could fix: non-pinned host memory transfers were wasting PCIe bandwidth, and synchronous stalls in the Pippenger multi-scalar multiplication (MSM) engine were leaving the GPU idle while the CPU caught up. Phase 9 was designed to eliminate both.

This chunk of the conversation captures the complete arc of Phase 9: from the comprehensive handoff document that synthesized everything learned across eight prior phases ([msg 2368]), through the implementation of two ambitious CUDA-level changes, through a grueling debugging session that uncovered a subtle CUDA memory pool asymmetry bug, and finally to validation with a 14.2% throughput improvement. It is a masterclass in systems-level GPU optimization, spanning C++ memory management, CUDA stream programming, concurrent worker architecture, and the kind of methodical debugging that separates working systems from broken ones.

The Foundation: Eight Phases of Optimization

To appreciate what Phase 9 accomplished, one must understand the project it served. The Curio node is a Filecoin storage provider implementation that must periodically generate Proofs of Replication (PoRep) to prove it is storing data correctly. These proofs rely on Groth16 zk-SNARKs, which are computationally expensive to generate — requiring hundreds of gigabytes of polynomial data, multi-gigabyte Structured Reference String (SRS) files, and complex sequences of Number Theoretic Transforms (NTTs) and Multi-Scalar Multiplications (MSMs) on GPUs.

The optimization journey began with Phases 0–5, which established the baseline proving pipeline and identified the ~200 GiB peak memory footprint. Phase 6 introduced waterfall timeline instrumentation to measure exactly where GPU time was spent. Phase 7 implemented a per-partition dispatch architecture, where each partition of the proof is processed independently. Phase 8 introduced a dual-GPU-worker interlock system, narrowing a C++ static mutex to cover only the CUDA kernel region, allowing two GPU workers to share a single device more efficiently.

The Phase 8 benchmark results showed the system was perfectly GPU-bound at 37.4 seconds per proof, with GPU efficiency at 100.0% at the scheduling level. But the user had observed GPU power and utilization dips correlating with PCIe traffic bursts, suggesting that the GPU was not actually busy 100% of the time at the Streaming Multiprocessor (SM) level — it was stalling on data transfers. This observation became the foundation for Phase 9.

The Handoff Document: A Blueprint for Action

Message [msg 2368] stands as one of the most remarkable artifacts in the entire conversation. It is a 2,000+ word comprehensive status document that the assistant wrote before beginning Phase 9 implementation. The message serves multiple purposes simultaneously: it is a retrospective analysis of everything learned in Phases 0–8, a forward-looking plan for Phase 9, a context management tool for the assistant's own working memory, and a handoff document that any future reader could use to understand the project state.

The message's "Discoveries" section is particularly rich. It includes a detailed PCIe transfer inventory table that accounts for every byte transferred during proof generation — 23.6 GiB total per partition — classified by pinning status and synchronization pattern. This table is the foundation of the optimization strategy:

| Phase | Transfer | Size | Pinned? | Problem | |---|---|---|---|---| | NTT | a/b/c polynomials | 6 GiB | No (Rust Vec) | Staged through 32 MB bounce buffer, half bandwidth | | H MSM | H SRS points (8 batches) | 6 GiB | Yes | Per-batch gpu.sync() — 8 hard stalls | | Batch additions | L/A/B_G1/B_G2 SRS | 7.7 GiB | Yes | OK — already double-buffered | | Tail MSMs | L/A/B bases + scalars | 3.9 GiB | No (std::vector) | Non-pinned + per-batch sync stalls |

From this inventory, the assistant identified two high-impact changes. Change 1 (Tier 1) targeted the 6 GiB non-pinned a/b/c polynomial upload — the largest single transfer with the worst bandwidth penalty. Change 2 (Tier 3) targeted the 8 per-batch sync stalls in the H MSM — the most frequent stall events. The assistant explicitly chose not to fix the tail MSM transfers in this phase, a pragmatic engineering decision to prioritize highest-impact, lowest-risk changes first.

The handoff document also includes a VRAM budget analysis, a detailed description of the Phase 8 architecture, benchmark data, git commit history, a prioritized TODO list, and references to every file that needs modification. It is, in essence, a complete engineering design document produced by an AI assistant as a natural part of the coding workflow.

The Implementation: Two Changes, Two Streams

The implementation of Phase 9 proceeded through a series of carefully orchestrated edits. Change 1 required modifying the GPU mutex region in groth16_cuda.cu to move the polynomial upload out of the critical path. The assistant introduced a pre-staging phase that runs before the mutex acquisition: host memory pages are pinned with cudaHostRegister, device-side buffers (d_a at 4 GiB and d_bc at 8 GiB) are allocated, and cudaMemcpyAsync transfers are issued on a dedicated upload stream with CUDA event-based synchronization. Inside the mutex, the code waits for the upload events to complete before launching the NTT kernel, but the actual transfer latency has been removed from the critical section.

The implementation required adding a new function, execute_ntt_msm_h_prestaged(), that accepts pre-allocated device pointers rather than allocating them internally. This function was carefully stitched into the existing code path, with fallback logic that preserves the original behavior if pre-staging fails.

Change 2 targeted the Pippenger MSM in pippenger.cuh. The existing code issued a hard gpu.sync() after each batch to ensure that device-to-host (DtoH) transfers of bucket results completed before the CPU collect() function read them. The assistant introduced double-buffered host result buffers (res_buf[2] and ones_buf[2]) and deferred the sync() call to the next iteration. This allowed GPU compute for batch N to overlap with the DtoH transfer of batch N-1's results, eliminating the per-batch stall.

Both changes required deep knowledge of CUDA programming models — stream semantics, event synchronization, pinned memory, and the subtle distinction between synchronous and asynchronous memory transfers. The assistant demonstrated this knowledge throughout the implementation, making precise edits to C++ CUDA code that spanned multiple files and functions.

The OOM Crisis: When Optimizations Collide with Reality

The initial dual-worker benchmark with gpu_workers_per_device=2 and concurrency=3 failed catastrophically. The first partition completed in a blistering 1,588 milliseconds — a 57.6% improvement over the Phase 8 baseline of 3,746 ms — but every subsequent partition crashed with out-of-memory (OOM) errors. Even the fallback path, which avoided pre-staging entirely, also failed. The GPU was left with 10 GiB of leaked memory after the failed runs.

Message [msg 2440] captures the assistant's response to this crisis. It is a masterclass in systems-level debugging, proceeding through a series of increasingly refined hypotheses:

Hypothesis 1: The pre-staging allocation is too large. The assistant computed exact memory sizes: d_a at 4 GiB, d_bc at 8 GiB, totaling 12 GiB on a 16 GiB GPU. But tracing the lg2 computation confirmed the domain size was correct — 2^27 = 134,217,728 elements for the PoRep circuit. The allocation size was expected.

Hypothesis 2: The cleanup order is wrong. The assistant traced the C++ code and discovered that d_bc was freed after the mutex release. When Worker 1 acquired the mutex, Worker 0's 8 GiB d_bc was still allocated. Worker 1 then tried to allocate its own 12 GiB, totaling 20 GiB on a 16 GiB GPU. The fix was to free d_bc before releasing the mutex, and even earlier — inside the per-GPU worker thread, right after the NTT phase completed.

Hypothesis 3: Host memory registration conflicts between workers. Both workers, processing different partitions of the same proof, attempted to pin the same host memory pages with cudaHostRegister. The second call returned cudaErrorHostMemoryAlreadyRegistered (error 712), causing Worker 1 to skip pre-staging and fall back to the original path. But the fallback path also failed, indicating a deeper issue.

Hypothesis 4: CUDA memory pool asymmetry. This was the breakthrough. The gpu.Dmalloc and gpu.Dfree functions in the supraseal-c2 codebase use cudaMallocAsync and cudaFreeAsync, which operate on CUDA memory pools. These pools cache freed memory and do not release it back to the synchronous cudaMalloc pool used by dev_ptr_t and the pre-staging allocator. When the first partition's MSM freed its ~385 MiB of buckets via cudaFreeAsync, that memory went into the async pool — invisible to the next partition's cudaMalloc call. The second partition saw a GPU that appeared to have ~14 GiB free (as reported by nvidia-smi) but the synchronous allocator could only see what remained in its own pool, which might be far less.

This diagnosis, detailed in [msg 2447], is a remarkable piece of reasoning. The assistant had to pierce the abstraction layer of CUDA's memory management API, understanding that cudaMallocAsync and cudaMalloc draw from separate pools, and that memory freed via the async path is not returned to the synchronous pool. This is not a bug in the application code — it is a fundamental property of the hardware abstraction layer that the code was unknowingly relying on.

The Memory-Aware Allocator: A Pivot in Design

The user's intervention at [msg 2445] was concise but crucial: "Immediate OOM - perhaps we want to memory-manage the early copies such that they only copy as much memory as we can actually allocate? With some 512MiB buffer left spare for other host processes." This suggestion reframed the problem. Instead of trying to force the full pre-staging allocation to work, the solution was to make the pre-staging adaptive.

Message [msg 2449] captures the assistant's redesign of the pre-staging allocation. The message lists four key insights:

  1. Use cudaMemGetInfo to check actual free VRAM — query the CUDA runtime for available memory at the moment of allocation, rather than assuming a fixed amount.
  2. Calculate precise memory requirementsd_a requires domain_size * 32 bytes (4 GiB for a 2^27 domain), d_bc requires domain_size * (1 + lot_of_memory) * 32 bytes (8 GiB), plus a 512 MiB safety margin.
  3. Implement a three-tier fallback strategy — if both d_a and d_bc fit, pre-stage both (optimal); if only d_a fits, pre-stage just d_a (partial optimization); if neither fits, fall back entirely to the original non-pre-staged path.
  4. Release the CUDA memory pool cache before checking — call cudaDeviceSynchronize() and trim the memory pool before the pre-staging check, ensuring the free memory query reflects what is actually available to synchronous cudaMalloc calls. The assistant also moved the pre-staging allocation inside the GPU mutex to serialize it across workers, preventing both workers from trying to allocate 12 GiB simultaneously. And it freed d_bc immediately after the NTT phase (before the mutex release) to minimize peak memory pressure.

The Validation: 14.2% Throughput Improvement

With the memory-aware allocator in place, the assistant ran the single-worker benchmark. Message [msg 2457] captures the moment of validation:

All 3 proofs COMPLETED! And the prove time is 32.1s average vs the Phase 8 baseline of 37.4s — that's a 14.2% improvement!

The detailed GPU timing logs confirmed the kernel-level gains:

| Metric | Phase 8 | Phase 9 | Delta | |---|---|---|---| | ntt_msm_h_ms | ~2430 ms | ~690 ms | -71.6% | | batch_add_ms | ~640 ms | ~650 ms | ~same | | tail_msm_ms | ~125 ms | ~82 ms | -34.4% | | gpu_total_ms | ~3746 ms | ~1450–1900 ms | -50–61% |

The NTT+MSM phase — the dominant GPU kernel — had been cut by over 70%. The tail MSM showed a 34% improvement from the deferred-sync double-buffering change. The batch_add metric was unchanged, as expected, since that phase was not a target of this optimization. Overall GPU time per partition was slashed by half to 61%, depending on the partition.

The memory-aware allocator logged its operation: prestage_vram free_mib=13884 usable_mib=13372 — detecting 13.9 GiB free, reserving a 512 MiB safety margin, and reporting 13.4 GiB usable. The prestage_setup=ok status confirmed no fallback was needed.

The Dual-Writer Benchmark: Revealing the Next Bottleneck

The assistant then pivoted to the full production benchmark with gpu_workers_per_device=2 and concurrency=3. This benchmark completed successfully, yielding a system throughput of 41.0 seconds per proof. While this is higher than the single-worker 32.1 seconds, it represents a successful validation of the dual-worker configuration that had previously crashed with OOM errors.

The gap between single-worker and dual-worker throughput revealed something important: PCIe bandwidth contention, CPU-side processing, or daemon scheduling are now the limiting factors. The GPU kernel times are dramatically improved — gpu_total_ms around 1,616 ms per partition, ntt_msm_h_ms between 588–855 ms, tail_msm_ms around 90 ms — but the end-to-end throughput under concurrency tells a different story. The assistant immediately pivoted to examining detailed timing logs to identify the next layer of bottlenecks.

The Thinking Process: What This Chunk Reveals

Across the 91 messages in this chunk, a consistent thinking process emerges. The assistant operates in a structured, iterative cycle: analyze the current state, design a targeted change, implement it with precision, test it, diagnose failures, and iterate. Each message builds on the previous one, creating a chain of reasoning that is remarkably transparent.

The handoff document ([msg 2368]) reveals the assistant's ability to synthesize vast amounts of information into a coherent plan. The PCIe transfer inventory table, the VRAM budget calculation, the prioritized TODO list — these are not just documentation; they are reasoning artifacts that show the assistant working through the optimization problem systematically.

The debugging messages ([msg 2440] through [msg 2447]) reveal a methodical approach to failure diagnosis. The assistant does not guess at the cause — it traces through every allocation and deallocation path, verifies reference counting, checks destructor ordering, and considers edge cases like CUDA context corruption after errors. Each hypothesis is tested against the evidence, and the assistant maintains multiple hypotheses simultaneously, letting the evidence narrow the field.

The redesign message ([msg 2449]) reveals the assistant's ability to pivot from debugging to architectural design. The four insights are not arbitrary; they form a logical progression: check memory, compute needs, decide allocation strategy, ensure accurate measurement. The assistant then grounds the plan in the actual codebase by reading the relevant file before making edits.

The validation messages ([msg 2457], [msg 2458]) reveal the assistant's discipline in verification. The headline number (14.2% improvement) is celebrated, but the assistant immediately digs into the detailed timing logs to confirm the kernel-level gains and check for unexpected regressions. The unchanged batch_add metric is noted as confirmation that the optimization was correctly scoped.

Knowledge Inputs and Outputs

This chunk draws on an extraordinary range of knowledge domains: CUDA programming models (streams, events, pinned memory, memory pools), GPU architecture (PCIe bandwidth, SM utilization, copy engine independence), SNARK proving (Groth16, NTT, MSM, Pippenger's algorithm), C++ memory management (RAII, smart pointers, lambda capture semantics), concurrent programming (mutexes, barriers, worker threads), and systems performance analysis (benchmark methodology, bottleneck identification, instrumentation).

The chunk produces equally valuable outputs: a validated 14.2% throughput improvement, a memory-aware allocator design that can be reused in future phases, a documented CUDA memory pool interaction pattern that explains phantom OOM failures, and a methodology for debugging concurrent GPU systems that future engineers can follow.

Conclusion

Phase 9 of the cuzk SNARK proving engine optimization represents a significant milestone in the project's trajectory. The 14.2% throughput improvement from 37.4 to 32.1 seconds per proof is meaningful, but the real value of this phase lies in what it revealed about the system. The CUDA memory pool asymmetry bug, the cleanup ordering issue, the host registration conflicts — each of these discoveries deepens the team's understanding of how GPU memory management works in a concurrent, multi-worker environment.

The chunk also demonstrates a particular style of engineering work that is characteristic of the best systems optimization: measure everything, identify the bottleneck, trace it to specific code paths, design targeted mitigations, implement with precision, test under realistic conditions, diagnose failures systematically, and iterate. The assistant's ability to produce a comprehensive handoff document, implement complex CUDA changes, debug subtle memory pool interactions, and validate results with rigorous benchmarking is a testament to the power of structured, methodical reasoning in software engineering.

The story does not end here. The dual-worker benchmark revealed that PCIe bandwidth contention is the next bottleneck, and the assistant has already pivoted to analyzing detailed timing logs to understand it. Phase 10 awaits. But Phase 9 has delivered its breakthrough, and the path forward is clearer than ever.## References

[1] "The Handoff Document: How a Comprehensive Status Message Bridges GPU Optimization Phases in a Filecoin SNARK Proving Engine" — analysis of message [msg 2368], the comprehensive status document that synthesized eight phases of optimization and planned Phase 9.

[73] "The Anatomy of a GPU Memory Crisis: Debugging Dual-Worker OOM in Phase 9 of the cuzk SNARK Proving Engine" — analysis of message [msg 2440], the assistant's methodical debugging of the OOM cascade failure.

[80] "Diagnosing the Phantom OOM: How CUDA Memory Pool Asymmetry Nearly Derailed Phase 9" — analysis of message [msg 2447], the breakthrough diagnosis of CUDA cudaMallocAsync/cudaMalloc pool asymmetry.

[82] "The Memory-Aware Pre-Staging Redesign: A Pivotal Debugging Synthesis in Phase 9 of the cuzk SNARK Engine" — analysis of message [msg 2449], the redesign of the pre-staging allocator with three-tier fallback.

[90] "The 14.2% Breakthrough: Validating Phase 9 PCIe Transfer Optimization for Groth16 Proving" — analysis of message [msg 2457], the validation benchmark showing 32.1 s/proof.

[91] "A Milestone in GPU Proving Optimization: Validating Phase 9's PCIe Transfer Optimizations" — analysis of message [msg 2458], the detailed comparison table showing 71.6% NTT+MSM reduction.