Skip to content

dictBuilder: speed up the COVER dmer sort with a multi-key quicksort - #4928

Open
jaypatrickhoward wants to merge 2 commits into
meta:devfrom
jaypatrickhoward:cover-mkq-sort
Open

jaypatrickhoward wants to merge 2 commits into
meta:devfrom
jaypatrickhoward:cover-mkq-sort

Conversation

@jaypatrickhoward

Copy link
Copy Markdown
Contributor

What and why

stableSort() reaches qsort() through a five-variant selection block (ZDICT_QSORT_APPLE/GNU/MSVC/C11/C90) choosing between qsort_r(), qsort_s() and a non-reentrant global, plus the _GNU_SOURCE and __STDC_WANT_LIB_EXT1__ feature tests needed to expose them.

This replaces that with a multi-key quicksort over the dmers. Dictionaries are byte-identical everywhere: this is a pure performance and simplification change.

Checklist

Tools used

Claude Code, under my direction and review, for writing the code and the test, and for benchmarking.


Why a different algorithm

The dmers being sorted are fixed-length byte strings that share long prefixes, and a comparison sort restarts every comparison at byte 0. Quicksort compares many elements against one fixed pivot, so deep in the recursion it re-walks the prefix those elements share with that pivot, repeatedly.

A multi-key quicksort (Bentley & Sedgewick) partitions on a single dmer byte and recurses into the equal bucket one byte deeper, so a byte already proven equal is never examined again.

Alternatives considered

Ten algorithms were benchmarked against the same workload before settling on this one. Measurements below come from a standalone harness reproducing the same comparator and data, so they are indicative rather than the shipping numbers quoted later.

algorithm why not
introsort, median-of-3 quicksort, pdqsort, 3-way (Bentley–McIlroy) quicksort All beat qsort at small d but collapse to ~0.5× at large d. They restart each comparison at byte 0, so they re-walk shared prefixes — the same defect that makes the current code slow, not a fix for it.
heapsort, bottom-up heapsort, shellsort, comb sort Never beat qsort at any d (0.44–0.80×). Ruled out immediately.
American flag sort (in-place MSD radix) Fastest in many cells, but each frame holds three 256-entry tables (~6 KB) and it segfaulted on a 45 MB corpus at d=112640 from stack exhaustion. Making it safe required a depth guard that falls back to multi-key quicksort anyway.
LSD radix sort Faster still, but allocates (a 256 KB counter table plus scratch) and is O(n·d) with no early exit — 0.04× at d=4096.
in-place stable mergesort (grail/wikisort) Stable and allocation-free, but substantially more code and worse constants than the alternatives above.
Timsort, and glibc-style mergesort Allocate.

Three refinements to the chosen algorithm were also tested and rejected: 16-bit digits instead of 8 (4–12% slower — a wider digit shrinks the equal bucket, which is where this algorithm earns its advantage), an "already ascending" pre-check before the position sort (neutral), and an all-equal fast path (neutral, and subsumed by the loop-on-largest recursion described under Safety).

Dictionaries are unchanged

Byte-identical to dev in 252 of 252 configurations: 6 platforms × 7 d values (6, 8, 12, 16, 32, 1024, 112640) × 6 corpus sizes (1, 4, 8, 12, 16, 32 MB).

The test added in #4765, which pins the exact dictionary bytes, passes. This PR also extends it from d = 8 and 16 to d = 6, the path for dmers shorter than 8 bytes; the new expected hash comes from dev's qsort-based code.

The end-to-end runs below add 70 more identical results, including musl, FreeBSD and MinGW-w64.

Also identical under QEMU on 9 architectures (ARM, ARM64, PPC, PPC64LE, S390X, MIPS, RISC-V, M68K, SPARC) at d = 6, 8, 9 and 16: 36 of 36, covering five big-endian targets (PPC, S390X, M68K, SPARC, MIPS) and both 32- and 64-bit word sizes. Every target produced not merely a matching dictionary but the same one.

Byte order follows each comparator: COVER_cmp8() compares a masked little-endian U64, making byte d-1 most significant, while COVER_cmp() uses memcmp(), making byte 0 most significant. MEM_readLE64() byte-swaps on big-endian hosts, so this holds on every architecture.

Performance

How it was measured

dev (6e51a24) and this PR were built side by side on each machine, by the project's own build system with its default release flags, and run alternately on the same data. Every machine is a GitHub-hosted runner except one, marked local.

platform runner CPU OS / libc compiler
Linux arm64, glibc ubuntu-24.04-arm Neoverse-N2 Ubuntu 24.04 gcc 13.3
Linux x86-64, glibc ubuntu-latest AMD EPYC 7763 Ubuntu 26.04 gcc 15.2
macOS arm64 macos-latest Apple M2 Pro (virtual) macOS 26.7 Apple clang 21.0
macOS arm64, local my laptop, on AC power and otherwise idle Apple M3 Pro (6P + 6E cores) macOS 27.2 Apple clang 21.0
macOS x86-64 macos-15-intel Intel Core i7-8700B macOS 15.7 Apple clang 17.0
Windows arm64, MSVC windows-11-arm Cobalt 100 Windows 11 MSVC 19.51
Windows x86-64, MSVC windows-latest sort: AMD EPYC 7763; end to end: Intel Xeon Platinum 8573C Windows Server 2025 MSVC 19.51
Windows x86-64, MinGW-w64 windows-latest AMD EPYC 7763 Windows Server 2025 gcc 16.2 (MSYS2)
FreeBSD x86-64 VM on ubuntu-latest AMD EPYC 9V74 FreeBSD 15.1 clang 19.1
Linux arm64, musl Alpine 3.22 on ubuntu-24.04-arm Neoverse-N2 musl 1.2.5 gcc 14.2
Linux x86-64, musl Alpine 3.22 on ubuntu-24.04 AMD EPYC 9V74 musl 1.2.5 gcc 14.2

Each runner has 4 vCPUs, except macOS arm64 with 5. The Linux glibc, macOS x86-64 and Windows MSVC rows ran both the sort-phase and the end-to-end benchmarks. The macOS arm64 runner ran the sort-phase benchmark only: GitHub had no macOS arm64 runner available for the end-to-end job, so that job ran on my laptop instead, with the same script, commits and timer patch. The last four rows ran end to end only.

  • Flags: make zstd with the Makefile's defaults (-O3, plus its warning and feature flags) for gcc and clang; cmake --config Release (/O2 /Ob2 /MD) for MSVC. Each job logs the exact command line that compiled cover.c.
  • Data: a synthetic corpus of 4 KB JSON-like records from a fixed-seed generator, so every platform trains on identical bytes, at 1, 4, 8, 12, 16 and 32 MB; end to end, also zstd's own github regression dataset (500 JSON files, 408 KB).
  • Runs: each sort-phase number is the median of 3 runs; each end-to-end number is the median of 2–5.
  • Timer: the sort phase is clock() around stableSort(), inserted by a benchmark-only patch applied identically to both builds; end to end is whole-process wall time.
  • Harness and logs: the workflows, the timer patch and the corpus generator are on the bench-e2e branch of my fork, at ab29a0feb; the job logs, with the exact compiler command lines, are in runs 43083049529 (sort) and 43083056295 (end to end).

The sort

Sort-phase time only. Each platform has 42 (corpus size, d) configurations, each the median of 3 runs; the columns are the worst, median and best of those 42.

platform worst median best
Linux arm64, glibc 1.05× 1.51× 1.69×
Linux x86-64, glibc 1.23× 1.98× 2.29×
macOS arm64 1.84× 2.22× 2.63×
Windows arm64, MSVC 1.86× 2.24× 3.16×
Windows x86-64, MSVC 2.30× 2.43× 3.21×
macOS x86-64 2.67× 4.26× 6.19×

Across all 252 configurations (6 platforms × 7 d values × 6 corpus sizes) the minimum is 1.05×. There is no platform, corpus size or d value where the sort is slower.

Restricted to the parameters most users actually hit (d ∈ {6, 8, 12, 16}, corpora 1–16 MB), per-platform medians are 1.31× / 1.75× / 2.36× / 2.35× / 2.66× / 4.19× in the row order above.

The spread tracks the quality of each platform's qsort. glibc's mergesort handles this workload well, so the gain there is smallest. Apple's libc (whose qsort comes from FreeBSD) and MSVC are quicksort-based and pay the repeated-prefix cost, as does FreeBSD itself; musl's smoothsort pays it most of all (both end to end, below).

End to end

Whole-process zstd --train-cover time. The fixed-k/d column covers four synthetic cases (8 and 32 MB, d = 8 and 16, k = 2048) plus the github dataset (k = 1024, d = 8, 16 KB dictionary); the parameter search is the default --train-cover on the 8 MB corpus and on github.

platform sort fixed k/d, end to end sort's share of dev's runtime parameter search, end to end
Linux arm64, glibc 1.03–1.43× 1.02–1.16× 39–61% 1.01×
Linux x86-64, glibc 1.19–2.19× 1.11–1.41× 54–66% 1.00×
macOS arm64, local 2.09–2.44× 1.25–1.70× 48–71% 1.10–1.12×
macOS x86-64 3.19–5.62× 1.96–2.21× 65–74% 1.08–1.19×
Windows arm64, MSVC 2.00–2.63× 1.35–1.89× 51–76% 1.04×
Windows x86-64, MSVC 2.46–4.06× 1.69–2.15× 63–73% 1.04–1.05×
Windows x86-64, MinGW-w64 1.92–4.00× 1.35–2.06× 52–72% 1.02–1.04×
FreeBSD x86-64 2.00–2.97× 1.57–1.72× 60–75% 1.03–1.04×
Linux arm64, musl (Alpine) 4.24–12.02× 2.79–5.30× 83–92% 1.13–1.20×
Linux x86-64, musl (Alpine) 8.72–14.34× 5.12–6.96× 87–94% 1.27–1.34×

With fixed parameters the sort is most of the work, so most of its gain reaches the user. The parameter search trains once per candidate k but sorts only once per d, so the sort is a small share of its runtime and the end-to-end gain is correspondingly small.

Safety

Recursion depth is bounded by log₂(n), independent of d or input. The recursion descends into the two smaller spans and loops on the largest; if L is the largest span and A either other, then A ≤ L and A + L ≤ n, so A ≤ n/2.

Measured maximum depth across d ∈ {6, 8, 16, 1024}, on 4 MB corpora chosen to stress it unless noted:

corpus depth
all-identical bytes 1
ab repeated 2
long repeated runs 7
one 2 KB prefix shared by every 4 KB record 13
random bytes 14
natural text, 8 MB (this repository's sources) 14
the same text four times over, 32 MB 15
random two-symbol alphabet 18

Depth 1 on the all-identical corpus is the loop-on-largest rule at work: the equal bucket is the whole range, so it is looped rather than recursed. The random two-symbol corpus is the deepest because every byte splits each range roughly in half, so recursion continues until ranges reach the insertion-sort cutoff: log₂(4 M / 24) ≈ 17.4.

Stack frame size, read from the function prologue on each ABI:

platform frame
MSVC x64 80 B
clang macOS x86_64 96 B
clang macOS arm64 / MSVC arm64 / gcc x86_64 112 B
gcc arm64 144 B
gcc ppc64le 176 B
gcc s390x 200 B

COVER_MAX_SAMPLES_SIZE caps n at ~4.29 G, so depth ≤ log₂(4.29 G) ≈ 32. Worst case is therefore 32 frames × 200 B (the largest frame measured) ≈ 6.4 KB of stack; the deepest measured, 18 frames, is ≈ 3.6 KB. The sort allocates nothing.

Code size

gcc 13.3 on x86-64 (Ubuntu 24.04), make -C lib with its defaults, and with CFLAGS=-Os for the last row:

dev this PR delta
libzstd.a, stripped 879,466 880,666 +1,200 B (+0.14%)
libzstd.so .text 776,134 777,318 +1,184 B (+0.15%)
cover.o .text 11,409 12,545 +1,136 B
cover.o .text at -Os 6,912 7,615 +703 B

Builds without ZSTD_LIB_DICTBUILDER are unaffected. The libzstd-size CI check is unaffected too: it limits the file size of libzstd.so, and that file is 96 bytes smaller (913,544 → 913,448, same compiler); its other three configurations are the same size or smaller.

Portability and thread safety

The dispatch exists because qsort_r is not portable, and that has caused repeated problems:

The C90 fallback is wider than it looks: it is where every platform the selection block does not name ends up, which includes FreeBSD and the other BSDs, MinGW-w64, Android, Solaris and musl builds with the override. The C11 branch would not rescue any of them, because it tests STDC_LIB_EXT1 rather than __STDC_LIB_EXT1__ and so is never selected automatically.

Two open PRs extend the detection further: #4468 enables qsort_r() on more systems, and #4886 declares it by hand for OpenHarmony. With no qsort_r() call left, neither is needed.

These are one root cause. Sorting the dmers directly removes it: no qsort_r, nothing to detect, no manual override, and no global — ZDICT_trainFromBuffer_cover() becomes thread-safe on every platform. The musl-build job (Ubuntu 22.04, musl 1.2.2) drops its override, and the ZDICT_QSORT entry comes out of lib/README.md. Reproduced in an Ubuntu 22.04 container: dev without the override fails there with implicit declaration of function 'qsort_r', and this branch passes the job's exact command.

Consistency and simplification

Sort behavior is now defined by zstd rather than inherited from whichever qsort the platform ships, so training speed no longer depends on the C library. On arm64 runners with the same CPU (Neoverse-N2), training on the 8 MB corpus at d=8 took 1.7 s with glibc and 6.4 s with musl before this change, and 1.6 s and 1.5 s after. The code also no longer needs to know which of five qsort spellings is available.

Removed: the ZDICT_QSORT_* selection block, the qsort_r/qsort_s/qsort dispatch, the non-reentrant g_coverCtx global, both COVER_strict_cmp wrappers, and the _GNU_SOURCE/__STDC_WANT_LIB_EXT1__ feature tests — 42 lines of platform-conditional code, for a net of +29 lines in cover.c.

A second commit removes two leftovers of the qsort_r dispatch from shared headers: the _GNU_SOURCE define in lib/common/zstd_deps.h, whose comment says it is there only to expose qsort_r() to the dictionary builder, and the WIN_CDECL macro in lib/common/compiler.h, which marked the comparators passed to MSVC's qsort_s() and now has no users. This is cleanup only: dropping that commit gives up none of the speed, portability or thread-safety benefits above, so it can be left out if you would rather not touch those headers.

Validation

make check and make test pass. A deliberately broken d < 8 comparison passes the test's original two cases but fails the new d = 6 case. cover.c compiles cleanly as C90 with -pedantic -Werror and the library's full warning set, at four DEBUGLEVEL/NDEBUG settings and at both -O0 and -O3. ASan/UBSan are clean at d ∈ {1, 6, 7, 8, 9, 15, 16, 17, 32, 1024, 112640}, with shrink, with a split point and on the multithreaded optimize path, and every dictionary matches dev's. MemorySanitizer reports no uninitialized reads, running CI's three MSan jobs (fuzzer unit tests, fuzzing and the CLI tests) and COVER training at nine d values plus the parameter search.

ThreadSanitizer, for #4045: eight threads training at once (d=8 and d=16, each on its own data, three rounds) report no races, and every dictionary matches the same training run done alone. The same test against dev built for its C90 path (-DZDICT_QSORT=ZDICT_QSORT_C90) reports a race on g_coverCtx and gets 21 of 24 dictionaries wrong.

make staticAnalyze (clang 23) reports no bugs, on this branch or on dev.

stableSort() reached qsort() through a five-variant selection block
(ZDICT_QSORT_APPLE/GNU/MSVC/C11/C90) that picked between qsort_r(),
qsort_s() and a non-reentrant global, plus the _GNU_SOURCE and
__STDC_WANT_LIB_EXT1__ feature tests needed to expose them.

Sort the dmers directly instead, with a multi-key quicksort (Bentley &
Sedgewick): partition on a single dmer byte and recurse into the equal
bucket one byte deeper. A byte proven equal is never examined again,
which matters here because dmers are heavily duplicated and a comparison
sort restarts every comparison at byte 0.

The order produced is the same total order as before -- by dmer, then by
position -- so dictionaries are unchanged. Byte order follows each
comparator: COVER_cmp8() compares a masked little-endian U64, making byte
d-1 most significant, while COVER_cmp() uses memcmp(), making byte 0 most
significant. MEM_readLE64() byte-swaps on big-endian hosts, so this holds
on every architecture.

Recursion descends into the two smaller spans and loops on the largest.
If L is the largest span and A is either other, A <= L and A + L <= n, so
A <= n/2 and depth is bounded by log2(n) regardless of d or input shape.
Measured maximum depth is 18 across corpora designed to stress it.

The sort is in-place and allocates nothing, the comparison is inlined
rather than reached through a function pointer, and the platform
dispatch and its global are removed.

With no qsort_r() left to detect, the ZDICT_QSORT build macro goes too:
drop its entry from lib/README.md, and its override from the musl CI job,
whose musl 1.2.2 has no qsort_r() and now builds without the override.

Extend the existing COVER ordering test to d=6, where dmers shorter than
8 bytes are compared as a masked 8-byte word; that path had no test. Its
expected hash comes from the qsort()-based code this replaces.
zstd_deps.h defined _GNU_SOURCE on Linux, Cygwin and MSYS solely to expose
qsort_r() to the dictionary builder, as its comment said. compiler.h defined
WIN_CDECL for the comparators the dictionary builder passed to MSVC's
qsort_s(). The COVER sort no longer calls either, nothing else uses them,
and an internal header should not opt every file that includes it into GNU
extensions.

Cleanup only: the previous commit does not depend on this one.
@meta-cla meta-cla Bot added the CLA Signed label Nov 11, 2026

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.

cover.c does not build on OpenHarmony: qsort_r is exported but not declared (musl)

1 participant