Mnemonic

The data pipeline

How I downloaded FineWeb-Edu and SmolTalk from assembly and got the text out of their Parquet files with my own Parquet reader and snappy and zstd decompressors.

The model learns from two datasets on Hugging Face: FineWeb-Edu for pretraining, and SmolTalk, a set of conversations, for the chat tuning later on. Both come as Parquet files, a column format with compression built in, and with nothing but the Windows API to call, I had to read them myself. The first thing I wrote was a reader for the file’s footer, so I could see which codecs and encodings the files really use before writing any decompression code.

Downloading with WinHTTP

WinHTTP comes with Windows, so bin\download can fetch the files from assembly. download fineweb 0 3 gets shards 0 to 3, a few at a time on the thread pool from chapter 1. WinHTTP wants its strings in UTF-16, so the constant ones are written with NASM’s __?utf16?__("..."). For each file the program calls WinHttpConnect on port 443, WinHttpOpenRequest with the secure flag, WinHttpSendRequest and WinHttpReceiveResponse, and WinHTTP follows Hugging Face’s redirect to its CDN on its own. WinHttpQueryHeaders gives the status and the length as numbers, and WinHttpReadData hands over the body in 1 MB pieces, which go into a .tmp file. Only a complete file gets renamed to its real name. Files that already exist are skipped, so after a failure I just run the same command again.

A Parquet file starts and ends with the four bytes PAR1. The footer sits right before the last four, followed by its own length, and it describes everything else: the schema, the row groups, and every column’s codec, encodings and offsets. pq_open maps the whole file with CreateFileMappingA and MapViewOfFile, so finding the footer is a little pointer arithmetic from the end:

cmp rdx, 12
jb .bad
cmp dword [rax], 'PAR1'
jne .bad
cmp dword [rax+rdx-4], 'PAR1'
jne .bad
; ... footer, then its length (4 bytes), then PAR1
mov ecx, [rax+rdx-8]
lea rsi, [rax+rdx-8]
sub rsi, rcx
call p_filemeta
call leaves

The footer is in Thrift’s compact protocol. Each field starts with a byte holding the difference from the previous field’s id in its high four bits and the type in its low four. Integers are zigzag varints: 7 bits per byte, with the lowest bit of the result holding the sign, which shr rax, 1, and edx, 1, neg rdx and xor rax, rdx undo. A bool keeps its value in the type nibble, except inside a list, where it takes a whole byte. Each struct gets a small parser, a recursive skip steps over the fields I don’t need, and the results go into plain structs in an arena.

bin\pqinfo prints all of this, and it showed me what I had to support. FineWeb-Edu uses zstd, plain values and RLE definition levels, in 52 row groups of 1,024 rows, which makes 53,248 documents and about 255 MB of text per shard. SmolTalk uses snappy, with dictionary pages and RLE_DICTIONARY values inside a nested list. So I needed two decompressors and nothing else.

Levels and the hybrid encoding

Parquet stores nested data as flat columns, with two small numbers for every entry. A leaf’s maximum definition level is the number of optional and repeated nodes on its path, and its maximum repetition level is the number of repeated ones, so SmolTalk’s messages.list.element.content has 4 and 1. A repetition level of 0 starts a new row, which here is a new conversation. A definition level of 3 or more means the message exists, and 4 means its content isn’t null either.

Those levels, and the dictionary indices, use Parquet’s RLE/bit-packed hybrid. Each run starts with a varint whose low bit says what follows. A 0 means one value repeated n times, which is a single rep stosd. A 1 means groups of 8 values packed at a fixed bit width, and each value is one 8-byte load at the bit position divided by 8, then shrx by the position’s low 3 bits and bzhi down to the width.

The values themselves never get copied. Each one becomes a pointer and a length into the decompressed page or the dictionary. Every page has to decompress to exactly its stated size, and the level entries of all the pages have to add up to the column chunk’s count, which catches most mistakes.

Snappy and zstd

Snappy is 123 lines. After the output length, every element is a literal or a copy from earlier output. Copies use rep movsb, which goes byte by byte, so a copy that overlaps its own output repeats a pattern the way the format intends.

Zstd is 1,386 lines: Huffman-coded literals, plus sequences coded with FSE, a table-driven entropy coder. Both decoders read their bit stream backwards, and the highest set bit of the last byte, which bsr finds, marks where the data begins. The reference decoder keeps a 64-bit container that it refills. I keep only a bit position that counts down, so reading n bits is a subtraction, one 8-byte load, a shift and a mask. Both of the last two are BMI2 instructions: shrx shifts by a count in any register instead of only cl, and bzhi clears every bit from a given position up.

; rax = next ecx bits of a backward bitstream. r8 = its first byte, r9 = bit position
; (counts down). bits below the start read as zero. uses rdx
%macro getbits 0
    sub r9, rcx
    js %%neg
    mov rdx, r9
    shr rdx, 3
    mov rax, [r8+rdx]
    mov edx, r9d
    and edx, 7
    shrx rax, rax, rdx
    bzhi rax, rax, rcx
    jmp %%done

The load can reach up to 8 bytes past the end of a buffer, so every decode buffer gets 64 bytes of slack. Zstd also checks itself well: every bit stream has to end exactly on bit 0, and every frame has to come out at exactly its stated size, so one wrong bit anywhere makes one of those fail. To find where, a failif macro stores its own offset into the code in zstd_err, and a nasm -l listing maps that back to the source line.

Extracting the text

bin\extract turns each Parquet file into a .docs file. The row groups are decoded in parallel, each thread with its own arenas and zstd context, and every document is checked for valid UTF-8. A .docs file is made to be memory mapped: a 64-byte header, an array of 64-bit offsets (document i runs from offset i to offset i+1), a role byte per message and the start of each conversation for chat files, and then all the text. bin\docsinfo checks a .docs file and prints a few documents, and bin\pqcat prints any single column with its levels.

Job Time
Decode one 255 MB FineWeb-Edu shard on 20 threads, with the UTF-8 check 0.035 s
Extract that shard, including the flushed write to disk 0.4 s
Download 88 shards, 8 at a time 64 s
Extract 98 files 51 s

Decoding alone runs at about 7.3 GB/s, so most of an extract is the disk write, at about 640 MB/s.

Two bugs and a crash handler

The bug that cost the most time was in zstd’s Huffman tables. A table lists one weight per byte value, and I had capped the number of weights at 250, but UTF-8 text uses nearly every byte value, so a real table can list 255.

The other one only showed up on the third file. pq_open filled in its struct without clearing it first, so the count of leaf columns kept going up from one file to the next, and the third file crashed. Now it clears the struct first:

mov rdi, rcx
mov rbx, rdx
mov [pq_cur], rdi
xor eax, eax                ; start clean, the struct may have held another file
mov ecx, PQ_SIZEOF / 8
rep stosq

Crashes like that are why lib_init, which my programs call at startup, installs a handler with AddVectoredExceptionHandler. On a fault it prints the exception code, the address as exe+offset to look up in the linker’s map file, and the 16 general registers. It only acts on faults inside my own executable, checked against SizeOfImage in the PE header, because system DLLs sometimes fault on purpose and handle it themselves. It has to be vectored, because my assembly has no unwind info, and without that, Windows’ frame-based exception handling can’t walk through my stack.

By the end of this stage test.bat ran 127 checks, including hand-made snappy, RLE and zstd inputs, and the real files checked against their footers and the first rows that Hugging Face shows.