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! |
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.