Skip to content

perf: add SIMD kernels for bf16 distance functions#6510

Merged
westonpace merged 10 commits into
lance-format:mainfrom
justinrmiller:feat/numkong-distance-kernels
Apr 16, 2026
Merged

perf: add SIMD kernels for bf16 distance functions#6510
westonpace merged 10 commits into
lance-format:mainfrom
justinrmiller:feat/numkong-distance-kernels

Conversation

@justinrmiller

@justinrmiller justinrmiller commented Apr 14, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Replaces the external numkong dependency with in-tree C kernels for bf16 distance computation (dot product, L2, cosine, norm_l2)
  • Follows the existing f16 kernel pattern: C source compiled via build.rs with per-architecture flags, runtime CPU dispatch via SIMD_SUPPORT
  • Kernels are only enabled when the CPU supports the required instructions (NEON on aarch64, AVX2/AVX-512 on x86_64, LSX/LASX on loongarch64), with scalar fallback otherwise
  • Gated behind the existing fp16kernels feature flag

Benchmark Results

Tested on two platforms with 1M x 1024-dim vectors:

Apple Silicon (M-series, NEON)

Benchmark Before (scalar) After (C kernel) Change
Dot(bf16) 144 ms 55 ms 2.6x faster
NormL2(bf16) 90 ms 36 ms 2.5x faster

AMD Ryzen 5 4500 (Zen 2, AVX2)

Benchmark Before (scalar) After (C kernel) Change
Dot(bf16) 578 ms 363 ms 1.6x faster (−37%)
NormL2(bf16) 365 ms 207 ms 1.8x faster (−43%)

Why the approach works

BF16-to-f32 conversion is a simple left-shift by 16 bits. The C kernels compiled with architecture-specific flags (-march=haswell, -mtune=apple-m1, etc.) plus -ffast-math and vectorization pragmas give the compiler more freedom to emit tight SIMD code than LLVM gets from the Rust scalar loops. ARM benefits more because the baseline Rust auto-vectorization was weaker there.

Files Changed

  • New: rust/lance-linalg/src/simd/bf16.c — C kernels for dot, L2, cosine, norm_l2
  • rust/lance-linalg/build.rs — compile bf16.c for each architecture
  • rust/lance-linalg/src/distance/{dot,l2,cosine,norm_l2}.rs — runtime SIMD dispatch for bf16
  • rust/lance-linalg/Cargo.toml — removed numkong dependency and feature
  • rust/lance-linalg/benches/{dot,l2,cosine}.rs — removed numkong benchmark sections
  • Deleted: scripts/bench_numkong.sh

Test plan

  • cargo test -p lance-linalg --features fp16kernels — all bf16 tests pass (kernel path)
  • cargo test -p lance-linalg — all bf16 tests pass (scalar fallback)
  • cargo clippy -p lance-linalg --features fp16kernels --tests --benches -- -D warnings — clean
  • Benchmarked on Apple Silicon (ARM NEON)
  • Benchmarked on AMD Ryzen 5 4500 (x86_64 AVX2)
  • To reproduce:
    git checkout HEAD~1
    TARGET_TIME=3 cargo bench -p lance-linalg --features fp16kernels --bench dot -- --save-baseline before "bf16"
    TARGET_TIME=3 cargo bench -p lance-linalg --features fp16kernels --bench norm_l2 -- --save-baseline before "bf16"
    git checkout -
    TARGET_TIME=3 cargo bench -p lance-linalg --features fp16kernels --bench dot -- --baseline before "bf16"
    TARGET_TIME=3 cargo bench -p lance-linalg --features fp16kernels --bench norm_l2 -- --baseline before "bf16"

🤖 Generated with Claude Code

Add optional `numkong` feature flag to lance-linalg that integrates
NumKong's hardware-accelerated SIMD kernels for distance computation.

NumKong provides runtime CPU detection and dispatches to the best
available ISA (AVX-512, AVX2, NEON, SVE, etc.) with compensated
summation for improved numerical accuracy.

Platform-specific dispatch strategy based on benchmarking:
- bf16: NumKong on all architectures (~48% faster on Apple Silicon,
  filling the "TODO: add SIMD support" gap)
- f32/f64: NumKong on x86_64 only (LLVM auto-vec already generates
  excellent NEON code on ARM; NumKong's f64 accumulator widening
  adds overhead that isn't offset by NEON's 128-bit registers)

Benchmarks on Apple Silicon (1M x 1024-dim vectors):
- bf16 dot: 138ms -> 74ms (-46%)
- f32 dot: 61ms (unchanged, auto-vec path preserved)
- f64 dot: 119ms (unchanged, auto-vec path preserved)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
justinrmiller and others added 5 commits April 14, 2026 03:00
Extend the numkong feature to also cover f16 dot product, L2, and
cosine distance. When enabled, NumKong's runtime-dispatched SIMD
kernels replace Lance's hand-written C FFI kernels (fp16kernels).

This allows benchmarking NumKong f16 vs Lance's existing per-platform
C kernels to determine if the custom C code can be simplified.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Benchmarking on both Apple Silicon (NEON) and AMD Ryzen 5 4500 (AVX2)
showed that NumKong's compensated summation overhead causes regressions
for f16, f32, and f64 on both platforms. Only bf16 shows consistent
improvement because Lance had no SIMD for it at all.

Results on Apple Silicon (M-series, 1M x 1024-dim vectors):
- bf16: 138ms -> 74ms  (-46%) -- NumKong win
- f32:  61ms -> 211ms  (+243%) -- regression
- f64:  119ms -> 387ms (+226%) -- regression

Results on AMD Ryzen 5 4500 (Zen 2, AVX2, 1M x 1024-dim vectors):
- bf16: 568ms -> 166ms (-71%) -- NumKong win
- f16:  111ms -> 194ms (+74%) -- regression
- f32:  161ms -> 352ms (+113%) -- regression
- f64:  306ms -> 535ms (+75%) -- regression

Root cause: NumKong promotes all accumulators to wider types (f32->f64)
and uses Neumaier compensated summation for numerical accuracy. This
extra precision work costs throughput. LLVM's auto-vectorizer already
generates good SIMD code (NEON on ARM, AVX2 on x86) for the simple
multiply-accumulate loops used by f16/f32/f64, making NumKong slower
despite its hand-tuned SIMD kernels.

bf16 is the exception because Lance had zero SIMD support for it
(the code literally had "// TODO: add SIMD support"), so even with
the accumulation overhead, NumKong's SIMD kernels are a large win.

This commit reverts f16/f32/f64 to their original implementations
and keeps NumKong for bf16 only, on all platforms.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@justinrmiller

Copy link
Copy Markdown
Contributor Author

We may need to bump clang for this to work.

@jackye1995

Copy link
Copy Markdown
Contributor

Given this only improves bf16, why not just implement our own, learn from its implementation? So we can avoid taking a dependency just for this specific feature

@justinrmiller

Copy link
Copy Markdown
Contributor Author

Given this only improves bf16, why not just implement our own, learn from its implementation? So we can avoid taking a dependency just for this specific feature

I discussed that option with Yang. It's possible the performance regression is due to a technique being used that increases precision, and there's the fact we wouldn't have to maintain the kernels anymore.

@westonpace

Copy link
Copy Markdown
Member

We may need to bump clang for this to work.

I think this would be my main concern. If it's a new version of clang that can easily be installed on ubuntu then maybe, as an opt-in feature, it would be nice. If it requires something like building clang from source then it will probably be a non-starter for most users.

The benefit of the current approach is that we don't have to worry about any portability concerns. As long as that can continue I don't have much issue adopting numkong. For starters though, I agree it should be an opt-in feature as in this PR.

justinrmiller and others added 2 commits April 14, 2026 10:05
Lift bf16 distance kernels (dot, L2, cosine, norm_l2) into lance-linalg
as C source compiled via build.rs, following the existing f16 kernel
pattern. Runtime CPU dispatch via SIMD_SUPPORT ensures kernels are only
used when the hardware supports them (NEON on aarch64, AVX2/AVX-512 on
x86_64, LSX/LASX on loongarch64), with scalar fallback otherwise.

Removes the numkong optional dependency entirely.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@justinrmiller

Copy link
Copy Markdown
Contributor Author

@jackye1995 no more NumKong direct dependency. :)

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

LGTM
maybe need a new PR title

@justinrmiller justinrmiller changed the title perf: add NumKong SIMD kernels for bf16 distance functions perf: add SIMD kernels for bf16 distance functions Apr 15, 2026
@codecov

codecov Bot commented Apr 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 55.55556% with 8 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
rust/lance-linalg/src/distance/cosine.rs 66.66% 2 Missing ⚠️
rust/lance-linalg/src/distance/dot.rs 50.00% 2 Missing ⚠️
rust/lance-linalg/src/distance/l2.rs 50.00% 2 Missing ⚠️
rust/lance-linalg/src/distance/norm_l2.rs 50.00% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@westonpace westonpace left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

There are some merge conflicts. If we resolve those I can merge. Thanks for investigating this. It's nice to see better support for bf16!

Resolve merge conflict in cosine benchmarks by accepting upstream u8
cosine benchmarks. Apply cargo fmt to fix formatting in extern fn
signatures and benchmark files.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
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.

4 participants