Skip to content

perf(maxsim): compute each query token's norm once per document - #764

Open
ohdearquant wants to merge 4 commits into
ruvnet:mainfrom
ohdearquant:perf/maxsim-hoist-query-norm
Open

perf(maxsim): compute each query token's norm once per document#764
ohdearquant wants to merge 4 commits into
ruvnet:mainfrom
ohdearquant:perf/maxsim-hoist-query-norm

Conversation

@ohdearquant

@ohdearquant ohdearquant commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

What

maxsim calls cosine once 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 Σ q_i² was accumulated m times.

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:

multiplies per dimension, per pair adds
before 3 (ai*bi, ai*ai, bi*bi) 3
after 2 (ai*bi, bi*bi) 2

plus one Σ q_i² per query token, amortised over the whole document. For
documents 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_exact asserts exactly that, comparing
f32::to_bits() rather than an epsilon, across 7 dimensions and 4
query/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 cosine itself:
since the second commit restored cosine to its own single-pass body, the hoisted
helper and cosine share no accumulation code, so a bug in either side cannot
cancel against the other. An earlier revision of this text described the oracle as
an independently written-out copy; that stopped being the implementation when
cosine was restored, and the claim is corrected here.

Two more tests pin the degenerate branches the f32::EPSILON denominator guard
exists for: zero_query_token_scores_zero and
zero_doc_token_does_not_poison_max (a zero-magnitude document token must
score 0.0 and lose the max, not become NaN and win it). Direct coverage of the
zero/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 maxsim
through its four real call sites.

Mutation-sensitivity, each part mutated separately rather than the patch as a
whole:

mutation result
norm() returns the sum of squares without the sqrt 2 tests fail
cosine_with_lhs_norm accumulates ai*ai into nb instead of bi*bi 2 tests fail

The 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 -- --check exits 0. cargo clippy -p ruvector-maxsim --all-targets emits 175 lines and none of them name
score.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.lock change, no public API change:
cosine keeps its signature and (since the second commit) its original fused
single-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_bench target. Base is always the merge-base with main. Criterion
--measurement-time 15, baseline saved on the base phase. CPU idle sampled
every 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.

group change
FlatMaxSim/500 -19.4%
FlatMaxSim/2000 -20.1%
BucketMaxSim/500 -3.4%
BucketMaxSim/2000 +2.9% regression
HnswMaxSim/500 within noise
HnswMaxSim/2000 +2.4%, within noise

The pattern maps exactly onto call structure. FlatMaxSim::search reaches this
module only through maxsim, which got the hoist: it improves. BucketMaxSim
and HnswMaxSim additionally call the public cosine directly (centroid
scoring, graph traversal), and the first version of this patch had made
cosine delegate to norm(a) + cosine_with_lhs_norm — two traversals of
a where the original fused loop did one. Callers that hold the query fixed
across many documents gained; callers invoking cosine once per pair paid.

The fix

The second commit restores cosine to its original single fused pass (three
accumulators, one traversal) and keeps the hoisted path private to maxsim.
Direct cosine callers 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.

group change
FlatMaxSim/500 -20.7%
FlatMaxSim/2000 -20.6%
BucketMaxSim/500 -7.8%
BucketMaxSim/2000 no change (p = 0.41)
HnswMaxSim/500 -2.5%, within noise
HnswMaxSim/2000 -5.7%

The +2.9% Bucket regression is gone, Bucket/500 improves further (its Phase-2
maxsim still benefits while its Phase-1 centroid cosine no 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 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.

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
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
ohdearquant marked this pull request as ready for review August 3, 2026 18:08
ohdearquant and others added 2 commits August 3, 2026 15:05
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>
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