Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,16 @@ true until the next version shipped.
This is the third arm in this file to be repaired for counting a string across a
whole file. The `deltuples` comment 15 lines above records the first, fixed by
scoping; these two were left as whole-file counts and did the same thing again.
- A parallel custom scan divided its whole run cost by the worker count.

Core seqscan divides CPU across workers and leaves disk I/O whole. The
partial columnar path divided `(total - startup)` by `workers`, so an
I/O-dominated scan was quoted at half its serial cost with two workers.
Measured: serial run 10825, parallel Custom Scan 5412.5 (ratio 2.000).
Leaving I/O undivided, the same fixture is 10112.5 (ratio 1.070).

`get_parallel_divisor` is static in core; the leader-participation heuristic
is reproduced so CPU uses the same divisor a parallel seqscan does.

- `compare_to_bash.py`'s corpus arm called a WRAPPED name fabricated. A name too long
for one line is written as adjacent literals, and Python joins them at parse time,
Expand Down Expand Up @@ -2302,6 +2312,7 @@ true until the next version shipped.

### Fixed


- The standing parity arm graded a hand-written list, and nothing enforced it
(#432, #1046).

Expand Down
65 changes: 61 additions & 4 deletions src/columnar_customscan.c
Original file line number Diff line number Diff line change
Expand Up @@ -2517,6 +2517,47 @@ pgcolumnar_refined_scan_cost(RelOptInfo *rel, Oid relid, Path *seqpath,
*out_total = startup + run * survival;
}

/*
* pgcolumnar_parallel_divisor
* Core's get_parallel_divisor (costsize.c) is static, so the
* leader-participation heuristic is reproduced here. CPU is divided
* the same way a parallel seqscan is; I/O is not.
*/
static double
pgcolumnar_parallel_divisor(Path *path)
{
double parallel_divisor = path->parallel_workers;

if (parallel_leader_participation)
{
double leader_contribution;

leader_contribution = 1.0 - (0.3 * path->parallel_workers);
if (leader_contribution > 0)
parallel_divisor += leader_contribution;
}

return parallel_divisor;
}

/*
* pgcolumnar_scan_io_run_cost
* The page-read portion of a columnar scan, after projected-width
* scaling (#171) and zone-map survival (#434). This is the term core
* leaves undivided on a parallel seqscan.
*/
static Cost
pgcolumnar_scan_io_run_cost(RelOptInfo *rel, Oid relid)
{
double survival = pgcolumnar_zonemap_survival(rel, relid);
double widthFrac = pgcolumnar_projected_width_fraction(rel, relid);
Cost pageCost = seq_page_cost * (double) rel->pages;

if (widthFrac < 1.0)
pageCost *= widthFrac;
return pageCost * survival;
}

/*
* PgColumnarSetRelPathlist
* set_rel_pathlist_hook: for a columnar base relation, replace the
Expand Down Expand Up @@ -2845,7 +2886,7 @@ PgColumnarSetRelPathlist(PlannerInfo *root, RelOptInfo *rel, Index rti,
* Add a parallel-aware partial path (gap 23) so the planner can put a Gather
* over a parallel columnar scan. Workers each claim distinct stripes from a
* shared counter set up by the DSM callbacks. The cost model mirrors a
* parallel seqscan: the per-tuple work is divided among the workers.
* parallel seqscan: CPU is divided among the workers, disk I/O is not.
*
* Costed from the serial columnar path rather than from the seqscan, and no
* longer conditional on a seqscan surviving (#362). add_path frees the
Expand Down Expand Up @@ -2950,7 +2991,10 @@ PgColumnarSetRelPathlist(PlannerInfo *root, RelOptInfo *rel, Index rti,
if (workers > 0)
{
CustomPath *ppath = makeNode(CustomPath);
double divisor = (double) workers;
double divisor;
Cost serialRun;
Cost ioRun;
Cost cpuRun;

ppath->path.pathtype = T_CustomScan;
ppath->path.parent = rel;
Expand All @@ -2959,10 +3003,23 @@ PgColumnarSetRelPathlist(PlannerInfo *root, RelOptInfo *rel, Index rti,
ppath->path.parallel_aware = true;
ppath->path.parallel_safe = true;
ppath->path.parallel_workers = workers;
ppath->path.rows = rel->rows / divisor;
/*
* Core seqscan divides CPU by get_parallel_divisor and leaves disk
* I/O whole (costsize.c: the disk run cost cannot be amortized).
* This path used to divide the entire (total - startup) by the
* worker count, so an I/O-dominated scan was quoted at 1/N of its
* serial cost.
*/
divisor = pgcolumnar_parallel_divisor(&ppath->path);
serialRun = serialTotalCost - serialStartupCost;
ioRun = pgcolumnar_scan_io_run_cost(rel, rte->relid);
if (ioRun > serialRun)
ioRun = serialRun;
cpuRun = serialRun - ioRun;
ppath->path.rows = clamp_row_est(rel->rows / divisor);
ppath->path.startup_cost = serialStartupCost;
ppath->path.total_cost = serialStartupCost +
(serialTotalCost - serialStartupCost) / divisor;
ioRun + cpuRun / divisor;
ppath->path.pathkeys = NIL;
ppath->flags = 0;
ppath->custom_paths = NIL;
Expand Down
8 changes: 8 additions & 0 deletions test/check_ledger.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -1228,3 +1228,11 @@ native_join_vector_agg native_join_vector_agg unique join fold answer equals GUC
native_join_vector_agg native_join_vector_agg unique join fold answer equals heap 15;16;17;18;19 never -
native_join_vector_agg native_join_vector_agg unique join uses core Agg when GUC off 15;16;17;18;19 never -
native_join_vector_agg native_join_vector_agg unique join uses vectorized agg when GUC on 15;16;17;18;19 2026-09-12 drop JOINREL fold
parallel_scan_cost parallel_scan_cost an I/O-dominated parallel scan is not priced at serial/workers 15;16;17;18 never -
parallel_scan_cost parallel_scan_cost premise: both scans have a positive run cost 15;16;17;18 never -
parallel_scan_cost parallel_scan_cost premise: the parallel plan has Gather 15;16;17;18 never -
parallel_scan_cost parallel_scan_cost premise: the parallel plan is a columnar scan 15;16;17;18 never -
parallel_scan_cost parallel_scan_cost premise: the parallel plan uses two workers 15;16;17;18 never -
parallel_scan_cost parallel_scan_cost premise: the serial plan has no Gather 15;16;17;18 never -
parallel_scan_cost parallel_scan_cost premise: the serial plan is a columnar scan 15;16;17;18 never -
parallel_scan_cost parallel_scan_cost premise: the table holds every inserted row 15;16;17;18 never -
2 changes: 1 addition & 1 deletion test/check_ledger_budget.txt
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,4 @@ suites_not_covered 249
# that is not this one. Neither survives. Re-derived by COUNTING on the merged tree,
# which is the only resolution this number has:
# awk -F'\t' '$5=="never"' test/check_ledger.tsv | wc -l
checks_never_observed_red 1222
checks_never_observed_red 1230
103 changes: 103 additions & 0 deletions test/parallel_scan_cost.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
#
# pgColumnar: a parallel custom scan must not divide I/O by the worker count.
#
# The partial path prices itself as serial_startup + (serial_run / workers).
# Core seqscan divides CPU only and leaves disk I/O whole (costsize.c: "the disk
# run cost cannot be amortized at all"). Dividing the whole run quotes an
# I/O-dominated scan at 1/N of its serial cost, so Gather wins against honestly
# costed alternatives.
#
# This suite pins the PLANNER number, not a runtime. Costs move with the
# constants; the property is that an I/O-only run is not halved when two
# workers are granted. Independent of test/pytest/test_parallel_scan_cost.py:
# same public seam (EXPLAIN of a columnar scan), own fixture, own observations.
#
# Usage: test/parallel_scan_cost.sh [PG_CONFIG]
# Written fresh for pgColumnar.

set -uo pipefail
. "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
pgc_setup "${1:-/usr/local/pg17/bin/pg_config}"

N=40000
psql_run "CREATE TABLE psc (id int, k int, payload text) USING pgcolumnar;"
psql_run "INSERT INTO psc SELECT g, g%17, md5(g::text) FROM generate_series(1,$N) g;"
psql_run "ALTER TABLE psc SET (parallel_workers = 2);"
psql_run "ANALYZE psc;"

# Session GUCs that isolate the I/O term: seq_page_cost is raised so the
# serial run is mostly pages. Dividing the whole run by 2 then halves I/O;
# leaving I/O whole leaves the run almost unchanged. CPU terms stay at their
# defaults so the parallel path is still a little cheaper than serial and
# Gather still wins -- zeroing CPU made the two paths equal and the planner
# declined Gather, which hid the number this suite exists to read.
# parallel_leader_participation is off so the divisor is the worker count.
setg() { q "ALTER DATABASE $PGC_DB SET $1 = $2;" >/dev/null; }
setg seq_page_cost 100
setg parallel_setup_cost 0
setg parallel_tuple_cost 0
setg parallel_leader_participation off
setg min_parallel_table_scan_size 0
setg jit off

explain_scan() {
# $1 = max_parallel_workers_per_gather
env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
-d "$PGC_DB" -Atq \
-c "SET max_parallel_workers_per_gather = $1;" \
-c "EXPLAIN (COSTS ON) SELECT id, k, payload FROM psc;"
}

serial_plan="$(explain_scan 0)"
par_plan="$(explain_scan 2)"

scan_cost_pair() {
# startup and total of the first Custom Scan (PgColumnarScan) line.
echo "$1" | grep -F "Custom Scan (PgColumnarScan)" | head -1 \
| grep -oE "cost=[0-9.]+\\.\\.[0-9.]+" | head -1 \
| sed -E "s/cost=([0-9.]+)\\.\\.([0-9.]+)/\\1 \\2/"
}

s_pair="$(scan_cost_pair "$serial_plan")"
p_pair="$(scan_cost_pair "$par_plan")"
s_start="${s_pair%% *}"
s_total="${s_pair##* }"
p_start="${p_pair%% *}"
p_total="${p_pair##* }"

s_run="$(awk -v t="$s_total" -v s="$s_start" "BEGIN{ print t-s }")"
p_run="$(awk -v t="$p_total" -v s="$p_start" "BEGIN{ print t-s }")"
ratio="$(awk -v s="$s_run" -v p="$p_run" "BEGIN{ if (p<=0) print 0; else printf \"%.3f\", s/p }")"

echo "-- serial Custom Scan cost=$s_start..$s_total run=$s_run"
echo "-- parallel Custom Scan cost=$p_start..$p_total run=$p_run ratio=$ratio"

check "premise: the table holds every inserted row" \
"$(q "SELECT count(*) FROM psc")" "$N"

check "premise: the serial plan is a columnar scan" \
"$(echo "$serial_plan" | grep -c "Custom Scan (PgColumnarScan)")" "1"

check "premise: the serial plan has no Gather" \
"$(echo "$serial_plan" | grep -c "Gather")" "0"

check "premise: the parallel plan has Gather" \
"$(echo "$par_plan" | grep -c "Gather")" "1"

check "premise: the parallel plan uses two workers" \
"$(echo "$par_plan" | grep -oE "Workers Planned: [0-9]+" | head -1 | grep -oE "[0-9]+")" "2"

check "premise: the parallel plan is a columnar scan" \
"$(echo "$par_plan" | grep -c "Custom Scan (PgColumnarScan)")" "1"

check "premise: both scans have a positive run cost" \
"$(awk -v s="$s_run" -v p="$p_run" "BEGIN{ print (s>0 && p>0) ? \"yes\" : \"no\" }")" "yes"

# On the unfixed path the ratio is 2.000 (whole run / 2 workers). Core leaves
# I/O whole, so with CPU terms zeroed the ratio stays near 1. 1.35 is
# unreachable by dividing the whole run, and reachable only if I/O remains.
check "an I/O-dominated parallel scan is not priced at serial/workers" \
"$(awk -v r="$ratio" "BEGIN{ print (r < 1.35) ? \"io-kept\" : \"halved\" }")" "io-kept"

pgc_summary
25 changes: 25 additions & 0 deletions test/pytest/TESTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ behaviour, the source of that number is named.
- [41. test_projections.py: a second copy of some columns, kept honest](#41-test_projectionspy-a-second-copy-of-some-columns-kept-honest)
- [42. test_compression_reaches_the_cascade.py: the codec setting decides encodings too](#42-test_compression_reaches_the_cascadepy-the-codec-setting-decides-encodings-too)
- [43. test_pgxn_metadata.py: the published distribution metadata, which nothing read](#43-test_pgxn_metadatapy-the-published-distribution-metadata-which-nothing-read)
- [44. test_parallel_scan_cost.py: a parallel custom scan must not divide I/O](#44-test_parallel_scan_costpy-a-parallel-custom-scan-must-not-divide-io)

## 1. How to read a test in here

Expand Down Expand Up @@ -4338,3 +4339,27 @@ Removal proof, run on both harnesses: restore `META.json` as it shipped and the
substantive arms redden on each side while every premise stays green. The premises
hold because the file still parses and still names *a* script -- it names the wrong
one, which is exactly the distinction the arms draw.

## 44. test_parallel_scan_cost.py: a parallel custom scan must not divide I/O

The port of `test/parallel_scan_cost.sh`. The partial path priced itself as
`serial_startup + (serial_run / workers)`. Core seqscan divides CPU only and
leaves disk I/O whole. Dividing the whole run quotes an I/O-dominated scan at
half its serial cost with two workers, so Gather beat honestly costed
alternatives.

Public seam: `EXPLAIN` of a columnar scan. This file raises `seq_page_cost` so
I/O dominates the serial run; the shell twin does the same with a different
page-cost and its own table. CPU terms stay at their defaults so the parallel
path is still a little cheaper than serial and Gather still appears -- the
number this suite exists to read. Assertion names match the shell suite.

### Every arm

| test | what it holds |
| --- | --- |
| `test_parallel_scan_cost` | the serial plan is a columnar scan with no Gather; the parallel plan is a columnar scan under Gather with two workers; both have a positive run cost; an I/O-dominated parallel scan is not priced at serial/workers |

The load-bearing assertion classifies the ratio `serial_run / parallel_run` as
`io-kept` (below 1.35) rather than `halved` (2.000 on the unfixed path). It is
unreachable by dividing the whole run, and reachable only if I/O remains.
3 changes: 2 additions & 1 deletion test/pytest/expected_tests.txt
Original file line number Diff line number Diff line change
Expand Up @@ -237,4 +237,5 @@ guard_tests 346
# `guard_tests` was re-derived in the same run and did NOT move -- 342 -- which is
# the expected answer for a file that needs a cluster, and checking it was the point
# rather than assuming it.
cluster_tests 410
# cluster_tests re-derived by collection on the rebased tree, never by adding a delta measured on another tree.
cluster_tests 411
3 changes: 2 additions & 1 deletion test/pytest/test_compare_to_bash.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@
# The shape is `SHELL_REFERENCES`' in `test_harness_deps.py`, asserted in both
# directions for the same reason: a one-way list rots into a permanent exemption.
COMPLETE = ["differential", "hilbert_cluster", "hilbert_locality",
"native_ownership", "native_projection", "projection_privilege",
"native_ownership", "native_projection", "parallel_scan_cost",
"projection_privilege",
"projections",
"sorted_pathkeys", "stats_privilege", "zonemap_boundaries"]

Expand Down
Loading
Loading