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

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.

@ankane

ankane commented Sep 23, 2026

Copy link
Copy Markdown
Member

Hi @Manuelreyesbravo, thanks for sharing. Will try out READ_STREAM_FULL and retest PrefetchBuffer for earlier Postgres versions.

@ankane

ankane commented Sep 23, 2026 •

Copy link
Copy Markdown
Member

Here's what I'm seeing on Postgres 19 with 1M 1536-dimension vectors, 100 queries, and a cold page cache:

approach io_method=worker io_method=io_uring
master 6.0 sec 6.3 sec
READ_STREAM_USE_BATCHING 3.3 sec 3.1 sec
READ_STREAM_FULL 2.1 sec 1.8 sec
prefetch 1.4 sec 1.4 sec

@Manuelreyesbravo

Copy link
Copy Markdown
Author

Thanks for taking the time to test this. First, a correction to my earlier comment: I said read_stream_resume() restores the saved distance, so the ramp-up happens once per stream. That was wrong, sorry for the confusion. The distance is only saved by read_stream_pause(); when the callback returns InvalidBlockNumber, read_stream.c sets it to 0 without saving it (the same in 19beta2), so every hop starts again from the initial distance: 16 with READ_STREAM_FULL, 1 without. That is also why READ_STREAM_FULL helped so much.

I checked it with a build that logs the distance: on one query (49 hops) the distance grew to 32-90 within each hop, and resume restored 16 on all 49. With read_stream_pause() it restored the grown value.

That is one of two limits behind the gap:

  1. The distance restarts at 16 on every hop, and a hop at layer 0 has ~28 unvisited neighbors, so the reads past the 16th start later.
  2. The stream never has more than effective_io_concurrency (16) reads in flight.

Fixing either one alone barely helps; fixing both closes most of the gap: return read_stream_pause(stream); instead of return InvalidBlockNumber; in HnswReadStreamNextBlock, plus effective_io_concurrency = 64.

PG 19beta4 (-O2), 500k random vectors of 1536 dimensions, default HNSW parameters, 100 queries after a cold start, io_uring, median of 9-14 runs (master 5; runs disturbed by other load on the machine dropped). First column on a local NVMe, second with 1 ms added to every read (dm-delay):

local NVMe +1 ms per read
master 6.21 s 69.8 s
READ_STREAM_USE_BATCHING 2.46 s 22.5 s
READ_STREAM_FULL 1.47 s 10.5 s
FULL + eic 64 1.47 s 10.5 s
FULL + pause 1.41 s 10.1 s
FULL + pause + eic 64 1.39 s 7.2 s
PrefetchBuffer (this PR) 1.19 s 6.8 s

On the fast disk the fix closes less of the gap, since the per-I/O cost of the stream matters more there. With io_method = worker the FULL stream took 2.18 s / 13.7 s; PrefetchBuffer took 1.24 s / 6.8 s, and on PG 18.6 6.8 s at +1 ms (master 68.4 s).

Separately, I tried also prefetching the unvisited neighbors of the next two candidates while the current hop's reads are in flight, so hops overlap: 1.06 s / 5.5 s, for 9% more reads, and the same on PG 18. It works with either approach, so I've kept it out of this PR; happy to open it separately if it seems worth it.

Caveats: random vectors, one machine, default m and ef_search. I can share the scripts if useful.

@ankane

ankane commented Sep 26, 2026

Copy link
Copy Markdown
Member

Thanks @Manuelreyesbravo, seeing slightly better performance with read_stream_pause. However, I still don't understand the gap between FULL + pause + eic 64 and prefetch.

If going the prefetch route, I think we'll need to limit by get_tablespace_io_concurrency / get_tablespace_maintenance_io_concurrency (as the comment in the hnsw-prefetch branch indicates), but with eic 64, that shouldn't be a factor.

@Manuelreyesbravo

Copy link
Copy Markdown
Author

Thanks, you're right that the limit matters, and it changes the picture. I added it the way the hnsw-prefetch comment suggests: at most get_tablespace_io_concurrency() prefetches outstanding per hop (the first eic up front, then one more for each neighbor read).

Same setup as before (500k x 1536, io_uring), a new run pinned to one CCX, so the numbers differ slightly from my previous table; local NVMe / +1 ms per read, median of 10-14 runs:

eic 16 eic 64
FULL + pause 1.38 s / 10.2 s 1.34 s / 7.2 s
prefetch, limited by eic 1.34 s / 10.3 s 1.19 s / 6.8 s
prefetch, not limited (as in this PR) 1.20 s / 6.8 s 1.20 s / 6.8 s

So at the default eic, the PR's advantage came from ignoring effective_io_concurrency; with the limit it is about even with the read stream + pause. At eic 64 the limit makes no difference, as you said.

On the remaining gap at eic 64: the backend uses about twice the system time with the read stream, and that part is io_uring handing reads to its worker threads. pgaio_uring_should_use_async() sets IOSQE_ASYNC on buffered reads once more than 4 IOs are in flight, and most of the ~28 reads in a hop hit that. In a build where it returns false, the stream's system time at +1 ms drops from 0.81 s to 0.50 s per run (prefetch: 0.41 s). But the time doesn't change (7.11 s in both builds, against 6.78 s for prefetch), so that isn't the cause, and I still can't explain the remaining ~5%.

One thing that does help the read stream: while the current hop's reads are in flight, prefetching the unvisited neighbors of the next two candidates, using only what is left of the eic budget after the hop's own reads. In a separate run at eic 64 and +1 ms (so slightly different numbers from the table), that took FULL + pause from 7.43 s to 6.53 s, for 8% more reads, below the eic-limited prefetch without it (6.84 s in that run). It changes nothing at eic 16, where the budget is used up, and nothing on the local NVMe.

Given that the two are about even at the default setting, and the read stream gets AIO and I/O accounting from core, I think the read stream with read_stream_pause is the better base, and I'm happy to close this PR if you agree. I can send the look-ahead separately on top of your branch, if it's of interest.

@ankane

ankane commented Sep 28, 2026

Copy link
Copy Markdown
Member

Thanks for the additional numbers. fwiw, I'm not seeing a difference between READ_STREAM_DEFAULT and READ_STREAM_FULL after applying the read_stream_pause fix.

Re "prefetching the unvisited neighbors of the next two candidates": the next two candidates may not be visited, which would cause unnecessary prefetches.

@Manuelreyesbravo

Copy link
Copy Markdown
Author

Thanks, that fits: with the pause fix the distance is kept between hops, so without FULL it only has to ramp up once per layer.

And yes, some look-ahead prefetches are wasted. The search reads the same blocks either way, so every extra read is one: in that run 5.2k extra over 67k (8%). It only spends what's left of the eic budget, but if the waste isn't worth it, I'd leave it out.

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.

2 participants