Hash a ScalarArrayOpExpr whose array is fixed for one execution

From: best xmg <bestxmg(at)gmail(dot)com>
To: "pgsql-hackers(at)lists(dot)postgresql(dot)org" <pgsql-hackers(at)lists(dot)postgresql(dot)org>
Subject: Hash a ScalarArrayOpExpr whose array is fixed for one execution
Date: 2026-09-09 17:18:58
Message-ID: TY6PR01MB17327691C30B403369B16B02AF4B02@TY6PR01MB17327.jpnprd01.prod.outlook.com
Views: Whole Thread | Raw Message | Download mbox | Resend email
Thread:
Lists: pgsql-hackers

Hi hackers,

Since PG 14 (50e17ad281) the executor can evaluate "col = ANY (array)"
with a hash table instead of a linear scan, but only when the array is a
Const. Every parameterised form stays on the O(rows * N) path:

col = ANY ($1) -- Param, generic/cached plan
col IN ($1, ..., $N) -- ArrayExpr of Params
col = ANY ($1::int[]) -- ArrayCoerceExpr
col = ANY (string_to_array($1, ',')) -- FuncExpr

These are what drivers emit (JDBC setArray, psycopg "= ANY(%s)", asyncpg)
and what anything that expands an IN list into bind placeholders produces.
With a cached generic plan the list arrives as a Param and is never
hashed; a one-off custom plan folds it to a Const and is fine -- which is
why the slowdown tends to appear only after a statement has run a few
times. The PG 14 thread named the non-Const case as future work,
deferred "from fear that we may slow down cases where the expression is
evaluated only once".

The attached patch does that. It hashes any array argument the planner
can prove is fixed for the whole execution: no Vars, no volatile
functions, no aggregate/grouping/window functions, no sub-selects, and no
Params other than PARAM_EXTERN. For such an array the executor compiles
it as an independent sub-expression, evaluates it once on the first row,
builds the hash table, and reuses it. MIN_ARRAY_SIZE_FOR_HASHED_SAOP is
still enforced, now against the run-time element count; a shorter array
falls back to the existing linear ExecEvalArrayCompareInternal(). A
standalone ExprState (a PL/pgSQL "simple expression", reused across calls
with different parameters) keeps the linear path -- the "one execution"
proof needs an execution boundary it does not have.

Not covered, because the array is not fixed for the execution: a Var (new
array per row), a volatile function (per-row by definition), an
aggregate/grouping/window value (per group/frame/row, and only evaluable
inside its owning node), a sub-select, or a PARAM_EXEC (correlated /
nestloop-inner). These stay linear.

Diffstat: 5 backend files, ~360 lines, plus regression tests; no new GUC,
syntax, or catalog change (as 50e17ad281).

Performance
-----------
Release build, no LLVM, generic plan forced, warm cache, one Ryzen 7
4800H laptop. Seq scan, "SELECT count(*) FROM t WHERE v = ANY($1)",
1,000,000 rows, median of 9:

N master patched
6 59 ms 57 ms (below threshold: linear both)
9 65 ms 50 ms
40 150 ms 52 ms
100 297 ms 52 ms
1000 2494 ms 51 ms

Patched is flat in N once hashed. "IN ($1,...,$N)": 3.6x at N=9, 12x at
N=60. No change on paths that stay linear (N < 9, literal IN, pgbench).

Single evaluation -- the PG 14 concern. Plan built and run once, fresh
connection:

rows N master patched
1 40 0.025 ms 0.028 ms
1 200 0.023 ms 0.035 ms (+12 us, worst seen)
1000 100 0.316 ms 0.085 ms

Break-even is around 10 rows; worst case is +12 us to build a 200-entry
hash for a one-row scan.

Open question
------------
cost_qual_eval charges the hashed cost for an opaque stable array using
estimate_array_length()'s default guess, which slightly over-estimates
startup for an array that turns out short at run time. I have left that
for a follow-up; happy to fold in a fix if reviewers prefer.

Possible follow-ups (kept out of scope so the bind-parameter win does not
depend on new machinery): an uncorrelated ARRAY(SELECT ...) becomes an
InitPlan whose PARAM_EXEC never changes -- execution-stable, and only the
planner needs to recognise it; and a PARAM_EXEC array on a nestloop inner
side is fixed per rescan, so it could be hashed once per rescan given a
cost gate and rescan-aware invalidation of the cached table.

The patch is v1, based on master (base-commit line included).

[1] https://www.postgresql.org/message-id/flat/CAAaqYe8x62%2B%3Dwn0zvNKCj55tPpg-JBHzhZFFc6ANovdqFw7-dA%40mail.gmail.com

Regards,
Linden Lance

Attachment Content-Type Size
v1-0001-Hash-a-ScalarArrayOpExpr-whose-array-is-fixed-for.patch application/octet-stream 41.7 KB

Browse pgsql-hackers by date

  From Date Subject
Next Message Noah Misch 2026-09-09 17:21:32 Re: pg_get_*_ddl() needs a redesign
Previous Message Greg Burd 2026-09-09 17:14:54 Re: Support for 8-byte TOAST values, round two