ferritin_plms/t5/runner.rs
1//! T5-family runner — embeds sequences through
2//! `candle_transformers::models::t5::T5EncoderModel`.
3//!
4//! One runner serves ProtT5 and Ankh because they differ in nothing this code
5//! cares about: same architecture, same residue alphabet and ids, same
6//! `EOS_ONLY` token layout. What differs is dimensions and the FFN activation
7//! (Ankh is gated-gelu), and both of those come from the checkpoint's own
8//! `config.json`.
9
10use crate::loader::{LoadOptions, WeightSource};
11use crate::plm_runner::{ModelMetadata, PlmRunner, SpecialTokenLayout};
12use crate::registry::{self, ModelCard};
13use crate::t5::tokenizer;
14use anyhow::{Context, Result};
15use candle_core::{DType, Device, Tensor};
16use candle_transformers::models::t5::{
17 ActivationWithOptionalGating, Config as T5Config, T5EncoderModel,
18};
19use std::sync::Mutex;
20
21/// Available T5-family models.
22pub enum T5Models {
23 /// `Rostlab/prot_t5_xl_half_uniref50-enc` — the encoder-only
24 /// half-precision build, which is the one almost everyone uses. The full
25 /// encoder-decoder `prot_t5_xl_uniref50` doubles the download for a
26 /// decoder that embedding work never runs.
27 ProtT5XlHalfUniref50Enc,
28 /// `ElnaggarLab/ankh-base` — 48 encoder layers at `d_model` 768.
29 AnkhBase,
30 /// `ElnaggarLab/ankh-large` — 48 encoder layers at `d_model` 1536.
31 AnkhLarge,
32}
33
34impl T5Models {
35 /// This variant's registry id.
36 pub const fn registry_id(&self) -> &'static str {
37 match self {
38 Self::ProtT5XlHalfUniref50Enc => "prott5-xl-half-uniref50-enc",
39 Self::AnkhBase => "ankh-base",
40 Self::AnkhLarge => "ankh-large",
41 }
42 }
43
44 /// This variant's [`ModelCard`].
45 pub fn card(&self) -> &'static ModelCard {
46 registry::lookup(self.registry_id())
47 .expect("every T5Models variant must have a registry entry")
48 }
49
50 /// The precision this checkpoint is published at.
51 ///
52 /// ProtT5's encoder-only build ships `float16`, so loading it at F32 would
53 /// double 2.4 GB in memory for values that are F16 either way. Ankh ships
54 /// `float32`, and rounding it down would lose real precision rather than
55 /// save anything the checkpoint has. Either can be overridden through
56 /// [`T5Runner::from_pretrained_with`] (ferritin-100.9).
57 pub const fn published_dtype(&self) -> DType {
58 match self {
59 Self::ProtT5XlHalfUniref50Enc => DType::F16,
60 Self::AnkhBase | Self::AnkhLarge => DType::F32,
61 }
62 }
63
64 /// Where this variant's weights live, plus its built-in fallback config.
65 pub fn model_info(&self) -> (WeightSource, &'static str, T5Config) {
66 let card = self.card();
67 (card.source, card.file, self.fallback_config())
68 }
69
70 /// The published `config.json`, transcribed, for when the hub is
71 /// unreachable. Values copied from each checkpoint's own file.
72 fn fallback_config(&self) -> T5Config {
73 let card = self.card();
74 let base = T5Config {
75 vocab_size: card.metadata.vocab_size,
76 d_model: card.metadata.d_model,
77 num_layers: card.metadata.n_layers,
78 relative_attention_num_buckets: 32,
79 relative_attention_max_distance: 128,
80 initializer_factor: 1.0,
81 layer_norm_epsilon: 1e-6,
82 tie_word_embeddings: false,
83 is_decoder: false,
84 is_encoder_decoder: true,
85 use_cache: false,
86 pad_token_id: tokenizer::PAD_ID as usize,
87 eos_token_id: tokenizer::EOS_ID as usize,
88 decoder_start_token_id: Some(tokenizer::PAD_ID as usize),
89 // Overridden per model below.
90 d_kv: 64,
91 d_ff: 0,
92 num_decoder_layers: None,
93 num_heads: 0,
94 dropout_rate: 0.0,
95 feed_forward_proj: Default::default(),
96 };
97 match self {
98 Self::ProtT5XlHalfUniref50Enc => T5Config {
99 d_kv: 128,
100 d_ff: 16384,
101 num_decoder_layers: Some(24),
102 num_heads: 32,
103 dropout_rate: 0.1,
104 ..base
105 },
106 // Ankh is gated: `feed_forward_proj: "gated-gelu"`, which candle
107 // parses into a gated `T5DenseGatedActDense` rather than the plain
108 // ReLU dense ProtT5 uses.
109 Self::AnkhBase => T5Config {
110 d_kv: 64,
111 d_ff: 3072,
112 num_decoder_layers: Some(24),
113 num_heads: 12,
114 feed_forward_proj: gated_gelu(),
115 ..base
116 },
117 Self::AnkhLarge => T5Config {
118 d_kv: 64,
119 d_ff: 3840,
120 num_decoder_layers: Some(24),
121 num_heads: 16,
122 feed_forward_proj: gated_gelu(),
123 ..base
124 },
125 }
126 }
127}
128
129/// Ankh's `"gated-gelu"` feed-forward.
130///
131/// Built as a struct literal rather than by deserializing the string: candle
132/// maps `"gated-gelu"` through `deserialize_feed_forward_proj_activation`, a
133/// *field-level* `deserialize_with` on `Config`, so
134/// `ActivationWithOptionalGating`'s own derived `Deserialize` expects a struct
135/// and rejects the bare string. This mirrors what that function produces —
136/// `NewGelu`, i.e. the tanh approximation, matching Ankh's
137/// `dense_act_fn: "gelu_new"`.
138fn gated_gelu() -> ActivationWithOptionalGating {
139 ActivationWithOptionalGating {
140 gated: true,
141 activation: candle_nn::Activation::NewGelu,
142 }
143}
144
145/// A T5 encoder wrapped for [`PlmRunner`].
146///
147/// # Why the model sits behind a `Mutex`
148///
149/// `T5EncoderModel::forward` takes `&mut self`, because the same `T5Attention`
150/// code serves the decoder, where it maintains a KV cache. [`PlmRunner::embed`]
151/// takes `&self`, and widening the trait to `&mut self` would make every
152/// runner harder to share across threads for the sake of one model.
153///
154/// The encoder does not actually cache. `T5Attention::load` sets
155/// `use_cache: cfg.use_cache && decoder`, and `T5EncoderModel::load` builds its
156/// stack with `decoder = false`, so the cache branch is dead on this path
157/// whatever `config.json` says. `load_config` also forces `use_cache: false`
158/// so the guarantee does not depend on a detail of candle's internals, and
159/// `test_repeated_embed_is_deterministic` fails if a future candle version
160/// starts carrying state across calls.
161///
162/// So the `Mutex` buys the `&mut` the signature demands while keeping the
163/// runner `Send + Sync`, and it is uncontended in the sense that matters: there
164/// is no state to protect. It does serialise concurrent `embed` calls on one
165/// runner, which is a real cost — clone the runner per thread if that bites.
166pub struct T5Runner {
167 model: Mutex<T5EncoderModel>,
168 config: T5Config,
169 device: Device,
170 /// The registry id, so `model_name` distinguishes ProtT5 from Ankh in
171 /// error messages and conformance output.
172 name: &'static str,
173}
174
175impl T5Runner {
176 /// Load from the hub at the checkpoint's own published precision.
177 ///
178 /// See [`T5Models::published_dtype`]: F16 for ProtT5, F32 for Ankh. Use
179 /// [`from_pretrained_with`][Self::from_pretrained_with] to override.
180 pub fn from_pretrained(model: T5Models, device: Device) -> Result<Self> {
181 let dtype = model.published_dtype();
182 Self::from_pretrained_with(model, &LoadOptions::new(device).with_dtype(dtype))
183 }
184
185 /// Load with an explicit device and dtype.
186 pub fn from_pretrained_with(model: T5Models, opts: &LoadOptions) -> Result<Self> {
187 let (source, file, fallback) = model.model_info();
188 let config = load_config(&source, fallback);
189 let vb = source.var_builder(file, opts)?;
190 let encoder = T5EncoderModel::load(vb, &config)
191 .with_context(|| format!("failed to load {} as a T5 encoder", source.repo_id))?;
192 Ok(Self {
193 model: Mutex::new(encoder),
194 config,
195 device: opts.device.clone(),
196 name: model.registry_id(),
197 })
198 }
199
200 /// Token ids for `sequence`, shaped `(1, L + 1)` for the trailing `</s>`.
201 fn encode(&self, sequence: &str) -> Result<Tensor> {
202 let ids = tokenizer::encode(sequence);
203 Ok(Tensor::new(ids.as_slice(), &self.device)?.unsqueeze(0)?)
204 }
205}
206
207/// Parse a HuggingFace T5 `config.json` into candle's `Config`.
208///
209/// Strips keys whose value is `null` before deserializing. That is not
210/// cosmetic: candle marks optional fields `#[serde(default = "...")]`, and
211/// serde's `default` fills in a **missing** key, not a present-but-null one. So
212/// `"tie_word_embeddings": null` — which is exactly what
213/// `Rostlab/ProstT5_fp16` ships — fails with *"invalid type: null, expected a
214/// boolean"* and drops the whole config on the floor.
215///
216/// For ProstT5 that would be quietly serious rather than merely noisy:
217/// `tie_word_embeddings` decides whether `decode` scales the hidden state by
218/// `sqrt(d_model)` and projects through the shared embedding, and the
219/// checkpoint has no `lm_head` to fall back on.
220///
221/// Treating an explicit null as absent is what HuggingFace itself means by it.
222pub(crate) fn parse_t5_config(json: &str) -> Result<T5Config> {
223 let mut value: serde_json::Value = serde_json::from_str(json)?;
224 if let Some(map) = value.as_object_mut() {
225 map.retain(|_, v| !v.is_null());
226 }
227 Ok(serde_json::from_value(value)?)
228}
229
230/// Load `config.json` from the hub, falling back to the built-in config.
231///
232/// candle's `t5::Config` derives `Deserialize` over the same field names
233/// HuggingFace writes, so the published file parses directly once nulls are
234/// stripped; the extra keys these repos carry (`architectures`, `n_positions`,
235/// `torch_dtype`, …) are ignored.
236///
237/// `use_cache` is forced off afterwards regardless of what the file says — it
238/// is meaningful for a decoder, and this loads an encoder. See [`T5Runner`].
239///
240/// The fallback is loud, matching `ESM2Runner::resolve_config`: a config the
241/// struct cannot represent silently becoming a different model's config is the
242/// failure ferritin-goh.9 was about.
243fn load_config(source: &WeightSource, fallback: T5Config) -> T5Config {
244 let mut config = match source.fetch_optional("config.json") {
245 None => {
246 eprintln!(
247 "warning: {}: could not download config.json; using the built-in \
248 config for this variant. Verify it matches the checkpoint.",
249 source.repo_id
250 );
251 fallback
252 }
253 Some(path) => match std::fs::read_to_string(&path)
254 .map_err(|e| e.to_string())
255 .and_then(|s| parse_t5_config(&s).map_err(|e| e.to_string()))
256 {
257 Ok(config) => config,
258 Err(e) => {
259 eprintln!(
260 "warning: {}: config.json did not parse as a T5 Config ({e}); \
261 using the built-in config instead. If this checkpoint is not a \
262 T5 variant, the built-in config is probably wrong for it.",
263 source.repo_id
264 );
265 fallback
266 }
267 },
268 };
269 config.use_cache = false;
270 config
271}
272
273impl PlmRunner for T5Runner {
274 /// Run the T5 encoder and return per-token hidden states.
275 ///
276 /// Shape: `(1, L + 1, d_model)` — the trailing `</s>` row is included, and
277 /// there is no BOS row.
278 fn embed(&self, sequence: &str) -> Result<Tensor> {
279 let ids = self.encode(sequence)?;
280 let mut model = self
281 .model
282 .lock()
283 .map_err(|_| anyhow::anyhow!("T5 model mutex was poisoned by an earlier panic"))?;
284 Ok(model.forward(&ids)?)
285 }
286
287 fn model_name(&self) -> &str {
288 self.name
289 }
290
291 /// ProtT5 appends `</s>` and prepends nothing: T5's
292 /// `build_inputs_with_special_tokens` is `ids + [eos]`, and the vocabulary
293 /// has no BOS piece at all.
294 ///
295 /// Every other runner in the crate is `BOS_EOS`, which is exactly why the
296 /// layout is declared rather than assumed — reading a ProtT5 embedding as
297 /// if row 0 were a BOS token shifts every residue by one.
298 fn special_tokens(&self) -> SpecialTokenLayout {
299 SpecialTokenLayout::EOS_ONLY
300 }
301
302 fn metadata(&self) -> ModelMetadata {
303 ModelMetadata {
304 d_model: self.config.d_model,
305 n_layers: self.config.num_layers,
306 vocab_size: self.config.vocab_size,
307 // Relative position buckets: no hard architectural cap. The
308 // published config carries `n_positions: 512`, but T5 inherits
309 // nothing from it — the buckets are computed from the query/key
310 // offset at run time.
311 max_positions: None,
312 }
313 }
314
315 fn device(&self) -> &Device {
316 &self.device
317 }
318
319 /// Whitespace is not a residue: `"M K T"` is three residues, not five.
320 fn residue_count(&self, sequence: &str) -> usize {
321 tokenizer::residue_count(sequence)
322 }
323}
324
325// ── Tests ─────────────────────────────────────────────────────────────────────
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330
331 /// Every variant's fallback config must be *constructible* and agree with
332 /// its registry card.
333 ///
334 /// This is not a formality. `fallback_config` is built eagerly by
335 /// `model_info`, so a panic in it takes down loading before the hub is ever
336 /// contacted — which is exactly what happened while adding Ankh: the gated
337 /// activation was first built by deserializing the string `"gated-gelu"`,
338 /// but candle maps that name through a *field-level* `deserialize_with` on
339 /// `Config`, so `ActivationWithOptionalGating`'s own derived `Deserialize`
340 /// rejected it. Nothing without a 2.9 GB download caught that.
341 #[test]
342 fn test_fallback_configs_are_constructible_and_match_their_cards() {
343 for model in [
344 T5Models::ProtT5XlHalfUniref50Enc,
345 T5Models::AnkhBase,
346 T5Models::AnkhLarge,
347 ] {
348 let card = model.card();
349 let config = model.fallback_config();
350 let id = model.registry_id();
351 assert_eq!(config.d_model, card.metadata.d_model, "{id}: d_model");
352 assert_eq!(
353 config.num_layers, card.metadata.n_layers,
354 "{id}: num_layers"
355 );
356 assert_eq!(
357 config.vocab_size, card.metadata.vocab_size,
358 "{id}: vocab_size"
359 );
360 assert!(config.num_heads > 0, "{id}: num_heads must be set");
361 assert!(config.d_ff > 0, "{id}: d_ff must be set");
362 assert!(
363 !config.use_cache,
364 "{id}: use_cache must be forced off — see T5Runner"
365 );
366 }
367 }
368
369 /// Ankh is gated, ProtT5 is not. Getting this backwards loads a
370 /// `T5DenseGatedActDense` against plain-dense weights (or the reverse),
371 /// which fails on tensor shapes rather than silently — but only after a
372 /// multi-gigabyte download.
373 #[test]
374 fn test_only_ankh_is_gated() {
375 assert!(
376 !T5Models::ProtT5XlHalfUniref50Enc
377 .fallback_config()
378 .feed_forward_proj
379 .gated,
380 "ProtT5 is feed_forward_proj: \"relu\", not gated"
381 );
382 for model in [T5Models::AnkhBase, T5Models::AnkhLarge] {
383 let ff = model.fallback_config().feed_forward_proj;
384 assert!(ff.gated, "{}: Ankh is gated-gelu", model.registry_id());
385 assert_eq!(
386 ff.activation,
387 candle_nn::Activation::NewGelu,
388 "{}: gated-gelu means the tanh-approximation GELU",
389 model.registry_id()
390 );
391 }
392 }
393
394 /// `Rostlab/ProstT5_fp16` ships `"tie_word_embeddings": null`, which
395 /// candle's `#[serde(default)]` does *not* absorb — serde's `default`
396 /// covers a missing key, not a present-but-null one.
397 ///
398 /// Without the null-stripping in [`parse_t5_config`] this config is
399 /// rejected wholesale and the loader silently falls back to a built-in one.
400 /// For ProstT5 that flips `tie_word_embeddings`, which decides whether
401 /// `decode` scales by `sqrt(d_model)` and projects through the shared
402 /// embedding — and that checkpoint has no `lm_head` to fall back on.
403 #[test]
404 fn test_null_valued_config_keys_are_treated_as_absent() {
405 let json = r#"{
406 "vocab_size": 150, "d_model": 1024, "d_kv": 128, "d_ff": 16384,
407 "num_layers": 24, "num_decoder_layers": 24, "num_heads": 32,
408 "relative_attention_num_buckets": 32, "dropout_rate": 0.1,
409 "layer_norm_epsilon": 1e-06, "initializer_factor": 1.0,
410 "feed_forward_proj": "relu", "is_encoder_decoder": true,
411 "tie_word_embeddings": null,
412 "pad_token_id": 0, "eos_token_id": 1, "decoder_start_token_id": 0
413 }"#;
414 assert!(
415 serde_json::from_str::<T5Config>(json).is_err(),
416 "candle rejects an explicit null — if this ever starts passing, \
417 the strip in parse_t5_config is no longer needed"
418 );
419 let config = parse_t5_config(json).expect("nulls should be stripped");
420 assert_eq!(config.d_model, 1024);
421 assert!(
422 config.tie_word_embeddings,
423 "with the key treated as absent, candle's default (true) applies — \
424 which is what HuggingFace means by writing null"
425 );
426 }
427
428 /// The published precisions differ, and defaulting either to the other
429 /// wastes memory or loses fidelity (ferritin-100.9).
430 #[test]
431 fn test_published_dtypes() {
432 assert_eq!(
433 T5Models::ProtT5XlHalfUniref50Enc.published_dtype(),
434 DType::F16
435 );
436 assert_eq!(T5Models::AnkhBase.published_dtype(), DType::F32);
437 assert_eq!(T5Models::AnkhLarge.published_dtype(), DType::F32);
438 }
439}