Chapter 18.2 · Query Planning and Execution · 23 min read
Executor Scan Nodes: Seq, Index, Bitmap and AO
How each scan node fetches tuples — sequential, index, index-only, bitmap, and the append-optimized and PAX scans unique to Cloudberry.
By Tushar Pednekar · · Verified against the Apache Cloudberry source tree, read September 2026
Scan nodes are where the storage chapters meet the execution chapters, and where most of a query’s I/O is decided.
Scan nodes are the leaves of every plan tree, and they are where the executor stops being a tree-walker and starts being a reader of bytes. That makes this section the place where Part III meets Part I: a Seq Scan in an EXPLAIN output is a promise that something will hand tuples upward, and the whole of Chapters 4, 5 and 6 is the answer to what. Cloudberry takes that indirection further than PostgreSQL does — the same plan node, the same 474 lines of nodeSeqscan.c, reads a heap page, an append-optimized row segment, a column-store segment file, or a PAX block, and the plan text does not change at all. We start there, then work outward: the shared scan loop in execScan.c, the buffer strategy a big scan uses, index and bitmap scans, Cloudberry’s run-time-partitioned Dynamic* family, and finally the one genuinely new scan feature in Cloudberry 3.0 — parallelism inside a segment.
18.2.1 One node, four storage engines
nodeSeqscan.c does not know what a heap is. Its workhorse SeqNext (nodeSeqscan.c:57) opens a scan and pulls slots through the table access method — and Cloudberry’s entry point is not upstream’s table_beginscan but table_beginscan_es (src/include/access/tableam.h:1055), which additionally hands the AM the node’s own PlanState. Everything after that is a function-pointer call into whatever TableAmRoutine the relation’s relam names.
src/backend/executor/nodeSeqscan.c:95
scandesc = table_beginscan_es(node->ss.ss_currentRelation,
estate->es_snapshot,
nkeys, keys,
NULL,
&node->ss.ps);
node->ss.ss_currentScanDesc = scandesc;
...
if (table_scan_getnextslot(scandesc, direction, slot))
return slot;
Four access methods, one plan node. The other three EXPLAINs came back byte-identical apart from the relation name.
SET optimizer = off; -- ORCA prints the same node here
CREATE TABLE m182_heap (id int, k int, city text, payload text) DISTRIBUTED BY (id);
CREATE TABLE m182_ao (LIKE m182_heap) WITH (appendonly=true) DISTRIBUTED BY (id);
CREATE TABLE m182_aoco (LIKE m182_heap) WITH (appendonly=true, orientation=column) DISTRIBUTED BY (id);
CREATE TABLE m182_pax (LIKE m182_heap) USING pax DISTRIBUTED BY (id);
-- 600 000 identical rows loaded into each
SELECT relname, amname, pg_size_pretty(pg_total_relation_size(c.oid)) sz
FROM pg_class c JOIN pg_am a ON a.oid = c.relam
WHERE relname LIKE 'm182%' ORDER BY 1;
EXPLAIN (COSTS OFF) SELECT count(*) FROM m182_aoco WHERE k = 42;
relname | amname | sz
-----------+-----------+---------
m182_ao | ao_row | 52 MB
m182_aoco | ao_column | 44 MB
m182_heap | heap | 62 MB
m182_pax | pax | 5356 kB
(4 rows)
QUERY PLAN
------------------------------------------------
Finalize Aggregate
-> Gather Motion 3:1 (slice1; segments: 3)
-> Partial Aggregate
-> Seq Scan on m182_aoco
Filter: (k = 42)
Optimizer: Postgres query optimizer
So what does change? The AM decides how much it has to read. Because table_beginscan_es passes the PlanState, a column store can look at the plan’s target list and qualification and open only the columns the query mentions — aoco_beginscan_extractcolumns (aocsam_handler.c:612) walks ps->plan->targetlist and ps->plan->qual into a cols[] bitmap, with a charming special case: if the query names no column at all (count(*)), it scans column 1 so that there is something to count. A heap has no such option; a row store must read whole rows. Two 300 000-row tables with one narrow int and one 2 560-byte text column make the gap plain.
Column projection is an access-method property, not a plan property. Both queries below are the same Seq Scan; the AOCO one reads 1/9 of the work when the wide column is not referenced. Timings are ratios on one —enable-cassert machine, not benchmarks.
-- same 300 000 rows, same 2 560-byte blob column, two access methods
CREATE TABLE m182_wideh (id int, k int, blob text) DISTRIBUTED BY (id); -- heap, 782 MB
CREATE TABLE m182_wide (id int, k int, blob text)
WITH (appendonly=true, orientation=column, compresstype=none) DISTRIBUTED BY (id); -- AOCO, 737 MB
iming on
echo heap narrow
SELECT count(k) FROM m182_wideh;
echo heap wide
SELECT count(blob) FROM m182_wideh;
echo AOCO narrow
SELECT count(k) FROM m182_wide;
echo AOCO wide
SELECT count(blob) FROM m182_wide;
heap narrow
300000
Time: 73.719 ms
heap wide
300000
Time: 78.100 ms
AOCO narrow
300000
Time: 14.042 ms
AOCO wide
300000
Time: 122.095 ms
18.2.2 The scan loop
Every scan node that has a qualification or a projection funnels through one 110-line function, ExecScan (execScan.c:160). Its contract is small and worth memorising, because it explains most of what EXPLAIN ANALYZE prints under a scan: reset the per-tuple memory context, ask the access method for a slot, publish that slot as econtext->ecxt_scantuple, run ExecQual, and either project (§18.5) or return the raw slot. A tuple that fails the qual bumps InstrCountFiltered1 — that counter, and nothing else, is the Rows Removed by Filter line. Nodes with neither qual nor projection short-circuit the whole loop at execScan.c:184 and return the AM’s slot untouched.
src/backend/executor/execScan.c:200
for (;;)
{
slot = ExecScanFetch(node, accessMtd, recheckMtd);
if (TupIsNull(slot)) ...
econtext->ecxt_scantuple = slot;
qual = node->ps.qual;
if (qual == NULL || ExecQual(qual, econtext))
return projInfo ? ExecProject(projInfo) : slot;
else
InstrCountFiltered1(node, 1);
ResetExprContext(econtext);
}
ExecScanFetch (execScan.c:35) has one other job. When estate->es_epq_active is set the executor is inside an EvalPlanQual recheck — re-evaluating a row that changed underneath a SELECT FOR UPDATE or an UPDATE — and the fetch is replaced by a caller-supplied test tuple which recheckMtd validates. nodeSeqscan.c:129’s SeqRecheck simply returns true, with a comment admitting that a seq scan never pushed keys into the AM so there is nothing to re-verify; index scans do the real work there.
The filter counter in practice: 221 rows survived on the busiest segment and 200 046 did not. Note what is missing — with BUFFERS requested, only the coordinator’s planning buffers are reported.
SET optimizer = off;
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, COSTS OFF)
SELECT count(*) FROM m182_heap WHERE k = 42;
Finalize Aggregate (actual rows=1 loops=1)
-> Gather Motion 3:1 (slice1; segments: 3) (actual rows=3 loops=1)
-> Partial Aggregate (actual rows=1 loops=1)
-> Seq Scan on m182_heap (actual rows=221 loops=1)
Filter: (k = 42)
Rows Removed by Filter: 200046
Planning:
Buffers: shared hit=267
Planning Time: 1.568 ms
(slice0) Executor memory: 114K bytes.
(slice1) Executor memory: 119K bytes avg x 3x(0) workers, 119K bytes max (seg0).
Optimizer: Postgres query optimizer
Execution Time: 54.812 ms
Per-node
Buffers:lines never appear under a Motion on this cluster: the scan ran in three QEs and theBufferUsagecounters are not shipped back to the coordinator with the tuples. The same is true of the bitmap counters in §18.2.5. When you need them, run the query in utility mode against one segment — see §18.1 for what a QE-sideInstrumentationcan and cannot report.
18.2.3 Buffered access — a big scan does not evict the cache
A sequential scan of a table larger than the buffer cache could, naively, throw the entire cache away for one query. It does not. initscan (heapam.c:289) compares the relation size against NBuffers / 4 and, above that threshold, asks for a BAS_BULKREAD strategy (heapam.c:301) — a ring buffer of 256 kB (freelist.c:564), converted to a buffer count at freelist.c:598. On this cluster block_size is 32 kB, so the ring is exactly 8 buffers: the scan recycles its own eight frames for the whole table instead of allocating one per page (§3.4). PG16’s pg_stat_io gives us a direct read on this, because BAS_BULKREAD maps to its own I/O context (freelist.c:738).
A 6 110-page scan through a 4 000-buffer cache. reuses counts frames recycled from the scan’s own ring; evictions counts pages belonging to somebody else that had to go. Utility mode on segment 0, because pg_stat_io is per-instance.
-- PGOPTIONS='-c gp_role=utility' psql -p 7102 -d postgres
-- step 1, own transaction: snapshot the counters
CREATE TABLE m182_io0 AS
SELECT context, reads, evictions, reuses FROM pg_stat_io
WHERE backend_type = 'client backend' AND object = 'relation';
-- step 2, own transaction: one full scan of a table bigger than the cache
SELECT relpages, current_setting('block_size') bs,
(SELECT count(*) FROM pg_buffercache) nbuffers
FROM pg_class WHERE relname = 'm182_ring';
SELECT count(*) FROM m182_ring;
-- step 3: the delta, plus what is left resident
SELECT n.context, n.reads-o.reads d_reads, n.evictions-o.evictions d_evictions,
n.reuses-o.reuses d_reuses
FROM pg_stat_io n JOIN m182_io0 o USING (context)
WHERE n.backend_type='client backend' AND n.object='relation'
AND n.context IN ('bulkread','normal');
SELECT count(*) AS ring_pages_resident FROM pg_buffercache
WHERE relfilenode = pg_relation_filenode('m182_ring') AND relforknumber = 0;
relpages | bs | nbuffers
----------+-------+----------
6110 | 32768 | 4000
count
--------
366564
context | d_reads | d_evictions | d_reuses
----------+---------+-------------+----------
bulkread | 6094 | 7 | 6086
normal | 0 | 0 |
(2 rows)
ring_pages_resident
---------------------
24
Two honest caveats. First, only heap and PAX reads go through shared buffers at all —
aocsam.candappendonlyam.ccontain zeroReadBuffercalls; AO and AOCO scans read throughAppendOnlyStorageReadand their own buffers, so no ring strategy and nopg_buffercacherow will ever describe them. Second, the resident-page count above is only meaningful as a delta on an otherwise idle segment: a 6 110-page table left 24 pages behind, but any concurrent session sharing that 4 000-buffer cache will muddy the number. Thepg_stat_iodelta is the trustworthy instrument.
18.2.4 Index and index-only scans
nodeIndexscan.c swaps the AM: ExecIndexBuildScanKeys (nodeIndexscan.c:1168) turns the plan’s indexqual into an array of ScanKeys at init time, index_beginscan (:111) opens the index, and index_getnext_slot (:132) walks it. Two consequences matter for plans. The output is ordered by the index, so it can satisfy pathkeys without a Sort (§15.4). And nodeIndexonlyscan.c can skip the heap entirely when the visibility map says a page is all-visible (nodeIndexonlyscan.c:162, §4.4); each time it cannot, InstrCountTuples2 fires (:169) and surfaces as the Heap Fetches line (explain.c:2483).
Same predicate, two nodes. After VACUUM set the visibility map, the index-only scan never touched the heap.
CREATE INDEX m182_heap_k ON m182_heap (k);
VACUUM ANALYZE m182_heap;
SET optimizer = off; SET enable_seqscan = off; SET enable_bitmapscan = off;
EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF) SELECT id, city FROM m182_heap WHERE k = 42;
EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF) SELECT k FROM m182_heap WHERE k = 42;
Gather Motion 3:1 (slice1; segments: 3) (actual rows=600 loops=1)
-> Index Scan using m182_heap_k on m182_heap (actual rows=221 loops=1)
Index Cond: (k = 42)
Execution Time: 4.575 ms
Gather Motion 3:1 (slice1; segments: 3) (actual rows=600 loops=1)
-> Index Only Scan using m182_heap_k on m182_heap (actual rows=221 loops=1)
Index Cond: (k = 42)
Heap Fetches: 0
Execution Time: 0.976 ms
The distributed twist is that there is no such thing as one index here. CREATE INDEX on a distributed table creates a separate physical index per segment, each covering only that segment’s slice of the rows (§7.1), and the ordered stream each one produces is merged by the Motion above it — visible as the Merge Key: annotation, which is how a per-segment ORDER BY … LIMIT still yields a globally sorted answer.
Three segments, three independent index relations of 43 pages each; the coordinator sees only their merged output.
-- one physical index per segment, reached in utility mode
for p in 7102 7103 7104; do
PGOPTIONS='-c gp_role=utility' psql -p $p -d postgres -qtAc "select $p, relname, pg_relation_filenode(oid), relpages
from pg_class where relname='m182_heap_k';"
done
-- and how their orderings are combined
EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF)
SELECT id FROM m182_heap ORDER BY id LIMIT 5;
7102|m182_heap_k|25217|43
7103|m182_heap_k|25211|43
7104|m182_heap_k|25211|43
Limit (actual rows=5 loops=1)
-> Gather Motion 3:1 (slice1; segments: 3) (actual rows=5 loops=1)
Merge Key: id
-> Limit (actual rows=5 loops=1)
-> Index Only Scan using m182_heap_id on m182_heap (actual rows=5 loops=1)
Heap Fetches: 0
18.2.5 Bitmap scans
A bitmap scan splits an index scan in two. MultiExecBitmapIndexScan (nodeBitmapIndexscan.c:71) is not a normal ExecProcNode at all — it returns a whole bitmap in one call via index_getbitmap (:129), and provides its own instrumentation (:83, :156) because it never returns tuples. nodeBitmapHeapscan.c then visits the heap in physical block order, which turns scattered random reads into something closer to sequential. nodeBitmapAnd.c and nodeBitmapOr.c sit between them and combine bitmaps. The price is memory: when the bitmap outgrows work_mem it degrades page-wise — a lossy page records only “some row here matches” — and the heap scan must re-apply the Recheck Cond to every row on it.
Run on segment 0 in utility mode, because these counters are QE-local. 650 of 660 pages went lossy under a 64 kB work_mem, and the recheck threw away 118 088 rows.
-- PGOPTIONS='-c gp_role=utility' psql -p 7102 -d postgres
SET optimizer = off; SET enable_seqscan = off;
SET enable_indexscan = off; SET enable_indexonlyscan = off;
SET work_mem = '64kB';
EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF)
SELECT count(city) FROM m182_heap WHERE k < 400;
Aggregate (actual rows=1 loops=1)
-> Bitmap Heap Scan on m182_heap (actual rows=79921 loops=1)
Recheck Cond: (k < 400)
Rows Removed by Index Recheck: 118088
Heap Blocks: exact=10 lossy=650
-> Bitmap Index Scan on m182_heap_k (actual rows=79921 loops=1)
Index Cond: (k < 400)
Planning Time: 1.874 ms
(slice0) Executor memory: 12859K bytes (seg0).
Optimizer: Postgres query optimizer
Execution Time: 61.273 ms
Two things called “bitmap” meet here and they are not the same thing. A bitmap scan is an execution strategy, and it works over any index AM that implements amgetbitmap — a b-tree, most often. Cloudberry additionally ships a bitmap index type (USING bitmap, §7.6), and its bmgetbitmap (bitmap.c:269) returns a lazily-iterated StreamBitmap rather than a materialised TIDBitmap; nodeBitmapIndexscan.c:131 accepts either. That design leaks into EXPLAIN in a way worth recognising: bmgetbitmap ends with a literal return 1 under the comment “we don’t have a precise idea of the number of heap tuples involved” (bitmap.c:324), so a bitmap-AM index scan always reports actual rows=1.
A BitmapAnd fed by two different index AMs. The btree side counts TIDs; the bitmap-AM side reports one row because that is what bmgetbitmap returns.
CREATE INDEX m182_bm_a ON m182_bm USING btree (a); -- 100 distinct values
CREATE INDEX m182_bm_b ON m182_bm USING bitmap (b); -- 100 distinct values
VACUUM ANALYZE m182_bm;
SET optimizer = off; SET enable_seqscan = off;
EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF)
SELECT count(tag) FROM m182_bm WHERE a = 5 AND b = 7;
Finalize Aggregate (actual rows=1 loops=1)
-> Gather Motion 3:1 (slice1; segments: 3) (actual rows=3 loops=1)
-> Partial Aggregate (actual rows=1 loops=1)
-> Bitmap Heap Scan on m182_bm (actual rows=23 loops=1)
Recheck Cond: ((b = 7) AND (a = 5))
-> BitmapAnd (actual rows=1 loops=1)
-> Bitmap Index Scan on m182_bm_b (actual rows=1 loops=1)
Index Cond: (b = 7)
-> Bitmap Index Scan on m182_bm_a (actual rows=3066 loops=1)
Index Cond: (a = 5)
Optimizer: Postgres query optimizer
Execution Time: 43.364 ms
Three smaller scan nodes round out the family and behave exactly as upstream, so we only name them:
nodeTidscan.c(Tid Scan, actid = …lookup — on a distributed table Cloudberry warns that actidis only unique together withgp_segment_id),nodeTidrangescan.c(Tid Range Scan, PG14’s block-range scan), andnodeSamplescan.c(Sample Scan,TABLESAMPLE; on this clusterTABLESAMPLE SYSTEM (1)over 600 000 rows returnedactual rows=3333on the busiest segment). All three go through the sameExecScanloop of §18.2.2.
18.2.6 The Dynamic* family — partitions chosen at run time
Here Cloudberry departs from PostgreSQL outright. PostgreSQL scans a partitioned table as an Append over one child plan node per surviving partition (§18.6), pruned at plan time and, for parameterised cases, again at run time. ORCA instead emits a single Dynamic* node holding a list of partition OIDs, and opens them one after another during execution. The header of nodeDynamicSeqscan.c:11 gives the motivation bluntly: a plan with one node per partition has to fix the column mapping for every child at plan time, and “when there are many partitions with many columns, the plan size improvement becomes very large, on the order of 100+ MB in some cases.” Six files implement the family, sharing index plumbing in execDynamicIndexes.c.
| File | Plan label | What it adds over the static node |
|---|---|---|
nodeDynamicSeqscan.c (315) | Dynamic Seq Scan | iterates partOids, calling ExecInitSeqScanForPartition per partition |
nodeDynamicIndexscan.c (140) | Dynamic Index Scan | opens that partition’s own index, via execDynamicIndexes.c |
nodeDynamicIndexOnlyscan.c (135) | Dynamic Index Only Scan | same, keeping the visibility-map shortcut |
nodeDynamicBitmapIndexscan.c (282) | Dynamic Bitmap Index Scan | builds the TID bitmap for the current partition |
nodeDynamicBitmapHeapscan.c (329) | Dynamic Bitmap Heap Scan | consumes it against that partition’s storage |
nodeDynamicForeignscan.c (324) | Dynamic Foreign Scan | foreign / external partitions |
src/backend/executor/nodeDynamicSeqscan.c:128
if (++node->whichPart < node->nOids)
pid = &node->partOids[node->whichPart];
else
return false;
if (NULL != scanState->ps.instrument)
scanState->ps.instrument->numPartScanned++; /* -> "Partitions scanned:" */
currentRelation = scanState->ss_currentRelation =
table_open(node->partOids[node->whichPart], AccessShareLock);
...
attMap = build_attrmap_by_name_if_req(partTupDesc, lastTupDesc, false);
...
node->seqScanState = ExecInitSeqScanForPartition(&plan->seqscan, estate,
currentRelation);
Same query, same table, two optimizers. ORCA keeps one node and narrows the OID list; the PostgreSQL planner resolves the partition at plan time and scans the child relation by name.
CREATE TABLE m182_part (id int, dt date, amt numeric) DISTRIBUTED BY (id)
PARTITION BY RANGE (dt)
(START (date '2026-01-01') END (date '2026-07-01') EVERY (interval '1 month'));
SET optimizer = on;
EXPLAIN (COSTS OFF) SELECT count(*) FROM m182_part WHERE dt = date '2026-03-15';
SET optimizer = off;
EXPLAIN (COSTS OFF) SELECT count(*) FROM m182_part WHERE dt = date '2026-03-15';
Finalize Aggregate
-> Gather Motion 3:1 (slice1; segments: 3)
-> Partial Aggregate
-> Dynamic Seq Scan on m182_part
Number of partitions to scan: 1 (out of 6)
Filter: (dt = '2026-03-15'::date)
Optimizer: GPORCA
Finalize Aggregate
-> Gather Motion 3:1 (slice1; segments: 3)
-> Partial Aggregate
-> Seq Scan on m182_part_1_prt_3 m182_part
Filter: (dt = '2026-03-15'::date)
Optimizer: Postgres query optimizer
A constant predicate is the easy case. The point of the family is the case a plan-time pruner cannot touch: the partition key is compared against values that only exist once the query is running. ExecDynamicSeqScan (nodeDynamicSeqscan.c:179) handles it on its first call — if the plan carries join_prune_paramids, it asks ExecFindMatchingSubPlans which partitions survive and rewrites partOids in place before opening anything. Now the two EXPLAIN lines say different things: Number of partitions to scan is the plan’s estimate, while Partitions scanned is the run-time truth, counted by that numPartScanned++ above and formatted at explain_gp.c:1755.
Run-time partition elimination. The plan conceded all 6 partitions; execution opened 1. The Partition Selector that fed the pruning is §18.7’s node, not ours.
SET optimizer = on;
EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF)
SELECT count(*) FROM m182_part p JOIN m182_days d ON p.dt = d.dt;
Finalize Aggregate (actual rows=1 loops=1)
-> Gather Motion 3:1 (slice1; segments: 3) (actual rows=3 loops=1)
-> Partial Aggregate (actual rows=1 loops=1)
-> Hash Join (actual rows=1526 loops=1)
Hash Cond: (p.dt = d.dt)
-> Dynamic Seq Scan on m182_part p (actual rows=22973 loops=1)
Number of partitions to scan: 6 (out of 6)
Partitions scanned: Avg 1.0 x 3 workers. Max 1 parts (seg0).
-> Hash (actual rows=2 loops=1)
-> Partition Selector (selector id: $0) (actual rows=2 loops=1)
-> Seq Scan on m182_days d (actual rows=2 loops=1)
Optimizer: GPORCA
Execution Time: 15.491 ms
Two loose ends deliberately left dangling. The
Partition Selectorand theSequencenode that drive these scans are §18.7’s material. And the second half ofinitNextTableToScan—build_attrmap_by_name_if_reqfollowed bychange_varattnos_of_a_varno— exists because sibling partitions may disagree about physical column numbering afterDROP COLUMN; aDynamic*node re-maps theVars of its own qual and target list as it walks from one partition to the next, which is precisely the work anAppendplan pays for at plan time instead.
18.2.7 Intra-segment parallel scan
Cloudberry has always been parallel across segments. Cloudberry 3.0 adds parallelism inside one, and enable_parallel (guc_gp.c:3200, default off) is the switch. Turn it on and a scan becomes Parallel Seq Scan — but the headline is one line higher up, in the Motion. FillSliceGangInfo (execUtils.c:1677) multiplies the gang: planNumSegments = numsegments * factor, where factor is the slice’s parallel_workers. Three segments times two workers is six senders, so the Gather Motion’s arity changes from 3:1 to 6:1. Raise max_parallel_workers_per_gather to 4 and it becomes 12:1.
src/backend/executor/execUtils.c:1679
int factor = ps->parallel_workers ? ps->parallel_workers : 1;
int numsegments = ps->numsegments;
slice->useMppParallelMode = (ps->parallel_workers != 0);
slice->parallel_workers = factor;
switch (slice->gangType)
{
...
case GANGTYPE_PRIMARY_READER:
slice->planNumSegments = numsegments * factor;
One GUC, three plan shapes. Only the Motion arity and the word Parallel change; the scan node is still nodeSeqscan.c. ORCA produces the same shape — this is not a planner-specific feature.
SET optimizer = off;
EXPLAIN (COSTS OFF) SELECT count(*) FROM m182_ring WHERE payload LIKE '%z%';
SET enable_parallel = on;
EXPLAIN (COSTS OFF) SELECT count(*) FROM m182_ring WHERE payload LIKE '%z%';
SET max_parallel_workers_per_gather = 4;
EXPLAIN (COSTS OFF) SELECT count(*) FROM m182_ring WHERE payload LIKE '%z%';
-> Partial Aggregate
-> Gather Motion 3:1 (slice1; segments: 3)
-> Seq Scan on m182_ring
-> Gather Motion 6:1 (slice1; segments: 6)
-> Partial Aggregate
-> Parallel Seq Scan on m182_ring
-> Gather Motion 12:1 (slice1; segments: 12)
-> Partial Aggregate
-> Parallel Seq Scan on m182_ring
Optimizer: Postgres query optimizer
Mechanically this is a hybrid. The workers are extra QEs in the gang, dispatched like any other (§13.3) — not PostgreSQL background workers. But the work division is upstream’s: the block handoff still happens through a ParallelTableScanDesc in shared memory, and ExecSeqScanInitializeDSM / ExecSeqScanInitializeWorker (nodeSeqscan.c:329, :385) are the same hooks PostgreSQL uses. The bridge is GpInsertParallelDSMHash (parallel.c:1822), called from ExecutorRun when estate->useMppParallelMode is set (execMain.c:2882): the sibling QEs of one slice on one segment look up a DSM segment keyed by (gp_command_count, sliceIndex, gp_session_id) (parallel.c:1840), the first one to arrive creates it and arms a Barrier, the rest attach, and all of them meet at BarrierArriveAndWait before the scan starts.
Warm repeats of a 1.1-million-row filtered scan, alternating the GUC in one session. Roughly 1.8×, on an —enable-cassert build — read it as a ratio, not a benchmark.
SET optimizer = off;
SELECT count(*) FROM m182_ring WHERE payload LIKE '%zzz%'; -- warm the cache
iming on
SET enable_parallel = off;
SELECT count(*) FROM m182_ring WHERE payload LIKE '%zzz%';
SELECT count(*) FROM m182_ring WHERE payload LIKE '%zzz%';
SET enable_parallel = on;
SELECT count(*) FROM m182_ring WHERE payload LIKE '%zzz%';
SELECT count(*) FROM m182_ring WHERE payload LIKE '%zzz%';
-- enable_parallel = off
1100000
Time: 186.469 ms
1100000
Time: 162.008 ms
-- enable_parallel = on
1100000
Time: 100.434 ms
1100000
Time: 84.379 ms
Which raises the obvious question: where are PostgreSQL’s own Gather and Gather Merge? nodeGather.c (477 lines) and nodeGatherMerge.c (789) are still compiled into this binary, and they are unreachable. Every path that would produce them has been switched off: generate_gather_paths (allpaths.c:3785), generate_useful_gather_paths (allpaths.c:3928) and gather_grouping_paths (planner.c:9134) all open with Assert(false), and their call sites in planner.c are inside #if 0 blocks (planner.c:4924, :8539). Motion is the gather; the executor comment at execMain.c:2876 says as much — “CBDB style parallelism won’t interfere PG style parallel mechanism” — and EXPLAIN on this cluster never prints either node. A parallel Cloudberry plan therefore has exactly one gathering mechanism, and it belongs to Chapter 19.
Loose ends checked and reported honestly.
parallel_leader_participation(offhere) changes nothing in the plan — flipping it toonstill givesGather Motion 6:1, because there is no PostgreSQL leader process in this scheme: all six are QEs.EXPLAIN ANALYZEshows no per-worker breakdown either; instead the per-slice memory footer changes from3x(0) workersto6x(0) workers, i.e. the workers are counted as gang members (§18.1). Andenable_parallel_hash/enable_parallel_appendare alreadyonby default, so they are governed byenable_paralleltoo rather than being independent switches.
More in Query Planning and Execution
- From SQL to Segments: Cloudberry Query Processing
- Statistics and Selectivity Estimation in Cloudberry
- The Postgres Planner in Cloudberry, Step by Step
- Distributed Planning: Locus, Motion and Direct Dispatch
- ORCA Optimizer: Entry Points and Fallback Rules
- Inside ORCA's Cascades Engine: Groups and Jobs