Repository navigation
dfast: pipelined extDict block compressor - #4764
gaynor-anthropic wants to merge 5 commits into
Conversation
The fast match finder tests whether a table candidate lies inside the window with either a conditional move or a branch, chosen per block. The branch wins when nearly every candidate is in range; the conditional move wins when many entries are stale and the branch would mispredict. The double-fast noDict loop always used the conditional move, which is the slower form on x86-64 at level 3 on large inputs. Share the helpers between the two match finders (adding 8-byte variants for the long table) and let double-fast choose too, by table freshness rather than window size alone. A slot goes stale only if it is not overwritten for a whole window, which is rare once windowLog >= hashLog + 4 and common when the tables are about as large as the window. So level 3 on large inputs takes the branch; level 4 on large inputs and any small window keep the conditional move. The long candidate at ip+1 goes through the same helper instead of a plain branch. zstd -b on silesia.tar and enwik8: level 3 is 1-4% faster on x86-64 and unchanged on aarch64, where GCC already emitted a branch; level 4 is unchanged; levels 1-2 are not touched. One-shot output is byte-identical. Streaming output can differ by a few bytes once the window slides (at most 0.006%, on silesia and on a synthetic stream of small messages), because the ip+1 long candidate is now accepted at exactly the lowest valid index, as the ip candidates already were. On aarch64 with GCC this change alone costs 1.7-2.5% where the conditional move is used, because GCC turns the ternary in ZSTD_selectAddr() (and so the new ip+1 select) back into a branch. The next commit adds an aarch64 csel there to recover that. Co-authored-by: Claude <noreply@anthropic.com>
ZSTD_selectAddr() exists so that the "is this table index inside the window" test in the fast and double-fast candidate checks compiles to a conditional move, not a branch that mispredicts when many entries are stale, as with a small window. x86-64 forces this with inline asm; elsewhere it is a plain ternary, which GCC 13 and 15 on aarch64 turn back into a branch, though clang does not. Force it on aarch64 too, with an inline-asm csel. Output is unchanged. Neoverse-V2 (Graviton4), GCC 13: double-fast on synthetic small records with a 32 KB window and level-3 tables (wlog=15, hlog=14, clog=13) is 10% faster. zstd -b on silesia.tar and enwik8: level 4 is 2-3% faster, levels 1-2 unchanged; level 3, where nearly all candidates are in range, is unchanged now that double-fast branches when windowLog >= hashLog + 4 (it would be 2.5% slower if double-fast still used the select there). Co-authored-by: Claude <noreply@anthropic.com>
ZSTD_count() and ZSTD_count_2segments() are a handful of instructions on the hottest path of every block compressor and are meant to be inlined. GCC inlines them with default flags. With the hardening flags distributions commonly add (-D_FORTIFY_SOURCE=3, -fstack-protector-strong, -fstack-clash-protection), GCC 13-15 at -O3 stop inlining them in several zstd_double_fast.c specializations once the file gains a few more specializations, as the next commit does; the calls then cost about 2% on match-dense inputs. Add ZSTD_count_inline() and ZSTD_count_2segments_inline(), the same code marked FORCE_INLINE_TEMPLATE, and use them throughout zstd_double_fast.c. The original functions are untouched, so the other block compressors compile to the same code as before (function sizes in zstd_fast.o, zstd_lazy.o, zstd_opt.o and zstd_ldm.o verified identical). Compressed output does not change. Co-authored-by: Claude <noreply@anthropic.com>
When a stream with a small window is flushed every few KB, zstd's input
buffer wraps about once per window's worth of input, so nearly every
block is compressed in extDict mode, and at level 3 the double-fast
extDict loop sets streaming speed. That loop still searched one position
at a time; the double-fast noDict and fast extDict loops had already
been restructured to overlap work across positions and avoid branching
on index validity.
Give that loop the structure of the noDict one. The long-table lookup
for ip+1 is issued before the candidates at ip are resolved. Candidates
are tested with the conditional-move helpers: in extDict mode the lowest
valid index moves with every block, so the tables always hold stale
entries, and the branch form measured slower or equal at every window
size tried (windowLog 15 to 21). Repcodes that cannot be valid anywhere
in the block are disabled up front rather than range-checked at every
position. Step/skip acceleration, prefetch placement and per-minMatch
specialization follow the noDict loop.
Segment handling is unchanged: an index below prefixStartIndex is
addressed through dictBase, match lengths use the two-segment count, and
backward extension stops at the start of the candidate's segment. Output
changes slightly with the parse: -0.15% bytes on silesia cut into 4 KB
flushes with a 32 KB window, +0.04% on a synthetic message stream.
Compression speed of this commit plus the three preceding ones versus
unpatched dev; level 3, 32 KB window, one core, GCC 15 (silesia row
GCC 13):
workload Neoverse-V2 Sapphire Rapids
4.5-8.9 KB flushed messages,
2,048 live streams interleaved +10-11% +7-10%
one stream +11% +15%
silesia.tar, 4 KB flushes, 16 streams +22% +24%
One-shot zstd -b, same four changes, GCC 13: levels 1-2 unchanged, level
3 up to 3% faster, level 4 is 5-6% faster on Neoverse-V2 and unchanged
on Sapphire Rapids. The messages are synthetic small structured records,
not a public corpus; Neoverse-V2 is an AWS Graviton4.
Co-authored-by: Claude <noreply@anthropic.com>
|
What does the performance look like on recent clang versions? |
|
Looks to be more or less a no-op on x86-64, but still a sizable win on arm64.
|
|
Today in computers are bad, it looks like this may be super specific to Clang+x86, and the following patch would get wins there: Happy to add that to this PR, or do it as a follow up. |
|
I'm looking at the regression-test failure |
The pipelined extDict compressor finds different matches than the old loop, so compressed sizes change at dfast levels (0, 3, 4) in extDict scenarios. 81 rows change, none added or removed. silesia.tar streaming: -0.15% to -0.17% github.tar with dict: +0.87% to +1.13% github with dict load: +0.78% github with dict copy: -0.005% to -0.14%
|
@gaynor-anthropic I've started trying to reproduce the claimed win today, and so far haven't been successful. So far I've been testing with gcc-11 on an AMD Turin machine, and clang-21 on an M4. In order to reduce the number of variables, could you please share a benchmark harness that measures the performance of the streaming use case? So far I've been using I think the direction of the patch generally makes sense, especially the |
|
Absolute minimal reproducer: For a stand-alone harness: /* flushbench: streaming compression throughput with a flush every `chunk` bytes.
* usage: flushbench FILE [level=3] [windowLog=15] [chunk=4096] [streams=1] [rounds=7]
* The file is cut into `streams` equal segments, one CCtx each, fed round-robin. */
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <zstd.h>
#define CHECK(f) do { size_t const r_ = (f); if (ZSTD_isError(r_)) { \
fprintf(stderr, "%s: %s\n", #f, ZSTD_getErrorName(r_)); exit(1); } } while (0)
static double now(void) {
struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t);
return (double)t.tv_sec + 1e-9 * (double)t.tv_nsec;
}
int main(int argc, char** argv) {
if (argc < 2) { fprintf(stderr, "usage: %s FILE [level] [windowLog] [chunk] [streams] [rounds]\n", argv[0]); return 1; }
int const level = argc > 2 ? atoi(argv[2]) : 3;
int const wlog = argc > 3 ? atoi(argv[3]) : 15;
size_t const chunk = argc > 4 ? (size_t)atol(argv[4]) : 4096;
size_t const streams = argc > 5 ? (size_t)atol(argv[5]) : 1;
int const rounds = argc > 6 ? atoi(argv[6]) : 7;
FILE* const f = fopen(argv[1], "rb");
if (!f) { perror(argv[1]); return 1; }
fseek(f, 0, SEEK_END); size_t const size = (size_t)ftell(f); fseek(f, 0, SEEK_SET);
char* const src = malloc(size);
if (!src || fread(src, 1, size, f) != size) { fprintf(stderr, "read error\n"); return 1; }
fclose(f);
size_t const seg = size / streams; /* tail remainder is ignored */
size_t const cap = ZSTD_compressBound(chunk);
char* const dst = malloc(cap);
ZSTD_CCtx** const cctx = malloc(streams * sizeof(*cctx));
for (size_t s = 0; s < streams; s++) {
cctx[s] = ZSTD_createCCtx();
CHECK(ZSTD_CCtx_setParameter(cctx[s], ZSTD_c_compressionLevel, level));
CHECK(ZSTD_CCtx_setParameter(cctx[s], ZSTD_c_windowLog, wlog));
}
double best = 1e30; size_t csize = 0;
for (int r = 0; r < rounds; r++) {
for (size_t s = 0; s < streams; s++) CHECK(ZSTD_CCtx_reset(cctx[s], ZSTD_reset_session_only));
csize = 0;
double const t0 = now();
for (size_t off = 0; off < seg; off += chunk) {
size_t const n = seg - off < chunk ? seg - off : chunk;
for (size_t s = 0; s < streams; s++) {
ZSTD_inBuffer in = { src + s * seg + off, n, 0 };
ZSTD_outBuffer out = { dst, cap, 0 };
size_t const rem = ZSTD_compressStream2(cctx[s], &out, &in, ZSTD_e_flush);
CHECK(rem);
if (rem || in.pos != n) { fprintf(stderr, "flush incomplete\n"); return 1; }
csize += out.pos;
}
}
double const dt = now() - t0;
if (dt < best) best = dt;
}
printf("level %d wlog %d chunk %zu streams %zu: %.1f MB/s, %zu -> %zu bytes\n",
level, wlog, chunk, streams, (double)(seg * streams) / best / 1e6, seg * streams, csize);
return 0;
}which can be run like |
|
Thanks for sharing that @gaynor-anthropic, that will definitely help in measuring this PR! I'm going on vacation for October, so @Cyan4973 will have to take over the review |
|
Cheers, hopefully it's somewhere fun! |
…#71) * profile_encode: streaming-with-flushes and dictionary modes (#62) New modes, with the same calls on both libraries: - native-flush / c-flush: one frame through StreamEncoder / ZSTD_compressStream2 with a flush (ZSTD_e_flush) every `chunk` bytes (default 4096); ZSTD_BENCH_WLOG sets the window log. Once the input buffer wraps, blocks go through the extDict match finders. - native-dict / c-dict: frames of `chunk` bytes (default 4096) with a prepared dictionary (EncoderDictionary / ZSTD_createCDict + ZSTD_CCtx_refCDict + ZSTD_compress2), from up to 4 MiB of the dataset. Small inputs attach the dictionary (dictMatchState), larger ones copy its tables (extDict). ZSTD_BENCH_DICT=raw:n | trained:n | <file>. A ZSTD_BENCH_CORPUS file named as the dataset is now read alone (quick start, clean perf stat counts). Baseline, main b33e172 against libzstd 1.5.7 (EPYC 7543, CPU 8, medians of 9 runs, silesia dickens/samba/xml): libzstd was faster by - flush every 4/16/64 KiB: L1 0-6%, L2 -4..+4.5%, L3 2-10%, L4 1.5-9%; - dictionary, 1-4 KiB inputs: L1 12-18%, L2 9-15%, L3 6-11%, L4 6-11%; 16 KiB inputs: zstd-native faster at L1/L2 (2-7%), libzstd at L3/L4 (5-11%). * dfast extDict: out of line, BMI2, cmov candidate checks (meta/zstd#4764) (#62) The extDict loop (streaming once the input buffer wraps, copied dictionaries) was inlined into build_seq_store with every block invariant spilled. Now: - out of line, with a BMI2 build (shrx hashes); - the table candidates' segment and validity selected with cmovs, the index tested only once the bytes match (ZSTD_match8Found_cmov / match4Found_cmov of #4764, through the new fast::cand_match / select_addr / then_valid; an empty asm! keeps the branch order), used from windowLog >= max(chainLog, 16): with smaller windows nearly every candidate is stale, the branch predicts well and is 1-8% faster. Same parse as libzstd 1.5.7. #4764's pipelined loop changes the parse (its own PR: -0.15% bytes), so it is not ported; loading the next position's entries ahead within 1.5.7's parse, and prefetching them 2-4 positions ahead, measured slower (more instructions; the loop is not table-miss bound). Flush every 4/16/64 KiB (dickens, samba, xml), against main: L3 +3.7..+5.2%, L4 +2.6..+8.5%; 32 KiB window L3 +1.6..+1.8%, L4 +2.9..+3.6%. L1/L2 and one-shot unchanged (control: +-0.8%). * dfast dictMatchState: cmov prefix checks (meta/zstd#4866), out of line (#62) The three prefix candidate checks (long and short at ip, long at ip+1) read through ZSTD_selectAddr and test the index only once the bytes match: with an attached dictionary the prefix is short, and whether a candidate is in it is an unpredictable branch. The loop also moves out of build_seq_store (0.5-1.5% faster than inlined); a BMI2 build measured 1.5-3% slower here and is not used. Dictionary, 1-16 KiB inputs, against the previous commit: raw 16 KiB dictionary L3 +0.5..+6.2%, L4 +1.5..+7.2%; trained 64 KiB dictionary -1.0..+2.8% (the -1.0% is xml 16 KiB L4; control noise +-0.8%). * fast dictMatchState: ZSTD_match4Found_cmov, BMI2 (#62) libzstd 1.5.7 checks the prefix candidate of the fast dictMatchState loop with ZSTD_match4Found_cmov; the port used a branch on the index first, mispredicted with the short prefix of small inputs. Now the same cmov check, and a BMI2 build of the loop. Dictionary, inputs that attach (L1/L2 up to 8 KiB), against the previous commit: L1 +6.0..+13.4%, L2 +4.0..+7.3% (branch misses -38% at L1); 16 KiB inputs (copied dictionary) -0.6..+0.2%. * fast extDict: cmov candidate checks for windows up to 64 KiB (#62) When the valid indexes span at most 64 KiB (small windows, small copied dictionaries), many table entries are stale: the candidate's segment and validity are selected with cmovs (meta/zstd#4764) in a BMI2 build. Larger windows keep libzstd's branches, in their own out-of-line function (code placement: inside one function with the cmov path they measured 1-2% slower with identical instructions). Against the previous commit: flush every 4 KiB with a 32 KiB window L1 +11%, L2 +8%; 64 KiB window L1 +3..+8%; 16 KiB inputs with a 16 KiB dictionary (copied) L1 +8%, L2 +7%. 128 KiB windows and up, and a copied 64 KiB dictionary: -0.4..+1.0% (control +-0.5%). * stream_encode: frequent flushes at the fast levels (#62) A flush every 1-16 KiB at levels -3..4 with 1 KiB to 128 KiB windows and the default ones (past them: 3 MB of input), small output buffers: nearly every block in extDict mode, against libzstd's bytes. * CLAUDE.md: the new unsafe and asm! locations (#62) The run-time BMI2 dispatch of the fast/dfast extDict and fast dictMatchState loops, and then_valid's empty asm! barrier.
…#75) (#77) * Encoder: dfast extDict block loop in asm (#75) Profiles of the streaming cases still behind libzstd (a flush every 1-64 KiB at levels 3-4: every block after the input buffer's first wrap is compressed in extDict mode) put the whole gap in the dfast extDict match finder, and there in its search loop (the positions without a match: 60% of the loop's cycles, 6% more than libzstd's). The Rust loop ran libzstd's 72 instructions per position there (5% more in the whole loop, sampled), with fewer branch misses (perf, dickens L3, flush every 16 KiB: 10.9M vs 11.1M) and the same L1/L2 and dTLB misses; but it kept the table pointers, a hash shift, the segment bases and offset_1 on the stack and reloaded them at every position (and stored a hoisted hash prime at every position), and saw 40% more store-to-load interlocks (ls_bad_status2.stli_other) in the loop. Tried in Rust and not kept (cycles within noise or worse): unchecked sequence stores (-1% instructions, -1.3..-2.4% speed on mozilla/samba), the branch candidate checks (libzstd 1.5.7's), keeping the prime out of the loop, moving the tables' 4 KiB offsets. Huge pages for the 512 + 256 KiB tables (with glibc's malloc THP tunable: -2.6% cycles for us, -4.1% for libzstd; 110M dTLB misses each) are left for later. Now the whole loop, match handling included, is asm (block_asm.rs, dfast_ext_block, next to the noDict loops and with their macros): per-position values in registers, the per-match state in one context struct, ZSTD_count_2segments as a macro over the count loop (now count_lim_asm with the limit in a register). Same parse and checks as the CMOV Rust loop (meta/zstd#4764's cmov candidate selection, a dummy read for invalid candidates, the index tested once the bytes match), so it is used where that loop is (windowLog >= max(chainLog, 16): the default windows); the Rust loops remain for smaller windows, without BMI2 and with the portable feature. An asm build of the branch variant for the small windows and copied dictionaries measured the same as the Rust loop there (samba L4 / xml L3, 16 KiB inputs with a 16 KiB raw dictionary, flush every 4 KiB with windowLog 12/15: -0.3..+0.3% cycles) and 3.6% slower than the cmov one with the default windows: not kept. Prefetching the next position's table entries: dickens -1%, mozilla +1.6% cycles: not kept. perf stat, dickens L3, flush every 16 KiB (10 iterations, EPYC 7543, CPU 8): cycles main 2.69e9, this 2.62e9 (-2.3%), libzstd 2.61e9; instructions 4.96e9 -> 4.58e9 (-7.7%). Test: stream_encode ext_dict_long_lengths: extDict blocks with literal runs over 64 KiB and matches over 64 KiB that start in the extDict segment and run on into the prefix (both long length flags are hit in the asm), levels -1..4, windows 2^17..2^20 and the defaults, against libzstd's bytes. EPYC 7543 (Zen 3), CPU 8, MiB/s of input, median of 9 (3 rounds x 3 binary copies of different path lengths, random order); control = main + a black_box in MatchState::reset_impl. profile_encode native-flush / native-dict (raw 16 KiB dictionary), against main: case this control libzstd vs this dickens L3 flush 1024 +1.9% -0.4% +0.7% dickens L3 flush 4096 +1.9% -0.2% 0.0% dickens L3 flush 16384 +2.7% +0.3% +0.4% dickens L3 flush 65536 +2.6% +0.2% +0.9% dickens L3 flush 4096 wlog17 +2.3% -1.5% -9.6% dickens L4 flush 1024 +1.4% 0.0% -0.1% samba L3 flush 4096 +2.1% -0.7% -1.3% samba L3 flush 16384 +2.4% -0.4% +0.2% samba L3 flush 65536 +2.6% -0.3% -1.4% samba L4 flush 65536 +3.2% +0.2% -6.6% mozilla L4 flush 4096 +3.9% -0.3% -2.1% mozilla L4 flush 16384 +5.0% -0.6% -1.7% mozilla L3 flush 4096 wlog16 +2.8% +0.2% -13.2% xml L4 flush 16384 +0.6% -0.6% -3.6% not through this loop (fast, small windows, dictionaries): dickens L2 flush 16384 -1.0% -0.6% -1.3% xml L1 flush 4096 -0.5% -0.4% -3.1% samba L1 flush 1024 +0.4% +0.9% -7.2% xml L1 dict 1024 +0.9% 0.0% -7.7% xml L2 dict 4096 +0.5% +0.3% +0.6% dickens L3 dict 16384 -1.1% -0.6% -3.6% samba L4 dict 16384 0.0% +0.3% +1.9% (the -1.0% and -1.1% run the same instructions as main, 2.459e9 and 2.939e9; their cycles differ by -1.4..+0.3% between runs) One-shot, no regression (frames of the whole file; L19 4 KiB frames), 12 Silesia files, median of 6 (2 rounds x 3 copies) against main: level this control L1 -0.4..+0.4% -0.6..+0.4% L3 -0.2..+0.5% -0.7..+0.6% L9 -0.7..+0.2% -0.5..+0.4% L19 -0.2..+0.8% -0.3..+0.4% (dickens, mozilla, mr, nci, ooffice, osdb, reymont, samba, sao, webster, x-ray, xml: every case within -0.7..+0.8%) * Encoder: optimal parser price tables updated per changed code (#75) One-shot dickens at level 19 in 4 KiB frames was 4.7% behind libzstd. Profile (perf, EPYC): OptStats::set_base_prices was 17% of the time (330M of 1968M cycles; libzstd's ZSTD_setBasePrices 9M). It runs after every stretch of stored sequences (ZSTD_setBasePrices at the end of the parser's outer loop: a few hundred times per 128 KiB block, more often on text), and besides libzstd's four sum base prices it rebuilt the per-code tables the parser reads (36 literal length codes, 53 match length codes and 128 match length prices) every time, although one stretch changes the frequency of only a few codes. Now the tables leave out the sums' base prices (added where they are read: the literal length one in ll_price, the match length one in match_off_price, once per candidate; the same wrapping u32 sums as libzstd's, so the same prices), and set_base_prices recomputes only the codes whose frequency changed since the last call (update_stats marks them in two bit masks); a new block (rescale_freqs: every frequency and the price type may change) recomputes all of them. Test: incremental_prices_match_libzstd checks ll_price and match_price against libzstd's ZSTD_litLengthPrice / ZSTD_getMatchPrice computed from the frequencies, for every literal and match length up to 300 and the code boundaries up to 128 KiB, after random statistics updates, blocks and both price types, at optLevel 0, 1 and 2; ml_code_bases checks the match length code ranges. perf stat, EPYC 7543, CPU 8, fixed work, median cycles of 3 binary copies (instructions) against the previous commit: dickens L19, 4 KiB frames: 1213M -> 1039M (-14.3%) (3.32e9 -> 2.71e9) xml L19, 4 KiB frames: 1640M -> 1576M (-3.9%) (4.95e9 -> 4.66e9) xml L19, whole file: 3597M -> 3477M (-3.3%) (10.22e9 -> 10.19e9) EPYC 7543, CPU 8, MiB/s, median of 6 (2 rounds x 3 binary copies, random order); control = the previous commit + a black_box in MatchState::reset_impl: case MiB/s this control libzstd vs this dickens L19, 4 KiB frames 7.7 +18.4% -0.1% -11.4% ooffice L18, 4 KiB frames 8.8 +41.8% 0.0% -10.3% samba L16, 4 KiB frames 7.6 +10.1% -0.1% -23.0% xml L19, 4 KiB frames 4.8 +4.6% +0.2% -36.8% mozilla L17, 4 KiB frames 9.7 +4.1% +0.4% -28.3% dickens L22, 16 KiB frames 6.1 +7.8% -0.1% -14.0% nci L19, 16 KiB frames 3.1 +0.9% +0.1% -36.6% ooffice L18, whole file 6.5 +6.9% +0.4% -27.0% x-ray L16, whole file 11.9 +6.4% +0.3% -36.0% sao L17, whole file 7.5 +5.8% -0.7% -25.7% xml L19, whole file 5.5 +2.8% 0.0% -41.7% reymont L19, whole file 3.3 -0.8% -0.1% -21.1% xml L19, 4 KiB, trained 16 KiB dict 5.9 +4.5% -0.2% -38.4% samba L16, 16 KiB, trained dict 9.0 +3.7% +0.1% -28.2% dickens L19, flush every 64 KiB 3.2 -0.7% -0.3% -19.4% The two negative ones, perf stat on CPU 8 (medians of 6, 2 rounds x 3 copies): reymont L19 cycles -0.2% (instructions -0.16%); dickens L19 flush 64 KiB cycles +0.5% with a 1.3% spread between runs of the same binary (instructions -0.1%): noise.
Restructures the double-fast extDict loop the way the noDict loop already is: the ip+1 long-table lookup is issued early, candidate checks use cmov instead of branching on index validity, and repcodes that cannot be valid in the block are disabled up front. This path dominates level-3 compression when a small-window stream is flushed every few KB, since nearly every block is then compressed in extDict mode.
The first three commits are preparatory refactors: sharing the cmov/branch candidate-check helpers between fast and double-fast (with an aarch64
cselso GCC keeps them branchless), and force-inlinedZSTD_countvariants so GCC does not stop inlining under distro hardening flags.Results
Measured against dev, change in throughput.
fullbench -b5(extDict), 1 MB silesia chunkszstd -b3, silesia.tar / enwik8zstd -b4zstd -b1,-b2One-shot output is byte-identical. Streaming output changes slightly with the parse once the window slides: -0.15% bytes on silesia in 4 KB flushes with a 32 KB window, +0.04% on the message stream.