main
sh 635 lines 27 KB
Raw
1 #!/bin/bash
2 # =============================================================================
3 # vLLM disaggregated (P/D) launcher (rank-based / SLURM-native).
4 # -----------------------------------------------------------------------------
5 #
6 # rank 0 .. xP-1 -> prefill (rank 0 is also the orchestrator + proxy)
7 # rank xP .. xP+yD-1 -> decode
8 #
9 # Rank 0 additionally: starts the MoRIIO proxy, health-gates every server,
10 # runs the post-health workload (RUN_AFTER_HEALTH=bench|accuracy|none), then
11 # drops a shared-FS completion sentinel so the other ranks shut down and the
12 # single `srun` returns. Rank 0's exit code is the run's pass/fail.
13 #
14 # Explicit per-role invocation is also supported (granular for manual use):
15 # proxy | prefill | decode | bench | accuracy.
16 #
17 # ---- LAUNCH IT (both supported) --------------------------------------------
18 # 1) Single srun (SLURM provides the rank) — what run_xPyD_disagg.slurm does:
19 # srun --nodes=$((xP+yD)) --ntasks-per-node=1 \
20 # bash .../vllm_disagg.sh node
21 # (IPADDRS must be ordered to match ranks: prefill IPs first, then decode,
22 # in the SAME order srun assigns PROCID — sort nodes alphabetically.)
23 #
24 # 2) Manual, one shell per node (rank from NODE_RANK; share LOG_PATH/FS):
25 # IPADDRS=ipP0,ipD0 xP=1 yD=1 NODE_RANK=0 bash vllm_disagg.sh node
26 # IPADDRS=ipP0,ipD0 xP=1 yD=1 NODE_RANK=1 bash vllm_disagg.sh node
27 #
28 # 3) Manual, fully granular (per-role):
29 # bash vllm_disagg.sh proxy
30 # bash vllm_disagg.sh prefill
31 # NODE_RANK=1 bash vllm_disagg.sh decode
32 # bash vllm_disagg.sh bench
33 #
34 # Cluster config: cluster.sh (sourced). Model flags: models.yaml.
35 # Parallelism: WIDE_EP_MODE=0 tp (independent TP servers) | 1 ep (DP+EP groups).
36 # =============================================================================
37 set -euo pipefail
38
39 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
40
41 _PROXY_IP_OVERRIDE="${PROXY_IP:-}"
42
43 log() { echo "[vllm_disagg] $*"; }
44 die() { echo "ERROR: $*" >&2; exit 1; }
45
46 usage() {
47 echo "Usage: $0 [node|proxy|prefill|decode|bench|accuracy] [--wide-ep-mode 0|1]" >&2
48 echo " (no role => 'node': derive role from \$SLURM_PROCID/\$NODE_RANK)" >&2
49 exit 1
50 }
51
52 # ----------------------------------------------------------------- arg parsing
53 # Sets globals: ROLE (default 'node'), _WIDE_EP_MODE_OVERRIDE.
54 parse_args() {
55 ROLE="${1:-node}"
56 shift || true
57 _WIDE_EP_MODE_OVERRIDE=""
58 while [[ $# -gt 0 ]]; do
59 case "$1" in
60 --wide-ep-mode) _WIDE_EP_MODE_OVERRIDE="${2:-}"; shift 2 ;;
61 --wide-ep-mode=*) _WIDE_EP_MODE_OVERRIDE="${1#*=}"; shift ;;
62 -h|--help) usage ;;
63 *) die "Unknown argument: $1" ;;
64 esac
65 done
66 }
67
68 # ----------------------------------------------------------------- load config
69 # Sources cluster.sh and resolves WIDE_EP_MODE -> PARALLEL_MODE (tp|ep).
70 load_config() {
71 CLUSTER_ENV="${CLUSTER_ENV:-${SCRIPT_DIR}/cluster.sh}"
72 [[ -f "${CLUSTER_ENV}" ]] || die "cluster env file not found: ${CLUSTER_ENV}"
73 # shellcheck disable=SC1090
74 source "${CLUSTER_ENV}"
75
76 # Topology fanout normally arrives from cluster.sh; restate the defaults so
77 # the rank arithmetic is defined even when it does not.
78 : "${xP:=1}" "${yD:=1}"
79
80 [[ -n "${_WIDE_EP_MODE_OVERRIDE}" ]] && WIDE_EP_MODE="${_WIDE_EP_MODE_OVERRIDE}"
81 WIDE_EP_MODE="${WIDE_EP_MODE:-0}"
82 case "${WIDE_EP_MODE}" in
83 0) PARALLEL_MODE="tp" ;;
84 1) PARALLEL_MODE="ep" ;;
85 *) die "WIDE_EP_MODE must be 0 (tp) or 1 (ep); got '${WIDE_EP_MODE}'" ;;
86 esac
87
88 # node-mode orchestration knobs
89 RUN_AFTER_HEALTH="${RUN_AFTER_HEALTH:-accuracy}" # bench | accuracy | none
90 HEALTH_TIMEOUT_S="${HEALTH_TIMEOUT_S:-3600}"
91
92 # MoRIIO KV transfer direction. 0 (default)
93 MORIIO_READ_MODE="${MORIIO_READ_MODE:-0}"
94 }
95
96 # Emits the `read_mode` KV-config fragment (leading comma + newline) when read
97 # mode is enabled, else nothing (leaving MoRIIO's write-mode default).
98 moriio_read_mode_kv() {
99 case "${MORIIO_READ_MODE:-0}" in
100 1|true|True|TRUE|yes|on) printf ',\n "read_mode": true' ;;
101 *) : ;;
102 esac
103 }
104
105 # ----------------------------------------------------------------- topology
106 # Resolves MODEL_PATH, MODELS_YAML, the IP array, and the master/proxy addresses.
107 # IP_ARRAY[0..xP-1] = prefill nodes, [xP..xP+yD-1] = decode nodes.
108 resolve_topology() {
109 MODEL_PATH="${MODEL_DIR%/}/${MODEL_NAME}"
110 MODELS_YAML="${MODELS_YAML:-${SCRIPT_DIR}/models.yaml}"
111 mkdir -p "${LOG_PATH}" 2>/dev/null || true
112
113 [[ -z "${IPADDRS}" ]] && IPADDRS="${PREFILL_IP},${DECODE_IP}" # 1P1D fallback
114 IFS=',' read -ra IP_ARRAY <<< "${IPADDRS}"
115 PREFILL_MASTER_ADDR="${IP_ARRAY[0]}"
116 DECODE_MASTER_ADDR="${IP_ARRAY[$xP]:-${IP_ARRAY[-1]}}"
117 # Proxy is co-located on the prefill master (rank 0) unless the CALLER
118 # explicitly set PROXY_IP. We use the pre-cluster.sh override (captured at the
119 # top) — not the live PROXY_IP, which cluster.sh always populates with the
120 # static PREFILL_IP fallback and would otherwise mask the real master.
121 PROXY_IP="${_PROXY_IP_OVERRIDE:-${PREFILL_MASTER_ADDR}}"
122 }
123
124 # ============================================================================
125 # Non-server roles
126 # ============================================================================
127
128 run_proxy() {
129 [[ -f "${PROXY_SCRIPT}" ]] || die "proxy script not found: ${PROXY_SCRIPT}"
130 log "proxy: http=:${PROXY_PORT} discovery=:${PROXY_PING_PORT} (${PROXY_SCRIPT})"
131 exec python3 "${PROXY_SCRIPT}" --port "${PROXY_PORT}"
132 }
133
134 # Start the proxy in the BACKGROUND (node-mode rank 0). Sets PROXY_PID.
135 start_proxy_bg() {
136 [[ -f "${PROXY_SCRIPT}" ]] || die "proxy script not found: ${PROXY_SCRIPT}"
137 local plog="${LOG_PATH}/proxy_$(date +%Y%m%d_%H%M%S).log"
138 log "proxy(bg): http=:${PROXY_PORT} discovery=:${PROXY_PING_PORT} log=${plog}"
139 python3 "${PROXY_SCRIPT}" --port "${PROXY_PORT}" >"${plog}" 2>&1 &
140 PROXY_PID=$!
141 }
142
143 run_bench() {
144 local base_url="http://127.0.0.1:${GATEWAY_PORT:-${PROXY_PORT}}"
145 local result_dir="${LOG_PATH}/bench_$(date +%Y%m%d_%H%M%S)"
146 mkdir -p "${result_dir}"
147 log "bench -> ${base_url} model=${MODEL_PATH} wide_ep_mode=${WIDE_EP_MODE}(${PARALLEL_MODE})"
148 log "combinations='${BENCHMARK_COMBINATIONS}' concurrency='${BENCHMARK_CON}'"
149
150 local combo isl osl con nump logf
151 for combo in ${BENCHMARK_COMBINATIONS}; do
152 isl="${combo%%/*}"; osl="${combo##*/}"
153 for con in ${BENCHMARK_CON}; do
154 nump=$(( NUM_PROMPTS_FACTOR * con ))
155 (( nump < BENCHMARK_MIN_PROMPTS )) && nump="${BENCHMARK_MIN_PROMPTS}"
156 logf="${result_dir}/bench_${isl}_${osl}_con${con}.log"
157 log "ISL=${isl} OSL=${osl} CON=${con} num_prompts=${nump} -> ${logf}"
158 vllm bench serve \
159 --backend openai \
160 --base-url "${base_url}" \
161 --endpoint /v1/completions \
162 --model "${MODEL_PATH}" \
163 --dataset-name random \
164 --random-input-len "${isl}" \
165 --random-output-len "${osl}" \
166 --num-prompts "${nump}" \
167 --max-concurrency "${con}" \
168 --percentile-metrics ttft,tpot,itl,e2el \
169 --ignore-eos \
170 2>&1 | tee "${logf}"
171 done
172 done
173 log "bench complete. Results in ${result_dir}"
174 }
175
176 run_accuracy() {
177 # lm_eval isn't in the stock vLLM image; install on demand (rank 0 only, and
178 # only when accuracy is actually selected). Override with ACCURACY_PIP_SPEC.
179 if ! command -v lm_eval >/dev/null 2>&1; then
180 log "lm_eval not found — installing ${ACCURACY_PIP_SPEC:-lm_eval[api]}"
181 python3 -m pip install --no-cache-dir "${ACCURACY_PIP_SPEC:-lm_eval[api]}"
182 fi
183 local base_url="http://127.0.0.1:${GATEWAY_PORT:-${PROXY_PORT}}/v1/completions"
184 local ts; ts="$(date +%Y%m%d_%H%M%S)"
185 local logf="${LOG_PATH}/accuracy_${MODEL_NAME}_${PARALLEL_MODE}_${ts}.log"
186 local outdir="${LOG_PATH}/lm_eval_${MODEL_NAME}_${PARALLEL_MODE}_${ts}"
187 log "accuracy -> ${base_url} model=${MODEL_PATH} tasks=${ACCURACY_TASKS} wide_ep_mode=${WIDE_EP_MODE}(${PARALLEL_MODE})"
188 log "log=${logf} results=${outdir}"
189
190 # --output_path makes lm_eval persist a results_*.json for scraping.
191 local eval_rc=0
192 ( set -o pipefail
193 # Model weights are already served (loaded offline in a separate process);
194 # allow the Hub only here so lm_eval can fetch the eval dataset (e.g. gsm8k).
195 export HF_HUB_OFFLINE=0 HF_DATASETS_OFFLINE=0
196 python3 -m lm_eval --model local-completions \
197 --tasks "${ACCURACY_TASKS}" \
198 --model_args "model=${MODEL_PATH},base_url=${base_url},num_concurrent=${ACCURACY_NUM_CONCURRENT},max_retries=${ACCURACY_MAX_RETRIES},tokenized_requests=False,trust_remote_code=True,timeout=${ACCURACY_TIMEOUT:-3600}" \
199 --output_path "${outdir}" \
200 2>&1 | tee "${logf}"
201 ) || eval_rc=$?
202 if (( eval_rc != 0 )); then
203 log "FAIL: lm_eval exited rc=${eval_rc} (no usable score) — Log: ${logf}"
204 return "${eval_rc}"
205 fi
206
207 # Gate on the score. Find the results JSON lm_eval just wrote, pull the max
208 # value of ACCURACY_METRIC across filters/tasks, and compare to the threshold.
209 local results_json=""
210 results_json="$(find "${outdir}" -name 'results_*.json' -type f 2>/dev/null | sort | tail -n1)"
211 if [[ -z "${results_json}" ]]; then
212 log "FAIL: no results_*.json under ${outdir} — cannot evaluate threshold"
213 return 1
214 fi
215
216 local score=""
217 score="$(python3 - "${results_json}" "${ACCURACY_METRIC}" <<'PY'
218 import json, sys
219 path, metric = sys.argv[1], sys.argv[2]
220 with open(path) as f:
221 data = json.load(f)
222 best = None
223 for task, metrics in (data.get("results") or {}).items():
224 for key, val in metrics.items():
225 if isinstance(val, bool) or not isinstance(val, (int, float)):
226 continue
227 if key.split(",")[0] == metric:
228 best = val if best is None else max(best, val)
229 print("" if best is None else "%.6f" % best)
230 PY
231 )" || score=""
232
233 if [[ -z "${score}" ]]; then
234 log "FAIL: could not parse metric '${ACCURACY_METRIC}' from ${results_json}"
235 return 1
236 fi
237
238 if awk -v s="${score}" -v t="${ACCURACY_THRESHOLD}" 'BEGIN{exit !(s+0 >= t+0)}'; then
239 log "PASS: ${ACCURACY_METRIC}=${score} >= threshold=${ACCURACY_THRESHOLD} (tasks=${ACCURACY_TASKS}). Log: ${logf}"
240 return 0
241 fi
242 log "FAIL: ${ACCURACY_METRIC}=${score} < threshold=${ACCURACY_THRESHOLD} (tasks=${ACCURACY_TASKS}). Log: ${logf}"
243 return 1
244 }
245
246 # ============================================================================
247 # Server roles (prefill / decode) and their shared helpers
248 # ============================================================================
249
250 # Read model-specific flags from models.yaml (mode + role aware) into globals:
251 # MODEL_BASE_FLAGS / MODEL_ROLE_FLAGS / MODEL_EXPERIMENTAL_FLAGS -> MODEL_CONFIG
252 # Also exports the model's env: block (only if not already set; caller env wins).
253 load_model_flags() {
254 [[ -f "${MODELS_YAML}" ]] || die "models.yaml not found: ${MODELS_YAML}"
255 export MODELS_YAML MODEL_NAME PARALLEL_MODE ROLE
256 eval "$(python3 - <<'PY'
257 import os, shlex, sys, yaml
258 path = os.environ["MODELS_YAML"]; name = os.environ["MODEL_NAME"]
259 mode = os.environ["PARALLEL_MODE"]; role = os.environ["ROLE"]
260 with open(path, "r", encoding="utf-8") as f:
261 doc = yaml.safe_load(f) or {}
262 # Preferred form: top-level `models:` list of {model: <name>, ...} entries.
263 # Back-compat: a top-level mapping keyed by model name.
264 entries = doc.get("models") if isinstance(doc, dict) else doc
265 cfg = None
266 if isinstance(entries, list):
267 cfg = next((e for e in entries if isinstance(e, dict) and e.get("model") == name), None)
268 elif isinstance(doc, dict):
269 cfg = doc.get(name)
270 if cfg is None:
271 print(f'echo "ERROR: model {name} not found in {path}" >&2; exit 1'); sys.exit(0)
272 role_cfg = cfg.get(role, {}) or {}
273 def q(v): return shlex.quote(str(v if v is not None else ""))
274 exports = {
275 "MODEL_BASE_FLAGS": cfg.get("base_flags", "") or "",
276 "MODEL_ROLE_FLAGS": role_cfg.get(mode, "") or "",
277 "MODEL_EXPERIMENTAL_FLAGS": cfg.get("experimental_flags", "") or "",
278 }
279 for k, v in exports.items(): print(f"{k}={q(v)}")
280 # Model-specific env: exported only if not already set (caller env wins).
281 for k, v in (cfg.get("env", {}) or {}).items():
282 if k not in os.environ:
283 print(f"export {k}={q(v)}")
284 PY
285 )"
286 MODEL_CONFIG="${MODEL_BASE_FLAGS} ${MODEL_ROLE_FLAGS} ${MODEL_EXPERIMENTAL_FLAGS}"
287 }
288
289 # Build the TP-mode (WIDE_EP_MODE=0) serve command into the global CMD array.
290 # Each node is an independent TP server registered to the proxy.
291 build_tp_cmd() {
292 local port="${TP_PORT}"
293 [[ -n "${HOST_IP}" ]] && CMD+=(--host "${HOST_IP}")
294 CMD+=(--port "${port}" --tensor-parallel-size "${TP_SIZE}")
295 local _mc; read -ra _mc <<< "${MODEL_CONFIG}"; CMD+=("${_mc[@]}")
296 local kv_cfg read_mode_kv
297 read_mode_kv="$(moriio_read_mode_kv)"
298 kv_cfg=$(cat <<EOF
299 {
300 "kv_connector": "MoRIIOConnector",
301 "kv_role": "${KV_ROLE}",
302 "kv_connector_extra_config": {
303 "proxy_ip": "${PROXY_IP}",
304 "proxy_ping_port": "${PROXY_PING_PORT}",
305 "http_port": "${port}",
306 "handshake_port": "${HANDSHAKE_PORT}",
307 "notify_port": "${NOTIFY_PORT}"${read_mode_kv}
308 }
309 }
310 EOF
311 )
312 CMD+=(--kv-transfer-config "${kv_cfg}")
313 log "role=${ROLE} mode=tp host=${HOST_IP:-0.0.0.0}:${port} kv_role=${KV_ROLE} tp=${TP_SIZE} read_mode=${MORIIO_READ_MODE}"
314 }
315
316 # Build the EP-mode (WIDE_EP_MODE=1) serve command into the global CMD array.
317 # DP + expert parallel; masters expose the API server + KV transfer, children
318 # join headless.
319 build_ep_cmd() {
320 # prefill -> mori_high_throughput (paired with --enforce-eager in models.yaml)
321 # decode -> mori_low_latency (paired with CUDA graphs in models.yaml)
322 local default_backend="mori_low_latency"
323 [[ "${ROLE}" == "prefill" ]] && default_backend="mori_high_throughput"
324 local backend="${ALL2ALL_BACKEND:-${default_backend}}"
325 export VLLM_ALL2ALL_BACKEND="${VLLM_ALL2ALL_BACKEND:-${backend}}"
326 local port="${SERVE_PORT}"
327 CMD+=(
328 -tp 1
329 --data-parallel-size "${DP_GROUP_SIZE}"
330 --data-parallel-size-local "${GPUS_PER_NODE}"
331 --data-parallel-address "${DP_MASTER_ADDR}"
332 --data-parallel-rpc-port "${RPC_PORT}"
333 --enable-expert-parallel
334 --all2all-backend "${backend}"
335 --port "${port}"
336 --no-enable-prefix-caching
337 --distributed-timeout-seconds "${DISTRIBUTED_TIMEOUT_SECONDS}"
338 )
339 local _mc; read -ra _mc <<< "${MODEL_CONFIG}"; CMD+=("${_mc[@]}")
340
341 if [[ "${IS_MASTER}" == "1" ]]; then
342 CMD+=(--api-server-count="${GPUS_PER_NODE}")
343 local kv_cfg read_mode_kv
344 read_mode_kv="$(moriio_read_mode_kv)"
345 kv_cfg=$(cat <<EOF
346 {
347 "kv_connector": "MoRIIOConnector",
348 "kv_role": "${KV_ROLE}",
349 "kv_port": "${KV_PORT}",
350 "kv_connector_extra_config": {
351 "proxy_ip": "${PROXY_IP}",
352 "proxy_port": "${PROXY_PORT}",
353 "proxy_ping_port": "${PROXY_PING_PORT}",
354 "http_port": "${port}",
355 "local_ping_port": "${LOCAL_PING_PORT}",
356 "handshake_port": "${HANDSHAKE_PORT}",
357 "notify_port": "${NOTIFY_PORT}"${read_mode_kv}
358 }
359 }
360 EOF
361 )
362 CMD+=(--kv-transfer-config "${kv_cfg}")
363 log "role=${ROLE} mode=ep MASTER node_rank=${NODE_RANK} dp_size=${DP_GROUP_SIZE} dp_addr=${DP_MASTER_ADDR} kv_role=${KV_ROLE} all2all=${backend} read_mode=${MORIIO_READ_MODE}"
364 else
365 CMD+=(--data-parallel-start-rank "${DP_START_RANK}" --headless)
366 log "role=${ROLE} mode=ep CHILD node_rank=${NODE_RANK} dp_size=${DP_GROUP_SIZE} dp_start_rank=${DP_START_RANK} dp_addr=${DP_MASTER_ADDR} all2all=${backend}"
367 fi
368 }
369
370 # Set the prefill topology globals for the current NODE_RANK (no execution).
371 configure_prefill() {
372 : "${NODE_RANK:=0}" # manual default: prefill master
373 KV_ROLE="kv_producer"
374 DP_GROUP_SIZE=$(( xP * GPUS_PER_NODE ))
375 DP_MASTER_ADDR="${PREFILL_MASTER_ADDR}"
376 DP_START_RANK=$(( NODE_RANK * GPUS_PER_NODE ))
377 [[ "${NODE_RANK}" -eq 0 ]] && IS_MASTER=1 || IS_MASTER=0
378 TP_PORT="${PREFILL_PORT}"
379 }
380
381 # Set the decode topology globals for the current NODE_RANK (no execution).
382 configure_decode() {
383 : "${NODE_RANK:=${xP}}" # manual default: decode master
384 KV_ROLE="kv_consumer"
385 DP_GROUP_SIZE=$(( yD * GPUS_PER_NODE ))
386 DP_MASTER_ADDR="${DECODE_MASTER_ADDR}"
387 DP_START_RANK=$(( (NODE_RANK - xP) * GPUS_PER_NODE ))
388 [[ "${NODE_RANK}" -eq "${xP}" ]] && IS_MASTER=1 || IS_MASTER=0
389 TP_PORT="${DECODE_PORT}"
390 }
391
392 # Build the serve command (model flags + CMD array + LOGF). No exec.
393 # Expects role globals set by configure_prefill/configure_decode.
394 build_server_cmd() {
395 HOST_IP="${IP_ARRAY[$NODE_RANK]:-}" # own IP for --host binding, if known
396 load_model_flags
397 LOGF="${LOG_PATH}/${ROLE}_${PARALLEL_MODE}_node${NODE_RANK}_$(date +%Y%m%d_%H%M%S).log"
398 CMD=(vllm serve "${MODEL_PATH}")
399 if [[ "${WIDE_EP_MODE}" == "0" ]]; then build_tp_cmd; else build_ep_cmd; fi
400 log "model=${MODEL_PATH}"
401 }
402
403 # Foreground server run (explicit prefill/decode roles — original behavior).
404 run_server_fg() {
405 build_server_cmd
406 log "log=${LOGF}"
407 "${CMD[@]}" 2>&1 | tee "${LOGF}"
408 }
409
410 run_prefill() { configure_prefill; run_server_fg; }
411 run_decode() { configure_decode; run_server_fg; }
412
413 # ============================================================================
414 # node mode — SLURM-native rank-based self-select + rank-0 orchestration
415 # ============================================================================
416
417 # Endpoints to health-check, one per line as "ip:port", depending on mode.
418 # tp: every prefill node (:PREFILL_PORT) and every decode node (:DECODE_PORT)
419 # ep: only the masters expose an API (prefill rank 0, decode rank xP) :SERVE_PORT
420 health_endpoints() {
421 local i
422 if [[ "${WIDE_EP_MODE}" == "0" ]]; then
423 for (( i=0; i<xP; i++ )); do echo "${IP_ARRAY[$i]}:${PREFILL_PORT}"; done
424 for (( i=xP; i<xP+yD; i++ )); do echo "${IP_ARRAY[$i]}:${DECODE_PORT}"; done
425 else
426 echo "${IP_ARRAY[0]}:${SERVE_PORT}"
427 echo "${IP_ARRAY[$xP]}:${SERVE_PORT}"
428 fi
429 }
430
431 # Block until every endpoint's /health is OK, or timeout, or our local server dies.
432 wait_all_healthy() {
433 local deadline=$(( $(date +%s) + HEALTH_TIMEOUT_S ))
434 local eps; mapfile -t eps < <(health_endpoints)
435 log "health-gate: waiting on ${#eps[@]} endpoint(s): ${eps[*]}"
436 local ep
437 for ep in "${eps[@]}"; do
438 until /usr/bin/curl -sf "http://${ep}/health" >/dev/null 2>&1; do
439 (( $(date +%s) >= deadline )) && { log "TIMEOUT waiting for ${ep}"; return 1; }
440 kill -0 "${SERVER_PID}" 2>/dev/null || { log "local server (pid ${SERVER_PID}) exited while waiting"; return 1; }
441 sleep 10
442 done
443 log "healthy: ${ep}"
444 done
445 return 0
446 }
447
448 run_workload() {
449 case "${RUN_AFTER_HEALTH}" in
450 bench) run_bench ;;
451 accuracy) run_accuracy ;;
452 none|"") log "RUN_AFTER_HEALTH=none — skipping workload" ;;
453 *) die "unknown RUN_AFTER_HEALTH='${RUN_AFTER_HEALTH}'" ;;
454 esac
455 }
456
457 # rank 0: proxy + health-gate + workload, then write the completion sentinel.
458 orchestrate_master() {
459 local sentinel="$1" rc=0
460 # Front door: the proxy runs in-container (started here); the vllm-router
461 # runs as a SEPARATE container started by the SLURM job on this (rank-0) node,
462 # so in that mode we don't start anything here.
463 PROXY_PID=""
464 if [[ "${ROUTER_TYPE:-vllm-router}" == "vllm-router" ]]; then
465 log "ROUTER_TYPE=vllm-router: external router expected on gateway :${GATEWAY_PORT:-${ROUTER_PORT}} (not starting proxy)"
466 else
467 start_proxy_bg
468 fi
469 if wait_all_healthy; then
470 # If using the external vllm-router, wait for it to become ready before
471 # running the workload. The router starts as a separate container on this
472 # (rank-0) node; give it up to 300s to bind port ROUTER_PORT.
473 if [[ "${ROUTER_TYPE:-vllm-router}" == "vllm-router" ]]; then
474 local router_deadline=$(( $(date +%s) + 300 ))
475 log "waiting for vllm-router on :${ROUTER_PORT} (up to 300s)"
476 until /usr/bin/curl -sf "http://127.0.0.1:${ROUTER_PORT}/health" >/dev/null 2>&1; do
477 if (( $(date +%s) >= router_deadline )); then
478 log "FAIL: vllm-router not ready on :${ROUTER_PORT} after 300s (bring-up)"
479 rc=1
480 break
481 fi
482 sleep 5
483 done
484 (( rc == 0 )) && log "vllm-router healthy on :${ROUTER_PORT}"
485 fi
486 if (( rc == 0 )); then
487 set +e
488 run_workload
489 rc=$?
490 set -e
491 fi
492 else
493 log "FAIL: health-gate did not pass — server bring-up failed"
494 rc=1
495 fi
496 echo "${rc}" > "${sentinel}" 2>/dev/null || true
497 log "master: workload rc=${rc}; sentinel=${sentinel}; tearing down local proxy+server"
498 [[ -n "${PROXY_PID:-}" ]] && kill "${PROXY_PID}" 2>/dev/null || true
499 kill "${SERVER_PID}" 2>/dev/null || true
500 wait "${SERVER_PID}" 2>/dev/null || true
501 return "${rc}"
502 }
503
504 # non-master ranks: keep serving until the master signals completion (sentinel)
505 # or our local server dies unexpectedly.
506 watch_until_done() {
507 local sentinel="$1" rc=0
508 log "rank ${NODE_RANK} serving; waiting for completion sentinel (${sentinel})"
509 while :; do
510 [[ -f "${sentinel}" ]] && { log "completion sentinel seen; shutting down"; break; }
511 if ! kill -0 "${SERVER_PID}" 2>/dev/null; then
512 log "local server (pid ${SERVER_PID}) exited before completion"; rc=1; break
513 fi
514 sleep 10
515 done
516 kill "${SERVER_PID}" 2>/dev/null || true
517 wait "${SERVER_PID}" 2>/dev/null || true
518 return "${rc}"
519 }
520
521 run_node() {
522 NODE_RANK="${NODE_RANK:-${SLURM_PROCID:-0}}"
523 local total=$(( xP + yD ))
524 (( NODE_RANK >= 0 && NODE_RANK < total )) \
525 || die "NODE_RANK=${NODE_RANK} out of range [0,${total}) (xP=${xP} yD=${yD})"
526
527 if (( NODE_RANK < xP )); then ROLE="prefill"; configure_prefill
528 else ROLE="decode"; configure_decode
529 fi
530
531 # Shared-FS completion sentinel (LOG_PATH is shared & per-run/per-job).
532 local sentinel="${LOG_PATH}/.disagg_done"
533
534 log "node mode: rank=${NODE_RANK}/${total} role=${ROLE} master=${IS_MASTER} wide_ep=${WIDE_EP_MODE}(${PARALLEL_MODE})"
535 log "topology: xP=${xP} yD=${yD} gpus/node=${GPUS_PER_NODE} IPADDRS=${IPADDRS}"
536
537 # DRY_RUN: resolve+print the plan (role, serve cmd, endpoints) and exit 0.
538 # Validates wiring without launching vLLM / the proxy / any workload.
539 if [[ "${DRY_RUN:-0}" == "1" ]]; then
540 build_server_cmd
541 log "DRY_RUN: server cmd: ${CMD[*]}"
542 if (( NODE_RANK == 0 )); then
543 local eps; mapfile -t eps < <(health_endpoints)
544 log "DRY_RUN: health endpoints: ${eps[*]}"
545 log "DRY_RUN: post-health workload: ${RUN_AFTER_HEALTH}"
546 log "DRY_RUN: sentinel: ${sentinel}"
547 fi
548 log "DRY_RUN complete (rank ${NODE_RANK}) — no processes launched"
549 exit 0
550 fi
551
552 # aiter and flydsl keep runtime JIT/kernel caches inside the aiter install
553 # tree (…/aiter/jit and …/aiter/jit/flydsl_cache), which is read-only for the
554 # non-root (--user) container. AITER_JIT_DIR / FLYDSL_RUNTIME_CACHE_DIR
555 # redirect those to node-local /tmp (both are sanctioned overrides: aiter only
556 # points flydsl at its bundled cache when FLYDSL_RUNTIME_CACHE_DIR is unset).
557 # Seed each once from the image's bundled copy so we keep the prebuilt
558 # modules/kernels (fast, no NFS, no from-source rebuild / MLIR recompile).
559 local _aiter_src
560 _aiter_src="$(python3 -c 'import os,aiter; print(os.path.join(os.path.dirname(aiter.__file__),"jit"))' 2>/dev/null || true)"
561 if [[ -n "${AITER_JIT_DIR:-}" && ! -e "${AITER_JIT_DIR}/.seeded" ]]; then
562 if [[ -n "${_aiter_src}" && -d "${_aiter_src}" ]]; then
563 mkdir -p "${AITER_JIT_DIR}"
564 cp -a "${_aiter_src}/." "${AITER_JIT_DIR}/" 2>/dev/null || true
565 touch "${AITER_JIT_DIR}/.seeded" 2>/dev/null || true
566 log "seeded AITER_JIT_DIR=${AITER_JIT_DIR} from ${_aiter_src}"
567 else
568 log "WARN: could not locate aiter jit dir to seed AITER_JIT_DIR=${AITER_JIT_DIR}; aiter may rebuild from source"
569 fi
570 fi
571 if [[ -n "${FLYDSL_RUNTIME_CACHE_DIR:-}" && ! -e "${FLYDSL_RUNTIME_CACHE_DIR}/.seeded" ]]; then
572 mkdir -p "${FLYDSL_RUNTIME_CACHE_DIR}"
573 if [[ -n "${_aiter_src}" && -d "${_aiter_src}/flydsl_cache" ]]; then
574 cp -a "${_aiter_src}/flydsl_cache/." "${FLYDSL_RUNTIME_CACHE_DIR}/" 2>/dev/null || true
575 log "seeded FLYDSL_RUNTIME_CACHE_DIR=${FLYDSL_RUNTIME_CACHE_DIR} from ${_aiter_src}/flydsl_cache"
576 else
577 log "FLYDSL_RUNTIME_CACHE_DIR=${FLYDSL_RUNTIME_CACHE_DIR} set; no bundled flydsl_cache to seed (will JIT-compile at runtime)"
578 fi
579 touch "${FLYDSL_RUNTIME_CACHE_DIR}/.seeded" 2>/dev/null || true
580 fi
581
582 # mori JIT-compiles its shmem/all2all kernels to ~/.mori/jit/<arch>_<nic>/ on
583 # first EP use. HOME here is /workspace, bind-mounted to persistent+shared NFS
584 # (/data/$USER), so a wrong-arch cache from an earlier run/build survives and
585 # gets reloaded even after MORI_GPU_ARCHS changes -- an existing cache dir
586 # wins over the arch setting. Reloading a gfx942 shmem_kernels.hsaco on gfx950
587 # hardware fails with "device kernel image is invalid" and every EP worker
588 # dies at init (surfacing async as a torch.tensor HIP error). Guard: on EP
589 # runs, if the cache has no build for our target arch, purge it so mori
590 # recompiles for MORI_GPU_ARCHS. A matching arch dir is kept (fast reload).
591 if [[ "${WIDE_EP_MODE}" == "1" && -n "${MORI_GPU_ARCHS:-}" ]]; then
592 local _mori_jit="${HOME:-/workspace}/.mori/jit"
593 if [[ -d "${_mori_jit}" ]] && ! compgen -G "${_mori_jit}/${MORI_GPU_ARCHS}_*" >/dev/null 2>&1; then
594 log "purging stale mori jit cache ${_mori_jit} (no ${MORI_GPU_ARCHS}_* build present; forcing recompile for ${MORI_GPU_ARCHS})"
595 rm -rf "${_mori_jit}" 2>/dev/null || true
596 fi
597 fi
598
599 # Start this node's server in the background (full server log -> LOGF; this
600 # shell's stdout stays free for orchestration logs the srun/CI captures).
601 build_server_cmd
602 "${CMD[@]}" >"${LOGF}" 2>&1 &
603 SERVER_PID=$!
604 log "server started pid=${SERVER_PID} log=${LOGF}"
605
606 local rc=0
607 if (( NODE_RANK == 0 )); then
608 orchestrate_master "${sentinel}" || rc=$?
609 # Canonical, human-readable end-of-run verdict for the login-node poller
610 # and CI logs (the .disagg_done sentinel remains the machine authority).
611 log "VERDICT: $( (( rc == 0 )) && echo PASS || echo FAIL) rc=${rc}"
612 else
613 watch_until_done "${sentinel}" || rc=$?
614 fi
615 log "node rank=${NODE_RANK} exiting rc=${rc}"
616 exit "${rc}"
617 }
618
619 # ============================================================================
620 main() {
621 parse_args "$@"
622 load_config
623 resolve_topology
624 case "${ROLE}" in
625 node) run_node ;;
626 proxy) run_proxy ;;
627 bench) run_bench ;;
628 accuracy) run_accuracy ;;
629 prefill) run_prefill ;;
630 decode) run_decode ;;
631 *) usage ;;
632 esac
633 }
634
635 main "$@"