Vectorize small-offset overlapping match copies with NEON tbl / SSSE3 pshufb - #4718
Vectorize small-offset overlapping match copies with NEON tbl / SSSE3 pshufb#4718thevar1able wants to merge 4 commits into
tbl / SSSE3 pshufb#4718Conversation
For small match offsets (overlap period < 16 bytes), `ZSTD_wildcopy` falls back to an 8-byte-per-iteration `COPY8` loop. On AArch64 this dominates decompression of integer and low-cardinality columns, whose matches frequently have offsets of 4 or 8 bytes (e.g. repeated fixed-width values). Build the repeating pattern once in a NEON register and store 16 bytes per iteration, advancing the pattern with a single `vqtbl1q_u8` table lookup and no load inside the loop. The scalar `COPY8` path is kept for other targets. On Graviton 4 (Neoverse-V2), ZSTD level 1 decompression of ClickBench `hits` columns speeds up: UserID 2.30x, AdvEngineID 1.46x, ClientIP 1.42x, RegionID 1.27x; string and incompressible columns are unchanged. Verified byte-exact and clean under ASan and UBSan. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> (cherry picked from commit 1ced1f2)
The x86_64 counterpart of the NEON tbl path, using the same adv index table: build the repeating pattern once in an SSE register and store 16 bytes per iteration, advancing the pattern with a single _mm_shuffle_epi8 (pshufb) and no load inside the loop. Gated behind compile-time SSSE3 detection (__SSSE3__). Also reworks the pattern seeding for both arches: the first 16 bytes are copied with COPY8, exactly like the first two iterations of the scalar loop, and the pattern register is then loaded from the just-written output. Seeding with a 16-byte load at ip (the previous approach, via an init table) reads bytes of dst that have not been written yet, which MemorySanitizer rejects even though the table lookup never selects them. This removes the init table; only adv remains. Squash of ClickHouse/zstd commits ba909e8, 7de9d6e, fc89989. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
vqtbl1q_u8 is an A64-only intrinsic: 32-bit ARM NEON provides only vtbl, which can look up in a 64-bit table. ZSTD_ARCH_ARM_NEON is defined for ARMv7 NEON as well (__ARM_NEON), so the path as previously gated fails to compile there. Require __aarch64__ or _M_ARM64 in addition; 32-bit ARM keeps the scalar COPY8 loop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ZSTD_wildcopy is force-inlined into the sequence execution loops many times over, and expanding the vector path in place enlarges the hot decoder functions enough to measurably regress inputs that rarely take the small-offset path: on x86_64/clang, silesia.tar decompression loses ~2.5% with the inline version even though profiling shows the new instructions themselves are never hot there (the cost is code growth: +7% L1i misses in ZSTD_decompressSequences_bmi2). As a standalone FORCE_NOINLINE function (ZSTD_overlapCopyShortOffset) the regression disappears while the columnar-data speedup is retained; pattern copies long enough to matter amortize the call: x86_64 decompression, best-of interleaved runs (clang 21 / gcc 16): silesia.tar lvl 1: 1.002x / 1.001x lvl 3: 0.997x / 1.008x int64 run-heavy lvl 1: 1.174x / 1.157x lvl 3: 1.234x / 1.212x int64 small-delta lvl 1: 1.087x / 1.116x lvl 3: 1.090x / 1.116x int16/int32 mixed within noise (0.995x-1.006x) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Hi @thevar1able! Thank you for your pull request and welcome to our community. Action RequiredIn order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you. ProcessIn order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA. Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks! |
Summary
For small match offsets (overlap period < 16 bytes),
ZSTD_wildcopyfalls back to an 8-byte-per-iterationCOPY8loop whose load hits the previous iteration's store, so it runs at store-forwarding latency. This loop dominates decompression of integer and low-cardinality columnar data, whose matches frequently have offsets of 4 or 8 bytes (runs of repeated fixed-width values) — a very common shape for column-oriented storage. We hit this in ClickHouse (ClickHouse/ClickHouse#108049).This PR materializes one full period of the repeating pattern in a vector register and stores 16 bytes per iteration, advancing the pattern with a single table-lookup instruction —
tbl(vqtbl1q_u8) on AArch64,pshufb(_mm_shuffle_epi8) on x86_64 with SSSE3 — and no load inside the loop:The emitted AArch64 loop is 4 instructions:
tbl/str q,[x],#16/cmp/b.hi.Design notes:
FORCE_NOINLINEfunction (ZSTD_overlapCopyShortOffset), not inlined intoZSTD_wildcopy.wildcopyis force-inlined into the sequence-execution loops many times over; an earlier version of this change expanded the vector path in place and regressed silesia.tar decompression by ~2.5% with clang on x86_64 purely through code growth in the hot decoder functions (profiling showed the new instructions themselves were cold on silesia). As a standalone function the regression disappears, and the aarch64 binary shrinks by 65 KB. The out-of-line call is cheaper than the scalar loop it replaces even at minimum trip count (see the timestamp-column result below).COPY8, exactly like the first two iterations of the scalar loop. This seeds the pattern register from initialized memory: a 16-byte load atipwould touch not-yet-written bytes ofdst(MemorySanitizer rejects that, even though the shuffle never selects those lanes).op >= oend, overrunning the logical end by at most 15 bytes — within the existingWILDCOPY_OVERLENGTH(32) slack, enforced with aZSTD_STATIC_ASSERT.diffis always in [8, 16) here (callers expand smaller offsets viaZSTD_overlapCopy8first; the scalarCOPY8loop relies on the same contract).__SSSE3__(baseline x86-64 builds keep the scalar loop; x86-64-v2/v3 distro builds and-march=nativeget the new path). The NEON path is gated on AArch64 (vqtbl1q_u8does not exist in 32-bit ARM NEON, whosevtblcan only look up in a 64-bit table); verified that an ARMv7 NEON build compiles and falls back to scalar.ZSTD_wildcopywithZSTD_overlap_src_before_dst. Compressed output is byte-identical with and without the patch.Prior art
The wildcopy family has been optimized several times — #1668, #1804, #1977, #2256, and #3145 (Arm's two-stage copy) — but all of these target the non-overlapping (offset >= 16) fast path. The small-offset overlap path has remained the scalar
COPY8loop since #1804 gave it its current shape. We found no previous issue/PR proposing a shuffle-based pattern-advance for it. (LZ4 special-cases small offsets inLZ4_memcpy_using_offset, but via scalar pattern expansion.)Benchmarks
Methodology per CONTRIBUTING.md:
zstd -b# -i3+, interleaved A/B runs pinned to one core (taskset), 5-10 repetitions, best-of aggregation, warmup pass. Per-rep spread 0.06–0.92%.x86_64 — AMD Ryzen 9 9950X3D, clang 21.1.8 / gcc 16.1.1,
-O3 -mssse3Decompression (MB/s):
AArch64 — ARM Neoverse-N1 (Ampere Altra, 2.6 GHz), gcc 14.2.0,
-O3Decompression (MB/s), best-of-5:
A clang 20.1.8 A/B corroborates: no silesia regression (its apparent +2-3% is uniform across control cases, i.e. a code-layout artifact, not claimed as a gain). Earlier measurements of the inline predecessor on Graviton 4 (Neoverse-V2) with real ClickBench
hitscolumns: UserID (Int64) 2.30x, AdvEngineID (Int16) 1.46x, ClientIP (Int32) 1.42x, RegionID (Int32) 1.27x.Correctness
make checkpasses on x86_64 (-mssse3) and aarch64.8 <= diff < 16cases the new code handles.libzstd.afor aarch64 (gcc) and 32-bit ARMv7 with NEON enabled (clang/zig) — the latter as a regression check for the compile-time gate.