pub mod collector;
pub mod file;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Instant;
use anyhow::{bail, Context, Result};
use clap::Parser;
use frink_core::cache::KvCache;
use frink_core::WeightMatrix;
use frink_gguf::sharded::ShardedGguf;
use frink_gguf::GgufFile;
use frink_models::decoder::{Decoder, ExpertBacking};
use collector::Collector;
use file::OutputFormat;
#[derive(Parser, Debug)]
pub struct ImatrixArgs {
#[arg(short = 'm', long = "model")]
pub model: String,
#[arg(short = 'f', long = "file")]
pub file: PathBuf,
#[arg(short = 'o', long = "output", default_value = "imatrix.gguf")]
pub output: PathBuf,
#[arg(long = "output-format", default_value = "gguf")]
pub output_format: OutputFormat,
#[arg(short = 'c', long = "ctx-size", default_value_t = 512)]
pub ctx_size: usize,
#[arg(long, default_value_t = -1)]
pub chunks: i64,
#[arg(long)]
pub process_output: bool,
#[arg(short = 't', long, default_value_t = 0)]
pub threads: usize,
#[arg(long)]
pub compare: Option<PathBuf>,
}
pub fn run(args: ImatrixArgs) -> Result<()> {
if args.ctx_size == 0 {
bail!("imatrix requires --ctx-size > 0");
}
crate::bench_model::apply_env(args.threads, 0)?;
let path = crate::pull::resolve_model_path(&args.model)?;
let text = std::fs::read_to_string(&args.file)
.with_context(|| format!("reading calibration text {}", args.file.display()))?;
let t0 = Instant::now();
let (decoder, tokens, _eos) = crate::verify_engine::load_and_tokenize(
Path::new(&path),
&text,
frink_models::tokenizer::SpecialTokens::AsText,
None,
)
.context("loading the model and tokenizing the calibration text")?;
println!(
"imatrix: tokenization and load took {:.1} s; {} tokens",
t0.elapsed().as_secs_f64(),
tokens.len()
);
let file = ShardedGguf::open(&path)?;
let bos = if frink_models::tokenizer::should_add_bos_token(&file) {
file.metadata_u64("tokenizer.ggml.bos_token_id")
.map(|v| v as usize)
} else {
None
};
drop(file);
let n_ctx = args.ctx_size;
if tokens.len() < 2 * n_ctx {
bail!(
"you need at least {} tokens for a context of {n_ctx} tokens; the data file you \
provided tokenizes to only {}",
2 * n_ctx,
tokens.len()
);
}
let n_chunk_max = tokens.len() / n_ctx;
let n_chunk = if args.chunks < 0 {
n_chunk_max
} else {
(args.chunks as usize).min(n_chunk_max)
};
let header = GgufFile::open(&path)?;
let mut collector = Collector::default();
let registered = register_decoder(&decoder, &header, &mut collector, args.process_output)?;
drop(header);
println!(
"imatrix: collecting {} weights ({} dense, {} expert stacks); computing over {n_chunk} \
chunks, n_ctx={n_ctx}, backend {}",
collector.n_registered(),
registered.dense.len(),
registered.expert_stacks.len(),
crate::bench_model::active_backend()
);
let collector = Arc::new(collector);
let tap = collector.clone();
let guard = frink_core::activation_tap::install(Arc::new(
move |m: &WeightMatrix, rows: &[f32], n: usize| tap.observe(m, rows, n),
))
.map_err(|e| anyhow::anyhow!("{e}"))?;
let run_start = Instant::now();
for chunk in 0..n_chunk {
let start = chunk * n_ctx;
let mut ids = tokens[start..start + n_ctx].to_vec();
if let Some(b) = bos {
ids[0] = b;
}
let mut caches: Vec<KvCache> = decoder.config.new_kv_caches();
let t = Instant::now();
let _logits = decoder.forward_batch(&ids, 0, &mut caches);
if let Some(msg) = collector.non_finite() {
drop(guard);
bail!("{msg}");
}
let per_chunk = t.elapsed().as_secs_f64();
println!(
"[{}/{n_chunk}] {per_chunk:.2} s/chunk, ETA {:.0} s",
chunk + 1,
per_chunk * (n_chunk - chunk - 1) as f64
);
}
drop(guard);
println!(
"imatrix: {n_chunk} chunks in {:.1} s",
run_start.elapsed().as_secs_f64()
);
let stats = collector.stats();
let n_tokens = (n_chunk * n_ctx) as i64;
check_counts(&stats, ®istered, n_tokens)?;
let dataset = args.file.to_string_lossy().into_owned();
file::write(
&args.output,
args.output_format,
&stats,
&[dataset],
n_chunk as u32,
n_ctx as u32,
)?;
println!(
"imatrix: stored collected data after {n_chunk} chunks in {}",
args.output.display()
);
if let Some(other) = &args.compare {
compare(&args.output, other)?;
}
Ok(())
}
#[derive(Default)]
struct Registered {
dense: Vec<String>,
expert_stacks: Vec<String>,
}
fn register_decoder(
decoder: &Decoder,
header: &GgufFile,
collector: &mut Collector,
process_output: bool,
) -> Result<Registered> {
if decoder.gpt_oss.is_some() {
bail!(
"this checkpoint uses the gpt-oss graph, whose projections `frink imatrix` has not \
mapped to tensor names; refusing rather than writing an imatrix with the wrong \
entries"
);
}
let has = |name: &str| header.find_tensor(name).is_some();
let mut reg = Registered::default();
let dense =
|collector: &mut Collector, reg: &mut Registered, m: &WeightMatrix, name: String| {
collector.register_dense(m, &name);
reg.dense.push(name);
};
for (i, layer) in decoder.layers.iter().enumerate() {
let n = |t: &str| format!("blk.{i}.{t}.weight");
if has(&n("attn_qkv")) {
dense(collector, &mut reg, &layer.attn.q_proj, n("attn_qkv"));
} else {
for (m, t) in [
(&layer.attn.q_proj, "attn_q"),
(&layer.attn.k_proj, "attn_k"),
(&layer.attn.v_proj, "attn_v"),
] {
if has(&n(t)) {
dense(collector, &mut reg, m, n(t));
} else {
bail!(
"layer {i}: neither {} nor {} is in the file",
n(t),
n("attn_qkv")
);
}
}
}
if has(&n("attn_output")) {
dense(collector, &mut reg, &layer.attn.o_proj, n("attn_output"));
} else {
bail!("layer {i}: {} is not in the file", n("attn_output"));
}
let experts = match &layer.moe.experts {
ExpertBacking::Resident(v) => v,
ExpertBacking::Stored { .. } => bail!(
"layer {i}: experts are streamed from an expert store, whose per-use weight \
views have no stable identity for the tap; run with resident experts"
),
};
if experts.len() == 1 && has(&n("ffn_gate")) {
let ex = &experts[0];
for (m, t) in [
(&ex.gate, "ffn_gate"),
(&ex.up, "ffn_up"),
(&ex.down, "ffn_down"),
] {
if has(&n(t)) {
dense(collector, &mut reg, m, n(t));
} else {
bail!("layer {i}: {} is not in the file", n(t));
}
}
} else if has(&n("ffn_gate_exps")) {
let n_experts = experts.len();
for t in ["ffn_gate_exps", "ffn_up_exps", "ffn_down_exps"] {
if !has(&n(t)) {
bail!("layer {i}: {} is not in the file", n(t));
}
}
for (e, ex) in experts.iter().enumerate() {
collector.register_expert(&ex.gate, &n("ffn_gate_exps"), e, n_experts);
collector.register_expert(&ex.up, &n("ffn_up_exps"), e, n_experts);
collector.register_expert(&ex.down, &n("ffn_down_exps"), e, n_experts);
}
reg.expert_stacks.extend(
["ffn_gate_exps", "ffn_up_exps", "ffn_down_exps"]
.iter()
.map(|t| n(t)),
);
if has(&n("ffn_gate_inp")) {
dense(collector, &mut reg, &layer.moe.router, n("ffn_gate_inp"));
}
if let Some(sh) = layer.moe.shared_experts.first() {
for (m, t) in [
(&sh.gate, "ffn_gate_shexp"),
(&sh.up, "ffn_up_shexp"),
(&sh.down, "ffn_down_shexp"),
] {
if has(&n(t)) {
dense(collector, &mut reg, m, n(t));
}
}
}
} else {
bail!(
"layer {i}: the file carries neither {} nor {}, so its FFN weights cannot be named",
n("ffn_gate"),
n("ffn_gate_exps")
);
}
}
if process_output {
if has("output.weight") {
dense(
collector,
&mut reg,
&decoder.output_head,
"output.weight".into(),
);
} else {
println!(
"imatrix: --process-output given but the file has no output.weight (tied \
embeddings); llama.cpp does not collect a tied head either, so nothing is added"
);
}
}
Ok(reg)
}
fn check_counts(
stats: &std::collections::BTreeMap<String, file::Stats>,
registered: &Registered,
n_tokens: i64,
) -> Result<()> {
for name in ®istered.dense {
let c = stats[name].counts[0];
if c != n_tokens {
bail!(
"entry {name} saw {c} activation rows but {n_tokens} tokens were processed: the \
decoder path for this weight did not go through the activation tap exactly \
once, so the file would be wrong. Refusing to write it."
);
}
}
for name in ®istered.expert_stacks {
let counts = &stats[name].counts;
let n_zero = counts.iter().filter(|&&c| c == 0).count();
if n_zero == counts.len() {
bail!(
"entry {name} saw no activation rows for any of its {} experts while {n_tokens} \
tokens were processed: the MoE prefill path did not go through the activation \
tap. Refusing to write a file with an empty expert stack.",
counts.len()
);
}
if n_zero > 0 {
println!(
"imatrix: entry '{name}' has partial data ({:.2}%)",
100.0 * (counts.len() - n_zero) as f64 / counts.len() as f64
);
}
}
Ok(())
}
fn compare(ours: &Path, theirs: &Path) -> Result<()> {
let a = file::read(ours)?;
let b = file::read(theirs)?;
println!(
"imatrix compare: {} ({} entries) vs {} ({} entries)",
ours.display(),
a.entries.len(),
theirs.display(),
b.entries.len()
);
let mut worst = 0f64;
let mut worst_name = String::new();
let mut n_common = 0usize;
for (name, va) in &a.entries {
let Some(vb) = b.entries.get(name) else {
println!(" only in {}: {name}", ours.display());
continue;
};
if va.len() != vb.len() {
println!(
" {name}: {} values vs {} -- not comparable",
va.len(),
vb.len()
);
continue;
}
n_common += 1;
let mut max_rel = 0f64;
let mut max_abs = 0f64;
for (&x, &y) in va.iter().zip(vb) {
let (x, y) = (x as f64, y as f64);
let denom = x.abs().max(y.abs());
if denom > 0.0 {
max_rel = max_rel.max((x - y).abs() / denom);
}
max_abs = max_abs.max(x.abs().max(y.abs()));
}
println!(" {name:<40} max rel diff {max_rel:.3e} (max |w| {max_abs:.3e})");
if max_rel > worst {
worst = max_rel;
worst_name = name.clone();
}
}
for name in b.entries.keys() {
if !a.entries.contains_key(name) {
println!(" only in {}: {name}", theirs.display());
}
}
println!(
"imatrix compare: {n_common} entries compared; worst relative difference {worst:.3e} \
in {worst_name}"
);
Ok(())
}