Skip to content

WIP: port MI355X DeepSeek-R1 MXFP4 disagg to srt-slurm - #2633

Open
cquil11 wants to merge 58 commits into
mainfrom
agent/port-dsr1-fp4-mi355x-srt-slurm
Open

WIP: port MI355X DeepSeek-R1 MXFP4 disagg to srt-slurm#2633
cquil11 wants to merge 58 commits into
mainfrom
agent/port-dsr1-fp4-mi355x-srt-slurm

Conversation

@cquil11

@cquil11 cquil11 commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • replace the legacy MI355X DeepSeek-R1 MXFP4 disaggregated launcher with srt-slurm
  • preserve the STP and MTP fixed-sequence topology/tuning search space as explicit recipe selectors
  • use SGLang Router, MoRI KV transfer, and the unchanged InferenceX benchmark_serving.py custom benchmark
  • support shared local production checkpoints and one-time shared-cache prefetch for the external NextN draft model

Stack

This PR is stacked on #2631 so it can reuse the reviewed MI355X srt-slurm launcher/runtime integration. It should be rebased onto main after #2631 merges.

Validation so far

  • 231 matrix generation/validation tests pass
  • master config expands to exactly 14 jobs across the three migrated config keys
  • all 12 explicit srt-slurm selectors resolve and pass a live MI355X dry-run with the pinned srt-slurm runtime
  • local model validation confirms the complete shared MXFP4 checkpoint; historical production evidence confirms the external SGLang/DeepSeek-R1-NextN draft model contract

Full InferenceX sweep validation is next and will be linked here.

cquil11 added 30 commits August 9, 2026 19:02
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.
@github-actions

Copy link
Copy Markdown
Contributor

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 As a PR reviewer and CODEOWNER, I have reviewed this and have.

For PR verification, add the full-sweep-fail-fast label (strongly recommended) to this PR — the benchmark sweep only runs on labeled PRs. Use full-sweep-enabled only if you need matrix jobs to keep running past a failure.

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 模板,包括保留英文语句 As a PR reviewer and CODEOWNER, I have reviewed this and have

如需进行 PR 验证,请为此 PR 添加 full-sweep-fail-fast 标签(强烈推荐)— 基准测试 sweep 仅在带有标签的 PR 上运行。仅当需要矩阵任务在失败后继续运行时才使用 full-sweep-enabled

PR 作者有责任确保合并后所有 GitHub Action 任务完全通过。 很多时候失败只是偶发抖动(flake),重新运行失败的任务即可解决。参见 GitHub 关于重新运行失败任务的文档

@cquil11
cquil11 marked this pull request as ready for review August 17, 2026 05:50
@cquil11
cquil11 changed the base branch from agent/port-dsr1-fp8-mi355x-srt-slurm to main August 17, 2026 05:51
@cquil11
cquil11 requested a review from a team August 17, 2026 05:51
Comment on lines +314 to +326
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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:

  1. 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_tp8 for both its prefill and decode keys, reaching into the internals of a block that has nothing to do with it structurally.
  2. 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), and override_mtp1_2p1d_dep8 (480) — all alias *cg512 even though none of them share any other config with override_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.

Comment on lines +89 to +111

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment on lines +125 to +130
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" \

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

  1. SRT_MODEL_LOCAL_PATH is empty -> the else branch runs -> model_repos+=("$MODEL") -> model_repos=("deepseek-ai/DeepSeek-R1-0528").
  2. SRT_DRAFT_MODEL is set -> model_repos+=("$SRT_DRAFT_MODEL") -> model_repos=("deepseek-ai/DeepSeek-R1-0528" "SGLang/DeepSeek-R1-NextN").
  3. model_repo_list="deepseek-ai/DeepSeek-R1-0528,SGLang/DeepSeek-R1-NextN".
  4. srun receives --export=ALL,...,MODEL_REPOS=deepseek-ai/DeepSeek-R1-0528,SGLang/DeepSeek-R1-NextN.
  5. srun's own comma-split parses this as two tokens: MODEL_REPOS=deepseek-ai/DeepSeek-R1-0528 (accepted) and SGLang/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).
  6. Inside the container, os.environ["MODEL_REPOS"] == "deepseek-ai/DeepSeek-R1-0528" only.
  7. snapshot_download runs 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.

@github-actions

Copy link
Copy Markdown
Contributor

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

1 participant