Skip to content

Replace the nightly simd feature with runtime-dispatched SIMD, and vectorize more of the encoder - #262

Open
Mnwa wants to merge 6 commits into
dropbox:masterfrom
Mnwa:fearless-simd
Open

Mnwa wants to merge 6 commits into
dropbox:masterfrom
Mnwa:fearless-simd

Conversation

@Mnwa

@Mnwa Mnwa commented Aug 11, 2026

Copy link
Copy Markdown

Summary

The simd feature requires nightly (#![feature(portable_simd)]), so the vectorized paths
ship to nobody on stable, and src/enc/compat.rs exists only to hand-write a scalar twin for
every vector op — two implementations to keep in sync.

This replaces that with fearless_simd: runtime
instruction-set detection and dispatch, on stable Rust, with no unsafe. One
implementation covers every target — AVX2 hardware takes AVX2, aarch64 takes NEON, everything
else falls back to scalar, from the same binary. With that in place it vectorizes the encoder
stages that profiling shows actually dominate, and drops a redundant histogram copy in
clustering.

Compressed output does not change. Every optimization is bit-identical to the scalar code
it replaces.

What changed

SIMD backend

  • Removed the simd feature and the nightly gate; vectorization is now unconditional on stable.
  • Deleted src/enc/compat.rs (392 lines of scalar shim).
  • s16 / v8 / s8 become Mem16x16 / Mem256f / Mem256i in enc::vectorization — plain
    Copy + Default arrays, so they still live in the allocator-backed slices; arithmetic happens
    on fearless_simd registers.
  • detect_level() caches the detected level in a OnceLock, and dispatch! regions are
    hoisted to the outermost loop that can own them (a whole Zopfli block, a whole match-finder
    walk) so detection isn't paid per comparison.
  • no_std still builds, via fearless_simd's libm feature.

Newly vectorized paths

  • H10 binary-tree match finder (hash_to_binary_tree.rs).
  • Zopfli shortest path (hq.rs): UpdateNodes, FindAllMatchesH10, ZopfliIterate and the
    per-position sweep.
  • Static-dictionary match length (static_dict.rs): short matches stay scalar and never touch
    the CPU feature set; only long matches pay for detection and switch to 32-byte compares.
  • Block splitter (block_splitter.rs): FindBlocks keeps a per-lane running winner and reduces
    once per row. Ties still resolve to the lowest histogram id.
  • Population cost (bit_cost.rs): eight buckets per compare, per-bucket work only for populated
    ones — histograms are mostly empty, which is what the wide compare is for.

Histogram clustering no longer materializes sums

BrotliHistogramBitCostDistance and the clustering pair queue used to clone a histogram, run
HistogramAddHistogram, then cost the result — and between them they are nearly every
population-cost call in the encoder. BrotliPopulationCostOfSum costs the histogram that sum
would produce, letting the cost walk do the adding. Same values in the same order, so the
float accumulation is unchanged; it just skips a copy and an add pass.

Optional profiling — an optional hotpath layer behind
hotpath / hotpath-cpu / hotpath-alloc. Every call site is a cfg_attr, so a default build
neither links it nor pays for it. Happy to drop this or split it into its own PR.

Verification

Encoder output diffed against the merge-base over 60 (corpus, quality) pairs
alice29.txt, asyoulik.txt, random_org_10k.bin, monkey × qualities 0–11 including 9.5,
9.5x, 9.5y, all -w22. Every pair byte-identical. Existing 100-test suite passes unchanged.

Repeated on a second instruction set: the same 60 pairs cross-compiled to x86_64-apple-darwin
and run under Rosetta (SSE4.2). Also byte-identical, and identical to the aarch64 output — so
the NEON path, the SSE4.2 path and the original scalar code all agree byte for byte.

AVX2 and AVX-512 are not verified. I have no x86 hardware and Rosetta caps at SSE4.2. Same
dispatch!, same source, so I expect them to agree — but I haven't checked. If CI can run the
diff on an AVX2 runner before merging, that closes the gap; I'm glad to add that job.

Performance

3.1 MB varied corpus, Apple M5 Pro, release + LTO, best of three:

quality base this branch change
q9 0.18 s 0.16 s −11%
q10 0.82 s 0.70 s −15%
q11 1.93 s 1.77 s −8%

NEON numbers, one machine, one corpus; no x86 measurements at all. Indication of scale, not a
promise.

Breaking changes — your call

  1. MSRV 1.59 → 1.89, required by fearless_simd (edition 2024 needs 1.85 independently).
    This is the big one. If it's a blocker, the alternative is a default-on feature with a scalar
    fallback — which brings back the two-implementations problem compat.rs had. I didn't want
    to reintroduce that without asking.
  2. Edition 2015 → 2024, mechanical but most of the line count, mainly the FFI layer where
    unsafe blocks inside unsafe fn must now be explicit. Isolated in its own commit.
  3. simd feature removed, not deprecated — --features simd becomes an error. I can leave
    it as an accepted no-op instead.
  4. New unconditional dependency on fearless_simd ~0.6. no_std-capable, no unsafe in
    the surface this crate uses, but it's a 0.x crate and that's a real call to make.
  5. sha2 ~0.10~0.11 (only reachable via the existing validation feature).

@CLAassistant

CLAassistant commented Aug 11, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

Comment thread Cargo.toml
edition = "2015"
rust-version = "1.59.0"
# Bounded by `fearless_simd`, which the encoder's vectorized paths use unconditionally.
rust-version = "1.89.0"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

is there an older version of fearless_simd we could use with an older rust, or could we only have it select this rust version if the fearless_simd feature is selected?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants