Skip to content

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.

PlannedStmt.slices[ ] planner output — serialized, identical on every QE EState.es_sliceTable executor state — one copy per process gangs and QE processes CdbProcess: host, listener port, pid PlanSlice[0] sliceIndex 0 parentIndex −1 gangType UNALLOCATED numsegments 1 no gang — the QD itself PlanSlice[1] sliceIndex 1 parentIndex 0 gangType PRIMARY_READER numsegments 3 · workers 0 directDispatch off PlanSlice[2] sliceIndex 2 parentIndex 1 gangType PRIMARY_READER numsegments 3 · workers 0 no rootIndex, no children ExecSlice[0] rootIndex 0 · parentIndex −1 children 1 planNumSegments 1 · segments — primaryProcesses: the QD, pid 10559 processesMap empty · never dispatched ExecSlice[1] rootIndex 0 · parentIndex 0 children 2 planNumSegments 3 · segments 0 1 2 primaryProcesses: 3 CdbProcess processesMap b 0 1 2 ExecSlice[2] rootIndex 0 · parentIndex 1 children — planNumSegments 3 · segments 0 1 2 primaryProcesses: 3 CdbProcess processesMap b 3 4 5 slice 0 has no gang. It runs in the coordinator backend and is the only slice that is not shipped anywhere. writer gang id 0 · seg0 · pid 10561 id 1 · seg1 · pid 10562 id 2 · seg2 · pid 10563 ids are qe_identifier values reader gang id 3 · seg0 · pid 10567 id 4 · seg1 · pid 10568 id 5 · seg2 · pid 10569 the bitmap is the address InitSliceTable execUtils.c:1747 AssignGangs setupCdbProcessList planner: no processes, no tree links executor: tree links, then processes dispatcher: identity by bitmap
One slice table, two representations. Left: what the planner decided, shipped inside the plan. Middle: what the executor built from it. Right: what the dispatcher bound to 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;
QuestionPlanSlice (planner)ExecSlice (executor)
Where it livesPlannedStmt.slices[]EState.es_sliceTable->slices[]
Who writes itcdbllize_build_slice_table (§16.3) or ORCA’s DXL translator (§17.5)InitSliceTable at ExecutorStart, then AssignGangs at dispatch
Lifetimeplanning → serialization → every QE reads the same bytesone instance per process, per statement
Tree linksparentIndex onlyparentIndex + rootIndex + children list
Segment infonumsegments (a count) + directDispatch.contentIdssegments (an actual list of ids) + planNumSegments
ProcessesnoneprimaryGang, primaryProcesses, processesMap
Parallel workersparallel_workers = 0 when not parallelparallel_workers = 1 when not parallel, and useMppParallelMode
Whose identitynobody’s — the array is symmetricSliceTable.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. the qe_identifier of the gang’s first member (explain.c:1452-1461). So gang0 / gang3 / gang6 / gang9 on 3 segments means identifiers 0–2, 3–5, 6–8, 9–11 — exactly the processesMap bitmaps, and exactly the id column of gp_backend_info(). Twelve QEs for one SELECT.

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 clusterslicesQE slices x gang sizeQEs in the session
SELECT * FROM m193_fact WHERE id = 4221 x 1 (direct dispatch)1
count(*) over a 2-table join32 x 36
count(*) over a 3-table join + two-stage agg54 x 312
count(*) with enable_parallel = on21 x 6 (3 segs x 2 workers)6
count(*) with an uncorrelated scalar subquery42 x 3, run one after the other3 (gang reused)

All five counts were measured in fresh sessions with gp_backend_info(), using PGOPTIONS rather than SET — because a SET statement itself goes through CdbDispatchSetCommand, which allocates a full writer gang across all segments (cdbdisp_query.c:303,323). Run one SET before the direct-dispatch query and the session shows 3 QEs instead of 1. Also: numSlices is capped — SET gp_max_slices = 3 then running the five-slice query gives ERROR: at most 3 slices are allowed in a query, current number: 5 from execUtils.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
InitPlan slice tree, rootIndex 2
main slice tree, rootIndex 0
result becomes the InitPlan parameter
slice 3

Reader

gang size 3
slice 2

Dispatcher

gang size 0
slice 1

Reader

gang size 3
slice 0

Dispatcher

gang size 0
Two slice trees in one slice table. The InitPlan tree is dispatched first, in its entirety, before the main tree starts.

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_table is listed in src/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:464 only forwards GUC_GPDB_NEED_SYNC ones). A plain SET reaches 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, then SET, 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