Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 59 additions & 77 deletions crates/aprender-compute/src/inference/gguf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,84 +21,59 @@ use crate::error::TruenoError;

const GGUF_MAGIC: u32 = 0x4655_4747; // "GGUF" in little-endian

/// GGML tensor type IDs (subset used for LLM inference).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u32)]
pub enum GgmlType {
F32 = 0,
F16 = 1,
Q4_0 = 2,
Q4_1 = 3,
Q5_0 = 6,
Q5_1 = 7,
Q8_0 = 8,
Q8_1 = 9,
Q2K = 10,
Q3K = 11,
Q4K = 12,
Q5K = 13,
Q6K = 14,
Q8K = 15,
Bf16 = 30,
}

impl GgmlType {
fn from_u32(v: u32) -> Option<Self> {
match v {
0 => Some(Self::F32),
1 => Some(Self::F16),
2 => Some(Self::Q4_0),
3 => Some(Self::Q4_1),
6 => Some(Self::Q5_0),
7 => Some(Self::Q5_1),
8 => Some(Self::Q8_0),
9 => Some(Self::Q8_1),
10 => Some(Self::Q2K),
11 => Some(Self::Q3K),
12 => Some(Self::Q4K),
13 => Some(Self::Q5K),
14 => Some(Self::Q6K),
15 => Some(Self::Q8K),
30 => Some(Self::Bf16),
_ => None,
}
}

/// Bytes per block for this quantization type.
pub fn block_bytes(&self) -> usize {
match self {
Self::F32 => 4,
Self::F16 | Self::Bf16 => 2,
Self::Q4_0 => 18, // 32 weights: 2 (scale) + 16 (4-bit)
Self::Q4_1 => 20, // 32 weights: 2+2 (scale+min) + 16
Self::Q5_0 => 22, // 32 weights
Self::Q5_1 => 24,
Self::Q8_0 => 34, // 32 weights: 2 (scale) + 32 (8-bit)
Self::Q8_1 => 36,
Self::Q2K => 84, // 256 weights
Self::Q3K => 110,
Self::Q4K => 144, // 256 weights
Self::Q5K => 176,
Self::Q6K => 210,
Self::Q8K => 292,
// PMAT-3430 M1 Phase 3: the enum that used to be declared here is now the
// workspace's ONE ggml tensor-type enum, re-exported from the leaf crate.
//
// compute's copy listed 15 ids and carried the block geometry for them. Every
// one of those 15 rows agreed with upstream ggml exactly — it was the only one
// of the three in-tree enums that did — so nothing about sizes changes here;
// `block_bytes`, `block_size` and `tensor_bytes` now read the same numbers out
// of `TRAITS`, which is extracted from ggml at the pinned commit rather than
// typed. `crates/aprender-compute/src/inference/gguf_characterization_tests.rs`
// asserts all 15 rows and the rounding are unchanged.
pub use trueno_quant::GgmlType;

/// The ids compute ACCEPTS at its GGUF parse boundary.
///
/// The leaf knows all 35 live ggml types; this crate knew 15 and must keep
/// knowing exactly 15 (#3430 Q1-c: M1 changes no crate's admitted set, because
/// widening one silently is how a loader starts decoding bytes it has no kernel
/// for). Adding a row here is a deliberate act with its own ticket, not a
/// side effect of the enum growing.
const ADMITTED: [GgmlType; 15] = [
GgmlType::F32,
GgmlType::F16,
GgmlType::Q4_0,
GgmlType::Q4_1,
GgmlType::Q5_0,
GgmlType::Q5_1,
GgmlType::Q8_0,
GgmlType::Q8_1,
GgmlType::Q2K,
GgmlType::Q3K,
GgmlType::Q4K,
GgmlType::Q5K,
GgmlType::Q6K,
GgmlType::Q8K,
GgmlType::BF16,
];

/// The GGUF parse boundary: an id becomes a type only if compute admits it.
///
/// This was `GgmlType::from_u32`, an inherent method. An inherent method cannot
/// follow a type that is now defined in another crate, so it survives as a free
/// function under the same name — the one call-site change #3430 Q1-c named in
/// advance (`GgmlType::from_u32(x)` -> `from_u32(x)`).
fn from_u32(v: u32) -> Option<GgmlType> {
let t = GgmlType::from_id(v)?;
let mut i = 0;
while i < ADMITTED.len() {
if ADMITTED[i] as u32 == t as u32 {
return Some(t);
}
i += 1;
}

/// Weights per block.
pub fn block_size(&self) -> usize {
match self {
Self::F32 | Self::F16 | Self::Bf16 => 1,
Self::Q4_0 | Self::Q4_1 | Self::Q5_0 | Self::Q5_1 | Self::Q8_0 | Self::Q8_1 => 32,
Self::Q2K | Self::Q3K | Self::Q4K | Self::Q5K | Self::Q6K | Self::Q8K => 256,
}
}

/// Total bytes for `n_elements` weights.
pub fn tensor_bytes(&self, n_elements: usize) -> usize {
let bs = self.block_size();
let n_blocks = (n_elements + bs - 1) / bs;
n_blocks * self.block_bytes()
}
None
}

/// Info about a single tensor in the GGUF file.
Expand Down Expand Up @@ -223,7 +198,7 @@ impl GgufFile {
dims.push(read_u64(&mut cursor)?);
}
let dtype_u32 = read_u32(&mut cursor)?;
let dtype = GgmlType::from_u32(dtype_u32).ok_or_else(|| {
let dtype = from_u32(dtype_u32).ok_or_else(|| {
TruenoError::InvalidInput(format!(
"Unknown GGML type {dtype_u32} for tensor '{name}'"
))
Expand Down Expand Up @@ -437,3 +412,10 @@ mod tests {
assert_eq!(file.tensors.len(), 0);
}
}

// PMAT-3430 Q1-c: the characterization snapshot for compute's one id boundary
// and its block geometry. A child module, so it reaches the private `from_u32`
// without widening it. This `mod` line is the only non-test edit Phase 1 makes.
#[cfg(test)]
#[path = "gguf_characterization_tests.rs"]
mod gguf_characterization_tests;
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
//! CHARACTERIZATION — compute's one id boundary, snapshotted before PMAT-3430
//! Phases 3-4 re-export `trueno_quant::GgmlType` here.
//!
//! Written against UNCHANGED code; must pass UNCHANGED afterwards, with ONE
//! permitted edit named in advance by #3430 Q1-c: `GgmlType::from_u32(x)`
//! becomes the free function `from_u32(x)`, because an inherent method cannot
//! survive on a re-exported foreign type. The admitted SET may not change.
//!
//! Sizes are characterized here too. compute is the only one of the three
//! enums that carried block geometry, and it is the one the upstream extraction
//! agreed with exactly — so these 15 rows are the evidence that delegating to
//! `TRAITS` is value-identical rather than merely plausible.

use super::{from_u32, GgmlType};

/// The 15 ids compute admitted before M1, with their block geometry.
/// (id, blck_size, type_size)
const ADMITTED_TODAY: [(u32, usize, usize); 15] = [
(0, 1, 4),
(1, 1, 2),
(2, 32, 18),
(3, 32, 20),
(6, 32, 22),
(7, 32, 24),
(8, 32, 34),
(9, 32, 36),
(10, 256, 84),
(11, 256, 110),
(12, 256, 144),
(13, 256, 176),
(14, 256, 210),
(15, 256, 292),
(30, 1, 2),
];

fn admitted(id: u32) -> Option<(usize, usize)> {
ADMITTED_TODAY
.iter()
.find(|(candidate, _, _)| *candidate == id)
.map(|(_, blck, size)| (*blck, *size))
}

#[test]
fn from_u32_admits_exactly_fifteen_ids_over_the_whole_byte_range() {
for id in 0u32..=255 {
let got = from_u32(id);
assert_eq!(
got.is_some(),
admitted(id).is_some(),
"qtype {id}: compute's admitted set changed"
);
if let Some(t) = got {
assert_eq!(t as u32, id, "qtype {id} no longer round-trips through the enum");
}
}
}

#[test]
fn block_geometry_is_unchanged_for_every_admitted_id() {
for (id, blck, size) in ADMITTED_TODAY {
let t = from_u32(id).expect("an admitted id must construct");
assert_eq!(t.block_size(), blck, "qtype {id} block_size changed");
assert_eq!(t.block_bytes(), size, "qtype {id} block_bytes changed");
}
}

#[test]
fn tensor_bytes_rounds_partial_blocks_up_exactly_as_it_did() {
for (id, blck, size) in ADMITTED_TODAY {
let t = from_u32(id).expect("an admitted id must construct");
assert_eq!(t.tensor_bytes(0), 0, "qtype {id} at 0 elements");
assert_eq!(t.tensor_bytes(blck), size, "qtype {id} at one full block");
assert_eq!(t.tensor_bytes(blck * 4), size * 4, "qtype {id} at four blocks");
if blck > 1 {
assert_eq!(t.tensor_bytes(1), size, "qtype {id} rounds 1 element up to a block");
assert_eq!(
t.tensor_bytes(blck + 1),
size * 2,
"qtype {id} rounds a partial second block up"
);
}
}
}
6 changes: 3 additions & 3 deletions crates/aprender-compute/src/inference/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -541,7 +541,7 @@ fn load_f32_or_dequant_tensor(
.ok_or_else(|| TruenoError::InvalidInput(format!("Missing tensor data: {name}")))?;

match info.dtype {
GgmlType::F32 | GgmlType::F16 | GgmlType::Bf16 => {
GgmlType::F32 | GgmlType::F16 | GgmlType::BF16 => {
Ok(to_f32_from_any(data, info.dtype, expected_elements))
}
GgmlType::Q4K => {
Expand Down Expand Up @@ -606,7 +606,7 @@ fn load_weight_matrix(

match info.dtype {
GgmlType::Q4K => Ok(WeightMatrix::Q4K { data: data.to_vec(), rows: out_dim }),
GgmlType::F32 | GgmlType::F16 | GgmlType::Bf16 => {
GgmlType::F32 | GgmlType::F16 | GgmlType::BF16 => {
let f32_data = to_f32_from_any(data, info.dtype, n_elements);
Ok(WeightMatrix::F32 { data: f32_data, rows: out_dim })
}
Expand Down Expand Up @@ -721,7 +721,7 @@ fn to_f32_from_any(data: &[u8], dtype: GgmlType, n_elements: usize) -> Vec<f32>
})
.collect()
}
GgmlType::Bf16 => {
GgmlType::BF16 => {
let count = n_elements.min(data.len() / 2);
(0..count)
.map(|i| {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//! CHARACTERIZATION — `GgufTensor::byte_size` for all 12 of core's variants,
//! snapshotted before PMAT-3430 Phase 3 replaces its hand-written match with
//! delegation to `trueno_quant`'s upstream-extracted table.
//!
//! core has NO id or name boundary — nothing here turns an integer or a string
//! into a `GgmlType`; the enum is only ever built from literals. So core's
//! entire exposure to M1 is this one exhaustive match, which stops compiling
//! (E0004) the moment the enum gains variants, and must be replaced by
//! delegation rather than patched with a bare `_ =>` (#3430 Q1-c).
//!
//! EXACTLY ONE ROW IS EXPECTED TO CHANGE, and it is named here in advance:
//! `Q4_1` is sized at **18** bytes per 32-element block by the hand-written arm
//! (`Q4_0 | Q4_1 => …`), where upstream ggml says **20** (2 x f16 scale+min,
//! plus 16 nibble bytes) and core's OWN other size table, `format/gguf/shape.rs`,
//! already says 20. Two tables in one crate disagreeing. When delegation lands,
//! the `Q4_1` expectation below moves 54 -> 60 and that one-line diff IS the
//! record of the behaviour change. Every other row must be untouched.

use super::{GgmlType, GgufTensor};

fn tensor(dtype: GgmlType, elements: u64) -> GgufTensor {
GgufTensor {
name: "t".to_string(),
shape: vec![elements],
dtype,
data: vec![],
}
}

/// (dtype, elements, bytes) for every variant core carries, at a block-aligned
/// count and a deliberately non-aligned one.
#[test]
fn byte_size_is_unchanged_for_all_twelve_variants() {
let cases: [(GgmlType, u64, usize); 24] = [
(GgmlType::F32, 64, 256),
(GgmlType::F32, 65, 260),
(GgmlType::F16, 64, 128),
(GgmlType::F16, 65, 130),
// Q4_0: 2 (scale) + 16 (nibbles) = 18 bytes per 32 elements. Correct.
(GgmlType::Q4_0, 64, 36),
(GgmlType::Q4_0, 65, 54),
// Q4_1: THE ROW THAT MOVED, in Phase 3, and the only one. 2 blocks x 20
// and 3 blocks x 20, where the hand-written arm gave 18 per block.
// Upstream ggml and core's own shape.rs both say 20.
(GgmlType::Q4_1, 64, 40),
(GgmlType::Q4_1, 96, 60),
(GgmlType::Q8_0, 64, 68),
(GgmlType::Q8_0, 65, 102),
(GgmlType::Q4K, 512, 288),
(GgmlType::Q4K, 513, 432),
(GgmlType::Q6K, 256, 210),
(GgmlType::Q6K, 257, 420),
(GgmlType::I8, 64, 64),
(GgmlType::I8, 65, 65),
(GgmlType::I16, 64, 128),
(GgmlType::I16, 65, 130),
(GgmlType::I32, 64, 256),
(GgmlType::I32, 65, 260),
(GgmlType::I64, 64, 512),
(GgmlType::I64, 65, 520),
(GgmlType::F64, 64, 512),
(GgmlType::F64, 65, 520),
];
for (dtype, elements, expected) in cases {
assert_eq!(
tensor(dtype, elements).byte_size(),
expected,
"{dtype:?} at {elements} elements"
);
}
}
Loading
Loading