Bound the group estimate when the planner had nothing to estimate from (#369) - #443
Conversation
…e from (#369) estimate_num_groups cannot see through a function, so for date_trunc('minute', ts) it falls back to the timestamp column's ndistinct: 19,996,000 against 720 actual, 27,772x. That number is charged TWICE on the parallel arm and not at all on the serial node. The Gather pays parallel_tuple_cost * rows and the Finalize pays per-group terms, while the serial node is priced per input row and is immune. So the serial node won by construction on exactly the shapes where the arm is faster, which is why raising the serial node's price could not fix it: at a faithful cost_agg rate that node ties core's HashAggregate inside STD_FUZZ_FACTOR and is discarded on the parallel_safe tie-break, deleting the feature instead. The gate is core's own test for whether an estimate is informed: examine_variable reporting a statsTuple or a unique index. A plain Var, an expression index, and a user's CREATE STATISTICS ON (expr) all qualify and are left untouched. Only when nothing is informed do we substitute, and only downward, so a shape we misjudge is priced no worse than today. The bound itself handles one shape deliberately: a truncating time function over a single Var, whose distinct outputs cannot exceed the buckets its input range spans, plus one partial bucket at each end. The range comes only from Const btree comparisons on that Var in baserestrictinfo, which describe the rows actually scanned. month, quarter and year use their shortest possible length so the count is over-estimated rather than under; this must remain an upper bound, because under-estimating would under-price the arm and under-size the Finalize's hash table. Measured on 20M rows, same fixture, same box: main with the bound G1 serial, est 19,996,000, 2017 ms PARALLEL, est 722, 497 ms G2 serial, est 19,996,000, 4687 ms PARALLEL, est 722, 1146 ms G3 PARALLEL, est 3998, 409 ms PARALLEL, est 3998, 401 ms 722 is floor(12h / 1min) + 2. G3 is the detector for the gate leaking into the informed case and its estimate is bit-identical, which is what was asserted rather than "still fast". Coverage in native_groupagg.sh asserts the bound is accurate rather than merely smaller, that it is an upper bound, that an informed estimate is not replaced, and that a key with no range bound and a non-time expression key both get nothing from us. 71 checks there; ungrouped_vector_agg, parallel_vector_agg, native_agg, parallel and pushdown_report unchanged. Builds clean on 15 to 19.
ChronicallyJD
left a comment
There was a problem hiding this comment.
The bound is applied to functions that are not date_trunc, and then it is not a bound
I went at this the way you asked. The design holds up: the gate is core's own test, the
substitution is only ever downward, hi <= lo is guarded so contradictory predicates cannot
produce a negative bound, both sides are required so an open-ended query gets nothing from
you, and the month/quarter/year widths are minimums (28/89/365 days) so those over-count
buckets, which is the safe direction. I could not break any of that.
I did break the premise the whole argument rests on.
f->funcid is never checked. pgcolumnar_truncating_time_bound matches on shape alone:
a FuncExpr of two or three args, a text Const first, a timestamp Var second. date_trunc
has that shape. So does any user function with the signature (text, timestamp).
Measured on your branch (8b02bd3), pg18a assert build, 500k rows across a 12h window:
| group key | actual groups | estimate, feature on | estimate, feature off |
|---|---|---|---|
date_trunc('minute',ts) |
720 | 722 | 499,900 |
hi_card('minute',ts) |
499,999 | 722 | 499,900 |
hi_card is deliberately not a truncating function, and PL/pgSQL because an SQL function
would be inlined and lose the shape:
CREATE FUNCTION hi_card(t text, s timestamp) RETURNS text AS
$$ BEGIN RETURN t || md5(s::text); END $$ LANGUAGE plpgsql IMMUTABLE;Custom Scan (PgColumnarScan) (cost=138742.76..138742.76 rows=722 width=40) (actual rows=499999.00 loops=1)
722 against 499,999 actual. A 692x underestimate, and core had it very nearly right at
499,900 before the substitution replaced it.
What it costs, interleaved so the first arm is not just the cold one
| round | feature on | feature off |
|---|---|---|
| 1 | 1328.2 ms | 1139.9 ms |
| 2 | 1309.0 ms | 1133.5 ms |
| 3 | 1350.9 ms | 1130.9 ms |
About 16% slower, consistently. Read that as "the plan this estimate wins" against "the plan
it displaces", not as a pure A/B of the bound, because the off arm disables the vectorized
aggregate entirely. Core's HashAggregate even spills here (9 batches, 31 MB disk) and is
still ahead.
I also went looking for something worse than a bad price, since a node costed for 722 groups
that receives 499,999 is the shape that blows up a pre-sized hash table. I did not find it.
Our node returned the right 499,999 rows and did not fail. So this is a mispricing, not a
correctness or memory bug.
Why I am not calling it urgent
The first argument has to be one of your unit names for the width lookup to succeed, so
myfunc('x', ts) gets no bound and only myfunc('minute', ts) does. That narrows real
exposure a long way. My first version of this probe used 'x', found nothing, and would have
told you the code was clean. Worth knowing if you reproduce it.
It still needs fixing, because the function claims an upper bound it has not established, and
the fix is one line against the date_trunc OIDs. You already include utils/fmgroids.h.
Smaller, and reasoned rather than measured
c->consttype may be TIMESTAMPOID or TIMESTAMPTZOID regardless of tsvar->vartype, and
the raw datum is read with DatumGetTimestamp either way. When the two differ, PostgreSQL
converts using the session TimeZone at runtime, so the two int64s are not on one scale and
hi - lo can come out short by up to the UTC offset. Short range means short bound. I did
not measure this, and the harness pins timezone='UTC', which would hide it. Requiring
c->consttype == tsvar->vartype costs nothing.
One thing I did not re-verify
I did not reproduce G3. Your bit-identical claim for the plain-column key is the right
detector and I have no reason to doubt it, but it is not something I checked, so do not read
this review as confirming it.
@ChronicallyJD broke the premise the whole argument rests on. The bound matched a FuncExpr of two or three args with a text Const and a timestamp Var, which is date_trunc's shape and also the shape of any user function declared (text, timestamp). f->funcid was never checked. Their reproduction, a deliberately high-cardinality lookalike: CREATE FUNCTION hi_card(t text, s timestamp) RETURNS text AS $$ BEGIN RETURN t || md5(s::text); END $$ LANGUAGE plpgsql IMMUTABLE; group key actual estimate before estimate after date_trunc('minute',ts) 720 722 722 hi_card('minute',ts) 499,999 722 499,900 722 against 499,999 is a 692x UNDER-estimate, and core had it very nearly right at 499,900 before the substitution replaced it. Under-estimating is the direction that does damage: it under-prices the arm and under-sizes the Finalize's hash table into avoidable spill, which is the opposite of the point. Now matched on funcid: F_DATE_TRUNC_TEXT_TIMESTAMP, F_DATE_TRUNC_TEXT_TIMESTAMPTZ and the three-argument timezone form. Identical oids on 15 through 19. The interval form is excluded deliberately, because its argument is a duration rather than a point in time, so a range over it does not describe a scanned window. native_groupagg.sh gains the lookalike as a permanent guard, in PL/pgSQL because an SQL function would be inlined and lose the shape that made the bug reachable. 72 checks.
You broke the premise the whole argument rests on, and you are right.
Reproduced your finding and the fix on the same fixture:
722 against 499,999. And the part that makes it worse than a missed optimisation: core had Now matched on The interval form is excluded deliberately, and I want that on the record rather than Your lookalike is now a permanent guard in On the 16 percentNoted, and I agree with your reading that it is "the plan this estimate wins" against "the That leaves the measured G1/G2 case as the only one the bound changes, which is what I |
|
The funcid fix is right, and putting the lookalike in The second one was not fixed, and I said it was unmeasured, so here it is measured
On your fixed branch
The planner kept it as a bare Const, which is the step the concern depends on: The gap is not approximately right, it is exactly right. +05:30 is 330 minutes, your slack is Same direction as the funcid bug, and same reason it matters more than a missed optimisation: One line, as before: if (c->constisnull || c->consttype != tsvar->vartype)
continue;With that, the mixed predicate contributes no bound, I am leaving the review at changes-requested for that one line only. Everything else here I |
…#369) @ChronicallyJD measured the case I listed as untested in the PR description. PostgreSQL has cross-type operators for timestamp and timestamptz, so a mixed predicate keeps a BARE Const and reaches the extraction rather than being wrapped in a cast. Both are int64 microseconds and DatumGetTimestamp reads either, but they are on different scales: a naive timestamp is wall clock, a timestamptz is UTC. Under a non-UTC TimeZone the range was computed across that offset. Now requires c->consttype == tsvar->vartype. A mixed predicate gets no bound, which is always safe: this is an optimisation, and converting between the scales is possible but not worth it for the case it serves. Measured under Asia/Kolkata (+05:30) with a fixture chosen so the two outcomes cannot be confused, because my first attempt used 720 rows where core's own estimate is already near the bound and "no bound" looked identical to "correct bound": predicate actual estimate mixed timestamp/timestamptz 390 274,949 (core's, no bound) matching timestamptz 720 722 (bound applied) Both guards are now permanent in native_groupagg.sh, with the discriminating fixture rather than the small one. 74 checks.
Fixed. And my first attempt to verify it could not have told the difference.You measured the case I listed as untested in the PR description, which is the right thing
The part worth reporting is my instrument, not the fixMy first verification used the 720-row fixture from the issue. Result: Which looks like a pass. It is not evidence of anything: at 720 rows core's own estimate is With a fixture where core's estimate is six figures: Now the two outcomes cannot be confused. Both guards are permanent in That is the third time today I have caught myself with a check that would have passed |
ChronicallyJD
left a comment
There was a problem hiding this comment.
Verified on 4020b45, same fixture and box as the finding, with a premise check that the guard
is actually in the tree I built:
| predicate | actual | estimate before | estimate now |
|---|---|---|---|
mixed timestamp / timestamptz bounds |
1050 | 722 | 1050 |
both timestamptz (control) |
1050 | 1050 | 1050 |
The underestimate is gone. Approving.
One caveat on my instrument, by your own argument. My fixture has 1050 rows with 1050
distinct minutes, so core's estimate and a correctly-applied bound both land near 1050. It
proves the 722 is gone; it does not discriminate "no bound applied" from "bound applied
correctly". That is the same weakness you just described in your 720-row attempt, and I did not
notice it in mine until I read your comment. Your discriminating fixture in native_groupagg.sh
is what actually covers this, not my probe.
Agreed on not converting between the scales. No bound is always safe and this is an
optimisation, so declining to guess at a timezone is the right call.
On the tally
Three today, and the pattern is the same in both directions: the defects were in claims that had
been written down and not run, and the near-misses were checks that would have passed either
way. Mine included. PGC_SKIP_TIMING was documented in a comment and wired to nothing, and I
shipped it.
What seems to actually work is not being more careful. It is that the person who did not write
the claim is the one who tests it.
Closes #369. @ChronicallyJD for review, and this one has a long history of me being wrong
about it, so please be as hard on it as you were on #441.
The measurement, same fixture and box, both arms
722 is
floor(12h / 1min) + 2. Actual is 720.G3's estimate is bit-identical, which is what I said I would assert rather than "still
fast". It is the detector for the gate leaking into the informed case, and it did not move
by a digit.
Why this and not the obvious fix
Raising the serial node's price cannot work, and the reason is worth having on the record
because it was my own first proposal. At a faithful
cost_aggrate that node lands at~578,578, which ties core's HashAggregate inside
STD_FUZZ_FACTOR, andadd_path'stie-break then prefers the parallel-safe path. Ours sets
parallel_safe = false. Socharging the serial node honestly does not flip G1 to our arm, it deletes the feature.
The inflated number is charged twice on the parallel arm, by the Gather as
parallel_tuple_cost * rowsand again by the Finalize, and not at all on the serialnode, which is priced per input row. Attacking the estimate fixes both terms at once.
The gate is core's own test, not one I invented
examine_variablereporting astatsTupleor a unique index. That is exactly whatclausesel.cuses to decide whether an expression estimate is informed. It means:Varis informed, so G3 never reaches the substitutionCREATE STATISTICS ON (date_trunc(...))is informed and outranks usand only when nothing is informed do we substitute, only ever downward via
Min.The bound is deliberately narrow
One shape: a truncating time function over a single
Var. Its distinct outputs cannotexceed the buckets its input range spans, plus one partial at each end. The range comes
only from
Constbtree comparisons on that sameVarinbaserestrictinfo, whichdescribe the rows this scan will actually read.
month,quarterandyearuse their shortest possible length, so the count isover-estimated rather than under. That direction matters: an under-estimate would
under-price the arm and under-size the Finalize's hash table into avoidable spill.
Coverage
native_groupagg.sh, 71 checks. The five new ones assert the bound is accurate ratherthan merely smaller (12 for an 11-bucket window), that it really is an upper bound, that an
informed estimate is not replaced, and that both a key with no range bound and a non-time
expression key get nothing from us.
ungrouped_vector_agg,parallel_vector_agg,native_agg,parallelandpushdown_reportunchanged. Builds clean on 15, 16, 17, 18 and 19.What I would attack if I were reviewing
USECS_PER_*arithmetic and the shortest-length choice formonth/quarter/year.If any of those is wrong in the under-estimating direction, the bound stops being a
bound.
examine_variablecan report informed for something I have not thought of, whichwould silently disable the fix rather than break it. That fails safe, but quietly.
Constextraction handles atimestampagainst atimestamptzboundcorrectly. I only test matching types.