Skip to main content

ferrox_models/
bert_gguf_loader.rs

1//! BERT GGUF → [`BertEncoder`].
2//!
3//! Tensor names and requiredness follow llama.cpp
4//! `llama_model_bert::load_arch_tensors` (`src/models/bert.cpp`), and
5//! hparam keys follow its `load_arch_hparams` plus the shared
6//! `LLM_KV_*` set.
7//!
8//! # `bert.cpp` upstream is five architectures; this is one of them
9//!
10//! `nomic-bert`, `nomic-bert-moe`, `jina-bert-v2`, `jina-bert-v3` and
11//! `neo-bert` all build their graph from the same file, each switching
12//! on `model.arch` for RoPE, a gated FFN, expert layers or a second
13//! attention norm. [`crate::bert_encoder`] implements only the plain
14//! `bert` shape, so every one of those differences is checked for here
15//! and **refused by name**: a checkpoint that carries `ffn_gate` or
16//! `attn_q_norm` is not silently run without it.
17//!
18//! The last line of defence is
19//! [`crate::loader::assert_every_tensor_consumed`], which the load ends
20//! with: for `bge-small-en-v1.5-q8_0.gguf` this graph reads all 197
21//! tensors, so any weight a variant adds and this module has no home
22//! for stops the load instead of being ignored.
23
24use ferrox_gguf::{ShardedGguf, TensorSource};
25
26use crate::bert_encoder::{BertEncoder, BertHparams, BertLayer};
27use crate::loader::{
28    assert_every_tensor_consumed, load_f32_vec, load_f32_vec_optional, load_weight_matrix,
29    LoadError,
30};
31use crate::pooling::PoolingType;
32
33/// The architecture string this loader implements.
34pub const BERT_ARCH: &str = "bert";
35
36/// llama.cpp's `tokenizer_model == "bert"` defaults, applied when the
37/// GGUF carries no explicit id (`llama-vocab.cpp`).
38const DEFAULT_CLS_ID: u32 = 101;
39const DEFAULT_SEP_ID: u32 = 102;
40
41fn meta_u64(file: &impl TensorSource, key: &str) -> Result<u64, LoadError> {
42    file.metadata_u64(key)
43        .ok_or_else(|| LoadError::MissingHparam(key.to_string()))
44}
45
46fn refuse(what: &str) -> LoadError {
47    LoadError::UnsupportedFeature(BERT_ARCH.to_string(), what.to_string())
48}
49
50/// Refuses if `name` exists, naming the upstream variant that carries it.
51fn reject_tensor(file: &ShardedGguf, name: &str, why: &str) -> Result<(), LoadError> {
52    if file.find_tensor(name).is_some() {
53        return Err(refuse(&format!("checkpoint carries '{name}': {why}")));
54    }
55    Ok(())
56}
57
58/// The whole architecture policy of this loader, in one place so it can
59/// be tested without a GGUF: `bert` and nothing else.
60pub fn check_arch(arch: &str) -> Result<(), LoadError> {
61    if arch == BERT_ARCH {
62        Ok(())
63    } else {
64        Err(LoadError::UnsupportedArchitecture(arch.to_string()))
65    }
66}
67
68/// Reads and checks `bert.*` hparams. Fails closed on anything the
69/// graph in [`crate::bert_encoder`] does not implement.
70pub fn read_bert_hparams(file: &impl TensorSource) -> Result<BertHparams, LoadError> {
71    let arch = file
72        .metadata_str("general.architecture")
73        .ok_or_else(|| LoadError::MissingHparam("general.architecture".into()))?
74        .to_string();
75    check_arch(&arch)?;
76    let p = |suffix: &str| format!("{arch}.{suffix}");
77
78    let n_layer = meta_u64(file, &p("block_count"))? as usize;
79    let n_embd = meta_u64(file, &p("embedding_length"))? as usize;
80    let n_ff = meta_u64(file, &p("feed_forward_length"))? as usize;
81    let n_head = meta_u64(file, &p("attention.head_count"))? as usize;
82    let n_head_kv = file
83        .metadata_u64(&p("attention.head_count_kv"))
84        .unwrap_or(n_head as u64) as usize;
85    let n_ctx_train = meta_u64(file, &p("context_length"))? as usize;
86
87    // `LLM_KV_ATTENTION_LAYERNORM_EPS` is read with `get_key(..., true)`
88    // upstream, i.e. required: there is no sane default for a norm this
89    // small (this checkpoint's is 1e-12, a thousand times tighter than
90    // any RMSNorm eps in the rest of this codebase).
91    let layer_norm_eps = file
92        .metadata_f32(&p("attention.layer_norm_epsilon"))
93        .ok_or_else(|| LoadError::MissingHparam(p("attention.layer_norm_epsilon")))?;
94
95    // `n_token_types` is required by upstream's own loader, which
96    // throws "model needs to define token type count".
97    let n_token_types = meta_u64(file, "tokenizer.ggml.token_type_count")? as usize;
98    if n_token_types == 0 {
99        return Err(refuse("tokenizer.ggml.token_type_count is 0"));
100    }
101
102    // An encoder is bidirectional by construction. If a checkpoint ever
103    // says otherwise, this graph is the wrong one for it.
104    if file.metadata_bool(&p("attention.causal")).unwrap_or(false) {
105        return Err(refuse(
106            "bert.attention.causal is true, but this graph applies no mask — \
107             a causal BERT would need a decoder path",
108        ));
109    }
110
111    if n_head == 0 || n_head_kv == 0 || !n_head.is_multiple_of(n_head_kv) {
112        return Err(refuse(&format!(
113            "head_count {n_head} is not a multiple of head_count_kv {n_head_kv}"
114        )));
115    }
116    if !n_embd.is_multiple_of(n_head) {
117        return Err(refuse(&format!(
118            "embedding_length {n_embd} is not divisible by head_count {n_head}"
119        )));
120    }
121    if file.metadata_u64(&p("expert_count")).unwrap_or(0) != 0
122        || file.metadata_u64(&p("moe_every_n_layers")).unwrap_or(0) != 0
123    {
124        return Err(refuse(
125            "expert layers (nomic-bert-moe's moe_every_n_layers) are not implemented",
126        ));
127    }
128
129    // Upstream defaults `hparams.pooling_type` to NONE and reads the key
130    // as optional, so an absent key means "return every row", not
131    // "guess CLS".
132    let pooling = PoolingType::from_gguf(file, &arch)
133        .map_err(|e| refuse(&e.to_string()))?
134        .unwrap_or(PoolingType::None);
135
136    let cls_id = file
137        .metadata_u64("tokenizer.ggml.bos_token_id")
138        .unwrap_or(u64::from(DEFAULT_CLS_ID)) as u32;
139    let sep_id = file
140        .metadata_u64("tokenizer.ggml.seperator_token_id")
141        .unwrap_or(u64::from(DEFAULT_SEP_ID)) as u32;
142
143    Ok(BertHparams {
144        arch,
145        n_layer,
146        n_embd,
147        n_ff,
148        n_head,
149        n_head_kv,
150        n_ctx_train,
151        n_token_types,
152        layer_norm_eps,
153        pooling,
154        cls_id,
155        sep_id,
156    })
157}
158
159/// Loads a `bert` GGUF into a runnable encoder.
160pub fn load_bert_encoder_from_path(
161    path: impl AsRef<std::path::Path>,
162) -> Result<BertEncoder, LoadError> {
163    load_bert_encoder(&ShardedGguf::open(path.as_ref())?)
164}
165
166/// Same, from an already-open file — so a caller that also needs the
167/// tokenizer out of it ([`crate::embedding_model`]) mmaps it once.
168pub fn load_bert_encoder(file: &ShardedGguf) -> Result<BertEncoder, LoadError> {
169    let hp = read_bert_hparams(file)?;
170
171    let tok_embd = load_weight_matrix(file, "token_embd.weight")?;
172    let pos_embd = load_weight_matrix(file, "position_embd.weight")?;
173    if pos_embd.rows() != hp.n_ctx_train {
174        return Err(refuse(&format!(
175            "position_embd.weight has {} rows but {}.context_length says {} — the learned \
176             position table and the advertised context disagree",
177            pos_embd.rows(),
178            hp.arch,
179            hp.n_ctx_train
180        )));
181    }
182    if pos_embd.cols() != hp.n_embd || tok_embd.cols() != hp.n_embd {
183        return Err(refuse(&format!(
184            "embedding tables are {} / {} wide but embedding_length is {}",
185            tok_embd.cols(),
186            pos_embd.cols(),
187            hp.n_embd
188        )));
189    }
190
191    // Row 0 only: upstream views `type_embd` at offset 0 and adds it,
192    // because a single-sequence embedding pass is always "Sentence A".
193    // The tensor is `TENSOR_NOT_REQUIRED` there, so its absence is not
194    // an error — but the whole table is then unused, and
195    // `assert_every_tensor_consumed` would flag any other row anyway.
196    let type_embd_row0 = match file.find_tensor("token_types.weight") {
197        Some(_) => {
198            let table = load_weight_matrix(file, "token_types.weight")?;
199            if table.rows() != hp.n_token_types || table.cols() != hp.n_embd {
200                return Err(refuse(&format!(
201                    "token_types.weight is {}x{}, expected {}x{}",
202                    table.rows(),
203                    table.cols(),
204                    hp.n_token_types,
205                    hp.n_embd
206                )));
207            }
208            Some(table.dequant_row(0))
209        }
210        None => None,
211    };
212
213    let tok_norm_w = load_f32_vec(file, "token_embd_norm.weight")?;
214    let tok_norm_b = load_f32_vec(file, "token_embd_norm.bias")?;
215
216    let mut layers = Vec::with_capacity(hp.n_layer);
217    for l in 0..hp.n_layer {
218        let b = format!("blk.{l}");
219        reject_tensor(
220            file,
221            &format!("{b}.attn_qkv.weight"),
222            "a fused QKV projection; this graph reads separate attn_q/attn_k/attn_v",
223        )?;
224        reject_tensor(
225            file,
226            &format!("{b}.attn_q_norm.weight"),
227            "per-projection QK normalization (jina-bert-v3 / neo-bert), not implemented",
228        )?;
229        reject_tensor(
230            file,
231            &format!("{b}.attn_k_norm.weight"),
232            "per-projection QK normalization (jina-bert-v3 / neo-bert), not implemented",
233        )?;
234        reject_tensor(
235            file,
236            &format!("{b}.attn_norm_2.weight"),
237            "jina-bert-v2's second attention norm, not implemented",
238        )?;
239        reject_tensor(
240            file,
241            &format!("{b}.ffn_gate.weight"),
242            "a gated FFN (nomic-bert / jina-bert-v2 GEGLU); this graph runs a plain GELU MLP",
243        )?;
244        reject_tensor(
245            file,
246            &format!("{b}.ffn_up_exps.weight"),
247            "MoE expert tensors (nomic-bert-moe), not implemented",
248        )?;
249
250        layers.push(BertLayer {
251            wq: load_weight_matrix(file, &format!("{b}.attn_q.weight"))?,
252            bq: load_f32_vec_optional(file, &format!("{b}.attn_q.bias"))?,
253            wk: load_weight_matrix(file, &format!("{b}.attn_k.weight"))?,
254            bk: load_f32_vec_optional(file, &format!("{b}.attn_k.bias"))?,
255            wv: load_weight_matrix(file, &format!("{b}.attn_v.weight"))?,
256            bv: load_f32_vec_optional(file, &format!("{b}.attn_v.bias"))?,
257            wo: load_weight_matrix(file, &format!("{b}.attn_output.weight"))?,
258            bo: load_f32_vec_optional(file, &format!("{b}.attn_output.bias"))?,
259            attn_out_norm_w: load_f32_vec(file, &format!("{b}.attn_output_norm.weight"))?,
260            attn_out_norm_b: load_f32_vec(file, &format!("{b}.attn_output_norm.bias"))?,
261            ffn_up: load_weight_matrix(file, &format!("{b}.ffn_up.weight"))?,
262            ffn_up_b: load_f32_vec_optional(file, &format!("{b}.ffn_up.bias"))?,
263            ffn_down: load_weight_matrix(file, &format!("{b}.ffn_down.weight"))?,
264            ffn_down_b: load_f32_vec_optional(file, &format!("{b}.ffn_down.bias"))?,
265            layer_out_norm_w: load_f32_vec(file, &format!("{b}.layer_output_norm.weight"))?,
266            layer_out_norm_b: load_f32_vec(file, &format!("{b}.layer_output_norm.bias"))?,
267        });
268    }
269
270    let kv_dim = hp.n_head_kv * hp.head_dim();
271    for (l, layer) in layers.iter().enumerate() {
272        for (name, m, rows) in [
273            ("attn_q", &layer.wq, hp.n_embd),
274            ("attn_k", &layer.wk, kv_dim),
275            ("attn_v", &layer.wv, kv_dim),
276            ("attn_output", &layer.wo, hp.n_embd),
277            ("ffn_up", &layer.ffn_up, hp.n_ff),
278            ("ffn_down", &layer.ffn_down, hp.n_embd),
279        ] {
280            if m.rows() != rows {
281                return Err(refuse(&format!(
282                    "blk.{l}.{name}.weight has {} output rows, expected {rows}",
283                    m.rows()
284                )));
285            }
286        }
287    }
288
289    assert_every_tensor_consumed(file)?;
290
291    Ok(BertEncoder {
292        hp,
293        tok_embd,
294        type_embd_row0,
295        pos_embd,
296        tok_norm_w,
297        tok_norm_b,
298        layers,
299    })
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    /// Only `bert`. The other eleven encoder rows in the catalog share
307    /// `bert.cpp` upstream, and each of them differs from this graph in
308    /// a way that would load clean and embed wrong.
309    #[test]
310    fn a_non_bert_architecture_is_refused_by_name() {
311        for arch in [
312            "nomic-bert",
313            "nomic-bert-moe",
314            "jina-bert-v2",
315            "jina-bert-v3",
316            "neo-bert",
317            "modern-bert",
318            "llama",
319        ] {
320            let err = check_arch(arch).unwrap_err();
321            assert!(
322                matches!(&err, LoadError::UnsupportedArchitecture(a) if a == arch),
323                "{arch} was not refused: {err}"
324            );
325        }
326        assert!(check_arch(BERT_ARCH).is_ok());
327    }
328}