//! W4A16 Weight-Only Quantized Matrix Multiplication //! //! This module implements fused dequantize+GEMM for AWQ INT4 weight-only quantization. //! Weights remain in INT4 storage (4-bit precision, 2× memory savings over FP16) while //! activations are BF16/f32. The kernel dequantizes weight blocks **on-the-fly** during //! the inner product loop, eliminating the need to materialize a full-precision weight //! matrix before every forward pass. //! //! # Memory Layout //! //! The existing [`AWQQuantizedWeight`] stores weights as `Vec` where each element //! holds one INT4 value in the low four bits (signed, range −8…7). Groups of //! `group_size` consecutive weights (in row-major, flattened order) share a single //! `f32` scale and `i8` zero-point. //! //! Dequantization of a single weight element: //! //! ```text //! w_fp32 = (q_i4 - zero) * scale * act_scale[in_col] //! ``` //! //! # Nibble Packing Strategy //! //! When manually packing INT4 values into bytes (e.g., for GPU staging), this module //! uses **low-nibble-first** order: //! //! ```text //! byte = (val[2k] & 0x0F) | ((val[2k+1] & 0x0F) << 4) //! ``` //! //! Unpacking: //! - low nibble: `byte & 0x0F` → element at even index //! - high nibble: `(byte >> 4) & 0x0F` → element at odd index //! //! This matches the convention used by the MX kernel in `cuda_kernels/mx_kernels.cu`. //! //! # Example //! //! ```rust,ignore //! use rtx_compress::quantization::w4a16_matmul::w4a16_matmul_cpu; //! //! let result = weights.matmul_cpu(&activations, batch_size)?; //! ``` use crate::{ Result, error::{CompressionError, QuantizationError}, }; use super::advanced::AWQQuantizedWeight; // --------------------------------------------------------------------------- // BF16 helpers // --------------------------------------------------------------------------- /// Decode a little-endian BF16 byte pair to f32. /// /// BF16 occupies the high 16 bits of an IEEE 754 f32. We reconstruct the f32 /// by zero-extending the two bytes into the upper half of a 32-bit word. #[inline(always)] fn bf16_bytes_to_f32(lo: u8, hi: u8) -> f32 { // Interpret the two bytes as a u16 in little-endian order. let bits16 = u16::from_le_bytes([lo, hi]); // BF16 → f32: shift the 16-bit pattern into the high half of a u32. f32::from_bits((bits16 as u32) << 16) } // --------------------------------------------------------------------------- // Nibble pack / unpack (public for test visibility) // --------------------------------------------------------------------------- /// Pack two 4-bit signed values (each in the range −8…7, stored as i8) into a /// single byte using **low-nibble-first** order. /// /// # Panics /// /// Panics in debug builds if either value is outside `−8..=7`. #[inline(always)] pub fn pack_nibbles(low: i8, high: i8) -> u8 { debug_assert!((-8..=7).contains(&low), "low nibble out of INT4 range"); debug_assert!((-8..=7).contains(&high), "high nibble out of INT4 range"); ((low as u8) & 0x0F) | (((high as u8) & 0x0F) << 4) } /// Extract the **low** nibble (even-index element) from a packed byte and /// sign-extend it to i8. #[inline(always)] pub fn unpack_low_nibble(byte: u8) -> i8 { let nibble = byte & 0x0F; // Sign-extend 4-bit → 8-bit: if bit 3 is set the value is negative. if nibble & 0x08 != 0 { (nibble | 0xF0) as i8 } else { nibble as i8 } } /// Extract the **high** nibble (odd-index element) from a packed byte and /// sign-extend it to i8. #[inline(always)] pub fn unpack_high_nibble(byte: u8) -> i8 { let nibble = (byte >> 4) & 0x0F; if nibble & 0x08 != 0 { (nibble | 0xF0) as i8 } else { nibble as i8 } } // --------------------------------------------------------------------------- // CPU reference implementation // --------------------------------------------------------------------------- /// W4A16 fused dequantize-then-GEMM on the CPU. /// /// Computes `output[b, out_row] = Σ_i dequant(weights[out_row, i]) * activations[b, i]` /// without materialising the full-precision weight matrix. /// /// # Arguments /// /// * `weights` – AWQ-quantized weight matrix, shape `[out_features, in_features]`. /// * `activations` – Flat row-major f32 buffer of shape `[batch_size, in_features]`. /// * `batch_size` – Number of input vectors (rows of the activation buffer). /// /// # Returns /// /// Flat row-major f32 buffer of shape `[batch_size, out_features]`. /// /// # Errors /// /// Returns [`CompressionError`] if the weight shape is invalid or the activation /// buffer length is inconsistent with `batch_size`. pub fn w4a16_matmul_cpu( weights: &AWQQuantizedWeight, activations: &[f32], batch_size: usize, ) -> Result> { // ------------------------------------------------------------------ // Validate inputs // ------------------------------------------------------------------ if weights.shape.len() != 2 { return Err(CompressionError::Quantization( QuantizationError::InvalidConfig(format!( "w4a16_matmul_cpu: weight shape must be 2-D, got {} dimensions", weights.shape.len() )), )); } let out_features = weights.shape[0]; let in_features = weights.shape[1]; if in_features == 0 || out_features == 0 { return Err(CompressionError::Quantization( QuantizationError::InvalidConfig( "w4a16_matmul_cpu: weight dimensions must be non-zero".to_string(), ), )); } let expected_act_len = batch_size * in_features; if activations.len() != expected_act_len { return Err(CompressionError::Quantization( QuantizationError::InvalidConfig(format!( "w4a16_matmul_cpu: activation buffer length {} \ does not match batch_size={} × in_features={}", activations.len(), batch_size, in_features )), )); } let group_size = weights.group_size; if group_size == 0 { return Err(CompressionError::Quantization( QuantizationError::InvalidConfig( "w4a16_matmul_cpu: group_size must be non-zero".to_string(), ), )); } // Precompute group count for bounds checking. let num_weight_elements = out_features * in_features; let num_groups = (num_weight_elements + group_size - 1) / group_size; if weights.scales.len() < num_groups { return Err(CompressionError::Quantization( QuantizationError::InvalidConfig(format!( "w4a16_matmul_cpu: scales vec too short: got {}, need {}", weights.scales.len(), num_groups )), )); } if weights.zeros.len() < num_groups { return Err(CompressionError::Quantization( QuantizationError::InvalidConfig(format!( "w4a16_matmul_cpu: zeros vec too short: got {}, need {}", weights.zeros.len(), num_groups )), )); } if weights.quantized_data.len() < num_weight_elements { return Err(CompressionError::Quantization( QuantizationError::InvalidConfig(format!( "w4a16_matmul_cpu: quantized_data too short: got {}, need {}", weights.quantized_data.len(), num_weight_elements )), )); } // ------------------------------------------------------------------ // Output buffer [batch_size, out_features] // ------------------------------------------------------------------ let mut output = vec![0.0f32; batch_size * out_features]; // ------------------------------------------------------------------ // Fused dequant + dot-product // // Outer loops: (batch_row, out_row). // Inner loop: groups of `group_size` input features. // // Group accounting: the existing AWQQuantizer assigns groups linearly // across the *entire* flattened weight array (not per-row). Group index // for element at (out_row, in_col) is therefore: // // flat_idx = out_row * in_features + in_col // group_idx = flat_idx / group_size // // This matches the indexing in AWQQuantizedWeight::dequantize(). // ------------------------------------------------------------------ for b in 0..batch_size { let act_row_offset = b * in_features; for out_row in 0..out_features { let mut accumulator = 0.0f64; let weight_row_offset = out_row * in_features; // Walk in_features in group-aligned chunks for cache efficiency. let mut in_col = 0usize; while in_col < in_features { // Compute group metadata for the first element of this slice. let flat_idx = weight_row_offset + in_col; let group_idx = flat_idx / group_size; // How many elements remain in this group starting at in_col? // The group boundary within the row: // next_group_flat = (group_idx + 1) * group_size // next_group_in_col = next_group_flat - weight_row_offset // (clamped to in_features) let next_group_flat = (group_idx + 1) * group_size; let next_in_col = (next_group_flat.saturating_sub(weight_row_offset)).min(in_features); let scale = weights.scales[group_idx]; let zero = weights.zeros[group_idx] as f32; // Inner loop: all elements sharing the same scale/zero. for col in in_col..next_in_col { let q = weights.quantized_data[weight_row_offset + col] as f32; // Per-column activation scale (inverse of the activation-derived // scale applied during AWQ quantisation). let act_scale = weights.activation_scales.get(col).copied().unwrap_or(1.0); let w_fp32 = (q - zero) * scale * act_scale; let a_fp32 = activations[act_row_offset + col]; accumulator += (w_fp32 * a_fp32) as f64; } in_col = next_in_col; } output[b * out_features + out_row] = accumulator as f32; } } Ok(output) } // --------------------------------------------------------------------------- // Extension trait — attaches matmul_cpu to AWQQuantizedWeight // --------------------------------------------------------------------------- /// Extension trait that adds W4A16 fused matmul to [`AWQQuantizedWeight`]. pub trait AWQQuantizedWeightExt { /// Fused dequantize + GEMM on the CPU. /// /// Equivalent to `w4a16_matmul_cpu(self, activations, batch_size)`. fn matmul_cpu(&self, activations: &[f32], batch_size: usize) -> Result>; } impl AWQQuantizedWeightExt for AWQQuantizedWeight { fn matmul_cpu(&self, activations: &[f32], batch_size: usize) -> Result> { w4a16_matmul_cpu(self, activations, batch_size) } } // --------------------------------------------------------------------------- // GPU stub (CUDA feature gate) // --------------------------------------------------------------------------- /// W4A16 GPU matmul stub — returns `NotImplemented` until the kernel is wired. /// /// # Planned kernel: `w4a16_dequant_gemm` /// /// Thread layout: /// - One warpgroup (4 warps = 128 threads) per output tile of 128 outputs × 64 inputs. /// - Each warp unrolls the inner loop in groups of 8: /// - Load 4 bytes (8 nibbles) from `weights_packed` via `__ldg()` (read-only cache). /// - Unpack 8 INT4 values: `lo[k] = (byte >> 0) & 0x0F`, `hi[k] = (byte >> 4) & 0x0F`, /// sign-extend each to i8. /// - Load the corresponding group scale and zero-point once per group boundary. /// - Dequantize: `w_bf16[k] = __float2bfloat16((q[k] - zero) * scale * act_scale[col])`. /// - Multiply: `acc_f32 += __bfloat162float(w_bf16[k]) * __bfloat162float(a_bf16[k])`. /// - Warp-reduce the 128 partial sums with `__reduce_add_sync`. /// - Write one BF16 output per warpgroup lane-0: `output[b, out] = __float2bfloat16(acc_f32)`. /// /// Memory traffic analysis at FP16 throughput (RTX 5060 Ti, 448 GB/s): /// - Weights: `out × in / 2` bytes (INT4 packed) — 2× vs FP16. /// - Scales/zeros: `out × in / group_size × 2` bytes — negligible at group_size=128. /// - Activations: `batch × in × 2` bytes (BF16). /// - Output: `batch × out × 2` bytes (BF16). /// /// Expected: ~2× bandwidth reduction vs FP16 matmul → ~2× throughput improvement /// on memory-bound large-model inference. #[cfg(feature = "cuda")] pub fn w4a16_matmul_gpu( _weights: &AWQQuantizedWeight, _activations: &[f32], _batch_size: usize, ) -> Result> { Err(CompressionError::Quantization( QuantizationError::InvalidConfig( "w4a16 GPU kernel not yet linked — compile src/quantization/cuda/w4a16_gemm.cu \ and wire the PTX through build.rs" .to_string(), ), )) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::quantization::advanced::AWQQuantizedWeight; // ----------------------------------------------------------------------- // Helper: build a minimal AWQQuantizedWeight with uniform scale/zero. // // Parameters // ---------- // out_features, in_features — matrix dimensions // quant_values — INT4 values (one per element, row-major) // scale — single group scale (we set group_size = total elements so // there is exactly one group, making assertions simple) // zero — zero-point applied to all elements // act_scales — per-column activation-derived inverse scales // ----------------------------------------------------------------------- fn make_weight( out_features: usize, in_features: usize, quant_values: Vec, scale: f32, zero: i8, act_scales: Vec, ) -> AWQQuantizedWeight { let total = out_features * in_features; // One group covering all elements keeps the arithmetic transparent. let group_size = total.max(1); AWQQuantizedWeight { quantized_data: quant_values, scales: vec![scale], zeros: vec![zero], activation_scales: act_scales, group_size, bit_width: 4, shape: vec![out_features, in_features], } } // ----------------------------------------------------------------------- // Nibble pack / unpack // ----------------------------------------------------------------------- #[test] fn test_nibble_unpack_low_nibble() { // 0xAB → low nibble = 0xB = 11 (unsigned), but sign-extended 4-bit: // 0xB = 0b1011 → bit3 set → negative → 0b1111_1011 = -5 as i8. // The value 11 in 4-bit signed is -5. let byte = 0xABu8; let low = unpack_low_nibble(byte); assert_eq!(low, -5i8, "0xB should sign-extend to -5"); } #[test] fn test_nibble_unpack_high_nibble() { // 0xAB → high nibble = 0xA = 10 (unsigned); 4-bit signed: -6. let byte = 0xABu8; let high = unpack_high_nibble(byte); assert_eq!(high, -6i8, "0xA should sign-extend to -6"); } #[test] fn test_nibble_pack_round_trip() { // Positive values let packed = pack_nibbles(3, 5); assert_eq!(unpack_low_nibble(packed), 3); assert_eq!(unpack_high_nibble(packed), 5); // Negative values let packed2 = pack_nibbles(-1, -8); assert_eq!(unpack_low_nibble(packed2), -1); assert_eq!(unpack_high_nibble(packed2), -8); // Zero values let packed3 = pack_nibbles(0, 0); assert_eq!(unpack_low_nibble(packed3), 0); assert_eq!(unpack_high_nibble(packed3), 0); } // ----------------------------------------------------------------------- // Identity weights: all dequant to 1.0 → output = row-sum of activations // ----------------------------------------------------------------------- #[test] fn test_w4a16_identity_weights() { // 1 output row, 4 input features. // We want dequant(q) = 1.0 for every weight. // Choose: zero = 0, scale = 1.0, q = 1 for all weights. // act_scale = 1.0 (identity). let out_f = 1usize; let in_f = 4usize; let w = make_weight(out_f, in_f, vec![1; 4], 1.0, 0, vec![1.0; 4]); let activations = vec![2.0f32, 3.0, 4.0, 5.0]; // sum = 14 let result = w4a16_matmul_cpu(&w, &activations, 1).unwrap(); assert_eq!(result.len(), 1); // output[0] = 1*2 + 1*3 + 1*4 + 1*5 = 14 assert!((result[0] - 14.0).abs() < 1e-4, "got {}", result[0]); } // ----------------------------------------------------------------------- // Output shape check // ----------------------------------------------------------------------- #[test] fn test_w4a16_matmul_shape() { let out_f = 3usize; let in_f = 4usize; let total = out_f * in_f; let w = make_weight(out_f, in_f, vec![0i8; total], 1.0, 0, vec![1.0; in_f]); let activations = vec![1.0f32; 2 * in_f]; // batch_size = 2 let result = w4a16_matmul_cpu(&w, &activations, 2).unwrap(); // Expected shape: [batch_size=2, out_features=3] → 6 elements. assert_eq!(result.len(), 6, "output length must be batch*out_features"); } // ----------------------------------------------------------------------- // Zero activations → zero output // ----------------------------------------------------------------------- #[test] fn test_w4a16_zero_activations() { let out_f = 2usize; let in_f = 4usize; let total = out_f * in_f; let w = make_weight(out_f, in_f, vec![3i8; total], 0.5, 0, vec![1.0; in_f]); let activations = vec![0.0f32; 1 * in_f]; // all zero let result = w4a16_matmul_cpu(&w, &activations, 1).unwrap(); for (i, &v) in result.iter().enumerate() { assert!(v.abs() < 1e-6, "element {} should be zero, got {}", i, v); } } // ----------------------------------------------------------------------- // Batch size 1 — single-sample inference // ----------------------------------------------------------------------- #[test] fn test_w4a16_batch_size_1() { // 2 output rows, 2 input features. // W = [[1, 2], [3, 4]] (as INT4), scale=1, zero=0, act_scale=1. // act = [1, 1] // out[0] = 1*1 + 2*1 = 3 // out[1] = 3*1 + 4*1 = 7 let w = make_weight(2, 2, vec![1, 2, 3, 4], 1.0, 0, vec![1.0; 2]); let activations = vec![1.0f32, 1.0]; let result = w4a16_matmul_cpu(&w, &activations, 1).unwrap(); assert_eq!(result.len(), 2); assert!((result[0] - 3.0).abs() < 1e-4, "out[0]={}", result[0]); assert!((result[1] - 7.0).abs() < 1e-4, "out[1]={}", result[1]); } // ----------------------------------------------------------------------- // Group size 128 (standard AWQ config) // ----------------------------------------------------------------------- #[test] fn test_w4a16_group_size_128() { // 1 output row, 256 input features = 2 groups of 128. // Group 0: scale=2.0, zero=0; q values all = 1 // Group 1: scale=3.0, zero=0; q values all = 1 // activations all = 1.0 // out = sum_{0..127}(1*2*1*1) + sum_{128..255}(1*3*1*1) // = 128*2 + 128*3 = 256 + 384 = 640 let out_f = 1usize; let in_f = 256usize; let group_size = 128usize; let quant_data = vec![1i8; in_f]; let scales = vec![2.0f32, 3.0]; let zeros = vec![0i8; 2]; let act_scales = vec![1.0f32; in_f]; let activations = vec![1.0f32; in_f]; let w = AWQQuantizedWeight { quantized_data: quant_data, scales, zeros, activation_scales: act_scales, group_size, bit_width: 4, shape: vec![out_f, in_f], }; let result = w4a16_matmul_cpu(&w, &activations, 1).unwrap(); assert_eq!(result.len(), 1); assert!( (result[0] - 640.0).abs() < 1e-2, "expected 640, got {}", result[0] ); } // ----------------------------------------------------------------------- // w4a16_matmul_cpu vs dequantize().dot(act) — key correctness check // // We use a small, randomly-seeded matrix so the test is deterministic // and easy to reason about, then compare both paths within 1e-3 tolerance. // ----------------------------------------------------------------------- #[test] fn test_w4a16_vs_dequant_then_matmul() { use rtx_tensor::{Device, Tensor}; // 4 output rows, 8 input features, group_size = 4 (2 groups per row). let out_f = 4usize; let in_f = 8usize; let group_size = 4usize; let total = out_f * in_f; let num_groups = total / group_size; // = 8 // Deterministic quant values in the INT4 range −8…7. // We cycle through a small sequence to get varied values. let quant_pattern: Vec = [1i8, -2, 3, -4, 5, -1, 2, 0] .iter() .cycle() .take(total) .copied() .collect(); // Two distinct scales and zero-points to exercise non-trivial groups. let mut scales = Vec::with_capacity(num_groups); let mut zeros = Vec::with_capacity(num_groups); for g in 0..num_groups { scales.push(0.5 + 0.1 * (g as f32)); zeros.push(if g % 2 == 0 { 1i8 } else { -1i8 }); } // Uniform activation scales for simplicity (no per-column correction). let act_scales = vec![1.0f32; in_f]; let w = AWQQuantizedWeight { quantized_data: quant_pattern, scales: scales.clone(), zeros: zeros.clone(), activation_scales: act_scales.clone(), group_size, bit_width: 4, shape: vec![out_f, in_f], }; // Activations: [batch=2, in_f=8] let batch = 2usize; let activations: Vec = (0..batch * in_f).map(|i| (i as f32) * 0.25).collect(); // --- Path A: fused w4a16_matmul_cpu --- let fused_result = w4a16_matmul_cpu(&w, &activations, batch).unwrap(); // --- Path B: dequantize() then explicit matmul --- let dequant_tensor: Tensor = w.dequantize().unwrap(); let dequant_data: Vec = dequant_tensor.to_vec().unwrap(); // dequant_data is [out_f, in_f] row-major. let mut ref_result = vec![0.0f32; batch * out_f]; for b in 0..batch { for o in 0..out_f { let mut acc = 0.0f32; for i in 0..in_f { acc += dequant_data[o * in_f + i] * activations[b * in_f + i]; } ref_result[b * out_f + o] = acc; } } // Compare: tolerance 1e-3 (both paths use f32; small rounding differences are fine). let tolerance = 1e-3f32; assert_eq!( fused_result.len(), ref_result.len(), "output length mismatch" ); for (idx, (&fused, &reference)) in fused_result.iter().zip(ref_result.iter()).enumerate() { let diff = (fused - reference).abs(); assert!( diff <= tolerance, "element {}: fused={:.6}, reference={:.6}, diff={:.6} > tolerance={:.6}", idx, fused, reference, diff, tolerance ); } } // ----------------------------------------------------------------------- // Batch size 4 — multi-sample // ----------------------------------------------------------------------- #[test] fn test_w4a16_batch_size_4() { // Simple 2×2 weight matrix, batch=4. // W = [[2, 0], [0, 2]], scale=1, zero=0 → dequant = [[2,0],[0,2]] (identity*2) // activations[b] = [b, b] // out[b, 0] = 2*b + 0*b = 2b // out[b, 1] = 0*b + 2*b = 2b let w = make_weight(2, 2, vec![2, 0, 0, 2], 1.0, 0, vec![1.0; 2]); let activations: Vec = (0..4).flat_map(|b| vec![b as f32, b as f32]).collect(); let result = w4a16_matmul_cpu(&w, &activations, 4).unwrap(); assert_eq!(result.len(), 8); for b in 0..4usize { let expected = 2.0 * b as f32; assert!( (result[b * 2] - expected).abs() < 1e-4, "b={} out[0]: got {}, want {}", b, result[b * 2], expected ); assert!( (result[b * 2 + 1] - expected).abs() < 1e-4, "b={} out[1]: got {}, want {}", b, result[b * 2 + 1], expected ); } } // ----------------------------------------------------------------------- // Error: activation buffer length mismatch // ----------------------------------------------------------------------- #[test] fn test_w4a16_activation_length_error() { let w = make_weight(2, 4, vec![0i8; 8], 1.0, 0, vec![1.0; 4]); // Provide only 3 elements instead of batch=1 * in_f=4 = 4. let activations = vec![1.0f32; 3]; let result = w4a16_matmul_cpu(&w, &activations, 1); assert!(result.is_err(), "should fail on activation length mismatch"); } // ----------------------------------------------------------------------- // AWQQuantizedWeightExt trait method // ----------------------------------------------------------------------- #[test] fn test_awq_ext_matmul_cpu() { let w = make_weight(1, 2, vec![1, 1], 1.0, 0, vec![1.0; 2]); let acts = vec![3.0f32, 4.0]; // Use extension trait method. let result = w.matmul_cpu(&acts, 1).unwrap(); assert_eq!(result.len(), 1); // 1*3 + 1*4 = 7 assert!((result[0] - 7.0).abs() < 1e-4, "got {}", result[0]); } }