use std::collections::HashMap;
use std::io::Write;
use std::path::Path;
use crate::error::{QLoraError, Result};
use crate::quantization::QuantizedTensor;
const GGUF_MAGIC: u32 = 0x4655_4747;
const GGUF_VERSION: u32 = 3;
const GGUF_TYPE_Q4_0: u32 = 2;
#[derive(Debug, Clone)]
pub struct GgufMetadata {
pub model_name: String,
pub model_type: String,
pub model_size: usize,
}
impl Default for GgufMetadata {
fn default() -> Self {
Self {
model_name: "qlora-model".to_string(),
model_type: "qlora".to_string(),
model_size: 0,
}
}
}
pub fn export_gguf<P: AsRef<Path>>(
tensors: &[(&str, &QuantizedTensor)],
metadata: Option<GgufMetadata>,
output_path: P,
) -> Result<()> {
let mut file = std::fs::File::create(output_path)
.map_err(|e| QLoraError::GgufExport(format!("Failed to create output file: {e}")))?;
let metadata = metadata.unwrap_or_default();
let mut kv_pairs = HashMap::new();
kv_pairs.insert("general.name".to_string(), metadata.model_name);
kv_pairs.insert("general.type".to_string(), metadata.model_type);
let header_size = calculate_header_size(tensors, &kv_pairs);
let mut current_offset = header_size as u64;
let mut tensor_offsets = Vec::new();
for (_name, tensor) in tensors {
tensor_offsets.push(current_offset);
current_offset += tensor.data.len() as u64; current_offset += (tensor.scales.len() * 4) as u64; if let Some(ref zp) = tensor.zero_points {
current_offset += (zp.len() * 4) as u64; }
if let Some(ref scales_q) = tensor.scales_quantized {
current_offset += scales_q.len() as u64;
}
if let Some(ref scales_s) = tensor.scales_scales {
current_offset += (scales_s.len() * 4) as u64;
}
}
write_gguf_header(&mut file, &kv_pairs, tensors, &tensor_offsets)?;
for (_name, tensor) in tensors {
file.write_all(&tensor.data)
.map_err(|e| QLoraError::GgufExport(format!("Failed to write tensor data: {e}")))?;
for &scale in &tensor.scales {
file.write_all(&scale.to_le_bytes())
.map_err(|e| QLoraError::GgufExport(format!("Failed to write scale: {e}")))?;
}
if let Some(ref zp) = tensor.zero_points {
for &zp_val in zp {
file.write_all(&zp_val.to_le_bytes()).map_err(|e| {
QLoraError::GgufExport(format!("Failed to write zero point: {e}"))
})?;
}
}
if let Some(ref scales_q) = tensor.scales_quantized {
file.write_all(scales_q).map_err(|e| {
QLoraError::GgufExport(format!("Failed to write double-quantized scales: {e}"))
})?;
}
if let Some(ref scales_s) = tensor.scales_scales {
for &scale_s in scales_s {
file.write_all(&scale_s.to_le_bytes()).map_err(|e| {
QLoraError::GgufExport(format!(
"Failed to write double-quantized scale factors: {e}"
))
})?;
}
}
}
Ok(())
}
fn write_gguf_header<W: Write>(
writer: &mut W,
kv_pairs: &HashMap<String, String>,
tensors: &[(&str, &QuantizedTensor)],
offsets: &[u64],
) -> Result<()> {
writer
.write_all(&GGUF_MAGIC.to_le_bytes())
.map_err(|e| QLoraError::GgufExport(format!("Failed to write magic: {e}")))?;
writer
.write_all(&GGUF_VERSION.to_le_bytes())
.map_err(|e| QLoraError::GgufExport(format!("Failed to write version: {e}")))?;
let n_tensors = u64::try_from(tensors.len())
.map_err(|_| QLoraError::GgufExport("Too many tensors".into()))?;
writer
.write_all(&n_tensors.to_le_bytes())
.map_err(|e| QLoraError::GgufExport(format!("Failed to write tensor count: {e}")))?;
let n_kv = u64::try_from(kv_pairs.len())
.map_err(|_| QLoraError::GgufExport("Too many metadata entries".into()))?;
writer
.write_all(&n_kv.to_le_bytes())
.map_err(|e| QLoraError::GgufExport(format!("Failed to write kv count: {e}")))?;
for (key, value) in kv_pairs {
write_kv_pair(writer, key, value)?;
}
for ((name, tensor), &offset) in tensors.iter().zip(offsets.iter()) {
write_tensor_info(writer, name, tensor, offset)?;
}
Ok(())
}
fn write_kv_pair<W: Write>(writer: &mut W, key: &str, value: &str) -> Result<()> {
let key_bytes = key.as_bytes();
writer
.write_all(&(key_bytes.len() as u64).to_le_bytes())
.map_err(|e| QLoraError::GgufExport(format!("Failed to write key length: {e}")))?;
writer
.write_all(key_bytes)
.map_err(|e| QLoraError::GgufExport(format!("Failed to write key: {e}")))?;
writer
.write_all(&1u32.to_le_bytes())
.map_err(|e| QLoraError::GgufExport(format!("Failed to write value type: {e}")))?;
let value_bytes = value.as_bytes();
writer
.write_all(&(value_bytes.len() as u64).to_le_bytes())
.map_err(|e| QLoraError::GgufExport(format!("Failed to write value length: {e}")))?;
writer
.write_all(value_bytes)
.map_err(|e| QLoraError::GgufExport(format!("Failed to write value: {e}")))?;
Ok(())
}
fn write_tensor_info<W: Write>(
writer: &mut W,
name: &str,
tensor: &QuantizedTensor,
offset: u64,
) -> Result<()> {
let name_bytes = name.as_bytes();
writer
.write_all(&(name_bytes.len() as u64).to_le_bytes())
.map_err(|e| QLoraError::GgufExport(format!("Failed to write name length: {e}")))?;
writer
.write_all(name_bytes)
.map_err(|e| QLoraError::GgufExport(format!("Failed to write name: {e}")))?;
let n_dims = u32::try_from(tensor.shape.len())
.map_err(|_| QLoraError::GgufExport("Tensor has too many dimensions".into()))?;
writer
.write_all(&n_dims.to_le_bytes())
.map_err(|e| QLoraError::GgufExport(format!("Failed to write dimension count: {e}")))?;
for &dim in &tensor.shape {
writer
.write_all(&(dim as u64).to_le_bytes())
.map_err(|e| QLoraError::GgufExport(format!("Failed to write dimension: {e}")))?;
}
writer
.write_all(&GGUF_TYPE_Q4_0.to_le_bytes())
.map_err(|e| QLoraError::GgufExport(format!("Failed to write tensor type: {e}")))?;
writer
.write_all(&offset.to_le_bytes())
.map_err(|e| QLoraError::GgufExport(format!("Failed to write tensor offset: {e}")))?;
Ok(())
}
fn calculate_header_size(
tensors: &[(&str, &QuantizedTensor)],
kv_pairs: &HashMap<String, String>,
) -> usize {
let mut size = 0;
size += 4 + 4 + 8 + 8;
for (key, value) in kv_pairs {
size += 8 + key.len() + 4 + 8 + value.len(); }
for (name, tensor) in tensors {
size += 8 + name.len(); size += 4; size += tensor.shape.len() * 8; size += 4; size += 8; }
size
}
pub fn merge_and_export_gguf<P: AsRef<Path>>(
layer: &crate::qlora::QLoraLayer,
output_path: P,
) -> Result<()> {
use crate::quantization::{dequantize_nf4, quantize_nf4};
let quantized_base = layer.quantized_weight();
let device = layer.device();
let w_base = dequantize_nf4(quantized_base, device)?;
let (lora_a, lora_b) = layer.lora_weights();
let scale = layer.lora_scale();
let lora_delta = lora_b.matmul(lora_a)?;
let lora_delta_scaled = lora_delta.affine(scale, 0.0)?;
let w_merged = w_base.add(&lora_delta_scaled)?;
let config = layer.config();
let merged_quantized = quantize_nf4(&w_merged, config.quantization.block_size)?;
let metadata = GgufMetadata {
model_name: "qlora-merged".to_string(),
model_type: "merged".to_string(),
model_size: merged_quantized.numel(),
};
export_gguf(
&[("merged_weight", &merged_quantized)],
Some(metadata),
output_path,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::quantization::quantize_nf4;
use candle_core::{Device, Tensor};
use std::io::Read;
#[test]
fn test_export_gguf_header() {
let device = Device::Cpu;
let tensor = Tensor::zeros(&[64, 64], candle_core::DType::F32, &device).unwrap();
let quantized = quantize_nf4(&tensor, 64).unwrap();
let temp_path = std::env::temp_dir().join("test_export.gguf");
export_gguf(&[("test_tensor", &quantized)], None, &temp_path).unwrap();
let mut file = std::fs::File::open(&temp_path).unwrap();
let mut magic = [0u8; 4];
file.read_exact(&mut magic).unwrap();
assert_eq!(u32::from_le_bytes(magic), GGUF_MAGIC);
std::fs::remove_file(temp_path).ok();
}
#[test]
fn test_export_gguf_with_metadata() {
let device = Device::Cpu;
let tensor = Tensor::zeros(&[32, 32], candle_core::DType::F32, &device).unwrap();
let quantized = quantize_nf4(&tensor, 64).unwrap();
let metadata = GgufMetadata {
model_name: "test_model".to_string(),
model_type: "test".to_string(),
model_size: 32 * 32,
};
let temp_path = std::env::temp_dir().join("test_export_meta.gguf");
export_gguf(&[("weights", &quantized)], Some(metadata), &temp_path).unwrap();
let metadata = std::fs::metadata(&temp_path).unwrap();
assert!(metadata.len() > 0);
std::fs::remove_file(temp_path).ok();
}
#[test]
fn test_export_gguf_multiple_tensors() {
let device = Device::Cpu;
let tensor1 = Tensor::zeros(&[64, 64], candle_core::DType::F32, &device).unwrap();
let tensor2 = Tensor::zeros(&[32, 32], candle_core::DType::F32, &device).unwrap();
let quantized1 = quantize_nf4(&tensor1, 64).unwrap();
let quantized2 = quantize_nf4(&tensor2, 64).unwrap();
let temp_path = std::env::temp_dir().join("test_export_multi.gguf");
export_gguf(
&[("weights1", &quantized1), ("weights2", &quantized2)],
None,
&temp_path,
)
.unwrap();
let mut file = std::fs::File::open(&temp_path).unwrap();
let mut magic = [0u8; 4];
file.read_exact(&mut magic).unwrap();
assert_eq!(u32::from_le_bytes(magic), GGUF_MAGIC);
let mut version = [0u8; 4];
file.read_exact(&mut version).unwrap();
assert_eq!(u32::from_le_bytes(version), GGUF_VERSION);
std::fs::remove_file(temp_path).ok();
}
#[test]
fn test_merge_and_export_gguf() {
use crate::qlora::{QLoraConfig, QLoraLayer, QuantizedLinear};
let device = Device::Cpu;
let config = QLoraConfig::preset_all_bf16(8, 16);
let in_features = 64;
let out_features = 128;
let weight = Tensor::ones(
&[out_features, in_features],
candle_core::DType::F32,
&device,
)
.unwrap();
let linear = QuantizedLinear::from_weight(&weight, None, &config, &device).unwrap();
let layer = QLoraLayer::new(linear);
let temp_path = std::env::temp_dir().join("test_merge_export.gguf");
let result = merge_and_export_gguf(&layer, &temp_path);
assert!(result.is_ok(), "merge_and_export_gguf failed: {result:?}");
let mut file = std::fs::File::open(&temp_path).unwrap();
let mut magic = [0u8; 4];
file.read_exact(&mut magic).unwrap();
assert_eq!(u32::from_le_bytes(magic), GGUF_MAGIC);
let metadata = std::fs::metadata(&temp_path).unwrap();
assert!(metadata.len() > 0, "Output file should not be empty");
std::fs::remove_file(temp_path).ok();
}
#[test]
fn test_merge_and_export_gguf_preserves_shape() {
use crate::qlora::{QLoraConfig, QLoraLayer, QuantizedLinear};
let device = Device::Cpu;
let config = QLoraConfig::preset_all_bf16(4, 8);
let in_features = 32;
let out_features = 32;
let weight = Tensor::randn(0.0f32, 1.0f32, &[out_features, in_features], &device).unwrap();
let linear = QuantizedLinear::from_weight(&weight, None, &config, &device).unwrap();
let layer = QLoraLayer::new(linear);
let temp_path = std::env::temp_dir().join("test_merge_shape.gguf");
merge_and_export_gguf(&layer, &temp_path).unwrap();
assert!(temp_path.exists(), "Output file should exist after export");
let mut file = std::fs::File::open(&temp_path).unwrap();
let mut magic = [0u8; 4];
file.read_exact(&mut magic).unwrap();
assert_eq!(u32::from_le_bytes(magic), GGUF_MAGIC);
std::fs::remove_file(temp_path).ok();
}
}