Skip to content

Prefetch unvisited neighbors during HNSW search - #1020

Open
Manuelreyesbravo wants to merge 1 commit into
pgvector:masterfrom
Manuelreyesbravo:prefetch-hnsw-neighbors
Open

Prefetch unvisited neighbors during HNSW search#1020
Manuelreyesbravo wants to merge 1 commit into
pgvector:masterfrom
Manuelreyesbravo:prefetch-hnsw-neighbors

Conversation

@Manuelreyesbravo

Copy link
Copy Markdown

HnswLoadUnvisitedFromDisk fills the unvisited array with the tid of every neighbor the search is about to consider, so their block numbers are all known before the first one is read. The loop right after reads them one at a time, waiting on each. This asks for them up front so the reads can overlap.

The whole change is one loop, guarded to the on-disk path:

if (!inMemory)
{
    for (int i = 0; i < unvisitedLength; i++)
        PrefetchBuffer(index, MAIN_FORKNUM,
                       ItemPointerGetBlockNumber(&unvisited[i].indextid));
}

When it helps, and when it does not

38,352 vector(768) rows, 145 MB HNSW index, PostgreSQL 19beta2. Each run is 300 searches with a different query vector every time (pgbench), so the working set is the whole index rather than one repeated path.

memory available without with
200 MB 14.9 ms 8.0 ms 1.9x
350 MB 5.03 ms 4.96 ms no difference
index fully resident 0.756 ms 0.737 ms no difference

It helps when I/O dominates and stays out of the way when it does not. I could not measure a cost in the resident case; HNSW visits few enough nodes that the extra calls disappear into the noise.

Method

Getting this to measure anything real took three attempts, so the details matter:

  • Two prebuilt binaries swapped between runs, not rebuilds. With the change already committed, git stash silently stashes nothing and you end up timing one binary against itself.
  • A/B order alternated between rounds. The first run warms the cache and the second looks faster whichever binary it is.
  • Page cache dropped identically before each run, and PostgreSQL confined to a memory cgroup. Without the cgroup a 145 MB index simply lives in the host page cache and reads land at ~3 us, which is memory. Inside it they land at ~56 us, which is the disk.

An earlier version of this measurement dropped the page cache before every query and reported 3.5x. That simulates "every search is the first one after a reboot" and overstates the effect by about two times. The 1.9x above is the number that survives a realistic workload.

Correctness

  • make installcheck: 14/14, including hnsw_vector, hnsw_halfvec, hnsw_bit, hnsw_sparsevec.
  • Buffer reads and hits are identical with and without the change, down to the last buffer: the search walks the same graph and touches the same pages.
  • The 50 nearest neighbors come back identical, in the same order, compared with diff between the two builds.

PrefetchBuffer is a hint and not a read: no pin, no lock, nothing to release, so it cannot change what the scan visits.

Where this does not pay off

I tried the same change in a quantized graph index (pgvectorscale's DiskANN, ~0.7 KB per vector) and it measured as noise. With quantization many neighbors share one 8 KB page, so asking for sixteen of them is three or four reads, not sixteen. HNSW stores the full vector, each element takes close to a page of its own, and a neighborhood really is that many independent reads. That seems to be the condition for this to be worth anything.

Caveats

One machine, one dataset, one index size. ivfscan.c has the same shape and is untouched here. Happy to run whatever else would make this easier to evaluate — different dimensions, m, ef_search, or a larger corpus.

HnswLoadUnvisitedFromDisk fills the unvisited array with the tid of every
neighbor the search is about to consider, so their block numbers are all known
before the first one is read. The loop that follows reads them one at a time,
waiting on each. Issuing those reads up front lets them overlap.

Measured on 38,352 vector(768) rows with a 145 MB HNSW index. Each run is 300
searches with a different query vector every time (pgbench), so the working set
is the whole index rather than one repeated path. Two prebuilt binaries are
swapped between runs, A/B order alternated between rounds, page cache dropped
identically before each run, and PostgreSQL is confined to a memory cgroup so
that reads actually reach the disk (~56 us each) instead of the host page cache
(~3 us):

  memory available    without      with
  200 MB              14.9 ms    8.0 ms    1.9x
  350 MB              5.03 ms    4.96 ms   no difference
  fully resident      0.756 ms   0.737 ms  no difference

It helps when I/O dominates, and stays out of the way when it does not. Skipped
on the in-memory path, where there are no buffers to fetch.

This is a hint and not a read: no pin, no lock, nothing to release, so it cannot
change what the scan visits. Buffer reads and hits are identical with and
without it, and the 50 nearest neighbors come back identical, in the same order.

Worth noting where this does NOT pay off: the same change in a quantized graph
index (pgvectorscale's DiskANN, ~0.7 KB per vector) measured as noise, because
many neighbors share one 8 KB page and there is little left to overlap. HNSW
stores the full vector, so each element takes close to a page of its own and a
neighborhood is that many independent reads.

make installcheck: 14/14.
@Manuelreyesbravo

Manuelreyesbravo commented Aug 26, 2026

Copy link
Copy Markdown
Author

I should have read your branches before opening this. You already wrote this
patch: hnsw-prefetch (May 2025) is the same loop in the same function, and mine
differs only in guarding on !inMemory instead of USE_PREFETCH. You then moved
on to hnsw-read-stream and hnsw-read-stream3, which is clearly the better
shape. So the interesting question is not the one this PR asks — it is how the
two compare, and I could not find that measured. Here it is, including the part
that argues against my own patch.

Setup: 38,352 vector(768) rows, 145 MB HNSW index, PG19beta2. Three binaries
from one tree with identical flags, swapped between runs, A/B order alternated,
page cache dropped identically, PostgreSQL in a 200 MB cgroup, 300 searches per
run with a different query vector each time.

One correction was needed first: hnsw-read-stream3 sits on an older master
whose Makefile predates -ffp-contract=fast, so comparing it against current
master would have measured two compilers rather than two algorithms, in distance
code. I cherry-picked 90aaf21 onto current master instead — it applies cleanly.

latency vs master
master 12.85 ms
hnsw-read-stream3 11.54 ms 1.11x
this PR 7.57 ms 1.67x

That gap is mostly not about read streams. It is the ramp-up. A stream starts at
readahead_distance = 1 and doubles only when it has to wait for I/O, and it
decays on hits — and in HNSW two thirds of the accesses are hits (585 hits vs 278
reads per query), which arrive right before the misses do. Adding
READ_STREAM_FULL to your flags skips the ramp-up:

latency
hnsw-read-stream3 as written 14.28 ms
+ READ_STREAM_FULL 10.78 ms 1.32x
this PR 1.14x over the above

(absolute numbers in that pair are inflated — something else was using the disk.
The A/B is internal and alternated, so the ratio holds, but do not compare those
milliseconds against the first table.)

So with a one-line change your branch lands within 14% of this patch, and at
that distance I would keep the read stream: it gets I/O accounting, buffer
management and AIO integration from core, and this PR gets none of that. I think
that weakens the case for merging this, and it seemed worth saying plainly rather
than leaving it for you to find.

I also re-ran the head-to-head under io_method=io_uring rather than worker,
since testing something called "async I/O" on the less favourable backend would
not have been fair. It made no difference (1.58x vs 1.49x, noise ~1 ms).

All versions read the identical set of pages — 585 hits, 278 reads, to the block —
so none of this changes what the search visits.

One thing I got wrong along the way, in case it saves you time: I assumed the
per-node read_stream_resume restarted the look-ahead each neighborhood. It does
not — resume restores the saved distance, and its comment says it exists for
exactly this case ("streams of self-referential blocks"). The ramp-up happens once
per stream, not once per node.

Correction to an earlier version of this comment: I first described
READ_STREAM_FULL as a flag I was abusing, on the assumption that it meant "I am
reading the whole relation". It does not. Its documented meaning is exactly this
case — "this flag disables [the ramp-up], declaring ahead of time that we'll be
reading all available buffers". So it is not a workaround, it is the intended
mechanism, and an HNSW neighborhood does consume every block the callback
produces. That makes the fix a plain one-line flag addition rather than anything
core needs to grow.

Caveats: one machine, one dataset, one index size, one m and ef_search, and
your commit measured rebased rather than as you left it. Close this PR if the read
stream is where you want it to go — the READ_STREAM_FULL result stands on its
own and is yours to use.

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.

1 participant