Skip to content

Replace the zstd C library with a port to safe Rust - #47079

Open
dylan-conway wants to merge 224 commits into
mainfrom
claude/zstd-rust-replacement-4qwmbq
Open

dylan-conway wants to merge 224 commits into
mainfrom
claude/zstd-rust-replacement-4qwmbq

Conversation

@dylan-conway

@dylan-conway dylan-conway commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

What does this PR do?

Replaces the vendored zstd C library (v1.5.7 at f8745da6, plus Bun's two patches) with a port of it to safe Rust, in the bun_zstd crate (src/zstd). Nothing of the C library is built for Bun or linked into it any more. (One test program in one developer configuration still takes its decoder: see Build.)

The goal of the port is that nobody can tell: the same compressed bytes for the same input and parameters, the same decompressed bytes, the same error codes, and the same input/output positions after every streaming call.

The crate

  • #[forbid(unsafe_code)], and indexing_slicing, unwrap_used, expect_used, panic and unreachable are denied: a frame from the network cannot make it panic. Allocations whose size depends on the input or on parameters are fallible and report memory_allocation. The few small ones of fixed size (a new context, a worker's state) abort on failure, like the rest of Bun.
  • Its only dependencies are strum, thiserror and bun_pod.
  • Decoder: frames, skippable frames, checksums, raw and trained dictionaries, one-shot and streaming.
  • Compressor: all nine strategies (fast, dfast, greedy/lazy/lazy2 with hash chains and with the row match finder, btlazy2, btopt, btultra, btultra2), dictionaries both attached and copied, long distance matching, the block splitters, targetCBlockSize, magicless frames, one-shot and streaming, and nbWorkers / jobSize / overlapLog.
  • Workers share nothing but the pool that their contexts are in, which is as in the C code: a job takes the context that was given back last, and gives it back before it reports its end. They take jobs from one queue; a job owns its input, sends its output through a channel, and hands the state that has to see the input in order (long distance matching, checksum) over to the next job. The sections are cut where the C code cuts them, so the frame is the same.
  • Where the C code compares pointers into different buffers (the window, its extDict, a dictionary), the port gives each buffer a range of a virtual address space and does the same arithmetic on those addresses.

Not ported, because nothing in Bun can reach it: ZDICT_* (training), legacy formats, static and stable-buffer contexts, refPrefix on the compressing side, ZSTD_compress2, ZSTD_CCtx_reset, changing parameters in the middle of a frame, the sequence producer API, indexing a prefix for long distance matching, rsyncable mode, and the experimental parameters (ids 500 and 1000+).

Callers: Bun.zstdCompress* / Bun.zstdDecompress*, node:zlib (NativeZstd.rs), CompressionStream / DecompressionStream, fetch and Bun.serve bodies, compressed embedded assets, standalone executables, and the ICU data decompressor, which now calls a small Rust shim (src/runtime/icu_decompress.rs). ProcessBindingConstants.cpp no longer has zstd.h to take the ZSTD_* constants from, so it asks NativeZstd.rs, which takes them from the crate.

Build: bun_sema_standalone had a build script that compiled the C decoder from vendor/zstd into its test binaries. It is gone, with the cc build dependency and the clone-zstd target of the cargo test step (scripts/rust-test.ts), which now tests bun_zstd too. What stays: jsc and testaot, WebKit's own programs beside Bun, read the compressed data of a prebuilt WebKit's ICU with the C decoder, which since #47316 they compile for themselves from zstd's sources (JSCProgram.sourcesFrom). scripts/build/deps/zstd.ts is kept to fetch these sources where one of them asks; it builds nothing, and it is not among Bun's dependencies but in a list of its own, programDeps, so nothing of it reaches Bun's compiles or link. The ZstdCompressor feature of --trim listed the C entry points; it now names the crate's compressor by path (cxx-function bun_zstd ^<?bun_zstd::compress), since the macros that mark Rust entry points need unsafe.

What changes for users

  • process.versions.zstd is "1.5.7" instead of a commit hash. There is no commit to name.
  • node:zlib only lets the documented parameters through, but _handle.init() called by hand with a longer array could set experimental ones. Those are now refused (ERR_ZSTD_PARAM_SET_FAILED).
  • Bug fix: a zlib.createZstdCompress() stream with ZSTD_c_nbWorkers >= 1 lost its data as soon as the input was enough for one job (Bun 1.4.4 ends such a stream with 0 bytes, without an error). With workers, ZSTD_compressStream2(ZSTD_e_continue) returns after any progress, and zlib.ts takes room left in the output to mean that the input was consumed. NativeZstd::do_work now calls it until one of the two is used up. It is in this PR because it is what makes the multithreaded code reachable from a stream at all.
  • Bug fix: zlib.createZstdDecompress({ dictionary }) and zlib.zstdDecompressSync(..., { dictionary }) with a dictionary that cannot be loaded gave empty output without an error. They now throw ERR_ZLIB_INITIALIZATION_FAILED, as Node does.
  • Bug fix: new DecompressionStream("zstd") failed after 16 empty chunks in the middle of a frame.
  • Closing a stream with workers that are busy no longer waits for them (321 ms to 13 ms for a level 19 stream with 2 workers): they are told to stop and leave on their own, within a block, or while they index the prefix of their job, within 4,096 positions in trees and, since 1e789521e, 65,536 in rows (after a frame that is given up at level 12, the next one waited 254 to 324 ms, as in C, now 19 to 35). fast and dfast index theirs first: up to 5 ms, or 1.6 s with a table of 2^27 entries. 30 streams at level 19 with 4 workers that are dropped in the middle of a frame leave at most 8 threads and 380 MB behind, for 0.04 s.
  • A new context is smaller (compress about 15 KB, decompress about 36 KB instead of 96 KB), since buffers that the C struct embeds are allocated when a frame needs them. zlib-estimated-size-gc.test.ts had the old size in it.
  • Bun is larger. What bun build --compile --aot of a hello world maps on Linux x64 (test/bundler/compile-aot-size.test.ts), at 46048ba7a (Buildkite #138405) against main at the commit it has merged (#138398): 67,934,305 bytes instead of 67,672,289 (+262 KB, limit 68,440,000), and with --trim 25,397,345 instead of 25,331,937 (+65 KB, limit 25,880,000). The loops that find matches are compiled once for each size of table that a level comes with, which is what makes them as fast as they are, and the optimal parser once for each thing that can come before the input. c7888487 and 9b6d3303 took out the 156 KB of instances that bought nothing.
  • Speed, see below.

Speed

All times in this text but those of this paragraph are from one kind of processor, AMD Zen 4. On an Intel Xeon of the Skylake family the port compares less well. The same binaries of fc13be22b, 25 kinds of data from 1 to 512 KB, relative to C: to compress at levels 1 to 13, 16 and 19, 0.96 in the mean, and 437 of 1,500 rows are more than 3% behind (Zen 4, levels 1 to 12: 0.91, 116 of 1,200). To decompress into a buffer that is there 0.97, 369 of 700 behind (Zen 4: 0.87, 107 of 682); into a new one 1.01, 457 of 700. fc13be22b itself is mixed there: records of 100 bytes take 2 to 5% longer, 16 rows with a dictionary 1 to 4%. Bun's x64 build is for Nehalem, so the port has no BMI2, which C picks at run time. Built with it (4 orders): to compress 0.92, 244 behind (skewed bytes, level 1: 1.40, then 1.05); to decompress 0.94, 311, and 0.97, 386. Open.

Latest measurement, 9b6d3303 against main. The survey that the next paragraph describes (266 rows) was run again with a better baseline: main at e64b59d9, which has the C library, built on the same machine with the same profile as this branch, so that nothing differs but zstd (Bun 1.4.4, the baseline of the older numbers below, also has another WebKit). Two passes of each binary in turn, the better time of each row counts. Time of this branch, as a share of that of main, geometric mean: 0.90 to compress and 0.95 to decompress. 54 rows to compress and 73 to decompress are more than 3% slower, 144 and 139 more than 3% faster. By size, 1 KB to 8 MB: 0.92, 0.94, 0.93, 0.87, 0.84 to compress and 0.96, 0.98, 0.92, 0.94, 0.95 to decompress. By level, 1, 3, 9, 19: 0.91, 0.91, 0.88, 0.90 and 0.96, 0.96, 0.95, 0.94. What is said below about the rows that are slower still holds.

compress worst decompress worst
zeros 0.82 1.77 0.79 1.10
noise 0.67 1.05 0.96 1.11
base64 of noise 0.97 1.16 0.80 0.99
hex of noise 0.94 1.15 0.92 1.17
4 letters 0.99 1.11 0.86 0.94
skewed bytes (7.1 bits each) 0.97 1.14 1.08 1.15
noise with a period of 1 KB 0.76 1.04 1.02 1.11
noise with a period of 100 KB 0.77 1.23 1.06 1.21
mostly zeros 0.97 1.15 1.00 1.22
rising 32-bit integers 0.94 1.14 1.06 1.23
doubles 0.97 1.04 0.98 1.05
JSON 0.95 1.02 0.92 0.99
log lines 0.95 1.12 0.88 1.03
noise and letters in turn 0.97 1.08 0.88 1.05

Correction to four rows of this table. To decompress zeros, noise or noise with a period is little more than a copy, and in Bun such a call takes one of two times from one pass to the next, with either binary. Two passes were too few for such rows, in either direction. These four kinds were run six more times with each binary, in turn, and their rows above are now the best of all eight passes. With them the means of all 266 rows are 0.90 to compress and 0.94 to decompress, 54 and 75 rows are more than 3% slower, 142 and 141 more than 3% faster.

What this survey did not see, fixed in 41fad94a3. It has four levels and no data made of records. With records that differ in one field the port took 1.3 to 1.9 times the time of C on 16 KB at levels 6 to 8, and up to 2.2 times on 1 MB at level 12. A search beside a match that the parser already has first looks at 4 bytes of a candidate, where a match that is good enough would end, which is a shortcut of the port. Here that is past the field, where all records are the same again, so every candidate passed and was compared in full, up to 64 for each search. The first one that turns out too short after 16 bytes or more now moves the 4 bytes to where it ended, which is where the C code has them. In the harness, time relative to C before and after: 100 bytes and a counter, 16 KB at level 8: 1.90, 0.87, 1 MB at level 12: 1.86, 1.04. It has a price: text, JSON, logs and binaries of 16 KB at levels 5 to 8 take 1.0 to 1.7% longer than before (0.70 to 0.95 of C).

All levels on 25 kinds of data, and what that found in the decoder, fixed in 605aa6e57. At 41fad94a3, in the same harness (not in Bun): 25 kinds of data, four sizes from 1,000 to 1,000,000 bytes, levels 1 to 19, 1,900 rows. The output is that of C in every row. The geometric mean of the time relative to C is 0.900 to compress and 0.915 to decompress; 335 rows to compress and 523 to decompress are more than 3% slower than C. Slowest to decompress were records, at 1.9 to 2.45 at some levels: a bug. A block with few literals has them in a single Huffman stream. A valid stream of 129 to 183 symbols of about a bit each suited neither part of the quick decoder for that and went to the one that follows the C code step by step, which fills a table of its own each time (8 us a block). Records of 1 KB, 1 MB at level 17: 2.45 of C before, 1.22 after. The new loop was compared with HUF_decompress1X_usingDTable of the C library on 196 million streams, most of them damaged, without a difference. What is left, 1.1 to 1.3 to decode data that is nearly all long matches (records, HTML, short periods), is open; a good part of it is that safe code clears the room for a block before it writes there.

128 KB at level 10, fixed in 07e8c4e6a. Records took 1.2 to 1.4 times the time of C there, from something the port has and C has not. Rows of 64 entries get a second tag per entry if the input is at least as large as the hash table, which is the case for exactly 131,072 bytes at level 10, from 4 MiB at level 11 and from 8 MiB at level 12. It costs time for every position, and whether it pays was asked at the end of a block, so an input of one block paid all the way. The parser now stops every 8 KB while there are second tags and goes on from where it was, with them or without. In the harness, relative to C, before and after: records of 200 bytes 1.44, 1.08; of 100 bytes 1.33, 1.03. Where the second tags pay, the stops cost up to 2%. Open: on 5 to 17 MB at levels 11 and 12, never giving up the second tags is 8 to 25% faster on source text and mixed data, and 12 to 30% slower on HTML, numbers and records.

128 KB at levels 11 and 12, and the room for the literals, in 9778f063c and b06f15db1. 21 and 19 of the 25 kinds were more than 3% slower than C there. btlazy2 now gives hints at what the trees are about to read only after the first block, and the loop that adds the positions that a match skipped reads 4 bytes where 4 count. Every new context cleared 131 KB for literals: now 4 KB ahead of them, but for levels 3 and 4, whose loops are 3 to 6% slower that way; open. Relative to C, 25 kinds of 128 KB: level 11 1.087, now 1.031; level 12 1.081, 1.036; level 1 0.965, 0.924.

The ways down the trees, in dd2ac327c and 8f8c6f481. A step down a binary tree compared 16 bytes, looked for the first that differ and read these two again. The 16 bytes as two big-endian numbers tell the way without that wait. And the optimal parser gives fewer hints while it adds a run of 32 or more skipped positions. Relative to b06f15db1, 16 KB to 1 MB at levels 11 to 22: 0.962 to 0.986. Relative to C, 128 KB: levels 11 and 12 1.000, levels 13 to 15 0.956 to 0.998. Of 548 rows none takes more than 2% longer.

Matches in the older segment of the window, in d0d121dc9. With a dictionary whose tables are copied, or a stream longer than its window, the trees lead to the older segment, and each position there was compared out of line: 58% of the time for records of 1 KB at level 16 with a dictionary of 64 KB. That function now starts with 16 bytes at once. 352 rows: means of 0.96 to 0.99, my records 0.75 to 0.87 at levels 13 to 19, none slower when measured again. Relative to C, 64 KB and 128 KB: 1.038 before, 0.996 now, records still at 0.89 to 1.38.

Rows with a dictionary that is searched in place, in 8aec2cdde. With a dictionary and inputs of up to 32 KB, levels 6 to 9 took 1.06 to 1.47 times the time of C on data with few matches. A position whose row has no candidate was searched all the same, for the sake of the dictionary's row: that row is now looked at first, and searched alone if it has one. And in these functions, the largest, the loop that inserts positions read its operands from the stack on every pass: it now has copies of them. 396 rows (11 kinds, levels 5 to 10): 0.83 to 0.94 of the time before by level, no row slower; of C, 0.65 to 0.90, from 0.70 to 1.05. 58 rows are behind C, by 5% at most (hex digits, skewed bytes), where 185 were, by up to 47%.

The optimal parser with such a dictionary, in 306afc849, and how time is measured from here on. The parser's loop for positions that follow literals and have no match returned at once if there was a dictionary to search in place, and levels 13 to 19 took 1.15 to 1.36 times the time of C on data with few matches. It now searches the dictionary's tree too. Noise, base64 and skewed bytes: 0.64 to 0.84 of the time before, 0.78 to 1.03 of C. Of 286 rows 111 were more than 3% behind C, 61 are. Two builds of one source that differ only in the order of the functions (--shuffle-sections) differ by 0.5 to 2% in most rows, 5 to 10% in some and up to 35% at level 1, so these are means of 16 orders on each side, judged by their standard errors. That is why the parsers for such a dictionary are copies (19.7 KB): one parser that chooses makes 65 of 150 rows without a dictionary 0.4 to 1.4% slower.

The same loop while a repeat offset leads into the dictionary, in 666b76602. After a match in the dictionary the loop returned at once, in 86% of the calls. Now such an offset is looked up there. 32 KB at level 13: skewed bytes 0.57 of before (1.45 of C, now 0.83), base64 0.72 (1.12, now 0.81). 11 more kinds of data, after a review: noise with pieces of the dictionary is at 0.53 to 0.79 of before (1.25 to 1.68 of C, now 0.88 to 1.00). Slower, of 603 rows, at more than 3 standard errors: 17 of copies of the dictionary with one byte in 8 to 32 altered, by 0.5 to 1.7%, and 4 others by 0.3 to 0.7%.

And into an older segment of the window, in 55c964301: a dictionary whose tables are copied, or what a stream has before the place where its buffer starts again. Noise with pieces of a copied dictionary: 0.60 to 0.86 of before (1.25 to 1.71 of C, now 1.00 to 1.08). Streams longer than their window (204 rows, levels 16 to 19): 0.95 of before. 5 of 139 rows with a dictionary are 0.2 to 0.7% slower. 22 KB more code.

Positions that are added to the binary trees are compared 8 bytes at a time, in de7ed9c89. HTML and records were furthest behind C. With a copied dictionary: HTML 0.89 of before (1.05 of C, now 0.93), records of 1 KB 0.87 (1.29, now 1.12). Of 1,206 rows, 283 were more than 3% behind C and 218 are; 4 are 0.1 to 1.2% slower. 223 bytes more code.

Where something comes before the current segment, matches are looked for in copies of get_all_matches, in f9f4dec21. A repeat offset that leads there is looked at before it is counted, and a step down a tree into an older segment makes no call. With a copied dictionary: records of 100 bytes 0.81 of before (0.97 of C, now 0.80), of 1 KB 0.88 (1.14, now 1.01). Of 1,031 rows, 197 were more than 3% behind C and 116 are; one is 0.2 to 0.4% slower, and 3 of 54 more rows 0.4 to 0.5%. 12 KB more code.

A position that is added to the binary trees does not wait for the one before, in 209068dfd. How far to move on came from what the walk had met, a chain from each position to the next that C has too; now 1, which it nearly always is, is a branch. 16 KB of records of 100 bytes at level 16: 0.82 of before (1.14 of C, now 0.93). Of 1,206 rows, 144 were more than 3% behind C and 97 are; none is slower. 2 KB more code.

The decoder where it takes one sequence at a time, in fc13be22b. That loop, for long matches and small blocks, had its state in memory, because a call further down takes its address: now it has a copy, in registers. HTML 0.95 of before (1.08 of C, now 1.03), records of 100 bytes 0.97; of 682 rows into a buffer that is there, 123 were more than 3% behind C and 107 are. With more than 16 MB of history it asked for no match ahead of time: now for 8, as C does. 19 frames of 48 MB, not timed before: 1.21 of C in the mean, now 1.05; pieces of 40 to 330 bytes from far back 0.64 to 0.79 of before. Slower: source code three times over with long distance matching 3.7% (0.93 of C), 512 KB with a period of 1 KB 2%, two more rows 1 to 2%. A dictionary in a new context, 356 rows, not timed before: 0.93 of C, unchanged. 5.7 KB more code.

Dictionaries at levels 5 to 10, in 1e789521e. To load one of 8 to 512 KB and compress 1 KB had not been timed: 1.12 of C over levels 5 to 12, 68 of 88 rows more than 3% behind (Intel Xeon: 1.08, 69). The loops that index it in hash chains and in rows checked every position. Now 0.89 of C and no row (0.93, 7); levels 5 to 8 take 0.74 of the time (0.80), no row longer.

The tables further down are for two files, source text and an executable, and they do not hold for every kind of data. A wider survey (14 kinds of generated data, 1 KB to 8 MB, levels 1, 3, 9 and 19, Bun.zstdCompressSync and Bun.zstdDecompressSync against Bun 1.4.4, best of 24 rounds, 266 rows) showed that at efbf33dd the port was slower than C on average: the geometric mean of all rows was 1.065 to compress and 1.077 to decompress, the worst rows 6.1 and 2.4. As of d106dc84 it is 0.93 and 0.96, the worst rows 1.9 and 1.6, and 85 rows to compress and 87 to decompress are still more than 3% slower. Rows that run at the speed of memory depend on what the process has allocated before: measured alone in a new process, five of the worst rows to decompress (1.30 to 1.56 in the survey) come to 1.00, 1.07, 1.08, 1.14 and 1.24, and the two worst to compress after the 1 KB of zeros (1.39 and 1.44) to 1.08 and 1.11. So the port is not yet faster than C everywhere, and this is what is being worked on. Time relative to C by kind of data (geometric mean over sizes and levels, and the worst row):

compress efbf33dd compress d106dc84 worst decompress efbf33dd decompress d106dc84 worst
zeros 1.11 0.86 1.93 1.27 0.85 1.30
noise 1.58 0.86 1.25 1.56 0.95 1.11
base64 of noise 1.08 0.97 1.15 0.89 0.79 0.96
hex of noise 0.98 0.94 1.15 0.95 0.90 1.11
4 letters 1.00 1.00 1.14 0.87 0.86 0.94
skewed bytes (7.1 bits each) 1.12 0.99 1.14 1.27 1.08 1.15
noise with a period of 1 KB 1.04 0.78 1.10 1.27 1.08 1.46
noise with a period of 100 KB 1.29 0.83 1.44 1.33 1.23 1.56
mostly zeros 0.97 0.98 1.16 1.05 1.01 1.24
rising 32-bit integers 0.96 0.95 1.18 1.15 1.06 1.25
doubles 0.97 0.97 1.06 0.98 0.98 1.06
JSON 0.97 0.97 1.10 0.92 0.92 0.99
log lines 0.97 0.97 1.06 0.89 0.88 1.06
noise and letters in turn 1.00 0.98 1.10 0.94 0.90 1.21

What is known about the rows that are slower: level 19 on 1 KB of zeros (1.9: tables that are cleared) and on 1 to 16 KB of integers or of mostly zeros (1.15 to 1.2); 8 MB of hex digits at level 9 (1.15, also when measured alone); literals with long Huffman codes, to encode (skewed bytes, base64 and 4 letters at levels 1 to 9: 1.07 to 1.14) and to decode (1.09 to 1.15: C has assembly and BMI2 for that); inputs of 1 KB at level 1 (1.1 to 1.25, of 1.5 us); integers to decode at levels 1 to 9 (1.1 to 1.25); mostly zeros of 1 MB and more to decode (1.1). In the harness, where both sides get the same buffers, 1 MB of noise, of zeros and of either period decode in 1.00, 0.78, 0.85 and 1.00 of the time of C.

What d106dc84 and the six commits before it changed. To compress: long matches are counted 64 bytes at a time, and at levels 13 and up the positions of a stretch without any match are gone over in one loop (128 KB of noise at level 19: 0.85 of the time of C, from 1.25). To decompress: what is appended to a large new buffer is copied 2 KB at a time, because glibc's rep movsb is slow to write to pages for the first time; and short matches from less than 16 bytes back have an instance of the loop for common sequences of their own, whose test costs other data 0.5 to 2.5%. e48dc2dc: that loop lost sight of the dictionary in every frame but the first of a call. 39b1e9fa: blocks of more than 64 KB of literals, some of which C keeps in the output buffer, were decoded one sequence at a time; now the loop stops short of where those literals would be.

What 0cfb03b5 and the two commits before it changed: a block asks for room in the output when its sequences are known and not before, a block that is stored is appended, and a block without a sequence is neither copied to the store of literals nor, if it is seen to stay as it is, written as raw literals first. Noise of 128 KB and more now compresses in 0.6 to 0.96 of the time of C at levels 1 to 9 but for one row (1.08). Blocks that may be split (levels 16 and up) or have a target size are left as they were, on purpose: they spend their time elsewhere.

What a87cc412 and the two commits before it changed: literals whose codes are long are decoded one to a lookup, from a table chosen by how many symbols a lookup would yield; at levels 16 and up a position without a match takes less, and the tables of the parser are as long as a block, not always 4099 positions. Rows measured again with the binaries in turn showed no row slower than before.

What 5a031e0a fixed: glibc 2.39 copies more than 2 KB at 3.5 GB/s instead of 57 to 97 on Zen 4 if the destination is 1 to 15 bytes further into its page than the source (BZ 30994), which the port always hit with literals that are stored, since mimalloc aligns its buffers to pages (the C library hits it too, with stored blocks of an input that is aligned to a page: 49 us for 128 KB of noise, the port 87 before and 16 now); the output of one call was zeroed before it was written; and the counts of a table were read with 13 cycles of latency each.

The aim is to be faster than the C library everywhere. As of efbf33dd, in Bun itself, for these two files, for inputs of 3 MB and more it compresses faster at every level measured, by 3 to 22%, and decompresses faster, by 1 to 11%, except what level 1 made of 15 and 20 MB, where the two are even (1.00). For smaller inputs it depends on how one measures. With a different input for every call, which is what a program does, it is faster in 31 of the 32 cells measured (700 bytes to 100 KB, levels 1 to 9, both directions), by 2 to 15%. With the same call repeated thousands of times it is faster from level 6 on, and 1 to 4% slower for 4 KB and 100 KB at levels 1 and 3 to compress and for 4 KB to decompress. The C library has hand-written assembly for Huffman decoding on x64 and picks BMI2 kernels at run time; the port has neither.

The numbers that were here before 0a40fdcf were too good. They came from a harness in which rustc generated the crate's code. A release build of Bun leaves that to ThinLTO in the link, and the same source comes out differently there. One difference was large: the comparison of the 64 tags of a row (levels 11 and 12) was no longer a comparison of vectors (281 instructions instead of 31), so that in the real binary level 12 took 1.10 (text) and 1.20 (executable) times as long as C, where the harness said 0.81 and 0.93. That is fixed, no other function has lost its vector code, and the harness now builds the crate as a release build does (bitcode from rustc, ThinLTO at level 2 in lld, the standard library from source). The first tables below are of Bun itself.

Bun.zstdCompressSync and Bun.zstdDecompressSync in Bun 1.4.4 (C) and in a release build of this branch, so with the allocation of the output. One thread, x64 (an AMD EPYC), built for nehalem as Bun is, MB/s; the last column of each group is the time of the port relative to C (below 1 is faster). Bun 1.4.4 is the official build and the other is a local one (same flags for the crate; the C++ around it is not under LTO). At 901a9d9a the same local build of main as of the merge base, with the C library, was within 3% of Bun 1.4.4 in every row of the table of smaller inputs, mostly within 1%, so the comparison is fair. These are the best of 4 to 48 calls (the more the faster the level) and of 32 for decompression. The libraries alone, further down, are as of e5c4c3cf. That many are needed: with buffers of 15 to 20 MB a call takes up to half as long again, in either binary, when the allocator has to get pages from the kernel, and with fewer calls it is luck which of the two is seen at its best. At levels 1 and 3 the medians give the same ratios to within 0.02.

15 MB of TypeScript and JavaScript sources:

level compress C compress Rust decompress C decompress Rust
1 411 446 0.92 1368 1374 1.00
3 280 302 0.93 1217 1257 0.97
5 145 149 0.97 1220 1286 0.95
7 91.2 101 0.90 1347 1406 0.96
9 61.8 68.1 0.91 1392 1451 0.96
12 29.6 37.1 0.80 1430 1492 0.96
15 6.1 7.1 0.86 1455 1528 0.95
19 2.7 3.2 0.84 1389 1475 0.94

20 MB of an executable:

level compress C compress Rust decompress C decompress Rust
1 515 536 0.96 1297 1300 1.00
3 270 290 0.93 1108 1139 0.97
5 143 148 0.96 943 985 0.96
7 94.2 98.6 0.96 952 1012 0.94
9 65.1 66.8 0.97 944 1023 0.92
12 31.1 33.7 0.92 935 1031 0.91
15 7.6 9.6 0.79 942 1036 0.91
19 2.9 3.7 0.78 1010 1021 0.99

Smaller inputs (text), time relative to Bun 1.4.4. A different input for every call, 150 to 3,800 of them in turn, taken from all over the 15 MB:

input compress 1 3 6 9 decompress
700 bytes 0.92 0.93 0.85 0.93 0.96 to 0.97
4 KB 0.97 0.96 0.87 0.96 0.97 to 0.98
20 KB 0.93 0.96 0.93 0.88 0.95 to 0.98
100 KB 0.93 0.96 0.93 0.87 0.94 to 1.01

The same call again and again:

input compress 1 3 6 9 decompress
4 KB 1.02 1.02 0.91 0.94 1.01 to 1.04
100 KB 1.01 1.03 0.92 0.86 0.85 to 0.96
3 MB 0.91 0.92 0.91 0.89 0.89 to 0.96

Safe code can only write to memory that has a value, and the room that a frame can take is as large as its input. Up to e5c4c3cf all of it was made zeros first, which at level 1 on the executable cost as much in the kernel as the port was ahead in user mode (20 against 15 million cycles per call, of some 160 million). Now the room is the capacity of a Vec whose length grows, with zeros, ahead of each block (15 million).

One context per thread for Bun.zstdCompressSync, kept from call to call, would save 0 to 7% for inputs of up to 100 KB (7% at 700 bytes, 4% at 4 KB, 1% at 100 KB, at levels 1 and 3) (measured in the harness, with the same bytes out). That is not worth memory that a thread never gives back, so each call makes its own, as with the C library.

The libraries alone, in the harness, into buffers that are there already. Correction: the C library here is built from the same pin, but with -O2, where Bun's release build has -O3, which I had wrong. With Bun's flags it compresses JSON 5 to 6% faster at levels 1, 3 and 19 and data without matches 7 to 13% faster at level 19, is as fast on literals alone, and decompresses 4% slower, so the compression ratios in these tables flatter the port by as much. The tables and the survey above, which are against Bun 1.4.4 itself, are not affected. The harness now has both builds.

15 MB of TypeScript and JavaScript sources:

level compress C compress Rust decompress C decompress Rust
1 404 452 0.89 1335 1475 0.91
3 282 308 0.91 1187 1396 0.85
5 145 154 0.94 1194 1418 0.84
7 90.4 103 0.88 1326 1584 0.84
9 61.0 69.7 0.88 1364 1634 0.83
12 28.4 37.0 0.77 1423 1713 0.83
15 6.1 7.2 0.85 1460 1747 0.84
19 2.6 3.2 0.81 1391 1675 0.83

20 MB of an executable:

level compress C compress Rust decompress C decompress Rust
1 511 542 0.94 1591 1641 0.97
3 287 300 0.96 1333 1474 0.90
5 139 153 0.91 1147 1304 0.88
7 90.8 99.4 0.91 1196 1385 0.86
9 63.3 69.1 0.92 1186 1389 0.85
12 29.6 33.9 0.87 1204 1423 0.85
15 7.6 9.6 0.80 1218 1434 0.85
19 2.8 3.7 0.75 1308 1416 0.92

Differences of less than 2% are not to be trusted either way: moving the hot functions of the decoder by 32 bytes changes as much. The list that follows was measured before the harness was built like a release build, and has not been measured again.

Where C is still ahead: inputs of up to 16 KB at level 11, where the optimal parser starts (0 to 14%, half of it from clearing its tables), and of 32 to 128 KB at levels 11 and 13 (up to 7%); random data at levels 13 and 15 (about 30%) and 19 to 22 (5%); very repetitive data at levels 13 to 16 (6 to 8%); a dictionary that is copied, on binary data at level 11 (4%); small JSON at level 9 (1 to 3%); the match finder of levels 3 and 4 on log files and JSON (4 to 7%, even for the whole compression); data that is mostly literals, where the Huffman encoder is 15% behind (5 to 7% overall); some synthetic data at levels 9 to 19 (8 to 16%); in the decoder, frames of 30 bytes with a dictionary (4 to 6%), very repetitive data with matches of a few hundred bytes (3 to 21%), random far matches (10 to 15%), a stream of frames with a window of 128 MB on one decoder (10 to 16%), and more than 16 MB of mostly zeros decompressed in one call (up to 30%, since the output is cleared before it is written; even on real data). These numbers hold for the pinned toolchain: with an older LLVM (21), which does not vectorize the comparison of 16 bytes in the binary trees, levels 13 to 19 take up to 3x the time of C on data with very long matches (with the pinned one, built like a release build: 0.71 to 0.98 on the same files).

Where the port no longer follows the C code line by line, to be faster with the same result:

  • Decoder: the output of one call is a Vec that grows: blocks that are stored or one byte, and literals and matches of 256 bytes or more that do not fit in what is there, are appended and never zeroed; sequences are decoded 128 at a time into an array and then executed; a small output is decoded where there is room to copy in chunks up to its end, and then copied out; a Huffman lookup yields up to three literals (one, in a simpler loop, where codes of up to 11 bits are so long that few lookups would yield more), and a stream that does not end exactly where it should is decoded again by a second decoder that does what C does, step by step, so that damaged input gives the same bytes and errors; matches in a dictionary or in the wrapped part of the window are handled by the common path.
  • Greedy, lazy and lazy2 (levels 5 to 12): one parser loop over 32-bit positions; candidates that cannot beat the best match so far are dropped by looking at the bytes where they would have to be longer; hash chains put off the insertion of a position until the next search; rows of tags are compared 16 at a time by code that the compiler vectorizes. For rows of 64 entries (levels 11 and 12) on inputs of known size, a second byte of tag per entry (4 to 8 MB more memory) avoids most false candidates.
  • Fast and dfast (levels 1 to 4): a dictionary is indexed without reading the tables, which are empty (a dictionary loaded for each frame took 1.3 to 1.7x the time of C, now 0.5 to 1.06x); the input is read once per step, what is needed once per match is kept out of the search, a candidate's bytes are compared before its index is checked, and the table sizes of the default levels are known at compile time.
  • Binary trees and the optimal parser (levels 13 to 22, and from level 9 on small inputs): what the next positions will need is prefetched, 16 bytes are compared at a time in a way that also tells which side of the tree to go down, prices are kept in arrays of their own and updated when a frequency changes, the three repeat offsets are compared at once, a position whose hash has no candidate in the window skips the walk, and the tables of the parser have a position for each byte of a block instead of always 4099. btlazy2 compares a candidate's first 16 bytes without waiting for what the way down so far says they share, does not go down a tree that has nothing, and has a version without prefetching for segments of up to 64 KB.
  • Entropy stage: a first pass makes one record per sequence and the three histograms, a second writes each sequence at once; Huffman tables are built from the symbols that occur; the tables of the predefined distributions are constants.
  • Output that goes to the spare capacity of a Vec: a block asks for room, which has to be zeroed, once its literals and sequences are known, which bound what they take; a block that is stored is appended. A block without a sequence reads its literals where they are.
  • Contexts: nothing is allocated or cleared before it is needed, and tables that get smaller and larger again from frame to frame are not cleared as long as indices go on.
  • Copies of more than 2 KB are made 2 KB at a time where glibc would be slow (see Speed), on x86_64 with glibc only.
  • Everywhere: state in local variables instead of behind references (a write to the output makes the compiler reload everything that is behind one), slices that shrink and 32-bit positions instead of usize positions (a bounds check that the compiler can remove, or that is one comparison), tables indexed through a mask, match lengths counted 16 bytes at a time.
  • A thread keeps the tables (about 30 KB) of the last decoder that was dropped on it for the next one, since clearing new ones takes longer than decoding a small frame. Nothing reads a table that the frame has not set.

Buffers whose size a frame header or a parameter dictates are no longer cleared when they are allocated. They grow as they are written: with mimalloc, clearing a recycled window-sized buffer for a stream of a few bytes was by far the largest cost of such a stream.

bun_zstd is built with opt-level = 3 in debug builds too, as the C library was; at opt-level = 0 tests time out.

bun_pod gets try_zeroed_vec, a fallible calloc for a Vec of Zeroable elements, which is how the crate allocates its tables without unsafe of its own.

How did you verify your code works?

Against the C library. A harness (not in this PR, it needs the C sources) links the C library, built from the same pin with the same patches and flags, and the crate into one program and compares them on random inputs: every return value, every position, every output byte.

  • Decoder: valid frames from every level and parameter, with and without dictionaries, then the same frames damaged (bit flips, truncation, insertion, copying); one-shot with exact, short and long outputs; streaming with random input and output sizes down to one byte.
  • Compressor: random parameters (valid, out of range, unknown), levels, pledged sizes right and wrong, raw and trained and damaged dictionaries, several frames on one context, random cuts of the input and output, flushes, frames given up half way.
  • Workers: only the concatenated output is compared, since when output becomes available depends on timing in the C library too.
  • Each module was first compared on its own against the C function it ports (match finders on the sequences they emit, entropy coders on their bytes and tables), with mutation testing of the harness for the fast and dfast finders (45 of 45 seeded bugs caught).
  • Frames over 3.5 GB, and 9 GB of frames on one context, for the index overflow correction.

No difference is known other than those listed below. Each push is checked with tens of thousands of iterations of this harness (the push of the rewritten decoder: 48,000 decoder iterations, each of which decodes one set of frames about 20 ways, after 220,000 while it was written; 12,000 compressor iterations, 4,500 with dictionaries, 250 with workers).

Fuzzing, which goes on while this PR is open, in lockstep with the C library:

  • The same harness with 15 more kinds of data and fresh seeds: about 950,000 compressor, 550,000 decoder, 1.4 million dictionary and 90,000 multithreaded iterations so far.
  • libFuzzer targets for the decoder, the compressor, dictionary loading, the wrappers in lib.rs, frames built from sequences that the fuzzer chooses, and the entropy coders called directly: about 50 million executions. The one for the compressor has a slow, large corpus, and from 306afc849 to 1e789521e did little more than replay it (2,200 new inputs a round); split up on 2 November.
  • 4 million variants of frames from upstream's decodecorpus. The decoder's corpora were also run against a C build without assembly or BMI2, which gives the same as the one with.
  • A stress test for workers (flush storms, contexts dropped or reset with jobs in flight), one context reused for hundreds of frames, 4.4 GB inputs for index overflow correction with every strategy, allocation failure under an address space limit, and about 140 scenarios that compare CPU time.
  • What the ICU shim relies on for its from_raw_parts: frame_extent never asks for more bytes than the frame has.

Line coverage of all of it, measured with a counter in every basic block: 6,398 of the 6,854 lines with code (93.3%; an earlier, coarser measure said 95%). Half of the rest are headers and braces of functions that are always inlined. 196 of the lines were then probed one by one: 67 were reached after all (equal return Err(..) blocks are merged), 129 never. What of that can be reached has a tool of its own now, each compared with C: indices near their limit (about 150,000 times that they are brought down, every strategy, with long distance matching, a dictionary in reach, an older segment), the encoder's entropy functions called one by one, sub-blocks, a block that the splitter cuts with a raw part in the middle, dictionaries of 16 MB and more and dictionaries that are wrong in one place, and inputs, sizes told and skippable frames around 2^32 bytes. The tools were checked with 38 faults planted in the port: 33 were seen; the other 5 change nothing, because a check right after does the same, in C as well. What is left is behind a check that a caller has made before, as in C.

It has found no panic and no hang, and one difference in output, fixed in dd89d7876:

  • Frames that were valid but not those of C, once about 3.4 GB had gone through one context with workers. Each worker had a context of its own, on the ground that which context a job goes to is a matter of timing in the C library. It is not where jobs do not overlap: ZSTDMT_getCCtx takes the context that was given back last, and a job gives its context back before it reports its end, so frames of one job, a stream that is flushed after each write, and anything with a single worker go through one and the same context. What that context has been through shows in where its indices stand, which decides where they are brought down, which lets go of an attached dictionary. It takes a context that lives for 3.4 GB, then a frame with a dictionary that is attached and still in reach 16 MB into the frame (a window of 32 MB or more, a first job of more than 16 MB). One case: one worker, 200 MB, a larger hashLog, 3,264 MB, then 60 MB with a dictionary: 41,784,989 bytes in C, 43,041,965 here. The contexts are now in a pool as in C, and keep the books of the C workspace like any other context. Checked against the unmodified C library: those cases and the lengths around them with 1, 2 and 4 workers; about 1,100 iterations at random on copies of both libraries whose indices start high (parameters and the number of workers changing between frames, many small jobs, workspaces that C replaces as too small and as too large for too long, the latter counted: 60 of 60), in 300 of which the parameters are held so that indices are brought down in the last frame, with the dictionary in reach, about 4 times in 5. 52 runs of 3.8 GB through one context of the libraries as they are (every strategy, 16 with workers) showed no other difference.

It has found performance bugs, which are fixed here but for what the last item calls open:

  • With workers and long distance matching, every flush zeroed a buffer that was sized for a whole job (20x the time of C at level 22).
  • The optimal parser took twice the time of C with targetLength at its maximum on repetitive input.
  • A worker cleared its tables whenever a job was larger than those before. Since dd89d7876 what prevents that is what prevents it in C: small jobs all go to the context that had the first job of the frame. With a dictionary that job has the small tables of the dictionary, so a flush that is larger than all before it does clear the tables, in both. 300 flushes of 100 to 30,000 bytes with a dictionary of 100 KB and one worker take 8 to 11 ms at level 3 (C: 12 ms), 290 ms at level 19 (20 ms before that commit; C: 15.7 s) and 110 ms at level 22 (87 ms; C: 148 s).
  • A job was bound to one worker when it was posted, so that it could wait for a busy one while others were idle.
  • Its timings of its own corpus led to the records that differ in one field, which are described above, before the survey (41fad94a3). Most of what they showed at first was its compiler, an older one, in which the comparisons of 16 bytes at a time are not turned into vector instructions. With the pinned toolchain three kinds are left above 1.25 times: binary trees at btlazy2 on data with a short period (1.3), the optimal parser with an attached dictionary and very small tables (1.4 to 1.6), and long distance matching with a ldmMinMatch of 12 to 15 and a ldmHashRateLog of 1 (1.3 to 1.6). None is at the parameters of a level. They are open.

And one bug that only shows when an allocation fails: the block splitter took that for an estimate that failed, which means no split, so the frame was valid and success was reported, but the bytes were not those of C. It is an error now.

It has also found two crashes in the C library (so in Bun 1.4.4, if anything there gets to them; I have not tried). When a frame with workers is given up while a job waits for a free worker, jobReady stays set, and the next frame on that context posts a job description that has been zeroed, which a worker dereferences; I have checked this one against the source. The other, which I have not checked myself: after a frame is given up, raising nbWorkers for the next one resizes the pool before the old jobs are waited for, and they use memory that has been freed. The port has no such state.

One more thing in 1.5.7 that the port does as well, found on the way: there are inputs, made on purpose, that a stream at level 22 cannot compress. One block of them is split into so many parts, all of which are stored, that they do not fit the stream's own output buffer (ZSTD_compressBound of a block), and ZSTD_compressStream2 fails with dstSize_tooSmall; ZSTD_compress with the bound for the whole input has the room. Both do the same here as in C. Only levels 16 and up split blocks in that way.

What is not the same as 1.5.7, on purpose. The rule: where 1.5.7 reports success for something that breaks the format or the API's contract, and what it gives is not defined, the port reports the error.

  • For some trained dictionaries the C library reads table entries that it never initialized (symbols above the dictionary's maximum), so its own output depends on what malloc returns. The port gives what C gives when that memory is zero.
  • An offset beyond the frame's window, which the format forbids and 1.5.7 does not check. A streaming decoder keeps little more than the window, so once its buffer has wrapped around, such an offset gets whatever is there by then, and the streaming output of the C library differs from its own one-shot output. The port reports corruption_detected from that point on. Decoding in one call, where all of the output is still there, is as in C.
  • A compressed block that makes more than the largest block size. In 1.5.7 only the streaming decoder refuses it; decoded in one call, such a block can overwrite literals that it has yet to read. The port reports corruption_detected in both.
  • With workers and a frame of more than one job, 1.5.7 does not notice input that is not of the pledged size, and makes a frame with the wrong size in its header, which cannot be decoded. The port reports srcSize_wrong, as both do without workers.
  • With long distance matching and ldmHashRateLog above the window log, 1.5.7 makes a table of 8 GiB because a subtraction wraps around. The port does what upstream does since cef5a5611 (March 2026) and makes the smallest table, which can give other output than 1.5.7 for those parameters.
  • After ZSTD_decompressStream has returned an error, zstd.h calls another call undefined behavior, and 1.5.7 goes on with sizes that no longer belong together. The port returns the same error again until the stream is reset. Only the two errors that tell of a lack of progress leave the stream as it was, in both.
  • zstd.h says that what starts a new decompression job, ZSTD_decompressDCtx among them, resets the context. 1.5.7 does not reset the stream: one that was under way goes on afterwards with what the other job left. The port does what the header says.
  • After ZSTD_compressStream2 has failed in the middle of a frame, which the header calls undefined as well, 1.5.7 goes on with the frame if it has no workers (later calls can succeed) and starts a new one if it has. The port returns the same error from then on and lets its workers go. Their contexts go with them, where C keeps its own, so after a reset of the session the indices of the next frames start again: that shows in the way described under Fuzzing, 3.4 GB later (seen in 3 of 4 seeds). The same holds for a frame that is given up with jobs under way, which the port stops at their next block; 1.5.7 does not get over that (see the crashes above), so there is nothing to compare with. Nothing in Bun uses a context again after an error, or mixes streams and whole frames on one.

One more thing can show, with frames that no encoder makes. A raw or RLE block larger than the frame's largest block size is accepted by 1.5.7 when a frame is decoded in one pass and refused when it is streamed, and a streaming decoder takes the one pass if the frame says its size, all of it is there and there is room for all of its output. The port does all of that. But the decoder of fetch response bodies no longer offers the decoder all the spare capacity of its buffer at once, so such a frame of more than 4 KB that used to get through, depending on that capacity, is now an error. The other way round, a frame without a checksum that ends with an empty last block before it has made as many bytes as its header says is refused in one pass and accepted when it is streamed, by 1.5.7 and by the port alike. Such a frame that says more than the room it is offered (4 KB to 256 KB), and that used to be refused if the spare capacity happened to be enough, now gets through. Two more answers of 1.5.7 depend on that room in the same way, and the port gives them call by call. The limit on the window (128 MB) is only checked when a frame is streamed, so a frame with a larger window that says a small size, which is legal but which no encoder I know of writes, used to get through if the spare capacity was enough for all of it, and is now refused if it says more than the room it is offered. And a frame that makes more than its header says is refused at the block that does not fit or only at its end, depending on the buffer that an earlier frame left in the decoder: if such a frame is also cut off, the error of fetch can now be that the body ended early instead of that it is corrupt.

The differential harness now compares with two builds of the C library: the pin, and the pin with these changes made to it.

zstd's own history. All 4,141 commits that touched the library or its tests, from 2015 to upstream's dev of October 2026, were gone through for bug fixes, each of which was checked against the Rust code as it is now and, where a reproducer can be made, run on both sides. The four CVEs: CVE-2019-11922 (one-pass compression to a buffer below the bound) is not there: the check that fixed it is, and compressing and decompressing to every capacity from 0 to the bound gives what C gives (1.5 million calls); CVE-2021-24031, CVE-2021-24032 and CVE-2022-4899 are in the command line tool, which is not ported. The last four differences above come from this, and so do the two smaller bug fixes for users. For index overflow correction, both sides were also built with the limit at 48 MB instead of 3.5 GB, so that it happens every few frames: about 200 runs over every strategy, dictionaries, long distance matching and workers gave the same bytes. Parts of the crate were rewritten for speed after the commits that concern them had been checked, so those findings were checked again against the code as it is now: of 386 findings, 335 concerned code that had changed; 330 were read again and 236 run again. That found three bugs that the rewrites had brought in, all fixed: after 3.4 GB through one context and a change of parameters, an attached dictionary in a window of 32 MB or more was let go of at another place than in C (valid output, other bytes); a context whose tables could not be allocated went on with stale indices in the tables it had (a frame that does not decode, after 3.4 GB through that context); and a stream with workers could hang after a thread had failed to start. The 28 findings about the binary trees and the optimal parser were checked last, on that code as it is now: none is a bug. For these, 31 copies of the crate were made with one fix taken out of each, to see whether the runs that count as evidence notice: seven went unnoticed, so six reproducers were written that do notice them. Four of those are now tests ("the optimal parser"); the other two need a context that changes its dictionary, which no API of Bun does, or the build with the low index limit. Limits of this review: nothing above 4 GB in one call was run with the binary trees at the real index limit, and about 340 of the oldest commits were dismissed by their subject line alone.

In this repo.

  • New in test/js/bun/util/zstd.test.ts, "the output is that of zstd 1.5.7": size and hash of the output at every level, with 14 sets of parameters, with a raw and with a trained dictionary at 8 levels and 2 sizes, and in a stream with flushes. The expected values were recorded with Bun 1.4.4, which has the C library at the same pin, so these tests pass there too; that is the point of them.

  • "in a stream with workers" fails with Bun 1.4.4 and passes here.

  • "in a stream with workers, where a flush does not cost as much as a whole job" and "where a larger job than those before does not clear the tables" are for the performance bugs above: they timed out before the fixes. Bun 1.4.4 passes them.

  • "bugs of zstd 1.5.7": one test for each of the last four differences above. The size of the table of long distance matches does not show from JavaScript, so that has a unit test in the crate as well, which cargo miri test now runs.

  • "bugs that zstd has had": 87 tests from the review of the history (frames that were once decoded wrongly or crashed, inputs that were once compressed wrongly, parameter bounds, dictionaries, workers). The expected values are those of the C library (Bun 1.4.4, and Node 24 where Bun 1.4.4 has a bug of its own), so 81 of them pass on Bun 1.4.4; they are there because the port does not follow the C code line by line.

  • "few literals whose codes are long": three tests for the Huffman decoder's loop of one literal to a lookup. While that was being written it read one bit too many of a stream whose last byte is 1 and whose first seven codes have 8 bits, and gave a wrong byte without an error; the differential harness found that in 1 of some 20,000 frames, the first of these tests finds it in its third. Bun 1.4.4 passes them.

  • "literals that take many bits": three kinds of bytes at six sizes, decoded both ways, with how the literals of each block are written pinned, so that blocks that go on with the code of the block before are known to be among them. A unit test in the crate drives the tables directly: codes of 8 to 12 bits, blocks of 200 to 16384 literals in one and four streams that go on with one code, every change from one kind of table to the other, damaged streams compared with the exact decoder.

  • "where a block is one match, which is too short for the optimal parser to take at once" reaches the last position of the parser's tables: with one position less it fails. A unit test compresses a long, a short and the long input again on one context. Both, like most tests here, hold values from Bun 1.4.4 and pass there.

  • "where blocks are nothing but literals", in one call and streamed, at levels -5 to 16. A unit test has literals that start and end like noise with something compressible between, which the C library stores, at the capacities around the size of the frame: a review found a first version to compress them, or to succeed where C fails, when there were fewer than 7 bytes to spare.

  • "where blocks that are stored are between blocks that are compressed", with values from Bun 1.4.4, and a unit test in the crate (not under Miri, where it takes over 10 minutes) that compares room that grows with room that is there, at five levels and five capacities. The first version gave a block that is made in parts (levels 16 and up) room for 197 of them. A review made two inputs of 35 MB for level 22 that need more, on which it failed or gave other bytes than C; 100,000 frames of the differential harness had not come near that. Such blocks now get all the room at once, as before.

  • "short matches from less than 16 bytes back": noise with copies planted 1 to 15 bytes back in every, every second or every eighth sequence, decoded in one call, as a stream, in a window of 1 KB, and after a dictionary that the first two matches reach into. With the new loop made to skip a match that reaches before the output, it fails. "where matches are far between, for the optimal parsers" has values from Bun 1.4.4 for the loop that goes over what has no match.

  • Unit tests in the crate, which cargo miri test runs: three for the contexts of workers (the indices of a context start again where C makes a new workspace: too small, too large at the 129th reset and not at the 128th, and never if it is as large as it takes; the pool gives out the context that was given back last and keeps no more than there are workers; with 4 workers, jobs that come one after the other have the same context, which stays when there are fewer workers and goes when there are more than ever), each of which fails with one of six faults planted, among them a context that is given back after the job has reported its end; every offset and length of the copy for short close matches; matches counted up to where they end, around the 64 bytes that are compared at a time; long copies in parts; three for streams after an error, each clause of which was taken out once to see the test fail; and two for matches in a dictionary from frames that are not the first of a call, one of which calls the loop for common sequences itself and fails with the rule that was there before. "frames one after the other whose matches are in a dictionary" does the same from JavaScript. One more has blocks made by hand with more than 64 KB of literals, in one call and in a stream whose buffer has wrapped: valid ones, blocks that make too much, and blocks whose 125th sequence the C library refuses because its output catches up with its literals; the results were checked against the C library, and each of the four conditions of the change was taken out once to see the test fail. "blocks of more than 64 KB of literals" has four cases from JavaScript (matches all over, only at the start, in the older part of a stream's buffer, in a dictionary).

  • "where records differ in a few fields": 16 cases with values from Bun 1.4.4, for the hash chains and the rows, with second tags, with a small window and with a dictionary; which of them each case reaches was checked with counters in a copy of the crate. Like the rest of that block it holds the output to that of C, so it passes before the change as well: it is there for wrong variants of it (taking every candidate that is too short, not only the first, changes 6 of the values). That the change is there at all is what a unit test in the crate is for, which fails with each of four faults planted. Nothing in the repository measures the time.

  • "where the binary trees lead to what is before the current segment of the window": 9 cases with values from Bun 1.4.4, after a dictionary and in a buffer that is written to from its start again. All change with a fault in the new code. Two unit tests see the 15 faults put into it (one only since 615100a88).

  • "long matches from more than 16 MB back", in one go and as a stream: its frame sees 8 of the 12 faults put into the new function. Bun 1.4.4 passes it.

  • "where the rows of a dictionary have candidates and those of the input have none": 10 cases with values from Bun 1.4.4, rows of 16, 32 and 64 entries. It fails with each of 8 faults put into the new code. A unit test holds the two loops that insert positions to the same tables, and sees 4 of 4 faults.

  • "after a dictionary that is indexed in several parts": 8 cases with values from Bun 1.4.4. Of 12 faults it sees 9, the fuzzers 12. That a worker looks at the flag again between parts is not tested.

  • "where there are literals before a match in the trees of a dictionary that is searched in place": 15 cases with values from Bun 1.4.4. Of 22 faults put into the new code the fuzzers see 20, and 19 of these change a digest.

  • "where the kind of data changes within a block, for rows of 64 entries": 12 cases with values from Bun 1.4.4, where the parser hands over with and without second tags, with literals pending and while it skips. Like the rest of that block it passes before the change too; it is there for the hand-over (it is the only test that fails if the start of the pending literals is lost, and one of three if the tags are not moved). That the change is there is what two unit tests in the crate are for: one has blocks of 128 KB and looks at the tags afterwards (it fails with the rule as it was; not under Miri, where it takes over 6 minutes), the other has the rule alone.

  • A unit test in the crate for single Huffman streams whose most frequent symbol takes one bit: every length from 1 to 1,023, two codes (longest code 3 and 11 bits), three forms of the table. Each valid stream has to be decoded by the quick decoder, and streams with one bit flipped have to give what the careful decoder gives, for both kinds of table that C has. It fails with the old limit and without the new clamp; a smaller limit for what is left does not compile. Under Miri it has two lengths and no damaged streams (11 s).

  • Unit tests in the crate for 9778f063c and b06f15db1, under Miri too: many positions added to the trees at once against one by one, around the end of the table, for hashes of 4 and of 5 bytes; the hash from 4 bytes against the one from 8; the literals and the length of their buffer over two blocks, up to a whole block, after a failure, and where it does not grow. 16 faults put into the code are each seen by one of them. For dd2ac327c: the comparisons, for a first difference after each of 0 to 17 bytes, bytes on both sides of 0x80, and what follows the difference in the other order; 15 faults put in by hand are all seen.

  • "frames that no encoder makes decode as with zstd 1.5.7": 116 frames from upstream's decodecorpus, each decoded 20 ways (one-shot, streams cut in several ways, truncated, damaged), with the results recorded with Bun 1.4.4.

  • Run on a debug (ASAN) build on Linux x64:

    • test/js/bun/util/zstd.test.ts (258), test/js/node/zlib/zlib.test.js (473), zlib-handle-bounds-check (41), zlib-estimated-size-gc (6), zlib-reset-race (5), zlib-onerror-reentrancy (3), leak (8)
    • test/js/web/streams/compression.test.ts (74), test/js/web/fetch/fetch-compress.test.ts (32), test/js/web/intl/intl.test.ts (21, for the ICU data)
    • test/regression/issue/18413-*, 20053, 23314/*, test/js/node/errors/error-code-messages.test.ts
    • test/js/node/test/parallel/test-zlib-zstd*.js and the other test-zlib*.js that have a zstd case
    • --trim, on a release build with the trimming link (local WebKit and the system's ICU, so without ICU's data package): all of compile-trim-runtime-by-use (243), compile-trim-runtime-by-use-features (44) and compile-trim-remove.test.ts (18) pass. The compressor is 643 KB of an executable that uses it, an executable that only decompresses does not have it, and one that has it forced out says --trim removed zstd compression from this executable.
    • By hand: the same script on this build and on Bun 1.4.4 (every API above, valid and damaged input) prints the same sizes, hashes and error codes; a --compile --sourcemap executable remaps its stack traces.
  • Left to CI: the rest of the test suite and every platform other than Linux x64. Not run locally either: test/js/node/process/process.test.js, which needs a package that I could not install.


Generated by Claude Code

claude added 11 commits October 27, 2026 11:37
The build no longer fetches, patches, compiles or links facebook/zstd.
Nothing replaces it yet, so the build fails: bun_icu_decompress.cpp and
ProcessBindingConstants.cpp include zstd.h, BunProcess.cpp reads
BUN_VERSION_ZSTD_HASH, and bun_zstd declares zstd's symbols.
bun_zstd no longer declares zstd's C symbols. It now holds a port of the
decoder of zstd 1.5.7 with no unsafe code: frames, skippable frames,
dictionaries, prefixes, one call and streaming. For every input it is
meant to give the output, the error code and the stream positions that
the C decoder gives.

bun_pod gets try_zeroed_vec, for buffers whose size the input dictates.

The compressor is still missing and nothing is moved to the new API yet,
so the build still fails.
Parameters, frames, dictionaries, block splitting, streaming and the
multithreaded mode, on top of the match finders and the entropy coders.
The users of the C API move to the new one. src/zstd/wip holds the
differential test harness and scratch copies, to be removed.
…g calls

- VecOutput: output at the end of a Vec that zeroes about as much as gets
  written. StreamingDecoder and the CompressionStream coder zeroed the whole
  spare capacity before every call, which is quadratic in the number of frames.
- node:zlib: with workers a call returns after any progress, so call again
  until the input or the output is used up. Streams with nbWorkers lost data.
- The size that node:zlib reports to the GC comes from the types.
- A job's input buffer grows with the input instead of being reserved whole.
- Remove CCtx::reset, DCtx::reset, parameter updates in the middle of a frame
  and unused exports; share duplicated constants and helpers.
- Tests: a trained dictionary, a stream with workers, one shared sample.
@robobun

robobun commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 4:44 PM PT - Nov 7th, 2026

✅ @claude, your commit 46048ba7ac67a33e1ffd1c8b74e16e2b6739dacb passed in Build #138405! 🎉


🧪   To try this PR locally:

bunx bun-pr 47079

That installs a local version of the PR into your bun-47079 executable, so you can run:

bun-47079 --bun

It needs the C sources. It stays in the history of this branch.
The loop that decodes and executes sequences took 50% more instructions than
the C one. Now it works on local copies of its state, which stay in registers
(what is behind a reference is stored and loaded again around every write to
the output), and the fast path is written with slices that shrink instead of
positions, so that the compiler sees that most bounds hold. A match that starts
before the prefix takes the slow path.

15 MB of source text at level 3: 977 MB/s before, 1219 MB/s now, C 1217 MB/s.
- fast and dfast without a dictionary work on 32-bit positions in the part of
  the input that they can read (Near), where a bounds check is one comparison,
  read each position once, and index tables through a mask. An index that is
  too low wraps around and fails the same check as one that is out of bounds.
- SeqSink: the sequence store as a local variable while a hot loop adds to it.
- The row match finder compares 8 tags at a time instead of one, and reads the
  bytes that a longer match has to have once per length instead of once per
  candidate. Level 12 goes from 1.43x to 1.16x the time of the C library.
- The bit writer clamps its position where it writes, which removes the check.
- The parts of generic functions that do not depend on the parameter are
  functions of their own (NoDictBlock, TwoSegments, SavedRep, Unsorted,
  ChainSearch), tuples of two of the same type are a struct or a range, and
  the size of the loaded header is in the stage that it belongs to.
- fast, dfast: what does not depend on the minimum match length moves out
  of the functions that are compiled once for each (first position, least
  step, repeat offsets with an older segment, width of a tagged hash).
- btlazy2: the slot of the position to sort and its first candidate are
  worked out with the rest of what is known about it.
- Workers use mpmc channels, whose receivers are Sync, so that a context is
  Send + Sync and an unsafe impl around one has nothing to vouch for.
- Removes what nothing reaches: indexing a dictionary for long distance
  matching (only for a prefix from the user) and the full load method when
  filling the table of a context (the C code asserts that it is not used).
…its own input

The buffer was sized, and zeroed, for a whole section (jobSize / ldmMinMatch
entries of 12 bytes) for every job, so a flush of 1 KB cost as much as that:
20x the time of the C library at level 22, and gigabytes with a large jobSize.
The C library allocates as much but only touches what it uses. The limit that
the search stops at is still that of a whole section, as it decides whether
the input gets indexed at all.
The optimal parser prices every length of every match it finds. The price is
the sum of a part for the offset, which is now worked out once for a match,
and a part for the length, which is now worked out when the code of the length
changes. The positions that a match reaches are walked as a slice.

With targetLength at its maximum and repetitive input, where this loop is
nearly all there is, level 19 took twice the time of the C library and now
takes two thirds of it. Other input is within 2% of what it was.
@dylan-conway
dylan-conway force-pushed the claude/zstd-rust-replacement-4qwmbq branch from 802a3d5 to 078890f Compare October 27, 2026 17:06
claude added 10 commits October 27, 2026 17:15
…before

A job is only told its own size, and the model of the C workspace took a job
that was a little larger than any before it for a reason to make a new
workspace, which clears the tables: 640 MB at level 22, for each flush of a
stream whose flushes grow. The C library has the same rule but hardly gets
there, as its small jobs all go to the context that had the first job of the
frame. A worker's context now counts with blocks of any size from the start.
… 8 at a time

Where two words differ takes no branch, so most matches are measured without
one that is hard to predict: 15 to 20% fewer branch misses from level 1 to 9.
…d as they are made, and a sequence is written at once
…search, and takes no branch on an index that is too low
… of the machine

`zstd.enabled` is true for a prebuilt WebKit whose tarball has the objects of
`jsc` and `testaot`, because `testaot` then compiles zstd's decoder. The case
that expects false looked at the shared build cache, so it failed on a machine
that has fetched such a tarball. It has a cache of its own now, which is empty,
and the test that puts these objects in a cache expects true.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

dylan-conway commented Nov 1, 2026 •

Copy link
Copy Markdown
Member Author

CI on 285d751 (#136300, still running): 18 test files fail so far, 17 under test/aot and test/bundler/compile-aot-size.test.ts. They came with the merge of main, which is red with all 18 of them: #136252 (2b0ace8) has 41 failing files, these among them, and #136293 (d70e177), which was cancelled part of the way, has 15 of the 18.

Most are about mocks under bun test --aot (a-mock-of-a-built-in-module-…, an-export-that-only-a-mock-has, mock-module-of-data-files, …). test/aot/engine/native-6.test.ts is a native test of testaot, property-lookup/absence-filter-names-what-stops-stores, which stops at ASSERTION FAILED: ofBelow && ofBelow->letsStoresPass(*number) (AOTPropertyLookupTests.cpp(1511)), with the same assertion on main.

Before the merge, the only failure on this branch was three-layers-agree.test.ts (#136243). Nothing in this PR touches these tests or what they test. I have found no fix to port, on main (up to 0903892) or among the open PRs. When main is green again I will merge it.


Generated by Claude Code

… repeat offset leads into the dictionary

With a dictionary that is searched in place, `skip_matchless_in_place` returned
at once when one of the three repeat offsets led before the input. After a match
in the dictionary that is so until three other offsets have come, or until the
position is past the offset: 86% of the calls, in runs with random parameters.

Now there is `skip_matchless_far_reps`, the same loop with such an offset looked
up in the dictionary. It runs up to the position from which all three offsets
lead into the input, where the loop that was there takes over.

What it looks at is `far_rep`, the part of `rep_len` that finds where the offset
leads and compares the first 3 or 4 bytes. It is inlined in the loop: with a
call of `rep_len`, which also counts, noise with pieces of the dictionary takes
19 to 28% longer at level 13.

Before the loop, `skip_matchless_in_place` looks at the last offset alone. In a
copy of the dictionary in which single bytes differ, the position after such a
byte goes on at that offset, and all that the loop does there is done again by
`get_all_matches`: without this look, such copies take 3 to 8% longer than
before at level 13.

Time relative to before, means of 16 orders of the functions in the binary for
each, a dictionary of 64 KB. At level 13, inputs of 1, 4, 16 and 32 KB:
- skewed bytes 0.96, 0.91, 0.84, 0.57; the last was at 1.45 of C and is at 0.83
- base64 0.97, 0.93, 0.77, 0.72; the last was at 1.12 of C and is at 0.81
- noise 0.98, 0.96, 0.92, 0.86
These three kinds, wherever the dictionary is searched in place (54 rows, levels
13 to 19, a dictionary of 30 KB too): 0.57 to 1.00 of before. They were at 0.78
to 1.45 of C and are at 0.75 to 0.88.

The review of a first form of this change measured what the 11 kinds of data so
far do not have, so there are 11 more:
- Noise of 6 or 8 bits with 16 bytes of the dictionary every 40 or 200 bytes (48
  rows): 0.53 to 0.79 of before. They were at 1.25 to 1.68 of C, the worst rows
  there were, and are at 0.88 to 1.00.
- Pieces of the dictionary with one byte altered, at 2 or 4 distances in turn,
  among noise of 5 bits (24 rows): 0.91 to 0.99. 1.14 to 1.29 of C before, 1.07
  to 1.21 now.
- The dictionary itself with one byte in 5 altered (12 rows): 0.92 to 0.99.
- The same with one byte in 8, 16, 32 or 100 (48 rows): 0.99 to 1.017, mean
  1.004. 15 of these rows take 0.5 to 1.7% longer, at 3 to 7 standard errors.
  That is the look at the last offset, which is for nothing there.

The 8 other kinds of the first 11, by level: 1.0001, 1.0012, 1.0009, 1.0007,
1.0000. Four of their 208 rows are more than 3 standard errors slower: hex
digits of 16 and 32 KB at level 15 (0.7 and 0.4%), 16 KB of records of 1 KB at
level 13 and 1 KB of JSON at level 15 (0.3%).

`rep_len` is other instructions than before, for all who call it. A dictionary
whose tables are copied (66 rows of 64 and 256 KB, and the 44 of the new kinds):
means of 0.999 to 1.001, no row of the 66 beyond 3 standard errors, two of the
44 (one byte in 8 altered, 32 KB, levels 16 and 19: 1.2%, where a second run
over 8 of the orders has 0.993). No dictionary (75 rows of 16 KB): 1.001 to
1.002, none beyond 3.

Nothing else changes: `skip_matchless_in_place` and `rep_len` are other
instructions, `skip_matchless_far_reps` is new, and the other 383 functions of
the crate are the same as before, `get_all_matches`, `skip_matchless`,
`search_dms` and the parsers among them. It is 4.3 KB more code. The output does
not change.

What else was tried, 8 orders each:
- `far_rep` for the candidates of `get_all_matches`, which calls `rep_len` for
  every offset that leads before the input: the function comes out different,
  and hex digits take 14 to 19% longer, text 5%.
- The offsets before the root of the dictionary's tree in the new loop: copies
  with altered bytes 0 to 2% faster, noise 2 to 4% slower.
- No loop after a single literal, and no look at the last offset: free where one
  byte is altered, but two in a row are common with one in 8 (2.4% at level 15),
  and noise with pieces every 40 bytes takes 3% longer. Both rules: the loop
  that was there comes out different, and noise of 1 KB gains nothing.
- The look at the last offset in `skip_matchless_far_reps` (1 to 3% on the
  copies) or in a function of its own (the old loop 2% slower on noise).
- `rep_len` as it was and a second function for the loop: no faster.
- `far_rep` with a closure for what to do with the place: the closure is not
  inlined, and most rows with a dictionary take 1 to 5% longer.
- One condition, `in_place != self.dms.is_some()`, for two at the start of the
  loop: noise and base64 of 1 KB 3 to 4.5% slower.
- Whether the trees are up to date before the look at the last offset, which is
  the cheaper question: the old loop comes out different, noise, base64 and
  skewed bytes 1 to 3% slower, nothing faster.
- The new loop for an older segment of the window as well, which is where a
  dictionary is whose tables are copied: noise with pieces of it, 16 KB at level
  16, goes from 1.66 of C to 1.00. That changes `skip_matchless`, which all data
  without a dictionary goes through, so it is for a change of its own.

The test in `zstd.test.ts` compares with what Bun 1.4.4 writes, as all of them
there, so it passes before this change too. Of 24 faults put into the new code,
runs against the C library with random parameters see 16, and each of these
changes one of the 8 digests of the test. Five of the others only cost time (the
new loop not entered, left one position early, or entered without the look at
the last offset, and the like). Three are bounds that no offset gets to, which
were in `rep_len` before.

The generator of the test is one that 4 other places of that `describe` had a
copy of. They share it now.
Comment thread scripts/verify-baseline.ts Resolved
… bool

`skip_matchless_generic(.., true, false)` does not say which is which, and
mordant's `bare_bool_args` refuses it. `far_reps` is `Reps::Near` or `Reps::Far`
now. All 386 functions of the crate are the same instructions as before.
8f7a87e gave the emulated run of simd-baseline.test.ts 90 s per test, because
its first JPEG test took 2 to 5.7 s under Intel SDE against a default of 5 s.
main has 15 s for the same reason since #47070, which is enough. The file is
main's again.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

… of the window

A dictionary whose tables are copied (inputs of more than 32 KB at levels 13 to
15, of more than 8 KB from level 16 on) is an older segment of the window, as is
what a stream has before the place where its buffer starts again, and what a job
of a worker has of the job before. After a match there, `skip_matchless` returned
at once, as `skip_matchless_in_place` did until 666b766. On noise with pieces
of the dictionary these levels took 1.25 to 1.71 times the time of C.

Now there is `skip_matchless_older`: the look at the last offset, the loop of
666b766 with the offsets looked up in the older segment
(`skip_matchless_far_reps_older`), and then the loop for offsets that are near.
What the two functions for before the current segment share is
`skip_matchless_far_then_near`. `skip_matchless_far_reps` is
`skip_matchless_far_reps_in_place` now.

Where a match can be 3 bytes, the loop also left at every candidate from the
table for 3 bytes that is in the older segment, without a look at it. In a stream
that is longer than its window, that is how nearly all calls ended: of 545,000 on
3 MB of noise at level 19, 537,000, and 4,200 of those candidates were a match.
`older_starts_alike` compares them now. That is in the two loops for an older
segment, of which the one for near offsets is `skip_matchless_near_reps_older`.
With a longer minimum that loop would be `skip_matchless` again, so it is
`skip_matchless` that is called.

The parsers that call all this are copies again, `compress_block_opt0_older` and
`compress_block_opt2_older`, which `compress_block_opt` takes for
`DictMode::ExtDict`. What there is before the current segment is an enum now,
`Before`, where the parsers and the loop had a bool.

Time relative to before, means of 16 orders of the functions in the binary for
each. A dictionary of 64 KB whose tables are copied:
- skewed bytes: 64 KB at level 13 0.66 (1.33 of C, now 0.88), 256 KB 0.79; 16 to
  64 KB at levels 16 to 19 0.81 to 0.89 (1.01 to 1.08 of C, now 0.88 to 0.90)
- noise: 64 and 256 KB at level 13 0.78 and 0.82; levels 16 to 19 0.96 to 1.002
- base64: 64 KB at level 13 0.79 (1.09 of C, now 0.86); levels 16 to 19 0.94 to
  1.008
- noise with 16 bytes of the dictionary every 40 or 200 bytes (12 rows): 0.60 to
  0.86. They were at 1.25 to 1.71 of C and are at 1.00 to 1.08.
- pieces of the dictionary with a byte altered among noise (8 rows): 0.93 to
  1.006; the dictionary with one byte in 5 altered: 0.93 to 0.98; with one in 8
  to 100 (16 rows): 0.99 to 1.002
- the 8 other kinds, by level (13, 16, 17, 19): 0.9986, 1.0001, 1.0004, 1.0004
Of these 139 rows, 69 were more than 3% behind C and 55 are. Their mean relative
to C goes from 1.040 to 0.983. Five take longer by more than 3 standard errors:
hex digits of 32 KB at level 17 and of 16 KB at level 19 (0.6 and 0.7%), 64 KB of
the pieces with an altered byte at level 16 (0.6%), 64 KB of records of 100 bytes
at level 13 (0.5%), 16 KB of text at level 16 (0.2%).

Streams were not timed so far: 1 MB in pieces of 64 KB, no size given, windows of
16, 64 and 256 KB, levels 16 to 19, 17 kinds of data (204 rows). By window: 0.92,
0.95, 0.985 of before. By level: 0.94, 0.95, 0.96, 0.96. By kind, mean and best
row: skewed bytes 0.84 and 0.66, base64 0.86 and 0.69, noise 0.90 and 0.74, noise
of 5 bits 0.94 and 0.65, noise with pieces from up to 12,000 bytes back 0.88 to
0.94 and 0.76, the same with a byte altered 0.98 and 0.91, hex digits 0.99 and
0.91, the 7 others 0.999 to 1.001. The mean relative to C goes from 0.84 to 0.80.
These rows are less certain than the others. At level 13, which without a size is
not an optimal parser and runs the same instructions as before, the 51 rows are
at 0.97 to 1.04, one of them 3.5 standard errors slower (in the run for another
form of this change, one by 7% at 4.6). At levels 16 to 19 four rows are more
than 3 slower, by 0.6 to 3.6%. Each was run three more times with more rounds:
0.992 to 1.004. Jobs of workers were not timed.

Without a dictionary nothing of the optimal parser changes: `skip_matchless`,
`get_all_matches`, `compress_block_opt0` and `compress_block_opt2` are the same
instructions, as are the functions for a dictionary that is searched in place.
`compress_block`, which all levels go through, has 21 instructions more, for the
choice of a parser. 175 rows without a dictionary (25 kinds, 1 KB at levels 1, 5,
9, 13 and 16, 16 KB at levels 13 and 16): means of 0.9991 to 1.0000 by level, no
row slower by more than 3 standard errors. It is 22 KB more code. The output does
not change.

What else was tried, 8 or 16 orders each:
- No copies of the parsers, `skip_matchless` itself with the new part in front:
  its loop comes out different, and without a dictionary noise, base64 and skewed
  bytes take 2 to 6% longer, all rows 1%.
- The candidates of 3 bytes as before, and `skip_matchless` for all near offsets:
  what the review of this change found. In streams at level 18, noise and the
  like take 1.0 to 1.8% longer than before, at 3 to 5 standard errors, since
  every one of those calls goes through one function more.
- A loop of its own for near offsets whatever the minimum, inlined in
  `skip_matchless_older`: noise in a stream takes 3.5 to 8% longer at level 16.
  Out of line: 64 KB of records of 1 KB 1.4% at level 13.
- That loop for a minimum of 3 only, but inlined: 256 KB of noise with a copied
  dictionary 1.5% longer, at 12 standard errors.
- `older_starts_alike` not inlined: the same rows 0.9 to 1.2%, and streams gain
  less at levels 18 and 19.
- `index_overlap_check` for the same condition in it: the loop comes out with 697
  instructions for 663. Not timed.
- `skip_matchless_older` inlined in the two parsers: nothing faster, HTML, JSON
  and records 0.3 to 0.6% slower.
- The parsers for a dictionary that is searched in place for an older segment
  too, which is one line and no more code (the review, one order): most rows up
  to 6% slower than this, two 11 to 12%, and noise 2 to 7% slower than before.

The test in `zstd.test.ts` has one more input, 33,000 bytes, for which both of
its levels copy the tables of the dictionary, and there is a second test: 63,000
bytes without a dictionary in windows of 8 and 16 KB. They compare with what Bun
1.4.4 writes, as all of them there, so they pass before this change too. Of 19 faults put into the
new code, runs against the C library with random parameters see 11, and each of
these changes a digest of the new inputs. Three of them, in `older_starts_alike`,
are seen only by runs that are aimed at this: optimal parsers, windows of 1 to
256 KB, pieces from far back. The tree mostly finds the same match of 3 bytes.
The other eight cannot change the output: six choose another of the loops or
parsers, leave the loop where it could go on, or hand on a dictionary where there
is none, and two are at the last 3 bytes of the older segment, which never get
into the table for 3 bytes.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread src/runtime/node/zlib/NativeZstd.rs Resolved
…es at a time

At levels 13 to 19, HTML and records were what took longest relative to C: with a
dictionary whose tables are copied 1.05, 1.07 and 1.29 times its time in the mean
(HTML, records of 100 bytes and of 1 KB), up to 1.35. After a long match, all its
positions are added to the trees, and that is where the time goes: 96% of it for
64 KB of records of 100 bytes at level 13 with such a dictionary, in
`update_tree_in_part` and what it calls. A step down a tree takes as many
instructions there as in `ZSTD_insertBt1`, 86. The comparison of the two
positions takes 59 where C takes 30, and a step 31 cycles where C takes 23.

The comparison took 16 bytes at once, as a vector. Now it takes them as two words
of 8, each with a branch (`Common::within_words`):
- in `insert_bt1`, which is what `update_tree_in_part` calls for each position.
  Where matches are looked for, `tree_matches`, it stays as it was.
- in `TreeInput::compare_older`, for all who get to a match in the older segment.
  It also has two conditions where it had five: `TreeInput::new` cuts the buffer
  of the older segment where the segment ends. The field is private now, so that
  there is no other way to make one.

What `Near::compare_chunks` does with the first 16 bytes is an enum now, `First`,
where it was a bool, and `bt_walk` takes it where it had a bool for
`Near::compare_head`, which goes with `First::Alike`.

Time relative to before, means of 16 orders of the functions in the binary for
each, 1,206 rows: 283 were more than 3% behind C, 218 are. 444 take less time by
more than 3 standard errors, 289 by more than 1.5%. By kind, all rows of levels
13 to 19 (mean, best row, how many are more than 3% behind C):
- HTML (61 rows) 0.92, 0.79, 36 before and 11 now
- records of 1 KB (61) 0.93, 0.83, 43 and 31
- records of 100 bytes (61) 0.95, 0.82, 35 and 25
- JSON (61) 0.98, 0.95, 5 and none
By what there is before the input (rows, how many behind C, mean relative to C):
- a dictionary of 64 KB whose tables are copied, 11 kinds (99): 30 and 21, 0.970
  and 0.926. HTML 0.89 of before (1.05 of C, now 0.93), records of 1 KB 0.87 (1.29,
  now 1.12), of 100 bytes 0.91 (1.07, now 0.97).
- the same, pieces of the dictionary and copies with bytes altered (40): 24 and
  22, 1.015 and 1.006
- a dictionary of 64 or 30 KB that is searched in place, 11 kinds (286): 51 and
  37, 0.89 and 0.87; the pieces and copies (176): 75 and 72, 0.976 and 0.972
- nothing, 1 and 16 KB (175): 24 and 22, 0.928 and 0.922; 128 and 512 KB (175, not
  timed so far): 35 and 26, 0.914 and 0.902
- a stream that is longer than its window (255): 44 and 18, 0.820 and 0.801
Six rows take longer by more than 3 standard errors. Each was run three more
times with more rounds. 128 KB at level 13 of words drawn from 60 at random:
0.9%, then 0.9 to 1.2% (it is at 0.91 of C). 128 KB of text at level 13 0.4%,
then 0.2 to 0.4%. 64 KB of pieces of a copied dictionary with a byte altered at
level 13 0.5%, then 0.3 to 0.5%. 4 KB of text at level 16 with a dictionary of 30
KB 0.3%, then 0.1 to 0.3%. Two of streams, 2.1 and 1.6%, then 0.986 to 1.018.

`get_all_matches`, the `skip_matchless` functions and the six parsers are the
same instructions. Other ones: `compare_rest`, which has `compare_older`, the
three `update_tree_in_part`, `Trees::new`, and the six `compress_block_lazy_part`
of the binary tree finder of levels 13 to 15, which have `TreeInput::new`. 512 KB at levels 13 and 15, where that finder is
used: 0.998 in the mean, 0.967 to 1.017, none beyond 3 standard errors; streams at
level 13 0.989. It is 223 bytes more code. The output does not change.

What else was tried, 8 or 16 orders each:
- The two words where matches are looked for as well: HTML and records as here,
  but 35 of 314 rows take 0.3 to 1.8% longer, at up to 18 standard errors: text,
  logs, JSON, floats, and the copies of a dictionary with bytes altered. There
  the first difference is often in the second word.
- The same and `compare_older` as it was: with a copied dictionary, 41 of 139 rows
  take 0.5 to 1.6% longer, all but two of them from level 16 on.
- The two words only after 16 bytes in common, else the vector: half the gain,
  and noise, base64 and skewed bytes take 1 to 5% longer, in streams up to 13%.
- One branch for whether the 16 bytes differ, and the word chosen with
  `select_unpredictable`: HTML takes 2 to 3% longer than before.
- The same with 128 bits (`u128`): no gain (one order).
- `le64_at` for the words: all four are read before the first is compared, and
  `compare_rest` has 104 lines of assembly for 84. Not timed.
- A loop over 16 bytes at a time in `compare_rest`: it has registers to save
  then, and records take 2.5% longer (one order).

Of 12 faults put into the new code, runs against the C library with random
parameters see 9. The unit tests see these 9, and a tenth that only costs time.
8 of the 9 change a digest of the tests in `zstd.test.ts` for literals while a
repeat offset leads before the current segment. The ninth is the buffer of the
older segment not being cut. The last two only choose the other comparison.
`the_head_of_a_position_is_compared_as_all_of_it` and
`a_match_that_leaves_the_older_segment_goes_on_in_the_current_one` go over the
three values of `First`. All times are from x86-64.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

… an older segment

At levels 13 to 19, what took longest relative to C was an input with something
before its current segment: a dictionary that is searched in place, or an older
segment, which is what a dictionary whose tables are copied is, or the part of
a stream before the place where its buffer starts again. Two things cost there.

A repeat offset that leads before the current segment was a candidate without a
look at what is there, so `get_all_matches` called `rep_len` for it, up to three
times a position. `far_rep`, which the loops over literals use, tells whether it
starts like the position.

A step down a tree that gets to the older segment first failed in
`Near::compare_chunks` and then called `compare_rest`. For 32 KB of records of 100
bytes at level 16 with a copied dictionary of 64 KB, 86% of the time is
`get_all_matches` and the `compare_rest` that its walk calls, which alone is 39%.

`get_all_matches` did both for all modes, and a change to it moves the times of
all of them. Now there is a copy for each `Before`, as there is of the loops over
literals and of the parsers: `get_all_matches_in_place` and
`get_all_matches_older`. `get_all_matches` is the same instructions as before.
- Both copies ask `starts_like_rep`, which has `far_rep`, for the three repeat
  offsets.
- `bt_walk` takes an `Older`. `Called` is what it did. `TriedLast` is the same
  without the call. `TriedFirst` looks at whether the position is in the older
  segment before anything else, which takes a cycle for one that is not.
- `get_all_matches_older` looks for matches with `TriedFirst`, and adds the
  positions that a match skipped with `TriedLast`, but for a long run in a
  current segment of more than 64 KB. That is one more `update_tree_in_part`.
- All else stays `Called`: the loops over literals, and the binary tree finder of
  levels 13 to 15, which has not been tried with the others.

Time relative to before, means of 16 orders of the functions in the binary for
each, 1,031 rows: 197 were more than 3% behind C, 116 are. 499 take less time by
more than 3 standard errors, 185 by more than 5%, 64 by more than 10%. By what
there is before the input (rows, how many behind C, mean relative to C):
- a dictionary of 64 KB whose tables are copied, 11 kinds, 16 to 256 KB (99): 24
  and 7, 0.928 and 0.880. Records of 100 bytes 0.81 of before (0.97 of C, now
  0.80), of 1 KB 0.88 (1.14, now 1.01), HTML 0.94 (0.93, now 0.87), JSON 0.94,
  hex digits 0.96, text 0.97.
- the same, pieces of the dictionary and copies with bytes altered (40): 23 and
  15, 1.006 and 0.933
- a dictionary of 64 or 30 KB, 1 to 32 KB, 11 kinds (286): where it is searched in
  place (198) 17 and 13, 0.862 and 0.851; where it is copied (88) 22 and 2, 0.906
  and 0.842
- the same, the pieces and copies (176): in place (132) 50 and 35, 0.982 and 0.940;
  copied (44) 23 and 12, 0.981 and 0.901
- a stream that is longer than its window (255): 16 and 10, 0.809 and 0.800
- nothing, 1 and 16 KB (175), the same instructions: 22 and 22, 0.922 and 0.923
Three rows take longer by more than 3 standard errors. Each was run three more
times with more rounds. 64 KB with a period of 60 at level 16 with a copied
dictionary 0.2%, then 0.2 to 0.4% (it is at 0.88 of C). Two of streams, 3.7 and
1.4%, then 0.976 to 1.003.

54 more rows after a review, which has 2% more instructions where there is a
copied dictionary but the trees lead to the current segment: six kinds of other
data, 32 to 256 KB, levels 13, 16 and 19. Records of 100, 300 and 1,000 bytes of
which each is the one before with bytes altered: 0.993 to 1.006, one row beyond 3
standard errors, 0.4%. A period of 60: 0.998 to 1.005, two rows, 0.4 and 0.5%. JSON
0.92 to 1.00, logs 0.99 to 1.00. All of these rows but three are ahead of C.

The same instructions: `get_all_matches`, `compare_rest`, the loops over literals,
the two parsers without a mode, the binary tree finder, the three
`update_tree_in_part` there were. The four parsers with a mode call other
functions. It is 11,958 bytes more code. The output does not change.

What else was tried, 8 or 16 orders each unless it says one:
- `TriedFirst` for all walks where there is an older segment: more for records (a
  copied dictionary: 3 rows behind C, 0.873 in the mean; 256 KB of records of 1 KB
  0.85 where this has 1.00), but 24 rows take 0.5 to 2.5% longer, one 3.5%: a period
  of 60 at all sizes, 256 KB of noise, HTML, JSON and logs, streams of records with
  a window of 256 KB.
- `TriedLast` for all of them (one order): two thirds of that gain, and 256 KB of
  noise takes 4% longer.
- With `TriedFirst`, no call either for what `compare_chunks` leaves of a match in
  the current segment: a period of 60 takes 0.3 to 1.6% longer from level 16 on, 7
  rows.
- Only the copies, with the call: records of 100 bytes with a copied dictionary
  take 1.1 to 3.8% longer from level 16 on, 15 rows. The walk of the copy came out
  with 3% more instructions.
- The dictionary handed to `starts_like_rep` by the caller: hex digits take 0.6 to
  2.6% longer with a dictionary in place.
- The far case in the closure that `rep_offsets.map` takes: the closure is not
  inlined any more, and all data takes 10 to 18% longer (one order).
- `Older` as a const parameter of `bt_walk`, so that `tree_matches` has two calls:
  other instructions for `get_all_matches` and the loops in place. Not timed.
- A function for the comparison without the call, which returns a `Result`: 1 to
  2% more instructions (one order).
- Two loops, since the indices only go down on the way: one for the current
  segment, then one for the older. 2% more instructions where few walks get
  there, and no less time (one order). The second loop out of line: what the
  closure captures is in memory then, 3 to 10% more instructions (one order).
- `cold_path` before the code for the older segment: nothing, or slower (one
  order).

Of 15 faults put into the new code, runs against the C library with random
parameters see 9. The other six cannot change the output: five only choose
another way to the same comparison, one is about an offset that `far_rep` refuses.
All 9 change a digest of the tests in `zstd.test.ts` for literals while a repeat
offset leads before the current segment.
`a_way_down_a_tree_is_the_same_whatever_is_done_with_the_older_segment` is new:
it fills a tree over two segments with each `Older` and each `First`, and compares
each step with the bytes. It sees the two faults in `bt_walk`. It is not run
under Miri, where it takes minutes.

`a_match_that_leaves_the_older_segment_goes_on_in_the_current_one` does the
comparison without the call too. All times are from x86-64.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

…the one before

At levels 13 to 19, records were still behind C where nothing comes before the
input: 16 KB of records of 100 bytes took 1.13 and 1.15 times its time at levels 13
and 16, with fewer instructions (8.57 G for 9.03 G) and fewer of them a cycle (4.4
for 5.25). 83% of the time is `update_tree_in_part`, which adds the positions that
a match skipped. Five times as many cycles as in C go by with a scheduler of
the integer units full (0.27 G for 0.05 G of `int_sch0_token_stall`), and 2.7 times
as many places to dispatch to stay empty for want of room behind.

`insert_bt1` returns how many positions to move forward, which comes from the
longest match that the walk met and from how far a match reached. That is a
chain from one position to the next: the last comparison, the length, two
conditional moves, the index, the 8 bytes there, the hash, the cell of the table,
the first comparison. C has the same chain. The answer is 1 but for matches that
overlap the position or are longer than 384 bytes.

Now `update_tree_in_part` has a branch for 1, so the processor goes on with the
next position while the walk of this one ends. The compiler folds such a branch
into the addition that it stands for, so the other arm goes through
`core::hint::black_box`.

Also, `update_tree` hands `OLDER` to the instance without hints too, which is for
long runs in a current segment of more than 64 KB where ways down are short. So
far only short runs had it. That is one more `update_tree_in_part`. The one with
hints at the bytes alone stays as it is: without the call, 256 KB of records of
1 KB at level 16 take 15% less time, but HTML 2.5% and a period of 60 1.3% more
(one order).

Time relative to before, means of 16 orders of the functions in the binary for
each, 1,206 rows: 144 were more than 3% behind C, 97 are. 332 take less time by
more than 3 standard errors, 68 by more than 5%, 18 by more than 10%. By kind, all
rows of levels 13 to 19 but those with pieces of a dictionary (mean, best row, how
many are more than 3% behind C):
- records of 100 bytes (63 rows) 0.955, 0.80, 21 before and 4 now
- records of 1 KB (63) 0.960, 0.85, 19 and 3
- words drawn from 2,000 at random (63) 0.976, 0.93; HTML (63) 0.983, 0.92, 3 and
  none
- CSV (13) 0.983, 0.95; a period of 60 (63) 0.985, 0.94
By what there is before the input (rows, how many behind C, mean relative to C):
- nothing, 1 and 16 KB (175): 22 and 15, 0.921 and 0.912; 128 and 512 KB (175): 21
  and 17, 0.894 and 0.889
- a dictionary of 64 or 30 KB, 1 to 32 KB, 11 kinds (286): where it is searched in
  place (198) 13 and none, 0.850 and 0.826; where it is copied (88) 2 and 1, 0.837
  and 0.834
- the same, pieces of the dictionary and copies with bytes altered (176): 48 and
  44, 0.930 and 0.922
- a dictionary of 64 KB whose tables are copied, 16 to 256 KB (99): 8 and 3, 0.880
  and 0.869. 256 KB of records of 100 bytes at level 13 0.85 of before (1.22 of C, now
  1.04), of 1 KB 0.90 at level 13 (1.12, now 1.00) and 0.85 at level 19 (1.12, now 0.95).
- the same, the pieces and copies (40): 15 and 9, 0.931 and 0.920
- a stream that is longer than its window (255): 15 and 8, 0.805 and 0.798
Four rows take longer by more than 3 standard errors, by 0.7 to 1.4%. Each was run
three more times with more rounds: 0.995 to 1.005, none beyond 3 standard errors.

Other instructions: the four `update_tree_in_part` there were, and nothing else. It
is 1,954 bytes more code. The output does not change.

What else was tried:
- `cold_path` where `insert_bt1` notes how far a match reaches and where it asks
  for more than 384 bytes, so that both stay branches and the answer is a
  constant on the usual way (16 orders): as much for records, but the loop of the
  walk comes out with 5% more instructions. With a copied dictionary from level 16
  on HTML takes 2.2 to 4.7% longer and records of 1 KB 3.1 to 3.7%; 50 rows in all.
- The other arm as a function that is cold and not inlined (one order): 3% more
  for records, but 3% more instructions where walks are long, and HTML with a
  copied dictionary takes 2% longer at level 16.
- The branch with `cold_path` alone: folded, the same instructions as before.
- `Descent::step` reads the one child that it takes, 4 bytes, where `bt_children`
  reads both as 8: a third fewer loads that wait for a store of 4 bytes, 3% fewer
  instructions, 0.5 to 1.8% less time for records and HTML, 1% more for noise and
  JSON (one order). Left for later: it changes all walks.
- Hints at the older segment for the children that are there: 5 to 13% more
  instructions, slower in all rows (one order).
- `rep_len` inlined in the two copies of `get_all_matches`: 2 to 3% more
  instructions and time (one order).

Of 6 faults put into the new code, runs against the C library with random
parameters see 5. The sixth only chooses the call for the older segment. The test
"at level %i" in `zstd.test.ts` sees the 5: with its data, in one call as there, all
change what level 16 makes, and 4 what levels 17 to 19 make.

`black_box` is documented as best effort. If a compiler folds the branch one day,
the output stays the same and no test fails: only the time is lost. The branch is
there in the assembly for x86-64 and for aarch64.

All times are from x86-64.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

…, and ahead where the history is long

A survey of the port against C without a dictionary, 25 kinds of data from 1 to
512 KB: of 1,200 rows of compression at levels 1 to 12, 116 are more than 3% behind,
of 682 of decompression into a buffer that is there 139, of 700 into a new one 201.
So this is about the decoder. What took longest relative to C was HTML, 1.09 times
its time in the mean and up to 1.24, and records.

Where the table of match lengths says that matches are long, and for blocks of up
to 32 sequences, `Executor::run` takes one sequence at a time: it decodes it and
executes it. Two things were wrong with that loop.

It worked on `seq_state`, whose address `decode_batch` takes further down in the
function. So all of it was in memory: the bits, how many are consumed, the three
states, the last offsets. For 128 KB of HTML of level 3, 17% of the samples are on
one reload of the bits, and there are 0.53 G loads that get their value from a store
that is not done, for 0.42 G in C. Now the loop of `run::<false>` has a copy that
nothing takes the address of, as it has of the executor (`fork`): 0.28 G such
loads, 8% fewer cycles.

With `LONG`, which is for a history of more than 16 MB with far offsets, the same
loop ran, and it asks for no match ahead of time. That had not been timed. 48 MB of
which the second half is pieces of the first taken at random, with a window of 64
MB, took 1.18 to 1.64 times the time of C. Now `run_long_matches_ahead` does what
`ZSTD_decompressSequencesLong_body` does: a sequence is executed once 8 more are
decoded and their matches asked for, up to the end of the block.

`LONG` is also for the first block after a dictionary that counts as cold, which is
each one in a new context. Such a dictionary has just been copied, so it is in a
cache. There the loop stays as it was, without the copy either: 128 KB of HTML,
of records of 100 bytes and of copies of the dictionary with bytes altered take 5
to 7% longer with the copy, and 14 to 16% with 8 ahead (one order). So the new
function is for a history of more than 16 MB alone.

Time relative to before, means of 16 orders of the functions in the binary for
each. Rows, how many are more than 3% behind C, mean relative to C, how many take
less time by more than 3 standard errors:
- into a buffer that is there (682): 123 and 107, 0.875 and 0.869, 107, of which 33 by
  more than 5%. HTML 0.950 of before (1.081 of C, now 1.027), records of 100 bytes
  0.971 (1.012, now 0.982), a period of 60 0.972, words drawn from 200 at random 0.983,
  records of 1 KB 0.985.
- into a new buffer (700): 191 and 190, 0.908 and 0.903, 105. HTML 0.953 (1.137, now
  1.083), a period of 60 0.973, records of 100 bytes 0.977.
- frames of 48 MB with a window of 64 MB (19, not timed so far): 14 and 14, 1.206 and
  1.048, 14. At level 3 with long distance matching and at level 9, pieces from far
  back of 100 bytes 0.794 and 0.644 (1.39 and 1.64 of C, now 1.11 and 1.06); of 300
  bytes 0.794 and 0.724 (1.35 and 1.52, now 1.07 and 1.10); of 1 KB 0.926 and 0.893
  (1.18, now 1.09 and 1.05); of 70 to 330 bytes 0.781 and 0.718; of 8 to 2,000 bytes
  0.945 and 0.926; of 20 to 90 bytes 0.971 and 0.868; of 40 bytes at level 9 0.680
  (1.57, now 1.07). Of 20 bytes, which go in batches, 0.998.
- a new context, a dictionary of 64 KB is loaded, a frame of 1 to 128 KB is
  decompressed, 26 kinds, levels 1, 3, 9 and 19 (356, not timed so far): 80 and 79, 0.928
  and 0.929, 2.
Eleven rows take longer by more than 3 standard errors. All but the first and the
last were run three more times with more rounds.
- 17 MB of source code three times over, with a byte altered every 200 bytes in the
  mean, at level 3 with long distance matching: 3.7% (0.897 of C, now 0.929). Its
  matches are far but one after the other, which the processor sees by itself.
  There the loop without anything ahead took a quarter less time than C's, and
  this one takes what C's takes. Two thirds of it is the delay, not the hints (one
  order). Nothing cheap tells such a block from one with pieces taken at random:
  long offsets have the same share of the table, 125 of 256.
- 512 KB with a period of 1 KB, the frame of levels 9 and 13, into either buffer, four
  rows: 1.9 to 2.2%, then 1.3 to 2.6% (0.91 to 0.94 of C, and 1.01 to 1.02). A block
  is two sequences, and takes 181 more instructions: what is in registers is saved
  around the calls that copy.
- 512 KB with a period of 100 KB of level 3 into a new buffer 1.2%, then 0.6 to 1.2%.
  1 KB of skewed bytes of level 9 into a new buffer 2.7%, then 1.7 to 2.4%, within 3
  standard errors (0.98 of C).
- 16 KB of a binary of level 5 0.7%, then 0.998 to 1.001. With a dictionary, 128 KB of
  HTML of level 1 0.6% and 32 KB of records of 500 bytes of level 9 0.8%, then 0.0 to
  0.4%, within 3 standard errors.
- 16 KB of integers of level 19 into a new buffer 0.1%.

Other instructions: `run::<false>` and `run::<true>`, which has the test for the new
function. It is 5,736 bytes more code. The output and the errors do not change:
the sequences are decoded in the same order, and the same ones are executed before
the end of the stream is looked at.

What else was tried, one order unless it says more:
- The new loop in `run` itself (16 orders): as much for far matches, or 2% more.
  But `run::<false>` comes out with other instructions, which gain no more, and
  512 KB with a period of 100 KB of level 3 take 2.5% longer.
- The call in `decompress_block`: that has 992 lines of assembly for 921.
- The new function for all blocks with a long history, not only where the table
  says that matches are long: pieces of 20 bytes take 9% longer, of 20 to 90 bytes
  3%. So blocks with the predefined table, which has few long lengths, stay in
  batches whatever their matches are.
- With a long history, batches for long matches too: 1.14 to 1.37 of C. Batches of 16,
  32 or 64 for 128: 1.15 to 1.31.
- The 8 that wait are executed before the last 8 are decoded: 1.04 to 1.18 of C.
  Blocks of such data have 200 to 1,600 sequences.
- 16 ahead: nothing.
- Hints at all of a match, up to 512 or 1,024 bytes: pieces of 1 KB 0.92 to 0.97 of C,
  but those of 300 bytes take 2% longer, and so does the source code.
- No hint for a match with the offset of the one before: nothing.
- The loop as it was for blocks of up to 32 sequences: a third of what HTML gains
  is lost, most of what records of 100 bytes gain, and the period of 1 KB keeps two
  thirds of what it loses.
- `History::prefetch` on top of what takes a `Sequence`, so that nothing is converted
  to and fro: `decode_batch` comes out with other instructions. Not timed.
- Batches for all blocks, with a short history: HTML as here or 2 to 4% less,
  records of 100 bytes 13% more than before.
- Matches of 17 to 64 bytes without a call of `memcpy`: HTML 1 to 2.5% less, records of
  1 KB 2% more. Left for later.
- For a new buffer, where 15 to 19% of the time of records of 1 KB is `memset`: blocks
  with long matches grow the buffer as they go, where now all that a block may
  write is zeroed first: records of 1 KB take 14 to 22% longer, HTML 3%.

Of 13 faults put into the new code, 9 are seen. The state that is not written back
after the loop of `run::<false>`: runs against the C library with random input see
it at once. Eight in the new function: 7 that wait for 8, 7 decoded ahead, no
rotation, rotation the other way, the newest executed for the oldest, the last
sequence not told from the others, the offsets of the start returned, the position
not written back. Random input does not get there, as it takes 16 MB. The test
"long matches from more than 16 MB back" in `zstd.test.ts` is new: its frame, whose
blocks have 592 to 857 sequences, sees all eight, in one call and as a stream, and
so do frames that were timed. Not seen: the end of the stream is not looked at,
which takes a damaged frame; the last sequence is not told from the others among
the first 8, which takes a block of up to 8 sequences; literals that would be in
the output; a history of any length, which only costs time. 642 results of the
debug build are those of Bun 1.4.4: the 19 frames, each also damaged at three places
and cut short, and frames with a dictionary, with it, without it and damaged.

All times are from x86-64.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

Copy link
Copy Markdown
Member Author

Build #136583 (fc13be22b) has one red job that no earlier build of this PR or of main had, because the job itself is new: darwin 27 aarch64 - test-bun (agent darwin-aarch64-27.0-1).

It fails 1 test of 51,008: test/regression/issue/29283.test.ts, "Socket.setTimeout resets on incoming data", reported as "1 failing in the parallel batch". The test gives a node:net client an idle timeout of 300 ms while a server writes every 50 ms, so it fails whenever the process is held up for 300 ms, which is what can happen in a batch that runs beside others. Nothing in it reaches zstd, and this PR touches no socket or timer code. I know of no fix for it elsewhere, and I have no re-run left for this PR.

The rest of the build is as before: the 40 files that main fails too (#136408, #136252), and test/aot/three-layers-agree.test.ts on x64-asan, which passed on its third attempt.


Generated by Claude Code

…onary is indexed faster at levels 5 to 10

When a frame with workers is given up, they are told to stop, and the next frame
waits until they have. A worker looked at that flag before each block, and every
4,096 positions while it adds the prefix of its job to the binary trees. It did not
look while it adds it to rows. 3 workers, level 12, jobs of 1 MB, long distance
matching, which makes the prefix up to 4 MB: with 4.6 MB of noise fed, from the reset
of the session to the end of a next frame of 5 bytes it took 254 to 324 ms, where
the C library, which waits for all jobs to end, takes 200 to 270 ms.

`row_update` now takes 65,536 positions at a time and looks at the flag before each
part, as `update_tree` does: 19 to 35 ms. Level 8 with the longest overlap and 8 MB:
12 to 19 ms, now 0.7 to 2.6 ms.

Not changed:
- Hash chains. Rows take over from a window of 32 KB on, so there the prefix of a job
  is 16 KB at most.
- `fast` and `dfast`, which index every third position. With long distance matching,
  or a window of 128 MB and the longest overlap, and the tables of the level: 0.2 to
  4.6 ms. With a hash table of 2^27 entries as well, a job indexes 128 MB first, which
  a reviewer timed at 1.6 s. To index that in parts takes more care than a loop, as
  what is done near the end of a part depends on where it ends.

To see what that costs, a dictionary is loaded into a context and 1 KB compressed,
which had not been timed: 11 dictionaries of 8 KB to 1 MB, levels 5 to 12, 88 rows, 8
orders of the functions in the binary for each. The port was behind C: 1.08 in the
mean, 69 rows by more than 3%. Two loops were the reason.

- Hash chains (a dictionary of 8 KB at levels 5 to 8: 1.43 to 1.55 of C).
  `insert_and_find_first_index` chose the hash by the least length of a match at each
  position, and checked three indices. It now calls `Chains::insert_all`, which the
  search has for the same thing. `hash_ptr`, `hash_ptr_salted` and `hash5` to `hash8` have
  no caller left and are gone; `hash3` and `hash4`, without a salt now, are in their
  one caller each.
- Rows. `row_update` checked at each position that 8 bytes can be read. It now takes
  the bytes of a part once and goes through windows of 8. It is also a function of
  its own: within `load_dictionary_content`, its loops came out 5 to 10% slower or
  faster with what else is in there.

Time relative to before, and relative to C before and after:
- all 88 rows: 0.856; 1.080, now 0.925. 69 rows more than 3% behind C, now 7. 67 take
  less time by more than 3 standard errors, none takes more.
- by level: 5 to 8 0.80 to 0.81 (1.12 to 1.14, now 0.89 to 0.92); 9 and 10 0.85 (1.08, now
  0.91 and 0.92); 11 and 12, where most of these are binary trees, 0.98.
- by dictionary: 110 KB of JSON 0.79, 64 KB of HTML 0.81, of JSON 0.82, of text 0.85, of
  noise 0.91; 200 KB of HTML 0.82; 8 KB of HTML 0.84; 400 KB of text 0.86, of noise 0.94;
  30 KB of text 0.88; 1 MB of a binary 0.93.
- still more than 3% behind: 64 to 200 KB of HTML and JSON at levels 11 and 12 (1.04 to
  1.12, binary trees), 400 KB of noise at level 12 (1.06), 8 KB of HTML at level 5 (1.06).
These times are from an Intel Xeon of the Skylake family alone. On AMD Zen 4, where
all earlier times are from, this has not been run.

Other instructions: `load_dictionary_content`, and `row_update`, which is new as a
function. It is 172 bytes less code. The output does not change: both loops add
the same positions in the same order.

What else was tried:
- The loop over parts in `load_dictionary_content`, for rows and hash chains (8
  orders): the wait is as short. 57 rows take 4 to 10% less time, only because the
  loops of `row_update` come out otherwise, and the 8 KB dictionary at levels 5 to 8
  takes 13 to 14% longer.
- That with `Chains::insert_all` and `row_update` as a function of its own, reading 8
  bytes at a time as before (8 orders): 0.930 of before, 0.999 of C.

Tests:
- The unit test of a worker that stops now also runs with rows. Without the look at
  the flag it fails. Not tested: that the flag is looked at again between parts,
  which takes a race.
- "after a dictionary that is indexed in several parts" in `zstd.test.ts` is new: 200,000
  bytes of 3 bits each, and pieces from all over it, from 9 positions before to 2
  after where each part ends, and from its end; hash chains with a least length of
  3 to 7, and rows of levels 5, 8 and 10, with values from Bun 1.4.4. Like the rest of
  that block it passes before the change too.
- Of 12 faults put into the two loops, runs against the C library with random input
  see all 12, the new test 9: only the first part is indexed; the first or the last
  position of a part is left out; one or 7 windows too few; the bytes of the next
  position; indices one too high; a hash of 4 bytes always, or of 6 at most. It does
  not see: where parts meet is indexed twice; one position more or less at the end
  of the hash chains.
- 342 results of the debug build are those of Bun 1.4.4: dictionaries of 65,543 to
  400,000 bytes at levels 5 to 12, with hash chains too, and 4 MB with 3 workers,
  with and without long distance matching and overlap.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

…old instructions

The change of #47613, as it is there at eb43446, so that it is nothing once main
has it.

On macOS arm64 the test fails for some layouts of the executable, and for that of
1e78952 (Buildkite #136744, all four attempts): `placesThatCall` finds a place that
calls `execve` in an executable without what starts programs. Its bytes are
0f0f0f0f0f0f0f0f0f10101010..., a table in `__TEXT,__const`: `readMachO` gave whole
segments to the scan, which reads every word of an executable segment as an
instruction. Now it lists sections, and only one that says that it holds
instructions is read as such.

The two tests "a Mach-O file" pass here and fail with the helper as it was. The rest
of the file is skipped in a build with AddressSanitizer, so that it does what is
expected of it on macOS is left to CI.

Copy link
Copy Markdown
Member Author

Build #136744 (1e789521e): 40 of the failing files are the ones that main fails too (#136408). Two things are new.

test/bundler/compile-trim-capabilities.test.ts on macOS aarch64, all four attempts. It finds a place that calls execve in the trimmed executable, and the bytes there are 0f0f0f0f0f0f0f0f0f10101010…: a table in __TEXT,__const, read as an instruction. That is the false alarm that #47613 describes and repairs, and whether it shows depends on the layout of the executable, which 1e789521e moved. I have put the change of #47613 (as of eb43446e8, two test files) into this PR as 98c81e35c, so that it is nothing once main has it. Its two new tests pass here and fail with the helper as it was; the rest of the file is skipped in an ASAN build, so the macOS result is for the next build to show.

A debian 13 x64-asan - test-bun job timed out in the fourth attempt of test/aot/three-layers-agree.test.ts. The first three failed the same way: the --aot run prints all its results and then exits with 134 instead of 0. In the earlier builds of this PR it failed twice and passed the third time. It is not this PR's: builds of other branches show the same on that shard (#136690 at bf41088d8: two failures, then a third attempt; #136600 at 84ac132d1: three failures, then a fourth). main's own builds run no x64-asan tests, so they cannot show it. I know of no fix, and I have no re-run left for this PR.

test/js/web/fetch/fetch-leak.test.ts on macOS aarch64 failed once on an RSS bound and passed on its second attempt.


Generated by Claude Code

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

…acement-4qwmbq

main has the rewritten ahead-of-time compiler (#47942). Its
src/sema/program/retire.rs and src/aot/report/source_texts.rs no longer
compress anything, so both are main's, and `bun_zstd::Contexts`, which only
retire.rs used, goes here as it goes from the binding on main, with its unit
test. In a build of the crate as a release build makes it, `compress_append`
is the one function of 397 whose instructions change (175 to 172).

test/aot/sanitizers/thread-sanitizer.ts is deleted, as on main: this branch
had changed one line of its help.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

Copy link
Copy Markdown
Member Author

Buildkite #138405 for 46048ba7a (the merge of main at 2596ac5d3) is reported as failed, but no test has failed. The four darwin any x64 - test-bun jobs waited from 21:36 to 23:05 UTC without getting an agent and expired, so nothing in them ran. Buildkite has queued them again by itself. The other 185 jobs passed, test/bundler/compile-aot-size.test.ts among them. There is nothing in the branch to change for this; I will read the four jobs when they have run.


Generated by Claude Code

This branch has not been deployed

No deployments
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.

3 participants