Skip to main content

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}