Skip to content

huf: Tune X1/X2 decoder selection for AArch64 - #4803

Open
kevyang wants to merge 1 commit into
meta:devfrom
kevyang:dev
Open

kevyang wants to merge 1 commit into
meta:devfrom
kevyang:dev

Conversation

@kevyang

@kevyang kevyang commented Sep 29, 2026

Copy link
Copy Markdown

Summary

Add AArch64-specific timing coefficients to HUF_selectDecoder(). The decoder
implementations, selector formula, X2 cache-cost adjustment, and non-AArch64
table are unchanged.

X2 has a higher fixed table-construction cost, but can decode two symbols per
lookup when their combined code length fits in one entry. The break-even point
therefore depends on both the Huffman distribution and the target CPU. Native
measurements showed that the existing coefficients systematically select a
suboptimal decoder for some AArch64 inputs.

Calibration methodology

I benchmarked X1 and X2 independently on the same valid Huffman blocks,
measuring table construction, decoding, and their combined cost. The inputs
were deterministic synthetic symbol distributions designed to cover every
selector quantization bucket. For each distribution, the benchmark exercised
ten output sizes from 256 bytes through 128 KiB and recorded five timing
rounds.

The selector models each decoder as:

tableTime + decode256Time * (dstSize >> 8)

For each quantization bucket and decoder, I first took the median measurement
at each output size, then fitted the intercept and slope of that model. Three
disjoint deterministic seeds were used as separate fitting, gating, and
held-out datasets. A fitted row was retained only when it changed a selector
decision and did not increase measured combined cost on the gating dataset;
otherwise that row kept the existing coefficients.

The initial calibration used three separate 72-core Arm Neoverse V2 systems,
one for each dataset. I then repeated the full three-seed, ten-size, five-round
matrix on a 128-core Arm Neoverse V3AE system, pinned to one CPU, to test
whether one AArch64 table generalized across microarchitectures.

"Selector accuracy" is the fraction of samples for which the model chooses the
decoder with the lower measured combined cost. "Oracle regret" is the excess
cost relative to choosing the faster measured decoder independently for every
sample.

Held-out CPU Default accuracy Candidate accuracy Default oracle regret Candidate oracle regret Selected-cost change
Arm Neoverse V2 83.08% 91.48% 2.103% 0.540% -1.531%
Arm Neoverse V3AE 79.76% 88.40% 1.849% 0.530% -1.295%

A table fitted specifically to the V3AE measurements reduced selected cost by
1.316%, only 0.021 percentage points more than the shared candidate. No
quantization bucket regressed on the V3AE held-out dataset.

The resulting changes are concentrated in two regions: X2 is selected at
smaller block sizes for compressible inputs in Q3-Q9, while X1 remains selected
for weakly compressible inputs in Q12-Q13. Rows without a validated
improvement retain the existing coefficients.

The calibration corpus is synthetic so it can exercise the complete selector
decision surface reproducibly; the results above measure the selector and HUF
table-build/decode work directly rather than an end-to-end application
workload.

Testing

  • make check on the current dev branch
  • Optimized x86-64 and AArch64 builds
  • make test-fuzzer FUZZERTEST=-T10s (349 tests passed)
  • make test-zstream ZSTREAM_TESTTIME=-T10s (807 legacy API and 561 new API tests passed)
  • make test-decodecorpus DECODECORPUS_TESTTIME=-T10 (1,424 tests passed)
  • Replayed identical x86-64 inputs and verified that selector decisions are unchanged

cc @terrelln

Use AArch64-specific timing coefficients for HUF_selectDecoder(). The existing selector formula and all non-AArch64 behavior remain unchanged.

Signed-off-by: Kevin Yang <kjy@fb.com>
@meta-cla meta-cla Bot added the CLA Signed label Sep 29, 2026
Comment on lines +1811 to +1824
{{ 150,216}, { 381,119}}, /* Q == 2 : 12-18% */
{{ 314,167}, { 349,102}}, /* Q == 3 : 18-25% */
{{ 291,167}, { 448,103}}, /* Q == 4 : 25-32% */
{{ 197,194}, { 644,107}}, /* Q == 5 : 32-38% */
{{ 170,167}, { 296,102}}, /* Q == 6 : 38-44% */
{{ 173,167}, { 339,103}}, /* Q == 7 : 44-50% */
{{ 229,169}, { 539,108}}, /* Q == 8 : 50-56% */
{{ 258,168}, { 631,103}}, /* Q == 9 : 56-62% */
{{ 688,187}, {1712,122}}, /* Q ==10 : 62-69% */
{{ 825,186}, {1965,136}}, /* Q ==11 : 69-75% */
{{ 515,169}, {1327,180}}, /* Q ==12 : 75-81% */
{{ 543,169}, { 862,199}}, /* Q ==13 : 81-87% */
{{1377,185}, {1731,202}}, /* Q ==14 : 87-93% */
{{1412,185}, {1695,202}}, /* Q ==15 : 93-99% */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that Q=2,5,10,14,15 are unchanged from the x86 rows. Why is that?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I fitted an intercept/slope for every Q bucket using some synthetic data by measuring X1 and X2 total time on that data. After this, I replayed the selector decisions on separate synthetic datasets as well as the data in https://github.com/terrelln/huffman-sample-files. I then retain a changed row if it changes at least one decision and reduced the measured total HUF cost.

For Q2, Q5, Q11, Q14, and Q15 the decision between X1 and X2 was the same for aarch64 fitted vs. status quo for all of the samples I ran. So for those, I kept the rows unchanged.

For Q10, the fitting procedure produced a Q10 row that overwhelmingly selected X2, but when I validated the row later against some workloads it regressed so I opted to keep that untouched.

Open to suggestions on the tuning methodology!

@terrelln

Copy link
Copy Markdown
Contributor

I'm measuring a slight performance regression on the silesia corpus when benchmarking on a Neoverse-V2 CPU:

$ ./zstd-before -b1e1r --single-thread silesia/
 1# 12 files         : 211938580 ->  73229468 (x2.894),  566.4 MB/s, 1734.1 MB/s     
$ ./zstd-after -b1e1r --single-thread silesia/
 1# 12 files         : 211938580 ->  73229468 (x2.894),  567.9 MB/s, 1725.3 MB/s     

On an M4 chip, I see a slight increase in performance:

$ ./zstd-before -b1e1r --single-thread silesia/
 1# 12 files         : 211938580 ->  73229468 (x2.894),  604.0 MB/s, 1472.3 MB/s
$ ./zstd-after -b1e1r --single-thread silesia/
 1# 12 files         : 211938580 ->  73229468 (x2.894),  602.2 MB/s, 1496.7 MB/s

@kevyang

kevyang commented Sep 30, 2026

Copy link
Copy Markdown
Author

I'm measuring a slight performance regression on the silesia corpus when benchmarking on a Neoverse-V2 CPU:

$ ./zstd-before -b1e1r --single-thread silesia/
 1# 12 files         : 211938580 ->  73229468 (x2.894),  566.4 MB/s, 1734.1 MB/s     
$ ./zstd-after -b1e1r --single-thread silesia/
 1# 12 files         : 211938580 ->  73229468 (x2.894),  567.9 MB/s, 1725.3 MB/s     

On an M4 chip, I see a slight increase in performance:

$ ./zstd-before -b1e1r --single-thread silesia/
 1# 12 files         : 211938580 ->  73229468 (x2.894),  604.0 MB/s, 1472.3 MB/s
$ ./zstd-after -b1e1r --single-thread silesia/
 1# 12 files         : 211938580 ->  73229468 (x2.894),  602.2 MB/s, 1496.7 MB/s

Hmm, I was not able to reproduce this but let me run a few more times and look into this

@terrelln

terrelln commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

I'm going on vacation for October, so @Cyan4973 will have to take over the review

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.

2 participants