Improve hash aggregate spilling by writing only the needed columns

From: Mario Karuza <mkaruza(dot)pg(at)icloud(dot)com>
To: pgsql-hackers <pgsql-hackers(at)lists(dot)postgresql(dot)org>
Subject: Improve hash aggregate spilling by writing only the needed columns
Date: 2026-09-16 10:10:42
Message-ID: 7247cfafe659f49340ebd5d9e31a5ced45381879.camel@icloud.com
Views: Whole Thread | Raw Message | Download mbox | Resend email
Thread:
Lists: pgsql-hackers

Hi hackers,

While exploring TPC-H and its spilling behaviour, I noticed that
spilling can be quite inefficient for queries where only a small subset
of the input columns is actually used by the aggregate.

In particular, when the input is a seq scan, it can pass the complete
table tuple to the aggregate even when we need only a few columns. When
we spill, the tuple is written using the full input tuple layout, with
the unused columns set to NULL. This means that for wide tables we can
end up writing and reading much more data than the aggregate actually
needs.

Reducing what a hash aggregate spills was discussed before, both as a
planner-side and an executor-side change [1][2]. The result is the
current behaviour where unneeded columns are set to NULL when a tuple
is spilled.

The attached patch changes the spill format locally in the aggregate
node, so that only the columns needed by the aggregate are written to
the spill file.

A tuple descriptor for that layout and a map from spill columns back to
input columns are built once in ExecInitAgg. The write path gathers the
needed values from the input slot into the spill slot, and the read path
scatters them back into the original input tuple layout.

A tuple that is read back from a spill file and has to be spilled again
is now written out as it was read, instead of being deformed and formed
again.

hashagg_spill_tuple() is marked now as pg_noinline. Patch left the
function with a single caller, so the compiler started inlining it into
lookup_hash_entries(), which runs for every input tuple whether the
aggregate spills or not

When all columns are needed there is no behavoiur change.

TPC-H benchmark
---

TPC-H was constructed with scale factor 1, 5 and 10.

The configuration was:
- work_mem = 4MB
- max_parallel_workers_per_gather = 0.

Nothing else was changed, so the plans are the ones the planner picks by
default.

With that setting only Q18 shows significant spilling. The whole query
was run unmodified.

All benchmarks were run locally on a laptop so benchmark may contain
noise.

Measured with EXPLAIN (ANALYZE, TIMING OFF), 3 runs, median:

* execution time (ms) * | * spill disk size *
HEAD patched change | HEAD patched change
SF1 1113 1075 -3.4% | - - -
SF5 16944 12751 -24.7% | 1.06 GiB 857.4 MiB -21.0%
SF10 34231 26079 -23.8% | 2.19 GiB 1.68 GiB -23.2%

* At SF1 the query does not spill, so this shows that in-memory path is
not affected. Minimal diff -3.4% is could be explained by benchmark
noise.

Edge cases
---

The patch was tested with two edge cases to verify that there is no
regression.

Case 1 -- a two column table, one column needed:

CREATE TABLE t AS
SELECT (random() * 5e6)::int AS a,
(random() * 100)::int AS b
FROM generate_series(1, 20000000);
VACUUM ANALYZE t;

EXPLAIN (VERBOSE, COSTS OFF) SELECT a FROM t GROUP BY a;

HashAggregate
Output: a
Group Key: t.a
-> Seq Scan on public.t
Output: a, b

Case 2 -- a ten column table, nine columns needed:

CREATE TABLE m AS
SELECT (random() * 5e6)::int AS a,
i % 1000 AS c1, i % 1000 AS c2, 
i % 1000 AS c3, i % 1000 AS c4,
i % 1000 AS c5, i % 1000 AS c6, 
i % 1000 AS c7, i % 1000 AS c8,
0::int AS unused
FROM generate_series(1, 10000000) i;
VACUUM ANALYZE m;

EXPLAIN (VERBOSE, COSTS OFF)
SELECT a, sum(c1+c2+c3+c4+c5+c6+c7+c8) FROM m GROUP BY a;

HashAggregate
Output: a, sum((((((((c1 + c2) + c3) + c4) + c5) + c6) + c7) + c8))
Group Key: m.a
-> Seq Scan on public.m
Output: a, c1, c2, c3, c4, c5, c6, c7, c8, unused

Measured with EXPLAIN (ANALYZE, TIMING OFF), work_mem = 4MB,
max_parallel_workers_per_gather = 0, 3 runs, median:

HEAD (ms) PATCH (ms) CHANGE SPILL DISK SIZE
case 1 4059 3805 -6.3% 445.8 -> 445.8 MiB (0.0%)
case 2 3521 3244 -7.9% 635.3 -> 571.5 MiB (-10.1%)

Regards,
Mario

[1]
https://www.postgresql.org/message-id/flat/20200519151202.u2p2gpiawoaznsv2%40development

[2]https://www.postgresql.org/message-id/flat/20200606041146.slqfg7cuptx27tuy%40alap3.anarazel.de

Attachment Content-Type Size
v1-0001-Write-only-the-needed-columns-to-hash-aggregate-s.patch text/x-patch 8.8 KB

Browse pgsql-hackers by date

  From Date Subject
Next Message shveta malik 2026-09-16 10:13:46 Re: Distinguish publication exclusions in object addresses
Previous Message Zhijie Hou (Fujitsu) 2026-09-16 09:54:47 RE: Distinguish publication exclusions in object addresses