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
Open
Conversation
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
marked this pull request as ready for review
August 3, 2026 18:08
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The gap, in your own words
ruvector-graph's schema scan path scores every candidate through three hand-rolled scalarloops. The
dothelper carried this note:Those two constraints are the whole reason this layer is still scalar, and they are exactly what
lattice-embedsatisfies:optional = truebehind an off-by-defaultlattice-simdfeature, so a no-feature build is byte-for-byte the build you have now.
simd128, which is where this matters most: onwasm32 the
simdfeature 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'ssimdfeature saidruvector-coreexcludes simsimd onwasm32. It does not —
simsimdis a plain optional dependency and still resolves into the wasm32graph (
cargo tree --target wasm32-unknown-unknownshows it, same count as the host). What isactually gated is core's SimSIMD call sites, on
not(target_arch = "wasm32"), so the featuretakes 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-embedunderlattice-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_pretakes aprecomputed
query_normso a scan loop hoists‖query‖out of the per-candidate work. Atwo-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-embed0.7.1 addscosine_similarity_pre_normalized(query, candidate, query_norm), whichtakes 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 mismatchinstead. Unequal inputs keep taking the scalar path so the public behaviour of
score_preisunchanged.
score_propertyalready rejects mismatched dimensions on both of its paths, so thisonly concerns direct callers of
score_pre.Verification
test -p ruvector-graph --features lattice-simd --libtest -p ruvector-graph --lib(default)test -p ruvector-graph --features simd --libclippy --features lattice-simd --all-targets -- -D warningsclippy --all-targets -- -D warningsThe 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:
lattice-simdarmdot:dot_product(a, b)->dot_product(a, a)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 thecaller passed
‖query‖. Under that mutation the plain cosine comparison stays silent and only therescaling assertion fires (
got 1, want 0.5).Each mutation leaving the default arm green is what proves it stayed inside its own
cfgblockrather than breaking the crate outright.
Two things worth stating
This resolves a second copy of
lattice-embedinto the workspace.ruvector-corepins^0.6; this pins^0.7, and those cannot unify, so until the other in-flight lattice PRs land, abuild enabling both features compiles it twice. Verified with
cargo treerather than read offthe 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-embedrequires Rust >= 1.93, so enabling this feature raises the effective MSRV forwhoever 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_benchtarget, Criterion--measurement-time 10. Both phases arethe same commit and differ only by the feature flag:
mainhas 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_preinschema.rs;typed_graph_benchdrives the fusedsearch_then_traverseoperator throughruvector_graph::schema, so the changedpath is on the benchmarked one.
search_then_traverse/1000search_then_traverse/10000search_then_traverse/50000hash_embed_256validate_noderrf_2x1000The 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_256is flat at p = 1.00. That is the result a group outside thechange should give, and it says the harness did not shift uniformly between
phases.
validate_nodemoved +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.
search_then_traverse/1000search_then_traverse/10000search_then_traverse/50000hash_embed_256validate_noderrf_2x1000search_then_traverseimproves by ~9-15% in both runs at every size. That isthe changed path, and the direction and rough magnitude replicate under two
different noise profiles.
validate_nodedeserves 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 neverreaches the test phase. #786 restores the exclusion list that the shard's
packages:value losesto a shell comment, #784 unblocks the
ruvector-filtertest target that the compiler cannotfinish, and #787 fixes a deadlock waiting behind both. That failure is not caused by this branch.