Upload folder using huggingface_hub
Browse files- LICENSE +22 -0
- README.md +684 -0
- bench/__pycache__/decode-bench.cpython-314.pyc +0 -0
- bench/decode-bench.py +194 -0
- scripts/cache-warden.py +185 -0
LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 rhattala
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 7 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 8 |
+
in the Software without restriction, including without limitation the rights
|
| 9 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 10 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 11 |
+
furnished to do so, subject to the following conditions:
|
| 12 |
+
|
| 13 |
+
The above copyright notice and this permission notice shall be included in all
|
| 14 |
+
copies or substantial portions of the Software.
|
| 15 |
+
|
| 16 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 17 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 18 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 19 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 20 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 21 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 22 |
+
SOFTWARE.
|
README.md
ADDED
|
@@ -0,0 +1,684 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: mit
|
| 3 |
+
base_model: Qwen/Qwen3.8-Flash-Next-FP8
|
| 4 |
+
tags:
|
| 5 |
+
- dgx-spark
|
| 6 |
+
- gb10
|
| 7 |
+
- sm121
|
| 8 |
+
- sglang
|
| 9 |
+
- nvfp4
|
| 10 |
+
- field-notes
|
| 11 |
+
- recipe
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
# Qwen3.8-Flash-Next NVFP4 on 2× DGX Spark — field notes
|
| 15 |
+
|
| 16 |
+
*Mirror of https://github.com/beastllama/dgx-spark-qwen38-flash-next-recipe — the base
|
| 17 |
+
recipe is [MiaAI-Lab's](https://github.com/MiaAI-Lab/Qwen3.8-Flash-Next-Dual-DGX-Sparks);
|
| 18 |
+
this is the config delta plus everything that went wrong on the way and the measurements
|
| 19 |
+
nobody had published. From the same homelab as the
|
| 20 |
+
[GLM-5.3-Flash + DFlash2 recipe](https://huggingface.co/randomllama/GLM-5.3-Flash-DFlash2-SGLang-2x-DGX-Spark).*
|
| 21 |
+
|
| 22 |
+
Serving **Qwen3.8-Flash-Next-NVFP4** across **two DGX Sparks (GB10 / sm_121)** with SGLang, TP=2
|
| 23 |
+
over ConnectX-7 RoCE (**single rail** — dual-rail is measured fabric capability, untested
|
| 24 |
+
under SGLang). Vision enabled. 262,144 context. MTP speculative decoding.
|
| 25 |
+
|
| 26 |
+
**Start here, then read the findings.** The base stack is MiaAI-Lab's — this repo is the config
|
| 27 |
+
delta on top of it plus everything that went wrong getting there and how it was fixed
|
| 28 |
+
(see [Credit](#credit)). What follows was *not* in either published recipe:
|
| 29 |
+
repeated node wedges (on unified memory, exhaustion does not error — it takes the whole box), a deadlock that only appears behind a default-deny
|
| 30 |
+
firewall, the first speculative-decode acceptance measurements we're aware of for this model, and three
|
| 31 |
+
optimisation avenues that turned out to be **dead ends** — documented as such, with numbers.
|
| 32 |
+
|
| 33 |
+
Everything here was measured on real hardware. Where a number is contested or unproven, it says so.
|
| 34 |
+
|
| 35 |
+
---
|
| 36 |
+
|
| 37 |
+
## Status
|
| 38 |
+
|
| 39 |
+
| | |
|
| 40 |
+
|---|---|
|
| 41 |
+
| Serving | ✅ TP=2 across 2 nodes, 262,144 context |
|
| 42 |
+
| Vision | ✅ verified end-to-end (see below) |
|
| 43 |
+
| Spec decode | ✅ NEXTN 3/1/4 (`num_steps`/`eagle_topk`/`num_draft_tokens`) — note the engine self-reports `speculative_algorithm: EAGLE` — **the architectural maximum, not a default** |
|
| 44 |
+
| Decode | **~63 tok/s** single-stream on real generation |
|
| 45 |
+
| Concurrency | **306 tok/s** aggregate at 6 streams |
|
| 46 |
+
| Prefill | 3,050 tok/s (cache defeated) |
|
| 47 |
+
| Thermals | 52 °C / 35 W peak under load |
|
| 48 |
+
|
| 49 |
+
<sub>Conditions, because this repo insists on them: **decode ~63 tok/s** = real generation of a
|
| 50 |
+
10.7k-token HTML file, thinking off, temp 0.3, 2398 MHz idle / 2522 under load. **306 tok/s** =
|
| 51 |
+
aggregate across 6 concurrent streams, code prompt, 400 `max_tokens`, `ignore_eos`. **Prefill
|
| 52 |
+
3,050 tok/s** = ~7,450-token unique prompt per run, `cached_tokens=0` asserted, n=6. **Thermals** =
|
| 53 |
+
concurrency 4, 1 Hz sampling. A number without its prompt, token count and clock state is not
|
| 54 |
+
comparable to anything — including these.</sub>
|
| 55 |
+
|
| 56 |
+
---
|
| 57 |
+
|
| 58 |
+
## Performance
|
| 59 |
+
|
| 60 |
+
Two DGX Sparks. 180B params (125B backbone + 51B PLE), NVFP4, 262k context, vision on.
|
| 61 |
+
|
| 62 |
+
| | tok/s | conditions |
|
| 63 |
+
|---|---|---|
|
| 64 |
+
| **Single stream, real work** | **~63** | 10.7k-token HTML page, natural stop, 2522 MHz |
|
| 65 |
+
| Single stream, 400 tok | 63.7 | code prompt, `ignore_eos` |
|
| 66 |
+
| 2 concurrent | **104.9** agg | 52.8/stream² |
|
| 67 |
+
| 4 concurrent | **178.7** agg | 45.2/stream² |
|
| 68 |
+
| **6 concurrent** | **306.6** agg | 51.7/stream² |
|
| 69 |
+
| Prefill | **3,050** | ~7,450-token unique prompt, `cached_tokens=0` asserted, n=6 |
|
| 70 |
+
| Stress floor | 47.6 | `ignore_eos` + hard prompt + 800 tok — a deliberate FLOOR, see below |
|
| 71 |
+
|
| 72 |
+
<sub>² Concurrency measured **before** the config was pinned — at `max_running_requests=12` and an
|
| 73 |
+
unpinned KV pool of 850,816 tokens, not the 8 / 600,000 in the recipe below. Under the pinned
|
| 74 |
+
config 8 is the cap, so 6 streams is near it rather than "still climbing". Re-measure before
|
| 75 |
+
quoting these against the shipped config.</sub>
|
| 76 |
+
|
| 77 |
+
**Power and heat, at concurrency 4:** **52 °C, 35.5 W** peak per node; 42 °C / 10.4 W idle. That is
|
| 78 |
+
roughly **half the draw** of a comparably-sized dense-ish MoE we previously ran on the same boxes
|
| 79 |
+
(88 °C / 65 W), at *higher* clocks. Cause: ~6B active params per token (10 of 512 experts) and only
|
| 80 |
+
12 of 48 layers are full attention — the rest are linear-attention GDN, so decode waits on memory
|
| 81 |
+
rather than burning watts. Practical effect: thermal guard stages sized for the older model are
|
| 82 |
+
unreachable by 43 °C, and two Sparks serve this at **~71 W combined under load**.
|
| 83 |
+
|
| 84 |
+
**Why two numbers for "single stream".** `ignore_eos` benchmarks force generation past the model's
|
| 85 |
+
natural stopping point into degenerate text. They're excellent for regression detection and useless
|
| 86 |
+
as a headline. Real generation of a complete HTML page runs at **~63 tok/s**; the same stack under
|
| 87 |
+
`ignore_eos` on a hard prompt reports **47.6**. Both are correct. Quote the one that matches what
|
| 88 |
+
you're doing, and say which.
|
| 89 |
+
|
| 90 |
+
**Speculative decoding is doing much of the work, but the delta is not isolated.** Our earlier
|
| 91 |
+
vLLM deployment of the same checkpoint (no MTP, `--enforce-eager`) decoded at 20–21 tok/s; this
|
| 92 |
+
SGLang stack with MTP runs ~3× that. **Engine, CUDA-graph mode and MTP all changed together** — we
|
| 93 |
+
have no SGLang-with-MTP-off measurement, and neither published recipe ships one to compare against.
|
| 94 |
+
See §3 for why 3/1/4 is the ceiling.
|
| 95 |
+
|
| 96 |
+
---
|
| 97 |
+
|
| 98 |
+
## Recipe
|
| 99 |
+
|
| 100 |
+
**1. Base stack.** Clone [MiaAI-Lab's repo](https://github.com/MiaAI-Lab/Qwen3.8-Flash-Next-Dual-DGX-Sparks)
|
| 101 |
+
and follow it — it builds the SM121 QSA patch onto the public SGLang image and handles fabric
|
| 102 |
+
preflight, worker-first ordering and readiness waiting. Everything below is a delta on that.
|
| 103 |
+
|
| 104 |
+
**2. Stage NCCL on both nodes.** Both published recipes treat host-staged NCCL as required for
|
| 105 |
+
GB10 multi-node stability:
|
| 106 |
+
|
| 107 |
+
```bash
|
| 108 |
+
mkdir -p ~/nccl-2.30.7
|
| 109 |
+
cp /usr/lib/aarch64-linux-gnu/libnccl.so.2.30.7 ~/nccl-2.30.7/
|
| 110 |
+
ln -sf libnccl.so.2.30.7 ~/nccl-2.30.7/libnccl.so.2
|
| 111 |
+
```
|
| 112 |
+
|
| 113 |
+
**3. Apply the config delta** (table below) to the `.env`.
|
| 114 |
+
|
| 115 |
+
**4. Pin the control plane to the fabric** — the single most important line if you run a
|
| 116 |
+
default-deny firewall, and the one that cost us the longest debug:
|
| 117 |
+
|
| 118 |
+
```bash
|
| 119 |
+
-e SGLANG_HOST_IP=<this node's fabric IP>
|
| 120 |
+
```
|
| 121 |
+
|
| 122 |
+
**5. Evict page cache immediately before launch, and keep it bounded during the load:**
|
| 123 |
+
|
| 124 |
+
```bash
|
| 125 |
+
# before launch (no root needed — this is what scripts/cache-warden.py automates)
|
| 126 |
+
python3 -c "
|
| 127 |
+
import os,glob
|
| 128 |
+
for p in glob.glob(os.path.expanduser('~/.cache/huggingface/hub/**/*.safetensors'),recursive=True):
|
| 129 |
+
rp=os.path.realpath(p)
|
| 130 |
+
if os.path.exists(rp):
|
| 131 |
+
fd=os.open(rp,os.O_RDONLY); os.posix_fadvise(fd,0,0,os.POSIX_FADV_DONTNEED); os.close(fd)"
|
| 132 |
+
|
| 133 |
+
# during the load, on BOTH nodes
|
| 134 |
+
python3 scripts/cache-warden.py --model-dir ~/.cache/huggingface/hub \
|
| 135 |
+
--interval 20 --stop-below-gb 25 --max-runtime 86400 --log ~/warden.jsonl
|
| 136 |
+
```
|
| 137 |
+
|
| 138 |
+
**6. Verify it took — at the point of effect, not in your config file:**
|
| 139 |
+
|
| 140 |
+
```bash
|
| 141 |
+
docker exec <container> env | grep -E 'SGLANG_HOST_IP|NCCL_IB_HCA|NCHANNELS'
|
| 142 |
+
curl -s localhost:8899/get_server_info | python3 -m json.tool | grep -E 'max_total|speculative|context'
|
| 143 |
+
```
|
| 144 |
+
|
| 145 |
+
A setting you did not confirm arrived is a setting you did not set. Ours silently disagreed with
|
| 146 |
+
the `.env` more than once.
|
| 147 |
+
|
| 148 |
+
---
|
| 149 |
+
|
| 150 |
+
## Stack (pin these when reproducing)
|
| 151 |
+
|
| 152 |
+
| | |
|
| 153 |
+
|---|---|
|
| 154 |
+
| Model | `RadixArk/Qwen3.8-Flash-Next-NVFP4` (206 shards, 135.2 GB) |
|
| 155 |
+
| Engine | SGLang, image `lmsysorg/sglang:qwen38flashnext` + MiaAI-Lab's SM121 QSA patch |
|
| 156 |
+
| Driver | NVIDIA 580.173.02 (open kernel module, aarch64) |
|
| 157 |
+
| NCCL | 2.30.7, host-staged and `LD_PRELOAD`ed (both recipes treat this as required for GB10 multi-node) |
|
| 158 |
+
| Hardware | 2× DGX Spark, GB10 / sm_121, 121 GB unified per node |
|
| 159 |
+
|
| 160 |
+
### Config delta vs. the MiaAI-Lab defaults
|
| 161 |
+
|
| 162 |
+
Everything else is hers. These are the only changes, and why:
|
| 163 |
+
|
| 164 |
+
| setting | value | why |
|
| 165 |
+
|---|---|---|
|
| 166 |
+
| `SGLANG_HOST_IP` | fabric IP, per node | the deadlock in §1 |
|
| 167 |
+
| `NCCL_MIN/MAX_NCHANNELS` | 4 | default negotiated **64 channels and hung** during init; tonyd2wild pins 4 |
|
| 168 |
+
| `CUDA_GRAPH_BS` | dense `1..8` | a ladder with **gaps** forces padding, and padded rows carry `decode_len=0`, which wedges the sparse indexer under spec decode. Dense inside range means nothing pads. tonyd2wild solves the same problem with `--disable-cuda-graph-padding`; we do both |
|
| 169 |
+
| `MAX_RUNNING_REQUESTS` | 8 | matches the graph ladder — 9–16 ran eager with transient workspace |
|
| 170 |
+
| `--max-total-tokens` | 600000 | pinned; unpinned "OOMs under sustained load" (tonyd2wild) |
|
| 171 |
+
| `MEM_FRACTION_STATIC` | 0.80 | leaves ~23 GB headroom |
|
| 172 |
+
| `NCCL_CROSS_NIC` | 0 | a multi-Spark report had `CROSS_NIC=1` **wedge within hours** under real traffic |
|
| 173 |
+
|
| 174 |
+
---
|
| 175 |
+
|
| 176 |
+
## The five things this repo adds
|
| 177 |
+
|
| 178 |
+
### 1. `SGLANG_HOST_IP` — multi-node SGLang deadlocks behind a default-deny firewall
|
| 179 |
+
|
| 180 |
+
**Symptom:** both ranks hang forever after `CustomAllreduce is disabled`. No error, no timeout, no
|
| 181 |
+
NCCL warning. NCCL itself completes fine — RoCE connects, channels build.
|
| 182 |
+
|
| 183 |
+
**Diagnosis** (`py-spy dump` on both ranks — this is what made it solvable):
|
| 184 |
+
|
| 185 |
+
```
|
| 186 |
+
rank 0 (head): wait_until_ready (shm_broadcast.py:333) <- writer awaiting subscriptions
|
| 187 |
+
rank 1 (worker): wait_until_ready (shm_broadcast.py:344) <- reader awaiting READY
|
| 188 |
+
```
|
| 189 |
+
|
| 190 |
+
A ZMQ PUB/SUB deadlock, not an NCCL problem. Root cause: SGLang's `get_local_ip_auto()` returns the
|
| 191 |
+
**default-route interface** — the management LAN address — and binds the cross-rank XPUB control
|
| 192 |
+
socket there. With `-P INPUT DROP` on that interface, the worker's subscription never arrives.
|
| 193 |
+
|
| 194 |
+
**Fix** — pin the control plane to the fabric, per node:
|
| 195 |
+
|
| 196 |
+
```bash
|
| 197 |
+
docker run ... -e SGLANG_HOST_IP=10.10.10.1 # head
|
| 198 |
+
docker run ... -e SGLANG_HOST_IP=10.10.10.2 # worker
|
| 199 |
+
```
|
| 200 |
+
|
| 201 |
+
The bug requires two conditions together: the fabric is not the default route, and the default-route
|
| 202 |
+
interface drops unsolicited inbound. Absent either, it never appears — which is presumably why the
|
| 203 |
+
published recipes don't mention it. vLLM's equivalent is `VLLM_HOST_IP`, which is what confirmed the fix was legitimate
|
| 204 |
+
rather than a workaround.
|
| 205 |
+
|
| 206 |
+
### 2. Page cache is half your memory budget — and it is the wedge
|
| 207 |
+
|
| 208 |
+
On GB10 the GPU and host share **one** physical pool. `safetensors` mmaps each shard, so file pages
|
| 209 |
+
and resident tensors compete for the same memory. Measured on an idle node:
|
| 210 |
+
|
| 211 |
+
```
|
| 212 |
+
reading 41.6 GB of shards -> MemFree 103.0 -> 64.1 GB (~1:1)
|
| 213 |
+
```
|
| 214 |
+
|
| 215 |
+
A full load reads far more than that. When it runs out, **the NVIDIA driver fails before the kernel
|
| 216 |
+
reclaims** — from our own kernel log on a hung boot:
|
| 217 |
+
|
| 218 |
+
```
|
| 219 |
+
18:48:40 NVRM: Out of memory [NV_ERR_NO_MEMORY] .. _memdescAllocInternal @ mem_desc.c:1359
|
| 220 |
+
18:48:54 systemd-journald: Under memory pressure, flushing caches.
|
| 221 |
+
```
|
| 222 |
+
|
| 223 |
+
NVRM failed **14 seconds before** the kernel registered any pressure of its own, and that boot
|
| 224 |
+
contains no `page allocation failure`, no `order:` line, and no OOM-killer invocation. The cache was
|
| 225 |
+
resident; the driver could not have it. On unified memory this does not raise — **it wedges the
|
| 226 |
+
whole box**, and recovery is a physical power-cycle. Note: when it wedges this hard the **power
|
| 227 |
+
button is dead too** — no lights, no fans, no response to a long hold. Unplug and replug is the only
|
| 228 |
+
recovery we found.
|
| 229 |
+
|
| 230 |
+
**Consequences, all measured:**
|
| 231 |
+
|
| 232 |
+
- **Gate on `MemFree`, never `MemAvailable`.** MemAvailable counts reclaimable page cache the driver
|
| 233 |
+
cannot use. Live example from a node in this state: `MemFree 1.9 GB` vs `MemAvailable 17.2 GB`.
|
| 234 |
+
- **`drop_caches` before launch is necessary but not sufficient** — the cache regrows during the
|
| 235 |
+
135 GB read. See [`scripts/cache-warden.py`](scripts/cache-warden.py), which bounds it *during*
|
| 236 |
+
and *after* load, needs **no root**, and requires no engine patch.
|
| 237 |
+
- **The same pressure silently costs throughput**, not just stability:
|
| 238 |
+
|
| 239 |
+
| MemFree | median tok/s | CV | min |
|
| 240 |
+
|---|---|---|---|
|
| 241 |
+
| 1.7 GB | 58.77 | 9.7% | 44.96 |
|
| 242 |
+
| 21 GB | 60.69 | **2.0%** | 58.74 |
|
| 243 |
+
|
| 244 |
+
One root cause, two symptoms. Every benchmark in this repo evicts cache first.
|
| 245 |
+
|
| 246 |
+
The warden's own A/B, identical 41.6 GB read:
|
| 247 |
+
|
| 248 |
+
| | MemFree before → after |
|
| 249 |
+
|---|---|
|
| 250 |
+
| control (no warden) | 103.0 → 63.9 GB (**−39.1**) |
|
| 251 |
+
| with warden | 102.8 → 102.6 GB (**−0.2**) |
|
| 252 |
+
|
| 253 |
+
**Safe against a running engine.** `posix_fadvise(DONTNEED)` drops only *clean, unmapped* pages:
|
| 254 |
+
pages the live engine has mmap'd are skipped by the kernel, dirty pages are never discarded, and
|
| 255 |
+
the shards are read-only anyway. Worst case is a re-read from NVMe — latency, never corruption.
|
| 256 |
+
It carries a hard self-limit, exits when its target process dies, and reports a loud `FATAL` on a
|
| 257 |
+
bad directory and `UNVERIFIED` when it cannot evict. All three exit paths were proven by
|
| 258 |
+
execution, not by reading the code.
|
| 259 |
+
|
| 260 |
+
- **`--max-total-tokens` is a ceiling, not a floor.** Requesting 600,000 with a warm cache silently
|
| 261 |
+
yielded **557,120** — the engine under-fills the pool and does not warn.
|
| 262 |
+
|
| 263 |
+
### 3. Speculative decoding is at its architectural ceiling — and the drafter saturates it
|
| 264 |
+
|
| 265 |
+
`3/1/4` is not a conservative default. Raising it is **refused by the engine**:
|
| 266 |
+
|
| 267 |
+
```
|
| 268 |
+
NotImplementedError: Qwen QSA requires speculative_num_draft_tokens <= the QSA compress ratio (4):
|
| 269 |
+
the pending index-key ring holds one group; got 5
|
| 270 |
+
```
|
| 271 |
+
|
| 272 |
+
Qwen Sparse Attention's pending index-key ring holds exactly one group of 4. `num_draft_tokens`
|
| 273 |
+
can never exceed 4 **regardless of acceptance rate**.
|
| 274 |
+
|
| 275 |
+
**Per-run acceptance measurements — the first we're aware of for this model** — n=16, hard code prompt, per-run:
|
| 276 |
+
|
| 277 |
+
```
|
| 278 |
+
spec_accept_length 3.300 – 3.850 median 3.500 (HARD CEILING 4.0)
|
| 279 |
+
tok/s 52.55 – 62.56
|
| 280 |
+
correlation r = +0.786
|
| 281 |
+
```
|
| 282 |
+
|
| 283 |
+
Two findings:
|
| 284 |
+
|
| 285 |
+
**The throughput variance people see is acceptance, not noise.** It is not thermal, not clocks, not
|
| 286 |
+
page cache — all were flat/controlled. It is inherent and cannot be removed by environmental control.
|
| 287 |
+
|
| 288 |
+
**The drafter reaches 3.85 against a hard maximum of 4.0.** Net of the bonus token that is ~95% of
|
| 289 |
+
draft slots accepted on the best runs. The model would benefit from a larger draft budget and QSA
|
| 290 |
+
makes that impossible.
|
| 291 |
+
|
| 292 |
+
**Cumulative figure — and the trap we fell into publishing it.** Over the eval campaign:
|
| 293 |
+
**accept length 2.99, accept rate 0.66**, derived from lifetime counters only:
|
| 294 |
+
|
| 295 |
+
```
|
| 296 |
+
generation_tokens_total 601,124 + 49,010 = 650,134 (BOTH streaming series, summed by name)
|
| 297 |
+
spec_verify_calls_total 217,242
|
| 298 |
+
650,134 / 217,242 = 2.99
|
| 299 |
+
```
|
| 300 |
+
|
| 301 |
+
⚠️ **The windowed-gauge trap — we published this mistake before catching it.** SGLang's
|
| 302 |
+
`/metrics` gauges `sglang:spec_accept_length` and `spec_accept_rate` are **recomputed and reset
|
| 303 |
+
every decode-log interval**. They describe the last few dozen forwards, not the lifetime.
|
| 304 |
+
`spec_verify_calls_total` *is* lifetime. Pairing them silently labels a window as a campaign.
|
| 305 |
+
|
| 306 |
+
Watch a single gauge over minutes on an idle-ish engine:
|
| 307 |
+
|
| 308 |
+
```
|
| 309 |
+
spec_accept_length: 1.45 → 3.425 → 2.05 → 3.325 (window)
|
| 310 |
+
spec_verify_calls_total: 211,743 → 217,091 → 217,242 (lifetime, monotonic)
|
| 311 |
+
```
|
| 312 |
+
|
| 313 |
+
A cumulative average over *more* calls cannot fall from 3.425 to 2.05. **We published
|
| 314 |
+
"3.425 over 211,743 verify calls" — a gauge read pinned to a counter — and it flattered us by
|
| 315 |
+
~15%.** The per-run figures above survive, because a per-run gauge read approximates that run's
|
| 316 |
+
own window. Any dashboard reading of these gauges is a window too.
|
| 317 |
+
|
| 318 |
+
**External anchor.** LMSYS reports this model on B200 TP4 NVFP4 at **accept length 3.3** (their
|
| 319 |
+
workload is unstated). Our numbers **bracket** it — 2.99 cumulative on a mixed workload, 3.50
|
| 320 |
+
median on a hard code prompt. Given the ±40 pp prompt sensitivity below, "the same range" is the
|
| 321 |
+
most anyone can honestly claim from a cross-workload acceptance comparison.
|
| 322 |
+
|
| 323 |
+
LMSYS also names the mechanism behind the ceiling: **IndexShare MTP** reuses QSA selections across
|
| 324 |
+
draft steps, which is precisely why the pending index-key ring holds a single group.
|
| 325 |
+
|
| 326 |
+
⚠️ **Acceptance swings ~40 pp on prompt alone.** Measured on the same engine within one hour:
|
| 327 |
+
accept **length** `3.5–3.7` on one code prompt vs `2.475` on a chat+code mix — i.e. accept **rate**
|
| 328 |
+
~0.84–0.90 vs ~0.49, a ~40 **percentage-point** swing (length and rate are different units;
|
| 329 |
+
rate = (length − 1) / draft_steps). All arithmetically self-consistent — they
|
| 330 |
+
measure different prompt mixes. **Never quote an acceptance number without naming the prompt set.**
|
| 331 |
+
|
| 332 |
+
**Is there a way past the ceiling?** Not today. As of 2026-08-27 no DFlash / DSpark / EAGLE3
|
| 333 |
+
drafter exists for Flash-Next — z-lab's DFlash repo lists Muse-Glimmer-30B and Qwen3.8-**27B**
|
| 334 |
+
(a different model) and does not mention Flash-Next in supported models, roadmap or TODO. Two
|
| 335 |
+
things look like hits and are not: a HuggingFace repo named `…-MTP-Drafter-GGUF` is a repackaging
|
| 336 |
+
of the built-in MTP ("extracted … unmodified", 33 tensors), and SGLang's cookbook lists
|
| 337 |
+
`--speculative-algorithm DFLASH` because that's the engine-wide picker on every page — it needs a
|
| 338 |
+
`--speculative-draft-model-path` checkpoint that doesn't exist for this target. **The flag being
|
| 339 |
+
selectable is a label; the weights are the evidence.**
|
| 340 |
+
|
| 341 |
+
### 4. Vision works, and the self-review loop closes
|
| 342 |
+
|
| 343 |
+
Verified with a generated image of known content, not taken from the model card:
|
| 344 |
+
|
| 345 |
+
```
|
| 346 |
+
224×224 PNG, quadrants TL red / TR blue / BL green / BR yellow
|
| 347 |
+
answer: all four correct, image_tokens=64, 1.3 s
|
| 348 |
+
```
|
| 349 |
+
|
| 350 |
+
More useful — the full loop:
|
| 351 |
+
|
| 352 |
+
```
|
| 353 |
+
model writes HTML → headless Chrome renders at 1280px and 380px
|
| 354 |
+
→ model reads its own screenshots → critiques its own output
|
| 355 |
+
```
|
| 356 |
+
|
| 357 |
+
On a run truncated by too small a `max_tokens`, it reported *"the rendering is a complete failure…
|
| 358 |
+
just a dark background with a subtle grid pattern"* — describing what was on screen, not what it had
|
| 359 |
+
intended to write. On a complete run it found a font-size inconsistency and a checkmark-colour
|
| 360 |
+
mismatch that required zooming in to confirm. **It contradicts its own prior output**, which is the
|
| 361 |
+
property that makes self-review worth anything.
|
| 362 |
+
|
| 363 |
+
⚠️ **`max_tokens` ≥ 8000 for a full page.** At 2,600 the file truncated mid-CSS and produced a
|
| 364 |
+
*valid-looking* file that rendered blank. No error. Only the screenshot caught it.
|
| 365 |
+
|
| 366 |
+
### 5. Thinking mode: binary, helps reasoning, and fails catastrophically 30% of the time
|
| 367 |
+
|
| 368 |
+
There are **no effort levels**. The engine reports
|
| 369 |
+
`ReasoningToggleConfig(toggle_param='enable_thinking', default_enabled=True, effort_kwarg=None)`.
|
| 370 |
+
|
| 371 |
+
A/B on 8 reasoning problems with verifiable answers:
|
| 372 |
+
|
| 373 |
+
| | thinking OFF | thinking ON |
|
| 374 |
+
|---|---|---|
|
| 375 |
+
| score | 6/8 | **8/8** |
|
| 376 |
+
| time | 2.9 s | 14.8 s (5×) |
|
| 377 |
+
| tokens | 78 | 733, of which 651 thinking (9×) |
|
| 378 |
+
|
| 379 |
+
It fixes exactly the intuition traps: bat-and-ball `$0.10 → $0.05`, "Sally's sisters" `3 → 2`.
|
| 380 |
+
|
| 381 |
+
⚠️ **But do not default it on for code generation.** Same task, same config, temperature 0,
|
| 382 |
+
`max_tokens=14000`, n=10 each:
|
| 383 |
+
|
| 384 |
+
| | runaways (empty answer, budget exhausted) | completion tokens |
|
| 385 |
+
|---|---|---|
|
| 386 |
+
| thinking **ON** | **3 / 10** | 1,342 – 14,000 |
|
| 387 |
+
| thinking **OFF** | **0 / 10** | 222 – 287 |
|
| 388 |
+
|
| 389 |
+
**30% of thinking-on requests consumed the entire 14,000-token budget and returned zero characters
|
| 390 |
+
of content**, with everything in `reasoning_content` and `finish_reason: length`. Thinking off
|
| 391 |
+
solved the identical task in 222–287 tokens every single time — roughly **50× cheaper and
|
| 392 |
+
completely stable**.
|
| 393 |
+
|
| 394 |
+
Note the token range under thinking: 1,342 to 14,000, a **10× spread at temperature 0**. Greedy
|
| 395 |
+
decoding is not bit-reproducible on this stack (NVFP4 GEMM variance on sm_121 is the usual
|
| 396 |
+
explanation), and thinking amplifies that divergence into a coin-flip between "fine" and
|
| 397 |
+
"produces nothing at all".
|
| 398 |
+
|
| 399 |
+
**Practical guidance:**
|
| 400 |
+
- **Reasoning problems, short outputs** → thinking ON is a real win (6/8 → 8/8 on classic
|
| 401 |
+
intuition traps: bat-and-ball `$0.10 → $0.05`, "Sally's sisters" `3 → 2`).
|
| 402 |
+
- **Code generation, long outputs** → thinking OFF. It is faster, ~50× cheaper in tokens, and does
|
| 403 |
+
not silently return nothing.
|
| 404 |
+
- **If you must run thinking on unattended**, you need a guard: treat
|
| 405 |
+
`finish_reason == "length"` or empty `content` as a retryable failure, not as a model answer.
|
| 406 |
+
A harness without that guard will book 30% of its thinking-arm results as task failures and
|
| 407 |
+
conclude "thinking hurts on code" — which is not what is happening.
|
| 408 |
+
|
| 409 |
+
⚠️ **Wherever thinking is on, `max_tokens` must be ≥ 2000** regardless. Thinking consumes the
|
| 410 |
+
*same* budget as the answer, so a small cap guarantees the empty-content outcome rather than
|
| 411 |
+
merely risking it.
|
| 412 |
+
|
| 413 |
+
Tool calling was **not** harmed by thinking in our testing (correct `tool_calls` at temp 0.0, 0.7 and
|
| 414 |
+
1.0). One recipe reports a token-0 `!!!!!` repetition loop for thinking+tools; we probed n=6 at
|
| 415 |
+
temp 1.0 and saw none, on the *riskier* configuration (flashinfer sampling, radix cache on).
|
| 416 |
+
**n=6 cannot prove absence of a rare probabilistic loop.** Keep it on the watch list.
|
| 417 |
+
|
| 418 |
+
That watch-list item now has a confirmed sibling, below — and note *why* the n=6 probe found
|
| 419 |
+
nothing: it ran at temperature 1.0, which is precisely the setting that does not loop.
|
| 420 |
+
|
| 421 |
+
---
|
| 422 |
+
|
| 423 |
+
## Sampling: a 1-in-5 repetition loop at temperature 0 — cause NOT established
|
| 424 |
+
|
| 425 |
+
The engine ships `sampling_defaults='model'`, so a request that sends **no** sampling parameters
|
| 426 |
+
gets the checkpoint's own `generation_config`:
|
| 427 |
+
|
| 428 |
+
```
|
| 429 |
+
temperature 1.0 top_k 20 top_p 0.95
|
| 430 |
+
```
|
| 431 |
+
|
| 432 |
+
Passing `temperature: 0` overrides that. **On this stack, long greedy builds loop
|
| 433 |
+
INTERMITTENTLY — measured at 1 of 5 runs.**
|
| 434 |
+
|
| 435 |
+
**What was measured (2026-08-27).** Identical prompt — rebuild a home page from a structured
|
| 436 |
+
brief — thinking off, `max_tokens 14000`, **one run per arm**:
|
| 437 |
+
|
| 438 |
+
| sampling | tokens | finish | outcome | compliance audit |
|
| 439 |
+
|---|---|---|---|---|
|
| 440 |
+
| `temperature: 0` | 14,000 | `length` | **one CSS line emitted 507 times, never escaped** | 12/28 |
|
| 441 |
+
| *(none sent — checkpoint default)* | 9,550 | `stop` | clean | 27/29 |
|
| 442 |
+
| `temp 0.7 / top_p 0.8 / top_k 20` | 11,831 | `stop` | clean | 28/29 |
|
| 443 |
+
|
| 444 |
+
The greedy run never reached the end of the document, so the page had no `<main>`, no footer and
|
| 445 |
+
no links — which is why the compliance score collapses. At 800 tokens **neither** config repeats
|
| 446 |
+
a line, so whatever this is, it is length-dependent.
|
| 447 |
+
|
| 448 |
+
**It is rare, and our first write-up of it was wrong.** The identical greedy build was re-run four
|
| 449 |
+
more times on the same prompt:
|
| 450 |
+
|
| 451 |
+
```
|
| 452 |
+
run 1 10,785 tok finish=stop max repeated content line 3 clean
|
| 453 |
+
run 2 11,006 tok finish=stop max repeated content line 3 clean
|
| 454 |
+
run 3 12,252 tok finish=stop max repeated content line 4 clean
|
| 455 |
+
run 4 14,000 tok finish=length max repeated content line 1 clean (long, not looping)
|
| 456 |
+
```
|
| 457 |
+
|
| 458 |
+
**0 of 4.** Pooled with the original, the observed rate is **1 in 5** — not something greedy does,
|
| 459 |
+
something greedy sometimes does. Greedy decoding is **not bit-reproducible on this stack** (NVFP4
|
| 460 |
+
GEMM variance on sm_121 — the same effect behind the 10× token spread documented in the thinking
|
| 461 |
+
section above), so `temperature: 0` names a *distribution*, not one trajectory. A small slice of
|
| 462 |
+
that distribution lands in a basin greedy cannot leave. A sampled decoder can land in the same
|
| 463 |
+
basin and still escape by chance — that asymmetry, not the loop itself, is the finding.
|
| 464 |
+
|
| 465 |
+
The two sampled arms are **one run each**. n=1 bounds nothing; treat their rate as unmeasured,
|
| 466 |
+
merely lower.
|
| 467 |
+
|
| 468 |
+
### Why we are NOT claiming "temperature 0 causes this"
|
| 469 |
+
|
| 470 |
+
⚠️ **There is an uncontrolled confound, and it is a big one.** We run
|
| 471 |
+
`--sampling-backend flashinfer`. [tonyd2wild's recipe](https://github.com/tonyd2wild/qwen3.8-flash-next-nvfp4-dgx-spark)
|
| 472 |
+
for the same model and hardware documents a degenerate-output loop and attributes it to that exact
|
| 473 |
+
kernel — his fix is `--sampling-backend pytorch`, described as ruling out "the FlashInfer kernel
|
| 474 |
+
arg-maxing a stale row to token 0." With his four-part stack he reports the loop **clean at temp
|
| 475 |
+
0.0 / 0.2 / 0.7**, with a residual edge only at temp 1.0.
|
| 476 |
+
|
| 477 |
+
We ship **two of his four** loop-fix elements (`enable_thinking: false`,
|
| 478 |
+
`--disable-cuda-graph-padding`) and not the other two (`--sampling-backend pytorch`,
|
| 479 |
+
`--disable-radix-cache`). So the honest statement is:
|
| 480 |
+
|
| 481 |
+
> A long greedy generation looped **on a stack missing the sampling-backend fix that a published
|
| 482 |
+
> recipe says prevents exactly this class of failure.** Temperature is correlated with the failure
|
| 483 |
+
> in our three runs; it is not established as the cause.
|
| 484 |
+
|
| 485 |
+
Our manifestation also differs from his — a whole CSS line repeated 507 times, not a token-0 `!`
|
| 486 |
+
loop — so they may be different bugs. **Unresolved.** Testing it properly means restarting the
|
| 487 |
+
engine with `--sampling-backend pytorch` and re-running all three arms, which we have not done.
|
| 488 |
+
|
| 489 |
+
(Do not reach for `--disable-radix-cache` casually as the other half of his stack: his own
|
| 490 |
+
2026-08-27 update reports it *silently collapses the mamba/SSM state pool* to
|
| 491 |
+
`max_running_requests`. Our workload is also prefill-dominated, which is precisely where a prefix
|
| 492 |
+
cache pays.)
|
| 493 |
+
|
| 494 |
+
### And no, we cannot tell you greedy is faster
|
| 495 |
+
|
| 496 |
+
An earlier version of this section claimed temperature 0 was **+6.9%** faster (48.2 vs 45.1 tok/s),
|
| 497 |
+
citing higher speculative acceptance (58.3% vs 50.8%) as the mechanism. **That claim is withdrawn.**
|
| 498 |
+
It came from **n=3 per arm**, against a measured inherent CV of **~6.3%** on this cluster — the
|
| 499 |
+
"difference" was the same size as the noise, and the defaults arm contained a 42.3 outlier of
|
| 500 |
+
exactly the shape this log has previously root-caused to page-cache pressure. This repo's own
|
| 501 |
+
standard, set after an earlier bad call, is that **n=5 is not enough** to report a config win.
|
| 502 |
+
n=3 is not close.
|
| 503 |
+
|
| 504 |
+
The acceptance figures (2.75 vs 2.525 accept-length) are real per-run gauge reads and the
|
| 505 |
+
direction is mechanically plausible — greedy tokens are more predictable, so the drafter hits more
|
| 506 |
+
often. Plausible is not measured. If you want this number, it needs n≥16 with page cache evicted
|
| 507 |
+
and a named prompt.
|
| 508 |
+
|
| 509 |
+
**Practical guidance, as far as it is actually supported:**
|
| 510 |
+
- **Long generation on a flashinfer-sampling stack** → send no sampling parameters, or cap
|
| 511 |
+
temperature at ≤0.7 per tonyd2wild. Both completed cleanly here — *one run each*, so this is a
|
| 512 |
+
completion, not a rate. The reason to prefer them is the escape asymmetry above, not a measured
|
| 513 |
+
difference in loop frequency.
|
| 514 |
+
- **A 1-in-5 chance of losing the whole document is worth engineering around** even though it is
|
| 515 |
+
rare. If you run greedy on long output, treat `finish_reason == "length"` plus a high
|
| 516 |
+
repeated-line count as a retryable failure.
|
| 517 |
+
- **On the server-side default there is a real trade, and we have not resolved it.** We leave
|
| 518 |
+
`sampling_defaults='model'`, which serves temp 1.0 to any client that sends nothing — and temp
|
| 519 |
+
1.0 is precisely where tonyd2wild reports his residual edge, with an explicit recommendation to
|
| 520 |
+
cap agent temperature at ≤0.7. Keeping `model` preserves the diagnostic signal and honours the
|
| 521 |
+
checkpoint's own config; it also defaults silent clients into the one regime the cited source
|
| 522 |
+
calls risky. Pick deliberately rather than inheriting it, as we did.
|
| 523 |
+
- **Benchmarks** → always say which sampling config produced the number. The direction is
|
| 524 |
+
plausible; the size, and whether it exists at all, is unmeasured.
|
| 525 |
+
|
| 526 |
+
---
|
| 527 |
+
|
| 528 |
+
## Capability evaluation
|
| 529 |
+
|
| 530 |
+
13 tasks across backend Python, backend Node/TS, SQL schema design, debugging, three frontend
|
| 531 |
+
stacks (vanilla, React, Next.js App Router), Sanity CMS schemas, and multi-file cross-file
|
| 532 |
+
debugging. Two passes per arm, temp 0.
|
| 533 |
+
|
| 534 |
+
**8 of the 13 are graded by executing held-out tests in a sandbox** (backend Python ×2, Node ×2,
|
| 535 |
+
SQL, debugging ×2, and the cross-file task). The three frontend tasks, the Sanity schema and one
|
| 536 |
+
large-codebase task are graded by **structural checks on the output text** — weaker, and the
|
| 537 |
+
negative controls validate only the executing verifiers.
|
| 538 |
+
|
| 539 |
+
| arm | scored PASS | INVALID | wall clock¹ |
|
| 540 |
+
|---|---|---|---|
|
| 541 |
+
| thinking **OFF**, pass 1 | **13 / 13** | 0 | **3 m 44 s** |
|
| 542 |
+
| thinking **OFF**, pass 2 | **13 / 13** | 0 | **3 m 45 s** |
|
| 543 |
+
| thinking **ON**, pass 1 | 12 / 12 | 1 | **81 min** |
|
| 544 |
+
| thinking **ON**, pass 2 | 11 / 11 | 2 | **84 min** |
|
| 545 |
+
|
| 546 |
+
<sub>¹ Wall clock between arm-start markers in the run log — this is what you actually wait for.
|
| 547 |
+
It is much larger than the sum of per-task `elapsed`, because retried attempts are not counted in
|
| 548 |
+
the per-record figure and one frontend task alone burned ~3 × 400 s per thinking-ON arm.</sub>
|
| 549 |
+
|
| 550 |
+
**Thinking off is ~22× faster in wall clock, with equal correctness.** Both INVALIDs in pass 2
|
| 551 |
+
were thinking-budget exhaustion at 20,000 tokens on long-output tasks.
|
| 552 |
+
|
| 553 |
+
**A fourth independent measurement of the runaway, from the campaign itself:** retries fired in
|
| 554 |
+
**10 of 30 thinking-ON cells and 0 of 32 thinking-OFF cells**. The thinking-ON scoreline is
|
| 555 |
+
therefore *retry-dependent* — retries only fire on INVALID (truncation or empty output), never on
|
| 556 |
+
FAIL, so they cannot turn a wrong answer into a pass, but they do resample a nondeterministic
|
| 557 |
+
coin-flip. Without the retry policy the thinking arm would show ~30% failures that are not
|
| 558 |
+
capability failures.
|
| 559 |
+
|
| 560 |
+
⚠️ **A clean sweep measures the suite, not the model.** 13/13 bounds the failure rate; it does not
|
| 561 |
+
locate the ceiling. The 95% Wilson interval on 13/13 is roughly **77–100%** — wide, because n is
|
| 562 |
+
small. The honest reading is "this suite sits below the model's capability", not "this model does
|
| 563 |
+
not fail". These tasks were written by us and are not a public benchmark.
|
| 564 |
+
|
| 565 |
+
**Disclosure:** first-pass results under two buggy verifiers were 12/13. `fe-01` (both OFF passes)
|
| 566 |
+
and `dbg-02` (ON pass 1) were **re-run after the verifier fixes described below** — fresh
|
| 567 |
+
generations, not re-grades. The headline includes those re-run cells.
|
| 568 |
+
|
| 569 |
+
The hardest task — a four-file service with a cross-file contract bug (a heap negating priority
|
| 570 |
+
while the constants documented the opposite convention) — passed in **18 s** with thinking on (under 4 s with it off), changing only the
|
| 571 |
+
file that needed changing, fixing the misleading comment that caused it, and satisfying a held-out
|
| 572 |
+
three-part test covering ordering, FIFO tie-break, and untouched retry semantics.
|
| 573 |
+
|
| 574 |
+
### Verifier validation
|
| 575 |
+
|
| 576 |
+
Every run includes negative controls whose tests are **deliberately unsatisfiable**: a Python task
|
| 577 |
+
asserting `2+2==5`, and a SQL task whose table is pre-created so the model's own DDL must collide.
|
| 578 |
+
Both failed correctly in all four arms, and real tasks pass — so the verifiers genuinely execute
|
| 579 |
+
and are not merely always-fail.
|
| 580 |
+
|
| 581 |
+
### ⚠️ Three of our own checks failed *correct* output
|
| 582 |
+
|
| 583 |
+
This is the part worth copying if you build something similar. In the first pass, three verifiers
|
| 584 |
+
produced confident, specific, **false** results:
|
| 585 |
+
|
| 586 |
+
| check | what it did | reality |
|
| 587 |
+
|---|---|---|
|
| 588 |
+
| mutable-default fix | asserted the caller's list must not be mutated | the prompt never asked for a defensive copy; taking ownership is a normal contract |
|
| 589 |
+
| self-contained HTML | banned the substring `http://` | flagged `xmlns="http://www.w3.org/2000/svg"` — a namespace URI browsers never fetch |
|
| 590 |
+
| token budget | 6,000 max_tokens | thinking consumed it, so truncation looked like failure |
|
| 591 |
+
|
| 592 |
+
Uncorrected, the writeup would have claimed this model fails the classic mutable-default bug and
|
| 593 |
+
cannot produce self-contained HTML. Both are the opposite of true. **A verifier is a claim about
|
| 594 |
+
the world and needs its own negative controls** — ours caught the model's failures fine; what they
|
| 595 |
+
could not catch was themselves. The tell each time was a *surprising* failure that turned out, on
|
| 596 |
+
reading the actual output, to be correct.
|
| 597 |
+
|
| 598 |
+
---
|
| 599 |
+
|
| 600 |
+
## Dead ends — documented so you don't spend the time
|
| 601 |
+
|
| 602 |
+
**Clock headroom does not exist.** `clocks.max.sm` reports 3003 MHz; the GPU runs 2528 under load.
|
| 603 |
+
Locking `-lgc 2800,3003` yields **2528 MHz** — the floor does not take — and prefill changes by
|
| 604 |
+
**0.07%**:
|
| 605 |
+
|
| 606 |
+
| | clock | prefill |
|
| 607 |
+
|---|---|---|
|
| 608 |
+
| default | 2528 MHz | 3,055 tok/s |
|
| 609 |
+
| locked 2800–3003 | 2528 MHz | 3,053 tok/s |
|
| 610 |
+
|
| 611 |
+
GB10 is **memory-bandwidth bound**, not clock bound, for both prefill and decode. This also explains
|
| 612 |
+
the low power draw — the GPU is mostly waiting on memory.
|
| 613 |
+
|
| 614 |
+
**Raising the draft budget is impossible.** See §3.
|
| 615 |
+
|
| 616 |
+
**`--load-format dummy` should not be used on GB10** — the rule and the >150 GB transient figure are
|
| 617 |
+
Mia's; our contribution is only that it explains one of our own wedges. A "safe rehearsal" is more
|
| 618 |
+
dangerous than the real load.
|
| 619 |
+
|
| 620 |
+
---
|
| 621 |
+
|
| 622 |
+
## Thermals — much cooler than a comparable dense-ish MoE
|
| 623 |
+
|
| 624 |
+
Measured at concurrency 4, 1 Hz telemetry:
|
| 625 |
+
|
| 626 |
+
```
|
| 627 |
+
idle 42.0 °C · 10.4 W · 2398 MHz
|
| 628 |
+
peak (load) 52.0 °C · 35.5 W · 2522 MHz
|
| 629 |
+
```
|
| 630 |
+
|
| 631 |
+
For scale, a previous model on identical hardware peaked at **88 °C / 65 W** uncapped. Qwen runs
|
| 632 |
+
~36 °C cooler at ~45% the power — at *higher* clocks. Cause: ~6B active params/token (10 of 512
|
| 633 |
+
experts) and only 12 of 48 layers are full attention; the rest are linear-attention GDN.
|
| 634 |
+
|
| 635 |
+
**Practical effect:** thermal guard stages sized for the older model are effectively unreachable
|
| 636 |
+
(43 °C of margin), and a clock cap intended to control thermals has nothing left to control.
|
| 637 |
+
|
| 638 |
+
---
|
| 639 |
+
|
| 640 |
+
## Benchmark discipline
|
| 641 |
+
|
| 642 |
+
Two spectacular false results were produced and caught during this work. Both were **prefix-cache
|
| 643 |
+
artifacts**:
|
| 644 |
+
|
| 645 |
+
```
|
| 646 |
+
"72,000 tok/s prefill" -> identical prompt repeated, radix cache hit
|
| 647 |
+
"46,388 tok/s prefill" -> a shell function that never passed its seed argument
|
| 648 |
+
true prefill: 3,050 tok/s (unique prompt per run, cached_tokens=0 asserted)
|
| 649 |
+
```
|
| 650 |
+
|
| 651 |
+
**Always assert `usage.prompt_tokens_details.cached_tokens == 0`** when measuring prefill. A 24×
|
| 652 |
+
speedup that appears without a config change is a cache hit, not a discovery.
|
| 653 |
+
|
| 654 |
+
Likewise, **`ignore_eos` benchmarks are a floor, not real-world throughput.** They force generation
|
| 655 |
+
past the natural stopping point into degenerate text:
|
| 656 |
+
|
| 657 |
+
| measurement | tok/s |
|
| 658 |
+
|---|---|
|
| 659 |
+
| real generation (10.7k tokens of HTML) | **62.9** |
|
| 660 |
+
| harness, `ignore_eos`, hard prompt, 800 tok | 47.6 |
|
| 661 |
+
|
| 662 |
+
Both correct; they measure different things. Name the prompt, token count and clock state on every
|
| 663 |
+
number, or it is not comparable to anything.
|
| 664 |
+
|
| 665 |
+
---
|
| 666 |
+
|
| 667 |
+
## Files
|
| 668 |
+
|
| 669 |
+
| | |
|
| 670 |
+
|---|---|
|
| 671 |
+
| [`scripts/cache-warden.py`](scripts/cache-warden.py) | bounds page cache during and after load; no root, no engine patch |
|
| 672 |
+
| [`bench/decode-bench.py`](bench/decode-bench.py) | decode benchmark that waits for idle, discards contended runs, reports medians, and names its conditions |
|
| 673 |
+
|
| 674 |
+
## Credit
|
| 675 |
+
|
| 676 |
+
This work stands on two recipes published first, and would not exist without them:
|
| 677 |
+
|
| 678 |
+
- **[MiaAI-Lab/Qwen3.8-Flash-Next-Dual-DGX-Sparks](https://github.com/MiaAI-Lab/Qwen3.8-Flash-Next-Dual-DGX-Sparks)** — the orchestration and the SM121 QSA Triton fallback kernel that makes this model run on sm_121 at all. Our deployment *is* this stack.
|
| 679 |
+
- **[tonyd2wild/qwen3.8-flash-next-nvfp4-dgx-spark](https://github.com/tonyd2wild/qwen3.8-flash-next-nvfp4-dgx-spark)** — `--disable-cuda-graph-padding`, NCCL channel pinning (which fixed a 64-channel init hang for us), KV pinning, and the rule that any fix making the model text-only is off the table.
|
| 680 |
+
- **[bird/GLM-spark](https://github.com/bird/GLM-spark)** — published the `posix_fadvise(DONTNEED)` page-cache mechanism first, as an in-loader vLLM patch. We arrived at it independently and measured it before finding theirs; `cache-warden.py` is an out-of-tree, no-root variant that also bounds cache *during* and *after* load.
|
| 681 |
+
|
| 682 |
+
## License
|
| 683 |
+
|
| 684 |
+
MIT.
|
bench/__pycache__/decode-bench.cpython-314.pyc
ADDED
|
Binary file (13 kB). View file
|
|
|
bench/decode-bench.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Single-stream decode benchmark for an OpenAI-compatible endpoint (vLLM or SGLang).
|
| 3 |
+
|
| 4 |
+
Discipline, because casual decode benchmarks are usually wrong:
|
| 5 |
+
* waits for a genuinely idle engine and DISCARDS any run foreign traffic touched mid-flight
|
| 6 |
+
(a background keepalive will otherwise silently poison your numbers)
|
| 7 |
+
* forces generation with `ignore_eos` so the run actually decodes instead of stopping early
|
| 8 |
+
* reports the MEDIAN of N clean runs, never the best
|
| 9 |
+
* names prompt, token count and clock state on every result
|
| 10 |
+
|
| 11 |
+
NOTE: `ignore_eos` makes this a FLOOR, not real-world throughput -- it pushes generation past the
|
| 12 |
+
natural stopping point into degenerate text. Real generation on the same stack measured ~33% higher.
|
| 13 |
+
|
| 14 |
+
Engine metric namespaces differ (`vllm:*` vs `sglang:*`) and the names are NOT the same; both are
|
| 15 |
+
parsed BY NAME. If neither matches, in-flight reads as UNVERIFIED -- never as "idle".
|
| 16 |
+
|
| 17 |
+
Env: BENCH_HOST BENCH_MODEL BENCH_NS BENCH_RUNS BENCH_MAX_TOK BENCH_LABEL BENCH_CLOCK_CMD BENCH_DEPTH
|
| 18 |
+
"""
|
| 19 |
+
import json, os, re, subprocess, sys, threading, time, urllib.request
|
| 20 |
+
|
| 21 |
+
# Engine-agnostic 2026-08-27. DEFAULTS ARE UNCHANGED so a DeepSeek run is byte-identical to the
|
| 22 |
+
# runs that produced the recorded baseline (chat 40.41 / code 65.71). Override via env to point at
|
| 23 |
+
# another stack -- SAME code, SAME prompts, SAME protocol is the whole point of the comparison.
|
| 24 |
+
HOST = os.environ.get("BENCH_HOST", "http://127.0.0.1:8899")
|
| 25 |
+
MODEL = os.environ.get("BENCH_MODEL", "qwen38-flash-next")
|
| 26 |
+
# vLLM exports `vllm:*`, SGLang exports `sglang:*` and the in-flight metric NAMES DIFFER.
|
| 27 |
+
# Parsed BY NAME (never positionally). If neither namespace matches, inflight() returns None and
|
| 28 |
+
# contended() reports UNVERIFIED -- it must never silently read as "idle".
|
| 29 |
+
NS = os.environ.get("BENCH_NS", "sglang")
|
| 30 |
+
_RUNNING = {"vllm": "vllm:num_requests_running", "sglang": "sglang:num_running_reqs"}[NS]
|
| 31 |
+
_WAITING = {"vllm": "vllm:num_requests_waiting", "sglang": "sglang:num_queue_reqs"}[NS]
|
| 32 |
+
RUNS = int(os.environ.get("BENCH_RUNS", "5"))
|
| 33 |
+
MAX_TOK = int(os.environ.get("BENCH_MAX_TOK", "800"))
|
| 34 |
+
LABEL = os.environ.get("BENCH_LABEL", "unlabelled")
|
| 35 |
+
PROMPTS = {
|
| 36 |
+
"chat": "Explain how consensus works in a distributed database, with examples.",
|
| 37 |
+
"code": "Write a Python LRU cache with TTL expiry, thread-safe, with tests. Explain the design.",
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
# ── context depth ────────────────────────────────────────────────────────────
|
| 41 |
+
# BENCH_DEPTH pads the prompt to roughly this many tokens so decode is measured
|
| 42 |
+
# at a chosen context length. Motivation, observed on production traffic
|
| 43 |
+
# 2026-08-27: at ~170k context `sglang:spec_accept_length` read 1.00 -- the
|
| 44 |
+
# drafter contributing NOTHING -- against 2.75 on a 39-token prompt. If that
|
| 45 |
+
# holds, the ~3x speculative speedup this stack advertises is a SHORT-CONTEXT
|
| 46 |
+
# number. That needs a controlled sweep, which needs this knob.
|
| 47 |
+
#
|
| 48 |
+
# The filler is UNIQUE per run (a counter is embedded) so the prefix cache cannot
|
| 49 |
+
# serve it. A cached prefill would make deep runs decode against a warm cache and
|
| 50 |
+
# report a depth the engine never actually processed -- and `cached_tokens` is
|
| 51 |
+
# asserted below rather than assumed.
|
| 52 |
+
DEPTH = int(os.environ.get("BENCH_DEPTH", "0"))
|
| 53 |
+
_depth_seq = [0]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def pad(prompt):
|
| 57 |
+
"""Return (prompt, expected_prefill_tokens). No padding when DEPTH is 0."""
|
| 58 |
+
if DEPTH <= 0:
|
| 59 |
+
return prompt, 0
|
| 60 |
+
_depth_seq[0] += 1
|
| 61 |
+
# ~4 chars/token is close enough; the ACTUAL prompt_tokens is read back from
|
| 62 |
+
# the response and reported, so the label never depends on this estimate.
|
| 63 |
+
words = []
|
| 64 |
+
seed = f"run{_depth_seq[0]}x{int(time.time())}"
|
| 65 |
+
for i in range(DEPTH):
|
| 66 |
+
words.append(f"{seed}-{i:06d}")
|
| 67 |
+
filler = " ".join(words)[: DEPTH * 4]
|
| 68 |
+
return (f"Reference log (ignore it; answer only the question at the end):\n"
|
| 69 |
+
f"{filler}\n\nQuestion: {prompt}"), DEPTH
|
| 70 |
+
|
| 71 |
+
def inflight():
|
| 72 |
+
try:
|
| 73 |
+
b = urllib.request.urlopen(HOST + "/metrics", timeout=8).read().decode()
|
| 74 |
+
r = re.search(r"^" + re.escape(_RUNNING) + r"\{?[^}\s]*\}?\s+([0-9.]+)", b, re.M)
|
| 75 |
+
w = re.search(r"^" + re.escape(_WAITING) + r"\{?[^}\s]*\}?\s+([0-9.]+)", b, re.M)
|
| 76 |
+
if r is None or w is None:
|
| 77 |
+
return None
|
| 78 |
+
return float(r.group(1)) + float(w.group(1))
|
| 79 |
+
except Exception:
|
| 80 |
+
return None
|
| 81 |
+
|
| 82 |
+
def wait_idle(timeout=1800):
|
| 83 |
+
end = time.time() + timeout; streak = 0
|
| 84 |
+
while time.time() < end:
|
| 85 |
+
if inflight() == 0:
|
| 86 |
+
streak += 1
|
| 87 |
+
if streak >= 2: return True
|
| 88 |
+
else: streak = 0
|
| 89 |
+
time.sleep(15)
|
| 90 |
+
return False
|
| 91 |
+
|
| 92 |
+
def sample(stop, out):
|
| 93 |
+
while not stop.is_set():
|
| 94 |
+
f = inflight(); out.append(f if f is not None else -1.0); time.sleep(1.5)
|
| 95 |
+
|
| 96 |
+
def contended(samples):
|
| 97 |
+
if not samples: return "UNVERIFIED: no samples"
|
| 98 |
+
foreign = blind = 0
|
| 99 |
+
for f in samples:
|
| 100 |
+
if f < 0:
|
| 101 |
+
blind += 1; foreign = 0
|
| 102 |
+
if blind >= 2: return "UNVERIFIED: /metrics unreadable mid-run"
|
| 103 |
+
else:
|
| 104 |
+
blind = 0; foreign = foreign + 1 if f >= 2.0 else 0
|
| 105 |
+
if foreign >= 2: return "foreign traffic mid-run"
|
| 106 |
+
return ""
|
| 107 |
+
|
| 108 |
+
def one(prompt):
|
| 109 |
+
prompt, _ = pad(prompt)
|
| 110 |
+
body = json.dumps({"model": MODEL, "messages": [{"role": "user", "content": prompt}],
|
| 111 |
+
"max_tokens": MAX_TOK, "min_tokens": MAX_TOK, "ignore_eos": True,
|
| 112 |
+
"temperature": 0, "stream": False}).encode()
|
| 113 |
+
s = []; stop = threading.Event()
|
| 114 |
+
th = threading.Thread(target=sample, args=(stop, s), daemon=True); th.start()
|
| 115 |
+
t0 = time.time()
|
| 116 |
+
try:
|
| 117 |
+
d = json.load(urllib.request.urlopen(
|
| 118 |
+
urllib.request.Request(HOST + "/v1/chat/completions", body,
|
| 119 |
+
{"Content-Type": "application/json"}), timeout=600))
|
| 120 |
+
finally:
|
| 121 |
+
stop.set(); th.join(timeout=5)
|
| 122 |
+
dt = time.time() - t0
|
| 123 |
+
u = d["usage"]
|
| 124 |
+
tok = u["completion_tokens"]
|
| 125 |
+
if tok < MAX_TOK * 0.9:
|
| 126 |
+
return None, f"only {tok}/{MAX_TOK} tok generated"
|
| 127 |
+
# A deep run served from the prefix cache did not measure that depth. Assert
|
| 128 |
+
# it rather than trusting the unique filler to have worked.
|
| 129 |
+
cached = (u.get("prompt_tokens_details") or {}).get("cached_tokens", 0) or 0
|
| 130 |
+
if DEPTH and cached > u["prompt_tokens"] * 0.2:
|
| 131 |
+
return None, f"prefix cache served {cached}/{u['prompt_tokens']} prompt tok — depth not real"
|
| 132 |
+
return (dt, tok, u["prompt_tokens"], cached, accept_length()), contended(s)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def accept_length():
|
| 136 |
+
"""sglang:spec_accept_length, BY NAME. None (never 0.0) when absent."""
|
| 137 |
+
try:
|
| 138 |
+
b = urllib.request.urlopen(HOST + "/metrics", timeout=8).read().decode()
|
| 139 |
+
m = re.search(r"^sglang:spec_accept_length\{[^}]*\}\s+([0-9.eE+-]+)", b, re.M)
|
| 140 |
+
return float(m.group(1)) if m else None
|
| 141 |
+
except Exception:
|
| 142 |
+
return None
|
| 143 |
+
|
| 144 |
+
def main():
|
| 145 |
+
# Clock state is REQUIRED context for a throughput number -- prompt, token count and clock
|
| 146 |
+
# state each move the result by more than the effects people try to measure with them.
|
| 147 |
+
# It is site-specific, so it comes from an env var. Unset -> print UNVERIFIED, never an
|
| 148 |
+
# empty string: a check that cannot verify must say so rather than look fine.
|
| 149 |
+
_cmd = os.environ.get("BENCH_CLOCK_CMD", "")
|
| 150 |
+
if _cmd:
|
| 151 |
+
_p = subprocess.run(_cmd, shell=True, capture_output=True, text=True)
|
| 152 |
+
clk = (_p.stdout.strip() or "UNVERIFIED") if _p.returncode == 0 else "UNVERIFIED"
|
| 153 |
+
else:
|
| 154 |
+
clk = "UNVERIFIED (set BENCH_CLOCK_CMD)"
|
| 155 |
+
print(f"=== decode bench :: {LABEL} ===")
|
| 156 |
+
print(f" prompts=chat,code max_tokens={MAX_TOK} (ignore_eos) runs={RUNS}/prompt clock={clk}")
|
| 157 |
+
if inflight() != 0:
|
| 158 |
+
print(" engine busy — waiting for idle...", flush=True)
|
| 159 |
+
if not wait_idle():
|
| 160 |
+
print(" ABORT: never idle. UNVERIFIED."); return 2
|
| 161 |
+
out = {}
|
| 162 |
+
for name, p in PROMPTS.items():
|
| 163 |
+
res = []; att = 0
|
| 164 |
+
while len(res) < RUNS and att < RUNS * 4:
|
| 165 |
+
att += 1
|
| 166 |
+
if inflight() != 0:
|
| 167 |
+
if not wait_idle(900): print(" gave up waiting"); break
|
| 168 |
+
r, why = one(p)
|
| 169 |
+
if why or r is None:
|
| 170 |
+
print(f" [{name}] attempt {att}: DISCARDED — {why}", flush=True); continue
|
| 171 |
+
dt, tok, ptok, cached, al = r
|
| 172 |
+
res.append((tok / dt, ptok, al))
|
| 173 |
+
print(f" [{name}] clean {len(res)}/{RUNS}: {tok/dt:6.2f} tok/s ({dt:.2f}s) "
|
| 174 |
+
f"prompt={ptok:,} cached={cached} accept_len={al if al is None else round(al,2)}",
|
| 175 |
+
flush=True)
|
| 176 |
+
if res:
|
| 177 |
+
res.sort(key=lambda x: x[0])
|
| 178 |
+
mid = res[len(res)//2]
|
| 179 |
+
als = [x[2] for x in res if x[2] is not None]
|
| 180 |
+
out[name] = {"tok_s": mid[0], "prompt_tokens": mid[1],
|
| 181 |
+
"accept_length_median": (sorted(als)[len(als)//2] if als else None)}
|
| 182 |
+
print()
|
| 183 |
+
for k, v in out.items():
|
| 184 |
+
al = v["accept_length_median"]
|
| 185 |
+
print(f" {k:5} median {v['tok_s']:.2f} tok/s prompt={v['prompt_tokens']:,} tok "
|
| 186 |
+
f"accept_len={'UNVERIFIED' if al is None else round(al,2)}")
|
| 187 |
+
if out:
|
| 188 |
+
print(f" MEAN of medians: {sum(v['tok_s'] for v in out.values())/len(out):.2f} tok/s")
|
| 189 |
+
print(json.dumps({"label": LABEL, "clock": clk, "max_tok": MAX_TOK,
|
| 190 |
+
"depth_requested": DEPTH, "results": out}))
|
| 191 |
+
return 0
|
| 192 |
+
|
| 193 |
+
if __name__ == "__main__":
|
| 194 |
+
sys.exit(main())
|
scripts/cache-warden.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""cache-warden — bound safetensors page cache DURING a model load, without root.
|
| 3 |
+
|
| 4 |
+
WHY THIS EXISTS
|
| 5 |
+
---------------
|
| 6 |
+
On GB10 the GPU and host share one physical pool. `safetensors` mmaps each shard, so the file
|
| 7 |
+
pages and the resident tensors compete for the SAME memory. Measured on an idle node:
|
| 8 |
+
|
| 9 |
+
reading 41.6 GB of shards -> Cached +38.7 GB, MemFree 103.0 -> 64.1 GB (~1:1)
|
| 10 |
+
|
| 11 |
+
A full load reads far more than that, and the NVIDIA driver cannot force the kernel to reclaim
|
| 12 |
+
those pages -- our hung-boot kernel log shows NVRM failing 14 s BEFORE the kernel registered any
|
| 13 |
+
memory pressure of its own. So the cache wins the race and the load dies mid-flight.
|
| 14 |
+
|
| 15 |
+
`drop_caches` before launch only starts you clean; the cache re-grows during the read. This evicts
|
| 16 |
+
the clean pages of already-read shards WHILE the load runs. `posix_fadvise(POSIX_FADV_DONTNEED)`
|
| 17 |
+
needs no privileges -- verified: 38.7 of 38.7 GB reclaimed, 100%, as an unprivileged user.
|
| 18 |
+
|
| 19 |
+
PRIOR ART -- WE WERE NOT FIRST. `bird/GLM-spark` (MIT) patches vLLM's `weight_utils.py` to call
|
| 20 |
+
`posix_fadvise(fd, 0, size, POSIX_FADV_DONTNEED)` on each shard close, with the same diagnosis
|
| 21 |
+
(~67 GB page cache + ~67 GB CUDA tensors vs ~119 GB available -> OOM at 66% of load; peak drops to
|
| 22 |
+
~72 GB after). Convergent discovery: we diagnosed it independently from our own kernel logs (NVRM failing
|
| 23 |
+
14 s before the kernel registered pressure), but THEY PUBLISHED THE MECHANISM FIRST. The published DGX Spark recipes (MiaAI-Lab, tonyd2wild) instead mandate
|
| 24 |
+
`sync; echo 3 > /proc/sys/vm/drop_caches` before every launch.
|
| 25 |
+
What is arguably still additive about THIS implementation, and the reason it exists:
|
| 26 |
+
* out-of-tree -- no engine patch, so it works against vLLM and SGLang unchanged
|
| 27 |
+
* needs NO ROOT (drop_caches does), which matters where there is no NOPASSWD grant
|
| 28 |
+
* bounds cache DURING the load, not only before it; drop_caches alone lets it regrow mid-read
|
| 29 |
+
Credit belongs to bird/GLM-spark for publishing the mechanism first.
|
| 30 |
+
|
| 31 |
+
SAFETY: evicting a shard the loader later re-touches costs a re-read from NVMe, never corruption.
|
| 32 |
+
|
| 33 |
+
Self-limiting BY DESIGN. A loop that re-asserts state with no owner, no lease and no self-limit is
|
| 34 |
+
how a test guard once kept re-applying an admission gate on behalf of a unit that should have been
|
| 35 |
+
dead. This one carries a hard deadline, exits when its target process is gone, and cleans up on
|
| 36 |
+
SIGTERM/SIGINT.
|
| 37 |
+
|
| 38 |
+
USAGE
|
| 39 |
+
cache-warden.py --model-dir DIR [--interval 5] [--max-runtime 1800]
|
| 40 |
+
[--pid PID | --container NAME] [--stop-below-gb 0]
|
| 41 |
+
"""
|
| 42 |
+
import argparse, glob, json, os, signal, subprocess, sys, time
|
| 43 |
+
|
| 44 |
+
STOP = False
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _sig(signum, _frame):
|
| 48 |
+
global STOP
|
| 49 |
+
STOP = True
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def meminfo(key):
|
| 53 |
+
with open("/proc/meminfo") as fh:
|
| 54 |
+
for line in fh:
|
| 55 |
+
if line.startswith(key + ":"):
|
| 56 |
+
return int(line.split()[1]) / 1048576.0
|
| 57 |
+
return float("nan")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def shard_paths(model_dir):
|
| 61 |
+
"""Resolved, existing shard files. Skips dangling symlinks (an interrupted HF download)."""
|
| 62 |
+
out = []
|
| 63 |
+
# Accept a single model dir OR a whole HF hub root. The recursive pattern matters: pointed at
|
| 64 |
+
# `~/.cache/huggingface/hub` the non-recursive globs match NOTHING (real layout is
|
| 65 |
+
# hub/models--X/snapshots/<hash>/*.safetensors) and the tool exits FATAL. Covering the hub root
|
| 66 |
+
# is the useful case at runtime -- a RETIRED model's shards are unreferenced and fully
|
| 67 |
+
# evictable, while the live model's are mmap'd and correctly cannot be dropped.
|
| 68 |
+
for pat in ("snapshots/*/*.safetensors", "*.safetensors", "**/*.safetensors"):
|
| 69 |
+
for p in glob.glob(os.path.join(model_dir, pat), recursive=True):
|
| 70 |
+
rp = os.path.realpath(p)
|
| 71 |
+
if os.path.exists(rp):
|
| 72 |
+
out.append(rp)
|
| 73 |
+
return sorted(set(out))
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def evict(paths):
|
| 77 |
+
"""Drop clean page-cache for each file. Returns (files_ok, files_failed)."""
|
| 78 |
+
ok = failed = 0
|
| 79 |
+
for rp in paths:
|
| 80 |
+
try:
|
| 81 |
+
fd = os.open(rp, os.O_RDONLY)
|
| 82 |
+
except OSError:
|
| 83 |
+
failed += 1
|
| 84 |
+
continue
|
| 85 |
+
try:
|
| 86 |
+
os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED)
|
| 87 |
+
ok += 1
|
| 88 |
+
except OSError:
|
| 89 |
+
failed += 1
|
| 90 |
+
finally:
|
| 91 |
+
os.close(fd)
|
| 92 |
+
return ok, failed
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def target_alive(pid, container):
|
| 96 |
+
"""Liveness of whatever we are warding for. None == no target given."""
|
| 97 |
+
if pid:
|
| 98 |
+
try:
|
| 99 |
+
os.kill(pid, 0)
|
| 100 |
+
return True
|
| 101 |
+
except OSError:
|
| 102 |
+
return False
|
| 103 |
+
if container:
|
| 104 |
+
r = subprocess.run(
|
| 105 |
+
["docker", "inspect", "-f", "{{.State.Running}}", container],
|
| 106 |
+
capture_output=True, text=True)
|
| 107 |
+
return r.returncode == 0 and r.stdout.strip() == "true"
|
| 108 |
+
return None
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def main():
|
| 112 |
+
ap = argparse.ArgumentParser()
|
| 113 |
+
ap.add_argument("--model-dir", required=True)
|
| 114 |
+
ap.add_argument("--interval", type=float, default=5.0)
|
| 115 |
+
ap.add_argument("--max-runtime", type=float, default=1800.0,
|
| 116 |
+
help="hard self-limit in seconds; ALWAYS exits by this deadline")
|
| 117 |
+
ap.add_argument("--pid", type=int, default=0, help="exit when this pid is gone")
|
| 118 |
+
ap.add_argument("--container", default="", help="exit when this container stops running")
|
| 119 |
+
ap.add_argument("--stop-below-gb", type=float, default=0.0,
|
| 120 |
+
help="only evict when MemFree is below this (0 = always evict)")
|
| 121 |
+
ap.add_argument("--log", default="", help="append JSONL telemetry here (fsync'd per line)")
|
| 122 |
+
a = ap.parse_args()
|
| 123 |
+
|
| 124 |
+
signal.signal(signal.SIGTERM, _sig)
|
| 125 |
+
signal.signal(signal.SIGINT, _sig)
|
| 126 |
+
|
| 127 |
+
paths = shard_paths(a.model_dir)
|
| 128 |
+
if not paths:
|
| 129 |
+
print(f"cache-warden: FATAL no readable shards under {a.model_dir}", file=sys.stderr)
|
| 130 |
+
return 2
|
| 131 |
+
|
| 132 |
+
total = sum(os.path.getsize(p) for p in paths)
|
| 133 |
+
print(f"cache-warden: warding {len(paths)} shards, {total/1e9:.1f} GB, "
|
| 134 |
+
f"interval={a.interval}s deadline={a.max_runtime:.0f}s", flush=True)
|
| 135 |
+
|
| 136 |
+
logfh = open(a.log, "a") if a.log else None
|
| 137 |
+
start = time.time()
|
| 138 |
+
ticks = 0
|
| 139 |
+
reclaimed_total = 0.0
|
| 140 |
+
try:
|
| 141 |
+
while not STOP:
|
| 142 |
+
elapsed = time.time() - start
|
| 143 |
+
if elapsed >= a.max_runtime:
|
| 144 |
+
print(f"cache-warden: self-limit {a.max_runtime:.0f}s reached, exiting", flush=True)
|
| 145 |
+
break
|
| 146 |
+
alive = target_alive(a.pid, a.container)
|
| 147 |
+
if alive is False:
|
| 148 |
+
print("cache-warden: target gone, exiting", flush=True)
|
| 149 |
+
break
|
| 150 |
+
|
| 151 |
+
free_before, cached_before = meminfo("MemFree"), meminfo("Cached")
|
| 152 |
+
did = False
|
| 153 |
+
if a.stop_below_gb <= 0 or free_before < a.stop_below_gb:
|
| 154 |
+
ok, failed = evict(paths)
|
| 155 |
+
did = True
|
| 156 |
+
if failed and ok == 0:
|
| 157 |
+
# Never degrade to a silent no-op: if we cannot evict anything, say so loudly.
|
| 158 |
+
print(f"cache-warden: UNVERIFIED — every fadvise failed ({failed} files); "
|
| 159 |
+
f"cache is NOT being bounded", file=sys.stderr, flush=True)
|
| 160 |
+
free_after, cached_after = meminfo("MemFree"), meminfo("Cached")
|
| 161 |
+
ticks += 1
|
| 162 |
+
reclaimed_total += max(0.0, cached_before - cached_after)
|
| 163 |
+
|
| 164 |
+
rec = dict(t=round(elapsed, 1), evicted=did,
|
| 165 |
+
memfree_before=round(free_before, 1), memfree_after=round(free_after, 1),
|
| 166 |
+
cached_before=round(cached_before, 1), cached_after=round(cached_after, 1))
|
| 167 |
+
if logfh:
|
| 168 |
+
logfh.write(json.dumps(rec) + "\n")
|
| 169 |
+
logfh.flush()
|
| 170 |
+
os.fsync(logfh.fileno()) # survive a node hang; the trace is the whole point
|
| 171 |
+
if did and cached_before - cached_after > 0.5:
|
| 172 |
+
print(f"cache-warden: t={elapsed:6.0f}s MemFree {free_before:6.1f} -> "
|
| 173 |
+
f"{free_after:6.1f} GB (reclaimed {cached_before-cached_after:.1f})",
|
| 174 |
+
flush=True)
|
| 175 |
+
time.sleep(a.interval)
|
| 176 |
+
finally:
|
| 177 |
+
if logfh:
|
| 178 |
+
logfh.close()
|
| 179 |
+
print(f"cache-warden: done after {time.time()-start:.0f}s, {ticks} ticks, "
|
| 180 |
+
f"~{reclaimed_total:.0f} GB reclaimed cumulatively", flush=True)
|
| 181 |
+
return 0
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
if __name__ == "__main__":
|
| 185 |
+
sys.exit(main())
|