Skip to content

HNSW: don't rewind insertPage to deleted slots that cannot fit the element - #1026

Open
jitokim wants to merge 3 commits into
pgvector:masterfrom
jitokim:fix/hnsw-insertpage-unfittable-slot
Open

jitokim wants to merge 3 commits into
pgvector:masterfrom
jitokim:fix/hnsw-insertpage-unfittable-slot

Conversation

@jitokim

@jitokim jitokim commented Sep 10, 2026

Copy link
Copy Markdown

HnswFreeOffset() latches *newInsertPage on the first deleted element it finds, before checking whether that slot can hold the element being inserted. With fixed-size types every slot fits, so this is harmless. With sparsevec a slot freed by a small element cannot hold a larger one, so after a VACUUM the metapage insertPage is rewound to a page that never fits, and every subsequent larger insert walks the index from there to the end — a full index scan per insert, permanently.

Fixes #1025

Change

Latch newInsertPage only when the element tuple fits (pageFree >= etupSize). The freed neighbor tuple is always at least a level-0 neighbor tuple, so checking etupSize alone is sufficient. This matches the "first page where element at level 0 can fit" criterion AddElementOnDisk() already uses for minCombinedSize, so both updates of newInsertPage follow the same rule.

An earlier revision of this PR checked the full fit for the current element (pageFree >= etupSize && npageFree >= ntupSize). That over-constrains for fixed-size types: a level-N element needs a larger neighbor tuple than a level-0 slot provides, so it would skip level-0 slots without latching and the next insert would start past them. Measured on vector(64) m=16 (200k rows, 5% scattered delete, VACUUM, 50k inserts, 5 runs): ~4% of freed slots left unreused, +0.2% index size, recovered on the next VACUUM. The current revision leaves zero such slots in 12/12 runs (counted via pageinspect as deleted=1 element tuples).

Verification

sparsevec(10000), 100k rows at nnz=50, scattered 5% delete + VACUUM, then nnz=500 inserts — buffers per insert:

stock this PR
L1 — first large insert after VACUUM 10,772 10,784
L2 — repeat 10,747 1,637
L3 — after a small insert 10,749 1,664

make installcheck 14/14 on this branch (PostgreSQL 16).

@jitokim
jitokim force-pushed the fix/hnsw-insertpage-unfittable-slot branch from bf88fcb to 360efe8 Compare September 10, 2026 10:12
HnswFreeOffset() claimed *newInsertPage on the first deleted element it
encountered, before checking whether that slot could hold the incoming
element. For fixed-size types every slot fits so this was harmless, but
for variable-size types (sparsevec) a slot freed by a small element
cannot hold a larger one. The metapage insertPage was then rewound to
that page on every insert, and each subsequent larger insert walked the
index from there to the end.

Latch newInsertPage only when the element tuple fits (pageFree >=
etupSize). The freed neighbor tuple is always at least a level-0
neighbor tuple, so this matches the "first page where element at level 0
can fit" criterion that AddElementOnDisk() already uses for
minCombinedSize, and keeps the two updates of the same variable
consistent.

Fixes pgvector#1025
@jitokim
jitokim force-pushed the fix/hnsw-insertpage-unfittable-slot branch from 360efe8 to cd5a316 Compare September 10, 2026 10:14
@jitokim

jitokim commented Sep 10, 2026

Copy link
Copy Markdown
Author

Pushed two follow-up commits:

  • test/t/049_hnsw_sparsevec_insert_page.pl — 30k sparsevec rows, 5% scattered delete, VACUUM, then asserts that a repeat large insert (and a large insert after a small one) reads less than half the index. The check is a ratio against pg_relation_size, so it does not depend on the machine. Fails on master (9324 < 3751.5 FAILED), passes with this patch (~15 s).
  • A comment above the latch explaining why it has to come after the size check.

Verified on pgvector/pgvector:pg16 (0.8.6 / PG 16.15): make installcheck 14/14 and the full TAP suite (49 files, 1,256 tests) pass. Fixed-size vector(512) insert cost and final index sizes for append-only and delete/VACUUM/reinsert workloads are unchanged versus master. Full numbers in #1025.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant