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 模型结构伪代码是容易对上的。

  1. def forward():
  2. residual = hidden_states
  3. hidden_states = self.input_layernorm(hidden_states)
  4. # Self Attention
  5. hidden_states, self_attn_weights = self.self_attn()
  6. hidden_states = residual + hidden_states
  7. # Fully Connected
  8. residual = hidden_states
  9. hidden_states = self.post_attention_layernorm(hidden_states)
  10. hidden_states = self.mlp(hidden_states)
  11. hidden_states = residual + hidden_states
  12. outputs = (hidden_states,)
  13. return outputs

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

  1. def forward():
  2. # Self Attention
  3. if residual is None:
  4. residual = hidden_states
  5. hidden_states = self.input_layernorm(hidden_states)
  6. else:
  7. hidden_states, residual = self.input_layernorm(hidden_states, residual)
  8. hidden_states = self.self_attn(...)
  9. # Fully Connected
  10. hidden_states, residual = self.post_attention_layernorm(hidden_states, residual)
  11. hidden_states = self.mlp(hidden_states)
  12. 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。

  1. def get_rope(
  2. head_size: int,
  3. rotary_dim: int,
  4. max_position: int,
  5. base: int,
  6. is_neox_style: bool = True,
  7. rope_scaling: Optional[Dict[str, Any]] = None,
  8. dtype: Optional[torch.dtype] = None,
  9. partial_rotary_factor: float = 1.0,
  10. ) -> RotaryEmbedding:
  11. ...
  12. if partial_rotary_factor < 1.0:
  13. 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 和 TCP_IFACE 到正确的值。

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

# 主节点
NCCL_DEBUG=INFO NCCL_IB_GID_INDEX=3 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_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_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_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): 108.13
Total input tokens: 51200
Total generated tokens: 51200
Total generated tokens (retokenized): 50917
Request throughput (req/s): 2.37
Input token throughput (tok/s): 473.49
Output token throughput (tok/s): 473.49
Total token throughput (tok/s): 946.98
Concurrency: 127.38
----------------End-to-End Latency----------------
Mean E2E Latency (ms): 53802.80
Median E2E Latency (ms): 53530.89
---------------Time to First Token----------------
Mean TTFT (ms): 7768.52
Median TTFT (ms): 7769.45
P99 TTFT (ms): 12003.95
---------------Inter-Token Latency----------------
Mean ITL (ms): 232.35
Median ITL (ms): 220.48
P95 ITL (ms): 242.17
P99 ITL (ms): 270.50
Max ITL (ms): 10250.34
==================================================

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

代码改动

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

地震

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

刷新 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 的爆发让很多人措手不及,我也是其中一个,但我不想因为自己的认知限制团队的上限。

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

长期主义和高认知闭合

认知闭合需要

认知闭合需要是一个心理学术语,英文是 need for cognitive closure[1],有时候会缩写为 NFCC 或者 NCC,它描述个体在面临模糊、不确定或者复杂的情况时,强烈渴望获得一个明确、确定的答案或结论的心理需要(或动机)。

我是在光华管理学院张志学教授的讲座上第一次听到这个词,对这个词的思考给我带来一些新认知。

高认知闭合对人的影响

心理学研究认为[2]:认知闭合需要对个人的信息处理过程有显著的影响,进而可以影响到人们的判断决策。具体来说,高认知闭合的人对信息的处理倾向于简略,倾向于过度利用某些线索,不愿意吸收与原有信息不一致的新信息。因而在判断决策时,会表现出较强的启发式思维的特点,容易基于首因效应刻板印象进行判断,容易受到参照点人员身份的影响,并且为了避免再次面对不确定性,不愿接受新信息,也难以被说服。

通俗点儿来说,高认知闭合的人倾向于快速地收集信息,而不管收集到的信息是否全面;快速地做决策,往往根据第一面的印象、刻板印象来快速决策,而不顾情况的变化,往往倾向于采取经验指导的方法,而不愿意冒风险;在决策之后不愿意再面对不确定性,因而不接受新信息,也不愿重新决策。

这对我来说有点反常识,我一直认为快速决策是一个优秀的能力。从这些理论研究来看,仅仅追求快速本身是有很大缺陷的。

长期主义理念 v.s. 高认知闭合决策

我经常还遇到这类人(有时候也包括自己):在设定目标时非常理想化,也会将坚持长期主义挂在嘴边,但是在制定实现路径时,却往往只取短期收益,看不出这个路线是否能够走到长期目标。我戏称为“长期主义的短期化执行”。

我以前不太明白为何会这样?你不能说他们不是一个长期主义者或者理想主义者,因为很多时候这些目标都是正确的、困难的、值得去追求的,定下这些目标也是有挑战的。但是在执行的时候,表现出来的却是对短期计划和收益的看重,对长期计划、全面因素和隐患的忽视。

看到认知闭合理论后,我在想,也许他们不是不愿意追求长期目标,而是过于着急想为长期目标找一个解法。这个着急也许来自于环境的压力,也许来自于上级高认知闭合的压力,也许来自自己高认知闭合的心态,然后在未获得足够信息的情况下,拍脑袋做一堆决策和激进承诺,然后又僵化地去执行这些决策。

常见的情况包括:很多决策会有一个 deadline,但往往这个 deadline 是不做决策那个人拍的;很多目标需要一个达成路径,但往往这个达成路径做目标的那个人不思考;很多时候非要做长期的战略,但长期的战略反而没有经过长期的思考。这是分工的必然,也是管理者要思考解决的问题。

认知闭合理论给我们一个提醒,要警惕高认知闭合。在收集信息和决策时,要多问一下自己和团队,现在的信息是否足够支撑计划或决策了?现在的计划或决策是否要随着新情况的变化要做调整?是否有哪些新的信息被忽视了?

引用

[1] Wikipedia: Closure (psychology) , https://en.wikipedia.org/wiki/Closure_(psychology)
[2] 刘雪峰, 张志学. (2009) 认知闭合需要研究评述. 心理科学进展,Vol.17,No.1,51-55.

应该把 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”就是在解决这个问题,只是我之前没有意识到这个技术的广泛重要性

北京-山西长城1号路、黄河1号路1600公里自驾游

北京-山西长城1号旅游公路、黄河1号旅游公路 1600 公里自驾游地图执行版

每到长假都发愁怎么安排,哪哪儿都是人,机票和酒店都飞涨,拖家带口的选择困难。22 年夏天去内蒙自驾体验还不错,加上今年对山西兴趣比较大,于是五一就规划了一趟山西自驾游。没错,这是一篇从五一拖到十一的游记 (^_^)

原计划是往大同古城、云岗石窟、悬空寺等热门景点转一圈,在查询了酒店价格以后放弃了。大部分酒店的价格都是平日的 3 倍以上,比如平日 200+ 的汉庭五一期间是 900+。即使能接受这个溢价,这价格后面反映出来的需求也是恐怖的。清明假期云岗石窟已经限流了,五一期间可能会更严重。

北京-山西长城1号旅游公路、黄河1号旅游公路 1600 公里自驾游计划版

前年内蒙玩的时候,很多好看的风景都在路上。于是考虑看山西有没有风景优美的公路,看到了长城1号路和黄河1号路的建设新闻,就萌生了规划一条途经长城和黄河1号路的自驾路线的想法。在我之前,还没有看到有北京出发的同志将这两条线路连起来的规划,于是我在小红书上发布了一个路线概要设计,没想到真收到很多有价值的建议。这个路线在小红书上也累计超过了 5000 多次浏览。

实际执行路线跟最初的设计略有偏离,不过大致还是执行了下来,整体体验比内蒙克旗那次要更棒。整个行程 5 天,总里程 1647 公里,车上时间 38 个小时。

4月30日 北京-张家口 190km 3小时

孩子们中午都放假了,所以提前出发半天,躲一下五一出京的高峰。第一站选择了张家口,因为预期后面几天住的酒店都不会太好,计划在张家口住一家有泳池的豪华酒店,带孩子们玩玩水。实际上回头看,给长城1号路东线留的时间稍微短了点。

开高速错过了京礼的出口,一直走京藏多堵了一会儿。路过了鸡鸣山,在某一段正对着高速,像是平地起了座山,以后有空了可以去爬一爬。

应该是鸡鸣山吧

选的酒店是张家口北辰五洲皇冠酒店。酒店整体来说很不错,入住送水果、甜品和大堂吧饮料。泳池也挺干净的,人也少。忘了给女儿带游泳圈,租了个泳圈 10 元一次,在豪华酒店里面算亲民价了。

游泳池

晚饭在酒店附近吃了顿烀饼子,味道还不错,一家五口吃饱花了 130,蛮实惠了。

5月1日 张家口-李二口村&长城博物馆-得胜堡-丰镇 240km 7小时

我在网上搜到的天镇县长城1号路是从平远头村开始,但在平远头没有看到任何旅游公路的宣传。走到李二口村才发现长城1号旅游公路特有的红色和蓝色地面油漆,以及起点还有跨县边界的标志牌。这两个村中间还隔着座山(汗)。如果你想拍到天镇县的长城1号旅游公路的起点照片,那么最好导航到“大同长城博物馆”或者“李二口村”。

长城1号旅游公路天镇县 0 公里点
李二口村牌坊

其实从平远头村开始,公路旁边已经有明长城遗址的墩段了,也有山西省文保碑,只是公路表面没有刷漆,路边也没有旅游公路的标志牌。整个长城1号路的东段都是与长城平行的,有的离路较远,有的较近。

李二口村东边有一个建好没多久的博物馆,叫做大同长城博物馆,非常值得一看。我是在博物馆里才知道长城防线不是一条孤零零的边墙,而是由好几条边墙加墩堡组成的体系防线。大同的这道长城是比较靠外的边墙,雁门关是比较靠内的边墙。博物馆除了长城展出之外,还有一些当地的文物展,楼顶还有一个挺大的图书馆。图书馆的设计挺有特色的,人少的时候拍照应该比较出片。

大同长城博物馆
大同长城博物馆顶楼图书馆
万里长城全图

李二口村有一段依山而建的长城,可惜家人们不愿意爬山,没上去走走。村口卫生间和长城博物馆都有较大的停车场,五一去时都是免费的。

从李二口村往西到得胜堡就是网上常说的“长城一号公路东线”,这一路穿越了三个县:天镇县、阳高县和新荣区。在每个县区交界处都会有显眼的标志牌和地面涂刷,适合拍照。但要注意不要完全沿着导航行驶,而是关注路面上长城 1 号公路的标记。如果沿导航行驶,很容易沿 G512 行驶到内蒙区域内。

远望长城

东线的前半部分是山脚平原,从公路上往北看,每隔几百米就有一个长城的墩,多为夯土建造在山脚下,这个山脉是阴山的一个支脉。过了阳高县后,阴山支脉有一部分向南延伸,所以东线的后半部分需要穿过这个山脉,有一段可以俯瞰平原的山路。整个东线非常幽静,虽然经过的村子不少,但大部分村子都在路的北侧一段距离,路上遇到的车辆都很少。

山上的一座桥

东线会路过几个堡,守口堡、镇边堡、得胜堡等。这些景点只有少量的旅游开发,大部分堡里面还住着居民。感兴趣的可以下车转一转,或者放飞无人机拍一圈。

镇边堡
得胜堡

由于五一期间大同酒店太贵,选择了去内蒙的丰镇市住宿,距离得胜堡也不远。在丰镇吃了顿冰煮羊,便宜是挺便宜,但是羊肉不像義公子冰煮羊那样切的方方正正,后来上的羊肉卷也觉得味道怪怪的,不太推荐。

5月2日 拒门口-八台子-老牛湾-关河口 343km 9小时

从丰镇出来开了一段乡村小路到拒门口,这一路墩台较为不明显。因为这段的墩台距离山脉较远,已经在耕种区。从拒门口到镇宁空心箭楼,就是网上常说的“长城一号公路西段”。这段路穿越了新荣区和左云县两个县。

助马堡

西段我只走到八台子教堂,因为有时间焦虑,担心天黑前赶不到老牛湾景区。这段路中间有个威鲁堡,北侧有个月华池景区值得一看,因为旁边建着一个长城阁,可以登高望远。

长城阁

八台子教堂有两条路,一条路是穿过村子开到沟对面,远眺教堂,一条路可以开到教堂附近。

八台子教堂

整体来说,大同境内的长城一号公路自驾体验非常好,我自己认为超过内蒙克旗的达达和热阿线。因为牧区都被牧民圈地占满了,公路沿线全是低矮的铁丝网,非常煞风景。而且草原风光比较单一,长城1号公路既有人文,又有自然风光。车很少,还没有牛粪味。有机会的话,我可能还会再走一遍。

长城1号旅游公路-大同段

去老牛湾的路上经过一段高速,高速下来没多远就是偏关县,然后我又看到了熟悉的长城 1 号旅游公路。原来,从老牛湾景区到水泉镇,也改名叫做了长城1号旅游公路。这段公路都是盘山道,风景也不错,路过了建在小山包顶上的堡,还有很多大风车,很感兴趣,但没时间下车看了。

老牛湾是在入口买票,然后再开一段山路上去。我是大约 4 点到的,很多车都已经走了,所以很顺利地停到了距离老牛湾堡入口最近的停车场。看那狭窄的山路,如果高峰期来应该会堵不短时间。

老牛湾长城黄河交汇处
老牛湾堡

本来买的是老牛湾和乾坤湾联票,但时间不够了,孩子们也饿了,就没有往乾坤湾拐弯。从老牛湾往关河口驿站走就是黄河 1 号旅游公路,路上有一些观景台风景也不错,但路线离黄河比较远,路上风景一般。这一点跟加州的沿海 1 号公路差距比较大。

黄河1号旅游公路

关河口驿站是个地名,也是个酒店的名字。这个酒店位置非常赞,是偏关河入黄河的河口,悬崖上面是酒店,下面是关河口村。可惜的是假期人太多,没能住到河口的宾馆,而是安排到了往里几百米的游客集散中心。

关河口驿站和关河口村全景

5月3日 关河口-黄河1号路-五坝线-朔州 363km 9小时

从关河口往下走,两岸与黄河的落差越来越小。黄河 1 号路比较好的是路边有一些比较用心的观景台,比如硫磺窑观景台。但最刺激的观景台还是石径禅院。又叫弥佛洞,是建在黄河悬崖中的一个小寺院,很小很土,从悬崖上往下走又陡峭,但是风景真的绝美。两岸都是悬崖,黄河在这绕了个小弯,身在悬崖中间。

硫磺窑观景台
石径禅院

再往下走风景就越来越一般了,大车也越来越多,走到保德县吃了顿饭,就拐向了五寨县,准备走一下穿越芦芽山的五坝线。顺便说一下,石径禅院和五坝线都是小红书网友的推荐,听劝,非常赞!

走到五坝线入口的时候,其实已经快 4 点了。孩子又在游客中心的大停车场玩了好大一会儿无人机,固定翼泡沫无人机。北京没办法玩,出来看到大的场地就想玩会儿。本来期待不是很高,没想到越往里走越惊艳。先是河谷公路伴随着潺潺的小溪,后是碧绿的缓坡草甸,然后是挡住天空的,密密麻麻的松林,太棒了!就是道路太窄,也就是防火道的宽度,五一天气下部分路段还有结冰。上升的过程中温度降了得有 10 度,特别担心车会不会坏在路上。还好一个多小时的行程,天黑之前已经穿出了山路。

五坝线河谷
五坝线草甸
五坝线松林

晚上住在朔州的一个汉庭,早餐非常好。不知道朔州也有古城,第二天走的时候才看到,错过了。

5月4日 朔州-代县-平型关-涞源湖 303km 6小时

代县的确是个宝藏县城,文庙的门口就是停车场,边靖楼一圈就是个环岛,而且可以在缝隙里停车,阿育王塔在县政府后院里面,开车还能直接进。

代州文庙
边靖楼

年纪大了,觉得看看古建还挺有意思的,小娃们不太懂。

从代县去平型关的路上,又看到了长城1号旅游公路,不过这次是灵丘县的长城1号旅游公路,起点在天神线向平型关转向的路口。

平型关大捷纪念馆还是值得一看的,主要是了解从 1937 年七七事变到 9 月份平型关大捷,日本侵华的动向和国共两党合作、动员、联合作战的过程。这短短的两个多月在很多历史书上也就是一笔带过,但是平型关大捷纪念馆中有比较详细的记录。

平型关大捷纪念馆

最让人震撼的是最后一项展览:从平型关大捷中走出来的将帅。元帅、大将、上将、中将的照片有几面墙,少将都不配有照片(227位)。

平型关大捷纪念馆是依山而建,前、后和侧面都有停车场。后广场还有武器展出,战斗机就停放在停车场中间。这可能是我的车唯一一次能和飞机合影的机会了。

车和飞机合影

涞源湖没啥好说的,就是一个休息的中间点。

5月5日 涞源湖—北京 212km 4.5小时

最后一天就直接返京了,进京检查站秒过,没想到最后在杜家坎堵了快1小时。

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,或者一些数据的生成时间。