Teaching it to chat
How I fine-tuned the pretrained models on conversations, taught them their own name, and found two bugs that made a working model look as if it had learned nothing.
A pretrained model continues whatever text it’s given. To get one that answers, I fine-tuned it on conversations and counted the loss only on the replies. The trainer barely changed. The work was in the data, and in two bugs that made the model look as if it had learned nothing, although neither of them was in the model.
The data and the template
I had planned on SmolTalk, but while the 126M was still pretraining I switched to smol-smoltalk, Hugging Face’s cut of it for models under a billion parameters. Its conversations are shorter, with no function calling and no hard math. It has 460,000 training conversations and 24,000 test ones (about 424M and 22M tokens) in the same Parquet layout, so the stage 2 tools read it unchanged.
A conversation becomes <|bos|>, then for each message the role’s special token, the content and <|end|>. The
special tokens sit at the top of the 32,768-token vocabulary, so every id fits in 15 bits, and chat token files use
bit 15 as a flag (MASKBIT) on the tokens the model should learn to write. render_conv sets it with
or word [rdi+rcx*2], MASKBIT on every token of an assistant message, including its <|end|>, so the model also
learns when to stop. On the GPU, the embedding kernel masks the flag off, and the cross-entropy kernel skips every row
whose target doesn’t have it, so those rows add no loss and get a zero gradient. The trainer divides by the number of
flagged tokens, which makes the loss a mean over the replies alone.
Packing conversations into blocks
The trainer reads rows of ctx + 1 tokens with a stride of ctx, so with chat data most rows would start partway into a
conversation. bin\chatpack fixes that before training. It splits the chat token files into conversations at each
<|bos|>, shuffles them and packs them into blocks of exactly ctx tokens. Each block takes the first unused
conversation, then any of the following ones that still fit, and the rest is padded with <|bos|> tokens that are
never targets. Every training row is then exactly one block that starts at a conversation. An input written as
identity.tok*20 goes in 20 times.
The first version had a flaw I fixed later. 41% of smol-smoltalk conversations are longer than 1,024 tokens, and
chatpack kept the first 1,024 of each, so the cut could land in the middle of a reply. That teaches the model that a
reply can stop without <|end|>. Now chatpack walks back to the last flagged <|end|> that fits and keeps whole turns
only. Packed at 2,048 for the 304M, that cut 2,333 conversations back to whole turns, dropped 4,181 whose first reply
didn’t fit, and filled 96.2% of the tokens.
Who it is
A model trained only on smol-smoltalk has no idea what it’s called, so I made data\identity.txt: 107 short
conversations about its name, who made it, where the code is, what it’s built from and what it can’t do. Each message
starts with user: or assistant:, and bin\chatdocs turns the file into the same .docs format that bin\extract
makes from Parquet. Mixed in 20 times, it was 0.5% of the conversations and 0.03% of the tokens.
For the 304M I made a bigger set of 2,121 conversations, 609 of them with several turns, covering greetings, its creator, the code and links, assembly, the architecture, the training, its limits and staying in character. Another 199 sit in a separate file that’s never trained on. A low loss on those means the model learned who it is, and not just a list of sentences.
The fine-tuning runs
For these runs, init= starts from a checkpoint’s weights with fresh AdamW state, the validation loss counts only the
flagged tokens, and the sample printed during training is asked as a user turn.
| Run | Model | Starts from | Data | Learning rate | Steps | Time | Chat val loss |
|---|---|---|---|---|---|---|---|
| chat | 126M | main | smol-smoltalk + identity ×20, blocks of 1,024 | 2e-4 | 2,545 | about 80 min | 1.350 |
| chatid | 126M | chat | identity ×500 in one smol-smoltalk file | 1e-4 | about 300 | about 10 min | 1.359 |
| chat300 | 304M | stretch | smol-smoltalk + identity-300m ×12, blocks of 2,048 | 1e-4 | 2,968 | 3.4 h | 1.1959 |
The chat run is one pass over 333M tokens. Its loss on the replies started at 2.2, and the validation loss went from 1.67 at step 200 to 1.350 at the end. The 304M’s figure is only roughly comparable, since it sees twice the context and was packed with whole turns.
Two bugs that looked like a bad model
After the chat run, the 126M answered “Hi! Who are you?” with made-up people: “Hi, my Name: Sarah Rodrigue…” and “I am a non-geotechnic science module”. I assumed it had seen too little identity data, grew identity.txt to 208 conversations with lots of paraphrases, and ran a short second pass, chatid, with the identity set 500 times over in one smol-smoltalk file. Its loss on the packed identity blocks fell to 0.017, close to memorized, and its samples still made up personas.
The first bug was in the packing. With so many repeats, chatpack put several identity conversations into the same block, and attention lets every token see everything earlier in its row. A conversation asking the model’s name could find the answer in a twin earlier in the block, so the model learned to copy “I’m Mnemonic…” from the context, and a fresh chat has nothing to copy. I measured it with idval.tok, the identity set with each conversation alone at the start of its own block. The chat model scored 1.46 there and chatid 0.16, nowhere near 0.017.
The fix is a + in front of an input file. Its conversations only ever go first into an empty block, and ordinary
conversations fill the rest. chatpack keeps each conversation as a pointer and a length, and the flag lives in bit 62
of the length:
cmp byte [rdi+rcx], 0
jne .skip
mov rax, rcx
shl rax, 4
mov rdx, [rsi+rax+CV_LEN]
btr rdx, 62 ; a + conversation only goes into an empty block
jnc .len
test r12, r12
jnz .skip
.len:
lea r14, [r12+rdx]
cmp r14, [ctx]
ja .skip
Rerun on the fixed packing, chatid reached 0.009 on idval.tok, and the general chat loss barely moved.
The samples were still wrong, and that was the second bug. Greedy decoding on chatid did say “Mnemonic.” and
“Supergoatscriptguy.”, but sampling with a temperature didn’t. The trainer’s sampler kept the top-k logits sorted in
topv and turned them into weights in place, exp((v_i - v_0) / temp). The first pass of that loop wrote exp(0) = 1
over v_0, so every later weight was computed against 1 instead of the largest logit. Those weights came out huge
next to the top choice’s weight of 1, and every sample since the dev model had been drawn from the top-k minus the
token the model liked best. Greedy decoding has only one candidate, which is why it worked and what gave the bug away.
The loss numbers were never affected. The fix copies v_0 to the stack first, so the frame grew from sub rsp, 40 to
sub rsp, 56, which with four pushes still comes to 8 modulo 16.
xorpd xmm0, xmm0
movsd [rsp+32], xmm0 ; sum
movsd xmm0, [topv]
movsd [rsp+40], xmm0 ; the max
xor edi, edi
.p:
cmp rdi, rbx
jae .draw
lea rax, [topv]
movsd xmm0, [rax+rdi*8]
subsd xmm0, [rsp+40]
divsd xmm0, [temp]
call math_exp
lea rax, [topv]
movsd [rax+rdi*8], xmm0
After that, “Hi! Who are you?” got “Hi! I’m Mnemonic, a small language model made by Supergoatscriptguy.”, and paraphrases it had never seen worked too. Both bugs looked exactly like a model that hadn’t learned, and measuring the loss the way the model would really be used is what told them apart.
What it can and can’t do
At a temperature of 0.7 the 126M explained photosynthesis, gave study tips and wrote a short poem about the ocean, all coherent and on topic. It leaned hard on earlier turns: after the poem, its study tips turned into ocean metaphors, so a new conversation between topics helps. “What’s the link to your code?” got read as a request to write code, since that phrasing wasn’t in the identity set.
The 304M introduced itself as Mnemonic from about step 2,000 of its run. On the held-out identity conversations its
loss is 2.46, against 4.25 for the base model. It gets its name, its creator, its size, its 10 billion training tokens
and the repository link right, with small slips like “I is” or once quoting the 126M’s 5 billion tokens. Side by side,
both decoding greedily, it gets Lincoln’s presidency right where the 126M called him the first president, and it
explains rainbows with refraction where the 126M said reflection. Both write a correct is_prime, both are hopeless
at math, and both state wrong facts with confidence.