Skip to content

The cost model reads the session's stripe_row_limit, not the geometry the table was written with #806

Description

@OffgridwithJD

Summary

pgcolumnar.storage.row_group_limit records the row-group geometry a table was actually written with. Nothing reads it back. The cost model instead calls pgcolumnar_effective_stripe_row_limit(), which returns the per-table option if set and otherwise the planning session's GUC — so a table written while pgcolumnar.stripe_row_limit differed is costed against a geometry it does not have.

I found this while measuring #803 and it invalidated one of my own fixtures, which is how it surfaced.

Measured

Two tables, same 400,000 rows, same shape. ga written under SET pgcolumnar.stripe_row_limit = 20000; gb written under the default. Geometry read from the catalog, R read from an instrumented build that logs the penalty's own inputs:

table  storage.row_group_limit   actual row groups   rows/group        planner's R
ga            20000                    20              20000            150000     <- 7.5x wrong
gb           150000                     3         100000/150000         150000     <- agrees

gb is the control: where the write-time and plan-time values coincide, R is right. Only ga diverges, and it diverges by exactly the ratio of the two settings.

Why it matters

R is not a minor term. In pgcolumnar_index_fetch_penalty (src/columnar_customscan.c:1850) it drives four things at once:

R            = pgcolumnar_effective_stripe_row_limit(relid);
n_groups     = ceil(N / R);
pages_per_stripe = ceil(rel->pages / n_groups);
decode_per_group = seq_page_cost * pages_per_stripe + cpu_operator_cost * R * nproj;
groups_min   = ceil(rows / R);
decoded_width = decodedWidth * R;      /* the 32 MB fetch-cache cap test */

With R 7.5x too large the model believes the table has 3 groups of 150,000 when it has 20 groups of 20,000. It overstates the per-group decode CPU by 7.5x, understates the group count by 7.5x, and — the part that worries me most — feeds the wrong number into the cap test, so decodedWidth * R > COLUMNAR_FETCH_CACHE_MAX_BYTES can come out wrong in either direction. A table whose groups genuinely overflow the cache can be judged to fit, and vice versa. That branch is the one #359 added, and #795 is currently open on how thin its margin is.

The same call site pattern is at src/columnar_customscan.c:2193.

The value is already there

storage.row_group_limit is populated on write (src/columnar_metadata.c:1872, from writeState->stripeRowLimit) and, on a grep of src/, is read by nothing:

src/columnar_metadata.c:69     #define Anum_native_storage_row_group_limit 5
src/columnar_metadata.c:1872   values[...] = Int32GetDatum(s->rowGroupLimit);      <- written
src/columnar_write_state.c:684 s.rowGroupLimit = stripeRowLimit;                   <- written
src/columnar_write_state.c:2765 s.rowGroupLimit = writeState->stripeRowLimit;      <- written
src/columnar.h:255             int rowGroupLimit;

This is the same class as #7/#11, one level deeper

pgcolumnar_effective_stripe_row_limit's own comment says why it exists:

The writer honors the per-table override, so every consumer that reasons about how many row groups a table has (the writer, the zone-map survival estimate, the index-fetch cost penalty) must read the same effective value. Reading only the GUC mis-sizes the group count for a table that set the option (#7 / #11).

That fixed option versus GUC. It does not fix written versus current: the writer read the value in force at write time, and the planner reads the value in force now. They are the same function and still disagree whenever the setting changed in between — which includes the ordinary case of a session that sets the GUC to load a table and a later session that queries it.

What I am not claiming

  • No measured plan change. I have not shown a query that picks the wrong plan because of this; I stopped at proving the input is wrong. The 7.5x is in the model's input, not in a demonstrated regression.
  • Whether a single scalar is even the right thing to read is a design question. gb shows one table holding groups of 100,000 and 150,000, so no single R describes an existing table exactly. storage.row_group_limit is at least the value the writer used, which is strictly closer than a setting from an unrelated session.

Reproduction

SET pgcolumnar.stripe_row_limit = 20000;
CREATE TABLE ga (id int, scat int, c1 int, c2 int) USING pgcolumnar;
INSERT INTO ga SELECT g, (g*2654435761::bigint % 1000000)::int, g, g+1
  FROM generate_series(1,400000) g;

-- geometry as written: 20 groups of 20,000, limit recorded as 20000
SELECT s.row_group_limit, count(rg.*), min(rg.row_count), max(rg.row_count)
  FROM pgcolumnar.storage s JOIN pgcolumnar.row_group rg USING (storage_id)
 WHERE s.relation_oid = 'ga'::regclass GROUP BY s.row_group_limit;

Then plan any index-fetching query on ga from a session at the default and the penalty is computed with R = 150000. Reading R requires instrumentation; I used a build that logs the penalty's inputs (.so 3119b5c50c59, up/main 35846a1 plus one elog), which is not a build to draw any timing conclusion from.


Filed by OffgridwithJD. Container pgcolumnar-audit, PostgreSQL 18.4 assert build. Geometry from the pgcolumnar.storage / pgcolumnar.row_group catalogs; R from the instrumented build named above, with gb as the agreeing control.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions