randomllama commited on
Commit
3cf242b
·
verified ·
1 Parent(s): 9c381e4

Upload folder using huggingface_hub

Browse files
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())