Lab: Logit Distillation with TRL's Distillation Trainers
Validated on: written from the documentation cited above; not yet validated on hardware on any track. The per-track versions, memory peaks and wall-clock times will be recorded here when the validation pass is done.
Objective
Section titled “Objective”By the end of this lab you will have trained a student against a teacher’s probability distribution rather than against its text, and you will be able to say whether that was worth the memory it cost.
Concretely: a tokeniser compatibility report for your pair, a memory budget written down before the
run, a training run with TRL’s DistillationTrainer, a divergence loss you can read and explain,
and a comparison against the sequence-level student from the previous lab on the same tasks, with
what each route cost taken from the run log.
The result may well be that the sequence-level student is as good and cost a fifth as much. That is a legitimate outcome of this lab and a useful thing to know about your own hardware.
Requirements
Section titled “Requirements”The Part 11 training environment with TRL 1.12.0 · verified 2026-09-08, transformers 5.16.1 · verified 2026-09-08 and PEFT 0.20.0 · verified 2026-09-08, the seed prompt file from the previous lab, your Part 10 task file, and 60 minutes of which about 30 are unattended.
This is the one page in this part with a real memory floor, because two models are resident.
| Memory | Teacher | Student | Notes |
|---|---|---|---|
| 32 GB and above | Qwen3-8B | Qwen3-1.7B | Comfortable; the headline pair |
| 24 GB | Qwen3-8B | Qwen3-1.7B | Under a gigabyte of headroom; see the budget below |
| 12 to 16 GB | Qwen3-4B | Qwen3-0.6B | The reduced path; not the primary path this page is written for |
Both pairs are same-family and Apache-2.0 according to their model cards, read on 2026-09-09.
Track S — NVIDIA DGX Spark
The primary track for this lab, along with N. The 128 GB pool makes the headline pair
comfortable and leaves room to raise --max-completion-length and see what it costs. Work inside
the NGC PyTorch container from Part 11 with your course directory mounted.
TRL’s distillation trainer can use vLLM to generate the student’s on-policy completions, which is the slow part of every step. That is available on this track and is the first thing to try if the run is slower than you can stand; it is not required, and the lab is written without it so that the same commands work on all four tracks.
Track X — AMD Ryzen AI Max+ 395Partial
This lab trains rather than serves, so it needs the ROCm build of PyTorch. The ROCm 10.0.0 compatibility matrix dated 2026-08-14 lists gfx1151 without a support-tier qualifier and AMD's PyTorch install page read on 2026-09-09 does not mention the chip. On the CPU, an on-policy trainer that generates at every step is impractically slow rather than merely slow.
Where the ROCm wheels work, the 128 GB pool makes this lab comfortable and everything below
applies unchanged. Confirm with python -c 'import torch; print(torch.cuda.is_available())'
first.
Where they do not, this is the one page in the part with no honest CPU path. The previous lab’s CPU route works because supervised fine-tuning does no generation; this trainer generates a completion at every step, and on a CPU that turns a 30-minute run into an overnight one. Read the lab, run the tokeniser check in task 1, which needs no accelerator, and take the comparison in task 6 from the sequence-level student you already have.
Track M — Apple siliconPartial
mlx-lm has no distillation trainer at the time of writing, so this runs under PyTorch's MPS backend in float32, which doubles the memory of both models and rules out the headline pair.
Use the reduced pair, Qwen3-4B teacher and Qwen3-0.6B student, and expect float32 rather than bfloat16. In float32 those two models are about 16 GB and 2.4 GB of weights respectively, so a 128 GB Mac has room and a 16 GB Mac does not.
The script detects the MPS backend and prints the pair it recommends. Everything else on this page, including the loss discussion and the comparison, applies unchanged.
Track N — NVIDIA desktop or laptop
The tier table above is written for this track. At 24 GB the headline pair fits with under a gigabyte to spare, so close everything else on the machine, and if the card is also driving your displays, use the reduced pair instead. There is no shame in it: the reduced pair demonstrates every idea on this page and finishes sooner.
On Windows, work inside WSL2, and remember that the virtual machine’s memory limit is what the training process sees.
Working directory and terminal roles
Prepare the course execution workspace once before this procedure. It includes this part's scripts, data and shared Python helpers. In the client or training terminal, select this directory:
RunnableAll tracks
export LABS_ROOT="${LABS_ROOT:-$HOME/llm-course/labs}"export LAB_DIR="$LABS_ROOT/part-15-distillation"cd "$LAB_DIR"pwdtest -f "train-logit-distil.py"Expected result: pwd ends in part-15-distillation and the file check returns successfully. If it does not, finish workspace preparation before continuing. Activate the environment in the requirements for your track. Bare script and data filenames below are relative to this directory; paths to earlier experiments must point at the artefacts you actually retained.
Keep each foreground server in a separate terminal and send requests from this terminal. Reapply lesson-specific environment variables in each new shell. Stop at the first failed checkpoint and retain its output; the execution guide explains how to distinguish missing files, endpoint failures and capacity problems.
1. Check the tokenisers before anything else
Section titled “1. Check the tokenisers before anything else”This is the check that distinguishes a lab from a wasted evening. Logit distillation applies the teacher’s probability for token id n to the student’s vocabulary at index n. If the two vocabularies differ, the loss is well defined, the training runs, the loss falls, and the student learns the wrong thing. TRL’s own documentation warns that a teacher with a different vocabulary trains against the wrong tokens, silently when its vocabulary is no larger than the student’s.
RunnableAll tracks
"""Distil a student against a teacher's next-token distribution with TRL.
Purpose: the logit lab's training run. Holds a teacher and a student in memory at once and trains the student to match the teacher's distribution rather than its text. Two trainers are selectable: TRL's DistillationTrainer, which release 1.12.0's documentation lists in the stable API and which is on-policy (the student generates the completions it is trained on), and the experimental GKDTrainer, whose lmbda setting lets you dial the same run from fully off-policy to fully on-policy so the two ends can be compared.Platform: spark, nvidia (CUDA) primary; strix on ROCm, dated on the page; mac under PyTorch MPS in float32 with the small pair only. Two models are resident, so this is the one page in the part with a real memory floor.Minimum memory: 24 GB for the 8B-to-1.7B pair; 12 to 16 GB for the 4B-to-0.6B pairAssumes: torch, transformers, trl and peft installed in the active environment; a prompt file in JSON Lines with a "prompt" field on every line, as written by make-seed-prompts.py or derived from the sequence-level lab's training data; distillog.py sits next to this file. The teacher and the student must share a tokeniser: this script checks and refuses to run if they do not.
Usage: python3 train-logit-distil.py --teacher Qwen/Qwen3-8B --student Qwen/Qwen3-1.7B \\ --prompts seeds/prompts.jsonl --output-dir runs/logit-qwen3-1.7b \\ --labbook labbook.md python3 train-logit-distil.py --teacher Qwen/Qwen3-4B --student Qwen/Qwen3-0.6B \\ --prompts seeds/prompts.jsonl --output-dir runs/logit-qwen3-0.6b \\ --max-completion-length 256 --batch-size 1 --grad-accum 8 python3 train-logit-distil.py --teacher Qwen/Qwen3-8B --student Qwen/Qwen3-1.7B \\ --prompts seeds/prompts.jsonl --trainer gkd --lmbda 0.0 --beta 0.0 \\ --output-dir runs/gkd-offpolicy python3 train-logit-distil.py --teacher Qwen/Qwen3-8B --student Qwen/Qwen3-1.7B \\ --check-tokenisers-only"""
from __future__ import annotations
import argparseimport jsonimport timefrom pathlib import Path
import torchfrom datasets import load_datasetfrom peft import LoraConfigfrom transformers import AutoTokenizer
import distillog
DEFAULT_TARGETS = ["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]
# The strings the vocabulary check compares. Anything with special tokens, digits,# whitespace and a non-Latin script in it; two tokenisers that agree on all of# these agree on everything this lab does.PROBE_STRINGS = ( "<|im_start|>user\nHello, world!<|im_end|>\n", "<think>\n2 + 2 = 4\n</think>\nAnswer: 4", "def nth_word(text, n):\n return text.split()[n - 1]\n", "quantisation, tokeniser, générateur, 分词器",)
def compare_tokenisers(teacher_id: str, student_id: str) -> dict: """Encode the probes with both tokenisers and report every disagreement.
The distillation loss reads the teacher's probability for a token id and applies it to the student's vocabulary at the same index. If the two vocabularies differ, the loss is well defined, runs without error, and trains the student towards the wrong tokens. TRL's own documentation warns that a teacher with a different vocabulary trains against the wrong tokens, silently when its vocabulary is no larger than the student's, so this check runs first. """ teacher_tok = AutoTokenizer.from_pretrained(teacher_id) student_tok = AutoTokenizer.from_pretrained(student_id) report = { "teacher": teacher_id, "student": student_id, "teacher_class": type(teacher_tok).__name__, "student_class": type(student_tok).__name__, "teacher_vocab_size": len(teacher_tok), "student_vocab_size": len(student_tok), "mismatched_probes": [], "special_token_differences": [], } for probe in PROBE_STRINGS: a = teacher_tok(probe, add_special_tokens=False)["input_ids"] b = student_tok(probe, add_special_tokens=False)["input_ids"] if a != b: report["mismatched_probes"].append({ "probe": probe[:60], "teacher_ids": a[:20], "student_ids": b[:20], }) teacher_specials = dict(sorted(teacher_tok.get_added_vocab().items())) student_specials = dict(sorted(student_tok.get_added_vocab().items())) for token, tid in teacher_specials.items(): if student_specials.get(token) != tid: report["special_token_differences"].append( {"token": token, "teacher_id": tid, "student_id": student_specials.get(token)} ) report["compatible"] = ( report["teacher_vocab_size"] == report["student_vocab_size"] and not report["mismatched_probes"] and not report["special_token_differences"] ) return report
def print_tokeniser_report(report: dict) -> None: print(f"teacher tokeniser: {report['teacher_class']}, {report['teacher_vocab_size']} entries") print(f"student tokeniser: {report['student_class']}, {report['student_vocab_size']} entries") if report["compatible"]: print("compatible: the same ids for every probe, and the same special-token ids") return print("NOT compatible:") if report["teacher_vocab_size"] != report["student_vocab_size"]: print(" vocabulary sizes differ") for item in report["mismatched_probes"]: print(f" probe {item['probe']!r}") print(f" teacher {item['teacher_ids']}") print(f" student {item['student_ids']}") for item in report["special_token_differences"][:10]: print(f" special token {item['token']}: teacher {item['teacher_id']}, student {item['student_id']}")
def pick_device() -> str: if torch.cuda.is_available(): return "cuda" mps = getattr(torch.backends, "mps", None) if mps is not None and mps.is_available(): return "mps" return "cpu"
def as_prompt_only(path: str) -> str: """Accept either a prompt file or the sequence-level lab's training file.
DistillationTrainer wants a prompt-only dataset, because the student writes the completions itself. Passing it a file that also carries the teacher's completions would be silently ignoring the column that took the longest to make, so the script says which shape it found. """ first = None with open(path, encoding="utf-8") as handle: for line in handle: if line.strip(): first = json.loads(line) break if first is None: raise SystemExit(f"{path} is empty") if "prompt" not in first: raise SystemExit(f"{path} has no 'prompt' field; make-seed-prompts.py writes one") if "completion" in first: print(f"note: {path} also has a 'completion' column. This trainer generates the " "student's own completions, so that column is not used here. It is what " "train-student.py trained on, which is exactly the comparison this lab makes.") return path
def main() -> None: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--teacher", default="Qwen/Qwen3-8B") parser.add_argument("--student", default="Qwen/Qwen3-1.7B") parser.add_argument("--prompts", default="seeds/prompts.jsonl") parser.add_argument("--output-dir", default="runs/logit-student") parser.add_argument("--trainer", choices=["distillation", "gkd"], default="distillation", help="distillation is the stable trainer; gkd is experimental") parser.add_argument("--beta", type=float, default=None, help="generalised JSD interpolation. 0.0 is forward KL, 1.0 reverse KL. " "Defaults to the trainer's own default when omitted.") parser.add_argument("--lmbda", type=float, default=0.5, help="GKD only: fraction of on-policy student data. 0.0 is fully off-policy.") parser.add_argument("--seq-kd", action="store_true", help="GKD only: train on teacher-generated output, i.e. sequence-level KD") parser.add_argument("--temperature", type=float, default=1.0) parser.add_argument("--epochs", type=float, default=1.0) parser.add_argument("--batch-size", type=int, default=1) parser.add_argument("--grad-accum", type=int, default=8) parser.add_argument("--lr", type=float, default=1e-4, help="adapter rate; the trainer's own default is 1e-6 for a full student") parser.add_argument("--max-completion-length", type=int, default=256) parser.add_argument("--rank", type=int, default=16) parser.add_argument("--alpha", type=int, default=32) parser.add_argument("--target-modules", nargs="+", default=DEFAULT_TARGETS) parser.add_argument("--full-student", action="store_true", help="train every student weight instead of a LoRA adapter; needs far more memory") parser.add_argument("--limit", type=int, default=None, help="use only this many prompts") parser.add_argument("--seed", type=int, default=0) parser.add_argument("--labbook", default=None) parser.add_argument("--check-tokenisers-only", action="store_true", help="run the vocabulary check and exit") parser.add_argument("--allow-tokeniser-mismatch", action="store_true", help="run anyway. Only for reproducing the fault in this part's challenge.") args = parser.parse_args()
report = compare_tokenisers(args.teacher, args.student) print_tokeniser_report(report) if args.check_tokenisers_only: return if not report["compatible"] and not args.allow_tokeniser_mismatch: raise SystemExit( "teacher and student do not share a tokeniser. Logit distillation matches " "probabilities index by index, so this run would train the student towards " "the wrong tokens without failing. Pick a same-family pair, or use " "sequence-level distillation, which needs only text. Pass " "--allow-tokeniser-mismatch to reproduce the fault deliberately." )
device = pick_device() bf16 = device == "cuda" and torch.cuda.is_bf16_supported() print(f"device: {device} precision: {'bfloat16' if bf16 else 'float32'}") if device == "mps": print("note: on Track M this runs in float32 under PyTorch's MPS backend. Use the " "4B-to-0.6B pair; the 8B teacher will not fit beside a student in float32.")
prompts_path = as_prompt_only(args.prompts) dataset = load_dataset("json", data_files={"train": prompts_path})["train"] if args.limit: dataset = dataset.select(range(min(args.limit, len(dataset)))) dataset = dataset.map( lambda row: {"prompt": [{"role": "user", "content": row["prompt"]}]}, remove_columns=[c for c in dataset.column_names if c != "prompt"], ) print(f"prompts: {len(dataset)}")
peft_config = None if args.full_student else LoraConfig( r=args.rank, lora_alpha=args.alpha, lora_dropout=0.05, target_modules=args.target_modules, bias="none", task_type="CAUSAL_LM", )
common = dict( output_dir=args.output_dir, num_train_epochs=args.epochs, per_device_train_batch_size=args.batch_size, gradient_accumulation_steps=args.grad_accum, learning_rate=args.lr, temperature=args.temperature, bf16=bf16, logging_steps=5, report_to="none", seed=args.seed, save_strategy="epoch", save_total_limit=1, )
if args.trainer == "distillation": from trl import DistillationConfig, DistillationTrainer
config = DistillationConfig( **common, max_completion_length=args.max_completion_length, **({"beta": args.beta} if args.beta is not None else {}), ) trainer = DistillationTrainer( model=args.student, teacher_model=args.teacher, args=config, train_dataset=dataset, peft_config=peft_config, ) method = "trl.DistillationTrainer (on-policy, generalised JSD)" else: from trl.experimental.gkd import GKDConfig, GKDTrainer
config = GKDConfig( **common, max_new_tokens=args.max_completion_length, lmbda=args.lmbda, seq_kd=args.seq_kd, **({"beta": args.beta} if args.beta is not None else {}), ) trainer = GKDTrainer( model=args.student, teacher_model=args.teacher, args=config, train_dataset=dataset, peft_config=peft_config, ) method = f"trl.experimental.gkd.GKDTrainer (lmbda={args.lmbda}, seq_kd={args.seq_kd})"
print(f"trainer: {method}") if peft_config is not None and hasattr(trainer.model, "print_trainable_parameters"): trainer.model.print_trainable_parameters()
if device == "cuda": torch.cuda.reset_peak_memory_stats() started = time.time() watts_start = distillog.sample_power() trainer.train() elapsed = time.time() - started watts_end = distillog.sample_power() peak_gb = round(torch.cuda.max_memory_allocated() / 1e9, 2) if device == "cuda" else None
trainer.save_model(args.output_dir) AutoTokenizer.from_pretrained(args.student).save_pretrained(args.output_dir)
history = trainer.state.log_history losses = [row["loss"] for row in history if "loss" in row] summary = { "first_loss": round(losses[0], 4) if losses else None, "final_loss": round(losses[-1], 4) if losses else None, "steps": len(losses), "peak_cuda_memory_gb": peak_gb, } print(json.dumps(summary, indent=2)) print(f"student saved to {args.output_dir}")
if args.labbook: readings = [w for w in (watts_start, watts_end) if w is not None] rec = distillog.record( labbook=args.labbook, lab="part-15/train-logit-distil", stage="train-logit", teacher={"id": args.teacher, "resident": True, "tokeniser_compatible": report["compatible"]}, student={"id": args.student, "adapter": args.output_dir, "method": "lora" if peft_config else "full"}, dataset={"prompts": prompts_path, "sha256": distillog.file_sha256(prompts_path), "used": len(dataset)}, hyperparameters={ "trainer": method, "beta": args.beta, "lmbda": args.lmbda if args.trainer == "gkd" else None, "seq_kd": args.seq_kd if args.trainer == "gkd" else None, "temperature": args.temperature, "max_completion_length": args.max_completion_length, "epochs": args.epochs, "batch_size": args.batch_size, "grad_accum": args.grad_accum, "learning_rate": args.lr, "precision": "bfloat16" if bf16 else "float32", "rank": args.rank if peft_config else None, }, seed=args.seed, cost=distillog.build_cost( seconds=elapsed, mean_watts=(sum(readings) / len(readings)) if readings else None, ), losses=summary, config_path=__file__, notes=None, ) print(f"recorded {rec['stage']} as {rec['run_id']} in {args.labbook}")
if __name__ == "__main__": main()RunnableAll tracks
python train-logit-distil.py \ --teacher Qwen/Qwen3-8B \ --student Qwen/Qwen3-1.7B \ --check-tokenisers-onlyOutput — what you should see
teacher tokeniser: Qwen2Tokenizer, 151936 entriesstudent tokeniser: Qwen2Tokenizer, 151936 entriescompatible: the same ids for every probe, and the same special-token idsThe check encodes four probe strings containing chat markers, thinking tags, code, digits and
non-Latin script, and compares the id sequences, then compares every added special token by id. Both
Qwen3 sizes declare the Qwen2Tokenizer class and the same ids for <|im_start|>, <|im_end|>,
<think> and </think>, which their tokenizer_config.json files show directly.
2. Budget the memory for two models
Section titled “2. Budget the memory for two models”The headline pair on a 24 GB machine: an 8B teacher and a 1.7B student with a rank-16 adapter
- Teacher weights, BF16, frozen
- 16.4 GB
- Student weights, BF16, frozen
- 3.4 GB
- Adapter, gradients and optimiser states
- 0.3 GB
- Activations and chunked divergence
- 1 GB
- Student generation cache
- 0.1 GB
- Reserved for the operating system
- 2 GB
- Free
- 0.8 GB
- Total
- 24 GB
Under a gigabyte free on a 24 GB machine. That is the honest picture, and it is why the tier table says 32 GB for comfort. A browser, a desktop compositor or a second Python process will take that gigabyte.
The reduced pair changes the first two bars to 8.0 GB and 1.2 GB and the adapter to 10.1 million parameters, about 0.16 GB, which is the same figure Part 11’s first training run reported for Qwen3-0.6B. That budget fits a 16 GB machine with room to work in.
3. Understand what the trainer will do before you run it
Section titled “3. Understand what the trainer will do before you run it”TRL TRL 1.12.0 · verified 2026-09-08 lists four trainers under knowledge distillation, and its release note
says the DistillationTrainer “graduates to the stable API — on-policy knowledge distillation that
matches a teacher’s full next-token distribution with a memory-efficient chunked JSD loss and
vLLM-powered generation”. Read on 2026-09-09, GKDTrainer, MiniLLMTrainer and
AsyncDistillationTrainer are marked experimental and live under trl.experimental.
Three things about the stable trainer surprise people, and all three are documented.
It is on-policy. The dataset it takes is prompt-only: “the student generates its own completions on-policy, so only the prompt is needed”. You are not feeding it the teacher answers from the previous lab. It generates the student’s own completions and asks the teacher what it would have said at each position.
The default divergence is reverse KL. beta defaults to 1.0. The documentation is explicit
that unlike GRPO’s beta, which penalises divergence from a reference model, “here it selects the
divergence itself; there is no reference-model KL penalty”. Zero is forward KL, one is reverse KL,
and a half is the symmetric Jensen-Shannon divergence.
The default learning rate is 1e-6. That is a full-model rate. This lab trains an adapter, and
the script’s default is 1e-4, for the reasons Part 13’s LoRA lesson gives.
Pseudocode — not a real command
for each step: student generates a completion for each prompt in the batch teacher and student each produce a distribution at every completion position loss = generalised Jensen-Shannon divergence between them, interpolated by beta backward pass updates the adapter only4. Train
Section titled “4. Train”RunnableAll tracks
python train-logit-distil.py \ --teacher Qwen/Qwen3-8B \ --student Qwen/Qwen3-1.7B \ --prompts seeds/prompts.jsonl \ --output-dir runs/logit-qwen3-1.7b \ --max-completion-length 256 \ --batch-size 1 \ --grad-accum 8 \ --epochs 1 \ --labbook labbook.mdOn the reduced pair, substitute --teacher Qwen/Qwen3-4B --student Qwen/Qwen3-0.6B and
--output-dir runs/logit-qwen3-0.6b.
Output — what you should see
teacher tokeniser: Qwen2Tokenizer, 151936 entriesstudent tokeniser: Qwen2Tokenizer, 151936 entriescompatible: the same ids for every probe, and the same special-token idsdevice: cuda precision: bfloat16prompts: 600trainer: trl.DistillationTrainer (on-policy, generalised JSD)trainable params: 17,432,576 || all params: 1,7xx,xxx,xxx || trainable%: 1.0xxxThe script reuses the seed prompts from the previous lab and says so if the file also has a
completion column: that column is what the sequence-level student trained on and this trainer does
not use it, which is precisely the comparison the two labs set up.
5. Read the loss, which is not a cross entropy
Section titled “5. Read the loss, which is not a cross entropy”The number falling in your terminal is a divergence between two distributions, and it reads differently from the cross entropy of a supervised run.
- It has a floor that is not zero for any interesting pair. The student cannot represent the teacher’s distribution exactly; it has fewer parameters. A divergence that flattens well above zero is the expected shape, not a stalled run.
- It is not comparable with a supervised loss. Do not put the sequence-level run’s final loss and this run’s final loss in the same column. They measure different things.
- It is comparable with itself across runs of the same pair at the same
betaand temperature. Change either and the numbers move for reasons that have nothing to do with the student.
TRL documents the metrics the trainer logs, and two are worth watching beside the loss.
completions/mean_length tells you how long the student’s own generations are; a collapse towards
the minimum usually means the student has learned to stop early rather than to answer well.
entropy is “average entropy of token predictions across generated completions”; a falling
divergence with a collapsing entropy is a student narrowing rather than learning, which is the
characteristic risk of the reverse-KL direction.
6. Compare against the sequence-level student
Section titled “6. Compare against the sequence-level student”Export the logit student the same way as the previous lab, serve it under a third alias, and run the comparison.
RunnableAll tracks
python ~/llm-course/merge-adapter.py \ --adapter runs/logit-qwen3-1.7b \ --merged-dir models/logit-qwen3-1.7b-mergedpython ~/llama.cpp/convert_hf_to_gguf.py models/logit-qwen3-1.7b-merged \ --outfile models/logit-qwen3-1.7b.gguf \ --outtype bf16~/llama.cpp/build/bin/llama-quantize \ models/logit-qwen3-1.7b.gguf \ models/logit-qwen3-1.7b-Q4_K_M.gguf \ Q4_K_MRunnableAll tracks
"""Put the sequence-level student and the logit student side by side, with what each cost.
Purpose: the logit lab's conclusion. Runs Part 10's harness over the same tasks for both students, asks the judge to compare them with the order swapped so position bias shows as a flip rate rather than hiding inside the answer, and reads the lab notebook for what each route cost in tokens, seconds and watt-hours. The output is a decision, not a score: which of the two routes to use next time, and on what evidence.Platform: all (pure Python over HTTP; both students are reached through an OpenAI-compatible API, so they may be served by any engine on any track)Minimum memory: 12 GB on the machine serving the students; this script needs very littleAssumes: Python 3.10 or newer; Part 10's run-eval.py and judge.py in --harness-dir; both students reachable at --base-url under the names given; distillog.py next to this file; the lab notebook holding the generate, filter, train and train-logit records written by the other scripts in this part.
Usage: python3 compare-students.py --harness-dir ~/eval --tasks my-tasks.json \\ --base-url http://127.0.0.1:4000/v1 \\ --sequence-student local/student-seq --logit-student local/student-logit \\ --judge-model local/chat --quant Q4_K_M --engine llama.cpp \\ --engine-version v0.4.0 --out-dir compare-out --labbook labbook.md
Add --skip-eval to reuse results files already in --out-dir, which is what you want when only the judging step needs rerunning."""
from __future__ import annotations
import argparseimport jsonimport subprocessimport sysfrom pathlib import Pathfrom typing import Any
import distillog
# Which run-log stage paid for which route. The sequence-level student paid for# generation and filtering as well as its own training; the logit student paid# only for its training run, because it never generated a dataset.ROUTE_STAGES = { "sequence": ("generate", "filter", "train"), "logit": ("train-logit",),}
def run(script: Path, arguments: list[str]) -> None: command = [sys.executable, str(script), *arguments] print("+ " + " ".join(command)) subprocess.run(command, check=True)
def checks(results_path: Path) -> tuple[int, int]: data = json.loads(results_path.read_text(encoding="utf-8")) return sum(1 for r in data["results"] if r["checks"]["passed"]), len(data["results"])
def route_cost(labbook: str, route: str) -> dict[str, Any]: """Add up the cost blocks of the stages that belong to one route.
A stage that recorded null for a field contributes nothing to that field and is counted under `incomplete`, so a total is never quietly made up of half the run. """ totals = {"prompt_tokens": 0, "completion_tokens": 0, "seconds": 0.0, "watt_hours": 0.0} present = {k: 0 for k in totals} stages_found = [] for record in distillog.read_stages(labbook): if record.get("stage") not in ROUTE_STAGES[route]: continue stages_found.append(record["stage"]) cost = record.get("cost") or {} for key in totals: value = cost.get(key) if isinstance(value, (int, float)): totals[key] += value present[key] += 1 return { "stages": stages_found, "totals": {k: (round(v, 2) if isinstance(v, float) else v) for k, v in totals.items()}, "incomplete": [k for k in totals if present[k] == 0], }
def main() -> None: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--harness-dir", required=True) parser.add_argument("--tasks", required=True) parser.add_argument("--base-url", default="http://127.0.0.1:8080/v1") parser.add_argument("--api-key", default=None) parser.add_argument("--sequence-student", required=True, help="the student trained on teacher text") parser.add_argument("--logit-student", required=True, help="the student trained against teacher logits") parser.add_argument("--judge-model", default=None) parser.add_argument("--quant", default="unknown") parser.add_argument("--engine", default="llama.cpp") parser.add_argument("--engine-version", default="unknown") parser.add_argument("--out-dir", default="compare-out") parser.add_argument("--skip-eval", action="store_true", help="reuse results files already in --out-dir") parser.add_argument("--labbook", default="labbook.md") args = parser.parse_args()
harness = Path(args.harness_dir) run_eval, judge = harness / "run-eval.py", harness / "judge.py" if not run_eval.is_file(): raise SystemExit(f"{run_eval} not found; point --harness-dir at your Part 10 evaluation directory") out = Path(args.out_dir) out.mkdir(parents=True, exist_ok=True)
files = {"sequence": out / "results-sequence.json", "logit": out / "results-logit.json"} models = {"sequence": args.sequence_student, "logit": args.logit_student}
for route, path in files.items(): if args.skip_eval and path.is_file(): print(f"reusing {path}") continue run(run_eval, [ "--base-url", args.base_url, *(["--api-key", args.api_key] if args.api_key else []), "--model", models[route], "--quant", args.quant, "--engine", args.engine, "--engine-version", args.engine_version, "--tasks", args.tasks, "--out", str(path), "--notes", f"Part 15 student comparison, {route} route", ])
if args.judge_model: if not judge.is_file(): raise SystemExit(f"{judge} not found, but --judge-model was given") run(judge, [ "compare", "--base-url", args.base_url, *(["--api-key", args.api_key] if args.api_key else []), "--judge-model", args.judge_model, "--results-a", str(files["sequence"]), "--results-b", str(files["logit"]), "--labbook", args.labbook, ]) print("\nRead the flip rate the comparison printed before reading the winner. A high " "flip rate means the judge is answering the order of the answers rather than " "their quality, and the head-to-head number means little.")
summary: dict[str, Any] = {} print("\n" + "=" * 72) print(f"{'route':<12}{'served as':<26}{'checks':>10}") for route, path in files.items(): passed, total = checks(path) cost = route_cost(args.labbook, route) summary[route] = {"model": models[route], "checks_passed": passed, "checks_total": total, "cost": cost} print(f"{route:<12}{models[route]:<26}{passed:>4}/{total:<5}")
print("\nWhat each route cost, from the run log:") for route in files: cost = summary[route]["cost"] totals = cost["totals"] stages = ", ".join(cost["stages"]) or "no stages found in the notebook" print(f" {route:<10} stages: {stages}") print(f" completion tokens {totals['completion_tokens']}, " f"{totals['seconds'] / 60:.1f} minutes, " f"{totals['watt_hours']} Wh") if cost["incomplete"]: print(f" not recorded anywhere: {', '.join(cost['incomplete'])}")
seq, log = summary["sequence"], summary["logit"] print() if seq["checks_passed"] == log["checks_passed"]: print("The two students passed the same number of deterministic checks. Choose on " "cost, on the judge comparison, and on which route you can run again next " "month, not on a difference that is not there.") else: better = "sequence" if seq["checks_passed"] > log["checks_passed"] else "logit" print(f"The {better} route passed more deterministic checks on this task set. That is " "one task set on one machine: report it with the settings above and treat it " "as evidence rather than as a general result.")
report_path = out / "comparison.json" report_path.write_text(json.dumps(summary, indent=2), encoding="utf-8") print(f"\nwritten to {report_path}")
rec = distillog.record( labbook=args.labbook, lab="part-15/compare-students", stage="compare", teacher=None, student={"sequence": models["sequence"], "logit": models["logit"]}, dataset={"tasks": args.tasks, "tasks_sha256": distillog.file_sha256(args.tasks)}, hyperparameters={"engine": args.engine, "engine_version": args.engine_version, "quant": args.quant, "judge_model": args.judge_model}, seed=None, scores=summary, config_path=__file__, notes=None, ) print(f"recorded {rec['stage']} as {rec['run_id']} in {args.labbook}")
if __name__ == "__main__": main()RunnableAll tracks
python compare-students.py \ --harness-dir ~/eval \ --tasks my-tasks.json \ --base-url http://127.0.0.1:4000/v1 \ --sequence-student local/student-distilled \ --logit-student local/student-logit \ --judge-model local/chat \ --quant Q4_K_M \ --engine llama.cpp --engine-version v0.4.0 \ --out-dir compare-out \ --labbook labbook.mdOutput — what you should see
route served as checkssequence local/student-distilled ../..logit local/student-logit ../..
What each route cost, from the run log: sequence stages: generate, filter, train completion tokens ..., ... minutes, ... Wh logit stages: train-logit completion tokens 0, ... minutes, ... WhThe cost lines are the point. The sequence route paid for generation, filtering and training; the logit route paid only for its own training, and generated nothing it kept. Whether that is cheaper depends entirely on your machine, and now you have the number for yours.
The script also runs Part 10’s judge in compare mode, which asks twice with the answers swapped.
Read the flip rate before the winner: a high flip rate means the judge is answering the order of the
answers rather than their quality.
7. Optional: the off-policy end, with GKD
Section titled “7. Optional: the off-policy end, with GKD”The classic offline recipe, where the student is scored against a fixed dataset rather than its own
generations, is GKDTrainer with a mixing fraction of zero. TRL documents lmbda=0.0 as reducing
“to supervised JSD where the student is trained with the token-level probabilities of the teacher”.
RunnableAll tracks
python train-logit-distil.py \ --teacher Qwen/Qwen3-8B \ --student Qwen/Qwen3-1.7B \ --prompts seeds/prompts.jsonl \ --trainer gkd --lmbda 0.0 --beta 0.0 \ --output-dir runs/gkd-offpolicy \ --labbook labbook.md--beta 0.0 selects forward KL, the mean-seeking direction, which with lmbda 0.0 is as close as
these trainers get to the 2015 recipe. Run it if you have time; the interesting comparison is not
which scores better but how much faster the off-policy run is, since it does not generate.
Check vocabulary alignment and memory before the trainer starts
Section titled “Check vocabulary alignment and memory before the trainer starts”Compare the teacher and student tokenisers, including token-to-ID mappings and special tokens. Equal vocabulary size alone does not establish that a logit at one index refers to the same event. If the method requires shared tokenisation and the check fails, stop and choose a compatible pair or the sequence-level lab.
Budget for both models plus student training state and activation/logit buffers. Test a tiny batch with the intended sequence length and dtype before the full run. Confirm that teacher parameters are frozen and that gradients reach the student. Record whether examples are teacher-generated, student-generated or supplied from the dataset; this determines which distillation experiment you are performing.
Compare with the sequence-level student using the same held-out task file and deployment precision. Do not compare raw losses from different objectives as a quality ranking. Keep the teacher access method, temperature, divergence configuration, data mixture and model revisions. If the full path does not fit, report the reduced experiment and its changed settings rather than silently offloading or shortening one candidate. The useful result is a controlled task comparison plus measured resource cost, not merely a lower divergence value during training.
Validation
Section titled “Validation”You are done when all of the following are true:
- the tokeniser check reported
compatiblefor your pair, and you have seen what an incompatible pair looks like; - the memory budget you wrote down before the run is in the notebook next to the peak the script reported;
- the trainer printed a trainable-parameter count matching the arithmetic for your student: about 17.4 million for Qwen3-1.7B at rank 16, about 10.1 million for Qwen3-0.6B;
- the divergence fell from the first logged step and flattened above zero;
completions/mean_lengthdid not collapse towards the minimum over the run;- a quantised GGUF of the logit student exists and an engine has generated from it;
compare-out/comparison.jsonexists with both routes’ checks and both routes’ costs;- the run log has a
train-logitline and acompareline, and you can state which route you would use again on this machine and why.
Expected outcome
Section titled “Expected outcome”Two students trained two ways, one comparison, and a cost figure for each route on your own hardware. The table is what to measure; the validation pass will fill it in.
| Track | Pair | Peak memory, GB | Training wall clock | Final divergence | Checks passed vs sequence student |
|---|---|---|---|---|---|
| S: DGX Spark, 128 GB | Qwen3-8B to Qwen3-1.7B | to be measured | to be measured | to be measured | to be measured |
| X: Ryzen AI Max+ 395 | Qwen3-8B to Qwen3-1.7B | to be measured | to be measured | to be measured | to be measured |
| M: Apple silicon, MPS float32 | Qwen3-4B to Qwen3-0.6B | to be measured | to be measured | to be measured | to be measured |
| N: NVIDIA, 32 GB and above | Qwen3-8B to Qwen3-1.7B | to be measured | to be measured | to be measured | to be measured |
| N: NVIDIA, 12 to 16 GB | Qwen3-4B to Qwen3-0.6B | to be measured | to be measured | to be measured | to be measured |
the four platform tracks, one machine each · TRL DistillationTrainer with a PEFT LoRA adapter on the student transformers 5.16.1, trl 1.12.0, peft 0.20.0 · teacher and student as listed per row, both models BF16 during training, except Track M in float32; Q4_K_M after export · 256 tokens of context · 2026-09-09
Not yet run on hardware on any track. The divergence column is comparable only between rows with the same pair, the same beta and the same temperature; it is not comparable with the cross-entropy loss of the sequence-level lab.
Troubleshooting
Section titled “Troubleshooting”The script refuses to run, saying the tokenisers do not match. That is the check working. Pick a same-family pair, or use sequence-level distillation, which needs only text. Override it only to reproduce the fault in this part’s challenge.
Out of memory before the first step. The two models did not fit. In order: drop to the reduced
pair, lower --max-completion-length, and confirm nothing else is holding accelerator memory. The
budget diagram tells you which bar you are fighting; the teacher’s is the one you cannot shrink.
Out of memory during the first generation. The weights fit and the generation cache did not.
Lower --max-completion-length; it bounds both the cache and the number of positions the divergence
is computed over.
Extremely slow steps. Generation dominates. On Track S or N, TRL documents vLLM-powered
generation for this trainer, which is the intended fix. Otherwise lower
--max-completion-length, and accept that on-policy training is slower per example than supervised
training by design.
The divergence goes down and entropy collapses. The student is narrowing onto one mode rather
than matching the distribution, which is the characteristic risk of reverse KL. Try --beta 0.5,
the symmetric divergence, or --beta 0.0 for forward KL, and compare on the evaluation rather than
on the loss.
ImportError on trl.experimental.gkd. The experimental modules move between releases. Check
the trainer page for the version you have installed rather than assuming the import path here is
still right; this is what “experimental” means in practice.
The logit student is worse than the sequence-level student. A legitimate result, and worth
reporting as one. Check three things before concluding: that completions/mean_length did not
collapse, that the comparison used the same task file and settings for both, and that you served the
model you think you served.
Cleanup
Section titled “Cleanup”Keep both adapters, both quantised exports, compare-out/ and the notebook. The project at the end
of this part can run either route, and Part 16’s quantisation lab wants a fine-tuned model to
measure.
RunnableAll tracks
rm -rf runs/logit-qwen3-1.7b/checkpoint-*rm -rf models/logit-qwen3-1.7b-merged models/logit-qwen3-1.7b.ggufThe base models stay in the Hugging Face cache. The teacher is the largest download here and is reused by the project.
What you learned
Section titled “What you learned”- The tokeniser check is not a formality. It is the difference between logit distillation and a silent, plausible-looking failure, and it costs thirty seconds.
- Two resident models is a different kind of budget. The teacher’s weights are the largest term and nothing shrinks them, which is why this page has the only real memory floor in the part.
- The stable trainer is on-policy. It takes prompts, not answers, and generates the completions it trains on, which is why it costs what it costs.
- A divergence is not a cross entropy. It has a non-zero floor set by the capacity gap, it is comparable only with itself at the same settings, and the metrics beside it say more about the run than the loss does.
- The comparison has two columns. Quality and cost. On one machine the logit route wins; on another the sequence-level route wins by not needing the memory at all, and now you know which yours is.
Record in the notebook: the tokeniser report; the predicted and actual peak memory; the trainable
parameter count; beta, temperature and max_completion_length; the first and final divergence;
whether completions/mean_length or entropy collapsed; the two students’ scores on the same task
file; and the cost of each route from the run log.
Check your understanding
Sources for this lesson
10 verified · checked 2026-09-09
- 01TRL documentation — Distillation Trainer§ Overview; Quick start; Computing the loss; Expected dataset type; Logged metrics; Train adapters with PEFT; DistillationConfighuggingface.co/docs/trl/en/distillation_trainer2026-09-09
- 02TRL documentation — Generalized Knowledge Distillation Trainer§ Usage tips; GKDConfighuggingface.co/docs/trl/en/gkd_trainer2026-09-09
- 03TRL documentation — index and trainer taxonomy§ What's New; Taxonomyhuggingface.co/docs/trl/en/index2026-09-09
- 04On-Policy Distillation of Language Models: Learning from Self-Generated Mistakes (Agarwal et al., arXiv:2306.13649)§ Abstractarxiv.org/abs/2306.136492026-09-09
- 05Qwen3-8B config.json§ Model overviewhuggingface.co/Qwen/Qwen3-8B2026-09-09
- 06Qwen3-1.7B config.jsonhuggingface.co/Qwen/Qwen3-1.7B/raw/main/config.json2026-09-09
- 07Qwen3-0.6B config.jsonhuggingface.co/Qwen/Qwen3-0.6B/raw/main/config.json2026-09-09
- 08Qwen3-4B tokenizer_config.jsonhuggingface.co/Qwen/Qwen3-4B/raw/main/tokenizer_config.json2026-09-09
- 09Qwen3-0.6B tokenizer_config.jsonhuggingface.co/Qwen/Qwen3-0.6B/raw/main/tokenizer_config.json2026-09-09
- 10PEFT documentation — LoRA developer guide§ LoraConfighuggingface.co/docs/peft/main/en/developer_guides/lora2026-09-09
Every technical claim on this page was checked against the official documentation of the tool, vendor or model publisher on the date shown, at the version pinned for the course. Where the course disagrees with folklore, the source is how you can tell which one to trust.