Skip to content

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

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

jitokim wants to merge 5 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.

@ankane

ankane commented Sep 23, 2026

Copy link
Copy Markdown
Member

Hi @jitokim, thanks for the PR. The proposed approach has its own drawback - if a sparsevec with a large nnz is inserted after a vacuum, the insert page will be moved to the end of the index and none of the space will be reused.

One idea to partially mitigate this is to only move the insert page a certain number of free slots.

--- a/src/hnswinsert.c
+++ b/src/hnswinsert.c
@@ -42,10 +42,11 @@ GetInsertPage(Relation index)
  * Check for a free offset
  */
 static bool
-HnswFreeOffset(Relation index, Buffer buf, Page page, HnswElement element, Size etupSize, Size ntupSize, Buffer *nbuf, Page *npage, OffsetNumber *freeOffno, OffsetNumber *freeNeighborOffno, BlockNumber *newInsertPage, uint8 *tupleVersion)
+HnswFreeOffset(Relation index, Buffer buf, Page page, HnswElement element, Size etupSize, Size ntupSize, Buffer *nbuf, Page *npage, OffsetNumber *freeOffno, OffsetNumber *freeNeighborOffno, BlockNumber *newInsertPage, int *newInsertPageTries, uint8 *tupleVersion)
 {
 	OffsetNumber offno;
 	OffsetNumber maxoffno = PageGetMaxOffsetNumber(page);
+	int			startingTries = *newInsertPageTries;
 
 	for (offno = FirstOffsetNumber; offno <= maxoffno; offno = OffsetNumberNext(offno))
 	{
@@ -65,9 +66,6 @@ HnswFreeOffset(Relation index, Buffer buf, Page page, HnswElement element, Size
 			Size		pageFree;
 			Size		npageFree;
 
-			if (!BlockNumberIsValid(*newInsertPage))
-				*newInsertPage = elementPage;
-
 			if (neighborPage == elementPage)
 			{
 				*nbuf = buf;
@@ -99,6 +97,20 @@ HnswFreeOffset(Relation index, Buffer buf, Page page, HnswElement element, Size
 			else if (pageFree >= etupSize)
 				npageFree += pageFree - etupSize;
 
+			/*
+			 * Keep track of first page where element at level 0 can fit. For
+			 * variable-length items (sparsevec), skip up to 3 pages to
+			 * prevent large scans.
+			 */
+			if (!BlockNumberIsValid(*newInsertPage))
+			{
+				if (pageFree >= etupSize || *newInsertPageTries >= 3)
+					*newInsertPage = elementPage;
+				/* Only increment once per page */
+				else if (*newInsertPageTries == startingTries)
+					(*newInsertPageTries)++;
+			}
+
 			/* Check for space */
 			if (pageFree >= etupSize && npageFree >= ntupSize)
 			{
@@ -160,6 +172,7 @@ AddElementOnDisk(Relation index, HnswElement e, int m, BlockNumber insertPage, B
 	OffsetNumber freeOffno = InvalidOffsetNumber;
 	OffsetNumber freeNeighborOffno = InvalidOffsetNumber;
 	BlockNumber newInsertPage = InvalidBlockNumber;
+	int			newInsertPageTries = 0;
 	uint8		tupleVersion;
 	char	   *base = NULL;
 
@@ -210,7 +223,7 @@ AddElementOnDisk(Relation index, HnswElement e, int m, BlockNumber insertPage, B
 		}
 
 		/* Next, try space from a deleted element */
-		if (HnswFreeOffset(index, buf, page, e, etupSize, ntupSize, &nbuf, &npage, &freeOffno, &freeNeighborOffno, &newInsertPage, &tupleVersion))
+		if (HnswFreeOffset(index, buf, page, e, etupSize, ntupSize, &nbuf, &npage, &freeOffno, &freeNeighborOffno, &newInsertPage, &newInsertPageTries, &tupleVersion))
 		{
 			if (nbuf != buf)
 			{

This would prevent it from getting stuck on sparsevec with a small nnz.

Another approach would be to use the free space map. Will spend some time trying this.

…element

With the previous change, a larger element that does not fit the slots
freed by VACUUM scans past them to the end of the index and moves
insertPage there, so smaller elements stop reusing the freed slots until
the next VACUUM.

Count the pages whose deleted slots are all too small for the element.
After a few of them, jump to the last page instead of scanning the rest
of the index, and keep insertPage where it is so the freed slots are
still reused by elements that fit them.

The last page is only followed if it is initialized. A page that was
added by a crashed or failed insert is not linked from the previous page.
@jitokim

jitokim commented Sep 25, 2026

Copy link
Copy Markdown
Author

Thanks for the feedback, @ankane. Reproduced: after VACUUM, a few large inserts move insertPage to the tail and small inserts stop reusing the freed slots (+1,259 pages vs +10 on master, 50k rows).

The tries-bounded latch keeps reuse, but repeat large inserts still scan to the end of the index (049 fails), since insertPage only moves ~4 pages per insert.

Pushed a version that bounds the scan instead: after 3 pages whose deleted slots are all too small, jump to the last page and leave insertPage unchanged. Reuse matches master, large inserts stay near baseline, and 050 covers your case. Numbers are in #1025.

That said, FSM sounds like the better fix. Happy to close this if you go that way.

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

Labels

None yet

Development

Successfully merging this pull request may close these issues.

HNSW: insertPage rewinds to unfittable deleted slots after VACUUM, causing near-full index scan per insert with variable-size types (sparsevec) — reproduction for #975

2 participants