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!

zcsizmadia added a commit to zcsizmadia/zstd-native that referenced this pull request Nov 6, 2026
…#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.
zcsizmadia added a commit to zcsizmadia/zstd-native that referenced this pull request Nov 6, 2026
…#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.

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