diff --git a/docs/limitations.md b/docs/limitations.md index da216cb..55d87b1 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -346,11 +346,20 @@ returning no rows. ## Parallel scans A columnar scan can run in parallel, and the planner builds a parallel path for -it. On a wide projection it chooses that path, because the decode cost is priced -by column width and a parallel plan divides it across workers. The wide query -described below was measured on one machine. The parallel plan runs in about -300 ms. The serial plan runs in about 600 ms. Two builds of the same table gave -306 and 604, then 300 and 610. +it. On a wide projection it chooses that path when +`max_parallel_workers_per_gather` is 4. The decode cost is priced by column +width, and a parallel plan divides it across workers. At the default of 2 the +planner still chooses the serial plan for the wide query below. + +The wide query was measured on one machine, on a build without assertions. The +parallel plan runs in about 275 ms and the serial plan in about 639 ms. Both are +the fastest of 7 interleaved readings at 4 workers. Two earlier readings of the +same query gave 306 and 604, then 300 and 610. + +The fastest reading is quoted rather than the middle one. This machine is shared, +so scheduling noise only ever adds time, and the fastest reading is the one least +polluted by it. The middle reading moved 31% between runs where the fastest moved +2.5%. On a narrow projection the planner still prefers the serial plan where the parallel one is faster. @@ -361,6 +370,15 @@ parallel plan ran 1.8 to 2.5 times faster on the median. In every shape the slowest parallel run was still faster than the fastest serial run, by 1.45 times at the narrowest margin. +Those readings were taken on a build with assertions enabled. The narrow query +below was re-measured on a build without them. Taking the fastest reading of each +arm at 4 workers, it ran 2.5 to 2.7 times faster. On the middle reading it ran +2.4 to 2.5 times faster. The slowest parallel run beat the fastest serial run by 1.60 +times. Both arm orders were run and both agree. + +Which column the narrow query filters on decides all of this. The section below +on the predicate column gives the measurements and says why. + "Narrow" here means few columns, not few rows. A scan that reads three columns of fourteen decodes little. The serial plan is therefore cheap, and a fixed per row charge above it then decides the comparison. @@ -421,28 +439,60 @@ That gives 383 MB for `c753` against 823 MB for `h753`. Three of the `int4` columns hold values in a small range, and they compress well. Change them and the columnar size changes, and so does every number below. -The narrow query is `SELECT sel, a, b FROM c753 WHERE sel <= 1000000`, which -returns 1,000,000 rows. The wide query is +The narrow query is `SELECT sel, a, b FROM c753 WHERE a <= -1073741824`, which +returns 1,000,158 rows. The wide query is `SELECT * FROM c753 WHERE sel <= 2000000`, which returns 2,000,000 rows. +### The predicate column must be unordered, or the zone map answers first + +The column a narrow query filters on decides how much work the serial plan does. +`sel` is the `generate_series` counter, so it is stored in order. The zone +map then excludes 20 of the table's 27 chunk groups before any row is read. + +Filtering instead on `a`, which is `hashint4` derived and unordered, reads all 27 +groups for the same number of rows returned. The two differ by more than the +clock: + +| narrow query filters on | groups read | serial cost | speedup, fastest | speedup, middle | columnar turns parallel at | heap turns parallel at | +| --- | --- | --- | --- | --- | --- | --- | +| `sel`, stored in order | 7 of 27 | 22,428 | 1.54 to 1.67 | 1.24 to 1.31 | 0.015 | 0.035 | +| `a`, unordered | 27 of 27 | 83,305 | 2.54 to 2.67 | 2.43 to 2.51 | 0.060 | 0.035 | + +The last two columns are at 4 workers and are the causal evidence. + +On the ordered column the effect this section describes nearly disappears. The +slowest parallel run is then slower than the fastest serial run, so the +non-overlap stated above does not hold there. The numbers in this section are +measured on the unordered column, because that is the case where the planner's +choice costs the most. + +Read the last two columns together. The columnar threshold moves from 0.015 to +0.060 between the two predicates. The heap threshold does not move at all. A heap +has no zone map, so that is what identifies pruning as the cause, rather than +anything about the two columns themselves. + The values below come from sweeping `parallel_tuple_cost` down from the default 0.100 in steps of 0.001. The threshold is the first value at which `EXPLAIN` shows a `Gather` node. | `max_parallel_workers_per_gather` | columnar turns parallel at | heap turns parallel at | | --- | --- | --- | -| 2, the default | 0.009 to 0.010 | 0.026 to 0.027 | -| 3 | 0.013 to 0.014 | 0.030 to 0.032 | -| 4 | 0.015 to 0.016 | 0.034 to 0.035 | +| 2, the default | 0.039 | 0.026 | +| 3 | 0.053 | 0.031 | +| 4 | 0.060 | 0.035 | -Each cell is a range because `ANALYZE` samples. Over nine draws the estimate for -a query that returns 1,000,000 rows landed between 968,663 and 1,015,140. The -heap threshold moves with that estimate. The columnar threshold barely moves. -The ranges cover PostgreSQL 17 and PostgreSQL 18. +Each value is one sweep on one build of the table. `ANALYZE` samples, so the row +estimate moves between builds, and the heap threshold moves with it. The columnar +threshold barely moves. An earlier edition reported these as ranges over repeated +builds, and those ranges covered PostgreSQL 17 and PostgreSQL 18. A different table gives different values, and so does a different worker count. -An earlier edition of this page quoted 0.060 for the columnar plan. It named -neither the table nor the worker count, so a reader could not reproduce it. +An earlier edition of this page quoted 0.060 for the columnar plan and named +neither the table nor the worker count. That value is correct. It is this table +at 4 workers, with the narrow query filtering on an unordered column. A later +edition replaced it with 0.010, which is the same table at 2 workers with the +query filtering on `sel`. Both are right for the case they measure, and neither +edition recorded which case that was. ## Index-only scans diff --git a/test/doc_parallel_premise.sh b/test/doc_parallel_premise.sh new file mode 100755 index 0000000..250a465 --- /dev/null +++ b/test/doc_parallel_premise.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# +# The parallel-scan numbers in docs/limitations.md assume the narrow query reads +# EVERY chunk group. Nothing checked that the query the page publishes does. +# +# It did not. The page published `WHERE sel <= 1000000`, and `sel` is the +# `generate_series` counter, so it is stored in order and the zone map excluded +# 20 of 27 chunk groups before a row was read. The speedup numbers beside it were +# taken on a run that asserts 27 of 27 groups read. Measured on the published +# fixture, the published query gives 1.24 to 1.31 times and no non-overlap, where +# the page claims 1.8 to 2.5 times and non-overlap at 1.45 times. +# +# So the page described one table and quoted another table's numbers. This suite +# holds the premise those numbers rest on. +# +# WHY IT READS THE DOCUMENT. A suite carrying its own copy of the query cannot +# see the document drift away from it, which is the whole defect. The filter +# column is extracted from docs/limitations.md and the checks run against that. +# If the page stops naming a narrow query, the extraction fails and this suite +# goes red rather than passing on nothing. +# +# WHAT IS NOT CLAIMED. This suite does not check the published RATIOS. Those are +# wall clock on one machine and no suite should pin them. It checks the +# structural premise underneath them, which is clock-free and exact: the +# documented query must read every chunk group, and the ordered column must +# prune. PGC_SKIP_TIMING does not apply, because nothing here reads a clock. +# +# Usage: test/doc_parallel_premise.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}" + +DOC="$PGC_SRCDIR/docs/limitations.md" +ROWS=${PGC_DPP_ROWS:-200000} + +# --- extract the narrow query's filter column FROM THE DOCUMENT ------------- +doc_line=$(grep -m1 'The narrow query is' "$DOC") +doc_query=$(printf '%s' "$doc_line" | sed -n 's/.*`\(SELECT[^`]*\)`.*/\1/p') +doc_col=$(printf '%s' "$doc_query" | sed -n 's/.*WHERE[[:space:]]*\([a-zA-Z_][a-zA-Z0-9_]*\).*/\1/p') + +check "premise: the page still names a narrow query" \ + "$([ -n "$doc_query" ] && echo yes || echo no)" "yes" +check "premise: and that query has an extractable filter column" \ + "$([ -n "$doc_col" ] && echo yes || echo no)" "yes" +[ -n "$doc_col" ] || { echo "could not extract a filter column from: $doc_line"; pgc_summary; } +echo "-- the page's narrow query filters on: $doc_col" + +# --- the documented fixture shape, at a size a suite can afford ------------- +# Same columns as the page's SQL. sel is the counter and is stored in order; a +# is hashint4-derived and is not. The row-group limit is lowered so a small +# table still holds many groups, which is what makes pruning observable. +psql_run "DROP TABLE IF EXISTS c753; + SET pgcolumnar.stripe_row_limit = 20000; + SET pgcolumnar.chunk_group_row_limit = 10000; + CREATE TABLE c753 (sel int4, a int4, b int4) USING pgcolumnar; + INSERT INTO c753 SELECT g, hashint4(g), hashint4(g+1) + FROM generate_series(1, $ROWS) g;" +psql_run "ANALYZE c753;" + +groups_total=$(q "SELECT count(*) FROM pgcolumnar.row_group r + JOIN pgcolumnar.storage s USING (storage_id) + WHERE s.relation_oid = 'c753'::regclass") +check "premise: the fixture holds several row groups, so pruning can be seen" \ + "$([ "${groups_total:-0}" -ge 5 ] && echo many || echo "$groups_total")" "many" + +# A constant taken from the data, so the selectivity is the same whichever +# column is filtered and the check does not depend on the table's size. +p25() { q "SELECT percentile_disc(0.25) WITHIN GROUP (ORDER BY $1) FROM c753"; } + +explain_of() { # column -> the EXPLAIN ANALYZE text for a 25% scan on it + q "SET max_parallel_workers_per_gather = 0; + EXPLAIN (ANALYZE, TIMING OFF) + SELECT sel, a, b FROM c753 WHERE $1 <= $(p25 "$1")" +} +read_groups() { explain_of "$1" | sed -n 's/.*Chunk Groups Read: \([0-9]*\).*/\1/p' | head -1; } +total_groups() { explain_of "$1" | sed -n 's/.*Chunk Groups Total: \([0-9]*\).*/\1/p' | head -1; } +# The plan-time stripe_row_limit is set to the value this table was WRITTEN at. +# +# This is a workaround for the half of #817 that is still open: that +# pgcolumnar_zonemap_survival sizes the group count from the planning SESSION's +# GUC rather than from the written geometry. PR #821 fixed that estimator's +# sample, NOT this. Do not delete this line on the strength of #817 being +# referenced as fixed somewhere. +# +# ceil(200000/150000) is 2, so at the default the estimator models a 10-group +# table as a 2-group one, and both groups it examines survive. Zone map pruning +# then leaves the cost entirely: both columns cost 4212.00, where the written +# limit prices the pruning. Measured on both sides of #821, which moved the +# written-limit figure and left the default one untouched: +# +# plan-time 150000 plan-time 20000 +# before #821 4212.00 / 4212.00 4212.00 / 1404.00 +# after #821 4212.00 / 4212.00 4212.00 / 1263.60 +# +# The post-#821 figure is 4212 x 3/10, and EXPLAIN ANALYZE reports "Chunk Groups +# Read: 3 of 10", so the estimate now agrees with the scan it prices. The check +# below asserts the ORDERING of the two costs, which holds on either side. +serial_cost() { + q "SET max_parallel_workers_per_gather = 0; + SET pgcolumnar.stripe_row_limit = 20000; + EXPLAIN SELECT sel, a, b FROM c753 WHERE $1 <= $(p25 "$1")" \ + | grep -m1 -oE 'cost=[0-9.]+\.\.[0-9.]+' | sed 's/.*\.\.//' +} + +doc_read=$(read_groups "$doc_col"); doc_tot=$(total_groups "$doc_col") +sel_read=$(read_groups sel); sel_tot=$(total_groups sel) +echo "-- $doc_col (the documented column): $doc_read of $doc_tot groups read" +echo "-- sel (stored in order): $sel_read of $sel_tot groups read" + +# --- 1. the defect this suite exists for ------------------------------------ +# The published speedup numbers were taken with every group read. If the page's +# query prunes, the numbers beside it describe a different amount of work. +check "the documented narrow query reads every chunk group" \ + "$doc_read" "$doc_tot" + +# --- 2. the contrast the page now documents is real ------------------------- +check "and filtering the ordered column instead prunes some away" \ + "$([ "${sel_read:-0}" -lt "${sel_tot:-0}" ] && echo prunes || echo "$sel_read of $sel_tot")" \ + "prunes" + +# --- 3. stated directly, so the comparison below cannot be a self-comparison - +# Without this, a page that names the ordered column makes check 4 compare a +# value with itself, which is a check that cannot pass rather than one that +# measured something. +check "the documented column is not the one stored in order" \ + "$([ "$doc_col" != "sel" ] && echo yes || echo "no, the page names sel")" "yes" + +# --- 4. the same effect in the cost, which is what moves the plan ----------- +# Exact rather than bounded: reading more groups must cost more. A bound here +# would need calibrating and the direction does not. +if [ "$doc_col" != "sel" ]; then + dc=$(serial_cost "$doc_col"); sc=$(serial_cost sel) + echo "-- serial cost: $doc_col $dc, sel $sc" + check "and the documented column is costed above the ordered one" \ + "$(awk -v a="$dc" -v b="$sc" 'BEGIN { print (a > b) ? "yes" : "no (" a " vs " b ")" }')" "yes" +fi + +pgc_summary diff --git a/test/run_all_versions.sh b/test/run_all_versions.sh index f21305d..60432ef 100644 --- a/test/run_all_versions.sh +++ b/test/run_all_versions.sh @@ -69,6 +69,7 @@ SUITES=( decode_skip_interrupts dependency_estimate differential + doc_parallel_premise docs_style drop_cleanup eager_ordering_record