Need help? Join us on discord: https://discord.gg/M7XTrRJW3
nvidia/GLM-5.3-Flash-NVFP4 served by vLLM on two, three, four or six GB10 boxes (TP=2, 3, 4 or 6), over RoCE. The base is an unmodified vLLM nightly with a few small patches, a newer FlashKDA, and mentat in place of Ray. What makes it fast:
Each of these lives in experimental/, replaces files inside the image this
repo builds, and can be turned off. experimental/README.md
has the details.
| TP=2 | TP=3 | TP=4 | TP=6 | |
|---|---|---|---|---|
| prefill @32k, cold | 2,931 tok/s | 3,864 tok/s | 4,934 tok/s | 4,907 tok/s |
| prefill @128k, cold | 2,871 tok/s | 3,736 tok/s | 4,776 tok/s | 4,731 tok/s |
| decode*, code / prose / structured | 66.2 / 42.6 / 94.7 tok/s | 88.0 / 54.2 / 121.4 tok/s | 117.3 / 70.5 / 164.3 tok/s | – |
| mixed*, 4 / 8 streams, aggregate | 83 / 110 tok/s | 107 / 138 tok/s | 144 / 194 tok/s | – |
| KV pool (fp8_e4m3) | 1.10M tokens, 8 GiB pin | 1.91M tokens, 12 GiB pin | 4.40M tokens, 26 GiB pin | 4.47M tokens, 26 GiB pin |
| longest request | 160k tokens | 524k tokens | 524k tokens | 524k tokens |
| requests decoding at once | 16 | 64 | 64 | 64 |
| boot, once snapshots exist | ~3 min | ~3 min | ~3 min | not measured |
| needle recall | 6/6 up to 128k tokens | 12/12 up to 507k tokens | 12/12 up to 507k tokens | 12/12 up to 480k tokens |
* modified rigmark-like approach, see gate/bench.
Prefill is cold: random words, nothing cached.
We measured TP=2, 3 and 4 on boots that restored weight snapshots. TP=4 prefill is the median of four such boots. The first boot, which loads the checkpoint and writes the snapshots, leaves less memory free and runs slower (128k prefill at TP=2 lost 7-15%), so restart once before measuring. TP=6 ran on other boxes and builds, with GPU clocks locked at 1989 MHz. experimental/tp3/README.md has the details.
TP=4 is the default. For two boxes, set TP=2 in compose/.env on both. Each
box then holds twice the weights, so the entrypoint shrinks the KV pin, context
length, request limit and batch budget to fit.
experimental/tp3/README.md has the steps for TP=3
and TP=6.
TP=RING4 runs TP=4 on four boxes cabled in a loop, with no switch: each box's
two ConnectX-7 ports go to its two neighbours. mentat places the ranks in cable
order, and every collective uses only neighbour links, relaying traffic for the
opposite box through a neighbour. It needs mentat 0.17.1 on every daemon
and a subnet per cable for each PCIe root. Through our switch, the relay hop
cost about 1% of decode. On real ring cables, another user measured RigMark
decode at 100.7 / 59.9 / 146.7 tok/s for code / prose / structured, and cold
prefill at 4,745 / 4,737 tok/s at 32k / 64k (mentat 0.17.1, every overlay,
RecoverSSM off). That is 12% below the switched TP=4 column on code, 9% on
structured, level on prose, and about 5% below our RigMark prefill.
experimental/README.md has the details.
model.yaml has the checkpoints and the memory footprint, and KNOBS.md lists every environment variable the entrypoint reads. NOTES.md has the measurements and diagnosis behind the choices here.
multi-user.target). These boxes ship with a
GNOME desktop enabled, and a desktop session takes memory and GPU time from
the model: sudo systemctl set-default multi-user.target && sudo systemctl
isolate multi-user.target. The preflight warns while one is running.TP=RING4, with RoCE
working. Through a switch, each box needs a static IPv4 on its ConnectX
interface, all in one subnet, MTU 9000. For full prefill speed also give
the ConnectX-7's second PCIe root an address in a second subnet on every
box (see FABRIC_SUBNETS in Tuning). mentatd tells the model which
subnets these are (step 4)./srv/models/glm-5.3-flash-nvfp4 (MODEL_HOST_DIR)/srv/models/glm-5.3-flash-dflash2
(DFLASH_HOST_DIR). It is licensed CC BY-NC-ND
4.0, non-commercial; check that before you serve it. SPEC_METHOD=mtp
uses the checkpoint's own MTP head instead, slower but with no second
download.hf download is resumable:
hf download nvidia/GLM-5.3-Flash-NVFP4 --local-dir /srv/models/glm-5.3-flash-nvfp4
hf download incoai/GLM-5.3-Flash-DFlash2 --local-dir /srv/models/glm-5.3-flash-dflash2
/dev/infiniband present on the host.| port | what | where |
|---|---|---|
| 6379, 6380 | mentatd control and HTTP | every box |
| 6381 | mentatd-serve: the OpenAI API and merged MCP for clients | one box |
| 6382/udp | mentatd announcements | every box |
| 8002 | vLLM's OpenAI API (/v1/chat/completions, /v1/models, /metrics) |
head only |
| 8082 | status page and MCP (/mcp) |
every box |
Point clients at mentatd-serve on :6381, not at vLLM on :8002. It
health-gates, so a request during a boot or a reload waits instead of
failing, and it routes by model name, so another model on the same boxes
answers on the same address. mentatd-serve is a separate process from the
daemon by design: nothing that routes inference traffic runs inside the thing
that holds cluster membership.
| path | what |
|---|---|
image/ |
Dockerfile, entrypoint, patches, verify-base.py, self-test.py, chat template, build.sh |
compose/glm53.yaml |
the model, the same file on every box |
.env.example |
optional overrides for compose/.env |
smoketest/ |
run.sh <base> [served-name] |
.submodules/spark-agent |
the status server, reached through the vllm symlink |
dev/ |
not in the image: the corruption diagnosis and repros, kernel and patch tests, the step tap |
mentatd and mentatd-serve come from the mentat repo, with their own compose files (step 4).
git clone --recursive https://github.com/mmastrac/glm-5.3-flash-4x-gx10
# or, in an existing clone: git submodule update --init
The status server comes from the spark-agent submodule, and the image build fails without it.
image/build.sh # tags the build.sh default
TAG=spark-glm53:mine image/build.sh # or your own tag
BASE=... image/build.sh # override the pinned nightly
The build context is the repo root (docker build -f image/Dockerfile .), so
the vllm symlink into the submodule stays inside it. Build on a GX10: there
is no cross-build for aarch64 here. A plain rsync -a copy of the repo also
builds, because it keeps the hidden .submodules/ and the symlink; a bare
scp -r * misses the first. Build once and copy the image to the other three
boxes (docker save | ssh ... docker load, or a registry): every rank must
run the same image.
The base is vllm/vllm-openai:nightly-ddd6fbca148a867aad1fcab7ec72f582b9977db4
(the tag is the full commit), which carries glm5_next
and DFlash2 upstream, and the patches are small anchored edits that fail the
build if the tree moves under them. The one thing the build compiles is
FlashKDA (see Patches), in a builder stage that took 98 s on a GX10.
image/verify-base.py then checks the finished tree. The image embeds mentat
0.17.1. Run the same version everywhere: mentatd and mentatd-serve on
every box should be 0.17.1 too.
compose/.env is optional. Each box reads its LAN address and fabric subnets
from its own mentatd (step 4), the boxes elect a head once all of them have
registered, and the paths and the image have defaults. Put a value in
compose/.env only to override one. Compose reads .env from compose/,
beside the compose file, whatever directory you run it from.
.env.example lists the usual overrides.
| variable | unset | set it when |
|---|---|---|
IMAGE |
image/build.sh's default tag |
you built under another tag |
MODEL_HOST_DIR |
/srv/models/glm-5.3-flash-nvfp4 |
the checkpoint is elsewhere on this box |
DFLASH_HOST_DIR |
/srv/models/glm-5.3-flash-dflash2 |
the drafter is elsewhere on this box |
CACHE_HOME, LOG_DIR |
the volumes glm53_cache and glm53_logs |
you want the JIT caches or the logs in a host directory |
HEAD_HOST |
elected: the box with the lowest LAN address | you want a fixed head: its LAN address, the same on every box. Each box then works out its own role |
ROLE |
from HEAD_HOST |
never, unless you set it on every box: head on one, worker on the rest |
VLLM_HOST_IP |
the address mentatd tags lan |
mentatd tags nothing lan on this box |
FABRIC_SUBNETS |
one subnet per address mentatd tags rdma |
mentatd tags no fabric address rdma |
CLUSTER_SUBNET |
the first of FABRIC_SUBNETS |
you set one subnet by hand, the older form of FABRIC_SUBNETS |
With HEAD_HOST unset, the boxes elect the one with the lowest LAN address
as head, so with all of them up it is always the same box. Each box logs
one election: line naming the candidates, the head and its own role. Set
HEAD_HOST on every box or on none: a box with a fixed head does not take
part in the election. The weight snapshots under CACHE_HOME are per TP rank,
so moving the head reshuffles the ranks, and the first boot after that loads
the checkpoint in full and writes new snapshots.
Leave every tuned knob out: each has its default in image/entrypoint.sh, and
a copy in .env silently wins over the measured value.
The volumes survive docker compose down and go with down -v. The engine
log is /logs/vllm.log inside the container (vllm-head.log or
vllm-worker.log when ROLE is set), readable through the status page's MCP
tools or docker exec glm53 tail /logs/vllm.log.
mentat has its own repo, compose files and .env. On every box, in a
checkout of mmastrac/mentat at v0.17.1:
VERSION=0.17.1 ./build.sh
cat > .env <<'EOF'
MENTAT_PEERS=<another box's LAN address>:6379
MENTAT_ANNOUNCE_IFACES=en*f*np*=connectx+rdma,en*=lan
EOF
docker compose -f mentatd.yaml up -d
Or skip the build and add IMAGE=mmastrac/mentatd:0.17.1 to that .env
(mmastrac/mentatd-serve:0.17.1 for the router): the published images cover
arm64.
The model reads its networking from MENTAT_ANNOUNCE_IFACES. Tag the LAN
interface lan and every ConnectX interface that holds a fabric address
rdma. The line above does both on a GX10: en*f*np* matches both PCIe
roots (enp1s0f0np0 and enP2p1s0f0np0, or their f1 twins). A pattern that
misses the second root, such as enp1s0f*np*, leaves NCCL on one root, and
the preflight warns about it. To check a box:
curl -s localhost:6380/status | jq .addr_tags
The daemon names the box by its default route's address, which must be the
LAN address. Set MENTAT_NODE_IP in mentat's .env when it is not. The
model container registers with the daemon on its own box
(127.0.0.1:6379). That daemon passes the registration on to the daemon
mentat elected as its head. With HEAD_HOST set, the container registers
with the daemon at HEAD_HOST:6379 instead. Then, on one box:
docker compose -f mentatd-serve.yaml up -d
mentat's mentatd.yaml explains the other settings, such as signing
announcements (MENTAT_SECRET, which must then be set on every box).
On every box:
./glm53 up -d
glm53 runs docker compose with compose/glm53.yaml and every overlay in
experimental/compose, in the order the numbers at the top were measured with
(later files win). It passes the rest of its arguments to docker compose, so
./glm53 down, ./glm53 logs -f and ./glm53 ps work too, and
./glm53 -f my.yaml up -d adds your own files after the overlays. Started with
compose/glm53.yaml alone (./glm53 --stock), the stack runs vLLM's stock
kernels at about half the speed, so the entrypoint refuses to start that way
unless compose/.env sets ALLOW_STOCK=1.
From cold, the boxes may start in any order: registration retries until the
daemon answers, and the head waits for all four GPUs before it loads. The
cold boot takes about eight minutes to reach the API. The status page on :8082 answers from
container start, so there is something to read while it loads.
Replacing a running stack is different. Recreating all four ranks at once
lets a starting rank join a group that is still tearing down, and the whole
group then hangs just past NCCL setup: every rank running, restart count 0,
CPU ~1%, no weights loading, and nothing in any log after the
custom_all_reduce warning. Seen on 2026-09-10 after several rapid recreate
cycles. Take every rank down, confirm all four containers are gone, then start
them again a few seconds later:
# on each box
./glm53 down --timeout 60
# confirm on all four: docker ps -a | grep glm53 -> nothing
# then up again; with fixed roles, the head first
The project name is pinned to glm53. A stack started under another project
name (an older checkout run from a different directory, say) must be taken
down with its own compose file first, or the two collide on the container
name.
smoketest/run.sh http://<mentatd-serve box>:6381
Eight cases, each with an answer that can be checked, because this model can load cleanly, report healthy and serve fluent nonsense. The first fails against any model that is not a GLM-5.3 checkpoint. Exit status is the number of failures. See smoketest/README.md.
The first run after a fresh install is slow. Kernels compile the first time
each batch shape and draft length comes up, and on an empty cache that happens
inside your first requests: the first request can take minutes, and the first
pass over several concurrency levels runs well below the numbers at the top
(code at 4 streams: 127 tok/s on the first pass, 196 after a restart). The
compiled kernels are kept in CACHE_HOME, so restarts and later boots start
warm. Run your workload once, or restart once, before timing anything.
Each container runs spark-agent's status server on :8082 from container
start, before the model is loadable. It does not proxy inference. The router's
/mcp merges its tools under the glm53__ prefix: node_status,
cluster_status, metrics, serve_args, throughput,
latency_percentiles, cache_sizes, ray_status, versions, and
find_files / search_files over a fixed set of roots. /memory on the
status page says whether torch or something else holds a box's memory.
:8082is unauthenticated and binds0.0.0.0. Anyone who can reach the port can read engine state and search file contents underSEARCH_ROOTS(the installed vLLM package,/root/.cache,/logsand/cacheby default). Paths are resolved withrealpathso symlinks cannot escape those roots, arguments are passed as argv rather than through a shell, and output is capped at 16 KB, but there is no authentication. Fine on an isolated network; put it behind something, or narrowSEARCH_ROOTS, on any network you do not control.
Two checks run at every start and print to the container log; neither stops the boot:
preflight, on every node, lists what makes the stack slow or fragile:
a fabric port down or below 200 Gb/s, a port MTU too small for RoCE's 4096-byte path MTU (under 4200), a PCIe link
below its maximum, one ConnectX root instead of two, link flaps, RDMA
retransmit counters, GPU clock-limit events, other GPU processes, too little
host memory for the TP size and KV pin, page cache the GPU cannot use yet
(it evicts the model files' cached pages itself), swap in use, a model directory on
NFS, no disk for the first weight snapshot, a memlock limit, and a running
desktop session. PREFLIGHT=0 skips it.fabric check, on every rank before vLLM starts, with the results printed
on the head: an NCCL all-reduce over the fabric with its bus bandwidth against
the ~95 Gb/s per ConnectX root a healthy link gives, and every version,
override file and knob that must match across nodes, with the ones that do
not. A fabric can link up at full rate and still move 12 Gb/s until the
boxes' power is drained; this is where that shows. FABRIC_CHECK=0 skips
it; a node that cannot meet the others within FABRIC_CHECK_TIMEOUT_S
(120) skips it too.Host-level facts (GPU, PCI, RDMA counters, dmesg, systemd) come from spark-agent's separate per-machine agent, which is not part of this recipe. The container tools above cover the engine; diagnosing the fabric or the box needs that agent or plain ssh.
The bias throughout is that a long prefill must never block a short request,
and that a single stream should be fast, rather than maximising aggregate
throughput at concurrency. Every value below is the entrypoint's default.
FABRIC_SUBNETS comes from mentatd's tags, and a patch bakes busy_loop_s
into the image.
| knob | value | why |
|---|---|---|
LONG_PREFILL_TOKEN_THRESHOLD |
2304 | Caps one prefill's share of each scheduler step. Left at the default (budget − 256) a 120k prefill takes the whole step and a 12-token request waits 78–90 s; at 2304 it waited 4.83 s (2026-09-06). Must be a multiple of 2304, the KDA block size, because prefix caching snaps chunk ends to it: 2048 yields alternating 2048/256-token chunks. Costs nothing: the 200k prefill got faster. |
MAX_NUM_BATCHED_TOKENS |
16384 | Measured the same as 8192 at 200k once chunks are capped (234.1 s against 237.7 s, 2026-09-06). 8192 at TP=2, which leaves memory for its KV pin. |
KV_CACHE_MEMORY |
26 GiB | 8 GiB at TP=2, 12 GiB at TP=3. Pinned, --gpu-memory-utilization no longer sizes the pool, and vLLM says so at startup. At 28 GiB the head sat near 1 GiB free and eight long requests had a worker OOM-killed. |
FABRIC_SUBNETS |
every address mentatd tags rdma |
Each GB10's ConnectX-7 sits on two PCIe roots and one root tops out near 110 Gb/s. NCCL over both doubles all-reduce bandwidth (110 to 190 Gb/s) and took a 126k prefill from 2,412 to 2,680 tok/s (2026-09-26); decode did not move. Needs an IPv4 on the second root's interface in its own subnet, MTU 9000, the same RoCE v2 GID index on both roots, and both interfaces tagged rdma (step 4). To set it by hand instead, quote it and separate the subnets with spaces (FABRIC_SUBNETS="198.18.0. 198.19.0."). With only CLUSTER_SUBNET set, NCCL uses that one root. The numbers at the top use both. |
MAX_NUM_SEQS |
32 | Each running request holds a KDA recurrent state for every verify position (1+k = 8 at k=7) out of the KV pool, so the pool caps this, not throughput. With RecoverSSM, which keeps one state per request, it is 64 at TP=3, 4 and 6 and 16 at TP=2. |
DFlash2 k=7 |
Decodes 121.9 / 91.3 / 38.7 tok/s structured / code / prose on this image without the overrides (dev/repro/decode.py, thinking off). On an earlier image (2026-09-23) it gave 109.8 / 88.8 / 52.6, and the checkpoint's own MTP head at k=4 gave 57.2 / 54.4 / 45.6. Costs ~41% of the KV pool: 3.44M tokens with speculation off, 2.02M with it at the same pin, on the pre-nightly image (2026-09-06). |
|
MOE_BACKEND |
flashinfer_cutlass |
NVFP4 weights and activations, quantizing activations with the checkpoint's own input scales. The MoE kernels in experimental/ read its processed tensors, so they need it. marlin keeps activations in 16 bits and ignores the input scales. It ran on earlier images and is untested on this one. |
SAFETENSORS_LOAD_STRATEGY |
eager | Loads in 511 s against 690 s for lazy. Unpinned, eager's buffers cost 38% of the KV cache; with the pin they cost nothing. |
busy_loop_s |
0.002 | See Patches. Raises decode and drops the SoC ~20 °C. |
GPU_MEM_UTIL |
0.88 | 0.90 passes every startup check and wedges the box hours later. See Troubleshooting. |
If you are serving many concurrent users instead, raise MAX_NUM_SEQS as far
as the KDA state allows, drop the KV pin back toward 20 GiB, and consider a
larger LONG_PREFILL_TOKEN_THRESHOLD: the fairness reserve costs a solo user
about 5% and buys nothing when every step has several requests in it anyway.
Applied at build time from image/patches/; each asserts its anchor matches
exactly once. image/verify-base.py then checks the finished tree, reading
files as text (an import-based check needs a GPU driver that does not exist
during docker build).
| file | what it does | source |
|---|---|---|
glm53-flash_SM121.py |
Makes the model run on GB10 at all. On capability 12 the nightly offers only FLASHINFER_MLA_SPARSE_SM120, which needs the packed fp8_ds_mla layout with pe_dim == 64; this checkpoint is NoPE, so the engine dies in concat_and_cache_mla after a full weight load. Lists the SM90 sparse-MLA path for capability 12 and swaps FA3 for FA2. |
MiaAI-Lab, MIT |
mia_retarget.py |
Two of MiaAI's edits target code that has moved since: the indexer allocation, now in models/glm5next/nvidia/sparse_indexer.py, and FlashInfer 0.7.0's FA2 fp8 gate. Rewrites their paths and anchors in a copy, so the vendored file stays verbatim. |
ours |
sm90_fp8_kv_dtype.py |
The SM90 backend planned an fp8 KV cache as uint8. | ours |
gb10_plugin_backend.py |
Lets VLLM_GLM53_CUDA_SPARSE_MLA pick between the two sm_121 MLA kernels, which otherwise collide silently. |
ours |
glm53_mtp_bf16.py |
nvidia's config.json says the MTP layer is NVFP4, but its weights are BF16, so MTP dies at load. Excludes it from quantisation when the checkpoint holds no scales for it. |
ours |
glm53_eagle3_aux.py |
DFlash2 reads auxiliary hidden states from target layers 5, 14, 24, 33 and 42; upstream GLM5next does not expose them. | ours |
glm53_dflash2_kv_groups.py |
The GLM-5-Next KV grouper gives up on the drafter's sliding-window layers and the model dies unifying page sizes. Keeps the target's groups and adds the drafter's. | ours |
vllm-58720-routed-experts.patch |
Indexes the expert mapping once per load instead of scanning it for every checkpoint tensor. Merged after this nightly. | vllm#58720 |
image/flashkda/ |
Rebuilds vllm/_flashkda_C from FlashKDA 17a037d. The nightly's b59532f rounds the KDA recurrent state to bf16 every 16 tokens, and long prefills then corrupt tool-call output; 17a037d keeps it in fp32. |
vllm#58846, open |
glm53_reasoning_always_parsed.py |
Thinking off maps to low reasoning effort (image/chat-template.jinja), so the model always emits a short <think> block. vLLM's glm47_moe stops parsing <think> when thinking is off, and the trace would land in content. |
ours |
glm47_failclosed.py |
Tool-call parser plugin, opt-in with TOOL_PARSER=glm47_failclosed (the default is the stock glm47). Checks each call against the tools the request offered; a call with a bad name or argument keys comes back as a retryable call whose sentinel argument names the mistake, instead of being dropped or leaking into history. Containment, not a cure. |
ours, after NNNtrance #7 and #11 |
spin_wait.py |
vLLM's shm queue spins for busy_loop_s (1 s) after each message; on GB10 the CPU and GPU share one power budget, so the spin costs ~20 °C and decode. 0.002 keeps the fast path; 0 (always block) measured slower. |
nacyot |
worker_memory_cap.py |
Caps each worker's share of unified memory (TORCH_MEM_FRACTION, 0.92). vLLM never calls set_per_process_memory_fraction, so nothing else bounds a worker. |
ours |
spark_mem_trace.py |
Names whatever crosses that bound, instead of leaving an OOM anonymous. | ours |
link_cuda_headers.sh |
The base ships CUDA libraries without their headers where nvcc looks, which breaks FlashInfer JIT at link time. | ours |
LibertAI's sparse-MLA kernel plugin is not mounted. To use it, build it for sm_121a into a directory on every box and add an override file:
# compose/ext.yaml
services:
glm53:
volumes:
- /srv/ext:/opt/ext:ro
environment:
- PYTHONPATH=/opt/ext
- VLLM_GLM53_CUDA_SPARSE_MLA=1
./glm53 -f compose/ext.yaml up -d
VLLM_GLM53_CUDA_SPARSE_MLA without the plugin drops the working SM90 path
and leaves the one that fails on this checkpoint. Leave the plugin's
VLLM_GLM53_MOE_INPUT_SCALE unset: it applies one constant activation scale,
and this checkpoint carries real per-projection scales.
dev/patch-tests/ holds tests for two of the patches and for the entrypoint's
mentat discovery (_entrypoint_discovery_test.sh); the image does not use
them. The old recipe's gb10_topk_fallback.py is now a flag
(--sparse-indexer-topk-backend per_row). thinking_budget_guard.py and
glm53_kpool_tail_ring.py (the spec-decode tail ring,
vllm#58454) are upstream.
Prefill at half speed, every metric healthy. If NCCL all-reduce crawls
(~12 Gb/s) while ib_write_bw reads a healthy 109 Gb/s and no error counter
moves, the ConnectX-7 has latched a slow fallback state from the DAC cables
being hot-plugged. Power off and unplug for a minute: a reboot does not
clear it, and neither does a NIC hotplug reset. The tell is that NCCL Tree
beats Ring. Healthy on both roots is Ring 180-191 Gb/s against Tree 55-93
(NOTES.md). A single unidirectional stream
cannot see this, which is why the RDMA test passes; a ring collective, sending
and receiving at once, can.
The box wedges hours after a clean start. GPU_MEM_UTIL=0.90 passes every
startup check and is worth +28% KV, then takes the box down with no ssh, no
userspace and ping only (2026-08-27). Unified memory means the CUDA allocation
is host memory, so none of it is reclaimable and the OOM killer cannot help.
0.88 is the default.
Long output repeats or skips when thinking is off. "Count from 1 to 200"
comes back as 35 36 37 37, or jumps ahead, or starts copying the prompt.
This is the model, not the stack. GLM-5.3-Flash has no non-thinking mode: its
official template always opens <think> under Reasoning Effort: Max, and an
empty <think></think> is out of distribution. Every checkpoint (nvidia,
RedHatAI, the official FP8), both MoE kernels, TP=2 and TP=4, and Hugging
Face's own glm5_next implementation fail the same way (2026-09-23). The
image's template (image/chat-template.jinja) maps thinking: false /
enable_thinking: false to Reasoning Effort: Low instead: a few dozen tokens
of reasoning and a clean answer. reasoning_effort (low, high, default
max) also works directly, at the top level of the request. If long output
still breaks with thinking off, check that CHAT_TEMPLATE isn't pointing at the
checkpoint's own template.
Intermittent corrupted tokens. The LibertAIDAI modelopt NVFP4 build emits them mid-word, inside rare tokens: invisible in English, reproducible with a Korean prompt, and identical under both MoE kernels, both attention backends, and with speculation off (2026-09-06). The nvidia build this recipe uses and the compressed-tensors builds (RedHatAI NVFP4, INT4 AWQ) are clean on the same stack. Independently reported by tonyd2wild.
Workers restart forever with group 'glm53' already has an active driver session,
and the head waits for GPUs. More than one node is running as the head: every
head starts a driver, mentat allows one per group, and the others exit and
restart while the real head never sees its workers. With roles set by hand,
set ROLE=worker in compose/.env on every node except the head, keep
HEAD_HOST the head's address everywhere, then take all four down and start
them again. An image built from this tree refuses a head whose VLLM_HOST_IP is not
HEAD_HOST, with a FATAL line naming the fix. Elected boxes agree on one
head, but a box with ROLE=head in its .env beside them is a second one:
set the roles on every box or on none. If the error remains with the roles
right, an earlier head's session is still held: with all four down, run
mentat stop --group glm53 against the head's daemon (or restart its mentatd).
Boot waits at electing: waiting for 4 agents in group glm53, have 3. A
box has not registered. Every box starts its agent before the election, so
the missing one is not running, cannot reach its own mentatd, or runs under
another MENTAT_GROUP. docker exec mentatd mentatd status --group glm53
on any box lists the agents mentat's head holds: each box should appear once
with alive=true.
Boot hangs at waiting for 4 GPUs, have 1, with HEAD_HOST set. Every
box must set HEAD_HOST to the head's address. Seen 2026-09-06 on a
mentat that held each agent on the daemon it registered with: point a box at
its own daemon and the head lists the agent yet marks it alive=false
degraded=true, while that box's own daemon sees only its own agent. Confirm
with docker exec mentatd mentatd status on the head: every agent should read
alive=true.
The model waits in placement with nothing in the container log. If
MENTAT_ANNOUNCE_IFACES tags a link rdma and the daemons' probes over that
link fail, mentat will not place the four ranks, and the group waits for
MENTAT_PG_PENDING_TIMEOUT_MS (10 minutes). pending_reason in the
daemon's /status (HTTP, port 6380) names the constraint. Fix the link, or set MENTAT_ISLAND_PLACEMENT=off and restart
the daemons.
The second boot deadlocks after NCCL setup. A persisted FlashInfer
autotune cache keys some MoE entries per rank, so rank 0 loads a tuning the
others lack and they wait on each other forever (2026-09-23). The entrypoint
keeps the cache ephemeral at TP>1 and wipes it at every start, which costs
about two minutes of autotune a boot; AUTOTUNE_CACHE=persist brings the old
behaviour back.
The first requests after a fresh install are slow. Kernels compile the
first time each batch shape comes up, mid-serve, and the first request can
take minutes. It happens once per shape per CACHE_HOME: clearing that cache
or its volume brings it back. Run the workload once before timing anything.
A value in .env does nothing. .env only substitutes into the compose
file; a variable reaches the container only if compose/glm53.yaml lists it
under environment. And .env must be in compose/, not in the repo root.
NCCL fails every TP init after a reboot. Do not pin NCCL_IB_GID_INDEX.
The RoCE GID table is indexed by (address, RoCE version) in the order
addresses appeared, so boxes do not agree and a removed address leaves a hole;
the right index moved from 6 to 5 on one box across a reboot (2026-08-26). The
entrypoint derives it at every start. To read a table:
for i in $(seq 0 9); do p=/sys/class/infiniband/<dev>/ports/1; \
echo "$i $(cat $p/gid_attrs/types/$i) $(cat $p/gids/$i)"; done
The right entry is the RoCE v2 one for the box's static fabric address.
mmastrac/mentat ·
mmastrac/spark-agent ·
tonyd2wild ·
MiaAI-Lab (sm_121 patches, see
image/patches/LICENSE.MiaAI-Lab) ·
tonyliu312 (the KV pin) ·
nacyot (spin wait) ·
alexellis,
and RigMark for the gate ·
incoai (the DFlash2 drafter) ·
NNNtrance (the fail-closed
tool parser idea) ·
Chuck (the RDMA path MTU, #6, and the
streamed tool call and page cache reports, #7) ·
ayayalar (the first-boot stall report behind the
fabric check, #5) ·
stevededrick (the arx idle backoff,
#59) ·
sdougbrown (the EAGLE block drop,
#54, and the stale JIT
lock, #53) ·
calvarado2004 (the arx ring at three ranks,
#57, and the stock tool
parser default, #58) ·
tfolkman (how a killed worker leaves the JIT lock,
#53)
The files in experimental/ that replace vLLM and FlashInfer files keep their
Apache-2.0 headers, and experimental/README.md
lists which ones they are.