Repository navigation
fix(hnsw): keep per-ISA search builds from sharing generic code - #194
Merged
Merged
Conversation
ex_bits_code_with_norm reverted the code of each non-positive coordinate behind a branch. Query signs are effectively random, so baseline x86-64 builds mispredicted about half of them (about 480 per 960-dimension query) and could not vectorize the loop. Select between the two values instead; the codes are identical by construction.
Each src/index/hnsw_search_<isa>.cpp instantiated the whole search, including query quantization, the visited-set pool and std containers, under AVX2 or AVX-512 flags. Functions the compiler did not inline became weak symbols that generic objects also define, and the linker keeps one copy for every caller, so a generic caller could run AVX code. Optimized GCC builds shared 7 such functions with different code and Clang builds 13. Generic code (HierarchicalNSW::search_knn_sorted_with) now prepares the query, computes centroid distances, acquires the visited set and search buffer, and formats results. Each ISA source only exports search_layers_<isa>, which traverses the graph; its Kernel type is in an anonymous namespace and the result queue is a local class, so everything instantiated with them keeps internal linkage. The per-backend search_knn_sorted_<isa> entry points keep their signatures for tests. Search results are bit-identical.
scripts/check-isa-symbols.sh recompiles the library sources from compile_commands.json and fails when a weak function is defined under different ISA flag sets with different code. CI runs it on the portable Release builds of the Clang static-analysis job and the GCC installed-consumer job. docs/development.md describes the check, its Release-only scope and the HNSW structure, and AGENTS.md lists it for SIMD changes.
GCC 13 does not inline the std::unordered_set insertion behind HashBasedVisitedSet::set, so each per-ISA HNSW search source emitted its own copy of the hash-table code, which the ISA symbol check reported in CI. Move the spill-over lookup and insertion into src/index/visited_set_hash.cpp; the direct-table fast path stays inline. The check now passes with GCC 11 through 14 and Clang 23, and at -O1/-O2 only two pre-existing Eigen loops in the FastScan estimators remain.
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.
Summary
The per-ISA HNSW search sources (
src/index/hnsw_search_{avx2,avx512_core,avx512_popcnt}.cpp) instantiated the whole search under AVX flags: query quantization, the visited-set pool and several std containers. Functions the compiler did not inline became weak symbols that generic objects also define, and the linker keeps one copy for every caller, so a generic caller (for example on a CPU without AVX) could run AVX-compiled code. In optimized portable builds, 7 such functions had different code across ISA builds with GCC and 13 with Clang (event_sweep,VisitedSetPool::acquire,SplitSingleQuery's constructor, ...). The GNU linker currently happens to keep the generic copies because generic objects come first in the archive; nothing guaranteed that.perf(core): flip negative-coordinate codes without a branch:ex_bits_code_with_normreverted the code of each non-positive coordinate behind a branch. Baseline x86-64 builds mispredicted about half of them on random-sign queries and could not vectorize the loop. A select produces identical codes.fix(hnsw): keep per-ISA search builds from sharing generic code: generic code (HierarchicalNSW::search_knn_sorted_with) now prepares the query, computes centroid distances, acquires the visited set and search buffer, and formats results. Each ISA source only exportssearch_layers_<isa>(graph traversal); itsKerneltype is in an anonymous namespace and the result queue is a local class, so their instantiations keep internal linkage. Thesearch_knn_sorted_<isa>test entry points keep their signatures.ci: check that ISA builds share no differing weak functions:scripts/check-isa-symbols.shrecompiles the library sources fromcompile_commands.jsonand fails when a weak function is defined under different ISA flag sets with different code. CI runs it on the portable Release builds of the Clang static-analysis job and the GCC installed-consumer job.docs/development.mddocuments the check and its scope: Release (-O3) only; GCC at-O1/-O2still shares two Eigen assignment loops in the FastScan estimators, which this PR does not change.fix(hnsw): compile the visited set's spill path once: GCC 13 (the CI compiler) does not inline thestd::unordered_setinsertion behindHashBasedVisitedSet::set, so each per-ISA search source still emitted its own hash-table code. The first CI run of the new check caught it. The spill-over lookup and insertion now live insrc/index/visited_set_hash.cpp; the direct-table fast path stays inline.No public API, Python API or saved-format change.
hnsw_dispatch.hpp(a detail header) changes its internal declarations.Performance
Search results are unchanged, but query preparation now runs in generic code instead of AVX-512-compiled code, and visited-set collisions call into a generic function. That costs some throughput. Without commit 1, 960-dimension search lost 6–16%. With commits 1–3 the cost was about 2–3%, and commit 4 adds about 1.4% (pooled median 0.986 over 960d bits 1/4 and 128d bits 4, ef 50/100/200, 12 rounds). A combined test-and-set variant that halves the spill-path calls measured no better and was not kept.
Setup: Xeon Gold 6418H, GCC 14.3, Release,
RABITQ_ENABLE_NATIVE_OPTIMIZATION=OFF, single thread pinned to an idle core (numactl --physcpubind=36 --preferred=1). Data: synthetic clustered Gaussian, 50k vectors, 64 clusters, M=16, efConstruction=200, 1000 queries, k=10. Each run loads the same saved index and takes the best of 3 timed passes peref. Builds alternate, 12 pairs. Shared host (load average 4.2–4.9), CPU frequency not locked.maine2c99c7e2c99c7Recall@10 is identical in every configuration. A perf comparison shows equal sample counts in the search loop and kernels; the remaining cost is spread over per-query setup (a function-pointer call, one result buffer) and code layout, not one hotspot. Moving the search buffer into a local variable inside the ISA function measured slower (0.984) and was not kept.
Testing
Linux x86-64, GCC 14.3 and Clang 23,
RABITQ_ENABLE_NATIVE_OPTIMIZATION=OFF.--no-tests=error): 443 passed, 1 skipped (OpenMP placement); GCC 13 build: 428 passed.e2c99c7.ex_bits0–8 (7.6 MB) are byte-identical.mainit reports 7 (GCC 14) and 13 (Clang) differing weak functions. GCC 13 at commit 3 reproduces the CI failure. A two-file reproduction, where an inline function returns a different constant under__AVX2__, fails as expected.check-format.sh, fullcheck-tidy.shwith clang-tidy 23 (bindings on), ShellCheck,git diff --check. actionlint reports only the existingrunner-labelnotices for the custom runners..github/scriptstests: 60 passed, 558 subtests.check-python.sh;pytest tests/python812 passed, 2 skipped;tests/test_pyright.pyandtests/test_mypy.pypass.hnsw_search_neon.cppreviewed by reading, not compiled), Windows/MSVC, and AVX2-only execution; CI covers them. The script needs GNU binutils and does not cover MSVC.