Skip to main content

rlx_models_core/
gguf_resolve.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! Pluggable GGUF tensor-name resolution per `general.architecture`.
17
18use rlx_gguf::GgufFile;
19use std::sync::{Mutex, OnceLock};
20
21use crate::weight_loader::{
22    gguf_to_hf_name, gguf_to_hf_name_for_arch, hf_to_gguf_name, hf_to_gguf_name_for_arch,
23};
24
25/// Resolve a builder-requested tensor name to the name stored in a GGUF file.
26pub trait GgufTensorNameResolver: Send + Sync {
27    fn matches_arch(&self, arch: &str) -> bool;
28    fn resolve(&self, file: &GgufFile, requested_key: &str) -> Option<String>;
29}
30
31/// HF `model.layers.N.*` ↔ GGUF `blk.N.*` (Llama, Qwen3, Qwen35, …).
32pub struct LlamaFamilyGgufResolver;
33
34impl GgufTensorNameResolver for LlamaFamilyGgufResolver {
35    fn matches_arch(&self, arch: &str) -> bool {
36        matches!(
37            arch,
38            "llama"
39                | "llama4"
40                | "phi3"
41                | "phi4"
42                | "qwen3"
43                | "qwen2"
44                | "qwen35"
45                | "qwen35moe"
46                | "qwen36"
47                | "gemma"
48                | "gemma2"
49                | "mistral"
50        )
51    }
52
53    fn resolve(&self, file: &GgufFile, key: &str) -> Option<String> {
54        if file.tensors.contains_key(key) {
55            return Some(key.to_string());
56        }
57        if let Some(g) = hf_to_gguf_name(key) {
58            if file.tensors.contains_key(&g) {
59                return Some(g);
60            }
61        }
62        if let Some(h) = gguf_to_hf_name(key) {
63            if file.tensors.contains_key(&h) {
64                return Some(h);
65            }
66        }
67        None
68    }
69}
70
71/// Strip common HF prefixes and match verbatim tensor names (architecture-agnostic fallback).
72pub struct PrefixStripGgufResolver;
73
74/// Alias for [`PrefixStripGgufResolver`] (older name).
75pub type PassThroughGgufResolver = PrefixStripGgufResolver;
76
77impl GgufTensorNameResolver for PrefixStripGgufResolver {
78    fn matches_arch(&self, _arch: &str) -> bool {
79        true
80    }
81
82    fn resolve(&self, file: &GgufFile, key: &str) -> Option<String> {
83        let mut k = key.to_string();
84        for prefix in [
85            "model.diffusion_model.",
86            "diffusion_model.",
87            "transformer.",
88            "model.",
89        ] {
90            if let Some(rest) = k.strip_prefix(prefix) {
91                k = rest.to_string();
92                break;
93            }
94        }
95        if file.tensors.contains_key(&k) {
96            return Some(k);
97        }
98        if file.tensors.contains_key(key) {
99            return Some(key.to_string());
100        }
101        None
102    }
103}
104
105/// Gemma 2/3/4: 4 RMSNorms per layer disagree with the Llama 2-norm convention.
106///
107/// Llama treats `post_attention_layernorm` as the pre-FFN norm and aliases it
108/// to `ffn_norm`. Gemma 2/3/4 (V2/V3/V4 layer styles) have a dedicated
109/// `post_attention_norm` between the attention output and the residual add,
110/// *and* a separate `ffn_norm` / `post_ffw_norm` pair around the MLP. Without
111/// this resolver, the Llama mapper would alias `post_attention_layernorm` to
112/// `ffn_norm`, collide with `pre_feedforward_layernorm`, and silently load
113/// the wrong tensor. The tail map is identical across these arches — only
114/// the GGUF arch tag (and runtime details like sliding-window stride) differ.
115pub struct Gemma2GgufResolver;
116
117impl GgufTensorNameResolver for Gemma2GgufResolver {
118    fn matches_arch(&self, arch: &str) -> bool {
119        matches!(
120            arch,
121            "gemma2" | "gemma3" | "gemma3n" | "gemma4" | "gemma4moe"
122        )
123    }
124
125    fn resolve(&self, file: &GgufFile, key: &str) -> Option<String> {
126        // Identity hit first — accept native GGUF names verbatim.
127        if file.tensors.contains_key(key) {
128            return Some(key.to_string());
129        }
130        // Handle the four-norm-per-layer scheme explicitly. The Llama mapper
131        // is wrong for `post_attention_layernorm` (it aliases to `ffn_norm`,
132        // which Gemma 2 reserves for the pre-FFN norm) and has no entry at
133        // all for the `pre_feedforward_layernorm`/`post_feedforward_layernorm`
134        // pair.
135        if let Some(rest) = key.strip_prefix("model.layers.") {
136            if let Some((idx, tail)) = rest.split_once('.') {
137                let gguf_tail = match tail {
138                    "post_attention_layernorm.weight" => Some("post_attention_norm.weight"),
139                    "pre_feedforward_layernorm.weight" => Some("ffn_norm.weight"),
140                    "post_feedforward_layernorm.weight" => Some("post_ffw_norm.weight"),
141                    // Inverse of `gguf_to_hf_name_for_arch` — Gemma 4 GGUF stores
142                    // per-layer output scalars as `layer_output_scale`, not HF
143                    // `self_attn.output_scale`.
144                    "self_attn.output_scale.weight" => Some("layer_output_scale.weight"),
145                    _ => None,
146                };
147                if let Some(t) = gguf_tail {
148                    let g = format!("blk.{idx}.{t}");
149                    if file.tensors.contains_key(&g) {
150                        return Some(g);
151                    }
152                }
153            }
154        }
155        // Fall through to Llama-family mapping for everything else
156        // (input_layernorm, attn/mlp weights, lm_head, embeddings, …).
157        LlamaFamilyGgufResolver.resolve(file, key)
158    }
159}
160
161/// Phi 3 / 4: fused `attn_qkv` and `ffn_up` (gate∥up) tensors.
162pub struct Phi3GgufResolver;
163
164impl GgufTensorNameResolver for Phi3GgufResolver {
165    fn matches_arch(&self, arch: &str) -> bool {
166        matches!(arch, "phi3" | "phi4")
167    }
168
169    fn resolve(&self, file: &GgufFile, key: &str) -> Option<String> {
170        if file.tensors.contains_key(key) {
171            return Some(key.to_string());
172        }
173        for arch in ["phi3", "phi4"] {
174            if let Some(g) = hf_to_gguf_name_for_arch(key, arch) {
175                if file.tensors.contains_key(&g) {
176                    return Some(g);
177                }
178            }
179            if let Some(h) = gguf_to_hf_name_for_arch(key, arch) {
180                if file.tensors.contains_key(&h) {
181                    return Some(h);
182                }
183            }
184        }
185        None
186    }
187}
188
189/// Qwen3.5 native `blk.N.*` names; also accept HF aliases via the Llama mapper.
190pub struct Qwen35NativeGgufResolver;
191
192impl GgufTensorNameResolver for Qwen35NativeGgufResolver {
193    fn matches_arch(&self, arch: &str) -> bool {
194        matches!(arch, "qwen35" | "qwen35moe" | "qwen36")
195    }
196
197    fn resolve(&self, file: &GgufFile, key: &str) -> Option<String> {
198        if file.tensors.contains_key(key) {
199            return Some(key.to_string());
200        }
201        LlamaFamilyGgufResolver.resolve(file, key)
202    }
203}
204
205static CUSTOM_RESOLVERS: Mutex<Vec<Box<dyn GgufTensorNameResolver>>> = Mutex::new(Vec::new());
206static BUILTIN_RESOLVERS: OnceLock<Vec<Box<dyn GgufTensorNameResolver>>> = OnceLock::new();
207
208fn builtin_resolvers() -> &'static Vec<Box<dyn GgufTensorNameResolver>> {
209    BUILTIN_RESOLVERS.get_or_init(|| {
210        vec![
211            Box::new(Qwen35NativeGgufResolver),
212            Box::new(Gemma2GgufResolver),
213            Box::new(Phi3GgufResolver),
214            Box::new(LlamaFamilyGgufResolver),
215            Box::new(PrefixStripGgufResolver),
216        ]
217    })
218}
219
220/// Register built-in GGUF resolvers (idempotent). Called automatically on first resolve; \
221/// call from `main` if you register custom resolvers and need ordering guarantees.
222pub fn ensure_builtin_resolvers() {
223    let _ = builtin_resolvers();
224}
225
226/// Register a custom resolver (call before first GGUF load). Later registrations win
227/// among resolvers that match the same architecture.
228pub fn register_gguf_tensor_resolver(resolver: Box<dyn GgufTensorNameResolver>) {
229    CUSTOM_RESOLVERS
230        .lock()
231        .expect("gguf resolver registry lock")
232        .push(resolver);
233}
234
235/// Resolve `requested_key` against tensors in `file` using registered resolvers.
236pub fn resolve_gguf_tensor_name(
237    file: &GgufFile,
238    arch: &str,
239    requested_key: &str,
240) -> Option<String> {
241    for r in builtin_resolvers().iter() {
242        if r.matches_arch(arch) {
243            if let Some(name) = r.resolve(file, requested_key) {
244                return Some(name);
245            }
246        }
247    }
248    let custom = CUSTOM_RESOLVERS
249        .lock()
250        .expect("gguf resolver registry lock");
251    for r in custom.iter() {
252        if r.matches_arch(arch) {
253            if let Some(name) = r.resolve(file, requested_key) {
254                return Some(name);
255            }
256        }
257    }
258    if file.tensors.contains_key(requested_key) {
259        return Some(requested_key.to_string());
260    }
261    if let Some(g) = hf_to_gguf_name(requested_key) {
262        if file.tensors.contains_key(&g) {
263            return Some(g);
264        }
265    }
266    if let Some(h) = gguf_to_hf_name(requested_key) {
267        if file.tensors.contains_key(&h) {
268            return Some(h);
269        }
270    }
271    None
272}