经验贴_8x910B4部署DeepSeek-V4.1-Flash_全过程
在 8×昇腾 910B4 上把 DeepSeek-V4.1-Flash(W4A8)跑成生产服务:一次完整的踩坑与优化记录
这是一篇"把失败的也写出来"的经验贴。
我们把一台 8 卡 A2 服务器上的 DeepSeek-V4.1-Flash 从"能起服"做到"GPUStack 托管对外服务",
中间做了 55 个工作步骤(编号到 56)、9 个调参候选、一整轮算子级下钻,
最后真正落地生效的只有 3 个开关,
而且它们加起来的性质是"每步快 4.4%"+“把交互式请求的等待时间从 2 分钟压到 13 秒”,
不是"算力变强了"。如果你正打算在 910B 上跑这个模型,本文第 2、3 节能帮你少走一天弯路;
第 4、7 节能帮你别去试那些注定的死路;第 8 节(度量方法学)是本项目最值钱的部分 ——
因为我们在那里自己骗过自己三次。
0. 一页速览
| 项 | 值 |
|---|---|
| 硬件 | 8× 昇腾 910B4(A2 代,SOC_VERSION=ascend910b1),单机 TP8 + Expert Parallel,DP=1 |
| 模型 | DeepSeek-V4.1-Flash-w4a8-Ascend(W4A8 量化,MoE,384 专家,上下文 1M) |
| 运行镜像 | dsv41-a2a:v8-gs2-acx(= c971d70da5d6,作者二次封装 + 我们打的 V41_AC_EXTRA 挂点) |
| 托管 | GPUStack v2.2.3,自定义后端 dsv41-a2-custom,对外服务名沿用 Qwen3.6-A3B |
| 关键启动参数 | MAX_SEQS=32 BAT_TOKENS=8192 GPU_UTIL=0.92 PREFIX=0 SP_TOKENS=5 DRAFT_GRAPH=1 MAX_LEN=1048576 |
| 最终生产配置(3 个开关) | V41_AC_EXTRA='…short_request_first_config…' + V41_SLOT_MAP_FUSED=1 + MC2_ALG=hierarchy |
| 性能快照 | 单请求 68.4 tok/s;短请求首字 0.36 s;9 万 token 文档首字 71.9 s(热身充分时);90k 聚合吞吐 17.3 tok/s |
| 与上一代 4.0 比 | 单请求 +208%;分水岭在 16 并发(c16 以上 4.0 反超);9 万文档首字同口径 +5.8% |
| 一句话结论 | 端到端调参这条路已经走到头了;剩下的空间在"调度/业务取舍"与"上游算子"两层,不在参数层 |
1. 时间线:我们做了什么,哪些成了、哪些没成
| 阶段 | 做了什么 | 结果 |
|---|---|---|
| ① 可部署性评估 | 读源码、核对显存与算子清单、社区情报(ModelScope 交流区 50 条) | ✅ 结论:A2 可跑,但要先解决"冷编译超时" |
| ② 手工起服打通 | 用作者的 serve_a2.sh 把服务跑起来,顺带把 skcache 静态内核缓存编译好 | ✅ 冷启动 35 min → 缓存后 ~346 s |
| ③ EPLB(专家负载均衡) | 6 个假设逐一二分证伪 + 7 个方向排除 | ❌ 不可用,机理超出 adaptor,已交上游 |
| ④ MC2 通信-算子融合 | 在 A2 上把被"判死刑"的 MatmulAllReduce 跑通 | ⚠️ 能用,但收益≈代价(重叠 +6.81% vs KV −5.8%)⇒ 驳回 |
| ⑤ 调度层"10 分钟筛" | 9 个候选,每个 10 分钟 | ❌ 8 个死/惰性,1 个可装 |
| ⑥ 短请求优先(SRF) | 造"长短混合"基准 → 四臂确认 → 生产形态复验 → 上线 | ✅✅ 本项目最大收益(短请求首字 −88%) |
| ⑦ 算子级画像 | 离线 profiler 榨干:三个分母口径对齐、按"瓶颈管线"重排优先级 | ✅ 前 4 优先级项全部定位到代码行 |
⑧ V41_SLOT_MAP_FUSED=1 | 6 样本配对测量 + verify 等价证明 | ✅ 上线(每条时间线 −3.23%,3.4σ) |
⑨ MC2_ALG=hierarchy | 在 prefill 侧补测(此前在 decode 侧测过、结论无效) | ✅ 上线(−1.19%,配对 t≈−5.8σ) |
| ⑩ 端到端四列对比 | 同态协议重做"优化前 vs 优化后 vs 4.0" | ✅ 完成,并推翻了自己上一轮的对比(见 §8.7) |
总共 55 个工作步骤(编号到 56),最终落地的开关:3 个。
2. 集成层:GPUStack 托管这个模型的四条硬约束
这一节的价值在于:这四条决定了整个集成方案,任何一条没照顾到就得重做。
2.1 推理容器只挂一个卷 ⇒ 模型必须在 GPUStack 数据卷内
GPUStack 起的推理容器只有一个挂载点(数据卷)。模型放在别处它看不见。
所以要么把模型搬进数据卷(注意必须同一文件系统,否则 mv 退化成"复制 + 删除"),
要么在容器里做别的处理。我们选择"搬进数据卷 + 原位置留符号链接"——
这样手工起服的老命令仍然有效(docker 在宿主侧解析符号链接)。
2.2 ★ 不支持额外挂载 ⇒ 一切"外挂"都要烘进镜像
一开始我们想用 -v 把补丁、引擎目录挂进去 —— 行不通。
所以:补丁、缓存、PGO 库、自定义算子,全部 docker build 烘进镜像。
2.3 ★★ 没有可调的就绪超时 ⇒ 必须消除冷编译(这条差点让部署永远起不来)
社区里有个实测警告很关键:
“35 分钟第一次起不来,脚本直接清理了,重新等半小时”
serve_a2.sh 的 READY_TIMEOUT 默认 2100 s(35 分钟),超时就 die 并删掉容器。
而 GPUStack 的就绪/健康检查超时是它自己的,模型 schema 里没有可调字段。
两者叠加的后果是:GPUStack 判失败 → 重启 → 永远在冷编译 → 永远起不来。
对策(顺序很重要):
- 先在停机窗口里用作者脚本手工起服一次 —— 既验证链路,又把 skcache 静态内核缓存编好;
- 手工起通后再交给 GPUStack(它复用同一份缓存,冷启动降到 ~346 s);
- 千万别一上来就让 GPUStack 直接冷编译起服。
2.4 ShmSize / memlock 已经全局配好,不用动
3. ★★★ 最贵的一个坑:启动期副作用导致静默丢 25% 性能
3.1 现象:所有指标"看着都正常",就是慢
我们的包装脚本 serve_gpustack.sh 直接 exec serve_v2.sh,
跳过了 serve_a2.sh 的全部启动期动作。其中第 1 条是:
DRAFT_GRAPH=1时,把 draft 版三个整文件从/opt/dsv41/patches/draft/拷到 live tree。
镜像里其实有这些文件(PATCH_MODE=baked),但没有任何人把它拷到 live tree。
少了这一步:
DSPARK_GRAPH_CAPTURE_METADATA=1 设了没人消费 → AscendDSAImpl.forward() 走
“no metadata” 回退分支 → 重放出来的图里根本没有 attention。
症状极隐蔽:
| 判据 | 漏装时的表现 | 为什么骗人 |
|---|---|---|
A(投机接受长度) | 不掉,甚至可能更高 | 掉进复读吸引子时 A 反而上升 |
ms/step | 反而"好看"(每步只出 1 token,步时自然短) | 单看步时以为变快了 |
tok/s | 掉约 25% | ✅ 只有它能发现 |
实测三方对比(18 token prompt / 128 输出):
| 配置 | steps/s | ms/step | A | tok/s |
|---|---|---|---|---|
手工 DRAFT_GRAPH=1(正确) | 27.4 | 36.5 | 2.10 | 57.7 |
| 托管漏装 draft 文件(错误) | 17.5 | 57.3 | 2.46 | 43.0 |
手工 DRAFT_GRAPH=0(基线) | 17.6 | 56.8 | ~2.27 | 39.9 |
⇒ 漏装时的数字与 DRAFT_GRAPH=0 基线几乎完全重合 —— 这就是识别手段。
3.2 ★ DRAFT_GRAPH 根本不是 Python 读的
这是一个让人绕远路的细节:在 /vllm-workspace 里
grep -rn DRAFT_GRAPH --include=*.py 只会命中注释,一处代码读取都没有。
真正消费它的是包装脚本:
DRAFT_GRAPH=${DRAFT_GRAPH:-1}
export DSPARK_GRAPH_CAPTURE_METADATA="$([ "$DRAFT_GRAPH" = "1" ] && echo 1 || echo 0)"
export DSPARK_DRAFT_USE_CUDAGRAPH=1 # 无条件 1
if [ "$DRAFT_GRAPH" = "1" ]; then export SPEC_EAGER=0; else export SPEC_EAGER=1; fi
# 且 =1 时才把 draft 三文件从 patches/draft/ 拷进 live tree
⇒ DRAFT_GRAPH=1 的真实含义 = 「draft 入图(SPEC_EAGER=0)」+「装 draft 补丁」+「图捕获带 metadata」,
三者绑在一起。判断它有没有生效,不能 grep 自己的变量,要去容器里验 live tree。
3.3 每次部署后必跑的三条自查(30 秒,能救命)
CN=$(docker ps --format '{{.Names}}' | grep '<model>.*-run-0' | head -1)
LIVE=/vllm-workspace/vllm-ascend/vllm_ascend/spec_decode/dspark_proposer.py
# 1) draft 补丁有没有装进 live tree(=0 说明装的是 stock 版 ⇒ 静默失效)
docker exec $CN grep -c DSPARK_GRAPH_CAPTURE_METADATA $LIVE # 必须 ≥1
# 2) live tree 与 patches 源必须逐字节一致
docker exec $CN md5sum $LIVE /opt/dsv41/patches/draft/dspark_proposer.py
# 3) DRAFT_GRAPH 有没有真的传进容器(注意:要看容器 env,不是你的脚本变量)
docker exec $CN bash -lc 'echo DRAFT_GRAPH=$DRAFT_GRAPH SPEC_EAGER=$SPEC_EAGER MAX_SEQS=$MAX_SEQS'
3.4 这一节的三条通用教训
- “vLLM 参数一致” ≠ “行为一致”。 迁移到别的编排器时,必须逐条比对原脚本的启动期动作,
而不是只比vllm serve的命令行。 - 静默失效永远优先排查。 一个"性能不对但功能正常、指标还好看"的问题,
先怀疑某个开关没生效,而不是先怀疑框架慢。 - 选对主指标。
A和ms/step在这个故障上都会指向错误方向,只有tok/s是对的。
4. 调参路线:9 个候选,8 个死(这一段是"别去试"的清单)
4.1 调度层"10 分钟筛"的结果
| 候选 | 结论 | 为什么 |
|---|---|---|
dynamic_spec_config | ❌ 不可用 | 图捕获阶段需要 host 同步 ⇒ EE1016,服务挂死 2556 s 起不来(上游 e2e 用 enforce_eager=True 所以没暴露) |
sparse_kv_offload_config | ❌ | 我们的 config.json 有 compress_ratios(43 项)⇒ 启动即 ValueError |
dyntra_lb_config / recompute_scheduler_enable | ❌ | 需要 PD-disagg 的 D 节点(kv_role='kv_consumer');我们是 PD-mixed |
enable_balance_scheduling | ❌ | 需要 DP>1;我们 DP=1 |
profiling_chunk_config / xlite_graph_config | ❌ | 分别需要 pp>1 / num_speculative_tokens==1 |
batch_job_sched_config | ⚠️ | 可装,但机理不适用(需要 #job_name[...]# 前缀分桶;它的收益来自"KV 紧张时减少抢占",而我们 18× 余量、零抢占) |
mix_placement | ❌ | 与 multistream_overlap_shared_expert 互斥 ⇒ 直接起服失败 |
VLLM_ADMISSION_GATE | ➖ | 作者的安全机制,默认已开、不要动。但代价强依赖负载区间:轻负载下 deferred_decode_reqs 恒为 0,而在"持续 prefill 积压"区间会大量推后 decode(单份日志 cumulative_deferred=1242) |
short_request_first_config | ✅ 唯一可装 | 见 §5 |
4.2 ★ 一条更省事的判据:先数"这个开关的决策点触发过几次"
评估一个新开关时,先去历史日志里数它的决策点被触发了几次。
如果遥测恒为 0 / 恒定不变 ⇒ 该开关在当前负载下是惰性的,翻日志即可定论,不用起服。
本项目里 V41_IDS64_HOIST 就是这样被判掉的(§7.1)。
4.3 ★ EPLB:六个假设全部证伪,七个方向排除 ⇒ 交上游
EPLB(专家负载均衡)看起来是最该开的东西(MoE 必然有负载不均),但我们做到了:
- 6 个修复假设(FIX1…FIX7)逐一二分实验,全部被否;
- 二分实验给出了决定性答案:EPLB 一打开,模型在"加载/初始化"阶段就被改坏了,与运行时重排动作无关;
- 又排掉 7 个方向(map 同步 /
dynamic_eplb标志 / 重排动作 / 48 buffer 显存 / per-step 热度收集 /
named buffer 注册 / 调度器静默改写参数); - 剩余候选在机理上也站不住 ⇒ 判定:病因在"配置解析 / 初始化顺序层",已写成缺陷报告交上游。
教训:有些门控"打开就坏"但没有明确报错,这时不要靠猜,
用二分法 + 带诊断补丁重启去收事实,比读源码猜快得多。
4.4 MC2 通信-算子融合:A2 上能用,但收益 ≈ 代价 ⇒ 驳回
作者在 A3 上把 MatmulAllReduce 判了"死刑",我们没有直接外推,而是在 A2 上实测:
- ✅ A2 上其实是齐的、可用(A3 的判定不能外推);
- 坑:
hcom不能用字面量,必须用backend.get_hccl_comm_name(rank)(这一步最容易卡住); - 补上了作者 headroom 报告缺的一块:MC2 融合要付多少显存;
- 但端到端一测:通信重叠 +6.81%,KV 侧 −5.8%,净收益 ≈ 0 ⇒ 驳回。
⇒ “算子能跑通” ≠ “该开”。 融合类优化的账必须端到端算。
5. ✅ 本项目最大的收益:短请求优先(SRF)
5.1 它的价值不在"更快",在"重新分配等待时间"
先说清楚它不是什么:它不改算力、不改吞吐。
它改的是调度:当长请求正在占批时,让短请求插到前面去。
所以:
- 纯短请求扫(A 段):没有长请求占批 ⇒ 不进它的分支 ⇒ 测不出效果;
- 纯长请求扫(D 段):没有短请求可插队 ⇒ 也没有插队对象 ⇒ 测不出效果;
- 必须专门造"长短混合"段,指标看短请求的首字延迟。
我们为此写了一个专门的基准:先放 8 个 9 万 token 的长请求,3 秒后再放 8/16 个短请求。
5.2 ★ 实测数据(同态协议,两臂各自新容器 + 预热丢弃轮)
| 段 | 指标 | 优化前 | 优化后 | 变化 |
|---|---|---|---|---|
| 对照:8 短单独跑 | 短请求首字 p50 | 1.538 s | 1.555 s | +1.1%(两臂等价 ⇒ 对照组成立) |
| 8 长(9万) 在飞 + 8 短 | 短请求首字 p50 | 113.999 s | 13.257 s | −88.4% |
| 同上 | 短请求首字 p95 | 114.964 s | 14.186 s | −87.7% |
| 同上 | 长请求首字 p50 | 72.516 s | 75.122 s | +3.6%(代价) |
| 同上 | 整段墙钟 | 123.2 s | 123.6 s | +0.3%(长请求并未被拖慢) |
| 8 长(9万) 在飞 + 16 短 | 短请求首字 p50 | 114.954 s | 14.479 s | −87.4% |
| 同上 | 长请求首字 p50 | 72.249 s | 77.101 s | +6.7%(代价) |
| 同上 | 整段墙钟 | 126.2 s | 125.5 s | −0.6% |
⇒ 定价:用「长请求首字 +3.6~6.7%」换「短请求首字 −88%,绝对少等 100 秒」,
而长请求的整段完成时间不变。
这个交易非常划算,因为长请求本来就要跑十几分钟,多等 3~4 秒首字无所谓;
而短请求用户真的在乎那几分钟。
另一组数据(生产形态·长请求连续到达,每 8 s 一个):短请求首字 p50 82.4 → 8.4 s(−89.8%),
长请求 +0.45%,makespan +0.31%,吞吐 −0.30%(在噪声底内)。
5.3 ★★ 坑:long_max_wait_ms 是悬崖不是旋钮
我们一开始按"别让它饿死长请求"的直觉设了 long_max_wait_ms=5000,
结果 收益完全归零(aged_long_promotions=9)。
原因是量出来的:长请求的排队时长 TTFT p50 ≈ 80 s
⇒ 任何 < 80 s 的 long_max_wait_ms 都会退化(因为上限比排队时长还短,等于没让它等)。
⇒ 用 0。 如果一定要硬上限,必须 > 80 s(例如 180000),绝不要给 5000/10000。
5.4 公平性:lmw=0 会不会把长请求饿死?实测是有界的
| 短请求到达率 | 长请求排队等待 | 判断 |
|---|---|---|
| ≤ ~0.7 /s | ≈ 0 s | ✅ 安全 |
| ~3.3 /s | 27–46 s(短流一停立刻放行) | ⚠️ 明显推迟但有界 |
机理:长请求的排队等待 ≈ 短泳道当时的深度 × 单个 prefill 步时长 ⇒ 有界。
自查:看 ShortRequestFirst stats 的 sizes,若 long 持续 >0 且增长 ⇒ 长请求正被扣住。
5.5 上线要盯的三个数
- 生效证据:日志里必须出现
Using custom scheduler class ...ShortRequestFirstAsyncSchedulerShortRequestFirst waiting queue installed+dispatches={'short': N};
- 日常自检:
aged_long_promotions必须恒为 0(>0 ⇒ 收益已丢失); - 退化告警:
docker logs $CN | grep -c '回落到逐组路径'应为 0(这条同时也是别的开关的体检项)。
6. ✅ 另外两个落地开关(都是"每步延迟"的改善,不是吞吐)
| 开关 | 效果 | 统计强度 | 性质 |
|---|---|---|---|
V41_SLOT_MAP_FUSED=1 | 单流 ms/step −3.23%(31.5 → 30.5 ms) | 3.4σ,6 样本(2 slot × 3 次),3 次独立重启方向一致 | 调用方式(省 12 次 kernel 启动的 host 开销) |
MC2_ALG=hierarchy | 单流 ms/step −1.19% | 配对 t = −10.30 / −3.30(8K / 32K 各 6/6 负) | 通信算法(无等价证明,浮点加法顺序可能变) |
两条重要的口径提醒:
- 它们是"每步快",不是"每秒多算 token"。 聚合吞吐仍受 prefill 算力天花板约束(90k 文档 ~17 tok/s)。
- 两者性质不同 ⇒ 上线闸门不同:
V41_SLOT_MAP_FUSED有等价证明(自带verify双路 +torch.equal)⇒ 常规闸门;MC2_ALG=hierarchy改的是通信算法 ⇒ 强化闸门(--tokens 60000,90000 --reps 10两深度各 10/10)
+ 闸门不过自动回滚。
★ 加新 env 开关前必须先 grep 包装脚本。 serve_gpustack.sh 硬编码了
FUSED_MC2 / MC2 / MC2_HIER / REDUCE_SAMPLE = 0(这几个怎么设都无效),
而 MC2_ALG 未被硬编码 ⇒ 会透传。
模型 env 只能控制"包装脚本用 ${VAR:-默认} 透传的那些" + 起服脚本直接读的那几个。
★ MC2_ALG 不在 V41_* 前缀里,它的值只能解析 --additional-config 的 JSON 取最终值 ——
只看 docker inspect 的 env 不够,因为包装脚本会再加工。
7. ❌ 失败的尝试与"无效"的结论(都写机理)
这一节可能是本文对同行最有用的部分:这些方向你可以不用试了。
7.1 V41_IDS64_HOIST=1 —— 实测 +0.00%,且时间预算本身就不存在
- 它确实会执行(
needs_moe_input_ids=True,因为vision_n_layers=32让gate.bias_vl成了真 Parameter); - 平台段实测 +0.00%,符号检验 p = 1.00;
- 离线取证:它要省的那个 cast 总共 476 次 / 1.445 ms / 0.0125% 设备时间。
作者估的是 0.20 ms/step = 0.63% —— 大了两个数量级。
⇒ 教训:先算时间预算,再决定要不要做实验。 一个只占 0.0125% 的目标,
无论优化到多好都不可能测出来。
7.2 hc_sinkhorn_iters 20 → 16 —— −0.47%~−0.51%(1.3–1.5σ),但不采纳
- 符号检验 7 负 1 正,p = 0.070 —— 差一点,但没到;
- 更关键:这是数值改动,证据不足以背书 ⇒ config 已还原为 20。
- 附带说明:我们原先从 R2−R1 读出的 −1.8% 主要是暖机差异,不是真实效果。
7.3 route-pipe(重排路由等待)—— 无效果(±0.2%),并给出机理
不是"测不出",而是测出来了没有。机理:
它只重排"等待",不减少 host 的总工作量,而关键路径上卡住的恰恰是总量。
辅助证据:bneck route 指标几乎没动(1.611/1.659 → 1.574/1.590)。
7.4 enable_prefill_mc2=true —— 无效果,并给出机理
- TTFT 的 σ 只有 0.008~0.019 s(≈0.3%)⇒ 测量很精确 ⇒ +0.06%~+0.43%(符号为正 = 略差);
- 机理:这个开关只改变 MC2 的 token 容量计算,不改变执行路径;
而容量本来就不是瓶颈(我们 18× KV 余量)。
⇒ 教训:一个开关"作用在 prefill 还是 decode"要先问清楚,否则会拿 decode 指标去测 prefill 开关。
(我们在这上面白测过一轮,见 §8.6。)
7.5 mc2_comm_alg=fullmesh —— +0.19%(t = +0.65 / +0.76) ⇒ 无效果
原因是 A2 缺 MC2_FULLMESH_V2_COMM 这个能力位(那是 A3 的),
get_mc2_comm_alg() 只在有该能力位时才把 fullmesh 改写成 fullmesh_v1。
而同族里的 hierarchy 是 A2+A3 都支持的 ⇒ 这就是为什么我们最终选 hierarchy。
7.6 带栈 profiler(A1)—— 失败,如实记录
/stop_profile返回 HTTP 500 “cancelled” ⇒ worker 侧 trace 从未 flush;- 只拿到 API 进程的 trace(482,121 事件),其中 0 个带
args.stack; - 产出 27 GB 无效产物,容器退出。
- 【安全提示】 删这 27 GB 之前我们先核对过:文件名里没有回滚文件、内容里没有 SRF 配置、不含 CSV,
确认是纯 profiler 残留后才删。任何删除前都要先确认它不是回滚备份。
⇒ 但这一轮的真正收获是:放弃带栈 profiler 后,改用作者自己内建的运行时探针
(bneck-probe,通过 /tmp/v41_bneck_mode 切换 stock / delay<ms> / nohost / nocomm / mirror),
拿到的东西比带栈 profile 更好、更便宜。⇒ 要测量之前,先找作者内建的探针。
7.7 作者留下的"消融臂"不都能在线用 —— mirror / mirror_validate 是 no-op
作者的 bneck 探针里留了几个消融臂,理论上很有价值。但:
runner 侧没有调 set_engram_host_inputs(全仓只有定义与注释)
⇒ _ENGRAM_HOST_INPUTS["ids"] 恒为 None ⇒ 这个臂根本是个 no-op。
⇒ 教训:看到源码里有"消融臂"别急着用,先确认它真的被接到了。
7.8 MAX_SEQS 从 32 提到 64 —— 不值得
- 吞吐只 +8.7%;
- 人均速度反而 −34%。
- ⇒ 差距在算力层面,调参无法解决。
7.9 其它小坑(都影响不大,但会浪费时间)
| 坑 | 后果 | 对策 |
|---|---|---|
探针里对 127.0.0.1 的 curl 不加 --noproxy | 被宿主 Squid 劫持成 503,看着像"服务没起来" | 一律 --noproxy '*'(或 no_proxy=*) |
MODEL=$1 没 export | exec 子进程拿不到 ⇒ 静默回退到硬编码默认路径 | export MODEL= + 目录存在性硬断言 |
探针 GATE_MODEL 没改 | 10 个请求全即时失败,像探针坏了 | 传对模型名 |
| 写死容器名/端口 | 重启后全失效(容器名带随机后缀、端口会变) | 每次动态取 |
cluster_id 漏填 | 建模型 500 NotNullViolation | 填 1 |
用 docker run --entrypoint bash 再 commit | ENTRYPOINT 被污染,起不来 | 用 docker create |
| 不烘缓存 | 冷编译 15–20 min → 撞就绪超时 | 手工起一次预热缓存 |
8. ★★★ 度量方法学(本项目最值钱的部分)
这一节和参数无关。我们在这一节里自己骗过自己三次,每次都花了数小时。
8.1 噪声尺子:单流 ms/step 的单样本噪声 ≈ ±3.5%
怎么校准的:我们放了一个no-op 电报臂(env 与对照完全相同,什么都不改),
它却测出了 +3.49% ⇒ 这就是当前的噪声底。
⇒ 期望收益 < 3.5% 的改动,单样本测不出,必须重复测量。
8.2 ★★ 但 ±3.5% 主要来自"暖机瞬态",不是硬件
同一个臂连测 8 次:31.25 → 30.75 → 30.55 → 29.85 → … → 29.85,
头 4 次就快 1.5 ms,之后落在共同平台 ~29.6 ms;三臂曲线形状完全一样。
同一个生产态在不同口径下给出 31.00 / 30.150 / 29.66(前 2 次 / 全部 16 样本 / 平台段)
⇒ 「ms/step 均值」在 <6% 量级上强依赖"测了多久" ⇒ 跨会话不可比。
正确做法:
- 只比平台段(先扔前 4 次,约 6 分钟);
- 按同一 rep 序号配对做差(暖机是共同项,一配对就被消掉;本次 σ 从 0.41 → 0.16 ms);
- 所有臂的暖机条件必须一致(要么都跑闸门要么都不跑 —— 我们有一次让 H0 没跑、H1/H2 跑了,
等于白送暖机,读出一个不存在的 −0.85%); - 保留一个 no-op 电报臂。
★ 报告里必须写"剔了几次" —— 同一份数据在不同剔除策略下能给出 0.00% 和 −0.85% 两个数。
8.3 ★ 样本量先算,再决定是"样本不足"还是"场景不对"
我们有一条旧结论"mc2_comm_alg 无差异",一开始我以为是测的场景不对(只测了 decode)。
后来发现真正的病根是样本量不足(当时只有 3 个样本)。
先算 n ≈ (σ/Δ)² × k:本次配对差 σ≈0.08~0.30 ms、Δ≈0.32~0.40 ms ⇒ 6 对就够。
补到 6 样本后,hierarchy 就从"不可定论"变成 −1.19%,t≈−5.8σ。
⇒ "降低结论强度"和"提高样本量"是两条路,应先在原场景把样本量补够,再考虑换场景。
8.4 ★★ 静默降级检测:接口 200、但输出被过滤光了
我们的基准一度出现 ok=8/8 但 TTFT=None 的诡异组合。
根因:服务退化后重复输出 <|begin▁of▁sentence|> 这类特殊 token,被 skip_special_tokens 全过滤了
⇒ 一个 content chunk 都数不到。
对策:基准脚本里单独统计"有内容的请求数",为 0 就直接中止并告警;
并给"已知答案"的探针跑逐字节闸门。
另一条:判断服务是否健康不要用 A(接受长度) ——
模型掉进复读吸引子时 A 反而更高;而且作者给的 pos0 ≥ 0.8 阈值在我们机器上不成立
(实测单峰 0.65 而文本完全连贯)。以 tok/s 为主判据 + 扫一眼文本有无复读。
8.5 ★★ profiler 的口径:三个分母 + 两个陷阱
三个分母会给出三个不同的数字,报数时必须说清用哪个:
| 口径 | 总时间 | 说明 |
|---|---|---|
op_statistic.csv | 6460.4 ms | 只有"算子",不含通信 |
kernel_details.csv | 11582.9 ms | 全 device,含通信 |
step_trace_time.csv | — | 墙钟 |
陷阱 1:op_statistic.csv 的 Ratio(%) 列分母是"算子总时间"而不是"全 device 时间"。
陷阱 2:kernel_details.csv 里通信 kernel 也叫 AivKernel(Core Type = COMMUNICATION)
⇒ 很容易把"通信"误当成"最大的算子"。我们就一度把通信排成"第一大头寸"。
"sum 口径"与"并集口径"也会差一倍:通信 kernel 求和 5122.5 ms,但并集只有 2561.3 ms
(通信 kernel 彼此之间重叠约一半)。⇒ 报"通信占比"必须说清是 sum 还是 union。
8.6 ★★ 判断"图内 / 图外"要看 Model ID,不是 OP State
Model ID == 4294967295(= 0xFFFFFFFF)= 无模型 ID = 图外(每步重新启动 ⇒ 有 host 启动开销);16/17= 图内 replay;OP State(static/dynamic)只是内核编译模式,不是判别器。
⇒ 判据成型:一个"减少 kernel 启动次数"的优化能不能生效 =
① 那个 kernel 是否每步都在图外重启动 ② 每步启动几次。
用这个判据一筛就很清楚:slot-mapping 是 12 次/步(1152 = 12×96)⇒ 省得动;
int64 cast 只有 ~5 次/前向 ⇒ 省不动(与 §7.1 的离线取证一致)。
8.7 ★★★ 两臂各自要重部署才能切换时:必须过一道"预热丢弃轮"
这是我们踩得最惨的一次,值得单独说。
事故:做"优化前 vs 优化后"对比时,「优化前」臂是刚重建容器、ready 就直接开测,
而「优化后」臂跑在长期在线的热容器上。结果:
| 臂 | 容器状态 | 单请求吞吐 |
|---|---|---|
| 优化前 | 刚重建(冷) | 6.6 tok/s |
| 优化后 | 长期在线(热) | 66.7 tok/s |
判它是伪影(三步,全是读数据):
- 历史 8 次基线,同一档一直是 29.6~66.2 tok/s ⇒ 6.6 是 5~10 倍离群;
- 看曲线形状:优化前 c4/c8/c16 = 61.2/54.3/68.0 ms,与历史上"另一个已知配置"那条
(73.6/66.4/81.5)几乎重合,而 c32 以上所有版本都收敛到 65~105
⇒ 低并发段退化成了一条已知的慢路径; - 两臂热状态不对等 ⇒ 这个对比整体作废。
修法(同态协议,两臂逐字相同):
redeploy(env) → 断言 env → 冒烟 /v1/models=200
→ 【跑一整轮基准当预热,输出直接丢弃】 ← 关键,别省
→ 正式测量
改完之后,预热轮自己重现了坏数字 —— 这是最干净的证据:
| 轮次 | 单请求吞吐 | 首字延迟 |
|---|---|---|
| 上一轮作废臂(无预热) | 6.6 tok/s | 12.111 s |
| 本轮预热轮(丢弃) | 6.6 tok/s | 11.953 s |
| 本轮正式轮 | 58.2 tok/s | 0.360 s |
★ 两个附带教训:
-
env断言要"逐项",包括你以为两边都一样的项。 那轮只断言了"三个实验开关不存在",
偏偏漏了DRAFT_GRAPH=1—— 而它才是影响低并发最大的那一个。
"我以为它在"不构成断言。 -
预热还必须与"被测负载的形状"同形。 我们的预热轮跑的是 18 token 短提示:
- 短请求路径被烤热了:同一臂 A 段从 6.6 → 58.2 tok/s;
- 长提示路径没被烤热:9 万 token 段两臂都是 86.3/86.6 s。
而在同一个容器上、长提示被反复走过数十分钟后重测,直接回落到 71.91 s(−17.0%)、
吞吐 15.1 → 17.3 tok/s,与历史长期在线容器的 72~74 s 吻合。
⇒ "绝对值不可跨会话比"与"臂间可比"是两件事,报告里要分开说。
8.8 ★★ 两个"验证工具本身"的坑(同一类错误的两种形态)
形态一:%r 引号 bug(它解释了三次自伤里的两次)
旧写法 real = "echo %s | sudo -S bash -lc %r" % (PASS, cmd) —— %r 用 Python repr 把命令裹成 '...',
只要 cmd 里含单引号,repr 会产出 \',而 shell 里单引号内无法用 \' 转义
⇒ 内层 unexpected EOF while looking for matching '"' ⇒ 命令根本没执行、输出为空
⇒ 上层"判子串"只得 False ⇒ 把"读不到"误判成"值是错的"。
受害者:一个"配置修改成功"的假象(patch_sinkhorn 打印成功但配置没改)、以及白丢两个实验臂。
修法:用 shlex.quote(cmd)(POSIX 下唯一稳妥的做法)。
形态二:判子串证明不了"生效"
grep -m1 ... | tail -c 1500 这种写法无法区分"没找到"和"值不对"。
必须把结构化参数解析出来取最终值:
例如 additional_config 是"追加片段"注入的,出现重复键是正常的,
json.loads 的语义是重复键取最后一个 ⇒ 必须解析出 JSON 再读键。
(grep -c 至少能区分"有没有",但证明不了"值对不对"。)
形态三:探针自检挡刀
上面那两个 bug 之后,我们给探针加了强制自检(AC-JSON 解析 + 期望值断言)。
后来在一次窗口重跑时,自检当场报 (未找到) 并终止了脚本 —— 没白烧一次重启。
⇒ 教训:验证工具本身必须被验证。 在这之前,我们所有的"没找到/没效果"结论都不可信。
8.9 ★ 一条远程运维的硬纪律
从本机跑 1 小时的远端实验时:
- SSH 通道约 10 分钟会超时 ⇒ 直接阻塞式跑会被掐断,而远端仍在跑
⇒ 我们因此让两个远端进程写了同一个日志文件,污染了数据; pkill -f 'script.py full'会匹配到自己的 SSH 命令行,把自己杀掉(exit −1);- 修法:远端一律
setsid nohup脱管 + 本地短轮询一个"完成标记文件";
pkill用不会匹配自身的匹配式;每次跑用新的 TAG,避免旧产物混淆; - 就绪判据只能用「容器名换过」+「
/v1/models返回 200」,
不要用ready_replicas—— 它会把正在销毁的旧实例报成 ready,也会卡在 0 而服务其实已可用(实测白等 2700 s)。 - 端口不要写死:它从
/v2/models/<id>/instances动态取(我们见过 40003 → 40019 → 40054 → 40030 → 40045)。 - 本机对
127.0.0.1的请求一律禁代理(否则被 Squid 劫持成 503)。
8.10 一条"血的教训":改 additional_config 不能用追加 flag
--additional-config 重复出现是 last-wins 完全覆盖,不是合并。
实测:parse_args(['--additional-config','{"a":1}','--additional-config','{"b":2}']) → {'b':2}。
⇒ 绝不能用 EXTRA_ARGS='--additional-config {...}' 追加键:
脚本会把它接到已有 flag 之后,整份基线配置被清空
(丢 enable_engram / engram_storage=int8 / ascend_compilation_config{npugraph_ex,static_kernel} /
enable_cpu_binding / multistream_*),而且可能报在别处的错,极难归因。
正确做法:给 serve_v2.sh 加一个只追加、不动基线键的挂点:
if [ -n "${V41_AC_EXTRA:-}" ]; then AC="${AC%?}${V41_AC_EXTRA}}"; fi
V41_AC_EXTRA 是逗号开头、无空格的 JSON 片段;未设时 AC 逐字节不变(可断言)。
起服后必须断言"基线键仍在 + 新键已进",缺任何一个就中止。
9. 与上一代 4.0 的对比(同机、同脚本、同入口)
| 并发 | 4.0 吞吐 (tok/s) | 4.1 吞吐 (tok/s) | 差异 |
|---|---|---|---|
| 1 | 22.2 | 68.4 | +208% |
| 4 | 76.3 | 152.9 | +100% |
| 8 | 135.8 | 200.6 | +48% |
| 16 | 288.6 | 229.7 | −20% |
| 32 | 558.1 | 275.7 | −51% |
| 64 | 751.8 | 265.1 | −65% |
| 128 | 372.0 | 266.3 | −28% |
- 分水岭在 8~16 并发之间:8 人以内 4.1 明显更快(单用户最高 3 倍);16 人以上 4.0 更强。
- 原因不是配置问题:4.0 是"小模型 + 极佳的批量扩展性"(并发越高越划算);
4.1 是"大模型 + 单次推理加速技术"(单用户很快,但算力在高并发时共享)。 - 长文档(9 万 token,8 并发):4.1 首字 72.3 s vs 4.0 68.3 s(+5.8%),吞吐持平(17.0 tok/s)。
这是硬件算力边界(约 5.5–6.1k token/s 的文档读取速度),与模型版本无关。 - ⚠️ 口径警告:4.1 那个 72.3 s 与 4.0 的 68.3 s 都是长期在线容器的值,可以直接比;
但新起容器的 9 万文档首字是 86.6 s(见 §8.7)——不要跨态比。
升级的额外收益:上下文 80 万 → 100 万 token;新增图像理解;
关掉命中率仅 3.52% 的前缀缓存换来长文档首字 −14.6%(87.4 → 74.6 s)。
10. 给框架作者 / 上游的几条反馈
10.1 ★ 通信几乎不重叠,但原因不是"开关没开"
- 通信并集 2561.3 ms(占计算+通信的 28.4%),而与计算的重叠只有 13.2 ms ⇒ 0.517%;
- 按 (类别, Stream, Model ID) 做交叉表发现:通信跑在
11 / 109 / 114这些计算没占用的专用流上
⇒ 多流机制是在工作的,硬件层面的并行机会存在; - ⇒ 不重叠的原因是"层间依赖同步"(MoE / TP 的通信结果被下一层立刻消费,
event.wait让计算必须等)。
⇒ 正确的提法:不是"请把 overlap 打开"(已经开着),
而是"请把通信切碎并与更细粒度的计算交错,或减少通信量本身"。
三条可执行建议:① 优先动 prefill 侧(通信的 79% 在图外);
② 减少通信次数(hcom_allReduce_ 共 17080 次 / 96 个前向 ≈ 178 次/前向,平均每层 4~5 次 allReduce,评估合并);
③ 注意:本栈上"加重叠"是有代价的(我们实测 MC2 融合:重叠 +6.81% vs KV −5.8%,净 ≈ 0)。
10.2 ★ 四个算子已定位到代码行
| 优先级 | 算子 | 占比 | 瓶颈管线 | 定位到 |
|---|---|---|---|---|
| 1 | 集合通信 | 全 device 44.22% | — | 机理已定量(见 10.1) |
| 2 | SparseFlashMla | 19.99% | aiv_scalar_ratio 0.722 | GetRealS2Idx() 里的 GM 标量读,每轮调 2 次 |
| 3 | HcPre | 12.93% | aic_mte1_ratio 0.476 | K_L0_SIZE 写死 32 + L0A/L0B_BUF_NUM = 2 |
| 4 | ScatterNdUpdateSk | 6.01% | aiv_scalar_ratio 0.195(最"空") | dispatcher 里 显式 #undef HIGH_PERFORMANCE |
其中第 4 项值得特别说一句:scatter_nd_update_sk.cpp 里有这样一段原文注释:
// NOTE: the build system passes --impl_mode=high_performance,optional ...
// The HP kernel is intentionally NOT compiled/used here:
// it skips SyncAll and is non-deterministic for duplicate indices (missed/polluted rows).
// Always take the deterministic path: LinearIndex + Sort + Scatter with SyncAll.
#ifdef HIGH_PERFORMANCE
#undef HIGH_PERFORMANCE
#endif
⇒ 高性能实现已经写好了,只是被强制关掉(为了确定性)。
建议:把"允许重复索引"做成显式 opt-in(tiling 里加一个 flag),
让能保证索引无重复的调用点走 HP 路径。这可能是这四个算子里最低成本的一个收益。
10.3 ★ 三条给 profiler / 工具的请求
- 在输出里说明分母:
op_statistic.csv的Ratio(%)分母是"算子总时间"而非"全 device 时间",
这一点很容易让人把"通信"误当成最大的算子(我们就误判过一次); - 给通信 kernel 一个可区分的名字:它现在也叫
AivKernel,只能靠 Core Type 分辨; - 暴露 tilingKey:
ScatterNdUpdateSk有 5 条策略分支(tilingKey = indexType×10 + sortFlag),
但现在只能看到总耗时,看不到实际走了哪一支 —— 这决定了后续该改哪条实现。
10.4 ★ 两条"文档级"的反馈
DRAFT_GRAPH=1有一类不体现在A值上的静默失效 —— 建议在 README 里写清
“漏装补丁时A不降反升、ms/step反而更好看,只有tok/s能发现”,并给出 md5 自查命令;pos0 ≥ 0.8这个判据跨机器不成立 —— 我们实测单峰 0.65 而输出文本完全连贯。
建议改成"按请求类型选判据 + 先用基线自比标定"。
11. 如果重来一次,我会这样做(Checklist)
集成阶段
- 先问清编排器的四条硬约束(挂载、镜像、就绪超时、ShmSize),再动手;
- 先用作者脚本手工起一次服,把冷编译缓存编好,再交给编排器;
- 别用
-v,一切烘进镜像; - 别用追加 flag 改
additional_config(last-wins 覆盖),用"追加挂点"。
验证阶段
- 起服后立刻跑三条自查(补丁是否装进 live tree / md5 / 容器 env 真实值);
- 主指标用
tok/s,不要用A;另加"有内容的请求数"防静默降级; - 断言要逐项,包括你以为不会变的那几项;
- 先读容器
/proc/1/environ,再看docker inspect(后者可能不是全量)。
测量阶段
- 先放一个 no-op 电报臂校准噪声底;
- 只比平台段 + 按 rep 配对,报告里写清剔了几次;
- 期望收益 < 噪声底的改动,先算样本量再开跑;
- 两臂各自要重部署时,必须过"预热丢弃轮",且预热要与被测负载同形;
- 报性能数字必须带口径(哪个分母、sum 还是 union、容器热不热)。
优化阶段
- 先算时间预算,再决定做不做(0.0125% 的目标不值得试);
- 先数决策点触发次数,惰性开关翻日志就能定论;
- 端到端算账:“算子跑通了” ≠ “该开”;
- 测不出效果 ≠ 没效果 —— 先检查基准是否具备该特性生效的前提条件
(纯短扫测不出插队型调度,就是典型); - 一个"无差异"的旧结论,病根可能是样本量不足而不是场景不对。
最后一条
- 验证工具本身必须被验证。 在这之前,所有"没找到 / 没效果"的结论都不可信。
附录:产物索引
| 类别 | 文件 |
|---|---|
| 运维手册(含启动期清单、坑表、回滚) | V4.1-Flash_GPUStack托管运维手册.md |
| 算子层改进提案(给上游,§14 起以此为准) | V4.1-Flash_算子层改进提案_给上游.md |
| 全过程工作日志(56 步) | V4.1-Flash_③算子优化_工作日志.md |
| 剩余优化项台账(含失败记录与方法学事故) | V4.1-Flash_剩余优化项台账_20260927.md |
| 四列性能对比汇报页 | 4.1优化效果_对比汇报.html |
| 同态协议 A/B 驱动脚本 | p8_ab_warm.py |
| 长短混合流量基准 | v41_mix_bench.py |
| 只读巡检脚本 | p4_health.py |
本文所有数字都可在上述文件中追溯到具体运行、具体文件、具体口径。
标注为"实测"的都是我们真跑过的;标注为"机理"的都是读源码/日志得出的;标注为"估算"的会显式说明。
鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。
更多推荐



所有评论(0)