| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032 |
- #!/bin/bash
- set -euo pipefail
- # ---------------------------------------------------------------------------
- # Serve GLM-5.3-Flash (NVFP4) across the ConnectX-linked GX10 boxes. TP and PP
- # come from the environment, so the same image serves the TP=2/PP=2 pair-of-
- # pairs and a flat TP=4 across one fabric.
- #
- # Deliberately simpler than ds4-flash/entrypoint.sh in one respect: it loads
- # PLAIN SAFETENSORS and builds no sharded-state cache. That costs boot time and
- # buys correctness margin -- the sharded-state path needs the modelopt
- # IDEMPOTENT-GUARD patch, because a cache is dumped AFTER the NVFP4
- # kernel-format transform and re-running that transform on load permutes the
- # fused gate/up halves and serves fluent nonsense with no error. This image is
- # built on upstream's per-model base and carries none of our patches, so the
- # safe load is the one that runs the transform exactly once.
- #
- # Adopt the sharded-state cache only together with that patch. See
- # ds4-flash/vllm-spark.patch.
- # ---------------------------------------------------------------------------
- TP="${TP:-4}"
- MTP="${MTP:-1}"
- # TP=RING4 is TP=4 on four boxes cabled in a loop with no switch. mentat
- # places the ranks in cable order, and fabric_ring.py sets each rank's NCCL
- # and arx devices from the neighbours mentat gives it.
- FABRIC_LAYOUT=mesh
- if [[ "$TP" == RING4 ]]; then
- TP=4; FABRIC_LAYOUT=ring
- export MENTAT_CLAIM_LAYOUT=ring
- fi
- # --- per-TP defaults ---------------------------------------------------------
- # Each rank holds 1/TP of the weights and of every request's KDA state, so
- # the memory left for KV, and how many requests fit in it, depend on TP. These
- # fill in whatever .env leaves out. TP=4 is the measured four-box setup. TP=2
- # is two boxes with 89.6 GiB of weights per rank. Halving the batched-token
- # budget to 8192 shrinks the activation peak by ~2.8 GiB on the head, at
- # ~9% of prefill speed, and an 8 GiB KV pin then leaves the head 1.4 GiB free
- # at its lowest. At 16384 an 8 GiB pin got a worker OOM-killed mid-prefill.
- # Each rank carries half the KDA heads, so a request's states are twice as big.
- # RecoverSSM (recoverssm.yaml) keeps one state per request instead of 1 + k:
- # 64 run at TP=4, the most whose k=7 steps fit the largest captured graph,
- # and 16 at TP=2. It also removes the state
- # writes adaptive-k's step cost used to include, so it gets its own prior,
- # fitted on code and prose at two concurrencies with k forced to 2 and 7.
- _rs=0; [[ "${VLLM_GLM5NEXT_RECOVERSSM:-0}" == 1 ]] && _rs=1
- case "$TP" in
- 4) # KV_EXTRA_MEMORY (bytes) raises the default pin. dispram.yaml sets 2 GiB,
- # the RAM kindling-spark-os's 64 KiB kernel returns.
- : "${KV_CACHE_MEMORY:=$(( 27917287424 + ${KV_EXTRA_MEMORY:-0} ))}" "${MAX_MODEL_LEN:=524288}"
- (( _rs )) && : "${MAX_NUM_SEQS:=64}" "${VLLM_ADAPTIVE_K_MODEL:=26.4,0.660,0.484}"
- # With the drafter's KV in its own pool (fixes.yaml), ~50 requests' KDA
- # states fit in the pool; without it, 32.
- [[ "${VLLM_GLM5NEXT_DRAFT_POOL:-0}" == 1 ]] && : "${MAX_NUM_SEQS:=50}"
- : "${VLLM_ADAPTIVE_K_MODEL:=27.0,0.635,0.542}" ;;
- 3) # Three boxes on the zero-padded checkpoint (experimental/tp3). 26 GiB of
- # KV had the host OOM-killer take rank 0 during graph capture; 12 GiB
- # holds 1.4M tokens (1.7M with RecoverSSM). Block 3456 becomes 3584 in
- # vLLM: 14% mamba page padding, where the 4608 it picks from 2304 pads 46%
- # and coarsens the prefix cache.
- : "${KV_CACHE_MEMORY:=12884901888}" "${MAX_MODEL_LEN:=524288}" "${BLOCK_SIZE:=3456}"
- export BLOCK_SIZE
- # With RecoverSSM, 64 requests at k=7 fill the 512-token graph cap as at
- # TP=4: +24% aggregate at 48 streams over 32, single-stream unchanged.
- (( _rs )) && : "${MAX_NUM_SEQS:=64}"
- # What the online fit converged to at TP=3 with RecoverSSM on (3% mean
- # error; the two three-box sets agreed within 2%).
- : "${VLLM_ADAPTIVE_K_MODEL:=27.5,0.795,0.50}" ;;
- 6) # Six boxes on the same zero-padded checkpoint as TP=3 (66 / 66 / 2304
- # split as 11 heads and 384 MoE columns per rank). Weights are ~1/6 per
- # rank, so the TP=4 KV pin fits. With 11 KDA heads a rank's mamba page is
- # ~1573 tokens of attention page: block 2304 pads it 46%, 1792 (the
- # smallest multiple of 256 above it) 14%, for a 1.7% bigger pool and
- # 17% faster cached TTFT.
- : "${KV_CACHE_MEMORY:=27917287424}" "${MAX_MODEL_LEN:=524288}" "${BLOCK_SIZE:=1792}"
- export BLOCK_SIZE
- (( _rs )) && : "${MAX_NUM_SEQS:=64}"
- # What the online fit converged to at TP=6 with RecoverSSM on (1% error).
- : "${VLLM_ADAPTIVE_K_MODEL:=19.3,0.50,0.40}" ;;
- 2) : "${KV_CACHE_MEMORY:=8589934592}" "${MAX_MODEL_LEN:=163840}" "${MAX_NUM_BATCHED_TOKENS:=8192}"
- # A rank reads twice the expert weights it does at TP=4, so the
- # per-expert cost is held at twice TP=4's and the rest fitted.
- (( _rs )) && : "${MAX_NUM_SEQS:=16}" "${VLLM_ADAPTIVE_K_MODEL:=32.1,1.32,0.427}"
- : "${MAX_NUM_SEQS:=4}"
- : "${VLLM_ADAPTIVE_K_MODEL:=35.0,1.27,0.8}" ;;
- *) echo "FATAL: TP=$TP; this recipe is tuned for TP=4 or TP=RING4 (four boxes), TP=6 (six), TP=3 (three) or TP=2 (two)" >&2; exit 1 ;;
- esac
- export VLLM_ADAPTIVE_K_MODEL
- # SPEC_METHOD picks the drafter: dflash (the separate DFlash2 draft model at
- # DFLASH_MODEL, which must be mounted on every node; k must be 7, its block
- # size minus one, and the model refuses anything else), mtp (the checkpoint's
- # own head, num_nextn_predict_layers: 1), or none. MTP=0 is the older off
- # switch and still turns speculation off whichever method is named. Resolved
- # here, above the cache tag, because the tag hashes it.
- #
- # dflash is what serves: 109.8 / 88.8 / 52.6 tok/s on structured / code / prose
- # with v6, where MTP gave 57.2 / 54.4 / 45.6 with v4 (both 2026-09-23). Judge a drafter
- # on acceptance LENGTH, not acceptance rate. Set here rather than through
- # EXTRA_ARGS, which is one string that a compose override restating it
- # silently replaces.
- SPEC_METHOD="${SPEC_METHOD:-dflash}"
- [[ "$MTP" == "0" ]] && SPEC_METHOD=none
- # With neither ROLE nor HEAD_HOST set, the boxes elect a head once all TP
- # agents have registered (discover.sh). HEAD_HOST alone fixes the head, and
- # each box's role follows from whether it holds that address.
- . "$(dirname "$0")/discover.sh"
- _elect=""
- [[ -z "${ROLE:-}" && -z "${HEAD_HOST:-}" ]] && _elect=1
- MODEL="${MODEL_DIR:-/models/glm-5.3-flash-nvfp4}"
- SERVED="${SERVED_NAME:-glm53}"
- # --- cluster networking: everything rides the ConnectX link -----------------
- # No literal address appears here or in the compose file. The boxes are NOT
- # symmetric (one carries its link on enp1s0f1np1, the rest on enp1s0f0np0), so
- # any hardcoded interface name is wrong somewhere whichever you pick.
- #
- # VLLM_HOST_IP is this node's LAN address: mentat identifies a node by it, and
- # the agent and daemon must agree on one string. Address prefixes
- # (CLUSTER_SUBNET, FABRIC_SUBNETS) name the fabric. Whatever .env leaves out
- # comes from the local mentatd: its lan-tagged address, and one prefix per
- # rdma-tagged address.
- #
- # An uncabled port powers down completely -- no PCI device, no
- # /sys/class/infiniband entry. That is not a missing driver and no amount of
- # modprobe fixes it, so a node reports only the ports it actually has.
- discover_network
- CLUSTER_SUBNET="${CLUSTER_SUBNET:-${FABRIC_SUBNETS%% *}}"
- export VLLM_HOST_IP
- # Gloo needs an EXACT interface name -- NCCL_SOCKET_IFNAME takes a prefix, this
- # does not. Left unset, Gloo binds 127.0.0.1 and the cluster fails silently at
- # rendezvous, so derive it from whichever interface actually owns VLLM_HOST_IP.
- # Bounded, unlike the fabric wait above: the address is already up, so the
- # interface and its RDMA device are local state that either settles in seconds
- # or is broken. Waiting past that hides the fault instead of reporting it.
- if [[ -z "${GLOO_SOCKET_IFNAME:-}" ]]; then
- _waited=0
- while :; do
- GLOO_SOCKET_IFNAME=$(ip -o -4 addr show 2>/dev/null \
- | awk -v ip="$VLLM_HOST_IP" '$4 ~ "^"ip"/" {print $2; exit}')
- [[ -n "$GLOO_SOCKET_IFNAME" ]] && break
- if (( _waited >= ${ROCE_SETTLE_S:-60} )); then
- echo "FATAL: no interface holds VLLM_HOST_IP=$VLLM_HOST_IP after ${_waited}s" >&2
- ip -br addr show >&2; exit 1
- fi
- sleep 5; _waited=$(( _waited + 5 ))
- done
- fi
- export GLOO_SOCKET_IFNAME
- # --- fabric ports: which RoCE devices carry the cluster, at which GID -------
- # FABRIC_SUBNETS names one address prefix per cabled port. A second entry puts
- # NCCL on both PCIe roots of the ConnectX-7, which ib_write_bw measured at 196
- # Gb/s against 112 for one root alone. It is opt-in because the second root's
- # registrations are GPU-resident and land AFTER vLLM profiles, so they eat the
- # headroom a long prefill needs rather than the KV cache: allocations sat at
- # 111.41 GiB against the 104.6 GiB GPU_MEM_UTIL=0.86 budgets. At TP=2 that
- # bought nothing -- the per-token allreduce is ~720 KB, a fraction of a
- # millisecond against a 45 ms token -- so weigh it only at TP>2.
- #
- # Keyed on the subnet rather than on VLLM_HOST_IP because the two are no longer
- # the same address. mentat identifies a node by its LAN address and the agent
- # and daemon must agree on one string, so VLLM_HOST_IP is the LAN one, and no
- # RoCE GID will ever match it.
- FABRIC_SUBNETS="${FABRIC_SUBNETS:-$CLUSTER_SUBNET}"
- # Echoes "<rdma-device> <gid-index>" for the port holding an address in $1.
- # Returns 1 when this node has not cabled that port, or its GID has yet to
- # appear.
- fabric_port() {
- local prefix="$1" found addr ifname dev="" hex i t g d n
- found=$(ip -o -4 addr show 2>/dev/null \
- | awk -v p="$prefix" '$4 ~ "^"p {split($4,a,"/"); print $2, a[1]; exit}')
- [[ -n "$found" ]] || return 1
- ifname="${found%% *}"; addr="${found##* }"
- for d in /sys/class/infiniband/*; do
- for n in "$d"/ports/1/gid_attrs/ndevs/*; do
- [[ -f "$n" ]] || continue
- [[ "$(cat "$n" 2>/dev/null)" == "$ifname" ]] || continue
- dev="$(basename "$d")"; break 2
- done
- done
- [[ -n "$dev" ]] || return 1
- # Slots 0/1 hold the driver's MAC-derived GIDs whatever the IP configuration,
- # so match this port's own static address in its IPv4-mapped form, v2 only.
- hex=$(printf '%s' "$addr" | awk -F. '{printf "%02x%02x:%02x%02x", $1,$2,$3,$4}')
- for i in $(seq 0 15); do
- t=$(cat "/sys/class/infiniband/$dev/ports/1/gid_attrs/types/$i" 2>/dev/null) || continue
- g=$(cat "/sys/class/infiniband/$dev/ports/1/gids/$i" 2>/dev/null) || continue
- [[ "$t" == "RoCE v2" && "$g" == *"ffff:$hex" ]] || continue
- printf '%s %s\n' "$dev" "$i"; return 0
- done
- return 1
- }
- # There is no correct constant for the GID index. The table is keyed by
- # (address, RoCE version), slots are allocated first-free and freed in place, so
- # the index for one address differs per node AND per boot. Observed, not
- # theorised: on 2026-08-26 one node held the right entry at 6 and its peer at
- # 5, because a `nmcli con delete` / `add` had left the first a hole at slot 3. Reboot
- # it with the static profile already in place and the table comes up dense,
- # moving that 6 to 5, at which point a pinned 6 names an empty slot and NCCL
- # fails every TP init with "unhandled system error".
- if [[ -z "${NCCL_IB_HCA:-}" || -z "${NCCL_IB_GID_INDEX:-}" ]]; then
- _waited=0
- while :; do
- _hcas=""; _gid=""; _mismatch=""
- for _p in $FABRIC_SUBNETS; do
- _r=$(fabric_port "$_p") || continue
- _d="${_r%% *}"; _i="${_r##* }"
- [[ -n "$_gid" && "$_i" != "$_gid" ]] && _mismatch="$_d at $_i, expected $_gid"
- _gid="${_gid:-$_i}"
- _hcas="${_hcas:+$_hcas,}$_d"
- done
- [[ -n "$_hcas" ]] && break
- if (( _waited >= ${ROCE_SETTLE_S:-60} )); then
- echo "FATAL: no RoCE v2 GID for any of: $FABRIC_SUBNETS" >&2
- ip -br addr show >&2
- ls /sys/class/infiniband/ >&2 || echo "(no /sys/class/infiniband at all)" >&2
- exit 1
- fi
- sleep 5; _waited=$(( _waited + 5 ))
- done
- # NCCL_IB_GID_INDEX applies to every device in NCCL_IB_HCA, so a device whose
- # index differs gets asked for a GID it does not have and QP setup dies with
- # "local GID ::". One IPv4 per fabric interface keeps them aligned; refuse the
- # list rather than hand NCCL one that cannot work.
- # A ring gives each cable its own subnet, and fabric_ring.py picks each
- # device's GID by address instead.
- if [[ -n "$_mismatch" && "$FABRIC_LAYOUT" == mesh ]]; then
- echo "FATAL: fabric ports disagree on GID index ($_mismatch)." >&2
- echo "Each fabric interface must carry exactly one IPv4 address." >&2
- exit 1
- fi
- NCCL_IB_HCA="${NCCL_IB_HCA:-$_hcas}"
- NCCL_IB_GID_INDEX="${NCCL_IB_GID_INDEX:-$_gid}"
- echo "fabric: NCCL_IB_HCA=$NCCL_IB_HCA gid=$NCCL_IB_GID_INDEX (subnets: $FABRIC_SUBNETS)"
- else
- echo "fabric: NCCL_IB_HCA=$NCCL_IB_HCA gid=$NCCL_IB_GID_INDEX (both pinned; derivation skipped)"
- fi
- export NCCL_IB_HCA NCCL_IB_GID_INDEX
- # The EXACT interface holding VLLM_HOST_IP, not a prefix. This is only the
- # out-of-band bootstrap path -- the IB devices above carry the data -- and a
- # prefix matches every interface that happens to share it. Once the fabric
- # grew a second cable, "enp1s0f" matched both the head link and an unrelated
- # one, and NCCL bootstrapped toward the wrong wire: "Connection closed by
- # remote peer spark-head".
- export NCCL_SOCKET_IFNAME="${NCCL_SOCKET_IFNAME:-$GLOO_SOCKET_IFNAME}"
- # Channel count. NCCL picks 64 here, while every published GB10 recipe pins
- # 4-12. 8 was chosen on 2026-09-06 while the fabric was stuck at 12 Gb/s, before
- # a power drain fixed it; NCCL's own choice has not been re-tested since.
- export NCCL_MAX_NCHANNELS="${NCCL_MAX_NCHANNELS:-8}"
- # INFO prints the devices NCCL actually selected, which is the only way to
- # confirm both roots are in use.
- export NCCL_DEBUG="${NCCL_DEBUG:-INFO}"
- # --- preflight: things that make the stack slow or fragile without failing --
- # One table, WARN rows first to read, never fatal. PREFLIGHT=0 skips it.
- preflight() {
- local rows=() warns=0 dev nd mtu rate state phys cur max tp kv need avail swap fs free apps
- # Fields are split on the unit separator: fix text can hold "|" (echo 3 | sudo tee).
- local sep=$'\x1f'
- row() { rows+=("$1$sep$2$sep$3"); [[ "$3" == WARN ]] && warns=$(( warns + 1 )); return 0; }
- local -a devs; IFS=, read -r -a devs <<< "$(sed -E 's/^[=^]+//; s/:[0-9]+//g' <<< "$NCCL_IB_HCA")"
- (( ${#devs[@]} >= 2 )) && row "fabric devices" "${NCCL_IB_HCA}" ok \
- || row "fabric devices" "${NCCL_IB_HCA} (one PCIe root tops out near 110 Gb/s; set FABRIC_SUBNETS to both)" WARN
- local roots; roots=$(for dev in "${devs[@]}"; do basename "$(readlink -f "/sys/class/infiniband/$dev/device")" | cut -d: -f1-2; done | sort -u | wc -l)
- (( ${#devs[@]} < 2 || roots >= 2 )) || row "PCIe roots" "all fabric devices share one root" WARN
- for dev in "${devs[@]}"; do
- local p=/sys/class/infiniband/$dev
- [[ -d $p ]] || { row "$dev" "no such device" WARN; continue; }
- nd=$(ls "$p/device/net" 2>/dev/null | head -1)
- state=$(cut -d' ' -f2 "$p/ports/1/state"); phys=$(cut -d' ' -f2- "$p/ports/1/phys_state")
- [[ $state == ACTIVE ]] && row "$dev link" "$state, $phys" ok || row "$dev link" "$state, $phys" WARN
- rate=$(cut -d' ' -f1 "$p/ports/1/rate")
- (( rate >= 200 )) && row "$dev rate" "$(cat "$p/ports/1/rate")" ok || row "$dev rate" "$(cat "$p/ports/1/rate") (expected 200 Gb/sec)" WARN
- # RoCE's path MTU is the largest IB MTU that fits the netdev's: 9000 gives
- # 4096, 1500 gives 1024. arx and arxbig follow it, and smaller packets cost
- # bandwidth everywhere.
- mtu=$(cat "/sys/class/net/$nd/mtu" 2>/dev/null || echo 0)
- (( mtu >= 4200 )) && row "$nd MTU" "$mtu" ok || row "$nd MTU" "$mtu (RoCE path MTU below 4096; set 9000 end to end)" WARN
- cur="$(cut -d' ' -f1-2 "$p/device/current_link_speed") x$(cat "$p/device/current_link_width")"
- max="$(cut -d' ' -f1-2 "$p/device/max_link_speed") x$(cat "$p/device/max_link_width")"
- [[ $cur == "$max" ]] && row "$dev PCIe" "$cur" ok \
- || row "$dev PCIe" "$cur of $max (degraded link; a full power drain has fixed this before)" WARN
- # Each up/down is two changes; more than a few since boot means a flapping link.
- local flaps; flaps=$(cat "/sys/class/net/$nd/carrier_changes" 2>/dev/null || echo 0)
- (( flaps <= 4 )) || row "$nd flaps" "$flaps carrier changes since boot (check the cable or transceiver)" WARN
- local c v bad=""
- for c in local_ack_timeout_err packet_seq_err out_of_sequence; do
- v=$(cat "$p/ports/1/hw_counters/$c" 2>/dev/null || echo 0)
- (( v > 0 )) && bad+="$c=$v "
- done
- [[ -z $bad ]] || row "$dev retransmits" "${bad% } (since driver load; drops, likely no PFC)" info
- done
- # RDMA pins its buffers; a memlock limit fails registration or NCCL's setup.
- [[ "$(ulimit -l)" == unlimited ]] && row "memlock limit" "unlimited" ok \
- || row "memlock limit" "$(ulimit -l) KiB (RDMA registration needs unlimited: ulimits memlock -1)" WARN
- # GPU: a power-delivery or thermal event caps clocks for the whole boot.
- local ev; ev=$(nvidia-smi --query-gpu=clocks_event_reasons.hw_slowdown,clocks_event_reasons.hw_power_brake_slowdown,clocks_event_reasons.hw_thermal_slowdown,clocks_event_reasons.sw_thermal_slowdown,temperature.gpu --format=csv,noheader 2>/dev/null)
- if [[ -z $ev ]]; then row "GPU" "nvidia-smi unavailable" WARN
- elif awk -F', ' '{for (i = 1; i < NF; i++) if ($i == "Active") f = 1} END {exit !f}' <<< "$ev"; then
- row "GPU slowdown" "$ev (hw, power brake, hw thermal, sw thermal, C)" WARN
- else row "GPU slowdown" "none, ${ev##*, } C" ok; fi
- apps=$(nvidia-smi --query-compute-apps=pid,process_name,used_memory --format=csv,noheader 2>/dev/null | tr '\n' ';')
- [[ -z $apps ]] && row "other GPU processes" "none" ok || row "other GPU processes" "${apps%;}" WARN
- # Host memory: ~182 GiB of weights split TP ways, the pinned KV, and ~30 GiB
- # more (CUDA graphs, the activation reserve, NCCL and RDMA buffers, the
- # processes themselves). TP=2 with 8 GiB of KV came to ~123 GiB and the OOM
- # killer took a worker mid-prefill.
- tp=${TP:-4}; kv=$(( ${KV_CACHE_MEMORY:-27917287424} >> 30 ))
- need=$(( 182 / tp + kv + 30 ))
- avail=$(( $(awk '/MemAvailable/ {print $2}' /proc/meminfo) >> 20 ))
- (( avail >= need )) && row "host memory" "${avail} GiB free, ~${need} GiB needed" ok \
- || row "host memory" "${avail} GiB free, ~${need} GiB needed (stop other models; lower KV_CACHE_MEMORY)" WARN
- # MemAvailable counts the page cache as free, but on GB10 CUDA allocations
- # can fail before the kernel reclaims it: NCCL's init died "out of memory"
- # with ~113 GB cached after a weight copy. Drop the model files' own cached
- # pages (no privilege needed), then say how to drop the rest.
- unfree() { echo $(( $(awk '/MemFree/ {print $2}' /proc/meminfo) >> 20 )); }
- if (( $(unfree) < need )); then
- python3 - "$MODEL" "${DFLASH_MODEL:-/models/glm-5.3-flash-dflash2}" "${VLLM_WEIGHT_SNAPSHOT_DIR:-}" <<'PY' 2>/dev/null || true
- import os, sys
- for root in filter(None, sys.argv[1:]):
- for dirpath, _, files in os.walk(root):
- for name in files:
- try:
- fd = os.open(os.path.join(dirpath, name), os.O_RDONLY)
- os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED)
- os.close(fd)
- except OSError:
- pass
- PY
- fi
- local memfree; memfree=$(unfree)
- local cached; cached=$(( $(awk '/^Cached:/ {print $2}' /proc/meminfo) >> 20 ))
- (( memfree >= need )) && row "page cache" "${cached} GiB cached, ${memfree} GiB free" ok \
- || row "page cache" "${cached} GiB cached, ${memfree} GiB free, ~${need} GiB needed (GPU allocations may fail: on the host, sync; echo 3 | sudo tee /proc/sys/vm/drop_caches)" WARN
- swap=$(( ( $(awk '/SwapTotal/ {print $2}' /proc/meminfo) - $(awk '/SwapFree/ {print $2}' /proc/meminfo) ) >> 20 ))
- (( swap < 1 )) || row "swap in use" "${swap} GiB (the GPU shares this memory; paging stalls it)" WARN
- fs=$(findmnt -n -o FSTYPE -T "${MODEL_DIR:-/models/glm-5.3-flash-nvfp4}" 2>/dev/null)
- [[ $fs == nfs* || $fs == cifs || $fs == fuse* ]] && row "model filesystem" "$fs (every rank reads it all; use local disk)" WARN \
- || row "model filesystem" "${fs:-unknown}" ok
- # The first boot at a TP size writes a weight snapshot (snapshot.yaml).
- if [[ -n "${VLLM_WEIGHT_SNAPSHOT_DIR:-}" ]]; then
- free=$(df -BG --output=avail "${CACHE_ROOT:-/root/.cache}" 2>/dev/null | tail -1 | tr -dc 0-9)
- ver=$(sed -n 's/^SNAPSHOT_VERSION = \([0-9]*\).*/\1/p' \
- /usr/local/lib/python3.12/dist-packages/vllm/model_executor/model_loader/weight_snapshot.py 2>/dev/null)
- if compgen -G "$VLLM_WEIGHT_SNAPSHOT_DIR/v${ver}-target-tp*of${tp}-*" >/dev/null; then row "weight snapshot" "present for TP=$tp" ok
- elif (( ${free:-0} >= 182 / tp + 5 )); then row "weight snapshot" "none yet for TP=$tp, ${free} GiB free" ok
- else row "weight snapshot" "none yet for TP=$tp, ${free:-?} GiB free, needs ~$(( 182 / tp )) GiB" WARN; fi
- fi
- # A desktop session holds GPU memory and CPU on the shared memory, and these
- # boxes ship with GDM enabled. systemd lists running units in /run/systemd/units.
- if [[ -d /host/systemd-units ]]; then
- local dm; dm=$(ls /host/systemd-units 2>/dev/null \
- | sed -n 's/^invocation:\(gdm3\?\|lightdm\|sddm\|display-manager\)\.service$/\1/p' | head -1)
- [[ -z $dm ]] && row "desktop session" "none" ok \
- || row "desktop session" "$dm is running (sudo systemctl set-default multi-user.target && sudo systemctl isolate multi-user.target)" WARN
- fi
- grep -q '^search \.$' /etc/resolv.conf 2>/dev/null && row "resolv.conf" "'search .' frozen in; bare hostnames fail (restart the container)" WARN
- local on; on=$(overlays | tr '\n' ' ')
- [[ -n $on ]] && row "overlays" "$on" ok || row "overlays" "none: stock kernels" WARN
- echo "preflight ($warns warning$([[ $warns == 1 ]] || echo s)):"
- printf '%s\n' "${rows[@]}" | awk -F"$sep" '$3 == "WARN" {printf " %-5s %-26s %s\n", $3, $1, $2}'
- printf '%s\n' "${rows[@]}" | awk -F"$sep" '$3 != "WARN" {printf " %-5s %-26s %s\n", $3, $1, $2}'
- }
- # The experimental/ overlays this container has mounted, one name per line.
- # Each one mounts a file the image does not ship, except sp, which only sets
- # its environment.
- overlays() {
- local v=/usr/local/lib/python3.12/dist-packages/vllm
- [[ -e $v/distributed/device_communicators/arx.py ]] && echo arx
- [[ -e $v/model_executor/model_loader/weight_snapshot.py ]] && echo snapshot
- [[ -e $v/v1/core/sched/adaptive_k.py ]] && echo adaptive-k
- [[ -e $v/model_executor/model_loader/dense_fp8.py ]] && echo fp8
- [[ -e $v/model_executor/layers/fused_moe/megamoe_vllm.py ]] && echo megamoe
- [[ -e $v/v1/attention/backends/mla/gb10_sparse_mla.py ]] && echo fixes
- [[ -n "${VLLM_GLM_SP_TP:-}" ]] && echo sp
- [[ -e $v/models/glm5next/common/recoverssm.py ]] && echo recoverssm
- return 0
- }
- [[ "${PREFLIGHT:-1}" == 1 ]] && preflight || true
- # Without any overlay the stack runs vLLM's stock kernels at about half the
- # README's decode speed, and nothing else says so. Refuse to start instead.
- if [[ -z "$(overlays)" && "${ALLOW_STOCK:-0}" != 1 ]]; then
- echo "FATAL: no experimental overlays are mounted, so this would run stock kernels at about half the" \
- "README's speed. Start the stack with ./glm53 up -d (README step 5), or set ALLOW_STOCK=1" \
- "in compose/.env to run stock on purpose." >&2
- exit 1
- fi
- # --- worker memory ---------------------------------------------------------
- # Torch's caching allocator never hands a freed block back to the OS, and on
- # unified memory that block is host memory. The sparse indexer scores
- # chunk x context/kpool, so a session whose context keeps growing asks for a
- # slightly bigger block each step and strands the last one.
- # expandable_segments:True grows one segment instead of laddering.
- export PYTORCH_CUDA_ALLOC_CONF="${PYTORCH_CUDA_ALLOC_CONF:-expandable_segments:True}"
- # Hard ceiling on one worker's device memory, as a fraction of the 121.6 GiB,
- # read by spark_mem_trace.py. Keep it above GPU_MEM_UTIL by enough for a full
- # prefill's indexer scratch, and below 1.0 by whatever the host needs to keep
- # forking: past this a request raises OutOfMemoryError instead of the node
- # wedging. 0.92 is ~111.9 GiB against GPU_MEM_UTIL's ~107.
- export TORCH_MEM_FRACTION="${TORCH_MEM_FRACTION:-0.92}"
- if [[ -n "$_elect" ]]; then
- # Every box registers with its own daemon. The daemon relays to mentat's
- # head, and every group lives on the head.
- RAY_ADDRESS="${RAY_ADDRESS:-127.0.0.1:6379}"
- export RAY_ADDRESS
- else
- RAY_ADDRESS="${RAY_ADDRESS:-${HEAD_HOST:?set HEAD_HOST to the head node address}:6379}"
- export RAY_ADDRESS
- # One head per group. A worker left at ROLE=head starts a second driver:
- # mentat refuses it ("group ... already has an active driver session"), the
- # container restarts forever, and the real head waits for GPUs that never
- # join. Catch the mismatch here and say which setting.
- fixed_role
- case "$ROLE" in head|worker) ;; *) echo "FATAL: ROLE=$ROLE; set it to head or worker in .env" >&2; exit 1;; esac
- if [[ "$ROLE" == "head" && "$VLLM_HOST_IP" != "$_head_ip" ]]; then
- echo "FATAL: ROLE=head, but VLLM_HOST_IP=$VLLM_HOST_IP is not HEAD_HOST=$HEAD_HOST ($_head_ip). Set ROLE=worker in .env on every node except the head." >&2
- exit 1
- fi
- if [[ "$ROLE" == "worker" && "$VLLM_HOST_IP" == "$_head_ip" ]]; then
- echo "FATAL: ROLE=worker, but VLLM_HOST_IP=$VLLM_HOST_IP is HEAD_HOST. Set ROLE=head in .env on this node." >&2
- exit 1
- fi
- fi
- # mentat (the Ray replacement in this image) rendezvouses subclusters by
- # group; every rank of this deployment must carry the same value. Running the
- # same model twice means two compose stacks with DISTINCT MENTAT_GROUP values.
- export MENTAT_GROUP="${MENTAT_GROUP:-${SERVICE_NAME:-glm53}}"
- # The shim reads MENTAT_CLAIM_LAYOUT only when it makes a claim, which needs a
- # claim name.
- [[ "$FABRIC_LAYOUT" == ring ]] && export MENTAT_CLAIM="${MENTAT_CLAIM:-$MENTAT_GROUP}"
- # Pin the executor the mentat shim was audited against. It is vLLM's default
- # today (verified in this image AND the DS4 tree), but the default is
- # undocumented, and if an upstream bump flips it the legacy executor's
- # compiled-DAG surface comes alive -- which mentat does not implement and
- # fails loudly on. Pinning turns that failure into a grep-able one-liner.
- export VLLM_USE_RAY_V2_EXECUTOR_BACKEND=1
- # --- scheduler: per-step token budget, and one prefill's share of it --------
- # One setting, not two. The scheduler gives a long prefill
- # min(threshold, remaining budget), so a threshold at or above the budget lets
- # one big prompt take the entire step -- no decodes of running requests, no
- # admissions from the waiting queue. Measured on DS4 2026-08-25: a 90-token
- # "ping" sent 20s into a 198K-token prefill took 125.5s to answer.
- #
- # 16384 with each prefill capped at 2304, so one step carries several prefill
- # chunks and every running decode. Measured on TP=4 (2026-09-06): 8192 and
- # 16384 prefill a 200k prompt in the same time (234.1 s against 237.7 s); the
- # old 9.5x win for 8192 was a PP=2 pipeline bubble, gone at PP=1. Measure this
- # at 200k, never at 8k: an 8k prompt fits inside both budgets.
- export MAX_NUM_BATCHED_TOKENS="${MAX_NUM_BATCHED_TOKENS:-16384}"
- # --- max_num_seqs is capped by the KDA state, not by throughput -------------
- # 34 of this model's 45 layers are KDA linear attention, and a linear-attention
- # layer keeps a fixed-size RECURRENT STATE per sequence rather than a per-token
- # KV entry. vLLM accounts for those as "Mamba cache blocks", one per decode
- # sequence, and they are allocated out of whatever is left after weights.
- #
- # Measured here 2026-08-26: exactly 32 blocks fit at GPU_MEM_UTIL=0.86 with the
- # 181 GiB NVFP4 checkpoint. vLLM's default max_num_seqs of 256 therefore aborts
- # at CUDA graph capture, AFTER a full 10-minute weight load:
- #
- # ValueError: max_num_seqs (256) exceeds available Mamba cache blocks (32).
- # Each decode sequence requires one Mamba cache block, so CUDA graph capture
- # cannot proceed.
- #
- # This is a hard structural cap, not a tuning preference -- raising
- # GPU_MEM_UTIL is the only way to buy more blocks. The default takes all 32.
- #
- # Keep CUDAGRAPH_CAPTURE_SIZES's largest entry >= this value: with MTP off a
- # decode step is one token per sequence, so a full batch is exactly this many
- # tokens, and a step larger than the biggest captured size runs uncaptured.
- export MAX_NUM_SEQS="${MAX_NUM_SEQS:-32}"
- # One prefill's share of the step: 2304, one KDA block. A multiple of the block
- # matters -- 2048 produced alternating 2048/256 chunks. With it (2026-09-06),
- # prefill ran 2,004 tok/s at 200k and a 12-token ping sent under a 120k prefill
- # answered in 4.83 s. DECODE_RESERVE_TOKENS, when set, replaces it with
- # budget minus reserve, which is how DS4 expresses the same split.
- LONG_PREFILL_TOKEN_THRESHOLD="${LONG_PREFILL_TOKEN_THRESHOLD:-2304}"
- if [[ -n "${DECODE_RESERVE_TOKENS:-}" ]]; then
- LONG_PREFILL_TOKEN_THRESHOLD=$(( (MAX_NUM_BATCHED_TOKENS - DECODE_RESERVE_TOKENS) / 4 * 4 ))
- if (( LONG_PREFILL_TOKEN_THRESHOLD < 4 )); then
- echo "FATAL: DECODE_RESERVE_TOKENS=${DECODE_RESERVE_TOKENS} leaves only" >&2
- echo "${LONG_PREFILL_TOKEN_THRESHOLD} tokens for prefill out of a ${MAX_NUM_BATCHED_TOKENS} budget." >&2
- exit 1
- fi
- fi
- CUDAGRAPH_CAPTURE_SIZES="${CUDAGRAPH_CAPTURE_SIZES:-8 16 32 64 96 128 192 256}"
- echo "scheduler: budget=${MAX_NUM_BATCHED_TOKENS} prefill<=${LONG_PREFILL_TOKEN_THRESHOLD}" \
- "reserve=$(( MAX_NUM_BATCHED_TOKENS - LONG_PREFILL_TOKEN_THRESHOLD ))/step"
- export MAX_JOBS="${MAX_JOBS:-4}"
- # --- JIT/autotune cache tiers ----------------------------------------------
- # Two tiers, because the caches have different invalidation domains: the shared
- # tier is keyed on version/arch/kv-dtype only and is the expensive one to
- # rebuild, so it must stay stable across serving-option experiments; the keyed
- # tier holds torch.compile, which vLLM hashes against the whole serving config.
- CACHE_ROOT="${CACHE_ROOT:-/root/.cache}"
- _arch="$(nvidia-smi --query-gpu=compute_cap --format=csv,noheader 2>/dev/null | head -1 | tr -d ' .')"
- _arch="${_arch:-unknown}"
- # --- KV cache dtype: auto (bf16), NOT fp8 -----------------------------------
- # fp8 here selects vLLM's `fp8_ds_mla` packed KV format, which is DeepSeek's and
- # hardcodes pe_dim == 64:
- #
- # RuntimeError: concat_and_cache_mla, cache_kernels.cu:866,
- # pe_dim must be 64 for fp8_ds_mla
- #
- # GLM-5.3-Flash is NoPE sparse MLA -- `qk_rope_head_dim: 0`, no rotary on the
- # sparse path at all -- so pe_dim is 0 and that kernel refuses. This is what the
- # vendor recipe means by "FlashInfer 0.6.17+ is required for NoPE sparse MLA":
- # the NoPE variant is a different code path, not just a newer one.
- #
- # Copying ds4-flash's `--kv-cache-dtype fp8` across is therefore wrong: DS4 is
- # DeepSeek geometry (pe_dim 64) and this model is not. It costs a full weight
- # load (~10 min) to find out, because the failure is at KV cache init.
- #
- # The default below is fp8_e4m3, and it is what every measurement on this model
- # was taken with: the Korean corruption probe comes back clean and 200k recall
- # passes on it. Only 11 of 45 layers carry a KV cache and they use
- # kv_lora_rank 512, so bf16 would also fit -- but nothing here has been measured
- # on bf16, so do not switch on the assumption that it is the safer default.
- _kv="${KV_CACHE_DTYPE:-fp8_e4m3}"
- export SHARED_TAG="${SHARED_TAG:-glm53-${_arch}-${_kv}}"
- # Resolve ONCE, above every use. Two MAX_MODEL_LEN defaults in one
- # file is the "two places to change and one silently winning" trap the DS4
- # compose file warns about -- and it was live here: the cache tag said 131072
- # while the server was told 262144.
- #
- # 524288 of the model's 1M. KV is cheap here -- only 11 of 45 layers carry one,
- # at kv_lora_rank 512 -- so the 26 GiB pin below holds 2,632,595 tokens with
- # DFlash2, 5.02 requests at full length (head boot log, 2026-09-23).
- MAX_MODEL_LEN="${MAX_MODEL_LEN:-524288}"
- _optstr="tp=${TP} mtp=${MTP} spec=${SPEC_METHOD} len=${MAX_MODEL_LEN} cg=${CUDAGRAPH_CAPTURE_SIZES}"
- _opthash=$(printf '%s' "$_optstr" | sha256sum | cut -c1-8)
- export CACHE_TAG="${CACHE_TAG:-${SHARED_TAG}-${_opthash}}"
- # FlashInfer autotune: EPHEMERAL on TP>1, and wiped on every start. A persisted
- # cache deadlocks the NEXT boot, silently and forever. On the nightly, rank 0
- # reads its cache and broadcasts it, but only rank 0 ever saves, and the fused-
- # MoE entries it saves key per rank: ranks 1-3 miss what rank 0 hits, go off to
- # profile (a CPU-group all_reduce) while rank 0 has moved on to the model's NCCL
- # all_reduce, and each waits for the other. Measured 2026-09-23 with py-spy on
- # spark-glm53:v3. The first boot always works (nobody has a cache); every boot
- # after a good one hangs with the head at 96% GPU and the workers at 0%.
- # Wiping on start, not just keeping it out of the bind mount, matters: a head
- # engine restart keeps the container's /tmp. Costs ~2 min of autotune a boot.
- # AUTOTUNE_CACHE=persist brings the old behaviour back (TP=1 is safe with it).
- if [[ "${AUTOTUNE_CACHE:-}" == "persist" || "${TP:-1}" -le 1 ]]; then
- export VLLM_FLASHINFER_AUTOTUNE_CACHE_DIR="${CACHE_ROOT}/${SHARED_TAG}/flashinfer_autotune"
- else
- export VLLM_FLASHINFER_AUTOTUNE_CACHE_DIR=/tmp/flashinfer_autotune
- rm -rf "$VLLM_FLASHINFER_AUTOTUNE_CACHE_DIR"
- fi
- echo "autotune cache: $VLLM_FLASHINFER_AUTOTUNE_CACHE_DIR"
- export VLLM_CACHE_ROOT="${CACHE_ROOT}/${CACHE_TAG}/vllm"
- mkdir -p "$VLLM_FLASHINFER_AUTOTUNE_CACHE_DIR" "$VLLM_CACHE_ROOT"
- # FlashInfer's own JIT cache is deliberately NOT redirected. FLASHINFER_CACHE_DIR
- # looks like the knob but is a module-level constant computed from
- # FLASHINFER_WORKSPACE_BASE (default $HOME), so setting it does nothing at all.
- # $HOME/.cache is the bind mount, so the default already persists.
- export TRITON_CACHE_DIR="${TRITON_CACHE_DIR:-${CACHE_ROOT}/${SHARED_TAG}/triton}"
- mkdir -p "$TRITON_CACHE_DIR"
- # TileLang JIT builds the mHC (Manifold-Constrained Hyper-Connection) kernels
- # this architecture adds, and the DSA/MTP kernels. Its default is
- # ~/.tilelang/cache -- a SIBLING of the bind-mounted ~/.cache, so left alone it
- # lands inside the container and is destroyed on every `compose down`, and every
- # boot recompiles. Same invalidation domain as FlashInfer, hence the shared tier.
- export TILELANG_CACHE_DIR="${TILELANG_CACHE_DIR:-${CACHE_ROOT}/${SHARED_TAG}/tilelang}"
- mkdir -p "$TILELANG_CACHE_DIR"
- echo "cache shared: ${CACHE_ROOT}/${SHARED_TAG} (arch=${_arch} kv=${_kv})"
- echo "cache keyed : ${CACHE_ROOT}/${CACHE_TAG} ($_optstr)"
- echo "cache tilelang: $TILELANG_CACHE_DIR"
- # --- status page ------------------------------------------------------------
- # Own port, started immediately. Loading 181 GiB of weights takes long enough
- # that "connection refused" is a poor answer to "what is it doing?", and this
- # model's metrics read zero throughout a long prefill anyway.
- export STAGE_FILE="${STAGE_FILE:-/tmp/glm53-stage}"
- : > "$STAGE_FILE"
- stage() { echo "$1" >> "$STAGE_FILE"; echo "== stage: $1 =="; }
- STATUS_PORT="${STATUS_PORT:-8082}" PORT="${API_PORT:-8002}" \
- python3 /usr/local/bin/status-server.py &
- _status_pid=$!
- stage starting
- if [[ ! -f "$MODEL/config.json" ]]; then
- echo "FATAL: no config.json under MODEL_DIR=$MODEL." >&2
- echo "Both ranks read the full checkpoint, so it must be present on THIS node." >&2
- exit 1
- fi
- if [[ -d "${MCP_LOG_DIR:-/logs}" && -w "${MCP_LOG_DIR:-/logs}" ]]; then
- # An elected box has no role yet, and its role can change between boots.
- _log="${MCP_LOG_DIR:-/logs}/vllm${ROLE:+-$ROLE}.log"
- [[ -f "$_log" ]] && mv -f "$_log" "${_log%.log}.prev.log" 2>/dev/null || true
- echo "logging to $_log"
- exec > >(tee "$_log") 2>&1
- fi
- # NEUTRALIZED BY MENTAT, kept for real-ray fallback images (the flag is
- # accepted and ignored -- mentat has no object store at all, which is the
- # actual fix). The history, because it justifies mentat's existence:
- #
- # Ray's object store defaults to ~30% of RAM. On a discrete-GPU box that is
- # harmless -- it is host memory the model never wanted. GB10 is UNIFIED memory,
- # so it comes straight out of the pool the weights and KV cache need.
- #
- # Measured 2026-08-27: the head was capped at 4 GB but the worker was not, and
- # `ray status` reported 40.15 GiB of object store cluster-wide -- roughly 36 GiB
- # of it on the worker, for a model that passes tensors over NCCL and puts
- # essentially nothing in the object store.
- RAY_OBJECT_STORE_MEMORY="${RAY_OBJECT_STORE_MEMORY:-4294967296}"
- # NEUTRALIZED BY MENTAT, kept for real-ray fallback images (mentat has no
- # memory monitor; nothing in it ever kills a worker on a heuristic).
- #
- # Ray's memory monitor kills worker processes when the NODE crosses 95% memory.
- # That heuristic assumes host RAM and GPU memory are separate pools. On GB10
- # they are the same pool, so the ~89 GiB of resident model weights count as
- # host memory and the node sits at ~95% whenever the model is simply loaded.
- #
- # Measured 2026-08-27: rank 0 was killed three times (13:41, 13:52, 14:03),
- # each time seconds after the engine came up, with the raylet logging
- # "Memory on the node was 115.68GB / 121.63GB (0.951074)"
- # "Selected to kill: vllm_Worker_..._TP0, pid=3889, actual memory used=2.07GB"
- # Ray killed the 2 GB process because the 89 GiB of weights pushed the node
- # over its threshold. Nothing was leaking and no CUDA allocation had failed.
- #
- # This looked like a GPU OOM for hours: NV_ERR_NO_MEMORY appears in dmesg from
- # vLLM's own profiling probes, which allocate until they fail by design. Those
- # are noise. The kill decision is Ray's, and it is logged only in the raylet
- # event log, not in vLLM's output.
- #
- # 0 disables the monitor. Raising the threshold instead only moves the cliff:
- # there is no honest value when the weights alone are 73% of the pool.
- export RAY_memory_monitor_refresh_ms="${RAY_MEMORY_MONITOR_REFRESH_MS:-0}"
- # --- service announcement (mentat-serve) ------------------------------------
- # The agent reads these at `ray start` and carries them in its registration.
- # mentat-serve routes by what is announced, and probes it before believing
- # it. Purely additive: an image or daemon without this support ignores them.
- # Every rank announces its management MCP (the status server runs on both
- # roles). Only the head announces the OpenAI endpoint: a worker holds a TP
- # rank and serves nothing, and a URL announced from it would route requests
- # at a port with nothing behind it. The provider names the engine behind that
- # endpoint, which two servers answering /v1/chat/completions do not reveal by
- # answering it, so it goes on the rank that announces the endpoint.
- #
- # Port form (8002/v1): the router resolves it against every address the node
- # announces, so a router on the LAN and one on the fabric both reach it. A URL
- # pins one address, reachable only from that link. Both servers bind 0.0.0.0.
- export MENTAT_MCP_API="${MENTAT_MCP_API:-${STATUS_PORT:-8082}/mcp}"
- # --- head election ------------------------------------------------------------
- # Each box starts its agent first, with no OpenAI endpoint since the role is
- # not known yet, and then elects a head from the group's live agents
- # (discover.sh). The agent stays in the background: the head announces the
- # endpoint through `python -m ray.register` below, and a worker waits on it.
- _agent_pid=""
- if [[ -n "$_elect" ]]; then
- stage joining
- env -u MENTAT_OPENAI_API -u MENTAT_MODEL_PROVIDER \
- ray start --block --address="$RAY_ADDRESS" \
- --object-store-memory="$RAY_OBJECT_STORE_MEMORY" &
- _agent_pid=$!
- elect_head
- # The status server read ROLE once, before there was one, and took the
- # head's stages. A worker restarts it so its page reads as a worker's.
- if [[ "$ROLE" == "worker" ]]; then
- kill "$_status_pid" 2>/dev/null || true
- wait "$_status_pid" 2>/dev/null || true
- STATUS_PORT="${STATUS_PORT:-8082}" PORT="${API_PORT:-8002}" \
- python3 /usr/local/bin/status-server.py &
- fi
- fi
- if [[ "$ROLE" == "worker" ]]; then
- unset MENTAT_OPENAI_API
- else
- export MENTAT_OPENAI_API="${MENTAT_OPENAI_API:-${API_PORT:-8002}/v1}"
- export MENTAT_MODEL_PROVIDER="${MENTAT_MODEL_PROVIDER:-vllm}"
- fi
- # --- fabric check: every rank meets on the head before vLLM ------------------
- # Times an NCCL all-reduce and compares versions, override files and knobs
- # across nodes (fabric-check.py); on a ring it compares only. Warnings only;
- # FABRIC_CHECK=0 skips it, and a check that hangs is stopped.
- if [[ "${FABRIC_CHECK:-1}" == "1" ]]; then
- stage fabric-check
- FABRIC_LAYOUT="$FABRIC_LAYOUT" timeout "$(( 3 * ${FABRIC_CHECK_TIMEOUT_S:-120} ))" \
- python3 /usr/local/bin/fabric-check.py || echo "fabric check: failed or timed out; continuing"
- fi
- # --- worker: join and block -------------------------------------------------
- # Under mentat, `ray start --block --address` retries forever, so the
- # head-first ordering below stopped mattering -- start either side first. The
- # stage-file dance is kept because it still reads well on the status page,
- # and because a real-ray fallback image (where `ray start --address` does NOT
- # retry) needs the head up first.
- if [[ "$ROLE" == "worker" ]]; then
- stage joining
- ( for _ in $(seq 1 120); do
- if ray status --address="$RAY_ADDRESS" >/dev/null 2>&1; then
- stage worker-ready; break
- fi
- sleep 5
- done ) &
- # An elected worker's agent is already running. Wait on it as exec would:
- # the container exits when the agent does.
- if [[ -n "$_agent_pid" ]]; then
- wait "$_agent_pid"
- exit
- fi
- exec ray start --block --address="$RAY_ADDRESS" \
- --object-store-memory="$RAY_OBJECT_STORE_MEMORY"
- fi
- # --- head -------------------------------------------------------------------
- # --address names the daemon explicitly. mentat 0.7.0 refuses a bare --head
- # while RAY_ADDRESS is set ("--head names no daemon, so this agent would
- # register with the local one while RAY_ADDRESS names ..."): every agent of a
- # group and its driver must reach one daemon, and 0.6.0 let the two disagree
- # silently. On the head RAY_ADDRESS is its own daemon, so this changes nothing
- # about where it registers -- it only stops mentat having to guess.
- #
- # An elected head's agent started before the election and announced only the
- # MCP endpoint. `python -m ray.register` adds the OpenAI endpoint under a
- # second agent id, since two registrations with one id replace each other.
- if [[ -n "$_agent_pid" ]]; then
- python3 -m ray.register --address="$RAY_ADDRESS" \
- --container="${CONTAINER_NAME:-glm53}-api" --mcp= &
- else
- ray start --head --address="$RAY_ADDRESS" --node-ip-address="$VLLM_HOST_IP" --port=6379 \
- --object-store-memory="$RAY_OBJECT_STORE_MEMORY"
- fi
- # Starting the engine without its workers reaches NCCL and dies there
- # ("invalid usage"), so this waits rather than giving up and proceeding. The
- # stage stays visible on the status page throughout. WORKER_WAIT_S bounds it;
- # 0 waits forever, which is what a switch reboot wants.
- stage waiting-workers
- _waited=0
- while :; do
- have=$(ray status 2>/dev/null | grep -oE '[0-9.]+/[0-9.]+ GPU' | cut -d/ -f2 | cut -d. -f1 || echo 0)
- [[ "${have:-0}" -ge "$TP" ]] && break
- if (( ${WORKER_WAIT_S:-0} > 0 && _waited >= ${WORKER_WAIT_S:-0} )); then
- echo "FATAL: only ${have:-0} of "$TP" GPUs after ${_waited}s" >&2
- exit 1
- fi
- (( _waited % 60 )) || echo "waiting for "$TP" GPUs, have ${have:-0} (${_waited}s)"
- sleep 5
- _waited=$(( _waited + 5 ))
- done
- # The drafter was chosen at the top (SPEC_METHOD). A missing drafter would
- # otherwise fail only after the ~10 minute target load, so check it first.
- SPEC=()
- case "$SPEC_METHOD" in
- dflash)
- SPEC_TOKENS="${SPEC_TOKENS:-7}"
- DFLASH_MODEL="${DFLASH_MODEL:-/models/glm-5.3-flash-dflash2}"
- if [[ ! -f "$DFLASH_MODEL/config.json" ]]; then
- echo "FATAL: SPEC_METHOD=dflash but no config.json under DFLASH_MODEL=$DFLASH_MODEL." >&2
- echo "Mount the DFlash2 drafter there on every node, or set SPEC_METHOD=mtp." >&2
- exit 1
- fi
- SPEC=(--speculative-config "{\"method\":\"dflash\",\"model\":\"${DFLASH_MODEL}\",\"num_speculative_tokens\":${SPEC_TOKENS}}") ;;
- mtp)
- SPEC_TOKENS="${SPEC_TOKENS:-4}"
- SPEC=(--speculative-config "{\"method\":\"mtp\",\"num_speculative_tokens\":${SPEC_TOKENS}}") ;;
- none) ;;
- *) echo "FATAL: SPEC_METHOD=$SPEC_METHOD; want mtp, dflash or none" >&2; exit 1 ;;
- esac
- # SPEC_EXTRA: more keys for the speculative config above, as one JSON object.
- # adaptive-k.yaml puts its per-batch-size draft widths, the drafter's attention
- # backend and disable_eagle_block_drop here. They used to arrive as a second
- # --speculative-config in EXTRA_ARGS, and vLLM keeps the last one, so whenever
- # that overlay was loaded SPEC_METHOD, SPEC_TOKENS and DFLASH_MODEL were
- # silently ignored (SPEC_METHOD=mtp still served DFlash2). Draft widths above
- # SPEC_TOKENS are capped to it.
- if [[ ${#SPEC[@]} -gt 0 && "${EXTRA_ARGS:-}" == *--speculative-config* ]]; then
- echo "FATAL: EXTRA_ARGS sets --speculative-config too. vLLM keeps the last one, so SPEC_METHOD," \
- "SPEC_TOKENS and DFLASH_MODEL would be ignored. Put extra keys in SPEC_EXTRA (a JSON object) instead." >&2
- exit 1
- fi
- if [[ -n "${SPEC_EXTRA:-}" ]]; then
- if [[ "$SPEC_METHOD" != dflash ]]; then
- echo "FATAL: SPEC_EXTRA is set (adaptive-k.yaml sets it, tuned for DFlash2) but SPEC_METHOD=$SPEC_METHOD." \
- "Start without experimental/compose/adaptive-k.yaml to run $SPEC_METHOD." >&2
- exit 1
- fi
- SPEC[1]=$(python3 -c '
- import json, sys
- spec, extra = json.loads(sys.argv[1]), json.loads(sys.argv[2])
- if not isinstance(extra, dict): sys.exit("SPEC_EXTRA must be a JSON object")
- fixed = sorted(set(extra) & {"method", "model", "num_speculative_tokens"})
- if fixed: sys.exit("SPEC_EXTRA must not set " + ", ".join(fixed) + ": use SPEC_METHOD, DFLASH_MODEL and SPEC_TOKENS")
- n = spec["num_speculative_tokens"]
- for row in extra.get("num_speculative_tokens_per_batch_size", []): row[2] = min(row[2], n)
- spec.update(extra)
- print(json.dumps(spec, separators=(",", ":")))' "${SPEC[1]}" "$SPEC_EXTRA") || { echo "FATAL: SPEC_EXTRA: see above ($SPEC_EXTRA)" >&2; exit 1; }
- fi
- [[ ${#SPEC[@]} -gt 0 ]] && echo "speculative: ${SPEC[*]}"
- stage loading
- if [[ "${SELF_TEST:-1}" == "1" ]]; then
- (
- if python3 /usr/local/bin/self-test.py \
- --base "http://127.0.0.1:${API_PORT:-8002}" --model "$SERVED"; then
- stage serving
- else
- stage self-test-failed
- echo "!! SELF-TEST FAILED. Serving anyway so the model can be probed;" >&2
- echo "!! the status page reports unhealthy. A wrong-but-healthy model is" >&2
- echo "!! the specific failure this gate exists to catch." >&2
- fi
- ) &
- else
- ( for _ in $(seq 1 480); do
- curl -sf -o /dev/null --max-time 4 "http://127.0.0.1:${API_PORT:-8002}/v1/models" \
- && { stage serving; break; }
- sleep 5
- done ) &
- fi
- # --- tool parser --------------------------------------------------------------
- # The stock glm47 by default. TOOL_PARSER=glm47_failclosed selects the
- # fail-closed plugin baked at /usr/local/share (loaded with
- # --tool-parser-plugin), a stopgap for tool calls corrupted upstream. It
- # refuses any call to a tool the request did not list, which stalls clients
- # that reach deferred tools through a tool-search step (Qwen Code's MCP
- # tools). The stock parser drops such a call silently: this vLLM fixes
- # validate_tool_names=True in glm47_moe_config, with no flag to turn it off.
- # Selected here, not through EXTRA_ARGS: an override restating EXTRA_ARGS once
- # dropped the plugin from production.
- TOOL_PARSER="${TOOL_PARSER:-glm47}"
- TOOL_ARGS=(--enable-auto-tool-choice --tool-call-parser "$TOOL_PARSER")
- if [[ "$TOOL_PARSER" == "glm47_failclosed" ]]; then
- TOOL_ARGS=(--tool-parser-plugin /usr/local/share/glm47_failclosed.py "${TOOL_ARGS[@]}")
- fi
- echo "tool parser: $TOOL_PARSER"
- # --- sparse indexer top-k -------------------------------------------------------
- # GB10 cannot run persistent_topk once the KV pool passes ~3.4M tokens (it would
- # oversubscribe 48 CTAs), and the FilteredTopK fallback wants 128 KB of shared
- # memory per block against the 101,376 B an SM has. per_row is the same
- # computation without the persistent-CTA scheme. This used to be a patch
- # (gb10_topk_fallback.py); on main it is a flag. "auto" tries cooperative,
- # then persistent, then per_row.
- TOPK_BACKEND="${TOPK_BACKEND:-per_row}"
- TOPK_ARGS=(--sparse-indexer-topk-backend "$TOPK_BACKEND")
- # --load-format auto, NOT sharded_state -- see the header.
- #
- # Parser names do not match the model version, which is normal here: the vendor
- # recipe for 5.3-Flash specifies glm47 and glm45.
- # --- MoE backend and CUDA graphs -------------------------------------------
- # flashinfer_cutlass, the native NVFP4 kernel, which runs W4A4 off the
- # checkpoint's per-projection input scales. Production has served on it with
- # the nvidia checkpoint since 2026-09-21; why it replaced marlin then is not
- # recorded. It decodes 109.8 / 88.8 / 52.6 tok/s (structured / code / prose,
- # v6 + DFlash2, 2026-09-23) and passes the thinking-on count-to-200 probe 8/8.
- # It takes vLLM's own CUDA graph sizes; CUDAGRAPH_CAPTURE_SIZES applies to
- # marlin only.
- #
- # marlin was the default before that. It is weight-only: it dequantises to FP16
- # and runs bf16 activations, so a checkpoint's input scales are never read. It
- # was adopted when the native kernels died on sm_121 with
- # cudaErrorNoKernelImageForDevice, a failure both MiaAI-Lab and LibertAI later
- # put down to one checkpoint's uninitialised input_scale. It is also not
- # bit-deterministic at M >> 1 (dev/TOPK-CORRUPTION.md).
- #
- # Pairing marlin with --enforce-eager is recipe convention, not a correctness
- # constraint. Marlin's workspace helper reuses storage precisely so a captured
- # graph's addresses stay valid, and the only eager gate in this vLLM is for
- # DeepseekV4. Capture measures 6 s here. CUDA_GRAPHS=0 restores eager.
- #
- # Capture sizes are in TOKENS and vLLM rounds them to multiples of (1 + draft
- # tokens). Above max_num_seqs * (1 + k) the dispatcher returns NONE and decode
- # falls back to eager with nothing in the log to say so, which is why the
- # ceiling is computed and checked rather than left to whoever edits the list.
- MOE=()
- MOE_BACKEND="${MOE_BACKEND:-flashinfer_cutlass}"
- if [[ "$MOE_BACKEND" == "marlin" ]]; then
- if [[ "${CUDA_GRAPHS:-1}" == "1" ]]; then
- _k=1
- [[ -n "$SPEC_METHOD" && "$SPEC_METHOD" != none ]] && _k=$(( SPEC_TOKENS + 1 ))
- _ceil=$(( MAX_NUM_SEQS * _k ))
- _largest=$(tr ' ' '\n' <<<"$CUDAGRAPH_CAPTURE_SIZES" | sort -n | tail -1)
- if (( _largest > _ceil )); then
- echo "WARNING: largest capture size $_largest exceeds max_num_seqs*(1+k)=$_ceil;" >&2
- echo "decode above $_ceil tokens will run eager and log nothing." >&2
- fi
- MOE=(--moe-backend marlin
- --cudagraph-capture-sizes ${CUDAGRAPH_CAPTURE_SIZES}
- --compilation-config "{\"cudagraph_mode\":\"${CUDAGRAPH_MODE:-FULL_AND_PIECEWISE}\"}")
- echo "MoE backend: marlin, cudagraphs ${CUDAGRAPH_MODE:-FULL_AND_PIECEWISE}" \
- "(sizes: $CUDAGRAPH_CAPTURE_SIZES, ceiling ${_ceil})"
- else
- MOE=(--moe-backend marlin --enforce-eager)
- echo "MoE backend: marlin (enforce-eager, CUDA_GRAPHS=0)"
- fi
- else
- MOE=(--moe-backend "${MOE_BACKEND}")
- # vLLM captures graphs up to max_num_seqs x (1 + drafts) x 2, capped at
- # 512 tokens. The top half only serves steps that mix decodes with a short
- # new prompt, so stop at the largest decode step: 32 tokens at TP=2 instead
- # of 64, for example. Those mixed steps run without a graph.
- # A draft width that varies with batch size (adaptive-k.yaml's
- # num_speculative_tokens_per_batch_size, through SPEC_EXTRA) decodes 1 + k tokens per request
- # for each k in the schedule: 3, 5, 6, 10, 12, 15. vLLM adds those sizes to
- # its capture list only when it picks the ceiling itself, so an explicit cap
- # leaves them to run without a full graph. Leave the ceiling to vLLM there,
- # unless CUDAGRAPH_MAX sets one.
- if [[ -z "${CUDAGRAPH_MAX:-}" && "${SPEC[*]:-}" == *num_speculative_tokens_per_batch_size* ]]; then
- echo "MoE backend: ${MOE_BACKEND}, vLLM's own CUDA graph sizes (draft width varies with batch size)"
- else
- _q=1; [[ -n "$SPEC_METHOD" && "$SPEC_METHOD" != none ]] && _q=$(( SPEC_TOKENS + 1 ))
- _cgmax=$(( MAX_NUM_SEQS * _q < 512 ? MAX_NUM_SEQS * _q : 512 ))
- CUDAGRAPH_MAX="${CUDAGRAPH_MAX:-$_cgmax}"
- MOE+=(--max-cudagraph-capture-size "$CUDAGRAPH_MAX")
- echo "MoE backend: ${MOE_BACKEND}, CUDA graphs up to ${CUDAGRAPH_MAX} tokens"
- fi
- fi
- # Multimodal: up to 16 images a prompt, no video. Exceeding a cap is a clean
- # 400 with an OpenAI-shaped error body, not an engine fault, but a client that
- # treats any 400 as fatal dies on it.
- # The closing brace is escaped: bash ends a :- default at the first unescaped
- # one, so it would cut the default short and append the tail as literal text.
- LIMIT_MM="${LIMIT_MM:-{\"image\":16,\"video\":0\}}"
- python3 -c 'import json, sys; json.loads(sys.argv[1])' "$LIMIT_MM" 2>/dev/null || {
- echo "FATAL: LIMIT_MM is not valid JSON: $LIMIT_MM" >&2
- echo " Write the whole object, closing brace included." >&2
- exit 1; }
- MM=(--limit-mm-per-prompt "$LIMIT_MM")
- # 0: run the max-size dummy forward at init, so the vision encoder's peak is
- # budgeted at startup instead of coming out of the headroom a long prefill
- # needs at run time. 1 skips it, which is faster to boot and was the default
- # while images were capped at 4.
- [[ "${SKIP_MM_PROFILING:-0}" == "1" ]] && MM+=(--skip-mm-profiling)
- # The image's template is nvidia's, with one change: thinking off asks for low
- # reasoning effort. GLM-5.3-Flash has no non-thinking mode, and the empty
- # <think></think> that thinking off otherwise produces makes long output repeat
- # and skip (README, Troubleshooting). The checkpoint's own template is ignored,
- # so a fresh download cannot bring the problem back. CHAT_TEMPLATE overrides.
- TMPL=()
- : "${CHAT_TEMPLATE:=/usr/local/share/glm53-chat-template.jinja}"
- if [[ -n "$CHAT_TEMPLATE" ]]; then
- TMPL=(--chat-template "$CHAT_TEMPLATE")
- echo "chat template: $CHAT_TEMPLATE"
- fi
- # A 320B MoE takes far longer to init than the default allows.
- export VLLM_ENGINE_READY_TIMEOUT_S="${VLLM_ENGINE_READY_TIMEOUT_S:-3600}"
- # --- KV cache pin ----------------------------------------------------------
- # Left unset, vLLM PROFILES the KV cache and takes everything available -- here
- # that was 13.59 GiB, giving 796,361 tokens. FlashInfer's autotune then runs
- # during warmup, asks the driver for more, and there is none:
- #
- # NVRM: Check failed: Out of memory [NV_ERR_NO_MEMORY] ... _memdescAllocInternal
- # RayWorkerProc rank=[0] died unexpectedly
- #
- # On a discrete GPU the profiler's headroom assumptions usually survive this.
- # On GB10's unified memory they do not, which is what the upstream recipe means
- # by "--kv-cache-memory pin (UMA OOM only)". Pin it below the profiled figure so
- # warmup has room.
- #
- # Measured 2026-08-27: a 10 GiB pin made things WORSE, because skipping the
- # profile also discards the gpu_memory_utilization margin autotune relies on.
- # That was before TORCH_MEM_FRACTION and the ephemeral autotune cache.
- #
- # 26 GiB is the pin that serves under DFlash2. At 28 the head sat near 1 GiB
- # free and eight concurrent long-context requests had a worker OOM-killed. A
- # pin also stops the pool moving by ~0.9 GiB between identical boots with
- # page-cache timing at profiling. DFlash2 costs 41% of the pool: 3,437,736
- # tokens with speculation off against 2,024,644 with it (pre-nightly image,
- # 2026-09-06).
- KV_CACHE_MEMORY="${KV_CACHE_MEMORY:-27917287424}"
- KV_ARGS=(--kv-cache-memory "${KV_CACHE_MEMORY}")
- echo "kv-cache-memory pinned to ${KV_CACHE_MEMORY} bytes ($(( KV_CACHE_MEMORY / 1073741824 )) GiB)"
- # eager stages each shard through anonymous memory and loads faster -- 511 s
- # against 690 s for lazy. Unpinned, its buffers were still resident when vLLM
- # profiled for KV and cost 38% of the cache: 810,576 tokens against 1,296,922.
- # With the KV pin above the pool is not profiled, and production runs eager.
- # Go back to lazy if the pin is ever dropped.
- exec vllm serve "$MODEL" \
- --served-model-name "$SERVED" \
- --tensor-parallel-size "$TP" \
- --distributed-executor-backend ray \
- --load-format auto \
- --safetensors-load-strategy "${SAFETENSORS_LOAD_STRATEGY:-eager}" \
- --gpu-memory-utilization "${GPU_MEM_UTIL:-0.88}" \
- --max-model-len "${MAX_MODEL_LEN}" \
- --kv-cache-dtype "${KV_CACHE_DTYPE:-fp8_e4m3}" \
- --block-size "${BLOCK_SIZE:-2304}" \
- "${KV_ARGS[@]}" \
- --trust-remote-code \
- --enable-prefix-caching \
- --max-num-batched-tokens "${MAX_NUM_BATCHED_TOKENS}" \
- --max-num-seqs "${MAX_NUM_SEQS}" \
- --long-prefill-token-threshold "${LONG_PREFILL_TOKEN_THRESHOLD}" \
- "${MOE[@]}" \
- "${MM[@]}" \
- "${TMPL[@]}" \
- ${ITERATION_DETAILS:+--enable-logging-iteration-details} \
- "${TOOL_ARGS[@]}" \
- "${TOPK_ARGS[@]}" \
- --reasoning-parser glm45 \
- "${SPEC[@]}" \
- --host 0.0.0.0 --port "${API_PORT:-8002}" \
- ${EXTRA_ARGS:-}
|