use std::collections::BTreeMap;
use std::io::{BufWriter, Read};
use std::path::Path;
use anyhow::{bail, Context, Result};
use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
use frink_gguf::{GgmlType, GgufFile, GgufValue, GgufWriter, TensorPlan, GGUF_MAGIC};
pub const KV_GENERAL_TYPE: &str = "general.type";
pub const GENERAL_TYPE_IMATRIX: &str = "imatrix";
pub const KV_DATASETS: &str = "imatrix.datasets";
pub const KV_CHUNK_COUNT: &str = "imatrix.chunk_count";
pub const KV_CHUNK_SIZE: &str = "imatrix.chunk_size";
const SUFFIX_IN_SUM2: &str = ".in_sum2";
const SUFFIX_COUNTS: &str = ".counts";
#[derive(Debug, Clone, PartialEq)]
pub struct Stats {
pub values: Vec<f32>,
pub counts: Vec<i64>,
}
impl Stats {
pub fn n_mat(&self) -> usize {
self.counts.len()
}
pub fn n_per_row(&self) -> usize {
self.values.len() / self.n_mat().max(1)
}
}
#[derive(Debug, Default, Clone)]
pub struct ImatrixWeights {
pub entries: BTreeMap<String, Vec<f32>>,
pub datasets: Vec<String>,
pub chunk_count: u32,
}
pub fn read(path: &Path) -> Result<ImatrixWeights> {
let mut magic = [0u8; 4];
std::fs::File::open(path)
.and_then(|mut f| f.read_exact(&mut magic))
.with_context(|| format!("opening imatrix {}", path.display()))?;
if u32::from_le_bytes(magic) == GGUF_MAGIC {
read_gguf(path)
} else {
read_legacy(path)
}
}
fn read_gguf(path: &Path) -> Result<ImatrixWeights> {
let file =
GgufFile::open(path).with_context(|| format!("parsing imatrix {}", path.display()))?;
if file.metadata_str(KV_GENERAL_TYPE) != Some(GENERAL_TYPE_IMATRIX) {
bail!(
"{} is a GGUF but not an importance matrix: `{KV_GENERAL_TYPE}` is {:?}, expected \
\"{GENERAL_TYPE_IMATRIX}\"",
path.display(),
file.metadata_str(KV_GENERAL_TYPE)
);
}
let chunk_count = file
.metadata_u64(KV_CHUNK_COUNT)
.with_context(|| format!("{} has no `{KV_CHUNK_COUNT}`", path.display()))?
as u32;
file.metadata_u64(KV_CHUNK_SIZE)
.with_context(|| format!("{} has no `{KV_CHUNK_SIZE}`", path.display()))?;
let datasets = match file.metadata.get(KV_DATASETS) {
Some(GgufValue::Array(items)) => items
.iter()
.map(|v| {
v.as_str()
.map(str::to_string)
.with_context(|| format!("`{KV_DATASETS}` holds a non-string entry"))
})
.collect::<Result<Vec<_>>>()?,
_ => bail!("{} has no `{KV_DATASETS}` string array", path.display()),
};
let mut sums: BTreeMap<String, &frink_gguf::TensorInfo> = BTreeMap::new();
let mut counts: BTreeMap<String, &frink_gguf::TensorInfo> = BTreeMap::new();
for t in &file.tensors {
if let Some(name) = t.name.strip_suffix(SUFFIX_IN_SUM2) {
sums.insert(name.to_string(), t);
} else if let Some(name) = t.name.strip_suffix(SUFFIX_COUNTS) {
counts.insert(name.to_string(), t);
}
}
let mut entries = BTreeMap::new();
for (name, sum_info) in &sums {
let Some(count_info) = counts.get(name) else {
bail!("mismatched sums and counts for {name}: `.in_sum2` without `.counts`");
};
let sum = f32_tensor(&file, sum_info)?;
let cnt = f32_tensor(&file, count_info)?;
let n_mat = cnt.len();
if n_mat == 0 || !sum.len().is_multiple_of(n_mat) {
bail!(
"{name}: {} sums cannot be split over {n_mat} count(s)",
sum.len()
);
}
let ne0 = sum.len() / n_mat;
let mut e = vec![0f32; sum.len()];
for j in 0..n_mat {
let count = cnt[j];
for i in 0..ne0 {
e[j * ne0 + i] = if count > 0.0 {
sum[j * ne0 + i] / count
} else {
1.0
};
}
}
entries.insert(name.clone(), e);
}
for name in counts.keys() {
if !sums.contains_key(name) {
bail!("mismatched sums and counts for {name}: `.counts` without `.in_sum2`");
}
}
if entries.is_empty() {
bail!("no data in imatrix {}", path.display());
}
Ok(ImatrixWeights {
entries,
datasets,
chunk_count,
})
}
fn f32_tensor(file: &GgufFile, info: &frink_gguf::TensorInfo) -> Result<Vec<f32>> {
if info.dtype != GgmlType::F32 {
bail!(
"imatrix tensor {} is {:?}; llama.cpp writes F32",
info.name,
info.dtype
);
}
let bytes = file.tensor_bytes(&info.name)?;
Ok(bytes
.as_chunks::<4>()
.0
.iter()
.map(|c| f32::from_le_bytes(*c))
.collect())
}
fn read_legacy(path: &Path) -> Result<ImatrixWeights> {
let bytes = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
let mut cur = std::io::Cursor::new(bytes.as_slice());
let n_entries = cur
.read_i32::<LittleEndian>()
.context("legacy imatrix: entry count")?;
if n_entries < 1 {
bail!("no data in imatrix {} (legacy format)", path.display());
}
let mut entries = BTreeMap::new();
for i in 0..n_entries {
let remaining = |pos: u64| bytes.len().saturating_sub(pos as usize);
let len = cur
.read_i32::<LittleEndian>()
.context("legacy imatrix: name length")?;
if len < 0 || len as usize > remaining(cur.position()) {
bail!("legacy imatrix entry {i}: name length {len} exceeds the file");
}
let mut name = vec![0u8; len as usize];
cur.read_exact(&mut name)?;
let name = String::from_utf8(name).context("legacy imatrix: name is not UTF-8")?;
let ncall = cur
.read_i32::<LittleEndian>()
.context("legacy imatrix: ncall")?;
let nval = cur
.read_i32::<LittleEndian>()
.context("legacy imatrix: nval")?;
if nval < 1 || (nval as usize).saturating_mul(4) > remaining(cur.position()) {
bail!("legacy imatrix entry {i} ({name}): value count {nval} exceeds the file");
}
let mut vals = vec![0f32; nval as usize];
cur.read_f32_into::<LittleEndian>(&mut vals)?;
if ncall > 0 {
for v in &mut vals {
*v /= ncall as f32;
}
}
entries.insert(name, vals);
}
let mut chunk_count = 0u32;
let mut datasets = Vec::new();
if (cur.position() as usize) < bytes.len() {
chunk_count = cur.read_i32::<LittleEndian>().unwrap_or(0).max(0) as u32;
if let Ok(len) = cur.read_i32::<LittleEndian>() {
let remaining = bytes.len().saturating_sub(cur.position() as usize);
if len > 0 && len as usize <= remaining {
let mut d = vec![0u8; len as usize];
cur.read_exact(&mut d)?;
datasets.push(String::from_utf8_lossy(&d).into_owned());
}
}
}
Ok(ImatrixWeights {
entries,
datasets,
chunk_count,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
Gguf,
Dat,
}
impl std::str::FromStr for OutputFormat {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s {
"gguf" => Ok(OutputFormat::Gguf),
"dat" => Ok(OutputFormat::Dat),
other => Err(format!(
"--output-format must be `gguf` or `dat`, not `{other}`"
)),
}
}
}
pub fn write(
path: &Path,
format: OutputFormat,
stats: &BTreeMap<String, Stats>,
datasets: &[String],
chunk_count: u32,
chunk_size: u32,
) -> Result<()> {
match format {
OutputFormat::Gguf => write_gguf(path, stats, datasets, chunk_count, chunk_size),
OutputFormat::Dat => write_legacy(path, stats, datasets, chunk_count, chunk_size),
}
}
fn ggml_shape(ne0: usize, ne1: usize) -> Vec<u64> {
if ne1 == 1 {
vec![ne0 as u64]
} else {
vec![ne0 as u64, ne1 as u64]
}
}
fn write_gguf(
path: &Path,
stats: &BTreeMap<String, Stats>,
datasets: &[String],
chunk_count: u32,
chunk_size: u32,
) -> Result<()> {
let mut metadata = BTreeMap::new();
metadata.insert(
KV_GENERAL_TYPE.to_string(),
GgufValue::String(GENERAL_TYPE_IMATRIX.into()),
);
metadata.insert(
KV_DATASETS.to_string(),
GgufValue::Array(
datasets
.iter()
.map(|d| GgufValue::String(d.clone()))
.collect(),
),
);
metadata.insert(KV_CHUNK_COUNT.to_string(), GgufValue::U32(chunk_count));
metadata.insert(KV_CHUNK_SIZE.to_string(), GgufValue::U32(chunk_size));
let mut plan = Vec::with_capacity(stats.len() * 2);
for (name, s) in stats {
let (n_mat, ne0) = (s.n_mat(), s.n_per_row());
if s.values.is_empty() || n_mat == 0 {
continue;
}
plan.push(TensorPlan {
name: format!("{name}{SUFFIX_IN_SUM2}"),
shape: ggml_shape(ne0, n_mat),
dtype: GgmlType::F32,
byte_len: s.values.len() * 4,
});
plan.push(TensorPlan {
name: format!("{name}{SUFFIX_COUNTS}"),
shape: ggml_shape(1, n_mat),
dtype: GgmlType::F32,
byte_len: n_mat * 4,
});
}
let out =
std::fs::File::create(path).with_context(|| format!("creating {}", path.display()))?;
let mut w = GgufWriter::create(BufWriter::new(out), &metadata, plan)?;
for (name, s) in stats {
if s.values.is_empty() || s.n_mat() == 0 {
continue;
}
let sums: Vec<u8> = s.values.iter().flat_map(|v| v.to_le_bytes()).collect();
w.write_tensor(&format!("{name}{SUFFIX_IN_SUM2}"), &sums)?;
let counts: Vec<u8> = s
.counts
.iter()
.flat_map(|c| (*c as f32).to_le_bytes())
.collect();
w.write_tensor(&format!("{name}{SUFFIX_COUNTS}"), &counts)?;
}
w.finish()?.into_inner().context("flushing")?;
Ok(())
}
fn write_legacy(
path: &Path,
stats: &BTreeMap<String, Stats>,
datasets: &[String],
chunk_count: u32,
chunk_size: u32,
) -> Result<()> {
let to_store: Vec<(&String, &Stats)> = stats
.iter()
.filter(|(_, s)| !s.counts.is_empty() && s.counts.iter().any(|&c| c != 0))
.collect();
let out =
std::fs::File::create(path).with_context(|| format!("creating {}", path.display()))?;
let mut w = BufWriter::new(out);
w.write_i32::<LittleEndian>(to_store.len() as i32)?;
for (name, s) in &to_store {
w.write_i32::<LittleEndian>(name.len() as i32)?;
w.write_all_bytes(name.as_bytes())?;
let max_count = s.counts.iter().copied().max().unwrap_or(0);
let ncall = ((max_count + i64::from(chunk_size) - 1) / i64::from(chunk_size)) as i32;
w.write_i32::<LittleEndian>(ncall)?;
let nval = s.values.len();
let nmat = s.counts.len();
w.write_i32::<LittleEndian>(nval as i32)?;
for i in 0..nval {
let mut count = s.counts[i / (nval / nmat)] as f32;
let mut value = s.values[i];
if count == 0.0 {
value = 1.0;
count = 1.0;
}
w.write_f32::<LittleEndian>((value / count) * ncall as f32)?;
}
}
w.write_i32::<LittleEndian>(chunk_count as i32)?;
let dataset = datasets.last().map(String::as_str).unwrap_or("");
w.write_i32::<LittleEndian>(dataset.len() as i32)?;
w.write_all_bytes(dataset.as_bytes())?;
use std::io::Write;
w.flush()?;
Ok(())
}
trait WriteAllBytes {
fn write_all_bytes(&mut self, b: &[u8]) -> std::io::Result<()>;
}
impl<W: std::io::Write> WriteAllBytes for W {
fn write_all_bytes(&mut self, b: &[u8]) -> std::io::Result<()> {
self.write_all(b)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"frink-imatrix-file-{tag}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn sample() -> BTreeMap<String, Stats> {
let mut m = BTreeMap::new();
m.insert(
"blk.0.attn_q.weight".to_string(),
Stats {
values: vec![10.0, 20.0, 30.0, 40.0],
counts: vec![4],
},
);
m.insert(
"blk.0.ffn_gate_exps.weight".to_string(),
Stats {
values: vec![6.0, 9.0, 0.0, 0.0],
counts: vec![3, 0],
},
);
m
}
#[test]
fn gguf_round_trip_divides_sums_by_counts_and_substitutes_one_for_empty() {
let dir = tmp("gguf");
let p = dir.join("im.gguf");
write(
&p,
OutputFormat::Gguf,
&sample(),
&["calib.txt".to_string()],
7,
512,
)
.unwrap();
let back = read(&p).unwrap();
assert_eq!(back.chunk_count, 7);
assert_eq!(back.datasets, vec!["calib.txt".to_string()]);
assert_eq!(
back.entries["blk.0.attn_q.weight"],
vec![2.5, 5.0, 7.5, 10.0]
);
assert_eq!(
back.entries["blk.0.ffn_gate_exps.weight"],
vec![2.0, 3.0, 1.0, 1.0]
);
let f = GgufFile::open(&p).unwrap();
assert_eq!(
f.find_tensor("blk.0.attn_q.weight.in_sum2").unwrap().shape,
vec![4]
);
assert_eq!(
f.find_tensor("blk.0.attn_q.weight.counts").unwrap().shape,
vec![1]
);
assert_eq!(
f.find_tensor("blk.0.ffn_gate_exps.weight.in_sum2")
.unwrap()
.shape,
vec![2, 2]
);
assert_eq!(
f.find_tensor("blk.0.ffn_gate_exps.weight.counts")
.unwrap()
.shape,
vec![1, 2]
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn legacy_round_trip_reproduces_quantize_cpps_division() {
let dir = tmp("dat");
let p = dir.join("im.dat");
write(
&p,
OutputFormat::Dat,
&sample(),
&["calib.txt".to_string()],
7,
2,
)
.unwrap();
let back = read(&p).unwrap();
assert_eq!(back.chunk_count, 7);
assert_eq!(back.datasets, vec!["calib.txt".to_string()]);
assert_eq!(
back.entries["blk.0.attn_q.weight"],
vec![2.5, 5.0, 7.5, 10.0]
);
assert_eq!(
back.entries["blk.0.ffn_gate_exps.weight"],
vec![2.0, 3.0, 1.0, 1.0]
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_gguf_without_the_imatrix_type_is_refused() {
let dir = tmp("notim");
let p = dir.join("model.gguf");
let mut md = BTreeMap::new();
md.insert(
"general.architecture".to_string(),
GgufValue::String("llama".into()),
);
let f = std::fs::File::create(&p).unwrap();
GgufWriter::create(BufWriter::new(f), &md, vec![])
.unwrap()
.finish()
.unwrap();
let err = read(&p).unwrap_err().to_string();
assert!(err.contains("not an importance matrix"), "{err}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_legacy_value_count_larger_than_the_file_is_refused_before_allocating() {
let dir = tmp("bound");
let p = dir.join("bad.dat");
let mut b = Vec::new();
b.write_i32::<LittleEndian>(1).unwrap();
b.write_i32::<LittleEndian>(3).unwrap();
b.extend_from_slice(b"abc");
b.write_i32::<LittleEndian>(1).unwrap();
b.write_i32::<LittleEndian>(i32::MAX).unwrap();
std::fs::write(&p, &b).unwrap();
let err = read(&p).unwrap_err().to_string();
assert!(err.contains("exceeds the file"), "{err}");
std::fs::remove_dir_all(&dir).ok();
}
}