metrale_model_engine/model/nllb/
mod.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: Served NLLB-200 / M2M-100 encoder-decoder translation model.
4//!
5//! `NllbGpuModel` implements [`crate::traits::Model`], so an NLLB checkpoint is served
6//! through the same scheduler and API as the decoder-only models, with its weights from
7//! the standard `--model` store. The factory builds it for `model_type` `m2m_100` or
8//! `nllb`.
9//!
10//! The model owns all its KV and does not use the scheduler's paged block cache: per
11//! sequence, a decoder self-attention cache that grows one row per token and a
12//! cross-attention cache computed once from the encoder (see `kv`). Logits are bf16.
13//!
14//! Owner: model-engine.
15//! Invariants: none beyond the types.
16
17use std::collections::HashMap;
18use std::sync::Mutex;
19use std::sync::atomic::{AtomicBool, Ordering};
20
21use anyhow::{Context, Result, ensure};
22use metrale_config::ModelConfig;
23use metrale_gpu_runtime::gpu::{DevicePtr, GpuBackend};
24use metrale_model_weights::weights::{WeightDtype, WeightStore};
25
26mod beam;
27mod beam_compute;
28mod beam_multi;
29mod compute;
30mod kernels;
31mod kv;
32mod lang;
33mod lora;
34mod model_impl;
35mod util;
36
37pub use lang::NllbLang;
38
39use compute::DecScratch;
40use kernels::NllbKernels;
41use kv::NllbSeqKv;
42use lora::NllbLora;
43
44/// 2026-09-25: Floor on the decoder self-attention cache depth:
45/// `cache_rows = max(DEFAULT_CACHE_ROWS, min(max_seq_len, 2048))`. One sequence's
46/// self-attention KV is `cache_rows · d · 2 bytes · 2 (K and V) · dec_layers`.
47const DEFAULT_CACHE_ROWS: usize = 512;
48
49/// 2026-09-25: The served NLLB encoder-decoder model. Fields are immutable after
50/// construction, behind a `Mutex`, or atomic. The decode scratch (`dec`) and the
51/// LoRA gate (`lora_active`) are shared by every sequence, so callers must not
52/// forward two sequences at once.
53pub struct NllbGpuModel {
54    gpu: Box<dyn GpuBackend>,
55    kernels: NllbKernels,
56    weights: HashMap<String, DevicePtr>,
57    embed_table: DevicePtr,
58    d: usize,
59    heads: usize,
60    head_dim: usize,
61    ffn: usize,
62    enc_layers: usize,
63    dec_layers: usize,
64    vocab: usize,
65    embed_scale: f32,
66    attn_scale: f32,
67    cache_rows: usize,
68    max_batch: usize,
69    lang: NllbLang,
70    dec: DecScratch,
71    /// 2026-09-25: bf16 decode logits `[max_batch, vocab]`. `decode_batch` writes
72    /// contiguous rows `0..n` (batch position `i` ↔ `seqs[i]`), the scheduler's contract.
73    decode_logits: DevicePtr,
74    /// 2026-09-25: bf16 prefill logits `[max_batch, vocab]`. A prefill writes row
75    /// `slot_idx % max_batch`.
76    prefill_logits: DevicePtr,
77    /// 2026-09-25: Decoder sinusoidal position table `[cache_rows, d]` bf16.
78    pos_table: DevicePtr,
79    kv: Mutex<HashMap<usize, NllbSeqKv>>,
80    slots: Mutex<SlotAlloc>,
81    /// 2026-09-25: Optional PEFT LoRA adapter, applied after each biased linear
82    /// projection it targets.
83    lora: Option<NllbLora>,
84    /// 2026-09-25: Per-request LoRA gate, set from the sequence's `adapter_slot`
85    /// before each forward (`>= 0` applies the adapter).
86    lora_active: AtomicBool,
87}
88
89/// 2026-09-25: Monotonic slot allocator with a free list for reuse.
90#[derive(Default)]
91struct SlotAlloc {
92    next: usize,
93    free: Vec<usize>,
94}
95
96impl SlotAlloc {
97    fn claim(&mut self) -> usize {
98        self.free.pop().unwrap_or_else(|| {
99            let s = self.next;
100            self.next += 1;
101            s
102        })
103    }
104    fn release(&mut self, slot: usize) {
105        self.free.push(slot);
106    }
107}
108
109impl NllbGpuModel {
110    /// 2026-09-25: Build from the standard `--model` weight store and GPU
111    /// backend. `lang` carries the language ids the server's tokenizer
112    /// resolved. `max_seq_len` sets the decoder KV depth, clamped to
113    /// `DEFAULT_CACHE_ROWS..=2048`. Fails unless `model.shared.weight` is bf16.
114    pub fn new(
115        config: &ModelConfig,
116        store: &WeightStore,
117        gpu: Box<dyn GpuBackend>,
118        lang: NllbLang,
119        max_seq_len: usize,
120        max_batch: usize,
121        lora_dir: Option<&std::path::Path>,
122    ) -> Result<Self> {
123        let d = config.hidden_size;
124        let heads = config.num_attention_heads;
125        let head_dim = config.head_dim;
126        let ffn = config.intermediate_size;
127        let dec_layers = config.num_hidden_layers;
128        // 2026-09-25: The encoder uses the decoder's layer count
129        // (`num_hidden_layers`), head count and FFN width.
130        let enc_layers = dec_layers;
131        let vocab = config.vocab_size;
132        // 2026-09-25: Embeddings are always scaled by √d_model; the config's
133        // `scale_embedding` is not read.
134        let embed_scale = (d as f32).sqrt();
135        let attn_scale = (head_dim as f32).powf(-0.5);
136        let cache_rows = DEFAULT_CACHE_ROWS.max(max_seq_len.min(2048));
137
138        ensure!(
139            store.get("model.shared.weight")?.dtype == WeightDtype::BF16,
140            "nllb serving requires a bf16 checkpoint; convert with \
141             scripts/convert-safetensors-to-bf16.py"
142        );
143        let weights: HashMap<String, DevicePtr> = store
144            .names()
145            .map(|n| Ok((n.to_string(), store.get(n)?.ptr)))
146            .collect::<Result<_>>()?;
147        let embed_table = *weights
148            .get("model.shared.weight")
149            .context("nllb: missing tied embedding model.shared.weight")?;
150
151        // 2026-09-25: Make the CUDA context current on this thread before the
152        // kernel lookups and allocations below.
153        gpu.bind_to_thread()?;
154        let kernels = NllbKernels::new(gpu.as_ref())?;
155        let dec = DecScratch::new(gpu.as_ref(), d, ffn, vocab)?;
156        let max_batch = max_batch.max(1);
157        let decode_logits = gpu.alloc(max_batch * vocab * 2)?;
158        let prefill_logits = gpu.alloc(max_batch * vocab * 2)?;
159        let pos_table = gpu.alloc(cache_rows * d * 2)?;
160        let pos_host = util::decoder_pos_table_bf16(cache_rows, d);
161        gpu.copy_h2d(util::bf16_bytes(&pos_host), pos_table)?;
162
163        let lora = match lora_dir {
164            Some(dir) => Some(NllbLora::load(dir, gpu.as_ref(), cache_rows)?),
165            None => None,
166        };
167
168        tracing::info!(
169            "NLLB served model ready: d={d} heads={heads} enc={enc_layers} dec={dec_layers} \
170             vocab={vocab} src_lang_id={} tgt_lang_id={} cache_rows={cache_rows}",
171            lang.src_lang_id,
172            lang.tgt_lang_id,
173        );
174
175        Ok(Self {
176            gpu,
177            kernels,
178            weights,
179            embed_table,
180            d,
181            heads,
182            head_dim,
183            ffn,
184            enc_layers,
185            dec_layers,
186            vocab,
187            embed_scale,
188            attn_scale,
189            cache_rows,
190            max_batch,
191            lang,
192            dec,
193            decode_logits,
194            prefill_logits,
195            pos_table,
196            kv: Mutex::new(HashMap::new()),
197            slots: Mutex::new(SlotAlloc::default()),
198            lora,
199            lora_active: AtomicBool::new(false),
200        })
201    }
202
203    /// 2026-09-25: Device pointer for weight `name`. Panics when the store has
204    /// no such weight; construction copies every name in the store.
205    #[inline]
206    pub(super) fn w(&self, name: &str) -> DevicePtr {
207        self.weights[name]
208    }
209
210    /// 2026-09-25: Arm or disarm the LoRA delta for the sequence about to be
211    /// forwarded (`adapter_slot >= 0` arms it). Always disarmed when no adapter
212    /// is loaded.
213    #[inline]
214    pub(super) fn set_lora_active(&self, adapter_slot: i32) {
215        self.lora_active
216            .store(self.lora.is_some() && adapter_slot >= 0, Ordering::Relaxed);
217    }
218
219    #[inline]
220    pub(super) fn lora_is_active(&self) -> bool {
221        self.lora_active.load(Ordering::Relaxed)
222    }
223}