Skip to content

a zstd codec written entirely in pith - #638

Merged
kacy merged 25 commits into
mainfrom
zstd-pure
Aug 2, 2026
Merged

a zstd codec written entirely in pith#638
kacy merged 25 commits into
mainfrom
zstd-pure

Conversation

@kacy

@kacy kacy commented Aug 2, 2026

Copy link
Copy Markdown
Owner

zstd, both directions, in pure pith — no c, no crate in the path. std.compress.zstd_pure_* decodes and encodes real frames: raw, rle, and compressed blocks; huffman literals in one and four streams; fse-coded sequences with repeat offsets; xxh64 content checksums.

the correctness anchor throughout is the system zstd binary, not our own round trip. twice while building this an encoder and decoder agreed with each other and disagreed with the spec — the fse next-state offset, and the predefined match-length distribution, both of which are invisible to a round trip because the same wrong table sits on either side. so make zstd-pure-check requires our decoder to read 18 shapes of system-produced frames byte-identical, and make zstd-encode-check requires the system binary to read every frame we produce, across 9 shapes plus a 60-shape seeded differential run. both now gate ci.

where it lands, against the crate-backed kernel on a corpus built from this repo (make zstd-pure-bench, 2:1 to 11:1):

corpus decode vs kernel encode vs kernel size vs kernel
text 8kb 54 mb/s 8.3x 8 mb/s 17x 101%
prose 284kb 95 7.4x 15 9x 106%
source 585kb 155 6.0x 20 12x 107%
json 500kb 350 5.7x 26 22x 119%
rle runs 78kb 7223 0.56x 51 69x 137%

the decoder started at 129x the kernel and took three passes to get here; the run-heavy row is faster than the kernel because an overlapping match copy became a period-doubling memcpy instead of a byte loop. the encoder has had one pass and produces frames within 1-7% of the reference codec's size on real content.

three of those passes were not really about zstd. the hot loops kept hitting per-operation overhead that every pith program pays, so the fixes landed in the compiler and runtime: xs[i] used to call into the runtime, heap-allocate an optional tuple, unpack it and release it — the ir consumer now collapses that whole pattern to inline loads keeping the same loud out-of-bounds failure; bits.band crossed two call layers and is now one native instruction; a word load from bytes inlines to an 8-byte load and a mask; the disabled perf-stats probe became a single relaxed load. new runtime primitives — write_range, write_word, read_word, and a chunked copy_within — replace per-byte call loops for anything doing bulk byte work.

what was tested

every gate below was run on the final tree, and the two subagent-produced passes were independently re-verified rather than taken on report:

  • module tests: bits, fse, huffman, sequences, frame, xxhash, encode, and std/bytes — 0 failed
  • make zstd-pure-check: 18 system frames decoded byte-identical
  • make zstd-encode-check: 9 shapes read back by both our decoder and the system binary; plus a 60-shape seeded differential run
  • make zstd-interop-check: both directions at levels 3 and 19
  • make run-regressions-only: 303 passed — the compiler changes touch every program, so this is the one that matters most
  • make leak-check-only: flat across 200k and 800k rounds
  • make bootstrap-verify: fixed point verified (the seed changed for the new checker builtins)
  • cargo test -p pith-codegen: the new inline passes have unit tests for their pattern detectors
  • doc coverage clean on the new modules

notes

  • bench/zstd_decode.pith is now bench/zstd_codec.pith and times both directions. it hands the pure encoder's frame to the kernel's decoder before timing, and reports output size as a percentage of the kernel's, so encode throughput cannot be bought by compressing worse.
  • the benchmark's rle-runs row is reported but deliberately not the headline: a frame packing 80kb into 22 bytes decodes almost entirely through the match copy and measures memory bandwidth rather than entropy decoding.
  • decode_and_execute is the fused hot path; decode_sequences and execute_into remain as separately-testable reference forms with a comment binding the three. the hand-built sequence tests they support are the module's spec anchor and were kept deliberately — a round trip cannot check them.
  • known and not fixed here: the encoder leaks about 26kb per compress() call. it predates this pass, which halved it by allocating less. worth its own change before anything depends on the encoder in a long-lived process.
  • remaining levers, documented in docs/performance.md: the encoder's match finder is now 31% of its profile, the last of the size gap is fewer sequences rather than cheaper ones, and sequence-table repeat mode is unimplemented.

kacy added 25 commits August 1, 2026 15:37
first checkpoint of the experiment to replace the libzstd crate with a pure
codec. two layers, both tested end to end.

the bitstreams are zstd's two shapes: a backward reader/writer (entropy
payloads pack lsb-first with a sentinel above the last real bit, and the
reader walks down from just below it) and a plain forward reader for table
descriptions. the writer records reads in decode order and reverses, so a
round trip through it is the test.

fse is the entropy coder both the decoder and the encoder turn on: the
reference table-build (spread symbols by the fixed stride, derive each
slot's bit count and base from its occurrence), a decoder, and an encoder
that walks a symbol sequence backward choosing states. the proof is a full
round trip — encode a real sequence, decode it back through an actual
backward stream — on both a uniform and a skewed distribution.

next: xxhash64, frame and block parsing, huffman literals, the sequence
fse tables, and lz77. the crate stays on main; this builds beside it so the
two are directly comparable when the encoder lands.
a module constant set to a hex or binary literal silently became zero. the
emitter passes an integer literal's source text into the ir unchanged, and
the consumer parsed the global initializer with a decimal-only str::parse
whose failure it swallowed with unwrap_or(0) — while the identical literal
written inline went through parse_i64_operand and compiled correctly.

so MAGIC := 0xFD2FB528 was 0, with no diagnostic anywhere: a wrong value
that type-checks, links, and runs. found writing a frame parser whose magic
number stopped matching.

the global initializer now uses parse_i64_operand, which accepts the same
0x/0b/0o forms as every other numeric operand and reports malformed input
instead of coercing it — the rule #552 established for the rest of the ir.
xxh64 is the checksum a zstd frame carries over its decompressed content,
so a decoder needs the whole 64-bit hash to verify a frame. it is wrapping
64-bit arithmetic throughout, which pith's Int supports directly: * and +
wrap rather than trap and bits.shr is logical, so the bit patterns match
the reference exactly. the published empty-string vector pins it.

the frame layer parses the magic, the variable-length header (content size,
window descriptor, dictionary id, checksum flag), and the block headers,
and decodes raw and rle blocks. compressed blocks fail with a clear message
until the entropy layers land.

that is already enough for real interop: the decoder reads frames produced
by the system zstd binary from incompressible input — which zstd stores as
raw blocks — and reproduces the original bytes, with the frame's own
checksum verified through our xxh64. two independent implementations
agreeing on a real frame is the only proof that counts.
zstd describes a literals huffman table by per-symbol weights rather than
code lengths: weight w means a code of (max_bits + 1 - w) bits, 0 means
absent, and the last symbol's weight is inferred from the gap left in the
probability space — which is also the validity check, since that gap has to
be a power of two. the decode table is flat, so decoding is peek the table
log, index, consume the entry's length; the bitstream gained peek and skip
for exactly that.

build_codes derives each symbol's canonical code from the finished decode
table rather than recomputing it, so an encoder and the decoder cannot
disagree by construction — the round-trip tests encode with those codes and
decode back, on a uniform and a skewed table.

also here: the direct (4-bit packed) weight description and the literals
section header, whose size fields sit at bit offsets that vary by block
type and size format across five layouts. the fse-coded weight description
and the four-stream jump table come with the sequence layer next.
the normalized counts an fse table is built from travel in a variable-width
forward bitstream: the field narrows as the remaining probability space
shrinks, a stored value is biased so zero can mean the 'less than one'
probability, and a zero count is followed by a repeat field so runs of
absent symbols cost two bits each. this reads that, and writes it — the
writer exists so the reader can be round-tripped without a reference
frame, and the encoder will need it anyway.

writing the round-trip test found a real gap in the reader: the forward
bitstream yields zeros past the end of its data rather than refusing, so a
truncated description decoded as a run of low-probability symbols that
balanced perfectly against the accuracy log while reading bits that were
not there. it now fails when the description consumed more than the data
held. two zero bytes was the case that caught it.

this is the last shared piece before sequences: the same reader serves the
huffman weight description and all three sequence tables.
a compressed block is literals plus sequences — copy N literals, then copy
M bytes from D back — with the three code streams fse-coded and interleaved
into one backward bitstream carrying three live decoder states. this adds
the baseline and extra-bit tables, the three predefined distributions, the
mode byte and its table descriptions, the interleaved decode loop, offset
resolution against the rolling repeat history, and lz77 execution that
copies byte-at-a-time so a match can overlap itself.

the decoder now reads real zstd output: sixteen frames across five input
shapes and levels 1, 3, 9 and 19, byte-identical, checksums verified. the
four that do not are all huffman-coded literals, the one documented gap.

two bugs only a real frame could have found, both of which my round-trip
tests were structurally unable to catch:

the fse table gave each symbol's next-state a run starting at one, with
floor-log2 miscomputed as a bit length. the encoder built its codes from
the same wrong table, so the two agreed perfectly — self-consistent and
wrong. the spec starts each symbol's run at its own normalized count, which
is what pushes a frequent symbol's states into the table's cheap half.

and the predefined match-length distribution had four low-probability
symbols where the spec has seven. it summed to sixty-four either way, so
the sum invariant — the only property a synthetic test can check without a
reference — passed happily. the shape test now pins the counts and the
low-probability boundaries, not just the total.
huffman literals close the last gap: fse-coded weight descriptions decoded
by two interleaved states, and the four-stream layout whose jump table
gives the first three lengths while the output splits into four segments.
treeless blocks reuse the previous block's tree.

widening the corpus past the first happy cases found two more bugs, both
only reachable across block boundaries. repeat mode for the sequence
tables was unimplemented — a later block naming the tables an earlier one
described. and the repeat-offset history lives for the whole frame, not the
block; resetting it per block produced output of exactly the right length
with the wrong bytes, which the frame checksum caught and nothing else
would have. both are frame-level state now, threaded alongside the huffman
table.

thirty-six frames from the system zstd binary decode byte-identical: nine
shapes — prose, source, markdown, random binary, structured json, wide
alphabet, empty, one byte, 100 bytes — across levels 1, 5, 12 and 19,
covering raw, rle and compressed blocks, direct and fse-coded huffman
weights, one and four streams, every sequence mode, and multi-block frames.
make zstd-pure-check pins eighteen of them as a ci gate.

measured decode throughput against the linked crate on the same frames:
3.4 MB/s vs 437 MB/s on prose, 17 vs 3178 on a wide alphabet, 42 vs 5449
on incompressible input — call it 130-190x slower. that is the honest cost
of bit-level work in a language with no inline assembly and arc-managed
containers, and it is the number the decision should be made on.
append bytes copied from the buffer's own contents — the back-reference
every lz-family decoder emits. when the source range overlaps the
destination the copy repeats, which is not a bug to work around but the
encoding itself: a run of a hundred identical bytes is one short match.
the runtime copies forward a byte at a time in that case and uses a bulk
extend when the ranges are disjoint.

zstd matches were reading and writing one byte per position through
separate runtime calls, which is what motivated this.
extracting n bits walked the stream one bit per iteration, each with a
divide and a modulo, so an eleven-bit huffman code cost eleven iterations.
it now loads the bytes the field spans as one word and shifts.

on top of that the backward reader caches a sixty-four bit window. reads
walk downward, so one window serves about fifty bits before it has to be
refilled, and a field read becomes a shift and a mask against a value
already in hand.

the empty-window sentinel sits above every valid bit index rather than
below it: the test is `at < window_base`, and a short stream's reads all
fit under sixty-four bits, so a negative sentinel would let them read an
all-zero window instead of refilling. the unit tests caught exactly that
while every real frame still decoded.
a compressed block used to build its own byte buffer and then be appended
to the frame's, and it took a copy of everything decoded so far so matches
could reach back into it. blocks now decode straight into the frame buffer:
a match into an earlier block just indexes further back. literal runs are
written as one slice instead of a byte at a time, and matches go through
copy_within.

the fse and huffman decode tables hold one packed Int per entry rather
than a struct. decoding a sequence indexes those tables six times, so a
struct entry meant six heap handles and six reference-count round trips
per sequence. read_ncount caps the accuracy log at 22, which keeps base
under 2^22 and the packed fields from colliding.

resolving an offset against the repeat history now updates the history in
place. it ran once per sequence and returned a fresh three-element list
each time, which cost more than everything else in the loop put together.
the two implementations that briefly existed are back down to one, so the
rules can't drift apart.
builds its corpus from the repo — docs, std sources, generated log lines —
so there are no binary fixtures in tree and the shapes stay realistic:
between 2:1 and 11:1, which is what a decoder meets in a content-encoding
or an otlp export.

the highly-compressible case is reported last and labelled, not led with. a
frame that packs 80KB into 22 bytes decodes almost entirely through the
match copy, so it measures memory bandwidth rather than entropy decoding
and reads five times better than any real workload.

each phase runs to a wall-clock budget rather than a fixed rep count. the
kernel decodes some of these in tens of microseconds, and a fixed count
lands inside the millisecond clock's granularity and reports its
quantization as though it were throughput.
a literal-length code needed a baseline from one list and an extra-bit
count from another; the two are now packed as baseline * 256 + bits, so
the decode loop indexes once per code instead of twice. the length bounds
check reads a hoisted length rather than calling len() every iteration.

the packing is written out by hand, so the test pins it against the
format: every fixed baseline and width, plus the invariant that each
baseline sits exactly one past the span its predecessor covers. that last
check is what matters — a mispacked entry leaves some length unreachable
and another ambiguous, and it caught a wrong value in the first draft of
this table.

worth about 1.5% on its own. the interesting part is what it ruled out: a
list index measures 18ns in isolation but only about 2ns of marginal cost
here, because the surrounding loop has enough independent work to overlap
them. isolated per-operation costs overstate what removing an operation
actually buys.
the stats hooks sit on the hottest runtime entry points — every list get,
byte-buffer write, and struct alloc probes whether PITH_PERF_STATS is set.
that probe went through a OnceLock, which showed up as a measurable slice
of a list index. it is now a three-state atomic with an inlined accessor
and a cold probe path, so with stats off the cost is a predictable branch
on one relaxed load.
three per-operation extern calls become inline code.

xs[i] called pith_list_get_opt, which heap-allocated a two-slot optional
tuple the caller immediately unpacked and released. a conservative
pre-scan proves a call's result is consumed only by that exact unpack
pattern — any other use of the register disqualifies it — and rewrites the
call, the allocation, and the release into inline loads with null, magic,
element-size, and bounds checks. a failed check still produces is_some ==
0, so the loud out-of-bounds error is unchanged. escaping optionals from
.get(i) keep the real call.

xs[i] = v on primitive lists gets the matching store fast path; tagged
lists and out-of-bounds writes fall back to the runtime call so retain,
release, and the oob-is-a-no-op contract stay where they were.

bits.band(x, y) compiled to a call to the std.bits wrapper whose body
called the ffi extern — two call layers per bit operation, and the
sequence decoder does about thirty per sequence. a pass now structurally
verifies a two-parameter function whose body is exactly load, load, call
bit builtin, ret, and aliases calls to it (and to the builtins directly)
to the single native instruction. shr stays logical, matching the extern.
an edited wrapper stops matching and keeps its calls; local shadowing is
respected. the wrapper detector and the unpack scan have unit tests.

decoding throughput on the pure zstd corpus roughly doubles from these
two files alone; the full regression corpus, the leak gates, and both
zstd interop checks pass unchanged.
the sequence and literal decode loops now mirror the bit reader's window
in locals and do their peek, read, and skip as shift and mask inline,
going back through the reader only on a window miss and at the stream
tail. resolve_offset is likewise inlined over three local repeat
variables, written back after the loop; the named function and its tests
are unchanged, and a comment ties the two copies together.

the three predefined fse tables are built once and shared behind a mutex
instead of rebuilt per block — repeat mode already established that a
borrowed table is safe. build_decode_table pushes entries instead of
zero-filling and assigning.

the readable helpers stay for every cold path; this trades inline
arithmetic for calls only in the two loops the profile says dominate.
three new builtins, each replacing a per-byte call loop with one call:
write_range appends a range of another bytes value without materializing
an intermediate slice; write_word appends up to eight bytes packed in one
integer; read_word is its mirror, a little-endian load of up to eight
bytes. out-of-range and bad counts return failure or zero rather than
partial work, and callers that rely on zero-past-the-end tolerance check
bounds themselves.

copy_within's overlapping case is also reworked: an overlapping range
means the output repeats with period (len - src), so each chunk copied is
a whole number of periods and the chunk doubles as the copy proceeds — a
long run costs a logarithmic number of memcpys instead of a byte loop.
run-heavy zstd frames decode an order of magnitude faster for it.

the bootstrap seed is regenerated for the new checker builtins; the
bootstrap fixed point verifies.
a word load from a bytes value is one 8-byte load and a mask when the
whole word is in bounds; the call survives only for the tail and for bad
counts, keeping the runtime's edge-case behavior word for word. the
bitstream refill and the xxhash stripe loop sit on this, so the call
overhead was the dominant cost of both.
decode_and_execute decodes each sequence and runs it straight into the
frame buffer. the list of sequence structs that used to sit between the
two loops cost more to build and read back than the arithmetic that
produced it; the fused loop never materializes it. decode_sequences and
execute_into stay as the separately-testable reference forms — the fused
body mirrors both, and a comment ties the three together.

literal runs, trailing literals, and raw blocks write through write_range
instead of slicing first. huffman literals decode eight symbols into a
packed word and append it with one call instead of eight. the backward
reader's window refill and the forward extract load words with read_word,
as does the xxhash kernel — which also hoists its primes and collapses
its rotates, taking the checksum from half of a checksummed frame's
decode time to a twentieth.
a performance.md section with the three-pass optimization arc — where
the time went, which fixes were decoder-side and which the compiler now
applies to every program — and the two measurement lessons worth keeping:
isolated microbenchmark costs overstate marginal cost about 5x, and a
representation change has to price the read side, not just the write.
bench/README.md gets the corpus and methodology notes, including why the
run-heavy case is reported but not treated as the headline.
compress() emits real zstd any decoder reads: raw and rle blocks split at
the format's 128kb cap, huffman-coded literals with exact length-limited
codes (package-merge, so the kraft sum is exactly one — the implied-last-
weight description demands it), and lz77 sequences over the predefined
tables with repeat-offset codes. the greedy finder hashes 4-byte prefixes
frame-wide, so matches reach across block boundaries. huffman weight
descriptions are fse-coded when that pays, which is what unlocks literals
for alphabets past symbol 128. compress() never fails — any internal error
falls back to a stored frame — and compress_checked() is the loud form the
tests and gates use.

every wire decision is derived from the decoder's own tables: codes come
from build_codes over the decode table the weights build, fse state paths
from the decode tables the counts build, repeat-offset candidates are
validated by running the decoder's resolve_offset. the two sides cannot
drift apart, and the spec anchor is external: make zstd-encode-check
requires the system zstd binary to decode every pith-compressed frame
byte-identical, because a round trip through our own decoder has twice
been self-consistent and wrong.

sizes: within 1.16x of zstd -3 on text, parity on runs and incompressible
input, 1.44x on a megabyte of source; ~4 mb/s encode. remaining size
levers, documented in the module: custom fse descriptions for the
sequence streams and lazy matching.
the example now proves interop in both directions — the reference codec
reads a pure-pith frame and the pure decoder reads a reference frame —
rather than a round trip through our own code. performance.md gets the
encoder's standing next to the decoder story.
the benchmark only ever timed decoding, so the encoder's throughput was
undocumented and its size claims came from a different corpus than the
one the performance notes cite. it now times both directions over the
same shapes: the pure encoder's frame is decoded by the kernel and
checked against the input before timing, and the reported size is a
percentage of the kernel's own output so throughput cannot be bought by
compressing worse. renamed to bench/zstd_codec.pith to match what it
measures.

the numbers correct an overstatement in the previous notes. sizes are
within 1.15x of the kernel on small text but 1.39-1.88x on prose,
source, and json — the earlier "parity on runs and incompressible input"
came from encode-check shapes that degenerate to rle frames, not from
these. encode runs 30-65x slower than the reference. the decoder earned
its numbers over three passes; the encoder has had none, and the docs now
say so.
sizes come down from 1.15-1.88x the reference codec's output to
1.01-1.19x, and throughput rises 2.5-3.8x. both metrics move together;
neither was traded for the other.

most of the size came from per-block fse tables. all three sequence
streams used the predefined distributions, which cost most on data whose
histogram is nothing like them — json logs were the worst at 1.88x. the
encoder now histograms the codes a block actually uses, normalizes to the
accuracy log (rare symbols to the "less than one" slot, leftovers by
largest residual), and emits a table description only when the estimated
bits beat predefined by more than the description costs. the finder also
prefers the previous offset, since a repeat code costs less than an
offset's magnitude, and holds a match until the next position has been
checked for a clearly longer one.

most of the speed came from writing bits forward. the backward bitstream
writer recorded every field as a value and a width on two lists and
packed them on a second pass; a packer that flushes four bytes at a time
into the buffer runs the same loop once. per-sequence scratch histories
and their failable calls are gone, the fse state walk computes its slot
from the table's tiling instead of scanning for it, and the finder's hash
table is sized to the input rather than always 65536 entries — which was
65536 pushes to encode eight kilobytes.

the packer landed with a test asserting it packs byte-identical to the
recording writer over 400 varied fields across every flush boundary,
before anything switched to it.
sizes 1.01-1.19x of the reference codec (was 1.15-1.88x), throughput up
2.5-3.8x, and the profile's largest cost is now the match finder rather
than allocation churn. records where the remaining size gap actually is:
fewer sequences rather than cheaper ones.
the encoder's spec anchor is the system zstd binary decoding every frame
we produce; that gate only protects the tree if it runs on every change.
@kacy
kacy merged commit c0f6fa5 into main Aug 2, 2026
2 of 3 checks passed
@kacy
kacy deleted the zstd-pure branch August 2, 2026 21:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant