优化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
==================================================

验证方法

使用我的 SGLang sm89-fp8 分支,编译安装 sgl-kernel,安装 sglang:

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。如果你在验证的时候发现任何问题,请不吝留言指教。

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 将这些路走通以后,也许未来的很多大模型公司,大模型框架,都会往沿着这个方向继续演进。

        应该把 Prefix Caching 当作一种效果优化技术

        我在 4 月份写的博客《LLM 推理优化 Prefix Caching 及其实现》带来了不少流量,这篇博客在 Google 搜索“Prefix Caching”时排在比较靠前的位置,也给我的微信公众号“边际效应”带来了超过 100 个关注者,由此可以看到大家对这项技术的广泛兴趣。

        新认知

        最近关于 Prefix Caching 我又产生了一次认知升级:在一个 Eureka 时刻,我忽然领悟到 Prefix Caching 实际上可以是一种效果优化手段——但让其充分发挥效能需要跟长上下文语言模型 Long-Context LLM 和基于 Prefix Cache 的调度技术相结合

        RAG v.s. Super Long Domain Specific Prefixes

        为了减少模型的幻觉,提升时效性,我们往往会采取 RAG 技术来增强模型。召回的结果本身会增加 Prompt 的长度,会加大 Context 的计算量,会影响模型的推理时长,因而召回结果的补充也是谨慎克制的,不会让 Prompt 变得非常长。

        但是 Prefix Caching 优化技术给我们开了个口子:如果把信息放在 Prompt 的共享 Prefix 中,加长 Prompt 的代价就由计算和时延,转化到了存储。而存储代价可以通过 Cache 复用来摊薄,这就是一笔很划算的经济账。

        如果把 RAG 召回的结果当作 Prompt 里的变量,那么 Prefix 就是 Prompt 里的常量,当增加变量信息的规模受到限制时,增加常量信息的规模也可以提升生成的效果。

        举个例子:如果你做 K12 领域的模型,把 12 个年级的语文知识点都放在 Prefix 里,然后再叠加 RAG,然后回答用户语文方面的提问,肯定能更好地保证生成的效果。

        所以,LLM 技术后续的一个新发展分支,也许会是超长特定领域专用的前缀设计

        Inference Time Scaling

        最近讨论很多的一个技术方向,是从基于预训练提升效果的 Scaling Law 转向基于 reasoning at inference time 的 Scaling Law,使用更多的推理计算来获得更好的效果。

        但这些 Scaling Law 还是基于“computing”的 Scaling,我认为也许会存在基于“memory”的 Scaling Law,即更长的共享 Domain Specific Prefix 带来更好的效果。因为这里的“memory”是计算的缓存,memory 的共享本质上是计算的共享。

        Long Context Large Language Model

        在无法共享计算的场景下,长上下文的大语言模型应用往往由于其计算成本或显著延迟而无用武之地。但如果基于 memory 的 Scaling Law work 的话,那长上下文大语言模型将成为必经之路

        这也许是 Moonshot 和 Gemini 早早投入百万 Token 级别长上下文模型的背后逻辑

        Prefix Cache Aware Scheduling

        在没有理想的大一统 Prefix 之前(大概率也不可能),共享 Prefix 只能是 Domain Specific 才最合适。那就意味着会有很多个共享 Prefix,而 Prefix Cache 占用的空间又是不可忽视的,所以不可能在每张卡/每台机器上都存储所有的共享 Prefix。

        这就要求在用户请求调度时,根据 Prefix 分配到不同的卡/机器/集群上。Prefix Cache 也需要能够根据请求的热度在不同的服务器间弹性扩散或者收缩,甚至在显存、内存、SSD 之间调度。其实 Kimi 的论文“Mooncake: A KVCache-centric Disaggregated Architecture for LLM Serving”就是在解决这个问题,只是我之前没有意识到这个技术的广泛重要性

        Google Search 淘气三千问: Q7~Q9

        前言见:Google Search 淘气三千问: Q1~Q5

        Q7: Google 是怎么做线上实验的?

        在我 18 年写的这篇博客《ABTest 平台设计 - 如何进行流量分桶》里,我就引用了 Google 2010 年 KDD 发表的层叠并行实验平台论文《Overlapping Experiment Infrastructure: More, Better, Faster Experimentation》。国内很多公司的在线实验平台,可能都是通过参考这篇论文而起步的,所以实验平台这块不再多说,感兴趣的可以看下论文,或者我之前的系列文章。

        这里我想说的是实验变量的控制。在上一篇博客《Google Search 淘气三千问: Q6》中提到了一些特征变量,在看原始文档时你会发现它的类型可能会是 QualityNsrVersionedFloatSignal,其实这就是一个带版本的浮点数组。

        简单看规律,那就是如果一个特征是 ground truth 类统计特征,它在协议中就只是一个数值类型;如果一个特征是模型打分类特征,它可能就会是一个带版本的数值数组。

        这也就是说,Google 在设计协议的时候,就已经考虑到了哪些特征需要做实验,而哪些不用。这会让他们的线上实验更加容易和系统化。

        BTW,在 Google ContentWarehouse API 中读到 Google 把线上实验称作 Live Experiment,简称 LE,这将对我们阅读其它参考资料时有所帮助(见Q8)。

        GoogleApi.ContentWarehouse.V1.Model.QualityNsrExperimentalNsrTeamData
        Experimental NsrTeam data. This is a proto containing versioned signals which can be used to run live experiments. This proto will not be propagated to MDU shards, but it will be populated at query time by go/web-signal-joins inside the CompressedQualitySignals subproto of PerDocData proto. See go/0DayLEs for the design doc. Note how this is only meant to be used during LEs, it should not be used for launches.

        Q8: Google 做 Live Experiment 时关注哪些核心指标?

        在美国司法部起诉 Google 的案子中,一个由 Pandu Nayak 起草的标题为《Ranking Newsletters » 2014 Q3 Ranking Newsletters » Aug 11 - Aug 15, 2014》的文件被作为证据提供,里面提到了 Google 2014 年在做 LE 时观察的几个核心指标:

        • CTR: 点击率,这个可能不用解释
        • Manual Refinement: 手工优化(Query)的平均次数,当你对搜索结果不满意时,你可能会手工修改 Query 内容再次发起搜索
        • Queries Per Task: 单任务 Query 数,解决一个需求时的平均搜索次数
        • Query lengths (in char): Query 平均长度,以字符为单位
        • Query lengths (in word): Query 平均长度,以单词为单位
        • Abandonment: 平均放弃(次数?),当你在搜索完成后不再继续搜索时,被视为一次放弃
        • Average Click Position: 平均点击位置,在搜索结果页中用户可能会点击多条结果,对多条结果的位置进行平均。
        • Duplicates: 重复搜索行为,可能是因为网络、速度等问题,导致用户重试

        这肯定不是 Google 做 LE 时观察的所有指标,但肯定是其中最重要的几个。因为这份文件讨论的内容是 2014 年 Google 用户在桌面和手机端的行为和意图差异,以决定 Google 在两端的工作计划,这在当时应该是非常重要的一件事。

        Q9: Google 怎么衡量 Query 的用户满意度?

        在《Ranking Newsletters » 2014 Q3 Ranking Newsletters » Aug 11 - Aug 15, 2014》这个文件里,还提到一个很关键的信息,就是 Google 怎么衡量搜索 Query 的用户满意度(在 one-box 直接满足的场景)。这可是搜索引擎的核心问题,因为你只有知道用户对什么满意,才能保证你的产品方向是对的。

        衡量 Query 满意度的第一个指标,是 singleton abandonment。singleton abandonment 是指一次孤立的搜索行为,即在这次搜索前用户没有搜索任何 Query,在这次搜索后用户也没有更换 Query 进行第二次搜索。

        文件里提到一个有意思的点:Google 之前的一个研究发现,非 singleton abandonment (换了很多 Query 后放弃了继续搜索)能更好的刻画不满意率,但不适合刻画满意率。虽然 singleton adandonment 不能孤立的作为一个强正向信号(用户不满意也可能放弃继续搜索),但在有直接答案的情况下,比如结果页内就能满足,比如天气、词典等,Google 认为它是一个足够的正向信号。

        衡量 Query 满意度的第二个指标,被涂黑了。但这是唯二的两个指标,我非常感兴趣,所以我进行了一个大胆的猜测。我把原文片段和猜测的部分放到了下面:

        第一个涂黑的地方太短,又很重要,因为文件说 Google 把它当成一个明确的正向信号。所以我猜测是 CTR,但又不确定有什么修饰词。Page CTR 这个词大家不常说,但是 Google AdSense Help 里对这个术语有定义。第二个涂黑的地方涂得不完全,露出了一些字母边缘,虽然长但还是能硬猜一下。希望不要误导大家。

        把 CTR 当成一个满意信号很好理解:如果 Query 通过摘要满足了,那用户就没有其他行为了,就是 singleton abandonment;如果摘要没满足,但是用户点击消费了搜索结果,然后也没有继续搜,那就是 singleton CTR

        也就是说,在衡量 One-Box(天气、词典等摘要满足的结果) 对用户 Query 满意度的影响方面,Google 使用了 singleton abandonment 和整页级别的点击率作为指标。

        Google Search 淘气三千问: Q6

        前言见:Google Search 淘气三千问: Q1~Q5

        Q6: Google 为站点设计了哪些特征?

        在 Google ContentWarehouse API 中有很多字段以 nsr 开头,有人说它代表 Neural Search Ranking,我觉得这种说法不对,因为 nsrDataProto 字段的注释是 Stripped site-level signals:

        GoogleApi.ContentWarehouse.V1.Model.PerDocData

        * nsrDataProto (type: GoogleApi.ContentWarehouse.V1.Model.QualityNsrNsrData.t, default: nil) - Stripped site-level signals, not present in the explicit nsr_* fields, nor compressed_quality_signals.

        说明 NSR 应该是站点信号,考虑到 QualityNsrNsrData 中还有一个 nsr 字段,我猜测应该是 New Site Rank,或者 Normalized Site Rank。也许这个字段以前只是一个简单的代表站点质量的信号,后来扩展成了一系列信号的组合,但是沿用了 nsr 这个前缀。

        其中字段注释非常简单,这里我尝试把他们汇编并且扩展解读一下,看看 Google 的站点特征体系都包括哪些。

        首先要解释一点,Nsr 虽然是站点信号,但是它的信号汇聚粒度并不全是站点范围,而是有个 sitechunk 的概念。我猜测 sitechunk 可能会代表一个子域,或者一个比较关键的路径前缀,比如一个网站有新闻、博客或者论坛,那就会有不同的 sitechunk。这样允许 Google 针对同一个域名下的不同的频道、标签做不同的分析。所以下面讨论到所有的站点信号,都应该理解成是 sitechunk 信号。

        QualityNsrNsrData 中,有以下这些特征:

        • smallPersonalSite:为个人博客小站提权的分数。我一直觉得谷歌对博客网站很友好,果然在站点特征体系中有专门的提权打分。
        • siteAutopilotScore:如果一个网站的内容都是自动生成的,它会被称为是一个 Autopilot Website。这个分数是描述这个站点下所有页面自动生成方面评分的一个汇总值。
        • isVideoFocusedSite:如果站点有超过一半的页面是视频播放页面,而且它又不是一些知名的视频网站,那么这个特征就是 true。
        • ugcScore:与上面分数相似,可能是这个站点每个页面是否为 UGC 内容的评分的一个汇总值。
        • videoScore:与 ugcScore 相似,可能是这个站点每个页面是否为视频播放页的评分的汇总。
        • shoppingScore:与 ugcScore 相似,可能是这个站点每个页面是否为商品购买页的评分的汇总。
        • localityScore: 与 ugcScore 相似,可能是这个站点每个页面是否为 LBS 服务页的评分的汇总,不过这里提到了一个叫做 LocalAuthority 的模块/策略,希望以后能弄懂它。
        • articleScoreV2: 与 ugcScore 相似,可能是这个站点每个页面是否为文章页的评分的汇总。
        • healthScore: 与 ugcScore 相似,可能是这个站点每个页面是否为医疗健康页的评分的汇总。
        • ymylNewsV2Score:无注释,YMYL 是 Your Money Your Life 的缩写,这里可能是指这个站点每个页面是否为敏感(健康、金融相关的)新闻页的评分的汇总。
        • clutterScore: 判断站点是否加载了很多乱七八糟的内容,比如加载了很多不同来源的广告之类。
        • clutterScores: 带版本的 clutterScore。
        • racterScores: 站点级别 AGC 分类打分;
        • titlematchScore:大概是这个网站每个网页的标题,能匹配上多少 Google Query 的一个综合评分;
        • siteQualityStddevs: 站点质量标准差,从名字判断,可能来自于站点所有网页的站点质量得分的统计。从这些方差指标可以看出,Google 很在乎站点内容的一致性,可能对页面质量参差不齐的站点有打压。
        • chromeInTotal:站点级别的 Chrome 访问量;
        • impressions:站点在 Google 搜索结果中的展现次数;
        • chardEncoded: 有人说 chard 代表 CHrome AveRage Duration,站点平均停留时间,我本来猜测可能是 CHrome Average Returning Days,或者 CHrome Average Retention Data。核心就是我觉得这是一个留存指标,留存比时长更能体现网站的受欢迎程度。但注释中又说它是 site quality predictor based on content,所以也许我的理解是错的,也许 c 是 Content?但是 hard 是什么,我实在猜不出来了。
        • chardVariance:站点(首页) chard 的方差。
        • chardScoreEncoded: 站点中所有页面的 chard 得分;
        • chardScoreVariance:站点所有页面 chard 得分的方差。
        • nsrVariance: 站点首页 nsr 与站点所有页面质量均值的差;
        • siteQualityStddev: 站点所有页面质量的方差,与 nsrVariance 不同,它衡量的是页面之间的方差;
        • tofu: 与 chard 一样,都是基于 content 算出来的一个得分。tofu 是豆腐块的意思,在网页里可能代表了页面内有多少个豆腐块区域,或者有多少个豆腐块广告。
        • pnavClicks:PNAV 大概是指 Primary Navigation,即站点的主要导航链接。这个值是对主要导航链接点击数的一个分母,可能在某个地方记录了这个站点每个导航链接的点击数,这样就能算出来哪些导航更受欢迎,也许是用在搜索结果页中展示站点的关键导航上;
        • pnav: 一个分位值,可能是主要导航链接占页面链接数比例?
        • vlq: 视频低质量模型的打分,猜测 LQ 代表 Low Quality。
        • vlqNsr: 针对低质量视频站点设计的一个额外的 nsr 打分,有可能是为了避免这些站点 nsr 得分过低,导致一些用户需求不满足(例如某类视频)。
        • siteLinkOut: 这个站点所有外链的平均得分;
        • siteLinkIn: 这个站点所有内链(反向链入的页面)的平均得分;
        • siteChunkSource: sitechunk 来源,可能是记录怎么分的 chunk;
        • spambrainLavcScores:这个没有注释,看起来是 Google 有一个 spambrain,会给站点一个 Lavc 分数,应该表示网站是否有 spam 行为的打分;
        • sitePr:站点的 PageRank。
        • nsr: 也许是最原始的 Normalized Site Rank,用一个分值代表站点质量。
        • versionedData: 实验版本的 nsr 值,当算法升级后 nsr 计算逻辑与以前不同时,先拿它用来做实验;
        • priorAdjustedNsr: 先验调整 nsr,用于判断当前站点的 nsr 在它所属的 slice 里比平均 nsr 高还是低;
        • ketoVersionedData: 带版本的 keto 数据,包括站点得分和站点得分在所有站点中的分位值。keto 可能代表了一个策略,含义未知。
        • nsrOverrideBid: 用来干预 nsr,当它的值提供并且大于 0.001 时,直接覆盖掉 nsr。也就是说可以通过人工干预调高或者调低某个站点的 nsr。
        • nsrEpoch: nsr 最早的获取时间;
        • siteChunk: nsr 对应的主 sitechunk,即分析出来的 sitechunk 对应的文档 URL;但文档中提到在一些稀有情况下,它可能基于网页中的一个标记。我猜测像一些 Single Page Application,URL 全部使用 # 页内标记,这种情况下只能使用页内标记来标记 sitechunk。
        • secondarySiteChunk: nsr 对应的二级 sitechunk,如果存在的话,划分比 sitechunk 粒度更细。
        • i18nBucket: 属于哪(几)种语言,这是一个 int 值,也许会是一个 bitmap,可以把站点放入多个语言桶中。
        • language: 站点的语言,暂不清楚与 i18nBucket 的差异,因为它也是一个 int 值。
        • isCovidLocalAuthority: 是否为 Covid 本地官方网站,也许是在疫情期间对官方网站消息的提权;
        • isElectionAuthority: 是否为(美国)选举相关的官方网站;
        • directFrac: 无注释,我猜测是 Chrome 输入 URL 直接访问的 PV 占所有访问量的占比。
        • site2vecEmbedding: 看起来像是将上面的每个站点 nsr 特征,综合起来表达成了一个稀疏的 embedding,可能是 one-hot 编码那种,也可能是稀疏模型编码;
        • site2vecEmbeddingEncoded:这里是一个压缩版本的 embedding,主要用于 SuperRoot。
        • nsrdataFromFallbackPatternKey: 如果为真,代表以上的 nsr 特征都来自于其它站点;
        • url:站点的 URL;
        • host: 站点的域名或者主机名;
        • clusterId:站点所属站群的 ID,被一个叫做 Tundra 的生态项目所使用,这个项目在文档中也出现过多次,希望后面能弄清楚它的含义。站群一般是指页面互相之间有链接的一批站点,会被用来做 SEO 提升 pagerank,看起来 Google 对这种行为是有识别的。
        • clusterUplift: 与上面提到的 Tundra 项目有关,主要看站群是不是小站,是不是本地站,可能是用于站点的提、降权;
        • metadata: 记录了一些在不同系统里查找 nsr 数据的 key,或者一些数据的生成时间。