use anyhow::{Context, Result, ensure};
use crate::backend::cpu;
use crate::gguf::GgufFile;
use crate::kv_cache::{InferenceState, LayerState};
use crate::tensor::DType;
#[doc(hidden)]
pub mod oracle_dump {
use std::cell::RefCell;
thread_local! {
static SINK: RefCell<Option<Vec<(String, f64)>>> = const { RefCell::new(None) };
}
pub fn begin() {
SINK.with(|s| *s.borrow_mut() = Some(Vec::new()));
}
pub fn take() -> Vec<(String, f64)> {
SINK.with(|s| s.borrow_mut().take().unwrap_or_default())
}
#[inline]
pub fn is_active() -> bool {
SINK.with(|s| s.borrow().is_some())
}
#[inline]
pub(crate) fn record(name: &str, data: &[f32]) {
SINK.with(|s| {
if let Some(buf) = s.borrow_mut().as_mut() {
buf.push((name.to_string(), data.iter().map(|&x| x as f64).sum()));
}
});
}
}
#[cfg(all(any(target_arch = "x86_64", target_arch = "aarch64"), not(has_blas)))]
#[derive(Clone)]
#[allow(dead_code)]
pub enum Repacked {
Q40 {
packed: Vec<u8>,
scales: Vec<f32>,
},
Q4K {
packed: Vec<u8>,
dsc: Vec<f32>,
dmn: Vec<f32>,
},
}
#[cfg(all(any(target_arch = "x86_64", target_arch = "aarch64"), not(has_blas)))]
#[derive(Clone)]
pub struct RepackedWeight {
pub kind: Repacked,
pub m: usize,
pub k: usize,
}
#[cfg(all(any(target_arch = "x86_64", target_arch = "aarch64"), not(has_blas)))]
impl std::fmt::Debug for RepackedWeight {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let (tag, packed_len) = match &self.kind {
Repacked::Q40 { packed, .. } => ("Q4_0", packed.len()),
Repacked::Q4K { packed, .. } => ("Q4_K", packed.len()),
};
f.debug_struct("RepackedWeight")
.field("kind", &tag)
.field("m", &self.m)
.field("k", &self.k)
.field("packed_len", &packed_len)
.finish()
}
}
#[derive(Debug, Clone)]
pub struct WeightRef {
pub start: u64,
pub size: usize,
pub dtype: DType,
pub m: usize,
pub k: usize,
#[cfg(all(any(target_arch = "x86_64", target_arch = "aarch64"), not(has_blas)))]
pub repacked: Option<std::sync::Arc<RepackedWeight>>,
#[cfg(has_blas)]
pub cached_f32: std::sync::Arc<std::sync::OnceLock<Vec<f32>>>,
#[cfg(has_blas)]
pub cached_f32_transposed: std::sync::Arc<std::sync::OnceLock<Vec<f32>>>,
}
impl WeightRef {
pub fn new(start: u64, size: usize, dtype: DType, m: usize, k: usize) -> Self {
Self {
start,
size,
dtype,
m,
k,
#[cfg(all(any(target_arch = "x86_64", target_arch = "aarch64"), not(has_blas)))]
repacked: None,
#[cfg(has_blas)]
cached_f32: std::sync::Arc::new(std::sync::OnceLock::new()),
#[cfg(has_blas)]
cached_f32_transposed: std::sync::Arc::new(std::sync::OnceLock::new()),
}
}
#[allow(unused_mut)]
pub(crate) fn with_repack(mut self, _gguf: &GgufFile) -> Self {
#[cfg(all(any(target_arch = "x86_64", target_arch = "aarch64"), not(has_blas)))]
{
let gguf = _gguf;
let mut kind = None;
if self.dtype == DType::Q4_0 && cpu::q4_0_repack_supported(self.m, self.k) {
let (packed, scales) =
cpu::repack_q4_0_8x8(weight_data(gguf, &self), self.m, self.k);
kind = Some(Repacked::Q40 { packed, scales });
}
#[cfg(target_arch = "x86_64")]
{
if kind.is_none()
&& self.dtype == DType::Q4KM
&& cpu::q4_k_repack_supported(self.m, self.k)
{
let (packed, dsc, dmn) =
cpu::repack_q4_k_8x8(weight_data(gguf, &self), self.m, self.k);
kind = Some(Repacked::Q4K { packed, dsc, dmn });
}
}
self.repacked = kind.map(|k| {
std::sync::Arc::new(RepackedWeight {
kind: k,
m: self.m,
k: self.k,
})
});
}
self
}
}
pub fn resolve_weight(gguf: &GgufFile, name: &str) -> Result<WeightRef> {
let info = gguf
.tensors
.get(name)
.with_context(|| format!("tensor not found: {name}"))?;
let start = info.offset;
ensure!(
info.size_bytes > 0,
"tensor {name} has unsupported GGML type {} ({}) — cera cannot run this file",
info.ggml_type_id,
crate::gguf::ggml_type_name(info.ggml_type_id)
);
let size = info.size_bytes;
let dtype = info.dtype;
let k = info.shape.first().copied().unwrap_or(1);
let m = if info.shape.len() > 1 {
info.shape[1]
} else {
1
};
Ok(WeightRef::new(start, size, dtype, m, k))
}
pub(crate) fn resolve_expert_weight(
gguf: &GgufFile,
name: &str,
expert: usize,
) -> Result<WeightRef> {
let (start, size, m, k, dtype) = gguf.tensor_meta_expert(name, expert)?;
Ok(WeightRef::new(start as u64, size, dtype, m, k))
}
#[inline]
pub(crate) fn weight_data<'a>(gguf: &'a GgufFile, wref: &WeightRef) -> &'a [u8] {
let start = usize::try_from(wref.start).expect("weight offset fits in usize for CPU execution");
&gguf.mmap_data()[start..start + wref.size]
}
#[cfg(test)]
#[cfg(any(target_arch = "aarch64", target_arch = "x86_64", has_blas))]
mod gate_tests {
use super::*;
#[test]
fn k_quant_batched_gemm_requires_whole_superblocks() {
for k in [32usize, 128, 255, 257, 384] {
assert!(
!batched_gemm_supports(DType::Q4KM, k),
"Q4KM admitted k={k}, which is not a multiple of 256"
);
assert!(
!batched_gemm_supports(DType::Q6K, k),
"Q6K admitted k={k}, which is not a multiple of 256"
);
}
let expect = k_quant_gemm_available();
for k in [256usize, 512, 2048] {
assert_eq!(
batched_gemm_supports(DType::Q4KM, k),
expect,
"Q4KM at aligned k={k} disagrees with k_quant_gemm_available()"
);
}
}
#[test]
fn unsupported_dtypes_are_never_batched() {
for dtype in [DType::F16, DType::F32, DType::BF16, DType::I32, DType::U8] {
assert!(
!batched_gemm_supports(dtype, 256),
"{dtype:?} was admitted to the batched path with no kernel to run it"
);
}
}
#[test]
fn q5_k_is_batched_exactly_under_blas() {
for k in [256usize, 512, 2048] {
assert_eq!(
batched_gemm_supports(DType::Q5KM, k),
cfg!(has_blas),
"Q5_K at k={k} must track the `blas` feature exactly"
);
}
for k in [32usize, 96, 128, 255, 257, 384] {
assert!(
!batched_gemm_supports(DType::Q5KM, k),
"Q5_K admitted at k={k}, which is not a multiple of its 256-wide superblock"
);
}
}
#[cfg(has_blas)]
const ALL_DTYPES: [DType; 11] = [
DType::F32,
DType::F16,
DType::BF16,
DType::I32,
DType::U8,
DType::Q4_0,
DType::Q4_1,
DType::Q4KM,
DType::Q5KM,
DType::Q8_0,
DType::Q6K,
];
#[cfg(has_blas)]
fn all_dtypes_is_exhaustive(d: DType) -> bool {
match d {
DType::F32
| DType::F16
| DType::BF16
| DType::I32
| DType::U8
| DType::Q4_0
| DType::Q4_1
| DType::Q4KM
| DType::Q5KM
| DType::Q8_0
| DType::Q6K => true,
}
}
#[cfg(has_blas)]
#[test]
fn blas_gate_agrees_with_dequantizer_table() {
assert!(
ALL_DTYPES.iter().copied().all(all_dtypes_is_exhaustive),
"ALL_DTYPES holds a variant the exhaustive match does not"
);
for dtype in ALL_DTYPES {
if batched_gemm_supports(dtype, 256) {
assert!(
blas_dequantizer(dtype).is_some(),
"{dtype:?} is admitted to the batched path but has no BLAS \
dequantizer, so the GEMM would be silently skipped"
);
}
}
}
#[test]
fn q4_1_is_batched_when_the_int8_path_is_available() {
let expect = k_quant_gemm_available();
for k in [32usize, 96, 256, 2048] {
assert_eq!(
batched_gemm_supports(DType::Q4_1, k),
expect,
"Q4_1 at k={k} disagrees with k_quant_gemm_available()"
);
}
}
}
pub(crate) fn gemv(gguf: &GgufFile, wref: &WeightRef, x: &[f32], y: &mut [f32]) {
let data = weight_data(gguf, wref);
cpu::gemv_dispatch(wref.dtype, data, x, y, wref.m, wref.k, None);
}
#[cfg(target_arch = "aarch64")]
pub(crate) fn gemv_preq(
gguf: &GgufFile,
wref: &WeightRef,
x_f32: &[f32],
q8s: &[f32],
q8q: &[i8],
y: &mut [f32],
) {
let data = weight_data(gguf, wref);
cpu::gemv_with_preq(wref.dtype, data, q8s, q8q, x_f32, y, wref.m, wref.k);
}
#[cfg(target_arch = "aarch64")]
#[allow(dead_code)]
pub(crate) fn gemv_preq_argmax(
gguf: &GgufFile,
wref: &WeightRef,
x_f32: &[f32],
q8s: &[f32],
q8q: &[i8],
) -> usize {
let data = weight_data(gguf, wref);
cpu::gemv_with_preq_argmax(wref.dtype, data, q8s, q8q, x_f32, wref.m, wref.k)
}
#[cfg(target_arch = "aarch64")]
pub(crate) fn quantize_to_scratch_bufs(
x: &[f32],
q8_scales: &mut Vec<f32>,
q8_quants: &mut Vec<i8>,
) {
assert_eq!(
x.len() % 32,
0,
"quantize_to_scratch: x.len() must be divisible by 32"
);
let nb = x.len() / 32;
q8_scales.resize(nb, 0.0);
q8_quants.resize(x.len(), 0);
unsafe {
crate::backend::simd::neon::quantize_f32_to_q8_0_neon(x, q8_scales, q8_quants);
}
}
#[cfg(target_arch = "aarch64")]
pub(crate) fn quantize_to_scratch(x: &[f32], state: &mut InferenceState) {
quantize_to_scratch_bufs(
x,
&mut state.scratch.q8_scales,
&mut state.scratch.q8_quants,
);
}
#[cfg(any(target_arch = "aarch64", target_arch = "x86_64", has_blas))]
#[doc(hidden)]
pub fn batched_gemm_supports(dtype: DType, k: usize) -> bool {
match dtype {
DType::Q4_0 | DType::Q8_0 => cfg!(has_blas) || crate::backend::cpu::int8_gemm_available(),
DType::Q4_1 => k_quant_gemm_available(),
DType::Q4KM | DType::Q6K => k_quant_gemm_available() && k.is_multiple_of(256),
DType::Q5KM => cfg!(has_blas) && k.is_multiple_of(256),
_ => false,
}
}
#[cfg(any(target_arch = "aarch64", target_arch = "x86_64", has_blas))]
fn k_quant_gemm_available() -> bool {
#[cfg(has_blas)]
{
true
}
#[cfg(all(not(has_blas), target_arch = "aarch64"))]
{
crate::backend::simd::neon::k_quant_gemm_available()
}
#[cfg(all(not(has_blas), target_arch = "x86_64"))]
{
crate::backend::cpu::int8_gemm_available()
}
#[cfg(all(
not(has_blas),
not(target_arch = "aarch64"),
not(target_arch = "x86_64")
))]
{
false
}
}
#[cfg(any(target_arch = "aarch64", target_arch = "x86_64", has_blas))]
pub(crate) fn warn_unbatchable(tensor: &str, dtype: DType) {
use std::sync::Mutex;
static SEEN: Mutex<Vec<DType>> = Mutex::new(Vec::new());
let mut guard = match SEEN.lock() {
Ok(g) => g,
Err(p) => p.into_inner(), };
if !guard.contains(&dtype) {
guard.push(dtype);
tracing::warn!(
"prefill fell back to the per-token path: `{tensor}` is {dtype:?}, which is \
not supported on the batched path for this model. Prefill will be several \
times slower than it should be."
);
}
}
#[cfg(has_blas)]
pub(crate) type MatrixDequantizer = fn(&[u8], usize, usize, &mut [f32]);
#[cfg(has_blas)]
pub(crate) fn blas_dequantizer(dtype: DType) -> Option<MatrixDequantizer> {
match dtype {
DType::Q4_0 => Some(crate::quant::dequantize_q4_0_matrix),
DType::Q4_1 => Some(crate::quant::dequantize_q4_1_matrix),
DType::Q8_0 => Some(crate::quant::dequantize_q8_0_matrix),
DType::Q4KM => Some(crate::quant::dequantize_q4_k_m_matrix),
DType::Q5KM => Some(crate::quant::dequantize_q5_k_matrix),
DType::Q6K => Some(crate::quant::dequantize_q6_k_matrix),
DType::F32 | DType::F16 | DType::BF16 | DType::I32 | DType::U8 => None,
}
}
#[cfg(has_blas)]
fn blas_cache_weights_enabled() -> bool {
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ENABLED.get_or_init(|| std::env::var("CERA_BLAS_CACHE_WEIGHTS").as_deref() == Ok("1"))
}
#[cfg(has_blas)]
#[allow(clippy::too_many_arguments)]
pub(crate) fn try_blas_prefill_gemm(
gguf: &GgufFile,
wref: &WeightRef,
b: &[f32],
out: &mut [f32],
m: usize,
n: usize,
k: usize,
dequant_scratch: &mut Vec<f32>,
) -> bool {
debug_assert_eq!(wref.m, m, "try_blas_prefill_gemm: weight m mismatch");
debug_assert_eq!(wref.k, k, "try_blas_prefill_gemm: weight k mismatch");
let should_cache = blas_cache_weights_enabled();
if should_cache {
let dequant = wref.cached_f32.get_or_init(|| {
let data = weight_data(gguf, wref);
if let Some(dequantize) = blas_dequantizer(wref.dtype) {
let mut buf = vec![0.0f32; m * k];
dequantize(data, m, k, &mut buf);
buf
} else {
Vec::new()
}
});
if dequant.is_empty() {
report_uncomputed_gemm("try_blas_prefill_gemm", wref.dtype, k);
return false;
}
crate::backend::blas::sgemm_rowmajor_nn(m, n, k, dequant, b, out);
return true;
}
let data = weight_data(gguf, wref);
if let Some(dequantize) = blas_dequantizer(wref.dtype) {
if dequant_scratch.len() < m * k {
dequant_scratch.resize(m * k, 0.0);
}
let row_buf = &mut dequant_scratch[..m * k];
dequantize(data, m, k, row_buf);
crate::backend::blas::sgemm_rowmajor_nn(m, n, k, row_buf, b, out);
true
} else {
report_uncomputed_gemm("try_blas_prefill_gemm", wref.dtype, k);
false
}
}
#[cfg(has_blas)]
pub(crate) fn dequantize_weight_transposed(gguf: &GgufFile, wref: &WeightRef) -> Vec<f32> {
let (m, k) = (wref.m, wref.k);
let data = weight_data(gguf, wref);
if let Some(dequantize) = blas_dequantizer(wref.dtype) {
let mut row_buf = vec![0.0f32; m * k];
dequantize(data, m, k, &mut row_buf);
let mut col_buf = vec![0.0f32; k * m];
for r in 0..m {
for c in 0..k {
col_buf[c * m + r] = row_buf[r * k + c];
}
}
col_buf
} else {
Vec::new()
}
}
#[cfg(has_blas)]
pub(crate) fn get_dequantized_f32<'a>(gguf: &GgufFile, wref: &'a WeightRef) -> &'a [f32] {
wref.cached_f32_transposed
.get_or_init(|| dequantize_weight_transposed(gguf, wref))
}
#[cfg(has_blas)]
#[allow(clippy::too_many_arguments)]
pub(crate) fn try_blas_prefill_gemm_rowmajor(
gguf: &GgufFile,
wref: &WeightRef,
b: &[f32],
out: &mut [f32],
n: usize,
m: usize,
k: usize,
dequant_scratch: &mut Vec<f32>,
) -> bool {
debug_assert_eq!(
wref.m, m,
"try_blas_prefill_gemm_rowmajor: weight m mismatch"
);
debug_assert_eq!(
wref.k, k,
"try_blas_prefill_gemm_rowmajor: weight k mismatch"
);
let should_cache = blas_cache_weights_enabled();
if should_cache {
let dequant = get_dequantized_f32(gguf, wref);
if dequant.is_empty() {
report_uncomputed_gemm("try_blas_prefill_gemm_rowmajor", wref.dtype, k);
return false;
}
crate::backend::blas::sgemm_rowmajor_nn_parallel(n, m, k, b, dequant, out);
return true;
}
let data = weight_data(gguf, wref);
if let Some(dequantize) = blas_dequantizer(wref.dtype) {
if dequant_scratch.len() < m * k {
dequant_scratch.resize(m * k, 0.0);
}
let row_buf = &mut dequant_scratch[..m * k];
dequantize(data, m, k, row_buf);
crate::backend::blas::sgemm_rowmajor_nt_parallel(n, m, k, b, row_buf, out);
true
} else {
report_uncomputed_gemm("try_blas_prefill_gemm_rowmajor", wref.dtype, k);
false
}
}
#[cfg(all(any(target_arch = "x86_64", target_arch = "aarch64"), not(has_blas)))]
#[allow(clippy::too_many_arguments)]
pub(crate) fn try_repacked_gemm_rowmajor(
wref: &WeightRef,
b_scales: &[f32],
b_quants: &[i8],
out: &mut [f32],
n: usize,
m: usize,
k: usize,
) -> bool {
debug_assert_eq!(wref.m, m, "try_repacked_gemm_rowmajor: weight m mismatch");
debug_assert_eq!(wref.k, k, "try_repacked_gemm_rowmajor: weight k mismatch");
if let Some(rp) = &wref.repacked {
let ran = match &rp.kind {
Repacked::Q40 { packed, scales } => cpu::gemm_preq_repacked_q4_0_rowmajor_dispatch(
packed, scales, b_scales, b_quants, out, n, m, k,
),
_ => false,
};
if ran {
return true;
}
}
false
}
#[cfg(all(any(target_arch = "x86_64", target_arch = "aarch64"), not(has_blas)))]
#[allow(clippy::too_many_arguments)]
pub(crate) fn gemm_preq_rowmajor(
gguf: &GgufFile,
wref: &WeightRef,
b_scales: &[f32],
b_quants: &[i8],
out: &mut [f32],
n: usize,
m: usize,
k: usize,
) -> bool {
if try_repacked_gemm_rowmajor(wref, b_scales, b_quants, out, n, m, k) {
return true;
}
let mut col_out = vec![0.0f32; m * n];
if gemm_preq(gguf, wref, b_scales, b_quants, &mut col_out, m, n, k) {
gemm_out_to_rows(&col_out, m, n, m, out);
true
} else {
false
}
}
#[cfg(all(any(target_arch = "x86_64", target_arch = "aarch64"), not(has_blas)))]
#[allow(dead_code)]
#[allow(clippy::too_many_arguments)]
pub(crate) fn try_repacked_gate_up_silu_rowmajor(
gate: &WeightRef,
up: &WeightRef,
b_scales: &[f32],
b_quants: &[i8],
out: &mut [f32],
n: usize,
m: usize,
k: usize,
) -> bool {
debug_assert_eq!(
gate.m, m,
"try_repacked_gate_up_silu_rowmajor: gate m mismatch"
);
debug_assert_eq!(
gate.k, k,
"try_repacked_gate_up_silu_rowmajor: gate k mismatch"
);
debug_assert_eq!(up.m, m, "try_repacked_gate_up_silu_rowmajor: up m mismatch");
debug_assert_eq!(up.k, k, "try_repacked_gate_up_silu_rowmajor: up k mismatch");
#[allow(clippy::collapsible_if)]
if let (Some(g_rp), Some(u_rp)) = (&gate.repacked, &up.repacked) {
if let (
Repacked::Q40 {
packed: gp,
scales: gs,
},
Repacked::Q40 {
packed: up,
scales: us,
},
) = (&g_rp.kind, &u_rp.kind)
{
return cpu::gemm_preq_repacked_q4_0_gate_up_silu_dispatch(
gp, gs, up, us, b_scales, b_quants, out, m, n, k,
);
}
}
false
}
#[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))]
#[allow(dead_code, clippy::too_many_arguments)]
pub(crate) fn gemm_preq(
gguf: &GgufFile,
wref: &WeightRef,
b_scales: &[f32],
b_quants: &[i8],
out: &mut [f32],
m: usize,
n: usize,
k: usize,
) -> bool {
debug_assert_eq!(wref.m, m, "gemm_preq: weight m mismatch");
debug_assert_eq!(wref.k, k, "gemm_preq: weight k mismatch");
debug_assert_eq!(k % 32, 0, "gemm_preq: k ({k}) must be a multiple of 32");
debug_assert!(
b_scales.len() >= n * (k / 32) && b_quants.len() >= n * k,
"gemm_preq: input scratch too small (need {} scales / {} quants for n={n}, k={k})",
n * (k / 32),
n * k,
);
let data = weight_data(gguf, wref);
let b_scales = &b_scales[..n * (k / 32)];
let b_quants = &b_quants[..n * k];
let out = &mut out[..m * n];
#[cfg(all(any(target_arch = "x86_64", target_arch = "aarch64"), not(has_blas)))]
if let Some(rp) = &wref.repacked {
debug_assert_eq!(rp.m, m, "gemm_preq: repacked m mismatch");
debug_assert_eq!(rp.k, k, "gemm_preq: repacked k mismatch");
let ran = match &rp.kind {
Repacked::Q40 { packed, scales } => cpu::gemm_preq_repacked_q4_0_dispatch(
packed, scales, b_scales, b_quants, out, m, n, k,
),
#[cfg(target_arch = "x86_64")]
Repacked::Q4K { packed, dsc, dmn } => cpu::gemm_preq_repacked_q4_k_dispatch(
packed, dsc, dmn, b_scales, b_quants, out, m, n, k,
),
#[allow(unreachable_patterns)]
_ => false,
};
if !ran {
report_uncomputed_gemm("gemm_preq", wref.dtype, k);
}
return ran;
}
let ran = cpu::gemm_preq_dispatch(wref.dtype, data, b_scales, b_quants, out, m, n, k);
if !ran {
report_uncomputed_gemm("gemm_preq", wref.dtype, k);
}
ran
}
#[cfg(any(
all(any(target_arch = "aarch64", target_arch = "x86_64"), not(has_blas)),
has_blas
))]
fn report_uncomputed_gemm(route: &str, dtype: DType, k: usize) {
debug_assert!(
false,
"{route}: no batched kernel ran for {dtype:?} (k={k}), but `batched_gemm_supports` \
admitted it — the gate and the kernel table have drifted. `out` is now stale."
);
tracing::error!(
"{route}: no batched kernel for {dtype:?} (k={k}); the matmul was NOT computed \
and the output buffer holds stale data"
);
}
#[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))]
#[allow(dead_code)]
pub(crate) fn gemm_out_to_rows(src: &[f32], rows: usize, n: usize, cols: usize, dst: &mut [f32]) {
assert!(
cols <= rows,
"cannot take a {cols}-row prefix of a {rows}-row GEMM result"
);
assert_eq!(src.len(), rows * n, "src must be column-major [rows * n]");
assert_eq!(dst.len(), n * cols, "dst must be row-major [n * cols]");
for (j, row) in dst.chunks_exact_mut(cols).enumerate() {
for (i, d) in row.iter_mut().enumerate() {
*d = src[i * n + j];
}
}
}
#[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))]
#[allow(dead_code)]
pub(crate) fn quantize_columns(
mat: &[f32],
dim: usize,
n: usize,
col: &mut [f32],
scales: &mut [f32],
quants: &mut [i8],
) {
assert!(
dim.is_multiple_of(32),
"quantize_columns: dim ({dim}) must be a multiple of 32"
);
assert!(
mat.len() >= dim * n
&& col.len() >= dim
&& scales.len() >= n * (dim / 32)
&& quants.len() >= n * dim,
"quantize_columns: scratch too small for dim={dim}, n={n}",
);
let nb = dim / 32;
#[cfg(feature = "parallel")]
{
let min_cols = cpu::prequant_par_min_cols();
if n >= min_cols {
let mat_ptr = mat.as_ptr() as usize;
let quants_ptr = quants.as_mut_ptr() as usize;
cpu::par_rows_n(&mut scales[..n * nb], nb, min_cols, move |(j, sc)| {
let mat = mat_ptr as *const f32;
let qcol = (quants_ptr as *mut i8).wrapping_add(j * dim);
let mut blk = [0.0f32; 32];
for b in 0..nb {
for (t, bt) in blk.iter_mut().enumerate() {
*bt = unsafe { *mat.add((b * 32 + t) * n + j) };
}
unsafe {
let qs = core::slice::from_raw_parts_mut(qcol.add(b * 32), 32);
cpu::quantize_f32_to_q8_0_into(&blk, &mut sc[b..b + 1], qs);
}
}
});
return;
}
}
for j in 0..n {
for i in 0..dim {
col[i] = mat[i * n + j];
}
cpu::quantize_f32_to_q8_0_into(
&col[..dim],
&mut scales[j * nb..(j + 1) * nb],
&mut quants[j * dim..(j + 1) * dim],
);
}
}
#[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))]
#[allow(dead_code)]
pub(crate) fn quantize_rows(
mat: &[f32],
dim: usize,
n: usize,
scales: &mut [f32],
quants: &mut [i8],
) {
assert!(
dim.is_multiple_of(32),
"quantize_rows: dim must be divisible by 32"
);
assert!(
mat.len() >= dim * n && scales.len() >= n * (dim / 32) && quants.len() >= n * dim,
"quantize_rows: scratch too small for dim={dim}, n={n}"
);
let nb = dim / 32;
#[cfg(feature = "parallel")]
{
let min_cols = cpu::prequant_par_min_cols();
if n >= min_cols {
let mat_ptr = mat.as_ptr() as usize;
let quants_ptr = quants.as_mut_ptr() as usize;
cpu::par_rows_n(&mut scales[..n * nb], nb, min_cols, move |(j, sc)| {
let tok_f32 = unsafe {
core::slice::from_raw_parts((mat_ptr as *const f32).add(j * dim), dim)
};
let tok_qs = unsafe {
core::slice::from_raw_parts_mut((quants_ptr as *mut i8).add(j * dim), dim)
};
cpu::quantize_f32_to_q8_0_into(tok_f32, sc, tok_qs);
});
return;
}
}
for j in 0..n {
let tok_f32 = &mat[j * dim..(j + 1) * dim];
cpu::quantize_f32_to_q8_0_into(
tok_f32,
&mut scales[j * nb..(j + 1) * nb],
&mut quants[j * dim..(j + 1) * dim],
);
}
}
pub(crate) fn dequantize_row_into(
gguf: &GgufFile,
wref: &WeightRef,
row_idx: usize,
out: &mut [f32],
) {
assert!(
row_idx < wref.m,
"dequantize_row: row_idx {row_idx} out of range (m={})",
wref.m
);
let data = weight_data(gguf, wref);
let block_size = wref.dtype.block_size();
assert_eq!(
wref.k % block_size,
0,
"dequantize_row: k ({}) is not a multiple of the {:?} block size ({block_size})",
wref.k,
wref.dtype,
);
let row_bytes = wref.k / block_size * wref.dtype.block_bytes();
let row_start = row_idx * row_bytes;
let row_data = &data[row_start..row_start + row_bytes];
dequantize_row_slice(wref.dtype, row_data, out);
}
pub fn dequantize_row_slice(dtype: DType, row_data: &[u8], out: &mut [f32]) {
match dtype {
DType::Q6K => crate::quant::dequantize_q6_k_row(row_data, out),
DType::Q8_0 => crate::quant::dequantize_q8_0_row(row_data, out),
DType::Q4_0 => crate::quant::dequantize_q4_0_row(row_data, out),
DType::Q4_1 => crate::quant::dequantize_q4_1_row(row_data, out),
DType::Q4KM => crate::quant::dequantize_q4_k_m_row(row_data, out),
DType::Q5KM => crate::quant::dequantize_q5_k_row(row_data, out),
DType::F32 => {
if let Ok(floats) = bytemuck::try_cast_slice::<u8, f32>(row_data) {
assert_eq!(floats.len(), out.len(), "F32 embedding row length");
out.copy_from_slice(floats);
} else {
assert_eq!(
row_data.len() / 4,
out.len(),
"F32 unaligned embedding row byte length"
);
for (o, chunk) in out.iter_mut().zip(row_data.as_chunks::<4>().0) {
*o = f32::from_le_bytes(*chunk);
}
}
}
DType::F16 => {
if let Ok(halves) = bytemuck::try_cast_slice::<u8, u16>(row_data) {
assert_eq!(halves.len(), out.len(), "F16 embedding row length");
for (o, &h) in out.iter_mut().zip(halves) {
*o = crate::quant::f16_to_f32(h);
}
} else {
assert_eq!(
row_data.len() / 2,
out.len(),
"F16 unaligned embedding row byte length"
);
for (o, chunk) in out.iter_mut().zip(row_data.as_chunks::<2>().0) {
let h = u16::from_le_bytes(*chunk);
*o = crate::quant::f16_to_f32(h);
}
}
}
DType::BF16 => {
if let Ok(halves) = bytemuck::try_cast_slice::<u8, u16>(row_data) {
assert_eq!(halves.len(), out.len(), "BF16 embedding row length");
for (o, &h) in out.iter_mut().zip(halves) {
*o = crate::quant::bf16_to_f32(h);
}
} else {
assert_eq!(
row_data.len() / 2,
out.len(),
"BF16 unaligned embedding row byte length"
);
for (o, chunk) in out.iter_mut().zip(row_data.as_chunks::<2>().0) {
let h = u16::from_le_bytes(*chunk);
*o = crate::quant::bf16_to_f32(h);
}
}
}
_ => panic!("unsupported embedding dtype: {:?}", dtype),
}
}
pub(crate) fn dequantize_row(gguf: &GgufFile, wref: &WeightRef, row_idx: usize) -> Vec<f32> {
let mut out = vec![0.0f32; wref.k];
dequantize_row_into(gguf, wref, row_idx, &mut out);
out
}
#[cfg(any(
feature = "gpu",
all(feature = "metal", any(target_os = "macos", target_os = "ios"))
))]
pub(crate) fn dequantize_weight(gguf: &GgufFile, wref: &WeightRef) -> Vec<f32> {
let mut out = vec![0.0f32; wref.m * wref.k];
for row in 0..wref.m {
let row_out = &mut out[row * wref.k..(row + 1) * wref.k];
dequantize_row_into(gguf, wref, row, row_out);
}
out
}
pub(crate) struct AttnWeights<'a> {
pub attn_q: &'a WeightRef,
pub attn_k: &'a WeightRef,
pub attn_v: &'a WeightRef,
pub attn_output: &'a WeightRef,
}
pub(crate) struct AttnExtras<'a> {
pub qkv_bias: Option<(&'a [f32], &'a [f32], &'a [f32])>,
pub qk_norm: Option<(&'a [f32], &'a [f32])>,
}
#[derive(Clone, Copy)]
pub(crate) struct AttnDims<'a> {
pub hidden_size: usize,
pub n_heads: usize,
pub n_kv_heads: usize,
pub head_dim: usize,
pub rope_theta: f32,
pub rms_norm_eps: f32,
pub rope_type: cpu::RopeType,
pub attn_scale: Option<f32>,
pub rope_freqs: Option<&'a [f32]>,
}
pub(crate) enum KvView<'a> {
F32 {
k: &'a [f32],
v: &'a [f32],
},
F16 {
k: &'a [u16],
v: &'a [u16],
},
}
pub(crate) struct DecodeAttnDims {
pub n_heads: usize,
pub n_kv_heads: usize,
pub head_dim: usize,
pub scale: f32,
pub seq_len: usize,
}
impl DecodeAttnDims {
#[inline]
fn group_size(&self) -> usize {
self.n_heads / self.n_kv_heads
}
#[inline]
fn kv_dim(&self) -> usize {
self.n_kv_heads * self.head_dim
}
}
const DECODE_ATTN_PAR_MIN_WORK_DEFAULT: usize = 8_192;
fn decode_attn_par_min_work() -> usize {
static W: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
*W.get_or_init(|| {
crate::backend::cpu_features::env_usize("CERA_DECODE_ATTN_PAR_MIN_WORK")
.unwrap_or(DECODE_ATTN_PAR_MIN_WORK_DEFAULT)
})
}
fn decode_attn_head(
q: &[f32],
kv: &KvView<'_>,
d: &DecodeAttnDims,
h: usize,
scores: &mut [f32],
head_out: &mut [f32],
) {
let q_head = &q[h * d.head_dim..(h + 1) * d.head_dim];
let kv_h_offset = (h / d.group_size()) * d.head_dim;
match kv {
KvView::F16 { k, v } => {
cpu::attn_scores_f16(
q_head,
k,
scores,
d.kv_dim(),
kv_h_offset,
d.head_dim,
d.scale,
d.seq_len,
);
cpu::softmax_inplace(scores);
cpu::attn_values_f16(
scores,
v,
head_out,
d.kv_dim(),
kv_h_offset,
d.head_dim,
d.seq_len,
);
}
KvView::F32 { k, v } => {
cpu::attn_scores(
q_head,
k,
scores,
d.kv_dim(),
kv_h_offset,
d.head_dim,
d.scale,
d.seq_len,
);
cpu::softmax_inplace(scores);
cpu::attn_values(
scores,
v,
head_out,
d.kv_dim(),
kv_h_offset,
d.head_dim,
d.seq_len,
);
}
}
}
pub(crate) fn decode_attention(
q: &[f32],
kv: &KvView<'_>,
d: &DecodeAttnDims,
attn_out: &mut [f32],
scratch: &mut Vec<f32>,
) {
debug_assert!(
d.n_kv_heads > 0 && d.n_heads.is_multiple_of(d.n_kv_heads),
"GQA invariant: n_heads ({}) must be a positive multiple of n_kv_heads ({})",
d.n_heads,
d.n_kv_heads
);
let work = d
.n_heads
.saturating_mul(d.seq_len)
.saturating_mul(d.head_dim);
let fan_out =
d.n_heads > 1 && work >= decode_attn_par_min_work() && cpu::decode_par_threads() > 1;
if !fan_out {
scratch.resize(d.seq_len, 0.0);
for h in 0..d.n_heads {
let head_out = &mut attn_out[h * d.head_dim..(h + 1) * d.head_dim];
decode_attn_head(q, kv, d, h, scratch.as_mut_slice(), head_out);
}
return;
}
let stride = d.seq_len.saturating_add(d.head_dim);
scratch.resize(d.n_heads.saturating_mul(stride), 0.0);
cpu::par_rows_n_chunked_decode(scratch, stride, 1, 1, |(h, row)| {
let (scores, head_out) = row.split_at_mut(d.seq_len);
decode_attn_head(q, kv, d, h, scores, head_out);
});
for h in 0..d.n_heads {
let src = h * stride + d.seq_len;
attn_out[h * d.head_dim..(h + 1) * d.head_dim]
.copy_from_slice(&scratch[src..src + d.head_dim]);
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn forward_attn_block(
gguf: &GgufFile,
layer: usize,
weights: &AttnWeights,
extras: &AttnExtras,
dims: AttnDims<'_>,
hidden: &[f32],
pos: usize,
state: &mut InferenceState,
) {
let head_dim = dims.head_dim;
let n_heads = dims.n_heads;
let n_kv_heads = dims.n_kv_heads;
let hidden_size = dims.hidden_size;
let kv_dim = n_kv_heads * head_dim;
let q_dim = n_heads * head_dim;
let lora = state.lora.clone();
let q = &mut state.scratch.q[..q_dim];
let k = &mut state.scratch.k[..kv_dim];
let v = &mut state.scratch.v[..kv_dim];
#[cfg(target_arch = "aarch64")]
{
gemv_preq(
gguf,
weights.attn_q,
hidden,
&state.scratch.q8_scales,
&state.scratch.q8_quants,
q,
);
gemv_preq(
gguf,
weights.attn_k,
hidden,
&state.scratch.q8_scales,
&state.scratch.q8_quants,
k,
);
gemv_preq(
gguf,
weights.attn_v,
hidden,
&state.scratch.q8_scales,
&state.scratch.q8_quants,
v,
);
}
#[cfg(not(target_arch = "aarch64"))]
{
gemv(gguf, weights.attn_q, hidden, q);
gemv(gguf, weights.attn_k, hidden, k);
gemv(gguf, weights.attn_v, hidden, v);
}
if let Some((q_bias, k_bias, v_bias)) = extras.qkv_bias {
cpu::add_inplace(q, q_bias);
cpu::add_inplace(k, k_bias);
cpu::add_inplace(v, v_bias);
}
if let Some(lora) = &lora {
crate::lora::apply_attn_qkv(lora, layer, hidden, q, k, v, &mut state.scratch.lora_tmp);
}
if let Some((q_norm, k_norm)) = extras.qk_norm {
for h in 0..n_heads {
cpu::rmsnorm(
&mut q[h * head_dim..(h + 1) * head_dim],
q_norm,
dims.rms_norm_eps,
);
}
for h in 0..n_kv_heads {
cpu::rmsnorm(
&mut k[h * head_dim..(h + 1) * head_dim],
k_norm,
dims.rms_norm_eps,
);
}
}
match dims.rope_type {
cpu::RopeType::Neox => cpu::rope(q, k, pos, n_heads, n_kv_heads, head_dim, dims.rope_theta),
cpu::RopeType::Norm => cpu::rope_norm(
q,
k,
pos,
n_heads,
n_kv_heads,
head_dim,
dims.rope_theta,
dims.rope_freqs,
),
}
let use_f16 = state.kv_f16;
if let LayerState::Attention {
key_cache,
value_cache,
key_cache_f16,
value_cache_f16,
..
} = &mut state.layers[layer]
{
if use_f16 {
key_cache_f16.extend(
state.scratch.k[..kv_dim]
.iter()
.map(|&x| crate::quant::f32_to_f16(x)),
);
value_cache_f16.extend(
state.scratch.v[..kv_dim]
.iter()
.map(|&x| crate::quant::f32_to_f16(x)),
);
} else {
key_cache.extend_from_slice(&state.scratch.k[..kv_dim]);
value_cache.extend_from_slice(&state.scratch.v[..kv_dim]);
}
}
let scale = dims
.attn_scale
.unwrap_or_else(|| 1.0 / (head_dim as f32).sqrt());
{
let (k_cache, v_cache, k_cache_f16, v_cache_f16) = match &state.layers[layer] {
LayerState::Attention {
key_cache,
value_cache,
key_cache_f16,
value_cache_f16,
..
} => (
key_cache.as_slice(),
value_cache.as_slice(),
key_cache_f16.as_slice(),
value_cache_f16.as_slice(),
),
_ => panic!("expected Attention state for layer {layer}"),
};
let seq_len = if use_f16 {
k_cache_f16.len() / kv_dim
} else {
k_cache.len() / kv_dim
};
let attn_out = &mut state.scratch.attn_out[..q_dim];
let q = &state.scratch.q[..q_dim];
let kv = if use_f16 {
KvView::F16 {
k: k_cache_f16,
v: v_cache_f16,
}
} else {
KvView::F32 {
k: k_cache,
v: v_cache,
}
};
decode_attention(
q,
&kv,
&DecodeAttnDims {
n_heads,
n_kv_heads,
head_dim,
scale,
seq_len,
},
attn_out,
&mut state.scratch.scores,
);
}
#[cfg(target_arch = "aarch64")]
{
quantize_to_scratch_bufs(
&state.scratch.attn_out[..q_dim],
&mut state.scratch.q8_scales,
&mut state.scratch.q8_quants,
);
gemv_preq(
gguf,
weights.attn_output,
&state.scratch.attn_out[..q_dim],
&state.scratch.q8_scales,
&state.scratch.q8_quants,
&mut state.scratch.out[..hidden_size],
);
}
#[cfg(not(target_arch = "aarch64"))]
{
let out = &mut state.scratch.out[..hidden_size];
gemv(
gguf,
weights.attn_output,
&state.scratch.attn_out[..q_dim],
out,
);
}
if let Some(lora) = &lora
&& let Some(t) = lora.get(layer, crate::lora::LoraTarget::AttnOutput)
{
let out = &mut state.scratch.out[..hidden_size];
crate::lora::apply_decode(
t,
&state.scratch.attn_out[..q_dim],
out,
&mut state.scratch.lora_tmp,
);
}
}
pub(crate) struct FfnWeights<'a> {
pub ffn_gate: &'a WeightRef,
pub ffn_up: &'a WeightRef,
pub ffn_down: &'a WeightRef,
}
pub(crate) fn forward_ffn_block(
gguf: &GgufFile,
layer: usize,
weights: &FfnWeights,
hidden_size: usize,
intermediate_size: usize,
ffn_input: &[f32],
state: &mut InferenceState,
) {
let lora = state.lora.clone();
#[cfg(target_arch = "aarch64")]
{
let can_fuse_swiglu = lora.is_none()
&& weights.ffn_gate.dtype == DType::Q4_0
&& weights.ffn_up.dtype == DType::Q4_0;
if can_fuse_swiglu {
let g_data = weight_data(gguf, weights.ffn_gate);
let u_data = weight_data(gguf, weights.ffn_up);
cpu::gemv_q4_0_gate_up_swiglu_with_q8(
g_data,
u_data,
&state.scratch.q8_scales,
&state.scratch.q8_quants,
&mut state.scratch.gate[..intermediate_size],
intermediate_size,
hidden_size,
);
} else if weights.ffn_gate.dtype == DType::Q4_0 && weights.ffn_up.dtype == DType::Q4_0 {
let g_data = weight_data(gguf, weights.ffn_gate);
let u_data = weight_data(gguf, weights.ffn_up);
cpu::gemv_q4_0_fused2_with_q8(
g_data,
u_data,
&state.scratch.q8_scales,
&state.scratch.q8_quants,
&mut state.scratch.gate[..intermediate_size],
&mut state.scratch.up[..intermediate_size],
intermediate_size,
hidden_size,
);
} else {
gemv_preq(
gguf,
weights.ffn_gate,
ffn_input,
&state.scratch.q8_scales,
&state.scratch.q8_quants,
&mut state.scratch.gate[..intermediate_size],
);
gemv_preq(
gguf,
weights.ffn_up,
ffn_input,
&state.scratch.q8_scales,
&state.scratch.q8_quants,
&mut state.scratch.up[..intermediate_size],
);
}
}
#[cfg(not(target_arch = "aarch64"))]
{
gemv(
gguf,
weights.ffn_gate,
ffn_input,
&mut state.scratch.gate[..intermediate_size],
);
gemv(
gguf,
weights.ffn_up,
ffn_input,
&mut state.scratch.up[..intermediate_size],
);
}
#[cfg(target_arch = "aarch64")]
let fused_swiglu_done = lora.is_none()
&& weights.ffn_gate.dtype == DType::Q4_0
&& weights.ffn_up.dtype == DType::Q4_0;
#[cfg(not(target_arch = "aarch64"))]
let fused_swiglu_done = false;
if !fused_swiglu_done {
if let Some(lora) = &lora {
if let Some(t) = lora.get(layer, crate::lora::LoraTarget::FfnGate) {
crate::lora::apply_decode(
t,
ffn_input,
&mut state.scratch.gate[..intermediate_size],
&mut state.scratch.lora_tmp,
);
}
if let Some(t) = lora.get(layer, crate::lora::LoraTarget::FfnUp) {
crate::lora::apply_decode(
t,
ffn_input,
&mut state.scratch.up[..intermediate_size],
&mut state.scratch.lora_tmp,
);
}
}
cpu::silu_mul_inplace(
&mut state.scratch.gate[..intermediate_size],
&state.scratch.up[..intermediate_size],
);
}
#[cfg(target_arch = "aarch64")]
{
let nb = intermediate_size / 32;
state.scratch.q8_scales.resize(nb, 0.0);
state.scratch.q8_quants.resize(intermediate_size, 0);
unsafe {
crate::backend::simd::neon::quantize_f32_to_q8_0_neon(
&state.scratch.gate[..intermediate_size],
&mut state.scratch.q8_scales,
&mut state.scratch.q8_quants,
);
}
gemv_preq(
gguf,
weights.ffn_down,
&state.scratch.gate[..intermediate_size],
&state.scratch.q8_scales,
&state.scratch.q8_quants,
&mut state.scratch.out[..hidden_size],
);
}
#[cfg(not(target_arch = "aarch64"))]
gemv(
gguf,
weights.ffn_down,
&state.scratch.gate[..intermediate_size],
&mut state.scratch.out[..hidden_size],
);
if let Some(lora) = &lora
&& let Some(t) = lora.get(layer, crate::lora::LoraTarget::FfnDown)
{
crate::lora::apply_decode(
t,
&state.scratch.gate[..intermediate_size],
&mut state.scratch.out[..hidden_size],
&mut state.scratch.lora_tmp,
);
}
}
#[cfg(all(test, target_arch = "aarch64", not(has_blas), feature = "parallel"))]
mod tests {
use super::*;
#[cfg(all(any(target_arch = "aarch64", target_arch = "x86_64"), not(has_blas)))]
#[test]
fn gemm_out_to_rows_transposes_and_drops_pad_rows() {
for (rows, n, cols) in [(5usize, 3usize, 5usize), (6, 2, 4)] {
let src: Vec<f32> = (0..rows)
.flat_map(|i| (0..n).map(move |j| (i * 100 + j) as f32))
.collect();
let mut dst = vec![0.0f32; n * cols];
gemm_out_to_rows(&src, rows, n, cols, &mut dst);
for j in 0..n {
for i in 0..cols {
assert_eq!(
dst[j * cols + i],
(i * 100 + j) as f32,
"rows={rows} n={n} cols={cols}: slot (j={j}, i={i})"
);
}
}
}
}
#[test]
fn quantize_columns_parallel_matches_serial() {
let dim = 256usize;
let n = 64usize; let nb = dim / 32;
let mut st = 0x9E37_79B9_7F4A_7C15u64;
let mut lcg = || {
st = st
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
((st >> 33) as f32 / (1u64 << 31) as f32) - 1.0
};
let mat: Vec<f32> = (0..dim * n).map(|_| lcg()).collect();
let mut col = vec![0.0f32; dim];
let mut scales = vec![0.0f32; n * nb];
let mut quants = vec![0i8; n * dim];
quantize_columns(&mat, dim, n, &mut col, &mut scales, &mut quants);
let mut ref_scales = vec![0.0f32; n * nb];
let mut ref_quants = vec![0i8; n * dim];
let mut rc = vec![0.0f32; dim];
for j in 0..n {
for (i, ci) in rc.iter_mut().enumerate() {
*ci = mat[i * n + j];
}
cpu::quantize_f32_to_q8_0_into(
&rc,
&mut ref_scales[j * nb..(j + 1) * nb],
&mut ref_quants[j * dim..(j + 1) * dim],
);
}
assert_eq!(
quants, ref_quants,
"parallel quantize_columns quants differ"
);
assert_eq!(
scales, ref_scales,
"parallel quantize_columns scales differ"
);
}
}
#[cfg(test)]
mod decode_attn_tests {
use super::*;
fn serial_reference(q: &[f32], kv: &KvView<'_>, d: &DecodeAttnDims) -> Vec<f32> {
let mut out = vec![0.0f32; d.n_heads * d.head_dim];
let mut scores = vec![0.0f32; d.seq_len];
for h in 0..d.n_heads {
let head_out = &mut out[h * d.head_dim..(h + 1) * d.head_dim];
decode_attn_head(q, kv, d, h, &mut scores, head_out);
}
out
}
fn would_fan_out(d: &DecodeAttnDims) -> bool {
let work = d
.n_heads
.saturating_mul(d.seq_len)
.saturating_mul(d.head_dim);
d.n_heads > 1 && work >= decode_attn_par_min_work() && cpu::decode_par_threads() > 1
}
fn run_case(
n_heads: usize,
n_kv_heads: usize,
head_dim: usize,
seq_len: usize,
expect_fan_out: bool,
) {
let kv_dim = n_kv_heads * head_dim;
let mut st = 0x2545_F491_4F6C_DD1Du64;
let mut lcg = || {
st ^= st << 13;
st ^= st >> 7;
st ^= st << 17;
(st >> 40) as f32 / 8_388_608.0 - 1.0
};
let q: Vec<f32> = (0..n_heads * head_dim).map(|_| lcg()).collect();
let k: Vec<f32> = (0..seq_len * kv_dim).map(|_| lcg()).collect();
let v: Vec<f32> = (0..seq_len * kv_dim).map(|_| lcg()).collect();
let k_f16: Vec<u16> = k
.iter()
.map(|&x| half::f16::from_f32(x).to_bits())
.collect();
let v_f16: Vec<u16> = v
.iter()
.map(|&x| half::f16::from_f32(x).to_bits())
.collect();
for use_f16 in [false, true] {
let kv = if use_f16 {
KvView::F16 {
k: &k_f16,
v: &v_f16,
}
} else {
KvView::F32 { k: &k, v: &v }
};
let d = DecodeAttnDims {
n_heads,
n_kv_heads,
head_dim,
scale: 1.0 / (head_dim as f32).sqrt(),
seq_len,
};
let gate_overridden = std::env::var_os("CERA_DECODE_ATTN_PAR_MIN_WORK").is_some();
if cfg!(feature = "parallel") && !gate_overridden && cpu::decode_par_threads() > 1 {
assert_eq!(
would_fan_out(&d),
expect_fan_out,
"case (n_heads={n_heads}, seq_len={seq_len}) took the wrong \
branch — this test would not be checking what it claims"
);
}
let want = serial_reference(&q, &kv, &d);
let mut got = vec![0.0f32; n_heads * head_dim];
let mut scratch = vec![0.0f32; 1];
decode_attention(&q, &kv, &d, &mut got, &mut scratch);
assert_eq!(
got, want,
"decode_attention differs (n_heads={n_heads}, seq_len={seq_len}, use_f16={use_f16})"
);
}
}
#[test]
fn decode_attention_parallel_matches_serial() {
run_case(8, 2, 64, 256, true);
run_case(7, 7, 64, 512, true);
}
#[test]
fn decode_attention_serial_branch_matches_reference() {
run_case(8, 2, 64, 8, false);
run_case(1, 1, 64, 4096, false);
}
#[test]
fn decode_attention_across_growing_depth() {
let n_heads = 8;
let n_kv_heads = 2;
let head_dim = 64;
let kv_dim = n_kv_heads * head_dim;
let max_len = 160;
let mut st = 0x853C_49E6_748F_EA9Bu64;
let mut lcg = || {
st ^= st << 13;
st ^= st >> 7;
st ^= st << 17;
(st >> 40) as f32 / 8_388_608.0 - 1.0
};
let q: Vec<f32> = (0..n_heads * head_dim).map(|_| lcg()).collect();
let k: Vec<f32> = (0..max_len * kv_dim).map(|_| lcg()).collect();
let v: Vec<f32> = (0..max_len * kv_dim).map(|_| lcg()).collect();
let k_f16: Vec<u16> = k
.iter()
.map(|&x| half::f16::from_f32(x).to_bits())
.collect();
let v_f16: Vec<u16> = v
.iter()
.map(|&x| half::f16::from_f32(x).to_bits())
.collect();
for use_f16 in [false, true] {
let mut scratch = Vec::new();
for seq_len in 1..=max_len {
let n = seq_len * kv_dim;
let kv = if use_f16 {
KvView::F16 {
k: &k_f16[..n],
v: &v_f16[..n],
}
} else {
KvView::F32 {
k: &k[..n],
v: &v[..n],
}
};
let d = DecodeAttnDims {
n_heads,
n_kv_heads,
head_dim,
scale: 1.0 / (head_dim as f32).sqrt(),
seq_len,
};
let want = serial_reference(&q, &kv, &d);
let mut got = vec![0.0f32; n_heads * head_dim];
decode_attention(&q, &kv, &d, &mut got, &mut scratch);
assert_eq!(
got, want,
"decode_attention differs at seq_len={seq_len} (use_f16={use_f16})"
);
}
}
}
}