From the day my two-node NVIDIA DGX Spark cluster came up in early May 2026 until May 14, it could serve a model across both machines, but only slowly, and only with one of the most important performance features in the serving stack turned off. The fix turned out not to be a setting. It was compiling one library from source for the exact GPU architecture in the box.
This is the story of that deadlock: what I tried first, what actually fixed it, and the general lesson I took away for anyone running brand-new hardware.
The setup
The DGX Spark is a small machine built around NVIDIA’s GB10 Grace Blackwell Superchip, with 128 GB of unified memory shared between the CPU and GPU. I run two of them in my AI lab, connected directly by a 200 Gb QSFP cable between their ConnectX-7 network adapters. NCCL, NVIDIA’s collective communications library, moves data between the two GPUs over RoCE.
To split a model across both machines, I use vLLM with tensor parallelism across the two nodes (TP=2). The model in this story was Gemma 4 31B in an FP8 build. Every generated token requires the two GPUs to exchange data through NCCL all-reduce operations, so the communication layer sits directly in the hot path.
The symptom
Serving worked from the start, with one catch. The cluster was only stable with vLLM’s --enforce-eager flag, which disables CUDA graphs. CUDA graphs let the runtime record a sequence of GPU operations once and replay it cheaply on every decode step. Without them, single-stream decode sat at roughly 5 tokens per second.
With CUDA graphs enabled, the failure was specific, and at first it looked like success. The engine started. Graph capture completed. The first request came back. Then the second request deadlocked: generation throughput dropped to zero and never recovered.
That pattern was a clue. The first request can trigger fresh graph work, while later requests replay cached graphs. So the problem looked like it lived in graph replay across the cross-node boundary, not in startup.
What did not fix it
I worked through the problem one variable at a time, all on the same model at TP=2:
- vLLM 0.19 through 0.20.1 with the Ray executor and the stock NCCL package. Immediate deadlock.
- vLLM 0.20.2, which replaced the Ray component I first suspected. Graph capture now completed and the first request worked. The second request deadlocked.
- PyTorch’s native multiprocessing backend instead of Ray. Same deadlock on the second request, which told me the executor was not the cause.
NCCL_GRAPH_MIXING_SUPPORT=0. NVIDIA’s NCCL documentation describes graph mixing as a source of CUDA deadlocks in a situation that closely matched my symptom. No effect.NCCL_P2P_DISABLE=1plusNCCL_SHM_DISABLE=1. Suggested on NVIDIA’s forums for all-reduce hangs on this architecture. Throughput briefly peaked around 12 tokens per second before hanging again. That hinted the transport path mattered, but it was not a fix.
Each of those was a reasonable thing to try. Each was also a way of adjusting how a component behaved without asking whether that component had been built for the hardware it was running on.
Finding the code that was actually running
The question that broke it open was simple: which library’s code is executing at the moment of failure, and was it compiled for this GPU?
GB10 is CUDA compute capability 12.1, which appears in build targets as sm_121. The stock NCCL 2.30.4 package I had installed shipped prebuilt kernels only up to sm_120, plus PTX, NVIDIA’s portable intermediate code. On a GPU without a matching native binary, the CUDA driver JIT-compiles that intermediate code at runtime.
Everything I had observed was consistent with that runtime JIT layer breaking CUDA graph replay across multi-node all-reduce operations. I could test that idea directly by removing the JIT step. If NCCL contained native sm_121 code, the driver would have nothing to compile.
The build
The fix was to build NCCL from its master branch with the architecture target set explicitly:
git clone https://github.com/NVIDIA/nccl.git
cd nccl
make -j src.build \
NVCC_GENCODE="-gencode=arch=compute_121,code=sm_121" \
CUDA_HOME=/usr/local/cuda
Three details mattered as much as the flag.
- Build inside the serving container. The library is loaded inside the vLLM container, so it needs to be compatible with that container’s glibc, not the host’s. Building on the host invites an ABI mismatch.
- Replace the library where it is actually loaded. I bind-mounted the custom
libnccl.sointo the containers at runtime, over both the copy bundled with the Python packages and the system copy. Replacing only one risks testing the old library while believing you are testing the new one. - Put the same build on both nodes. Both ranks of the tensor-parallel group need the fixed library.
I also switched the cluster from Ray to PyTorch’s multiprocessing backend and removed --enforce-eager.
The result
The deadlock was gone, and the numbers moved a lot:
- Single-stream decode: about 5 tokens per second in eager mode before, 12.1 tokens per second after, averaged over 18 measurements (three iterations at each of six output lengths). That is a 2.4x improvement.
- Concurrency: 92.6 tokens per second in aggregate with 8 concurrent requests, about 95 percent scaling efficiency.
- Stability: zero deadlocks across the sequential and concurrent tests.
- Cost: CUDA graph capture added roughly 15 seconds to a cold start.
The same fix carried over to a much larger model. Mistral Medium 3.5 128B in FP8 ran stably with CUDA graphs on the same NCCL build, and later with speculative decoding on top.
The lesson for brand-new hardware
The easy mental model is that a software package either supports a GPU or it does not. On brand-new hardware, reality is messier. A package can load, run, and mostly work through a compatibility path while one specific feature, here CUDA graph replay across two machines, fails in a way no error message points to.
When something fails on cutting-edge hardware now, this is my checklist:
- Write down every component in the stack. Driver, CUDA toolkit, NCCL, PyTorch, vLLM, the container base image, glibc. Any of them can be the one that has not caught up with the hardware.
- Identify the code that is executing at the failure point. Ask whether it was compiled for this architecture before tuning how it behaves.
- Search upstream issues, pull requests, and forums. On new hardware, the answer often lives in an unmerged pull request or a forum thread rather than a release.
- Try the cheap fixes first, but never rule out a source build. Environment variables and flags are faster to test. They are not a substitute for native code.
- Verify with repetition. My failing configurations passed a first request. A fix is not a fix until it holds across many requests, output lengths, and concurrency levels.
- Write it up. The next person with the same hardware will hit the same wall, and upstream projects benefit from a clear report.
Compiling a core library from source can sound like a last resort. On hardware this new, it is an ordinary debugging tool, and in this case it was the one that worked.