本文梳理 Kimi K3 在 B200 上 PD 分离部署的优化思路:先说清楚这个模型难在哪里,再看 prefill(PP16)和两套 decode(DP16、DP2 + DCP8)分别怎么拆、为什么这么拆,最后落到启动脚本里每组开关在 SGLang 代码中的位置。

问题:K3 在 B200 上难在哪里

K3 的结构细节见 kimi k3 weight,这里只摘和部署相关的几个事实:

再叠加 prefill 和 decode 截然不同的瓶颈:prefill 是大矩阵乘,算力密集;decode 每步只有几个 token,但要读完整的专家、KV 和 state,访存密集。同一种并行方式很难两边都好,所以 P 和 D 各用一套。

总体设计:P 走流水线,D 走数据并行

Kimi K3 B200 部署形态

两份权重都用 /Kimi-K3-ATTN-MOE-DENSE-FP8:路由专家保持 MXFP4,attention 投影、shared expert、latent 上下投影、layer 0 的 dense MLP 转成 block-FP8(128×128)。SGLANG_K3_ATTN_MOE_FP8=1 是加载这份混合 checkpoint 的总开关(layers/quantization/modelopt_quant.py 里的 _is_k3_attn_fp8_target / _is_k3_moe_fp8_target),非专家权重的显存和带宽因此减半。

优化时间线

按 aiak_sglang 的提交记录,K3 的优化大致分四波:

时间 主题 代表性提交
7 月下旬 跑起来:K3 模型接入、DSPARK、DCP 支持 Kimi Linear、DSpark 接 DeepEP [Kimi] Support kimi-k3、luno-4671
8 月上中旬 PP + PD + DSPARK 打通;decode 显存优化;MoE weight sharding;FlashKDA luno-4774、luno-4793、luno-4801、luno-4777
8 月 27 日 集中一波性能:K3 weight shard(含 draft)、ATTN/MoE FP8、prefill / decode kernel 融合、PP PD 早发 last chunk / 目标并行传输 / sideband 共识、eager bank、vocab TP group luno-4912、4914、4957、4958、4974~4978、5003
9 月 ~ 10 月 打磨:DSPARK SWA 池(DP / PP16)、block-FP8 q-proj 的 DCP、DCP 兼容 DP16、masked GEMM block M、lazy alloc、CuTe FlashKDA prefill luno-5101、5242、5373、5408、5293、5416

下面把后三波里的重点逐个说清楚:每项先说原来卡在哪,再说怎么改、换来了什么。数字注明出处的来自提交记录或代码注释,其余是按 config 和 safetensors 头算出的估算值。更细的实现放在后面 P / D 的章节里。

8 月上中旬:先把链路打通,把 decode 显存算准

8 月 27 日:一波集中的性能优化

下面这张图把 decode A(DP16)单卡权重显存按改动顺序叠起来,后面几项的显存账都可以对着它看。

Decode A 单卡权重显存
PP16 prefill 的四处等待

9 月 ~ 10 月:打磨显存、兼容性和小 batch 效率

下面按 P / D 拆开讲。

Prefill:PP16 的切分与流水

层怎么切:SGLANG_PP_LAYER_PARTITION

默认均匀切会让 stage 负载失衡:MLA 层的注意力代价随前缀长度增长,KDA 不会;attn-res bank 只在每个 block 的首层写入。所以脚本手工指定 7,5,7,5,…,7,5,6,3(distributed/utils.py 的 get_pp_indices 读取,校验长度 = pp_size、和 = 93)。

PP16 层切分

切分规律:

stage 间通信:把 bank 提前发出去

PP 线上传的是 hidden_states 加 attn-res bank [T, NB, H],后者是大头。SGLANG_PP_EAGER_BANK_FORWARD=1 把它拆成两条消息(managers/scheduler_pp_mixin.py 的 _pp_eager_bank_forward):

接收端在侧流上提前一个迭代预取 bank。SGLANG_PP_EAGER_BANK_RANKS="7" 只在 s7 上开:s7 不写 bank 行,又恰好是 s7→s8 这条唯一的跨机边。提交 luno-4974 记录的效果是 boundary stall 33 ms → 11 ms,吞吐 +3.7%。

--pp-async-batch-depth 2 让微批槽位变成 pp_size + 2,上一个微批的输出回传和 CPU 侧预处理在当前微批 launch 之前提交,把 last stage → PP0 的回程和调度开销藏到 GPU 计算后面。

PD 衔接:别让 PP 再多转一圈

普通 PD prefill 里,一个请求的结束要靠 PP 环上的共识推进,16 个 stage 意味着每次共识都要绕一圈。几个开关逐个砍掉这些圈:

开关 做法 解决的问题
SGLANG_PP_PD_PREFILL_EARLY_LAST_CHUNK 非 PP0 的 stage 在下一轮 CPU 阶段直接发自己最后一个 chunk 的 KV/state;只有 PP0 发 aux(首 token) 省掉 last chunk 的一整圈 PP 延迟
SGLANG_PP_PD_PREFILL_SIDEBAND_BOOTSTRAP_CONSENSUS 各 stage 通过 ZMQ 旁路向 PP0 报 ready,16 个都到齐才放行 取代固定等 pp_size 轮的入队共识
SGLANG_PP_PD_PREFILL_EARLY_STREAM_OUTPUT PP0 自己的 prefill 一完成就回客户端,释放共识只管 KV/slot 回收 第二圈共识逆流水方向,曾让 P 的 TTFT 比 D 还大(luno-5302)
SGLANG_PP_PD_KV_TRANSFER_TARGET_PARALLEL 一个 room 的多目标发送拆成每个 decode 目标一条,按 hash(bootstrap_room) 分到 worker 旧 key 是目标端口之和,decode 集群固定时所有 room 落在同一个线程上串行

其余 prefill 开关

两个当前不生效的设置:SGLANG_DYNAMIC_CHUNKING_COST_MODEL=1 只在 --enable-dynamic-chunking 打开时才被读取,脚本没有开;CACHE_OPT 被定义了两次,第二次的 --disable-radix-cache 覆盖了 asradix cache。

Decode 的共同底座:EP16 + DeepEP low-latency

两套 decode 的专家侧完全一样:--ep-size 16 --moe-a2a-backend deepep --deepep-mode low_latency --moe-runner-backend deep_gemm,每卡持有 1/16 的路由专家(约 90 GB MXFP4)。decode 每步 token 很少,平均到 896 个专家上每个专家只有个位数 token,几个开关都围绕这一点:

Decode A:DP16,用 batch 换吞吐

DP16 下 attn_tp = 1,每张卡就是一个完整的 attention 副本:一个请求的 KDA state、MLA KV 全在本卡,16 个 rank 各跑 24 个请求。这让 attention 侧没有任何 TP 通信,代价是所有非专家权重在每张卡上都是一份完整副本:KDA/MLA 投影、shared expert、latent 上下投影加起来 FP8 下约 54 GB,再加 BF16 的 embedding 与 lm_head 各 2.35 GB。这部分是 DP16 优化的主线。

两种 decode 布局下一张卡上装着什么

重复权重:weight shard

SGLANG_ENABLE_K3_WEIGHT_SHARD=1(sharding.py 的 WeightShardingConfig,models/kimi_k3.py 的 KimiK3DecoderLayer._setup_weight_shard,luno-4912):本来每卡都一样的权重,改成机内 8 卡各存 1/8 行,forward 时用一次 flat AllGather 拼回完整权重。思路见 Weight Sharding,K3 上的几个具体选择:

embedding / lm_head:按词表切,且不出机

去掉每步的同步:静态 DP 形状

DP attention 每步都要 all-gather 各 rank 的 token 数、forward mode 等信息,才能决定 padding 和 CUDA graph。SGLANG_SCHEDULER_SKIP_ALL_GATHER=1 跳过这次阻塞集合通信(luno-4279),SGLANG_DP_STATIC_SYNC_BOUND=24 让每个 rank 直接假设 global_num_tokens = [24] * 16(managers/scheduler_components/dp_attn.py 的 _fill_static_sync_values)。24 就是 max_running_requests / dp = 384 / 16。所有 rank 都按 MAX_LEN 对齐,DP gather、词表切分的 embed / lm_head、DSPARK 的行容量都得到同一个固定布局,不需要任何通信;本地请求数超过 24 时 scheduler 直接报错。

显存的零碎账

开关 作用
SGLANG_K3_ONLY_LANGUAGE=1 不构建 vision tower 和 projector
SGLANG_K3_MLA_CELL_SIZE=13312 覆盖 token 池求解用的每 token 字节数,公式值为 576 × 24 = 13824;为什么取 13312 未查到说明
SGLANG_K3_DSPARK_MHA_CELL_SIZE=10240 draft 每 token KV 字节数:5 层 × K/V × 16 kv head × 64 dim × 1 B
SGLANG_LAZY_ATTN_WORKSPACE=1 attention workspace 推迟到第一次 kernel 调用再分配;tokenspeed_mla 自带 workspace,不需要 trtllm / FlashInfer 那份
SGLANG_FLASHINFER_WORKSPACE_SIZE=134217728 把仍会用到的 FlashInfer workspace 压到 128 MB
SGLANG_SKIP_DSPARK_VERIFY_TREE_MASK=1 DSPARK verify 是线性链,不分配 max_tokens × max_ctx 的树 mask(1M 上下文时超过 1 GiB)
SGLANG_ENABLE_STATIC_AUX_HIDDEN_CAT_BUFFER=1 各 CUDA graph 的 aux hidden 拼接共用一个静态 buffer,每个 graph 省约 40 MB
SGLANG_BLOCK_METRICS_K3=1 token_usage 不再取 KDA slot 占用的最大值,避免 KDA 主导限流

注意力 kernel 用 --attention-backend tokenspeed_mla:TRTLLMMLABackend 的子类,在 Blackwell 上跑 TokenSpeed 的 CuTe DSL MLA kernel,要求 fp8_e4m3 KV、page size 32/64,原生支持 DCP 的局部长度和 LSE 输出。SGLANG_OPT_USE_FUSED_MLA_PREPARE_FP8 把 q/k 的 concat 和三次 fp8 cast 合成一次 Triton。

Decode B:DP2 × DCP8,用通信换单请求带宽

DP16 的上限在于一个请求只能用一张卡:每步要读 0.43 GB 的 KDA state 和全部上下文的 MLA KV,单卡 HBM 带宽决定了单请求的步时间,单卡容量决定了能放多长的上下文。DP2 把一台机器的 8 张卡变成一个 attention 组(attn_tp = 16 / 2 = 8),一个请求摊到 8 张卡上。

KDA 按 head 切,MLA 按 token 切

两种层的切法不同,原因在于它们的状态长什么样:

distributed/parallel_state.py 里 DCP 组和 attention TP 组都是每个 TP 组内连续的 8 个 rank,dp=2 时两者恰好重合,也正好是一台机器,DCP 的通信全在 NVLink 内。

Q 复制,只交换 (O, LSE)

_kimi_k3_overrides(arg_groups/overrides.py)给 K3 强制 dcp_replicate_q_proj=True,通信后端在 MNNVL 设备上选 fi_a2a,否则选 a2a:

  1. 权重准备:capture 前 ModelRunner._prepare_replicated_q_proj 把各卡的 q_b_proj 和 w_kc head 分片 all-gather 成全 head 版本,之后每卡本地就能算出全部 96 个 head 的 Q,不需要 all-gather Q。block-FP8 的 q_proj 由 luno-5242 支持(解包 UE8M0 scale → gather → 重新打包)。
  2. 局部 attention:tokenspeed_mla 用本卡的局部序列长度算全部 head 对 1/8 token 的 attention,输出 (O_r, LSE_r)。
  3. 合并:dcp_a2a_lse_reduce(layers/dcp/comm.py)把 [B, H, D] 按 head 切成 8 份,fp32 LSE 附在后面,一次 all_to_all_single 后每卡拿到自己那 H/8 个 head 的 8 份局部结果,用 Triton 按 LSE 合并。结果回到 head 切分的 TP 布局,直接接 w_vc / o_proj。

复制 Q 的代价是每卡多存一份全 head 的 q_b / w_kc。SGLANG_K3_DEL_MLA_KVB_DCP=1 把它补回来:w_kc / w_vc 从 kv_b_proj 导出后释放 kv_b_proj.weight;在 replicate-Q 路径且 q_proj 为 16 bit 时,再删掉已被全 head 版本取代的本地 w_kc 和 q_b_proj(block-FP8 时保留,extend 还要用)。SGLANG_OPT_USE_FUSED_DCP_A2A_PACK=1 把 A2A 前的四次拷贝合成一个 Triton pack kernel,减少每个 MLA 层的 launch 数。

MoE 前后:SP-MoE

attention 输出是 8 卡共享 32 个请求的 TP 布局。如果直接进 MoE,8 张卡会把同样的行 dispatch 8 次。kimi_k3.py 的 SP-MoE 路径(attn_tp > 1 且用 DeepEP 时开启)把 o_proj 原本的组内 all-reduce 拆成 reduce-scatter + all-gather:中间整个 MoE 区域(norm、gate、latent 投影、TP1 的 shared expert、EP dispatch)每卡只处理 1/8 的行,MoE 结束后再 all-gather 回来。RS + AG 的字节数与原来的 all-reduce 相同,却去掉了 8 倍的重复 dispatch 和 MoE 前段计算。这也是 DP2 脚本里 dispatch buffer 只要 48 行的原因。

DCP 下的一致性

DCP 组内 8 张卡共同服务同一批请求,投机解码的每个随机决策都必须一致,否则各卡的 KV 和 state 会分叉。luno-5101 让组内用同一个 seed 的 RNG 采样,并从 DCP rank 0 广播 accept 结果;luno-5373 把 draft 的 eager resample 限制在 DCP 场景,因为在 DP16 下各 DP rank 的这个标志不同步,会让词表集合通信错位挂死。--enable-dp-lm-head 让 lm_head 只在 attention TP 组内切,DSPARK 在 DP attention 下需要它。

A 与 B 怎么选

A · DP16 B · DP2 × DCP8
总并发 384(24 / 卡) 64(32 / 机)
每请求每卡读的 KDA state 0.43 GB 54 MB
每 token 每卡 MLA KV 13.5 KB 1.7 KB
attention 权重 完整副本,靠 weight shard 回收 天然 1/8
额外通信 每层权重 AllGather(可预取) 每 MLA 层一次机内 A2A;每层 o_proj 的 RS / AG
gamma 5 4
适合 吞吐、中短上下文 单请求延迟、长上下文

两者不是谁替代谁:A 在并发足够时把 HBM 带宽摊给更多请求,B 在并发上不去或上下文很长时把一个请求的访存摊给 8 张卡。

表中 B 的并发是早期配置。当前 start_k3_decode_dp2.sh 用的是每 DP 192(总并发 384,dispatch 128)。在同样 384 并发下两者的逐项计算与显存对比见 K3 Decode:DP16 vs DP2×DCP8。

DSPARK 与 KDA state:投机解码在两侧的分工

DSPARK 在 PD 两侧各做什么

DSPARK 的 draft 是 5 层 dense 模型,全部是窗口 2048 的 sliding attention(64 q head / 16 kv head,head dim 64),输入是 target 第 7/23/51/67/83 层 hidden 的投影。它一次前向并行给出 gamma 个 token(--speculative-dspark-block-size,checkpoint 默认 7,P 和 D-B 用 4,D-A 用 5),topk 固定为 1,所以 target 验证的是一条 gamma+1 长的线性链。

Prefill 侧不出 draft,只准备 draft 的 context KV(dspark_worker_v2.py 的 _forward_prefill):持有 aux 层的 stage 就地投影,把结果累加进 dspark_ctx_acc 随 PP proxy 传下去,原始 aux hidden 不上线(luno-4885);最后一个 stage 把它写成 draft KV,放在与 target 相同的 out_cache_loc 上,Mooncake 随 target 的 page 一起发出。非最后 stage 只留 1 页 draft pool,不需要 lm_head。

Decode 侧的三个关键选择:

PD 传输:PP16 的 KV 怎么到 DCP8 / DP16

P 侧是 TP1、按层切;D 侧要么 TP1 但请求分散在 16 个 DP rank(A),要么 attention TP8 且 MLA 按 token 交错(B)。Mooncake 这边的处理(disaggregation/common/conn.py、disaggregation/mooncake/conn.py):

小结

K3 的部署优化可以归结为一句话:按瓶颈选并行维度,再把每个维度带来的冗余一项项收回来。prefill 算力密集,用 PP 把 93 层摊开换取零层内通信,然后按 MLA 与 attn-res block 的位置手工切层、提前转发 bank、砍掉 PD 共识的多余圈数。decode 访存密集,用 EP16 摊开专家,再在 attention 侧二选一:DP16 拼 batch,代价是非专家权重和词表的副本,用机内 weight shard 和词表切分收回;DP2 × DCP8 拼单请求带宽,KDA 按 head 切、MLA 按 token 切,代价是 Q 复制和每层的 A2A,用删除冗余权重和 SP-MoE 收回。投机解码这一层同理:draft KV 按窗口而不是按上下文分配,KDA 的验证按输入重放而不是按 state 快照,让 DSPARK 的收益不被显存吃掉。