Skip to content

dfast: pipelined extDict block compressor - #4764

Open
gaynor-anthropic wants to merge 5 commits into
meta:devfrom
gaynor-anthropic:series1-dfast-extdict
Open

gaynor-anthropic wants to merge 5 commits into
meta:devfrom
gaynor-anthropic:series1-dfast-extdict

Conversation

@gaynor-anthropic

Copy link
Copy Markdown

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 csel so GCC keeps them branchless), and force-inlined ZSTD_count variants so GCC does not stop inlining under distro hardening flags.

Results

Measured against dev, change in throughput.

Benchmark Sapphire Rapids (GCC 13) Neoverse-V2 (GCC 15)
streaming, 32 KB window, 4 KB flushes, silesia.tar +24% +22%
streaming, 32 KB window, small flushed messages +7% to +15% +10% to +11%
fullbench -b5 (extDict), 1 MB silesia chunks +12% to +40% not measured
one-shot zstd -b3, silesia.tar / enwik8 +1% / +2% up to +3%
one-shot zstd -b4 0% +5% to +6%
one-shot zstd -b1, -b2 0% 0%

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

gaynor-anthropic and others added 4 commits September 5, 2026 20:11
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>
@meta-cla meta-cla Bot added the CLA Signed label Sep 5, 2026
@terrelln

Copy link
Copy Markdown
Contributor

What does the performance look like on recent clang versions?

@gaynor-anthropic

Copy link
Copy Markdown
Author

Looks to be more or less a no-op on x86-64, but still a sizable win on arm64.

Benchmark Sapphire Rapids (clang 23.1) Neoverse-V2 (clang 23.1)
streaming, 32 KB window, 4 KB flushes, silesia.tar +1% +25%
streaming, 32 KB window, small flushed messages +2% to +3% +13% to +15%
fullbench -b5 (extDict), 1 MB silesia chunks +1% to +5% +9% to +32%
one-shot zstd -b3, silesia.tar / enwik8 +2% / +1% +2% / +3%
one-shot zstd -b4 0% / −1% 0% / −1%
one-shot zstd -b1, -b2 0% 0%

@gaynor-anthropic

Copy link
Copy Markdown
Author

Today in computers are bad, it looks like this may be super specific to Clang+x86, and the following patch would get wins there:

Subject: [PATCH] dfast: keep extDict segment-base selects as conditional moves

The pipelined double-fast extDict loop addresses each table candidate
through dictBase or base, chosen by comparing the candidate's index
with prefixStartIndex. The candidates met within one block lie on both
sides of that index in no learnable order (previous-segment positions
and never-written slots below it, current-segment positions above), so
the loop makes that choice with a select rather than a branch. LLVM's
x86 cmov-conversion pass turns these selects into branches, which in a
clang x86-64 build mispredict about 360 more times per 4.5 KB flushed
message (930 vs. 570); marking the comparisons __builtin_unpredictable
keeps the conditional moves.

Compression speed versus unpatched dev, clang 23, Sapphire Rapids
(x86-64), level 3, one core; streaming rows use a 32 KB window:

    workload                                 unmarked  marked
    4.5-8.9 KB flushed messages,
      2,048 live streams interleaved         +2-3%     +10-13%
      one stream                             +2%       +19-23%
    silesia.tar, 4 KB flushes, 16 streams    +1%       +26%
    fullbench -b5 -l3, first MB of each
      silesia file                           0-6%      +12-59%

GCC (x86-64, aarch64) and clang on aarch64 generate identical object
code marked or unmarked, so their numbers do not change.

UNPREDICTABLE() sits next to LIKELY()/UNLIKELY() in compiler.h and is
the plain condition where __builtin_unpredictable does not exist.
---
 lib/common/compiler.h           | 11 +++++++++++
 lib/compress/zstd_double_fast.c | 14 +++++++++-----
 2 files changed, 20 insertions(+), 5 deletions(-)

diff --git a/lib/common/compiler.h b/lib/common/compiler.h
index 5e70570e..6b933997 100644
--- a/lib/common/compiler.h
+++ b/lib/common/compiler.h
@@ -191,6 +191,17 @@
 #define UNLIKELY(x) (x)
 #endif
 
+/* Tell the compiler that a condition follows no pattern a branch predictor
+ * could learn, so that a value chosen on it is better computed with a
+ * conditional move than reached through a branch.
+ * As with LIKELY/UNLIKELY, only use it where it measurably matters.
+ */
+#if __has_builtin(__builtin_unpredictable)
+#  define UNPREDICTABLE(x) (__builtin_unpredictable(x))
+#else
+#  define UNPREDICTABLE(x) (x)
+#endif
+
 #if __has_builtin(__builtin_unreachable) || (defined(__GNUC__) && (__GNUC__ > 4 || (__GNUC__ == 4 && __GNUC_MINOR__ >= 5)))
 #  define ZSTD_UNREACHABLE do { assert(0), __builtin_unreachable(); } while (0)
 #else
diff --git a/lib/compress/zstd_double_fast.c b/lib/compress/zstd_double_fast.c
index 77ecb035..23d3731d 100644
--- a/lib/compress/zstd_double_fast.c
+++ b/lib/compress/zstd_double_fast.c
@@ -716,16 +716,20 @@ size_t ZSTD_compressBlock_doubleFast_extDict_generic(
 
         hl0 = ZSTD_hashPtr(ip, hBitsL, 8);
         idxl0 = hashLong[hl0];
-        matchl0 = (idxl0 < prefixStartIndex ? dictBase : base) + idxl0;
+        /* Table candidates met within one block lie on both sides of
+         * prefixStartIndex in no learnable order: the comparisons choosing a
+         * segment base in this loop are marked UNPREDICTABLE so that the choice
+         * is made with a conditional move rather than a branch. */
+        matchl0 = (UNPREDICTABLE(idxl0 < prefixStartIndex) ? dictBase : base) + idxl0;
 
         /* Inner Loop: one iteration per search / position */
         do {
             const size_t hs0 = ZSTD_hashPtr(ip, hBitsS, mls);
             const U32 repIndex = (U32)(ip-base) + 1 - offset_1;
-            const BYTE* const repMatch = (repIndex < prefixStartIndex ? dictBase : base) + repIndex;
+            const BYTE* const repMatch = (UNPREDICTABLE(repIndex < prefixStartIndex) ? dictBase : base) + repIndex;
             idxs0 = hashSmall[hs0];
             curr = (U32)(ip-base);
-            matchs0 = (idxs0 < prefixStartIndex ? dictBase : base) + idxs0;
+            matchs0 = (UNPREDICTABLE(idxs0 < prefixStartIndex) ? dictBase : base) + idxs0;
 
             hashLong[hl0] = hashSmall[hs0] = curr;   /* update hash tables */
 
@@ -757,7 +761,7 @@ size_t ZSTD_compressBlock_doubleFast_extDict_generic(
             }
 
             idxl1 = hashLong[hl1];
-            matchl1 = (idxl1 < prefixStartIndex ? dictBase : base) + idxl1;
+            matchl1 = (UNPREDICTABLE(idxl1 < prefixStartIndex) ? dictBase : base) + idxl1;
 
             /* check short match */
             if (matchShortFound(ip, matchs0, idxs0, dictStartIndex)) {
@@ -860,7 +864,7 @@ _match_stored:
             while (ip <= ilimit) {
                 U32 const current2 = (U32)(ip-base);
                 U32 const repIndex2 = current2 - offset_2;
-                const BYTE* const repMatch2 = (repIndex2 < prefixStartIndex ? dictBase : base) + repIndex2;
+                const BYTE* const repMatch2 = (UNPREDICTABLE(repIndex2 < prefixStartIndex) ? dictBase : base) + repIndex2;
                 if ( ((ZSTD_index_overlap_check(prefixStartIndex, repIndex2)) & (offset_2 > 0))
                   && (MEM_read32(repMatch2) == MEM_read32(ip)) ) {
                     const BYTE* const repEnd2 = repIndex2 < prefixStartIndex ? dictEnd : iend;
-- 
2.55.0

Happy to add that to this PR, or do it as a follow up.

@gaynor-anthropic

Copy link
Copy Markdown
Author

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%
@terrelln

Copy link
Copy Markdown
Contributor

@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 fullbench -b5 with compression level 3, and using the zstd CLI writing to /dev/null (which uses extDict almost exclusively) at level 3 with various window sizes to change the probability a match is found in the extDict (smaller window should increase probability a match is in extDict rather than prefix).

I think the direction of the patch generally makes sense, especially the ZSTD_selectAddr change, so I want to make sure that we can run exactly the same benchmark, so I can reproduce the win.

@gaynor-anthropic

Copy link
Copy Markdown
Author

Absolute minimal reproducer:

head -c 1048576 silesia.tar > sil1M && ./tests/fullbench -b5 -l3 sil1M

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 ./flushbench silesia.tar 3 15 4096.

@terrelln

terrelln commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

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

@gaynor-anthropic

Copy link
Copy Markdown
Author

Cheers, hopefully it's somewhere fun!

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants