frink_models/bert_encoder.rs
1//! BERT: the encoder graph, transcribed from llama.cpp
2//! `src/models/bert.cpp` (`llama_model_bert::graph::graph`).
3//!
4//! Loading lives next door in [`crate::bert_gguf_loader`]; pooling in
5//! [`crate::pooling`]; the reason this is not an
6//! [`crate::engine::Engine`] in [`crate::encoder`].
7//!
8//! # The graph, and the five places it is not a decoder
9//!
10//! ```text
11//! h[i] = tok_embd[t[i]] + type_embd[seg[i]] + pos_embd[i] (1) (2)
12//! h = LayerNorm(h, token_embd_norm) (3)
13//! for each layer:
14//! q,k,v = Wq h + bq, Wk h + bk, Wv h + bv
15//! a = softmax(q·kᵀ / √head_dim) v (4)
16//! x = LayerNorm(Wo a + bo + h, attn_output_norm) (3)
17//! f = W_down · GELU(W_up x + b_up) + b_down (5)
18//! h = LayerNorm(f + x, layer_output_norm) (3)
19//! result = h (6)
20//! ```
21//!
22//! 1. **Learned position embeddings, added.** Not RoPE. `pos_embd` is a
23//! real `[n_ctx_train, n_embd]` table and position `i` is a row
24//! lookup. A learned table cannot be extrapolated, which is why
25//! [`crate::encoder::EncodeError::TooLong`] is an error and not a
26//! warning.
27//! 2. **A token-type embedding, per position.** A single-sequence
28//! embedding pass is all "Sentence A" and uses row 0, which is what
29//! upstream hardcodes — `ggml_view_1d(ctx0, model.type_embd, n_embd,
30//! 0)`, with the comment that token types are hardcoded to zero
31//! because `llama_batch` carries no segment ids. A cross-encoder
32//! PAIR is not that case: HuggingFace's `tokenizer(query, document)`
33//! emits `0…0 1…1` and `BertModel` adds row 1 to every position
34//! after the first `[SEP]`. Frink adds the row the caller names,
35//! which is row 0 for every embedding request and 0/1 for a rerank
36//! pair. Matching upstream here instead was measured, on
37//! `cross-encoder/ms-marco-MiniLM-L6-v2` against a NumPy transcription
38//! of `BertForSequenceClassification`, to put the RELEVANT document
39//! LAST in three of four rankings — see
40//! `tests/rerank_cross_encoder_ordering.rs`.
41//! 3. **LayerNorm, not RMSNorm, at three sites per layer plus one on
42//! the input.** Mean-subtracting, and every one of them carries a
43//! `bias` tensor as well as a `weight`. Substituting RMSNorm here
44//! loads fine and produces a plausible-looking vector that is wrong.
45//! 4. **No causal mask.** Row 0 attends to the last token. This is the
46//! single property that makes the whole model an encoder, and
47//! `attention_is_bidirectional_not_causal` below is the test that
48//! would go red if a mask ever appeared.
49//! 5. **A plain GELU MLP, not a gated one.** Two matrices, not three,
50//! and both carry biases. `LLM_FFN_GELU, LLM_FFN_SEQ` upstream.
51//! 6. **No output head and no logits.** The hidden states *are* the
52//! result (`res->t_embd`); this checkpoint has no `output.weight` at
53//! all.
54//!
55//! # What this module does not do
56//!
57//! Only `arch == "bert"`, and only its dense, non-RoPE, separate-QKV
58//! shape. `nomic-bert` (RoPE + gated FFN), `jina-bert-v2` (GEGLU + a
59//! second attention norm), `nomic-bert-moe` (expert layers) and
60//! `modern-bert` all share `bert.cpp` upstream and are all refused by
61//! name in the loader instead of being run through this graph.
62
63use frink_core::matmul::{gelu, layer_norm};
64use frink_core::weight_matrix::WeightMatrix;
65
66use crate::encoder::{EncodeError, PairSequence, TextEncoder};
67use crate::pooling::PoolingType;
68
69/// `bert.*` metadata, after the loader has checked it.
70#[derive(Debug, Clone)]
71pub struct BertHparams {
72 pub arch: String,
73 pub n_layer: usize,
74 pub n_embd: usize,
75 pub n_ff: usize,
76 pub n_head: usize,
77 pub n_head_kv: usize,
78 /// Height of the learned position table.
79 pub n_ctx_train: usize,
80 pub n_token_types: usize,
81 pub layer_norm_eps: f32,
82 /// NEOX RoPE on Q and K instead of a learned position table:
83 /// `Some(theta)` for the architectures `bert.cpp:126-133` rotates
84 /// (`nomic-bert`, `nomic-bert-moe`, `jina-bert-v3`), `None` for
85 /// `bert` itself, which adds `position_embd` at `:90` instead.
86 ///
87 /// One field for both facts on purpose: a file cannot have a
88 /// position table AND a rotation here, because upstream decides
89 /// both from the architecture and never reads the table for a
90 /// rotating one -- measured, libllama's load log never names
91 /// `position_embd` for `nomic-bert`.
92 pub rope_theta: Option<f32>,
93 /// How many of each head's channels rotate
94 /// (`{arch}.rope.dimension_count`), `head_dim` when the key is
95 /// absent. Only read when [`Self::rope_theta`] is `Some`.
96 pub rope_dim: usize,
97 /// The FFN this architecture runs (`bert.cpp:179-201`).
98 pub ffn: BertFfn,
99 /// Where this architecture's norms sit and what they are.
100 pub topology: BertTopology,
101 /// `true` for the NORM (interleaved-pair) rotation, `false` for
102 /// NEOX (split-half). `llama_model_rope_type` answers NORM for
103 /// `neo-bert` alone among the encoders here.
104 pub rope_interleaved: bool,
105 /// ALiBi slopes, one per head, for the architecture whose graph
106 /// carries a positional bias instead of a table or a rotation.
107 ///
108 /// `jina-bert-v2.cpp:5` sets `f_max_alibi_bias = 8.0f` as a
109 /// LITERAL and `bert.cpp:78-80` builds no `inp_pos` for it at all,
110 /// so this is the only place position enters that model. The bias
111 /// is SYMMETRIC here -- `llama-graph.cpp:442` fills the mask with
112 /// `-|p0 - p1|` for a non-causal model, where the decoder's is
113 /// `p_key - p_query` -- which is why the encoder computes its own
114 /// rather than calling the decoder's row helper.
115 pub alibi_slopes: Option<Vec<f32>>,
116 pub pooling: PoolingType,
117 /// `[CLS]` / `[SEP]`, from `tokenizer.ggml.bos_token_id` and
118 /// `tokenizer.ggml.seperator_token_id` (upstream's spelling of the
119 /// key, typo included). See [`BertEncoder::wrap_special`].
120 pub cls_id: u32,
121 pub sep_id: u32,
122}
123
124impl BertHparams {
125 pub fn head_dim(&self) -> usize {
126 self.n_embd / self.n_head
127 }
128}
129
130/// One transformer block's weights. Biases that llama.cpp marks
131/// `TENSOR_NOT_REQUIRED` are `Option`, so a checkpoint without them is
132/// run without them rather than with a silently fabricated zero vector.
133/// Where an encoder layer's norms sit, and which function they are.
134///
135/// `bert.cpp`'s graph is POST-norm with LayerNorm: attention, residual,
136/// norm, FFN, residual, norm. `neo-bert.cpp:59-118` and
137/// `eurobert.cpp:55-114` are the other shape -- RMSNorm BEFORE each
138/// block and a bare residual after it, with one final norm at the end
139/// -- and they are one topology with four table columns between them,
140/// not two graphs.
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub enum BertTopology {
143 /// `bert.cpp`: LayerNorm with biases, after each residual.
144 PostNormLayerNorm,
145 /// `neo-bert` / `eurobert`: RMSNorm with a weight and no bias,
146 /// before each block, and one final norm.
147 PreNormRms,
148}
149
150/// The two FFN shapes `bert.cpp` builds on this graph for the
151/// architectures frink serves.
152///
153/// `bert` and `nomic-bert-moe`'s dense layers take the ungated GELU
154/// with both biases (`:179-187`); `nomic-bert` takes the gated SiLU
155/// with none (`:195-201`, the final `else`). The variant is the
156/// architecture's, read once at load, so a layer body cannot ask
157/// "is there a gate tensor" and answer differently on two files.
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159pub enum BertFfn {
160 /// `down(gelu(up(x)))`, biases where the file has them.
161 GeluSeq,
162 /// `down(silu(gate(x)) * up(x))`, no biases.
163 SwigluPar,
164 /// `down(gelu(gate(x)) * up(x))`: `jina-bert-v2` with a separate
165 /// `ffn_gate` (`bert.cpp:188-194` picks `LLM_FFN_GELU` under
166 /// `LLM_FFN_PAR`, which `build_ffn` turns into `ggml_geglu_split`).
167 GegluPar,
168 /// The same function with the gate FUSED into `ffn_up`: the matrix
169 /// is `2 * n_ff` rows and `ggml_geglu` splits it, the FIRST half
170 /// being the gate (`bert.cpp:189`, `up_contains_gate`).
171 GegluFusedUp,
172 /// SwiGLU with the gate fused into `ffn_up` the same way:
173 /// `neo-bert.cpp:35,110-115` creates a `2 * n_ff`-wide matrix and
174 /// passes `LLM_FFN_SWIGLU` under `LLM_FFN_SEQ`, which is
175 /// `ggml_swiglu` over the doubled row.
176 SwigluFusedUp,
177}
178
179/// One layer's Q/K LayerNorm pair, weights and biases.
180#[derive(Debug, Clone)]
181pub struct QkLayerNorm {
182 pub q_w: Vec<f32>,
183 pub q_b: Vec<f32>,
184 pub k_w: Vec<f32>,
185 pub k_b: Vec<f32>,
186}
187
188pub struct BertLayer {
189 pub wq: WeightMatrix,
190 pub bq: Option<Vec<f32>>,
191 pub wk: WeightMatrix,
192 pub bk: Option<Vec<f32>>,
193 pub wv: WeightMatrix,
194 pub bv: Option<Vec<f32>>,
195 pub wo: WeightMatrix,
196 pub bo: Option<Vec<f32>>,
197 /// A LayerNorm over the WHOLE Q / K projection, with biases, when
198 /// the file carries one (`bert.cpp:109-123`; `jina-bert-v2.cpp:
199 /// 30-35` creates the pair optional). Not per head: the reshape at
200 /// `:110` is `n_embd_head * n_head` wide, so one norm covers every
201 /// head's channels together.
202 pub qk_norm: Option<QkLayerNorm>,
203 /// `attn_norm` / `ffn_norm`: the PRE-norm weights, `Some` exactly
204 /// for [`BertTopology::PreNormRms`].
205 pub pre_attn_norm: Option<Vec<f32>>,
206 pub pre_ffn_norm: Option<Vec<f32>>,
207 /// `attn_output_norm`, applied after the attention residual.
208 /// `Some` exactly for [`BertTopology::PostNormLayerNorm`].
209 pub attn_out_norm_w: Option<Vec<f32>>,
210 pub attn_out_norm_b: Option<Vec<f32>>,
211 /// `attn_norm_2`, `jina-bert-v2`'s second attention norm
212 /// (`bert.cpp:156-159`): the LAYER INPUT is re-added and normed a
213 /// second time before the FFN reads it.
214 pub attn_norm_2: Option<(Vec<f32>, Vec<f32>)>,
215 pub ffn_up: WeightMatrix,
216 pub ffn_up_b: Option<Vec<f32>>,
217 /// `ffn_gate`, present exactly for [`BertFfn::SwigluPar`].
218 pub ffn_gate: Option<WeightMatrix>,
219 pub ffn_down: WeightMatrix,
220 pub ffn_down_b: Option<Vec<f32>>,
221 /// `layer_output_norm`, applied after the FFN residual. `Some`
222 /// exactly for [`BertTopology::PostNormLayerNorm`].
223 pub layer_out_norm_w: Option<Vec<f32>>,
224 pub layer_out_norm_b: Option<Vec<f32>>,
225}
226
227pub struct BertEncoder {
228 pub hp: BertHparams,
229 pub tok_embd: WeightMatrix,
230 /// `token_types.weight`, **every** row: `[n_token_types, n_embd]`.
231 /// Row 0 is "Sentence A" and row 1 "Sentence B". `None` when the
232 /// checkpoint carries no table at all, which upstream allows
233 /// (`TENSOR_NOT_REQUIRED`) and which means no segment embedding is
234 /// added anywhere. Loading only row 0 — what this held before — is
235 /// what made a rerank pair score both halves as Sentence A.
236 pub type_embd: Option<Vec<Vec<f32>>>,
237 /// The learned position table, `None` for a rotating architecture
238 /// (see [`BertHparams::rope_theta`]).
239 pub pos_embd: Option<WeightMatrix>,
240 /// The embedding LayerNorm, `Some` exactly for
241 /// [`BertTopology::PostNormLayerNorm`]; a pre-norm encoder feeds
242 /// the raw embeddings into layer 0.
243 pub tok_norm_w: Option<Vec<f32>>,
244 pub tok_norm_b: Option<Vec<f32>>,
245 /// The final RMSNorm weight, `Some` exactly for
246 /// [`BertTopology::PreNormRms`] (`enc.output_norm` for
247 /// `neo-bert`, `output_norm` for `eurobert`).
248 pub final_norm: Option<Vec<f32>>,
249 pub layers: Vec<BertLayer>,
250}
251
252/// Adds `bias` to every `width`-wide row of `rows`, when there is one.
253fn add_bias_rows(rows: &mut [f32], width: usize, bias: Option<&Vec<f32>>) {
254 let Some(b) = bias else { return };
255 debug_assert_eq!(b.len(), width);
256 for row in rows.chunks_exact_mut(width) {
257 for (x, bv) in row.iter_mut().zip(b.iter()) {
258 *x += bv;
259 }
260 }
261}
262
263/// RMSNorm applied independently to each `width`-wide row, returning a
264/// new buffer: the pre-norm shape needs the normed value AND the
265/// unnormed residual, so this one does not work in place.
266fn rms_norm_rows(rows: &[f32], width: usize, weight: &[f32], eps: f32) -> Vec<f32> {
267 debug_assert_eq!(weight.len(), width);
268 rows.chunks_exact(width)
269 .flat_map(|row| frink_core::matmul::rms_norm(row, weight, eps))
270 .collect()
271}
272
273/// LayerNorm applied independently to each `width`-wide row, in place.
274fn layer_norm_rows(rows: &mut [f32], width: usize, weight: &[f32], bias: &[f32], eps: f32) {
275 for row in rows.chunks_exact_mut(width) {
276 let normed = layer_norm(row, weight, bias, eps);
277 row.copy_from_slice(&normed);
278 }
279}
280
281/// In-place softmax over one score row, max-shifted.
282fn softmax_row(scores: &mut [f32]) {
283 let max = scores.iter().copied().fold(f32::NEG_INFINITY, f32::max);
284 let mut sum = 0.0f32;
285 for s in scores.iter_mut() {
286 *s = (*s - max).exp();
287 sum += *s;
288 }
289 let inv = 1.0 / sum;
290 for s in scores.iter_mut() {
291 *s *= inv;
292 }
293}
294
295/// Full bidirectional multi-head attention over `n` positions.
296///
297/// `q` is `[n][n_head * head_dim]`; `k` and `v` are
298/// `[n][n_head_kv * head_dim]`. **Every query row attends to every key
299/// row** — there is no mask argument here on purpose, so a causal mask
300/// cannot be added by accident.
301#[allow(clippy::too_many_arguments)]
302fn bidirectional_attention(
303 q: &[f32],
304 k: &[f32],
305 v: &[f32],
306 n: usize,
307 n_head: usize,
308 n_head_kv: usize,
309 head_dim: usize,
310 // One slope per head, or `None`. The bias is `-slope * |i - j|`:
311 // SYMMETRIC, because `llama-graph.cpp:442` fills a non-causal
312 // model's mask with `-|p0 - p1|` and `ggml_soft_max_ext`
313 // multiplies it by the head's slope.
314 alibi_slopes: Option<&[f32]>,
315) -> Vec<f32> {
316 let q_width = n_head * head_dim;
317 let kv_width = n_head_kv * head_dim;
318 let heads_per_kv = n_head / n_head_kv;
319 let scale = 1.0 / (head_dim as f32).sqrt();
320 let mut out = vec![0.0f32; n * q_width];
321 let mut scores = vec![0.0f32; n];
322 for h in 0..n_head {
323 let kv_h = h / heads_per_kv;
324 let q_off = h * head_dim;
325 let kv_off = kv_h * head_dim;
326 let slope = alibi_slopes.map(|s| s[h]);
327 for i in 0..n {
328 let qi = &q[i * q_width + q_off..i * q_width + q_off + head_dim];
329 for (j, s) in scores.iter_mut().enumerate() {
330 let kj = &k[j * kv_width + kv_off..j * kv_width + kv_off + head_dim];
331 *s = qi.iter().zip(kj).map(|(a, b)| a * b).sum::<f32>() * scale;
332 if let Some(slope) = slope {
333 *s += slope * -((i as f32 - j as f32).abs());
334 }
335 }
336 softmax_row(&mut scores);
337 let dst = &mut out[i * q_width + q_off..i * q_width + q_off + head_dim];
338 for (j, &p) in scores.iter().enumerate() {
339 let vj = &v[j * kv_width + kv_off..j * kv_width + kv_off + head_dim];
340 for (o, &vv) in dst.iter_mut().zip(vj) {
341 *o += p * vv;
342 }
343 }
344 }
345 }
346 out
347}
348
349impl BertEncoder {
350 pub fn vocab_size(&self) -> usize {
351 self.tok_embd.rows()
352 }
353}
354
355impl TextEncoder for BertEncoder {
356 fn bert_hparams(&self) -> Option<&BertHparams> {
357 Some(&self.hp)
358 }
359
360 fn n_embd(&self) -> usize {
361 self.hp.n_embd
362 }
363
364 fn n_ctx_train(&self) -> usize {
365 self.hp.n_ctx_train
366 }
367
368 fn pooling_type(&self) -> PoolingType {
369 self.hp.pooling
370 }
371
372 /// `[CLS] … [SEP]`, which is what llama.cpp's WPM branch adds when
373 /// `add_special` is set: it pushes `special_bos_id` before the
374 /// pieces and `special_sep_id` after, unconditionally — the
375 /// `add_bos`/`add_eos` flags are not consulted on that path
376 /// (`llama-vocab.cpp`, `case LLAMA_VOCAB_TYPE_WPM`).
377 fn wrap_special(&self, pieces: &[u32]) -> Vec<u32> {
378 let mut out = Vec::with_capacity(pieces.len() + 2);
379 out.push(self.hp.cls_id);
380 out.extend_from_slice(pieces);
381 out.push(self.hp.sep_id);
382 out
383 }
384
385 /// The height of `token_types.weight`, or 1 when the checkpoint
386 /// carries no table (nothing is added at any position, which is
387 /// what a one-row table would do anyway).
388 fn n_segments(&self) -> usize {
389 self.type_embd.as_ref().map(Vec::len).unwrap_or(1)
390 }
391
392 /// `[CLS] a [SEP] b [SEP]` with segments `0…0 1…1` — what
393 /// HuggingFace's `tokenizer(query, document)` builds for a BERT
394 /// cross-encoder, which is the input these checkpoints were
395 /// trained on.
396 ///
397 /// The boundary is defined once, here, and both vectors are cut on
398 /// it: the first `[SEP]` closes segment 0 (HF counts it as part of
399 /// the first half) and everything after it is segment 1. Returning
400 /// the ids alone and letting the graph assume a segment is how the
401 /// document half came to be scored as "Sentence A".
402 fn wrap_special_pair(&self, a: &[u32], b: &[u32]) -> Option<PairSequence> {
403 let mut tokens = Vec::with_capacity(a.len() + b.len() + 3);
404 tokens.push(self.hp.cls_id);
405 tokens.extend_from_slice(a);
406 tokens.push(self.hp.sep_id);
407 let first_half = tokens.len();
408 tokens.extend_from_slice(b);
409 tokens.push(self.hp.sep_id);
410 let mut segments = vec![0u32; tokens.len()];
411 for s in segments[first_half..].iter_mut() {
412 *s = 1;
413 }
414 Some(PairSequence { tokens, segments })
415 }
416
417 fn encode_on_worker(
418 &self,
419 tokens: &[u32],
420 segments: Option<&[u32]>,
421 ) -> Result<Vec<f32>, EncodeError> {
422 let n = tokens.len();
423 if n == 0 {
424 return Err(EncodeError::EmptySequence);
425 }
426 if let Some(seg) = segments {
427 if seg.len() != n {
428 return Err(EncodeError::RaggedSegments {
429 tokens: n,
430 segments: seg.len(),
431 });
432 }
433 }
434 if n > self.hp.n_ctx_train {
435 return Err(EncodeError::TooLong {
436 got: n,
437 max: self.hp.n_ctx_train,
438 arch: self.hp.arch.clone(),
439 });
440 }
441 let d = self.hp.n_embd;
442 let vocab_size = self.vocab_size();
443
444 // (1)(2) token + type + position, then the input LayerNorm.
445 let mut h = vec![0.0f32; n * d];
446 for (i, &t) in tokens.iter().enumerate() {
447 if t as usize >= vocab_size {
448 return Err(EncodeError::TokenOutOfRange { id: t, vocab_size });
449 }
450 let tok = self.tok_embd.dequant_row(t as usize);
451 let row = &mut h[i * d..(i + 1) * d];
452 match &self.pos_embd {
453 Some(table) => {
454 let pos = table.dequant_row(i);
455 for (j, slot) in row.iter_mut().enumerate() {
456 *slot = tok[j] + pos[j];
457 }
458 }
459 // A rotating architecture adds no table here;
460 // `bert.cpp:90` is gated on `arch == LLM_ARCH_BERT`.
461 None => row.copy_from_slice(&tok),
462 }
463 if let Some(table) = &self.type_embd {
464 let seg = segments.map(|s| s[i]).unwrap_or(0);
465 let ty = table
466 .get(seg as usize)
467 .ok_or(EncodeError::SegmentOutOfRange {
468 id: seg,
469 pos: i,
470 n_segments: table.len(),
471 })?;
472 for (slot, tv) in row.iter_mut().zip(ty.iter()) {
473 *slot += tv;
474 }
475 }
476 }
477 if let (Some(w), Some(b)) = (&self.tok_norm_w, &self.tok_norm_b) {
478 layer_norm_rows(&mut h, d, w, b, self.hp.layer_norm_eps);
479 }
480
481 let head_dim = self.hp.head_dim();
482 for layer in &self.layers {
483 // A pre-norm layer normalises what the block reads and
484 // leaves the residual alone; a post-norm one reads the
485 // residual directly and norms after each add.
486 // `neo-bert.cpp:62-65` against `bert.cpp:103`.
487 let block_in = match &layer.pre_attn_norm {
488 None => h.clone(),
489 Some(w) => rms_norm_rows(&h, d, w, self.hp.layer_norm_eps),
490 };
491 let mut q = layer.wq.apply_batch(&block_in, n);
492 let mut k = layer.wk.apply_batch(&block_in, n);
493 let mut v = layer.wv.apply_batch(&block_in, n);
494 add_bias_rows(&mut q, self.hp.n_head * head_dim, layer.bq.as_ref());
495 add_bias_rows(&mut k, self.hp.n_head_kv * head_dim, layer.bk.as_ref());
496 add_bias_rows(&mut v, self.hp.n_head_kv * head_dim, layer.bv.as_ref());
497
498 if let Some(qk) = &layer.qk_norm {
499 // `bert.cpp:109-123`: a LayerNorm over the whole
500 // projection, BEFORE the rotation below, which is the
501 // order the graph builds them in.
502 layer_norm_rows(
503 &mut q,
504 self.hp.n_head * head_dim,
505 &qk.q_w,
506 &qk.q_b,
507 self.hp.layer_norm_eps,
508 );
509 layer_norm_rows(
510 &mut k,
511 self.hp.n_head_kv * head_dim,
512 &qk.k_w,
513 &qk.k_b,
514 self.hp.layer_norm_eps,
515 );
516 }
517
518 if let Some(theta) = self.hp.rope_theta {
519 // `bert.cpp:126-133`, NEOX (`llama_model_rope_type`),
520 // over the first `rope_dim` channels of every head and
521 // at the row's own position -- the same rotation the
522 // decoder path applies, on both Q and K.
523 let rot = self.hp.rope_dim.min(head_dim);
524 // NORM (interleaved pairs) for `neo-bert`, NEOX
525 // (split-half) for the others: `llama_model_rope_type`
526 // is the table and `BertHparams::rope_interleaved`
527 // carries its answer.
528 let rotate: fn(&mut [f32], usize, f32) = if self.hp.rope_interleaved {
529 frink_core::attention::apply_rope_interleaved
530 } else {
531 frink_core::attention::apply_rope
532 };
533 for (pos, row) in q.chunks_exact_mut(self.hp.n_head * head_dim).enumerate() {
534 for head in row.chunks_exact_mut(head_dim) {
535 rotate(&mut head[..rot], pos, theta);
536 }
537 }
538 for (pos, row) in k.chunks_exact_mut(self.hp.n_head_kv * head_dim).enumerate() {
539 for head in row.chunks_exact_mut(head_dim) {
540 rotate(&mut head[..rot], pos, theta);
541 }
542 }
543 }
544
545 let attn = bidirectional_attention(
546 &q,
547 &k,
548 &v,
549 n,
550 self.hp.n_head,
551 self.hp.n_head_kv,
552 head_dim,
553 self.hp.alibi_slopes.as_deref(),
554 );
555
556 let mut x = layer.wo.apply_batch(&attn, n);
557 add_bias_rows(&mut x, d, layer.bo.as_ref());
558 // Residual over the *layer input*; the post-norm shape
559 // then norms it, the pre-norm shape does not.
560 for (xv, hv) in x.iter_mut().zip(h.iter()) {
561 *xv += hv;
562 }
563 if let (Some(w), Some(b)) = (&layer.attn_out_norm_w, &layer.attn_out_norm_b) {
564 layer_norm_rows(&mut x, d, w, b, self.hp.layer_norm_eps);
565 }
566
567 // `bert.cpp:156-159`: the layer INPUT is re-added and
568 // normed a second time. Only `jina-bert-v2` carries the
569 // tensor, and only on the layers that have it.
570 if let Some((w, b)) = &layer.attn_norm_2 {
571 for (xv, hv) in x.iter_mut().zip(h.iter()) {
572 *xv += hv;
573 }
574 layer_norm_rows(&mut x, d, w, b, self.hp.layer_norm_eps);
575 }
576
577 // (5) the architecture's MLP; the FFN residual is over
578 // `x`, i.e. over the post-norm value, not over the layer
579 // input.
580 let ffn_in = match &layer.pre_ffn_norm {
581 None => x.clone(),
582 Some(w) => rms_norm_rows(&x, d, w, self.hp.layer_norm_eps),
583 };
584 let mut up = layer.ffn_up.apply_batch(&ffn_in, n);
585 add_bias_rows(&mut up, self.hp.n_ff, layer.ffn_up_b.as_ref());
586 match (self.hp.ffn, &layer.ffn_gate) {
587 (BertFfn::GeluSeq, _) => {
588 for a in up.iter_mut() {
589 *a = gelu(*a);
590 }
591 }
592 (BertFfn::SwigluPar, Some(gate)) => {
593 let g = gate.apply_batch(&ffn_in, n);
594 for (a, gv) in up.iter_mut().zip(g.iter()) {
595 *a *= frink_core::matmul::silu(*gv);
596 }
597 }
598 (BertFfn::GegluPar, Some(gate)) => {
599 let g = gate.apply_batch(&ffn_in, n);
600 for (a, gv) in up.iter_mut().zip(g.iter()) {
601 *a *= gelu(*gv);
602 }
603 }
604 (BertFfn::SwigluFusedUp, _) => {
605 // `ggml_swiglu` over a `2 * n_ff`-wide row: the
606 // first half is the gate.
607 let wide = self.hp.n_ff * 2;
608 debug_assert_eq!(up.len(), n * wide);
609 let mut folded = vec![0.0f32; n * self.hp.n_ff];
610 for (row, out) in up
611 .chunks_exact(wide)
612 .zip(folded.chunks_exact_mut(self.hp.n_ff))
613 {
614 let (gate, rest) = row.split_at(self.hp.n_ff);
615 for ((o, g), u) in out.iter_mut().zip(gate).zip(rest) {
616 *o = frink_core::matmul::silu(*g) * u;
617 }
618 }
619 up = folded;
620 }
621 (BertFfn::GegluFusedUp, _) => {
622 // `ggml_geglu` over a `2 * n_ff`-wide row: the
623 // first half is the gate and the second the up, per
624 // row, and the result is `n_ff` wide.
625 let wide = self.hp.n_ff * 2;
626 debug_assert_eq!(up.len(), n * wide);
627 let mut folded = vec![0.0f32; n * self.hp.n_ff];
628 for (row, out) in up
629 .chunks_exact(wide)
630 .zip(folded.chunks_exact_mut(self.hp.n_ff))
631 {
632 let (gate, rest) = row.split_at(self.hp.n_ff);
633 for ((o, g), u) in out.iter_mut().zip(gate).zip(rest) {
634 *o = gelu(*g) * u;
635 }
636 }
637 up = folded;
638 }
639 // Unreachable through the loader, which builds the
640 // pair together; spelled so a third FFN has to answer
641 // here rather than silently running GELU.
642 (BertFfn::SwigluPar | BertFfn::GegluPar, None) => {
643 return Err(EncodeError::MissingGate { layer: 0 });
644 }
645 }
646 let mut down = layer.ffn_down.apply_batch(&up, n);
647 add_bias_rows(&mut down, d, layer.ffn_down_b.as_ref());
648 for (dv, xv) in down.iter_mut().zip(x.iter()) {
649 *dv += xv;
650 }
651 if let (Some(w), Some(b)) = (&layer.layer_out_norm_w, &layer.layer_out_norm_b) {
652 layer_norm_rows(&mut down, d, w, b, self.hp.layer_norm_eps);
653 }
654 h = down;
655 }
656 // One final norm for the pre-norm shape, which has normed
657 // nothing since the last block read its input
658 // (`neo-bert.cpp:122-125`, `eurobert.cpp:116-119`).
659 if let Some(w) = &self.final_norm {
660 h = rms_norm_rows(&h, d, w, self.hp.layer_norm_eps);
661 }
662 Ok(h)
663 }
664}
665
666#[cfg(test)]
667mod tests {
668 use super::*;
669 use frink_core::tensor::Tensor;
670
671 /// Deterministic pseudo-random weights: a small LCG, so the fixture
672 /// is reproducible without pulling in a dependency.
673 struct Lcg(u64);
674 impl Lcg {
675 fn next_f32(&mut self) -> f32 {
676 self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1);
677 ((self.0 >> 33) as f32 / (1u64 << 31) as f32) - 0.5
678 }
679 fn vec(&mut self, n: usize) -> Vec<f32> {
680 (0..n).map(|_| self.next_f32()).collect()
681 }
682 fn matrix(&mut self, rows: usize, cols: usize) -> WeightMatrix {
683 WeightMatrix::F32(Tensor::new(self.vec(rows * cols), vec![rows, cols]))
684 }
685 }
686
687 const D: usize = 8;
688 const FF: usize = 16;
689 const HEADS: usize = 2;
690 const VOCAB: usize = 20;
691 const CTX: usize = 12;
692 const EPS: f32 = 1e-12;
693
694 fn fixture(n_layer: usize) -> BertEncoder {
695 let mut r = Lcg(0x5EED);
696 let tok_embd = r.matrix(VOCAB, D);
697 let pos_embd = r.matrix(CTX, D);
698 // Two rows, like every real BERT: "Sentence A" and "Sentence B".
699 let type_embd = Some(vec![r.vec(D), r.vec(D)]);
700 let tok_norm_w = r.vec(D);
701 let tok_norm_b = r.vec(D);
702 let layers = (0..n_layer)
703 .map(|_| BertLayer {
704 ffn_gate: None,
705 qk_norm: None,
706 attn_norm_2: None,
707 pre_attn_norm: None,
708 pre_ffn_norm: None,
709 wq: r.matrix(D, D),
710 bq: Some(r.vec(D)),
711 wk: r.matrix(D, D),
712 bk: Some(r.vec(D)),
713 wv: r.matrix(D, D),
714 bv: Some(r.vec(D)),
715 wo: r.matrix(D, D),
716 bo: Some(r.vec(D)),
717 attn_out_norm_w: Some(r.vec(D)),
718 attn_out_norm_b: Some(r.vec(D)),
719 ffn_up: r.matrix(FF, D),
720 ffn_up_b: Some(r.vec(FF)),
721 ffn_down: r.matrix(D, FF),
722 ffn_down_b: Some(r.vec(D)),
723 layer_out_norm_w: Some(r.vec(D)),
724 layer_out_norm_b: Some(r.vec(D)),
725 })
726 .collect();
727 BertEncoder {
728 hp: BertHparams {
729 topology: BertTopology::PostNormLayerNorm,
730 rope_interleaved: false,
731 alibi_slopes: None,
732 rope_theta: None,
733 rope_dim: 0,
734 ffn: BertFfn::GeluSeq,
735 arch: "bert".into(),
736 n_layer,
737 n_embd: D,
738 n_ff: FF,
739 n_head: HEADS,
740 n_head_kv: HEADS,
741 n_ctx_train: CTX,
742 n_token_types: 2,
743 layer_norm_eps: EPS,
744 pooling: PoolingType::Cls,
745 cls_id: 1,
746 sep_id: 2,
747 },
748 tok_embd,
749 type_embd,
750 pos_embd: Some(pos_embd),
751 tok_norm_w: Some(tok_norm_w),
752 tok_norm_b: Some(tok_norm_b),
753 final_norm: None,
754 layers,
755 }
756 }
757
758 /// An f64 transcription of the graph in the module docs, written
759 /// the slowest possible way: no `apply_batch`, no shared buffers,
760 /// one scalar loop per matrix element. It exists to disagree with
761 /// [`BertEncoder::encode`] if the fast path transposes a matrix,
762 /// drops a bias, norms the wrong residual, reuses a buffer it
763 /// should not, or reads the wrong row of the token-type table.
764 fn reference_forward(m: &BertEncoder, tokens: &[u32], segments: &[u32]) -> Vec<f64> {
765 let d = m.hp.n_embd;
766 let n = tokens.len();
767 let hd = m.hp.head_dim();
768
769 let dense = |w: &WeightMatrix| -> Vec<Vec<f64>> {
770 (0..w.rows())
771 .map(|r| w.dequant_row(r).iter().map(|&v| v as f64).collect())
772 .collect()
773 };
774 let matvec = |w: &Vec<Vec<f64>>, x: &[f64]| -> Vec<f64> {
775 w.iter()
776 .map(|row| row.iter().zip(x).map(|(a, b)| a * b).sum())
777 .collect()
778 };
779 let ln = |x: &[f64], wt: &[f32], b: &[f32]| -> Vec<f64> {
780 let mean = x.iter().sum::<f64>() / x.len() as f64;
781 let var = x.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / x.len() as f64;
782 let inv = 1.0 / (var + m.hp.layer_norm_eps as f64).sqrt();
783 x.iter()
784 .zip(wt)
785 .zip(b)
786 .map(|((v, w), bb)| (v - mean) * inv * (*w as f64) + (*bb as f64))
787 .collect()
788 };
789
790 let mut h: Vec<Vec<f64>> = tokens
791 .iter()
792 .enumerate()
793 .map(|(i, &t)| {
794 let tok = m.tok_embd.dequant_row(t as usize);
795 let pos = m
796 .pos_embd
797 .as_ref()
798 .expect("the reference model has a table")
799 .dequant_row(i);
800 let ty = m
801 .type_embd
802 .as_ref()
803 .map(|t| t[segments[i] as usize].clone())
804 .unwrap_or_else(|| vec![0.0; d]);
805 let row: Vec<f64> = (0..d)
806 .map(|j| tok[j] as f64 + pos[j] as f64 + ty[j] as f64)
807 .collect();
808 // The naive reference covers the POST-norm topology,
809 // which is the one it was written for; a pre-norm
810 // model has no embedding norm at all.
811 let (tw, tb) = (
812 m.tok_norm_w.as_ref().expect("post-norm reference"),
813 m.tok_norm_b.as_ref().expect("post-norm reference"),
814 );
815 ln(&row, tw, tb)
816 })
817 .collect();
818
819 for layer in &m.layers {
820 let (wq, wk, wv, wo) = (
821 dense(&layer.wq),
822 dense(&layer.wk),
823 dense(&layer.wv),
824 dense(&layer.wo),
825 );
826 let (wu, wd) = (dense(&layer.ffn_up), dense(&layer.ffn_down));
827 let bias = |v: &mut Vec<f64>, b: &Option<Vec<f32>>| {
828 if let Some(b) = b {
829 for (x, bb) in v.iter_mut().zip(b) {
830 *x += *bb as f64;
831 }
832 }
833 };
834 let mut q = Vec::new();
835 let mut k = Vec::new();
836 let mut v = Vec::new();
837 for row in &h {
838 let mut a = matvec(&wq, row);
839 bias(&mut a, &layer.bq);
840 q.push(a);
841 let mut a = matvec(&wk, row);
842 bias(&mut a, &layer.bk);
843 k.push(a);
844 let mut a = matvec(&wv, row);
845 bias(&mut a, &layer.bv);
846 v.push(a);
847 }
848 let mut attn = vec![vec![0.0f64; d]; n];
849 for head in 0..m.hp.n_head {
850 let off = head * hd;
851 for i in 0..n {
852 let raw: Vec<f64> = (0..n)
853 .map(|j| {
854 (0..hd).map(|c| q[i][off + c] * k[j][off + c]).sum::<f64>()
855 / (hd as f64).sqrt()
856 })
857 .collect();
858 let mx = raw.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
859 let ex: Vec<f64> = raw.iter().map(|s| (s - mx).exp()).collect();
860 let sum: f64 = ex.iter().sum();
861 for j in 0..n {
862 let p = ex[j] / sum;
863 for c in 0..hd {
864 attn[i][off + c] += p * v[j][off + c];
865 }
866 }
867 }
868 }
869 let mut next = Vec::new();
870 for i in 0..n {
871 let mut o = matvec(&wo, &attn[i]);
872 bias(&mut o, &layer.bo);
873 for (x, hv) in o.iter_mut().zip(&h[i]) {
874 *x += hv;
875 }
876 let x = ln(
877 &o,
878 layer.attn_out_norm_w.as_ref().expect("post-norm reference"),
879 layer.attn_out_norm_b.as_ref().expect("post-norm reference"),
880 );
881 let mut up = matvec(&wu, &x);
882 bias(&mut up, &layer.ffn_up_b);
883 let act: Vec<f64> = up
884 .iter()
885 .map(|&u| {
886 const K: f64 = 0.797_884_560_802_865_4;
887 const C: f64 = 0.044_715;
888 0.5 * u * (1.0 + (K * (u + C * u * u * u)).tanh())
889 })
890 .collect();
891 let mut down = matvec(&wd, &act);
892 bias(&mut down, &layer.ffn_down_b);
893 for (dv, xv) in down.iter_mut().zip(&x) {
894 *dv += xv;
895 }
896 next.push(ln(
897 &down,
898 layer
899 .layer_out_norm_w
900 .as_ref()
901 .expect("post-norm reference"),
902 layer
903 .layer_out_norm_b
904 .as_ref()
905 .expect("post-norm reference"),
906 ));
907 }
908 h = next;
909 }
910 h.into_iter().flatten().collect()
911 }
912
913 #[test]
914 fn matches_an_independent_f64_transcription_of_the_graph() {
915 let m = fixture(3);
916 let tokens = [1u32, 7, 13, 4, 9, 2];
917 let got = m.encode_tokens(&tokens).unwrap();
918 let want = reference_forward(&m, &tokens, &[0; 6]);
919 assert_eq!(got.len(), want.len());
920 for (i, (g, w)) in got.iter().zip(&want).enumerate() {
921 assert!(
922 (*g as f64 - w).abs() < 2e-4,
923 "element {i}: {g} vs reference {w}"
924 );
925 }
926 }
927
928 /// The same transcription, driven with a real `0 0 0 1 1 1` split.
929 /// The point is not that segments *do something* — it is that the
930 /// fast path reads the SAME row the reference does at every
931 /// position, so an off-by-one on the boundary or a table indexed
932 /// with the token id would show up here.
933 #[test]
934 fn the_segment_id_selects_the_token_type_row_at_every_position() {
935 let m = fixture(3);
936 let tokens = [1u32, 7, 13, 4, 9, 2];
937 let segments = [0u32, 0, 0, 1, 1, 1];
938 let got = m.encode(&tokens, Some(&segments)).unwrap();
939 let want = reference_forward(&m, &tokens, &segments);
940 for (i, (g, w)) in got.iter().zip(&want).enumerate() {
941 assert!(
942 (*g as f64 - w).abs() < 2e-4,
943 "element {i}: {g} vs reference {w}"
944 );
945 }
946 // And it is genuinely a different graph from the all-zeros one,
947 // which is the whole of issue #44: scoring the second half as
948 // "Sentence A" is not a rounding difference.
949 let all_zero = m.encode_tokens(&tokens).unwrap();
950 let moved: f32 = all_zero
951 .iter()
952 .zip(&got)
953 .map(|(x, y)| (x - y).abs())
954 .sum::<f32>();
955 assert!(moved > 1e-3, "segment 1 changed nothing ({moved})");
956 }
957
958 /// A segment id with no row, and a segment list that is not one per
959 /// token, are refusals rather than a panic or a silently wrong row.
960 #[test]
961 fn a_segment_id_off_the_table_and_a_ragged_segment_list_are_refused() {
962 let m = fixture(1);
963 assert!(matches!(
964 m.encode(&[1, 7, 2], Some(&[0, 2, 0])),
965 Err(EncodeError::SegmentOutOfRange { id: 2, pos: 1, .. })
966 ));
967 assert!(matches!(
968 m.encode(&[1, 7, 2], Some(&[0, 0])),
969 Err(EncodeError::RaggedSegments {
970 tokens: 3,
971 segments: 2
972 })
973 ));
974 }
975
976 /// The property that makes this an encoder. Row 0's output must
977 /// change when the *last* token changes; under a causal mask it
978 /// could not, because position 0 would attend only to itself.
979 #[test]
980 fn attention_is_bidirectional_not_causal() {
981 let m = fixture(2);
982 let a = m.encode_tokens(&[5u32, 6, 7, 8]).unwrap();
983 let b = m.encode_tokens(&[5u32, 6, 7, 19]).unwrap();
984 let moved: f32 = a[..D].iter().zip(&b[..D]).map(|(x, y)| (x - y).abs()).sum();
985 assert!(
986 moved > 1e-3,
987 "row 0 barely moved ({moved}) when the last token changed — \
988 attention is behaving causally"
989 );
990 }
991
992 /// Position is a learned table lookup, so the same token at a
993 /// different index must land somewhere else.
994 #[test]
995 fn position_embeddings_make_the_same_token_differ_by_index() {
996 let m = fixture(1);
997 let out = m.encode_tokens(&[11u32, 11]).unwrap();
998 let delta: f32 = out[..D]
999 .iter()
1000 .zip(&out[D..2 * D])
1001 .map(|(x, y)| (x - y).abs())
1002 .sum();
1003 assert!(
1004 delta > 1e-3,
1005 "identical tokens gave identical rows: {delta}"
1006 );
1007 }
1008
1009 /// The graph ends on a LayerNorm: with unit weight and zero bias
1010 /// each output row is mean-zero and unit-variance. An RMSNorm in
1011 /// that slot would leave the mean wherever it was.
1012 #[test]
1013 fn the_last_op_is_a_mean_subtracting_layer_norm() {
1014 let mut m = fixture(2);
1015 let last = m.layers.last_mut().unwrap();
1016 last.layer_out_norm_w = Some(vec![1.0; D]);
1017 last.layer_out_norm_b = Some(vec![0.0; D]);
1018 let out = m.encode_tokens(&[3u32, 4, 5]).unwrap();
1019 for row in out.as_chunks::<D>().0 {
1020 let mean: f32 = row.iter().sum::<f32>() / D as f32;
1021 let var: f32 = row.iter().map(|v| (v - mean).powi(2)).sum::<f32>() / D as f32;
1022 assert!(mean.abs() < 1e-4, "row mean {mean} is not zero");
1023 assert!((var - 1.0).abs() < 1e-3, "row variance {var} is not one");
1024 }
1025 }
1026
1027 #[test]
1028 fn refuses_an_empty_sequence_and_one_past_the_position_table() {
1029 let m = fixture(1);
1030 assert!(matches!(
1031 m.encode_tokens(&[]),
1032 Err(EncodeError::EmptySequence)
1033 ));
1034 let long: Vec<u32> = (0..CTX as u32 + 1).map(|i| i % VOCAB as u32).collect();
1035 let err = m.encode_tokens(&long).unwrap_err();
1036 assert!(
1037 matches!(err, EncodeError::TooLong { got, max, .. } if got == CTX + 1 && max == CTX)
1038 );
1039 assert!(matches!(
1040 m.encode_tokens(&[VOCAB as u32]),
1041 Err(EncodeError::TokenOutOfRange { .. })
1042 ));
1043 }
1044
1045 #[test]
1046 fn wrap_special_brackets_the_pieces_with_cls_and_sep() {
1047 let m = fixture(1);
1048 assert_eq!(m.wrap_special(&[7, 8]), vec![1, 7, 8, 2]);
1049 assert_eq!(m.wrap_special(&[]), vec![1, 2]);
1050 }
1051
1052 /// The cross-encoder input is `[CLS] a [SEP] b [SEP]` with segments
1053 /// `0 0 0 0 1 1` — the boundary between the two halves is the whole
1054 /// reason a reranker scores differently from an embedding model.
1055 /// Concatenating without it, dropping the trailing `[SEP]`, or
1056 /// leaving every segment at 0, produces a perfectly plausible
1057 /// ranking that is not the model's, so both vectors are asserted
1058 /// exactly rather than by length.
1059 ///
1060 /// The first `[SEP]` belongs to segment 0, which is what
1061 /// HuggingFace's `tokenizer(query, document)` emits: an off-by-one
1062 /// there is a one-position difference that no shape check catches.
1063 #[test]
1064 fn the_pair_form_separates_the_two_halves_and_labels_each_one() {
1065 let m = fixture(1);
1066 let pair = m.wrap_special_pair(&[7, 8], &[9]).unwrap();
1067 assert_eq!(pair.tokens, vec![1, 7, 8, 2, 9, 2]);
1068 assert_eq!(pair.segments, vec![0, 0, 0, 0, 1, 1]);
1069 // An empty half is still a half: the boundary stays.
1070 let empty = m.wrap_special_pair(&[], &[]).unwrap();
1071 assert_eq!(empty.tokens, vec![1, 2, 2]);
1072 assert_eq!(empty.segments, vec![0, 0, 1]);
1073 // And it is NOT the single-sequence form of the two texts run
1074 // together, which is what a defaulted implementation would give.
1075 assert_ne!(pair.tokens, m.wrap_special(&[7, 8, 9]));
1076 }
1077
1078 /// A checkpoint with no "Sentence B" row cannot express a pair, and
1079 /// [`crate::EmbeddingModel`] refuses one at load. This is the value
1080 /// that refusal reads.
1081 #[test]
1082 fn n_segments_is_the_height_of_the_token_type_table() {
1083 let mut m = fixture(1);
1084 assert_eq!(m.n_segments(), 2);
1085 m.type_embd = Some(vec![vec![0.0; D]]);
1086 assert_eq!(m.n_segments(), 1);
1087 m.type_embd = None;
1088 assert_eq!(m.n_segments(), 1);
1089 }
1090
1091 /// `embed_tokens` must return the CLS row of the hidden states this
1092 /// checkpoint's `pooling_type` names, not the mean and not the last.
1093 #[test]
1094 fn embed_tokens_pools_the_way_the_hparams_say() {
1095 let m = fixture(2);
1096 let tokens = [1u32, 9, 4, 2];
1097 let hidden = m.encode_tokens(&tokens).unwrap();
1098 assert_eq!(m.embed_tokens(&tokens).unwrap(), hidden[..D].to_vec());
1099 }
1100}