From 6da6cf8192a9e62995d3113a1fa15fdbd4239609 Mon Sep 17 00:00:00 2001
From: Peter Geoghegan <pg@bowt.ie>
Date: Wed, 25 Mar 2026 16:58:09 -0400
Subject: [PATCH v18 08/19] heapam: Add 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 with local SSDs, and 50x faster or more with higher-latency
storage such as network-attached block devices, where the benefit of
hiding I/O latency through prefetching is even greater.

A new GUC (enable_indexscan_prefetch) controls the use of index
prefetching.  The default setting is 'on', so all plain index scans use
prefetching where support exists.  All index-only scans will also use
prefetching automatically where supported (once the scan starts to
require a significant number of heap fetches).

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 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 delays
initialization of the scan's read stream until the scan transitions from
its first batch to its second batch.  Each batch corresponds to matching
TIDs from a single index leaf page, so prefetching only begins once the
scan reads from its second leaf page containing at least one matching
item.  A selective index scan that touches only one leaf page never
reaches the second batch, so the heuristic correctly avoids prefetching
overhead.  The picture for more complicated cases is mixed.

The same principle applies to nestloop inner index scans with very tight
limits (e.g., a correlated subquery with LIMIT 1), where each rescan
reads from only a single leaf page: the heuristic avoids the cost of
repeatedly creating and resetting a read stream across many rescans.
On the other hand, some selective scans that access randomly-ordered
heap pages would genuinely benefit from prefetching, but never get as
far as reaching their second batch -- a missed opportunity, where the
heuristic is overly cautious.

Conversely, the heuristic is not cautious enough with slightly less
selective nestloop inner scans (e.g., LIMIT 3 within a LATERAL join).
These rescans may span two leaf pages, just barely crossing the
second-batch threshold, while still only needing to fetch two or three
heap pages -- not enough for prefetching to realistically help or pay
for itself on any individual rescan.  Such cases are regressed by as
much as 5% relative to PostgreSQL 18, though only when the scan has to
read heap pages from the filesystem.

Adding a smarter heuristic that addresses both shortcomings remains as
work for a future release.  Passing down an ExecSetTupleBound style hint
and using that hint to influence how the read stream ramps up its
distance seems like a promising approach.

Author: Tomas Vondra <tomas@vondra.me>
Author: Peter Geoghegan <pg@bowt.ie>
Reviewed-By: Andres Freund <andres@anarazel.de>
Reviewed-By: Thomas Munro <thomas.munro@gmail.com>
Discussion: https://postgr.es/m/cf85f46f-b02f-05b2-5248-5000b894ebab@enterprisedb.com
---
 src/include/access/heapam.h                   |  12 +
 src/include/access/indexbatch.h               |   8 +-
 src/include/access/relscan.h                  |  35 ++
 src/include/optimizer/cost.h                  |   1 +
 src/backend/access/heap/heapam_indexscan.c    | 405 +++++++++++++++++-
 src/backend/access/index/indexbatch.c         |  49 ++-
 src/backend/optimizer/path/costsize.c         |   1 +
 src/backend/utils/misc/guc_parameters.dat     |   7 +
 src/backend/utils/misc/postgresql.conf.sample |   1 +
 doc/src/sgml/config.sgml                      |  16 +
 doc/src/sgml/indexam.sgml                     |  66 ++-
 doc/src/sgml/tableam.sgml                     |   8 +
 src/test/regress/expected/sysviews.out        |   3 +-
 13 files changed, 599 insertions(+), 13 deletions(-)

diff --git a/src/include/access/heapam.h b/src/include/access/heapam.h
index 66994cb03..2665b9db6 100644
--- a/src/include/access/heapam.h
+++ b/src/include/access/heapam.h
@@ -137,6 +137,18 @@ typedef struct IndexFetchHeapData
 	/* Plain index scan xs_lastinblock optimization */
 	bool		xs_lastinblock; /* last TID on this block in current batch? */
 
+	/*
+	 * The read stream is allocated at the beginning of the scan and reset on
+	 * rescan or when the scan direction changes. The scan direction is saved
+	 * each time a new tuple is requested. If the scan direction changes from
+	 * one tuple to the next, the read stream releases all previously pinned
+	 * buffers and resets the prefetch block.
+	 */
+	bool		xs_paused;		/* paused until next batch is read? */
+	ScanDirection xs_read_stream_dir;	/* index scan direction */
+	BlockNumber xs_prefetch_block;	/* last block returned to xs_read_stream */
+	ReadStream *xs_read_stream; /* index I/O prefetching read stream */
+
 } IndexFetchHeapData;
 
 /*
diff --git a/src/include/access/indexbatch.h b/src/include/access/indexbatch.h
index 3754f702e..6e959ba25 100644
--- a/src/include/access/indexbatch.h
+++ b/src/include/access/indexbatch.h
@@ -40,16 +40,16 @@ extern void tableam_util_unguard_batch(IndexScanDesc scan, IndexScanBatch batch)
 /*
  * Fetch the next batch of matching items for the scan (or the first).
  *
- * Called when caller's current batch (passed to us as priorBatch) has no more
- * matching items in the given scan direction.  Caller passes a NULL
- * priorBatch on the first call here for the scan.
+ * Called when caller's current scanBatch/prefetchBatch (passed to us as
+ * priorBatch) has no more matching items in the given scan direction.  Caller
+ * passes a NULL priorBatch on the first call here for the scan.
  *
  * Returns the next batch to be processed by caller in the given scan
  * direction, or NULL when there are no more matches in that direction.
  *
  * This is where batches are appended to the scan's ring buffer.  We don't
  * free any batches here, though; that is left up to the caller.  The caller
- * is also responsible for advancing their position.
+ * is also responsible for advancing their scanPos/prefetchPos position.
  */
 static pg_attribute_always_inline IndexScanBatch
 tableam_util_fetch_next_batch(IndexScanDesc scan, ScanDirection direction,
diff --git a/src/include/access/relscan.h b/src/include/access/relscan.h
index 950312199..d1bc85d47 100644
--- a/src/include/access/relscan.h
+++ b/src/include/access/relscan.h
@@ -196,6 +196,10 @@ typedef struct IndexScanBatchData
 	 * This allows table AMs to avoid redundant amgetbatch calls with the same
 	 * priorbatch -- the index AM might need to read additional index pages to
 	 * determine there are no more matching items beyond caller's priorbatch.
+	 * In particular, during prefetching the read stream callback discovers
+	 * the end-of-scan via prefetchBatch.  tableam_util_fetch_next_batch()
+	 * checks these flags so that the scan side doesn't repeat the same
+	 * amgetbatch call when it later reaches that batch as scanBatch.
 	 */
 	bool		knownEndBackward;
 	bool		knownEndForward;
@@ -248,12 +252,21 @@ typedef struct IndexScanBatchData *IndexScanBatch;
  * 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.
+ *
+ * This data structure also provides table AMs with a way to read ahead of the
+ * current read position by _multiple_ batches/index pages.  The further out
+ * the table AM reads ahead like this, the further it can see into the future.
+ * That way the table AM is able to reorder work as aggressively as desired.
+ * For example, index scans sometimes need to readahead by as many as a few
+ * dozen amgetbatch batches in order to maintain an optimal I/O prefetch
+ * distance (distance for reading table blocks/fetching table tuples).
  */
 typedef struct BatchRingBuffer
 {
 	/* current positions in IndexScanDescData.batchbuf[] for scan */
 	BatchRingItemPos scanPos;	/* scan's read position */
 	BatchRingItemPos markPos;	/* mark/restore position */
+	BatchRingItemPos prefetchPos;	/* prefetching position */
 
 	/* markPos's batch (not in ring buffer when markBatch != scanBatch) */
 	IndexScanBatch markBatch;
@@ -487,6 +500,28 @@ index_scan_batch_append(IndexScanDescData *scan, IndexScanBatch batch)
 	ringbuf->nextBatch++;
 }
 
+/*
+ * 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,
+				   ScanDirection direction)
+{
+	int8		batchdiff = (int8) (pos1->batch - pos2->batch);
+
+	if (batchdiff != 0)
+		return batchdiff;
+
+	/* Same batch, compare items */
+	if (ScanDirectionIsForward(direction))
+		return pos1->item - pos2->item;
+	else
+		return pos2->item - pos1->item;
+}
+
 /*
  * 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;
diff --git a/src/backend/access/heap/heapam_indexscan.c b/src/backend/access/heap/heapam_indexscan.c
index c0dd097aa..17471f6e5 100644
--- a/src/backend/access/heap/heapam_indexscan.c
+++ b/src/backend/access/heap/heapam_indexscan.c
@@ -18,6 +18,7 @@
 #include "access/heapam.h"
 #include "access/indexbatch.h"
 #include "access/visibilitymap.h"
+#include "optimizer/cost.h"
 #include "storage/predicate.h"
 #include "utils/pgstat_internal.h"
 
@@ -52,6 +53,14 @@ static void heapam_index_batch_pos_visibility(IndexScanDesc scan,
 											  IndexScanBatch batch,
 											  HeapBatchData *hbatch,
 											  BatchRingItemPos *pos);
+static pg_noinline void heapam_index_dirchange_reset(IndexFetchHeapData *hscan,
+													 ScanDirection direction,
+													 BatchRingBuffer *batchringbuf);
+static pg_attribute_always_inline void heapam_index_consider_prefetching(IndexScanDesc scan,
+																		 IndexFetchHeapData *hscan);
+static BlockNumber heapam_index_prefetch_next_block(ReadStream *stream,
+													void *callback_private_data,
+													void *per_buffer_data);
 
 /* ------------------------------------------------------------------------
  * Index Scan Callbacks for heap AM
@@ -78,6 +87,10 @@ heapam_index_fetch_begin(Relation rel)
 	/* xs_lastinblock optimization state */
 	Assert(!hscan->xs_lastinblock);
 
+	/* Read stream state (other fields initialized by callback) */
+	Assert(hscan->xs_read_stream_dir == NoMovementScanDirection);
+	Assert(hscan->xs_read_stream == NULL);
+
 	return &hscan->xs_base;
 }
 
@@ -89,6 +102,23 @@ heapam_index_fetch_reset(IndexFetchTableData *scan)
 	/* Rescans should avoid an excessive number of VM lookups */
 	hscan->xs_vm_items = 1;
 
+	/* Defensively do an unconditional read stream direction reset */
+	hscan->xs_read_stream_dir = NoMovementScanDirection;
+
+	/*
+	 * Reset read stream itself, and other associated state.
+	 *
+	 * Note: we expect the core executor to call index_batchscan_reset (when
+	 * the scan is usebatchring).  This will invalidate the scan's batch ring
+	 * buffer state, including scanPos and prefetchPos.
+	 */
+	if (hscan->xs_read_stream)
+	{
+		hscan->xs_prefetch_block = InvalidBlockNumber; 		/* defensive */
+		hscan->xs_paused = false;
+		read_stream_reset(hscan->xs_read_stream);
+	}
+
 	/*
 	 * Deliberately avoid dropping pins now held in xs_cbuf and xs_vmbuffer.
 	 * This saves cycles during certain tight nested loop joins, and during
@@ -110,6 +140,9 @@ heapam_index_fetch_end(IndexFetchTableData *scan)
 	if (BufferIsValid(hscan->xs_vmbuffer))
 		ReleaseBuffer(hscan->xs_vmbuffer);
 
+	if (hscan->xs_read_stream)
+		read_stream_end(hscan->xs_read_stream);
+
 	pfree(hscan);
 
 	/*
@@ -246,7 +279,14 @@ heapam_index_fetch_tuple_impl(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)
+		 */
+		if (hscan->xs_read_stream)
+			hscan->xs_cbuf = read_stream_next_buffer(hscan->xs_read_stream, NULL);
+		else
+			hscan->xs_cbuf = ReadBuffer(hscan->xs_base.rel, hscan->xs_blk);
 
 		/*
 		 * Prune page when it is pinned for the first time
@@ -525,6 +565,12 @@ heapam_index_getnext_scanbatch_pos(IndexScanDesc scan,
 	Assert(scanPos->valid || index_scan_batch_count(scan) == 0);
 	Assert(all_visible == NULL || scan->xs_want_itup);
 
+	/* Handle resetting the read stream when scan direction changes */
+	if (hscan->xs_read_stream_dir == NoMovementScanDirection)
+		hscan->xs_read_stream_dir = direction;	/* first call */
+	else if (unlikely(hscan->xs_read_stream_dir != direction))
+		heapam_index_dirchange_reset(hscan, direction, batchringbuf);
+
 	/*
 	 * Check if there's an existing loaded scanBatch for us to return the next
 	 * matching item's TID/index tuple from
@@ -534,7 +580,7 @@ heapam_index_getnext_scanbatch_pos(IndexScanDesc scan,
 	{
 		/*
 		 * scanPos is valid, so scanBatch must already be loaded in batch ring
-		 * buffer.  We rely on that here.
+		 * buffer.  We rely on that here (can't do this with prefetchBatch).
 		 */
 		pg_assume(batchringbuf->headBatch == scanPos->batch);
 
@@ -565,6 +611,17 @@ heapam_index_getnext_scanbatch_pos(IndexScanDesc scan,
 		return NULL;
 	}
 
+	if (hadExistingScanBatch && !hscan->xs_read_stream)
+	{
+		Assert(!scan->batchringbuf.prefetchPos.valid);
+
+		/*
+		 * Not using a read stream to do index prefetching.  Decide whether to
+		 * start one now.
+		 */
+		heapam_index_consider_prefetching(scan, hscan);
+	}
+
 	/*
 	 * Advanced scanBatch.  Now position scanPos to the start of new
 	 * scanBatch.
@@ -580,6 +637,7 @@ heapam_index_getnext_scanbatch_pos(IndexScanDesc scan,
 	{
 		IndexScanBatch headBatch = index_scan_batch(scan,
 													batchringbuf->headBatch);
+		BatchRingItemPos *prefetchPos = &batchringbuf->prefetchPos;
 
 		Assert(headBatch != scanBatch);
 		Assert(batchringbuf->headBatch != scanPos->batch);
@@ -587,12 +645,44 @@ heapam_index_getnext_scanbatch_pos(IndexScanDesc scan,
 		/* 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_index_prefetch_next_block 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 here.)
+		 *
+		 * This handling is approximately the opposite of resuming a paused
+		 * read stream: this helps the scan deal with prefetchPos falling
+		 * behind scanPos, whereas pausing is used when scanPos has fallen
+		 * behind (very far behind) prefetchPos.
+		 */
+		if (prefetchPos->valid &&
+			prefetchPos->batch == batchringbuf->headBatch)
+			prefetchPos->valid = false;
+
 		/* Remove the batch from the ring buffer (even if it's markBatch) */
 		batchringbuf->headBatch++;
+
+		if (unlikely(hscan->xs_paused))
+		{
+			/*
+			 * heapam_index_prefetch_next_block paused the scan's read stream
+			 * due to our running out of free batch slots.  Now that we've
+			 * freed up one such slot, we can resume the read stream (since
+			 * there's now space for heapam_index_prefetch_next_block to store
+			 * one more batch).
+			 */
+			Assert(!index_scan_batch_full(scan));
+			read_stream_resume(hscan->xs_read_stream);
+			hscan->xs_paused = false;
+		}
 	}
 
 	/* In practice scanBatch will always be the ring buffer's headBatch */
 	Assert(batchringbuf->headBatch == scanPos->batch);
+	Assert(!hscan->xs_paused);
 
 	return heapam_index_return_scanpos_tid(scan, hscan, direction,
 										   scanBatch, scanPos, all_visible);
@@ -699,6 +789,13 @@ heapam_index_return_scanpos_tid(IndexScanDesc scan, IndexFetchHeapData *hscan,
  * (important for inner index scans of anti-joins and semi-joins), and the
  * need to unguard batches promptly.
  *
+ * In no event will the scan be allowed to guard more than one batch 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.  (Unguarding via
+ * the amunguardbatch callback often means releasing a buffer pin on an
+ * index page, which counts against the same shared pin limit.)
+ *
  * Once we've resolved visibility for all items in a batch, we can safely
  * unguard it by calling amunguardbatch.  This is safe with respect to
  * concurrent VACUUM because the batch's guard (typically a buffer pin on the
@@ -825,3 +922,307 @@ heapam_index_batch_pos_visibility(IndexScanDesc scan, ScanDirection direction,
 	else
 		hscan->xs_vm_items = scan->maxitemsbatch;
 }
+
+/*
+ * 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.
+ *
+ * Note: iff the scan _continues_ in this new direction, and actually steps
+ * off scanBatch to an earlier index page, tableam_util_fetch_next_batch 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
+heapam_index_dirchange_reset(IndexFetchHeapData *hscan,
+							 ScanDirection direction,
+							 BatchRingBuffer *batchringbuf)
+{
+	/* Reset read stream state */
+	batchringbuf->prefetchPos.valid = false;
+	hscan->xs_paused = false;
+	hscan->xs_read_stream_dir = direction;
+
+	/* Reset read stream itself */
+	if (hscan->xs_read_stream)
+		read_stream_reset(hscan->xs_read_stream);
+}
+
+/*
+ * Decide whether to start a read stream for heap block prefetching during an
+ * index scan.
+ *
+ * Called each time a new batch is obtained from the index AM, barring the
+ * first time that happens.  We delay initializing the stream until reading
+ * from the scan's second batch.  This heuristic avoids wasting cycles on
+ * starting a read stream for very selective index scans.
+ *
+ * We 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).
+ *
+ * We 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 to perform
+ * heap fetches.
+ */
+static pg_attribute_always_inline void
+heapam_index_consider_prefetching(IndexScanDesc scan,
+								  IndexFetchHeapData *hscan)
+{
+	Assert(!hscan->xs_read_stream);
+	Assert(!scan->batchringbuf.prefetchPos.valid);
+
+	if (scan->MVCCScan && enable_indexscan_prefetch &&
+		hscan->xs_blk != InvalidBlockNumber)	/* for index-only scans */
+		hscan->xs_read_stream =
+			read_stream_begin_relation(READ_STREAM_DEFAULT, NULL,
+									   scan->heapRelation, MAIN_FORKNUM,
+									   heapam_index_prefetch_next_block,
+									   scan, 0);
+	/* else don't start a read stream for prefetching (not yet, at least) */
+}
+
+/*
+ * Return the next block to the read stream when performing index prefetching.
+ *
+ * The initial batch is always loaded by heapam_index_getnext_scanbatch_pos.
+ * 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 heapam_index_getnext_scanbatch_pos will return
+ * to the scan (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 (until prefetching ends).
+ */
+static BlockNumber
+heapam_index_prefetch_next_block(ReadStream *stream,
+								 void *callback_private_data,
+								 void *per_buffer_data)
+{
+	IndexScanDesc scan = (IndexScanDesc) callback_private_data;
+	IndexFetchHeapData *hscan = (IndexFetchHeapData *) scan->xs_heapfetch;
+	BatchRingBuffer *batchringbuf = &scan->batchringbuf;
+	BatchRingItemPos *scanPos = &batchringbuf->scanPos;
+	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(scanPos->valid && index_scan_batch_loaded(scan, scanPos->batch));
+	Assert(scan->MVCCScan);
+	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.  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 we're actually called).
+	 * 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).  This is particularly likely
+	 * during index-only scans that require only a few heap fetches: we'll
+	 * only be called when read_stream_next_buffer is called, which happens
+	 * far less often than it would during an equivalent plain index scan.
+	 *
+	 * Note: when heapam_index_getnext_scanbatch_pos 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 keeping any batch guarded for more than an instant,
+		 * to avoid undesirable interactions with the scan's read stream.
+		 * See comment and assertion at the top of the loop below.
+		 */
+		if (scan->xs_want_itup)
+		{
+			HeapBatchData *hbatch;
+
+			/*
+			 * Make heapam_index_batch_pos_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_index_batch_pos_visibility(scan, xs_read_stream_dir,
+											  prefetchBatch, hbatch,
+											  prefetchPos);
+		}
+		else
+			Assert(scan->batchImmediateUnguard);
+	}
+
+	prefetchBatch = index_scan_batch(scan, prefetchPos->batch);
+	for (;;)
+	{
+		BatchMatchingItem *item;
+		BlockNumber prefetch_block;
+
+		/*
+		 * We never call amgetbatch without immediately unguarding the batch,
+		 * either within the index AM or here (when we eagerly load all of the
+		 * batch's visibility information during an index-only scan).  The
+		 * index AM won't hold onto TID interlock buffer pins, keeping the
+		 * absolute number of pins held to a minimum.
+		 *
+		 * This is defensive.  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.
+		 */
+		Assert(!prefetchBatch->isGuarded);
+		if (fromScanPos)
+		{
+			/*
+			 * Don't increment item when prefetchPos was just initialized
+			 * using scanPos.  We'll return the scanPos item's heap block
+			 * directly on the first call here.  In other words, we'll return
+			 * the heap block from TID passed to heapam_index_fetch_tuple_impl
+			 * at the point where it called read_stream_next_buffer for the
+			 * first time during the scan.
+			 */
+			fromScanPos = false;
+		}
+		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_index_getnext_scanbatch_pos will resume the read
+				 * stream later, though only after scanPos has consumed all
+				 * remaining items from scanBatch (at which point the current
+				 * head batch will be freed, making a slot available for reuse
+				 * here by us).
+				 *
+				 * 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.  It also seems like a good idea to
+				 * impose some hard limit on the number of batches that
+				 * prefetchPos can get ahead of scanPos by (especially in the
+				 * case of index-only scans, where we often won't have any
+				 * heap block to return from most of the scan's batches).
+				 */
+				hscan->xs_paused = true;
+				return read_stream_pause(stream);
+			}
+
+			prefetchBatch = tableam_util_fetch_next_batch(scan,
+														  xs_read_stream_dir,
+														  prefetchBatch,
+														  prefetchPos);
+			if (!prefetchBatch)
+			{
+				/*
+				 * No more batches in this direction, so all the batches that
+				 * the scan will ever require (barring a change in scan
+				 * direction) are now loaded
+				 */
+				return InvalidBlockNumber;
+			}
+
+			/* 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_index_batch_pos_visibility(scan, xs_read_stream_dir,
+												  prefetchBatch, hbatch,
+												  prefetchPos);
+			}
+			else
+				Assert(scan->batchImmediateUnguard);
+		}
+
+		/*
+		 * prefetchPos now points to the next item whose TID's heap block
+		 * number might need to be prefetched
+		 */
+		Assert(index_scan_batch(scan, prefetchPos->batch) == prefetchBatch);
+		Assert(prefetchPos->item >= prefetchBatch->firstItem &&
+			   prefetchPos->item <= prefetchBatch->lastItem);
+		/* scanPos is always <= prefetchPos when we return */
+		Assert(index_scan_pos_cmp(scanPos, prefetchPos, xs_read_stream_dir) <= 0);
+
+		if (scan->xs_want_itup)
+		{
+			HeapBatchData *hbatch = heap_batch_data(prefetchBatch, scan);
+
+			Assert(hbatch->visInfo[prefetchPos->item] & HEAP_BATCH_VIS_CHECKED);
+			if (hbatch->visInfo[prefetchPos->item] & HEAP_BATCH_VIS_ALL_VISIBLE)
+			{
+				/* item is known to be all-visible -- don't prefetch */
+				continue;
+			}
+		}
+
+		item = &prefetchBatch->items[prefetchPos->item];
+		prefetch_block = ItemPointerGetBlockNumber(&item->tableTid);
+
+		if (prefetch_block == hscan->xs_prefetch_block)
+		{
+			/*
+			 * prefetch_block matches the last prefetchPos item's TID's heap
+			 * block number; we must not return the same prefetch_block twice
+			 * (twice in succession)
+			 */
+			continue;
+		}
+
+		/* We have a new heap block number to return to read stream */
+		hscan->xs_prefetch_block = prefetch_block;
+		return prefetch_block;
+	}
+
+	return InvalidBlockNumber;
+}
diff --git a/src/backend/access/index/indexbatch.c b/src/backend/access/index/indexbatch.c
index 565182430..8e913225b 100644
--- a/src/backend/access/index/indexbatch.c
+++ b/src/backend/access/index/indexbatch.c
@@ -10,7 +10,10 @@
  * approach enables efficient prefetching of table AM blocks during ordered
  * index scans.
  *
- * The ring buffer loads batches in index key space order.
+ * The ring buffer loads batches in index key space order.  This allows the
+ * table AM to maintain an adequate prefetch distance: its read stream
+ * callback is thereby able to request table blocks referenced by index pages
+ * that are well ahead of the current scan position's index page.
  *
  * There's three types of functions in this module:
  *
@@ -30,6 +33,25 @@
  *    AMs that implement the amgetbatch interface.  These manage batch
  *    allocation, index page buffer lock release, and batch memory recycling.
  *
+ * These three layers coordinate without explicit coupling: the core lifecycle
+ * functions assume that table AMs use scanPos/scanBatch and prefetchPos/
+ * prefetchBatch in a standardized way (see heapam_indexscan.c for the
+ * reference implementation), while table AMs assume that index AMs free and
+ * unlock batches according to the conventions established here.  See
+ * indexam.sgml for the full specification of this contract.
+ *
+ * The table AM fully controls the read stream as its own private state.
+ * When the scan direction changes, the table AM must immediately reset its
+ * read stream and invalidate prefetchPos -- blocks already requested via
+ * prefetchPos will no longer match what scanPos needs to return.
+ *
+ * Crossing a batch boundary in a new scan direction is a separate process,
+ * handled here: table AMs are required to call tableam_util_batch_dirchange
+ * to leave the scan's batch ring buffer in a consistent state.  The current
+ * implementation handles this by simply discarding most batches.  The key
+ * invariant is that all loaded batches must be in a consistent scan direction
+ * order.
+ *
  * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
  * Portions Copyright (c) 1994, Regents of the University of California
  *
@@ -64,6 +86,7 @@ index_batchscan_init(IndexScanDesc scan)
 
 	scan->batchringbuf.scanPos.valid = false;
 	scan->batchringbuf.markPos.valid = false;
+	scan->batchringbuf.prefetchPos.valid = false;
 
 	scan->batchringbuf.markBatch = NULL;
 	scan->batchringbuf.headBatch = 0;
@@ -84,6 +107,7 @@ index_batchscan_reset(IndexScanDesc scan)
 
 	batchringbuf->scanPos.valid = false;
 	batchringbuf->markPos.valid = false;
+	batchringbuf->prefetchPos.valid = false;
 
 	/*
 	 * Ensure tableam_util_free_batch won't skip the old markBatch in the loop
@@ -200,7 +224,13 @@ index_batchscan_mark_pos(IndexScanDesc scan)
  * the current scanBatch when needed.
  *
  * We just discard all batches (other than markBatch/restored scanBatch),
- * except when markBatch is already the scan's current scanBatch.
+ * except when markBatch is already the scan's current scanBatch.  We always
+ * invalidate prefetchPos.  The read stream and related prefetching state are
+ * reset by table_index_fetch_reset(), called before this function.  This
+ * approach keeps things simple for table AMs: most code that deals with
+ * batches is thereby able to assume that the common case where scan direction
+ * never changes is the only case (tableam_util_batch_dirchange takes a
+ * similar approach to handling a cross-batch change in scan direction).
  */
 void
 index_batchscan_restore_pos(IndexScanDesc scan)
@@ -215,6 +245,14 @@ index_batchscan_restore_pos(IndexScanDesc scan)
 	Assert(scan->xs_heapfetch);
 	Assert(markPos->valid);
 
+	/*
+	 * Restoring a mark always requires stopping prefetching.  This is similar
+	 * to the handling table AMs implement to deal with a tuple-level change
+	 * in the scan's direction.  The read stream must have already been reset
+	 * by the caller (via table_index_fetch_reset).
+	 */
+	batchringbuf->prefetchPos.valid = false;
+
 	if (scanBatch == markBatch)
 	{
 		/* markBatch is already scanBatch; needn't change batchringbuf */
@@ -284,6 +322,13 @@ index_batchscan_restore_pos(IndexScanDesc scan)
  * to determine which batch comes next in the new scan direction.  This
  * approach isn't particularly efficient, but it works well enough for what
  * ought to be a relatively rare occurrence.
+ *
+ * Caller must have reset the scan's read stream before calling here.  That
+ * needs to happen as soon as the scan requests a tuple in whatever scan
+ * direction is opposite-to-current.  We only deal with the case where the
+ * scan backs up by enough items to cross a batch boundary (when the scan
+ * resumes scanning in its original direction/ends before crossing a boundary,
+ * there isn't any need to call here).
  */
 void
 tableam_util_batch_dirchange(IndexScanDesc scan)
diff --git a/src/backend/optimizer/path/costsize.c b/src/backend/optimizer/path/costsize.c
index 1c575e56f..6fcb815f7 100644
--- a/src/backend/optimizer/path/costsize.c
+++ b/src/backend/optimizer/path/costsize.c
@@ -146,6 +146,7 @@ int			max_parallel_workers_per_gather = 2;
 bool		enable_seqscan = true;
 bool		enable_indexscan = true;
 bool		enable_indexonlyscan = true;
+bool		enable_indexscan_prefetch = true;
 bool		enable_bitmapscan = true;
 bool		enable_tidscan = true;
 bool		enable_sort = true;
diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat
index 0a862693f..2b5620f9c 100644
--- a/src/backend/utils/misc/guc_parameters.dat
+++ b/src/backend/utils/misc/guc_parameters.dat
@@ -931,6 +931,13 @@
   boot_val => 'true',
 },
 
+{ name => 'enable_indexscan_prefetch', type => 'bool', context => 'PGC_USERSET', group => 'QUERY_TUNING_METHOD',
+  short_desc => 'Enables prefetching for index scans and index-only scans.',
+  flags => 'GUC_EXPLAIN',
+  variable => 'enable_indexscan_prefetch',
+  boot_val => 'true',
+},
+
 { name => 'enable_material', type => 'bool', context => 'PGC_USERSET', group => 'QUERY_TUNING_METHOD',
   short_desc => 'Enables the planner\'s use of materialization.',
   flags => 'GUC_EXPLAIN',
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index cf1559738..2135ea524 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -420,6 +420,7 @@
 #enable_incremental_sort = on
 #enable_indexscan = on
 #enable_indexonlyscan = on
+#enable_indexscan_prefetch = on
 #enable_material = on
 #enable_memoize = on
 #enable_mergejoin = on
diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index 229f41353..336c621d5 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -5712,6 +5712,22 @@ ANY <replaceable class="parameter">num_sync</replaceable> ( <replaceable class="
       </listitem>
      </varlistentry>
 
+     <varlistentry id="guc-enable-indexscan-prefetch" xreflabel="enable_indexscan_prefetch">
+      <term><varname>enable_indexscan_prefetch</varname> (<type>boolean</type>)
+      <indexterm>
+       <primary><varname>enable_indexscan_prefetch</varname> configuration parameter</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        Enables or disables prefetching for index scan and index-only scan
+        plan types.  Prefetching can improve performance by reading table AM
+        pages ahead of when they are needed during index scans.  The default
+        is <literal>on</literal>.
+       </para>
+      </listitem>
+     </varlistentry>
+
      <varlistentry id="guc-enable-material" xreflabel="enable_material">
       <term><varname>enable_material</varname> (<type>boolean</type>)
       <indexterm>
diff --git a/doc/src/sgml/indexam.sgml b/doc/src/sgml/indexam.sgml
index 0f6c373b1..25b516ba6 100644
--- a/doc/src/sgml/indexam.sgml
+++ b/doc/src/sgml/indexam.sgml
@@ -807,9 +807,12 @@ amgetbatch (IndexScanDesc scan,
   <para>
    The <function>amgetbatch</function> interface is an alternative to
    <function>amgettuple</function> that returns matching index entries in batches
-   rather than one at a time.  By returning all matching index entries from a
-   single index page together, the table AM gains visibility into which table
-   blocks will be needed in the near future.
+   rather than one at a time. This enables the table access method to
+   optimize table block access patterns and perform I/O prefetching.
+   By returning all matching index entries from a single index page together,
+   the table AM can readahead through the index and identify which table
+   blocks will be needed, allowing prefetching of table AM pages during
+   ordered index scans.
   </para>
 
   <para>
@@ -1001,7 +1004,8 @@ amunguardbatch (IndexScanDesc scan,
    free the pins at an opportune point (for example when <function>amrescan</function>
    and/or <function>amendscan</function> are called).  It must also keep the
    number of retained pins fixed and small, to avoid exhausting the backend's
-   buffer pin limit.
+   buffer pin limit (which is shared with the table AM's read stream
+   for index scan prefetching).
   </para>
 
   <para>
@@ -1376,6 +1380,60 @@ amtranslatecmptype (CompareType cmptype, Oid opfamily, Oid opcintype);
    or vice versa, if its internal implementation is unsuited to one API or the other.
   </para>
 
+  <sect2 id="index-scanning-batches">
+   <title>Table AM Considerations for Batch Scanning</title>
+
+   <para>
+    This section is primarily relevant to
+    <link linkend="tableam">table access method</link> authors.
+    When an index scan uses the <function>amgetbatch</function> interface,
+    the table AM is responsible for managing position state within the
+    <structname>IndexScanDesc</structname>'s
+    <structfield>batchringbuf</structfield> and for controlling when
+    buffer pins on index pages are released.
+   </para>
+
+   <para>
+    The <structfield>scanPos</structfield> field within
+    <structfield>batchringbuf</structfield> tracks which batch and item within
+    that batch will be returned next to the executor.  The table AM must advance
+    <structfield>scanPos</structfield> as tuples are returned by
+    <function>table_index_getnext_slot</function>.  The core code may also
+    modify this field during operations such as mark/restore.
+   </para>
+
+   <para>
+    The <structfield>prefetchPos</structfield> field tracks the position used
+    for I/O prefetching.  It is generally advanced by initializing it from
+    <structfield>scanPos</structfield> within a read stream callback, allowing
+    the table AM to prefetch table blocks pointed to by items that are well
+    ahead of the current scan position.  Initially
+    <structfield>prefetchPos</structfield> starts at
+    <structfield>scanPos</structfield>, but as the read stream ramps up it can
+    get far ahead &mdash; spanning multiple index pages if necessary to
+    maintain an optimal I/O prefetch distance for table block reads.  A major
+    goal of the <function>amgetbatch</function> interface is to allow the
+    table AM to prefetch without being limited to items from the current
+    <structfield>scanPos</structfield> batch's index leaf page.
+   </para>
+
+   <para>
+    Both <structfield>scanPos</structfield> and
+    <structfield>prefetchPos</structfield> are controlled by the table AM and
+    core code; index access methods should not access or manipulate these
+    fields.  See the <filename>src/backend/access/heap/</filename>
+    implementation for a reference example.
+   </para>
+
+   <para>
+    For details on buffer pin management during batch scans, including the
+    <structfield>batchImmediateUnguard</structfield> policy and the
+    <function>amunguardbatch</function> callback, see
+    <xref linkend="index-locking"/>.
+   </para>
+
+  </sect2>
+
  </sect1>
 
  <sect1 id="index-locking">
diff --git a/doc/src/sgml/tableam.sgml b/doc/src/sgml/tableam.sgml
index 9ccf5b739..8e70a6196 100644
--- a/doc/src/sgml/tableam.sgml
+++ b/doc/src/sgml/tableam.sgml
@@ -129,6 +129,14 @@ my_tableam_handler(PG_FUNCTION_ARGS)
   optional), the block number needs to provide locality.
  </para>
 
+ <para>
+  Table access methods can support ordered index scans using the
+  <function>amgetbatch</function> interface. See also
+  <xref linkend="index-scanning-batches"/> for details on interfacing with
+  <function>amgetbatch</function> index access methods, and managing the
+  scan's position.
+ </para>
+
  <para>
   For crash safety, an AM can use postgres' <link
   linkend="wal"><acronym>WAL</acronym></link>, or a custom implementation.
diff --git a/src/test/regress/expected/sysviews.out b/src/test/regress/expected/sysviews.out
index 132b56a58..32bc3dd3e 100644
--- a/src/test/regress/expected/sysviews.out
+++ b/src/test/regress/expected/sysviews.out
@@ -166,6 +166,7 @@ select name, setting from pg_settings where name like 'enable%';
  enable_incremental_sort        | on
  enable_indexonlyscan           | on
  enable_indexscan               | on
+ enable_indexscan_prefetch      | on
  enable_material                | on
  enable_memoize                 | on
  enable_mergejoin               | on
@@ -180,7 +181,7 @@ select name, setting from pg_settings where name like 'enable%';
  enable_seqscan                 | on
  enable_sort                    | on
  enable_tidscan                 | on
-(25 rows)
+(26 rows)
 
 -- There are always wait event descriptions for various types.  InjectionPoint
 -- may be present or absent, depending on history since last postmaster start.
-- 
2.53.0

