Skip to content

ORE v2 (4/n): NEON + AVX2 SIMD backends - #81

Merged
coderdan merged 3 commits into
feat/ore-v2-efficient-encodingfrom
feat/ore-v2-simd
Oct 12, 2026
Merged

coderdan merged 3 commits into
feat/ore-v2-efficient-encodingfrom
feat/ore-v2-simd

Conversation

@coderdan

Copy link
Copy Markdown
Contributor

Stacked on #80. Plan §3 (docs/plans/2026-06-12-ore-v2-architecture.md).

What

primitives::simd with the two data-parallel kernels of right-ciphertext encoding, selected by a spike benchmarked on M1 Max (kernels verified byte-identical over 10k randomized inputs before lifting):

Kernel NEON AVX2 scalar fallback
indicator mask (table[j] > x → bitmask) vcgtq_u8 + bitpos-AND + vaddv_u8 (8.3 ns/256 lanes) vpcmpgtb (0x80-biased) + vpmovmskb branch-free 8-per-byte
hash-LSB mask (stride-16 gather) ld1q_x4 + constant-index vqtbl4q (41.8 ns/256) — (scalar; no win without AVX-512 VBMI) branch-free 8-per-byte

Dispatch: compile-time NEON on aarch64, runtime-detected AVX2 on x86_64 (is_x86_feature_detected!, cached by std), scalar elsewhere. No cargo feature — one build, tests cover the dispatched and scalar paths plus the per-bit reference.

Constant-time

Every path has fixed trip counts, no data-dependent branches, and constant tbl indices; documented as a module invariant.

Honest numbers

~0.5% over PR 3 at Bit8 on M1 Max — PRP construction now dominates that scheme (see #80). These kernels are sized for Bit6 (PR 5), where per-block AES work drops 4× and the 64-lane indicator variant (2.7 ns) carries the encoder. Landing them separately keeps PR 5 reviewable.

Verification notes for review

  • AVX2 path is verified by CI on this PR (ubuntu x86_64 runners have AVX2; the gt_mask_dispatched_matches_scalar test exercises it). It could not be run locally (aarch64 host, no rustup x86_64 std).
  • NEON path is verified locally end-to-end by the PR 1 byte vectors.
  • Spike notes (incl. why shrn and ld4+uzp1 lost on M1, and the Neoverse re-bench caveat) preserved in the commit message and /tmp/ore-neon-spike/RESULTS.md.

coderdan added a commit that referenced this pull request Jun 16, 2026
…view)

Code-review follow-ups on the SIMD PR:

- lsb_mask_256: promote the blocks.len()==256 / out.len()==32 debug_asserts to
  real assert_eq!. The NEON path gathers `blocks` via raw pointers assuming
  exactly 256 blocks, so a shorter slice would read out of bounds (UB) in a
  release build; the assert enforces the unsafe precondition at the boundary
  rather than relying on the single caller's length check.
- Replace the blanket #[allow(unreachable_code)] on both dispatchers' scalar
  tails with #[cfg(not(target_arch = "aarch64"))], and drop the now-redundant
  aarch64 return. Paths are compile-time mutually exclusive, so a future bad
  early-return surfaces as a real diagnostic instead of being silently masked.
- Scope scalar::gt_mask_xor_256 to cfg(any(not(aarch64), test)) so it isn't
  dead code on aarch64 (only reached via the non-aarch64 dispatcher or tests);
  no allow needed. scalar::lsb_mask stays always-compiled (hash.rs uses it
  directly for non-256 inputs).

AVX2 pre-merge test coverage gap tracked in #87. No wire change (compat vectors
byte-identical).
@coderdan
coderdan marked this pull request as ready for review October 8, 2026 08:50
@coderdan
coderdan requested a balanced review from Copilot October 8, 2026 10:05

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Release validation can silently truncate masks, and the new bit setter cannot clear previously set bits.

3 open findings
What changed in this PR

Adds NEON and AVX2 acceleration for right-ciphertext encoding while retaining scalar fallbacks.

Changes:

  • Adds SIMD indicator-mask and hash-LSB kernels.
  • Dispatches by architecture and CPU capabilities.
  • Adds randomized backend-equivalence tests.
File Description
primitives/​simd.rs Implements SIMD kernels, dispatch, fallbacks, and tests.
primitives/​hash.rs Uses vectorized LSB packing for 256 blocks.
primitives/​prp.rs Uses vectorized indicator-mask construction.
primitives.rs Registers SIMD module and updates contracts.
scheme/​width.rs Adds width-generic bit-setting support.

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

Comment thread packages/ore-rs/src/primitives/hash.rs Outdated
Comment thread packages/ore-rs/src/primitives/simd.rs Outdated
Comment thread packages/ore-rs/src/scheme/width.rs Outdated
@coderdan
coderdan requested a review from freshtonic October 8, 2026 23:41
coderdan added a commit that referenced this pull request Oct 9, 2026
…view)

Code-review follow-ups on the SIMD PR:

- lsb_mask_256: promote the blocks.len()==256 / out.len()==32 debug_asserts to
  real assert_eq!. The NEON path gathers `blocks` via raw pointers assuming
  exactly 256 blocks, so a shorter slice would read out of bounds (UB) in a
  release build; the assert enforces the unsafe precondition at the boundary
  rather than relying on the single caller's length check.
- Replace the blanket #[allow(unreachable_code)] on both dispatchers' scalar
  tails with #[cfg(not(target_arch = "aarch64"))], and drop the now-redundant
  aarch64 return. Paths are compile-time mutually exclusive, so a future bad
  early-return surfaces as a real diagnostic instead of being silently masked.
- Scope scalar::gt_mask_xor_256 to cfg(any(not(aarch64), test)) so it isn't
  dead code on aarch64 (only reached via the non-aarch64 dispatcher or tests);
  no allow needed. scalar::lsb_mask stays always-compiled (hash.rs uses it
  directly for non-256 inputs).

AVX2 pre-merge test coverage gap tracked in #87. No wire change (compat vectors
byte-identical).
coderdan added a commit that referenced this pull request Oct 9, 2026
Review of #81:
- Removing pack_bits_lsb_first (68ec6d0) dropped its release-mode length
  check. hash_all_into, scalar::lsb_mask and gt_mask_xor_256 now assert
  their output length in release too, so a mismatch fails the same way on
  every backend instead of the scalar path silently truncating.
- RightBitVec::set_bit promised assignment but forwarded to an
  OR-only setter. It had no callers outside tests, so it is removed from
  the trait; the inherent RightBlock32::set_bit now overwrites the bit,
  matching its doc. Its only production caller wrote 0/1 into zeroed
  blocks, where OR and assign agree, so no ciphertext byte changes.
coderdan added a commit that referenced this pull request Oct 9, 2026
e164730 (13 June) predates the #81 self-review commit 951c7cb (16
June). When this branch was rebased over it, e164730's version of
simd.rs won, silently undoing two of 951c7cb's fixes:

- lsb_mask_256's length checks went back to debug_assert. The NEON path
  gathers blocks through raw pointers assuming exactly 256 of them, so a
  shorter slice reads out of bounds in a release build: UB reachable
  from a safe fn. Today's callers check the length first, but the fn
  was unsound. They are real asserts again.
- The scalar fallbacks went back behind #[allow(unreachable_code)],
  which Rust 1.78 does not honour on a statement, so clippy failed on
  aarch64. Every dispatcher, including the new gt_mask_xor_64, now ends
  in the NEON call on aarch64 and puts the AVX2 check and scalar
  fallback under cfg(not(aarch64)); scalar::gt_mask_xor is compiled only
  where it is used, as 951c7cb had it.

Bit6 also drops its explicit DOMAIN, now derived from BITS.
Adds primitives::simd with the two vectorisable kernels from the right-
ciphertext encoder (v2 plan §3), wired into indicator_mask_xor and
hash_all_into:

- gt_mask_xor_256: bytewise greater-than against a broadcast value packed
  to a bitmask. NEON (vcgtq_u8 + and-with-bitpos + vaddv_u8 pack; the shrn
  trick benched slower on M1 for this exact bit layout) and AVX2
  (vpcmpgtb with 0x80 bias for unsigned semantics + vpmovmskb). ~30x over
  the pre-PR-3 per-bit shape, ~4x over the scalar bulk form in isolation.
- lsb_mask_256: stride-16 gather of AES-output LSBs. NEON via ld1q_x4 +
  constant-index vqtbl4q (the textbook ld4+uzp1 tree benched ~25% slower
  on M1); scalar elsewhere (AVX2 gains nothing without AVX-512 VBMI).

Dispatch: compile-time on aarch64 (NEON is baseline), cached runtime
detection on x86_64, length-generic scalar fallback everywhere else. All
paths branch-free with fixed trip counts and constant tbl indices
(constant-time discipline documented in the module).

Equivalence pinned three ways: dispatched-vs-scalar randomized tests in
the module, mask-vs-per-bit-invert quickcheck in prp.rs, and the PR 1
byte vectors end-to-end (which on this machine exercise the NEON path).

Per-u64 gain on M1 Max is ~0.5% over PR 3 (PRP construction dominates at
Bit8); these kernels are sized for the Bit6 scheme where per-block AES
work shrinks 4x. Microarch caveat: pack/gather strategy choices are
M1-tuned; re-bench on Neoverse before locking server-ARM dispatch.

Part of the ORE v2 program (docs/plans/2026-06-12-ore-v2-architecture.md, PR 4).
…view)

Code-review follow-ups on the SIMD PR:

- lsb_mask_256: promote the blocks.len()==256 / out.len()==32 debug_asserts to
  real assert_eq!. The NEON path gathers `blocks` via raw pointers assuming
  exactly 256 blocks, so a shorter slice would read out of bounds (UB) in a
  release build; the assert enforces the unsafe precondition at the boundary
  rather than relying on the single caller's length check.
- Replace the blanket #[allow(unreachable_code)] on both dispatchers' scalar
  tails with #[cfg(not(target_arch = "aarch64"))], and drop the now-redundant
  aarch64 return. Paths are compile-time mutually exclusive, so a future bad
  early-return surfaces as a real diagnostic instead of being silently masked.
- Scope scalar::gt_mask_xor_256 to cfg(any(not(aarch64), test)) so it isn't
  dead code on aarch64 (only reached via the non-aarch64 dispatcher or tests);
  no allow needed. scalar::lsb_mask stays always-compiled (hash.rs uses it
  directly for non-256 inputs).

AVX2 pre-merge test coverage gap tracked in #87. No wire change (compat vectors
byte-identical).
Review of #81:
- Removing pack_bits_lsb_first (68ec6d0) dropped its release-mode length
  check. hash_all_into, scalar::lsb_mask and gt_mask_xor_256 now assert
  their output length in release too, so a mismatch fails the same way on
  every backend instead of the scalar path silently truncating.
- RightBitVec::set_bit promised assignment but forwarded to an
  OR-only setter. It had no callers outside tests, so it is removed from
  the trait; the inherent RightBlock32::set_bit now overwrites the bit,
  matching its doc. Its only production caller wrote 0/1 into zeroed
  blocks, where OR and assign agree, so no ciphertext byte changes.

@freshtonic freshtonic left a comment

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.

Approved after the round-2 security re-review. The round-1 findings for this PR are fixed or have no remaining blocking items.

🤖 Generated with Claude Code

@coderdan
coderdan merged commit 463e5fa into main Oct 12, 2026
4 checks passed
@coderdan
coderdan deleted the feat/ore-v2-simd branch October 12, 2026 08:34
coderdan added a commit that referenced this pull request Oct 12, 2026
e164730 (13 June) predates the #81 self-review commit 951c7cb (16
June). When this branch was rebased over it, e164730's version of
simd.rs won, silently undoing two of 951c7cb's fixes:

- lsb_mask_256's length checks went back to debug_assert. The NEON path
  gathers blocks through raw pointers assuming exactly 256 of them, so a
  shorter slice reads out of bounds in a release build: UB reachable
  from a safe fn. Today's callers check the length first, but the fn
  was unsound. They are real asserts again.
- The scalar fallbacks went back behind #[allow(unreachable_code)],
  which Rust 1.78 does not honour on a statement, so clippy failed on
  aarch64. Every dispatcher, including the new gt_mask_xor_64, now ends
  in the NEON call on aarch64 and puts the AVX2 check and scalar
  fallback under cfg(not(aarch64)); scalar::gt_mask_xor is compiled only
  where it is used, as 951c7cb had it.

Bit6 also drops its explicit DOMAIN, now derived from BITS.
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