Skip to content

dictBuilder: break COVER sort ties by position, not by address - #4765

Open
jaypatrickhoward wants to merge 1 commit into
facebook:devfrom
jaypatrickhoward:cover-cmp-fix
Open

dictBuilder: break COVER sort ties by position, not by address#4765
jaypatrickhoward wants to merge 1 commit into
facebook:devfrom
jaypatrickhoward:cover-cmp-fix

Conversation

@jaypatrickhoward

Copy link
Copy Markdown

Fixes#4185

Summary

stableSort() in the COVER dictionary builder is documented to leave each dmer group ordered by position in the input. It doesn't. The tie-break compares the addresses of the elements being sorted rather than the positions they hold, making the comparator depend on transient sort state instead of on the values being compared.

As a result, dictionary output is not reproducible, it differs between C libraries, dmer frequencies are systematically under-counted, and on MSVC training degrades superlinearly — 458 s for a 16 MB corpus at d=6, versus 6.5 s with this fix.

The change is the same one-line edit at both tie-break sites, plus two one-line comment corrections. Four lines.

The bug

COVER_group() counts how many samples a dmer occurs in using a forward-only cursor, so it requires each dmer group to be ordered by position. COVER_ctx_init() states the assumption directly:

/* The sort is stable, so each dmer group is sorted by position in input. */

qsort() is not stable, so COVER_strict_cmp() synthesises the ordering:

result=lp<rp ? -1 : 1;

lp and rp are the addresses of the elements being compared, not the positions they contain. Those coincide only while suffix[i] == i; the first swap breaks the correspondence, after which the tie-break orders by where an element currently sits rather than by what it holds. The comparator is therefore not a function of the values it compares.

Consequences

1. Output depends on the sort implementation, and is not reproducible. Linking the unmodified builder against four different sorts, same input:

sortdictionary (d=8)
libc qsort24df76c6…
mergesort012b539f…
quicksort43b34fe5…
heapsort0d85f425…

A single implementation need not even be self-consistent between runs. When qsort() compares an element against a temporary — a pivot copy — one operand is not in the array at all, so the comparison is stack-vs-heap and ASLR decides it. On Apple libc, the same binary on the same machine with the same input produced two distinct dictionaries across six consecutive runs at every d tested. Reproduced through the CLI and through a directly-linked single-threaded ZDICT_trainFromBuffer_cover() call, so thread scheduling is not involved.

Linux has been getting reproducible output, but by luck rather than design: glibc's qsort uses mergesort whenever its internal allocation succeeds, and mergesort happens to satisfy the invariant. When that allocation fails, glibc falls back to heapsort, which does not — see Why accept a cost on glibc below.

2. Frequencies are under-counted. 91% of tied groups emerge unordered, and 26–78% of dmer frequencies are under-counted — never over-counted, which is the signature of COVER_group()'s forward-only cursor skipping occurrences.

Why this is catastrophic on MSVC

The tie-break is only reached when two dmers compare equal, so the damage depends on how often that happens. Holding d=8 and the corpus at 8 MB, varying only content:

corpusmean dmer groupcurrentwith fix
random bytes1.0 (all unique)4.6 s4.5 s
JSON-like records104.545.0 s3.6 s
two-symbol alphabet6,400.0150.4 s2.3 s

With unique dmers the tie-break is never reached and there is no effect. This is why smaller d looks worse — d is a proxy for duplicate density, not a cause. Repetitive input is exactly what dictionary training targets.

Measurements here are MSVC. #4185 also reports the hang under cygwin, msvcrt and ucrt with clang and gcc, which I have not measured — but the defect is a property of the comparator, not of any one implementation.

The fix

Compare the stored positions instead of the addresses. The key becomes (dmer, position). Positions are unique, so no two elements compare equal, the order is total, and every conforming qsort() must produce the same arrangement — making stability unnecessary rather than merely unachieved.

With this applied, all four sort implementations in the table above produce the identical dictionary 012b539f….

The reporter of #4185 arrived at the same conclusion independently. Their C++ reimplementation was fast, and they noted in passing: "I had to change the 'tiebreaker' because the pointer value is not passed to the comparator."

Performance

Medians of 5–9 interleaved repetitions, each against a null control of identical binaries. Absolute times are comparable only within a row — MSVC and glibc share a generated 16 MB JSON corpus; Apple libc was measured on a separate 16 MB corpus on different hardware.

platformdcurrentwith fixspeedup
MSVC x86_646458.39 s6.52 s70.3×
8133.43 s6.54 s20.4×
1620.96 s12.19 s1.72×
Apple libc arm6463.697 s3.417 s1.082×
83.757 s3.515 s1.069×
164.993 s4.654 s1.073×
glibc x86_6464.60 s4.68 s0.983×
84.81 s4.90 s0.982×
167.44 s7.54 s0.987×

The effect tracks how quicksort-like each implementation is. glibc's mergesort performs the same comparisons either way, so it only pays for the extra dereference; quicksort-based implementations recover more than they pay.

At the parameters reported in #4185 (d=10), MSVC:

corpuscurrentwith fixspeedup
8 MB20.2 s4.1 s4.9×
16 MB53.9 s7.9 s6.8×
32 MB166.7 s19.8 s8.4×

The speedup widens with corpus size because the current behaviour is superlinear and the fixed behaviour is not: n^1.52 versus n^1.13 at this d. Extrapolating to the 100 MB dataset in the report, a single sort goes from roughly 15 minutes to about a minute; that run used steps=512, which performs many such sorts.

The glibc cost is consistent and does not compound with corpus size (null ±0.33%):

corpuscurrentwith fixcost
4 MB1.251 s1.280 s+2.31%
16 MB5.160 s5.289 s+2.50%
64 MB22.436 s22.860 s+1.89%

Why accept a cost on glibc

glibc produces the correct ordering today, but not by design — only as a coincidence of its qsort. That coincidence already fails on a reachable path. stdlib/qsort.c uses mergesort with a heapsort fallback when its internal allocation fails:

if (!qsort_r_malloc (pbase, total_elems, size, cmp, arg, total_size))
/* Fallback to heapsort in case of memory failure. */heapsort_r (pbase, total_elems-1, size, cmp, arg);

Forcing that allocation to fail (same binary, same input, glibc 2.36):

currentwith fix
mergesorte38748a6…e38748a6…
heapsort fallback03da4bc9…e38748a6…

A dictionary trained under memory pressure silently differs from one trained normally. glibc also replaced this qsort with introsort and reverted it in January 2024; had that landed, Linux would have inherited the nondeterminism silently.

Dictionary output

On glibc taking its mergesort path — the normal case — output is unchanged. Byte-identical across 215 configurations: 20 real corpora × 7 parameter sets on glibc 2.36, plus 5 synthetic content types × 3 sizes × 5 parameter sets on glibc 2.39. Corpus content was digest-verified before and after, and every dictionary hash reproduced in an independent second run.

glibc under memory pressure is the exception: today it falls back to heapsort and produces something different, as shown above. With this fix it produces the mergesort-path result regardless.

On Apple libc and MSVC dictionaries change to what glibc produces on its mergesort path, such that the dictionary produced from a given corpus is identical across runs and does not depend on the platform or architecture. Measured over 420 configurations spanning 20 corpora (source, English and Polish prose, executables, PCM audio, MRI/X-ray, JSON logs, database dumps): mean +0.52%, median +0.26% compression improvement; 271 better, 23 unchanged, 126 worse.

21 of 420 regress by more than 1%, with a median of −1.79%. Those concentrate in two corpora of very small records — ghusers (7 cases, worst −13.9%) and plists (5 cases, worst −2.9%); outside those, no regression exceeds −2.4%. The baseline here was itself nondeterministic, which adds variance to individual comparisons but does not bias the mean.

Verification

make check with DEBUGLEVEL=1; make test; make staticAnalyze (16 findings, identical to those on unmodified dev — same count, checkers and locations, none in cover.c); C90 -Wall -Wextra -Werror -pedantic clean across four DEBUGLEVEL/NDEBUG combinations; deterministic across 6 consecutive runs; output verified identical on glibc/x86_64, MSVC/x86_64 and Apple libc/arm64.

Validated on the full zstd CI suite: 103/103 checks pass, including nine QEMU architectures (ARM, ARM64, M68K, MIPS, PPC, PPC64LE, RISC-V, S390X, SPARC), eight Windows toolchain configurations, and the ASan/UBSan/MSan regression suites.

stableSort() is documented to leave each dmer group ordered by position in
the input, and COVER_group() depends on it: it counts how many samples a dmer
occurs in using a forward-only cursor, so a group whose positions are not
ascending has occurrences silently dropped.
COVER_strict_cmp() tried to provide that ordering by breaking ties on
`lp < rp`. But lp and rp are the addresses of the elements being compared,
not the positions they hold. Those coincide only until qsort() performs its
first swap; afterwards the tie-break orders by where an element currently
sits rather than by what it contains.
The result is a comparator that is not a function of the values it compares,
with three consequences:
- Output is not reproducible. When qsort() compares an element against a
temporary (a pivot copy, which is common), one operand is not in the array
at all, so the comparison is stack-vs-heap and ASLR decides it. The same
binary on the same input produced two different dictionaries in six
consecutive runs.
- Output depends on the C library. glibc happens to satisfy the invariant;
Apple libc and MSVC do not, so they produce different dictionaries from the
same input.
- An inconsistent comparator breaks quicksort's partitioning assumptions.
On MSVC, training on a 16 MB corpus at d=6 takes 465s; with this fix, 8s.
Comparing the stored positions instead makes the key (dmer, position).
Positions are unique, so no two elements compare equal, the order is total,
and every conforming qsort() must produce the same arrangement.
Verified: on glibc the output is unchanged (byte-identical across 215
configurations spanning 20 corpora), and on Apple libc and MSVC the output
now matches what glibc produces.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

--train-cover performance on Windows experiences a critical bottleneck at Constructing partial suffix array

1 participant

@jaypatrickhoward