Skip to main content

kopitiam_loader/
model.rs

1//! The format-agnostic result of loading a model file, and the trait that
2//! produces it.
3
4use std::io::Read;
5use std::path::Path;
6
7use indexmap::IndexMap;
8use kopitiam_core::{DType, Error, Result, Shape};
9
10use crate::byte_source::ByteSource;
11use crate::gguf::GgufLoader;
12use crate::metadata::ModelMetadata;
13use crate::safetensors::SafeTensorsLoader;
14
15/// One tensor's identity and location, without its bytes.
16///
17/// Deliberately does *not* hold a `&[u8]` or a `Vec<u8>` directly. Doing so
18/// would tie every `TensorEntry` to the lifetime of (or force a copy out
19/// of) the [`LoadedModel`] that owns the backing storage, for no benefit —
20/// nothing in this crate ever needs a tensor's bytes without also having
21/// the `LoadedModel` at hand. Storing an offset and length instead, and
22/// serving bytes through [`LoadedModel::tensor_bytes`], keeps `TensorEntry`
23/// cheap to clone and keeps the bounds check in exactly one place
24/// ([`ByteSource::slice`](crate::byte_source::ByteSource::slice)).
25#[derive(Debug, Clone, PartialEq)]
26pub struct TensorEntry {
27    /// The tensor's name as it appears in the file, e.g. `"blk.0.attn_q.weight"`.
28    pub name: String,
29    pub dtype: DType,
30    /// Dimensions in [`kopitiam_core::Shape`]'s convention: outermost
31    /// first, row-major, last dimension contiguous. See the crate-level
32    /// docs and [`crate::gguf`] for why GGUF's on-disk dimension order has
33    /// to be reversed to produce this.
34    pub shape: Shape,
35    pub(crate) offset: usize,
36    pub(crate) len: usize,
37}
38
39/// A parsed model file: metadata plus a directory of tensors, backed by one
40/// open file.
41///
42/// This type intentionally never constructs a `Tensor`. `kopitiam-tensor`
43/// owns that type and is being developed independently of this crate; a
44/// loader that returned tensors would have to depend on (and track the
45/// in-flux API of) a crate it has no need to know about. Instead,
46/// `LoadedModel` hands out exactly what a tensor needs to be built —
47/// dtype, shape, and raw bytes — and lets whoever owns `Tensor` do the
48/// building.
49pub struct LoadedModel {
50    pub(crate) metadata: ModelMetadata,
51    pub(crate) tensors: IndexMap<String, TensorEntry>,
52    pub(crate) source: ByteSource,
53    pub(crate) format: &'static str,
54}
55
56impl std::fmt::Debug for LoadedModel {
57    /// Deliberately hand-written rather than `#[derive]`d: the backing
58    /// storage can be a multi-gigabyte `mmap`, and a derived `Debug` would
59    /// either fail to compile (`memmap2::Mmap` is not `Debug`) or, worse,
60    /// succeed and dump gigabytes of tensor bytes into a log line. This
61    /// prints only what is useful for diagnostics: the format, tensor
62    /// count and names, and the metadata.
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        f.debug_struct("LoadedModel")
65            .field("format", &self.format)
66            .field("metadata", &self.metadata)
67            .field("tensors", &self.tensors.values().collect::<Vec<_>>())
68            .finish()
69    }
70}
71
72impl LoadedModel {
73    /// The format-agnostic hyperparameters and raw metadata bag.
74    pub fn metadata(&self) -> &ModelMetadata {
75        &self.metadata
76    }
77
78    /// `"gguf"` or `"safetensors"` — matches the `format` field of any
79    /// [`Error::MalformedModel`] this model's loader could have produced.
80    pub fn format(&self) -> &'static str {
81        self.format
82    }
83
84    /// Number of tensors in the file.
85    pub fn tensor_count(&self) -> usize {
86        self.tensors.len()
87    }
88
89    /// Tensor names, in file order.
90    pub fn tensor_names(&self) -> impl Iterator<Item = &str> {
91        self.tensors.keys().map(String::as_str)
92    }
93
94    /// Looks up a tensor's name/dtype/shape by name, without touching its
95    /// bytes.
96    pub fn tensor(&self, name: &str) -> Option<&TensorEntry> {
97        self.tensors.get(name)
98    }
99
100    /// All tensor entries, in file order.
101    pub fn tensors(&self) -> impl Iterator<Item = &TensorEntry> {
102        self.tensors.values()
103    }
104
105    /// The raw bytes of the named tensor.
106    ///
107    /// This is the loader/tensor boundary: whoever builds a
108    /// `kopitiam_tensor::Tensor` from a loaded model calls this to get the
109    /// bytes, combines them with [`TensorEntry::dtype`] and
110    /// [`TensorEntry::shape`] (available via [`LoadedModel::tensor`]), and
111    /// constructs the tensor on their side of the boundary. The bytes
112    /// returned are exactly the on-disk encoding for `dtype` — still
113    /// block-quantized if `dtype` is quantized, still `f16`/`bf16` if the
114    /// file stored it that way. This loader does not dequantize or convert
115    /// anything.
116    pub fn tensor_bytes(&self, name: &str) -> Result<&[u8]> {
117        let entry = self
118            .tensors
119            .get(name)
120            .ok_or_else(|| Error::MissingTensor { name: name.to_string() })?;
121        self.source.slice(self.format, entry.offset, entry.len)
122    }
123}
124
125/// A parser for one on-disk model format.
126///
127/// Implemented by [`crate::GgufLoader`] and [`crate::SafeTensorsLoader`].
128/// The trait exists (rather than two unrelated `load` free functions) so
129/// that callers who *do* know their format up front can hold a
130/// `Box<dyn ModelLoader>` chosen once, and so that [`load_model`] itself is
131/// just "try each known loader" instead of hand-rolled dispatch logic
132/// duplicated at every call site that needs to support both formats.
133pub trait ModelLoader {
134    /// A short, lowercase name for the format this loader parses — always
135    /// one of the `format` strings this loader's errors carry.
136    fn format_name(&self) -> &'static str;
137
138    /// Cheap, non-destructive sniff: does `bytes` (the start of the file)
139    /// look like this loader's format? Used by [`load_model`] to pick a
140    /// loader by content rather than by file extension.
141    ///
142    /// A `true` result is not a promise the file will load successfully —
143    /// only that it is worth trying. A `false` result *is* a promise this
144    /// loader will refuse the file, so [`load_model`] can skip straight to
145    /// the next candidate without wasting a full parse attempt.
146    fn probe(&self, bytes: &[u8]) -> bool;
147
148    /// Parses the file at `path` into a [`LoadedModel`].
149    fn load(&self, path: &Path) -> Result<LoadedModel>;
150}
151
152/// Loads a model file, choosing GGUF or SafeTensors by sniffing its
153/// content.
154///
155/// # Why content, not extension
156///
157/// GGUF files always start with the four-byte magic `b"GGUF"`
158/// ([`GgufLoader::probe`](crate::GgufLoader)), so sniffing it is exact.
159/// SafeTensors has no magic number at all — a file starting with anything
160/// other than the GGUF magic is *assumed* to be SafeTensors and handed to
161/// [`SafeTensorsLoader`], which will itself fail with
162/// [`Error::MalformedModel`] if that assumption is wrong. This is still
163/// preferable to trusting a `.gguf`/`.safetensors` extension: an extension
164/// is metadata about the file, supplied by whoever named it, not a fact
165/// about the bytes — sniffing means a renamed or extension-less file still
166/// loads correctly, and a genuinely unrecognized file still fails loudly
167/// rather than silently mis-parsing.
168pub fn load_model(path: impl AsRef<Path>) -> Result<LoadedModel> {
169    let path = path.as_ref();
170
171    // Read only the handful of bytes probing needs — not the whole file.
172    // A file shorter than 8 bytes legitimately yields fewer; that is not an
173    // I/O error, it just means every probe below sees a shorter (possibly
174    // empty) slice and correctly reports "not this format". `take(8)` plus
175    // `read_to_end` (rather than one `read` call into a fixed buffer) is
176    // used because a single `read` is allowed to return short even when
177    // more bytes are available.
178    let file = std::fs::File::open(path)?;
179    let mut probe_bytes = Vec::with_capacity(8);
180    file.take(8).read_to_end(&mut probe_bytes)?;
181
182    let gguf = GgufLoader;
183    if gguf.probe(&probe_bytes) {
184        return gguf.load(path);
185    }
186
187    SafeTensorsLoader.load(path)
188}