perf(maxsim): compute each query token's norm once per document - #764
Open
ohdearquant wants to merge 4 commits into
Open
perf(maxsim): compute each query token's norm once per document#764ohdearquant wants to merge 4 commits into
ohdearquant wants to merge 4 commits into
Conversation
maxsim calls cosine for every (query token, document token) pair, and cosine recomputed the query token's norm inside each of those calls. For a document of m tokens the same norm was accumulated m times. Hoists it to once per query token. The inner loop now does two multiply-adds per dimension instead of three, plus one norm per query token amortised over the whole document. The result is bit-identical, not merely close: the norm is accumulated in the same order and multiplied into the same denominator either way. A new test asserts that bitwise against the pre-hoist formulation, written out in full rather than delegating to cosine so a bug in the shared helper cannot cancel on both sides. Two more tests pin the degenerate cases, a zero-magnitude query token and a zero-magnitude document token. No public API change: cosine keeps its signature and now delegates to the same helper maxsim uses.
ohdearquant
marked this pull request as draft
August 2, 2026 14:37
cosine used to be one traversal of the operands accumulating dot, ||a||^2 and ||b||^2 together. It was changed to compute norm(a) as a separate pass and hand it to cosine_with_lhs_norm, so maxsim could hoist the query norm out of its inner loop over document tokens. That hoist helps maxsim, which reuses a fixed query token's norm across many document tokens: FlatMaxSim benchmarks improved by roughly 20%. But bucket.rs and hnsw.rs/graph.rs call the public cosine directly, once per vector pair, on their centroid and graph-traversal phases. For them the two-pass cosine only adds a redundant traversal of the left operand, and BucketMaxSim/HnswMaxSim benchmarks regressed accordingly. cosine is restored to its original single fused pass (three accumulators, one zip over both operands). cosine_with_lhs_norm, norm, and maxsim are unchanged, so the hoist and its benefit stay intact for maxsim's own inner loop while every direct cosine caller is back to its original cost. The test oracle that hand-duplicated the fused body is retired now that it would be identical to the restored cosine; the comparison instead checks that maxsim's hoisted composition (norm + cosine_with_lhs_norm) agrees with calling the public cosine per pair, which is the invariant that actually matters post-hoist. Co-Authored-By: claude-flow <ruv@ruv.net>
ohdearquant
marked this pull request as ready for review
August 3, 2026 18:08
Add a thread-local test-only counter inside norm() (used exclusively by maxsim's hoisted query-norm computation) to assert exactly one query-norm computation per query token, independent of document count. Add direct zero-only-document and all-zero-document-tokens assertions (0.0, is_finite(), and equivalence to the un-hoisted oracle) so the denominator guard in cosine_with_lhs_norm is independently checkable rather than only implied by an equivalence test that a max-fold can mask. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
maxsim's hoisted per-query-token norm is only bit-identical to the pre-hoist per-pair cosine when query and document tokens have equal length: norm(q) is taken over the full query, so combining it with a dot product zipped (and thus truncated) to a shorter document token silently changes the score. Fall back to a truncating fused cosine for mismatched-length pairs, matching the original single-pass cosine's truncating-zip behavior instead of the hoisted fast path's full-length norm. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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
maxsimcallscosineonce for every (query token, document token) pair, andcosinerecomputed the query token's norm inside each of those calls. For adocument of
mtokens, the sameΣ q_i²was accumulatedmtimes.This hoists it: each query token's norm is computed once and reused across the
whole document.
Arithmetic
Counted by reading the loop, not measured:
ai*bi,ai*ai,bi*bi)ai*bi,bi*bi)plus one
Σ q_i²per query token, amortised over the whole document. Fordocuments of any realistic length the inner loop does two thirds of the work it
used to.
An earlier revision of this description attached no wall-clock figure because the
measurement host at the time could not certify one. Measurements were subsequently
taken under sampled idle conditions; see the Measurement section below.
Correctness: bit-identical, and checked as such
The norm is accumulated in the same order and multiplied into the same
denominator either way, so this refactor should not move the result by a single
ULP.
hoisting_query_norm_is_bit_exactasserts exactly that, comparingf32::to_bits()rather than an epsilon, across 7 dimensions and 4query/document shapes. An epsilon comparison would have hidden a real change in
the arithmetic behind a tolerance.
At the current head the comparison oracle is the shipping fused
cosineitself:since the second commit restored
cosineto its own single-pass body, the hoistedhelper and
cosineshare no accumulation code, so a bug in either side cannotcancel against the other. An earlier revision of this text described the oracle as
an independently written-out copy; that stopped being the implementation when
cosinewas restored, and the claim is corrected here.Two more tests pin the degenerate branches the
f32::EPSILONdenominator guardexists for:
zero_query_token_scores_zeroandzero_doc_token_does_not_poison_max(a zero-magnitude document token mustscore 0.0 and lose the
max, not becomeNaNand win it). Direct coverage of thezero/NaN branch itself — single- and multi-token all-zero documents asserted exactly
0.0, finite, and equal to the per-pair oracle — was added in a later commit.
Verification
cargo test -p ruvector-maxsim --lib: 31 passed, 0 failed at the current head(26 when this section was first written; later commits added the call-count
witness, the direct zero-branch tests, and the mismatched-dimension regression
tests). That includes the
crate's existing flat / graph / bucket / hnsw index tests, which reach
maxsimthrough its four real call sites.
Mutation-sensitivity, each part mutated separately rather than the patch as a
whole:
norm()returns the sum of squares without thesqrtcosine_with_lhs_normaccumulatesai*aiintonbinstead ofbi*biThe second one is the important arm: it lives in the helper both the new and
old paths would share if the oracle delegated, so it is the mutation an
insufficiently independent test would have missed.
Both reverted.
cargo fmt --all -- --checkexits 0.cargo clippy -p ruvector-maxsim --all-targetsemits 175 lines and none of them namescore.rs.Mismatched-dimension semantics (current head)
Hoisting the query norm changed release-mode scores for mismatched-length
query/document pairs: the base fused loop truncated both norms to the shorter
length, while the hoisted path computed the full query norm. Flat and Bucket do
not validate query dimensions and HNSW validates documents only, so such inputs
are externally reachable. The current head routes equal-length pairs through the
hoisted path and mismatched-length pairs through a private fused truncating
cosine that reproduces the base accumulation exactly, restoring pre-hoist
semantics for every input. Two regression tests pin this; removing the branch in
a release build reproduces the predicted 25/(13*5) = 0.3846 counterexample.
Rejecting mismatched dimensions outright at the index boundary (as Graph already
does, and as the trait documentation promises) would be the stricter long-term
answer, but that is an API-contract change proposed separately rather than
smuggled into a perf PR.
Scope
One file, no dependency change, no
Cargo.lockchange, no public API change:cosinekeeps its signature and (since the second commit) its original fusedsingle-pass body; the hoisted helper is private to
maxsim.Measurement
Apple silicon Mac mini, macOS, aarch64, rustc 1.93.0, the crate's own
maxsim_benchtarget. Base is always the merge-base withmain. Criterion--measurement-time 15, baseline saved on the base phase. CPU idle sampledevery 20s inside each measured phase; per-phase minima are stated with each
run because the host also runs a browser with fluctuating load, and a figure
that hides its conditions is indistinguishable from one taken on a quiet
machine.
Round 1 — hoist only (first head), and a regression it exposed
Idle minima: base 72%, head 87%. That asymmetry favours the head, which makes
the one regression below more credible, not less.
The pattern maps exactly onto call structure.
FlatMaxSim::searchreaches thismodule only through
maxsim, which got the hoist: it improves.BucketMaxSimand
HnswMaxSimadditionally call the publiccosinedirectly (centroidscoring, graph traversal), and the first version of this patch had made
cosinedelegate tonorm(a)+cosine_with_lhs_norm— two traversals ofawhere the original fused loop did one. Callers that hold the query fixedacross many documents gained; callers invoking
cosineonce per pair paid.The fix
The second commit restores
cosineto its original single fused pass (threeaccumulators, one traversal) and keeps the hoisted path private to
maxsim.Direct
cosinecallers are byte-for-byte back on the pre-patch code path.Round 2 — at the current head
Idle minima: base 50%, head 62%. Below the bar I would call certified, and this
time the asymmetry runs against the base, which flatters the head; the reason
to believe the table anyway is agreement with round 1 on the untouched groups
(FlatMaxSim within a point across four independent runs) and the elimination
of a regression that the noisier base phase would tend to exaggerate, not hide.
The +2.9% Bucket regression is gone, Bucket/500 improves further (its Phase-2
maxsimstill benefits while its Phase-1 centroidcosineno longer pays),and no group is left worse than base. An earlier 5s-measurement run had shown
Bucket/2000 at +9.8%; Criterion warned it could not fit its sample count at
that size, and the 15s runs do not reproduce it, so it is discarded as
underpowered rather than averaged in.
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.