Skip to content

fix(hnsw): keep per-ISA search builds from sharing generic code - #194

Merged
gouyt13 merged 4 commits into
mainfrom
fix/hnsw-isa-isolation
Oct 31, 2026
Merged

gouyt13 merged 4 commits into
mainfrom
fix/hnsw-isa-isolation

Conversation

@gouyt13

@gouyt13 gouyt13 commented Oct 31, 2026 •

Copy link
Copy Markdown
Member

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.

  1. perf(core): flip negative-coordinate codes without a branch: ex_bits_code_with_norm reverted 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.
  2. 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 exports search_layers_<isa> (graph traversal); its Kernel type is in an anonymous namespace and the result queue is a local class, so their instantiations keep internal linkage. The search_knn_sorted_<isa> test entry points keep their signatures.
  3. ci: check that ISA builds share no differing weak functions: 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 documents the check and its scope: Release (-O3) only; GCC at -O1/-O2 still shares two Eigen assignment loops in the FastScan estimators, which this PR does not change.
  4. fix(hnsw): compile the visited set's spill path once: GCC 13 (the CI compiler) does not inline the std::unordered_set insertion behind HashBasedVisitedSet::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 in src/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 per ef. Builds alternate, 12 pairs. Shared host (load average 4.2–4.9), CPU frequency not locked.

Comparison Configs Median QPS ratio
Commits 1–3 vs main 960 dims, bits 1 and 4, ef 50/100/200 (two 12-round runs) 0.974 and 0.981 (IQR about [0.955, 0.999])
Commit 4 vs commits 1–3 960d bits 1/4 and 128d bits 4, ef 50/100/200 0.986 (IQR [0.966, 1.010])
Commits 1–3 plus #192/#193 vs e2c99c7 128 dims, bits 1/4/9, ef 50/100/200 0.957–0.994
Commits 1–3 plus #192/#193 vs e2c99c7 960 dims, bits 1/4/9, ef 50/100/200 0.939–0.983

Recall@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.

  • CTest (Release with samples and benchmarks, --no-tests=error): 443 passed, 1 skipped (OpenMP placement); GCC 13 build: 428 passed.
  • Equivalence:
    • 10,800 HNSW search results (dims 64/128/960 × L2/IP × bits 1/4/9 × ef 10/50/200, with removed points) are bit-identical to e2c99c7.
    • Core quantization and estimation outputs over dims 32–1000 and ex_bits 0–8 (7.6 MB) are byte-identical.
  • ISA symbol check: passes on this branch with GCC 11, 12, 13 and 14 and Clang 23; on main it 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.
  • Static analysis: check-format.sh, full check-tidy.sh with clang-tidy 23 (bindings on), ShellCheck, git diff --check. actionlint reports only the existing runner-label notices for the custom runners. .github/scripts tests: 60 passed, 558 subtests.
  • Python (package rebuilt and installed): check-python.sh; pytest tests/python 812 passed, 2 skipped; tests/test_pyright.py and tests/test_mypy.py pass.
  • Not run locally: NEON (hnsw_search_neon.cpp reviewed by reading, not compiled), Windows/MSVC, and AVX2-only execution; CI covers them. The script needs GNU binutils and does not cover MSVC.

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.
@github-actions github-actions Bot added the release:fixed Release category managed from the PR title label Oct 31, 2026
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.
@gouyt13
gouyt13 merged commit 51a9ec3 into main Oct 31, 2026
49 checks passed
@gouyt13
gouyt13 deleted the fix/hnsw-isa-isolation branch October 31, 2026 07:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release:fixed Release category managed from the PR title

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant