Subject: Re: index prefetching

From: Alexandre Felipe <[email-masked]>
Date: Sun, 15 Feb 2026 00:13:39 +0000

Hi,
I decided to test this PR.

I didn't take much time to go through the thread or the code in detail yet.
But I have my first benchmark results and I would like to share.

EXPERIMENT

I tested [CF 4351] v10 - Index Prefetching

I created a table with 100k rows and
Sequential is, as guessed, 1,2,3,4 (indexed)
Periodic is a quasi random (i * jump) % num_rows, where gcd(jump, num_rows)
= 1, guarantee that there are no repeated entries (indexed)
Random is a `row_number() over (order by random())` (indexed)
The payload is a fixed 200 character long string, just to make it more
realistic.

For the tests, I disable sorting, sequential scans, index only scans and
bitmap scans.
Since buffer cache always has a significant impact on the query
performance, I shuffled the tests, and tried to adjust for the number of
buffer hit/read, but later I found that the best way to control that was to
use a table small enough to be entirely held in cache, and evict the
buffers.

* off: buffers are kept in cache
* pg: buffers evicted from postgres pg_buffercache_evict from
pg_buffercache extension.
* os: supported only in  python, I separated the buffer eviction in
purge_cache as it requires sudo (tested only in MacOS).

I varied
 * max_parallel_workers_per_gather (although I guess it wasn't exploited),
 * enable_index_prefetch
 * the column used as sorting key and, as a result, the index used.
 * and buffer eviction mode.

Running from python with psycopg

SUMMARY

I could not see the expected positive impact and when using the python
script and buffers evicted prefetch had a detrimental impact.

APPENDIX

psql run
column_name io_method  num_workers evict  n  off_ms  on_ms  pct_change
 ci_low  ci_high
   periodic    worker            0   off 10   172.4  182.8    6.024162
 -3.7     15.7
   periodic    worker            0    pg 10   222.7  214.8   -3.539448
-13.0      5.9
   periodic    worker            2   off 10   173.5  172.7   -0.442660
 -1.6      0.7
   periodic    worker            2    pg 10   226.3  213.2   -5.795049
-16.9      5.3
     random    worker            0   off 10   173.3  174.1    0.476657
 -1.0      2.0
     random    worker            0    pg 10   216.6  218.1    0.704020
 -2.8      4.3
     random    worker            2   off 10   182.7  175.3   -4.031139
-15.6      7.5
     random    worker            2    pg 10   217.2  213.7   -1.586813
 -4.4      1.2
 sequential    worker            0   off 10   131.1  130.4   -0.568573
 -3.4      2.3
 sequential    worker            0    pg 10   150.7  188.2   24.923924
 22.2     27.6
 sequential    worker            2   off 10   129.7  130.2    0.421814
 -1.0      1.9
 sequential    worker            2    pg 10   151.0  184.1   21.925935
 19.5     24.3

psycopg run
column_name io_method  num_workers evict  n  off_ms  on_ms  pct_change
 ci_low  ci_high
   periodic    worker            0   off 10   186.7  193.7    3.701950
 -7.3     14.7
   periodic    worker            0    os 10   207.0  535.2  158.507266
153.7    163.3
   periodic    worker            0    pg 10   201.2  537.0  166.923995
161.5    172.3
   periodic    worker            2   off 10   181.2  189.0    4.303359
 -0.2      8.8
   periodic    worker            2    os 10   221.7  548.2  147.322934
131.8    162.9
   periodic    worker            2    pg 10   203.1  533.5  162.688738
160.4    165.0
     random    worker            0   off 10   194.6  186.8   -3.986974
-11.5      3.5
     random    worker            0    os 10   211.4  338.2   59.979252
 52.9     67.1
     random    worker            0    pg 10   212.3  336.8   58.686711
 50.2     67.1
     random    worker            2   off 10   183.3  187.7    2.364585
0.4      4.4
     random    worker            2    os 10   222.5  341.9   53.659704
 42.1     65.2
     random    worker            2    pg 10   204.1  333.3   63.348746
 62.3     64.4
 sequential    worker            0   off 10   129.9  129.5   -0.260586
 -1.9      1.3
 sequential    worker            0    os 10   150.9  182.4   20.909424
 16.6     25.2
 sequential    worker            0    pg 10   150.3  185.0   23.132583
 19.2     27.1
 sequential    worker            2   off 10   129.9  132.9    2.323179
 -4.3      9.0
 sequential    worker            2    os 10   153.8  189.8   23.402255
 13.9     32.9
 sequential    worker            2    pg 10   151.1  185.9   22.997500
 20.1     25.9

Regards,
Alexandre

On Mon, Feb 9, 2026 at 10:45 PM Peter Geoghegan <[email-masked]> wrote:

> On Fri, Jan 30, 2026 at 7:18 PM Peter Geoghegan <[email-masked]> wrote:
> > Attached is v9.
>
> Attached is v10. There are 2 major areas of improvement in this latest
> revision:
>
> 1. We have enhanced the read stream callback (heapam_getnext_stream,
> which is added by
> v10-0005-Add-prefetching-to-index-scans-using-batch-inter.patch),
> making it yield at key intervals. When we yield, we temporarily
> suspend prefetching -- but only for long enough to give the scan the
> opportunity to return one more matching tuple (don't confuse yielding
> with pausing; we do both, but the goals are rather different in each
> case).
>
> Yielding like this keeps the scan responsive during prefetching: the
> scan should never go too long without returning at least one tuple
> (except when that just isn't possible at all). Testing has shown that
> keeping the scan responsive in this sense is particularly important
> during scans that appear in "ORDER BY ... LIMIT N" queries, as well as
> during scans that feed into certain merge joins. IOW, it is
> particularly important that we "keep the scan responsive" whenever it
> has the potential to allow the scan to shut down before it has
> performed work that turns out to be unnecessary (though it also seems
> to have some benefits even when that isn't the case).
>
> There is a complex trade-off here: we want to yield when we expect
> some benefit from doing so. But we don't want to yield when doing so
> risks compromising the read stream's ability to maintain an adequate
> prefetch distance. There are comments in heapam_getnext_stream that
> describe the theory in more detail. There are heuristics that were
> derived using adversarial benchmarking/stress-testing. I'm sure that
> they need more work, but this does seem like roughly the right idea.
> Note that we now test whether the scan's read stream is using its
> fast-path mode (read stream uses this when the scan reads heap pages
> that are all cached).
>
> 2. A new patch
> (v10-0007-Limit-get_actual_variable_range-to-scan-one-inde.patch)
> compensates for the fact that the main prefetching commit removes
> get_actual_variable_range's VISITED_PAGES_LIMIT mechanism. Since
> VISITED_PAGES_LIMIT cannot easily be ported over to the new table AM
> interface selfuncs.c now uses.
>
> I described the problem that we need to address when I posted v9:
>
> > selfuncs.c problem
> > ------------------
> >
> > Also worth noting that we recently found a problem with selfuncs.c:
> > the VISITED_PAGES_LIMIT stuff in selfuncs.c is broken right now. v9
> > tears that code out, pending adding back a real fix (earlier versions
> > of the patch had VISITED_PAGES_LIMIT, but it didn't work).
> >
> > The underlying problem is that the existing VISITED_PAGES_LIMIT design
> > is incompatible with our new table_index_getnext_slot interface. The
> > new interface doesn't stop scanning until it is able to at least
> > return 1 tuple. But VISITED_PAGES_LIMIT was invented precisely because
> > get_actual_variable_endpoint's index scans took far too long, even
> > though they're only ever required to locate 1 tuple. So that just
> > won't work.
> >
> > We'll need to invent some kind of API that directly acknowledges the
> > needs of the selfuncs.c caller, and others like it. Doing it in an
> > ad-hoc way just doesn't seem possible anymore. That will have to wait
> > for the next revision, though (or the one after that).
>
> The new patch deals with the problem in a completely different way,
> and at a completely different layer: it adds a new
> IndexScanDescData.xs_read_extremal_only field, set only by
> get_actual_variable_range. When nbtree sees that the field has been
> set, it gives up after scanning only one leaf page (the page that
> contains extremal values that are of interest to
> get_actual_variable_range). Note that we completely stop caring about
> heap page fetches with this new approach.
>
> There are good reasons to believe that the new
> IndexScanDescData.xs_read_extremal_only approach will solve existing
> problems with VISITED_PAGES_LIMIT. Recent benchmarking from Mark
> Callaghan [1] (which I've independently recreated with my own minimal
> test suite) shows that VISITED_PAGES_LIMIT becomes completely
> ineffective, once we reach a tipping point where many index tuples at
> one end of the index all have their LP_DEAD bit set.
>
> I'm going to start a new thread to discuss the issues in this area
> later today. I'm aiming to fix an existing, independent issue in this
> new patch, so it makes sense to discuss it on a completely separate
> thread.
>
> [1]
> https://smalldatum.blogspot.com/2026/01/cpu-bound-insert-benchmark-vs-postgres.html
> --
> Peter Geoghegan
>

---

From: Peter Geoghegan <[email-masked]>
Date: Sat, 14 Feb 2026 21:04:22 -0500

On Sat, Feb 14, 2026 at 7:13 PM Alexandre Felipe
<[email-masked]> wrote:
> For the tests, I disable sorting, sequential scans, index only scans and bitmap scans.
> Since buffer cache always has a significant impact on the query performance, I shuffled the tests, and tried to adjust for the number of buffer hit/read, but later I found that the best way to control that was to use a table small enough to be entirely held in cache, and evict the buffers.

How did you account for the OS filesystem cache? It looks like you
didn't, based on this run_benchmarks.sh code:

> drop_caches() {
>    echo "Skip dropping OS caches (requires sudo)..."
> }

I ran your benchmark test_prefetch_regressions.sql, using "psql -f
test_prefetch_regressions.sql". Against my working copy of the patch,
this procedure gave me these results:

┌─────────────┬───────────┬─────────────┬────────────┬────┬────────┬───────┬────────┐
│ column_name │ io_method │ num_workers │ evict_mode │ n  │ OFF_ms │
ON_ms │ effect │
├─────────────┼───────────┼─────────────┼────────────┼────┼────────┼───────┼────────┤
│ periodic    │ io_uring  │           0 │ off        │ 20 │  76.59 │
77.11 │  0.003 │
│ periodic    │ io_uring  │           0 │ pg         │ 20 │  87.22 │
89.77 │  0.014 │
│ periodic    │ io_uring  │           2 │ off        │ 20 │  76.46 │
77.94 │  0.010 │
│ periodic    │ io_uring  │           2 │ pg         │ 20 │  87.42 │
89.64 │  0.013 │
│ random      │ io_uring  │           0 │ off        │ 20 │  77.76 │
78.32 │  0.004 │
│ random      │ io_uring  │           0 │ pg         │ 20 │  87.66 │
91.52 │  0.022 │
│ random      │ io_uring  │           2 │ off        │ 20 │  77.59 │
78.57 │  0.006 │
│ random      │ io_uring  │           2 │ pg         │ 20 │  87.85 │
91.19 │  0.019 │
│ sequential  │ io_uring  │           0 │ off        │ 20 │  64.18 │
64.64 │  0.004 │
│ sequential  │ io_uring  │           0 │ pg         │ 20 │  73.04 │
69.24 │ -0.027 │
│ sequential  │ io_uring  │           2 │ off        │ 20 │  64.16 │
64.36 │  0.002 │
│ sequential  │ io_uring  │           2 │ pg         │ 20 │  73.05 │
69.16 │ -0.027 │
└─────────────┴───────────┴─────────────┴────────────┴────┴────────┴───────┴────────┘
(12 rows)

The "effect" shown on my Linux workstation just looks like random
noise (I also tested "worker", with similar results). iostat seems to
show just about no I/O on my system when psql runs. I certainly didn't
do anything to drop the OS filesystem cache here, so this isn't
surprising.

How should I go about recreating your result? This was my best guess
at how to do so. But it doesn't feel like a good guess.

> Running from python with psycopg

Are the numbers you showed comparing the patch to the master branch?
Or is it just comparing enable_indexscan_prefetch=on to
enable_indexscan_prefetch=off with the patch?

Did you write all this test code yourself?

> I could not see the expected positive impact and when using the python script and buffers evicted prefetch had a detrimental impact.

What expected benefit?

The results you've shown put the patch in a very negative light -- at
least if taken at face value. There are a few small single digit
percentage improvements, but no positive result that is
distinguishable from noise. There are several queries that are more
than 2x slower.

It's very hard to believe that what you've shown could be in any way
representative of the patch -- it's completely at odds with my
experience testing the patch. I've spent weeks and weeks looking for
regressions in the patch, often using adversarial, randomized query
generation. But I haven't seen anything near a 2x slowdown, for any
query, with any possible config.

There's no point in speculating what might have happened here until I
can reproduce your results, so I'll refrain from that.

--
Peter Geoghegan

---

From: Tomas Vondra <[email-masked]>
Date: Sun, 15 Feb 2026 08:57:19 +0100

Hi,

On 2/15/26 01:13, Alexandre Felipe wrote:
> I didn't take much time to go through the thread or the code in detail
> yet. But I have my first benchmark results and I would like to share.
> 

I'm quite confused by the scripts you shared, it seems incomplete. The
run_regression.py is meant to call purge_cache.sh (which is missing),
and the run_benchmark tries to call all sorts of missing .sql scripts.

So how do we use that?

> Periodic is a quasi random (i * jump) % num_rows, where gcd(jump,
> num_rows) = 1, guarantee that there are no repeated entries (indexed)
> buffer hit/read, but later I found that the best way to control that was
> to use a table small enough to be entirely held in cache, and evict the
> buffers.
> 

That seems a bit bizarre. The whole point of index prefetching is better
I/O scheduling (ahead of time), but if you "control" the impact of cache
by making sure everything is cached, that kinda defeats the whole thing.

A table that is just 24MB and fits into buffers is a bit useless. It
means that even with random pattern (which is generally about the best
for prefetching), only about ~1/30 of pages will require I/O. Each page
has ~32 items, but only the first item from each page will incur an I/O.

> Running from python with psycopg

On what kind of hardware? How much variance is in the results?

regards

-- 
Tomas Vondra

---

From: Alexandre Felipe <[email-masked]>
Date: Sun, 15 Feb 2026 08:00:23 +0000

Wow, quick response.

OS eviction is only implemented in the python version. But the most
intriguing part is that the python version also shows a degradation by
simply evicting with pg_buffeercache_evict. I checked many times, first I
thought that my differences had a sign inversion. Then I thought it could
be something about the python speed, could it be? I am timing with explain
analyse, if I was fetching the rows this would be the obvious culprit.

> didn't, based on this run_benchmarks.sh code:

Sorry I attached the wrong file. I didn't use this run_benchmark.sh.

> at how to do so. But it doesn't feel like a good guess.

Could you try running the python script or should I provide it in a
different way?

> enable_indexscan_prefetch=off with the patch?

Just changing the parameter.

> Did you write all this test code yourself?

In the sense of typing it no, I used cursor, so I just edited what I could
see it didn't get right.

> least if taken at face value.

Please notice that I tried to be neutral in the narrative.

Maybe because I tested on a MacOS maybe it works differently from the type
of
"modern storage" mentioned at the start of the thread.

> can reproduce your results
>

I totally agree, that is why my first step was to try to reproduce your
results independently.

I see another message, checking that.

--
Alexandre

---

From: Alexandre Felipe <[email-masked]>
Date: Sun, 15 Feb 2026 08:08:34 +0000

Another quick response, I am impressed

> and the run_benchmark tries to call all sorts of missing .sql scripts.

That was a confusion I did, I uploaded run_benchmark that was just a
nonsensical AI generated script in my work area and I didn't upload
purge_cache.sh. Notice that you can still run the python script with evict
mode off,pg. The missing script is attached in my previous message.

> has ~32 items, but only the first item from each page will incur an I/O.

OK, I will start it with different parameters, for development I needed
something that was not too slow, to be able to catch the bugs.

Is a Mac mini M1. Please check the .PNG it shows the confidence intervals
of the difference of execution time with the parameter ON and OFF, with
different settings.

--
Alexandre

---

From: Andres Freund <[email-masked]>
Date: Sun, 15 Feb 2026 14:34:07 -0500

Hi,

> use a table small enough to be entirely held in cache, and evict the

I doubt that shuffling the tests will provide meaningful uncached performance
in a test like this - your data size is tiny, so there's simply not a whole
lot of data, i.e. it all stays cached.  Which is also what is going to limit
the amount of benefit from prefetching you're going to get.

Evicting from the postgres buffercache won't do much, because you're not going
to evict it from the kernel cache, so there's no actual IO that prefetching
can avoid.

I think generally your queries are not particularly IO bound, due to the small
size.  I've adapted your data creation script to create a table with the
number of rows suffixed and created it for 100k and 1M rows.

100k rows:

prefetching=0:

 Index Scan using idx_periodic_100000 on prefetch_test_data_100000  (cost=0.29..15351.09 rows=100000 width=8) (actual time=0.026..201.730 rows=100000.00 loops>
   Index Searches: 1
   Buffers: shared hit=96875 read=3400
   I/O Timings: shared read=32.727
 Planning:
   Buffers: shared hit=81 read=23
   I/O Timings: shared read=1.521
 Planning Time: 1.851 ms
 Execution Time: 207.031 ms

prefetching=1:

 Index Scan using idx_periodic_100000 on prefetch_test_data_100000  (cost=0.29..15351.09 rows=100000 width=8) (actual time=0.209..193.508 rows=100000.00 loops=1)
   Index Searches: 1
   Buffers: shared hit=96875 read=3400
   I/O Timings: shared read=29.830
 Planning:
   Buffers: shared hit=81 read=23
   I/O Timings: shared read=1.962
 Planning Time: 2.299 ms
 Execution Time: 198.809 ms
(9 rows)

Here about 15.8% of the time is spent waiting for IO, improved by prefetching
only to 14.4%.

1M rows:

prefetching=0

 Index Scan using idx_periodic_1000000 on prefetch_test_data_1000000  (cost=0.42..153478.15 rows=1000000 width=8) (actual time=0.126..5172.605 rows=1000000.00 loops=1)
   Index Searches: 1
   Buffers: shared hit=971485 read=31250
   I/O Timings: shared read=3371.993
 Planning:
   Buffers: shared hit=96 read=7
   I/O Timings: shared read=0.591
 Planning Time: 0.946 ms
 Execution Time: 5225.337 ms

prefetching=1:

 Index Scan using idx_periodic_1000000 on prefetch_test_data_1000000  (cost=0.42..153478.15 rows=1000000 width=8) (actual time=0.126..2106.244 rows=1000000.00 loops=1)
   Index Searches: 1
   Buffers: shared hit=971485 read=31250
   I/O Timings: shared read=94.310
 Planning:
   Buffers: shared hit=96 read=7
   I/O Timings: shared read=0.585
 Planning Time: 0.938 ms
 Execution Time: 2157.727 ms

Here about 64.5% of the time is spent waiting for IO, improved by prefetching
to 4.3%.

I think one reason the smaller data performs so badly is that read stream
ramps down the distance too quickly, due to the high cache hit ratio, at least
in the periodic case.  If I disable the stream->distance-- in read_stream.c,
the results, particularly with debug_io_direct=data, are vastly better ([1]).  It's
easier to see with DIO:

debug_io_direct=data, enable_indexscan_prefetch=0
┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│                                                                            QUERY PLAN                                                                            │
├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Index Scan using idx_periodic_100000 on prefetch_test_data_100000  (cost=0.29..15351.09 rows=100000 width=8) (actual time=0.311..643.697 rows=100000.00 loops=1) │
│   Index Searches: 1                                                                                                                                              │
│   Buffers: shared hit=97150 read=3125                                                                                                                            │
│   I/O Timings: shared read=479.851                                                                                                                               │
│ Planning Time: 0.066 ms                                                                                                                                          │
│ Execution Time: 648.820 ms                                                                                                                                       │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
(6 rows)

debug_io_direct=data, enable_indexscan_prefetch=1, w/ distance--
┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│                                                                            QUERY PLAN                                                                            │
├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Index Scan using idx_periodic_100000 on prefetch_test_data_100000  (cost=0.29..15351.09 rows=100000 width=8) (actual time=0.570..676.908 rows=100000.00 loops=1) │
│   Index Searches: 1                                                                                                                                              │
│   Prefetch: distance=2.000 count=6008 stalls=93627 skipped=0 resets=0 pauses=0 ungets=0 forwarded=0                                                              │
│             histogram [2,4) => 6008                                                                                                                              │
│   Buffers: shared hit=97150 read=3125                                                                                                                            │
│   I/O Timings: shared read=507.223                                                                                                                               │
│ Planning Time: 0.056 ms                                                                                                                                          │
│ Execution Time: 682.205 ms                                                                                                                                       │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

debug_io_direct=data, enable_indexscan_prefetch=1, w/o distance--
┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│                                                                            QUERY PLAN                                                                            │
├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Index Scan using idx_periodic_100000 on prefetch_test_data_100000  (cost=0.29..15351.09 rows=100000 width=8) (actual time=0.304..187.170 rows=100000.00 loops=1) │
│   Index Searches: 1                                                                                                                                              │
│   Prefetch: distance=750.741 count=99721 stalls=4 skipped=0 resets=0 pauses=90 ungets=0 forwarded=0                                                              │
│             histogram [2,4) => 3, [4,8) => 4, [8,16) => 6, [16,32) => 10, [32,64) => 25, [64,128) => 33, [128,256) => 65, [256,512) => 130, [512,1024) => 99445  │
│   Buffers: shared hit=97150 read=3125                                                                                                                            │
│   I/O Timings: shared read=25.086                                                                                                                                │
│ Planning Time: 0.062 ms                                                                                                                                          │
│ Execution Time: 192.363 ms                                                                                                                                       │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

If the distance ends up capped at 2, it's not too surprising that readahead
doesn't work out.

FWIW, all that's seemingly needed to actually have mostly sufficient distance
is to change the distance increase from
  distance = stream->distance * 2;
to
  distance = stream->distance * 2 + 1;

The + 1 has a big effect because when the distance is 1, each hit reduces the
distance by as much as each miss does. Just addressing that improves the
results dramatically:

debug_io_direct=data, enable_indexscan_prefetch=1, w/ stream->distance * 2 + 1
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│                                                                             QUERY PLAN                                                                              │
├─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Index Scan using idx_periodic_100000 on prefetch_test_data_100000  (cost=0.29..15351.09 rows=100000 width=8) (actual time=0.316..176.703 rows=100000.00 loops=1)    │
│   Index Searches: 1                                                                                                                                                 │
│   Prefetch: distance=707.476 count=11158 stalls=88503 skipped=0 resets=0 pauses=26 ungets=0 forwarded=0                                                             │
│             histogram [2,4) => 5, [4,8) => 11, [8,16) => 26, [16,32) => 30, [32,64) => 54, [64,128) => 109, [128,256) => 221, [256,512) => 428, [512,1024) => 10274 │
│   Buffers: shared hit=96875 read=3400                                                                                                                               │
│   I/O Timings: shared read=33.874                                                                                                                                   │
│ Planning:                                                                                                                                                           │
│   Buffers: shared hit=78 read=21                                                                                                                                    │
│   I/O Timings: shared read=2.772                                                                                                                                    │
│ Planning Time: 3.065 ms                                                                                                                                             │
│ Execution Time: 182.959 ms                                                                                                                                          │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

The stall stats are bogus, because they get increased even when we correctly
are not prefetching due to everything being in shared buffers. I think the
  if (distance == 1) stats.nstalls++
would need to be just before the WaitReadBuffers().

Interestingly the stream->distance * 2 + 1 performs considerably better than
never decreasing the distance for the periodic case, which I guess makes
sense, because once the data is in s_b, it is purely cost to have a high
distance (for now, eventually I hope we can do batched buffer lookups to have
a small gain even in that case).

Greetings,

Andres Freund

[1] That's of course not an actual proposal, just a test

---

From: Andres Freund <[email-masked]>
Date: Sun, 15 Feb 2026 15:59:09 -0500

Hi,

> debug_io_direct=data, enable_indexscan_prefetch=1, w/ stream->distance * 2 + 1

The histogram and distance are also somewhat misleading: They measure what the
distance is at the time the next block is determined, but that's not really
informative, as the distance can be much bigger than what we are actually
doing IO wise (to allow for IO combining etc).  The limit for the number of
in-flight IOs will be the limiting factor in a case with random-ish IOs and
it's also really what matters for performance.

For a query with lots of random prefetching, run with
effective_io_concurrency=8, compare:

stats using stream->distance:

 Limit  (cost=0.43..150968.68 rows=1000000 width=8) (actual time=0.138..5425.851 rows=1000000.00 loops=1)
   Buffers: shared hit=701118 read=301757
   I/O Timings: shared read=3919.597
   ->  Index Scan using idx_random_10000000 on prefetch_test_data_10000000  (cost=0.43..1509682.87 rows=10000000 width=8) (actual time=0.137..5331.243 rows=1000000.00 loops=1)
         Index Searches: 1
         Prefetch: distance=143.999 count=1297237 stalls=6 skipped=3 resets=0 pauses=0 ungets=297462 forwarded=2
                   histogram [16,32) => 9, [32,64) => 2, [64,128) => 2, [128,256) => 1297224
         Buffers: shared hit=701118 read=301757
         I/O Timings: shared read=3919.597
 Planning:
   Buffers: shared hit=96 read=7
   I/O Timings: shared read=0.812
 Planning Time: 1.310 ms
 Execution Time: 5496.622 ms

stats using stream->ios_in_progress:

 Limit  (cost=0.43..150968.68 rows=1000000 width=8) (actual time=0.157..5427.138 rows=1000000.00 loops=1)
   Buffers: shared hit=701118 read=301757
   I/O Timings: shared read=3919.857
   ->  Index Scan using idx_random_10000000 on prefetch_test_data_10000000  (cost=0.43..1509682.87 rows=10000000 width=8) (actual time=0.156..5332.607 rows=1000000.00 loops=1)
         Index Searches: 1
         Prefetch: distance=6.864 count=1297237 stalls=6 skipped=3 resets=0 pauses=0 ungets=297462 forwarded=106
                   histogram [1,2) => 1164, [2,4) => 12634, [4,8) => 1283335
         Buffers: shared hit=701118 read=301757
         I/O Timings: shared read=3919.857
 Planning:
   Buffers: shared hit=96 read=7
   I/O Timings: shared read=0.809
 Planning Time: 1.305 ms
 Execution Time: 5502.332 ms

The former sure makes it look like that the bottleneck isn't the number of IOs
in flight, after all the distance is quite deep.  Yet, if you increase
effective_io_concurrency:

stats using stream->distance:

 Limit  (cost=0.43..150968.68 rows=1000000 width=8) (actual time=0.138..2769.847 rows=1000000.00 loops=1)
   Buffers: shared hit=701960 read=301786
   I/O Timings: shared read=1100.819
   ->  Index Scan using idx_random_10000000 on prefetch_test_data_10000000  (cost=0.43..1509682.87 rows=10000000 width=8) (actual time=0.137..2667.780 rows=1000000.00 loops=1)
         Index Searches: 1
         Prefetch: distance=1038.301 count=1293137 stalls=0 skipped=3 resets=0 pauses=694 ungets=291801 forwarded=2
                   histogram [16,32) => 16, [32,64) => 18, [64,128) => 33, [128,256) => 2, [256,512) => 3, [512,1024) => 125045, [1024,2048) => 1168020
         Buffers: shared hit=701960 read=301786
         I/O Timings: shared read=1100.819
 Planning:
   Buffers: shared hit=96 read=7
   I/O Timings: shared read=0.819
 Planning Time: 1.346 ms
 Execution Time: 2845.891 ms
(14 rows)

stats using stream->ios_in_progress:

 Limit  (cost=0.43..150968.68 rows=1000000 width=8) (actual time=0.136..2767.839 rows=1000000.00 loops=1)
   Buffers: shared hit=701960 read=301786
   I/O Timings: shared read=1102.115
   ->  Index Scan using idx_random_10000000 on prefetch_test_data_10000000  (cost=0.43..1509682.87 rows=10000000 width=8) (actual time=0.135..2665.838 rows=1000000.00 loops=1)
         Index Searches: 1
         Prefetch: distance=61.054 count=1293134 stalls=0 skipped=3 resets=0 pauses=694 ungets=291798 forwarded=4
                   histogram [1,2) => 1, [2,4) => 2, [4,8) => 4, [8,16) => 9, [16,32) => 8737, [32,64) => 1284379
         Buffers: shared hit=701960 read=301786
         I/O Timings: shared read=1102.115
 Planning:
   Buffers: shared hit=96 read=7
   I/O Timings: shared read=0.819
 Planning Time: 1.334 ms
 Execution Time: 2846.017 ms

If you want to measure in the domain of blocks, rather than, I'd suggest at
least using the number of currently pinned buffers, rather than the
distance. But I think IOs is a much more useful measure.

E.g. for prefetching a index scan on a sequential column, with:

io_effective_io_concurrency=128 io_combine_limit=16

stats using stream->distance:
   Prefetch: distance=18.872 count=320174 stalls=30736 skipped=9687128 resets=0 pauses=7684 ungets=0 forwarded=0
             histogram [1,2) => 3416, [2,4) => 23052, [4,8) => 53788, [8,16) => 115244, [16,32) => 124654, [32,64) => 20
   Buffers: shared hit=27325 read=312500
   I/O Timings: shared read=1794.060

stats using stream->pinned_buffers:
   Prefetch: distance=8.259 count=320174 stalls=30736 skipped=9687128 resets=0 pauses=7684 ungets=0 forwarded=29897
             histogram [1,2) => 23052, [2,4) => 58070, [4,8) => 84497, [8,16) => 105873, [16,32) => 18785
   Buffers: shared hit=27325 read=312500
   I/O Timings: shared read=887.850

stats using stream->ios_in_progress:
   Prefetch: distance=0.059 count=320174 stalls=30736 skipped=9687128 resets=0 pauses=7684 ungets=0 forwarded=301389
             histogram [1,2) => 18785
   Buffers: shared hit=27325 read=312500
   I/O Timings: shared read=881.723

Only the ios-in_progress one really shows that we are basically not doing
any readahead, due to heapam pausing, thinking we're far enough ahead - but
due to IO combining the batchdistance limit is reached before we can issue
multiple IOs.

io_effective_io_concurrency=128 io_combine_limit=4:

stats using stream->ios_in_progress:
   Prefetch: distance=4.200 count=328882 stalls=1 skipped=9687128 resets=0 pauses=16391 ungets=0 forwarded=9
             histogram [1,2) => 8, [2,4) => 129775, [4,8) => 199090
   Buffers: shared hit=27325 read=312500
   I/O Timings: shared read=300.284

FWIW, if I change the batchdistance <= 2 check to <= 8, I get good perf even
with io_combine_limit=16:

stats using stream->ios_in_progress:
   Prefetch: distance=2.605 count=315526 stalls=3 skipped=9687128 resets=0 pauses=3035 ungets=0 forwarded=50
             histogram [1,2) => 72679, [2,4) => 170115, [4,8) => 72682
   Buffers: shared hit=27325 read=312500
   I/O Timings: shared read=125.902

but that was just an experiment.

Greetings,

Andres Freund

---

From: Tomas Vondra <[email-masked]>
Date: Sun, 15 Feb 2026 22:17:05 +0100

On 2/15/26 09:08, Alexandre Felipe wrote:
> purge_cache.sh. Notice that you can still run the python script with
> evict mode off,pg. The missing script is attached in my previous message. 
>  

The purge_cache.sh script does not use sudo anywhere, so on Linux it has
no effect at all. No idea if "purge" works on MacOS like this.

> Is a Mac mini M1. Please check the .PNG it shows the confidence
> intervals of the difference of execution time with the parameter ON and
> OFF, with different settings.
> 

I don't have access to a M1 machine (and it also does not say what type
of storage is it using, which seems pretty important for a patch aiming
to improve I/O behavior). But I tried running this on my ryzen machine
with local SSDs (in RAID0), and with the 100k rows (and fixed handling
of page cache) I get this:

  column_name io_method  evict  n  master_ms  off_ms  on_ms  effect_pct
     periodic    worker    off 10       35.8    35.1   36.5         2.0
     periodic    worker     os 10       49.4    49.9   58.8         8.1
     periodic    worker     pg 10       39.5    39.9   47.1         8.3
       random    worker    off 10       35.9    35.6   35.7         0.2
       random    worker     os 10       49.0    49.0   42.6        -7.0
       random    worker     pg 10       39.6    39.9   40.9         1.2
   sequential    worker    off 10       28.2    27.9   27.7        -0.4
   sequential    worker     os 10       39.3    39.2   34.8        -6.0
   sequential    worker     pg 10       30.1    30.1   29.4        -1.3

  column_name io_method  evict  n  master_ms  off_ms  on_ms  effect_pct
     periodic  io_uring    off 10       35.9    35.8   35.8        -0.1
     periodic  io_uring     os 10       49.3    49.9   50.0         0.1
     periodic  io_uring     pg 10       40.1    39.8   41.7         2.4
       random  io_uring    off 10       35.6    35.2   35.7         0.8
       random  io_uring     os 10       49.1    48.9   46.1        -3.0
       random  io_uring     pg 10       39.8    40.1   42.6         3.1
   sequential  io_uring    off 10       28.0    27.8   28.0         0.4
   sequential  io_uring     os 10       39.8    39.1   40.7         1.9
   sequential  io_uring     pg 10       30.2    30.0   29.6        -0.8

This is on default config with io_workers=12 and data_checksums=off. I'm
not showing results for parallel query, because it's irrelevant.

This also has timings for master, for worker and io_uring (which you
could not get on M1, at least no in MacOS). For "worker" the differences
are much smaller (within 10% in the worst case), and almost non-existent
for io_uring. Which suggests this is likely due to the "signal" overhead
associated with worker, which can be annoying for certain data patterns
(where we end up issuing an I/O for individual blocks at distance 1).

That is not something the index prefetching can "fix", this affects all
places using the read stream facility (it may be true index scans are
more likely to generate such "poor" data patterns). The above is about
the best we can do right now. In the future we might tweak the read
stream heuristics, or invent a special mode for index scans, not sure.

As already suggested, the whole point of index prefetching is improving
cases where we have to do a fair amount of I/O. And the 100k rows is far
too small for that - 24MB can get easily cached in various places
(shared buffers, kernel, the drive itself, ...). The best we can hope
for in this case is not causing regressions. Which I'd say we're mostly
achieving, per the results I just shared.

But let's look at a larger data set, with 1M rows and fillfactor=10.
That inflates the table to 2.6GB, which is a bit more sensible.

  column_name io_method  evict  n  master_ms  off_ms  on_ms    pct
     periodic    worker    off 10      737.5   747.3  766.3    1.3
     periodic    worker     os 10     2949.1  3000.1 1148.1  -44.6
     periodic    worker     pg 10      745.3   740.1  757.0    1.1
       random    worker    off 10     1491.4  1503.2 1103.8  -15.3
       random    worker     os 10    11236.7 12100.0 2363.9  -67.3
       random    worker     pg 10     1523.0  1489.6 1122.2  -14.1
   sequential    worker    off 10      627.6   629.9  377.8  -25.0
   sequential    worker     os 10      963.4   975.0  588.6  -24.7
   sequential    worker     pg 10      630.8   631.4  372.2  -25.8

  column_name io_method  evict  n  master_ms  off_ms  on_ms    pct
     periodic  io_uring    off 10      752.3   753.1  858.3    6.5
     periodic  io_uring     os 10     2956.4  2800.6 1210.0  -39.7
     periodic  io_uring     pg 10      738.5   750.0  858.3    6.7
       random  io_uring    off 10     1502.2  1506.3 1691.6    5.8
       random  io_uring     os 10     1905.9 11159.4 2756.7  -60.4
       random  io_uring     pg 10     1535.6  1512.0 1680.4    5.3
   sequential  io_uring    off 10      628.5   630.6  584.4   -3.8
   sequential  io_uring     os 10      993.1   962.2  937.6   -1.3
   sequential  io_uring     pg 10      625.7   634.6  587.1   -3.9

Clearly, significant benefits, but also some interesting differences in
behavior between worker/io_uring. I haven't looked into the details, but
my guess would be it's due to the different "parallelism" of the two
methods (io_uring is confined to the single process, worker can spread
the work over multiple processes). It does not surprise me, and there is
no single "best" I/O method. And never will be.

Another thing to consider is that 2.6GB table is much larger than shared
buffers, which means it'll never be "fully cached" in PG.

regards

-- 
Tomas Vondra

---

From: Tomas Vondra <[email-masked]>
Date: Sun, 15 Feb 2026 22:35:35 +0100

> it's also really what matters for performance.

This EXPLAIN part was hacked together as something to help us during
development, and a lot of the information is wonky and not well defined.
Which is why we chose not to include it in the patches posted to
hackers, so I'm a bit confused which patch / branch you're looking at.

For stalls you're probably right. I'll think about it.

I'm not sure about the distance shown. What do you mean by "the distance
can be much bigger than what we are actually doing IO wise"? IIRC in
that particular case we needed to know how far ahead is the "prefetch
position" (I mean, how many index entries are in between). We might have
*skipped* many of those for various reasons - duplicate blocks,
all-visible pages in index-only scans, pages already in shared buffers,
and so on. And I think that's still an interesting information, because
it tells us how many "batches ahead" we need.

Maybe we need to track multiple "distances"? I'm a bit worried about
overwhelming people, though.

> but that was just an experiment.

I'll take a close look tomorrow, but AFAICS we really aim to measure two
rather different things. I've been interested in "how far ahead are we
looking" and you're more interested in the number of I/Os initiated by
the stream. Which both seem interesting and necessary to understand
what's going on.

regards

-- 
Tomas Vondra

---

From: Andres Freund <[email-masked]>
Date: Sun, 15 Feb 2026 17:13:50 -0500

Hi,

> hackers, so I'm a bit confused which patch / branch you're looking at.

I was looking at Peter's git tree and unreverted the stats, after seeing some
odd performance.  If found the stats quite valuable, without them it'd have
been quite hard to figure out why right now lager sequential or periodic
tables (from Alexandre's workload) have very subpar performance.

> For stalls you're probably right. I'll think about it.

Thx.

> can be much bigger than what we are actually doing IO wise"?

stream->distance is just a cap of how far we *may* look ahead, not how far we
are currently looking ahead.

E.g. if you have a stream that full tilt blazes ahead with 1 block random IOs,
none of then in s_b, you'll soon have a distance that's large, as it gets
doubled for every miss until hitting the cap (of io_combine_limit *
effective_io_concurrency, capped by the buffer pin limit).  But because you're
doing random IO, you're just effective_io_concurrency IOs, not
effective_io_concurrency * io_combine_limit.

This gets even more extreme if you yield often, because that will lead to the
distance staying relatively high, while preventing actually issuing much
concurrent IO.

> IIRC in that particular case we needed to know how far ahead is the
> "prefetch position" (I mean, how many index entries are in between).

Right - but that's not what looking at ->distance tells you :).  I think you
could use ->pinned_buffers for it, if you want to look at the number of
blocks, not the number of IOs.

> the stream. Which both seem interesting and necessary to understand

When do you care about the distance purely in blocks, rather than IOs? If you
can't actually have IO concurrency, due to io combining and yielding / low pin
limits never actually allowing multiple IOs, you'll have no gain from AIO.

Greetings,

Andres Freund

---

From: Andres Freund <[email-masked]>
Date: Sun, 15 Feb 2026 17:39:21 -0500

Hi,

> (where we end up issuing an I/O for individual blocks at distance 1).

I don't think this is just the signalling issue. For "periodic" I think it's
the signalling issue triggered by the read stream distance being kept too
low. Due to the small distance, the latency affects us much more.

Any my system, with turbo boost etc disabled.

worker w/ enable_indexscan_prefetch=0:

 Index Scan using idx_periodic_100000 on prefetch_test_data_100000  (cost=0.29..15101.09 rows=100000 width=208) (actual time=0.157..84.129 rows=100000.00 loops=1)
   Index Searches: 1
   Buffers: shared hit=97150 read=3125
   I/O Timings: shared read=31.274
 Planning:
   Buffers: shared hit=97 read=7
   I/O Timings: shared read=0.595
 Planning Time: 0.944 ms
 Execution Time: 89.319 ms

worker w/ enable_indexscan_prefetch=1:

 Index Scan using idx_periodic_100000 on prefetch_test_data_100000  (cost=0.29..15101.09 rows=100000 width=208) (actual time=0.158..115.279 rows=100000.00 loops=1)
   Index Searches: 1
   Prefetch: distance=1.060 count=99635 stalls=3004 skipped=0 resets=0 pauses=0 ungets=0 forwarded=0
             histogram [1,2) => 93627, [2,4) => 6008
   Buffers: shared hit=97150 read=3125
   I/O Timings: shared read=56.077
 Planning:
   Buffers: shared hit=97 read=7
   I/O Timings: shared read=0.612
 Planning Time: 0.994 ms
 Execution Time: 120.575 ms

Right, a regression. But note how low the distance is - no wonder the worker
latency has a bad effect - we only have the downside, never the upside, as
there's pretty much no IO concurrency.

After applying this diff:

@@ -1006,7 +1038,9 @@ read_stream_next_buffer(ReadStream *stream, void **per_buffer_data)
             stream->oldest_io_index = 0;
 
         /* Look-ahead distance ramps up rapidly after we do I/O. */
-        distance = stream->distance * 2;
+        distance = stream->distance * 2
+            + 1
+            ;
         distance = Min(distance, stream->max_pinned_buffers);
         stream->distance = distance;

worker w/ enable_indexscan_prefetch=1 + patch:

 Index Scan using idx_periodic_100000 on prefetch_test_data_100000  (cost=0.29..15101.09 rows=100000 width=208) (actual time=0.157..82.673 rows=100000.00 loops=1)
   Index Searches: 1
   Prefetch: distance=70.892 count=103109 stalls=5 skipped=0 resets=0 pauses=0 ungets=3474 forwarded=0
             histogram [1,2) => 88975, [2,4) => 5, [4,8) => 11, [8,16) => 26, [16,32) => 28, [32,64) => 64, [64,128) => 104, [128,256) => 136, [256,512) => 602, [512,1024) => 13158
   Buffers: shared hit=97150 read=3125
   I/O Timings: shared read=19.711
 Planning:
   Buffers: shared hit=97 read=7
   I/O Timings: shared read=0.596
 Planning Time: 0.951 ms
 Execution Time: 87.887 ms

By no means a huge win compared to prefetching being disabled, but the
regression does vanish.

The problem that this fixes is that the periodic workload has cache hits
frequently, which reduce the stream->distance by 1. Then, on a miss, we double
the distance.  But that means that if you have the trivial pattern of one hit
and one miss, which this workload very often has, you *never* get above
1. I.e. we increase the distance as quickly as we decrease it.

Greetings,

Andres Freund

---

From: Alexandre Felipe <[email-masked]>
Date: Mon, 16 Feb 2026 05:30:00 +0000

Thank you for your comments Tomas and Andres,

I will try to be brief.

## Large table MacOS test

I did a 1000x larger test in MacOS, with the sql script . prefetch had
negligible effect for random and periodic, and made sequential 16% slower
[a].

## Small scale linux test.

I have repeated the tests with a 10k table row on a linux system, this time
I tried using either a SSD or a HDD, with shared_buffers of either 128kB or
128MB, and either psql or python with psycopg. I used a slightly different
table [b].

Prefetch makes...

SDD with low available buffers is significantly slower, except for
sequential reads.
SDD with cold OS reads is significantly faster for random access.
HDD faster, but not reliably (high variance).

I am not questioning the usefulness of the patch, and I know that there is
a log of work already put into it. The reason why I decided to review it is
because I believe this can be one important step forward. But I hope that
it is not hard to agree that these (counter)examples suggest that there are
some edges to be pruned. Where I work, most of the queries will access
at most a few hundred lines and are expected to be perceived as
instantaneous.

If the tests I am doing are pointless, should we consider having something
in the planner to prevent these scans from using prefetch?

Should we introduce centralized coordination for IO? As far as I know this
is an area where we just let each query request what they need and hope for
the best. What happens if we have two sequential scans in different tables?
the disk access could interleave pages of the two scans, falling into a
random access pattern, right?

### Cache control

This is a way to make the script run without sudo in linux, you give
ownership to root, and then you pin this program to the owner
% gcc drop_cache.c -o drop_cache;
% sudo chown root:root drop_cache;
% sudo chmod 4755 drop_cache;

In MacOS  purge in the sudoers[2] temporarily, similar to [3]
user ALL=(ALL) NOPASSWD: /usr/bin/purge
So that I don't need to give sudo privileges to the script (that imports
code that I am not even aware of).

Notes:

[a] I did some profiling with sample [4], and tried to spot functions with
the highest increase or decrease in run time, but I was too confused, no
point in dumping raw logs here.

[b] This time I used a (SELECT string_agg((i*j)::text, '+') FROM
generate_series(1, 50)) instead of repeat('x', 100), just to prevent it
from compressing to nothing when I try larger payloads, and hit the TOAST
thresholds. I removed the primary key `id` because it was annoying to take
20 minutes to insert the data in the large scale test.

References:

[1]
https://www.reddit.com/r/sysadmin/comments/pwiboy/whats_the_purpose_of_chmod_4755_binsh_command/
[2] https://man7.org/linux/man-pages/man5/sudoers.5.html
[3] https://unix.stackexchange.com/a/13058
[4] https://www.unix.com/man_page/osx/1/sample/

Regards,
Alexandre

---

From: Tomas Vondra <[email-masked]>
Date: Mon, 16 Feb 2026 09:41:01 +0100

On 2/16/26 06:30, Alexandre Felipe wrote:
> negligible effect for random and periodic, and made sequential 16%
> slower [a].
> 

How did you do that? Did you increase the number of rows, make the rows
wider (by increasing the 'repeat' parameter in the script), or something
else? Did you verify the table really is 1000x larger?

> I have repeated the tests with a 10k table row on a linux system, this
> time I tried using either a SSD or a HDD, with shared_buffers of either
> 128kB or 128MB, and either psql or python with psycopg. I used a
> slightly different table [b].
> 

The "10k table row" means repeat('x',10000) when generating data? Oh, I
see you're using some string_agg(), to make it not compress. But note
that if it's TOASTed, it become entirely irrelevant for the prefetching
test because it's in a separate relation.

Unfortunately, you have not included the new script, so we can't try
reproducing your results.

128kB shared buffers is a little bit ... insane. I refuse to optimize
anything for this value, and I don't even call about regressions. Even
128MB is not really practical, any serious system caring about
performance will use tens or hundreds of GBs of shared buffers.

> Prefetch makes...
> 
> SDDwith low available buffers is significantly slower, except for
> sequential reads.
> > I am not questioning the usefulness of the patch, and I know that there
> is a log of work already put into it. The reason why I decided to review
> it is because I believe this can be one important step forward. But I
> hope that it is not hard to agree that these (counter)examples suggest
> that there are some edges to be pruned. Where I work, most of the
> queries will access at most a few hundred lines and are expected to be
> perceived as instantaneous.
> 

No comment unless I can reproduce these results.

> If the tests I am doing are pointless, should we consider having
> something in the planner to prevent these scans from using prefetch?
> 

How would you do that? Please explain.

The planner would need to know things it fundamentally does not (and
can't) know - for example it does not know if it'll touch only cached
data, or the particular I/O pattern (which may matter a lot).

There are runtime provisions to not enable prefetching in some cases,
and we may improve them in the future. But all of this is heuristics,
and every heuristics has a set of adversary cases.

> Should we introduce centralized coordination for IO? As far as I know
> this is an area where we just let each query request what they need and
> hope for the best. What happens if we have two sequential scans in
> different tables? the disk access could interleave pages of the two
> scans, falling into a random access pattern, right?
> 

The patch does nothing about that, it's the same as now. And we have no
ambition to work on that.

> code that I am not even aware of).

... or you could modify the script to simply use sudo.

> 
> Notes:
> 
> [a] I did some profiling with sample [4], and tried to spot functions
> with the highest increase or decrease in run time, but I was too
> confused, no point in dumping raw logs here.
> 
> from compressing to nothing when I try larger payloads, and hit the
> TOAST thresholds. I removed the primary key `id` because it was annoying
> to take 20 minutes to insert the data in the large scale test.
> 

It's better to load the data first, and then create indexes.

regards

-- 
Tomas Vondra

---

From: Alexandre Felipe <[email-masked]>
Date: Mon, 16 Feb 2026 10:12:12 +0000

> else? Did you verify the table really is 1000x larger?

I increased the number of rows by 1000. I didn't really check the size of
the table, the time increase suggests that it was right.

> test because it's in a separate relation.

sorry, 10k row table, for the payload I left a note

> reproducing your results.

 Let me try to find something not so insane.

> performance will use tens or hundreds of GBs of shared buffers.

I am not going to dispute that

> How would you do that? Please explain.

I have no idea. But based on what you said I thought you would know. In my
head: "If my test seemed ridiculous to them all, they have some clear
boundaries in their mind that they could write in the planner".

> ... or you could modify the script to simply use sudo.
In that case sudo would request a password to the caller, and the caller is
a python script, no interaction there, of course I could do all the steps
manually, but it is more error prone (just my own mistakes are enough).

Regards,
Alexandre

---

From: Tomas Vondra <[email-masked]>
Date: Mon, 16 Feb 2026 16:07:24 +0100

> tables (from Alexandre's workload) have very subpar performance.

OK, I was just confused as it wasn't in the published patch. I agree the
info is useful/valuable, which is why wrote the patch initially. I'll
try to improve it to make it less misleading etc.

> distance staying relatively high, while preventing actually issuing much

I wonder if this might be hurting us. Peter was working on adding what
he calls "adaptive yielding", so maybe it could be preventing issuing
much enough concurrent IOs.

> blocks, not the number of IOs.

Sure, but it's the one thing that's easily accessible, even if it's
imperfect. It can definitely tell us when the distance "collapses" close
to 1.0 (in which case we can't be issuing any concurrent IOs).

Ideally we'd have a way to look at the "distance" in the actual batch,
but that's invisible to the read_stream code. Maybe we could track that
within indexam.c/indexbatch.c - I'll give it a try. The patch predates
the refactoring a couple weeks ago, it might work much nicer now.

>>> but that was just an experiment.

I missed this comment about batchdistance before. I'm sure there are
cases where a higher threshold would work better, but IIRC it's meant as
a safety for short queries that happen to run-away trying to look for
the next block to prefetch. And the block may not even be needed in some
cases, e.g. with LIMIT.

So increasing the value to 8 would help this particular query, but could
easily hurt various other queries - like index-only scans with LIMIT.

Maybe we should gradually ramp-up the threshold, instead of keeping it
at 2 forever.

> limits never actually allowing multiple IOs, you'll have no gain from AIO.

If we're doing a 1000 IOs, but each IO can is either 8K or 128K chunk,
those seem like rather different situations, no? The bandwidth will be
vastly different, and it also saturates the other operators differently.
But I haven't thought about that too deeply. Just knowing the number of
IOs seems incomplete.

regards

-- 
Tomas Vondra

---

From: Tomas Vondra <[email-masked]>
Date: Mon, 16 Feb 2026 16:44:03 +0100

> 1. I.e. we increase the distance as quickly as we decrease it.

I've described this exact behavior a couple months ago in this very
thread. The queue of batches has limited size, the prefetch needs to be
paused - at that point read_stream_pause did not exist, so it was done
by terminating the stream and then read_stream_reset to "resume" it.

That has the unfortunate effect that it resets distance to 1, and so it
easily led exactly to the issue you describe. But without the pausing it
would work perfectly fine, in many cases.

The way I'm thinking about it is that each data set with a uniform cache
miss rate has a distance threshold for stability. Let's say the miss
rate is 1%. That means that we'll do an I/O and then the next 99 blocks
are cache hits. So we double the distance, and then decay it by -1. To
make this stable, these two things need to balance. If we start at
distance=99, we'll go 198->99->198->99->198->... But if we start just a
little bit lower, at 98, we'll quickly decay to distance=1. This is what
I call the "stability threshold".

With the resetting, this effect is pretty brutal. The queries often have
a period of increased "cache misses" at the beginning (because caches
are cold). The distance can "climb up" above the threshold, and keep up
sufficient prefetch distance. But the stream reset sets the distance=1,
without the cache misses to push it up again.

Having read_stream_pause/resume solves this particular issue, but the
overall issue of "stability threshold" still exists. Even when the
distance gets adjusted differently (e.g. 2*d + 1), it'll still be
trivial to construct "nice" data sets that narrowly miss the threshold.
I don't see a way around that, unfortunately (short of not decaying the
distance at all).

I think a "proper" solution would require some sort of cost model for
the I/O part, so that we can schedule the I/Os just so that the I/O
completes right before we actually need the page.

A trivial super-simplified example: let's say reading a page costs 100
coins, and processing one tuple (fetched from the index scan + heap)
takes 1 coin. Then we need to schedule ~100 IOs ahead, because an IO
we'll need in ~100 cycles should be just about complete.

Of course, that's a super simplified model. The point is it needs some
concept of actual I/O costs, I don't think there's a perfect formula
considering only the distance itself.

regards

-- 
Tomas Vondra

---

From: Andres Freund <[email-masked]>
Date: Mon, 16 Feb 2026 11:16:57 -0500

Hi,

> I don't see a way around that, unfortunately (short of not decaying the

I think this specific issue is a bit different, because today you get
drastically different behaviour if you have

a) [miss, (miss, hit)+]
vs
b) [(miss, hit)+]

because for a), the first miss will get to distance = 2, the second one to 4,
and then we go to 3, to 6, to 5, to 10, ... I.e. things will work out nicely.

But today, for b), we just oscillate between 1 and 2. Despite the cache
hit/miss pattern being almost entirely the same. That's terrible.

I think we need to fix this, even if there are more complicated scenarios that
won't be addressed. This is a tiny difference in hit pattern having a huge
performance impact, without there being any reason to behave that way.

It doesn't have to be
    distance = distance * 2 + 1
I'd just has happy with
    distance = Min(distance * 2, 4)

or such. We just need to get out of the situation that there's a different
behaviour when distance = 1 than otherwise.

I'd probably go further and make it so that the distance does not decay as
long as there is any in-flight IO. I think that makes a lot of sense, if
looking ahead allows to start IOs earlier, looking ahead is worthwhile. What
the distance-- is trying to achieve is to avoid continuing to do deep
lookahead when you start having a high cache hit ratio (e.g. because your
entire table now is in memory). Whether anything in our lookahead distance
actually needed to do IO is a pretty good predictor.

> completes right before we actually need the page.

I agree that that would be really useful (I've been collaborating with some
folks from microsoft research towards developing something for that, FWIW).,
but i think that's actually somewhat orthogonal. The main gain of something
like that would be that it would avoid increasing the lookahead distance
further, past where it can help. Right now we'll happily increase the distance
to the max with a lot of misses, even though that's very often not going to
increase throughput after some point.

The reason I think such an algorithm is orthogonal to the problem I described
is that even if we had such a cost model, you actually need to start out with
heuristics, at the start of the scan there is no information with which to
guide such a scheduler.  The actual IO pattern and the user of the stream have
a *huge* impact on how aggressively you need to read ahead, and that's not
known at the start of the stream. Yet you need to start ramping up readahead
at the start of the stream, not after thousands of IOs.

Lastly, improving our pretty naive heuristics until we have such a model is
helpful, even if there are always going to be cases where the heuristics don't
work out.

> considering only the distance itself.

Clearly there is not - but I don't think there needs to be perfection, just
being noticeably better than what we have today is good enough.

Greetings,

Andres Freund

---

From: Andres Freund <[email-masked]>
Date: Mon, 16 Feb 2026 11:48:38 -0500

Hi,

> much enough concurrent IOs.

Yes, It's hurting quite substantially. For well correlated index scans it
prevents readahead from becoming aggressive enough even on a local low latency
SSD. Which means it'll not even be even remotely aggressive enough on a
networked block store.

On a pgbench scale 300, with io_method=io_uring and debug_io_direct=data (that
avoids needing to drop the entire kernel buffers and makes the results more
predictable) and local changes to measure the stats over ios_in_progress,
instead of distance:

psql -Xq \
  -c "SELECT pg_buffercache_evict_relation('pgbench_accounts');" \
  -c "SELECT pg_prewarm('pgbench_accounts_pkey')" &&
psql -Xq \
  -c "SET enable_seqscan = 0; SET enable_indexonlyscan = 0;" \
  -c "EXPLAIN ANALYZE SELECT count(*) FROM (SELECT * FROM pgbench_accounts ORDER BY aid);"

 Aggregate  (cost=1645852.77..1645852.78 rows=1 width=8) (actual time=10195.159..10195.160 rows=1.00 loops=1)
   Buffers: shared hit=81971 read=491804
   I/O Timings: shared read=3609.144
   ->  Index Scan using pgbench_accounts_pkey on pgbench_accounts  (cost=0.56..1270852.22 rows=30000044 width=352) (actual time=0.314..8758.229 rows=30000000.>
         Index Searches: 1
         Prefetch: distance=0.000 count=519121 stalls=81967 skipped=29507836 resets=0 pauses=27322 ungets=0 forwarded=0
                   histogram [1,2) => 519121
         Buffers: shared hit=81971 read=491804
         I/O Timings: shared read=3609.144
 Planning:
   Buffers: shared hit=70
 Planning Time: 0.225 ms
 Execution Time: 10195.214 ms

iostat looks like this:

Device            r/s     rMB/s   rrqm/s  %rrqm r_await rareq-sz     w/s     wMB/s   wrqm/s  %wrqm w_await wareq-sz     d/s     dMB/s   drqm/s  %drqm d_await dareq-sz     f/s f_await  aqu-sz  %util
nvme6n1       5089.00    178.93     0.00   0.00    0.13    36.00    0.00      0.00     0.00   0.00    0.00     0.00    0.00      0.00     0.00   0.00    0.00     0.00    0.00    0.00    0.65  65.90

Note that there is pretty much *no* readhead, because the yields happen more
frequently than a io_combine_limit sized IO can be formed.

With the yielding logic disabled:

 Aggregate  (cost=1645852.77..1645852.78 rows=1 width=8) (actual time=5631.206..5631.207 rows=1.00 loops=1)
   Buffers: shared hit=81971 read=491804
   I/O Timings: shared read=140.005
   ->  Index Scan using pgbench_accounts_pkey on pgbench_accounts  (cost=0.56..1270852.22 rows=30000044 width=352) (actual time=0.524..4491.521 rows=30000000.>
         Index Searches: 1
         Prefetch: distance=30.981 count=533770 stalls=6 skipped=29507836 resets=0 pauses=41966 ungets=0 forwarded=0
                   histogram [1,2) => 44, [2,4) => 31, [4,8) => 49, [8,16) => 81, [16,32) => 145, [32,64) => 533420
         Buffers: shared hit=81971 read=491804
         I/O Timings: shared read=140.005
 Planning:
   Buffers: shared hit=70
 Planning Time: 0.244 ms
 Execution Time: 5631.259 ms

iostat looks like this:

Device            r/s     rMB/s   rrqm/s  %rrqm r_await rareq-sz     w/s     wMB/s   wrqm/s  %wrqm w_await wareq-sz     d/s     dMB/s   drqm/s  %drqm d_await dareq-sz     f/s f_await  aqu-sz  %util
nvme6n1       7508.00    682.55     0.00   0.00    0.18    93.09    0.00      0.00     0.00   0.00    0.00     0.00    0.00      0.00     0.00   0.00    0.00     0.00    0.00    0.00    1.32 100.00

And the only reason the difference isn't bigger is that the second query is
completely CPU bound.

> to 1.0 (in which case we can't be issuing any concurrent IOs).

I don't follow - ->pinned_buffers is also readily accessible? As is
->ios_in_progress?

I don't think the distance really tells you that there's no concurrent
IO. E.g. the query from above, with yielding enabled, and the stats using
->distance
   ->  Index Scan using pgbench_accounts_pkey on pgbench_accounts  (cost=0.56..1270852.22 rows=30000044 width=352) (actual time=0.166..12169.987 rows=30000000>
         Index Searches: 1
         Prefetch: distance=9.421 count=519121 stalls=81967 skipped=29507836 resets=0 pauses=27322 ungets=0 forwarded=0
                   histogram [1,2) => 27322, [2,4) => 81967, [4,8) => 191253, [8,16) => 218579
         Buffers: shared hit=81971 read=491804
         I/O Timings: shared read=5469.932

Sure looks like there might be some concurrent IO...

> Maybe we should gradually ramp-up the threshold, instead of keeping it

The comment seems to say it's about avoiding to look very into the future when
using index only scans that just need a few heap lookups. Certainly an
important goal.

I did not actually want to suggest that changing this to ->batchdistance <= 8
makes sense, I was just experimenting to figure out why the query was so IO
bound / not doing any actual readahead.

I briefly experimented with a heuristic that skips yielding if there are few
IOs in flight within the lookahead distance. The idea being that that would
detect cases where io combining limits how many IOs we have in flight, despite
looking reasonably far ahead.  While that works, it's not obvious how many
in-flight IOs we'd want before yielding?

So I guess yes, you'd need some ramping?

One thing that does confuse me about the yielding logic is that it seems to
actually put a cap on ever looking more than two batches ahead (with a bit of
fuzziness)? Why support more batches then (INDEX_SCAN_MAX_BATCHES == 128)?
Isn't two batches too low for anything with some correlation on even remotely
higher latency storage?

> But I haven't thought about that too deeply. Just knowing the number of

Hm, maybe tracking the average size of IOs would be quite interesting? Given
how big the perf effects of being able to combine or not are? Then the number
of IOs would tell you a lot more...

Greetings,

Andres Freund

---

From: Andres Freund <[email-masked]>
Date: Mon, 16 Feb 2026 12:15:20 -0500

Hi,

> negligible effect for random and periodic, and made sequential 16% slower

Quite surprised by that, I don't see that at all.

> 128MB, and either psql or python with psycopg. I used a slightly different

128kB is a useless test. That's 16 buffers. Just about any real workload will
trigger errors about having too many buffer pinned, independent of this
patch. We support it because it's useful for some testing, not because anybody
should ever use postgres that way.

We should not spend a single second optimizing performance on such toy setups.

Even 128MB borders on irrelevant for anything performance sensitive. It
actually will limit how much AIO you can do (due to now allowing one session
to pin all buffers). If you have a workload that is at all IO bound, you
should use a significantly larger s_b.

> at most a few hundred lines and are expected to be perceived as

I think some of the tests do show something useful (like readahead not
happening for some cases where it should).  But testing with pointless
configurations is not useful.

> in the planner to prevent these scans from using prefetch?

No, that'd just be wasted cycles.

> Should we introduce centralized coordination for IO?

I don't think it's any of the next few steps. The amount of contention you can
create by doing that is significant, so you have to be really careful.

There *are* interesting bits around more central coordination of IOs:

I'd e.g. love allowing autovacuum to do IO more aggressively as long as the
system otherwise is idle IO wise. That's much better than having an upper cap
on how much IO AV is allowed to do at all time, because it allows to use spare
IO capacity when available *and* scales down AV activity when the system is
otherwise very busy.

But it's decidedly nontrivial to do right, and it requires every IO to go
through the AIO subsystem, which isn't yet the case (buffered writes & all WAL
IO don't yet, for example).

> As far as I know this is an area where we just let each query request what
> they need and hope for the best. What happens if we have two sequential
> scans in different tables?  the disk access could interleave pages of the
> two scans, falling into a random access pattern, right?

Each stream would generate 128kB sized reads (that's the default
io_combine_limit) that would then be interspersed between the two
streams. Because of the larger size that's fine.

Central coordination of IOs couldn't do anything better either. You have,
after all, two streams processing data that are on different parts of the
disk. So you will have some non-sequential IO.

> 20 minutes to insert the data in the large scale test.

FWIW, because your query uses length(payload), it actually ends up spending a
lot of CPU time in that, reducing the effect size of IO boundedness.

postgres[1077180][1]=# EXPLAIN (ANALYZE) SELECT length(payload) FROM prefetch_test_data_1000000 ORDER BY sequential;
┌────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│                                                                               QUERY PLAN                                                                               │
├────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Index Scan using idx_sequential_1000000 on prefetch_test_data_1000000  (cost=0.42..59733.43 rows=1000000 width=8) (actual time=0.046..543.910 rows=1000000.00 loops=1) │
│   Index Searches: 1                                                                                                                                                    │
│   Buffers: shared hit=33985                                                                                                                                            │
│ Planning Time: 0.125 ms                                                                                                                                                │
│ Execution Time: 576.192 ms                                                                                                                                             │
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

postgres[1077180][1]=# EXPLAIN (ANALYZE) SELECT id FROM prefetch_test_data_1000000 ORDER BY sequential;
┌────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│                                                                               QUERY PLAN                                                                               │
├────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Index Scan using idx_sequential_1000000 on prefetch_test_data_1000000  (cost=0.42..57233.43 rows=1000000 width=8) (actual time=0.036..165.802 rows=1000000.00 loops=1) │
│   Index Searches: 1                                                                                                                                                    │
│   Buffers: shared hit=33985                                                                                                                                            │
│ Planning Time: 0.119 ms                                                                                                                                                │
│ Execution Time: 199.965 ms                                                                                                                                             │
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

Greetings,

Andres Freund

---

From: Alexandre Felipe <[email-masked]>
Date: Mon, 16 Feb 2026 23:05:26 +0000

Hi guys,

There seems to be some very interesting stuff here, I have to try to
catch up with your analysis Andres.

In the meantime.

I am sharing the results I have got on a well behaved Linux system.

No sophisticated algorithm here but evicting OS cache helps to verify the
benefit of prefetching at a much smaller scale, and I think this is useful
% gcc drop_cache.c -o drop_cache;
% sudo chown root:root drop_cache;
% sudo chmod 4755 drop_cache;

I was executing like this
python3 .../run_regression_test.py --port 5433 --iterations 10 \
            --columns sequential,random --workers 0 --evict os,off \
            --payload-size 50 \
            --rows 10000 \
            --reset \
            --ntables 5

1 table: significant benefit with HDD cold, SSD random cold access.
5 tables: significant benefit for random cold access. Somewhat detrimental
for sequential cold access, and random hot access.
10 tables: significant benefit for random cold access. Slightly better than
5 tables for cold sequential access, and somewhat detrimental for random
hot access.

> drastically different behaviour if you have

Tomas said
> completes right before we actually need the page.

I dare to ask
Why not use this on a feedback loop?

while (!current_buffer.ready && reasonable to prefetch) {
  fetch next index tuple.
  if necessary prefetch one more buffer
}

I also dare to ask
Is it possible to skip an unavailable buffer and gain time processing the
rows that will be needed afterwards?
This could also help by releasing buffers more quickly if they need to be
recycled.

Regards,
Alexandre

---

From: Tomas Vondra <[email-masked]>
Date: Tue, 17 Feb 2026 00:33:21 +0100

> I am sharing the results I have got on a well behaved Linux system.

Can you share how is the system / Postgres configured? It's a good
practice to provide all the information others might need to reproduce
your results.

In particular, what is shared_buffers set to? Are you still using
io_method=worker? With how many io workers?

> No sophisticated algorithm here but evicting OS cache helps to verify
> the benefit of prefetching at a much smaller scale, and I think this is
> useful
> 5 tables: significant benefit for random cold access. Somewhat
> detrimental for sequential cold access, and random hot access.
> 10 tables: significant benefit for random cold access. Slightly better
> than 5 tables for cold sequential access, and somewhat detrimental for
> random hot access.
> 

What's the distance in those cases? You may need to add some logging to
read_stream to show that. If the distance is not ~1.0 then it's not the
issue described by Andres, I think.

There are other ways to look at issued IOs, either using iostat, or
tools like perf-trace.

>   if necessary prefetch one more buffer

What does "reasonable to prefetch" mean in practice, and how you
determine it at runtime, before initiating the buffer prefetch?

> I also dare to ask
> Is it possible to skip an unavailable buffer and gain time processing
> the rows that will be needed afterwards?
> This could also help by releasing buffers more quickly if they need to
> be recycled.
> 

Not at the moment, AFAIK. And for most index-only scans that would not
really work anyway, because those need to produce sorted output.

regards

-- 
Tomas Vondra

---

From: Tomas Vondra <[email-masked]>
Date: Tue, 17 Feb 2026 14:39:15 +0100

> And the only reason the difference isn't bigger is that the second query is

Interesting. Seems the yielding can be quite harmful.

> Sure looks like there might be some concurrent IO...

I don't follow. I was not talking about concurrent IO at all. My point
is that it's interesting to know how many index entries are between the
"scan" and "prefetch" positions.

> bound / not doing any actual readahead.

Sure.

> Isn't two batches too low for anything with some correlation on even remotely
IIRC it's done like this because of queries that require fast start, and
may not need to access all the index items. Like LIMIT queries, semi
joins and so on.

Getting the next block number may be expensive, e.g. because the index
is correlated (and so many blocks get skipped as "duplicate") or with
index-only scans (where we can skip all-visible pages). This ensures we
don't do way too much work trying to prefetch one more item.

That's what the patch explains in the comment, after all.

But maybe it's too aggressive - in general we chose to be defensive,
i.e. we prefer not to risk regressions in realistic cases even if it
means we have to sacrifice some gains. Maybe it's too careful.

> of IOs would tell you a lot more...

Right, that could be interesting.

regards

-- 
Tomas Vondra

---

From: Peter Geoghegan <[email-masked]>
Date: Tue, 17 Feb 2026 12:16:23 -0500

> SSD. Which means it'll not even be even remotely aggressive enough on a

I agree that the current heuristics (which were invented recently) are
too conservative. I overfit the heuristics to my current set of
adversarial queries, as a stopgap measure.

> frequently than a io_combine_limit sized IO can be formed.

ISTM that we need the yields to better cooperate with whatever's
happening on the read stream side.

> using index only scans that just need a few heap lookups. Certainly an

The main motivation for yielding is to deal with things like merge
joins fed by at least one plain index scan, and plain scans for an
"ORDER BY .... LIMIT N" query. The LIMIT N is also addressed by the
"Use ExecSetTupleBound hint during index scans" patch, albeit
imperfectly. This is also important with index-only scans, though only
when heap fetches are actually required in the first place (most
index-only scans will never create a read stream in the first place).

I attach an example of where disabling the yield mechanism hurts
instead of helping, to give you a sense of the problems in this area.
Notice that the inner side of the merge join ("Index Scan using
prefetch_customers_pkey on prefetch_customers c") requires the same
number of buffer hits + buffer reads as master with yielding enabled
-- that allows the patch to shave off 7% of master's execution time.
Whereas without yielding, there's quite a lot more buffer hits (for
index page reads) + buffer misses (for heap page reads) -- which hurts
us. Without yielding, the patch takes about 13% longer to execute the
query.

Just to be clear, I'm not arguing that this is the right trade-off.
And I understand that it's just not feasible to completely prevent
such plan shapes from reading more data than strictly necessary -- a
certain amount of that seems like a "cost of doing business". But it
seems important that the added costs of speculatively reading later
batches/index leaf pages never exceeds some fixed threshold.

The non-yielding EXPLAIN ANALYZE has an inner-index scan that does
~60% more work than master/than the yielding variant of the patch.
It's not so much that the amount of extra work we'll perform is
excessive (though it is); what really concerns me is that the amount
of extra work is *indeterminate*. There's likely to be other queries
that are much slower still, at least in the absence of some kind of
yielding mechanism. And so ISTM that we need a better yield mechanism
-- one that is better attuned to the need to maintain an adequate
prefetch distance on the read stream side.

> Isn't two batches too low for anything with some correlation on even remotely

FWIW that's not actually true; we *can* still use more than 2 batches.
My instrumentation confirms this. Though it seems to top out at about
4 batches total with the current yield logic/with my test suite
queries.

-- 
Peter Geoghegan

---

From: Peter Geoghegan <[email-masked]>
Date: Tue, 17 Feb 2026 13:17:15 -0500

> would work perfectly fine, in many cases.

Just to be clear: it *used* to work that way, back when we weren't
using Thomas Munro's built-in pausing mechanism. Because we'd actually
reset the read stream back then, as a workaround for the lack of any
built-in pausing mechanism. But we don't do that anymore; recent
versions only reset in cases where it's strictly necessary, such as on
a rescan, or when the scan direction changes.

Recent versions of the patch are just about impossible to make pause.
Because (for better or worse) we'll yield before ever running out of
batch slots/before ever pausing. Andres has shown that this can be
detrimental to our ability to maintain an adequate prefetch distance.
But that's likely only because pausing hurts when it prevents us from
aggressively ramping up prefetch distance at the start of the scan.

Even without yielding, pausing is rare and likely doesn't hurt us
much. After all, 64 batches is usually plenty.

> With the resetting, this effect is pretty brutal.

What resetting? We don't do that anymore? And even back when the patch
did things that way, we made sure to save the old prefetch distance
(which was a kludge upon a kludge)?

Basically I'm confused about why you're now talking about how the
patch worked months ago, which was always a temporary workaround to
deal with the lack of a built-in pausing mechanism. All of our current
problems are due to yielding (not pausing), and/or issues on the read
stream side.

-- 
Peter Geoghegan

---

From: Andres Freund <[email-masked]>
Date: Tue, 17 Feb 2026 14:27:56 -0500

Hi,

> adversarial queries, as a stopgap measure.

Are you doing any testing on higher latency storage?  I found it to be quite
valuable to use dm_delay to have a disk with reproducible (i.e. not cloud)
higher latency (i.e. not just a local SSD).  Low latency NVMe can reduce the
penalty of not enough readahead so much that it's hard to spot problems...

> happening on the read stream side.

Plausible.  It could be that we could get away with controlling the rampup to
be slower in potentially problematic cases, without needing the yielding, but
not sure.

If that doesn't work, it might just be sufficient to increase the number of
batches that trigger yields as the scan goes on (perhaps by taking the number
of already "consumed" batches into account).

To evaluate the amount of wasted work, it could be useful to make the read
stream stats page spit out the amount of "unconsumed" IOs at the end of the
scan.

> "ORDER BY .... LIMIT N" query.

Would be good to document why the yielding exists more extensively in the
comment above it...

> instead of helping, to give you a sense of the problems in this area.

What data/schema is that? Looks kinda but not really TPC-H like.

I assume that there are no mark & restores in the query, given that presumably
the inner side is unique?

Greetings,

Andres Freund

---

From: Tomas Vondra <[email-masked]>
Date: Tue, 17 Feb 2026 21:55:08 +0100

> problems are due to yielding (not pausing), and/or issues on the read

Yes, the patch no longer resets the stream like this.

I was responding to Andres, explaining that we saw basically the same
issue, with the twist that the impact strongly depends on the distance
value at the moment we hit the particular hit/miss pattern.

We no longer have that issue (with the "read_stream_reset" forcing the
distance collapse). We still have the issue with decaying too early in
some cases, depending on the exact pattern of hits/misses.

Sorry if that wasn't clear.

-- 
Tomas Vondra

---

From: Peter Geoghegan <[email-masked]>
Date: Tue, 24 Feb 2026 13:13:25 -0500

> be slower in potentially problematic cases, without needing the yielding, but

Attached is v11, which makes the read stream yielding mechanism better
cooperate with index prefetching, so as to avoid interefering with
io_combine_limit. This should deal with the odd performance that you
complained about. See
v11-0006-Introduce-read_stream_-pause-resume-yield.patch (and the
later prefetching patch
v11-0007-Add-heapam-index-scan-I-O-prefetching.patch) for details.

The whole idea of measuring "batch distance" is gone in this version,
though we do still only consider whether now is a good time to yield
at "batch boundaries". We always refuse yield on the first few batches
of the scan, so the idea of caring about batch boundaries is still
there, albeit in a much more limited form.

I think that the read stream aspects of this need expert review from
somebody like Melanie, Andres, or Thomas. The new approach to yielding
should definitely be considered a work in progress. Though testing
seems to show that it's a step or two in the right direction.

The other focus for v11 has been fixing regressions, particularly with
index-only scans that run on fully cached data. We've added a more
efficient approach to how we manage the memory for batches used for
index-only scans -- they require extra space to store cached
visibility info taken from the visibility map, which is now more compact.
It is allocated as part of the main batch allocation, avoiding needless
palloc churn.

This v11 also includes a new "Don't allocate _bt_search stack" patch,
which avoids allocating memory for an nbtree descent stack during
index scans. I found that this helped a bit with certain index scans
that call _bt_search frequently/descend the index many times. Anything
that avoids the use of palloc in a critical path is a good idea,
especially when doing so is as straightforward as it is with the
_bt_search stack (we simply don't need any stack in the hot _bt_first
code path).

--
Peter Geoghegan

---

From: Andres Freund <[email-masked]>
Date: Thu, 26 Feb 2026 23:18:03 -0500

Hi,

> there, albeit in a much more limited form.

I'm planning to do some reviewing in the next days. In preparation I just
retried a benchmark and saw some odd results.  After a while I was able to
reproduce even with a simpler setup:

-c shared_buffers=2GB -c debug_io_direct=data -c io_method=io_uring

pgbench -i -q -s 100 --fillfactor=90
┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│                                                                            QUERY PLAN                                                                            │
├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Index Scan using pgbench_accounts_pkey on pgbench_accounts  (cost=0.43..441511.11 rows=10000045 width=97) (actual time=0.308..6101.837 rows=10000000.00 loops=1) │
│   Index Searches: 1                                                                                                                                              │
│   Buffers: shared hit=27325 read=181819                                                                                                                          │
│   I/O Timings: shared read=4538.003                                                                                                                              │
│ Planning Time: 0.041 ms                                                                                                                                          │
│ Execution Time: 6433.192 ms                                                                                                                                      │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

pgbench -i -q -s 100 --fillfactor=50
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│                                                                           QUERY PLAN                                                                            │
├─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Index Scan using pgbench_accounts_pkey on pgbench_accounts  (cost=0.43..593022.41 rows=9999798 width=97) (actual time=0.131..3973.698 rows=10000000.00 loops=1) │
│   Index Searches: 1                                                                                                                                             │
│   Buffers: shared hit=19239 read=341420                                                                                                                         │
│   I/O Timings: shared read=1752.057                                                                                                                             │
│ Planning:                                                                                                                                                       │
│   Buffers: shared hit=42 read=15                                                                                                                                │
│ Planning Time: 1.668 ms                                                                                                                                         │
│ Execution Time: 4308.182 ms                                                                                                                                     │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

pgbench -i -q -s 100 --fillfactor=25
┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│                                                                            QUERY PLAN                                                                            │
├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Index Scan using pgbench_accounts_pkey on pgbench_accounts  (cost=0.43..926358.51 rows=10000005 width=97) (actual time=0.112..3259.362 rows=10000000.00 loops=1) │
│   Index Searches: 1                                                                                                                                              │
│   Buffers: shared hit=9610 read=684382                                                                                                                           │
│   I/O Timings: shared read=242.259                                                                                                                               │
│ Planning:                                                                                                                                                        │
│   Buffers: shared hit=18                                                                                                                                         │
│ Planning Time: 0.097 ms                                                                                                                                          │
│ Execution Time: 3594.782 ms                                                                                                                                      │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

Note how the increase in scanned heap pages actually *decreases* the overall
time rather substantially.

It's quite visible, both in iostat, and a query like
  SELECT pid, target_desc, off, length FROM pg_aios \watch 0.5

that for the first query has basically no IO concurrency, the second has very
intermittent IO concurrency and the third one has nice IO concurrency.

If I disable the yield logic, the fillfactor=90 case is good:
┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│                                                                            QUERY PLAN                                                                            │
├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Index Scan using pgbench_accounts_pkey on pgbench_accounts  (cost=0.43..441511.11 rows=10000045 width=97) (actual time=0.470..1662.331 rows=10000000.00 loops=1) │
│   Index Searches: 1                                                                                                                                              │
│   Buffers: shared hit=27325 read=181819                                                                                                                          │
│   I/O Timings: shared read=21.113                                                                                                                                │
│ Planning Time: 0.043 ms                                                                                                                                          │
│ Execution Time: 1995.723 ms                                                                                                                                      │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

Of course this is a silly query, but you'd also see that with a mergejoin or
such.

Greetings,

Andres Freund

---

From: Peter Geoghegan <[email-masked]>
Date: Thu, 26 Feb 2026 23:28:02 -0500

> Of course this is a silly query, but you'd also see that with a mergejoin or

Can you post the query?

--
Peter Geoghegan

---

From: Alexandre Felipe <[email-masked]>
Date: Fri, 27 Feb 2026 08:51:19 +0000

> retried a benchmark and saw some odd results.

Since we are talking about results I will share mine too :)

The bottomline is: Prefetch is working, but it might make some things
slower.

It is obvious that this should better exploit IO for one single heavy
query, in

one single table.

It is not so obvious, to me, how this would behave when there are multiple

concurrent queries. It is not so obvious how this will impact when multiple

tables are queried at the same time. My feeling is that it should greatly

improve on a disk with a mechanical head, if it performs the same reads

reducing the number of times it has to jump from one to another. Is there
much

interest in special optimisations for those or is the focus more on SSDs?

On my previous review I wasted way to much time trying to improve
read_stream,

to end up getting just some mixed results. This time I tried to step back
and

try to look at various functions that could have changed. Initially I tried

compiler function instrumentation, but then the profiling overhead of 33k

functions dominated.

This time what I did (1) added a indexscan_prefetch_distance, maybe a better

name would be just prefetch_distance, it limits the growth of distance in

read_stream (distance-limit.diff). (2) captured execution statistics for 15

functions (profiling-instrumentation.diff). At exit each process will
create a

log with its configuration and call statistics.

The benchmark was with full index scan on a sequential column, executed

repeatedly and no cache eviction: buffer hit path.

BENCHMARK RESULTS

MacOS in normal (for me) use

Prefetch     Avg Time     Min Time     Max Time

------------------------------------------------

off           6.03s        5.12s        11.70s

  1          59.44s       25.33s       257.60s

  4          19.74s       12.66s        44.36s

 16          11.87s        7.49s        19.13s

 64           8.77s        6.05s        13.97s

128           6.40s        4.33s        11.74s

MacOS idle, after reboot

Prefetch     Avg Time     Min Time     Max Time

------------------------------------------------

off           2.17s        2.12s         2.26s

  1           5.53s        5.44s         5.57s

  4           3.17s        3.04s         3.39s

 16           3.13s        3.04s         3.29s

 64           2.82s        2.66s         2.88s

128           2.83s        2.69s         2.90s

Docker on MacOS, idle, after reboot

Prefetch     Avg Time     Min Time     Max Time

------------------------------------------------

off          1.38s       1.36s       1.46s

  1          3.65s       3.56s       3.70s

  4          2.00s       1.98s       2.09s

 16          1.56s       1.53s       1.59s

 64          1.29s       1.25s       1.33s

128          1.28s       1.26s       1.32s

Docker on Linux

Prefetch     Avg Time     Min Time     Max Time

------------------------------------------------

off           6.07s        5.92s        6.29s

  1           6.85s        6.67s        7.04s

  4           6.26s        6.10s        6.41s

 16           6.14s        5.95s        6.30s

 64           5.74s        5.62s        5.91s

128           5.72s        5.63s        5.86s

The linux execution presented very little degradation. On MacOS host the

degradation was more noticeable than on MacOS docker running a debian,

suggesting that software ecosystem contributes, docker on MacOS (arm), was

slower than docker on a native linux (x86_64), here I could be it is CPU

architecture or OS kernel differences.

WHAT CHANGED

The benchmark will produced, 195 autovac_worker, and 3293 backend and one

bgworker log. For prefetch off the number of calls is constant. For
prefetch on

they vary widely, but I am looking at the total time per function, assuming

that the differences in the number of calls changes only how the work was

partitioned but the final work was the same.

With Docker version 28.3.0, build 38b7060, Python 3.10.18

$ docker compose up --build benchmark

$ docker cp docker-postgres-1:/tmp/profiling ./docker-profiling

$ python compare_profiles.py docker-profiling

Function                  off,d=0 on,d=128     Diff        % z-statistic

------------------------------------------------------------------------

read_stream_next_buffer       0.0   3944.9  +3944.9     NEW%  +654.88

read_stream_look_ahead        0.0   2999.3  +2999.3     NEW%  +624.00

WaitReadBuffers              98.3    754.6   +656.3  +667.7%  +414.56

_bt_next                    748.7   1072.8   +324.1   +43.3%   +20.35

heapam_batch_getnext        788.4   1114.6   +326.2   +41.4%   +20.18

btgetbatch                  777.0   1096.7   +319.7   +41.2%   +20.14

IndexNext                 17031.7  10400.3  -6631.5   -38.9%  -249.51

_bt_first                    17.2     13.0     -4.2   -24.6%    10.56

Function                  off,d=0   on,d=1     Diff        % z-statistic

------------------------------------------------------------------------

read_stream_look_ahead        0.0  28135.9 +28135.9      NEW    N/A

read_stream_next_buffer       0.0 199245.7 +199245.7     NEW    N/A

IndexNext                 17031.7 211861.0 +194829.2   12.4x  +283.00

WaitReadBuffers              98.3 169641.9 +169543.6   1724x  +275.63

heapam_index_fetch_tuple  13564.5 205828.2 +192263.7     15x  +172.56

heapam_batch_getnext        788.4   1944.0  +1155.6  +146.6%   +25.39

_bt_next                    748.7   1833.9  +1085.2  +144.9%   +24.85

btgetbatch                  777.0   1881.2  +1104.2  +142.1%   +24.74
_bt_first                    17.2     19.3     +2.1   +12.0%   +10.33

PS.: The docker environment cache eviction requires adjustments.

---

From: Tomas Vondra <[email-masked]>
Date: Fri, 27 Feb 2026 16:41:05 +0100

On 2/27/26 09:51, Alexandre Felipe wrote:
> 
> 
> On Fri, Feb 27, 2026 at 4:18 AM Andres Freund <[email-masked]
> <mailto:[email-masked]>> wrote:
> 
>     Hi,
> 
> It is obvious that this should better exploit IO for one single
> heavy query, in one single table.
> 
> It is not so obvious, to me, how this would behave when there are 
> multiple concurrent queries. It is not so obvious how this will 
> impact when multiple tables are queried at the same time. My feeling
> is that it should greatly improve on a disk with a mechanical head,
> if it performs the same reads reducing the number of times it has to
> jump from one to another. Is there much interest in special
> optimisations for those or is the focus more on SSDs?
> 

I don't follow your argument about spinning drives vs. SSDs.

Are you suggesting prefetching is not as important for SSDs? Prefetching
can help on systems with spinning drives, but I'd say
performance-sensitive systems are not using spinning drives anymore.

Yes, multiple backends executing queries and issuing I/O concurrently
may have a similar effect - the queues will be longer, and the storage
system will be better utilized. But what if there are not that many
active backends, or most backends are querying cached data?

I don't understand your point about querying multiple tables. Please
clarify why do you believe it makes prefetching less effective.

> read_stream, to end up getting just some mixed results. This time I 
> tried to step back and try to look at various functions that could
> have changed. Initially I tried  compiler function instrumentation,
> but then the profiling overhead of 33k functions dominated.
> 
> 
> This time what I did (1) added a indexscan_prefetch_distance, maybe 
> a better name would be just prefetch_distance, it limits the growth 
> of distance in read_stream (distance-limit.diff). (2) captured 
> execution statistics for 15 functions (profiling-
> instrumentation.diff). At exit each process will create a log with
> its configuration and call statistics.
> 

Nont sure what you're trying to archive by this instrumentation, and why
you didn't just do profiling using perf. Adding custom code to hot paths
is going to affect L1 cache hit ratios, for example.

Likewise, not sure what's the point of indexscan_prefetch_distance. What
are you trying to achieve by this? It likely maximizes the overhead by
forcing tiny I/O requests, which is the whole point why we allow the
distance to grow.

> 
> The benchmark was with full index scan on a sequential column,
> executed repeatedly and no cache eviction: buffer hit path.
> 
> 
> BENCHMARK RESULTS
> 
> 

I don't know what "in normal use" means. I don't have access to a MacOS
machine to figure out the filesystems behave under load, etc.

It'd be helpful if you could investigate that on that system. We don't
even know what exact hardware it has.

BTW is this with the custom instrumentation or not? How expensive is
clock_gettime on MacOS?

> The linux execution presented very little degradation. On MacOS host
the degradation was more noticeable than on MacOS docker running a
debian, suggesting that software ecosystem contributes, docker on MacOS
(arm), was slower than docker on a native linux (x86_64), here I could
be it is CPU architecture or OS kernel differences.

No idea. You're the only one who has access to the system and can
investigate what's happening.

Are all the tests running on the same hardware? Or is MacOS on one
system and Linux on a different system?

> 
> 
> WHAT CHANGED
> 
> The benchmark will produced, 195 autovac_worker, and 3293 backend 
> and one bgworker log. For prefetch off the number of calls is 
> constant. For prefetch on they vary widely, but I am looking at the 
> total time per function, assuming that the differences in the number 
> of calls changes only how the work was partitioned but the final
> work was the same.
>

I don't understand. Number of what calls varies widely?

I'm not sure elapsed time (per wall-time) is particularly interesting.

> Function                  off,d=0 on,d=128     Diff        % z-statistic

No idea. There's almost no information about the configuration, so I
can't even try reproducing this on a Linux.

regards

-- 
Tomas Vondra

---

From: Peter Geoghegan <[email-masked]>
Date: Fri, 27 Feb 2026 15:11:24 -0500

> If I disable the yield logic, the fillfactor=90 case is good:

I can recreate your results. Including the part where you found that
the problem would go away once yields were completely disabled.

I can certainly understand why you're suspicious of the yielding
mechanism. I wonder if I gave undue weight to the merge join query I
showed you [1] (and one or two others like it). Declaring that the
underlying merge join/yielding issue is not worth the complexity
required to yield would certainly be convenient. Yielding *isn't*
helpful for the vast majority of individual queries, so I'm certainly
tempted. But I can't help but feel nervous about the large disparity
in the number of *index* pages read by that particular query, once the
yielding mechanism is disabled.

Just in case there's any doubt: I'm flexible about whether a yielding
mechanism is needed and how it should work. Ideally we can come up
with a design that gives us the best of all possible worlds -- but
everything is on the table. It's not that I'm attached to the idea of
yielding; I'm just nervous about one or two funny looking cases [1].

With that being said, it seems as if yielding isn't the only factor in
play here. I also notice that even master exhibits roughly the same
performance disparity (also while using direct I/O, though with
shared_buffers set to 16GB rather than your 2GB):

=================================
EXPLAIN OUTPUT (best run, master)
=================================

--- Fillfactor 90 ---
  Index Scan using pgbench_accounts_ff90_pkey on pgbench_accounts_ff90
    Index Searches: 1
    Buffers: shared hit=27325 read=181819
    I/O Timings: shared read=16822.256
  Planning Time: 0.035 ms
  Execution Time: 18048.198 ms

--- Fillfactor 50 ---
  Index Scan using pgbench_accounts_ff50_pkey on pgbench_accounts_ff50
    Index Searches: 1
    Buffers: shared hit=27325 read=333334
    I/O Timings: shared read=30685.965
  Planning Time: 0.028 ms
  Execution Time: 32005.962 ms

--- Fillfactor 25 ---
  Index Scan using pgbench_accounts_ff25_pkey on pgbench_accounts_ff25
    Index Searches: 1
    Buffers: shared hit=27325 read=666667
    I/O Timings: shared read=10278.124
  Planning Time: 0.034 ms
  Execution Time: 11796.573 ms

While fillfactor 90 is fastest, fillfactor 25 is almost 3x faster than
fillfactor 50, despite performing about twice as many reads. I have to
imagine this relates to my Samsung 980 Pro SSD performing its own
read-ahead, in a way that works inconsistently across workloads.

Note again that this effect with master only appears when
shared_buffers is set to 16GB. With your 2GB shared_buffers setting,
master takes 17930.381 ms for FF 90, 31822.473 ms for FF 50, and
61094.676 ms for FF 25 (which is at least consistent-ish in the way
that one would expect).

For context, here is how the patch compares to master with
shared_buffers=16GB (here master uses the same query execution/query
plans as those shown above) once the patch/Pfetch's yielding is
disabled:

  FF  Heap Pages      Master   Pfetch ON   ON/Master
----------------------------------------------------
  90      181819     18048.2      1465.0       0.081x
  50      333334     32006.0      1682.2       0.053x
  25      666667     11796.6      1928.4       0.163x

I also noticed that the patch isn't at all sensitive to whether
shared_buffers is set to 2GB or 16GB -- not once yielding is disabled
like this. Obviously that insensitivity is desirable, which argues for
removing yielding.

For context, with standard v11/with yielding enabled, the "2GB vs
16GB" matters to quite a surprising degree:

====================================
Patch + yielding, 2GB shared_buffers
====================================

  FF  Heap Pages   Pfetch ON
------------------------------
  90      181819      4276.5
  50      333334      1523.3
  25      666667      6805.7

=====================================
Patch + yielding, 16GB shared_buffers
=====================================

  FF  Heap Pages   Pfetch ON
------------------------------
  90      181819      4384.6
  50      333334      1683.2
  25      666667      2002.0

Notice that with 16GB shared_buffers, the perverse effect from
yielding is even more pronounced!

I'm not sure how relevant this later point about "shared_buffers
sensitivity with yielding" really is. Nor am I sure if the effect with
master (and the possible role of device-level readahead) is all that
significant. I'm pointing all of this out in the hope that you can
offer an explanation that'll help me to improve my own intutions about
this stuff.

[1] https://postgr.es/m/CAH2-Wzk-89uCvdJ1Q6NsM6LvDvUEt6Qy66T6A60J=[email-masked]
--
Peter Geoghegan

---

From: Andres Freund <[email-masked]>
Date: Fri, 27 Feb 2026 18:52:09 -0500

Hi,

On 2026-02-24 13:13:25 -0500, Peter Geoghegan wrote:
> From bf097582e730c0f0e7c048139d5f30c2971af94c Mon Sep 17 00:00:00 2001
> From: Peter Geoghegan <[email-masked]>
> Date: Sun, 18 Jan 2026 11:18:11 -0500
> Subject: [PATCH v11 01/12] Extract fake LSN infrastructure from GiST index AM.
>
> Extract utility functions used by GiST to generate fake LSNs so that
> other index AMs can reuse this infrastructure to generate fake LSNs.
>
> Preparation for an upcoming commit that will change the rules around
> holding on to buffer pins on leaf pages in unlogged nbtree indexes
> (actually, in all cases barring scans that use a non-MVCC snapshot).
> This is the patch that will add the new amgetbatch interface.  Another
> preparatory commit will add fake LSN support to nbtree ahead of the
> amgetbatch commit.
>
> Bump XLOG_PAGE_MAGIC due to XLOG_GIST_ASSIGN_LSN becoming
> XLOG_ASSIGN_LSN.

> +/*
> + * XLogGetFakeLSN - get a fake LSN for an index page that isn't WAL-logged.
> + *
> + * Some index AMs (nbtree, hash, GiST) use LSNs to detect concurrent page
> + * modifications, but not all index pages are WAL-logged.  This function
> + * provides a sequence of fake LSNs for that purpose.
> + *
> + * The behavior depends on the relation's persistence:
> + *
> + * - For temporary relations, we use a simple backend-local counter since
> + *   temporary relations are only accessible within our session.
> + *
> + * - For permanent relations when WAL-logging is disabled (e.g., during index
> + *   creation with wal_level=minimal), we use the current WAL insert position.
> + *   If the insert position hasn't advanced since the last call, we emit a
> + *   dummy WAL record via XLogAssignLSN() to ensure we get a distinct LSN.
> + *
> + * - For unlogged relations, we use the global fake LSN counter maintained
> + *   by GetFakeLSNForUnloggedRel().
> + */
> +XLogRecPtr
> +XLogGetFakeLSN(Relation rel)
> +{
> +	if (rel->rd_rel->relpersistence == RELPERSISTENCE_TEMP)
> +	{
> +		/*
> +		 * Temporary relations are only accessible in our session, so a simple
> +		 * backend-local counter will do.
> +		 */
> +		static XLogRecPtr counter = FirstNormalUnloggedLSN;
> +
> +		return counter++;
> +	}
> +	else if (RelationIsPermanent(rel))
> +	{
> +		/*
> +		 * WAL-logging on this relation will start after commit, so its LSNs
> +		 * must be distinct numbers smaller than the LSN at the next commit.
> +		 * Emit a dummy WAL record if insert-LSN hasn't advanced after the
> +		 * last call.
> +		 */
> +		static XLogRecPtr lastlsn = InvalidXLogRecPtr;
> +		XLogRecPtr	currlsn = GetXLogInsertRecPtr();
> +
> +		/* Shouldn't be called for WAL-logging relations */
> +		Assert(!RelationNeedsWAL(rel));
> +
> +		/* No need for an actual record if we already have a distinct LSN */
> +		if (XLogRecPtrIsValid(lastlsn) && lastlsn == currlsn)
> +			currlsn = XLogAssignLSN();
> +
> +		lastlsn = currlsn;
> +		return currlsn;
> +	}

I know you just moved this code, but isn't it kinda bogus to use if
(RelationIsPermanent()) here?  If the fake LSN is used on a durable page, it
wouldn't be safe to just use XLogAssignLSN(), as it'd not emit an FPI for the
page.

All the gist callers use if (!RelationNeedsWAL()) to protect calls to this, so
it's not a bug, but still seems somewhat odd.

I guess there is the "Assert(!RelationNeedsWAL(rel));", but at least the
comments of the function should make clear that this should only be called if
!RelationNeedsWAL().

> diff --git a/src/backend/storage/buffer/bufmgr.c b/src/backend/storage/buffer/bufmgr.c
> index d1babaff0..a7a569c99 100644
> --- a/src/backend/storage/buffer/bufmgr.c
> +++ b/src/backend/storage/buffer/bufmgr.c
> @@ -4470,13 +4470,13 @@ FlushBuffer(BufferDesc *buf, SMgrRelation reln, IOObject io_object,
>  	 * lost after a crash anyway.  Most unlogged relation pages do not bear
>  	 * LSNs since we never emit WAL records for them, and therefore flushing
>  	 * up through the buffer LSN would be useless, but harmless.  However,
> -	 * GiST indexes use LSNs internally to track page-splits, and therefore
> -	 * unlogged GiST pages bear "fake" LSNs generated by
> -	 * GetFakeLSNForUnloggedRel.  It is unlikely but possible that the fake
> -	 * LSN counter could advance past the WAL insertion point; and if it did
> -	 * happen, attempting to flush WAL through that location would fail, with
> -	 * disastrous system-wide consequences.  To make sure that can't happen,
> -	 * skip the flush if the buffer isn't permanent.
> +	 * some index AMs (nbtree, hash, GiST) use LSNs internally to detect
> +	 * concurrent page modifications, and therefore unlogged index pages bear
> +	 * "fake" LSNs generated by XLogGetFakeLSN.  It is unlikely but possible
> +	 * that the fake LSN counter could advance past the WAL insertion point;
> +	 * and if it did happen, attempting to flush WAL through that location
> +	 * would fail, with disastrous system-wide consequences.  To make sure
> +	 * that can't happen, skip the flush if the buffer isn't permanent.
>  	 */
>  	if (buf_state & BM_PERMANENT)
>  		XLogFlush(recptr);

I'd probably not list the AMs here, because as of this commit, it'd not be
true yet.

Other than these points, I think it's probably worth pushing this soon?

BufferGetLSNAtomic()'s comment about not needing a lock if hint bits aren't
used looks a bit bogus to me with this use-case - it makes it sound like we
don't rely on the correctness of the return value if hint bits aren't
enabled. But that's bogus, we do.  The relevant difference afaict is that
without WAL logging of hint bits we will never set the LSN of a page that is
read locked, and therefore any caller that has at least a read lock doesn't
need GetLSNAtomic() unless XLogHintBitIsNeeded().

> From 64fae29f3b98219a8f32246b2adf103ee264f4d5 Mon Sep 17 00:00:00 2001
> From: Peter Geoghegan <[email-masked]>
> Date: Sun, 18 Jan 2026 11:14:36 -0500
> Subject: [PATCH v11 02/12] Use fake LSNs to improve nbtree dropPin behavior.
>
> Previously unlogged nbtree indexes needed to hold on to a leaf page
> buffer pin when stopped on that leaf page, purely so that the
> _bt_killitems process had a way to be sure that there wasn't any unsafe
> concurrent TID recycling by VACUUM.  The _bt_killitems' dropPin strategy
> couldn't be used before now, since it works by checking if the page LSN
> has changed in the period after _bt_readpage read the page's items, but
> before _bt_killitems was called.  We now use the same LSN trick with
> unlogged indexes, bringing the same benefits to these scans that commit
> 2ed5b87f brought to scans of logged relations.
>
> This is preparation for an upcoming commit that will add the amgetbatch
> interface and switch nbtree over to it (from amgettuple).  That will go
> further by completely obviating the need for amgetbatch scans to hang on
> to buffer pins (barring scans involving a non-MVCC snapshot).
>
> Author: Peter Geoghegan <[email-masked]>
> Discussion: https://postgr.es/m/[email-masked]
> ---
>  src/backend/access/nbtree/README      |  5 +-
>  src/backend/access/nbtree/nbtdedup.c  |  8 ++-
>  src/backend/access/nbtree/nbtinsert.c | 48 +++++++++-------
>  src/backend/access/nbtree/nbtpage.c   | 82 +++++++++++++++------------
>  src/backend/access/nbtree/nbtree.c    |  8 ---
>  src/backend/access/nbtree/nbtsearch.c |  1 -
>  src/backend/access/nbtree/nbtutils.c  |  1 -
>  7 files changed, 80 insertions(+), 73 deletions(-)
>
> diff --git a/src/backend/access/nbtree/README b/src/backend/access/nbtree/README
> index 53d4a61dc..cb921ca2e 100644
> --- a/src/backend/access/nbtree/README
> +++ b/src/backend/access/nbtree/README
> @@ -485,9 +485,8 @@ We handle this kill_prior_tuple race condition by having affected index
>  scans conservatively assume that any change to the leaf page at all
>  implies that it was reached by btbulkdelete in the interim period when no
>  buffer pin was held.  This is implemented by not setting any LP_DEAD bits
> -on the leaf page at all when the page's LSN has changed.  (That won't work
> -with an unlogged index, so for now we don't ever apply the "don't hold
> -onto pin" optimization there.)
> +on the leaf page at all when the page's LSN has changed.  (This is why we
> +implement "fake" LSNs for unlogged index relations.)
>

Does the reference to btbulkdelete actually cover all the relevant cases?
Seems like a e.g. a concurrent _bt_delete_or_dedup_one_page() would also be a
problem?

One thing that's somewhat sad about this logic is that the
		latestlsn = BufferGetLSNAtomic(buf);
		Assert(so->currPos.lsn <= latestlsn);
		if (so->currPos.lsn != latestlsn)

check will trigger even if all the changes were done due to hint bit
FPIs. E.g. due to another backend hinting other index entries on the same page
as dirty... But anyway, that's not the "fault" of your change.

(sorry, that's just me trying to understand the code to be able to review)

> @@ -2720,25 +2730,25 @@ _bt_unlink_halfdead_page(Relation rel, Buffer leafbuf, BlockNumber scanblkno,
>  			xlinfo = XLOG_BTREE_UNLINK_PAGE;
>
>  		recptr = XLogInsert(RM_BTREE_ID, xlinfo);
> +	}
> +	else
> +		recptr = XLogGetFakeLSN(rel);
>
> -		if (BufferIsValid(metabuf))
> -		{
> -			PageSetLSN(metapg, recptr);
> -		}
> -		page = BufferGetPage(rbuf);
> +	if (BufferIsValid(metabuf))
> +		PageSetLSN(metapg, recptr);
> +	page = BufferGetPage(rbuf);
> +	PageSetLSN(page, recptr);
> +	page = BufferGetPage(buf);
> +	PageSetLSN(page, recptr);
> +	if (BufferIsValid(lbuf))
> +	{
> +		page = BufferGetPage(lbuf);
>  		PageSetLSN(page, recptr);
> -		page = BufferGetPage(buf);
> +	}
> +	if (target != leafblkno)
> +	{
> +		page = BufferGetPage(leafbuf);
>  		PageSetLSN(page, recptr);
> -		if (BufferIsValid(lbuf))
> -		{
> -			page = BufferGetPage(lbuf);
> -			PageSetLSN(page, recptr);
> -		}
> -		if (target != leafblkno)
> -		{
> -			page = BufferGetPage(leafbuf);
> -			PageSetLSN(page, recptr);
> -		}
>  	}
>
>  	END_CRIT_SECTION();

This hunk ends up a bit more confusing because of removing the curlies around
PageSetLSN(metapg, recptr), but I also see why you removed them...

> diff --git a/src/backend/access/nbtree/nbtsearch.c b/src/backend/access/nbtree/nbtsearch.c
> index 32ae0bda8..0ced7b02c 100644
> --- a/src/backend/access/nbtree/nbtsearch.c
> +++ b/src/backend/access/nbtree/nbtsearch.c
> @@ -67,7 +67,6 @@ _bt_drop_lock_and_maybe_pin(Relation rel, BTScanOpaque so)
>  	 * Have to set so->currPos.lsn so that _bt_killitems has a way to detect
>  	 * when concurrent heap TID recycling by VACUUM might have taken place.
>  	 */
> -	Assert(RelationNeedsWAL(rel));
>  	so->currPos.lsn = BufferGetLSNAtomic(so->currPos.buf);
>  	_bt_relbuf(rel, so->currPos.buf);
>  	so->currPos.buf = InvalidBuffer;

This is somewhat orthogonal and just quick idea:

I wonder if the worry about the overhead of BufferGetLSNAtomic() could be
addressed by adding a ReleaseBuffer() version that returns the current LSN.

Have you done any testing to see if the added BufferGetLSNAtomic() calls
introduce a measurable overhead when using unlogged tables?  If so, could the
BufferGetLSNAtomic() be skipped if there currently the scan doesn't currently
have any killed items?

> From c28cb1f517f458fc8976081bb52ca7310d3fff0a Mon Sep 17 00:00:00 2001
> From: Peter Geoghegan <[email-masked]>
> Date: Tue, 9 Sep 2025 19:50:03 -0400
> Subject: [PATCH v11 03/12] Add interfaces that enable index prefetching.
>
> Add a new amgetbatch index AM interface that allows index access methods
> to implement plain/ordered index scans that return index entries in
> batches comprising all matching items from an index page, rather than
> one match at a time.
>
> This commit also adds a new table AM interface callback, called by the
> core executor through the new table_index_getnext_slot shim function.
> This allows the table AM to directly manage the progress of index scans
> rather than having individual TIDs passed in by the caller one by one.
> The amgetbatch interface is tightly coupled with the new approach to
> index scans added to the table AM.  The table AM can apply knowledge of
> which TIDs will be returned to the scan in the near future to perform
> I/O prefetching.  Prefetching will be added by an upcoming commit.
>
> Index access methods that support plain index scans must now implement
> either the amgetbatch interface OR the amgettuple interface.  The
> amgettuple interface will still be used by index AMs that require direct
> control over the progress of index scans (e.g., GiST with KNN ordered
> scans).  Almost all existing callers that perform index scans now use
> the new table_index_getnext_slot interface, regardless of whether the
> underlying index AM uses amgetbatch or amgettuple.
>
> The amgetbatch interface returns batches that hold a buffer pin on an
> index page that can be used by the table AM as an interlock against
> concurrent TID recycling by VACUUM.  Now heapam only needs to hold on to
> such a pin for an instant -- except during scans that use a non-MVCC
> snapshot.  Non-MVCC scans continue to need to hold the pin until all of
> the batch's TIDs have been fetched from the heap.
>
> This extends the dropPin mechanism added to nbtree by commit 2ed5b87f,
> and generalizes it to work with all index AMs that support the new
> amgetbatch interface.  We can always safely drop index page pins
> eagerly, provided the scan uses an MVCC snapshot (unlike the nbtree
> dropPin optimization, which had a couple of additional restrictions).
>
> An upcoming commit that will add index prefetching will use a read
> stream to read heap pages during index scans.  Read stream is careful to
> limit how many things it pins, lest we run into problems due to having
> too many buffers pinned.  Simply never holding on to index page buffer
> pins greatly simplifies resource management for index prefetching;
> there's no risk of unintended interactions between the read stream and
> index AM.  The only downside is that we cannot support prefetching
> during scans that use a non-MVCC snapshot, which seems quite acceptable.
>
> In practice, heapam doesn't drop each batch's index page buffer pin at
> the earliest opportunity during index-only scans.  This was deemed
> necessary to avoid regressing index-only scans with a LIMIT, in
> particular with nestloop anti-joins and nestloop semi-joins; eagerly
> loading all the visibility information up front regressed such queries.
> The new amgetbatch interface gives table AMs the authority to decide
> when and where to drop index page pins, so this can be considered a
> heapam implementation detail (index AMs don't need to know about it).
> This scheme still allows index prefetching to consistently hold no more
> than one batch index page pin at a time, even when an index-only scan
> (that must perform some heap fetches) holds open several index batches
> at once in order to maintain an adequate prefetch distance.
> ...
>  47 files changed, 2413 insertions(+), 1440 deletions(-)

This is a huge change. Is there a chance we can break it up into more
manageable chunks?

A first note: Seems like this needs to update doc/src/sgml/indexam.sgml?

> +/*
> + * Location of a BatchMatchingItem that appears in a IndexScanBatch returned
> + * by (and subsequently passed to) an amgetbatch routine
> + */
> +typedef struct BatchRingItemPos
> +{
> +	/* Position references a valid BatchRingBuffer.batches[] entry? */
> +	bool		valid;
> +
> +	/* BatchRingBuffer.batches[]-wise index to relevant IndexScanBatch */
> +	uint8		batch;
> +
> +	/* IndexScanBatch.items[]-wise index to relevant BatchMatchingItem */
> +	int			item;
> +} BatchRingItemPos;

Does item actually need to be a 32bit int?  This way there's a hole and
presumably IndexScanBatch.items[] has a limited size?

Even if, any reason for it to not be unsigned?

> +/*
> + * Matching item returned by amgetbatch (in returned IndexScanBatch) during an
> + * index scan.  Used by table AM to locate relevant matching table tuple.
> + */
> +typedef struct BatchMatchingItem
> +{
> +	ItemPointerData heapTid;	/* TID of referenced heap item */

If we're generalizing this, I'd be inclined to rename it away from heap to
table.

> +	OffsetNumber indexOffset;	/* index item's location within page */
> +	LocationIndex tupleOffset;	/* IndexTuple's offset in workspace, if any */
> +} BatchMatchingItem;

> +/*
> + * Data about one batch of items returned by (and passed to) amgetbatch during
> + * index scans
> + */
> +typedef struct IndexScanBatchData
> +{
> +	/*
> +	 * Information output by amgetbatch index AMs upon returning a batch with
> +	 * one or more matching items, describing details of the index page where
> +	 * matches were located.
> +	 *
> +	 * Used in the next amgetbatch call to determine which index page to read
> +	 * next (or to determine if there's no further matches in current scan
> +	 * direction).
> +	 */
> +	BlockNumber currPage;		/* Index page with matching items */
> +	BlockNumber prevPage;		/* currPage's left link */
> +	BlockNumber nextPage;		/* currPage's right link */

Hm. Are these, and some of the later fields, generic enough for all index
types? There IIRC are index types out there that don't use buffers or blocks
in that sense.

I wonder if individual index AMs need a way to influence what is stored per
batch.  You could e.g. imagine something like this:

typedef struct BtreeIndexScanBatchData
{
    BlockNumber currPage;
    BlockNumber prevPage;
    ...

    IndexScanBatchData common;
} BtreeIndexScanBatchData;

and have the callers to indexam_util_batch_alloc() provide how much space
before BtreeIndexScanBatchData is needed (or have that be a property in the
IndexAmRoutine). That way the efficient FLEXIBLE_ARRAY_MEMBER addressing for
items could be kept.

> +	Buffer		buf;			/* currPage buf (invalid means unpinned) */
> +	XLogRecPtr	lsn;			/* currPage's LSN */
> +
> +	/* scan direction when the index page was read */
> +	ScanDirection dir;
> +
> +	/*
> +	 * knownEndLeft and knownEndRight are used by table AMs to track whether
> +	 * there may be matching index entries to the left and right of currPage,
> +	 * respectively.  This helps them to avoid repeatedly calling amgetbatch.
> +	 */
> +	bool		knownEndLeft;
> +	bool		knownEndRight;

If I understand correctly, these mean that that we'd reach the end of the scan
in one direction?  If so, why does it use left/right instead of
forward/backward?

> +	/*
> +	 * Matching items state for this batch.  Output by index AM for table AM.
> +	 *
> +	 * The items array is always ordered in index order (ie, increasing
> +	 * indexoffset).  When scanning backwards it is convenient for index AMs
> +	 * to fill the array back-to-front, so we start at the last slot and fill
> +	 * downwards.  Hence they need a first-valid-entry and a last-valid-entry
> +	 * counter.
> +	 */
> +	int			firstItem;		/* first valid index in items[] */
> +	int			lastItem;		/* last valid index in items[] */

I'd make these unsigned.

Not sure what "indexoffset" really means in a generic abstraction.

> +	/* info about killed items if any (killedItems is NULL if never used) */
> +	int			numKilled;		/* number of currently stored items */
> +	int		   *killedItems;	/* indexes of killed items */

Signedness again.

I'd specify what is being indexed by killedItems.

What size is killedItems?

I wonder if "killed" is really the right term in the generic code, it's
unclear whether it refers to table or index items. And the past tense is a bit
odd too, since nothing may yet been "killed".

> +	/*
> +	 * If we are doing an index-only scan, this array stores the per-item
> +	 * visibility flags BATCH_VIS_CHECKED and BATCH_VIS_ALL_VISIBLE
> +	 */
> +	uint8	   *visInfo;		/* per-item visibility flags, or NULL */

Why just for index only?  Seems like it'd be rather useful to batch visibility
checks for plain index scans too?  Obviously that doesn't have to be
implemented immediately, but I'd probably not preclude it in generic
infrastructure.

This seems pretty heap specific.  I guess you didn't put it in
IndexFetchHeapData because it'd be somewhat complicated to per-batch state in
there?

Perhaps my earlier suggestion about having "private" per-batch state for the
indexam is required for table AMs too.  I guess we could store an offset to
the "tableam private" and "indexam private" data in each batch.

> +	/*
> +	 * If we are doing an index-only scan, this is the tuple storage workspace
> +	 * for the matching tuples (tuples referenced by items[]).  It is of size
> +	 * BLCKSZ, so it can hold as much as a full page's worth of tuples.
> +	 * currTuples points into the trailing portion of this allocation, just
> +	 * past items[].  It is NULL for plain index scans.
> +	 */

This was a bit hard to understand before, but in generic code it seems like it
is too specific to the way it was used in nbtree.

> +	char	   *currTuples;		/* tuple storage for items[] */
> +	BatchMatchingItem items[FLEXIBLE_ARRAY_MEMBER]; /* matching items */
> +} IndexScanBatchData;
> +
> +typedef struct IndexScanBatchData *IndexScanBatch;

I wish we would just stop doing this stupid pointer hiding game.

> +
> +/*
> + * Maximum number of batches (leaf pages) we can keep in memory.  We need a
> + * minimum of two, since we'll only consider releasing one batch when another
> + * is read.
> + */
> +#define INDEX_SCAN_MAX_BATCHES		2
> +#define INDEX_SCAN_CACHE_BATCHES	2

Hm. These limits are increased in a later patch adding prefetching to
heapam. I guess you kept it low until then to avoid some overhead?  Perhaps
that's indicative that hardcoding it isn't great?

> diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h
> index 119593b7b..8611b03e5 100644
> --- a/src/include/access/tableam.h
> +++ b/src/include/access/tableam.h
> @@ -433,11 +433,29 @@ typedef struct TableAmRoutine
>  	 */
>  	void		(*index_fetch_end) (struct IndexFetchTableData *data);
>
> +	/*
> +	 * Fetch the next tuple from an index scan into slot, scanning in the
> +	 * specified direction, and return true if a tuple was found, false
> +	 * otherwise.
> +	 *
> +	 * This callback allows the table AM to directly manage the scan process,
> +	 * including interfacing with the index AM. The caller simply specifies
> +	 * the direction of the scan; the table AM takes care of retrieving TIDs
> +	 * from the index, performing visibility checks, and returning tuples in
> +	 * the slot.
> +	 */
> +	bool		(*index_getnext_slot) (IndexScanDesc scan,
> +									   ScanDirection direction,
> +									   TupleTableSlot *slot);

Hm, it's somewhat confusing that we have this now, but still have
->index_fetch_tuple() etc.

> diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
> index 63c067d5a..7c1b427fb 100644
> --- a/src/include/nodes/execnodes.h
> +++ b/src/include/nodes/execnodes.h
> @@ -1723,7 +1723,7 @@ typedef struct IndexScanState
>  	ExprContext *iss_RuntimeContext;
>  	Relation	iss_RelationDesc;
>  	struct IndexScanDescData *iss_ScanDesc;
> -	IndexScanInstrumentation iss_Instrument;
> +	IndexScanInstrumentation *iss_Instrument;
>  	SharedIndexScanInstrumentation *iss_SharedInfo;

Don't really have an opinion about this, but it's not clear to me why this
patch changed this?

If we want to change it, could we do that in a prereq patch?

> @@ -124,23 +139,29 @@ heapam_index_fetch_tuple(struct IndexFetchTableData *scan,
>
>  	Assert(TTS_IS_BUFFERTUPLE(slot));
>
> -	/* We can skip the buffer-switching logic if we're in mid-HOT chain. */
> -	if (!*call_again)
> +	/* We can skip the buffer-switching logic if we're on the same page. */
> +	if (hscan->xs_blk != ItemPointerGetBlockNumber(tid))
>  	{
> -		/* Switch to correct buffer if we don't have it already */
> -		Buffer		prev_buf = hscan->xs_cbuf;
> +		Assert(!*call_again);
>
> -		hscan->xs_cbuf = ReleaseAndReadBuffer(hscan->xs_cbuf,
> -											  hscan->xs_base.rel,
> -											  ItemPointerGetBlockNumber(tid));
> +		/* Remember this buffer's block number for next time */
> +		hscan->xs_blk = ItemPointerGetBlockNumber(tid);
> +
> +		if (BufferIsValid(hscan->xs_cbuf))
> +			ReleaseBuffer(hscan->xs_cbuf);
> +
> +		hscan->xs_cbuf = ReadBuffer(hscan->xs_base.rel, hscan->xs_blk);
>
>  		/*
> -		 * Prune page, but only if we weren't already on this page
> +		 * Prune page when it is pinned for the first time
>  		 */
> -		if (prev_buf != hscan->xs_cbuf)
> -			heap_page_prune_opt(hscan->xs_base.rel, hscan->xs_cbuf);
> +		heap_page_prune_opt(hscan->xs_base.rel, hscan->xs_cbuf);
>  	}
>
> +	Assert(BufferIsValid(hscan->xs_cbuf));
> +	Assert(BufferGetBlockNumber(hscan->xs_cbuf) == hscan->xs_blk);
> +	Assert(hscan->xs_blk == ItemPointerGetBlockNumber(tid));
> +
>  	/* Obtain share-lock on the buffer so we can examine visibility */
>  	LockBuffer(hscan->xs_cbuf, BUFFER_LOCK_SHARE);
>  	got_heap_tuple = heap_hot_search_buffer(tid,

This seems independent too. Let's just introduce xs_blk and do this change
separately? The we can get it out of the way?

> + * heapam_batch_resolve_visibility
> + *		Obtain visibility information for a TID from caller's batch.
> + *
> + * Called during index-only scans.  We always check the visibility of caller's
> + * item (an offset into caller's batch->items[] array).  We might also set
> + * visibility info for other items from caller's batch more proactively when
> + * that makes sense.
> + *
> + * We keep two competing considerations in balance when determining whether to
> + * check additional items: the need to keep the cost of visibility map access
> + * under control when most items will never be returned by the scan anyway
> + * (important for inner index scans of anti-joins and semi-joins), and the
> + * need to not hold onto index leaf pages for too long.
> + *
> + * Note on Memory Ordering Effects
> + * -------------------------------
> + *
> + * visibilitymap_get_status does not lock the visibility map buffer, and
> + * therefore the result we read here could be slightly stale.  However, it
> + * can't be stale enough to matter.
> + *
> + * We need to detect clearing a VM bit due to an insert right away, because
> + * the tuple is present in the index page but not visible.  The reading of the
> + * TID by this scan (using a shared lock on the index buffer) is serialized
> + * with the insert of the TID into the index (using an exclusive lock on the
> + * index buffer).  Because the VM bit is cleared before updating the index,
> + * and locking/unlocking of the index page acts as a full memory barrier, we
> + * are sure to see the cleared bit if we see a recently-inserted TID.

I don't think the index page locking on the inserter side relevant - that side
of the memory barrier paring is provided solely by visibilitymap_set(), which
uses an exclusive lock and thus also provides a barrier (so does the heap
buffer locking).

The relevant index locking would be the lock on the index page during *lookup*
leading to the call to heapam_batch_resolve_visibility().  But I guess there
could be an indexam that doesn't use locking? Seems unlikely, but ...

> + * Deletes do not update the index page (only VACUUM will clear out the TID),

Well, not just, right? We can only prune when inserting into the index?

> + * so the clearing of the VM bit by a delete is not serialized with this test
> + * below, and we may see a value that is significantly stale.  However, we
> + * don't care about the delete right away, because the tuple is still visible
> + * until the deleting transaction commits or the statement ends (if it's our
> + * transaction).  In either case, the lock on the VM buffer will have been
> + * released (acting as a write barrier) after clearing the bit.  And for us to
> + * have a snapshot that includes the deleting transaction (making the tuple
> + * invisible), we must have acquired ProcArrayLock after that time, acting as
> + * a read barrier.
> + *
> + * It's worth going through this complexity to avoid needing to lock the VM
> + * buffer, which could cause significant contention.
> + */

If we are concerned about read side ordering, it seems a memory barrier on the
read side would suffice (and would not cause contention).

There's a much more recent memory barrier already than the ProcArrayLock
during snapshot acquisition - the pinning of the visibilitymap is a barrier.

Which is good, because we should really remove the ProcArrayLock acquisition
when we are able to reuse snapshots... And it'd be nasty if that introduced a
hard to hit memory ordering issue.

> +static void
> +heapam_batch_resolve_visibility(IndexScanDesc scan, IndexScanBatch batch,
> +								BatchRingItemPos *pos)
> +{

FWIW, for me this is not inlined into the caller, which means that there's a
function call even for the case where the visibility information would already
be cached.

> +	IndexFetchHeapData *hscan = (IndexFetchHeapData *) scan->xs_heapfetch;
> +	int			posItem = pos->item;
> +	int			noSetItem,
> +				step;
> +	bool		allbatchitemvisible;
> +
> +	/* Do nothing if we already resolved visibility for the item. */
> +	if (batch->visInfo[posItem] & BATCH_VIS_CHECKED)
> +		return;

I'd at least mark this branch likely, so that the compiler can see that it'd
make sense to move this check into the callsites. At least on gcc that seems
to suffice for the hot path to not need the function call.

Or you have a static inline wrapper that does the check and then calls the
bulk of heapam_batch_resolve_visibility() iff the cache isn't sufficient.

> +	/* We better still have a pin on batch's index page */
> +	Assert(BufferIsValid(batch->buf));
> +
> +	/* Determine the range of items to set visibility for */
> +	if (ScanDirectionIsForward(batch->dir))
> +	{
> +		noSetItem = Min(batch->lastItem + 1, posItem + hscan->xs_vm_items);
> +		allbatchitemvisible = noSetItem > batch->lastItem &&
> +			(posItem == batch->firstItem ||
> +			 (batch->visInfo[batch->firstItem] & BATCH_VIS_CHECKED));
> +		step = 1;
> +	}
> +	else
> +	{
> +		noSetItem = Max(batch->firstItem - 1, posItem - hscan->xs_vm_items);
> +		allbatchitemvisible = noSetItem < batch->firstItem &&
> +			(posItem == batch->lastItem ||
> +			 (batch->visInfo[batch->lastItem] & BATCH_VIS_CHECKED));
> +		step = -1;
> +	}
> +
> +	/*
> +	 * Set visibility info for a range of items, in scan order.
> +	 *
> +	 * noSetItem is the first item (in the given scan direction) that won't be
> +	 * set during this call.  noSetItem often points to just past the end of
> +	 * (or just before the start of) the batch's 'items' array.
> +	 *
> +	 * We iterate this way to avoid the need for 2 direction-specific loops,
> +	 * since this is a hot code path that's sensitive to code size increases.
> +	 */
> +	for (int setItem = posItem; setItem != noSetItem; setItem += step)
> +	{
> +		ItemPointer tid = &batch->items[setItem].heapTid;
> +		uint8		flags = BATCH_VIS_CHECKED;
> +
> +		if (VM_ALL_VISIBLE(scan->heapRelation,
> +						   ItemPointerGetBlockNumber(tid),
> +						   &hscan->vmbuf))
> +			flags |= BATCH_VIS_ALL_VISIBLE;
> +
> +		batch->visInfo[setItem] = flags;
> +	}

Hm. It'll be pretty common for the block number of two consecutive items to be
on the same page.  The comments here suggest that the VM lookup is a relevant
performance factor, so why not check if the last VM_ALL_VISIBLE() was for the
same block?

While visibilitymap_get_status() isn't particularly expensive, it still is at
least two external function calls (visibilitymap_get_status() and
BufferGetBlockNumber()) and some nontrivial math.

Heh, indeed: A quick hacky implementation makes
  SELECT count(*) FROM pgbench_accounts
about 16% faster.

> +	/*
> +	 * It's safe to drop the batch's buffer pin as soon as we've resolved the
> +	 * visibility status of all of its items
> +	 */
> +	if (allbatchitemvisible && scan->MVCCScan)
> +	{
> +		Assert(batch->visInfo[batch->firstItem] & BATCH_VIS_CHECKED);
> +		Assert(batch->visInfo[batch->lastItem] & BATCH_VIS_CHECKED);
> +
> +		ReleaseBuffer(batch->buf);
> +		batch->buf = InvalidBuffer;
> +	}

It doesn't seem like it can be right that heapam releases an index buffer
directly here.  What, e.g., if the indexam requires multiple buffers to be
pinned for correctness?

> +/* ----------------
> + *		heapam_index_getnext_slot - get the next tuple from a scan
> + *
> + * The result is true if a tuple satisfying the scan keys and the snapshot was
> + * found, false otherwise.  The tuple is stored in the specified slot.
> + *
> + * On success, resources (like buffer pins) are likely to be held, and will be
> + * dropped by a future call here (or by a later call to index_endscan).
> + *
> + * Note: caller must check scan->xs_recheck, and perform rechecking of the
> + * scan keys if required.  We do not do that here because we don't have
> + * enough information to do it efficiently in the general case.
> + * ----------------
> + */
> +static pg_attribute_hot bool
> +heapam_index_getnext_slot(IndexScanDesc scan, ScanDirection direction,
> +						  TupleTableSlot *slot)
> +{
> +	IndexFetchHeapData *hscan = (IndexFetchHeapData *) scan->xs_heapfetch;
> +	ItemPointer tid = NULL;
> +
> +	for (;;)
> +	{
> +		if (!scan->xs_heap_continue)
> +		{
> +			/*
> +			 * Scans that use an amgetbatch index AM are managed by heapam's
> +			 * index scan manager.  This gives heapam the ability to read heap
> +			 * tuples in a flexible order that is attuned to both costs and
> +			 * benefits on the heapam and table AM side.
> +			 *
> +			 * Scans that use an amgettuple index AM simply call through to
> +			 * index_getnext_tid to get the next TID returned by index AM. The
> +			 * progress of the scan will be under the control of index AM (we
> +			 * just pass it through a direction to get the next tuple in), so
> +			 * we cannot reorder any work.
> +			 */
> +			if (scan->usebatchring)
> +				tid = heapam_batch_getnext_tid(scan, hscan, direction);
> +			else
> +			{

It might be worth putting this function into a static inline with a
usebatchring argument, and calling it with a constant true/false from the
top-level of heapam_index_getnext_slot().  Right now the code is a fair bit
less tight due to both branches being supported inside the loop.

Or even just storing scan->usebatchring in a local var (and passing it to
index_fetch_heap()), so the compiler at least can realize that it won't change
between iterations.

> +				tid = index_getnext_tid(scan, direction);
> +
> +				if (tid != NULL && scan->xs_want_itup)
> +					scan->xs_visible = VM_ALL_VISIBLE(scan->heapRelation,
> +													  ItemPointerGetBlockNumber(tid),
> +													  &hscan->vmbuf);

I'm a bit confused about the need for xs_visible (and also xs_heaptid, but
that's old).  Afaict it's just used to store very transient information that
could just be passed around upwards?

Storing and then shortly after reading data can lead to increased latency due
to store-forwarding. And reading from memory is more expensive than just doing
it via the register. And the compiler can't optimize the memory writes out in
this kinda code, because it won't be able to proof it's never read later.

> +			else
> +			{
> +				/*
> +				 * We didn't access the heap, so we'll need to take a
> +				 * predicate lock explicitly, as if we had.  For now we do
> +				 * that at page level.
> +				 */
> +				PredicateLockPage(hscan->xs_base.rel,
> +								  ItemPointerGetBlockNumber(tid),
> +								  scan->xs_snapshot);
> +			}

FWIW, this does show up in profiles here (partially due to overhead of the
call, partially due to it being annoyingly expensive to get the block from a
tid).  Seems we could amortise this from once-per-tuple to once-per-block for
the pretty common case of there being multiple tids for the same block in a
row?

> +			/*
> +			 * Return matching index tuple now set in scan->xs_itup (or return
> +			 * matching heap tuple now set in scan->xs_hitup).
> +			 *
> +			 * Note: we won't usually have fetched a heap tuple into caller's
> +			 * table slot.  This is per the table_index_getnext_slot contract
> +			 * for scan->xs_want_itup callers.
> +			 */
> +			return true;

That contract is ... not very clear to me.  table_index_getnext_slot() says:

 * Fetch the next tuple from an index scan into `slot`, scanning in the
 * specified direction. Returns true if a tuple was found, false otherwise.
 *
 * The index scan should have been started via table_index_fetch_begin().
 * Callers must check scan->xs_recheck and recheck scan keys if required.
 *
 * Index-only scan callers (that pass xs_want_itup=true to index_beginscan)
 * can consume index tuple results by examining IndexScanDescData fields such
 * as xs_itup and xs_hitup.  The table AM won't usually fetch a heap tuple
 * into the provided slot in the case of xs_want_itup=true callers.

I don't know what "won't usually" here really means and waht the caller is
supposed to do with that.

It's decidedly not free to store a tuple in a slot, so it seems a bit silly
that we do so unnecessarily if we have to verify tuple visibility.

(I didn't look too closely at further parts of the commit, as it seemed the
shape of the rest might change in response to earlier feedback).

> From 34ad62ef8f43550ccc2c0b2e2c41f30205d37716 Mon Sep 17 00:00:00 2001
> From: Peter Geoghegan <[email-masked]>
> Date: Thu, 12 Feb 2026 14:06:47 -0500
> Subject: [PATCH v11 04/12] Don't allocate _bt_search stack

IIC we've talked about this one for a while. Seems we could pull it ahead of
the larger 03?  Not pointlessly allocating memory only needed for modification
during read-only searching seems like a hard-to-argue about improvement.

> Subject: [PATCH v11 05/12] Limit get_actual_variable_range to scan one index
>  leaf page.

I skipped looking at this, as it seems like a separate axis. I have marked it
as something to look at in the other thread though.

> From 3fbfb87b6f3ca3c4246c1d3145b452b25f398421 Mon Sep 17 00:00:00 2001
> From: Thomas Munro <[email-masked]>
> Date: Tue, 13 Jan 2026 20:44:14 +0100
> Subject: [PATCH v11 06/12] Introduce read_stream_{pause,resume,yield}().

> Note: I (pgeoghegan) have extended read_stream_yield to support yielding
> that avoids affecting the io_combine_limit mechanism.

I doubt that goes far enough into fixing the problem with yielding
early. Submitting a pending read before yielding makes sense, but it doesn't
really help with actually issuing IO early enough to actually get benefits
from AIO.

> From d1c7743540df015ba6d1f189b5b87086437d8a04 Mon Sep 17 00:00:00 2001
> From: Peter Geoghegan <[email-masked]>
> Date: Sat, 15 Nov 2025 14:03:58 -0500
> Subject: [PATCH v11 07/12] Add heapam index scan I/O prefetching.
>
> This commit implements I/O prefetching for index scans (and index-only
> scans that require heap fetches). This was made possible by the recent
> addition of batching interfaces to both the table AM and index AM APIs.
>
> The amgetbatch index AM interface provides batches of matching TIDs
> (rather than one tuple at a time), each of which must be taken from
> index tuples that appear together on a single index page.  This allows
> multiple batches to be held open simultaneously.  Giving the table AM an
> explicit understanding of index AM concepts/index page boundaries allows
> it to consider all of the relevant costs and benefits.
>
> Prefetching is implemented using a prefetching position under the
> control of the table AM and core code.  This is closely related to the
> scan position added by commit FIXME, which introduced the amgetbatch
> interface.  A read stream callback advances the read stream as needed to
> provide sufficiently many heap block numbers to maintain the read
> stream's target prefetch distance.
>
> Testing has shown that index prefetching can make index scans much
> faster.  Large range scans that return many tuples can be as much as 35x
> faster.
>
> An important goal of the amgetbatch design is to enable the table AM's
> read stream callback to advance its prefetch position using TIDs that
> appear on a leaf page that's ahead of the current scan position's leaf
> page.  This is crucial with scans of indexes where each leaf page
> happens to have relatively few distinct heap blocks among its matching
> TIDs (as well as with scans with leaf pages that have relatively few
> total matching items).  Index scans can have as many as 64 open batches,
> which testing has shown to be about the maximum number that can ever be
> useful.  Batches are maintained in scan order using a simple ring buffer
> data structure.
>
> In rare cases where the scan exceeds this quasi-arbitrary limit of 64,
> the read stream is temporarily paused.  Prefetching (via the read
> stream) is resumed only after the scan position advances beyond its
> current open batch and then frees and removes the batch from the scan's
> batch ring buffer.  Testing has shown that it isn't very common for
> scans to hold open more than about 10 batches to get the desired I/O
> prefetch distance.

> +/*
> + * Handle a change in index scan direction (at the tuple granularity).
> + *
> + * Resets the read stream, since we can't rely on scanPos continuing to agree
> + * with the blocks that read stream already consumed using prefetchPos.
> + */
> +static pg_noinline void
> +heapam_dirchange_readstream_reset(IndexFetchHeapData *hscan,
> +								  BatchRingBuffer *batchringbuf,
> +								  ScanDirection direction)
> +{
> +	/* Reset read stream state */
> +	batchringbuf->prefetchPos.valid = false;
> +	hscan->xs_yield_check = false;
> +	hscan->xs_paused = false;
> +	hscan->xs_read_stream_dir = NoMovementScanDirection;	/* see note below */
> +
> +	/* Reset read stream itself */
> +	if (hscan->xs_read_stream)
> +		read_stream_reset(hscan->xs_read_stream);
> +
> +	/*
> +	 * Finally, remember new scan direction.
> +	 *
> +	 * Note: we needed to set xs_read_stream_dir to NoMovementScanDirection
> +	 * momentarily to avoid spuriously prefetching more blocks from within the
> +	 * read stream callback.  Once we return, the read stream can be used to
> +	 * fetch blocks in the opposite scan direction.

I don't follow, if we are resetting, nothing should look at the
xs_read_stream_dir.   Ah, I see that you elsewhere have a comment saying this
is just needed due to a bug.

> +	/*
> +	 * Consider if we need to yield, if we haven't done so already for the
> +	 * current prefetchBatch.  We yield at the natural boundary between
> +	 * exhausting one batch's items and loading the next, giving the scan a
> +	 * chance to return scanPos items and potentially end entirely (e.g., with
> +	 * LIMIT) before we eagerly commit to loading another batch.
> +	 *
> +	 * When we yield, the scan will return at least one more scanPos item,
> +	 * often several more (particularly during scans of indexes with high
> +	 * physical/logical correlation, where batches contain groups of adjacent
> +	 * items whose TIDs all point to the same heap page).
> +	 *
> +	 * Prefetching can continue once the scan requests the buffer for the next
> +	 * enqueued heap block by calling read_stream_next_buffer.  We weigh the
> +	 * need to keep the scan responsive (to avoid senselessly doing a large
> +	 * amount of work when we should just return a scanPos tuple immediately)
> +	 * against the need for the read stream to maintain its prefetch distance
> +	 * (if we pause too much it'll hurt the stream's ability to maintain a
> +	 * sufficient prefetch distance when I/O bound).
> +	 *
> +	 * Keeping the scan responsive is important during index-only scans that
> +	 * require only a few heap fetches.  It also matters when allowing the
> +	 * scan to return just a few more items has the potential to allow the
> +	 * scan to end entirely (e.g., with an ORDER BY ... LIMIT N, or within a
> +	 * scan that feeds into a merge join).
> +	 */
> +	else if (!hscan->xs_yield_check)
> +	{

Minor nit:  I find it pretty odd that we check for yielding if xs_yield_check
is false.

> +		hscan->xs_yield_check = true;
> +		if (!hscan->xs_rampup_done)
> +		{
> +			/*
> +			 * Never yield during the initial ramp-up phase of the scan.
> +			 * Yielding too early prevents the read stream from building up an
> +			 * adequate prefetch distance.
> +			 */
> +			if (batchringbuf->headBatch >= 4)
> +				hscan->xs_rampup_done = true;

Where is that 4 coming from? It doesn't seem like having processed more than 4
batches is a particularly good proxy for anything?

> +		}
> +
> +		if (hscan->xs_rampup_done)
> +			return read_stream_yield(stream);

I don't really understand what logic behind yielding is.

>From what I can tell we'll basically yield once a batch after reaching
headBatch >= 4 (because heapam_batch_getnext() set xs_yield_check = false, and
).

That's maybe fine if one index batch references a lot of heap pages, but if it
instead returns tids for consecutive table blocks, it's highly likely that the
entire batch can be executed as a single IO for heap, due to IO combining
(unless part of the data is in the buffer pool).  Which, in turn, means that
it's unlikely to have more than one IO in flight in that case - the yielding
will trigger one IO and IO will only be resumed the next time
read_stream_next_buffer() is called, which will wait for the IO started before
yielding.

Seems at the very least this would need to be tied to the difference between
the scan and prefetch pos and increase the distance between the two that needs
to be reached to yield again.

What's the workload showing the need for yielding most extremely? Is that the
merge join case we talked about before? I'd like to experiment with a few
other approaches.

Ran out of time & energy for the moment, more later.

Greetings,

Andres Freund

---

From: Andres Freund <[email-masked]>
Date: Fri, 27 Feb 2026 19:37:53 -0500

Hi,

> the problem would go away once yields were completely disabled.

Ooops, just sent an review that I started writing a few hours ago that would
have benefited from having read this first...

> yielding mechanism is disabled.

I do continue to wonder if we ought to pass down some hints from the planner
about how much data an indexscan is likely to read to influence readahead
aggressiveness.

I do agree it's right beign concerned about the increase in index fetches with
such mark/restore cases.

> 61094.676 ms for FF 25 (which is at least consistent-ish in the way

Is this, by any chance, with starting the server and running these queries in
that order?  Are you repeating these runs within one server start, evicting
the buffers inbetween?

If you don't, you'll often get very inconsistent performance, because the
first time a buffer pool page is accessed, you'll get a page fault, during
which the kernel has to find free memory to back the page, which then also has
to be zeroed out.

With a small buffer pool you reach the point where individual buffers are
reused much more quickly, which would explain why it only happens with the
larger s_b.

Are you using huge pages?  I see rather differing performance results
with/without when not prefetching.

In fact, when not repeating the benchmarks and running them in order within
the same "server start cycle", I get similar timings as you were when not
using huge pages.  But just running the queries in a different order gives
very different results.

> like this.

It really shouldn't be sensitive - that query will never be able to reuse
heapam pages within a query, and evicting a clean buffer isn't that expensive.

> offer an explanation that'll help me to improve my own intutions about

I suspect it's really related to running multiple different queries in a
specific order, without restarting in between.  Exacerbated perhaps by not
using huge pages.

Greetings,

Andres Freund

---

From: Peter Geoghegan <[email-masked]>
Date: Sat, 28 Feb 2026 14:16:22 -0500

> it's not a bug, but still seems somewhat odd.

Will fix.

> I'd probably not list the AMs here, because as of this commit, it'd not be

I'll fix this too.

> Other than these points, I think it's probably worth pushing this soon?

Great. I think that it makes sense to push the first 2 patches
together. I'll work on doing that towards the end of the next week (so
around Friday March 6).

> need GetLSNAtomic() unless XLogHintBitIsNeeded().

Do you think this needs to be fixed in the first patch of this series?

> Seems like a e.g. a concurrent _bt_delete_or_dedup_one_page() would also be a

It's a bit complicated, though the exact details don't seem
particularly important to me.

Currently, it is barely possible for a query to call _bt_readpage to
read a page's items, and subsequently successfully set some LP_DEAD
bits on that same page even when a concurrent deduplication pass
occurred on the same page in the interim. In practice this can only
happen during a !dropPin nbtree index scan -- so it cannot ever happen
during most index scans. So, to answer your question: no, it isn't
strictly necessary to give up on setting LP_DEAD bits from within
_bt_killitems when there has been a concurrent
_bt_delete_or_dedup_one_page call. I think this might work unreliably,
its possibility is more an artefact of how things worked before the
dropPin behavior was added back in 2015.

The amgetbatch interface *requires* that all amgetbatch index AMs use
nbtree's dropPin behavior for all scans (though we no longer call it
dropPin; it's just how amgetbatch operates). I think that it's still
the case that we only really need to worry about concurrently TID
recycing hazards of the kind that can be avoided by requiring
btbulkdelete to acquire a cleanup lock.

> as dirty... But anyway, that's not the "fault" of your change.

Yeah, that is suboptimal. But it's worked that way for most index
scans for over 10 years now. I complained about one aspect of how this
could be ineffective back in 2017:

https://www.postgresql.org/message-id/flat/CAH2-Wz%3DSfAKVMv1x9Jh19EJ8am8TZn9f-yECipS9HrrRqSswnA%40mail.gmail.com#b20ead9675225f12b6a80e53e19eed9d

The problem is greatly ameliorated by the fact that the deletion
process opportunistically checks TIDs from index tuples not marked
LP_DEAD when it is cheap to do so in passing (when the TID points to a
heap block we need to access anyway). IIRC, when the regression tests
are run, the ntbree tuple deletion process deletes many more
non-LP_DEAD-marked index tuples than LP_DEAD marked tuples (obviously
this is workload dependent).

> addressed by adding a ReleaseBuffer() version that returns the current LSN.

I'm open to anything that can help.

> introduce a measurable overhead when using unlogged tables?

Yes. It definitely matters. That's why I've been treating that patch
of Andreas Karlsson's to make BufferGetLSNAtomic use an atomic op as a
dependency (though without posting it to this thread). My testing
seems to show that the patch almost eliminates the problem altogether.
This is particularly important for avoiding regressions in hash index
scans.

It would be good to get the BufferGetLSNAtomic atomic op patch out of
the way soon. Since it's clearly independently useful.

> If so, could the

Not really, no. There's no way to know whether the page will have any
index tuples that can be LP_DEAD marked. We could perhaps invent
heuristics that try to predict it based on past trends, but that seems
rather unappealing. Especially considering that there are good ways of
making BufferGetLSNAtomic a lot cheaper.

> This is a huge change. Is there a chance we can break it up into more

I'm open to any ideas about that (like the one you go on to suggest
about introducing IndexFetchHeapData.xs_blk in its own commit).

> A first note: Seems like this needs to update doc/src/sgml/indexam.sgml?

I arbitrarily decided to put all of the sgml doc updates into the
later prefetching commit. Purely because it was hard to avoid talking
about prefetching in the docs. I can put minimal sgml doc updates in
the amgetbatch commit.

> presumably IndexScanBatch.items[] has a limited size?

We don't allocate more than one of these BatchRingItemPos structs
anywhere, and always do so on the stack. I think that we rely on them
being signed within _bt_readpage, when it generates its return value,
here:

"""
return (newbatch->firstItem <= newbatch->lastItem);
"""

During a forwards scan _bt_readpage that returns no items, we'll
return false when "newbatch->lastItem = itemIndex - 1;" makes
newbatch->lastItem have the value -1 (note that newbatch->firstItem is
always 0 during a _bt_readpage in the forwards scan direction).

> If we're generalizing this, I'd be inclined to rename it away from heap to

WFM.

> batch.

How strongly do you feel about it?

Many months ago, this struct provided a way for index AMs to provide
their own state in each batch. But I ultimately concluded that the
overhead of that approach was a problem.

It's not as if there's no way that the index AM can manage its own
state that describes the progress of the scan. For example, nbtree
still manages its own array key state (and must provide its own
amposreset function to provide indexbatch.c with a way to invalidate
that state, such as when the scan direction changes).

Index AMs aren't obligated to use fields like currPage and nextPage;
they are explicitly private state. So I think it's perfectly possible
for an index AM that doesn't use buffers or pages to use the
amgetbatch interface, while fully managing its own progress state. It
would be straightforward with an index type that didn't support
scanning backwards. Even if the scan did support scrolling in either
direction, it could map currPage to some kind of private state entry.

> in one direction?  If so, why does it use left/right instead of

The names are based on the existing, similar moreLeft and moreRight
fields used by both nbtree and hash. I'm not attached to any of these
names. Feel free to suggest alternatives.

> > +     int                     lastItem;               /* last valid index in items[] */

They must be the same type as BatchRingItemPos.item offsets. So I
could probably make them int16, but it wouldn't be easy to make them
unsigned due to the aforementioned _bt_readpage issue.

> Not sure what "indexoffset" really means in a generic abstraction.

It means an offset into the currTuples buffer at which the table AM
can find an IndexTuple to return, during an index-only scan. Can you
suggest an alternative terminology and/or presentation?

> > +     int                *killedItems;        /* indexes of killed items */

Another case where the underlying type used must be the same type as
BatchRingItemPos.item offsets.

> I'd specify what is being indexed by killedItems.

Will fix.

> What size is killedItems?

In practice it is always IndexScanDescData.maxitemsbatch, when
actually allocated. IOW, for nbtree it is MaxTIDsPerBTreePage (1358
items), while for hash it is MaxIndexTuplesPerPage (408 items).

> odd too, since nothing may yet been "killed".

Will come up with a better name for the next revision.

> implemented immediately, but I'd probably not preclude it in generic

Whether or not we allocate visInfo space from the generic indexbatch.c
routines is determined by IndexScanDesc.xs_want_itup, though.

> IndexFetchHeapData because it'd be somewhat complicated to per-batch state in

Yes. And because we really want to keep the visInfo space right next
to the corresponding BatchMatchingItem/items[] state. Allocating and
releasing all batch state together is important.

> the "tableam private" and "indexam private" data in each batch.

I think that would be painful. Especially if the goal is to allocate
visInfo in memory that is exclusively managed by heapam.

> is too specific to the way it was used in nbtree.

How so?

> that's indicative that hardcoding it isn't great?

The fact that I only increase INDEX_SCAN_MAX_BATCHES in the later
prefetching patch was a fairly arbitrary choice.

> ->index_fetch_tuple() etc.

I don't see a way around that. Not as long as there's a need to
support table_index_fetch_tuple_check, which is fundamentally about
passing a TID owned by the caller.

> Don't really have an opinion about this, but it's not clear to me why this

Really? You actually suggested it (albeit in a completely different
context). It just felt like a good idea to make the allocation
dynamic, now that I'm adding more stuff to the instrumentation struct.
I can change it back, if you prefer.

Sure.

> separately? The we can get it out of the way?

Will do. The next revision will put these changes into their own patch.

> uses an exclusive lock and thus also provides a barrier (so does the heap

These comments were moved from nodeIndexScan.c.

> Well, not just, right? We can only prune when inserting into the index?

Again, these are direct copies of the existing comments.

> hard to hit memory ordering issue.

Once again, these are direct copies of the existing comments.

Can you suggest a replacement comment block that comprehensively
addresses all your concerns? I think that these are all independent
issues. As far as I know there's no reason to think that the rules in
this area have changed (or need to change) due to our
structural/layering revisions.

> to suffice for the hot path to not need the function call.

I'll have something like that in the next revision.

>   SELECT count(*) FROM pgbench_accounts

I'm aware of several ways to speed up the bulk VM lookups, but I held
off because it didn't seem particularly crucial to this patch. For
example, Matthias's bug fix for GiST index-only scans actually adds
bulk VM lookups that use SIMD instructions.

Seems like independent work? I can add a patch like this if you think
that it's important.

> directly here.

I think that it makes perfect sense that the AM table controls this.
After all, holding on to buffer pins exists purely for heapam's
benefit -- it has nothing to do with index AM considerations.
(Actually, it kinda does with the _bt_killitems safety stuff, but IMV
that's just a table AM problem disguised as an index AM problem. And
one that can be fixed in a table-AM-agnostic way by always using the
LSN trick.)

The TID recycling hazards we're worried about here are inescapably
tied to how heapam VACUUM sets LP_DEAD stub line pointers in heap
pages to LP_UNUSED. It's entirely possible that another table AM will
unconditionally free each batch's buffer pin (which is close to what
heapam actually does). Or that such a table AM will have a completely
different policy.

> What, e.g., if the indexam requires multiple buffers to be
> pinned for correctness?

I agree that's not unusual. In fact, we often require this within hash
index scans.

That is handled in the later hash index patch by calling
IncrBufferRefCount against a to-be-returned batch's buffer just before
it is returned, as needed. This seems like a fairly elegant solution.
It allows the index AM to hold its own pin reference, which can be
released either before or after the table AM's reference, without any
special care.

> index_fetch_heap()), so the compiler at least can realize that it won't change

I experimented with these ideas yesterday, but for whatever reason
didn't see much of any win. I'll revisit it, though.

> could just be passed around upwards?

Yes. I agree that that's a little weird.

> this kinda code, because it won't be able to proof it's never read later.

Again, I didn't see much success when I experimented with this
yesterday. But it could be a question of needing the right query to
see any benefit.

> tid).

I've seen that too.

> Seems we could amortise this from once-per-tuple to once-per-block for

It's not obvious that it'd be a win, since there's no convenient choke
point. We can't use IndexFetchHeapData.xs_blk for this, since that is
only set when the item was not all-visible (whereas this code path is
only used when it is). So we'd need to add a new field to
IndexFetchHeapData, just for this. We'd need to call
ItemPointerGetBlockNumber, to check if there's been any change in this
"all-visible heap block" field.

You might think that we could piggy-back some of this work on the read
stream callback. But this code is only used in the happy path, where
tuples are all-visible -- where we don't expect the read stream
callback to be called.

The most promising approach might also be the simplest: We could gate
the call to PredicateLockPage using something similar to
"MySerializableXact == InvalidSerializableXact)". Maybe it'd be
possible to store that information only once, in IndexFetchHeapData,
or maybe in IndexScanDesc?

> supposed to do with that.

Most table_index_getnext_slot callers don't need to do anything about
this. But nodeIndexonlyscan.c and similar callers (basically all
index-only scan style callers) expect to examine certain IndexScanDesc
fields. They want to examine IndexScanDesc.xs_itup for the "slot that
was set by table_index_getnext_slot", and other similar items.

This is admittedly a bit odd. These callers do really need a table
slot -- that's required when heap accesses turn out to be needed on
the heapam side. But they don't really use the table slot directly
themselves.

Maybe it would make sense to invent a new
table_index_getnext_slot-like function, used only by the
IndexScanDesc.xs_itup callers. Passing the same table slot again and
again wouldn't be required with this function, which would be clearer
(the underlying heapam.c code would still require a table slot, but we
wouldn't be pretending to return heap tuples using that slot anymore).

> that we do so unnecessarily if we have to verify tuple visibility.

We're not actually doing so for these callers
(IndexScanDescData.xs_want_itup callers), except when we need to do
heap fetches.

> during read-only searching seems like a hard-to-argue about improvement.

Will do. Maybe I can commit this one right after the earlier patches.

> as something to look at in the other thread though.

It is at least somewhat related to the IndexScanDescData.xs_want_itup
callers I just went into.

> really help with actually issuing IO early enough to actually get benefits

I agree.

> is just needed due to a bug.

I fixed that bug in the past few days. It'll be in the next revision.

> I don't really understand what logic behind yielding is.

(...Various other well-justified criticisms of yielding omitted...)

I like your idea of fixing the problems in this area (in the most
recent email, that I have yet to begin to respond to) by inventing a
new READ_STREAM_SLOW_START, and using it selectively, as indicated by
a hint passed in by the executor. Since I'm going to pursue that
approach next, there isn't much point in responding to your specific
points about yielding now.

> merge join case we talked about before? I'd like to experiment with a few

Yes.

> Ran out of time & energy for the moment, more later.

Thanks for the review!

--
Peter Geoghegan

---

From: Peter Geoghegan <[email-masked]>
Date: Sat, 28 Feb 2026 14:32:35 -0500

> about how much data an indexscan is likely to read to influence readahead

As you know, I already have some of that, but it isn't particularly
well developed. I agree that pushing the hinting mechanism further and
using something like your proposed READ_STREAM_SLOW_START mechanism
(which you mention in your later email from today) seems most
promising.

> that order?  Are you repeating these runs within one server start, evicting

Yes, I repeat the runs 3 times, evicting/prewarming as needed each
time. I go with the fastest run.

> with/without when not prefetching.

Yes, I always use huge_pages (and never use transparent huge pages).

> heapam pages within a query, and evicting a clean buffer isn't that expensive.

Could have been my previous failure to use "cpupower idle-set -D 0" to
get maximally stable performance for my microbenchmarks. As discussed
privately, I've added this as yet another step in the script that
automates all this for me/that prepares my system to run a
microbenchmark.

-- 
Peter Geoghegan

---

From: Andres Freund <[email-masked]>
Date: Sat, 28 Feb 2026 17:08:04 -0500

Hi,

> about introducing IndexFetchHeapData.xs_blk in its own commit).

The indexscan stats changes, if we want them, are also pretty verbose and
pretty independent.

It'd be great if the move of indexonly visibility check handling from
nodeIndexonlyscan.c into the table AM could be split out, but it might be too
churn-y.  But getting it out of the way first would be nice.

> about prefetching in the docs. I can put minimal sgml doc updates in

Ah, ok.

> anywhere, and always do so on the stack.

Hm? There are two in BatchRingBuffer and I don't see any on the stack?

It probably still doesn't matter though, compared to the size of
BatchRingBuffer.batches (once it's beyond the toy size).

> I think that we rely on them

> always 0 during a _bt_readpage in the forwards scan direction).

Isn't that about a different field? Ah, I see, you later say they have to be
the same type as BatchRingItemPos.item.

This actually reminds me that it's not entirely clear to me what firstItem,
lastItem mean:

	/*
	 * Matching items state for this batch.  Output by index AM for table AM.
	 *
	 * The items array is always ordered in index order (ie, increasing
	 * indexoffset).  When scanning backwards it is convenient for index AMs
	 * to fill the array back-to-front, so we start at the last slot and fill
	 * downwards.  Hence they need a first-valid-entry and a last-valid-entry
	 * counter.
	 */
	int			firstItem;		/* first valid index in items[] */
	int			lastItem;		/* last valid index in items[] */

I think it should at very least state that -1 is used to indicate there are no
items.

I take it that if firstItem == lastItem, there's exactly one item?

> How strongly do you feel about it?

I think it's a problem, too prescriptive about what exactly an indexam might
need and how it stores data.

> overhead of that approach was a problem.

With the approach that I hand waved at, namely to use one allocation for both
the AM private state and the generic state, that should not be a real issue?
There should be maybe 2-3 cheap instructions to translate?  I've expanded on
that further down.

> names. Feel free to suggest alternatives.

knownEnd{Forward, Backward} or isEnd{Forward,Bckward} seems like it'd do the
trick?

> suggest an alternative terminology and/or presentation?

	/*
	 * If we are doing an index-only scan, these are the tuple storage
	 * workspaces for the matching tuples (tuples referenced by items[]). Each
	 * is of size BLCKSZ, so it can hold as much as a full page's worth of
	 * tuples.
	 */
	char	   *currTuples;		/* tuple storage for items[] */
	BatchMatchingItem items[FLEXIBLE_ARRAY_MEMBER]; /* matching items */

I previously read the "these are" to reference currTuples and items, but I
think the plural is actually just an anachronism, from when currTuples was
followed by markTuples?

It says that it's the tuple storage for items[], but I think that may just be
trying to say that it's the tuple storage corresponding the items in ->items[].

> routines is determined by IndexScanDesc.xs_want_itup, though.

Yea, that's my point, why should it be controlled that way :). If you e.g. had
an undo based AM, the gain from batch resolving visibility for non-index-only
scans would be an order of magnitude bigger than it is in heap, but with this
coupling it couldn't easily.

> visInfo in memory that is exclusively managed by heapam.

I agree it shouldn't be exclusively managed by heapam and that it needs to be
stored together with the batch.  But it could be a size determined at the time
of index_beginscan() as part of table_index_fetch_begin(heapRelation), e.g. a
field in the returned IndexFetchTableData.

I'm basically thinking that each batch would be stored like this:

   [table am specific data]
   [index am specific data]
   struct IndexScanBatchData
   [variable amount of items]

With the offset from the *IndexScanBatchData to the index/table data stored
inside IndexScanDesc.

Accessing the heap specific portion of an IndexScanBatch could then be
something like
  ((char*) batch - iscan->batch_table_offset)
(wrapped in a helper, of course)

The index AM conversion could be made even cheaper with the above, because for
the index AM the offset would typically be known at compile time
(i.e. something like MAXALIGN(sizeof(BTIndexBatchData))).

> > is too specific to the way it was used in nbtree.

BLCKSZ might not mean anything to a non block oriented index am. Or BLCKSZ
might not be sufficient for an AM that compresses index entries.

> passing a TID owned by the caller.

Hm. It looks like the only remaining users (_bt_check_unique(),
unique_key_recheck()) don't care about actually getting the tuple, they just
want to check if it exists.

For those the API is actually unnecessarily expensive, we don't need to return
the tuple, therefore don't need to create a slot.  So once we get the main
stuff in, I think we should evolve the API to be a bit more catered
specifically to this usecase.  I think it'll be a lot less confusing that way.

> > If we want to change it, could we do that in a prereq patch?

I think it's a sane change, it just didn't seem really related with the
commit.

> Will do. The next revision will put these changes into their own patch.

Thanks.

> Once again, these are direct copies of the existing comments.

Sorry for missing that, I had just looked at the new code, given the size of
the commit...

> Can you suggest a replacement comment block that comprehensively addresses
> all your concerns? I think that these are all independent issues. As far as
> I know there's no reason to think that the rules in this area have changed
> (or need to change) due to our structural/layering revisions.

I'll try.

> bulk VM lookups that use SIMD instructions.

To be clear, I was just suggesting to elide repeated VM lookups for index
entries that all point to the same table page. Not to actually do any batching
or such. It's just four more lines or so.

It seems somewhat related, in that the patch opens up the possibility of doing
more VM lookups than before, e.g. due to an IOS on the inner side of an
anti-join nestloop.

> Seems like independent work? I can add a patch like this if you think

I think it's worth doing, given the simplicity of it and that this is all new
code. Having a bit more of an efficiency buffer against regressions is nice
too.

> benefit -- it has nothing to do with index AM considerations.

I think it's right that the table AM controls *when* the pins are released,
what doesn't seem right that it knows *which* specific buffer being released
is the right thing and that's done.  What if the index AM is one outside of
postgres' buffer pool?

> released either before or after the table AM's reference, without any

That helps with multiple pins for the same buffer, but not having to pin
multiple *different* buffers.  Sure, the index AM could just hold onto the
additional buffers until the scan ends or such, but that's not really an
option.

I really think that the generic code in indexbatch.c or the heapam code has
absolutely no business itself mucking around with index AM buffers. That would
be a huge layering violation.

> yesterday. But it could be a question of needing the right query to

I'd say that even if it were not to improve perf (pretty sure I saw some
though), not introducing fields that have more complicated lifetimes is just
good for human understanding too.

Greetings,

Andres Freund

---

From: Tomas Vondra <[email-masked]>
Date: Sun, 1 Mar 2026 16:03:36 +0100

Hi,

I've decided to run a couple tests, trying to reproduce some of the
behaviors described in your (Felipe's) messages.

I'm not trying to redo the tests exactly, because (a) I don't have a M1
machine, and (b) there's not enough details about the hardware and
configuration to actually redo it properly.

I've focused on quantifying the impact of a couple things mentioned in
the previous message:

1) the distance limit

2) the profiling instrumentation

3) concurrency (multiple backends doing I/O)

I wrote a couple scripts to run two benchmarks, one focusing on (1) and
(2), and the second one focusing on (3).

Both were ran on four builds:

1) master
2) patched (index prefetch v11)
3) patched-limit (patched + distance limit)
4) patched-limit-instrument (patched-limit + instrumentation)

The scripts initialize an instance, vary a couple important parameters
(shared buffers, io_method, direct I/O, ...) and run index scans on a
table with either sequential or random data.

I'm attaching the full scripts, raw results, and PDFs with a nicer
version of the results.

single-client test (single-client.tgz)
--------------------------------------

The test varies the following parameters:

* buffered or direct I/O
* io_method = (worker | io_uring)
* shared_buffers = (128MB | 8GB)
* enable_indexscan_prefetch = (on | off)
* indexscan_prefetch_distance = (0, 1, 4, 16, 64, 128)
* sequential / random data (1M rows, 550MB, ~15 rows per page)

This was done on an old Xeon machine from ~2016, with a WD Ultrastar DC
SN640 960GB NVMe SSD.

The single-client.pdf shows the timings for different combinations of
parameters, branches and distance limit values. There's also a table
with timing relative to master (100% means the same as master, green =
good, red = bad).

There are literally only 4 cases where prefetching does worse than
master, and those are for random data with distance limit 1. I claim
this is irrelevant, because it literally disables prefetching while
still paying the full cost (all 4 are for io_method=worker, where the
signal overhead can be high, so it's not a surprise).

We ram up the distance exactly for this reason, that's the solution for
this overhead problem. I refuse to consider these regressions with
limit=1 a problem. It's a bit like buying a race horse, break its leg
and then complain it's not running very fast.

The overhead of the instrumentation seems relatively small, probably
within 5% or so. That's a bit less than I expected, but I still don't
understand what this is meant to say us. It's measuring wall-time, and
it's no surprise that in an I/O-bound workload most of the time is spent
in functions doing (and waiting for) I/O. Like read_stream_next_buffer.
But it does not give any indication *why*.

multi-client test (multi-client.tgz)
------------------------------------

The test varies the following parameters:

* buffered or direct I/O
* io_method = (worker | io_uring)
* io_workers = (12 | 32)
* shared_buffers = (128MB | 8GB)
* enable_indexscan_prefetch = (on | off)
* indexscan_prefetch_distance = (0, 1, 4, 16, 64, 128)
* sequential / random data (1M rows, 550MB, ~15 rows per page)
* number of parallel workers (1, 2, 4, 8)

This was done on a Ryzen 9 machine from ~2023, with 4x Samsung 990 PRO
1TB drives in RAID0.

The test prepares a separate table for each worker, and then runs the
index scans concurrently (and "syncs" the workers to start at the same
time). It measures the duration, and we can compare it to the timing
from master (without prefetching).

The multi-client-full.pdf has detailed results for all parameters, but
as I said I don't think the distance limit (particularly for limit 1) is
interesting.

The multi-client-simple.pdf shows only results for limit=0 (i.e. without
limit), and is hopefully easier to understand. The first table shows
timings for each combination, the second table shows timing relative to
master (for the same number of workers etc.).

The results are pretty positive. For random data (which is about the
worst case for I/O), it's consistently faster than master. Yes, the
gains with 8 workers is not as significant as with 1 worker. For
example, it may look like this:

               master      prefetch
   1 worker:     2960          1898       64%
   8 workers:    5585          5361       96%

But that's not a huge surprise. The storage has a limited throughput,
and at some point it gets saturated. Whether it's by prefetching, or by
having multiple workers does not matter.

For sequential data (which is what you did in your examples) it's much
simpler. For buffered there's not much benefit, because page cache does
read-ahead with mostly the same effect, or there's a nice consistent
speedup for direct I/O.

This all seems perfectly fine to me. The bad behavior would be if the
prefetching gets slower than master, because that would be a regression
affecting users. But that happens only in 4 cells in the table. My guess
is it hits some limit on the number of signals the system can process.
The random data set is not great for this, it's worse with more workers,
and the 128MB buffers make that even worse. This is a bit of perfect
storm, and it's already there - bitmap scans can hit that too, AFAICS.

(But I'm speculating, I haven't investigated this in detail yet.)

Moreover, io_uring does not have this issue. Which is another indication
it's something about the signal overhead.

In any case, these results clearly prefetching can be a huge improvement
even in environments with concurrent activity, etc.

If you see something different on the Mac, you need to investigate why.
It could be something in the OS, or maybe it it's hardware specific
thing (consumer SSDs can choke on too many requests). Hard to say. I
don't even know what kind of M1 machine you have, what SSD etc.

regards

-- 
Tomas Vondra

---

From: Peter Geoghegan <[email-masked]>
Date: Sun, 1 Mar 2026 15:39:55 -0500

> The indexscan stats changes, if we want them, are also pretty verbose and

Then I'll remove them from the next revision. We can re-review that
decision at some point in the weeks ahead.

> churn-y.  But getting it out of the way first would be nice.

That seems harder, but I'll see what I can do.

> I think it should at very least state that -1 is used to indicate there are no

It's a bit awkward because by definition it isn't possible for a batch
returned by amgetbatch to contain no items. amgetbatch always returns
a batch for the next index page that has at least one matching item
(or it returns NULL, indicating that the scan has completely run out
of matching items).

But I'll add a note about some index AMs needing these to be signed to
represent no-matching-items internally.

> I take it that if firstItem == lastItem, there's exactly one item?

That's right.

> There should be maybe 2-3 cheap instructions to translate?  I've expanded on

I'll work towards that, then.

WFM.

> trying to say that it's the tuple storage corresponding the items in ->items[].

You're right. Will fix.

> (i.e. something like MAXALIGN(sizeof(BTIndexBatchData))).

That seems reasonable. I'll at least work towards that for the next
revision (might make sense to produce a revision without those changes
to fix bit rot from committing earlier patches).

> specifically to this usecase.  I think it'll be a lot less confusing that way.

Noted.

> or such. It's just four more lines or so.

I prototyped this. You were right; it's a no-brainer. It'll definitely
be in the next version.

> is the right thing and that's done.

The fact is that the "Index Locking Considerations" section from
"Chapter 63. Index Access Method Interface Definition" in the docs
describes things that only make sense when using heapam, or a table AM
very much like it. Any design derived from the existing one will
likely retain some of that.

For example, the entire concept of ambuldelete requiring a cleanup
lock is unrelated to index AM considerations. If (say) nbtree only
MVCC snapshot plain index scans and bitmap index scans, then there'd
be no need for btbulkdelete to acquire cleanup locks, and no need for
any scan to ever hold on to a buffer pin. To me it seems natural to
make such buffer pins (those that specifically exist to form an
interlock against unsafe concurrent TID recycling by VACUUM) the
responsibility of the table AM -- that's the only place where it
actually matters. That's the main reason the AM table releases the
batch's buffer pin in the current design of index prefetching (though
another important factor is nestloop anti-joins with inner index-only
scans, as I detail below).

Here are some points I imagine we agree on already:

* It is definitely a good idea to drop index page buffer pins during
or right after amgetbatch returns. Doing so avoids unintended
interactions with the read stream.

* As a practical matter it makes sense to be maximally strict, by
requiring (at least during prefetching) that the index AM immediately
releases any buffer pins it acquires (acquires from an `amgetbatch`
call originating from the read stream callback). That way it simply
isn't possible for the read stream to be affected by these buffer pins
in the first place. IOW while a paranoid policy might not be strictly
necessary, it ends up being simpler that way.

* It is necessary to avoid bulk-loading a batch's visibility
information immediately (before prefetching begins) to prevent new
regressions in things like nestloop anti-joins with an inner
index-only scan. My microbenchmarking shows this clearly.

Specifically for index-only scans, the only way to drop a batch/index
page pin at the earliest possible opportunity like this is to bulk
save visibility information for the batch's items before dropping the
index page pin. This avoids concurrent TID recycling races with VACUUM
that could otherwise lead to wrong answers. At the same time,
index-only scans get the usual strong guarantee about not holding on
to buffer pins that could adversely affect the read stream in
unpredictable ways (just like plain index scans).

Note that I'm only discussing pins held by a batch; I'm not discussing
other pin types that certain index AMs (not including nbtree) must
retain for their own reasons. To me, the "batch pin vs index AM owned
pin" distinction is important; I try to be clear about the pin type
involved when discussing these issues.

Here's where you might disagree with me, or at least where I need
greater clarity to actually understand your position:

I think it follows from what I said that some piece of code (either in
the index AM or the table AM) must be responsible for saving
visibility info for a batch using the visibility map, and only then
releasing the associated buffer pin. I chose to do this in the table
AM, partly because heapam needs the flexibility to *not* release the
batch/index buffer pin right away. ISTM that heapam needs the
authority to choose whether or not to bulk load all visibility info
from each batch immediately. This is necessary for the first batch
returned during an index-only scan with (say) a LIMIT 5, where loading
all of the visibility information immediately would significantly hurt
performance.

How should I resolve the tension among all these goals? Is your
concern that heapam knows about buffer pins specifically, as opposed
to an abstract interlock concept?

> What if the index AM is one outside of
> postgres' buffer pool?

Then it also cannot support WAL-logging, I think. Where does that
leave the LSN trick?

> additional buffers until the scan ends or such, but that's not really an

Why is it not an option? Is this purely because of the potential to
disrupt the read stream's management of the backend's buffer pin
limit?

The problem with that position is that it just isn't compatible with
how some non-nbtree index AMs work and have always worked. Most
notably, the hash index AM feels entitled to hold on to up to 2 extra
buffer pins for its own reasons. If hash cannot continue doing that,
and has no reasonable alternative, doesn't that mean that hash is
fundamentally incompatible with prefetching? No matter how we design
the abstractions?

I believe hash indexes aren't generally important, so I certainly
wouldn't mind dropping hash index support for Postgres 19. Having such
strict rules about index AMs holding onto buffer pins won't affect
nbtree at all, but a maximally strict rule about it seems pretty
severe to me. Can't we just invent a way for ambeginscan to advise the
table AM that it might need (say) as many as 2 extra buffer pins?

My assumption up until now (which the docs do mention) is that it's
okay for index AMs to hold on to a small number of buffer pins for
their own purposes. Admittedly my understanding of this isn't rigorous
-- I guess I figured that some small amount of that had to be
permitted, since a number of existing index AMs fundamentally expect
it. I meant to ask you to help me put that on a more rigorous footing.

> good for human understanding too.

I agree that creating a new IndexScanDesc.xs_visible field for
transient state like this is ugly.

I'll incorporate changes along these lines.

Thanks again
-- 
Peter Geoghegan

---

From: Alexandre Felipe <[email-masked]>
Date: Sun, 1 Mar 2026 22:32:53 +0000

> behaviors described in your (Felipe's) messages.

Thank you,
I will look into this data later. I am impressed with the number of IO
workers
you used, my test was typically with 3.

> configuration to actually redo it properly.

Well I was running on a M1 because this is what I have in front of me
but I know that any serious database will run on linux.

> I've focused on quantifying the impact of a couple things mentioned in

I will have a look into this later and compute the effect size.

> signal overhead can be high, so it's not a surprise).

I agree with your claim, the idea of the distance limit was to separate
to have an idea of AIO overhead without the benefit of prefetch, because
I was seeing very similar results, but when I controlled the distance
the prefetch benefit became visible. And also the gradation would
show if this has a U shape or the larger the distance the better the
performance.

It's a bit like buying a race horse, break its leg
> and then complain it's not running very fast
>
 😂

> But it does not give any indication *why*.

Well, I was hoping to be able to create a self balancing mechanism
in read_stream_next_buffer

 /* Do we have to wait for an associated I/O first? */
if (stream->ios_in_progress > 0 &&
stream->ios[stream->oldest_io_index].buffer_index == oldest_buffer_index)
{
  // prefetch and increase the distance while we wait here
WaitReadBuffers(&stream->ios[io_index].op);
 ...
}
...
// this call could be removed if we prefetched earlier.
read_stream_look_ahead(stream);

There same principle that guided the
> Don't wait for already in-progress IO
patch. Here we should prioritise increasing the distance, and if it is not
possible (maybe we consumed all the buffers). We could take the
opportunity to yield.

> multi-client test (multi-client.tgz)

 Are parallel workers here clients issuing queries?

> affecting users. But that happens only in 4 cells in the table.

And in this case we have to take the other extremum, and run the queries
where the prefetch is not expected to help. In this sense I agree with Peter
that the yielding logic is important. We may be limiting the potential of
the
prefetch in some cases but excessive reads is the highest risk in my
opinion.
You may know better than me, but I talk about the workloads I have seen
or worked with, it is typically a high number of small queries. Not these
huge
scans.
Large queries are rare, and when they come to our attention is because
they used too much memory and started to create temporary files.

Fair enough.

> it's something about the signal overhead.

That is interesting.

> don't even know what kind of M1 machine you have, what SSD etc.

My guess is that the cause is IPC, I don't know well how the
async IO works, but if it is a different process I think that MacOS is
by less efficient than linux.  But I don't know how to measure that.

Regards,
Alexandre

---

From: Tomas Vondra <[email-masked]>
Date: Mon, 2 Mar 2026 00:33:30 +0100

On 3/1/26 23:32, Alexandre Felipe wrote:
> 
> On Sun, Mar 1, 2026 at 3:03 PM Tomas Vondra <[email-masked]
> <mailto:[email-masked]>> wrote:
> 
>     Hi,
> 

3 is extremely low for an I/O bound system. It's our tradition to pick
defaults that work even on tiny systems, but need tuning on actual
non-toy systems :-(

The number of workers limits the number of I/O happening concurrently.
So with 3 workers, there'll be only 3 concurrent I/Os (which is not a
lot). At which point it can be better to keep doing synchronous I/O from
multiple backends.

It's a trade off. Having more workers is not "free", so we can't have
infinite number of those. io_uring doesn't have this limitation.

> but I know that any serious database will run on linux.

Sure, but even then it'd be better to have more details about the
hardware. "M1" doesn't really say much (especially to people who don't
use Apple stuff very much). There's a range of M1-based systems, from
MacBook Air/Pro, Mini, Studio, ...

> show if this has a U shape or the larger the distance the better the

OK, understood. I guess you could do very similar thing by adjusting
effective_io_concurrency (although I admit it's not exactly the same thing).

> possible (maybe we consumed all the buffers). We could take the 

IIUC the idea would be to (automatically) increase the distance just
enough so that the IOs complete right before we actually need the
buffers? That reminds me the "IO cost model" I mentioned a couple days
ago [1]. But it's not clear to me how this profiling helps with it.

>     multi-client test (multi-client.tgz)

Yes, clients running queries. But workers in a parallel query have
mostly the same effect, really.

> that the yielding logic is important. We may be limiting the potential
> of the
> or worked with, it is typically a high number of small queries. Not
> these huge
> scans. 

You mean in the 4 cells that show regressions? Maybe. Or maybe we could
look for ways to make the communication / signalling cheaper, to not
overwhelm the workers like this.

The problem with addressing this by yielding is that the query has no
idea whether it runs alone, or with many other queries. There's no such
context available in the read_stream, so it can't consider this when
yielding.

Moreover, why would yielding help with this? Yielding will just schedule
some of the I/O later, how would it not make it *even slower*? Notice
that in the multi-client-full.pdf the regression happens even with
limit=1, at which point you *can't* yield anymore.

> by less efficient than linux.  But I don't know how to measure that.

I wrote about the signal overhead a couple months ago, with a simple
benchmark simulating it [2]. I also wrote a brief explanation [3] about
the AIO in PG18, which mentions that too.

[1]
https://www.postgresql.org/message-id/433c7359-3c5d-4fe3-a488-93a17f44d0bf%40vondra.me

[2]
https://www.postgresql.org/message-id/1c9302da-c834-4773-a527-1c1a7029c5a3%40vondra.me

[3] https://vondra.me/posts/tuning-aio-in-postgresql-18/

regards

-- 
Tomas Vondra

---

From: Alexandre Felipe <[email-masked]>
Date: Mon, 2 Mar 2026 09:00:34 +0000

> defaults that work even on tiny systems, but need tuning on actual

That is was a surprise for me, because I am used to javascript
that does everything in one single process (with a coroutine
async model) and does with very little overhead.

Cold Cache (buffer eviction before each run):
io pf=off pf=on d<=16 d<=64 d<=128
 3 0.68s  1.20s 1.29s 0.78s 0.68s
10 0.75s  1.02s 1.51s 1.62s 0.82s
30 0.75s  0.79s 2.95s 1.65s 1.43s

Warm Cache (no eviction):
io pf=off pf=on d<=16 d<=64 d<=128
 3  0.42s 0.67s 0.40s 0.41s 0.41s
10  0.53s 0.96s 0.39s 0.38s 0.44s
30  0.39s 0.76s 0.39s 0.39s 0.40s

> MacBook Air/Pro, Mini, Studio, ...

I tried to find out some details of the hardware. It shows that I didn't
even know the (marketing) model name...

Chip: Apple M2, 8 (physical) cores, 24 GB, macOS Tahoe 26.1
Apple SSD AP2048Z (2 TB NVMe), block size 4kb, protocol: Apple Fabric

> ago [1]. But it's not clear to me how this profiling helps with it.

Exactly, actually my investigation started from your (Tomas)

> completes right before we actually need the page.

We are controlling the distance because we can, even knowing
that that is not what we need. We need to prefetch enough
to make sure the buffers will be ready when we need, but
on the other hand we need to minimise the reads of buffers
we don't need.

It is clear that reading unnecessary blocks could lead to
degradation of other queries in two ways. (1) delaying
concurrent tasks that require I/O, be it queries, maintenance,
replication. (2) future queries, by unnecessarily evicting
buffers.
If the system have queries reading on average N pages, and
we read just one additional page we get 1/N overhead. For
N=100 this is negligible, for N<=2 it is not.

Yielding is important, but it is also important that the
executor yield to the prefetch again before the distance
is too low.

DISTANCE INCREASE

The current distance increase mechanism captures the idea of
_completing right before we actually need the page_ by
increasing distance when a wait is required (well, reflecting
about it, I am not sure if that condition is free of false
positives). I think that increasing exponentially is too
aggressive. If waited for 10 buffers in a row you will have
a distance of ~1000 (potential waste of 100x).
Wouldn't it make more sense to increased by blocks in
increments based on I/O combine limit and I/O concurrency?
That way the waste is limited by a constant factor of the
required buffers.

DISTANCE DECREASE

Also the interpretation of distance going up is different
from the interpretation of distance going down.
When it goes up it is an upper bound for the distance
when it goes down it tracks the actual distance.
In this sense I agree with something written in

This could explain Andres Freund observation [1]

> It seems caused to a significant degree by waiting at low queue depths.
If I
> comment out the stream->distance-- in read_stream_start_pending_read() the
> regression is reduced greatly.

CLOSED LOOP THOUGHTS

Here I dump some ideas that we could work on, but but again,
this is something that could go on a separate commit, without
blocking this feature.

Let distance D of a scan that will make sure a given scan
have the buffers ready before consuming. For a long enough scan
we can find d >= D.

Label each operation with the distance (d_io) when it was started.
Every time we wait for a buffer, we know that d_io < D (by definition)
set d = max(d, d_io). Notice that the current model increments the
distance based from its current value not the distance when the I/O
started, this gives room for even more overshoot, but the prefetch
is not immediate, probably that is damping it, and I speculate that
the distance decay was added as a work around for this.

On the other hand, decreasing distance when we consume a buffer
will bring us again to risky ground, below the value that was
previously seen to be necessary to ensure no waits. Maybe this
decay was introduced just to counteract the excessive increase
due to feedback on the current distance instead of the distance
when the operations started.

Increasing exponentially is that we may have a long gap between the
computed distance and the actual required distance. Increasing
it by steps based on maximum I/O concurrency we would still start
as much I/O as could be useful, while keeping the gap between
the distance and the optimal distance small, it could also make it
even faster (just a little bit), by increasing the concurrency on
the initial reads when distance would be 1,2,4,8.

TIME AGNOSTIC MODEL

A proper model would have distinguish the distance of the I/O being
started, the I/O operation being finished, and the I/O distance that
we are aiming for.

start read:
  save io.distance = number of pinned buffers before this.
loop:
  if(have to wait)
    target_distance = io.distance + concurrency;
    start_prefetch
  if(distance <= min_distance)
    distance += prefetch(concurrency, combine_limit)
    if(buffer consumed)
      distance--

TIME AWARE MODEL

The IO model could take delays in consideration. I know that
timing has a cost, but hopefully it would be acceptable
to add the timestamp of the IO when it starts. Then we could
build a model based on an approximation of some quantile of the
distance. If we know e.g. 50% of the IOs take longer
than T(50%), then we could time the first few buffer consumption
to to have a guess, say, tc is the average time per buffer
required for this scan on this query. then T(50%) / tc, is a
a distance that puts us within a 50% chance of requiring a read.

But in order for this model to be effective we should keep it
global, if we make it per query when we tuned the parameters the
query already went too far, it could be saved in the index of stats,
along with the number of scans, tuples heap fetches.

A simpler, local version could be based on the time of the first
IO, but how do we time it? Currently we don't have a callback
to register when it is ready. Let's say that we time it just
after the WaitReadBuffers.

A pseudo code draft, that might be clearer than what I said

init:
t_start=now();
loop:
buffer_count++;
if(has to wait){
  t_wait = now()
  t_per_buffer = buffer_count / t_wait;
  // is this wait blocking other scans??
  WaitReadBuffer(io);
  t_ready = now();
  t_io = t_ready - t_io_start;
  distance_lower_bound = Max(distance_min, io.distance);
  distance_guess = Max(distance_guess, t_io / t_per_buffer);
  t_start = t_ready;
  buffer_count = 0;
}

We have the timing overhead, but notice that this is independent
of the time unit, or the epoch, some CPU tick count could be used
as a cheaper alternative. And we may do this just a few times

Distance lower bound would limit the distance decay, we already
know that we had to wait for an IO started at io.distance, so,
let's stay above that (unless we approach the scan limit).

I hope this makes sense.

> benchmark simulating it [.]. I also wrote a brief explanation [.] about

Added to pending reads :)

[1]
https://www.postgresql.org/message-id/qdl4fojnbfcnm2k7b4zpvgd6gwzwdgtbl5c7shpimrb76dbyy6%40scdnspus3ejh

---

From: Tomas Vondra <[email-masked]>
Date: Mon, 2 Mar 2026 14:18:31 +0100

On 3/2/26 10:00, Alexandre Felipe wrote:
> 
> 
> On Sun, Mar 1, 2026 at 11:33 PM Tomas Vondra <[email-masked]
>     <mailto:[email-masked]>
>     > <mailto:[email-masked] <mailto:[email-masked]>>> wrote:
>     >
>     >     Hi,
>     >

Well, we don't have coroutines (or other threads). If you want something
similarly lightweight, you need to use io_uring. Which means you need to
be on Linux, unfortunately.

> 30  0.39s 0.76s 0.39s 0.39s 0.40s

I don't know what these numbers are / from what test.

> Apple SSD AP2048Z (2 TB NVMe), block size 4kb, protocol: Apple Fabric
OK

> on the other hand we need to minimise the reads of buffers

OK, but how does the instrumentation help with this?

> replication. (2) future queries, by unnecessarily evicting

Agreed on (1). This would be a problem e.g. is a huge query "fills" the
IO queue with requests, and small queries that only need a couple blocks
need to wait much longer.

I don't think (2) would be a huge problem, though. It's extremely timing
sensitive - if the second query runs a second later, the blocks will be
gone anyway. If your "cache hit ratio" depends on this, you'll have a
bad time. It's also possible the second query will use the new data, in
which case it's better to evict sooner.

> N=100 this is negligible, for N<=2 it is not.

Isn't it wasted only if you don't actually use the page later? Which for
most queries is not the case - the query will access the page, no matter
how early it prefetches it.

> executor yield to the prefetch again before the distance

That's up to the read stream, and it will try to prefetch again on the
next buffer read. There's not much this patch can do.

> That way the waste is limited by a constant factor of the

I don't follow. The current distance heuristics does not consider this
at all. It only looks at buffer hits and misses (before issuing the IO),
which is an entirely different thing.

It would need to check if the page is already in shared buffers in
read_stream_next_buffer(), or if it needs to wait for the I/O to
complete. And it does not do that.

I also think you're missing the difference between the 'distance' and
effective_io_concurrency. The 'distance' does not determine how many
blocks we request in advance, it determines how far ahead we're allowed
to look for I/O. The number of concurrent I/O is still limited by
effective_io_concurrency, which is 16 by default. So maybe you have
distance=1000, but if the next 16 blocks are not in shared buffers,
you'll only submit IO for the next 16 blocks.

>> It seems caused to a significant degree by waiting at low queue
> depths. If I

Yes, because the distance heuristics is based solely on buffer hits and
misses (and not on some direct feedback if we're prefetching far enough).

For any algorithm that increases/decreases the distance (e.g. the
current distance*2 and distance--) there is a data patterns that
collapses to 1 too early. It just takes the right fraction of hits and
misses. (If we ignore "trivial" heuristics that never decreases the
distance etc.)

And once you get to distance=1, you're doing a synchronous I/O with the
AIO overhead (signaling workers etc.). Which is what Andres described.

IMHO the simplest solution with the current heuristics would be to not
allow the distance to "drop" below a "small" value that is high enough
to hide the AIO overhead.

> this is something that could go on a separate commit, without

There's no way we can include this in the current commit. The patch is
already large, and it's a far too large change, too late in the cycle.

> when the operations started.

The decay is there because the hit/miss ratio can vary over time (in the
same scan), and there's no need to prefetch very far when everything is
in cache.

> the initial reads when distance would be 1,2,4,8.

I don't follow. Why would an exponential distance increase have a long
gap, but increasing it by fixed steps would not?

> let's stay above that (unless we approach the scan limit).
Perhaps. Some feedback like this might work, but I have no opinion on
which of those approaches it the best one. I'd have to see some PoC
patches, etc.

regards

-- 
Tomas Vondra

---

From: Alexandre Felipe <[email-masked]>
Date: Mon, 2 Mar 2026 18:45:03 +0000

Andres,

> Unfortunately that has too big a performance penalty for fully cached
> workloads :(. Doing buffer mapping lookups ahead of the current point is
not
> free.

Could you elaborate?

---

From: Andres Freund <[email-masked]>
Date: Mon, 2 Mar 2026 14:29:16 -0500

Hi,

> > workloads :(. Doing buffer mapping lookups ahead of the current point is

Looking ahead requires pinning those buffers (or you need to do more buffer
lookups, which also isn't free). The way things work right now is that the
infrastructure to maintain buffer pins gets more expensive the more buffers
you pin.  That increased overhead doesnt' matter much if you actually avoid IO
or you have fair bit of work for each buffer (like looking at each tuple), but
if you just are fully cached and just look at a single tuple, it's painful.

CREATE TABLE uncorrelated(id int8 primary key, balance float not null);
INSERT INTO uncorrelated(id, balance) SELECT id, 0 FROM (SELECT generate_series(1, 1000000) AS id) ORDER BY random();
VACUUM (FREEZE, ANALYZE) uncorrelated;

CREATE TABLE correlated(id int8 primary key, balance float not null);
INSERT INTO correlated(id, balance) SELECT id, 0 FROM (SELECT generate_series(1, 1000000) AS id);
VACUUM (FREEZE, ANALYZE) correlated;

SELECT pg_prewarm('uncorrelated');
SET max_parallel_workers_per_gather=0;SET enable_bitmapscan = 0;SET enable_seqscan = 0;
SELECT sum(balance) FROM uncorrelated WHERE id > 1000 and id < 500000;

SELECT pg_prewarm('correlated');
SET max_parallel_workers_per_gather=0;SET enable_bitmapscan = 0;SET enable_seqscan = 0;
SELECT sum(balance) FROM correlated WHERE id > 1000 and id < 500000;

Index prefetching patch:
uncorrelated: 228.936 ms
correlated:   71.684  ms

+ minimum distance 2:
uncorrelated: 244.361 ms
correlated:   71.848 ms

+ minimum distance 4:
uncorrelated: 246.164 ms
correlated:   71.912 ms

+ minimum distance 8:
uncorrelated: 268.686 ms
correlated:   71.880 ms

+ minimum distance 16:
uncorrelated: 310.320 ms
correlated:   71.890 ms

+ minimum distance 32:
uncorrelated: 330.714 ms
correlated:   74.178 ms

There starts to be some overhead of the increased distance even when
correlated, but it's small and starts much later.

We probably can reduce the overhead of buffer tracking, but probably not
enough for it to not be a factor at all.

Possible improvements to refcount tracking:

- increase REFCOUNT_ARRAY_ENTRIES - there's a very significant cliff at 8
  right now, and with vectorized lookup it might not hurt too much to go to 16
  or so

- To make the cliff at REFCOUNT_ARRAY_ENTRIES smaller, replace dynahash with
  simplehash. That should reduce the perf penalty a good bit.

Unfortunately it's not just the refcount tracking, it's also resowner
management that gets more expensive.

Greetings,

Andres Freund

---

From: Alexandre Felipe <[email-masked]>
Date: Thu, 5 Mar 2026 13:47:10 +0000

Thank you Andres,

I see, It combines an array that is fast for few buffers and a hash that in
theory
scales well for very large number of buffers. And avoids using an array that
would be fast but would multiply the memory usage by the number of backends.

> correlated:   71.684  ms

I did some tests
>  right now, and with vectorized lookup it might not hurt too much to go
to 16

Yes, that is true, but only up to 16, the index prefetch test I was doing
was
getting to 90 or so, and that was clipped by max_pinned_buffers.
Also, I noticed a commit 3 months ago that removed the mid-loop return
that effectively will add the first few pins right to left instead of left
to right.

Maybe this works well with vectorisation, but I see an optimization for the
for the (pin/unpin)+ sequence, what about the pin(pin/unpin)+ sequence.
The previous code would always find the buffers on the first or the second
iteration, the new implementation will have to go to the 7th or 8th
iteration,
(or I am not missing something very important).

>  simplehash. That should reduce the perf penalty a good bit.

This is also true, even remove the refcount array completely.

> management that gets more expensive.

I didn't read this sentence until I came back to reply. It is exactly what
I noticed.
Once we fix the reference counting the resowner still puts a floor.
And that is even more important when a buffer is pinned multiple times
because the resowner will add one entry to the buffer for each pin.

There is another problem, ResOwnerReleaseBuffer unlocks buffers,
even if it is not the owner of the lock.

I think this deserves a separate thread.

---

From: Alexandre Felipe <[email-masked]>
Date: Sun, 8 Mar 2026 06:16:00 +0000

Hi Tomas,

> example, it may look like this:

branch: patched
data: random
io: buffered

          patched          master
iomethod io_uring worker io_uring worker
workers
1            1.52   1.29     2.79   2.75
2            1.77   1.63     3.03   3.04
4            2.36   4.24     3.44   3.40
8            3.60   8.53     4.30   4.30

They are about the same for 1 worker, but degrade as the number of
workers increase, to be honest I was expecting this behaviour IO not
with buffered. But as you pointed out using io_unring the issue goes
away.

Lock contention in pgaio_worker_submit_internal?
Or maybe nsync > 0 at the bottom of the function?
(in src/backend/storage/aio/method_worker.c)

Regards

---

From: Peter Geoghegan <[email-masked]>
Date: Tue, 10 Mar 2026 16:57:35 -0400

> This is a huge change. Is there a chance we can break it up into more

Attached is v12, which has revisions that address most of your
feedback items. It also includes items that address problems that I
noticed during performance validation work.

Highlights:

* Substantial revisions that give table AMs and index AMs direct
control over batch layout -- without giving up on batch
recycling/caching. This is essentially what you (Andres) requested
because the design from v11 was not sufficiently AM agnostic. In
particular:

- Table AMs now control the size and layout of visibility information
(in practice heapam uses this to store per-item visibility state from
the visibility map).

- Index AMs have their own opaque state for things like sibling link
block numbers, avoiding the assumption that other index AMs supporting
amgetbatch will need to work like nbtree and hash as regards how they
navigate to the next index page/index keyspace associated with each
batch.

* No more read stream yielding. Numerous new patches from Andres are
now included, which helps with this. In particular, "WIP: read_stream:
Only increase distance when waiting for IO" fixes the problematic
regression in an adversarial query -- the one that prompted me to
invent yielding in the first place. As a result of all this, the read
stream callback added by the prefetching commit itself is now
substantially simpler than it was in v11.

* There are now a couple of extra patches created by breaking things
into more distinct commits. Namely, there's a new "heapam: Track heap
block in IndexFetchHeapData using xs_blk" commit, as well as a new
"Make IndexScanInstrumentation a pointer in executor scan nodes"
commit.

* Moreover, some commits now appear in a slightly different order,
prioritizing work closer to being committable; those commits now come
first.

* The SGML doc changes are now broken up a bit better: the commit that
adds basic amgetbatch functionality now includes SGML doc updates
describing the required API revisions. The SGML doc changes that
describe prefetching concepts now appear in the later prefetching
commit.

* New commit "Use simple hash for PrivateRefCount" addresses some of
the problems we were seeing with PrivateRefCount performance. This
generic optimization addresses an existing problem that would
otherwise be much worse with the index prefetching work in place.

* The new xs_read_extremal_only mechanism (which replaces selfuncs.c's
current use of VISITED_PAGES_LIMIT) will now hold on for longer -- up
to 3 index leaf pages can be read before we give up on the scan. The
number of pages that we're willing to read before giving up is now
under the control of the selfuncs.c caller. This addresses concerns
that Tom had about selfuncs.c giving up too easily, for example when
the rightmost/leftmost leaf page happens to have very few items.

* v12 fixes a ~5% regression in bitmap index scans, which was tied to
the fact that we were naively using retail pallocs to allocate new
batches in all cases. The fix gives amgetbitmap routines the ability
to use their own batch cache mechanism, similar to the one used during
amgetbatch scans. In practice, btgetbitmap and hashgetbitmap scans
will never need to do more than one palloc to allocate a batch; they
can continually reuse the same batch. This relies on the fact that
amgetbitmap routines don't actually need to hold open more than a
single batch at a time/maintain a batch ring buffer (just like master
manages currPos state).

* v12 addresses certain performance issues with nestloop joins and
merge joins by no longer dropping the scan's heap page buffer pin on
rescan. This keeps regressions with fully cached workloads to an
absolute minimum, and seems to have no real downside.

However, I have NOT yet acted on a few feedback items from Andres:

* I still don't know what Andres meant about requiring table AMs to
free batch index page buffer pins representing a modularity violation.
I don't see how we can reasonably avoid it while still preserving the
guarantees needed to safely drop buffer pins eagerly during index-only
scans that require prefetching.

* I'm also not at all sure what Andres meant about index AMs like hash
not holding onto their own buffer pins, given that prefetching uses a
read stream sensitive to the number of buffer pins the backend holds.

I understand the general concern about interfering with the read
stream, I think, but I don't see how imposing such a strict rule on
index AMs, which have always been allowed to do this, can be practical
(though note that nbtree doesn't actually hold on to any buffer pins
for internal purposes, and hasn't since Postgres 18 commit 1bd4bc85,
so at least nbtree can be relied on to never do this). I asked Andres
about these 2 points not too long ago [1], and await further feedback
on those questions.

* Separate from the 2 closely related items about batch buffer pins, I
have not yet made most of the changes Andres requested to the "Use
ExecSetTupleBound hint during index scans" commit -- just because I
haven't had time. v11 of the patch set hasn't applied cleanly in about
a week, so I didn't want to further delay posting v12.

Thanks

[1] https://postgr.es/m/CAH2-Wznxu+AFz-EBOG-XiRA_R3nXLp45NEiGSD3ebx3h=[email-masked]

--
Peter Geoghegan

---

From: Andres Freund <[email-masked]>
Date: Tue, 10 Mar 2026 18:29:48 -0400

Hi,

On 2026-03-01 15:39:55 -0500, Peter Geoghegan wrote:
> any scan to ever hold on to a buffer pin.

This seems pretty unrelated to my concern.  I have a problem with the fact
that heapam knows which specific resources need to be held (&released) to
prevent concurrency issues during an index only scan.  I am *NOT* concerned
with there needing to be a pin and heapam triggering the release of that
resource.

All I am saying is that instead of

	/*
	 * It's safe to drop the batch's buffer pin as soon as we've resolved the
	 * visibility status of all of its items
	 */
	if (allbatchitemvisible && scan->MVCCScan)
	{
...
		ReleaseBuffer(batch->buf);
		batch->buf = InvalidBuffer;
	}

it should be something like

	/*
	 * It's safe to drop the batch's buffer pin as soon as we've resolved the
	 * visibility status of all of its items
	 */
	if (allbatchitemvisible && scan->MVCCScan)
	{
...
                index_batchscan_release(scan, batch);
	}

where, unfortunately, index_batchscan_release() has to call back into the
indexam to release the buffer pin.

> To me it seems natural to make such buffer pins (those that specifically
> exist to form an interlock against unsafe concurrent TID recycling by
> VACUUM) the responsibility of the table AM -- that's the only place where it
> actually matters. That's the main reason the AM table releases the batch's
> buffer pin in the current design of index prefetching (though another
> important factor is nestloop anti-joins with inner index-only scans, as I
> detail below).

To me it doesn't seem acceptable to require that index AMs need to have
exactly one buffer that needs to be pinned to prevent dangerous reuse. An
index AM should be free to

a) Prevent reuse by using a resource other than a buffer pin

   That's not possible in the patch, because we unconditionally do
   ReleaseBuffer(batch->buf)

b) Require multiple buffers to be pinned to prevent reuse

   That's not possible in the patch, because exactly one buffer is released.

Sure.

Sure.

> index-only scan. My microbenchmarking shows this clearly.

It's a very plausible way, at least.  I can think of other ways to prevent
regressions, but I don't really see a reason to go for them.

> unpredictable ways (just like plain index scans).

Right.

> all of the visibility information immediately would significantly hurt

Agreed.

Yes.

> Then it also cannot support WAL-logging, I think. Where does that

I don't think WAL-logging requires using the buffer pool.  It might have at
some point, but these days you can just create an rmgr of your own and manage
everything that way.  Yes, dealing with FPIs will be a tad more work, but it's
entirely doable.

> Why is it not an option?

If we care about releasing one pin soon, why should we not care about
the timely release of other pins?

> Is this purely because of the potential to

I'm not particularly bothered by a small number of extra buffer pins,
particularly for AMs other than nbtree. They won't cause issues in any real
world setups.  If nbtree kept a slightly larger number of pins around, I'd
expect *some* problems, due to us running our tests with very small number of
shared buffers.  Imagine the fun of occasionally running out of pins,
depending on when cache invalidations arrive and trigger catalog accesses.

Greetings,

Andres Freund

---

From: Andres Freund <[email-masked]>
Date: Tue, 10 Mar 2026 18:47:52 -0400

Hi,

> navigate to the next index page/index keyspace associated with each

Nice!

> substantially simpler than it was in v11.

Yay.

> "Make IndexScanInstrumentation a pointer in executor scan nodes"

Yay^2.

> prioritizing work closer to being committable; those commits now come

Yay^3.

> otherwise be much worse with the index prefetching work in place.

Let's get that in soon.

Alexandre Felipe posted an implementation of this in
https://postgr.es/m/CAE8JnxNTETEUiAOF31%3D_yo%3DpvyAi9npOeJfcTvEJJbi4vomtYA%40mail.gmail.com

I don't agree with many of the other changes, but the simplehash conversion
contains an interesting piece - the ability to avoid the status field.  I'd
encourage Alexandre to upstream that separately from this thread (and also
separately from the rest of the patches in the above thread).

> read stream sensitive to the number of buffer pins the backend holds.

I tried to respond in
https://postgr.es/m/vbb4naf2tvm2tm7yoml54pzvrmn77p4nvq4awfa4wufc3hn7qx%40mof5q6li3xzv
to explain my concerns / what I think needs to happen.

Greetings,

Andres Freund

---

From: Peter Geoghegan <[email-masked]>
Date: Thu, 12 Mar 2026 13:27:00 -0400

On Thu, Mar 12, 2026 at 10:43 AM Tomas Vondra <[email-masked]> wrote:
> I took a quick look at 0001 and 0002 patches, and I think it's mostly
> ready to go.

Pushed both patches just now. Thanks for the review!

> I only have a couple very minor comments:
>
> 1) v12-0001-Use-simple-hash-for-PrivateRefCount.patch

> - the "common/hashfn.h" include seems in the wrong place

Fixed in the pushed patch.

> 2) v12-0002-Don-t-allocate-_bt_search-stack.patch
>
> - seems like a reasonable optimization for most callers
> - _bt_search could set "stack_in = new_stack;" in the first if block, I
> think it's nicer

Agreed. Done that way in the commit that I pushed.

> - My only concern is that _bt_search is part of the public API (at least
> technically, as it's in nbtree.h), and we're removing _bt_freestack()
> from the API so the callers won't have a convenient way to free it.
> Maybe that's not an issue in practice, because doing a local version of
> freestack is easy.

I don't think that it's an issue. _bt_search is very much an
nbtree-internal function (amcheck notwithstanding), and as you point
out it's easy enough for an extension to create its own version of
_bt_freestack in the unlikely event that it needs to.

--
Peter Geoghegan

---

From: Alexandre Felipe <[email-masked]>
Date: Thu, 12 Mar 2026 18:32:23 +0000

>
> - seems like a (mostly) mechanical switch for a different hashtable

But Andres slapped me with a question: "Did you try on a real query?"
When I tried on a real query it was a bit slower on the that Andres gave as
an
example:
SELECT sum(balance) INTO s FROM uncorrelated WHERE id > 1000 and id <
500000;

It is marginal but consistently slower, I guess it is just the presence of
more code
patched: 120.36 - 124.98
master: 116.19 - 118.09
Passing -falign-functionns=64
patched: 117.82 - 118.88
master: 115.40 - 119.91

Maybe just the presence of more the code, because we don't touch the hash.
I tried with SH_SCOPE `static` or `static pg_noinline` but that doesn't
help either.
Assuming one ReadBuffer/ReleaseBuffer per row, we can infer infer that it
was
~3ms / 5e5 = 6ns slower.

On micro benchmarking it cuts the gap by half when holding 100 to 1000
buffers.

If we can accept one more pointer dereference, by having a pointer instead
of a static
array we can reduce by the ramp by 80% (and no, that is not achieved by
placing
the bottom of the ramp half way up hehe)

Regards,
Alexandre

---

From: Peter Geoghegan <[email-masked]>
Date: Thu, 12 Mar 2026 15:44:32 -0400

> with there needing to be a pin and heapam triggering the release of that

Attached v13 does things this way.

All index AMs that use amgetbatch must register a new amreleasebatch
callback in v13. This callback releases any buffer pin held as an
interlock against unsafe concurrent TID recycling by VACUUM -- at
least for nbtree and hash. The buffer itself is now stored in the
portion of each batch used as opaque index AM state, so nothing stops
other index AMs that support amgetbatch in the future from using a
different kind of interlock, from holding multiple pins instead of
just one, etc.

We have a generic policy that determines (at the level of each scan)
whether an interlock is required at all. If an interlock isn't
required, index AMs will drop both their lock and pin together.
Otherwise, they will only drop the lock, and only drop the
pin/abstract interlock when the new amreleasebatch callback is called
by the table AM. Our policy is to assume that we don't need a TID
recycling interlock except during index-only scans and non-MVCC scans
(regardless of the table AM and index AM involved).

Separately, I removed the requirement that amgetbatch index AMs
support Valgrind instrumentation requests like those nbtree has long
supported. And I removed all such instrumentation from the hash index
AM.

> world setups.

Okay. Then I don't think that we need to do anything here. As you
know, nbtree never holds buffer pins for its own internal purposes.
The hash index AM continues to hang on to up to 2 extra buffer pins
for its own purposes, just like on master.

--
Peter Geoghegan

---

From: Peter Geoghegan <[email-masked]>
Date: Fri, 13 Mar 2026 20:50:50 -0400

> Attached v13 does things this way.

Several patches from this patchset have been committed to master. I'm
posting a new version (v14) here to keep the patch applying cleanly.

There are no notable changes to the patches themselves here -- just
some minor cleanup of the docs, and some comments.

-- 
Peter Geoghegan

---

From: Peter Geoghegan <[email-masked]>
Date: Tue, 17 Mar 2026 13:37:34 -0400

> posting a new version (v14) here to keep the patch applying cleanly.

v14 has already bitrot, so I'm posting this new v15 to keep the
patchset applying cleanly.

There is one new patch in v15:
v15-0007-Add-UnlockBufferGetLSN-utility-function.patch. This adds a
new utility function to bufmgr.c, which combines the functionality of
BufferGetLSNAtomic with UnlockReleaseBuffer. This microoptimization is
intended to avoid regressions against master with workloads like
pgbench SELECT. With this, we're slightly faster than the master
branch on these workloads.

-- 
Peter Geoghegan

---

From: Peter Geoghegan <[email-masked]>
Date: Thu, 19 Mar 2026 13:44:16 -0400

> patchset applying cleanly.

And now v15 has bitrot. So here's a new v16. There are 2 notable updates here.

Most notably, I decided to take a more conservative approach to fixing
the issues with the selfuncs.c mechanism that gives up when
get_actual_variable_range does too much work during planning. Rather
than inventing a new approach that bases the decision to give up on
the number of leaf pages accessed, which has certain advantages [1], I
have decided to cut scope and more or less reimplement the existing
VISITED_PAGES_LIMIT mechanism on the heapam side. This was a pragmatic
choice; there is no clear consensus on whether the design I proposed
for this will be acceptable, and we lack the time to resolve those
issues now.

To recap, selfuncs.c can no longer be the place where
VISITED_PAGES_LIMIT is implemented, because the new slot-based
interface won't return until it finds a visible tuple. The new
higher-level interface simply cannot support callers that want to
count the number of heap pages examined themselves.

The other notable change aims to improve performance for cached
workloads through more aggressive specialization of callback routines
like heapam_index_getnext_slot. There are now a total of 4 versions:

1. One for index-only scans + amgetbatch.
2. One for one for index-only scans + amgettuple.
3. One for plain index scans + amgetbatch.
4. One for plain index scans + amgettuple.

I've also aggressively used pg_attribute_always_inline (without this
specialization, performance ends up worse rather than better).

There might be some concerns about the distributed costs of increased
code size. But this revision is the fastest so far across all
available metrics (though of course the specialization work is
focussed on cached query performance specifically).

[1] (see https://www.postgresql.org/message-id/flat/CAH2-Wzkt1WkKp4VRJu3qHfmKXc8W%2BXYv1RXg5d2d3fSvAeO%3Drg%40mail.gmail.com)
-- 
Peter Geoghegan

---

From: Andres Freund <[email-masked]>
Date: Fri, 20 Mar 2026 20:33:02 -0400

Hi,

On 2026-03-19 13:44:16 -0400, Peter Geoghegan wrote:
> code size.

I'm not particularly concerned about that - I think it actually might often
*increase* the icache hit ratio, because without this specialization every
scan has to jump over a fair bit of code not used during that scan.

> From 8bcd4eb22a6479302fecaf5088c1f03f43f1f407 Mon Sep 17 00:00:00 2001
> Date: Tue, 10 Mar 2026 14:22:25 -0400
> Subject: [PATCH v16 01/17] Make IndexScanInstrumentation a pointer in executor
>  scan nodes.
>
> Change the IndexScanInstrumentation fields in IndexScanState,
> IndexOnlyScanState, and BitmapIndexScanState from inline structs to
> pointers.  This avoids adding additional overhead as new fields are
> added to IndexScanInstrumentation, at least in the common case where the
> instrumentation isn't used (i.e. when the executor node isn't being run
> through an EXPLAIN ANALYZE).

LGTM

> From 997822891f393a13be22c1be2d2d0094a485fddf Mon Sep 17 00:00:00 2001
> Date: Tue, 10 Mar 2026 14:40:35 -0400
> Subject: [PATCH v16 02/17] heapam: Track heap block in IndexFetchHeapData
>  using xs_blk
>
> Add an explicit BlockNumber field (xs_blk) to IndexFetchHeapData that
> tracks which heap block is currently pinned in xs_cbuf.
>
> heapam_index_fetch_tuple now uses xs_blk to determine when buffer
> switching is needed, replacing the previous approach that compared
> buffer identities via ReleaseAndReadBuffer on every non-HOT-chain call.

> The new approach skips the buffer-switching path entirely when the next
> TID is on the same heap page, which also means that heap_page_prune_opt
> is called exactly once per block (when the block is first pinned).

I think that was already almost always the case, due to the
if (prev_buf != hscan->xs_cbuf)

> This is preparatory work for an upcoming commit that will need xs_blk
> to manage buffer pin transfers between the scan and the executor slot.

A subsequent commit adds an earlier ExecClearTuple(slot) to make the buffer
refcount decrement cheaper (due to hitting the one-element cache in
bufmgr.c). I wonder if it's worth pulling that into this commit? Mostly to
make that larger commit smaller.

> From c83aef317ba452b7631feee10062d92f33a64930 Mon Sep 17 00:00:00 2001
> Subject: [PATCH v16 03/17] Add interfaces that enable index prefetching.

>
> -/* mark current scan position */
> -typedef void (*ammarkpos_function) (IndexScanDesc scan);
> -
> -/* restore marked scan position */
> -typedef void (*amrestrpos_function) (IndexScanDesc scan);
> +/* invalidate index AM state that independently tracks scan's position */
> +typedef void (*amposreset_function) (IndexScanDesc scan,
> +									 IndexScanBatch batch);
>
>  /*
>   * Callback function signatures - for parallel index scans.
> @@ -309,10 +320,12 @@ typedef struct IndexAmRoutine
>  	ambeginscan_function ambeginscan;
>  	amrescan_function amrescan;
>  	amgettuple_function amgettuple; /* can be NULL */
> +	amgetbatch_function amgetbatch; /* can be NULL */
> +	amkillitemsbatch_function amkillitemsbatch; /* can be NULL */
> +	amunguardbatch_function amunguardbatch; /* can be NULL */
>  	amgetbitmap_function amgetbitmap;	/* can be NULL */
>  	amendscan_function amendscan;
> -	ammarkpos_function ammarkpos;	/* can be NULL */
> -	amrestrpos_function amrestrpos; /* can be NULL */
> +	amposreset_function amposreset; /* can be NULL */
>
>  	/* interface functions to support parallel index scans */
>  	amestimateparallelscan_function amestimateparallelscan; /* can be NULL */

The commit message doesn't mention that this affects ammarkpos/amrestrpos.

Does that imply that index AMs that want to support mark/restore need to
switch away from amgettuple? Probably fine, but worth mentioning, I think.

> diff --git a/src/include/access/genam.h b/src/include/access/genam.h
> index 1a27bf060..6b5c48e5e 100644
> --- a/src/include/access/genam.h
> +++ b/src/include/access/genam.h
> @@ -96,6 +96,7 @@ typedef bool (*IndexBulkDeleteCallback) (ItemPointer itemptr, void *state);
>
> +/*
> + * amgetbatch utilities called by indexam.c (in indexbatch.c)
> + */
> +extern void index_batchscan_init(IndexScanDesc scan);
> +extern void index_batchscan_reset(IndexScanDesc scan);
> +extern void index_batchscan_end(IndexScanDesc scan);
> +extern void index_batchscan_mark_pos(IndexScanDesc scan);
> +extern void index_batchscan_restore_pos(IndexScanDesc scan);
> +
> +/*
> + * amgetbatch utilities called by table AMs (in indexbatch.c)
> + */
> +extern void tableam_util_batch_dirchange(IndexScanDesc scan);
> +extern void tableam_util_kill_scanpositem(IndexScanDesc scan);
> +extern void tableam_util_free_batch(IndexScanDesc scan, IndexScanBatch batch);
> +extern void tableam_util_unguard_batch(IndexScanDesc scan, IndexScanBatch batch);
> +
> +/*
> + * amgetbatch utilities called by index AMs (in indexbatch.c)
> + */
> +extern void indexam_util_batch_unlock(IndexScanDesc scan, IndexScanBatch batch,
> +									  Buffer buf);
> +extern IndexScanBatch indexam_util_batch_alloc(IndexScanDesc scan);
> +extern void indexam_util_batch_release(IndexScanDesc scan, IndexScanBatch batch);
> +
>  #endif							/* GENAM_H */

I wonder if these should be in a separate header, a reasonably large number of
files include genam.h due to systable_beginscan() etc, and those should not
need to know about this stuff.

> diff --git a/src/include/access/heapam.h b/src/include/access/heapam.h
> index 9bcf74862..55c9069d6 100644
> --- a/src/include/access/heapam.h
> +++ b/src/include/access/heapam.h
> @@ -129,10 +129,37 @@ typedef struct IndexFetchHeapData
>  	Buffer		xs_cbuf;
>  	BlockNumber xs_blk;
>
> -	/* Current heap block's corresponding page in the visibility map */
> -	Buffer		xs_vmbuffer;
> +	/* For index-only scans that must access the visibility map */
> +	Buffer		xs_vmbuffer;	/* visibility map buffer */
> +	int			xs_vm_items;	/* # items to resolve visibility info for */
> +	bool		xs_lastinblock; /* last TID on this block in current batch? */
> +
>  } IndexFetchHeapData;

FWIW, Melanie is going to use xs_vmbuffer for on-access pruning soon, maybe
worth not phrasing it as specific to IOSs as it will need to be revised anyway.

> + typedef struct HeapBatchData
> + {
> + } HeapBatchData;
> +  * Per-item visibility flags stored in HeapBatchData.visInfo array
> +  */
> + #define HEAP_BATCH_VIS_CHECKED		0x01	/* checked item in VM? */
> + #define HEAP_BATCH_VIS_ALL_VISIBLE	0x02	/* block is known all-visible? */

So we only 2 out of 8 bits. I guess it's probably not worth doing bit shift
stuff. But still curious if you tried?

> diff --git a/src/include/access/nbtree.h b/src/include/access/nbtree.h
> index da7503c57..74af9efc3 100644
> --- a/src/include/access/nbtree.h
> +++ b/src/include/access/nbtree.h
> @@ -924,111 +924,27 @@ typedef struct BTVacuumPostingData
>
> + * Access the btree-private per-batch data from an IndexScanBatch pointer.
> + * This follows the standard convention for index AM opaque state: it can be
> + * found at a fixed negative offset from the IndexScanBatch pointer.
>   */

> +static inline BTBatchData *
> +BTBatchGetData(IndexScanBatch batch)
>  {
> +	return (BTBatchData *) ((char *) batch - MAXALIGN(sizeof(BTBatchData)));
> +}

I think it maybe worth threading this through a generic helper macro taking
IndexScanDesc, IndexScanBatch, and the type of the private patch data that
does the pointer math and also can assert that
  iscan->batch_index_opaque_size == MAXALIGN(sizeof(BTBatchData))

> + * index scans.
> + * Each batch allocation has the following memory layout:
> + *
> + *   [table AM opaque area]    <- at -(batch_table_offset) from batch ptr
> + *   [index AM opaque area]    <- at -(batch_index_opaque_size) from batch ptr
> + *   [IndexScanBatchData]      <- the returned pointer
> + *   [items[maxitemsbatch]]
> + *   [table AM trailing data]  <- e.g. per-item visibility flags
> + *   [currTuples workspace]    <- sized by index AM (batch_tuples_workspace)
> + *
> + * The AM-specific opaque areas are accessed via accessor functions defined by
> + * each table AM and index AM that supports the batch interfaces.
> + */
> +	XLogRecPtr	lsn;			/* index page's LSN */

Still doubtful this should be in generic infra (together with
indexam_util_batch_unlock()).

> + * The current maximum of 64 batches is somewhat of an arbitrary limit.  Very
> + * few scans ever get near to this limit in practice.
> + */
> +#define INDEX_SCAN_MAX_BATCHES		64

Just curious: How did you end up with INDEX_SCAN_CACHE_BATCHES == 2?

> +/*
> + * State used by table AMs to manage an index scan that uses the amgetbatch
> + * interface.  Scans use a ring buffer of batches returned by amgetbatch.
> + *
> + * Batches are kept in the order that they were returned in by amgetbatch,
> + * since that is the same order that table_index_getnext_slot will return
> + * matches in.  However, table AMs are free to fetch table tuples in whatever
> + * order is most convenient/efficient -- provided that such reordering cannot
> + * affect the order that table_index_getnext_slot later returns tuples in.
> + */
> +typedef struct BatchRingBuffer
> +{
> +	/* current positions in batches[] for scan */
> +	BatchRingItemPos scanPos;	/* scan's read position */
> +	BatchRingItemPos markPos;	/* mark/restore position */
> +
> +	IndexScanBatch markBatch;
> +
> +	/*
> +	 * headBatch is an index to the earliest still-valid batch in 'batches'.
> +	 * In practice this must be the scan's current scanPos batch (scanBatch).
> +	 */
> +	uint8		headBatch;
> +
> +	/*
> +	 * nextBatch is an index to the next empty batch slot in 'batches'.  This
> +	 * is only actually usable when the scan is !index_scan_batch_full().
> +	 */
> +	uint8		nextBatch;

Is nextBatch basically the tail?

The "only actually usable" bit is a bit confusing, given that
index_scan_batch_full() is implemented by
  (nextBatch - headBatch) == INDEX_SCAN_MAX_BATCHES

Maybe worth having a static assert somewhere to ensure that
INDEX_SCAN_MAX_BATCHES isn't set to a value bigger than what fits into a
uint8?

> +/*
> + * Append given batch to scan's batch ring buffer.
> + */
> +static inline void
> +index_scan_batch_append(IndexScanDescData *scan, IndexScanBatch batch)
> +{
> +	BatchRingBuffer *ringbuf = &scan->batchringbuf;
> +	uint8		nextBatch = ringbuf->nextBatch;
> +
> +	ringbuf->batches[nextBatch & (INDEX_SCAN_MAX_BATCHES - 1)] = batch;
> +	ringbuf->nextBatch++;
> +}

Perhaps worth an assert ensuring that there's space in the ring buffer?

> diff --git a/src/include/executor/instrument_node.h b/src/include/executor/instrument_node.h
> index 8847d7f94..b5b8f509a 100644
> --- a/src/include/executor/instrument_node.h
> +++ b/src/include/executor/instrument_node.h
> @@ -48,6 +48,12 @@ typedef struct IndexScanInstrumentation
>  {
>  	/* Index search count (incremented with pgstat_count_index_scan call) */
>  	uint64		nsearches;
> +
> +	/*
> +	 * heap blocks fetched counts (incremented by index_getnext_slot calls
> +	 * within table AMs, though only during index-only scans)
> +	 */
> +	uint64		nheapfetches;
>  } IndexScanInstrumentation;

s/heap/table/ for anything new imo.

> + *		Obtain visibility information for a TID from caller's batch.

I really dislike these pointless restatements of the function name and the
weird indentation of the function "title" that's indented differently across
files and in the file.  Since they're not in use in heapam_handler.c can we
please not introduce them now?

> + * Note on Memory Ordering Effects

ISTM that moving this comment here is hiding it too much, I'm pretty sure
there are other AMs using visibilitymap out there.  Perhaps we should just
move it to the top of visibilitymap.c?

> +static void
> +heapam_batch_resolve_visibility(IndexScanDesc scan, ScanDirection direction,
> +								IndexScanBatch batch, HeapBatchData *hbatch,
> +								BatchRingItemPos *pos)
> +{
> +	BlockNumber curvmheapblkno = InvalidBlockNumber;
> +	uint8		curvmheapblkflags = 0;
> +
> +	Assert(hbatch == heap_batch_data(batch, scan));
> +
> +	/*
> +	 * We better still have the TID recycling interlock (generally a pin on
> +	 * the batch's index page) -- amunguardbatch has not been called yet
> +	 */
> +	Assert(!scan->batchImmediateUnguard);
> +
> +	if (ScanDirectionIsForward(direction))
> +	{
> +			 (hbatch->visInfo[batch->firstItem] & HEAP_BATCH_VIS_CHECKED));
> +		step = 1;
> +	}
> +	else
> +	{
> +			 (hbatch->visInfo[batch->lastItem] & HEAP_BATCH_VIS_CHECKED));
> +		step = -1;
> +	}
> +
> +	/*

I'm surprised this is better than two loops, tbh. It makes the actual looping
code more complicated, with higher register pressure.

> +/* ----------------
> + *		heapam_batch_getnext - get the next batch of TIDs from a scan
> + *
> + * Called when we need to load the next batch of index entries to process in
> + * the given direction.  Caller passes us a batch and a batch position, which
> + * has just been used to read all items from the batch in the direction passed
> + * by caller.
> + *
> + * Returns the next batch to be processed by the index scan, or NULL when
> + * there are no more matches in the given scan direction.  Does not advance
> + * caller's batch position; that is left up to caller.
> + *
> + * This is also where batches are appended to the scan's ring buffer.  We
> + * don't free any batches here, though; that is also left up to caller.
> + * ----------------
> + */
> +static pg_attribute_hot IndexScanBatch
> +heapam_batch_getnext(IndexScanDesc scan, ScanDirection direction,
> +					 IndexScanBatch priorBatch, BatchRingItemPos *pos)
> +{

Kinda seems nothing (except the one assert below) in here is heap specific?
I.e. that every table AM will need pretty exactly this?  Seems like it ought
to be somewhere where it can be reused.  Could be a static inline in an
indexbatch.h file or such if inlining were desirable.

> +/* ----------------
> + *		heapam_batch_getnext_tid - get next TID from batch ring buffer
> + *
> + * Get the next TID from the scan's batch ring buffer, when moving in the
> + * given scan direction.
> + * ----------------
> + */
> +static pg_attribute_hot pg_attribute_always_inline ItemPointer
> +heapam_batch_getnext_tid(IndexScanDesc scan, IndexFetchHeapData *hscan,
> +						 ScanDirection direction, bool *all_visible)
> +{
> +	BatchRingBuffer *batchringbuf = &scan->batchringbuf;
> +	BatchRingItemPos *scanPos = &batchringbuf->scanPos;
> +	IndexScanBatch scanBatch = NULL;
> +
> +	Assert(!scanPos->valid || batchringbuf->headBatch == scanPos->batch);
> +	Assert(scanPos->valid || index_scan_batch_count(scan) == 0);
> +	Assert(all_visible == NULL || scan->xs_want_itup);
> +
> +	/*
> +	 * Check if there's an existing loaded scanBatch for us to return the next
> +	 * matching item's TID/index tuple from
> +	 */
> +	if (scanPos->valid)
> +	{
> +		/*
> +		 * scanPos is valid, so scanBatch must already be loaded in batch ring
> +		 * buffer.  We rely on that here.
> +		 */
> +		Assert(batchringbuf->headBatch == scanPos->batch);
> +
> +		scanBatch = index_scan_batch(scan, scanPos->batch);
> +
> +		if (index_scan_pos_advance(direction, scanBatch, scanPos))
> +			return heapam_batch_return_tid(scan, hscan, direction,
> +										   scanBatch, scanPos,
> +										   all_visible);
> +	}
> +
> +	/*
> +	 * Either ran out of items from our existing scanBatch, or it hasn't been
> +	 * loaded yet (because this is the first call here for the entire scan).
> +	 * Try to advance scanBatch to the next batch (or get the first batch).
> +	 */
> +	scanBatch = heapam_batch_getnext(scan, direction, scanBatch, scanPos);
> +
> +	if (!scanBatch)
> +	{
> +		/*
> +		 * We're done; no more batches in the current scan direction.
> +		 *
> +		 * Note: scanPos is generally still valid at this point.  The scan
> +		 * might still back up in the other direction.
> +		 */
> +		return NULL;
> +	}
> +
> +	/*
> +	 * Advanced scanBatch.  Now position scanPos to the start of new
> +	 * scanBatch.
> +	 */
> +	index_scan_pos_nextbatch(direction, scanBatch, scanPos);
> +	Assert(index_scan_batch(scan, scanPos->batch) == scanBatch);
> +
> +	/*
> +	 * Remove the head batch from the batch ring buffer (except when this new
> +	 * scanBatch is our only one)
> +	 */
> +	if (batchringbuf->headBatch != scanPos->batch)
> +	{
> +		IndexScanBatch headBatch = index_scan_batch(scan,
> +													batchringbuf->headBatch);
> +
> +		/* free obsolescent head batch (unless it is scan's markBatch) */
> +		tableam_util_free_batch(scan, headBatch);
> +
> +		/* Remove the batch from the ring buffer */
> +		batchringbuf->headBatch++;
> +	}
> +
> +	/* In practice scanBatch will always be the ring buffer's headBatch */
> +	Assert(batchringbuf->headBatch == scanPos->batch);
> +
> +	return heapam_batch_return_tid(scan, hscan, direction, scanBatch, scanPos,
> +								   all_visible);
> +}
> +

A lot of this also seems like it should be generic code.  Where it actually
starts to be heapam specific is in heapam_batch_return_tid() - and there's two
calls to that.  So maybe there should be generic helper for the bulk of
heapam_batch_getnext_tid() that's called by the heapam version, which either
returns a NULL upwards or does a single heapam_batch_return_tid().

> +/* ----------------
> + *		heapam_index_fetch_heap - get the scan's next heap tuple
> + *
> + * The result is a visible heap tuple associated with the index TID most
> + * recently fetched by our caller in scan->xs_heaptid, or NULL if no more
> + * matching tuples exist.  (There can be more than one matching tuple because
> + * of HOT chains, although when using an MVCC snapshot it should be impossible
> + * for more than one such tuple to exist.)
> + *
> + * On success, the buffer containing the heap tup is pinned.  The pin must be
> + * dropped elsewhere.
> + * ----------------
> + */
> +static pg_attribute_hot pg_attribute_always_inline bool

---

From: Andres Freund <[email-masked]>
Date: Sat, 21 Mar 2026 15:54:40 -0400

Hi,

On 2026-03-20 20:33:02 -0400, Andres Freund wrote:
> I'll try to read through the rest soon. But now: Food.

I'm still wondering about whether 0003 can be split apart - it's just huge and
does a lot of things at once. I find it hard to get patches of that size into
a mergeable state at once.

What about splitting out the changes to the indexscan API, i.e. moving IOS
handling to indexam.c / heapam_handler.c into its own commit?  I feel like
that's a sizeable chunk that needs to touch a lot of places that don't need to
be touched in the commit adding batching. And it's such an architectural
improvement that it's a worthwhile thing to do on its own.

One advantage of that would be that we could merge the VISITED_PAGES_LIMIT
before the introduction of batches.

If the the amgetbatch introduction didn't also replace ammarkpos/amrestrpos
with amposreset (see also question in previous email), I'd say that I'd split
off the introduction of the batch interface from the conversion of btree to
it. But it looks like it'd be not entirely trivial to keep both interfaces
around, so maybe that's not worth it.

> From c1d6a412a943189372cc733b1041bff96e8c8e76 Mon Sep 17 00:00:00 2001
> Date: Thu, 19 Mar 2026 12:32:34 -0400
> Subject: [PATCH v16 04/17] Add VISITED_PAGES_LIMIT to heapam
>
> ---
>  src/include/access/relscan.h             |  7 +++++++
>  src/backend/access/heap/heapam_handler.c | 22 ++++++++++++++++++++++
>  src/backend/access/index/genam.c         |  1 +
>  src/backend/utils/adt/selfuncs.c         |  9 +++++----
>  4 files changed, 35 insertions(+), 4 deletions(-)
>
> diff --git a/src/include/access/relscan.h b/src/include/access/relscan.h
> index 9c1563c88..13370b1c9 100644
> --- a/src/include/access/relscan.h
> +++ b/src/include/access/relscan.h
> @@ -372,6 +372,13 @@ typedef struct IndexScanDescData
>
>  	/* parallel index scan information, in shared memory */
>  	struct ParallelIndexScanDescData *parallel_scan;
> +
> +	/*
> +	 * Limit on distinct heap pages visited before giving up (0 = no limit).
> +	 * Used by selfuncs.c to bound the cost of get_actual_variable_endpoint().
> +	 */
> +	uint8		xs_visited_pages_limit;

I'd maybe phrase it a bit more vaguely, e.g

    An approximate limit on the amount of work, measured in pages touched,
    imposed on the index scan. The default, 0, means no limit. Used by
    selfuncs.c to bound the cost of get_actual_variable_endpoint().

so that, in the near future (presumably 20), we can just start to also take
the number of index pages visited into account. Because I do think your
concern that leaving out the number of index pages from the limit is
correct. The easiest way there seems to be to just have one shared limit.

> diff --git a/src/backend/access/heap/heapam_handler.c b/src/backend/access/heap/heapam_handler.c
> index b8cc3d39c..1b58db199 100644
> --- a/src/backend/access/heap/heapam_handler.c
> +++ b/src/backend/access/heap/heapam_handler.c
> @@ -824,6 +824,7 @@ heapam_index_only_getnext_slot(IndexScanDesc scan, ScanDirection direction,
>  				heap_continue = false;
>
>  	Assert(scan->xs_want_itup);
> +	Assert(scan->xs_visited_pages_limit == 0);
>
>  	for (;;)
>  	{

Why do we not need to support this for amgettuple indexes?

> @@ -906,6 +907,9 @@ heapam_index_only_batch_getnext_slot(IndexScanDesc scan, ScanDirection direction
>  	ItemPointer tid;
>  	bool		all_visible,
>  				heap_continue = false;
> +	BlockNumber last_visited_block = InvalidBlockNumber;
> +	uint8		n_visited_pages = 0,
> +				xs_visited_pages_limit = scan->xs_visited_pages_limit;
>
>  	Assert(scan->xs_want_itup);
>
> @@ -936,7 +940,25 @@ heapam_index_only_batch_getnext_slot(IndexScanDesc scan, ScanDirection direction
>  				scan->instrument->nheapfetches++;
>
>  			if (!heapam_index_fetch_heap(scan, slot, &heap_continue, true))
> +			{
> +				/*
> +				 * No visible tuple.  If caller set a visited-pages limit
> +				 * (only selfuncs.c does this), count distinct heap pages and
> +				 * give up once we've visited too many.
> +				 */
> +				if (unlikely(xs_visited_pages_limit > 0))
> +				{
> +					if (hscan->xs_blk != last_visited_block)
> +					{
> +						last_visited_block = hscan->xs_blk;
> +						if (++n_visited_pages > xs_visited_pages_limit)
> +							return false;	/* give up */
> +					}
> +				}
>  				continue;		/* no visible tuple, try next index entry */
> +			}
>
>  			ExecClearTuple(slot);

I wonder if the accounting for the number of visited pages wouldn't better be
done in heapam_index_fetch_tuple(), as that already has a dedicated branch for
the cross-heap-page-boundary case.  I guess the current location was
convenient because it provides for an easy control flow for ending the scan?

> diff --git a/src/backend/access/index/genam.c b/src/backend/access/index/genam.c
> index 6e87169c2..d0b68f38e 100644
> --- a/src/backend/access/index/genam.c
> +++ b/src/backend/access/index/genam.c
> @@ -126,6 +126,7 @@ RelationGetIndexScan(Relation indexRelation, int nkeys, int norderbys)
>  	scan->xs_itupdesc = NULL;
>  	scan->xs_hitup = NULL;
>  	scan->xs_hitupdesc = NULL;
> +	scan->xs_visited_pages_limit = 0;
>
>  	scan->batch_index_opaque_size = 0;
>  	scan->batch_tuples_workspace = 0;
> diff --git a/src/backend/utils/adt/selfuncs.c b/src/backend/utils/adt/selfuncs.c
> index 1f8b0e684..768581cf7 100644
> --- a/src/backend/utils/adt/selfuncs.c
> +++ b/src/backend/utils/adt/selfuncs.c
> @@ -7168,11 +7168,11 @@ get_actual_variable_endpoint(Relation heapRel,
>  	 * pages.  When we fail for that reason, the caller will end up using
>  	 * whatever extremal value is recorded in pg_statistic.
>  	 *
> -	 * XXX This can't work with the new table_index_getnext_slot interface,
> -	 * which simply won't return a tuple that isn't visible to our snapshot.
> -	 * table_index_getnext_slot will need some kind of callback that provides
> -	 * a way for the scan to give up when the costs start to get out of hand.
> +	 * We set xs_visited_pages_limit to tell the table AM to count distinct
> +	 * heap pages visited for non-visible tuples and give up after the limit
> +	 * is exceeded.
>  	 */
> +#define VISITED_PAGES_LIMIT 100
>  	InitNonVacuumableSnapshot(SnapshotNonVacuumable,
>  							  GlobalVisTestFor(heapRel));
>
> @@ -7180,6 +7180,7 @@ get_actual_variable_endpoint(Relation heapRel,
>  								 &SnapshotNonVacuumable, NULL,
>  								 1, 0);
>  	Assert(index_scan->xs_want_itup);
> +	index_scan->xs_visited_pages_limit = VISITED_PAGES_LIMIT;
>  	index_rescan(index_scan, scankeys, 1, NULL, 0);

Wonder if it'd be worth doing this via a wrapper like index_limit_effort()
that could check that the scan is an IOS, uses batch mode etc.

> From c1602d1dc3c4e2dcd4afad12400ed3c28db71396 Mon Sep 17 00:00:00 2001
> Subject: [PATCH v16 05/17] Add heapam index scan I/O prefetching.
>

> faster.  Large range scans that return many tuples can be as much as 35x

I think on networked storage (unfortunately what most postgres instances run
on these days), there's easily much bigger wins :)

Perhaps worth mentioning that the wins are bigger with higher latency storage?

> the read stream is temporarily paused using the read stream pausing
> mechanism added by commit 38229cb9.  Prefetching (via the read stream)
> is resumed only after the scan position advances beyond its current open
> batch and then frees and removes the batch from the scan's batch ring
> buffer.  Testing has shown that it isn't very common for scans to hold
> open more than about 10 batches to get the desired I/O prefetch
> distance.
>
> The heuristic used to decide when to begin prefetching -- delaying until
> the scan's second batch -- is imperfect.

Perhaps worth introducing the heuristic, and the need for it, in a bit more
detail?

> Selective index scans that
> access randomly-ordered heap pages will benefit from prefetching, but

s/will/would/?

> diff --git a/src/include/access/relscan.h b/src/include/access/relscan.h
> index 13370b1c9..3eb41e707 100644
> --- a/src/include/access/relscan.h
> +++ b/src/include/access/relscan.h

> +/*
> + * Compare two batch ring positions in the given scan direction.
> + *
> + * Returns negative if pos1 is behind pos2, 0 if equal, positive if pos1 is
> + * ahead of pos2.
> + */
> +static inline int
> +index_scan_pos_cmp(BatchRingItemPos *pos1, BatchRingItemPos *pos2,
> +	int8		batchdiff = (int8) (pos1->batch - pos2->batch);
> +
> +	if (batchdiff != 0)
> +		return batchdiff;
> +
> +	/* Same batch, compare items */
> +		return pos1->item - pos2->item;
> +	else
> +		return pos2->item - pos1->item;
> +}

Continuing to think that a bunch of this really doesn't belong into relscan.h
but should be in a dedicated indexbatch.h or such.

>  /*
>   * Advance position to its next item in the batch.
>   *
> diff --git a/src/include/optimizer/cost.h b/src/include/optimizer/cost.h
> index f2fd5d315..419300a6b 100644
> --- a/src/include/optimizer/cost.h
> +++ b/src/include/optimizer/cost.h
> @@ -52,6 +52,7 @@ extern PGDLLIMPORT int max_parallel_workers_per_gather;
>  extern PGDLLIMPORT bool enable_seqscan;
>  extern PGDLLIMPORT bool enable_indexscan;
>  extern PGDLLIMPORT bool enable_indexonlyscan;
> +extern PGDLLIMPORT bool enable_indexscan_prefetch;
>  extern PGDLLIMPORT bool enable_bitmapscan;
>  extern PGDLLIMPORT bool enable_tidscan;
>  extern PGDLLIMPORT bool enable_sort;

Just reminded me: At some point soon we're going to have to adjust the costing
model to account for prefetching. I think there will be a fewer cases where
bitmap scans are the correct choice than there are today...

> diff --git a/src/backend/access/heap/heapam_handler.c b/src/backend/access/heap/heapam_handler.c
> index 1b58db199..28a144d1c 100644
> --- a/src/backend/access/heap/heapam_handler.c
> +++ b/src/backend/access/heap/heapam_handler.c
> @@ -37,6 +37,7 @@
>  #include "commands/progress.h"
>  #include "executor/executor.h"
>  #include "miscadmin.h"
> +#include "optimizer/cost.h"
>  #include "pgstat.h"
>  #include "storage/bufmgr.h"
>  #include "storage/bufpage.h"

I suspect there's a good argument for moving the decision about whether to
enable prefetching to the planner instead of doing it here. But doing it here
doesn't have a lot of architectural impact for now, so we don't necessarily
have to move the point of decision making now.

> @@ -61,6 +62,9 @@ static BlockNumber heapam_scan_get_blocks_done(HeapScanDesc hscan);
>  static bool BitmapHeapScanNextBlock(TableScanDesc scan,
>  									bool *recheck,
>  									uint64 *lossy_pages, uint64 *exact_pages);
> +static BlockNumber heapam_getnext_stream(ReadStream *stream,
> +										 void *callback_private_data,
> +										 void *per_buffer_data);
>

I think heapam_getnext_stream() is too generally named. For one it could just
as well be for a sequential scan, for another it sounds like it's a function
like heap_getnext() or such.  Maybe heapam_index_scan_next_block() or such?

>  /* ------------------------------------------------------------------------
> @@ -102,6 +106,17 @@ heapam_index_fetch_reset(IndexFetchTableData *scan)
>  	/* Rescans should avoid an excessive number of VM lookups */
>  	hscan->xs_vm_items = 1;
>
> +	/* Reset read stream direction unconditionally */
> +	hscan->xs_read_stream_dir = NoMovementScanDirection;

That's obviously the case given the assignment, but it doesn't even hint at
why?

> @@ -192,7 +210,14 @@ heapam_index_fetch_tuple(struct IndexFetchTableData *scan,
>  		if (BufferIsValid(hscan->xs_cbuf))
>  			ReleaseBuffer(hscan->xs_cbuf);
>
> -		hscan->xs_cbuf = ReadBuffer(hscan->xs_base.rel, hscan->xs_blk);
> +		/*
> +		 * When using a read stream, the stream will already know which block
> +		 * number comes next (though an assertion will verify a match below)
> +		 */
> +			hscan->xs_cbuf = read_stream_next_buffer(hscan->xs_read_stream, NULL);
> +		else
>  		 * Prune page when it is pinned for the first time

The assertion mentioned is only after the call to heap_pageprune_opt(). Which
is probably fine, but perhaps it's worth just doing it immediately after the
above if block, so if the assertion is triggered one can figure out quickly
it's the fault of the read stream getting confused.

> @@ -277,6 +302,30 @@ heapam_index_fetch_tuple(struct IndexFetchTableData *scan,
>   * (important for inner index scans of anti-joins and semi-joins), and the
>   * need to not hold onto index leaf pages for too long.
>   *
> + * Dropping leaf page pins early
> + * -----------------------------
> + *
> + * In no event will the scan be allowed to hold onto more than one batch's
> + * leaf page pin at a time.  The primary reason for this restriction is to
> + * avoid unintended interactions with the read stream, which has its own
> + * strategy for keeping the number of pins held by the backend under control.
> + *
> + * Once we've resolved visibility for all items in a batch, we can safely drop
> + * its leaf page pin.  This is safe with respect to concurrent VACUUM because
> + * index vacuuming will block on acquiring a conflicting cleanup lock on the
> + * batch's index page due to our holding a pin on that same page.  Copying the
> + * relevant visibility map data into our local cache suffices to prevent unsafe
> + * concurrent TID recycling: if any of these TIDs point to dead heap tuples,
> + * VACUUM cannot possibly return from ambulkdelete and mark the pointed-to
> + * heap pages as all-visible.  VACUUM _can_ do so once we release the batch's
> + * pin, but that's okay; we'll be working off of cached visibility info that
> + * indicates that the dead TIDs are NOT all-visible.

I think it'd be worth also explaining why it's safe to use the cached VM bits
in the case where the tuples on an all-vsibile pge were only deleted after
after we fetched the VM. I.e. that it's ok for this scan to think the page is
all visible, because for the purpose of the tids it already had fetched from
the index, it was all visible, i.e. all those tids will also be visible to the
current mvcc snapshot.

> @@ -558,6 +609,35 @@ heapam_batch_getnext(IndexScanDesc scan, ScanDirection direction,

I'm also worried a bit about the namespacing of these functions (were
introduced in an earlier commit, but ...). Not at all clear that a function of
that name couldn't be part of a seqscan or such.

Maybe we should prefix all of these in something like heapam_iscan_ or such?

>
>  		/* Append batch to the end of ring buffer/write it to buffer index */
>  		index_scan_batch_append(scan, batch);
> +
> +		/*
> +		 * Delay initializing stream until reading from scan's second batch.
> +		 * This heuristic avoids wasting cycles on starting a read stream for
> +		 * very selective index scans.  We can likely improve upon this, but
> +		 * it works well enough for now.
> +		 *
> +		 * Also avoid prefetching during scans where we're unable to drop each
> +		 * batch's buffer pin right away (non-MVCC snapshot scans).  We are
> +		 * not prepared to sensibly limit the total number of buffer pins held
> +		 * (read stream handles all pin resource management for us, and knows
> +		 * nothing about pins held on index pages/within batches).
> +		 *
> +		 * Also delay creating a read stream during index-only scans that
> +		 * haven't done any heap fetches yet.  We don't want to waste any
> +		 * cycles on allocating a read stream until we have a demonstrated
> +		 * need for perform heap fetches.
> +		 */
> +		if (!hscan->xs_read_stream && priorBatch && scan->MVCCScan &&
> +			hscan->xs_blk != InvalidBlockNumber &&	/* for index-only scans */
> +			enable_indexscan_prefetch)

I wonder if it'd be worth moving this into its own helper function
(e.g. heapam_index_consider_prefetching()), just to make it a bit more obvious
that that's an important decision point.  I'd also expect the logic to become
a bit more complicated in the future.

> +		{
> +			Assert(!batchringbuf->prefetchPos.valid);
> +
> +			hscan->xs_read_stream =
> +				read_stream_begin_relation(READ_STREAM_DEFAULT, NULL,
> +										   scan->heapRelation, MAIN_FORKNUM,
> +										   heapam_getnext_stream, scan, 0);

It's too bad efficiency wise that we can't safely use
READ_STREAM_USE_BATCHING.  For some workloads it looks like it's a decent
improvement.  We could probably do the work to make it safe (basically exiting
batchmode in heapam_getnext_stream() when we have to do index IO and then
re-enabling it afterwards), but that's clearly for later.

> + * Note: iff the scan _continues_ in this new direction, and actually steps
> + * off scanBatch to an earlier index page, heapam_batch_getnext will deal with
> + * it.  But that might never happen; the scan might yet change direction again
> + * (or just end before returning more items).
> + */
> +static pg_noinline void
> +								  BatchRingBuffer *batchringbuf)
> +{
> +	hscan->xs_read_stream_dir = direction;
> +

Comparing this to heapam_index_fetch_reset():
Should this also invalidate hscan->xs_prefetch_block?

And the other way round, should heapam_index_fetch_reset() not reset
batchringbuf->prefetchPos.valid?

> @@ -654,21 +766,275 @@ heapam_batch_getnext_tid(IndexScanDesc scan, IndexFetchHeapData *hscan,
>  	{
>  		IndexScanBatch headBatch = index_scan_batch(scan,
>  													batchringbuf->headBatch);
> +		BatchRingItemPos *prefetchPos = &batchringbuf->prefetchPos;
>
>  		/* free obsolescent head batch (unless it is scan's markBatch) */
>  		tableam_util_free_batch(scan, headBatch);
>
> +		/*
> +		 * If we're about to release the batch that prefetchPos currently
> +		 * points to, just invalidate prefetchPos.  We'll reinitialize it
> +		 * using scanPos if and when heapam_getnext_stream is next called. (We
> +		 * must avoid confusing a prefetchPos->batch that's actually before
> +		 * headBatch with one that's after nextBatch due to uint8 overflow;
> +		 * simplest way is to invalidate prefetchPos like this.)
> +		 */
> +		if (prefetchPos->valid &&
> +			prefetchPos->batch == batchringbuf->headBatch)
> +			prefetchPos->valid = false;
> +

What causes us to reach this condition? Seems like we should take care that
the scan position doesn't overtake the prefetch position?  Does this only
happen when paused?

> +/*
> + * heapam_getnext_stream
> + *		return the next block to pass to the read stream
> + *
> + * The initial batch is always loaded by heapam_batch_getnext_tid.  We don't
> + * get called until the first read_stream_next_buffer() call, when a heap
> + * block is requested from the scan's stream for the first time.
> + *
> + * The position of the read_stream is stored in prefetchPos.  It is typical for
> + * prefetchPos to consistently stay ahead of the scanPos position that's used to
> + * track the next TID to be returned to the scan by heapam_batch_getnext_tid
> + * after the first time we get called.  However, that isn't a precondition.
> + * There is a strict postcondition, though: when we return we'll always leave
> + * scanPos <= prefetchPos (except in cases where we return InvalidBlockNumber).
> + */
> +static BlockNumber
> +heapam_getnext_stream(ReadStream *stream, void *callback_private_data,
> +					  void *per_buffer_data)
> +{
> +	IndexScanDesc scan = (IndexScanDesc) callback_private_data;
> +	BatchRingItemPos *prefetchPos = &batchringbuf->prefetchPos;
> +	ScanDirection xs_read_stream_dir = hscan->xs_read_stream_dir;
> +	IndexScanBatch prefetchBatch;
> +	bool		fromScanPos = false;
> +
> +	/*
> +	 * scanPos must always be valid when prefetching takes place.  There has
> +	 * to be at least one batch, loaded as our scanBatch.  The scan direction
> +	 * must be established, too.
> +	 */
> +	Assert(index_scan_batch_count(scan) > 0);
> +	Assert(scan->MVCCScan);
> +	Assert(scanPos->valid);
> +	Assert(!hscan->xs_paused);
> +	Assert(xs_read_stream_dir != NoMovementScanDirection);
> +
> +	/*
> +	 * prefetchPos might not yet be valid.  It might have also fallen behind
> +	 * scanPos.  Deal with both.
> +	 *
> +	 * If prefetchPos has not been initialized yet, that typically indicates
> +	 * that this is the first call here for the entire scan.  We initialize
> +	 * prefetchPos using the current scanPos, since the current scanBatch
> +	 * item's TID should have its block number returned by the read stream
> +	 * first.

Just for my understanding: I assume right now we'll always at the start of a
scan batch when prefetchPos is invalid?

> It's likely that prefetchPos will get ahead of scanPos before
> +	 * long, but that hasn't happened yet.
> +	 *
> +	 * It's also possible for prefetchPos to "fall behind" scanPos, at least
> +	 * in a trivial sense: if many adjacent items are returned that contain
> +	 * TIDs that point to the same heap block, scanPos can actually overtake
> +	 * prefetchPos (prefetchPos can't advance until the scan actually calls
> +	 * read_stream_next_buffer).  Reinitializing from scanPos is enough to
> +	 * ensure that prefetchPos still fetches the next heap block that scanPos
> +	 * will require (prefetchPos can never fall behind "by more than one group
> +	 * of items that all point to the same heap block", so this is safe).

I guess this could (not necessarily should) be avoided by having
heapam_getnext_stream() advance the prefetchPos beyond a block of runs with
the same buffer before returning?  If we did that, we should never fall behind
anymore, is that right?

> +	 * Note: when heapam_batch_getnext_tid frees a batch that prefetchPos
> +	 * points to, it'll invalidate prefetchPos for us.  This removes any
> +	 * danger of prefetchPos.batch falling so far behind scanPos.batch that it
> +	 * wraps around (and appears to be ahead of scanPos instead of behind it).
> +	 */
> +	if (!prefetchPos->valid ||
> +		index_scan_pos_cmp(prefetchPos, scanPos, xs_read_stream_dir) < 0)
> +	{
> +		hscan->xs_prefetch_block = InvalidBlockNumber;
> +		*prefetchPos = *scanPos;
> +		fromScanPos = true;
> +
> +		/*
> +		 * We must avoid holding on to any batch's buffer pin for more than an
> +		 * instant, to avoid undesirable interactions with the scan's read
> +		 * stream.  batchImmediateUnguard scans always get this behavior
> +		 * automatically.  Other types of scans (these are all index-only
> +		 * scans in practice) are made to drop their buffer pin eagerly
> +		 * through a policy of always eagerly setting all the batch item's
> +		 * visibility info in one go.
> +		 */
> +		if (scan->xs_want_itup)
> +		{
> +			HeapBatchData *hbatch;
> +
> +			/* Make heapam_batch_resolve_visibility release resources eagerly */
> +			hscan->xs_vm_items = scan->maxitemsbatch;
> +
> +			/* Make sure that this new prefetchBatch has no resources held */
> +			prefetchBatch = index_scan_batch(scan, prefetchPos->batch);
> +			hbatch = heap_batch_data(prefetchBatch, scan);
> +
> +			/* Set visibility info not set through scanBatch */
> +			heapam_batch_resolve_visibility(scan, xs_read_stream_dir,
> +											prefetchBatch, hbatch,
> +											prefetchPos);

Wonder if it's worth somehow asserting that after this the page is actually
unguarded after the call.

> +	prefetchBatch = index_scan_batch(scan, prefetchPos->batch);
> +	for (;;)
> +	{
> +		BatchMatchingItem *item;
> +		BlockNumber prefetch_block;
> +
> +		/*
> +		 * We never call amgetbatch without immediately releasing the batch's
> +		 * index AM resources (which requires special care during index-only
> +		 * scans).  The read stream is sensitive to buffer shortages, so we
> +		 * defensively avoid anything that visibly affects the per-backend
> +		 * buffer limit.
> +		 */

It's probably just me (not the read stream) being sensitive, but I'd say that
the read stream tries to be careful about not pinning too many buffers, and
that's harder to do reliably if there are variable numbers of pins taken
without such care...

> +		else if (!index_scan_pos_advance(xs_read_stream_dir,
> +										 prefetchBatch, prefetchPos))
> +		{
> +			/*
> +			 * Ran out of items from prefetchBatch.  Try to advance to the
> +			 * scan's next batch.
> +			 */
> +			if (unlikely(index_scan_batch_full(scan)))
> +			{
> +				/*
> +				 * Can't advance prefetchBatch because all available
> +				 * batchringbuf batch slots are currently in use.
> +				 *
> +				 * Deal with this by momentarily pausing the read stream.
> +				 * heapam_batch_getnext_tid will resume the read stream later,
> +				 * though only after scanPos has consumed all remaining items
> +				 * from scanBatch (at which point scanBatch will be freed,
> +				 * making its slot available for reuse by a later batch).
> +				 *
> +				 * In practice we hardly ever need to do this.  It would be
> +				 * possible to avoid the need to pause the read stream by
> +				 * dynamically allocating slots, but that would add complexity
> +				 * for no real benefit.
> +				 */
> +				hscan->xs_paused = true;
> +				return read_stream_pause(stream);
> +			}

Just for my understanding: What is the easiest way to hit this?  I assume a
well correlated indexscan with small heap tuples (and thus few heap pages) is
the most obvious path?

> +			prefetchBatch = heapam_batch_getnext(scan, xs_read_stream_dir,
> +												 prefetchBatch, prefetchPos);
> +			if (!prefetchBatch)
> +			{
> +				/*
> +				 * Failed to load next batch, so all the batches that the scan
> +				 * will ever require (barring a change in scan direction) are
> +				 * now loaded
> +				 */
> +				return InvalidBlockNumber;
> +			}

I assume failed means that we reached the end of the scan? If so failed sounds
a bit odd.

> +			/* Position prefetchPos to the start of new prefetchBatch */
> +			index_scan_pos_nextbatch(xs_read_stream_dir,
> +									 prefetchBatch, prefetchPos);
> +
> +			if (scan->xs_want_itup)
> +			{
> +				HeapBatchData *hbatch = heap_batch_data(prefetchBatch, scan);
> +
> +				/* make sure we have visibility info for the entire batch */
> +				Assert(hscan->xs_vm_items == scan->maxitemsbatch);
> +				heapam_batch_resolve_visibility(scan, xs_read_stream_dir,
> +												prefetchBatch, hbatch,
> +												prefetchPos);

It's a bit sad to have this code duplicated between "starting up prefetching"
and this. But it's also not entirely obvious how to do so differently.

> From 01164d1c646d441b508fb7b67fc8bea7583cb222 Mon Sep 17 00:00:00 2001
> Date: Sun, 18 Jan 2026 11:32:52 -0500
> Subject: [PATCH v16 16/17] Add fake LSN support to hash index AM.
>
> This is preparation for an upcoming patch that will add the amgetbatch
> interface and switch hash over to it (from amgettuple).  We need fake
> LSNs to make it safe to apply behavior that is equivalent to nbtree's
> previous dropPin behavior that works with unlogged hash index scans.
>
> The commit that will add hashgetbatch will replace _hash_kill_items with
> a new hashkillitemsbatch routine.  This will be very similar to the
> btkillitemsbatch routine added by commit XXXXX.  In particular, it will
> use the same "did the index page's LSN change since the page was first
> read?" trick.
>

If you reformulated the commit message to not reference the commit I see no
real reason to hold off pushing this?

Admittedly I only skimmed it, but it looks like it looks pretty much the same
as when I last looked at it (which is fine, I think it was ready to commit
then, except for the dependency on XLogGetFakeLSN, which hadn't yet been
committed)?

> @@ -372,6 +374,7 @@ _hash_vacuum_one_page(Relation rel, Relation hrel, Buffer metabuf, Buffer buf)
>  	Page		page = BufferGetPage(buf);
>  	HashPageOpaque pageopaque;
>  	HashMetaPage metap;
> +	XLogRecPtr	recptr;
>
>  	/* Scan each tuple in page to see if it is marked as LP_DEAD */
>  	maxoff = PageGetMaxOffsetNumber(page);

I guess here you could move recptr up only one level, not two, but whatever.

> @@ -510,7 +512,11 @@ _hash_freeovflpage(Relation rel, Buffer bucketbuf, Buffer ovflbuf,
..
> +	else						/* !RelationNeedsWAL(rel) */
> +	{
> +		/* Determine if wbuf is modified */
> +		if (nitups > 0)
> +			mod_wbuf = true;
> +		else if (is_prev_bucket_same_wrt)
> +			mod_wbuf = true;
> +	}

Any reason to not just compute mod_wbuf before the if (NeedsWal()), just like
you did for is_prim_bucket_same_wrt etc?

Greetings,

Andres Freund

---

From: Peter Geoghegan <[email-masked]>
Date: Sat, 21 Mar 2026 19:01:44 -0400

First of all, thanks for the review.

I can immediately act on most of what you've said here. I'm planning
to commit the first patch (and maybe the hash index fake LSN patch) in
the next couple of days, ahead of posting a new v17 of the patch set.
You can expect any item I reply to with "fixed" or similar to be in
that version. Other items might not be addressed in v17 -- generally
because I require more context or feedback to act.

I'm going to work through both this email and the later one from today
before posting this v17. What I've said applies equally to both
reviews/emails.

> scan has to jump over a fair bit of code not used during that scan.

That certainly seems likely to me, since we're sensitive to icache
misses in general. But this change was a win across the board.

I was mostly just acknowledging that using pg_attribute_always_inline
this aggressively is unorthodox. This will be the first code that
actually uses pg_attribute_hot, too.

> > Subject: [PATCH v16 01/17] Make IndexScanInstrumentation a pointer in executor

Cool. I will definitely push this one soon.

> make that larger commit smaller.

Is it really worth doing that without also doing the
xs_lastinblock/ExecStorePinnedBufferHeapTuple stuff? We need batches
to do the latter.

v17 will do it the other way around: it'll delay adding the
ExecClearTuple(slot)  as well as the
xs_lastinblock/ExecStorePinnedBufferHeapTuple stuff until a *later*
commit. That seems more logical to me.

> The commit message doesn't mention that this affects ammarkpos/amrestrpos.

It does not. But FWIW there's a prominent "Note" about it in the SGML docs.

> switch away from amgettuple?

Yes. It also means that amgetbatch-based index AMs are *required* to
support mark + restore.

For nbtree, we need the new amposreset function to safely handle this
(and to manage cross-batch changes in the scan's direction). For hash,
which lacks state to independently track the scan's progress outside
of batches (i.e. no array keys), we don't need to do anything;
mark/restore support is automatic.

Of course there is zero need for hash to support mark/restore. But it
is formally supported.

> Probably fine, but worth mentioning, I think.

I'll mention it in the commit message, then.

> need to know about this stuff.

I'll add a new header for these.

Will fix.

> stuff. But still curious if you tried?

No, I didn't try. I doubt saving space is worthwhile, since we'll need
a relatively large allocation for batches used during index-only scans
regardless.

>   iscan->batch_index_opaque_size == MAXALIGN(sizeof(BTBatchData))

Will have that in the next version.

> indexam_util_batch_unlock()).

We're now advertising that indexam_util_batch_unlock is optional, and
that index AMs can go there own way when needed.

Basically, I just don't see much point in having 2 identical copies of
indexam_util_batch_unlock. It wouldn't be terrible to give nbtree and
hash their own copies, but it means we cannot centralize discussion of
things like the batchImmediateUnguard rules. With
indexam_util_batch_unlock, the place that handles those rules for
index AMs is right next to the place that does so for table AMs.

> Just curious: How did you end up with INDEX_SCAN_CACHE_BATCHES == 2?

Mostly trial and error. We don't want fully cached scans to repeatedly
palloc + pfree a batch. Two cache slots should suffice to reliably
reach that steady state.

It also seems to work well with uncached scans. I have custom
instrumentation that shows good "batch cache recycling hit rates" for
those.

> Is nextBatch basically the tail?

Formally, nextBatch - 1 is the tail.

> INDEX_SCAN_MAX_BATCHES isn't set to a value bigger than what fits into a

Added a couple of static assertions for that.

> Perhaps worth an assert ensuring that there's space in the ring buffer?

Added one.

> s/heap/table/ for anything new imo.

Usually I'd agree, but here we're using the same name as the one
presented in EXPLAIN ANALYZE. IMV this should match that. (Maybe the
EXPLAIN ANALYZE output should change, and this field name along with
it, but that's another discussion entirely.)

> please not introduce them now?

Fixed both places that looked like that.

> move it to the top of visibilitymap.c?

I think that the most natural place for it in visibilitymap.c is just
above visibilitymap_get_status. They'll be moved over there in the
next revision.

> code more complicated, with higher register pressure.

I seem to recall that it was, but that was weeks ago. Let me get back
to you on this (won't be in the next version, most likely).

> returns a NULL upwards or does a single heapam_batch_return_tid().

I agree that it might very well make sense to break down
heapam_batch_getnext and heapam_batch_getnext_tid into inline
functions that allow for some amount of code reuse. But I'm not going
to have time to fix that before the next revision (too much
performance validation is required).

I'll prioritize simplifying and slimming down all of the specialized
versions of functions like heapam_index_getnext_slot (which you ask
about later). That is way too complicated right now, and inlining
helper functions seems like a promising alternative approach.

Once I'm happy with that, I'll return to this item. Not gonna be in
the next version, though.

> From what I can tell it doesn't make sense to specify both pg_attribute_hot
> pg_attribute_always_inline as the the pg_attribute_hot seems to be disregarded
> once inlined into the caller.

Fixed.

> Have you thought about implementing the different variants of this by having
> one common always-inline implementation that is called by the different
> variants, with constant arguments to decide between e.g. index_getnext_tid()
> and heapam_batch_getnext_tid()?

I'll look into it. As I said, that will be my priority for the next
revision (not making functions like heapam_batch_getnext more table AM
agnostic).

> I find it pretty hard to keep the flow below
> heapam_index_[only_][batch_]getnext_slot() straight.
>
>  - heapam_index_fetch_heap()
>  - heapam_index_fetch_tuple()
>  - heapam_batch_return_tid()
>  - heapam_batch_getnext()
>  - heapam_batch_getnext_tid()
>
> Maybe I'm just weak brained today, but from the naming I have no idea what the
> difference between e.g. heapam_index_fetch_heap(), heapam_index_fetch_tuple()
> could be and similarly the difference between heapam_batch_return_tid() and
> heapam_batch_getnext_tid() is unclear, and heapam_batch_getnext() isn't
> particularly clear either.

Yes, it's pretty unclear. You can tell that I rushed this part.

> I also suspect it'd be worth creating a new heapam.c file for this new
> code. heapam_index.c or such.

I had thought about that myself. How would that be structured, in
terms of the commits? It's pretty clear that we'd have to move some
existing heapam_handler.c code as part of this whole process (e.g.,
heapam_index_fetch_tuple).

I think that it would likely be easiest if we added an extra commit,
right after "heapam: Track heap block in IndexFetchHeapData using
xs_blk" and right before "Add interfaces that enable index
prefetching". This would be a strictly mechanical commit that moved
the code to the new file without adding anything else.

> xs_want_itup in a generic routine seems too low-level. The xs_ stuff is just
> the struct prefix that was once uniformly used for IndexScanDescData members,
> it doesn't seem to belong in a parameter.
>
> Could it just be an index_only_scan or such?

Fixed.

> > @@ -281,7 +280,23 @@ index_beginscan(Relation heapRelation,
> >        */
> >       scan->heapRelation = heapRelation;
> >       scan->xs_snapshot = snapshot;
> > +     scan->MVCCScan = IsMVCCLikeSnapshot(snapshot);
>
> Elsewhere you have an xxx comment about making sure the snapshot is
> pushed/registered - seems it should be here, not there...

That comment was copied from index_getnext_tid (it's still there in
the patch/hasn't been moved). This was based on the theory that it
made just as much sense in the new code paths, which generally won't
call index_getnext_tid. And, there are numerous other identical
comments, including in procarray.c.

I'm fine with consolidating all this, but I'd prefer it if you gave me
guidance on what to do with all of them.

> Any reason this isn't in index_beginscan_internal(), given both
> index_beginscan() and index_beginscan_parallel() need it?  I realize you'd
> need to add arguments to index_beginscan_internal(), but I don't see a problem
> with that.  Alternatively a helper for this seems like a possibility too.

Fixed, by moving much more of the initialization done by each variant
(index_beginscan, index_beginscan_bitmap, index_beginscan_parallel)
into index_beginscan_internal itself.

> > + *
> > + * This should only be called by table AM's index_getnext_slot implementation,
> > + * and only given an index AM that supports the single-tuple amgettuple
> > + * interface.
> >   * ----------------
>
> Seems like this comment should also be turned into assertions?

Fixed.

> Hm. Would it be worth using a much wider ring position to avoid this kind of
> danger?

I don't think so. It worked that way a few months ago, but I felt
using a ring buffer with frequently overflowing offsets would be
easier to test. Note that we reset all offsets to 0 to handle a change
in scan direction, so the offsets aren't entirely immutable.

I like that approach; it explicitly creates the appearance of a ring
buffer whose scan direction never changed (it's as if the new
direction had always been in use). Going to those lengths might not be
necessary, but it seems more robust. Most code (almost all code in the
table AM) doesn't have to think about changes in scan direction at
all.

The slow path loop in index_batchscan_mark_pos that you were concerned
about will be removed in the next version. It wasn't really necessary;
we can safely compare the batch pointer at the given loaded batch slot
offset. Comparing batch pointers to each other seems pretty safe and
simple to me.

> Couldn't it be that markBatch is one behind scanBatch, in which case wouldn't
> need to throw out all the prefetched batches (in the future commits that do
> prefetching)?

We can very often use the "scanBatch == markBatch" happy path, since
in practice merge joins rarely need to restore a markPos that's very
far behind scanPos. This is made more likely by some of the nbtree
work from the past 8 years, such as suffix truncation and
deduplication. Equal index tuples naturally avoid spanning multiple
index leaf pages unless it's absolutely unavoidable because there are
too many to fit on one page.

> My understanding is that it'd be rather likely that, if the
> markPos is not the current batch, it'd be in the last batch.

I agree: cases that cannot use the "scanBatch == markBatch" happy path
are very likely to restore a markBatch for the batch that was most
recently removed from the head of the ring buffer. However, I'd much
rather avoid a special case where we don't remove markBatch from the
batch ring buffer, just in case we restore the mark (which probably
won't happen, since most marks taken are never restored). I get why
you'd ask about this (naturally I thought of it myself), but I just
can't see it paying for itself.

Consider the costs. The complexity stems from it breaking the
"scanBatch == headBatch" invariant. That would affect pausing. When
the read stream callback pauses, it expects the scan code to consume
all remaining items from scanBatch and then remove scanBatch from the
ring buffer, allowing the read stream to be resumed in passing. Like a
cross-batch restore, pausing is itself a hard-to-hit code path;
testing the intersection of those 2 things would be very tricky.
(There are other reasons why I want to keep this invariant, which I
won't detail now.)

I also don't see much benefit. We're already comfortably beating the
master branch with merge joins that heavily use mark/restore, due to
improved buffer pin management (particularly with index-only scans).
The need to re-read index pages in these cases is nothing new. We
reset the read stream whenever we restore a mark (even in the happy
path), so there's relatively little risk of the distance to get out of
hand afterwards (the index pages are very likely to still be in
shared_buffers each time).

> Do we have decent test coverage of queries changing scan direction? Seems like
> something that could quite easily have bugs.

You're right to be concerned about potential bugs here. And about the
existing test coverage.

While the regression tests have limited coverage, my personal test
suite covers many variations. Some of these come from my work on the
nbtree projects included in Postgres 17 and 18. Others were derived
from a stress-testing suite developed by Tomas that generates
cursor-based queries that randomly scroll back and forth, verifying
that the patch agrees with master at every step.

Improving the core regression tests in this area would be difficult. A
test case like this must focus on one specific problematic page
transition (or a small series of related transitions). Obviously, that
depends on the data being laid out precisely, which depends on many
implementation details. And we usually need quite a bit of data to
tickle the implementation in just the right/wrong way.

> > +void
> > +tableam_util_free_batch(IndexScanDesc scan, IndexScanBatch batch)
> > +{

> Any reason to not use indexam_util_batch_release() to implement the release?
> Seems nicer to not have two copies of this code.

Yes: We don't always want to use the batch cache, even when it's
available. In particular, we don't want to do that during simple point
queries (like those from pgbench SELECT), when batches are freed only
as the scan ends.

IOW, when we're called through index_batchscan_end (specifically, when
we're called through index_batchscan_reset when it is called by
index_batchscan_end) we don't want to use the cache. It seemed to make
sense to implement this in a way that didn't require any special
handling from within indexam_util_batch_release (since it's not a
concern of index AMs).

> > +#if defined(USE_VALGRIND)
> >       if (!RelationUsesLocalBuffers(rel))
> > -             VALGRIND_MAKE_MEM_NOACCESS(BufferGetPage(buf), BLCKSZ);
> > +             VALGRIND_MAKE_MEM_NOACCESS(page, BLCKSZ);
> > +#endif
> >  }
>
> Is this a leftover from a larger change earlier? Because otherwise it's not
> obvious why this changed as part of this commit.

I wanted to make it as consistent as possible with the new nbtsearch.c
code, that also calls VALGRIND_MAKE_MEM_NOACCESS like this.

> > -                             info->amhasgettuple = (amroutine->amgettuple != NULL);
> > +                             info->amhasgetbatch = (amroutine->amgetbatch != NULL ||
> > +                                                                        amroutine->amgettuple != NULL);
>
> Setting amhasgetbatch when not having amgetbatch seems pretty darn confusing.
> Why?

Fixed this by renaming the IndexOptInfo field that previously appeared
as 'amhasgetbatch'. It's now called 'amcanplainscan'.

> I don't think this should do two GetIndexAmRoutineByAmId() calls for the same
> AM.

Fixed.

-- 
Peter Geoghegan

---

From: Peter Geoghegan <[email-masked]>
Date: Sat, 21 Mar 2026 21:17:08 -0400

> improvement that it's a worthwhile thing to do on its own.

I'll try to get that done for v18 (the version *after* the next
version I'll post, not the next version/v17). I want to commit the 2
patches that are now ready ahead of producing a v17. I don't want to
wait for a solution to this unrelated problem (nor do I want to leave
CFTester unhappy).

> before the introduction of batches.

Good point.

> around, so maybe that's not worth it.

Right. The whole concept of a batch kinda exists in nbtree already,
and has for a long time: there's always been markPos, which overwrites
the scan's currPos on restore. This shares many of the same features
(e.g., an independent markTuples array is used during index-only
scans). So there's just no practical way to separate the
btgetbatch/nbtree changes from the core changes that add functionality
to indexbatch.c.

> the number of index pages visited into account.

Fixed, using your wording now.

> Because I do think your

Yeah, there's just no question that it's a real problem.

> Why do we not need to support this for amgettuple indexes?

Fixed -- amgettuple index AMs will support the xs_visited_pages_limit
stuff in the next version.

> convenient because it provides for an easy control flow for ending the scan?

That's right, that's what makes it convenient.

> that could check that the scan is an IOS, uses batch mode etc.

We could always add it in the future. Maybe when fixing the issue with
scanning too many index leaf pages.

> on these days), there's easily much bigger wins :)

I'm excited to see what users do with this. That's such an enormous
difference that it likely enables using the system in ways that
wouldn't have made sense in the past.

> Perhaps worth mentioning that the wins are bigger with higher latency storage?

Will fix for next version.

> Perhaps worth introducing the heuristic, and the need for it, in a bit more

Will do. I need to mention that the heuristic doesn't handle certain
cases well (which we've discussed privately). These are nestloop join
queries with a limit on the inner side of the scan that the prior
batch heuristic doesn't quite save. (Previously, the tuples_needed
patch helped with this, but since we're removing that patch, it makes
sense to frame this as a deficiency of the priorbatch heuristic.)

Will fix.

> but should be in a dedicated indexbatch.h or such.

It'll work that way in the next revision.

> bitmap scans are the correct choice than there are today...

I think so too.

Obviously, the main benefit here is significantly improved
performance. But ISTM that there's a second order benefit: the
uncertainty about how much slower an index scan will be in the worst
case is likely much lower. This is particularly true of index-only
scans that require at least some heap fetches; it's difficult to
predict how many a given scan will perform. While it isn't all that
bad when the heap pages are in shared_buffers, it's much much slower
when they aren't.

The optimizer has only a very basic understanding of how much data is
likely cached in shared_buffers. It's very hard to improve that model
because it relies so much on things that can change quickly. Making
the true cost profile of index scans simpler and more predictable
might make the optimizer's cost model much more accurate (perhaps with
a little work on the cost function side). It's just easier to fit a
cost function to that profile because being wrong (about IoS heap
fetches or correlation) matters significantly less.

> have to move the point of decision making now.

I think that it makes sense for the optimizer to give advice without
being prescriptive. Basically something a bit like the tuples_needed
patch, that table AMs are free to ignore when they find the hint to be
wrong.

> like heap_getnext() or such.  Maybe heapam_index_scan_next_block() or such?

Fixed, will use your name in the next version.

> That's obviously the case given the assignment, but it doesn't even hint at

It's just defensive. I'll add a comment to that effect.

> it's the fault of the read stream getting confused.

But that means that it won't be triggered when we don't enter the "if
(hscan->xs_blk != ItemPointerGetBlockNumber(tid))" block that contains
all this code. Besides, it just doesn't seem possible that
heap_page_prune_opt would release its caller's pin.

> the index, it was all visible, i.e. all those tids will also be visible to the

Added a small additional paragraph to that effect to my pending v17 of
the patch set.

> Maybe we should prefix all of these in something like heapam_iscan_ or such?

I'm going to rename things as follows, which is both self-consistent
and as consistent with established convention as possible:

heapam_batch_getnext_tid -> heapam_index_getnext_tid
heapam_batch_getnext -> heapam_index_batch_getnext
heapam_batch_resolve_visibility -> heapam_index_resolve_visibility
heapam_batch_return_tid -> heapam_index_return_tid
heapam_dirchange_readstream_reset -> heapam_index_dirchange_reset

That'll be in the next version.

> a bit more complicated in the future.

That'll be in the next revision, too.

> re-enabling it afterwards), but that's clearly for later.

Agreed. Though I wasn't able to see much benefit here, when I tried that.

> Should this also invalidate hscan->xs_prefetch_block?

It isn't necessary, because hscan->xs_prefetch_block is initialized
from scratch when the read stream callback is called following our
invalidating prefetchPos.

> batchringbuf->prefetchPos.valid?

Nope, for the same reason.

> What causes us to reach this condition?

As a general rule, prefetchPos can only advance in the read stream
callback. It is mostly up to the read stream callback to notice when
prefetchPos has fallen behind so it can reinitialize from scanPos. But
we need this extra check to make its approach robust against
prefetchPos->batch getting so far behind that uint8 wrap makes it look
like it's ahead instead of behind.

Importantly, while prefetchPos can fall behind scanPos, it's only
possible in a limited, trivial sense: it can only fall behind for as
long as we go without calling the read stream callback. Once the read
stream callback is called, we can rely on its postcondition: when it
returns, prefetchPos must be >= scanPos. So, in effect, everything
behaves exactly as it would if it really was impossible for
prefetchPos to be < scanPos for even a picosecond (we don't want to
have to check prefetchPos needlessly in hot code paths).

> Seems like we should take care that
> the scan position doesn't overtake the prefetch position?

That's precisely what we're doing here. We're just not checking
prefetchPos on every item returned (we only do so when advancing to
the next batch) to save cycles.

> Does this only

This "prefetchPos->valid = false" stuff is approximately the opposite
of pausing. Pausing resolves the problem of prefetchPos getting so far
ahead of scanPos that the batch ring buffer runs out of slots. Whereas
this prefetchPos invalidation code helps the read stream deal with
prefetchPos falling behind scanPos.

This is a fairly rare edge-case, that doesn't actually need
consideration in the high-level design -- in a certain sense the high
level design pretends that prefetchPos cannot fall behind scanPos for
even one picosecond (it can, but not in a way that actually
matters/affects the invariants for the read stream callback).

Note that prefetchPos falling behind scanPos is most likely during
index-only scans that require some heap fetches, but not too many. The
read stream is seldom called, but is called with such a scan. So
prefetchPos could fall far behind scanPos (or would, if we didn't have
this code).

> scan batch when prefetchPos is invalid?

Not quite.

We might have invalidated prefetchPos explicitly in that code path
that you just asked about. We deal with prefetchPos falling behind
scanPos there, but also here, in the read stream -- which has a
"index_scan_pos_cmp(prefetchPos, scanPos, xs_read_stream_dir) < 0"
check for that.

> the same buffer before returning?  If we did that, we should never fall behind

The problem isn't what the read stream callback does or doesn't do. As
I said, we can rely on its postcondition: when it returns, prefetchPos
must be >= scanPos. However, there's no way to be sure the read stream
callback will get called again before we consume many more batches --
particularly during index-only scans that need only a few heap fetches
(but also with plain index scans that see a run of leaf pages that
each have items that all point to the same heap block, which is
unlikely but certainly possible).

> unguarded after the call.

We used to, but the new layering forced me to remove it. Any ideas
about how to add it back?

> that's harder to do reliably if there are variable numbers of pins taken

Will use that wording.

> well correlated indexscan with small heap tuples (and thus few heap pages) is

Depends on what you mean. If you just want to test it quickly, then
you can easily do so by temporarily reducing INDEX_SCAN_MAX_BATCHES to
2 (the minimum usable value). If you meant to ask the most likely way
this will happen in the real world, I can think of a couple of things.

Possibly, the easiest way is to perform an index-only scan that
requires only a few heap fetches. These fetches can be spaced out such
that we have a hard time maintaining an adequate prefetch distance,
and have to read many leaf pages just to do one more fetch. (In
general, these kinds of index-only scans might be the best way to
create extreme conditions in the read stream callback.)

Fixed.

> real reason to hold off pushing this?

I'll commit this one ahead of the next revision, then.

Right.

> you did for is_prim_bucket_same_wrt etc?

No good reason. Will fix.

Thanks again for the review!
-- 
Peter Geoghegan

---

From: Peter Geoghegan <[email-masked]>
Date: Sun, 22 Mar 2026 21:14:23 -0400

> before posting this v17. What I've said applies equally to both

I committed the fake LSN hash index patch (as commit e5836f7b), as
well as the "Make IndexScanInstrumentation a pointer in executor scan
nodes" patch (as commit f026fbf0).

V17 is attached. This addresses most of your feedback, but defers
dealing with the trickier parts for now. I didn't want to delay
producing a new version that cleanly applies on top of master.
Performance validation takes at least as long as writing the code,
with these sorts of changes.

Notable things that I have *not* done just yet:

* No new heapam_index.c file in this version (though there is an indexbatch.h).

* No split of table AM revisions that pushes down VM accesses for
index-only scans into its own commit (though there are a couple of new
patches that were split from the big one to keep the size of the
commits slightly more manageable, as promised).

* No progress on splitting the batch management code currently in
heapam_handler.c into smaller, reusable/modular pieces. Pieces that
other table AMs can reuse.

* Recently, Andres asked, "Does it really make sense to not just have
2 separate loops from within heapam_batch_resolve_visibility?". That
is a question that remains unanswered, for now.

Notable changes (things that I *have* done) include:

* Cleaned up the mess of carrying around four full-length, specialized
versions of the table AM index scan slot callback. We still
specialize, but now there's only one generic
pg_attribute_always_inline function definition, which is called by all
four specializations.  The compiler can create specialized versions
because the amgetbatch + index_only_scan parameters are constants
(four specializations for all four combinations of amgetbatch +
index_only_scan boolean parameters).

* Lots of renaming and polishing, along the lines requested in your
review. Basically, everything I specifically told you I would do soon
in my initial responses over the past couple of days has been
completed. A large number of small things, that aren't worth noting
individually.

-- 
Peter Geoghegan

---

From: Andres Freund <[email-masked]>
Date: Tue, 24 Mar 2026 12:26:53 -0400

Hi,

> because I require more context or feedback to act.

Cool.

> xs_lastinblock/ExecStorePinnedBufferHeapTuple stuff? We need batches

I see perf benefits from it alone, yes.

> commit. That seems more logical to me.

Shrugh, also works.

> It does not. But FWIW there's a prominent "Note" about it in the SGML docs.

Approximately nobody looking at the commit to see what they need to change
will see that...

> a relatively large allocation for batches used during index-only scans

Seems fine to not care for now. But, FWIW, the motivating reason wouldn't be
to to really save memory, it'd be to make it more likely the data fits into a
higher level of the cache.

Fair enough.

> it, but that's another discussion entirely.)

I think if we add it into more and more places it'll get harder and harder to
eventually fix...

Agreed.

> functions that allow for some amount of code reuse.

Code reuse and making it easier for other AMs to adapt this...

> But I'm not going to have time to fix that before the next revision (too
> much performance validation is required).

Fair.

> terms of the commits?

I'm imagining something like heapam_iscan.c or heapam_indexfetch.c or such.

I'd introduce it by adding it in a commit that moves heap_hot_search_buffer(),
and heapam_index_fetch_{begin,reset,end}() into it.

Moving heap_hot_search_buffer() into the same file will be nice because it'll
allow partial inlining of it into some really performance sensitive functions.

> It's pretty clear that we'd have to move some existing heapam_handler.c code
> as part of this whole process (e.g., heapam_index_fetch_tuple).

> the code to the new file without adding anything else.

Obviously agreed.

I think we should move the seq/tid scan stuff into its own file too, but
that's obviously a separate thread.

> comments, including in procarray.c.

Ah, right.

> guidance on what to do with all of them.

I guess we just need to fix this crap one of these days :(

So just ignore what I said on this, I don't see a great solution immediately.

> into index_beginscan_internal itself.

Nice.  Haven't checked out your new version yet - are you doing that as a
separate commit?

> in scan direction, so the offsets aren't entirely immutable.

Well, a 64bit one would never overflow :)

But, jokes aside, fair enough, either there should never be overflows or they
should be frequent.

> shared_buffers each time).

Fair.  I did see some large number of index IO page accesses that look like it
could be avoided by looking back one batch, but that was an intentional
torture query that I can't see as being realistic.

> tickle the implementation in just the right/wrong way.

I don't think we necessarily need the coverage of your full torture test suite
in core, but I feel some basic sanity tests really ought to be in the core
tests. There's very little, from what I can tell.  Even just making sure we
have coverage for a index [only] scans going forward and backward, and the
same for merge & nestloop joins would be quite a win.

> handling from within indexam_util_batch_release (since it's not a

I am not really following.  Right now there's two close copies of this code:

void
indexam_util_batch_release(IndexScanDesc scan, IndexScanBatch batch)
...
	 * Try to store caller's batch in this amgetbatch scan's cache of
	 * previously released batches first
	 */
	for (int i = 0; i < INDEX_SCAN_CACHE_BATCHES; i++)
	{
		if (scan->batchcache[i] == NULL)
		{
			/* found empty slot, we're done */
			scan->batchcache[i] = batch;
			return;
		}
	}

	/*
	 * Failed to find a free slot for this batch.  We'll just free it
	 * ourselves.
	 */
	if (batch->deadItems)
		pfree(batch->deadItems);
	pfree(batch_alloc_base(batch, scan));

void
tableam_util_free_batch(IndexScanDesc scan, IndexScanBatch batch)
{
...
	/*
	 * Use cache, just like indexam_util_batch_release does it (unless scan is
	 * shutting down)
	 */
	if (scan->xs_heapfetch)
	{
		for (int i = 0; i < INDEX_SCAN_CACHE_BATCHES; i++)
		{
			if (scan->batchcache[i] == NULL)
			{
				/* found empty slot, we're done */
				scan->batchcache[i] = batch;
				return;
			}
		}
	}

	if (batch->deadItems)
		pfree(batch->deadItems);
	pfree(batch_alloc_base(batch, scan));

Yes, one of them has the additional "if (scan->xs_heapfetch)" condition, but
that hardly seems like a real problem preventing sharing the code.

I don't think that "if (scan->xs_heapfetch)" is a particularly obvious
signifier of the scan being terminated, FWIW. I'd probably either add a
parameter to tableam_util_free_batch() signifying whether caching is desired
or add an internal helper with that parameter that's then used internally in
indexbatch.c.

Greetings,

Andres Freund

---

From: Andres Freund <[email-masked]>
Date: Tue, 24 Mar 2026 13:27:47 -0400

Hi,

> wait for a solution to this unrelated problem (nor do I want to leave

Makes sense and great!

> likely cached in shared_buffers.

Yea, it's a surprisingly naive model.  I'd argue it doesn't actually model
that anything is initially in s_b, just whether repated acceses within one
query are likely to still be in s_b.

I do think we'll eventually have to do a *bit* better than that.

> It's very hard to improve that model because it relies so much on things
> that can change quickly. Making the true cost profile of index scans simpler
> and more predictable might make the optimizer's cost model much more
> accurate (perhaps with a little work on the cost function side). It's just
> easier to fit a cost function to that profile because being wrong (about IoS
> heap fetches or correlation) matters significantly less.

There are two things that I think we will eventually have to take into
account:

1) The amount of synchronous or effectively synchronous IO:

a) Once index prefetching is in, the cost of random table IO in a large ordered
index scan is drastically reduced, even if the index and table are not well
correlated. We probably need to lower the costing to take that into account.

b) However, if you have a badly correlated index nestloop scan, where there's
only a single tuple on the inner side, there's nothing prefetching can do,
you'll have the cost of a synchronous IO every time.  For this we probably
need to *increase* the cost.

c) Similarly, if an index is so large that it's unlikely to be in s_b, we
probably need to better account for the synchronous random index IO. That's
probably not quite as commonly an issue as the two above, but I've seen index
IO cost dramatically undercosted in such scenarios.

2) The expected sizes of IOs

It turns out to matter a fair bit for performance, particularly if data is in
the kernel page cache vs needing to actually do IO, whether the average IO
size is 8kB or 128kB. A large portion of the CPU cost of an IO is constant,
independent of the size of the IO.

I think we'll have a hard time avoiding wrong plan choices if we don't take
that into account when costing the effect of IO.

Agreed.

Clearly the table AM may know a bit more about when it makes sense to do
prefetching or not IF it knows how many rows are expected to be fetched.

I suspect this means that the planner should ask the table AM for information
about expected IO costs for an index scan, including the effect of
prefetching, at plan time. Just choosing whether to enable prefetching at
execution time (or at createplan time), can't lead to a better path being
chosen.

Although I have to admit that it I am concerned that doing any of this well
for in the presence of thigns like anti-joins or LIMITs seems nontrivial,
given that we don't know anything at those lower levels about only few tuples
ending up getting fetched.

> heap_page_prune_opt would release its caller's pin.

I was more concerned about read_stream_next_buffer() returning the wrong
block, due to prefetching somehow "desynchronizing" with the scan position and
catching that when it's clear that we just read a new block, rather than in a
place where it could be either the continuation of a scan on the same page or
a new page.

> the next batch) to save cycles.

I think I had largely missed the "danger" of index only scans here. I think
it'd be good to call that out more explicitly in these comments.

> > Does this only happen when paused?
> 

Because I had somewhat missed the real cause of the problem - not calling the
read stream code due to index only scans - I thought that somehow we could end
up in this state due to not resuming prefetching before the scan position
overtakes the prefetch position. But I don't think that actually happen.

> about how to add it back?

Adding an "isGuarded" field to IndexScanBatchData would be the easiest
way. That way we can make assertions about the state without knowing anything
about the internal mechanism of how guarding is implemented.

I doubt setting/clearing that field even when assertions are disabled will be
measurable, as long as you place it alongside the other booleans where there's
padding space available.

> create extreme conditions in the read stream callback.)

After replacing the pause with an error I found that it's surprisingly easy to
hit on slow storage (or on fast storage if you set needed_wait=true in
read_stream_next_buffer()).  I've not done any performance validation on
whether that means the limit is too low.

Greetings,

Andres Freund

---

From: Peter Geoghegan <[email-masked]>
Date: Tue, 24 Mar 2026 21:28:23 -0400

> I see perf benefits from it alone, yes.

Does that mean it should be in a separate commit?

The upcoming v18 will significantly change the patch set's structure
by breaking up the big patch so that the slot interface changes are in
their own commit. Along with pushing the VM accesses down into heapam.

That feels like a big enough change on its own. There is bound to be a
v19, so to me it makes sense to defer a decision on this kind of thing
until you see v18.

> Approximately nobody looking at the commit to see what they need to change

In any case v17 updated the commit message to point out the removal of
ammarkpos/amrestrpos.

> higher level of the cache.

Understood.

Cool.

> I think if we add it into more and more places it'll get harder and harder to

Okay, I'll make this change for v18.

> Code reuse and making it easier for other AMs to adapt this...

Of course, that was what I meant.

> allow partial inlining of it into some really performance sensitive functions.

I'm not opposed, of course. But let's leave that question until after
I post v18.

> that's obviously a separate thread.

Makes sense.

> Nice.  Haven't checked out your new version yet - are you doing that as a

Maybe. Again, let's see how things shake out in v18, and then revisit.

> same for merge & nestloop joins would be quite a win.

We probably have that level of coverage. What we lack is coverage for
things that are actually tricky.

For example, the regression tests lack coverage for an nbtree scan
that uses a scrollable cursor + an SAOP scan key that backs up across
a page boundary, relying directly on a call to the new amposreset
routine for correct behavior. Nor do we have the equivalent SAOP merge
join that restores a mark across a page/batch boundary (note that we
can safely skip the amposreset in the restore "scanBatch == markBatch"
happy path).

The underlying difficulty is that these things tend to "acidentally
fail to fail". Some of these cases were rather difficult to write any
kind of test for, even for my own purposes.

> that hardly seems like a real problem preventing sharing the code.

I'll try to address this for v18.

Thanks
-- 
Peter Geoghegan

---

From: Peter Geoghegan <[email-masked]>
Date: Tue, 24 Mar 2026 21:34:51 -0400

> place where it could be either the continuation of a scan on the same page or

Then I don't follow. The existing assertions will catch that (I should
know, they've failed enough times during development).

Basically, I don't get the concern about heap_page_prune_opt releasing
its caller's pin. Even if that happened, the existing assertions would
still catch it.

> it'd be good to call that out more explicitly in these comments.

Will do.

> overtakes the prefetch position. But I don't think that actually happen.

Right, it can't happen. In any case the assertions we have are quite
effective at catching problems like that. For example, if we don't
resume prefetching and consume another batch, there's an assertion for
that. Actually, there's more than one. There's a direct assertion, on
the scan side. And the read stream callback itself has a precondition
assertion that the read stream is not paused.

> measurable, as long as you place it alongside the other booleans where there's

I've prototyped that, and it works well. It'll be in v18.

> whether that means the limit is too low.

It's been a while since I last validated performance to justify the
current maximum number of batches. I used buffered I/O for that. I'm
sure that a higher maximum with very slow storage and a very high
effective_io_concurrency will provide some benefit. But perfectly
handling that isn't essential for the first committed version of index
prefetching.

I must admit I'm unsure how to evaluate the maximum number of batches.
It can make sense to pursue diminishing returns. But up to what point,
and according to what principle?

-- 
Peter Geoghegan

---

From: Peter Geoghegan <[email-masked]>
Date: Fri, 27 Mar 2026 18:02:22 -0400

On Thu, Mar 26, 2026 at 5:47 PM Andres Freund <[email-masked]>
wrote:> > I must admit I'm unsure how to evaluate the maximum number
of batches.
> I think the theoretical amount of required IO concurrency can be calculated
> based on the storage latency and IOPS.  IIRC it is
>
> iops_qd1 = (1000 / latency_ms)
> queue_depth = IOPS / iops_qd1
> queue_depth = IOPS / (1000 / latency_ms)

> So, to be able to fully utilize current hardware with one query, we need to be
> able to reach queue depth in the low hundreds, in the case of striped cheap
> cloud SSDs. That's when a backend *just* does IO, nothing else.

That sounds like a useful starting point. But empirically, as far as I
can tell, the relationship between query latency and how close you are
to fully saturating I/O is not linear, or anything like it. Maybe it's
an S-curve?

It seems that a fully I/O-bound query isn't remotely close to twice as
fast as the same query when it is restricted to using only half the
number of batches (half the number required to reach that saturation
point). OTOH, using more batches than strictly necessary usually isn't
much of a problem. So I don't think that we can rely on a precise
formula, even if we're willing to make fixed assumptions about data
layout (which we're not).

If there were a good enough reason for index prefetching to use an
unbounded number of batches, we could surely figure out a way to
support that requirement. It'd be messy, and relatively hard to test.
And I'd worry a bit about there being zero backstop for index-only
scans. But it can be done. If we did things that way (which doesn't
seem like a good idea right now), we wouldn't have to model I/O
saturation at all. Which tbh makes me wonder if that kind of modelling
has much practical use either way.

> Something like an index scan, will have its own limit to how much it can
> process in a second. If we can only do 100k IOPS while searching the index,
> fetching the heap tuples and processing them, we don't need to support the
> queue depths to support doing 1M IOPS within one backend.
>
> That's something that can presumably be quite easily experimentally
> ballparked:
>
> A fully cached, completely uncorrelated, index scan seems to be able to fetch
> about 1.5M page fetches on my ~6 YO server CPU with turbo boost disabled, when
> never looking at the results (i.e. using OFFSET) or immediately filtering away
> the row.

> So I'd guess the limit on newer CPUs in SKUs optimized for clock
> speed and boost enabled, is north of 2.5M pages/sec, higher than I'd have
> thought!  That's without doing any IO though.

We've done good work on nbtree's ability to avoid provably unnecessary
work in recent years; see _bt_set_startikey. What that means is that
the majority of the index scans used to test the patch probably have
_bt_readpage calls that spend most of their time simply collecting all
of the TIDs from the leaf page, without any scan key overhead (barring
an initial precheck within _bt_set_startikey once per _bt_readpage, to
prove that the optimization is safe).

With large posting list tuples, we'll do even less work, since they're
just an array of ItemPointerData.

> With correlated scans the limit is much lower, maybe 150k, just because
> there's so many more tuples per page (and processing them trivially becomes
> the bottleneck).
>
>
> So, to support actually utilizing the full IO IO capability, we need to allow
> for enough batches to keep a few hundred IOs in flight at the very extreme
> end.  I'd assume you have a much better idea to how many batches that
> translates to?

I can give you a range. The problem is that it's a range starting from
"absurdly optimistic" through to "absurdly pessimistic". Neither
extreme is very unlikely (there's a wide natural variation in
workloads), and it's hard to argue usefully about what will be true in
most cases. In short, I can tell you plenty, but nothing that seems
particularly useful for determining how many batches we should cap the
ring buffer at.

I don't think there's anything fundamentally objectionable about our
deriving the current maximum of 64 through trial and error. I assume
that INDEX_SCAN_MAX_BATCHES must be constrained to a low-ish power of
two so that the ring buffer maintenance routines avoid DIV
instructions (from the use of a modulo operator that the compiler
cannot optimize into a bitwise AND). There just aren't that many
integers that even qualify as candidates!

I'm pretty sure that 32 is likely too low (though it's hard to tell
with buffered I/O on a fast local SSD). 128 might still be too low in
extreme corner cases involving high latency and few matches per batch
(though I doubt it). 256 seems too implausibly high to ever make sense
(but I've been wrong before).

--
Peter Geoghegan

---

From: Peter Geoghegan <[email-masked]>
Date: Fri, 27 Mar 2026 20:35:24 -0400

On Sun, Mar 22, 2026 at 9:14 PM Peter Geoghegan <[email-masked]> wrote:
> dealing with the trickier parts for now.

Attached is v18, which addresses the tricky issues I skipped in v17.

Highlights:

* New commit (the first) that adds a heapam_indexscan.c file, and
moves all of the relevant existing heapam_handler.c functions over to
this new file. This commit is strictly mechanical, and has no
functional impact.

* Another new commit (the second) adds the new slot-based index scan
interface, and pushes the VM lookups that currently take place in
nodeIndexonlyScan.c into heapam. This includes the instrumentation
changes that were previously in their own small patch. It also
includes the VISITED_PAGES_LIMIT stuff -- there's no longer any commit
that breaks VISITED_PAGES_LIMIT, nor is there a later commit that
subsequently restores the functionality.

* We now extract one key piece of infrastructure that other table AMs
can now reuse to manage their own index scan batches: a new inline
function called tableam_util_fetch_next_batch now appears as an
indexbatch.h inline function.

This function (which appeared under a different name in v17) manages
deciding whether to call amgetbatch (performing the amgetbatch call
when needed) or to simply return the next batch in line (because such
a batch already exists/is already loaded in the ring buffer from
earlier). It also detects and handles cross-batch changes in scan
direction, and sets/reads the fields that track when a batch is
definitely the last one in the given scan direction.

I tried to go further by also placing the bulk of the code in
heapam_index_getnext_scanbatch_pos in indexbatch.h, but ultimately
concluded that it wasn't worth it. There isn't much code in
heapam_index_getnext_scanbatch_pos to generalize. Some of that code
coordinates quite directly with the heapam read stream callback.

* Added an "isGuarded" field to IndexScanBatchData. That way we can
assert that any batch loaded within the read stream callback is
already unguarded/has no batch index page buffer pin still held. The
field isn't just for assertions, though. We also rely on it in one or
two places during release builds, which seemed clearer to me. For
example, we no longer require that amunguardbatch routines be
idempotent, which seems like a minor improvement.

* Other work is broken out into its own commit, to better highlight
issues that aren't strictly related to the amgetbatch changes.
Specifically, a new commit adds the "don't unpin on rescan" behavior.
These changes, which first appeared in the main amgetbatch commit, aim
to minimize regressions with cached workloads (this was discussed
briefly upthread, plus Andres and I discussed it over IM some weeks
back).

* Replaced what was previously an assert with an equivalent
pg_assume(), within heapam_index_getnext_scanbatch_pos -- which is a
very hot function.  We now "pg_assume(batchringbuf->headBatch ==
scanPos->batch)", allowing the compiler to exploit this existing
invariant. This seems to help the compiler generate better code,
presumably by allowing more efficient register allocation. I haven't
looked at the disassembly just yet, but it helps with certain
microbenchmarks enough to matter.

* The "Add UnlockBufferGetLSN utility function" commit now uses an
atomic operation (in the common case where it actually drops the index
page's pin), just like UnlockReleaseBuffer itself following Andres'
commit f39cb8c011062d65e146c1e9d1aae221e96d8320 from today.

I completed this item quickly, after pulling from master a little
while ago. The expectation for UnlockBufferGetLSN is that it be at
least as good as a BufferGetLSNAtomic followed by a
UnlockReleaseBuffer, so IMV it made sense to include this last minute
addition in v18.

* The "don't wait" patches are no longer included, since Andres
committed them to the master branch just now. Thanks to both you
(Andres) and Melanie for taking care of this very thorny issue!

I believe I have now acted on all of Andres' feedback, with one minor
exception: Andres disliked how we use 'if (scan->xs_heapfetch)' to
determine whether to use the scan's batch cache (we don't want to use
it at all if the scan is ending). I just ran out of steam, so this
version doesn't address the problem. But I'm still tracking it as an
open item (I don't disagree with Andres, but this problem is slightly
awkward). Note that I *did* create a helper function here, so we at
least avoid having 2 copies of the loop that looks for a free cache
batch slot.

--
Peter Geoghegan

---

From: SATYANARAYANA NARLAPURAM <[email-masked]>
Date: Sat, 28 Mar 2026 23:15:55 -0700

Hi Peter,

> least avoid having 2 copies of the loop that looks for a free cache

I am still reviewing / understanding the patch, a couple quick comments
based on my review.

1. Looks like off-by-one in the for loop in the
patch v18-0015-WIP-aio-io_uring-Use-IO-size-not-IO-queue-to-tri.patch

@@ -730,7 +711,39 @@ pgaio_uring_sq_from_io(PgAioHandle *ioh, struct
io_uring_sqe *sqe)
  ioh->op_data.read.iov_length,
  ioh->op_data.read.offset);

+ for (int i = 0; i <= ioh->op_data.read.iov_length; i++, iov++)
+ io_size += iov->iov_len;

2. NIT comment: _bt_endpoint return type changed to IndexScanBatch from
bool in the patch
v18-0005-Add-interfaces-that-enable-index-prefetching.patch.
But the places it is returning false would be  good to replace with NULL.
Though false is treat as 0/NULL and no compiler errors, it improves the
readability of the code.

 if (!BufferIsValid(btfirstbatch->buf))
{
/*
* Empty index. Lock the whole relation, as nothing finer to lock
* exists.
*/
PredicateLockRelation(rel, scan->xs_snapshot);
_bt_parallel_done(scan);
return false;
}

Thanks,
Satya

---

From: Peter Geoghegan <[email-masked]>
Date: Sun, 29 Mar 2026 15:52:08 -0400

On Sun, Mar 29, 2026 at 2:16 AM SATYANARAYANA NARLAPURAM
<[email-masked]> wrote:
> I am still reviewing / understanding the patch, a couple quick comments based on my review.
>
> 1. Looks like off-by-one in the for loop in the patch v18-0015-WIP-aio-io_uring-Use-IO-size-not-IO-queue-to-tri.patch

You're right. Andres independently fixed this bug on our working
branch just yesterday.

> 2. NIT comment: _bt_endpoint return type changed to IndexScanBatch from bool in the patch v18-0005-Add-interfaces-that-enable-index-prefetching.patch.
> But the places it is returning false would be  good to replace with NULL. Though false is treat as 0/NULL and no compiler errors, it improves the readability of the code.

I agree. This was a simple oversight, not a deliberate choice. It'll
be fixed in the next version.

Thanks
-- 
Peter Geoghegan

---

From: Peter Geoghegan <[email-masked]>
Date: Tue, 31 Mar 2026 13:39:30 -0400

> Attached is v18, which addresses the tricky issues I skipped in v17.

v19 is attached. It contains a few notable improvements over v18:

* The first commit (the one that simply moves code into the new
heapam_indexscan.c file) now moves heap_hot_search_buffer over there
as well.

Andres suggested this at one point, and I believe it wins us a small
but significant performance benefit.

> commit f39cb8c011062d65e146c1e9d1aae221e96d8320 from today.

* v19 removes the "Add UnlockBufferGetLSN utility function" patch entirely.

Following Andres' commit f39cb8c0, this no longer seemed to help
performance (compared to just calling BufferGetLSNAtomic and
UnlockReleaseBuffer directly), so I removed it.

> version doesn't address the problem.

* v19 fixes this by adding a new helper function. This allows us to
pass down the required context (whether the scan is ending) to inform
the relevant batch recycling code whether it really should place its
caller's batch in the scan's cache (or whether it should just pfree it
right away).

As in previous versions, I'm including several extra read stream
patches to address various problems in that area. These problems, tied
to how index prefetching stresses the read stream that are new and not
encountered by existing read stream users. Andres wrote all of these
patches. Those aspects of the patch should be discussed on a new
thread dedicated to index prefetching-related read stream
enhancements: https://postgr.es/m/f3xxfrkafjxpyqxywcxricxgyizjirfceychyxsgn7bwjp5eda@kwbduhy7tfmu

(Though I'm also carrying some extra bug fix patches here, not
included on that other thread, to address preexisting read stream
bugs, albeit bugs highly unlikely to affect current read stream
users.)

--
Peter Geoghegan

---

From: Andres Freund <[email-masked]>
Date: Tue, 31 Mar 2026 16:22:36 -0400

Hi,

> but significant performance benefit.

To me it also just makes sense.

> From 742dcbea7d184443d2c4ef3c095b60f47f870f72 Mon Sep 17 00:00:00 2001
> Date: Wed, 25 Mar 2026 00:22:17 -0400
> Subject: [PATCH v19 01/18] Move heapam_handler.c index scan code to new file.
>
> Move the heapam index fetch callbacks (index_fetch_begin,
> index_fetch_reset, index_fetch_end, and index_fetch_tuple) into a new
> dedicated file.  Also move heap_hot_search_buffer over.  This is a
> purely mechanical move with no functional impact.

I don't love that this renames arguments (s/call_again/heap_continue/) while
moving. Makes it harder to verify this is just the mechanical change it claims
to be.

> +/*-------------------------------------------------------------------------
> + *
> + * heapam_indexscan.c
> + *	  heap table plain index scan and index-only scan code
> + *
> + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
> + * Portions Copyright (c) 1994, Regents of the University of California
> + *
> + *
> + * IDENTIFICATION
> + *	  src/backend/access/heap/heapam_indexscan.c
> + *
> + *-------------------------------------------------------------------------
> + */
> +#include "postgres.h"
> +
> +#include "access/heapam.h"
> +#include "storage/predicate.h"

Should probably include access/relscan.h for +IndexFetchTableData, rather than
relying on the indirect include.

> From 5ca838ac32a8d43510325cb400be4ecea47ea8d2 Mon Sep 17 00:00:00 2001
> Date: Sun, 22 Mar 2026 02:36:57 -0400
> Subject: [PATCH v19 02/18] Add slot-based table AM index scan interface.
>
> Add table_index_getnext_slot, a new table AM callback that wraps both
> plain and index-only index scans that use amgettuple.  Two new
> TableAmRoutine callbacks are introduced -- one for plain scans and one
> for index-only scans -- which an upcoming commit that adds the
> amgetbatch interface will expand to four.  The appropriate callback is
> resolved once in index_beginscan, and called through a function pointer
> (xs_getnext_slot) on the IndexScanDesc when the table_index_getnext_slot
> shim function is called from executor nodes.
>
> This moves VM checks for index-only scans out of the executor and into
> heapam, enabling batching of visibility map lookups (though for now we
> continue to just perform retail lookups).

I'd also add that it's architecturally much cleaner this way.

> A small minority of callers (2 callers in total) continue to use the old
> table_index_fetch_tuple interface.  This is still necessary with callers
> that fundamentally need to pass a TID to the table AM.  Both callers
> perform some kind of constraint enforcement.

I think we may need to do something about this before all of this over. It's
just too confusing to have the generic sounding index_fetch_tuple() interfaces
that are barely ever used and *shouldn't* be used when, uhm, fetching a tuple
pointed to by an index.

At the very least it seems we should rename the ->index_fetch_tuple() to
->index_fetch_tid() or something and remove the call_again, all_dead
arguments, since they are never used in this context.  I suspect it should
*not* use the table_index_fetch_begin()/table_index_fetch_end() interface
either.

> XXX Do we still need IndexFetchTableData.rel?  We are now mostly using
> IndexScanDescData.heapRelation, but we could use it for absolutely
> everything if we were willing to revise the signature of the old
> table_index_fetch_tuple interface by giving it its own heapRelation arg.

I think we *should* change the argument of table_index_fetch_tuple().

> Index-only scan callers pass table_index_getnext_slot a TupleTableSlot
> (which the table AM needs internally for heap fetches), but continue to
> read their results from IndexScanDescData fields such as xs_itup (rather
> than from the slot itself).

Pretty this ain't.  But I guess it's been vaguely gross like this for quite a
while, so...

> This convention lets both plain and index-only scans use the same
> table_index_getnext_slot interface.

Not convinced that's really a useful goal.

> All callers can continue to rely on the scan descriptor's xs_heaptid field
> being set on each call. (execCurrentOf is the only caller that reads this
> field directly, to determine the current row of an index-only scan, but it
> seems like a good idea to do the same thing for all callers).

> XXX Should execCurrentOf continue to do this?  Can't it get the same TID
> from the table slot instead?

I think the tid from the slot is always the tid from the start of the HOT
chain, not the actual location of the tuple. That's important, as otherwise a
HOT pruning after determining the slot's tid would potentially make the tid
invalid.  Whereas, I think, xs_heaptid, is currently set to the offset of the
actual tuple, even if it's a just a HOT version.

> @@ -177,10 +181,13 @@ typedef struct IndexScanDescData
>  	struct TupleDescData *xs_hitupdesc; /* rowtype descriptor of xs_hitup */
>
>  	ItemPointerData xs_heaptid; /* result */
> -	bool		xs_heap_continue;	/* T if must keep walking, potential
> -									 * further results */
>  	IndexFetchTableData *xs_heapfetch;
>
> +	/* Resolved getnext_slot implementation, set by index_beginscan */
> +	bool		(*xs_getnext_slot) (struct IndexScanDescData *scan,
> +									struct TupleTableSlot *slot);
> +
>  	bool		xs_recheck;		/* T means scan keys must be rechecked */
>
>  	/*

Hm. Why is it ok that we now don't have an equivalent of xs_heap_continue on
the scan level anymore?  I see there's a local heap_continue in
heapam_index_getnext_slot(), but isn't that insufficient e.g. for a
SNAPSHOT_ANY snapshot, where all the row versions would be visible?

Am I misunderstanding how this works?

> +/*
> + * Fetch the next tuple from an index scan into `slot`, scanning in the
> + * specified direction.  Returns true if a tuple satisfying the scan keys and
> + * the snapshot was found, false otherwise.  The tuple is stored in the
> + * specified slot.
> + *
> + * Dispatches through scan->xs_getnext_slot, which is resolved once by
> + * index_beginscan.
> + *
> + * released by a future table_index_getnext_slot or index_endscan call.
> + *
> + * For index-only scans, the callback also fills xs_itup/xs_itupdesc or
> + * xs_hitup/xs_hitupdesc (or both) so that index data can be returned without
> + * a heap fetch.
> + */
> +static inline bool
> +table_index_getnext_slot(IndexScanDesc iscan, ScanDirection direction,
> +						 TupleTableSlot *slot)
> +{
> +	return iscan->xs_getnext_slot(iscan, direction, slot);
> +}

Hm. Does this actually belong in tableam.h? I'm a bit conflicted.

> @@ -2553,6 +2554,8 @@ static const TableAmRoutine heapam_methods = {
>  	.index_fetch_begin = heapam_index_fetch_begin,
>  	.index_fetch_reset = heapam_index_fetch_reset,
>  	.index_fetch_end = heapam_index_fetch_end,
> +	.index_plain_amgettuple_getnext_slot = heapam_index_plain_amgettuple_getnext_slot,
> +	.index_only_amgettuple_getnext_slot = heapam_index_only_amgettuple_getnext_slot,
>  	.index_fetch_tuple = heapam_index_fetch_tuple,
>
>  	.tuple_insert = heapam_tuple_insert,

Wonder if it's perhaps worth deleting _slot and shortening getnext to
next. These are quite only names...

> +/* table_index_getnext_slot callback: amgettuple, plain index scan */
> +pg_attribute_hot bool
> +heapam_index_plain_amgettuple_getnext_slot(IndexScanDesc scan,
> +	Assert(!scan->xs_want_itup);
> +	Assert(scan->indexRelation->rd_indam->amgettuple != NULL);
> +
> +	return heapam_index_getnext_slot(scan, direction, slot, false);
> +}
> +
> +/* table_index_getnext_slot callback: amgettuple, index-only scan */
> +pg_attribute_hot bool
> +heapam_index_only_amgettuple_getnext_slot(IndexScanDesc scan,
> +	Assert(scan->xs_want_itup);
> +	Assert(scan->indexRelation->rd_indam->amgettuple != NULL);
> +
> +	return heapam_index_getnext_slot(scan, direction, slot, true);
> +}
> +
>  bool
>  heapam_index_fetch_tuple(struct IndexFetchTableData *scan,
>  						 ItemPointer tid,
> @@ -233,6 +274,18 @@ heapam_index_fetch_tuple(struct IndexFetchTableData *scan,
>  						 TupleTableSlot *slot,
>  						 bool *heap_continue, bool *all_dead)
>  {
> +
> +	return heapam_index_fetch_tuple_impl(scan, tid, snapshot, slot,
> +										 heap_continue, all_dead);
> +}
> +
> +static pg_attribute_always_inline bool
> +heapam_index_fetch_tuple_impl(struct IndexFetchTableData *scan,
> +							  ItemPointer tid,
> +							  Snapshot snapshot,
> +							  TupleTableSlot *slot,
> +							  bool *heap_continue, bool *all_dead)
> +{
>  	IndexFetchHeapData *hscan = (IndexFetchHeapData *) scan;
>  	BufferHeapTupleTableSlot *bslot = (BufferHeapTupleTableSlot *) slot;
>  	bool		got_heap_tuple;
> @@ -289,3 +342,172 @@ heapam_index_fetch_tuple(struct IndexFetchTableData *scan,
>
>  	return got_heap_tuple;
>  }
> +
> +/*
> + * Common implementation for both heapam_index_*_getnext_slot variants.
> + *
> + * dropped by a future call here (or by a later call to heapam_index_fetch_end
> + * through index_endscan).
> + *
> + * The index_only parameter is a compile-time constant at each call site,
> + * allowing the compiler to specialize the code for each variant.
> + */
> +static pg_attribute_always_inline bool
> +						  TupleTableSlot *slot, bool index_only)

For heapam_index_fetch_tuple_impl() you added _impl, but here you didn't.
Mild preference for also adding _impl here.

> +	ItemPointer tid;
> +	bool		heap_continue = false;

As mentioned above, it's not clear to me that it is correct for all scan types
to have a short-lived heap_continue.

> +	bool		all_visible = false;
> +				xs_visited_pages_limit = 0;
> +
> +	if (index_only)

Did you check that it's useful to have xs_visited_pages_limit as a longer
lived variable? I suspect it's just going to add register pressure and will
need to be saved/restored across other calls, making it just as cheap to
always fetch it from scan.

> +	for (;;)
> +	{
> +		if (!heap_continue)
> +		{
> +			/* Get the next TID from the index */
> +			/* If we're out of index entries, we're done */
> +			if (tid == NULL)
> +				break;
> +
> +			/* For index-only scans, check the visibility map */
> +			if (index_only)
> +				all_visible = VM_ALL_VISIBLE(scan->heapRelation,
> +											 &hscan->xs_vmbuffer);
> +		Assert(ItemPointerIsValid(&scan->xs_heaptid));
> +
> +		if (index_only)
> +		{
> +			/*
> +			 * We can skip the heap fetch if the TID references a heap page on
> +			 * which all tuples are known visible to everybody.  In any case,
> +			 * we'll use the index tuple not the heap tuple as the data
> +			 * source.
> +			 */
> +			if (!all_visible)
> +			{
> +				/*
> +				 * Rats, we have to visit the heap to check visibility.
> +				 */
> +				if (scan->instrument)
> +					scan->instrument->ntablefetches++;
> +
> +				if (!heapam_index_fetch_heap(scan, slot, &heap_continue))
> +				{

Still find it somewhat weird that we have local 'tid' variable, that we do not
use pass to heapam_index_fetch_heap(), because heapam_index_fetch_heap()
relies on index_getnext_tid() having stored the to-be-fetched tid in
xs_heaptid.

But I guess that's something to change at a later date.

> +					 * (only selfuncs.c does this), count distinct heap pages
> +					 * and give up once we've visited too many.
> +					 */
> +						BlockNumber blk = ItemPointerGetBlockNumber(tid);
> +
> +						if (blk != last_visited_block)
> +						{
> +							last_visited_block = blk;
> +					continue;	/* no visible tuple, try next index entry */
> +				}
> +
> +				ExecClearTuple(slot);

Previously the ExecClearTuple() here had at least this comment:
/* We don't actually need the heap tuple for anything */

Now it looks even more confusing...

> @@ -313,7 +313,32 @@ visibilitymap_set(BlockNumber heapBlk,
>   * since we don't lock the visibility map page either, it's even possible that
>   * someone else could have changed the bit just before we look at it, but yet
>   * we might see the old value.  It is the caller's responsibility to deal with
> - * all concurrency issues!
> + * all concurrency issues!  In practice it can't be stale enough to matter for
> + * the primary use case: index-only scans that check whether a heap fetch can
> + * be skipped.
> + *
> + * The argument for why it can't be stale enough to matter for the primary use
> + * case is as follows:
> + *
> + * Inserts: we need to detect that a VM bit was cleared by an insert right
> + * away, because the new tuple is present in the index but not yet visible.
> + * Reading the TID from the index page (under a shared lock on the index
> + * buffer) is serialized with the insertion of the TID into the index (under
> + * an exclusive lock on the same index buffer).  Because the VM bit is cleared
> + * before the index is updated, and locking/unlocking of the index page acts
> + * as a full memory barrier, we are sure to see the cleared bit whenever we
> + * see a recently-inserted TID.
> + *
> + * Deletes: the clearing of the VM bit by a delete is NOT serialized with the
> + * index page access, because deletes do not update the index page (only
> + * VACUUM removes the index TID).  So we may see a significantly stale value.
> + * However, we don't need to detect the delete right away, because the tuple
> + * remains visible until the deleting transaction commits or the statement
> + * ends (if it's our own transaction).  In either case, the lock on the VM
> + * buffer will have been released (acting as a write barrier) after clearing
> + * the bit.  And for us to have a snapshot that includes the deleting
> + * transaction (making the tuple invisible), we must have acquired
> + * ProcArrayLock after that time, acting as a read barrier.
>   */
>  uint8
>  visibilitymap_get_status(Relation rel, BlockNumber heapBlk, Buffer *vmbuf)

Nice. Much better place for the comment.

> index 1408989c5..acc9f3e6a 100644
> @@ -126,6 +126,8 @@ RelationGetIndexScan(Relation indexRelation, int nkeys, int norderbys)
>  	scan->xs_hitup = NULL;
>  	return scan;

Orthogonal: I suspect eventually we should pass the table to
RelationGetIndexScan(), have the tableam return how much space it needs, and
allocate it one chunk.

> From 85be7bf520fc2746f4ee73a0744c7aa04da55e52 Mon Sep 17 00:00:00 2001
> Subject: [PATCH v19 03/18] heapam: Track heap block in IndexFetchHeapData.

LGTM.

> Subject: [PATCH v19 04/18] heapam: Keep buffer pins across index rescans.
>
> Avoid dropping the heap page pin (xs_cbuf) and visibility map pin
> (xs_vmbuffer) during heapam_index_fetch_reset.  Retaining these pins
> saves cycles during tight nested loop joins and merge joins that
> frequently restore a saved mark, since the next tuple fetched after a
> rescan often falls on the same heap page.  It can also avoid repeated
> pinning and unpinning of the same buffer when rescans happen to revisit
> the same page.
>
> Preparation for an upcoming patch that will add the amgetbatch
> interface to enable optimizations such as I/O prefetching.
>
> Reviewed-By: Andres Freund <[email-masked]>
> Discussion: https://postgr.es/m/CAH2-Wz=g=[email-masked]
> ---
>  src/backend/access/heap/heapam_indexscan.c | 27 ++++++++++++----------
>  src/backend/access/index/indexam.c         |  6 ++---
>  2 files changed, 18 insertions(+), 15 deletions(-)
>
> diff --git a/src/backend/access/heap/heapam_indexscan.c b/src/backend/access/heap/heapam_indexscan.c
> index 4b5e2b30d..82f135e1e 100644
> --- a/src/backend/access/heap/heapam_indexscan.c
> +++ b/src/backend/access/heap/heapam_indexscan.c
> @@ -59,18 +59,15 @@ heapam_index_fetch_reset(IndexFetchTableData *scan)
>  {
>  	IndexFetchHeapData *hscan = (IndexFetchHeapData *) scan;
>
> -	if (BufferIsValid(hscan->xs_cbuf))
> -	{
> -		ReleaseBuffer(hscan->xs_cbuf);
> -		hscan->xs_cbuf = InvalidBuffer;
> -		hscan->xs_blk = InvalidBlockNumber;
> -	}
> +	/* Resets are a no-op (XXX amgetbatch commit resets xs_vm_items here) */
> +	(void) hscan;

I assume that's not a line you intend to commit?

LGTM otherwise.

Need to do something else for a while.  More another time.

Greetings,

Andres Freund

---

From: Peter Geoghegan <[email-masked]>
Date: Wed, 1 Apr 2026 11:51:19 -0400

Will fix.

Will fix.

> I'd also add that it's architecturally much cleaner this way.

Agreed, will fix the commit message.

Agreed.

> *not* use the table_index_fetch_begin()/table_index_fetch_end() interface

I'm working on a version of this that makes index_fetch_tid into a
more specialized thing, purely designed for the requirements of the 2
remaining callers (that just need to do constraint enforcement) that
absolutely have to pass a TID/that fundamentally cannot use a
slot-based interface. This approach will avoid the need to call
table_index_fetch_begin()/table_index_fetch_end() -- because it won't
even have an IndexFetchTableData to pass.

Something that has long bothered me about the current structure of the
patch is that indexam.c sometimes resets the batch structure (e.g., on
rescans), while heapam sometimes does very similar things (e.g., when
the scan changes direction). This means heapam relies on indexam.c
having already reset the batch ring buffer when
heapam_index_fetch_reset is called on a rescan; it only needs to reset
the read stream state (which is fairly closely related to the batch
ring buffer and has invariants involving the ring buffer). In short,
the ownership of the batch ring buffer is muddled.

I plan on clarifying things in this area for v20. It will fully
embrace the idea that IndexFetchTableData is just the table AM
specific part of IndexScanDescData. This will allow me to move most of
the batch-related calls currently in indexam.c over to heapam/the
table AM. That will make it very clear that the table AM fully owns
the batch ring buffer (there'll continue to be indexam.c calls to do
things like take a mark, since that is implemented in a way that's
already table AM agnostic).

One problem with the current design is that it still treats
IndexFetchTableData as not just the table AM piece of
IndexScanDescData. I now realize that it works that way purely so we
can avoid changing anything about index_fetch_tuple. But, as you say,
we really *should* do that anyway -- it shouldn't need its own
IndexFetchTableData (or its own IndexScanDescData), since it literally
doesn't have anything to do with index scans.

> Not convinced that's really a useful goal.

I don't think so either. That was just pro forma. The next version
won't say this.

> Am I misunderstanding how this works?

No, I just screwed this up -- not sure how I missed it. It's a pity
the tests didn't fail as a result.

Will fix.

> Hm. Does this actually belong in tableam.h? I'm a bit conflicted.

I can see arguments for both.

> next. These are quite only names...

I will shorten these for the next version.

> Mild preference for also adding _impl here.

That was when there was a separate heapam_index_fetch_tuple for the
callback. But that's going away anyway now.

Will fix.

> But I guess that's something to change at a later date.

Agreed, it is weird.

> Now it looks even more confusing...

I will add that comment back.

> RelationGetIndexScan(), have the tableam return how much space it needs, and

Agreed. Since these two structs (IndexScanDescData and
IndexFetchTableData) are essentially 2 different parts of the same
thing. That might have been debatable before now, but everything we're
doing here is moving in that direction.

Cool.

Will fix.

Cool.

Thanks for the review!

--
Peter Geoghegan

---

From: Peter Geoghegan <[email-masked]>
Date: Wed, 1 Apr 2026 18:50:15 -0400

> already table AM agnostic).

Attached is v20, which does things this way. Now it's completely clear
that the scan's batch ring buffer is fully controlled by the table AM.

There are now only 2 remaining indexbatch.c functions that get called
from indexam.c. Since these are both trivial, highly generic
functions, restructuring things so that heapam would call these two
seemed unnecessary.

> doesn't have anything to do with index scans.

v20 also repurposes and renames index_fetch_tuple (the new name is
fetch_tid). This clarifies that it's a specialized function used only
by callers that inherently need to pass their own TID (the 2 remaining
constraint enforcement callers). These callers don't really perform an
index scan at all (the TID might come from an index, but clearly
_bt_check_unique isn't performing an index scan, by any reasonable
definition).

IndexFetchTableData is no longer used by fetch_tid or its callers. As
discussed, this change makes it possible to pass an IndexScanDescData
pointer (not a IndexFetchTableData pointer) through to heapam
functions like heapam_index_fetch_reset and heapam_index_fetch_end
(since doing so won't break index_fetch_tuple/fetch_tid, now that
their dependency on IndexFetchTableData has been broken).

One consequence of this refactoring is that we can no longer just call
heapam_index_fetch_reset from indexam.c when restoring a mark -- that
will reset the scan's batchringbuf, which isn't appropriate here (we
usually don't change the batchringbuf at all, in the happy path we
only need to override scanPos with markPos). indexam.c now calls a new
heapam_index_fetch_restrpos function (through its table AM shim)
instead -- heapam handles restoring the mark on its behalf.

The addition of this new heapam_index_fetch_restrpos function seems
strictly better to me. Restoring a mark in v19 meant resetting
xs_vm_items back to 1 -- which is only appropriate during a true
rescan. As in many other areas, the table AM can fully see what's
going on (it has all the relevant context), which allows it to do
exactly the right thing (layering that obscures useful information
about what's really going on is generally something we want to avoid
IMV).

--
Peter Geoghegan

---

From: Peter Geoghegan <[email-masked]>
Date: Fri, 3 Apr 2026 01:17:14 -0400

> that the scan's batch ring buffer is fully controlled by the table AM.

Attached is v21, which advances things in the direction established in
v20. It also fixes bitrot -- we no longer carry certain read stream
related patches from Andres that were committed since I posted v20 a
couple of days ago.

> functions, restructuring things so that heapam would call these two

I changed my mind about this: I now think it's better if indexam.c
never directly calls indexbatch.c. Just for flexibility and
extensibility. That's how it's done in v21.

In v21, the code in indexbatch.c is about 80% table AM code, 20% index
AM code -- nothing more. That seems a bit cleaner to me, because the
table AM knowing that the batchringbuf must have already been
initialized on its behalf is no longer requires (nothing like that is
required). The table AM fully owns batchringbuf, and initializes it
for itself where needed.

This means I've had to add a new table AM callback to take a mark,
which isn't strictly necessary for heapam -- the heapam implementation
just calls the relevant indexbatch.c routine, which in practice
handles everything that's needed by heapam on its own. My guess is
that Andres will slightly prefer things that way, since of course it's
possible that some external table AM will need non-trivial code just
to take a mark (restoring a mark has to be a bit more complicated to
account for the read stream, which is why v20 already had one).

We're losing 2 callbacks from the index AM API (for mark/restore), but
gaining 2 very similar ones in the table AM API.

We now expect the table AM's index_fetch_begin callback to set
IndexScanDescData.xs_getnext_slot for the scan itself -- another thing
that happens in the table AM rather than in indexam.c in v21. That way
we don't need 4 extra TableAmRoutine callbacks (one for every
combination of amgettuple, amgetbatch, index-only, plain).

I don't think requiring exactly 4 slot-based variants set within
TableAmRoutine, as was the case with the last several patch versions,
was sensible. In v21, the table AM can provide as few or as many
callbacks as it likes (obviously at least one callback must exist).
This works fine, provided the table AM is sure that the callback will
be correct for the duration of that particular scan, based on what is
known about the scan when it begins. (In practice this means the table
AM exploiting knowledge of whether the scan is index-only or plain, or
whether it's amgettuple or amgetbatch -- but other variations might
make sense in the future.)

-- 
Peter Geoghegan

---

From: Peter Geoghegan <[email-masked]>
Date: Sat, 4 Apr 2026 00:16:37 -0400

> related patches from Andres that were committed since I posted v20 a

The patch bitrot again. Attached is v22 to fix that.

Nothing terribly interesting here, compared to v21. I spent quite a
lot of time on the documentation today. This involved general
polishing, some of which focused on making the documentation better
align with the changes from v20 and v21.

-- 
Peter Geoghegan

---

From: Andres Freund <[email-masked]>
Date: Sat, 4 Apr 2026 01:21:53 -0400

Hi,

> related patches from Andres that were committed since I posted v20 a

There's already bitrot again :(

commit b7b27eb41a5
Author: Amit Langote <[email-masked]>
Date:   2026-04-03 14:33:53 +0900

    Optimize fast-path FK checks with batched index probes

Added new calls that need to be updated.

> From 0d2fb87ab64539b64fce948d7829d78a0ceee8f8 Mon Sep 17 00:00:00 2001
> Date: Wed, 1 Apr 2026 17:44:54 -0400
> Subject: [PATCH v21 01/15] Rename heapam_index_fetch_tuple argument for
>  clarity.
>
> Rename heapam_index_fetch_tuple's call_again argument to heap_continue,
> for consistency with the pointed-to variable name (IndexScanDescData's
> xs_heap_continue field).
>
> Preparation for an upcoming commit that will move index scan related
> heapam functions into their own file.
>
> Discussion: https://postgr.es/m/bmbrkiyjxoal6o5xadzv5bveoynrt3x37wqch7w3jnwumkq2yo@b4zmtnrfs4mh

> From c883bcff62612c538eae9d8d950321f5d9876d3c Mon Sep 17 00:00:00 2001
> Subject: [PATCH v21 02/15] Move heapam_handler.c index scan code to new file.
>
> Upcoming work to add a slot-based table AM interface for index scans
> will substantially expand this code.  Keeping it in heapam_handler.c
> would clutter a file whose primary role is to wire up the TableAmRoutine
> callbacks.  Bitmap heap scans and sequential scans would benefit from
> similar separation in the future.
>
> Discussion: https://postgr.es/m/bmbrkiyjxoal6o5xadzv5bveoynrt3x37wqch7w3jnwumkq2yo@b4zmtnrfs4mh

Any reason to hold off pushing these?

> From b21d3af88aa5af6c038e860465d644a47425f131 Mon Sep 17 00:00:00 2001
> Subject: [PATCH v21 03/15] Add slot-based table AM index scan interface.
>
> continue to just perform retail lookups).  Using the new higher level
> slot-based interface greatly simplifies nodeIndexonlyscan.c, which no
> longer has to deal with the visibility map directly.  More importantly,
> this is a significant architectural improvement: table AMs can now
> implement index-only scans that are not tied to heapam's visibility map.

Nice.

> A small minority of callers (2 callers in total) fundamentally need to
> pass a TID to the table AM (both perform constraint enforcement).  These
> callers don't actually perform index scans (even if their TIDs are taken
> from an index), and have no need for most of the index scan machinery.
> Switch these callers over to the new fetch_tid interface (which replaces
> the previous TID-based index_fetch_tuple interface).  All index scan
> callers now use the new slot-based interface (table_index_getnext_slot).

Nice.

> (which the table AM needs internally for heap fetches),

FWIW, that parenthetical does not convince me. Storing tuples in a slot is
more expensive than just doing so internally in a non-generic datastructure.

> @@ -181,6 +184,14 @@ typedef struct IndexScanDescData
>  									 * further results */
> +	 * Resolved table_index_getnext_slot callback, which is set by
> +	 * table_index_fetch_begin at the start of amgettuple scans
> +	 */

At first I didn't like very much that now the tableam is responsible for
determining this, but thinking about it more, it makes sense - it very well
could be that a tableam could benefit from choosing a xs_getnext_slot
implementation depending on things only it itself knows.

> index 4647785fd..e05c09fdb 100644
> @@ -17,6 +17,7 @@
>  #ifndef TABLEAM_H
>  #define TABLEAM_H
> +#include "access/genam.h"
>  #include "access/relscan.h"
>  #include "access/sdir.h"
>  #include "access/xact.h"

I'd strongly prefer forward declaring
typedef struct IndexScanDescData *IndexScanDesc;
over including genam.h into tableam.h

genam.h has a decent footprint with its own includes, I don't think we should
unnecessarily expose that to all tableam.h includes.

> @@ -442,60 +443,66 @@ typedef struct TableAmRoutine
>  	 */
>
>  	/*
> -	 * Prepare to fetch tuples from the relation, as needed when fetching
> -	 * tuples for an index scan.  The callback has to return an
> -	 * IndexFetchTableData, which the AM will typically embed in a larger
> -	 * structure with additional information.
> +	 * Prepare to fetch tuples, as needed when fetching tuples for an index
> +	 * scan.  The callback has to return an IndexFetchTableData, which the AM
> +	 * will typically embed in a larger structure with additional information.
> +	 * A pointer to this structure will be stored in passed index scan
> +	 * descriptor's xs_heapfetch field by the caller (core executor code).
>  	 *
>  	 * flags is a bitmask of ScanOptions affecting underlying table scan
>  	 * behavior. See scan_begin() for more information on passing these.
>  	 *
> -	 * Tuples for an index scan can then be fetched via index_fetch_tuple.
> +	 * Callback is responsible for setting IndexScanDesc.xs_getnext_slot to
> +	 * the appropriate slot-based callback.  Tuples can then be fetched via
> +	 * table_index_getnext_slot().  No separate slot-based callback exists in
> +	 * this struct!
> +	 *
> +	 * In principle a single general-purpose callback (stored here) would
> +	 * suffice, but using specialized variants allows the table AM to provide
> +	 * minimal code based on conditions that are fixed for the whole scan as
> +	 * an optimization (e.g., variants for plain index scans and index-only
> +	 * scans, each with fewer branches).
> +	 *
> +	 * Note that AMs that do not necessarily update indexes when indexed
> +	 * columns do not change, need to return the current/correct version of
> +	 * the tuple that is visible to the snapshot, even if the tid points to an
> +	 * older version of the tuple.
>  	 */
> -	struct IndexFetchTableData *(*index_fetch_begin) (Relation rel, uint32 flags);
> +	struct IndexFetchTableData *(*index_fetch_begin) (IndexScanDesc scan,
> +													  uint32 flags);

I kinda wonder if this should look roughly like this instead:

    /* size of the tableam's IndexFetchTableData */
    size_t index_fetch_state_size;
    void *(*index_fetch_begin) (Relation rel, IndexScanDesc scan, IndexFetchTableData *table_fetch, uint32 flags);

Moving the allocation of the IndexFetchTableData to the caller.  The advantage
would be that this would allow us to later combine the allocation of the
IndexScanDesc with the IndexFetchTableData, without again needing to change
the tableam interface.

>
>  /*
>   * Prepare to fetch tuples from the relation, as needed when fetching tuples
> - * for an index scan.
> + * for an index scan.  Caller puts this in the passed index scan descriptor's
> + * xs_heapfetch field.

s/, as needed when fetching tuples for/as part of/

The old formulation sounds a bit odd now that it's not not something standing
on its own anymore.

>  /*
> - * Release resources and deallocate index fetch.
> + * Release resources and deallocate index fetch held in the scan's underlying
> + * IndexFetchTableData.
>   */

The comment was weird before (mea culpa probably), but "index fetch held in
the scan's underlying IndexFetchTableData" seems even odder. A fetch held a
fetch :)

I'd just say "Release resources and deallocate the IndexFetchTableData in the
scan.".

If you were to go for the suggestion earlier about making it the caller's
responsibility to allocate the IndexFetchTableData, we'd obviously not make it
the tableam's responsibility anymore.

> +/*
> + * Convenience wrapper around table_fetch_tid() for callers that don't want to
> + * provide their own slot.

s/don't want to provide their own slot/that just need to check if a tuple is visible/

>
> +static bool heapam_index_plain_amgettuple_next(IndexScanDesc scan,
> +static bool heapam_index_only_amgettuple_next(IndexScanDesc scan,
> +static pg_attribute_always_inline bool heapam_index_fetch_tuple_impl(Relation rel,
> +																	 IndexFetchHeapData *hscan,
> +																	 ItemPointer tid,
> +																	 Snapshot snapshot,
> +																	 TupleTableSlot *slot,
> +																	 bool *heap_continue,
> +																	 bool *all_dead);
> +static pg_attribute_always_inline bool heapam_index_getnext_slot(IndexScanDesc scan,
> +																 bool index_only);
> +static pg_attribute_always_inline bool heapam_index_fetch_heap(IndexScanDesc scan,
> +															   IndexFetchHeapData *hscan,
> +															   TupleTableSlot *slot,
> +															   bool *heap_continue);
> +

This is quite a spectacularly ugly indentation.  Not your fault.  Still can't
stand it :)

I'd probably just reorder the function definitions so you don't need the
forward declaration for the pg_attribute_always_inline functions.

> +/*
> + * Simple, single-shot TID lookup for constraint enforcement code (unique
> + * checks and similar).  This is essentially just a heap_hot_search_buffer
> + * wrapper.
> + *
> + * This isn't actually related to index scans, but keeping it near
> + * heap_hot_search_buffer may help the compiler generate better code.
> + */
>  bool
> -heapam_index_fetch_tuple(struct IndexFetchTableData *scan,
> -						 ItemPointer tid,
> -						 Snapshot snapshot,
> -						 TupleTableSlot *slot,
> -						 bool *heap_continue, bool *all_dead)
> +heapam_fetch_tid(Relation rel, ItemPointer tid, Snapshot snapshot,
> +				 TupleTableSlot *slot, bool *all_dead)
> +{
> +	BufferHeapTupleTableSlot *bslot = (BufferHeapTupleTableSlot *) slot;
> +	Buffer		buf;
> +	bool		found;
> +
> +	Assert(TTS_IS_BUFFERTUPLE(slot));
> +
> +	buf = ReadBuffer(rel, ItemPointerGetBlockNumber(tid));
> +
> +	LockBuffer(buf, BUFFER_LOCK_SHARE);
> +	found = heap_hot_search_buffer(tid, rel, buf, snapshot,
> +								   &bslot->base.tupdata, all_dead, true);
> +	bslot->base.tupdata.t_self = *tid;
> +	LockBuffer(buf, BUFFER_LOCK_UNLOCK);
> +
> +	if (found)
> +	{
> +		slot->tts_tableOid = RelationGetRelid(rel);
> +		ExecStorePinnedBufferHeapTuple(&bslot->base.tupdata, slot,
> +									   buf);
> +	}
> +	else
> +		ReleaseBuffer(buf);
> +
> +	return found;
> +}
> +
> +heapam_index_plain_amgettuple_next(IndexScanDesc scan,
> +heapam_index_only_amgettuple_next(IndexScanDesc scan,
> +heapam_index_fetch_tuple_impl(Relation rel,
> +							  IndexFetchHeapData *hscan,
> +							  ItemPointer tid,
> +							  Snapshot snapshot,
> +							  TupleTableSlot *slot,

I'd probably put all of these just after heapam_index_fetch_end(), given they
are basically part of the implementation of heapam_index_*.

An

> diff --git a/src/backend/access/index/indexam.c b/src/backend/access/index/indexam.c
> index 44496ae09..0bc8bb535 100644
> --- a/src/backend/access/index/indexam.c
> +++ b/src/backend/access/index/indexam.c
> @@ -24,9 +24,7 @@
>   *		index_parallelscan_initialize - initialize parallel scan
>   *		index_parallelrescan  - (re)start a parallel scan of an index
>   *		index_beginscan_parallel - join parallel index scan
> - *		index_getnext_tid	- get the next TID from a scan
> - *		index_fetch_heap		- get the scan's next heap tuple
> - *		index_getnext_slot	- get the next tuple from a scan
> + *		index_getnext_tid	- amgettuple table AM helper routine
>   *		index_getbitmap - get all tuples from a scan
>   *		index_bulk_delete	- bulk deletion of index tuples
>   *		index_vacuum_cleanup	- post-deletion cleanup of an index
> @@ -105,9 +103,16 @@ do { \
>  			 CppAsString(pname), RelationGetRelationName(scan->indexRelation)); \
>  } while(0)
>
> -static IndexScanDesc index_beginscan_internal(Relation indexRelation,
> -											  int nkeys, int norderbys, Snapshot snapshot,
> -											  ParallelIndexScanDesc pscan, bool temp_snap);
> +static pg_attribute_always_inline IndexScanDesc index_beginscan_internal(Relation indexRelation,
> +																		 Relation heapRelation,
> +																		 int nkeys,
> +																		 int norderbys,
> +																		 Snapshot snapshot,
> +																		 ParallelIndexScanDesc pscan,
> +																		 IndexScanInstrumentation *instrument,
> +																		 bool index_only_scan,
> +																		 bool temp_snap,
> +																		 uint32 flags);
>  static inline void validate_relation_as_index(Relation r);

Pretty sure it'd be enough to put the pg_attribute_always_inline in the
function definition.  Or you just move the definition of
index_beginscan_internal() to before index_beginscan(), and you don't need the
prototype anymore.  Which imo is nicer, because you don't need to update as
many places anymore when changing the signature.

> -			/*
> -			 * Only MVCC snapshots are supported here, so there should be no
> -			 * need to keep following the HOT chain once a visible entry has
> -			 * been found.  If we did want to allow that, we'd need to keep
> -			 * more state to remember not to call index_getnext_tid next time.
> -			 */
> -			if (scandesc->xs_heap_continue)
> -				elog(ERROR, "non-MVCC snapshots are not supported in index-only scans");

So that's now only an assertion, right? Probably ok...

> @@ -792,15 +702,14 @@ ExecIndexOnlyScanInitializeDSM(IndexOnlyScanState *node,
>
>  	node->ioss_ScanDesc =
>  		index_beginscan_parallel(node->ss.ss_currentRelation,
> -								 node->ioss_RelationDesc,
> +								 node->ioss_RelationDesc, true,
>  								 node->ioss_Instrument,
>  								 node->ioss_NumScanKeys,
>  								 node->ioss_NumOrderByKeys,
>  								 piscan,
>  								 ScanRelIsReadOnly(&node->ss) ?
>  								 SO_HINT_REL_READ_ONLY : SO_NONE);
> -	node->ioss_ScanDesc->xs_want_itup = true;
> -	node->ioss_VMBuffer = InvalidBuffer;
> +	Assert(node->ioss_ScanDesc->xs_want_itup);
>
>  	/*
>  	 * If no run-time keys to calculate or they are ready, go ahead and pass
> @@ -860,14 +769,14 @@ ExecIndexOnlyScanInitializeWorker(IndexOnlyScanState *node,
>
>  	node->ioss_ScanDesc =
>  		index_beginscan_parallel(node->ss.ss_currentRelation,
> -								 node->ioss_RelationDesc,
> +								 node->ioss_RelationDesc, true,
>  								 node->ioss_Instrument,
>  								 node->ioss_NumScanKeys,
>  								 node->ioss_NumOrderByKeys,
>  								 piscan,
>  								 ScanRelIsReadOnly(&node->ss) ?
>  								 SO_HINT_REL_READ_ONLY : SO_NONE);
> -	node->ioss_ScanDesc->xs_want_itup = true;
> +	Assert(node->ioss_ScanDesc->xs_want_itup);
>
>  	/*
>  	 * If no run-time keys to calculate or they are ready, go ahead and pass
> diff --git a/src/backend/executor/nodeIndexscan.c b/src/backend/executor/nodeIndexscan.c
> index 1620d1460..55fa2d421 100644
> --- a/src/backend/executor/nodeIndexscan.c
> +++ b/src/backend/executor/nodeIndexscan.c
> @@ -109,7 +109,7 @@ IndexNext(IndexScanState *node)
>  		 * serially executing an index scan that was planned to be parallel.
>  		 */
>  		scandesc = index_beginscan(node->ss.ss_currentRelation,
> -								   node->iss_RelationDesc,
> +								   node->iss_RelationDesc, false,
>  								   estate->es_snapshot,
>  								   node->iss_Instrument,
>  								   node->iss_NumScanKeys,
> @@ -132,7 +132,7 @@ IndexNext(IndexScanState *node)
>  	/*
>  	 * ok, now that we have what we need, fetch the next tuple.
>  	 */
> -	while (index_getnext_slot(scandesc, direction, slot))
> +	while (table_index_getnext_slot(scandesc, direction, slot))
>  	{
>  		CHECK_FOR_INTERRUPTS();
>
> @@ -207,7 +207,7 @@ IndexNextWithReorder(IndexScanState *node)
>  		 * serially executing an index scan that was planned to be parallel.
>  		 */
>  		scandesc = index_beginscan(node->ss.ss_currentRelation,
> -								   node->iss_RelationDesc,
> +								   node->iss_RelationDesc, false,
>  								   estate->es_snapshot,
>  								   node->iss_Instrument,
>  								   node->iss_NumScanKeys,
> @@ -266,7 +266,7 @@ IndexNextWithReorder(IndexScanState *node)
>  		 * Fetch next tuple from the index.
>  		 */
>  next_indextuple:
> -		if (!index_getnext_slot(scandesc, ForwardScanDirection, slot))
> +		if (!table_index_getnext_slot(scandesc, ForwardScanDirection, slot))
>  		{
>  			/*
>  			 * No more tuples from the index.  But we still need to drain any
> @@ -818,6 +818,7 @@ ExecEndIndexScan(IndexScanState *node)
>  		 * which will have a new IndexOnlyScanState and zeroed stats.
>  		 */
>  		winstrument->nsearches += node->iss_Instrument->nsearches;
> +		Assert(node->iss_Instrument->ntabletuplefetches == 0);
>  	}
>
>  	/*
> @@ -1730,7 +1731,7 @@ ExecIndexScanInitializeDSM(IndexScanState *node,
>
>  	node->iss_ScanDesc =
>  		index_beginscan_parallel(node->ss.ss_currentRelation,
> -								 node->iss_RelationDesc,
> +								 node->iss_RelationDesc, false,
>  								 node->iss_Instrument,
>  								 node->iss_NumScanKeys,
>  								 node->iss_NumOrderByKeys,
> @@ -1796,7 +1797,7 @@ ExecIndexScanInitializeWorker(IndexScanState *node,
>
>  	node->iss_ScanDesc =
>  		index_beginscan_parallel(node->ss.ss_currentRelation,
> -								 node->iss_RelationDesc,
> +								 node->iss_RelationDesc, false,
>  								 node->iss_Instrument,
>  								 node->iss_NumScanKeys,
>  								 node->iss_NumOrderByKeys,

Given that all the index_beginscan* callers used onlly one argument per line,
I'd also do that for the ios=false.

> From 491b65ca7ddf3829ae217aa5eac7137e76d9a6eb Mon Sep 17 00:00:00 2001
> Subject: [PATCH v21 04/15] heapam: Track heap block in IndexFetchHeapData.
>
> This is preparatory work for an upcoming commit that will add index
> prefetching using a read stream.  Delegating the release of a currently
> pinned buffer to ReleaseAndReadBuffer won't work anymore -- at least not
> when the next buffer that the scan needs to pin is one returned by
> read_stream_next_buffer (not a buffer returned by ReadBuffer).
>

LGTM.

> From 00244c1729f697ae72d77acfc2fcbc21d0c7236d Mon Sep 17 00:00:00 2001
> Date: Thu, 26 Mar 2026 18:45:27 -0400
> Subject: [PATCH v21 05/15] heapam: Keep buffer pins across index rescans.
>
> Note that not dropping xs_vmbuffer on a rescan isn't a new behavior
> (it's always worked this way).  Recent commit XXX, which added a new
> slot-based interface, changed that behavior when it moved VM pin
> management out of the core executor.  This commit restores that behavior
> (and has heapam treat heap page pins in the same way, which _is_ a new
> behavior).
>

LGTM.

> index b7305f822..fd6f4fa9e 100644
> @@ -73,18 +73,14 @@ heapam_index_fetch_reset(IndexScanDesc scan)
>  {
>  	IndexFetchHeapData *hscan = (IndexFetchHeapData *) scan->xs_heapfetch;
>
> +	/* Resets are a no-op */
> +	(void) hscan;
>
> -	if (BufferIsValid(hscan->xs_vmbuffer))
> -	{
> -		ReleaseBuffer(hscan->xs_vmbuffer);
> -		hscan->xs_vmbuffer = InvalidBuffer;
> -	}
> +	/*
> +	 * Deliberately avoid dropping pins now held in xs_cbuf and xs_vmbuffer.
> +	 * This saves cycles during certain tight nested loop joins (it can avoid
> +	 * repeated pinning and unpinning of the same buffer across rescans).
> +	 */
>  }
>
>  void
> @@ -92,7 +88,13 @@ heapam_index_fetch_end(IndexScanDesc scan)
>  {
>  	IndexFetchHeapData *hscan = (IndexFetchHeapData *) scan->xs_heapfetch;
>
> -	heapam_index_fetch_reset(scan);
> +	/* drop pin if there's a pinned heap page */
> +	/* drop pin if there's a pinned visibility map page */
> +	if (BufferIsValid(hscan->xs_vmbuffer))
> +		ReleaseBuffer(hscan->xs_vmbuffer);
>
>  	pfree(hscan);
>  }

Personally I'd keep the clearing of the Buffer fields after the release, but I
do admit that's rank paranoia.

> From aaffcbba0ec9a52c050ab6ae332e31e069541531 Mon Sep 17 00:00:00 2001
> Date: Wed, 25 Mar 2026 16:48:43 -0400
> Subject: [PATCH v21 06/15] Add interfaces that enable index prefetching.
>
> to implement plain index scans and index-only scans that return index
> entries in batches comprising all matching items from an index page,
> rather than one match at a time.  Also switch nbtree over from
> amgettuple to the new amgetbatch interface.
>
> This expands the slot-based table AM interface added by commit FIXME
> from two callbacks (for amgettuple plain and index-only scans) to four,
> adding amgetbatch variants for both scan types.  The amgetbatch
> interface is tightly coupled with this approach to index scans: the
> table AM can apply knowledge of which TIDs will be returned to the scan
> in the near future to perform I/O prefetching.  Prefetching will be
> added by an upcoming commit.

I think this paragraph is a bit out of date.

> With amgetbatch, a scan-level policy determines whether each batch's
> index page buffer pin is dropped eagerly by the index AM (for plain
> scans with an MVCC snapshot, where the snapshot itself prevents TID
> recycling problems) or retained as an interlock against concurrent TID
> recycling by VACUUM.  The interlock is retained for non-MVCC scans and

s/non-MVCC/plain non-MVCC/

> for index-only scans, and is dropped by the table AM via the new
> amunguardbatch callback when it is safe to do so. (Actually, index AMs
> are usually able to drop the pin at the same time that they release the
> lock.  In practice, the amunguardbatch callback is only really needed
> during index-only scans, where dropping the pin interlock might need to
> be delayed ever so slightly, as explained below.)
>
> amgetbatch interface (LP_DEAD marking of index entries must be performed

It's a bit funny to say that it expands it to all index AMs that support the
new amgetbatch, given that that's just nbtree as of this commit :)

> @@ -129,9 +129,39 @@ typedef struct IndexFetchHeapData
>  	BlockNumber xs_blk;
>
>  	/* For visibility map checks (index-only scans and on-access pruning) */
> -	Buffer		xs_vmbuffer;

Probably unintentional trailing empty line in the struct.

> +/*
> + * Access the heap-private fixed-size data from the beginning of an allocated
> + * IndexScanBatch, using caller's IndexScanBatch pointer
> + */
> +static inline HeapBatchData *
> +heap_batch_data(IndexScanDesc scan, IndexScanBatch batch)
> +{
> +	/* heapam's fixed-size space is at the start of the palloc'd area */
> +	return (HeapBatchData *) index_scan_batch_base(scan, batch);
> +}

I don't think the table AM implementation should need to know about the
allocation base of a batch and similar implementation details.

And index_scan_batch_base()'s comment are now a bit too limited:

/*
 * Return the true allocation base of a batch (used to pfree batches)
 */
static inline void *
index_scan_batch_base(IndexScanDescData *scan, IndexScanBatch batch)
{
	return (char *) batch - scan->batch_table_offset;
}

heap_batch_data() and HeapBatchData also seems like a bit too generic names,
there are batches in other parts of heapam.

Hm. Does all of this actually need to live in heapam.h? Seems like it could
just be in heapam_indexscan.c.

> +	if (batch)
> +	{
> +		/* We got the batch from the index AM */
> +		Assert(batch->dir == direction);
> +		Assert(batch->firstItem <= batch->lastItem);
> +
> +		/* Append batch to the end of ring buffer/write it to buffer index */
> +		index_scan_batch_append(scan, batch);
> +
> +		/*
> +		 * Theoretically we should set knownEndForward/knownEndBackward to
> +		 * false (whichever is used when moving in the opposite direction)
> +		 * when this is the scan's first returned batch.  We don't bother
> +		 * because the index AM should always record that fact in its own
> +		 * opaque area.  (These fields only exist because we don't want index
> +		 * AMs setting _any_ field from any priorbatch that we pass to them.
> +		 * Besides, it would be cumbersome for index AMs to keep track of
> +		 * which batch is the current amgetbatch call's original priorbatch.)
> +		 */

Why aren't we setting them to false?

> +	}
> +	else
> +	{
> +		/* amgetbatch returned NULL */
> +		if (priorBatch)
> +		{
> +			/*
> +			 * There are no further matches to be found in the current scan
> +			 * direction, following priorBatch.  Remember that priorBatch is
> +			 * the last batch with matching items.
> +			 */
> +				priorBatch->knownEndForward = true;
> +			else
> +				priorBatch->knownEndBackward = true;
> +		}
> +	}
> +
> +	/* xs_hitup isn't currently supported by amgetbatch scans */
> +	Assert(!scan->xs_hitup);
> +
> +	return batch;
> +}
> +
> +/*
> + * utilities called by index AMs
> + */

I'd make these section comments a bit more prominent (like e.g. in tableam.h,
otherwise they're quite hard to distinguish from function comments.

> + *   [table AM opaque area]    <- fixed-size, -(batch_table_offset) from base
> + *   [index AM opaque area]    <- at -(batch_index_opaque_size) from base
> + *   [IndexScanBatchData]      <- base pointer, returned by amgetbatch

This conflicts a bit with how e.g. index_scan_batch_base() uses base pointer.

> +StaticAssertDecl(INDEX_SCAN_MAX_BATCHES <= PG_UINT8_MAX + 1,
> +				 "INDEX_SCAN_MAX_BATCHES must fit in uint8 ring buffer indexes");

I'd just use a < without the + 1 on the other side :)

> @@ -2555,8 +2555,11 @@ static const TableAmRoutine heapam_methods = {
>  	.parallelscan_reinitialize = table_block_parallelscan_reinitialize,
>
> +	.index_fetch_batch_init = heapam_index_fetch_batch_init,
> +	.index_fetch_markpos = heapam_index_fetch_markpos,
> +	.index_fetch_restrpos = heapam_index_fetch_restrpos,

Given that the ->index_fetch_begin() now is responsible for configuring
IndexScanDesc->xs_getnext_slot, it's not clear to me that it really makes
sense to have stuff like ->index_fetch_batch_init() be set in TableAmRoutine.

Wonder if we should just have a TableIndexFetchRoutine struct embedded in the
IndexScanDesc that ->index_fetch_begin needs to fully initialize.

> @@ -38,11 +45,28 @@ static pg_attribute_always_inline bool heapam_index_fetch_tuple_impl(Relation re
>  static pg_attribute_always_inline bool heapam_index_getnext_slot(IndexScanDesc scan,
>  																 ScanDirection direction,
>  																 TupleTableSlot *slot,
> -																 bool index_only);
> +																 bool index_only,
> +																 bool amgetbatch);
>  static pg_attribute_always_inline bool heapam_index_fetch_heap(IndexScanDesc scan,
>  															   IndexFetchHeapData *hscan,
>  															   TupleTableSlot *slot,
> -															   bool *heap_continue);
> +															   bool *heap_continue,
> +															   bool amgetbatch);
> +static pg_attribute_always_inline ItemPointer heapam_index_getnext_scanbatch_pos(IndexScanDesc scan,
> +																				 IndexFetchHeapData *hscan,
> +																				 bool *all_visible);
> +static inline ItemPointer heapam_index_return_scanpos_tid(IndexScanDesc scan,
> +														  IndexFetchHeapData *hscan,
> +														  IndexScanBatch scanBatch,
> +														  BatchRingItemPos *scanPos,
> +														  bool *all_visible);
> +static void heapam_index_batch_pos_visibility(IndexScanDesc scan,
> +											  IndexScanBatch batch,
> +											  HeapBatchData *hbatch,
> +											  BatchRingItemPos *pos);

See earlier comments about ugliness of the super deep indentation due to
pg_attribute_always_inline and that getting rid of several of the prototypes
might be the best answer.

> +/*
> + * Restore batch ring buffer's markPos into its scanPos
> + */
> +void
> +heapam_index_fetch_restrpos(IndexScanDesc scan)
> +{
> +	Assert(scan->usebatchring);
> +
> +	(void) hscan;
> +
> +	tableam_util_batchscan_restore_pos(scan);
> +}

What's the point of fetching hscan and then using a (void) hscon to avoid a
warning?

> +/*
> + * Get next TID from batch ring buffer, moving in the given scan direction.
> + * Also sets *all_visible for item when caller passes a non-NULL arg.
> + */
> +static pg_attribute_always_inline ItemPointer
> +heapam_index_getnext_scanbatch_pos(IndexScanDesc scan, IndexFetchHeapData *hscan,
> +	bool		hadExistingScanBatch;
> +
> +	hadExistingScanBatch = scanPos->valid;
> +	if (scanPos->valid)
> +	{
> +		/*
> +		pg_assume(batchringbuf->headBatch == scanPos->batch);
> +
> +			return heapam_index_return_scanpos_tid(scan, hscan, direction,
> +												   scanBatch, scanPos,
> +												   all_visible);
> +	}
> +
> +	/*
> +	scanBatch = tableam_util_fetch_next_batch(scan, direction,
> +											  scanBatch, scanPos);
> +
> +	if (!scanBatch)
> +	{
> +		/*
> +	if (hadExistingScanBatch)
> +	{
> +		Assert(headBatch != scanBatch);
> +		Assert(batchringbuf->headBatch != scanPos->batch);
> +
> +		/* Remove the batch from the ring buffer (even if it's markBatch) */
> +	return heapam_index_return_scanpos_tid(scan, hscan, direction,
> +										   scanBatch, scanPos, all_visible);
> +}

Still looks like most of this ought to be in a tableam_util_...

> +/*
> + * Save the current scanPos/scanBatch item's TID in scan's xs_heaptid, and
> + * return a pointer to that TID.  When all_visible isn't NULL (during an
> + * index-only scan), also sets item's visibility status in *all_visible.
> + *
> + * heapam_index_getnext_scanbatch_pos helper function.
> + */
> +static inline ItemPointer
> +heapam_index_return_scanpos_tid(IndexScanDesc scan, IndexFetchHeapData *hscan,
> +								IndexScanBatch scanBatch,
> +								BatchRingItemPos *scanPos,
> +								bool *all_visible)
> +{
> +	HeapBatchData *hbatch;
> +
> +	pgstat_count_index_tuples(scan->indexRelation, 1);

The asymmetry of where pgstat_count_index_tuples() is called (i.e. here for
amgetbatch(), in index_getnext_tid() for amgettuple) still seems weird to me.

> +	 * Note: visibilitymap_get_status does not lock the visibility map buffer,
> +	 * so the result could be slightly stale.  See the "Memory ordering
> +	 * effects" discussion above visibilitymap_get_status for an explanation
> +	 * of why this is okay.
> +	 */
> +		int			lastSetItem = Min(batch->lastItem,
> +									  posItem + hscan->xs_vm_items - 1);
> +
> +		for (int setItem = posItem; setItem <= lastSetItem; setItem++)
> +		{
> +			ItemPointer tid = &batch->items[setItem].tableTid;
> +			BlockNumber heapblkno = ItemPointerGetBlockNumber(tid);
> +			uint8		flags;
> +
> +			if (heapblkno == curvmheapblkno)
> +			{
> +				hbatch->visInfo[setItem] = curvmheapblkflags;
> +				continue;
> +			}
> +
> +			flags = HEAP_BATCH_VIS_CHECKED;
> +			if (VM_ALL_VISIBLE(scan->heapRelation, heapblkno, &hscan->xs_vmbuffer))
> +				flags |= HEAP_BATCH_VIS_ALL_VISIBLE;
> +
> +			hbatch->visInfo[setItem] = curvmheapblkflags = flags;
> +			curvmheapblkno = heapblkno;
> +		}
> +
> +		allbatchitemsvisible = lastSetItem >= batch->lastItem &&
> +		int			lastSetItem = Max(batch->firstItem,
> +									  posItem - hscan->xs_vm_items + 1);
> +
> +		for (int setItem = posItem; setItem >= lastSetItem; setItem--)
> +		{
> +			ItemPointer tid = &batch->items[setItem].tableTid;
> +			BlockNumber heapblkno = ItemPointerGetBlockNumber(tid);
> +			uint8		flags;
> +
> +			if (heapblkno == curvmheapblkno)
> +			{
> +				hbatch->visInfo[setItem] = curvmheapblkflags;
> +				continue;
> +			}
> +
> +			flags = HEAP_BATCH_VIS_CHECKED;
> +			if (VM_ALL_VISIBLE(scan->heapRelation, heapblkno, &hscan->xs_vmbuffer))
> +				flags |= HEAP_BATCH_VIS_ALL_VISIBLE;
> +
> +			hbatch->visInfo[setItem] = curvmheapblkflags = flags;
> +			curvmheapblkno = heapblkno;
> +		}
> +
> +		allbatchitemsvisible = lastSetItem <= batch->firstItem &&

Might be worth putting into a static inline.

> +	/*
> +	 * It's safe to unguard the batch (via amunguardbatch) as soon as we've
> +	 * resolved the visibility status of all of its items (unless this is a
> +	 * non-MVCC scan)
> +	 */
> +	if (allbatchitemsvisible)
> +	{
> +		Assert(hbatch->visInfo[batch->firstItem] & HEAP_BATCH_VIS_CHECKED);
> +		Assert(hbatch->visInfo[batch->lastItem] & HEAP_BATCH_VIS_CHECKED);
> +
> +		/*
> +		 * Note: nodeIndexonlyscan.c only supports MVCC snapshots, but we
> +		 * still cope with index-only scan callers with other snapshot types.
> +		 * This is certainly not unexpected; selfuncs.c performs index-only
> +		 * scans that use SnapshotNonVacuumable.
> +		 */
> +		if (scan->MVCCScan)
> +			tableam_util_unguard_batch(scan, batch);
> +	}
> +
> +	/*
> +	 * Else check visibility for twice as many items next time, or all items.
> +	 * We check all items in one go once we're passed the scan's first batch.
> +	 */
> +	else if (hscan->xs_vm_items < (batch->lastItem - batch->firstItem))
> +		hscan->xs_vm_items *= 2;
> +	else

Can'tt the *2 lead to computing a xs_vm_items bigger than scan->maxitemsbatch?

> +
> +/*
> + * Free resources at end of a batch index scan.
> + *
> + * Called by table AM when an index scan is ending, right before the owning
> + * scan descriptor goes away.  Cleans up all batch related resources.
> + */
> +void
> +tableam_util_batchscan_end(IndexScanDesc scan)
> +{
> +	/* Free all remaining loaded batches (even markBatch), bypassing cache */
> +	tableam_util_batchscan_reset(scan, true);
> +
> +	for (int i = 0; i < INDEX_SCAN_CACHE_BATCHES; i++)
> +	{
> +		IndexScanBatch cached = scan->batchcache[i];
> +
> +		if (cached == NULL)
> +			continue;
> +
> +		if (cached->deadItems)
> +			pfree(cached->deadItems);
> +		pfree(index_scan_batch_base(scan, cached));

I'd set scan->batchcache[i] to NULL after freeing.

> +tableam_util_batchscan_mark_pos(IndexScanDesc scan)
> +{
> +	BatchRingItemPos *scanPos = &scan->batchringbuf.scanPos;
> +	BatchRingItemPos *markPos = &batchringbuf->markPos;
> +	IndexScanBatch scanBatch = index_scan_batch(scan, scanPos->batch);
> +	IndexScanBatch markBatch = batchringbuf->markBatch;
> +	bool		freeMarkBatch;
> +
> +	 * Free the previous mark batch (if any) -- but only if it isn't our
> +	 * scanBatch (defensively make sure that markBatch isn't some later
> +	 * still-needed batch, too)
> +	 */
> +	if (!markBatch || markBatch == scanBatch)
> +	{
> +		/* Definitely no markBatch that we should free now */
> +		freeMarkBatch = false;
> +	}
> +	else if (likely(!index_scan_batch_loaded(scan, markPos->batch)))
> +	{
> +		/* Definitely have a no-longer-loaded markBatch to free */
> +		freeMarkBatch = true;
> +	}
> +	else
> +	{
> +		/*
> +		 * index_scan_batch_loaded indicates that markPos->batch is loaded,
> +		 * but after uint8 overflow a stale batch offset can alias a

Maybe s/overflow/wraparound/?

> +		 * currently-loaded range (false positive).  Confirm by checking
> +		 * whether the batch pointer in markPos->batch's slot still matches.
> +		 */
> +		freeMarkBatch = (index_scan_batch(scan, markPos->batch) != markBatch);

Perhaps this should be be integrated into index_scan_batch_loaded()?

> +/*
> + * Restore mark to scanPos position

This sounds a bit like the mark is restored into the position of the scan pos,
which obviously makes no sense.

> +/*
> + * Free a batch, optionally caching it for reuse.
> + *
> + * tableam_util_free_batch implementation function.  We split out the
> + * implementation like this because we don't want to give external table AM
> + * callers the option of passing allow_cache=false.
> + *
> + * When allow_cache is true, we try to store the batch in the scan's batch
> + * cache for later reuse.  When allow_cache is false (typically because the
> + * scan is shutting down), we pfree the caller's batch unconditionally.
> + */
> +static void
> +batch_free(IndexScanDesc scan, IndexScanBatch batch, bool allow_cache)
> +{
> +	Assert(!(scan->batchImmediateUnguard && batch->isGuarded));
> +	Assert(batch->isGuarded || scan->MVCCScan);
> +
> +	/* don't free caller's batch if it is scan's current markBatch */
> +	if (batch == scan->batchringbuf.markBatch)
> +		return;
> +
> +	/* Drop TID recycling interlock via amunguardbatch as needed */
> +	if (!scan->batchImmediateUnguard && batch->isGuarded)
> +		tableam_util_unguard_batch(scan, batch);
> +
> +	/*
> +	 * Let the index AM set LP_DEAD bits in the index page, if applicable.
> +	 *
> +	 * batch.deadItems[] is now in whatever order the scan returned items in.
> +	 * We might have even saved the same item/TID twice.
> +	 *
> +	 * Sort and unique-ify deadItems[].  That way the index AM can safely
> +	 * assume that items will always be in their original index page order.
> +	 */
> +	if (batch->numDead > 0 &&
> +		scan->indexRelation->rd_indam->amkillitemsbatch != NULL)
> +	{
> +		if (batch->numDead > 1)
> +		{
> +			qsort(batch->deadItems, batch->numDead, sizeof(int),
> +				  batch_compare_int);
> +			batch->numDead = qunique(batch->deadItems, batch->numDead,
> +									 sizeof(int), batch_compare_int);
> +		}
> +
> +		scan->indexRelation->rd_indam->amkillitemsbatch(scan, batch);
> +	}

Based on the name or function header one sure wouldn't think this will call
indexam callbacks...

>  /* ----------------
>   *      index_restrpos  - restore a scan position
>   *
> - * NOTE: this only restores the internal scan state of the index AM.  See
> + * NOTE: this only restores the batch positional state of the table AM.  See
>   * comments for ExecRestrPos().
>   *
>   * NOTE: For heap, in the presence of HOT chains, mark/restore only works
>   * correctly if the scan's snapshot is MVCC-safe; that ensures that there's at
>   * most one returnable tuple in each HOT chain, and so restoring the prior
> - * state at the granularity of the index AM is sufficient.  Since the only
> - * current user of mark/restore functionality is nodeMergejoin.c, this
> - * effectively means that merge-join plans only work for MVCC snapshots.  This
> - * could be fixed if necessary, but for now it seems unimportant.
> + * state at the scan item granularity is sufficient.  Since the only current
> + * user of mark/restore functionality is nodeMergejoin.c, this effectively
> + * means that merge-join plans only work for MVCC snapshots.  This could be
> + * fixed if necessary, but for now it seems unimportant.
>   * ----------------
>   */
>  void
> @@ -459,16 +480,11 @@ index_restrpos(IndexScanDesc scan)
>      Assert(IsMVCCLikeSnapshot(scan->xs_snapshot));
>
>      SCAN_CHECKS;
> -    CHECK_SCAN_PROCEDURE(amrestrpos);
> +    CHECK_SCAN_PROCEDURE(amgetbatch);
>
> -    /* reset table AM state for restoring the marked position */
> -    if (scan->xs_heapfetch)
> -        table_index_fetch_reset(scan);
> -
> -    scan->kill_prior_tuple = false; /* for safety */
>      scan->xs_heap_continue = false;
>
> -    scan->indexRelation->rd_indam->amrestrpos(scan);
> +    table_index_fetch_restrpos(scan);
>  }

Did we loose that kill_prior_tuple? Or is there a reason it's not necessary
anymore?  Too tired to think through this.

Greetings,

Andres Freund

---

From: Andres Freund <[email-masked]>
Date: Sat, 4 Apr 2026 01:33:32 -0400

Hi,

> I'd just use a < without the + 1 on the other side :)

Actually, I think there are actual issues around this. Once you set
INDEX_SCAN_MAX_BATCHES to 256, there are a lot of, justified looking,
warnings.

[1/1 1 100%] Compiling C object src/backend/postgres_lib.a.p/access_heap_heapam_handler.c.o
In file included from ../../../../../home/andres/src/postgresql/src/include/access/heapam.h:19,
                 from ../../../../../home/andres/src/postgresql/src/backend/access/heap/heapam_handler.c:23:
../../../../../home/andres/src/postgresql/src/include/access/relscan.h: In function 'index_scan_batch_full':
../../../../../home/andres/src/postgresql/src/include/access/relscan.h:488:45: warning: comparison is always false due to limited range of data type [-Wtype-limits]
  488 |         return index_scan_batch_count(scan) == INDEX_SCAN_MAX_BATCHES;
      |                                             ^~

I'm also not sure this is actually quite right

/*
 * Do we already have a batch loaded at 'idx' offset in scan's ring buffer?
 *
 * NOTE: a stale batch idx can alias a currently-loaded range after uint8
 * overflow, producing a false positive.  False negatives are not possible.
 */
static inline bool
index_scan_batch_loaded(IndexScanDescData *scan, uint8 idx)
{
	return (int8) (idx - scan->batchringbuf.headBatch) >= 0 &&
		(int8) (idx - scan->batchringbuf.nextBatch) < 0;
}

once the differences between the values don't fit into an int8 anymore. But
I'm tired.

Greetings,

Andres Freund

---

From: Peter Geoghegan <[email-masked]>
Date: Sat, 4 Apr 2026 19:35:28 -0400

On Sat, Apr 4, 2026 at 1:33 AM Andres Freund <[email-masked]> wrote:
> once the differences between the values don't fit into an int8 anymore.

The static assertion is incorrect (or not strict enough). It didn't
account for this int8 arithmetic. That'll be fixed in the next
version; the true maximum number of batches is 128 (still more than
anybody is likely to ever need).

> But I'm tired.

Same.

-- 
Peter Geoghegan

---

From: Peter Geoghegan <[email-masked]>
Date: Sun, 5 Apr 2026 01:37:02 -0400

Attached is v23, which addresses your (Andres') most recent review
feedback. I respond to the points that you brought up in your review
below.

Andres just pushed numerous related read stream patches (thanks for
your work on that!), so I'm down to only 8 patches. This includes a
new version of "Allow read_stream_reset() to not wait for IO
completion" (which uses a less "hacky" approach than previous versions
of that patch).

Note that I reordered the patches for v23, to put the more committable
ones first. I already committed 4 of the v22 patches (that I myself
authored) that were deemed ready earlier today.

The patches I'm posting now are also in a slightly different order to
the one used by prior revisions: they put the hash index commit
earlier. I think it makes sense to commit that the hash index patch
immediately after the main amgetbatch commit gets in. Prefetching
itself depends on at least one moew pending patch from you, so that
might well need to go in last.

> Any reason to hold off pushing these?

Nope, no reason. Pushed those 2, plus the "Track heap block in
IndexFetchHeapData" and "Keep buffer pins" patches.

> more expensive than just doing so internally in a non-generic datastructure.

I changed the wording from "needs" to "uses". Was that all you meant?

It's unclear if you're asking (or at least suggesting) that I should
invent a new table_index_getnext_slot-like shim, used only during
index-only scans, that doesn't require passing a slot (but does
require getting a table slot some other way, likely at scan startup).
I can see the argument for that. I've only avoided it because it would
require adding more mechanism to the generic index_beginscan path
(though I guess we could invent a new index_beginscan_indexonly
similar to index_beginscan_bitmap).

Cool.

Fixed.

> IndexScanDesc with the IndexFetchTableData, without again needing to change

Hmmm. So index_fetch_state_size is a field in the TableAmRoutine
struct, that is set statically by the table AM?

Fixed.

Fixed.

Fixed.

> This is quite a spectacularly ugly indentation.  Not your fault.  Still can't

I was torn between two different neatnik impulses: the desire to have
consistent function prototypes (that all appear in the same order as
their definitions!), versus the desire for acceptable indentation
without any ludicrously long lines.

> forward declaration for the pg_attribute_always_inline functions.

Okay, sure. I've done it that way; reordered things such that I was
able to remove function prototypes with pg_attribute_always_inline
only.

> are basically part of the implementation of heapam_index_*.

I don't follow.

Note that I renamed heapam_index_fetch_tuple to
heapam_index_fetch_tuple and renamed heapam_index_fetch_heap to
heapam_index_fetch_heap_item in the attached revision. I think that
those names are slightly clearer. The latter function is just a
wrapper around the former that performs some extra steps that are
secondary to fetching a heap tuple.

> Or you just move the definition of

Fixed it that way, much like with heapam_indexscan.c prototypes.

> So that's now only an assertion, right? Probably ok...

Yes. But the new assertion is *less* strict than this defensive elog
was: it tolerates heap_continue being set iff a non-MVCC snapshot is
used.

It has to work that way now, I think. The new index-only scan code
cannot assume that a non-MVCC snapshot will be used in all cases -- we
have the selfuncs.c caller to consider (which uses
SnapshotNonVacuumable) now. Obviously that never affected the old
nodeIndexonlyScan.c code quoted here.

Actually, I don't know if we really *should* treat the selfuncs.c
caller any different (except as needed to avoid spurious assertion
failures). The existing selfuncs.c handling assumes that the first
heap tuple returned from a HOT chain is always an acceptable tuple to
return to the scan. That is, it doesn't care about later HOT chain
members.

> I'd also do that for the ios=false.

I fixed the affected index_beginscan* callers, by following your
suggested approach.

> do admit that's rank paranoia.

The committed patch didn't do that. I just don't see a point here,
given that we're directly pfree'ing the memory that you're suggested
need to be reset.

Besides, if I did this, wouldn't I also have to clear xs_blk by
resetting it to InvalidBlockNumber? That's what it would take to make
it safe if we weren't just pfree'ing the struct right away.

> > adding amgetbatch variants for both scan types.

Fixed.

Fixed.

> new amgetbatch, given that that's just nbtree as of this commit :)

Plan now is to commit the hash index patch immediately after this one.
Maybe that makes it clearer?

Fixed.

> allocation base of a batch and similar implementation details.

But they have to, if only to be able to implement their own
heapam_index_fetch_batch_init-like function.

> just be in heapam_indexscan.c.

Agreed. Fixed.

> Why aren't we setting them to false?

It offers no practical benefit (no query can possibly read extra index
pages as a result of the omission), and I'm paranoid (perhaps
unreasonably so) about adding cycles here. Especially with pgbench
SELECT style point queries.

Fixed.

> This conflicts a bit with how e.g. index_scan_batch_base() uses base pointer.

Good point. Fixed in latest version, by using "allocation base"
terminology in these comments.

> IndexScanDesc that ->index_fetch_begin needs to fully initialize.

I'm glad that you came around to my point of view on it being a good
idea to allow the table AM to do more in ->index_fetch_begin.
Specifically, allowing that callback to pick its preferred slot makes
sense. But I don't think the same argument I made works for these
other new callbacks, like ->index_fetch_batch_init?

Or were you making another argument?

> What's the point of fetching hscan and then using a (void) hscon to avoid a

Fixed in committed patch.

Why?

Code reuse is a virtue, but it isn't the highest virtue. Splitting up
this function is possible but quite awkward.

> amgetbatch(), in index_getnext_tid() for amgettuple) still seems weird to me.

It's also kinda weird that index_getnext_tid can call
table_index_fetch_reset, even though index_getnext_tid is only really
supposed to be called by table AMs now (and is only called by heapam
in practice).

What do you want to do about it? index_getnext_tid isn't adding too
much right now, I'd say.

> Might be worth putting into a static inline.

Why do you say that? I don't follow.

> Can'tt the *2 lead to computing a xs_vm_items bigger than scan->maxitemsbatch?

Yes, it can.

> I'd set scan->batchcache[i] to NULL after freeing.

I tried that out, but I *think* it regressed pgbench SELECT by about
~1.5% of total TPS (I'd need more thorough testing to confirm this).
Are you sure we need this?

Fixed.

> Perhaps this should be be integrated into index_scan_batch_loaded()?

I doubt that would be practical, because there is an important
difference between a batch being immediately available (meaning it can
read it from memory without calling amgetbatch again) and a batch
actually being in the ring buffer. markBatch is a special case in that
it can be in the former category without also being in the latter one.

Most callers really don't need to think about that distinction, and it
seems actively bad if index_scan_batch_loaded knows about the scan's
saved markBatch in the context of these callers.

Fixed.

> Based on the name or function header one sure wouldn't think this will call

I dunno, how do you think it should work?

To me it makes sense to call amkillitemsbatch here, because when the
table AM frees a batch it clearly has no further need to perform heap
fetches for any items in that batch. The table-AM-focussed function
name seems fine to me because this is one extra step, that happens to
involve calling the index AM.

Bear in mind that the call to amkillitemsbatch only does what the AM
table instructs. It has no real awareness of any ongoing scan. We
always use the LSN trick there now, so it's really not coordinating
with itself/with the table AM.

> anymore?  Too tired to think through this.

We still need kill_prior_tuple, but only for amgettuple scans. Those
don't support mark + restore anymore.

--
Peter Geoghegan

---

From: Andres Freund <[email-masked]>
Date: Sun, 5 Apr 2026 19:01:53 -0400

Hi,

Makes sense.

> I changed the wording from "needs" to "uses". Was that all you meant?

It's better than nothing :)

> require getting a table slot some other way, likely at scan startup).

Why would it require creating a tableam shaped slot internally?  It seems
rather silly to store a tuple fetched to verify visibility into a slot, where
it is never used. Storing in a tuple requires an indirect things like
IncrBufferRefCount(), which isn't that cheap.

heapam_index_getnext_slot(index_only=>1)
-> heapam_index_fetch_heap_item()
-> heapam_index_fetch_tuple()

right now does heap_hot_search_buffer() and then, if that found something,
stores the tuple in the slot.  But there's no reason to do that if the tuple
isn't actually desired by the caller.   So there's afaict simply no need to
have the slot.

Just passing index_only down and skipping storing the tuple in the slot in an
IOS seems to work.  Passing a NULL slot needs a bit more work, but not a whole
lot.

So I guess yes, I am suggesting that we have a separate
table_index_getnext_ios() or such, which is only usable for index only scans.

> similar to index_beginscan_bitmap).

It probably would be cleaner to have index_beginscan_indexonly(), yea.

Not sure why it would need a meaningful amount of new mechanism for beginscan?
I'd expect the assertions for ->xs_want_itup in the table_index_getnext_ios()
wrapper and the opposite in table_index_getnext_slot().

I could see passing an ine shaped slot to table_index_getnext_ios(), which
would improve the layering for nodeIndexonlyscan.c further.  It's a bit gross
that there's this stuff in IndexOnlyNext()

		/*
		 * Fill the scan tuple slot with data from the index.  This might be
		 * provided in either HeapTuple or IndexTuple format.  Conceivably an
		 * index AM might fill both fields, in which case we prefer the heap
		 * format, since it's probably a bit cheaper to fill a slot from.
		 */
		if (scandesc->xs_hitup)
		{
			/*
			 * We don't take the trouble to verify that the provided tuple has
			 * exactly the slot's format, but it seems worth doing a quick
			 * check on the number of fields.
			 */
			Assert(slot->tts_tupleDescriptor->natts ==
				   scandesc->xs_hitupdesc->natts);
			ExecForceStoreHeapTuple(scandesc->xs_hitup, slot, false);
		}
		else if (scandesc->xs_itup)
			StoreIndexTuple(node, slot, scandesc->xs_itup, scandesc->xs_itupdesc);
		else
			elog(ERROR, "no data returned for index-only scan");

and

		/*
		 * We expect that the index will return data in IndexTuple not
		 * HeapTuple format.
		 */
		if (!index_scan->xs_itup)
			elog(ERROR, "no data returned for index-only scan");

in get_actual_variable_endpoint().

But I'd probably defer that part for now.

> struct, that is set statically by the table AM?

Yea.

> nodeIndexonlyScan.c code quoted here.

That seems ok to me, if you somehow call this stuff with a non-mvcc snapshot
without knowing what you're doing, well, that's too bad for you.

> return to the scan. That is, it doesn't care about later HOT chain

I don't think it's worth assuming that that's the code in the core
infrstructure.

> heapam_index_fetch_batch_init-like function.

I don't follow.  Of course it needs to know the size of its own private data,
but it doesn't itself need to know how to get from IndexScanBatch to that.

> other new callbacks, like ->index_fetch_batch_init?

Yea, I guess it's less likely you'd have multiple implementations of that (I
can come up with some cases where you might want to, but I don't think they're
particularly likely to come up)

> Or were you making another argument?

I guess I was just a bit saddened by
indexam_util_batch_alloc()
->table_index_fetch_batch_init()
having to do

	scan->heapRelation->rd_tableam->index_fetch_batch_init(scan, batch, new_alloc);

that's a lot of indirection to go through.

Which is what then made me think of embedding something like a
TableIndexFetchRoutine (with batch_init, reset, markpos, restrpos, end
members) in IndexScanDesc. That way table_index_{fetch_reset,batch_Init,...}()
wouldn't need this level of indirection.

> this function is possible but quite awkward.

Not having the same code in multiple table AMs would make it easier to write
them, which would be nice in itself, but I am more concerned with ending up
with copies of heapam_index_getnext_scanbatch_pos() in various AMs, us finding
problems with heapam_index_getnext_scanbatch_pos() in a minor release, and
having a harder time adjusting things due to the copied code.

> supposed to be called by table AMs now (and is only called by heapam

Hm.

> What do you want to do about it? index_getnext_tid isn't adding too

It does indeed not add a whole lot.

I think we should at least rename it, if there are extensions calling it, they
should probabloy not continue to do so.

I would move it into a tableam_util_fetch_next_tid() (or such) helper, I
think.  That also makes it easier to get rid of the table_index_fetch_reset()
and move the pgstat_count_index_tuples(scan->indexRelation, 1) into one place.

> Why do you say that? I don't follow.

It makes sense to have the forward/backward loops for runtime efficiency, but
it seems just about enough code that it'd be nice to put it into a helper
always function that's called with a constant is_forward=0/1 argument.  Or
perhaps just have the loop body in a helper.

But it's a purely local concern, so we can do that at any later point, so it
really doesn't matter too much right now.

> > Can'tt the *2 lead to computing a xs_vm_items bigger than scan->maxitemsbatch?

But we prevent it from overflow by a Max()/Min() in the loop headers.

> Are you sure we need this?

That seems surprising. But no, I don't think we need this.

> I dunno, how do you think it should work?

It's perhaps ok, I just was a bit surprised to see the callback inside a
function with that name.  I'd perhaps just name it
tableam_util_release_batch() (and rename batch_free accordingly).

> involve calling the index AM.

Yea, I don't have a problem with it being named tableam_*.

> don't support mark + restore anymore.

I'd probably just assert !kill_prior_tuple.

Greetings,

Andres Freund

---

From: Peter Geoghegan <[email-masked]>
Date: Mon, 6 Apr 2026 01:47:43 -0400

> -> heapam_index_fetch_tuple()

The fact is that nodeIndexOnlyscan.c calls index_fetch_heap right now,
on master, which uses a TupleTableSlot.

Yes, using a slot is suboptimal. But it's nothing new. And this is the
first time I'm hearing about this being a problem. A couple of days
before feature freeze.

> table_index_getnext_ios() or such, which is only usable for index only scans.

What if we offered such an interface but still used a slot internally?
That way we'd have the API you're asking for, without any significant
changes to heap_hot_search_buffer. The caller doesn't need to know
about the table slot's usage.

I get that making the caller use a slot interface with a slot that
they don't exactly need is a bit hokey. We can fix that part, without
seriously risking not getting the patch into 19.

> > failures).

> I don't think it's worth assuming that that's the code in the core

To be clear, I agree.

> wouldn't need this level of indirection.

But that's the same level of indirection as before? Except it's once
per batch instead of once per tuple.

> having a harder time adjusting things due to the copied code.

I'll need to study this further.

> and move the pgstat_count_index_tuples(scan->indexRelation, 1) into one place.

I'll put it into indexbatch.h (next to the function that calls
amgetbatch), and make sure that the amgettuple path is consistent in
terms of how it calls pgstat_count_index_tuples(), and how the
fetch/index scan gets reset.

I don't think that we should be resetting here -- even if it's the
historical behavior. I'm absolutely sure that whatever we do shouldn't
vary based on whether amgetbatch or amgettuple was used (that was not
intended).

Right.

> That seems surprising. But no, I don't think we need this.

I'll revisit, might have been wrong.

Will do.

Will do.

-- 
Peter Geoghegan

---

From: Andres Freund <[email-masked]>
Date: Mon, 6 Apr 2026 09:09:01 -0400

Hi,

Peter and I just chatted a bit about the state of the patchset.  Unfortunately
we both think it needs more polish than is doable in the remaining time.

Quite a few people have tried very hard over the last months to get this, and
its dependencies, into a committable shape. The patchset is much better than
it was. The performance is better. Regressions are drastically reduced. The
performance gains are often unbelievably large.

I'd be happy to merge the patchset from the performance POV. I'd be happy to
merge it from a high-level architectural perspective. It's just the pesky
details that need more buffing out than our tired arms can do in the remaining
time.  I think Peter feels similar.

I was kind of wondering if it could make sense to try to push
    "Add slot-based table AM index scan interface."

given just how much better it architecturally is than what we do today. But
that would probably end up with non-trivial tableam changes in consecutive
releases, which doesn't quite seem worth it.

Oh well, PG 20 will be awesome.

Thanks to everyone helping!

Andres

---

From: Peter Geoghegan <[email-masked]>
Date: Mon, 6 Apr 2026 11:39:39 -0400

> time.  I think Peter feels similar.

That's a good summary.

At a high level, the patch has no significant problems. It introduces
virtually no regressions, which required a great deal of effort from
all of us -- that exceeds what I once thought possible. There's a
general sense that the high level architecture is a big improvement,
without any notable flaws.

As you said, it came down to lots of small things. And timing. Many
last minute non-trivial changes to specific aspects of the patch
design seemed necessary. This all happened under enormous time
pressure. And I'm honestly just exhausted right now (I'm likely not
the only one). I just couldn't justify committing such an invasive
patch set under those circumstances.

Attached is v24 -- one last revision ahead of feature freeze, just to
post what I have, before taking some time away from this.

Summary of changes from v23:

* index_getnext_tid is renamed and moved into indexbatch.h, where it
becomes a utility function that has a similar contract to the one that
is used to call amgetbatch (and manage changes in the batch
direction). Now there's only one canonical call to
pgstat_count_index_tuples in heapam, and no accidental table index
fetch state resets when a scan that happens to be using amgettuple
ends.

* Miscellaneous small cleanups, including renaming some of the
functions in indexbatch.c.

* Slightly simplified tableam_util_batchscan_mark_pos logic.

Obviously this isn't everything that I'd hoped would appear in the
next revision. It's just what got done in the past 24 hours.

-- 
Peter Geoghegan

---

From: Peter Geoghegan <[email-masked]>
Date: Wed, 27 May 2026 12:11:52 -0700

> post what I have, before taking some time away from this.

I'm resuming work on this project, now targeting Postgres 20.

Attached is v25, which I'm posting now to keep CFTested happy and to
ensure the patch set applies cleanly against the master branch. There
are no substantive changes here compared to v24.

I would like to get the first patch committed soon-ish (the one that
revises the table AM by making index scans use a higher-level,
slot-based interface). Maybe in the next 4-8 weeks. That piece is much
simpler than the later ones.

-- 
Peter Geoghegan

---

From: Peter Geoghegan <[email-masked]>
Date: Sat, 6 Jun 2026 20:52:27 -0400

> IncrBufferRefCount(), which isn't that cheap.

Attached is v26, which resolves many outstanding issues from when we
paused work on this, at the end of the final CF for Postgres 19.

I'll highlight each change with its own bullet-point. The stand-out
item is probably:

* A new patch now implements gistgetbatch, which makes GiST scans do
index prefetching.

Performance is very similar to nbtree, so query execution time
reductions of 20x or more are quite possible. heapam's prefetching
approach generalizes very well across all core index AMs that support
plain scans. This proves that the amgetbatch design is general enough
to completely replace amgettuple, without any real downsides for index
AMs.

I want to flag something up-front about the GiST patch: it fixes a
long standing bug in GiST index-only scans [1]. But there's a
catch/downside: the fix works (in part) by teaching the planner to
disallow index-only scans that use orderbyclauses, such as the <->
operator.

I'll discuss why this makes sense at the end of the email. Note that
the ordered scan issue isn't related to the amgetbatch design; it's
fundamentally a problem with GiST, which has long ignored the rules
about buffer pins explained in the docs "Index Locking Considerations"
section. Making it follow those rules seems impossible for ordered
scans specifically (but is quite doable for all other GiST scan
types).

Slot-related improvements
-------------------------

I made many improvements to the high-level slot-passing API functions.
I also improved certain lower-level details of how we use slots within
heapam_indexscan.c. To start:

* Index-only scans no longer need to use a buffered tuple table slot
at all. As such, IoS callers to table_index_getnext_slot are no longer
required to "lend heapam a buffered tuple slot", as in previous
versions.

Andres pointed out that the previous approach was silly, and it was.
Now index-only scans use a temp HeapTuple directly; calling
heap_hot_search_buffer doesn't truly require passing a slot (not now,
at least).

>                  * format, since it's probably a bit cheaper to fill a slot from.

Agreed that the layering improvements within nodeIndexonlyscan.c still
didn't go far enough in previous versions. Which brings me to my next
highlight/improvement:

* Index-only scans now use their own virtual slot to retrieve rows
through table_index_getnext_slot, in the obvious and idiomatic way
(just like plain scans, though using a different slot type). Places
like nodeIndexonlyscan.c no longer perform ad-hoc reading of
IndexScanDesc fields such as xs_itup, xs_hitup, and xs_recheck.

Implementing this item necessitated moving the "Fill the scan tuple
slot with data from the index" logic into heapam_indexscan.c, just as
you'd predicted. I think that that's basically fine. I'm not entirely
sure if the relevant function needs to be exported so other table AMs
can call it.

A non-obvious advantage of this approach is that it fixes a
theoretical latent bug in selfuncs.c: it will sometimes scans an
nbtree index on a name column, with cstring index storage, while
arguably expecting that there is a full NAMEDATALEN of storage for
each datum. This is not actually a bug in practice; we don't really
read past the end of batch memory, because selfuncs.c doesn't actually
do anything that runs that risk. Better to nail it down, though.

Note that I didn't invent a new table_index_getnext_ios shim function
(which Andres suggested during related discussion). The existing
table_index_getnext_slot already provided everything needed.
table_index_getnext_slot will now assert that its caller is using the
expected kind of slot (either TTS_IS_BUFFERTUPLE or TTS_IS_VIRTUAL)
according to whether the scan is !scan->xs_want_itup. I don't feel
very strongly about this point, though.

IndexScanDesc and IndexFetchTableData structs
---------------------------------------------

A few notable things to say here.

> > > IndexScanDesc with the IndexFetchTableData, without again needing to change

Maybe it makes sense to share one allocation for everything in the way
that you've outline here, but I haven't done that yet. That seems like
strictly an optimization to me. However:

* I completely removed the IndexFetchTableData struct, which seems
pointless given our extensive use of IndexScanDesc in
heapam_indexscan.c.

We do keep the underlying heapam struct (IndexFetchHeapData), which is
now accessed through a void pointer in IndexScanDesc -- exactly like
it's done with index AMs. There's no more "inheritance from
IndexFetchTableData", which seems to add nothing anymore.

There's also a tangentially related index AM/table AM API improvement:

* New IndexAmRoutine.amcanmarkpos field determines whether an index AM
can support mark/restore.

Only nbtree sets IndexAmRoutine.amcanmarkpos=true. We now require that
index AMs explicitly opt in to mark/restore support (like on master),
rather than implicitly making every index AM support mark/restore just
because they use amgetbatch. That was the theoretical contract in
previous patch versions, which was silly; while hash index scans can
arguably support mark/restore, allowing it makes no sense. And GiST
index scans certainly cannot support it. GiST fundamentally expects to
control the progress of the scan itself, even with the new
gistgetbatch support (which is why it still sets
IndexAmRoutine.amcanbackward=false and
IndexAmRoutine.amcanmarkpos=false along with it, and why it doesn't
even save sibling links in its opaque area).

Batch allocation, layout, opaque areas
--------------------------------------

I've improved how batches are laid out in memory. These improvements
affect both table AMs and index AMs in various ways. Improving the
layout of batches (and making related API enhancements) addressed
problems in many areas.

> that's a lot of indirection to go through.

Most of the indirection is now avoided:

* We now avoid calling index_fetch_batch_init during scans whose table
AM didn't request any opaque area be made available in batches.

In practice this means that heapam plain index scans (and all bitmap
index scans) don't call index_fetch_batch_init at all. Now
index_fetch_batch_init is strictly about initializing the table AM
opaque area; scans without such an area in their batches will never
receive any calls to their index_fetch_batch_init callback.

* Layering improvement, related to the last item: We no longer
condition whether a table AM opaque area is included in each allocated
batch on whether the scan is index-only or not. We simply condition it
on whether the table AM asked us for an opaque area at the start of
the scan.

Obviously, other table AMs might have "table AM opaque area"
requirements that don't match heapam. The table AM can choose to use
an opaque area for its own reasons, whatever they are. It can also
control the precise layout of the opaque area. This area might include
a per-item portion, usually for visibility info, but it doesn't have
to; it can be a fixed-size struct or a simple array without any struct
header.

* We now make the table AM opaque area in each batch a single
contiguous area. heapam uses a flexible array member for this (when it
requests its own opaque area in the first place).

* heapam_index_fetch_batch_init no longer needs to memset() visibility
info as batches are allocated/recycled.

The HeapBatchData struct now contains the bounds of a range of
known-set items from the batch it is contained within (this
information is in its header struct, before the flexible array member
at the end). Visibility info for each batch item is stored in
HeapBatchData's flexible array member. When index_fetch_batch_init is
called, heapam_index_fetch_batch_init will just invalidate the
HeapBatchData bounds instead of calling memset.

Avoiding the memset might be a minor performance win (completely
skipping most index_fetch_batch_init calls certainly was). But FWIW I
actually did things this way because it seemed a bit clearer to me.
And because it proves that the new flexible array scheme actually
works.

gistgetbatch support and batch layout
-------------------------------------

As I mentioned, other changes to the batch layout specifically concern
index AMs. One AM in particular: GiST. I mentioned at the start of
this email that GiST scans are now supported. This improvement affects
the core indexbatch.h contract for index AMs, but actually appears in
the later GiST patch:

* Index AMs can now optionally request a second, dynamically-sized
opaque area (similar to those that table AMs request).

This area should not be confused with the standard
fixed-offset/fixed-size struct that the index AM must use in every
batch. That remains mandatory, and is still at a fixed offset, known
at compile time. Only GiST (and maybe SP-GiST) will find this second
area concept useful. It's a relatively niche facility.

In short, this new area gives those index AMs a way to store
tuple-level state required to implement all of the features that
weren't possible in earlier versions of the patch set. This other
opaque area gives the index AM "just enough tuple-level processing to
get by". gistgetbatch scans are fundamentally structured around
generating batches for each leaf page, just like btgetbatch and
hashgetbatch scans always were. However, a new amgettransform callback
is registered when the AM needs to perform tuple-level processing
later (at the point where the executor actually needs to return
items).

GiST's new amgettransform callback (gitgettransform)
----------------------------------------------------

This two-phase approach keeps the scan process simple. We store GiST's
on-disk format tuples in currTuples (just like nbtree), only
converting them into scan->xs_hitup format tuples when they are
actually consumed/returned. That way we can size each batch's
currTuples to BLCKSZ, enabling the same batch memory management
strategy used in other index AMs -- we can freely recycle batches
because their layout is fixed and predictable (for the duration of a
given scan).

Our approach generally simplifies resource management: GiST support
functions like leaking memory, but amgettransform can reset an
internal memory context every time another tuple is returned.

We primarily need amgettransform to handle rechecks in the core
executor, which might be required for any individual tuple, during any
GiST index scan. GiST opclasses have always been permitted to
implement a consistent function that can unpredictably cause
individual tuples to require (or not require) a recheck in the core
executor (unlike hash and nbtree, which always and never require a
recheck respectively). That "tuple needs recheck" information must
travel with the matching items in the same batch, so it can ultimately
be returned to executor node callers (table_index_getnext_slot
callers) at the tuple granularity.

Why GiST ordered index-only scans were disabled, "virtual" batches
------------------------------------------------------------------

As I mentioned in passing at the start of this email, GiST ordered
index-only scans are disabled in the planner by the GiST patch. This
disabling is necessary to fully fix the bugs in GiST index-only scans
[1]. Most GiST index-only scans now follow the exact same locking
protocol as nbtree index-only scans, which fixes the bug for those
scan types (note that GiST VACUUM now acquires cleanup locks that'll
conflict with pins held by scans). But ordered scans are different,
and require their own custom solution: disabling them altogether in
the planner.

The crucial difference between ordered GiST scans and "standard" GiST
scans is in how batches are formed. Ordered scans use "virtual
batches", which are groups of tuples that we *pretend* were returned
from a single leaf page that stored the tuples in the scan's required
sort order. In reality, the underlying tuples may originate from many
different leaf pages.

Holding many buffer pins as an interlock against concurrent TID
recycling (released by gistunguardbatch during index-only scans) just
isn't practical here, since we might have to hold an indeterminate
number of pins at once. It seems very difficult, perhaps impossible,
to limit the worst-case number of required pins. Why shouldn't a badly
implemented GiST opclass require an ordered index-only scan to pin
almost every leaf page in the index before GiST can return even one
index tuple to the scan? How can we nail down all that stuff in a way
that works in the general case?

This isn't a problem for prefetching's read stream -- the problem is
that the backend might run out of pins. I don't see a way around that
problem. Even if it's possible, the effort and testing required to get
it right seems unlikely to be worth it.

Another closely related consequence of using virtual batches is that
we cannot safely perform LP_DEAD-marking of index tuples within
gistkillitemsbatch. That restriction is nothing new, though; that has
never been supported. In general, using virtual batches (for ordered
scans) implies restrictions similar to those that have long affected
bitmap scans (no VM access due to the resulting TID interlock race
conditions [2], no setting of LP_DEAD bits).

Things I haven't done
---------------------

* There have been recent CI failures on "linux-autoconf", that I
haven't debugged.

001_aio.pl seems to reliably report "Dubious, test returned 4 (wstat
1024, 0x400)" on this CI target. I fully expect this v26 to fail when
CFTester runs it through CI.

I strongly suspect an issue in Andres' "Allow read_stream_reset() to
not wait for IO completion" patch is involved here. It could just be a
bug in the new 001_aio.pl tests added by that patch. It certainly
cannot be a bug in index prefetching itself, since it isn't exercised
by 001_aio.pl at all.

* I've not given much thought to improving the cost model of index
scans, to account for prefetching.

I believe the planner's cost functions give bitmap scans an undue
advantage over plain scans, at least with prefetching. It would
definitely make sense to start thinking about how to fix this problem,
but I haven't really started on that.

I think that we probably need to do something like stop charging
random_page_cost (and charge sequential page cost instead) once the
scan has processed a certain number of index pages and/or heap pages.
That is roughly how it's done already when costing bitmap heap scans.

* Continued to care about pg_attribute_always_inline making certain
function prototypes overlong and ugly.

Andres has a patch to shorten pg_attribute_always_inline to
pg_always_inline in the open CF, which will presumably be committed
soon. I don't want to forego prototypes; I am particular about the
order in which function definitions appear. For now, ugly and overlong
prototypes seem acceptable (I won't commit any patches with that
issue).

> wouldn't need this level of indirection.

* I didn't go this far.

> having a harder time adjusting things due to the copied code.

* I still haven't made progress on making
heapam_index_getnext_scanbatch_pos into a generic helper.

* I haven't added SP-GiST support.

I think that this can be done, but I'm not sure if it's worth the
trouble. SP-GiST VACUUM seems to do some weird things with buffer
pins, and I don't really understand how it deals with redirects.
Besides, we might be better off keeping SP-GiST as-is, so we have one
amgettuple index AM left for testing purposes.

The GiST patch should remove any doubt that the new amgetbatch API is
a suitable replacement for amgettuple; we should actually deprecate
amgettuple at some point. But I'm in no hurry.

[1] https://postgr.es/m/CAH2-Wz=[email-masked]
[2] See commit 459e7bf8, which showed that bitmap scans cannot safely
access the VM as an optimization (this is very similar to the extant
bugs in GiST and SP-GiST)
--
Peter Geoghegan

---

From: Peter Geoghegan <[email-masked]>
Date: Sun, 14 Jun 2026 14:54:26 -0400

> paused work on this, at the end of the final CF for Postgres 19.

Attached is v27, which cleans up some remaining loose ends.

* The standout item here is that v27 simplifies
heapam_index_getnext_scanbatch_pos by making it wholly rely on new,
higher level helper functions in indexbatch.h.

Similar simplifications exist for heapam_index_prefetch_next_block,
our read stream callback.

Andres requested these changes on April 5 due to concerns about code
duplication across table AMs. That's one reason to do things this way,
but there's another: heapam is no longer "partially responsible" for
managing the scan's batch ring buffer. For example,
heapam_index_getnext_scanbatch_pos no longer has to invalidate
prefetchPos itself so that heapam_index_prefetch_next_block cannot
become confused by prefetchPos.batch getting so far behind
scanPos.batch, making it appear ahead instead of behind (due to uint8
wraparound). In general, heapam knows nothing about these batchringbuf
implementation details as of v27.

Removing an obsolescent scanBatch from the ringbuffer also lives in
one of the new helpers -- that's obviously one aspect of batchringbuf
management that heapam shouldn't have to deal with. Especially because
some of the indexbatch.c helpers assume that the table AM follows
certain conventions. In particular, the routines that deal with
mark/restore assume that scanBatch must be the head slot/entry of
batchringbuf when called; the new helper functions enforce that
scheme.

Note that batchringbuf management works essentially the same way as in
previous versions; v27 improves things by fully taking it out of
heapam's hands.
Importantly, the concept of the table AM applying its own handling
when scanPos falls behind prefetchPos is now encapsulated in these new
helper functions. This doesn't assume the use of a read stream, nor
does it place other inappropriate restrictions on table AMs.

The heapam implementation side keeps its handling for obviously
heapam-related tasks: heapam_index_getnext_scanbatch_pos must still
return a TID using heapam-specific logic (which considers the
visibility map cache and so on), and must still unpause a paused read
stream (heapam owns its read stream, and it's possible that other
table AMs won't even use a read stream but will require a similar
pausing mechanism). Obviously, heapam_index_prefetch_next_block still
pauses the read stream when it detects that we've run out of
batchringbuf slots. (Basically, the concept of pausing a read stream
is decoupled from the concept of running out of batchringbuf slots.)

Version 27 also includes a tangentially related style improvement:

* v27 moves most of the new batch-related helpers that previously
appeared in relscan.h into indexbatch.h.

We still declare structs like IndexScanBatchData and BatchRingBuffer
in relscan.h (which is needed to embed batchringbuf in IndexScanDesc),
but all batch accessor functions and batchringbuf
manipulation/maintenance functions now live in indexbatch.h. This was
a pretty natural consequence of inventing the aforementioned new
higher-level helper functions that heapam now uses.

Some of the "Elementary batch ring buffer operation" functions (e.g.,
index_scan_batch_append, index_scan_pos_startbatch,
index_scan_pos_advance, index_scan_batch_full) are no longer called
directly by heapam; they are now only called through the new higher
level helper functions. Others (e.g., index_scan_pos_cmp) are still
called from within heapam, but only within documenting assertions.

> IndexFetchTableData", which seems to add nothing anymore.

* This new v27 renames IndexFetchHeapData to IndexScanHeapData.

This makes sense because the struct now represents "the heapam private
state that relates to an index scan", not "the heapam state that
relates to the part of the index scan that just deals with fetching
heap tuples for a given TID".

I also added new and better test infrastructure:

* Valgrind marks recycled batch memory as UNDEFINED, which should
catch cases where AMs have dangling references to recycled batch
memory that was never actually pfree'd.

* Added test_indexscan, a new test module that performs index scans
with a variety of snapshot types (plus accompanying pg_regress tests).

Without this, no test will fail if each call to
table_index_getnext_slot (actually, the underlying slot-based callback
that heapam implements) starts with scan->xs_heap_continue set to
false unconditionally. IOW, we actually have some chance of noticing
if there's a bug that makes heapam fail to correctly maintain
scan->xs_heap_continue. That's only really possible during non-MVCC
scans, and is very difficult to test without dedicated infrastructure
such as this.

There's also a new isolation test (dirty_snapshot.spec) which
illustrates the role of dirty snapshots in constraint enforcement. For
illustrative purposes, it compares what a dirty snapshot sees to what
the xact's active MVCC snapshot sees at the same point in time across
various isolation test schedules (e.g., xact commit vs xact abort, HOT
update vs non-HOT update). I'm not sure if this extra isolation test
is actually worth the added test cycles, but thought I'd include it
for now. Maybe there's a historical bug that we could add test
coverage for using dirty_snapshot.spec?

* Related to the last item, v27 formalizes the rules around what is
allowed during non-MVCC scans.

I think it's worth grounding the rules in this area rigorously,
supported by defensive documentation assertions. It probably won't end
up mattering to anybody, but I see no reason not to take a position on
this stuff -- it doesn't cost us much.

An assertion (in the prefetching patch) now verifies that non-MVCC
scans never change scan direction. These scans are still allowed to
scan either forwards or backwards (because selfuncs.c expects to be
able to scan either way), but they are not allowed to change the
direction of a scan that's already underway.

It's fairly obvious that this cannot be allowed; what if the scan
changes direction while we're traversing a HOT chain? There's no
provision for reversing scan direction when stopped in the middle of a
HOT chain. Whereas it should be okay to "scan a HOT chain in the
forwards direction" during a backwards scan -- that still ought to
produce reasonable answers.

> Things I haven't done

No progress here.

> scans, to account for prefetching.

Today several of us (Robert Haas, Tomas, Andres, and I) discussed this
privately over IM. I hope that we'll have made concrete progress on it
before long, because it's really important.

I even think the main benefit of this work might be enabling the
planner to use plain scans (and index-only scans) more often in
general; this will be very beneficial even with workloads whose
working set fits in shared_buffers. Prefetching makes it safe to be
optimistic about the added overhead from random I/O, particularly
relative to equivalent bitmap scans, so no actual prefetching is
required for users to see significant benefits.

> prototypes seem acceptable (I won't commit any patches with that

Still waiting for Andres' pg_always_inline patch to get committed.

--
Peter Geoghegan

---

From: Peter Geoghegan <[email-masked]>
Date: Mon, 22 Jun 2026 16:48:10 -0400

> Attached is v27, which cleans up some remaining loose ends.

V28 is attached.

Executive summary:

v28 includes better "start read stream" heuristics, adds SP-GiST
support for amgetbatch, improves the heuristics that bound the work
that get_actual_variable_range performs with a severely bloated index
during planning, and adds more extensive testing of tricky nbtree
edge-cases.

Full details:

* v28 replaces the "only start prefetching on second batch" heuristic
with a more principled approach based on the number of heap blocks
read so far. We now create a read stream the fourth time the scan
switches to another heap block. This cutoff was derived empirically,
by following a process driven by my microbenchmark suite.

This is the standout item in v28. Obviously, the old heuristic left
money on the table regarding prefetching benefits [1] -- that is the
main reason to replace it with something better. Certain
somewhat-selective queries are now benefit much more from prefetching,
without it regressing anything else. But the new heuristic has 2
additional nonobvious advantages:

1. Typically, the new heuristic will start prefetching much earlier
than the old one. But sometimes the opposite occurs: prefetching takes
*longer* to begin. That also helps performance (because we weigh the
actual cost we care about, not a noisy proxy for it).

This helps with some of my adversarial test queries, which still had
regressions before v28. I'm referring to adversarial queries that
perform lateral joins with a LIMIT on the inner side of a nestloop
join (e.g., the A8 query that some of us discussed over IM). Now there
are no regressions at all in my main microbenchmark test suite, which
was an unexpected bonus.

We now seem to avoid all regressions related to LIMITs, lateral joins,
nestloop antijoins, and nestloop semijoins -- without needing to pass
information from the planner down to the scan to do so.

2. The rules determining when to create a read stream are now exactly
the same for index-only scans -- no special case logic is required
anymore. This is simpler and more elegant.

The old heuristic was actually: "Start prefetching on the second
batch, except when there have been exactly zero heap fetches so far
(possible only during an inde-only scan)". The new heuristic only
considers heap fetches.

* A new v28 patch adds amgetbatch support to SP-GiST, the last in-core
index AM that still used the amgettuple interface. Performance
improves to a degree very similar to other index AMs: range scan
queries can be 20x to 25x faster.

An additional restriction applies to index-only scans, which is a
separate issue peculiar to SP-GiST: index-only scans are now disabled
for "long values" opclasses such as the text radix opclass. Unlike the
similar IoS issue in GiST + SP-GiST (the ordered scans issue), this is
a legitimate limitation in the amgetbatch design itself (there is no
existing bug involved here). Don't confuse these two separate
IoS-related issues.

Here's why I just desupported index-only scans for this particular
subset of SP-GiST opclasses: using "long values" doesn't fit well with
our resource management strategy during amgetbatch scans. Our usual
approach of reconstructing a value runs into the problem that the
required metadata size is essentially unbounded. The prefix cannot
reliably fit into a fixed per-batch reconstruction workspace; we can't
use a generic conservative estimate known at the start of the scan.
Note that some prefix style opclasses are not affected at all -- those
that already promise to keep their prefix size under BLCKSZ (our usual
amgettransform approach can work there).

I think we should just accept this limitation to gain the benefit of
prefetching during SP-GiST scans. I'm loathe to invent complicated new
heapam infrastructure just to deal with this problem. I'd rather just
not support SP-GiST at all.

The SP-GiST patch is still very much WIP. I changed how SP-GiST VACUUM
uses its own read stream (for prefetching on index pages) to avoid
obtaining self-conflicting cleanup locks, which is another aspect of
SP-GiST that presented a unique challenge. See the
SPGIST_VACUUM_DRAIN_INTERVAL mechanism.

* v28 includes a new patch that augments the existing
VISITED_PAGES_LIMIT mechanism (which get_actual_variable_range uses to
limit the amount of work its scan performs in pathological cases) with
a new INDEX_PAGES_LIMIT limit. This fixes a complaint from Mark
Callaghan [2] about planning time for range queries becoming excessive
with queue-like tables (because VISITED_PAGES_LIMIT alone was
ineffective).

Note that this is now independent work; it really has nothing to do
with amgetbatch support per se. I'm including it now, because the work
in this area is an offshoot of the new slot-based index scan design,
and was previously discussed on this thread.

Background: Earlier versions of this patch set included a roughly
comparable patch, which I abandoned towards the end of the PG 19
cycle. My earlier proposal wholly replaced VISITED_PAGES_LIMIT; this
new patch complements VISITED_PAGES_LIMIT, by specifically targeting
its one major weakness. That is, this new INDEX_PAGES_LIMIT mechanism
will only tally index leaf page reads that return zero matching items
to the table AM (i.e., those that don't return any batch). And so
INDEX_PAGES_LIMIT is complementary to VISITED_PAGES_LIMIT; it doesn't
replace it.

The results from one microbenchmark indicate that the pathological
case is fully fixed (times given are for planning time, in
milliseconds):

1. Baseline (no dead tuples):
  Metric           Master        Patch      Ratio
  ---------- ------------ ------------ ----------
  Avg               0.140        0.129      0.916x
  p95               0.213        0.188      0.882x
  p99               0.259        0.224      0.867x

3. Main (with bulk-deleted tuples, concurrent inserts/deletes):
  Metric           Master        Patch      Ratio
  ---------- ------------ ------------ ----------
  Avg               3.017        0.259      0.086x
  p95              10.055        0.230      0.023x
  p99              28.034        2.121      0.076x

It makes sense that planning time can double in extreme cases due to
concentrated index bloat (relative to the case with no bloat). It does
not make sense for it to increase by 20x or more. The important thing
is that the worst case is some fixed small-ish multiple of the
best/average case -- there's absolutely no sensible reason to expect
get_actual_variable_range to not at least achieve that.

* v28 also adds a new "read stream throttling" mechanism to prevent
calls to the read stream callback during index-only scans that return
many batches with no prefetchable heap blocks (because every batch
consists of matching items that are all-visible). This is also used
during plain index scans, though that is much less critical.

Throttling works by pausing the read stream when we detect that
amgetbatch calls aren't producing any useful heap block numbers for
the read stream to consume. It makes no sense for a single call to the
read stream callback to invoke amgetbatch *several* times without
returning even one usable heap block number to the read stream. As a
bonus, this adds test coverage for the pausing mechanism.

I developed an adversarial microbenchmark for this. v27 of the patch
performed pretty poorly here:

  === ios / cached ===
  build / setting          buffers (h+r)  bufs vs master heap_fetch
io_read(ms)  exec(ms)  exec x
  ----------------------------------------------------------------------------------------------
  master (no prefetch)                 9       identical          5
       -     0.017   1.00x
  rc prefetch=off                      9       identical          5
       -     0.013   0.76x
  rc prefetch=on                      72             +63          5
       -     0.276  16.24x

v28 leaves the rc prefetch=on case ~1.5x slower than
master/prefetch=off (not shown in the table). This seems quite
acceptable for an unrealistic LIMIT N microbenchmark such as this
(prefetching begins exactly where there'll be no more heap fetches,
which is inherently impossible to handle without any new overhead).

* v28 adds tests that cover nbtree edge cases involving array keys:
one test relies critically on a call to nbtree's btposreset for
mark/restore, another covers the case where btposreset is critical to
correctly backing up a cursor across a batch boundary for a scan with
array keys.

At one point Andres asked about adding such test coverage. It was
tricky to do it in a way that didn't require lots of data, but I found
a way to do it that keeps the added test cycles at an acceptable
level.

> illustrates the role of dirty snapshots in constraint enforcement.

* I removed this in v28 because it didn't seem to be pulling its
weight: too many added test cycles for no actual new test coverage.

> > CFTester runs it through CI.

I suspect that this is just a bug in one of the tests added to
001_aio.pl. I can recreate the failure locally with a
-DRELCACHE_FORCE_RELEASE build.

For now I have disabled that one test case, without removing it, just
so CI passes.

[1] https://postgr.es/m/[email-masked]
[2] https://postgr.es/m/CAH2-Wzkt1WkKp4VRJu3qHfmKXc8W+XYv1RXg5d2d3fSvAeO=[email-masked]
--
Peter Geoghegan