My two DGX Sparks had a failure mode that looked harmless from Docker and was completely dead from the client. The DeepSeek V4 container was still listed as running, but its OpenAI-compatible API had disappeared halfway through a long request.
The important fix was not another launch flag. It was treating a distributed model server as a service with an actual health contract: test the API, restart both tensor-parallel ranks together, and leave enough unified-memory headroom that the recovery is not immediately asked to repeat the same failure.
This is the failure, the recovery design, and the load test that followed.
The stack
The model is deepseek-ai/DeepSeek-V4-Flash-DSpark, served over two 128 GB DGX
Spark nodes with tensor parallelism across their dedicated 200 GbE RoCE link.
The launch started from the community
two-Spark DSpark recipe
and the related
NVIDIA forum work.
The important serving settings are:
--tensor-parallel-size 2
--pipeline-parallel-size 1
--kv-cache-dtype nvfp4_ds_mla
--block-size 256
--max-model-len 1048576
--max-num-seqs 12
--max-num-batched-tokens 8192
--gpu-memory-utilization 0.80
--kv-cache-memory-bytes 10737418240
--enable-prefix-caching
--async-scheduling
--enable-chunked-prefill
--speculative-config '{"method":"dspark","num_speculative_tokens":3,"draft_sample_method":"probabilistic"}'
That explicit 10 GiB KV reservation is per rank. vLLM reports 1,515,055 KV tokens for the cluster, or about 1.44 full-length 1,048,576-token sequences. The point is not to promise 1.5 million usable prompt tokens in every workload; it is to make the scheduler's real budget visible instead of guessing from a memory-utilization percentage.
The failure
A request with 40,967 prompt tokens reached the compressor KV-score GEMM and
failed with CUBLAS_STATUS_INTERNAL_ERROR. vLLM declared the engine dead and
shut down the API process. There was no matching GPU Xid, Linux OOM kill, or
RoCE link failure. The hosts were alive and the network was still healthy.
Docker's view was misleading. PID 1 in the container remained alive after the
serving process failed, so the container never transitioned to exited.
restart: unless-stopped therefore had nothing to react to. From outside the
box, the result was simple: port 8000 was gone and stayed gone.
This distinction matters:
| Layer | What it knew |
|---|---|
| GPU and host | Alive; no fatal device or OS event |
| vLLM engine | Dead after the CUDA failure |
| Container runtime | PID 1 still alive, so container is "running" |
| Client | API connection fails |
A container-state check cannot detect that failure. A service-health check can.
The recovery design
The head Spark now owns the distributed service through systemd. A timer probes
/health every 30 seconds. Three consecutive failures trigger one coordinated
recovery:
- Stop the stale head rank.
- Stop the worker rank over passwordless SSH.
- Start the worker rank.
- Start the head rank.
- Let the normal startup probe wait for the API.
The three-failure threshold avoids turning a brief startup pause into a restart loop. Coordinating both ranks is essential: restarting only one side of a two-node tensor-parallel job leaves the other rank holding stale distributed state.
The boot service also waits for Docker, both dedicated QSFP addresses, SSH to
the worker, and peer reachability before it launches anything. Docker still has
restart: unless-stopped, but it is now the first layer rather than the only
layer.
There is one deliberate escape hatch. If the systemd owner is stopped or disabled to run another large model, the watchdog stays out of the way. A self-healing default should not fight an intentional model switch.
Two smaller corrections
The launch advertised two RDMA HCAs even though this cluster uses one dedicated
ConnectX path. Restricting NCCL_IB_HCA to rocep1s0f1 removed the repeated
unused-device GID warning and made the selected data path unambiguous.
I also reduced the explicit KV reservation from 12 GiB to 10 GiB per rank. The old setting exposed more nominal KV, but it left less unified memory for CUDA, JIT workspaces, and concurrent side services. The new budget still covers a full 1M-token request and gives each node about 2 GiB more operational margin.
Reproducing the failed request
Before running a matrix, I sent a fresh 40,024-token prompt through the repaired service. It completed in 20.94 seconds and returned a valid response. That does not prove the original CUDA path can never fail again, but it proves the exact request class that killed the prior engine now completes on the hardened launch.
Cold-prefill results
The prefill pass used unique generated padding and cold standalone requests. The requested labels and actual token counts differ slightly because tokenizers do not map characters to tokens at an exact fixed ratio.
| Target | Actual prompt | TTFT | Prefill |
|---|---|---|---|
| 8K | 8,193 | 4.18 s | 1,960 tok/s |
| 16K | 16,252 | 8.27 s | 1,966 tok/s |
| 32K | 32,342 | 16.53 s | 1,956 tok/s |
| 64K | 64,557 | 33.72 s | 1,915 tok/s |
| 128K | 128,982 | 71.12 s | 1,813 tok/s |
| 256K | 257,831 | 158.86 s | 1,623 tok/s |
| 512K | 515,501 | 388.66 s | 1,326 tok/s |
The 512K request is the useful endpoint here. It occupied enough KV and ran long enough to exercise the system beyond the original 40K failure without tripping the engine or the recovery path.
Sustained decode and concurrency
The second pass used 25 percent unique context, 2,048 output tokens per stream, 30-second measured windows, and concurrency 1, 2, 4, 8, and 12. Each cell had a separate warmup and up to five minutes to reach its requested active load.
| Context | C=1 | C=2 | C=4 | C=8 | C=12 |
|---|---|---|---|---|---|
| 8K | 45.4 | 70.2 | 101.3 | 134.3 | 144.2 |
| 32K | 43.8 | 67.5 | 89.1 | 117.3 | 145.5 |
| 64K | 52.6 | 69.1 | 83.1 | 47.3 | not admitted* |
| 128K | 38.3 | 62.6 | 80.0 | not admitted* | not admitted* |
* marks a capacity-limited cell. Those values describe delivered aggregate
throughput while excess streams wait for KV capacity; they are not evidence
that every requested stream was resident at once.
Across 20 measured cells, the matrix represented about 4,581,455 prompt tokens and 41,961 measured output tokens before counting warmups. 3 cells were marked capacity-limited, the largest observed queue was 7 requests, and the client recorded 0 request errors.
During the largest cells, observed KV occupancy repeatedly reached 97-99 percent. The scheduler queued excess requests for capacity and drained them without an engine death, NCCL failure, GPU Xid, or OS memory-pressure event. That behavior is exactly what I wanted: visible backpressure instead of a dead API.
What this test proves, and what it does not
It proves that the repaired service can replay the original request size, cold prefill through 512K, and sustain a matrix that repeatedly drives KV to the edge. It also proves the scheduler can queue work at that edge without taking the API down.
It does not prove that a CUDA kernel can never fail. The practical improvement is that one kernel failure is no longer allowed to become an indefinite outage. The watchdog detects the user-visible condition, and the recovery operation matches the two-rank topology.
The two sanitized result files are available here:
The lasting lesson is straightforward: docker ps is not a health check. For a
distributed inference service, recovery has to begin from the endpoint clients
actually use and has to restart the whole distributed job, not whichever
container happens to look suspicious first.





