Conversation
… selector
The xPyD harness already models pools that span multiple nodes (xP/yD count
NODES, roles are MASTER/CHILD via --data-parallel-start-rank + --headless), but
the wideEP path hardcoded TP=1: dp_size was xP*GPUS_PER_NODE and the connector
emitted a literal `-tp 1`. That makes any model whose REPLICATED (non-expert)
weights exceed one GPU inexpressible - e.g. Kimi-K3 on MI300X, where TP1/DP16
needs 190.7 GiB/GPU (> 192 GB HBM) and TP2 is required to fit.
- vllm_disagg.sh: topology math is TP-aware. dp_per_node = GPUS_PER_NODE/TP_SIZE
feeds dp_size, dp_size_local and both start-rank calculations. TP_SIZE defaults
to 1 and is validated to divide GPUS_PER_NODE.
- moriio.sh: emit -tp ${TP_SIZE}; clamp --api-server-count to dp_size (it must be
<= data-parallel-size or the frontend DP balancer routes to ranks that do not
exist - only reachable once TP>1 shrinks dp_size below GPUS_PER_NODE).
- moriio.sh: advertise the peer pool's node IPs as moriio_pod_hosts when a pool
spans >1 node. Without it the connector falls back to the peer MASTER only and
KV writes aimed at ranks on a peer CHILD node silently miss, so those ranks
decode with no context. Emitted only when xP>1 || yD>1.
- run_xPyD_models.slurm: forward TP_SIZE/GPUS_PER_NODE, wire BENCHMARK_SCRIPT=niah
to the already-present benchmark_niah.sh, forward NIAH_* knobs.
Default-neutral: at xP=1 yD=1 TP_SIZE=1 - the topology every registered entry
uses - DRY_RUN output is byte-identical to develop across all four node roles.
At xP=2 yD=2 TP_SIZE=2 it emits tp=2, dp_size=8, dp_local=4, child start-rank 4
(= EP16 per pool over 16 GPUs).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…Kimi-K3 recipe
Two parts.
1) moriio.sh wideEP dropped ${model_args[@]} on the floor. models.yaml documents
per-model dp: tuning as supported ("both connectors now append it") and rixl's
deepep path does append it, but the moriio wideEP branch parsed the flags,
logged them, and then emitted an argv without them. Verified with a sentinel
flag: present in the log line, absent from the command. Now appended, matching
rixl. Every existing wideEP model has an empty dp: block, so emitted argv is
unchanged for all of them.
2) Kimi-K3 recipe, entirely as data: env: block for the gfx942 knobs
(VLLM_ROCM_USE_AITER_MLA=0 - the AITER MLA kernel is gfx950-only -
AITER_SITUV2_A8W4=1 for the packed-int4 SiTUv2 MoE path, KDA conv state layout,
40e9 KV cache for >600K contexts, per-role cudagraph + MoRI backends), and a
dp: block for the serve flags the connector does not emit (reasoning parser,
1M max-model-len, batched-tokens pinned at 2048, MoE quantization-config).
Registered in MORI_EP_VALID_MODELS and WIDE_EP_ONLY_MODELS.
The JSON quantization-config carries its own shell quotes: the launcher
word-splits dp: via eval, which would otherwise strip the JSON's double quotes
and hand vLLM invalid JSON. Verified the emitted value parses as JSON.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docker/pyt_vllm_kimi_k3_mi300x.ubuntu.amd.Dockerfile
The shared vllm_disagg_inference stack with the pins K3 needs on gfx942
(MoRI 1.2.2, AITER 0.1.19 + flydsl 0.2.4, the K3+MoRIIO vLLM commit) plus the
K3-aware AITER graft from the public vendor image - without that graft the K3
MoE profiling shape finds no tuned FlyDSL config, falls back to a heuristic
kernel and aborts LLVM inside determine_available_memory.
Differences from the recipe this is ported from:
- every source pinned to an immutable SHA, not a fork branch name (branches on
personal forks can be force-pushed; MAD needs the image rebuildable later)
- GH_TOKEN build-arg dropped: all three repos are public, and a token passed
this way is recorded in image metadata
- the flydsl 0.2.4 re-pin happens at build time, so the launcher no longer runs
pip install inside every container at serve time
- WITH_NIXL defaults to 0 (K3 uses the moriio connector only)
- no runtime patchers: all connector/KDA fixes are committed in the pinned vLLM
scripts/vllm_multinode/
Colocated (single-instance) multi-node serving - the counterpart to
vllm_dissag, for models that do not fit one node but want lowest single-request
latency rather than disagg throughput. TP within a node, PP across nodes.
It owns only node discovery, the container launch and the head/worker split;
models.yaml, socket_barrier.py, benchmark_xPyD.sh, benchmark_niah.sh and
parse_to_csv.py are reused from vllm_dissag (scripts/ is mounted whole), so the
model recipe and the CSV pipeline have one home.
The three K3 colocated variants collapse to this one script: dry-run confirms
pp2xtp8 / allgather / moriep argv differ only by --enable-expert-parallel and
--all2all-backend, so they become three models.json entries rather than three
near-identical run.sh copies.
Not built here - this environment has no docker and one 8-GPU node; the harness
is verified by DRY_RUN argv inspection only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
models.json: four entries, all skip_gpu_arch gfx950 (the existing four Kimi-K3 entries are gfx950/TP8 single-node and all carry skip_gpu_arch gfx942, so K3 is currently skipped entirely on MI300X - this is that gap): pyt_vllm_disagg_mori_kimi-k3 -N 4 xP2 yD2 TP2 -> EP16/pool pyt_vllm_kimi-k3_mi300x_pp2xtp8 -N 2 TP8 x PP2, no EP pyt_vllm_kimi-k3_mi300x_wideep_allgather -N 2 + EP, allgather_reducescatter pyt_vllm_kimi-k3_mi300x_wideep_moriep -N 2 + EP, mori_low_latency The three colocated entries differ only in ENABLE_EP / ALL2ALL_BACKEND / AITER_SITUV2_A8W4, which is why they share one launcher. Result reporting: BENCHMARK_SCRIPT=niah previously produced logs and nothing else, so a model declaring multiple_results would have reported no metric. - parse_to_csv.py gains --niah: parses benchmark_niah.py output and writes the same 29-column madengine perf.csv as the throughput path (verified identical column sets). One row per context size, performance = needles found /10. A size that errored is written as a FAILURE row with performance 0 rather than dropped, so a pass->crash regression is visible instead of silent. - benchmark_niah.sh calls it. - Both slurm launchers copy /run_logs/$SLURM_JOB_ID/perf.csv to ./perf_$MODEL_NAME.csv on completion: madengine resolves multiple_results against the job directory it launched from, not the container log mount. No-op for entries that do not declare multiple_results. Verified: madengine discover goes 133 -> 137 with exactly these four added and none removed; every referenced dockerfile and script exists; -N matches each topology; the int4 quantization-config survives shell word-splitting as valid JSON; DeepSeek moriio and rixl/deepep DRY_RUN output is byte-identical to develop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ports the writeups from PR ROCm#193 next to the existing gfx950 K3 doc, keyed to the madengine tags rather than to shell scripts: topology rationale (why MI300X needs multi-node and why the disagg pool needs TP2), the gfx942 knobs and where each one lives now, the NIAH results, and the three root-cause fixes. Corrections carried in: - the NIAH ceiling is 900K throughout. The upstream READMEs still said 300K in seven places after two later commits raised it to 500K and then 900K. - states that the NIAH harness sizes context in WORDS (~1.3x tokens) while the results table is in tokens, so the two columns are not comparable. benchmark/kimi_k3/README.md gains a pointer: all four entries there carry skip_gpu_arch gfx942, which is exactly the gap the MI300X page fills. Every tag, node count and knob value in the new page is checked against models.json and models.yaml. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…metadata The four MI300X Kimi-K3 entries could not run correctly on a cluster. Three separate defects, all from the colocated launcher inheriting conventions that only hold for the disaggregated one. Node sizing. The entries carried `"args": "-N 4 -n 4"` on the assumption that madengine forwards args to sbatch. It does not — args are appended to `bash <model>.slurm`, and neither launcher parses $@, so the flags were dropped and the job was submitted with the `slurm.nodes` default of 1. Sizing now uses `distributed.nnodes`, which madengine reconciles into `#SBATCH --nodes`, and the inert args are removed. `slurm.nodes` is deliberately left unset: setting it selects the multi-node preset, whose `NCCL_SOCKET_IFNAME=eth0` is forwarded into the container by run_xPyD_models.slurm and would override the fabric interface on an RDMA cluster. `slurm.time` is set explicitly instead, since the single-node preset's 12 h default is short for a full NIAH sweep. NIAH model tag. benchmark_niah.sh requested MODEL_PATH as the model name, correct for the disagg path where vLLM defaults served_model_name to the serve argument. serve_colocated.sh passes `--served-model-name "$MODEL_NAME"`, so every request 404'd and all three colocated entries — which default to BENCHMARK_SCRIPT=niah — would have recorded a full sweep of FAILURE rows. The harness now honors NIAH_MODEL when a launcher sets it, and the colocated launcher sets it to the tag it actually serves. Colocated run metadata. parse_to_csv derived nnodes, n_gpus, deployment_type and tags from xP/yD. serve_colocated.sh exports xP=1 yD=0 only so the shared log filenames stay unique, so a 2-node 16-GPU colocated run reported itself as a 1-node 8-GPU `disagg_1P0D` with a nixl backend it never used. Node count now comes from NNODES (exported by both launchers), and a launcher whose shape is not "xP prefill + yD decode" states its own identity via PERF_DEPLOYMENT_TYPE / PERF_TAGS. The disagg path is byte-identical to before. Also corrects the README, which documented the args-to-sbatch behavior that does not exist and a quick start that cannot work now that the "<supply-your-image>" placeholder is rejected rather than pulled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
NUM_NODES is derived from the requested topology (xP+yD), not from the allocation, and the node list was then truncated with `head -n $NUM_NODES`. A short allocation therefore produced a silently wrong topology: pools were built from nodes that were never allocated, and the run failed much later as a connector handshake timeout with no indication of the real cause. This mattered little while these models were always launched from a matching `salloc`, but the sbatch path now sizes the allocation from the model card, so a mismatch between card and cluster is a reachable state. Check up front and name the three ways to fix it. The colocated launcher already fails on the equivalent mismatch via its TP*PP == NNODES*GPUS check, so it needs no counterpart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ntract parse_to_csv.py hand-wrote a full 29-column perf.csv, assembling node counts, GPU counts, launcher, image and tags from the environment. Most of that is not the workload's to know: madengine already owns it, and the guesses were wrong for the colocated launcher, which sets xP=1 yD=0 only to keep log filenames unique. The NIAH writer now emits a narrow CSV — model, performance, metric, status, plus descriptive columns — and madengine merges in the run metadata via the entries' `multiple_results` declaration. The columns that genuinely describe the workload's own configuration rather than its placement (tp, pp, ep_backend, prefill_decode) move to descriptive columns, matching how scripts/vllm/run_vllm.py already reports tp/dtype/bs on the templated path. Nothing is lost; it is relocated to whichever side actually knows it. `status` stays explicit: an errored context size scores 0, and deriving status from performance would file that real failure as a SUCCESS. Scoped to leave every other workload alone. The NIAH writer is reachable only by the four Kimi entries, which all declare `multiple_results`. The throughput writer keeps its full-schema output by default, because the twelve disagg cards that use it declare no `multiple_results` and madengine reads their CSV directly with no metadata to merge — a narrow CSV there would drop every descriptive column. Their output is byte-identical; `--narrow` is the opt-in for migrating one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…uted.nnodes
madengine reads only slurm.nodes when emitting `#SBATCH --nodes`
(deployment/slurm.py: `self.nodes = slurm_config.get("nodes", 1)`), and
nothing maps distributed.nnodes across — build_orchestrator copies nnodes
into deployment_config.distributed for launcher detection only. The four
MI300X model cards carried nnodes but no slurm.nodes, so every one of them
would have been submitted as a 1-node job. The launcher's
TP*PP == NNODES*GPUS_PER_NODE assertion catches it, but only after the
allocation is granted.
Add nodes/gpus_per_node to the four slurm blocks, and ship the site configs
that carry the values a model card cannot:
- results_dir, absent from the key list madengine copies out of models.json,
and required because the slurm_multi collector globs results_dir for
perf*.csv rather than resolving multiple_results
- MODEL_DIR / LOG_PATH, which otherwise default to /shared_inference
*.json is gitignored repo-wide, so the templates need explicit negations or
they never reach a clone.
Correct the two README claims that did not match madengine's behavior
(nnodes-sizes-the-allocation, multiple_results-is-resolved-first), document
that --account/--qos are unwired for slurm_multi and need SBATCH_ACCOUNT,
and drop --keep-model-dir from the SLURM examples — it is a local-Docker
flag that madengine ignores with a warning on SLURM.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four defects, each found by running the recipes end to end on a real
MI300X SLURM cluster for the first time. None of them could have survived
a single successful run, which is consistent with these cards never having
been executed through madengine.
1. Build was not reproducible. Every source is pinned to a commit SHA, but
pip resolves build dependencies in an isolated environment from PyPI at
build time, so nothing pinned the toolchain. setuptools >= 80 added
assert isinstance(self.compiler, CCompiler)
to distutils' build_ext.build_extension, which MoRI's legacy
Cython.Distutils.build_ext path violates, so the amd_mori wheel failed
with "AssertionError: run() must precede build_extension()" while every
pinned SHA was still correct (job 223849). PIP_CONSTRAINT is the only
mechanism that reaches inside pip's build isolation -- installing
setuptools in the image does not. Set globally so the AITER, vLLM and
router stages cannot regress the same way. Build then completed in
19 minutes, all 18 stages (job 223872).
2. RDMA library mounts used -e, which is true for directories. Docker
creates a DIRECTORY at a bind-mount source that does not exist, so the
first run on a node lacking e.g. libionic.so.1 leaves an empty directory
behind, and every later run on that node mounts a directory over a file
inside the image:
OCI runtime create failed: ... not a directory
Self-propagating and per-node, so it presents as intermittent. The
surviving node then waits at socket_barrier.py forever for a peer that
already died -- 4h10m of "Waiting for nodes. . ." before the wall clock
(job 223909). Use -f so only regular files are mounted.
3. pyt_vllm_kimi-k3_mi300x_pp2xtp8 omitted the gfx942 MoE requirement that
the two wideep variants carry. gfx942 has no scaled-MXFP4 MFMA and the
a16w4 SiTUv2 heuristic FlyDSL kernel cannot codegen there, so the MoE
must be requantized to packed int4. Without it the worker aborts inside
determine_available_memory with
LLVM ERROR: Do not know how to expand this operator's operand!
and quantization_config=None in the engine config (job 224132). This is
a hardware requirement, not an EP-specific tuning, so all three
colocated cards now carry AITER_SITUV2_A8W4=1 and the int4 config.
4. A run that produced no results exited 0. The launchers warned about a
missing perf CSV and returned success anyway, so madengine recorded
exit_code=0 in its completion marker and the collector reported
"0 successful, 0 failed" -- a hard engine crash was indistinguishable
from a clean run. For a benchmark repo that is the worst possible
reporting outcome. Both launchers now exit 1.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each of these cost hours to diagnose on a real cluster, and in every case the symptom is a long way from the cause -- an intermittent multi-node hang that is actually a poisoned bind-mount path, a wheel build that fails while every pinned SHA is correct, an LLVM abort that is a missing quantization flag. Record them so the next person reads instead of rediscovers. Also record the cluster facts a model card structurally cannot carry: the SLURM association wall-time cap (which silently parks a job as PENDING rather than failing), account requirements, docker on compute nodes, and the fact that a registry-less cluster has no supported path to distribute an image -- --build-on-compute requires --registry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lsifiable
Both found on the first end-to-end MI300X run (job 224200), which served
Kimi-K3 and produced results -- but results that could not be trusted, from
a job that then would not exit.
Deadlock on teardown. serve_colocated.sh has the head kill only its own
server and exit, while every worker sits in `wait` on a headless vLLM that
nothing stops. srun waits on those tasks, so the job holds all nodes until
the wall clock: 4.5 hours of two idle exclusive MI300X nodes AFTER the
benchmark had already written perf.csv. The head now raises a sentinel on
the shared log volume and workers watch for it. The sentinel is raised from
an EXIT trap so a head that fails or times out cannot strand workers either.
NIAH could not distinguish truncation from retrieval failure. Scoring reads
content + reasoning_content, and K3 is a reasoning model served with
--reasoning-parser kimi_k3, so a trace that exhausts max_tokens is cut off
and only the earliest needles survive. At the old 2048 default, two of four
context sizes scored 1/10 and both listed exactly ANIMALS[0] -- the needle
placed first in the haystack. That signature is a truncated trace, not a
model failure, but finish_reason was never recorded so the two were
indistinguishable, and every row was written SUCCESS regardless.
- record finish_reason and print it as finish=<reason>
- raise the default max_tokens to 8192
- keep the needle count as the metric, always reported, never dropped
- mark a truncated row FAILURE and annotate the metric, so a measurement
artifact cannot enter the results as if it were a model result
9/10 stays SUCCESS: the README documents ~9/10 at >=20K as a known RDMA
residual, so a strict 10/10 gate would flag known-acceptable behaviour.
Logs predating the finish= field still parse.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds Kimi-K3 MI300X support by integrating a new colocated multi-node vLLM launcher alongside updates to the existing disaggregated (prefill/decode) vLLM harness, plus reporting/benchmarking and documentation updates to make results consumable by madengine.
Changes:
- Introduces a 2-node colocated vLLM SLURM+container launcher (
vllm_multinode) with shared-model recipe support and shutdown coordination. - Extends the disaggregated launcher path to support
TP_SIZE>1, multi-node pool host advertisement for MoRIIO, and NIAH benchmarking/reporting. - Adds Kimi-K3 model recipes/cards, MI300X-specific docs/site-config templates, and a pinned Dockerfile for the K3 MI300X image.
Reviewed changes
Copilot reviewed 15 out of 16 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| scripts/vllm_multinode/serve_colocated.sh | Per-node in-container entrypoint for colocated multi-node vLLM serving + benchmarking. |
| scripts/vllm_multinode/run_multinode.slurm | SLURM launcher that discovers nodes/IPs, runs per-node containers, and publishes perf CSV. |
| scripts/vllm_dissag/vllm_disagg.sh | Adds TP-aware topology math and peer-pool host export for KV connector. |
| scripts/vllm_dissag/run_xPyD_models.slurm | Adds Kimi-K3 support, TP_SIZE plumbing, NIAH option, safer RDMA mounts, perf CSV publish. |
| scripts/vllm_dissag/parse_to_csv.py | Adds narrow-schema perf CSV mode, NNODES-aware metadata, and NIAH parsing/output. |
| scripts/vllm_dissag/models.yaml | Adds Kimi-K3 MI300X serving recipe env + flags (MXFP4/int4 MoE path). |
| scripts/vllm_dissag/connectors/moriio.sh | Adds multi-node pod host list to KV config; TP-aware api-server-count and tp flag. |
| scripts/vllm_dissag/benchmark_niah.sh | Uses NIAH_MODEL when set; emits perf.csv for NIAH via parse_to_csv.py. |
| scripts/vllm_dissag/benchmark_niah.py | Raises default max_tokens, records finish_reason, and prints parseable result lines. |
| models.json | Adds Kimi-K3 MI300X disagg + colocated model-card entries with multiple_results CSV. |
| docker/pyt_vllm_kimi_k3_mi300x.ubuntu.amd.Dockerfile | New pinned build for Kimi-K3 MI300X (MoRI/AITER/vLLM/router + graft step). |
| benchmark/kimi_k3/README.md | Notes gfx942 requires multi-node sharding and points to MI300X recipes. |
| benchmark/kimi_k3/mi300x/slurm-config.disagg.json | Site-config template for 4-node disaggregated MI300X runs. |
| benchmark/kimi_k3/mi300x/slurm-config.colocated.json | Site-config template for 2-node colocated MI300X runs. |
| benchmark/kimi_k3/mi300x/README.md | Full MI300X recipe documentation, tradeoffs, and madengine integration notes. |
| .gitignore | Ensures MI300X site-config templates are not ignored. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| if [[ -n "${COLOCATED_EXTRA_ARGS:-}" ]]; then | ||
| eval "extra_args=(${COLOCATED_EXTRA_ARGS})" | ||
| serve_args+=("${extra_args[@]}") | ||
| fi |
| docker rm -f $DOCKER_CONT_NAME 2>/dev/null || true | ||
| fuser -k 2223/tcp 2>/dev/null || true | ||
| sleep 2 |
Job 239755 (Kimi-K3, 2x8 on oci-64) died in vLLM's startup barrier. Node 0 finished loading weights at 23:21:41 and entered in_the_same_node_as -> torch.distributed.barrier; node 1 was still constructing MoE layers ~50 min later. gloo gave up at 23:51:41, exactly 1800s in, and the master's TCPStore died with it, so node 1 then failed with "Broken pipe" to :29500. The barrier is all-or-nothing across all 16 ranks, so what has to stay small is the GAP between nodes, not the absolute load time. Five problems, addressed here: 1. --distributed-timeout-seconds does not reach the barrier that failed. It feeds ParallelConfig.distributed_timeout_seconds, which only configures the device/NCCL groups. The startup barrier runs on the gloo CPU group, built by GroupCoordinator from the separate cpu_distributed_timeout_seconds field. Unset, it falls back to PyTorch's stock 1800s -- which is exactly the deadline in the traceback, despite 7200 being passed. Pass --cpu-distributed-timeout-seconds too. 2. Cold AITER JIT compile on every rank, every run. The image points AITER_JIT_DIR/TRITON_CACHE_DIR/VLLM_CACHE_ROOT/COMGR_CACHE_DIR at /opt/vllm_cache, but this launcher mounted only /tmp/vllm_cache, a path nothing reads, inside --rm containers. Mount a host-persistent cache at /opt/vllm_cache keyed by image ID, as vllm_dissag/run_xPyD_models.slurm already does. Per-node cold compile is precisely the skew the barrier cannot absorb. 3. WORKER_PID was tee's PID. After `a | b &` the shell reports b, so every kill in this script hit tee and left vLLM running, and no liveness check was possible. Launch through process substitution instead. 4. A dead engine was indistinguishable from a slow one. The head polled the log for the full 4000s after its writer had already died, then reported a timeout rather than the traceback. Both wait loops now check liveness -- via a helper that also rejects zombies, since an exited-but-unreaped child still answers kill -0. This also fixes a latent hang where a worker would spin forever on a dead engine. 5. Container env was a closed allowlist. Tuning any of the above from CI meant a new -e line and another MAD PR. COLOCATED_FORWARD_ENV names the variables to forward, and they are passed via docker --env-file so values containing spaces survive. The file is node-local, mode 600, and unlike the wrapper scripts is never archived as a build artifact. Also adds an opt-out checkpoint page-cache pre-warm before the container barrier (PREWARM_CHECKPOINT=0 to skip). Its per-node duration is the real diagnostic: a large spread means the storage path is the problem and no timeout will fix it. Verified: bash -n clean on both files, and on the single-quoted srun body extracted in isolation; env-file forwarding preserves spaces and skips unset vars at mode 600; the liveness helper correctly reports an unreaped child as dead; DRY_RUN=1 emits the new flag and honours an override, with COLOCATED_EXTRA_ARGS still appended last. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
The new run_multinode.slurm launcher has verified set -u-triggered failure paths (optional env expansion and hardcoded barrier port) that can prevent runs from starting or make barrier cleanup inconsistent.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
scripts/vllm_multinode/run_multinode.slurm:158
- The barrier port is configurable via
CONTAINER_BARRIER_PORT(and serve_colocated.sh honors it), but thisfusercall always kills 2223. If the port is overridden, stale listeners on the configured port won’t be cleared and the barrier can hang/fail. Use the same${CONTAINER_BARRIER_PORT:-2223}default here.
fuser -k 2223/tcp 2>/dev/null || true
- Files reviewed: 15/16 changed files
- Comments generated: 1
- Review effort level: Lite
Job 240624 spent its entire 7200s allocation in the pre-warm added by the previous commit. Both nodes printed "[prewarm] reading ..." and neither ever printed "[prewarm] done", so vllm serve was never launched -- the run timed out having tested nothing. The flaw: it reads the WHOLE checkpoint on EVERY node, while PP2xTP8 means a node only ever loads its own shard. On a 1453 GiB checkpoint that is roughly 4x the necessary I/O against a single NFS export, with both nodes competing for it. It manufactured exactly the contention it was meant to relieve. Default it to off. It stays available, because the per-node duration is a useful storage probe -- a large spread between nodes means the storage path is the problem and no timeout will fix it -- but it is now opt-in via PREWARM_CHECKPOINT=1 and bounded by PREWARM_TIMEOUT_SECONDS (default 900). On expiry it warns that the filesystem could not deliver the checkpoint in the window and proceeds, rather than burning the whole allocation. Note this run did not exercise --cpu-distributed-timeout-seconds: vLLM never reached the startup barrier, so the absence of the 1800000ms gloo timeout in the log says nothing either way. That fix is still untested. Verified: default unset is silent; PREWARM_CHECKPOINT=1 runs and reports its duration; a stalled read hits the budget, warns, and exits 0 so startup continues. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
There are a few correctness/robustness blockers in the new launchers (per-pool host list logic, unsafe eval on args, and a hard-coded barrier port/kill behavior) that should be addressed before relying on these scripts in CI or shared clusters.
Review details
Suppressed comments (5)
Previously missed (3) — in code that hasn't changed since the last review.
scripts/vllm_dissag/vllm_disagg.sh:137
PREFILL_POD_HOSTS/DECODE_POD_HOSTSare currently populated whenever either pool spans >1 node. This causes single-node pools (e.g., xP=2,yD=1 or xP=1,yD=2) to still export a non-empty peer list for the 1-node side, which defeats the stated goal of keeping the emitted KV config byte-identical for single-node pools (and can change connector behavior unexpectedly). Populate each host list only when that specific pool spans >1 node.
scripts/vllm_multinode/run_multinode.slurm:79run_multinode.slurmsupportsBENCHMARK_SCRIPT=long_contextandBENCHMARK_SCRIPT=niah, but the earlyREQUIRED_FILEScheck only validatesbenchmark_xPyD.sh. This means a missing long-context or NIAH script will fail later (inside the container) with a less actionable error. Validate all referenced benchmark scripts up front.
scripts/vllm_multinode/run_multinode.slurm:247- If
CONTAINER_BARRIER_PORTis set in the launcher (e.g., to avoid collisions), it is not currently passed into the container environment, soserve_colocated.shwill still default to 2223 inside the container. PassCONTAINER_BARRIER_PORTviadocker run -eso the host-side choice is honored in-container.
scripts/vllm_multinode/serve_colocated.sh:113
COLOCATED_EXTRA_ARGSis expanded witheval, which allows arbitrary command execution if the environment value is ever influenced by untrusted input (e.g., CI, wrapper scripts, or user-provided model cards). This can be avoided by using a non-eval parser (e.g., Pythonshlex.split) to safely produce argv tokens.
if [[ -n "${COLOCATED_EXTRA_ARGS:-}" ]]; then
eval "extra_args=(${COLOCATED_EXTRA_ARGS})"
serve_args+=("${extra_args[@]}")
fi
scripts/vllm_multinode/run_multinode.slurm:160
- The container barrier port is hard-coded to 2223 and the script unconditionally runs
fuser -k 2223/tcp. This can kill unrelated processes on non-exclusive nodes and also makes concurrent runs on the same nodes fragile due to port collisions. Consider deriving a job-unique barrier port fromSLURM_JOB_IDand using that for both thefusercleanup and the in-container barrier.
srun --nodelist="$SELECTED_NODELIST" bash -c '
echo "Rank $SLURM_PROCID on $(hostname)"
docker rm -f $DOCKER_CONT_NAME 2>/dev/null || true
fuser -k 2223/tcp 2>/dev/null || true
sleep 2
docker pull $DOCKER_IMAGE_NAME 2>/dev/null || true
- Files reviewed: 15/16 changed files
- Comments generated: 0 new
- Review effort level: Lite
Job 432788 died six seconds in:
run_multinode.slurm: line 272: _RDMA_MOUNTS: unbound variable
naming a variable assigned, plainly, at line 206 -- inside the same srun body.
The whole body is passed to the compute node as a single-quoted string:
srun --nodelist="$LIST" bash -c '
...
'
and a comment I added during the cluster.sh refactor read "cluster.sh's helper".
That apostrophe closed the string at line 220. Everything after it -- including
$_RDMA_MOUNTS, $_NVME_MOUNT and $_JIT_CACHE_MOUNT -- was then expanded by the
BATCH shell, which has set -u and no such variables. The error prefix said so:
it named run_multinode.slurm, not "bash: -c", but that only helps if you notice.
Not a syntax error, so bash -n accepted it: the quotes re-balanced later in the
file. Comment rewritten without the apostrophe, and a note left in the block
saying why none may appear there.
Added a check for the class. It scans every single-quoted `bash -c` body for an
apostrophe that closes it early, while accepting the two legitimate forms: the
'"$VAR"' interpolation idiom, and a closing line carrying positional arguments
(bash -c '...' _ "$X" "$Y"), which is how serve_colocated.sh passes its prewarm
arguments. Across all 55 shell files in scripts/ it reports zero problems, and
against the parent commit it reports exactly this one.
What the run did prove, before it hit this: the weight probe found Kimi-K3 on
local NVMe on both allocated nodes and selected it over NFS, and the forwarded
env arrived (fwd-env: LOG_WAIT_TIMEOUT_SECONDS). Both fixes work.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Critical launch, orchestration, and benchmark-reporting issues remain unresolved.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (10)
Previously missed (1) — in code that hasn't changed since the last review.
scripts/vllm_dissag/connectors/moriio.sh:278
- The wide-EP command expands
model_argsat both lines 275 and 278, so every model-specific flag is passed twice. This makes the generated argv harder to reason about and can cause last-occurrence-wins behavior if a recipe later contains a non-idempotent option; keep a single expansion here.
docker/pyt_vllm_kimi_k3_mi300x.ubuntu.amd.Dockerfile:83
- The Dockerfile says every source is pinned to immutable commits, but the default AITER donor is a mutable registry tag. Rebuilding this image can silently graft different Python packages even when all repository SHAs are unchanged, defeating the reproducibility guarantee. Pin this
FROMsource by digest (or record and verify the expected image digest).
ARG PROVEN_K3_IMAGE=amdsiloai/vllm:kimi-k3-mi325x-release-v2
docker/pyt_vllm_kimi_k3_mi300x.ubuntu.amd.Dockerfile:291
- The optional
WITH_NIXL=1build is also not reproducible despite the pinning claim: rocSHMEM checks out the movingdevelopbranch and DeepEP checks out its default branch with no ref at all. A future opt-in build can therefore change independently of this PR; pin both repositories to immutable commits (and record the refs inversions.txt).
cd rocm-systems && git sparse-checkout set --cone projects/rocshmem && git checkout develop && \
mkdir -p /tmp/rocshmem-build && cd /tmp/rocshmem-build && \
/tmp/rocm-systems/projects/rocshmem/scripts/build_configs/all_backends \
-DUSE_EXTERNAL_MPI=OFF -DGPU_TARGETS="${GFX_COMPILATION_ARCH}" && \
cd /tmp && git clone https://github.com/ROCm/DeepEP.git && cd DeepEP && \
scripts/sglang_disagg/run_xPyD_models.slurm:263
cluster_resolve_model_pathcan select a site-specificMODEL_DIRorMODEL_DIR_CANDIDATES, but the container below only mounts/shared_inferenceand/mnt/m2m_nobackup. With a custom resolved root, the host-side probe succeeds while every container sees a nonexistentMODEL_PATH, so model loading fails; mount the resolved model root (or reject paths outside the mounted roots) before starting the ranks.
cluster_resolve_model_path || exit 1
scripts/vllm_dissag/benchmark_niah.sh:44
- The wrapper still injects
NIAH_MAXTOK=2048when the variable is unset, so the new Python default of 8192 is never used in launcher runs. The Kimi-K3 cards do not setNIAH_MAXTOK, leaving reasoning traces truncated and producing the false retrieval misses this change was meant to diagnose. Align this wrapper default with the Python default (or set it explicitly on the K3 cards).
NIAH_MODEL="${NIAH_MODEL:-${MODEL_PATH}}" \
NIAH_WORDS="${NIAH_WORDS:-2000,8000,20000,35000}" \
NIAH_SEEDS="${NIAH_SEEDS:-0,1,2}" \
NIAH_MAXTOK="${NIAH_MAXTOK:-2048}" \
scripts/vllm_dissag/run_xPyD_models.slurm:503
- This call has the same separate-shell problem: the
srun ... bash -cbody has not sourcedcommon/cluster.sh, socluster_mark_diris undefined and the command fails on every node instead of writing cache metadata. Source or inline the helper before calling it.
cluster_mark_dir "$_JIT_CACHE_HOST" "JIT/Triton/AITER kernel cache for one docker image; rebuilt on demand"
scripts/vllm_dissag/run_xPyD_models.slurm:263
cluster_resolve_model_pathcan select a site-specificMODEL_DIRorMODEL_DIR_CANDIDATES, but the container below only mounts/shared_inferenceand/mnt/m2m_nobackup. With a custom resolved root, the host-side probe succeeds while every container sees a nonexistentMODEL_PATH, so model loading fails; mount the resolved model root (or reject paths outside the mounted roots) before starting the ranks.
NIXL_REPO_DIR=$(pwd)
scripts/vllm_multinode/run_multinode.slurm:285
- The site configuration allows
MODEL_DIR/MODEL_DIR_CANDIDATESoutside the two default roots, and the host-side probe can successfully select such a path. The container then mounts only fixed/shared_inferenceplusNVME_ROOT, so an explicit model under another root is invisible inside Docker and startup fails; bind the resolved model parent (or the configured shared roots) as well.
-v /shared_inference:/shared_inference \
scripts/vllm_multinode/run_multinode.slurm:256
cluster_mark_diris defined only in the batch shell that sourcedcommon/cluster.sh; this command runs in the separatesrun bash -cshell and therefore returnscommand not foundon every node. Because the inner shell is notset -e, the job continues but the promised CACHEDIR.TAG/README.ci metadata is never created. Source the helper inside this shell or inline the marker logic.
cluster_mark_dir "$_JIT_CACHE_HOST" "JIT/Triton/AITER kernel cache for one docker image; rebuilt on demand"
scripts/vllm_multinode/serve_colocated.sh:94
- This comment says an unset timeout falls back to PyTorch's 1800-second default, but both argv entries explicitly use
:-7200(andcluster.shalso defaults them to 7200). Operators following the comment will underestimate the launcher’s actual wait; document the 7200-second entrypoint default instead.
# Separate field from the one above: --distributed-timeout-seconds only
# reaches the device/NCCL groups, while the startup barrier that has been
# killing colocated runs here is on the gloo CPU group and takes its deadline
# from this one. Unset, both fall back to PyTorch's stock 1800s.
--cpu-distributed-timeout-seconds "${CPU_DISTRIBUTED_TIMEOUT_SECONDS:-7200}"
- Files reviewed: 27/29 changed files
- Comments generated: 15
- Review effort level: Lite
| libibverbs-dev rdma-core strace libgflags-dev \ | ||
| libaio-dev liburing-dev libcpprest-dev libgrpc-dev libgrpc++-dev \ | ||
| libprotobuf-dev protobuf-compiler-grpc wget && \ | ||
| pip install meson==0.64.0 "pybind11[global]" pyyaml && \ |
| export TP_SIZE="${TP_SIZE:-${GPUS_PER_NODE}}" | ||
| export PP_SIZE="${PP_SIZE:-1}" |
| config_match = re.search(r'RUNNING: prompts\s+(?:\d+\s+)?isl\s+(\d+)\s+osl\s+(\d+)\s+con\s+(\d+)', prev_section) | ||
| if config_match: | ||
| current_input_seq_len = int(config_match.group(1)) | ||
| current_output_seq_len = int(config_match.group(2)) | ||
| current_concurrency = int(config_match.group(3)) |
| counts = [c for c, _ in vals] | ||
| n_trunc = sum(1 for _, f in vals if f == "length") | ||
| mean = sum(counts) / len(counts) | ||
| extra = (" [%d timeout/err excluded]" % n_to) if n_to else "" | ||
| if n_trunc: |
| python3 "${DIR}/parse_to_csv.py" "${LOG}" --niah \ | ||
| --perf-csv "/run_logs/${SLURM_JOB_ID}/perf.csv" \ | ||
| --model-name "${MODEL_NAME}" 2>&1 | tee -a "${LOG}" |
| mkdir -p /run_logs/${SLURM_JOB_ID} | ||
| SHARED_DIR=$MAD_SCRIPTS_MOUNT/vllm_dissag \ | ||
| bash $MAD_SCRIPTS_MOUNT/vllm_multinode/serve_colocated.sh \ | ||
| 2>&1 | tee /run_logs/${SLURM_JOB_ID}/colocated_bench_NODE${SLURM_PROCID}.log |
| )" | ||
| [[ -n "$_yaml_env" ]] && eval "$_yaml_env" |
| export PERF_DEPLOYMENT_TYPE="${PERF_DEPLOYMENT_TYPE:-colocated_pp${PP_SIZE}xtp${TP_SIZE}}" | ||
| export PERF_TAGS="${PERF_TAGS:-vllm_multinode,colocated,${_ep_tag}}" | ||
|
|
||
| bash "${SHARED_DIR}/${BENCHMARK_SCRIPT_FILE:-benchmark_xPyD.sh}" |
| bash "${SHARED_DIR}/${BENCHMARK_SCRIPT_FILE:-benchmark_xPyD.sh}" | ||
|
|
||
| echo "[colocated] benchmark complete; stopping server" | ||
| pkill -P "${WORKER_PID}" 2>/dev/null; kill "${WORKER_PID}" 2>/dev/null || true | ||
| exit 0 |
| MODEL = os.environ.get("NIAH_MODEL", "") | ||
| WORDS = [int(x) for x in os.environ.get("NIAH_WORDS", "2000,8000,20000,35000").split(",") if x.strip()] | ||
| MAXTOK = int(os.environ.get("NIAH_MAXTOK", "2048")) | ||
| MAXTOK = int(os.environ.get("NIAH_MAXTOK", "8192")) |
Job 432890 brought both ranks up, passed the container barrier, launched
vllm serve -- and the server died ten seconds later:
OSError: /lib/x86_64-linux-gnu/libibverbs.so.1: version `IBVERBS_PRIVATE_57`
not found (required by /lib/x86_64-linux-gnu/libmlx5.so.1)
reported through a Python traceback that names a quantization plugin, because
vLLM loads plugins during startup and that is where the shared library resolves.
The cause is the host-library mount. libibverbs.so.1 is the name everything links
against and it is a symlink. This launcher mounted only the versioned file
(libibverbs.so.1.14.x), which lands the host copy at a path the image symlink does
not point to -- so libibverbs.so.1 still resolved to the image's older copy, while
libmlx5.so.1 came from the pattern loop below it and therefore WAS the host's, and
demanded a symbol only the host libibverbs exports.
scripts/vllm_dissag/run_xPyD_models.slurm has always had a third loop mounting the
sonames themselves, which is why the disagg path has run green. This launcher was
written without it. Ported verbatim.
Also fixed, same commit: cluster_mark_dir was being called inside the srun body in
both this launcher and vllm_dissag. That body is a separate shell on the compute
node where cluster.sh was never sourced, so it failed with "command not found"
once per node -- visible in this run's stderr. The marker is now written inline
there, which is what the comment a few lines below already said to do for the NVMe
mount. Added a check that no cluster.sh function is called from inside any srun
body; all three launchers pass.
What this run proved before it hit the library problem: weights selected from
local NVMe on both nodes, forwarded env delivered, both ranks past the barrier,
and the dead-engine detection reporting "vllm serve exited after 10s" with the
traceback instead of waiting out the 4000s readiness timeout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Critical launcher issues and unresolved benchmark/reporting problems block approval.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (16)
Previously missed (2) — in code that hasn't changed since the last review.
scripts/vllm_dissag/connectors/moriio.sh:278
model_argsis appended at line 275 and again at line 278. On the wide-EP Kimi path this repeats every model-specific flag, including the quantization config and maximum model length; remove one occurrence so the generated vLLM command has a single model-argument set.
scripts/vllm_multinode/serve_colocated.sh:94CPU_DISTRIBUTED_TIMEOUT_SECONDSis defaulted to 7200 on line 94, so the statement that unset timeouts fall back to PyTorch's stock 1800s is false. Document the 7200-second launcher default or actually leave the variable unset; otherwise the timeout behavior is misleading during startup failures.
benchmark/kimi_k3/mi300x/README.md:278
- The implementation does not currently satisfy this reporting contract: partial seed failures are excluded from the mean and still written with
status=SUCCESS, while all-seed failures emitNO-RESULTand produce no row. A timeout regression can therefore be either mislabeled success or silently omitted rather than a FAILURE row with performance 0.
whose request errors is recorded as a `FAILURE` row with performance 0, so a
pass→crash regression shows up instead of silently disappearing.
docker/pyt_vllm_kimi_k3_mi300x.ubuntu.amd.Dockerfile:291
- The Dockerfile's PINNING section says every source is immutable, but the optional
WITH_NIXL=1path checks outrocm-systemsat the movingdevelopbranch and clones DeepEP without any checkout. That build mode is therefore not reproducible and can change or break independently of the recorded pins; add immutable refs and record them in the image metadata.
cd /tmp && git clone --no-checkout --filter=blob:none https://github.com/ROCm/rocm-systems.git && \
cd rocm-systems && git sparse-checkout set --cone projects/rocshmem && git checkout develop && \
mkdir -p /tmp/rocshmem-build && cd /tmp/rocshmem-build && \
/tmp/rocm-systems/projects/rocshmem/scripts/build_configs/all_backends \
-DUSE_EXTERNAL_MPI=OFF -DGPU_TARGETS="${GFX_COMPILATION_ARCH}" && \
cd /tmp && git clone https://github.com/ROCm/DeepEP.git && cd DeepEP && \
scripts/vllm_dissag/benchmark_niah.py:32
- The launcher still invokes this script with
NIAH_MAXTOK="${NIAH_MAXTOK:-2048}"inbenchmark_niah.sh, so this new 8192 default is overridden for every normal harness run unless the model card sets the variable (the Kimi entries do not). Raise the wrapper default or pass 8192 from the Kimi recipes so the intended reasoning headroom is actually used.
MAXTOK = int(os.environ.get("NIAH_MAXTOK", "8192"))
scripts/vllm_dissag/benchmark_niah.py:142
- When every scored request for a context times out, this branch emits only
NO-RESULT;parse_to_csv.parse_niah_logrecognizes only mean summary lines, so that context disappears fromperf.csvinstead of becoming the documentedFAILURE/zero row. Emit a parseable failure record or update the parser to consume this case.
if not vals:
print(" words=%6d NO-RESULT (%d/%d timed out or errored — likely cold compile; "
"raise NIAH_TIMEOUT or keep NIAH_WARMUP=1)" % (n, n_to, len(scored)), flush=True)
continue
scripts/vllm_dissag/benchmark_niah.py:149
- The timeout count is written only as a human-readable suffix, while the parser matches the summary prefix and drops that suffix; consequently a context with partial timeouts is emitted with a reduced mean and
SUCCESSrather than the documented failure state. Preserve the timeout count in the parsed result and map it to the intended failure/performance behavior.
extra = (" [%d timeout/err excluded]" % n_to) if n_to else ""
if n_trunc:
# A truncated answer scores low for lack of room, not lack of retrieval.
extra += " (TRUNCATED in %d/%d: raise NIAH_MAXTOK)" % (n_trunc, len(vals))
scripts/vllm_dissag/benchmark_niah.sh:44
- The Python NIAH default was raised to 8192 for reasoning models, but this wrapper still supplies 2048 whenever the caller does not set
NIAH_MAXTOK. All three new colocated Kimi cards omit that variable, so their requests retain the truncating limit and the intended fix is bypassed.
NIAH_MAXTOK="${NIAH_MAXTOK:-2048}" \
scripts/vllm_dissag/benchmark_niah.sh:55
- Now that this wrapper invokes the NIAH parser, a context size whose every seed times out still emits only
NO-RESULT;parse_to_csv.pymatches no summary row, so that context silently disappears instead of becoming the documentedFAILURErow with performance 0. Emit or parse an explicit failure record for all-error contexts.
python3 "${DIR}/parse_to_csv.py" "${LOG}" --niah \
--perf-csv "/run_logs/${SLURM_JOB_ID}/perf.csv" \
--model-name "${MODEL_NAME}" 2>&1 | tee -a "${LOG}"
scripts/vllm_dissag/parse_to_csv.py:359
- The
--niahpath is not actually narrow: it callssave_niah_perf_csv, which builds the legacy full-schema header and injects_get_run_metadata(). This contradicts the statedmultiple_resultscontract that NIAH rows are workload-only and madengine supplies topology/image metadata, so colocated rows can bypass the authoritative metadata merge.
parser.add_argument('--narrow', action='store_true',
help='Emit a narrow results CSV (model/performance/metric[/status]) for a model card '
'declaring multiple_results, letting madengine supply the run metadata. '
'Ignored with --niah, which is always narrow.')
scripts/vllm_dissag/run_xPyD_models.slurm:111
Kimi-K3is only added toMORI_EP_VALID_MODELS, butvalidate_model_namechecksVALID_MODELSfirst (line 144). The newpyt_vllm_disagg_mori_kimi-k3card therefore exits as invalid before it can reach the allowed MoRI wideEP path; add Kimi-K3 to the primary allowlist as well.
"Kimi-K3" \
scripts/vllm_multinode/run_multinode.slurm:113
long_contextis accepted here, butbenchmark_long_context.shonly writes benchmark logs and never creates/run_logs/$SLURM_JOB_ID/perf.csv; the unconditional publish step below then exits 1. Either add CSV generation for this mode or reject it instead of advertising it as a valid selector.
sweep) BENCHMARK_SCRIPT_FILE="benchmark_xPyD.sh" ;;
long_context) BENCHMARK_SCRIPT_FILE="benchmark_long_context.sh" ;;
niah) BENCHMARK_SCRIPT_FILE="benchmark_niah.sh" ;;
scripts/vllm_multinode/run_multinode.slurm:86
cluster.shinitializesMODEL_NAMEto the literalNonebefore this expansion, so${MODEL_NAME:?}never fails when the required variable is omitted; the launcher probes for a model namedNoneinstead of reporting the documented missing-env error. Check for empty/Noneexplicitly or remove the sentinel before validating.
MODEL_NAME="${MODEL_NAME:?set MODEL_NAME (key into scripts/vllm_dissag/models.yaml)}"
scripts/vllm_multinode/run_multinode.slurm:334
- The container receives topology and benchmark settings here, but not
LOG_WAIT_TIMEOUT_SECONDS,DISTRIBUTED_TIMEOUT_SECONDS, orCPU_DISTRIBUTED_TIMEOUT_SECONDSfromcluster.sh. Site-level timeout overrides are therefore silently lost andserve_colocated.shfalls back to its internal 4000/7200-second values unless users know to list them inCOLOCATED_FORWARD_ENV; forward these shared settings explicitly.
-e TP_SIZE=$TP_SIZE \
-e PP_SIZE=$PP_SIZE \
-e ENABLE_EP=$ENABLE_EP \
${ALL2ALL_BACKEND:+-e ALL2ALL_BACKEND=$ALL2ALL_BACKEND} \
scripts/vllm_multinode/run_multinode.slurm:365
- The site configs require the CSV beside
scripts/vllm_multinode, but this destination uses the caller's current working directory. A madengine wrapper can submit the script from its job directory, leavingperf_Kimi-K3.csvoutsideslurm.results_dirso the collector misses an otherwise valid result. Write to${SCRIPT_DIR}instead.
_PERF_DST="$(pwd)/perf_${MODEL_NAME}.csv"
scripts/vllm_multinode/serve_colocated.sh:252
- This claim does not match
save_niah_perf_csv: NIAH currently writes the full legacy metadata schema, not the narrow workload schema described here and in the Kimi README (for example, it omitsbenchmark,context_words,tp,pp, andep_backend). Either emit the documented narrow fields before saying madengine supplies the metadata, or correct the documentation and contract.
# Only consulted on the LEGACY full-schema reporting path
# (BENCHMARK_SCRIPT=sweep). With BENCHMARK_SCRIPT=niah the CSV
# is narrow and madengine supplies these fields itself.
- Files reviewed: 27/29 changed files
- Comments generated: 3
- Review effort level: Lite
| export TP_SIZE="${TP_SIZE:-${GPUS_PER_NODE}}" | ||
| export PP_SIZE="${PP_SIZE:-1}" |
| -v /shared_inference:/shared_inference \ | ||
| ${_NVME_MOUNT} \ |
| mkdir -p /run_logs/${SLURM_JOB_ID} | ||
| SHARED_DIR=$MAD_SCRIPTS_MOUNT/vllm_dissag \ | ||
| bash $MAD_SCRIPTS_MOUNT/vllm_multinode/serve_colocated.sh \ | ||
| 2>&1 | tee /run_logs/${SLURM_JOB_ID}/colocated_bench_NODE${SLURM_PROCID}.log |
Two things the first green Kimi run exposed. WEIGHTS THAT DIFFER PER NODE. On this cluster /mnt/m2m_nobackup/models_blog/Kimi-K3 is a real 1.5T directory on some nodes and a symlink to Kimi-K3-MXFP4 on others -- different owners, three weeks apart. The probe only asked whether a non-empty directory existed, which is true on both, so a two-node allocation could load two different quantizations under one MODEL_PATH. Every rank loads from that one path, so the result is wrong numbers, not an error. Node selection cannot help: the divergence is in the path, not the node, and the scheduler has no way to express it. Each node now fingerprints the weights -- md5 of config.json, which is small, always present in a HuggingFace layout, and carries quantization_config, so variants of one model differ in it. readlink is reported alongside purely so the message can say WHY they differ. Disagreement REJECTS that candidate rather than failing the run, so the walk falls through to shared NFS: one copy every node reads, uniform by construction. Slower, and correct. CSV PROVENANCE. The green run published four rows with empty docker_image and machine_name. Both parse_to_csv.py implementations already read DOCKER_IMAGE_NAME and SLURM_JOB_NODELIST (_get_run_metadata), but they run inside the container and neither name was on this launcher's -e list, so they were simply absent. scripts/sglang_disagg passes both and its rows have always carried the image tag and nodelist; vllm_dissag passed only the nodelist; vllm_multinode passed neither. All three now pass both, so a row means the same thing whichever launcher produced it. Verified against the real divergence: two nodes with matching config.json -> NVMe selected; one real directory and one symlink to -MXFP4 -> rejected, both fingerprints and both resolved paths printed, falls through to NFS and selects it. Quote, orphan, srun-body and both-paths checks all clean; 16 resolver assertions unchanged; bash -n clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The variant guard added in the previous commit rejected a divergent NVMe candidate and fell through to shared NFS. For most models that is the right trade: slower, correct. For Kimi-K3 it is not. At 1.5 TB the checkpoint cannot sit in page cache, so an NFS read is served at NFS speed to every node at once, and at that size the read alone can outlast the time limit the partition allows. Falling back does not degrade the run; it produces a job that cannot finish, after holding two nodes for an hour. Failing immediately, with a message saying which nodes are wrong and how, is strictly better use of the allocation. REQUIRE_LOCAL_WEIGHTS=1 makes local roots the only candidates -- by filtering out anything under SHARED_MOUNT rather than hardcoding a path, so an overridden MODEL_DIR_CANDIDATES with several local roots still works -- and turns a miss or a per-node variant mismatch into a hard failure that names the fix. Set on the four Kimi-K3 cards. Left at 0 everywhere else: it is a property of the model, not of the site, and the smaller models keep the NFS fallback that has always worked for them -- Qwen3-32B is not staged on NVMe at all and loads from NFS in about six seconds. Verified against the real node layouts: NVMe present and identical -> selected; NVMe divergent (one real directory, one symlink to -MXFP4) -> fatal, no fallback; NVMe missing on one node -> fatal; and with the flag off the same layout still falls back to NFS. Both-paths agreement holds across all 43 cards with the new key, args -N still equals nnodes equals slurm.nodes, quote/orphan/srun-body checks clean, bash -n clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved launcher, topology, model-path, and benchmark issues must be addressed before approval.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (17)
Previously missed (1) — in code that hasn't changed since the last review.
scripts/vllm_dissag/parse_to_csv.py:359
- The CLI help says NIAH output is “always narrow,” but
save_niah_perf_csvwrites the legacy 29-column metadata schema (fieldnamesat lines 240-248). This makes the documented--niahoutput contract inaccurate for callers and model-card integration; either make NIAH honor the narrow schema or describe the actual full-schema output.
scripts/common/cluster.sh:92
cluster.shis sourced before both launchers choose their topology defaults. ExportingTP_SIZE=GPUS_PER_NODEandPP_SIZE=1here prevents${TP_SIZE:-1}invllm_disaggfrom defaulting to TP1 (all existing wide-EP cards become TP8), and prevents${PP_SIZE:-${NUM_NODES}}inrun_multinodefrom defaulting to PP across nodes (a 2-node invocation fails the GPU-shape check unless PP_SIZE is explicitly set). Leave these launcher-specific variables unset here.
export TP_SIZE="${TP_SIZE:-${GPUS_PER_NODE}}"
export PP_SIZE="${PP_SIZE:-1}"
scripts/common/cluster.sh:243
- The
REQUIRE_LOCAL_WEIGHTS=1filter runs before this block, but an explicitMODEL_DIRis appended afterward without the same shared-mount check. A Kimi site config withMODEL_DIR=/shared_inference/...therefore bypasses the local-only policy and can select NFS, contradicting the launcher’s documented guarantee and risking an unfinishable multi-terabyte load. Reject or skip explicit MODEL_DIR values underSHARED_MOUNTwhen REQUIRE_LOCAL_WEIGHTS is enabled.
# Append an explicit MODEL_DIR unless the list already covers it.
if [ -n "${MODEL_DIR:-}" ]; then
case " ${candidates} " in
*" ${MODEL_DIR%/} "*) : ;;
*) candidates="${candidates} ${MODEL_DIR%/}" ;;
esac
scripts/vllm_dissag/benchmark_niah.py:32
- The new Python default of 8192 is overridden by
benchmark_niah.sh, which still setsNIAH_MAXTOK=${NIAH_MAXTOK:-2048}before invoking this script. Since the Kimi cards do not set NIAH_MAXTOK, these runs still use 2048 and can truncate reasoning responses; update the wrapper default as well.
MAXTOK = int(os.environ.get("NIAH_MAXTOK", "8192"))
scripts/vllm_dissag/benchmark_niah.py:145
- When every seed times out or errors, the preceding
if not valsbranch printsNO-RESULTand continues, so this summary line is never emitted.parse_niah_logonly parses summary lines, meaning that context size disappears instead of becoming the documented FAILURE/0 row; an all-failure run produces no perf.csv at all.
counts = [c for c, _ in vals]
n_trunc = sum(1 for _, f in vals if f == "length")
mean = sum(counts) / len(counts)
scripts/vllm_dissag/benchmark_niah.sh:44
- The shell wrapper overrides
benchmark_niah.py’s new 8192 default with 2048 wheneverNIAH_MAXTOKis unset. The Python comments explicitly note that reasoning models need a larger budget, and all three Kimi model cards omit this variable, so their answers can be truncated and scored inaccurately at the lower limit. Keep the wrapper default aligned with the Python default or set it explicitly in the cards.
NIAH_MAXTOK="${NIAH_MAXTOK:-2048}" \
scripts/vllm_dissag/connectors/moriio.sh:275
model_argsis already appended on line 278 in this same command. Adding it here duplicates every model-specific flag at runtime, including Kimi's--quantization-configand reasoning parser, unlike the dry-run path; duplicate or conflicting single-valued arguments can then change the serve configuration or fail argument parsing. Keep the later append and remove this one.
"${model_args[@]}" \
scripts/vllm_dissag/run_xPyD_models.slurm:111
validate_model_namechecksVALID_MODELSat lines 125-144 before the connector-specific allowlist is consulted. Kimi-K3 is added only toMORI_EP_VALID_MODELShere, so the newpyt_vllm_disagg_mori_kimi-k3card is rejected as invalid before it can launch. Add Kimi-K3 toVALID_MODELSas well.
"Kimi-K3" \
scripts/vllm_dissag/run_xPyD_models.slurm:715
- The existing
agenticentries inscripts/vllm_dissag/models.jsondo not declaremultiple_results, andcommon/benchmark_agentic.shwrites agentic artifacts/JSON rather than/run_logs/$SLURM_JOB_ID/perf.csv. This new unconditional check therefore exits 1 for those previously supported DeepSeek agentic cards even when the agentic benchmark succeeds. Restrict the CSV requirement to benchmark modes that produce it, or add an agentic results adapter.
# Publish the perf CSV where madengine collects it. benchmark_xPyD.sh /
# benchmark_niah.sh write /run_logs/$SLURM_JOB_ID/perf.csv inside the container
# (= $LOG_PATH on the host), but madengine resolves a model's `multiple_results`
# against the JOB DIRECTORY it launched us from - so copy it there under the
# declared name. Harmless for entries that do not declare multiple_results.
scripts/vllm_dissag/run_xPyD_models.slurm:294
- The new resolver accepts an explicit
MODEL_DIR/MODEL_PATH, but this launcher only bind-mounts/shared_inferenceand/mnt/m2m_nobackupinto the container. If the documented site config points at another shared or local filesystem, the host probe succeeds while the vLLM container cannot seeMODEL_PATH, so the run fails at model load. Bind-mount the explicit model root or reject unsupported paths before starting Docker.
cluster_resolve_model_path || exit 1
scripts/vllm_multinode/run_multinode.slurm:113
benchmark_long_context.shdoes not invokeparse_to_csvor write/run_logs/$SLURM_JOB_ID/perf.csv, while this launcher unconditionally requires that file before succeeding. Selecting the advertisedBENCHMARK_SCRIPT=long_contexttherefore guarantees a nonzero job even when the benchmark completes. Add a reporting path for this benchmark or remove it from the valid choices until one exists.
long_context) BENCHMARK_SCRIPT_FILE="benchmark_long_context.sh" ;;
niah) BENCHMARK_SCRIPT_FILE="benchmark_niah.sh" ;;
scripts/vllm_multinode/run_multinode.slurm:140
- Both the master address and the per-node list select the first
hostname -Iaddress, but the allocated nodes are multi-homed andcluster.shprovidesFABRIC_SUBNET_PREFIXspecifically to select the interconnect. If the first address is the management NIC, the rendezvous and barrier addresses are unreachable from peers and startup hangs. Reuse the fabric-subnet selection and fail-fast handling used by the SGLang launcher.
MASTER_ADDR=$(srun --nodes=1 --ntasks=1 --time=00:20:00 --nodelist="$MASTER_NODE" bash -c 'hostname -I' | awk '{print $1}')
IPS=()
for NODE in $SELECTED_NODES; do
IP=$(srun --nodes=1 --ntasks=1 --time=00:20:00 --nodelist="$NODE" bash -c 'hostname -I' | awk '{print $1}')
scripts/vllm_multinode/run_multinode.slurm:361
- The command inside the container pipes the launcher through
teewithout enablingpipefail. Ifserve_colocated.shexits nonzero,teecan still return 0 and this rank is reported as successful to Slurm, masking startup failures. Enableset -o pipefailin the nestedbash -cscript before this pipeline.
2>&1 | tee /run_logs/${SLURM_JOB_ID}/colocated_bench_NODE${SLURM_PROCID}.log
scripts/vllm_multinode/run_multinode.slurm:315
cluster_resolve_model_pathaccepts an explicitMODEL_DIR/MODEL_PATH, but the container only mounts/shared_inferenceand the NVMe root. A site config that follows the documented “wherever the checkpoint lives” guidance and uses another filesystem will pass the host-side probe, then leaveMODEL_PATHinvisible inside Docker and fail model loading. Bind-mount the explicit model root (with a directory guard) or reject unsupported paths before launching.
${_NVME_MOUNT} \
scripts/vllm_multinode/serve_colocated.sh:258
- The default
benchmark_xPyD.shresolves its parser as$NIXL_COOKBOOK_PATH/parse_to_csv.py, but this colocated container sets neitherNIXL_COOKBOOK_PATHnor a replacement. It therefore tries/parse_to_csv.py, produces noperf.csv, and the outer launcher reports failure for the default sweep. Set the variable to${SHARED_DIR}for this invocation or make the benchmark use its own directory.
bash "${SHARED_DIR}/${BENCHMARK_SCRIPT_FILE:-benchmark_xPyD.sh}"
scripts/vllm_multinode/serve_colocated.sh:160
- This recomputes the local barrier address using the first
hostname -Iresult instead of the rank-ordered address already passed inIPADDRS. On a multi-homed node, peers can dial the fabric address while the barrier binds the management address, so every rank waits indefinitely. Select the address forNODE_RANKfromIPADDRS(or use the same fabric resolver).
host_ip=$(hostname -I | awk '{print $1}')
scripts/vllm_multinode/serve_colocated.sh:208
- When a worker’s vLLM process exits, this loop simply falls through and exits successfully without touching the shared shutdown flag or notifying the head. The head only checks its own PID, so it can continue polling readiness for the full 4000-second timeout after a worker failure, holding the allocation instead of reporting the worker error promptly. Propagate a worker failure to the head and terminate the remaining ranks.
done
wait "${WORKER_PID}" 2>/dev/null || true
exit 0
- Files reviewed: 27/29 changed files
- Comments generated: 2
- Review effort level: Lite
A model card is not always named after its checkpoint. The mad-rccl branch adds Kimi-K2-Instruct-MoRI-AB and Kimi-K2-Instruct-DeepEP-AB, which differ only in their all-to-all backend and share one set of weights; its launcher resolves the directory through a model_weights_name() mapping and passes MODEL_WEIGHTS_NAME. cluster_resolve_model_path assumed catalog key == directory name, so on that branch it would look for a directory that does not exist and fail a run whose weights are present. This is also the larger half of the merge conflict between that branch and this one: both walk the same three candidates, theirs keyed on MODEL_WEIGHTS_NAME and ours on MODEL_NAME. MODEL_WEIGHTS_NAME defaults to MODEL_NAME, so every card that is not an alias resolves exactly as before and no existing card changes behaviour. Verified against per-node fake roots: an alias card with MODEL_WEIGHTS_NAME set resolves to the shared checkpoint; a plain card is unchanged; an alias name with MODEL_WEIGHTS_NAME unset still fails, and now says "<name> NOT staged" per node rather than resolving something wrong. Quote, orphan, both-paths and resolver checks all clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fabric block was one cluster's values written as constants: GID index 3,
mlx5_1, and an implied Mellanox driver. That is one row of a table with at least
three. The mad-rccl branch runs the same workloads on AMD AINIC and Broadcom
Thor2, where the RDMA driver, the GID index and the control-plane interface are
all different, and it carries the matrix in
.claude/skills/mad-slurm-multinode/references/cluster-types.md:
cx7 mlx5_* GID 3 eth0 mlx5
ainic rdma0..7 GID 1 eno0 ionic + RCCL_AINIC_ROCE=1
thor2 bnxt_re0..7 GID 3 fenic0 bnxt_re
Getting one wrong does not fail. That file records what happens: RCCL initializes
zero NICs and "silently falls back to TCP sockets -- making the perf number a
measurement of the wrong path". A run that completes and reports a number
measured over the wrong transport is the worst outcome available, and it is the
same class of fault as the weight-variant and NIC-list problems already fixed
here.
CLUSTER_ARCHETYPE is detected from the adapters actually present, because the
node knows and a person guessing does not. Their detect_cluster_env.sh
classifies by the same HCA prefixes; this applies the same rule at source time
rather than proposing values for a human to paste. Everything stays
${VAR:-default}, so naming the archetype or setting any single variable still
wins.
An unclassifiable node resolves to exactly the previous constants, so a node this
cannot identify behaves as it did before rather than differently.
Verified: each archetype produces its own row (cx7 GID 3/eth0/mlx5/mlx5_1; ainic
GID 1/eno0/ionic/rdma0 with RCCL_AINIC_ROCE=1; thor2 GID 3/fenic0/bnxt_re);
unknown reproduces the old values; explicit NCCL_IB_GID_INDEX and KV_IB_DEVICE
override the archetype while the rest still come from it; and detection on this
host correctly returns cx7 from real mlx5_* devices. Quote, orphan, both-paths,
resolver and require-local checks all clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The claim that way 4 is a front end and not a migration is asserted by a unit test against a synthetic card. This makes it checkable against a real one: the same workload, expressed by layer instead of as a flat env_vars block, has to produce the same environment and the same perf_Kimi-K3.csv as the card that ran green in builds 59 and 60. The twin declares only the image and a pointer. Everything else -- TP_SIZE, PP_SIZE, ENABLE_EP, AITER_SITUV2_A8W4, COLOCATED_EXTRA_ARGS, NIAH_WORDS, REQUIRE_LOCAL_WEIGHTS, MODEL_NAME -- comes from the layered file, split by who creates it: REQUIRE_LOCAL_WEIGHTS is a site fact (only this cluster's NVMe loads 1.5T in workable time), the serve values belong to whoever tuned the model, and the NIAH sweep belongs to whoever defines the measurement. The file is deliberately NOT named mad-config.yaml. That name is picked up by convention for every card in the directory, which would switch way 4 on for the two cards that are the control and destroy the comparison. The twin points at it explicitly through env_vars.MAD_CONFIG, so nothing else in this directory changes -- which is the same rule the config itself is meant to demonstrate: one way being present must not disturb the others. Verified before submitting anything: resolving the twin through layered_config.resolve_for_model reproduces the original card's nine environment values exactly, no key added, none missing, none differing.
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical and moderate findings affect launcher correctness, environment propagation, reporting, and build reproducibility.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (15)
docker/pyt_vllm_kimi_k3_mi300x.ubuntu.amd.Dockerfile:83
- This donor image is referenced by a mutable tag even though the Dockerfile claims its sources are pinned for reproducible builds. A tag move can silently change the grafted K3 AITER artifacts without any MAD source change, making benchmark results and rebuilds non-reproducible. Pin the donor image by digest (or record and verify an immutable image identifier).
ARG PROVEN_K3_IMAGE=amdsiloai/vllm:kimi-k3-mi325x-release-v2
docker/pyt_vllm_kimi_k3_mi300x.ubuntu.amd.Dockerfile:291
- The optional
WITH_NIXL=1build checks outrocm-systemsfrom the movingdevelopbranch, and the following DeepEP clone has no checkout at all. That contradicts the Dockerfile's reproducibility claim and can change the transport binaries between identical image builds; pin both repositories to immutable commits (and record them inversions.txt).
cd /tmp && git clone --no-checkout --filter=blob:none https://github.com/ROCm/rocm-systems.git && \
cd rocm-systems && git sparse-checkout set --cone projects/rocshmem && git checkout develop && \
mkdir -p /tmp/rocshmem-build && cd /tmp/rocshmem-build && \
/tmp/rocm-systems/projects/rocshmem/scripts/build_configs/all_backends \
-DUSE_EXTERNAL_MPI=OFF -DGPU_TARGETS="${GFX_COMPILATION_ARCH}" && \
cd /tmp && git clone https://github.com/ROCm/DeepEP.git && cd DeepEP && \
docker/sglang_disagg_inference.ubuntu.amd.Dockerfile:37
- The image pins MoRI to a commit but installs whatever
sglang-routeris newest at build time. A later router release can change the proxy CLI/API and make the launcher incompatible while the Dockerfile remains unchanged. Pin a tested router version (or commit) alongside the other runtime dependencies.
RUN pip install --upgrade sglang-router
scripts/common/cluster.sh:99
- These shared exports preempt the launcher-specific topology defaults:
vllm_dissag/run_xPyD_models.slurmfalls back to TP=1, while the colocated launcher falls back to PP=NNODES. With two nodes and no explicit overrides,run_multinode.slurmpreserves TP=8/PP=1 from here and fails its 8 != 16 validation; existing disaggregated jobs also silently become TP=8. Leave both variables unset in the shared site file and let each launcher set its own defaults.
export TP_SIZE="${TP_SIZE:-${GPUS_PER_NODE}}"
export PP_SIZE="${PP_SIZE:-1}"
scripts/vllm_dissag/benchmark_niah.py:32
- The Python fallback was raised to 8192, but
benchmark_niah.shalways exportsNIAH_MAXTOKwith a 2048 default before invoking this file. Since the Kimi-K3 cards do not overrideNIAH_MAXTOK, normal launcher runs never use the new 8192 default; update the wrapper default or remove that override.
MAXTOK = int(os.environ.get("NIAH_MAXTOK", "8192"))
scripts/vllm_dissag/benchmark_niah.sh:55
- This new NIAH reporting call exposes two contract bugs in the existing
--niahpath: it writes the legacy full-schema CSV even though the Kimi-K3 cards usemultiple_results/narrow reporting, andsave_niah_perf_csv()hard-codes every emitted row asSUCCESSwhile timeout-only contexts disappear. A failed or partially failed retrieval run can therefore publish an incompatible and falsely successfulperf.csv; add a status-aware narrow NIAH writer or align the cards and documentation with the legacy behavior.
python3 "${DIR}/parse_to_csv.py" "${LOG}" --niah \
--perf-csv "/run_logs/${SLURM_JOB_ID}/perf.csv" \
--model-name "${MODEL_NAME}" 2>&1 | tee -a "${LOG}"
scripts/vllm_dissag/connectors/moriio.sh:278
- The wideEP command expands
model_argsat both lines 275 and 278. Kimi-K3 recipe flags such as--reasoning-parserand--quantization-configare therefore passed twice to every live wideEP server, even though the dry-run branch emits them once. Remove the second expansion.
"${model_args[@]}" \
scripts/vllm_dissag/parse_to_csv.py:288
- If every seed times out,
benchmark_niah.pyemitsNO-RESULTandparse_niah_logproduces an empty result for that context, so this early return writes no CSV row at all. The Kimi-K3 documentation promises a FAILURE row with performance 0 for this case; preserve the failed context through CSV generation instead of silently dropping it.
if not results:
print("No results to save to perf.csv.")
return
scripts/vllm_dissag/run_xPyD_models.slurm:593
- Because the preceding line continues the command, this
#starts a shell comment whiledocker runis being assembled. The newline after the comment terminates the Docker command, so the following-elines are executed as separate commands and the disaggregated container never receives the remaining Docker arguments. Move this note abovedocker runor outside the continued command.
# Same reason as the nodelist above: parse_to_csv.py reads this for the
# docker_image column and runs inside the container, so without it the column
# is blank on every row this launcher produces.
scripts/vllm_dissag/run_xPyD_models.slurm:111
validate_model_nameruns beforemodel_allows_mori_ep, so adding Kimi-K3 only to this backend-specific allowlist still rejectsMODEL_NAME=Kimi-K3at line 144. Add it to the globalVALID_MODELSlist so the new disaggregated card can reach the MoRI-EP validation.
"Kimi-K3" \
scripts/vllm_multinode/run_multinode.slurm:332
- Because the preceding line continues the command, this
#starts a shell comment whiledocker runis being assembled. The newline after the comment terminates the Docker command, so the following-elines are executed as separate commands (-e: command not found) and no container starts. Move this provenance note abovedocker runor outside the continued command.
# Provenance for the perf CSV. Both parse_to_csv.py implementations already
# read these two names (_get_run_metadata -> docker_image, machine_name), but
# they run INSIDE the container, so a name the -e list omits is simply absent
# and the column comes out blank. scripts/sglang_disagg passes both and its
# rows carry the image tag and nodelist; this launcher passed neither, which
scripts/vllm_multinode/run_multinode.slurm:314
cluster.shallowsSHARED_MOUNTto be overridden and resolvesMODEL_PATHunder that root, but this bind mount is hard-coded to/shared_inference. On a site using another shared root, host-side discovery succeeds while the container sees an unmounted model path (or Docker creates an empty directory), so vLLM cannot load the weights. Bind the configured shared root instead.
-v /shared_inference:/shared_inference \
scripts/vllm_multinode/run_multinode.slurm:112
long_contextis advertised as a valid selector, butbenchmark_long_context.shends after the serving loops and never invokesparse_to_csv.pyor writes/run_logs/${SLURM_JOB_ID}/perf.csv. The outer launcher unconditionally requires that file before completing, so every long-context run is reported as failed even when the benchmark itself completes. Add its CSV emission or remove this selector.
long_context) BENCHMARK_SCRIPT_FILE="benchmark_long_context.sh" ;;
scripts/vllm_multinode/run_multinode.slurm:373
- Unlike the disaggregated launcher, this script never changes to
SCRIPT_DIR, but the colocated Slurm config setsresults_dirtoscripts/vllm_multinodeand documents that the CSV is copied beside the launcher.$(pwd)depends on the caller (including the madengine wrapper), so the collector can find noperf_Kimi-K3.csv; write to the already-computed script directory instead.
_PERF_DST="$(pwd)/perf_${MODEL_NAME}.csv"
scripts/vllm_multinode/serve_colocated.sh:258
- The colocated cards declare
multiple_results, and the documented contract says those workload CSVs are narrow, but this shared invocation provides no narrow-reporting mode.benchmark_xPyD.shcallsparse_to_csv.pywithout--narrow, so a colocated sweep emits the legacy full-schema CSV instead of the declared contract. Add a colocated narrow mode or align the cards and documentation with legacy reporting.
bash "${SHARED_DIR}/${BENCHMARK_SCRIPT_FILE:-benchmark_xPyD.sh}"
- Files reviewed: 28/30 changed files
- Comments generated: 4
- Review effort level: Lite
| srun --nodelist="$SELECTED_NODELIST" bash -c ' | ||
| echo "Rank $SLURM_PROCID on $(hostname)" |
| -v /tmp/vllm_cache:/tmp/vllm_cache \ | ||
| $_JIT_CACHE_MOUNT \ | ||
| $_RDMA_MOUNTS \ | ||
| --env-file "$_ENV_FILE" \ |
| # Assemble argv | ||
| # ----------------------------------------------------------------------------- | ||
| serve_args=( | ||
| --served-model-name "${MODEL_NAME:-model}" |
| # Append an explicit MODEL_DIR unless the list already covers it. | ||
| if [ -n "${MODEL_DIR:-}" ]; then | ||
| case " ${candidates} " in | ||
| *" ${MODEL_DIR%/} "*) : ;; | ||
| *) candidates="${candidates} ${MODEL_DIR%/}" ;; | ||
| esac | ||
| fi |
…command
Build 61 staged 1.5T of Kimi-K3 weights onto node-local NVMe, verified the
fingerprint matched on both nodes, pulled the image, mounted the JIT cache -- and
then died 56 seconds in with:
docker: 'docker run' requires at least 1 argument
/usr/bin/bash: line 148: -e: command not found
I put a comment inside the docker run continuation in c772cdf:
-e MODEL_NAME=$MODEL_NAME \
# Provenance for the perf CSV...
-e DOCKER_IMAGE_NAME=... \
Bash splices the trailing backslash with the next line, so the `#` lands mid
command and ends it there -- docker run with no IMAGE -- and every following
`-e` line then executes as its own command. The comment I added to explain the
provenance fix is what stopped the provenance fix from running.
Both launchers carried it. vllm_multinode showed it because that is the card
being re-run; vllm_dissag has been broken the same way since the same commit and
nobody has run those 17 cards since. Both are fixed here, with the rationale
moved above the command where it cannot be spliced into anything.
This is the second time a comment has changed what executes in these scripts: an
apostrophe inside an srun body closed the string early in build 56 (1e42e8f).
Both are invisible to `bash -n`, which accepts the result as valid -- it simply
is not what was written. So this adds scripts/common/check_continuations.py,
which reads 68 files and fails on any comment inside a continuation. A continued
line that is itself a comment is allowed; the backslash is already inside a
comment and splices nothing.
Verified: the checker reports both offenders before the fix and none after, and
bash -n still accepts both launchers.
There was a problem hiding this comment.
🔵 Needs a closer look
Unresolved issues affect launcher defaults, routing, model paths, benchmark results, and image reproducibility.
Review details
Suppressed comments (14)
docker/pyt_vllm_kimi_k3_mi300x.ubuntu.amd.Dockerfile:291
- The Dockerfile claims every source is pinned to an immutable SHA, but the optional
WITH_NIXL=1path checks outrocm-systemsat the movingdevelopbranch and clones DeepEP without any ref. Rebuilding that supported variant can silently change the image even when this Dockerfile is unchanged; pin immutable commits (and record them in versions.txt) for these sources as well.
cd rocm-systems && git sparse-checkout set --cone projects/rocshmem && git checkout develop && \
mkdir -p /tmp/rocshmem-build && cd /tmp/rocshmem-build && \
/tmp/rocm-systems/projects/rocshmem/scripts/build_configs/all_backends \
-DUSE_EXTERNAL_MPI=OFF -DGPU_TARGETS="${GFX_COMPILATION_ARCH}" && \
cd /tmp && git clone https://github.com/ROCm/DeepEP.git && cd DeepEP && \
scripts/common/cluster.sh:99
- These shared exports override the launchers' own defaults:
run_xPyD_models.slurmlater uses${TP_SIZE:-1}, so every existing disaggregated run now inheritsTP_SIZE=GPUS_PER_NODE(8) instead of TP1, whilerun_multinode.slurmcan never fall back toPP_SIZE=NNODESbecause this sets PP1. That changes existing disagg topology and makes colocated jobs that omit PP_SIZE fail the GPU-count check. Keep TP_SIZE/PP_SIZE unset here and let each launcher own its defaults.
export TP_SIZE="${TP_SIZE:-${GPUS_PER_NODE}}"
export PP_SIZE="${PP_SIZE:-1}"
scripts/common/cluster.sh:307
REQUIRE_LOCAL_WEIGHTS=1is filtered before this block, but an explicitMODEL_DIRis appended afterward without the same filter. The Kimi cards set this flag, so pointing the site template'sMODEL_DIRat/shared_inferencesilently re-enables the 1.5-TB NFS load despite the guard; reject or ignore sharedMODEL_DIRvalues when local weights are required.
if [ -n "${MODEL_DIR:-}" ]; then
case " ${candidates} " in
*" ${MODEL_DIR%/} "*) : ;;
*) candidates="${candidates} ${MODEL_DIR%/}" ;;
esac
scripts/vllm_dissag/benchmark_niah.py:32
- The implementation default changed to 8192, but the module's
NIAH_MAXTOKenvironment documentation still says the default is 2048. Once the wrapper default is corrected, direct invocations will still be documented incorrectly; update the contract comment alongside this code change.
MAXTOK = int(os.environ.get("NIAH_MAXTOK", "8192"))
scripts/vllm_dissag/benchmark_niah.py:32
- This new Python fallback is not used by the normal harness:
benchmark_niah.shstill exportsNIAH_MAXTOK="${NIAH_MAXTOK:-2048}"before invoking Python. The Kimi cards do not set NIAH_MAXTOK, so their reasoning responses still run with 2048 tokens and can be truncated, despite this change documenting that 8192 is needed. Raise the shell wrapper default or set the value in the Kimi cards as well.
MAXTOK = int(os.environ.get("NIAH_MAXTOK", "8192"))
scripts/vllm_dissag/benchmark_niah.py:149
- The warning here is only text:
parse_niah_logmatches the summary prefix andsave_niah_perf_csvwrites every parsed row withstatus=SUCCESS. A context with some timeouts is therefore published as a successful mean, while a context whose requests all time out disappears from the CSV entirely. Emit an explicit failure/NaN result (or propagate the timeout status) so a degraded NIAH run cannot be reported as successful.
if n_trunc:
# A truncated answer scores low for lack of room, not lack of retrieval.
extra += " (TRUNCATED in %d/%d: raise NIAH_MAXTOK)" % (n_trunc, len(vals))
scripts/vllm_dissag/benchmark_niah.sh:44
- The Python default was raised to 8192, but this wrapper still exports
NIAH_MAXTOKwith a 2048 default. The Kimi cards do not setNIAH_MAXTOK, so normal Kimi runs continue to hit the 2048-token truncation that the new finish-reason diagnostic calls out. Raise this wrapper default (or set it explicitly in every reasoning-model card).
NIAH_MODEL="${NIAH_MODEL:-${MODEL_PATH}}" \
NIAH_WORDS="${NIAH_WORDS:-2000,8000,20000,35000}" \
NIAH_SEEDS="${NIAH_SEEDS:-0,1,2}" \
NIAH_MAXTOK="${NIAH_MAXTOK:-2048}" \
scripts/vllm_dissag/connectors/moriio.sh:275
- The new
model_argsexpansion is duplicated by the existing expansion at line 278 in every non-dry-run wideEP command. This makes the real vLLM argv differ from DRY_RUN and passes every model-specific flag twice (including Kimi's quantization and reasoning flags); remove one of the two expansions.
"${model_args[@]}" \
scripts/vllm_dissag/connectors/moriio.sh:219
- For Kimi's TP2 disaggregated shape,
dp_sizehere is the pool-wide size (2 nodes × 4 DP ranks = 8), while each master has onlyDP_PARALLEL_SIZE_LOCAL=4. The comparison therefore leaves--api-server-count=8; clamp against the local DP count, otherwise the frontend can create routes for ranks not served by that master.
local _api_servers="${_GPUS_PER_NODE}"
[ "${dp_size}" -lt "${_api_servers}" ] && _api_servers="${dp_size}"
extra_args+=(--api-server-count=${_api_servers})
scripts/vllm_dissag/run_xPyD_models.slurm:111
validate_model_nameruns againstVALID_MODELSbefore it reachesMORI_EP_VALID_MODELS, but Kimi-K3 was added only to the latter list. Consequently the newpyt_vllm_disagg_mori_kimi-k3card always exits as an invalid model before starting its MoRI-EP path. Add Kimi-K3 to the primary valid-model list as well, and keep the gate test mirror in sync.
"Kimi-K3" \
scripts/vllm_multinode/run_multinode.slurm:113
- This selector exposes
benchmark_long_context.sh, but that script only writes its log and never callsparse_to_csv.pyto create/run_logs/$SLURM_JOB_ID/perf.csv. The mandatory post-run check below will therefore fail everyBENCHMARK_SCRIPT=long_contextcolocated job even when the benchmark itself completes. Add the CSV emission to that benchmark or remove this selector until it produces the declared result.
long_context) BENCHMARK_SCRIPT_FILE="benchmark_long_context.sh" ;;
niah) BENCHMARK_SCRIPT_FILE="benchmark_niah.sh" ;;
scripts/vllm_multinode/run_multinode.slurm:328
cluster_resolve_model_pathcan select an explicitMODEL_DIRor a customizedSHARED_MOUNT, but the container only mounts the hard-coded/shared_inferenceand the optional NVME root. With the site templates' documented customMODEL_DIR, the host-side probe succeeds while the container cannot seeMODEL_PATH, so vLLM fails with a missing model. Mount the resolved model root/configured shared root on every node before launching the container.
-v /shared_inference:/shared_inference \
${_NVME_MOUNT} \
scripts/vllm_multinode/serve_colocated.sh:79
- Serving the colocated instance under
MODEL_NAMEbreaks the shared throughput benchmarks:benchmark_xPyD.shandbenchmark_long_context.shstill invokevllm bench serve --model "$MODEL_PATH", while only the NIAH wrapper overrides its request model viaNIAH_MODEL. Sweep and long-context requests therefore use a model id the server does not serve and fail with 404s. Preserve a single served/request model id across all benchmark scripts.
scripts/vllm_multinode/serve_colocated.sh:258 - The shared throughput harness resolves its parser as
$NIXL_COOKBOOK_PATH/parse_to_csv.py, but this colocated launcher sets onlySHARED_DIRand never defines NIXL_COOKBOOK_PATH. WithBENCHMARK_SCRIPT=sweep, parsing consequently targets/parse_to_csv.pyand no perf.csv is produced, so the valid sweep fails at the launcher copy check. ExportNIXL_COOKBOOK_PATH="$SHARED_DIR"before invoking the shared script, or update the harness to use SHARED_DIR.
- Files reviewed: 29/31 changed files
- Comments generated: 0 new
- Review effort level: Lite
Build 72 ran with the build 61 fix in place. The docker errors were gone, weights were staged on NVMe on both nodes, the image pulled -- and the job still failed in 66 seconds with no container output anywhere and no perf CSV. The cause was the comment I added in 16e2a04 to explain the build 61 fix. It quoted that build's error message verbatim: # docker: 'docker run' requires at least 1 argument That sits inside the srun body, which is ONE single-quoted string. The first apostrophe closes it; the space in "docker run" is then unquoted, so the shell splits the word there and bash -c receives a TRUNCATED script with everything from that point onward as arguments. The executed body ended mid-comment, so the docker run at the next line was never in it. That is exactly what the logs show: stdout stops at [fwd-env], the container never starts, and the run fails on the missing CSV it was never going to produce. So the fix for build 61's comment-in-a-continuation reintroduced build 56's apostrophe-in-a-comment. Rather than de-apostrophising in place, all the prose moves ABOVE the srun, where neither hazard can reach it, and states both rules for whoever edits this next. Nothing but the body belongs inside the body. The existing quote checker passed this file. It treated an even apostrophe count as the deliberate '"$VAR"' splice idiom -- and 'docker run' is even. Parity was never the right test. What truncates a body is unquoted WHITESPACE in the escaped text, because that is what splits the shell word; escaped text without whitespace merges back into the same word and survives, which is why `srun bash -c '...'` inside a comment in vllm_dissag is harmless and flagging it would have been crying wolf. check_srun_quotes.py now tests the interior of each pair, and is versioned here rather than living in /tmp where the last one did. Calibrated both ways: it reports 16e2a04's line 316 as the offender, and reports 68 files clean afterwards. check_continuations.py still passes, and bash -n accepts both launchers -- as it did throughout, which is the whole problem with this class of bug.
STANDALONE has been green since builds 59 and 60; madengine is what is being brought up now. They share run_multinode.slurm -- Jenkins sbatches it through a wrapper, madengine sbatches it as slurm_multi -- so a break in that file takes both down, and I have broken it twice in two days with comments. Two pieces of evidence that STANDALONE is intact. Comparing EXECUTABLE lines only (comments and blanks stripped), run_multinode.slurm is unchanged at 188 lines across both recent fixes: 16e2a04 and 9213331 moved prose and changed no behaviour. Reaching further back, the only executable change since before the provenance work is the three -e additions it was meant to make: run_multinode.slurm 186 -> 188 +DOCKER_IMAGE_NAME +SLURM_JOB_NODELIST run_xPyD_models.slurm 511 -> 512 +DOCKER_IMAGE_NAME Everything else that moved was a comment, and both of the failures were comments changing what executes rather than code doing so. verify_both_paths_generate.py covers the half no test reached. The existing harness proves the two paths resolve to the same ENVIRONMENT across 44 cards; this proves madengine can still GENERATE its invocation, which is what builds 62 and 63 could not do. It drives the real generator with a namespaced card name, asserts the filename is one path segment and writes where build 62 raised ENOENT, confirms both paths name the same launcher, and runs bash -n plus both hazard checkers over it. Four checks now guard this file, and every one of them catches something bash -n does not: continuations, srun quotes, environment parity, generation parity.
There was a problem hiding this comment.
🟡 Changes recommended
Critical and moderate launch, model-resolution, benchmark, and validation issues remain unresolved.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (12)
Previously missed (1) — in code that hasn't changed since the last review.
scripts/vllm_dissag/connectors/moriio.sh:275
- model_args is appended both here and again at line 278. This duplicates every model-specific flag in the actual WIDE_EP launch, including Kimi's quantization and reasoning arguments; the DRY_RUN command does not show the duplicate, so the current argv test will not catch it. Keep only the existing later expansion.
benchmark/kimi_k3/README.md:15
- The overview table contains three framework/model entries (vLLM, SGLang, and ATOM), not four, so this statement is inaccurate. Say “all three” or list the missing fourth model to avoid confusing the MI300X coverage.
> **On MI300X (gfx942)?** All four models above carry `skip_gpu_arch: gfx942` —
scripts/common/cluster.sh:99
- Because this file is sourced before either launcher applies its own fallback, these exports override both contracts: a default disaggregated run now gets TP_SIZE=8 instead of TP1, while a default colocated 2-node run gets PP_SIZE=1 and then fails the TP*PP == total-GPUs check. Leave these launcher-specific variables unset here.
export TP_SIZE="${TP_SIZE:-${GPUS_PER_NODE}}"
export PP_SIZE="${PP_SIZE:-1}"
scripts/common/cluster.sh:307
- When REQUIRE_LOCAL_WEIGHTS=1, shared candidates are removed above, but this unconditional append reintroduces an explicit MODEL_DIR under SHARED_MOUNT. A Kimi run with MODEL_DIR=/shared_inference/... will therefore fall back to the NFS checkpoint despite the card's local-only hard-fail requirement. Apply the same local-only filter to this explicit candidate.
if [ -n "${MODEL_DIR:-}" ]; then
case " ${candidates} " in
*" ${MODEL_DIR%/} "*) : ;;
*) candidates="${candidates} ${MODEL_DIR%/}" ;;
esac
scripts/vllm_dissag/benchmark_niah.py:145
- When all seeds for a context time out, this branch emits NO-RESULT and continues without a summary line. The new benchmark_niah.sh parser only recognizes summary lines, so that context is omitted from perf.csv; with partial timeouts, the remaining row is still written as SUCCESS. Emit a failure row/status for missing contexts so a long-context regression cannot pass by omission.
counts = [c for c, _ in vals]
n_trunc = sum(1 for _, f in vals if f == "length")
mean = sum(counts) / len(counts)
scripts/vllm_dissag/benchmark_niah.py:32
- This new Python default is never used by the normal NIAH launcher: benchmark_niah.sh always sets NIAH_MAXTOK=${NIAH_MAXTOK:-2048} before invoking this file. Therefore Kimi-K3 still runs with 2048 max tokens unless every card overrides NIAH_MAXTOK, despite this change claiming to give reasoning models headroom.
MAXTOK = int(os.environ.get("NIAH_MAXTOK", "8192"))
scripts/vllm_dissag/benchmark_niah.sh:55
- This newly enabled CSV path does not preserve the failure semantics described by the surrounding documentation: parse_niah_log only recognizes mean summary lines, so an all-timeout context is omitted and a partial-timeout context is still written as SUCCESS by save_niah_perf_csv. Emit an explicit failure/zero row for those contexts, or do not claim that request errors are recorded.
python3 "${DIR}/parse_to_csv.py" "${LOG}" --niah \
--perf-csv "/run_logs/${SLURM_JOB_ID}/perf.csv" \
--model-name "${MODEL_NAME}" 2>&1 | tee -a "${LOG}"
scripts/vllm_dissag/parse_to_csv.py:359
--narrowis documented here as producing a narrow NIAH CSV, but the--niahpath callssave_niah_perf_csv, whose fieldnames are the full legacy metadata schema and whose rows are always SUCCESS. The new NIAH launcher therefore does not produce the narrow multiple_results contract described in the Kimi README; make the NIAH path honor the same schema or correct the contract/documentation.
parser.add_argument('--narrow', action='store_true',
help='Emit a narrow results CSV (model/performance/metric[/status]) for a model card '
'declaring multiple_results, letting madengine supply the run metadata. '
'Ignored with --niah, which is always narrow.')
scripts/vllm_multinode/run_multinode.slurm:335
- cluster_resolve_model_path accepts an explicit MODEL_DIR and configurable shared roots, and the site README documents placing the checkpoint wherever the site needs. The container only binds /shared_inference and /mnt/m2m_nobackup, so a valid model selected from any other root is invisible inside Docker and vLLM fails to load it. Mount the selected model root (or reject unsupported paths) before launching.
scripts/vllm_multinode/run_multinode.slurm:374 benchmark_xPyD.shresolvesparse_to_csv.pythrough NIXL_COOKBOOK_PATH, but this container invocation never sets that variable. The default sweep therefore tries/parse_to_csv.pyand produces no perf.csv; pass the mounted vllm_dissag directory as NIXL_COOKBOOK_PATH for the benchmark.
scripts/vllm_multinode/run_multinode.slurm:112- This option selects
benchmark_long_context.sh, but that script only runsvllm benchand never invokesparse_to_csv.py. The launcher later unconditionally requires${LOG_PATH}/${SLURM_JOB_ID}/perf.csv, so selecting the documented long_context mode always ends as a failure for a missing results file.
scripts/vllm_multinode/serve_colocated.sh:79 - The server advertises only MODEL_NAME (for example,
Kimi-K3), but the shared throughput and long-context benchmarks send MODEL_PATH as their--modelrequest id. Thus every non-NIAH selection receives model-not-found/404 responses; only benchmark_niah overrides its request model. Use one common model id or pass the served name through those benchmark scripts.
- Files reviewed: 31/33 changed files
- Comments generated: 4
- Review effort level: Lite
| python3 "${DIR}/parse_to_csv.py" "${LOG}" --niah \ | ||
| --perf-csv "/run_logs/${SLURM_JOB_ID}/perf.csv" \ | ||
| --model-name "${MODEL_NAME}" 2>&1 | tee -a "${LOG}" |
| }, | ||
| "env_vars": { | ||
| "DOCKER_IMAGE_NAME": "<supply-your-image>", | ||
| "MAD_CONFIG": "mad-config.kimi-k3.yaml", |
| sys.path.insert(0, "/home/madengine/src") | ||
|
|
||
| MAD = "/tmp/madtest/MAD_pr242" | ||
| CARD_DIR = f"{MAD}/scripts/vllm_multinode" |
| target = out / f"madengine_{safe}.sh" | ||
| target.write_text("#!/bin/bash\n") | ||
| ok2 = target.is_file() and target.parent == out |
built on top of existing kimi k3 pr for madengine enablement and testing