1use crate::lfm::{Lfm2Block, Lfm2Conv, Lfm2LayerOp, Lfm2Model};
19use crate::{Attention, Linear, LoRAAdapter, RMSNorm, MLP};
20use ferrotensor_autograd::Tensor;
21use ferrotensor_backend::{ComputeEngine, UnifiedTensor, WgpuContext};
22use ferrotensor_loader::{Lfm2Config, WeightsContext};
23use std::sync::Arc;
24
25pub struct LoRaConfig {
27 pub rank: usize,
29 pub alpha: f32,
31 pub target_modules: Vec<String>, }
34
35impl Default for LoRaConfig {
36 fn default() -> Self {
37 Self {
38 rank: 8,
39 alpha: 1.0,
40 target_modules: vec![
41 "q_proj".to_string(),
42 "k_proj".to_string(),
43 "v_proj".to_string(),
44 "o_proj".to_string(),
45 "in_proj".to_string(),
46 "out_proj".to_string(),
47 ],
48 }
49 }
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct BuildError(pub String);
55
56impl std::fmt::Display for BuildError {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 f.write_str(&self.0)
59 }
60}
61
62impl std::error::Error for BuildError {}
63
64type Result<T> = std::result::Result<T, BuildError>;
65
66#[derive(Clone, Copy, Debug, PartialEq, Eq)]
68pub enum Architecture {
69 Llama,
71 Lfm2,
73}
74
75impl Architecture {
76 pub fn detect(config: &Lfm2Config) -> Self {
79 let lfm = config
80 .model_type
81 .as_deref()
82 .is_some_and(|t| t.eq_ignore_ascii_case("lfm2"));
83 if lfm || !config.layer_types.is_empty() {
84 Self::Lfm2
85 } else {
86 Self::Llama
87 }
88 }
89}
90
91pub struct Lfm2ModelBuilder<'a> {
93 ctx: Arc<WgpuContext>,
94 engine: Arc<ComputeEngine>,
95 weights: &'a WeightsContext,
96 config: Lfm2Config,
97 lora_config: Option<LoRaConfig>,
98 trainable_base: bool,
99}
100
101impl<'a> Lfm2ModelBuilder<'a> {
102 fn transpose_2d(data: &[f32], rows: usize, cols: usize) -> Vec<f32> {
103 let mut transposed = vec![0.0; data.len()];
104 for row in 0..rows {
105 for col in 0..cols {
106 transposed[col * rows + row] = data[row * cols + col];
107 }
108 }
109 transposed
110 }
111
112 pub fn new(
114 ctx: Arc<WgpuContext>,
115 engine: Arc<ComputeEngine>,
116 weights: &'a WeightsContext,
117 config: Lfm2Config,
118 ) -> Self {
119 Self {
120 ctx,
121 engine,
122 weights,
123 config,
124 lora_config: None,
125 trainable_base: false,
126 }
127 }
128
129 pub fn with_lora(mut self, lora_config: LoRaConfig) -> Self {
131 self.lora_config = Some(lora_config);
132 self
133 }
134
135 pub fn with_trainable_base(mut self, trainable: bool) -> Self {
138 self.trainable_base = trainable;
139 self
140 }
141
142 fn wrap(&self, data: UnifiedTensor) -> Tensor {
143 if self.trainable_base {
144 Tensor::new(self.ctx.clone(), data)
145 } else {
146 Tensor::frozen(self.ctx.clone(), data)
147 }
148 }
149
150 fn has(&self, name: &str) -> bool {
151 self.weights.get_tensor_info(name).is_some()
152 }
153
154 fn read(&self, name: &str) -> Result<(Vec<f32>, Vec<usize>)> {
155 self.weights
156 .read_f32_tensor(name)
157 .map_err(|e| BuildError(format!("failed to load weight {name}: {e}")))
158 }
159
160 fn load_tensor(&self, name: &str) -> Result<Tensor> {
162 if self.has(&format!("{name}.scales")) {
163 let ut = self
164 .weights
165 .upload_quantized(name, &self.ctx, &self.engine)
166 .map_err(|e| BuildError(format!("failed to load quantized weight {name}: {e}")))?;
167 Ok(self.wrap(ut))
168 } else {
169 self.load_raw_tensor(&format!("{name}.weight"))
170 }
171 }
172
173 fn load_raw_tensor(&self, name: &str) -> Result<Tensor> {
174 let (data, shape) = self.read(name)?;
175 Ok(self.wrap(UnifiedTensor::new(&self.ctx, &data, &shape)))
176 }
177
178 fn load_linear_weight_tensor(&self, prefix: &str) -> Result<Tensor> {
180 let (data, shape) = self.read(&format!("{prefix}.weight"))?;
181 let (data, shape) = if let [rows, cols] = shape[..] {
182 (Self::transpose_2d(&data, rows, cols), vec![cols, rows])
183 } else {
184 (data, shape)
185 };
186 Ok(self.wrap(UnifiedTensor::new(&self.ctx, &data, &shape)))
187 }
188
189 fn maybe_add_lora(&self, linear: Linear, module_name: &str, seed: u64) -> Linear {
190 if let Some(lora_cfg) = &self.lora_config {
191 if lora_cfg
192 .target_modules
193 .iter()
194 .any(|m| module_name.contains(m.as_str()))
195 {
196 let shape = &linear.weight.data.shape;
197 let lora = LoRAAdapter::new_seeded(
198 self.ctx.clone(),
199 shape[0],
200 shape[1],
201 lora_cfg.rank,
202 lora_cfg.alpha,
203 seed,
204 );
205 return Linear::with_lora(linear.weight, lora);
206 }
207 }
208 linear
209 }
210
211 fn build_linear(&self, prefix: &str, module_name: &str, seed: u64) -> Result<Linear> {
214 let weight = if self.has(&format!("{prefix}.scales")) {
215 self.load_tensor(prefix)?
216 } else {
217 self.load_linear_weight_tensor(prefix)?
218 };
219 Ok(self.maybe_add_lora(Linear::new(weight), module_name, seed))
220 }
221
222 fn build_rms_norm(&self, prefix: &str) -> Result<RMSNorm> {
223 let weight = self.load_raw_tensor(&format!("{prefix}.weight"))?;
224 Ok(RMSNorm::new(weight, self.config.norm_eps))
225 }
226
227 fn build_mlp(&self, prefix: &str, arch: Architecture, seed: u64) -> Result<MLP> {
228 let [gate, up, down] = match arch {
230 Architecture::Llama => ["gate_proj", "up_proj", "down_proj"],
231 Architecture::Lfm2 => ["w1", "w3", "w2"],
232 };
233 Ok(MLP {
234 gate_proj: self.build_linear(&format!("{prefix}.{gate}"), gate, seed)?,
235 up_proj: self.build_linear(&format!("{prefix}.{up}"), up, seed)?,
236 down_proj: self.build_linear(&format!("{prefix}.{down}"), down, seed)?,
237 })
238 }
239
240 fn build_attention(&self, prefix: &str, arch: Architecture, seed: u64) -> Result<Attention> {
241 let n_heads = self.config.num_attention_heads;
242 let num_kv_heads = self.config.num_key_value_heads.unwrap_or(n_heads);
243 let head_dim = self
244 .config
245 .head_dim
246 .unwrap_or(self.config.hidden_size / n_heads.max(1));
247 if n_heads == 0 || num_kv_heads == 0 || !n_heads.is_multiple_of(num_kv_heads) {
248 return Err(BuildError(format!(
249 "num_attention_heads ({n_heads}) must be a positive multiple of num_key_value_heads ({num_kv_heads})"
250 )));
251 }
252 if head_dim > 128 {
253 return Err(BuildError(format!(
254 "head_dim {head_dim} is not supported (the attention kernel handles at most 128)"
255 )));
256 }
257 if self.has(&format!("{prefix}.q_proj.bias")) {
258 return Err(BuildError(
259 "attention projections with bias (e.g. Qwen2) are not supported yet".into(),
260 ));
261 }
262 if self.has(&format!("{prefix}.q_layernorm.weight")) {
263 return Err(BuildError(
264 "LFM2 per-head q/k layernorm is not supported yet".into(),
265 ));
266 }
267 let o_name = match arch {
268 Architecture::Llama => "o_proj",
269 Architecture::Lfm2 => "out_proj",
270 };
271
272 Ok(Attention {
273 q_proj: self.build_linear(&format!("{prefix}.q_proj"), "q_proj", seed)?,
274 k_proj: self.build_linear(&format!("{prefix}.k_proj"), "k_proj", seed)?,
275 v_proj: self.build_linear(&format!("{prefix}.v_proj"), "v_proj", seed)?,
276 o_proj: self.build_linear(&format!("{prefix}.{o_name}"), o_name, seed)?,
277 n_heads,
278 head_dim,
279 num_kv_heads,
280 dropout_prob: self.config.attention_dropout.unwrap_or(0.0),
281 rope_theta: self.config.rope_theta.unwrap_or(10000.0) as f32,
282 })
283 }
284
285 fn build_conv(&self, prefix: &str, seed: u64) -> Result<Lfm2Conv> {
286 let conv_weight = self.load_raw_tensor(&format!("{prefix}.conv.weight"))?;
287 let conv_bias = self.wrap(UnifiedTensor::zeros(&self.ctx, &[self.config.hidden_size]));
289
290 Ok(Lfm2Conv {
291 in_proj: self.build_linear(&format!("{prefix}.in_proj"), "in_proj", seed)?,
292 conv_weight,
293 conv_bias,
294 out_proj: self.build_linear(&format!("{prefix}.out_proj"), "out_proj", seed)?,
295 })
296 }
297
298 fn build_layer(&self, layer_idx: usize, arch: Architecture) -> Result<Lfm2Block> {
299 let prefix = format!("model.layers.{layer_idx}");
300 let seed = layer_idx as u64 + 1;
301 match arch {
302 Architecture::Llama => Ok(Lfm2Block {
303 operator_norm: self.build_rms_norm(&format!("{prefix}.input_layernorm"))?,
304 op: Lfm2LayerOp::Attention(self.build_attention(
305 &format!("{prefix}.self_attn"),
306 arch,
307 seed,
308 )?),
309 ffn_norm: self.build_rms_norm(&format!("{prefix}.post_attention_layernorm"))?,
310 mlp: self.build_mlp(&format!("{prefix}.mlp"), arch, seed)?,
311 }),
312 Architecture::Lfm2 => {
313 let layer_type = self
314 .config
315 .layer_types
316 .get(layer_idx)
317 .map(String::as_str)
318 .unwrap_or("full_attention");
319 let op = match layer_type {
320 "conv" => Lfm2LayerOp::Conv(self.build_conv(&format!("{prefix}.conv"), seed)?),
321 "full_attention" | "attention" => Lfm2LayerOp::Attention(
322 self.build_attention(&format!("{prefix}.self_attn"), arch, seed)?,
323 ),
324 other => {
325 return Err(BuildError(format!(
326 "unknown layer type {other:?} at layer {layer_idx}"
327 )))
328 }
329 };
330 Ok(Lfm2Block {
331 operator_norm: self.build_rms_norm(&format!("{prefix}.operator_norm"))?,
332 op,
333 ffn_norm: self.build_rms_norm(&format!("{prefix}.ffn_norm"))?,
334 mlp: self.build_mlp(&format!("{prefix}.feed_forward"), arch, seed)?,
335 })
336 }
337 }
338 }
339
340 pub fn build(self) -> Result<Lfm2Model> {
343 let arch = Architecture::detect(&self.config);
344 let n_layers = self.config.num_hidden_layers;
345 log::info!("building {arch:?} model with {n_layers} layers");
346
347 let embed_tokens = self.load_tensor("model.embed_tokens")?;
348 let mut layers = Vec::with_capacity(n_layers);
349 for i in 0..n_layers {
350 layers.push(self.build_layer(i, arch)?);
351 }
352
353 let norm_f = if self.has("model.norm.weight") {
355 self.build_rms_norm("model.norm")?
356 } else if self.has("model.embedding_norm.weight") {
357 self.build_rms_norm("model.embedding_norm")?
358 } else {
359 return Err(BuildError(
360 "no final norm (model.norm.weight or model.embedding_norm.weight)".into(),
361 ));
362 };
363
364 let lm_head = if self.has("lm_head.weight") {
365 self.build_linear("lm_head", "lm_head", 0)?
366 } else {
367 let (data, shape) = self.read("model.embed_tokens.weight")?;
369 let [vocab, hidden] = shape[..] else {
370 return Err(BuildError(format!(
371 "model.embed_tokens.weight must be 2-D, got {shape:?}"
372 )));
373 };
374 let weight = Self::transpose_2d(&data, vocab, hidden);
375 let weight = self.wrap(UnifiedTensor::new(&self.ctx, &weight, &[hidden, vocab]));
376 self.maybe_add_lora(Linear::new(weight), "lm_head", 0)
377 };
378
379 Ok(Lfm2Model::new(embed_tokens, None, layers, norm_f, lm_head))
380 }
381}
382
383pub fn load_lfm2_model(
385 model_path: &str,
386 ctx: Arc<WgpuContext>,
387 engine: Arc<ComputeEngine>,
388 lora_config: Option<LoRaConfig>,
389) -> std::result::Result<Lfm2Model, String> {
390 use std::path::Path;
391
392 let model_dir = Path::new(model_path);
393 let config = ferrotensor_loader::ModelLoader::load_config(model_dir.join("config.json"))
394 .map_err(|e| format!("Failed to load config: {}", e))?;
395
396 let mut weights = WeightsContext::new();
397 weights
398 .load_model_dir(model_dir)
399 .map_err(|e| format!("Failed to load safetensors: {}", e))?;
400
401 let mut builder = Lfm2ModelBuilder::new(ctx, engine, &weights, config);
402 if let Some(lora) = lora_config {
403 builder = builder.with_lora(lora);
404 }
405
406 builder.build().map_err(|e| e.to_string())
407}
408
409#[cfg(test)]
410mod tests {
411 use super::Lfm2ModelBuilder;
412
413 #[test]
414 fn transpose_2d_swaps_rows_and_columns() {
415 let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
416 let transposed = Lfm2ModelBuilder::transpose_2d(&data, 2, 3);
417 assert_eq!(transposed, vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0]);
418 }
419}