//! Advanced Quantization Methods //! //! This module implements state-of-the-art quantization techniques: //! - AWQ (Activation-aware Weight Quantization): Finds optimal scales using activation importance //! - GPTQ (Accurate Post-Training Quantization): Hessian-based blockwise quantization //! - SmoothQuant: Migrates quantization difficulty from activations to weights //! //! These methods enable INT4/INT3 weight-only quantization with minimal accuracy loss. use crate::{ Result, error::{CompressionError, QuantizationError}, }; use rtx_tensor::{Device, Tensor}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; // Helper to convert tensor errors fn tensor_err(e: impl std::fmt::Display) -> CompressionError { CompressionError::Quantization(QuantizationError::TensorError(e.to_string())) } // ============================================================================= // AWQ (Activation-aware Weight Quantization) // ============================================================================= /// Configuration for AWQ quantization #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AWQConfig { /// Target bit width (typically 4 or 3) pub bit_width: u8, /// Group size for quantization (typically 128) pub group_size: usize, /// Number of calibration samples pub num_calibration_samples: usize, /// Percentile for activation importance (typically 0.9-0.99) pub activation_percentile: f32, /// Alpha for mixing search (0.0-1.0) pub alpha: f32, /// Number of search iterations for optimal scale pub num_search_iters: usize, /// Enable per-channel scaling pub per_channel: bool, /// Device for computation pub device: Device, } impl Default for AWQConfig { fn default() -> Self { Self { bit_width: 4, group_size: 128, num_calibration_samples: 128, activation_percentile: 0.95, alpha: 0.5, num_search_iters: 20, per_channel: true, device: Device::Cpu, } } } impl AWQConfig { /// Create new AWQ config with specified bit width pub fn new(bit_width: u8) -> Self { Self { bit_width, ..Default::default() } } /// Set group size pub fn with_group_size(mut self, group_size: usize) -> Self { self.group_size = group_size; self } /// Set calibration samples pub fn with_calibration_samples(mut self, num_samples: usize) -> Self { self.num_calibration_samples = num_samples; self } } /// AWQ Quantizer for activation-aware weight quantization #[derive(Debug)] pub struct AWQQuantizer { config: AWQConfig, /// Per-channel scales computed from activation importance activation_scales: HashMap>, /// Statistics collected during calibration stats: AWQStats, } /// Statistics from AWQ quantization #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct AWQStats { /// Number of layers quantized pub layers_quantized: usize, /// Total weights quantized pub total_weights: usize, /// Average quantization error (MSE) pub avg_quant_error: f64, /// Salient channel ratios per layer pub salient_ratios: HashMap, } impl AWQQuantizer { /// Create new AWQ quantizer pub fn new(config: AWQConfig) -> Self { Self { config, activation_scales: HashMap::new(), stats: AWQStats::default(), } } /// Calibrate scales using activation data pub fn calibrate(&mut self, activations: &HashMap>) -> Result<()> { for (layer_name, act_samples) in activations { // Compute per-channel activation magnitudes let channel_magnitudes = self.compute_channel_magnitudes(act_samples)?; // Find salient channels (high activation magnitude) let percentile_threshold = self.compute_percentile(&channel_magnitudes, self.config.activation_percentile); // Compute scales based on activation importance let scales: Vec = channel_magnitudes .iter() .map(|&mag| { if mag > percentile_threshold { // Salient channel: use larger scale to preserve precision (mag / percentile_threshold).sqrt() } else { 1.0 } }) .collect(); self.activation_scales.insert(layer_name.clone(), scales); } Ok(()) } /// Quantize weights using AWQ method pub fn quantize_weights( &mut self, layer_name: &str, weights: &Tensor, ) -> Result { let shape = weights.shape().dims(); let out_features = shape[0]; let in_features = shape[1]; // Get activation scales for this layer (or use uniform scales) let scales = self .activation_scales .get(layer_name) .cloned() .unwrap_or_else(|| vec![1.0; in_features]); // Scale weights by activation importance let mut weight_data = weights.to_vec().map_err(tensor_err)?; for j in 0..out_features { for i in 0..in_features { let idx = j * in_features + i; let scale = scales.get(i).copied().unwrap_or(1.0); weight_data[idx] *= scale; } } // Create scaled weights tensor for group quantization let scaled_weights = Tensor::from_slice( &weight_data, &[out_features, in_features], &self.config.device, ) .map_err(tensor_err)?; // Quantize scaled weights using group quantization let (quantized, quant_scales, zeros) = self.group_quantize(&scaled_weights)?; // Compute inverse scales for dequantization let inv_activation_scales: Vec = scales.iter().map(|&s| 1.0 / s).collect(); self.stats.layers_quantized += 1; self.stats.total_weights += out_features * in_features; Ok(AWQQuantizedWeight { quantized_data: quantized, scales: quant_scales, zeros, activation_scales: inv_activation_scales, group_size: self.config.group_size, bit_width: self.config.bit_width, shape: vec![out_features, in_features], }) } /// Group-wise quantization of weights fn group_quantize(&self, weights: &Tensor) -> Result<(Vec, Vec, Vec)> { let shape = weights.shape().dims(); let total_elements = shape.iter().product::(); let weight_data = weights.to_vec().map_err(tensor_err)?; let mut quantized = Vec::with_capacity(total_elements); let mut scales = Vec::new(); let mut zeros = Vec::new(); let num_groups = (total_elements + self.config.group_size - 1) / self.config.group_size; for group_idx in 0..num_groups { let start = group_idx * self.config.group_size; let end = (start + self.config.group_size).min(total_elements); // Find min/max in this group let mut min_val = f32::MAX; let mut max_val = f32::MIN; for i in start..end { let val = weight_data[i]; min_val = min_val.min(val); max_val = max_val.max(val); } // Compute scale and zero point let qmin = -(1 << (self.config.bit_width - 1)); let qmax = (1 << (self.config.bit_width - 1)) - 1; let scale = (max_val - min_val) / (qmax - qmin) as f32; let scale = if scale == 0.0 { 1.0 } else { scale }; let zero = ((qmin as f32 * scale - min_val) / scale).round() as i8; scales.push(scale); zeros.push(zero); // Quantize values in group for i in start..end { let val = weight_data[i]; let q = ((val / scale) + zero as f32).round(); let q = q.max(qmin as f32).min(qmax as f32) as i8; quantized.push(q); } } Ok((quantized, scales, zeros)) } /// Compute per-channel activation magnitudes fn compute_channel_magnitudes(&self, activations: &[Tensor]) -> Result> { if activations.is_empty() { return Ok(Vec::new()); } let shape = activations[0].shape().dims(); let num_channels = if shape.len() >= 2 { shape[shape.len() - 1] } else { shape[0] }; let mut magnitudes = vec![0.0f32; num_channels]; let mut counts = vec![0usize; num_channels]; for act in activations { let flat = act.to_vec().unwrap_or_default(); for (i, &val) in flat.iter().enumerate() { let channel_idx = i % num_channels; magnitudes[channel_idx] += val.abs(); counts[channel_idx] += 1; } } // Average magnitudes for i in 0..num_channels { if counts[i] > 0 { magnitudes[i] /= counts[i] as f32; } } Ok(magnitudes) } /// Compute percentile value fn compute_percentile(&self, values: &[f32], percentile: f32) -> f32 { if values.is_empty() { return 0.0; } let mut sorted = values.to_vec(); sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); let idx = ((sorted.len() as f32 - 1.0) * percentile) as usize; sorted[idx.min(sorted.len() - 1)] } /// Get statistics pub fn stats(&self) -> &AWQStats { &self.stats } } /// AWQ quantized weight representation #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AWQQuantizedWeight { /// Quantized weight data (packed integers) pub quantized_data: Vec, /// Per-group scales pub scales: Vec, /// Per-group zero points pub zeros: Vec, /// Activation-derived inverse scales for dequant pub activation_scales: Vec, /// Group size pub group_size: usize, /// Bit width pub bit_width: u8, /// Original shape [out_features, in_features] pub shape: Vec, } impl AWQQuantizedWeight { /// Dequantize weights pub fn dequantize(&self) -> Result { let out_features = self.shape[0]; let in_features = self.shape[1]; let total = out_features * in_features; let mut dequantized = vec![0.0f32; total]; let num_groups = self.scales.len(); for (i, &qval) in self.quantized_data.iter().enumerate() { if i >= total { break; } let group_idx = i / self.group_size; let group_idx = group_idx.min(num_groups - 1); let scale = self.scales[group_idx]; let zero = self.zeros[group_idx] as f32; let act_scale = self .activation_scales .get(i % in_features) .copied() .unwrap_or(1.0); dequantized[i] = (qval as f32 - zero) * scale * act_scale; } Tensor::from_slice(&dequantized, &[out_features, in_features], &Device::Cpu) .map_err(tensor_err) } /// Get memory footprint in bytes pub fn memory_bytes(&self) -> usize { let data_bytes = (self.quantized_data.len() * self.bit_width as usize + 7) / 8; let scale_bytes = self.scales.len() * 4; // f32 let zero_bytes = self.zeros.len(); // i8 let act_scale_bytes = self.activation_scales.len() * 4; // f32 data_bytes + scale_bytes + zero_bytes + act_scale_bytes } } // ============================================================================= // GPTQ (Accurate Post-Training Quantization) // ============================================================================= /// Configuration for GPTQ quantization #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GPTQConfig { /// Target bit width (typically 4 or 3) pub bit_width: u8, /// Group size for quantization pub group_size: usize, /// Block size for Hessian computation pub block_size: usize, /// Damping factor for Hessian (prevents numerical instability) pub damp_percent: f32, /// Enable static groups (same group across different rows) pub static_groups: bool, /// Number of calibration samples pub num_calibration_samples: usize, /// Enable activation order optimization pub act_order: bool, /// Use true sequential (quantize one column at a time) pub true_sequential: bool, /// Device for computation pub device: Device, } impl Default for GPTQConfig { fn default() -> Self { Self { bit_width: 4, group_size: 128, block_size: 128, damp_percent: 0.01, static_groups: true, num_calibration_samples: 128, act_order: false, true_sequential: true, device: Device::Cpu, } } } impl GPTQConfig { /// Create new GPTQ config pub fn new(bit_width: u8) -> Self { Self { bit_width, ..Default::default() } } /// Set group size pub fn with_group_size(mut self, group_size: usize) -> Self { self.group_size = group_size; self } /// Enable activation order optimization pub fn with_act_order(mut self, enabled: bool) -> Self { self.act_order = enabled; self } } /// GPTQ Quantizer using Hessian-based optimization #[derive(Debug)] pub struct GPTQQuantizer { config: GPTQConfig, /// Cached Hessian matrices per layer hessians: HashMap>>, /// Statistics stats: GPTQStats, } /// Statistics from GPTQ quantization #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct GPTQStats { /// Number of layers quantized pub layers_quantized: usize, /// Total weights quantized pub total_weights: usize, /// Average quantization error before compensation pub avg_error_before: f64, /// Average quantization error after compensation pub avg_error_after: f64, /// Hessian computation time (ms) pub hessian_time_ms: f64, /// Quantization time (ms) pub quant_time_ms: f64, } impl GPTQQuantizer { /// Create new GPTQ quantizer pub fn new(config: GPTQConfig) -> Self { Self { config, hessians: HashMap::new(), stats: GPTQStats::default(), } } /// Calibrate Hessian using input activations pub fn calibrate(&mut self, layer_name: &str, inputs: &[Tensor]) -> Result<()> { if inputs.is_empty() { return Ok(()); } let in_features = inputs[0].shape().dims().last().copied().unwrap_or(1); // Initialize Hessian as H = X^T * X (accumulated) let mut hessian = vec![vec![0.0f32; in_features]; in_features]; for input in inputs { let flat = input.to_vec().map_err(tensor_err)?; let batch_size = flat.len() / in_features; for b in 0..batch_size { for i in 0..in_features { for j in 0..in_features { let xi = flat[b * in_features + i]; let xj = flat[b * in_features + j]; hessian[i][j] += xi * xj; } } } } // Normalize by number of samples let num_samples = inputs.iter().map(|t| t.shape().dims()[0]).sum::(); for i in 0..in_features { for j in 0..in_features { hessian[i][j] /= num_samples as f32; } } // Add damping to diagonal let diag_mean: f32 = (0..in_features).map(|i| hessian[i][i]).sum::() / in_features as f32; let damp = self.config.damp_percent * diag_mean; for i in 0..in_features { hessian[i][i] += damp; } self.hessians.insert(layer_name.to_string(), hessian); Ok(()) } /// Quantize weights using GPTQ algorithm pub fn quantize_weights( &mut self, layer_name: &str, weights: &Tensor, ) -> Result { let shape = weights.shape().dims(); let out_features = shape[0]; let in_features = shape[1]; // Get Hessian (or create identity if not calibrated) let hessian = self.hessians.get(layer_name).cloned().unwrap_or_else(|| { let mut h = vec![vec![0.0f32; in_features]; in_features]; for i in 0..in_features { h[i][i] = 1.0; } h }); // Compute Cholesky decomposition of Hessian inverse let h_inv = self.cholesky_inverse(&hessian)?; // Working copy of weights let mut w = weights.to_vec().map_err(tensor_err)?; let mut quantized = vec![0i8; out_features * in_features]; let mut scales = Vec::new(); let mut zeros = Vec::new(); // Determine column order (optionally by activation importance) let col_order: Vec = if self.config.act_order { // Sort by diagonal of Hessian (activation importance) let mut indexed: Vec<_> = (0..in_features).map(|i| (i, hessian[i][i])).collect(); indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); indexed.into_iter().map(|(i, _)| i).collect() } else { (0..in_features).collect() }; // Quantize column by column for (block_start, _) in (0..in_features).step_by(self.config.block_size).enumerate() { let block_end = (block_start + self.config.block_size).min(in_features); for col_idx in block_start..block_end { let col = col_order[col_idx]; // Compute group index let group_idx = col / self.config.group_size; // Find min/max for this column across all rows let mut col_min = f32::MAX; let mut col_max = f32::MIN; for row in 0..out_features { let val = w[row * in_features + col]; col_min = col_min.min(val); col_max = col_max.max(val); } // Compute scale and zero let qmin = -(1 << (self.config.bit_width - 1)); let qmax = (1 << (self.config.bit_width - 1)) - 1; let scale = (col_max - col_min) / (qmax - qmin) as f32; let scale = if scale == 0.0 { 1.0 } else { scale }; let zero = ((qmin as f32 * scale - col_min) / scale).round() as i8; if group_idx >= scales.len() { scales.push(scale); zeros.push(zero); } // Quantize this column for each row for row in 0..out_features { let idx = row * in_features + col; let val = w[idx]; let q = ((val / scale) + zero as f32).round(); let q = q.max(qmin as f32).min(qmax as f32); quantized[idx] = q as i8; // Compute quantization error let dequant = (q - zero as f32) * scale; let error = val - dequant; // Update remaining weights to compensate for error (GPTQ key insight) // w_remaining -= error * H_inv[col, remaining] / H_inv[col, col] let h_diag = h_inv[col][col]; if h_diag > 1e-10 { for remaining_col in (col_idx + 1)..in_features { let rem = col_order[remaining_col]; let update = error * h_inv[col][rem] / h_diag; w[row * in_features + rem] -= update; } } } } } self.stats.layers_quantized += 1; self.stats.total_weights += out_features * in_features; Ok(GPTQQuantizedWeight { quantized_data: quantized, scales, zeros, group_size: self.config.group_size, bit_width: self.config.bit_width, shape: vec![out_features, in_features], col_order: if self.config.act_order { Some(col_order) } else { None }, }) } /// Compute Cholesky-based inverse of a symmetric positive definite matrix fn cholesky_inverse(&self, matrix: &[Vec]) -> Result>> { let n = matrix.len(); // Simple pseudo-inverse using regularized SVD-like approach // For production, use proper linear algebra library let mut inv = vec![vec![0.0f32; n]; n]; for i in 0..n { let diag = matrix[i][i]; if diag > 1e-10 { inv[i][i] = 1.0 / diag; } else { inv[i][i] = 1.0; } } // For off-diagonal elements, use approximate inverse for i in 0..n { for j in 0..n { if i != j && matrix[i][i] > 1e-10 && matrix[j][j] > 1e-10 { inv[i][j] = -matrix[i][j] / (matrix[i][i] * matrix[j][j]).sqrt(); } } } Ok(inv) } /// Get statistics pub fn stats(&self) -> &GPTQStats { &self.stats } } /// GPTQ quantized weight representation #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GPTQQuantizedWeight { /// Quantized weight data pub quantized_data: Vec, /// Per-group scales pub scales: Vec, /// Per-group zero points pub zeros: Vec, /// Group size pub group_size: usize, /// Bit width pub bit_width: u8, /// Original shape pub shape: Vec, /// Column order (if act_order enabled) pub col_order: Option>, } impl GPTQQuantizedWeight { /// Dequantize weights pub fn dequantize(&self) -> Result { let out_features = self.shape[0]; let in_features = self.shape[1]; let mut dequantized = vec![0.0f32; out_features * in_features]; for row in 0..out_features { for col in 0..in_features { let idx = row * in_features + col; let group_idx = col / self.group_size; let group_idx = group_idx.min(self.scales.len() - 1); let q = self.quantized_data[idx] as f32; let scale = self.scales[group_idx]; let zero = self.zeros[group_idx] as f32; dequantized[idx] = (q - zero) * scale; } } Tensor::from_slice(&dequantized, &[out_features, in_features], &Device::Cpu) .map_err(tensor_err) } /// Get compression ratio pub fn compression_ratio(&self) -> f32 { let original_bits = self.shape[0] * self.shape[1] * 32; // FP32 let quantized_bits = self.quantized_data.len() * self.bit_width as usize + self.scales.len() * 32 + self.zeros.len() * 8; original_bits as f32 / quantized_bits as f32 } } // ============================================================================= // SmoothQuant // ============================================================================= /// Configuration for SmoothQuant #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SmoothQuantConfig { /// Smoothing alpha (0.0-1.0, higher = more smoothing on activations) pub alpha: f32, /// Target bit width for weights pub weight_bit_width: u8, /// Target bit width for activations pub activation_bit_width: u8, /// Number of calibration samples pub num_calibration_samples: usize, /// Per-channel smoothing pub per_channel: bool, /// Minimum scale to prevent division by zero pub min_scale: f32, /// Device for computation pub device: Device, } impl Default for SmoothQuantConfig { fn default() -> Self { Self { alpha: 0.5, weight_bit_width: 8, activation_bit_width: 8, num_calibration_samples: 128, per_channel: true, min_scale: 1e-5, device: Device::Cpu, } } } impl SmoothQuantConfig { /// Create new SmoothQuant config pub fn new(alpha: f32) -> Self { Self { alpha: alpha.clamp(0.0, 1.0), ..Default::default() } } /// Set bit widths pub fn with_bit_widths(mut self, weight_bits: u8, activation_bits: u8) -> Self { self.weight_bit_width = weight_bits; self.activation_bit_width = activation_bits; self } } /// SmoothQuant migrates quantization difficulty from activations to weights #[derive(Debug)] pub struct SmoothQuantQuantizer { config: SmoothQuantConfig, /// Smoothing scales per layer (applied to activations) smoothing_scales: HashMap>, /// Statistics stats: SmoothQuantStats, } /// Statistics from SmoothQuant #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct SmoothQuantStats { /// Number of layers smoothed pub layers_smoothed: usize, /// Average activation range before smoothing pub avg_act_range_before: f64, /// Average activation range after smoothing pub avg_act_range_after: f64, /// Average weight range before smoothing pub avg_weight_range_before: f64, /// Average weight range after smoothing pub avg_weight_range_after: f64, } impl SmoothQuantQuantizer { /// Create new SmoothQuant quantizer pub fn new(config: SmoothQuantConfig) -> Self { Self { config, smoothing_scales: HashMap::new(), stats: SmoothQuantStats::default(), } } /// Calibrate smoothing scales using activations and weights pub fn calibrate( &mut self, layer_name: &str, activations: &[Tensor], weights: &Tensor, ) -> Result<()> { if activations.is_empty() { return Ok(()); } let in_features = weights.shape().dims()[1]; // Compute per-channel activation max let mut act_max = vec![0.0f32; in_features]; for act in activations { let flat = act.to_vec().map_err(tensor_err)?; let num_elements = flat.len(); for (i, &val) in flat.iter().enumerate() { let channel = i % in_features; act_max[channel] = act_max[channel].max(val.abs()); } } // Compute per-channel weight max (for each input channel) let weight_flat = weights.to_vec().map_err(tensor_err)?; let out_features = weights.shape().dims()[0]; let mut weight_max = vec![0.0f32; in_features]; for out_idx in 0..out_features { for in_idx in 0..in_features { let val = weight_flat[out_idx * in_features + in_idx]; weight_max[in_idx] = weight_max[in_idx].max(val.abs()); } } // Compute smoothing scales: s = act_max^alpha / weight_max^(1-alpha) let alpha = self.config.alpha; let scales: Vec = (0..in_features) .map(|i| { let a = act_max[i].max(self.config.min_scale); let w = weight_max[i].max(self.config.min_scale); (a.powf(alpha) / w.powf(1.0 - alpha)).max(self.config.min_scale) }) .collect(); self.smoothing_scales.insert(layer_name.to_string(), scales); self.stats.layers_smoothed += 1; Ok(()) } /// Get smoothing scales for a layer pub fn get_scales(&self, layer_name: &str) -> Option<&Vec> { self.smoothing_scales.get(layer_name) } /// Apply smoothing to activations (multiply by inverse scale) pub fn smooth_activations(&self, layer_name: &str, activations: &Tensor) -> Result { let scales = self.smoothing_scales.get(layer_name).ok_or_else(|| { CompressionError::Quantization(QuantizationError::TensorError(format!( "No scales for layer {}", layer_name ))) })?; let shape = activations.shape().dims(); let in_features = shape[shape.len() - 1]; let mut smoothed = activations.to_vec().map_err(tensor_err)?; for (i, val) in smoothed.iter_mut().enumerate() { let channel = i % in_features; let inv_scale = 1.0 / scales.get(channel).copied().unwrap_or(1.0); *val *= inv_scale; } Tensor::from_slice(&smoothed, shape, &self.config.device).map_err(tensor_err) } /// Apply inverse smoothing to weights (multiply by scale) pub fn smooth_weights(&self, layer_name: &str, weights: &Tensor) -> Result { let scales = self.smoothing_scales.get(layer_name).ok_or_else(|| { CompressionError::Quantization(QuantizationError::TensorError(format!( "No scales for layer {}", layer_name ))) })?; let shape = weights.shape().dims(); let out_features = shape[0]; let in_features = shape[1]; let mut smoothed = weights.to_vec().map_err(tensor_err)?; for out_idx in 0..out_features { for in_idx in 0..in_features { let idx = out_idx * in_features + in_idx; let scale = scales.get(in_idx).copied().unwrap_or(1.0); smoothed[idx] *= scale; } } Tensor::from_slice(&smoothed, shape, &self.config.device).map_err(tensor_err) } /// Quantize a smoothed layer (both weights and activations) pub fn quantize_layer( &mut self, layer_name: &str, weights: &Tensor, activations: &[Tensor], ) -> Result { // First calibrate self.calibrate(layer_name, activations, weights)?; // Smooth weights let smoothed_weights = self.smooth_weights(layer_name, weights)?; // Quantize smoothed weights let quantized_weights = self.quantize_tensor(&smoothed_weights, self.config.weight_bit_width)?; let scales = self .smoothing_scales .get(layer_name) .cloned() .unwrap_or_default(); Ok(SmoothQuantizedLayer { quantized_weights, smoothing_scales: scales, weight_bit_width: self.config.weight_bit_width, activation_bit_width: self.config.activation_bit_width, }) } /// Simple tensor quantization fn quantize_tensor(&self, tensor: &Tensor, bit_width: u8) -> Result { let flat = tensor.to_vec().map_err(tensor_err)?; // Find min/max let min_val = flat.iter().copied().fold(f32::MAX, f32::min); let max_val = flat.iter().copied().fold(f32::MIN, f32::max); let qmin = -(1i32 << (bit_width - 1)); let qmax = (1i32 << (bit_width - 1)) - 1; let scale = (max_val - min_val) / (qmax - qmin) as f32; let scale = if scale == 0.0 { 1.0 } else { scale }; let zero = ((qmin as f32 * scale - min_val) / scale).round() as i8; let quantized: Vec = flat .iter() .map(|&v| { let q = ((v / scale) + zero as f32).round(); q.max(qmin as f32).min(qmax as f32) as i8 }) .collect(); Ok(QuantizedTensorData { data: quantized, scale, zero_point: zero, shape: tensor.shape().dims().to_vec(), }) } /// Get statistics pub fn stats(&self) -> &SmoothQuantStats { &self.stats } } /// Simple quantized tensor data #[derive(Debug, Clone, Serialize, Deserialize)] pub struct QuantizedTensorData { /// Quantized data pub data: Vec, /// Scale factor pub scale: f32, /// Zero point pub zero_point: i8, /// Original shape pub shape: Vec, } impl QuantizedTensorData { /// Dequantize pub fn dequantize(&self, device: Device) -> Result { let dequantized: Vec = self .data .iter() .map(|&q| (q as f32 - self.zero_point as f32) * self.scale) .collect(); Tensor::from_slice(&dequantized, &self.shape, &device) .map_err(|e| QuantizationError::TensorError(e.to_string()).into()) } } /// SmoothQuant quantized layer #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SmoothQuantizedLayer { /// Quantized weights pub quantized_weights: QuantizedTensorData, /// Smoothing scales for activation preprocessing pub smoothing_scales: Vec, /// Weight bit width pub weight_bit_width: u8, /// Activation bit width pub activation_bit_width: u8, } impl SmoothQuantizedLayer { /// Get inverse smoothing scales (for activation preprocessing) pub fn inverse_scales(&self) -> Vec { self.smoothing_scales.iter().map(|&s| 1.0 / s).collect() } /// Number of output features (rows of the weight matrix). #[inline] pub fn out_features(&self) -> usize { self.quantized_weights.shape[0] } /// Number of input features (columns of the weight matrix). #[inline] pub fn in_features(&self) -> usize { self.quantized_weights.shape[1] } /// Run the quantized linear layer forward pass on a raw f32 buffer. /// /// Implements the SmoothQuant inference path: /// 1. **Smooth activations** – divide each channel by its smoothing scale, migrating /// quantisation difficulty from activations to the pre-scaled weights. /// 2. **Quantise activations to INT8** – compute a symmetric per-tensor scale /// `act_scale = max(|smoothed|) / 127` and round to the range `[−127, 127]`. /// 3. **INT8 GEMM** – compute `acc[b, out] = Σ_k act_q[b,k] * (weight_q[out,k] − zp)` /// with i32 accumulation. /// 4. **Dequantise** – `output[b, out] = acc[b, out] as f32 * act_scale * weight_scale`. /// /// # INT8 range /// /// Activations are clamped to **`−127…127`** (symmetric), matching the /// `activation_bit_width = 8` convention used throughout this crate. /// /// # Dequantisation formula /// /// ```text /// output[b, out] = (Σ_k act_q[b,k] * (weight_q[out,k] − zp)) * act_scale * weight_scale /// ``` /// /// # Arguments /// /// * `activations` – Row-major f32 slice of shape `[batch_size, in_features]`. /// * `batch_size` – Number of input rows. /// /// # Returns /// /// Row-major `Vec` of shape `[batch_size, out_features]`. /// /// # Errors /// /// Returns an error if the activation buffer length is inconsistent with `batch_size`. pub fn forward_raw(&self, activations: &[f32], batch_size: usize) -> Result> { let in_f = self.in_features(); let out_f = self.out_features(); let expected_len = batch_size * in_f; if activations.len() != expected_len { return Err(CompressionError::Quantization( QuantizationError::InvalidConfig(format!( "SmoothQuantizedLayer::forward_raw: activation buffer length {} \ does not match batch_size={} × in_features={}", activations.len(), batch_size, in_f )), )); } let zp = self.quantized_weights.zero_point as i32; let weight_scale = self.quantized_weights.scale; // ------------------------------------------------------------------ // Step 1 + 2: smooth then quantise activations to INT8 // // We compute smoothed values first (in place into a temporary buffer), // then find max_abs and derive act_scale before quantising. // ------------------------------------------------------------------ let mut smoothed = vec![0.0f32; batch_size * in_f]; for b in 0..batch_size { let row = b * in_f; for c in 0..in_f { // Divide by the per-channel smoothing scale (≥ ε for numerical safety). let s = self.smoothing_scales[c].max(f32::EPSILON); smoothed[row + c] = activations[row + c] / s; } } // Per-tensor activation scale: max absolute value over the whole batch. let max_abs = smoothed.iter().map(|v| v.abs()).fold(0.0f32, f32::max); // Guard against zero-tensor inputs; any non-zero scale works here. let act_scale = if max_abs < f32::EPSILON { 1.0f32 } else { max_abs / 127.0f32 }; // Quantise each smoothed value to INT8 in range [−127, 127]. let act_q: Vec = smoothed .iter() .map(|&v| { let q = (v / act_scale).round(); q.clamp(-127.0, 127.0) as i8 }) .collect(); // ------------------------------------------------------------------ // Step 3: INT8 GEMM with i32 accumulation // // output_i32[b, out_row] = Σ_k act_q[b,k] * (weight_q[out_row,k] − zp) // ------------------------------------------------------------------ let mut output = vec![0.0f32; batch_size * out_f]; for b in 0..batch_size { let act_row = b * in_f; for out_row in 0..out_f { let weight_row = out_row * in_f; let mut acc = 0i32; for k in 0..in_f { let wq = self.quantized_weights.data[weight_row + k] as i32 - zp; let aq = act_q[act_row + k] as i32; acc += aq * wq; } // Step 4: dequantise output[b * out_f + out_row] = acc as f32 * act_scale * weight_scale; } } Ok(output) } } // ============================================================================= // Tests // ============================================================================= #[cfg(test)] mod tests { use super::*; #[test] fn test_awq_config_default() { let config = AWQConfig::default(); assert_eq!(config.bit_width, 4); assert_eq!(config.group_size, 128); } #[test] fn test_awq_quantizer_basic() { let config = AWQConfig::new(4).with_group_size(64); let quantizer = AWQQuantizer::new(config); assert_eq!(quantizer.stats().layers_quantized, 0); } #[test] fn test_gptq_config_default() { let config = GPTQConfig::default(); assert_eq!(config.bit_width, 4); assert_eq!(config.group_size, 128); assert!(config.true_sequential); } #[test] fn test_gptq_quantizer_basic() { let config = GPTQConfig::new(4).with_act_order(true); let quantizer = GPTQQuantizer::new(config); assert!(quantizer.config.act_order); } #[test] fn test_smoothquant_config_default() { let config = SmoothQuantConfig::default(); assert_eq!(config.alpha, 0.5); assert_eq!(config.weight_bit_width, 8); assert_eq!(config.activation_bit_width, 8); } #[test] fn test_smoothquant_alpha_clamping() { let config1 = SmoothQuantConfig::new(1.5); assert_eq!(config1.alpha, 1.0); let config2 = SmoothQuantConfig::new(-0.5); assert_eq!(config2.alpha, 0.0); } #[test] fn test_quantized_tensor_dequantize() { let data = QuantizedTensorData { data: vec![0, 10, -10, 5], scale: 0.1, zero_point: 0, shape: vec![2, 2], }; let dequant = data.dequantize(Device::Cpu).unwrap(); let flat: Vec = dequant.to_vec().unwrap(); assert!((flat[0] - 0.0).abs() < 0.01); assert!((flat[1] - 1.0).abs() < 0.01); assert!((flat[2] - -1.0).abs() < 0.01); assert!((flat[3] - 0.5).abs() < 0.01); } #[test] fn test_awq_quantized_weight_memory() { let weight = AWQQuantizedWeight { quantized_data: vec![0; 1024], scales: vec![1.0; 8], zeros: vec![0; 8], activation_scales: vec![1.0; 256], group_size: 128, bit_width: 4, shape: vec![4, 256], }; let bytes = weight.memory_bytes(); assert!(bytes > 0); assert!(bytes < 1024 * 4 + 100); // Less than full FP32 + overhead } #[test] fn test_gptq_compression_ratio() { let weight = GPTQQuantizedWeight { quantized_data: vec![0; 1024], scales: vec![1.0; 8], zeros: vec![0; 8], group_size: 128, bit_width: 4, shape: vec![32, 32], col_order: None, }; let ratio = weight.compression_ratio(); assert!(ratio > 1.0); // Should be compressed assert!(ratio < 10.0); // But not impossibly so } // ----------------------------------------------------------------------- // Helper: build a SmoothQuantizedLayer with fully controlled state // // Parameters: // out_features, in_features — weight matrix shape // weight_data — INT8 quantized weights, row-major [out, in] // weight_scale — single global scale for the weight tensor // weight_zp — zero point for weight dequantization // smoothing — per-channel smoothing scales, len = in_features // ----------------------------------------------------------------------- fn make_smooth_layer( out_features: usize, in_features: usize, weight_data: Vec, weight_scale: f32, weight_zp: i8, smoothing: Vec, ) -> SmoothQuantizedLayer { SmoothQuantizedLayer { quantized_weights: QuantizedTensorData { data: weight_data, scale: weight_scale, zero_point: weight_zp, shape: vec![out_features, in_features], }, smoothing_scales: smoothing, weight_bit_width: 8, activation_bit_width: 8, } } /// Output length must equal batch_size × out_features. #[test] fn test_smoothquant_forward_raw_shape() { let out_f = 3usize; let in_f = 4usize; let layer = make_smooth_layer( out_f, in_f, vec![0i8; out_f * in_f], 1.0, 0, vec![1.0f32; in_f], ); let activations = vec![0.0f32; 2 * in_f]; // batch=2 let out = layer.forward_raw(&activations, 2).unwrap(); assert_eq!( out.len(), 2 * out_f, "output length must be batch * out_features" ); } /// Identity layer (W=I, smoothing=1, zp=0, weight_scale=1) reproduces activations. /// /// With weight_scale=1 and act_scale derived from the input, roundtrip quantisation /// introduces < 1% relative error for values in [1, 127]. #[test] fn test_smoothquant_forward_raw_identity_layer() { // 4×4 identity weight matrix (INT8), zero-point=0, weight_scale=1. // smoothing_scales = [1.0; 4] → no smoothing effect. // Activations: [1, 2, 3, 4] (batch=1). // Expected output ≈ [1, 2, 3, 4] (within quantisation tolerance). let in_f = 4usize; let out_f = 4usize; let identity: Vec = vec![ 1, 0, 0, 0, // 0, 1, 0, 0, // 0, 0, 1, 0, // 0, 0, 0, 1, // ]; let layer = make_smooth_layer(out_f, in_f, identity, 1.0, 0, vec![1.0f32; in_f]); let activations = vec![1.0f32, 2.0, 3.0, 4.0]; let out = layer.forward_raw(&activations, 1).unwrap(); assert_eq!(out.len(), out_f); // Tolerance: INT8 quantisation of activations loses at most 1/127 ≈ 0.8%. for (i, (&computed, &expected)) in out.iter().zip(activations.iter()).enumerate() { let rel_err = (computed - expected).abs() / (expected.abs().max(1e-6_f32)); assert!( rel_err < 0.02, "element {i}: got {computed:.4}, expected {expected:.4}, rel_err={rel_err:.4}" ); } } /// smoothing_scales=[2.0]*n divides each activation channel by 2 before quantisation. /// /// With W=I, this halves the output compared to smoothing_scales=[1.0]. #[test] fn test_smoothquant_forward_raw_scale_effect() { let in_f = 4usize; let out_f = 4usize; let identity: Vec = vec![ 1, 0, 0, 0, // 0, 1, 0, 0, // 0, 0, 1, 0, // 0, 0, 0, 1, // ]; // Layer with smoothing_scales = [1.0; 4] (baseline) let layer_no_smooth = make_smooth_layer(out_f, in_f, identity.clone(), 1.0, 0, vec![1.0f32; in_f]); // Layer with smoothing_scales = [2.0; 4] (halves activations) let layer_smooth = make_smooth_layer(out_f, in_f, identity, 1.0, 0, vec![2.0f32; in_f]); let activations = vec![10.0f32, 20.0, 30.0, 40.0]; let out_baseline = layer_no_smooth.forward_raw(&activations, 1).unwrap(); let out_halved = layer_smooth.forward_raw(&activations, 1).unwrap(); for (i, (&baseline, &halved)) in out_baseline.iter().zip(out_halved.iter()).enumerate() { let ratio = baseline / halved; assert!( (ratio - 2.0).abs() < 0.05, "element {i}: baseline={baseline:.4} / halved={halved:.4} = ratio {ratio:.4}, expected ~2.0" ); } } /// Manually computed reference: 2×2 layer, known weights/scales/input. /// /// Setup: /// W (INT8) = [[2, 0], [0, 2]], weight_scale=0.5, weight_zp=0 /// smoothing_scales = [1.0, 1.0] /// activations = [10.0, 20.0] (batch=1) /// /// Step-by-step: /// 1. Smooth: [10/1, 20/1] = [10.0, 20.0] /// 2. act_scale = max(|10|, |20|) / 127 = 20/127 ≈ 0.15748 /// 3. Quantise: act_q = round([10/0.15748, 20/0.15748]).clamp(-127,127) /// ≈ round([63.5, 127.0]) = [64, 127] (or [63, 127] depending on rounding) /// 4. INT8 GEMM (weight_zp=0): /// out[0] = 2*act_q[0] + 0*act_q[1] = 2*64 = 128 (or 2*63=126) /// out[1] = 0*act_q[0] + 2*act_q[1] = 2*127 = 254 /// 5. Dequant: output * act_scale * weight_scale /// out[0] ≈ 128 * 0.15748 * 0.5 ≈ 10.08 (expected ~10.0) /// out[1] ≈ 254 * 0.15748 * 0.5 ≈ 20.0 /// /// Tolerance: 2% (quantisation rounding). #[test] fn test_smoothquant_forward_raw_matches_manual() { let layer = make_smooth_layer(2, 2, vec![2i8, 0, 0, 2], 0.5, 0, vec![1.0f32, 1.0]); let activations = vec![10.0f32, 20.0]; let out = layer.forward_raw(&activations, 1).unwrap(); assert_eq!(out.len(), 2); assert!( (out[0] - 10.0).abs() < 0.5, "out[0] ≈ 10.0, got {:.4}", out[0] ); assert!( (out[1] - 20.0).abs() < 0.5, "out[1] ≈ 20.0, got {:.4}", out[1] ); } }