← back to dooner.tech

Blog

Notes, projects, and random discoveries
Docker Said Running. DeepSeek Was Dead: Hardening Two DGX Sparks
A long-context CUDA failure killed vLLM while Docker still reported a healthy container. Here is the two-rank recovery design, memory adjustment, and near-capacity validation that made the service self-healing.

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:

  1. Stop the stale head rank.
  2. Stop the worker rank over passwordless SSH.
  3. Start the worker rank.
  4. Start the head rank.
  5. 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.

Three GPUs Are Not a Rounding Error: DeepSeek V4 Flash at TP=3
Making DeepSeek V4 Flash use all three RTX PRO 6000s required virtual tensor-parallel padding, odd-size collectives, and a carefully pinned launch stack.

Most multi-GPU inference recipes are written for powers of two. Two GPUs divide cleanly. Four GPUs divide cleanly. Kernel test matrices, collective libraries, and even configuration validators tend to encode that assumption.

We had three RTX PRO 6000 Blackwell Workstation Edition cards. Leaving the third one idle was not an appealing systems design, so we made DeepSeek V4 Flash run at tensor parallel size three.

The final result was a production-capable vLLM v9 deployment using the standard DeepSeek V4 Flash checkpoint, two-token MTP speculative decoding, B12X A16 kernels, FP8 KV cache, and a hybrid NCCL/B12X all-reduce policy. The interesting part was not changing --tensor-parallel-size 2 to 3. It was identifying all the places where three was a shape, storage, synchronization, or capture problem rather than just a number.

The pinned stack

Reproducibility starts with exact source identities. Our minimal validated TP3 baseline used:

Component Pin
Hardware 3 x RTX PRO 6000 Blackwell 96 GB, PCIe, 450 W each
Model deepseek-ai/DeepSeek-V4-Flash revision 6976c7ff1b30a1b2cb7805021b8ba4684041f136
vLLM dev/eldritch-enlightenment at 45c1582e9b80ba83e71c3a6458e71da4736fbdc4
B12X base f3686b555d639823b276c2080f173145eed7f007
B12X transport source 97b3d642b8ce08ce23184a36882710ce3b60ba13
Base image voipmonitor/vllm@sha256:7703639ae9532759d180f26b649c4dd10064a84e6b7bb1767510fab900e6c468
Local image ai01/vllm-v9-b12x-tp3:20260710-v7-routing

The public starting point is the pinned DeepSeek V4/DSpark v9 card. Our TP3 work is an overlay on that stack, not a claim that arbitrary current vLLM and B12X revisions accept the same patches.

Divisibility is a model-loading problem

DeepSeek V4 Flash was not shaped around three tensor-parallel ranks. Several important dimensions do not divide by three:

  • 64 attention heads;
  • routed expert intermediate width 2048;
  • shared expert width 2048;
  • vocabulary size 129280; and
  • eight output groups.

The v9 B12X path already contained a virtual tensor-parallel plan. Instead of changing the checkpoint, it expands selected global dimensions to aligned virtual sizes, distributes those sizes evenly, and zero-fills the tails. For TP3, the plan is:

Axis Checkpoint Virtual global Logical per rank Kernel-local
Attention heads 64 72 24 24
Output groups 8 9 3 3
Routed MoE intermediate 2048 2112 704 B12X-specific layout
Shared expert intermediate 2048 2304 768 768
Vocabulary 129280 129408 43136 43136

This is more than padding a tensor to a multiple of three. Each weight loader must know whether its offsets are measured in unpacked elements, packed FP4 bytes, or scale groups. Every downstream kernel must either operate on the logical width or safely consume a zero-padded kernel width.

That distinction is why we did not simply remove the TP3 guard from the Lucifer-CUTLASS path. Our source audit found that routed W13 happened to slice correctly, but packed W2 would use byte offsets 0/384/768 instead of 0/352/704, and W2 E8M0 scales would use 0/24/48 instead of 0/22/44. The model could load and still have corrupted expert shards. The full audit is in vllm-lucifer-tp3-padding-audit.md.

The lesson was straightforward: a successful load is not a correctness test.

Odd-world-size communication

The original B12X PCIe one-shot transport explicitly accepted world sizes 2,4,6,8,10. TP3 required all three of the following:

  1. Python validation had to accept world size three.
  2. C++/CUDA dispatch had to instantiate the N=3 template.
  3. The transport had to survive actual three-rank eager, graph, and multistream execution.

The first two changes compiled. The first eight-element FP16 all-reduce then hung forever. That failure led to the IPC initialization race described in part two.

After the transport fix, we still did not send every reduction through B12X. The final image set both the minimum and maximum one-shot size to 64 KiB. Smaller latency-sensitive reductions used NCCL, exactly 64 KiB used B12X, and larger tensors fell back to NCCL. This routing policy was faster at C1 than the 16 KiB and 32 KiB cutoffs and avoided treating one collective implementation as universally best.

The validated launch profile

The minimal 524K profile was:

GPUS=0,1,2
TP=3
DCP=1
BACKEND=b12x-a16
MODE=standard-mtp2
MTP_TOKENS=2
MAX_MODEL_LEN=524288
MAX_NUM_SEQS=64
MAX_BATCHED=8192
GPU_MEM=0.94
GRAPH=512
BREAKABLE_CUDAGRAPH=1
CUDAGRAPH_MODE=FULL_AND_PIECEWISE
PCIE_ALLREDUCE=1
PCIE_ONESHOT_MIN_SIZE=64KB
PCIE_ONESHOT_SINGLE_CHANNEL=1

The speculative configuration passed to vLLM was:

{
  "method": "mtp",
  "num_speculative_tokens": 2,
  "draft_sample_method": "probabilistic",
  "moe_backend": "b12x"
}

The complete helper is run-ds4-v9-server.sh, and the exact profile is deepseek-v4-flash-b12x-tp3. CPU and NUMA pinning in that profile is specific to ai01; another host should derive its own mapping from PCIe and NUMA topology.

What the third GPU delivered

The matched production MTP2 run reported a KV budget of 3,287,424 tokens. At a 524,288-token maximum sequence length, that is about 6.27 request-lengths of KV capacity before block granularity and scheduler details.

Measurement Result
KV capacity 3,287,424 tokens
8K standalone prefill 11,645 tok/s
64K standalone prefill 10,580 tok/s
Zero-context C1 decode 213.2 tok/s
Zero-context C16 decode 1,284.0 tok/s
Zero-context C32 decode 1,875.3 tok/s
Zero-context C64 decode 2,689.5 tok/s
Coding median 228.5 tok/s

The raw result is ai01-dsv4f-v9-tp3-b12x-mtp2-matched-450w-confirm.json.

These numbers are not a universal claim about TP3 scaling. They describe one pinned model, image, topology, power limit, and benchmark. Their main value is that the same deployment survived model load, CUDA graph capture, long-prefill tests, sustained decode through C64, coding probes, and repeated service use.

What we did not claim

The working result was narrower than "DeepSeek V4 supports TP3 everywhere":

  • B12X A16 with standard MTP2 was production-proven.
  • DSpark N5 was made functional and benchmarked, then not selected as the general production default.
  • Lucifer-CUTLASS TP3 remained an audited implementation plan because its packed MXFP4 loader needed storage-unit-aware slicing.
  • A graph-warmup patch passed focused unit tests but was not part of the minimal v7 production image.

That scope is a feature, not a weakness. Odd tensor-parallel sizes cross model geometry, weight storage, collectives, and graph capture. Keeping each claim tied to a test is how we avoided turning "the server started" into a false definition of success.

The All-Reduce That Erased Its Own Signal
The first eight-element TP3 all-reduce deadlocked because a late CUDA memset could erase a peer's published barrier arrival. This is the diagnosis, fix, soak test, and final routing policy.

The first B12X TP3 patch looked almost too small to fail. Add three to the supported world sizes, instantiate the CUDA template for N=3, rebuild, and run the existing distributed test.

It failed on the first FP16 operation with eight elements.

That was useful. A failure at the smallest possible operation, before model loading and CUDA graph complexity, gave us a constrained communication bug. The transport had initialized, every process was alive, and no meaningful payload size was involved. The ranks were waiting on a barrier generation that would never arrive.

The tempting incomplete patch

The obvious changes were valid but insufficient:

-SUPPORTED_WORLD_SIZES = (2, 4, 6, 8, 10)
+SUPPORTED_WORLD_SIZES = (2, 3, 4, 6, 8, 10)

 switch (world_size_) {
   case 2: KL(2); break;
+  case 3: KL(3); break;
   case 4: KL(4); break;
 }

With those changes, Python accepted TP3 and CUDA had code to execute it. The first operation still deadlocked. Adding a block-level __syncthreads() did not change the failure, which told us the missing ordering was not confined to threads inside one launched kernel.

The race was in publication order

The eager transport allocates a shared CUDA IPC slab containing signals and staging buffers. The old sequence was:

  1. allocate the local slab without zero-filling it;
  2. publish its IPC handle and open peer handles;
  3. after handle exchange returns, zero the local signal region; and
  4. begin collective operations.

Handle exchange does not make every rank return at the same instant. A fast rank can open a slower rank's slab and post its first barrier arrival. The slower rank can then return from exchange and zero its own signal region, erasing the arrival that was already posted.

sequenceDiagram
    participant R0 as "Fast rank"
    participant R1 as "Slow rank"
    R1->>R0: Publish IPC handle
    R0->>R1: Open peer slab
    R0->>R1: Post generation-1 arrival
    Note over R1: Handle exchange returns late
    R1->>R1: cudaMemset(signal slab, 0)
    Note over R0,R1: Generation-1 arrival is gone
    R0->>R1: Wait for exact generation 1
    R1->>R0: Wait for exact generation 1

The barrier checks an exact generation value. It does not accept "at least" that generation, and the erased write is not repeated. Every rank can spin forever while all processes and GPUs appear otherwise healthy.

The correction was to initialize before publication:

 slab = allocate_shared_buffer(
     exchange_group,
     slab_bytes,
-    zero_fill=False,
+    zero_fill=True,
     ipc=ipc,
 )
-cudaMemset(local_signal_region, 0)

This ordering guarantees that no peer can observe the slab until its initial signal state is valid. The isolated patch is b12x-f3686b5-tp3-init-race.patch, with the full analysis in b12x-f3686b5-tp3-init-race.md.

Controlled tests before a model launch

We did not validate the fix by starting a 300-plus-GB model and hoping. The transport test covered eager execution, CUDA graph capture/replay, and two streams over FP16, BF16, and FP32 with multiple message sizes.

Candidate Result
N=3 validation and dispatch only Hung on first eight-element FP16 operation
Same plus leading __syncthreads() Same hang
Same plus pre-publication initialization Passed eager, graph, and multistream tests
Minimal fix, 256-iteration soak Passed in 11.97 seconds

The soak executed 7,936 launches per rank:

  • 3,072 eager launches;
  • 4,352 CUDA graph launches; and
  • 512 launches split across two streams.

That is 23,808 launches across the three ranks. This is still not formal proof of every possible stream schedule, but it is much stronger evidence than a single successful server request.

The reusable test entry point is run-b12x-v9-overlay-tp3-test.sh.

A second problem appeared during server capture

The standalone collective passed, but full vLLM startup exposed another boundary. The server completed 67 PIECEWISE graph descriptors and then stopped making visible progress as it entered FULL capture.

Source inspection found a plausible divergence in vLLM's custom all-reduce warmup behavior. While capture bookkeeping was active but the CUDA stream was not actually capturing, legacy code returned an uninitialized torch.empty_like(input) placeholder. That preserves allocation shape for the legacy custom collective. B12X, however, has a real eager path backed by preallocated buffers. Rank-local garbage entering DeepSeek's data-dependent warmup could make different ranks choose different work before FULL capture.

The B12X-only candidate was:

if self._pcie_runtime is not None or _is_piecewise_cudagraph_runtime():
    return self.all_reduce(input, registered=False)

Two focused unit tests passed: B12X performed a real eager all-reduce, while legacy custom all-reduce retained its placeholder behavior. The report and candidate patch are vllm-b12x-graph-capture.md and vllm-b12x-capture-warmup.patch.

This distinction matters: the warmup patch was a strong source-level hypothesis, but it was not part of the final minimal v7 image. We did not rewrite history after finding a production route that worked.

The production answer was size-aware routing

Small all-reduces are not automatically good candidates for a custom PCIe kernel. Launch overhead and synchronization can dominate. We added VLLM_PCIE_ONESHOT_ALLREDUCE_MIN_SIZE and measured zero-context C1 decode at three cutoffs:

B12X minimum size C1 decode
16 KiB 210.7 tok/s
32 KiB 222.6 tok/s
64 KiB 227.0 tok/s

The final configuration used both a 64 KiB minimum and a 64 KiB maximum. In other words, only exactly 64 KiB reductions used B12X one-shot; smaller and larger operations used NCCL. The confirmation run measured:

Concurrency Aggregate decode
1 229.4 tok/s
8 929.2 tok/s
16 1,322.0 tok/s
32 1,881.0 tok/s
64 2,745.4 tok/s

The cutoff artifacts are stored under blog/data. The final policy is documented in the B12X TP3 overlay README.

What this debugging sequence changed

The result was not "odd GPU counts only needed one switch statement." It was a three-layer correction:

  1. Make world size three representable in validation and CUDA dispatch.
  2. Establish correct IPC initialization-before-publication ordering.
  3. Route each message-size regime to the collective that behaved best in the complete server.

It also reinforced a useful distributed-debugging rule: reduce the system until the first wrong event is visible. The full model hung in graph capture, but the transport's first eight-element operation already contained the most important correctness bug. Finding that first saved us from diagnosing model kernels, MTP, and scheduler state around a barrier signal that had simply been erased.

DSpark on TP3: Three Correctness Bugs and a Matched MTP2 Benchmark
DSpark TP3 needed fixes in virtual padding, TileLang head geometry, and draft-layer RoPE identity before a fair matched comparison with standard MTP2.

Once standard DeepSeek V4 Flash with MTP2 was stable at TP3, DSpark looked like the obvious next experiment. It had attractive sequential coding performance on two GPUs, and its five-token draft can outperform standard MTP when acceptance is high.

The server did not need one DSpark TP3 fix. It needed three independent fixes in configuration, attention geometry, and layer identity. Two produced obvious shape failures. The third produced plausible output with bad late-position acceptance, which made it the more dangerous bug.

Bug one: the draft never received virtual TP

The v9 virtual tensor-parallel plan was applied to draft configurations only when method="mtp". DSpark uses method="dspark", so its draft inherited the unmodified checkpoint geometry even though the target model had been expanded to the TP3 virtual shapes.

The correction was small but semantically important: both standard MTP and DSpark draft configurations must receive the same virtual TP plan as the base model. A draft model cannot verify a target using a different head and expert partition.

Bug two: 24 heads were legal to the model and illegal to TileLang

The virtual plan expands 64 attention heads to 72, producing 24 logical heads per TP3 rank. DSpark's TileLang attention kernel requires a multiple of 16.

The runtime now pads each local 24-head input to 32 heads, executes the kernel, and slices the output back to the first 24 logical heads. A focused GPU harness compared the padded implementation with a PyTorch reference at rtol=0.03, atol=0.03.

This is the kind of padding that is safe only when the boundary is explicit: kernel-local padding must not leak into model-global head counts or weight loading.

Bug three: mtp.0 was both a name and the wrong layer ID

DSpark has three stages stored under prefixes:

mtp.0
mtp.1
mtp.2

Generic prefix parsing treated the suffix as a model layer ID. It therefore looked up compression entries 0, 1, and 2. The actual draft stages are after the 43 target layers, so they need entries 43, 44, and 45.

That difference changes attention semantics. Entry 2 has compression ratio 4. Entries 43 through 45 are zero, where zero is a checkpoint sentinel meaning uncompressed KV with plain, non-YaRN RoPE. Treating a draft stage as layer 2 gave it the wrong compression mode and rotary embedding.

The fix preserved checkpoint parameter names while passing an explicit configuration layer ID of num_hidden_layers + stage_id. A raw ratio zero is represented internally as operational ratio one plus a separate use_unscaled_rope flag. That keeps zero out of KV size arithmetic while still selecting plain RoPE.

This same class of issue later motivated upstream vLLM PR #48304, which teaches DeepSeek V4 attention to honor checkpoint draft-layer compression entries.

Validation before the A/B

The candidate passed:

  • stage mapping 0/1/2 to configuration layers 43/44/45;
  • decoder forwarding and zero-ratio clamping;
  • plain-RoPE selection and FP32 RoPE cache selection;
  • the 24-to-32 TileLang GPU reference test;
  • loading all 48 composite checkpoint shards and 96 draft parameters;
  • PIECEWISE and FULL CUDA graph capture; and
  • a complete benchmark without request errors.

Acceptance by draft position improved from approximately 73/47/29/17/9% before the RoPE correction to 75/52/35/23/15% over the full post-fix matrix. A focused 8K sample immediately after restart measured 78/60/46/35/27%. Acceptance depends on prompt and concurrency, so these are diagnostic distributions, not fixed model constants.

The combined patch is vllm-v9-dspark-tp3.patch.

Matched DSpark N5 versus standard MTP2

Both sides used the same host, three GPUs at 450 W, FP8 KV, 524,288 maximum model length, 64 maximum sequences, 8,192 maximum batched tokens, breakable CUDA graphs, and B12X one-shot all-reduce only at 64 KiB. Each matrix cell ran for ten seconds after warmup.

Zero-context sustained decode

C DSpark N5 MTP2 DSpark delta
1 219.7 213.2 +3.0%
2 388.5 358.8 +8.3%
4 545.6 600.8 -9.2%
8 813.4 899.1 -9.5%
16 1,130.0 1,284.0 -12.0%
32 1,460.9 1,875.3 -22.1%
64 2,282.2 2,689.5 -15.1%

8K-context sustained decode

C DSpark N5 MTP2 DSpark delta
1 396.3 272.1 +45.6%
2 382.5 386.5 -1.0%
4 710.9 581.9 +22.2%
8 947.1 856.7 +10.6%
16 1,156.2 1,158.2 -0.2%
32 1,508.7 1,696.1 -11.0%
64 2,425.9 2,644.5 -8.3%

The 8K/C1 synthetic prompt had unusually high acceptance on both backends. We do not generalize its 45.6 percent result to arbitrary coding or chat traffic.

Capacity, prefill, and coding

Metric DSpark N5 MTP2 DSpark delta
Coding median 297.0 tok/s 228.5 tok/s +30.0%
Coding maximum 306.3 tok/s 234.1 tok/s +30.8%
8K prefill 10,923 tok/s 11,645 tok/s -6.2%
64K prefill 10,595 tok/s 10,580 tok/s +0.1%
KV capacity 2,995,972 3,287,424 -8.9%

The raw results are DSpark N5 and standard MTP2.

Why production returned to MTP2

DSpark won the sequential coding probe and several low-to-mid-concurrency 8K cells. Standard MTP2 had three advantages for the mixed production workload:

  1. It retained 8.9 percent more KV capacity.
  2. It was substantially faster at C32 and C64.
  3. It used the simpler standard checkpoint and already-proven production path.

The DSpark candidate was therefore a successful engineering result without becoming the default deployment. After the A/B, production returned to ai01/vllm-v9-b12x-tp3:20260710-v7-routing, standard MTP2, B12X A16, TP3.

That outcome is a useful reminder about speculative decoding benchmarks. Draft depth is not throughput. Acceptance, verifier cost, KV footprint, graph shape, prompt distribution, and scheduler concurrency all decide whether a deeper draft is valuable. DSpark N5 was better at some real tasks. MTP2 was better for the service as a whole.

Two GPUs, Two Backends: B12X A16 vs Lucifer CUTLASS at TP=2
A matched v9 backend comparison across 8K through 256K context showed that neither B12X A16 nor Lucifer CUTLASS wins every concurrency and context regime.

Backend comparisons are often reduced to one throughput number. Our DeepSeek V4 Flash comparison refused to cooperate. B12X A16 and Lucifer CUTLASS each won meaningful parts of the matrix, and the winner changed with context length and concurrency.

That is more useful than a universal winner. It tells us which path is serving which workload well, where a result is large enough to trust, and where another run is needed before changing a deployment.

What was actually matched

The two runs used the same ai01 host, the same pair of RTX PRO 6000 Blackwell Workstation Edition GPUs, and the same 450 W power limit per card. Both served the standard DeepSeek V4 Flash checkpoint with tensor parallel size two, FP8 KV cache, and two-token MTP speculative decoding.

Control Value
Model deepseek-ai/DeepSeek-V4-Flash
Model revision 6976c7ff1b30a1b2cb7805021b8ba4684041f136
vLLM line v9 dev/eldritch-enlightenment stack
Hardware 2 x RTX PRO 6000 Blackwell 96 GB, PCIe
Power limit 450 W per GPU
Tensor parallel size 2
Speculative mode standard MTP, two draft tokens
Decode contexts 8K, 16K, 64K, 128K, 256K
Concurrency 1, 2, 8, 16
Measurement 20 seconds per sustained-decode cell
Prefill standalone cold profile
Benchmark llm-inference-bench 0.4.29

The only intended deployment-path change was BACKEND:

BACKEND=b12x-a16
BACKEND=lucifer-cutlass

That switch is broader than one GEMM. The B12X path selected B12X sparse MLA, MoE, and linear implementations with A16 MoE activations. The Lucifer path selected FlashInfer sparse MLA and FlashInfer CUTLASS MXFP4 MoE. Common model, MTP, scheduler, cache, and communication controls remained unchanged.

This is therefore a comparison of two complete serving paths. It is not a microbenchmark that attributes every delta to a single kernel.

The launch selector lives in run-ds4-v9-server.sh. The public baseline for this stack is the pinned v9 model card.

Sustained decode

The following values are aggregate generated tokens per second. Each pair of rows shares a context length.

Context Backend C1 C2 C8 C16
8K B12X A16 238.5 399.0 758.7 999.8
8K Lucifer CUTLASS 235.5 347.0 778.2 1,110.9
16K B12X A16 224.0 372.6 719.8 987.1
16K Lucifer CUTLASS 227.4 353.6 770.2 1,023.7
64K B12X A16 212.8 391.6 730.1 976.4
64K Lucifer CUTLASS 242.5 339.7 724.6 1,046.5
128K B12X A16 215.6 332.4 668.7 980.7
128K Lucifer CUTLASS 212.4 335.7 649.2 939.9
256K B12X A16 215.4 356.4 651.2 845.8
256K Lucifer CUTLASS 206.1 326.7 620.4 873.0

Expressed as Lucifer relative to B12X:

Context C1 C2 C8 C16
8K -1.3% -13.0% +2.6% +11.1%
16K +1.5% -5.1% +7.0% +3.7%
64K +14.0% -13.2% -0.8% +7.2%
128K -1.5% +1.0% -2.9% -4.2%
256K -4.3% -8.3% -4.7% +3.2%

The shape of this table matters more than the count of green cells. Lucifer was consistently good at C16 through 64K and posted a large C1 win at 64K. B12X was stronger in most C2 cells and across most of the 128K and 256K matrix. Neither path dominated the entire operating range.

The alternating C2 results are also a warning. A 13 percent swing is large, but the direction flips with context. Before treating that column as an intrinsic backend property, we would repeat it with randomized cell ordering and several independent server starts.

Standalone cold prefill

Lucifer had a much clearer short-to-medium-context prefill advantage:

Context B12X A16 Lucifer CUTLASS Lucifer delta
8K 10,925 11,910 +9.0%
16K 10,703 11,654 +8.9%
32K 10,436 11,275 +8.0%
64K 9,963 10,672 +7.1%
128K 9,123 9,178 +0.6%
256K 7,851 7,346 -6.4%

Up through 64K, the Lucifer path was seven to nine percent faster. The paths converged at 128K, then B12X led at 256K. A backend recommendation based only on the 8K point would have missed that crossover.

What we concluded

For this exact v9 configuration:

  1. Lucifer CUTLASS was the better ingest path from 8K through 64K.
  2. Lucifer also had useful C16 decode wins at short and medium contexts.
  3. B12X A16 retained stronger results in much of the 128K and 256K matrix.
  4. Neither backend justified a blanket "always faster" claim.

That made workload shape the deciding input. A service dominated by moderate context ingest and concurrent short requests could reasonably prefer Lucifer. A service expected to hold long contexts and mixed concurrency had a stronger case for B12X. A mixed fleet could expose both and route by workload, provided the operational cost of maintaining two images is acceptable.

What this benchmark does not prove

There was one complete run per backend, not a distribution over many cold starts. Differences below roughly five percent should be treated as provisional unless they repeat.

The benchmark measured aggregate sustained decode, not per-user inter-token latency. It did not run burst/end-to-end tests or a quality evaluation. The client-side hardware sampler also ran on the Windows benchmark machine rather than inside ai01; its one-GPU power fields do not describe the two server GPUs. The 450 W cap was configured separately on the server, so these artifacts must not be used to claim measured joules per token.

The raw artifacts are:

Their hashes are recorded in data/README.md. Publishing the raw data matters here because the honest conclusion is conditional. The matrix, not one selected cell, is the result.

Benchmarking DeepSeek V4 TP2 v10 Without Fooling Ourselves
Warmup, prefix reuse, token targeting, KV accounting, and near-million-token prefill can all produce believable but wrong results. Here is what survived the checks.

The easiest way to get a spectacular long-context benchmark is to measure the wrong thing. Reused prefix KV, inaccurate token targeting, post-capture clock settling, and late JIT compilation can all turn a valid-looking number into a property of the benchmark procedure rather than the model server.

Our later DeepSeek V4 Flash TP2 work therefore separated three questions:

  1. How much KV capacity did the server expose?
  2. How much sustained decode did it deliver after warmup?
  3. Could it ingest a genuinely cold prompt near one million tokens?

Those questions use different tests. Their numbers should not be combined into one score.

The v10 profile

The local full-matrix run used the DSpark checkpoint and Lucifer CUTLASS on two RTX PRO 6000 Blackwell GPUs:

Setting Value
Image voipmonitor/vllm:fathomless-firmament-ds4-v10-vllmadf15ca-b12x90172a5-fi2cba2f7-cu132-20260712
Model deepseek-ai/DeepSeek-V4-Flash-DSpark
Hardware 2 x RTX PRO 6000 Blackwell 96 GB, PCIe, 450 W cap
Tensor parallel size 2
Decode context parallel size 1
Speculative mode DSpark
Attention and MoE Lucifer / FlashInfer CUTLASS
KV cache FP8
Maximum model length 1,048,576
Maximum sequences 64
Maximum batched tokens 6,144
GPU memory utilization 0.95
Maximum graph capture size 192
Prefix cache enabled
All-reduce B12X
Load format InstantTensor, buffered

The exact local profile is deepseek-v4-flash-v10-dspark-tp2-graph192-batch6144-gpu95, launched by run-ds4-v10-server.sh.

Several upstream changes explain why this stack was worth testing:

  • vLLM PR #48303 wires FlashInfer CUTLASS MXFP4 MoE correctly for DeepSeek-family models on SM120.
  • vLLM PR #48304 honors the checkpoint's draft-layer compression entries and unscaled draft RoPE.
  • vLLM PR #48317 fixes maximum-concurrency reporting for hybrid KV layouts by counting each group's whole blocks.

The broader release procedure and backend sweep are documented in the v10 model card. Our local artifact is a focused test of one profile, not a replacement for that full synchronized sweep.

Sustained decode after warmup

llm-inference-bench 0.4.30 ran a hidden 30-second C1 warmup at 32K, waited for each cell to reach its requested active-stream count, then measured each cell for 15 seconds. Aggregate throughput came from OpenAI continuous usage completion-token counts.

Context C1 C4 C8 C16 C32
0 226.1 501.0 734.2 1,105.5 1,655.3
4K 264.1 562.1 834.7 1,192.8 1,833.7
8K 268.7 525.2 875.0 1,193.4 1,692.7
16K 248.7 491.3 855.8 1,330.9 1,861.5
32K 250.4 504.8 776.7 1,225.2 1,673.4

This was a fully shared-prefix matrix: the artifact records unique_context_percent=0. It measures a useful serving case in which many requests share the same input KV. It does not measure 32 independent 32K contexts. That alternative requires --unique-context-percent 100, consumes far more KV, and should be reported as a different workload.

The same run exposed a benchmark KV budget of 1,149,077 tokens. That is enough to admit one near-million-token request with some headroom. It does not mean that MAX_NUM_SEQS=64 can admit 64 such requests. MAX_NUM_SEQS is a scheduler ceiling; the actual concurrent long-context limit is still bounded by the per-group KV block pool.

Prefill and a coding probe

Standalone cold-prefill samples used unique request prefixes and reported client-observed prompt tokens divided by time to first token:

Context Cold prefill
8K 11,656 tok/s
16K 11,780 tok/s
32K 11,462 tok/s
64K 10,755 tok/s
128K 9,687 tok/s

The ten-run sequential Sieve-of-Eratosthenes probe measured a median of 308.7 generated tok/s, with a range of 296.6 to 327.2 tok/s. That is a useful repeatable coding-shaped probe, but it is still one prompt. It does not stand in for a broad code-quality or agent-workload evaluation.

The full artifact is ai01-ds4-v10-dspark-1m-graph192-20260713-132554.json.

A real near-million-token prefill takes minutes

We also ran focused prefill-only probes at 1,044,482 actual prompt tokens:

Mode Prompt tokens TTFT Cold prefill
Standard MTP2 1,044,482 261.157 s 3,999 tok/s
DSpark 1,044,482 285.578 s 3,657 tok/s

Both results demonstrate a successful near-1M request. They are not a matched backend A/B. The MTP2 probe used a 0.912 GPU-memory setting, while the DSpark probe used the 0.95, graph-192 profile, and speculative modes have different KV and compute costs. Each artifact contains only one completed cold sample. The 9.4 percent arithmetic difference is therefore an observation, not a claim that MTP2 is universally 9.4 percent faster.

The accepted artifacts are:

The impossible results we rejected

Two earlier artifacts looked spectacular:

Probe Recorded prompt Median TTFT Reported rate Server-validation samples
MTP2 early attempt 1,044,480 1.117 s 3,307,181 tok/s 0
DSpark early attempt 1,044,480 1.070 s 6,948,653 tok/s 0

They failed basic dimensional and comparative checks. The saved tok/s field does not equal the saved prompt count divided by the saved TTFT because the tool took medians of those fields independently over two samples. More importantly, a one-second cold TTFT was incompatible with the neighboring 128K curve and the corrected 261-to-286-second 1M probes.

These attempts came from the token-targeting and cache-validation phase. With no server-validation samples in either artifact, we cannot prove how much of the error came from inaccurate target construction, usage-count fallback, or cache reuse. We can prove that the reported rate is not a valid cold 1M measurement, so it was excluded.

The rejected artifacts remain available for audit:

Keeping failed measurements is useful. It makes the rejection rule visible instead of allowing an implausible number to disappear without explanation.

API ready is not benchmark ready

The local benchmark's hidden warmup helps, but the v10 release sweep went further:

  1. Start every server in the synchronized GPU wave.
  2. Wait for every /v1/models endpoint.
  3. Wait 30 seconds after the final server reports ready.
  4. Run unreported C1, C16, C32, C64, and 8K/64K/128K prefill warmups.
  5. Wait another 30 seconds and mark the server-log measurement boundary.
  6. Start all benchmark clients only after the entire wave is ready.
  7. Reject a result if JIT compilation or a post-engine cache miss appears after that boundary.

That process was motivated by measured behavior. The public v10 card reports a same-image C1 case at roughly 133 tok/s seven seconds after long graph capture and 141 tok/s after a 30-second settle. Some Triton and CuTeDSL shapes can also compile after the model API is responsive.

For repeatable comparisons, warmup is therefore part of the test definition. A useful artifact should record:

  • exact model, image, profile, and power limit;
  • actual prompt-token counts, not only requested context labels;
  • TTFT and sample count alongside prefill tok/s;
  • shared versus unique context percentage;
  • KV capacity and capacity-limited cells;
  • the unreported warmup workload; and
  • server logs proving no measured-phase JIT or cache miss.

The most important v10 result was not the largest number. It was learning which numbers survived those checks.

Untangling Local Model Routing: LiteLLM, DAVE, and the Four-Layer Fix
Standardizing model access through LiteLLM with predictable aliases meant fixing four separate layers before the live agent stopped failing — config, agent model store, auth profiles, and session overrides.

Today was mostly about cleaning up the AI stack so the agents point at the right models consistently. The goal was straightforward on paper — standardize model access through LiteLLM using simple, predictable aliases — but the actual fix had to touch four layers before the live agent stopped failing.

The Naming Scheme

Instead of every agent knowing where a model physically runs, we moved toward stable names:

pve03-dsv4         ← DeepSeek-V4-Flash-DSpark on PVE03
pve03-qwen27b      ← Qwen3.6-27B-FP8 on PVE03
pve01-qwen27b      ← Qwen3.6-27B-FP8 on PVE01
codespc-ornith35   ← Ornith-1.0-35B on codespc
codespc-qwen35b    ← Qwen3.5-35B on codespc

That gives the agents stable names while the backend hardware, actual model builds, and quantization formats can all change underneath. LiteLLM sits in the middle as a thin routing layer.

Hermes: The Easy Part

Hermes was straightforward. Its config was updated to point at the LiteLLM gateway over Tailscale:

http://100.85.208.87:4000/v1

I cleaned up the model list so it only sees the intended chat models — no rerankers, no embedders, no old broken aliases. A quick smoke test through pve03-dsv4 returned ready. One config file, one test, done.

DAVE (OpenClaw): The Tricky Part

DAVE, the OpenClaw instance running on llmed-tars01, was a different story. The main OpenClaw config looked correct after updating it, but DAVE still threw:

Missing API key for the selected provider on the gateway

The cause was that OpenClaw has more than one place involved in model and auth resolution. The main config was updated, but the agent-local model store still had older provider data. The fix involved three files:

  • /home/tars01/.openclaw/openclaw.json — the main (global) config
  • /home/tars01/.openclaw/agents/main/agent/models.json — the agent-local model store
  • The formal auth profile store — needed the LiteLLM key registered as litellm-v2:manual

After those three were consistent, openclaw models status showed LiteLLM auth working correctly.

The Session Override Trap

The final issue was more subtle. DAVE’s active Telegram session was still pinned to the old direct Ornith model:

codespc-ornith/huggingface.co/bartowski/deepreinforce-ai_ornith-1.0-35b-gguf:Q5_K_M

So even after the provider config was fixed — UI looked right, models status showed green — Telegram messages kept failing because the active session override pointed at a model ID that no longer matched anything. Updating the session pin in:

/home/tars01/.openclaw/agents/main/sessions/sessions.json

fixed the real runtime failure.

Final Checks

All three passed:

Hermes -> pve03-dsv4        -> ready
DAVE raw model run           -> pve03-dsv4 -> ready
DAVE full agent session      -> pve03-dsv4 -> ready

The Lesson

Model alias cleanup on a multi-agent stack needs to cover four layers, not one:

LayerFileGotcha
Main configopenclaw.jsonObvious, gets fixed first
Agent model storeagents/*/agent/models.jsonSeparate from global config — easy to miss
Auth profile storeFormal auth storeAPI keys need to be registered separately
Session overridessessions/*/sessions.jsonUI shows right config; live agent still uses the pinned old model

If any one of these still references an old model name, the UI may look right while the live agent still fails. The session override is especially nasty — it’s a layer most people won’t think to check until they’ve verified everything else and the error still comes back.

Qwen3.6-27B-FP8 on 2x RTX 5090s
Getting Qwen3.6-27B-FP8 running on PVE01's dual-RTX 5090 rig — vLLM launch config, GPU memory tuning, quick benchmarks, and power management lessons.

Useful result of the day: Qwen/Qwen3.6-27B-FP8 is now running cleanly on the PVE01 2x RTX 5090 rig with vLLM, LiteLLM, and OpenWebUI access.

To be clear, the model is not running directly on the Proxmox host. The stack is:

PVE01 host
  → LXC 102 / vllm01
    → Docker container
      → vLLM on port 8020

That nested LXC-to-Docker setup does not appear to meaningfully hurt inference performance. The actual work is happening in CUDA kernels on the GPUs. The bigger limiter is the hardware topology: the two RTX 5090s sit behind PHB (PCIe Host Bridge), not PIX or NVLink, so vLLM disables custom all-reduce and falls back to NCCL/PYNCCL.

The Stable Launch Config

After some tuning, the launch that stuck:

repne/vllm:v12
Qwen/Qwen3.6-27B-FP8
--tensor-parallel-size 2
--gpu-memory-utilization 0.955
--max-model-len 262144
--max-num-seqs 128
--max-num-batched-tokens 32768
--max-cudagraph-capture-size 256
--language-model-only
--enable-prefix-caching
--attention-backend flashinfer
--reasoning-parser qwen3
--tool-call-parser qwen3_xml

The GPU Memory Utilization Lesson

The important tuning lesson was that 0.960 GPU memory utilization was too aggressive. It booted fine — vLLM started, loaded the model, reported everything healthy. But under real long-context load it failed. At 0.955, vLLM reports about 11.53 GiB of KV cache, 369,769 GPU KV tokens, and roughly 1.41x concurrency at the full 262,144 context length.

Quick Benchmarks

A quick benchmark at 0 and 32K context looked solid:

ContextC=1 (tok/s)C=2 (tok/s)
0 (zero)65.2125.9
32K62.0120.1

That is the encouraging part: 32K context barely changed decode speed, and concurrency 2 scaled almost linearly in this light test.

Power Management

Power was the other practical finding. Letting multiple rigs benchmark at once caused enough electrical draw to knock things over, so the 5090s on PVE01 are now capped to 450W each at boot with a systemd service. That should keep heat and power behavior more predictable without sacrificing much LLM throughput.

Net Result

PVE01 now has a fast, long-context, 27B-class local model behind LiteLLM as pve01-qwen27blnarize, with 262K context available and a sane power envelope.

Finding a Hidden DeepSeek-V4 Bottleneck: PCIe Topology Matters
Moving both RTX PRO 6000 GPUs onto the same PCIe switch path and clearing ACS redirect gave +25% prefill and up to +40% decode on DeepSeek-V4-Flash-DSpark — a reminder that physical topology still matters.

I’ve been running DeepSeek-V4-Flash-DSpark on pve03, a Proxmox host with two RTX PRO 6000 Blackwell-class GPUs. The model was working, but prefill performance felt lower than expected compared with other similar dual-GPU setups.

At first, the software side looked pretty reasonable. The vLLM launch was using the DSpark v8-style stack:

  • vLLM 0.11.2 dev build
  • DeepSeek-V4-Flash-DSpark
  • tensor_parallel_size=2
  • max_model_len=1,048,576
  • gpu_memory_utilization=0.94
  • kv_cache_dtype=fp8
  • FlashInfer MLA sparse DeepSeek-V4 attention
  • flashinfer_cutlass MoE backend
  • DSpark speculative decoding
  • b12x PCIe allreduce enabled

The model had about 1.345M KV-cache tokens available, enough for a full 1M context request with some headroom. But benchmark numbers suggested something was holding the system back.

Before: Working, But Slower Than Expected

Before changing the physical GPU layout, the cards showed up as NODE distance in nvidia-smi topo -m. P2P technically worked, but the path between GPUs was not ideal.

Old prefill performance was roughly:

  • 8k–128k prefill: ~5.6k–5.9k tok/s

C=1 decode was also lower than expected:

  • 0k: ~159 tok/s
  • 16k: ~159 tok/s
  • 32k: ~153 tok/s
  • 64k: ~189 tok/s
  • 128k: ~191 tok/s

P2Pmark confirmed the weak interconnect path:

  • 1:1 P2P memcpy: ~9.8 GB/s
  • Ring per GPU: ~8.8 GB/s
  • All-to-all total: ~17.4 GB/s
  • Allreduce best bus: ~6.8 GB/s
  • Remote-read latency: ~1.45 us

ACS redirect was also enabled on several PCIe bridges, which can force peer traffic upstream instead of letting it take the shortest local path.

The Fix: Move Both GPUs Onto the Same PCIe Switch Path

The motherboard is a Supermicro X11SPA-TF/-T. After looking at the block diagram and current lspci topology, I moved both GPUs so they landed behind the same PCIe switch/root path.

After the move:

  • GPU0 <-> GPU1 = PIX
  • P2P read/write = OK
  • Both GPUs = x16 width
  • ACS ReqRedir/CmpltRedir = cleared

The important topology changed from NODE to PIX. Then I cleared ACS redirect bits again.

After: Big Gains

The prefill improvement was immediate.

New prefill results:

  • 8k: 7,280–7,299 tok/s
  • 16k: 7,477–7,514 tok/s
  • 32k: 7,487–7,500 tok/s
  • 64k: 7,326–7,337 tok/s
  • 128k: 6,962–6,967 tok/s

That is roughly a +20% to +30% prefill improvement.

C=1 decode improved too:

  • 0k: 200.5 tok/s
  • 8k: 216.0 tok/s
  • 16k: 221.0 tok/s
  • 32k: 216.4 tok/s
  • 64k: 190.4 tok/s
  • 128k: 236.1 tok/s

Compared with the earlier run, some C=1 decode cases improved by around 25% to 40%.

P2Pmark explained why:

  • 1:1 P2P memcpy: ~14.1 GB/s (+44%)
  • Ring per GPU: ~13.35 GB/s (+52%)
  • All-to-all total: ~26.5 GB/s (+52%)
  • Allreduce best bus: ~10.3 GB/s (+51%)
  • Remote-read latency: ~1.13 us (~22% lower)
  • Dense score: 0.94 (was 0.89)
Summary of performance gains
Summary of all performance gains after the PCIe topology fix

The model was not just mildly sensitive to PCIe layout. Tensor-parallel communication was being materially throttled by the old slot placement.

Making It Persistent

Because ACS settings reset on reboot, I added a boot-time systemd service that:

  • Clears ACS ReqRedir and CmpltRedir
  • Enables NVIDIA persistence mode
  • Sets both GPUs to a 450W power limit instead of 600W

Current confirmed boot-tune state:

  • service: pve03-gpu-boot-tune.service enabled
  • GPU0 power limit: 450W
  • GPU1 power limit: 450W
  • ACS redirect: off
  • topology: PIX
  • DeepSeek-V4: up
  • max_model_len: 1,048,576

Takeaway

The biggest performance win was not a vLLM flag. It was physical topology.

For this DeepSeek-V4-Flash-DSpark setup, moving both GPUs onto the same local PCIe path and clearing ACS redirect produced:

  • Prefill: ~25% faster
  • C=1 decode: up to ~40% faster
  • P2P bandwidth: ~44–52% better
  • Allreduce: ~51% better
  • Latency: ~22% lower

On older PCIe 3.0 Xeon platforms, slot placement and ACS behavior can matter a lot. If a multi-GPU model feels slower than expected, nvidia-smi topo -m, ACS state, and P2Pmark are worth checking before chasing more exotic software tuning.

DIY Vision RAG — Chandra OCR-2 on a Spare GPU
Turning an idle RTX PRO 6000 into a self-hosted vision RAG pipeline with Chandra OCR-2, Qwen3-VL embeddings, and ChromaDB.

Back in late June, a Discord conversation with Ixtrix kicked off what turned into the homelab's first proper vision RAG pipeline. The premise was simple: I run DeepSeek-V4-Flash-DSpark on 2 of my 3 RTX PRO 6000s (96 GB each), and that 3rd GPU — a ~72 GB Blackwell card — was sitting completely idle. What could it do?

The Candidate: Chandra OCR-2

Ixtrix pointed me at Chandra OCR-2 by Datalab — currently the best model on the olmOCR benchmark at 85.8% (vs Gemini 2.5 Flash at 67.3% and GPT-4o at 69.9%). It outputs native HTML with data-bbox bounding boxes — meaning tables, handwriting, multi-column layouts, and 90 languages all come back as structured markup rather than a flat text blob. For the "give DeepSeek eyes" goal, that was the right shape.

The NVFP4A16 quant from dangvansam/chandra-ocr-2-NVFP4A16 clocks in at ~5.4 GB — barely a dent in a 72 GB card — and runs 2.5× faster than the bf16 baseline thanks to Blackwell's native FP4 tensor cores. Deployed it as a vLLM container (scope-chandra-ocr) on PVE01's vllm01 LXC, port 8020, GPU 0.

The Vision RAG Stack

OCR alone isn't RAG. The vision from Ixtrix's recommendations was a pipeline that preserved document structure through every stage:

PDF/Image → Chandra OCR-2 → Qwen3-VL-Embedding-8B-FP8
                                          ↓
                                   ChromaDB (vector store)
                                          ↓
                               Qwen3-VL-Reranker-2B
                                          ↓
                               Qwen3.6-35B-A3B MoE NVFP4
                                          ↓
                                   FastAPI (ingest/search/chat)

Chandra OCR-2 turns raw document pages into structured HTML. The Qwen3-VL-Embedding-8B-FP8 (~8 GB) creates vision-aware embeddings — it understands tables, charts, and layouts, not just raw text. Qwen3-VL-Reranker-2B (~4 GB) re-ranks top candidates for precision. Both are small enough to share GPU 1 alongside the LLM.

The LLM itself is Qwen3.6-35B-A3B MoE NVFP4 (~20 GB) — a 35B-parameter MoE model where only ~3B params activate per token. In NVFP4 quant it fits comfortably on a single RTX PRO 6000 with headroom for the embedding model.

The Backend Pattern

The FastAPI service follows a three-endpoint pattern from the rag-backend skill:

POST /ingest — upload a file, Chandra OCRs it, chunks it, embeds it, stores in ChromaDB.
POST /search — query in → retrieve top chunks → return results (no LLM call).
POST /chat — query in → retrieve → ask Qwen 3.6 → return an answer with sources.

ChromaDB runs embedded in the Python process — no separate container — persisting to disk. Microsoft Entra ID SSO validates JWTs from the existing frontend. In dev mode (no env vars set), auth is bypassed for local testing.

Hardware Fit

The 3rd GPU (RTX PRO 6000 Blackwell, 72 GB usable) splits cleanly:

GPU 0: Chandra OCR-2 (~6 GB) + Qwen3-VL-Reranker-2B (~4 GB) = ~10 GB, leaving ~62 GB free for batch OCR workloads.
GPU 1: Qwen3.6-35B-A3B MoE NVFP4 (~20 GB) + Qwen3-VL-Embedding-8B-FP8 (~8 GB) = ~28 GB, leaving ~44 GB free.

Total: ~38 GB utilized out of 144 GB across both GPUs. Plenty of headroom for concurrent requests, larger batch sizes, or adding TTS/STT models later.

Why Chandra Over the Alternatives

Ixtrix's advice was blunt: "ignore Paddle, Chandra is better." The benchmarks back it up:

ModelScoreNotes
Chandra OCR-285.8%Open source, structured HTML output
DeepSeek OCR75.4%Good tables, closed source
Gemini 2.5 Flash67.3%Paid API, no self-host
GPT-4o (Anchored)69.9%Paid API

Chandra wins on quality, runs locally, costs nothing beyond the electricity, and the NVFP4 quant makes it trivially small for any Blackwell card.

What's Next

The backend is ready. The frontend — a clean single-page chat interface with upload, search, and browsing — is something I'm working through myself. Long-term, the pipeline handles anything from scanned PDFs to office documents, handwritten notes to multi-language financial statements. The "give the LLM eyes" goal is met; now it's about making that access seamless.

Full write-up on the RAG backend pattern and deploy process lives in the rag-backend skill. If you're running Blackwell hardware, Chandra OCR-2 in NVFP4 is a no-brainer addition to any idle GPU.

Palworld Dedicated Server — v0.7 → v1.0
Standing up a Palworld dedicated server on the homelab. Proxmox LXC, LinuxGSM, Caddy reverse proxy, and a live status dashboard with dark themes.

Palworld's v1.0 drops July 10, and the homelab needed a dedicated server for a friends-and-family playthrough. Here's the stack:

Container: Ubuntu 24.04 LXC on PVE01 (4 cores, 8 GB RAM, 40 GB disk). Static DHCP lease at 192.168.0.153, port-forwarded through the AT&T router.

Server: LinuxGSM's pwserver wrapper handles install, updates, and lifecycle. PalServer-Linux-Shipping runs in a tmux session with -publiclobby and query port 27015.

Web: Flask app with a JSON API (/api/services) powers the dashboard. PVE01 host stats come from node_exporter:9100 metrics. Caddy terminates TLS and reverse-proxies everything.

Dashboard: Single-page landing at dooner.tech — 4 themes (dark, console, light, amber), live server status, expandable hardware cards, and a countdown to v1.0. Copy-to-clipboard with visual feedback.

DeepSeek-V4 Flash on PVE03: Notes From a Homelab Run
Tuning and benchmarking DeepSeek-V4-Flash-DSpark on a Proxmox host with two 96GB-class NVIDIA GPUs. DSpark v8-style vLLM, PCIe topology findings, decode throughput data, and the remaining bottlenecks.

I spent some time tuning and benchmarking DeepSeek-V4-Flash-DSpark on pve03, a Proxmox host with two 96GB-class NVIDIA GPUs on an older Intel Xeon Scalable platform. The goal was to see how close we could get to the current DSpark/vLLM recipe while keeping the system stable for real agent usage.

The current run is based on the DSpark v8-style vLLM image and serves the model as DeepSeek-V4. The important launch choices are:

gpu_memory_utilization: 0.94
max_model_len: 1,048,576
max_num_seqs: 32
max_num_batched_tokens: 4096
max_cudagraph_capture_size: 216
kv_cache_dtype: fp8
attention_backend: FLASHINFER_MLA_SPARSE_DSV4
moe_backend: flashinfer_cutlass
speculative decoding: dspark, 5 speculative tokens
reasoning_effort: high
thinking: true

With that config, vLLM reported about 1.345M KV-cache tokens available, enough for one full 1M-token request plus some headroom. For normal use, the model is more likely to run many smaller agent requests than several huge-context requests at once.

Performance was solid but not perfect. Prefill landed around 5.8k–5.9k tok/s after clearing ACS redirect bits. Earlier sustained decode testing showed roughly:

C1:   ~159 tok/s
C2:   ~304 tok/s
C4:   ~412 tok/s
C8:   ~602 tok/s
C16:  ~866 tok/s
C32: ~1049 tok/s

The biggest hardware finding was PCIe topology. The GPUs were visible as NODE distance rather than being on the same local PCIe switch/root path. P2P worked, but bandwidth was weaker than expected: roughly 9.8 GB/s 1:1 peer copy, versus another comparable system showing about 14.2 GB/s. Interestingly, our all-reduce result was not terrible, but prefill still looked lower than people on newer or cleaner PCIe setups.

ACS was also enabled on several bridges. Clearing ReqRedir and CmpltRedir helped slightly, but not dramatically. The likely next physical tuning step is moving both GPUs into slots behind the same PEX8747 switch group on the Supermicro X11SPA board.

We also checked a "model down or laggy?" event. LiteLLM was healthy, vLLM was alive, and pve03 did not show CPU, disk, or iowait pressure. Grafana showed no meaningful vLLM queue backlog at the time. The more likely culprit was network behavior: an offsite Backblaze backup was running while the PVE hosts were on Wi-Fi. Even if pve03 itself was not saturated, shared Wi-Fi or WAN upload saturation can make Tailscale, HTTP, and SSH feel broken.

The short version: DeepSeek-V4-Flash-DSpark is running well on pve03, but the remaining bottlenecks look more like platform and network issues than vLLM issues. The biggest wins left are likely better PCIe placement, wired networking, and rate-limiting offsite backups.

Hermes + gbrain: Running an AI That Actually Remembers
Most AI assistants start each conversation from scratch. With Hermes Agent and a persistent knowledge base, mine knows the homelab topology, config quirks, and deployment workflows — across sessions.

Most AI assistants start each conversation with a blank slate. Ask a question, get an answer — done. But when you manage a homelab with dozens of services, 3 GPU hosts, 14 TB of storage, game servers, monitoring stacks, custom API endpoints, and a growing set of automation scripts, a stateless assistant is useless after the first turn.

That’s where Hermes Agent with gbrain — a persistent knowledge base — changes the game.

How It Works

Hermes is an open-source, tool-calling AI agent by Nous Research. It connects to any LLM backend (I route through LiteLLM to vLLM on PVE03), and comes with a suite of built-in tools: terminal, file system, web search, browser automation, image generation, and more. But the killer feature is gbrain, an MCP server that acts as long-term memory.

gbrain is a knowledge base of markdown pages — interlinked, taggable, searchable. Every durable fact I learn about the homelab goes in there: IPs, credentials (with safe storage), config quirks, port numbers, deployment workflows, troubleshooting notes. When Hermes starts a task, it queries gbrain first for relevant context before even touching the tools.

Real-World Example

When I asked Hermes to rebuild this website’s landing page, it didn’t ask me for the server IP, which container it was in, or how to deploy files. It queried gbrain, found the homelab topology, the Caddy config pattern, and the deploy workflow — and got it done in one shot. Days later, when I asked for a new blog post about a different project, it already knew the site structure, the Flask route layout, and the theme system.

Not Just Memory — A Brain

gbrain supports bidirectional links ([[page links]]), full-text search, and a graph traversal that lets it find connections across topics. If I add a note about “vLLM GPU config” that links to “PVE03 hardware specs” and “LXC passthrough notes”, Hermes can follow those links automatically when troubleshooting a crash.

It also has ambient signal capture — every message goes through a “signal detector” that checks whether what you just said deserves to be remembered. No explicit “save this” command needed.

The Bottom Line

Persistent memory transforms an AI assistant from a clever chatbot into something closer to a system administrator who never forgets what you told them last week. For homelabs where complexity grows fast, that continuity is worth more than any model upgrade.

Proxmox LXC: Why Containers Beat VMs for Most of My Services
GPU passthrough, near-zero overhead, and pct tooling make LXC containers the obvious choice for most homelab workloads. Here’s why I use them over VMs.

When I built PVE01 — a dual-Xeon box with an RTX PRO 6000, a second consumer GPU, and 128 GB of RAM — I knew I’d be running a mix of services: web apps, game servers, model inference, databases. The question was VMs or containers.

Proxmox LXC containers won handily, and here’s why.

Containers vs. VMs for a Homelab

LXC containers share the host kernel, which means near-zero overhead for CPU, memory, and I/O. On a host where every watt of GPU compute and every GB of RAM counts, that matters. A VM running the same web stack would burn 2–4 GB on the guest OS alone before running anything useful. LXC cuts that to essentially zero — my web container uses about 180 MB at idle.

The trade-off: you can’t run a different kernel or Windows in LXC. But for Linux-only workloads — Flask apps, game servers, vLLM, Caddy, PostgreSQL — that’s not a constraint.

GPU Passthrough: The Real Differentiator

vLLM serving DeepSeek-V4 needs direct GPU access. With a VM, you’d need full PCIe passthrough — rebinding the GPU, isolating it from the host IOMMU groups, and losing the card to the VM. With LXC, you just mount the NVIDIA devices and libraries into the container:

# In /etc/pve/lxc/CT_ID.conf:
lxc.cgroup2.devices.allow: c 195:* rwm
lxc.cgroup2.devices.allow: c 509:* rwm
lxc.mount.entry: /dev/nvidia0 dev/nvidia0 none bind,optional,create=file
lxc.mount.entry: /dev/nvidia-uvm dev/nvidia-uvm none bind,optional,create=file
lxc.mount.entry: /dev/nvidiactl dev/nvidiactl none bind,optional,create=file
lxc.environment: NVIDIA_VISIBLE_DEVICES=all

The host keeps its display and can still use the GPU for node_exporter, monitoring, or lighter tasks. The container gets full CUDA access. It’s the best of both worlds.

The LXC Toolchain

Proxmox’s pct command makes day-to-day management dead simple — push files, exec commands, snapshot, resize. My deploy workflow is literally:

scp file root@pve01:/tmp/
pct push 240 /tmp/file /var/www/file

No SSH setup inside the container, no IP lookups — just direct host-level operations.

When I’d Still Use VMs

  • Running anything non-Linux (unlikely here)
  • Needing kernel-level isolation for multi-tenant security
  • Testing custom kernels or OS-level configs

For everything else in a single-admin homelab, LXC is faster, leaner, and easier to manage.

Homelab Networking on a Locked-Down AT&T Gateway
Self-hosting behind AT&T fiber means working with a gateway that gives you almost no control. Here’s how DNS, Caddy, and a single public IP make it work.

When AT&T is your only fiber option, you take what you can get — and what you get is a locked-down gateway that only lets you port-forward to DHCP-device dropdown entries. No static DHCP leases, no custom DNS override, no split-tunnel options. That makes self-hosting at home a game of working with the limitations rather than fighting them.

The Setup

A single public IP (99.74.254.214), Cloudflare in grey-cloud (DNS-only) mode so the actual origin IP stays routable, and every self-hosted service running behind Caddy for automated TLS termination. Ports 80 and 443 forward from the AT&T gateway to the web container on 192.168.0.153. Everything else — game servers, SSH, anything non-HTTP — gets its own port forward to the right internal IP.

Why Grey-Cloud DNS

Cloudflare’s proxied (orange-cloud) mode is great for static sites, but it breaks WebSocket connections, blocks non-standard ports, and hides your real IP at the cost of making Cloudflare the TLS terminator. For a homelab where you control every service, grey-cloud + Caddy means you own the cert chain end-to-end and nothing breaks unexpectedly when you add a new subpath.

The AT&T Gateway Tax

The most frustrating limitation: you can only forward ports to devices the gateway has assigned a DHCP lease to. Since the router’s DHCP table is an opaque dropdown list with no static-lease option, a container reboot can change its IP if the lease timing shifts. The fix? A long lease reservation (24 hours+) and a monitoring script that alerts if the internal IP changes. I’ve also been eyeing a UDM Pro Max in IP passthrough mode to bypass the gateway entirely — that moves DHCP, DNS, and firewall into one real appliance.

The DNS Layer

Cloudflare handles DNS with a simple A record pointing to the public IP. Internal traffic stays on Tailscale, so latency-sensitive services (Palworld at ~2ms, vLLM inference) don’t hairpin through the public internet. External visitors hit the AT&T gateway → port 80/443 → Caddy → Flask app → done. Clean, minimal, and cheap.

Takeaways

  • Grey-cloud DNS + Caddy is the right combo for a dynamic homelab with many sub-services.
  • Reserved DHCP leases with fallback monitoring are mandatory on locked-down ISP gateways.
  • An IP-passthrough capable router (UDM Pro Max, pfSense box) is the eventual upgrade that fixes everything in one move.