在 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=16 样本配对测量 + 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 判失败 → 重启 → 永远在冷编译 → 永远起不来。

对策(顺序很重要):

  1. 先在停机窗口里用作者脚本手工起服一次 —— 既验证链路,又把 skcache 静态内核缓存编好;
  2. 手工起通后再交给 GPUStack(它复用同一份缓存,冷启动降到 ~346 s);
  3. 千万别一上来就让 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/sms/stepAtok/s
手工 DRAFT_GRAPH=1(正确)27.436.52.1057.7
托管漏装 draft 文件(错误)17.557.32.4643.0
手工 DRAFT_GRAPH=0(基线)17.656.8~2.2739.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 这一节的三条通用教训

  1. “vLLM 参数一致” ≠ “行为一致”。 迁移到别的编排器时,必须逐条比对原脚本的启动期动作,
    而不是只比 vllm serve 的命令行。
  2. 静默失效永远优先排查。 一个"性能不对但功能正常、指标还好看"的问题,
    先怀疑某个开关没生效,而不是先怀疑框架慢。
  3. 选对主指标。 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 短单独跑短请求首字 p501.538 s1.555 s+1.1%(两臂等价 ⇒ 对照组成立)
8 长(9万) 在飞 + 8 短短请求首字 p50113.999 s13.257 s−88.4%
同上短请求首字 p95114.964 s14.186 s−87.7%
同上长请求首字 p5072.516 s75.122 s+3.6%(代价)
同上整段墙钟123.2 s123.6 s+0.3%(长请求并未被拖慢)
8 长(9万) 在飞 + 16 短短请求首字 p50114.954 s14.479 s−87.4%
同上长请求首字 p5072.249 s77.101 s+6.7%(代价)
同上整段墙钟126.2 s125.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 /s27–46 s(短流一停立刻放行)⚠️ 明显推迟但有界

机理:长请求的排队等待 ≈ 短泳道当时的深度 × 单个 prefill 步时长 ⇒ 有界。
自查:看 ShortRequestFirst stats 的 sizes,若 long 持续 >0 且增长 ⇒ 长请求正被扣住。

5.5 上线要盯的三个数

  1. 生效证据:日志里必须出现
    Using custom scheduler class ...ShortRequestFirstAsyncScheduler
    • ShortRequestFirst waiting queue installed + dispatches={'short': N};
  2. 日常自检:aged_long_promotions 必须恒为 0(>0 ⇒ 收益已丢失);
  3. 退化告警: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 负)通信算法(无等价证明,浮点加法顺序可能变)

两条重要的口径提醒:

  1. 它们是"每步快",不是"每秒多算 token"。 聚合吞吐仍受 prefill 算力天花板约束(90k 文档 ~17 tok/s)。
  2. 两者性质不同 ⇒ 上线闸门不同:
    • 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 没 exportexec 子进程拿不到 ⇒ 静默回退到硬编码默认路径export MODEL= + 目录存在性硬断言
探针 GATE_MODEL 没改10 个请求全即时失败,像探针坏了传对模型名
写死容器名/端口重启后全失效(容器名带随机后缀、端口会变)每次动态取
cluster_id 漏填建模型 500 NotNullViolation填 1
用 docker run --entrypoint bash 再 commitENTRYPOINT 被污染,起不来用 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% 量级上强依赖"测了多久" ⇒ 跨会话不可比。

正确做法:

  1. 只比平台段(先扔前 4 次,约 6 分钟);
  2. 按同一 rep 序号配对做差(暖机是共同项,一配对就被消掉;本次 σ 从 0.41 → 0.16 ms);
  3. 所有臂的暖机条件必须一致(要么都跑闸门要么都不跑 —— 我们有一次让 H0 没跑、H1/H2 跑了,
    等于白送暖机,读出一个不存在的 −0.85%);
  4. 保留一个 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.csv6460.4 ms只有"算子",不含通信
kernel_details.csv11582.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

判它是伪影(三步,全是读数据):

  1. 历史 8 次基线,同一档一直是 29.6~66.2 tok/s ⇒ 6.6 是 5~10 倍离群;
  2. 看曲线形状:优化前 c4/c8/c16 = 61.2/54.3/68.0 ms,与历史上"另一个已知配置"那条
    (73.6/66.4/81.5)几乎重合,而 c32 以上所有版本都收敛到 65~105
    ⇒ 低并发段退化成了一条已知的慢路径;
  3. 两臂热状态不对等 ⇒ 这个对比整体作废。

修法(同态协议,两臂逐字相同):

redeploy(env) → 断言 env → 冒烟 /v1/models=200
  → 【跑一整轮基准当预热,输出直接丢弃】   ← 关键,别省
  → 正式测量

改完之后,预热轮自己重现了坏数字 —— 这是最干净的证据:

轮次单请求吞吐首字延迟
上一轮作废臂(无预热)6.6 tok/s12.111 s
本轮预热轮(丢弃)6.6 tok/s11.953 s
本轮正式轮58.2 tok/s0.360 s

★ 两个附带教训:

  1. env 断言要"逐项",包括你以为两边都一样的项。 那轮只断言了"三个实验开关不存在",
    偏偏漏了 DRAFT_GRAPH=1 —— 而它才是影响低并发最大的那一个。
    "我以为它在"不构成断言。

  2. 预热还必须与"被测负载的形状"同形。 我们的预热轮跑的是 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)差异
122.268.4+208%
476.3152.9+100%
8135.8200.6+48%
16288.6229.7−20%
32558.1275.7−51%
64751.8265.1−65%
128372.0266.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)
2SparseFlashMla19.99%aiv_scalar_ratio 0.722GetRealS2Idx() 里的 GM 标量读,每轮调 2 次
3HcPre12.93%aic_mte1_ratio 0.476K_L0_SIZE 写死 32 + L0A/L0B_BUF_NUM = 2
4ScatterNdUpdateSk6.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 / 工具的请求

  1. 在输出里说明分母:op_statistic.csv 的 Ratio(%) 分母是"算子总时间"而非"全 device 时间",
    这一点很容易让人把"通信"误当成最大的算子(我们就误判过一次);
  2. 给通信 kernel 一个可区分的名字:它现在也叫 AivKernel,只能靠 Core Type 分辨;
  3. 暴露 tilingKey:ScatterNdUpdateSk 有 5 条策略分支(tilingKey = indexType×10 + sortFlag),
    但现在只能看到总耗时,看不到实际走了哪一支 —— 这决定了后续该改哪条实现。

10.4 ★ 两条"文档级"的反馈

  1. DRAFT_GRAPH=1 有一类不体现在 A 值上的静默失效 —— 建议在 README 里写清
    “漏装补丁时 A 不降反升、ms/step 反而更好看,只有 tok/s 能发现”,并给出 md5 自查命令;
  2. pos0 ≥ 0.8 这个判据跨机器不成立 —— 我们实测单峰 0.65 而输出文本完全连贯。
    建议改成"按请求类型选判据 + 先用基线自比标定"。

11. 如果重来一次,我会这样做(Checklist)

集成阶段

  1. 先问清编排器的四条硬约束(挂载、镜像、就绪超时、ShmSize),再动手;
  2. 先用作者脚本手工起一次服,把冷编译缓存编好,再交给编排器;
  3. 别用 -v,一切烘进镜像;
  4. 别用追加 flag 改 additional_config(last-wins 覆盖),用"追加挂点"。

验证阶段

  1. 起服后立刻跑三条自查(补丁是否装进 live tree / md5 / 容器 env 真实值);
  2. 主指标用 tok/s,不要用 A;另加"有内容的请求数"防静默降级;
  3. 断言要逐项,包括你以为不会变的那几项;
  4. 先读容器 /proc/1/environ,再看 docker inspect(后者可能不是全量)。

测量阶段

  1. 先放一个 no-op 电报臂校准噪声底;
  2. 只比平台段 + 按 rep 配对,报告里写清剔了几次;
  3. 期望收益 < 噪声底的改动,先算样本量再开跑;
  4. 两臂各自要重部署时,必须过"预热丢弃轮",且预热要与被测负载同形;
  5. 报性能数字必须带口径(哪个分母、sum 还是 union、容器热不热)。

优化阶段

  1. 先算时间预算,再决定做不做(0.0125% 的目标不值得试);
  2. 先数决策点触发次数,惰性开关翻日志就能定论;
  3. 端到端算账:“算子跑通了” ≠ “该开”;
  4. 测不出效果 ≠ 没效果 —— 先检查基准是否具备该特性生效的前提条件
    (纯短扫测不出插队型调度,就是典型);
  5. 一个"无差异"的旧结论,病根可能是样本量不足而不是场景不对。

最后一条

  1. 验证工具本身必须被验证。 在这之前,所有"没找到 / 没效果"的结论都不可信。

附录:产物索引

类别文件
运维手册(含启动期清单、坑表、回滚)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

本文所有数字都可在上述文件中追溯到具体运行、具体文件、具体口径。
标注为"实测"的都是我们真跑过的;标注为"机理"的都是读源码/日志得出的;标注为"估算"的会显式说明。

Logo

鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。

更多推荐