GPU facts and the app-side optimisation plan
Date: 2026-08-24 · Companion to the benchmark report
The one-sentence finding: we never hit a GPU limit today. Every bottleneck we found was an application or configuration limit, and most of them were sized for the old single-GPU H100 box.
Part A — What we now know about the GPUs
Hardware, verified
| Old | New | |
|---|---|---|
| Cards | 2× H100 NVL, 95,830 MiB, 400 W | 16× B200, 183,359 MiB, 1000 W (2 nodes × 8) |
| Total HBM | 187 GB | 2,865 GB (15.3×) |
| NVLink | NV12 — a 2-GPU bridged pair, ~319 GB/s each way |
NV18 — all-to-all across all 8, ~956 GB/s each way |
| Fabric | point-to-point bridge | NVSwitch |
They are not "B200 NVL". NVL is specifically the two-GPU bridged variant; these are SXM parts on an 8-GPU NVSwitch baseboard, which is a strictly stronger interconnect.
NVLink is per-node. Node 1 ↔ node 2 is ordinary networking. This is why the 397B must fit inside one node at TP=8 — a cross-node tensor-parallel split would run over Ethernet.
Measured performance ceilings
| Configuration | Throughput | p50 | Bound by |
|---|---|---|---|
| 1 B200 replica @ conc 64 | 8,946 tok/s | 1,835 ms | GPU |
| 1 B200 replica @ conc 128 | 12,750 tok/s | 2,520 ms | GPU |
| 6 replicas (production) @ conc 512 | 58,764 tok/s | 2,179 ms | GPU |
| 16 replicas @ conc 1536 | 127,055 tok/s | 2,009 ms | GPU (92% util, 99% samples) |
| 2× H100 NVL @ conc 512 (peak) | 20,813 tok/s | 5,690 ms | GPU |
Fleet scaling is 89% efficient at matched per-replica concurrency (7,928 vs 8,946 tok/s per GPU). The cluster scales well; nothing about the hardware is the problem.
Three things that are NOT true
- "The B200 is much faster per card." It is ~21–31% faster than an H100 NVL on this model. The 6.1× fleet gain is GPU count and memory, not per-card speed.
- "FP4 will double throughput." The
nvidia/…-NVFP4checkpoint isMIXED_PRECISIONwith W4A16 experts — 4-bit weights, 16-bit activations. It never touches the FP4 tensor cores and measured 4–11% slower. - "More power = more efficiency." Perf-per-watt got worse: 9.7 tok/s/W on H100 vs 6.3 on B200 for this small-MoE workload.
Part B — Every bottleneck we found was app-side
Six were found today. None were the GPU.
| # | Component | Was | Cap it imposed | Real capacity | Status |
|---|---|---|---|---|---|
| 1 | Benchmark client (threads) | 1 OS thread/request | 45% of fleet hidden | — | fixed (async) |
| 2 | ahu-gpu-server uvicorn |
1 process on 256 cores | collapsed at conc 24 | — | fixed (8 workers) |
| 3 | Celery worker-llm / -akta |
4 / 2 on 256 cores | — | — | fixed (16 / 8) |
| 4 | PADDLE_OCR_WORKERS |
4 | 37.8 req/s | 47.7 req/s | fixed (16) |
| 5 | Gateway qwen-35b slots |
96 | 15,700 tok/s | 53,619 tok/s | FIXED → 512 |
| 6 | Gateway paddleocr slots |
4 | ~24 req/s | 47.7 req/s | FIXED → 16 |
| 7 | Benchmark Idempotency-Keys | reused across runs | — | — | fixed (per-run ID) |
Items 5 and 6 are the important ones, and they share a cause.
The gateway's admission slots were never resized after the migration
internal/pool/pool.go:123 — if p.active >= p.slots.Total { return } — is a hard concurrency cap. Requests beyond it queue.
Every slot budget still reflects the world where one H100 GPU served everything:
| Upstream | slots | Backing capacity today | Verdict |
|---|---|---|---|
qwen-35b |
96 | 6 replicas, 1,536 vLLM seqs | caps production at ⅓ of capacity |
paddleocr |
4 | 16 workers, knee at conc 8 | the 16-worker fix is invisible |
cleanup-3b |
8 | full B200, max-num-seqs 256 |
likely far too low |
tei-embeddings |
8 | full B200 | likely far too low |
vllm-397b-onprem |
32 | 8 B200s, TP=8 | unmeasured |
gpu-server |
8 | 8 uvicorn workers | plausibly correct |
doc-classifier-svc |
4 | 1 H100 GPU | unmeasured |
ai-ahu-rag |
8 | CPU-only + Milvus | unmeasured |
qwen-35b is the costly one. At its 96-slot cap the production fleet delivers 19,184 tok/s. The same fleet at concurrency 512 delivers 58,764 tok/s with p50 still at 2.2 s — so the cap is leaving 3× on the table with no latency justification.
Part C — The optimisation plan
Ranked by measured value per unit of risk.
1. Resize the gateway slot budgets — config only, biggest win
qwen-35b: 96 → 512. Justification is measured, not guessed:
| Slots | Throughput | p50 |
|---|---|---|
| 96 (today) | 19,184 tok/s | 1,264 ms |
| 256 | 36,400 | 1,759 ms |
| 512 | 58,764 | 2,179 ms |
512 is the point where throughput is 3× and p50 is still comfortably under the interactive hold budget of 45 s. Going further trades latency for throughput that no current workload needs.
paddleocr: 4 → 16. The service peaks at 47.7 req/s at concurrency 8 with 16 workers; a slot budget of 4 makes that unreachable. 16 gives headroom without pushing past the measured knee.
Do NOT bulk-raise the rest. Measure first (method below). A slot budget above real capacity converts a clean queue into an overload collapse — exactly what happened to gpu-server at concurrency 24.
2. Measure each remaining upstream before resizing
Method, using the harness at /tmp/aload.py:
1. Run from ahu-int-01 (192.168.82.122) — it serves nothing.
2. Sweep concurrency until throughput plateaus.
3. Confirm client_cores_used stays near zero, or you are measuring the client.
4. Set slots at the knee, NOT the plateau — the knee is where
latency is still acceptable.
Priority order: vllm-397b-onprem (synthesis tier, user-facing), cleanup-3b, tei-embeddings, doc-classifier-svc, ai-ahu-rag.
3. Fix the app-side call pattern, not just the caps
Raising slots only helps if callers actually issue concurrent requests. Two things to verify per engine:
- HTTP connection pooling and keep-alive. A client that opens a new TCP connection per request will not reach high concurrency regardless of slot budget. The async harness used keep-alive and drove 1,536 concurrent on 0.05 of a core; the thread client could not.
- Sequential loops over documents/pages. If OCR extraction iterates pages one at a time awaiting each LLM call, the fleet sees concurrency 1 no matter how many replicas exist. Batching or
asyncio.gatherover independent items is where the app-side gain is.
4. Add saturation metrics so this is visible next time
None of these caps were observable — they were found by benchmarking. Worth exporting from the gateway per upstream:
slots_active / slots_total(are we at the admission cap?)- queue depth and time-in-queue
- rejections /
Retry-Afterresponses
If slots_active sits at slots_total, the cap is the bottleneck and the dashboard should say so.
5. Re-check placement only after the above
Consolidation freed GPUs and worked, but the current layout (6× 35B, one card shared by three aux services, one for paddle) is sized against capped demand. Once slots are correct and apps issue real concurrency, re-measure before moving cards again.
What NOT to spend time on
- FP4 — measured slower on the available checkpoint. Revisit only for a true W4A4 build.
- Moving the gateway for throughput — the earlier "gateway costs 25%" finding was a benchmark-client artifact. At scale it matches direct (130,613 vs 126,847 tok/s). Move it to
ahu-ctrl-01for zone architecture, not speed. - Buying more GPUs — at present the cluster is 3× under-driven by its own admission config.
Summary
The cluster can serve 127,055 tok/s. Production is configured to admit 19,184. The gap is a config file, not hardware — and the same pattern (a limit set for the old world) explains five of the six bottlenecks found today.
APPLIED 2026-08-24. qwen-35b slots 96 → 512 (batch_max 8 → 48), paddleocr 4 → 16.
Measured through the gateway on the production 6-replica fleet: ~15,700 → 53,619 tok/s (3.4×),
p50 2,466 ms — far inside the 45 s interactive hold budget. Backup: gateway.yaml.bak-pre-slotresize.
Also corrected while verifying: the gateway's real overhead is −8.6% at conc 512
(58,644 direct vs 53,619 through it), not the 25% claimed earlier nor the 0% claimed after that.
Both prior figures were measurement artifacts — see Part 7 of the benchmark report.
Next actions, in order: (1) measure and resize the remaining upstreams — vllm-397b-onprem 32,
cleanup-3b 8, tei-embeddings 8, doc-classifier-svc 4, ai-ahu-rag 8; (2) verify engines use
HTTP keep-alive and do not loop pages sequentially; (3) export slots_active/slots_total so the
next cap is visible without a benchmark.