Course: Course 3 — LLM Fine-Tuning Masterclass Module: FT19 — Quantization Formats Lab title: Quantize Three Ways Duration: 3–5 hours (the core lab of Pillar 6 — produces a defensible per-target recommendation) Environment: Python 3.11+. A consumer NVIDIA GPU (RTX 4090 / 24GB) OR a rented A10g/A100 (RunPod, Lambda, Colab Pro) for the AWQ path; Apple Silicon (M-series Mac) for the MLX path; CPU-only works for the GGUF path. ~25GB free disk for the base + three export artifacts.
By the end of this lab you will have:
This lab turns the format decision matrix from a table you read into a table you measured. The deliverable is not "three quantized files" — it is a defensible recommendation grounded in your own measurements.
Prerequisite. This lab assumes you have a fine-tuned checkpoint from FT11. If you do not, use a small openly-licensed instruct model as a stand-in (e.g.,
Qwen/Qwen2.5-1.5B-Instruct) and note that you are using an un-fine-tuned base. The mechanics are identical; only the "is this your steering preserved?" question differs.
You need three separate environments because the format toolchains do not all coexist cleanly. Set up one venv per path; this also mirrors how you would do it in production (the conversion happens wherever the target toolchain lives, not on the training box necessarily).
# Shared venv for measurement (perplexity, benchmark, size)
python3.11 -m venv ft19-measure-env && source ft19-measure-env/bin/activate
pip install -q transformers accelerate torch datasets evaluate
deactivate
# GGUF path: clone llama.cpp and build the quantize tool
git clone https://github.com/ggerganov/llama.cpp
cd llama.cpp && pip install -r requirements/requirements-convert_hf_to_gguf.txt
# Build llama-quantize (macOS: make; Linux w/ CUDA: make GGML_CUDA=1)
make
cd ..
# AWQ path
python3.11 -m venv ft19-awq-env && source ft19-awq-env/bin/activate
pip install -q autoawq transformers
deactivate
# MLX path (Apple Silicon only; skip on CUDA boxes — you will use a prebuilt MLX instead)
python3.11 -m venv ft19-mlx-env && source ft19-mlx-env/bin/activate
pip install -q mlx-lm
deactivate
Hardware note. The AWQ path needs CUDA (AutoAWQ's GPU kernels). The MLX path needs Apple Silicon. The GGUF path runs anywhere. If you are on a single machine, do the paths it supports and use a published equivalent for the others (document this in the report). The point is to learn the workflows and measurements, not to own every piece of hardware.
Ensure your FT11 checkpoint is a merged FP16/BF16 Hugging Face directory. If you trained with a LoRA adapter, merge it first.
# merge_adapter.py — run in ft19-measure-env
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel
import torch
BASE_ID = "Qwen/Qwen2.5-1.5B" # the base you fine-tuned from
ADAPTER_PATH = "./ft11-lora-out/checkpoint-XXX" # your FT11 adapter
MERGED_PATH = "./ft19-source-model"
base = AutoModelForCausalLM.from_pretrained(BASE_ID, torch_dtype=torch.bfloat16)
model = PeftModel.from_pretrained(base, ADAPTER_PATH)
model = model.merge_and_unload() # Layer 2 merges into Layer 1
model.save_pretrained(MERGED_PATH, safe_serialization=True)
tok = AutoTokenizer.from_pretrained(BASE_ID)
tok.save_pretrained(MERGED_PATH)
print("Merged checkpoint saved to", MERGED_PATH)
If you already merged in FT11, point everything at that directory and skip ahead. Record the path — it is SOURCE_MODEL for every conversion below.
# measure_baseline.py — run in ft19-measure-env
import os, torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from datasets import load_dataset
SOURCE = "./ft19-source-model"
size_bytes = sum(os.path.getsize(os.path.join(SOURCE,f)) for f in os.listdir(SOURCE) if f.endswith(".safetensors"))
print(f"FP16 size: {size_bytes/1e9:.2f} GB")
tok = AutoTokenizer.from_pretrained(SOURCE)
model = AutoModelForCausalLM.from_pretrained(SOURCE, torch_dtype=torch.bfloat16, device_map="auto")
# Perplexity on a small slice of wikitext (proxy for quality)
data = load_dataset("wikitext","wikitext-2-raw-v1", split="test")["text"]
enc = tok("\n\n".join(data), return_tensors="pt").to(model.device)
with torch.no_grad():
out = model(**enc, labels=enc["input_ids"])
print(f"FP16 perplexity: {torch.exp(out.loss).item():.3f}")
Record: FP16_SIZE_GB and FP16_PPL. These are the reference numbers every quantized variant gets compared against.
cd llama.cpp
# Step 1: HF checkpoint -> F16 GGUF
python convert_hf_to_gguf.py ../ft19-source-model --outfile ../model-f16.gguf
# Step 2: quantize to the sweet spot
./llama-quantize ../model-f16.gguf ../model-Q4_K_M.gguf Q4_K_M
cd ..
ls -lh model-Q4_K_M.gguf
Record GGUF_SIZE_GB.
# llama.cpp's bundled benchmark; reports tokens/sec
./llama.cpp/llama-bench -m model-Q4_K_M.gguf -p 128 -n 128
Record GGUF_TOKENS_PER_SEC (the tg128 column is generation speed).
llama.cpp can compute perplexity directly on the GGUF:
./llama.cpp/llama-perplexity -m model-Q4_K_M.gguf -f wikitext-2-raw-test.txt 2>&1 | tail -5
(Download a plain-text wikitext-2 test file, or extract it from the dataset in Phase 1.) Record GGUF_PPL.
Teaching moment: GGUF perplexity should be very close to the FP16 baseline (typically within 1–3% at Q4_K_M). If you see a large gap, you likely hit a known-bad layer; try Q5_K_M and re-measure. This is the "Q4 is near-lossless" claim, verified on your own model.
# awq_quantize.py — run in ft19-awq-env (CUDA required)
from awq import AutoAWQForCausalLM
from transformers import AutoTokenizer
SOURCE = "./ft19-source-model"
OUT = "./ft19-awq-model"
quant_config = { "zero_point": True, "q_group_size": 128, "w_bit": 4, "version": "GEMM" }
model = AutoAWQForCausalLM.from_pretrained(SOURCE)
tokenizer = AutoTokenizer.from_pretrained(SOURCE)
# AutoAWQ runs a calibration pass internally (default calibration set)
model.quantize(tokenizer, quant_config=quant_config)
model.save_quantized(OUT)
tokenizer.save_pretrained(OUT)
print("AWQ model saved to", OUT)
Record AWQ_SIZE_GB.
The cleanest speed measurement is through vLLM (which is where AWQ-Marlin runs in production). If vLLM is not available, fall back to the transformers AWQ loader — slower, but the perplexity number is still valid.
pip install -q vllm # in the awq env, or a fresh venv
# awq_measure.py
from vllm import LLM, SamplingParams
import os
OUT = "./ft19-awq-model"
llm = LLM(model=OUT, quantization="awq_marlin", dtype="float16", gpu_memory_utilization=0.8)
sp = SamplingParams(max_tokens=128, temperature=0.0)
# Speed: generate 128 tokens across a batch of prompts
prompts = ["The capital of France is"] * 8
import time
t0 = time.time()
outs = llm.generate(prompts, sp)
dt = time.time() - t0
total_tokens = sum(len(o.outputs[0].token_ids) for o in outs)
print(f"AWQ tokens/sec: {total_tokens/dt:.1f}")
For perplexity, load via transformers in the measure env:
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch
model = AutoModelForCausalLM.from_pretrained("./ft19-awq-model", device_map="auto")
# ... reuse the Phase 1 perplexity block, pointing at the AWQ dir
Record AWQ_TOKENS_PER_SEC and AWQ_PPL.
Skip this phase if you are not on Apple Silicon. Instead, download a published
mlx-community4-bit model of comparable size and measure that as a stand-in, noting in the report that you did not convert your own checkpoint for the MLX path.
source ft19-mlx-env/bin/activate
python -m mlx_lm.convert \
--hf-path ./ft19-source-model \
--quantize --q-bits 4 \
--mlx-path ./ft19-mlx-model
deactivate
ls -lh ft19-mlx-model
Record MLX_SIZE_GB.
# mlx_measure.py — run on the Mac, in the mlx env
import mlx_lm, time
model, tokenizer = mlx_lm.load("./ft19-mlx-model")
prompts = ["The capital of France is"] * 8
t0 = time.time()
for p in prompts:
mlx_lm.generate(model, tokenizer, prompt=p, max_tokens=128, verbose=False)
dt = time.time() - t0
print(f"MLX tokens/sec (per-prompt avg): {128*8/dt:.1f}")
For perplexity, MLX does not ship a one-liner; the practical proxy is to compute loss on a held-out text slice via mlx_lm.evaluate or compare against the GGUF/FP16 numbers using a small eval set. Record MLX_PPL (or note "see wikitext eval, delta vs FP16 within X%").
Assemble your numbers. This is the deliverable's centerpiece.
| Format | Size (GB) | Size vs FP16 | Tokens/sec | Perplexity | PPL vs FP16 | Runtime |
|---|---|---|---|---|---|---|
| FP16 (original) | _ | 1.0x | _ | _ | baseline | transformers/vLLM |
| GGUF Q4_K_M | _ | _ | _ | _ | _ | Ollama / llama.cpp |
| AWQ 4-bit (Marlin) | _ | _ | _ | _ | _ | vLLM / TGI |
| MLX 4-bit | _ | _ | _ | _ | _ | mlx-lm / LM Studio (Mac) |
State three deployment targets and name the format for each, defended with your numbers:
Example defense: "For Target 3 I ship AWQ 4-bit Marlin. My AWQ variant was 0.91x the size of GGUF Q4_K_M but delivered 3.2x the tokens/sec on the A100, and its perplexity was within 1.4% of FP16. GGUF's portability is irrelevant on a fixed vLLM server; AWQ-Marlin's kernel speed is the load-bearing property there."
The quality of the defense — not the choice itself — is what is graded.
Submit ft19-lab-report.md containing:
Q4_K_M file size, tokens/sec, perplexity.w_bit, q_group_size, version), file size, tokens/sec, perplexity. Note whether you measured via vLLM (Marlin) or transformers.mlx_lm.convert command, file size, tokens/sec, perplexity (or eval delta). If you used a stand-in model on non-Mac hardware, say so.q_group_size=128 is slightly larger due to zero-points and metadata.version mismatches the kernel. Check version="GEMM" and that the calibration set is reasonable. Try GEMV if GEMM is wrong for the target.-ngl) is set if a GPU is present.lm-eval) on FP16 vs each 4-bit variant. Confirm that perplexity deltas do (or do not) translate to benchmark deltas. Sometimes a small perplexity uptick is invisible on benchmarks; sometimes it is not.# Lab Specification — Module FT19: Quantization Formats
**Course**: Course 3 — LLM Fine-Tuning Masterclass
**Module**: FT19 — Quantization Formats
**Lab title**: Quantize Three Ways
**Duration**: 3–5 hours (the core lab of Pillar 6 — produces a defensible per-target recommendation)
**Environment**: Python 3.11+. A consumer NVIDIA GPU (RTX 4090 / 24GB) OR a rented A10g/A100 (RunPod, Lambda, Colab Pro) for the AWQ path; Apple Silicon (M-series Mac) for the MLX path; CPU-only works for the GGUF path. ~25GB free disk for the base + three export artifacts.
---
## Learning objectives
By the end of this lab you will have:
1. **Taken the fine-tuned model you produced in FT11** and converted it to **three deployment formats**: GGUF Q4_K_M, AWQ 4-bit, and MLX 4-bit.
2. **Measured each format** on three axes: on-disk size, inference speed (tokens/sec), and quality (perplexity + a benchmark).
3. **Compared all three to the FP16 original** and confirmed (or refuted) the claim that Q4 is a near-lossless sweet spot.
4. **Produced a per-deployment-target recommendation** — given a local-CPU target, a Mac target, and an NVIDIA-server target, which format ships, and why, defended with your own numbers.
This lab turns the format decision matrix from a table you read into a table you *measured*. The deliverable is not "three quantized files" — it is a defensible recommendation grounded in your own measurements.
> **Prerequisite.** This lab assumes you have a fine-tuned checkpoint from FT11. If you do not, use a small openly-licensed instruct model as a stand-in (e.g., `Qwen/Qwen2.5-1.5B-Instruct`) and note that you are using an un-fine-tuned base. The mechanics are identical; only the "is this your steering preserved?" question differs.
---
## Phase 0 — Environment setup (20 min)
You need three separate environments because the format toolchains do not all coexist cleanly. Set up one venv per path; this also mirrors how you would do it in production (the conversion happens wherever the target toolchain lives, not on the training box necessarily).
```bash
# Shared venv for measurement (perplexity, benchmark, size)
python3.11 -m venv ft19-measure-env && source ft19-measure-env/bin/activate
pip install -q transformers accelerate torch datasets evaluate
deactivate
```
```bash
# GGUF path: clone llama.cpp and build the quantize tool
git clone https://github.com/ggerganov/llama.cpp
cd llama.cpp && pip install -r requirements/requirements-convert_hf_to_gguf.txt
# Build llama-quantize (macOS: make; Linux w/ CUDA: make GGML_CUDA=1)
make
cd ..
```
```bash
# AWQ path
python3.11 -m venv ft19-awq-env && source ft19-awq-env/bin/activate
pip install -q autoawq transformers
deactivate
```
```bash
# MLX path (Apple Silicon only; skip on CUDA boxes — you will use a prebuilt MLX instead)
python3.11 -m venv ft19-mlx-env && source ft19-mlx-env/bin/activate
pip install -q mlx-lm
deactivate
```
> **Hardware note.** The AWQ path needs CUDA (AutoAWQ's GPU kernels). The MLX path needs Apple Silicon. The GGUF path runs anywhere. If you are on a single machine, do the paths it supports and use a published equivalent for the others (document this in the report). The point is to learn the *workflows* and *measurements*, not to own every piece of hardware.
---
## Phase 1 — Prepare the source checkpoint (15 min)
Ensure your FT11 checkpoint is a merged FP16/BF16 Hugging Face directory. If you trained with a LoRA adapter, merge it first.
```python
# merge_adapter.py — run in ft19-measure-env
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel
import torch
BASE_ID = "Qwen/Qwen2.5-1.5B" # the base you fine-tuned from
ADAPTER_PATH = "./ft11-lora-out/checkpoint-XXX" # your FT11 adapter
MERGED_PATH = "./ft19-source-model"
base = AutoModelForCausalLM.from_pretrained(BASE_ID, torch_dtype=torch.bfloat16)
model = PeftModel.from_pretrained(base, ADAPTER_PATH)
model = model.merge_and_unload() # Layer 2 merges into Layer 1
model.save_pretrained(MERGED_PATH, safe_serialization=True)
tok = AutoTokenizer.from_pretrained(BASE_ID)
tok.save_pretrained(MERGED_PATH)
print("Merged checkpoint saved to", MERGED_PATH)
```
If you already merged in FT11, point everything at that directory and skip ahead. Record the path — it is `SOURCE_MODEL` for every conversion below.
### Measure the FP16 baseline (size + perplexity)
```python
# measure_baseline.py — run in ft19-measure-env
import os, torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from datasets import load_dataset
SOURCE = "./ft19-source-model"
size_bytes = sum(os.path.getsize(os.path.join(SOURCE,f)) for f in os.listdir(SOURCE) if f.endswith(".safetensors"))
print(f"FP16 size: {size_bytes/1e9:.2f} GB")
tok = AutoTokenizer.from_pretrained(SOURCE)
model = AutoModelForCausalLM.from_pretrained(SOURCE, torch_dtype=torch.bfloat16, device_map="auto")
# Perplexity on a small slice of wikitext (proxy for quality)
data = load_dataset("wikitext","wikitext-2-raw-v1", split="test")["text"]
enc = tok("\n\n".join(data), return_tensors="pt").to(model.device)
with torch.no_grad():
out = model(**enc, labels=enc["input_ids"])
print(f"FP16 perplexity: {torch.exp(out.loss).item():.3f}")
```
**Record:** `FP16_SIZE_GB` and `FP16_PPL`. These are the reference numbers every quantized variant gets compared against.
---
## Phase 2 — Path A: GGUF Q4_K_M (45 min)
### 2a. Convert + quantize
```bash
cd llama.cpp
# Step 1: HF checkpoint -> F16 GGUF
python convert_hf_to_gguf.py ../ft19-source-model --outfile ../model-f16.gguf
# Step 2: quantize to the sweet spot
./llama-quantize ../model-f16.gguf ../model-Q4_K_M.gguf Q4_K_M
cd ..
ls -lh model-Q4_K_M.gguf
```
Record `GGUF_SIZE_GB`.
### 2b. Measure speed
```bash
# llama.cpp's bundled benchmark; reports tokens/sec
./llama.cpp/llama-bench -m model-Q4_K_M.gguf -p 128 -n 128
```
Record `GGUF_TOKENS_PER_SEC` (the `tg128` column is generation speed).
### 2c. Measure quality (perplexity)
llama.cpp can compute perplexity directly on the GGUF:
```bash
./llama.cpp/llama-perplexity -m model-Q4_K_M.gguf -f wikitext-2-raw-test.txt 2>&1 | tail -5
```
(Download a plain-text wikitext-2 test file, or extract it from the dataset in Phase 1.) Record `GGUF_PPL`.
> **Teaching moment:** GGUF perplexity should be very close to the FP16 baseline (typically within 1–3% at Q4_K_M). If you see a large gap, you likely hit a known-bad layer; try Q5_K_M and re-measure. This is the "Q4 is near-lossless" claim, verified on your own model.
---
## Phase 3 — Path B: AWQ 4-bit (60 min)
### 3a. Quantize with AutoAWQ
```python
# awq_quantize.py — run in ft19-awq-env (CUDA required)
from awq import AutoAWQForCausalLM
from transformers import AutoTokenizer
SOURCE = "./ft19-source-model"
OUT = "./ft19-awq-model"
quant_config = { "zero_point": True, "q_group_size": 128, "w_bit": 4, "version": "GEMM" }
model = AutoAWQForCausalLM.from_pretrained(SOURCE)
tokenizer = AutoTokenizer.from_pretrained(SOURCE)
# AutoAWQ runs a calibration pass internally (default calibration set)
model.quantize(tokenizer, quant_config=quant_config)
model.save_quantized(OUT)
tokenizer.save_pretrained(OUT)
print("AWQ model saved to", OUT)
```
Record `AWQ_SIZE_GB`.
### 3b. Measure speed + quality (via vLLM or transformers)
The cleanest speed measurement is through vLLM (which is where AWQ-Marlin runs in production). If vLLM is not available, fall back to the transformers AWQ loader — slower, but the perplexity number is still valid.
```bash
pip install -q vllm # in the awq env, or a fresh venv
```
```python
# awq_measure.py
from vllm import LLM, SamplingParams
import os
OUT = "./ft19-awq-model"
llm = LLM(model=OUT, quantization="awq_marlin", dtype="float16", gpu_memory_utilization=0.8)
sp = SamplingParams(max_tokens=128, temperature=0.0)
# Speed: generate 128 tokens across a batch of prompts
prompts = ["The capital of France is"] * 8
import time
t0 = time.time()
outs = llm.generate(prompts, sp)
dt = time.time() - t0
total_tokens = sum(len(o.outputs[0].token_ids) for o in outs)
print(f"AWQ tokens/sec: {total_tokens/dt:.1f}")
```
For perplexity, load via transformers in the measure env:
```python
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch
model = AutoModelForCausalLM.from_pretrained("./ft19-awq-model", device_map="auto")
# ... reuse the Phase 1 perplexity block, pointing at the AWQ dir
```
Record `AWQ_TOKENS_PER_SEC` and `AWQ_PPL`.
---
## Phase 4 — Path C: MLX 4-bit (40 min)
> Skip this phase if you are not on Apple Silicon. Instead, download a published `mlx-community` 4-bit model of comparable size and measure *that* as a stand-in, noting in the report that you did not convert your own checkpoint for the MLX path.
### 4a. Convert with mlx-lm
```bash
source ft19-mlx-env/bin/activate
python -m mlx_lm.convert \
--hf-path ./ft19-source-model \
--quantize --q-bits 4 \
--mlx-path ./ft19-mlx-model
deactivate
ls -lh ft19-mlx-model
```
Record `MLX_SIZE_GB`.
### 4b. Measure speed + quality
```python
# mlx_measure.py — run on the Mac, in the mlx env
import mlx_lm, time
model, tokenizer = mlx_lm.load("./ft19-mlx-model")
prompts = ["The capital of France is"] * 8
t0 = time.time()
for p in prompts:
mlx_lm.generate(model, tokenizer, prompt=p, max_tokens=128, verbose=False)
dt = time.time() - t0
print(f"MLX tokens/sec (per-prompt avg): {128*8/dt:.1f}")
```
For perplexity, MLX does not ship a one-liner; the practical proxy is to compute loss on a held-out text slice via `mlx_lm.evaluate` or compare against the GGUF/FP16 numbers using a small eval set. Record `MLX_PPL` (or note "see wikitext eval, delta vs FP16 within X%").
---
## Phase 5 — The comparison table (30 min)
Assemble your numbers. This is the deliverable's centerpiece.
| Format | Size (GB) | Size vs FP16 | Tokens/sec | Perplexity | PPL vs FP16 | Runtime |
| --- | --- | --- | --- | --- | --- | --- |
| **FP16 (original)** | _ | 1.0x | _ | _ | baseline | transformers/vLLM |
| **GGUF Q4_K_M** | _ | _ | _ | _ | _ | Ollama / llama.cpp |
| **AWQ 4-bit (Marlin)** | _ | _ | _ | _ | _ | vLLM / TGI |
| **MLX 4-bit** | _ | _ | _ | _ | _ | mlx-lm / LM Studio (Mac) |
### The per-target recommendation
State three deployment targets and name the format for each, defended with your numbers:
- **Target 1 — Local developer laptop (CPU-only, mixed offload):** format = ______. Defense: ______
- **Target 2 — Mac Studio M-series (unified memory):** format = ______. Defense: ______
- **Target 3 — NVIDIA production server (vLLM, concurrent users):** format = ______. Defense: ______
Example defense: *"For Target 3 I ship AWQ 4-bit Marlin. My AWQ variant was 0.91x the size of GGUF Q4_K_M but delivered 3.2x the tokens/sec on the A100, and its perplexity was within 1.4% of FP16. GGUF's portability is irrelevant on a fixed vLLM server; AWQ-Marlin's kernel speed is the load-bearing property there."*
The quality of the defense — not the choice itself — is what is graded.
---
## Deliverables
Submit `ft19-lab-report.md` containing:
- [ ] **Source checkpoint**: path, base model, whether it is your FT11 output or a stand-in. FP16 size + perplexity (the baseline).
- [ ] **Path A (GGUF)**: the two commands (convert + quantize), `Q4_K_M` file size, tokens/sec, perplexity.
- [ ] **Path B (AWQ)**: the AutoAWQ config (`w_bit`, `q_group_size`, `version`), file size, tokens/sec, perplexity. Note whether you measured via vLLM (Marlin) or transformers.
- [ ] **Path C (MLX)**: the `mlx_lm.convert` command, file size, tokens/sec, perplexity (or eval delta). If you used a stand-in model on non-Mac hardware, say so.
- [ ] **The comparison table**: filled in with your numbers.
- [ ] **The per-target recommendation**: three targets, one format each, 2–4 sentence defense per target using your numbers.
- [ ] **One honest surprise**: something your measurements showed that the module's generalities did not prepare you for (e.g., "AWQ was actually slower than GGUF on my consumer GPU because Marlin needs Hopper+" or "MLX 4-bit perplexity was closer to FP16 than either server format").
---
## Solution key
- **Sizes.** All three 4-bit variants should land in a similar band: roughly 25–30% of the FP16 size (a ~70–75% reduction). Exact numbers depend on tokenizer/config overhead and group size. GGUF Q4_K_M is typically the smallest of the three for a given model; MLX 4-bit is comparable; AWQ with `q_group_size=128` is slightly larger due to zero-points and metadata.
- **Speed.** The *rank order depends on hardware.* On Hopper/Blackwell GPUs, AWQ-Marlin should be fastest (optimized kernel, batched). On Apple Silicon, MLX should beat GGUF-on-Metal for generation throughput thanks to unified-memory exploitation. On CPU or mixed CPU/GPU consumer hardware, GGUF (llama.cpp) is the only one of the three that runs well — and that is precisely its strength. A student who reports "AWQ was slow" on a consumer GPU may have hit the Marlin kernel's architecture requirement — a legitimate, instructive finding.
- **Perplexity.** All three 4-bit variants should be within a few percent of FP16 (the Q4 sweet-spot claim). GGUF Q4_K_M and MLX 4-bit typically within ~1–2%; AWQ 4-bit within ~1–3%. If a student sees a large gap (>5%), suspect a calibration issue (AWQ) or a bad K-quant choice — have them try Q5_K_M / 8-bit group and re-measure. A clean confirmation of the sweet spot is the expected result.
- **The recommendation.** There is no single "best format" — the grade is whether the student mapped each target to the format that target's runtime consumes, defended with their own size/speed/quality numbers. Correct mapping: GGUF for local/CPU/Mac-via-Ollama; MLX for Mac-native; AWQ for vLLM/TGI on NVIDIA. A weak defense restates the matrix without the student's measurements; a strong defense cites specific cells.
- **Common failure modes to watch for:**
- *AWQ perplexity is wildly off:* calibration failed or `version` mismatches the kernel. Check `version="GEMM"` and that the calibration set is reasonable. Try `GEMV` if `GEMM` is wrong for the target.
- *GGUF is much slower than expected:* running on CPU without offload, or the wrong K-quant for the hardware. Check that GPU offload (`-ngl`) is set if a GPU is present.
- *MLX path skipped on a non-Mac box:* that is fine, but the student must use a stand-in and say so. A blank MLX row is not acceptable.
- *Perplexity identical across formats:* the student may have measured the FP16 by accident (pointed at the source dir). Check that each measurement points at the quantized artifact.
---
## Stretch goals
1. **Add a 5th column — Unsloth Dynamic 2.0 (or EXL2).** Convert the same checkpoint with a variable-rate quantizer and measure. Demonstrate (or refute) the claim that sensitivity-aware per-layer quant beats uniform Q4 on perplexity at the same file size.
2. **The K-quant sweep.** For the GGUF path, produce Q2_K, Q3_K_M, Q4_K_M, Q5_K_M, Q8_0 and plot size vs. perplexity. Find the knee of the curve on *your* model. This is the empirical version of the quality/size trade-off diagram.
3. **Cross-format quality probe.** Run a small task benchmark (GSM8K or MMLU via `lm-eval`) on FP16 vs each 4-bit variant. Confirm that perplexity deltas do (or do not) translate to benchmark deltas. Sometimes a small perplexity uptick is invisible on benchmarks; sometimes it is not.
4. **Round-trip your FT11 steering.** If your source checkpoint is a fine-tuned model with distinctive behavior (a format, a compliance profile), confirm that behavior survives quantization — generate from the FP16 and each quant on a prompt that exercises the steered behavior, and verify the quantized outputs preserve it. This is the "quantization preserves learned behavior" claim, tested directly.