pg.ddx.io  pgsql-hackers@postgresql.org mailing list archive  
help / color / mirror / Atom feed
From: Tomas Vondra <tomas@vondra.me>
To: Andres Freund <andres@anarazel.de>
To: Lukas Fittl <lukas@fittl.com>
Cc: Melanie Plageman <melanieplageman@gmail.com>
Cc: PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Subject: Re: EXPLAIN: showing ReadStream / prefetch stats
Date: Wed, 18 Mar 2026 23:41:13 +0100
Message-ID: <bf1d4799-e2fd-464c-96ff-dc448f021b1e@vondra.me> (raw)
In-Reply-To: <812dc1c8-6533-4c59-afc2-819c37eb5c10@vondra.me>
References: <a177a6dd-240b-455a-8f25-aca0b1c08c6e@vondra.me>
	<CAAKRu_byaXSbanJE_q0P2ngd46hQBu9jeBde72NOaKy=N83XnQ@mail.gmail.com>
	<e220606b-a786-416a-8e21-dcaa03bd6151@vondra.me>
	<0fa0794e-7e34-4fdb-8e35-a59ef8ce5246@vondra.me>
	<dyx4lhzn4ddperza3mypmw5bqrgcyiohrtqsityqzuhfuk4sau@y4w4pbxgczg2>
	<8125d541-1116-46e6-a5b0-e47a390214f1@vondra.me>
	<sr6iqpvzy5y3mf33spoon25o77g3vkbab3xziroysgfx73q3kh@uwvfkpjs2t77>
	<812dc1c8-6533-4c59-afc2-819c37eb5c10@vondra.me>

Hi!

Here's a v3 of the patch, adopting the idea of passing the data through
a field in the scan descriptor. I've kept it broken into multiple
smaller pieces 0002-0006 to show the incremental changes, but in the end
this should be all merged.

0001 - a prerequisite patch

0002 - v2 of the patch

0003 - basic cleanup / helpers to reduce code duplication, various
smaller fixes and improvements, ...

0004 - changes the design to use a new field in TableScanDesc

0005 - minor cleanup of 0004

0006 - support for TidRangeScan (the last scan using ReadStream),
requires changes similar to SeqScan in v2


Overall, I think it looks reasonable. There are probably ways to
simplify it further (e.g. by introducing a new read_stream_begin_
helper, so that it's not necessary to update every caller of
read_stream_begin_relation by adding a NULL argument).

The 0003 also changes the EXPLAIN to enable IO by default, just like we
do for BUFFERS. It seems like a reasonable precedent to me.

0004 reworks the tracking to use a field in TableScanDesc, instead of
adding a table AM callback. I agree it seems simpler / less disruptive.

0005 is a minor cleanup for 0004

0006 adds EXPLAIN support for TidRangeScan, similarly to how 0002 adds
support for SeqScan (but that still uses the TAM interface)


regards

-- 
Tomas Vondra

Attachments:

  [text/x-patch] v3-0001-bufmgr-Return-whether-WaitReadBuffers-needed-to-w.patch (2.8K, ../bf1d4799-e2fd-464c-96ff-dc448f021b1e@vondra.me/2-v3-0001-bufmgr-Return-whether-WaitReadBuffers-needed-to-w.patch)
  download | inline diff:
From ad614a659df1742240b34212da7f9669b586d623 Mon Sep 17 00:00:00 2001
From: Andres Freund <andres@anarazel.de>
Date: Tue, 3 Mar 2026 16:50:50 -0500
Subject: [PATCH v3 1/6] bufmgr: Return whether WaitReadBuffers() needed to
 wait

In a subsequent commit read_stream.c will use this as an input to the read
ahead distance.

Author:
Reviewed-by:
Discussion: https://postgr.es/m/
Backpatch:
---
 src/backend/storage/buffer/bufmgr.c | 18 +++++++++++++++++-
 src/include/storage/bufmgr.h        |  2 +-
 2 files changed, 18 insertions(+), 2 deletions(-)

diff --git a/src/backend/storage/buffer/bufmgr.c b/src/backend/storage/buffer/bufmgr.c
index 00bc609529a..c8ec7fb4f53 100644
--- a/src/backend/storage/buffer/bufmgr.c
+++ b/src/backend/storage/buffer/bufmgr.c
@@ -1738,12 +1738,20 @@ ProcessReadBuffersResult(ReadBuffersOperation *operation)
 	Assert(operation->nblocks_done <= operation->nblocks);
 }
 
-void
+/*
+ * Wait for the IO operation initiated by StartReadBuffers() et al to
+ * complete.
+ *
+ * Returns whether the IO operation already had completed by the time of this
+ * call.
+ */
+bool
 WaitReadBuffers(ReadBuffersOperation *operation)
 {
 	PgAioReturn *aio_ret = &operation->io_return;
 	IOContext	io_context;
 	IOObject	io_object;
+	bool		needed_wait = false;
 
 	if (operation->persistence == RELPERSISTENCE_TEMP)
 	{
@@ -1805,6 +1813,7 @@ WaitReadBuffers(ReadBuffersOperation *operation)
 				instr_time	io_start = pgstat_prepare_io_time(track_io_timing);
 
 				pgaio_wref_wait(&operation->io_wref);
+				needed_wait = true;
 
 				/*
 				 * The IO operation itself was already counted earlier, in
@@ -1835,6 +1844,12 @@ WaitReadBuffers(ReadBuffersOperation *operation)
 
 		CHECK_FOR_INTERRUPTS();
 
+		/*
+		 * If the IO completed only partially, we need to perform additional
+		 * work, consider that a form of having had to wait.
+		 */
+		needed_wait = true;
+
 		/*
 		 * This may only complete the IO partially, either because some
 		 * buffers were already valid, or because of a partial read.
@@ -1851,6 +1866,7 @@ WaitReadBuffers(ReadBuffersOperation *operation)
 	CheckReadBuffersOperation(operation, true);
 
 	/* NB: READ_DONE tracepoint was already executed in completion callback */
+	return needed_wait;
 }
 
 /*
diff --git a/src/include/storage/bufmgr.h b/src/include/storage/bufmgr.h
index 4017896f951..a1ef04354dd 100644
--- a/src/include/storage/bufmgr.h
+++ b/src/include/storage/bufmgr.h
@@ -249,7 +249,7 @@ extern bool StartReadBuffers(ReadBuffersOperation *operation,
 							 BlockNumber blockNum,
 							 int *nblocks,
 							 int flags);
-extern void WaitReadBuffers(ReadBuffersOperation *operation);
+extern bool WaitReadBuffers(ReadBuffersOperation *operation);
 
 extern void ReleaseBuffer(Buffer buffer);
 extern void UnlockReleaseBuffer(Buffer buffer);
-- 
2.53.0



  [text/x-patch] v3-0002-explain-show-prefetch-stats-in-EXPLAIN-ANALYZE-VE.patch (35.1K, ../bf1d4799-e2fd-464c-96ff-dc448f021b1e@vondra.me/3-v3-0002-explain-show-prefetch-stats-in-EXPLAIN-ANALYZE-VE.patch)
  download | inline diff:
From e4b049b1db70480d86c6dd8eca398ff6211d7a84 Mon Sep 17 00:00:00 2001
From: test <test>
Date: Thu, 12 Mar 2026 02:04:07 +0100
Subject: [PATCH v3 2/6] explain: show prefetch stats in EXPLAIN (ANALYZE,
 VERBOSE)

This adds details about AIO / prefetch for a number of executor nodes
using the ReadStream, notably:

- SeqScan
- BitmapHeapScan

The statistics is tracked by the ReadStream, and then propagated up
through the table AM interface.

The ReadStream tracks the statistics unconditionally, i.e. even outside
EXPLAIN ANALYZE etc. The amount of statistics is trivial (a handful of
integer counters), it's not worth gating this by a flag.

The TAM gets one new callback "scan_stats", to collect stats from the
scan (which fetch tuples from the TAM). There is also a new struct
TableScanStatsData/TableScanStats to separate the statistics from the
actual TAM implementation.

This required improving SeqScan to properly setup shared instrumentation
between the leader and workers, collect it at the end of a scan, etc.
---
 doc/src/sgml/ref/explain.sgml             |  12 +
 src/backend/access/heap/heapam.c          |  32 +++
 src/backend/access/heap/heapam_handler.c  |   1 +
 src/backend/commands/explain.c            | 266 +++++++++++++++++++++-
 src/backend/commands/explain_state.c      |  10 +
 src/backend/executor/execParallel.c       |   3 +
 src/backend/executor/nodeBitmapHeapscan.c |  16 ++
 src/backend/executor/nodeSeqscan.c        | 123 +++++++++-
 src/backend/storage/aio/read_stream.c     |  64 +++++-
 src/include/access/heapam.h               |   1 +
 src/include/access/relscan.h              |  27 +++
 src/include/access/tableam.h              |  17 ++
 src/include/commands/explain_state.h      |   1 +
 src/include/executor/instrument_node.h    |  45 +++-
 src/include/executor/nodeSeqscan.h        |   1 +
 src/include/nodes/execnodes.h             |   2 +
 src/include/storage/read_stream.h         |   3 +
 17 files changed, 617 insertions(+), 7 deletions(-)

diff --git a/doc/src/sgml/ref/explain.sgml b/doc/src/sgml/ref/explain.sgml
index 7dee77fd366..9da16c77d73 100644
--- a/doc/src/sgml/ref/explain.sgml
+++ b/doc/src/sgml/ref/explain.sgml
@@ -46,6 +46,7 @@ EXPLAIN [ ( <replaceable class="parameter">option</replaceable> [, ...] ) ] <rep
     TIMING [ <replaceable class="parameter">boolean</replaceable> ]
     SUMMARY [ <replaceable class="parameter">boolean</replaceable> ]
     MEMORY [ <replaceable class="parameter">boolean</replaceable> ]
+    IO [ <replaceable class="parameter">boolean</replaceable> ]
     FORMAT { TEXT | XML | JSON | YAML }
 </synopsis>
  </refsynopsisdiv>
@@ -295,6 +296,17 @@ ROLLBACK;
     </listitem>
    </varlistentry>
 
+   <varlistentry>
+    <term><literal>IO</literal></term>
+    <listitem>
+     <para>
+      Include information on I/O performed by each node.
+      This parameter may only be used when <literal>ANALYZE</literal> is also
+      enabled.  It defaults to <literal>FALSE</literal>.
+     </para>
+    </listitem>
+   </varlistentry>
+
    <varlistentry>
     <term><literal>FORMAT</literal></term>
     <listitem>
diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c
index e5bd062de77..b13f843cfad 100644
--- a/src/backend/access/heap/heapam.c
+++ b/src/backend/access/heap/heapam.c
@@ -1416,6 +1416,38 @@ heap_endscan(TableScanDesc sscan)
 	pfree(scan);
 }
 
+/*
+ * heap_scan_stats
+ *		return stats collected by the read stream
+ *
+ * Returns NULL if the scan is not using a read stream.
+ */
+TableScanStats
+heap_scan_stats(TableScanDesc sscan)
+{
+	HeapScanDesc scan = (HeapScanDesc) sscan;
+	ReadStreamInstrumentation stats;
+	TableScanStats res;
+
+	if (!scan->rs_read_stream)
+		return NULL;
+
+	stats = read_stream_prefetch_stats(scan->rs_read_stream);
+
+	res = palloc0(sizeof(TableScanStatsData));
+
+	res->prefetch_count = stats.prefetch_count;
+	res->distance_sum = stats.distance_sum;
+	res->distance_max = stats.distance_max;
+	res->distance_capacity = stats.distance_capacity;
+	res->stall_count = stats.stall_count;
+	res->io_count = stats.io_count;
+	res->io_nblocks = stats.io_nblocks;
+	res->io_in_progress = stats.io_in_progress;
+
+	return res;
+}
+
 HeapTuple
 heap_getnext(TableScanDesc sscan, ScanDirection direction)
 {
diff --git a/src/backend/access/heap/heapam_handler.c b/src/backend/access/heap/heapam_handler.c
index 253a735b6c1..51ac2164afd 100644
--- a/src/backend/access/heap/heapam_handler.c
+++ b/src/backend/access/heap/heapam_handler.c
@@ -2650,6 +2650,7 @@ static const TableAmRoutine heapam_methods = {
 
 	.scan_set_tidrange = heap_set_tidrange,
 	.scan_getnextslot_tidrange = heap_getnextslot_tidrange,
+	.scan_stats = heap_scan_stats,
 
 	.parallelscan_estimate = table_block_parallelscan_estimate,
 	.parallelscan_initialize = table_block_parallelscan_initialize,
diff --git a/src/backend/commands/explain.c b/src/backend/commands/explain.c
index 296ea8a1ed2..ad86fb752ca 100644
--- a/src/backend/commands/explain.c
+++ b/src/backend/commands/explain.c
@@ -13,6 +13,8 @@
  */
 #include "postgres.h"
 
+#include "access/genam.h"
+#include "access/tableam.h"
 #include "access/xact.h"
 #include "catalog/pg_type.h"
 #include "commands/createas.h"
@@ -139,6 +141,11 @@ static void show_hashagg_info(AggState *aggstate, ExplainState *es);
 static void show_indexsearches_info(PlanState *planstate, ExplainState *es);
 static void show_tidbitmap_info(BitmapHeapScanState *planstate,
 								ExplainState *es);
+static void show_scan_io_info(ScanState *planstate,
+							  ExplainState *es);
+static void show_worker_io_info(PlanState *planstate,
+								ExplainState *es,
+								int worker);
 static void show_instrumentation_count(const char *qlabel, int which,
 									   PlanState *planstate, ExplainState *es);
 static void show_foreignscan_info(ForeignScanState *fsstate, ExplainState *es);
@@ -2009,6 +2016,7 @@ ExplainNode(PlanState *planstate, List *ancestors,
 				show_instrumentation_count("Rows Removed by Filter", 1,
 										   planstate, es);
 			show_tidbitmap_info((BitmapHeapScanState *) planstate, es);
+			show_scan_io_info((ScanState *) planstate, es);
 			break;
 		case T_SampleScan:
 			show_tablesample(((SampleScan *) plan)->tablesample,
@@ -2027,6 +2035,7 @@ ExplainNode(PlanState *planstate, List *ancestors,
 										   planstate, es);
 			if (IsA(plan, CteScan))
 				show_ctescan_info(castNode(CteScanState, planstate), es);
+			show_scan_io_info((ScanState *) planstate, es);
 			break;
 		case T_Gather:
 			{
@@ -2297,8 +2306,8 @@ ExplainNode(PlanState *planstate, List *ancestors,
 	if (es->wal && planstate->instrument)
 		show_wal_usage(es, &planstate->instrument->walusage);
 
-	/* Prepare per-worker buffer/WAL usage */
-	if (es->workers_state && (es->buffers || es->wal) && es->verbose)
+	/* Prepare per-worker buffer/WAL/IO usage */
+	if (es->workers_state && (es->buffers || es->wal || es->io) && es->verbose)
 	{
 		WorkerInstrumentation *w = planstate->worker_instrument;
 
@@ -2315,6 +2324,10 @@ ExplainNode(PlanState *planstate, List *ancestors,
 				show_buffer_usage(es, &instrument->bufusage);
 			if (es->wal)
 				show_wal_usage(es, &instrument->walusage);
+
+			/* show prefetch info for the given worker */
+			show_worker_io_info(planstate, es, n);
+
 			ExplainCloseWorker(n, es);
 		}
 	}
@@ -3985,6 +3998,255 @@ show_tidbitmap_info(BitmapHeapScanState *planstate, ExplainState *es)
 	}
 }
 
+/*
+ * show_scan_io_info
+ *		show info about prefetching for a seq/bitmap scan
+ *
+ * Shows summary of stats for leader and workers (if any).
+ */
+static void
+show_scan_io_info(ScanState *planstate, ExplainState *es)
+{
+	Plan	   *plan = planstate->ps.plan;
+	TableScanStats	leader_stats;
+	TableScanStatsData	stats;
+
+	if (!es->io)
+		return;
+
+	/* scan not started, no prefetch stats */
+	if (!(planstate && planstate->ss_currentScanDesc))
+		return;
+
+	/* collect prefetch statistics from the read stream */
+	leader_stats = table_scan_stats(planstate->ss_currentScanDesc);
+
+	if (leader_stats)
+	{
+		memcpy(&stats, leader_stats, sizeof(TableScanStatsData));
+	}
+	else
+	{
+		memset(&stats, 0, sizeof(TableScanStatsData));
+	}
+
+	/* Initialize counters with stats from the local process first */
+	switch (nodeTag(plan))
+	{
+		case T_SeqScan:
+			{
+				SharedSeqScanInstrumentation *sinstrument
+					= ((SeqScanState *) planstate)->sinstrument;
+
+				/* get the sum of the counters set within each and every process */
+				if (sinstrument)
+				{
+					for (int i = 0; i < sinstrument->num_workers; ++i)
+					{
+						SeqScanInstrumentation *winstrument = &sinstrument->sinstrument[i];
+
+						stats.prefetch_count += winstrument->stream.prefetch_count;
+						stats.distance_sum += winstrument->stream.distance_sum;
+						if (winstrument->stream.distance_max > stats.distance_max)
+							stats.distance_max = winstrument->stream.distance_max;
+						if (winstrument->stream.distance_capacity > stats.distance_capacity)
+							stats.distance_capacity = winstrument->stream.distance_capacity;
+						stats.stall_count += winstrument->stream.stall_count;
+						stats.io_count += winstrument->stream.io_count;
+						stats.io_nblocks += winstrument->stream.io_nblocks;
+						stats.io_in_progress += winstrument->stream.io_in_progress;
+					}
+				}
+
+				break;
+			}
+		case T_BitmapHeapScan:
+			{
+				SharedBitmapHeapInstrumentation *sinstrument
+					= ((BitmapHeapScanState *) planstate)->sinstrument;
+
+				/* get the sum of the counters set within each and every process */
+				if (sinstrument)
+				{
+					for (int i = 0; i < sinstrument->num_workers; ++i)
+					{
+						BitmapHeapScanInstrumentation *winstrument = &sinstrument->sinstrument[i];
+
+						stats.prefetch_count += winstrument->stream.prefetch_count;
+						stats.distance_sum += winstrument->stream.distance_sum;
+						if (winstrument->stream.distance_max > stats.distance_max)
+							stats.distance_max = winstrument->stream.distance_max;
+						if (winstrument->stream.distance_capacity > stats.distance_capacity)
+							stats.distance_capacity = winstrument->stream.distance_capacity;
+						stats.stall_count += winstrument->stream.stall_count;
+						stats.io_count += winstrument->stream.io_count;
+						stats.io_nblocks += winstrument->stream.io_nblocks;
+						stats.io_in_progress += winstrument->stream.io_in_progress;
+					}
+				}
+
+				break;
+			}
+		default:
+			/* ignore other plans */
+			return;
+	}
+
+	/* don't print anything without prefetching */
+	if (stats.prefetch_count > 0)
+	{
+		if (es->format == EXPLAIN_FORMAT_TEXT)
+		{
+			/* prefetch distance info */
+			ExplainIndentText(es);
+			appendStringInfo(es->str, "Prefetch: avg=%.3f max=%d capacity=%d",
+							 (stats.distance_sum * 1.0 / stats.prefetch_count),
+							 stats.distance_max,
+							 stats.distance_capacity);
+			appendStringInfoChar(es->str, '\n');
+
+			/* prefetch I/O info (only if there were actual I/Os) */
+			if (stats.stall_count > 0 || stats.io_count > 0)
+			{
+				ExplainIndentText(es);
+				appendStringInfo(es->str, "I/O: stalls=%" PRIu64,
+								 stats.stall_count);
+
+				if (stats.io_count > 0)
+				{
+					appendStringInfo(es->str, " size=%.3f inprogress=%.3f",
+									 (stats.io_nblocks * 1.0 / stats.io_count),
+									 (stats.io_in_progress * 1.0 / stats.io_count));
+				}
+
+				appendStringInfoChar(es->str, '\n');
+			}
+		}
+		else
+		{
+			ExplainOpenGroup("Prefetch", "I/O", true, es);
+
+			ExplainPropertyFloat("Average Distance", NULL,
+								 (stats.distance_sum * 1.0 / stats.prefetch_count), 3, es);
+			ExplainPropertyInteger("Max Distance", NULL,
+								   stats.distance_max, es);
+			ExplainPropertyInteger("Capacity", NULL,
+								   stats.distance_capacity, es);
+			ExplainPropertyUInteger("Stalls", NULL,
+									stats.stall_count, es);
+
+			if (stats.io_count > 0)
+			{
+				ExplainPropertyFloat("Average IO Size", NULL,
+									 (stats.io_nblocks * 1.0 / stats.io_count), 3, es);
+				ExplainPropertyFloat("Average IOs In Progress", NULL,
+									 (stats.io_in_progress * 1.0 / stats.io_count), 3, es);
+			}
+
+			ExplainCloseGroup("Prefetch", "I/O", true, es);
+		}
+	}
+}
+
+/*
+ * show_io_worker_info
+ *		show info about prefetching for a single worker
+ *
+ * Shows prefetching stats for a parallel scan worker.
+ */
+static void
+show_worker_io_info(PlanState *planstate, ExplainState *es, int worker)
+{
+	Plan	   *plan = planstate->plan;
+	ReadStreamInstrumentation *stats = NULL;
+
+	if (!es->io)
+		return;
+
+	/* get instrumentation for the given worker */
+	switch (nodeTag(plan))
+	{
+		case T_BitmapHeapScan:
+			{
+				BitmapHeapScanState *state = ((BitmapHeapScanState *) planstate);
+				SharedBitmapHeapInstrumentation *sinstrument = state->sinstrument;
+				BitmapHeapScanInstrumentation *instrument = &sinstrument->sinstrument[worker];
+
+				stats = &instrument->stream;
+
+				break;
+			}
+		case T_SeqScan:
+			{
+				SeqScanState *state = ((SeqScanState *) planstate);
+				SharedSeqScanInstrumentation *sinstrument = state->sinstrument;
+				SeqScanInstrumentation *instrument = &sinstrument->sinstrument[worker];
+
+				stats = &instrument->stream;
+
+				break;
+			}
+		default:
+			/* ignore other plans */
+			return;
+	}
+
+	/* don't print stats if there's nothing to report */
+	if (stats->prefetch_count > 0)
+	{
+		if (es->format == EXPLAIN_FORMAT_TEXT)
+		{
+			/* prefetch distance info */
+			ExplainIndentText(es);
+			appendStringInfo(es->str, "Prefetch: avg=%.3f max=%d capacity=%d",
+							 (stats->distance_sum * 1.0 / stats->prefetch_count),
+							 stats->distance_max,
+							 stats->distance_capacity);
+			appendStringInfoChar(es->str, '\n');
+
+			/* prefetch I/O info (only if there were actual I/Os) */
+			if (stats->stall_count > 0 || stats->io_count > 0)
+			{
+				ExplainIndentText(es);
+				appendStringInfo(es->str, "I/O: stalls=%" PRIu64,
+								 stats->stall_count);
+
+				if (stats->io_count > 0)
+				{
+					appendStringInfo(es->str, " size=%.3f inprogress=%.3f",
+									 (stats->io_nblocks * 1.0 / stats->io_count),
+									 (stats->io_in_progress * 1.0 / stats->io_count));
+				}
+
+				appendStringInfoChar(es->str, '\n');
+			}
+		}
+		else
+		{
+			ExplainOpenGroup("Prefetch", "I/O", true, es);
+
+			ExplainPropertyFloat("Average Distance", NULL,
+								 (stats->distance_sum * 1.0 / stats->prefetch_count), 3, es);
+			ExplainPropertyInteger("Max Distance", NULL,
+								   stats->distance_max, es);
+			ExplainPropertyInteger("Capacity", NULL,
+								   stats->distance_capacity, es);
+			ExplainPropertyUInteger("Stalls", NULL,
+									stats->stall_count, es);
+
+			if (stats->io_count > 0)
+			{
+				ExplainPropertyFloat("Average IO Size", NULL,
+									 (stats->io_nblocks * 1.0 / stats->io_count), 3, es);
+				ExplainPropertyFloat("Average IOs In Progress", NULL,
+									 (stats->io_in_progress * 1.0 / stats->io_count), 3, es);
+			}
+
+			ExplainCloseGroup("Prefetch", "I/O", true, es);
+		}
+	}
+}
+
 /*
  * If it's EXPLAIN ANALYZE, show instrumentation information for a plan node
  *
diff --git a/src/backend/commands/explain_state.c b/src/backend/commands/explain_state.c
index 77f59b8e500..f5cbdd21aaa 100644
--- a/src/backend/commands/explain_state.c
+++ b/src/backend/commands/explain_state.c
@@ -159,6 +159,10 @@ ParseExplainOptionList(ExplainState *es, List *options, ParseState *pstate)
 								"EXPLAIN", opt->defname, p),
 						 parser_errposition(pstate, opt->location)));
 		}
+		else if (strcmp(opt->defname, "io") == 0)
+		{
+			es->io = defGetBoolean(opt);
+		}
 		else if (!ApplyExtensionExplainOption(es, opt, pstate))
 			ereport(ERROR,
 					(errcode(ERRCODE_SYNTAX_ERROR),
@@ -185,6 +189,12 @@ ParseExplainOptionList(ExplainState *es, List *options, ParseState *pstate)
 				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
 				 errmsg("EXPLAIN option %s requires ANALYZE", "TIMING")));
 
+	/* check that IO is used with EXPLAIN ANALYZE */
+	if (es->io && !es->analyze)
+		ereport(ERROR,
+				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				 errmsg("EXPLAIN option %s requires ANALYZE", "IO")));
+
 	/* check that serialize is used with EXPLAIN ANALYZE */
 	if (es->serialize != EXPLAIN_SERIALIZE_NONE && !es->analyze)
 		ereport(ERROR,
diff --git a/src/backend/executor/execParallel.c b/src/backend/executor/execParallel.c
index ac84af294c9..ffc708ed6be 100644
--- a/src/backend/executor/execParallel.c
+++ b/src/backend/executor/execParallel.c
@@ -1119,6 +1119,9 @@ ExecParallelRetrieveInstrumentation(PlanState *planstate,
 		case T_BitmapHeapScanState:
 			ExecBitmapHeapRetrieveInstrumentation((BitmapHeapScanState *) planstate);
 			break;
+		case T_SeqScanState:
+			ExecSeqScanRetrieveInstrumentation((SeqScanState *) planstate);
+			break;
 		default:
 			break;
 	}
diff --git a/src/backend/executor/nodeBitmapHeapscan.c b/src/backend/executor/nodeBitmapHeapscan.c
index 7cf8d23c742..bf9af7596ce 100644
--- a/src/backend/executor/nodeBitmapHeapscan.c
+++ b/src/backend/executor/nodeBitmapHeapscan.c
@@ -315,6 +315,7 @@ ExecEndBitmapHeapScan(BitmapHeapScanState *node)
 	if (node->sinstrument != NULL && IsParallelWorker())
 	{
 		BitmapHeapScanInstrumentation *si;
+		TableScanStats stats;
 
 		Assert(ParallelWorkerNumber < node->sinstrument->num_workers);
 		si = &node->sinstrument->sinstrument[ParallelWorkerNumber];
@@ -328,6 +329,21 @@ ExecEndBitmapHeapScan(BitmapHeapScanState *node)
 		 */
 		si->exact_pages += node->stats.exact_pages;
 		si->lossy_pages += node->stats.lossy_pages;
+
+		/* collect prefetch info for this process from the read_stream */
+		if ((stats = table_scan_stats(node->ss.ss_currentScanDesc)) != NULL)
+		{
+			si->stream.prefetch_count += stats->prefetch_count;
+			si->stream.distance_sum += stats->distance_sum;
+			if (stats->distance_max > si->stream.distance_max)
+				si->stream.distance_max = stats->distance_max;
+			if (stats->distance_capacity > si->stream.distance_capacity)
+				si->stream.distance_capacity = stats->distance_capacity;
+			si->stream.stall_count += stats->stall_count;
+			si->stream.io_count += stats->io_count;
+			si->stream.io_nblocks += stats->io_nblocks;
+			si->stream.io_in_progress += stats->io_in_progress;
+		}
 	}
 
 	/*
diff --git a/src/backend/executor/nodeSeqscan.c b/src/backend/executor/nodeSeqscan.c
index 8f219f60a93..7992946a5be 100644
--- a/src/backend/executor/nodeSeqscan.c
+++ b/src/backend/executor/nodeSeqscan.c
@@ -295,6 +295,43 @@ ExecEndSeqScan(SeqScanState *node)
 {
 	TableScanDesc scanDesc;
 
+	/*
+	 * When ending a parallel worker, copy the statistics gathered by the
+	 * worker back into shared memory so that it can be picked up by the main
+	 * process to report in EXPLAIN ANALYZE.
+	 */
+	if (node->sinstrument != NULL && IsParallelWorker())
+	{
+		SeqScanInstrumentation *si;
+		TableScanStats stats;
+
+		Assert(ParallelWorkerNumber < node->sinstrument->num_workers);
+		si = &node->sinstrument->sinstrument[ParallelWorkerNumber];
+
+		/*
+		 * Here we accumulate the stats rather than performing memcpy on
+		 * node->stats into si.  When a Gather/GatherMerge node finishes it
+		 * will perform planner shutdown on the workers.  On rescan it will
+		 * spin up new workers which will have a new SeqScanState and
+		 * zeroed stats.
+		 */
+
+		/* collect prefetch info for this process from the read_stream */
+		if ((stats = table_scan_stats(node->ss.ss_currentScanDesc)) != NULL)
+		{
+			si->stream.prefetch_count += stats->prefetch_count;
+			si->stream.distance_sum += stats->distance_sum;
+			if (stats->distance_max > si->stream.distance_max)
+				si->stream.distance_max = stats->distance_max;
+			if (stats->distance_capacity > si->stream.distance_capacity)
+				si->stream.distance_capacity = stats->distance_capacity;
+			si->stream.stall_count += stats->stall_count;
+			si->stream.io_count += stats->io_count;
+			si->stream.io_nblocks += stats->io_nblocks;
+			si->stream.io_in_progress += stats->io_in_progress;
+		}
+	}
+
 	/*
 	 * get information from node
 	 */
@@ -349,10 +386,23 @@ ExecSeqScanEstimate(SeqScanState *node,
 					ParallelContext *pcxt)
 {
 	EState	   *estate = node->ss.ps.state;
+	Size		size;
 
-	node->pscan_len = table_parallelscan_estimate(node->ss.ss_currentRelation,
+	size = table_parallelscan_estimate(node->ss.ss_currentRelation,
 												  estate->es_snapshot);
-	shm_toc_estimate_chunk(&pcxt->estimator, node->pscan_len);
+	node->pscan_len = size;
+
+	/* make sure the instrumentation is properly aligned */
+	size = MAXALIGN(size);
+
+	/* account for instrumentation, if required */
+	if (node->ss.ps.instrument && pcxt->nworkers > 0)
+	{
+		size = add_size(size, offsetof(SharedSeqScanInstrumentation, sinstrument));
+		size = add_size(size, mul_size(pcxt->nworkers, sizeof(SeqScanInstrumentation)));
+	}
+
+	shm_toc_estimate_chunk(&pcxt->estimator, size);
 	shm_toc_estimate_keys(&pcxt->estimator, 1);
 }
 
@@ -368,14 +418,42 @@ ExecSeqScanInitializeDSM(SeqScanState *node,
 {
 	EState	   *estate = node->ss.ps.state;
 	ParallelTableScanDesc pscan;
+	SharedSeqScanInstrumentation *sinstrument = NULL;
+	Size		size;
+	char	   *ptr;
+
+	/* Recalculate the size. This needs to match ExecSeqScanEstimate. */
+	size = MAXALIGN(node->pscan_len);
+	if (node->ss.ps.instrument && pcxt->nworkers > 0)
+	{
+		size = add_size(size, offsetof(SharedSeqScanInstrumentation, sinstrument));
+		size = add_size(size, mul_size(pcxt->nworkers, sizeof(SeqScanInstrumentation)));
+	}
 
-	pscan = shm_toc_allocate(pcxt->toc, node->pscan_len);
+	pscan = shm_toc_allocate(pcxt->toc, size);
 	table_parallelscan_initialize(node->ss.ss_currentRelation,
 								  pscan,
 								  estate->es_snapshot);
 	shm_toc_insert(pcxt->toc, node->ss.ps.plan->plan_node_id, pscan);
 	node->ss.ss_currentScanDesc =
 		table_beginscan_parallel(node->ss.ss_currentRelation, pscan);
+
+	/* initialize the shared instrumentation (with correct alignment) */
+	ptr = (char *) pscan;
+	ptr += MAXALIGN(node->pscan_len);
+	if (node->ss.ps.instrument && pcxt->nworkers > 0)
+		sinstrument = (SharedSeqScanInstrumentation *) ptr;
+
+	if (sinstrument)
+	{
+		sinstrument->num_workers = pcxt->nworkers;
+
+		/* ensure any unfilled slots will contain zeroes */
+		memset(sinstrument->sinstrument, 0,
+			   pcxt->nworkers * sizeof(SeqScanInstrumentation));
+	}
+
+	node->sinstrument = sinstrument;
 }
 
 /* ----------------------------------------------------------------
@@ -404,9 +482,48 @@ void
 ExecSeqScanInitializeWorker(SeqScanState *node,
 							ParallelWorkerContext *pwcxt)
 {
+	EState	   *estate = node->ss.ps.state;
 	ParallelTableScanDesc pscan;
+	char	   *ptr;
+	Size		size;
 
 	pscan = shm_toc_lookup(pwcxt->toc, node->ss.ps.plan->plan_node_id, false);
 	node->ss.ss_currentScanDesc =
 		table_beginscan_parallel(node->ss.ss_currentRelation, pscan);
+
+	/*
+	 * Workers don't get the pscan_len value in scan descriptor, so use the
+	 * TAM callback again. The result has to match the earlier result in
+	 * ExecSeqScanEstimate.
+	 */
+	size = table_parallelscan_estimate(node->ss.ss_currentRelation,
+									   estate->es_snapshot);
+
+	ptr = (char *) pscan;
+	ptr += MAXALIGN(size);
+
+	if (node->ss.ps.instrument)
+		node->sinstrument = (SharedSeqScanInstrumentation *) ptr;
+}
+
+/* ----------------------------------------------------------------
+ *		ExecSeqScanRetrieveInstrumentation
+ *
+ *		Transfer seq scan statistics from DSM to private memory.
+ * ----------------------------------------------------------------
+ */
+void
+ExecSeqScanRetrieveInstrumentation(SeqScanState *node)
+{
+	SharedSeqScanInstrumentation *sinstrument = node->sinstrument;
+	Size		size;
+
+	if (sinstrument == NULL)
+		return;
+
+	size = offsetof(SharedSeqScanInstrumentation, sinstrument)
+		+ sinstrument->num_workers * sizeof(SeqScanInstrumentation);
+
+	node->sinstrument = palloc(size);
+	memcpy(node->sinstrument, sinstrument, size);
 }
diff --git a/src/backend/storage/aio/read_stream.c b/src/backend/storage/aio/read_stream.c
index cd54c1a74ac..f560047afd6 100644
--- a/src/backend/storage/aio/read_stream.c
+++ b/src/backend/storage/aio/read_stream.c
@@ -72,6 +72,7 @@
 #include "postgres.h"
 
 #include "miscadmin.h"
+#include "executor/instrument_node.h"
 #include "storage/aio.h"
 #include "storage/fd.h"
 #include "storage/smgr.h"
@@ -107,6 +108,9 @@ struct ReadStream
 	bool		advice_enabled;
 	bool		temporary;
 
+	/* stats counters */
+	ReadStreamInstrumentation stats;
+
 	/*
 	 * One-block buffer to support 'ungetting' a block number, to resolve flow
 	 * control problems when I/Os are split.
@@ -172,6 +176,38 @@ block_range_read_stream_cb(ReadStream *stream,
 	return InvalidBlockNumber;
 }
 
+/*
+ * read_stream_update_stats_prefetch
+ *		update read_stream stats with current pinned buffer depth
+ *
+ * Called once per buffer returned to the consumer in read_stream_next_buffer().
+ * Records the number of pinned buffers at that moment, so we can compute the
+ * average look-ahead depth.
+ */
+static inline void
+read_stream_update_stats_prefetch(ReadStream *stream)
+{
+	stream->stats.prefetch_count++;
+	stream->stats.distance_sum += stream->pinned_buffers;
+	if (stream->pinned_buffers > stream->stats.distance_max)
+		stream->stats.distance_max = stream->pinned_buffers;
+}
+
+/*
+ * read_stream_update_stats_io
+ *		update read_stream stats about size of I/O requests
+ *
+ * We count the number of I/O requests, size of requests (counted in blocks)
+ * and number of in-progress I/Os.
+ */
+static inline void
+read_stream_update_stats_io(ReadStream *stream, int nblocks, int in_progress)
+{
+	stream->stats.io_count++;
+	stream->stats.io_nblocks += nblocks;
+	stream->stats.io_in_progress += in_progress;
+}
+
 /*
  * Ask the callback which block it would like us to read next, with a one block
  * buffer in front to allow read_stream_unget_block() to work.
@@ -380,6 +416,9 @@ read_stream_start_pending_read(ReadStream *stream)
 		Assert(stream->ios_in_progress < stream->max_ios);
 		stream->ios_in_progress++;
 		stream->seq_blocknum = stream->pending_read_blocknum + nblocks;
+
+		/* update I/O stats */
+		read_stream_update_stats_io(stream, nblocks, stream->ios_in_progress);
 	}
 
 	/*
@@ -703,6 +742,10 @@ read_stream_begin_impl(int flags,
 	stream->seq_until_processed = InvalidBlockNumber;
 	stream->temporary = SmgrIsTemp(smgr);
 
+	/* zero the stats, then set capacity */
+	memset(&stream->stats, 0, sizeof(ReadStreamInstrumentation));
+	stream->stats.distance_capacity = max_pinned_buffers;
+
 	/*
 	 * Skip the initial ramp-up phase if the caller says we're going to be
 	 * reading the whole relation.  This way we start out assuming we'll be
@@ -851,6 +894,7 @@ read_stream_next_buffer(ReadStream *stream, void **per_buffer_data)
 										flags)))
 			{
 				/* Fast return. */
+				read_stream_update_stats_prefetch(stream);
 				return buffer;
 			}
 
@@ -860,6 +904,9 @@ read_stream_next_buffer(ReadStream *stream, void **per_buffer_data)
 			stream->ios_in_progress = 1;
 			stream->ios[0].buffer_index = oldest_buffer_index;
 			stream->seq_blocknum = next_blocknum + 1;
+
+			/* update I/O stats */
+			read_stream_update_stats_io(stream, 1, stream->ios_in_progress);
 		}
 		else
 		{
@@ -871,6 +918,7 @@ read_stream_next_buffer(ReadStream *stream, void **per_buffer_data)
 		}
 
 		stream->fast_path = false;
+		read_stream_update_stats_prefetch(stream);
 		return buffer;
 	}
 #endif
@@ -916,12 +964,17 @@ read_stream_next_buffer(ReadStream *stream, void **per_buffer_data)
 	{
 		int16		io_index = stream->oldest_io_index;
 		int32		distance;	/* wider temporary value, clamped below */
+		bool		needed_wait;
 
 		/* Sanity check that we still agree on the buffers. */
 		Assert(stream->ios[io_index].op.buffers ==
 			   &stream->buffers[oldest_buffer_index]);
 
-		WaitReadBuffers(&stream->ios[io_index].op);
+		needed_wait = WaitReadBuffers(&stream->ios[io_index].op);
+
+		/* Count it as a stall if we need to wait for IO */
+		if (needed_wait)
+			stream->stats.stall_count += 1;
 
 		Assert(stream->ios_in_progress > 0);
 		stream->ios_in_progress--;
@@ -981,6 +1034,8 @@ read_stream_next_buffer(ReadStream *stream, void **per_buffer_data)
 	}
 #endif
 
+	read_stream_update_stats_prefetch(stream);
+
 	/* Pin transferred to caller. */
 	Assert(stream->pinned_buffers > 0);
 	stream->pinned_buffers--;
@@ -1118,3 +1173,10 @@ read_stream_end(ReadStream *stream)
 	read_stream_reset(stream);
 	pfree(stream);
 }
+
+/* return the prefetch stats for the read_stream */
+ReadStreamInstrumentation
+read_stream_prefetch_stats(ReadStream *stream)
+{
+	return stream->stats;
+}
diff --git a/src/include/access/heapam.h b/src/include/access/heapam.h
index 2fdc50b865b..81dc0b7521d 100644
--- a/src/include/access/heapam.h
+++ b/src/include/access/heapam.h
@@ -359,6 +359,7 @@ extern void heap_prepare_pagescan(TableScanDesc sscan);
 extern void heap_rescan(TableScanDesc sscan, ScanKey key, bool set_params,
 						bool allow_strat, bool allow_sync, bool allow_pagemode);
 extern void heap_endscan(TableScanDesc sscan);
+extern TableScanStats heap_scan_stats(TableScanDesc sscan);
 extern HeapTuple heap_getnext(TableScanDesc sscan, ScanDirection direction);
 extern bool heap_getnextslot(TableScanDesc sscan,
 							 ScanDirection direction, TupleTableSlot *slot);
diff --git a/src/include/access/relscan.h b/src/include/access/relscan.h
index ce340c076f8..cada9f28bd8 100644
--- a/src/include/access/relscan.h
+++ b/src/include/access/relscan.h
@@ -114,6 +114,33 @@ typedef struct ParallelBlockTableScanWorkerData
 } ParallelBlockTableScanWorkerData;
 typedef struct ParallelBlockTableScanWorkerData *ParallelBlockTableScanWorker;
 
+/*
+ * Generic prefetch stats for table scans.
+ */
+typedef struct TableScanStatsData
+{
+	/* number of buffers returned to consumer (for averaging distance) */
+	uint64		prefetch_count;
+
+	/* sum of pinned_buffers sampled at each buffer return */
+	uint64		distance_sum;
+
+	/* maximum actual pinned_buffers observed during the scan */
+	int16		distance_max;
+
+	/* maximum possible look-ahead distance (max_pinned_buffers) */
+	int16		distance_capacity;
+
+	/* number of stalled reads (waiting for I/O) */
+	uint64		stall_count;
+
+	/* I/O stats */
+	uint64		io_count;		/* number of I/Os */
+	uint64		io_nblocks;		/* sum of blocks for all I/Os */
+	uint64		io_in_progress;	/* sum of in-progress I/Os */
+} TableScanStatsData;
+typedef struct TableScanStatsData *TableScanStats;
+
 /*
  * Base class for fetches from a table via an index. This is the base-class
  * for such scans, which needs to be embedded in the respective struct for
diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h
index 06084752245..ab84b76443c 100644
--- a/src/include/access/tableam.h
+++ b/src/include/access/tableam.h
@@ -380,6 +380,11 @@ typedef struct TableAmRoutine
 											  ScanDirection direction,
 											  TupleTableSlot *slot);
 
+	/*
+	 * Collect statistics about table scan.
+	 */
+	TableScanStats		(*scan_stats) (TableScanDesc scan);
+
 	/* ------------------------------------------------------------------------
 	 * Parallel table scan related functions.
 	 * ------------------------------------------------------------------------
@@ -1006,6 +1011,18 @@ table_endscan(TableScanDesc scan)
 	scan->rs_rd->rd_tableam->scan_end(scan);
 }
 
+/*
+ * Fetch statistics about table scan.
+ */
+static inline TableScanStats
+table_scan_stats(TableScanDesc scan)
+{
+	if (scan->rs_rd->rd_tableam->scan_stats)
+		return scan->rs_rd->rd_tableam->scan_stats(scan);
+
+	return NULL;
+}
+
 /*
  * Restart a relation scan.
  */
diff --git a/src/include/commands/explain_state.h b/src/include/commands/explain_state.h
index 5a48bc6fbb1..b412f00f70c 100644
--- a/src/include/commands/explain_state.h
+++ b/src/include/commands/explain_state.h
@@ -55,6 +55,7 @@ typedef struct ExplainState
 	bool		summary;		/* print total planning and execution timing */
 	bool		memory;			/* print planner's memory usage information */
 	bool		settings;		/* print modified settings */
+	bool		io;				/* print info about IO (prefetch, ...) */
 	bool		generic;		/* generate a generic plan */
 	ExplainSerializeOption serialize;	/* serialize the query's output? */
 	ExplainFormat format;		/* output format */
diff --git a/src/include/executor/instrument_node.h b/src/include/executor/instrument_node.h
index 8847d7f94fa..d94dbbc917d 100644
--- a/src/include/executor/instrument_node.h
+++ b/src/include/executor/instrument_node.h
@@ -41,9 +41,51 @@ typedef struct SharedAggInfo
 
 
 /* ---------------------
- *	Instrumentation information for indexscans (amgettuple and amgetbitmap)
+ *	Instrumentation information about read streams
  * ---------------------
  */
+typedef struct ReadStreamInstrumentation
+{
+	/* number of buffers returned to consumer (for averaging distance) */
+	uint64		prefetch_count;
+
+	/* sum of pinned_buffers sampled at each buffer return */
+	uint64		distance_sum;
+
+	/* maximum actual pinned_buffers observed during the scan */
+	int16		distance_max;
+
+	/* maximum possible look-ahead distance (max_pinned_buffers) */
+	int16		distance_capacity;
+
+	/* number of stalled reads (waiting for I/O) */
+	uint64		stall_count;
+
+	/* I/O stats */
+	uint64		io_count;		/* number of I/Os */
+	uint64		io_nblocks;		/* sum of blocks for all I/Os */
+	uint64		io_in_progress;	/* sum of in-progress I/Os */
+} ReadStreamInstrumentation;
+
+
+/* ---------------------
+ *	Instrumentation information for sequential scans
+ * ---------------------
+ */
+typedef struct SeqScanInstrumentation
+{
+	ReadStreamInstrumentation	stream;
+} SeqScanInstrumentation;
+
+/*
+ * Shared memory container for per-worker information
+ */
+typedef struct SharedSeqScanInstrumentation
+{
+	int			num_workers;
+	SeqScanInstrumentation sinstrument[FLEXIBLE_ARRAY_MEMBER];
+} SharedSeqScanInstrumentation;
+
 typedef struct IndexScanInstrumentation
 {
 	/* Index search count (incremented with pgstat_count_index_scan call) */
@@ -71,6 +113,7 @@ typedef struct BitmapHeapScanInstrumentation
 {
 	uint64		exact_pages;
 	uint64		lossy_pages;
+	ReadStreamInstrumentation	stream;
 } BitmapHeapScanInstrumentation;
 
 /*
diff --git a/src/include/executor/nodeSeqscan.h b/src/include/executor/nodeSeqscan.h
index 7a1490596fb..e2122bfffe3 100644
--- a/src/include/executor/nodeSeqscan.h
+++ b/src/include/executor/nodeSeqscan.h
@@ -27,5 +27,6 @@ extern void ExecSeqScanInitializeDSM(SeqScanState *node, ParallelContext *pcxt);
 extern void ExecSeqScanReInitializeDSM(SeqScanState *node, ParallelContext *pcxt);
 extern void ExecSeqScanInitializeWorker(SeqScanState *node,
 										ParallelWorkerContext *pwcxt);
+extern void ExecSeqScanRetrieveInstrumentation(SeqScanState *node);
 
 #endif							/* NODESEQSCAN_H */
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index 0716c5a9aed..96a8a75031f 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -1644,6 +1644,8 @@ typedef struct SeqScanState
 {
 	ScanState	ss;				/* its first field is NodeTag */
 	Size		pscan_len;		/* size of parallel heap scan descriptor */
+	SeqScanInstrumentation	stats;
+	SharedSeqScanInstrumentation *sinstrument;
 } SeqScanState;
 
 /* ----------------
diff --git a/src/include/storage/read_stream.h b/src/include/storage/read_stream.h
index c9359b29b0f..ec396db5369 100644
--- a/src/include/storage/read_stream.h
+++ b/src/include/storage/read_stream.h
@@ -65,6 +65,7 @@
 
 struct ReadStream;
 typedef struct ReadStream ReadStream;
+typedef struct ReadStreamInstrumentation ReadStreamInstrumentation;
 
 /* for block_range_read_stream_cb */
 typedef struct BlockRangeReadStreamPrivate
@@ -104,4 +105,6 @@ extern void read_stream_resume(ReadStream *stream);
 extern void read_stream_reset(ReadStream *stream);
 extern void read_stream_end(ReadStream *stream);
 
+extern ReadStreamInstrumentation read_stream_prefetch_stats(ReadStream *stream);
+
 #endif							/* READ_STREAM_H */
-- 
2.53.0



  [text/x-patch] v3-0003-cleanup-and-simplification.patch (62.9K, ../bf1d4799-e2fd-464c-96ff-dc448f021b1e@vondra.me/4-v3-0003-cleanup-and-simplification.patch)
  download | inline diff:
From f278bd14ca6fcd27b02ebb14feed797d0da721ad Mon Sep 17 00:00:00 2001
From: Tomas Vondra <tomas@vondra.me>
Date: Tue, 17 Mar 2026 22:06:46 +0100
Subject: [PATCH v3 3/6] cleanup and simplification

- Reduce code duplication in explain.c by introducing a couple helper
  methods for repeated blocks - ACCUMULATE_TABLE_STATS() and
  show_io_info().

- Replace ReadStreamInstrumentation with generic IOStats.

- new read_stream_update_stats_stall() wrapper

- enable EXPLAIN (IO) by default, just like BUFFERS

- fix the explain group to have matching objtype/label

- don't update prefetch stats after stream ended

fixup: track prefetch distance
---
 doc/src/sgml/ref/explain.sgml                 |   2 +-
 src/backend/access/heap/heapam.c              |   2 +-
 src/backend/commands/explain.c                | 242 +++++++-----------
 src/backend/commands/explain_state.c          |   5 +
 src/backend/executor/nodeBitmapHeapscan.c     |  11 +-
 src/backend/executor/nodeSeqscan.c            |  11 +-
 src/backend/storage/aio/read_stream.c         |  26 +-
 src/include/access/relscan.h                  |  14 +
 src/include/executor/instrument_node.h        |   8 +-
 src/include/storage/read_stream.h             |   4 +-
 src/test/regress/expected/explain.out         |  39 ++-
 .../regress/expected/incremental_sort.out     |   4 +-
 src/test/regress/expected/memoize.out         |   2 +-
 src/test/regress/expected/merge.out           |   2 +-
 src/test/regress/expected/partition_prune.out |  75 ++++--
 src/test/regress/expected/select_parallel.out |   6 +-
 src/test/regress/expected/subselect.out       |   2 +-
 src/test/regress/sql/explain.sql              |  24 +-
 src/test/regress/sql/incremental_sort.sql     |   4 +-
 src/test/regress/sql/memoize.sql              |   2 +-
 src/test/regress/sql/merge.sql                |   2 +-
 src/test/regress/sql/partition_prune.sql      |   2 +-
 src/test/regress/sql/select_parallel.sql      |   6 +-
 src/test/regress/sql/subselect.sql            |   2 +-
 24 files changed, 252 insertions(+), 245 deletions(-)

diff --git a/doc/src/sgml/ref/explain.sgml b/doc/src/sgml/ref/explain.sgml
index 9da16c77d73..83f27badfc8 100644
--- a/doc/src/sgml/ref/explain.sgml
+++ b/doc/src/sgml/ref/explain.sgml
@@ -302,7 +302,7 @@ ROLLBACK;
      <para>
       Include information on I/O performed by each node.
       This parameter may only be used when <literal>ANALYZE</literal> is also
-      enabled.  It defaults to <literal>FALSE</literal>.
+      enabled.  It defaults to <literal>TRUE</literal>.
      </para>
     </listitem>
    </varlistentry>
diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c
index b13f843cfad..323badd27f1 100644
--- a/src/backend/access/heap/heapam.c
+++ b/src/backend/access/heap/heapam.c
@@ -1426,7 +1426,7 @@ TableScanStats
 heap_scan_stats(TableScanDesc sscan)
 {
 	HeapScanDesc scan = (HeapScanDesc) sscan;
-	ReadStreamInstrumentation stats;
+	IOStats stats;
 	TableScanStats res;
 
 	if (!scan->rs_read_stream)
diff --git a/src/backend/commands/explain.c b/src/backend/commands/explain.c
index ad86fb752ca..7a4009ae74f 100644
--- a/src/backend/commands/explain.c
+++ b/src/backend/commands/explain.c
@@ -141,11 +141,11 @@ static void show_hashagg_info(AggState *aggstate, ExplainState *es);
 static void show_indexsearches_info(PlanState *planstate, ExplainState *es);
 static void show_tidbitmap_info(BitmapHeapScanState *planstate,
 								ExplainState *es);
-static void show_scan_io_info(ScanState *planstate,
-							  ExplainState *es);
-static void show_worker_io_info(PlanState *planstate,
-								ExplainState *es,
-								int worker);
+static void show_scan_io_usage(ScanState *planstate,
+							   ExplainState *es);
+static void show_io_usage(PlanState *planstate,
+						  ExplainState *es,
+						  int worker);
 static void show_instrumentation_count(const char *qlabel, int which,
 									   PlanState *planstate, ExplainState *es);
 static void show_foreignscan_info(ForeignScanState *fsstate, ExplainState *es);
@@ -2016,7 +2016,7 @@ ExplainNode(PlanState *planstate, List *ancestors,
 				show_instrumentation_count("Rows Removed by Filter", 1,
 										   planstate, es);
 			show_tidbitmap_info((BitmapHeapScanState *) planstate, es);
-			show_scan_io_info((ScanState *) planstate, es);
+			show_scan_io_usage((ScanState *) planstate, es);
 			break;
 		case T_SampleScan:
 			show_tablesample(((SampleScan *) plan)->tablesample,
@@ -2035,7 +2035,7 @@ ExplainNode(PlanState *planstate, List *ancestors,
 										   planstate, es);
 			if (IsA(plan, CteScan))
 				show_ctescan_info(castNode(CteScanState, planstate), es);
-			show_scan_io_info((ScanState *) planstate, es);
+			show_scan_io_usage((ScanState *) planstate, es);
 			break;
 		case T_Gather:
 			{
@@ -2324,9 +2324,8 @@ ExplainNode(PlanState *planstate, List *ancestors,
 				show_buffer_usage(es, &instrument->bufusage);
 			if (es->wal)
 				show_wal_usage(es, &instrument->walusage);
-
-			/* show prefetch info for the given worker */
-			show_worker_io_info(planstate, es, n);
+			if (es->io)
+				show_io_usage(planstate, es, n);
 
 			ExplainCloseWorker(n, es);
 		}
@@ -3998,14 +3997,74 @@ show_tidbitmap_info(BitmapHeapScanState *planstate, ExplainState *es)
 	}
 }
 
+static void
+print_io_usage(ExplainState *es, TableScanStats stats)
+{
+	/* don't print stats if there's nothing to report */
+	if (stats->prefetch_count > 0)
+	{
+		if (es->format == EXPLAIN_FORMAT_TEXT)
+		{
+			/* prefetch distance info */
+			ExplainIndentText(es);
+			appendStringInfo(es->str, "Prefetch: avg=%.3f max=%d capacity=%d",
+							 (stats->distance_sum * 1.0 / stats->prefetch_count),
+							 stats->distance_max,
+							 stats->distance_capacity);
+			appendStringInfoChar(es->str, '\n');
+
+			/* prefetch I/O info (only if there were actual I/Os) */
+			if (stats->io_count > 0)
+			{
+				ExplainIndentText(es);
+				appendStringInfo(es->str, "I/O: stalls=%" PRIu64,
+								 stats->stall_count);
+
+				appendStringInfo(es->str, " size=%.3f inprogress=%.3f",
+								 (stats->io_nblocks * 1.0 / stats->io_count),
+								 (stats->io_in_progress * 1.0 / stats->io_count));
+
+				appendStringInfoChar(es->str, '\n');
+			}
+		}
+		else
+		{
+			ExplainOpenGroup("Prefetch", "Prefetch", true, es);
+
+			ExplainPropertyFloat("Average Distance", NULL,
+								 (stats->distance_sum * 1.0 / stats->prefetch_count), 3, es);
+			ExplainPropertyInteger("Max Distance", NULL,
+								   stats->distance_max, es);
+			ExplainPropertyInteger("Capacity", NULL,
+								   stats->distance_capacity, es);
+
+			ExplainCloseGroup("Prefetch", "Prefetch", true, es);
+
+			if (stats->io_count > 0)
+			{
+				ExplainOpenGroup("I/O", "I/O", true, es);
+
+				ExplainPropertyUInteger("Stalls", NULL,
+										stats->stall_count, es);
+				ExplainPropertyFloat("Average IO Size", NULL,
+									 (stats->io_nblocks * 1.0 / stats->io_count), 3, es);
+				ExplainPropertyFloat("Average IOs In Progress", NULL,
+									 (stats->io_in_progress * 1.0 / stats->io_count), 3, es);
+
+				ExplainCloseGroup("I/O", "I/O", true, es);
+			}
+		}
+	}
+}
+
 /*
- * show_scan_io_info
+ * show_scan_io_usage
  *		show info about prefetching for a seq/bitmap scan
  *
  * Shows summary of stats for leader and workers (if any).
  */
 static void
-show_scan_io_info(ScanState *planstate, ExplainState *es)
+show_scan_io_usage(ScanState *planstate, ExplainState *es)
 {
 	Plan	   *plan = planstate->ps.plan;
 	TableScanStats	leader_stats;
@@ -4044,17 +4103,7 @@ show_scan_io_info(ScanState *planstate, ExplainState *es)
 					for (int i = 0; i < sinstrument->num_workers; ++i)
 					{
 						SeqScanInstrumentation *winstrument = &sinstrument->sinstrument[i];
-
-						stats.prefetch_count += winstrument->stream.prefetch_count;
-						stats.distance_sum += winstrument->stream.distance_sum;
-						if (winstrument->stream.distance_max > stats.distance_max)
-							stats.distance_max = winstrument->stream.distance_max;
-						if (winstrument->stream.distance_capacity > stats.distance_capacity)
-							stats.distance_capacity = winstrument->stream.distance_capacity;
-						stats.stall_count += winstrument->stream.stall_count;
-						stats.io_count += winstrument->stream.io_count;
-						stats.io_nblocks += winstrument->stream.io_nblocks;
-						stats.io_in_progress += winstrument->stream.io_in_progress;
+						ACCUMULATE_TABLE_STATS(&stats, &winstrument->io);
 					}
 				}
 
@@ -4071,17 +4120,7 @@ show_scan_io_info(ScanState *planstate, ExplainState *es)
 					for (int i = 0; i < sinstrument->num_workers; ++i)
 					{
 						BitmapHeapScanInstrumentation *winstrument = &sinstrument->sinstrument[i];
-
-						stats.prefetch_count += winstrument->stream.prefetch_count;
-						stats.distance_sum += winstrument->stream.distance_sum;
-						if (winstrument->stream.distance_max > stats.distance_max)
-							stats.distance_max = winstrument->stream.distance_max;
-						if (winstrument->stream.distance_capacity > stats.distance_capacity)
-							stats.distance_capacity = winstrument->stream.distance_capacity;
-						stats.stall_count += winstrument->stream.stall_count;
-						stats.io_count += winstrument->stream.io_count;
-						stats.io_nblocks += winstrument->stream.io_nblocks;
-						stats.io_in_progress += winstrument->stream.io_in_progress;
+						ACCUMULATE_TABLE_STATS(&stats, &winstrument->io);
 					}
 				}
 
@@ -4092,73 +4131,23 @@ show_scan_io_info(ScanState *planstate, ExplainState *es)
 			return;
 	}
 
-	/* don't print anything without prefetching */
-	if (stats.prefetch_count > 0)
-	{
-		if (es->format == EXPLAIN_FORMAT_TEXT)
-		{
-			/* prefetch distance info */
-			ExplainIndentText(es);
-			appendStringInfo(es->str, "Prefetch: avg=%.3f max=%d capacity=%d",
-							 (stats.distance_sum * 1.0 / stats.prefetch_count),
-							 stats.distance_max,
-							 stats.distance_capacity);
-			appendStringInfoChar(es->str, '\n');
-
-			/* prefetch I/O info (only if there were actual I/Os) */
-			if (stats.stall_count > 0 || stats.io_count > 0)
-			{
-				ExplainIndentText(es);
-				appendStringInfo(es->str, "I/O: stalls=%" PRIu64,
-								 stats.stall_count);
-
-				if (stats.io_count > 0)
-				{
-					appendStringInfo(es->str, " size=%.3f inprogress=%.3f",
-									 (stats.io_nblocks * 1.0 / stats.io_count),
-									 (stats.io_in_progress * 1.0 / stats.io_count));
-				}
-
-				appendStringInfoChar(es->str, '\n');
-			}
-		}
-		else
-		{
-			ExplainOpenGroup("Prefetch", "I/O", true, es);
-
-			ExplainPropertyFloat("Average Distance", NULL,
-								 (stats.distance_sum * 1.0 / stats.prefetch_count), 3, es);
-			ExplainPropertyInteger("Max Distance", NULL,
-								   stats.distance_max, es);
-			ExplainPropertyInteger("Capacity", NULL,
-								   stats.distance_capacity, es);
-			ExplainPropertyUInteger("Stalls", NULL,
-									stats.stall_count, es);
-
-			if (stats.io_count > 0)
-			{
-				ExplainPropertyFloat("Average IO Size", NULL,
-									 (stats.io_nblocks * 1.0 / stats.io_count), 3, es);
-				ExplainPropertyFloat("Average IOs In Progress", NULL,
-									 (stats.io_in_progress * 1.0 / stats.io_count), 3, es);
-			}
-
-			ExplainCloseGroup("Prefetch", "I/O", true, es);
-		}
-	}
+	print_io_usage(es, &stats);
 }
 
 /*
- * show_io_worker_info
- *		show info about prefetching for a single worker
+ * show_io_usage
+ *		show info about I/O prefetching for a single worker
  *
  * Shows prefetching stats for a parallel scan worker.
  */
 static void
-show_worker_io_info(PlanState *planstate, ExplainState *es, int worker)
+show_io_usage(PlanState *planstate, ExplainState *es, int worker)
 {
 	Plan	   *plan = planstate->plan;
-	ReadStreamInstrumentation *stats = NULL;
+	IOStats	   *stats = NULL;
+
+	// XXX
+	TableScanStatsData	tmp;
 
 	if (!es->io)
 		return;
@@ -4172,7 +4161,7 @@ show_worker_io_info(PlanState *planstate, ExplainState *es, int worker)
 				SharedBitmapHeapInstrumentation *sinstrument = state->sinstrument;
 				BitmapHeapScanInstrumentation *instrument = &sinstrument->sinstrument[worker];
 
-				stats = &instrument->stream;
+				stats = &instrument->io;
 
 				break;
 			}
@@ -4182,7 +4171,7 @@ show_worker_io_info(PlanState *planstate, ExplainState *es, int worker)
 				SharedSeqScanInstrumentation *sinstrument = state->sinstrument;
 				SeqScanInstrumentation *instrument = &sinstrument->sinstrument[worker];
 
-				stats = &instrument->stream;
+				stats = &instrument->io;
 
 				break;
 			}
@@ -4191,60 +4180,17 @@ show_worker_io_info(PlanState *planstate, ExplainState *es, int worker)
 			return;
 	}
 
-	/* don't print stats if there's nothing to report */
-	if (stats->prefetch_count > 0)
-	{
-		if (es->format == EXPLAIN_FORMAT_TEXT)
-		{
-			/* prefetch distance info */
-			ExplainIndentText(es);
-			appendStringInfo(es->str, "Prefetch: avg=%.3f max=%d capacity=%d",
-							 (stats->distance_sum * 1.0 / stats->prefetch_count),
-							 stats->distance_max,
-							 stats->distance_capacity);
-			appendStringInfoChar(es->str, '\n');
-
-			/* prefetch I/O info (only if there were actual I/Os) */
-			if (stats->stall_count > 0 || stats->io_count > 0)
-			{
-				ExplainIndentText(es);
-				appendStringInfo(es->str, "I/O: stalls=%" PRIu64,
-								 stats->stall_count);
-
-				if (stats->io_count > 0)
-				{
-					appendStringInfo(es->str, " size=%.3f inprogress=%.3f",
-									 (stats->io_nblocks * 1.0 / stats->io_count),
-									 (stats->io_in_progress * 1.0 / stats->io_count));
-				}
-
-				appendStringInfoChar(es->str, '\n');
-			}
-		}
-		else
-		{
-			ExplainOpenGroup("Prefetch", "I/O", true, es);
-
-			ExplainPropertyFloat("Average Distance", NULL,
-								 (stats->distance_sum * 1.0 / stats->prefetch_count), 3, es);
-			ExplainPropertyInteger("Max Distance", NULL,
-								   stats->distance_max, es);
-			ExplainPropertyInteger("Capacity", NULL,
-								   stats->distance_capacity, es);
-			ExplainPropertyUInteger("Stalls", NULL,
-									stats->stall_count, es);
-
-			if (stats->io_count > 0)
-			{
-				ExplainPropertyFloat("Average IO Size", NULL,
-									 (stats->io_nblocks * 1.0 / stats->io_count), 3, es);
-				ExplainPropertyFloat("Average IOs In Progress", NULL,
-									 (stats->io_in_progress * 1.0 / stats->io_count), 3, es);
-			}
-
-			ExplainCloseGroup("Prefetch", "I/O", true, es);
-		}
-	}
+	/* XXX convert to TableScanStats */
+	tmp.distance_capacity = stats->distance_capacity;
+	tmp.distance_max = stats->distance_max;
+	tmp.distance_sum = stats->distance_sum;
+	tmp.io_count = stats->io_count;
+	tmp.io_in_progress = stats->io_in_progress;
+	tmp.io_nblocks = stats->io_nblocks;
+	tmp.prefetch_count = stats->prefetch_count;
+	tmp.stall_count = stats->stall_count;
+
+	print_io_usage(es, &tmp);
 }
 
 /*
diff --git a/src/backend/commands/explain_state.c b/src/backend/commands/explain_state.c
index f5cbdd21aaa..3d5f92bfa91 100644
--- a/src/backend/commands/explain_state.c
+++ b/src/backend/commands/explain_state.c
@@ -79,6 +79,7 @@ ParseExplainOptionList(ExplainState *es, List *options, ParseState *pstate)
 	ListCell   *lc;
 	bool		timing_set = false;
 	bool		buffers_set = false;
+	bool		io_set = false;
 	bool		summary_set = false;
 
 	/* Parse options list. */
@@ -161,6 +162,7 @@ ParseExplainOptionList(ExplainState *es, List *options, ParseState *pstate)
 		}
 		else if (strcmp(opt->defname, "io") == 0)
 		{
+			io_set = true;
 			es->io = defGetBoolean(opt);
 		}
 		else if (!ApplyExtensionExplainOption(es, opt, pstate))
@@ -183,6 +185,9 @@ ParseExplainOptionList(ExplainState *es, List *options, ParseState *pstate)
 	/* if the buffers was not set explicitly, set default value */
 	es->buffers = (buffers_set) ? es->buffers : es->analyze;
 
+	/* if the IO was not set explicitly, set default value */
+	es->io = (io_set) ? es->io : es->analyze;
+
 	/* check that timing is used with EXPLAIN ANALYZE */
 	if (es->timing && !es->analyze)
 		ereport(ERROR,
diff --git a/src/backend/executor/nodeBitmapHeapscan.c b/src/backend/executor/nodeBitmapHeapscan.c
index bf9af7596ce..0f1459f11a4 100644
--- a/src/backend/executor/nodeBitmapHeapscan.c
+++ b/src/backend/executor/nodeBitmapHeapscan.c
@@ -333,16 +333,7 @@ ExecEndBitmapHeapScan(BitmapHeapScanState *node)
 		/* collect prefetch info for this process from the read_stream */
 		if ((stats = table_scan_stats(node->ss.ss_currentScanDesc)) != NULL)
 		{
-			si->stream.prefetch_count += stats->prefetch_count;
-			si->stream.distance_sum += stats->distance_sum;
-			if (stats->distance_max > si->stream.distance_max)
-				si->stream.distance_max = stats->distance_max;
-			if (stats->distance_capacity > si->stream.distance_capacity)
-				si->stream.distance_capacity = stats->distance_capacity;
-			si->stream.stall_count += stats->stall_count;
-			si->stream.io_count += stats->io_count;
-			si->stream.io_nblocks += stats->io_nblocks;
-			si->stream.io_in_progress += stats->io_in_progress;
+			ACCUMULATE_TABLE_STATS(stats, &si->io);
 		}
 	}
 
diff --git a/src/backend/executor/nodeSeqscan.c b/src/backend/executor/nodeSeqscan.c
index 7992946a5be..4fa85a1b6bc 100644
--- a/src/backend/executor/nodeSeqscan.c
+++ b/src/backend/executor/nodeSeqscan.c
@@ -319,16 +319,7 @@ ExecEndSeqScan(SeqScanState *node)
 		/* collect prefetch info for this process from the read_stream */
 		if ((stats = table_scan_stats(node->ss.ss_currentScanDesc)) != NULL)
 		{
-			si->stream.prefetch_count += stats->prefetch_count;
-			si->stream.distance_sum += stats->distance_sum;
-			if (stats->distance_max > si->stream.distance_max)
-				si->stream.distance_max = stats->distance_max;
-			if (stats->distance_capacity > si->stream.distance_capacity)
-				si->stream.distance_capacity = stats->distance_capacity;
-			si->stream.stall_count += stats->stall_count;
-			si->stream.io_count += stats->io_count;
-			si->stream.io_nblocks += stats->io_nblocks;
-			si->stream.io_in_progress += stats->io_in_progress;
+			ACCUMULATE_TABLE_STATS(stats, &si->io);
 		}
 	}
 
diff --git a/src/backend/storage/aio/read_stream.c b/src/backend/storage/aio/read_stream.c
index f560047afd6..faa52ca4b2c 100644
--- a/src/backend/storage/aio/read_stream.c
+++ b/src/backend/storage/aio/read_stream.c
@@ -108,8 +108,8 @@ struct ReadStream
 	bool		advice_enabled;
 	bool		temporary;
 
-	/* stats counters */
-	ReadStreamInstrumentation stats;
+	/* scan stats counters */
+	IOStats		stats;
 
 	/*
 	 * One-block buffer to support 'ungetting' a block number, to resolve flow
@@ -177,8 +177,7 @@ block_range_read_stream_cb(ReadStream *stream,
 }
 
 /*
- * read_stream_update_stats_prefetch
- *		update read_stream stats with current pinned buffer depth
+ * Update stream stats with current pinned buffer depth.
  *
  * Called once per buffer returned to the consumer in read_stream_next_buffer().
  * Records the number of pinned buffers at that moment, so we can compute the
@@ -194,8 +193,7 @@ read_stream_update_stats_prefetch(ReadStream *stream)
 }
 
 /*
- * read_stream_update_stats_io
- *		update read_stream stats about size of I/O requests
+ * Update stream stats about size of I/O requests.
  *
  * We count the number of I/O requests, size of requests (counted in blocks)
  * and number of in-progress I/Os.
@@ -208,6 +206,12 @@ read_stream_update_stats_io(ReadStream *stream, int nblocks, int in_progress)
 	stream->stats.io_in_progress += in_progress;
 }
 
+static inline void
+read_stream_update_stats_stall(ReadStream *stream)
+{
+	stream->stats.stall_count++;
+}
+
 /*
  * Ask the callback which block it would like us to read next, with a one block
  * buffer in front to allow read_stream_unget_block() to work.
@@ -743,7 +747,7 @@ read_stream_begin_impl(int flags,
 	stream->temporary = SmgrIsTemp(smgr);
 
 	/* zero the stats, then set capacity */
-	memset(&stream->stats, 0, sizeof(ReadStreamInstrumentation));
+	memset(&stream->stats, 0, sizeof(IOStats));
 	stream->stats.distance_capacity = max_pinned_buffers;
 
 	/*
@@ -907,6 +911,9 @@ read_stream_next_buffer(ReadStream *stream, void **per_buffer_data)
 
 			/* update I/O stats */
 			read_stream_update_stats_io(stream, 1, stream->ios_in_progress);
+
+			/* update prefetch distance */
+			read_stream_update_stats_prefetch(stream);
 		}
 		else
 		{
@@ -918,7 +925,6 @@ read_stream_next_buffer(ReadStream *stream, void **per_buffer_data)
 		}
 
 		stream->fast_path = false;
-		read_stream_update_stats_prefetch(stream);
 		return buffer;
 	}
 #endif
@@ -974,7 +980,7 @@ read_stream_next_buffer(ReadStream *stream, void **per_buffer_data)
 
 		/* Count it as a stall if we need to wait for IO */
 		if (needed_wait)
-			stream->stats.stall_count += 1;
+			read_stream_update_stats_stall(stream);
 
 		Assert(stream->ios_in_progress > 0);
 		stream->ios_in_progress--;
@@ -1175,7 +1181,7 @@ read_stream_end(ReadStream *stream)
 }
 
 /* return the prefetch stats for the read_stream */
-ReadStreamInstrumentation
+IOStats
 read_stream_prefetch_stats(ReadStream *stream)
 {
 	return stream->stats;
diff --git a/src/include/access/relscan.h b/src/include/access/relscan.h
index cada9f28bd8..c0488987b2d 100644
--- a/src/include/access/relscan.h
+++ b/src/include/access/relscan.h
@@ -141,6 +141,20 @@ typedef struct TableScanStatsData
 } TableScanStatsData;
 typedef struct TableScanStatsData *TableScanStats;
 
+#define ACCUMULATE_TABLE_STATS(dst, src) \
+	do { \
+		(dst)->prefetch_count += (src)->prefetch_count; \
+		(dst)->distance_sum += (src)->distance_sum; \
+		if ((src)->distance_max > (dst)->distance_max) \
+			(dst)->distance_max = (src)->distance_max; \
+		if ((src)->distance_capacity > (dst)->distance_capacity) \
+			(dst)->distance_capacity = (src)->distance_capacity; \
+		(dst)->stall_count += (src)->stall_count; \
+		(dst)->io_count += (src)->io_count; \
+		(dst)->io_nblocks += (src)->io_nblocks; \
+		(dst)->io_in_progress += (src)->io_in_progress; \
+	} while (0)
+
 /*
  * Base class for fetches from a table via an index. This is the base-class
  * for such scans, which needs to be embedded in the respective struct for
diff --git a/src/include/executor/instrument_node.h b/src/include/executor/instrument_node.h
index d94dbbc917d..cda00ad40e9 100644
--- a/src/include/executor/instrument_node.h
+++ b/src/include/executor/instrument_node.h
@@ -44,7 +44,7 @@ typedef struct SharedAggInfo
  *	Instrumentation information about read streams
  * ---------------------
  */
-typedef struct ReadStreamInstrumentation
+typedef struct IOStats
 {
 	/* number of buffers returned to consumer (for averaging distance) */
 	uint64		prefetch_count;
@@ -65,7 +65,7 @@ typedef struct ReadStreamInstrumentation
 	uint64		io_count;		/* number of I/Os */
 	uint64		io_nblocks;		/* sum of blocks for all I/Os */
 	uint64		io_in_progress;	/* sum of in-progress I/Os */
-} ReadStreamInstrumentation;
+} IOStats;
 
 
 /* ---------------------
@@ -74,7 +74,7 @@ typedef struct ReadStreamInstrumentation
  */
 typedef struct SeqScanInstrumentation
 {
-	ReadStreamInstrumentation	stream;
+	IOStats		io;
 } SeqScanInstrumentation;
 
 /*
@@ -113,7 +113,7 @@ typedef struct BitmapHeapScanInstrumentation
 {
 	uint64		exact_pages;
 	uint64		lossy_pages;
-	ReadStreamInstrumentation	stream;
+	IOStats		io;
 } BitmapHeapScanInstrumentation;
 
 /*
diff --git a/src/include/storage/read_stream.h b/src/include/storage/read_stream.h
index ec396db5369..0676bdc9b14 100644
--- a/src/include/storage/read_stream.h
+++ b/src/include/storage/read_stream.h
@@ -65,7 +65,7 @@
 
 struct ReadStream;
 typedef struct ReadStream ReadStream;
-typedef struct ReadStreamInstrumentation ReadStreamInstrumentation;
+typedef struct IOStats IOStats;
 
 /* for block_range_read_stream_cb */
 typedef struct BlockRangeReadStreamPrivate
@@ -105,6 +105,6 @@ extern void read_stream_resume(ReadStream *stream);
 extern void read_stream_reset(ReadStream *stream);
 extern void read_stream_end(ReadStream *stream);
 
-extern ReadStreamInstrumentation read_stream_prefetch_stats(ReadStream *stream);
+extern IOStats read_stream_prefetch_stats(ReadStream *stream);
 
 #endif							/* READ_STREAM_H */
diff --git a/src/test/regress/expected/explain.out b/src/test/regress/expected/explain.out
index 7c1f26b182c..2d247f73c14 100644
--- a/src/test/regress/expected/explain.out
+++ b/src/test/regress/expected/explain.out
@@ -23,6 +23,10 @@ begin
         -- Ignore text-mode buffers output because it varies depending
         -- on the system state
         CONTINUE WHEN (ln ~ ' +Buffers: .*');
+        -- Ignore text-mode prefetch and I/O output because it varies
+        -- depending on the system state
+        CONTINUE WHEN (ln ~ ' +Prefetch: .*');
+        CONTINUE WHEN (ln ~ ' +I/O: .*');
         -- Ignore text-mode "Planning:" line because whether it's output
         -- varies depending on the system state
         CONTINUE WHEN (ln = 'Planning:');
@@ -68,7 +72,7 @@ select explain_filter('explain select * from int8_tbl i8');
  Seq Scan on int8_tbl i8  (cost=N.N..N.N rows=N width=N)
 (1 row)
 
-select explain_filter('explain (analyze, buffers off) select * from int8_tbl i8');
+select explain_filter('explain (analyze, buffers off, io off) select * from int8_tbl i8');
                                          explain_filter                                          
 -------------------------------------------------------------------------------------------------
  Seq Scan on int8_tbl i8  (cost=N.N..N.N rows=N width=N) (actual time=N.N..N.N rows=N.N loops=N)
@@ -76,7 +80,7 @@ select explain_filter('explain (analyze, buffers off) select * from int8_tbl i8'
  Execution Time: N.N ms
 (3 rows)
 
-select explain_filter('explain (analyze, buffers off, verbose) select * from int8_tbl i8');
+select explain_filter('explain (analyze, buffers off, io off, verbose) select * from int8_tbl i8');
                                              explain_filter                                             
 --------------------------------------------------------------------------------------------------------
  Seq Scan on public.int8_tbl i8  (cost=N.N..N.N rows=N width=N) (actual time=N.N..N.N rows=N.N loops=N)
@@ -93,7 +97,7 @@ select explain_filter('explain (analyze, buffers, format text) select * from int
  Execution Time: N.N ms
 (3 rows)
 
-select explain_filter('explain (analyze, buffers, format xml) select * from int8_tbl i8');
+select explain_filter('explain (analyze, buffers, io off, format xml) select * from int8_tbl i8');
                      explain_filter                     
 --------------------------------------------------------
  <explain xmlns="http://www.postgresql.org/N/explain"> +
@@ -144,7 +148,7 @@ select explain_filter('explain (analyze, buffers, format xml) select * from int8
  </explain>
 (1 row)
 
-select explain_filter('explain (analyze, serialize, buffers, format yaml) select * from int8_tbl i8');
+select explain_filter('explain (analyze, serialize, buffers, io off, format yaml) select * from int8_tbl i8');
         explain_filter         
 -------------------------------
  - Plan:                      +
@@ -310,6 +314,11 @@ select explain_filter('explain (analyze, buffers, format json) select * from int
        "Actual Rows": N.N,          +
        "Actual Loops": N,           +
        "Disabled": false,           +
+       "Prefetch": {                +
+         "Average Distance": N.N,   +
+         "Max Distance": N,         +
+         "Capacity": N              +
+       },                           +
        "Shared Hit Blocks": N,      +
        "Shared Read Blocks": N,     +
        "Shared Dirtied Blocks": N,  +
@@ -396,7 +405,7 @@ select explain_filter('explain (memory) select * from int8_tbl i8');
    Memory: used=NkB  allocated=NkB
 (2 rows)
 
-select explain_filter('explain (memory, analyze, buffers off) select * from int8_tbl i8');
+select explain_filter('explain (memory, analyze, buffers off, io off) select * from int8_tbl i8');
                                          explain_filter                                          
 -------------------------------------------------------------------------------------------------
  Seq Scan on int8_tbl i8  (cost=N.N..N.N rows=N width=N) (actual time=N.N..N.N rows=N.N loops=N)
@@ -445,6 +454,11 @@ select explain_filter('explain (memory, analyze, format json) select * from int8
        "Actual Rows": N.N,         +
        "Actual Loops": N,          +
        "Disabled": false,          +
+       "Prefetch": {               +
+         "Average Distance": N.N,  +
+         "Max Distance": N,        +
+         "Capacity": N             +
+       },                          +
        "Shared Hit Blocks": N,     +
        "Shared Read Blocks": N,    +
        "Shared Dirtied Blocks": N, +
@@ -568,6 +582,11 @@ select jsonb_pretty(
                              ],                             +
                              "Schema": "public",            +
                              "Disabled": false,             +
+                             "Prefetch": {                  +
+                                 "Capacity": 0,             +
+                                 "Max Distance": 0,         +
+                                 "Average Distance": 0.0    +
+                             },                             +
                              "Node Type": "Seq Scan",       +
                              "Plan Rows": 0,                +
                              "Plan Width": 0,               +
@@ -744,7 +763,7 @@ select explain_filter('explain (verbose) create table test_ctas as select 1');
 (3 rows)
 
 -- Test SERIALIZE option
-select explain_filter('explain (analyze,buffers off,serialize) select * from int8_tbl i8');
+select explain_filter('explain (analyze,buffers off,io off,serialize) select * from int8_tbl i8');
                                          explain_filter                                          
 -------------------------------------------------------------------------------------------------
  Seq Scan on int8_tbl i8  (cost=N.N..N.N rows=N width=N) (actual time=N.N..N.N rows=N.N loops=N)
@@ -772,7 +791,7 @@ select explain_filter('explain (analyze,serialize binary,buffers,timing) select
 (4 rows)
 
 -- this tests an edge case where we have no data to return
-select explain_filter('explain (analyze,buffers off,serialize) create temp table explain_temp as select * from int8_tbl i8');
+select explain_filter('explain (analyze,buffers off,io off,serialize) create temp table explain_temp as select * from int8_tbl i8');
                                          explain_filter                                          
 -------------------------------------------------------------------------------------------------
  Seq Scan on int8_tbl i8  (cost=N.N..N.N rows=N width=N) (actual time=N.N..N.N rows=N.N loops=N)
@@ -782,7 +801,7 @@ select explain_filter('explain (analyze,buffers off,serialize) create temp table
 (4 rows)
 
 -- Test tuplestore storage usage in Window aggregate (memory case)
-select explain_filter('explain (analyze,buffers off,costs off) select sum(n) over() from generate_series(1,10) a(n)');
+select explain_filter('explain (analyze,buffers off,io off,costs off) select sum(n) over() from generate_series(1,10) a(n)');
                                   explain_filter                                  
 ----------------------------------------------------------------------------------
  WindowAgg (actual time=N.N..N.N rows=N.N loops=N)
@@ -795,7 +814,7 @@ select explain_filter('explain (analyze,buffers off,costs off) select sum(n) ove
 
 -- Test tuplestore storage usage in Window aggregate (disk case)
 set work_mem to 64;
-select explain_filter('explain (analyze,buffers off,costs off) select sum(n) over() from generate_series(1,2500) a(n)');
+select explain_filter('explain (analyze,buffers off,io off,costs off) select sum(n) over() from generate_series(1,2500) a(n)');
                                   explain_filter                                  
 ----------------------------------------------------------------------------------
  WindowAgg (actual time=N.N..N.N rows=N.N loops=N)
@@ -807,7 +826,7 @@ select explain_filter('explain (analyze,buffers off,costs off) select sum(n) ove
 (6 rows)
 
 -- Test tuplestore storage usage in Window aggregate (memory and disk case, final result is disk)
-select explain_filter('explain (analyze,buffers off,costs off) select sum(n) over(partition by m) from (SELECT n < 3 as m, n from generate_series(1,2500) a(n))');
+select explain_filter('explain (analyze,buffers off,io off,costs off) select sum(n) over(partition by m) from (SELECT n < 3 as m, n from generate_series(1,2500) a(n))');
                                      explain_filter                                     
 ----------------------------------------------------------------------------------------
  WindowAgg (actual time=N.N..N.N rows=N.N loops=N)
diff --git a/src/test/regress/expected/incremental_sort.out b/src/test/regress/expected/incremental_sort.out
index 1e6e020fea8..55d5a52d96b 100644
--- a/src/test/regress/expected/incremental_sort.out
+++ b/src/test/regress/expected/incremental_sort.out
@@ -39,7 +39,7 @@ declare
   line text;
 begin
   for line in
-    execute 'explain (analyze, costs off, summary off, timing off, buffers off) ' || query
+    execute 'explain (analyze, costs off, summary off, timing off, buffers off, io off) ' || query
   loop
     out_line := regexp_replace(line, '\d+kB', 'NNkB', 'g');
     return next;
@@ -55,7 +55,7 @@ declare
   element jsonb;
   matching_nodes jsonb := '[]'::jsonb;
 begin
-  execute 'explain (analyze, costs off, summary off, timing off, buffers off, format ''json'') ' || query into strict elements;
+  execute 'explain (analyze, costs off, summary off, timing off, buffers off, io off, format ''json'') ' || query into strict elements;
   while jsonb_array_length(elements) > 0 loop
     element := elements->0;
     elements := elements - 0;
diff --git a/src/test/regress/expected/memoize.out b/src/test/regress/expected/memoize.out
index 00c30b91459..425aed4d749 100644
--- a/src/test/regress/expected/memoize.out
+++ b/src/test/regress/expected/memoize.out
@@ -10,7 +10,7 @@ declare
     ln text;
 begin
     for ln in
-        execute format('explain (analyze, costs off, summary off, timing off, buffers off) %s',
+        execute format('explain (analyze, costs off, summary off, timing off, buffers off, io off) %s',
             query)
     loop
         if hide_hitmiss = true then
diff --git a/src/test/regress/expected/merge.out b/src/test/regress/expected/merge.out
index 9cb1d87066a..f1331c307d3 100644
--- a/src/test/regress/expected/merge.out
+++ b/src/test/regress/expected/merge.out
@@ -1623,7 +1623,7 @@ $$
 DECLARE ln text;
 BEGIN
     FOR ln IN
-        EXECUTE 'explain (analyze, timing off, summary off, costs off, buffers off) ' ||
+        EXECUTE 'explain (analyze, timing off, summary off, costs off, buffers off, io off) ' ||
 		  query
     LOOP
         ln := regexp_replace(ln, '(Memory( Usage)?|Buckets|Batches): \S*',  '\1: xxx', 'g');
diff --git a/src/test/regress/expected/partition_prune.out b/src/test/regress/expected/partition_prune.out
index deacdd75807..1332cd0e646 100644
--- a/src/test/regress/expected/partition_prune.out
+++ b/src/test/regress/expected/partition_prune.out
@@ -11,7 +11,7 @@ declare
     ln text;
 begin
     for ln in
-        execute format('explain (analyze, costs off, summary off, timing off, buffers off) %s',
+        execute format('explain (analyze, costs off, summary off, timing off, buffers off, io off) %s',
             query)
     loop
         ln := regexp_replace(ln, 'Maximum Storage: \d+', 'Maximum Storage: N');
@@ -2322,7 +2322,8 @@ explain (analyze, costs off, summary off, timing off, buffers off) select * from
    Subplans Removed: 3
    ->  Seq Scan on list_part1 list_part_1 (actual rows=1.00 loops=1)
          Filter: (a = list_part_fn(1))
-(4 rows)
+         Prefetch: avg=1.000 max=1 capacity=94
+(5 rows)
 
 -- Ensure pruning does not take place when the function has a Var parameter
 explain (analyze, costs off, summary off, timing off, buffers off) select * from list_part where a = list_part_fn(a);
@@ -2331,13 +2332,17 @@ explain (analyze, costs off, summary off, timing off, buffers off) select * from
  Append (actual rows=4.00 loops=1)
    ->  Seq Scan on list_part1 list_part_1 (actual rows=1.00 loops=1)
          Filter: (a = list_part_fn(a))
+         Prefetch: avg=1.000 max=1 capacity=94
    ->  Seq Scan on list_part2 list_part_2 (actual rows=1.00 loops=1)
          Filter: (a = list_part_fn(a))
+         Prefetch: avg=1.000 max=1 capacity=94
    ->  Seq Scan on list_part3 list_part_3 (actual rows=1.00 loops=1)
          Filter: (a = list_part_fn(a))
+         Prefetch: avg=1.000 max=1 capacity=94
    ->  Seq Scan on list_part4 list_part_4 (actual rows=1.00 loops=1)
          Filter: (a = list_part_fn(a))
-(9 rows)
+         Prefetch: avg=1.000 max=1 capacity=94
+(13 rows)
 
 -- Ensure pruning does not take place when the expression contains a Var.
 explain (analyze, costs off, summary off, timing off, buffers off) select * from list_part where a = list_part_fn(1) + a;
@@ -2347,16 +2352,20 @@ explain (analyze, costs off, summary off, timing off, buffers off) select * from
    ->  Seq Scan on list_part1 list_part_1 (actual rows=0.00 loops=1)
          Filter: (a = (list_part_fn(1) + a))
          Rows Removed by Filter: 1
+         Prefetch: avg=1.000 max=1 capacity=94
    ->  Seq Scan on list_part2 list_part_2 (actual rows=0.00 loops=1)
          Filter: (a = (list_part_fn(1) + a))
          Rows Removed by Filter: 1
+         Prefetch: avg=1.000 max=1 capacity=94
    ->  Seq Scan on list_part3 list_part_3 (actual rows=0.00 loops=1)
          Filter: (a = (list_part_fn(1) + a))
          Rows Removed by Filter: 1
+         Prefetch: avg=1.000 max=1 capacity=94
    ->  Seq Scan on list_part4 list_part_4 (actual rows=0.00 loops=1)
          Filter: (a = (list_part_fn(1) + a))
          Rows Removed by Filter: 1
-(13 rows)
+         Prefetch: avg=1.000 max=1 capacity=94
+(17 rows)
 
 rollback;
 drop table list_part;
@@ -2524,6 +2533,7 @@ select explain_parallel_append('select avg(ab.a) from ab inner join lprt_a a on
                ->  Nested Loop (actual rows=N loops=N)
                      ->  Parallel Seq Scan on lprt_a a (actual rows=N loops=N)
                            Filter: (a = ANY ('{0,0,1}'::integer[]))
+                           Prefetch: avg=1.000 max=1 capacity=94
                      ->  Append (actual rows=N loops=N)
                            ->  Index Scan using ab_a1_b1_a_idx on ab_a1_b1 ab_1 (actual rows=N loops=N)
                                  Index Cond: (a = a.a)
@@ -2543,7 +2553,7 @@ select explain_parallel_append('select avg(ab.a) from ab inner join lprt_a a on
                                  Index Cond: (a = a.a)
                            ->  Index Scan using ab_a3_b3_a_idx on ab_a3_b3 ab_9 (never executed)
                                  Index Cond: (a = a.a)
-(27 rows)
+(28 rows)
 
 -- Ensure the same partitions are pruned when we make the nested loop
 -- parameter an Expr rather than a plain Param.
@@ -2558,6 +2568,7 @@ select explain_parallel_append('select avg(ab.a) from ab inner join lprt_a a on
                ->  Nested Loop (actual rows=N loops=N)
                      ->  Parallel Seq Scan on lprt_a a (actual rows=N loops=N)
                            Filter: (a = ANY ('{0,0,1}'::integer[]))
+                           Prefetch: avg=1.000 max=1 capacity=94
                      ->  Append (actual rows=N loops=N)
                            ->  Index Scan using ab_a1_b1_a_idx on ab_a1_b1 ab_1 (actual rows=N loops=N)
                                  Index Cond: (a = (a.a + 0))
@@ -2577,7 +2588,7 @@ select explain_parallel_append('select avg(ab.a) from ab inner join lprt_a a on
                                  Index Cond: (a = (a.a + 0))
                            ->  Index Scan using ab_a3_b3_a_idx on ab_a3_b3 ab_9 (never executed)
                                  Index Cond: (a = (a.a + 0))
-(27 rows)
+(28 rows)
 
 insert into lprt_a values(3),(3);
 select explain_parallel_append('select avg(ab.a) from ab inner join lprt_a a on ab.a = a.a where a.a in(1, 0, 3)');
@@ -2591,6 +2602,7 @@ select explain_parallel_append('select avg(ab.a) from ab inner join lprt_a a on
                ->  Nested Loop (actual rows=N loops=N)
                      ->  Parallel Seq Scan on lprt_a a (actual rows=N loops=N)
                            Filter: (a = ANY ('{1,0,3}'::integer[]))
+                           Prefetch: avg=1.000 max=1 capacity=94
                      ->  Append (actual rows=N loops=N)
                            ->  Index Scan using ab_a1_b1_a_idx on ab_a1_b1 ab_1 (actual rows=N loops=N)
                                  Index Cond: (a = a.a)
@@ -2610,7 +2622,7 @@ select explain_parallel_append('select avg(ab.a) from ab inner join lprt_a a on
                                  Index Cond: (a = a.a)
                            ->  Index Scan using ab_a3_b3_a_idx on ab_a3_b3 ab_9 (actual rows=N loops=N)
                                  Index Cond: (a = a.a)
-(27 rows)
+(28 rows)
 
 select explain_parallel_append('select avg(ab.a) from ab inner join lprt_a a on ab.a = a.a where a.a in(1, 0, 0)');
                                         explain_parallel_append                                         
@@ -2624,6 +2636,7 @@ select explain_parallel_append('select avg(ab.a) from ab inner join lprt_a a on
                      ->  Parallel Seq Scan on lprt_a a (actual rows=N loops=N)
                            Filter: (a = ANY ('{1,0,0}'::integer[]))
                            Rows Removed by Filter: N
+                           Prefetch: avg=1.000 max=1 capacity=94
                      ->  Append (actual rows=N loops=N)
                            ->  Index Scan using ab_a1_b1_a_idx on ab_a1_b1 ab_1 (actual rows=N loops=N)
                                  Index Cond: (a = a.a)
@@ -2643,7 +2656,7 @@ select explain_parallel_append('select avg(ab.a) from ab inner join lprt_a a on
                                  Index Cond: (a = a.a)
                            ->  Index Scan using ab_a3_b3_a_idx on ab_a3_b3 ab_9 (never executed)
                                  Index Cond: (a = a.a)
-(28 rows)
+(29 rows)
 
 delete from lprt_a where a = 1;
 select explain_parallel_append('select avg(ab.a) from ab inner join lprt_a a on ab.a = a.a where a.a in(1, 0, 0)');
@@ -2658,6 +2671,7 @@ select explain_parallel_append('select avg(ab.a) from ab inner join lprt_a a on
                      ->  Parallel Seq Scan on lprt_a a (actual rows=N loops=N)
                            Filter: (a = ANY ('{1,0,0}'::integer[]))
                            Rows Removed by Filter: N
+                           Prefetch: avg=1.000 max=1 capacity=94
                      ->  Append (actual rows=N loops=N)
                            ->  Index Scan using ab_a1_b1_a_idx on ab_a1_b1 ab_1 (never executed)
                                  Index Cond: (a = a.a)
@@ -2677,7 +2691,7 @@ select explain_parallel_append('select avg(ab.a) from ab inner join lprt_a a on
                                  Index Cond: (a = a.a)
                            ->  Index Scan using ab_a3_b3_a_idx on ab_a3_b3 ab_9 (never executed)
                                  Index Cond: (a = a.a)
-(28 rows)
+(29 rows)
 
 reset enable_hashjoin;
 reset enable_mergejoin;
@@ -2695,9 +2709,11 @@ select * from ab where a = (select max(a) from lprt_a) and b = (select max(a)-1
    InitPlan expr_1
      ->  Aggregate (actual rows=1.00 loops=1)
            ->  Seq Scan on lprt_a (actual rows=102.00 loops=1)
+                 Prefetch: avg=1.000 max=1 capacity=94
    InitPlan expr_2
      ->  Aggregate (actual rows=1.00 loops=1)
            ->  Seq Scan on lprt_a lprt_a_1 (actual rows=102.00 loops=1)
+                 Prefetch: avg=1.000 max=1 capacity=94
    ->  Bitmap Heap Scan on ab_a1_b1 ab_1 (never executed)
          Recheck Cond: (a = (InitPlan expr_1).col1)
          Filter: (b = (InitPlan expr_2).col1)
@@ -2752,7 +2768,7 @@ select * from ab where a = (select max(a) from lprt_a) and b = (select max(a)-1
          ->  Bitmap Index Scan on ab_a3_b3_a_idx (never executed)
                Index Cond: (a = (InitPlan expr_1).col1)
                Index Searches: 0
-(61 rows)
+(63 rows)
 
 -- Test run-time partition pruning with UNION ALL parents
 explain (analyze, costs off, summary off, timing off, buffers off)
@@ -2880,13 +2896,14 @@ explain (analyze, costs off, summary off, timing off, buffers off) execute ab_q6
    ->  Seq Scan on xy_1 (actual rows=0.00 loops=1)
          Filter: ((x = $1) AND (y = (InitPlan expr_1).col1))
          Rows Removed by Filter: 1
+         Prefetch: avg=1.000 max=1 capacity=94
    ->  Seq Scan on ab_a1_b1 ab_4 (never executed)
          Filter: ((a = $1) AND (b = (InitPlan expr_1).col1))
    ->  Seq Scan on ab_a1_b2 ab_5 (never executed)
          Filter: ((a = $1) AND (b = (InitPlan expr_1).col1))
    ->  Seq Scan on ab_a1_b3 ab_6 (never executed)
          Filter: ((a = $1) AND (b = (InitPlan expr_1).col1))
-(19 rows)
+(20 rows)
 
 -- Ensure we see just the xy_1 row.
 execute ab_q6(100);
@@ -3025,6 +3042,7 @@ select * from tbl1 join tprt on tbl1.col1 > tprt.col1;
 -----------------------------------------------------------------------------
  Nested Loop (actual rows=6.00 loops=1)
    ->  Seq Scan on tbl1 (actual rows=2.00 loops=1)
+         Prefetch: avg=1.000 max=1 capacity=94
    ->  Append (actual rows=3.00 loops=2)
          ->  Index Scan using tprt1_idx on tprt_1 (actual rows=2.00 loops=2)
                Index Cond: (col1 < tbl1.col1)
@@ -3044,7 +3062,7 @@ select * from tbl1 join tprt on tbl1.col1 > tprt.col1;
          ->  Index Scan using tprt6_idx on tprt_6 (never executed)
                Index Cond: (col1 < tbl1.col1)
                Index Searches: 0
-(21 rows)
+(22 rows)
 
 explain (analyze, costs off, summary off, timing off, buffers off)
 select * from tbl1 join tprt on tbl1.col1 = tprt.col1;
@@ -3052,6 +3070,7 @@ select * from tbl1 join tprt on tbl1.col1 = tprt.col1;
 -----------------------------------------------------------------------------
  Nested Loop (actual rows=2.00 loops=1)
    ->  Seq Scan on tbl1 (actual rows=2.00 loops=1)
+         Prefetch: avg=1.000 max=1 capacity=94
    ->  Append (actual rows=1.00 loops=2)
          ->  Index Scan using tprt1_idx on tprt_1 (never executed)
                Index Cond: (col1 = tbl1.col1)
@@ -3071,7 +3090,7 @@ select * from tbl1 join tprt on tbl1.col1 = tprt.col1;
          ->  Index Scan using tprt6_idx on tprt_6 (never executed)
                Index Cond: (col1 = tbl1.col1)
                Index Searches: 0
-(21 rows)
+(22 rows)
 
 select tbl1.col1, tprt.col1 from tbl1
 inner join tprt on tbl1.col1 > tprt.col1
@@ -3103,6 +3122,7 @@ select * from tbl1 inner join tprt on tbl1.col1 > tprt.col1;
 -----------------------------------------------------------------------------
  Nested Loop (actual rows=23.00 loops=1)
    ->  Seq Scan on tbl1 (actual rows=5.00 loops=1)
+         Prefetch: avg=1.000 max=1 capacity=94
    ->  Append (actual rows=4.60 loops=5)
          ->  Index Scan using tprt1_idx on tprt_1 (actual rows=2.00 loops=5)
                Index Cond: (col1 < tbl1.col1)
@@ -3122,7 +3142,7 @@ select * from tbl1 inner join tprt on tbl1.col1 > tprt.col1;
          ->  Index Scan using tprt6_idx on tprt_6 (never executed)
                Index Cond: (col1 < tbl1.col1)
                Index Searches: 0
-(21 rows)
+(22 rows)
 
 explain (analyze, costs off, summary off, timing off, buffers off)
 select * from tbl1 inner join tprt on tbl1.col1 = tprt.col1;
@@ -3130,6 +3150,7 @@ select * from tbl1 inner join tprt on tbl1.col1 = tprt.col1;
 -----------------------------------------------------------------------------
  Nested Loop (actual rows=3.00 loops=1)
    ->  Seq Scan on tbl1 (actual rows=5.00 loops=1)
+         Prefetch: avg=1.000 max=1 capacity=94
    ->  Append (actual rows=0.60 loops=5)
          ->  Index Scan using tprt1_idx on tprt_1 (never executed)
                Index Cond: (col1 = tbl1.col1)
@@ -3149,7 +3170,7 @@ select * from tbl1 inner join tprt on tbl1.col1 = tprt.col1;
          ->  Index Scan using tprt6_idx on tprt_6 (never executed)
                Index Cond: (col1 = tbl1.col1)
                Index Searches: 0
-(21 rows)
+(22 rows)
 
 select tbl1.col1, tprt.col1 from tbl1
 inner join tprt on tbl1.col1 > tprt.col1
@@ -3200,6 +3221,7 @@ select * from tbl1 join tprt on tbl1.col1 < tprt.col1;
 -----------------------------------------------------------------------------
  Nested Loop (actual rows=1.00 loops=1)
    ->  Seq Scan on tbl1 (actual rows=1.00 loops=1)
+         Prefetch: avg=1.000 max=1 capacity=94
    ->  Append (actual rows=1.00 loops=1)
          ->  Index Scan using tprt1_idx on tprt_1 (never executed)
                Index Cond: (col1 > tbl1.col1)
@@ -3219,7 +3241,7 @@ select * from tbl1 join tprt on tbl1.col1 < tprt.col1;
          ->  Index Scan using tprt6_idx on tprt_6 (actual rows=1.00 loops=1)
                Index Cond: (col1 > tbl1.col1)
                Index Searches: 1
-(21 rows)
+(22 rows)
 
 select tbl1.col1, tprt.col1 from tbl1
 inner join tprt on tbl1.col1 < tprt.col1
@@ -3238,6 +3260,7 @@ select * from tbl1 join tprt on tbl1.col1 = tprt.col1;
 -------------------------------------------------------------------
  Nested Loop (actual rows=0.00 loops=1)
    ->  Seq Scan on tbl1 (actual rows=1.00 loops=1)
+         Prefetch: avg=1.000 max=1 capacity=94
    ->  Append (actual rows=0.00 loops=1)
          ->  Index Scan using tprt1_idx on tprt_1 (never executed)
                Index Cond: (col1 = tbl1.col1)
@@ -3257,7 +3280,7 @@ select * from tbl1 join tprt on tbl1.col1 = tprt.col1;
          ->  Index Scan using tprt6_idx on tprt_6 (never executed)
                Index Cond: (col1 = tbl1.col1)
                Index Searches: 0
-(21 rows)
+(22 rows)
 
 select tbl1.col1, tprt.col1 from tbl1
 inner join tprt on tbl1.col1 = tprt.col1
@@ -3485,11 +3508,14 @@ select * from mc3p where a < 3 and abs(b) = 1;
  Append (actual rows=3.00 loops=1)
    ->  Seq Scan on mc3p0 mc3p_1 (actual rows=1.00 loops=1)
          Filter: ((a < 3) AND (abs(b) = 1))
+         Prefetch: avg=1.000 max=1 capacity=94
    ->  Seq Scan on mc3p1 mc3p_2 (actual rows=1.00 loops=1)
          Filter: ((a < 3) AND (abs(b) = 1))
+         Prefetch: avg=1.000 max=1 capacity=94
    ->  Seq Scan on mc3p2 mc3p_3 (actual rows=1.00 loops=1)
          Filter: ((a < 3) AND (abs(b) = 1))
-(7 rows)
+         Prefetch: avg=1.000 max=1 capacity=94
+(10 rows)
 
 --
 -- Check that pruning with composite range partitioning works correctly when
@@ -3508,7 +3534,8 @@ execute ps1(1);
      ->  Result (actual rows=1.00 loops=1)
    ->  Seq Scan on mc3p1 mc3p_1 (actual rows=1.00 loops=1)
          Filter: ((a = $1) AND (abs(b) < (InitPlan expr_1).col1))
-(6 rows)
+         Prefetch: avg=1.000 max=1 capacity=94
+(7 rows)
 
 deallocate ps1;
 prepare ps2 as
@@ -3523,9 +3550,11 @@ execute ps2(1);
      ->  Result (actual rows=1.00 loops=1)
    ->  Seq Scan on mc3p0 mc3p_1 (actual rows=1.00 loops=1)
          Filter: ((a <= $1) AND (abs(b) < (InitPlan expr_1).col1))
+         Prefetch: avg=1.000 max=1 capacity=94
    ->  Seq Scan on mc3p1 mc3p_2 (actual rows=1.00 loops=1)
          Filter: ((a <= $1) AND (abs(b) < (InitPlan expr_1).col1))
-(8 rows)
+         Prefetch: avg=1.000 max=1 capacity=94
+(10 rows)
 
 deallocate ps2;
 drop table mc3p;
@@ -3544,11 +3573,12 @@ select * from boolp where a = (select value from boolvalues where value);
      ->  Seq Scan on boolvalues (actual rows=1.00 loops=1)
            Filter: value
            Rows Removed by Filter: 1
+           Prefetch: avg=1.000 max=1 capacity=94
    ->  Seq Scan on boolp_f boolp_1 (never executed)
          Filter: (a = (InitPlan expr_1).col1)
    ->  Seq Scan on boolp_t boolp_2 (actual rows=0.00 loops=1)
          Filter: (a = (InitPlan expr_1).col1)
-(9 rows)
+(10 rows)
 
 explain (analyze, costs off, summary off, timing off, buffers off)
 select * from boolp where a = (select value from boolvalues where not value);
@@ -3559,11 +3589,12 @@ select * from boolp where a = (select value from boolvalues where not value);
      ->  Seq Scan on boolvalues (actual rows=1.00 loops=1)
            Filter: (NOT value)
            Rows Removed by Filter: 1
+           Prefetch: avg=1.000 max=1 capacity=94
    ->  Seq Scan on boolp_f boolp_1 (actual rows=0.00 loops=1)
          Filter: (a = (InitPlan expr_1).col1)
    ->  Seq Scan on boolp_t boolp_2 (never executed)
          Filter: (a = (InitPlan expr_1).col1)
-(9 rows)
+(10 rows)
 
 drop table boolp;
 --
diff --git a/src/test/regress/expected/select_parallel.out b/src/test/regress/expected/select_parallel.out
index 933921d1860..bfaf10a2906 100644
--- a/src/test/regress/expected/select_parallel.out
+++ b/src/test/regress/expected/select_parallel.out
@@ -580,7 +580,7 @@ select count(*) from bmscantest where a>1;
 -- test accumulation of stats for parallel nodes
 reset enable_seqscan;
 alter table tenk2 set (parallel_workers = 0);
-explain (analyze, timing off, summary off, costs off, buffers off)
+explain (analyze, timing off, summary off, costs off, buffers off, io off)
    select count(*) from tenk1, tenk2 where tenk1.hundred > 1
         and tenk2.thousand=0;
                                  QUERY PLAN                                  
@@ -606,7 +606,7 @@ $$
 declare ln text;
 begin
     for ln in
-        explain (analyze, timing off, summary off, costs off, buffers off)
+        explain (analyze, timing off, summary off, costs off, buffers off, io off)
           select * from
           (select ten from tenk1 where ten < 100 order by ten) ss
           right join (values (1),(2),(3)) v(x) on true
@@ -1170,7 +1170,7 @@ explain (costs off)
 -- to increase the parallel query test coverage
 SAVEPOINT settings;
 SET LOCAL debug_parallel_query = 1;
-EXPLAIN (analyze, timing off, summary off, costs off, buffers off) SELECT * FROM tenk1;
+EXPLAIN (analyze, timing off, summary off, costs off, buffers off, io off) SELECT * FROM tenk1;
                            QUERY PLAN                           
 ----------------------------------------------------------------
  Gather (actual rows=10000.00 loops=1)
diff --git a/src/test/regress/expected/subselect.out b/src/test/regress/expected/subselect.out
index 200236a0a69..0a1de6d7259 100644
--- a/src/test/regress/expected/subselect.out
+++ b/src/test/regress/expected/subselect.out
@@ -2051,7 +2051,7 @@ $$
 declare ln text;
 begin
     for ln in
-        explain (analyze, summary off, timing off, costs off, buffers off)
+        explain (analyze, summary off, timing off, costs off, buffers off, io off)
         select * from (select pk,c2 from sq_limit order by c1,pk) as x limit 3
     loop
         ln := regexp_replace(ln, 'Memory: \S*',  'Memory: xxx');
diff --git a/src/test/regress/sql/explain.sql b/src/test/regress/sql/explain.sql
index ebdab42604b..e9a3c2caf91 100644
--- a/src/test/regress/sql/explain.sql
+++ b/src/test/regress/sql/explain.sql
@@ -25,6 +25,10 @@ begin
         -- Ignore text-mode buffers output because it varies depending
         -- on the system state
         CONTINUE WHEN (ln ~ ' +Buffers: .*');
+        -- Ignore text-mode prefetch and I/O output because it varies
+        -- depending on the system state
+        CONTINUE WHEN (ln ~ ' +Prefetch: .*');
+        CONTINUE WHEN (ln ~ ' +I/O: .*');
         -- Ignore text-mode "Planning:" line because whether it's output
         -- varies depending on the system state
         CONTINUE WHEN (ln = 'Planning:');
@@ -63,11 +67,11 @@ set track_io_timing = off;
 
 explain (costs off) select 1 as a, 2 as b having false;
 select explain_filter('explain select * from int8_tbl i8');
-select explain_filter('explain (analyze, buffers off) select * from int8_tbl i8');
-select explain_filter('explain (analyze, buffers off, verbose) select * from int8_tbl i8');
+select explain_filter('explain (analyze, buffers off, io off) select * from int8_tbl i8');
+select explain_filter('explain (analyze, buffers off, io off, verbose) select * from int8_tbl i8');
 select explain_filter('explain (analyze, buffers, format text) select * from int8_tbl i8');
-select explain_filter('explain (analyze, buffers, format xml) select * from int8_tbl i8');
-select explain_filter('explain (analyze, serialize, buffers, format yaml) select * from int8_tbl i8');
+select explain_filter('explain (analyze, buffers, io off, format xml) select * from int8_tbl i8');
+select explain_filter('explain (analyze, serialize, buffers, io off, format yaml) select * from int8_tbl i8');
 select explain_filter('explain (buffers, format text) select * from int8_tbl i8');
 select explain_filter('explain (buffers, format json) select * from int8_tbl i8');
 
@@ -102,7 +106,7 @@ select explain_filter('explain (analyze, generic_plan) select unique1 from tenk1
 
 -- MEMORY option
 select explain_filter('explain (memory) select * from int8_tbl i8');
-select explain_filter('explain (memory, analyze, buffers off) select * from int8_tbl i8');
+select explain_filter('explain (memory, analyze, buffers off, io off) select * from int8_tbl i8');
 select explain_filter('explain (memory, summary, format yaml) select * from int8_tbl i8');
 select explain_filter('explain (memory, analyze, format json) select * from int8_tbl i8');
 prepare int8_query as select * from int8_tbl i8;
@@ -174,17 +178,17 @@ select explain_filter('explain (verbose) declare test_cur cursor for select * fr
 select explain_filter('explain (verbose) create table test_ctas as select 1');
 
 -- Test SERIALIZE option
-select explain_filter('explain (analyze,buffers off,serialize) select * from int8_tbl i8');
+select explain_filter('explain (analyze,buffers off,io off,serialize) select * from int8_tbl i8');
 select explain_filter('explain (analyze,serialize text,buffers,timing off) select * from int8_tbl i8');
 select explain_filter('explain (analyze,serialize binary,buffers,timing) select * from int8_tbl i8');
 -- this tests an edge case where we have no data to return
-select explain_filter('explain (analyze,buffers off,serialize) create temp table explain_temp as select * from int8_tbl i8');
+select explain_filter('explain (analyze,buffers off,io off,serialize) create temp table explain_temp as select * from int8_tbl i8');
 
 -- Test tuplestore storage usage in Window aggregate (memory case)
-select explain_filter('explain (analyze,buffers off,costs off) select sum(n) over() from generate_series(1,10) a(n)');
+select explain_filter('explain (analyze,buffers off,io off,costs off) select sum(n) over() from generate_series(1,10) a(n)');
 -- Test tuplestore storage usage in Window aggregate (disk case)
 set work_mem to 64;
-select explain_filter('explain (analyze,buffers off,costs off) select sum(n) over() from generate_series(1,2500) a(n)');
+select explain_filter('explain (analyze,buffers off,io off,costs off) select sum(n) over() from generate_series(1,2500) a(n)');
 -- Test tuplestore storage usage in Window aggregate (memory and disk case, final result is disk)
-select explain_filter('explain (analyze,buffers off,costs off) select sum(n) over(partition by m) from (SELECT n < 3 as m, n from generate_series(1,2500) a(n))');
+select explain_filter('explain (analyze,buffers off,io off,costs off) select sum(n) over(partition by m) from (SELECT n < 3 as m, n from generate_series(1,2500) a(n))');
 reset work_mem;
diff --git a/src/test/regress/sql/incremental_sort.sql b/src/test/regress/sql/incremental_sort.sql
index bbe658a7588..1c8036faade 100644
--- a/src/test/regress/sql/incremental_sort.sql
+++ b/src/test/regress/sql/incremental_sort.sql
@@ -21,7 +21,7 @@ declare
   line text;
 begin
   for line in
-    execute 'explain (analyze, costs off, summary off, timing off, buffers off) ' || query
+    execute 'explain (analyze, costs off, summary off, timing off, buffers off, io off) ' || query
   loop
     out_line := regexp_replace(line, '\d+kB', 'NNkB', 'g');
     return next;
@@ -38,7 +38,7 @@ declare
   element jsonb;
   matching_nodes jsonb := '[]'::jsonb;
 begin
-  execute 'explain (analyze, costs off, summary off, timing off, buffers off, format ''json'') ' || query into strict elements;
+  execute 'explain (analyze, costs off, summary off, timing off, buffers off, io off, format ''json'') ' || query into strict elements;
   while jsonb_array_length(elements) > 0 loop
     element := elements->0;
     elements := elements - 0;
diff --git a/src/test/regress/sql/memoize.sql b/src/test/regress/sql/memoize.sql
index 8d1cdd6990c..d1f80e18b41 100644
--- a/src/test/regress/sql/memoize.sql
+++ b/src/test/regress/sql/memoize.sql
@@ -11,7 +11,7 @@ declare
     ln text;
 begin
     for ln in
-        execute format('explain (analyze, costs off, summary off, timing off, buffers off) %s',
+        execute format('explain (analyze, costs off, summary off, timing off, buffers off, io off) %s',
             query)
     loop
         if hide_hitmiss = true then
diff --git a/src/test/regress/sql/merge.sql b/src/test/regress/sql/merge.sql
index 2660b19f238..c82e194078e 100644
--- a/src/test/regress/sql/merge.sql
+++ b/src/test/regress/sql/merge.sql
@@ -1074,7 +1074,7 @@ $$
 DECLARE ln text;
 BEGIN
     FOR ln IN
-        EXECUTE 'explain (analyze, timing off, summary off, costs off, buffers off) ' ||
+        EXECUTE 'explain (analyze, timing off, summary off, costs off, buffers off, io off) ' ||
 		  query
     LOOP
         ln := regexp_replace(ln, '(Memory( Usage)?|Buckets|Batches): \S*',  '\1: xxx', 'g');
diff --git a/src/test/regress/sql/partition_prune.sql b/src/test/regress/sql/partition_prune.sql
index d93c0c03bab..212de0e6285 100644
--- a/src/test/regress/sql/partition_prune.sql
+++ b/src/test/regress/sql/partition_prune.sql
@@ -12,7 +12,7 @@ declare
     ln text;
 begin
     for ln in
-        execute format('explain (analyze, costs off, summary off, timing off, buffers off) %s',
+        execute format('explain (analyze, costs off, summary off, timing off, buffers off, io off) %s',
             query)
     loop
         ln := regexp_replace(ln, 'Maximum Storage: \d+', 'Maximum Storage: N');
diff --git a/src/test/regress/sql/select_parallel.sql b/src/test/regress/sql/select_parallel.sql
index 71a75bc86ea..56e1ceac492 100644
--- a/src/test/regress/sql/select_parallel.sql
+++ b/src/test/regress/sql/select_parallel.sql
@@ -230,7 +230,7 @@ select count(*) from bmscantest where a>1;
 -- test accumulation of stats for parallel nodes
 reset enable_seqscan;
 alter table tenk2 set (parallel_workers = 0);
-explain (analyze, timing off, summary off, costs off, buffers off)
+explain (analyze, timing off, summary off, costs off, buffers off, io off)
    select count(*) from tenk1, tenk2 where tenk1.hundred > 1
         and tenk2.thousand=0;
 alter table tenk2 reset (parallel_workers);
@@ -242,7 +242,7 @@ $$
 declare ln text;
 begin
     for ln in
-        explain (analyze, timing off, summary off, costs off, buffers off)
+        explain (analyze, timing off, summary off, costs off, buffers off, io off)
           select * from
           (select ten from tenk1 where ten < 100 order by ten) ss
           right join (values (1),(2),(3)) v(x) on true
@@ -450,7 +450,7 @@ explain (costs off)
 -- to increase the parallel query test coverage
 SAVEPOINT settings;
 SET LOCAL debug_parallel_query = 1;
-EXPLAIN (analyze, timing off, summary off, costs off, buffers off) SELECT * FROM tenk1;
+EXPLAIN (analyze, timing off, summary off, costs off, buffers off, io off) SELECT * FROM tenk1;
 ROLLBACK TO SAVEPOINT settings;
 
 -- provoke error in worker
diff --git a/src/test/regress/sql/subselect.sql b/src/test/regress/sql/subselect.sql
index 4cd016f4ac3..3e930d8b56d 100644
--- a/src/test/regress/sql/subselect.sql
+++ b/src/test/regress/sql/subselect.sql
@@ -1016,7 +1016,7 @@ $$
 declare ln text;
 begin
     for ln in
-        explain (analyze, summary off, timing off, costs off, buffers off)
+        explain (analyze, summary off, timing off, costs off, buffers off, io off)
         select * from (select pk,c2 from sq_limit order by c1,pk) as x limit 3
     loop
         ln := regexp_replace(ln, 'Memory: \S*',  'Memory: xxx');
-- 
2.53.0



  [text/x-patch] v3-0004-store-the-IO-stats-in-scan-descriptor.patch (24.3K, ../bf1d4799-e2fd-464c-96ff-dc448f021b1e@vondra.me/5-v3-0004-store-the-IO-stats-in-scan-descriptor.patch)
  download | inline diff:
From fec8ab8c5bac3ed43cec1ccbbbb53e0fafb541eb Mon Sep 17 00:00:00 2001
From: Tomas Vondra <tomas@vondra.me>
Date: Wed, 18 Mar 2026 16:03:47 +0100
Subject: [PATCH v3 4/6] store the IO stats in scan descriptor

- Don't pass the stats through the TAM interface. Instead, make them
  part of the scan descriptor (optionally).

- ambeginscan() may allocate IOStats in the scan descriptor, and fill it
  with data. Being part of the decriptor means it won't survive
  amendscan().

- The IOStats is then passed to the ReadStream through a new argument
  added to read_stream_begin_relation(), and the stream updates this
  place directly. There's no need to "fetch" the stats from the stream.

- With parallel query, the data from workers is still copied through
  shared memory by endscan() in the workers. The leader has to consume
  and merge the data. The only difference is that the workers read the
  data from the scan descriptor, and not from the instrumentation.
---
 contrib/amcheck/verify_heapam.c           |  3 +-
 contrib/bloom/blscan.c                    |  3 +-
 contrib/bloom/blvacuum.c                  |  6 ++-
 contrib/pg_prewarm/autoprewarm.c          |  3 +-
 contrib/pg_prewarm/pg_prewarm.c           |  3 +-
 contrib/pg_visibility/pg_visibility.c     |  6 ++-
 contrib/pgstattuple/pgstatapprox.c        |  3 +-
 contrib/pgstattuple/pgstatindex.c         |  6 ++-
 src/backend/access/brin/brin.c            |  3 +-
 src/backend/access/gin/ginvacuum.c        |  3 +-
 src/backend/access/gist/gistvacuum.c      |  3 +-
 src/backend/access/hash/hash.c            |  3 +-
 src/backend/access/heap/heapam.c          | 41 +++-------------
 src/backend/access/heap/heapam_handler.c  |  1 -
 src/backend/access/heap/vacuumlazy.c      |  6 ++-
 src/backend/access/nbtree/nbtree.c        |  3 +-
 src/backend/access/spgist/spgvacuum.c     |  3 +-
 src/backend/commands/analyze.c            |  3 +-
 src/backend/commands/explain.c            | 31 ++----------
 src/backend/executor/nodeBitmapHeapscan.c |  6 +--
 src/backend/executor/nodeSeqscan.c        |  6 +--
 src/backend/storage/aio/read_stream.c     | 59 ++++++++++++++---------
 src/include/access/heapam.h               |  1 -
 src/include/access/relscan.h              |  6 +++
 src/include/access/tableam.h              | 17 -------
 src/include/nodes/execnodes.h             |  1 -
 src/include/storage/read_stream.h         |  5 +-
 27 files changed, 97 insertions(+), 137 deletions(-)

diff --git a/contrib/amcheck/verify_heapam.c b/contrib/amcheck/verify_heapam.c
index ada782f98f5..0ec4dc849c3 100644
--- a/contrib/amcheck/verify_heapam.c
+++ b/contrib/amcheck/verify_heapam.c
@@ -476,7 +476,8 @@ verify_heapam(PG_FUNCTION_ARGS)
 										MAIN_FORKNUM,
 										stream_cb,
 										stream_data,
-										0);
+										0,
+										NULL);
 
 	while ((ctx.buffer = read_stream_next_buffer(stream, NULL)) != InvalidBuffer)
 	{
diff --git a/contrib/bloom/blscan.c b/contrib/bloom/blscan.c
index 1a0e42021ec..9bdc4a95c18 100644
--- a/contrib/bloom/blscan.c
+++ b/contrib/bloom/blscan.c
@@ -138,7 +138,8 @@ blgetbitmap(IndexScanDesc scan, TIDBitmap *tbm)
 										MAIN_FORKNUM,
 										block_range_read_stream_cb,
 										&p,
-										0);
+										0,
+										NULL);
 
 	for (blkno = BLOOM_HEAD_BLKNO; blkno < npages; blkno++)
 	{
diff --git a/contrib/bloom/blvacuum.c b/contrib/bloom/blvacuum.c
index 6beb1c20ebb..587757c1e18 100644
--- a/contrib/bloom/blvacuum.c
+++ b/contrib/bloom/blvacuum.c
@@ -71,7 +71,8 @@ blbulkdelete(IndexVacuumInfo *info, IndexBulkDeleteResult *stats,
 										MAIN_FORKNUM,
 										block_range_read_stream_cb,
 										&p,
-										0);
+										0,
+										NULL);
 
 	for (blkno = BLOOM_HEAD_BLKNO; blkno < npages; blkno++)
 	{
@@ -224,7 +225,8 @@ blvacuumcleanup(IndexVacuumInfo *info, IndexBulkDeleteResult *stats)
 										MAIN_FORKNUM,
 										block_range_read_stream_cb,
 										&p,
-										0);
+										0,
+										NULL);
 
 	for (blkno = BLOOM_HEAD_BLKNO; blkno < npages; blkno++)
 	{
diff --git a/contrib/pg_prewarm/autoprewarm.c b/contrib/pg_prewarm/autoprewarm.c
index ba0bc8e6d4a..4c5448f177a 100644
--- a/contrib/pg_prewarm/autoprewarm.c
+++ b/contrib/pg_prewarm/autoprewarm.c
@@ -628,7 +628,8 @@ autoprewarm_database_main(Datum main_arg)
 												p.forknum,
 												apw_read_stream_next_block,
 												&p,
-												0);
+												0,
+												NULL);
 
 			/*
 			 * Loop until we've prewarmed all the blocks from this fork. The
diff --git a/contrib/pg_prewarm/pg_prewarm.c b/contrib/pg_prewarm/pg_prewarm.c
index c2716086693..e4541f46777 100644
--- a/contrib/pg_prewarm/pg_prewarm.c
+++ b/contrib/pg_prewarm/pg_prewarm.c
@@ -256,7 +256,8 @@ pg_prewarm(PG_FUNCTION_ARGS)
 											forkNumber,
 											block_range_read_stream_cb,
 											&p,
-											0);
+											0,
+											NULL);
 
 		for (block = first_block; block <= last_block; ++block)
 		{
diff --git a/contrib/pg_visibility/pg_visibility.c b/contrib/pg_visibility/pg_visibility.c
index dfab0b64cf5..f09bb68624f 100644
--- a/contrib/pg_visibility/pg_visibility.c
+++ b/contrib/pg_visibility/pg_visibility.c
@@ -519,7 +519,8 @@ collect_visibility_data(Oid relid, bool include_pd)
 											MAIN_FORKNUM,
 											block_range_read_stream_cb,
 											&p,
-											0);
+											0,
+											NULL);
 	}
 
 	for (blkno = 0; blkno < nblocks; ++blkno)
@@ -739,7 +740,8 @@ collect_corrupt_items(Oid relid, bool all_visible, bool all_frozen)
 										MAIN_FORKNUM,
 										collect_corrupt_items_read_stream_next_block,
 										&p,
-										0);
+										0,
+										NULL);
 
 	/* Loop over every block in the relation. */
 	while ((buffer = read_stream_next_buffer(stream, NULL)) != InvalidBuffer)
diff --git a/contrib/pgstattuple/pgstatapprox.c b/contrib/pgstattuple/pgstatapprox.c
index 21e0b50fb4b..a926a1f1168 100644
--- a/contrib/pgstattuple/pgstatapprox.c
+++ b/contrib/pgstattuple/pgstatapprox.c
@@ -146,7 +146,8 @@ statapprox_heap(Relation rel, output_type *stat)
 										MAIN_FORKNUM,
 										statapprox_heap_read_stream_next,
 										&p,
-										0);
+										0,
+										NULL);
 
 	for (;;)
 	{
diff --git a/contrib/pgstattuple/pgstatindex.c b/contrib/pgstattuple/pgstatindex.c
index 3a3f2637bd9..0de33558c47 100644
--- a/contrib/pgstattuple/pgstatindex.c
+++ b/contrib/pgstattuple/pgstatindex.c
@@ -296,7 +296,8 @@ pgstatindex_impl(Relation rel, FunctionCallInfo fcinfo)
 										MAIN_FORKNUM,
 										block_range_read_stream_cb,
 										&p,
-										0);
+										0,
+										NULL);
 
 	for (blkno = startblk; blkno < nblocks; blkno++)
 	{
@@ -685,7 +686,8 @@ pgstathashindex(PG_FUNCTION_ARGS)
 										MAIN_FORKNUM,
 										block_range_read_stream_cb,
 										&p,
-										0);
+										0,
+										NULL);
 
 	for (blkno = startblk; blkno < nblocks; blkno++)
 	{
diff --git a/src/backend/access/brin/brin.c b/src/backend/access/brin/brin.c
index 2a0f8c8e3b8..eb4e4c5de4a 100644
--- a/src/backend/access/brin/brin.c
+++ b/src/backend/access/brin/brin.c
@@ -2192,7 +2192,8 @@ brin_vacuum_scan(Relation idxrel, BufferAccessStrategy strategy)
 										MAIN_FORKNUM,
 										block_range_read_stream_cb,
 										&p,
-										0);
+										0,
+										NULL);
 
 	/*
 	 * Scan the index in physical order, and clean up any possible mess in
diff --git a/src/backend/access/gin/ginvacuum.c b/src/backend/access/gin/ginvacuum.c
index 840543eb664..484360a9d6d 100644
--- a/src/backend/access/gin/ginvacuum.c
+++ b/src/backend/access/gin/ginvacuum.c
@@ -799,7 +799,8 @@ ginvacuumcleanup(IndexVacuumInfo *info, IndexBulkDeleteResult *stats)
 										MAIN_FORKNUM,
 										block_range_read_stream_cb,
 										&p,
-										0);
+										0,
+										NULL);
 
 	for (blkno = GIN_ROOT_BLKNO; blkno < npages; blkno++)
 	{
diff --git a/src/backend/access/gist/gistvacuum.c b/src/backend/access/gist/gistvacuum.c
index 686a0418054..e4f578b2de0 100644
--- a/src/backend/access/gist/gistvacuum.c
+++ b/src/backend/access/gist/gistvacuum.c
@@ -223,7 +223,8 @@ gistvacuumscan(IndexVacuumInfo *info, IndexBulkDeleteResult *stats,
 										MAIN_FORKNUM,
 										block_range_read_stream_cb,
 										&p,
-										0);
+										0,
+										NULL);
 	for (;;)
 	{
 		/* Get the current relation length */
diff --git a/src/backend/access/hash/hash.c b/src/backend/access/hash/hash.c
index 6df5e7ccbd1..043088dbee8 100644
--- a/src/backend/access/hash/hash.c
+++ b/src/backend/access/hash/hash.c
@@ -546,7 +546,8 @@ hashbulkdelete(IndexVacuumInfo *info, IndexBulkDeleteResult *stats,
 										MAIN_FORKNUM,
 										hash_bulkdelete_read_stream_cb,
 										&stream_private,
-										0);
+										0,
+										NULL);
 
 bucket_loop:
 	while (cur_bucket <= cur_maxbucket)
diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c
index 323badd27f1..6acbcf1f342 100644
--- a/src/backend/access/heap/heapam.c
+++ b/src/backend/access/heap/heapam.c
@@ -1267,6 +1267,9 @@ heap_beginscan(Relation relation, Snapshot snapshot,
 
 	scan->rs_read_stream = NULL;
 
+	/* reset the stats counters */
+	memset(&scan->rs_base.rs_iostats, 0, sizeof(IOStats));
+
 	/*
 	 * Set up a read stream for sequential scans and TID range scans. This
 	 * should be done after initscan() because initscan() allocates the
@@ -1296,7 +1299,8 @@ heap_beginscan(Relation relation, Snapshot snapshot,
 														  MAIN_FORKNUM,
 														  cb,
 														  scan,
-														  0);
+														  0,
+														  &scan->rs_base.rs_iostats);
 	}
 	else if (scan->rs_base.rs_flags & SO_TYPE_BITMAPSCAN)
 	{
@@ -1307,7 +1311,8 @@ heap_beginscan(Relation relation, Snapshot snapshot,
 														  MAIN_FORKNUM,
 														  bitmapheap_stream_read_next,
 														  scan,
-														  sizeof(TBMIterateResult));
+														  sizeof(TBMIterateResult),
+														  &scan->rs_base.rs_iostats);
 	}
 
 	scan->rs_vmbuffer = InvalidBuffer;
@@ -1416,38 +1421,6 @@ heap_endscan(TableScanDesc sscan)
 	pfree(scan);
 }
 
-/*
- * heap_scan_stats
- *		return stats collected by the read stream
- *
- * Returns NULL if the scan is not using a read stream.
- */
-TableScanStats
-heap_scan_stats(TableScanDesc sscan)
-{
-	HeapScanDesc scan = (HeapScanDesc) sscan;
-	IOStats stats;
-	TableScanStats res;
-
-	if (!scan->rs_read_stream)
-		return NULL;
-
-	stats = read_stream_prefetch_stats(scan->rs_read_stream);
-
-	res = palloc0(sizeof(TableScanStatsData));
-
-	res->prefetch_count = stats.prefetch_count;
-	res->distance_sum = stats.distance_sum;
-	res->distance_max = stats.distance_max;
-	res->distance_capacity = stats.distance_capacity;
-	res->stall_count = stats.stall_count;
-	res->io_count = stats.io_count;
-	res->io_nblocks = stats.io_nblocks;
-	res->io_in_progress = stats.io_in_progress;
-
-	return res;
-}
-
 HeapTuple
 heap_getnext(TableScanDesc sscan, ScanDirection direction)
 {
diff --git a/src/backend/access/heap/heapam_handler.c b/src/backend/access/heap/heapam_handler.c
index 51ac2164afd..253a735b6c1 100644
--- a/src/backend/access/heap/heapam_handler.c
+++ b/src/backend/access/heap/heapam_handler.c
@@ -2650,7 +2650,6 @@ static const TableAmRoutine heapam_methods = {
 
 	.scan_set_tidrange = heap_set_tidrange,
 	.scan_getnextslot_tidrange = heap_getnextslot_tidrange,
-	.scan_stats = heap_scan_stats,
 
 	.parallelscan_estimate = table_block_parallelscan_estimate,
 	.parallelscan_initialize = table_block_parallelscan_initialize,
diff --git a/src/backend/access/heap/vacuumlazy.c b/src/backend/access/heap/vacuumlazy.c
index 82c5b28e0ad..bec454de6d8 100644
--- a/src/backend/access/heap/vacuumlazy.c
+++ b/src/backend/access/heap/vacuumlazy.c
@@ -1290,7 +1290,8 @@ lazy_scan_heap(LVRelState *vacrel)
 										MAIN_FORKNUM,
 										heap_vac_scan_next_block,
 										vacrel,
-										sizeof(bool));
+										sizeof(bool),
+										NULL);
 
 	while (true)
 	{
@@ -2790,7 +2791,8 @@ lazy_vacuum_heap_rel(LVRelState *vacrel)
 										MAIN_FORKNUM,
 										vacuum_reap_lp_read_stream_next,
 										iter,
-										sizeof(TidStoreIterResult));
+										sizeof(TidStoreIterResult),
+										NULL);
 
 	while (true)
 	{
diff --git a/src/backend/access/nbtree/nbtree.c b/src/backend/access/nbtree/nbtree.c
index aed74590cf4..e5cd51cce4d 100644
--- a/src/backend/access/nbtree/nbtree.c
+++ b/src/backend/access/nbtree/nbtree.c
@@ -1325,7 +1325,8 @@ btvacuumscan(IndexVacuumInfo *info, IndexBulkDeleteResult *stats,
 										MAIN_FORKNUM,
 										block_range_read_stream_cb,
 										&p,
-										0);
+										0,
+										NULL);
 	for (;;)
 	{
 		/* Get the current relation length */
diff --git a/src/backend/access/spgist/spgvacuum.c b/src/backend/access/spgist/spgvacuum.c
index 6b7117b56b2..206a8e9c8b0 100644
--- a/src/backend/access/spgist/spgvacuum.c
+++ b/src/backend/access/spgist/spgvacuum.c
@@ -835,7 +835,8 @@ spgvacuumscan(spgBulkDeleteState *bds)
 										MAIN_FORKNUM,
 										block_range_read_stream_cb,
 										&p,
-										0);
+										0,
+										NULL);
 
 	/*
 	 * The outer loop iterates over all index pages except the metapage, in
diff --git a/src/backend/commands/analyze.c b/src/backend/commands/analyze.c
index eeed91be266..a2cf176a485 100644
--- a/src/backend/commands/analyze.c
+++ b/src/backend/commands/analyze.c
@@ -1251,7 +1251,8 @@ acquire_sample_rows(Relation onerel, int elevel,
 										MAIN_FORKNUM,
 										block_sampling_read_stream_next,
 										&bs,
-										0);
+										0,
+										NULL);
 
 	/* Outer loop over blocks to sample */
 	while (table_scan_analyze_next_block(scan, stream))
diff --git a/src/backend/commands/explain.c b/src/backend/commands/explain.c
index 7a4009ae74f..25af6550098 100644
--- a/src/backend/commands/explain.c
+++ b/src/backend/commands/explain.c
@@ -3998,7 +3998,7 @@ show_tidbitmap_info(BitmapHeapScanState *planstate, ExplainState *es)
 }
 
 static void
-print_io_usage(ExplainState *es, TableScanStats stats)
+print_io_usage(ExplainState *es, IOStats *stats)
 {
 	/* don't print stats if there's nothing to report */
 	if (stats->prefetch_count > 0)
@@ -4067,8 +4067,7 @@ static void
 show_scan_io_usage(ScanState *planstate, ExplainState *es)
 {
 	Plan	   *plan = planstate->ps.plan;
-	TableScanStats	leader_stats;
-	TableScanStatsData	stats;
+	IOStats		stats;
 
 	if (!es->io)
 		return;
@@ -4078,16 +4077,7 @@ show_scan_io_usage(ScanState *planstate, ExplainState *es)
 		return;
 
 	/* collect prefetch statistics from the read stream */
-	leader_stats = table_scan_stats(planstate->ss_currentScanDesc);
-
-	if (leader_stats)
-	{
-		memcpy(&stats, leader_stats, sizeof(TableScanStatsData));
-	}
-	else
-	{
-		memset(&stats, 0, sizeof(TableScanStatsData));
-	}
+	stats = planstate->ss_currentScanDesc->rs_iostats;
 
 	/* Initialize counters with stats from the local process first */
 	switch (nodeTag(plan))
@@ -4146,9 +4136,6 @@ show_io_usage(PlanState *planstate, ExplainState *es, int worker)
 	Plan	   *plan = planstate->plan;
 	IOStats	   *stats = NULL;
 
-	// XXX
-	TableScanStatsData	tmp;
-
 	if (!es->io)
 		return;
 
@@ -4180,17 +4167,7 @@ show_io_usage(PlanState *planstate, ExplainState *es, int worker)
 			return;
 	}
 
-	/* XXX convert to TableScanStats */
-	tmp.distance_capacity = stats->distance_capacity;
-	tmp.distance_max = stats->distance_max;
-	tmp.distance_sum = stats->distance_sum;
-	tmp.io_count = stats->io_count;
-	tmp.io_in_progress = stats->io_in_progress;
-	tmp.io_nblocks = stats->io_nblocks;
-	tmp.prefetch_count = stats->prefetch_count;
-	tmp.stall_count = stats->stall_count;
-
-	print_io_usage(es, &tmp);
+	print_io_usage(es, stats);
 }
 
 /*
diff --git a/src/backend/executor/nodeBitmapHeapscan.c b/src/backend/executor/nodeBitmapHeapscan.c
index 0f1459f11a4..55b1e294d6c 100644
--- a/src/backend/executor/nodeBitmapHeapscan.c
+++ b/src/backend/executor/nodeBitmapHeapscan.c
@@ -315,7 +315,6 @@ ExecEndBitmapHeapScan(BitmapHeapScanState *node)
 	if (node->sinstrument != NULL && IsParallelWorker())
 	{
 		BitmapHeapScanInstrumentation *si;
-		TableScanStats stats;
 
 		Assert(ParallelWorkerNumber < node->sinstrument->num_workers);
 		si = &node->sinstrument->sinstrument[ParallelWorkerNumber];
@@ -331,10 +330,7 @@ ExecEndBitmapHeapScan(BitmapHeapScanState *node)
 		si->lossy_pages += node->stats.lossy_pages;
 
 		/* collect prefetch info for this process from the read_stream */
-		if ((stats = table_scan_stats(node->ss.ss_currentScanDesc)) != NULL)
-		{
-			ACCUMULATE_TABLE_STATS(stats, &si->io);
-		}
+		ACCUMULATE_TABLE_STATS(&node->ss.ss_currentScanDesc->rs_iostats, &si->io);
 	}
 
 	/*
diff --git a/src/backend/executor/nodeSeqscan.c b/src/backend/executor/nodeSeqscan.c
index 4fa85a1b6bc..28e33aa391d 100644
--- a/src/backend/executor/nodeSeqscan.c
+++ b/src/backend/executor/nodeSeqscan.c
@@ -303,7 +303,6 @@ ExecEndSeqScan(SeqScanState *node)
 	if (node->sinstrument != NULL && IsParallelWorker())
 	{
 		SeqScanInstrumentation *si;
-		TableScanStats stats;
 
 		Assert(ParallelWorkerNumber < node->sinstrument->num_workers);
 		si = &node->sinstrument->sinstrument[ParallelWorkerNumber];
@@ -317,10 +316,7 @@ ExecEndSeqScan(SeqScanState *node)
 		 */
 
 		/* collect prefetch info for this process from the read_stream */
-		if ((stats = table_scan_stats(node->ss.ss_currentScanDesc)) != NULL)
-		{
-			ACCUMULATE_TABLE_STATS(stats, &si->io);
-		}
+		ACCUMULATE_TABLE_STATS(&node->ss.ss_currentScanDesc->rs_iostats, &si->io);
 	}
 
 	/*
diff --git a/src/backend/storage/aio/read_stream.c b/src/backend/storage/aio/read_stream.c
index faa52ca4b2c..56b0b8e7fb5 100644
--- a/src/backend/storage/aio/read_stream.c
+++ b/src/backend/storage/aio/read_stream.c
@@ -109,7 +109,7 @@ struct ReadStream
 	bool		temporary;
 
 	/* scan stats counters */
-	IOStats		stats;
+	IOStats	   *stats;
 
 	/*
 	 * One-block buffer to support 'ungetting' a block number, to resolve flow
@@ -186,10 +186,15 @@ block_range_read_stream_cb(ReadStream *stream,
 static inline void
 read_stream_update_stats_prefetch(ReadStream *stream)
 {
-	stream->stats.prefetch_count++;
-	stream->stats.distance_sum += stream->pinned_buffers;
-	if (stream->pinned_buffers > stream->stats.distance_max)
-		stream->stats.distance_max = stream->pinned_buffers;
+	IOStats *stats = stream->stats;
+
+	if (stats == NULL)
+		return;
+
+	stats->prefetch_count++;
+	stats->distance_sum += stream->pinned_buffers;
+	if (stream->pinned_buffers > stats->distance_max)
+		stats->distance_max = stream->pinned_buffers;
 }
 
 /*
@@ -201,15 +206,25 @@ read_stream_update_stats_prefetch(ReadStream *stream)
 static inline void
 read_stream_update_stats_io(ReadStream *stream, int nblocks, int in_progress)
 {
-	stream->stats.io_count++;
-	stream->stats.io_nblocks += nblocks;
-	stream->stats.io_in_progress += in_progress;
+	IOStats *stats = stream->stats;
+
+	if (stats == NULL)
+		return;
+
+	stats->io_count++;
+	stats->io_nblocks += nblocks;
+	stats->io_in_progress += in_progress;
 }
 
 static inline void
 read_stream_update_stats_stall(ReadStream *stream)
 {
-	stream->stats.stall_count++;
+	IOStats *stats = stream->stats;
+
+	if (stats == NULL)
+		return;
+
+	stats->stall_count++;
 }
 
 /*
@@ -587,7 +602,8 @@ read_stream_begin_impl(int flags,
 					   ForkNumber forknum,
 					   ReadStreamBlockNumberCB callback,
 					   void *callback_private_data,
-					   size_t per_buffer_data_size)
+					   size_t per_buffer_data_size,
+					   IOStats *stats)
 {
 	ReadStream *stream;
 	size_t		size;
@@ -746,9 +762,10 @@ read_stream_begin_impl(int flags,
 	stream->seq_until_processed = InvalidBlockNumber;
 	stream->temporary = SmgrIsTemp(smgr);
 
-	/* zero the stats, then set capacity */
-	memset(&stream->stats, 0, sizeof(IOStats));
-	stream->stats.distance_capacity = max_pinned_buffers;
+	/* set capacity */
+	stream->stats = stats;
+	if (stream->stats)
+		stream->stats->distance_capacity = max_pinned_buffers;
 
 	/*
 	 * Skip the initial ramp-up phase if the caller says we're going to be
@@ -789,7 +806,8 @@ read_stream_begin_relation(int flags,
 						   ForkNumber forknum,
 						   ReadStreamBlockNumberCB callback,
 						   void *callback_private_data,
-						   size_t per_buffer_data_size)
+						   size_t per_buffer_data_size,
+						   IOStats *stats)
 {
 	return read_stream_begin_impl(flags,
 								  strategy,
@@ -799,7 +817,8 @@ read_stream_begin_relation(int flags,
 								  forknum,
 								  callback,
 								  callback_private_data,
-								  per_buffer_data_size);
+								  per_buffer_data_size,
+								  stats);
 }
 
 /*
@@ -824,7 +843,8 @@ read_stream_begin_smgr_relation(int flags,
 								  forknum,
 								  callback,
 								  callback_private_data,
-								  per_buffer_data_size);
+								  per_buffer_data_size,
+								  NULL);
 }
 
 /*
@@ -1179,10 +1199,3 @@ read_stream_end(ReadStream *stream)
 	read_stream_reset(stream);
 	pfree(stream);
 }
-
-/* return the prefetch stats for the read_stream */
-IOStats
-read_stream_prefetch_stats(ReadStream *stream)
-{
-	return stream->stats;
-}
diff --git a/src/include/access/heapam.h b/src/include/access/heapam.h
index 81dc0b7521d..2fdc50b865b 100644
--- a/src/include/access/heapam.h
+++ b/src/include/access/heapam.h
@@ -359,7 +359,6 @@ extern void heap_prepare_pagescan(TableScanDesc sscan);
 extern void heap_rescan(TableScanDesc sscan, ScanKey key, bool set_params,
 						bool allow_strat, bool allow_sync, bool allow_pagemode);
 extern void heap_endscan(TableScanDesc sscan);
-extern TableScanStats heap_scan_stats(TableScanDesc sscan);
 extern HeapTuple heap_getnext(TableScanDesc sscan, ScanDirection direction);
 extern bool heap_getnextslot(TableScanDesc sscan,
 							 ScanDirection direction, TupleTableSlot *slot);
diff --git a/src/include/access/relscan.h b/src/include/access/relscan.h
index c0488987b2d..e41f0f8ee13 100644
--- a/src/include/access/relscan.h
+++ b/src/include/access/relscan.h
@@ -16,6 +16,7 @@
 
 #include "access/htup_details.h"
 #include "access/itup.h"
+#include "executor/instrument_node.h"
 #include "nodes/tidbitmap.h"
 #include "port/atomics.h"
 #include "storage/relfilelocator.h"
@@ -62,6 +63,11 @@ typedef struct TableScanDescData
 	 */
 	uint32		rs_flags;
 
+	/*
+	 * IO statistics for the scan, optionally filled by the table AM.
+	 */
+	IOStats		rs_iostats;
+
 	struct ParallelTableScanDescData *rs_parallel;	/* parallel scan
 													 * information */
 } TableScanDescData;
diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h
index ab84b76443c..06084752245 100644
--- a/src/include/access/tableam.h
+++ b/src/include/access/tableam.h
@@ -380,11 +380,6 @@ typedef struct TableAmRoutine
 											  ScanDirection direction,
 											  TupleTableSlot *slot);
 
-	/*
-	 * Collect statistics about table scan.
-	 */
-	TableScanStats		(*scan_stats) (TableScanDesc scan);
-
 	/* ------------------------------------------------------------------------
 	 * Parallel table scan related functions.
 	 * ------------------------------------------------------------------------
@@ -1011,18 +1006,6 @@ table_endscan(TableScanDesc scan)
 	scan->rs_rd->rd_tableam->scan_end(scan);
 }
 
-/*
- * Fetch statistics about table scan.
- */
-static inline TableScanStats
-table_scan_stats(TableScanDesc scan)
-{
-	if (scan->rs_rd->rd_tableam->scan_stats)
-		return scan->rs_rd->rd_tableam->scan_stats(scan);
-
-	return NULL;
-}
-
 /*
  * Restart a relation scan.
  */
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index 96a8a75031f..f287f8fc813 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -1644,7 +1644,6 @@ typedef struct SeqScanState
 {
 	ScanState	ss;				/* its first field is NodeTag */
 	Size		pscan_len;		/* size of parallel heap scan descriptor */
-	SeqScanInstrumentation	stats;
 	SharedSeqScanInstrumentation *sinstrument;
 } SeqScanState;
 
diff --git a/src/include/storage/read_stream.h b/src/include/storage/read_stream.h
index 0676bdc9b14..b56bf925fc1 100644
--- a/src/include/storage/read_stream.h
+++ b/src/include/storage/read_stream.h
@@ -88,7 +88,8 @@ extern ReadStream *read_stream_begin_relation(int flags,
 											  ForkNumber forknum,
 											  ReadStreamBlockNumberCB callback,
 											  void *callback_private_data,
-											  size_t per_buffer_data_size);
+											  size_t per_buffer_data_size,
+											  IOStats *stat);
 extern Buffer read_stream_next_buffer(ReadStream *stream, void **per_buffer_data);
 extern BlockNumber read_stream_next_block(ReadStream *stream,
 										  BufferAccessStrategy *strategy);
@@ -105,6 +106,4 @@ extern void read_stream_resume(ReadStream *stream);
 extern void read_stream_reset(ReadStream *stream);
 extern void read_stream_end(ReadStream *stream);
 
-extern IOStats read_stream_prefetch_stats(ReadStream *stream);
-
 #endif							/* READ_STREAM_H */
-- 
2.53.0



  [text/x-patch] v3-0005-get-rid-of-TableScanStats-Data.patch (5.3K, ../bf1d4799-e2fd-464c-96ff-dc448f021b1e@vondra.me/6-v3-0005-get-rid-of-TableScanStats-Data.patch)
  download | inline diff:
From 940dad96d9810692e660b0f289af7c7ed2bf2022 Mon Sep 17 00:00:00 2001
From: Tomas Vondra <tomas@vondra.me>
Date: Wed, 18 Mar 2026 16:30:28 +0100
Subject: [PATCH v3 5/6] get rid of TableScanStats[Data]

finish replacing it with IOStats
---
 src/backend/commands/explain.c            |  4 +--
 src/backend/executor/nodeBitmapHeapscan.c |  2 +-
 src/backend/executor/nodeSeqscan.c        |  2 +-
 src/include/access/relscan.h              | 41 -----------------------
 src/include/executor/instrument_node.h    | 13 +++++++
 5 files changed, 17 insertions(+), 45 deletions(-)

diff --git a/src/backend/commands/explain.c b/src/backend/commands/explain.c
index 25af6550098..1b0feeadd9e 100644
--- a/src/backend/commands/explain.c
+++ b/src/backend/commands/explain.c
@@ -4093,7 +4093,7 @@ show_scan_io_usage(ScanState *planstate, ExplainState *es)
 					for (int i = 0; i < sinstrument->num_workers; ++i)
 					{
 						SeqScanInstrumentation *winstrument = &sinstrument->sinstrument[i];
-						ACCUMULATE_TABLE_STATS(&stats, &winstrument->io);
+						ACCUMULATE_IO_STATS(&stats, &winstrument->io);
 					}
 				}
 
@@ -4110,7 +4110,7 @@ show_scan_io_usage(ScanState *planstate, ExplainState *es)
 					for (int i = 0; i < sinstrument->num_workers; ++i)
 					{
 						BitmapHeapScanInstrumentation *winstrument = &sinstrument->sinstrument[i];
-						ACCUMULATE_TABLE_STATS(&stats, &winstrument->io);
+						ACCUMULATE_IO_STATS(&stats, &winstrument->io);
 					}
 				}
 
diff --git a/src/backend/executor/nodeBitmapHeapscan.c b/src/backend/executor/nodeBitmapHeapscan.c
index 55b1e294d6c..b785cb7a237 100644
--- a/src/backend/executor/nodeBitmapHeapscan.c
+++ b/src/backend/executor/nodeBitmapHeapscan.c
@@ -330,7 +330,7 @@ ExecEndBitmapHeapScan(BitmapHeapScanState *node)
 		si->lossy_pages += node->stats.lossy_pages;
 
 		/* collect prefetch info for this process from the read_stream */
-		ACCUMULATE_TABLE_STATS(&node->ss.ss_currentScanDesc->rs_iostats, &si->io);
+		ACCUMULATE_IO_STATS(&si->io, &node->ss.ss_currentScanDesc->rs_iostats);
 	}
 
 	/*
diff --git a/src/backend/executor/nodeSeqscan.c b/src/backend/executor/nodeSeqscan.c
index 28e33aa391d..2ecc0e3054d 100644
--- a/src/backend/executor/nodeSeqscan.c
+++ b/src/backend/executor/nodeSeqscan.c
@@ -316,7 +316,7 @@ ExecEndSeqScan(SeqScanState *node)
 		 */
 
 		/* collect prefetch info for this process from the read_stream */
-		ACCUMULATE_TABLE_STATS(&node->ss.ss_currentScanDesc->rs_iostats, &si->io);
+		ACCUMULATE_IO_STATS(&si->io, &node->ss.ss_currentScanDesc->rs_iostats);
 	}
 
 	/*
diff --git a/src/include/access/relscan.h b/src/include/access/relscan.h
index e41f0f8ee13..32ef27c546f 100644
--- a/src/include/access/relscan.h
+++ b/src/include/access/relscan.h
@@ -120,47 +120,6 @@ typedef struct ParallelBlockTableScanWorkerData
 } ParallelBlockTableScanWorkerData;
 typedef struct ParallelBlockTableScanWorkerData *ParallelBlockTableScanWorker;
 
-/*
- * Generic prefetch stats for table scans.
- */
-typedef struct TableScanStatsData
-{
-	/* number of buffers returned to consumer (for averaging distance) */
-	uint64		prefetch_count;
-
-	/* sum of pinned_buffers sampled at each buffer return */
-	uint64		distance_sum;
-
-	/* maximum actual pinned_buffers observed during the scan */
-	int16		distance_max;
-
-	/* maximum possible look-ahead distance (max_pinned_buffers) */
-	int16		distance_capacity;
-
-	/* number of stalled reads (waiting for I/O) */
-	uint64		stall_count;
-
-	/* I/O stats */
-	uint64		io_count;		/* number of I/Os */
-	uint64		io_nblocks;		/* sum of blocks for all I/Os */
-	uint64		io_in_progress;	/* sum of in-progress I/Os */
-} TableScanStatsData;
-typedef struct TableScanStatsData *TableScanStats;
-
-#define ACCUMULATE_TABLE_STATS(dst, src) \
-	do { \
-		(dst)->prefetch_count += (src)->prefetch_count; \
-		(dst)->distance_sum += (src)->distance_sum; \
-		if ((src)->distance_max > (dst)->distance_max) \
-			(dst)->distance_max = (src)->distance_max; \
-		if ((src)->distance_capacity > (dst)->distance_capacity) \
-			(dst)->distance_capacity = (src)->distance_capacity; \
-		(dst)->stall_count += (src)->stall_count; \
-		(dst)->io_count += (src)->io_count; \
-		(dst)->io_nblocks += (src)->io_nblocks; \
-		(dst)->io_in_progress += (src)->io_in_progress; \
-	} while (0)
-
 /*
  * Base class for fetches from a table via an index. This is the base-class
  * for such scans, which needs to be embedded in the respective struct for
diff --git a/src/include/executor/instrument_node.h b/src/include/executor/instrument_node.h
index cda00ad40e9..ce643ef0635 100644
--- a/src/include/executor/instrument_node.h
+++ b/src/include/executor/instrument_node.h
@@ -67,6 +67,19 @@ typedef struct IOStats
 	uint64		io_in_progress;	/* sum of in-progress I/Os */
 } IOStats;
 
+#define ACCUMULATE_IO_STATS(dst, src) \
+	do { \
+		(dst)->prefetch_count += (src)->prefetch_count; \
+		(dst)->distance_sum += (src)->distance_sum; \
+		if ((src)->distance_max > (dst)->distance_max) \
+			(dst)->distance_max = (src)->distance_max; \
+		if ((src)->distance_capacity > (dst)->distance_capacity) \
+			(dst)->distance_capacity = (src)->distance_capacity; \
+		(dst)->stall_count += (src)->stall_count; \
+		(dst)->io_count += (src)->io_count; \
+		(dst)->io_nblocks += (src)->io_nblocks; \
+		(dst)->io_in_progress += (src)->io_in_progress; \
+	} while (0)
 
 /* ---------------------
  *	Instrumentation information for sequential scans
-- 
2.53.0



  [text/x-patch] v3-0006-show-prefetch-stats-for-TidRangeScan.patch (9.9K, ../bf1d4799-e2fd-464c-96ff-dc448f021b1e@vondra.me/7-v3-0006-show-prefetch-stats-for-TidRangeScan.patch)
  download | inline diff:
From 8566941dfeb29f06880984bcd99db04ac3029a82 Mon Sep 17 00:00:00 2001
From: Tomas Vondra <tomas@vondra.me>
Date: Wed, 18 Mar 2026 20:55:00 +0100
Subject: [PATCH v3 6/6] show prefetch stats for TidRangeScan

- enable show_scan_io_usage for TidRangeScan

- add infrastructure to allocate/collect instrumentation from parallel
  workers
---
 src/backend/commands/explain.c          |  28 ++++++
 src/backend/executor/execParallel.c     |   3 +
 src/backend/executor/nodeTidrangescan.c | 110 ++++++++++++++++++++++--
 src/include/executor/instrument_node.h  |  11 +++
 src/include/executor/nodeTidrangescan.h |   1 +
 src/include/nodes/execnodes.h           |   1 +
 6 files changed, 149 insertions(+), 5 deletions(-)

diff --git a/src/backend/commands/explain.c b/src/backend/commands/explain.c
index 1b0feeadd9e..ee32fbafa9d 100644
--- a/src/backend/commands/explain.c
+++ b/src/backend/commands/explain.c
@@ -2152,6 +2152,7 @@ ExplainNode(PlanState *planstate, List *ancestors,
 				if (plan->qual)
 					show_instrumentation_count("Rows Removed by Filter", 1,
 											   planstate, es);
+				show_scan_io_usage((ScanState *) planstate, es);
 			}
 			break;
 		case T_ForeignScan:
@@ -4114,6 +4115,23 @@ show_scan_io_usage(ScanState *planstate, ExplainState *es)
 					}
 				}
 
+				break;
+			}
+		case T_TidRangeScan:
+			{
+				SharedTidRangeScanInstrumentation *sinstrument
+					= ((TidRangeScanState *) planstate)->trss_sinstrument;
+
+				/* get the sum of the counters set within each and every process */
+				if (sinstrument)
+				{
+					for (int i = 0; i < sinstrument->num_workers; ++i)
+					{
+						TidRangeScanInstrumentation *winstrument = &sinstrument->sinstrument[i];
+						ACCUMULATE_IO_STATS(&stats, &winstrument->io);
+					}
+				}
+
 				break;
 			}
 		default:
@@ -4160,6 +4178,16 @@ show_io_usage(PlanState *planstate, ExplainState *es, int worker)
 
 				stats = &instrument->io;
 
+				break;
+			}
+		case T_TidRangeScan:
+			{
+				TidRangeScanState *state = ((TidRangeScanState *) planstate);
+				SharedTidRangeScanInstrumentation *sinstrument = state->trss_sinstrument;
+				TidRangeScanInstrumentation *instrument = &sinstrument->sinstrument[worker];
+
+				stats = &instrument->io;
+
 				break;
 			}
 		default:
diff --git a/src/backend/executor/execParallel.c b/src/backend/executor/execParallel.c
index ffc708ed6be..62b26bfd18a 100644
--- a/src/backend/executor/execParallel.c
+++ b/src/backend/executor/execParallel.c
@@ -1122,6 +1122,9 @@ ExecParallelRetrieveInstrumentation(PlanState *planstate,
 		case T_SeqScanState:
 			ExecSeqScanRetrieveInstrumentation((SeqScanState *) planstate);
 			break;
+		case T_TidRangeScanState:
+			ExecTidRangeScanRetrieveInstrumentation((TidRangeScanState *) planstate);
+			break;
 		default:
 			break;
 	}
diff --git a/src/backend/executor/nodeTidrangescan.c b/src/backend/executor/nodeTidrangescan.c
index 617713bde04..4ff6e333144 100644
--- a/src/backend/executor/nodeTidrangescan.c
+++ b/src/backend/executor/nodeTidrangescan.c
@@ -340,6 +340,29 @@ ExecEndTidRangeScan(TidRangeScanState *node)
 {
 	TableScanDesc scan = node->ss.ss_currentScanDesc;
 
+	/*
+	 * When ending a parallel worker, copy the statistics gathered by the
+	 * worker back into shared memory so that it can be picked up by the main
+	 * process to report in EXPLAIN ANALYZE.
+	 */
+	if (node->trss_sinstrument != NULL && IsParallelWorker())
+	{
+		TidRangeScanInstrumentation *si;
+
+		Assert(ParallelWorkerNumber < node->trss_sinstrument->num_workers);
+		si = &node->trss_sinstrument->sinstrument[ParallelWorkerNumber];
+
+		/*
+		 * Here we accumulate the stats rather than performing memcpy on
+		 * node->stats into si.  When a Gather/GatherMerge node finishes it
+		 * will perform planner shutdown on the workers.  On rescan it will
+		 * spin up new workers which will have a new state and zeroed stats.
+		 */
+
+		/* collect prefetch info for this process from the read_stream */
+		ACCUMULATE_IO_STATS(&si->io, &node->ss.ss_currentScanDesc->rs_iostats);
+	}
+
 	if (scan != NULL)
 		table_endscan(scan);
 }
@@ -433,11 +456,23 @@ void
 ExecTidRangeScanEstimate(TidRangeScanState *node, ParallelContext *pcxt)
 {
 	EState	   *estate = node->ss.ps.state;
+	Size		size;
+
+	size = table_parallelscan_estimate(node->ss.ss_currentRelation,
+									   estate->es_snapshot);
+	node->trss_pscanlen = size;
+
+	/* make sure the instrumentation is properly aligned */
+	size = MAXALIGN(size);
 
-	node->trss_pscanlen =
-		table_parallelscan_estimate(node->ss.ss_currentRelation,
-									estate->es_snapshot);
-	shm_toc_estimate_chunk(&pcxt->estimator, node->trss_pscanlen);
+	/* account for instrumentation, if required */
+	if (node->ss.ps.instrument && pcxt->nworkers > 0)
+	{
+		size = add_size(size, offsetof(SharedTidRangeScanInstrumentation, sinstrument));
+		size = add_size(size, mul_size(pcxt->nworkers, sizeof(TidRangeScanInstrumentation)));
+	}
+
+	shm_toc_estimate_chunk(&pcxt->estimator, size);
 	shm_toc_estimate_keys(&pcxt->estimator, 1);
 }
 
@@ -452,8 +487,18 @@ ExecTidRangeScanInitializeDSM(TidRangeScanState *node, ParallelContext *pcxt)
 {
 	EState	   *estate = node->ss.ps.state;
 	ParallelTableScanDesc pscan;
+	SharedTidRangeScanInstrumentation *sinstrument = NULL;
+	Size	size;
+	char   *ptr;
 
-	pscan = shm_toc_allocate(pcxt->toc, node->trss_pscanlen);
+	size = MAXALIGN(node->trss_pscanlen);
+	if (node->ss.ps.instrument && pcxt->nworkers > 0)
+	{
+		size = add_size(size, offsetof(SharedTidRangeScanInstrumentation, sinstrument));
+		size = add_size(size, mul_size(pcxt->nworkers, sizeof(TidRangeScanInstrumentation)));
+	}
+
+	pscan = shm_toc_allocate(pcxt->toc, size);
 	table_parallelscan_initialize(node->ss.ss_currentRelation,
 								  pscan,
 								  estate->es_snapshot);
@@ -461,6 +506,23 @@ ExecTidRangeScanInitializeDSM(TidRangeScanState *node, ParallelContext *pcxt)
 	node->ss.ss_currentScanDesc =
 		table_beginscan_parallel_tidrange(node->ss.ss_currentRelation,
 										  pscan);
+
+	/* initialize the shared instrumentation (with correct alignment) */
+	ptr = (char *) pscan;
+	ptr += MAXALIGN(node->trss_pscanlen);
+	if (node->ss.ps.instrument && pcxt->nworkers > 0)
+		sinstrument = (SharedTidRangeScanInstrumentation *) ptr;
+
+	if (sinstrument)
+	{
+		sinstrument->num_workers = pcxt->nworkers;
+
+		/* ensure any unfilled slots will contain zeroes */
+		memset(sinstrument->sinstrument, 0,
+			   pcxt->nworkers * sizeof(TidRangeScanInstrumentation));
+	}
+
+	node->trss_sinstrument = sinstrument;
 }
 
 /* ----------------------------------------------------------------
@@ -489,10 +551,48 @@ void
 ExecTidRangeScanInitializeWorker(TidRangeScanState *node,
 								 ParallelWorkerContext *pwcxt)
 {
+	EState	   *estate = node->ss.ps.state;
 	ParallelTableScanDesc pscan;
+	char	   *ptr;
+	Size		size;
 
 	pscan = shm_toc_lookup(pwcxt->toc, node->ss.ps.plan->plan_node_id, false);
 	node->ss.ss_currentScanDesc =
 		table_beginscan_parallel_tidrange(node->ss.ss_currentRelation,
 										  pscan);
+
+	/*
+	 * Workers don't get the trss_pscanlen value in scan descriptor, so use
+	 * the TAM callback again. The result has to match the earlier result in
+	 * ExecTidRangeScanEstimate.
+	 */
+	size = table_parallelscan_estimate(node->ss.ss_currentRelation,
+									   estate->es_snapshot);
+	ptr = (char *) pscan;
+	ptr += MAXALIGN(size);
+
+	if (node->ss.ps.instrument)
+		node->trss_sinstrument = (SharedTidRangeScanInstrumentation *) ptr;
+}
+
+/* ----------------------------------------------------------------
+ *		ExecTidRangeScanRetrieveInstrumentation
+ *
+ *		Transfer scan statistics from DSM to private memory.
+ * ----------------------------------------------------------------
+ */
+void
+ExecTidRangeScanRetrieveInstrumentation(TidRangeScanState *node)
+{
+	SharedTidRangeScanInstrumentation *sinstrument = node->trss_sinstrument;
+	Size		size;
+
+	if (sinstrument == NULL)
+		return;
+
+	size = offsetof(SharedTidRangeScanInstrumentation, sinstrument)
+		+ sinstrument->num_workers * sizeof(TidRangeScanInstrumentation);
+
+	node->trss_sinstrument = palloc(size);
+	memcpy(node->trss_sinstrument, sinstrument, size);
 }
diff --git a/src/include/executor/instrument_node.h b/src/include/executor/instrument_node.h
index ce643ef0635..b33b296ce03 100644
--- a/src/include/executor/instrument_node.h
+++ b/src/include/executor/instrument_node.h
@@ -99,6 +99,17 @@ typedef struct SharedSeqScanInstrumentation
 	SeqScanInstrumentation sinstrument[FLEXIBLE_ARRAY_MEMBER];
 } SharedSeqScanInstrumentation;
 
+typedef struct TidRangeScanInstrumentation
+{
+	IOStats		io;
+} TidRangeScanInstrumentation;
+
+typedef struct SharedTidRangeScanInstrumentation
+{
+	int			num_workers;
+	TidRangeScanInstrumentation sinstrument[FLEXIBLE_ARRAY_MEMBER];
+} SharedTidRangeScanInstrumentation;
+
 typedef struct IndexScanInstrumentation
 {
 	/* Index search count (incremented with pgstat_count_index_scan call) */
diff --git a/src/include/executor/nodeTidrangescan.h b/src/include/executor/nodeTidrangescan.h
index 8752d1ea8c4..4f5dd74e0fc 100644
--- a/src/include/executor/nodeTidrangescan.h
+++ b/src/include/executor/nodeTidrangescan.h
@@ -27,5 +27,6 @@ extern void ExecTidRangeScanEstimate(TidRangeScanState *node, ParallelContext *p
 extern void ExecTidRangeScanInitializeDSM(TidRangeScanState *node, ParallelContext *pcxt);
 extern void ExecTidRangeScanReInitializeDSM(TidRangeScanState *node, ParallelContext *pcxt);
 extern void ExecTidRangeScanInitializeWorker(TidRangeScanState *node, ParallelWorkerContext *pwcxt);
+extern void ExecTidRangeScanRetrieveInstrumentation(TidRangeScanState *node);
 
 #endif							/* NODETIDRANGESCAN_H */
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index f287f8fc813..611a6280987 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -1896,6 +1896,7 @@ typedef struct TidRangeScanState
 	ItemPointerData trss_maxtid;
 	bool		trss_inScan;
 	Size		trss_pscanlen;
+	SharedTidRangeScanInstrumentation *trss_sinstrument;
 } TidRangeScanState;
 
 /* ----------------
-- 
2.53.0



view thread (59+ messages)  latest in thread

Message-ID: <bf1d4799-e2fd-464c-96ff-dc448f021b1e@vondra.me>
Permalink:  ../bf1d4799-e2fd-464c-96ff-dc448f021b1e@vondra.me/
Also on:    postgresql.org/message-id/bf1d4799-e2fd-464c-96ff-dc448f021b1e@vondra.me

 ·  · 

reply

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Reply to all the recipients using the --to and --cc options:
  reply via email

  To: pgsql-hackers@postgresql.org
  Cc: tomas@vondra.me, andres@anarazel.de, lukas@fittl.com, melanieplageman@gmail.com, pgsql-hackers@lists.postgresql.org
  Subject: Re: EXPLAIN: showing ReadStream / prefetch stats
  In-Reply-To: <bf1d4799-e2fd-464c-96ff-dc448f021b1e@vondra.me>

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

This inbox is served by DDX for PostgreSQL; see mirroring instructions
for how to clone and mirror all data and code used for this inbox