use candle_core::{DType, Device, Tensor};
use candle_nn::VarBuilder;
use peft_rs::{Adapter, LoraConfig, LoraLayer};
use serde::{Deserialize, Serialize};
use crate::error::{QLoraError, Result};
use crate::quantization::{
dequantize_nf4, quantize_nf4_with_config, ComputeDType, QuantizationConfig, QuantizedTensor,
};
fn warn_cpu_fallback(device: &Device) {
static WARN_ONCE: std::sync::Once = std::sync::Once::new();
if matches!(device, Device::Cpu) {
WARN_ONCE.call_once(|| {
eprintln!(
"qlora-rs: CPU device in use. CUDA is the intended default; enable the 'cuda' feature and use Device::cuda_if_available(0) when possible."
);
});
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QLoraConfig {
pub lora: LoraConfig,
pub quantization: QuantizationConfig,
#[serde(default = "default_target_modules")]
pub target_modules: Vec<String>,
#[serde(default)]
pub cache_dequantized: bool,
}
fn default_target_modules() -> Vec<String> {
vec![
"q_proj".into(),
"k_proj".into(),
"v_proj".into(),
"o_proj".into(),
"gate_proj".into(),
"up_proj".into(),
"down_proj".into(),
]
}
impl Default for QLoraConfig {
fn default() -> Self {
Self {
lora: LoraConfig {
r: 64,
alpha: 16,
dropout: 0.05,
..Default::default()
},
quantization: QuantizationConfig {
block_size: 64,
double_quant: true,
compute_dtype: ComputeDType::BF16, ..Default::default()
},
target_modules: default_target_modules(),
cache_dequantized: false, }
}
}
impl QLoraConfig {
#[must_use]
pub fn preset_all_bf16(r: usize, alpha: usize) -> Self {
Self {
lora: LoraConfig {
r,
alpha,
dropout: 0.05,
..Default::default()
},
quantization: QuantizationConfig {
block_size: 64,
double_quant: true,
compute_dtype: ComputeDType::BF16,
..Default::default()
},
target_modules: default_target_modules(),
cache_dequantized: false,
}
}
#[must_use]
pub fn preset_qv_bf16(r: usize, alpha: usize) -> Self {
Self {
lora: LoraConfig {
r,
alpha,
dropout: 0.05,
..Default::default()
},
quantization: QuantizationConfig {
block_size: 64,
double_quant: true,
compute_dtype: ComputeDType::BF16,
..Default::default()
},
target_modules: vec!["q_proj".into(), "v_proj".into()],
cache_dequantized: false,
}
}
#[must_use]
pub fn preset_inference(r: usize, alpha: usize) -> Self {
Self {
cache_dequantized: true, ..Self::preset_all_bf16(r, alpha)
}
}
#[must_use]
pub fn is_target(&self, module_name: &str) -> bool {
self.target_modules.iter().any(|t| module_name.contains(t))
}
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn scale(&self) -> f64 {
self.lora.alpha as f64 / self.lora.r as f64
}
pub fn validate_for_training(&self) -> Result<()> {
if self.lora.r == 0 {
return Err(QLoraError::InvalidConfig("LoRA rank must be > 0".into()));
}
if self.target_modules.is_empty() {
return Err(QLoraError::InvalidConfig(
"At least one target module required".into(),
));
}
if matches!(self.quantization.compute_dtype, ComputeDType::F16) {
tracing::warn!(
"FP16 compute dtype may cause training instability (20% failure rate). \
Consider using BF16 instead."
);
}
Ok(())
}
}
pub struct QuantizedLinear {
quantized_weight: QuantizedTensor,
cached_weight: Option<Tensor>,
bias: Option<Tensor>,
lora: LoraLayer,
device: Device,
config: QLoraConfig,
}
impl QuantizedLinear {
pub fn from_weight(
weight: &Tensor,
bias: Option<Tensor>,
config: &QLoraConfig,
device: &Device,
) -> Result<Self> {
warn_cpu_fallback(device);
let shape = weight.shape().dims();
if shape.len() != 2 {
return Err(QLoraError::InvalidConfig("weight must be 2D".into()));
}
let (out_features, in_features) = (shape[0], shape[1]);
let quantized_weight = quantize_nf4_with_config(weight, &config.quantization)?;
let cached_weight = if config.cache_dequantized {
Some(dequantize_nf4(&quantized_weight, device)?)
} else {
None
};
let lora =
LoraLayer::new_with_zeros(in_features, out_features, config.lora.clone(), device)?;
Ok(Self {
quantized_weight,
cached_weight,
bias,
lora,
device: device.clone(),
config: config.clone(),
})
}
pub fn from_weight_with_varbuilder(
weight: &Tensor,
bias: Option<Tensor>,
config: &QLoraConfig,
vb: VarBuilder,
) -> Result<Self> {
let shape = weight.shape().dims();
if shape.len() != 2 {
return Err(QLoraError::InvalidConfig("weight must be 2D".into()));
}
let (out_features, in_features) = (shape[0], shape[1]);
let device = weight.device();
warn_cpu_fallback(device);
let quantized_weight = quantize_nf4_with_config(weight, &config.quantization)?;
let cached_weight = if config.cache_dequantized {
Some(dequantize_nf4(&quantized_weight, device)?)
} else {
None
};
let lora = LoraLayer::new(in_features, out_features, config.lora.clone(), vb)?;
Ok(Self {
quantized_weight,
cached_weight,
bias,
lora,
device: device.clone(),
config: config.clone(),
})
}
pub fn new(
in_features: usize,
out_features: usize,
config: &QLoraConfig,
device: &Device,
) -> Result<Self> {
let weight = Tensor::zeros(&[out_features, in_features], DType::F32, device)?;
Self::from_weight(&weight, None, config, device)
}
pub fn forward(&self, input: &Tensor) -> Result<Tensor> {
let weight = if let Some(cached) = &self.cached_weight {
cached.clone()
} else {
dequantize_nf4(&self.quantized_weight, &self.device)?
};
let weight_t = weight.t()?;
let base_output = if input.dims().len() == 3 {
let (batch, seq, in_features) = input.dims3()?;
let reshaped = input.reshape(&[batch * seq, in_features])?;
let out = reshaped.matmul(&weight_t)?;
let out_features = weight_t.dim(1)?;
out.reshape(&[batch, seq, out_features])?
} else {
input.matmul(&weight_t)?
};
let output = self.lora.forward(input, Some(&base_output))?;
match &self.bias {
Some(bias) => Ok(output.broadcast_add(bias)?),
None => Ok(output),
}
}
pub fn enable_weight_caching(&mut self) -> Result<()> {
if self.cached_weight.is_none() {
self.cached_weight = Some(dequantize_nf4(&self.quantized_weight, &self.device)?);
}
Ok(())
}
pub fn disable_weight_caching(&mut self) {
self.cached_weight = None;
}
#[must_use]
pub fn is_weight_cached(&self) -> bool {
self.cached_weight.is_some()
}
#[must_use]
pub fn config(&self) -> &QLoraConfig {
&self.config
}
#[must_use]
pub fn lora(&self) -> &LoraLayer {
&self.lora
}
pub fn lora_mut(&mut self) -> &mut LoraLayer {
&mut self.lora
}
#[must_use]
pub fn lora_weights(&self) -> (&Tensor, &Tensor) {
self.lora.weights()
}
#[must_use]
pub fn num_trainable_parameters(&self) -> usize {
self.lora.num_parameters()
}
#[must_use]
pub fn memory_bytes(&self) -> usize {
let quantized_size = self.quantized_weight.size_bytes();
let lora_size = self.lora.num_parameters() * 4; let bias_size = self.bias.as_ref().map_or(0, |b| b.elem_count() * 4);
quantized_size + lora_size + bias_size
}
}
pub struct QLoraLayer {
linear: QuantizedLinear,
}
impl QLoraLayer {
#[must_use]
pub fn new(linear: QuantizedLinear) -> Self {
Self { linear }
}
pub fn forward(&self, input: &Tensor) -> Result<Tensor> {
self.linear.forward(input)
}
#[must_use]
pub fn quantized_weight(&self) -> &QuantizedTensor {
&self.linear.quantized_weight
}
#[must_use]
pub fn lora_weights(&self) -> (&Tensor, &Tensor) {
self.linear.lora_weights()
}
#[must_use]
pub fn lora_scale(&self) -> f64 {
self.linear.config.scale()
}
#[must_use]
pub fn device(&self) -> &Device {
&self.linear.device
}
#[must_use]
pub fn config(&self) -> &QLoraConfig {
&self.linear.config
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_qlora_creation() {
let config = QLoraConfig::default();
let device = Device::Cpu;
let layer = QuantizedLinear::new(768, 768, &config, &device);
assert!(layer.is_ok());
}
#[test]
fn test_qlora_forward_shape() {
let config = QLoraConfig::default();
let device = Device::Cpu;
let layer = QuantizedLinear::new(768, 768, &config, &device).unwrap();
let input = Tensor::zeros(&[1, 10, 768], DType::F32, &device).unwrap();
let output = layer.forward(&input).unwrap();
assert_eq!(output.shape().dims(), &[1, 10, 768]);
}
#[test]
fn test_qlora_memory_reduction() {
let config = QLoraConfig::default();
let device = Device::Cpu;
let layer = QuantizedLinear::new(4096, 4096, &config, &device).unwrap();
let full_size = 4096 * 4096 * 4;
let actual_size = layer.memory_bytes();
#[allow(clippy::cast_precision_loss)]
let ratio = f64::from(full_size) / actual_size as f64;
assert!(ratio > 2.0, "Expected >2x reduction, got {ratio:.2}x");
}
#[test]
fn test_preset_all_bf16() {
let config = QLoraConfig::preset_all_bf16(64, 16);
assert_eq!(config.lora.r, 64);
assert_eq!(config.lora.alpha, 16);
assert!((config.lora.dropout - 0.05).abs() < 1e-10);
assert!(matches!(
config.quantization.compute_dtype,
ComputeDType::BF16
));
assert!(config.quantization.double_quant);
assert!(config.target_modules.contains(&"q_proj".to_string()));
assert!(config.target_modules.contains(&"k_proj".to_string()));
assert!(config.target_modules.contains(&"v_proj".to_string()));
assert!(config.target_modules.contains(&"o_proj".to_string()));
assert!(config.target_modules.contains(&"gate_proj".to_string()));
assert!(!config.cache_dequantized);
}
#[test]
fn test_preset_qv_bf16() {
let config = QLoraConfig::preset_qv_bf16(32, 8);
assert_eq!(config.lora.r, 32);
assert_eq!(config.lora.alpha, 8);
assert_eq!(config.target_modules.len(), 2);
assert!(config.target_modules.contains(&"q_proj".to_string()));
assert!(config.target_modules.contains(&"v_proj".to_string()));
assert!(!config.target_modules.contains(&"k_proj".to_string()));
assert!(!config.target_modules.contains(&"o_proj".to_string()));
}
#[test]
fn test_preset_inference() {
let config = QLoraConfig::preset_inference(16, 32);
assert_eq!(config.lora.r, 16);
assert_eq!(config.lora.alpha, 32);
assert!(config.cache_dequantized);
assert!(matches!(
config.quantization.compute_dtype,
ComputeDType::BF16
));
}
#[test]
fn test_is_target() {
let config = QLoraConfig::preset_all_bf16(8, 16);
assert!(config.is_target("model.layer.q_proj"));
assert!(config.is_target("transformer.blocks.0.attn.v_proj"));
assert!(config.is_target("gate_proj"));
assert!(!config.is_target("embed_tokens"));
assert!(!config.is_target("lm_head"));
assert!(!config.is_target("layer_norm"));
}
#[test]
fn test_scale() {
let config = QLoraConfig::preset_all_bf16(64, 16);
let scale = config.scale();
assert!((scale - 0.25).abs() < 1e-10);
let config2 = QLoraConfig::preset_all_bf16(8, 32);
let scale2 = config2.scale();
assert!((scale2 - 4.0).abs() < 1e-10);
}
#[test]
fn test_validate_for_training_success() {
let config = QLoraConfig::preset_all_bf16(8, 16);
assert!(config.validate_for_training().is_ok());
}
#[test]
fn test_validate_for_training_zero_rank() {
let mut config = QLoraConfig::preset_all_bf16(0, 16);
config.lora.r = 0;
let result = config.validate_for_training();
assert!(result.is_err());
if let Err(e) = result {
assert!(e.to_string().contains("rank"));
}
}
#[test]
fn test_validate_for_training_empty_targets() {
let mut config = QLoraConfig::preset_all_bf16(8, 16);
config.target_modules.clear();
let result = config.validate_for_training();
assert!(result.is_err());
if let Err(e) = result {
assert!(e.to_string().contains("target module"));
}
}
#[test]
fn test_default_config() {
let config = QLoraConfig::default();
assert!(matches!(
config.quantization.compute_dtype,
ComputeDType::BF16
));
assert_eq!(config.lora.r, 64);
assert_eq!(config.lora.alpha, 16);
assert!(!config.target_modules.is_empty());
assert!(!config.cache_dequantized);
}
#[test]
fn test_lora_weights() {
let config = QLoraConfig::preset_all_bf16(8, 16);
let device = Device::Cpu;
let layer = QuantizedLinear::new(64, 128, &config, &device).unwrap();
let (a_weight, b_weight) = layer.lora_weights();
assert_eq!(a_weight.dims(), &[8, 64]);
assert_eq!(b_weight.dims(), &[128, 8]);
}
}