Skip to content

Chapter 18.5 · Query Planning and Execution · 24 min read

Expression Evaluation in the Cloudberry Executor

How expressions compile to an ExprState step program, how short-circuit jumps are patched, and where JIT compilation does and does not apply.

By Tushar Pednekar · · Verified against the Apache Cloudberry source tree, read September 2026

Expressions are compiled to a little step machine before they run. This is the least-documented layer of the executor.

Every other section of this chapter moves tuples. This one is about the code that computes values: the WHERE clause that decides whether a tuple survives, the target list that builds the tuple the parent will see, the hash and equality tests inside an aggregate. That code runs once per expression per tuple, on every segment, so it is the innermost loop in the whole system — and it has an unusual design because of it.

A qual arrives from the planner as an Expr node tree (§15.1). Walking that tree once per tuple would mean pointer-chasing plus a switch on node type at every level. So the executor does not walk it. src/backend/executor/execExpr.c (4 639 lines) compiles the tree once into a flat array of ExprEvalSteps — a little bytecode — and src/backend/executor/execExprInterp.c (4 943 lines) runs that array. The figure below is one real compilation, dumped out of a live segment backend.

Expr tree — one qual clause ExprState.steps — 13 x ExprEvalStep, 64 bytes each OR AND = > < a 500 a 100 a 200 tree edges: parent to child a is attno 2 of the scan slot 0EEOP_SCAN_FETCHSOMElast_var 2 1EEOP_SCAN_VARattnum 1 2EEOP_FUNCEXPR_STRICTint4gt, 100 3EEOP_BOOL_AND_STEP_FIRSTjumpdone 7 4EEOP_SCAN_VARattnum 1 5EEOP_FUNCEXPR_STRICTint4lt, 200 6EEOP_BOOL_AND_STEP_LASTjumpdone 7 7EEOP_BOOL_OR_STEP_FIRSTjumpdone 11 8EEOP_SCAN_VARattnum 1 9EEOP_FUNCEXPR_STRICTint4eq, 500 10EEOP_BOOL_OR_STEP_LASTjumpdone 11 11EEOP_QUALjumpdone 12 12EEOP_DONEreturn resvalue false true false or null amber = a step that can jump; everything else falls through to the next step
An Expr tree for WHERE (a > 100 AND a < 200) OR a = 500 and the 13-step array it compiles to. Amber steps are the ones that can jump; the arrows are the short-circuit paths.

Read the array top to bottom and the design becomes obvious. There is no recursion at run time and no node-type test: step 1 pulls a out of the scan slot, step 2 calls int4gt, step 3 decides whether the rest of the AND can be skipped. The short circuit is not a return from a recursive call — it is an index: step 3 carries jumpdone = 7, the step just past the last conjunct.

How the step arrays in this section were obtained. There is no SQL view of a compiled expression, so I attached gdb to a backend on segment 0 (opened with PGOPTIONS=’-c gp_role=utility’ psql -p 7102), broke on ExecReadyInterpretedExpr — which is called with the array fully built — and walked state->steps, printing (ExprEvalOp) opcode and the relevant d-union fields. Every listing below is that printf output, trimmed. A utility-mode segment session compiles quals exactly the way a QE does, and it is a single process, which makes it a much better laboratory than the coordinator.

18.5.1 From tree to step array

There are three public front doors, and they differ only in what they wrap around the same recursive walk: ExecInitExpr (execExpr.c:138) for a scalar expression, ExecInitQual (:225) for an implicit-AND list of clauses, and ExecBuildProjectionInfo (:369) for a target list. Each one makes an ExprState, calls the recursive ExecInitExprRec (:901), appends a final EEOP_DONE, and calls ExecReadyExpr.

src/backend/executor/execExpr.c:148

	/* Initialize ExprState with empty step list */
	state = makeNode(ExprState);
	state->expr = node;
	state->parent = parent;
	/* Insert setup steps as needed */
	ExecCreateExprSetupSteps(state, (Node *) node);
	/* Compile the expression proper */
	ExecInitExprRec(node, state, &state->resvalue, &state->resnull);
	/* Finally, append a DONE step */
	scratch.opcode = EEOP_DONE;
	ExprEvalPushStep(state, &scratch);
	ExecReadyExpr(state);

ExecInitExprRec is a big switch on nodeTag, one arm per Expr kind, and each arm fills in a stack-local scratch ExprEvalStep and hands it to ExprEvalPushStep (:2626), which memcpy’s it into a doubling array. Note the two pointers passed down the recursion: resv/resnull say where this subexpression must leave its answer. At the top they are &state->resvalue / &state->resnull — the ExprState’s own scratch slot — and a function call passes its argument slots instead, so a nested call writes its result straight into its parent’s FunctionCallInfo. That is the whole calling convention: no stack, just pre-wired destinations.

An ExprEvalStep is an opcode plus those two result pointers plus a union of per-opcode payloads (execExpr.h:274). The union is deliberately capped so the struct fits one cache line — and it does: gdb -batch -ex 'p sizeof(ExprEvalStep)' on this build prints 64.

The step array compiled for a two-clause WHERE (segment 0, utility mode, gdb)

-- gdb: break ExecReadyInterpretedExpr, then walk state->steps
SELECT count(*) FROM m185_t WHERE a > 100 AND s = 'row5';
Breakpoint 1.1, ExecReadyInterpretedExpr (state=0x55fdc0115f88)
=== steps_len=8 flags=1
step  0  EEOP_SCAN_FETCHSOME
step  1  EEOP_SCAN_VAR
step  2  EEOP_FUNCEXPR_STRICT
step  3  EEOP_QUAL
step  4  EEOP_SCAN_VAR
step  5  EEOP_FUNCEXPR_STRICT
step  6  EEOP_QUAL
step  7  EEOP_DONE

-- second pass over the same ExprState, printing the union payloads:
step0 last_var=3 fixed=1
step1 attnum=1        step4 attnum=2
step2 fn_oid=147 nargs=2 args[1].value=100 args[1].isnull=0
step5 fn_oid=67  nargs=2 fn_strict=1
step3 jumpdone=7      step6 jumpdone=7

Several things in that dump are worth slowing down for. flags=1 is EEO_FLAG_IS_QUAL (execnodes.h:78), set by ExecInitQual so ExecQual can assert it later. fn_oid=147 and 67 are int4gt and texteq. Step 0 is a setup step: one slot_getsomeattrs up to attribute 3, hoisted out in front of everything so the interpreter never has to deform the tuple twice. And there is no EEOP_CONST step for the literal 100 — look at where it ended up instead.

src/backend/executor/execExpr.c:2715

		if (IsA(arg, Const))
		{
			/*
			 * Don't evaluate const arguments every round; especially
			 * interesting for constants in comparisons.
			 */
			Const	   *con = (Const *) arg;

			fcinfo->args[argno].value = con->constvalue;
			fcinfo->args[argno].isnull = con->constisnull;
		}

A constant argument is written into the function’s FunctionCallInfo at compile time and never touched again — which is why args[1].value already reads 100 before a single tuple has been examined. ExecInitFunc (:2652) then picks the opcode; we come back to that choice in §18.5.3.

18.5.2 Jumps are patched in afterwards

A flat array cannot know its own jump targets while it is being built: the target of AND’s short circuit is the step after the last conjunct, and that index does not exist yet. So every jump is pushed with a placeholder -1, its own index is remembered in a list, and the list is walked once the group is complete. ExecInitQual is the shortest example of the pattern.

src/backend/executor/execExpr.c:264

	foreach(lc, qual)
	{
		Expr	   *node = (Expr *) lfirst(lc);
		/* first evaluate expression */
		ExecInitExprRec(node, state, &state->resvalue, &state->resnull);
		/* then emit EEOP_QUAL to detect if it's false (or null) */
		scratch.d.qualexpr.jumpdone = -1;
		ExprEvalPushStep(state, &scratch);
		adjust_jumps = lappend_int(adjust_jumps, state->steps_len - 1);
	}
	/* adjust jump targets */
	foreach(lc, adjust_jumps)
	{
		ExprEvalStep *as = &state->steps[lfirst_int(lc)];
		Assert(as->d.qualexpr.jumpdone == -1);
		as->d.qualexpr.jumpdone = state->steps_len;
	}

That also explains the jumpdone=7 on both EEOP_QUAL steps of the previous dump: the fixup runs before EEOP_DONE is appended, so state->steps_len is exactly the index DONE will get. Every clause that fails jumps to the same place. And notice what a top-level AND actually became — not BOOL_AND steps at all. The planner delivers WHERE as an implicit-AND list, and ExecInitQual emits one cheap EEOP_QUAL per element; the comment at :249 says why it is worth a dedicated opcode rather than reusing BOOL_AND (a qual only ever needs false or null → false, so it needs no three-valued bookkeeping).

You get real BOOL_* steps when an AND survives inside a larger boolean expression. ExecInitExprRec’s T_BoolExpr arm (:1399) splits AND/OR into _STEP_FIRST / _STEP / _STEP_LAST variants — the first resets the shared anynull flag, which is palloc’d once per BoolExpr node at :1409 — and applies the same fixup loop at :1473. This is the expression drawn in the figure.

A nested OR of ANDs: BOOL steps and their patched jump targets

-- same gdb recipe
SELECT count(*) FROM m185_t WHERE (a > 100 AND a < 200) OR a = 500;
=== steps_len=13 flags=1
step  0  EEOP_SCAN_FETCHSOME       last_var=2
step  1  EEOP_SCAN_VAR             attnum=1
step  2  EEOP_FUNCEXPR_STRICT      fn_oid=147   (int4gt)
step  3  EEOP_BOOL_AND_STEP_FIRST  jumpdone=7   anynull=0x55fdc01ef3f0
step  4  EEOP_SCAN_VAR             attnum=1
step  5  EEOP_FUNCEXPR_STRICT      fn_oid=66    (int4lt)
step  6  EEOP_BOOL_AND_STEP_LAST   jumpdone=7
step  7  EEOP_BOOL_OR_STEP_FIRST   jumpdone=11  anynull=0x55fdc01eefc8
step  8  EEOP_SCAN_VAR             attnum=1
step  9  EEOP_FUNCEXPR_STRICT      fn_oid=65    (int4eq)
step 10  EEOP_BOOL_OR_STEP_LAST    jumpdone=11
step 11  EEOP_QUAL                 jumpdone=12
step 12  EEOP_DONE

The two AND steps share one anynull cell and one target, 7 — the enclosing OR step that consumes their verdict. For step 3 that jump skips two real steps; for step 6 the target happens to be the next step, so the jump is indistinguishable from falling through. The OR pair behaves the same way with target 11. Nothing here is specific to Cloudberry: this is the same array a single-node PostgreSQL would build, compiled independently by each of the three QEs.

18.5.3 The opcode vocabulary

enum ExprEvalOp in src/include/executor/execExpr.h:66 holds 100 opcodes plus the EEOP_LAST sentinel. Their order is load-bearing: the interpreter’s dispatch table is a positional array, and execExprInterp.c:516 has a StaticAssertDecl that its length equals EEOP_LAST + 1, so adding an opcode in the wrong place is a compile error rather than a mystery crash.

FamilyMembers (count)What they do
slot accessEEOP_INNER/OUTER/SCAN_FETCHSOME, _VAR, _SYSVAR, EEOP_WHOLEROW (10)Deform a slot up to attribute n, then read one attribute out of the inner, outer or scan tuple — the three slots an ExprContext offers (§18.1)
result assignmentEEOP_ASSIGN_INNER/OUTER/SCAN_VAR, EEOP_ASSIGN_TMP[_MAKE_RO] (5)Write a column of the result slot; used only by projections
constantsEEOP_CONST (1)Copy a Const into the result location
function callsEEOP_FUNCEXPR, _STRICT, _FUSAGE, _STRICT_FUSAGE (4)Call one pg_proc function through its cached FmgrInfo
control flowEEOP_BOOL_AND/OR_STEP{,_FIRST,_LAST}, EEOP_BOOL_NOT_STEP, EEOP_QUAL, EEOP_JUMP{,_IF_NULL,_IF_NOT_NULL,_IF_NOT_TRUE}, EEOP_DONE (13)The only steps that do not simply fall through
testsEEOP_NULLTEST_* (4), EEOP_BOOLTEST_* (4)IS NULL, IS TRUE and friends, inline
parametersEEOP_PARAM_EXEC/EXTERN/CALLBACK (3)$n from a prepared statement (§13.2) or from an outer query’s SubPlan (§18.6)
aggregate supportEEOP_AGG_* (15) plus EEOP_AGGREF, EEOP_GROUPING_FUNC, EEOP_AGGEXPR_ID (18)The per-group transition calls nodeAgg.c builds with ExecBuildAggTrans — see §18.3
everything elseEEOP_IOCOERCE, EEOP_ARRAYEXPR, EEOP_ROWCOMPARE_*, EEOP_MINMAX, EEOP_FIELDSELECT, EEOP_SBSREF_*, EEOP_DOMAIN_*, EEOP_XMLEXPR, EEOP_JSON_CONSTRUCTOR, EEOP_IS_JSON, EEOP_WINDOW_FUNC, EEOP_SUBPLAN, …One arm each in the interpreter, most of them delegating to an exported ExecEval* helper

Why so many near-duplicates? Because specialization is the performance argument for this design. Compare the two function-call opcodes: a strict function must reject null arguments, a non-strict one must not, and that decision is fixed at compile time — so it becomes two opcodes and the interpreter never tests strictness per tuple.

src/backend/executor/execExprInterp.c:752

		EEO_CASE(EEOP_FUNCEXPR_STRICT)
		{
			FunctionCallInfo fcinfo = op->d.func.fcinfo_data;
			NullableDatum *args = fcinfo->args;
			int			nargs = op->d.func.nargs;

			/* strict function, so check for NULL args */
			for (int argno = 0; argno < nargs; argno++)
				if (args[argno].isnull) { *op->resnull = true; goto strictfail; }
			fcinfo->isnull = false;
			*op->resvalue = op->d.func.fn_addr(fcinfo);
			*op->resnull = fcinfo->isnull;
	strictfail:
			EEO_NEXT();

The choice is made once, in ExecInitFunc (execExpr.c:2735): strict and has arguments and function-usage statistics are not being collected gives EEOP_FUNCEXPR_STRICT. Every combination gets its own opcode so the run-time path holds no branch that was already decidable.

Cloudberry adds six opcodes of its own to this enum, and they are a fair summary of what an MPP executor needs beyond PostgreSQL: EEOP_GROUP_ID and EEOP_GROUPING_SET_ID implement the GROUP_ID() / grouping-set bookkeeping that survives two-stage aggregation, EEOP_AGGEXPR_ID tags a split tuple with the aggregate it belongs to (the multi-DQA machinery of §18.3), EEOP_ROWIDEXPR stamps a unique id on rows before a dedup-semi broadcast (§18.4), and EEOP_SCALARARRAYOP_FAST_INT / _FAST_STR are a fast path for IN lists. Both fire on a plain query.

Cloudberry’s IN-list opcodes, live

-- gdb, same recipe; s is text, a is int4
SELECT count(*) FROM m185_s WHERE s IN ('v7','v8','v9') AND a IN (70,80,90);
=== expr steps_len=8 flags=1
  step  0  EEOP_SCAN_FETCHSOME
  step  1  EEOP_SCAN_VAR                  attnum=2
  step  2  EEOP_SCALARARRAYOP_FAST_STR    opfuncid=67   (texteq)
  step  3  EEOP_QUAL                      jumpdone=7
  step  4  EEOP_SCAN_VAR                  attnum=1
  step  5  EEOP_SCALARARRAYOP_FAST_INT    opfuncid=65   (int4eq)
  step  6  EEOP_QUAL                      jumpdone=7
  step  7  EEOP_DONE

ExecInitScalarArrayOpFastPath (execExpr.c:3517) accepts the case only when the array side is a non-null Const, the operator is one of int2eq/int4eq/int8eq/date_eq/texteq/bpchareq, and — for the text cases — the collation is one memcmp can honour. It then deconstructs the array once at compile time into a plain Datum array, and ExecEvalScalarArrayOpFastInt (execExprInterp.c:3627) loops over it comparing Datums directly, with no FunctionCallInvoke per element. Upstream PostgreSQL 16 instead converts large IN lists into EEOP_HASHED_SCALARARRAYOP; both opcodes exist here, side by side.

18.5.4 Interpretation: computed goto

ExecInterpExpr (execExprInterp.c:400) is one enormous function containing one block per opcode, and it can be compiled two ways. Portably, the blocks are case labels of a switch inside a loop — switch threading. On GCC and Clang, the blocks are ordinary labels whose addresses are taken (the label-as-value extension), each block ends by jumping straight to the next block’s address, and the central switch disappears entirely — direct threading. The file’s header comment (:6) calls the latter one of the fastest known ways to interpret a program without writing assembly, because the jumps issue from many sites instead of one and so branch prediction has something to work with.

src/backend/executor/execExprInterp.c:122

#define EEO_SWITCH()
#define EEO_CASE(name)		CASE_##name:
#define EEO_DISPATCH()		goto *((void *) op->opcode)
#define EEO_OPCODE(opcode)	((intptr_t) dispatch_table[opcode])

#else							/* !EEO_USE_COMPUTED_GOTO */

#define EEO_SWITCH()		starteval: switch ((ExprEvalOp) op->opcode)
#define EEO_CASE(name)		case name:
#define EEO_DISPATCH()		goto starteval
#define EEO_OPCODE(opcode)	(opcode)

EEO_USE_COMPUTED_GOTO is defined if and only if HAVE_COMPUTED_GOTO is (:92), and on this build — GCC 12.2.1 — pg_config.h:120 reads #define HAVE_COMPUTED_GOTO 1, in the installed tree as well as the source tree. So direct threading is genuinely in force here, and you can watch it happen: ExecReadyInterpretedExpr overwrites each opcode field with the address of its code block and sets EEO_FLAG_DIRECT_THREADED (:370–:383).

The same step, before and after the array is made ready

-- gdb: print steps[3].opcode at ExecReadyInterpretedExpr entry,
-- then again at the first evaluation (ExecInterpExprStillValid)
SELECT count(*) FROM m185_s WHERE a > 100 AND s = 'v18';
at ExecReadyInterpretedExpr entry: flags=1  steps[3].opcode=28
at first evaluation:  flags=7  steps[3].opcode=0x55fdb69efa84  decoded=EEOP_QUAL
  steps[0].opcode=0x55fdb69f01e3 decoded=EEOP_SCAN_FETCHSOME
  evalfunc_private=0x55fdb69ef910          <-- == &ExecInterpExpr

28 is the ordinal of EEOP_QUAL in the enum; a moment later the same field holds a code address inside the postgres text segment, and flags has gone from 1 to 7 — IS_QUAL | INTERPRETER_INITIALIZED | DIRECT_THREADED. Because the numbers are gone, anything that needs the opcode back (EXPLAIN, the JIT provider, this debugging session) has to ask ExecEvalStepOp (:2459), which bsearches a reverse table built at first use by ExecInitInterpreter (:2427) — and that function gets the dispatch table by calling ExecInterpExpr(NULL, NULL, NULL), a special case that returns the address of the table instead of evaluating anything.

Making an expression ready is thus a small decision tree, and its most interesting branch is the one before threading: for the tiniest expressions even entering the interpreter is measurable, so ExecReadyInterpretedExpr recognizes fourteen exact 2- and 3-step patterns and installs a hand-written C function instead (:279–:367).

yes — needs a JIT provider
no — always, on this build
yes
no
ExecReadyExpr

execExpr.c:884
jit_compile_expr

returns true?
native function

compiled from the steps
ExecReadyInterpretedExpr
steps_len 2 or 3

matching a known pattern?
one of the ExecJust routines

no interpreter at all
rewrite every opcode field

into a code address
ExecInterpExpr

direct threaded
ExecReadyExpr: three possible fates for a compiled step array

ExecJustScanVar, ExecJustAssignOuterVar, ExecJustConst, ExecJustApplyFuncToCase and their …Virt cousins (for slots that are already virtual and need no deform step) each inline exactly what their pattern does. A single-column projection is precisely such a pattern, and the split is visible if you simply count function entries.

Who evaluates what: breakpoint hit counts on segment 0 (12 rows there, 9 pass the qual)

-- gdb: break ExecJust*, ExecInterpExpr with huge ignore counts,
-- run to ExecutorEnd, then: info breakpoints
SELECT s FROM m185_s WHERE a > 50;
2       breakpoint  in ExecJustAssignScanVar at execExprInterp.c:2267
	breakpoint already hit 9 times
6       breakpoint  in ExecInterpExpr at execExprInterp.c:519
	breakpoint already hit 12 times
7       breakpoint  in ExecutorEnd at execMain.c:1242
	breakpoint already hit 1 time

Twelve interpreter entries — one qual evaluation per tuple the segment stored — and nine fast-path entries, one projection per tuple that survived. Change the target list to two columns and the projection stops matching a Just pattern: the same query with SELECT a, s shows 21 ExecInterpExpr hits and no fast-path hits at all. That is the entire cost model of this subsystem in two numbers.

18.5.5 Quals, projection, and the per-tuple context

Callers never touch steps. They use two inline wrappers in src/include/executor/executor.h: ExecQual (:460), which asserts the IS_QUAL flag and returns DatumGetBool of the result, and ExecProject (:423), which evaluates a projection and marks the result slot as a valid virtual tuple. Both go through ExecEvalExprSwitchContext (:395), which does the one thing every expression evaluation must do first.

src/include/executor/executor.h:395

ExecEvalExprSwitchContext(ExprState *state, ExprContext *econtext,
						  bool *isNull)
{
	Datum		retDatum;
	MemoryContext oldContext;

	oldContext = MemoryContextSwitchTo(econtext->ecxt_per_tuple_memory);
	retDatum = state->evalfunc(state, econtext, isNull);
	MemoryContextSwitchTo(oldContext);
	return retDatum;
}

Expression results are allocated in ecxt_per_tuple_memory and are freed en masse by ResetExprContext (:593) once per tuple — ExecQualAndReset (:487) even fuses the two operations for hot callers. This is why a leaking expression is hard to write and why text concatenation in a target list does not accumulate: nobody frees anything, the context is simply thrown away. The ExprContext itself belongs to the PlanState, so §18.1 owns it.

EXPLAIN (VERBOSE) is the SQL-level view of the two ExprStates: Filter is the qual, Output is the projection’s target list. Note that the Gather Motion above has an Output list of its own — a Motion projects too (Chapter 19).

The qual and the projection, as EXPLAIN shows them (PostgreSQL planner)

SET optimizer = off;
EXPLAIN (VERBOSE, COSTS OFF)
  SELECT id, a*2 AS dbl, upper(s) AS us FROM m185_t
   WHERE a > 100 AND s = 'row5';
                            QUERY PLAN
------------------------------------------------------------------
 Gather Motion 3:1  (slice1; segments: 3)
   Output: id, ((a * 2)), (upper(s))
   ->  Seq Scan on public.m185_t
         Output: id, (a * 2), upper(s)
         Filter: ((m185_t.a > 100) AND (m185_t.s = 'row5'::text))
 Settings: optimizer = 'off'
 Optimizer: Postgres query optimizer

That target list compiles to six steps, and it shows off ExecBuildProjectionInfo’s own specialization: a plain, type-safe Var becomes a single EEOP_ASSIGN_SCAN_VAR that copies straight from input slot to output slot (execExpr.c:435), while a computed column evaluates into resvalue and is then moved by EEOP_ASSIGN_TMP. Variable-length results get EEOP_ASSIGN_TMP_MAKE_RO instead (:479), because an upper node may reference the column more than once and must not be handed a writable expanded datum. Compiling SELECT a, upper(s) yields exactly that: SCAN_FETCHSOME, ASSIGN_SCAN_VAR, SCAN_VAR, FUNCEXPR_STRICT, ASSIGN_TMP_MAKE_RO, DONE.

How much does all this cost? Compare two scans of the same 300 000-row table whose quals accept every row, so the two plans differ in nothing but the work per tuple.

Same plan, same rows, different qual: expression evaluation as the whole difference

SET optimizer = off;
EXPLAIN (ANALYZE, COSTS OFF) SELECT count(*) FROM m185_t WHERE a >= 0;
EXPLAIN (ANALYZE, COSTS OFF)
  SELECT count(*) FROM m185_t WHERE length(md5(s||s||s)) >= 0;
 Finalize Aggregate (actual time=30.094..30.096 rows=1 loops=1)
   ->  Gather Motion 3:1  (slice1; segments: 3) (actual ... rows=3 loops=1)
         ->  Partial Aggregate (actual time=19.241..19.242 rows=1 loops=1)
               ->  Seq Scan on m185_t (actual time=0.093..21.537 rows=100172 loops=1)
                     Filter: (a >= 0)
 ...
 Execution Time: 31.211 ms

 Finalize Aggregate (actual time=165.026..165.029 rows=1 loops=1)
   ->  Gather Motion 3:1  (slice1; segments: 3) (actual ... rows=3 loops=1)
         ->  Partial Aggregate (actual time=130.550..130.551 rows=1 loops=1)
               ->  Seq Scan on m185_t (actual time=0.642..157.476 rows=100172 loops=1)
                     Filter: (length(md5(((s || s) || s))) >= 0)
 ...
 Execution Time: 165.664 ms

Identical row counts, identical node shapes, and a factor of 5.3 between them; repeating each three times and taking the best gives 22.4 ms against 141.2 ms, a factor of 6.3. Four extra EEOP_FUNCEXPR_STRICT steps per tuple — two textcat, one md5, one length — dominate a sequential scan of 100 000 rows per segment. The dispatch machinery this section describes is not what costs; the functions it dispatches to are.

Ratios only, please. This build is —enable-cassert (debug_assertions = on), which taxes exactly this code path — the interpreter is full of Asserts. Two numbers measured back-to-back on one machine are comparable to each other and to nothing else.

18.5.6 Hashing and grouping support

One more consumer of compiled expressions deserves naming, because two other sections depend on it. src/backend/executor/execGrouping.c (570 lines) provides the TupleHashTable used by hash aggregation (§18.3), hashed SubPlans (§18.6), SetOp and RecursiveUnion. execTuplesHashPrepare (:98) turns a list of equality operators into equality function OIDs plus hash function FmgrInfos, and BuildTupleHashTableExt (:156) then asks the expression compiler for the comparison.

src/backend/executor/execGrouping.c:237

	/* build comparator for all columns */
	hashtable->tab_eq_func = ExecBuildGroupingEqual(inputDesc, inputDesc,
													NULL, &TTSOpsMinimalTuple,
													numCols,
													keyColIdx, eqfuncoids, collations,
													allow_jit ? parent : NULL);

ExecBuildGroupingEqual (execExpr.c:4224) builds an ExprState that compares the ExprContext’s inner and outer tuples with not-distinct semantics — two nulls match — and is invoked through ExecQual, since a comparison is just a boolean expression. Hashing itself is not compiled in PostgreSQL 16: TupleHashTableHash_internal (:435) rotates and xors the per-column FunctionCall1Coll results, then perturbs them with murmurhash32. Note the allow_jit ? parent : NULL argument — passing a NULL parent is how a caller deliberately forbids JIT for an expression whose lifetime does not match the JIT context (:229).

18.5.7 Set-returning functions

A function in a target list that returns a set breaks the one-value-per-step contract, so since PostgreSQL 10 it does not live in an ordinary projection at all: the planner interposes a ProjectSet node, and guarantees that set-returning functions sit directly at the top level of its target list, adding more ProjectSet nodes if it must (nodeProjectSet.c:8). ExecProjectSRF (:140) evaluates each element, watching for ExprMultipleResult (more rows to come from this input tuple) and ExprEndResult (this one is exhausted), and only advances its child when every SRF has ended.

An SRF in the target list is a ProjectSet node; in FROM it is a Function Scan

SET optimizer = off;
EXPLAIN (COSTS OFF) SELECT id, generate_series(1, a % 3 + 1) AS g
                      FROM m185_s WHERE id <= 3;
EXPLAIN (COSTS OFF) SELECT * FROM generate_series(1,3) g;
                      QUERY PLAN
-------------------------------------------------------
 Gather Motion 3:1  (slice1; segments: 3)
   Output: id, (generate_series(1, ((a % 3) + 1)))
   ->  ProjectSet
         Output: id, generate_series(1, ((a % 3) + 1))
         ->  Seq Scan on public.m185_s
               Filter: (m185_s.id <= 3)

             QUERY PLAN
-------------------------------------
 Function Scan on generate_series g
 Optimizer: Postgres query optimizer

The two paths meet in src/backend/executor/execSRF.c, and they support the two return protocols of ReturnSetInfo differently. ExecMakeFunctionResultSet (:502), the target-list path, allows only SFRM_ValuePerCall and SFRM_Materialize (:599): a C function like generate_series is re-entered once per row, while a PL/pgSQL function fills a tuplestore and the caller drains it (:645, :666). ExecMakeTableFunctionResult (:102), the FROM path, additionally offers SFRM_Materialize_Preferred and _Random (:151), because a Function Scan may want to rescan. ProjectSet under ORCA is the same node — this plan shape is not optimizer-specific.

18.5.8 JIT: what it would do, and why it does nothing here

There is a fourth way to run a step array. PostgreSQL can hand it to an LLVM back end that emits native code for this expression: llvm_compile_expr (src/backend/jit/llvm/llvmjit_expr.c:78) creates one LLVM basic block per step (:263), asks ExecEvalStepOp for each opcode (:279), and emits the block’s logic inline, branching to the next block. The interpreter’s dispatch — the one thing the flat array was designed to make cheap — vanishes completely, along with the per-step pointer indirections; complex opcodes still call the same exported ExecEval* helpers, which is exactly why execExprInterp.c exports them (:41 in its header comment).

The wiring runs through two decisions. The planner sets PlannedStmt.jitFlags (planner.c:831) if jit is on and the top plan’s total cost exceeds jit_above_cost, adding PGJIT_OPT3 / PGJIT_INLINE above their own thresholds and PGJIT_EXPR / PGJIT_DEFORM per GUC; then ExecReadyExpr calls jit_compile_expr (jit.c:153), which checks those flags and asks provider_init to load the provider shared library.

The honest readback: jit is on, JIT is not available

SELECT name, setting FROM pg_settings WHERE name LIKE 'jit%' ORDER BY 1;
SELECT pg_jit_available();
-- shell:
ls $GPHOME/lib/postgresql/ | grep -i llvm
pg_config --configure
          name           | setting
-------------------------+---------
 jit                     | on
 jit_above_cost          | 100000
 jit_expressions         | on
 jit_inline_above_cost   | 500000
 jit_optimize_above_cost | 500000
 jit_provider            | llvmjit
 jit_tuple_deforming     | on
 (jit_debugging_support, jit_dump_bitcode, jit_profiling_support = off)

 pg_jit_available
------------------
 f

$ ls $GPHOME/lib/postgresql/ | grep -i llvm      # no output
$ pg_config --configure
 '--prefix=/usr/local/cloudberry-db' '--enable-debug' '--enable-cassert'
 '--enable-depend' '--with-lz4' '--with-zstd' '--with-gssapi' '--enable-orafce'
 '--enable-ic-proxy' '--enable-orca' '--enable-gpcloud' '--with-libxml' ...
 # no --with-llvm

That is the cleanest possible evidence: --with-llvm was never passed, so llvmjit.so was never built, so provider_init (jit.c:68) fails its file_exists check on $pkglibdir/llvmjit.so (:91), records provider_failed_loading so it never retries, and jit_compile_expr returns false. Every expression on this cluster falls through to ExecReadyInterpretedExpr. pg_jit_available() is literally that check, exposed as a function (:59).

Forcing the threshold to zero changes nothing observable

SET optimizer = off;
SET jit_above_cost = 0;   -- session-local; every plan is now flagged for JIT
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF)
  SELECT count(*) FROM m185_t WHERE length(md5(s||s||s)) >= 0;
 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 m185_t (actual rows=100172 loops=1)
                     Filter: (length(md5(((s || s) || s))) >= 0)
 Planning Time: 1.148 ms
 Memory used:  128000kB
 Optimizer: Postgres query optimizer
 Execution Time: 138.518 ms      -- no "JIT:" section anywhere

With jit_above_cost = 0 the PlannedStmt really does carry PGJIT_PERFORM | PGJIT_EXPR | PGJIT_DEFORM, and EXPLAIN ANALYZE still prints no JIT: block — because ExplainPrintJIT returns immediately when no functions were created (explain.c:1214), and none were. The execution time is unchanged, within noise, from the un-flagged run in §18.5.5.

Stated plainly: I could not exercise JIT on this build. Everything above about llvm_compile_expr is read from the source in src/backend/jit/llvm/ (5 146 lines of C, plus the C++ inliner), not measured. Any claim you read elsewhere about JIT speedups on Cloudberry needs a build configured —with-llvm and an llvmjit.so in $GPHOME/lib/postgresql/ to be worth anything — and on an MPP cluster it needs to be measured per segment, since each QE would pay its own compilation cost for the same expression.

That closes the value-computing half of the executor. What remains are the node types that build on it: the subquery and utility nodes of §18.6, and Cloudberry’s own nodes in §18.7.

More in Query Planning and Execution