03 · Model dispatch and weights
Stage: GGUF parsed (02) → this stage: the file becomes a runnable model, with weights wired into every backend → tokenizer + template (04). Code:
models/mod.rs::load_model(dispatch),models/qwen2/loader.rs::loadandmodels/qwen3/loader.rs::load(the two implementations),main.rs:637-657(GPU init + legacy KV cache),tensor.rs(Cow<'static,[u8]>weight bytes),graph/alloc.rs::register_weight,src/metal/::register_weight/cuda.rs::register_weight(per-backend registries),models/qwen2/graph.rs::weights_on_gpu(the GPU participation gate).
1. Background — where this stage sits
Doc 02 left us holding a GgufModel. It is a container, not yet a model:
a parsed metadata table (key/value pairs), a tensor index (name, quant type,
shape, offset for every tensor), and the raw data blob of the file memory-mapped
into the process. Nothing in that container knows what a transformer is. If you
asked it "how many layers does this model have?", it can only answer "there is
a metadata key somewhere that might say". The bytes are all there, but they
have no meaning yet.
This stage gives them meaning. Two things happen, and they happen in this
order. First, the engine initializes its GPU backends — Metal on macOS,
CUDA when the binary was built with --features cuda and an NVIDIA GPU is
present. Second, the engine looks at one metadata string,
general.architecture, and dispatches the whole file to the model
implementation that matches it: "qwen2" goes to the Qwen2/Qwen2.5 loader,
"qwen3" to the Qwen3 loader. The loader then reads the hyperparameters
(the dimension numbers of the architecture — layer count, head counts,
embedding width; these are the settings that were chosen before training and
are never learned) and gathers every weight tensor by name. A weight is
one block of the model's learned numbers: the big matrices that projections
multiply by, the small gain vectors of the normalization layers, and the
optional bias vectors. "Learned" means fixed by training; inference never
changes them.
The output of this stage is a single object behind the ModelDef trait —
for example Qwen2Model — holding hyperparameters plus every weight tensor,
with its bytes still sitting in the mmap'd file. Alongside it, each available
backend has been told about the weights it cares about. Those registrations
are what make the next stages work: doc 05 will build a compute graph whose
nodes reference weights by name, and docs 07–08 will allocate buffers and
execute ops that fetch their weight through the registries this stage fills.
Why is the ordering "GPU init first, then dispatch" and not the other way round? Because loading is not a read-only operation. The loaders register weight tensors into the GPU registries while they walk the tensor list, so the registry must already exist. Get this wrong in one specific way — CUDA initialized lazily in the middle of a load — and you get a half-registered model whose backend gate flips between the first and the second half of the tensor list. The loader defends against exactly that (we will see the guard in §3.2).
What would break without this stage? Almost everything downstream, and in
ways that are loud rather than subtle. The graph builder needs the
hyperparameters to know how many layers to emit and what shapes the ops have.
The allocator needs n_kv_embd (the per-layer key/value width) to size the
persistent KV regions — a KV cache stores, per layer, the key and value
vectors of every token generated so far (doc 11 covers the mechanism). The
samplers need the end-of-sequence token ids, which are metadata this stage
parses. And the backends need the name→weight registries: a matmul whose
weight is not registered fails at execute time with weight '...' not registered. In short, this stage turns a parsed file into a machine the rest
of the pipeline can drive. One thing to keep in mind from here on: nothing
is dequantized at load — the weights stay exactly the bytes the file
shipped, 4-bit nibbles and block scales and all. §2.3 explains why that is a
feature, not laziness.
2. Principle — how it works and why
2.1 The cast of characters
The stage has five players. It is worth naming them once, because the rest of the doc is just their handshakes.
GgufModel(doc 02) — metadata key/values, the tensor index, and&'static [u8]slices into the mmap'd file parts.load_model(models/mod.rs) — readsgeneral.architectureand picks the implementation. This is the dispatch.- The per-architecture loader (
models/qwen2/loader.rs,models/qwen3/loader.rs) — parses hyperparameters, gathers tensors by name, registers them with the backends, and assembles the model struct. ModelDef(models/mod.rs) — the architecture-agnostic interface the rest of the engine talks to. Downstream code never says "Qwen2"; it says "whatever model is loaded, give men_layer(), build me a graph, format this chat".- The backend registries —
CpuBackend(a name→tensor map),MpsState(a name→(Metal buffer, offset) map),CudaState(a name→(device pointer, size) map). "Registry" here just means a hash map from a weight's GGUF name to whatever the backend needs in order to use it.
GgufModel ──"general.architecture"──► load_model ──► qwen2::loader::load
│
┌──────────────────────────────┴───────────────┐
▼ ▼
HParams (dims, token ids) Tensor per weight (bytes
│ borrowed from the mmap)
▼ │
Qwen2Model : ModelDef ────t.clone()────► GraphAllocator │
│ (CPU: name → Tensor) │
│ │
GPU init happens BEFORE load: │
MpsState::init() → name → (MTLBuffer, offset) ───────────────────┤
CudaState::init → name → (device ptr, size) ───────────────────┘
(graph nodes reference weights
by name only, not by pointer)
2.2 Dispatch on a metadata string
GGUF writes the architecture family into a top-level metadata key. minfer reads it once and matches it against the implementations it ships:
"qwen2"→ Qwen2 / Qwen2.5 (the 0.5B, 1.5B, 7B… checkpoints all reportqwen2; Qwen2.5 differs from Qwen2 only in training, not in tensor layout)."qwen3"→ Qwen3 dense (same overall wiring plus two twists we cover in §3.2: a decoupled head dimension and per-head Q/K norms).
The alternative — "try each loader until one succeeds" — is strictly worse, and the reasons are worth spelling out because they shape the whole design.
Determinism. A string match is a total function: one key, one answer. A
trial-parse loop depends on what each loader happens to tolerate, and both of
minfer's loaders deliberately accept llama.* metadata keys as a fallback
(some fine-tunes re-label their metadata). Two tolerant loaders plus a
try-loop is a recipe for loading a Qwen3 file as "some kind of qwen2" — a
parse success that is semantically wrong and would corrupt attention shapes.
One clear error. When the string does not match anything, the user gets
Unsupported architecture: 'llama' — the actual offending string — and the
run stops before any GPU work happens. A try-loop's failure mode is instead
"N loaders each printed a different complaint", or worse, a quiet success.
Load is side-effectful. This is the mechanical clincher. Loading registers weights into GPU registries, and on CUDA a registration is a one-way upload into device memory. A "try and reject" loader would leave half a model uploaded with no clean way to un-register (see §3.4: CUDA deliberately never frees stale weight buffers). Dispatching first, then loading exactly once, keeps the side effects all-or-nothing too.
2.3 Weights stay raw bytes
The single most important data decision of this stage: a loaded weight tensor
is a view, not a copy. The Tensor type stores its payload as
std::borrow::Cow<'static, [u8]> — a Rust "clone-on-write" enum that here is
always the Borrowed variant, i.e. a plain (pointer, length) slice pointing
into the mmap'd GGUF file. The 'static lifetime works because doc 02's
loader Box::leaks each mmap for the process lifetime (gguf.rs:1804-1810).
Three consequences follow, and each one answers a "why not the obvious alternative":
Why not dequantize to f32 at load? Because every consumer wants the bytes
as they are. The CPU matmul kernels (doc 10) consume packed 4-bit nibbles
directly — they dequantize a block on the fly inside the dot product, one
32-value block at a time, and never materialize the full f32 tensor. The GPU
kernels do the same in shaders (Metal) or stream raw quantized bytes (CUDA's
int8 MMQ path). Meanwhile the memory math is brutal for the alternative: a
Q4_0 block is 18 bytes for 32 values (2-byte fp16 scale + 16 bytes of
nibbles, block.rs:53-56), which is ≈ 0.56 bytes per value. A
0.5-billion-parameter checkpoint is then ≈ 0.28 GB of file bytes; dequantized
to f32 it would be 2 GB — 7× more, copied at load time, for zero benefit.
Why does cloning a Tensor not copy bytes? Because Cow::Borrowed
clones as pointer + length. That is what makes it affordable for the graph
path to re-register all weights on every graph (re)build (t.clone() at
qwen2/graph.rs:354) — the clone duplicates a small struct and a name
string, not gigabytes. The one caveat — registries still guard against
re-registration when a caller hands them owned bytes — has a measured war
story attached, told with excerpt 10.
Why do GPU backends get their own representation? Because "the weight" means something different per backend: on CPU it is the mmap bytes; on Metal it is a byte range inside a shared-memory buffer; on CUDA it is a pointer into device memory. The registry abstracts exactly that, and §2.4 walks each one.
2.4 What "the weight is on the GPU" means, per backend
The phrase "weights on GPU" hides three quite different mechanisms. Getting them straight explains everything the loader does.
CPU — registration is free. CpuBackend keeps
HashMap<String, Tensor>. Registering inserts the tensor struct. The bytes
were already in the process (they are the mmap pages, faulted in on first
touch), so the "registration" moves no data at all. On CPU, "the weight is
registered" means only "the name resolves to a byte range".
Metal — the GPU reads the same physical pages. On Apple Silicon, CPU and
GPU share one physical memory ("unified memory"). Metal exposes buffers that
both sides can address (StorageModeShared). The trick is that minfer does
not copy each weight into such a buffer. Before any weight is registered, the
loader hands each mmap'd file part to MpsState::register_part, which wraps
the whole mmap in one Metal buffer via newBufferWithBytesNoCopy — "no
copy" is the API's name and its contract. Each individual weight is then
registered as (buffer, byte offset) into that one buffer. The GPU reads the
file's pages directly; there is no GPU-side allocation and no memcpy, ever.
The one cost is a first-touch one: the very first GPU access to file-backed
pages pays ~44 ms of page/TLB setup, which the loader deliberately triggers
once at load time, outside the timed inference window (src/metal/runtime.rs).
CUDA — one upload, resident forever. NVIDIA GPUs have discrete memory
(device memory, VRAM) that the CPU cannot address; bytes must be copied
across the PCIe bus ("H2D", host-to-device). CudaState::register_weight
does cudaMalloc for the tensor's size, one cudaMemcpy H2D, and stores
(device pointer, size) under the name. That copy happens exactly once, at
load. From then on the weight is resident: every decode step reads it from
device memory at GPU bandwidth instead of re-uploading. This is the Phase 7
thesis in one sentence — a graph backend is only fast if the weights are
already addressable on the executing device, so registration is a load-time
job, not a per-step one. For scale: the 7B Q4_K_M model is ~4.4 GB of
weights; the alternative (per-step host staging) is exactly what the old
imperative path did for activations, and the CUDA campaign measured such
host round-trips at "~6 PCIe round trips × 24 layers ≈ 144 DMA operations
per decode step, 2–7 ms" (docs/CUDA_OPTIMIZATION.md). Resident weights
delete that entire class of cost.
2.5 The gate: GPU participation is all-or-nothing
Per §2.4, a backend can only execute an op if the op's weight lives where the op runs. The graph's backend assignment is per-op (doc 06), but the weights constrain it globally, so before building anything the graph path asks: "is every weight this model will use registered — and kernel-supported — on this backend?" Two functions do this:
Qwen2Graph::weights_on_gpu(Metal): every weight name must be present inMpsState's registry (models/qwen2/graph.rs:636-677).Qwen2Graph::weights_on_cuda(CUDA): every weight must be registered and of a type a kernel exists for — e.g. the embedding gather supports every registered type except Q4_1 (models/qwen2/graph.rs:695-756).
The result — metal_on || cuda_on — is stored in CParams.gpu, which is
part of the reuse identity: the fingerprint that decides whether a cached
graph can be reused (doc 13). Flip any env toggle or unplug the eGPU and the
next forward rebuilds the graph rather than executing stale assignments.
Why all-or-nothing rather than "put what fits on the GPU, layer by layer"?
The old imperative engine had a per-layer fallback, and it is preserved in
docs/ARCHITECTURE.md Appendix A.3 as a cautionary diagram: the moment one
layer failed its GPU check, the hidden state had to cross back to host
memory, the KV cache had to be synced to CPU, and the rest of the layers ran
on CPU — per token. In the graph path that cost is even sharper: KV regions
live on the backend that executes attention (by construction), so one
CPU-resident layer would force the whole layer's KV traffic across the bus
every step. The one-buffer-at-a-split-boundary design (doc 08) exists
precisely so cross-backend traffic happens a handful of times per forward,
not per op. All-or-nothing is how the design keeps that promise: either the
backend can host everything the graph reads, or it does not participate at
all.
2.6 Two architectures, one interface
Qwen2 and Qwen3 differ in exactly two load-time-visible ways, and both exist to keep the graph builder simple.
First, the head dimension. In Qwen2, the per-head size is derivable:
n_embd_head = n_embd / n_head. Qwen3 broke that identity — the 0.6B model
has n_embd / n_head = 64 but its keys are 128-wide — so the loader reads
qwen3.attention.key_length explicitly and asserts the K weight's actual
output width agrees (qwen3/loader.rs:311-326). Trust the bytes, not the
derived formula.
Second, per-head Q/K RMSNorm: Qwen3 normalizes each head's query and key
vectors before RoPE, with small learned gain vectors (q_norm, k_norm).
The loader stores them; the graph builder has a dedicated qk_norm op
(graph/builder.rs:99-119) that consumes them. The loader's job is
recognizing that these tensors exist and must not be lost — the minfer info listing truncates names, but they are in the file. Everything else —
the loader shape, the registration calls, the fused-QKV and fused-FFN concat
weights — is deliberately mirrored between the two loaders, so a new
architecture is a copy-and-edit job (§5).
3. Implementation
3.1 Data in / data out
In: the GgufModel from doc 02. Concretely, per part: ctx.kv (metadata
key/values), ctx.info (tensor index; each entry has name, type_, ne[4]
shape, and offset — offset from the start of the part's data section), and
part.data: &'static [u8] (the mmap'd bytes). A weight's file position is
ctx.offset + ti.offset, where ctx.offset is where the data section starts
in the file (gguf.rs:603-619).
Out: three things.
Box<dyn ModelDef>— the polymorphic model object (Qwen2Model/Qwen3Model):HParams+tok_embd+output_norm+output+ optionaloutput_b+ oneLayerWeightsper layer.- Populated backend registries: CPU always; Metal's
(buffer, offset)entries when MPS initialized; CUDA's device copies when a device exists. - The KV element-type decision (f16 vs f32), set once from the model's dimensions before any forward runs.
Shapes to internalize now (they recur in every later doc): a GGUF weight
matrix is stored with shape metadata [in, out] (ne[0] = input dim,
fastest-varying) while memory is row-major [out][in] — so wq of a 0.5B
model is 896×896 and wk is 128×896 as bytes even though its logical
projection is 896 → 128. The embedding table token_embd.weight is
[n_vocab, n_embd]: one row per vocabulary entry, each row the vector that
token id looks up to. Norm weights (attn_norm, ffn_norm, output_norm)
are 1-D f32 gain vectors of length n_embd (a gain is just a learned
per-feature multiplier applied after normalizing); biases (bq, bk, bv,
attn_output has none, output.bias optional) are 1-D f32 too. Quantized
matmul weights are one of Q4_0/Q4_1/Q5_0/Q5_1/Q8_0 (32-value blocks) or
Q4_K/Q5_K/Q6_K (256-value super-blocks) — quantization stores values in
fewer bits, grouped into blocks that share a scale factor.
3.2 Key code
Excerpt 1 — the startup order in main.rs: GPU init, dispatch, legacy KV
cache. (src/main.rs:637-657; the KV block below is historical — see the
forward note under the annotations)
#![allow(unused)] fn main() { // === GPU backends === #[cfg(target_os = "macos")] metal::MpsState::init(); #[cfg(feature = "cuda")] cuda::CudaState::init_with_gpu(gpu); // On CPU/Metal builds `--gpu` is a no-op: it is parsed but unused. #[cfg(not(feature = "cuda"))] let _ = gpu; // === Load model (dispatches on general.architecture) === let model = models::load_model(&gguf_model).expect("load model"); ... // === KV Cache === let n_kv_embd = model.n_kv_embd(); let n_layer = model.n_layer(); let mut kv_cache = cache::KVCache::new(n_layer, n_kv_embd, params.n_ctx); }
Forward note (#252, 2026-10-02): the
// === KV Cache ===block above is gone. #244 had already deleted theKVCacheLayerstorage; #252 then deleted the empty markersrc/cache.rs, itsmod cache;declaration and the vestigial&mut KVCacheparameter ofModelDef::forwardandforward_graph, somain.rsloads the model and the tokenizer and nothing else. There is no per-load KV allocation to narrate at all — the graph allocator's persistent regions are the only KV store (#244, #252).
Annotations: MpsState::init() is a OnceLock singleton init — inside, it
honors MINFER_DISABLE_MPS by returning None, so "disabled" and "no
device" are the same state downstream (src/metal/runtime.rs). CUDA likewise
honors MINFER_DISABLE_CUDA and takes the --gpu N index here. The
load_model call is where this entire doc's work happens — note .expect:
an unsupported architecture is fatal, by design (§2.2). The final three lines
in the excerpt allocated the legacy KV cache, whose only remaining job (after
#244 deleted its storage) was to satisfy the ModelDef::forward signature's
&mut KVCache parameter — a parameter no path read, and the claim that it cost
≈ 100 MB of zeroed memory stopped being true with #244. #252 deleted the
argument and the type rather than keep a dead allocation alive for the API
shape.
Excerpt 2 — the dispatch itself. (src/models/mod.rs:95-112)
#![allow(unused)] fn main() { pub fn load_model(model: &GgufModel) -> Option<Box<dyn ModelDef>> { let ctx = &model.parts[0].ctx; let arch = ctx.get_key_val_str("general.architecture")?; match arch.as_str() { "qwen2" => { let m = qwen2::loader::load(model)?; Some(Box::new(m)) } "qwen3" => { let m = qwen3::loader::load(model)?; Some(Box::new(m)) } other => { eprintln!("Unsupported architecture: '{}'", other); None } } } }
Annotations: part 0 is authoritative for metadata even in a multi-part split
(doc 02 merged the tensor index across parts; metadata comes from the first).
? on the string lookup means a file without the key is "no model", not a
panic — the caller reports it. The error branch prints the offending string,
which is what makes a mistyped or future architecture diagnosable in one
glance.
Excerpt 3 — the interface everything downstream codes against.
(src/models/mod.rs:22-45, :77-85 — abridged)
#![allow(unused)] fn main() { pub trait ModelDef: Send + Sync { fn forward(&self, tokens: &[u32], positions: &[usize], n_out: usize, n_ctx: usize) -> Vec<f32>; /// Downcast helper for the graph path's weight registration. fn as_any(&self) -> &dyn std::any::Any; /// Build the declarative compute graph for one forward step (Phase 5). /// Topology is a deterministic function of `params` (reuse invariant). fn build_graph(&self, _params: &GraphParams) -> ComputeGraph { ... } /// Graph-based forward with a caller-provided cache and explicit context /// size (server / multi-slot path). fn forward_graph_cached(&self, tokens: &[u32], positions: &[usize], n_out: usize, n_ctx: usize, cache: &mut GraphCache) -> Vec<f32> { ... } fn special_tokens(&self) -> SpecialTokens; fn n_layer(&self) -> usize; fn n_head_kv(&self) -> usize; fn n_embd_head(&self) -> usize; fn n_kv_embd(&self) -> usize; fn n_vocab(&self) -> usize; fn rope_style(&self) -> RopeStyle; } }
This trait looks wide for an interface with two implementations, and that is the point: each method exists because a downstream stage needs it and must not know which architecture it is talking to.
| Method | Who consumes it, and for what |
|---|---|
forward, forward_graph_cached | the CLI loop (doc 09) and the server's per-slot path — both just "run a forward"; the default forward_graph routes to the graph |
build_graph | doc 05: the pure-IR graph builder; topology is a function of GraphParams only |
n_layer | loader-loop sizing here, the graph builder's per-layer loop (doc 05), the legacy KV cache above |
n_head_kv, n_embd_head, n_kv_embd | GQA (grouped-query attention: fewer K/V heads than query heads) head mapping and strides (doc 11), and the KV region width n_kv_embd × n_ctx (doc 07) |
n_vocab | logits width — the sampler's input size (doc 12) |
special_tokens | the sampler's stop condition: main.rs fetches eos/im_end ids once and checks every sampled token against them (main.rs:839,900,1020) |
rope_style | doc 11: RoPE (rotary positional encoding) has two layout styles — Qwen's non-interleaved vs Llama's interleaved — and the vec-op must be told which |
as_any | lets graph code downcast to the concrete model when it needs specifics |
Send + Sync | the HTTP server shares the model across threads (Arc<dyn ModelDef>) |
Excerpt 4 — hyperparameter parsing with the dual metadata prefix.
(src/models/qwen2/loader.rs:117-156, abridged)
#![allow(unused)] fn main() { // Try qwen2 prefix first, fall back to llama/generic let n_embd = get_i64(ctx, "qwen2.embedding_length") .or_else(|| get_i64(ctx, "llama.embedding_length"))?; let n_head = get_i64(ctx, "qwen2.attention.head_count") .or_else(|| get_i64(ctx, "llama.attention.head_count"))?; let n_head_kv = get_i64(ctx, "qwen2.attention.head_count_kv") .or_else(|| get_i64(ctx, "llama.attention.head_count_kv")) .unwrap_or(n_head); // no GQA ⇒ KV heads = Q heads let n_layer = get_i64(ctx, "qwen2.block_count").or_else(|| get_i64(ctx, "llama.block_count"))?; ... f_norm_rms_eps: get_f32(ctx, "qwen2.attention.layer_norm_rms_epsilon") .or_else(|| get_f32(ctx, "llama.attention.layer_norm_rms_epsilon")) .unwrap_or(1e-6), rope_freq_base: /* ... llama.* fallback ... */ .unwrap_or(10000.0), rope_style: RopeStyle::NonInterleaved, n_kv_embd: n_head_kv * (n_embd / n_head), // default, updated from K weight below }
Annotations: every dimension is a fallback chain — the architecture's own
prefix first, then the LLaMA-family prefix that several fine-tunes use. The
.unwrap_or defaults are also data: n_head_kv defaulting to n_head
means "no grouped-query attention"; rms_eps = 1e-6 and freq_base = 10000.0 are the values llama.cpp would use. n_vocab is not read from a
metadata key at all but counted from the tokenizer.ggml.tokens array — the
tokenizer data is the ground truth (it arrives next stage). And n_kv_embd
starts as the naive product, purely so the struct is initialized; the loader
immediately overwrites it from the K weight's real shape.
Excerpt 5 — a weight tensor is born as a borrowed slice, then handed to the
GPU registries. (src/models/qwen2/loader.rs:176-220, abridged; the CUDA
branch at :221-285 is discussed in the annotations)
#![allow(unused)] fn main() { fn load_tensor(ctx: &GgufContext, raw: &'static [u8], ti: &GgufTensorInfo) -> Tensor { let ttype = TensorType::from_ggml_type(ti.type_); ... let off = ctx.offset + ti.offset as usize; let ts = ti.type_.type_size(); // bytes per block let bs = ti.type_.blck_size() as usize; // values per block let n = (shape[0] * shape[1] * shape[2] * shape[3]) as usize; let nbytes = (n / bs) * ts; // Borrow the tensor bytes straight from the mmap'd part file (zero-copy — // the file pages are shared with the CPU and GPU instead of a per-tensor copy). let src = &raw[off..off + nbytes]; ... let mut tensor = Tensor::from_data_borrowed_with_strides(ttype, &shape, &strides, src); // Register weight tensors with GPU backends. #[cfg(target_os = "macos")] if let Some(mps) = crate::metal::MpsState::get() { if matches!(ttype, TensorType::Q4_0 | TensorType::Q4_1 | TensorType::Q4_K | TensorType::Q5_0 | TensorType::Q5_1 | TensorType::Q5_K | TensorType::Q6_K | TensorType::Q8_0) { mps.register_weight(&ti.name, tensor.data()); } else if ttype == TensorType::F32 { mps.register_weight(&ti.name, tensor.data()); } } // #[cfg(feature = "cuda")] branch: same shape, more work — see below. tensor } }
Annotations: byte size comes from the GGML type's block size, not from a
TensorType guess — the comment notes this is "always correct regardless of
TensorType mapping". src is a sub-slice of the mmap: constructing the
tensor did one range check and zero copies, and the registration is woven
into the same walk rather than done as a second pass over the model. On
Metal, essentially everything quantized plus f32 gets registered — the
shader kernels handle the block formats natively. The CUDA branch is pickier
and does more work at registration: Q6_K weights are repacked into padded
224-byte slots (raw blocks are 210 bytes, which forces byte-granular GPU
loads — padding restores 16-byte-aligned vector loads), a f32-pair plane may
be precomputed for the Q4_K kernel, and unsupported types clear a fast
matmul-mode flag because a mode that assumed certain weight layouts would
otherwise read garbage. The point for this doc: registration is where
per-backend representation is decided — bytes for CPU, (buffer, offset)
for Metal, device allocation (+optional repack) for CUDA.
Excerpt 6 — two load-time side decisions: the KV element type, and the true
KV width. (src/models/qwen2/loader.rs:310-317 and :495-498; Qwen3's
guarded version at src/models/qwen3/loader.rs:311-331)
#![allow(unused)] fn main() { // KV cache element type (GPU path): auto-select f16 for the 7B class (KV // bandwidth-bound decode) unless MINFER_CACHE_TYPE overrides. Must run // before the first forward (kv_cache_is_f16 reads the OnceLock). #[cfg(target_os = "macos")] crate::metal::set_kv_cache_type(hparams.n_layer as usize, hparams.n_kv_embd as usize); // 8b: CUDA side shares the same policy and MINFER_CACHE_TYPE override. #[cfg(feature = "cuda")] crate::cuda::set_kv_cache_type(hparams.n_layer as usize, hparams.n_kv_embd as usize); ... // (later, after the per-layer weights are loaded:) // Override n_kv_embd from layer 0 K weight's actual output dimension if let Some((_, ti)) = tensor_map.get(&tn::attn_k(0)) { hparams.n_kv_embd = ti.ne[1]; } }
#![allow(unused)] fn main() { // qwen3/loader.rs — same override, resolved BEFORE the KV type pick, plus // an assert that is only sound for Qwen3 (Qwen2's `n_kv_embd` may // legitimately differ from `n_head_kv × n_embd_head`, so it cannot assert): if let Some((_, ti)) = tensor_map.get(&tn::attn_k(0)) { hparams.n_kv_embd = ti.ne[1]; // sanity: kv dim must equal n_head_kv * n_embd_head (catches a wrong // key_length fallback before it silently corrupts attention) assert_eq!( hparams.n_kv_embd, hparams.n_head_kv * hparams.n_embd_head, ...) } }
Annotations: the K projection is a real matrix sitting in the file — on the
0.5B it is [896 → 128] — so its output width is the KV width, whatever
the head-count metadata might imply; the loader trusts it over any derived
value. The Qwen3 loader reads it before the KV type pick (its comment says
why: the f16 auto-select multiplies n_layers × n_kv_embd), and its assert
turns a wrong key_length fallback into a load-time crash instead of
silently corrupting attention. The policy set_kv_cache_type implements
(src/metal/policy.rs): if MINFER_CACHE_TYPE says f16/f32, obey (since C4 the
value is parsed strictly on every device — an unknown spelling, or q8_0 on a
backend whose attention kernel has no packed read, fails the load instead of quietly running f32);
otherwise auto-select — f16 (half precision: 2 bytes per value instead of 4)
when n_layers × n_kv_embd ≥ 8192, i.e. models big enough that decode is
KV-bandwidth-bound (measured −1 ms/token on the 7B at 2K context), f32 for
small models where f16 measured ~3% slower.
Excerpt 7 — the merged tensor index and name lookup.
(src/models/qwen2/loader.rs:329-343)
#![allow(unused)] fn main() { // Merged tensor index across all split parts (llama.cpp weights_map): each // tensor lives in the part that lists it, read from that part's own data. let mut tensor_map = std::collections::HashMap::<String, (usize, &GgufTensorInfo)>::new(); for (pi, part) in model.parts.iter().enumerate() { for ti in &part.ctx.info { tensor_map.insert(ti.name.clone(), (pi, ti)); } } let load_one = |n: &str| -> Option<Tensor> { tensor_map.get(n).map(|(pi, ti)| { let part = &model.parts[*pi]; load_tensor(&part.ctx, &part.data, ti) }) }; }
The loader then reads weights by canonical name: token_embd.weight,
output_norm.weight, output.weight, blk.{i}.attn_norm.weight,
blk.{i}.attn_q.weight, … — the names come from a small tensor_names
module (models/qwen2/mod.rs:124-166), so a naming convention change is a
one-file edit. Two notable lookups: output falls back to the embedding
table when absent (load_one(tn::OUTPUT).unwrap_or_else(|| tok_embd.clone())
— weight tying: small models reuse the embedding table as the final
projection instead of shipping a second matrix), and every per-layer tensor
is Option because Qwen3 has no biases while Qwen2.5-7B does.
Excerpt 8 — Metal's zero-copy registry. (src/metal/, annotated
condensation of register_part :2320-2372 and register_weight :2374-2424)
#![allow(unused)] fn main() { pub fn register_part(&self, data: &'static [u8]) { let page = 16384; // macOS page size on Apple Silicon let base = data.as_ptr() as usize; debug_assert!(base % page == 0, "mmap'd GGUF part not page-aligned"); let buf = unsafe { self.inner.device .newBufferWithBytesNoCopy_length_options_deallocator( ptr, data.len(), MTLResourceOptions::StorageModeShared, None) .unwrap() }; self.inner.mmap_parts.lock().unwrap().push((base, data.len(), buf.clone())); // GPU-side warm-up (#39): the FIRST GPU access to file-backed pages // costs ~44 ms of one-time page/TLB setup → do it here, at load. } pub fn register_weight(&self, name: &str, data: &[u8]) { let force_copy = std::env::var("MINFER_WEIGHT_COPY").map_or(false, |v| v == "1"); // Zero-copy path: the weight is a slice of a registered mmap'd part // → (part buffer, offset). The GPU reads the mapped file pages // directly — no CPU→GPU memcpy, no GPU-side allocation. let entry = if !force_copy { parts.iter().find(|(base, len, _)| ptr >= *base && ptr + data.len() <= base + len) .map(|(base, _, buf)| (buf.clone(), (ptr - base) as u64)) } else { None }; let (buf, off) = match entry { Some(e) => e, None => { /* copy into a fresh shared buffer (offset 0) */ } }; self.inner.weights.lock().unwrap().insert(name.to_string(), (buf, off)); } }
Annotations: the loader calls register_part for every mmap'd part before
registering any weight (qwen2/loader.rs:319-327) — the ordering is load-
bearing, because register_weight locates its zero-copy entry by finding the
part that contains the pointer. StorageModeShared on Apple Silicon means
one physical allocation both CPU and GPU address; "zero-copy" is literal.
The copy fallback exists for the two cases where bytes are not file pages:
the fused attn_qkv/ffn_gu concat weights (built in RAM at load,
qwen2/loader.rs:391-426,446-491) and MINFER_WEIGHT_COPY=1, an A/B switch
that makes the cost of the zero-copy path measurable.
Excerpt 9 — CUDA's upload-once registry. (src/cuda/methods/weights.rs:18-97, abridged)
#![allow(unused)] fn main() { pub fn register_weight(&self, name: &str, data: &[u8]) { if data.is_empty() { return; } { let w = self.weights.lock().unwrap(); if let Some((_, size)) = w.get(name) { if *size == data.len() { // Device weights are immutable: same name + size ⇒ the // same GGUF tensor ... Reuse the existing device copy // instead of leaking one buffer per load. return; } // Different size ...: replace the entry. The stale buffer is // deliberately NOT freed — a live captured graph may still // reference it; ... } } let mut ptr: *mut std::ffi::c_void = std::ptr::null_mut(); let err = unsafe { cudaMalloc(&mut ptr, data.len()) }; ... let err = unsafe { cudaMemcpy(ptr, data.as_ptr() as *const c_void, data.len(), CUDA_MEMCPY_HOST_TO_DEVICE) }; ... self.weights.lock().unwrap().insert(name.to_string(), (CudaPtr(ptr), data.len())); } }
Annotations: three details repay attention. (1) The dedup check makes re-registration a no-op — graph rebuilds and unit tests that reload a model must not each leak another full-weight-set upload (~4.4 GB on the 7B). (2) The refusal to free stale buffers is deliberate, not sloppy: a captured CUDA Graph (doc 15) holds raw device pointers; freeing under it would be use-after-free. (3) A plain registration clears any stale "padded" flag for that name, so a second model reusing a tensor name with a non-Q6_K type cannot be dispatched through the padded-224 kernel on a raw-210 buffer (a Phase 8 review finding).
Excerpt 10 — CPU registration: the cheapest one. (src/graph/cpu_backend.rs:34-47)
#![allow(unused)] fn main() { /// Register a weight tensor by name (Phase 6 wires this from the model). pub fn register_weight(&mut self, name: &str, t: Tensor) { // Skip re-registration of an already-known weight: Tensor carries its // bytes as Cow::Owned, so the `t.clone()` at the model call sites // deep-copies the full weight set (~4.4 GB on 7B) on EVERY graph // (re)build — measured as a ~635 ms pure-CPU stall at the // prefill→decode graph switch (no CUDA calls, no kernels). Model // weights are immutable after load (weights_version guards any future // change), so a same-name registration always carries the same data. if self.weights.contains_key(name) { return; } self.weights.insert(name.to_string(), t); } }
The comment is the whole lesson: with borrowed bytes, even the unguarded
insert is cheap; the guard exists because one call path produced owned
clones. The graph allocator simply forwards to it
(graph/alloc.rs:135-138: self.cpu.register_weight(name, t)), which is
why the allocator's registration costs nothing on CPU.
Excerpt 11 — the graph's own registration pass, at first build.
(src/models/qwen2/graph.rs:344-378, abridged)
#![allow(unused)] fn main() { /// Register every weight the graph references on the allocator's backend. pub(crate) fn register_graph_weights(model: &Qwen2Model, alloc: &mut GraphAllocator) { for t in [&model.tok_embd, &model.output_norm, &model.output, &model.output_b] { if let Some(t) = t { let name = t.name.clone(); alloc.register_weight(&name, t.clone()); } } for l in &model.layers { for t in [&l.attn_norm, &l.wq, &l.bq, &l.wk, &l.bk, &l.wv, &l.bv, &l.wo, &l.ffn_norm, &l.ffn_gate, &l.ffn_up, &l.ffn_down] { if let Some(t) = t { let name = t.name.clone(); alloc.register_weight(&name, t.clone()); } } } } }
Annotations: this runs inside forward_cached on graph build only (guarded
by the reuse check), and the t.clone() is the cheap borrowed-clone of
§2.3. Twelve entries per layer is the Qwen2 inventory: 7 matmul weights
(wq, wk, wv, wo, ffn_gate, ffn_up, ffn_down), 2 norm gains, and 3–4 biases.
The list is deliberately spelled out — not derived by reflection — so the
compiler catches field renames in both the registration and the gate
(excerpt 12), which must enumerate the same weights.
Excerpt 12 — the Metal participation gate. (src/models/qwen2/graph.rs:634-677,
names list abridged)
#![allow(unused)] fn main() { /// Every weight the graph reads must be GPU-registered for the Metal path. #[cfg(target_os = "macos")] fn weights_on_gpu(model: &Qwen2Model) -> bool { let names: Vec<String> = { let mut v = Vec::new(); for t in [&model.tok_embd, &model.output_norm, &model.output, &model.output_b] { if let Some(t) = t { v.push(t.name.clone()); } } for l in &model.layers { for t in [&l.attn_norm, &l.wq, /* ... all 12 per layer ... */ &l.ffn_down] { if let Some(t) = t { v.push(t.name.clone()); } } } v }; let Some(mps) = crate::metal::MpsState::get() else { return false; }; names.iter().all(|n| mps.has_weight(n)) } }
And where the verdict lands (src/models/qwen2/graph.rs:431-457,462-470):
#![allow(unused)] fn main() { #[cfg(target_os = "macos")] let metal_on = crate::graph::metal_backend::metal_available() && Self::weights_on_gpu(model); ... #[cfg(feature = "cuda")] let cuda_on = crate::cuda::CudaState::get().is_some() && Self::weights_on_cuda(model); ... let params = GraphParams { n_tokens: nt, n_out, gtype: if nt == 1 { GraphType::Decode } else { GraphType::Prefill }, cparams: CParams { n_ctx, flash_attn: false, gpu: metal_on || cuda_on, // ← participation recorded fuse_qkv: nt == 1 && (metal_on || cuda_on) && !env("MINFER_NO_FUSE_QKV"), fuse_ffn: nt == 1 && (metal_on || cuda_on) && !env("MINFER_NO_FUSE_FFN"), }, weights_version: 1, }; if !cache.try_reuse(¶ms) { /* build → register → assign → fuse → alloc */ } }
Annotations: gpu is a single flag in the reuse identity, so toggling the
environment forces a rebuild (doc 13). The CUDA gate (weights_on_cuda,
:689-750) is stricter than Metal's: each matmul weight must be registered
and match a kernel type (has_weight_of_size compares the byte length so
padded Q6_K registrations still match by raw size), and the embedding is
checked separately because its gather kernel supports one fewer type
(Q4_1). On failure it names the first offending tensor instead of returning
a bare false — the difference between a debugging session and a support
ticket.
3.3 Design choices (why this shape and not another)
Dispatch on a string, once, before any side effect. Covered in §2.2; the one-line summary: deterministic, one honest error message, and compatible with the fact that loading mutates GPU state.
A wide trait instead of a narrow one. The tempting alternative is a
minimal trait (forward + a getter or two) with the rest downcast via
as_any. That pushes every consumer into arch-specific code. The chosen
shape inverts it: the trait declares everything the pipeline needs
(dims for graph shapes, build_graph for doc 05, special_tokens for doc
12, rope_style for doc 11, n_vocab for the sampler width), each
architecture implements them once, and downstream code stays
architecture-blind. The cost is some #[allow(dead_code)] ceremony on
methods only reached through Box<dyn ModelDef> — noted in the trait's own
comment (models/mod.rs:17-21) — which is a fair price for the type safety.
The IR references weights by name, not by pointer. Graph nodes carry
NodeMeta::MatMul { weight_name, weight_ttype, in_dim, out_dim }
(graph/builder.rs:127-145); backends resolve the name through their
registry at execute time. The alternatives: embedding raw byte pointers in
the IR (couples the pure graph to mmap lifetimes and makes the CUDA
representation impossible), or embedding Tensors (makes graph comparison —
the reuse identity — expensive). Names are cheap, comparable, and each
backend maps them to its own representation. The same choice is what makes
fusion possible: the fused attn_qkv weight is just another name, so a
fused node differs from three unfused ones only in metadata.
Zero-copy on Metal, upload-once on CUDA, free on CPU. Three honest answers to "where can this hardware read bytes from?" — not three implementations of one idea. What they share is the invariant: after load, no backend ever moves weight bytes again during inference.
No dequantization at load. §2.3's arithmetic: 0.56 B/value vs 4 B/value, and both CPU and GPU kernels are built to consume the packed forms directly. The exceptions prove the rule — every repack that does happen (CUDA's padded Q6_K slots, the Q4_K descriptor plane, the optional f16 dequant cache, the fused concat weights) exists because a specific kernel measured faster on a different layout, is gated on model size or env flag, and is documented with its byte math at the registration site.
All-or-nothing GPU participation, recorded in the reuse identity. §2.5.
The alternative (per-layer fallback) is the old engine's design, and its
cost — 144 DMA operations per decode step in the worst case — is on record
in docs/CUDA_OPTIMIZATION.md.
The legacy KVCache is gone (#252). #244 deleted the type's storage (it was never read), and
#252 then deleted the empty marker src/cache.rs, its mod cache; declaration, the
ModelDef::forward/forward_graph &mut KVCache parameter, the KVCache::new call in main.rs and
the tests that constructed one only to satisfy the signature. The graph path's real KV lives in the
allocator's persistent per-layer regions (docs 07–08) and always did; the "vestigial 100 MB
allocation" this paragraph used to call the cheaper mess stopped existing with #244's storage
deletion, so keeping the parameter bought nothing.
3.4 Pitfalls & invariants
- Registration order on Metal:
register_partfor every mmap part before anyregister_weight. The zero-copy lookup finds weights by pointer containment in a registered part; weights registered first would silently take the copy path. Page alignment of the mmap base is adebug_assert, not a hope (src/metal/runtime.rs). - CUDA init must complete before the first registration. The loaders
call
CudaState::init()up front and hold a model-load guard for the whole load (qwen2/loader.rs:295-306). The recorded failure mode: lazy init mid-load flips the backend gate between tensors, producing a graph that mixes CPU/CUDA assignment against persistent KV regions that were sized for one of them. - Weights are immutable after load. Both CPU and CUDA registries skip or
dedup same-name re-registration;
weights_versioninGraphParamsis the escape hatch if that ever changes. The bug behind the CPU guard cost a measured 635 ms per prefill→decode switch. - CUDA device buffers are never freed on replace. A captured graph may
reference them; the leak is bounded by distinct (architecture, tensor)
shapes ever loaded (
src/cuda/methods/weights.rs:24-37). - The gate and the registration must enumerate the same weights. Loader
registers;
weights_on_gpu/weights_on_cudacheck the same field list spelled out twice. That duplication is intentional — a new weight field fails the compile in both places until acknowledged. - Loader registers ⊋ gate accepts (on CUDA). Some types are registered
for the legacy path but have no graph kernel; the gate's type check is
what keeps those on CPU. The embedding's Q4_1 exclusion is the standing
example (
qwen2/graph.rs:685-693). - The KV element-type decision is write-once.
set_kv_cache_typeinitializes aOnceLock; it must run before the first forward, which is why the loaders do it mid-load — and why the Qwen3 loader resolvesn_kv_embdbefore calling it. positions[i] < n_ctxis a caller obligation. The KV regions are sizedn_kv_embd × n_ctxonce;forward_cachedasserts it loudly (qwen2/graph.rs:421-426) rather than corrupting a region.
4. Observe & verify
./target/release/minfer info <model>— dumps the tensor table (names, quant types, shapes) and metadata KV, so you can see exactly the names the loader will look up and thegeneral.architecturevalue dispatch matches.- Startup log, the stage's own narration:
File: … bytes in N part(s)(doc 02), thenMPS: GPU acceleration enabledorMPS: disabled by MINFER_DISABLE_MPS/CUDA: GPU acceleration enabled, thenLoaded: N layers,Model loaded., andVocabulary: N tokens(then_vocabthis stage counted). MINFER_DISABLE_MPS=1— forces CPU on macOS; the log flips toMPS: disabled by MINFER_DISABLE_MPSand the graph's backend colors (next bullet) go all-CPU.MINFER_DISABLE_CUDA=1is its CUDA twin.MINFER_WEIGHT_COPY=1/MINFER_CACHE_TYPE=f16|f32|q8_0(q8_0is read by CPU, CUDA and Metal — the last since #310, C4) — the first makes Metal copy each weight into a fresh buffer instead of wrapping the mmap pages (A/B the zero-copy path); the second pins the KV element type instead of the size-based auto-select.--dump-graph out.dot/--dump-graph-json(orMINFER_TRACE=/tmp/t.json) — exports the built graph with real backend assignment; nodes whose matmuls reference registered weights show their assigned backend, which is the visible outcome of this stage's gate.minfer vizrenders the same in a browser.MINFER_NO_FUSE_QKV=1/MINFER_NO_FUSE_FFN=1— skips building the fused concat weights at load too, so their memory cost disappears from your process footprint; a way to feel the difference between "raw GGUF bytes" and "registration-time derived copies".- Tests:
cargo testcovers the weight registry round-trip (cpu_backend tests register and look up by name), and the Metal/CUDA graph suites run the same tiny graphs on GPU and CPU asserting bit-identical output — the end-to-end proof that registration made weights reachable on each backend.
5. Cross-references
docs/ARCHITECTURE.md§2 (module map), §3 (pipeline position of this stage), §5 (backend layering + selection rules), §8 (the add-a-new-architecture checklist that mirrors this doc).docs/COMPUTE-GRAPH-DESIGN.md— Phase 5/6 record: how the imperative forward becamebuild_graph+ registries, and why nodes carry names.docs/CUDA-BACKEND-DESIGN.md— Phase 7 design and §2 inventory of the CUDA weight registry; the resident-weights thesis this doc leans on.docs/CUDA_OPTIMIZATION.md(+docs/cuda_optimization_steps/) — the measured cost of per-step host round-trips that resident weights delete; Q6_K padded registration details (7e②).docs/METAL_OPTIMIZATIONS.md— the mmap-part zero-copy design and the #39 first-touch warm-up; KV f16 auto-select measurements (§0/§2.5).docs/QWEN3-SUPPORT-PLAN.md§2 — the decoupled head dim and per-head Q/K norm rationale.docs/PERF-QWEN3-4B-VS-LLAMACPP.md— whyn_ctx(not the model's max context) sizes the KV regions.- Neighbors: 02 (what the
GgufModelcontainer is), 04 (tokenizer + template — the next consumers of metadata), 05 (the graph that finally reads these weights), 07 (the allocator that owns registration and KV regions), 14/15 (the Metal and CUDA backends whose registries were filled here).
← 02 — GGUF load · Index · 04 — Tokenizer and chat template →