diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b8d3adc..b818f274 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,6 +115,82 @@ true until the next version shipped. reader ignore the flag reddens ten, and using the group-wide size in the fetch path reddens exactly the three fetch arms. Ledger rows seeded from five real runs merged in one call, so each carries 15;16;17;18;19. +- Three cost-model suites ported to pytest (#432). + + `native_index_fetch_stripe_cost`, `scan_decode_cost` and + `index_fetch_penalty_width`, 18 names, each graded `missing: 0` by + `compare_to_bash.py`. Ports take 22 to 25 of the corpus. Every cost quoted here and + in the files came from ONE run, PG18 assert build, 2026-09-18. One subject: what the + planner charges for the decode a scan or a fetch really performs. + + EACH PORT ASSERTS SOMETHING ITS BASH ORIGINAL DOES NOT. + + native_index_fetch_stripe_cost the original moves the per-table + stripe_row_limit and asserts two costs + differ. Measured, the two arms are not the + same plan: at its 100,000 rows the second + is a Custom Scan, so the check compares the + prices of two different nodes and cannot + separate a re-priced fetch from a plan + flip. The port matches at 1,000 rows, where + both arms stay on the index, and asserts + the shape before comparing the prices. It + also adds the attribution cell: with + enable_index_fetch_penalty off the same two + arms price identically, 35.20 and 35.20 + against 51.70 and 808.98. + + scan_decode_cost the fixtures are smaller and the ratios + were measured before they were shrunk. The + column-count ratio is flat at 4.209 from + 100,000 rows to 2,000,000, so the original's + 2,000,000 buys the assertion nothing; + 200,000 is kept because it still spans more + than one row group. The point-lookup arm is + the one size decides, and its boundary was + measured: a Custom Scan at 50,000 rows, the + index from 100,000. + + index_fetch_penalty_width plan shape is read from FORMAT JSON by + exact Node Type equality rather than by + grepping plan text, and the row-group + geometry is read back from + pgcolumnar.row_group (20 groups per arm) + rather than assumed from the option that + set it. + + THE ROW-GROUP LIMIT IS A PER-TABLE OPTION IN THE PORT, NOT A CLUSTER SETTING. + The bash suite pins `pgcolumnar.stripe_row_limit=20000` in the cluster config so + the writing and planning sessions cannot disagree (#806). A pytest cluster is + shared by every test in the session, so that is not available -- and is not + needed: `set_options` before the write is durable and is what both the writer + and the planner read. + + REVIEW FIXES (@jdatcmd). The ordering arm's `FW <= FN` was satisfied by `FW = 0`, + which is also what an over-charged width weight produces; the ladder now reaches + below the shell suite's first rung, where the wide table does still fetch by index, + so `FW` is a measured 5 and `FW > 0` is asserted beside it. And the `#171` + point-lookup arm is labelled for what it actually guards: inflating the scan decode + charge a millionfold leaves it passing (`Index Scan`, #503 ratio 4.209 -> 16.999), + while inflating the index-fetch penalty reddens it (`got 'Custom Scan'`). It bounds + a neighbouring subsystem, not this change, and nothing in either harness bounds + this change from that side today. + + Every load-bearing arm carries a removal proof against the C, each mutation + asserted to have applied and restored byte-identical: + + decode charge deleted (#503) column-count ratio 4.209 -> 1.014 + width weight dropped (#768) width ratio 7.303 -> 0.966, and the + column-count arm stays green at 2.610 + prefix charged per column (#803) the ordering inverts: the wide table + fetches to 120 rows against the narrow + table's 40 + per-table option branch removed both stripe_row_limit arms collapse + onto 810.46 + + No bash suite changes, so no ledger row moves and the census does not. + `cluster_tests` 427 -> 430, re-derived by collection on the reseated tree. + - Three suites ported to pytest, and the queue re-derived (#432). `analyze_reltuples`, `projection_update` and `projection_drop_column`, 21 names, diff --git a/test/pytest/TESTS.md b/test/pytest/TESTS.md index 4d86c99e..16be009a 100644 --- a/test/pytest/TESTS.md +++ b/test/pytest/TESTS.md @@ -103,6 +103,9 @@ behaviour, the source of that number is named. - [55. test_projection_drop_column.py: DROP COLUMN must not invalidate a projection](#55-test_projection_drop_columnpy-drop-column-must-not-invalidate-a-projection) - [56. test_encode_post_codec.py: an encoding must be smaller after the codec](#56-test_encode_post_codecpy-an-encoding-must-be-smaller-after-the-codec) - [57. test_validity_elision.py: a column with no nulls must store no validity bitmap](#57-test_validity_elisionpy-a-column-with-no-nulls-must-store-no-validity-bitmap) +- [58. test_native_index_fetch_stripe_cost.py: the fetch penalty reads the table's own row-group limit](#58-test_native_index_fetch_stripe_costpy-the-fetch-penalty-reads-the-tables-own-row-group-limit) +- [59. test_scan_decode_cost.py: a scan is priced for the columns it decodes and their width](#59-test_scan_decode_costpy-a-scan-is-priced-for-the-columns-it-decodes-and-their-width) +- [60. test_index_fetch_penalty_width.py: a wider prefix must give up on per-row fetches sooner](#60-test_index_fetch_penalty_widthpy-a-wider-prefix-must-give-up-on-per-row-fetches-sooner) ## 1. How to read a test in here @@ -4851,3 +4854,134 @@ null in the table, so the column that still needs its bitmap is asserted beside the one that does not -- and the null count is asserted separately, because both `EXCEPT ALL` arms are satisfied by a table that agrees on values and not on nulls. + +## 58. test_native_index_fetch_stripe_cost.py: the fetch penalty reads the table's own row-group limit + +Port of `native_index_fetch_stripe_cost.sh` (#432). The index-fetch penalty prices one +row-group decode, so its size is the limit in force for that table -- the per-table +`stripe_row_limit` option when the owner set one, not only the session GUC (#806). + +The property is a response rather than a number: move the option, and the estimate must +move. A cost value would pin this box's cost constants instead. + +**The port asserts the plan SHAPE before it compares the two prices, and the shell suite +does not.** Measured on this fixture, PG18 assert build: + +| rows matched | limit 2,000 | limit 150,000 | +| --- | --- | --- | +| 1,000 | Index Scan 51.70 | Index Scan 808.98 | +| 20,000 | Index Scan 735.26 | Index Scan 1752.26 | +| 50,000 | Index Scan 1827.50 | Custom Scan 2004.50 | +| 100,000 | Index Scan 3657.00 | Custom Scan 2004.50 | + +At the shell suite's 100,000 rows the second arm is no longer an index scan, so the two +numbers it compares are the prices of two different plans. They differ and the check +passes, but a plan flip is one of the things a changed penalty causes, so that comparison +cannot separate "the penalty re-priced this fetch" from "the penalty moved the planner +onto another node". The port matches at 1,000 rows, where both arms stay on the index. + +It also carries the attribution cell the original leaves out. With +`enable_index_fetch_penalty = off` the same two arms price identically (35.20 and 35.20 +against 51.70 and 808.98), which names the penalty as the term carrying the option rather +than leaving any cost term that reads it equally satisfying. + +### Every arm + +| test | what it holds | +| --- | --- | +| `test_native_index_fetch_stripe_cost` | every arm: the cost is a real estimate, both arms are the same plan shape and that shape is the index fetch, the estimate moves with the per-table option, and with the penalty off it does not | + +## 59. test_scan_decode_cost.py: a scan is priced for the columns it decodes and their width + +Port of `scan_decode_cost.sh` (#432). Two defects in one cost term. The custom scan +inherited the heap seqscan cost, whose CPU term is per row, so decoding nine columns was +priced what decoding one was (#503); the fix that followed charged per decoded value, +which left a 324-byte text column charged what a 4-byte int4 column is (#768). #171 bounds +the correction from the other side: raising the full-scan cost must not push a point +lookup off its index. + +**The fixtures are smaller than the shell suite's, and the ratios were measured before +they were shrunk.** The shell suite writes 2,000,000 rows twice. + +| rows | narrow (1 col) | wide (9 cols) | ratio | insert | +| --- | --- | --- | --- | --- | +| 100,000 | 1250.9 | 5266.0 | 4.210 | 0.08s | +| 200,000 | 2501.8 | 10530.0 | 4.209 | 0.15s | +| 500,000 | 6254.2 | 26322.0 | 4.209 | 0.35s | +| 1,000,000 | 12508.5 | 52645.0 | 4.209 | 0.70s | +| 2,000,000 | 25016.9 | 105288.0 | 4.209 | 1.42s | + +The ratio the arm bounds at 1.5 is flat to three decimals across a 20x range, so the extra +rows buy the assertion nothing. 200,000 is kept rather than the cheapest cell because at +the default 150,000-row group limit it spans more than one row group. + +The point lookup is the one arm size actually decides, and its boundary was measured +rather than guessed: at 50,000 rows the plan is a Custom Scan and the arm would fail; from +100,000 it is an Index Scan. 200,000 is one doubling of margin. + +**What the `#171` arm can and cannot catch**, measured after review. Two mutations, each +x1,000,000: + +| mutation | the `#171` arm | +| --- | --- | +| the scan decode charge — this file's own subject | **passes** (`Index Scan`); the #503 ratio moves 4.209 to 16.999 | +| the index-fetch penalty — a neighbouring subsystem | **reddens**: `got 'Custom Scan' want 'index'` | + +A dearer scan makes the index more attractive, so no over-charge of the term this file is +about can take a point lookup off its index. The arm is a real guard, but on +`pgcolumnar_index_fetch_penalty`. It is not evidence that the decode charge is bounded +from the other side, and nothing in either harness bounds that today. + +### Every arm + +| test | what it holds | +| --- | --- | +| `test_scan_decode_cost` | every arm: both scans priced, nine decoded columns cost materially more than one, the widths are in the statistics, four wide text columns cost more than eight narrow int ones, and a point lookup still takes its index | + +## 60. test_index_fetch_penalty_width.py: a wider prefix must give up on per-row fetches sooner + +Port of `index_fetch_penalty_width.sh` (#432). The penalty's CPU term counted the decoded +prefix's columns, so a 68-byte text column was charged what a 4-byte int4 column was. The +consequence is an inverted ordering, not merely an under-charge: the model let the wide +table fetch about 3x more rows than the narrow one before switching to a scan, when the +wide table can afford about 3x fewer (#803). + +`nproj` is the decoded PREFIX length, so width cannot be varied at a fixed prefix inside +one table. Hence two tables of identical shape whose columns differ only in type at each +position. + +**The ladder reaches below the shell suite's first rung, and that is a fix rather than a +preference.** `FW <= FN` is satisfied by `FW = 0`, and 0 is what an over-charged width +weight produces as well as what "gives up immediately" produces. The shell ladder starts +at 20 and the wide table's flip point is between 5 and 7 rows, so every rung it tries is +already past it. Measured, penalty on: + +| k | rows | `ifw_n` | `ifw_w` | +| --- | --- | --- | --- | +| 1 | 0 | Index Scan | Index Scan | +| 5 | 2 | Index Scan | Index Scan | +| 10 | 5 | Index Scan | Index Scan | +| 20 | 7 | Index Scan | **Custom Scan** | + +With four rungs below 20, `FW` is a measured 5 rather than a floor, and the arm gained +`premise: and the wide table fetches by index somewhere on it too`. The shell suite has +the same one-sidedness; it is reported rather than changed here, because this pull +request adds no bash check and so moves no ledger row. + +**The row-group limit is a per-table option here, not a cluster setting.** The shell suite +pins `pgcolumnar.stripe_row_limit=20000` in the cluster config, because the writing and +the planning session must not disagree about it (#806). A pytest cluster is shared by +every test in the session, so a cluster-wide setting for one file is not available -- and +is not needed: `set_options(stripe_row_limit => ...)` before the write is durable, belongs +to the table rather than to a session, and is what both the writer and the planner read. +The geometry is then read back from `pgcolumnar.row_group` as a premise (20 groups on each +arm) rather than assumed from the option. + +Plan shape is read from `FORMAT JSON` by exact `Node Type` equality rather than by +grepping the plan text, which a property line carrying the same words can satisfy. + +### Every arm + +| test | what it holds | +| --- | --- | +| `test_index_fetch_penalty_width` | every arm: both fixtures complete, equal row-group counts above the fetch cache, the wide prefix really wider, both prefixes under the cache cap, the penalty is what moves the plan, the narrow table fetches somewhere on the ladder, and the wide table gives up no later | diff --git a/test/pytest/expected_tests.txt b/test/pytest/expected_tests.txt index 1d5f79ba..7e140a1a 100644 --- a/test/pytest/expected_tests.txt +++ b/test/pytest/expected_tests.txt @@ -318,4 +318,13 @@ guard_tests 380 # # DERIVED BY COLLECTION on this tree: `427 tests collected`. `guard_tests` was # re-derived in the same run and did NOT move: 380. -cluster_tests 427 +# 427 -> 430 when the three cost-model ports landed (#432): +# test_native_index_fetch_stripe_cost.py, test_scan_decode_cost.py and +# test_index_fetch_penalty_width.py, one test each. +# +# DERIVED BY COLLECTION AFTER EVERY RESEAT, never carried. This branch has stated 424 +# (base 421), then 426 (base 423), and now 430 (base 427) -- three different right +# answers to the same question, because main moved three times underneath it. A number +# that is carried across a reseat is a number that agrees with the tree it was taken +# from and not with this one. +cluster_tests 430 diff --git a/test/pytest/test_compare_to_bash.py b/test/pytest/test_compare_to_bash.py index da91562f..22fffb4e 100644 --- a/test/pytest/test_compare_to_bash.py +++ b/test/pytest/test_compare_to_bash.py @@ -86,16 +86,29 @@ # 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 = ["analyze_reltuples", - "differential", "encode_post_codec", "hilbert_cluster", "hilbert_locality", - "native_chunk_length_bound", "native_fetch_coalesce", "native_ownership", "native_projection", "parallel_am_scan", + "differential", + "encode_post_codec", + "hilbert_cluster", + "hilbert_locality", + "index_fetch_penalty_crossover", + "index_fetch_penalty_width", + "native_chunk_length_bound", + "native_fetch_coalesce", + "native_index_fetch_stripe_cost", + "native_ownership", + "native_projection", + "parallel_am_scan", + "parallel_scan_cost", "projection_drop_column", "projection_privilege", - "projection_update", "projection_scan_cost", + "projection_update", "projections", - "sorted_pathkeys", "stats_privilege", - "index_fetch_penalty_crossover", - "parallel_scan_cost", "validity_elision", "zonemap_boundaries"] + "scan_decode_cost", + "sorted_pathkeys", + "stats_privilege", + "validity_elision", + "zonemap_boundaries"] # stem -> why it does not yet reach zero. Empty today, and an entry here is a claim # about the PORT rather than a licence: the standing arm does not grade it, so the diff --git a/test/pytest/test_index_fetch_penalty_width.py b/test/pytest/test_index_fetch_penalty_width.py new file mode 100644 index 00000000..c88209c0 --- /dev/null +++ b/test/pytest/test_index_fetch_penalty_width.py @@ -0,0 +1,213 @@ +"""The index-fetch penalty charges decode CPU by column WIDTH, not a column count. + +`pgcolumnar_index_fetch_penalty` prices the row-group decode a per-row index fetch +forces. Its CPU term counted the decoded prefix's columns, so a 68-byte text column +was charged exactly what a 4-byte int4 column was. The consequence is not merely an +under-charge but an INVERTED ordering: the model let the wide table fetch about 3x +MORE rows than the narrow one before switching to a scan, when the wide table can +afford about 3x fewer (#803). + +This file pins the ordering, which is the part that changes a plan, and reads it from +the PLAN rather than the clock -- so it is not a timing check and PGC_SKIP_TIMING +never applies to it. The absolute row counts are deliberately not asserted: they move +with the cost constants, and the defect is the direction. + +TWO TABLES OF IDENTICAL SHAPE, DIFFERING ONLY IN COLUMN TYPE. `nproj` is the decoded +PREFIX length (`pgcolumnar_scan_decode_shape` sets `*nprefix = maxatt`, #363), so +width cannot be varied at a fixed prefix inside one table. Both prefixes are four +columns by construction. + +Independent of test/index_fetch_penalty_width.sh: same public seam (EXPLAIN of an +index-forced range scan), own fixture, own observations. Assertion names match the +shell suite so the two can be compared by name, not by importing each other. + +THE ROW-GROUP LIMIT IS A PER-TABLE OPTION HERE, NOT A CLUSTER SETTING. The shell twin +pins `pgcolumnar.stripe_row_limit=20000` in the cluster config, because the writing +and the planning session must not disagree about it (#806). A pytest cluster is +shared by every test in the session, so a cluster-wide setting for one file's benefit +is not available -- and is not needed: `set_options(stripe_row_limit => ...)` before +the write is durable, belongs to the table rather than to a session, and is what both +the writer and the planner read. The geometry is then read BACK from +`pgcolumnar.row_group` as a premise rather than assumed from the option. +""" + +N = 400_000 +R = 20_000 +CAP = 32 * 1024 * 1024 # COLUMNAR_FETCH_CACHE_MAX_BYTES +# FOUR RUNGS BELOW THE SHELL SUITE'S FIRST ONE. Its ladder starts at 20, and the +# wide table's flip point is between 5 and 7 rows, so every rung it tries is already +# past it and `flip_rows` returns 0 -- which satisfies the ordering arm below for the +# wrong reason. Measured, penalty on: ifw_w is an Index Scan at k=1,2,5,10 and a +# Custom Scan from k=20. With these rungs FW is a measured 5 rather than a floor. +LADDER = (1, 2, 5, 10, 20, 40, 60, 80, 100, 150, 200, 300, 400, 600, 900) + +SETS = ( + "SET enable_seqscan = off", + "SET max_parallel_workers_per_gather = 0", + "SET random_page_cost = 1.1", +) + + +def _nodes(plan): + stack = [plan[0]["Plan"]] + while stack: + node = stack.pop(0) + yield node + stack.extend(node.get("Plans") or ()) + + +def _plan(conn, sql, *, penalty=None): + with conn.cursor() as cur: + for s in SETS: + cur.execute(s) + if penalty is not None: + cur.execute(f"SET pgcolumnar.enable_index_fetch_penalty = {penalty}") + cur.execute(f"EXPLAIN (FORMAT JSON, COSTS OFF) {sql}") + return cur.fetchone()[0] + + +def _fetches_by_index(plan): + """True when the plan reads this table through a per-row index fetch. + + The shell twin greps the EXPLAIN text for `Index Scan`. Node Type equality on + parsed JSON cannot be satisfied by a property line that happens to carry the + words, which is a real way for a plan-shape check to lie. + """ + return any(n["Node Type"] == "Index Scan" for n in _nodes(plan)) + + +def _one(conn, sql, args=()): + with conn.cursor() as cur: + cur.execute(sql, args) + row = cur.fetchone() + return row[0] if row else None + + +def _flip_rows(conn, table): + """-> the largest fetched-row count at which `table` still plans an index fetch. + + A ladder rather than a bisection, as in the twin, so a failure prints where the + plan turned over instead of only that it did. + """ + last = 0 + for k in LADDER: + plan = _plan( + conn, + f"SELECT c1,c2 FROM {table} WHERE scat BETWEEN 1 AND {k}", + penalty="on", + ) + if not _fetches_by_index(plan): + break + last = _one(conn, f"SELECT count(*) FROM {table} WHERE scat BETWEEN 1 AND {k}") + return last + + +def test_index_fetch_penalty_width(pgc_conn, expect): + with pgc_conn.cursor() as cur: + cur.execute("CREATE TABLE ifw_n (id int, scat int, c1 int, c2 int) " + "USING pgcolumnar") + cur.execute("CREATE TABLE ifw_w (id int, scat int, c1 text, c2 text) " + "USING pgcolumnar") + for t in ("ifw_n", "ifw_w"): + cur.execute(f"SELECT pgcolumnar.set_options('{t}', stripe_row_limit => {R})") + cur.execute( + "INSERT INTO ifw_n SELECT g, (g * 2654435761::bigint % 1000000)::int, " + f"g, g + 1 FROM generate_series(1,{N}) g" + ) + cur.execute( + "INSERT INTO ifw_w SELECT g, (g * 2654435761::bigint % 1000000)::int, " + "repeat(md5(g::text),2), repeat(md5((g+1)::text),2) " + f"FROM generate_series(1,{N}) g" + ) + cur.execute("CREATE INDEX ifw_n_scat ON ifw_n(scat)") + cur.execute("CREATE INDEX ifw_w_scat ON ifw_w(scat)") + cur.execute("ANALYZE ifw_n") + cur.execute("ANALYZE ifw_w") + + # ---- premises --------------------------------------------------------- + for t in ("ifw_n", "ifw_w"): + expect.num( + _one(pgc_conn, f"SELECT count(*) FROM {t}"), N, + f"premise: {t} holds all {N} rows", + ) + + groups = {} + for t in ("ifw_n", "ifw_w"): + groups[t] = _one( + pgc_conn, + "SELECT count(*) FROM pgcolumnar.storage s " + "JOIN pgcolumnar.row_group rg USING (storage_id) " + f"WHERE s.relation_oid = '{t}'::regclass", + ) + gn, gw = groups["ifw_n"], groups["ifw_w"] + print(f"-- row groups: ifw_n={gn} ifw_w={gw}") + # THE GEOMETRY IS READ BACK, NOT ASSUMED FROM THE OPTION. More groups than the + # fetch cache holds is the whole mechanism being priced: with few enough groups + # the cache holds everything and the pathology does not occur at all. + expect.text( + f"yes ({gn})" if gn == gw and (gn or 0) > 4 else f"no (n={gn} w={gw})", + f"yes ({gn})", + "premise: both tables have the same row-group count, and more than the " + "fetch cache holds", + ) + + wn = _one(pgc_conn, + "SELECT sum(avg_width) FROM pg_stats WHERE tablename = 'ifw_n'") + ww = _one(pgc_conn, + "SELECT sum(avg_width) FROM pg_stats WHERE tablename = 'ifw_w'") + print(f"-- summed avg_width: ifw_n={wn} ifw_w={ww}") + expect.text( + "wider" if wn and ww and ww >= 4 * wn else f"NOT WIDER (n={wn} w={ww})", + "wider", + "premise: the wide table's prefix really is wider, by a large factor", + ) + expect.text( + "under" if (wn or 0) * R < CAP and (ww or 0) * R < CAP + else f"OVER (n={(wn or 0) * R} w={(ww or 0) * R} cap={CAP})", + "under", + "premise: both prefixes decode well under the fetch cache cap, so the " + "overflow blend is inactive on both arms", + ) + + # THE SUBJECT MUST BE WHAT MOVES THE PLAN. If the penalty never moves one there + # is no ordering to check and every arm below would pass vacuously. + sql = "SELECT c1,c2 FROM ifw_w WHERE scat BETWEEN 1 AND 400" + pen_off = _plan(pgc_conn, sql, penalty="off") + pen_on = _plan(pgc_conn, sql, penalty="on") + off_idx, on_idx = _fetches_by_index(pen_off), _fetches_by_index(pen_on) + print(f"-- penalty off -> index fetch: {off_idx} ; on -> {on_idx}") + expect.text( + "yes" if off_idx and not on_idx else f"no (off={off_idx} on={on_idx})", + "yes", + "premise: the penalty is what moves this plan (index without it, not with it)", + ) + + # ---- the check -------------------------------------------------------- + fn = _flip_rows(pgc_conn, "ifw_n") + fw = _flip_rows(pgc_conn, "ifw_w") + print(f"-- last row count still fetched by index: narrow prefix {wn}B = {fn} rows," + f" wide prefix {ww}B = {fw} rows") + + expect.at_least( + fn, 1, "premise: the narrow table does fetch by index somewhere on the ladder" + ) + # AND THE WIDE ONE DOES TOO, which is the arm's other side. `FW <= FN` is + # satisfied by FW = 0, and 0 is what an OVER-charged width weight produces as + # well as what "gives up immediately" produces -- so without this the check is + # one-sided: it catches the #803 under-charge and reports `ordered` for any + # over-charge severe enough to take the wide table off the index entirely. + expect.at_least( + fw, 1, "premise: and the wide table fetches by index somewhere on it too" + ) + + # A wider decoded prefix makes every row-group decode dearer, so the wide table + # must abandon per-row fetches NO LATER than the narrow one. Priced by column + # COUNT the two arms charge the same decode CPU and the wide table's larger page + # term pushes its flip point HIGHER, which is the inversion #803 reports. + expect.text( + "ordered" if (fw or 0) <= (fn or 0) + else f"INVERTED (wide {ww}B fetches to {fw} rows, narrow {wn}B only to {fn})", + "ordered", + "a wider decoded prefix gives up on per-row fetches no later than a narrow " + "one (#803)", + ) diff --git a/test/pytest/test_native_index_fetch_stripe_cost.py b/test/pytest/test_native_index_fetch_stripe_cost.py new file mode 100644 index 00000000..1c148ce6 --- /dev/null +++ b/test/pytest/test_native_index_fetch_stripe_cost.py @@ -0,0 +1,127 @@ +"""The index-fetch penalty sizes its row-group decode by the table's OPTION (#806). + +`pgcolumnar_index_fetch_penalty` prices the row-group decode a per-row index fetch +forces, and that decode is one row group, so its size is the limit in force for the +table -- the per-table `stripe_row_limit` option when set. Reading only the session +GUC prices every table alike however its owner declared it. + +The property is a RESPONSE, not a number: move the per-table option and the estimate +must move. Asserting a cost value would pin this box's cost constants instead. + +Independent of test/native_index_fetch_stripe_cost.sh: same public seam (EXPLAIN of +an index-forced range scan), own fixture, own observations. Assertion names match the +shell suite so the two can be compared by name, not by importing each other. + +THE RANGE IS 1,000 ROWS HERE AND 100,000 IN THE SHELL TWIN, AND THAT IS THE POINT. +Measured on this fixture, PG18 assert build, `enable_seqscan=off`, total cost of the +top node: + + rows matched limit=2,000 limit=150,000 + 1,000 Index Scan 51.70 Index Scan 808.98 + 20,000 Index Scan 735.26 Index Scan 1752.26 + 50,000 Index Scan 1827.50 Custom Scan 2004.50 + 100,000 Index Scan 3657.00 Custom Scan 2004.50 + +At the shell twin's 100,000 rows the second arm is not an index scan at all, so the +two numbers it compares are the prices of two DIFFERENT plans. They differ, and the +check passes, but a plan flip is exactly what a changed penalty can cause -- so that +comparison cannot separate "the penalty re-priced this fetch" from "the penalty +pushed the planner onto another node". Below 20,000 rows both arms stay on the index +and the difference is a difference in the price of one plan shape, which is the +claim. The port asserts the shape before comparing the prices. + +AND THE EFFECT IS THE PENALTY'S. Four cells, same fixture, 1,000 rows: + + penalty on 51.70 808.98 responds to the option + penalty off 35.20 35.20 identical + +so the arm below is not merely observing that two costs differ; the term that +carries the option into the price is named and its removal flattens both arms onto +the same number. That control is asserted, not just quoted. +""" + +RANGE = 1000 +SMALL, LARGE = 2000, 150000 + + +def _top(conn, *, penalty="on"): + """-> (node type, total cost, plan) for the index-forced range scan.""" + with conn.cursor() as cur: + cur.execute("SET enable_seqscan = off") + cur.execute(f"SET pgcolumnar.enable_index_fetch_penalty = {penalty}") + cur.execute( + "EXPLAIN (FORMAT JSON, COSTS ON) " + f"SELECT * FROM ct WHERE id BETWEEN 1 AND {RANGE}" + ) + plan = cur.fetchone()[0] + top = plan[0]["Plan"] + return top["Node Type"], top["Total Cost"], plan + + +def _set_limit(conn, rows): + with conn.cursor() as cur: + cur.execute( + "SELECT pgcolumnar.set_options('ct', stripe_row_limit => %s)", (rows,) + ) + + +def test_native_index_fetch_stripe_cost(pgc_conn, expect): + n = 200000 + with pgc_conn.cursor() as cur: + cur.execute("CREATE TABLE ct (id int, v int) USING pgcolumnar") + cur.execute(f"INSERT INTO ct SELECT g, g FROM generate_series(1, {n}) g") + cur.execute("CREATE INDEX ct_id ON ct (id)") + cur.execute("ANALYZE ct") + cur.execute("SELECT count(*) FROM ct") + expect.num(cur.fetchone()[0], n, f"premise: the table holds all {n} rows") + + _set_limit(pgc_conn, SMALL) + type1, c1, plan1 = _top(pgc_conn) + _set_limit(pgc_conn, LARGE) + type2, c2, _ = _top(pgc_conn) + print(f"-- index-scan total cost: stripe_row_limit={SMALL} -> {c1} ; " + f"={LARGE} -> {c2}") + print(f"-- top node: {type1} then {type2}") + + # A COST IS A NUMBER OR THE REST OF THIS FILE IS ABOUT NOTHING. The shell twin + # asks whether its `sed` produced digits; here the value arrives typed from + # FORMAT JSON, so the question is whether it is a real estimate rather than a + # zero. A floor of 1 refuses a missing or zero cost without pinning any of this + # box's cost constants. + expect.at_least( + c1, 1, + "the index-fetch cost responds to the per-table stripe_row_limit " + "(c1 is a number)", + ) + + # SAME SHAPE ON BOTH ARMS, ASSERTED BEFORE THE PRICES ARE COMPARED. See the + # module docstring: two costs also differ when the plan flipped, and then the + # comparison is about node selection rather than about the penalty's arithmetic. + expect.text(type2, type1, "premise: both arms priced the same plan shape") + expect.plan_node( + plan1, node_type="Index Scan", + name="premise: the priced plan is the per-row index fetch the penalty prices", + ) + + expect.differ( + c1, c2, + "changing the per-table stripe_row_limit changes the estimated cost", + ) + + # THE ATTRIBUTION CELL. Without it this file shows only that two costs differ + # while one option moved, and any other term reading that option would satisfy + # it equally. With the penalty off the same two arms must collapse onto one + # number -- that is what names the penalty as the term carrying the option. + _set_limit(pgc_conn, SMALL) + _, off_small, _ = _top(pgc_conn, penalty="off") + _set_limit(pgc_conn, LARGE) + _, off_large, _ = _top(pgc_conn, penalty="off") + print(f"-- penalty off: {SMALL} -> {off_small} ; {LARGE} -> {off_large}") + expect.num( + off_small, off_large, + "premise: with the fetch penalty off the option reaches no other cost term", + ) + + with pgc_conn.cursor() as cur: + cur.execute("SELECT 1") + expect.num(cur.fetchone()[0], 1, "backend alive") diff --git a/test/pytest/test_scan_decode_cost.py b/test/pytest/test_scan_decode_cost.py new file mode 100644 index 00000000..da9ce97d --- /dev/null +++ b/test/pytest/test_scan_decode_cost.py @@ -0,0 +1,191 @@ +"""A columnar scan's cost scales with the decoded columns, and with their WIDTH. + +Two defects, one cost term. #503: the custom scan inherited the heap seqscan cost, +whose CPU term is per ROW, so decoding nine columns was priced exactly what decoding +one was. #768: the fix that followed charged per decoded VALUE, which left a 324-byte +text column charged what a 4-byte int4 column is. And #171 bounds the correction from +the other side -- raising the full-scan cost must not push a point lookup off its +index. + +This file asserts the PLANNER's arithmetic, never a clock. The decode timings that +motivate the bounds live on the bench and in the shell twin's header. + +Independent of test/scan_decode_cost.sh: same public seam (EXPLAIN of a columnar +scan), own fixture, own observations. Assertion names match the shell suite so the +two can be compared by name, not by importing each other. + +THE FIXTURES ARE SMALLER THAN THE SHELL TWIN'S, AND THE RATIOS WERE MEASURED BEFORE +THEY WERE SHRUNK. The twin writes 2,000,000 rows twice. Measured on this box, PG18 +assert build, total cost of the top node: + + rows narrow(1 col) wide(9 cols) ratio insert + 100,000 1250.9 5266.0 4.210 0.08s + 200,000 2501.8 10530.0 4.209 0.15s + 500,000 6254.2 26322.0 4.209 0.35s + 1,000,000 12508.5 52645.0 4.209 0.70s + 2,000,000 25016.9 105288.0 4.209 1.42s + +The ratio the arm bounds at 1.5 is FLAT to three decimals across a 20x range, so the +extra rows buy the assertion nothing. 200,000 is kept rather than the cheapest cell +because at the default 150,000-row group limit it spans more than one row group, +which one of 100,000 would not. + +The point-lookup arm is the one size actually decides, and its boundary was measured +rather than guessed: + + 50,000 Custom Scan <- the arm would fail here + 100,000 Index Scan + 200,000 Index Scan <- chosen, one doubling of margin + 2,000,000 Index Scan + +The width arm keeps the twin's 100,000 rows: its ratio is 7.3 there against 8.5 at +25,000, so that is the least favourable of the sizes measured and there is no reason +to trade margin for 1.1 seconds. +""" + +N = 200_000 +TW = 100_000 +INDEX_NODES = ("Index Scan", "Index Only Scan", "Bitmap Index Scan", "Bitmap Heap Scan") + + +def _nodes(plan): + stack = [plan[0]["Plan"]] + while stack: + node = stack.pop(0) + yield node + stack.extend(node.get("Plans") or ()) + + +def _topcost(conn, sql): + """Total cost of the top plan node, read from typed JSON rather than grepped. + + The shell twin runs `grep -oiE 'cost=[0-9.]+\\.\\.[0-9.]+' | head -1`. That reads + the first cost on the first line, which IS the top node, but it reads it out of + text in which a property line can also carry the substring. + """ + with conn.cursor() as cur: + cur.execute(f"EXPLAIN (FORMAT JSON, COSTS ON) {sql}") + return cur.fetchone()[0][0]["Plan"]["Total Cost"] + + +def test_scan_decode_cost(pgc_conn, expect): + # ---- #503: nine decoded columns must not cost what one costs -------------- + with pgc_conn.cursor() as cur: + cur.execute( + "CREATE TABLE cw (k int, v1 bigint, v2 bigint, v3 bigint, v4 bigint," + " v5 bigint, v6 bigint, v7 bigint, v8 bigint) USING pgcolumnar" + ) + cur.execute( + f"INSERT INTO cw SELECT (g % 1000000), g,g,g,g,g,g,g,g " + f"FROM generate_series(1,{N}) g" + ) + cur.execute("ANALYZE cw") + cur.execute("SELECT count(*) FROM cw") + expect.num(cur.fetchone()[0], N, f"premise: cw holds all {N} rows") + + narrow = _topcost(pgc_conn, "SELECT k FROM cw") + wide = _topcost(pgc_conn, "SELECT k,v1,v2,v3,v4,v5,v6,v7,v8 FROM cw") + + # A COST OR NOTHING TO TALK ABOUT. The twin asks whether its `sed` produced a + # non-empty string; here the value is typed, so the question is whether it is a + # real estimate. A floor of 1 refuses a zero or a missing node without pinning + # any cost constant. + expect.at_least(narrow, 1, "premise: the narrow scan has a cost") + expect.at_least(wide, 1, "premise: the wide scan has a cost") + + ratio = wide / narrow + print(f"-- #503: 9 columns cost {wide}, 1 column costs {narrow} ({ratio:.3f}x)") + # The bound stays the twin's 1.5. Flat pricing puts these ~1.014 apart (the + # width difference alone), so 1.5 is unreachable without the decode charge, and + # the measured 4.2 leaves the bound a wide margin rather than a calibration. + expect.at_least( + ratio, 1.5, + "a wide projection costs materially more than a narrow one (decode is priced)", + ) + + # ---- #768: and the charge must follow WIDTH, not the column count ---------- + with pgc_conn.cursor() as cur: + cur.execute( + "CREATE TABLE cwide (a int4,b int4,c int4,d int4,e int4,f int4," + "g int4,h int4, t1 text, t2 text, t3 text, t4 text) USING pgcolumnar" + ) + cur.execute( + "INSERT INTO cwide SELECT i,i,i,i,i,i,i,i," + "repeat(md5(i::text),3), repeat(md5((i+1)::text),3)," + "repeat(md5((i+2)::text),3), repeat(md5((i+3)::text),3)" + f" FROM generate_series(1,{TW}) i" + ) + cur.execute("ANALYZE cwide") + cur.execute("SELECT count(*) FROM cwide") + expect.num(cur.fetchone()[0], TW, f"premise: cwide holds all {TW} rows") + cur.execute( + "SELECT attname, avg_width FROM pg_stats WHERE tablename = 'cwide'" + " AND attname IN ('a', 't1')" + ) + widths = dict(cur.fetchall()) + wt, wa = widths.get("t1"), widths.get("a") + print(f"-- widths from pg_stats: a={wa} t1={wt}") + + # THE MODEL READS WIDTH FROM THE STATISTICS, so a missing statistic makes this + # a test about ANALYZE rather than about costing: the two arms would then differ + # by nothing the planner can observe. + expect.at_least(wt, 64, "premise: the wide column's width is in the statistics") + expect.text( + "yes" if wa and wt and 0 < wa < wt else f"no (a={wa} t1={wt})", + "yes", + "premise: and the narrow column's width is too, and is smaller", + ) + + ctext = _topcost(pgc_conn, "SELECT t1,t2,t3,t4 FROM cwide") + cint = _topcost(pgc_conn, "SELECT a,b,c,d,e,f,g,h FROM cwide") + expect.at_least(min(ctext, cint), 1, "premise: both projections priced") + + wratio = ctext / cint + print(f"-- #768: 4 wide text columns cost {ctext}, 8 narrow int columns cost " + f"{cint} ({wratio:.3f}x)") + # Deliberately weak, as in the twin: the point is the SIGN. Charging by column + # count puts the text projection BELOW the int one at 0.79x, so 2.0 cannot be + # reached without width entering the charge. + expect.at_least( + wratio, 2.0, + "four wide text columns cost more than eight narrow int ones (#768)", + ) + + # ---- #171: and none of it may cost a point lookup its index --------------- + # + # THIS ARM IS A GUARD ON A NEIGHBOURING SUBSYSTEM, NOT ON THIS FILE'S SUBJECT, + # and it is worth saying so because the section reads like a bound on the decode + # charge above it. It is not. Two mutations, each x1,000,000, each built and run: + # + # the SCAN decode charge #503 ratio 4.209 -> 16.999, and this arm PASSES + # (Index Scan). A dearer scan makes the index MORE + # attractive, so no over-charge of the term this + # file is about can take a point lookup off its + # index. + # the INDEX-FETCH penalty this arm REDDENS: got 'Custom Scan' want 'index', + # with the #503 ratio unchanged at 4.209. + # + # So the arm is falsifiable and it is not decoration -- but the only thing that + # reddens it lives in pgcolumnar_index_fetch_penalty, which + # test_index_fetch_penalty_width.py and test_index_fetch_penalty_crossover.py + # are about. Keep it here, where the bash suite states it, and do not read it as + # evidence that the decode charge is bounded from the other side. Nothing in + # either harness bounds that today. + with pgc_conn.cursor() as cur: + cur.execute("CREATE TABLE pt (id int, v bigint) USING pgcolumnar") + cur.execute(f"INSERT INTO pt SELECT g, g FROM generate_series(1,{N}) g") + cur.execute("CREATE INDEX pt_id ON pt (id)") + cur.execute("ANALYZE pt") + cur.execute( + "EXPLAIN (FORMAT JSON, COSTS OFF) SELECT v FROM pt WHERE id = 12345" + ) + plan = cur.fetchone()[0] + types = [n["Node Type"] for n in _nodes(plan)] + print(f"-- #171 point-lookup plan: {' > '.join(types)}") + # The twin greps the plan text for `Index`, which any of these node types + # satisfies; classified rather than matched so a failure names what was chosen. + expect.text( + "index" if any(t in INDEX_NODES for t in types) else " > ".join(types), + "index", + "#171: a point lookup still uses the index, not a full scan", + )