makepad/libs/ai/vulkan/tests/whisper_quant.rs
Admin 74faf62ef1 ai: the AI hub gives way to the UI, smaller models, vision and speech
The AI hub serves local models on the LAN and now yields GPU memory to the
UI (shrinks the chat context, then unloads; grows back after; never loads
into the UI reserve), groups models by type, and reports each model's
memory split. LLM decode on Vulkan with Splash kernels, one shared GPU
device per process, vision on chat turns, an on-device text detector,
music models and stems.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 12:26:21 +02:00

207 lines
9.9 KiB
Rust

//! Real-device block/tail checks and a standalone phone benchmark:
//! whisper_quant-* --ignored --nocapture --test-threads=1
use makepad_ai_vulkan::*;
use makepad_script::makepad_math::{f16_bits_to_f32, f32_to_f16_bits};
use std::time::Instant;
#[derive(Clone, Copy, Debug)]
enum Format { F16, Quant(BlockQuant) }
impl Format {
fn stride(self, k: usize) -> usize {
match self {
Self::F16 => k.div_ceil(8) * 16,
Self::Quant(q) => k.div_ceil(q.block_elements() as usize) * q.block_bytes() as usize,
}
}
fn weights(self, n: usize, k: usize) -> Vec<u8> {
let stride = self.stride(k);
let mut bytes = vec![0; n * stride];
match self {
Self::F16 => {
for r in 0..n {
for d in 0..k {
let at = r * stride + d * 2;
bytes[at..at + 2].copy_from_slice(&f32_to_f16_bits(value(r * k + d)).to_le_bytes());
}
}
}
Self::Quant(q) => {
let size = q.block_bytes() as usize;
for (i, block) in bytes[..n * stride].chunks_exact_mut(size).enumerate() {
// Exercise every code, signed and zero scales, and
// nonzero padding in partial rows. Adjacent blocks have
// distinct metadata and alternate word alignment.
for (j, byte) in block.iter_mut().enumerate() {
*byte = (i * 17 + j * 59) as u8;
}
let scale = match i % 7 { 0 => 0.0, 1 => -0.007, 2 => 0.00001, _ => 0.023 };
block[..2].copy_from_slice(&f32_to_f16_bits(scale).to_le_bytes());
if q != BlockQuant::Q8_0 {
block[2..4].copy_from_slice(&f32_to_f16_bits(-0.17 + (i % 3) as f32 * 0.2).to_le_bytes());
}
}
}
}
bytes
}
// Scalar byte decoder independent of the GPU's unaligned word loads.
fn weight(self, bytes: &[u8], k: usize, r: usize, d: usize) -> f32 {
let row = &bytes[r * self.stride(k)..];
let half = |b: &[u8]| f16_bits_to_f32(u16::from_le_bytes([b[0], b[1]]));
match self {
Self::F16 => half(&row[d * 2..]),
Self::Quant(q) => {
let b = &row[d / q.block_elements() as usize * q.block_bytes() as usize..];
if q == BlockQuant::Q8_0 { return half(b) * b[2 + d % 32] as i8 as f32; }
let i = d % 256;
let j = i / 32;
let (sc, mn) = if j < 4 { (b[4 + j] & 63, b[8 + j] & 63) }
else { ((b[8 + j] & 15) | ((b[j] >> 6) << 4),
(b[8 + j] >> 4) | ((b[4 + j] >> 6) << 4)) };
let codes = if q == BlockQuant::Q5K { 48 } else { 16 };
let lo = (b[codes + (i / 64) * 32 + i % 32] >> ((j % 2) * 4)) & 15;
let hi = if q == BlockQuant::Q5K { ((b[16 + i % 32] >> j) & 1) * 16 } else { 0 };
half(b) * sc as f32 * (lo + hi) as f32 - half(&b[2..]) * mn as f32
}
}
}
}
fn value(i: usize) -> f32 { ((i * 7919 % 1009) as f32 - 504.0) / 509.0 }
fn bytes(v: &[f32]) -> Vec<u8> { v.iter().flat_map(|v| v.to_le_bytes()).collect() }
fn run(device: &Device, library: &mut Library, format: Format, [n, k, m]: [u32; 3], bias: bool, benchmark: bool) {
let (nn, kk, mm) = (n as usize, k as usize, m as usize);
let weights = format.weights(nn, kk);
let x: Vec<_> = (0..mm * kk).map(|i| if (i / kk + (i % kk) / 32) % 11 == 7 { 0.0 } else { value(i) }).collect();
// Independent CPU implementation of the shared activation quantiser,
// including the final partial block of each row.
let mut reference_x = x.clone();
if matches!(format, Format::Quant(_)) {
for row in reference_x.chunks_mut(kk) {
for block in row.chunks_mut(32) {
let d = block.iter().map(|v| v.abs()).fold(0.0f32, f32::max) / 127.0;
let inv = if d == 0.0 { 0.0 } else { 1.0 / d };
for v in block { *v = (*v * inv).round() * d; }
}
}
}
let b: Vec<_> = (0..nn).map(|i| value(i + 7)).collect();
let packed = match format {
Format::Quant(q) => q.pack(&weights, n, k).unwrap(),
Format::F16 => std::borrow::Cow::Borrowed(weights.as_slice()),
};
let wb = device.buffer(packed.len() as u64, BufferKind::Device).unwrap();
let xb = device.buffer((x.len() * 4) as u64, BufferKind::Device).unwrap();
let bb = device.buffer((b.len() * 4) as u64, BufferKind::Device).unwrap();
let dst = device.buffer((nn * mm * 4 + 64) as u64, BufferKind::Device).unwrap();
device.upload(&wb, 0, &packed).unwrap();
device.upload(&xb, 0, &bytes(&x)).unwrap();
device.upload(&bb, 0, &bytes(&b)).unwrap();
device.upload(&dst, (nn * mm * 4) as u64, &[0x5a; 64]).unwrap();
// Retain scratch through submission and every timed replay.
let matmul = match format {
Format::Quant(q) => Some(QuantizedMatmul::new(device, library, n, k, m, q).unwrap()),
Format::F16 => None,
};
let scratch = matmul.as_ref().map(|g| device.buffer(g.scratch_bytes(), BufferKind::Device).unwrap());
let mut stream = device.stream(2).unwrap();
stream.begin().unwrap();
stream.mark("matmul");
match format {
Format::Quant(_) => {
let matmul = matmul.as_ref().unwrap();
assert_eq!(matmul.kernel.workgroup(), [if m <= 4 { 256 } else { 128 }, 1, 1]);
matmul.encode(&mut stream, &wb, &xb, &dst, bias.then_some(&bb), scratch.as_ref().unwrap()).unwrap();
}
Format::F16 if m > 4 => {
Gemm::new_nt(device, library, n, k, m, ActivationType::F16, (format.stride(kk) / 2) as u32)
.unwrap().encode_bias(&mut stream, &wb, &xb, &dst, bias.then_some(&bb)).unwrap();
}
Format::F16 => {
let kernel = library.get(device, "whisper_gemv_f16").unwrap();
let args = kernel.args().buffer("x", xb.address).buffer("x4", xb.address)
.buffer("w4", wb.address).buffer("dst", dst.address).buffer("bias", bb.address)
.u32("m", m).u32("n", n).u32("k", k).u32("ws", (format.stride(kk) / 2) as u32)
.u32("has_bias", u32::from(bias));
stream.dispatch(&args, [n.div_ceil(4), m, 1]);
}
}
stream.mark("end");
stream.submit_wait().unwrap();
stream.submit_wait().unwrap(); // Replay must overwrite bias and scratch.
let output = device.download(&dst, 0, nn * mm * 4 + 64).unwrap();
assert_eq!(&output[nn * mm * 4..], &[0x5a; 64], "output guard");
let got: Vec<_> = output[..nn * mm * 4].chunks_exact(4)
.map(|v| f32::from_le_bytes(v.try_into().unwrap())).collect();
assert!(got.iter().all(|v| v.is_finite()));
let indices: Vec<_> = if benchmark { vec![0, nn - 1, (mm - 1) * nn, nn * mm - 1] }
else { (0..nn * mm).collect() };
let mut worst = 0.0f64;
for at in indices {
let (row, col) = (at / nn, at % nn);
let mut expected = if bias { b[col] as f64 } else { 0.0 };
let mut magnitude = expected.abs();
for d in 0..kk {
let term = format.weight(&weights, kk, col, d) as f64 * reference_x[row * kk + d] as f64;
expected += term;
magnitude += term.abs();
}
let error = (got[at] as f64 - expected).abs();
worst = worst.max(error);
assert!(error <= 3e-5 + magnitude * 3e-6,
"{format:?} {n}x{k}x{m} bias={bias} [{row},{col}]: {} vs {expected}, error {error}", got[at]);
}
if benchmark {
for _ in 0..3 { stream.submit_wait().unwrap(); }
let samples: usize = std::env::var("MAKEPAD_WHISPER_QUANT_SAMPLES").ok()
.map(|v| v.parse().expect("sample count")).unwrap_or(11);
assert!(samples > 0);
let mut times = Vec::new();
let mut walls = Vec::new();
for _ in 0..samples {
let start = Instant::now();
stream.submit_wait().unwrap();
walls.push(start.elapsed().as_secs_f64() * 1e3);
times.push(stream.timings().unwrap()[0].1);
}
times.sort_by(f64::total_cmp);
walls.sort_by(f64::total_cmp);
let ms = times[times.len() / 2];
let wall = walls[walls.len() / 2];
println!("{format:?} [n,k,m]=[{n},{k},{m}] weights={} B median={ms:.4} ms wall={wall:.4} ms {:.2} GOPS max_sample_error={worst:.6}",
wb.size, 2.0 * n as f64 * k as f64 * m as f64 / (ms * 1e6));
}
}
#[test]
fn block_matrices_and_vectors_match_cpu() {
let device = Device::shared().expect("a real Vulkan device is required");
let mut library = Library::new(KERNEL_SOURCES).unwrap();
for format in [BlockQuant::Q8_0, BlockQuant::Q5K, BlockQuant::Q4K] {
for dims in [[1, 1, 1], [3, 7, 4], [7, 31, 2], [7, 32, 1], [9, 33, 4],
[65, 65, 1], [5, 1057, 4], [1, 1, 5], [7, 31, 5], [63, 32, 63],
[65, 33, 67], [69, 96, 71], [7, 1057, 5], [9, 256, 31], [65, 1280, 32], [7, 512, 33]] {
for bias in [false, true] { run(&device, &mut library, Format::Quant(format), dims, bias, false); }
}
}
}
#[test]
#[ignore = "encoder/decoder benchmark; run serially on the target GPU"]
fn whisper_quant_bench() {
let device = Device::shared().expect("a real Vulkan device is required");
println!("{}", device.features.description());
let mut library = Library::new(KERNEL_SOURCES).unwrap();
for format in [Format::F16, Format::Quant(BlockQuant::Q8_0), Format::Quant(BlockQuant::Q5K), Format::Quant(BlockQuant::Q4K)] {
for dims in [[1280, 1280, 1500], [5120, 1280, 1500], [1280, 5120, 1500]] {
run(&device, &mut library, format, dims, true, true);
}
for [n, k] in [[1280, 1280], [5120, 1280], [1280, 5120], [51866, 1280]] {
for m in [1, 4] { run(&device, &mut library, format, [n, k, m], true, true); }
}
}
}