The Screenshot That Uncovered a PCIe Bottleneck: How a Single User Observation Drove the Root-Cause Analysis of GPU Underutilization
In the midst of a deep investigation into GPU underutilization in a zero-knowledge proof proving pipeline, a single user message arrived that would reframe the entire inquiry. At message index 3004 in the conversation, the user wrote:
started of chaining nicely even with 20ish syths running with likely huge memory pressure, but seeing bunch of gaps now
Attached was a screenshot (@2026-03-13-191721_2191x636_scrot.png) showing a GPU utilization monitor. This brief observation — just 18 words and an image — became the turning point that transformed a hunt for software-level contention into a discovery about PCIe memory bandwidth physics.
The Context: A Long-Running Performance Investigation
To understand why this message mattered, one must appreciate the investigation that preceded it. The team had been chasing a persistent ~50% GPU utilization problem in their cuzk proving daemon, a system that generates Groth16 proofs for Filecoin's proof-of-spacetime consensus. The system used two GPU workers sharing a single GPU, designed to interleave PCIe data transfers with GPU computation — Worker A would transfer data while Worker B computed, theoretically keeping the GPU saturated.
But the GPU was idle roughly half the time. The initial suspects were familiar culprits in Rust async systems: lock contention on the StatusTracker, overhead from malloc_trim calls that release memory back to the OS, and tokio thread-pool starvation. The assistant had spent the preceding rounds instrumenting these paths with precise timing (GPU_TIMING, FIN_TIMING), building Docker images, deploying binaries to a remote machine with 755 GiB of RAM and an NVIDIA GPU, and waiting for steady-state operation.
By message 3003, the assistant had deployed a timing-instrumented binary and waited three minutes for the system to reach a steady state. The user then responded with message 3004 — and with it, a screenshot that showed something the Rust-side timing could never reveal.
What the Message Actually Says
The user's message is deceptively simple. It contains two observations:
- "started of chaining nicely" — At the beginning of the workload, the GPU was processing partitions back-to-back with good utilization. The two GPU workers were successfully interleaving their work, with one worker's PCIe transfer overlapping the other's GPU compute.
- "but seeing bunch of gaps now" — After some period of operation, the GPU utilization developed idle gaps. The "chaining" broke down, and the GPU sat idle between bursts of compute activity. The parenthetical "even with 20ish syths running with likely huge memory pressure" is crucial context. The user notes that approximately 20 synthesis threads are running concurrently, each synthesizing circuit assignments (the a/b/c vectors) in host memory. This creates "likely huge memory pressure" — contention for memory bandwidth on the host side.
The Reasoning and Motivation Behind the Message
The user sent this message because they had been asked to wait for steady state (msg 3002: "wait 3 mins for steady-ish state") and were now reporting what they observed. But the motivation goes deeper: the user was actively monitoring the GPU utilization in real time, likely via nvidia-smi or nvtop, and recognized that the pattern they were seeing was diagnostically significant.
The user understood that the initial "chaining nicely" behavior ruled out certain classes of problems. If the system could achieve good utilization initially, then the issue wasn't a fundamental architectural flaw in the worker design or the GPU mutex mechanism. Something was changing over time — or under load — that caused the gaps to emerge.
The mention of "20ish syths" (synthesis threads) and "huge memory pressure" shows the user was already forming a hypothesis: the gaps correlated with memory pressure from concurrent synthesis. This was not a random observation; it was a targeted diagnostic report from someone who understood the system architecture and was pointing the assistant toward the likely root cause.
Assumptions Embedded in the Message
The user's message makes several implicit assumptions:
That the gaps are real and not measurement artifacts. The user trusts their monitoring tool (the screenshot) and assumes the GPU utilization graph accurately reflects periods of true GPU idle time, not just periods where the GPU is doing non-compute work like memory transfers.
That the initial "chaining nicely" behavior is the expected baseline. The user assumes that when the system works correctly, partitions should be processed back-to-back with minimal gaps, and that the appearance of gaps represents a regression from this baseline.
That memory pressure from synthesis threads is a plausible cause. The user explicitly flags the 20 concurrent synthesis threads and memory pressure as relevant context, suggesting they believe the bottleneck is on the host side (memory bandwidth) rather than the GPU side (compute capacity).
That the assistant can act on this information. The user assumes the assistant has access to the timing logs from the deployed binary and can correlate the visual gaps with the instrumentation data.
The Input Knowledge Required
To fully understand this message, one needs:
Knowledge of the system architecture: The cuzk proving pipeline uses a two-worker design where GPU workers interleave PCIe transfers with GPU compute. Synthesis runs concurrently on CPU threads, producing the a/b/c vectors that must be transferred to the GPU.
Knowledge of GPU utilization metrics: The screenshot likely shows nvidia-smi's GPU-Util metric, which measures the percentage of time one or more GPU cores were active over the sampling period. This metric does not capture PCIe transfer activity — the GPU can be busy with DMA transfers while showing 0% compute utilization.
Knowledge of the investigation history: The assistant had been systematically ruling out Rust-side contention (tracker lock, malloc_trim, tokio starvation) and had just deployed a binary with timing instrumentation. The user's message arrived at the moment when the assistant was ready to analyze the timing logs.
Knowledge of PCIe and CUDA memory models: The distinction between pinned memory (allocated via cudaHostAlloc, enabling direct GPU DMA access) and pageable memory (standard malloc, requiring staged copies through a bounce buffer) is essential to understanding why the observed bandwidth numbers matter.
The Output Knowledge Created
This message produced several forms of knowledge:
A concrete observation to anchor the analysis. Before this message, the assistant was theorizing about potential bottlenecks. Now there was a real-world observation — gaps in GPU utilization that appeared under memory pressure — that needed to be explained.
A correlation between synthesis activity and GPU gaps. The user's report that gaps appeared with "20ish syths" running created a direct link between host-side memory pressure and GPU-side idle time. This correlation would prove critical.
A direction for the next round of analysis. The assistant immediately responded (msg 3005) by grepping the timing logs for GPU_TIMING and FIN_TIMING entries, beginning the systematic analysis that would eventually identify the H2D transfer bottleneck.
A validation of the instrumentation approach. The fact that the user could see gaps in the GPU monitor while the Rust-side timing showed zero lock contention confirmed that the bottleneck was deeper in the system — inside the C++ gpu_prove_start function, beyond the reach of Rust instrumentation.
What the Screenshot Revealed (and What It Didn't)
The screenshot at the 0.1-second resolution showed a distinctive pattern: short bursts of GPU compute at 75-100% utilization lasting 1-2 seconds, separated by gaps of 2-8 seconds where utilization dropped to near zero. A cyan line representing GPU memory bandwidth showed low-level activity during the gaps — the telltale sign of PCIe data transfers happening without GPU compute kernels running.
The user later provided another critical data point (msg 3041): during the gaps, PCIe RX bandwidth was only 1-4 GB/s, but during compute bursts it hit 50 GB/s. This 10-50x disparity was the smoking gun. The 50 GB/s bursts corresponded to MSM (multi-scalar multiplication) operations reading SRS points from CUDA-pinned host memory — direct DMA at near PCIe Gen5 x16 line rate. The 1-4 GB/s during gaps corresponded to the NTT (number-theoretic transform) setup phase, which copies the a/b/c synthesis vectors from standard heap memory to the GPU.
The Thinking Process It Enabled
The assistant's reasoning in the following rounds (msgs 3005-3042) shows how this single observation cascaded into a complete root-cause analysis:
- Initial analysis of timing logs (msg 3005-3006): The assistant confirmed that Rust-side overhead was negligible —
status_ms=0,fail_check_ms=0,mark_busy_ms=0. Lock contention was ruled out. - Discovery of prove_start variance (msg 3006):
prove_start_msranged from 4.2s to 16.2s per partition, while actual GPU compute was only 1.5-2s. The gap between these numbers was the mystery. - Analysis of C++ CUZK_TIMING data (msg 3028-3030): The assistant discovered that
ntt_kernelsvaried from 2.2s to 8.9s — a 4x variation — whilemsm_invokewas stable at ~630ms andbatch_addat ~400ms. The variable component was the H2D transfer inside the NTT setup. - The PCIe bandwidth insight (msg 3042): When the user reported the 1-4 GB/s vs 50 GB/s disparity, the assistant connected it to the memory allocation model. The SRS points used
cudaHostAlloc(pinned, direct DMA), while the a/b/c vectors were standardVecallocations (pageable, staged copy). The fix became clear: synthesize directly into pinned memory.
Mistakes and Incorrect Assumptions
The assistant made several incorrect assumptions that the user's data helped correct:
That lock contention was the primary suspect. The assistant spent multiple rounds instrumenting the Rust hot path and malloc_trim calls, only to find they contributed essentially nothing to the idle gaps. The user's observation that gaps appeared under memory pressure — not under high lock contention — redirected the investigation.
That two GPU workers were providing benefit through mutex overlap. The assistant initially theorized that the two-worker design allowed one worker to do CPU setup while the other held the GPU mutex. The timing data showed this wasn't happening effectively — the coarse GPU mutex in the C++ code meant one worker blocked while the other held the lock through its entire CPU+GPU cycle.
That the bottleneck was in Rust-observable code. The assistant's instrumentation strategy focused on Rust-side timing, but the real bottleneck was inside the C++ CUDA code, invisible to Rust timers. The user's visual observation of GPU gaps was the clue that pushed the analysis deeper.
Conclusion
Message 3004 is a masterclass in effective diagnostic communication. In 18 words and a screenshot, the user provided: a baseline (chaining nicely), a symptom (gaps), a suspected cause (memory pressure from 20 synthesis threads), and a temporal pattern (gaps emerged after initial good behavior). This combination of information was far more valuable than any single metric from the instrumentation.
The message worked because it came from someone who understood the system deeply enough to know what was normal, what was abnormal, and what contextual factors might explain the difference. It transformed the investigation from a blind search for software bugs into a targeted analysis of memory bandwidth physics — and ultimately led to the design of a zero-copy pinned memory pool that would eliminate the H2D bottleneck entirely.