entrypoint.sh 54 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001
  1. #!/bin/bash
  2. set -euo pipefail
  3. # ---------------------------------------------------------------------------
  4. # Serve GLM-5.3-Flash (NVFP4) across the ConnectX-linked GX10 boxes. TP and PP
  5. # come from the environment, so the same image serves the TP=2/PP=2 pair-of-
  6. # pairs and a flat TP=4 across one fabric.
  7. #
  8. # Deliberately simpler than ds4-flash/entrypoint.sh in one respect: it loads
  9. # PLAIN SAFETENSORS and builds no sharded-state cache. That costs boot time and
  10. # buys correctness margin -- the sharded-state path needs the modelopt
  11. # IDEMPOTENT-GUARD patch, because a cache is dumped AFTER the NVFP4
  12. # kernel-format transform and re-running that transform on load permutes the
  13. # fused gate/up halves and serves fluent nonsense with no error. This image is
  14. # built on upstream's per-model base and carries none of our patches, so the
  15. # safe load is the one that runs the transform exactly once.
  16. #
  17. # Adopt the sharded-state cache only together with that patch. See
  18. # ds4-flash/vllm-spark.patch.
  19. # ---------------------------------------------------------------------------
  20. TP="${TP:-4}"
  21. MTP="${MTP:-1}"
  22. # TP=RING4 is TP=4 on four boxes cabled in a loop with no switch. mentat
  23. # places the ranks in cable order, and fabric_ring.py sets each rank's NCCL
  24. # and arx devices from the neighbours mentat gives it.
  25. FABRIC_LAYOUT=mesh
  26. if [[ "$TP" == RING4 ]]; then
  27. TP=4; FABRIC_LAYOUT=ring
  28. export MENTAT_CLAIM_LAYOUT=ring
  29. fi
  30. # --- per-TP defaults ---------------------------------------------------------
  31. # Each rank holds 1/TP of the weights and of every request's KDA state, so
  32. # the memory left for KV, and how many requests fit in it, depend on TP. These
  33. # fill in whatever .env leaves out. TP=4 is the measured four-box setup. TP=2
  34. # is two boxes with 89.6 GiB of weights per rank. Halving the batched-token
  35. # budget to 8192 shrinks the activation peak by ~2.8 GiB on the head, at
  36. # ~9% of prefill speed, and an 8 GiB KV pin then leaves the head 1.4 GiB free
  37. # at its lowest. At 16384 an 8 GiB pin got a worker OOM-killed mid-prefill.
  38. # Each rank carries half the KDA heads, so a request's states are twice as big.
  39. # RecoverSSM (recoverssm.yaml) keeps one state per request instead of 1 + k:
  40. # 64 run at TP=4, the most whose k=7 steps fit the largest captured graph,
  41. # and 16 at TP=2. It also removes the state
  42. # writes adaptive-k's step cost used to include, so it gets its own prior,
  43. # fitted on code and prose at two concurrencies with k forced to 2 and 7.
  44. _rs=0; [[ "${VLLM_GLM5NEXT_RECOVERSSM:-0}" == 1 ]] && _rs=1
  45. case "$TP" in
  46. 4) : "${KV_CACHE_MEMORY:=27917287424}" "${MAX_MODEL_LEN:=524288}"
  47. (( _rs )) && : "${MAX_NUM_SEQS:=64}" "${VLLM_ADAPTIVE_K_MODEL:=26.4,0.660,0.484}"
  48. # With the drafter's KV in its own pool (fixes.yaml), ~50 requests' KDA
  49. # states fit in the pool; without it, 32.
  50. [[ "${VLLM_GLM5NEXT_DRAFT_POOL:-0}" == 1 ]] && : "${MAX_NUM_SEQS:=50}"
  51. : "${VLLM_ADAPTIVE_K_MODEL:=27.0,0.635,0.542}" ;;
  52. 3) # Three boxes on the zero-padded checkpoint (experimental/tp3). 26 GiB of
  53. # KV had the host OOM-killer take rank 0 during graph capture; 12 GiB
  54. # holds 1.4M tokens (1.7M with RecoverSSM). Block 3456 becomes 3584 in
  55. # vLLM: 14% mamba page padding, where the 4608 it picks from 2304 pads 46%
  56. # and coarsens the prefix cache.
  57. : "${KV_CACHE_MEMORY:=12884901888}" "${MAX_MODEL_LEN:=524288}" "${BLOCK_SIZE:=3456}"
  58. export BLOCK_SIZE
  59. # With RecoverSSM, 64 requests at k=7 fill the 512-token graph cap as at
  60. # TP=4: +24% aggregate at 48 streams over 32, single-stream unchanged.
  61. (( _rs )) && : "${MAX_NUM_SEQS:=64}"
  62. # What the online fit converged to at TP=3 with RecoverSSM on (3% mean
  63. # error; the two three-box sets agreed within 2%).
  64. : "${VLLM_ADAPTIVE_K_MODEL:=27.5,0.795,0.50}" ;;
  65. 6) # Six boxes on the same zero-padded checkpoint as TP=3 (66 / 66 / 2304
  66. # split as 11 heads and 384 MoE columns per rank). Weights are ~1/6 per
  67. # rank, so the TP=4 KV pin fits. With 11 KDA heads a rank's mamba page is
  68. # ~1573 tokens of attention page: block 2304 pads it 46%, 1792 (the
  69. # smallest multiple of 256 above it) 14%, for a 1.7% bigger pool and
  70. # 17% faster cached TTFT.
  71. : "${KV_CACHE_MEMORY:=27917287424}" "${MAX_MODEL_LEN:=524288}" "${BLOCK_SIZE:=1792}"
  72. export BLOCK_SIZE
  73. (( _rs )) && : "${MAX_NUM_SEQS:=64}"
  74. # What the online fit converged to at TP=6 with RecoverSSM on (1% error).
  75. : "${VLLM_ADAPTIVE_K_MODEL:=19.3,0.50,0.40}" ;;
  76. 2) : "${KV_CACHE_MEMORY:=8589934592}" "${MAX_MODEL_LEN:=163840}" "${MAX_NUM_BATCHED_TOKENS:=8192}"
  77. # A rank reads twice the expert weights it does at TP=4, so the
  78. # per-expert cost is held at twice TP=4's and the rest fitted.
  79. (( _rs )) && : "${MAX_NUM_SEQS:=16}" "${VLLM_ADAPTIVE_K_MODEL:=32.1,1.32,0.427}"
  80. : "${MAX_NUM_SEQS:=4}"
  81. : "${VLLM_ADAPTIVE_K_MODEL:=35.0,1.27,0.8}" ;;
  82. *) 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 ;;
  83. esac
  84. export VLLM_ADAPTIVE_K_MODEL
  85. # SPEC_METHOD picks the drafter: dflash (the separate DFlash2 draft model at
  86. # DFLASH_MODEL, which must be mounted on every node; k must be 7, its block
  87. # size minus one, and the model refuses anything else), mtp (the checkpoint's
  88. # own head, num_nextn_predict_layers: 1), or none. MTP=0 is the older off
  89. # switch and still turns speculation off whichever method is named. Resolved
  90. # here, above the cache tag, because the tag hashes it.
  91. #
  92. # dflash is what serves: 109.8 / 88.8 / 52.6 tok/s on structured / code / prose
  93. # with v6, where MTP gave 57.2 / 54.4 / 45.6 with v4 (both 2026-09-23). Judge a drafter
  94. # on acceptance LENGTH, not acceptance rate. Set here rather than through
  95. # EXTRA_ARGS, which is one string that a compose override restating it
  96. # silently replaces.
  97. SPEC_METHOD="${SPEC_METHOD:-dflash}"
  98. [[ "$MTP" == "0" ]] && SPEC_METHOD=none
  99. # With neither ROLE nor HEAD_HOST set, the boxes elect a head once all TP
  100. # agents have registered (discover.sh). HEAD_HOST alone fixes the head, and
  101. # each box's role follows from whether it holds that address.
  102. . "$(dirname "$0")/discover.sh"
  103. _elect=""
  104. [[ -z "${ROLE:-}" && -z "${HEAD_HOST:-}" ]] && _elect=1
  105. MODEL="${MODEL_DIR:-/models/glm-5.3-flash-nvfp4}"
  106. SERVED="${SERVED_NAME:-glm53}"
  107. # --- cluster networking: everything rides the ConnectX link -----------------
  108. # No literal address appears here or in the compose file. The boxes are NOT
  109. # symmetric (one carries its link on enp1s0f1np1, the rest on enp1s0f0np0), so
  110. # any hardcoded interface name is wrong somewhere whichever you pick.
  111. #
  112. # VLLM_HOST_IP is this node's LAN address: mentat identifies a node by it, and
  113. # the agent and daemon must agree on one string. Address prefixes
  114. # (CLUSTER_SUBNET, FABRIC_SUBNETS) name the fabric. Whatever .env leaves out
  115. # comes from the local mentatd: its lan-tagged address, and one prefix per
  116. # rdma-tagged address.
  117. #
  118. # An uncabled port powers down completely -- no PCI device, no
  119. # /sys/class/infiniband entry. That is not a missing driver and no amount of
  120. # modprobe fixes it, so a node reports only the ports it actually has.
  121. discover_network
  122. CLUSTER_SUBNET="${CLUSTER_SUBNET:-${FABRIC_SUBNETS%% *}}"
  123. export VLLM_HOST_IP
  124. # Gloo needs an EXACT interface name -- NCCL_SOCKET_IFNAME takes a prefix, this
  125. # does not. Left unset, Gloo binds 127.0.0.1 and the cluster fails silently at
  126. # rendezvous, so derive it from whichever interface actually owns VLLM_HOST_IP.
  127. # Bounded, unlike the fabric wait above: the address is already up, so the
  128. # interface and its RDMA device are local state that either settles in seconds
  129. # or is broken. Waiting past that hides the fault instead of reporting it.
  130. if [[ -z "${GLOO_SOCKET_IFNAME:-}" ]]; then
  131. _waited=0
  132. while :; do
  133. GLOO_SOCKET_IFNAME=$(ip -o -4 addr show 2>/dev/null \
  134. | awk -v ip="$VLLM_HOST_IP" '$4 ~ "^"ip"/" {print $2; exit}')
  135. [[ -n "$GLOO_SOCKET_IFNAME" ]] && break
  136. if (( _waited >= ${ROCE_SETTLE_S:-60} )); then
  137. echo "FATAL: no interface holds VLLM_HOST_IP=$VLLM_HOST_IP after ${_waited}s" >&2
  138. ip -br addr show >&2; exit 1
  139. fi
  140. sleep 5; _waited=$(( _waited + 5 ))
  141. done
  142. fi
  143. export GLOO_SOCKET_IFNAME
  144. # --- fabric ports: which RoCE devices carry the cluster, at which GID -------
  145. # FABRIC_SUBNETS names one address prefix per cabled port. A second entry puts
  146. # NCCL on both PCIe roots of the ConnectX-7, which ib_write_bw measured at 196
  147. # Gb/s against 112 for one root alone. It is opt-in because the second root's
  148. # registrations are GPU-resident and land AFTER vLLM profiles, so they eat the
  149. # headroom a long prefill needs rather than the KV cache: allocations sat at
  150. # 111.41 GiB against the 104.6 GiB GPU_MEM_UTIL=0.86 budgets. At TP=2 that
  151. # bought nothing -- the per-token allreduce is ~720 KB, a fraction of a
  152. # millisecond against a 45 ms token -- so weigh it only at TP>2.
  153. #
  154. # Keyed on the subnet rather than on VLLM_HOST_IP because the two are no longer
  155. # the same address. mentat identifies a node by its LAN address and the agent
  156. # and daemon must agree on one string, so VLLM_HOST_IP is the LAN one, and no
  157. # RoCE GID will ever match it.
  158. FABRIC_SUBNETS="${FABRIC_SUBNETS:-$CLUSTER_SUBNET}"
  159. # Echoes "<rdma-device> <gid-index>" for the port holding an address in $1.
  160. # Returns 1 when this node has not cabled that port, or its GID has yet to
  161. # appear.
  162. fabric_port() {
  163. local prefix="$1" found addr ifname dev="" hex i t g d n
  164. found=$(ip -o -4 addr show 2>/dev/null \
  165. | awk -v p="$prefix" '$4 ~ "^"p {split($4,a,"/"); print $2, a[1]; exit}')
  166. [[ -n "$found" ]] || return 1
  167. ifname="${found%% *}"; addr="${found##* }"
  168. for d in /sys/class/infiniband/*; do
  169. for n in "$d"/ports/1/gid_attrs/ndevs/*; do
  170. [[ -f "$n" ]] || continue
  171. [[ "$(cat "$n" 2>/dev/null)" == "$ifname" ]] || continue
  172. dev="$(basename "$d")"; break 2
  173. done
  174. done
  175. [[ -n "$dev" ]] || return 1
  176. # Slots 0/1 hold the driver's MAC-derived GIDs whatever the IP configuration,
  177. # so match this port's own static address in its IPv4-mapped form, v2 only.
  178. hex=$(printf '%s' "$addr" | awk -F. '{printf "%02x%02x:%02x%02x", $1,$2,$3,$4}')
  179. for i in $(seq 0 15); do
  180. t=$(cat "/sys/class/infiniband/$dev/ports/1/gid_attrs/types/$i" 2>/dev/null) || continue
  181. g=$(cat "/sys/class/infiniband/$dev/ports/1/gids/$i" 2>/dev/null) || continue
  182. [[ "$t" == "RoCE v2" && "$g" == *"ffff:$hex" ]] || continue
  183. printf '%s %s\n' "$dev" "$i"; return 0
  184. done
  185. return 1
  186. }
  187. # There is no correct constant for the GID index. The table is keyed by
  188. # (address, RoCE version), slots are allocated first-free and freed in place, so
  189. # the index for one address differs per node AND per boot. Observed, not
  190. # theorised: on 2026-08-26 one node held the right entry at 6 and its peer at
  191. # 5, because a `nmcli con delete` / `add` had left the first a hole at slot 3. Reboot
  192. # it with the static profile already in place and the table comes up dense,
  193. # moving that 6 to 5, at which point a pinned 6 names an empty slot and NCCL
  194. # fails every TP init with "unhandled system error".
  195. if [[ -z "${NCCL_IB_HCA:-}" || -z "${NCCL_IB_GID_INDEX:-}" ]]; then
  196. _waited=0
  197. while :; do
  198. _hcas=""; _gid=""; _mismatch=""
  199. for _p in $FABRIC_SUBNETS; do
  200. _r=$(fabric_port "$_p") || continue
  201. _d="${_r%% *}"; _i="${_r##* }"
  202. [[ -n "$_gid" && "$_i" != "$_gid" ]] && _mismatch="$_d at $_i, expected $_gid"
  203. _gid="${_gid:-$_i}"
  204. _hcas="${_hcas:+$_hcas,}$_d"
  205. done
  206. [[ -n "$_hcas" ]] && break
  207. if (( _waited >= ${ROCE_SETTLE_S:-60} )); then
  208. echo "FATAL: no RoCE v2 GID for any of: $FABRIC_SUBNETS" >&2
  209. ip -br addr show >&2
  210. ls /sys/class/infiniband/ >&2 || echo "(no /sys/class/infiniband at all)" >&2
  211. exit 1
  212. fi
  213. sleep 5; _waited=$(( _waited + 5 ))
  214. done
  215. # NCCL_IB_GID_INDEX applies to every device in NCCL_IB_HCA, so a device whose
  216. # index differs gets asked for a GID it does not have and QP setup dies with
  217. # "local GID ::". One IPv4 per fabric interface keeps them aligned; refuse the
  218. # list rather than hand NCCL one that cannot work.
  219. # A ring gives each cable its own subnet, and fabric_ring.py picks each
  220. # device's GID by address instead.
  221. if [[ -n "$_mismatch" && "$FABRIC_LAYOUT" == mesh ]]; then
  222. echo "FATAL: fabric ports disagree on GID index ($_mismatch)." >&2
  223. echo "Each fabric interface must carry exactly one IPv4 address." >&2
  224. exit 1
  225. fi
  226. NCCL_IB_HCA="${NCCL_IB_HCA:-$_hcas}"
  227. NCCL_IB_GID_INDEX="${NCCL_IB_GID_INDEX:-$_gid}"
  228. echo "fabric: NCCL_IB_HCA=$NCCL_IB_HCA gid=$NCCL_IB_GID_INDEX (subnets: $FABRIC_SUBNETS)"
  229. else
  230. echo "fabric: NCCL_IB_HCA=$NCCL_IB_HCA gid=$NCCL_IB_GID_INDEX (both pinned; derivation skipped)"
  231. fi
  232. export NCCL_IB_HCA NCCL_IB_GID_INDEX
  233. # The EXACT interface holding VLLM_HOST_IP, not a prefix. This is only the
  234. # out-of-band bootstrap path -- the IB devices above carry the data -- and a
  235. # prefix matches every interface that happens to share it. Once the fabric
  236. # grew a second cable, "enp1s0f" matched both the head link and an unrelated
  237. # one, and NCCL bootstrapped toward the wrong wire: "Connection closed by
  238. # remote peer spark-head".
  239. export NCCL_SOCKET_IFNAME="${NCCL_SOCKET_IFNAME:-$GLOO_SOCKET_IFNAME}"
  240. # Channel count. NCCL picks 64 here, while every published GB10 recipe pins
  241. # 4-12. 8 was chosen on 2026-09-06 while the fabric was stuck at 12 Gb/s, before
  242. # a power drain fixed it; NCCL's own choice has not been re-tested since.
  243. export NCCL_MAX_NCHANNELS="${NCCL_MAX_NCHANNELS:-8}"
  244. # INFO prints the devices NCCL actually selected, which is the only way to
  245. # confirm both roots are in use.
  246. export NCCL_DEBUG="${NCCL_DEBUG:-INFO}"
  247. # --- preflight: things that make the stack slow or fragile without failing --
  248. # One table, WARN rows first to read, never fatal. PREFLIGHT=0 skips it.
  249. preflight() {
  250. local rows=() warns=0 dev nd mtu rate state phys cur max tp kv need avail swap fs free apps
  251. # Fields are split on the unit separator: fix text can hold "|" (echo 3 | sudo tee).
  252. local sep=$'\x1f'
  253. row() { rows+=("$1$sep$2$sep$3"); [[ "$3" == WARN ]] && warns=$(( warns + 1 )); return 0; }
  254. local -a devs; IFS=, read -r -a devs <<< "$(sed -E 's/^[=^]+//; s/:[0-9]+//g' <<< "$NCCL_IB_HCA")"
  255. (( ${#devs[@]} >= 2 )) && row "fabric devices" "${NCCL_IB_HCA}" ok \
  256. || row "fabric devices" "${NCCL_IB_HCA} (one PCIe root tops out near 110 Gb/s; set FABRIC_SUBNETS to both)" WARN
  257. 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)
  258. (( ${#devs[@]} < 2 || roots >= 2 )) || row "PCIe roots" "all fabric devices share one root" WARN
  259. for dev in "${devs[@]}"; do
  260. local p=/sys/class/infiniband/$dev
  261. [[ -d $p ]] || { row "$dev" "no such device" WARN; continue; }
  262. nd=$(ls "$p/device/net" 2>/dev/null | head -1)
  263. state=$(cut -d' ' -f2 "$p/ports/1/state"); phys=$(cut -d' ' -f2- "$p/ports/1/phys_state")
  264. [[ $state == ACTIVE ]] && row "$dev link" "$state, $phys" ok || row "$dev link" "$state, $phys" WARN
  265. rate=$(cut -d' ' -f1 "$p/ports/1/rate")
  266. (( rate >= 200 )) && row "$dev rate" "$(cat "$p/ports/1/rate")" ok || row "$dev rate" "$(cat "$p/ports/1/rate") (expected 200 Gb/sec)" WARN
  267. # RoCE's path MTU is the largest IB MTU that fits the netdev's: 9000 gives
  268. # 4096, 1500 gives 1024. arx and arxbig follow it, and smaller packets cost
  269. # bandwidth everywhere.
  270. mtu=$(cat "/sys/class/net/$nd/mtu" 2>/dev/null || echo 0)
  271. (( mtu >= 4200 )) && row "$nd MTU" "$mtu" ok || row "$nd MTU" "$mtu (RoCE path MTU below 4096; set 9000 end to end)" WARN
  272. cur="$(cut -d' ' -f1-2 "$p/device/current_link_speed") x$(cat "$p/device/current_link_width")"
  273. max="$(cut -d' ' -f1-2 "$p/device/max_link_speed") x$(cat "$p/device/max_link_width")"
  274. [[ $cur == "$max" ]] && row "$dev PCIe" "$cur" ok \
  275. || row "$dev PCIe" "$cur of $max (degraded link; a full power drain has fixed this before)" WARN
  276. # Each up/down is two changes; more than a few since boot means a flapping link.
  277. local flaps; flaps=$(cat "/sys/class/net/$nd/carrier_changes" 2>/dev/null || echo 0)
  278. (( flaps <= 4 )) || row "$nd flaps" "$flaps carrier changes since boot (check the cable or transceiver)" WARN
  279. local c v bad=""
  280. for c in local_ack_timeout_err packet_seq_err out_of_sequence; do
  281. v=$(cat "$p/ports/1/hw_counters/$c" 2>/dev/null || echo 0)
  282. (( v > 0 )) && bad+="$c=$v "
  283. done
  284. [[ -z $bad ]] || row "$dev retransmits" "${bad% } (since driver load; drops, likely no PFC)" info
  285. done
  286. # RDMA pins its buffers; a memlock limit fails registration or NCCL's setup.
  287. [[ "$(ulimit -l)" == unlimited ]] && row "memlock limit" "unlimited" ok \
  288. || row "memlock limit" "$(ulimit -l) KiB (RDMA registration needs unlimited: ulimits memlock -1)" WARN
  289. # GPU: a power-delivery or thermal event caps clocks for the whole boot.
  290. 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)
  291. if [[ -z $ev ]]; then row "GPU" "nvidia-smi unavailable" WARN
  292. elif awk -F', ' '{for (i = 1; i < NF; i++) if ($i == "Active") f = 1} END {exit !f}' <<< "$ev"; then
  293. row "GPU slowdown" "$ev (hw, power brake, hw thermal, sw thermal, C)" WARN
  294. else row "GPU slowdown" "none, ${ev##*, } C" ok; fi
  295. apps=$(nvidia-smi --query-compute-apps=pid,process_name,used_memory --format=csv,noheader 2>/dev/null | tr '\n' ';')
  296. [[ -z $apps ]] && row "other GPU processes" "none" ok || row "other GPU processes" "${apps%;}" WARN
  297. # Host memory: ~182 GiB of weights split TP ways, the pinned KV, and ~30 GiB
  298. # more (CUDA graphs, the activation reserve, NCCL and RDMA buffers, the
  299. # processes themselves). TP=2 with 8 GiB of KV came to ~123 GiB and the OOM
  300. # killer took a worker mid-prefill.
  301. tp=${TP:-4}; kv=$(( ${KV_CACHE_MEMORY:-27917287424} >> 30 ))
  302. need=$(( 182 / tp + kv + 30 ))
  303. avail=$(( $(awk '/MemAvailable/ {print $2}' /proc/meminfo) >> 20 ))
  304. (( avail >= need )) && row "host memory" "${avail} GiB free, ~${need} GiB needed" ok \
  305. || row "host memory" "${avail} GiB free, ~${need} GiB needed (stop other models; lower KV_CACHE_MEMORY)" WARN
  306. # MemAvailable counts the page cache as free, but on GB10 CUDA allocations
  307. # can fail before the kernel reclaims it: NCCL's init died "out of memory"
  308. # with ~113 GB cached after a weight copy. Drop the model files' own cached
  309. # pages (no privilege needed), then say how to drop the rest.
  310. unfree() { echo $(( $(awk '/MemFree/ {print $2}' /proc/meminfo) >> 20 )); }
  311. if (( $(unfree) < need )); then
  312. python3 - "$MODEL" "${DFLASH_MODEL:-/models/glm-5.3-flash-dflash2}" "${VLLM_WEIGHT_SNAPSHOT_DIR:-}" <<'PY' 2>/dev/null || true
  313. import os, sys
  314. for root in filter(None, sys.argv[1:]):
  315. for dirpath, _, files in os.walk(root):
  316. for name in files:
  317. try:
  318. fd = os.open(os.path.join(dirpath, name), os.O_RDONLY)
  319. os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED)
  320. os.close(fd)
  321. except OSError:
  322. pass
  323. PY
  324. fi
  325. local memfree; memfree=$(unfree)
  326. local cached; cached=$(( $(awk '/^Cached:/ {print $2}' /proc/meminfo) >> 20 ))
  327. (( memfree >= need )) && row "page cache" "${cached} GiB cached, ${memfree} GiB free" ok \
  328. || 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
  329. swap=$(( ( $(awk '/SwapTotal/ {print $2}' /proc/meminfo) - $(awk '/SwapFree/ {print $2}' /proc/meminfo) ) >> 20 ))
  330. (( swap < 1 )) || row "swap in use" "${swap} GiB (the GPU shares this memory; paging stalls it)" WARN
  331. fs=$(findmnt -n -o FSTYPE -T "${MODEL_DIR:-/models/glm-5.3-flash-nvfp4}" 2>/dev/null)
  332. [[ $fs == nfs* || $fs == cifs || $fs == fuse* ]] && row "model filesystem" "$fs (every rank reads it all; use local disk)" WARN \
  333. || row "model filesystem" "${fs:-unknown}" ok
  334. # The first boot at a TP size writes a weight snapshot (snapshot.yaml).
  335. if [[ -n "${VLLM_WEIGHT_SNAPSHOT_DIR:-}" ]]; then
  336. free=$(df -BG --output=avail "${CACHE_ROOT:-/root/.cache}" 2>/dev/null | tail -1 | tr -dc 0-9)
  337. ver=$(sed -n 's/^SNAPSHOT_VERSION = \([0-9]*\).*/\1/p' \
  338. /usr/local/lib/python3.12/dist-packages/vllm/model_executor/model_loader/weight_snapshot.py 2>/dev/null)
  339. if compgen -G "$VLLM_WEIGHT_SNAPSHOT_DIR/v${ver}-target-tp*of${tp}-*" >/dev/null; then row "weight snapshot" "present for TP=$tp" ok
  340. elif (( ${free:-0} >= 182 / tp + 5 )); then row "weight snapshot" "none yet for TP=$tp, ${free} GiB free" ok
  341. else row "weight snapshot" "none yet for TP=$tp, ${free:-?} GiB free, needs ~$(( 182 / tp )) GiB" WARN; fi
  342. fi
  343. # A desktop session holds GPU memory and CPU on the shared memory, and these
  344. # boxes ship with GDM enabled. systemd lists running units in /run/systemd/units.
  345. if [[ -d /host/systemd-units ]]; then
  346. local dm; dm=$(ls /host/systemd-units 2>/dev/null \
  347. | sed -n 's/^invocation:\(gdm3\?\|lightdm\|sddm\|display-manager\)\.service$/\1/p' | head -1)
  348. [[ -z $dm ]] && row "desktop session" "none" ok \
  349. || row "desktop session" "$dm is running (sudo systemctl set-default multi-user.target && sudo systemctl isolate multi-user.target)" WARN
  350. fi
  351. grep -q '^search \.$' /etc/resolv.conf 2>/dev/null && row "resolv.conf" "'search .' frozen in; bare hostnames fail (restart the container)" WARN
  352. local on; on=$(overlays | tr '\n' ' ')
  353. [[ -n $on ]] && row "overlays" "$on" ok || row "overlays" "none: stock kernels" WARN
  354. echo "preflight ($warns warning$([[ $warns == 1 ]] || echo s)):"
  355. printf '%s\n' "${rows[@]}" | awk -F"$sep" '$3 == "WARN" {printf " %-5s %-26s %s\n", $3, $1, $2}'
  356. printf '%s\n' "${rows[@]}" | awk -F"$sep" '$3 != "WARN" {printf " %-5s %-26s %s\n", $3, $1, $2}'
  357. }
  358. # The experimental/ overlays this container has mounted, one name per line.
  359. # Each one mounts a file the image does not ship, except sp, which only sets
  360. # its environment.
  361. overlays() {
  362. local v=/usr/local/lib/python3.12/dist-packages/vllm
  363. [[ -e $v/distributed/device_communicators/arx.py ]] && echo arx
  364. [[ -e $v/model_executor/model_loader/weight_snapshot.py ]] && echo snapshot
  365. [[ -e $v/v1/core/sched/adaptive_k.py ]] && echo adaptive-k
  366. [[ -e $v/model_executor/model_loader/dense_fp8.py ]] && echo fp8
  367. [[ -e $v/model_executor/layers/fused_moe/megamoe_vllm.py ]] && echo megamoe
  368. [[ -e $v/v1/attention/backends/mla/gb10_sparse_mla.py ]] && echo fixes
  369. [[ -n "${VLLM_GLM_SP_TP:-}" ]] && echo sp
  370. [[ -e $v/models/glm5next/common/recoverssm.py ]] && echo recoverssm
  371. return 0
  372. }
  373. [[ "${PREFLIGHT:-1}" == 1 ]] && preflight || true
  374. # Without any overlay the stack runs vLLM's stock kernels at about half the
  375. # README's decode speed, and nothing else says so. Refuse to start instead.
  376. if [[ -z "$(overlays)" && "${ALLOW_STOCK:-0}" != 1 ]]; then
  377. echo "FATAL: no experimental overlays are mounted, so this would run stock kernels at about half the" \
  378. "README's speed. Start the stack with ./glm53 up -d (README step 5), or set ALLOW_STOCK=1" \
  379. "in compose/.env to run stock on purpose." >&2
  380. exit 1
  381. fi
  382. # --- worker memory ---------------------------------------------------------
  383. # Torch's caching allocator never hands a freed block back to the OS, and on
  384. # unified memory that block is host memory. The sparse indexer scores
  385. # chunk x context/kpool, so a session whose context keeps growing asks for a
  386. # slightly bigger block each step and strands the last one.
  387. # expandable_segments:True grows one segment instead of laddering.
  388. export PYTORCH_CUDA_ALLOC_CONF="${PYTORCH_CUDA_ALLOC_CONF:-expandable_segments:True}"
  389. # Hard ceiling on one worker's device memory, as a fraction of the 121.6 GiB,
  390. # read by spark_mem_trace.py. Keep it above GPU_MEM_UTIL by enough for a full
  391. # prefill's indexer scratch, and below 1.0 by whatever the host needs to keep
  392. # forking: past this a request raises OutOfMemoryError instead of the node
  393. # wedging. 0.92 is ~111.9 GiB against GPU_MEM_UTIL's ~107.
  394. export TORCH_MEM_FRACTION="${TORCH_MEM_FRACTION:-0.92}"
  395. if [[ -n "$_elect" ]]; then
  396. # Every box registers with its own daemon. The daemon relays to mentat's
  397. # head, and every group lives on the head.
  398. RAY_ADDRESS="${RAY_ADDRESS:-127.0.0.1:6379}"
  399. export RAY_ADDRESS
  400. else
  401. RAY_ADDRESS="${RAY_ADDRESS:-${HEAD_HOST:?set HEAD_HOST to the head node address}:6379}"
  402. export RAY_ADDRESS
  403. # One head per group. A worker left at ROLE=head starts a second driver:
  404. # mentat refuses it ("group ... already has an active driver session"), the
  405. # container restarts forever, and the real head waits for GPUs that never
  406. # join. Catch the mismatch here and say which setting.
  407. fixed_role
  408. case "$ROLE" in head|worker) ;; *) echo "FATAL: ROLE=$ROLE; set it to head or worker in .env" >&2; exit 1;; esac
  409. if [[ "$ROLE" == "head" && "$VLLM_HOST_IP" != "$_head_ip" ]]; then
  410. 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
  411. exit 1
  412. fi
  413. if [[ "$ROLE" == "worker" && "$VLLM_HOST_IP" == "$_head_ip" ]]; then
  414. echo "FATAL: ROLE=worker, but VLLM_HOST_IP=$VLLM_HOST_IP is HEAD_HOST. Set ROLE=head in .env on this node." >&2
  415. exit 1
  416. fi
  417. fi
  418. # mentat (the Ray replacement in this image) rendezvouses subclusters by
  419. # group; every rank of this deployment must carry the same value. Running the
  420. # same model twice means two compose stacks with DISTINCT MENTAT_GROUP values.
  421. export MENTAT_GROUP="${MENTAT_GROUP:-${SERVICE_NAME:-glm53}}"
  422. # The shim reads MENTAT_CLAIM_LAYOUT only when it makes a claim, which needs a
  423. # claim name.
  424. [[ "$FABRIC_LAYOUT" == ring ]] && export MENTAT_CLAIM="${MENTAT_CLAIM:-$MENTAT_GROUP}"
  425. # Pin the executor the mentat shim was audited against. It is vLLM's default
  426. # today (verified in this image AND the DS4 tree), but the default is
  427. # undocumented, and if an upstream bump flips it the legacy executor's
  428. # compiled-DAG surface comes alive -- which mentat does not implement and
  429. # fails loudly on. Pinning turns that failure into a grep-able one-liner.
  430. export VLLM_USE_RAY_V2_EXECUTOR_BACKEND=1
  431. # --- scheduler: per-step token budget, and one prefill's share of it --------
  432. # One setting, not two. The scheduler gives a long prefill
  433. # min(threshold, remaining budget), so a threshold at or above the budget lets
  434. # one big prompt take the entire step -- no decodes of running requests, no
  435. # admissions from the waiting queue. Measured on DS4 2026-08-25: a 90-token
  436. # "ping" sent 20s into a 198K-token prefill took 125.5s to answer.
  437. #
  438. # 16384 with each prefill capped at 2304, so one step carries several prefill
  439. # chunks and every running decode. Measured on TP=4 (2026-09-06): 8192 and
  440. # 16384 prefill a 200k prompt in the same time (234.1 s against 237.7 s); the
  441. # old 9.5x win for 8192 was a PP=2 pipeline bubble, gone at PP=1. Measure this
  442. # at 200k, never at 8k: an 8k prompt fits inside both budgets.
  443. export MAX_NUM_BATCHED_TOKENS="${MAX_NUM_BATCHED_TOKENS:-16384}"
  444. # --- max_num_seqs is capped by the KDA state, not by throughput -------------
  445. # 34 of this model's 45 layers are KDA linear attention, and a linear-attention
  446. # layer keeps a fixed-size RECURRENT STATE per sequence rather than a per-token
  447. # KV entry. vLLM accounts for those as "Mamba cache blocks", one per decode
  448. # sequence, and they are allocated out of whatever is left after weights.
  449. #
  450. # Measured here 2026-08-26: exactly 32 blocks fit at GPU_MEM_UTIL=0.86 with the
  451. # 181 GiB NVFP4 checkpoint. vLLM's default max_num_seqs of 256 therefore aborts
  452. # at CUDA graph capture, AFTER a full 10-minute weight load:
  453. #
  454. # ValueError: max_num_seqs (256) exceeds available Mamba cache blocks (32).
  455. # Each decode sequence requires one Mamba cache block, so CUDA graph capture
  456. # cannot proceed.
  457. #
  458. # This is a hard structural cap, not a tuning preference -- raising
  459. # GPU_MEM_UTIL is the only way to buy more blocks. The default takes all 32.
  460. #
  461. # Keep CUDAGRAPH_CAPTURE_SIZES's largest entry >= this value: with MTP off a
  462. # decode step is one token per sequence, so a full batch is exactly this many
  463. # tokens, and a step larger than the biggest captured size runs uncaptured.
  464. export MAX_NUM_SEQS="${MAX_NUM_SEQS:-32}"
  465. # One prefill's share of the step: 2304, one KDA block. A multiple of the block
  466. # matters -- 2048 produced alternating 2048/256 chunks. With it (2026-09-06),
  467. # prefill ran 2,004 tok/s at 200k and a 12-token ping sent under a 120k prefill
  468. # answered in 4.83 s. DECODE_RESERVE_TOKENS, when set, replaces it with
  469. # budget minus reserve, which is how DS4 expresses the same split.
  470. LONG_PREFILL_TOKEN_THRESHOLD="${LONG_PREFILL_TOKEN_THRESHOLD:-2304}"
  471. if [[ -n "${DECODE_RESERVE_TOKENS:-}" ]]; then
  472. LONG_PREFILL_TOKEN_THRESHOLD=$(( (MAX_NUM_BATCHED_TOKENS - DECODE_RESERVE_TOKENS) / 4 * 4 ))
  473. if (( LONG_PREFILL_TOKEN_THRESHOLD < 4 )); then
  474. echo "FATAL: DECODE_RESERVE_TOKENS=${DECODE_RESERVE_TOKENS} leaves only" >&2
  475. echo "${LONG_PREFILL_TOKEN_THRESHOLD} tokens for prefill out of a ${MAX_NUM_BATCHED_TOKENS} budget." >&2
  476. exit 1
  477. fi
  478. fi
  479. CUDAGRAPH_CAPTURE_SIZES="${CUDAGRAPH_CAPTURE_SIZES:-8 16 32 64 96 128 192 256}"
  480. echo "scheduler: budget=${MAX_NUM_BATCHED_TOKENS} prefill<=${LONG_PREFILL_TOKEN_THRESHOLD}" \
  481. "reserve=$(( MAX_NUM_BATCHED_TOKENS - LONG_PREFILL_TOKEN_THRESHOLD ))/step"
  482. export MAX_JOBS="${MAX_JOBS:-4}"
  483. # --- JIT/autotune cache tiers ----------------------------------------------
  484. # Two tiers, because the caches have different invalidation domains: the shared
  485. # tier is keyed on version/arch/kv-dtype only and is the expensive one to
  486. # rebuild, so it must stay stable across serving-option experiments; the keyed
  487. # tier holds torch.compile, which vLLM hashes against the whole serving config.
  488. CACHE_ROOT="${CACHE_ROOT:-/root/.cache}"
  489. _arch="$(nvidia-smi --query-gpu=compute_cap --format=csv,noheader 2>/dev/null | head -1 | tr -d ' .')"
  490. _arch="${_arch:-unknown}"
  491. # --- KV cache dtype: auto (bf16), NOT fp8 -----------------------------------
  492. # fp8 here selects vLLM's `fp8_ds_mla` packed KV format, which is DeepSeek's and
  493. # hardcodes pe_dim == 64:
  494. #
  495. # RuntimeError: concat_and_cache_mla, cache_kernels.cu:866,
  496. # pe_dim must be 64 for fp8_ds_mla
  497. #
  498. # GLM-5.3-Flash is NoPE sparse MLA -- `qk_rope_head_dim: 0`, no rotary on the
  499. # sparse path at all -- so pe_dim is 0 and that kernel refuses. This is what the
  500. # vendor recipe means by "FlashInfer 0.6.17+ is required for NoPE sparse MLA":
  501. # the NoPE variant is a different code path, not just a newer one.
  502. #
  503. # Copying ds4-flash's `--kv-cache-dtype fp8` across is therefore wrong: DS4 is
  504. # DeepSeek geometry (pe_dim 64) and this model is not. It costs a full weight
  505. # load (~10 min) to find out, because the failure is at KV cache init.
  506. #
  507. # The default below is fp8_e4m3, and it is what every measurement on this model
  508. # was taken with: the Korean corruption probe comes back clean and 200k recall
  509. # passes on it. Only 11 of 45 layers carry a KV cache and they use
  510. # kv_lora_rank 512, so bf16 would also fit -- but nothing here has been measured
  511. # on bf16, so do not switch on the assumption that it is the safer default.
  512. _kv="${KV_CACHE_DTYPE:-fp8_e4m3}"
  513. export SHARED_TAG="${SHARED_TAG:-glm53-${_arch}-${_kv}}"
  514. # Resolve ONCE, above every use. Two MAX_MODEL_LEN defaults in one
  515. # file is the "two places to change and one silently winning" trap the DS4
  516. # compose file warns about -- and it was live here: the cache tag said 131072
  517. # while the server was told 262144.
  518. #
  519. # 524288 of the model's 1M. KV is cheap here -- only 11 of 45 layers carry one,
  520. # at kv_lora_rank 512 -- so the 26 GiB pin below holds 2,632,595 tokens with
  521. # DFlash2, 5.02 requests at full length (head boot log, 2026-09-23).
  522. MAX_MODEL_LEN="${MAX_MODEL_LEN:-524288}"
  523. _optstr="tp=${TP} mtp=${MTP} spec=${SPEC_METHOD} len=${MAX_MODEL_LEN} cg=${CUDAGRAPH_CAPTURE_SIZES}"
  524. _opthash=$(printf '%s' "$_optstr" | sha256sum | cut -c1-8)
  525. export CACHE_TAG="${CACHE_TAG:-${SHARED_TAG}-${_opthash}}"
  526. # FlashInfer autotune: EPHEMERAL on TP>1, and wiped on every start. A persisted
  527. # cache deadlocks the NEXT boot, silently and forever. On the nightly, rank 0
  528. # reads its cache and broadcasts it, but only rank 0 ever saves, and the fused-
  529. # MoE entries it saves key per rank: ranks 1-3 miss what rank 0 hits, go off to
  530. # profile (a CPU-group all_reduce) while rank 0 has moved on to the model's NCCL
  531. # all_reduce, and each waits for the other. Measured 2026-09-23 with py-spy on
  532. # spark-glm53:v3. The first boot always works (nobody has a cache); every boot
  533. # after a good one hangs with the head at 96% GPU and the workers at 0%.
  534. # Wiping on start, not just keeping it out of the bind mount, matters: a head
  535. # engine restart keeps the container's /tmp. Costs ~2 min of autotune a boot.
  536. # AUTOTUNE_CACHE=persist brings the old behaviour back (TP=1 is safe with it).
  537. if [[ "${AUTOTUNE_CACHE:-}" == "persist" || "${TP:-1}" -le 1 ]]; then
  538. export VLLM_FLASHINFER_AUTOTUNE_CACHE_DIR="${CACHE_ROOT}/${SHARED_TAG}/flashinfer_autotune"
  539. else
  540. export VLLM_FLASHINFER_AUTOTUNE_CACHE_DIR=/tmp/flashinfer_autotune
  541. rm -rf "$VLLM_FLASHINFER_AUTOTUNE_CACHE_DIR"
  542. fi
  543. echo "autotune cache: $VLLM_FLASHINFER_AUTOTUNE_CACHE_DIR"
  544. export VLLM_CACHE_ROOT="${CACHE_ROOT}/${CACHE_TAG}/vllm"
  545. mkdir -p "$VLLM_FLASHINFER_AUTOTUNE_CACHE_DIR" "$VLLM_CACHE_ROOT"
  546. # FlashInfer's own JIT cache is deliberately NOT redirected. FLASHINFER_CACHE_DIR
  547. # looks like the knob but is a module-level constant computed from
  548. # FLASHINFER_WORKSPACE_BASE (default $HOME), so setting it does nothing at all.
  549. # $HOME/.cache is the bind mount, so the default already persists.
  550. export TRITON_CACHE_DIR="${TRITON_CACHE_DIR:-${CACHE_ROOT}/${SHARED_TAG}/triton}"
  551. mkdir -p "$TRITON_CACHE_DIR"
  552. # TileLang JIT builds the mHC (Manifold-Constrained Hyper-Connection) kernels
  553. # this architecture adds, and the DSA/MTP kernels. Its default is
  554. # ~/.tilelang/cache -- a SIBLING of the bind-mounted ~/.cache, so left alone it
  555. # lands inside the container and is destroyed on every `compose down`, and every
  556. # boot recompiles. Same invalidation domain as FlashInfer, hence the shared tier.
  557. export TILELANG_CACHE_DIR="${TILELANG_CACHE_DIR:-${CACHE_ROOT}/${SHARED_TAG}/tilelang}"
  558. mkdir -p "$TILELANG_CACHE_DIR"
  559. echo "cache shared: ${CACHE_ROOT}/${SHARED_TAG} (arch=${_arch} kv=${_kv})"
  560. echo "cache keyed : ${CACHE_ROOT}/${CACHE_TAG} ($_optstr)"
  561. echo "cache tilelang: $TILELANG_CACHE_DIR"
  562. # --- status page ------------------------------------------------------------
  563. # Own port, started immediately. Loading 181 GiB of weights takes long enough
  564. # that "connection refused" is a poor answer to "what is it doing?", and this
  565. # model's metrics read zero throughout a long prefill anyway.
  566. export STAGE_FILE="${STAGE_FILE:-/tmp/glm53-stage}"
  567. : > "$STAGE_FILE"
  568. stage() { echo "$1" >> "$STAGE_FILE"; echo "== stage: $1 =="; }
  569. STATUS_PORT="${STATUS_PORT:-8082}" PORT="${API_PORT:-8002}" \
  570. python3 /usr/local/bin/status-server.py &
  571. _status_pid=$!
  572. stage starting
  573. if [[ ! -f "$MODEL/config.json" ]]; then
  574. echo "FATAL: no config.json under MODEL_DIR=$MODEL." >&2
  575. echo "Both ranks read the full checkpoint, so it must be present on THIS node." >&2
  576. exit 1
  577. fi
  578. if [[ -d "${MCP_LOG_DIR:-/logs}" && -w "${MCP_LOG_DIR:-/logs}" ]]; then
  579. # An elected box has no role yet, and its role can change between boots.
  580. _log="${MCP_LOG_DIR:-/logs}/vllm${ROLE:+-$ROLE}.log"
  581. [[ -f "$_log" ]] && mv -f "$_log" "${_log%.log}.prev.log" 2>/dev/null || true
  582. echo "logging to $_log"
  583. exec > >(tee "$_log") 2>&1
  584. fi
  585. # NEUTRALIZED BY MENTAT, kept for real-ray fallback images (the flag is
  586. # accepted and ignored -- mentat has no object store at all, which is the
  587. # actual fix). The history, because it justifies mentat's existence:
  588. #
  589. # Ray's object store defaults to ~30% of RAM. On a discrete-GPU box that is
  590. # harmless -- it is host memory the model never wanted. GB10 is UNIFIED memory,
  591. # so it comes straight out of the pool the weights and KV cache need.
  592. #
  593. # Measured 2026-08-27: the head was capped at 4 GB but the worker was not, and
  594. # `ray status` reported 40.15 GiB of object store cluster-wide -- roughly 36 GiB
  595. # of it on the worker, for a model that passes tensors over NCCL and puts
  596. # essentially nothing in the object store.
  597. RAY_OBJECT_STORE_MEMORY="${RAY_OBJECT_STORE_MEMORY:-4294967296}"
  598. # NEUTRALIZED BY MENTAT, kept for real-ray fallback images (mentat has no
  599. # memory monitor; nothing in it ever kills a worker on a heuristic).
  600. #
  601. # Ray's memory monitor kills worker processes when the NODE crosses 95% memory.
  602. # That heuristic assumes host RAM and GPU memory are separate pools. On GB10
  603. # they are the same pool, so the ~89 GiB of resident model weights count as
  604. # host memory and the node sits at ~95% whenever the model is simply loaded.
  605. #
  606. # Measured 2026-08-27: rank 0 was killed three times (13:41, 13:52, 14:03),
  607. # each time seconds after the engine came up, with the raylet logging
  608. # "Memory on the node was 115.68GB / 121.63GB (0.951074)"
  609. # "Selected to kill: vllm_Worker_..._TP0, pid=3889, actual memory used=2.07GB"
  610. # Ray killed the 2 GB process because the 89 GiB of weights pushed the node
  611. # over its threshold. Nothing was leaking and no CUDA allocation had failed.
  612. #
  613. # This looked like a GPU OOM for hours: NV_ERR_NO_MEMORY appears in dmesg from
  614. # vLLM's own profiling probes, which allocate until they fail by design. Those
  615. # are noise. The kill decision is Ray's, and it is logged only in the raylet
  616. # event log, not in vLLM's output.
  617. #
  618. # 0 disables the monitor. Raising the threshold instead only moves the cliff:
  619. # there is no honest value when the weights alone are 73% of the pool.
  620. export RAY_memory_monitor_refresh_ms="${RAY_MEMORY_MONITOR_REFRESH_MS:-0}"
  621. # --- service announcement (mentat-serve) ------------------------------------
  622. # The agent reads these at `ray start` and carries them in its registration.
  623. # mentat-serve routes by what is announced, and probes it before believing
  624. # it. Purely additive: an image or daemon without this support ignores them.
  625. # Every rank announces its management MCP (the status server runs on both
  626. # roles). Only the head announces the OpenAI endpoint: a worker holds a TP
  627. # rank and serves nothing, and a URL announced from it would route requests
  628. # at a port with nothing behind it. The provider names the engine behind that
  629. # endpoint, which two servers answering /v1/chat/completions do not reveal by
  630. # answering it, so it goes on the rank that announces the endpoint.
  631. #
  632. # Port form (8002/v1): the router resolves it against every address the node
  633. # announces, so a router on the LAN and one on the fabric both reach it. A URL
  634. # pins one address, reachable only from that link. Both servers bind 0.0.0.0.
  635. export MENTAT_MCP_API="${MENTAT_MCP_API:-${STATUS_PORT:-8082}/mcp}"
  636. # --- head election ------------------------------------------------------------
  637. # Each box starts its agent first, with no OpenAI endpoint since the role is
  638. # not known yet, and then elects a head from the group's live agents
  639. # (discover.sh). The agent stays in the background: the head announces the
  640. # endpoint through `python -m ray.register` below, and a worker waits on it.
  641. _agent_pid=""
  642. if [[ -n "$_elect" ]]; then
  643. stage joining
  644. env -u MENTAT_OPENAI_API -u MENTAT_MODEL_PROVIDER \
  645. ray start --block --address="$RAY_ADDRESS" \
  646. --object-store-memory="$RAY_OBJECT_STORE_MEMORY" &
  647. _agent_pid=$!
  648. elect_head
  649. # The status server read ROLE once, before there was one, and took the
  650. # head's stages. A worker restarts it so its page reads as a worker's.
  651. if [[ "$ROLE" == "worker" ]]; then
  652. kill "$_status_pid" 2>/dev/null || true
  653. wait "$_status_pid" 2>/dev/null || true
  654. STATUS_PORT="${STATUS_PORT:-8082}" PORT="${API_PORT:-8002}" \
  655. python3 /usr/local/bin/status-server.py &
  656. fi
  657. fi
  658. if [[ "$ROLE" == "worker" ]]; then
  659. unset MENTAT_OPENAI_API
  660. else
  661. export MENTAT_OPENAI_API="${MENTAT_OPENAI_API:-${API_PORT:-8002}/v1}"
  662. export MENTAT_MODEL_PROVIDER="${MENTAT_MODEL_PROVIDER:-vllm}"
  663. fi
  664. # --- fabric check: every rank meets on the head before vLLM ------------------
  665. # Times an NCCL all-reduce and compares versions, override files and knobs
  666. # across nodes (fabric-check.py); on a ring it compares only. Warnings only;
  667. # FABRIC_CHECK=0 skips it, and a check that hangs is stopped.
  668. if [[ "${FABRIC_CHECK:-1}" == "1" ]]; then
  669. stage fabric-check
  670. FABRIC_LAYOUT="$FABRIC_LAYOUT" timeout "$(( 3 * ${FABRIC_CHECK_TIMEOUT_S:-120} ))" \
  671. python3 /usr/local/bin/fabric-check.py || echo "fabric check: failed or timed out; continuing"
  672. fi
  673. # --- worker: join and block -------------------------------------------------
  674. # Under mentat, `ray start --block --address` retries forever, so the
  675. # head-first ordering below stopped mattering -- start either side first. The
  676. # stage-file dance is kept because it still reads well on the status page,
  677. # and because a real-ray fallback image (where `ray start --address` does NOT
  678. # retry) needs the head up first.
  679. if [[ "$ROLE" == "worker" ]]; then
  680. stage joining
  681. ( for _ in $(seq 1 120); do
  682. if ray status --address="$RAY_ADDRESS" >/dev/null 2>&1; then
  683. stage worker-ready; break
  684. fi
  685. sleep 5
  686. done ) &
  687. # An elected worker's agent is already running. Wait on it as exec would:
  688. # the container exits when the agent does.
  689. if [[ -n "$_agent_pid" ]]; then
  690. wait "$_agent_pid"
  691. exit
  692. fi
  693. exec ray start --block --address="$RAY_ADDRESS" \
  694. --object-store-memory="$RAY_OBJECT_STORE_MEMORY"
  695. fi
  696. # --- head -------------------------------------------------------------------
  697. # --address names the daemon explicitly. mentat 0.7.0 refuses a bare --head
  698. # while RAY_ADDRESS is set ("--head names no daemon, so this agent would
  699. # register with the local one while RAY_ADDRESS names ..."): every agent of a
  700. # group and its driver must reach one daemon, and 0.6.0 let the two disagree
  701. # silently. On the head RAY_ADDRESS is its own daemon, so this changes nothing
  702. # about where it registers -- it only stops mentat having to guess.
  703. #
  704. # An elected head's agent started before the election and announced only the
  705. # MCP endpoint. `python -m ray.register` adds the OpenAI endpoint under a
  706. # second agent id, since two registrations with one id replace each other.
  707. if [[ -n "$_agent_pid" ]]; then
  708. python3 -m ray.register --address="$RAY_ADDRESS" \
  709. --container="${CONTAINER_NAME:-glm53}-api" --mcp= &
  710. else
  711. ray start --head --address="$RAY_ADDRESS" --node-ip-address="$VLLM_HOST_IP" --port=6379 \
  712. --object-store-memory="$RAY_OBJECT_STORE_MEMORY"
  713. fi
  714. # Starting the engine without its workers reaches NCCL and dies there
  715. # ("invalid usage"), so this waits rather than giving up and proceeding. The
  716. # stage stays visible on the status page throughout. WORKER_WAIT_S bounds it;
  717. # 0 waits forever, which is what a switch reboot wants.
  718. stage waiting-workers
  719. _waited=0
  720. while :; do
  721. have=$(ray status 2>/dev/null | grep -oE '[0-9.]+/[0-9.]+ GPU' | cut -d/ -f2 | cut -d. -f1 || echo 0)
  722. [[ "${have:-0}" -ge "$TP" ]] && break
  723. if (( ${WORKER_WAIT_S:-0} > 0 && _waited >= ${WORKER_WAIT_S:-0} )); then
  724. echo "FATAL: only ${have:-0} of "$TP" GPUs after ${_waited}s" >&2
  725. exit 1
  726. fi
  727. (( _waited % 60 )) || echo "waiting for "$TP" GPUs, have ${have:-0} (${_waited}s)"
  728. sleep 5
  729. _waited=$(( _waited + 5 ))
  730. done
  731. # The drafter was chosen at the top (SPEC_METHOD). A missing drafter would
  732. # otherwise fail only after the ~10 minute target load, so check it first.
  733. SPEC=()
  734. case "$SPEC_METHOD" in
  735. dflash)
  736. SPEC_TOKENS="${SPEC_TOKENS:-7}"
  737. DFLASH_MODEL="${DFLASH_MODEL:-/models/glm-5.3-flash-dflash2}"
  738. if [[ ! -f "$DFLASH_MODEL/config.json" ]]; then
  739. echo "FATAL: SPEC_METHOD=dflash but no config.json under DFLASH_MODEL=$DFLASH_MODEL." >&2
  740. echo "Mount the DFlash2 drafter there on every node, or set SPEC_METHOD=mtp." >&2
  741. exit 1
  742. fi
  743. SPEC=(--speculative-config "{\"method\":\"dflash\",\"model\":\"${DFLASH_MODEL}\",\"num_speculative_tokens\":${SPEC_TOKENS}}") ;;
  744. mtp)
  745. SPEC_TOKENS="${SPEC_TOKENS:-4}"
  746. SPEC=(--speculative-config "{\"method\":\"mtp\",\"num_speculative_tokens\":${SPEC_TOKENS}}") ;;
  747. none) ;;
  748. *) echo "FATAL: SPEC_METHOD=$SPEC_METHOD; want mtp, dflash or none" >&2; exit 1 ;;
  749. esac
  750. [[ ${#SPEC[@]} -gt 0 ]] && echo "speculative: ${SPEC[*]}"
  751. stage loading
  752. if [[ "${SELF_TEST:-1}" == "1" ]]; then
  753. (
  754. if python3 /usr/local/bin/self-test.py \
  755. --base "http://127.0.0.1:${API_PORT:-8002}" --model "$SERVED"; then
  756. stage serving
  757. else
  758. stage self-test-failed
  759. echo "!! SELF-TEST FAILED. Serving anyway so the model can be probed;" >&2
  760. echo "!! the status page reports unhealthy. A wrong-but-healthy model is" >&2
  761. echo "!! the specific failure this gate exists to catch." >&2
  762. fi
  763. ) &
  764. else
  765. ( for _ in $(seq 1 480); do
  766. curl -sf -o /dev/null --max-time 4 "http://127.0.0.1:${API_PORT:-8002}/v1/models" \
  767. && { stage serving; break; }
  768. sleep 5
  769. done ) &
  770. fi
  771. # --- tool parser --------------------------------------------------------------
  772. # The stock glm47 by default. TOOL_PARSER=glm47_failclosed selects the
  773. # fail-closed plugin baked at /usr/local/share (loaded with
  774. # --tool-parser-plugin), a stopgap for tool calls corrupted upstream. It
  775. # refuses any call to a tool the request did not list, which stalls clients
  776. # that reach deferred tools through a tool-search step (Qwen Code's MCP
  777. # tools). The stock parser drops such a call silently: this vLLM fixes
  778. # validate_tool_names=True in glm47_moe_config, with no flag to turn it off.
  779. # Selected here, not through EXTRA_ARGS: an override restating EXTRA_ARGS once
  780. # dropped the plugin from production.
  781. TOOL_PARSER="${TOOL_PARSER:-glm47}"
  782. TOOL_ARGS=(--enable-auto-tool-choice --tool-call-parser "$TOOL_PARSER")
  783. if [[ "$TOOL_PARSER" == "glm47_failclosed" ]]; then
  784. TOOL_ARGS=(--tool-parser-plugin /usr/local/share/glm47_failclosed.py "${TOOL_ARGS[@]}")
  785. fi
  786. echo "tool parser: $TOOL_PARSER"
  787. # --- sparse indexer top-k -------------------------------------------------------
  788. # GB10 cannot run persistent_topk once the KV pool passes ~3.4M tokens (it would
  789. # oversubscribe 48 CTAs), and the FilteredTopK fallback wants 128 KB of shared
  790. # memory per block against the 101,376 B an SM has. per_row is the same
  791. # computation without the persistent-CTA scheme. This used to be a patch
  792. # (gb10_topk_fallback.py); on main it is a flag. "auto" tries cooperative,
  793. # then persistent, then per_row.
  794. TOPK_BACKEND="${TOPK_BACKEND:-per_row}"
  795. TOPK_ARGS=(--sparse-indexer-topk-backend "$TOPK_BACKEND")
  796. # --load-format auto, NOT sharded_state -- see the header.
  797. #
  798. # Parser names do not match the model version, which is normal here: the vendor
  799. # recipe for 5.3-Flash specifies glm47 and glm45.
  800. # --- MoE backend and CUDA graphs -------------------------------------------
  801. # flashinfer_cutlass, the native NVFP4 kernel, which runs W4A4 off the
  802. # checkpoint's per-projection input scales. Production has served on it with
  803. # the nvidia checkpoint since 2026-09-21; why it replaced marlin then is not
  804. # recorded. It decodes 109.8 / 88.8 / 52.6 tok/s (structured / code / prose,
  805. # v6 + DFlash2, 2026-09-23) and passes the thinking-on count-to-200 probe 8/8.
  806. # It takes vLLM's own CUDA graph sizes; CUDAGRAPH_CAPTURE_SIZES applies to
  807. # marlin only.
  808. #
  809. # marlin was the default before that. It is weight-only: it dequantises to FP16
  810. # and runs bf16 activations, so a checkpoint's input scales are never read. It
  811. # was adopted when the native kernels died on sm_121 with
  812. # cudaErrorNoKernelImageForDevice, a failure both MiaAI-Lab and LibertAI later
  813. # put down to one checkpoint's uninitialised input_scale. It is also not
  814. # bit-deterministic at M >> 1 (dev/TOPK-CORRUPTION.md).
  815. #
  816. # Pairing marlin with --enforce-eager is recipe convention, not a correctness
  817. # constraint. Marlin's workspace helper reuses storage precisely so a captured
  818. # graph's addresses stay valid, and the only eager gate in this vLLM is for
  819. # DeepseekV4. Capture measures 6 s here. CUDA_GRAPHS=0 restores eager.
  820. #
  821. # Capture sizes are in TOKENS and vLLM rounds them to multiples of (1 + draft
  822. # tokens). Above max_num_seqs * (1 + k) the dispatcher returns NONE and decode
  823. # falls back to eager with nothing in the log to say so, which is why the
  824. # ceiling is computed and checked rather than left to whoever edits the list.
  825. MOE=()
  826. MOE_BACKEND="${MOE_BACKEND:-flashinfer_cutlass}"
  827. if [[ "$MOE_BACKEND" == "marlin" ]]; then
  828. if [[ "${CUDA_GRAPHS:-1}" == "1" ]]; then
  829. _k=1
  830. [[ -n "$SPEC_METHOD" && "$SPEC_METHOD" != none ]] && _k=$(( SPEC_TOKENS + 1 ))
  831. _ceil=$(( MAX_NUM_SEQS * _k ))
  832. _largest=$(tr ' ' '\n' <<<"$CUDAGRAPH_CAPTURE_SIZES" | sort -n | tail -1)
  833. if (( _largest > _ceil )); then
  834. echo "WARNING: largest capture size $_largest exceeds max_num_seqs*(1+k)=$_ceil;" >&2
  835. echo "decode above $_ceil tokens will run eager and log nothing." >&2
  836. fi
  837. MOE=(--moe-backend marlin
  838. --cudagraph-capture-sizes ${CUDAGRAPH_CAPTURE_SIZES}
  839. --compilation-config "{\"cudagraph_mode\":\"${CUDAGRAPH_MODE:-FULL_AND_PIECEWISE}\"}")
  840. echo "MoE backend: marlin, cudagraphs ${CUDAGRAPH_MODE:-FULL_AND_PIECEWISE}" \
  841. "(sizes: $CUDAGRAPH_CAPTURE_SIZES, ceiling ${_ceil})"
  842. else
  843. MOE=(--moe-backend marlin --enforce-eager)
  844. echo "MoE backend: marlin (enforce-eager, CUDA_GRAPHS=0)"
  845. fi
  846. else
  847. MOE=(--moe-backend "${MOE_BACKEND}")
  848. # vLLM captures graphs up to max_num_seqs x (1 + drafts) x 2, capped at
  849. # 512 tokens. The top half only serves steps that mix decodes with a short
  850. # new prompt, so stop at the largest decode step: 32 tokens at TP=2 instead
  851. # of 64, for example. Those mixed steps run without a graph.
  852. # A draft width that varies with batch size (adaptive-k.yaml's
  853. # num_speculative_tokens_per_batch_size) decodes 1 + k tokens per request
  854. # for each k in the schedule: 3, 5, 6, 10, 12, 15. vLLM adds those sizes to
  855. # its capture list only when it picks the ceiling itself, so an explicit cap
  856. # leaves them to run without a full graph. Leave the ceiling to vLLM there,
  857. # unless CUDAGRAPH_MAX sets one.
  858. if [[ -z "${CUDAGRAPH_MAX:-}" && "${EXTRA_ARGS:-}" == *num_speculative_tokens_per_batch_size* ]]; then
  859. echo "MoE backend: ${MOE_BACKEND}, vLLM's own CUDA graph sizes (draft width varies with batch size)"
  860. else
  861. _q=1; [[ -n "$SPEC_METHOD" && "$SPEC_METHOD" != none ]] && _q=$(( SPEC_TOKENS + 1 ))
  862. _cgmax=$(( MAX_NUM_SEQS * _q < 512 ? MAX_NUM_SEQS * _q : 512 ))
  863. CUDAGRAPH_MAX="${CUDAGRAPH_MAX:-$_cgmax}"
  864. MOE+=(--max-cudagraph-capture-size "$CUDAGRAPH_MAX")
  865. echo "MoE backend: ${MOE_BACKEND}, CUDA graphs up to ${CUDAGRAPH_MAX} tokens"
  866. fi
  867. fi
  868. # Multimodal: up to 16 images a prompt, no video. Exceeding a cap is a clean
  869. # 400 with an OpenAI-shaped error body, not an engine fault, but a client that
  870. # treats any 400 as fatal dies on it.
  871. # The closing brace is escaped: bash ends a :- default at the first unescaped
  872. # one, so it would cut the default short and append the tail as literal text.
  873. LIMIT_MM="${LIMIT_MM:-{\"image\":16,\"video\":0\}}"
  874. python3 -c 'import json, sys; json.loads(sys.argv[1])' "$LIMIT_MM" 2>/dev/null || {
  875. echo "FATAL: LIMIT_MM is not valid JSON: $LIMIT_MM" >&2
  876. echo " Write the whole object, closing brace included." >&2
  877. exit 1; }
  878. MM=(--limit-mm-per-prompt "$LIMIT_MM")
  879. # 0: run the max-size dummy forward at init, so the vision encoder's peak is
  880. # budgeted at startup instead of coming out of the headroom a long prefill
  881. # needs at run time. 1 skips it, which is faster to boot and was the default
  882. # while images were capped at 4.
  883. [[ "${SKIP_MM_PROFILING:-0}" == "1" ]] && MM+=(--skip-mm-profiling)
  884. # The image's template is nvidia's, with one change: thinking off asks for low
  885. # reasoning effort. GLM-5.3-Flash has no non-thinking mode, and the empty
  886. # <think></think> that thinking off otherwise produces makes long output repeat
  887. # and skip (README, Troubleshooting). The checkpoint's own template is ignored,
  888. # so a fresh download cannot bring the problem back. CHAT_TEMPLATE overrides.
  889. TMPL=()
  890. : "${CHAT_TEMPLATE:=/usr/local/share/glm53-chat-template.jinja}"
  891. if [[ -n "$CHAT_TEMPLATE" ]]; then
  892. TMPL=(--chat-template "$CHAT_TEMPLATE")
  893. echo "chat template: $CHAT_TEMPLATE"
  894. fi
  895. # A 320B MoE takes far longer to init than the default allows.
  896. export VLLM_ENGINE_READY_TIMEOUT_S="${VLLM_ENGINE_READY_TIMEOUT_S:-3600}"
  897. # --- KV cache pin ----------------------------------------------------------
  898. # Left unset, vLLM PROFILES the KV cache and takes everything available -- here
  899. # that was 13.59 GiB, giving 796,361 tokens. FlashInfer's autotune then runs
  900. # during warmup, asks the driver for more, and there is none:
  901. #
  902. # NVRM: Check failed: Out of memory [NV_ERR_NO_MEMORY] ... _memdescAllocInternal
  903. # RayWorkerProc rank=[0] died unexpectedly
  904. #
  905. # On a discrete GPU the profiler's headroom assumptions usually survive this.
  906. # On GB10's unified memory they do not, which is what the upstream recipe means
  907. # by "--kv-cache-memory pin (UMA OOM only)". Pin it below the profiled figure so
  908. # warmup has room.
  909. #
  910. # Measured 2026-08-27: a 10 GiB pin made things WORSE, because skipping the
  911. # profile also discards the gpu_memory_utilization margin autotune relies on.
  912. # That was before TORCH_MEM_FRACTION and the ephemeral autotune cache.
  913. #
  914. # 26 GiB is the pin that serves under DFlash2. At 28 the head sat near 1 GiB
  915. # free and eight concurrent long-context requests had a worker OOM-killed. A
  916. # pin also stops the pool moving by ~0.9 GiB between identical boots with
  917. # page-cache timing at profiling. DFlash2 costs 41% of the pool: 3,437,736
  918. # tokens with speculation off against 2,024,644 with it (pre-nightly image,
  919. # 2026-09-06).
  920. KV_CACHE_MEMORY="${KV_CACHE_MEMORY:-27917287424}"
  921. KV_ARGS=(--kv-cache-memory "${KV_CACHE_MEMORY}")
  922. echo "kv-cache-memory pinned to ${KV_CACHE_MEMORY} bytes ($(( KV_CACHE_MEMORY / 1073741824 )) GiB)"
  923. # eager stages each shard through anonymous memory and loads faster -- 511 s
  924. # against 690 s for lazy. Unpinned, its buffers were still resident when vLLM
  925. # profiled for KV and cost 38% of the cache: 810,576 tokens against 1,296,922.
  926. # With the KV pin above the pool is not profiled, and production runs eager.
  927. # Go back to lazy if the pin is ever dropped.
  928. exec vllm serve "$MODEL" \
  929. --served-model-name "$SERVED" \
  930. --tensor-parallel-size "$TP" \
  931. --distributed-executor-backend ray \
  932. --load-format auto \
  933. --safetensors-load-strategy "${SAFETENSORS_LOAD_STRATEGY:-eager}" \
  934. --gpu-memory-utilization "${GPU_MEM_UTIL:-0.88}" \
  935. --max-model-len "${MAX_MODEL_LEN}" \
  936. --kv-cache-dtype "${KV_CACHE_DTYPE:-fp8_e4m3}" \
  937. --block-size "${BLOCK_SIZE:-2304}" \
  938. "${KV_ARGS[@]}" \
  939. --trust-remote-code \
  940. --enable-prefix-caching \
  941. --max-num-batched-tokens "${MAX_NUM_BATCHED_TOKENS}" \
  942. --max-num-seqs "${MAX_NUM_SEQS}" \
  943. --long-prefill-token-threshold "${LONG_PREFILL_TOKEN_THRESHOLD}" \
  944. "${MOE[@]}" \
  945. "${MM[@]}" \
  946. "${TMPL[@]}" \
  947. ${ITERATION_DETAILS:+--enable-logging-iteration-details} \
  948. "${TOOL_ARGS[@]}" \
  949. "${TOPK_ARGS[@]}" \
  950. --reasoning-parser glm45 \
  951. "${SPEC[@]}" \
  952. --host 0.0.0.0 --port "${API_PORT:-8002}" \
  953. ${EXTRA_ARGS:-}