feat(tiny-dancer-core): make the cosine backend selectable, with a lattice-simd option - #768
Open
ohdearquant wants to merge 2 commits into
Open
feat(tiny-dancer-core): make the cosine backend selectable, with a lattice-simd option#768ohdearquant wants to merge 2 commits into
ohdearquant wants to merge 2 commits into
Conversation
The candidate-scoring cosine kernel was wired directly to one library through a hard dependency, and the crate had no `[features]` section at all, so there was no way to build it against anything else or without a SIMD library. This adds a feature axis with three mutually exclusive arms and leaves the default build on the existing backend: default (simd-simsimd) -> SimSIMD, unchanged lattice-simd -> lattice-embed kernels --no-default-features -> scalar The two SIMD libraries disagree on what cosine returns: one gives a distance and the other a similarity, so the adapters differ by the `1 - x` and the arms are not interchangeable text. They also disagree on zero norms. The conventions this path has always returned are preserved on every arm: two all-zero vectors score 1.0, a zero vector against a non-zero one scores 0.0, and a length mismatch stays an error. `backend_matches_scalar_reference` checks whichever backend compiled in against an f64 single-pass reference across 21 dimensions straddling 4/8/16-lane widths and their remainders, so it covers the backend shipping today as well as the new ones. Its reference is deliberately not the crate's own scalar function, which is itself an f32 4-accumulator backend: using it would let a shared reduction-order bug pass. `backend_boundary_conventions_agree` pins the zero-norm and mismatch values exactly. lattice-embed requires Rust >= 1.93 and Cargo cannot express a per-feature `rust-version`, so enabling `lattice-simd` raises the effective MSRV for whoever turns it on. The default build is unaffected.
ohdearquant
marked this pull request as ready for review
August 3, 2026 18:08
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.
What this is
ruvector-tiny-dancer-corescores candidates through one cosine kernel, wired directly toSimSIMD through a hard dependency. The crate has no
[features]section at all, so there is noway to build it against a different kernel, and no way to build it without a SIMD library. This
adds a feature axis with three mutually exclusive arms and leaves the default on what ships
today.
simd-simsimd)lattice-simdlattice-embedkernels--no-default-featureslattice-simdwins if both are enabled, so the arms stay mutually exclusive and exhaustive.Let me be straight about what this does not buy you
I went into this expecting the wasm argument that motivates the same feature in
ruvector-coreto apply here, and it does not. I measured before writing the PR rather than after, so this
section is a correction to my own premise rather than a caveat bolted onto a claim.
The pure-Rust-dependency argument does not transfer to this crate. It is real for
ruvector-diskann, where SimSIMD is the reason a C toolchain is needed. Hereruvector-tiny-dancer-corealso hard-depends onrusqlitewithbundled, which compilesSQLite from C. Removing SimSIMD's C-ness does not make this crate buildable without a C
compiler, so I am not going to pretend it does.
This does not fix the wasm build, for reasons in the next section.
What is left is smaller and still worth having: a backend that can be selected at all, a scalar
path where there was none, and — for callers that opt in — the option of converging on one
kernel set instead of two implementations of the same arithmetic with independent rounding
behaviour inside one search path. To be precise about state versus option: the default build
still selects SimSIMD and the lockfile currently carries two
lattice-embedversions, so thisPR offers the convergence, it does not put the workspace in that state.
Performance. An earlier revision of this description made no performance claim because the
local 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%). Numbers were subsequently taken on a host that can
support the claim; see the Measurement section below.
Something I found while scoping this, unrelated to the change
ruvector-tiny-dancer-wasmdoes not build forwasm32-unknown-unknown. It iscrate-type = ["cdylib", "rlib"]withwasm-bindgen, and on unmodifiedmain:That is the first blocker, not the only one. Shimming it locally the way
ruvector-diskann/Cargo.tomlalready does for the same crate:moves the failure to the next one:
libsqlite3-sysbuilding bundled SQLite for wasm32, reached through the unconditionalrusqlitedependency inruvector-tiny-dancer-core. I stopped there and reverted the probe, soI do not know whether SimSIMD is a third blocker behind it — the build never gets that far.
Why CI does not catch it:
ruvector-tiny-dancer-wasmis only ever compiled for the nativehost. It appears in the nextest package list in
ci.ymland in an--excludelist in anothernative job, and none of the PR-triggered CI workflows build it for
wasm32-unknown-unknown(the release workflow does run
wasm-pack buildfor this crate, so the categorical statementstops at PR CI). The
wasm32target jobs thatdo exist (
build-attention.yml,build-graph-transformer.yml,metabiohacker-ci.yml) coverother crates. A gate whose trigger set does not intersect what its instrument actually reaches
produces greens that read as evidence.
I am reporting this rather than fixing it because the fix is a scoping decision about your
storage layer, not a drive-by. If you do want the wasm path, the order looks like: gate
rusqlitebehind a feature or a target, shimgetrandom, and then the SIMD layer is the nextthing in the way — which this PR makes removable rather than removes:
lattice-embedcompilesfor wasm32 and SimSIMD does not, but
ruvector-tiny-dancer-wasmstill consumes this crate'sdefault features, so its manifest would also need
default-features = falsepluslattice-simdfor the swap to reach it. That is an ordering observation, not a claim that this
PR unlocks anything on its own.
Conventions, which are the interesting part
The two libraries disagree on what "cosine" returns.
SpatialSimilarity::cosinereturns adistance, which is why the existing code reads
1.0 - f32::cosine(a, b).lattice_embed::simd::cosine_similarityreturns a similarity directly, so its adapter has no1 - x. The arms are not interchangeable text and a copy-paste between them would silentlyinvert the score.
They also disagree on zero norms.
lattice-embedreturns0.0for a zero norm, which collideswith the genuine "orthogonal" answer. The behaviour this path has always had is preserved on
every arm:
1.00.0I did not assume those values, I measured them:
backend_boundary_conventions_agreeasserts themexactly and passes on the unmodified default arm, which is what establishes them as the
incumbent behaviour rather than my guess at it. Restoring the all-zero case costs a cold-path
check that only runs when the kernel already returned
0.0.For the record,
1.0for two all-zero vectors is arguably the wrong answer, since cosine isundefined there. I have deliberately not changed it. A backend swap that also changes semantics
is two changes wearing one hat, and which value that should be is your call, not mine.
Verification
test --lib(default, SimSIMD)test --features lattice-simd --libtest --no-default-features --lib(scalar)test --no-default-features --features lattice-simd --libclippy --all-targets -- -D warnings(default)clippy --features lattice-simd --all-targets -- -D warningsclippy --no-default-features --all-targets -- -D warningsclippy --no-default-features --features lattice-simd --all-targets -- -D warningsSame test count in every arm, and both new tests are named in each arm's output, which is what
shows they are backend-independent rather than compiled into one configuration.
Mutation-checked per adapter, not per patch, since mutating a patch as a unit only certifies
its best line. Each mutation must fail its own arm and leave the default arm green — the second
half is what proves it stayed inside its own
cfgblock instead of breaking the crate outright.cosine_similarity(a, b)->(a, a)1.0restorationsqrton both norms0.0instead of1.0The probe echoes each mutation's diff before running, so a regex that silently failed to apply
cannot be scored as a passing arm, and it reports an instrument failure rather than a result if
no test suite actually ran.
Two consequences worth stating
This resolves a second copy of
lattice-embedinto the workspace.ruvector-corepins0.6.1; this pins0.7.1, which is the version the other in-flight lattice PRs move to.^0.6and
^0.7cannot unify, so until those land, a build enabling bothruvector-core/lattice-embeddingsandruvector-tiny-dancer-core/lattice-simdcompileslattice-embedtwice. Both versions are visible inCargo.lock. Whichever of these PRs landssecond will want a lockfile rebase.
The lockfile diff is
lattice-embedonly. The resolver also wanted to movetempfile'sgetrandomedge from0.3.4to0.4.3; that is unrelated to this change and not required byit, so it is reverted here and
cargo check -p ruvector-tiny-dancer-core --features lattice-simd --lockedresolves cleanly without it.Cost
lattice-embedrequires Rust >= 1.93. Enabling this feature raises the effective MSRV forwhoever turns it on. The default build is unaffected and stays on the workspace MSRV. 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
feature_engineeringbench target, Criterion--measurement-time 10. Bothphases are the same commit and differ only by the feature flag.
The baseline here is not scalar. This crate's
defaultis["simd-simsimd"],so the off side is SimSIMD and this table is a backend-to-backend comparison,
which is the harder of the two comparisons a backend PR can be asked to win.
cosine_similarity_384dfeature_weighting(3 cases)CPU idle was sampled every 20s inside each measured phase, three samples each.
Off phase minimum 76%, on phase minimum 68%. The on phase dipped slightly below
the bar I hold myself to, and the direction of that dip runs against the on side
rather than for it.
One number here is worth flagging as an open question rather than a result. In
ruvector-core, the same SimSIMD-to-lattice swap on 384-dimension cosinemeasures roughly -52%; here the same swap on a 384-dimension cosine measures
-33%, and the SimSIMD side itself costs 107.7 ns here against roughly 57 ns
there for what should be the same kernel and the same dimension. The difference
is wrapper overhead in this crate's cosine path, not the kernel, and it is
untouched by this PR. It is the reason the end-to-end
feature_weightingnumbersland at -8 to -10% rather than tracking the kernel figure: the kernel is not the
whole cost on this path.
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.