Skip to content

fix: a profile must not measure the assert build or its own fixture (#445) - #504

Merged
jdatcmd merged 1 commit into
mainfrom
fix/profile-harness-artifacts
Aug 8, 2026
Merged

fix: a profile must not measure the assert build or its own fixture (#445)#504
jdatcmd merged 1 commit into
mainfrom
fix/profile-harness-artifacts

Conversation

@jdatcmd

@jdatcmd jdatcmd commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Two artifacts in bench/run_profile.sh, found while profiling the load for #445. Both are the class that script already exists to prevent: output that looks like data.

1. An assert build is not a slower version of the same profile

--enable-cassert compiles in validation production never runs, and it does not distribute evenly. Profiled against the assert PG18 on this box, the top of the ingest profile was:

9.28%  postgres  verify_compact_attribute

The largest single self-time entry is a function that does not exist in a production build, with every other percentage diluted against it. A reader would reasonably have gone looking at tuple descriptor validation.

That is the same category as an unopenable perf event, which this script already refuses on the grounds that "profiling would report an empty profile as data". So it gets the same treatment: refuse, with PROFILE_ALLOW_CASSERT=1 to override, since an assert build is still worth profiling when it is the only one to hand and two assert builds compare fine with each other. The build is now printed with the event and the unwind method, for exactly the reason those are.

2. The ingest shape profiled its own data generator

INSERT INTO pw SELECT g, md5(g::text) FROM generate_series(1,200000) g

put md5_calc at 12.31% and pg_md5_hash at 1.65%. About 14% of an "ingest" measurement was the fixture inventing strings inside the timed statement, inflating the denominator every write-path entry was measured against. Rows are now materialised into a heap table first, outside the profiled statement, which is also closer to what a load is: rows arriving from somewhere rather than computed per row by the insert.

Proved by running it

  • assert build now exits 1 with a FATAL naming the measured cost (checked the exit code, not the grep's)
  • PROFILE_ALLOW_CASSERT=1 lets it through and labels every percentage, and the tail prints Build: ASSERT (--enable-cassert)
  • on a non-assert build md5_calc leaves the profile entirely

Why this matters immediately

With both artifacts removed, the ingest profile is a different document, and the #472 result is far larger than I had reported:

main with #472 (#502)
encode_fsst_shared 43.22% 11.36%
PgColumnarFsstBuildChunkTable 19.22% 4.99%
fsst_count_add 4.66% below cut
fsst_cand_cmp 2.91% below cut
FSST total ~70% ~16%

I had posted 48% to 11.7% on #445 from measurements taken through both artifacts. Same direction, wrong size, and the corrected figures are on that issue.

#499's own 33.9% and 14.6% were taken the same way, so they are directionally right and quantitatively off. Noted in the commit rather than left to be rediscovered.

🤖 Generated with Claude Code

…445)

Two artifacts in bench/run_profile.sh, both found while profiling the load for
#445, and both of the class the script already exists to prevent: output that
looks like data.

AN ASSERT BUILD IS NOT A SLOWER VERSION OF THE SAME PROFILE. --enable-cassert
compiles in validation production never runs, and it does not distribute evenly.
Profiled against the assert PG18 here, the largest single self-time entry of the
ingest shape was verify_compact_attribute at 9.28%, a function that does not
exist in a production build, with every other percentage diluted against it. A
reader would reasonably have gone looking at tuple descriptor validation.

That is the same category as an unopenable perf event, which this script already
refuses on the grounds that profiling would report an empty profile as data, so
it gets the same treatment: refuse, with PROFILE_ALLOW_CASSERT=1 to override,
because an assert build is still worth profiling when it is the only one to hand
and two assert builds compare fine with each other. The build is printed with
the event and the unwind method, for the same reason those are.

THE INGEST SHAPE PROFILED ITS OWN DATA GENERATOR. It was

    INSERT INTO pw SELECT g, md5(g::text) FROM generate_series(1,200000) g

so md5_calc took 12.31% and pg_md5_hash 1.65%: about 14% of an "ingest"
measurement was the fixture inventing strings inside the timed statement,
inflating the denominator every write-path entry was measured against. The rows
are now materialised into a heap table first, outside the profiled statement,
which is also closer to what a load is: rows arriving from somewhere rather than
computed per row by the insert.

Proved by running it: the assert build now exits 1 with a FATAL naming the
measured cost, PROFILE_ALLOW_CASSERT=1 lets it through and labels every
percentage, and on a non-assert build md5_calc leaves the profile entirely.

The numbers in #499 were taken through both artifacts, so its 33.9% and 14.6%
are directionally right and quantitatively off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@ChronicallyJD ChronicallyJD left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. Both are real, both are my defects, and the second one invalidates numbers I published.

The assert-build argument is the one I should have made myself. The script already refuses an unopenable perf event on the grounds that it "would report an empty profile as data", and then does not apply that same standard to the build it is profiling. verify_compact_attribute at 9.28% self time — the largest single entry, a function that does not exist in production, diluting every other percentage — is exactly the failure the rest of the script is built around. Refusing with an override, and printing the build alongside the event and the unwind method, is the consistent treatment.

The fixture defect is worse and it is mine. INSERT ... SELECT md5(g::text) FROM generate_series(...) put the data generator inside the timed statement, so about 14% of an "ingest" profile was the fixture inventing strings. I chose that shape for convenience and never asked what was in the denominator.

So #499's ingest figures are wrong and I have quoted them repeatedly — on #472 when sizing the cache, and in the benchmark report. Directionally right, quantitatively off, and the corrected ~70% makes the case for #502 stronger than the number I gave it. I will correct both.

Checked

  • grep -c 'enable-cassert' does not false-positive on --disable-cassert: "disable" is d-i-s-a-b-l-e, so enable-cassert is not a substring of it. A build configured with an explicit --disable-cassert is correctly read as non-assert.
  • grep -c prints 0 on no match and the || true absorbs its exit 1 under pipefail, so CASSERT is always a number and the != "0" test cannot fall through on an empty string.
  • The override warns per run and the tail labels the build, so a saved profile carries its own caveat rather than depending on someone remembering.

One residual, and one new interaction

The heap source is now in the denominator. Reading 200,000 rows from pw_src is far cheaper than computing md5 per row, and "rows arriving from somewhere" is the right model, so this is a large improvement rather than a trade. Worth one clause in the comment that the source scan is still measured, so the next person does not rediscover it as a third artifact.

More interesting: once #502 lands, the ingest number stops being window-independent. pw is never truncated between loop iterations, so each pass appends another ~200,000 rows and the table grows for the whole window. That is harmless today. With the FSST verdict cache it is not: the first row groups pay for the decision and later ones reuse it, so the measured FSST share falls as the window lengthens, and PROFILE_SECS becomes an input to the answer.

That does not affect your before/after here — both arms ran the same window, which is why the comparison holds. It affects anyone who later profiles ingest at a different PROFILE_SECS and compares against these numbers.

Cheapest fix is a TRUNCATE pw inside the loop before each insert, so every iteration profiles a load starting from the same state; that also removes the growing-relation effect on row-group flushing. It does put a truncate in the timed statement, which is a smaller artifact than the one being removed but is still one, so the alternative is to leave it and say in the comment that the ingest shape measures a sustained load rather than a cold one, and that its FSST share is window-dependent once the cache exists.

Either is fine. Silently window-dependent is the thing to avoid, and it is not obvious from reading the script.

On the correction itself

Recording that #499's numbers were taken through both artifacts, in the commit rather than leaving it to be rediscovered, is the right call. I would rather have my published figures corrected in the open than quietly superseded, and the same applies to the 48% to 11.7% on #445.

@jdatcmd
jdatcmd merged commit 44eeba3 into main Aug 8, 2026
11 checks passed
@jdatcmd
jdatcmd deleted the fix/profile-harness-artifacts branch August 8, 2026 12:51
ChronicallyJD pushed a commit to ChronicallyJD/pgcolumnar that referenced this pull request Aug 9, 2026
…mmandprompt#505)

Every bench harness assumed an environment that existed only because someone
built it by hand. The knowledge was spread across memories, a HANDOFF.md that is
deliberately not in the repository, and issue comments, so a second machine --
or this one after a rebuild -- could not reproduce a number.

This is the first half: the PostgreSQL builds, the contrib the suites need, and
an audit that says whether a box can run bench/ at all. It does NOT install
Citus, TimescaleDB, DuckDB or the ClickBench dataset, and says so in its own
header rather than leaving that to be discovered.

## `check` is the load-bearing part

It changes nothing, runs anywhere, and answers the question the issue actually
asks: can this box reproduce a number? A provisioning script nobody can verify
is how the undocumented box happened in the first place.

It writes down what was written down nowhere:

* **The naming convention is a contract.** pgNNa is an assert build for the
  matrix; pgNNn is NON-assert and is where every number in docs/benchmarks.md
  comes from; pgNN_san is clang with address and undefined sanitizers. Running a
  benchmark on an assert build is not a small error -- commandprompt#504 had to teach the
  profiler to refuse one because asserts put verify_compact_attribute at 9.28% of
  an ingest profile.
* **The suffix is checked against `pg_config --configure`, not trusted.** The
  name is a claim; the flags are the fact. A pg18n accidentally built with
  cassert would produce quietly wrong benchmark numbers and nothing else on the
  box would notice.
* **The privilege split**, which differs per script and fails in the worst
  direction: run_bench.sh, _fsst and _readstream install and want root; the
  installing scripts redirect make install to /dev/null, so run unprivileged they
  fail QUIETLY and measure the previously installed .so. run_bench_join.sh and
  run_clickbench.sh do not install and must not be run as root -- `check` warns
  when it is running as root for exactly that reason.
* **initdb needs --locale=C explicitly**, because sudo and runuser do not carry
  LANG and initdb then leaves a directory that is not a cluster, which reads as a
  start failure several steps later.

## It found something on the box it was written from

btree_gist is present on all five assert builds of the bench host and **absent
from pg18n**, the non-assert build benchmarks actually run on. That install is a
sixth prefix nobody had checked; since commandprompt#448 test/temporal.sh hard-fails rather
than skipping when btree_gist is missing.

## Two traps this hit while being written, both now handled

**`make install PG_CONFIG=... ` in a contrib directory inside a configured source
tree ignores PG_CONFIG.** It builds in-tree and installs to that tree's own
./configure --prefix. The first version reported "installed btree_gist into
pg18n" while the files went to an unrelated prefix -- exit 0, empty log, nothing
where it was wanted. Fixed with USE_PGXS=1.

**And it trusted the exit status.** A build system that installs the right files
into the wrong prefix returns 0, so the installer now verifies the .control file
exists at `pg_config --sharedir` afterwards and fails if it does not.

## Tested by running it on two boxes that disagree

An audit that cannot report a problem is worth nothing, so it was run where the
answers differ:

    bench host   19 ok, 0 warn, 1 missing   (found the pg18n gap)
    container    13 ok, 4 warn, 4 missing   (before), 17 ok, 4 warn, 0 missing (after)

Every state -- ok, WARN and MISSING -- is produced by a real box, and the
root-user warning fires on one and not the other. `contrib` is idempotent: the
second run reports "already in" for all six prefixes and changes nothing.

Refs commandprompt#505, commandprompt#504, commandprompt#448.
ChronicallyJD pushed a commit to ChronicallyJD/pgcolumnar that referenced this pull request Aug 9, 2026
…mmandprompt#505)

Every bench harness assumed an environment that existed only because someone
built it by hand. The knowledge was spread across memories, a HANDOFF.md that is
deliberately not in the repository, and issue comments, so a second machine --
or this one after a rebuild -- could not reproduce a number.

This is the first half: the PostgreSQL builds, the contrib the suites need, and
an audit that says whether a box can run bench/ at all. It does NOT install
Citus, TimescaleDB, DuckDB or the ClickBench dataset, and says so in its own
header rather than leaving that to be discovered.

## `check` is the load-bearing part

It changes nothing, runs anywhere, and answers the question the issue actually
asks: can this box reproduce a number? A provisioning script nobody can verify is
how the undocumented box happened in the first place.

It writes down what was written down nowhere:

* **The naming convention is a contract.** pgNNa is an assert build for the
  matrix; pgNNn is NON-assert and is where every number in docs/benchmarks.md
  comes from; pgNN_san is clang with address and undefined sanitizers. commandprompt#504 had
  to teach the profiler to refuse an assert build because asserts put
  verify_compact_attribute at 9.28% of an ingest profile.
* **The suffix is checked against `pg_config --configure`, not trusted.** The
  name is a claim; the flags are the fact. A pg18n accidentally built with
  cassert would produce quietly wrong benchmark numbers and nothing else on the
  box would notice.
* **The privilege split**, which fails in the worst direction: the installing
  harnesses redirect make install to /dev/null, so run unprivileged they fail
  QUIETLY and measure the previously installed .so. `check` warns when it is
  running as root, because run_bench_join.sh and run_clickbench.sh must not be.
* **initdb needs --locale=C explicitly**, because sudo and runuser do not carry
  LANG and initdb then leaves a directory that is not a cluster.

## What it found

btree_gist is present on all five ASSERT builds of the bench host and **absent
from pg18n and pg18_san** -- the two prefixes that are not assert builds, which
are exactly the two nobody had enumerated. pg18n is where every published
benchmark number comes from. Since commandprompt#448, test/temporal.sh hard-fails rather than
skipping when btree_gist is missing.

## Three traps this hit while being written

**`make install PG_CONFIG=...` inside a configured source tree ignores
PG_CONFIG.** A contrib directory there builds in-tree and installs to the parent
tree's ./configure --prefix. The first version reported "installed btree_gist
into pg18n" while the files went to an unrelated prefix -- exit 0, empty log,
nothing where it was wanted. USE_PGXS=1 makes PG_CONFIG authoritative. (An
out-of-tree extension like this one is unaffected: its Makefile takes PG_CONFIG
as authoritative already.)

**It trusted the exit status.** A build system that installs the right files into
the wrong prefix returns 0, so the installer now verifies the .control file
exists at `pg_config --sharedir` afterwards and fails if it does not. That
verification is what caught the third trap.

**A sanitizer build's own pg_config is instrumented and leaks on exit**, so with
leak detection on it exits NON-ZERO and every `$(pg_config --sharedir)` comes
back empty -- the failure then reads as "the extension is missing" rather than "I
could not ask where it goes". detect_leaks=0 is exported for that reason.

## And the auditor had the same blind spot it was written to catch

The first version enumerated the benchmark and matrix prefixes and skipped the
sanitizer one, so it could not have reported the pg18_san gap -- the same
"checked five, missed the sixth" shape that left the gap in the first place. The
contrib audit and installer now iterate every prefix the script knows about.

## Tested by running it on two boxes that disagree

An audit that cannot report a problem is worth nothing, so it was run where the
answers differ. Every state it can emit -- ok, WARN, MISSING -- is produced by a
real machine, and the root-user warning fires on one and not the other:

    bench host   19 ok, 0 warn, 1 missing    (found the pg18n gap; read-only)
    container    13 ok, 4 warn, 4 missing    before
    container    18 ok, 4 warn, 0 missing    after, all seven prefixes

`contrib` is idempotent: a second run reports "already in" and writes nothing.

Refs commandprompt#505, commandprompt#504, commandprompt#448.
ChronicallyJD pushed a commit to ChronicallyJD/pgcolumnar that referenced this pull request Aug 9, 2026
…mmandprompt#505)

Every bench harness assumed an environment that existed only because someone
built it by hand. The knowledge was spread across memories, a HANDOFF.md that is
deliberately not in the repository, and issue comments, so a second machine --
or this one after a rebuild -- could not reproduce a number.

This is the first half: the PostgreSQL builds, the contrib the suites need, and
an audit that says whether a box can run bench/ at all. It does NOT install
Citus, TimescaleDB, DuckDB or the ClickBench dataset, and says so in its own
header rather than leaving that to be discovered.

## `check` is the load-bearing part

It changes nothing, runs anywhere, and answers the question the issue actually
asks: can this box reproduce a number? A provisioning script nobody can verify is
how the undocumented box happened in the first place.

It writes down what was written down nowhere:

* **The naming convention is a contract.** pgNNa is an assert build for the
  matrix; pgNNn is NON-assert and is where every number in docs/benchmarks.md
  comes from; pgNN_san is clang with address and undefined sanitizers. commandprompt#504 had
  to teach the profiler to refuse an assert build because asserts put
  verify_compact_attribute at 9.28% of an ingest profile.
* **The suffix is checked against `pg_config --configure`, not trusted.** The
  name is a claim; the flags are the fact. A pg18n accidentally built with
  cassert would produce quietly wrong benchmark numbers and nothing else on the
  box would notice.
* **The privilege split**, which fails in the worst direction: the installing
  harnesses redirect make install to /dev/null, so run unprivileged they fail
  QUIETLY and measure the previously installed .so. `check` warns when it is
  running as root, because run_bench_join.sh and run_clickbench.sh must not be.
* **initdb needs --locale=C explicitly**, because sudo and runuser do not carry
  LANG and initdb then leaves a directory that is not a cluster.

## What it found

btree_gist is present on all five ASSERT builds of the bench host and **absent
from pg18n and pg18_san** -- the two prefixes that are not assert builds, which
are exactly the two nobody had enumerated. pg18n is where every published
benchmark number comes from. Since commandprompt#448, test/temporal.sh hard-fails rather than
skipping when btree_gist is missing.

## Three traps this hit while being written

**`make install PG_CONFIG=...` inside a configured source tree ignores
PG_CONFIG.** A contrib directory there builds in-tree and installs to the parent
tree's ./configure --prefix. The first version reported "installed btree_gist
into pg18n" while the files went to an unrelated prefix -- exit 0, empty log,
nothing where it was wanted. USE_PGXS=1 makes PG_CONFIG authoritative. (An
out-of-tree extension like this one is unaffected: its Makefile takes PG_CONFIG
as authoritative already.)

**It trusted the exit status.** A build system that installs the right files into
the wrong prefix returns 0, so the installer now verifies the .control file
exists at `pg_config --sharedir` afterwards and fails if it does not. That
verification is what caught the third trap.

**A sanitizer build's own pg_config is instrumented and leaks on exit**, so with
leak detection on it exits NON-ZERO and every `$(pg_config --sharedir)` comes
back empty -- the failure then reads as "the extension is missing" rather than "I
could not ask where it goes". detect_leaks=0 is exported for that reason.

## And the auditor had the same blind spot it was written to catch

The first version enumerated the benchmark and matrix prefixes and skipped the
sanitizer one, so it could not have reported the pg18_san gap -- the same
"checked five, missed the sixth" shape that left the gap in the first place. The
contrib audit and installer now iterate every prefix the script knows about.

## Tested by running it on two boxes that disagree

An audit that cannot report a problem is worth nothing, so it was run where the
answers differ. Every state it can emit -- ok, WARN, MISSING -- is produced by a
real machine, and the root-user warning fires on one and not the other:

    bench host   19 ok, 0 warn, 1 missing    (found the pg18n gap; read-only)
    container    13 ok, 4 warn, 4 missing    before
    container    18 ok, 4 warn, 0 missing    after, all seven prefixes

`contrib` is idempotent: a second run reports "already in" and writes nothing.

Refs commandprompt#505, commandprompt#504, commandprompt#448.

## Getting the source: a tarball is enough, and that is the safer shape

`contrib` no longer needs a configured source tree. It resolves an extracted tree
if one exists, otherwise extracts `postgresql-<version>.tar.*` to a scratch
directory, and always builds with USE_PGXS=1 against the target prefix's own
installed pgxs.

That is jdatcmd's suggestion from doing it by hand on the bench, where no
extracted tree existed at all, and it is better than what I had: it sidesteps the
in-tree PG_CONFIG trap **by construction** instead of guarding against it.

`check` now also reports whether a missing module is fixable on this box:

    MISSING btree_gist in pg16a  (test/temporal.sh hard-fails; 'provision.sh contrib' can fix it)
    MISSING btree_gist in pg16a  AND no contrib source for PostgreSQL 16.14 on this box

Reporting a problem without saying whether it can be fixed here is half an answer,
and the half that sends someone looking. Both branches were exercised by removing
btree_gist from pg16a and re-running with and without a reachable source.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants