[Profiling] Explain Kimi-K3 Deep PP scaling

This commit is contained in:
Zhiyi Hong 2026-08-21 18:05:42 +08:00
parent 0433fcc3ee
commit a58b931cc8
2 changed files with 108 additions and 16 deletions

View File

@ -263,22 +263,84 @@ A2A 后启动或 Prefill OOM 的情况。
## 9. 通信结果如何解释 ## 9. 通信结果如何解释
本轮 PP 搜索没有为 PP2/4/8/16 再录制 Nsight也没有在每个 benchmark 前后采集 ### 9.1 累计收益变大,但每次增加 PP 的边际收益在下降
独立 HCA Counter因此不能把 PP1 的 57.27% NCCL 占比直接写成 PP8 的实测
通信占比。
能够被当前证据直接证明的是: 第 7 节相对 PP1 的累计数字会让人感觉“PP 越大,收益增长越快”。逐级比较后,
实际规律相反。以 C=8 为例:
1. PP1 的同口径 Nsight 确认 TP32 Prefill 以暴露的 AllReduce 为主; | 变化 | Input TPS 边际变化 | TTFT p50 边际变化 |
2. PP2/4/8/16 的 `/get_server_info` 证明实际 PP/TP/EP 与计划一致; |---|---:|---:|
3. Rank 布局使 PP4 的 TP8、PP8 的 TP4/EP4 都限制在单节点内; | PP1 -> PP2 | +53.96% | -35.65% |
4. 所有 PP 配置的 NCCL P2P communicator 成功建立并完成全部请求; | PP2 -> PP4 | +42.65% | -31.48% |
5. Input TPS 按 PP1 < PP2 < PP4 < PP8 提升随后 PP16 回落表明通信域 | PP4 -> PP8 | +23.12% | -22.05% |
缩小确有收益,但 Deep PP 开销在 PP8 之后成为更大的限制。 | PP8 -> PP16 | -23.93% | +33.18% |
因此当前结果支持通信域重构这一解释但没有伪造一个未经采样的“PP8 NCCL 所以 PP1 到 PP8 的累计收益持续扩大,但每次把 PP 翻倍带来的新增收益已经逐步
占比”。如需进一步拆解 PP8 内部剩余时间,应单独执行短窗口 Nsight而不是 减小PP16 则越过最优点。C=16 也呈现相同趋势。
把 Profile 开销混进这组性能基准。
### 9.2 节点内 PCIe 与节点间 RoCE 的实测
在 601--604 上使用 NCCL 2.28.9 + CUDA 13 的 `all_reduce_perf`,分别测量与
PP Stage 对应的 TP4、TP8、TP16 和 TP32 通信域。所有测试均为 0 个错误值:
| 通信域 | 实际拓扑 | 128 MiB 延迟 | 128 MiB busbw | 128/256 MiB 平均 busbw |
|---|---|---:|---:|---:|
| TP4 | 单节点 GPU0--3同一 PCIe/NUMA 岛 | 4.93 ms | 40.85 GB/s | 41.00 GB/s |
| TP8 | 单节点 8 卡,跨两个 NUMA 岛 | 6.00 ms | 39.13 GB/s | 38.89 GB/s |
| TP16 | 两节点 | 6.24 ms | 40.31 GB/s | 40.60 GB/s |
| TP32 | 四节点 | 6.59 ms | 39.47 GB/s | 40.08 GB/s |
大消息带宽都在约 39--41 GB/s说明 PP 收益不是因为节点间 400G RoCE 的
持续带宽远低于节点内 PCIe。真正变化更明显的是 collective 的参与 Rank 数、
固定延迟和同步范围128 MiB AllReduce 从 TP32 的 6.59 ms 降到 TP4 的
4.93 ms。
两个拓扑边界尤其重要:
- PP2 -> PP4 把每个 Stage 的 TP 域从两节点 TP16 缩到单节点 TP8逐层
AllReduce 不再跨节点;
- PP4 -> PP8 把 TP8 缩到 TP4每个 Stage 可落在同一个 PCIe/NUMA 岛,避免
单节点内跨 CPU Root Complex同时每卡得到更大的 GEMM 分片,减轻 TP32
过度切分造成的小矩阵低效率。
### 9.3 PP1 与 PP8 的 Nsight 对照
为避免把 Profiler 开销混入第 7 节性能结果,另起同场景短窗口 Nsight
`16K -> 1, C=8, chunk=8K`。以下是稳定 Rank 的中位数:
| 每个活跃 Rank/Stage 的指标 | PP1 / TP32 | PP8 / TP4 | 变化 |
|---|---:|---:|---:|
| 普通 AllReduce 调用数 | 557 | 66 | -88.2% |
| 普通 AllReduce 累计时间 | 4,160.6 ms | 392.3 ms | -90.6% |
| 单次 AllReduce p50 | 7.10 ms | 5.83 ms | -17.9% |
| 计算与 NCCL 时间线重叠 | 约 0 | 约 0 | 未形成有效重叠 |
这里的 557 -> 66 是**单个 Rank/Stage**的 Profile 窗口,不是一个请求在整个
模型上的 collective 总数。PP8 把模型层分给 8 个 Stage一个请求仍要依次经过
全部 Stage它减少的是每个活跃 Rank 在一个 Stage 内负责的层数和同步工作,
并让不同请求的 Stage 可以流水并行,而不是凭空删除模型全部通信。
PP8 Trace 还出现约 3.43 s 的非 AllReduce NCCL 区间,主要对应 Pipeline
Send/Recv 及 Stage 等待。该值包含异步 Stage 等待,不能直接当作 3.43 s 的
网络传输时间,也不能把 PP8 的 NCCL wall 百分比与 PP1 做等口径比较。可靠的
对照是上表中的调用数、普通 AllReduce 累计时间和单次延迟。
### 9.4 为什么 PP8 最优PP16 反而退化
PP1 -> PP8 的收益由四项共同产生:
1. TP 通信域由 32 Rank 缩到 4 Rank单次 collective 延迟降低;
2. PP4/PP8 的 TP collective 保持在节点内PP8 进一步保持在同一 PCIe/NUMA
岛;
3. 每个 Rank 只执行所在 Stage 的层C=8/16 的不同请求能在多个 Stage 并行,
将通信和计算流水化;
4. TP32 过度切分的 GEMM 在 TP4 下变成更大的本地矩阵Kernel 效率更高。
收益没有继续延伸到 PP16C=8 时请求数甚至少于 16 个 Stage流水线无法填满
C=16 也只有一波请求。更多 Stage 边界、P2P Send/Recv、调度和 bubble 超过了
TP4 -> TP2 的剩余收益,而且 PP16 还因整除约束从 EP4 改成 EP2。因此当前证据
支持的是“PP8 在这个 Prefill 压力点达到通信、计算粒度与流水线开销的平衡”,
而不是“PP 越大越快”。
## 10. 最终判断与后续使用 ## 10. 最终判断与后续使用
@ -371,3 +433,22 @@ PP1 Nsight 通信归因:
kimi3_pro6000_sglang_prefill_communication_profile/ kimi3_pro6000_sglang_prefill_communication_profile/
results/kimi3-prefill-comm-20260820-143749/nsys_analysis.json results/kimi3-prefill-comm-20260820-143749/nsys_analysis.json
``` ```
PP8 Nsight 通信归因与四组 NCCL 微基准:
```text
/data/hzy/sskj/experiments/pro6000/
kimi3_pro6000_sglang_prefill_communication_profile/
results/kimi3-prefill-comm-pp8-20260821-172717/
nsys_analysis.json # PP8 各 Rank CUDA/NCCL 汇总
nsys_device_metrics.csv # PP8 各设备时间线指标
nsys/node0/prefill_node0.nsys-rep # 601 原始 Nsight Trace
nsys/node1/prefill_node1.nsys-rep # 602 原始 Nsight Trace
nsys/node2/prefill_node2.nsys-rep # 603 原始 Nsight Trace
nsys/node3/prefill_node3.nsys-rep # 604 原始 Nsight Trace
nccl_tp4_intra_node.log # 单节点同 PCIe/NUMA 岛
nccl_tp8_intra_node.log # 单节点跨 NUMA
nccl_tp16_2node.log # 两节点
nccl_tp32_4node.log # 四节点
```

View File

@ -17,6 +17,9 @@ DIST_PORT="${DIST_PORT:-20000}"
PROFILE_STEPS="${PROFILE_STEPS:-3}" PROFILE_STEPS="${PROFILE_STEPS:-3}"
CONCURRENCY="${CONCURRENCY:-8}" CONCURRENCY="${CONCURRENCY:-8}"
NUM_PROMPTS="${NUM_PROMPTS:-40}" NUM_PROMPTS="${NUM_PROMPTS:-40}"
PP_SIZE="${PP_SIZE:-1}"
TP_SIZE="${TP_SIZE:-32}"
EP_SIZE="${EP_SIZE:-4}"
CONTAINER_PREFIX="kimi3_prefill_comm_profile" CONTAINER_PREFIX="kimi3_prefill_comm_profile"
SSH_OPTS=(-o BatchMode=yes -o StrictHostKeyChecking=no -o ConnectTimeout=10) SSH_OPTS=(-o BatchMode=yes -o StrictHostKeyChecking=no -o ConnectTimeout=10)
@ -89,7 +92,7 @@ start_node() {
host="${NODE_HOSTS[$rank]}" host="${NODE_HOSTS[$rank]}"
name="$(container_name "$rank")" name="$(container_name "$rank")"
output="${RESULT_ROOT}/nsys/node${rank}/prefill_node${rank}" output="${RESULT_ROOT}/nsys/node${rank}/prefill_node${rank}"
launch="export SGLANG_HOST_IP=174.1.60.$((rank + 1)); exec nsys profile --trace=cuda,nvtx,nccl --nccl-trace=api,group,gpu,coll,kernel-launch --sample=none --cpuctxsw=none --capture-range=cudaProfilerApi --capture-range-end=stop --cuda-graph-trace=node --force-overwrite=true --stats=true --output ${output} python3 -m sglang.launch_server --model-path ${MODEL_PATH} --served-model-name kimi-k3 --tp-size 32 --ep-size 4 --nnodes 4 --node-rank ${rank} --dist-init-addr ${HEAD_HOST}:${DIST_PORT} --trust-remote-code --moe-runner-backend flashinfer_mxfp4 --chunked-prefill-size 8192 --mem-fraction-static 0.88 --cuda-graph-max-bs-decode 16 --mamba-radix-cache-strategy extra_buffer_lazy --disable-radix-cache --dist-timeout 3600 --mamba-full-memory-ratio 0.36 --host 0.0.0.0 --port ${PORT}" launch="export SGLANG_HOST_IP=174.1.60.$((rank + 1)); exec nsys profile --trace=cuda,nvtx,nccl --nccl-trace=api,group,gpu,coll,kernel-launch --sample=none --cpuctxsw=none --capture-range=cudaProfilerApi --capture-range-end=stop --cuda-graph-trace=node --force-overwrite=true --stats=true --output ${output} python3 -m sglang.launch_server --model-path ${MODEL_PATH} --served-model-name kimi-k3 --tp-size ${TP_SIZE} --pp-size ${PP_SIZE} --ep-size ${EP_SIZE} --nnodes 4 --node-rank ${rank} --dist-init-addr ${HEAD_HOST}:${DIST_PORT} --trust-remote-code --moe-runner-backend flashinfer_mxfp4 --chunked-prefill-size 8192 --mem-fraction-static 0.88 --cuda-graph-max-bs-decode 16 --mamba-radix-cache-strategy extra_buffer_lazy --disable-radix-cache --dist-timeout 3600 --mamba-full-memory-ratio 0.36 --host 0.0.0.0 --port ${PORT}"
local -a command=( local -a command=(
docker run -d --name "$name" docker run -d --name "$name"
@ -191,7 +194,7 @@ write_manifest() {
printf 'run_id=%s\n' "$RUN_ID" printf 'run_id=%s\n' "$RUN_ID"
printf 'image=%s\n' "$IMAGE" printf 'image=%s\n' "$IMAGE"
printf 'model=%s\n' "$MODEL_PATH" printf 'model=%s\n' "$MODEL_PATH"
printf 'tp=32\nep=4\ndp=1\n' printf 'pp=%s\ntp=%s\nep=%s\ndp=1\n' "$PP_SIZE" "$TP_SIZE" "$EP_SIZE"
printf 'moe_runner_backend=flashinfer_mxfp4\nmoe_a2a_backend=none\n' printf 'moe_runner_backend=flashinfer_mxfp4\nmoe_a2a_backend=none\n'
printf 'input_len=16384\noutput_len=1\nconcurrency=%s\n' "$CONCURRENCY" printf 'input_len=16384\noutput_len=1\nconcurrency=%s\n' "$CONCURRENCY"
printf 'chunked_prefill_size=8192\nprofile_steps=%s\n' "$PROFILE_STEPS" printf 'chunked_prefill_size=8192\nprofile_steps=%s\n' "$PROFILE_STEPS"
@ -200,11 +203,19 @@ write_manifest() {
run_all() { run_all() {
require_password require_password
(( PP_SIZE * TP_SIZE == 32 )) || {
echo "ERROR: PP_SIZE * TP_SIZE must equal 32" >&2
return 2
}
(( TP_SIZE % EP_SIZE == 0 )) || {
echo "ERROR: TP_SIZE must be divisible by EP_SIZE" >&2
return 2
}
prepare_nodes prepare_nodes
write_manifest write_manifest
stop_service stop_service
collect_gpu before collect_gpu before
log "starting current TP32/EP4/FlashInfer service under Nsight" log "starting PP${PP_SIZE}/TP${TP_SIZE}/EP${EP_SIZE}/FlashInfer service under Nsight"
start_node 1 start_node 1
start_node 2 start_node 2
start_node 3 start_node 3