kopitiam_runtime/bridge.rs
1//! The loader/tensor bridge: turning a [`LoadedModel`]'s raw bytes into
2//! [`Tensor`]s.
3//!
4//! `kopitiam-loader` and `kopitiam-tensor` were built concurrently and
5//! deliberately do not depend on each other (see `kopitiam-loader`'s crate
6//! docs, "Why this crate never constructs a `Tensor`"). `LoadedModel` hands
7//! back a [`TensorEntry`] (name, [`DType`], [`Shape`]) plus raw on-disk
8//! bytes via [`LoadedModel::tensor_bytes`]; this module is the one place in
9//! the workspace that combines the two into a real [`Tensor`], because
10//! `kopitiam-runtime` is the first crate that depends on both.
11//!
12//! # Byte order
13//!
14//! Every numeric format this module decodes (`f32`, raw `f16`/`bf16` bits,
15//! `i32`) is read little-endian. GGUF's spec is explicit that little-endian
16//! is the only format loaders need to support in practice (see
17//! `kopitiam_loader::gguf`'s module docs), and SafeTensors likewise stores
18//! tensors little-endian. Block-quantized formats ([`DType::is_quantized`])
19//! need no byte-order handling here at all: their bytes are handed to
20//! [`Tensor::from_quantized`] unmodified, and [`crate::quant`]-style
21//! decoding (inside `kopitiam-tensor`) owns interpreting the block layout.
22
23use kopitiam_core::{DType, Error, Result};
24use kopitiam_loader::{LoadedModel, TensorEntry};
25use kopitiam_tensor::Tensor;
26
27/// Builds a [`Tensor`] from one [`TensorEntry`]'s bytes, preserving its
28/// on-disk [`DType`] exactly (still block-quantized if `entry.dtype` is
29/// quantized, still `f16`/`bf16` if the file stored it that way).
30///
31/// Call [`Tensor::to_dtype`]`(DType::F32)` on the result to get a tensor the
32/// rest of `kopitiam-tensor`'s ops (which are `f32`-only, see that crate's
33/// docs) can actually compute on; [`load_tensor_f32`] does exactly that in
34/// one step; it is what every weight-loading call site in this crate uses.
35pub fn tensor_from_entry(model: &LoadedModel, entry: &TensorEntry) -> Result<Tensor> {
36 let bytes = model.tensor_bytes(&entry.name)?;
37 match entry.dtype {
38 DType::F32 => Tensor::from_f32(read_f32_le(bytes), entry.shape.clone()),
39 DType::F16 => Tensor::from_f16(read_u16_le(bytes), entry.shape.clone()),
40 DType::BF16 => Tensor::from_bf16(read_u16_le(bytes), entry.shape.clone()),
41 DType::I8 => Tensor::from_i8(bytes.iter().map(|&b| b as i8).collect(), entry.shape.clone()),
42 DType::I32 => Tensor::from_i32(read_i32_le(bytes), entry.shape.clone()),
43 DType::Q4_0
44 | DType::Q4_1
45 | DType::Q5_0
46 | DType::Q5_1
47 | DType::Q8_0
48 | DType::Q2_K
49 | DType::Q3_K
50 | DType::Q4_K
51 | DType::Q5_K
52 | DType::Q6_K
53 | DType::Q8_K => Tensor::from_quantized(entry.dtype, bytes.to_vec(), entry.shape.clone()),
54 }
55}
56
57/// Looks up `name` in `model`, builds a [`Tensor`] from its bytes, and
58/// dequantizes/upcasts it to `f32` — the dtype every op in `kopitiam-tensor`
59/// actually computes in. This is the call every weight-loading site in
60/// [`crate::weights`] goes through: model files may ship `f16`, `bf16`, or
61/// any of the five GGUF block-quantized formats, and this one function
62/// makes the rest of the forward pass indifferent to which.
63///
64/// # Errors
65///
66/// [`Error::MissingTensor`] if `name` is not present in `model`; whatever
67/// [`Tensor::to_dtype`] would return for a dtype this crate cannot
68/// dequantize (today, every [`DType`] this loader can produce *can* be
69/// dequantized to `f32`, so this is unreachable in practice, not a real gap
70/// — see [`Tensor::to_dtype`]'s docs).
71pub fn load_tensor_f32(model: &LoadedModel, name: &str) -> Result<Tensor> {
72 let entry = model
73 .tensor(name)
74 .ok_or_else(|| Error::MissingTensor { name: name.to_string() })?;
75 tensor_from_entry(model, entry)?.to_dtype(DType::F32)
76}
77
78/// Like [`load_tensor_f32`], but returns `Ok(None)` instead of
79/// [`Error::MissingTensor`] when `name` is absent.
80///
81/// Exists for optional weights: per-projection attention biases (present in
82/// Qwen2, absent in plain LLaMA) and the output projection (absent when it
83/// is tied to the token embedding — see [`crate::weights::ModelWeights`]'s
84/// docs on tied embeddings).
85pub fn load_tensor_f32_opt(model: &LoadedModel, name: &str) -> Result<Option<Tensor>> {
86 match model.tensor(name) {
87 Some(entry) => Ok(Some(tensor_from_entry(model, entry)?.to_dtype(DType::F32)?)),
88 None => Ok(None),
89 }
90}
91
92/// Loads `name` the way every *matmul-operand* weight
93/// (`wq`/`wk`/`wv`/`wo`, the SwiGLU MLP's three matrices, and
94/// `output.weight` — see [`crate::weights::LayerWeights`] /
95/// [`crate::weights::ModelWeights`]) should: keeping a block-quantized
96/// on-disk dtype *only when there is a fused kernel to compute on it*, and
97/// otherwise dequantizing it to `f32` at load.
98///
99/// # Why this is a separate function from [`load_tensor_f32`], not a flag
100///
101/// Every weight in a Qwen checkpoint is *not* interchangeable for this
102/// purpose. Token embeddings go through [`kopitiam_tensor::Tensor::gather_rows`]
103/// (row-indexed lookup) and norm weights go through elementwise
104/// arithmetic — both require [`kopitiam_tensor::DType::is_quantized`] to
105/// be false (see that crate's docs on why a quantized tensor cannot be
106/// indexed elementwise at all), so those call sites must keep using
107/// [`load_tensor_f32`]. Only the seven weight matrices that feed
108/// [`crate::linear::linear`] (a matmul) benefit from staying quantized, so
109/// this is a distinct, narrowly-named function rather than a boolean
110/// parameter that would let a caller accidentally quantize an embedding or a
111/// norm.
112///
113/// # Why only *some* quantized dtypes stay quantized
114///
115/// [`kopitiam_tensor::Tensor::quantized_matmul`] has a fused,
116/// dequantize-free kernel for only [`DType::Q4_0`] and [`DType::Q8_0`]
117/// today (see [`kopitiam_tensor::has_fused_matmul_kernel`], the shared
118/// predicate this function keys off so the load-time and compute-time
119/// decisions cannot drift). A weight in any *other* quantized format —
120/// `Q4_1`/`Q5_0`/`Q5_1` or a K-quant (`Q4_K`/`Q5_K`/`Q6_K`/...) — has no
121/// fused kernel, so leaving it quantized would dead-end
122/// [`crate::linear::linear`] in an [`Error::UnsupportedDType`]. Those are
123/// dequantized to `f32` here so the forward pass takes the ordinary
124/// [`kopitiam_tensor::Tensor::matmul`] path. This mirrors what happens to
125/// `f16`/`bf16` weights (also upcast to `f32`, no half-precision matmul
126/// kernel exists yet): `f32` is this crate's fallback for any dtype without
127/// a fused kernel. When a GPU backend later fuses more formats, only
128/// `has_fused_matmul_kernel` changes and those formats begin staying
129/// quantized here automatically.
130///
131/// # Errors
132///
133/// [`Error::MissingTensor`] if `name` is not present in `model`.
134pub fn load_matmul_weight(model: &LoadedModel, name: &str) -> Result<Tensor> {
135 let entry = model
136 .tensor(name)
137 .ok_or_else(|| Error::MissingTensor { name: name.to_string() })?;
138 let tensor = tensor_from_entry(model, entry)?;
139 keep_quantized_or_dequantize(tensor)
140}
141
142/// Like [`load_matmul_weight`], but returns `Ok(None)` instead of
143/// [`Error::MissingTensor`] when `name` is absent — the matmul-operand
144/// counterpart to [`load_tensor_f32_opt`], for the same optional weights
145/// (per-projection attention biases, `output.weight` when tied to the
146/// token embedding).
147pub fn load_matmul_weight_opt(model: &LoadedModel, name: &str) -> Result<Option<Tensor>> {
148 match model.tensor(name) {
149 Some(entry) => {
150 let tensor = tensor_from_entry(model, entry)?;
151 Ok(Some(keep_quantized_or_dequantize(tensor)?))
152 }
153 None => Ok(None),
154 }
155}
156
157/// Keeps `tensor` as-is when its dtype has a fused
158/// [`kopitiam_tensor::Tensor::quantized_matmul`] kernel, and otherwise
159/// dequantizes/upcasts it to `f32`. The one place [`load_matmul_weight`] and
160/// [`load_matmul_weight_opt`] encode the load-time half of the invariant
161/// [`kopitiam_tensor::has_fused_matmul_kernel`] anchors.
162fn keep_quantized_or_dequantize(tensor: Tensor) -> Result<Tensor> {
163 if kopitiam_tensor::has_fused_matmul_kernel(tensor.dtype()) {
164 Ok(tensor)
165 } else {
166 tensor.to_dtype(DType::F32)
167 }
168}
169
170fn read_f32_le(bytes: &[u8]) -> Vec<f32> {
171 bytes.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().expect("chunks_exact(4)"))).collect()
172}
173
174fn read_u16_le(bytes: &[u8]) -> Vec<u16> {
175 bytes.chunks_exact(2).map(|c| u16::from_le_bytes(c.try_into().expect("chunks_exact(2)"))).collect()
176}
177
178fn read_i32_le(bytes: &[u8]) -> Vec<i32> {
179 bytes.chunks_exact(4).map(|c| i32::from_le_bytes(c.try_into().expect("chunks_exact(4)"))).collect()
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185 use crate::test_support::synthetic_gguf::{tiny_model_bytes, write_temp_gguf};
186
187 #[test]
188 fn tensor_from_entry_round_trips_f32_bytes() {
189 let bytes = tiny_model_bytes();
190 let path = write_temp_gguf(&bytes, "bridge-round-trip");
191 let model = kopitiam_loader::load_model(&path).unwrap();
192 let entry = model.tensor("token_embd.weight").unwrap().clone();
193 let t = tensor_from_entry(&model, &entry).unwrap();
194 assert_eq!(t.dtype(), DType::F32);
195 assert_eq!(t.shape(), &entry.shape);
196 }
197
198 #[test]
199 fn load_tensor_f32_opt_is_none_for_a_missing_name() {
200 let bytes = tiny_model_bytes();
201 let path = write_temp_gguf(&bytes, "bridge-opt-missing");
202 let model = kopitiam_loader::load_model(&path).unwrap();
203 assert!(load_tensor_f32_opt(&model, "does.not.exist").unwrap().is_none());
204 }
205
206 #[test]
207 fn load_tensor_f32_errors_on_a_missing_required_name() {
208 let bytes = tiny_model_bytes();
209 let path = write_temp_gguf(&bytes, "bridge-required-missing");
210 let model = kopitiam_loader::load_model(&path).unwrap();
211 assert!(matches!(
212 load_tensor_f32(&model, "does.not.exist"),
213 Err(Error::MissingTensor { .. })
214 ));
215 }
216
217 /// A K-quant matmul weight (Q4_K) has no fused
218 /// [`kopitiam_tensor::Tensor::quantized_matmul`] kernel, so before this
219 /// fix `load_matmul_weight` left it quantized and the first
220 /// [`crate::linear::linear`] call dead-ended in an `UnsupportedDType`.
221 /// It must now be dequantized to `f32` at load and complete a forward
222 /// pass whose output equals dequantize-then-`f32`-matmul.
223 #[test]
224 fn a_q4_k_matmul_weight_is_dequantized_at_load_and_completes_a_forward_pass() {
225 use crate::test_support::synthetic_gguf::{
226 arbitrary_q4_k_blocks, single_quantized_weight_gguf, GGML_TYPE_Q4_K,
227 };
228
229 // Q4_K super-block = 256 elements, so `in_features` is one super-block
230 // per row and each of the 4 rows is exactly one super-block.
231 let (out_features, in_features) = (4, 256);
232 let bytes = arbitrary_q4_k_blocks(out_features, 0xBEEF);
233 let gguf = single_quantized_weight_gguf("test.weight", &[out_features, in_features], GGML_TYPE_Q4_K, &bytes);
234 let path = write_temp_gguf(&gguf, "q4k-load");
235 let model = kopitiam_loader::load_model(&path).unwrap();
236
237 // The whole point of the fix: a format without a fused kernel is
238 // dequantized at load, not kept quantized.
239 let w = load_matmul_weight(&model, "test.weight").unwrap();
240 assert_eq!(w.dtype(), DType::F32);
241 assert!(!w.dtype().is_quantized());
242 assert!(!kopitiam_tensor::has_fused_matmul_kernel(DType::Q4_K));
243
244 // The loaded weight is exactly the decode of the same on-disk bytes.
245 let entry = model.tensor("test.weight").unwrap().clone();
246 let reference_w = tensor_from_entry(&model, &entry).unwrap().to_dtype(DType::F32).unwrap();
247 assert_eq!(w.to_vec_f32().unwrap(), reference_w.to_vec_f32().unwrap());
248
249 // A forward pass through `linear` now completes with finite output
250 // equal to dequantize-then-f32-matmul (`x @ W^T`).
251 let x_vals: Vec<f32> = (0..in_features).map(|i| (i as f32 % 7.0 - 3.0) * 0.1).collect();
252 let x = Tensor::from_f32(x_vals, [1, in_features]).unwrap();
253 let y = crate::linear::linear(&x, &w, None).unwrap();
254 assert_eq!(y.dtype(), DType::F32);
255 let y_vals = y.to_vec_f32().unwrap();
256 assert_eq!(y_vals.len(), out_features);
257 assert!(y_vals.iter().all(|v| v.is_finite()), "forward-pass output must be finite: {y_vals:?}");
258
259 let reference = x.matmul(&reference_w.transpose(0, 1).unwrap()).unwrap().to_vec_f32().unwrap();
260 assert_eq!(y_vals, reference);
261 }
262
263 /// The no-regression counterpart: a Q4_0 matmul weight — the format the
264 /// shipped `qwen2.5-0.5b-instruct-q4_0` model uses — must still be kept
265 /// quantized at load and run through the fused kernel, never dequantized.
266 #[test]
267 fn a_q4_0_matmul_weight_stays_quantized_at_load_and_uses_the_fused_kernel() {
268 use crate::test_support::synthetic_gguf::{
269 quantize_q4_0_blocks, single_quantized_weight_gguf, GGML_TYPE_Q4_0,
270 };
271
272 let (out_features, in_features) = (3, 64); // 2 blocks of 32 per row.
273 let data: Vec<f32> = (0..out_features * in_features).map(|i| (i as f32 * 0.037).sin() * 0.5).collect();
274 let bytes = quantize_q4_0_blocks(&data);
275 let gguf = single_quantized_weight_gguf("test.weight", &[out_features, in_features], GGML_TYPE_Q4_0, &bytes);
276 let path = write_temp_gguf(&gguf, "q40-load");
277 let model = kopitiam_loader::load_model(&path).unwrap();
278
279 let w = load_matmul_weight(&model, "test.weight").unwrap();
280 assert_eq!(w.dtype(), DType::Q4_0, "Q4_0 must stay quantized for the fused kernel");
281 assert!(w.dtype().is_quantized());
282 assert!(kopitiam_tensor::has_fused_matmul_kernel(w.dtype()));
283
284 // `linear` routes it through the fused `quantized_matmul`, matching a
285 // direct fused call bit-for-bit (i.e. no dequantize slipped in).
286 let x_vals: Vec<f32> = (0..in_features).map(|i| (i as f32 % 5.0 - 2.0) * 0.1).collect();
287 let x = Tensor::from_f32(x_vals, [1, in_features]).unwrap();
288 let y = crate::linear::linear(&x, &w, None).unwrap();
289 let fused = x.quantized_matmul(&w).unwrap();
290 assert_eq!(y.to_vec_f32().unwrap(), fused.to_vec_f32().unwrap());
291 assert!(y.to_vec_f32().unwrap().iter().all(|v| v.is_finite()));
292 }
293}