Distillation internals¶
The teacher pass and the training head of gmlx distill keep their memory
bounded in ways the code enforces, and a set of measurements sits behind
the guide's defaults. The user guide is Distillation,
the flags are under gmlx distill, and every GB
here is decimal.
The teacher pass¶
The trunk runs over a chunk of --trunk tokens and keeps the hidden
states, which are small. The head then runs over those states in
sub-chunks of step positions, each of which materializes the logits
across the teacher's whole vocabulary. step is the largest size
whose logits and reduction temporaries fit under --logits-cap-gb.
gmlx/distill/teacher.py holds the budget per vocabulary element.
Every pass measures its first sub-chunk's peak. When the measured bytes exceed the budget, they replace it, and the step is derived again against the unchanged cap. When the step changes, the sub-chunk runs again from the same start at the new step to confirm that the peak fits. A second miss refuses the pass with the measured figure in the message.
Some families scale their logits after the projection, and the head
carries that scale. To catch a scale the head does not carry, the head
runs beside the model's own forward on a few tokens before the pass,
and again before train and eval score anything. A difference refuses
the run, so wrong logits never reach the cache.
A streaming MoE teacher reads its expert stacks again on every forward,
so its trunk chunk defaults to a larger size, which divides that traffic.
The manifest's throughput block records the forwards, the bytes
read and the stream bandwidth that the pass saw.
The training head¶
The student's head is fused into the loss and runs over the gathered
positions in chunks of --chunk positions, with a closed-form backward.
A chunk's memory scales linearly with --chunk and with the student's
vocabulary, and a chunk that holds tokenizer boundaries
holds more than one that does not.
train compares the closed form with the gradient of the head's own
forward on a few tokens, with the head in training mode. A student where
the two differ is refused, so a head that changes its input before the
projection, such as a Hadamard-folded head, never trains on a wrong
cotangent.
No head pass runs inside the trunk's gradient transform. MLX keeps every intermediate of a transform alive until the outer evaluation, so a head inside it would pin every chunk's logits at once. The trunk forward runs first, the head pass computes the loss and the cotangents of the gathered hidden states outside any transform, and a surrogate loss carries those cotangents back through the trunk.
The trunk therefore runs twice per step. Both forwards are seeded with the
step's seed right before they run, so LoRA dropout draws the same
mask in both and the cotangents land on the hidden states they came from.
A checkpointed layer replays its seed in the backward recompute. The
replay evaluates an array, which a compiled step cannot do, so
gmlx train refuses dropout with checkpointing.
The worked run¶
The guide's worked task used a Qwen3.6-27B teacher at UD-Q8_K_XL, a Qwen3.5-9B student at Q6_K, 615 training questions plus 264 combined ones, and two rounds.
| Step | Memory | Time | Disk |
|---|---|---|---|
gen |
The served teacher takes 36 GB. | The teacher writes 52 tokens per second at --concurrency 8, so the training prompts take about 4 hours. |
The replies take a few MB. |
cache |
The teacher takes about 40 GB, a few GB over its served size. | The teacher scores about 500 tokens per second. | Each position takes 1.6 KB at top-k 256. |
align |
Only the tokenizers load. | A conversation row takes about 25 ms on the CPU, and a plain-text row under 1 ms. | The output takes a few MB, unless --materialize writes the batch tensors too. |
train |
The 9B student peaks at 50.7 GB. | A step of 3 rows takes 9.3 s, so 678 steps take 1.75 hours. | Two checkpoints go under --ckpt-dir. |
eval |
Only the student loads. | Each slice takes minutes, longer with the adapter attached. | Two report files are written. |
These pass rates came out of the run, with the teacher and student on one tokenizer:
| Pass rate on | Untouched student | After round one | After round two |
|---|---|---|---|
| Held-out questions of the trained kinds | 0.022 | 0.817 | 0.882 |
| Questions of kinds never trained on | 0.017 | 0.917 | 0.925 |
| Combined held-out questions | 0.000 | 0.767 | 0.783 |
With the schema pasted into its prompt, the untouched student scored
0.946 on the held-out questions, so the adapter reached most of what
pasting the document gives. At the positions the document moved, the
student's nats per token fell from 4.41 to 0.70, against the teacher's
0.41 with the schema in view. The --chat-sanity, --chat-slice and
--tasks measures of eval did not move outside their noise.
Why the defaults are what they are¶
The guide's settings come from the same schema task, measured by the served pass rate on held-out questions. Each figure is one served sample of one adapter. Serving the same adapter again moves a pass rate by three or four items in a hundred, so differences of that size are sampling.
Combined training rows, which join two kinds of question in one prompt, raised the pass rate on kinds never trained on from 0.633 to 0.917. Rows that described the schema in prose instead of querying it lowered that rate. The second round, in which the student writes the replies and the teacher is cached over its verified ones with the schema in view, added about six and a half points on the trained kinds over round one. The other two slices moved by less than the sampling spread.
The loss defaults are --dk 1 --alm 1 --ce 0. A cross-entropy objective
on the teacher's tokens scored level with the sparse KL on the task
pass rates and behind it on every retention measure, so --ce stays at 0.
The hidden-state term, cache --hidden with train --hs, changed neither
the logit terms nor the served pass rates on this task, so it is off by
default.
Whole-reply bits per byte moves by a few thousandths when one
position in twenty gains a nat, which is inside the noise of a run, so
eval --reply-positions restricts the reply slice to the positions the
census found.
The loss has no per-token weighting. The tokens that decide a tool call are its opener and closer, the key names and the tool name, and they are the most certain positions of a reply. They sit at rank 1 in the cache with the whole mass, so the student already sees them at full weight.
The cross-tokenizer result¶
Aligned onto gemma-4-12b-it at Q6_K from another tokenizer family and served with thinking off, the same schema cache gave an adapter that reached 0.296 on the held-out questions against 0.930 with the schema pasted into its prompt. That is about a third of the gap. It reached 0.050 on the kinds never trained on, where the same-tokenizer student reached 0.925.
The adapter answered the single-table questions and failed the joins on column names that the schema does not have, so the alignment carried the replies' shape and only part of the document. The alignment statistics read an own-group fraction of 0.83, a singleton fraction of 0.20 and a shared-boundary fraction of 0.47. At the positions the document moved, the student's nats per token fell from 8.21 to 0.80.