perf(dictBuilder): skip the merge scans that cannot match - #4726
Open
teerthsharma wants to merge 2 commits into
Open
perf(dictBuilder): skip the merge scans that cannot match#4726teerthsharma wants to merge 2 commits into
teerthsharma wants to merge 2 commits into
Conversation
…point ZDICT_insertDictItem identifies the absorbed entry by its slot index while iterating ZDICT_tryMerge to a fixpoint. ZDICT_tryMerge ends each successful merge with a rank-improving insertion sort, which moves every entry between the absorbing entry's old and new slot one slot back. When the caller's index lies in that range it no longer denotes the absorbed entry, so the following ZDICT_removeDictItem deletes an unrelated live segment and the absorbed entry survives as a duplicate. Separately, ZDICT_removeDictItem shifts every entry above the removed slot one slot forward, and newMerge is carried into the next round of the fixpoint without that adjustment. eltNbToSkip becomes a U32*. Each rank sort reports whether it displaced that slot, and the fixpoint re-reads the slot after both the sort and the removal. Reproduction: insert (pos,length,savings) = (1000,10,200), (340,20,100), (300,20,90), (320,30,60). The fourth item bridges the second and third, the cascading merge lifts the absorbing entry from slot 3 to slot 1, and the segment at position 1000 -- which takes part in no merge and lies 700 bytes from the nearest other interval -- is deleted. Only ZDICT_trainFromBuffer_legacy and the CLI's --train-legacy reach this code. ZDICT_trainFromBuffer routes to ZDICT_optimizeTrainFromBuffer_fastCover and is unaffected. There is no public API change, no dictionary format change, and no change to the ranking heuristic or the savings computation. Signed-off-by: teerth sharma <teerths57@gmail.com>
Both loops in ZDICT_tryMerge return at the first match, so a loop only runs to completion when nothing matches. The entire quadratic term is therefore spent proving absence, once per candidate over the whole dictItem table. Each dictItem is an interval in the sample buffer, and the merge fixpoint keeps those intervals pairwise disjoint. Three counters make the absence proof O(1): the number of dictItems beginning at a byte answers the tail predicate, the number whose span holds a byte answers the front predicate, and a counting filter over hashed 8-byte prefixes answers the content predicate. Disjointness bounds the overlap depth, which is what lets nibble counters track coverage exactly. The counters only ever guard a decision to skip a loop, and they are conservative: they never report absence when a match exists. Every merge is still selected by the original predicates, so the emitted dictionary is byte-identical. Two further consequences of the same structure. The content test is skipped when no dictItem can carry the tested key, which removes a random read into the sample buffer per entry visited; and when exactly one dictItem spans elt.pos and no content match is possible, the positional test has a single solution, so the loop returns the same entry walked downward, which reaches it sooner because a merge partner has just been inserted and still sits at the low-savings end. dictItem caches the hash of its 8 bytes so the content pre-test reads the table entry already being walked. Measured with Callgrind on six corpora, against the parent commit, gcc 15.2.0, Ubuntu 26.04 LTS. ZDICT_tryMerge costs between 3.429x and 3.904x fewer instructions on every corpus. Whole-run ZDICT_trainFromBuffer_legacy is 1.585x to 2.382x on five of the six; the sixth is 1.361x, bounded by ZDICT_analyzePos and ZDICT_count, which this change does not touch and which are 61.7% of that run. Every emitted dictionary is byte-identical to the parent commit. Extra memory is one byte per sample byte for the span counters, the same as doneMarks, plus a prefix table sized from dictListSize. Signed-off-by: teerth sharma <teerths57@gmail.com>
teerthsharma
marked this pull request as ready for review
August 2, 2026 03:24
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
ZDICT_tryMergewalks the whole dictItem table twice per call, andZDICT_insertDictItemcalls it once per candidate plus once per round of itsmerge fixpoint. Both loops
returnat the first match, so a loop only runs tocompletion when nothing matches: the quadratic term is spent proving absence.
Each dictItem is an interval in the sample buffer, and the merge fixpoint keeps
those intervals pairwise disjoint. This change adds three counters that make the
absence proof O(1):
span[p]low nibble, the number of dictItems whose interval holdsp,answers the front positional predicate
span[p]high nibble, the number of dictItems beginning atp, answers thetail predicate
prefix[h], the number of dictItems whose 8-byte prefix hashes toh,answers the content predicate
Disjointness bounds the overlap depth, which is what allows nibble-width
counters to track coverage exactly. The counters only ever guard a decision to
skip a loop and are conservative: they never report absence when a match exists.
Every merge is still selected by the original predicates.
Two further consequences of the same structure:
removes a random read into the sample buffer per entry visited;
elt.posand no content match is possible,the positional test has a single solution, so the loop returns the same entry
whichever direction it is walked. It is walked downward there, which reaches
the entry sooner because a merge partner has just been inserted and still sits
at the low-savings end of the table.
dictItemgains a cached hash of the 8 bytes at its position so the contentpre-test reads the table entry already being walked rather than the sample
buffer. The exact
MEM_read64comparison still decides every merge.There is no public API change, no dictionary format change, and no change to
the ranking heuristic, the savings computation,
ZDICT_analyzePos, or thecover/fastcover trainers. Only
ZDICT_trainFromBuffer_legacyand the CLI's--train-legacyreach the modified code.Built on #4724, which establishes the pairwise disjointness this relies on.
Byte-identity
Six corpora,
-O3 -DNDEBUG, dictionary capacity 112,640 B,selectivityLevel9. The checksum is over the full emitted dictionary.lib/*.c94e7afcb94e7afcblib/*.ha29ac6f4a29ac6f4programs/*9948b1d19948b1d1tests/+doc/1a95763c1a95763cb14b4cd4b14b4cd43c9698f43c9698f46 of 6 identical, 0 differing. End to end through the CLI,
zstd --train-legacyover 49 headers emits a 55,860 B dictionary on both trees.Instruction counts
Callgrind, deterministic and identical between runs,
-O2 -fno-inline -gsothat per-function attribution is exact. gcc 15.2.0, Ubuntu 26.04 LTS under
WSL2. Base is
topo/zdict-h0-interval-mergeatfc2b6a358, so the measurementattributes only this change.
tryMergetryMergelib/*.clib/*.hprograms/*tests/+doc/On an
-O3 -DNDEBUGbuild the whole-run ratios are 1.660x, 1.971x, 1.585x,1.596x, 2.382x and 1.361x.
The counters cost 16,300,000 Ir out of 2,952,302,176, or 0.55% of a training
run.
Validation
From PR head
8e1109a22, built with the project's own test flags:tests/fuzzer -i3000tests/zstreamtest -i1000zstd --train-legacyover 49 headers, then compress and decompress each against the resulting dictionarylib/dictBuilder/zdict.ccompiles with no new diagnostics under-Wall -Wextra -Wconversion -Wcast-qual -Wstrict-prototypes -Wdeclaration-after-statement.Rejected alternatives
dictListSize; scaling it todictListSize * 64moved the ratio by under 0.03x, showing collisions were not the residualu = down ? tableSize-i : iLimits
The whole-run ratio varies because the merge phase is a variable share of
training. On the rust corpus
ZDICT_analyzePosis 40.83% andZDICT_count20.87% of the run, so removing
ZDICT_tryMergeentirely would still cap thatcorpus at 1.73x; that phase is a different algorithm and is untouched here. The
change adds one byte per sample byte for the span counters, the same footprint
as
doneMarks, plus a prefix table sized fromdictListSize.dictItemgrowsfrom 12 to 16 bytes. All measurements come from a single machine, and no 32-bit
target was exercised.
Fixes #4725.