Files
2026-07-13 12:09:03 +08:00

183 lines
6.6 KiB
Rust

// Lesson: Quantization — INT8 / GPTQ / AWQ / GGUF (phase 10 / lesson 11)
// Topic: symmetric INT8 quantization of an FP32 weight vector. Computes scale
// from abs-max, rounds + clips to [-127, 127], dequantizes, reports MSE,
// max abs error, SNR, cosine similarity, and a bit-width sweep (8 / 4 / 2 bit).
// Refs:
// https://pytorch.org/docs/stable/quantization.html
// https://leimao.github.io/article/Neural-Networks-Quantization/
// https://arxiv.org/abs/2210.17323 (GPTQ)
// https://arxiv.org/abs/2306.00978 (AWQ)
// Build: rustc --edition 2021 -O code/main.rs -o /tmp/lesson_quant && /tmp/lesson_quant
use std::f64;
fn lcg(seed: &mut u64) -> f64 {
*seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
let bits = (*seed >> 11) as u64;
let unit = bits as f64 / (1u64 << 53) as f64;
unit * 2.0 - 1.0
}
// Box-Muller via the LCG, so we generate normal-ish floats without external crates.
fn randn(seed: &mut u64) -> f64 {
let u1 = (lcg(seed) + 1.0) / 2.0;
let u2 = (lcg(seed) + 1.0) / 2.0;
let u1 = u1.max(1e-12);
let r = (-2.0 * u1.ln()).sqrt();
r * (2.0 * std::f64::consts::PI * u2).cos()
}
struct QuantResult {
qmin: i32,
qmax: i32,
scale: f64,
quantized: Vec<i32>,
reconstructed: Vec<f64>,
}
fn quantize_symmetric(weights: &[f64], num_bits: u32) -> QuantResult {
let qmax = (1i32 << (num_bits - 1)) - 1;
let qmin = -qmax;
let abs_max = weights.iter().fold(0.0f64, |acc, &x| acc.max(x.abs()));
let scale = if abs_max == 0.0 { 1.0 } else { abs_max / qmax as f64 };
let mut quantized = Vec::with_capacity(weights.len());
let mut reconstructed = Vec::with_capacity(weights.len());
for &w in weights {
let q = (w / scale).round() as i32;
let q = q.max(qmin).min(qmax);
quantized.push(q);
reconstructed.push(q as f64 * scale);
}
QuantResult { qmin, qmax, scale, quantized, reconstructed }
}
struct ErrorReport {
mse: f64,
rmse: f64,
max_abs_error: f64,
snr_db: f64,
cosine: f64,
}
fn error_report(original: &[f64], reconstructed: &[f64]) -> ErrorReport {
let n = original.len() as f64;
let mut sum_sq_err = 0.0f64;
let mut max_abs = 0.0f64;
let mut signal_power = 0.0f64;
let mut dot = 0.0f64;
let mut norm_a = 0.0f64;
let mut norm_b = 0.0f64;
for (a, b) in original.iter().zip(reconstructed.iter()) {
let diff = a - b;
sum_sq_err += diff * diff;
max_abs = max_abs.max(diff.abs());
signal_power += a * a;
dot += a * b;
norm_a += a * a;
norm_b += b * b;
}
let mse = sum_sq_err / n;
let rmse = mse.sqrt();
let snr_db = if mse > 0.0 {
10.0 * (signal_power / n / mse).log10()
} else {
f64::INFINITY
};
let cosine = if norm_a > 0.0 && norm_b > 0.0 {
dot / (norm_a.sqrt() * norm_b.sqrt())
} else {
0.0
};
ErrorReport { mse, rmse, max_abs_error: max_abs, snr_db, cosine }
}
fn print_quant_summary(label: &str, weights: &[f64], r: &QuantResult, err: &ErrorReport) {
println!("[{}]", label);
println!(" range [qmin, qmax] {} .. {}", r.qmin, r.qmax);
println!(" scale (FP32 step) {:.8}", r.scale);
println!(" sample weights (10) {:?}", &weights[..10.min(weights.len())]
.iter().map(|w| format!("{:+.4}", w)).collect::<Vec<_>>());
println!(" quantized codes (10) {:?}", &r.quantized[..10.min(r.quantized.len())]);
println!(" dequantized (10) {:?}", &r.reconstructed[..10.min(r.reconstructed.len())]
.iter().map(|w| format!("{:+.4}", w)).collect::<Vec<_>>());
println!();
println!(" mse {:.10}", err.mse);
println!(" rmse {:.10}", err.rmse);
println!(" max |error| {:.10}", err.max_abs_error);
println!(" snr {:.2} dB", err.snr_db);
println!(" cosine similarity {:.10}", err.cosine);
println!();
}
fn fmt_bytes(b: u64) -> String {
let kb = b as f64 / 1024.0;
if kb < 1024.0 { format!("{:.2} KB", kb) } else { format!("{:.2} MB", kb / 1024.0) }
}
fn main() {
let mut seed: u64 = 42;
let n = 8192;
let mut weights: Vec<f64> = (0..n).map(|_| randn(&mut seed) * 0.02).collect();
weights[0] *= 25.0;
weights[123] *= 15.0;
weights[2048] *= 10.0;
let stats = {
let abs_vals: Vec<f64> = weights.iter().map(|x| x.abs()).collect();
let max = abs_vals.iter().fold(0.0f64, |a, &b| a.max(b));
let mean: f64 = abs_vals.iter().sum::<f64>() / abs_vals.len() as f64;
let var: f64 = abs_vals.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / abs_vals.len() as f64;
(max, mean, var.sqrt())
};
println!();
println!("=== INT8 quantization (Rust, stdlib only) ===");
println!();
println!("Tensor : 1D weight vector, n = {}", n);
println!("Distribution : Normal(0, 0.02) with 3 outlier weights");
println!(" max |w| {:.6}", stats.0);
println!(" mean |w| {:.6}", stats.1);
println!(" std |w| {:.6}", stats.2);
println!();
let r8 = quantize_symmetric(&weights, 8);
let err8 = error_report(&weights, &r8.reconstructed);
print_quant_summary("INT8 symmetric per-tensor", &weights, &r8, &err8);
println!("--- Bit-width sweep (symmetric per-tensor) ---");
println!(" {:>5} {:>10} {:>14} {:>10} {:>12} {:>10}",
"bits", "levels", "mse", "snr_db", "max |err|", "ratio_vs_fp32");
for bits in [16u32, 8, 4, 2] {
let r = quantize_symmetric(&weights, bits);
let er = error_report(&weights, &r.reconstructed);
let ratio = 32.0 / bits as f64;
let levels = (r.qmax - r.qmin + 1) as u64;
println!(" {:>5} {:>10} {:>14.10} {:>10.2} {:>12.6} {:>9.1}x",
bits, levels, er.mse, er.snr_db, er.max_abs_error, ratio);
}
println!();
let fp32_bytes = (n * 4) as u64;
let int8_bytes = (n * 1) as u64 + 8;
let int4_bytes = ((n + 1) / 2) as u64 + 8;
println!("--- Memory footprint ---");
println!(" FP32 weights {}", fmt_bytes(fp32_bytes));
println!(" INT8 + scale {} ({:.1}x smaller)", fmt_bytes(int8_bytes), fp32_bytes as f64 / int8_bytes as f64);
println!(" INT4 + scale {} ({:.1}x smaller)", fmt_bytes(int4_bytes), fp32_bytes as f64 / int4_bytes as f64);
println!();
println!("Takeaway:");
println!(" - INT8 keeps SNR well above 30 dB for normal weight distributions.");
println!(" - Outliers dominate scale: 3 outliers in {} weights inflate scale and ", n);
println!(" waste precision on the rest. Per-channel (or GPTQ/AWQ) helps.");
println!();
}