Skip to content

feat(graph): optional lattice-embed kernels for the schema scan path, including the precomputed-norm cosine arm - #767

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

feat(graph): optional lattice-embed kernels for the schema scan path, including the precomputed-norm cosine arm#767
ohdearquant wants to merge 4 commits into
ruvnet:mainfrom
ohdearquant:feat/graph-lattice-simd

Conversation

@ohdearquant

@ohdearquant ohdearquant commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

The gap, in your own words

ruvector-graph's schema scan path scores every candidate through three hand-rolled scalar
loops. The dot helper carried this note:

SIMD via simsimd/ruvector-core is a follow-up (ADR-252 P5) but is deliberately not a hard
dependency here so the schema layer stays WASM- and no-feature-build-safe.

Those two constraints are the whole reason this layer is still scalar, and they are exactly what
lattice-embed satisfies:

  • Not a hard dependency. It goes in optional = true behind an off-by-default lattice-simd
    feature, so a no-feature build is byte-for-byte the build you have now.
  • Reaches wasm32. Its kernels compile to simd128, which is where this matters most: on
    wasm32 the simd feature does not vectorize this path at all.

One correction on that last point, because I found the existing note about it overstated. A
comment on ruvector-graph-wasm's simd feature said ruvector-core excludes simsimd on
wasm32. It does not — simsimd is a plain optional dependency and still resolves into the wasm32
graph (cargo tree --target wasm32-unknown-unknown shows it, same count as the host). What is
actually gated is core's SimSIMD call sites, on not(target_arch = "wasm32"), so the feature
takes the scalar arm there. The conclusion holds, the stated mechanism did not, and this PR fixes
the comment.

The change

All three metrics plus the query-norm hoist now route through lattice-embed under
lattice-simd, leaving the scalar bodies in place as the default and the length-mismatch path.

The cosine arm is the interesting one. It could not be vectorized by a conventional kernel,
and that is a design property of your API rather than an oversight: score_pre takes a
precomputed query_norm so a scan loop hoists ‖query‖ out of the per-candidate work. A
two-argument kernel cosine recomputes that norm on every candidate, which discards the hoist the
signature exists for, and silently ignores a caller-supplied norm that differs from ‖query‖.

lattice-embed 0.7.1 adds cosine_similarity_pre_normalized(query, candidate, query_norm), which
takes the precomputed norm and divides by it, so a supplied norm rescales the result exactly as
your scalar arm does. The pin is 0.7.1 rather than 0.7.0 because that function does not exist in
0.7.0 — a build requirement, not a tidy-up.

Equal-length guards are required, not defensive. Your scalar arms truncate to
min(query.len(), candidate.len()); the kernels return a fixed value on a length mismatch
instead. Unequal inputs keep taking the scalar path so the public behaviour of score_pre is
unchanged. score_property already rejects mismatched dimensions on both of its paths, so this
only concerns direct callers of score_pre.

Verification

arm result
test -p ruvector-graph --features lattice-simd --lib 180 passed, 0 failed, 3 ignored
test -p ruvector-graph --lib (default) 179 passed, 0 failed, 3 ignored
test -p ruvector-graph --features simd --lib 179 passed, 0 failed, 3 ignored
clippy --features lattice-simd --all-targets -- -D warnings clean
clippy --all-targets -- -D warnings clean

The feature arm's extra test is a routing witness asserting the lattice cosine
backend is the one actually called on the equal-length scan path.

The parity test compares whichever backend compiled in against naive scalar references across
dimensions straddling 4/8/16-lane widths and their remainders, so it is backend-independent and
covers the scalar path too.

Test coverage added beyond the routing itself: the cosine score was previously unchecked (only
its hoisted norm was), there was no assertion that a caller-supplied norm rescales rather than
being ignored, and the truncation case covered dot and euclidean but not cosine.

Mutation-checked per adapter, not per patch, since mutating a patch as a unit only certifies
its best line:

mutation lattice-simd arm default arm
dot: dot_product(a, b) -> dot_product(a, a) fails stays green
euclidean: drop the negation fails stays green
cosine: scale the result by 2 fails stays green
cosine: recompute the norm instead of using the supplied one fails stays green

That last one is the reason the new assertion exists rather than being decoration: it is invisible
to every correctness check, because recomputing ‖query‖ gives the right answer whenever the
caller passed ‖query‖. Under that mutation the plain cosine comparison stays silent and only the
rescaling assertion fires (got 1, want 0.5).

Each mutation leaving the default arm green is what proves it stayed inside its own cfg block
rather than breaking the crate outright.

Two things worth stating

This resolves a second copy of lattice-embed into the workspace. ruvector-core pins
^0.6; this pins ^0.7, and those cannot unify, so until the other in-flight lattice PRs land, a
build enabling both features compiles it twice. Verified with cargo tree rather than read off
the lockfile.

On performance claims. When this was first opened no measurement was attached: at the time
the host could not certify A/B deltas in the plausible range (an A/A control on byte-identical
source produced differences up to 9.97%). The Measurement section below was added later and
supersedes that position; it states explicitly what its two runs on a non-certified host do and
do not support.

Cost

lattice-embed requires Rust >= 1.93, so enabling this feature raises the effective MSRV for
whoever turns it on. The default build is unaffected. That is the real price and it is why the
feature is opt-in and off by default.

Measurement

Apple silicon Mac mini, macOS, aarch64, rustc 1.93.0, the crate's own
typed_graph_bench target, Criterion --measurement-time 10. Both phases are
the same commit and differ only by the feature flag: main has no such feature,
so a base-vs-head comparison would measure nothing about the backend.

Reach was established before measuring rather than assumed. The changed code is
VectorSchema::score_pre in schema.rs; typed_graph_bench drives the fused
search_then_traverse operator through ruvector_graph::schema, so the changed
path is on the benchmarked one.

group off on change (95% CI) p
search_then_traverse/1000 119.31 us 105.22 us -12.36% .. -10.77% 0.00
search_then_traverse/10000 658.11 us 577.33 us -11.96% .. -10.91% 0.00
search_then_traverse/50000 4.7239 ms 4.4194 ms -6.64% .. -6.26% 0.00
hash_embed_256 266.28 ns 270.62 ns -1.30% .. +2.27% 1.00
validate_node 68.926 ns 77.521 ns +7.24% .. +13.24% 0.00
rrf_2x1000 97.919 us 97.178 us -0.90% .. -0.41% 0.00

The last three groups do not touch vector scoring, and they are the reason this
table is worth reading carefully rather than quoted for its first three rows.

hash_embed_256 is flat at p = 1.00. That is the result a group outside the
change should give, and it says the harness did not shift uniformly between
phases.

validate_node moved +10%. Nothing in this diff can slow down node validation,
so that number is measuring the machine, not the code. It is a sub-100-nanosecond
benchmark, which is where ambient load shows up first.

Conditions, and a second run

CPU idle was sampled every 20s inside each measured phase. Run 1: minimum 16%
off / 20% on. Run 2 (same commit, fresh baseline, later the same day): minimum
16% both phases. The host runs a browser whose CPU draw fluctuates and neither
run meets a quiet-machine bar; what carries the result is agreement between
the two runs, not either run's idle trace.

group run 1 run 2
search_then_traverse/1000 -11.6% -9.5%
search_then_traverse/10000 -11.4% -15.2%
search_then_traverse/50000 -6.4% -10.6%
hash_embed_256 +0.0% (p=1.00) +1.5%
validate_node +10.0% +12.9%
rrf_2x1000 -0.7% +3.6%

search_then_traverse improves by ~9-15% in both runs at every size. That is
the changed path, and the direction and rough magnitude replicate under two
different noise profiles.

validate_node deserves the honest paragraph. It moved +10% and then +12.9% —
reproducing across runs, which ambient noise would not do — on a sub-100 ns
benchmark whose code this diff cannot reach. The remaining mechanism consistent
with both observations is a build-level effect: enabling the feature changes
what is compiled into the bench binary, and code layout shifts of that kind
land hardest on nanosecond-scale benchmarks. It is a real, reproducible cost of
flipping the feature on for this binary, but it is not a property of the
changed code path, and at index scale (the microsecond-and-up groups) no
corresponding penalty appears.

A certified-quiet re-run on a dedicated host is still the right ask before
these numbers are quoted as a benchmark. What two runs on a noisy host support
is: the scan path this PR touches gets a reproducible high-single to
low-double-digit improvement, the untouched microsecond-scale groups are flat,
and the one moving nanosecond group moves for build-layout reasons, not
algorithmic ones.


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.

Routes the schema layer's dot product and Euclidean scoring through
lattice-embed behind an off-by-default `lattice-simd` feature, with a
passthrough feature on ruvector-graph-wasm.

The dependency is optional and its kernels compile for wasm32, which is what
keeps this layer WASM-safe and no-feature-build-safe — the two properties that
kept simsimd out of it. Equal-length guards preserve the existing truncating
behaviour for mismatched slices, which the kernels do not share.
…ernel

Completes the schema scan path: the cosine arm previously stayed scalar because
a two-argument kernel cosine recomputes the query norm per candidate, which
discards the hoist `score_pre`'s signature exists for and ignores a
caller-supplied norm. lattice-embed 0.7.1 adds a kernel that takes the
precomputed norm and rescales by it, so the arm now routes with the same
equal-length guard as the others. The pin moves to 0.7.1 because that function
does not exist in 0.7.0, making it a build requirement rather than a tidy-up.

Tests gain cosine score coverage (previously only its hoisted norm was checked),
an assertion that a caller-supplied norm rescales rather than being ignored, and
a truncation case for the cosine arm.

Also corrects a comment on ruvector-graph-wasm's `simd` feature: it said
ruvector-core excludes simsimd on wasm32. It does not — the crate still resolves
into the wasm32 graph. What is gated is core's SimSIMD call sites, on
`not(target_arch = "wasm32")`, so the feature takes the scalar arm there.
@ohdearquant
ohdearquant marked this pull request as ready for review August 3, 2026 18:08
ohdearquant and others added 2 commits August 3, 2026 15:04
Add a lattice-simd-gated regression test that fails if the equal-length
cosine arm in DistanceMetric::score_pre stops calling
lattice_embed::simd::cosine_similarity_pre_normalized and silently falls
through to the scalar reference implementation. The existing parity tests
only compare numerical output, which the scalar fallback also satisfies.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Move the lattice-cosine selection witness from a pre-call flag to a
per-thread Cell holding the value the kernel actually returned, set
only once the call has completed. The selection test cross-checks
that value bit-for-bit against a fresh, independent call into
lattice_embed::simd::cosine_similarity_pre_normalized, so a reversion
that swaps in the scalar computation (even keeping the same store)
still diverges. thread_local storage also means another test's
cosine calls, running on their own thread, cannot satisfy this
test's assertion.
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