Skip to content

fix(hnsw): restore multi-threaded build; vendor USearch #735 fix - #25163

Merged
mergify[bot] merged 9 commits into
matrixorigin:mainfrom
cpegeric:usearch_build_fix
Jun 26, 2026
Merged

fix(hnsw): restore multi-threaded build; vendor USearch #735 fix#25163
mergify[bot] merged 9 commits into
matrixorigin:mainfrom
cpegeric:usearch_build_fix

Conversation

@cpegeric

@cpegeric cpegeric commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Concurrent USearch add() could orphan HNSW graph nodes (a vector stored but never linked, so search() couldn't reach it -> flaky recall@1). MatrixOne #24849 worked around it by forcing single-threaded builds everywhere. The root cause is fixed upstream-style in our vendored libusearch (two-pass add: form ALL forward links before ANY reverse link, so a node is never reachable as a descent seed while a lower level is still empty), so the workaround is no longer needed.

  • thirdparties/usearch-2.25.3.tar.gz: patched index.hpp with the Fix: concurrent add() no longer strands unreachable nodes (#735) unum-cloud/USearch#772 fix (pristine v2.25.3 source, only index.hpp/test.cpp changed; CMakeLists still march=native so the Makefile's sed applies as before).
  • build.go: drop the hardcoded nthread := 1; restore the real concurrency estimate (GetConcurrency / GetConcurrencyForBuild from nworker/ThreadsBuild).
  • sync.go: CDC/sync paths use GetConcurrencyForBuild directly.
  • types.go: remove the GetConcurrencyForSingleThreadBuild stopgap.
  • zz_orphan_test.go: enable TestZZBuildOrphan as a regression guard — 30x 8-thread builds with the BVT t2 params (M 64, EF_CONSTRUCTION/SEARCH 200), rotating insertion order each run to mimic load data ... parallel 'true'; asserts 0 orphans. Auto-skips without the SIFT fixture (~5s when present).

Validated: zz_orphan_test 0/30 multi-threaded (was ~1/30 pre-fix); 1M wiki_all HNSW build clean (recall@10 82% at M=8); vector_hnsw_async t2 BVT 30/30.

What type of PR is this?

  • API-change
  • BUG
  • Improvement
  • Documentation
  • Feature
  • Test and CI
  • Code Refactoring

Which issue(s) this PR fixes:

issue #24977

What this PR does / why we need it:

patch the fix with usearch PR: unum-cloud/USearch#772
add unit test to test the same bvt case flaky test to make sure no orphan in final index.
revert the change build thread = 1

cpegeric and others added 3 commits June 25, 2026 17:22
 fix

Concurrent USearch add() could orphan HNSW graph nodes (a vector stored but
never linked, so search() couldn't reach it -> flaky recall@1). MatrixOne matrixorigin#24849
worked around it by forcing single-threaded builds everywhere. The root cause is
fixed upstream-style in our vendored libusearch (two-pass add: form ALL forward
links before ANY reverse link, so a node is never reachable as a descent seed
while a lower level is still empty), so the workaround is no longer needed.

  - thirdparties/usearch-2.25.3.tar.gz: patched index.hpp with the matrixorigin#735 fix
    (pristine v2.25.3 source, only index.hpp/test.cpp changed; CMakeLists still
    march=native so the Makefile's sed applies as before).
  - build.go: drop the hardcoded `nthread := 1`; restore the real concurrency
    estimate (GetConcurrency / GetConcurrencyForBuild from nworker/ThreadsBuild).
  - sync.go: CDC/sync paths use GetConcurrencyForBuild directly.
  - types.go: remove the GetConcurrencyForSingleThreadBuild stopgap.
  - zz_orphan_test.go: enable TestZZBuildOrphan as a regression guard — 30x
    8-thread builds with the BVT t2 params (M 64, EF_CONSTRUCTION/SEARCH 200),
    rotating insertion order each run to mimic `load data ... parallel 'true'`;
    asserts 0 orphans. Auto-skips without the SIFT fixture (~5s when present).

Validated: zz_orphan_test 0/30 multi-threaded (was ~1/30 pre-fix); 1M wiki_all
HNSW build clean (recall@10 82% at M=8); vector_hnsw_async t2 BVT 30/30.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
 fix

Concurrent USearch add() could orphan HNSW graph nodes (a vector stored but
never linked, so search() couldn't reach it -> flaky recall@1). MatrixOne matrixorigin#24849
worked around it by forcing single-threaded builds everywhere. The root cause is
fixed upstream-style in our vendored libusearch (two-pass add: form ALL forward
links before ANY reverse link, so a node is never reachable as a descent seed
while a lower level is still empty), so the workaround is no longer needed.

  - thirdparties/usearch-2.25.3.tar.gz: patched index.hpp with the matrixorigin#735 fix
    (pristine v2.25.3 source, only index.hpp/test.cpp changed; CMakeLists still
    march=native so the Makefile's sed applies as before).
  - build.go: drop the hardcoded `nthread := 1`; restore the real concurrency
    estimate (GetConcurrency / GetConcurrencyForBuild from nworker/ThreadsBuild).
  - sync.go: CDC/sync paths use GetConcurrencyForBuild directly.
  - types.go: remove the GetConcurrencyForSingleThreadBuild stopgap.
  - zz_orphan_test.go: enable TestZZBuildOrphan as a regression guard — 30x
    8-thread builds with the BVT t2 params (M 64, EF_CONSTRUCTION/SEARCH 200),
    rotating insertion order each run to mimic `load data ... parallel 'true'`;
    asserts 0 orphans. Auto-skips without the SIFT fixture (~5s when present).
  - vector_hnsw_async.sql/.result: bump t2's post-build wait sleep(20)->sleep(30)
    so the async index is reliably visible before the NN query (the build's model
    becomes searchable a beat after sleep(20) under load — a visibility/timing
    flake, not an orphan).

Validated: zz_orphan_test 0/30 multi-threaded (was ~1/30 pre-fix); 1M wiki_all
HNSW build clean (recall@10 82% at M=8); vector_hnsw_async 5/5 at 100%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@mergify

mergify Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Queued — the merge queue status continues in this comment ↓.

@XuPeng-SH XuPeng-SH left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I re-checked this from the correctness / unhappy-path angle. The vendored USearch #735 patch looks directionally right, and the focused orphan test passes locally, but restoring multi-threaded HnswBuild re-enables two MatrixOne-side lifecycle bugs that are not covered by the new tests.

Blocker 1: async worker errors can be lost at finalization, and later producers can also hang.

Add() only polls err_chan before enqueueing (pkg/vectorindex/hnsw/build.go:158-168). If a worker fails while processing the last queued item, that Add() already returned nil. ToInsertSql() then only calls CloseAndWait() and never drains/joins err_chan (pkg/vectorindex/hnsw/build.go:271-284), so the build can be finalized as successful even though a worker returned an error. The same channel lifecycle is also risky for unhappy paths such as context cancellation: workers can exit, but a producer may still block on h.add_chan <- ... because the send does not select on worker error or context cancellation.

Please make finalization return the joined worker error, e.g. have CloseAndWait()/ToInsertSql() drain err_chan after wg.Wait(), and make the enqueue path avoid blocking forever once workers have failed or the context is cancelled. A focused regression should cover a multi-threaded build where the final queued vector fails (dimension mismatch or cancelled context) and ToInsertSql() must return that error.

Blocker 2: capacity rollover can save/destroy an index while other workers still have in-flight adds assigned to it.

In the multi-thread path, getIndexForAddSync() reserves a slot under h.mutex, but the actual idx.Add() happens after the lock is released (pkg/vectorindex/hnsw/build.go:178-239). When another worker crosses IndexCapacity, it receives the previous index as save_idx, calls SaveToFile() outside the lock, and SaveToFile() saves then destroys idx.Index (pkg/vectorindex/hnsw/model.go:144-224). That can race with earlier workers that already reserved slots in the old index but have not finished idx.Add() yet. The result can be an incomplete persisted HNSW file, usearch index is nil, or native use-after-destroy behavior depending on the interleaving.

Please bound the old-index lifecycle before saving it: track per-index in-flight adds / generation completion, or otherwise ensure all adds assigned to an index have completed before SaveToFile() can save and destroy that index. A regression with small IndexCapacity, nthread > 1, and enough concurrent adds to force rollover should verify all keys survive and worker errors are surfaced.

Local checks I ran:

git diff --check origin/main...HEAD
CGO_CFLAGS="-I/home/xupeng/matrixone/cgo -I/home/xupeng/matrixone/thirdparties/install/include" \
CGO_LDFLAGS="-L/home/xupeng/matrixone/cgo -lmo -L/home/xupeng/matrixone/thirdparties/install/lib -Wl,-rpath,/home/xupeng/matrixone/cgo -Wl,-rpath,/home/xupeng/matrixone/thirdparties/install/lib -fopenmp" \
LD_LIBRARY_PATH="/home/xupeng/matrixone/cgo:/home/xupeng/matrixone/thirdparties/install/lib:${LD_LIBRARY_PATH}" \
go test ./pkg/vectorindex/hnsw -run 'TestBuildMultiWorker|TestZZBuildOrphan' -count=1

Both passed, but they do not cover the two lifecycle cases above.

…ent build

Restoring multi-threaded HnswBuild re-enabled two lifecycle races:

Blocker 1 — lost worker errors + producer hang. A worker failing on the
last queued vector failed after Add() already returned nil, and
finalization never drained err_chan, so a corrupt build reported success.
Add()'s enqueue was also an unconditional blocking send, so a full buffer
blocked the producer forever once workers died. Replace the poll-once
err_chan with a first-error record (workerErr) plus a `stopped` channel
closed on the first failure or context cancellation. Add() now selects on
the send vs <-stopped (can't block once workers are gone, surfaces the
error); CloseAndWait() returns the recorded error; ToInsertSql()/Destroy()
propagate it up through the existing hnsw_create error path.

Blocker 2 — rollover saves/destroys an index with in-flight adds. A worker
crossing IndexCapacity received the previous index as save_idx and called
SaveToFile() (which saves AND destroys idx.Index) outside the lock, racing
peer workers still doing idx.Add() on it (use-after-destroy / partial
save; observed as "usearch index is nil"). Add a per-index in-flight
WaitGroup on HnswModel: reserve the slot under the same lock that decides
rollover (getIndexForAdd), release after the add, and Wait() it before
SaveToFile(). The rolled-over index gets no new adds, so the wait
converges; the crossing worker's own add targets the new index, so no
self-deadlock.

Regressions (both fail on the old code, pass with the fix, run under -race):
- TestBuildMultiWorkerLastItemError: dim mismatch on the final queued
  vector must surface from ToInsertSql().
- TestBuildMultiWorkerRollover: small IndexCapacity + 8 workers + 1000
  adds forcing ~50 rollovers; all keys survive and finalization succeeds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@XuPeng-SH XuPeng-SH left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-reviewed the latest head from the multi-threaded build / unhappy-path angle. The two lifecycle blockers I raised on the previous revision look fixed now.

What changed in this head:

  • Worker failures are now recorded with recordWorkerErr, producers can stop once stopped is closed, and ToInsertSql() returns the recorded worker error after CloseAndWait(). This covers the “last queued item fails after Add returned nil” case.
  • Rollover now reserves an inflight slot on the assigned index under HnswBuild.mutex, and waits for the rolled-over index's in-flight adds before SaveToFile() saves/destroys it. That addresses the save/destroy vs concurrent idx.Add() race.
  • Focused regressions were added for both cases: final worker error propagation and small-capacity multi-worker rollover.

Local checks:

git diff --check origin/main...HEAD
git merge-tree --write-tree HEAD origin/main
CGO_CFLAGS="-I/home/xupeng/matrixone/cgo -I/home/xupeng/matrixone/thirdparties/install/include" \
CGO_LDFLAGS="-L/home/xupeng/matrixone/cgo -lmo -L/home/xupeng/matrixone/thirdparties/install/lib -Wl,-rpath,/home/xupeng/matrixone/cgo -Wl,-rpath,/home/xupeng/matrixone/thirdparties/install/lib -fopenmp" \
LD_LIBRARY_PATH="/home/xupeng/matrixone/cgo:/home/xupeng/matrixone/thirdparties/install/lib:${LD_LIBRARY_PATH}" \
go test ./pkg/vectorindex/hnsw -run 'TestBuildMultiWorker|TestBuildMultiWorkerLastItemError|TestBuildMultiWorkerRollover|TestZZBuildOrphan' -count=1
CGO_CFLAGS="-I/home/xupeng/matrixone/cgo -I/home/xupeng/matrixone/thirdparties/install/include" \
CGO_LDFLAGS="-L/home/xupeng/matrixone/cgo -lmo -L/home/xupeng/matrixone/thirdparties/install/lib -Wl,-rpath,/home/xupeng/matrixone/cgo -Wl,-rpath,/home/xupeng/matrixone/thirdparties/install/lib -fopenmp" \
LD_LIBRARY_PATH="/home/xupeng/matrixone/cgo:/home/xupeng/matrixone/thirdparties/install/lib:${LD_LIBRARY_PATH}" \
go test -race ./pkg/vectorindex/hnsw -run 'TestBuildMultiWorkerRollover|TestBuildMultiWorkerLastItemError' -count=1
CGO_CFLAGS="-I/home/xupeng/matrixone/cgo -I/home/xupeng/matrixone/thirdparties/install/include" \
CGO_LDFLAGS="-L/home/xupeng/matrixone/cgo -lmo -L/home/xupeng/matrixone/thirdparties/install/lib -Wl,-rpath,/home/xupeng/matrixone/cgo -Wl,-rpath,/home/xupeng/matrixone/thirdparties/install/lib -fopenmp" \
LD_LIBRARY_PATH="/home/xupeng/matrixone/cgo:/home/xupeng/matrixone/thirdparties/install/lib:${LD_LIBRARY_PATH}" \
go test ./pkg/sql/colexec/table_function -run 'TestHnswCreate|TestHnswCreateF64' -count=1

All passed locally. LGTM.

@mergify mergify Bot added the queued label Jun 26, 2026
@mergify

mergify Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Merge Queue Status

  • Entered queue2026-06-26 08:39 UTC · Rule: main · triggered by rule Automatic queue on approval for main
  • Checks passed · in-place
  • Merged2026-06-26 09:50 UTC · at b251b1815c755e4af8023c4306678c1a1e77d1c9 · squash

This pull request spent 1 hour 10 minutes 26 seconds in the queue, including 1 hour 10 minutes 1 second running CI.

Required conditions to merge
  • #approved-reviews-by >= 1 [🛡 GitHub branch protection]
  • #review-threads-unresolved = 0 [🛡 GitHub branch protection]
  • github-review-decision = APPROVED [🛡 GitHub branch protection]
  • any of [🛡 GitHub branch protection]:
    • check-success = Matrixone Compose CI / multi cn e2e bvt test docker compose(PESSIMISTIC)
    • check-neutral = Matrixone Compose CI / multi cn e2e bvt test docker compose(PESSIMISTIC)
    • check-skipped = Matrixone Compose CI / multi cn e2e bvt test docker compose(PESSIMISTIC)
  • any of [🛡 GitHub branch protection]:
    • check-success = Matrixone Standlone CI / Multi-CN e2e BVT Test on Linux/x64(LAUNCH, PROXY)
    • check-neutral = Matrixone Standlone CI / Multi-CN e2e BVT Test on Linux/x64(LAUNCH, PROXY)
    • check-skipped = Matrixone Standlone CI / Multi-CN e2e BVT Test on Linux/x64(LAUNCH, PROXY)
  • any of [🛡 GitHub branch protection]:
    • check-success = Matrixone Standlone CI / e2e BVT Test on Linux/x64(LAUNCH, PESSIMISTIC)
    • check-neutral = Matrixone Standlone CI / e2e BVT Test on Linux/x64(LAUNCH, PESSIMISTIC)
    • check-skipped = Matrixone Standlone CI / e2e BVT Test on Linux/x64(LAUNCH, PESSIMISTIC)
  • any of [🛡 GitHub branch protection]:
    • check-success = Matrixone CI / UT Test on Ubuntu/x86
    • check-neutral = Matrixone CI / UT Test on Ubuntu/x86
    • check-skipped = Matrixone CI / UT Test on Ubuntu/x86
  • any of [🛡 GitHub branch protection]:
    • check-success = Matrixone Compose CI / multi cn e2e bvt test docker compose(Optimistic/PUSH)
    • check-neutral = Matrixone Compose CI / multi cn e2e bvt test docker compose(Optimistic/PUSH)
    • check-skipped = Matrixone Compose CI / multi cn e2e bvt test docker compose(Optimistic/PUSH)
  • any of [🛡 GitHub branch protection]:
    • check-success = Matrixone Standlone CI / e2e BVT Test on Linux/x64(LAUNCH,Optimistic)
    • check-neutral = Matrixone Standlone CI / e2e BVT Test on Linux/x64(LAUNCH,Optimistic)
    • check-skipped = Matrixone Standlone CI / e2e BVT Test on Linux/x64(LAUNCH,Optimistic)
  • any of [🛡 GitHub branch protection]:
    • check-success = Matrixone Upgrade CI / Compatibility Test With Target on Linux/x64(LAUNCH)
    • check-neutral = Matrixone Upgrade CI / Compatibility Test With Target on Linux/x64(LAUNCH)
    • check-skipped = Matrixone Upgrade CI / Compatibility Test With Target on Linux/x64(LAUNCH)
  • any of [🛡 GitHub branch protection]:
    • check-success = Matrixone Utils CI / Coverage
    • check-neutral = Matrixone Utils CI / Coverage
    • check-skipped = Matrixone Utils CI / Coverage
  • any of [🛡 GitHub branch protection]:
    • check-success = Matrixone CI / SCA Test on Linux/arm64
    • check-neutral = Matrixone CI / SCA Test on Linux/arm64
    • check-skipped = Matrixone CI / SCA Test on Linux/arm64

@mergify
mergify Bot merged commit 34fe775 into matrixorigin:main Jun 26, 2026
23 of 24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kind/bug Something isn't working size/M Denotes a PR that changes [100,499] lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants