Skip to content

MOD-14916 Devirtualize HNSW / brute-force search hot path - #937

Merged
ofiryanai merged 2 commits into
mainfrom
perf/MOD-14916-devirt-hnsw-hot-path
Apr 19, 2026
Merged

MOD-14916 Devirtualize HNSW / brute-force search hot path#937
ofiryanai merged 2 commits into
mainfrom
perf/MOD-14916-devirt-hnsw-hot-path

Conversation

@ofiryanai

@ofiryanai ofiryanai commented Apr 19, 2026

Copy link
Copy Markdown
Collaborator

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 IndexCalculatorInterface and RawDataContainer vtables.

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 of visited_candidates × M times per query and was preventing the optimizer from inlining the fetch path.

Net diff: 7 files, +27 / −10 lines. Public APIs are unchanged.

Changes

  1. Cache the raw distance function in VecSimIndexAbstract (vec_sim_index.h, calculator.h)

    • Adds IndexCalculatorInterface::getDistFunc() to expose the underlying dist_func_t<DistType>.
    • VecSimIndexAbstract caches that pointer at construction time as cachedDistFunc and calls it directly in calcDistance, skipping the indexCalculator virtual dispatch on every per-candidate distance call.
  2. Devirtualize getDataByInternalId (hnsw.h, brute_force.h)

    • VecSimIndexAbstract::vectors is constructed as a DataBlocksContainer and never reassigned, so both HNSW and brute-force statically downcast and qualify the call (DataBlocksContainer::getElement) to bypass the RawDataContainer vtable.
  3. Inline DataBlocksContainer::getElement (data_blocks_container.{h,cpp})

    • Moves the implementation into the header so the index-arithmetic (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.
    • Switches the inner block lookup from blocks.at(...) to blocks[...]; the existing assert(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 × M paired calls of getDataByInternalId(...) followed by calcDistance(...) per query, so a few cycles per call compound quickly:

  • One vtable lookup avoided per calcDistance call.
  • One vtable lookup + one non-inlinable function call + one runtime bounds check avoided per getDataByInternalId call.
  • With getElement and getDataByInternalId both visible to the optimizer, the indexing arithmetic and the SIMD kernel can share registers and stay in the same call frame inside processCandidate.

Benchmarks

End-to-end replay of a production-representative FT.AGGREGATE / FT.SEARCH workload (vector index, ~1M vectors) using memtier_benchmark, 4 threads × 10 connections, 60 s, comparing a build with this PR applied to a stock build:

Workload Stock — ops/sec This PR — ops/sec Stock — p99 This PR — p99
FT.AGGREGATE 124 272 1,352 ms 549 ms
FT.SEARCH 1.95 2.62 954 ms 369 ms

+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

  • No public-API changes; all changes are confined to the in-memory HNSW/BF path.
  • DataBlocksContainer::getElement keeps the same assert(id < element_count) precondition; the only difference vs. the previous implementation is dropping std::deque::at's redundant range check (the outer assert already covers the same precondition).
  • The new getDistFunc() virtual is implemented in DistanceCalculatorCommon (the only production calculator) and in the dummy test calculator; no other implementations exist in tree.
  • Existing unit tests cover the hot path (tests/unit/test_components.cpp, tests/unit/test_hnsw*.cpp).

Backports

After merge, plan to backport to 8.4 and 8.2 branches under the same MOD-14916 ticket.

Ticket

  • MOD-14916

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.

VecSimIndexAbstract now caches the raw dist_func_t via a new IndexCalculatorInterface::getDistFunc() and uses it directly in calcDistance, avoiding virtual dispatch on every distance calculation. HNSW and brute-force getDataByInternalId now downcast vectors to DataBlocksContainer and call DataBlocksContainer::getElement directly, and DataBlocksContainer::getElement is inlined in the header (dropping deque::at bounds checking in favor of an assert) 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.

@jit-ci

jit-ci Bot commented Apr 19, 2026

Copy link
Copy Markdown

🛡️ Jit Security Scan Results

CRITICAL HIGH MEDIUM

✅ No security findings were detected in this PR


Security scan by Jit

@ofiryanai
ofiryanai force-pushed the perf/MOD-14916-devirt-hnsw-hot-path branch from 4e7ba4a to 0dbb6bf Compare April 19, 2026 09:40
@ofiryanai
ofiryanai marked this pull request as ready for review April 19, 2026 10:39
@ofiryanai
ofiryanai requested a review from GuyAv46 April 19, 2026 10:39
@codecov

codecov Bot commented Apr 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.89%. Comparing base (7f01bfa) to head (341ed12).
⚠️ Report is 1 commits behind head on main.

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment on lines +128 to +130
indexCalculator(components.indexCalculator),
cachedDistFunc(components.indexCalculator ? components.indexCalculator->getDistFunc()
: nullptr),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When is it nullptr? We later call it unchecked in calcDistance, so only for testing?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is what fixed the svs sanitizer failure. checking

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll add docs/assert as soon as the benchmarks finish, I don't want to cancel them now

@ofiryanai
ofiryanai force-pushed the perf/MOD-14916-devirt-hnsw-hot-path branch from 0dbb6bf to 7bd5554 Compare April 19, 2026 14:57
GuyAv46
GuyAv46 previously approved these changes Apr 19, 2026
Comment thread src/VecSim/vec_sim_index.h Outdated
@ofiryanai
ofiryanai requested a review from GuyAv46 April 19, 2026 15:06
ofiryanai and others added 2 commits April 19, 2026 18:07
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.
@ofiryanai
ofiryanai force-pushed the perf/MOD-14916-devirt-hnsw-hot-path branch from 7bd5554 to 341ed12 Compare April 19, 2026 15:08
@ofiryanai
ofiryanai enabled auto-merge April 19, 2026 15:08
@ofiryanai
ofiryanai added this pull request to the merge queue Apr 19, 2026
Merged via the queue into main with commit 4ca500a Apr 19, 2026
21 checks passed
@ofiryanai
ofiryanai deleted the perf/MOD-14916-devirt-hnsw-hot-path branch April 19, 2026 16:18
@ofiryanai
ofiryanai restored the perf/MOD-14916-devirt-hnsw-hot-path branch April 20, 2026 12:45
ofiryanai added a commit that referenced this pull request Apr 20, 2026
* 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)
ofiryanai added a commit that referenced this pull request Apr 20, 2026
* 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)
ofiryanai added a commit that referenced this pull request Apr 20, 2026
* 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)
jeremyplichta added a commit to jeremyplichta/VectorSimilarity that referenced this pull request Jul 8, 2026
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants