有关对讲机在车内发射时信号衰减的理论探讨

自驾时到底用手台好还是车台好?

这是一个在业余无线电圈子和越野车圈子经常被讨论的问题,普遍的理解是车台比手台信号好,但是手台比车台方便。但还有一种使用方式是给手台接上天线,那到底这几种方式信号强度差多少呢?

没看到有人详细讨论过,这里我根据一些参考资料做一下理论探讨。

信号频率在讨论信号衰减时,是一个关键信息。虽然国内大部分对讲机提供了 136MHz ~ 174MHz 的 VHF 段和 400MHz ~ 480MHz 的 UHF 段,但实际上除了在水面上,大部分人日常使用的大概率是 UHF 段。

为了防杠,假设大家都是合规发射,例如使用 409~410 MHz 的公众对讲频道 。

汽车入口损耗

金属外壳的汽车,相当于一个法拉第笼。那 UHF 无线电信号穿透汽车时,会衰减多少呢?我找到了一个国际电信联盟的文档作为参考。

国际电联无线电通信部门 2015 年 2 月的 ITU-R BT.1368-12 建议书,《VHF/UHF频段内地面数字电视业务的规划准则(包括保护比)》文档[1, 2],附件 5 第 2.3 节(第 96 页 )中给出:

对于在汽车内以手持装置进行接收,应当考虑汽车车体引入的损耗。根据蜂窝无线电试验,UHF频段、IV/V波段内典型的汽车入口损耗为6 dB。

3 dB 损耗相当于信号衰减 50%,6 dB 损耗相当于信号衰减 75%,可以看到信号衰减相当之大了。但是车载无线电台就没有信号衰减吗?

馈线损耗

目前大部分车台使用国标 50-3(接近美标 RG58) 同轴电缆作为馈线,对于这种同轴电缆的电气特性,有以下资料:

  1. 根据《GB/T 14864-2013 实心聚乙烯绝缘柔软射频电缆》国家标准 [3],50-3 同轴电缆在 20 度室温 400MHz 频率下的衰减常数是 0.558 dB/m。
  2. 根据 w4rp 网站[4],RG-58 同轴电缆在 450MHz 频率下的衰减常数是 34.8 dB/100m。
  3. 根据 B 站 Up 主“成都M哥”实测 [5],5 米长 50-3 同轴电缆(含接头)在 430MHz 频率下的衰减是 41.94%,相当于 0.472dB/m。

针对馈线的 UHF 接头(通常称 M-公头/M-母头,美标 PL-259/SO-239),也有一些讨论,有人实测 [6] UHF 公母接头在 433MHz 下的损耗仅为 0.11dB。

假设车台使用的 50-3 同轴电缆符合国标,假设车台使用了 5 米馈线,加上两对 M 接头,馈线的理论损耗是 3.01 dB,相当于信号衰减 50%。这个衰减率与“成都M哥”实测 41.9% 数据差不太多;如果车台使用了 10 米馈线,那理论损耗就可能达到 5.8dB。

是不是很惊讶,馈线的损耗居然比汽车入口损耗差不了多少?别着急,故事还没有结束。

人体损耗

由于人体对天线的传播也有影响,信号会被人体吸收或者发生方向畸变,所以手持无线电台发射时,信号也是有衰减的。根据通用电气 1972 年的 Systems Application Manual “Two-way personal radio system design.”[7],450MHz ~ 470MHz 下,人体手持无线电台的衰减是:

  • 手持,天线垂直时,衰减 17dB
  • 手持,天线倾斜时,衰减 18dB
  • 挂在髋部,衰减 17dB

也就是,人体手持时信号衰减 98%。正好这文章里也测量了在车里手持无线电台的衰减:

  • 挂在驾驶员的髋部,衰减 25dB (25 - 17 =8 dB,约等于上面的汽车入口损耗)
  • 手持,天线垂直时,衰减 16dB
  • 使用放在车顶的单位增益天线,用 15 英尺(4.57米) RG58 馈线,衰减 4dB

结论

通过参考资料,我们可以得出结论:

手持无线电台最大的信号衰减来自于人体,对 UHF 段的信号衰减可达 98%;汽车对 UHF 段的信号衰减可达 75%;馈线带来的损耗和馈线长度有关,可能到 50% 以上。

如果车台使用 3~5 米馈线,在符合国标的情况下,信号衰减大约是 2~3 dB(37%~50%);如果手台使用馈线,信号衰减大约 4dB(60%),与车台大致相同;如果直接使用手台,信号衰减 17~25 dB(98%~99.6%),远大于使用天线。

所以,如果希望信号质量好,应该尽量使用车台或者手台+车外天线进行无线电发射。

[1] https://www.itu.int/dms_pubrec/itu-r/rec/bt/R-REC-BT.1368-12-201502-S!!PDF-C.pdf
[2] https://www.itu.int/dms_pubrec/itu-r/rec/bt/R-REC-BT.1368-12-201502-S!!PDF-E.pdf
[3] http://www.xianlan18.com/uploadfile/2016/0520/20160520040157273.pdf
[4] https://www.w4rp.com/ref/coax.html
[5] https://www.bilibili.com/video/BV1eJ4m1u7FC
[6] https://www.iz2uuf.net/wp/index.php/2016/01/15/pl-259-vs-n-round-2-hard-testing
[7] https://www.softwright.com/faq/engineering/Building%20and%20Body%20Loss%20Values.html

优化Qwen3 - FP8模型L40S卡性能提升18%

我在之前的博客中介绍了自己写的 SM89 上的 FP8 Blockwise GEMM CUDA kernel。后来看到 Qwen3 官方文档中的 H20 卡上的性能测试数据,本文仿照 Qwen3 文档的测试方法,对新 Kernel 进行更多 Qwen3 FP8 模型在 L40S 的对比性能测试。

总结放最前

对下面的速度指标可以有几个观察:

  1. 新 Kernel 平均提升性能 18%。依照 Qwen3 官方的测试用例,平均下来新 Kernel 对端到端推理的性能优化幅度是 18%。
  2. 速度优化的比例随着模型参数的增加先升后降。大概是因为最小模型的参数量太小,调度占比更高;而更大的模型参数量太大,访存占比升高。
  3. A3B 的模型优于 4B 的模型。但我在更多的测试后发现,A3B 仅在 batch_size 2 以下性能优于 4B,在超过 2 以后,A3B 的性能曲线增长远不如 4B 的性能曲线。我的测试在 L40S 上,4B 的最大吞吐大约是 A3B 的 1.7 倍,8B 的最大吞吐大约是 A3B 的 1.4 倍。这大概是因为 A3B 模型计算时需要更多的显存带宽。
  4. 新 Kernel 下 L40S 的平均吞吐是 H20 的 56% (triton:47%)。可以用作单卡运行 Qwen3 模型时,L40S 和 H20 的性价比大略预估。

下面是具体的测试环境和测试数据:

1. 测试环境

硬件使用的是 NVIDIA L40S,评测的每个模型都跑在 1 张 L40S 卡上。Docker 使用的是 SGLang 的开发镜像:lmsysorg/sglang:dev。

SGLang 和 SGL-Kernel 可以根据我的这篇博客中介绍的方法自行编译,或者从下面链接下载我编译好的 Python wheel 包: https://github.com/solrex/sglang/releases/tag/sm89-fp8 然后通过下面命令安装:

pip install sgl_kernel-0.1.5-cp39-abi3-linux_x86_64.whl
pip install sglang-0.4.6.post5-py3-none-any.whl

2. 测试设置

SGLang 的测试设置基本与 Qwen3 官方的测试设置相同,启动参数主要设置了这些。因为下面测试的 batch size 只有 1,所以 --cuda-graph-max-bs 只设置了 8,正常批量压测时最好设置到 64 以上。

python3 -m sglang.launch_server --model-path MODEL_PATH --tp 1 --allow-auto-truncate --enable-mixed-chunk --context-length 40960 --enable-torch-compile --torch-compile-max-bs 8 --cuda-graph-max-bs 8 --port ****

使用以上方式启动,使用的是默认的 FP8 Blockwise Triton Kernel;如果希望使用新的 CUDA 实现的 FP8 Blockwise Kernel,只需要在启动前增加环境变量:

export SGLANG_SUPPORT_CUTLASS_BLOCK_FP8=1

所以后面的测试,主要是比较 Triton 和 CUDA 实现的 FP8 Blockwise GEMM Kernel 的性能区别。

指标中加上“triton”前后缀的,即为 Triton Kernel 的指标;指标中加上“cuda”前后缀的,即为我新实现的 CUDA Kernel 的指标。

3. 准确率速测

首先测一下新 Kernel 的实现是否有严重的问题,这里使用 SGLang 代码库中的 benchmark/gsm8k 进行一下速测。速测有 512 token 截断,测试 500 条,仅用于粗略比较是否有 bug,不能充分说明模型的能力差距。

速测命令如下,通过 port 将请求发送给不同的模型:

python3 bench_sglang.py --num-questions 500 --port ****
模型triton-准确率cuda-准确率
Qwen3-0.6B-FP841.0%43.4%
Qwen3-1.7B-FP869.0%68.8%
Qwen3-4B-FP885.4%86.2%
Qwen3-8B-FP892.0%92.2%
Qwen3-14B-FP889.2%86.2%
Qwen3-32B-FP880.8%83.8%
Qwen3-30B-A3B-FP890.6%88.2%

可以看到我写的 CUDA Kernel 在准确率上与官方的 triton Kernel 没有明显的差距。

4. 性能测试结果

为了与 Qwen3 官方的性能指标进行对齐,我采用了完全相同的请求参数,即:batch_size 固定为 1,生成长度固定为 2048,输入长度使用 1、6144、14336、30720,速度指标使用 (输入+输出)/时间。

Qwen3 官方如此设置大概是为了照顾 Transformers 库,但这种测试方式并不常见。batch_size 为 1 很难测出来最大吞吐,30K 的输入长度在业务场景下也不算常见,最终指标受 prefill 影响很大。为了显示生成的性能,我增加了一列平均 ITL 指标。

指标列说明:

  • 输入:输入的长度,单位是 token;
  • H20:Qwen3 官方文档中 H20 的速度,单位是 tokens/s;
  • L40S-triton:L40S 上 SGLang 官方 triton FP8 blockwise GEMM 实现的速度,单位是 tokens/s;
  • L40S-cuda:L40S 上替换为我实现的 CUDA Kernel 实现的速度,单位是 tokens/s;
  • 速度提升的百分比:L40S-cuda / L40S-triton - 100%
  • ITL-triton:SGLang 官方 triton 实现下生成每个 token 的平均间隔,单位是毫秒 ms;
  • ITL-cuda:我的 CUDA 实现下生成每个 token 的平均间隔,单位是毫秒 ms;

4.1 Qwen3-0.6B-FP8 (SGLang sm89-fp8 branch)

输入H20L40S-tritonL40S-cuda速度提升ITL-tritonITL-cuda
1458.03250.36303.09+21.1%3.983.28
61441572.95805.75936.57+16.0%4.944.24
143362689.081270.001427.09+12.0%6.265.57
307203819.861773.351918.70+8.0%8.978.28

4.2 Qwen3-1.7B-FP8 (SGLang sm89-fp8 branch)

输入H20L40S-tritonL40S-cuda速度提升ITL-tritonITL-cuda
1333.90148.35197.24+32.9%6.735.05
61441198.20518.43661.93+27.7%7.696.01
143362095.61877.281071.52+22.1%9.087.43
307203165.321356.381576.85+16.2%11.7410.07

4.3 Qwen3-30B-A3B-FP8 (SGLang sm89-fp8 branch)

输入H20L40S-tritonL40S-cuda速度提升ITL-tritonITL-cuda
1155.55102.83117.38+14.2%9.708.49
6144551.34377.75426.18+12.8%10.549.34
14336945.13680.58757.84+11.4%11.7010.50
307201405.911133.151238.20+9.3%14.0512.85

4.4 Qwen3-4B-FP8 (SGLang sm89-fp8 branch)

输入H20L40S-tritonL40S-cuda速度提升ITL-tritonITL-cuda
1200.6175.19103.18+37.2%13.289.67
6144662.26278.90370.24+32.8%14.3110.76
143361066.23498.17638.03+28.1%16.0212.50
307201467.71821.341002.76+22.1%19.4215.90

4.5 Qwen3-8B-FP8 (SGLang sm89-fp8 branch)

输入H20L40S-tritonL40S-cuda速度提升ITL-tritonITL-cuda
1150.2553.4766.56+24.5%18.6915.00
6144516.64202.20248.00+22.7%19.7516.10
14336859.92371.91447.97+20.5%21.4717.82
307201242.24641.60751.65+17.2%24.8821.23

4.6 Qwen3-14B-FP8 (SGLang sm89-fp8 branch)

输入H20L40S-tritonL40S-cuda速度提升ITL-tritonITL-cuda
197.1134.2039.30+14.9%29.2225.46
6144342.95130.36148.56+13.9%30.6526.89
14336587.33246.01278.15+13.1%32.4828.72
30720880.72441.62492.80+11.6%36.1732.40

4.7 Qwen3-32B-FP8 (SGLang sm89-fp8 branch)

输入H20L40S-tritonL40S-cuda速度提升ITL-tritonITL-cuda
146.1715.9518.31+14.8%62.7054.59
6144165.7161.3670.13+14.3%65.1456.98
14336287.60117.39133.28+13.5%68.0959.96
30720436.59FAILFAIL

RDMA 环境下的一点 NCCL 调试经验

使用 GPU 多机多卡分布式推理和训练时,往往会使用 NCCL 进行卡间通信。一般云上的 GPU 环境,或者有 PaaS 管理的机群,这些都会被妥善配置。但碰巧我有一些物理机,需要自己配置,踩过一些坑,备忘一下便于以后查找。

PaaS 注入的环境变量

PaaS 往往会通过 container 直接注入一些 NCCL 环境变量到容器的 1 号进程,通过 1 号进程启动的子进程会继承这个变量,但是在 shell 中无法直接通过 echo 拿到。

可以在容器内,通过下面这个命令查看所有注入的 NCCL 环境变量:

# cat /proc/1/environ | tr '\0' '\n' | grep NCCL_
NCCL_VERSION=2.21.5
NCCL_DEBUG=INFO
NCCL_SOCKET_IFNAME=bond0
NCCL_IB_HCA=mlx5_8,mlx5_6,mlx5_4,mlx5_0,mlx5_9,mlx5_7,mlx5_5,mlx5_3
NCCL_IB_GID_INDEX=3
...

NCCL_DEBUG

这个环境变量会控制 NCCL 调试日志的打印级别,一般调试时设置到 INFO。在 INFO 级别看不懂的错误,调到更低的 TRACE 级别大概率也是看不懂。

NCCL_IB_HCA 和 NCCL_SOCKET_IFNAME

这里还需要有一点背景知识,RDMA/IB 网络和普通的以太网络有两套不同的术语:

  • RDMA/IB 的网络适配器叫做 HCA (Host Channel Adapter),如果用的是 Mellanox 家网卡的话,一般 HCA 名字(HCA ID)类似于 mlx5_*。可以通过 ibv_devinfo/nvidia-smi topo -m 命令列出所有的 RDMA 网络接口。
  • 以太网的网络适配器一般叫做 Network Interface 或 IP Interface。一般网卡名是 eth*, ens*, xgbe*, bond*,可以通过 ifconfig 或者 ip addr show 列出所有以太网接口。

在 RoCE 网络下,每个网络接口都有一个以太网接口名和一个 RDMA 接口名(HCA ID)。比如以太网接口 bond0,RDMA 接口 mlx5_bond_0;以太网接口 xgbe2,RDMA 接口 mlx5_2。可以通过下面的方式查询 RDMA 接口对应的以太网接口名:

$ ibdev2netdev
mlx5_2 port 1 ==> xgbe2 (Up)
mlx5_3 port 1 ==> xgbe3 (Up)
mlx5_4 port 1 ==> xgbe4 (Up)
mlx5_5 port 1 ==> xgbe5 (Up)
mlx5_bond_0 port 1 ==> bond0 (Up)
$ cat /sys/class/infiniband/mlx5_2/ports/1/gid_attrs/ndevs/0
xgbe2

背景介绍完毕,下面为了方便,对网卡接口的 IP 就是混着写了。我在单机多卡之间启用 NCCL 通信时,遇到过下面这个问题:

120858:120906 [6] ibvwrap.c:262 NCCL WARN Call to ibv_modify_qp failed with 110 Connection timed out, on dev mlx5_4:1, curr state INIT, next state RTR, local GID index 3, local GID ::ffff:【MLX5_4 IP】, remote GID ::ffff:【BOND0 IP】

从报错来看是两个 RDMA 接口之间建立 RDMA 连接的时候超时。仔细看两个 IP 不在一个网段,一个是机器主网卡(链路聚合网卡)的 IP,一个是仅用做 RDMA 通信的网卡的 IP。可以通过下面的命令验证一下两个 RDMA 接口之间的 RDMA 连接是否连通:

# 在主网卡 RDMA 接口启动 RDMA 服务端
ib_write_bw -d mlx5_bond_0
# 从 RDMA 接口 mlx5_4 请求服务端
ib_write_bw -d mlx5_4 【主网卡IP】

在主网卡和 RDMA 专用的网卡之间不连通,应该是一个正常现象,这可能是因为他们连接的交换机等网络设备配置不同导致的。这时候就需要配置 NCCL_IB_HCA 和 NCCL_SOCKET_IFNAME 来保证 NCCL 能识别这一点。

多说一句:有时候在 paper 里,主网卡可能会叫做 Storage NIC,因为主要跟存储设置通信,也会启用 RDMA,叫做存储网络平面;专用 GPU 通信的网卡一般叫做 CXn NIC,有的甚至每张卡会连到一个专门的网络平面,比如 DeepSeek V3 的训练环境,有 8 个专门的 GPU 计算网络平面,每个平面之间都是不互通的。所以上面说有些网卡之间 RDMA 不连通可能是期望中的。

一般来说,NCCL_SOCKET_IFNAME 要配置为主网卡的以太网接口名,NCCL_IB_HCA 要配置为专用于 RDMA 网络的接口 HCA ID 白名单列表。比如这样:

export NCCL_IB_HCA=mlx5_2,mlx5_3,mlx5_4,mlx5_5
export NCCL_SOCKET_IFNAME=bond0

也可以使用黑名单的方式,或者自动生成白名单的方式,例如:

export NCCL_IB_HCA=^mlx5_bond_0
export NCCL_IB_HCA=$(ls /sys/class/infiniband | grep -E "mlx5_[0-9]+" | paste -sd,)

单机多卡训练时不一定需要 RDMA 通信,但是明确设置 NCCL_IB_HCA 和 NCCL_SOCKET_IFNAME 这两个环境变量,能避免很多 NCCL 做通信方式决策的问题。

NCCL_IB_GID_INDEX

GID 相当于 RDMA 网络中的 IP 地址,但是每个 RDMA 接口会有多个 GID,用户需要自己手动选择使用每个接口的第几个 GID 进行通信。

RoCE 网络下,一般的 HCA 接口下会有 4 个 GID,使用 ibv_devinfo -v 可以列出来每个 HCA 接口的 GID 列表。形如:

GID[0]: fe80:0000:0000:0000:966d:aeff:fed8:f1dc, RoCE v1
GID[1]: fe80::966d:aeff:fed8:f1dc, RoCE v2
GID[2]: 0000:0000:0000:0000:0000:ffff:0a37:5968, RoCE v1
GID[3]: ::ffff:【IPv4】, RoCE v2

一般来说,只有 3 号 GID,是支持 RoCE 交换机子网间通信的,所以在跨机通信时,大部分 NCCL_IB_GID_INDEX 需要选择 3,否则可能导致多机不连通。在本机多卡间通信时,一般 0、1、2、3 都可以,但是为了方便默认设置成 3 也没什么影响。

Docker 启动命令

如果 container 没有配置注入环境变量,可以在自己启动 Docker 时从命令行注入环境变量,比如:

docker run --network=host -it --runtime=nvidia --gpus all --shm-size=700g --privileged \
--cap-add=SYS_ADMIN --cap-add=NET_RAW --cap-add=NET_ADMIN --cap-add=IPC_LOCK \
--ulimit memlock=-1 --device=/dev/fuse --device=/dev/infiniband \
-e NCCL_IB_HCA=mlx5_2,mlx5_3,mlx5_4,mlx5_5 -e NCCL_SOCKET_IFNAME=bond0 -e NCCL_IB_GID_INDEX=3 \
...

nccl-tests

nccl-tests 是比较便捷的 NCCL 连接测试工具,可以自己编译,但很多 docker 镜像中都会自带这个工具,比如 sglang:dev,用起来更方便。可以通过下面的命令测试单机多卡之间连通和性能(如果卡之间需要 RDMA 连接的话):

NCCL_IB_HCA=^mlx5_bond_0 NCCL_SOCKET_IFNAME=bond0 all_reduce_perf -b 8M -e 256M -f 2 -g 8

也可以通过 CUDA_VISIBLE_DEVICES 选择对哪几张卡进行连通测试,-g 设置卡的数量:

CUDA_VISIBLE_DEVICES=0,4 NCCL_IB_HCA=^mlx5_bond_0 NCCL_SOCKET_IFNAME=bond0 all_reduce_perf -b 8M -e 256M -f 2 -g 2

但如果想跑多机多卡的 all_reduce_perf,就需要用到 openmpi 和支持 MPI 的 all_reduce_perf (可以通过 ldd all_reduce_perf 查看是否依赖 libmpi.so 判断)。openmpi 使用 ssh 登陆多台服务器来分发任务,所以需要在宿主机间,或者容器间配置 ssh 自动登录。下面是一个 2 机 16 卡的 nccl 通信测试命令:

mpirun -np 16 -host IP1:8,IP2:8 -mca btl_tcp_if_exclude lo,docker0 -x NCCL_DEBUG=INFO -x NCCL_IB_HCA=mlx5_2,mlx5_3,mlx5_4,mlx5_5 -x NCCL_SOCKET_IFNAME=bond0 -x NCCL_IB_GID_INDEX=3 all_reduce_perf -b 8M -e 256M -f 2 -g 1

一个能提升 DeepSeek/Qwen3 在 L40S/L20 卡上 FP8 量化计算性能的 CUDA Kernel

从 DeepSeek-V3 开始,blockwise FP8 量化逐渐进入大家的视野,DeepGEMM 的开源更是扩大了这种量化方式的影响。但无论是 DeepGEMM 还是 CUTLASS,目前都只在 Hopper (SM_90) 或以后的架构上支持 blockwise FP8 量化。

Hopper 之前的 Ada Lovelace (SM_89) 一代的卡,比如 L40S、L20、40x0,虽然也支持 FP8 计算,但目前大部分实现都是通过 Python 写的 Triton Kernel 实现 blockwise FP8 量化计算。

支持 SM_89 上 FP8 Blockwise 量化的 CUDA Kernel

为了优化 blockwise FP8 量化在 SM_89 系列 GPU 上的计算性能,我基于 CUTLASS 实现了支持 blockwise FP8 量化的 CUDA Kernel,源代码在:https://github.com/NVIDIA/cutlass/pull/2328

这个 Kernel 不仅支持 SM_89 之后 GPU 上的 FP8 blockwise 量化,还支持 SM_80 之后 GPU 上的 INT8 blockwise 量化。使用方法可以参考 examples/85_ada_ampere_gemm_with_blockwise_scaling 下的用例。

在矩阵计算规模较小时,这个 Kernel 在 L40S 卡上的计算性能甚至可以超过 H800 卡上的 warp specialized + tma 实现。在矩阵计算规模较大时,例如 M=4096、N=7168、K=2048 时,在 L40S 上的计算吞吐是 260 TFLOPS,对比 DeepGEMM 的 1215 TFLOPS (https://github.com/deepseek-ai/DeepGEMM/pull/86),大约是 1 : 4.67,而 L40S 对比 H800 的 FP8 算力是 733 TFLOPS : 3958 TFLOPS,大约是 1 : 5.39。可以看到这个 kernel 的性能还算过得去。

$./examples/85_ada_ampere_gemm_with_blockwise_scaling/85a_ada_fp8_gemm_with_groupwise_scaling_cute --m=4096 --n=7168 --k=2048
Problem Size: 4096x7168x2048x1
Tile shape (M, N, K): _64, _128, _128
ScaleGranularityM: 1 (ScaleMsPerTile: 64)
ScaleGranularityN: 128 (ScaleNsPerTile: 1)
Running...
Result MSE: 4.56599e-06, MRE: 15.6246, greatest error: 0.0286751
Disposition: Passed
Avg runtime: 0.462145 ms
GFLOPS: 260220

SGLang 集成

我在自己的 SGLang 分支的 sgl-kernel 中集成了这个 Kernel,在 L40S 卡上对比 SGLang 已有的 Triton 实现性能有大幅提升,最高提升幅度可达 60%。以下是一个例子,benchmark 性能的单位是 GB/s。

deepseek-ai/DeepSeek-V3 N=24576 K=7168: 
Using default W8A8 Block FP8 kernel config. Performance might be sub-optimal! Config file not found at /workspace/git/sglang/python/sglang/srt/layers/quantization/configs/N=24576,K=7168,device_name=NVIDIA_L40S,dtype=fp8_w8a8,block_shape=[128, 128].json
fp8 blockwise scaled matmul:
batch_size sgl-kernel sglang triton
0 1.0 927.460912 905.494708
1 8.0 7562.373485 7500.555847
2 16.0 15083.308993 15001.111694
3 32.0 29920.694988 29920.694988
4 64.0 59195.447003 59357.498347
5 128.0 112069.375528 114995.465571
6 256.0 208242.387702 166830.544152
7 512.0 268966.505252 185836.556807
8 1024.0 286227.561402 187468.003955
9 2048.0 300380.325376 187069.874028
10 4096.0 297778.251151 189459.651356

分支源代码在 https://github.com/solrex/sglang/tree/sm89-fp8 ,需要自行编译 sgl-kernel,benchmark 脚本是 sgl-kernel/benchmark/bench_fp8_blockwise_gemm.py,感兴趣的朋友可以自行验证。

Qwen3 FP8 模型

因为 Qwen3 的 FP8 版本参数也是采用的 blockwise 量化,可以使用 Qwen3 量化模型对比新的 CUDA Kernel 和 Triton Kernel 的端到端性能。在 L40S 上,使用我实现的 CUDA Kernel 比原 Triton Kernel,可以提升 Qwen3-8B-FP8 模型吞吐 12% 左右

# Triton Kernel
============ Serving Benchmark Result ============
Backend: sglang-oai
Traffic request rate: 64.0
Max reqeuest concurrency: 64
Successful requests: 512
Benchmark duration (s): 139.73
Total input tokens: 262144
Total generated tokens: 262144
Total generated tokens (retokenized): 262140
Request throughput (req/s): 3.66
Input token throughput (tok/s): 1876.12
Output token throughput (tok/s): 1876.12
Total token throughput (tok/s): 3752.25
Concurrency: 63.73
----------------End-to-End Latency----------------
Mean E2E Latency (ms): 17390.95
Median E2E Latency (ms): 17425.19
---------------Time to First Token----------------
Mean TTFT (ms): 1887.69
Median TTFT (ms): 1911.72
P99 TTFT (ms): 3277.92
---------------Inter-Token Latency----------------
Mean ITL (ms): 30.59
Median ITL (ms): 28.11
P95 ITL (ms): 31.14
P99 ITL (ms): 31.97
Max ITL (ms): 3035.59
==================================================

# CUDA Kernel
============ Serving Benchmark Result ============
Backend: sglang-oai
Traffic request rate: 64.0
Max reqeuest concurrency: 64
Successful requests: 512
Benchmark duration (s): 123.98
Total input tokens: 262144
Total generated tokens: 262144
Total generated tokens (retokenized): 262144
Request throughput (req/s): 4.13
Input token throughput (tok/s): 2114.39
Output token throughput (tok/s): 2114.39
Total token throughput (tok/s): 4228.78
Concurrency: 63.66
----------------End-to-End Latency----------------
Mean E2E Latency (ms): 15416.44
Median E2E Latency (ms): 15475.84
---------------Time to First Token----------------
Mean TTFT (ms): 1407.69
Median TTFT (ms): 1370.36
P99 TTFT (ms): 2305.31
---------------Inter-Token Latency----------------
Mean ITL (ms): 27.68
Median ITL (ms): 25.81
P95 ITL (ms): 28.79
P99 ITL (ms): 29.88
Max ITL (ms): 2201.85
==================================================

DeepSeek V3/R1 模型

基于我之前的工作《在 32 张 L40S/L20 上运行 DeepSeek-R1/V3 原版 FP8 模型》,在 32 张 L40S 上,单请求下,新 Kernel 的 otps 可以从 33 提升到 40 左右;压力下大概提升 5% 左右,下面是压测数据。

# Triton Kernel
============ Serving Benchmark Result ============
Backend: sglang-oai
Traffic request rate: 128.0
Max reqeuest concurrency: 128
Successful requests: 256
Benchmark duration (s): 98.63
Total input tokens: 51200
Total generated tokens: 51200
Total generated tokens (retokenized): 50971
Request throughput (req/s): 2.60
Input token throughput (tok/s): 519.11
Output token throughput (tok/s): 519.11
Total token throughput (tok/s): 1038.22
Concurrency: 127.26
----------------End-to-End Latency----------------
Mean E2E Latency (ms): 49032.00
Median E2E Latency (ms): 49199.08
---------------Time to First Token----------------
Mean TTFT (ms): 8294.87
Median TTFT (ms): 8306.07
P99 TTFT (ms): 15599.09
---------------Inter-Token Latency----------------
Mean ITL (ms): 205.47
Median ITL (ms): 183.05
P95 ITL (ms): 187.48
P99 ITL (ms): 243.63
Max ITL (ms): 11274.90
==================================================

# CUDA Kernel
============ Serving Benchmark Result ============
Backend: sglang-oai
Traffic request rate: 128.0
Max request concurrency: 128
Successful requests: 256
Benchmark duration (s): 93.56
Total input tokens: 51200
Total generated tokens: 51200
Total generated tokens (retokenized): 50980
Request throughput (req/s): 2.74
Input token throughput (tok/s): 547.24
Output token throughput (tok/s): 547.24
Total token throughput (tok/s): 1094.48
Concurrency: 127.28
----------------End-to-End Latency----------------
Mean E2E Latency (ms): 46516.28
Median E2E Latency (ms): 46715.28
---------------Time to First Token----------------
Mean TTFT (ms): 8396.97
Median TTFT (ms): 9343.49
P99 TTFT (ms): 13201.02
---------------Inter-Token Latency----------------
Mean ITL (ms): 192.20
Median ITL (ms): 172.65
P95 ITL (ms): 180.43
P99 ITL (ms): 217.66
Max ITL (ms): 10399.15
==================================================

验证方法

使用我的 SGLang sm89-fp8 分支,编译安装 sgl-kernel,安装 sglang。或者也可以下载我编译好的 wheel 直接安装。

git clone -b sm89-fp8 https://github.com/solrex/sglang.git
cd sglang/sgl-kernel
make build # 编译安装本地 sgl-kernel,耗时较久
cd ..
pip install -e "python[all]" # 安装本地 sglang

在启动 SGLang 服务时,可以通过是否设置 SGLANG_SUPPORT_CUTLASS_BLOCK_FP8 环境变量,控制使用 CUDA Kernel 还是 Triton Kernel。启用 CUDA Kernel 的命令形如:

SGLANG_SUPPORT_CUTLASS_BLOCK_FP8=1 python3 -m sglang.launch_server --model-path /workspace/Qwen3-8B-FP8/ --port 8080 --tp 1 --host 0.0.0.0 --allow-auto-truncate --enable-torch-compile --torch-compile-max-bs 64 --cuda-graph-max-bs 64

可以通过启动日志中是否有 "Using default W8A8 Block FP8 kernel config." 类似日志来判断使用的 Kernel 是哪个。当下面这条日志存在时,代表使用的是 Triton Kernel:

[2025-06-03 18:20:24] Using default W8A8 Block FP8 kernel config. Performance might be sub-optimal! Config file not found at /workspace/git/sglang/python/sglang/srt/layers/
quantization/configs/N=6144,K=4096,device_name=NVIDIA_L40S,dtype=fp8_w8a8,block_shape=[128, 128].json

压测使用的脚本是:

python3 -m sglang.bench_serving --backend sglang-oai --dataset-path /workspace/ShareGPT_Vicuna_unfiltered/ShareGPT_V3_unfiltered_cleaned_split.json --dataset-name random --random-range-ratio 1 --random-input-len 512 --random-output-len 512  --request-rate 64 --num-prompt 512 --max-concurrency 64 --host 127.0.0.1 --port 8080

在实现这个 kernel 时我遇到和解决了很多问题,现在也不敢说没有 bug。如果你在验证的时候发现任何问题,请不吝留言指教。

乒乓球菜鸟聊一下最近买的几个球拍

最近儿子突然爱上打乒乓球,家里又没有合适的乒乓球拍。作为理工男、参数党、选择困难症患者,就到了愉快而纠结的技术、产品调研和比价选择阶段。

只要稍作调研,可能第一个纠结的就是要不要买成品乒乓球拍。成品拍就是出厂双面胶皮都贴好的乒乓球拍,也就是经常在超市里看到的球拍,大部分比较便宜,也有比较贵的。目前主流建议是不要买成品拍,这个因人而异,我调研到最后,也认可这样的选择。我主要是从这两点出发:

  1. 无法准确地判断成品球拍的性价比。不同品牌 3 星、6 星、8 星乱标,主流舆论又从来不评测和讨论这些球拍,所以即使这个球拍性能很好,你也无从判断。
  2. 给孩子准备的球拍,还是有根据水平、磨损更换胶皮的需求。孩子用球拍并不爱惜,各种蹭、磕到胶皮的概率不低,再加上随着水平增加有可能磨着家长要换拍。成品拍的胶皮是很难完整撕下来并进行更换的。

此外,非成品拍也是丰俭由人,倒也不比成品球拍贵多少。但可能你还会想自己不会贴胶皮,贴不好怎么办?会不会浪费胶皮。

我在试过一次之前,也不知道贴胶皮是这么容易一件事。要说起来,贴胶皮有点像是补自行车胎,要等刷上的胶水跟空气接触一段时间,水分蒸发一些,再把两边粘起来。而且它的胶水会形成一层膜,即使刷不好,等干了搓一搓就能把胶水给全除掉再重新刷就好了。

自己贴胶皮,技术要求最高的部分可能是剪胶皮。因为胶皮加上海绵有两三毫米高,我第一次卡着底板边剪,结果把胶皮剪到比底板更小,后续贴海绵护边的时候,有些难看。但其实剪成什么样并不太影响使用。

商家剪胶皮,现在有一种专业的工具,的确比剪刀剪得好。所以如果总价相差不大,直接买商家贴好的球拍也行,体验上跟买成品拍没啥区别。但撕胶皮的时候,非成品拍的胶皮和海绵很容易完整撕下来,便于后续的更换。所以,贴胶皮的技术早晚总是得学的。

至于胶皮和底板的选择,尤其是对新手来说哪个好,我觉得就是个玄学了。跟羽毛球拍一样,技术指标在那摆着,你不能说没区别。可铝杆和碳杆你大概能打出来区别,但 400 多的碳杆和 1000 多的碳杆,要是让你盲测,大概率测不出来。

我就说说选的 3 个球拍吧,主要材料都来自拼多多,价钱平均在 100 元多一点。也就是够用就行,不怕磕碰,即使丢了也不太心疼的那种。

底板:银河 U2 横板,胶皮:729 普及训练套(红黑),价格:122 元
底板:银河 U2 横板,胶皮:729 普及训练套(红),锐龙 1(黑),价格:125元

前两个球拍是一起买的,商家贴好胶皮,连着胶皮的边角料发货的。从网上看,银河 U2 差不多是百元级球拍最推荐的底板了,我和儿子拿着这两个球拍练了几次球,没有什么觉得不顺手的地方。锐龙1 的胶皮和 729 的胶皮也没有打出来任何区别。

底板:729 黄芳碳横板,胶皮:水星2中(红),拍里奥CJ8000 38-41(黑),价格:108元

第三个球拍是我自己贴的,可以看到黑色胶皮剪到露海绵了,红色还好。719 黄芳碳也是百元级推荐的底板,水星 2 和 CJ8000 胶皮比 729 要略贵几块钱。儿子对这个底板的评价不高,说这个底板边缘磨虎口。而这个胶皮,我也觉得不太顺手,可能是软了,回球容易高。

我自己从小打乒乓球都是野路子,而且也没打多久。专业的意见也给不出来,就随便分享个调研情况,希望对你有用。

GLM-4-Z1 模型设计做错了一件事

DeepSeek-R1 API 在 OpenAI 兼容 API 基础上为思考模型增加了 "reasoning_content" 字段,可以将 "<think>" "</think>" 中间的内容独立返回,非思考部分通过 "content" 字段返回。

SGLang/vLLM 等框架也支持了这样的返回,比如 SGLang 可以在启动时增加参数 "--reasoning-parser deepseek-r1",让服务器像 DeepSeek-R1 API 那样返回。

Chatbox 等支持 OpenAI 兼容 API 的聊天 APP,对 DeepSeek-R1 风格的返回结果支持也更好,上面截图就是一个例子,当思考部分以 "reasoning_content" 字段返回时,显示效果与正文不同,背景是灰色,而且可折叠。

上一篇博客中提到我给 SGLang 添加了 GLM-Z1 模型的支持,然后我发现 GLM-Z1 系列模型无法做到思考内容和非思考内容分开输出。如果你在启动服务时增加了 "--reasoning-parser deepseek-r1" 参数,那么所有生成的内容都会放在 "reasoning_content" 字段返回,而 "content" 为空。Chatbox 显示效果如下所示:

我仔细研究了一下,发现了问题在 GLM-Z1 模型上:GLM-Z1 系列模型在设计时,没有像 DeepSeek-R1 那样将 "</think>" 编码为一个独立的 Token。

GLM-Z1 系列模型输出的 "</think>" 由 3 个 Token 组成:"</"、 "think" 和 ">",这就会导致 框架在流式逐字输出时,没法简单地判断一个新生成的 Token 是否为 "</think>",以决策在何时结束 "reasoning_content" 字段。

可以通过下面代码简单验证:

from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("GLM-Z1-9B-0414")
print(tokenizer("</think>\n"))

DeepSeek-R1 是在 tokenizer.json 中标记了 "</think>" 作为 "added_tokens",QwQ-32B 则把 "</think>" 放在了 added_tokens.json 中。这就意味着,DeepSeek-R1 和 QwQ-32B 输出的 "</think>" 是 1 个 token,很方便框架去完整地比对它是否存在。框架的确也可以兼容非一个 token 的 "</think>",但编码更为复杂,效率也会偏低。

建议思考模型的设计者都注意一下这个小小的细节。

有关 GLM-4-0414 的 SGLang 推理支持

在智谱的 GLM-4-0414 系列模型发布后,我观察到一个有意思的现象。GLM-4-0414 相较于 GLM-4 修改了模型结构,初期仅支持通过 transformers 库推理,但你去搜索它有哪些推理支持的话,什么也找不到,被大量的垃圾自媒体文章淹没,很多相较于官方模型仓库的 README 来说都是 0 信息量。

有些垃圾自媒体,可能连个 AI 都不如。

说回来 GLM-4-0414,虽然它名字看起来是 GLM-4 的延续,但是仔细看 config.json 你就会发现,GLM-4 的模型结构是 ChatGLMModel,而 GLM-4-0414 的模型结构是 Glm4ForCausalLM。这就导致很多推理框架都要对其进行重新适配。

vllm 可能是最早支持 GLM-4-0414 的开源框架,看起来是智谱员工提的 PR,但是第一版实现有两个 bug,会导致模型加载失败或者输出错误结果。由于对 GLM-Z1-9B-0414 模型在报告中声称大幅优于 DeepSeek-R1-Distill-Qwen-14B 过于好奇,我忍不住自己尝试在 SGLang 里适配了一下 GLM4-4-0414,见 PR#5485

嗯,学到了很多东西,也踩了两个 bug 其中的一个,哭。就说说这两个 bug 吧,其实都是源自于对 vllm/SGLang 库算子的不了解。

在 transformers 库中,很多算子仅实现其算子名表示的朴素的功能,但是 vllm/SGLang 代码库的一些算子,除了其朴素功能以外,往往还通过更多的参数实现对前后计算的融合、简化,导致其实际计算需要深入探究。

BUG 1: 融合 RMSNorm

以比较基础的 LLaMA 为例,transformers 中的 LLaMA DecoderLayer 的实现是这样的(删去了一些无关细节),这个跟大家理解的 Transformer 模型结构伪代码是容易对上的。

def forward(): residual = hidden_states hidden_states = self.input_layernorm(hidden_states) # Self Attention hidden_states, self_attn_weights = self.self_attn() hidden_states = residual + hidden_states # Fully Connected residual = hidden_states hidden_states = self.post_attention_layernorm(hidden_states) hidden_states = self.mlp(hidden_states) hidden_states = residual + hidden_states outputs = (hidden_states,) return outputs

但是 vllm/SGLang 中的实现是这样的:

def forward(): # Self Attention if residual is None: residual = hidden_states hidden_states = self.input_layernorm(hidden_states) else: hidden_states, residual = self.input_layernorm(hidden_states, residual) hidden_states = self.self_attn(...) # Fully Connected hidden_states, residual = self.post_attention_layernorm(hidden_states, residual) hidden_states = self.mlp(hidden_states) return hidden_states, residual

粗看你会有点懵,仔细研究就会发现,SGLang 里面通过给 RMSNorm 传入第二个参数,实现了 Norm 与 Add 的融合。但是这种融合需要调整计算顺序,影响了参数和返回值的类型,并且也影响了最后一次 model.norm() 的计算。

相似的还有 SiluAndMul() 算子。

BUG 2: get_rope 参数

GLM-4-0414 的 config.json 中提供了 partial_rotary_factor 参数,作用于 head_dim 上。有两种应用方法,一种是提前计算好 rotary_dim = int(partial_rotary_factor * self.head_dim),然后把这个参数传进去;另一种是令 rotary_dim = head_dim,然后传入 partial_rotary_factor。

def get_rope( head_size: int, rotary_dim: int, max_position: int, base: int, is_neox_style: bool = True, rope_scaling: Optional[Dict[str, Any]] = None, dtype: Optional[torch.dtype] = None, partial_rotary_factor: float = 1.0, ) -> RotaryEmbedding: ... if partial_rotary_factor < 1.0: rotary_dim = int(rotary_dim * partial_rotary_factor)

我重复犯的这个错误就是将 partial_rotary_factor 应用了两遍,rotary_dim 也计算了,partial_rotary_factor 参数也传了(因为参考了 vllm 实现和旧的 SGLang chatglm 实现,甚至看到别人提交的 bugfix 都不认为这里有错),其实就相当于应用了 partial_rotary_factor * partial_rotary_factor。这个 BUG 的现象就是导致 GLM-4 模型的输出大概率陷入死循环而无法结束。

BTW:transformers 库里的 glm4 代码没有读这个参数,而是硬编码在 apply_rotary_pos_emb() 算子中,所以这不是一个可调参数。

PR#5485 还在 Review 中,有需要的朋友可以使用 https://github.com/solrex/sglang/tree/glm4 这个分支支持 GLM-4-0414 系列模型在 SGLang 的推理。

git clone -b glm4 https://github.com/solrex/sglang.git
cd sglang
pip install -e "python[all]" --find-links https://flashinfer.ai/whl/cu124/torch2.5/flashinfer-python
python3 -m sglang.launch_server --model /workspace/GLM-Z1-9B-0414 --enable-torch-compile --torch-compile-max-bs 128 --cuda-graph-max-bs 128 --host 0.0.0.0 --port 8000

Llama-4 的 expert 参数组织问题和 FP8 量化方法

Llama-4 把模型结构里 expert 的参数组织搞得太恶心了。BF16 参数还好,如果想做一下量化,就会面临一堆麻烦。

先说一下 Llama-4 开源参数的问题。

HF BF16版本:3 维 experts 参数,gate_proj 和 up_proj 融合

Llama-4-Scout/Maverick 的 HuggingFace 版本参数 BF16 版本中,路由 expert 专家参数是按照 3 维存储的,而且 up_proj 和 gate_proj 放在了同一个 key 里。比如 Llama-4-Scout 的专家参数在 safetensors 文件中是这样存储的:

TensorsShapePrecision
language_model.model.layers.n.feed_forward.experts.down_proj
[16,8192,5120]
BF16
language_model.model.layers.n.feed_forward.experts.gate_up_proj
[16,5120,16384]
BF16

其中 n 是层数索引。Maverick 的 experts Shape 只是从 [16, , ] 变成了 [128, , ],其余都一样。

这个形状是与 Transformers 库中 llama4 的模型代码保持一致的:

// https://github.com/huggingface/transformers/blob/main/src/transformers/models/llama4/modeling_llama4.py class Llama4TextExperts(nn.Module): def __init__(self, config: Llama4Config): ... self.gate_up_proj = nn.Parameter(torch.empty(self.num_experts, self.hidden_size, 2 * self.expert_dim)) self.down_proj = nn.Parameter(torch.empty((self.num_experts, self.expert_dim, self.hidden_size))) ... def forward(self, hidden_states: torch.Tensor) -> torch.Tensor: ... gate_up = torch.bmm(hidden_states, self.gate_up_proj) gate, up = gate_up.chunk(2, dim=-1) # not supported for DTensors next_states = torch.bmm((up * self.act_fn(gate)), self.down_proj) ...

HF FP8版本:2 维 expert 参数,gate_proj 和 up_proj 分离

在 FP8 量化版本 Llama-4-Maverick-17B-128E-Instruct-FP8 中,expert 参数又被拆成了二维的,通过 key 中的索引标识属于哪个专家

TensorsShapePrecision
language_model.model.layers.n.feed_forward.experts.m.down_proj.weight[5120, 8192]F8_E4M3
language_model.model.layers.n.feed_forward.experts.m.down_proj. weight_scale[5120, 1]BF16
language_model.model.layers.n.feed_forward.experts.m.gate_proj.weight[8192, 5120]F8_E4M3
language_model.model.layers.n.feed_forward.experts.m.gate_proj. weight_scale[8192, 1]BF16
language_model.model.layers.n.feed_forward.experts.m.up_proj.weight[8192, 5120]F8_E4M3
language_model.model.layers.n.feed_forward.experts.m.up_proj. weight_scale[8192, 1]BF16

其中 n 是层数索引,m 是层中的专家数索引。

之前说到,Transformers 库中 modeling_llama4.py 是只支持融合模型的,那这种格式的参数怎么加载?

哎,人家用了一个办法:如果读到模型配置里有量化配置,在加载模型前修改一下模型结构。硬是在 transformers/quantizers/base.py 中增加了一个 _convert_model_for_quantization 方法,如果模型有子 module 叫做 "Llama4TextExperts",在量化 preprocess 的时候就给替换成 SequentialLlama4TextExperts 实现,不使用原始的 Llama4TextExperts 实现。

注意哦,这个替换对所有量化模型都生效。这种特化方法,要放在我的团队里,CR 估计都过不了。

怎么量化 llama4-scout ?

FB 官方提供了 128 专家的 FP8 模型,但是没有提供 16 专家的 FP8 量化模型。毕竟 16 专家模型也 200 多 G,如果想量化 16 专家模型,该怎么做呢?

meta 官方在 source/en/model_doc/llama4.md 里推荐的方法,是加载模型时使用 FbgemmFp8Config 进行 online 量化,这个我没跑成功,看着错误像是只支持单卡跑 fbgemm 量化,但是 H800 显存不够。如果这个问题可以解决,欢迎留言告诉我方法。

$ torchrun --nproc-per-node=4 test-16-fbgemm.py
self.pre_quantized False
Loading checkpoint shards:  100%|███████| 50/50 [00:45<00:00,  1.09it/s]
...
[rank0]:   File "/workspace/venv-fbgemm/lib/python3.10/site-packages/transformers/integrations/fbgemm_fp8.py", line 52, in forward
[rank0]:     x_quantized, x_scale = torch.ops.fbgemm.quantize_fp8_per_row(
[rank0]:   File "/workspace/venv-fbgemm/lib/python3.10/site-packages/torch/_ops.py", line 1158, in __call__
...
[rank0]:   File "/workspace/venv-fbgemm/lib/python3.10/site-packages/torch/distributed/tensor/_sharding_prop.py", line 486, in propagate_op_sharding_non_cached
[rank0]:     raise NotImplementedError(
[rank0]: NotImplementedError: Operator fbgemm.quantize_fp8_per_row.default does not have a sharding strategy registered.

咱又没有那么大显存的卡,只能想别的办法,能不能仿照 Llama-4-Maverick-17B-128E-Instruct-FP8 来转换出来一个 16 专家的 FP8 模型呢?

Llama-4-Maverick-17B-128E-Instruct-FP8 怎么转出来的?

首先了解一下 llama4 的转换脚本:

  • 在 github llama-models 代码库里,有一个 llama4/scripts/quantize.py 脚本,是用来将原始的 pytorch 模型参数,通过 fbgemm 转成 FP8 量化的 pytorch 模型参数。
  • 在 transformers 代码库里,有一个 llama4/convert_llama4_weights_to_hf.py 脚本,是用来将原始的 pytorch 模型参数,通过映射表映射到 huggingface 的 safetensors 模型参数。

然后来看 llama4 发布的模型:

        它不是通过 convert_llama4_weights_to_hf.py 转换 Llama-4-Maverick-17B-128E-Instruct-FP8-Original 来的,因为:

        1. FP8-Original 的量化是通过 fbgemm 做的,FP8 模型的量化是用 compressed-tensor 做的;
        2. FP8-Original 是分 TP 做的量化,与 FP8 的量化方法也不同。

        它也不是通过 llm-compressor(compressed-tensor) 转换 Llama-4-Maverick-17B-128E-Instruct 来的。因为 compressed-tensor 目前仅支持 Linear 算子的量化,但前面说过,加载原始 BF16 模型用的是 bmm 算子。

        convert_llama4_weights_to_hf.py 中有一个 _OFFLINE_QUANT_COMPATIBLE 的参数,可以控制是否对专家进行融合。但是这样转出来的模型,modeling_llama4.py 是无法加载的。

        我猜测 meta 线下可能替换了 modeling_llama4.py 中的专家层到 SequentialLlama4TextExperts,成功加载模型以后,然后再通过 llm-compressor 进行的模型转换

        然后就这样试了一下,先替换 transformers 库源码 modeling_llama4.py 中的 experts 组,并从源码安装 transformers:

        --- a/src/transformers/models/llama4/modeling_llama4.py +++ b/src/transformers/models/llama4/modeling_llama4.py @@ -153 +153,2 @@ class Llama4TextMoe(nn.Module): - self.experts = Llama4TextExperts(config) + from transformers.quantizers.base import SequentialLlama4TextExperts + self.experts = SequentialLlama4TextExperts(config)

        然后再执行:

        export OFFLINE_QUANT_COMPATIBLE=1
        python3 src/transformers/models/llama4/convert_llama4_weights_to_hf.py --instruct --convert_checkpoints --input_dir /workspace/Llama-4-Scout-17B-16E-Instruct-Original --output_dir /workspace/Llama-4-Scout-17B-16E-Instruct-Split-Experts

        然后再使用 llm-compressor 库,用下面的 recipe 参数对模型进行量化,这样就成功了。转换出来的模型仓库内容基本与 Llama-4-Maverick-17B-128E-Instruct-FP8 相同。

        recipe = QuantizationModifier(
                targets="Linear",
                scheme="FP8_DYNAMIC",
                ignore=[
                    're:.*lm_head',
                    're:.*self_attn',
                    're:.*router',
                    're:.*vision_model',
                    're:.*multi_modal_projector',
                    're:.*shared_expert',
                    're:.*feed_forward.gate_proj',
                    're:.*feed_forward.up_proj',
                    're:.*feed_forward.down_proj'
                ],
            )
        

        为什么?

        我是想不通 llama4 为啥要把简单的事情搞这么复杂,这样一改,很多开源库都要去适配这种格式的 MoE 参数,但功能上看起来又没有任何变化。做融合的 expert 参数有什么实际的收益吗?

        在 32 张 L40S/L20 上运行 DeepSeek-R1/V3 原版 FP8 模型

        上文讲到,FP8 模型之所以无法 TP32 运行,主要因为 DeepSeek R1/V3 模型保存的参数是 FP8 128x128 量化的。Attention 还好,128 个头做 TP16 或者 TP32 都没问题,问题主要出在专家的计算上。

        前三层 MLP 的 intermediate_size 是 18432,18432 做 TP16 是 1152,相当于 9 个 128。但如果做了 TP32,就是 4.5 个 128,这会导致参数无法切分在 128 边界上,无法支持按 128 block 量化。路由专家也类似,moe_intermediate_size 做 TP32 相当于 0.5 个 128。

        缩小 DeepSeek-R1/V3 的量化块到 64x64

        如果想支持 TP32,一个很显然的路径就是把量化方式改成按 64x64 分块量化。本来这是一个比较复杂的操作,但我想了一个取巧的办法:直接把 128x128 的缩放系数,复制到 4 份。

        为了方便理解这个方案,我画了一张图。假设我们有一个 4x8 的 INT32 矩阵,按照 4x4 block 量化到 INT8,它会分成 1x2 个 4x4 的块,每块一个缩放系数,那就是 1x2 个缩放系数。如下图所示,第一个块的缩放系数是 7.3465,它是通过第一个块里的最大绝对值 |-933|/127 得到的,同理第二个缩放系数来自 974/127。

        那如果我想将它的量化 block 缩小到 2x2,理论上我应该计算每个 2x2 block 的最大绝对值,然后 /127 得到缩放系数,这样精度损失最小。可是我嫌麻烦,偷个懒,我直接把 4x4 的缩放系数复制 4 份,虽然精度有损失,但好处是在计算上与 4x4 的量化结果完全一致。换句话说,就是原汁原味,纯血参数。

        将这个逻辑迁移到 DeepSeek FP8 量化的 128x128 block 缩小到 64x64,原理是一样的,也是将 scale 参数矩阵进行 2x2 等值扩充。通过非常简单的参数处理,就能够实现将 DeepSeek 原始模型转成 64x64 的分块量化,然后就可以用 SGLang 加载运行了。

        运行方法

        我们以昨天发布的 DeepSeek-V3-0324 为例,逐步说明如何使用这种方法在 L40S 和 L20 上运行 FP8 满血+纯血版的 DeepSeek-V3-0324,不需要等待美团再发布 INT8 版本。

        假设你已经下载好了模型,在 /workspace/DeepSeek-V3-0324/。那你需要先下载我的开发分支,并通过源代码安装它(如果遇到困难,建议你在 SGLang 的开发 Docker 中执行它):

        git clone -b l40s-dsfp8 https://github.com/solrex/sglang.git
        cd sglang
        pip install -e "python[all]" --find-links https://flashinfer.ai/whl/cu124/torch2.5/flashinfer-python

        然后用下面这个脚本,将 128x128 量化的 DeepSeek-V3-0324,转换到 64x64 量化的 DeepSeek-V3-0324-Block64x64:

        python3 scripts/resize_block_size.py /workspace/DeepSeek-V3-0324/

        当你在 4 台(或 8 台)机器上都完成了 SGLang 安装和参数拷贝后,就可以用下面的命令来启动 SGLang 服务了。注意替换 MASTER_IP、mlx5_? 和 TCP_IFACE 到正确的值。

        # MASTER_IP: 主节点 IP
        # TCP_IFACE: 主网卡接口名,可通过 ifconfig 获取

        # 主节点
        NCCL_DEBUG=INFO NCCL_IB_GID_INDEX=3 NCCL_IB_HCA=mlx5_? NCCL_SOCKET_IFNAME=TCP_IFACE GLOO_SOCKET_IFNAME=TCP_IFACE python3 -m sglang.launch_server --model /workspace/DeepSeek-V3-0324-Block64x64/ --tp 32 --dist-init-addr MASTER_IP:5000 --nnodes 4 --node-rank 0 --trust-remote --enable-torch-compile --torch-compile-max-bs 32 --cuda-graph-max-bs 32 --host 0.0.0.0 --port 8000

        # 从节点 1
        NCCL_DEBUG=INFO NCCL_IB_GID_INDEX=3 NCCL_IB_HCA=mlx5_? NCCL_SOCKET_IFNAME=TCP_IFACE GLOO_SOCKET_IFNAME=TCP_IFACE python3 -m sglang.launch_server --model /workspace/DeepSeek-V3-0324-Block64x64/ --tp 32 --dist-init-addr MASTER_IP:5000 --nnodes 4 --node-rank 1 --trust-remote --enable-torch-compile --torch-compile-max-bs 32 --cuda-graph-max-bs 32

        # 从节点 2
        NCCL_DEBUG=INFO NCCL_IB_GID_INDEX=3 NCCL_IB_HCA=mlx5_? NCCL_SOCKET_IFNAME=TCP_IFACE GLOO_SOCKET_IFNAME=TCP_IFACE python3 -m sglang.launch_server --model /workspace/DeepSeek-V3-0324-Block64x64/ --tp 32 --dist-init-addr MASTER_IP:5000 --nnodes 4 --node-rank 2 --trust-remote --enable-torch-compile --torch-compile-max-bs 32 --cuda-graph-max-bs 32

        # 从节点 3
        NCCL_DEBUG=INFO NCCL_IB_GID_INDEX=3 NCCL_IB_HCA=mlx5_? NCCL_SOCKET_IFNAME=TCP网卡 GLOO_SOCKET_IFNAME=TCP网卡 python3 -m sglang.launch_server --model /workspace/DeepSeek-V3-0324-Block64x64/ --tp 32 --dist-init-addr MASTER_IP:5000 --nnodes 4 --node-rank 3 --trust-remote --enable-torch-compile --torch-compile-max-bs 32 --cuda-graph-max-bs 32

        性能

        在 MASTER 节点使用下面的命令进行性能测试(需要先下载测试数据集 ShareGPT_Vicuna_unfiltered/ShareGPT_V3_unfiltered_cleaned_split.json),输入固定 200,输出固定 200,并发 128,测试两轮。

        python3 -m sglang.bench_serving --backend sglang-oai --dataset-path /workspace/ShareGPT_Vicuna_unfiltered/ShareGPT_V3_unfiltered_cleaned_split.json --dataset-name random --random-range-ratio 1 --random-input-len 200 --random-output-len 200  --request-rate 128 --num-prompt 256 --max-concurrency 128 --host localhost --port 8000

        我测试的性能指标是:

        ============ Serving Benchmark Result ============
        Backend: sglang-oai
        Traffic request rate: 128.0
        Max reqeuest concurrency: 128
        Successful requests: 256
        Benchmark duration (s): 98.63
        Total input tokens: 51200
        Total generated tokens: 51200
        Total generated tokens (retokenized): 50971
        Request throughput (req/s): 2.60
        Input token throughput (tok/s): 519.11
        Output token throughput (tok/s): 519.11
        Total token throughput (tok/s): 1038.22
        Concurrency: 127.26
        ----------------End-to-End Latency----------------
        Mean E2E Latency (ms): 49032.00
        Median E2E Latency (ms): 49199.08
        ---------------Time to First Token----------------
        Mean TTFT (ms): 8294.87
        Median TTFT (ms): 8306.07
        P99 TTFT (ms): 15599.09
        ---------------Inter-Token Latency----------------
        Mean ITL (ms): 205.47
        Median ITL (ms): 183.05
        P95 ITL (ms): 187.48
        P99 ITL (ms): 243.63
        Max ITL (ms): 11274.90
        ==================================================

        对照上一篇博客《刷新 32 张 L40S 运行 DeepSeek-R1-INT8 的性能数据》,看起来 FP8 Block 量化的性能比 INT8 Channel 量化的性能要差一些。

        代码改动

        这次的代码改动不大,主要是参数转换脚本,和一些针对 64x64 的 tunning,以及 warning 的修复。整理好后我会提个 PR 给 SGLang,但这次我不确定这个 PR 是否会被接受,感兴趣的同学可以直接看这个 commit:https://github.com/solrex/sglang/commit/03d34078d8d65983aabc0386391743cc43f535ed or https://github.com/sgl-project/sglang/pull/4860

        地震

        正当我写到这的时候,忽然手机通知地震了。生平第一次收到,记录一下。我完全没震感,但是有朋友感觉到了。

        刷新 32 张 L40S 运行 DeepSeek-R1-INT8 的性能数据

        前一篇博客《使 SGLang 支持在 32 张 L40S 上运行 DeepSeek-R1》中提到我那非常特殊的 L40S 显卡配置,结果发现是个大乌龙。

        首先,这台机器有 PCIE 4.0 Switch,每张 Switch 上插了 4 张 L40S 显卡。我误会的 PCI-to-PCI 应该是挂了一个给监控屏用的小显卡。

        其次,有一张网卡插错位置了。本来应该每 4 张显卡配 1 张双口网卡,其中一张没插到对应的 Switch 上。

        最后,3 张网卡 6 个网口,只启用了 1 个网口,有 5 个网口没启用。

        所以,实际上所有机内通信走的都是 PCIE,所有跨机通信走的都是主网卡,这……只能怪自己没经验,默认以为交过来的环境都是对的。

        折腾了几天,总算搞对了,正确的拓扑如下:

        主网卡两个网口做了链路聚合,用作 TCP 通信;PCIE Switch 上的 4 个网口专用作 RDMA 通信。同机两个 NUMA 域双卡之间通信,走 PCIE 大概 11GB/s,走 GDRDMA 能提升到 19GB/s。最关键的是多机通信,从单网口的小水管提升到了 4 网口 GDRDMA。

        采用与上篇文章同样不严谨的测试方式:

        # 128 并发
        [TP0] Decode batch. #running-req: 128, #token: 86816, token usage: 0.44, gen throughput (token/s): 777.92, #queue-req: 0, # 之前 565.73, 1.37x
        # 32 并发
        [TP0] Decode batch. #running-req: 32, #token: 8923, token usage: 0.04, gen throughput (token/s): 457.66, #queue-req: 0, # 之前 260.73,1.75x
        # 4 并发
        [TP0] Decode batch. #running-req: 4, #token: 1437, token usage: 0.01, gen throughput (token/s): 153.98, #queue-req: 0, # 之前 42.3,3.64x
        # 1 并发
        [TP0] Decode batch. #running-req: 1, #token: 482, token usage: 0.00, gen throughput (token/s): 49.02, #queue-req: 0, # 之前 26,1.88x

        固定 200 输入,200 输出,128 并发,2 轮请求:

        ============ Serving Benchmark Result ============
        Backend: sglang-oai
        Traffic request rate: 128.0
        Max reqeuest concurrency: 128
        Successful requests: 256
        Benchmark duration (s): 87.57
        Total input tokens: 51200
        Total generated tokens: 51200
        Total generated tokens (retokenized): 51023
        Request throughput (req/s): 2.92
        Input token throughput (tok/s): 584.64
        Output token throughput (tok/s): 584.64 # 之前 391.47,1.49x
        Total token throughput (tok/s): 1169.28
        Concurrency: 127.23
        ----------------End-to-End Latency----------------
        Mean E2E Latency (ms): 43524.37
        Median E2E Latency (ms): 43246.71
        ---------------Time to First Token----------------
        Mean TTFT (ms): 7225.06
        Median TTFT (ms): 6769.52
        P99 TTFT (ms): 14229.37
        ---------------Inter-Token Latency----------------
        Mean ITL (ms): 182.87
        Median ITL (ms): 162.95
        P95 ITL (ms): 166.60
        P99 ITL (ms): 202.39
        Max ITL (ms): 13711.39
        ==================================================

        可以看到,修复网络本身存在的问题以后,推理性能提升还是很显著的。

        one more thing

        前一篇文章提到: L40S/L20 虽然支持 FP8 精度,却不能运行 FP8 的 DeepSeek-V3/R1。这个问题搞定了,我已经实现在 L40S 上运行原始 FP8 参数的 DeepSeek-R1,满血+纯血。等我整理一下代码,下篇博客来介绍一下。

        使 SGLang 支持在 32 张 L40S/L20 上运行 DeepSeek-R1

        我提交的 PR: Support serving DeepSeek-R1-Channel-INT8 with 32 L40S. #4418 [1] 已经合入到了 SGLang 的主干,也许这是第一个用 PCIE 互联的 GPU 小卡跑通 DeepSeek-R1 推理的例子。

        有一些遇到的问题分享一下,在适配别的 GPU 时可以用来参考。

        背景

        因为 128x128 block 量化的 DeepSeek-R1/V3,参数维度在除 32 时,会遇到商无法被 128 整除的问题,所以即使 L40S 支持 FP8,也无法直接用 TP32 运行 DeepSeek-R1/V3。

        感谢美团提供了 channel 量化的 DeepSeek-R1 参数 DeepSeek-R1-Channel-INT8 [2],并且在 SGLang 代码库做了适配。这让我可以在 48G 显存的 L40S 上尝试一下运行满血版(int8 量化) DeepSeek-R1,但尝试过程没有我预期的顺利,遇到了不少问题。

        问题

        一、shared memory OutOfResources

          File "/sgl-workspace/sglang/python/sglang/srt/layers/attention/triton_ops/extend_attention.py", line 356, in extend_attention_fwd
        _fwd_kernel[grid](
        ...
        File "/usr/local/lib/python3.10/dist-packages/triton/compiler/compiler.py", line 374, in _init_handles
        raise OutOfResources(self.metadata.shared, max_shared, "shared memory")
        triton.runtime.errors.OutOfResources: out of resource: shared memory, Required: 102400, Hardware limit: 101376. Reducing block sizes or `num_stages` may help.

        服务启动正常,收到推理请求后,Attention kernel 报 shared memory 资源不足。我刚开始没理解这个问题,后来看了下代码,发现 triton attention 代码中对不同类型的 GPU 架构设置了不同的 block size。L40S 属于 SM89 架构,但 SM89 都归类到了 SM80 架构里。

        查了一下 CUDA 编程手册,SM89 的 shared memory 大小是 100K,但 SM80 是 160K。我猜测大概率就是这个问题,所以给 SM89 单独加了个分支,缩小了 SM89 的 block size,解决了这个问题。

        后来发现 sglang/test/srt/test_triton_attention_kernels.py 可以完美复现这个问题,而我却傻乎乎地每次重启整个服务去测试正确性。

        二、gemm executioin failed RuntimeError

          File "/usr/local/lib/python3.10/dist-packages/sgl_kernel/ops/__init__.py", line 118, in int8_scaled_mm
        return torch.ops.sgl_kernels.int8_scaled_mm(
        File "/usr/local/lib/python3.10/dist-packages/torch/_ops.py", line 1116, in __call__
        return self._op(*args, **(kwargs or {}))
        RuntimeError: gemm executioin failed, error: Error Internal

        收到推理请求后,有很大的概率触发这个错误,但也有小概率能完成一次推理。所以我没想到是算子问题,我以为是显存不足。调了半天各种显存占用的参数,后来没办法了才回过头来看实际的算子调用。

        又是跟上面类似的问题,sgl_kernel 中自定义的 int8 gemm 算子将 SM89 归类到 SM80 进行矩阵计算的 dispatch。这显然会遇到与上面类似的问题,但是我又不知道 SM89 该怎么进行 dispatch,看起来需要做很多 benchmark 或者计算才能确定。

        于是我就去翻 TensorRT-LLM 和 vLLM,让我给翻到了 vLLM 的实现,我就照着 vLLM 对 SM89 的 dispatch 逻辑抄了一遍。这次我学乖了,先看看有没有 test。跑通了 sglang/sgl-kernel/tests/test_int8_gemm.py,才去进行集成测试。

        三、sub-optimal MoE

        Using default MoE config. Performance might be sub-optimal! Config file not found at /usr/local/lib/python3.10/site-packages/sglang/srt/layers/moe/fused_moe_triton/configs/E=256,N=64,device_name=NVIDIA_L40S,dtype=int8_w8a8.json

        这是一个次要问题,看起来是一个专门的配置没有找到。后来我研究了一下,应该是 triton 版本的 fused_moe 需要读取一个在每种类型的 GPU 上都 benchmark 过的最好配置来运行。

        python  benchmark/kernels/fused_moe_triton/tuning_fused_moe_triton.py \
        --model /workspace/DeepSeek-R1-Channel-INT8 --tp-size 32 --dtype int8_w8a8 --tune

        我按照文档的要求,在 L40S 上跑了一下 bench,把最终输出的文件拷贝到对应的位置就好了。这个 bench 真的要跑好久,大概两三个小时。

        性能

        我这 4 台 L40S 的硬件配置有些特殊:它是单机 8 卡,PCIE 4.0 连接到主机,但它既不是 2-2-4,也不是 2-2-8。

        20250322:其实是硬件配置错误,详见:《刷新 32 张 L40S 运行 DeepSeek-R1-INT8 的性能数据

        其中 4 张卡直插 PCIE 上,另外 4 张卡通过一个 PCI-to-PCI Bridge 插到 PCIE 上。为了弥补这样连接的带宽缺陷,PCI-to-PCI Bridge 上还接了 2 个 100Gb/s 的 RDMA 网卡。主机上每个 NUMA 域,分别也有 2 个 100Gb/s 的 RDMA 网卡。

        所以这 8 张显卡,6 张网卡,拓扑如下所示:

        我也实在算不出来这玩意儿的互联带宽。逻辑上来说,这大概相当于用 4 PCIE 4.0 GPU + 2 RDMA 网卡的性能,所以也许这个 4 x 8卡,实际上相当于 8 x 4卡。以下性能评测结果供参考。

        下面是加载时的显存使用情况:

        [TP0] Load weight end. type=DeepseekV3ForCausalLM, dtype=torch.bfloat16, avail mem=22.43 GB, mem usage=21.27 GB.
        [TP0] Memory pool end. avail mem=7.95 GB
        [TP0] Capture cuda graph begin. This can take up to several minutes. avail mem=7.92 GB
        [TP0] Capture cuda graph end. Time elapsed: 411.41 s. avail mem=5.89 GB. mem usage=2.02 GB.
        [TP0] max_total_num_tokens=201723, chunked_prefill_size=8192, max_prefill_tokens=16384, max_running_requests=2049, context_len=163840

        下面是使用固定 200 token 输入时,不同并发下的净 decode 速度。因为 bench 速度太慢,我也懒得等最终的结果,所以这里直接节取了 log。

        # 128 并发
        [TP0] Decode batch. #running-req: 128, #token: 86816, token usage: 0.43, gen throughput (token/s): 565.73, #queue-req: 0,
        # 32 并发
        [TP0] Decode batch. #running-req: 32, #token: 8923, token usage: 0.04, gen throughput (token/s): 260.73, #queue-req: 0,
        # 4 并发
        [TP0] Decode batch. #running-req: 4, #token: 1439, token usage: 0.01, gen throughput (token/s): 42.30, #queue-req: 0,
        # 1 并发
        [TP0] Decode batch. #running-req: 1, #token: 482, token usage: 0.00, gen throughput (token/s): 26.00, #queue-req: 0,

        这是固定 200 输入,200 输出,128 并发,2 轮请求的完整压测结果:

        ============ Serving Benchmark Result ============
        Backend: sglang-oai
        Traffic request rate: 128.0
        Max reqeuest concurrency: 128
        Successful requests: 256
        Benchmark duration (s): 130.79
        Total input tokens: 51200
        Total generated tokens: 51200
        Total generated tokens (retokenized): 50992
        Request throughput (req/s): 1.96
        Input token throughput (tok/s): 391.47
        Output token throughput (tok/s): 391.47
        Total token throughput (tok/s): 782.94
        Concurrency: 127.49
        ----------------End-to-End Latency----------------
        Mean E2E Latency (ms): 65135.99
        Median E2E Latency (ms): 64974.27
        ---------------Time to First Token----------------
        Mean TTFT (ms): 17554.19
        Median TTFT (ms): 19216.02
        P99 TTFT (ms): 21662.98
        ---------------Inter-Token Latency----------------
        Mean ITL (ms): 239.84
        Median ITL (ms): 220.95
        P95 ITL (ms): 233.20
        P99 ITL (ms): 299.49
        Max ITL (ms): 16077.21
        ==================================================

        链接

        [1] https://github.com/sgl-project/sglang/pull/4418

        [2] https://huggingface.co/meituan/DeepSeek-R1-Channel-INT8

        [3] https://github.com/vllm-project/vllm/blob/main/csrc/quantization/cutlass_w8a8/scaled_mm_c2x_sm89_int8_dispatch.cuh

        理解 FlashMLA 在 DeepSeek MLA 计算过程中的位置和作用

        读过 DeepSeek V2 Paper 的人可能对下面这张图印象非常深刻,非常直观地揭示了 MLA 算法的基本原理。但结合这张图或者 MLA 公式去看 DeepSeek 在开源周发布的 FlashMLA [1],可能就一脸懵逼了。

        图1: MLA 与其它 Attention 对比 [2]

        理解 FlashMLA 代码库解决的是怎样一个问题,还需要更进一步的理解 MLA 计算的优化过程。

        DeepSeek MLA 公式

        下面这张图是 DeepSeek V2 Paper 中的 MLA 公式,相信读到这里的人都知道这是个啥。所以我不做解释,放这里主要是为了方便与下面的图进行交叉阅读。

        图2: MLA 计算公式 [2]

        MLA Naive 实现

        最直接的实现 MLA 的方式,就是实现图 2 MLA 的所有计算过程,这个计算过程也与图 1 完全一致。我以 DeepSeek V3 的参数为例,做了下面这张图。

        为简化复杂度,这张图里隐藏了两个维度,batch size 和 seq len,或者你可以把它简单地理解成,仅输入一个 token 给模型进行计算的状态。就像我在前面的博客《DeepSeek-V3 MTP 工程实现思考》中做的那样:你仅输入“how”这一个单词给模型,看看模型能给你生成什么。

        图3: MLA Naive 实现,转化为 MHA 计算

        每个绿色的方框代表一次矩阵乘/线性变换,框内是参数矩阵的名字和维度;每个白色的方框代表一个中间结果 tensor,框内是张量名字和维度;黄色的方框则代表核心的 Attention 计算,也就是图 2 中的公式 (46)。参数矩阵和中间结果 tensor 的名字与图 2 保持一致。

        在 Naive 实现中,512 维的 Latent KV cKV 被映射回对应 128 个 head,每个 head 128 维的 K kC 和 V vC,然后再拼接上位置向量 kR最终形成标准的 q、k、v,输入到标准的 Multi Head Attention 进行 Attetion 计算。与其他常见模型中 MHA 的唯一不同,可能是 head dim 192 不是 2 的 n 次方。

        Naive 实现最直观,但它的问题是在 Decode 计算时性能不够好。Decode 计算时,输入的 Q 往往只有一个 token,但需要用到所有前缀 token 的 k 和 v,也就是我们通常说的 KV Cache。Naive 实现有两种选择:

        ① 缓存 Latent KV。缓存规模小,但 Latent KV 缓存不能直接送 MHA 计算,还得经过 WUK 和 WUV 的线性映射,可以看到这是两个规模不小的矩阵计算,而且每轮都得重复计算。

        ② 缓存 KV。缓存规模大,不用重复计算,性能好。但 MLA 的一大好处就是 KV Cache 压缩,这样显存内能缓存更多 token,支持更大的 batch 和 prefix cache。如果缓存 KV,在显存上对比 MHA 就完全没有优势了。

        所以,Naive 实现可能会用于 Prefill,但在 Decode 计算时需要更好的计算方法

        MLA 优化实现

        很多人把下面这种 MLA 的优化称为矩阵吸收[3],来源是 DeepSeek V2 里面这样说:

        Fortunately, due to the associative law of matrix multiplication, we can absorb WUK into WUQ, and WUV into WO. Therefore, we do not need to compute keys and values out for each query. Through this optimization, we avoid the computational overhead for recomputing kCt and vCt during inference.

        但我更喜欢把它理解成矩阵乘法交换律。因为实际上大家发现,提前将两个参数矩阵乘起来,即把 (WUQ)TWUK 的计算结果做为新的参数矩阵,在性能上还不如分开计算[3]。既然实际计算过程是交换矩阵计算过程,从“矩阵吸收”角度思考反而更绕了。

        图4: MLA 优化实现,转化为 MQA 计算

        上图中的两个虚线箭头,显示了在优化的计算过程中,哪些参数矩阵被交换了位置。它们能交换的原因,就是从数学上这样修改是等价的(矩阵乘法交换律)。

        与图 3 相比,可以看到输入给 Attention 的 q、k、v 形状发生了明显的变化。q 的形状由 128x192 变化成了 128x576,k 的形状由 128x192 变化成了 576,v 的形状由 128x128 变化成了 512。这样一来,原来的 KV 就不存在了,新的计算过程中只剩下 Latent KV 了。而且实际上 V 也不存在了,因为 V 就是 K 的前 512 维。

        再回看图 1,你会发现,这不就是 MQA 么?而这就是实际上 FlashMLA 代码库解决的问题:提供了一个专门为 q k head dim 为 576,v head dim 为 512,v 与 k 的前 512 维重叠,q head 数不超过 128(TP 下会变少)设计,仅支持 H800/H100 GPU 的 MQA 优化算子。

        简单来说:虽然这个库叫做 FlashMLA,但它提供的 flash_mla_with_kvcache() 是个 MQA 算子,只不过这个 MQA 的输入形状有些特殊。

        小知识

        为什么会这样呢?因为开源软件约定俗成的 Attention 算子封装,仅仅指图 2 中公式(46)这一行,是不包含前后的线性变换的。开源推理框架允许用户通过配置选择不同的 Attention 算子实现,比如 FlashAttention、FlashInfer、Triton 实现等。

        虽然 MLA 算法的核心在前后的线性变换,FlashMLA 算子却不能提供这些变换。这些线性变换只能被实现在模型建模的 modeling 代码的 MLA 模块中,比如 SGLang 代码库 python/sglang/srt/models/deepseek_v2.py 文件中的 DeepseekV2AttentionMLA [4] Module。

        引用

        [1] https://github.com/deepseek-ai/FlashMLA

        [2] DeepSeek-V2: A Strong, Economical, and Efficient Mixture-of-Experts Language Model, https://arxiv.org/pdf/2405.04434v5

        [3] DeepSeek-V2 高性能推理 (1):通过矩阵吸收十倍提速 MLA 算子, https://zhuanlan.zhihu.com/p/700214123

        [4] https://github.com/sgl-project/sglang/blob/main/python/sglang/srt/models/deepseek_v2.py

        2 行代码校验大模型(如DeepSeek-R1)权重文件下载完整性

        很多人在 DeepSeek-V3/R1 爆火之后,都希望体验本地运行“满血版”模型。但是满血版模型的权重参数文件有 600 多个 G,光权重文件就拆成了 163 个。

        当你受不了 HuggingFace 官网的下载速度,用其它方法或者渠道获得了权重文件后,怎么确认这些权重文件是完整无损坏的呢?

        这里介绍一个最简单的方法,仅需要 2 行代码。

        环境

        前提 1,你已经 clone 了不含权重文件的模型 git 仓库。以 DeepSeek-R1 为例,通过下面命令可以仅 clone 代码文件到 DeepSeek-R1 目录下:

        GIT_LFS_SKIP_SMUDGE=1 git clone https://huggingface.co/deepseek-ai/DeepSeek-R1

        前提 2,你已经用某种方法下载好了权重文件。请将这些权重文件放到已 clone 的 git 仓库目录内,以 DeepSeek-R1 为例,就是将 163 个 *.safetensors 文件移动到 DeepSeek-R1 目录下。

        你也可以不移动权重文件,那么你就需要在执行第 2 行命令前将 checksum 文件移动到权重文件所在目录。

        第 1 行代码

        获得所有官方权重文件的 sha256 checksum,并保存成一个标准的 checksum 文件。这行代码需要在 git 仓库目录下执行

        git lfs ls-files -l | awk '{print $1"  "$3}' > large_files.sha256

        这行命令输出的文件内容形如:

        c2388e6b127ce6664e35c5e2529c3ce4bfc99f4f7fb6fa48e92b29ed5e4922af  model-00001-of-000163.safetensors
        5f450c75da7eb897b74a092eee65df8bb115fce81cccd2bbaeb220bd97197875 model-00002-of-000163.safetensors
        ...
        913177d9e0dfb228769e0a13a386c34b919dcbb32a430ce230979f53bf7ae5bc model-00163-of-000163.safetensors

        第 2 行代码

        根据官方权重文件的 checksum,检查本地文件的完整性。这个检查的执行速度会非常慢,因为它需要为每个文件计算 sha256sum,然后再与 checksum 文件做比对。

        sha256sum -c large_files.sha256

        这行命令的输出形如:

        model-00001-of-000163.safetensors: OK
        model-00002-of-000163.safetensors: FAILED
        ...
        model-00163-of-000163.safetensors: OK

        如果所有行的输出都是 OK,那么恭喜你,所有权重文件都没有损坏;如果有某行输出为 FAILED,就代表该文件没有通过完整性校验,你需要重新下载它。

        此方法对所有标记为 LFS 的文件均有效,并不仅限于 *.safetensors 文件,比如量化模型 *gguf 权重文件,也可以同样用此方法校验。

        单机 KTransformers 运行 DeepSeek-R1-GGUF 4 bit 量化模型 Q4_K_M 实测

        最近有些文章把 KTransformers 吹得没边儿,但是看到的实测案例非常少。我也比较好奇它的实际表现,所以来实测一下看看。

        机器配置

        硬件实测环境官方案例
        CPUIntel(R) Xeon(R) Platinum 8350C CPU @ 2.60GHz, 单插槽 32 核,64 超线程,2 插槽,2 NUMA 节点Intel (R) Xeon (R) Gold 6454S, 32 cores per socket, 2 sockets, 2 numa nodes
        内存64GB DDR4 2933MHz x 24,共 1.5 TB 内存standard DDR5-4800 server DRAM (1 TB), each socket with 8×DDR5-4800
        GPUNvidia L40S, 48GB VRAM4090D 24G VRAM

        实测环境机器配置看起来很强悍,但距离 KTransformer 首页给的官方案例配置还是有差距:

        1. 8350C 是第 3 代至强 CPU,官方案例用的 6454S 是第 4 代至强 CPU,Intel AMX 指令集只在第 4 代和第 5 代至强上支持,号称比前一代有 3~10 倍的推理性能提升;
        2. DDR4 2933 的访存带宽,跟官方案例用的 DDR5 4800,纸面数据差 60%;
        3. 虽说 L40S 比官方案例用的 4090 性能要更强,显存要更大,但目前 KTransformers 给的配置并不能完全发挥出来。

        程序环境

        基于 Pytorch 镜像 pytorch/pytorch:2.6.0-cuda12.6-cudnn9-devel ,本地编译 KTransformers 命令:make dev_install 。

        注:通过 pip install 的 KTransformers 包会在运行时 crash 在 cpuinfer.so 里,我猜测是官方的包使用了更高级的指令集,而我这台机器不支持。

        执行命令:numactl -N 1 -m 1 python ./ktransformers/local_chat.py --force_think --model_path DeepSeek-R1/ --optimize_rule_path ktransformers/optimize/optimize_rules/DeepSeek-V3-Chat.yaml --gguf_path DeepSeek-R1-GGUF/DeepSeek-R1-Q4_K_M/ --cpu_infer 33 --max_new_tokens 1000

        参数说明

        --force_think: 强制在开头输出 <think> 标签

        --model_path: 来自于 git clone https://huggingface.co/deepseek-ai/DeepSeek-V3,只需要下载代码,不需要下载参数文件

        --gguf_path: 下载的量化参数,只需要下载子目录:https://huggingface.co/unsloth/DeepSeek-R1-GGUF/tree/main/DeepSeek-R1-Q4_K_M

        --cpu_infer: 粗看代码,KTransformers 是一个线程分发任务,其余线程消费计算任务,所以官方案例这里总是配置的 2 的 N 次方 + 1。但是在实测时,是否 2 的 N 次方 + 1 区别不大。

        实测结果

        Prompt: 【《背影》全文不含标题】 请简明扼要地回答问题:这篇文章出自哪里?作者是谁?写于什么年代?

        图1:生成结果和性能

        可以看到性能,生成整篇答案花费了 4 分多钟,prefill 性能 11 toks/s,decode 性能 3.4 toks/s。这个比官方案例的性能慢了一倍多。而且这个回答太啰嗦了,没有遵循 prompt。

        图2: CPU 和 GPU 利用率

        图 2 是通过 top 和 nvidia-smi 查看的运行时的 CPU 和 GPU 利用率:可以看到内存占用 380 GB,CPU 33 核差不多用满;GPU 占用 13GB 显存,利用率大概 9%。

        图3:同样 prompt DeepSeek Chat 回答

        上图是 DeepSeek-R1 chat 官网的回答,看到比量化版好很多,至少遵循了“简明扼要地回答问题”的指令。

        其它尝试

        为了测出来最大性能,我尝试过加大 --cpu_infer 参数到 65、129,尝试过切换 optimize_rules 到 DeepSeek-V3-Chat-multi-gpu-4.yaml 或者 DeepSeek-V3-Chat-multi-gpu-8.yaml,实测都没有看到性能优化,很多甚至还劣化到 0.8 toks/s。

        但是降低 --cpu_infer 参数到 25,没有观察到性能劣化。

        观察

        从 GPU/CPU 的利用率可以看出,KTransformers 主要靠 CPU 推理,AMX 指令集和内存访问速度很关键,GPU 利用率很低,反而不关键。

        DeepSeek-R1-Q4_K_M 4 bit 量化模型较非量化模型效果有显著差距,可以观察到指令遵循都不太够。

        KTransformers 目前对计算任务的拆分,并没有实现跟随 CPU 核数线性提升性能,这说明也许里面还有很多优化可以做。

        讨论

        现在大模型动辄几百 GB,需要 N 张显卡才能运行,客观上阻碍了很多感兴趣的人去体验和创新。

        KTransformer 能用低配硬件慢速跑起来(接近)满血的模型,是非常赞的一个项目。它后续的持续优化也值得期待。

        但 CPU 推理在成本上有它的局限性,大公司也不傻,如果 CPU 成本低,为啥要买那么多 GPU 卡?

        核算推理成本要尊重客观事实,别动不动“告别天价显卡”。进行压力测试后,将合理计算的服务器成本平摊到每个请求/token上来计算。从这个角度看,大概有些人又要鼓吹“告别天价 CPU 服务器”了。

        DeepSeek-V3 MTP 工程实现思考

        一个东西从 idea 到实现,中间有着巨大的鸿沟,DeepSeek-V3 的 Multi-Token Prediction 也一样。虽然开源社区很多在一个多月前已经支持了基于 TP 的 DeepSeek-V3 推理,但是 MTP 部分目前都还在开发中,进展最快的可能是 vLLM,参见 vLLM PR #12755 [5] 。

        但我觉得这并不是一个终点,可能还有很多工程优化工作需要继续完成。下面我尽量用浅显的图表和语言来说明我的理解和思考,如有错误也欢迎指出。

        Speculative Decoding (投机解码)

        理解 MTP 首先要理解 Speculative Decoding,这里不过多介绍,仅用一张图说明 Speculative Decoding 的计算过程,便于理解后续的分析。如果希望深入了解可以观看 这个 Youtube 视频 [1]。

        图1:自回归和投机解码示例,来自视频:EAGLE and EAGLE-2 [1]

        左边展示的是常规的 LLM 自回归迭代计算过程。

        初始 prompt 是 token: "how",how 先经过 embedding 计算变成 ehow,然后经过 Decoder Layer 的计算,输出特征向量 fhow(最后一层 hidden states),经过 LM Head 转换成一个概率分布向量 phow,通过采样得到生成结果 token:"can"。

        然后 LLM 会把 can 作为新的输入,进行下一步的计算,直到 LLM 给出推理结束 token:"<EOS>"。

        自回归解码是逐 token 迭代进行的,生成一个 token,将 token 作为输入生成新的 token,直到遇到结束条件:生成 "<EOS>" 或者达到最大生成长度。

        右边展示的是 Speculative Decoding 的过程。

        初始 prompt 还是 how,但是通过其它方式(比如一个小模型,叫做草稿模型)先推测了两个草稿 token:"can、we",同时输入到目标模型。普通的 Decoder 实现仅能解码 1 个 token,这里改造成能够同时解码输出 3 个 token 的 hidden states。这样我们就能同时得到:phow, pcan 和 pwe。然后就可以跟草稿模型输出的 qhow 和 qcan 进行比较,验证是否接受草稿模型的草稿 token:can 和 we。

        图上目标模型验证结果是接受 can,但是拒绝 we,那就使用 pcan 采样,得到生成结果 token:I。这就意味着,投机解码通过一次推理,得到了两个 token:can、I实现了1倍逻辑加速:can 是推测以后得到验证的 token,I 是拒绝推测 we 以后,根据目标模型自身输出采样的 token。

        EAGLE 与 DeepSeek-V3 MTP

        EAGLE 简单说来是作者认为通过目标模型本身的特征向量(就是上面的 fhow)预测下一个 token 更准确,所以草稿模型使用了与目标模型基本相同的结构,利用了目标模型输出的特征向量(fhow)作为草稿模型输入。如果希望深入了解可以观看 这个 Youtube 视频 [1]。

        图2:EAGLE 和 DeepSeek-V3 MTP 的区别 [2][3]

        MTP 与 EAGLE 不同的点如上图所示,除了多做了一次 Norm 这种细节之外,主要是多步推理的时候的串行。EAGLE 在多步推理时,只使用到了一个草稿模型做自回归推理;MTP 在多步推理时,其实是多个草稿模型进行串行推理

        了解完上面这些背景以后,我们可以分析如果希望实现 DeepSeek-V3 MTP,都需要做哪些工作。

        MTP 实现

        1. MTP 加载

        虽然很多框架都支持了 EAGLE,但一般的实现,都只支持 1 个草稿模型。而 MTP 从设计上,需要加载多个草稿模型,每一个 MTP 层,都是一个草稿模型。

        在推理的时候,要根据不同的 step,选不同的模型进行推理。这就使得 MTP 草稿模型的加载和推理的调度比其它投机编码要复杂。

        但如果 MTP 的步长等于 1,那就相当于 1 个草稿模型,实现会简单很多。

        2. MTP Prefill

        图3:DeepSeek-V3 MTP [3]

        从上图可以看出,第 i 个 MTP Module 的输入 token,是第 i+1 个 token 到第 n 个 token,n 是当前生成的总长度。而它不仅需要 token 的 embedding,还需要 token 在前一个模型计算得到的 hidden states。

        比如 MTP Module 1 的输入,是 token 2 到 5 的 embedding 和 main model 最后一层输出的 token 2 到 5 的 hidden states。

        这也就意味着,在完成 DeepSeek-V3 的 prefill 时,需要输出最后一层的 hidden states,才能进行第 1 个 MTP 的 prefill;第一个 MTP 输出最后一层的 hidden states,才能进行第 2 个 MTP 的 prefill,以此类推。

        可以注意到:多个 MTP 的多次 prefill 计算是串行的。这意味着每增加 1 个 MTP Module,每次推理的时候就要多一轮串行的 prefill,并且多一份 kv cache。一个主模型加 N 个小模型的推理,可能会严重影响计算调度的效率,可能这也是为什么 DeepSeek-V3 只输出了 1 个 MTP Module 的原因。大概他们也认为,仅使用 1 个 MTP Module 性价比最高

        3.MTP PD 分离

        我在之前一篇博客[4]中列举了 PD 分离背后面临的很多架构选择,MTP 会让 PD 分离变得更复杂。框架有两种选择:

        选择一:Prefill 节点做 MTP Prefill:如下图所示,P 节点做完 DeepSeek-V3 Prefill 以后,保留最后一层所有 token(除了第 1 个,即index 0)的 hidden states,采样生成的第一个 token,获得 tokenid,然后将这些输入到 MTP Module 1 做 Prefill。最后将 1) DeepSeek-V3 61 层的 KV Cache; 2) DeepSeek-V3 MTP 的 KV Cache; 3) DeepSeek-V3 生成的第一个 tokenid;4) DeepSeek-V3 MTP 生成的第一个草稿 tokenid 和概率;这 4 部分传给 D 节点。

        图4:DeepSeek-V3 MTP Prefill PD 分离计算方案

        选择二:Prefill 节点不做 MTP Prefill:P 节点做完 DeepSeek-V3 Prefill 以后,把:1) DeepSeek-V3 61 层的 KV Cache; 2) 最后一层所有 token(除了第 1 个,即index 0)的 hidden states;3)所有 token (除了第 1 个,即 index 0)的 embedding。这 3 部分传给 D 节点。D 节点将生成第一个 token 的 hidden states 经过 LM Head 计算和采样获得 tokenid,然后对 MTP 进行 Prefill。

        考虑到通信量和复杂度,大概大家都会选择一,但这样 Prefill 节点就必须加载 LM Head 了,因为 MTP 依赖生成的 tokenid 做 embedding 输入。

        4. MTP MLA 算子优化

        由于 MLA 的复杂性,现在的很多 MLA 实现并不支持在 decode 单次前向计算时同时并行计算多个 Query token,所以只能通过 Batch Expansion 进行投机解码。

        Batch Expansion

        以 how [can, we] 举例,我们可以展开成 3 个请求:

        图5:投机编码的 Batch Expansion 并行计算方法

        从逻辑上来看,请求变多了,但 3 个请求放到一个 batch 中可以进行并行计算,可以共享 prefix cache (如果先做 prefill 的话),这样我们依然可以拿到 phow, pcan 和 pwe。通过并行请求也能够实现 1 次 Decode 验证多个 token。

        这里要注意一个逻辑:虽然要验证 2 个 token,但是却展开成了 3 个请求。这样如果全部两个草稿模型投机推理的 token 都被接受了,那第 3 个 token 会由目标模型自己生成这个 token 被称为 bonus token

        虽然 Batch Expansion 能解决投机编码时的并行问题,但 Batch Expansion 有一定的计算开销。在高吞吐的时候,会抵消投机编码带来的加速。更好的优化就需要 MLA 算子在单次前向计算时,同时 decode 2 个 query token,这有一定的改造成本。

        5. MTP 并行和 overlap 优化

        以 DeepSeek-V3 的参数规模,模型并行必不可少。尤其是考虑到微批计算、通信的 overlap 带来的高效率,MTP 的推理未必能像一般的草稿模型一样,单独执行。

        很有可能需要将 MTP 的推理和 Speculative Decoding 的打分、验证融入到 DeepSeek-V3 模型中。通过一次前向计算,完成:1) 草稿 token 的打分和验证;2) 生成 token 的输出;3) 新草稿 token 的生成。类似于图 4,我就不画了。

        结语

        所以个人思考,DeepSeek-V3 MTP 的最优实现方式,很大可能是将 1 层与主模型融合在一起调度,而不是按照独立模型单独执行;在 PD 分离时由 Prefill 节点同时负责 MTP 的 prefill。

        [1] EAGLE and EAGLE-2: Lossless Inference Acceleration for LLMs - Hongyang Zhang, https://www.youtube.com/watch?v=oXRSorx-Llg

        [2] EAGLE: Speculative Sampling Requires Rethinking Feature Uncertainty, https://arxiv.org/abs/2401.15077

        [3] DeepSeek-V3 Technical Report, https://arxiv.org/abs/2412.19437v1

        [4] LLM PD 分离背后的架构问题, https://yangwenbo.com/articles/reflections-on-prefilling-decoding-disaggregation-architecture.html

        [5] https://github.com/vllm-project/vllm/pull/12755

        DeepSeek 官方修正了 V3 的激活参数量说明

        在之前的博客《DeepSeek V3 模型各子模块参数量精算》中,我计算的模型激活参数量跟官方 README_WEIGHT.md 中的说明对不上。之后有读者跟我说,官方更新了激活参数量的数字。我查了一下 commit history,具体修改如下:

        DeepSeek V3 README_WEIGHTS.md commit

        可以看到,V3 模型激活参数量从 36.7 改成了 36.6,并且去掉了包含 0.9B Embedding 的说明,那基本上跟我的计算完全对上了。MTP 激活参数量从 2.4B 改成了 1.5B,也去掉了 0.9B 的 Embedding,跟我的计算还是有 0.1B 的差异。

        Anyway,这种总量统计只是为了揭示计算的大约规模,有点差异也不影响定性结论。真正有用的是你在拆分 TP、EP 等权重矩阵时,矩阵的形状是多大,要拆多少份,每份大概多大。

        为了分析像 DeepSeek V3 这样的超大模型具体参数,我写了一个小脚本,可以将 safetensors 文件里面的权重 Shape 提取出来,并且可以按不同的层级做参数量的聚合计算:

        https://github.com/solrex/solrex/blob/master/snippets/show_safetensors.py

        #!/usr/bin/env python3 import os import argparse import torch from safetensors import safe_open def print_tensor_tsv(model_dir, depth): '''Print tensor info in .safetensors into tsv format''' TENSOR_CLASS = { 'weight': 'weight', 'e_score_correction_bias': 'weight', 'weight_scale_inv': 'scale' } print('SafetensorsFile\tTensorKey\tTensorParams\tTensorType\tTensorShape') safetensor_files = sorted([f for f in os.listdir(model_dir) if f.endswith('.safetensors')]) summary = {} for filename in safetensor_files: file_path = os.path.join(model_dir, filename) with safe_open(file_path, framework='pt') as f: for key in f.keys(): tensor = f.get_tensor(key) print(f'{filename}\t{key}\t{tensor.numel()}\t{tensor.dtype}\t{tensor.shape}') lst = key.split('.') # Get suffix: .weight or .weight_scale_inv tclass = TENSOR_CLASS[lst[-1]] # Limit prefix to dep dep = min(len(lst), depth+1) if depth > 0 else len(lst) # Get summary of prefixes for prefix in ['.'.join(lst[:i]) for i in range(0, dep)]: summary[f'{tclass}[{prefix}]'] = summary.get(f'{tclass}[{prefix}]', 0) + tensor.numel() for key in sorted(summary): print(f'Summary\t{key}\t{summary[key]}\t\t') if __name__ == '__main__': parser = argparse.ArgumentParser(description='Print tensor shape and dtype of .safetensors file') parser.add_argument('model_dir', nargs='?', default='.', help='Model directory (default: $PWD)') parser.add_argument('--summary_depth', '-d', type=int, default=3, help='Summary depth of weights') args = parser.parse_args() print_tensor_tsv(args.model_dir, args.summary_depth)

        在 HuggingFace 模型根目录下执行 ./show_safetensors.py ,即可获得当前模型的所有权重 Shape 和前 3 层的聚合权重规模。可以通过 “-d” 参数调整最大聚合的层级。输出的文件是 tsv 格式的,可以导入表格进行再计算。

        以下是使用 show_safetensors.py 分析 DeepSeek V3 参数量的示例:

        $ ./show_safetensors.py -d 2
        SafetensorsFile TensorKey TensorParams TensorType TensorShape
        model-00001-of-000163.safetensors model.embed_tokens.weight 926679040 torch.bfloat16 torch.Size([129280, 7168])
        model-00001-of-000163.safetensors model.layers.0.input_layernorm.weight 7168 torch.bfloat16 torch.Size([7168])
        ...
        model-00163-of-000163.safetensors model.layers.61.shared_head.head.weight 926679040 torch.bfloat16 torch.Size([129280, 7168])
        model-00163-of-000163.safetensors model.layers.61.shared_head.norm.weight 7168 torch.bfloat16 torch.Size([7168])
        Summary scale[] 41540496
        Summary scale[model.layers] 41540496
        Summary scale[model] 41540496
        Summary weight[] 684489845504
        Summary weight[lm_head] 926679040
        Summary weight[model.embed_tokens] 926679040
        Summary weight[model.layers] 682636480256
        Summary weight[model.norm] 7168
        Summary weight[model] 683563166464

        可以看到第一列为文件名(像 model-00001-of-000163.safetensors)的行是该文件中的具体权重信息,包含 Shape 信息;第一列为 Summary 的行,是根据模型的 tensor key 名字结构, 例如 “model.layers.0.input_layernorm.weight”,按照 “.” 切成前缀,按照前缀聚合模型参数量的结果,不包含 Shape 信息。

        LLM PD 分离背后的架构问题

        PD 分离(Prefilling Decoding Disaggregation)推理是指将大模型推理的预填充阶段(P)和解码(D)阶段分离,以减少预填充与解码相互之间的影响,以便对两个阶段分别进行优化,提升 GPU 硬件的利用率,并减少推理延迟的一种推理技术方案。

        在 DistServe、Mooncake 等论文中介绍分离式架构之后,DeepSeek V3 的报告让大家更进一步意识到 PD 分离可能是影响成本和性能的关键技术。

        vLLM 对 PD 分离已经有了一个 1P1D 的实验版本。除此之外的开源框架大多还都不支持,不过很多已经在计划、实现中了。但纵览这些实现、文章或者计划,可以看到 PD 分离的架构选型上有很多问题需要思考,我尝试列举一下:

        一、PD 是否直连传输?或是否需要 KV Cache Store/Pool?

        PD 直连就是预填充节点直接将 KV Cache 发送给解码节点,它的好处是延迟低。但也意味着在整个 batch 的计算过程中锁定了P、D 节点的对应关系,一旦解码节点出现了问题,比如压力过大、服务出错、传输阻塞,在重试时无法仅调度 D 节点,需要重新进行整个预填充、解码过程。在 prompt 较长时,或者在 PD 节点数不对等的场景下,例如 2 个 P 对应到 1 个 D,重调度意味着抛弃较长或者多个 prefill batch,重调度的沉没成本较高。

        使用 KV Cache Store/Pool 是在 P 和 D 之间增加了一个中间存储,预填充节点先将 KV Cache 写到中间存储,解码节点从中间存储读。这样做数据会多传输一次,增加了延迟,也增加了一些复杂度。但好处是容错性更好,还有就是预填充阶段本身也可以利用这个中间存储做 Prefix Caching。

        中间存储也会对其它一些架构变动的复杂度产生影响,参见下面问题 四 和 五。

        目前来看,Kimi Mooncacke、vLLM 的下一步设计、阿里 RTP-LLM 都使用或者计划使用基于 KV Cache Store/Pool 的方案,DeepSeek V3 报告中没有提到这部分。

        在一些计算配比均衡、故障风险较小的场景下,比如同机多卡之间的 PD 分离,PD 直连的方案也有其简单易部署的优势。

        二、P/D 是否按层发送/接收 KV Cache?

        预填充最简单的实现是预填充节点完成第一个 token 的生成后,将所有的 KV Cache 传输给解码节点,这也是 vLLM 当前的实现。但这样实现有个问题,因为 KV Cache 的规模有可能非常大(尤其是原始 MHA),一个 batch 的 KV Cache 可能会是 GB 级别,都放在计算完成后传输,传输的延迟开销会比较大。

        Kimi Mooncacke 和阿里 RTP-LLM 都采取了按层传输的方案,这是利用了 LLM 多层计算的自然特性。在完成一层的计算以后,就将这一层的 KV Cache 发送出去。这样 KV Cache 的发送就呈流式,既能降低延迟,也能使数据的发送更平滑。还存在一个更显著的优势,是 KV Cache 占用显存的时间更短,在显存紧张的情况下显存效率更高。

        但按层发送对推理引擎的修改显然更大。我还没有看到开源的实现,猜测按层发送的引入对推理引擎的优化应该会有一定的影响,这里可能还需要一些精巧的设计才能减少影响。另外,按层发送对于 PD 非直连的场景下,中间存储的实现也会显著更复杂,QPS * num_hidden_layers,考虑到连续性可能还需要存储预分配和 session 保持。

        因此对于 MLA 这种 KV Cache 偏小的注意力实现,比如 DeepSeek V3 的 KV Cache 是 576B/token/layer,是否要做按层发送,也许要看一下实际收益。

        解码阶段和预填充阶段有所不同。解码需要多次迭代,在第一次迭代实现按层解码也没太大意义,而且涉及到计算的编排,应该需要拿到所有层的 KV Cache 才会开始计算。而且解码的计算时间比较长,如果解码的计算能够掩盖接收的延迟,不一定非要实现按层接收。

        解码时按层接收,对调度也有一定挑战。从时序上来说,先发请求给预填充,完成后再发请求给解码会更自然。同时请求预填充和解码,需要处理一些同步问题,比如预填充压力大、解码等 KV Cache 超时等等。比如像阿里 RTP-LLM,它会观测预填充的排队情况,当一个请求进入预填充执行阶段时,解码端开始启动显存申请。

        三、First Token 怎么处理

        通常来说,预填充的同时会顺便把第一个 Token 计算出来,但计算到 hidden states 还是 token id 需要做一个选择。

        计算到 hidden states 的好处是,预填充节点完全不需要加载和计算 lm_head 参数。比如 DeepSeek V3 的 lm_head 参数量是 0.9B,如果计算到 hidden states,这部分参数就完全不需要加载了。vLLM 目前就是采取的这个方式,预填充除了需要发送 KV Cache 之外,还需要发送一个 hidden states,解码时引擎也需要能支持加载 hidden states 延续计算。

        计算到 token id 的好处是,发送的数据量小。以 DeepSeek V3 为例,hidden states 7K,token id 4B,完全可以跟着控制面消息传输。解码时引擎处理也更简单,因为 token id 到 token 的 detokenizer 一般是 CPU 查表,不涉及 tensor 的特殊处理。阿里 RTP-LLM 看起来采用的是这个方案。

        四、Prefiller 和 Decoder 是否能相互转换?

        当到达请求的 prompt 长度有差异性的时候,预填充和解码就会出现压力的不均衡问题。因为整体的吞吐取决于 P 和 D 的全局资源利用,当 P 过载但 D 闲置,或者 P 闲置但 D 过载的时候,成本和性能都不是最优的。

        所以就需要考虑在 P 和 D 之间做负载均衡,要么从整个节点层面直接切换 P 和 D 的角色,要么 P 和 D 节点能够承担一些混杂的请求,比如通过 chunked prefill。

        这时候 P 和 D 是否直连对实现复杂度就有一些影响了,如果有中间存储的存在,通过 PD 转换做负载均衡的实现难度会降低很多。

        五、Decoder 能填充 KV Cache 吗?

        如果业务应用场景中会将生成的 context 也作为下一轮的输入,还可能需要考虑 Decoder 填充 KV Cache,用于下一轮的 prefix caching 复用。这时候,KV Cache Store/Pool 的存在,对流畅交互有比较大的意义。

        六、KV Cache Store/Pool 的设计抉择

        有别于我们通常的 KV 存储,由于 GPU、RDMA(IB、RoCE)、NVLink 新硬件的存在,KV Cache Store/Pool 的设计抉择点会非常多。

        在存储上,有 VRAM、DRAM、NVMe SSD,要选择 KV Cache Store 使用哪些介质。虽然对于 MHA 来说,因为 KV Cache 太大,基于 SSD 存储并不现实,但是对于 MQA、MLA 来说,NVMe SSD 并不是不可用。

        在通信上,有 TCP、NVLink、RDMA、GPU Direct RDMA、NVMe over RDMA。为了更高的性能,KV Cache Store 在数据面上可能要考虑使用更快、更直接的传输方法。但 RDMA 对数据访问的抽象比 TCP 复杂很多,TCP 就是一端发一端收,但 RDMA 很多是单边操作。比如数据从 A 机 VRAM 发送到 B 机 DRAM,可能有以下方法:

        • A 从 VRAM 复制到 DRAM 再写 B 的 DRAM
        • A 从 VRAM 复制到 DRAM 再让 B 读 A 的 DRAM
        • A 直接从 VRAM 复制到 B 的 DRAM
        • B 直接读 A 的 VRAM

        如果再加上 NVMe over RDMA,那要考虑的东西就更多了。P 发送到 Store,D 从 Store 接收,到底要通过哪些模式支持,是需要思考的。目前来看,预填充节点更适合单边写到 Store,这样能减少状态传输,更快地释放显存,但如果预填充节点也要读 prefix cache,那情况可能反过来;解码节点可能更适合单边读 Store。

        在分布式架构上,无论是做集群式的 KV Cache Store,还是单机 side-car 式的 KV Cache Store,都需要存储一些 meta,并且在 P、D 之间传输一些控制信息。学术界有一些完全基于 RDMA 实现的分布式 KV 数据库,但目前看复杂度还是比较高,也没有开源的实现。目前业界实现还是倾向于使用传统的 RPC 方式来传输控制信息,并且通过分布式技术方案做 meta 节点的一致性、可靠性设计。

        在接口 API 上,KV Cache Store 比传统的 KV Store 要复杂一些。比如要支持写的时候分 layer 写,读的时候能读到连续的内容;还可能要支持队列式的读,写完的 layer 可以很快被读走。如果要支持 prefix caching,还存在 KV Cache 的链式关系,写的时候不仅要分 layer,还要分 page,读的时候也是。TP/SP 等并行计算机制,对 API 可能还会有一些额外的要求。

        在数据结构上,如果希望从 VRAM 直接写 Store,减少一次复制,引擎本身的 KV Cache 数据结构就需要与 Store 的数据结构进行一定程度的对齐;如果希望同时兼做 prefix caching,那 store 的数据排布就要考虑相同 prefix 的 page 更接近,甚至共享。比如用 prompt 的所有 page 的 hash 组成 string,按前缀 range 分桶,桶内对相同前缀做 merge/引用等等,这在存储优化上会是一个挑战。

        整体来看,PD 分离的实现上有很多架构问题需要抉择,目前还没有一个理想的架构方案,或许未来也会是根据不同场景有很多参数化的灵活配置。

        DeepSeek V3 模型各子模块参数量精算

        网上很多文章一般只提到 DeepSeek V3 模型的总参数量,很少有人分析各子模块的参数量。我试着让各 AI 根据配置计算一下,没有一个靠谱的,只能自己算了。(也许本文的内容后续会变成 AI 回答本问题的 RAG 养料)

        下面是根据 DeepSeek V3 开源仓库 https://huggingface.co/deepseek-ai/DeepSeek-V3,对 DeepSeek V3 各子模块参数量进行的精算,在计算复杂的 TP、DP、EP 拆分时可以用作基数参考。如有错误,烦请评论指出。

        嵌入层 Embedding

        "vocab_size": 129280, // Token 字典大小
        "hidden_size": 7168,

        DeepSeek V3 的嵌入层参数量是:

        129280 * 7168 = 926,679,040 (~0.9B)

        MLA

        "hidden_size": 7168,
        "num_key_value_heads": 128,
        "v_head_dim": 128,
        "kv_lora_rank": 512,

        "num_attention_heads": 128,
        "q_lora_rank": 1536,

        "qk_nope_head_dim": 128,
        "qk_rope_head_dim": 64,

        "num_hidden_layers": 61,

        单层 MLA 中 Q 的 LoRA 参数量是:

        7168 * 1536 + 1536 + 1536 * 128 * (128 + 64) = 48,760,320

        单层 MLA 中 KV 的 LoRA 参数量是:

        7168 * (512 + 64) + 512 + 512 * 128 * (128 + 128) = 20,906,496

        单层 MLA 中 WO 的参数量是

        128 * 128 * 7168 = 117,440,512

        pre 和 post attention layernorm 的参数量是:

        7168 * 2 = 14336

        所以 DeepSeek V3 的 MLA 部分共 61 层的总参数量是:

        (48,760,320 + 20,906,496 + 117,440,512 + 14336) * 61 = 11,414,421,504 (~11B)

        MoE

        "num_hidden_layers": 61,
        "hidden_size": 7168,
        "moe_intermediate_size": 2048, // 路由专家 MLP 的中间维度
        "n_shared_experts": 1, // 共享专家数量
        "n_routed_experts": 256, // 路由专家数量
        "first_k_dense_replace": 3, // 前几层使用dense替换MoE
        "intermediate_size": 18432, // 前3层 (9*moe_intermediate_size)

        每个专家的参数量是:

        7168 * 2048 * 3 = 44,040,192

        路由 Gate 的参数量是:

        256 * 7168 + 256 = 1,835,264

        前 3 层 dense(固定激活 8 路由专家),前 3 层参数量是:

        44,040,192 * 9 * 3 = 1,189,085,184

        后 58 层稀疏(动态激活 8 路由专家),后 58 层参数量是:

        (44,040,192 * 257 + 1,835,264) * 58 = 656,569,547,264

        所以 DeepSeek V3 的 MoE 部分的总参数量是:

        1,189,085,184 + 656,569,547,264 = 657,758,632,448 (~657B)

        每次计算激活 1 个共享专家,8 个路由专家,所以 DeepSeek V3 MoE 部分的激活参数量是:

        44,040,192 * 9 * 61 + 1,835,264 * 58 = 24,284,510,720 (~24B)

        Layer 维度

        前 3 层是 dense,没有 gate,基于上面的计算,DeepSeek V3 前 3 层每层的参数量是:

        (48,760,320 + 20,906,496 + 117,440,512 + 14336) + (44,040,192 * 9) = 583,483,392

        后 58 层是 MoE 稀疏激活专家,基于上面的计算,DeepSeek V3 后 58 层每层的参数量是:

        (48,760,320 + 20,906,496 + 117,440,512 + 14336) + (44,040,192 * 257 + 1,835,264) = 11,507,286,272

        输出层

        DeepSeek V3 输出层的 RMSNorm 和 Linear 参数量是:

        7168 和 129280 * 7168 = 926,686,208 (~0.9B)

        总参数量

        核对一下 DeepSeek V3 总参数量是否为 671B:

        583,483,392 * 3 + 11,507,286,272 * 58 + 926,679,040 * 2 + 7168 = 671,026,419,200 (~671B)

        核对一下 DeepSeek V3 激活参数量是否为 37B:

        11,414,421,504 + 24,284,510,720 + 926,679,040 * 2 + 7168 = 37,552,297,472 (~37B)

        这个与 README_WEIGHT.md 中提到的 36.7B 不同,我还没找到计算错误的地方。我理解也许是考虑到 embedding 层只是查表,并不是矩阵乘,所以实际激活参数是:

        11,414,421,504 + 24,284,510,720 + 926,679,040 + 7168 + 7168 = 36,625,625,600 (~36.6B)

        PS (20250208)

        DeepSeek 更新了 README_WEIGHT.md ,激活参数量修正成了 36.6B,也去掉了包含 0.9B 输入 embedding 的注释。

        MTP

        DeepSeek V3 MTP 的 ebedding 和输出 head 与主模型共享,enorm 和 hnorm 的权重是:

        7168 + 7168 = 14336

        eh_proj 线性变换的权重规模是:

        7168 * 14336 = 102,760,448

        增加了一层 hidden layer,即第 61 层:

        (48,760,320 + 20,906,496 + 117,440,512 + 14336) + (44,040,192 * 257 + 1,835,264) = 11,507,286,272

        加起来 DeepSeek V3 MTP 的总参数量是:

        11,507,286,272 + 102,760,448 + 14336 = 11,610,061,056 (~11.6B)

        DeepSeek V3 MTP 的激活参数量是:

        11,610,061,056 - 44,040,192 * (256 - 8) + 926,686,208 * 2 = 2,541,465,856

        这个规模比 README_WEIGHT.md 中提到的 11.5B 独立参数,和 2.4B 激活参数都略大一点。

        PS (20250208)

        DeekSeek 更新了 README_WEIGHT.md ,MTP 的激活参数量由 2.4B 改成了 1.5B,可能跟上面的激活参数一样,都减去了 embedding 层。但在我的计算里,这个应该是 1.6B :),还是略有不同。

        11,610,061,056 - 44,040,192 * (256 - 8) + 926,686,208 = 1,614,779,648 (~1.6B)

        DeepSeek V3:AI 大模型 infra 基建新高度

        AI 工程化

        2021 年初,贾扬清在阿里云开始推广 AI 工程化这个概念,我非常认同。我在 21 年中做技术规划的时候,提出“AI 到生产力的转化,需要更高的工程化能力”,并且将 AI 工程化的实施总结为几大方向:

        • 语义索引场景化
        • 算力调度混合化
        • 模型研发标准化
        • 优化技术普惠化
        • 模型超大规模化
        • 架构系统智能化

        我的 AI 工程化团队在这些方向上也取得了许多成果。

        The AI Model

        但 2022 年底 LLM 大流行以后,情况发生了一些变化。原因主要是 LLM 让 AI models 变成了 The AI model,虽然这个 model 很大,也多多少少有一些变种,但从工程实践的角度来看,它并不“复杂”。

        很多时候,工程架构解决的是复杂性问题。

        比如,TensorFlow、PyTorch、PaddlePaddle 这些训练框架简化了搭建和训练神经网络的复杂度,在一段时间内,各种结构的网络层出不穷,大部分都是依托这些框架来实现的。

        而对于 LLM 来说,模型结构相对固定,虽然也使用了框架的一些外围能力,但是模型结构核心部分已经逐渐变成全手写以达成最佳性能,典型的实现包括 FlashAttention、TRT-LLM 等。

        而且 LLM 的接口调用是自然语言,因而也变得极其简单,所有的 LLM 模型几乎可以使用同一套 API。

        当时看起来 LLM 不需要太多的架构基建工作。

        Prefix Caching 和 Kimi

        我的这个认知在思考 prefix-caching 作用的时候,有了一些改变。

        在《应该把 Prefix Caching 当作一种效果优化技术》这篇博客中,我提到 Prefix Cache Aware Scheduling 是一件非常值得做的事情。而且从 Kimi 发表的论文来看,他们已经在实践了,但其它的技术报告提到这些工程架构工作的不多。

        DeepSeek V3

        前几天 DeepSeek AI 发布了 DeepSeek V3 版本,我一边在吐槽这 670B 的模型参数太大,下载太慢,一边在阅读它的技术报告。结果发现他们在模型的部署上,玩得更高端,给了我一些新的震撼。

        首先,prefilling 和 decoding 分开部署。prefilling 4 机 32 卡,decoding 40 机 320 卡。这样一来,我之前《LLM 推理优化 Continuous Batching 及其实现》这篇博客中提到的 Continuous Batching 就不再需要了。两阶段分开后,prefill 的计算过程(长度)是确定的,其算力利用是充分的,不再需要中间停下来插入新的请求。其实 prefilling 能够分开部署,跟 DeepSeek 以前的研究也是分不开的,DeepSeek V2 引入的 MLA 对 KV Cache 做了大幅度的低秩压缩,可以显著降低 KV Cache 从 prefilling 节点传递到 decoding 节点的带宽和延迟。

        其次,MoE 专家分开部署。因为 MoE 专家的激活是 Token 级别的,也就是说每个 Token 会决定走哪个专家进行计算,分开部署就可能会带来负载均衡问题:有些专家太忙,有些专家太闲。DeepSeek V3 为了解决这个问题,还做了复杂的负载均衡策略。例如:快速识别较忙的专家,部署冗余的专家副本以承担压力;重新调整专家在不同节点的布局,尽量利用跨 GPU 带宽而减少跨节点带宽(因为 IB 比 NVLink 要慢);单卡冗余部署多专家,但通过全局的路由计算来优化专家的动态激活数量。

        DeepSeek V3 还实现了计算和通信重叠。为了掩盖分布式计算过程中进行集合通信时的开销,将计算任务分为微批。一个微批进行集合通信时,进行下一个微批的计算。

        此外,DeepSeek V3 在推理时还将 TP(Tensor)、DP(Data)、SP(Sequence)、EP(Expert)不同维度的并行化融合到了一起。单拿出来一种并行化方法也许现在的框架还是支持的,但这些方法组合在一起,我怀疑目前也没有什么推理加速框架能直接处理。

        从技术报告中揭露的这些细节可以看出,为了发挥出模型的极致性能,DeepSeek 在 AI 大模型的分布式部署上花费了很大的心思。这也让 DeepSeek V3 成为目前公开资料可以看到的最复杂、最精巧的大模型 infra 设计

        这些 idea 以前也许不是没有人想到,但是 infra 的演进是有很高研发和试错成本的。当 DeepSeek 将这些路走通以后,也许未来的很多大模型公司,大模型框架,都会往沿着这个方向继续演进。

        技术领导的杠杆和支点

        不知不觉间,在互联网软件开发这个行当,已经做了快 15 年了。偶尔和兄弟们酒酣畅聊,回想起当初懵懵懂懂闯进软件开发这个职业,才发现开始总是比后来单纯和美好!

        做个好的软件工程师

        虽然我职业身份一直自称是互联网软件工程师,但事实上我最早从事的,是芯片行业。

        2006 年到 2008 年,我课余做了一年多的芯片工具链开发,独自完成了修改 GDB 调试器支持一种新体系结构的 CPU。初生牛犊非常虎,在各种 mailinglist 里灌水,现在搜一下,还能找到:

        那时候让我想未来,我觉得我应该是希望成为 Sun Chan 那样,20多年在编译器这个行当里,经历过 MIPS、SGI、Intel 这些传奇公司,经历过日新月异的 CPU 技术变革,还在应对手机SoC 和多核的挑战。

        那时候的我,只想做一个好的软件工程师。

        认识杠杆

        在真正进入职场以后,我才发现:当你有更大的野心时,只作一个好的软件工程师是不够的。

        当我的领导第一次告诉我,公司里 C++ 工程师的平均日编码量大约在 100 行左右时,我是有点惊讶的。后来我查阅了各种数据,发现这还真是一个产业界的客观水平。后来想想这不难理解,因为你不可能投入 100% 的时间在开发工作上。有需求沟通,有调研和设计,有讨论和评审,有修改和调试,有值班和运维,等等等等。

        只用代码量来衡量一个人,是愚蠢的。

        但代码量说明了,一个人的单打独斗,很难做什么大事。

        我的师兄徐宥在一篇博客中给出了他观察到的模型:

        做出 MapReduce 框架的和写琐碎 MapReduce 程序的工程师之间的差距并不是他们的工具和编程效率,也往往不是教育背景或者经验,而是他们各自的杠杆:所带领的团队。

        在企业中,表现优异的员工,是容易获得杠杆的。

        可能先从带实习生开始,然后做新员工的 mentor,然后慢慢有自己的小团队。然后就是传统的管理工作,决策、计划、分工、控制,可能也会做一些战略和激励,与其他行业的管理工作没有本质不同。

        但回顾我十几年的实践过程,又模模糊糊觉得这些不太足够。

        像师兄那篇文章中讲的那样,我也尝试总结一下自己积累的管理模型,比如扩展一下师兄的杠杆理论,我把它叫做“技术领导的杠杆和支点”。

        杠杆和支点模型

        其实团队杠杆理论有很多人讨论,但大部分讨论都是求诸于外,比如怎么分工、怎么授权、怎么激励,最终是提升效益。这不是我想讨论的部分,因为这和其它类型团队的管理方法没什么不同。

        我想讨论的是技术领导的求诸于内。

        大部分技术团队的领导,都是技术专家出身。但我观察到一些技术专家在成为领导之后,反而会选择弱化自己的专长,甚至停止了技术思考,变成“需求和问题驱动型领导”。只有提到他面前的需求,或者执行中出现了问题,才能看到他的工作。

        还有更多的技术领导,将所有工作全部分派授权下去,不再做任何具体的工作。这可能因为实在太忙没时间做到;可能因为累了想躺一躺;可能因为环境的影响,例如“是否还需要写代码”成为衡量你是否仍是牛马的标志;也可能因为对具体工作就没有那么的热爱。

        我不否认这样的技术领导依然有可能做得很成功,但这样做的技术专家放弃了一个自己最大的竞争优势:技术上的专业性。

        技术的专业性可以作为非常有效的杠杆效应放大器。

        在解决需求和问题的同时,趁机对系统做一些治理;在短期目标与长期不一致时,做更优雅的妥协;在团队厌倦于平庸的工作时,提出更有挑战的目标;在对代码屎山忍无可忍时,做更有远见、更有可持续性的重构设计;在无人拍板而内耗时,做大胆的决策,并往往做对;与大家讨论时,不以地位而是靠逻辑来说服人。这样的团队,能够更容易地形成技术共识,达成对目标和路径的清晰理解,也更有生产力。

        当你做每件事时,不仅从技术之外,还能从技术本身去思考它对现在的作用,对未来的意义,对每个参与者的影响,并能够据此去调整自己的决策。这本身就是一种比较优势,技术领导不要轻易放弃这种优势。

        解决 hard and dirty work 可以抬高杠杆的支点。

        我也曾见过这样的技术领导,系统设计理念非常先进,时髦的概念都能包含,目标更是远大宏观。但遇到问题的时候,他总会说,你们再试试,你们再想想,你们再调研一下。最终搞得团队放弃也不敢,继续干又干不下去,有苦而不敢言。

        客观来说,有时候这些主张未必是错的,但可能团队不具备这样的能力。最近有人常说:“世界就是一个巨大的草台班子。”那认识不到自己的团队是草台班子,是领导的责任;解决不了这样的困难,也意味着领导没有认识到自己的草台本质。

        没有什么能比亲手去解决阻塞团队的问题而实现自己吹过的牛皮,更能增强团队对一个技术领导的信心;没有什么能比亲自下场去干脏活累活,更能赢得团队对你的爱戴。

        《Team Geek》书里有这样的管理建议:

        Delegate, but get your hands dirty.

        授权,但仍然亲手做些事。

        不过如何在授权和自己做事之间平衡,依然是一个技术领导要长期考虑的问题。拿我自己来说,虽然最近几年基本还能保持一万行每年的亲手编码工作,但去年就没有做到。因为去年我花了太多时间在学习 LLM 的论文,看各种实现的代码。AI 的爆发让很多人措手不及,我也是其中一个,但我不想因为自己的认知限制团队的上限。

        这就是我的“杠杆和支点模型”,希望对你也有些用。