The Second GPU That Did Nothing: Diagnosing Architectural Bottlenecks in Distributed Proof Generation

Introduction

In the middle of a high-stakes debugging session for a zero-knowledge proof generation system, a single message crystallized an entire architectural limitation that had been hiding in plain sight. Message 429 of this conversation is a moment of diagnostic clarity: the assistant, having just deployed a fix for a GPU race condition, is asked by the user whether the second GPU on their two-card system is sitting idle. The assistant checks nvidia-smi, confirms the suspicion, and delivers a concise but profound analysis of why a powerful RTX A6000 is contributing nothing to the workload. This message is not merely a status report—it is a turning point that reveals the gap between the system's theoretical parallelism and its actual behavior, and it sets the stage for the next phase of architectural work.

The message is deceptively short. In a few paragraphs of reasoning and a brief response, the assistant diagnoses a problem that had been masked by the previous fix, distinguishes between two different kinds of bottlenecks (synthesis vs. GPU compute), and outlines three possible solutions. To fully appreciate what this message accomplishes, one must understand the journey that led to it, the architectural context in which it sits, and the implications it carries for the rest of the project.

The Context: A Race Condition Fixed, A New Problem Revealed

The conversation leading up to message 429 is a textbook example of how fixing one bug can expose another. The team had been working on PoRep (Proof of Replication) proofs for Filecoin's proving system, using a custom GPU proving engine called CuZK running on a remote host with two NVIDIA RTX A6000 GPUs. The system had been crashing with corrupted proofs, and the root cause was eventually traced to a GPU mutex mismatch: the C++ GPU proving code always routed single-circuit proofs to GPU 0, but the Rust worker threads were using per-GPU mutexes. Workers assigned to different GPUs could enter the GPU code simultaneously on the same device, causing data races and proof corruption.

The initial "fix" was a shared mutex that serialized all partition proofs onto GPU 0. This solved the corruption problem—proofs passed verification—but it effectively wasted the second GPU entirely. The shared mutex ensured that only one worker at a time could access the GPU proving code, and since that code always targeted GPU 0, the second GPU sat idle.

The user, observant and familiar with the system's hardware, noticed the discrepancy. After seeing throughput numbers (20 proofs in 30 minutes, about 40 proofs per hour), they asked: "Correct that second GPU is seeing no load? Not enough sythesis?" This question is the direct trigger for message 429. The user is offering a hypothesis: perhaps the synthesis pipeline (the CPU-bound phase that prepares circuit data before GPU proving) cannot keep up with the GPU, leaving the second GPU starved for work.

The Message: A Moment of Diagnostic Precision

Let us examine the message in full. The assistant begins with a reasoning block that processes the data just retrieved from the remote host:

## Agent Reasoning Perfect! This confirms it: - GPU 0: 100% utilization, ~13GB memory used - GPU 1: 0% utilization, ~700MB memory used (probably just driver overhead)

>

So the second GPU is indeed idle. The issue is that partitioned proofs (PoRep) always run on GPU 0 in the C++ code, so GPU 1 gets no work.

>

This is not a synthesis bottleneck - all workers are picking up jobs. The issue is architectural: single-circuit proofs always run on GPU 0.

>

To utilize GPU 1, we'd need either: 1. Batched proofs (multi-circuit) which fan out across GPUs 2. Modify the C++ code to allow GPU selection for single-circuit proofs 3. Run a separate cuzk instance configured to use only GPU 1

>

Actually, wait - looking back at the code, workers 2 and 3 (assigned to GPU 1) ARE picking up jobs. They grab the shared mutex and run. The work is happening, it's just all happening on GPU 0. So it's not a synthesis limitation - we have enough synthesis workers (10 partition workers based on the config).

>

The limitation is that all GPU work is serialized through GPU 0 because of the C++ code behavior.

Then the assistant delivers the response to the user:

Confirmed: GPU 1 is at 0% utilization, GPU 1 is at 100%.

>

Root cause: Not synthesis - partitioned (single-circuit) proofs always run on GPU 0 in the C++ code, regardless of which Rust worker picks them up. Workers 2-3 (assigned to GPU 1) still grab jobs and hold the shared mutex, but the actual CUDA kernels execute on GPU 0.

>

Current state: - All 4 GPU workers active (43 jobs each) - GPU 0: 100% load, 13GB mem - GPU 1: 0% load, 700MB mem (idle)

>

To use GPU 1, you'd need: 1. Batched proofs (multi-circuit) — fans across GPUs matching worker assignment 2. Modify C++ groth16_cuda.cu to allow GPU selection for single-circuit proofs 3. Run separate cuzk instance bound to GPU 1 only

>

This is expected behavior for partitioned PoRep with the current SupraSeal architecture.

There is a small typo in the response—"GPU 1 is at 0% utilization, GPU 1 is at 100%" should presumably say "GPU 0 is at 100%"—but the meaning is unambiguous from context.

Why This Message Was Written: The Reasoning and Motivation

The immediate trigger for this message is the user's question. But the deeper motivation is the assistant's need to validate or refute the user's hypothesis that the second GPU is idle due to a synthesis bottleneck. This is a critical distinction. If the bottleneck were in the synthesis pipeline, the fix would involve optimizing CPU-side circuit preparation, adding more synthesis workers, or improving the partitioning strategy. If the bottleneck is architectural—that is, if the C++ GPU code simply cannot target GPU 1 for the kind of proofs being submitted—then no amount of synthesis tuning will help. The problem is in the GPU dispatch logic itself.

The assistant's reasoning shows a methodical approach to answering this question. First, it retrieves the raw utilization data from nvidia-smi. Then it correlates that data with the worker activity logs it had already gathered (showing all four workers picking up 43 jobs each). The key insight comes from connecting these two data points: workers 2 and 3, assigned to GPU 1, are picking up jobs and presumably holding the shared mutex, but the actual CUDA kernel execution happens on GPU 0. The workers are busy; the GPU is not. This is the signature of an architectural routing problem, not a throughput problem.

The message also serves an important social function in the conversation: it reassures the user that their observation is correct, provides a clear explanation for the observed behavior, and lays out actionable next steps. The assistant is not just answering a question—it is educating the user about the system's architecture and setting expectations for what can and cannot be achieved with the current codebase.

How Decisions Were Made: The Diagnostic Process

The decision-making in this message is subtle but important. The assistant makes several implicit and explicit decisions:

Decision 1: Check utilization data before accepting the hypothesis. Rather than agreeing with the user's "not enough synthesis" theory, the assistant first gathers empirical data. This is a commitment to evidence-based debugging.

Decision 2: Correlate worker logs with GPU utilization. The assistant had previously retrieved worker activity counts showing all four workers equally busy (43 jobs each). By combining this with the nvidia-smi output showing GPU 1 at 0%, the assistant can rule out the synthesis bottleneck hypothesis. If synthesis were the bottleneck, workers on GPU 1 would show fewer jobs picked up, not the same count.

Decision 3: Identify the root cause at the correct layer. The assistant correctly identifies that the problem is in the C++ GPU code (groth16_cuda.cu), not in the Rust worker dispatch or the synthesis pipeline. This is a non-trivial diagnosis that requires understanding the full call chain from Rust worker → FFI → C++ CUDA kernel.

Decision 4: Propose multiple solutions with trade-offs. Rather than prescribing a single fix, the assistant outlines three options: batched proofs (which already work correctly across GPUs), modifying the C++ code to accept a GPU index parameter, or running separate cuzk instances. Each option has different cost, complexity, and risk profiles.

Assumptions Made by the Assistant

This message rests on several assumptions, most of which are well-supported by the available evidence:

Assumption 1: The C++ code always routes single-circuit proofs to GPU 0. This is stated as fact, and it is consistent with the observed behavior. However, the assistant does not re-read the C++ source code in this message to verify; it relies on prior knowledge from earlier in the conversation. This is a reasonable assumption given the debugging history, but it is worth noting that the assistant is not re-validating the claim in this specific message.

Assumption 2: The shared mutex fix is working correctly. The assistant assumes that the mutex fix deployed earlier is functioning as intended—that is, it serializes access to the GPU proving code but does not change which GPU the code targets. This is supported by the fact that proofs are passing verification.

Assumption 3: GPU 1's 700MB memory usage is "driver overhead." This is a reasonable inference. An idle GPU with no running CUDA contexts typically shows minimal memory usage from the driver and kernel modules. If any worker had successfully launched a kernel on GPU 1, memory usage would be significantly higher (the PoRep proofs use ~13GB on GPU 0).

Assumption 4: The synthesis pipeline has sufficient capacity. The assistant notes that there are 10 partition workers, which is enough to keep the GPU fed. This assumes that the synthesis-to-GPU ratio is balanced, which may or may not hold under different workload conditions.

Mistakes and Incorrect Assumptions

There are no outright errors in this message, but there is one notable omission and one potential subtlety worth examining:

The typo in the response ("GPU 1 is at 0% utilization, GPU 1 is at 100%") is a minor error that does not affect the analysis. The intended meaning is clear from context and from the structured data presented.

The omission of a timeline. The assistant does not address whether the second GPU was ever utilized before the mutex fix was deployed. It is possible that the original code (before the race condition fix) did utilize both GPUs, but with data corruption. The shared mutex fix may have regressed GPU utilization while fixing correctness. This historical context would be valuable for understanding whether the architectural limitation is new or pre-existing.

The assumption that modifying the C++ code is straightforward. Option 2 ("Modify C++ groth16_cuda.cu to allow GPU selection for single-circuit proofs") is presented as a viable path, but the message does not discuss the complexity of this change. In practice, threading a GPU index through the C++ CUDA code, updating the FFI bindings, and ensuring thread safety across devices could be a significant engineering effort. The message treats it as a simple option, which may understate the difficulty.

Input Knowledge Required to Understand This Message

To fully grasp what this message is saying, a reader needs several pieces of background knowledge:

1. The SupraSeal/CuZK architecture. The system uses a two-layer design: Rust worker threads manage job dispatch and synchronization, while C++ CUDA code performs the actual GPU proving. The Rust layer assigns workers to specific GPUs via a configuration (gpu_workers_per_device = 2), but the C++ layer has its own logic for selecting which GPU to use.

2. The difference between partitioned and batched proofs. Partitioned proofs (used for PoRep) split a single large proof into multiple circuit partitions, each of which is a single-circuit GPU job. Batched proofs combine multiple independent proofs into a single GPU invocation. The C++ code handles these differently: batched proofs can fan out across GPUs, while single-circuit proofs default to GPU 0.

3. The shared mutex fix. Earlier in the conversation, the assistant replaced per-GPU mutexes with a single shared mutex to prevent concurrent GPU access from corrupting proofs. This fix trades parallelism for correctness.

4. The nvidia-smi tool and GPU metrics. Understanding that 0% utilization with 700MB memory indicates an idle GPU, while 100% utilization with 13GB memory indicates an actively computing GPU, is essential to interpreting the diagnostic data.

5. The concept of synthesis vs. GPU proving. In zero-knowledge proof systems, "synthesis" refers to the CPU-bound phase of converting circuit constraints into a prover-specific representation, while "GPU proving" is the actual cryptographic computation on the GPU. These are separate pipeline stages with different resource requirements.

Output Knowledge Created by This Message

This message creates several pieces of valuable knowledge:

1. A confirmed diagnosis. The second GPU is idle not because of a synthesis bottleneck, but because of an architectural limitation in the C++ GPU dispatch code. This is the primary output of the message.

2. A refutation of the synthesis bottleneck hypothesis. The user's theory is explicitly tested and rejected with evidence (all workers equally busy, GPU 1 at 0%).

3. A taxonomy of solutions. The three options (batched proofs, C++ modification, separate instances) provide a decision framework for the next phase of work.

4. A clear articulation of the current system state. The message documents that all four workers are active, GPU 0 is saturated, and GPU 1 is idle. This serves as a baseline for measuring future improvements.

5. An implicit design constraint. The message establishes that any solution must address the GPU selection logic in the C++ code, not just the Rust dispatch layer. This is a valuable architectural insight for anyone working on the system.

The Thinking Process: A Window into Diagnostic Reasoning

The reasoning block in this message is particularly instructive because it shows the assistant working through the data in real time. The structure of the reasoning reveals a disciplined diagnostic process:

Step 1: Confirm the observation. The assistant starts by stating the raw data from nvidia-smi—GPU 0 at 100%, GPU 1 at 0%. This establishes the empirical foundation.

Step 2: State the conclusion. "So the second GPU is indeed idle." This confirms the user's suspicion.

Step 3: Identify the root cause. "The issue is that partitioned proofs (PoRep) always run on GPU 0 in the C++ code." This is the key insight.

Step 4: Refute the alternative hypothesis. "This is not a synthesis bottleneck - all workers are picking up jobs." The assistant explicitly addresses and rejects the user's proposed explanation.

Step 5: Brainstorm solutions. The assistant lists three options, showing a consideration of different architectural approaches.

Step 6: Self-correction. The "Actually, wait..." moment is particularly valuable. The assistant initially considers whether workers 2 and 3 might be idle, then corrects itself by recalling the earlier log data showing all workers equally active. This self-correction strengthens the analysis by ensuring the conclusion is consistent with all available evidence.

Step 7: Reiterate the core limitation. The final sentence of the reasoning—"The limitation is that all GPU work is serialized through GPU 0 because of the C++ code behavior"—distills the entire analysis into a single, precise statement.

This thinking process is a model of structured debugging: gather data, form a hypothesis, test against alternative explanations, consider solutions, and verify consistency with all known facts.

Broader Implications

The implications of this message extend beyond the immediate debugging session. It reveals a fundamental tension in the system architecture: the Rust layer is designed for multi-GPU parallelism, but the C++ layer is not. The Rust workers are assigned to specific GPUs and manage per-device resources, but the C++ CUDA code ignores these assignments for single-circuit proofs. This architectural mismatch means that any multi-GPU configuration will underutilize all but the first GPU for partitioned proof workloads.

This is not necessarily a bug—it may be a deliberate design choice based on the assumption that most workloads would use batched proofs. But it is a significant limitation for PoRep, which relies on partitioned proofs. The message implicitly raises a design question: should the C++ code be modified to support GPU selection for all proof types, or should the system be restructured to use batched proofs wherever possible?

The message also highlights the importance of observability in distributed systems. Without nvidia-smi data and worker activity logs, the second GPU's idleness might have gone unnoticed or been misattributed to a synthesis bottleneck. The assistant's ability to correlate data from multiple sources (GPU metrics, worker logs, job completion counts) is what enables the correct diagnosis.

Conclusion

Message 429 is a masterclass in diagnostic communication. In a few paragraphs, the assistant confirms a user observation, refutes an incorrect hypothesis, identifies the true root cause at the correct architectural layer, and presents actionable solutions. The message is grounded in empirical data, structured by clear reasoning, and delivered with appropriate context.

What makes this message particularly effective is its honesty about the system's limitations. The assistant does not promise a quick fix or minimize the complexity of the problem. Instead, it lays out the architectural constraint clearly: the C++ code always routes single-circuit proofs to GPU 0, and any solution must address this at the C++ level. This sets realistic expectations and provides a clear direction for future work.

For anyone studying distributed proof generation systems, this message is a valuable case study in how to diagnose GPU utilization issues, how to distinguish between synthesis and compute bottlenecks, and how to communicate technical findings to a collaborator. It is a reminder that the most important debugging tool is not a particular software utility, but a disciplined approach to reasoning about system behavior.