Chapter 19.3 · Query Planning and Execution · 23 min read
Slice Tables: Mapping Plan Slices to Gangs
How the planner cuts a plan into slices at every Motion, and how the slice table maps each PlanSlice onto the gang that will run it.
By Tushar Pednekar · · Verified against the Apache Cloudberry source tree, read September 2026
When a query fails with too many slices, this is the structure that ran out.
A dispatched plan is a broadcast: byte for byte, every executor receives the same PlannedStmt. Nothing in it says “you are the segment that scans, you are the one that receives”. Yet twelve processes must divide that one tree between them without further negotiation. The object that makes this possible is the slice table, and it exists in two forms with two different lifetimes: PlanSlice, which the planner writes and the dispatcher ships, and ExecSlice, which each executor builds locally and which alone knows about processes.
This section is about that pair of structures and the binding between them. §19.1 gave you the vocabulary (a slice is the unit of parallelism, a Motion is its boundary) and §19.2 owns the processes — how gangs are created, reused and recycled. Here we stay on the mapping: which slice is which, which process belongs to it, how a QE discovers its own identity, and — the practical pay-off — how to read a slice tree out of EXPLAIN and count the processes a plan will need before you run it.
sliceIndex is the join key across all three.19.3.1 Two structs, two lifetimes
The planner’s slice table is an array hanging off the PlannedStmt — numSlices entries at slices (src/include/nodes/plannodes.h:110-111), plus a parallel array subplan_sliceIds (:125) mapping each SubPlan to the slice that owns it. One entry looks like this.
src/include/nodes/plannodes.h:244
typedef struct PlanSlice
{
int sliceIndex;
int parentIndex;
GangType gangType;
/* # of segments in the gang, for PRIMARY_READER/WRITER slices */
int numsegments;
int parallel_workers;
/* segment to execute on, for SINGLETON_READER slices */
int segindex;
/* direct dispatch information, for PRIMARY_READER/WRITER slices */
DirectDispatchInfo directDispatch;
} PlanSlice;
That is the whole planner-side description of a slice: an index, a parent, a gang kind from the GangType enum (plannodes.h:231), a segment count, and an optional narrowing to specific segments. No process, no address, no tree links. You can read the array straight out of the plan with debug_print_plan, which serializes it verbatim (src/backend/nodes/outfuncs.c:421-433).
The planner’s slice table for a two-table join, read out of the dispatched PlannedStmt.
SET optimizer = off;
SET debug_print_plan = on;
SET client_min_messages = log;
SELECT count(*) FROM m193_fact f JOIN m193_cust c ON f.cust = c.cust;
LOG: plan:
DETAIL: {PLANNEDSTMT
...
:subplan_sliceIds <>
:numSlices 3
:slices[i].sliceIndex 0
:slices[i].parentIndex -1
:slices[i].gangType 0
:slices[i].numsegments 1
:slices[i].parallel_workers 0
:slices[i].segindex 0
:slices[i].directDispatch.isDirectDispatch false
:slices[i].directDispatch.contentIds <>
:slices[i].sliceIndex 1
:slices[i].parentIndex 0
:slices[i].gangType 3
:slices[i].numsegments 3
:slices[i].parallel_workers 0
:slices[i].segindex 0
:slices[i].directDispatch.isDirectDispatch false
:slices[i].directDispatch.contentIds <>
:slices[i].sliceIndex 2
:slices[i].parentIndex 1
:slices[i].gangType 3
:slices[i].numsegments 3
:slices[i].parallel_workers 0
...
gangType 0 is GANGTYPE_UNALLOCATED and 3 is GANGTYPE_PRIMARY_READER. Note that slice 0 — the one the coordinator runs itself — is in the array, with numsegments 1 and no gang type; §13.3 already noted that this slice is never dispatched. The executor-side struct is much fatter.
src/include/executor/execdesc.h:89
typedef struct ExecSlice
{
int sliceIndex;
int rootIndex;
int parentIndex;
int planNumSegments; /* nominal, for hash calculations */
List *children; /* child slices = receiving Motions */
GangType gangType;
List *segments; /* segment ids that run this slice */
struct Gang *primaryGang;
List *primaryProcesses; /* one CdbProcess per worker */
Bitmapset *processesMap; /* which QE runs this slice */
} ExecSlice;
| Question | PlanSlice (planner) | ExecSlice (executor) |
|---|---|---|
| Where it lives | PlannedStmt.slices[] | EState.es_sliceTable->slices[] |
| Who writes it | cdbllize_build_slice_table (§16.3) or ORCA’s DXL translator (§17.5) | InitSliceTable at ExecutorStart, then AssignGangs at dispatch |
| Lifetime | planning → serialization → every QE reads the same bytes | one instance per process, per statement |
| Tree links | parentIndex only | parentIndex + rootIndex + children list |
| Segment info | numsegments (a count) + directDispatch.contentIds | segments (an actual list of ids) + planNumSegments |
| Processes | none | primaryGang, primaryProcesses, processesMap |
| Parallel workers | parallel_workers = 0 when not parallel | parallel_workers = 1 when not parallel, and useMppParallelMode |
| Whose identity | nobody’s — the array is symmetric | SliceTable.localSlice says which entry is me |
19.3.2 The hand-off: InitSliceTable, then AssignGangs
ExecutorStart on the coordinator calls InitSliceTable (execMain.c:455 → execUtils.c:1747). It copies the array, then does two things the planner did not: it walks parentIndex upwards to compute each slice’s rootIndex and appends the child to its parent’s children list (execUtils.c:1795-1816), and it expands numsegments into a concrete segment list via FillSliceGangInfo.
src/backend/executor/execUtils.c:1677
int factor = ps->parallel_workers ? ps->parallel_workers : 1;
int numsegments = ps->numsegments;
...
case GANGTYPE_PRIMARY_WRITER:
case GANGTYPE_PRIMARY_READER:
slice->planNumSegments = numsegments * factor;
if (dd->isDirectDispatch)
{
/* ... one entry per dd->contentIds member, x factor ... */
}
else
{
for (i = 0; i < numsegments; i++)
for (j = 0; j < factor; j++)
slice->segments = lappend_int(slice->segments,
i % getgpsegmentCount());
planNumSegments = numsegments × parallel_workers is the arithmetic §18.2 introduced from the scan side; here you see it produce the gang size. Now dump the coordinator’s copy with debug_print_slice_table (guc_gp.c:731), which fires at DEBUG3 right after InitPlan (execMain.c:593).
The coordinator’s slice table, immediately after InitSliceTable — structure complete, processes absent.
SET debug_print_slice_table = on;
SET client_min_messages = debug3;
SELECT count(*) FROM m193_fact f JOIN m193_cust c ON f.cust = c.cust;
DEBUG: slice table:
DETAIL: {SLICETABLE
:localSlice 0
:numSlices 3
:slices[i].sliceIndex 0
:slices[i].rootIndex 0
:slices[i].parentIndex -1
:slices[i].planNumSegments 1
:slices[i].children (i 1)
:slices[i].gangType 0
:slices[i].segments <>
...
:slices[i].sliceIndex 1
:slices[i].rootIndex 0
:slices[i].parentIndex 0
:slices[i].planNumSegments 3
:slices[i].children (i 2)
:slices[i].gangType 3
:slices[i].segments (i 0 1 2)
:slices[i].useMppParallelMode false
:slices[i].parallel_workers 1
:slices[i].primaryGang <>
:slices[i].primaryProcesses <>
:slices[i].processesMap (b)
...
:hasMotions true
:ic_instance_id 0
}
DEBUG: Query plan size to dispatch: 2KB
children (i 1) and children (i 2) are the tree the planner only implied. primaryProcesses and processesMap are still empty because this dump happens before dispatch: AssignGangs (execUtils.c:1908) runs later, allocates a gang per slice tree via InventorySliceTree (:1997) and then calls setupCdbProcessList (cdbgang.c:645), which fills one CdbProcess per worker — host, interconnect listener port, pid, contentid, dbid (src/include/cdb/cdbgang.h:117) — and records each worker’s identifier in the bitmap (cdbgang.c:668). The QEs receive the result.
The same slice table as a QE received it. Now every worker has an address, and the bitmaps partition the processes between slices.
-- excerpt from the QE-side copy of the dump above (execMain.c:620)
DEBUG: slice table: (seg0 slice1 10.100.0.3:7102 pid=10561)
DETAIL: {SLICETABLE
:localSlice 1
:numSlices 3
...
:slices[i].sliceIndex 1
:slices[i].planNumSegments 3
:slices[i].segments (i 0 1 2)
:slices[i].primaryProcesses (
{CDBPROCESS
:listenerAddr \10.100.0.3
:listenerPort 50429
:pid 10561
:contentid 0
:dbid 2
}
... 2 more, pid 10562 seg1 / pid 10563 seg2 ...
)
:slices[i].processesMap (b 0 1 2)
:slices[i].sliceIndex 2
:slices[i].segments (i 0 1 2)
:slices[i].primaryProcesses (
... pid 10567 seg0 / pid 10568 seg1 / pid 10569 seg2 ...
)
:slices[i].processesMap (b 3 4 5)
:hasMotions true
:ic_instance_id 1
}
Those listenerAddr/listenerPort pairs are the whole reason the slice table travels with the plan: a sending Motion in slice 2 finds its receivers’ UDP endpoints by looking up its parent slice’s primaryProcesses (§19.5 uses them). And the two disjoint bitmaps (b 0 1 2) / (b 3 4 5) are the plan-to-gang mapping in its most compressed form. You can see the same mapping from the outside: gp_log_gang = debug makes EXPLAIN label each slice with its gang’s first identifier (explain.c:1452-1461), and gp_backend_info() lists the session’s backends by identifier.
A five-slice plan: four gangs, twelve QEs, and the identifier arithmetic that ties them together.
SET gp_log_gang = debug;
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF)
SELECT count(*) FROM (SELECT c.region, p.cat, sum(f.amt)
FROM m193_fact f JOIN m193_cust c ON f.cust = c.cust
JOIN m193_prod p ON f.prod = p.prod
GROUP BY 1,2) x;
SELECT id, type, content, pid FROM gp_backend_info() ORDER BY type DESC, id;
Finalize Aggregate (actual rows=1 loops=1)
-> Gather Motion 3:1 (slice1; gang0; segments: 3) (actual rows=3 loops=1)
-> Partial Aggregate (actual rows=1 loops=1)
-> Finalize HashAggregate (actual rows=13 loops=1)
-> Redistribute Motion 3:3 (slice2; gang3; segments: 3) (actual rows=39 loops=1)
-> Streaming Partial HashAggregate (actual rows=35 loops=1)
-> Hash Join (actual rows=66711 loops=1)
-> Hash Join (actual rows=66711 loops=1)
-> Seq Scan on m193_fact f (actual rows=66711 loops=1)
-> Hash (actual rows=1000 loops=1)
-> Broadcast Motion 3:3 (slice3; gang6; segments: 3)
-> Seq Scan on m193_cust c (actual rows=340 loops=1)
-> Hash (actual rows=50 loops=1)
-> Broadcast Motion 3:3 (slice4; gang9; segments: 3)
-> Seq Scan on m193_prod p (actual rows=18 loops=1)
(slice0) Executor memory: 136K bytes.
* (slice1) Executor memory: 130K bytes avg x 3x(0) workers, 130K bytes max (seg0).
* (slice2) Executor memory: 2199K bytes avg x 3x(0) workers, 2199K bytes max (seg0).
(slice3) Executor memory: 113K bytes avg x 3x(0) workers, 113K bytes max (seg0).
(slice4) Executor memory: 113K bytes avg x 3x(0) workers, 113K bytes max (seg0).
id | type | content | pid
----+------+---------+-------
0 | w | 0 | 12551
1 | w | 1 | 12552
2 | w | 2 | 12553
3 | r | 0 | 12557
...
11 | r | 2 | 12571
-1 | Q | -1 | 12549
The gang label is not a gang id — GPDB 5 had those and dropped them. It is
slice->primaryGang->db_descriptors[0]->identifier, i.e. theqe_identifierof the gang’s first member (explain.c:1452-1461). Sogang0 / gang3 / gang6 / gang9on 3 segments means identifiers 0–2, 3–5, 6–8, 9–11 — exactly theprocessesMapbitmaps, and exactly theidcolumn ofgp_backend_info(). Twelve QEs for oneSELECT.
19.3.3 How a QE learns which slice it is
A QE cannot deduce its role from the plan, and it cannot ask — dispatch is one-way and asynchronous (§19.2). Identity arrives through a side channel: when the coordinator opens the libpq connection it passes a gpqeid option whose fourth field is the worker’s qe_identifier (cdbgang.c:551-562, parsed before authentication). At MPPEXEC time the QE deserializes the slice table and searches the bitmaps for its own number.
src/backend/tcop/postgres.c:1270
/* Identify slice to execute */
for (i = 0; i < sliceTable->numSlices; i++)
{
slice = &sliceTable->slices[i];
if (bms_is_member(qe_identifier, slice->processesMap))
break;
}
if (i == sliceTable->numSlices)
elog(ERROR, "could not find QE identifier in process map");
sliceTable->localSlice = slice->sliceIndex;
localSlice is therefore the only field of the shipped slice table that differs between processes, and it is written by the receiver, not the sender. Everything else — LocallyExecutingSliceIndex (execUtils.c:2532), RootSliceIndex (:2562), and §18.1’s getGpExecIdentity (:2044) with its GP_ROOT_SLICE / GP_NON_ROOT_ON_QE / GP_IGNORE verdict — is derived from it. The dump from §19.3.2, grepped across processes, shows the whole population at once.
Six QEs and one QD, each holding the same slice table with a different localSlice.
$ grep -n 'slice table:' st.txt
$ grep -n ':localSlice' st.txt
20:DEBUG: slice table:
94:DEBUG: slice table: (seg0 slice1 10.100.0.3:7102 pid=10561)
192:DEBUG: slice table: (seg1 slice1 10.100.0.3:7103 pid=10562)
294:DEBUG: slice table: (seg0 slice2 10.100.0.3:7102 pid=10567)
391:DEBUG: slice table: (seg2 slice1 10.100.0.3:7104 pid=10563)
493:DEBUG: slice table: (seg1 slice2 10.100.0.3:7103 pid=10568)
602:DEBUG: slice table: (seg2 slice2 10.100.0.3:7104 pid=10569)
22: :localSlice 0
96: :localSlice 1
194: :localSlice 1
296: :localSlice 2
393: :localSlice 1
495: :localSlice 2
604: :localSlice 2
From that single integer, slice identity propagates into every Motion node. ExecInitMotion looks up the sending slice by the Motion’s own motionID — the numbering identity we will lean on in §19.3.4 — and then picks the node’s role by comparing the local slice with the two ends.
src/backend/executor/nodeMotion.c:708
/* Look up the sending and receiving gang's slice table entries. */
sendSlice = &sliceTable->slices[node->motionID];
Assert(sendSlice->sliceIndex == node->motionID);
recvSlice = &sliceTable->slices[parentIndex];
...
if (LocallyExecutingSliceIndex(estate) == recvSlice->sliceIndex)
{
/* this is recv */
motionstate->mstype = MOTIONSTATE_RECV;
}
else if (LocallyExecutingSliceIndex(estate) == sendSlice->sliceIndex)
{
/* this is send */
motionstate->mstype = MOTIONSTATE_SEND;
}
A Motion that is neither this QE’s sender nor its receiver keeps MOTIONSTATE_NONE, and executing one raises cannot execute inactive Motion (nodeMotion.c:197). Normally that never happens, because a QE does not even build the foreign parts of the tree: execute_pruned_plan (default on, guc_gp.c:2897) sets estate->eliminateAliens (execMain.c:564), and InitPlan then finds the local slice’s sending Motion and starts ExecInitNode there instead of at the plan root (execMain.c:1936-1944), also restricting which SubPlans get initialized (:1972-1973). Turn the pruning off and the design assumption becomes visible.
With alien elimination disabled, a QE initializes the whole tree — and immediately tries to run a Motion that belongs to another slice.
SET explain_memory_verbosity = detail;
SET execute_pruned_plan = on; -- default
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF)
SELECT count(*) FROM m193_fact f JOIN m193_cust c ON f.cust = c.cust;
-- ... plan prints normally, with per-slice memory:
-- (slice0) Executor memory: 261K bytes. Vmem reserved: 15360K bytes.
-- (slice1) Executor memory: 4524K bytes avg x 3x(0) workers ...
-- (slice2) Executor memory: 125K bytes avg x 3x(0) workers ...
SET execute_pruned_plan = off;
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF)
SELECT count(*) FROM m193_fact f JOIN m193_cust c ON f.cust = c.cust;
ERROR: cannot execute inactive Motion (nodeMotion.c:197) (seg1 slice2 10.100.0.3:7103 pid=11745) (nodeMotion.c:197)
19.3.4 Reading a slice tree from EXPLAIN
EXPLAIN has a Cloudberry-only option, SLICETABLE, which appends the executor slice table in the same shape the source comments use (explain.c:1054-1096). It is the fastest way to see structure that the tree indentation only hints at. Here is a three-way join with a two-stage aggregate: five slices, one of them the coordinator’s.
Five slices. The indented tree shows containment; the slice table shows the parent-child graph.
SET optimizer = off;
EXPLAIN (SLICETABLE ON, COSTS OFF)
SELECT count(*) FROM (SELECT c.region, p.cat, sum(f.amt)
FROM m193_fact f JOIN m193_cust c ON f.cust = c.cust
JOIN m193_prod p ON f.prod = p.prod
GROUP BY 1,2) x;
Finalize Aggregate
-> Gather Motion 3:1 (slice1; segments: 3)
-> Partial Aggregate
-> Finalize HashAggregate
Group Key: c.region, p.cat
-> Redistribute Motion 3:3 (slice2; segments: 3)
Hash Key: c.region, p.cat
-> Streaming Partial HashAggregate
Group Key: c.region, p.cat
-> Hash Join
Hash Cond: (f.prod = p.prod)
-> Hash Join
Hash Cond: (f.cust = c.cust)
-> Seq Scan on m193_fact f
-> Hash
-> Broadcast Motion 3:3 (slice3; segments: 3)
-> Seq Scan on m193_cust c
-> Hash
-> Broadcast Motion 3:3 (slice4; segments: 3)
-> Seq Scan on m193_prod p
Slice 0: Dispatcher; root 0; parent -1; gang size 0
Slice 1: Reader; root 0; parent 0; gang size 3
Slice 2: Reader; root 0; parent 1; gang size 3
Slice 3: Reader; root 0; parent 2; gang size 3
Slice 4: Reader; root 0; parent 2; gang size 3
Optimizer: Postgres query optimizer
Read it as follows. The sliceN label on a Motion names the slice below it — the senders — because the slice index is the Motion’s motionID. The receiving slice is the one containing the Motion node, which is parentIndex. So slices 3 and 4 both report parent 2: two Broadcast Motions feeding the same hash-join slice, which is why the tree is a tree and not a chain. Slice 0 is the coordinator’s (Dispatcher, GANGTYPE_UNALLOCATED, gang size 0, never dispatched), and the top Gather is always slice1 because motion ids are handed out during slice-table construction (§16.3; ORCA produces the identical shape for this query — §17.5). Finally, segments: N is list_length(slice->segments) (explain.c:1438), not planNumSegments — and the two differ exactly when direct dispatch is in play.
Direct dispatch: one QE runs the slice, but the hash arithmetic still believes in three segments.
EXPLAIN (SLICETABLE ON, COSTS OFF) SELECT * FROM m193_fact WHERE id = 42;
-- then, with debug_print_slice_table on, the same query:
Gather Motion 1:1 (slice1; segments: 1)
-> Seq Scan on m193_fact
Filter: (id = 42)
Slice 0: Dispatcher; root 0; parent -1; gang size 0
Slice 1: Reader; root 0; parent 0; gang size 1
DEBUG: slice table:
...
:slices[i].sliceIndex 1
:slices[i].parentIndex 0
:slices[i].planNumSegments 3
:slices[i].gangType 3
:slices[i].segments (i 0)
-- and the planner-side entry, from debug_print_plan:
:slices[i].numsegments 3
:slices[i].directDispatch.isDirectDispatch true
:slices[i].directDispatch.contentIds (i 0)
That is what the comment at execdesc.h:112-114 means by “nominal # of segments, for hash calculations. Can be different from gangSize, if direct dispatch”: the row for id = 42 lives on the segment chosen by a 3-way hash (§16.4), so the surviving QE must keep hashing modulo 3 even though it is alone. Intra-segment parallelism moves the same number in the opposite direction.
With intra-segment parallelism the segment list gains duplicates: 3 segments x 2 workers = a gang of 6.
SET enable_parallel = on;
SET min_parallel_table_scan_size = 0;
SET parallel_setup_cost = 0;
EXPLAIN (SLICETABLE ON, COSTS OFF) SELECT count(*) FROM m193_fact;
-- with debug_print_slice_table on:
Finalize Aggregate
-> Gather Motion 6:1 (slice1; segments: 6)
-> Partial Aggregate
-> Parallel Seq Scan on m193_fact
Slice 0: Dispatcher; root 0; parent -1; gang size 0
Slice 1: Reader; root 0; parent 0; gang size 6
DEBUG: slice table:
:slices[i].sliceIndex 1
:slices[i].planNumSegments 6
:slices[i].segments (i 0 0 1 1 2 2)
:slices[i].useMppParallelMode true
:slices[i].parallel_workers 2
| Query on this 3-segment cluster | slices | QE slices x gang size | QEs in the session |
|---|---|---|---|
SELECT * FROM m193_fact WHERE id = 42 | 2 | 1 x 1 (direct dispatch) | 1 |
count(*) over a 2-table join | 3 | 2 x 3 | 6 |
count(*) over a 3-table join + two-stage agg | 5 | 4 x 3 | 12 |
count(*) with enable_parallel = on | 2 | 1 x 6 (3 segs x 2 workers) | 6 |
count(*) with an uncorrelated scalar subquery | 4 | 2 x 3, run one after the other | 3 (gang reused) |
All five counts were measured in fresh sessions with
gp_backend_info(), usingPGOPTIONSrather thanSET— because aSETstatement itself goes throughCdbDispatchSetCommand, which allocates a full writer gang across all segments (cdbdisp_query.c:303,323). Run oneSETbefore the direct-dispatch query and the session shows 3 QEs instead of 1. Also:numSlicesis capped —SET gp_max_slices = 3then running the five-slice query givesERROR: at most 3 slices are allowed in a query, current number: 5fromexecUtils.c:1758, before any gang is created.
19.3.5 Subplan and InitPlan slices, and what rootIndex is for
Everything so far had one root. rootIndex exists because a slice table can hold several disjoint slice trees: §18.6 showed that an InitPlan gets its own gang and its own interconnect, and structurally that means its top slice has parentIndex = -1 — a second root in the same array.
Four slices, two roots. Slice 2 is a second Dispatcher slice; slice 3 hangs under it.
EXPLAIN (SLICETABLE ON, COSTS OFF)
SELECT count(*) FROM m193_fact WHERE amt > (SELECT avg(amt) FROM m193_fact);
Finalize Aggregate
InitPlan 1 (returns $0) (slice2)
-> Finalize Aggregate
-> Gather Motion 3:1 (slice3; segments: 3)
-> Partial Aggregate
-> Seq Scan on m193_fact m193_fact_1
-> Gather Motion 3:1 (slice1; segments: 3)
-> Partial Aggregate
-> Seq Scan on m193_fact
Filter: (amt > $0)
Slice 0: Dispatcher; root 0; parent -1; gang size 0
Slice 1: Reader; root 0; parent 0; gang size 3
Slice 2: Dispatcher; root 2; parent -1; gang size 0
Slice 3: Reader; root 2; parent 2; gang size 3
The planner records the ownership in subplan_sliceIds — for this plan debug_print_plan prints :subplan_sliceIds 2, one entry for one SubPlan — and the coordinator uses it to retarget itself: preprocess_initplans reads the array, checks that the slice really is a root (parentIndex == -1, otherwise it is an ordinary subplan on slice 0), and temporarily rewrites its own localSlice.
src/backend/cdb/cdbsubplan.c:134
if (qDispSliceId > 0)
{
/*
* Adjust for the slice to execute on the QD.
*/
rootIndex = qDispSliceId;
queryDesc->estate->es_sliceTable->localSlice = rootIndex;
/* set our global sliceid variable for elog. */
currentSliceId = rootIndex;
...
ExecSetParamPlan(sps, sps->planstate->ps_ExprContext, queryDesc);
So the coordinator dispatches slice 3 as its own statement, gathers the scalar, restores localSlice to 0 (cdbsubplan.c:170) and only then dispatches the main tree. Two consequences are visible in EXPLAIN ANALYZE: the two trees can share one gang, because they never run at the same time — and the main dispatch does not account for the InitPlan slice’s workers at all.
slice1 and slice3 both report gang0 — the same three QEs, used twice. And slice3’s workers are marked as not part of this dispatch.
SET gp_log_gang = debug;
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF)
SELECT count(*) FROM m193_fact WHERE amt > (SELECT avg(amt) FROM m193_fact);
Finalize Aggregate (actual rows=1 loops=1)
InitPlan 1 (returns $0) (slice2)
-> Finalize Aggregate (actual rows=1 loops=1)
-> Gather Motion 3:1 (slice3; gang0; segments: 3) (actual rows=3 loops=1)
-> Partial Aggregate (actual rows=1 loops=1)
-> Seq Scan on m193_fact m193_fact_1 (actual rows=66711 loops=1)
-> Gather Motion 3:1 (slice1; gang0; segments: 3) (actual rows=3 loops=1)
-> Partial Aggregate (actual rows=1 loops=1)
-> Seq Scan on m193_fact (actual rows=33778 loops=1)
Filter: (amt > $0)
Rows Removed by Filter: 32858
(slice0) Executor memory: 116K bytes.
(slice1) Executor memory: 120K bytes avg x 3x(0) workers, 120K bytes max (seg0).
(slice2) Executor memory: 60K bytes.
_ (slice3) Workers: Workers: 3 not dispatched;.
Executor memory: 119K bytes avg x 3x(0) workers, 119K bytes max (seg0).
19.3.6 Instruments
EXPLAIN (SLICETABLE ON) — The one instrument you should reach for first. Prints Slice N: <gang type>; root R; parent P; gang size G per slice, plus ; segment S for singleton readers (explain.c:1054). Works without ANALYZE, so it costs nothing.
gp_log_gang = debug — Adds gang<id> to every slice label in EXPLAIN, where the id is the gang’s first qe_identifier (explain.c:1452). Combined with gp_backend_info() this gives you slice → gang → pid in two queries.
gp_backend_info() — QD-side inventory of the session’s backends: id (the qe_identifier used in processesMap), type (Q/w/r), content, host, port, pid. Use it to count the QEs a plan actually needed.
debug_print_slice_table — Dumps the whole SliceTable node at DEBUG3 — on the coordinator before dispatch (execMain.c:593, unbound) and on each QE after it (:620, bound, with CdbProcess addresses and localSlice). The only way to see processesMap and the interconnect endpoints.
debug_print_plan — Contains the planner-side PlanSlice array and subplan_sliceIds verbatim (outfuncs.c:421-433) — the right place to check numsegments, parallel_workers and directDispatch.contentIds before the executor reinterprets them.
gp_log_interconnect = debug — Makes every QE log one line, seg%d executing slice%d under root slice%d (execMain.c:623-627) — the cheapest possible confirmation of slice identity. §19.5 owns the rest of what this GUC prints.
One trap worth knowing:
debug_print_slice_tableis listed insrc/include/utils/unsync_guc_name.h:94, so it is not among the GUCs the coordinator puts in a QE’s startup options (cdbgang.c:464only forwardsGUC_GPDB_NEED_SYNCones). A plainSETreaches the writer gang and any currently idle reader QEs (cdbdisp_query.c:303-330) — so if you enable it as the first statement of a session, only the writer gang’s QEs will echo their copy. Run any query first so the reader gangs exist, thenSET, and all of them do. Verified both ways on this cluster.
That completes the mapping. A plan enters the executor as an array of PlanSlices that knows only counts; it leaves AssignGangs as an array of ExecSlices that knows host names, ports and pids; and each of the N+1 processes finds itself in that array by looking up one integer it was told at connection time. §19.4 now opens the Motion node itself and follows a tuple from a sender’s ExecProcNode to a receiver’s, using precisely the primaryProcesses lists you have just seen.
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