Skip to content

[TileRT] Add GLM-5.3 FP8 MI355X prefill/decode-disaggregated recipes (8k1k + AgentX) / 新增 GLM-5.3 FP8 MI355X 的 PD 分离配方(8k1k + AgentX) - #3330

Open
Oseltamivir wants to merge 18 commits into
mainfrom
feat/glm5.3-fp8-mi355x-tilert
Open

Oseltamivir wants to merge 18 commits into
mainfrom
feat/glm5.3-fp8-mi355x-tilert

Conversation

@Oseltamivir

@Oseltamivir Oseltamivir commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator

Upstream continuation of #3328 by @CrimsonDump. The branch is the same commit, authored by @CrimsonDump and re-pushed to this repository so the normal same-repository sweep pipeline can run it (fork PRs need the trusted dispatcher). Review follow-ups are pushed here as separate commits. Credit for the recipe belongs to @CrimsonDump.

Status (2026-09-22): full sweep green on 992c9c0a1 (run 35715072662) at GLM-5.3's full 1048576-token context: 8k1k conc 1 → 16/16, median TTFT 886 ms, TPOT 1.97 ms, 344 tok/s; AgentX conc 1 → 234 requests, ISL p50 ~337k tokens, TTFT median 6.49 s, 491 tok/s per user. bf16 MLA wire layout (the only one TileRT 0.1.6 accepts from vLLM on ROCm); TileRT's two 99.06 GiB PD buffers live in 2 MiB-backed pinned host memory via a 5-file patch applied at container start (engine-patch waiver docs/waiver/3330.md; vLLM unpatched). Depends on InferenceX-app#1202 being deployed first (the app had no glm5.3 DB key; without it the site drops these rows at unofficial render and at official ingest). Open: one RUN_EVAL=true GSM8K pass on MI355X, the waiver's upstream TileRT link, the provisional golden-AL curve. Derivations: root cause, ionic registration ceiling, cross-node transfer proof, results.

中文

本 PR 是 #3328(作者 @CrimsonDump)在上游仓库的延续。分支为同一提交,作者仍为 @CrimsonDump,仅重新推送到本仓库,以便走常规的同仓库 sweep 流程(fork PR 需要受信任调度器)。评审后续修改以独立提交追加在此。配方的原始贡献归属 @CrimsonDump

Description

Adds GLM-5.3 FP8 on MI355X as a prefill/decode-disaggregated TileRT recipe: upstream vLLM ROCm prefill (TileRTConnector, kv_producer) + TileRT decode_server + an OpenAI-compatible pd_router, framework: tilert, KV over mooncake. Two scenarios — Single-turn 8k1k and AgentX agentic-coding — mirroring the merged B200 glm5.1-fp8-b200-tilert entries on the amd_utils orchestration.

Artifacts. Two first-party images, ghcr.io/tile-ai/tilert-rocm-decode:0.1.6 (decode) and ghcr.io/tile-ai/tilert-rocm-prefill:0.1.6 (prefill, the upstream vllm/vllm-openai-rocm release image plus the mooncake ROCm transfer engine). Neither carries the wheel: setup_deps.sh installs tilert==0.1.6 from PyPI into both ranks at container start — full install on decode, --no-deps connector plugin on prefill, so the image's torch/transformers/vLLM are untouched.

Topology. 1 prefill node (TP8) + 1 decode node (TP8), conc-list: [1] because TileRT decode serves one sequence at a time. bf16 KV cache on both roles — the ROCm vLLM sparse-MLA backend has no fp8_ds_mla dtype. max-model-len 1048576 (GLM-5.3's full context window), MTP at draft depth 3, gpu-memory-utilization 0.85 on the prefill.

Golden AL. Nothing hard-codes an acceptance length. server_tilert.sh reads the committed curve at launch, selecting it exactly as infx/srt_slurm/synthetic_acceptance.py does, applies it to agentic throughput only, suppresses it under RUN_EVAL/EVAL_ONLY, and fails the run rather than silently falling back to real acceptance if the curve or draft length is missing.

Reviewers: golden_al_distribution/glm5.3_mtp.yaml is GLM-5.2's curve, copied. No SPEED-Bench run exists for GLM-5.3; the two share a base and layer topology (config.json differs only by the fp8 quantization block), and only the K=3 cell is carried over because the ROCm TileRT GLM profile builds MTP at that depth only. The file is labelled PROVISIONAL. Flagging it rather than letting it read as a measured 5.3 curve — happy to take direction: a measured curve, a non-MTP agentic entry, or a waiver.

Two checklist items worth stating up front. Framework ordering: GLM-5.3 on AMD has no vLLM/SGLang entry, so this lands a TileRT arm first; the in-tree precedent is NVIDIA glm5.1, also TileRT-only, merged in #2533 (2026-08-09) and #2650 (2026-08-23), both after the ordering rule was added in #1886 (2026-06-22). Engine patching, with waiver: vLLM runs exactly as shipped, but the TileRT PD transport layer is patched at container start (benchmarks/multi_node/amd_utils/patches/tilert-0.1.6-pd-buffers-in-dram.patch, 5 pure-Python files under tilert/pd_vllm, applied by setup_deps.sh to the pip-installed tilert==0.1.6 on both ranks when TILERT_PD_BUFFER_DEVICE=cpu; marker-gated, pinned to 0.1.6) so that both PD buffers live in 2 MiB-backed pinned host memory; at the full 1M context they do not fit on the GPU. Covered by docs/waiver/3330.md — what is patched, why the unmodified stack cannot run this benchmark, upstream link (TileRT issue/PR pending), and the removal plan.

AI model disclosure

  • Model/version (original recipe): claude-opus-5[1m] — Claude Opus 5 (1M context), via Claude Code. This is the identifier the original runtime exposed.
  • Follow-up commits on this PR (review fixes, orchestration and memory analysis from d924515e5; the DRAM-buffer patch and waiver from 432e65111): claude-opus-5[1m] (Claude Opus 5, 1M context) through fb663fa26, then claude-fable-5-1[1m] (Claude Fable 5.1) from 432e65111, both via Claude Code; the adversarial verification and review subagents ran on claude-fable-5-1[1m]. Numbers quoted in those commits and comments were measured on the MI355X cluster or computed from the tilert 0.1.6 / vLLM 0.24.0 sources, as stated per item.
  • Role: Wrote the recipe, orchestration and configs; ran the local validation on 2x8 MI350X; drafted this description and the commit message. All numbers quoted here were produced by the runs described under Validation and read back from the resulting artifacts, not generated by the model.
  • Follow-up model: GPT 6 Astra, via Perplexity Computer. Ported the RDMAV_FORK_SAFE=1 fix from CrimsonDump's commit to both TileRT ranks, added explanatory comments and performance-changelog entries, and ran shell syntax and changelog/matrix validation. No delegated agents were used for this follow-up; no GPU/RDMA benchmark was rerun.

Related Issue

Type of Change

  • Bug fix
  • New feature
  • Configuration change
  • Documentation update
  • Other (please describe)

Validation

Run on 2x8 MI350X with those images and wheel, through the official entry points:

result
GSM8K (RUN_EVAL=true EVAL_ONLY=true, 1319 items) strict-match 0.9750 ±0.0043, flexible-extract 0.9735 ±0.0044
AgentX 3600 s, conc 1 submission_valid: true, coverage TTFT 99.8% / ITL 99.9%, 532 requests, 0 errors
Single-turn 8k1k, conc 1 TTFT p50 1010 ms, 16/16 requests completed
Matrix logic UTs 335 passed

MI350X is not MI355X. Same gfx950 ISA, 256 CU / 288 GB / 8 TB/s, but 1000 W vs 1400 W TBP and 2200 vs 2400 MHz peak. These numbers show the recipe runs and is accurate; they are not leaderboard results — the sweep produces those on real mi355x runners.

Checklist

  • I have completed the AI model disclosure and kept it current
  • I have tested my changes locally
  • Engine-patch waiver filed at docs/waiver/3330.md (TileRT PD transport only; vLLM unpatched)
  • I have updated documentation if necessary
  • For every change that can affect benchmark performance and every recipe addition or modification, I have appended a new entry to the physical end of perf-changelog.yaml and have not edited historical entries
  • Before merging via reuse, an authorized maintainer (OWNER/MEMBER/COLLABORATOR) has commented /use <run_id> (or the legacy /reuse-sweep-run) on this PR
中文

改动说明

以 TileRT 的 prefill/decode 分离形态新增 MI355X 上的 GLM-5.3 FP8:上游 vLLM ROCm 作 prefill(TileRTConnector、kv_producer)+ TileRT decode_server + 兼容 OpenAI 的 pd_routerframework: tilert,KV 走 mooncake。两个场景:单轮 8k1k 与 AgentX 智能体编码,形态对齐已合并的 B200 glm5.1-fp8-b200-tilert,走 amd_utils 编排链。

产物:两个第一方镜像 —— decode 用 ghcr.io/tile-ai/tilert-rocm-decode:0.1.6,prefill 用 ghcr.io/tile-ai/tilert-rocm-prefill:0.1.6(上游 vllm/vllm-openai-rocm 发布镜像 + 源码构建的 mooncake ROCm 传输引擎)。两者都不含 wheel:setup_deps.sh 在容器启动时从 PyPI 装 tilert==0.1.6,decode 全装、prefill --no-deps 只装 connector 插件,镜像内的 torch/transformers/vLLM 一概不动。

拓扑:1 个 prefill 节点(TP8)+ 1 个 decode 节点(TP8),conc-list: [1],因为 TileRT decode 一次只服务一条序列。两侧均用 bf16 KV cache —— ROCm 上 vLLM 的稀疏 MLA 后端没有 fp8_ds_mlamax-model-len 1048576,MTP 草稿深度 3。

黄金接受率:任何地方都不写死接受率。server_tilert.sh 在启动时读取提交的曲线,选择规则与 infx/srt_slurm/synthetic_acceptance.py 完全一致;仅作用于 agentic 吞吐,RUN_EVAL/EVAL_ONLY 下自动关闭;曲线或草稿深度缺失时直接失败,而不是静默回落到真实接受率。

请评审裁决golden_al_distribution/glm5.3_mtp.yaml从 GLM-5.2 抄来的曲线。GLM-5.3 没有 SPEED-Bench 数据,两者同底座、同层拓扑(config.json 仅多 fp8 量化块),且只搬了 K=3 那一格(ROCm 的 TileRT GLM profile 只编了这个深度)。文件头已标 PROVISIONAL。主动挑明,而不是让它看起来像实测的 5.3 曲线。实测曲线、改非 MTP 的 agentic 条目、或走豁免,都听安排。

两条主动说明的清单条款:① 框架顺序 —— AMD 侧 GLM-5.3 目前只有 TileRT;在树先例是 NVIDIA glm5.1 同样只有 TileRT(#2533#2650,均在规则 #1886 之后合入)。② 引擎补丁与豁免 —— vLLM 原样运行;但 TileRT 的 PD 传输层在容器启动时被打补丁(patches/tilert-0.1.6-pd-buffers-in-dram.patch,由 setup_deps.shTILERT_PD_BUFFER_DEVICE=cpu 时应用),使两个 PD 缓冲位于 2 MiB 大页支撑的锁页主机内存中——完整 1M 上下文下它们无法放入 GPU。已按 docs/waiver/3330.md 提交豁免(补丁内容、未修改栈无法运行的原因、上游链接(TileRT PR 待开)、移除计划)。

AI 模型使用说明

  • 模型/版本(原始配方)claude-opus-5[1m] —— Claude Opus 5(1M 上下文),经 Claude Code 使用。这是原始运行时暴露的标识。
  • 本 PR 后续提交(自 d924515e5 起的评审修复、编排与内存分析;自 432e65111 起的 DRAM 缓冲补丁与豁免):至 fb663fa26claude-opus-5[1m],自 432e65111 起为 claude-fable-5-1[1m],均经 Claude Code;对抗性验证与评审子代理运行于 claude-fable-5-1[1m]。所引数字均为 MI355X 集群实测或由 tilert 0.1.6 / vLLM 0.24.0 源码计算,逐项注明。
  • 工作内容:编写配方、编排与配置;在 2×8 MI350X 上执行本地验证;起草本说明与提交信息。文中所有数字均来自「验证」一节所述的实际运行、并从产物中读回,不是模型生成的。
  • 后续修改模型GPT 6 Astra,经 Perplexity Computer 使用。将 CrimsonDump 提交中的 RDMAV_FORK_SAFE=1 修复移植到 TileRT 两侧,补充说明注释和性能变更记录,并执行 Shell 语法及变更记录/矩阵验证。本次后续修改未使用委派 agent,也未重新运行 GPU/RDMA 基准测试。

关联 issue

无。

改动类型

新功能 + 配置改动。

验证结果

在 2×8 MI350X 上、用上述镜像与 wheel、走官方入口:GSM8K strict-match 0.9750、flexible-extract 0.9735;AgentX 3600 秒 submission_valid: true、覆盖率 99.8%/99.9%、532 请求、0 错误;单轮 8k1k TTFT p50 1010 ms、16/16 请求完成;矩阵逻辑单测 335 通过。

⚠️ 这是 MI350X,不是 MI355X:同 gfx950 ISA、同 256 CU / 288 GB / 8 TB/s,但 TBP 1000W vs 1400W、峰值频率 2200 vs 2400 MHz。这些数字用于证明配方能跑且结果正确,不是打榜成绩 —— 榜上成绩由 sweep 在真实 mi355x 机器上产出。

检查清单

清单已在上方英文部分勾选,此处不重复。

…/ 新增 GLM-5.3 FP8 MI355X 的 PD 分离配方

Adds GLM-5.3 FP8 on MI355X as a prefill/decode-disaggregated TileRT recipe:
upstream vLLM ROCm prefill (TileRTConnector, kv_producer) + TileRT
decode_server + an OpenAI-compatible pd_router, framework=tilert, KV over
mooncake. Two scenarios, Single-turn 8k1k and AgentX agentic-coding, mirroring
the merged B200 glm5.1-fp8-b200-tilert entries on the amd_utils orchestration.

以 TileRT 的 prefill/decode 分离形态新增 MI355X 上的 GLM-5.3 FP8:上游 vLLM
ROCm 作 prefill + TileRT decode_server + 兼容 OpenAI 的 pd_router,KV 走
mooncake。两个场景:单轮 8k1k 与 AgentX 智能体编码,形态对齐已合并的 B200
glm5.1-fp8-b200-tilert,走 amd_utils 编排链。

IMAGES AND WHEEL / 镜像与轮子
  decode  ghcr.io/tile-ai/tilert-rocm-decode:0.1.6
  prefill ghcr.io/tile-ai/tilert-rocm-prefill:0.1.6
          (the upstream vllm/vllm-openai-rocm release image plus the mooncake
           ROCm transfer engine built from source)
Neither image carries the wheel; setup_deps.sh installs tilert==0.1.6 from
PyPI into both ranks at container start -- full on decode, --no-deps connector
plugin on prefill -- so vLLM runs exactly as shipped and no file inside either
image is modified.

TOPOLOGY / 拓扑
1 prefill node (TP8) + 1 decode node (TP8), conc-list [1] because TileRT decode
serves one sequence at a time. bf16 KV cache on both roles -- the ROCm vLLM
sparse-MLA backend has no fp8_ds_mla dtype. max-model-len 202752. MTP via
spec-decoding=mtp at draft depth 3. Prefill runs --block-size 64: the ROCm
backend supports [1,64] and vLLM picks 1, which makes the connector's KI plane
copy fail and MLA address the wrong rows.

GOLDEN AL / 黄金接受率
Nothing hard-codes an acceptance length. server_tilert.sh reads the committed
curve at launch, selecting it exactly as infx/srt_slurm/synthetic_acceptance.py
does (<model-prefix>_<method>.yaml, then [model][thinking mode][draft tokens]),
applies it to agentic throughput only, suppresses it under RUN_EVAL/EVAL_ONLY
because simulated acceptance would score ~0 on GSM8K, and fails the run rather
than falling back to real acceptance when the curve or draft length is missing.

接受率不写死在任何配方或配置里,由 server_tilert.sh 启动时从提交的曲线读取,
选择规则与上游 srt-slurm 连接器一致;仅作用于 agentic 吞吐,eval 下自动关闭,
曲线缺失时直接失败而非静默回落。

NOTE FOR REVIEWERS: golden_al_distribution/glm5.3_mtp.yaml is GLM-5.2's curve,
copied. No SPEED-Bench run exists for GLM-5.3 and the two share a base and
layer topology. Only the K=3 cell is carried over. The file is labelled
PROVISIONAL. Flagged rather than left to read as a measured 5.3 curve.

LOCAL VALIDATION / 本地验证
Run on 2x8 MI350X with these images and wheel, through the official entry
points:
  GSM8K (RUN_EVAL + EVAL_ONLY, 1319 items)  strict 0.9750, flexible 0.9735
  AgentX 3600 s, conc 1                     submission_valid true, coverage
                                            99.8%/99.9%, 532 requests
  Single-turn 8k1k, conc 1                  TTFT p50 1010 ms
MI350X is not MI355X: same gfx950 ISA and memory system, but 1000 W vs 1400 W
and 2200 vs 2400 MHz. These show the recipe runs and is accurate; they are not
leaderboard results, which the sweep produces on real mi355x runners.

本地数据跑在 MI350X 上,不是 MI355X,仅用于证明配方能跑且结果正确,
不作为打榜成绩。

Matrix UTs 335 passed. perf-changelog is a pure tail append; the 8457 lines
inherited from main are byte-for-byte unchanged.
@github-actions

Copy link
Copy Markdown
Contributor

Thanks for the contribution!

  • Review: If this PR changes files owned by someone other than a repository admin or @SemiAnalysisAI/core, ask one eligible CODEOWNER to complete the latest PR_REVIEW_CHECKLIST.md before contacting a core maintainer on Slack. Follow the template exactly, including As a PR reviewer and CODEOWNER, I have reviewed this and have, so sign-off verification triggers.
  • PR verification: Sweeps only run on labeled PRs. Add full-sweep-fail-fast (strongly recommended); use full-sweep-enabled only when matrix jobs should continue after a failure.
  • After merging: PR authors must ensure all GitHub Actions jobs pass. Transient failures often pass on rerun; see how to rerun failed jobs.
中文

感谢你的贡献!

  • **审阅:**如果 PR 修改的文件归属于仓库管理员及 @SemiAnalysisAI/core 之外的 CODEOWNER,请先联系一位有资格的 CODEOWNER 填写最新的 PR_REVIEW_CHECKLIST.md,再通过 Slack 联系核心维护者。必须严格遵循模板,并保留 As a PR reviewer and CODEOWNER, I have reviewed this and have,才能触发签核验证。
  • **PR 验证:**扫描仅在带有标签的 PR 上运行。强烈建议添加 full-sweep-fail-fast;仅当需要矩阵任务在失败后继续运行时才使用 full-sweep-enabled
  • **合并后:**PR 作者必须确保所有 GitHub Actions 任务通过。临时性失败通常可以通过重新运行恢复;参见重新运行失败任务的说明

… / 修正 TileRT 配方的 Bash 规范与 MTP 草稿深度

Review follow-ups on the GLM-5.3 FP8 MI355X TileRT recipes.

- server_tilert.sh no longer invents defaults for caller-supplied
  configuration. Every value now arrives from the recipe through job.slurm
  and is validated with check_env_vars, mirroring server_atom.sh. The same
  applies to the two recipes and to the tilert branch of setup_deps.sh.
- Draft depth is consistent. DECODE_MTP_SIZE=3 is the TileRT decode draft
  depth, used by the weight converter and as the key the golden acceptance
  curve is read at; PREFILL_SPEC_TOKENS=1 is the vLLM prefill rank's MTP
  layer. Previously the converter and the curve used a hard-coded 3 while
  the recipes exported an unused DECODE_MTP_SIZE=1.
- THINKING_MODE and MODEL_PREFIX are forwarded into the container and
  validated, so the golden-acceptance selection no longer falls back to
  built-in guesses.
- job.slurm drops MODEL_NAME="${MODEL_NAME:-None}", which masked a missing
  model name for every engine, not just tilert.
- models_tilert.yaml keeps only model-owned engine environment; profile,
  model_type and max_model_len moved to the recipes, which removes the
  stale glm-5_2 model_type that the recipe already overrode.
- launch_mi355x-amds.sh and server.sh no longer carry unrelated comment and
  blank-line deletions.
- MODELS.md/MODELS_zh.md and the two changelog entries point at the
  upstream PR and carry the correct date.

以评审意见修正 GLM-5.3 FP8 MI355X 的 TileRT 配方:server_tilert.sh 不再为调用方
提供的配置自造默认值,全部改由配方经 job.slurm 传入并用 check_env_vars 校验,
与 server_atom.sh 保持一致;统一 MTP 草稿深度(decode 侧 DECODE_MTP_SIZE=3,
供权重转换与黄金接受率曲线取值,prefill 侧 PREFILL_SPEC_TOKENS=1);将
THINKING_MODE 与 MODEL_PREFIX 传入容器并校验;移除 job.slurm 中会掩盖所有引擎
MODEL_NAME 缺失的兜底;models_tilert.yaml 只保留模型自有的引擎环境;撤销
launcher 与 server.sh 中与本次改动无关的删除;文档与 changelog 链接改为上游 PR。
@Oseltamivir

Copy link
Copy Markdown
Collaborator Author

Review sweep of the carried-over commit; follow-ups pushed in d924515 (authored to @CrimsonDump).

Fixed

  1. server_tilert.sh invented defaults for caller-supplied configuration. The whole preamble was ${VAR:-default} with no check_env_vars, which the mandatory Bash conventions in AGENTS.md forbid. Every caller-owned value now comes from the recipe through job.slurm and is validated up front, mirroring server_atom.sh. Same treatment for the two recipes and the tilert branch of setup_deps.sh (TILERT_PIP_SPEC/TILERT_PIP_INDEX_URL/TILERT_PACKAGE were unreachable knobs; the version is now pinned by the recipe and validated).
  2. MTP draft depth was inconsistent. The recipes exported DECODE_MTP_SIZE=1, which nothing read; the weight converter and the golden-AL lookup both hard-coded 3. Now DECODE_MTP_SIZE=3 is the single TileRT decode draft depth — used by --num_mtp and as the curve key — and PREFILL_SPEC_TOKENS=1 is the vLLM prefill rank's MTP layer, with the asymmetry commented.
  3. THINKING_MODE and MODEL_PREFIX were never forwarded into the container, so the golden-AL selection silently used built-in guesses for the mode and the model prefix. Both are forwarded and validated now.
  4. job.slurm added MODEL_NAME="${MODEL_NAME:-None}" above the shared model-resolution block, masking a missing model name for every engine, not just tilert. MODEL_NAME is already set by launch_mi355x-amds.sh (${MODEL##*/}), so the line is removed.
  5. models_tilert.yaml carried dead, contradictory configmodel_type: glm-5_2 that the recipe already overrode with glm-5, plus profile/max_model_len the recipe also set. It now holds only model-owned engine environment, as models_atom.yaml does.
  6. Unrelated deletions in launch_mi355x-amds.sh (a dangling comment and three blank lines) and server.sh reverted; both files now carry only the tilert route.
  7. Docs and both changelog entries point at this PR and carry the correct date; the changelog MTP sentence now states the real depths.

Local checks

check result
test-config on both keys validates, node-count: 2 on each
bash -n on all touched scripts clean
server_tilert.sh DRY_RUN=1, decode role, stubbed PATH golden AL 2.99 read from glm5.3_mtp.yaml at thinking_on/K=3, correct decode_server argv
same, prefill role correct vllm serve argv (block-size 64, num_speculative_tokens: 1) and pd_router argv
missing one required var fails with Error: The following required environment variables are not set: PREFILL_BLOCK_SIZE

Still open for a CODEOWNER, unchanged from #3328: golden_al_distribution/glm5.3_mtp.yaml is GLM-5.2's curve copied, labelled PROVISIONAL. It is kept as submitted rather than resolved here, so the AgentX throughput number this sweep produces rests on an unmeasured acceptance length. AGENTS.md says a missing golden curve should fail rather than be worked around, so this needs an explicit call: a measured SPEED-Bench curve, a non-MTP agentic entry, or a waiver.

full-sweep-fail-fast is running: https://github.com/SemiAnalysisAI/InferenceX/actions/runs/35594261910

AI model disclosure

  • Model/version: claude-opus-5[1m] — Claude Opus 5 (1M context), via Claude Code, for the review sweep and the d924515 follow-ups. The original recipe commit's disclosure stands as written by @CrimsonDump.
  • Role: Reviewed the diff, made the fixes above, ran the local checks listed, and dispatched the sweep. No delegated agents.
中文

对迁移过来的提交做了评审清扫,修复以 d924515 推送(作者署 @CrimsonDump)。

  1. server_tilert.sh 为调用方配置自造默认值:整段前言都是 ${VAR:-default} 且没有 check_env_vars,违反 AGENTS.md 的强制 Bash 规范。现全部由配方经 job.slurm 传入并在入口校验,与 server_atom.sh 一致;两个配方与 setup_deps.sh 的 tilert 分支同样处理。
  2. MTP 草稿深度不一致:配方导出的 DECODE_MTP_SIZE=1 无人读取,权重转换与黄金接受率查表都写死 3。现统一为 DECODE_MTP_SIZE=3(decode 侧真实草稿深度,同时是曲线取值键),PREFILL_SPEC_TOKENS=1 为 prefill 侧 MTP 层,并加注说明二者本就不同。
  3. THINKING_MODEMODEL_PREFIX 从未传入容器,黄金接受率选择实际走的是内置猜测值;现已传入并校验。
  4. job.slurmMODEL_NAME="${MODEL_NAME:-None}" 位于所有引擎共用的模型解析之前,会掩盖任何引擎的 MODEL_NAME 缺失;该值本就由 launcher 设置,故删除。
  5. models_tilert.yaml 存在失效且矛盾的配置model_type: glm-5_2 已被配方覆盖为 glm-5),现只保留模型自有的引擎环境。
  6. 撤销无关删除:launcher 与 server.sh 现只含 tilert 路由改动。
  7. 文档与两条 changelog 条目改指本 PR、修正日期,并如实描述 MTP 深度。

本地检查结果见上表。仍需 CODEOWNER 裁决glm5.3_mtp.yaml 仍是 GLM-5.2 曲线的副本(标记为 PROVISIONAL),本次未作处理,因此本轮 sweep 产出的 AgentX 吞吐建立在未实测的接受率之上;AGENTS.md 要求缺失曲线应直接失败而非绕过,请明确选择:实测曲线、改为非 MTP 的 agentic 条目,或走豁免。

…完整 1M

max-model-len was 202752 on both roles. That is GLM-5.1's context window,
inherited from the B200 glm5.1-fp8-b200-tilert recipe this one mirrors
(perf-changelog entry for that recipe records "max-seq-len 202752"), not
GLM-5.3's. GLM-5.3 shares GLM-5.2's base, and every in-tree GLM-5.2 recipe
-- TRT-LLM and SGLang alike -- runs at 1048576. Both roles now launch at
1048576, and the AgentX trace filter follows it through MAX_MODEL_LEN.

原配方两侧均为 202752,这是 GLM-5.1 的上下文窗口,沿用自本配方对标的 B200
glm5.1-fp8-b200-tilert,并非 GLM-5.3 的。GLM-5.3 与 GLM-5.2 同底座,而树内所有
GLM-5.2 配方(TRT-LLM 与 SGLang)均使用 1048576。现两侧均以 1048576 启动,
AgentX 轨迹过滤经 MAX_MODEL_LEN 随之生效。
@Oseltamivir

Copy link
Copy Markdown
Collaborator Author

Context length corrected in df5e13b: 202752 → 1048576 on both roles.

202752 is GLM-5.1's context window, inherited from the B200 glm5.1-fp8-b200-tilert recipe this one mirrors — its changelog entry records max-seq-len 202752. It is not GLM-5.3's. GLM-5.3 shares GLM-5.2's base (the same premise the provisional AL curve rests on), and every in-tree GLM-5.2 recipe runs at the full 1M: 31 occurrences across glm5.2/trtllm/gb300-fp4/ and glm5.2/sglang/, all 1048576, with no other value anywhere in that family.

Both roles now launch at 1048576, and the AgentX trace filter follows it through MAX_MODEL_LEN — traces are no longer truncated at a fifth of the model's window. The changelog entries and the PR body are updated to match.

Two things this changes for the sweep, worth watching in the logs: the prefill rank now sizes its KV allocation for a 1M window at --gpu-memory-utilization 0.75, and the TileRT decode rank allocates its bf16 KV for the same. At bs=1 on 288 GB MI355X this should fit, but if the prefill OOMs at startup that is the first thing to look at.

The earlier sweep (35594261910) was superseded before its GPU jobs started; the restarted run is https://github.com/SemiAnalysisAI/InferenceX/actions/runs/35595701800.

中文

df5e13b 已将两侧上下文长度从 202752 改为 1048576

202752 是 GLM-5.1 的上下文窗口,沿用自本配方对标的 B200 glm5.1-fp8-b200-tilert(其 changelog 条目即记为 max-seq-len 202752),并非 GLM-5.3 的。GLM-5.3 与 GLM-5.2 同底座(这也正是那条临时 AL 曲线所依据的前提),而树内 GLM-5.2 的全部配方都跑在完整 1M 上:glm5.2/trtllm/gb300-fp4/glm5.2/sglang/ 共 31 处,均为 1048576,该系列没有其他取值。

现两侧均以 1048576 启动,AgentX 轨迹过滤经 MAX_MODEL_LEN 随之生效,轨迹不再被截断到模型窗口的五分之一。changelog 与 PR 描述已同步。

需在日志中关注的两点:prefill 侧现按 1M 窗口、--gpu-memory-utilization 0.75 分配 KV,decode 侧同样按 1M 分配 bf16 KV。bs=1、288 GB 的 MI355X 上应当放得下,若 prefill 启动即 OOM,这是首先要查的地方。

此前的 sweep(35594261910)在 GPU 作业开始前已被取代,重启后的运行为 35595701800。

@github-actions

github-actions Bot commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

…pool / 补齐 MI355X 上 TileRT 的 amd_utils 编排输入

The tilert arm of benchmarks/multi_node/runtime_settings.sh is the B200
srt-slurm profile. The MI355X recipes run through the shared amd_utils chain
instead, whose submit.sh validates ROUTER_TYPE, PROXY_PING_PORT,
HEADNODE_PORT and SERVER_PORT, and whose job.slurm and setup_deps.sh also
require VLLM_ROUTER_IMAGE, SKIP_RDMA_CHECK, SKIP_GPU_SANITY, ENABLE_METRICS,
PREFILL_ROUTER_POLICY, DECODE_ROUTER_POLICY, FLUSH_DRAIN_TIMEOUT,
CLEAR_CACHE_BETWEEN_CONC, ROCM_PATH, UCX_HOME and RIXL_HOME. None of those
were exported for FRAMEWORK=tilert, so submit.sh exited before sbatch and the
recipe returned no job id. The AMD block is scoped by RUNNER_TYPE so the B200
TileRT lane is untouched.

The fixed-seq-len recipe now sets KV_OFFLOADING=none itself: submit.sh and
job.slurm validate it, and the matrix emits it only for agentic scenarios.

launch_mi355x-amds.sh now fails when a recipe returns no job id. Its wait
loop polled for slurm_job-.out while its liveness guard degenerated to
`grep -q ""`, which matches any queued job of that user, so a failed submit
span the loop until the job hit its time limit rather than reporting the
error.

benchmarks/multi_node/runtime_settings.sh 的 tilert 分支是 B200 的 srt-slurm
配置,而 MI355X 走的是共用 amd_utils 链:submit.sh 校验 ROUTER_TYPE、
PROXY_PING_PORT、HEADNODE_PORT、SERVER_PORT,job.slurm 与 setup_deps.sh 另需
VLLM_ROUTER_IMAGE、SKIP_RDMA_CHECK、SKIP_GPU_SANITY、ENABLE_METRICS、
PREFILL_ROUTER_POLICY、DECODE_ROUTER_POLICY、FLUSH_DRAIN_TIMEOUT、
CLEAR_CACHE_BETWEEN_CONC、ROCM_PATH、UCX_HOME、RIXL_HOME。FRAMEWORK=tilert
下这些一概未导出,导致 submit.sh 在 sbatch 之前退出、配方返回空 job id。AMD 专用
块以 RUNNER_TYPE 限定,不影响 B200。

定长配方自行设置 KV_OFFLOADING=none(矩阵仅在 agentic 场景下产出该字段)。

launch_mi355x-amds.sh 现在在配方未返回 job id 时直接失败:原等待循环会一直轮询
slurm_job-.out,而其存活判断退化为 `grep -q ""`,会匹配该用户任意排队作业,于是
提交失败不会报错,而是空转到作业超时。
@Oseltamivir

Copy link
Copy Markdown
Collaborator Author

Root cause of the stuck jobs, fixed in ad1c997.

Why they hung. The tilert arm of benchmarks/multi_node/runtime_settings.sh is the B200 srt-slurm profile. These MI355X recipes run through the shared amd_utils chain instead (submit.shjob.slurmserver.shsetup_deps.sh), and that chain validates orchestration inputs the tilert arm never exported — only the sglang-disagg|vllm-disagg|atom-disagg arm sets them. submit.sh's check_env_vars fired and it exited before sbatch, so the recipe returned no job id.

Reproduced against the stub sbatch, with RUNNER_TYPE outside the AMD pool:

non-AMD RUNNER_TYPE -> JOB_ID=""
Error: The following required environment variables are not set:
  - ROUTER_TYPE
  - PROXY_PING_PORT
  - HEADNODE_PORT
  - SERVER_PORT

job.slurm and setup_deps.sh would then have wanted VLLM_ROUTER_IMAGE, SKIP_RDMA_CHECK, SKIP_GPU_SANITY, ENABLE_METRICS, PREFILL_ROUTER_POLICY, DECODE_ROUTER_POLICY, FLUSH_DRAIN_TIMEOUT, CLEAR_CACHE_BETWEEN_CONC, ROCM_PATH, UCX_HOME, RIXL_HOME as well.

Why it hung instead of failing. With JOB_ID empty, the launcher waits on slurm_job-.out, and its liveness guard is squeue -u "$USER" --noheader --format=%i | grep -q "$JOB_ID" — an empty JOB_ID makes that grep -q "", which matches any line squeue prints. Another runner having anything queued under cameronamd@ is enough to keep the guard satisfied forever, so the ls / squeue / sleep 5 loop spins to the job's time limit rather than reporting the submit failure. That is exactly the trace that was visible in the live log.

Three fixes

  1. runtime_settings.sh: the tilert arm now exports the amd_utils inputs, scoped by RUNNER_TYPE to *mi355x-amds* so the B200 TileRT lane is untouched. RUNNER_TYPE is validated at the top of the arm, so a missing value fails loudly instead of silently skipping the block.
  2. The fixed-seq-len recipe sets KV_OFFLOADING=none itself — submit.sh and job.slurm both validate it, and the matrix emits it only for agentic scenarios, so the 8k1k lane had no value at all.
  3. launch_mi355x-amds.sh fails when a recipe returns no job id, instead of entering that loop. This is a latent trap for every recipe on the pool, not just this one: any pre-sbatch failure previously burned the full time limit with no error.

Verified against a stub sbatch/squeue: both recipes now reach submission and return a job id (8k1k -> JOB_ID=987654, agentic -> JOB_ID=987654); with the AMD block bypassed both return empty, with the four-variable error above.

Sweep restarted: https://github.com/SemiAnalysisAI/InferenceX/actions/runs/35602514701

中文

ad1c997 已修复作业卡死的根因。

卡死原因benchmarks/multi_node/runtime_settings.shtilert 分支是 B200 的 srt-slurm 配置,而这两个 MI355X 配方走的是共用 amd_utils 链(submit.shjob.slurmserver.shsetup_deps.sh)。该链所校验的编排输入只在 sglang-disagg|vllm-disagg|atom-disagg 分支导出,tilert 分支一概没有,于是 submit.shcheck_env_varssbatch 之前退出,配方返回空 job id。用桩 sbatch 复现,缺失的正是 ROUTER_TYPEPROXY_PING_PORTHEADNODE_PORTSERVER_PORT 四项。

为何是卡死而不是报错JOB_ID 为空时,launcher 等待 slurm_job-.out,其存活判断 squeue ... | grep -q "$JOB_ID" 退化为 grep -q "",会匹配 squeue 输出的任意一行。只要该账号下还有其他排队作业,该判断就一直成立,循环便空转到作业超时,而不会报出提交失败 —— 这正是实时日志中看到的那段 trace。

三处修复:① tilert 分支补齐 amd_utils 所需输入,并以 RUNNER_TYPE 限定在 *mi355x-amds*,不影响 B200,且在分支开头校验 RUNNER_TYPE 以免静默跳过;② 定长配方自行设置 KV_OFFLOADING=none(矩阵仅在 agentic 场景产出该字段,8k1k 此前根本没有取值);③ launch_mi355x-amds.sh 在配方未返回 job id 时直接失败 —— 这是该机器池上所有配方的潜在陷阱,任何 sbatch 之前的失败此前都会空转满整个时间上限。

已用桩 sbatch/squeue 验证:两个配方现均能提交并返回 job id;绕过 AMD 专用块后则均返回空并报上述四项缺失。sweep 已重启。

@Oseltamivir

Copy link
Copy Markdown
Collaborator Author

Sweep 35602514701: orchestration now works end to end, and the run failed on a cluster-data gap rather than on code.

sbatch succeeded (Slurm jobs 45258 / 45259), two nodes allocated (mia1-p01-g11, mia1-p01-g14), ENGINE: tilert, Model found: GLM-5.3, Runfile set: server.sh. Both lanes then failed in about 60 seconds at the model-availability check:

Checking model availability across all allocated nodes...
mia1-p01-g11: Missing /it-share/data/GLM-5.3
mia1-p01-g14: Missing /it-share/data/GLM-5.3
FATAL ERROR: Model 'GLM-5.3' not found on ALL allocated nodes

launch_mi355x-amds.sh pins MODEL_PATH=/it-share/data for every MI355X recipe, so the checkpoint has to be at /it-share/data/GLM-5.3. The cluster has GLM-5-FP8, GLM-5-MXFP4 and GLM-5.2-MXFP4, but no GLM-5.3 in any precision — the local validation in #3328 ran on a 2x8 MI350X box with the weights already present, so this was never exercised on the sweep cluster.

Staging it now: zai-org/GLM-5.3 is public and ungated, 753B parameters, F8_E4M3 with BF16/F32 tails, 756 GB across 141 shards; /it-share has 3.2 TB free. The download runs on mia1-vm-amd-prj3-slog-001 into /it-share/data/.GLM-5.3.download and is moved into place only once complete — the availability check tests directory existence, so a partial download under the real name would pass the check and fail later in a much more confusing way. Permissions get widened for the runner account afterwards.

No further code change is expected from this; the sweep will be re-dispatched against the same head once the weights are in place.

中文

Sweep 35602514701:编排链已完全走通,本次失败源于集群数据缺失,而非代码。

sbatch 提交成功(Slurm 作业 45258 / 45259),分配到两个节点(mia1-p01-g11mia1-p01-g14),ENGINE: tilertModel found: GLM-5.3Runfile set: server.sh 均正常。两条 lane 随后在约 60 秒时于模型可用性检查处失败:两个节点上均缺少 /it-share/data/GLM-5.3

launch_mi355x-amds.sh 为所有 MI355X 配方固定 MODEL_PATH=/it-share/data,因此权重必须位于 /it-share/data/GLM-5.3。该集群现有 GLM-5-FP8GLM-5-MXFP4GLM-5.2-MXFP4,但没有任何精度的 GLM-5.3 —— #3328 的本地验证是在已有权重的 2×8 MI350X 机器上进行的,这条路径从未在 sweep 集群上跑过。

正在补齐:zai-org/GLM-5.3 为公开非受限仓库,753B 参数,F8_E4M3 加 BF16/F32 尾部,141 个分片共 756 GB;/it-share 剩余 3.2 TB。下载在 mia1-vm-amd-prj3-slog-001 上进行,落到 /it-share/data/.GLM-5.3.download,完成后才移入正式路径 —— 可用性检查只判断目录是否存在,若以正式名称存放未完成的下载,检查会通过并在后续以更难排查的方式失败。完成后会为 runner 账号放开读取权限。

预计无需再改代码;权重就位后将以同一 head 重新调度 sweep。

Port the launcher fix from CrimsonDump/InferenceX commit 9e745f3. Set RDMAV_FORK_SAFE=1 before either role initializes verbs to avoid silent TCP fallback and degraded TTFT; TPOT is unaffected. Append performance changelog entries for 8k1k and AgentX.

在 prefill 和 decode 两侧初始化 verbs 前设置 RDMAV_FORK_SAFE=1,避免静默回退到 TCP 导致 TTFT 恶化;TPOT 不受影响。补充代码注释及 8k1k、AgentX 性能变更记录。
@Oseltamivir

Copy link
Copy Markdown
Collaborator Author

Applied the missing RDMA environment fix in commit 54cef68, ported from CrimsonDump’s fix. server_tilert.sh now exports RDMAV_FORK_SAFE=1 before either rank initializes verbs. Without it, ibv_fork_init() can fail while Mooncake reports successful initialization, silently falling back to TCP and degrading TTFT; TPOT is unaffected. Added a code comment and performance-changelog entries for 8k1k and AgentX. Shell syntax, diff checks, and changelog/matrix validation passed. GPU/RDMA benchmarks were not rerun.

中文

已在提交 54cef68 中移植 CrimsonDump 的 RDMA 修复。server_tilert.sh 现在在两侧初始化 verbs 前导出 RDMAV_FORK_SAFE=1。缺少该设置时,ibv_fork_init() 可能失败,但 Mooncake 仍报告初始化成功,静默回退到 TCP,导致 TTFT 恶化;TPOT 不受影响。已补充代码注释以及 8k1k 和 AgentX 性能变更记录。Shell 语法、差异检查及变更记录/矩阵验证均通过;未重新运行 GPU/RDMA 基准测试。

…位后重新触发 sweep

The three earlier sweeps failed at the model-availability check because
/it-share/data/GLM-5.3 did not exist on the MI355X cluster. zai-org/GLM-5.3
is now staged there: 155 files, 141 shards, 704 GiB, every file byte-exact
against the Hugging Face manifest, world-readable for the runner account.

config.json confirms fp8 (e4m3, weight_block_size 128x128) and
max_position_embeddings 1048576, matching the recipe's precision and context.

No code change. Re-applying the sweep label produced label events but no
workflow run, and run-sweep.yml has no workflow_dispatch trigger, so this
empty commit provides the synchronize event.

此前三次 sweep 均因 MI355X 集群上不存在 /it-share/data/GLM-5.3 而在模型可用性
检查处失败。现已完成权重落盘:155 个文件、141 个分片、704 GiB,逐文件字节数与
Hugging Face manifest 完全一致,并已对 runner 账号开放读取权限。config.json
确认为 fp8(e4m3,weight_block_size 128x128)、max_position_embeddings 为
1048576,与配方的精度与上下文设置一致。

无代码改动。重新添加 sweep 标签只产生了 label 事件而未触发工作流,且
run-sweep.yml 没有 workflow_dispatch 触发器,故以此空提交产生 synchronize 事件。
@Oseltamivir
Oseltamivir marked this pull request as ready for review September 21, 2026 16:19
@Oseltamivir
Oseltamivir requested a review from a team September 21, 2026 16:19
…合入分支

perf-changelog.yaml was the only conflict: both sides appended to the tail
(base 8457 lines; main +59, this branch +35). Resolved so main's 8516 lines
stay byte-identical and this branch's four entries move to the physical end,
as the append-only rule requires. 1002 entries, no other file conflicted.

perf-changelog.yaml 是唯一冲突:双方均为尾部追加(基线 8457 行,main +59,
本分支 +35)。按 append-only 规则解决:保持 main 的 8516 行逐字节不变,本分支
的四条entries移至文件物理末尾。共 1002 条,其余文件无冲突。
neg.err was shell trace output from a local submit-chain negative test, run
with the working directory inside amd_utils and swept up by a `git add -A`
in ad1c997. It is not part of the recipe.

neg.err 是在 amd_utils 目录内执行提交链负向测试时产生的 shell trace 输出,
被 ad1c997 的 `git add -A` 误纳入,与配方无关。
…ileRT 路径不再校验 MoRI 与 SGLang 环境变量

Sweep 35625047683 reached the container, installed tilert 0.1.6 and found
mooncake, then failed in env.sh on eleven variables it never reads:
MORI_IO_SQ_BACKOFF_TIMEOUT_US, MORI_IO_QP_MAX_SEND_WR, MORI_IO_QP_MAX_CQE,
MORI_IO_QP_MAX_SGE, MORI_IO_TC_DISABLE, UCX_IB_GID_INDEX, MORI_APP_LOG_LEVEL,
SGLANG_ROUTER_STDOUT_LOGS, TORCH_NCCL_BLOCKING_WAIT, NCCL_BLOCKING_WAIT and
SGLANG_OPT_USE_AITER_INDEXER.

Those are MoRI-IO queue-pair tuning, the UCX RoCE GID index, SGLang router
logging and the SGLang decode cuda-graph NCCL workaround. server_tilert.sh
references none of them; KV moves over mooncake and no SGLang router starts.
env.sh validated them before it had even validated ENGINE, so the list is now
scoped to the engines that consume it. Supplying values instead would mean
inventing MoRI tuning for a transport this recipe never uses. Verified that
ENGINE=sglang-disagg still fails on the same list.

Also export INFMAX_CONTAINER_WORKSPACE=/workspace for the agentic replay, which
runs in this container rather than a separate client: benchmark_lib's
install_agentic_deps and build_replay_cmd both require it, the SGLang path sets
it in its client container's env file, and launch_mi355x-amds.sh exports it only
on the DSv4.1-Flash single-node branch. Without it the agentic lane would have
failed at the next step.

sweep 35625047683 已进入容器、装好 tilert 0.1.6 并找到 mooncake,随后在 env.sh
因十一个它从不读取的变量而失败。这些是 MoRI-IO 队列对调优、UCX RoCE GID 索引、
SGLang router 日志以及 SGLang decode cuda-graph 的 NCCL 规避项;server_tilert.sh
一概不引用,KV 走 mooncake,也不启动 SGLang router。env.sh 甚至在校验 ENGINE
之前就校验了它们,现已将该列表限定到真正消费它们的引擎;若改为提供取值,等于为
本配方根本不用的传输层编造 MoRI 调优。已验证 ENGINE=sglang-disagg 仍会因同一
列表失败。

同时为 agentic 回放导出 INFMAX_CONTAINER_WORKSPACE=/workspace:回放在本容器内
运行而非独立客户端容器,benchmark_lib 的 install_agentic_deps 与 build_replay_cmd
均要求该变量,而 SGLang 路径是在其客户端容器的 env 文件中设置、launcher 仅在
DSv4.1-Flash 单节点分支导出。否则 agentic lane 会在下一步失败。

@claude claude Bot left a comment

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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread benchmarks/multi_node/amd_utils/neg.err Outdated
+ [[ 1 -ne 0 ]]
+ echo 'Failed to submit job'
Failed to submit job
+ exit 1

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.

🟡 (optional) A stray debug shell trace was committed to the repo instead of being discarded, leaking local developer paths into history. It is a raw set -x trace from a failed submit.sh run on the author's machine (/Users/bshan/InferenceX) plus an unrelated agent scratchpad path (/tmp/claude-502/...), added whole in commit ad1c997. Fix: remove benchmarks/multi_node/amd_utils/neg.err from the PR; it carries no functional content and only pollutes the repo with another developer's local paths.

Why this was flagged

benchmarks/multi_node/amd_utils/neg.err is a new 93-line file added in commit ad1c997 (fix(tilert): supply the amd_utils orchestration inputs on the MI355X pool). It is a captured bash -x trace of a failed local submit.sh invocation, not source, config, or documentation. It embeds the author's home directory path /Users/bshan/InferenceX and a separate, unrelated agent session scratchpad path /tmp/claude-502/-Users-bshan-InferenceX/a3355d0f-16c4-46ef-b540-6e20f6eba79c/scratchpad/d3/models. On base, no such file exists; after merge it sits permanently in the repository as debug noise and exposes local filesystem layout with no reviewer benefit.

Verification: nit. The candidate's factual claims all check out. git diff --name-status shows A benchmarks/multi_node/amd_utils/neg.err — a brand-new 93-line file added whole in commit ad1c997 ("fix(tilert): supply the amd_utils orchestration inputs..."), with all 93 lines being insertions. The file contents are a raw bash -x (set -x) trace of a failed local submit.sh run, not… | nit.…

…p8 MLA KV 以容纳 1M 上下文

Sweep 35626519997 failed on both lanes for the same reason, on opposite roles:

  decode (agentic, g14)  torch.OutOfMemoryError: tried to allocate 99.06 GiB
                         with 95.94 GiB free; 184.17 GiB of weights already
                         resident on a 287.98 GiB card
  prefill (8k1k, g09)    ValueError: 91.71 GiB KV cache is needed, which is
                         larger than the available KV cache memory (85.25 GiB)

bf16 MLA KV does not fit at 1048576 tokens on either rank. The recipe chose
bf16 because the ROCm sparse-MLA backend has no fp8_ds_mla, which is true but
does not imply bf16: ROCM_AITER_MLA_SPARSE lists fp8 and fp8_e4m3 in
supported_kv_cache_dtypes, and TileRT's MlaNsaProfile.configure() maps
fp8_ds_mla, fp8 and fp8_e4m3 onto the same mla_fp8 layout. Only fp8_ds_mla is
CUDA-only. Plain fp8 therefore gives both ranks the matching layout that
TileRT's --kv-cache-dtype requires, and halves the cache:
KV_BYTES_FP8 = 528 B/token against KV_BYTES_BF16 = 1024 B/token. Decode drops
to roughly 51 GB alongside its weights, prefill to roughly 46 GiB inside its
85.25 GiB budget.

Also take the container-creation barrier timeout from CONTAINER_BARRIER_TIMEOUT
(5400s) rather than hardcoding 1800s, matching how server_sglang.sh sources it.
This recipe pulls two ~32 GB images, one per rank; on a node caching neither,
the rank that comes up first waits out the whole timeout while its peer pulls.
The observed cold decode pull took about 25 minutes of the old 30-minute budget.

sweep 35626519997 两条 lane 因同一原因在相对的两侧失败:decode 侧在 287.98 GiB
卡上已驻留 184.17 GiB 权重、再分配 99.06 GiB KV 时 OOM;prefill 侧因需要
91.71 GiB KV 而可用 85.25 GiB 被 vLLM 拒绝。1048576 上下文下 bf16 MLA KV 两侧
均无法容纳。配方原先选 bf16 是因为 ROCm 稀疏 MLA 没有 fp8_ds_mla,此事为真但并
不意味着只能用 bf16:ROCM_AITER_MLA_SPARSE 的 supported_kv_cache_dtypes 含 fp8
与 fp8_e4m3,而 TileRT 的 MlaNsaProfile.configure() 将 fp8_ds_mla/fp8/fp8_e4m3
映射到同一 mla_fp8 布局,仅 fp8_ds_mla 为 CUDA 独有。因此普通 fp8 即可让两侧获得
TileRT 所要求的一致布局,并将 KV 减半(每 token 528 字节对 1024 字节)。

屏障超时同时改为取自 CONTAINER_BARRIER_TIMEOUT(5400 秒),与
server_sglang.sh 的做法一致:本配方每 rank 各拉取约 32 GB 镜像,在均未缓存的
节点上,先起来的一侧会在对端拉取期间耗尽原有 30 分钟预算(实测冷拉取约 25 分钟)。
…合入分支

Two conflicts from main's twelve new commits.

setup_deps.sh: #3349 deleted install_transformers_glm5 and its dispatch arm as
retired runtime support, while this branch had added the tilert arm next to it.
Took main's deletion and kept only the tilert helpers and elif arm, leaving no
else branch. Safe for this recipe: both images already load glm_moe_dsa, as the
last sweep showed -- decode loaded weights and prefill reached KV allocation.

perf-changelog.yaml: this branch appends 43 lines at the tail. Main's side also
carries mid-file insertions from #3207 and #2912, older branches that merged
later; those bytes are preserved exactly and this branch's five entries stay at
the physical end. 1009 entries, parses clean.

main 的十二个新提交带来两处冲突。setup_deps.sh:#3349 作为「移除已退役运行时
支持」删除了 install_transformers_glm5 及其分派分支,而本分支曾在其旁新增
tilert 分支;现采用 main 的删除,仅保留 tilert 辅助函数与 elif 分支,不留 else。
对本配方安全:两个镜像均已支持 glm_moe_dsa(上轮 sweep 中 decode 已加载权重、
prefill 已进入 KV 分配阶段)。perf-changelog.yaml:本分支在尾部追加 43 行;
main 侧另含 #3207#2912 这两个较早分支较晚合入而产生的文件中部插入,其字节
原样保留,本分支五条条目仍位于文件物理末尾。共 1009 条,解析正常。
…kip-sweep] / 恢复 bf16 布局,1M 上下文取决于 DRAM 侧 PD 缓冲

Reverts the fp8 KV change from 19bea0b. It cannot run: vLLM 0.24.0's
ROCM_AITER_MLA_SPARSE writes a flat 576 B/token fp8 row with a per-layer
scale, and TileRT 0.1.6 infers the layout from the tensor stride and accepts
only 1152 B (bf16) or 656 B (fp8_ds_mla), raising "unexpected MLA cache
stride 576 B/token" at register_kv_caches on every prefill rank. The original
bf16 choice was the only layout-correct one on ROCm. PREFILL_KV_DTYPE is now
an explicit bfloat16 (auto resolves the same on this model; float16 would
pass the stride check and be misread).

Memory at 1048576 tokens, per rank, verified against the tilert and vLLM
sources and the MI355X logs:
  decode : 90.72 GiB weights + 93.25 GiB engine cache window
           + 99.06 GiB PD receive buffer         (~283 of 287.98 GiB)
  prefill: 90.45 GiB weights + 40.3 GiB profiling + 91.71 GiB vLLM KV
           + 99.06 GiB PD staging buffer outside vLLM's budget (~321 GiB)
The two PD buffers are dense in max_seq_len. Both must move to pinned host
memory for this context to start, which is a TileRT change; the recipe is set
up for that release (GPU_MEM_UTIL 0.85 so vLLM's 222.5 GiB fits inside its
budget) and keeps 1048576. Until the wheel and images are bumped the sweep
cannot pass, hence [skip-sweep].

Also: drop the invented TILERT_CONVERT_DEVICE default; pass --num-mtp
"$DECODE_MTP_SIZE" to decode_server so converter, golden key and engine
depth agree by data flow; make the conversion-cache check read
tilert_meta.json num_mtp so a stale non-MTP cache is re-converted instead of
failing after a 90 GiB weight load; validate CONTAINER_BARRIER_TIMEOUT in
job.slurm's tilert branch; correct the earlier changelog text (router deps
are installed on decode only, models_tilert.yaml holds prefill env, not
profile defaults) and replace the wrong halving arithmetic.

撤销 19bea0b 的 fp8 KV 改动:vLLM 0.24.0 的 ROCM_AITER_MLA_SPARSE 写出每 token
576 字节的扁平 fp8 行,而 TileRT 0.1.6 依步长推断布局,只接受 1152(bf16)或
656(fp8_ds_mla)字节,会在 register_kv_caches 报 "unexpected MLA cache stride
576 B/token"。原作者的 bf16 是 ROCm 上唯一正确的布局。

1048576 上下文下每 rank 的内存(已对照源码与日志核实):decode 侧 90.72 GiB 权重
+ 93.25 GiB 引擎缓存窗口 + 99.06 GiB PD 接收缓冲(约 283/287.98 GiB);prefill
侧 90.45 GiB 权重 + 40.3 GiB 分析开销 + 91.71 GiB vLLM KV + 位于 vLLM 预算之外
的 99.06 GiB PD 暂存缓冲(约 321 GiB)。两个 PD 缓冲按 max_seq_len 密集分配,
须移入锁页主机内存才能在该上下文启动,此为 TileRT 侧改动;配方已为该版本就位
(GPU_MEM_UTIL 0.85)并保留 1048576。wheel 与镜像升级前 sweep 无法通过,故
[skip-sweep]。

另:移除 TILERT_CONVERT_DEVICE 的自造默认;向 decode_server 显式传递 --num-mtp;
转换缓存检查读取 tilert_meta.json 的 num_mtp;job.slurm 的 tilert 分支校验
CONTAINER_BARRIER_TIMEOUT;修正此前 changelog 中的错误表述与减半算术。
@Oseltamivir

Copy link
Copy Markdown
Collaborator Author

Verified root cause, and why 1M context is gated on a TileRT change

Every claim below was checked against the exact code that ran (tilert 0.1.6 wheel; vLLM 0.24.0 from ghcr.io/tile-ai/tilert-rocm-prefill:0.1.6) and the node-local logs of jobs 45280/45281, then independently re-derived. Units are GiB unless noted; the log's 99.06 GB is GiB mislabelled.

Decode rank (bf16 wire layout, --max-seq-len 1048576), cuda:0 — three dense allocations:

allocation size source
weights (fp8 kept as-is, no upcast) 90.72 weight_converter.py RankBlobLoader; du rank0 = 89 G
engine cache window, 78×(kv 1.000 + pe 0.125) + 22×ki 0.250 93.25 end2end.py:246-273; dtype gated by TILERT_GLM5_FP8_KV env, not by --kv-cache-dtype
PD receive buffer, 79 × (1024 + 128 + 132) B/token 99.06 receive_server.py:45, mla_nsa.py:99-109

184.17 GiB resident (weights + engine window, matches the OOM message to 0.08%) + 99.06 requested = ~283 of 287.98 GiB. It OOM'd on g14 (6.29 GiB non-torch) and served on g15 (≤3.17 GiB non-torch): a node-marginal fit for one sequence.

Prefill rank 0 (vLLM, gpu-memory-utilization 0.75) — independent, deterministic refusal: budget 215.98 − weights 90.45 − profiling/non-torch 40.29 = 85.25 available; needed = 16384 blocks × (79 MLA × 73,728 + 22 indexer × 8,448) B = 91.71 ✓ matches the log. And the connector allocates a 99.06 GiB staging buffer on TP rank 0 outside vLLM's budget (prefill_connector.py:148, torch.zeros(..., device='cuda:N'), lazily on the first request). 90.45 + 40.3 + 91.71 + 99.06 = 321.5 GiB: bf16 at 1M cannot fit prefill at any utilization.

Why the fp8 attempt (19bea0b) was wrong and is reverted. vLLM's ROCM_AITER_MLA_SPARSE has no fp8_ds_mla (supported_kv_cache_dtypes = [auto, float16, bfloat16, fp8, fp8_e4m3], rocm_aiter_mla_sparse.py:265-273); its plain fp8 writes a flat 576 B/token row with a per-layer scale. TileRT infers the layout from the tensor stride and accepts exactly 656 (fp8_ds_mla) or 1152 (bf16) — mla_nsa.py:202-209 raises unexpected MLA cache stride 576 B/token at register_kv_caches. configure() mapping the string fp8 to mla_fp8=True declares what TileRT expects; it does not change what vLLM ROCm writes. The author's bf16 choice was the only layout-correct option. (Would have failed loudly at prefill start, not silently.) Also corrected: "fp8 halves both" — the PE and KI planes are dtype-independent, so the receive buffer would be 60.79 GiB (×0.614), not "~51 GB".

Compared with the green SGLang arm (35403089860): same context setting (1,048,576, derived from the checkpoint), conc 16, fp8_e4m3, mem-fraction-static 0.85. Its KV is a paged pool sized by free memory (~150 GiB, ~3M tokens time-shared across sessions, plus a 1.5× DRAM tier) at 48–56 KB/token; MoRI registers that same pool for disaggregation (disaggregation/decode.py:517-524), so disagg adds no second full-context buffer. TileRT sizes two dense full-context buffers by max_seq_len at batch 1. That is the whole difference; aggregated-vs-disaggregated is not it.

Decision: full 1M context, PD buffers moved to DRAM. Branch state (432e6511, [skip-sweep]): bf16 on both roles (PREFILL_KV_DTYPE=bfloat16 explicit, never float16 — it passes the 1152 check and is misread), TILERT_MAX_MODEL_LEN=1048576, GPU_MEM_UTIL=0.85 (222.5 GiB inside a 244.8 budget, 22 GiB KV margin, 43 GiB outside for the ~6 GiB non-torch baseline), CONTAINER_BARRIER_TIMEOUT=5400, --num-mtp "$DECODE_MTP_SIZE" wired explicitly, conversion-cache check reads tilert_meta.json num_mtp, invented TILERT_CONVERT_DEVICE default removed. With both PD buffers in pinned host memory the decode rank needs ~190 GiB and prefill rank 0 fits inside vLLM's budget. Cost: the KV path gains two PCIe hops; at 101,437 B/token an 8k prompt is ~17 ms per hop, a 230k-token agentic prompt ~0.47 s per hop (≈ +1 s TTFT).

The sweep is gated on a TileRT release that places both PD buffers in DRAM. No such wheel exists on PyPI (latest 0.1.6). Below is a compile-checked patch against 0.1.6 that does it, for the TileRT team to take upstream — the repo forbids applying it in the recipe or image (docs/PR_REVIEW_CHECKLIST: no site-packages edits, no rebuilt wheels). It adds decode_server --pd-buffer-device cpu and connector extra config tilert_pd_buffer_device: cpu; the recipe will pass both once TILERT_VERSION and the two images are bumped. Nothing else in the recipe changes for that bump. RUN_EVAL=true (GSM8K) must run once on MI355X at that point: no bf16 P→D transfer has yet executed on this cluster.

tilert-0.1.6-pd-buffers-in-dram.diff (5 files, py_compile clean)
Only in new: __pycache__
diff -ru orig/decode_server.py new/decode_server.py
--- orig/decode_server.py	2026-09-22 10:06:55
+++ new/decode_server.py	2026-09-22 10:08:38
@@ -309,6 +309,7 @@
     ap.add_argument('--num-mtp', type=int, choices=(3,), default=3, help='MTP draft depth for speculative decoding; needs --with-mtp')
     ap.add_argument('--transport', choices=['mooncake', 'nixl'], default='mooncake', help="RDMA data-plane backend (must match prefill's tilert_transport)")
     ap.add_argument('--kv-cache-dtype', default='fp8_ds_mla', help='MLA cache dtype (must match vLLM prefill); MLA-family profiles only')
+    ap.add_argument('--pd-buffer-device', choices=['cuda', 'cpu'], default='cuda', help="where the PD receive buffer lives; 'cpu' = pinned host memory registered with the transport (frees buffer_bytes(max_seq_len) of VRAM, adds one H2D copy per request)")
     return ap
 
 def main() -> None:
@@ -329,7 +330,7 @@
         logger.info('loading TileRT engine (profile=%s, weights=%s)...', profile.name, args.model_weights_dir)
         engine = profile.build_engine(model_weights_dir=args.model_weights_dir, max_seq_len=args.max_seq_len, with_mtp=args.with_mtp, ar_steps=8, num_mtp=num_mtp)
         logger.info('TileRT engine ready (cache window %d, num_mtp %d)', engine.max_seq_len, num_mtp)
-    server = ReceiveServer(profile, max_seq_len=args.max_seq_len, ctrl_port=args.ctrl_port, transport=args.transport)
+    server = ReceiveServer(profile, max_seq_len=args.max_seq_len, ctrl_port=args.ctrl_port, transport=args.transport, buffer_device='cpu' if args.pd_buffer_device == 'cpu' else 'cuda:0')
     app = build_app(server, engine)
     logger.info('decode server on :%d (profile=%s, engine=%s, ctrl=:%d)', args.http_port, profile.name, args.engine, args.ctrl_port)
     sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
diff -ru orig/prefill_connector.py new/prefill_connector.py
--- orig/prefill_connector.py	2026-09-22 10:06:55
+++ new/prefill_connector.py	2026-09-22 10:07:21
@@ -49,6 +49,7 @@
         self._max_seq = int(extra.get('tilert_max_seq_len', vllm_config.model_config.max_model_len))
         self._profile = profiles.get_profile(extra.get('tilert_model', 'glm5'))
         self._transport_name = extra.get('tilert_transport', 'mooncake')
+        self._pd_buffer_device = str(extra.get('tilert_pd_buffer_device', 'cuda'))
         self._pending: dict[str, _Pending] = {}
         self._kv_caches: dict = {}
         self._reg = None
@@ -145,10 +146,18 @@
         hostname = wire.local_ip()
         total = self._profile.staging_bytes(self._reg, self._tp_rank, self._max_seq)
         dev = torch.cuda.current_device()
-        self._staging = torch.zeros(total, dtype=torch.uint8, device=f'cuda:{dev}')
+        # Dense in max_seq_len (99.06 GiB at 1M for glm5_2 bf16) and allocated
+        # outside vLLM's gpu-memory-utilization budget. In pinned host memory
+        # when tilert_pd_buffer_device == 'cpu'; extract() already fills it with
+        # copy_(), which performs the D2H transfer.
+        host = self._pd_buffer_device == 'cpu'
+        if host:
+            self._staging = torch.zeros(total, dtype=torch.uint8, pin_memory=True)
+        else:
+            self._staging = torch.zeros(total, dtype=torch.uint8, device=f'cuda:{dev}')
         self._transport = make_transport(self._transport_name)
         self._transport.init(hostname)
-        self._transport.register(self._staging.data_ptr(), total, dev)
+        self._transport.register(self._staging.data_ptr(), total, dev, host=host)
         self._sender_thread = threading.Thread(target=self._sender_loop, name='tilert-pd-sender', daemon=True)
         self._sender_thread.start()
         logger.info('worker ready: rank=%d transport=%s staging=%.1f MB profile=%s', self._tp_rank, self._transport.name, total / 1000000.0, self._profile.name)
Only in new/profiles: __pycache__
diff -ru orig/profiles/mla_nsa.py new/profiles/mla_nsa.py
--- orig/profiles/mla_nsa.py	2026-09-22 10:06:55
+++ new/profiles/mla_nsa.py	2026-09-22 10:07:21
@@ -125,14 +125,23 @@
         for lid in range(self.num_layers):
             ko = lid * max_seq_len * kv_bpt
             kv_raw = buffer[ko:ko + seq * kv_bpt].view(seq, kv_bpt)
+            # A host-resident receive buffer is moved to the device plane by
+            # plane; the copy scales with the request's seq_len, not max_seq_len.
+            if not buffer.is_cuda:
+                kv_raw = kv_raw.to('cuda:0', non_blocking=True)
             if self.mla_fp8:
                 kv = self._dequant_kv(kv_raw, seq)
             else:
                 kv = kv_raw.view(torch.bfloat16).view(seq, KV_LORA_RANK).contiguous()
             po = pe_base + lid * max_seq_len * PE_BPT
-            pe = buffer[po:po + seq * PE_BPT].view(torch.bfloat16).view(seq, QK_ROPE_HEAD_DIM).contiguous()
+            pe = buffer[po:po + seq * PE_BPT]
+            if not buffer.is_cuda:
+                pe = pe.to('cuda:0', non_blocking=True)
+            pe = pe.view(torch.bfloat16).view(seq, QK_ROPE_HEAD_DIM).contiguous()
             io = ki_base + lid * _max_pages(max_seq_len) * KI_PAGE_BYTES
             ki_raw = buffer[io:io + npages * KI_PAGE_BYTES].view(npages, KI_PAGE_BYTES)
+            if not buffer.is_cuda:
+                ki_raw = ki_raw.to('cuda:0', non_blocking=True)
             layers.append((self._dequant_ki(ki_raw, seq, self.ki_tiled), kv, pe))
         torch.cuda.synchronize()
         return ConvertedRequest(rid=received.rid, seq_len=seq, last_prompt_token=received.last_prompt_token, first_token_id=received.first_token_id, sampling=received.sampling, layers=layers)
diff -ru orig/receive_server.py new/receive_server.py
--- orig/receive_server.py	2026-09-22 10:06:55
+++ new/receive_server.py	2026-09-22 10:06:55
@@ -34,15 +34,24 @@
 
 class ReceiveServer:
 
-    def __init__(self, profile, max_seq_len: int, ctrl_port: int=5556, hostname: str | None=None, device: str='cuda:0', request_timeout: float=120.0, transport: str='mooncake'):
+    def __init__(self, profile, max_seq_len: int, ctrl_port: int=5556, hostname: str | None=None, device: str='cuda:0', request_timeout: float=120.0, transport: str='mooncake', buffer_device: str='cuda:0'):
         self.profile = profile
         self.max_seq_len = max_seq_len
         self.ctrl_port = ctrl_port
         self.device = device
         self.request_timeout = request_timeout
         total = profile.buffer_bytes(max_seq_len)
-        logger.info('allocating receive buffer: %.2f GB on %s (profile=%s)', total / 1024 ** 3, device, profile.name)
-        self.buffer = torch.zeros(total, dtype=torch.uint8, device=device)
+        # Dense in max_seq_len: 99.06 GiB at 1M tokens for glm5_2 bf16. Held in
+        # pinned host memory when buffer_device == 'cpu' so it does not compete
+        # with the weights and the engine cache window on the GPU; convert()
+        # then moves each request's planes to the device (H2D scales with the
+        # prompt, not with max_seq_len).
+        self.buffer_device = buffer_device
+        if buffer_device == 'cpu':
+            self.buffer = torch.zeros(total, dtype=torch.uint8, pin_memory=True)
+        else:
+            self.buffer = torch.zeros(total, dtype=torch.uint8, device=buffer_device)
+        logger.info('allocating receive buffer: %.2f GiB on %s (profile=%s)', total / 1024 ** 3, self.buffer.device, profile.name)
         self.base_ptr = self.buffer.data_ptr()
         self._hello_layout = profile.hello_layout(self.base_ptr, max_seq_len)
         from tilert.pd_vllm.transport import make_transport
@@ -51,7 +60,7 @@
         dev_id = torch.device(device).index or 0
         self._transport = make_transport(transport)
         self._transport.init(hostname)
-        self._transport.register(self.base_ptr, total, dev_id)
+        self._transport.register(self.base_ptr, total, dev_id, host=(buffer_device == 'cpu'))
         self._transport_meta = self._transport.local_meta()
         logger.info('transport=%s ready, buffer registered (%.2f GB)', self._transport.name, total / 1024 ** 3)
         self._lock = threading.Lock()
diff -ru orig/transport.py new/transport.py
--- orig/transport.py	2026-09-22 10:06:55
+++ new/transport.py	2026-09-22 10:08:13
@@ -8,7 +8,7 @@
     def init(self, host: str) -> None:
         ...
 
-    def register(self, ptr: int, nbytes: int, dev_id: int) -> None:
+    def register(self, ptr: int, nbytes: int, dev_id: int, host: bool=False) -> None:
         ...
 
     def local_meta(self) -> dict:
@@ -28,7 +28,8 @@
             raise RuntimeError(f'Mooncake engine init failed: {ret}')
         self.session_id = f'{host}:{self.engine.get_rpc_port()}'
 
-    def register(self, ptr: int, nbytes: int, dev_id: int) -> None:
+    def register(self, ptr: int, nbytes: int, dev_id: int, host: bool=False) -> None:
+        # Mooncake registers host (pinned) and device pointers through the same call.
         ret = self.engine.batch_register_memory([ptr], [nbytes])
         if ret != 0:
             raise RuntimeError(f'Mooncake register failed: {ret}')
@@ -50,10 +51,12 @@
         self._agent = nixl_agent(f'{host}:{os.getpid()}', nixl_agent_config(backends=['UCX']))
         self._remotes: dict[bytes, str] = {}
         self._dev = 0
+        self._mem_type = 'VRAM'
 
-    def register(self, ptr: int, nbytes: int, dev_id: int) -> None:
-        self._dev = dev_id
-        self._agent.register_memory([(ptr, nbytes, dev_id, '')], 'VRAM')
+    def register(self, ptr: int, nbytes: int, dev_id: int, host: bool=False) -> None:
+        self._dev = 0 if host else dev_id
+        self._mem_type = 'DRAM' if host else 'VRAM'
+        self._agent.register_memory([(ptr, nbytes, self._dev, '')], self._mem_type)
 
     def local_meta(self) -> dict:
         return {'nixl_meta': base64.b64encode(self._agent.get_agent_metadata()).decode(), 'nixl_dev': self._dev}
@@ -65,8 +68,8 @@
             rname = self._agent.add_remote_agent(meta_b)
             self._remotes[meta_b] = rname
         rdev = int(remote_meta.get('nixl_dev', 0))
-        ld = self._agent.get_xfer_descs([(int(s), int(n), self._dev) for s, n in zip(srcs, lens)], 'VRAM')
-        rd = self._agent.get_xfer_descs([(int(d), int(n), rdev) for d, n in zip(dsts, lens)], 'VRAM')
+        ld = self._agent.get_xfer_descs([(int(s), int(n), self._dev) for s, n in zip(srcs, lens)], self._mem_type)
+        rd = self._agent.get_xfer_descs([(int(d), int(n), rdev) for d, n in zip(dsts, lens)], self._mem_type)
         h = self._agent.initialize_xfer('WRITE', ld, rd, rname)
         try:
             st = self._agent.transfer(h)
中文

已核实的根因。 decode 侧 bf16、1048576 上下文下有三块按 max_seq_len 密集分配的显存:权重 90.72 GiB(fp8 原样加载)、引擎缓存窗口 93.25 GiB(dtype 由环境变量 TILERT_GLM5_FP8_KV 控制,与 --kv-cache-dtype 无关)、PD 接收缓冲 99.06 GiB,合计约 283/287.98 GiB,属节点边缘:g14 因非 torch 占用 6.29 GiB 而 OOM,g15 仅 ≤3.17 GiB 得以服务。prefill 侧 vLLM 在 0.75 利用率下确定性拒绝:需 91.71 GiB KV 而可用 85.25 GiB;且 connector 在 TP rank 0 于 vLLM 预算之外再分配 99.06 GiB 暂存缓冲,合计 321.5 GiB,bf16 下任何利用率都放不下。

fp8 方案(19bea0bdf)错误,已撤销。 ROCm 的 vLLM 稀疏 MLA 后端没有 fp8_ds_mla,其普通 fp8 写出每 token 576 字节的扁平行;TileRT 依步长只接受 656 或 1152 字节,会在 register_kv_caches 直接报错。原作者的 bf16 是唯一正确布局。

与 SGLang 绿色运行的对比。 同为 1M 上下文设置、并发 16,其 KV 为按可用显存划定的分页池(约 150 GiB,各会话分时共享),MoRI 分离亦复用同一池;TileRT 则按 max_seq_len 在 batch 1 下密集预分配两块整上下文缓冲,这才是根本差异。

决定:保留 1M,PD 缓冲移入 DRAM。 分支现为 bf16、1048576、GPU_MEM_UTIL=0.85,并附 [skip-sweep]。sweep 取决于将两块 PD 缓冲放入锁页主机内存的 TileRT 版本(PyPI 尚无)。上方附有针对 0.1.6 的补丁供 TileRT 团队上游合入;仓库规则禁止在配方或镜像中直接打补丁。wheel 与镜像升级后需在 MI355X 上执行一次 RUN_EVAL=true

@Oseltamivir

Copy link
Copy Markdown
Collaborator Author

DRAM PD buffers: the ionic ibv_reg_mr ceiling, measured on this pool — and how to clear it

The TileRT team reported ibv_reg_mr failing with EINVAL once ~4 GiB of host memory is registered per Pensando/ionic HCA. Reproduced here on mia1-p01-g09 (ionic 25.11.1.001, fw 1.117.5-a-45, kernel 6.8.0-84), raw libibverbs via ctypes, no Mooncake/TileRT in the path:

test result
cumulative 1 GiB MRs, 4 KiB pages 3 GiB ok, 4th → EINVAL
exact ceiling, 64 MiB steps 3968 MiB = 1,015,808 × 4 KiB pages, then EINVAL
single MR, 4 KiB pages 1, 2 GiB ok; ≥ 3 GiB → ENOMEM
rdma1 while rdma0 saturated ok → the budget is per HCA
THP-backed (MADV_HUGEPAGE, 100 % AnonHugePages verified) 32 GiB cumulative ok; single 4/8/16/32 GiB MRs ok
6 GiB THP region with one 2 MiB hole of 4 KiB pages ENOMEM — the whole MR falls to 4 KiB granularity
in the ROCm image: torch.zeros(8 GiB, pin_memory=True) already 2 MiB-backed, is_pinned=True, registers ok
in the ROCm image: mmap+MADV_HUGEPAGE+hipHostRegister+torch.frombuffer (8 and 32 GiB) is_pinned=True, registers ok

So the limit is not bytes but a ~2²⁰ page-table-entry budget per HCA: ~3.9 GiB at 4 KiB pages, ~1.9 TiB at 2 MiB pages — which is why device memory (large contiguous chunks) registers tens of GiB without trouble. The firmware rejects the admin command (dmesg: rdma0: opcode 3 error 5, exactly two lines for the two cumulative failures); oversized single MRs are refused earlier with ENOMEM. ibv_devinfo: page_size_cap 0x40201000 (4K/2M/1G), max_mr_size unlimited.

Consequence for the DRAM route: both PD buffers (99.06 GiB each at 1M) must be 2 MiB-backed end to end and that must be verified at allocation, not assumed — one 4 KiB-backed slice anywhere in the region and the MR fails. At 2 MiB, 99.06 GiB is ~50.7k entries, 5 % of the budget. These nodes run THP enabled=madvise, defrag=madvise, 3.17 TB RAM; hugetlbfs is not an option (11 GiB reserved, 0 free).

Patch updated (~/tilert-0.1.6-pd-buffers-in-dram.diff, 5 files, 231 lines, py_compile clean): transport.alloc_pinned_huge() allocates mmap+MADV_HUGEPAGE, populates every 2 MiB page, checks /proc/self/smaps reports AnonHugePages == size and raises otherwise, hipHostRegisters the region so the GPU can DMA to it, and returns a torch.frombuffer tensor (pinned). Both ReceiveServer (--pd-buffer-device cpu) and TileRTConnector (tilert_pd_buffer_device: cpu) use it. Full patch below.

tilert-0.1.6-pd-buffers-in-dram.diff v2
Only in new: __pycache__
diff -ru orig/decode_server.py new/decode_server.py
--- orig/decode_server.py	2026-09-22 10:06:55
+++ new/decode_server.py	2026-09-22 10:08:38
@@ -309,6 +309,7 @@
     ap.add_argument('--num-mtp', type=int, choices=(3,), default=3, help='MTP draft depth for speculative decoding; needs --with-mtp')
     ap.add_argument('--transport', choices=['mooncake', 'nixl'], default='mooncake', help="RDMA data-plane backend (must match prefill's tilert_transport)")
     ap.add_argument('--kv-cache-dtype', default='fp8_ds_mla', help='MLA cache dtype (must match vLLM prefill); MLA-family profiles only')
+    ap.add_argument('--pd-buffer-device', choices=['cuda', 'cpu'], default='cuda', help="where the PD receive buffer lives; 'cpu' = pinned host memory registered with the transport (frees buffer_bytes(max_seq_len) of VRAM, adds one H2D copy per request)")
     return ap
 
 def main() -> None:
@@ -329,7 +330,7 @@
         logger.info('loading TileRT engine (profile=%s, weights=%s)...', profile.name, args.model_weights_dir)
         engine = profile.build_engine(model_weights_dir=args.model_weights_dir, max_seq_len=args.max_seq_len, with_mtp=args.with_mtp, ar_steps=8, num_mtp=num_mtp)
         logger.info('TileRT engine ready (cache window %d, num_mtp %d)', engine.max_seq_len, num_mtp)
-    server = ReceiveServer(profile, max_seq_len=args.max_seq_len, ctrl_port=args.ctrl_port, transport=args.transport)
+    server = ReceiveServer(profile, max_seq_len=args.max_seq_len, ctrl_port=args.ctrl_port, transport=args.transport, buffer_device='cpu' if args.pd_buffer_device == 'cpu' else 'cuda:0')
     app = build_app(server, engine)
     logger.info('decode server on :%d (profile=%s, engine=%s, ctrl=:%d)', args.http_port, profile.name, args.engine, args.ctrl_port)
     sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
diff -ru orig/prefill_connector.py new/prefill_connector.py
--- orig/prefill_connector.py	2026-09-22 10:06:55
+++ new/prefill_connector.py	2026-09-22 11:54:44
@@ -49,6 +49,7 @@
         self._max_seq = int(extra.get('tilert_max_seq_len', vllm_config.model_config.max_model_len))
         self._profile = profiles.get_profile(extra.get('tilert_model', 'glm5'))
         self._transport_name = extra.get('tilert_transport', 'mooncake')
+        self._pd_buffer_device = str(extra.get('tilert_pd_buffer_device', 'cuda'))
         self._pending: dict[str, _Pending] = {}
         self._kv_caches: dict = {}
         self._reg = None
@@ -145,10 +146,19 @@
         hostname = wire.local_ip()
         total = self._profile.staging_bytes(self._reg, self._tp_rank, self._max_seq)
         dev = torch.cuda.current_device()
-        self._staging = torch.zeros(total, dtype=torch.uint8, device=f'cuda:{dev}')
+        # Dense in max_seq_len (99.06 GiB at 1M for glm5_2 bf16) and allocated
+        # outside vLLM's gpu-memory-utilization budget. In pinned host memory
+        # when tilert_pd_buffer_device == 'cpu'; extract() already fills it with
+        # copy_(), which performs the D2H transfer.
+        host = self._pd_buffer_device == 'cpu'
+        if host:
+            from tilert.pd_vllm.transport import alloc_pinned_huge
+            self._staging = alloc_pinned_huge(total, dev)
+        else:
+            self._staging = torch.zeros(total, dtype=torch.uint8, device=f'cuda:{dev}')
         self._transport = make_transport(self._transport_name)
         self._transport.init(hostname)
-        self._transport.register(self._staging.data_ptr(), total, dev)
+        self._transport.register(self._staging.data_ptr(), total, dev, host=host)
         self._sender_thread = threading.Thread(target=self._sender_loop, name='tilert-pd-sender', daemon=True)
         self._sender_thread.start()
         logger.info('worker ready: rank=%d transport=%s staging=%.1f MB profile=%s', self._tp_rank, self._transport.name, total / 1000000.0, self._profile.name)
Only in new/profiles: __pycache__
diff -ru orig/profiles/mla_nsa.py new/profiles/mla_nsa.py
--- orig/profiles/mla_nsa.py	2026-09-22 10:06:55
+++ new/profiles/mla_nsa.py	2026-09-22 10:07:21
@@ -125,14 +125,23 @@
         for lid in range(self.num_layers):
             ko = lid * max_seq_len * kv_bpt
             kv_raw = buffer[ko:ko + seq * kv_bpt].view(seq, kv_bpt)
+            # A host-resident receive buffer is moved to the device plane by
+            # plane; the copy scales with the request's seq_len, not max_seq_len.
+            if not buffer.is_cuda:
+                kv_raw = kv_raw.to('cuda:0', non_blocking=True)
             if self.mla_fp8:
                 kv = self._dequant_kv(kv_raw, seq)
             else:
                 kv = kv_raw.view(torch.bfloat16).view(seq, KV_LORA_RANK).contiguous()
             po = pe_base + lid * max_seq_len * PE_BPT
-            pe = buffer[po:po + seq * PE_BPT].view(torch.bfloat16).view(seq, QK_ROPE_HEAD_DIM).contiguous()
+            pe = buffer[po:po + seq * PE_BPT]
+            if not buffer.is_cuda:
+                pe = pe.to('cuda:0', non_blocking=True)
+            pe = pe.view(torch.bfloat16).view(seq, QK_ROPE_HEAD_DIM).contiguous()
             io = ki_base + lid * _max_pages(max_seq_len) * KI_PAGE_BYTES
             ki_raw = buffer[io:io + npages * KI_PAGE_BYTES].view(npages, KI_PAGE_BYTES)
+            if not buffer.is_cuda:
+                ki_raw = ki_raw.to('cuda:0', non_blocking=True)
             layers.append((self._dequant_ki(ki_raw, seq, self.ki_tiled), kv, pe))
         torch.cuda.synchronize()
         return ConvertedRequest(rid=received.rid, seq_len=seq, last_prompt_token=received.last_prompt_token, first_token_id=received.first_token_id, sampling=received.sampling, layers=layers)
diff -ru orig/receive_server.py new/receive_server.py
--- orig/receive_server.py	2026-09-22 10:06:55
+++ new/receive_server.py	2026-09-22 11:54:44
@@ -34,15 +34,25 @@
 
 class ReceiveServer:
 
-    def __init__(self, profile, max_seq_len: int, ctrl_port: int=5556, hostname: str | None=None, device: str='cuda:0', request_timeout: float=120.0, transport: str='mooncake'):
+    def __init__(self, profile, max_seq_len: int, ctrl_port: int=5556, hostname: str | None=None, device: str='cuda:0', request_timeout: float=120.0, transport: str='mooncake', buffer_device: str='cuda:0'):
         self.profile = profile
         self.max_seq_len = max_seq_len
         self.ctrl_port = ctrl_port
         self.device = device
         self.request_timeout = request_timeout
         total = profile.buffer_bytes(max_seq_len)
-        logger.info('allocating receive buffer: %.2f GB on %s (profile=%s)', total / 1024 ** 3, device, profile.name)
-        self.buffer = torch.zeros(total, dtype=torch.uint8, device=device)
+        # Dense in max_seq_len: 99.06 GiB at 1M tokens for glm5_2 bf16. Held in
+        # pinned host memory when buffer_device == 'cpu' so it does not compete
+        # with the weights and the engine cache window on the GPU; convert()
+        # then moves each request's planes to the device (H2D scales with the
+        # prompt, not with max_seq_len).
+        self.buffer_device = buffer_device
+        if buffer_device == 'cpu':
+            from tilert.pd_vllm.transport import alloc_pinned_huge
+            self.buffer = alloc_pinned_huge(total)
+        else:
+            self.buffer = torch.zeros(total, dtype=torch.uint8, device=buffer_device)
+        logger.info('allocating receive buffer: %.2f GiB on %s (profile=%s)', total / 1024 ** 3, self.buffer.device, profile.name)
         self.base_ptr = self.buffer.data_ptr()
         self._hello_layout = profile.hello_layout(self.base_ptr, max_seq_len)
         from tilert.pd_vllm.transport import make_transport
@@ -51,7 +61,7 @@
         dev_id = torch.device(device).index or 0
         self._transport = make_transport(transport)
         self._transport.init(hostname)
-        self._transport.register(self.base_ptr, total, dev_id)
+        self._transport.register(self.base_ptr, total, dev_id, host=(buffer_device == 'cpu'))
         self._transport_meta = self._transport.local_meta()
         logger.info('transport=%s ready, buffer registered (%.2f GB)', self._transport.name, total / 1024 ** 3)
         self._lock = threading.Lock()
diff -ru orig/transport.py new/transport.py
--- orig/transport.py	2026-09-22 10:06:55
+++ new/transport.py	2026-09-22 11:54:44
@@ -8,7 +8,7 @@
     def init(self, host: str) -> None:
         ...
 
-    def register(self, ptr: int, nbytes: int, dev_id: int) -> None:
+    def register(self, ptr: int, nbytes: int, dev_id: int, host: bool=False) -> None:
         ...
 
     def local_meta(self) -> dict:
@@ -28,7 +28,8 @@
             raise RuntimeError(f'Mooncake engine init failed: {ret}')
         self.session_id = f'{host}:{self.engine.get_rpc_port()}'
 
-    def register(self, ptr: int, nbytes: int, dev_id: int) -> None:
+    def register(self, ptr: int, nbytes: int, dev_id: int, host: bool=False) -> None:
+        # Mooncake registers host (pinned) and device pointers through the same call.
         ret = self.engine.batch_register_memory([ptr], [nbytes])
         if ret != 0:
             raise RuntimeError(f'Mooncake register failed: {ret}')
@@ -50,10 +51,12 @@
         self._agent = nixl_agent(f'{host}:{os.getpid()}', nixl_agent_config(backends=['UCX']))
         self._remotes: dict[bytes, str] = {}
         self._dev = 0
+        self._mem_type = 'VRAM'
 
-    def register(self, ptr: int, nbytes: int, dev_id: int) -> None:
-        self._dev = dev_id
-        self._agent.register_memory([(ptr, nbytes, dev_id, '')], 'VRAM')
+    def register(self, ptr: int, nbytes: int, dev_id: int, host: bool=False) -> None:
+        self._dev = 0 if host else dev_id
+        self._mem_type = 'DRAM' if host else 'VRAM'
+        self._agent.register_memory([(ptr, nbytes, self._dev, '')], self._mem_type)
 
     def local_meta(self) -> dict:
         return {'nixl_meta': base64.b64encode(self._agent.get_agent_metadata()).decode(), 'nixl_dev': self._dev}
@@ -65,8 +68,8 @@
             rname = self._agent.add_remote_agent(meta_b)
             self._remotes[meta_b] = rname
         rdev = int(remote_meta.get('nixl_dev', 0))
-        ld = self._agent.get_xfer_descs([(int(s), int(n), self._dev) for s, n in zip(srcs, lens)], 'VRAM')
-        rd = self._agent.get_xfer_descs([(int(d), int(n), rdev) for d, n in zip(dsts, lens)], 'VRAM')
+        ld = self._agent.get_xfer_descs([(int(s), int(n), self._dev) for s, n in zip(srcs, lens)], self._mem_type)
+        rd = self._agent.get_xfer_descs([(int(d), int(n), rdev) for d, n in zip(dsts, lens)], self._mem_type)
         h = self._agent.initialize_xfer('WRITE', ld, rd, rname)
         try:
             st = self._agent.transfer(h)
@@ -86,4 +89,55 @@
     key = (name or 'mooncake').lower()
     if key not in _BACKENDS:
         raise ValueError(f'unknown transport {name!r}; choices: {sorted(_BACKENDS)}')
-    return _BACKENDS[key]()
\ No newline at end of file
+    return _BACKENDS[key]()
+
+def alloc_pinned_huge(total: int, device_index: int = 0):
+    """Host buffer for the PD data plane, backed end-to-end by 2 MiB pages.
+
+    The ionic RDMA VFs on the MI355X pool have a ~2**20 page-table-entry budget
+    per HCA: with 4 KiB pages ibv_reg_mr fails (EINVAL/ENOMEM) once ~3.9 GiB is
+    registered, while 2 MiB-backed regions register tens of GiB. One 4 KiB-backed
+    slice anywhere in the region drops the whole MR to 4 KiB granularity, so the
+    backing is verified from /proc/self/smaps before the memory is handed out.
+    The region is then hipHostRegister'ed so the GPU can DMA into it and torch
+    reports it pinned.
+    """
+    import ctypes, mmap, re
+    import torch
+    MiB = 1 << 20
+    if total % (2 * MiB):
+        total += 2 * MiB - total % (2 * MiB)
+    mm = mmap.mmap(-1, total, flags=mmap.MAP_PRIVATE | mmap.MAP_ANONYMOUS)
+    mm.madvise(mmap.MADV_HUGEPAGE)
+    for off in range(0, total, 2 * MiB):
+        mm[off] = 0
+    addr = ctypes.addressof(ctypes.c_char.from_buffer(mm))
+    huge = 0
+    for blk in re.split(r'\n(?=[0-9a-f]+-[0-9a-f]+ )', open('/proc/self/smaps').read()):
+        m = re.match(r'([0-9a-f]+)-([0-9a-f]+) ', blk)
+        if not m:
+            continue
+        lo, hi = int(m.group(1), 16), int(m.group(2), 16)
+        if hi <= addr or lo >= addr + total:
+            continue
+        h = re.search(r'AnonHugePages:\s+(\d+) kB', blk)
+        huge += int(h.group(1)) * 1024 if h else 0
+    if huge < total:
+        raise RuntimeError(f'PD host buffer is only {huge / 2**30:.2f} of {total / 2**30:.2f} GiB huge-page backed; '
+                           'the RDMA MR would fall back to 4 KiB pages and exceed the per-HCA entry budget '
+                           '(check /sys/kernel/mm/transparent_hugepage/{enabled,defrag} and free memory)')
+    hip = None
+    for lib in ('/opt/rocm/lib/libamdhip64.so', 'libamdhip64.so', 'libcudart.so'):
+        try:
+            hip = ctypes.CDLL(lib)
+            break
+        except OSError:
+            continue
+    if hip is not None:
+        fn = hip.hipHostRegister if hasattr(hip, 'hipHostRegister') else hip.cudaHostRegister
+        rc = fn(ctypes.c_void_p(addr), ctypes.c_size_t(total), ctypes.c_uint(0))
+        if rc != 0:
+            raise RuntimeError(f'hipHostRegister failed rc={rc}')
+    buf = torch.frombuffer(mm, dtype=torch.uint8)
+    buf._pd_mmap = mm  # keep the mapping alive with the tensor
+    return buf
中文

TileRT 团队报告 ionic HCA 上注册的主机内存超过约 4 GiB 后 ibv_reg_mr 返回 EINVAL。已在 mia1-p01-g09 上以原生 libibverbs 复现:4 KiB 页时每 HCA 累计上限 3968 MiB = 1,015,808 页(EINVAL,固件拒绝,dmesg opcode 3 error 5),单个 ≥3 GiB 的 MR 报 ENOMEM,限制为每 HCA 独立;改用 2 MiB 大页(MADV_HUGEPAGE,smaps 确认 100% AnonHugePages)后 32 GiB 累计与单个 32 GiB MR 均注册成功。因此这是每 HCA 约 2²⁰ 条页表项的预算,而非字节上限;6 GiB 大页区域中哪怕一个 2 MiB 的 4 KiB 页空洞也会使整个 MR 失败。ROCm 容器内 torch.zeros(pin_memory=True) 已是 2 MiB 页支撑并可注册。补丁已更新为在分配时校验大页覆盖、hipHostRegister 并返回 pinned 张量。

@Oseltamivir

Copy link
Copy Markdown
Collaborator Author

DRAM PD buffers: cross-node mooncake transfer proven; chunking into <4 GiB MRs does not avoid the ionic limit

Cross-node proof (receiver mia1-p01-g11, sender mia1-p01-g09, tilert-rocm-prefill:0.1.6 image, the same mooncake calls tilert.pd_vllm.transport.MooncakeTransport makes):

step result
receiver: 100 GiB host buffer, mmap+MADV_HUGEPAGE, populated 100 % AnonHugePages
hipHostRegister of the full 100 GiB rc=0
mooncake batch_register_memory of 100 GiB host memory rc=0 (2.2 s)
sender: 8 GiB THP source, batch_transfer_sync_write host→host into the receiver at a 64 GiB offset rc=0, 0.38 s = 20.9 GiB/s
receiver: pattern check on all 8 blocks DATA_OK

So every mechanism the DRAM patch relies on has now executed on this fabric: 2 MiB-backed host registration at full size, GPU mapping of that region, and RDMA into it across nodes. Populating 100 GiB of THP from a Python loop took 149 s; production code should use MADV_POPULATE_WRITE (kernel 6.8 has it) or a parallel memset.

On "register memory regions one by one, each < 4 GiB": this does not work with 4 KiB pages. The ceiling is cumulative per HCA (~2²⁰ page-table entries), not per MR:

4 KiB pages, one HCA outcome
1 GiB MRs in sequence 3 GiB held → 4th MR EINVAL
256 MiB MRs in sequence 3.75 GiB held → EINVAL
64 MiB MRs in sequence 3968 MiB held → EINVAL (1,015,808 pages)

Each 4 KiB page costs one entry whichever MR it belongs to, so 25 × 4 GiB pieces still need ~26M entries; spreading over all 8 HCAs yields ~31 GiB, a third of one buffer. The only thing that lifts it is the entry size: 2 MiB backing registers 100 GiB as one MR. Chunking is harmless on top of huge pages but unnecessary.

Remaining before the sweep can pass: the TileRT release carrying the DRAM buffers (patch v2 above), then the recipe bump (TILERT_VERSION, both images, --pd-buffer-device cpu, tilert_pd_buffer_device: cpu), then one RUN_EVAL=true run — the bf16 P→D inject has still never executed end to end on MI355X, and the sweep runs no GSM8K by default.

中文

跨节点验证(接收端 g11、发送端 g09,与 TileRT MooncakeTransport 相同的 mooncake 调用):100 GiB 主机缓冲全部为 2 MiB 大页,hipHostRegister 与 mooncake 注册均成功(2.2 秒);8 GiB 主机到主机的跨节点 RDMA 写入成功,0.38 秒、20.9 GiB/s,数据校验通过。DRAM 方案依赖的全部机制均已在该网络上实际运行。

关于「逐块注册、每块小于 4 GiB」: 在 4 KiB 页下无效。上限是每 HCA 累计约 2²⁰ 条页表项,而非单个 MR:1 GiB 逐块注册到 3 GiB 后第 4 块 EINVAL;256 MiB 逐块到 3.75 GiB 后 EINVAL;64 MiB 逐块到 3968 MiB 后 EINVAL。每个 4 KiB 页占一条页表项,与所属 MR 无关;分成 25 个 4 GiB 块仍需约 2600 万条;分散到 8 个 HCA 也只有约 31 GiB。唯一有效的办法是改用 2 MiB 大页——100 GiB 可作为单个 MR 注册。大页之上再分块无害但没有必要。

sweep 通过前仍需: 含 DRAM 缓冲的 TileRT 版本、配方升级(版本、镜像、两个新开关),以及一次 RUN_EVAL=true 运行。

…合入分支

perf-changelog.yaml only: main appended 15 lines (#3337), this branch appends
at the tail; main's 8589 lines are byte-identical and this branch's entries
follow them. configs/amd-master.yaml auto-merged.

仅 perf-changelog.yaml 冲突:main 追加 15 行(#3337),本分支尾部追加;main 的
8589 行逐字节不变,本分支条目位于其后。configs/amd-master.yaml 自动合并。
…ert 0.1.6 patch + waiver 3330 / PD 缓冲移入 2 MiB 大页锁页内存以支持 1M 上下文,附 tilert 0.1.6 补丁与豁免

TileRT 0.1.6 keeps its two PD buffers on the GPU, dense in max_seq_len:
99.06 GiB each at 1048576 tokens. With 90.72 GiB of weights and the 93.25 GiB
engine cache window the decode rank sits at ~283 of 287.98 GiB (node-marginal,
OOM on g14), and prefill rank 0 needs ~321 GiB (weights 90.45 + profiling
40.29 + vLLM KV 91.71 inside the budget + the 99.06 GiB staging buffer outside
it). No --kv-cache-dtype shrinks them on ROCm: TileRT accepts only the 1152 B
(bf16) or 656 B (fp8_ds_mla) stride and vLLM's ROCM_AITER_MLA_SPARSE has no
fp8_ds_mla.

setup_deps.sh now applies patches/tilert-0.1.6-pd-buffers-in-dram.patch to the
pip-installed tilert 0.1.6 on both ranks when TILERT_PD_BUFFER_DEVICE=cpu
(marker-gated, pinned to 0.1.6, checked before the wheel download). The
patch (5 pure-Python files under tilert/pd_vllm) adds decode_server
--pd-buffer-device and connector extra config tilert_pd_buffer_device, and
allocates both buffers with alloc_pinned_huge(): a 2 MiB-aligned mmap with
MADV_HUGEPAGE, populated via MADV_POPULATE_WRITE, verified from
/proc/self/smaps to be fully huge-page backed, hipHostRegister'ed, wrapped as
a pinned torch tensor; convert() moves each received plane to the device.
The verification matters: the Pensando ionic RDMA VFs cap 4 KiB-page
ibv_reg_mr at 3968 MiB per HCA (~2^20 entries), while 2 MiB-backed 100 GiB
regions register in one MR and took a cross-node mooncake write at 20.9 GiB/s
(measured on g09/g11). Registering in <4 GiB pieces does not help; the budget
is per HCA, not per MR.

server_tilert.sh validates TILERT_PD_BUFFER_DEVICE in {cpu,cuda} up front and
passes --pd-buffer-device only for cpu, so cuda runs the shipped wheel (which
has no such flag) unmodified. job.slurm forwards and validates the knob; both
recipes set cpu and GPU_MEM_UTIL 0.85 (222.5 GiB inside a 244.8 GiB budget).
vLLM is not patched. Engine-patch waiver: docs/waiver/3330.md; the upstream
TileRT link is pending.

TileRT 0.1.6 将两个按 max_seq_len 密集分配的 PD 缓冲放在 GPU 上,1048576 上下文下
各 99.06 GiB;连同 90.72 GiB 权重与 93.25 GiB 引擎缓存窗口,decode 侧约 283/287.98
GiB(节点边缘,g14 OOM),prefill rank 0 约需 321 GiB。ROCm 上无法用
--kv-cache-dtype 缩减:TileRT 只接受 1152(bf16)或 656(fp8_ds_mla)字节步长,而
vLLM 的 ROCM_AITER_MLA_SPARSE 没有 fp8_ds_mla。

现由 setup_deps.sh 在 TILERT_PD_BUFFER_DEVICE=cpu 时对两侧 pip 安装的 tilert 0.1.6
应用 patches/tilert-0.1.6-pd-buffers-in-dram.patch(有标记门控、固定 0.1.6,在下载
wheel 前检查)。补丁新增 decode_server --pd-buffer-device 与 connector 配置
tilert_pd_buffer_device,用 alloc_pinned_huge() 分配两个缓冲:2 MiB 对齐的 mmap +
MADV_HUGEPAGE,经 MADV_POPULATE_WRITE 预填充,依 /proc/self/smaps 校验全部为大页,
hipHostRegister 后包装为 pinned torch 张量;convert() 将接收到的各层数据搬到设备。
校验的必要性:ionic RDMA VF 对 4 KiB 页的 ibv_reg_mr 上限为每 HCA 3968 MiB(约 2^20
条页表项),而 2 MiB 大页的 100 GiB 区域可作单个 MR 注册,并以 20.9 GiB/s 接收跨节点
mooncake 写入(g09/g11 实测);分成小于 4 GiB 的块无济于事,预算按 HCA 而非按 MR。

server_tilert.sh 预先校验 TILERT_PD_BUFFER_DEVICE 取值,仅在 cpu 时传递
--pd-buffer-device,cuda 下原样运行 wheel。job.slurm 转发并校验该变量;两份配方设为
cpu 并将 GPU_MEM_UTIL 设为 0.85。vLLM 未打补丁。引擎补丁豁免见 docs/waiver/3330.md,
上游 TileRT 链接待补。
@Oseltamivir Oseltamivir added the engine-patch Modifies inference engine or serving-stack code; apply patchwork CI priority label Sep 22, 2026
CrimsonDump and others added 4 commits September 22, 2026 15:04
…口 60 秒

sync.py --enable-port keeps the local port open for max(60, timeout // 2)
seconds after the barrier passes, in the foreground, so a peer one poll behind
still sees it. With the TileRT launcher's CONTAINER_BARRIER_TIMEOUT=5400 that
became a 45-minute idle sleep on every rank: jobs 45373/45374 passed the
barrier at 06:28 and sat in time.sleep until 07:13 with two nodes allocated
(main thread in hrtimer_nanosleep, accept thread in inet_csk_accept). The old
hardcoded 1800s hid a 15-minute version of the same sleep.

sync.py gains --grace <seconds>; unset keeps the existing formula, so the
SGLang/vLLM/ATOM arms are unchanged. server_tilert.sh passes --grace 60: both
ranks pass within one 5 s poll of each other, and the prefill and decode
stages have their own readiness waits (DECODE_WAIT, PREFILL_WAIT, ROUTER_WAIT).

sync.py 的 --enable-port 在屏障通过后会在前台将本地端口保持 max(60, timeout // 2)
秒,以便晚一次轮询的对端仍能看到。TileRT 启动脚本的 CONTAINER_BARRIER_TIMEOUT=5400
使之变为每个 rank 空转 45 分钟:作业 45373/45374 于 06:28 通过屏障后一直 sleep 到
07:13,占用两个节点。原先硬编码的 1800 秒同样隐藏着 15 分钟的空转。

sync.py 新增 --grace <秒>;不传则沿用原公式,SGLang/vLLM/ATOM 各臂不受影响。
server_tilert.sh 传入 --grace 60:两侧在一次 5 秒轮询内相继通过,且后续 prefill/decode
阶段各有自己的就绪等待。
…经 torch.cuda.cudart 注册 PD 主机缓冲

Sweep 35693738597 job 45373: the decode rank died in alloc_pinned_huge with
"neither libamdhip64 nor libcudart could be loaded". The patch dlopen'ed
/opt/rocm/lib/libamdhip64.so or libamdhip64.so by name, which exists in the
prefill image (ROCm 7.2.3) but not in tilert-rocm-decode:0.1.6, whose torch
2.12+rocm7.14 wheel ships only the versioned libamdhip64.so.7 and no
/opt/rocm tree.

alloc_pinned_huge now registers the region with torch.cuda.cudart()
.cudaHostRegister(addr, total, 0), torch's own runtime binding, hipified on
ROCm and present on CUDA builds; verified in both images (cudaError.success,
tensor.is_pinned() True). The ctypes library search is gone. Waiver text
updated to match.

sweep 35693738597 的作业 45373:decode 侧在 alloc_pinned_huge 中因 "neither
libamdhip64 nor libcudart could be loaded" 退出。补丁按名字 dlopen
/opt/rocm/lib/libamdhip64.so 或 libamdhip64.so,prefill 镜像(ROCm 7.2.3)有,而
tilert-rocm-decode:0.1.6 的 torch 2.12+rocm7.14 wheel 只带版本化的 libamdhip64.so.7,
且无 /opt/rocm 目录。

现改为通过 torch.cuda.cudart().cudaHostRegister(addr, total, 0) 注册,即 torch 自身
的运行时绑定,ROCm 上已 hipify、CUDA 构建亦有;两镜像均验证通过
(cudaError.success,is_pinned 为 True)。移除 ctypes 库查找,豁免文本同步更新。
Add glm5.3* to the 1M-context trace-source pattern and append the required performance changelog entry. Explicit loader overrides remain unchanged.

Validation: bash -n and git diff --check passed; 11 real-resolver behavioral cases passed with external dependency installation/download stubbed; changelog YAML, exact config key, and historical byte preservation checked. No GPU benchmark rerun.

AI disclosure: GPT 6 Astra via Perplexity Computer implemented and validated this follow-up. No delegated agents.

中文:将 glm5.3* 加入 1M 上下文语料匹配分支,默认选择未截断的 062126 语料,并在性能变更记录末尾追加条目;显式 loader 覆盖保持不变。Shell 语法、差异检查、11 个真实解析函数行为用例及变更记录校验通过;未重新运行 GPU 基准。由 GPT 6 Astra 经 Perplexity Computer 实现和验证,未使用委派 agent。
…ic workspace before sourcing; merge origin/main / 结果目录与工作区分离、提前设置 agentic 工作区变量,并合入 origin/main

Sweep 35704948491 brought both lanes up at 1048576 tokens with the PD buffers
in DRAM (decode: "receive buffer ready: 99.06 GiB on cpu", "transport=mooncake
ready", REQSTAT lines with 7252-token prompts converted and decoded; prefill
vLLM ready at gpu-memory-utilization 0.85; router ready) and the 8k1k lane ran
its benchmark to completion (16/16, median TTFT 888 ms, TPOT 1.89 ms). Both
lanes then failed after the fact:

- 8k1k: the launcher copied the result JSON into $GITHUB_WORKSPACE and its
  EXIT trap ran `sudo rm -rf "$BENCHMARK_LOGS_DIR"`. The tilert arm of
  benchmarks/multi_node/runtime_settings.sh is the B200 profile and sets
  BENCHMARK_LOGS_DIR="$GITHUB_WORKSPACE", so the trap deleted the checkout,
  results included. The AMD block now sets
  BENCHMARK_LOGS_DIR="$GITHUB_WORKSPACE/benchmark_logs", the convention
  runners/runtime_settings.sh uses for this pool, and launch_mi355x-amds.sh
  refuses a BENCHMARK_LOGS_DIR that is the checkout or contains it.
- agentic: benchmark_lib.sh derives AGENTIC_DIR and AIPERF_DIR from
  INFMAX_CONTAINER_WORKSPACE when it is sourced; server_tilert.sh exported the
  variable later, inside run_agentic_replay, so the client bootstrap looked for
  /utils/agentic-benchmark/requirements.txt. The export now precedes the
  source line; resolve_trace_source and install_agentic_deps fail the replay
  instead of falling through (no errexit here); HF_HOME points at the node's
  /run_logs mount so the trace corpus is not re-downloaded per job.

Merges origin/main (6c5eff0, #3360: priority classifier on Opus 5), no
conflicts.

sweep 35704948491 中两条 lane 均在 1048576 上下文下以 DRAM 中的 PD 缓冲成功启动,
8k1k 基准测试跑完(16/16,TTFT 中位数 888 ms,TPOT 1.89 ms),随后在事后失败:
8k1k 侧 launcher 将结果复制进 $GITHUB_WORKSPACE 后,EXIT trap 的
`rm -rf "$BENCHMARK_LOGS_DIR"` 因 tilert 分支沿用 B200 配置(值为
$GITHUB_WORKSPACE)而删除了整个检出目录;AMD 专用块现改为
$GITHUB_WORKSPACE/benchmark_logs,launcher 亦拒绝指向检出目录的取值。agentic 侧
benchmark_lib.sh 在 source 时由 INFMAX_CONTAINER_WORKSPACE 推导路径,而该变量此前在
之后才导出;现已提前,两个引导函数失败即返回,HF_HOME 指向节点 /run_logs 以免每次
重下语料。同时合入 origin/main(6c5eff0b8,#3360),无冲突。
@Oseltamivir

Copy link
Copy Markdown
Collaborator Author

Sweep 35704948491: both lanes served GLM-5.3 at 1M with DRAM PD buffers; 8k1k benchmark completed; two post-benchmark bugs fixed

The DRAM route works in production. Decode rank (job 45394, g15), from decode_mia1-p01-g15.log:

receive_server.py:50  allocating receive buffer: 99.06 GiB on cpu (profile=glm5_2)
receive_server.py:56  receive buffer ready: 99.06 GiB on cpu
receive_server.py:67  transport=mooncake ready, buffer registered (99.06 GB)
decode_server.py:335  decode server on :5557 (profile=glm5_2, engine=tilert, ctrl=:5556)
decode_server.py:106  REQSTAT rid=… seq=7252 completion=858 wire_wait=0.1 convert=28.5 inject=116.4 decode=1545.4 finish_reason=length mtp_verify_calls=296

Prefill vLLM came up at --max-model-len 1048576 --gpu-memory-utilization 0.85 ([wait_for_tcp] …:8000 ready), the router came up, and 7,252-token prompts were transferred over mooncake into the host buffer, converted (28.5 ms steady state; 1.7 s for the first request including warm-up), injected and decoded with MTP. Same on the agentic lane (job 45393). This is the first bf16 P→D transfer executed on MI355X.

First 8k1k numbers (conc 1, recovered from the node's /tmp/slurm_job-45394 copy because the run then deleted its workspace — see below; the official artifact comes from the next sweep):

metric value
completed 16 / 16
median TTFT 888 ms (p99 1180)
median TPOT 1.89 ms (p99 2.10)
output throughput 351.7 tok/s
median E2E 2.68 s
duration 42.2 s

Why the lanes still failed — both after the benchmark, both fixed in 992c9c0a1:

  1. 8k1k: the launcher copied the result JSON into $GITHUB_WORKSPACE (Found result file …concurrency_1_…json), then its EXIT trap ran sudo rm -rf "$BENCHMARK_LOGS_DIR". The tilert arm of benchmarks/multi_node/runtime_settings.sh is the B200 profile and had set BENCHMARK_LOGS_DIR="$GITHUB_WORKSPACE", so the trap deleted the checkout, results included ("No benchmark result files found", then An error occurred trying to start process … No such file or directory). The AMD block now sets BENCHMARK_LOGS_DIR="$GITHUB_WORKSPACE/benchmark_logs", the value runners/runtime_settings.sh already uses for this pool.
  2. agentic: the AgentX client bootstrap died on File not found: /utils/agentic-benchmark/requirements.txt. benchmark_lib.sh:3077-3078 derive AGENTIC_DIR/AIPERF_DIR from INFMAX_CONTAINER_WORKSPACE when sourced; server_tilert.sh exported it later, inside run_agentic_replay. The export now precedes the source line.

Also in 992c9c0a1: a launcher guard so BENCHMARK_LOGS_DIR can never be the checkout again, fail-fast on the agentic bootstrap helpers, HF_HOME on the node mount, and origin/main (#3360) merged. Already on the branch since the previous push: sync.py --grace 60 (the barrier's post-pass port-hold was max(60, timeout/2) in the foreground — a 45-minute sleep on every rank at CONTAINER_BARRIER_TIMEOUT=5400, observed 06:28→07:13 on jobs 45373/45374), and the host buffer is registered through torch.cuda.cudart().cudaHostRegister instead of dlopen'ing libamdhip64.so by name, which the decode image (torch 2.12+rocm7.14, only libamdhip64.so.7) does not provide.

中文

DRAM 方案已在生产运行中验证。 decode 侧日志:allocating receive buffer: 99.06 GiB on cpureceive buffer readytransport=mooncake ready, buffer registered (99.06 GB)decode server on :5557,随后出现真实请求的 REQSTAT 记录(7252 token 提示,convert 28.5 ms、inject 116.4 ms、decode 1545.4 ms,MTP 校验 296 次)。prefill vLLM 在 1048576 上下文、0.85 利用率下就绪,router 就绪。这是 MI355X 上首次完成的 bf16 P→D 传输。

首批 8k1k 数据(并发 1,从节点本地副本恢复):16/16 完成,TTFT 中位数 888 ms,TPOT 中位数 1.89 ms,输出吞吐 351.7 tok/s,端到端中位数 2.68 s。

两条 lane 仍失败的原因均在基准测试之后,已在 992c9c0a1 修复: ① 8k1k:launcher 将结果复制进 $GITHUB_WORKSPACE 后,EXIT trap 执行 rm -rf "$BENCHMARK_LOGS_DIR",而 tilert 分支沿用 B200 配置将其设为 $GITHUB_WORKSPACE,整个检出目录连同结果被删除;AMD 专用块现改为 $GITHUB_WORKSPACE/benchmark_logs。② agentic:benchmark_lib.sh 在 source 时即由 INFMAX_CONTAINER_WORKSPACE 推导路径,而该变量此前在之后才导出;现已提前。同批推送还包含 sync.py --grace 60(屏障通过后原本前台保持端口 45 分钟)以及改用 torch.cuda.cudart().cudaHostRegister 注册主机缓冲(decode 镜像只有 libamdhip64.so.7)。

@Oseltamivir

Copy link
Copy Markdown
Collaborator Author

Sweep 35715072662 on 992c9c0a1: green — both lanes, full 1,048,576-token context, PD buffers in DRAM

Every job in https://github.com/SemiAnalysisAI/InferenceX/actions/runs/35715072662 succeeded: check-changelog, reuse-sweep-gate, setup, canary-select, multi-node 8k1k, multi-node agentic, collect-results, compare-results, upload-changelog-metadata, calc-success-rate. First complete GLM-5.3 results on MI355X.

Single-turn 8k1k, conc 1 (bmk_glm5.3_8k1k_fp8_tilert_… artifact):

metric value
successful requests 16 / 16
median / p99 TTFT 886 ms / 1,189 ms
median / p99 TPOT 1.97 ms / 2.15 ms
output / total token throughput 344 / 3,114 tok/s
median E2E 2.72 s

Run-to-run: the aborted sweep's node-local copy showed 888 ms / 1.89 ms / 352 tok/s — same numbers within noise.

AgentX agentic-coding, conc 1, 3600 s (…_conc1.json; the workflow's agentic result guard passed):

metric value
requests 234 successful / 245 total (11 warm-up, 1 error dropped)
TTFT median / p90 / p99 6.49 s / 9.28 s / 16.9 s
ITL median / p99 2.03 ms / 2.56 ms
output tok/s per user (interactivity) 491 (p50 492)
request throughput 0.06 req/s
ISL p50 seen by the client ~337k tokens; individual requests up to 339k
decode acceptance simulated at the golden 2.99 (K=3), per golden_al_distribution/glm5.3_mtp.yaml — output text is meaningless by design

Those ~337k-token conversations are the point of the 1M context: at the inherited 202,752 they would have been filtered out. TTFT at that length is dominated by prefill plus the P→D copy of ~34 GB of bf16 KV per request (convert ≈ 200 ms, inject ≈ 1.1 s in the decode REQSTAT lines).

Still open before merge

  1. Correctness on this stack has not been proven. The sweep runs no GSM8K by default, and the agentic lane's text is meaningless under simulated acceptance. One RUN_EVAL=true pass on MI355X (real MTP verification, bf16 KV through the DRAM path) is required; the author's 0.975 was on MI350X, pre-DRAM, at 202,752.
  2. docs/waiver/3330.md needs the upstream TileRT PR/issue link (currently "pending"); CODEOWNER sign-off checks for it.
  3. golden_al_distribution/glm5.3_mtp.yaml remains GLM-5.2's curve, labelled PROVISIONAL.
  4. For merge via reuse, this is the validated full-sweep run: a maintainer can pin it with /use 35715072662.
中文

sweep 35715072662(992c9c0a1)全绿:两条 lane、结果收集、对比、元数据上传全部成功,完整 1,048,576 上下文,PD 缓冲位于 DRAM。这是 MI355X 上首批完整的 GLM-5.3 结果。

单轮 8k1k、并发 1:16/16 成功,TTFT 中位数 886 ms(p99 1,189 ms),TPOT 中位数 1.97 ms,输出吞吐 344 tok/s,端到端中位数 2.72 s;与上一轮从节点恢复的数据(888 ms / 1.89 ms / 352 tok/s)一致。

AgentX 智能体编码、并发 1、3600 秒:234 成功 / 245 总计(11 预热、1 错误丢弃),TTFT 中位数 6.49 s(p99 16.9 s),ITL 中位数 2.03 ms,每用户输出 491 tok/s,请求吞吐 0.06 req/s,客户端所见 ISL 中位数约 33.7 万 token(单请求最高 33.9 万);decode 侧按黄金曲线 2.99(K=3)模拟接受率,输出文本本身无意义。这些约 33.7 万 token 的会话正是 1M 上下文的意义所在:在沿用的 202,752 下它们会被过滤掉。

合并前仍待处理:① 该栈上的正确性尚未验证 —— sweep 默认不跑 GSM8K,需在 MI355X 上执行一次 RUN_EVAL=true;② docs/waiver/3330.md 缺上游 TileRT 链接;③ 黄金接受率曲线仍为 GLM-5.2 的临时副本;④ 通过复用合并时,可由维护者以 /use 35715072662 固定本次验证运行。

@Oseltamivir

Copy link
Copy Markdown
Collaborator Author

Why the run does not show on the site yet — and the app PR that fixes it

GET https://inferencex.semianalysis.com/api/unofficial-run?runId=35715072662 returns 200 with runInfos populated and benchmarks: []. The run, token and artifacts are all fine (the B200 glm5.1 TileRT run returns rows from the same endpoint); both rows are dropped server-side in packages/db/src/etl/benchmark-mapper.tsresolveModelKey because the app maps only glm5, glm5.1 and glm5.2 (packages/constants/src/models.ts:33-35). "GLM5.2/GLM5.3 744B" and /inference/glm-5-3 are display over the glm5.2 bucket; this PR is the first to emit model-prefix: glm5.3, so nothing routed it. Running the app's own mapper against this run's agg_bmk.json confirms: baseline both rows null; with a glm5.3 mapping both map (mi355x / tilert / fp8 / disagg, 8k1k and agentic). Framework, hardware and precision already resolve — model was the only failing dimension. No URL parameter can help (the rows never reach the client).

Fix: SemiAnalysisAI/InferenceX-app#1202glm5.3 as its own DB key shown in the GLM-5.2 bucket (as glm5.1 folds into GLM-5), compare-slug dbKeys, an HF-id fallback, tests, and the API-contract digest.

Merge ordering: the same mapper runs at official ingest (ingest-ci-run.ts), so InferenceX-app#1202 must be merged and deployed before this PR merges to main — otherwise the first official ingest silently skips the GLM-5.3 rows and a re-ingest is needed (app docs/adding-entities.md).

Also from the same pass: /evaluation?unofficialRun=… stays empty because this run uploaded no eval artifact (no RUN_EVAL yet — still the open correctness item), and the MI355X TileRT lane produced no power telemetry (power_valid=0), so the point will render with power withheld, unlike the B200 TileRT recipe.

中文

/api/unofficial-run?runId=35715072662 返回 200runInfos 有值但 benchmarks: []:两行结果在服务端 resolveModelKey 处被丢弃,因为应用仅识别 glm5glm5.1glm5.2;「GLM5.2/GLM5.3 744B」与 /inference/glm-5-3 只是 glm5.2 数据桶上的展示层,而本 PR 是首个上报 model-prefix: glm5.3 的配方。以应用自身 mapper 处理本次运行的 agg_bmk.json 已证实:加入 glm5.3 映射后两行均可解析;框架、硬件、精度本就可解析,模型键是唯一失败维度。URL 参数无法补救。

修复: InferenceX-app#1202。合并顺序: 正式入库使用同一 mapper,须先合并并部署 InferenceX-app#1202,再合并本 PR,否则首次入库会静默跳过 GLM-5.3 行。另:/evaluation 链接为空是因为本次运行未上传评测产物(仍待 RUN_EVAL);MI355X TileRT 未产生功耗遥测,数据点将以功耗缺省显示。

This branch has not been deployed

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

Labels

engine-patch Modifies inference engine or serving-stack code; apply patchwork CI priority full-sweep-fail-fast

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

2 participants