Breaking Through the GPU-Bound Plateau: Phase 9's PCIe Transfer Optimization Plan for the cuzk SNARK Proving Engine

Introduction

In the relentless pursuit of Filecoin proof-of-replication (PoRep) throughput, the cuzk SNARK proving engine had reached an intriguing impasse. After eight phases of optimization—spanning parallel synthesis, per-partition dispatch, dual-GPU-worker interlock, and systematic parameter sweeps—the system had achieved a state of perfect GPU-boundedness: the measured 37.4 seconds per proof exactly matched the serial CUDA kernel time of 10 partitions multiplied by 3.75 seconds per partition. Cross-sector GPU transitions after warmup were under 50 milliseconds, and CPU-side synthesis was fully overlapped with GPU work. There was, seemingly, nothing left to optimize on the CPU side.

Yet the observant engineer noticed something puzzling: slight dips in GPU utilization and power consumption that correlated with bursts of PCIe traffic reaching 50 GB/s. These dips represented hidden inefficiency—moments when the GPU's streaming multiprocessors (SMs) went idle while data shuffled across the PCIe bus. If those transfers could be moved outside the critical path, the GPU-bound plateau could be broken.

This article examines message [msg 2355], the pivotal moment where the assistant synthesized an exhaustive investigation into the PCIe transfer inventory inside the GPU mutex and produced a detailed two-tier optimization plan for what would become Phase 9 of the cuzk project. The message represents a masterclass in GPU performance analysis: from raw observation to root-cause diagnosis to a concrete, implementable plan with quantified impact estimates.

The Path to Discovery: From TIMELINE Analysis to PCIe Inventory

The story begins in the preceding messages of segment 25. The assistant had just completed a deep TIMELINE analysis of the Phase 8 benchmark with partition_workers=10, the optimal setting discovered after a systematic sweep from 10 to 20 workers. The TIMELINE analysis was a watershed moment: it confirmed that the system was perfectly GPU-bound, with the measured throughput matching the serial CUDA kernel time exactly. This meant that every CPU-side optimization that could be done had been done—synthesis was fully overlapped, GPU transitions were negligible, and the only remaining lever was the GPU kernel region itself.

The user's observation in [msg 2333] was the catalyst: "One thing I still see is there are slight dips in GPU util/power correlated to large pci traffic (50GB/s rx, also large tx sometimes), can those moves be moved outside the 'gpu compute' semaphore?" This question triggered a multi-message investigation spanning the entire CUDA kernel region.

The assistant launched a subagent task in [msg 2334] to analyze the PCIe transfers inside generate_groth16_proofs_c, then proceeded to read the relevant source files: groth16_cuda.cu, groth16_ntt_h.cu, groth16_split_msm.cu, and the pippenger MSM implementation in sppark/msm/pippenger.cuh. Each file revealed another piece of the puzzle. The assistant traced the HtoD calls, examined the gpu_t stream infrastructure, checked VRAM capacity with nvidia-smi, and even verified the cudaMemcpyAsync implementation to understand how non-pinned memory is handled.

The result was the comprehensive inventory presented in [msg 2350]: 23.6 GiB of host-to-device (HtoD) transfers per partition inside the GPU mutex, broken down by phase. The two root causes were:

  1. Non-pinned host memory for a/b/c polynomials: 6 GiB uploaded through CUDA's ~32 MB pinned bounce buffer, effectively halving PCIe bandwidth
  2. Per-batch hard sync stalls in the Pippenger MSM: 8+ synchronizations per partition where the GPU idles while the CPU processes bucket results The assistant proposed three tiers of mitigation and asked the user which to pursue. The user's answer—"1 + 3-pipeline"—set the stage for the detailed plan in our subject message.

The Subject Message: A Blueprint for Phase 9

Message [msg 2355] is the assistant's response to the user's scope confirmation. It is a meticulously structured engineering plan that transforms the root-cause analysis into concrete code changes. The message opens with a key insight about the CUDA stream infrastructure:

Key insight: gpu_t has 3 flip-flop streams (gpu[0], gpu[1], gpu[2]) plus a zero stream. The pippenger MSM uses gpu[0]/gpu[1] for double-buffered compute+points, and gpu[2] for scalar processing. We can potentially use the zero stream or create an additional stream for pre-staging.

This observation is critical because it reveals an architectural affordance: the existing stream infrastructure already supports overlapping data movement with computation. The pippenger MSM uses gpu[2] for scalar digit-sorting while gpu[0]/gpu[1] handle the main compute pipeline. The zero stream (or a new stream) could serve as a dedicated copy engine for pre-staging a/b/c data before the mutex is even acquired.

Tier 1: Pre-upload a/b/c Polynomials

The Tier 1 proposal addresses the first root cause: the 6 GiB of non-pinned polynomial data uploaded inside the mutex. The assistant presents the current flow and proposed flow side by side, using ASCII-art pseudocode to make the transformation vivid.

Current flow (inside mutex):

[mutex acquired]
  gpu.Dmalloc(d_a)              // allocate 2 GiB on device
  for each circuit:
    execute_ntt_msm_h(gpu, d_a, provers[c], ...)
      stream.HtoD(d_a, input.a)  // 2 GiB non-pinned upload
      NTT(d_a)
      stream.HtoD(d_b, input.b)  // 2 GiB non-pinned upload
      NTT(d_b)
      stream.HtoD(d_c, input.c)  // 2 GiB non-pinned upload
      NTT(d_c)
      ...
[mutex released]

Proposed flow:

// BEFORE mutex — overlap with other worker's CUDA kernels
cudaHostRegister(input.a, size, cudaHostRegisterDefault)
cudaHostRegister(input.b, size, cudaHostRegisterDefault)
cudaHostRegister(input.c, size, cudaHostRegisterDefault)
gpu.Dmalloc(d_a)               // 2 GiB device alloc
gpu.Dmalloc(d_b)               // 2 GiB device alloc (need separate)
stream_copy.HtoD(d_a, input.a) // async copy on copy stream
stream_copy.HtoD(d_b, input.b) // async copy on copy stream
stream_copy.HtoD(d_c, input.c) // c needs temp buffer too, or share d_b space

[mutex acquired]
  // d_a, d_b, d_c already on GPU — NTT starts immediately
  for each circuit:
    execute_ntt_msm_h_preloaded(gpu, d_a, d_b, d_c, ...)
      wait(copy_event)     // ensure upload done
      NTT(d_a), NTT(d_b), NTT(d_c)
      ...
[mutex released]
cudaHostUnregister(input.a)
cudaHostUnregister(input.b)
cudaHostUnregister(input.c)

The key technical insight here is the use of cudaHostRegister to pin the Rust-allocated Vec<Fr> memory pages for DMA without copying. This is a zero-copy registration: the existing pages become page-locked, allowing cudaMemcpyAsync to achieve full PCIe bandwidth without staging through CUDA's internal bounce buffer. The cost is minimal—approximately 1-5 milliseconds for 2 GiB of registration.

The assistant also considers VRAM budgeting, a critical constraint given the RTX 5070 Ti's 16 GiB capacity:

d_a: 2 GiB, d_b: 2 GiB, d_c: 2 GiB = 6 GiB pre-allocated before mutex During NTT+MSM: d_a reused as MSM scalars (2 GiB), MSM buckets (~205 MiB) batch_addition: ~550 MiB per call Total peak: ~8-9 GiB inside 16 GiB = feasible

Two complications are identified: the loop structure with num_circuits=1 (straightforward for per-partition dispatch), and the need to ensure pre-upload happens on the correct GPU device context. These are not blockers but implementation details that must be handled correctly.

Tier 3: Async SRS Pipelining in Pippenger

The Tier 3 proposal tackles the second root cause: the per-batch hard sync stalls in the Pippenger MSM. This is a more subtle problem. The SRS points themselves are already in pinned memory (they're loaded from disk-mapped files), so they achieve full PCIe bandwidth. The issue is architectural: the pippenger MSM processes points in 8 batches, and each batch ends with a hard gpu.sync() that stalls the GPU until the CPU has finished its collect() processing of the previous batch's bucket results.

The assistant dissects the current pattern:

for batch i = 0..7:
  gpu[i&1].wait(ev)           // wait for digit sorting
  batch_addition kernel        // GPU compute
  accumulate kernel            // GPU compute  
  integrate kernel             // GPU compute
  
  // Pipeline next batch (if not last):
  gpu[2].HtoD(d_scalars)      // scalars for next batch
  gpu[2].wait(ev)
  digits kernel                // digit sorting
  gpu[j].HtoD(d_points[j])    // points for next batch (double-buffered)
  
  // Synchronize current batch:
  if (i > 0) collect(p, res, ones)  // CPU: reduce previous batch's buckets
  gpu[i&1].DtoH(ones, ...)    // small DtoH
  gpu[i&1].DtoH(res, ...)     // small DtoH
  gpu[i&1].sync()             // HARD SYNC — GPU idle until this completes

The hard sync at the end of each batch creates a predictable idle gap: the GPU finishes compute and DtoH, then waits while the CPU does collect() and issues the next batch's kernels. The assistant's proposed fix is elegant—deferred collect using double-buffered host result buffers:

for batch i = 0..7:
  gpu[i&1].wait(ev)
  batch_addition, accumulate, integrate kernels
  
  // Pipeline next batch upload (overlaps with current compute):
  if (i < batch-1):
    gpu[2].HtoD(d_scalars)
    digits kernel
    gpu[j].HtoD(d_points[j])
  
  gpu[i&1].DtoH(ones_buf[i&1], ...)  // DtoH to double-buffered host
  gpu[i&1].DtoH(res_buf[i&1], ...)   // DtoH to double-buffered host
  gpu[i&1].record(ev_batch[i&1])      // record event instead of sync
  
  if (i > 0):
    gpu[(i-1)&1].sync()  // sync PREVIOUS batch (already done by now)
    collect(p, res_buf[(i-1)&1], ones_buf[(i-1)&1])
    out.add(p)

The key insight: instead of synchronizing the current batch's results before proceeding, the code records a CUDA event and synchronizes the previous batch's results while the current batch's compute is still running. The double-buffered host buffers (ones_buf[0]/ones_buf[1], res_buf[0]/res_buf[1]) allow the GPU to write results for batch N to buffer slot N&1 while the CPU reads results for batch N-1 from slot (N-1)&1. Since the DtoH transfers are tiny (~1.5 MiB) compared to the compute time per batch, the sync for the previous batch should complete well before it's needed.

The assistant honestly flags the risk: this is a change to sppark/msm/pippenger.cuh, which is a third-party dependency from Supranational's sppark library. However, the change is localized to the invoke() method and is backward-compatible—same API, same numerical results, just different scheduling.

Expected Impact

The assistant closes with quantified estimates:

Assumptions Embedded in the Plan

Every engineering plan rests on assumptions, and this message is no exception. Several are worth examining:

Assumption 1: The GPU idle dips are caused by PCIe traffic, not by other factors. The assistant assumes a causal relationship between the observed 50 GB/s PCIe RX and the GPU utilization/power dips. This is a reasonable inference given the correlation, but it's worth noting that GPU power dips can also be caused by workload imbalance across SMs, memory bandwidth saturation, or thermal throttling. The assistant's analysis of the transfer inventory provides strong circumstantial evidence, but the assumption is validated only after implementation and benchmarking.

Assumption 2: cudaHostRegister overhead is negligible (~1-5ms for 2 GiB). This is based on typical CUDA driver behavior, but actual overhead depends on the memory allocator, page size, and system load. On a production system with fragmented memory, registration could take longer. The assistant implicitly assumes a well-behaved memory system.

Assumption 3: The copy engine is independent of the compute engine on the RTX 5070 Ti. Modern NVIDIA GPUs have separate copy engines (for PCIe) and compute engines (for SMs), so async copies on a dedicated stream should overlap with compute on another stream. However, the degree of overlap depends on the specific GPU architecture (Blackwell in this case) and the PCIe topology. The assistant assumes full overlap, which may not be achievable in practice.

Assumption 4: VRAM budgeting works out. The assistant calculates 8-9 GiB peak inside 16 GiB, leaving a comfortable margin. But this assumes no fragmentation, no other concurrent GPU workloads, and no unexpected allocations from the CUDA driver or other libraries. On a production system running multiple proof generations, VRAM pressure could be higher.

Assumption 5: The pippenger deferred collect pattern is backward-compatible. The assistant asserts that the change produces the same numerical results because the bucket reduction is associative. This is true for the mathematical operation, but CUDA's floating-point arithmetic is not strictly associative. The order of accumulation could produce slightly different results, though for cryptographic proofs this is typically acceptable as long as the result is deterministic.

Assumption 6: The user will implement the plan as described. The message ends with a question asking the user to confirm the plan. The assistant assumes the user has the expertise to implement these CUDA-level changes correctly. Given that the user has been driving the optimization effort through eight prior phases, this is a reasonable assumption.

Input Knowledge Required to Understand This Message

To fully grasp the significance of message [msg 2355], a reader needs familiarity with several domains:

CUDA programming model: Understanding of streams, events, cudaMemcpyAsync, cudaHostRegister, cudaMalloc, device pointers, and the distinction between pinned and non-pinned host memory. The concept of CUDA's internal bounce buffer for non-pinned transfers is particularly important.

GPU architecture: Knowledge of the separation between copy engines and compute engines on modern NVIDIA GPUs, the role of PCIe bandwidth in GPU performance, and the characteristics of streaming multiprocessors (SMs).

Groth16 proof generation: Understanding of the NTT (Number Theoretic Transform) and MSM (Multi-Scalar Multiplication) operations that dominate SNARK proving time, and how they map to GPU kernels.

The cuzk project architecture: Familiarity with the per-partition dispatch model (Phase 7), the dual-GPU-worker interlock (Phase 8), and the mutex-protected CUDA kernel region in generate_groth16_proofs_c.

Pippenger's algorithm: Understanding of batched MSM computation, bucket accumulation, and the trade-off between batch size and memory usage.

Filecoin PoRep: Knowledge that a 32 GiB sector is split into 10 partitions, each requiring a separate Groth16 proof, and that the SRS (Structured Reference String) is a large (~13.7 GiB) set of elliptic curve points loaded from disk.

Without this background, the message's technical details—"double-buffered host result buffers," "flip-flop streams," "batch sync stalls"—would be opaque. The assistant's clear pseudocode and explanatory prose help bridge the gap, but the reader still needs a solid foundation in GPU programming.

Output Knowledge Created by This Message

Message [msg 2355] creates several forms of knowledge that persist beyond the conversation:

1. A documented optimization plan for Phase 9. The message itself becomes the blueprint for implementation. It specifies which files to modify (groth16_cuda.cu, groth16_ntt_h.cu, sppark/msm/pippenger.cuh), what changes to make, and the expected impact. This plan was subsequently formalized in c2-optimization-proposal-9.md and committed to the repository.

2. A methodology for diagnosing PCIe-related GPU stalls. The assistant's approach—starting from a user observation of GPU power dips, correlating with PCIe traffic, then building a complete transfer inventory by tracing every HtoD call inside the mutex—is a reusable diagnostic technique. Future performance investigations can follow the same pattern.

3. Quantified understanding of the transfer profile. The inventory of 23.6 GiB HtoD per partition, broken down by phase (NTT+H MSM, batch additions, tail MSMs) and by pinning status, is a valuable reference. It reveals that non-pinned transfers account for ~8.9 GiB (a/b/c + tail bases/scalars) and pinned transfers account for ~14.7 GiB (SRS points).

4. The deferred collect pattern for Pippenger MSM. This is a novel scheduling optimization that could be applied to any batched MSM implementation, not just the one in cuzk. The insight that hard syncs can be replaced with double-buffered result buffers and deferred synchronization is generalizable.

5. VRAM budget validation. The assistant's calculation that 8-9 GiB peak fits within 16 GiB VRAM provides confidence that the plan is feasible without memory pressure.

6. Risk assessment for third-party dependency changes. The assistant explicitly flags that modifying sppark/msm/pippenger.cuh is a change to a third-party dependency, and argues that it's localized and backward-compatible. This risk assessment helps the user make an informed decision about whether to proceed.

The Thinking Process: A Window into GPU Performance Engineering

What makes message [msg 2355] particularly valuable is the visible thinking process. The assistant doesn't just present a plan—it shows how it arrived at the plan through a chain of reasoning that exemplifies expert GPU performance engineering.

From observation to hypothesis. The chain begins with the user's raw observation: GPU power dips correlate with PCIe traffic. The assistant doesn't take this at face value but uses it as a starting point for investigation. The hypothesis is that PCIe transfers inside the mutex are causing GPU idle, and the null hypothesis is that the system is already optimal (GPU-bound).

Building the evidence base. The assistant systematically reads source files, traces HtoD calls, calculates sizes, checks pinning status, and builds a complete inventory. This is detective work: each file read reveals another piece of the puzzle, and the assistant cross-references between files to ensure completeness.

Quantifying the problem. The inventory is not just a list—it's a quantified breakdown with sizes, pinning status, and phase attribution. The assistant knows that "non-pinned" is the key qualifier because it halves effective bandwidth. The 6 GiB of non-pinned a/b/c transfers are the biggest single target.

Designing the fix with architectural awareness. The Tier 1 fix uses cudaHostRegister rather than allocating new pinned buffers or modifying the Rust memory allocator. This choice reflects architectural awareness: changing bellperson/PCE's memory allocation (Option C) would require changes across the entire Rust-C++ boundary, while cudaHostRegister is a localized CUDA API call that pins existing pages in place. The assistant correctly identifies this as the "cleanest" option.

Leveraging existing infrastructure. The Tier 3 fix doesn't introduce new streams or complex synchronization primitives. It leverages the existing double-buffered stream pattern (gpu[0]/gpu[1]) and adds double-buffered host result buffers. The record/sync pattern using CUDA events is already well-established in the codebase. The assistant works with the grain of the existing architecture rather than against it.

Honest about limitations. The assistant doesn't oversell the plan. The impact estimates are presented as ranges (200-400ms for Tier 1, 50-200ms for Tier 3), and the combined improvement is 7-16%—significant but not transformative. The risk of modifying a third-party dependency is acknowledged. This honesty builds trust and helps the user make an informed decision.

Potential Mistakes and Incorrect Assumptions

While the plan is well-reasoned, several aspects warrant critical examination:

The 50 GB/s PCIe RX measurement may be misleading. The user reported 50 GB/s RX, but PCIe Gen4 x16 has a theoretical maximum of ~32 GB/s in each direction. The 50 GB/s figure likely represents a combination of RX and TX traffic, or it may be a measurement artifact from nvidia-smi or similar tools. If the actual PCIe bandwidth is lower than assumed, the impact of Tier 1 may be less than estimated.

The cudaHostRegister approach has subtle implications. Registering Rust Vec memory with CUDA pins the pages, which means they cannot be swapped out or migrated by the kernel. On a system with limited physical memory, this could reduce overall system performance or cause OOM conditions under memory pressure. The assistant assumes the system has sufficient physical RAM to pin 6 GiB without impact.

The deferred collect pattern may introduce latency variability. In the current pattern, the sync() at the end of each batch guarantees that results are on the host before the next iteration's collect(). In the deferred pattern, the sync for batch N-1 happens during batch N's compute. If batch N's compute completes unusually quickly (due to workload variation), the sync may not have finished, causing a stall later. The assistant assumes compute time is consistent enough that the overlap is always sufficient.

The impact estimate for Tier 3 may be optimistic. The assistant estimates 50-200ms per partition saved, but the per-batch sync gaps may already be partially overlapped with the next batch's upload pipeline. The current code already does some pipelining (uploading next batch's scalars and points while the current batch computes). The hard sync may not add as much idle time as assumed.

The VRAM budget may be tighter than calculated. The assistant's calculation of 8-9 GiB peak assumes ideal conditions. In practice, CUDA driver overhead, memory alignment requirements, and fragmentation could increase actual usage. Additionally, the cudaHostRegister call may trigger implicit device memory allocations for mapping tables.

The plan assumes the user can implement these changes correctly. The assistant provides detailed pseudocode but doesn't verify that the user has the necessary CUDA expertise. Given the complexity of the changes (especially the pippenger deferred collect pattern), implementation errors could introduce subtle correctness bugs in the proof generation.

Conclusion

Message [msg 2355] represents a pivotal moment in the cuzk optimization journey. After eight phases of progressively deeper optimization—from parallel synthesis to per-partition dispatch to dual-GPU-worker interlock—the system had reached a GPU-bound plateau. The observation of PCIe-correlated GPU power dips opened a new frontier: the PCIe transfer pattern inside the GPU mutex.

The assistant's response is a masterclass in GPU performance engineering. It transforms a qualitative observation ("GPU dips correlate with PCIe traffic") into a quantitative inventory (23.6 GiB HtoD per partition), identifies two root causes (non-pinned memory and batch sync stalls), and designs a two-tier mitigation plan with concrete code changes, VRAM budgeting, and impact estimates.

The plan's elegance lies in its architectural alignment: Tier 1 uses cudaHostRegister to pin existing memory without copying, working with the existing allocation pattern rather than against it. Tier 3 uses double-buffered host result buffers to defer synchronization, leveraging the existing stream infrastructure. Both changes are localized and backward-compatible.

The message also demonstrates the value of the assistant's investigative methodology: start with a user observation, build a complete evidence base through systematic source code analysis, quantify the problem, design fixes that work with the existing architecture, and present honest impact estimates with acknowledged risks.

Whether the plan delivers its estimated 7-16% improvement depends on implementation fidelity and the accuracy of its assumptions. But the thinking process captured in this message—the transition from observation to hypothesis to evidence to design—is itself a valuable artifact, documenting how expert GPU performance engineering is done in practice.