WIP: port MI355X DeepSeek-R1 MXFP4 disagg to srt-slurm - #2633
Conversation
Document the MI300X aggregate and disaggregated validation plan, cluster assumptions, safety boundaries, and current srt-slurm development pin.
Add the reusable MI300X cluster runtime profile and a minimal stable-vLLM fixed-sequence aggregate recipe for functional bring-up.
…ntegration # Conflicts: # perf-changelog.yaml
|
Thanks for the contribution! Please reach out to respective companies' CODEOWNER to fill in the latest PR_REVIEW_CHECKLIST.md before pinging core maintainer on Slack for review. In order for the signoff PR check bot to trigger, you must follow the PR_REVIEW_CHECKLIST.md template correctly, including the phrase For PR verification, add the PR authors are responsible for ensuring that after merging, all GitHub Action jobs fully pass. A lot of the time, failures are just flakes and simply re-running the failed jobs will fix it. See GitHub's docs on re-running failed jobs 感谢你的贡献!请联系相应公司的 CODEOWNER 填写最新的 PR_REVIEW_CHECKLIST.md,然后再在 Slack 上联系核心维护者进行审阅。为了触发 signoff PR 检查机器人,你必须正确遵循 PR_REVIEW_CHECKLIST.md 模板,包括保留英文语句 如需进行 PR 验证,请为此 PR 添加 PR 作者有责任确保合并后所有 GitHub Action 任务完全通过。 很多时候失败只是偶发抖动(flake),重新运行失败的任务即可解决。参见 GitHub 关于重新运行失败任务的文档 |
…i355x-srt-slurm # Conflicts: # configs/amd-master.yaml # perf-changelog.yaml
| override_mtp3_1p2d_tp8_narrow: | ||
| name: "mi355x-dsr1-fp4-mtp3-1p2d-tp8-narrow-fixed-seq" | ||
| resources: | ||
| prefill_nodes: 1 | ||
| decode_nodes: 2 | ||
| prefill_workers: 1 | ||
| decode_workers: 2 | ||
| backend: | ||
| decode_environment: | ||
| SGLANG_MORI_NUM_MAX_DISPATCH_TOKENS_PER_RANK: "2048" | ||
| sglang_config: | ||
| prefill: *mtp3_tp8 | ||
| decode: *mtp3_tp8 |
There was a problem hiding this comment.
🟡 override_mtp3_1p2d_tp8_narrow (lines 325-326) aliases *mtp3_tp8, an anchor defined only inside the sibling override_mtp3_1p2d_tp8_wide block (line 299). Similarly, override_stp_2p1d_dep8, override_mtp3_1p1d_dep8, override_mtp1_1p1d_dep8, and override_mtp1_2p1d_dep8 all alias *cg512, defined only inside override_stp_1p1d_dep8 (line 222) — 5 blocks depending on one sibling's internals. This parses fine today, but consider hoisting the shared cuda-graph-bs list (and mtp3_tp8) to a base/top-level anchor, the same way &cg128 already is, so each override stays independently editable.
Extended reasoning...
Every override block in fp4-disagg-fixed-seq.yaml is supposed to be an independently editable unit — that's the whole point of the override_* naming convention, and the file's own base section demonstrates the right pattern: the large cuda-graph-bs list is hoisted once to &cg128 at the top level and then aliased with *cg128 wherever it's needed (prefill and decode within base).
Two override blocks break that pattern by defining a YAML anchor that is then consumed by other, unrelated override blocks rather than just within themselves:
override_mtp3_1p2d_tp8_wide(prefill block, line 299) defines&mtp3_tp8. The very next sibling block,override_mtp3_1p2d_tp8_narrow(lines 325-326), doesn't define its own speculative-decoding config — it aliases*mtp3_tp8for both itsprefillanddecodekeys, reaching into the internals of a block that has nothing to do with it structurally.override_stp_1p1d_dep8's decode block (line 222) defines&cg512, the 512-entry cuda-graph-bs list. Four other override blocks —override_stp_2p1d_dep8(261),override_mtp3_1p1d_dep8(393),override_mtp1_1p1d_dep8(434), andoverride_mtp1_2p1d_dep8(480) — all alias*cg512even though none of them share any other config withoverride_stp_1p1d_dep8.
Because this is a single YAML document, anchors are document-scoped and resolved in document order — *cg512/*mtp3_tp8 work today only because their defining block happens to appear earlier in the file than every block that aliases it. Nothing in the file signals this dependency locally: reading override_stp_2p1d_dep8 in isolation gives no hint that its cuda-graph-bs list actually lives inside override_stp_1p1d_dep8. This is fragile in a way the file's own convention (base-level &cg128) was clearly designed to avoid.
Concrete failure scenario: a future contributor decides override_mtp3_1p2d_tp8_wide is no longer needed and deletes it (or simply renames its anchor while touching MTP3-wide tuning). YAML anchors are undefined outside their defining document position, so override_mtp3_1p2d_tp8_narrow's *mtp3_tp8 alias on lines 325-326 would immediately fail to parse — with an error pointing at line 325, giving no indication that the real cause is the deletion of an anchor 26 lines earlier in a "sibling" block. The same applies to &cg512: reordering or deleting override_stp_1p1d_dep8 silently breaks parsing for the 4 dependent blocks (up to 5 total counting override_stp_2p1d_dep8), scattered across unrelated topology/tuning families (STP-dep8, MTP3-dep8, MTP1-dep8 ×2).
This is purely a maintainability/DRY concern — the YAML parses correctly today because every defining anchor happens to precede its uses in document order, so nothing is broken at merge time. The fix that avoids the fragility mirrors what the file already does for &cg128: hoist the shared cuda-graph-bs 512-entry list to a base/top-level anchor (e.g. &cg512 defined once in base, or promoted alongside &cg128), and likewise hoist the mtp3_tp8 speculative-decoding config to a shared top-level anchor, then have every dependent override alias the shared, top-level definition instead of a sibling override's internals. That keeps every override block independently addable, removable, or reorderable without risk of silently breaking unrelated blocks.
|
|
||
| model_repos=() | ||
| if [[ -n "$SRT_MODEL_LOCAL_PATH" ]]; then | ||
| python3 - "$SRT_MODEL_LOCAL_PATH" <<'PYMODEL' | ||
| import json | ||
| import sys | ||
| from pathlib import Path | ||
|
|
||
| root = Path(sys.argv[1]) | ||
| if not root.is_dir(): | ||
| raise SystemExit(f"local model directory does not exist: {root}") | ||
| for required in ("config.json", "tokenizer_config.json", "model.safetensors.index.json"): | ||
| if not (root / required).is_file(): | ||
| raise SystemExit(f"local model is missing {required}: {root}") | ||
| index = json.loads((root / "model.safetensors.index.json").read_text()) | ||
| shards = sorted(set(index.get("weight_map", {}).values())) | ||
| if not shards: | ||
| raise SystemExit(f"local model index has no shards: {root}") | ||
| missing = [shard for shard in shards if not (root / shard).is_file()] | ||
| if missing: | ||
| raise SystemExit(f"local model is missing {len(missing)} indexed shards: {missing[:5]}") | ||
| print(f"validated local model {root}: {len(shards)} indexed shards") | ||
| PYMODEL |
There was a problem hiding this comment.
🟡 The new PYMODEL heredoc in runners/launch_mi355x-amds-srt.sh (lines ~89-108) reimplements checkpoint-completeness validation (tokenizer/config metadata + model.safetensors.index.json shard-existence walk) that already exists as checkpoint_is_complete() in benchmarks/single_node/agentic/glm5.2_fp4_b200_sglang_mtp.sh and minimaxm3_fp4_b200_mtp.sh/minimaxm3_fp8_h200.sh. This is a pre-existing pattern being copied a fourth time rather than shared, so a future fix to the shard-verification logic will need to be applied in multiple places; consider extracting a shared validate_checkpoint.py helper.
Extended reasoning...
runners/launch_mi355x-amds-srt.sh (lines 89-108) adds a standalone Python heredoc, PYMODEL, that validates a local checkpoint directory before it is used for MI355X MXFP4 disaggregated serving: it checks that config.json, tokenizer_config.json, and model.safetensors.index.json exist, then loads the index file, computes the sorted set of shard filenames from weight_map, and confirms every named shard is present on disk — raising SystemExit on any gap.
This is functionally the same check already implemented as a bash helper, checkpoint_is_complete(), in benchmarks/single_node/agentic/glm5.2_fp4_b200_sglang_mtp.sh (lines 56-72) and duplicated again in minimaxm3_fp4_b200_mtp.sh / minimaxm3_fp8_h200.sh. That helper checks tokenizer_config.json + (tokenizer.json or tokenizer.model) + model.safetensors.index.json, then runs an inline Python block that loads the index, computes sorted(set(weight_map.values())), and exits nonzero if any shard file is missing — the identical core validation the new PR code performs. The existing helper explicitly exists to catch aborted or metadata-only checkpoint pulls (its usage is tied to CI run 30729467646, where a checkpoint directory existed with valid metadata but incomplete shard data, which caused a serving failure that this check now guards against).
None of these implementations — the two/three existing copies or this PRs new one — are shared through benchmarks/benchmark_lib.sh or any common location. grep -r "checkpoint_is_complete\|weight_map" benchmarks/ confirms they are textually independent copies. Concretely: if a future run reveals that the shard-existence check also needs to handle sharded tokenizer.model files (as flagged by the differences between the two existing copies, one of which checks tokenizer.model/tokenizer.json while the new code omits that entirely and only checks config.json+tokenizer_config.json), that fix has to be manually propagated across three-plus files, and it is easy to update one copy and forget the others, silently leaving some launchers vulnerable to the exact aborted-pull failure mode the check was designed to prevent.
The mitigating factor, which keeps this from being a "must-fix," is that the new copy runs inside a generated sbatch heredoc that is srun-dispatched to a remote compute node and does not source benchmark_lib.sh or any repo-relative bash file, so directly reusing the existing bash function is not a drop-in replacement — some plumbing (e.g., embedding a shared Python script, or writing it out at generation time) would be needed to de-duplicate. That makes this a design/DRY suggestion rather than a functional defect: the new code is correct as written, it just reimplements a well-tested pattern instead of centralizing it.
Proof of duplication: (1) Read benchmarks/single_node/agentic/glm5.2_fp4_b200_sglang_mtp.sh:56-72 — defines checkpoint_is_complete(), which loads model.safetensors.index.json, computes shards = sorted(set(json.load(fh)["weight_map"].values())), and exits 1 if any shard file is missing. (2) Read runners/launch_mi355x-amds-srt.sh:89-108 (the new PYMODEL heredoc) — computes shards = sorted(set(index.get("weight_map", {}).values())) and raises SystemExit if any shard file (root / shard) is missing. (3) These are the same algorithm on the same JSON structure, confirming a duplicate rather than a coincidental similarity. (4) grep across benchmarks/ for weight_map and checkpoint_is_complete shows no shared module defines this logic once for reuse.
Recommended fix: extract the shared JSON-index + shard-existence walk into a small standalone validate_checkpoint.py (or equivalent) that both the bash helpers and this generated heredoc can invoke/embed, so the completeness contract (including the tokenizer file check the new code currently omits) lives in one place.
| model_repo_list="\$(IFS=,; echo "\${model_repos[*]}")" | ||
| srun --nodes=1 --ntasks=1 \ | ||
| --container-image="$SHARED_IMAGE" \ | ||
| --container-mounts="$SHARED_HF_CACHE:/hf_hub_cache,$LEGACY_HF_CACHE:$LEGACY_HF_CACHE" \ | ||
| --container-writable --container-remap-root --no-container-entrypoint \ | ||
| --export=ALL,HF_HOME=/hf_hub_cache,HF_HUB_CACHE=/hf_hub_cache/hub,HUGGINGFACE_HUB_CACHE=/hf_hub_cache/hub,MODEL_REPOS="\$model_repo_list" \ |
There was a problem hiding this comment.
🟡 srun --export splits its argument on commas, so when model_repos (runners/launch_mi355x-amds-srt.sh:125-130) ever holds 2+ entries, the comma-joined MODEL_REPOS="$model_repo_list" gets mis-parsed: only the first repo survives as MODEL_REPOS, and the second becomes a bogus bare token that srun drops. No config in configs/amd-master.yaml triggers this today (every MTP arm pairs SRT_MODEL_LOCAL_PATH with SRT_DRAFT_MODEL, giving a single-entry list), but it will silently truncate the prefetch the moment a recipe sets SRT_DRAFT_MODEL without SRT_MODEL_LOCAL_PATH. Use a non-comma delimiter (space or newline) for MODEL_REPOS and split on that in the Python snapshot_download loop.
Extended reasoning...
The bug: runners/launch_mi355x-amds-srt.sh builds model_repos as a bash array and joins it with commas (IFS=,; echo "${model_repos[*]}") to produce model_repo_list, then passes it to srun as:
--export=ALL,HF_HOME=...,MODEL_REPOS="$model_repo_list"
Slurm's srun --export documents its argument as a comma-separated list of NAME or NAME=value tokens, with no escaping mechanism for a comma embedded inside a value. So once model_repos has two or more entries, e.g. ("deepseek-ai/DeepSeek-R1-0528" "SGLang/DeepSeek-R1-NextN"), the resulting string MODEL_REPOS=repoA,repoB is not parsed as one NAME=value pair — srun splits on the comma first, yielding two tokens: MODEL_REPOS=repoA (a valid export) and a bare repoB (interpreted as "propagate an existing env var named repoB"). Since repoB contains a / (HF repo ids always do) it is not a legal environment variable name and does not exist in the parent shell anyway, so srun silently drops it.
Where this fires: Inside the container, os.environ["MODEL_REPOS"] therefore only ever contains the first repo. The Python one-liner [snapshot_download(repo) for repo in os.environ["MODEL_REPOS"].split(",") if repo] then only prefetches repo[0]; every repo after the first is silently skipped from the shared-cache prefetch step.
Why the surrounding code doesn't catch this: The array/loop refactor in this PR was written specifically to support multiple repos — seed_legacy_cache is called in a for repo in "${model_repos[@]}" loop, and the Python download step is a list comprehension precisely to prefetch more than one model. The one link in that chain that can't carry more than one element is the comma-joined --export transport, because Slurm's own comma-delimited parsing collides with the comma-joined value.
Reachability today: I checked every entry in configs/amd-master.yaml that reaches this launcher. Every MTP arm sets SRT_MODEL_LOCAL_PATH alongside SRT_DRAFT_MODEL (see e.g. override_mtp3_1p1d_tp8 selectors), so model_repos becomes [SRT_DRAFT_MODEL] — a single entry, because the script's else branch that appends $MODEL is skipped when SRT_MODEL_LOCAL_PATH is set. Every non-MTP (STP) arm sets only SRT_MODEL_LOCAL_PATH, giving an empty model_repos (early exit at ${#model_repos[@]} == 0). So no config shipped in this PR triggers the multi-entry path — the bug is latent.
Step-by-step proof of the failure mode (hypothetical future recipe): Suppose a future disagg-MTP recipe uses a remote (non-local) main model plus a draft model — i.e. it sets SRT_DRAFT_MODEL=SGLang/DeepSeek-R1-NextN but does not set SRT_MODEL_LOCAL_PATH, and MODEL=deepseek-ai/DeepSeek-R1-0528.
SRT_MODEL_LOCAL_PATHis empty -> theelsebranch runs ->model_repos+=("$MODEL")->model_repos=("deepseek-ai/DeepSeek-R1-0528").SRT_DRAFT_MODELis set ->model_repos+=("$SRT_DRAFT_MODEL")->model_repos=("deepseek-ai/DeepSeek-R1-0528" "SGLang/DeepSeek-R1-NextN").model_repo_list="deepseek-ai/DeepSeek-R1-0528,SGLang/DeepSeek-R1-NextN".- srun receives
--export=ALL,...,MODEL_REPOS=deepseek-ai/DeepSeek-R1-0528,SGLang/DeepSeek-R1-NextN. - srun's own comma-split parses this as two tokens:
MODEL_REPOS=deepseek-ai/DeepSeek-R1-0528(accepted) andSGLang/DeepSeek-R1-NextN(a bare token — not a legal var name because of the/, and not present in the environment, so it's dropped with no error). - Inside the container,
os.environ["MODEL_REPOS"]=="deepseek-ai/DeepSeek-R1-0528"only. snapshot_downloadruns once, for the main model only. The NextN draft model is never pre-seeded into the shared cache.
Impact: This degrades gracefully rather than crashing — the skipped draft model just falls back to being downloaded independently by each P/D worker at startup, which is the exact pre-refactor behavior the PR set out to eliminate for shared-cache efficiency. It does not corrupt results or fail the job.
Fix: Use a delimiter that cannot appear inside an HF repo id and that Slurm's --export parser will not itself split on — e.g. a space or newline — when joining model_repo_list, and split on that same delimiter in the Python os.environ["MODEL_REPOS"].split(...) call. Since --export values also generally can't contain characters like spaces safely across all Slurm versions/shells, a newline joined via printf '%s\n' (or passing repos through a small temp file instead of --export) is the more robust choice.
|
see unofficial run visualizer at https://inferencex.semianalysis.com/inference?unofficialRun=31999776709 |
Summary
benchmark_serving.pycustom benchmarkStack
This PR is stacked on #2631 so it can reuse the reviewed MI355X srt-slurm launcher/runtime integration. It should be rebased onto
mainafter #2631 merges.Validation so far
SGLang/DeepSeek-R1-NextNdraft model contractFull InferenceX sweep validation is next and will be linked here.