From f5b3f8c0da7b62035d1eb633500ff146da0cd470 Mon Sep 17 00:00:00 2001 From: Linden Lance Date: Thu, 10 Sep 2026 04:15:36 +1200 Subject: [PATCH v1] Hash a ScalarArrayOpExpr whose array is fixed for one execution Since PG 14 (50e17ad281) a ScalarArrayOpExpr with "useOr" can be evaluated with a hash table instead of a linear scan of the array, but only when the array argument is a Const. A parameterised list -- "col = ANY($1)", "col IN ($1, $2, ..., $N)", "col = ANY($1::int[])", "col = ANY(string_to_array($1, ','))" -- keeps a Param / ArrayExpr / FuncExpr in the plan and falls back to the O(rows * N) linear path. That is the shape emitted by JDBC setArray, psycopg and asyncpg, and by any driver that expands an IN list into bind parameters; MySQL and MariaDB binary-search their sorted in_vector for the same query and stay sub-linear. Treat "is this array fixed for the execution?" as the property that matters, not the node type. * clauses.c: convert_saop_to_hashed_saop_walker() accepts, besides a non-null Const, any non-Const array argument that cannot vary from one row to the next (nor from group to group, nor from one window frame to the next): no volatile functions, and -- via saop_array_arg_has_unstable_node_walker() -- no Var or PlaceHolderVar of any level, no aggregate / grouping / window function, no sub-select, and no Param other than PARAM_EXTERN. The walker rejects a Var of any level because this runs in preprocess_expression() before SS_replace_correlation_vars(), so an outer-query reference is still a Var, not a Param. The element count is known at plan time for a Const and for a one-dimensional ArrayExpr, so the MIN_ARRAY_SIZE_FOR_HASHED_SAOP cutoff is applied there; for anything else the executor applies it once the run-time array is known. * execExpr.c: for a non-Const stable array in a plan node the hashed step no longer emits the array sub-expression inline (which would rebuild it every row); it is compiled into an independent ExprState (array_expr) that the step owns and evaluates once. A standalone ExprState -- a PL/pgSQL "simple expression", whose compiled state is reused across calls with different parameter values -- gets a plain EEOP_SCALARARRAYOP instead: the "fixed for one execution" proof relies on an execution boundary that a reused state does not have. * execExprInterp.c: ExecEvalHashedScalarArrayOp() evaluates array_expr once and keeps a detoasted copy of the result (DatumGetArrayTypePCopy(), the call ExecEvalArrayCoerce() uses) in the run-time state, then builds the hash table from that. A run-time array shorter than the threshold keeps hashtab NULL and does a linear search through the existing ExecEvalArrayCompareInternal(); a NULL array yields NULL. The element type metadata is cached in the side struct so the linear-search path does not re-probe the type cache per row, matching ExecEvalScalarArrayOp(). The now-redundant finfo field of the hashed step is replaced by array_expr, so ExprEvalStep does not grow. primnodes.h: the ScalarArrayOpExpr.hashfuncid comment now covers the non-Const case, and MIN_ARRAY_SIZE_FOR_HASHED_SAOP moves here so the planner and the executor can share it. Regression tests for the new array shapes -- external Param, IN ($1,...,$N), $1::int[], string_to_array(), a stable function, and the correlated-sub-select exclusion -- are added to src/test/regress/sql/expressions.sql next to the existing hashed-SAOP tests. Not handled: a correlated array (a Var / PlaceHolderVar / PARAM_EXEC, or an ARRAY(SELECT ...) sub-select), which is deliberately excluded and stays on the linear path. --- src/backend/executor/execExpr.c | 48 ++-- src/backend/executor/execExprInterp.c | 318 ++++++++++++++-------- src/backend/optimizer/util/clauses.c | 121 +++++--- src/include/executor/execExpr.h | 7 +- src/include/nodes/primnodes.h | 20 +- src/test/regress/expected/expressions.out | 207 ++++++++++++++ src/test/regress/sql/expressions.sql | 113 ++++++++ 7 files changed, 658 insertions(+), 176 deletions(-) diff --git a/src/backend/executor/execExpr.c b/src/backend/executor/execExpr.c index 82e846a1f4f..b081dd9f980 100644 --- a/src/backend/executor/execExpr.c +++ b/src/backend/executor/execExpr.c @@ -1320,34 +1320,48 @@ ExecInitExprRec(Expr *node, ExprState *state, opexpr->inputcollid, NULL, NULL); /* - * If hashfuncid is set, we create a EEOP_HASHED_SCALARARRAYOP - * step instead of a EEOP_SCALARARRAYOP. This provides much - * faster lookup performance than the normal linear search - * when the number of items in the array is anything but very - * small. + * A valid hashfuncid means use EEOP_HASHED_SCALARARRAYOP, + * which hash-probes the array instead of scanning it. A + * non-Const array is only safe to hash inside a real + * execution, so for a standalone ExprState (a reused PL/pgSQL + * "simple expression") fall back to the linear + * EEOP_SCALARARRAYOP. */ - if (OidIsValid(opexpr->hashfuncid)) + if (OidIsValid(opexpr->hashfuncid) && + (IsA(arrayarg, Const) || state->parent != NULL)) { /* Evaluate scalar directly into left function argument */ ExecInitExprRec(scalararg, state, &fcinfo->args[0].value, &fcinfo->args[0].isnull); - /* - * Evaluate array argument into our return value. There's - * no danger in that, because the return value is - * guaranteed to be overwritten by - * EEOP_HASHED_SCALARARRAYOP, and will not be passed to - * any other expression. - */ - ExecInitExprRec(arrayarg, state, resv, resnull); - - /* And perform the operation */ scratch.opcode = EEOP_HASHED_SCALARARRAYOP; scratch.d.hashedscalararrayop.inclause = opexpr->useOr; - scratch.d.hashedscalararrayop.finfo = finfo; scratch.d.hashedscalararrayop.fcinfo_data = fcinfo; scratch.d.hashedscalararrayop.saop = opexpr; + if (IsA(arrayarg, Const)) + { + /* + * Evaluate the Const array into our return value. + * There's no danger in that: it is overwritten by + * EEOP_HASHED_SCALARARRAYOP and not passed to any + * other expression. + */ + ExecInitExprRec(arrayarg, state, resv, resnull); + scratch.d.hashedscalararrayop.array_expr = NULL; + } + else + { + /* + * A non-Const array that the planner proved is fixed + * for one execution (see + * convert_saop_to_hashed_saop). Compile it as an + * independent sub-expression; the hashed step + * evaluates it once and caches the result. + */ + scratch.d.hashedscalararrayop.array_expr = + ExecInitExpr((Expr *) arrayarg, state->parent); + } ExprEvalPushStep(state, &scratch); } diff --git a/src/backend/executor/execExprInterp.c b/src/backend/executor/execExprInterp.c index 397219f7a3a..b4675626caa 100644 --- a/src/backend/executor/execExprInterp.c +++ b/src/backend/executor/execExprInterp.c @@ -222,12 +222,24 @@ static uint32 saop_element_hash(struct saophash_hash *tb, Datum key); /* * ScalarArrayOpExprHashTable - * Hash table for EEOP_HASHED_SCALARARRAYOP + * Run-time state for EEOP_HASHED_SCALARARRAYOP. + * + * A hash table of the array elements, built once and probed per row. When the + * array is not a Const, the step's array_expr is evaluated once and copied into + * cached_array, and the table is built from that. A short array leaves hashtab + * NULL and is searched linearly. */ typedef struct ScalarArrayOpExprHashTable { - saophash_hash *hashtab; /* underlying hash table */ + saophash_hash *hashtab; /* element hash table, or NULL for a short + * (linear-search) array */ struct ExprEvalStep *op; + Datum cached_array; /* array value the state was built from */ + bool cache_isnull; /* the array evaluated to SQL NULL */ + /* element type metadata, cached when the array is built (type is fixed) */ + int16 typlen; + bool typbyval; + char typalign; FmgrInfo hash_finfo; /* function's lookup data */ FunctionCallInfoBaseData hash_fcinfo_data; /* arguments etc */ } ScalarArrayOpExprHashTable; @@ -4235,39 +4247,64 @@ saop_hash_element_match(struct saophash_hash *tb, Datum key1, Datum key2) fcinfo->args[1].value = key2; fcinfo->args[1].isnull = false; - result = elements_tab->op->d.hashedscalararrayop.finfo->fn_addr(fcinfo); + result = fcinfo->flinfo->fn_addr(fcinfo); return DatumGetBool(result); } /* - * Evaluate "scalar op ANY (const array)". + * Fetch the array for ExecEvalHashedScalarArrayOp(), setting *arr_value and + * *arr_isnull. A Const is already in the step's result area; a non-Const + * array_expr sub-expression is evaluated here (once, on the first call). + */ +static void +saop_hash_eval_array(ExprEvalStep *op, ExprContext *econtext, + Datum *arr_value, bool *arr_isnull) +{ + ExprState *array_expr = op->d.hashedscalararrayop.array_expr; + + if (array_expr == NULL) + { + *arr_value = *op->resvalue; + *arr_isnull = *op->resnull; + return; + } + + /* Open-coded rather than ExecEvalExpr() to avoid including executor.h. */ + *arr_value = array_expr->evalfunc(array_expr, econtext, arr_isnull); +} + +/* + * Evaluate "scalar op ANY (array)". * - * Similar to ExecEvalScalarArrayOp, but optimized for faster repeat lookups - * by building a hashtable on the first lookup. This hashtable will be reused - * by subsequent lookups. Unlike ExecEvalScalarArrayOp, this version only - * supports OR semantics. + * Similar to ExecEvalScalarArrayOp, but builds a hash table of the array + * elements on the first call and probes it thereafter; OR semantics only. * - * Source array is in our result area, scalar arg is already evaluated into - * fcinfo->args[0]. + * The array is fixed for the whole execution -- either a Const in our result + * area, or the sub-expression the planner compiled into + * op->d.hashedscalararrayop.array_expr -- and is evaluated once. A NULL array + * yields NULL; one with fewer than MIN_ARRAY_SIZE_FOR_HASHED_SAOP elements is + * linear-searched every call (hashtab left NULL), as ExecEvalScalarArrayOp does. * - * The operator always yields boolean. + * The scalar arg is already evaluated into fcinfo->args[0]. The operator + * always yields boolean. */ void ExecEvalHashedScalarArrayOp(ExprState *state, ExprEvalStep *op, ExprContext *econtext) { ScalarArrayOpExprHashTable *elements_tab = op->d.hashedscalararrayop.elements_tab; + ExprState *array_expr = op->d.hashedscalararrayop.array_expr; FunctionCallInfo fcinfo = op->d.hashedscalararrayop.fcinfo_data; bool inclause = op->d.hashedscalararrayop.inclause; - bool strictfunc = op->d.hashedscalararrayop.finfo->fn_strict; + bool strictfunc = fcinfo->flinfo->fn_strict; Datum scalar = fcinfo->args[0].value; bool scalar_isnull = fcinfo->args[0].isnull; Datum result; bool resultnull; bool hashfound; - - /* We don't setup a hashed scalar array op if the array const is null. */ - Assert(!*op->resnull); + ArrayType *arr; + Datum arr_value = (Datum) 0; + bool arr_isnull = false; /* * If the scalar is NULL, and the function is strict, return NULL; no @@ -4279,33 +4316,19 @@ ExecEvalHashedScalarArrayOp(ExprState *state, ExprEvalStep *op, ExprContext *eco return; } - /* Build the hash table on first evaluation */ + /* + * On the first call, obtain the (single, execution-long) array value and + * build the hash table from it -- or, if it turns out to be too short to + * be worth hashing, decide on a linear search. + */ if (elements_tab == NULL) { - ScalarArrayOpExpr *saop; - int16 typlen; - bool typbyval; - char typalign; - uint8 typalignby; - int nitems; - bool has_nulls = false; - char *s; - uint8 *bitmap; - int bitmask; MemoryContext oldcontext; - ArrayType *arr; - saop = op->d.hashedscalararrayop.saop; - - arr = DatumGetArrayTypeP(*op->resvalue); - nitems = ArrayGetNItems(ARR_NDIM(arr), ARR_DIMS(arr)); - - get_typlenbyvalalign(ARR_ELEMTYPE(arr), - &typlen, - &typbyval, - &typalign); - typalignby = typalign_to_alignby(typalign); + /* Evaluate the array in the current (short-lived) context ... */ + saop_hash_eval_array(op, econtext, &arr_value, &arr_isnull); + /* ... then keep our run-time state for the whole execution. */ oldcontext = MemoryContextSwitchTo(econtext->ecxt_per_query_memory); elements_tab = (ScalarArrayOpExprHashTable *) @@ -4314,94 +4337,171 @@ ExecEvalHashedScalarArrayOp(ExprState *state, ExprEvalStep *op, ExprContext *eco op->d.hashedscalararrayop.elements_tab = elements_tab; elements_tab->op = op; - fmgr_info(saop->hashfuncid, &elements_tab->hash_finfo); - fmgr_info_set_expr((Node *) saop, &elements_tab->hash_finfo); + if (arr_isnull) + { + elements_tab->cache_isnull = true; + } + else + { + int16 typlen; + bool typbyval; + char typalign; + int nitems; - InitFunctionCallInfoData(elements_tab->hash_fcinfo_data, - &elements_tab->hash_finfo, - 1, - saop->inputcollid, - NULL, - NULL); + /* + * A computed array may be short-lived and toasted -- copy it into + * the per-query context; a Const is already flat and query-lived. + */ + if (array_expr != NULL) + arr = DatumGetArrayTypePCopy(arr_value); + else + arr = DatumGetArrayTypeP(arr_value); + elements_tab->cached_array = PointerGetDatum(arr); + elements_tab->cache_isnull = false; - /* - * Create the hash table sizing it according to the number of elements - * in the array. This does assume that the array has no duplicates. - * If the array happens to contain many duplicate values then it'll - * just mean that we sized the table a bit on the large side. - */ - elements_tab->hashtab = saophash_create(CurrentMemoryContext, nitems, - elements_tab); + nitems = ArrayGetNItems(ARR_NDIM(arr), ARR_DIMS(arr)); + get_typlenbyvalalign(ARR_ELEMTYPE(arr), &typlen, &typbyval, &typalign); - MemoryContextSwitchTo(oldcontext); + /* + * Cache the element type metadata so the linear-search path below + * need not re-probe the type cache on every call, matching what + * ExecEvalScalarArrayOp() does for the non-hashed step. + */ + elements_tab->typlen = typlen; + elements_tab->typbyval = typbyval; + elements_tab->typalign = typalign; - s = (char *) ARR_DATA_PTR(arr); - bitmap = ARR_NULLBITMAP(arr); - bitmask = 1; - for (int i = 0; i < nitems; i++) - { - /* Get array element, checking for NULL. */ - if (bitmap && (*bitmap & bitmask) == 0) - { - has_nulls = true; - } - else + if (nitems >= MIN_ARRAY_SIZE_FOR_HASHED_SAOP) { - Datum element; - - element = fetch_att(s, typbyval, typlen); - s = att_addlength_pointer(s, typlen, s); - s = (char *) att_nominal_alignby(s, typalignby); + ScalarArrayOpExpr *saop = op->d.hashedscalararrayop.saop; + uint8 typalignby = typalign_to_alignby(typalign); + bool has_nulls = false; + char *s; + uint8 *bitmap; + int bitmask; + + fmgr_info(saop->hashfuncid, &elements_tab->hash_finfo); + fmgr_info_set_expr((Node *) saop, &elements_tab->hash_finfo); + InitFunctionCallInfoData(elements_tab->hash_fcinfo_data, + &elements_tab->hash_finfo, 1, + saop->inputcollid, NULL, NULL); - saophash_insert(elements_tab->hashtab, element, &hashfound); - } + /* + * Create the hash table sizing it according to the number of + * elements in the array. This does assume that the array has + * no duplicates. If it does, we just sized a bit large. + */ + elements_tab->hashtab = saophash_create(CurrentMemoryContext, + nitems, elements_tab); - /* Advance bitmap pointer if any. */ - if (bitmap) - { - bitmask <<= 1; - if (bitmask == 0x100) + s = (char *) ARR_DATA_PTR(arr); + bitmap = ARR_NULLBITMAP(arr); + bitmask = 1; + for (int i = 0; i < nitems; i++) { - bitmap++; - bitmask = 1; + /* Get array element, checking for NULL. */ + if (bitmap && (*bitmap & bitmask) == 0) + { + has_nulls = true; + } + else + { + Datum element; + + element = fetch_att(s, typbyval, typlen); + s = att_addlength_pointer(s, typlen, s); + s = (char *) att_nominal_alignby(s, typalignby); + + saophash_insert(elements_tab->hashtab, element, &hashfound); + } + + /* Advance bitmap pointer if any. */ + if (bitmap) + { + bitmask <<= 1; + if (bitmask == 0x100) + { + bitmap++; + bitmask = 1; + } + } } - } - } - /* - * Remember if we had any nulls so that we know if we need to execute - * non-strict functions with a null lhs value if no match is found. - */ - op->d.hashedscalararrayop.has_nulls = has_nulls; + /* + * Remember if we had any nulls so that we know if we need to + * execute non-strict functions with a null lhs value if no + * match is found. + */ + op->d.hashedscalararrayop.has_nulls = has_nulls; - /* - * When we have a non-strict equality function, check and cache the - * result from looking up a NULL. Non-strict functions are free to - * treat a NULL as equal to any other value, e.g. a 0 or an empty - * string. Here we perform a linear search over the array and cache - * the outcome so that we can use that result any time we receive a - * NULL. - */ - if (!strictfunc) - { - bool null_lhs_result; + /* + * When we have a non-strict equality function, check and + * cache the result from looking up a NULL. Non-strict + * functions are free to treat a NULL as equal to any other + * value, e.g. a 0 or an empty string. Here we perform a + * linear search over the array and cache the outcome so that + * we can use that result any time we receive a NULL. + */ + if (!strictfunc) + { + bool null_lhs_result; - fcinfo->args[0].value = (Datum) 0; - fcinfo->args[0].isnull = true; + fcinfo->args[0].value = (Datum) 0; + fcinfo->args[0].isnull = true; - ExecEvalArrayCompareInternal(fcinfo, arr, typlen, typbyval, - typalign, true, &result, - &resultnull); + ExecEvalArrayCompareInternal(fcinfo, arr, typlen, typbyval, + typalign, true, &result, + &resultnull); - null_lhs_result = DatumGetBool(result); + null_lhs_result = DatumGetBool(result); - /* invert non-NULL results for NOT IN */ - if (!resultnull && !inclause) - null_lhs_result = !null_lhs_result; + /* invert non-NULL results for NOT IN */ + if (!resultnull && !inclause) + null_lhs_result = !null_lhs_result; - op->d.hashedscalararrayop.null_lhs_isnull = resultnull; - op->d.hashedscalararrayop.null_lhs_result = null_lhs_result; + op->d.hashedscalararrayop.null_lhs_isnull = resultnull; + op->d.hashedscalararrayop.null_lhs_result = null_lhs_result; + } + } + else + { + /* too short to be worth hashing: linear search each row */ + elements_tab->hashtab = NULL; + } } + + MemoryContextSwitchTo(oldcontext); + } + + /* A NULL array yields NULL. */ + if (elements_tab->cache_isnull) + { + *op->resnull = true; + return; + } + + arr = DatumGetArrayTypeP(elements_tab->cached_array); + + /* + * Linear-search mode: the array was too short to be worth a hash table. + * Compare against every element on each call, exactly as the non-hashed + * ExecEvalScalarArrayOp() would. + */ + if (elements_tab->hashtab == NULL) + { + ExecEvalArrayCompareInternal(fcinfo, arr, + elements_tab->typlen, + elements_tab->typbyval, + elements_tab->typalign, + true, &result, &resultnull); + + /* ExecEvalArrayCompareInternal computes ANY; invert for NOT IN */ + if (!inclause && !resultnull) + result = BoolGetDatum(!DatumGetBool(result)); + + *op->resvalue = result; + *op->resnull = resultnull; + return; } /* @@ -4461,7 +4561,7 @@ ExecEvalHashedScalarArrayOp(ExprState *state, ExprEvalStep *op, ExprContext *eco fcinfo->args[1].value = (Datum) 0; fcinfo->args[1].isnull = true; - result = op->d.hashedscalararrayop.finfo->fn_addr(fcinfo); + result = fcinfo->flinfo->fn_addr(fcinfo); resultnull = fcinfo->isnull; /* diff --git a/src/backend/optimizer/util/clauses.c b/src/backend/optimizer/util/clauses.c index 55cebe4a74b..0b67643fc28 100644 --- a/src/backend/optimizer/util/clauses.c +++ b/src/backend/optimizer/util/clauses.c @@ -132,6 +132,7 @@ static Relids find_nonnullable_rels_walker(Node *node, bool top_level); static List *find_nonnullable_vars_walker(Node *node, bool top_level); static bool is_strict_saop(ScalarArrayOpExpr *expr, bool falseOK); static bool convert_saop_to_hashed_saop_walker(Node *node, void *context); +static bool saop_array_arg_has_unstable_node_walker(Node *node, void *context); static bool grouping_conflict_walker(Node *node, grouping_walker_ctx *ctx); static bool grouping_check_operands(Oid opno, Oid inputcollid, List *args, grouping_walker_ctx *ctx); @@ -2629,7 +2630,6 @@ eval_const_expressions(PlannerInfo *root, Node *node) return eval_const_expressions_mutator(node, &context); } -#define MIN_ARRAY_SIZE_FOR_HASHED_SAOP 9 /*-------------------- * convert_saop_to_hashed_saop * @@ -2638,13 +2638,18 @@ eval_const_expressions(PlannerInfo *root, Node *node) * evaluate using a hash table rather than a linear search. * * We'll use a hash table if all of the following conditions are met: - * 1. The 2nd argument of the array contain only Consts. + * 1. The 2nd argument is a non-null Const array, or a non-Const expression + * whose value is fixed for the duration of one execution (no Vars, no + * volatile functions, no aggregate/grouping/window functions, no + * sub-selects). In the latter case the executor evaluates it once and + * builds the hash table from the run-time value. * 2. useOr is true or there is a valid negator operator for the * ScalarArrayOpExpr's opno. * 3. There's valid hash function for both left and righthand operands and * these hash functions are the same. - * 4. If the array contains enough elements for us to consider it to be - * worthwhile using a hash table rather than a linear search. + * 4. If the array is a Const, it contains enough elements to be worth hashing + * rather than doing a linear search. For a non-Const array the count is + * not known here, so the executor applies that cutoff at run time. */ void convert_saop_to_hashed_saop(Node *node) @@ -2665,9 +2670,41 @@ convert_saop_to_hashed_saop_walker(Node *node, void *context) Node *arrayarg = (Node *) lsecond(saop->args); Oid lefthashfunc; Oid righthashfunc; + bool try_hashing = false; - if (arrayarg && IsA(arrayarg, Const) && - !((Const *) arrayarg)->constisnull) + /* + * Hash the array when it is fixed for the whole execution and has at + * least MIN_ARRAY_SIZE_FOR_HASHED_SAOP elements: a non-null Const, or + * a non-Const expression with no volatile function and nothing + * rejected by saop_array_arg_has_unstable_node_walker(). The size + * cutoff is applied here when the count is known now (a Const, or a + * 1-D ArrayExpr); otherwise the executor applies it at run time. + */ + if (arrayarg && IsA(arrayarg, Const)) + { + Const *arrconst = (Const *) arrayarg; + + if (!arrconst->constisnull) + { + ArrayType *arr = (ArrayType *) DatumGetPointer(arrconst->constvalue); + + try_hashing = ArrayGetNItems(ARR_NDIM(arr), ARR_DIMS(arr)) >= + MIN_ARRAY_SIZE_FOR_HASHED_SAOP; + } + } + else if (arrayarg && + !contain_volatile_functions(arrayarg) && + !saop_array_arg_has_unstable_node_walker(arrayarg, NULL)) + { + if (IsA(arrayarg, ArrayExpr) && + !((ArrayExpr *) arrayarg)->multidims) + try_hashing = list_length(((ArrayExpr *) arrayarg)->elements) >= + MIN_ARRAY_SIZE_FOR_HASHED_SAOP; + else + try_hashing = true; + } + + if (try_hashing) { if (saop->useOr) { @@ -2675,23 +2712,8 @@ convert_saop_to_hashed_saop_walker(Node *node, void *context) &lefthashfunc, &righthashfunc) && lefthashfunc == righthashfunc) { - Datum arrdatum = ((Const *) arrayarg)->constvalue; - ArrayType *arr = (ArrayType *) DatumGetPointer(arrdatum); - int nitems; - - /* - * Only fill in the hash functions if the array looks - * large enough for it to be worth hashing instead of - * doing a linear search. - */ - nitems = ArrayGetNItems(ARR_NDIM(arr), ARR_DIMS(arr)); - - if (nitems >= MIN_ARRAY_SIZE_FOR_HASHED_SAOP) - { - /* Looks good. Fill in the hash functions */ - saop->hashfuncid = lefthashfunc; - } - return false; + /* Looks good. Fill in the hash functions */ + saop->hashfuncid = lefthashfunc; } } else /* !saop->useOr */ @@ -2708,29 +2730,14 @@ convert_saop_to_hashed_saop_walker(Node *node, void *context) &lefthashfunc, &righthashfunc) && lefthashfunc == righthashfunc) { - Datum arrdatum = ((Const *) arrayarg)->constvalue; - ArrayType *arr = (ArrayType *) DatumGetPointer(arrdatum); - int nitems; + /* Looks good. Fill in the hash functions */ + saop->hashfuncid = lefthashfunc; /* - * Only fill in the hash functions if the array looks - * large enough for it to be worth hashing instead of - * doing a linear search. + * Also set the negfuncid. The executor will need that to + * perform hashtable lookups. */ - nitems = ArrayGetNItems(ARR_NDIM(arr), ARR_DIMS(arr)); - - if (nitems >= MIN_ARRAY_SIZE_FOR_HASHED_SAOP) - { - /* Looks good. Fill in the hash functions */ - saop->hashfuncid = lefthashfunc; - - /* - * Also set the negfuncid. The executor will need - * that to perform hashtable lookups. - */ - saop->negfuncid = get_opcode(negator); - } - return false; + saop->negfuncid = get_opcode(negator); } } } @@ -2739,6 +2746,34 @@ convert_saop_to_hashed_saop_walker(Node *node, void *context) return expression_tree_walker(node, convert_saop_to_hashed_saop_walker, NULL); } +/* + * saop_array_arg_has_unstable_node_walker + * True if 'node' contains something that keeps a ScalarArrayOpExpr's + * array argument from being evaluated once and reused for the whole + * execution: a Var or PlaceHolderVar (any level -- this runs before + * SS_replace_correlation_vars, so an outer reference is still a Var, not + * a Param), an aggregate/grouping/window function, a sub-select, or a + * non-PARAM_EXTERN Param. Volatile functions are checked by the caller. + */ +static bool +saop_array_arg_has_unstable_node_walker(Node *node, void *context) +{ + if (node == NULL) + return false; + if (IsA(node, Var) || IsA(node, PlaceHolderVar)) + return true; + if (IsA(node, Param)) + return ((Param *) node)->paramkind != PARAM_EXTERN; + if (IsA(node, Aggref) || + IsA(node, GroupingFunc) || + IsA(node, WindowFunc) || + IsA(node, SubLink) || + IsA(node, SubPlan) || + IsA(node, AlternativeSubPlan)) + return true; + return expression_tree_walker(node, saop_array_arg_has_unstable_node_walker, + context); +} /*-------------------- * estimate_expression_value diff --git a/src/include/executor/execExpr.h b/src/include/executor/execExpr.h index c61b3d624d5..056a491b5e0 100644 --- a/src/include/executor/execExpr.h +++ b/src/include/executor/execExpr.h @@ -646,7 +646,12 @@ typedef struct ExprEvalStep * returns. */ bool null_lhs_isnull; struct ScalarArrayOpExprHashTable *elements_tab; - FmgrInfo *finfo; /* function's lookup data */ + + /* + * Compiled non-Const array argument, evaluated once at run time; + * NULL when the array is a Const filled in by a preceding step. + */ + struct ExprState *array_expr; FunctionCallInfo fcinfo_data; /* arguments etc */ ScalarArrayOpExpr *saop; } hashedscalararrayop; diff --git a/src/include/nodes/primnodes.h b/src/include/nodes/primnodes.h index 09b0c29408c..12de419fdcf 100644 --- a/src/include/nodes/primnodes.h +++ b/src/include/nodes/primnodes.h @@ -890,12 +890,13 @@ typedef OpExpr NullIfExpr; * flag to remember whether it's ANY or ALL, and we don't have to store * the result type (or the collation) because it must be boolean. * - * A ScalarArrayOpExpr with a valid hashfuncid is evaluated during execution - * by building a hash table containing the Const values from the RHS arg. - * This table is probed during expression evaluation. The planner will set - * hashfuncid to the hash function which must be used to build and probe the - * hash table. The executor determines if it should use hash-based checks or - * the more traditional means based on if the hashfuncid is set or not. + * A ScalarArrayOpExpr with a valid hashfuncid is evaluated by building a hash + * table from the array argument and probing it once per row; hashfuncid is the + * hash function used to build and probe it. The planner sets hashfuncid when + * the array is a Const, or a non-Const expression it proves stays fixed for the + * duration of one execution (see convert_saop_to_hashed_saop()) -- in that case + * the executor evaluates the array once and builds the table from the result. + * With no valid hashfuncid the executor falls back to a linear scan. * * When performing hashed NOT IN, the negfuncid will also be set to the * equality function which the hash table must use to build and probe the hash @@ -937,6 +938,13 @@ typedef struct ScalarArrayOpExpr ParseLoc location; } ScalarArrayOpExpr; +/* + * Minimum array length for which hashing a ScalarArrayOpExpr beats a linear + * search. Applied by the planner when the length is known then, otherwise by + * the executor once the run-time array is in hand. + */ +#define MIN_ARRAY_SIZE_FOR_HASHED_SAOP 9 + /* * BoolExpr - expression node for the basic Boolean operators AND, OR, NOT * diff --git a/src/test/regress/expected/expressions.out b/src/test/regress/expected/expressions.out index 730f7bc7eba..8d77badd152 100644 --- a/src/test/regress/expected/expressions.out +++ b/src/test/regress/expected/expressions.out @@ -327,6 +327,213 @@ select return_text_input('a') not in ('a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i f (1 row) +rollback; +-- +-- Hashed ScalarArrayOpExpr when the array argument is not a Const but is fixed +-- for the whole execution: external params, IN ($1,...,$N), stable functions. +-- Check the hashed path returns what the linear path does, and that the planner +-- does not hash an array that can vary per row or per group. +-- +begin; +create table saop_stab (i int, r int4range); +insert into saop_stab + select g, int4range(g, g + 1) from generate_series(1, 20) g; +-- a stable plpgsql function is never inlined, so the array stays non-Const +create function saop_intarr(int[]) returns int[] as + $$ begin return $1; end $$ language plpgsql stable; +-- just below / at / above the hashing threshold of 9 +select array_agg(i order by i) from saop_stab where i = any (saop_intarr('{1,2,3,4,5,6,7,8}')); + array_agg +------------------- + {1,2,3,4,5,6,7,8} +(1 row) + +select array_agg(i order by i) from saop_stab where i = any (saop_intarr('{1,2,3,4,5,6,7,8,9}')); + array_agg +--------------------- + {1,2,3,4,5,6,7,8,9} +(1 row) + +select array_agg(i order by i) from saop_stab where i = any (saop_intarr('{1,2,3,4,5,6,7,8,9,10,11,12}')); + array_agg +------------------------------ + {1,2,3,4,5,6,7,8,9,10,11,12} +(1 row) + +-- NULL array yields NULL; empty array and no-match array yield no rows +select count(*) from saop_stab where i = any (saop_intarr(null)); + count +------- + 0 +(1 row) + +select count(*) from saop_stab where i = any (saop_intarr('{}')); + count +------- + 0 +(1 row) + +select array_agg(i order by i) from saop_stab where i = any (saop_intarr('{5,5,5,5,5,5,5,5,5,5}')); + array_agg +----------- + {5} +(1 row) + +-- NOT IN / <> ALL, with and without a NULL element (three-valued logic) +select count(*) from saop_stab where i <> all (saop_intarr('{1,2,3,4,5,6,7,8,9,10}')); + count +------- + 10 +(1 row) + +select count(*) from saop_stab where i <> all (saop_intarr('{1,2,3,4,5,6,7,8,9,null}')); + count +------- + 0 +(1 row) + +-- bare external Param array, generic plan (stays a Param); re-EXECUTE with a +-- different array, then NULL and empty +set plan_cache_mode = force_generic_plan; +prepare saop_p(int[]) as + select array_agg(i order by i) from saop_stab where i = any ($1); +execute saop_p('{1,2,3,4,5,6,7,8,9,10}'); + array_agg +------------------------ + {1,2,3,4,5,6,7,8,9,10} +(1 row) + +execute saop_p('{11,12,13}'); + array_agg +------------ + {11,12,13} +(1 row) + +execute saop_p(null); + array_agg +----------- + +(1 row) + +execute saop_p('{}'); + array_agg +----------- + +(1 row) + +deallocate saop_p; +-- IN ($1, ..., $N) is an ArrayExpr of Params +prepare saop_in(int,int,int,int,int,int,int,int,int,int) as + select array_agg(i order by i) from saop_stab + where i in ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10); +execute saop_in(1,2,3,4,5,6,7,8,9,10); + array_agg +------------------------ + {1,2,3,4,5,6,7,8,9,10} +(1 row) + +deallocate saop_in; +-- $1::int[] cast, and string_to_array($1, ',') +prepare saop_cast(text) as + select array_agg(i order by i) from saop_stab where i = any ($1::int[]); +execute saop_cast('{2,4,6,8,10,12,14,16,18,20}'); + array_agg +----------------------------- + {2,4,6,8,10,12,14,16,18,20} +(1 row) + +deallocate saop_cast; +prepare saop_sta(text) as + select array_agg(i order by i) from saop_stab + where i::text = any (string_to_array($1, ',')); +execute saop_sta('1,2,3,4,5,6,7,8,9,10,11,12'); + array_agg +------------------------------ + {1,2,3,4,5,6,7,8,9,10,11,12} +(1 row) + +deallocate saop_sta; +-- same query under a custom plan: $1 folds to a Const and the pre-existing +-- Const path handles it -- must match the generic-plan result above +set plan_cache_mode = force_custom_plan; +prepare saop_c(int[]) as + select array_agg(i order by i) from saop_stab where i = any ($1); +execute saop_c('{1,2,3,4,5,6,7,8,9,10}'); + array_agg +------------------------ + {1,2,3,4,5,6,7,8,9,10} +(1 row) + +deallocate saop_c; +-- rescan: a stable Param array on the inner side of a nestloop +set plan_cache_mode = force_generic_plan; +prepare saop_rs(int[]) as + select d.x, count(*) from (values (1),(2),(3)) d(x) + join saop_stab on saop_stab.i = any ($1) + group by d.x order by d.x; +execute saop_rs('{1,2,3,4,5,6,7,8,9,10}'); + x | count +---+------- + 1 | 10 + 2 | 10 + 3 | 10 +(3 rows) + +deallocate saop_rs; +reset plan_cache_mode; +-- two hashable ScalarArrayOpExprs in one qual: both must be applied (cf. +-- b136db07c6) and both correct +prepare saop_two(int[], int[]) as + select array_agg(i order by i) from saop_stab where i = any ($1) or i = any ($2); +execute saop_two('{1,2,3,4,5,6,7,8,9,10}', '{15,16,17,18,19,20,1,2,3,4}'); + array_agg +------------------------------------------ + {1,2,3,4,5,6,7,8,9,10,15,16,17,18,19,20} +(1 row) + +deallocate saop_two; +-- the planner must NOT hash an array that varies per row: a Var in the array +-- keeps a plain (linear) ScalarArrayOpExpr +explain (costs off) +select i from saop_stab +where i = any (array[i,i+1,i+2,i+3,i+4,i+5,i+6,i+7,i+8]); + QUERY PLAN +-------------------------------------------------------------------------------------------------------- + Seq Scan on saop_stab + Filter: (i = ANY (ARRAY[i, (i + 1), (i + 2), (i + 3), (i + 4), (i + 5), (i + 6), (i + 7), (i + 8)])) +(2 rows) + +-- ... nor an array_agg() in a HAVING clause (a value per group, not per +-- execution): must not be hashed and must not error with "Aggref found in +-- non-Agg plan node" +select i % 3 as g, count(*) from saop_stab +group by i % 3 +having (i % 3) = any (array_agg(1)) +order by g; + g | count +---+------- + 1 | 7 +(1 row) + +-- ... nor an array built from an outer-query reference in a correlated +-- sub-select: it varies per rescan, so it must stay linear and give the same +-- answer as the below-threshold (never-hashed) form. convert_saop_to_hashed_saop +-- runs before uplevel Vars become Params, so the check must reject Vars of any +-- level. +select d.k, + (select count(*) from saop_stab + where i = any (array[d.k,d.k+1,d.k+2,d.k+3,d.k+4,d.k+5,d.k+6,d.k+7,d.k+8])) as ge9, + (select count(*) from saop_stab + where i = any (array[d.k,d.k+1,d.k+2,d.k+3,d.k+4,d.k+5,d.k+6,d.k+7])) as lt9 +from (values (1),(8),(15)) d(k) +order by d.k; + k | ge9 | lt9 +----+-----+----- + 1 | 9 | 8 + 8 | 9 | 8 + 15 | 6 | 6 +(3 rows) + rollback; -- Test with non-strict equality function. -- We need to create our own type for this. diff --git a/src/test/regress/sql/expressions.sql b/src/test/regress/sql/expressions.sql index 3b3048f9731..870f371eb5e 100644 --- a/src/test/regress/sql/expressions.sql +++ b/src/test/regress/sql/expressions.sql @@ -134,6 +134,119 @@ select return_text_input('a') not in ('a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i rollback; +-- +-- Hashed ScalarArrayOpExpr when the array argument is not a Const but is fixed +-- for the whole execution: external params, IN ($1,...,$N), stable functions. +-- Check the hashed path returns what the linear path does, and that the planner +-- does not hash an array that can vary per row or per group. +-- +begin; + +create table saop_stab (i int, r int4range); +insert into saop_stab + select g, int4range(g, g + 1) from generate_series(1, 20) g; + +-- a stable plpgsql function is never inlined, so the array stays non-Const +create function saop_intarr(int[]) returns int[] as + $$ begin return $1; end $$ language plpgsql stable; + +-- just below / at / above the hashing threshold of 9 +select array_agg(i order by i) from saop_stab where i = any (saop_intarr('{1,2,3,4,5,6,7,8}')); +select array_agg(i order by i) from saop_stab where i = any (saop_intarr('{1,2,3,4,5,6,7,8,9}')); +select array_agg(i order by i) from saop_stab where i = any (saop_intarr('{1,2,3,4,5,6,7,8,9,10,11,12}')); + +-- NULL array yields NULL; empty array and no-match array yield no rows +select count(*) from saop_stab where i = any (saop_intarr(null)); +select count(*) from saop_stab where i = any (saop_intarr('{}')); +select array_agg(i order by i) from saop_stab where i = any (saop_intarr('{5,5,5,5,5,5,5,5,5,5}')); + +-- NOT IN / <> ALL, with and without a NULL element (three-valued logic) +select count(*) from saop_stab where i <> all (saop_intarr('{1,2,3,4,5,6,7,8,9,10}')); +select count(*) from saop_stab where i <> all (saop_intarr('{1,2,3,4,5,6,7,8,9,null}')); + +-- bare external Param array, generic plan (stays a Param); re-EXECUTE with a +-- different array, then NULL and empty +set plan_cache_mode = force_generic_plan; +prepare saop_p(int[]) as + select array_agg(i order by i) from saop_stab where i = any ($1); +execute saop_p('{1,2,3,4,5,6,7,8,9,10}'); +execute saop_p('{11,12,13}'); +execute saop_p(null); +execute saop_p('{}'); +deallocate saop_p; + +-- IN ($1, ..., $N) is an ArrayExpr of Params +prepare saop_in(int,int,int,int,int,int,int,int,int,int) as + select array_agg(i order by i) from saop_stab + where i in ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10); +execute saop_in(1,2,3,4,5,6,7,8,9,10); +deallocate saop_in; + +-- $1::int[] cast, and string_to_array($1, ',') +prepare saop_cast(text) as + select array_agg(i order by i) from saop_stab where i = any ($1::int[]); +execute saop_cast('{2,4,6,8,10,12,14,16,18,20}'); +deallocate saop_cast; +prepare saop_sta(text) as + select array_agg(i order by i) from saop_stab + where i::text = any (string_to_array($1, ',')); +execute saop_sta('1,2,3,4,5,6,7,8,9,10,11,12'); +deallocate saop_sta; + +-- same query under a custom plan: $1 folds to a Const and the pre-existing +-- Const path handles it -- must match the generic-plan result above +set plan_cache_mode = force_custom_plan; +prepare saop_c(int[]) as + select array_agg(i order by i) from saop_stab where i = any ($1); +execute saop_c('{1,2,3,4,5,6,7,8,9,10}'); +deallocate saop_c; + +-- rescan: a stable Param array on the inner side of a nestloop +set plan_cache_mode = force_generic_plan; +prepare saop_rs(int[]) as + select d.x, count(*) from (values (1),(2),(3)) d(x) + join saop_stab on saop_stab.i = any ($1) + group by d.x order by d.x; +execute saop_rs('{1,2,3,4,5,6,7,8,9,10}'); +deallocate saop_rs; +reset plan_cache_mode; + +-- two hashable ScalarArrayOpExprs in one qual: both must be applied (cf. +-- b136db07c6) and both correct +prepare saop_two(int[], int[]) as + select array_agg(i order by i) from saop_stab where i = any ($1) or i = any ($2); +execute saop_two('{1,2,3,4,5,6,7,8,9,10}', '{15,16,17,18,19,20,1,2,3,4}'); +deallocate saop_two; + +-- the planner must NOT hash an array that varies per row: a Var in the array +-- keeps a plain (linear) ScalarArrayOpExpr +explain (costs off) +select i from saop_stab +where i = any (array[i,i+1,i+2,i+3,i+4,i+5,i+6,i+7,i+8]); + +-- ... nor an array_agg() in a HAVING clause (a value per group, not per +-- execution): must not be hashed and must not error with "Aggref found in +-- non-Agg plan node" +select i % 3 as g, count(*) from saop_stab +group by i % 3 +having (i % 3) = any (array_agg(1)) +order by g; + +-- ... nor an array built from an outer-query reference in a correlated +-- sub-select: it varies per rescan, so it must stay linear and give the same +-- answer as the below-threshold (never-hashed) form. convert_saop_to_hashed_saop +-- runs before uplevel Vars become Params, so the check must reject Vars of any +-- level. +select d.k, + (select count(*) from saop_stab + where i = any (array[d.k,d.k+1,d.k+2,d.k+3,d.k+4,d.k+5,d.k+6,d.k+7,d.k+8])) as ge9, + (select count(*) from saop_stab + where i = any (array[d.k,d.k+1,d.k+2,d.k+3,d.k+4,d.k+5,d.k+6,d.k+7])) as lt9 +from (values (1),(8),(15)) d(k) +order by d.k; + +rollback; + -- Test with non-strict equality function. -- We need to create our own type for this. base-commit: a12600b762c36d91450ce085fa25ef75250bc1c2 -- 2.53.0