ftts_kernels/int8.rs
1//! Int8 W8A8 kernels: symmetric per-output-channel Q8 weights times per-row Q8 activations.
2//!
3//! This is the Phase-2/3A quantized projection route for the talker and microdecoder GEMMs.
4//! The numeric contract is S8S8: weights quantized by the canonical symmetric recipe
5//! (`scale = max|row| / 127`, ties-to-even, `[-127, 127]`, `-128` never emitted — identical to
6//! `ftts-artifacts::converter::quantize_output_channel_q8`, byte-for-byte, asserted by a
7//! cross-crate test in `ftts-model-qwen`), activations quantized dynamically per row with the
8//! same recipe. Accumulation is exact i32; the two f32 scales are applied once, after
9//! accumulation, in a fixed multiplication order shared by every tier.
10//!
11//! ## Tier law
12//!
13//! Every tier of [`dot_i32`] is *exactly equal in i32* to [`Int8Tier::Scalar`] on every input —
14//! integer addition is associative, and the overflow selftest proves the all-extreme reduction
15//! fits i32 at every census binding K. A tier is only dispatchable after
16//! [`crate::selftest::run_selftest`] has executed its all-extreme proof rows through the real
17//! kernel function on the running silicon. Do not add a tier here without extending the selftest.
18//!
19//! Inherited prior NE-INH-003 (re-verify per toolchain): on Apple M4, LLVM autovectorization of
20//! the scalar shape beat a hand SDOT micro-tile at m=1. Both routes therefore ship; dispatch
21//! preference is decided by measurement (`FTTS_INT8_TIER` forces a route for A/B), never by
22//! assumption.
23
24use std::sync::OnceLock;
25
26/// Largest absolute Q8 byte the canonical symmetric recipe emits.
27pub const Q8_MAX_ABS: i8 = 127;
28
29/// Weight bytes below which a linear stays on the calling thread even when a team exists.
30///
31/// Every talker/microdecoder projection (2-12 MB) and the codec's ConvNeXt projections clear
32/// this; genuinely small ops don't repay the dispatch handshake.
33const TEAM_WORK_THRESHOLD_BYTES: usize = 512 * 1024;
34
35/// Quantizes one row (weight output channel or activation row) with the canonical symmetric
36/// Q8 recipe.
37///
38/// The returned scale is `max(abs(row)) / 127`; all-zero rows use the explicit scale `1.0` and
39/// emit zero bytes. Values are clamped to `[-127, 127]` and rounded ties-to-even; `-128` is never
40/// emitted. This is the same arithmetic as the offline converter's
41/// `quantize_output_channel_q8`, restated here because the artifact crate depends on this one.
42///
43/// # Panics
44///
45/// Panics if `output.len() != row.len()` or a value is non-finite. A NaN/inf activation reaching
46/// the quantizer means the f32 graph upstream is already corrupt; refusing loudly here beats
47/// synthesizing garbage audio quietly.
48pub fn quantize_row_q8(row: &[f32], output: &mut [i8]) -> f32 {
49 assert_eq!(output.len(), row.len(), "quantize output length mismatch");
50 let mut maximum = 0.0_f32;
51 for (index, &value) in row.iter().enumerate() {
52 assert!(
53 value.is_finite(),
54 "non-finite value {value} at index {index} reached the Q8 quantizer"
55 );
56 maximum = maximum.max(value.abs());
57 }
58 if maximum == 0.0 {
59 output.fill(0);
60 return 1.0;
61 }
62 let scale = maximum / 127.0;
63 if scale == 0.0 {
64 // A subnormal maximum can flush the division to zero; value/scale would then be inf or
65 // NaN. A row this close to zero rounds to the zero row it effectively is.
66 output.fill(0);
67 return 1.0;
68 }
69 for (&value, slot) in row.iter().zip(output.iter_mut()) {
70 let rounded = (value / scale).clamp(-127.0, 127.0).round_ties_even();
71 // The clamp bounds the conversion inside i8, and the symmetric contract additionally
72 // excludes the otherwise-representable -128.
73 *slot = rounded as i8;
74 }
75 scale
76}
77
78/// A weight matrix quantized with per-output-channel symmetric Q8 scales.
79///
80/// Layout is the `nn.Linear` layout the checkpoint stores: `data` is `[n, k]` row-major with one
81/// f32 scale per output row. Quantized once at hydration; the borrowed f32 tensor is untouched.
82#[derive(Clone, Debug)]
83pub struct QuantizedMatrix {
84 /// Q8 bytes, `[n, k]` row-major, each value in `[-127, 127]`.
85 pub data: Vec<i8>,
86 /// One symmetric scale per output row, `[n]`.
87 pub scales: Vec<f32>,
88 /// Output rows.
89 pub n: usize,
90 /// Reduction length of one output element.
91 pub k: usize,
92}
93
94impl QuantizedMatrix {
95 /// Stacks matrices with a shared reduction length into one taller matrix.
96 ///
97 /// Row bytes and scales are byte-identical to quantizing each part separately — this exists
98 /// so fused projections (QKV, gate‖up) can run as ONE kernel dispatch while every output
99 /// row keeps exactly the per-channel quantization it would have had alone.
100 ///
101 /// # Panics
102 ///
103 /// Panics if the parts disagree on `k` or the list is empty.
104 #[must_use]
105 pub fn concat_rows(parts: &[&Self]) -> Self {
106 let k = parts.first().expect("at least one part").k;
107 assert!(parts.iter().all(|part| part.k == k), "parts must share k");
108 let n = parts.iter().map(|part| part.n).sum();
109 let mut data = Vec::with_capacity(n * k);
110 let mut scales = Vec::with_capacity(n);
111 for part in parts {
112 data.extend_from_slice(&part.data);
113 scales.extend_from_slice(&part.scales);
114 }
115 Self { data, scales, n, k }
116 }
117
118 /// Quantizes an `[n, k]` f32 weight matrix one output channel at a time.
119 ///
120 /// # Panics
121 ///
122 /// Panics if `weight.len() != n * k` or any value is non-finite.
123 #[must_use]
124 pub fn quantize(weight: &[f32], n: usize, k: usize) -> Self {
125 assert_eq!(weight.len(), n * k, "weight must be [n, k]");
126 let mut data = vec![0_i8; n * k];
127 let mut scales = vec![0.0_f32; n];
128 for ((weight_row, data_row), scale) in weight
129 .chunks_exact(k)
130 .zip(data.chunks_exact_mut(k))
131 .zip(scales.iter_mut())
132 {
133 *scale = quantize_row_q8(weight_row, data_row);
134 }
135 Self { data, scales, n, k }
136 }
137}
138
139/// An executable int8 dot-product route.
140///
141/// Every variant is exactly equal in i32 to `Scalar` on every input. `NeonSdot` exists only on
142/// aarch64 builds with the `neon-dotprod` feature and is dispatchable only where the CPU reports
143/// FEAT_DotProd at runtime.
144#[derive(Clone, Copy, Debug, Eq, PartialEq)]
145pub enum Int8Tier {
146 /// Portable left-to-right checked-free scalar loop; the reference every tier must equal.
147 Scalar,
148 /// Portable eight-lane loop, retained ONLY as an A/B datapoint: measured ~15x SLOWER than
149 /// `Scalar` at m=1 on M4 Pro (NE-001) — the manual lane structure defeats LLVM's
150 /// autovectorizer, while the plain `Scalar` shape vectorizes to memory bandwidth. Never the
151 /// dispatch default.
152 Autovec,
153 /// Hand SDOT island (aarch64 + FEAT_DotProd), four 16-byte accumulator streams.
154 NeonSdot,
155 /// Hand SIMD128 island (wasm32 + `simd128`), four 16-byte accumulator streams.
156 ///
157 /// The browser's equivalent of [`Self::NeonSdot`]. Unlike aarch64, wasm has no int8 dot for
158 /// the autovectorizer to find, so without this tier a browser runs the byte-at-a-time
159 /// `Scalar` loop.
160 WasmSimd128,
161}
162
163impl Int8Tier {
164 /// Stable machine-readable route name.
165 #[must_use]
166 pub const fn as_str(self) -> &'static str {
167 match self {
168 Self::Scalar => "scalar",
169 Self::Autovec => "autovec",
170 Self::NeonSdot => "neon-sdot",
171 Self::WasmSimd128 => "wasm-simd128",
172 }
173 }
174
175 /// Every tier this build can execute on the running silicon, scalar first.
176 #[must_use]
177 pub fn available() -> Vec<Self> {
178 let mut tiers = vec![Self::Scalar, Self::Autovec];
179 if neon_sdot_available() {
180 tiers.push(Self::NeonSdot);
181 }
182 if wasm_simd128_available() {
183 tiers.push(Self::WasmSimd128);
184 }
185 tiers
186 }
187
188 /// The route the int8 path dispatches by default, honoring the `FTTS_INT8_TIER` override.
189 ///
190 /// The override exists for interleaved A/B measurement (`scalar` / `autovec` / `neon-sdot`);
191 /// an unavailable or unrecognized override falls back to the measured default rather than
192 /// panicking mid-synthesis. Until a per-shape KernelPlan lands, the default is `NeonSdot`
193 /// where FEAT_DotProd exists, else `Scalar`. Measured on M4 Pro (2026-08-08, shape bench,
194 /// noisy shared host, indicative): plain `Scalar` autovectorizes to ~50 GB/s and ties SDOT
195 /// at m=1 — NE-INH-003 reconfirmed — while the hand-shaped `Autovec` lane loop defeats the
196 /// vectorizer and loses ~15x; it stays only as an A/B datapoint.
197 #[must_use]
198 pub fn dispatch() -> Self {
199 // wasm32 first and without consulting the environment: there are no environment variables
200 // in a browser, and unlike aarch64 the fallback here is not a vectorized scalar loop but a
201 // byte-at-a-time one, so the island is the only route worth dispatching.
202 if wasm_simd128_available() {
203 return Self::WasmSimd128;
204 }
205 match std::env::var("FTTS_INT8_TIER").as_deref() {
206 Ok("scalar") => Self::Scalar,
207 Ok("autovec") => Self::Autovec,
208 Ok("neon-sdot") if neon_sdot_available() => Self::NeonSdot,
209 _ if neon_sdot_available() => Self::NeonSdot,
210 _ => Self::Scalar,
211 }
212 }
213}
214
215/// Which quantized linear op class the armed route runs.
216///
217/// `W8A8` quantizes activations per row and uses the exact-i32 int8 dot — fastest, but the
218/// activation rounding perturbs logits enough that seeded sampling can draw different tokens
219/// than f32. `W8A16` keeps activations f32 and dequantizes weights in-register — the same
220/// one-byte-per-weight memory traffic, no activation error, so the output tracks the f32
221/// reference much more closely. Its f32 accumulation is lane-ordered (not the reference's
222/// left-to-right order): this is a lossy route already, so reduction-order freedom is part of
223/// the deal, and the fidelity gate is measured downstream, not asserted bitwise.
224#[derive(Clone, Copy, Debug, Eq, PartialEq)]
225pub enum QuantLinearMode {
226 /// Int8 activations times int8 weights, exact i32 accumulation.
227 W8A8(Int8Tier),
228 /// f32 activations times dequantized int8 weights, lane-ordered f32 accumulation.
229 W8A16,
230}
231
232impl QuantLinearMode {
233 /// Stable machine-readable mode name.
234 #[must_use]
235 pub const fn as_str(self) -> &'static str {
236 match self {
237 Self::W8A8(_) => "w8a8",
238 Self::W8A16 => "w8a16",
239 }
240 }
241}
242
243/// W8A16 linear: f32 activations `[m, k]` times a [`QuantizedMatrix`] `[n, k]` producing
244/// f32 `[m, n]`.
245///
246/// Eight independent f32 FMA lanes per dot product, weights widened from i8 in-register; the
247/// per-output-channel scale multiplies once after accumulation, mirroring the W8A8 dequant
248/// order. Weight-stationary loop, like [`linear_q8`].
249///
250/// # Panics
251///
252/// Panics on any shape mismatch.
253pub fn linear_w8a16(
254 x: &[f32],
255 weight: &QuantizedMatrix,
256 bias: Option<&[f32]>,
257 m: usize,
258 out: &mut [f32],
259) {
260 let (n, k) = (weight.n, weight.k);
261 assert_eq!(x.len(), m * k, "x must be [m, k]");
262 assert_eq!(out.len(), m * n, "out must be [m, n]");
263 if let Some(bias) = bias {
264 assert_eq!(bias.len(), n, "bias must be [n]");
265 }
266 for col in 0..n {
267 let w_row = &weight.data[col * k..(col + 1) * k];
268 let w_scale = weight.scales[col];
269 let bias_term = bias.map(|b| b[col]);
270 for row in 0..m {
271 let x_row = &x[row * k..(row + 1) * k];
272 let acc = dot_w8a16(x_row, w_row);
273 let value = acc * w_scale;
274 out[row * n + col] = bias_term.map_or(value, |b| value + b);
275 }
276 }
277}
278
279/// Eight-lane f32 dot of an f32 row against an i8 weight row, widened in-register.
280fn dot_w8a16(x: &[f32], w: &[i8]) -> f32 {
281 const LANES: usize = 8;
282 let mut lanes = [0.0_f32; LANES];
283 let chunks = x.len() / LANES;
284 for chunk in 0..chunks {
285 let base = chunk * LANES;
286 for lane in 0..LANES {
287 lanes[lane] = f32::from(w[base + lane]).mul_add(x[base + lane], lanes[lane]);
288 }
289 }
290 let mut sum: f32 = lanes.iter().sum();
291 for index in chunks * LANES..x.len() {
292 sum = f32::from(w[index]).mul_add(x[index], sum);
293 }
294 sum
295}
296
297/// The armed quantized-linear mode for the talker/microdecoder route.
298///
299/// `FTTS_INT8=1` or `w8a8` selects the int8-dot route; `FTTS_INT8=w8a16` selects the
300/// weight-only route. Anything else means the caller should not be arming quantization at all
301/// (the kill-switch check happens before this is consulted).
302#[must_use]
303pub fn quant_mode_from_environment() -> QuantLinearMode {
304 match std::env::var("FTTS_INT8").as_deref() {
305 Ok("w8a16") => QuantLinearMode::W8A16,
306 _ => QuantLinearMode::W8A8(autotuned_plan().decode_gemv),
307 }
308}
309
310/// Runs one quantized linear in the selected mode; the drop-in used by the armed model paths.
311pub fn quant_linear(
312 mode: QuantLinearMode,
313 x: &[f32],
314 weight: &QuantizedMatrix,
315 bias: Option<&[f32]>,
316 m: usize,
317 out: &mut [f32],
318) {
319 match mode {
320 QuantLinearMode::W8A8(tier) => linear_q8_dynamic(x, weight, bias, m, out, tier),
321 QuantLinearMode::W8A16 => linear_w8a16(x, weight, bias, m, out),
322 }
323}
324
325/// The measured per-regime route assignment, decided once per process.
326///
327/// v0 of the KernelPlan: two regimes, no persistence (`.fttspack` owns that when it lands).
328/// Safe to decide by noisy measurement because every tier produces bit-identical output — a
329/// wrong pick costs microseconds, never correctness.
330#[derive(Clone, Copy, Debug, Eq, PartialEq)]
331pub struct KernelPlanV0 {
332 /// Route for m=1 decode GEMVs (the talker/microdecoder step shape).
333 pub decode_gemv: Int8Tier,
334 /// Route for batched GEMMs (prefill, seq-16 verify, offline codec).
335 pub batch_gemm: Int8Tier,
336}
337
338/// Measures each available tier at the two live regimes and returns the winners.
339///
340/// Decided once per process and cached. `FTTS_INT8_TIER` overrides both regimes — the A/B
341/// override must pin the route it names, not merely suggest it. Cost: a few milliseconds of
342/// synthetic dots at the model's real reduction lengths.
343pub fn autotuned_plan() -> KernelPlanV0 {
344 static PLAN: OnceLock<KernelPlanV0> = OnceLock::new();
345 *PLAN.get_or_init(|| {
346 // wasm32 is pinned, never measured: `Instant::now` panics as `unreachable` there (no
347 // monotonic clock in std — exactly how the browser playground's first synthesize died),
348 // and there is nothing to choose between anyway. `dispatch()` names the SIMD128 island
349 // when it is compiled in, which it is for every browser build; an earlier revision of
350 // this pinned `Scalar` on the grounds that no other tier existed on wasm, and that
351 // sentence stopped being true the moment the island landed — leaving the fast kernel
352 // built, dispatchable, and never dispatched.
353 #[cfg(target_arch = "wasm32")]
354 {
355 let tier = Int8Tier::dispatch();
356 KernelPlanV0 {
357 decode_gemv: tier,
358 batch_gemm: tier,
359 }
360 }
361 #[cfg(not(target_arch = "wasm32"))]
362 {
363 if std::env::var("FTTS_INT8_TIER").is_ok() {
364 let forced = Int8Tier::dispatch();
365 return KernelPlanV0 {
366 decode_gemv: forced,
367 batch_gemm: forced,
368 };
369 }
370 if let Some(cached) = load_persisted_plan() {
371 return cached;
372 }
373 let plan = KernelPlanV0 {
374 // Talker/microdecoder decode: one activation row against tall matrices; K = 1024
375 // and 3072 are the real reduction lengths, 256 output rows keep the probe cheap
376 // while streaming enough weight bytes to reach the bandwidth regime.
377 decode_gemv: fastest_tier(&[(1, 1024, 256), (1, 3072, 256)]),
378 // Verify/prefill/codec batches: sixteen rows, same reduction lengths.
379 batch_gemm: fastest_tier(&[(16, 1024, 128), (16, 3072, 64)]),
380 };
381 persist_plan(plan);
382 plan
383 }
384 })
385}
386
387/// Where the measured plan is cached between runs: the pre-`.fttspack` v0 of the per-machine
388/// execution cache. Losing or corrupting this file only costs a re-measurement.
389fn plan_cache_path() -> Option<std::path::PathBuf> {
390 std::env::var_os("HOME")
391 .map(|home| std::path::PathBuf::from(home).join(".cache/franken_tts/kernel_plan_v0.txt"))
392}
393
394/// The cache key: anything here changing invalidates the measurement.
395fn plan_cache_key() -> String {
396 let tiers: Vec<&str> = Int8Tier::available().iter().map(|t| t.as_str()).collect();
397 format!(
398 "v0|crate={}|tiers={}",
399 env!("CARGO_PKG_VERSION"),
400 tiers.join(",")
401 )
402}
403
404fn load_persisted_plan() -> Option<KernelPlanV0> {
405 // A valid plan file is three short lines; reading it bounded keeps a corrupt or hostile
406 // multi-gigabyte file at this user-writable path from ballooning the process.
407 let text = {
408 use std::io::Read as _;
409 let mut text = String::new();
410 let file = std::fs::File::open(plan_cache_path()?).ok()?;
411 file.take(512).read_to_string(&mut text).ok()?;
412 text
413 };
414 let mut lines = text.lines();
415 if lines.next()? != plan_cache_key() {
416 return None;
417 }
418 let parse = |line: &str| match line {
419 "scalar" => Some(Int8Tier::Scalar),
420 "autovec" => Some(Int8Tier::Autovec),
421 "neon-sdot" if neon_sdot_available() => Some(Int8Tier::NeonSdot),
422 _ => None,
423 };
424 Some(KernelPlanV0 {
425 decode_gemv: parse(lines.next()?)?,
426 batch_gemm: parse(lines.next()?)?,
427 })
428}
429
430fn persist_plan(plan: KernelPlanV0) {
431 let Some(path) = plan_cache_path() else {
432 return;
433 };
434 if let Some(parent) = path.parent() {
435 let _ = std::fs::create_dir_all(parent);
436 }
437 // Best-effort: an unwritable cache directory must never fail synthesis.
438 let _ = std::fs::write(
439 path,
440 format!(
441 "{}\n{}\n{}\n",
442 plan_cache_key(),
443 plan.decode_gemv.as_str(),
444 plan.batch_gemm.as_str()
445 ),
446 );
447}
448
449/// Times every available tier over the given `(m, k, n)` probes; median of three rounds each,
450/// summed across probes, smallest total wins. Ties break toward the earlier tier in
451/// [`Int8Tier::available`] order (scalar first — the simpler route).
452fn fastest_tier(probes: &[(usize, usize, usize)]) -> Int8Tier {
453 use std::time::Instant;
454 let tiers = Int8Tier::available();
455 let mut best = (tiers[0], f64::MAX);
456 for &tier in &tiers {
457 let mut total = 0.0_f64;
458 for &(m, k, n) in probes {
459 // Shifted into [-127, 127]: `as i8` alone wraps 128..=254 to -128..=-2, and -128 is
460 // outside the pinned S8S8 contract this same file declares.
461 let x_q: Vec<i8> = (0..m * k)
462 .map(|i| (((i * 37 + 11) % 255) as i32 - 127) as i8)
463 .collect();
464 let x_scales = vec![1.0_f32; m];
465 let weight = QuantizedMatrix {
466 data: (0..n * k)
467 .map(|i| (((i * 29 + 5) % 255) as i32 - 127) as i8)
468 .collect(),
469 scales: vec![1.0_f32; n],
470 n,
471 k,
472 };
473 let mut out = vec![0.0_f32; m * n];
474 let mut rounds: Vec<f64> = (0..3)
475 .map(|_| {
476 let start = Instant::now();
477 linear_q8(&x_q, &x_scales, &weight, None, m, &mut out, tier);
478 start.elapsed().as_secs_f64()
479 })
480 .collect();
481 rounds.sort_by(f64::total_cmp);
482 total += rounds[1];
483 }
484 if total < best.1 {
485 best = (tier, total);
486 }
487 }
488 best.0
489}
490
491/// Whether the SDOT island is compiled in and the CPU reports FEAT_DotProd.
492#[must_use]
493pub fn neon_sdot_available() -> bool {
494 #[cfg(all(target_arch = "aarch64", feature = "neon-dotprod"))]
495 {
496 neon_dotprod::available()
497 }
498 #[cfg(not(all(target_arch = "aarch64", feature = "neon-dotprod")))]
499 {
500 false
501 }
502}
503
504/// Whether the SIMD128 island is compiled in.
505///
506/// Compile-time only, deliberately: `simd128` is a wasm target feature, so a module built with it
507/// either instantiates on an engine that has it or is refused outright. There is no partial
508/// support to detect at runtime the way FEAT_DotProd must be.
509#[must_use]
510pub fn wasm_simd128_available() -> bool {
511 cfg!(all(target_arch = "wasm32", target_feature = "simd128"))
512}
513
514/// Exact i32 dot product of two Q8 rows over the selected route.
515///
516/// # Panics
517///
518/// Panics if the lengths differ, or if `NeonSdot` is requested where it is not executable.
519#[must_use]
520pub fn dot_i32(a: &[i8], b: &[i8], tier: Int8Tier) -> i32 {
521 assert_eq!(a.len(), b.len(), "int8 dot inputs must match");
522 match tier {
523 Int8Tier::Scalar => dot_i32_scalar(a, b),
524 Int8Tier::Autovec => dot_i32_autovec(a, b),
525 Int8Tier::NeonSdot => dot_i32_neon_or_panic(a, b),
526 Int8Tier::WasmSimd128 => dot_i32_wasm_or_panic(a, b),
527 }
528}
529
530#[cfg(all(target_arch = "wasm32", target_feature = "simd128"))]
531fn dot_i32_wasm_or_panic(a: &[i8], b: &[i8]) -> i32 {
532 wasm_simd128::dot_i32(a, b)
533}
534
535#[cfg(not(all(target_arch = "wasm32", target_feature = "simd128")))]
536fn dot_i32_wasm_or_panic(_a: &[i8], _b: &[i8]) -> i32 {
537 panic!("wasm-simd128 route selected on a build without the island");
538}
539
540#[cfg(all(target_arch = "aarch64", feature = "neon-dotprod"))]
541fn dot_i32_neon_or_panic(a: &[i8], b: &[i8]) -> i32 {
542 assert!(
543 neon_dotprod::available(),
544 "neon-sdot route selected without FEAT_DotProd"
545 );
546 neon_dotprod::dot_i32(a, b)
547}
548
549#[cfg(not(all(target_arch = "aarch64", feature = "neon-dotprod")))]
550fn dot_i32_neon_or_panic(_a: &[i8], _b: &[i8]) -> i32 {
551 panic!("neon-sdot route selected on a build without the island");
552}
553
554fn dot_i32_scalar(a: &[i8], b: &[i8]) -> i32 {
555 let mut sum = 0_i32;
556 for index in 0..a.len() {
557 sum += i32::from(a[index]) * i32::from(b[index]);
558 }
559 sum
560}
561
562/// Eight independent i32 lanes over fixed-width chunks; LLVM autovectorizes this shape into
563/// widening multiply-accumulate sequences (and SDOT where the target baseline carries it).
564/// Integer addition is associative, so the result is exactly [`dot_i32_scalar`]'s.
565fn dot_i32_autovec(a: &[i8], b: &[i8]) -> i32 {
566 const LANES: usize = 8;
567 let mut lanes = [0_i32; LANES];
568 let chunks = a.len() / LANES;
569 for chunk in 0..chunks {
570 let base = chunk * LANES;
571 for lane in 0..LANES {
572 lanes[lane] += i32::from(a[base + lane]) * i32::from(b[base + lane]);
573 }
574 }
575 let mut sum: i32 = lanes.iter().sum();
576 for index in chunks * LANES..a.len() {
577 sum += i32::from(a[index]) * i32::from(b[index]);
578 }
579 sum
580}
581
582#[cfg(all(target_arch = "aarch64", feature = "neon-dotprod"))]
583mod neon_dotprod {
584 //! The audited SDOT island. Named per the crate law: feature-gated, runtime-detected,
585 //! bit-identical scalar fallback in the parent module, every load bounds-checked by loop
586 //! structure, every unsafe operation carrying a SAFETY note.
587
588 use core::arch::aarch64::{vaddq_s32, vaddvq_s32, vdotq_s32, vdupq_n_s32, vld1q_s8};
589
590 /// Whether the running CPU reports FEAT_DotProd.
591 #[must_use]
592 pub fn available() -> bool {
593 std::arch::is_aarch64_feature_detected!("dotprod")
594 }
595
596 /// Exact i32 dot product via SDOT, four accumulator streams over 64-byte blocks.
597 ///
598 /// # Panics
599 ///
600 /// Panics (in the caller) unless [`available`] returned true; lengths are asserted equal by
601 /// [`super::dot_i32`].
602 #[must_use]
603 pub fn dot_i32(a: &[i8], b: &[i8]) -> i32 {
604 debug_assert!(available(), "SDOT island entered without FEAT_DotProd");
605 // SAFETY: `dot_i32_sdot` requires NEON + FEAT_DotProd, which `available()` has confirmed
606 // on this CPU at every dispatch site (asserted in `super::dot_i32`, debug-asserted here).
607 unsafe { dot_i32_sdot(a, b) }
608 }
609
610 // SAFETY: callers must have confirmed FEAT_DotProd via `available()` — the sole caller
611 // `dot_i32` above does, and `super::dot_i32` asserts it at the dispatch site. All loads are
612 // bounded by `a.len()`, which the caller asserts equals `b.len()`, and the tail is handled
613 // scalar-side, so no read passes either slice's end.
614 #[target_feature(enable = "neon,dotprod")]
615 unsafe fn dot_i32_sdot(a: &[i8], b: &[i8]) -> i32 {
616 let len = a.len();
617 let a_ptr = a.as_ptr();
618 let b_ptr = b.as_ptr();
619 let mut acc0 = vdupq_n_s32(0);
620 let mut acc1 = vdupq_n_s32(0);
621 let mut acc2 = vdupq_n_s32(0);
622 let mut acc3 = vdupq_n_s32(0);
623 let mut index = 0_usize;
624 while index + 64 <= len {
625 // SAFETY: `index + 64 <= len` bounds all four 16-byte loads inside both slices,
626 // whose lengths are equal by the caller's assertion. `vld1q_s8` has no alignment
627 // requirement beyond byte alignment.
628 unsafe {
629 acc0 = vdotq_s32(acc0, vld1q_s8(a_ptr.add(index)), vld1q_s8(b_ptr.add(index)));
630 acc1 = vdotq_s32(
631 acc1,
632 vld1q_s8(a_ptr.add(index + 16)),
633 vld1q_s8(b_ptr.add(index + 16)),
634 );
635 acc2 = vdotq_s32(
636 acc2,
637 vld1q_s8(a_ptr.add(index + 32)),
638 vld1q_s8(b_ptr.add(index + 32)),
639 );
640 acc3 = vdotq_s32(
641 acc3,
642 vld1q_s8(a_ptr.add(index + 48)),
643 vld1q_s8(b_ptr.add(index + 48)),
644 );
645 }
646 index += 64;
647 }
648 while index + 16 <= len {
649 // SAFETY: `index + 16 <= len` bounds this 16-byte load inside both slices.
650 unsafe {
651 acc0 = vdotq_s32(acc0, vld1q_s8(a_ptr.add(index)), vld1q_s8(b_ptr.add(index)));
652 }
653 index += 16;
654 }
655 let mut sum = vaddvq_s32(vaddq_s32(vaddq_s32(acc0, acc1), vaddq_s32(acc2, acc3)));
656 while index < len {
657 sum += i32::from(a[index]) * i32::from(b[index]);
658 index += 1;
659 }
660 sum
661 }
662}
663
664#[cfg(all(target_arch = "wasm32", target_feature = "simd128"))]
665mod wasm_simd128 {
666 //! The audited SIMD128 island — the browser's counterpart to the SDOT one above.
667 //!
668 //! Why this exists at all: on wasm32 the dispatch fell through to `Scalar`, and NE-001's
669 //! finding that the scalar shape "vectorizes to memory bandwidth" is an *aarch64* result that
670 //! does not transfer. wasm SIMD128 has no int8 dot instruction for LLVM to pattern-match, so
671 //! the autovectorizer has nothing to reach for and the browser ran a byte-at-a-time loop.
672 //!
673 //! The instruction that replaces it is `i32x4.dot_i16x8_s`: eight i16 products summed
674 //! pairwise into four i32 lanes, one op. Feed it from `i16x8.extend_low/high_i8x16_s` and a
675 //! 16-byte block of int8 costs two widenings per operand, two dots and two adds.
676 //!
677 //! Exactness is free here and that is the point: every product of two `i8` fits `i16`, every
678 //! pairwise sum fits `i32`, and integer addition is associative — so lane order, accumulator
679 //! count and reduction order cannot change the result. This tier is *equal* to `Scalar` in
680 //! i32, not merely close, which is what lets it share the parent module's tier-equality test.
681 //!
682 //! Overflow carries no new obligation: the bound is unchanged from the scalar path at the
683 //! model's real worst-case K (3072 → |sum| ≤ 3072 × 127² ≈ 49.5M, ~43× inside i32).
684 //!
685 //! No runtime detection: `simd128` is a compile-time target feature, and a browser without it
686 //! refuses the module outright rather than mis-executing. Every engine this ships to has had
687 //! it for years (Safari 16.4 / iOS 16.4 being the last holdout).
688
689 use core::arch::wasm32::{
690 i16x8_extend_high_i8x16, i16x8_extend_low_i8x16, i32x4_add, i32x4_dot_i16x8,
691 i32x4_extract_lane, i32x4_splat, v128, v128_load,
692 };
693
694 /// Exact i32 dot product via `i32x4.dot_i16x8_s`, four accumulator streams over 64-byte
695 /// blocks.
696 #[must_use]
697 pub fn dot_i32(a: &[i8], b: &[i8]) -> i32 {
698 // SAFETY: every load below is bounded by the loop conditions against `len`, and the two
699 // slices are asserted equal in length by `super::dot_i32`. `v128_load` requires no
700 // alignment beyond the byte alignment an `&[i8]` already guarantees.
701 unsafe { dot_i32_simd128(a, b) }
702 }
703
704 /// Accumulates one 16-byte block of each operand into `acc`.
705 ///
706 /// # Safety
707 ///
708 /// `a` and `b` must each be valid for a 16-byte read.
709 // SAFETY: the contract above is discharged at both call sites, where the block loop runs only
710 // while `offset + 16 <= len` and `len` is the asserted-common length of the two slices.
711 #[inline]
712 unsafe fn accumulate_block(acc: v128, a: *const i8, b: *const i8) -> v128 {
713 // SAFETY: the caller guarantees both pointers address 16 readable bytes.
714 let (left, right) = unsafe { (v128_load(a.cast()), v128_load(b.cast())) };
715 let low = i32x4_dot_i16x8(i16x8_extend_low_i8x16(left), i16x8_extend_low_i8x16(right));
716 let high = i32x4_dot_i16x8(
717 i16x8_extend_high_i8x16(left),
718 i16x8_extend_high_i8x16(right),
719 );
720 i32x4_add(acc, i32x4_add(low, high))
721 }
722
723 /// Four output columns per pass, sharing one widening of the activation.
724 ///
725 /// Loop order stays weight-stationary — four weight rows are streamed once and reused across
726 /// all `m` activation rows — so this keeps the property the serial form was written for while
727 /// removing the redundant activation widening a per-column dot repeats `n` times.
728 pub fn linear_blocked(
729 x_q: &[i8],
730 x_scales: &[f32],
731 weight: &super::QuantizedMatrix,
732 bias: Option<&[f32]>,
733 m: usize,
734 out: &mut [f32],
735 ) {
736 let (n, k) = (weight.n, weight.k);
737 let mut col = 0;
738 while col + 4 <= n {
739 for row in 0..m {
740 let x_row = &x_q[row * k..(row + 1) * k];
741 // SAFETY: `col + 4 <= n` bounds all four weight rows inside `weight.data`, whose
742 // length is `n * k` by the type's invariant.
743 let acc = unsafe { dot4_simd128(x_row, &weight.data[col * k..], k) };
744 for (lane, accumulated) in acc.iter().enumerate() {
745 let column = col + lane;
746 #[allow(clippy::cast_precision_loss)]
747 let value = *accumulated as f32 * (x_scales[row] * weight.scales[column]);
748 out[row * n + column] = bias.map_or(value, |values| value + values[column]);
749 }
750 }
751 col += 4;
752 }
753 // Columns past the last full block of four fall back to the single-column kernel.
754 while col < n {
755 let w_row = &weight.data[col * k..(col + 1) * k];
756 for row in 0..m {
757 let x_row = &x_q[row * k..(row + 1) * k];
758 #[allow(clippy::cast_precision_loss)]
759 let value = dot_i32(x_row, w_row) as f32 * (x_scales[row] * weight.scales[col]);
760 out[row * n + col] = bias.map_or(value, |values| value + values[col]);
761 }
762 col += 1;
763 }
764 }
765
766 /// Dots one activation row against four consecutive weight rows.
767 ///
768 /// # Safety
769 ///
770 /// `weights` must be valid for `4 * k` readable bytes and `x` for `k`.
771 // SAFETY: the caller enters this path only when four whole weight rows remain (`col + 4 <= n`)
772 // and slices `weights` at `col * k` for `4 * k` bytes, with `x` the full k-length activation
773 // row; every load below is bounded by `index < k` against those same lengths.
774 unsafe fn dot4_simd128(x: &[i8], weights: &[i8], k: usize) -> [i32; 4] {
775 let x_ptr = x.as_ptr();
776 let w_ptr = weights.as_ptr();
777 let mut acc = [i32x4_splat(0); 4];
778 let mut index = 0_usize;
779 while index + 16 <= k {
780 // SAFETY: `index + 16 <= k` bounds the activation load, and each weight row starts at
781 // `lane * k` inside a region the caller guarantees is `4 * k` long.
782 let (low, high) = unsafe {
783 let block = v128_load(x_ptr.add(index).cast());
784 (
785 i16x8_extend_low_i8x16(block),
786 i16x8_extend_high_i8x16(block),
787 )
788 };
789 for (lane, accumulator) in acc.iter_mut().enumerate() {
790 // SAFETY: same bound, offset into this lane's weight row.
791 let w = unsafe { v128_load(w_ptr.add(lane * k + index).cast()) };
792 let products = i32x4_add(
793 i32x4_dot_i16x8(low, i16x8_extend_low_i8x16(w)),
794 i32x4_dot_i16x8(high, i16x8_extend_high_i8x16(w)),
795 );
796 *accumulator = i32x4_add(*accumulator, products);
797 }
798 index += 16;
799 }
800 let mut sums = [0_i32; 4];
801 for (lane, sum) in sums.iter_mut().enumerate() {
802 let total = acc[lane];
803 *sum = i32x4_extract_lane::<0>(total)
804 + i32x4_extract_lane::<1>(total)
805 + i32x4_extract_lane::<2>(total)
806 + i32x4_extract_lane::<3>(total);
807 for tail in index..k {
808 // SAFETY: `tail < k` indexes inside this lane's weight row.
809 let w = unsafe { *w_ptr.add(lane * k + tail) };
810 *sum += i32::from(x[tail]) * i32::from(w);
811 }
812 }
813 sums
814 }
815
816 /// # Safety
817 ///
818 /// `a` and `b` must have equal length; the caller asserts this.
819 // SAFETY: `super::dot_i32` asserts the two lengths are equal before dispatching here, and the
820 // only other caller is the public `dot_i32` wrapper directly above, which forwards the same
821 // pair. Every block load is guarded by `offset + 64 <= len` and the remainder runs scalar.
822 unsafe fn dot_i32_simd128(a: &[i8], b: &[i8]) -> i32 {
823 let len = a.len();
824 let a_ptr = a.as_ptr();
825 let b_ptr = b.as_ptr();
826 let mut acc0 = i32x4_splat(0);
827 let mut acc1 = i32x4_splat(0);
828 let mut acc2 = i32x4_splat(0);
829 let mut acc3 = i32x4_splat(0);
830 let mut index = 0_usize;
831 // Four independent streams so the dependent-add latency of one does not stall the next,
832 // mirroring the SDOT island's blocking.
833 while index + 64 <= len {
834 // SAFETY: `index + 64 <= len` bounds all four 16-byte loads inside both slices.
835 unsafe {
836 acc0 = accumulate_block(acc0, a_ptr.add(index), b_ptr.add(index));
837 acc1 = accumulate_block(acc1, a_ptr.add(index + 16), b_ptr.add(index + 16));
838 acc2 = accumulate_block(acc2, a_ptr.add(index + 32), b_ptr.add(index + 32));
839 acc3 = accumulate_block(acc3, a_ptr.add(index + 48), b_ptr.add(index + 48));
840 }
841 index += 64;
842 }
843 while index + 16 <= len {
844 // SAFETY: `index + 16 <= len` bounds this 16-byte load inside both slices.
845 unsafe {
846 acc0 = accumulate_block(acc0, a_ptr.add(index), b_ptr.add(index));
847 }
848 index += 16;
849 }
850 let total = i32x4_add(i32x4_add(acc0, acc1), i32x4_add(acc2, acc3));
851 let mut sum = i32x4_extract_lane::<0>(total)
852 + i32x4_extract_lane::<1>(total)
853 + i32x4_extract_lane::<2>(total)
854 + i32x4_extract_lane::<3>(total);
855 while index < len {
856 sum += i32::from(a[index]) * i32::from(b[index]);
857 index += 1;
858 }
859 sum
860 }
861}
862
863/// W8A8 linear: quantized activations `[m, k]` times a [`QuantizedMatrix`] `[n, k]`, producing
864/// f32 `[m, n]`.
865///
866/// `x_scales` carries one dynamic activation scale per row of `x_q`. The i32 accumulator is
867/// exact on every tier; dequantization applies `acc as f32 * (x_scale * w_scale)` in exactly
868/// that order on every tier, so the f32 output of any two tiers is bit-identical, not merely
869/// close. Bias (only `text_projection` carries one) is added after dequantization.
870///
871/// # Panics
872///
873/// Panics on any shape mismatch.
874#[allow(clippy::too_many_arguments)]
875pub fn linear_q8(
876 x_q: &[i8],
877 x_scales: &[f32],
878 weight: &QuantizedMatrix,
879 bias: Option<&[f32]>,
880 m: usize,
881 out: &mut [f32],
882 tier: Int8Tier,
883) {
884 let (n, k) = (weight.n, weight.k);
885 assert_eq!(x_q.len(), m * k, "x_q must be [m, k]");
886 assert_eq!(x_scales.len(), m, "x_scales must be [m]");
887 assert_eq!(out.len(), m * n, "out must be [m, n]");
888 if let Some(bias) = bias {
889 assert_eq!(bias.len(), n, "bias must be [n]");
890 }
891 // Large operations fan out across the persistent team when one exists; the partitioned
892 // result is bit-identical per element, so this is purely a speed dispatch. Small matrices
893 // stay serial — the dispatch handshake would cost more than the work.
894 if n * k >= TEAM_WORK_THRESHOLD_BYTES
895 && !crate::team::thread_bypassed()
896 && let Some(team) = crate::team::armed()
897 {
898 team.linear_q8(x_q, x_scales, weight, bias, m, out, tier);
899 return;
900 }
901
902 // Weight-stationary loop order: each Q8 weight row is streamed exactly once and reused
903 // across all m activation rows, so an m>1 call (prefill, the seq-16 verify pass) does not
904 // re-read the whole matrix m times. Each output element's dot product is unchanged, so this
905 // ordering is bit-identical to the m-outer form.
906 // wasm has no int8 dot instruction, so a lone dot spends four of its eight ops per 16 bytes
907 // just widening i8 to i16 — and half of that widening is the *activation*, which is identical
908 // for every output column. Computing four columns per pass hoists it: 26 ops for 64 MACs
909 // instead of 32, which is the register-blocking lever the doctrine warns is the real one
910 // ("the instruction is not the lever; the blocking is"). Bit-identical by construction — the
911 // same per-element i32 dot, only the loop nest changes.
912 #[cfg(all(target_arch = "wasm32", target_feature = "simd128"))]
913 if matches!(tier, Int8Tier::WasmSimd128) {
914 wasm_simd128::linear_blocked(x_q, x_scales, weight, bias, m, out);
915 return;
916 }
917
918 for col in 0..n {
919 let w_row = &weight.data[col * k..(col + 1) * k];
920 let w_scale = weight.scales[col];
921 let bias_term = bias.map(|b| b[col]);
922 for row in 0..m {
923 let x_row = &x_q[row * k..(row + 1) * k];
924 let acc = dot_i32(x_row, w_row, tier);
925 let value = acc as f32 * (x_scales[row] * w_scale);
926 out[row * n + col] = bias_term.map_or(value, |b| value + b);
927 }
928 }
929}
930
931/// Quantizes an f32 activation matrix `[m, k]` per row and runs [`linear_q8`].
932///
933/// This is the drop-in W8A8 counterpart of `f32ref::linear`: same `[m, k] × [n, k]ᵀ → [m, n]`
934/// layout, same bias placement. The row quantization is the canonical symmetric recipe.
935///
936/// # Panics
937///
938/// Panics on any shape mismatch or a non-finite activation.
939pub fn linear_q8_dynamic(
940 x: &[f32],
941 weight: &QuantizedMatrix,
942 bias: Option<&[f32]>,
943 m: usize,
944 out: &mut [f32],
945 tier: Int8Tier,
946) {
947 let k = weight.k;
948 assert_eq!(x.len(), m * k, "x must be [m, k]");
949 let mut x_q = vec![0_i8; m * k];
950 let mut x_scales = vec![0.0_f32; m];
951 for ((x_row, q_row), scale) in x
952 .chunks_exact(k)
953 .zip(x_q.chunks_exact_mut(k))
954 .zip(x_scales.iter_mut())
955 {
956 *scale = quantize_row_q8(x_row, q_row);
957 }
958 linear_q8(&x_q, &x_scales, weight, bias, m, out, tier);
959}
960
961#[cfg(test)]
962mod tests {
963 use super::*;
964
965 /// Deterministic pseudo-random Q8 bytes (SplitMix64), full `[-127, 127]` range.
966 fn pseudo_random_q8(len: usize, seed: u64) -> Vec<i8> {
967 let mut state = seed;
968 (0..len)
969 .map(|_| {
970 state = state.wrapping_add(0x9e37_79b9_7f4a_7c15);
971 let mut z = state;
972 z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
973 z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
974 z ^= z >> 31;
975 // Map to [-127, 127]; never -128, matching the converter contract.
976 ((z % 255) as i32 - 127) as i8
977 })
978 .collect()
979 }
980
981 /// The model's real decode GEMV shapes: (n, k) per §7 of the plan.
982 const MODEL_SHAPES: &[(usize, usize)] = &[
983 (2048, 1024), // q_proj / per-depth heads
984 (1024, 1024), // k_proj / v_proj
985 (1024, 2048), // o_proj
986 (3072, 1024), // gate/up_proj, primary head
987 (1024, 3072), // down_proj (binding talker K)
988 ];
989
990 #[test]
991 fn every_tier_is_exactly_equal_in_i32_at_every_model_shape() {
992 for &(n, k) in MODEL_SHAPES {
993 let a = pseudo_random_q8(k, 0x5eed_0001 ^ (n as u64) << 20 ^ k as u64);
994 let w = pseudo_random_q8(n * k, 0x5eed_0002 ^ (n as u64) << 20 ^ k as u64);
995 for row in [0, n / 2, n - 1] {
996 let w_row = &w[row * k..(row + 1) * k];
997 let reference = dot_i32(&a, w_row, Int8Tier::Scalar);
998 for tier in Int8Tier::available() {
999 assert_eq!(
1000 dot_i32(&a, w_row, tier),
1001 reference,
1002 "tier {} diverged at shape {n}x{k} row {row}",
1003 tier.as_str()
1004 );
1005 }
1006 }
1007 }
1008 }
1009
1010 #[test]
1011 fn every_tier_survives_the_all_extreme_reduction_at_the_binding_census_k() {
1012 // 127 * 127 * 8192 = 132,120,576 — the S8S8 all-extreme envelope at the largest census K.
1013 for k in [2048_usize, 3072, 4608, 7168, 8192] {
1014 let a = vec![127_i8; k];
1015 let b = vec![127_i8; k];
1016 let negative = vec![-127_i8; k];
1017 let expected = 127_i64 * 127 * k as i64;
1018 for tier in Int8Tier::available() {
1019 assert_eq!(
1020 i64::from(dot_i32(&a, &b, tier)),
1021 expected,
1022 "positive all-extreme diverged on {} at K={k}",
1023 tier.as_str()
1024 );
1025 assert_eq!(
1026 i64::from(dot_i32(&a, &negative, tier)),
1027 -expected,
1028 "negative all-extreme diverged on {} at K={k}",
1029 tier.as_str()
1030 );
1031 }
1032 }
1033 }
1034
1035 #[test]
1036 fn tail_lengths_that_defeat_block_boundaries_stay_exact() {
1037 // Exercise every SDOT path: <16 (pure tail), 16..64 (single-block loop), 64+tail.
1038 for len in [1_usize, 7, 15, 16, 17, 63, 64, 65, 100, 129] {
1039 let a = pseudo_random_q8(len, tail_seed(len));
1040 let b = pseudo_random_q8(len, tail_seed(len) ^ 1);
1041 let reference = dot_i32(&a, &b, Int8Tier::Scalar);
1042 for tier in Int8Tier::available() {
1043 assert_eq!(
1044 dot_i32(&a, &b, tier),
1045 reference,
1046 "len={len} {}",
1047 tier.as_str()
1048 );
1049 }
1050 }
1051 }
1052
1053 #[test]
1054 fn quantizer_matches_the_canonical_converter_semantics() {
1055 // Ties-to-even, clamp, zero-row scale, and the -128 exclusion. The cross-crate
1056 // byte-identity test against `ftts-artifacts` lives in `ftts-model-qwen`.
1057 let row = [
1058 -127.0_f32, -126.5, -125.5, -1.5, -0.5, 0.5, 1.5, 125.5, 126.5, 127.0,
1059 ];
1060 let mut q = [0_i8; 10];
1061 let scale = quantize_row_q8(&row, &mut q);
1062 assert_eq!(scale.to_bits(), 1.0_f32.to_bits());
1063 assert_eq!(q, [-127, -126, -126, -2, 0, 0, 2, 126, 126, 127]);
1064
1065 let zeros = [0.0_f32; 4];
1066 let mut qz = [1_i8; 4];
1067 assert_eq!(
1068 quantize_row_q8(&zeros, &mut qz).to_bits(),
1069 1.0_f32.to_bits()
1070 );
1071 assert_eq!(qz, [0, 0, 0, 0]);
1072
1073 let matrix = QuantizedMatrix::quantize(&[2.0, -1.0, 0.0, 3.0], 2, 2);
1074 assert_eq!(matrix.scales[0].to_bits(), (2.0_f32 / 127.0).to_bits());
1075 assert_eq!(matrix.scales[1].to_bits(), (3.0_f32 / 127.0).to_bits());
1076 assert!(matrix.data.iter().all(|&b| b != -128));
1077 }
1078
1079 #[test]
1080 fn dynamic_w8a8_linear_tracks_the_f32_reference_within_quant_error() {
1081 // Not a parity claim — a sanity bound that the dequant plumbing is wired correctly.
1082 let (n, k) = (64_usize, 128_usize);
1083 let mut weight = vec![0.0_f32; n * k];
1084 let mut x = vec![0.0_f32; k];
1085 let mut state = 0x1234_5678_u64;
1086 let mut next = || {
1087 state = state
1088 .wrapping_mul(6_364_136_223_846_793_005)
1089 .wrapping_add(1);
1090 ((state >> 33) as f32 / (1u64 << 31) as f32) - 1.0
1091 };
1092 for value in weight.iter_mut() {
1093 *value = next();
1094 }
1095 for value in x.iter_mut() {
1096 *value = next();
1097 }
1098 let quantized = QuantizedMatrix::quantize(&weight, n, k);
1099 let mut out_q8 = vec![0.0_f32; n];
1100 linear_q8_dynamic(&x, &quantized, None, 1, &mut out_q8, Int8Tier::Autovec);
1101
1102 let mut out_f32 = vec![0.0_f32; n];
1103 crate::f32ref::linear(&x, &weight, None, 1, k, n, &mut out_f32);
1104
1105 let dot = |a: &[f32], b: &[f32]| a.iter().zip(b).map(|(x, y)| x * y).sum::<f32>();
1106 let cosine = dot(&out_q8, &out_f32)
1107 / (dot(&out_q8, &out_q8).sqrt() * dot(&out_f32, &out_f32).sqrt());
1108 assert!(
1109 cosine > 0.999,
1110 "W8A8 dequant plumbing is broken: cosine {cosine}"
1111 );
1112 }
1113
1114 #[test]
1115 fn tiers_produce_bit_identical_f32_output_not_merely_close() {
1116 let (n, k) = (256_usize, 1024_usize);
1117 let weight: Vec<f32> = pseudo_random_q8(n * k, 77)
1118 .iter()
1119 .map(|&b| f32::from(b) / 64.0)
1120 .collect();
1121 let x: Vec<f32> = pseudo_random_q8(k, 78)
1122 .iter()
1123 .map(|&b| f32::from(b) / 64.0)
1124 .collect();
1125 let quantized = QuantizedMatrix::quantize(&weight, n, k);
1126 let mut reference = vec![0.0_f32; n];
1127 linear_q8_dynamic(&x, &quantized, None, 1, &mut reference, Int8Tier::Scalar);
1128 for tier in Int8Tier::available() {
1129 let mut out = vec![0.0_f32; n];
1130 linear_q8_dynamic(&x, &quantized, None, 1, &mut out, tier);
1131 for (index, (a, b)) in reference.iter().zip(&out).enumerate() {
1132 assert_eq!(
1133 a.to_bits(),
1134 b.to_bits(),
1135 "tier {} f32 output differs at {index}",
1136 tier.as_str()
1137 );
1138 }
1139 }
1140 }
1141
1142 fn tail_seed(len: usize) -> u64 {
1143 0x7a11_0000 ^ len as u64
1144 }
1145}