use crate::modules::Module;
use fuel_ir::Shape;
use fuel_ir::error::Result;
use fuel_tensor::{lazy::Tensor, lazy::WeightStorage};
use std::sync::Arc;
#[derive(Debug, Clone)]
pub struct LoraLinear {
base_weight: WeightStorage,
bias: Option<Arc<[f32]>>,
lora_a: Arc<[f32]>,
lora_b: Arc<[f32]>,
rank: usize,
alpha: f32,
in_features: usize,
out_features: usize,
}
impl LoraLinear {
pub fn new(
base_weight: WeightStorage,
bias: Option<Arc<[f32]>>,
lora_a: Arc<[f32]>,
lora_b: Arc<[f32]>,
rank: usize,
alpha: f32,
in_features: usize,
out_features: usize,
) -> Result<Self> {
if rank == 0 {
return Err(
fuel_ir::error::Error::Msg("LoraLinear::new: rank must be > 0".into()).bt(),
);
}
if base_weight.elem_count() != in_features * out_features {
return Err(fuel_ir::error::Error::Msg(format!(
"LoraLinear::new: base_weight has {} elements but \
in_features * out_features = {} * {} = {}",
base_weight.elem_count(),
in_features,
out_features,
in_features * out_features,
))
.bt());
}
if matches!(base_weight, WeightStorage::WithLoRA { .. }) {
return Err(fuel_ir::error::Error::Msg(
"LoraLinear::new: base_weight is already WithLoRA \
(nested adapters unsupported)"
.into(),
)
.bt());
}
if lora_a.len() != in_features * rank {
return Err(fuel_ir::error::Error::Msg(format!(
"LoraLinear::new: lora_a has {} elements but \
in_features * rank = {} * {} = {}",
lora_a.len(),
in_features,
rank,
in_features * rank,
))
.bt());
}
if lora_b.len() != rank * out_features {
return Err(fuel_ir::error::Error::Msg(format!(
"LoraLinear::new: lora_b has {} elements but \
rank * out_features = {} * {} = {}",
lora_b.len(),
rank,
out_features,
rank * out_features,
))
.bt());
}
if let Some(b) = bias.as_ref()
&& b.len() != out_features
{
return Err(fuel_ir::error::Error::Msg(format!(
"LoraLinear::new: bias has length {} but \
out_features = {}",
b.len(),
out_features,
))
.bt());
}
Ok(Self {
base_weight,
bias,
lora_a,
lora_b,
rank,
alpha,
in_features,
out_features,
})
}
pub fn base_weight(&self) -> &WeightStorage {
&self.base_weight
}
pub fn bias(&self) -> Option<&Arc<[f32]>> {
self.bias.as_ref()
}
pub fn lora_a(&self) -> &Arc<[f32]> {
&self.lora_a
}
pub fn lora_b(&self) -> &Arc<[f32]> {
&self.lora_b
}
pub fn rank(&self) -> usize {
self.rank
}
pub fn alpha(&self) -> f32 {
self.alpha
}
pub fn in_features(&self) -> usize {
self.in_features
}
pub fn out_features(&self) -> usize {
self.out_features
}
}
impl Module for LoraLinear {
fn forward(&self, xs: &Tensor) -> Result<Tensor> {
let merged = self.base_weight.clone().with_lora(
Arc::clone(&self.lora_a),
Arc::clone(&self.lora_b),
self.rank,
self.alpha,
self.in_features,
self.out_features,
);
let y = merged.apply_linear(xs, self.in_features, self.out_features)?;
match &self.bias {
Some(b) => {
let bias_t =
y.const_f32_like(Arc::clone(b), Shape::from_dims(&[self.out_features]))?;
y.broadcast_add(&bias_t)
}
None => Ok(y),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::modules::Linear;
use fuel_tensor::Device;
fn ramp_f32(n: usize, scale: f32, offset: f32) -> Vec<f32> {
(0..n).map(|i| (i as f32) * scale + offset).collect()
}
#[test]
fn lora_zero_alpha_matches_base_linear() {
let in_features = 6;
let out_features = 4;
let rank = 3;
let seq = 5;
let w: Vec<f32> = ramp_f32(in_features * out_features, 0.05, -0.1);
let bias: Vec<f32> = ramp_f32(out_features, 0.2, -0.3);
let lora_a: Vec<f32> = ramp_f32(in_features * rank, 0.07, 0.15);
let lora_b: Vec<f32> = ramp_f32(rank * out_features, 0.04, -0.2);
let x_data: Vec<f32> = ramp_f32(seq * in_features, 0.03, -0.25);
let lora = LoraLinear::new(
WeightStorage::F32(Arc::from(w.clone())),
Some(Arc::from(bias.clone())),
Arc::from(lora_a),
Arc::from(lora_b),
rank,
0.0,
in_features,
out_features,
)
.unwrap();
let plain = Linear::new(
WeightStorage::F32(Arc::from(w)),
Some(Arc::from(bias)),
in_features,
out_features,
)
.unwrap();
let x = Tensor::from_f32(
x_data.clone(),
Shape::from_dims(&[seq, in_features]),
&Device::cpu(),
)
.unwrap();
let y_lora = lora.forward(&x).unwrap();
let x2 = Tensor::from_f32(
x_data,
Shape::from_dims(&[seq, in_features]),
&Device::cpu(),
)
.unwrap();
let y_plain = plain.forward(&x2).unwrap();
assert_eq!(y_lora.shape().dims(), &[seq, out_features]);
let got = y_lora.realize_f32();
let expected = y_plain.realize_f32();
assert_eq!(got.len(), expected.len());
for (i, (a, e)) in got.iter().zip(expected.iter()).enumerate() {
assert!(
(a - e).abs() < 1e-5,
"lora alpha=0 [{i}] expected {e}, got {a}",
);
}
}
#[test]
fn lora_unit_alpha_adds_lora_correction_to_base() {
let in_features = 4;
let out_features = 3;
let rank = 2;
let seq = 3;
let w: Vec<f32> = ramp_f32(in_features * out_features, 0.05, -0.2);
let bias: Vec<f32> = ramp_f32(out_features, 0.1, 0.0);
let lora_a: Vec<f32> = ramp_f32(in_features * rank, 0.08, -0.1);
let lora_b: Vec<f32> = ramp_f32(rank * out_features, 0.06, 0.05);
let x_data: Vec<f32> = ramp_f32(seq * in_features, 0.03, -0.4);
let mut expected = vec![0.0_f32; seq * out_features];
for bi in 0..seq {
for o in 0..out_features {
let mut acc = bias[o];
for k in 0..in_features {
acc += x_data[bi * in_features + k] * w[k * out_features + o];
}
for r in 0..rank {
let mut xa = 0.0_f32;
for k in 0..in_features {
xa += x_data[bi * in_features + k] * lora_a[k * rank + r];
}
acc += xa * lora_b[r * out_features + o];
}
expected[bi * out_features + o] = acc;
}
}
let lora = LoraLinear::new(
WeightStorage::F32(Arc::from(w)),
Some(Arc::from(bias)),
Arc::from(lora_a),
Arc::from(lora_b),
rank,
rank as f32, in_features,
out_features,
)
.unwrap();
let x = Tensor::from_f32(
x_data,
Shape::from_dims(&[seq, in_features]),
&Device::cpu(),
)
.unwrap();
let y = lora.forward(&x).unwrap();
assert_eq!(y.shape().dims(), &[seq, out_features]);
let got = y.realize_f32();
assert_eq!(got.len(), expected.len());
for (i, (a, e)) in got.iter().zip(expected.iter()).enumerate() {
assert!(
(a - e).abs() < 1e-5,
"lora unit-alpha [{i}] expected {e}, got {a}",
);
}
}
}