1use frink_gguf::{ShardedGguf, TensorSource};
25
26use crate::bert_encoder::{BertEncoder, BertFfn, BertHparams, BertLayer, BertTopology};
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
33pub const BERT_ARCH: &str = "bert";
35
36const 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
50fn 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
58pub fn check_arch(arch: &str) -> Result<(), LoadError> {
61 if ENCODER_ARCHS.iter().any(|(a, _)| *a == arch) {
62 Ok(())
63 } else {
64 Err(LoadError::UnsupportedArchitecture(arch.to_string()))
65 }
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub struct EncoderSpec {
81 pub ffn: BertFfn,
82 pub topology: BertTopology,
83 pub rope_interleaved: bool,
86 pub final_norm_name: &'static str,
91}
92
93const POST: EncoderSpec = EncoderSpec {
94 ffn: BertFfn::GeluSeq,
95 topology: BertTopology::PostNormLayerNorm,
96 rope_interleaved: false,
97 final_norm_name: "",
98};
99
100pub const ENCODER_ARCHS: &[(&str, EncoderSpec)] = &[
101 ("bert", POST),
102 (
103 "nomic-bert",
104 EncoderSpec {
105 ffn: BertFfn::SwigluPar,
106 ..POST
107 },
108 ),
109 ("jina-bert-v3", POST),
114 (
118 "jina-bert-v2",
119 EncoderSpec {
120 ffn: BertFfn::GegluFusedUp,
121 ..POST
122 },
123 ),
124 (
131 "neo-bert",
132 EncoderSpec {
133 ffn: BertFfn::SwigluFusedUp,
134 topology: BertTopology::PreNormRms,
135 rope_interleaved: true,
136 final_norm_name: "enc.output_norm.weight",
137 },
138 ),
139 (
140 "eurobert",
141 EncoderSpec {
142 ffn: BertFfn::SwigluPar,
143 topology: BertTopology::PreNormRms,
144 rope_interleaved: false,
145 final_norm_name: "output_norm.weight",
146 },
147 ),
148];
149
150const JINA_V2_ALIBI_MAX_BIAS: f32 = 8.0;
152
153fn clone_matrix(m: &frink_core::WeightMatrix) -> frink_core::WeightMatrix {
160 let cols = m.cols();
161 let mut data = Vec::with_capacity(m.rows() * cols);
162 for r in 0..m.rows() {
163 data.extend_from_slice(&m.dequant_row(r));
164 }
165 frink_core::WeightMatrix::F32(frink_core::Tensor::new(data, vec![m.rows(), cols]))
166}
167
168pub fn read_bert_hparams(file: &impl TensorSource) -> Result<BertHparams, LoadError> {
171 let arch = file
172 .metadata_str("general.architecture")
173 .ok_or_else(|| LoadError::MissingHparam("general.architecture".into()))?
174 .to_string();
175 check_arch(&arch)?;
176 let p = |suffix: &str| format!("{arch}.{suffix}");
177
178 let spec = ENCODER_ARCHS
179 .iter()
180 .find(|(a, _)| *a == arch)
181 .map(|(_, s)| *s)
182 .expect("check_arch admitted this architecture");
183 let ffn = spec.ffn;
184 let n_layer = meta_u64(file, &p("block_count"))? as usize;
185 let n_embd = meta_u64(file, &p("embedding_length"))? as usize;
186 let n_ff = meta_u64(file, &p("feed_forward_length"))? as usize;
187 let n_head = meta_u64(file, &p("attention.head_count"))? as usize;
188 let n_head_kv = file
189 .metadata_u64(&p("attention.head_count_kv"))
190 .unwrap_or(n_head as u64) as usize;
191 let n_ctx_train = meta_u64(file, &p("context_length"))? as usize;
192
193 let eps_key = match spec.topology {
203 BertTopology::PostNormLayerNorm => p("attention.layer_norm_epsilon"),
204 BertTopology::PreNormRms => p("attention.layer_norm_rms_epsilon"),
205 };
206 let layer_norm_eps = match file.metadata_f32(&eps_key) {
207 Some(eps) => eps,
208 None => return Err(LoadError::MissingHparam(eps_key)),
209 };
210
211 let n_token_types = meta_u64(file, "tokenizer.ggml.token_type_count")? as usize;
214 if n_token_types == 0 {
215 return Err(refuse("tokenizer.ggml.token_type_count is 0"));
216 }
217
218 if file.metadata_bool(&p("attention.causal")).unwrap_or(false) {
221 return Err(refuse(
222 "bert.attention.causal is true, but this graph applies no mask — \
223 a causal BERT would need a decoder path",
224 ));
225 }
226
227 if n_head == 0 || n_head_kv == 0 || !n_head.is_multiple_of(n_head_kv) {
228 return Err(refuse(&format!(
229 "head_count {n_head} is not a multiple of head_count_kv {n_head_kv}"
230 )));
231 }
232 if !n_embd.is_multiple_of(n_head) {
233 return Err(refuse(&format!(
234 "embedding_length {n_embd} is not divisible by head_count {n_head}"
235 )));
236 }
237 if file.metadata_u64(&p("expert_count")).unwrap_or(0) != 0
238 || file.metadata_u64(&p("moe_every_n_layers")).unwrap_or(0) != 0
239 {
240 return Err(refuse(
241 "expert layers (nomic-bert-moe's moe_every_n_layers) are not implemented",
242 ));
243 }
244
245 let pooling = PoolingType::from_gguf(file, &arch)
249 .map_err(|e| refuse(&e.to_string()))?
250 .unwrap_or(PoolingType::None);
251
252 let cls_id = file
253 .metadata_u64("tokenizer.ggml.bos_token_id")
254 .unwrap_or(u64::from(DEFAULT_CLS_ID)) as u32;
255 let sep_id = file
256 .metadata_u64("tokenizer.ggml.seperator_token_id")
257 .unwrap_or(u64::from(DEFAULT_SEP_ID)) as u32;
258
259 let ffn = if ffn == BertFfn::GegluFusedUp && file.find_tensor("blk.0.ffn_gate.weight").is_some()
268 {
269 BertFfn::GegluPar
270 } else {
271 ffn
272 };
273 let rope_theta = (arch != BERT_ARCH && arch != "jina-bert-v2")
277 .then(|| file.metadata_f32(&p("rope.freq_base")).unwrap_or(10_000.0));
278 let _ = &spec;
281 let alibi_slopes = (arch == "jina-bert-v2")
282 .then(|| frink_core::alibi::slopes(n_head, JINA_V2_ALIBI_MAX_BIAS))
283 .flatten();
284 let head_dim = n_embd / n_head;
285 let rope_dim = file
286 .metadata_u64(&p("rope.dimension_count"))
287 .map(|v| v as usize)
288 .unwrap_or(head_dim);
289 if rope_theta.is_some() && (rope_dim == 0 || rope_dim > head_dim || !rope_dim.is_multiple_of(2))
290 {
291 return Err(refuse(&format!(
292 "rope.dimension_count is {rope_dim}, which is not an even width at or under \
293 the {head_dim}-wide head"
294 )));
295 }
296
297 Ok(BertHparams {
298 arch,
299 topology: spec.topology,
300 rope_interleaved: spec.rope_interleaved,
301 alibi_slopes,
302 rope_theta,
303 rope_dim,
304 ffn,
305 n_layer,
306 n_embd,
307 n_ff,
308 n_head,
309 n_head_kv,
310 n_ctx_train,
311 n_token_types,
312 layer_norm_eps,
313 pooling,
314 cls_id,
315 sep_id,
316 })
317}
318
319pub fn load_bert_encoder_from_path(
321 path: impl AsRef<std::path::Path>,
322) -> Result<BertEncoder, LoadError> {
323 load_bert_encoder(&ShardedGguf::open(path.as_ref())?)
324}
325
326pub fn load_bert_encoder(file: &ShardedGguf) -> Result<BertEncoder, LoadError> {
329 let hp = read_bert_hparams(file)?;
330
331 let tok_embd = load_weight_matrix(file, "token_embd.weight")?;
332 let pos_embd = match (hp.rope_theta, hp.alibi_slopes.is_some()) {
341 (None, false) => Some(load_weight_matrix(file, "position_embd.weight")?),
342 _ => {
343 if file.find_tensor("position_embd.weight").is_some() {
344 return Err(refuse(
345 "this encoder's graph carries its position in the attention (RoPE at \
346 bert.cpp:126-133, or ALiBi for jina-bert-v2) and never reads \
347 position_embd.weight; llama.cpp refuses the file as carrying an \
348 unread tensor and so does frink",
349 ));
350 }
351 None
352 }
353 };
354 if let Some(table) = &pos_embd {
355 if table.rows() != hp.n_ctx_train {
356 return Err(refuse(&format!(
357 "position_embd.weight has {} rows but {}.context_length says {} — the \
358 learned position table and the advertised context disagree",
359 table.rows(),
360 hp.arch,
361 hp.n_ctx_train
362 )));
363 }
364 }
365 if pos_embd.as_ref().is_some_and(|t| t.cols() != hp.n_embd) || tok_embd.cols() != hp.n_embd {
366 return Err(refuse(&format!(
367 "embedding tables are {} / {} wide but embedding_length is {}",
368 tok_embd.cols(),
369 pos_embd.as_ref().map_or(hp.n_embd, |t| t.cols()),
370 hp.n_embd
371 )));
372 }
373
374 let type_embd = match file.find_tensor("token_types.weight") {
385 Some(_) => {
386 let table = load_weight_matrix(file, "token_types.weight")?;
387 if table.rows() != hp.n_token_types || table.cols() != hp.n_embd {
388 return Err(refuse(&format!(
389 "token_types.weight is {}x{}, expected {}x{}",
390 table.rows(),
391 table.cols(),
392 hp.n_token_types,
393 hp.n_embd
394 )));
395 }
396 Some((0..table.rows()).map(|r| table.dequant_row(r)).collect())
397 }
398 None => None,
399 };
400
401 let (tok_norm_w, tok_norm_b, final_norm) = match hp.topology {
406 BertTopology::PostNormLayerNorm => (
407 Some(load_f32_vec(file, "token_embd_norm.weight")?),
408 Some(load_f32_vec(file, "token_embd_norm.bias")?),
409 None,
410 ),
411 BertTopology::PreNormRms => {
412 let name = ENCODER_ARCHS
413 .iter()
414 .find(|(a, _)| *a == hp.arch)
415 .map(|(_, s)| s.final_norm_name)
416 .expect("a loaded architecture is in the table");
417 (None, None, Some(load_f32_vec(file, name)?))
418 }
419 };
420
421 let mut layers = Vec::with_capacity(hp.n_layer);
422 for l in 0..hp.n_layer {
423 let b = format!("blk.{l}");
424 let fused_qkv = match hp.topology {
429 BertTopology::PreNormRms => file.find_tensor(&format!("{b}.attn_qkv.weight")).is_some(),
430 BertTopology::PostNormLayerNorm => {
431 reject_tensor(
432 file,
433 &format!("{b}.attn_qkv.weight"),
434 "a fused QKV projection; this graph reads separate attn_q/attn_k/attn_v",
435 )?;
436 false
437 }
438 };
439 let split = if fused_qkv {
440 let fused = load_weight_matrix(file, &format!("{b}.attn_qkv.weight"))?;
441 let q_rows = hp.n_head * hp.head_dim();
442 let kv_rows = hp.n_head_kv * hp.head_dim();
443 if fused.rows() != q_rows + 2 * kv_rows {
444 return Err(refuse(&format!(
445 "blk.{l}.attn_qkv.weight has {} rows, expected {} (q {q_rows} + 2 x kv \
446 {kv_rows})",
447 fused.rows(),
448 q_rows + 2 * kv_rows
449 )));
450 }
451 Some(crate::qkv_fused::split_fused_weight(
452 &fused,
453 crate::qkv_fused::FusedQkvRows::from_widths(q_rows, kv_rows),
454 )?)
455 } else {
456 None
457 };
458 if hp.arch != "jina-bert-v2" {
459 reject_tensor(
460 file,
461 &format!("{b}.attn_q_norm.weight"),
462 "per-projection QK normalization (jina-bert-v3 / neo-bert), not implemented",
463 )?;
464 reject_tensor(
465 file,
466 &format!("{b}.attn_k_norm.weight"),
467 "per-projection QK normalization (jina-bert-v3 / neo-bert), not implemented",
468 )?;
469 reject_tensor(
470 file,
471 &format!("{b}.attn_norm_2.weight"),
472 "jina-bert-v2's second attention norm, not implemented",
473 )?;
474 }
475 if hp.ffn == BertFfn::GeluSeq {
480 reject_tensor(
481 file,
482 &format!("{b}.ffn_gate.weight"),
483 "a gated FFN (jina-bert-v2 GEGLU); this architecture's graph runs a plain \
484 GELU MLP (bert.cpp:179-187)",
485 )?;
486 }
487 reject_tensor(
488 file,
489 &format!("{b}.ffn_up_exps.weight"),
490 "MoE expert tensors (nomic-bert-moe), not implemented",
491 )?;
492
493 layers.push(BertLayer {
494 wq: match split.as_ref() {
495 Some((q, _, _)) => clone_matrix(q),
496 None => load_weight_matrix(file, &format!("{b}.attn_q.weight"))?,
497 },
498 bq: load_f32_vec_optional(file, &format!("{b}.attn_q.bias"))?,
499 wk: match split.as_ref() {
500 Some((_, k, _)) => clone_matrix(k),
501 None => load_weight_matrix(file, &format!("{b}.attn_k.weight"))?,
502 },
503 bk: load_f32_vec_optional(file, &format!("{b}.attn_k.bias"))?,
504 wv: match split.as_ref() {
505 Some((_, _, v)) => clone_matrix(v),
506 None => load_weight_matrix(file, &format!("{b}.attn_v.weight"))?,
507 },
508 bv: load_f32_vec_optional(file, &format!("{b}.attn_v.bias"))?,
509 wo: load_weight_matrix(file, &format!("{b}.attn_output.weight"))?,
510 bo: load_f32_vec_optional(file, &format!("{b}.attn_output.bias"))?,
511 pre_attn_norm: match hp.topology {
516 BertTopology::PostNormLayerNorm => None,
517 BertTopology::PreNormRms => {
518 Some(load_f32_vec(file, &format!("{b}.attn_norm.weight"))?)
519 }
520 },
521 pre_ffn_norm: match hp.topology {
522 BertTopology::PostNormLayerNorm => None,
523 BertTopology::PreNormRms => {
524 Some(load_f32_vec(file, &format!("{b}.ffn_norm.weight"))?)
525 }
526 },
527 attn_out_norm_w: match hp.topology {
528 BertTopology::PostNormLayerNorm => {
529 Some(load_f32_vec(file, &format!("{b}.attn_output_norm.weight"))?)
530 }
531 BertTopology::PreNormRms => None,
532 },
533 attn_out_norm_b: match hp.topology {
534 BertTopology::PostNormLayerNorm => {
535 Some(load_f32_vec(file, &format!("{b}.attn_output_norm.bias"))?)
536 }
537 BertTopology::PreNormRms => None,
538 },
539 qk_norm: match load_f32_vec_optional(file, &format!("{b}.attn_q_norm.weight"))? {
540 None => None,
541 Some(q_w) => Some(crate::bert_encoder::QkLayerNorm {
542 q_w,
543 q_b: load_f32_vec(file, &format!("{b}.attn_q_norm.bias"))?,
544 k_w: load_f32_vec(file, &format!("{b}.attn_k_norm.weight"))?,
545 k_b: load_f32_vec(file, &format!("{b}.attn_k_norm.bias"))?,
546 }),
547 },
548 attn_norm_2: match load_f32_vec_optional(file, &format!("{b}.attn_norm_2.weight"))? {
549 None => None,
550 Some(w) => Some((w, load_f32_vec(file, &format!("{b}.attn_norm_2.bias"))?)),
551 },
552 ffn_up: load_weight_matrix(file, &format!("{b}.ffn_up.weight"))?,
553 ffn_up_b: load_f32_vec_optional(file, &format!("{b}.ffn_up.bias"))?,
554 ffn_gate: match hp.ffn {
555 BertFfn::GeluSeq | BertFfn::GegluFusedUp | BertFfn::SwigluFusedUp => None,
556 BertFfn::SwigluPar | BertFfn::GegluPar => {
557 Some(load_weight_matrix(file, &format!("{b}.ffn_gate.weight"))?)
558 }
559 },
560 ffn_down: load_weight_matrix(file, &format!("{b}.ffn_down.weight"))?,
561 ffn_down_b: load_f32_vec_optional(file, &format!("{b}.ffn_down.bias"))?,
562 layer_out_norm_w: match hp.topology {
563 BertTopology::PostNormLayerNorm => Some(load_f32_vec(
564 file,
565 &format!("{b}.layer_output_norm.weight"),
566 )?),
567 BertTopology::PreNormRms => None,
568 },
569 layer_out_norm_b: match hp.topology {
570 BertTopology::PostNormLayerNorm => {
571 Some(load_f32_vec(file, &format!("{b}.layer_output_norm.bias"))?)
572 }
573 BertTopology::PreNormRms => None,
574 },
575 });
576 }
577
578 let kv_dim = hp.n_head_kv * hp.head_dim();
579 for (l, layer) in layers.iter().enumerate() {
580 for (name, m, rows) in [
581 ("attn_q", &layer.wq, hp.n_embd),
582 ("attn_k", &layer.wk, kv_dim),
583 ("attn_v", &layer.wv, kv_dim),
584 ("attn_output", &layer.wo, hp.n_embd),
585 (
588 "ffn_up",
589 &layer.ffn_up,
590 match hp.ffn {
591 BertFfn::GegluFusedUp | BertFfn::SwigluFusedUp => 2 * hp.n_ff,
592 _ => hp.n_ff,
593 },
594 ),
595 ("ffn_down", &layer.ffn_down, hp.n_embd),
596 ] {
597 if m.rows() != rows {
598 return Err(refuse(&format!(
599 "blk.{l}.{name}.weight has {} output rows, expected {rows}",
600 m.rows()
601 )));
602 }
603 }
604 }
605
606 assert_every_tensor_consumed(file)?;
607
608 Ok(BertEncoder {
609 hp,
610 tok_embd,
611 type_embd,
612 pos_embd,
613 tok_norm_w,
614 tok_norm_b,
615 final_norm,
616 layers,
617 })
618}
619
620#[cfg(test)]
621mod tests {
622 use super::*;
623
624 #[test]
628 fn an_architecture_outside_the_table_is_refused_by_name() {
629 for arch in ["nomic-bert-moe", "modern-bert", "t5encoder", "llama"] {
630 assert!(
631 !ENCODER_ARCHS.iter().any(|(a, _)| *a == arch),
632 "`{arch}` is in the table; the refusal below would be wrong"
633 );
634 let err = check_arch(arch).unwrap_err();
635 assert!(
636 matches!(&err, LoadError::UnsupportedArchitecture(a) if a == arch),
637 "{arch} was not refused: {err}"
638 );
639 }
640 assert!(check_arch(BERT_ARCH).is_ok());
641 }
642}