Files
rustytorch/crates/training/rtx-compress/src/quantization/advanced.rs
T
osobhandClaude Sonnet 5 4aaa36a57a style: cargo fmt --workspace (whitespace/wrapping only, no semantic change)
Whole-workspace rustfmt pass picked up while iterating on Mamba GPU
backward work. Verified formatting-only via diff sampling; no logic
changed.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-10 07:09:36 -07:00

1411 lines
47 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<String, Vec<f32>>,
/// 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<String, f32>,
}
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<String, Vec<Tensor>>) -> 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<f32> = 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<AWQQuantizedWeight> {
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<f32> = 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<i8>, Vec<f32>, Vec<i8>)> {
let shape = weights.shape().dims();
let total_elements = shape.iter().product::<usize>();
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<Vec<f32>> {
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<i8>,
/// Per-group scales
pub scales: Vec<f32>,
/// Per-group zero points
pub zeros: Vec<i8>,
/// Activation-derived inverse scales for dequant
pub activation_scales: Vec<f32>,
/// Group size
pub group_size: usize,
/// Bit width
pub bit_width: u8,
/// Original shape [out_features, in_features]
pub shape: Vec<usize>,
}
impl AWQQuantizedWeight {
/// Dequantize weights
pub fn dequantize(&self) -> Result<Tensor> {
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<String, Vec<Vec<f32>>>,
/// 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::<usize>();
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::<f32>() / 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<GPTQQuantizedWeight> {
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<usize> = 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<f32>]) -> Result<Vec<Vec<f32>>> {
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<i8>,
/// Per-group scales
pub scales: Vec<f32>,
/// Per-group zero points
pub zeros: Vec<i8>,
/// Group size
pub group_size: usize,
/// Bit width
pub bit_width: u8,
/// Original shape
pub shape: Vec<usize>,
/// Column order (if act_order enabled)
pub col_order: Option<Vec<usize>>,
}
impl GPTQQuantizedWeight {
/// Dequantize weights
pub fn dequantize(&self) -> Result<Tensor> {
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<String, Vec<f32>>,
/// 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<f32> = (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<f32>> {
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<Tensor> {
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<Tensor> {
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<SmoothQuantizedLayer> {
// 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<QuantizedTensorData> {
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<i8> = 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<i8>,
/// Scale factor
pub scale: f32,
/// Zero point
pub zero_point: i8,
/// Original shape
pub shape: Vec<usize>,
}
impl QuantizedTensorData {
/// Dequantize
pub fn dequantize(&self, device: Device) -> Result<Tensor> {
let dequantized: Vec<f32> = 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<f32>,
/// 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<f32> {
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<f32>` 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<Vec<f32>> {
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<i8> = 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<f32> = 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<i8>,
weight_scale: f32,
weight_zp: i8,
smoothing: Vec<f32>,
) -> 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<i8> = 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<i8> = 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]
);
}
}