Skip to content

feat(spann): add an optional lattice-simd distance backend - #763

Open
ohdearquant wants to merge 4 commits into
ruvnet:mainfrom
ohdearquant:feat/spann-lattice-simd
Open

feat(spann): add an optional lattice-simd distance backend#763
ohdearquant wants to merge 4 commits into
ruvnet:mainfrom
ohdearquant:feat/spann-lattice-simd

Conversation

@ohdearquant

@ohdearquant ohdearquant commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

What

ruvector-spann/src/distance.rs had four scalar iterator sums and no SIMD path.
l2_squared is called from eleven places across index.rs and kmeans.rs, so
it is the inner loop of partition assignment, spill selection, and search.

This adds an opt-in lattice-simd feature that routes the inner products
through lattice-embed's runtime-dispatched SIMD kernels (AVX-512F, AVX2,
NEON, wasm32 SIMD128, each with a scalar fallback). It is the same backend
split as #762, so a reader who has seen that one already knows this shape.

Default builds are unchanged. With the feature off, the crate's resolved
normal dependency tree is still empty (cargo tree -e normal prints the single
ruvector-spann node); the manifest gains one optional dependency that a
default build never compiles, and the code compiled is the code that was here
before.

What is deliberately not behind the feature flag

Only the accumulation moved. Length handling and the 1e-9 small-norm guard
stay outside the backend split, so both backends take identical branches and
can only differ in floating-point association.

Two specifics worth a reviewer's attention:

  • cosine_distance is composed from three inner products rather than calling
    lattice_embed::simd::cosine_similarity. That function applies its own
    zero-norm rule, while this module's contract is "return 1.0 below 1e-9". Using
    dot_product three times keeps the threshold the single place either backend
    decides it.
  • The lattice route is taken only when a.len() == b.len(). lattice returns
    f32::MAX (squared L2) or 0.0 (dot) on a mismatch, where the existing
    scalar path truncates to the shorter slice. The debug_assert_eq! already
    calls a mismatch a caller bug; guarding here means enabling the feature cannot
    silently change what a release build does with one.

MSRV

lattice-embed requires Rust >= 1.93 (edition 2024) and Cargo cannot express a
per-feature rust-version, so turning lattice-simd on raises the effective
MSRV for whoever turns it on. This is the same trade-off ruvector-core
documents for lattice-embeddings, and the same shape as the existing
simd-avx512 feature's documented per-feature bump (#438). Default builds are
unaffected.

The pin uses default-features = false, which excludes lattice-embed's model,
tokenizer, and download stack; what remains enabled is the SIMD kernel path
plus its small support dependencies, not a dependency-free graph.

Verification

Both feature settings, --locked, on the same machine:

build result
cargo test -p ruvector-spann --lib 12 passed, 0 failed
cargo test -p ruvector-spann --lib --features lattice-simd 13 passed, 0 failed

The default count includes the crate's existing k-means and index recall
acceptance tests, which exercise l2_squared through its real call sites
rather than directly. The feature build's extra test is a routing witness
asserting the lattice backend is actually the one called on the equal-length
path.

New backend_matches_reference compares whichever backend is compiled against
an f64 reference over 17 dimensions chosen to straddle the 4/8/16-lane widths
and the unrolled chunk sizes a SIMD backend uses, so remainder handling is
exercised rather than assumed. New cosine_zero_vector_is_not_nan pins the
small-norm branch.

Reachability, checked rather than assumed. A cfg-gated backend can be dead
and still let every test pass, so each arm was mutated individually and both
outcomes recorded:

mutation --features lattice-simd default
squared_euclidean_distance -> euclidean_distance 2 tests FAIL 12 pass
dot_product(b, b) -> dot_product(a, a) in the cosine path 1 test FAILS 12 pass

The failing column proves the lattice route is genuinely taken and the tests
detect it; the passing column proves each mutation stayed inside the cfg
block and the default path is untouched. Both were reverted; both settings are
green as pushed.

cargo fmt --all -- --check exits 0. cargo clippy -p ruvector-spann --all-targets produces zero diagnostics attributable to distance.rs under
either feature setting (139 lines of clippy output on the feature build, none
naming the file).

Cargo.lock

The lock gains the lattice-embed 0.7.1 entry and disambiguates
ruvector-core's existing lattice-embed edge to 0.6.1, since two versions
now appear. Re-resolution also wanted to drift tempfile's getrandom edge
from 0.3.4 to 0.4.3; that is unrelated to this change and was reverted, so the
diff is only the lattice entry. cargo check --locked passes on both feature
settings.

Both lattice-embed pins are behind off-by-default optional features, so a
default build compiles neither and the two versions only ever coexist in a
build that enables ruvector-core/lattice-embeddings and
ruvector-spann/lattice-simd at once. #758 moves the workspace pins to 0.7.1,
after which the question disappears; happy to rebase onto it if you would
rather land them in that order.

Benchmarks

When this PR was first opened, no speed claim was made: the measurement host
was too noisy at the time to certify an A/B (ambient idle sampled at 50-63%,
under the floor the harness enforces), and the position taken was to open with
correctness evidence only. A quiet window later became available; the
Measurement section below supersedes this paragraph and records the numbers
with their conditions.

Measurement

When this section was first written the crate had no benchmark to run — no
[[bench]] target, no benches/ directory, no criterion dev-dependency —
and the position taken was that a harness should arrive as its own reviewable
change rather than ride under a backend flag. That harness now exists as #782.
The numbers below were produced by cherry-picking #782's commit onto this PR's
head locally; if #782 lands first, this branch measures cleanly as-is.

Apple silicon Mac mini, macOS, aarch64, rustc 1.93.0, cargo bench -p ruvector-spann --bench distance, Criterion --measurement-time 10. This
crate's default feature set is empty, so the off side is the scalar path.
Same commit both phases; only the feature flag differs. CPU idle sampled every
20s inside each measured phase: off phase minimum 84%, on phase minimum 83% —
both phases quiet and symmetric, which is the cleanest window any measurement
in this PR series has had.

benchmark off (scalar) on (lattice-embed) change
l2_squared/128 29.14 ns 7.02 ns -76.0%
l2_squared/384 120.67 ns 18.78 ns -84.4%
l2_squared/768 309.76 ns 38.76 ns -87.2%
l2_squared/1536 692.15 ns 79.58 ns -88.5%
cosine_distance/128 72.33 ns 24.59 ns -65.9%
cosine_distance/384 349.17 ns 54.09 ns -84.5%
cosine_distance/768 890.65 ns 107.18 ns -88.0%
cosine_distance/1536 2.0258 us 219.27 ns -89.3%

All p = 0.00. Inputs are seeded-random, not constant fills.

What this does and does not claim: these are kernel-level numbers on the two
functions this PR routes, measured through the crate's own new harness. Nothing
here measures the SPANN index end to end, and the sibling PRs show the
kernel-to-end-to-end ratio varies widely between crates. The feature is off by
default; the default build keeps the scalar path bit-for-bit.


Note on this PR's CI. Tests (core-and-rest) is red on every branch in this repository,
including main: the job is cancelled at its 240-minute cap while still compiling and never
reaches the test phase. #786 restores the exclusion list that the shard's packages: value loses
to a shell comment, #784 unblocks the ruvector-filter test target that the compiler cannot
finish, and #787 fixes a deadlock waiting behind both. That failure is not caused by this branch.

The SPANN partition index computes l2_squared in twelve places across
index.rs and kmeans.rs, and every one of them ran a scalar iterator sum.

Adds an opt-in `lattice-simd` feature that routes the inner products
through lattice-embed's runtime-dispatched SIMD kernels (AVX-512, AVX2,
NEON, wasm32 SIMD128, each with a scalar fallback). Default builds are
byte-for-byte the same code as before and this crate's default dependency
set stays empty.

Only the accumulation changes. Length handling and the 1e-9 small-norm
guard stay outside the backend split, so both backends take identical
branches. cosine_distance is composed from three inner products rather
than calling lattice's cosine_similarity, because that function applies
its own zero-norm rule and this module's contract is the 1e-9 threshold.

The lattice route is taken only for equal-length inputs: lattice returns
f32::MAX (l2) or 0.0 (dot) on a mismatch where the scalar path truncates
to the shorter slice, and routing only the equal-length case keeps the
two backends from disagreeing on an input the debug assertion already
treats as a caller bug.

The dependency is pinned with default-features = false, which excludes
lattice-embed's model, tokenizer, and download stack and leaves only the
SIMD kernels.
0.7.1 is the current release. Keeping 0.7.0 here would land the lockfile one
patch behind on the day this merges and diverge from the sibling backend PRs
for no reason; 0.7.1 is additive over 0.7.0, so no code change is needed.

Re-resolution again wanted to move tempfile's getrandom edge from 0.3.4 to
0.4.3, unrelated to this change and reverted as before, so the lock diff is
only the lattice entry.
ohdearquant and others added 2 commits August 3, 2026 15:05
Add a lattice-simd-gated test that fails if the SIMD routing in
distance.rs is silently reverted to the scalar loops, and correct the
edge/WASM doc section that claimed the manifest has no dependencies at
all (the optional lattice-simd feature now adds lattice-embed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replace the bit-inequality selection test, which rejected a valid
lattice-embed scalar fallback on hosts without an accelerated SIMD
path, with a per-thread call witness set after each
lattice_embed::simd::* call returns. Also drop the README's
no_std/wasm-pack overclaim: the crate has no #![no_std] attribute
and uses std::cmp::Ordering in production code, and has no tested
WASM build configuration.
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.

1 participant