Place histogram bounds at core's positions, and count null_frac over live rows (#414, #485) - #488
Conversation
…core's positions, and count null_frac over live rows
Two defects in pgcolumnar.analyze(), both found at the same seam -- call the
function, read pg_stats -- and both of the shape this function keeps producing:
a plausible value, written successfully, with nothing raising.
1. histogram_bounds were quantiles, not core's positions
---------------------------------------------------------
core's compute_scalar_stats places bound i at
values[floor(i * (nvals - 1) / (num_hist - 1))]
among the rows left after the most-common values are removed. percentile_disc
resolves a fraction p to index ceil(p * nv) - 1, which is a different index
whenever frac(i*nv/nfrac) is small, and a different VALUE whenever that shift
crosses a value boundary.
So ask percentile_disc for the fractions that land on core's positions rather
than for evenly spaced quantiles:
p_i = (floor(i * (nv - 1) / nfrac) + 0.5) / nv
The half is load-bearing. The exact boundary (T+1)/nv is a double, and nv in the
millions leaves ~1e-9 of slack in p*nv; landing a hair above T+1 makes ceil()
return T+2 and take the next value. Half a row of margin cannot be crossed by
that error.
nv -- the rows the histogram is actually placed over -- is now returned by the
most-common aggregation, which is split into the full group and its most-common
slice so it can report how many ROWS each covers. Taking nv from totalrows minus
a zone-map null_frac would have put a rounded float in a position index.
2. null_frac counted rows a DELETE had already removed (commandprompt#485)
-------------------------------------------------------------
null_frac came from the zone maps, which count what was WRITTEN. Deleting a row
marks it dead without rewriting those counts, so the denominator kept counting
rows the table no longer held. On 1,000 rows with 100 nulls, deleting the 301
rows holding one value leaves a true null_frac of 0.1431 and a zone-map
null_frac of 0.1000 -- 30% low, and VACUUM does not heal it.
The size of the error is not the worst of it. null_frac came from the zone maps
while the most-common frequencies came from count(*), so one pg_stats row
carried two statistics normalised against different populations:
null_frac 0.100000 implies a 1,200-row table
most_common_freqs[1] 0.2667 implies a 900-row table
rows actually present 900
null_frac + sum(mcv_freqs) + rest = 1 stopped holding, and eqsel subtracts both
to price everything else.
The null count now comes from the read the function was already doing --
count(*) was in that query already, so this is one more aggregate over a scan
that happens either way. The zone maps keep the one job they can still do
exactly: telling us whether the column has any row groups at all.
Tests
-----
Eight checks in test/analyze_function.sh, at the public seam.
The existing 500,000-row fixtures cannot show defect 1: with many rows per
distinct value a one-row shift lands on the same value, so both algorithms agree
and a check would pass either way. Eleven distinct rows at a statistics target of
3 separate them -- stride takes position 6, percentile_disc position 7.
The expected bounds are not taken from the implementation. They are computed by
an independent SQL oracle from core's formula over row_number(), and separately
worked by hand ({1,4,7,11}); a premise check asserts those two agree before
either judges the code.
Verified to discriminate, rather than assumed to: run against stock origin/main
the new checks fail 3 of 8 -- exactly the three behavioural ones, with all five
premise checks still passing, so the premises are not doing the work.
new tests + this change 46 checks, PASSED
new tests + origin/main 46 checks, FAILED (3)
analyze_function 46, analyze_differential 26, analyze_stats 27, all green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRQYekvivA4RLDnndhanHK
…es from the zone maps Found by trial-merging commandprompt#490, which touches the same file: the two merge cleanly and neither owns this line, so the merged suite would have carried a header contradicting its own check. The claim is invalidated by this branch, so it is this branch's to fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QRQYekvivA4RLDnndhanHK
|
Not a review yet, just clearing a hazard so the review is about the code. Your green predates two merges into the file you touch. This PR's CI ran at 18:52:28 on
I merged it locally and ran it, so it is answered rather than assumed:
So there is nothing to rebase for and nothing of mine that your change breaks. On splitting: my instinct is not to. #485 is the one with a user-visible wrong answer, so there is a real argument for landing it alone and fast, but the two are green together, they are reviewed together, and splitting now costs a full CI round plus a second review for no evidence gained. If you have a reason to want #485 in isolation that I cannot see from here, say so and I will take it that way. I will come back with an actual review of the substance. The two parts I want to spend time on are the changed |
jdatcmd
left a comment
There was a problem hiding this comment.
Both claims check out. I verified each against a source independent of your description rather than agreeing with the reasoning, and one of them I can now put a number on that you did not have.
1. Core's placement formula: confirmed against analyze.c
compute_scalar_stats does not write the closed form, it accumulates:
delta = (nvals - 1) / (num_hist - 1);
deltafrac = (nvals - 1) % (num_hist - 1);
pos = posfrac = 0;
for (i = 0; i < num_hist; i++) {
hist_values[i] = values[pos].value;
pos += delta; posfrac += deltafrac;
if (posfrac >= (num_hist - 1)) { pos++; posfrac -= (num_hist - 1); }
}That is Bresenham for values[(i * (nvals - 1)) / (num_hist - 1)] with C integer division, and core's own comment above the loop states exactly that closed form and says the accumulation exists only to avoid integer overflow at large stats targets. So floor(i * (nvals - 1) / (num_hist - 1)) is right, including that it is floor rather than round.
Two consequences worth recording, both of which your code already gets right:
nvalsthere is rows remaining after MCV removal, not distinct values, which is why yournvhas to be a row count and why deriving it from a zone-mapnull_fracwould have been wrong for a second reason beyond the rounded float.- Your
numericfloor and core's C integer division agree. A ratio of integers can only sit within 1e-20 of an integer if it IS that integer, so numeric's rounding cannot cross a boundary that C truncation does not.
2. percentile_disc's index rule: confirmed, and the + 0.5 is load-bearing
Your PR uses the array form, so the code that runs is percentile_disc_multi_final -> setup_pct_info, which for the discrete case does:
int64 row = (int64) ceil(p * rowcount);
row = Max(1, row);
pct_info[i].first_row = row; /* 1-based */0-based index ceil(p*nv) - 1, as you said. So landing on T requires p*nv in (T, T+1]: (T+1)/nv is the upper endpoint, (T+0.5)/nv the midpoint.
I did not want to take "the margin is needed" on the argument, so I measured the failure rate. Computing both forms in float8 exactly as the server does, over every T for each nv:
nv |
naive (T+1)/nv wrong |
(T+0.5)/nv wrong |
|---|---|---|
| 999 | 6 | 0 |
| 1000 | 0 | 0 |
| 4096 | 0 | 0 |
| 10007 | 948 | 0 |
| 100000 | 5336 | 0 |
| 999983 | 11571 | 0 |
| 1000000 | 11555 | 0 |
| 4000037 | 46205 | 0 |
About 1 bound in 87 at a million rows, zero for yours at every size tested. And end to end through the aggregate itself, on the first failing case the sweep found:
nv = 1000000, target index T = 122
percentile_disc((122+1)/1000000) -> 123 wrong
percentile_disc((122+0.5)/1000000) -> 122 right
So the half is not defensive, it is the difference between correct and about one percent wrong, and the round-number cases (1000, 4096) are exactly the ones that would have made a hand-check of the naive form look fine. Worth having that table in the comment, or in the commit message, so nobody later reads + 0.5 as noise and simplifies it away.
Verdict
Approving. Not splitting, per my earlier comment, and I have nothing to add on the null_frac half beyond what I posted: the merge with current main is clean and green on PG18 and PG19, 50 checks each.
The one thing I would still like written down somewhere, and it does not block this: the histogram change alters an emitted value the planner consumes. Endpoints are unchanged so #414's exactness claim holds, but the CHANGELOG entry should say plainly that interior bounds can move by one position, so that anyone diffing pg_stats across an upgrade knows it is intended.
…er, 443 lines up
Review catch. The header comment at :985 said
null_frac from the zone maps (metadata only, no data read)
which is a stronger claim than the COMMENT ON FUNCTION this branch already
fixed: it does not merely name the source, it promises no data read. After commandprompt#488
that read always happens.
It survived my own sweep because it wraps across two lines, so a single-line
grep for the phrase cannot match it. Re-swept the whole tree with a multi-line
pattern; the only remaining occurrences are past-tense history in this file and
in test/analyze_function.sh, plus commandprompt#488's note at :1352 explaining why nv is NOT
derived from a zone-map null_frac, which is correct and stays.
Rewritten to lead with the property rather than the source: all of it exact,
all of it from ONE read, which is what makes every statistic describe one
population.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRQYekvivA4RLDnndhanHK
…the zone maps #488 moved null_frac off the zone maps and onto the same read as n_distinct, because the zone-map counts describe what was written and a DELETE left the fraction normalised against rows the table no longer held. It did not update the COMMENT ON FUNCTION, which still tells the user the opposite: taking null_frac exactly from the zone maps rather than sampling That is my own omission from #488. It is user-visible through \df+ and through the extension script, and it is the kind of stale claim that is believed because it is adjacent to correct code. The replacement says what is now true and adds the property that matters more than the source: null_frac, n_distinct and the most-common frequencies all come from one read, so they describe one population. That identity is what #485 was actually about. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QRQYekvivA4RLDnndhanHK
Closes #485. Follows on from #475 and #476.
Two defects in
pgcolumnar.analyze(), both found at the same seam — call the function, readpg_stats— and both of the shape this function keeps producing: a plausible value, written successfully, with nothing raising.These are separable and I will split them if you would rather. They land together because they touch adjacent lines of the same loop and the same suite, and because the second one needs a row count the first one already computes. #485 is the one with a user-visible wrong answer today, so if you want it alone and fast, say so and I will send it on its own.
1.
histogram_boundswere quantiles, not core's positionscompute_scalar_statsplaces bound i atamong the rows left after the most-common values are removed.
percentile_discresolves a fraction p to indexceil(p * nv) - 1— a different index wheneverfrac(i*nv/nfrac)is small, and a different value whenever that shift crosses a value boundary.So the fix asks
percentile_discfor the fractions that land on core's positions rather than for evenly spaced quantiles:The half is load-bearing rather than decorative. The exact boundary
(T+1)/nvis a double, andnvin the millions leaves roughly 1e-9 of slack inp*nv; landing a hair aboveT+1makesceil()returnT+2and take the next value. Half a row of margin cannot be crossed by that error.nv— the population the histogram is actually placed over — now comes back from the most-common aggregation, which is split into the full group and its most-common slice so it can report how many rows each covers. Derivingnvastotalrowsminus a zone-mapnull_fracwould have put a rounded float in a position index.This changes the emitted array. Same length, same endpoints (position 0 is still the true minimum and
nv-1the true maximum, so #414's exactness claim is untouched); interior bounds can shift by one position. Both forms are valid equi-depth histograms — the case for core's is that it is what the planner's selectivity estimators were tuned against, and that it is the form computable from grouped data, which is where the follow-on performance work goes.2.
null_fraccounted rows a DELETE had already removed (#485)It came from the zone maps, which count what was written. Deleting a row marks it dead without rewriting those counts, so the denominator kept counting rows the table no longer held. On 1,000 rows with 100 nulls, deleting the 301 rows holding one value:
VACUUMThe size of the error is not the worst of it.
null_fraccame from the zone maps while the most-common frequencies came fromcount(*), so onepg_statsrow carried two statistics normalised against different populations:null_fracmost_common_freqs[1]null_frac + sum(mcv_freqs) + rest = 1stopped holding, andeqselsubtracts both to price everything else.The null count now comes from the read the function was already doing —
count(*)was in that query already, so this is one more aggregate over a scan that happens either way. The zone maps keep the one job they can still do exactly: telling us whether the column has any row groups at all.This gives up "null_frac is a metadata read". That was #414 slice 1's stated selling point and it is worth saying out loud rather than deleting quietly. It cost nothing here because the function never collects
null_fracalone — it always goes on to read the column forn_distinct. If you want a metadata-only fast path later, the honest version needs a live-row count, which is the same scan.The wider question — whether
pgcolumnar.zone_map's counts should account for the delete vector, which would touch pruning too — is real and is not addressed here.Tests
Eight checks in
test/analyze_function.sh, at the public seam.The existing 500,000-row fixtures cannot show defect 1: with many rows per distinct value a one-row shift lands on the same value, so both algorithms agree and a check would pass either way. Eleven distinct rows at a statistics target of 3 separate them — stride takes position 6,
percentile_discposition 7.The expected bounds are not taken from the implementation. They are computed by an independent SQL oracle from core's formula over
row_number(), and separately worked by hand ({1,4,7,11}); a premise check asserts those two agree with each other before either judges the code.The checks were verified to discriminate, not assumed to
Running the new tests against stock
origin/main:Exactly the three behavioural checks fail, and all five premise checks still pass in both arms — so the premises are describing the fixture rather than doing the discriminating.
Gate
analyze_functionanalyze_differentialanalyze_statsmake cleanbetween the two majors — without itmakerelinks the previous major's objects and the PG19.sostill referencesget_relation_info_hook, which PG19 removed in favour ofbuild_simple_rel_hook. The version guards incolumnar_tableam.care correct andtest/run_all_versions.shcleans per major (line 556), so the project's gate does not have this hole; an ad-hoc single-suite run across majors in one tree does. Confirmed the PG19.soby symbol before trusting the run.Interaction with #487
#487 is about the flaky premise above check 1. This PR renames that check — it now reads "reports
null_fracexactly, from reading the column" rather than "from the zone maps", because the mechanism it named is gone. It does not touch the flaky premise itself, so #487 stands as filed and the two will conflict on that region if both are in flight. Happy to rebase on whichever lands first.Not in this PR
The performance work that started this. Main aggregates the same column twice —
count(DISTINCT)and then aGROUP BYfor the most-common list — and the grouped pass already holds both answers. That is behaviour-preserving, so it has no failing test to drive it, and it belongs in its own PR with the measurement rather than smuggled in beside two correctness fixes.