Making it faster
How I measured where the time went on the CPU and the GPU, sped both up, and got better answers out of the same int4 files.
By the end of stage 8 everything worked, so stage 9 was about speed, and I measured before changing anything. On the CPU most of a token’s time went to the thread pool rather than the arithmetic, and on the GPU no kernel was badly off but the card sat partly idle. The last section is about accuracy: better int4 weights at the same size.
The thread pool
The console chat (bin\chat) splits each matrix-vector product across threads with par_for from
lib\thread.asm. In stage 8 every worker slept on its own event: par_for called SetEvent 19 times, one after
another, and then waited for the workers’ done events with WaitForMultipleObjects. An empty par_for took 28.5
microseconds, and a token through the 126M model runs about 81 of them, so 2.3 ms of a 3.6 ms token went to
waking threads and waiting for them.
Now a worker spins after a job, watching a 64-bit ticket for 2^20 ticks of rdtsc (about 0.3 ms) before it
sleeps, and par_for only wakes workers that have raised their asleep flag. It also waits for items rather than
threads, through a done counter, so a worker that Windows has parked holds nobody up unless it claimed a chunk.
An empty par_for now takes 3 microseconds.
The ticket is the job’s generation in the top 32 bits and the next unclaimed item in the bottom 32. A worker
claims a chunk by adding the chunk size to the whole ticket with lock cmpxchg:
.grab:
mov rsi, [ticket]
mov r12, [job_n]
mov rdi, [job_chunk]
mov r14, [job_fn]
mov r15, [job_ctx]
mov eax, esi ; next index
cmp rax, r12
jae .out
lea r13, [rax+rdi] ; our end
lea rdx, [rsi+rdi]
mov rax, rsi
lock cmpxchg [ticket], rdx
jne .grab
The generation protects against a worker that shows up late, after the next job has rewritten job_n and
job_fn. par_for closes the ticket (index 0xffffffff) before it touches those fields, and publishes the next one
with xchg, a full barrier, only after writing them. So if the cmpxchg goes through, the fields the worker read
belong to the job it claimed from. Closing the ticket had a trap: or rax, 0xffffffff sign-extends its 32-bit
immediate and wipes the generation, so the code puts 0xffffffff in ecx, which zero-extends into rcx, and ORs
that.
Fewer threads
With the pool fixed, more threads made things slower: the 126M int8 model did 417 tokens a second on 12 threads,
383 on 16 and 212 on 20. A spinning worker that Windows bumps off its core still holds its chunk, and the token
waits for it. A token is also memory bound, since it reads every weight once: the f32 model at 134 tokens a second
already moves 68 GB/s, which is about all this machine’s memory can do. bin\chat now defaults to three quarters
of the logical CPUs, at most 12. The browser engine in the next chapter, written before this stage, had already
peaked at 12 to 14 threads.
The matrix and attention kernels
The matrix-vector kernels added every group of 32 into one vfmadd231ps chain per row, which waits 4 cycles a
group. Two sums taking two groups a step made int4 13% faster, and int8, already limited by memory bandwidth,
didn’t change.
The int4 nibbles are stored as 0 to 15 and mean q - 8. The old kernel subtracted 8 with vpsubb and then needed
the int8 sign trick, since vpmaddubsw and AVX-VNNI’s vpdpbusd multiply an unsigned operand by a signed one. But
the nibbles are already unsigned, and (q - 8)·x = q·x - 8·sum(x). So they go in as they are, and each group’s sums
start from -8·sum(x), which is made once per vector with vpmaddubsw (8s against the activations) and then
vpmaddwd against ones. That gained 5% on AVX2 and nothing on the VNNI paths.
Attention was latency bound too: a key’s score was one chain of eight FMAs over 64 floats, about 45 cycles a key.
Now the query stays in ymm8 to ymm15 for the whole head, and four keys go at once as four independent chains,
which three vhaddps fold together. The weighted sum of the values loops over keys into all 64 outputs, held in
the same eight registers. The engine now requires 64-wide heads, and the f32 loss was still exactly 1.4324.
Tokens a second for the 126M chat model, roughly, before and after:
| Weights | Before | After |
|---|---|---|
| f32 | 121 | 134 |
| int8 | 277 | 420 |
| int4 | 309 | 550 |
Ollama, which runs llama.cpp, does about 390 with int8 and 570 with int4 on the same model.
Profiling the GPU
To see where a training step’s time went, I gave gpu_launch in gpu\cuda.asm a profiling mode. Between
gpu_prof_begin and gpu_prof_end it records a CUDA event before every launch, along with the kernel and, for a
matmul, its shape. cuEventElapsedTime between neighbouring events then gives each kernel’s GPU time, and when
profiling is off it costs one compare per launch. bin\profile runs one micro-batch and one optimizer step this
way, with random weights, on one stream so the times add up.
For the 300M, with micro-batches of 4 × 2,048 tokens, a step took 17.5 seconds. Matmuls were 68% of it at 63 to 91 TFLOPS, flash attention 18.5% at 72 to 80, small kernels like the norms 13.5%, mostly at memory bandwidth already, and AdamW 0.1%.
The weight-gradient matmuls looked slow at 72 to 77 TFLOPS, while the same code did 91 on the logits’ weight gradient. That’s wave quantization. The card has 70 SMs that fit 2 blocks each, so 140 output tiles of 128 × 128 run at once. W13’s gradient (5632 × 1024) is 352 tiles, or 2.5 waves, and W2’s (1024 × 2816) is 176 tiles, or 1.26 waves. Splitting K didn’t help for outputs this big, because adding up the partial sums cost more than it saved.
A second CUDA stream
In the backward pass dx carries down the layers in a strict chain, but nothing needs the weight gradients until
the optimizer step. With streams=2 the weight-gradient matmuls and the norm weights’ gradients go on a second
stream beside the chain.
They only read, but stream 0 overwrites what they read a little later, so model\model.asm wraps them in helpers,
all no-ops when streams=1. fork makes the second stream wait for stream 0’s work so far, on2 sends launches
to it, back records an event after the reader and switches back, waitfor makes stream 0 wait for that event
before reusing the buffer, and join waits for everything before the embedding’s backward. Here’s the MLP part of
a layer:
; mlp: x' = x2 + swiglu(norm(x2) W13^T) W2^T
call fork
MM [d_drest], [rsi+LP_W2], [d_dg], 0, [cur_m], [mdl+MD_F], [mdl+MD_D], [mmbftb]
call on2
MM [d_drest], [rsi+LP_G], [rsi+LP_G2], [rsi+LP_G2], [mdl+MD_D], [mdl+MD_F], [cur_m], MM_TA|MM_TB
lea rcx, [ev_drest]
call back
lea rcx, [ev_dh]
call waitfor
The first matmul is the dx side on stream 0, the second adds W2’s gradient into its fp32 buffer on the second
stream, and then stream 0 waits until the second stream has finished reading dh for the layer above before the
SwiGLU backward overwrites it. The fork has to be recorded before the next matmul on stream 0, or the second
stream waits for that matmul too. And the second stream must be created with CU_STREAM_NON_BLOCKING, because
the model runs on the legacy default stream, which synchronizes with every blocking stream.
Every kernel still gets the same inputs and adds them in the same order, so the gradients come out bit for bit the
same. test\model checks that, bin\profile stretch streams=2 check=5 compares all 304M gradients over five
runs, and 40 steps of the tiny preset on two streams left 0 of 55M checkpoint bytes different.
| 300M | One stream | Two streams |
|---|---|---|
| Milliseconds per micro-batch | 274.2 | 252.9 |
| Tokens a second | 29,900 | 32,400 |
| TFLOPS | 63.5 | 68.8 |
That’s 8.4% faster, about 3.6 hours off the 300M run, which was halfway through by then. I paused it, rebuilt the trainer, and it resumed at step 9,535 on two streams.
Better int4 scales
The original int4 quantizer, q4_row, gave each group of 32 weights the scale max|x| / 7 and rounded every weight
to a whole number from -7 to 7. A nibble holds -8 to 7, so one level went unused, and the scale was never fitted
to the group.
q4_row_fit searches instead. For each group it tries 19 scales that put the biggest weight at -7.1, -7.2 and so
on down to -8.9, so all 16 levels are in play, plus q4_row’s own. Each try rounds and clamps the group with
SSE4.1, then refits the scale by least squares, d = Σxq / Σq². With that d the squared error is
Σx² - (Σxq)² / Σq², and Σx² is the same for every try, so the winner has the largest (Σxq)² / Σq²:
; with d = sum xq / sum q^2 the error is sum x^2 - (sum xq)^2 / sum q^2
movss xmm3, xmm1
mulss xmm3, xmm1
divss xmm3, xmm2
comiss xmm3, [rsp+32]
jbe .tn
movss [rsp+32], xmm3
mov eax, [rsp+52]
mov [rsp+36], eax
divss xmm1, xmm2
movss [rsp+40], xmm1
The scale can come out negative, but both engines just multiply by it, so neither engine nor the file format changed. The 300M quantizes in 5 seconds.
The squared weight error fell to 0.69 times max/7’s, and the matrix-vector error against f32 went from 10.9% to 7.5%. Validation losses:
| Model and data | int8 | int4, max/7 | int4, fitted |
|---|---|---|---|
| 300M, chat | 1.1628 | 1.2120 | 1.1965 |
| 300M, held-out identity questions | 2.3505 | 2.4645 | 2.4458 |
| 126M, chat | 1.2689 | 1.3197 | 1.3091 |
The 300M in f32 scores 1.1620 on the same rows, so the fitted scales close 31% of the gap between int4 and full
precision at the same size and speed. They’re the default now, and bin\gguf uses them for Ollama’s q4_0 too,
with a smaller gain: llama.cpp’s perplexity for the 300M on 1.6 MB of FineWeb-Edu was 24.27 for f16, 24.80 for its own q4_0
and 24.75 with the fitted scales, since its q4_0 already used all 16 levels.