Skip to main content

kopitiam_runtime/
config.rs

1//! Architecture hyperparameters for a Qwen-family (LLaMA-shaped) decoder-only
2//! transformer, resolved from [`kopitiam_loader::ModelMetadata`].
3//!
4//! `ModelMetadata` deliberately leaves every field `Option` — it has to
5//! represent both a fully-specified GGUF export and a bare SafeTensors dump
6//! with no architecture metadata at all (see that type's docs). This module
7//! is where "this field is missing" becomes either a documented fallback
8//! (RoPE theta, RoPE dimension count, normalization epsilon: every Qwen/
9//! LLaMA checkpoint agrees on these defaults closely enough that guessing
10//! is safer than refusing to load) or a hard error (layer count, head
11//! counts, hidden size, vocab size: guessing any of these wrong does not
12//! fail to load, it loads a model that computes silent nonsense, which is
13//! strictly worse than refusing).
14
15use kopitiam_core::{Error, Result};
16use kopitiam_loader::ModelMetadata;
17
18/// `format` tag used on every [`Error::MalformedModel`] this module raises,
19/// so a config-resolution failure is distinguishable from a GGUF/SafeTensors
20/// parse failure raised by `kopitiam-loader` itself (which always tags its
21/// errors `"gguf"` or `"safetensors"`).
22const FORMAT: &str = "qwen-config";
23
24/// The resolved shape of a Qwen-family transformer: everything the forward
25/// pass needs to know about the model it is about to run, with no further
26/// `Option`s or metadata lookups once construction succeeds.
27///
28/// # Why "Qwen-family" and not "generic transformer config"
29///
30/// This struct encodes one specific architecture family: pre-norm residual
31/// blocks, RMSNorm, rotary position embeddings, grouped-query attention, and
32/// a SwiGLU MLP — the LLaMA-derived shape that Qwen, LLaMA itself, Mistral,
33/// and most current open decoder-only models share. It is not an attempt at
34/// a universal config covering encoder-decoder models, ALiBi, or MoE
35/// routing; those are different forward passes, not different values of the
36/// same fields, and would be a new config type (and a new [`crate::Model`]
37/// impl) rather than more `Option`s bolted onto this one.
38#[derive(Debug, Clone, PartialEq)]
39pub struct QwenConfig {
40    /// Number of transformer blocks.
41    pub n_layers: usize,
42    /// Number of query attention heads.
43    pub n_heads: usize,
44    /// Number of key/value attention heads. Equal to `n_heads` for ordinary
45    /// multi-head attention; smaller under grouped-query attention (Qwen2's
46    /// usual configuration), where each KV head is shared by
47    /// `n_heads / n_kv_heads` query heads. See [`crate::attention`].
48    pub n_kv_heads: usize,
49    /// The model's hidden/embedding width (`d_model`).
50    pub hidden_size: usize,
51    /// Width of one attention head: `hidden_size / n_heads`. Not an
52    /// independent metadata field — GGUF does not record it separately —
53    /// but derived here once so every consumer agrees on it.
54    pub head_dim: usize,
55    /// Width of the SwiGLU MLP's gate/up projections.
56    pub ffn_hidden_size: usize,
57    /// Vocabulary size — the row count of the token embedding table.
58    pub vocab_size: usize,
59    /// The context window this model's KV cache should be sized for.
60    pub max_context: usize,
61    /// RoPE base frequency (`theta`). Defaults to `10000.0`, the value
62    /// every LLaMA/Qwen checkpoint has shipped with to date, when the
63    /// metadata omits `rope.freq_base`.
64    pub rope_theta: f32,
65    /// Number of leading dimensions of each head RoPE actually rotates.
66    /// Defaults to `head_dim` (full rotary) when the metadata omits
67    /// `rope.dimension_count` — which is what every current Qwen2/LLaMA
68    /// GGUF export does, since full rotary is their only configuration; the
69    /// field exists in the format for architectures (e.g. GPT-NeoX-style
70    /// partial rotary) that rotate only a prefix of each head.
71    pub rope_dimension_count: usize,
72    /// RMSNorm epsilon. Defaults to `1e-6`, the value every current Qwen2
73    /// checkpoint uses, when the metadata omits both
74    /// `attention.layer_norm_rms_epsilon` and `attention.layer_norm_epsilon`.
75    pub norm_eps: f32,
76}
77
78impl QwenConfig {
79    /// Resolves a [`QwenConfig`] from a loaded model's metadata.
80    ///
81    /// # Errors
82    ///
83    /// Returns [`Error::MalformedModel`] if any field with no safe default
84    /// (layer count, either head count, hidden size, feed-forward size, or
85    /// vocab size) is absent, or if `n_heads` does not evenly divide
86    /// `hidden_size` (which would make `head_dim` — and therefore every
87    /// weight matrix shape this crate assumes — ill-defined), or if
88    /// `n_kv_heads` does not evenly divide `n_heads` (which would make the
89    /// grouped-query head-repeat in [`crate::attention`] ill-defined).
90    pub fn from_metadata(metadata: &ModelMetadata) -> Result<Self> {
91        let n_layers = require(metadata.n_layers, "block_count")?;
92        let n_heads = require(metadata.n_heads, "attention.head_count")? as usize;
93        // GGUF's own convention (see kopitiam_loader::ModelMetadata::n_kv_heads
94        // docs): an absent head_count_kv means "equal to n_heads", i.e.
95        // ordinary multi-head attention rather than GQA. That fallback is a
96        // modeling decision the loader deliberately leaves to its consumer;
97        // this is where it is made.
98        let n_kv_heads = metadata.n_kv_heads.map(|v| v as usize).unwrap_or(n_heads);
99        let hidden_size = require(metadata.embedding_length, "embedding_length")? as usize;
100        let ffn_hidden_size = require(metadata.feed_forward_length, "feed_forward_length")?;
101        let vocab_size = require(metadata.vocab_size, "vocab_size (tokenizer.ggml.tokens length)")?;
102        let max_context = metadata.context_length.map(|v| v as usize).unwrap_or(4096);
103
104        if n_heads == 0 || !hidden_size.is_multiple_of(n_heads) {
105            return Err(malformed(format!(
106                "attention.head_count ({n_heads}) must evenly divide embedding_length ({hidden_size})"
107            )));
108        }
109        let head_dim = hidden_size / n_heads;
110
111        if n_kv_heads == 0 || !n_heads.is_multiple_of(n_kv_heads) {
112            return Err(malformed(format!(
113                "attention.head_count_kv ({n_kv_heads}) must evenly divide attention.head_count ({n_heads})"
114            )));
115        }
116
117        let rope_theta = metadata.rope_theta.unwrap_or(10_000.0);
118        let rope_dimension_count = metadata
119            .rope_dimension_count
120            .map(|v| v as usize)
121            .unwrap_or(head_dim);
122        if rope_dimension_count == 0 || rope_dimension_count > head_dim {
123            return Err(malformed(format!(
124                "rope.dimension_count ({rope_dimension_count}) must be in 1..=head_dim ({head_dim})"
125            )));
126        }
127        // RoPE pairs dimension i with i + rope_dimension_count/2 (see
128        // crate::rope), so an odd rotary width has no valid pairing.
129        if !rope_dimension_count.is_multiple_of(2) {
130            return Err(malformed(format!(
131                "rope.dimension_count ({rope_dimension_count}) must be even: RoPE pairs dimension i with i + count/2"
132            )));
133        }
134
135        let norm_eps = metadata.norm_epsilon.unwrap_or(1e-6);
136
137        Ok(Self {
138            n_layers: n_layers as usize,
139            n_heads,
140            n_kv_heads,
141            hidden_size,
142            head_dim,
143            ffn_hidden_size: ffn_hidden_size as usize,
144            vocab_size: vocab_size as usize,
145            max_context,
146            rope_theta,
147            rope_dimension_count,
148            norm_eps,
149        })
150    }
151
152    /// Query heads sharing each key/value head: `n_heads / n_kv_heads`.
153    /// `1` for ordinary multi-head attention, `> 1` under GQA.
154    pub fn gqa_group_size(&self) -> usize {
155        self.n_heads / self.n_kv_heads
156    }
157}
158
159fn require(field: Option<u64>, key: &str) -> Result<u64> {
160    field.ok_or_else(|| malformed(format!("missing required metadata field \"{key}\"")))
161}
162
163fn malformed(reason: String) -> Error {
164    Error::MalformedModel { format: FORMAT, reason }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    fn base_metadata() -> ModelMetadata {
172        ModelMetadata {
173            n_layers: Some(2),
174            n_heads: Some(4),
175            n_kv_heads: Some(2),
176            embedding_length: Some(16),
177            feed_forward_length: Some(32),
178            context_length: Some(512),
179            vocab_size: Some(100),
180            ..Default::default()
181        }
182    }
183
184    #[test]
185    fn resolves_a_complete_config_with_explicit_values() {
186        let mut metadata = base_metadata();
187        metadata.rope_theta = Some(1_000_000.0);
188        metadata.rope_dimension_count = Some(4);
189        metadata.norm_epsilon = Some(1e-5);
190
191        let config = QwenConfig::from_metadata(&metadata).unwrap();
192        assert_eq!(config.n_layers, 2);
193        assert_eq!(config.n_heads, 4);
194        assert_eq!(config.n_kv_heads, 2);
195        assert_eq!(config.hidden_size, 16);
196        assert_eq!(config.head_dim, 4);
197        assert_eq!(config.ffn_hidden_size, 32);
198        assert_eq!(config.vocab_size, 100);
199        assert_eq!(config.max_context, 512);
200        assert_eq!(config.rope_theta, 1_000_000.0);
201        assert_eq!(config.rope_dimension_count, 4);
202        assert_eq!(config.norm_eps, 1e-5);
203        assert_eq!(config.gqa_group_size(), 2);
204    }
205
206    #[test]
207    fn missing_n_kv_heads_falls_back_to_ordinary_multi_head_attention() {
208        let mut metadata = base_metadata();
209        metadata.n_kv_heads = None;
210        let config = QwenConfig::from_metadata(&metadata).unwrap();
211        assert_eq!(config.n_kv_heads, config.n_heads);
212        assert_eq!(config.gqa_group_size(), 1);
213    }
214
215    #[test]
216    fn missing_rope_and_norm_fields_use_documented_defaults() {
217        let metadata = base_metadata();
218        let config = QwenConfig::from_metadata(&metadata).unwrap();
219        assert_eq!(config.rope_theta, 10_000.0);
220        assert_eq!(config.rope_dimension_count, config.head_dim);
221        assert_eq!(config.norm_eps, 1e-6);
222    }
223
224    #[test]
225    fn missing_required_field_is_rejected() {
226        let mut metadata = base_metadata();
227        metadata.n_layers = None;
228        assert!(matches!(
229            QwenConfig::from_metadata(&metadata),
230            Err(Error::MalformedModel { .. })
231        ));
232    }
233
234    #[test]
235    fn head_count_that_does_not_divide_hidden_size_is_rejected() {
236        let mut metadata = base_metadata();
237        metadata.n_heads = Some(3); // 16 is not divisible by 3
238        assert!(matches!(
239            QwenConfig::from_metadata(&metadata),
240            Err(Error::MalformedModel { .. })
241        ));
242    }
243
244    #[test]
245    fn kv_head_count_that_does_not_divide_head_count_is_rejected() {
246        let mut metadata = base_metadata();
247        metadata.n_kv_heads = Some(3); // 4 heads is not divisible by 3
248        assert!(matches!(
249            QwenConfig::from_metadata(&metadata),
250            Err(Error::MalformedModel { .. })
251        ));
252    }
253
254    #[test]
255    fn odd_rope_dimension_count_is_rejected() {
256        let mut metadata = base_metadata();
257        metadata.rope_dimension_count = Some(3);
258        assert!(matches!(
259            QwenConfig::from_metadata(&metadata),
260            Err(Error::MalformedModel { .. })
261        ));
262    }
263}