BERT
主线位置:推理主线的 safetensors 那条路。
VarBuilder::from_mmaped_safetensors按名字取 f32 张量(见 VarBuilder、safetensors 与 mmap),BertModel::load用它们搭出 embedding 和 12 层 encoder,forward一次算完整个句子,没有 KV 缓存,也不取样。代码在candle-transformers/src/models/bert.rs(625 行)和with_tracing.rs(195 行)。
flowchart TB ids["input_ids 和 token_type_ids"] --> emb["BertEmbeddings"] emb --> enc["BertEncoder 12 层"] mask["attention_mask"] --> ext["扩展成加性 mask"] ext --> enc enc --> seq["B x T x 768"] seq -->|mean pooling| vec["句向量"] seq -->|BertForMaskedLM| mlm["词表 logits"]
本页的实验在 models-exp 的 bert 里(release),模型是本地的 bert-base-chinese(model.safetensors 412 MB,F32),平台 M2 Max、macOS 27、rustc 1.99.0。
结构对到代码
bert.rs 是照着 Hugging Face transformers 的 modeling_bert.py 一个类一个类翻译的,每个结构体上面都留着对应 Python 代码的链接。权重名字由 vb.pp(..) 一层层拼出来:
| 结构体 | 权重名(bert. 之后) | 做什么 |
|---|---|---|
BertEmbeddings | embeddings.word_embeddings、position_embeddings、token_type_embeddings、LayerNorm | 三个查表相加,再 LayerNorm |
BertSelfAttention | encoder.layer.{i}.attention.self.query / key / value | 12 个头,每头 64 维 |
BertSelfOutput | attention.output.dense、attention.output.LayerNorm | 投影、加残差、LayerNorm |
BertIntermediate | intermediate.dense | 768 → 3072,再 GELU |
BertOutput | output.dense、output.LayerNorm | 3072 → 768,加残差、LayerNorm |
BertModel | — | embeddings + encoder,forward 返回最后一层的隐状态 |
几处和 Python 版不同的地方:
- 没有 pooler。 Python 的
BertModel还有一个pooler(取[CLS]位置过dense和tanh)。candle 的BertModel::forward只返回(B, T, 768)的序列输出,句向量怎么取由调用方决定。bert-base-chinese 文件里的bert.pooler.dense.*不会被读。 Dropout是空的。Dropout::forward里只有一行// TODO和Ok(x.clone()):这个模型只能做推理。- 位置 id 总是
0..seq_len。BertEmbeddings::forward里自己生成,旁边一条注释「TODO: Proper absolute positions?」。 - softmax 用的是拼出来的
ops::softmax(max_keepdim、exp、sum_keepdim、除法四个 op),不是融合的softmax_last_dim。LayerNorm 走with_tracing::LayerNorm→candle_nn::LayerNorm,在连续输入上用融合的layer_norm(见常用层)。 - GELU 是精确的 erf 版本。
config.json里的"hidden_act": "gelu"反序列化成HiddenAct::Gelu,对应gelu_erf;GeluApproximate是 tanh 近似。后面会看到这个选择在 CPU 上很贵。
attention_mask 在 get_extended_attention_mask 里变成加到注意力分数上的值:
let attention_mask = attention_mask.to_dtype(dtype)?;
// torch.finfo(dtype).min
(attention_mask.ones_like()? - &attention_mask)?.broadcast_mul(
&Tensor::try_from(f32::MIN)?
.to_device(attention_mask.device())?
.to_dtype(dtype)?,
)
mask 为 1 的位置加 0,为 0 的位置(padding)加 f32::MIN,softmax 之后权重为 0。不传 mask 时用全 1,即每个位置都能看所有位置。
加载 bert-base-chinese:两处名字对不上
直接照 candle-examples 的写法加载(load):
plain load: Err(cannot find tensor embeddings.word_embeddings.weight)
这条报错是误导。BertModel::load 会试两种前缀:
let (embeddings, encoder) = match (
BertEmbeddings::load(vb.pp("embeddings"), config),
BertEncoder::load(vb.pp("encoder"), config),
) {
(Ok(embeddings), Ok(encoder)) => (embeddings, encoder),
(Err(err), _) | (_, Err(err)) => {
if let Some(model_type) = &config.model_type {
if let (Ok(embeddings), Ok(encoder)) = (
BertEmbeddings::load(vb.pp(format!("{model_type}.embeddings")), config),
BertEncoder::load(vb.pp(format!("{model_type}.encoder")), config),
) {
(embeddings, encoder)
} else {
return Err(err);
}
// ...
先试不带前缀,失败了再用 config.model_type(这里是 "bert")当前缀重试。重试也失败时返回的是第一次的错误,所以报的是没前缀的名字,而文件里本来就有 bert.embeddings.word_embeddings.weight。真正缺的要换成 BertForMaskedLM::load(它直接 vb.pp("bert")、不重试)才看得到:
BertForMaskedLM plain: Some("cannot find tensor bert.embeddings.LayerNorm.weight")
bert-base-chinese 是从 TensorFlow 转过来的老文件,LayerNorm 的参数叫 gamma / beta:
bert.embeddings.LayerNorm.beta F32 [768]
bert.embeddings.LayerNorm.gamma F32 [768]
而 candle_nn::layer_norm 要的是 weight / bias。Hugging Face transformers 加载时会替你改名(本机 uv 缓存里 transformers 4.57.1 的 modeling_utils.py 有一段注释「Rename LayerNorm beta & gamma params for some early models ported from Tensorflow (e.g. Bert)」),candle 不会。改名用 VarBuilder::rename_f(机制见 VarBuilder):
fn renamed(vb: VarBuilder<'static>, log: Arc<Mutex<Vec<String>>>) -> VarBuilder<'static> {
vb.rename_f(move |name: &str| {
log.lock().unwrap().push(name.to_string());
if let Some(p) = name.strip_suffix("LayerNorm.weight") {
format!("{p}LayerNorm.gamma")
} else if let Some(p) = name.strip_suffix("LayerNorm.bias") {
format!("{p}LayerNorm.beta")
} else {
// ...(BertForMaskedLM 还要两条,见下文)
name.to_string()
}
})
}
闭包拿到的是拼好前缀的完整名字。顺手把每次请求记下来,和文件里的名字对一下:
renamed load: ok; 199 names requested, file has 206 tensors
first requests: ["embeddings.word_embeddings.weight", "encoder.layer.0.attention.self.query.weight", "bert.embeddings.word_embeddings.weight"]
file tensors never requested (9): ["bert.pooler.dense.bias", "bert.pooler.dense.weight", "cls.predictions.bias", "cls.predictions.transform.LayerNorm.beta", "cls.predictions.transform.LayerNorm.gamma", "cls.predictions.transform.dense.bias", "cls.predictions.transform.dense.weight", "cls.seq_relationship.bias", "cls.seq_relationship.weight"]
- 前两次请求就是那两次失败的无前缀尝试:
match里的元组两边都会求值,BertEncoder::load也试了一次。 - 197 次成功的请求 = embeddings 5 个 + 12 层 × 16 个。
- 没用到的 9 个:pooler 2 个,预训练用的两个头
cls.predictions(掩码语言模型)和cls.seq_relationship(下一句预测)7 个。
用 BertForMaskedLM 验一下权重
句向量看不出权重加载得对不对,掩码预测可以。BertForMaskedLM 在 BertModel 上加了 cls.predictions 头,它的输出层要 cls.predictions.decoder.weight 和 .bias。bert-base-chinese 里没有这两个:输出层和词嵌入共享权重,偏置存在 cls.predictions.bias。只改 gamma / beta 时:
BertForMaskedLM gamma/beta only: Some("cannot find tensor cls.predictions.decoder.weight")
在改名闭包里再加两条,cls.predictions.decoder.weight → bert.embeddings.word_embeddings.weight,cls.predictions.decoder.bias → cls.predictions.bias。之后预测 [MASK](mlm,CPU,前 3 个及概率):
北京是中国的[MASK]都。 -> 首=0.905 古=0.054 国=0.006
我今天很高[MASK]。 -> 兴=0.815 興=0.184 傲=0.000
Rust 是一种编程语[MASK]。 -> 言=1.000 法=0.000 语=0.000
预测是对的,说明 199 个名字都落在了正确的张量上。改名闭包是共享权重的通用办法:两个名字指向文件里的同一个张量,VarBuilder 各读一份(不是零拷贝,见 safetensors 与 mmap)。
句向量:mean pooling
BertModel 输出每个 token 一个 768 维向量。句向量最常见的取法是对非 padding 的位置求平均(mean pooling),再做 L2 归一化,余弦相似度就是点积:
fn mean_pool(out: &Tensor, mask: &Tensor) -> Result<Tensor> {
let m = mask.to_dtype(DType::F32)?.unsqueeze(2)?;
let sum = out.broadcast_mul(&m)?.sum(1)?;
let pooled = sum.broadcast_div(&m.sum(1)?)?;
Ok(pooled.broadcast_div(&pooled.sqr()?.sum_keepdim(1)?.sqrt()?)?)
}
六个句子,tokenizer 开 BatchLongest 补齐成一个 (6, 18) 的批,attention_mask 来自 tokenizer(embed,CPU):
| 句子 | |
|---|---|
| s0 | 今天天气很好,我们去公园散步吧。 |
| s1 | 天气晴朗,适合出门走走。 |
| s2 | 这家餐厅的菜太难吃了。 |
| s3 | 我不喜欢这家饭店的饭菜。 |
| s4 | Rust 的所有权系统保证了内存安全。 |
| s5 | 借用检查器在编译期防止数据竞争。 |
mean pooling, cosine:
s0 1.000 0.852 0.687 0.717 0.676 0.661
s1 0.852 1.000 0.685 0.724 0.661 0.655
s2 0.687 0.685 1.000 0.912 0.658 0.636
s3 0.717 0.724 0.912 1.000 0.664 0.642
s4 0.676 0.661 0.658 0.664 1.000 0.815
s5 0.661 0.655 0.636 0.642 0.815 1.000
[CLS] vector, cosine:
s0 1.000 0.739 0.665 0.659 0.599 0.605
s1 0.739 1.000 0.704 0.696 0.637 0.624
s2 0.665 0.704 1.000 0.931 0.667 0.646
s3 0.659 0.696 0.931 1.000 0.664 0.631
s4 0.599 0.637 0.667 0.664 1.000 0.814
s5 0.605 0.624 0.646 0.631 0.814 1.000
- 每一行最大的非对角元素都是同一话题的另一句:天气(0.852)、难吃的饭(0.912)、Rust(0.815)。
- 但不相关的句子之间也有 0.63 到 0.72。bert-base-chinese 是用掩码语言模型目标训练的,没有为句子相似度训练过,向量都挤在一个窄锥里。排序可用,绝对值不能当成「相似的概率」。专门训练过的句向量模型(例如 sentence-transformers 系列)分得开得多,candle 的
bert例子默认用的就是all-MiniLM-L6-v2。 - 直接取
[CLS]位置(不过 pooler)也能分出这三组,s1 和 s0 的差距小一些。
padding 有没有被正确遮住,可以拿单独编码的句子对比:
s1 alone (14 tokens) vs in batch: max abs diff 8.009374e-8
s1 in batch without attention_mask: max abs diff 2.3064047e-2
s1 单独算(14 个 token,不补齐)和放在补齐到 18 的批里算,句向量最大差 8e-8,只是浮点误差。同一个批不传 attention_mask(forward(.., None),全 1),真实 token 也会注意到 4 个 [PAD],句向量差到 0.02。
BertModel 曾经没有 attention maskPR #1934(「bert attention mask」,首次进入 0.7.0)之前,BertModel::forward(&self, input_ids, token_type_ids) 只有两个参数,BertSelfAttention、BertAttention、BertLayer、BertEncoder 都实现 Module,forward 只收隐状态。补齐的批里每个句子都会看到自己的 [PAD],正是上面「without attention_mask」那一行的结果。这个 PR 把这四层的 forward 都改成多收一个 attention_mask,BertModel::forward 加了 Option<&Tensor>,新增 get_extended_attention_mask;提交说明里有一条「Allow for using None as a mask.」,None 时补一个全 1 的 mask。当时 mask 写死成 DType::F32,PR #2872(「Fix hardcoded f32 dtype for attention_mask. Use the model dtype for compatibility.」,首次进入 0.9.0-alpha.2,正式版 0.9.0)才改成跟随 embedding 输出的 dtype。
with_tracing:每个层带一个 span
bert.rs 的 Linear、LayerNorm 不是 candle_nn 的,而是 with_tracing 里的包装:
#[derive(Debug, Clone)]
pub struct Linear {
inner: candle_nn::Linear,
span: tracing::Span,
}
pub fn linear(d1: usize, d2: usize, vb: VarBuilder) -> Result<Linear> {
let inner = candle_nn::linear(d1, d2, vb)?;
let span = tracing::span!(tracing::Level::TRACE, "linear");
Ok(Linear { inner, span })
}
impl Module for Linear {
fn forward(&self, xs: &Tensor) -> Result<Tensor> {
let _enter = self.span.enter();
self.inner.forward(xs)
}
}
每个包装都是「内层 + 一个 TRACE 级 span」,forward 进入 span 再转发。bert.rs 自己的结构体也各有一个 span:model、embeddings、encoder、layer、attn、self-attn、softmax、self-out、inter、hidden-act、out。span 在构造层的时候创建,forward 里只 enter。没有 subscriber 时,span! 返回一个禁用的 span,enter 什么也不做(见 tracing 卷的 Span 与 Entered)。candle-examples 的 bert 用 tracing-chrome 把这些 span 写成 Chrome trace 文件(--tracing)。
这里换一个更直接的看法:装一个自己写的 tracing_subscriber::Layer,在 on_enter / on_exit 记时间,按 span 名字汇总次数、总时间和自身时间(减去期间进入的其他 span)。层之间的嵌套按进入顺序算,因为这些 span 的「父 span」是在建模型时定的,和调用关系无关。一个 (6, 18) 的批,预热两轮后的第三轮(trace):
| span | 次数 | CPU 总 ms | CPU 自身 ms | Metal 总 ms | Metal 自身 ms |
|---|---|---|---|---|---|
hidden-act | 12 | 43.692 | 43.692 | 0.010 | 0.010 |
linear | 72 | 35.965 | 35.965 | 0.486 | 0.486 |
layer-norm | 25 | 4.471 | 4.471 | 0.043 | 0.043 |
self-attn | 12 | 12.906 | 1.995 | 0.624 | 0.288 |
softmax | 12 | 0.642 | 0.642 | 0.123 | 0.123 |
self-out | 12 | 6.160 | 0.104 | 0.119 | 0.019 |
out | 12 | 14.765 | 0.164 | 0.080 | 0.016 |
inter | 12 | 52.990 | 0.013 | 0.161 | 0.003 |
attn | 12 | 19.080 | 0.014 | 0.747 | 0.005 |
layer | 12 | 86.857 | 0.022 | 0.995 | 0.006 |
embeddings | 1 | 0.331 | 0.108 | 0.117 | 0.113 |
model | 1 | 87.222 | 0.021 | 1.132 | 0.016 |
同一轮的墙钟时间(forward 之后 synchronize):CPU 87.2 ms,Metal 15.0 ms。encoder 一行和 layer 几乎一样,省略了。
- 次数对得上结构:
linear72 次 = 12 层 × 6 个(q、k、v、self-out、inter、out),layer-norm25 次 = 12 × 2 + embedding 的 1 个。 - CPU 上一半以上的时间在
hidden-act,比 72 次矩阵乘加起来还多。gelu_erf在 CPU 上是逐元素的标量循环:GeluErf只有标量版本的f32,每个元素调一次erf_f32,单线程(逐元素运算单线程见 CPU 后端)。而 tanh 近似的Gelu在开了acceleratefeature 时有f32_vec,整段交给 Accelerate 的vs_gelu。矩阵乘本身走 Accelerate 的sgemm,很快。 - Metal 上 span 只量到编码时间。 所有 span 加起来一两毫秒,墙钟时间却是十几毫秒:
forward只是把内核编进命令缓冲区,synchronize时 GPU 才算完。span 告诉你 CPU 侧哪一层编码最慢,不告诉你 GPU 在哪一层花了时间。
BertModel::load,再 tracing_subscriber::registry().with(timer).init()。第三轮 forward 之后,表里有几行?把激活换成 tanh 近似(config.hidden_act = HiddenAct::GeluApproximate,gelu):
| 激活 | CPU,每轮 | Metal(热),每轮 |
|---|---|---|
Gelu(gelu_erf) | 74.4–76.6 ms | 16.0–16.2 ms |
GeluApproximate(gelu) | 35.5–36.4 ms(一次 41.7) | 15.6–17.5 ms |
各跑 6 轮,去掉每种的第一轮。
CPU 上一次前向省掉一半多;单看一个 (6, 18, 3072) 张量,gelu_erf 2.3 ms 对 gelu 0.33 ms。句向量最大差 1.9e-4,余弦相似度矩阵最大差 2.6e-4,上面的排序不受影响。Metal 上两种激活都是一个内核,几乎没有差别。candle-examples 的 bert 有 --approximate-gelu 开关,是 PR #1410(「Speed up bert with approx gelu」)加的,当时的 README 里 bge-large-zh-v1.5 一次前向从 176.7 ms 降到 116.8 ms。
CPU 与 Metal
同一个批,time:加载(含 rename_f)后连跑几轮 forward + mean pooling + to_vec2 读回;再把 6 个句子复制 8 份拼成 (48, 18) 的批:
| CPU(Accelerate) | Metal(热) | |
|---|---|---|
加载(mmap、rename_f、复制到设备) | 60–64 ms | 65–76 ms |
(6, 18),108 个 token,进程里第一轮 | 76–92 ms | 40–46 ms |
(6, 18),之后每轮 | 74–90 ms | 15.4–17.5 ms |
(48, 18),864 个 token,每轮 | 504–620 ms | 79–87 ms |
这台机器在做这组实验时还在跑别的程序,同一设置在不同进程之间有 10% 到 20% 的波动,表里是几次运行的范围。
- Metal 快 4 到 7 倍。 12 层里
Linear的权重一共约 8500 万个(每层 4 × 768² + 2 × 768 × 3072),每个 token 约 1.7 亿次浮点运算:小批约 18 GFLOP,大批约 147 GFLOP。Metal 上分别是每秒 1.1 和 1.8 TFLOP 左右,CPU 上不到 0.3 TFLOP,其中还有一半时间不在矩阵乘上(gelu_erf)。 - 批越大 Metal 越划算。 token 数乘 8,CPU 时间乘 7 左右,Metal 只乘 5 左右:小批时 Metal 每个内核都喂不饱,固定开销占比大。
- 进程里第一轮 Metal 要 40 多毫秒,之后 16 ms:第一次用到的内核要建管线、缓冲区要第一次分配。macOS 的 Metal 库缓存没命中时第一轮会长得多(这组实验里见过一次 406 ms、一次 4.1 s),机制见 Metal 后端。
- 结果和 CPU 一致:Metal 上的余弦相似度矩阵前三位小数和上面完全相同,s1 单独算与在批里算差 2.4e-7。
- 只算句向量、批又不大时,CPU 换 tanh 近似 GELU 之后约 36 ms,和 Metal 的差距缩到两倍多。本站的服务器没有 GPU,用 bert-base-chinese 给文章算向量的讨论见 10 在本站中。
动脑筋答案
trace-late,CPU:
round 0: wall (after synchronize) 94.38 ms
round 1: wall (after synchronize) 83.07 ms
round 2: wall (after synchronize) 86.38 ms
[Cpu last round] spans: name, enters, total ms, self ms
一行都没有。span! 在执行时问当前的 subscriber 要不要这个 span;那时还没有 subscriber,得到的是一个禁用的 span,存进了结构体的 span 字段。之后再装 subscriber,这些字段里的 span 也不会变成启用的:enter 对禁用的 span 是空操作,subscriber 一次 on_enter 都收不到。所以 candle-examples 的 bert 例子在 main 里先初始化 tracing,再加载模型。
小结
bert.rs一个结构体对一个 Python 类:BertEmbeddings→ 12 个BertLayer(自注意力、输出、中间层、输出);没有 pooler,Dropout是空操作,forward返回(B, T, 768)。- 加载时先试无前缀、再试
bert.前缀,失败时报的是第一次的名字;bert-base-chinese 的 LayerNorm 叫gamma/beta,要用rename_f改名;BertForMaskedLM还要把输出层指到词嵌入上。 - mean pooling 要用 tokenizer 的
attention_mask,既传给forward,也用来求平均;不传 mask 时 padding 会改变句向量。 - bert-base-chinese 的句向量能排序、不能当绝对分数:无关句子之间余弦也有 0.6 以上。
with_tracing的包装在构造时建 span,subscriber 必须在加载模型之前装好。CPU 上 span 量出gelu_erf占一半时间,换成 tanh 近似快一倍、句向量差 2e-4;Metal 上 span 只量到编码时间。