What int8 weight-only quantization saves

Follow one float weight into an int8 code and back. The trained checkpoint got 41.7% smaller with almost the same bits per byte, but this PyTorch decode path ran 20.1% slower.

Take one weight, −0.35. Store it as a small integer plus a scale that says what one integer step means. When the model uses that layer, multiply the integer by the scale and you get an approximate float back. Now move the largest weight below and watch what happens to −0.35, one outlier makes the scale coarser for the whole row.

Playground
float weight−0.350int8 code-44restored weight−0.346÷ scale, round× scale
Column12345
Float weight−0.800−0.3500.0000.3201.000
Signed int8-102-44041127
Restored−0.803−0.3460.0000.3231.000
Scale for this row0.00787largest absolute weight ÷ 127
Largest absolute error0.0035
Storage for this row9 bytes5 int8 weights + one float32 scale

This is the same symmetric, per-output-row rule as octlm's Int8Linear. Raise the outlier: the row needs a coarser scale, so smaller weights lose precision. The restored numbers are what the current PyTorch path multiplies in float16 or float32 before its linear operation. The 9-byte count excludes file metadata and alignment.

Follow −0.35 from float to signed int8 and back, and look at all five weights in the row. Move the outlier to make the integer grid coarser. The rule matches Int8Linear and a PyTorch fixture.

The Day 4 model uses float16 on a T4, so every float16 projection weight takes two bytes in an inference checkpoint. Quantization asks, can we store one-byte integer weights instead and make the checkpoint smaller without hurting held-out story prediction too much ? It might change generation speed too, but the current PyTorch implementation rebuilds a float weight on every linear call. So a smaller file doesn't mean a faster decoder, those are two separate things.

Before the conversion

Float16 was already a compression step

The mixed-precision experiment first moved matrix operations from float32 to float16. The T4 ran it faster and the validation loss matched the float32 control, and it halved the bytes per floating-point weight. Int8 starts from that float16 inference model and tries to halve the projection-weight payload again. This is an inference storage change, not a training recipe, the optimizer and gradients stay in the training checkpoint.

The model has 26,255,872 parameters. Its attention and feed-forward linear layers hold 22,052,864 weights in 36,224 output rows. The tied token embedding and output matrix holds 4,194,304 weights, and octlm leaves that one in float16, because quantizing a tied matrix opens a whole separate quality and implementation question. Norm gains also stay floating point.

The rule

One scale per output row

For a row of weights ww, take its largest absolute value and map it to signed int8 value 127. The row's scale and each stored integer are

s=max⁡(max⁡i∣wi∣,10−8)127,qi=clip⁡(round⁡(wi/s),−127,127).s = \frac{\max(\max_i |w_i|, 10^{-8})}{127}, \qquad q_i = \operatorname{clip}(\operatorname{round}(w_i/s), -127, 127).

On a forward pass the approximate weight is w^i=sqi\hat w_i = s q_i. The scale is float32 and the integer is signed int8. The 10−810^{-8} floor is just so a row of all zeros doesn't blow up. This is called symmetric per-output-channel weight-only quantization, input activations aren't quantized and every output row gets its own scale. If we used a single scale for the whole matrix, one big row would reduce the precision of every other row.

Read the conversion

In octlm

Replace the projections, leave the tied output alone

quantize_int8 walks only the decoder blocks and swaps their linear projections for Int8Linear. It requires eval mode and doesn't touch the tied token embedding and output weight. Int8Linear stores integer rows and scales as buffers. Its forward casts the integer weights to the input dtype, multiplies by the scales, and then calls a normal floating-point linear.

octlm/day5.pyline 224
class Int8Linear(nn.Module):
    def __init__(self, source: nn.Linear) -> None:
        super().__init__()
        weight = source.weight.detach().float()
        scale = weight.abs().amax(dim=1).clamp_min(1e-8) / 127
        self.register_buffer(
            "qweight", (weight / scale[:, None]).round().clamp(-127, 127).to(torch.int8)
        )
        self.register_buffer("scale", scale)
        self.register_buffer("bias", None if source.bias is None else source.bias.detach().clone())

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        weight = self.qweight.to(x.dtype)
        weight.mul_(self.scale.to(x.dtype).unsqueeze(1))
        return F.linear(x, weight, None if self.bias is None else self.bias.to(x.dtype))

This makes the storage format easy to inspect and compare, but it is not a fused int8 matrix multiply. Rebuilding the float weights for every token can cost more time and temporary memory than reading the smaller stored weights saves. TorchAO has specialized inference paths, but here I deliberately measure the plain PyTorch path we already have before deciding whether a new dependency or kernel is worth it.

Just from parameter counts, the 22,052,864 projection weights take about 42.1 MiB in float16. Their integer values plus 36,224 float32 row scales take about 21.2 MiB, so the payload drops by roughly 20.9 MiB. The final .pt files also hold the untouched embedding, norm weights and serialization overhead, so the file comparison below uses actual saved bytes. And file size by itself says nothing about peak GPU memory during dequantization.

Does the smaller file help ?

Size, quality and decode speed have to be measured together

octlm.day5 quant loads the trained Day 4 checkpoint, evaluates the same held-out blocks before and after conversion, and saves two inference-only state dictionaries. Then it measures 256 generated tokens with the same KV-cached generator, prefill included, for both variants. The output records model bytes, tokens per second and the bits-per-byte change.

octlm/day5.pyline 251
def stage_quant(args: argparse.Namespace) -> None:
    if args.out.exists():
        raise FileExistsError(f"{args.out} already contains a measurement")
    model, tokenizer = load_model(args)
    device = next(model.parameters()).device
    if device.type == "cuda":
        model.half()
    valid_tokens = token_file(args.data / "valid.bin")
    valid = validation_blocks(valid_tokens, tokenizer, model.config.context_length)
    baseline_bpb = evaluate(model, valid, tokenizer.pad_id)["bits_per_byte"]
    model.eval()
    quantized = quantize_int8(copy.deepcopy(model))
    quantized_bpb = evaluate(quantized, valid, tokenizer.pad_id)["bits_per_byte"]
    quantized.eval()
    args.out.parent.mkdir(parents=True, exist_ok=True)
    baseline_path = args.out.with_suffix(".float.pt")
    quantized_path = args.out.with_suffix(".int8.pt")
    torch.save(model.state_dict(), baseline_path)
    torch.save(quantized.state_dict(), quantized_path)
    prompt = torch.tensor([[tokenizer.bos_id]], device=device)
    model.generate_cached(prompt, 2)
    quantized.generate_cached(prompt, 2)
    _, baseline_seconds = timed_generation(model, prompt, args.quant_tokens, True)
    _, quantized_seconds = timed_generation(quantized, prompt, args.quant_tokens, True)
    _write(
        args.out,
        {
            "baseline_bits_per_byte": baseline_bpb,
            "baseline_model_bytes": baseline_path.stat().st_size,
            "baseline_tokens_per_second": args.quant_tokens / baseline_seconds,
            "bits_per_byte_delta": quantized_bpb - baseline_bpb,
            "device": str(device),
            "dtype": str(next(model.parameters()).dtype),
            "generated_tokens": args.quant_tokens,
            "int8_bits_per_byte": quantized_bpb,
            "int8_model_bytes": quantized_path.stat().st_size,
            "int8_tokens_per_second": args.quant_tokens / quantized_seconds,
            "pytorch": torch.__version__,
            "type": "int8_benchmark",
        },
    )

The earlier one-step CPU command only proved that the files and the code path worked on a random model. The finished Kaggle run gives the actual trained-checkpoint answer. It used one Tesla T4, PyTorch 2.10.0+cu128, float16 activations, the same held-out blocks and one timed 256-token generation per variant after warmup.

Measured on a T4
Measured comparison
float1650.1 MiB
int829.2 MiB
File size saved41.7%inference state dictionaries
Int8 decode slowdown20.1%256 tokens, including prefill
Validation bits per byte0.483929 → 0.483954lower is better

One timed T4 run per variant. The file shrank and held-out quality barely moved, but int8 decoded more slowly. This PyTorch path reconstructs floating-point weights on every call; the timing does not isolate that cost. The bars compare observed values.

Switch between the saved state-file sizes and cached generation speed. Both bars come from the same trained Day 4 checkpoint. Bits per byte is shown under the bars, because its change is way too small to show honestly as a bar.

The float16 file was 52,542,613 bytes and int8 was 30,652,347 bytes, so 41.66 percent smaller. Held-out bits per byte went from 0.48392883 to 0.48395385, a +0.00002503 change, which is far below the +0.01 stop threshold. But cached generation dropped from 104.31 to 83.30 tokens per second, 20.14 percent slower. The path rebuilds float weights on every linear call, which is the likely cost, but one timing can't isolate the cause or give us a speed distribution.

Decision, keep int8 as an opt-in smaller checkpoint and for the Day 8 merge experiment, and keep float16 as the default inference path, because it decoded faster and had marginally better bits per byte. So this int8 implementation is a storage tradeoff, not a speed optimization.