All checks were successful
jouleclaw public surface must stay documented / Public-surface audit (jouleclaw-rs) (pull_request) Successful in 1m2s
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 6s
repository checks / lockfiles, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 46s
standards workspaces build + test / arl-rs (arl/arl-rs, arl-rs, linux) (pull_request) Successful in 1m16s
standards workspaces build + test / jcp-rs (jcp-conformance, jcp/jcp-rs, -p jcp-energy --features nvml, jcp-rs, linux) (pull_request) Successful in 1m53s
standards workspaces build + test / eoc-rs (eoc/eoc-rs, -p eoc-meter --features cuda, eoc-rs, linux) (pull_request) Successful in 2m9s
standards workspaces build + test / joule-ui-rs (joule-ui/joule-ui-rs, joule-ui-rs, linux) (pull_request) Successful in 1m14s
standards workspaces build + test / joulehook-rs (joulehook/joulehook-rs, joulehook-rs, linux) (pull_request) Successful in 1m11s
standards workspaces build + test / pool-rs (pool/pool-rs, pool-rs, linux) (pull_request) Successful in 1m8s
standards workspaces build + test / proof-rs (proof-conformance, proof/proof-rs, proof-rs, linux, true) (pull_request) Successful in 1m28s
standards workspaces build + test / sense-rs (sense/sense-rs, sense-rs, linux) (pull_request) Successful in 1m16s
standards workspaces build + test / sandbox-rs (sandbox-conformance, sandbox/sandbox-rs, sandbox-rs, linux) (pull_request) Successful in 2m27s
standards workspaces build + test / smart-byte-rs (smart-byte/smart-byte-rs, smart-byte-rs, linux) (pull_request) Successful in 4m10s
standards workspaces build + test / jouleclaw-rs (-nodebug, jouleclaw/jouleclaw-rs, -p jouleclaw-energy --features rapl,nvml, jouleclaw-rs, linux-arm64, -C target-feature=+fp16) (pull_request) Successful in 6m50s
standards workspaces build + test / joulecontract-rs (joulecontract/joulecontract-rs, joulecontract-rs, linux-arm64, true) (pull_request) Successful in 1m30s
standards workspaces build + test / map-rs (map-conformance, map/map-rs, map-rs, linux-arm64) (pull_request) Successful in 1m28s
standards workspaces build + test / aac-rs (aac-conformance, aac/aac-rs, aac-rs, linux-arm64) (pull_request) Successful in 5m17s
standards workspaces build + test / joule-code-rs (joule-code/joule-code-rs, joule-code-rs, linux-arm64, true) (pull_request) Successful in 5m10s
standards workspaces build + test / mesh-rs (mesh/mesh-rs, -p mesh-engine-gguf -p mesh-openai --features mesh-engine-gguf/gguf,mesh-openai/server, mesh-rs, linux-arm64) (pull_request) Successful in 2m24s
standards workspaces build + test / openpay (openpay, openpay, linux-arm64) (pull_request) Successful in 6m16s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 8m16s
standards workspaces build + test / jouledesk-rs (jouledesk/jouledesk-rs, jouledesk-rs, macos) (pull_request) Successful in 2m52s
12 -> 0, in both the default and `rayon fabric-meter` configurations, and the crate joins the `-D warnings` clippy step in standards-build.yml. The last dozen needed judgement rather than a script: - two `from_str` methods that are not FromStr-shaped (one returns Option, the other is infallible) renamed to `parse`. Both are file-local, so there is no caller churn -- except that my check for callers missed mlx.rs, which reaches SafetensorsDtype through a type ALIAS, so `SafetensorsDtype::from_str(` never matched. Renaming wants a grep for the method name across the workspace, not for the type names you happen to know. - `assert!(FLASH_KV_CHUNK.is_power_of_two())` and `>= 64` moved out of a `#[test]` into `const _: () = assert!(..)` beside the constant. These are properties of a constant; a test only holds them if someone runs it. - the `kv_quant_tests` module moved to the end of kv_cache_inplace.rs, because 300 lines of real items sat after it. - an example discarded the `Result` from `matmul_bf16_threaded`, so a failure would have been timed as a success. - `needless_range_loop` allowed per-file in three benches and one test: those are separate crates, so src/lib.rs's decision does not reach them. - checked_div for two guarded divisions, and one unused `mut`. Two near-misses worth recording: - `fabric_dispatch.rs` calls `ledger_record` twice with identical arguments, which reads exactly like a copy-paste bug. The comment three lines above says attention is "recorded as two GEMM-equivalents" so the measured µJ/GFLOP table prices it. Deliberate. Left alone. - clippy's `chunks_exact_mut` suggestion is `as_chunks_mut::<N>().0.iter()` -- a non-mutable iterator over a mutable chunk. It does not compile, and only the fabric-meter configuration contained one, so the default build stayed green while that example was broken. Checking both configurations is what caught it. The gate covers default features only. Under `rayon fabric-meter` this crate is also clean, but clippy reports jouleclaw-backend-apple through the path dependency, and that crate has 43 warnings -- 27 `unused_unsafe` and 15 `unused_imports` that are unused in THAT configuration and needed in others. Those want cfg work, not deletion, which is the trap #373 already paid for once. Verified: four lint specs pass, 343 loader-gguf tests, 77 omni tests. Co-Authored-By: Claude Opus 5 <[email protected]>
258 lines
9.4 KiB
Rust
258 lines
9.4 KiB
Rust
//! Tests for quantized dequantization. Each format is verified with
|
|
//! hand-constructed blocks where the expected output is computable from
|
|
//! the formula.
|
|
|
|
// Benches and integration tests are their own crates, so the decision
|
|
// recorded in src/lib.rs does not reach them: these loops fill arrays by
|
|
// index, which is what the index is for.
|
|
#![allow(clippy::needless_range_loop)]
|
|
|
|
use jouleclaw_loader_gguf::dequant::{dequantize_q4_k, dequantize_q5_k, dequantize_q8_0};
|
|
|
|
/// Encode an f32 as IEEE 754 binary16 little-endian. Just inverse of the
|
|
/// loader's f16->f32 we already have, used here only for test-block
|
|
/// construction. We pick exactly representable values so rounding doesn't
|
|
/// confuse anything.
|
|
fn f32_to_f16_bytes(v: f32) -> [u8; 2] {
|
|
// Implementation only needs to round-trip the values we use in tests:
|
|
// 0.0, 1.0, 2.0, 0.5, 0.25.
|
|
let bits = v.to_bits();
|
|
let sign = (bits >> 31) & 0x1;
|
|
let exp = ((bits >> 23) & 0xff) as i32;
|
|
let mant = bits & 0x7fffff;
|
|
|
|
let h_bits: u16 = if v == 0.0 {
|
|
(sign as u16) << 15
|
|
} else {
|
|
let h_exp = exp - 127 + 15;
|
|
if !(1..=30).contains(&h_exp) {
|
|
panic!("test helper does not support {} (out of normal F16 range)", v);
|
|
}
|
|
let h_mant = (mant >> 13) as u16;
|
|
((sign as u16) << 15) | ((h_exp as u16) << 10) | h_mant
|
|
};
|
|
h_bits.to_le_bytes()
|
|
}
|
|
|
|
// =====================================================================
|
|
// Q8_0
|
|
// =====================================================================
|
|
|
|
#[test]
|
|
fn q8_0_zero_scale_yields_all_zeros() {
|
|
// d = 0, all q = anything → all output should be 0.
|
|
let mut block = vec![0u8; 34];
|
|
block[0..2].copy_from_slice(&f32_to_f16_bytes(0.0));
|
|
for i in 0..32 { block[2 + i] = (i as i8 + 1) as u8; }
|
|
|
|
let out = dequantize_q8_0(&block, 32).unwrap();
|
|
assert_eq!(out, vec![0.0; 32]);
|
|
}
|
|
|
|
#[test]
|
|
fn q8_0_unit_scale_returns_quants_as_floats() {
|
|
// d = 1.0, q = [-5, -4, ..., 26]. Output should equal q.
|
|
let mut block = vec![0u8; 34];
|
|
block[0..2].copy_from_slice(&f32_to_f16_bytes(1.0));
|
|
for i in 0..32 {
|
|
let q = (i as i32 - 5) as i8;
|
|
block[2 + i] = q as u8;
|
|
}
|
|
let out = dequantize_q8_0(&block, 32).unwrap();
|
|
let expected: Vec<f32> = (0..32).map(|i| (i - 5) as f32).collect();
|
|
assert_eq!(out, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn q8_0_scale_two_doubles_quants() {
|
|
// d = 2.0, q = 1..=32. Output = 2,4,6,...,64.
|
|
let mut block = vec![0u8; 34];
|
|
block[0..2].copy_from_slice(&f32_to_f16_bytes(2.0));
|
|
for i in 0..32 { block[2 + i] = (i as i8 + 1) as u8; }
|
|
let out = dequantize_q8_0(&block, 32).unwrap();
|
|
for i in 0..32 {
|
|
assert_eq!(out[i], 2.0 * (i as f32 + 1.0));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn q8_0_multiple_blocks() {
|
|
// Two blocks: first with d=1.0 and q=1..32, second with d=2.0 and q=1..32.
|
|
let mut block = vec![0u8; 68];
|
|
block[0..2].copy_from_slice(&f32_to_f16_bytes(1.0));
|
|
for i in 0..32 { block[2 + i] = (i as i8 + 1) as u8; }
|
|
block[34..36].copy_from_slice(&f32_to_f16_bytes(2.0));
|
|
for i in 0..32 { block[36 + i] = (i as i8 + 1) as u8; }
|
|
|
|
let out = dequantize_q8_0(&block, 64).unwrap();
|
|
for i in 0..32 {
|
|
assert_eq!(out[i], (i + 1) as f32);
|
|
assert_eq!(out[32 + i], 2.0 * (i + 1) as f32);
|
|
}
|
|
}
|
|
|
|
// =====================================================================
|
|
// Q4_K
|
|
// =====================================================================
|
|
|
|
/// Build a Q4_K block with given d, dmin, all sub-block scales = `sc`,
|
|
/// all sub-block mins = `mn`, all 4-bit quants = `q_value` (0..=15).
|
|
fn build_q4_k_block(d: f32, dmin: f32, sc: u8, mn: u8, q_value: u8) -> Vec<u8> {
|
|
let mut block = vec![0u8; 144];
|
|
block[0..2].copy_from_slice(&f32_to_f16_bytes(d));
|
|
block[2..4].copy_from_slice(&f32_to_f16_bytes(dmin));
|
|
|
|
// Pack 12-byte scales array so all scale_j = sc and all min_j = mn for j in 0..8.
|
|
// Layout (verified against ggml's get_scale_min_k4):
|
|
// For j in 0..4:
|
|
// scales[j] bits 0-5 = scale_j, bits 6-7 = (scale_{j+4} >> 4) & 0x3
|
|
// scales[j + 4] bits 0-5 = min_j, bits 6-7 = (min_{j+4} >> 4) & 0x3
|
|
// For j in 4..8:
|
|
// scales[j + 4] bits 0-3 = scale_j & 0xF, bits 4-7 = min_j & 0xF
|
|
assert!(sc < 64 && mn < 64, "6-bit values only");
|
|
|
|
let scales_high_2 = (sc >> 4) & 0x3;
|
|
let mins_high_2 = (mn >> 4) & 0x3;
|
|
|
|
for j in 0..4 {
|
|
block[4 + j] = (sc & 0x3F) | (scales_high_2 << 6);
|
|
block[4 + j + 4] = (mn & 0x3F) | (mins_high_2 << 6);
|
|
}
|
|
for j in 4..8 {
|
|
// bytes 8..11 = scales[j+4]; we want (sc & 0xF) in low nibble, (mn & 0xF) in high nibble
|
|
block[4 + j + 4] = (sc & 0x0F) | ((mn & 0x0F) << 4);
|
|
}
|
|
|
|
// Quantized data: 128 bytes; both nibbles = q_value.
|
|
let packed = (q_value & 0x0F) | ((q_value & 0x0F) << 4);
|
|
for i in 0..128 { block[16 + i] = packed; }
|
|
|
|
block
|
|
}
|
|
|
|
#[test]
|
|
fn q4_k_zero_scales_yield_zero_minus_zero() {
|
|
// d=0, dmin=0 → all output = 0 regardless of q.
|
|
let block = build_q4_k_block(0.0, 0.0, 5, 3, 12);
|
|
let out = dequantize_q4_k(&block, 256).unwrap();
|
|
assert_eq!(out, vec![0.0; 256]);
|
|
}
|
|
|
|
#[test]
|
|
fn q4_k_unit_d_with_zero_min_returns_sc_times_q() {
|
|
// d=1, dmin=0, all sc=2, all q=3 → output = 1*2*3 - 0*m = 6.
|
|
let block = build_q4_k_block(1.0, 0.0, 2, 7, 3);
|
|
let out = dequantize_q4_k(&block, 256).unwrap();
|
|
for v in &out { assert!((v - 6.0).abs() < 1e-6, "expected 6, got {}", v); }
|
|
}
|
|
|
|
#[test]
|
|
fn q4_k_min_subtraction_works() {
|
|
// d=0, dmin=1, all sc=anything, all m=4 → output = 0 - 1*4 = -4.
|
|
let block = build_q4_k_block(0.0, 1.0, 7, 4, 9);
|
|
let out = dequantize_q4_k(&block, 256).unwrap();
|
|
for v in &out { assert!((v + 4.0).abs() < 1e-6, "expected -4, got {}", v); }
|
|
}
|
|
|
|
#[test]
|
|
fn q4_k_general_formula() {
|
|
// d=2.0, dmin=0.5, all sc=3, all m=2, all q=10.
|
|
// Output = 2.0 * 3 * 10 - 0.5 * 2 = 60 - 1 = 59.
|
|
let block = build_q4_k_block(2.0, 0.5, 3, 2, 10);
|
|
let out = dequantize_q4_k(&block, 256).unwrap();
|
|
for v in &out { assert!((v - 59.0).abs() < 1e-4, "expected 59, got {}", v); }
|
|
}
|
|
|
|
#[test]
|
|
fn q4_k_with_high_6bit_values_packs_correctly() {
|
|
// sc=63, mn=63 (max 6-bit values). Tests that the high-2-bit packing
|
|
// for sub-blocks 4..7 works correctly.
|
|
let block = build_q4_k_block(1.0, 0.0, 63, 0, 1);
|
|
let out = dequantize_q4_k(&block, 256).unwrap();
|
|
// Output = 1.0 * 63 * 1 - 0 = 63 for every element across all 8 sub-blocks.
|
|
for (i, v) in out.iter().enumerate() {
|
|
assert!((v - 63.0).abs() < 1e-4,
|
|
"elem {}: expected 63, got {}", i, v);
|
|
}
|
|
}
|
|
|
|
// =====================================================================
|
|
// Q5_K
|
|
// =====================================================================
|
|
|
|
/// Build a Q5_K block with given d, dmin, sc, mn, and a single low-4-bit
|
|
/// q_lo value plus a single high-bit value (0 or 1).
|
|
fn build_q5_k_block(d: f32, dmin: f32, sc: u8, mn: u8, q_lo: u8, q_hi: u8) -> Vec<u8> {
|
|
let mut block = vec![0u8; 176];
|
|
block[0..2].copy_from_slice(&f32_to_f16_bytes(d));
|
|
block[2..4].copy_from_slice(&f32_to_f16_bytes(dmin));
|
|
|
|
let scales_high_2 = (sc >> 4) & 0x3;
|
|
let mins_high_2 = (mn >> 4) & 0x3;
|
|
for j in 0..4 {
|
|
block[4 + j] = (sc & 0x3F) | (scales_high_2 << 6);
|
|
block[4 + j + 4] = (mn & 0x3F) | (mins_high_2 << 6);
|
|
}
|
|
for j in 4..8 {
|
|
block[4 + j + 4] = (sc & 0x0F) | ((mn & 0x0F) << 4);
|
|
}
|
|
|
|
// qh: 32 bytes. We want every output element's high bit = q_hi.
|
|
// qh[k % 32] >> (k / 32) & 1 = q_hi for all k in 0..256.
|
|
// If q_hi = 0, all qh bytes = 0. If q_hi = 1, all qh bytes = 0xFF.
|
|
let qh_byte = if q_hi & 1 != 0 { 0xFF } else { 0x00 };
|
|
for i in 0..32 { block[16 + i] = qh_byte; }
|
|
|
|
// qs: 128 bytes. Both nibbles = q_lo.
|
|
let packed = (q_lo & 0x0F) | ((q_lo & 0x0F) << 4);
|
|
for i in 0..128 { block[48 + i] = packed; }
|
|
|
|
block
|
|
}
|
|
|
|
#[test]
|
|
fn q5_k_low_bits_only_match_q4_k_formula() {
|
|
// q_hi=0 → effective q = q_lo (0..=15). With d=1, dmin=0, sc=1, mn=0, q_lo=7:
|
|
// output = 1 * 1 * 7 - 0*0 = 7.
|
|
let block = build_q5_k_block(1.0, 0.0, 1, 0, 7, 0);
|
|
let out = dequantize_q5_k(&block, 256).unwrap();
|
|
for v in &out { assert!((v - 7.0).abs() < 1e-6, "got {}", v); }
|
|
}
|
|
|
|
#[test]
|
|
fn q5_k_high_bit_extends_quant_to_5_bits() {
|
|
// q_hi=1, q_lo=3 → effective q = (1<<4) | 3 = 19. With d=1, sc=1, dmin=0:
|
|
// output = 1*1*19 - 0 = 19.
|
|
let block = build_q5_k_block(1.0, 0.0, 1, 0, 3, 1);
|
|
let out = dequantize_q5_k(&block, 256).unwrap();
|
|
for v in &out { assert!((v - 19.0).abs() < 1e-6, "expected 19, got {}", v); }
|
|
}
|
|
|
|
#[test]
|
|
fn q5_k_max_quant_value() {
|
|
// q_lo=15, q_hi=1 → effective q = 31 (max for 5-bit unsigned).
|
|
// d=2, sc=1, dmin=0 → output = 2*1*31 = 62.
|
|
let block = build_q5_k_block(2.0, 0.0, 1, 0, 15, 1);
|
|
let out = dequantize_q5_k(&block, 256).unwrap();
|
|
for v in &out { assert!((v - 62.0).abs() < 1e-4, "got {}", v); }
|
|
}
|
|
|
|
// =====================================================================
|
|
// Error handling
|
|
// =====================================================================
|
|
|
|
#[test]
|
|
fn rejects_misaligned_element_count() {
|
|
let block = vec![0u8; 144];
|
|
assert!(dequantize_q4_k(&block, 100).is_err(),
|
|
"100 is not a multiple of 256");
|
|
assert!(dequantize_q5_k(&block, 100).is_err());
|
|
assert!(dequantize_q8_0(&block, 31).is_err(),
|
|
"31 is not a multiple of 32");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_buffer_too_small() {
|
|
let block = vec![0u8; 50]; // way too small for a Q4_K block
|
|
assert!(dequantize_q4_k(&block, 256).is_err());
|
|
}
|