Kimi K3 B200 介绍了 prefill 为什么用 PP16 × TP1,以及每个优化是哪次提交引入的。本文换一个角度:拿 prefill 启动脚本 start_k3_prefill_pp16.sh 里的环境变量逐个对照 aiak_sglang 代码,说清楚每个开关缩短的是哪一段时间、为什么能缩短,以及哪些开关其实没有生效。

问题:PP16 prefill 的时间花在哪

PP16 把 93 层切成 16 段,一张卡一段。层内没有任何 TP 或 EP 通信,代价转移到了三个地方:

另外,单个 stage 内部还有一个和 PP 无关的问题:K3 每卡 96 个 head,不是 2 的幂;投影输出切出来的张量带 stride。PyTorch 的通用实现在这些形状上会退化成不能向量化的小 kernel。

抽象:吞吐看周期,TTFT 看确认次数

把脚本里的开关按”缩短的是哪段时间”分成两组,就容易看清:

PP16 prefill:每个开关缩短的是哪一段时间

下面三节讲吞吐,第四节讲 TTFT,最后两节是不影响速度但必须设对的开关,以及当前脚本里没生效的设置。

吞吐一:层怎么切

SGLANG_PP_LAYER_PARTITION="7,5,7,5,7,5,7,5,7,5,7,5,7,5,6,3" 指定每个 stage 的层数,由 distributed/utils.py 的 get_pp_indices 读取,启动时校验个数等于 pp_size、总和等于 93。

按 config 的 full_attn_layers 数下来,这个切法有以下规律:

跨机切点的位置是这个切法里最讲究的地方。前 8 段合计 48 层,所以 s7 负责第 43 ~ 47 层(0-based),跨机消息里的 bank 只有 4 行(第 0、12、24、36 层写入)。最初的 PP16 脚本(luno-4974)注释里记录:切点挪到 49 会让第 48 层写的第 5 行也跨机,吞吐下降 3.8%。

旧版脚本还有一条约束:前缀和不能落在 8/24/52/68/84,否则 DSPARK 的 aux 抓取层(0-based 7/23/51/67/83)正好是某个 stage 的最后一层,会访问只有最后一个 stage 才有的 output_attn_res_proj 而报错。现在的切法前缀和里有 24 和 84,能启动是因为 luno-5270 给非最后 stage 加了 next_stage_attn_res_proj(models/kimi_k3.py 的 _dspark_capture_stream),这条约束已经不存在。

吞吐二:跨机边上的 bank

PP 消息由 KimiK3Model.forward 在非最后 stage 组装:hidden_states 加 residual,而 K3 的 residual 实际是 attn_res.block_residual,形状 [T, NB, H]。在 48 层切开时 NB = 4,bank 占 s7 → s8 流量的 80%。

问题在于这 4 行 s7 一行都没改,却要等 s7 的 forward 跑完才跟着 hidden 一起发出去。这时下一个微批还没开始算,传输没有可以重叠的计算。

SGLANG_PP_EAGER_BANK_FORWARD=1 把消息拆成两条(managers/scheduler_pp_mixin.py 的 _pp_eager_bank_forward / _pp_eager_bank_strip):

接收端默认(SGLANG_PP_EAGER_BANK_PREFETCH=1)在侧流上提前一轮收 bank。传的字节和原来完全相同,结果 bit-exact。SGLANG_PP_EAGER_BANK_RANKS="7" 把改写限定在 s7 这个发送端,也就是唯一的跨机边。

luno-4974 的实测(2 机 PP16,varlen prefill,平均输入 76K,200 请求,各跑两次):s7 → s8 的 boundary stall 从 33.2 / 30.5 ms 降到 11.2 / 9.8 ms,吞吐从 61623 提到 63882 tok/s,提升 3.7%。

这是一个两端协议:s8 是否要收 pp_bank,取决于 s7 是否发。两台机器的 pod 各自读环境变量,如果一台设了一台没设,就会有一端的 send 没有对应的 recv。所以 _pp_resolve_eager_bank_cfg 启动时在 PP 组里 all-gather 配置,一律以 PP0 的为准,不一致只打 warning。

--pp-async-batch-depth 2 是同一类的命令行参数:微批槽位变成 pp_size + 2,上一个微批的输出回传和 CPU 侧预处理放到当前微批 launch 之前提交,藏到 GPU 计算后面。

吞吐三:stage 内的 kernel

前两节决定的是”哪个 stage、哪条边最慢”,这一节让每一层本身变快。93 层里 69 层是 KDA、24 层是 MLA,每个 stage 都有两种层,所以这些优化 16 个 stage 一起受益,不会改变谁是瓶颈。

一层 KDA 和一层 MLA 的 prefill 算子链

KDA

开关 换掉了什么 代码位置
SGLANG_OPT_FLASH_KDA_CUTE=1 外部 flash_kda 包换成仓内 CuTe DSL 实现(SM100+),加载完权重后预编译,避免首个请求冷编译 layers/attention/linear/kernels/kda_flashkda.py;预编译在 models/kimi_k3.py 的 precompile_kernels_after_loading
SGLANG_FLASHKDA_MIN_SEQ_LEN=64 / MAX_SEQ_LEN=24576 FlashKDA 的适用长度窗,默认上限 2048 FlashKDAKernel._should_fall_back
SGLANG_K3_KDA_SHORT_CONV=1 q/k/v 三次 Triton causal_conv1d_fn(含 SiLU 和 conv state 更新)合成一次 CUDA launch;5 种 prefill 形状上合计快 1.787 倍 layers/attention/linear/kda_backend.py 的 forward_extend
SGLANG_OPT_USE_K3_KDA_FUSED_BETA_SIGMOID=1 从 [f_a\|b] 的 strided 切片上做 BF16 → FP32 copy 和 sigmoid,两个 kernel 合成一个 models/kimi_k3.py 的 kda_activate_beta
SGLANG_OPT_USE_K3_KDA_FUSED_TRACK_CONV_STATE=1 prefix_lens > 0 比较、conv 窗口 gather、index_put 三个小 kernel 合成一个,原地写 conv pool kda_backend.py
SGLANG_OPT_USE_KDA_STRIDED_ONORM=1 FusedRMSNormGated 的 reshape 被迫做的非连续 copy 和 gated RMSNorm 合成一个 kernel models/kimi_k3.py 的 KDA 输出尾部

这一组里最容易踩坑的是 FlashKDA 的长度窗。判断是按整个 batch 做的:batch 里最短的 extend 低于下限,或者最长的超过上限,这个 batch 的 69 层 KDA 全部退回 Triton chunk_kda。原 PP16 脚本注释记录这会慢约 5%,并且单独调大 chunk 会因此表现为负优化。所以 SGLANG_FLASHKDA_MAX_SEQ_LEN 必须不小于 --chunked-prefill-size。当前脚本 chunk 是 16K、上限是 24K,满足要求。

四个小融合单看每个只省几微秒,但它们都在 KDA 层的 forward 里,一个 chunk 要乘以 69 层。四个都与原路径 bit-exact,形状覆盖不到时自动回退。

MLA

trtllm_mla 后端处理 extend 时一律走 MHA_CHUNKED_KV(models/deepseek_common/attention_backend_handler.py 的 handle_attention_trtllm_mla):KV pool 里只存 latent,每次 forward 都要把前缀经 kv_b_proj 分块展开成完整的 K/V,逐块算 attention 再按 LSE 合并。

开关 作用
SGLANG_OPT_USE_FLATROW_CONCAT_MHA_K=1 把 k_nope 和 k_pe 拼成 K。现有的两个快速 kernel 分别只支持 128 head 和 2 的幂个 head,K3 的 96 head 都不满足,PyTorch 切片赋值会退化成两个每线程只搬 2 字节的 copy。开启后按 (token, head) 平铺成行,用 Triton 写。新 token 和每个前缀块的拼接都会用到
SGLANG_OPT_USE_TRITON_MLA_QKV_FP8_CAST=1 _quantize_fp8_qkv 里三次 bf16 → fp8 的 Tensor.to 换成 Triton。v 是 kv[..., 128:] 的视图,head stride 256、只有内层 128 个元素连续,torch 的 cast 无法向量化,只有 0.667 TB/s。在 B200、T = 32768、12 head 的形状上,它占这组算子 397.6 µs 中的 188.9 µs
SGLANG_MAX_KV_CHUNK_CAPACITY=65536 每个前缀块最多展开多少 token(按 batch 平分),默认 128K。调小后长前缀会多切几块,主要作用是压低展开 K/V 的临时显存峰值。这个值是在 luno-5385(修复 PP 重放时准入不一致的崩溃)里改的,应该看作稳定性设置,不是提速开关

FP8 cast 那个 kernel 连 PyTorch 的溢出编码都照搬了:|x| > 464 以及 inf / nan 一律写成带符号的 0x7f,而不是硬件默认的截到 448。bit-exact 就是靠这种细节保证的。

SGLANG_K3_ATTN_MOE_FP8=1 不是可选的优化。它是加载 /Kimi-K3-ATTN-MOE-DENSE-FP8 这份混合 checkpoint 的总开关:attention 投影、shared expert 等按 ModelOpt FP8 加载,路由专家保持 MXFP4(layers/quantization/modelopt_quant.py 的 _k3_attn_moe_fp8_enabled)。换了 checkpoint 就必须开。

TTFT:去掉绕环一圈的确认

一个请求在 PD prefill 里要过三道全体 stage 的关口:准入前 16 个 stage 都要确认 bootstrap 完成;最后一个 chunk 算完,16 个 stage 各自的 KV 都要发出去;全部发完后才释放 KV 页和请求槽位。原来这三道关口都沿 PP 环推进,每一跳占一个微批槽位。下面四个开关都只在 pp_size > 1 时生效,前三个要求 Mooncake 传输后端。

开关 原来卡在哪 现在
SGLANG_PP_PD_PREFILL_SIDEBAND_BOOTSTRAP_CONSENSUS=1 “所有 stage 都确认了这个 room”沿 PP 中继链每轮迭代走一跳,准入前固定多等 pp_size 轮 每个 stage 自己的 transfer_infos 齐了就经旁路 ZMQ 直接向 PP0 报 PP_READY,PP0 收齐 16 份才把 room 置为 WaitingForInput。准入条件不变,只是确认改成并行
SGLANG_PP_PD_PREFILL_EARLY_LAST_CHUNK=1 最后一个 chunk 的 KV 在 process_batch_result 里发,要等输出沿 PP 环回到 PP0 非 PP0 的 stage 在下一轮迭代的 CPU 阶段就发自己那几层的 KV/state;aux(首 token)只由 PP0 写和发;输出环里中继的那半圈去掉,只剩最后一个 stage 发、PP0 收
SGLANG_PP_PD_KV_TRANSFER_TARGET_PARALLEL=1 发送队列的 key 是目标 rpc 端口之和,decode 集群固定时这个值不变,一个 rank 的所有 room 都挤在同一个队列、同一个线程上串行 按 hash(bootstrap_room) 选队列;一个 room 有多个 decode 目标时拆成每个目标一个任务。这个开关隐含打开 SGLANG_PP_PD_KV_TRANSFER_ROOM_SHARDING
SGLANG_PP_PD_PREFILL_EARLY_STREAM_OUTPUT=1 HTTP 响应要等释放共识。共识的第二圈逆着流水线方向走,曾经让 prefill 侧的 TTFT 比 decode 还大(luno-5302) PP0 自己的 prefill 一完成就回复客户端。共识只控制 KV 页和请求槽位的释放。decode 端靠自己收到的传输通知判断 KV 是否成功,不依赖这个响应

旁路共识和 eager bank 一样是两端协议,但处理更严格:disaggregation/common/conn.py 的 _check_uniform_sideband_flag 在启动时广播 PP0 的值,任何 rank 不一致就直接报错退出。原因是共识环上的消息不带 tag,只按发送顺序配对;一端少发一条,后面所有消息都会错位,而且不会有任何异常。

开启 EARLY_STREAM_OUTPUT 后,prefill 侧上报的 TTFT 指标不再按”KV 传完”计时(disaggregation/prefill.py),看监控时要注意口径变了。

为这条路径服务的两个容量参数:SGLANG_DISAGGREGATION_QUEUE_SIZE=50 和 SGLANG_DISAGGREGATION_THREAD_POOL_SIZE=50。默认是 4 个队列,线程数按 CPU 数算,最多 12 个。每个队列独占一个工作线程,按 room 分片要有足够多的队列才能真正并行。SGLANG_MOONCAKE_SEND_AUX_TCP=1 让 aux 这种几十字节的元数据走 TCP,不占 RDMA 传输。

SGLANG_DISAGGREGATION_DECODE_LAZY_ALLOC=true 让 decode 先认领请求,等 prefill 报告算完再分配 KV,SGLANG_DISAGGREGATION_LAZY_INDICES_TIMEOUT=60 是 prefill 等 decode 回传 KV 地址的超时。这个开关 prefill 和 decode 必须设成一样:prefill 会通过 /route 公布自己的值,decode 在 bootstrap 时发现不一致就拒绝请求。

不影响速度、但要设对的开关

这一组只作用于启动、预热或运维,不改变稳态的 prefill 吞吐和 TTFT:

当前脚本里没生效的设置

设置 为什么没生效 怎么处理
SGLANG_DYNAMIC_CHUNKING_COST_MODEL=1 只在 profile_and_init_predictor 里读取,而这个函数只有开了 --enable-dynamic-chunking 才会调用(managers/scheduler.py 的 init_chunked_prefill,条件是 enable_dynamic_chunking and pp_size > 1)。脚本的 PARALLEL_ARGS 里没有这个参数 想要它就在 PARALLEL_ARGS 里加 --enable-dynamic-chunking。最初的 PP16 脚本(luno-4974)是带着这个参数测出上面那组吞吐数字的
CACHE_OPT 第一次赋值 数组被定义两次,第二次的 --disable-radix-cache 覆盖了 --enable-asradix-cache 如果本意是关 cache,删掉第一段,避免误读
AS_* 环境变量 只有 asradix cache 会读,cache 已关 随 asradix 一起生效
DEEPEP_CONFIG_JSON aiak_sglang 和已安装的 deep_ep 包里都没有代码读这个变量;而且 TP1 加 --moe-runner-backend flashinfer_mxfp4 的 prefill 不走 DeepEP。DeepEP 的配置入口是命令行参数 --deepep-config 可以删掉

如果打开 dynamic chunking,cost model 的作用是:按每个 stage 的 FLOPs 给 chunk 定价,cost_s(L, x) = k_g·x + k_e·(L + x) + k_a·x·(L + 0.5x),其中 L 是已缓存的前缀长度、x 是本次新算的 token 数(managers/chunk_cost_model.py)。预算等于”前缀为 0 时一个 --chunked-prefill-size chunk 的代价”,所以前缀越长、chunk 越小,每个微批的耗时保持接近。系数直接从加载好的权重数出来,16 个 stage 各算一份再取最大值,因为短前缀时 GEMM 多的 stage 是瓶颈,长前缀时 MLA 多的 stage 是瓶颈。在当前关掉 cache 的配置下 L 来自 chunked prefill 自己的前几块,这个功能仍然有意义:同一个长请求的后几个 chunk 会自动变小。

小结

PP16 prefill 的开关可以按一个问题来读:它缩短的是周期,还是确认次数。层切分、跨机边上的 eager bank 和 stage 内的 kernel 融合缩短微批周期,决定吞吐;旁路共识、早发 last chunk、按目标并行发送和提前回复客户端各去掉一次”沿 16 个 stage 绕一圈”的等待,决定 TTFT。两类开关里有几个是跨 pod 的两端协议(eager bank、旁路共识、lazy alloc),两台机器必须设成一样;FlashKDA 的长度上限必须跟着 chunk 大小走。dynamic chunking 的 cost model 在当前脚本里没有生效,补上 --enable-dynamic-chunking 才会起作用。