Skip to content

Reject duplicate neighbor IDs while preserving score order - #738

Merged
MarkWolters merged 7 commits into
mainfrom
fix-neighbor-id-uniqueness
Oct 19, 2026
Merged

MarkWolters merged 7 commits into
mainfrom
fix-neighbor-id-uniqueness

Conversation

@tlwillke

@tlwillke tlwillke commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Asymmetric scoring (A · quant(B) ≠ B · quant(A)) can assign different scores to the same neighbor ID. Duplicate checks restricted to equal-score entries then admit repeated IDs, consuming adjacency capacity and reducing effective degree. The problem is particularly apparent at higher levels in hierarchical indexes.

For example, in one 999,000-vector OpenAI1536 graph built from main, redundant neighbor entries consumed 0.16% of adjacency entries at level 0, 8.2% at level 1, 25.1% at level 2, and 45.7% at level 3.

  • NodeArray checks IDs independently of score and deduplicates entire candidate merges, including tails. Existing scores, score ordering, and equal-score interleaving are preserved.
  • ConcurrentNeighborMap deduplicates initial candidate batches, preserves merge inputs across concurrent update retries, and rejects duplicate offers before copying, eviction, or changes to pruning state.
  • Regression coverage spans different-score duplicates, capacity boundaries, merge ordering, concurrent offers, pruning state, and graph uniqueness at every layer.

Supersedes @marianotepper's draft #522 while retaining score-sorted adjacency and the existing diversity-prefix optimization. API and index format are unchanged; rebuilding regenerates existing adjacency.

Validation

TestNodeArray, TestNeighbors, and GraphIndexBuilderTest: 27 test executions each in scalar and SIMD configurations on JDK 22, with zero failures, errors, or skips.

The completed comparative benchmark covered MPNet, Cohere English v3, CAP, and OpenAI3072 under JDK 22/25: 72 fresh builds and all-layer audits, plus 256 query JVMs. All 36 fixed graphs contained zero duplicate neighbor IDs.

Benchmarking

Distinct edges broaden graph coverage and can improve recall, but increase traversal work and reduce QPS at fixed search settings.

The tables cover five datasets, both JDKs, and 40 matched pairs (80 graphs). Mean recall improved in all ten dataset/JDK groups at 1× and nine of ten at 2×. OpenAI3072/JDK25 mean recall decreased by 0.225 pp at 2×. QPS decreased; construction time varied. All 40 fixed graphs had zero duplicate neighbor IDs in all-layer audits. Four of 40 matched pairs had lower recall at either depth; the individual comparisons are retained below.

Construction and throughput

Values: main → PR #738 (relative change).

Dataset JDK Construction (s) QPS, 1× OQ QPS, 2× OQ
DPR-Gemma768 22 44.99 → 43.23 (-3.9%) 13,058 → 12,660 (-3.0%) 10,174 → 9,915 (-2.5%)
DPR-Gemma768 25 35.58 → 39.49 (+11.0%) 13,664 → 13,303 (-2.6%) 10,480 → 10,304 (-1.7%)
PersonaHub1024 22 38.66 → 38.38 (-0.7%) 12,221 → 11,927 (-2.4%) 9,581 → 9,434 (-1.5%)
PersonaHub1024 25 36.01 → 39.70 (+10.3%) 11,684 → 11,241 (-3.8%) 9,208 → 8,940 (-2.9%)
OpenAI1536 22 57.10 → 59.70 (+4.5%) 8,126 → 7,831 (-3.6%) 5,980 → 5,880 (-1.7%)
OpenAI1536 25 55.93 → 55.26 (-1.2%) 7,970 → 7,817 (-1.9%) 5,869 → 5,726 (-2.4%)
Tahoe2560 22 67.31 → 70.82 (+5.2%) 5,026 → 4,871 (-3.1%) 4,168 → 4,082 (-2.1%)
Tahoe2560 25 59.60 → 63.43 (+6.4%) 4,848 → 4,665 (-3.8%) 4,041 → 3,922 (-2.9%)
OpenAI3072 22 112.54 → 111.39 (-1.0%) 3,814 → 3,740 (-1.9%) 3,123 → 3,067 (-1.8%)
OpenAI3072 25 112.44 → 111.42 (-0.9%) 3,784 → 3,693 (-2.4%) 3,055 → 2,983 (-2.4%)

Recall@10

Values: main → PR #738, in percent; changes in percentage points. Recall columns are the means of four matched pairs per row. “Lower pairs” counts individual pairs (out of a possible of 4 pairs) with lower PR recall, shown as 1× / 2×; it does not label the row's mean as a loss.

Dataset JDK Recall, 1× Recall, 2× Lower pairs
1x OQ / 2x OQ
(out of 4)
DPR-Gemma768 22 64.827 → 66.603 (+1.777 pp) 84.492 → 86.001 (+1.510 pp) 0 / 0
DPR-Gemma768 25 65.024 → 66.622 (+1.598 pp) 84.644 → 86.044 (+1.401 pp) 0 / 0
PersonaHub1024 22 41.939 → 42.465 (+0.526 pp) 61.266 → 61.704 (+0.437 pp) 0 / 0
PersonaHub1024 25 42.036 → 42.590 (+0.554 pp) 61.637 → 61.920 (+0.283 pp) 0 / 1
OpenAI1536 22 59.845 → 61.105 (+1.260 pp) 77.350 → 77.970 (+0.620 pp) 1 / 1
OpenAI1536 25 59.460 → 61.150 (+1.690 pp) 77.303 → 78.540 (+1.238 pp) 0 / 0
Tahoe2560 22 20.996 → 22.055 (+1.059 pp) 32.980 → 34.193 (+1.213 pp) 0 / 0
Tahoe2560 25 20.943 → 21.757 (+0.815 pp) 32.715 → 33.735 (+1.021 pp) 0 / 0
OpenAI3072 22 64.992 → 66.173 (+1.180 pp) 80.648 → 80.993 (+0.345 pp) 0 / 0
OpenAI3072 25 65.368 → 65.893 (+0.525 pp) 80.723 → 80.498 (-0.225 pp) 1 / 2

Individual pairs with a loss at either depth are shown below. PersonaHub/JDK25 pair 1 improves at 1× and loses slightly at 2×.

Dataset JDK Pair Recall, 1× OQ: main → PR Recall, 2× OQ: main → PR
PersonaHub1024 25 1 42.056 → 42.288 (+0.232 pp) 61.517 → 61.473 (-0.044 pp)
OpenAI1536 22 3 61.260 → 60.720 (-0.540 pp) 77.800 → 77.510 (-0.290 pp)
OpenAI3072 25 2 64.540 → 65.610 (+1.070 pp) 79.760 → 79.560 (-0.200 pp)
OpenAI3072 25 3 66.970 → 66.240 (-0.730 pp) 82.650 → 81.190 (-1.460 pp)

Method

Approximately 1M vectors per dataset; all catalog queries and four matched seed combinations per dataset/JDK. The OpenAI3072 rows summarize the completed targeted seed-confirmation study; the other four datasets use the same two-by-two PQ/graph-seed design. Results are computed separately for each dataset/JDK, with no pooling between studies. One additional OpenAI3072/JDK25 observation with nonidentical PQ codebook bytes is retained separately and excluded from the matched-pair averages. M=32, efConstruction=100, FusedPQ/NVQ, pruning disabled, one query worker on CPU0. Actual PQ codebook bytes and hierarchy assignments match within each pair.

Construction is the median per-build total for training, encoding, NVQ setup, and graph/write. QPS is the mean of per-graph median measured QPS; recall is the mean across matched pairs. Every completed pair is included. These timings are descriptive results from the recall-confirmation study.

Exhaustive scoring checks

OpenAI3072 exhaustive PQ and NVQ scans produced identical encoded bytes, per-query score-bit hashes, and top-10 results between arms on both JDKs. Recall was 0.8531/0.8537 for the two PQ codebooks and 0.9981 for NVQ. These scans do not cover fused graph scoring.

Saved-graph diagnostics and global validation are complete.

@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Before you submit for review:

  • Does your PR follow guidelines from CONTRIBUTIONS.md?
  • Did you summarize what this PR does clearly and concisely?
  • Did you include performance data for changes which may be performance impacting?
  • Did you include useful docs for any user-facing changes or features?
  • Did you include useful javadocs for developer oriented changes, explaining new concepts or key changes?
  • Did you rebase your branch onto the latest main for regression testing and PR submission?
  • Did you trigger regression testing via Run Bench Main and review results?
  • Did you adhere to the code formatting guidelines (TBD)
  • Did you group your changes for easy review, providing meaningful descriptions for each commit?
  • Did you ensure that all files contain the correct copyright header?
  • Did you add documentation for this feature to the release notes directory?

If you did not complete any of these, then please explain below.

@tlwillke tlwillke added the bug Something isn't working label Sep 30, 2026
@tlwillke

tlwillke commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator Author

PQ benchmark results — performance not cleared

Completed 12 fresh PQ builds (two per arm per dataset), 12 retained-index query retests, and four separate CAP JFR construction profiles. Baseline e31aa595; candidate a4be0e9b. The earlier fallback-reader runs are excluded.

Configuration: JDK 23, Panama 512-bit SIMD, verified MemorySegmentReader, 48 construction workers, single-thread queries; degree 64, efConstruction 200, overflow 1.2, hierarchy/refinement enabled, search pruning disabled; PQ mFactor=8, k=256, uncentered, fused PQ + NVQ reranking. Shared codebooks and identical benchmark overlays. Build order main/fix/fix/main.

Means below; QPS is from the separate retained-index retest with sequential warming, verified initial residency, three real-query warmup passes, and three samples of at least 10 seconds. Initial residency does not guarantee residency throughout execution.

Dataset Arm Build seconds Warm QPS 1x / 2x Recall@10 % 1x / 2x Visited 1x / 2x
Ada002-1M main 582.73 2673.7 / 2369.3 61.000 / 80.951 773.3 / 1081.9
Ada002-1M fix 544.20 2442.5 / 2434.2 61.215 / 81.140 808.2 / 1115.5
CAP-1M main 278.99 2302.8 / 2188.2 56.851 / 77.099 722.4 / 1005.8
CAP-1M fix 766.13 2545.0 / 2414.8 56.936 / 77.264 769.3 / 1053.8
Cohere-English-v3-1M main 180.22 3370.5 / 3160.3 66.831 / 85.852 823.3 / 1200.4
Cohere-English-v3-1M fix 728.80 3152.3 / 2884.2 66.810 / 85.758 899.0 / 1276.5

Correctness

Every fixed graph has zero duplicate neighbor IDs at every level. Baseline extra duplicate entries per build: Ada002 180,869 / 180,227; CAP 199,123 / 199,720; Cohere 770,411 / 769,181. The 27 focused regression tests passed. Recall differences are small, but two builds do not establish statistical significance.

Timing limitations and profiling

Do not interpret construction means as an isolated deduplication cost. Raw build times (seconds): Ada main 249.24/916.22, fix 201.76/886.65; CAP main 316.40/241.58, fix 645.70/886.56; Cohere main 187.54/172.90, fix 784.38/673.22. All outliers are retained. Query timing also varies across JVMs despite warming.

Separate CAP JFR runs locate most slow-run time in insertion. Insertion times main/fix/fix/main were 1337.00/1277.67/227.96/869.51 seconds (diagnostic timings, not pooled above). Slow runs on both branches heavily sample PanamaVectorUtilSupport.assembleAndSumPQ_512 line 949 → IntVector.intoArray → VectorSupport.store → Java lane-by-lane stOp fallback loops. The fast fixed-branch run lacks material sampling in those fallback loops. Selecting Panama does not guarantee every operation is intrinsified. This identifies a shared hot path; the precise compiler/intrinsic cause and isolated deduplication overhead remain unresolved.

Recommendation: keep this PR draft. Correctness is demonstrated; performance is not cleared. Investigate the shared PQ compilation instability separately before requesting readiness. No ASH, reader implementation, query scorer, or format changes are included in this PR.

@tlwillke

tlwillke commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator Author

CAP-100k construction diagnosis: shared PQ/JIT instability

Follow-up on the construction-time variability above. Tested exactly the first 100,000 CAP vectors, with the same shared PQ codebook, graph parameters, JDK23, 48 construction workers, Panama512, and verified MemorySegmentReader. No recall measurement for this prefix. Production artifacts remain unchanged.

Uninstrumented insertion times in main/fix/fix/main order: 15.84 / 55.53 / 21.09 / 83.05 seconds. Both branches exhibit the problem.

Four separate JFR/compiler-log runs gave insertion 57.82 / 21.48 / 19.71 / 21.86 seconds. All reached C2 early. The slow kernel lacks _VectorStoreOp; all three fast kernels have it. Slow stacks include assembleAndSumPQ_512 → IntVector.intoArray → Java store fallback.

Separate timing instrumentation measured 1.251B vs 1.258B insertion diversity comparisons (+0.59%), but sampled cost rose from 670 ns to 2,869 ns per comparison. Search scoring did not slow down. Wrappers can change JIT decisions; these are diagnostic measurements, not isolated branch-overhead estimates.

Allocation is a major part of this: GC logs show approximately 5.21 TB cumulatively reclaimed in the slow-main profiled JVM versus 12.9 GB in fast main (rounded before/after heap sizes, whole runs, not resident memory). JFR allocation stacks identify temporary vector objects and backing arrays in PQ conversions/gather/add operations. Short GC pauses alone substantially understate the allocation cost.

Controls, again main/fix/fix/main insertion seconds:

  • Suppress only the kernel store intrinsic: 80.99 / 38.77 / 83.64 / 78.98. This generates an inlined fallback and is not identical to the naturally slow path.
  • Disable tiered compilation: 133.40 / 164.79 / 166.08 / 142.64. This also loses load/gather intrinsics; C2 rejects the two fromByteSequence calls and fromVectorFloat because the helpers are already compiled into big methods.

During the observed 164.79-second insertion, system I/O pressure was only 0.007 seconds over the 162.36-second monitored window, system iowait 0.006%, and no Java D-state threads were sampled. This argues against disk stalls as the main cause of that run. Separate writeInline contention and one 24.59-second flush outlier remain documented; they are not being discarded.

Keep this PR draft. The shared PQ compilation/allocation problem is established, but the exact trigger for the default-mode store-intrinsic failure and a production remedy remain unresolved. Stabilize that scorer before interpreting the large 1M build differences as deduplication overhead. Neither diagnostic JVM flag is a recommended production setting.

@MarkWolters

Copy link
Copy Markdown
Contributor

This is good, regression test shows across the board improvement in recall

Recall_at_10

full report attached
pr738.tar.gz

@tlwillke tlwillke added the 4.0.2 Release label label Oct 13, 2026
@tlwillke
tlwillke marked this pull request as ready for review October 17, 2026 19:22
@tlwillke tlwillke self-assigned this Oct 17, 2026
The existing graph-level uniqueness test builds with exact DOT_PRODUCT,
which is symmetric, so it passes on main. A PQ build scores the same edge
differently from each endpoint; this test fails on main (4/4 seeds) with
a repeated level-0 edge and passes with the fix.
Both IntHashSets started at Agrona's default capacity and rehashed
repeatedly on every insertDiverse merge. Size them to 2x their element
count so they stay under the 0.65 load factor for the whole merge.
@tlwillke
tlwillke requested a review from MarkWolters October 17, 2026 20:56
@tlwillke

Copy link
Copy Markdown
Collaborator Author

@jshook thanks for your related PR #765 with fixes and suggestions. Incorporated #765’s asymmetric-PQ regression test and hash-set pre-sizing, preserving both commits. Added documentation clarifications and formatting cleanup.

I did not adopt:

  • Replacing an existing edge’s score with a later “better” score. Duplicate offers retain the installed score; concurrent arrival behavior is now documented.
  • Score-based duplicate detection alone. Binary search still finds the insertion position, but detecting duplicate IDs requires checking across scores.
  • Automatic repair of loaded indexes. Removing old duplicates requires a full rebuild.

NodeArray is internal despite its Java-public visibility; the supported library API and index format remain unchanged.

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

reapproval after merge

@MarkWolters
MarkWolters merged commit d774fad into main Oct 19, 2026
20 checks passed
@MarkWolters
MarkWolters deleted the fix-neighbor-id-uniqueness branch October 19, 2026 16:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4.0.2 Release label bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants