Skip to content

fix(tests): isolate selection cost from dot-product cost in VectorSearcher benchmark - #33

Merged
Platonenkov merged 2 commits into
mainfrom
claude/perf-test-ratio-524a4
Aug 4, 2026
Merged

fix(tests): isolate selection cost from dot-product cost in VectorSearcher benchmark#33
Platonenkov merged 2 commits into
mainfrom
claude/perf-test-ratio-524a4

Conversation

@Platonenkov

Copy link
Copy Markdown
Collaborator

Summary

  • Search_At8735By1024RealisticScale_CompletesWithinAGenerousBound was flaky (Flaky: VectorSearcherTests realistic-scale ratio assertion fails intermittently on ubuntu #32): it timed the whole Search pipeline (dot products + heap selection) against a reference that recomputed the same ~8735 x 1024 dot products plus a full sort. Both paths pay the identical, dominant dot-product cost, which should cancel out of the ratio — but on one CI run it did not (ratio 2.112 against a 1.5 ceiling), then passed on an identical re-run.
  • Split VectorSearcher.Search into two internal steps, ComputeScores and SelectTopKFromScores, so the benchmark can time only the selection strategies against a fixed, precomputed scores array instead of the fused pipeline. This removes the shared dot-product cost from the measured window entirely, so the ratio reflects what the assertion has always claimed to compare — bounded-heap selection vs. a full sort, not "score + select" vs. "score + select".
  • Reuses the real SelectTopKFromScores (via a new, narrowly-scoped InternalsVisibleTo) rather than duplicating the heap logic in test code, since a hand-rolled copy risks silently drifting from the real tie-break rule (already the subject of a documented regression in this same test file).
  • With the shared cost gone, local measurement clusters tightly around 0.02-0.03 across 60 runs (solo and under the suite's default parallelism), so the ceiling tightens from 1.5 to 1.0 — about 30x headroom over the observed value.

Root cause

Both Search and the reference full sort call the identical TensorPrimitives.Dot — ruling out "different dot-product implementations." Code inspection found no allocation or hardware-branch asymmetry that would explain a 2x+ slowdown specific to the heap path. The most defensible explanation, given the CI failure was on an otherwise-identical re-run, is generic environment noise (GC/scheduler contention from xUnit's default parallel-by-collection execution) landing inside the measured window — made worse by the old test measuring "shared dot products + selection" instead of selection alone, so a few milliseconds of unavoidable shared cost diluted (and occasionally let noise dominate) the very small selection-cost signal the assertion cares about.

Test plan

  • Full suite: 408/408 passing (dotnet test CodeIndexMcp.slnx -c Release)
  • Isolated selection benchmark run 60x total (solo, -parallel none, and under the suite's default parallel-by-collection execution): ratio consistently 0.019-0.027, well under the new 1.0 ceiling

…rcher benchmark

Search_At8735By1024RealisticScale_CompletesWithinAGenerousBound was flaky
(issue #32): it timed the whole Search pipeline (dot products + heap
selection) against a reference that recomputed the same dot products plus a
full sort. Both paths pay the identical, dominant dot-product cost, which
should cancel out of the ratio, but on one CI run it did not: the ratio
spiked to 2.112 against a 1.5 ceiling, then passed on an identical re-run.

Split VectorSearcher.Search into two internal steps, ComputeScores and
SelectTopKFromScores, so the benchmark can time only the selection
strategies against a fixed, precomputed scores array instead of the fused
pipeline. This removes the shared dot-product cost from the measured window
entirely, so the ratio reflects what the assertion has always claimed to
compare. Reusing the real SelectTopKFromScores (via InternalsVisibleTo)
rather than duplicating the heap logic in test code avoids re-deriving the
tie-break rule that VectorSearcherTests already documents as easy to get
subtly wrong.

With the shared cost gone, local measurement clusters tightly around
0.02-0.03 across 60 runs (solo and under the suite's default parallelism),
so the ceiling tightens from 1.5 to 1.0 while gaining roughly 30x headroom
over the observed value.
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8d6722fa-1232-4cf9-86d2-8a1f6ecad075

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

The split moved a cost onto the production path that the PR did not state:
the previous fused loop offered each score to the heap as it was computed
and never held more than topK entries, while materialising all scores first
makes every search allocate a float[Count].

Memory per search is therefore O(N), not O(topK). At measured scale that is
~35 KB of Gen0 garbage per query against ~190 ms spent waiting on the
embedding call, so it is immaterial here -- but it is a real change, not a
pure refactor, and it stops being immaterial on a much larger index. The
remedy at that point is named in the comment: refuse the split for the
production path and keep it only for the benchmark.
@Platonenkov

Copy link
Copy Markdown
Collaborator Author

Дописал одно, чего в PR не хватало: разделение меняет профиль аллокаций боевого пути, и это не отражено ни в описании, ни в комментариях.

Прежний слитый цикл считал очко и тут же предлагал его куче — в памяти жило не больше topK элементов. Теперь все очки материализуются в массив, то есть память на поиск выросла с O(topK) до O(N).

На нашем масштабе это 8735 float, примерно 35 КБ мусора Gen0 на запрос, против ~190 мс ожидания эмбеддинга и ~1,6 мс собственно поиска. Незначимо — но это вывод, который должен делать читатель, а не автор за него. На индексе на порядок-два больше значимость появится, и в комментарии теперь названо лекарство: вернуть слияние для боевого пути, оставив разделение только бенчмарку.

Сам разбор причины флака — хороший. Гипотеза «разные реализации дот-продукта» проверена и отвергнута, а решающая улика найдена в самих числах упавшего прогона: fullSortMs был нормальным, подскочил только heapMs — значит замедлилась не общая доминирующая работа, а что-то внутри окна измерения. Отсюда и правильный вывод: старый тест мерил сумму, в которой полезный сигнал тонул.

Результат по разбросу говорит сам за себя: ratio 0,019–0,027 на 60 прогонах против прежних 0,6–0,9, порог ужесточён с 1,5 до 1,0.

@Platonenkov
Platonenkov merged commit 71c3375 into main Aug 4, 2026
4 checks passed
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