Skip to content

feat(lance-linalg): runtime SIMD dispatch for pre-Haswell x86_64 from-source builds#6630

Merged
wjones127 merged 27 commits into
lance-format:mainfrom
tobocop2:fix/runtime-simd-multiversion
Jul 14, 2026
Merged

feat(lance-linalg): runtime SIMD dispatch for pre-Haswell x86_64 from-source builds#6630
wjones127 merged 27 commits into
lance-format:mainfrom
tobocop2:fix/runtime-simd-multiversion

Conversation

@tobocop2

@tobocop2 tobocop2 commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Tracks #6618. On x86_64 CPUs without AVX2 — Sandy Bridge / Ivy Bridge / Westmere on Intel, Bulldozer / Piledriver / Steamroller on AMD — import lancedb SIGILLs because the wheel bakes AVX2 + FMA into every compiled function with no runtime guard. numpy and pyarrow handle the same hardware via runtime CPU dispatch.

Summary

  • Adds 5-tier runtime SIMD dispatch (scalar / AVX / AVX+FMA / AVX2+FMA / AVX-512) to the f32/f64 hot kernels in lance-linalg::distance::{cosine, dot, l2, norm_l2}. Same match *SIMD_SUPPORT + mod x86 { #[target_feature] pub unsafe fn ... } shape as dot_u8.rs / cosine_u8.rs / l2_u8.rs. Where the AVX2 and AVX+FMA kernel bodies use no AVX2-specific intrinsics, the dispatch matches Avx2 | AvxFma to a shared kernel.
  • Adds lance.simd_info() Python introspection mirroring pyarrow.runtime_info() so users can verify which tier the runtime selected.
  • Adds a qemu-pre-haswell CI job that builds with RUSTFLAGS="-C target-cpu=x86-64-v2" (env-var-scoped to that one job — workspace .cargo/config.toml is unchanged) and runs lance-linalg lib tests under qemu-x86_64 -cpu Nehalem.
  • Documents the legacy build path in CONTRIBUTING.md: RUSTFLAGS="-C target-cpu=x86-64-v2" cargo build --release.

Per westonpace's review on lancedb/lancedb#3324, the workspace baseline stays at target-cpu=haswell. Modern wheels are unchanged; legacy users opt into the lower baseline at build time.

Benchmark

The AVX2 path on modern hardware is preserved as one of the per-tier kernels and the workspace baseline still bakes AVX2 into surrounding code, so by construction the modern compile is unchanged. Numbers still pending — Codespace's 30-min idle timeout killed my last full cargo bench -p lance-linalg --bench {cosine,dot,l2,norm_l2} run mid-suite (even with nohup — the VM itself sleeps). If anyone can recommend a free resource that holds a benchmark for ~1 hour, or a maintainer-preferred narrower bench shape, I'd appreciate the pointer.

Pre-Haswell verification on Sandy Bridge Xeon E5-2609 (the hardware the published wheel SIGILLs on) via the companion lancedb wheel build: pre-PR pip install lancedb SIGILLs at import; post-PR a from-source build with the documented RUSTFLAGS override produces a wheel where import + table-create + vector-search all work at the AVX tier. PASS output in tobocop2/lancedb#2.

Incidental fix: FMA not verified before the AVX2 tier

While auditing the dispatch table for this PR I found a latent soundness bug that predates it. On main the tier is chosen with is_x86_feature_detected!("avx2") alone, but every kernel the AVX2 tier dispatches to is #[target_feature(enable = "avx,fma")]. AVX2 does not imply FMA in the x86 ISA, so a host with AVX2 but no FMA would select that tier and execute vfmadd — an illegal instruction.

No shipping AVX2 part lacks FMA, so this is latent rather than live, but it is exactly the class of fault this PR exists to remove. The fix checks FMA explicitly, documents the tier's contract, and adds a test that fails if any tier is ever selected on a host that cannot run its kernels. Tracked separately as #7732 so it stays on the record independent of the perf work.

Test plan

  • cargo test -p lance-linalg --lib — 83/83 on aarch64 dev box
  • cargo clippy --all-targets -- -D warnings clean; cargo fmt --check clean; Cargo.lock unchanged
  • 11 proptest cases verifying scalar↔SIMD bit-for-bit equivalence per tier per kernel; gated on is_x86_feature_detected!() so each runs on hosts that can execute its tier
  • SIGILL repro confirmed gone on Sandy Bridge Xeon E5-2609 with the documented RUSTFLAGS override
  • qemu-pre-haswell CI gate green (lights up when this PR runs CI)
  • Modern-hardware bench delta posted

To be transparent: this isn't my domain of expertise and the implementation is AI-generated — I stuck to the existing mod x86 { #[target_feature] } precedent and verified end-to-end on the failing hardware, but wanted to be upfront. Happy to roll in feedback.

Summary by CodeRabbit

  • New Features
    • Added lance.simd_info() (Python) to report the runtime SIMD tier, target architecture, and detected host features.
  • Bug Fixes
    • Improved runtime SIMD dispatch and scalar fallback behavior for cosine, dot, L2 distance, and L2 norms, including more accurate tier handling across mixed AVX/AVX+FMA capability.
  • Documentation
    • Added instructions for building on legacy x86_64 (pre-Haswell) hosts and verifying SIMD selection.
  • Tests
    • Added simd_info() tests and expanded dispatched-vs-scalar parity coverage, plus a new regression seed.
  • Chores
    • Enhanced CI with QEMU-based SIMD-tier validation on a lower x86-64 baseline.

@github-actions github-actions Bot added enhancement New feature or request A-python Python bindings labels Apr 28, 2026
@tobocop2
tobocop2 force-pushed the fix/runtime-simd-multiversion branch from a3df856 to 9193496 Compare April 28, 2026 04:59
tobocop2 added a commit to tobocop2/lancedb that referenced this pull request Apr 28, 2026
The default lancedb wheel targets target-cpu=haswell (AVX2 + FMA + F16C)
and SIGILLs at import on pre-Haswell silicon (Sandy Bridge / Ivy Bridge /
Westmere on Intel; Bulldozer / Piledriver / Steamroller on AMD). Per
westonpace's review on lancedb#3324 (fast by default, extra steps to work on
legacy), the default wheel baseline stays fast for modern users;
pre-Haswell users get a separately-published 'lancedb-compat' wheel.

Adds a 'lancedb-compat' matrix entry to pypi-publish.yml that builds
with RUSTFLAGS=-C target-cpu=x86-64-v2 (Nehalem-class baseline). The
compat wheel relies on runtime SIMD dispatch in the embedded lance crate
(landing via lance-format/lance#6630) to pick the appropriate kernel
tier at load time, so it still goes fast on modern hardware.

Generalizes build_linux_wheel and upload_wheel composites with optional
package-name and rustflags inputs (defaults preserve current behavior
for the existing 4 lancedb matrix entries). Documents the choice in
python/README.md: pip install lancedb-compat for pre-Haswell hosts;
same import lancedb API.

Maintainer setup required before this can ship: register lancedb-compat
on PyPI and configure trusted publishing.

Blocked on lance-format/lance#6630.
@tobocop2
tobocop2 force-pushed the fix/runtime-simd-multiversion branch 2 times, most recently from 7db5171 to 26aa7f4 Compare April 28, 2026 05:46
@tobocop2
tobocop2 marked this pull request as ready for review May 26, 2026 23:19

@claude claude Bot 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.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@tobocop2
tobocop2 force-pushed the fix/runtime-simd-multiversion branch from 8691248 to 448a275 Compare June 10, 2026 16:11
tobocop2 added a commit to tobocop2/lancedb that referenced this pull request Jun 10, 2026
The default lancedb wheel targets target-cpu=haswell (AVX2 + FMA + F16C)
and SIGILLs at import on pre-Haswell silicon (Sandy Bridge / Ivy Bridge /
Westmere on Intel; Bulldozer / Piledriver / Steamroller on AMD). Per
westonpace's review on lancedb#3324 (fast by default, extra steps to work on
legacy), the default wheel baseline stays fast for modern users;
pre-Haswell users get a separately-published 'lancedb-compat' wheel.

Adds a 'lancedb-compat' matrix entry to pypi-publish.yml that builds
with RUSTFLAGS=-C target-cpu=x86-64-v2 (Nehalem-class baseline). The
compat wheel relies on runtime SIMD dispatch in the embedded lance crate
(landing via lance-format/lance#6630) to pick the appropriate kernel
tier at load time, so it still goes fast on modern hardware.

Generalizes build_linux_wheel and upload_wheel composites with optional
package-name and rustflags inputs (defaults preserve current behavior
for the existing 4 lancedb matrix entries). Documents the choice in
python/README.md: pip install lancedb-compat for pre-Haswell hosts;
same import lancedb API.

Maintainer setup required before this can ship: register lancedb-compat
on PyPI and configure trusted publishing.

Blocked on lance-format/lance#6630.
@tobocop2
tobocop2 force-pushed the fix/runtime-simd-multiversion branch from 448a275 to 126a6a5 Compare June 10, 2026 16:24
@github-actions github-actions Bot added A-index Vector index, linalg, tokenizer A-ci CI / build workflows labels Jun 10, 2026
@tobocop2
tobocop2 force-pushed the fix/runtime-simd-multiversion branch from 126a6a5 to 26a520b Compare June 10, 2026 16:56
@stumpylog

Copy link
Copy Markdown
Contributor

Benchmarked this PR on two modern x86_64 boxes (the "no slowdown on newer hardware" question). As-is it regresses the f32 cosine batch path on AVX2-only CPUs; a small refinement fixes that while keeping the AVX-512 win.

cargo bench -p lance-linalg --bench cosine, criterion, same-session base→PR→fix (Cosine(f32, scalar) is a build-independent control ≈ 0% on clean runs). auto-vectorized = cosine_distance_batch dim 1024; simd,f32x8 = dim 8.

PR as-is vs base:

Broadwell (AVX2) Zen 4 (AVX-512)
dim 1024 +24% −6%
dim 8 +36% +78%

Cause: f32::cosine_batch does the match *SIMD_SUPPORT + a #[target_feature] call per vector. On a wheel already built with target-cpu=haswell, that's pure overhead vs the pre-PR inlined f32x16/f32x8 kernel.

Refinement: gate the dispatch on #[cfg(target_feature = "avx2")] — AVX2-baseline builds use the inlined (byte-identical to pre-PR) kernels with no per-vector dispatch, and dispatch to AVX-512 once per batch, large dims only (a masked 512-bit load loses to a plain AVX2 load at 8 lanes). Sub-AVX2 builds keep your runtime path.

After (fix vs base):

Broadwell (AVX2) Zen 4 (AVX-512)
dim 1024 +6% −7%
dim 8 flat −3%

Fast by default, AVX-512 still wins where it helps.

Notes: the +6% at dim 1024 on AVX2 is a codegen-unit artifact (the kernel there is byte-identical to pre-PR), not the dispatch path. I only reworked f32 cosine — dot/l2/norm_l2 have the same per-vector shape and likely the same regression. Branch: https://github.com/stumpylog/lance/tree/cosine-batch-dispatch-fix — happy to PR it against your branch.

(Downstream context: paperless-ngx/paperless-ngx#12970.)

@tobocop2

tobocop2 commented Jun 10, 2026 via email

Copy link
Copy Markdown
Contributor Author

@tobocop2

Copy link
Copy Markdown
Contributor Author

@westonpace

Just a friendly bump now that there are other folks with similar needs. I'm wondering if you could nudge someone on this team our way. Thank you very much.

@wjones127
wjones127 self-requested a review July 7, 2026 15:36
@tobocop2
tobocop2 force-pushed the fix/runtime-simd-multiversion branch from a877569 to 782877e Compare July 7, 2026 16:47
tobocop2 and others added 2 commits July 7, 2026 13:47
…-source builds

Adds 5-tier runtime SIMD dispatch (scalar / AVX / AVX+FMA / AVX2+FMA /
AVX-512) to the 10 hot f32/f64 distance kernels in lance-linalg, matching
the per-tier dispatch shape already used by dot_u8.rs / cosine_u8.rs /
l2_u8.rs and the f16/bf16 paths in norm_l2.rs.

Today, `import lancedb` SIGILLs on AVX-without-AVX2 CPUs (Intel Sandy
Bridge / Ivy Bridge / Westmere; AMD Bulldozer / Piledriver / Steamroller)
because the wheel bakes AVX2 into every compiled function with no runtime
guard. numpy and pyarrow handle the same hardware via runtime dispatch;
this PR brings lance to parity for the from-source legacy build path.

Per westonpace's review feedback on lancedb/lancedb#3324 — fast by default,
extra steps to work on legacy — the workspace .cargo/config.toml baseline
stays at target-cpu=haswell. The qemu-pre-haswell CI job sets
RUSTFLAGS=-C target-cpu=x86-64-v2 only in its own env block so the
runtime-dispatch path gets exercised under qemu Nehalem without affecting
any other build. CONTRIBUTING.md documents the from-source legacy build
for users on pre-Haswell hardware.

Changes:

- lance-core::utils::cpu: extend SimdSupport with Avx and AvxFma tiers,
  add #[non_exhaustive] for forward compatibility, add SimdInfo +
  simd_info() introspection API mirroring pyarrow.runtime_info().
- lance-linalg::distance::{cosine, dot, l2, norm_l2}: per-tier dispatch
  via match *SIMD_SUPPORT for all f32/f64 hot kernels. AVX-512 inner
  kernels use _mm512_* intrinsics directly; AVX+FMA kernels use the
  existing f32x8 / f64x8 SIMD types; AVX-only kernels use raw
  _mm256_mul/_mm256_add intrinsics because f32x8::multiply_add lowers
  to an FMA instruction. AVX2-host dispatch routes to the AvxFma kernel
  where the body uses no AVX2-specific intrinsics
  (Avx2 | AvxFma => x86::FOO_avx_fma) — eliminates ~480 lines of
  byte-identical AVX2/AvxFma per-tier function pairs across all 10 hot
  kernels. Documents AvxFma/Avx fall-through to scalar in the u8 and
  dist_table dispatchers (their AVX2 inners use integer ops not
  available below AVX2).
- lance-linalg::simd::{f32, f64}: drop the dead AVX-512 specializations
  of f32x16 / f64x8 (gated on target_feature="avx512f" which no project
  CI configuration enables). Per-tier dispatch in distance/* uses raw
  _mm512_* intrinsics directly. f32x8::gather adds a runtime AVX2 guard
  with a scalar fallback for x86_64 hosts without AVX2.
- python: expose lance.simd_info() (native binding + re-export in
  lance/__init__.py with a pytest) so users can verify which SIMD tier
  the runtime dispatched to without rebuilding.
- .github/workflows/rust.yml: new qemu-pre-haswell job that builds with
  RUSTFLAGS=-C target-cpu=x86-64-v2 and runs lance-linalg lib tests under
  qemu-x86_64 -cpu Nehalem, catching SIGILL leaks in the legacy-build
  path before they ship.
- CONTRIBUTING.md: documents the legacy build (RUSTFLAGS=-C target-cpu=x86-64-v2 cargo build --release).

Zero new external dependencies; Cargo.lock unchanged. lance-linalg lib
tests all green (83/83). Verified end-to-end on Sandy Bridge Xeon E5-2609
via the companion lancedb wheel build: pre-PR `pip install lancedb`
SIGILLs; post-PR a from-source build with the documented RUSTFLAGS
override produces a wheel where import + vector search work at the AVX
tier.

Closes lance-format#6618.
…=avx2)

On AVX2-baseline builds (the default haswell wheel), cosine_batch uses inlined
non-target_feature kernels (base-equivalent) plus a single per-batch AVX-512
check, avoiding the per-vector runtime-dispatch + target_feature tax that
regressed the modern path. Sub-AVX2 builds keep the runtime dispatch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@tobocop2
tobocop2 force-pushed the fix/runtime-simd-multiversion branch from 782877e to 6141fed Compare July 7, 2026 17:48
@tobocop2

tobocop2 commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

addressed all CI issues that I saw @wjones127 thank you so much

@codecov

codecov Bot commented Jul 8, 2026

Copy link
Copy Markdown

tobocop2 added 2 commits July 8, 2026 02:32
The runtime-dispatch codecov run flagged the f32 cosine batch kernels and
the scalar x86 gather fallback as uncovered. Both are only reached at
runtime on sub-AVX2 hosts, so on the AVX2+ CI runner they were never
exercised even though the surrounding per-vector kernels have full parity
tests.

Add direct parity tests that call cosine_batch_avx, cosine_batch_avx_fma,
and cosine_batch_avx512 across the 8/16/general dimension arms, comparing
each batch entry against the scalar cosine_fast reference and gating each
tier on the matching is_x86_feature_detected! check. Add a direct test for
gather_scalar_x86 so the non-AVX2 gather path is covered on hosts that
route through the AVX2 gather at runtime.
@tobocop2

tobocop2 commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

the review request was accidental @wjones127 my apologies. This still needs benchmarks and also it needs to address what is discussed here #6630 (comment)

tobocop2 added 3 commits July 9, 2026 19:26
Every kernel the Avx2 tier dispatches to is annotated
`#[target_feature(enable = "avx,fma")]`, but the tier was selected on
`is_x86_feature_detected!("avx2")` alone. AVX2 does not imply FMA in the ISA.
No shipping AVX2 part lacks FMA, so this was latent rather than live, but it is
the same class of fault the rest of this work exists to prevent.

Check FMA explicitly, document the tier's contract, and add a test that fails
if a tier is ever selected on a host that cannot run its kernels.
Runtime dispatch was happening once per vector inside the batch loops. On a
build whose baseline already implies AVX2 — the default `haswell` wheel — the
old code inlined the kernel directly, so the `match *SIMD_SUPPORT` plus the
non-inlinable `#[target_feature]` call bought nothing and cost a measured +24%
at dim 1024 and +36% at dim 8 on Broadwell. Cosine was fixed this way already;
dot and l2 were not.

Dispatch once, for the whole batch:

- On AVX2-baseline builds, call the same inlined auto-vectorized kernel the
  code used before dispatch existed. Only an AVX-512 host with dimension > 16
  takes a wide kernel, entered once.
- On sub-AVX2 builds, still dispatch, but into a kernel that loops internally.

`Dot` had no batch hook at all, so `dot_distance_batch` mapped `dot_distance`
per vector; add `Dot::dot_batch` with a per-vector default, so every other
impl keeps its current behaviour. Both batch methods return `impl Iterator`
rather than a trait object: the k-means assignment loop drives them one element
at a time, and a `Box<dyn Iterator>` would trade the per-vector dispatch for a
per-element virtual call. `BatchIter` keeps the lazy and eager shapes in one
statically dispatched type.

`do_dot_distance_arrow_batch` carried its own copy of the per-vector loop and
would not have benefited; route it through `dot_distance_batch`.

Public signatures are unchanged from before the dispatch work: `l2_distance_batch`
still returns `impl Iterator`, `dot_distance_batch` still returns a boxed one.
Benchmarking the previous commit on two hosts turned up a regression it had
introduced: L2(simd,f32x8) was +154% on an AVX2-only EPYC 7B13 and +78.7% on an
AVX-512 Xeon, while the dim-1024 benchmarks were flat.

The cause was the `BatchIter` enum. Wrapping the batch iterator loses two things
the bare `Map<ChunksExact, _>` has, and both matter only when the per-vector work
is small:

- `Map` overrides `Iterator::fold` to drive `ChunksExact` in one inlined,
  auto-vectorized loop. Through the enum, `fold` fell back to calling `next()`
  per element.
- `Map<ChunksExact, _>` is `TrustedLen`, so `.collect()` preallocates exactly.
  No enum can be, and `TrustedLen` cannot be implemented outside std.

`fold` can be delegated; `TrustedLen` cannot, and the hot consumers do not all
use `fold` — `argmin_value_float` drives the iterator through `enumerate()`, and
the arrow batch paths `.collect()`.

So on an AVX2-baseline build, return the bare `Map` over the inlined scalar
kernel: byte-for-byte the iterator the code produced before runtime dispatch
existed. That removes the per-vector dispatch without adding anything, which is
the only shape that is non-regressing by construction rather than by measurement.

`BatchIter` now exists only for sub-AVX2 builds, where the eager
`#[target_feature]` kernels need a common return type and there is no prior
behaviour to regress. It delegates `fold`/`for_each` and implements
`ExactSizeIterator` so that path stays reasonable too.
@tobocop2

Copy link
Copy Markdown
Contributor Author

Benchmarks found a regression — holding this PR

@wjones127 I ran the AVX2 and AVX-512 benchmarks you asked for. They turned up a real regression in this PR, so it isn't merge-ready. Details below; I'm working the root cause now.

@stumpylog you called this in June — dot/l2 had the same per-vector dispatch shape as cosine. No need to rerun anything, I have the hardware up and I'm taking care of it.

The finding

L2(simd,f32x8)l2_distance_batch at dim 8 — regresses badly against upstream/main:

host L2(simd,f32x8) (dim 8) L2(f32, simd) (dim 1024) Cosine(f32, scalar) (control)
AMD EPYC 7B13 — avx2, fma, no avx512f +156.1% +0.71% −0.30%
Intel Xeon (Cascade Lake) — avx512f +78.7% −0.57% +0.19%

The control is flat on both hosts and dim-1024 is flat, so this is a real effect localized to small dimensions — exactly where the per-vector dispatch overhead dominates the per-vector work. Dim 8 is not academic: it's the sub-vector width PQ uses.

Two attempted fixes have not moved it, which means my causal model is wrong rather than my patch. Bisecting now.

Method

criterion, one machine, one session, base → PR, on two GCE VMs:

Both verified with /proc/cpuinfo rather than trusted by instance family.

git checkout <base>;  cargo bench -p lance-linalg --bench {cosine,dot,l2,norm_l2} -- --save-baseline pre
git checkout <pr>;    cargo bench -p lance-linalg --bench {cosine,dot,l2,norm_l2} -- --baseline pre

No RUSTFLAGS override — .cargo/config.toml already pins target-cpu=haswell for
x86_64-unknown-linux-gnu, and that AVX2 baseline is the only one on which this regression exists. Overriding it measures nothing.

base 5dbd1400d → head 97865b523, rustc 1.94.0.

I'll follow up with the full numbers once the regression is fixed and the run is clean.

@tobocop2
tobocop2 marked this pull request as draft July 10, 2026 01:42
tobocop2 added 3 commits July 9, 2026 21:57
Hoisting the tier choice out of the per-vector loop is only half the job. The
first attempt also replaced the kernel with the auto-vectorized scalar one,
on the theory that an AVX2-baseline build makes the SIMD kernel redundant.
Benchmarks say otherwise: at dim 8, `L2(simd,f32x8)` went from -41.6% (before
this work) to +156% against upstream/main. Removing a cheap branch is worthless
if it costs the kernel behind it.

The baseline already guarantees avx2+fma, so call the AVX+FMA kernel directly:
no runtime check, and the `#[target_feature]` contract is satisfied statically,
so it inlines into the loop.

Measured on an AMD EPYC 7B13 (avx2, fma, no avx512f), against upstream/main:

                          before this work   with this commit
  L2(simd,f32x8)  dim 8        -41.6%             -45.4%
  L2(f32, simd)   dim 1024      +1.5%              +0.4%
  Dot(f32, SIMD)                -0.1%              -0.5%

The iterator stays a bare `Map`: `Map<ChunksExact, _>` is `TrustedLen`, so
`.collect()` preallocates, and `Map::fold` drives `ChunksExact` in one inlined
loop. Any wrapper — trait object or enum — loses both.
`cfg(target_feature = "avx2")` does not imply `fma` — `-C target-feature=+avx2`
sets one and not the other. The batch path guarded on it calls a kernel declared
`#[target_feature(enable = "avx,fma")]`, so such a build emitted `vfmadd` while
claiming a baseline without FMA.

Gate the direct-kernel arm on both features, and make the runtime-dispatch arm
its exact complement so the two remain total. Verified by building the crate at
`+avx2`, `+avx2,+fma`, `+avx`, `target-cpu=haswell`, and the default baseline.
`dot_scalar`/`l2_scalar` chunk by 16 lanes. At or below that width the chunking
degenerates to the scalar remainder loop and vectorizes nothing, which is why
the explicit AVX kernel is worth ~40% at dim 8 — the width PQ sub-vectors use.
Above 16 lanes the autovectorizer is already good, and on Zen 3 the 8-wide
kernel loses to it.

Measured on an AMD EPYC 7B13 (avx2, fma, no avx512f), two repetitions each,
against upstream/main:

                       kernel everywhere      this commit
  L2(simd,f32x8)         -42.2% / -41.4%      (unchanged, kernel)
  Dot(f32, SIMD)         +1.9%  / +3.6%       (scalar, == base)

So above the threshold keep exactly the kernel the pre-dispatch code used. The
wide path is then byte-identical to base and cannot regress; the narrow path
keeps the win. The branch is loop-invariant.
@tobocop2

tobocop2 commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Benchmark results — no regressions on either CPU

@wjones127 here are the AVX2 and AVX-512 numbers. @stumpylog, no need to rerun anything.

Same method as your June run: criterion, one machine, one session, base → PR. No RUSTFLAGS override, so the repo's default target-cpu=haswell baseline applies — the only baseline on which any of this matters.

base 5dbd1400d (upstream/main) → head 6b94ccdbe, rustc 1.94.0. Both hosts 16 vCPU, features read from /proc/cpuinfo. Full logs: https://gist.github.com/tobocop2/3c6d0f449cbd736aa2501f89a7fe56a2

benchmark EPYC 7B13 (avx2, fma, no avx512f) Xeon Cascade Lake (avx512f)
Cosine(f32, scalar) (control) +0.04% +0.09%
Cosine(f64, scalar) −0.34% −1.94%
Cosine(u8, SIMD) +2.30% +3.63%
Dot(f16, SIMD) −0.58% +0.61%
Dot(f32, SIMD) +0.34% −6.08%
Dot(f32, arrow_arity) +0.02% −0.00%
L2(f32, scalar) −0.10% −0.02%
L2(f32, simd) (dim 1024) +2.63% −0.53%
L2(simd,f32x8) (dim 8) −45.9% −25.1%
L2(u8, SIMD) +0.42% −3.11%
NormL2(f32, SIMD) −1.02% −4.17%
NormL2(f64, SIMD) +3.51% −0.58%

Nothing regresses beyond the noise floor. Dim 8 — the PQ sub-vector width — improves 25–46%.

On the two >2% rows

L2(f32, simd) and Dot(f32, SIMD) allocate ~4 GB each ("1M of 1024-D vectors") and are memory-bandwidth bound. Their run-to-run drift on these hosts reaches ±9%, larger than any effect this PR has on them. Rather than argue, the gist includes a null experiment: base measured against base, interleaved with base against head, three repetitions.

EPYC   L2(f32,simd)   null: -0.75%  +3.53%  +2.03%
                      head: -0.17%  +2.74%  +1.37%

The head deltas sit inside the base-vs-base spread. That is what "identical code path" looks like on a shared machine — and above dim 16 it is the same code path, see below.

Dot(f32, SIMD) calls f32::dot per vector rather than the batch API, so it measures the single-vector dispatch, not the batch work. On AVX-512 that resolves to the wide kernel: −6%, well outside its ±1.5% null floor.

What changed

@stumpylog called it in June: dot and l2 had the same per-vector dispatch shape as cosine and would regress the same way. Only cosine had been fixed. (norm_l2 has no batch API, so nothing to fix there.)

Both now choose the SIMD tier once per batch instead of once per vector.

The subtlety is which kernel the batch path should then call. dot_scalar/l2_scalar chunk by 16 lanes; at or below that width the chunking degenerates to a scalar remainder loop and vectorizes nothing, so the explicit AVX+FMA kernel is worth ~40% at dim 8. Above 16 lanes the autovectorizer is already good and the 8-wide kernel can lose to it. So the wide path keeps exactly the kernel the pre-dispatch code used, which makes it non-regressing by construction rather than by measurement.

Dot had no batch hook, so Dot::dot_batch is new with a per-vector default; non-f32 impls are unchanged. do_dot_distance_arrow_batch carried its own copy of the per-vector loop and now routes through dot_distance_batch. Public signatures match pre-dispatch: l2_distance_batch returns impl Iterator, dot_distance_batch a boxed one.

The benchmark caught one bug. Fixing it led me to two more.

What the benchmark caught — a real slowdown.

An earlier version of this change moved the dispatch out of the per-vector loop, which was the point. But it also swapped the SIMD kernel out for the plain auto-vectorized one, on the assumption that an AVX2 baseline makes the hand-written SIMD kernel redundant.

It doesn't. On FMA hardware that swap cost +156% on L2(simd,f32x8) — the benchmark caught it immediately. Taking out a cheap branch isn't a win if the kernel behind it gets slower. Fixed in 6b94ccdbe.

What the benchmark couldn't catch — two FMA bugs I found while fixing the first one.

Fixing that meant actually reading the dispatch table, and that's where the other two turned up. Neither of these can ever show up in a benchmark, because no real CPU triggers them — you only get there with hand-picked build flags:

  • The new batch path was gated on cfg(target_feature = "avx2"). But -C target-feature=+avx2 turns on AVX2 without FMA, and the kernel behind that gate needs FMA. So a build at exactly +avx2 would compile in a kernel the CPU wasn't guaranteed to support.
  • The runtime tier selection had the same blind spot — it picked the AVX2 tier on is_x86_feature_detected!("avx2") alone, even though those kernels need FMA too. On a CPU with AVX2 but no FMA that's an illegal instruction, i.e. a crash. This one predates my PR — it's already on main. Fixed in bb8af46, written up in Runtime SIMD dispatch can select an FMA kernel tier on a CPU without FMA #7732.

Both now check for FMA explicitly. The compile-time gate and the runtime check are each the exact complement of the other, so there's no third path left open. Verified by building at +avx2, +avx2,+fma, +avx, target-cpu=haswell, and the default baseline.

@tobocop2
tobocop2 marked this pull request as ready for review July 10, 2026 07:33

@wjones127 wjones127 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.

This is looking better. Thanks for the benchmark results, they look good. Once clippy lints are fixed, I'm happy to approve and merge.

@tobocop2

Copy link
Copy Markdown
Contributor Author

thank you!

The per-batch `dot_batch_f32_avx*` / `l2_batch_f32_avx*` wrappers are only
called from the runtime-dispatch path, which is compiled out when the build
baseline already guarantees avx2+fma (the repo's `target-cpu=haswell`
default). Under that baseline the wrappers, their test helper, and their
tests were unused, so `cargo clippy -D warnings` failed on dead_code.

Gate the definitions and their tests with the same
`not(all(target_feature = "avx2", target_feature = "fma"))` cfg as their
caller, so definition, dispatch, and test compile together. No behavior
change: at the avx2+fma baseline `dot_batch`/`l2_batch` already inline the
kernel directly; below it the runtime dispatch and these wrappers remain.
@tobocop2

tobocop2 commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

@wjones127 clippy lints are fixed in b1670de

@tobocop2
tobocop2 requested a review from wjones127 July 13, 2026 23:25
@wjones127
wjones127 merged commit 4fc74b7 into lance-format:main Jul 14, 2026
33 checks passed
wjones127 pushed a commit to lancedb/lancedb that referenced this pull request Jul 16, 2026
)

Tracks #3324. On x86_64 CPUs without AVX2 (Sandy Bridge / Ivy Bridge /
Westmere on Intel; Bulldozer / Piledriver / Steamroller on AMD), `import
lancedb` SIGILLs because the wheel bakes AVX2 + FMA into every compiled
function. Per [westonpace's
review](#3324 (comment)),
the default `lancedb` wheel stays fast; pre-Haswell users get a
separately-published `lancedb-compat` wheel.

## Summary

- Adds a `lancedb-compat` matrix entry to `pypi-publish.yml` that builds
with `RUSTFLAGS="-C target-cpu=x86-64-v2"` (Nehalem-class baseline).
Same Python API (`import lancedb` works) — files install to the same
namespace, so the two wheels conflict at install time and users pick
one. Same pattern as `psycopg2` / `psycopg2-binary` and `tensorflow` /
`tensorflow-cpu`.
- Generalizes `build_linux_wheel` and `upload_wheel` composites with
optional `package-name` and `rustflags` inputs (defaults preserve the
existing 4 `lancedb` matrix entries verbatim).
- Documents the choice in `python/README.md`: `pip install
lancedb-compat` for pre-Haswell hosts.

The default `.cargo/config.toml` baseline is unchanged.

## Sequencing

1. ~~lance-format/lance#6630 merges → runtime SIMD dispatch lands in
lance.~~ **Done — merged.**
2. lancedb's lance dep is bumped to a release that includes it (separate
PR / normal cadence).
3. This PR's `lancedb-compat` wheel build path starts producing a wheel
that runs on pre-Haswell hardware. **Maintainer setup**: register
`lancedb-compat` on PyPI and configure trusted publishing.

## Verified end-to-end on Sandy Bridge Xeon E5-2609

Verification was done locally against a fork-pinned lance dep that
includes the runtime dispatch implementation, using the same
`RUSTFLAGS="-C target-cpu=x86-64-v2"` flags this PR uses in CI:

```
$ RUSTFLAGS="-C target-cpu=x86-64-v2" maturin build --release
$ pip install ./target/wheels/lancedb-*.whl
$ python verify.py
PASS: import + simd dispatch + table create + vector search all work.
```

Pre-fix on the same CPU (default `pip install lancedb`): `Illegal
instruction (core dumped)`. Full reproducer (deps + clone + build +
verification):
https://gist.github.com/tobocop2/2e341358b55c143527416edfdb1e37df.
Fork-internal verification PR with the dep bump and full logs:
[`tobocop2#2`](tobocop2#2).

## Benchmarks — no regressions on modern CPUs from the lance-side change

These are the numbers I ran for the lance PR, confirming the runtime
dispatch doesn't slow down the default (`target-cpu=haswell`) wheel that
existing users install. Criterion, one machine, one session, base → PR,
no `RUSTFLAGS` override. Full methodology, null experiments, and logs:
[lance-format/lance#6630 benchmark
comment](lance-format/lance#6630 (comment))
and the [logs
gist](https://gist.github.com/tobocop2/3c6d0f449cbd736aa2501f89a7fe56a2).

| benchmark | EPYC 7B13 (`avx2`, `fma`, no `avx512f`) | Xeon Cascade
Lake (`avx512f`) |
|---|---|---|
| `Cosine(f32, scalar)` *(control)* | +0.04% | +0.09% |
| `Cosine(f64, scalar)` | −0.34% | −1.94% |
| `Cosine(u8, SIMD)` | +2.30% | +3.63% |
| `Dot(f16, SIMD)` | −0.58% | +0.61% |
| `Dot(f32, SIMD)` | +0.34% | **−6.08%** |
| `Dot(f32, arrow_arity)` | +0.02% | −0.00% |
| `L2(f32, scalar)` | −0.10% | −0.02% |
| `L2(f32, simd)` (dim 1024) | +2.63% | −0.53% |
| **`L2(simd,f32x8)` (dim 8)** | **−45.9%** | **−25.1%** |
| `L2(u8, SIMD)` | +0.42% | −3.11% |
| `NormL2(f32, SIMD)` | −1.02% | −4.17% |
| `NormL2(f64, SIMD)` | +3.51% | −0.58% |

Nothing regresses beyond the noise floor. Dim 8 — the PQ sub-vector
width — improves 25–46%.

---

To be transparent: this isn't my domain of expertise and the lance-side
implementation is AI-generated. I verified it works end-to-end on the
failing hardware. Happy to roll in feedback.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-ci CI / build workflows A-deps Dependency updates A-index Vector index, linalg, tokenizer A-python Python bindings enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants