July 3, 2026
From-Scratch AWQ INT4 Quantization on Qwen3-8B
TL;DR
We validate a from-scratch, pure-PyTorch AWQ implementation on Qwen3-8B: group-wise INT4 with per-channel AWQ scaling produces a 4.0× smaller model (13.9 GB → 3.5 GB linear weights) at 1.034× FP16 perplexity on WikiText-2 (10.08 vs 9.75), loaded and run in a real INT4 GEMM runtime. The decisive factor is norm-folding the AWQ scale — 20× more accurate per weight than weight-dequantization.
Introduction
Activation-aware Weight Quantization (AWQ, Lin et al. 2023) is the de-facto method for turning a 7–8B-class FP16 LLM into a deployable INT4 model. It protects the weight channels aligned with large-magnitude activations by scaling them up before quantization, then compensating the activations. The production toolchain, however, is largely black-box - AutoAWQ and GPTQ packages that hide the scale search, the packing, and the runtime handoff behind a single call.
We wanted the opposite: a from-scratch, pure-PyTorch, model-agnostic implementation where every stage - activation statistics, per-layer scale search, INT4 packing, reconstruction verification, and the bridge to a real INT4 runtime - is inspectable. And we wanted to prove, end-to-end on a viable-sized model on CUDA, that it produces a 4× smaller model with near-zero loss.
This post presents that implementation and its validation on Qwen3-8B. The pipeline produces an INT4 model that is 4.0× smaller in linear weights and runs at 1.034× FP16 perplexity in a real INT4 GEMM runtime, with coherent greedy generation indistinguishable in substance from the FP16 baseline.
Background
AWQ scaling
For a linear layer with weight and per-input-channel activation magnitude , AWQ chooses a per-channel scale
scales the weights up by before quantization (), and divides the activations by at inference. The reference implementation (mit-han-lab/llm-awq) folds into the preceding norm's weight () rather than storing per linear. The optimal is found by grid search, scored by the activation-weighted reconstruction error ; gives (plain RTN), so the search can only improve on RTN, never worsen it.
Group-wise symmetric INT4
Weights are quantized in groups of group_size input channels with one FP16 scale per group: , , so the signed grid is (15 codes). Two INT4 are packed per byte in the internal artifact.
AutoAWQ / HF-AWQ GEMM on-disk format
The runtime-loadable layout is unsigned int4 packed 8-per-int32, with an AWQ pack order governing the within-int32 ordering. Reconciling our per-linear with this shared-norm convention is the hard part - covered in §4.
Method
The pipeline is deliberately linear and inspectable. Each phase is independently runnable and produces a discrete artifact on disk.
FP16 model ──► calibrate ──► calibration_stats.pt ──► scales ──► awq_scales.pt
│
▼
prompts ──────────────────────────────────────────────────► quantize ──► quantized_state.pt + metadata.json
│
▼
verify (MSE)
│
▼
export ──► awq_hf/ (AutoAWQ GEMM)
qweight / qzeros / scales
+ folded-norm FP16Calibrate
A forward hook on every nn.Linear aggregates the per-input-channel activation magnitude on-the-fly as a running sum: , accumulated per layer with a running count. Memory is per layer - not - so hundreds of calibration samples cost nothing beyond a single forward. We calibrate on 128 WikiText-2 samples at max_length = 2048.
Scales
For each linear, we grid-search over n_grid = 20 values in and score each candidate by actually quantizingthe weight with it (the pseudo-quantizer mirrors the real quantizer's grid exactly) and computing . The layer count for depth-dependent skip strategies is derived from the calibration stats via model.layers.{i}.*, not hardcoded - the implementation is model-agnostic for any Llama/Qwen/Mistral-style causal LM.
Quantize
The quantizer never loads the full model. It streams safetensors one weight tensor at a time, applies , performs group-wise symmetric INT4 quantization, packs two INT4 per byte, and writes quantized_state.pt + metadata.json. Peak memory is one tensor plus its packed output. All 252 linear layers of Qwen3-8B are quantized this way.
Verify
The canonical dequantizer inverts the packer and reports MSE against the original FP16 weight read from disk. This is a reconstruction-quality check on the quantization itself, independent of any runtime.
Export - the design behind near-zero loss
The hard part is producing a model a real INT4 runtime can load, because our per-linear does not map 1:1 onto the standard AWQ runtime convention:
q_proj/k_proj/v_projshareinput_layernorm;gate_proj/up_projsharepost_attention_layernorm;- you cannot fold three different into one norm;
o_projanddown_projhave no preceding norm at all.
The exporter reconciles this as follows:
- Aggregate the per-linear into one shared scale per norm group (geometric mean over the member linears) and fold into that norm ().
- Re-quantize the 5 norm-following linears (
q/k/v/gate/up) with the shared . - Export
o_proj/down_projas plain RTN INT4 () - they have no preceding norm to absorb a scale. - Map the signed grid onto AutoAWQ's unsigned grid by storing and , so the runtime's exactly reproduces our group dequant - zero re-quantization error on the AWQ-scaled linears.
The output directory contains AutoAWQ GEMM qweight / qzeros / scales per quantized linear, the FP16 (folded) norms + embeddings + lm_head, and a quantize_config.json. AutoAWQ loads it directly and replaces each quantized linear with a WQLinear INT4 GEMM module.
Experimental setup
| Value | |
|---|---|
| Hardware | AWS EC2 g6.xlarge, NVIDIA L4 (24 GB) |
| Software | PyTorch 2.6.0+cu124, transformers 5.12.1, AutoAWQ 0.2.9 |
| Models | Qwen3-0.6B (neg. control), Qwen3-1.7B (primary), Qwen3-8B |
| Quantization | all linears, group_size = 128, 128 WikiText-2 samples, max_length = 2048 |
| Perplexity | WikiText-2 test split, sliding window (stride 512, max_length 2048) |
| Generation | greedy (do_sample=False), max_new_tokens = 48, fixed 4-prompt set |
The pass bar for near-zero loss is AWQ perplexity ≤ 1.10× FP16.
Results - 4× smaller, near-zero loss on 8B
On the production-relevant 8B model, the exported AWQ-INT4 artifact is 4.0× smaller in linear weights (13.9 GB → 3.5 GB) and runs at 1.034× FP16 perplexity(9.75 → 10.08) in AutoAWQ's real INT4 GEMM runtime - comfortably under the 1.10× pass bar, with coherent greedy generation across every prompt.
| Model | FP16 PPL | AWQ-runtime PPL | × FP16 | Bar |
|---|---|---|---|---|
| Qwen3-0.6B (neg. control) | ~coherent | coherent | - | below INT4 viability |
| Qwen3-1.7B | 16.79 | 20.90 | 1.245× | INVESTIGATE |
| Qwen3-8B | 9.75 | 10.08 | 1.034× | PASS (≤1.10×) |
Headline perplexity on the WikiText-2 test split. AWQ-runtime = awq export → AutoAWQ real INT4 GEMM.
Benchmark comparison
Task-level operational profile
WikiText-2 PPL (8B)
3% worse
Linear weights (GB)
75% better
Runtime model on disk (GB)
62% better
Compression & reconstruction
The compression ratio counts packed INT4 weights plus per-group FP16 scales and per-channel AWQ scales, so it reflects the true on-disk footprint of the quantized linear weights. Reconstruction MSE is tiny relative to the weight magnitudes - and, crucially, the runtime does not pay this error as a forward-pass penalty because the export folds into the norm (§7).
| Metric | FP16 | INT4 AWQ | Ratio |
|---|---|---|---|
| Linear weights (all 252, 36 layers) | 13.9 GB | 3.5 GB | 4.0× smaller (25.0%) |
| Runtime-loadable model on disk | 16.0 GB | 6.10 GB | 2.6× |
| Avg verify MSE (5 layers) | - | 2.59e-4 | - |
Qwen3-8B compression and reconstruction quality.
Greedy generation
Across a fixed 4-prompt set, the INT4 model matches the FP16 baseline in substance - no degeneration or repetition. Qualitative near-zero loss.
| Prompt | FP16 | AWQ-runtime (INT4 GEMM) |
|---|---|---|
| "The capital of France is" | Paris, Rome, Madrid, Berlin, Amsterdam… | Paris, Washington DC, London, Berlin, Rome, Madrid… |
| "Explain gradient descent in one sentence:" | correct, coherent | correct, coherent |
| "def fibonacci(n):" | clean recursive fib | clean fib (if n <= 1) |
| "Once upon a time in a galaxy far away," | "planet Mathoria, base-12 number system" | "planet Mathoria, Number Navigators" |
Qwen3-8B greedy generation: FP16 vs AWQ-runtime (real INT4).
Scaling
Quality retention improves with scale - 1.7B sits at 1.245× (the INVESTIGATE edge), while 8B passes cleanly at 1.034×. This is exactly what AWQ theory predicts: larger models have more redundant capacity to absorb fixed-point error. The 8B result, not the 1.7B one, is the deployment-relevant data point.
Why near-zero loss holds: norm-fold vs weight-dequant
The single most important design choice is how the AWQ scale is applied at inference. There are two strategies:
- Weight-dequantization: dequantize each linear to as an FP16 weight and run a normal FP16 forward (no norm change).
- Norm-folding: store and fold into the preceding norm (), so the activation is divided by . This is the reference
mit-han-lab/llm-awqconvention and whatawq exportdoes.
They are not equivalent. On a single 1.7B projection layer:
| Strategy | Weight norm (orig 72.03) | MSE vs FP16 | Per-layer output rel. err. |
|---|---|---|---|
| Weight-dequant (Q(W·s)/s) | 79.38 (+10%) | 4.2e-4 | 29–46% |
| Norm-fold (Q(W·s), s in norm) | 72.27 (+0.3%) | 2.1e-5 | - |
model.layers.0.self_attn.q_proj (Qwen3-1.7B, group_size = 128).
| Model | FP16 | AWQ-runtime (norm-fold) | Weight-dequant (Q(W·s)/s) |
|---|---|---|---|
| Qwen3-1.7B | 16.79 | 20.90 | 264 120 |
| Qwen3-8B | 9.75 | 10.08 | 174 464 |
Full per-config perplexity. The weight-dequant column is the quantitative evidence that the strategy choice is decisive.
Ablation - grid search vs fixed α
Grid search wins on per-layer weight reconstruction (~2× lower MSE), confirming the search is real and the scoring function is correctly weighted. Fixed- wins on end-to-end runtime PPL - and this is not a scoring bug.
| Scale selection (1.7B) | Verify MSE (weight) | AWQ-runtime PPL |
|---|---|---|
| Grid search (--n-grid 20) | 2.74e-4 | 20.90 |
| Fixed α = 0.5 (--no-grid-search) | 5.63e-4 | 19.94 |
Grid search wins on reconstruction; fixed-α wins on export runtime PPL.
Discussion & limitations
- The win, restated. A from-scratch AWQ implementation produces a 4× smaller, +3.4%-PPL Qwen3-8B that runs in a real INT4 GEMM runtime - production-relevant, not a toy result on a sub-viable model.
- RTN for o_proj / down_proj. These two linears per block have no preceding norm, so the exporter leaves them at RTN () rather than applying a scale it cannot fold. A follow-up can absorb their into the preceding projection (
v_proj/up_projoutput channels) for further quality headroom. - Norm-group-level search. The ablation shows per-layer grid search optimizes the wrong unit for the exportable format; norm-group search is the natural next improvement.
- Generalization. Evaluated on Qwen3; the
model.layers.{i}.*convention makes Llama/Mistral support straightforward.lm_headis intentionally skipped (vocabulary projection is too sensitive to INT4).
Conclusion
A from-scratch, pure-PyTorch, model-agnostic AWQ implementation, with a runtime-loadable export, delivers a 4× smaller Qwen3-8B at 1.034× FP16 perplexity - near-zero loss, coherent generation, runnable in a real INT4 GEMM runtime. The decisive factor is norm-folding the AWQ scale (20× more accurate per weight than weight-dequantization), and the clear next step is norm-group-level scale search to bring smaller models into the same near-lossless regime.
The implementation is open-source at github.com/egesabanci/awq, with the full pipeline (calibrate / scales / quantize / verify / export) and a standalone evaluator for perplexity and generation.
Reproduction
All numbers here are regenerated by the commands below.
# Quantize (CUDA)
awq run --model /data/models/Qwen3-8B --dataset wikitext \
--output-dir /data/out-8b --samples 128 --max-length 2048 \
--device cuda --quantize-strategy all --group-size 128 --verify-layers 5
# Export to a runtime-loadable AutoAWQ/HF-AWQ model
awq export --model /data/models/Qwen3-8B \
--from /data/out-8b/awq_quantized/quantized_state.pt \
--to /data/out-8b/awq_hf --group-size 128 --device cuda
# Evaluate: FP16 baseline vs AWQ real-INT4 runtime
python eval/ppl.py --model /data/models/Qwen3-8B --config fp16
python eval/ppl.py --model /data/models/Qwen3-8B --config awq-runtime \
--export-dir /data/out-8b/awq_hf --genKey takeaways
4.0×
Smaller linear weights on Qwen3-8B
13.9 GB → 3.5 GB across all 252 linear layers (36 transformer layers). The runtime-loadable model on disk is 2.6× smaller (16.0 GB → 6.10 GB) once FP16 embeddings, norms, and lm_head are included.
1.034×
FP16 perplexity (10.08 vs 9.75)
WikiText-2 test, sliding window. Comfortably under the 1.10× pass bar for near-zero loss, with greedy generation that stays on-task across every prompt.
20×
More accurate per weight via norm-folding
Folding 1/s into the preceding norm (instead of dividing weights by s) avoids 1/s amplification of INT4 error on low-activation channels - the single decisive factor behind the 8B result.
0
Re-quantization error on AWQ-scaled linears
A signed [-7,7] → unsigned [0,15] mapping with zero=7 makes the runtime dequant reproduce our group dequant exactly. o_proj / down_proj fall back to RTN (s=1) since they have no preceding norm.
More research
Jul 20, 2026
Two Stages, Much Smaller MoE: REAP Expert Pruning Followed by AWQ INT4 Quantization
We chain two complementary compression stages on Liquid LFM2.5-8B-A1B — REAP CUDA expert pruning (32→16 experts across 22 MoE layers) then external AWQ INT4 quantization (W4A16_ASYM, group 128). The p...
Jun 29, 2026
REAP Expert Pruning for On-Device MoE Models
We present REAP-MLX, an Apple Silicon implementation of Router-weighted Expert Activation Pruning (REAP) for Mixture-of-Experts language models. We evaluate quality retention across compression ratios...