Foundations
How I wrote the small library that every later program links in, from printing floats to a thread pool, a progress bar and a Ctrl+C handler.
Without a C runtime there is no printf, no malloc, no fopen and no threads library. Stage 1 was writing my own
versions of the parts I would need, once, in a folder called lib\. It has twelve .asm files and one include file,
lib.inc, which declares every routine as an extern, holds the offsets of the shared structs, and defines a say
macro that prints a string literal. A program calls lib_init first, which sets up the console, the timer, the CPU
feature flags and the command line.
Printing numbers
All output goes through WriteFile, so it still works when it’s redirected to a file. At startup con_init asks
GetConsoleMode whether the output is a real console. If it is, it turns on escape code processing and switches the
code page to 65001 (UTF-8), and con_restore puts both back on exit, because the console belongs to the shell.
The formatting routines write into a buffer and return a pointer to the end of what they wrote. Integers come out
one digit at a time, lowest first. A 64-bit div costs 30 to 90 cycles, so the loop divides by ten with a
multiply instead:
mov r11, 0xcccccccccccccccd ; ceil(2^67 / 10): x/10 = (x * this) >> 67
.digit:
mov rcx, rax
mul r11
shr rdx, 3
lea rax, [rdx+rdx*4]
add rax, rax
sub rcx, rax ; x - (x/10)*10
add cl, '0'
dec r10
mov [r10], cl
mov rax, rdx
test rax, rax
jnz .digit
mul leaves the 128-bit product in rdx:rax, so rdx already holds it shifted right by 64, and shr rdx, 3 makes
it 67. Then lea and add multiply the quotient back by ten to get the remainder.
Floats reuse the integer printer. fmt_fixed multiplies the value by a power of ten, rounds it to an integer with
cvtsd2si, and one integer divide splits that into the whole part and the fraction digits. Going the other way,
parse_float collects the digits into one integer and then, for a negative exponent, divides by an exact power of
ten from a table. That rounds once, so “0.1” and “3e-4” come out bit for bit as the closest double. Multiplying by
a stored 10^-k would round twice, since 10^-k isn’t exact itself.
Memory, files and settings
mem_alloc is VirtualAlloc with MEM_COMMIT and MEM_RESERVE, which gives zeroed, page-aligned memory. The arena is
a 32-byte struct (base, used, committed, reserved). It reserves a large range of address space up front, which
costs nothing, and commits another megabyte whenever an allocation runs past what’s committed.
The file routines wrap CreateFileA, ReadFile and WriteFile. Those take 32-bit sizes, so file_read and
file_write loop in 1 GB chunks. file_map maps a whole file read-only with CreateFileMappingA and MapViewOfFile.
file_replace is a single MoveFileExA with MOVEFILE_REPLACE_EXISTING and MOVEFILE_WRITE_THROUGH. Writing a
temporary file, flushing it and then replacing the real one means a crash leaves either the old file or the new
one, which is how checkpoints get saved in chapter 6.
args_init splits GetCommandLineA into argv. cfg_load reads a file of key = value lines, cutting the strings
in place by writing zero bytes into the file’s buffer, and cfg_args adds every key=value argument from the
command line. Lookups search the list newest first, so when a program loads its config file before its arguments,
the command line wins.
Timers and random numbers
Timing uses QueryPerformanceCounter, which ticks at 10 MHz on current Windows. time_init stores one over the
frequency, so turning ticks into seconds is a subtract and a multiply.
The random number generator is xoshiro256**. Its state is 32 bytes that the caller owns, so a program can have
several streams and a checkpoint can save them. rng_seed fills the state with splitmix64, so similar seeds still
give unrelated streams. rng_float keeps the top 53 bits, exactly what a double holds, and scales them into [0, 1).
rng_normal uses the Box-Muller transform, which needs a logarithm and a cosine. Those come from the old x87
floating-point unit, which has them built in (fyl2x, fcos). They’re slow, but that’s fine for per-step math
like the learning rate schedule.
The thread pool
pool_init starts one worker per logical CPU. par_for(fn, ctx, count, chunk) calls
fn(ctx, start, end, thread) over the range from 0 to count on every thread, the main thread included, and
returns when it’s all done. Each thread claims its next chunk with lock xadd on one shared counter, which sits
alone on its own 64-byte cache line because every thread hammers it. There’s no fixed split, so fast cores just
come back for more. The Core Ultra 7 265F has 8 performance cores and 12 efficiency cores, and the threads on P
cores ended up with about 60 chunks each against about 45 on the E cores, without any tuning.
| Threads | Time (s) | Speedup |
|---|---|---|
| 1 | 0.295 | 1.0 |
| 20 | 0.019 | 15.7 |
In stage 1 the workers slept on Windows events between jobs. That was fine here, but too slow once the chat engine ran about 80 tiny jobs per token, and chapter 9 replaces it with workers that spin.
The progress bar
The bar is four lines of text redrawn in place. Before each redraw, the escape sequence ESC[4A moves the cursor up
four lines, and every line ends with ESC[K, which clears whatever the last draw left behind. The whole frame goes
out in a single WriteFile, so it’s never seen half drawn, at most ten times a second.
The bar is 30 cells wide and fills in eighths of a cell. Unicode has a full block (U+2588) and partial blocks from U+258F for one eighth to U+2589 for seven, so n eighths is U+2590 minus n. In UTF-8 they’re all e2 96 and one more byte:
.full:
test ecx, ecx
jz .part
mov dword [rdi], 0x8896e2 ; U+2588 full block, utf-8 e2 96 88
add rdi, 3
dec ecx
jmp .full
.part:
test eax, eax
jz .rest
mov word [rdi], 0x96e2
mov cl, 0x90
sub cl, al ; U+258F..U+2589 are the 1/8..7/8 blocks
mov [rdi+2], cl
add rdi, 3
dec edx
The dword store writes four bytes (e2 96 88 and a zero) but rdi only moves three, so the next character covers the
zero. When the output is redirected there’s no bar, and prog_line writes the same numbers as one key=value line
for the log file instead.
Ctrl+C and crashes
ctrlc_init registers a handler with SetConsoleCtrlHandler, and Windows runs it on a new thread with the event
type in ecx:
; windows calls this on a fresh thread. ecx = event type
global ctrlc_handler
ctrlc_handler:
sub rsp, 40
cmp ecx, 1
ja .closing ; 2 = window closed, 5/6 = logoff/shutdown
lock bts dword [stop_flag], 0
jc .force ; it was already set
mov eax, 1 ; handled, don't kill us
add rsp, 40
ret
.force:
call con_restore
say 13, 10, "forced exit", 13, 10
mov ecx, 130
call ExitProcess
The first Ctrl+C only sets stop_flag, and the main loop checks it after every step, saves and exits. A second one
finds the bit already set and exits on the spot with code 130. A process also inherits the “ignore Ctrl+C” setting
from whoever started it, so ctrlc_init first calls SetConsoleCtrlHandler with a null handler to turn that off.
A crash handler joined them in stage 2, and the next chapter describes it.
Tests
test.bat builds every test program and every tool, then runs the tests that check themselves. They share a few
macros, like check e, "name", which turns the flags from a compare into a pass or a fail, and each test exits
with its number of failures. At the end of stage 1 that was 107 checks, which ran in about 7 seconds.
The most involved one tests Ctrl+C with real console events. bin\progress.exe is a fake training loop with the
progress bar, a log, autosaves and resume. The test starts it in a hidden console, and a helper copy of the test
sends the Ctrl+C by calling FreeConsole, AttachConsole with the target’s process id, and GenerateConsoleCtrlEvent.
It checks that one Ctrl+C saves and exits partway through, that running it again finishes with the same step, loss
and random number state, bit for bit, as a run that never stopped, and that two Ctrl+Cs exit with 130.