Skip to content

fix(dictBuilder): keep dictItem slot index valid across the merge fixpoint - #4724

Open
teerthsharma wants to merge 1 commit into
facebook:devfrom
teerthsharma:topo/zdict-h0-interval-merge
Open

fix(dictBuilder): keep dictItem slot index valid across the merge fixpoint#4724
teerthsharma wants to merge 1 commit into
facebook:devfrom
teerthsharma:topo/zdict-h0-interval-merge

Conversation

@teerthsharma

Copy link
Copy Markdown

Summary

ZDICT_insertDictItem builds the dictionary segment table by fixpoint. Each
dictItem is an interval [pos, pos + length) in the sample buffer;
ZDICT_tryMerge fuses the candidate with one overlapping entry, and the loop
re-runs because fusing two intervals can produce one that reaches a third.
Across that loop the absorbed entry is identified by its array slot, mergeId.

ZDICT_tryMerge ends each successful merge with a rank-improving insertion sort
(zdict.c:377-380 and zdict.c:396-400). Merging raises savings, so the
absorbing entry moves toward the front of the savings-ranked table and every
entry between its old and new slot shifts one slot back — including, on some
paths, the entry mergeId names. The following ZDICT_removeDictItem(table, mergeId) then deletes an unrelated live segment, and the absorbed entry
survives as a duplicate. Separately, ZDICT_removeDictItem shifts every entry
above mergeId one slot forward, and newMerge is carried into the next round
of the fixpoint without that adjustment.

This change makes eltNbToSkip 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.

There is no public API change, no dictionary format change, and no change to
the ranking heuristic, the savings computation, ZDICT_analyzePos, or the
cover/fastcover trainers. Only ZDICT_trainFromBuffer_legacy and the CLI's
--train-legacy reach the modified code; ZDICT_trainFromBuffer routes to
ZDICT_optimizeTrainFromBuffer_fastCover and is unaffected.

Reproduction

Four inserts into a freshly initialised table, items given as
(pos, length, savings):

(1000, 10, 200)   isolated: 700 bytes from the nearest other interval
( 340, 20, 100)
( 300, 20,  90)
( 320, 30,  60)   bridges [300,320) and [340,360)

The fourth item tail-overlaps (340, 20), which then front-overlaps
(300, 20). The second merge lifts the absorbing entry from slot 3 to slot 1,
shifting slots 1 and 2 back; the caller still holds slot 2.

tree surviving entries
dev at 82d322c (pos=300 len=60 savings=229)
this change (pos=300 len=60 savings=238), (pos=1000 len=10 savings=200)

The segment at position 1000 takes part in no merge and is deleted. Both rows
are produced by the same program built against each tree with gcc 15.2.0 on
Ubuntu 26.04 LTS under WSL2.

Validation

From PR head fc2b6a358 on base 82d322c49, gcc 15.2.0, Ubuntu 26.04 LTS under
WSL2, built with the project's own test flags (which include
-Wdeclaration-after-statement and -Wc++-compat):

check result
tests/fuzzer -i3000 3000/3000 completed, exit 0
tests/zstreamtest -i1000 1001/1001 completed, exit 0
zstd --train-legacy over 49 headers from lib/, then compress and decompress each against the resulting 55,860 B dictionary 49/49 byte-identical

An earlier run of the same three checks at tests/fuzzer -i5000 and
tests/zstreamtest -i2000 also completed with exit 0.

lib/dictBuilder/zdict.c compiles with no new diagnostics under
-Wall -Wextra -Wconversion -Wcast-qual -Wstrict-prototypes; the warnings the
file already emits are at zdict.c:596, :905 and :989, none of them on
changed lines.

Frequency

An executable port of zdict.c:137-545 driven over this repository's own lib/
directory counts every ZDICT_removeDictItem call whose victim differs from the
entry the caller selected, comparing (pos, length) before and after:

training corpus segments before segments after wrongful deletions before after
48 KB 263 263 6 0
96 KB 622 628 15 0
192 KB 1371 1381 30 0

At 48 KB the segment count is unchanged because each destroyed segment is
replaced by a duplicate of another; at 96 KB and above the change retains
strictly more distinct segments.

Dictionary effect

Dictionaries built through ZDICT_trainFromBuffer_legacy from lib/ and
programs/: 57 training files totalling 2,359,503 B, 57 held-out files
totalling 1,340,695 B, dictionary capacity 112,640 B, selectivityLevel 9.
Both trees fill the capacity exactly.

dev at 82d322c this change
dictionary checksum b95f42d3 34a56617
duplicated 32-byte windows 2,554 2,525
held-out compressed, level 3 313,463 313,388
held-out compressed, level 9 270,632 270,672

Held-out compression moves -75 B at level 3 and +40 B at level 9, both under
0.03% and in opposite directions.

Existing coverage

tests/fuzzer.c exercises ZDICT_trainFromBuffer (fuzzer.c:3050,
fuzzer.c:3058, fuzzer.c:3648, fuzzer.c:4474) and
ZDICT_trainFromBuffer_cover (fuzzer.c:3790). It does not call
ZDICT_trainFromBuffer_legacy. tests/playTests.sh invokes --train-legacy
only in two negative cases that assert failure on insufficient and on pure-noise
input (playTests.sh:1165, playTests.sh:1167). The legacy trainer has no
positive assertion on its output in the test suite.

Limits

The merge machinery and the segment table are file-static and not reachable from
any public or static-API entry point, so the reproduction above requires
including zdict.c directly and does not fit the pattern used by the existing
tests; no regression test is included for that reason. The change alters emitted
dictionary bytes on inputs that trigger the defect, so any consumer pinning a
legacy-trained dictionary checksum will observe a difference. Held-out
compression ratio is not the argument for this change and the measured effect is
within noise in both directions; the argument is that the fixpoint deletes an
entry it did not select. All measurements were taken on a single machine, and
no 32-bit target was exercised.

Fixes #4723.

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

zdict legacy trainer: the merge fixpoint deletes an unrelated segment when the rank sort invalidates its slot index

1 participant