Skip to content

Quantization: int8, int4 group (packed), fp8, KV quant

ModuleL8.5 · build · Python · Pass 6 · 5 to 7 h
You buildpython/tinyllm/infer/quant.py: quantize_int8_per_channel, pack_int4, unpack_int4, quantize_int4_group, quantize_fp8_per_channel, quantize_mx, quantize_kv_fp8, dequantize, nbytes, QuantLinear, quantize_model, export_q4, output_error_bound, quant_ppl, and the dataclasses Q8Tensor, Q4Tensor, FP8Tensor, MXTensor, KVQuant
Contractcourse/contracts/py/tinyllm/infer/quant.pyi; the int4 bytes in formats/safetensors.md; KV format v2 in formats/kv-block.md
Testscourse/tests/L8.5/test_quant.py, 17 tests (what they check: section 4) · golden course/fixtures/parity/quant_int4.json · your own tests in python/tests/l8-5-quant/, rung R4, graded by mutation (threshold 0.80) · parity suite ol parity quant.int4
NeedsM09.4 FP8 and microscaling formats (tinyllm.num.lowp) · L0.4 modules and Linear · L0.1 the Tensor · M09.3 tolerance budgets (matmul_error_bound) · L6.7 the evaluation harness (eval_ppl) · reading: M09.1 IEEE 754 and float16, M07.4 confidence intervals
Used bylater: L9.5 (the C int4 and int8 kernels read these bytes), L10.1 (the Rust runner loads *.q4.safetensors), craft.13 (KV format v2 uses quantize_kv_fp8 as its oracle)
MilestoneMS-L8 (step 2: perplexity under --quant int8, q4_g32, fp8_e4m3 within budget)
Optional depthDettmers et al., LLM.int8(); Frantar et al., GPTQ; Lin et al., AWQ; OCP, Microscaling Formats (MX) v1.0
  • Symmetric quantization stores small integers and one scale per row, group, or head; the scale maps the largest magnitude onto the largest code, so nothing clips and every element is within half a step (test_int4_error_is_at_most_half_a_step).
  • int4 packs two weights per byte: the even column in the low nibble, each a 4-bit two’s complement value, exactly the bytes the C kernel reads (test_hand_example_int4_group, test_packed_bytes_match_the_golden).
  • The scale is stored as float16, so the codes must be chosen against the float16 value actually stored, not the float32 one computed (test_int4_dequantizes_with_the_stored_scale).
  • Decoding is memory-bound, so 4.25 bits per weight instead of 32 is up to 7.5x less to read per token; each scheme has an error budget the model’s output must stay within (test_quantize_model_within_budget).
  • The KV cache quantizes the same way: one fp8 scale per head, the layout of KV format v2 (test_kv_fp8_follows_format_v2).
Terminal window
ol start L8.5 # stubs python/tinyllm/infer/quant.py
ol tests L8.5 # read the test catalog first
ol check L8.5 # exit code is the verdict; then grades your tests by mutation
ol parity quant.int4 # your packed bytes against the golden file
ol diff L8.5 # after passing: your code against the reference

Do int8 first (one scale per row, no packing), then pack_int4/unpack_int4 on the 256 pairs of section 4, then quantize_int4_group, then fp8, MX, and KV through M09.4, then QuantLinear and quantize_model.


Your inference stack decodes one token at a time, and each token reads every weight of the model once (L8.2): decoding is limited by bytes moved, not by arithmetic. SmolLM2-135M is 540 MB in float32, and reading those bytes is most of what a token costs. Storing weights in 8, 4, or fewer bits cuts those bytes by 4 to 7.5 times, and the same applies to the KV cache, which grows with every sequence. This module turns float weights into compact codes and back, in the exact byte layout your C kernel (L9.5) will multiply without ever expanding the weights, and measures how much accuracy each scheme costs.

SymbolMeaningType
WWa Linear weight [out, in]float32
ssa scale (per row, per group, or per head)float32 or float16
qqa code: an integer (int8, int4) or a low-precision bit pattern (fp8, fp4)int8, uint8
ggint4 group size: consecutive columns of a row sharing one scaleint
amax\text{amax}the largest magnitude in a row, group, or headfloat32
rint\text{rint}rounding to the nearest integer, ties to even

Symmetric scaling. Choose s=amax/qmax⁡s = \text{amax} / q_{\max}, store q=rint(W/s)q = \text{rint}(W / s), and read back W^=q⋅s\hat W = q \cdot s. Then ∣W∣≤amax|W| \le \text{amax} means ∣W/s∣≤qmax⁡|W / s| \le q_{\max}, nothing clips, and ∣W−W^∣≤s/2|W - \hat W| \le s / 2: rounding moves each value at most half a step. There is no zero point, so 0 maps to 0 exactly. Rounding is ties-to-even everywhere (np.rint), and every quotient W/sW / s is formed in float32 before rounding, so two implementations of the rule produce the same codes.

int8 per output channel. One float32 scale per row, qmax⁡=127q_{\max} = 127: codes in [−127,127][-127, 127] (symmetric, so −128-128 is never used). A zero row gets s=0s = 0 and codes 0.

int4 per group. One scale per gg consecutive columns (32 by default), qmax⁡=7q_{\max} = 7, codes in [−8,7][-8, 7]. A per-row scale would let one outlier column flatten the other columns of its row to a few codes; a group of 32 confines the damage. The scale is stored as float16 (formats/safetensors.md), so compute s16=float16(amax/7)s_{16} = \text{float16}(\text{amax} / 7) first and quantize against s16s_{16}: rounding the scale can make amax/s16\text{amax} / s_{16} slightly above 7, at most 7(1+2−11)<7.57 (1 + 2^{-11}) < 7.5, which still rounds to 7 and keeps the half-step bound. Quantizing against the float32 scale and storing the float16 one breaks the bound and disagrees with the kernel. A group whose scale is 0 (all zeros, or values so small amax/7\text{amax}/7 underflows float16) gets codes 0; a scale that overflows float16 is an error.

Packing. Two int4 codes per byte: byte bb of a row holds column 2b2b in its low nibble and 2b+12b + 1 in its high nibble, each as 4-bit two’s complement (q & 0xF, so −1-1 is 0xF, −8-8 is 0x8). Unpacking sign-extends: a nibble v≥8v \ge 8 means v−16v - 16.

FP8 per channel. E4M3 codes through M09.4’s quantize_fp8 with one float32 scale per row, s=amax/448s = \text{amax} / 448 (448 is E4M3’s largest finite value). E4M3 keeps 3 mantissa bits, so a value at least 2−62^{-6} of the scale lands within a relative 2−42^{-4} of itself.

MXFP4. M09.4’s mx_quantize: blocks of 32 values share one power-of-two E8M0 scale XX, elements are FP4 E2M1 codes (0,0.5,1,1.5,2,3,4,60, 0.5, 1, 1.5, 2, 3, 4, 6 times XX). The largest gap is from 4X4X to 6X6X, and values above 6X6X saturate, so every element is within 2X2X; storage is 4+8/32=4.254 + 8/32 = 4.25 bits per weight.

KV fp8 (format v2). A K or V slab [n_kv_heads, T, d_head] gets one float32 scale per head, s=float32(amax)/448s = \text{float32}(\text{amax}) / 448 (1.0 for a head of zeros), and E4M3 codes of x/sx / s: the payload of KV format v2, which craft.13 migrates the C pool to.

Quantized layers. QuantLinear(q, bias) holds a quantized weight, dequantizes it once, and computes x @ W^T + b on L0.1 Tensors; it has no parameters (it is for inference). quantize_model(model, scheme) replaces every L0.4 Linear except those named in skip (default lm_head, which a model often ties to its embeddings) in place. export_q4 writes the tensors of a *.q4.safetensors file for the Rust runner (L10.1).

The error budget. A quantized layer computes xW^Tx \hat W^T instead of xWTx W^T. Each output is a dot product of kk = in terms, so M09.3’s bound applies with the weight perturbed by Δ=∣W−W^∣\Delta = |W - \hat W| (at most s/2s/2 per element for int8 and int4): ∣y′−y∣≤Δ∣x∣T+γk(∣W∣+Δ)∣x∣T|y' - y| \le \Delta |x|^T + \gamma_k (|W| + \Delta) |x|^T elementwise, in the format the product ran in. output_error_bound(w, q, x) returns exactly M09.3’s matmul_error_bound(w, x^T, dtype, dA=Δ)^T. The first term is the quantization, the second the float32 rounding; for every scheme here the first dominates by three orders of magnitude, which is why a test with rounding-only tolerances fails a correct quantizer.

The perplexity cost. A layer’s error budget does not say what a model loses. quant_ppl(model, scheme, ids, ctx_len, stride) measures it: L6.7’s eval_ppl on the float model, then on a quantized copy (quantize_model works in place, and the caller still needs the float model), and returns ppl, ppl_quant, their difference delta, and ratio. MS-L8’s budgets read these numbers.

int4 with one group of 4 per row:

Row 0Row 1
WW1.75 -0.6 0.1 0.30.375 -0.125 0.625 1.75
amax, s16=float16(amax/7)s_{16} = \text{float16}(\text{amax}/7)1.75, 0.25 (exact)1.75, 0.25
W/sW / s7 -2.4 0.4 1.21.5 -0.5 2.5 7
q=rintq = \text{rint}7 -2 0 12 0 2 7 (ties go to even: 1.5 to 2, −0.5-0.5 to 0, 2.5 to 2)
nibbles (q & 0xF)7 E 0 12 0 2 7
bytes (even column low)E7 1002 72
W^=q⋅s\hat W = q \cdot s1.75 -0.5 0 0.250.5 0 0.5 1.75

Every error is at most s/2=0.125s / 2 = 0.125 (the largest, 0.125, is the tie 0.375→0.50.375 \to 0.5). This is test_hand_example_int4_group.

int8, scale 1 (row amax 127): 127 -3.5 2.5 0.4 becomes 127 -4 2 0 (−3.5-3.5 and 2.52.5 are ties, to the even −4-4 and 22). A row of zeros has scale 0 and codes 0. This is test_hand_example_int8_per_channel.

@dataclass class Q8Tensor: q: NDArray; scales: NDArray # int8 [out, in], float32 [out]
@dataclass class Q4Tensor: packed: NDArray; scales: NDArray; group: int; shape: tuple[int, int]
@dataclass class FP8Tensor: codes: NDArray; scales: NDArray; fmt: str # uint8 [out, in], float32 [out]
@dataclass class MXTensor: codes: NDArray; scales: NDArray; block: int; elem: str
@dataclass class KVQuant: codes: NDArray; scales: NDArray # uint8 [H, T, D], float32 [H]
def quantize_int8_per_channel(w) -> tuple[NDArray, NDArray]
def pack_int4(q) -> NDArray; def unpack_int4(packed) -> NDArray
def quantize_int4_group(w, group: int = 32) -> Q4Tensor
def quantize_fp8_per_channel(w, fmt="e4m3") -> FP8Tensor
def quantize_mx(w, block=32, elem="fp4_e2m1") -> MXTensor
def quantize_kv_fp8(x) -> KVQuant
def dequantize(q) -> NDArray # float32, any form above (an int8 (q, scales) tuple too)
def nbytes(q) -> int
class QuantLinear(Module): def __init__(self, q, bias=None); def forward(self, x: Tensor) -> Tensor
def quantize_model(model: Module, scheme: str, skip=("lm_head",)) -> Module # "int8" "q4_g32" "fp8_e4m3" "mxfp4"
def export_q4(model: Module) -> tuple[dict[str, NDArray], dict[str, str]]
def output_error_bound(w, q, x, dtype: str = "f32") -> NDArray # [n, out], M09.3's matmul_error_bound
def quant_ppl(model, scheme, ids, ctx_len, stride, skip=("lm_head",)) -> dict[str, float] # L6.7's eval_ppl

The design sketch’s optional awq_search_scales and gptq_quantize are not part of this module; section “Going further” points at them.

TestKINDChecksWhy it matters downstream
test_hand_example_int4_groupunitsection 3: codes, scales, bytes E7 10 02 72, dequantized valuesyou, the tests, and the C kernel agree on the layout
test_hand_example_int8_per_channelunitsection 3’s int8 row, a zero row, ties at scale 2, codes within [−127,127][-127, 127]L9.5’s tl_matmul_q8_f32 input
test_packed_bytes_match_the_goldengoldenevery case of the quant.int4 parity golden (1 x 2 at group 2 to 5 x 48 at group 16 and 2 x 64 at group 64) byte for bytethe bytes ol parity quant.int4 and L9.5 compare
test_nibble_roundtrip_every_pairpropertyall 256 pairs pack to distinct bytes and unpack to themselves; out-of-range and odd widths refusedtwo’s complement, even column low
test_int4_error_is_at_most_half_a_steppropertyevery element within s16/2s_{16}/2 at scales 1e-3 to 100, groups 16 to 64the scheme’s promise
test_int4_dequantizes_with_the_stored_scaleunitdequantize is code times float16 scale exactly; codes are rint(W/s16)\text{rint}(W / s_{16}), including a hand-made tie at 3.5 stored scalesquantize against what is stored
test_zero_and_tiny_groupsboundaryzero and underflowing groups give zeros, no NaN; a float16 overflow raisespruned weights and outliers
test_shape_rulesboundarygroups that do not divide the width, odd groups, 1-D input, NaN, a non-quantized argumentbytes the kernel would misread are never written
test_int8_error_bound_and_rangepropertyhalf-step bound per row; each row’s amax maps to ±127\pm127per-channel, not per-tensor
test_fp8_per_channel_relative_errorpropertyscale = amax / 448 per row; relative error ≤2−4\le 2^{-4} above 2−62^{-6} of the scale; amax reproducedfp8 weights without saturation
test_mxfp4_blockspropertyone E8M0 scale per 32 values; error within 2X2X; 4.25 bits per weightMX weights through M09.4
test_kv_fp8_follows_format_v2unitper-head scale float32(amax)/448, 1.0 for a zero head; relative error boundcraft.13’s oracle for the C writer
test_quant_linear_is_the_dequantized_matmuldifferentialQuantLinear(x) equals x @ dequantize(q)^T + b for int4, int8, fp8; no parametersL10.1’s q4 runner is checked against it
test_quantize_model_within_budgetpropertyevery Linear but lm_head swapped; logits within the scheme’s relative error budget (int8 0.02, q4_g32 0.2, fp8 0.08, mxfp4 0.35)the stand-in for MS-L8’s perplexity budgets
test_export_q4_names_and_metadataunit<name>.qweight uint8, <name>.scales float16, biases and lm_head kept, metadata int4-g32-symthe file the Rust runner loads
test_output_error_stays_within_the_budgetpropertyfor int8, int4, fp8, and mxfp4, every output of QuantLinear is within output_error_bound, which uses more than 1% of itselfM09.3’s budget turned into a test tolerance that a wrong quantizer cannot pass
test_quant_ppl_reports_the_cost_and_keeps_the_modelpropertyeval_ppl before and after; int8 costs less than q4; the caller’s model stays floatMS-L8’s perplexity budgets

Your tests (rung R4). Write python/tests/l8-5-quant/test_*.py, importing only names from contracts/py, with properties in prose turned into code (Hypothesis is allowed): “every element is within half its group’s stored scale”, “pack then unpack is the identity on [−8,7][-8, 7]”, “the even column is the low nibble”, “QuantLinear equals the dequantized matmul”. The planted bugs “the nibbles are swapped” (s01) and “codes are chosen against the float32 scale” (s07) are required; overall 80%.

Parity. ol parity quant.int4 runs your quantize_int4_group through course/conformance/parity/drivers/quant_int4.py on the golden cases; L9.5’s C side joins the same suite.

PitfallSymptomCaught by
Even column in the high nibblethe C kernel multiplies every pair swappedtest_hand_example_int4_group, test_nibble_roundtrip_every_pair (mutant s01)
Offset binary (code + 8) instead of two’s complementevery code off by 8 for the kerneltest_hand_example_int4_group, test_nibble_roundtrip_every_pair (mutant s02)
Unpacking without sign extensionnegative weights read as 9 to 15test_hand_example_int4_group, test_nibble_roundtrip_every_pair (mutant s03)
Truncating instead of roundingerrors up to a whole step, biased toward zerotest_hand_example_int4_group, test_int4_error_is_at_most_half_a_step (mutant s04)
Casting int8 codes without roundingthe same truncation for int8test_hand_example_int8_per_channel (mutant s05)
int8 with scale amax / 128the row maximum clipstest_hand_example_int8_per_channel (mutant s06)
Quantizing against the float32 scale, storing float16a weight of exactly 3.5 stored scales rounds to 3 instead of 4; the kernel’s products disagreetest_int4_dequantizes_with_the_stored_scale (mutant s07)
Scale amax / 8the largest weight clips at 7test_int4_error_is_at_most_half_a_step (mutant s08)
Writing an overflowing float16 scaleinf in the file, NaN in every producttest_zero_and_tiny_groups (mutant s09)
Accepting an odd groupa group splits a byte between two scalestest_shape_rules (mutant s10)
One int8 scale per tensorsmall rows lose all precisiontest_int8_error_bound_and_range (mutant s11)
fp8 scale ignoring the format’s maximumwasted range or saturationtest_fp8_per_channel_relative_error (mutant s12)
MX block size ignoredscales do not line up with the format’s blockstest_mxfp4_blocks (mutant s13)
One KV scale for all headsquiet heads lose their precisiontest_kv_fp8_follows_format_v2 (mutant s14)
A zero KV head with scale 0division by zero in quantize_fp8test_kv_fp8_follows_format_v2 (mutant s15)
QuantLinear dropping the biasevery output shiftedtest_quant_linear_is_the_dequantized_matmul (mutant s16)
Multiplying by WW instead of WTW^Tshapes fail, or square layers compute garbagetest_quant_linear_is_the_dequantized_matmul (mutant s17)
Quantizing lm_headtied embeddings change; logits lose the most precisiontest_quantize_model_within_budget (mutant s18)
quantize_model losing biasesthe model’s output drifts past the budgettest_quantize_model_within_budget (mutant s19)
Wrong tensor names in the exportthe Rust runner cannot find the weightstest_export_q4_names_and_metadata (mutant s20)
Repeating group scales along the wrong axisshapes fail on dequantizetest_hand_example_int4_group (mutant m01)
Counting a byte per fp4 codememory budgets double-count MX weightstest_mxfp4_blocks (mutant m02)
A budget with rounding only (Δ=0\Delta = 0)every quantized output fails its tolerance by a factor of a thousandtest_output_error_stays_within_the_budget (mutant s21)
Quantizing the caller’s model in quant_pplthe float model the caller compares against is gonetest_quant_ppl_reports_the_cost_and_keeps_the_model (mutant s22)
DirectionModuleHow it uses this
BackM09.4quantize_fp8, dequantize_fp8, fp8_max, mx_quantize, mx_dequantize
BackL0.4QuantLinear is a Module; quantize_model walks named_modules and finds Linears
BackL0.1QuantLinear.forward runs on Tensor
BackM09.3output_error_bound is matmul_error_bound with the quantization step as dA
BackL6.7quant_ppl runs eval_ppl on the float model and on its quantized copy
ForwardL9.5tl_matmul_q4_f32 and tl_matmul_q8_f32 multiply these bytes without expanding them; parity/quant.int4
ForwardL10.1the Rust runner loads export_q4’s tensors and checks its logits against QuantLinear
Forwardcraft.13KV format v2’s fp8 payload follows quantize_kv_fp8
Your pieceProduction equivalentWhat it addsWhere to look
int4 group, symmetricllama.cpp Q4_0 and K-quants32-weight blocks with float16 scales, mixed precision per tensor (Q4_K_M)ggml/src/ggml-quants.c
round to nearestGPTQ, AWQsecond-order error compensation (GPTQ) or activation-aware scaling of salient channels (AWQ) at the same bit widthAutoGPTQ, llm-awq
fp8 per channelvLLM FP8 W8A8fp8 activations too, with per-token dynamic scales and fused kernelsvllm/model_executor/layers/quantization/fp8.py
KV fp8vLLM kv_cache_dtype="fp8"per-tensor or per-head KV scales calibrated offlinevLLM docs, “Quantized KV Cache”