From 8beefb53e7fa94a060456d1321f36abb221cbe47 Mon Sep 17 00:00:00 2001 From: Amit Langote Date: Sat, 20 Dec 2025 23:09:37 +0900 Subject: [PATCH v7 5/5] Add EXPLAIN (BATCHES) option for tuple batching statistics Add a BATCHES option to EXPLAIN that reports per-node batch statistics when a node uses batch mode execution. For nodes that support batching (currently SeqScan), this shows the number of batches fetched along with average, minimum, and maximum rows per batch. Output is supported in both text and non-text formats. Add regression tests covering text output, JSON format, filtered scans, LIMIT, and disabled batching. Discussion: https://postgr.es/m/CA+HiwqFfAY_ZFqN8wcAEMw71T9hM_kA8UtyHaZZEZtuT3UyogA@mail.gmail.com --- src/backend/commands/explain.c | 44 +++++++++++ src/backend/commands/explain_state.c | 8 ++ src/backend/executor/execRowBatch.c | 44 ++++++++++- src/backend/executor/nodeSeqscan.c | 8 +- src/include/commands/explain_state.h | 1 + src/include/executor/execRowBatch.h | 22 +++++- src/include/executor/instrument.h | 1 + src/test/regress/expected/explain.out | 107 ++++++++++++++++++++++++++ src/test/regress/sql/explain.sql | 59 ++++++++++++++ 9 files changed, 291 insertions(+), 3 deletions(-) diff --git a/src/backend/commands/explain.c b/src/backend/commands/explain.c index 73eaaf176ac..8c98ca57c92 100644 --- a/src/backend/commands/explain.c +++ b/src/backend/commands/explain.c @@ -22,6 +22,7 @@ #include "commands/explain_format.h" #include "commands/explain_state.h" #include "commands/prepare.h" +#include "executor/execRowBatch.h" #include "foreign/fdwapi.h" #include "jit/jit.h" #include "libpq/pqformat.h" @@ -519,6 +520,8 @@ ExplainOnePlan(PlannedStmt *plannedstmt, IntoClause *into, ExplainState *es, instrument_option |= INSTRUMENT_BUFFERS; if (es->wal) instrument_option |= INSTRUMENT_WAL; + if (es->batches) + instrument_option |= INSTRUMENT_BATCHES; /* * We always collect timing for the entire statement, even when node-level @@ -1370,6 +1373,7 @@ ExplainNode(PlanState *planstate, List *ancestors, int save_indent = es->indent; bool haschildren; bool isdisabled; + RowBatch *batch = NULL; /* * Prepare per-worker output buffers, if needed. We'll append the data in @@ -2296,6 +2300,46 @@ ExplainNode(PlanState *planstate, List *ancestors, if (es->wal && planstate->instrument) show_wal_usage(es, &planstate->instrument->instr.walusage); + /* BATCHES */ + switch (nodeTag(plan)) + { + case T_SeqScan: + batch = castNode(SeqScanState, planstate)->batch; + break; + default: + break; + } + + if (es->batches && batch) + { + RowBatchStats *stats = batch->stats; + + Assert(stats); + if (stats->batches > 0) + { + if (es->format == EXPLAIN_FORMAT_TEXT) + { + ExplainIndentText(es); + appendStringInfo(es->str, + "Batches: %lld Avg Rows: %.1f Max: %d Min: %d\n", + (long long) stats->batches, + RowBatchAvgRows(batch), stats->max_rows, + stats->min_rows == INT_MAX ? 0 : + stats->min_rows); + } + else + { + ExplainPropertyInteger("Batches", NULL, stats->batches, es); + ExplainPropertyFloat("Average Batch Rows", NULL, + RowBatchAvgRows(batch), 1, es); + ExplainPropertyInteger("Max Batch Rows", NULL, stats->max_rows, es); + ExplainPropertyInteger("Min Batch Rows", NULL, + stats->min_rows == INT_MAX ? 0 : + stats->min_rows, es); + } + } + } + /* Prepare per-worker buffer/WAL usage */ if (es->workers_state && (es->buffers || es->wal) && es->verbose) { diff --git a/src/backend/commands/explain_state.c b/src/backend/commands/explain_state.c index 77f59b8e500..28022a171cd 100644 --- a/src/backend/commands/explain_state.c +++ b/src/backend/commands/explain_state.c @@ -159,6 +159,8 @@ ParseExplainOptionList(ExplainState *es, List *options, ParseState *pstate) "EXPLAIN", opt->defname, p), parser_errposition(pstate, opt->location))); } + else if (strcmp(opt->defname, "batches") == 0) + es->batches = defGetBoolean(opt); else if (!ApplyExtensionExplainOption(es, opt, pstate)) ereport(ERROR, (errcode(ERRCODE_SYNTAX_ERROR), @@ -198,6 +200,12 @@ ParseExplainOptionList(ExplainState *es, List *options, ParseState *pstate) errmsg("%s options %s and %s cannot be used together", "EXPLAIN", "ANALYZE", "GENERIC_PLAN"))); + /* check that BATCHES is used with EXPLAIN ANALYZE */ + if (es->batches && !es->analyze) + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("EXPLAIN option %s requires ANALYZE", "BATCHES"))); + /* if the summary was not set explicitly, set default value */ es->summary = (summary_set) ? es->summary : es->analyze; diff --git a/src/backend/executor/execRowBatch.c b/src/backend/executor/execRowBatch.c index 6a298813bd8..6ef54deca04 100644 --- a/src/backend/executor/execRowBatch.c +++ b/src/backend/executor/execRowBatch.c @@ -20,7 +20,7 @@ * Allocate and initialize a new RowBatch envelope. */ RowBatch * -RowBatchCreate(int max_rows) +RowBatchCreate(int max_rows, bool track_stats) { RowBatch *b; @@ -35,6 +35,20 @@ RowBatchCreate(int max_rows) b->materialized = false; b->slot = NULL; + if (track_stats) + { + RowBatchStats *stats = palloc_object(RowBatchStats); + + stats->batches = 0; + stats->rows = 0; + stats->max_rows = 0; + stats->min_rows = INT_MAX; + + b->stats = stats; + } + else + b->stats = NULL; + return b; } @@ -52,3 +66,31 @@ RowBatchReset(RowBatch *b, bool drop_slots) b->materialized = false; /* b->slot belongs to the owning PlanState node */ } + +void +RowBatchRecordStats(RowBatch *b, int rows) +{ + RowBatchStats *stats = b->stats; + + if (stats == NULL) + return; + + stats->batches++; + stats->rows += rows; + if (rows > stats->max_rows) + stats->max_rows = rows; + if (rows < stats->min_rows && rows > 0) + stats->min_rows = rows; +} + +double +RowBatchAvgRows(RowBatch *b) +{ + RowBatchStats *stats = b->stats; + + Assert(stats != NULL); + if (stats->batches == 0) + return 0.0; + + return (double) stats->rows / stats->batches; +} diff --git a/src/backend/executor/nodeSeqscan.c b/src/backend/executor/nodeSeqscan.c index d0ce8858c49..135b0a4f9a2 100644 --- a/src/backend/executor/nodeSeqscan.c +++ b/src/backend/executor/nodeSeqscan.c @@ -247,8 +247,12 @@ SeqScanCanUseBatching(SeqScanState *scanstate, int eflags) static void SeqScanInitBatching(SeqScanState *scanstate) { - RowBatch *batch = RowBatchCreate(MaxHeapTuplesPerPage); + RowBatch *batch; + EState *estate = scanstate->ss.ps.state; + bool track_stats = estate->es_instrument && + (estate->es_instrument & INSTRUMENT_BATCHES); + batch = RowBatchCreate(MaxHeapTuplesPerPage, track_stats); batch->slot = scanstate->ss.ss_ScanTupleSlot; scanstate->batch = batch; @@ -351,6 +355,8 @@ SeqNextBatch(SeqScanState *node) if (!table_scan_getnextbatch(scandesc, b, direction)) return false; + RowBatchRecordStats(b, b->nrows); + return true; } diff --git a/src/include/commands/explain_state.h b/src/include/commands/explain_state.h index 5a48bc6fbb1..579ca4cfa20 100644 --- a/src/include/commands/explain_state.h +++ b/src/include/commands/explain_state.h @@ -56,6 +56,7 @@ typedef struct ExplainState bool memory; /* print planner's memory usage information */ bool settings; /* print modified settings */ bool generic; /* generate a generic plan */ + bool batches; /* print batch statistics */ ExplainSerializeOption serialize; /* serialize the query's output? */ ExplainFormat format; /* output format */ /* state for output formatting --- not reset for each new plan tree */ diff --git a/src/include/executor/execRowBatch.h b/src/include/executor/execRowBatch.h index 021fdeecc73..ad0b4763b70 100644 --- a/src/include/executor/execRowBatch.h +++ b/src/include/executor/execRowBatch.h @@ -13,9 +13,12 @@ #ifndef EXECROWBATCH_H #define EXECROWBATCH_H +#include + #include "executor/tuptable.h" typedef struct RowBatchOps RowBatchOps; +typedef struct RowBatchStats RowBatchStats; /* * RowBatch @@ -38,6 +41,9 @@ typedef struct RowBatch bool materialized; /* tuples in slots valid? */ TupleTableSlot *slot; /* row view */ + + RowBatchStats *stats; /* NULL if instrumentation stats + * are not requested */ } RowBatch; /* @@ -58,8 +64,17 @@ typedef struct RowBatchOps void (*repoint_slot) (RowBatch *b, int idx); } RowBatchOps; +/* Instrumentation stats populated for EXPLAIN ANALYZE BATCHES */ +typedef struct RowBatchStats +{ + int64 batches; /* total number of batches fetched */ + int64 rows; /* total tuples across all batches */ + int max_rows; /* max rows in any single batch */ + int min_rows; /* min rows in any single batch (non-zero) */ +} RowBatchStats; + /* Create/teardown */ -extern RowBatch *RowBatchCreate(int max_rows); +extern RowBatch *RowBatchCreate(int max_rows, bool track_stats); extern void RowBatchReset(RowBatch *b, bool drop_slots); /* Validation */ @@ -85,4 +100,9 @@ RowBatchGetNextSlot(RowBatch *b) return b->slot; } +/* === Batching stats. ===*/ + +extern void RowBatchRecordStats(RowBatch *b, int rows); +extern double RowBatchAvgRows(RowBatch *b); + #endif /* EXECROWBATCH_H */ diff --git a/src/include/executor/instrument.h b/src/include/executor/instrument.h index cc9fbb0e2f0..89df74a86c1 100644 --- a/src/include/executor/instrument.h +++ b/src/include/executor/instrument.h @@ -64,6 +64,7 @@ typedef enum InstrumentOption INSTRUMENT_BUFFERS = 1 << 1, /* needs buffer usage */ INSTRUMENT_ROWS = 1 << 2, /* needs row count */ INSTRUMENT_WAL = 1 << 3, /* needs WAL usage */ + INSTRUMENT_BATCHES = 1 << 4, /* needs batches */ INSTRUMENT_ALL = PG_INT32_MAX } InstrumentOption; diff --git a/src/test/regress/expected/explain.out b/src/test/regress/expected/explain.out index 7c1f26b182c..950de5a9d78 100644 --- a/src/test/regress/expected/explain.out +++ b/src/test/regress/expected/explain.out @@ -822,3 +822,110 @@ select explain_filter('explain (analyze,buffers off,costs off) select sum(n) ove (9 rows) reset work_mem; +-- Test BATCHES option +set executor_batch_rows = 64; +create temp table batch_test (a int, b text); +insert into batch_test select i, repeat('x', 100) from generate_series(1, 10000) i; +analyze batch_test; +-- BATCHES without ANALYZE should error +explain (batches, costs off) select * from batch_test; +ERROR: EXPLAIN option BATCHES requires ANALYZE +-- BATCHES without ANALYZE but with other options +explain (batches, buffers off, costs off) select * from batch_test; +ERROR: EXPLAIN option BATCHES requires ANALYZE +-- Basic: verify batch stats line appears in text format +select explain_filter('explain (analyze, batches, buffers off, costs off) select * from batch_test'); + explain_filter +---------------------------------------------------------------- + Seq Scan on batch_test (actual time=N.N..N.N rows=N.N loops=N) + Batches: N Avg Rows: N.N Max: N Min: N + Planning Time: N.N ms + Execution Time: N.N ms +(4 rows) + +-- With filter: batch line still appears +select explain_filter('explain (analyze, batches, buffers off, costs off) select * from batch_test where a > 5000'); + explain_filter +---------------------------------------------------------------- + Seq Scan on batch_test (actual time=N.N..N.N rows=N.N loops=N) + Filter: (a > N) + Rows Removed by Filter: N + Batches: N Avg Rows: N.N Max: N Min: N + Planning Time: N.N ms + Execution Time: N.N ms +(6 rows) + +-- With non-batchable qual (OR): batching still active but +-- batch qual falls back to per-tuple ExecQual +select explain_filter('explain (analyze, batches, buffers off, costs off) select * from batch_test where a > 5000 or b is null'); + explain_filter +---------------------------------------------------------------- + Seq Scan on batch_test (actual time=N.N..N.N rows=N.N loops=N) + Filter: ((a > N) OR (b IS NULL)) + Rows Removed by Filter: N + Batches: N Avg Rows: N.N Max: N Min: N + Planning Time: N.N ms + Execution Time: N.N ms +(6 rows) + +-- With LIMIT: batch stats appear on child Seq Scan node +select explain_filter('explain (analyze, batches, buffers off, costs off) select * from batch_test limit 100'); + explain_filter +---------------------------------------------------------------------- + Limit (actual time=N.N..N.N rows=N.N loops=N) + -> Seq Scan on batch_test (actual time=N.N..N.N rows=N.N loops=N) + Batches: N Avg Rows: N.N Max: N Min: N + Planning Time: N.N ms + Execution Time: N.N ms +(5 rows) + +-- Verify batch stats keys present in JSON output +select + j #> '{0,Plan}' ? 'Batches' as has_batches, + j #> '{0,Plan}' ? 'Average Batch Rows' as has_avg, + j #> '{0,Plan}' ? 'Max Batch Rows' as has_max, + j #> '{0,Plan}' ? 'Min Batch Rows' as has_min +from explain_filter_to_json( + 'explain (analyze, batches, buffers off, format json) select * from batch_test' +) as j; + has_batches | has_avg | has_max | has_min +-------------+---------+---------+--------- + t | t | t | t +(1 row) + +-- With LIMIT: batch stats keys on child node in JSON +select + j #> '{0,Plan,Plans,0}' ? 'Batches' as child_has_batches, + j #> '{0,Plan,Plans,0}' ? 'Average Batch Rows' as child_has_avg, + j #> '{0,Plan,Plans,0}' ? 'Max Batch Rows' as child_has_max, + j #> '{0,Plan,Plans,0}' ? 'Min Batch Rows' as child_has_min +from explain_filter_to_json( + 'explain (analyze, batches, buffers off, format json) select * from batch_test limit 100' +) as j; + child_has_batches | child_has_avg | child_has_max | child_has_min +-------------------+---------------+---------------+--------------- + t | t | t | t +(1 row) + +-- Batching disabled: no batch stats in text output +set executor_batch_rows = 0; +select explain_filter('explain (analyze, batches, buffers off, costs off) select * from batch_test'); + explain_filter +---------------------------------------------------------------- + Seq Scan on batch_test (actual time=N.N..N.N rows=N.N loops=N) + Planning Time: N.N ms + Execution Time: N.N ms +(3 rows) + +-- Batching disabled: no batch keys in JSON +select + j #> '{0,Plan}' ? 'Batches' as has_batches +from explain_filter_to_json( + 'explain (analyze, batches, buffers off, format json) select * from batch_test' +) as j; + has_batches +------------- + f +(1 row) + +reset executor_batch_rows; diff --git a/src/test/regress/sql/explain.sql b/src/test/regress/sql/explain.sql index ebdab42604b..55acb9058ce 100644 --- a/src/test/regress/sql/explain.sql +++ b/src/test/regress/sql/explain.sql @@ -188,3 +188,62 @@ select explain_filter('explain (analyze,buffers off,costs off) select sum(n) ove -- 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))'); reset work_mem; + +-- Test BATCHES option +set executor_batch_rows = 64; + +create temp table batch_test (a int, b text); +insert into batch_test select i, repeat('x', 100) from generate_series(1, 10000) i; +analyze batch_test; + +-- BATCHES without ANALYZE should error +explain (batches, costs off) select * from batch_test; + +-- BATCHES without ANALYZE but with other options +explain (batches, buffers off, costs off) select * from batch_test; + +-- Basic: verify batch stats line appears in text format +select explain_filter('explain (analyze, batches, buffers off, costs off) select * from batch_test'); + +-- With filter: batch line still appears +select explain_filter('explain (analyze, batches, buffers off, costs off) select * from batch_test where a > 5000'); + +-- With non-batchable qual (OR): batching still active but +-- batch qual falls back to per-tuple ExecQual +select explain_filter('explain (analyze, batches, buffers off, costs off) select * from batch_test where a > 5000 or b is null'); + +-- With LIMIT: batch stats appear on child Seq Scan node +select explain_filter('explain (analyze, batches, buffers off, costs off) select * from batch_test limit 100'); + +-- Verify batch stats keys present in JSON output +select + j #> '{0,Plan}' ? 'Batches' as has_batches, + j #> '{0,Plan}' ? 'Average Batch Rows' as has_avg, + j #> '{0,Plan}' ? 'Max Batch Rows' as has_max, + j #> '{0,Plan}' ? 'Min Batch Rows' as has_min +from explain_filter_to_json( + 'explain (analyze, batches, buffers off, format json) select * from batch_test' +) as j; + +-- With LIMIT: batch stats keys on child node in JSON +select + j #> '{0,Plan,Plans,0}' ? 'Batches' as child_has_batches, + j #> '{0,Plan,Plans,0}' ? 'Average Batch Rows' as child_has_avg, + j #> '{0,Plan,Plans,0}' ? 'Max Batch Rows' as child_has_max, + j #> '{0,Plan,Plans,0}' ? 'Min Batch Rows' as child_has_min +from explain_filter_to_json( + 'explain (analyze, batches, buffers off, format json) select * from batch_test limit 100' +) as j; + +-- Batching disabled: no batch stats in text output +set executor_batch_rows = 0; +select explain_filter('explain (analyze, batches, buffers off, costs off) select * from batch_test'); + +-- Batching disabled: no batch keys in JSON +select + j #> '{0,Plan}' ? 'Batches' as has_batches +from explain_filter_to_json( + 'explain (analyze, batches, buffers off, format json) select * from batch_test' +) as j; + +reset executor_batch_rows; -- 2.47.3