use super::gguf_format::{
payload_from_tensors, write_gguf_file, GGUFPayload, GGUF_DEFAULT_ALIGNMENT,
};
use super::{collect_model_tensors, ExportConfig, ExportFormat, ModelExporter};
use crate::traits::{Config, Model};
use anyhow::{anyhow, Result};
use std::collections::BTreeMap;
pub use super::gguf_format::{
GGUFHeader, GGUFTensorInfo, GGUFTensorType, GGUFValue, GGUFValueType, GGUF_MAGIC, GGUF_VERSION,
};
#[derive(Clone, Debug)]
pub struct GGUFExporter {
alignment: usize,
}
impl Default for GGUFExporter {
fn default() -> Self {
Self::new()
}
}
impl GGUFExporter {
pub fn new() -> Self {
Self {
alignment: GGUF_DEFAULT_ALIGNMENT as usize,
}
}
pub fn with_alignment(mut self, alignment: usize) -> Self {
self.alignment = alignment;
self
}
pub fn alignment(&self) -> usize {
self.alignment
}
pub fn align_offset(&self, offset: u64) -> u64 {
super::gguf_format::align_up(offset, self.alignment as u64)
}
pub fn build_payload<M: Model>(&self, model: &M, config: &ExportConfig) -> Result<GGUFPayload> {
if !self.alignment.is_power_of_two() || self.alignment == 0 {
return Err(anyhow!(
"GGUF alignment must be a positive power of two, got {}",
self.alignment
));
}
let tensors = collect_model_tensors(model)?;
let tensor_type = GGUFTensorType::from_precision(config.precision);
let architecture = model.get_config().architecture();
let mut metadata = BTreeMap::new();
metadata.insert(
"general.architecture".to_string(),
GGUFValue::String(architecture.to_string()),
);
metadata.insert(
"general.alignment".to_string(),
GGUFValue::UInt32(self.alignment as u32),
);
metadata.insert(
"general.file_type".to_string(),
GGUFValue::UInt32(tensor_type.file_type()),
);
metadata.insert(
"general.quantization_version".to_string(),
GGUFValue::UInt32(2),
);
metadata.insert(
"general.parameter_count".to_string(),
GGUFValue::UInt64(model.num_parameters() as u64),
);
if let Some(context_length) = config.sequence_length {
metadata.insert(
format!("{architecture}.context_length"),
GGUFValue::UInt64(context_length as u64),
);
}
if let Some(vocab_size) = config.vocab_size {
metadata.insert(
format!("{architecture}.vocab_size"),
GGUFValue::UInt64(vocab_size as u64),
);
}
payload_from_tensors(metadata, &tensors, tensor_type)
}
}
impl ModelExporter for GGUFExporter {
fn export<M: Model>(&self, model: &M, config: &ExportConfig) -> Result<()> {
if config.format != ExportFormat::GGUF {
return Err(anyhow!("GGUFExporter only supports GGUF format"));
}
let payload = self.build_payload(model, config)?;
let output_path = format!("{}.gguf", config.output_path);
write_gguf_file(&output_path, &payload)?;
log::info!(
"wrote {} tensors ({} parameters) to {}",
payload.tensors.len(),
model.num_parameters(),
output_path
);
Ok(())
}
fn supported_formats(&self) -> Vec<ExportFormat> {
vec![ExportFormat::GGUF]
}
fn validate_model<M: Model>(&self, model: &M, format: ExportFormat) -> Result<()> {
if format != ExportFormat::GGUF {
return Err(anyhow!("GGUFExporter only supports GGUF format"));
}
collect_model_tensors(model)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::export::gguf_format::read_gguf_file;
use crate::export::test_support::TestModel;
use crate::export::ExportPrecision;
fn temp_dir(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(name);
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temp dir");
dir
}
#[test]
fn test_gguf_exporter_creation() {
let exporter = GGUFExporter::new();
assert_eq!(exporter.alignment(), 32);
assert_eq!(exporter.with_alignment(64).alignment(), 64);
}
#[test]
fn test_gguf_constants() {
assert_eq!(GGUF_MAGIC, 0x4655_4747);
assert_eq!(GGUF_VERSION, 3);
}
#[test]
fn test_gguf_tensor_type_conversion() {
assert_eq!(
GGUFTensorType::from_precision(ExportPrecision::FP32) as u32,
0
);
assert_eq!(
GGUFTensorType::from_precision(ExportPrecision::FP16) as u32,
1
);
assert_eq!(
GGUFTensorType::from_precision(ExportPrecision::INT8) as u32,
8
);
assert_eq!(
GGUFTensorType::from_precision(ExportPrecision::INT4) as u32,
2
);
}
#[test]
fn test_supported_formats() {
let exporter = GGUFExporter::new();
assert_eq!(exporter.supported_formats(), vec![ExportFormat::GGUF]);
}
#[test]
fn test_offset_alignment() {
let exporter = GGUFExporter::new().with_alignment(32);
assert_eq!(exporter.align_offset(0), 0);
assert_eq!(exporter.align_offset(1), 32);
assert_eq!(exporter.align_offset(32), 32);
assert_eq!(exporter.align_offset(33), 64);
}
#[test]
fn export_writes_the_models_real_tensors() {
let dir = temp_dir("trustformers_gguf_export_real");
let output = dir.join("model");
let model = TestModel::with_seed(0.5);
let config = ExportConfig {
format: ExportFormat::GGUF,
output_path: output.to_string_lossy().to_string(),
precision: ExportPrecision::FP32,
..Default::default()
};
GGUFExporter::new().export(&model, &config).expect("export");
let parsed = read_gguf_file(output.with_extension("gguf")).expect("re-read");
assert_eq!(parsed.tensors.len(), 3);
assert_eq!(
parsed.metadata.get("general.architecture").and_then(GGUFValue::as_str),
Some("test_transformer")
);
for (name, tensor) in model.named_tensors() {
let expected = tensor.to_vec_f32().expect("f32");
let actual = parsed.tensor_f32(&name).expect("tensor present");
assert_eq!(actual, expected, "tensor '{name}' must round-trip exactly");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn export_output_varies_with_the_model_weights() {
let dir = temp_dir("trustformers_gguf_export_varies");
let write = |seed: f32, name: &str| {
let output = dir.join(name);
let config = ExportConfig {
format: ExportFormat::GGUF,
output_path: output.to_string_lossy().to_string(),
..Default::default()
};
GGUFExporter::new()
.export(&TestModel::with_seed(seed), &config)
.expect("export");
std::fs::read(output.with_extension("gguf")).expect("read back")
};
let a = write(0.0, "a");
let b = write(7.0, "b");
assert_eq!(a.len(), b.len(), "same shapes give the same file size");
assert_ne!(a, b, "different weights must give different files");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn export_refuses_a_model_without_named_tensors() {
let dir = temp_dir("trustformers_gguf_export_empty");
let output = dir.join("model");
let config = ExportConfig {
format: ExportFormat::GGUF,
output_path: output.to_string_lossy().to_string(),
..Default::default()
};
let err = GGUFExporter::new()
.export(&TestModel::empty(), &config)
.expect_err("must not invent weights");
assert!(err.to_string().contains("named_tensors"), "{err}");
assert!(
!output.with_extension("gguf").exists(),
"no file may be written"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn quantized_export_round_trips_within_tolerance() {
let dir = temp_dir("trustformers_gguf_export_q8");
let output = dir.join("model");
let values: Vec<f32> = (0..64).map(|i| (i as f32 - 32.0) * 0.125).collect();
let model = TestModel::new(
Default::default(),
vec![(
"w".to_string(),
crate::tensor::Tensor::from_vec(values.clone(), &[2, 32]).expect("tensor"),
)],
);
let config = ExportConfig {
format: ExportFormat::GGUF,
output_path: output.to_string_lossy().to_string(),
precision: ExportPrecision::INT8,
..Default::default()
};
GGUFExporter::new().export(&model, &config).expect("export");
let parsed = read_gguf_file(output.with_extension("gguf")).expect("re-read");
assert_eq!(parsed.tensors[0].0.tensor_type, GGUFTensorType::Q8_0);
let recovered = parsed.tensor_f32("w").expect("tensor");
for (original, actual) in values.iter().zip(recovered.iter()) {
assert!(
(original - actual).abs() < 4.0 / 127.0,
"{original} -> {actual}"
);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn quantized_export_rejects_tensors_that_do_not_fill_a_block() {
let dir = temp_dir("trustformers_gguf_export_ragged");
let output = dir.join("model");
let config = ExportConfig {
format: ExportFormat::GGUF,
output_path: output.to_string_lossy().to_string(),
precision: ExportPrecision::INT4,
..Default::default()
};
let err = GGUFExporter::new()
.export(&TestModel::with_seed(1.0), &config)
.expect_err("ragged tensors must be rejected, not padded silently");
assert!(err.to_string().contains("block size"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
}