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}