MOD-14916 Devirtualize HNSW / brute-force search hot path - #937
Conversation
🛡️ Jit Security Scan Results✅ No security findings were detected in this PR
Security scan by Jit
|
4e7ba4a to
0dbb6bf
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #937 +/- ##
==========================================
- Coverage 96.91% 96.89% -0.03%
==========================================
Files 129 129
Lines 7651 7656 +5
==========================================
+ Hits 7415 7418 +3
- Misses 236 238 +2 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
| indexCalculator(components.indexCalculator), | ||
| cachedDistFunc(components.indexCalculator ? components.indexCalculator->getDistFunc() | ||
| : nullptr), |
There was a problem hiding this comment.
When is it nullptr? We later call it unchecked in calcDistance, so only for testing?
There was a problem hiding this comment.
I think this is what fixed the svs sanitizer failure. checking
There was a problem hiding this comment.
Confirmed — it's nullptr in production for SVS, not just tests.
src/VecSim/index_factories/svs_factory.cpp:32-33:
IndexComponents<svs_details::vecsim_dt<DataType>, float> components = {
nullptr, preprocessors}; // calculator is not in use in svs.SVS uses its own internal distance kernels (via the MetricType template param from the Intel SVS library) and never calls VecSimIndexAbstract::calcDistance. Same reason it also doesn't populate this->vectors — it has its own index_storage_type. So the unchecked call in calcDistance is safe: only HNSW/BF reach it, and both always have a valid indexCalculator.
The ? : is only needed in the constructor because we now eagerly cache the dist-func at construction time. Before this PR, indexCalculator was just stored as-is (SVS got away with storing a nullptr that was never dereferenced). Now we dereference it once up front, so SVS's path needs the guard.
There was a problem hiding this comment.
So, we keep calcDistance as is, and expect it to crash if the dist func is set to nullptr and later we attempt to call it. Right?
Consider documenting that
There was a problem hiding this comment.
I'll add docs/assert as soon as the benchmarks finish, I don't want to cancel them now
0dbb6bf to
7bd5554
Compare
MOD-14916 / LTK perf investigation.
Two virtual dispatches per HNSW candidate added between v2.10.21 and
v8.2.6 account for a measurable share of the KNN regression observed
in the LTK benchmarks (-38% throughput on Intel). Both are removed
here with the minimum possible change.
V1 - distance computation:
Every calcDistance() call goes through IndexCalculatorInterface's
vtable to reach DistanceCalculatorCommon, which then calls the
underlying SIMD function pointer. The intermediate vtable hop is
pure indirection; the concrete calculator class is fixed for the
life of an index.
Expose the underlying dist_func via a new pure-virtual
getDistFunc() on IndexCalculatorInterface, implemented by
DistanceCalculatorCommon. Cache the returned function pointer in
VecSimIndexAbstract at construction time and call it directly in
calcDistance(), bypassing the virtual dispatch.
V2 - vector fetch:
HNSWIndex::getDataByInternalId and BruteForceIndex::getDataByInternalId
call this->vectors->getElement(id), which is virtual through the
RawDataContainer base. DataBlocksContainer is the only concrete
implementation, and this->vectors is always a DataBlocksContainer
(created and owned by VecSimIndexAbstract's constructor).
Use a static_cast to DataBlocksContainer* plus a qualified call to
DataBlocksContainer::getElement to skip the vtable lookup.
No behavior change; per-candidate distance and neighbor-fetch calls
on HNSW / brute force search paths become direct function-pointer /
direct-member calls. Headers in index_factories, hnsw_serializer,
and brute_force_factory compile cleanly.
Follow-up to the previous V1/V2 devirt commit. The static_cast+qualified call in getDataByInternalId removed the vtable lookup but left the larger cost on the table: DataBlocksContainer::getElement was still defined in data_blocks_container.cpp, so every per-candidate neighbor fetch still paid a real out-of-line function call and a bounds-checked blocks.at() lookup. Without LTO the compiler could neither inline the body nor hoist the div/mod in the HNSW hot loop. Move the definition into the header as inline and drop the .at() bounds check to match the v2.10.21 baseline, which used unchecked operator[] and was fully inlined into processCandidate. Also add a getDistFunc() override to DistanceCalculatorDummy in test_components.cpp so BUILD_TESTS still compiles after the pure virtual added in the previous commit.
7bd5554 to
341ed12
Compare
* MOD-14916 Devirtualize distance + getElement on HNSW search hot path
MOD-14916 / LTK perf investigation.
Two virtual dispatches per HNSW candidate added between v2.10.21 and
v8.2.6 account for a measurable share of the KNN regression observed
in the LTK benchmarks (-38% throughput on Intel). Both are removed
here with the minimum possible change.
V1 - distance computation:
Every calcDistance() call goes through IndexCalculatorInterface's
vtable to reach DistanceCalculatorCommon, which then calls the
underlying SIMD function pointer. The intermediate vtable hop is
pure indirection; the concrete calculator class is fixed for the
life of an index.
Expose the underlying dist_func via a new pure-virtual
getDistFunc() on IndexCalculatorInterface, implemented by
DistanceCalculatorCommon. Cache the returned function pointer in
VecSimIndexAbstract at construction time and call it directly in
calcDistance(), bypassing the virtual dispatch.
V2 - vector fetch:
HNSWIndex::getDataByInternalId and BruteForceIndex::getDataByInternalId
call this->vectors->getElement(id), which is virtual through the
RawDataContainer base. DataBlocksContainer is the only concrete
implementation, and this->vectors is always a DataBlocksContainer
(created and owned by VecSimIndexAbstract's constructor).
Use a static_cast to DataBlocksContainer* plus a qualified call to
DataBlocksContainer::getElement to skip the vtable lookup.
No behavior change; per-candidate distance and neighbor-fetch calls
on HNSW / brute force search paths become direct function-pointer /
direct-member calls. Headers in index_factories, hnsw_serializer,
and brute_force_factory compile cleanly.
* MOD-14916 Inline DataBlocksContainer::getElement on HNSW search hot path
Follow-up to the previous V1/V2 devirt commit. The static_cast+qualified
call in getDataByInternalId removed the vtable lookup but left the larger
cost on the table: DataBlocksContainer::getElement was still defined in
data_blocks_container.cpp, so every per-candidate neighbor fetch still
paid a real out-of-line function call and a bounds-checked blocks.at()
lookup. Without LTO the compiler could neither inline the body nor hoist
the div/mod in the HNSW hot loop.
Move the definition into the header as inline and drop the .at() bounds
check to match the v2.10.21 baseline, which used unchecked operator[] and
was fully inlined into processCandidate.
Also add a getDistFunc() override to DistanceCalculatorDummy in
test_components.cpp so BUILD_TESTS still compiles after the pure virtual
added in the previous commit.
(cherry picked from commit 4ca500a)
* MOD-14916 Devirtualize distance + getElement on HNSW search hot path
MOD-14916 / LTK perf investigation.
Two virtual dispatches per HNSW candidate added between v2.10.21 and
v8.2.6 account for a measurable share of the KNN regression observed
in the LTK benchmarks (-38% throughput on Intel). Both are removed
here with the minimum possible change.
V1 - distance computation:
Every calcDistance() call goes through IndexCalculatorInterface's
vtable to reach DistanceCalculatorCommon, which then calls the
underlying SIMD function pointer. The intermediate vtable hop is
pure indirection; the concrete calculator class is fixed for the
life of an index.
Expose the underlying dist_func via a new pure-virtual
getDistFunc() on IndexCalculatorInterface, implemented by
DistanceCalculatorCommon. Cache the returned function pointer in
VecSimIndexAbstract at construction time and call it directly in
calcDistance(), bypassing the virtual dispatch.
V2 - vector fetch:
HNSWIndex::getDataByInternalId and BruteForceIndex::getDataByInternalId
call this->vectors->getElement(id), which is virtual through the
RawDataContainer base. DataBlocksContainer is the only concrete
implementation, and this->vectors is always a DataBlocksContainer
(created and owned by VecSimIndexAbstract's constructor).
Use a static_cast to DataBlocksContainer* plus a qualified call to
DataBlocksContainer::getElement to skip the vtable lookup.
No behavior change; per-candidate distance and neighbor-fetch calls
on HNSW / brute force search paths become direct function-pointer /
direct-member calls. Headers in index_factories, hnsw_serializer,
and brute_force_factory compile cleanly.
* MOD-14916 Inline DataBlocksContainer::getElement on HNSW search hot path
Follow-up to the previous V1/V2 devirt commit. The static_cast+qualified
call in getDataByInternalId removed the vtable lookup but left the larger
cost on the table: DataBlocksContainer::getElement was still defined in
data_blocks_container.cpp, so every per-candidate neighbor fetch still
paid a real out-of-line function call and a bounds-checked blocks.at()
lookup. Without LTO the compiler could neither inline the body nor hoist
the div/mod in the HNSW hot loop.
Move the definition into the header as inline and drop the .at() bounds
check to match the v2.10.21 baseline, which used unchecked operator[] and
was fully inlined into processCandidate.
Also add a getDistFunc() override to DistanceCalculatorDummy in
test_components.cpp so BUILD_TESTS still compiles after the pure virtual
added in the previous commit.
(cherry picked from commit 4ca500a)
* MOD-14916 Devirtualize distance + getElement on HNSW search hot path
MOD-14916 / LTK perf investigation.
Two virtual dispatches per HNSW candidate added between v2.10.21 and
v8.2.6 account for a measurable share of the KNN regression observed
in the LTK benchmarks (-38% throughput on Intel). Both are removed
here with the minimum possible change.
V1 - distance computation:
Every calcDistance() call goes through IndexCalculatorInterface's
vtable to reach DistanceCalculatorCommon, which then calls the
underlying SIMD function pointer. The intermediate vtable hop is
pure indirection; the concrete calculator class is fixed for the
life of an index.
Expose the underlying dist_func via a new pure-virtual
getDistFunc() on IndexCalculatorInterface, implemented by
DistanceCalculatorCommon. Cache the returned function pointer in
VecSimIndexAbstract at construction time and call it directly in
calcDistance(), bypassing the virtual dispatch.
V2 - vector fetch:
HNSWIndex::getDataByInternalId and BruteForceIndex::getDataByInternalId
call this->vectors->getElement(id), which is virtual through the
RawDataContainer base. DataBlocksContainer is the only concrete
implementation, and this->vectors is always a DataBlocksContainer
(created and owned by VecSimIndexAbstract's constructor).
Use a static_cast to DataBlocksContainer* plus a qualified call to
DataBlocksContainer::getElement to skip the vtable lookup.
No behavior change; per-candidate distance and neighbor-fetch calls
on HNSW / brute force search paths become direct function-pointer /
direct-member calls. Headers in index_factories, hnsw_serializer,
and brute_force_factory compile cleanly.
* MOD-14916 Inline DataBlocksContainer::getElement on HNSW search hot path
Follow-up to the previous V1/V2 devirt commit. The static_cast+qualified
call in getDataByInternalId removed the vtable lookup but left the larger
cost on the table: DataBlocksContainer::getElement was still defined in
data_blocks_container.cpp, so every per-candidate neighbor fetch still
paid a real out-of-line function call and a bounds-checked blocks.at()
lookup. Without LTO the compiler could neither inline the body nor hoist
the div/mod in the HNSW hot loop.
Move the definition into the header as inline and drop the .at() bounds
check to match the v2.10.21 baseline, which used unchecked operator[] and
was fully inlined into processCandidate.
Also add a getDistFunc() override to DistanceCalculatorDummy in
test_components.cpp so BUILD_TESTS still compiles after the pure virtual
added in the previous commit.
(cherry picked from commit 4ca500a)
… distance hot path Upstream RedisAI#946 split preprocessor alignment into storage/query parameters and upstream RedisAI#937 caches a raw distance function pointer on the index. TQ preprocessors adopt the new signatures; TQ calculators are stateful so they return nullptr from getDistFunc() and calcDistance() falls back to the virtual call for them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Summary
Devirtualizes the HNSW / brute-force search hot path so the per-candidate distance and vector-fetch calls compile down to direct calls instead of going through
IndexCalculatorInterfaceandRawDataContainervtables.The SIMD distance kernels themselves are unchanged — what changes is the scalar "glue" around them (vtable dispatch, an out-of-line
getElement, and a runtime bounds check), which is exercised on the order ofvisited_candidates × Mtimes per query and was preventing the optimizer from inlining the fetch path.Net diff: 7 files, +27 / −10 lines. Public APIs are unchanged.
Changes
Cache the raw distance function in
VecSimIndexAbstract(vec_sim_index.h,calculator.h)IndexCalculatorInterface::getDistFunc()to expose the underlyingdist_func_t<DistType>.VecSimIndexAbstractcaches that pointer at construction time ascachedDistFuncand calls it directly incalcDistance, skipping theindexCalculatorvirtual dispatch on every per-candidate distance call.Devirtualize
getDataByInternalId(hnsw.h,brute_force.h)VecSimIndexAbstract::vectorsis constructed as aDataBlocksContainerand never reassigned, so both HNSW and brute-force statically downcast and qualify the call (DataBlocksContainer::getElement) to bypass theRawDataContainervtable.Inline
DataBlocksContainer::getElement(data_blocks_container.{h,cpp})id / block_size,id % block_size) and the vector-block lookup are visible to the compiler at every call site and can be folded into the surrounding loop.blocks.at(...)toblocks[...]; the existingassert(id < element_count)guards the same precondition in debug builds.The abstractions (
IndexCalculatorInterface,RawDataContainer) are kept intact; this only short-circuits them on the in-memory HNSW/BF path where the concrete type is statically known.Why it helps
HNSW search performs roughly
visited_candidates × Mpaired calls ofgetDataByInternalId(...)followed bycalcDistance(...)per query, so a few cycles per call compound quickly:calcDistancecall.getDataByInternalIdcall.getElementandgetDataByInternalIdboth visible to the optimizer, the indexing arithmetic and the SIMD kernel can share registers and stay in the same call frame insideprocessCandidate.Benchmarks
End-to-end replay of a production-representative
FT.AGGREGATE/FT.SEARCHworkload (vector index, ~1M vectors) usingmemtier_benchmark, 4 threads × 10 connections, 60 s, comparing a build with this PR applied to a stock build:FT.AGGREGATEFT.SEARCH≈ +119 % throughput and −54 % avg latency on the aggregate workload, with the largest gains in the tail (p99 / p99.9). The replay path is noisy run-to-run, so the overall throughput delta should be read as "in the same ballpark, consistently above stock"; the per-call latency reduction in the HNSW hot path is the primary, repeatable signal. The SIMD kernels are unchanged — the wins come entirely from removing the scalar glue around them.
Risk
DataBlocksContainer::getElementkeeps the sameassert(id < element_count)precondition; the only difference vs. the previous implementation is droppingstd::deque::at's redundant range check (the outer assert already covers the same precondition).getDistFunc()virtual is implemented inDistanceCalculatorCommon(the only production calculator) and in the dummy test calculator; no other implementations exist in tree.tests/unit/test_components.cpp,tests/unit/test_hnsw*.cpp).Backports
After merge, plan to backport to
8.4and8.2branches under the same MOD-14916 ticket.Ticket
Pull Request opened by Augment Code with guidance from the PR author
Note
Medium Risk
Touches core HNSW/brute-force search hot paths by bypassing virtual dispatch and changing how distances/vectors are fetched; while intended as perf-only, mistakes could cause crashes (e.g., null cached dist func) or subtle behavior differences under edge configurations.
Overview
Devirtualizes the HNSW and brute-force search hot path to reduce per-candidate overhead during queries.
VecSimIndexAbstractnow caches the rawdist_func_tvia a newIndexCalculatorInterface::getDistFunc()and uses it directly incalcDistance, avoiding virtual dispatch on every distance calculation. HNSW and brute-forcegetDataByInternalIdnow downcastvectorstoDataBlocksContainerand callDataBlocksContainer::getElementdirectly, andDataBlocksContainer::getElementis inlined in the header (droppingdeque::atbounds checking in favor of anassert) to enable better inlining/index-arithmetic optimization.Reviewed by Cursor Bugbot for commit 341ed12. Bugbot is set up for automated code reviews on this repo. Configure here.