aiml
SearchMy learning
Menu

Lesson 18 of 21Module 5

Compare packed weight quantization and output error

Pack trained matrices into eight-bit values or paired four-bit codes. Compare numeric payload sizes and output errors using dequantized CPU inference.

Prerequisites

Compare a handwritten transformer with PyTorch; the course training checkpoint, numeric precision and matrix multiplication.

By the end, you can

  • Pack selected trained matrices into signed eight-bit values or paired four-bit codes with row scales.
  • Reconstruct floating-point weights and compare logits against the preserved full-precision checkpoint.
  • Separate numeric payload size, serialized artifact size and measured output changes from runtime-performance claims.
Runtime
Python 3.12+ and CPU PyTorch
Dependencies
PyTorch 2.14.1, pinned in requirements.txt

Lab code, tests and fixtures (ZIP, 1.8 MB)Quiz check your understanding

Progress is saved in this browser.

Using fewer bits for a matrix can reduce its stored numeric payload while changing the model computation. This activity packs actual trained-course weights, dequantizes them for the existing CPU predictor, and measures both payload and output differences. It keeps the original checkpoint intact.

When you need this. You want to inspect where a smaller weight representation saves bytes and where its rounding changes predictions. Measure the packed bytes and scales directly; a file labelled INT4 would not prove its values were packed or that inference uses integer arithmetic.

A row scale maps a matrix row to a symmetric integer range. The code divides by the scale, rounds and clamps, then stores the codes. Dequantization multiplies each restored code by the row scale. It quantizes the selected linear matrices only; embeddings, normalization parameters and biases remain float64. The implementation is a small explicit format, not an activation-aware quantization method or a specialized inference kernel.

Preserved trained weightsFull-precision predictorRow scales +actual packed codesDequantize to float64then run predictorCompare payload bytesand output differencesPreserved trained weightsFull-precision predictorRow scales +actual packed codesDequantize to float64then run predictorCompare payload bytesand output differences
The packed representation changes storage. Inference here uses reconstructed float64 matrices, while the preserved checkpoint supplies the comparison.

The lab loads the byte-preserved trained story-model params.json and its tokenizer. For each selected matrix it computes scales, writes actual byte payloads, and reconstructs a float64 tensor. The existing decoder consumes those reconstructed tensors. A full-precision forward pass on the same input supplies the logit and argmax reference.

The copied trained parameters and tokenizer come from the existing story-model training lesson. Their original files remain unchanged.

Download the complete lab and enter quantization-and-batching/. Create an isolated Python environment and install its pinned dependency with python -m pip install -r requirements.txt. The computation uses CPU tensors; no pretrained model download, GPU or paid service is needed. The PyTorch installation guide covers platform-specific installation choices. Another library version requires rerunning the checks.

1. Inspect the row scale and packed representation

For a row with maximum absolute value m, the scale is m/qmax; an all-zero row uses scale 1. qmax is 127 for eight bits or 7 for four bits. The four-bit format uses integer values -7 through 7, offsets each by 8, and stores adjacent codes in low and high nibbles of one byte. An odd final code gets a zero-valued padding code that is removed using the saved shape.

test_signed_four_bit_packing_and_odd_lengths checks signed values and an odd-length row. test_zero_row_and_error_bound_for_both_widths checks zero-row handling and reconstruction within half a row scale. These inspect the stored representation, not just a tensor cast.

2. Compare payload and model output

Execute the quantization experimentTerminal · bash
python run.py quantization

The input is mia has a red hat., encoded as [58,36,33,89,79,4]. The selected matrices are Wq, Wk, Wv, Wo, W1, W2, W_out. Full-precision numeric parameter payload is 59520 bytes. The following table reads the actual result:

Width Numeric model payload Selected packed matrices and scales Maximum logit error Changed argmax positions
8 bits 28544 bytes 5888 bytes 0.094337 0/6
4 bits 26240 bytes 3584 bytes 1.383815 1/6

The numeric payload includes float64 row scales and untouched parameters. It excludes container metadata; the raw record separately reports JSON serialization sizes. It is not measured process memory: this experiment retains full-precision and reconstructed tensors while comparing them. Unchanged argmax positions can still have different logits.

Read the complete implementation
experiment.pySource · pythonDownload
"""Actual CPU weight quantization and token-step scheduling experiments.

The scheduler uses logical arrival ticks, not measured network latency.
"""
import copy
import json
import math
from pathlib import Path
import torch
import torch.nn.functional as F
import decoder
from bpe import Tokenizer

HERE=Path(__file__).resolve().parent
MATRICES=['Wq','Wk','Wv','Wo','W1','W2','W_out']


def parameters():
    raw=json.loads((HERE/'params.json').read_text())
    decoder.CONFIG['d']=len(raw['tok_emb'][0])
    return {name:torch.tensor(value,dtype=torch.float64) for name,value in raw.items()}


def quantize(weight,bits):
    if bits not in (4,8) or weight.ndim!=2 or min(weight.shape)<1 or not torch.isfinite(weight).all():
        raise ValueError('finite matrix and 4 or 8 bits required')
    qmax=2**(bits-1)-1
    maximum=weight.abs().amax(1)
    scales=torch.where(maximum==0,torch.ones_like(maximum),maximum/qmax)
    q=(weight/scales[:,None]).round().clamp(-qmax,qmax).to(torch.int8)
    if bits==8:
        payload=bytes(q.view(torch.uint8).flatten().tolist())
    else:
        shifted=(q.flatten().to(torch.int16)+8).to(torch.uint8)
        if len(shifted)%2:shifted=torch.cat((shifted,torch.tensor([8],dtype=torch.uint8)))
        payload=bytes((shifted[::2] | (shifted[1::2]<<4)).tolist())
    return {'bits':bits,'shape':list(weight.shape),'scales':scales.tolist(),'payload_hex':payload.hex()}


def dequantize(artifact):
    bits=artifact['bits'];rows,cols=artifact['shape']
    if bits not in (4,8) or type(rows) is not int or type(cols) is not int or rows<1 or cols<1:
        raise ValueError('invalid quantized shape or width')
    scales=torch.tensor(artifact['scales'],dtype=torch.float64)
    if scales.shape!=(rows,) or not torch.isfinite(scales).all() or (scales<=0).any():
        raise ValueError('invalid row scales')
    data=bytearray.fromhex(artifact['payload_hex'])
    required=rows*cols if bits==8 else (rows*cols+1)//2
    if len(data)!=required:raise ValueError('quantized payload size mismatch')
    packed=torch.tensor(list(data),dtype=torch.uint8)
    if bits==8:q=packed.view(torch.int8).to(torch.float64)
    else:q=torch.stack((packed&15,packed>>4),1).flatten()[:rows*cols].to(torch.float64)-8
    return q.reshape(rows,cols)*scales[:,None]


def batched_logits(p,sequences):
    if not sequences:raise ValueError('nonempty batch required')
    for ids in sequences:decoder.inputs(p,ids,[0]*len(ids))
    length=max(map(len,sequences));d=p['tok_emb'].shape[1]
    ids=torch.zeros((len(sequences),length),dtype=torch.long)
    sizes=torch.tensor([len(s) for s in sequences])
    for i,sequence in enumerate(sequences):ids[i,:len(sequence)]=torch.tensor(sequence)
    x=p['tok_emb'][ids]+p['pos_emb'][:length]
    norm=lambda value,name:F.layer_norm(value,(d,),p[name+'_g'],p[name+'_b'],eps=decoder.model.EPS)
    h=norm(x,'ln1');q,k,v=(h@p[name] for name in ['Wq','Wk','Wv'])
    scores=(q@k.transpose(-1,-2))/math.sqrt(d)
    causal=torch.ones(length,length,dtype=torch.bool).tril()
    valid=torch.arange(length)[None,:]<sizes[:,None]
    allowed=causal[None,:,:]&valid[:,None,:]
    mixed=scores.masked_fill(~allowed,float('-inf')).softmax(-1)@v
    x=x+mixed@p['Wo'];h=norm(x,'ln2')
    x=x+F.relu(h@p['W1']+p['b1'])@p['W2']+p['b2']
    logits=norm(x,'lnf')@p['W_out']+p['b_out']
    return torch.stack([logits[i,n-1] for i,n in enumerate(sizes)])


def schedule(p,jobs,continuous=True,capacity=2):
    if type(capacity) is not int or capacity<1:raise ValueError('positive capacity required')
    if not isinstance(jobs,list) or not jobs or len(jobs)>64:
        raise ValueError('1..64 jobs required')
    if any(not isinstance(job,dict) or set(job)!={'id','arrival','prompt','tokens'} or not isinstance(job['id'],str) or not job['id'] for job in jobs):
        raise ValueError('named jobs with arrival, prompt and tokens required')
    if len({job['id'] for job in jobs})!=len(jobs):raise ValueError('unique job IDs required')
    for job in jobs:
        if type(job['arrival']) is not int or job['arrival']<0 or type(job['tokens']) is not int or job['tokens']<1:
            raise ValueError('nonnegative arrival and positive token budget required')
        decoder.inputs(p,job['prompt'],[0]*len(job['prompt']))
        if len(job['prompt'])+job['tokens']>len(p['pos_emb']):raise ValueError('context budget exceeded')
    pending=sorted(copy.deepcopy(jobs),key=lambda j:(j['arrival'],j['id']));active=[];done=[];ticks=[];tick=0
    with torch.no_grad():
        while pending or active:
            if continuous or not active:
                while pending and pending[0]['arrival']<=tick and len(active)<capacity:
                    job=pending.pop(0);job['generated']=[];job['admitted']=tick;active.append(job)
            if not active:
                tick=pending[0]['arrival'];continue
            logits=batched_logits(p,[j['prompt']+j['generated'] for j in active])
            tokens=logits.argmax(-1).tolist();ticks.append({'tick':tick,'active':[j['id'] for j in active]})
            keep=[]
            for job,token in zip(active,tokens):
                job['generated'].append(token)
                if len(job['generated'])==job['tokens']:
                    job['finished']=tick+1;done.append(job)
                else:keep.append(job)
            active=keep;tick+=1
    return {'policy':'continuous admission' if continuous else 'fixed batch until all finish',
            'capacity':capacity,'logical_ticks':tick,'batch_forward_calls':len(ticks),
            'jobs':sorted(done,key=lambda j:j['id']),'trace':ticks}


def quantization_run():
    p=parameters();tok=Tokenizer.load(HERE/'tokenizer.json');text='mia has a red hat.';ids=tok.encode(text)
    baseline,_=decoder.forward(p,ids,[0]*len(ids));rows=[]
    full=sum(value.numel()*value.element_size() for value in p.values())
    for bits in [8,4]:
        changed={name:value.clone() for name,value in p.items()};packed={}
        for name in MATRICES:
            packed[name]=quantize(p[name],bits);changed[name]=dequantize(packed[name])
        logits,_=decoder.forward(changed,ids,[0]*len(ids))
        payload=sum(len(bytes.fromhex(a['payload_hex']))+len(a['scales'])*8 for a in packed.values())
        untouched=sum(value.numel()*value.element_size() for name,value in p.items() if name not in MATRICES)
        rows.append({'bits':bits,'selected_matrices':MATRICES,'numeric_model_payload_bytes':payload+untouched,
                     'packed_matrix_bytes':payload,'unquantized_parameter_bytes':untouched,
                     'serialized_quantized_artifacts_bytes':len(json.dumps(packed).encode()),
                     'max_logit_error':(logits-baseline).abs().max().item(),
                     'changed_argmax_positions':int((logits.argmax(-1)!=baseline.argmax(-1)).sum()),
                     'positions':len(ids)})
    return {'mode':'actual local CPU quantization, packing and dequantized inference on an existing trained checkpoint',
            'input_text':text,'input_ids':ids,'full_precision_payload_bytes':full,'results':rows,
            'scope':'Selected linear matrices only; scales remain float64. No integer kernel, AWQ, GPU speed or quality benchmark.'}


def batching_run():
    p=parameters();tok=Tokenizer.load(HERE/'tokenizer.json')
    jobs=[{'id':'a','arrival':0,'prompt':tok.encode('mia has'),'tokens':2},
          {'id':'b','arrival':0,'prompt':tok.encode('zoe has'),'tokens':5},
          {'id':'c','arrival':0,'prompt':tok.encode('ben has'),'tokens':1}]
    fixed=schedule(p,jobs,False);continuous=schedule(p,jobs,True)
    same=[j['generated'] for j in fixed['jobs']]==[j['generated'] for j in continuous['jobs']]
    if not same:raise AssertionError('scheduling changed deterministic output')
    return {'mode':'actual local CPU batched token decoding under authored logical arrival ticks',
            'fixed':fixed,'continuous':continuous,'outputs_equal':same,
            'scope':'Logical ticks are not wall-clock latency or throughput. Full-prefix recomputation; no paged KV cache or vLLM claim.'}
Actual saved quantization executionRecorded output · textDownload
{
  "mode": "actual local CPU quantization, packing and dequantized inference on an existing trained checkpoint",
  "input_text": "mia has a red hat.",
  "input_ids": [
    58,
    36,
    33,
    89,
    79,
    4
  ],
  "full_precision_payload_bytes": 59520,
  "results": [
    {
      "bits": 8,
      "selected_matrices": [
        "Wq",
        "Wk",
        "Wv",
        "Wo",
        "W1",
        "W2",
        "W_out"
      ],
      "numeric_model_payload_bytes": 28544,
      "packed_matrix_bytes": 5888,
      "unquantized_parameter_bytes": 22656,
      "serialized_quantized_artifacts_bytes": 13296,
      "max_logit_error": 0.09433748097707007,
      "changed_argmax_positions": 0,
      "positions": 6
    },
    {
      "bits": 4,
      "selected_matrices": [
        "Wq",
        "Wk",
        "Wv",
        "Wo",
        "W1",
        "W2",
        "W_out"
      ],
      "numeric_model_payload_bytes": 26240,
      "packed_matrix_bytes": 3584,
      "unquantized_parameter_bytes": 22656,
      "serialized_quantized_artifacts_bytes": 8462,
      "max_logit_error": 1.3838154876852728,
      "changed_argmax_positions": 1,
      "positions": 6
    }
  ],
  "scope": "Selected linear matrices only; scales remain float64. No integer kernel, AWQ, GPU speed or quality benchmark.",
  "refusal": "finite matrix and 4 or 8 bits required",
  "command": [
    "python",
    "run.py",
    "quantization",
    "--record"
  ],
  "exit_status": 0,
  "environment": {
    "python": "3.12.8",
    "torch": "2.14.1",
    "platform": "macOS-27.0.1-arm64-arm-64bit"
  },
  "sources": {
    "experiment.py": "1306ee38f04deea2060cdbf6fa7449da38279f9fe7be2eec8893d3a2afe8a9eb",
    "decoder.py": "5706ca21af551b17ee54ca00f5a6011981b327e03b1e880b6712c06dd7d1c39a",
    "vendor/model.py": "0d3d469afd5f35ef19b04e0b0587143c39f8fbca37d6154e6435c4ed4e8b80fc",
    "run.py": "aaac2de2d29a3e5f70f654839dc454cd8ef0ac97fa14e870d245d28ab65d3022",
    "test_experiment.py": "f9c08f86d6ce9f9a5d78dd6acf572f81431711992e709ac6106c17b90f76d04d",
    "params.json": "26510c85e6e6bf6e9b1dd8766f20a062ce6da35eff4d50984f1b2aead774c1d5",
    "tokenizer.json": "7478ffcb69ca5383828956b1d6db10f2ca6bc925abab7122e8da6f5d05e61657",
    "bpe.py": "f2986e70b69224d9bae7694b085275d01d8cf0103b7f05399e8c9840a1679143"
  }
}
Actual local CPU quantization report, including the computed result, scope and refusal.
Actual local CPU execution. Full-resolution capture · Raw result · Capture provenance. Opening the saved report does not execute the experiment again.

run.py quantization performs new local computation and leaves saved files unchanged. --record deliberately replaces this stage’s results and report. The raw record retains the command, exit status, runtime and implementation hashes.

Failure cases

Boundary checked Required behavior
Invalid packed representation Refuse unsupported widths, empty axes, malformed shapes, payload lengths and scales.
Signed packing with an odd count Restore signed codes exactly in the controlled test and discard the final padding nibble using the stored shape.
Zero-row and reconstruction bound Keep zero rows finite and reconstruction error within half the row scale.

The representation cannot be decoded. test_invalid_width_payload_and_scales_refused rejects unsupported bit widths, empty matrix axes, mismatched payload sizes, invalid shapes and nonpositive row scales. The saved invalid-width refusal is finite matrix and 4 or 8 bits required. Correct the representation instead of supplying zero weights to make inference continue.

The lower-bit model changes an output. The changed-argmax count in the table is an observed consequence, not a parser failure. Inspect the logits at those input positions before deciding whether a compressed model meets a task requirement. One short input cannot establish generation quality or an acceptable accuracy tradeoff.

Run python -m unittest -v test_experiment.py. In a separate copy, inspect an all-zero row and an odd-length row before dequantization. Explain why the saved shape is needed even when the packed byte count is correct.

Interpret the result at its actual scope

The code performs floating-point inference after dequantization. It does not execute INT8 or INT4 matrix-multiplication kernels, implement AWQ, compare GPU speed, or measure throughput. Keep weight-storage comparisons separate from deployment memory, latency and task-quality measurements. The original trained parameters and earlier recordings remain unchanged.

Check your understanding

The INT4 payload is smaller, but the decoder receives dequantized float64 weights. What did this run measure?

Choose one answer, then check your reasoning.

Read the explanation

Actual codes are packed, then reconstructed for the existing predictor. Those are the measured boundaries.

References

Primary sources checked October 24, 2026. The saved local execution, rather than the cited research results, supplies the measurements above.

Written by Arjun Prabhulal.

Module 5

Compare inference work and adaptation choices