Skip to content

[dfast] Use ZSTD_selectAddr() for prefix checks in dictMatchState - #4866

Open
jeffchen006 wants to merge 1 commit into
meta:devfrom
jeffchen006:dfast-dms-branchless-prefix
Open

jeffchen006 wants to merge 1 commit into
meta:devfrom
jeffchen006:dfast-dms-branchless-prefix

Conversation

@jeffchen006

@jeffchen006 jeffchen006 commented Oct 26, 2026 •

Copy link
Copy Markdown

This is the dictionary follow-up suggested at the end of #4165:

the current patch applies the new strategy to the noDict variants of fast and dfast only. It seems interesting to also test and eventually adopt this strategy for dictionary compression too.

ZSTD_compressBlock_doubleFast_dictMatchState_generic() tests matchIndex >= prefixLowestIndex before it reads a prefix candidate. With an attached dictionary, the input is usually small and the prefix is short, so many candidate indexes come from before the prefix. The branch is then hard to predict.

This PR applies the method of #4144 / #4165 to the three prefix checks in this function: the long match at ip, the short match at ip, and the long match at ip+1.

  • The candidate is read through ZSTD_selectAddr(), which returns a local dummy[] buffer when the index is invalid. This is the same dummy[] as in the noDict function.
  • The index is tested only after the bytes compare equal. Equal bytes are rare, so both branches become well predicted.

The index test still decides the result, so the compressed output is byte-identical.

Unlike #4165, this does not need a separate branch-based variant for long inputs. With an attached dictionary, this function runs only while the dictionary is in the window, and long streamed inputs are faster as well (see below).

Results

silesia.tar compressed as independent blocks (zstd -b<level> -i2 -B<size> -D <dict>), with a 16 KB raw-content dictionary: the first 16,384 bytes of dickens (head -c 16384 dickens > raw.dict). Blocks of 16 KB or less attach the dictionary, so they use dictMatchState. Each cell is the median of 10 alternating dev/PR pairs (12 for the 16 KB rows) on one pinned core. "Change" is the median of the per-pair ratios.

Compiler Level Block dev MB/s PR MB/s Change Faster pairs
clang 14.0.0 3 1 KB 106.1 121.2 +14.36% 10/10
clang 14.0.0 3 4 KB 163.7 191.8 +17.34% 10/10
clang 14.0.0 3 16 KB 203.9 236.4 +15.94% 12/12
clang 14.0.0 4 4 KB 157.8 184.6 +16.79% 10/10
clang 14.0.0 4 16 KB 195.1 222.4 +13.95% 12/12
gcc 11.4.0 3 1 KB 114.1 118.0 +3.28% 10/10
gcc 11.4.0 3 4 KB 176.5 182.4 +3.52% 10/10
gcc 11.4.0 3 16 KB 218.8 226.8 +3.47% 12/12
gcc 11.4.0 4 4 KB 170.7 177.1 +4.01% 10/10
gcc 11.4.0 4 16 KB 208.3 214.0 +2.60% 12/12

Compressed sizes are identical to dev in every cell. A/A on the same core (-B16K -D, level 3): gcc +0.33%, clang +0.41%. Decompression is not changed, and all its rows are within −0.4% to +0.7%.

The results are similar with trained dictionaries (64 KB and 112 KB, trained on 4 KB slices of Silesia), level 3:

Compiler Dictionary 4 KB blocks 16 KB blocks
clang 14.0.0 trained 64 KB +16.09% (10/10) +15.08% (10/10)
clang 14.0.0 trained 112 KB +15.71% (10/10) +14.18% (10/10)
gcc 11.4.0 trained 64 KB +3.44% (10/10) +3.22% (10/10)
gcc 11.4.0 trained 112 KB +3.16% (10/10) +2.63% (10/10)

Mechanism (perf stat, per pass over 12 × 1 MB Silesia files, level 3, 16 KB chunks with ZSTD_compress_usingCDict()):

Compiler Branch misses Instructions Cycles
clang 14.0.0 3.30 M → 1.16 M (−65%) +7.3% −16.5%
gcc 11.4.0 3.36 M → 2.19 M (−35%) +13.7% −3.5%

Long inputs with an attached dictionary. #4165 found that the cmov method makes long noDict inputs slower. Here, the matching case is a stream with unknown size: the CDict stays attached, and the prefix grows up to the window size. I measured it with ZSTD_compressStream2(), ZSTD_CCtx_refCDict(), 128 KB ZSTD_e_continue steps, the same 16 KB dictionary, and the 1 MB prefixes of the 12 Silesia files. perf confirms that ZSTD_compressBlock_doubleFast_dictMatchState_* takes 79% of the time. 12 pairs:

Compiler Level dev MB/s PR MB/s Change Faster pairs
clang 14.0.0 3 208.5 216.6 +3.42% 11/12
clang 14.0.0 4 206.5 215.2 +4.71% 12/12
gcc 11.4.0 3 216.4 221.2 +2.44% 10/12
gcc 11.4.0 4 210.8 217.2 +2.52% 11/12

A/A in this mode: gcc +0.17% (6/12), clang +0.34% (9/12).

Unchanged paths (this function is not used):

Case gcc 11.4.0 clang 14.0.0
whole silesia.tar, no dictionary, level 3 +0.26% (7/12) −0.74% (3/12)
whole silesia.tar, no dictionary, level 4 −0.62% (2/10) −0.02% (5/10)
64 KB blocks + dictionary (copied, not attached), level 3 −0.06% (5/10) −0.29% (3/10)
128 KB blocks + dictionary, level 3 −0.06% (4/10) −0.10% (3/10)

These rows are within the A/A spread.

Cost

  • Code size. size text:

    Build gcc 11.4.0 clang 14.0.0
    zstd CLI 1,042,034 → 1,043,354 (+1,320, +0.13%) 967,602 → 970,546 (+2,944, +0.30%)
    zstd_double_fast.o, -O3 +1,288 +2,944
    zstd_double_fast.o, -O2 +704 +752
  • Adversarial input. The code compares 8 or 4 bytes from dummy[] with the input when the index is invalid. An input made of the dummy[] byte pattern therefore causes many false-equal reads, and every false-equal read costs an index test. On such a crafted 300 KB input (-B4K, level 3), the PR is −1.6% (gcc) and −4.4% (clang). noDict has the same property.

Correctness

  • Long checks: ZSTD_selectAddr(idx, low, match, dummy) returns match if and only if idx >= low. The two operands of && have no side effects, so the swapped order gives the same boolean. For a valid index, the read address is the same as before. For an invalid index, the result is false whatever dummy[] contains.
  • Short check: the control flow is the same as before. ZSTD_selectAddr() uses >=, but the check uses >. At idx == prefixLowestIndex, the read goes to prefixLowest, which is valid memory, and idx > prefixLowestIndex is false. The dictionary branch is reached under the same condition as before (dictTagsMatchS && idx <= prefixLowestIndex).
  • Memory: the code reads 8 bytes from the 10-byte local array through MEM_read64, and it never reads below the prefix.
  • Byte identity, with cross-decoding:
    • About 150,000 library configurations per build had 0 differences. They cover 23 inputs × 6 dictionaries × 14 parameter sets (minMatch 4–7, wlog 10–17, hlog/clog), chunk sizes from 1 B to 128 KB, attach modes 0/1/2, CDict and loadDictionary, and compress2 and streaming with and without a pledged size. The builds were gcc, clang, -m32 and the forced C fallback of ZSTD_selectAddr().
    • The attach cutoff (16,000–32,769 B) is covered by 58,752 configurations.
    • 23,520 CLI cases had 0 differences.
    • A coverage build hit both sides of every changed condition, including idx == prefixLowestIndex and dummy bytes equal to the input.
  • Tests: fuzzer -T180s, zstreamtest -T180s, an ASan+UBSan fuzzer -T120s, and playTests.sh (also with --test-large-data) all passed. ASan+UBSan and DEBUGLEVEL=1 library builds passed 66,168 configurations each.
Method
  • Machine: AMD EPYC 9654 (Zen 4), Ubuntu 22.04, Linux 5.15. The machine is shared, so each benchmark ran pinned to one core.
  • Builds: make zstd and make -C lib libzstd.a with default flags (-O3) and no -march.
  • Input: silesia.tar, 211,957,760 bytes, SHA-256 0b5ec70c8048321525f820bc1d76429840bd22ee2c7a116175253a0fec2c724d.
  • Dictionary: head -c 16384 dickens > raw.dict (SHA-256 626287fc08506a3f5c970220e11a187237abd7a24c86e907699a781b1e6ef88d). The trained dictionaries were made with zstd --train on 4 KB slices of Silesia, so they favor this data.
  • CLI rows: zstd -b<level> -i2 -B<size> -D <dict> silesia.tar. Each pair runs dev and the PR back to back, and the order alternates between pairs.
  • Streaming and perf rows: a small C program, statically linked with each libzstd.a. In stream mode it uses ZSTD_CCtx_refCDict() + ZSTD_compressStream2() with 128 KB ZSTD_e_continue steps, then ZSTD_e_end. In chunk mode it calls ZSTD_compress_usingCDict() on each 16 KB chunk. Each run does 1 second of complete passes. The program checks that dev and the PR produce the same total compressed size. I can add it to the PR if it helps.

Limits

  • Platforms: measured only on x86-64 (Zen 4) with gcc 11.4 and clang 14.0. I have no aarch64, Apple M1 or newer-compiler results. On aarch64, ZSTD_selectAddr() is a plain ternary, so the effect depends on whether the compiler emits csel. Improve compression speed on small blocks #4165 saw a good result on M1 for noDict.
  • Scope: only the dfast dictMatchState path (levels 3–4 with an attached dictionary) is changed. The extDict path and the fast dictMatchState path are not changed.

Checklist

  • I searched open pull requests and issues: this problem is not already being addressed.
  • Bug fix: it comes with a reproducer or a test that fails without the fix. (Not applicable: this is a performance change with identical output.)
  • Performance change: numbers from repeated runs are included, with CPU, compiler, flags and data.
    A gain in one configuration is not a general gain: see the Performance section of CONTRIBUTING.md.
  • I stated below whether AI or other automated tools were used.

Tools used

Claude Code (Claude Opus) found this optimization, wrote the patch, ran the correctness checks (byte-identity harnesses, fuzzer, zstreamtest, ASan/UBSan, playTests) and the benchmarks, and drafted this description. A separate Claude Code agent reviewed the patch independently and repeated the measurements. I reviewed the diff and the benchmark data before submitting.

ZSTD_compressBlock_doubleFast_dictMatchState_generic() tests
matchIndex >= prefixLowestIndex before it reads the prefix candidate.
When the prefix is small, which is the usual case for small inputs with
an attached dictionary, this branch is hard to predict.

Apply the method of meta#4144 / meta#4165 (noDict) to the three prefix checks
(long at ip, short at ip, long at ip+1): read through ZSTD_selectAddr(),
which returns a local dummy buffer for an invalid index, and test the
index only after the bytes compare equal. The index test still decides
the result, so the compressed output is unchanged.

silesia.tar compressed in independent 1-16 KB blocks with a 16 KB raw
dictionary, AMD EPYC 9654, median of 10-12 alternating pairs:
  clang 14: levels 3-4 +14% to +17%
  gcc 11:   levels 3-4 +2.6% to +4.0%
Branch misses drop by 35% (gcc) and 65% (clang). Streaming with an
attached dictionary and unknown size, on 1 MB inputs, is +2.4% to +4.7%.
Without a dictionary, or with blocks > 16 KB, this code does not run and
the results are neutral.

Code size: text +1320 bytes (gcc), +2944 bytes (clang).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@meta-cla meta-cla Bot added the CLA Signed label Oct 26, 2026
@jeffchen006
jeffchen006 marked this pull request as ready for review October 27, 2026 00:02
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.

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.

1 participant