Double-check ClickHouse perf-test results
What it does
Given a commit SHA from a PR that ran the Performance Comparison check, this
skill:
- Looks up the PR for the commit (via
gh api).
- Detects the local machine's architecture (
amd / arm).
- Discards shards CI did not run (
SKIPPED, PENDING, RUNNING,
DROPPED) — they publish no artifacts, so a synthesized report URL only
returns HTTP 403. If no shard ran, the skill stops with an error rather
than reporting "no changes": "CI never ran the comparison" and "CI ran it
and found nothing" are different answers, and only the second is a
verdict. It stops the same way when shards ran but none of their reports
can be read (expired artifacts), and when some report is unreadable and
the readable ones happened to be clean: a missing report contributes no
changed queries, exactly like a shard that had none, so "clean" would be a
claim about a part of the comparison nobody looked at. When there are
changes to rerun, an unreadable shard instead marks the run INCOMPLETE
in the report and makes the exit code non-zero.
It also stops when any shard carries a baseline other than master_head
— see the release_base limitation below.
- For each remaining perf shard, fetches
report.html and extracts the rows in the
"Changes in Performance" table (<tr id="changes-in-performance.<test>.<idx>">),
then pulls the timing numbers for those rows from all-query-metrics.tsv.
This matches the report exactly — re-implementing compare.sh's
changed_show predicate locally would require historical thresholds
and per-test <report_threshold> settings we don't have on the client side.
- Runs every CI-flagged query locally — even ones flagged only on a
different architecture. Cross-arch changes still get measured against
the same two binaries; the report table tags each row with a "CI@"
column showing which arch(es) CI flagged the query on (e.g.
arm-only
means CI saw the change on ARM but the local rerun is on AMD). This
surfaces silent drift on the local arch and lets the user judge whether
an <arch>-only CI verdict was real or noise. One row per query survives
the cross-arch dedup, but each arch's CI numbers are kept: when CI called
the same query slower on one arch and faster on the other, a CI split
line under the row shows both, since the row itself can only carry one.
- Resolves the reference (left/baseline) git SHA used by the CI run by
querying
query_metrics_v2 on play.clickhouse.com for the row with
new_sha = <pr-sha> (the report.html "Tested Commits" section is
unreliable — for official builds clickhouse --version does not embed
the git hash). The lookup is scoped to the pull request as well as the
commit and architecture, so a run of the same commit under a different
pr_number cannot supply the baseline. Within that scope, a commit
measured more than once has one reference per run, so the newest one is
taken — that is the run the
S3 report reflects, since it is overwritten in place — and the script
warns when the choice was not unique.
- Downloads both binaries from
clickhouse-builds:
- Right:
PRs/<pr>/<sha>/pr/build_{amd,arm}_release/clickhouse
- Left:
REFs/master/<ref-sha>/masterci/build_{amd,arm}_release/clickhouse
- Starts two local
clickhouse-server processes (ports 9001 + 19001, the
same ports CHServer uses in ci/jobs/performance_tests.py).
- Reruns only the affected query indices via
tests/performance/scripts/perf.py for each affected XML.
- Prints a side-by-side comparison: CI numbers vs. local numbers, with a
verdict per query (
CONFIRMED slower, NOT REPRODUCED, no local data).
Arguments
$0 (required): commit SHA (full or short — gh resolves short hashes).
--db-path PATH (optional): directory with the standard perf datasets
loaded (hits10, hits100, hits_v1, values, tpch10, tpcds1).
Must match the layout of ci/tmp/perf_wd/db0. If omitted, the script
probes ci/tmp/perf_wd/db0.
--pr N, --reference-sha SHA: override auto-detection.
--runs N: minimum measurements per query. Unset by default, as in CI —
perf.py's adaptive policy decides the counts from its --min-runs /
--tau precision stop. Passing a value only widens that policy and
changes the sampling, and with it the medians, the rerun precision and
the verdict.
--populate: rebuild the affected hits tables on each server
separately, the way CI's populate_data_both does, instead of sharing
one hardlinked copy. See "Hardlinked data vs. --populate" below.
--no-cpu-pinning: don't pin the servers with taskset and don't cap
max_threads. Only for a machine where pinning is undesirable — it
measures under noisier conditions than the report being checked.
--use-working-tree-tests: run this checkout's tests/performance and
configs instead of the ones from the commit under test. Only for iterating
on a local change to a test — see the note on pinning below.
--dry-run: stop after resolving PR / SHAs / changed queries; do not
download or run.
--port-offset N: shift every port the script uses. The defaults mirror
CI, where the left server sits on the standard ClickHouse ports
(8123/9009/9181/9234); on a development machine a local server
usually owns those and the run is refused. Shifting is the safe fix —
never stop someone else's server. Does not affect what is measured.
Procedure
1. Sanity checks
--dry-run needs nothing but python3 and git: the query expansion runs
perf.py with stand-ins for clickhouse_driver and scipy, which it
imports at module scope but never uses on the metadata path. The rerun
itself does need them, and is refused up front, before any download, if
they are missing.
The skill must be invoked from the root of a ClickHouse checkout (the
script verifies tests/performance/scripts/perf.py is present).
The dry-run inspects only the flagged query indices of every affected
XML — asking perf.py --print-queries to expand them, since one <query>
element with substitutions becomes several numbered queries — plus every
create_query/fill_query/drop_query, which run whatever
--queries-to-run says. It prints the list of external datasets they
actually reference (hits_*, test_values, tpch.*,
tpcds.*). Most perf tests are self-contained — they CREATE TABLE … FROM numbers(…) and need no preloaded data at all. Only require the datasets
the changed queries truly use; do not insist on the full 50 GB bootstrap.
If the affected XMLs reference zero external datasets, the script creates
an empty ci/tmp/perf_wd/db0 automatically and proceeds.
If they do reference one or more external datasets and ci/tmp/perf_wd/db0
is missing, the script bails with the minimal list of tarball URLs
needed for this particular run. Ask the user before downloading. Example:
if the only affected XML uses hits_100m_single, just fetch that one
tarball (~10 GB) into ci/tmp/perf_wd/db0, not all six.
mkdir -p ci/tmp/perf_wd/db0/data/default
# extract only the tarballs the dry-run identified as needed
wget -nv -nd -c "<url-from-dry-run>" -O- | tar --extract -C ci/tmp/perf_wd/db0
Do not auto-download — confirm with the user first.
2. Resolve the commit
Always run a dry-run first so the user can sanity-check what's about to be
rerun before any download starts:
python3 .claude/skills/double-check-perf-tests/double_check_perf.py <commit-sha> --dry-run
This prints: PR number, architecture, reference SHA, and the list of
affected XML files with their changed query indices. If anything looks
wrong (wrong arch, wrong reference SHA, wrong PR), pass --pr /
--reference-sha to override. Resolving the reference SHA needs
clickhouse client; on the dry-run path its absence is only a warning and
the line reads unresolved, so planning keeps working on a bare checkout.
The real run still refuses to start without it.
3. Run the comparison
python3 .claude/skills/double-check-perf-tests/double_check_perf.py <commit-sha>
Working directory defaults to tmp/double_check_perf/ in the cwd (per
CLAUDE.md: don't use /tmp). It contains:
left/clickhouse, right/clickhouse — downloaded binaries, each with
a .identity file recording the SHA it was built from. The work dir is
shared across runs, so a cached binary is reused only when it is the one
the current invocation asked for; a different commit or
--reference-sha re-downloads.
left/db/, right/db/ — hardlinked dataset copies
left/server.log, right/server.log — server logs
raw/<test>-raw.tsv — perf.py output per test
result.json — structured result of the local rerun
4. Present the results
The script prints a table. For each changed query show:
- CI old / new / Δ (from the report)
- Local old / new / Δ / p-value (from
perf.py)
- Verdict:
CONFIRMED slower|faster, NOT REPRODUCED, no local data,
NO VERDICT (CI's threshold for a demoted query is unavailable),
query ERRORED locally ... NOT MEASURED, or
perf.py FAILED ... NOT MEASURED. The last two are not verdicts about the
change: nothing was measured, and raw/<test>-err.log says why. Either one
also makes the script exit non-zero. ERRORED is the sneaky case —
perf.py drops a query that failed on every server (if len(no_errors) == 0: continue) and still exits 0, writing only a traceback to stderr, so
without reading that log it would look like the benign no local data. Any
stderr from an otherwise-clean perf.py run is taken as that signal.
A query counts as CONFIRMED when the local rerun passes the same gate
compare.sh uses to confirm a flagged query: same direction, |Δ| above the
per-query threshold CI used to flag it, and |Δ| >= stat_threshold of the
rerun itself (non-strict, as in compare.sh). Anything else is
NOT REPRODUCED, and the verdict says which of the three conditions failed.
A query CI flagged and then demoted in its own confirmation rerun is kept and
marked * in the CI@ column. compare.sh retracts such queries from
all-query-metrics.tsv while still listing them in the report, so their
numbers are read from report.html instead — dropping them would turn a
non-empty CI report into an all-clear, and they are exactly the ambiguous
results a local rerun should settle. A flagged query readable from neither
source is reported as unresolved, and if that leaves nothing to rerun the
skill fails rather than calling the comparison clean.
Retracting a demoted query from the TSV also takes its changed_threshold
with it, and judging it by the bare 0.15 floor would be a weaker gate than
the one CI used — enough to call a historically noisy query CONFIRMED. The
threshold is therefore rebuilt the way compare.sh builds it,
ceil(greatest(0.15, historical p99 x 1.5, the test's max_ignored_relative_change), 2), running CI's own historical-thresholds
query against play.clickhouse.com with the window anchored on the day that
run happened rather than today, and keyed by
(test, query_index, query_display_name) — the join compare.sh performs,
with the display name derived from the pinned test tree via
perf.py --print-queries rather than scraped from the report (report.py
writes query text into the table cell unescaped, so a query containing <
and > cannot be recovered from the HTML). The historical rows come back as
JSONEachRow, not TSV: most display names are multi-line — query_display
joins statements with ;\n and keeps the XML body's own newlines — and TSV
output re-escapes those, so a TSV-keyed lookup would miss every multi-line
query and silently drop it to the floor —
so an edited query body at the same positional index falls back to the floor
instead of inheriting the learned threshold of the query that used to be
there. This applies only to rows read from report.html; a shard old enough
to predate the changed_threshold column keeps the documented 0.15 floor,
since CI exported no threshold for it either. If it cannot be recovered the query is
reported with no verdict instead of being judged under a weaker rule.
stat_threshold is the q99 of the balanced-split null — the measurement
precision this rerun actually reached. It is recomputed from the rerun's own
per-run samples (the query rows of the raw TSV, the same lines compare.sh
collects for its confirmation step), using perf.py's own stat_threshold
function, lifted out of the script rather than reimplemented so the two cannot
drift. perf.py's p-value is displayed but does not decide anything: it is a
Welch t-test, not the statistic the CI gate applies.
The threshold is not a fixed number: compare.sh computes it per query as
the 0.15 floor raised by the query's historical p99 and the test's
<max_ignored_relative_change>, and exports it as the changed_threshold
column of all-query-metrics.tsv. A historically noisy query therefore has
to clear a much larger bar than a stable one. Using a flat bar instead would
let the rerun call a change CONFIRMED that CI's own gate would not have
flagged — the floor alone is deliberately above the 10–15% that micro
benchmarks swing between two binaries from machine noise and code layout.
Shards predating the column fall back to the 0.15 floor.
When summarising back to the user, separate the confirmed regressions /
improvements from the not-reproduced cases. Confirmed regressions are the
ones worth investigating further; not-reproduced ones can usually be
treated as CI noise.
Notes
- The same
tests/performance/scripts/perf.py and the same drop-in config
files (tests/performance/scripts/config/{config.d,users.d}) are used as
in CI, so the run is as close to CI as possible without Praktika. The
ports and shared dataset directory match CHServer in
ci/jobs/performance_tests.py.
- The tests come from the commit under test, not from your checkout.
tests/performance (the XMLs, perf.py, the perf config drop-ins),
tests/benchmarks (the SQL and settings tpch.xml / tpcds.xml /
tpch-join_algorithm-* load through file="..."), programs/server and
tests/config/top_level_domains are extracted from
the commit CI measured into tmp/double_check_perf/perf-tree/<sha> and
everything runs from there, fetching the commit if the clone lacks it — and
if the clone's .git cannot be written to, as in some sandboxes, into a
scratch repository under the work dir instead. Only this checkout's own
origin is fetched into the clone; the scratch repository additionally
tries the canonical upstream with --depth=1, so a fork checkout — whose
refs/pull/<n>/head is a different pull request — still resolves the
commit. A fetch counts only when the commit is present afterwards, never on
the fetch's exit code. This
is not a nicety: query indices are positional and substitutions expand
them, so an XML that gained or lost a query means index n is a different
query — on a checkout of this repo one commit behind,
and_compare_chain_derived.xml has no query #2 at all while CI flagged
exactly that. A refs/pull/<n>/merge checkout has the same problem, since
it is not the commit CI measured. perf.py and the thresholds it computes
are pinned for the same reason.
- CPU pinning. On Linux x86_64, CI pins both servers with
taskset to
one hyperthread per physical core and caps max_threads at the size of
that set, so query threads never share a hyperthread sibling depending on
scheduler mood — CI's top suspect for the amd-vs-arm A/A noise gap (0.51%
vs 0.42%). The script does the same, including the same
--jemalloc_profiler_sampling_rate. This matters for the verdicts: an
unpinned rerun is noisier than the report it is adjudicating, which is how
a real change ends up looking NOT REPRODUCED. arm runs on real cores
and is not pinned, in CI or here.
- The reference (left) binary's git hash is resolved via
play.clickhouse.com (anonymous explorer user, no credentials needed),
using the query_metrics_v2.old_sha column for the matching new_sha
and arch. If that query fails or returns nothing (e.g. the run never
finished uploading), pass --reference-sha explicitly. The CI sets this
field from SELECT value FROM system.build_options WHERE name='GIT_HASH'
on the reference binary itself, so the resulting SHA is guaranteed to
match a buildable commit under REFs/master/<sha>/masterci/build_*_release/.
- Datasets are intentionally not downloaded automatically — they are large
and the user should opt in. Existing data is hardlinked into both server
dirs via
cp -al (same trick performance_tests.py uses), so disk usage
stays low.
- The perf framework expects
test.hits (not datasets.hits_v1) for
several tests (url_hits, count_from_formats, ...). By default the
script runs a temporary "preconfig" clickhouse-server pointed at db0
and issues CREATE DATABASE test; RENAME TABLE datasets.hits_v1 TO test.hits via SQL, so one copy of the data is shared by both sides.
(ci/jobs/performance_tests.py instead builds test.hits with
INSERT SELECT on each server — that is what --populate reproduces,
and under --populate this rename is skipped so the source table stays
available to both sides.) Doing this via
filesystem-only moves of the .sql files looks equivalent but leaves
bookkeeping in a state that crashes the next server start while
loading tpcds (NULL deref in
DatabaseOrdinary::getConvertToReplicatedFlagPath). Always use the SQL
path. Step is idempotent — skipped if test.hits already exists in
db0. After the preconfig server exits, the script strips
data/system, metadata/system, status, preprocessed_configs from
db0 since those are per-server state that mustn't be shared between
the left/right hardlinked copies.
- Hardlinked data vs.
--populate. By default both servers read one
hardlinked copy of db0 (cp -al, the same trick
performance_tests.py uses), so the parts they read were written by
whatever binary produced the dataset tarball. CI does not do this: its
populate_data_both re-inserts hits_10m_single, hits_100m_single
and datasets.hits_v1 → test.hits on each server, so each side's
parts carry that side's own write-time defaults (sparse columns,
statistics, mark format). A regression that lives in the write path,
or one that only shows on freshly written serialization, therefore
comes back NOT REPRODUCED under the default. Pass --populate to
reproduce CI faithfully; it only rebuilds the hits tables the
affected XMLs actually reference, but each one is a full rewrite per
side (hits_100m_single alone is ~21 GiB and tens of minutes) and
gives up the hardlink disk saving for those tables. When a confirmed CI
regression does not reproduce and the PR touches anything on the write
path, rerun with --populate before calling it noise.
- Between test XMLs, everything a test wrote into
user_files is removed
from both sides while the seeded fixture symlinks are kept — the same
cleanup CI runs after every test. Tests write there with INSERT INTO FUNCTION file(...) (parquet_read, json_type_parsing,
insert_values_with_expressions, ...) and drop_query only drops tables,
so without it a later XML can read what an earlier one left behind and a
multi-test rerun becomes order-dependent.
--profile-seconds 0 is a deliberate deviation: CI passes 10. The profile
runs happen after a query's diff has been computed, so they cannot change
its numbers, and this skill does not collect flamegraphs.
- The skill does not attempt to reproduce flamegraphs or profiling —
for that, use the
perf-report skill on the same PR.
- Architecture mismatch is not a blocker. The script runs queries
flagged on either arch locally, and that rule keeps applying when the
local arch has no shards at all — an ARM-only report is the common case,
since the AMD shards run only for a PR labeled
pr-performance. Only the
play.clickhouse.com lookups are keyed by architecture, so they ask about
an arch CI measured; what they return is a master commit, and every
master build publishes both arches, so the local-arch binaries for it
exist regardless. When CI never measured the local arch the script says so
up front and again under the table: the CI old/new/Δ columns are then the
other arch's timings, so NOT REPRODUCED means "the local arch does not
show it", not "CI was wrong". For the strictest verification, run the skill
on each arch separately; otherwise the AMD rerun of an ARM-only change is
still useful ("local AMD doesn't reproduce the ARM regression" is a
meaningful and common verdict).
- Only the
master_head baseline is supported. CI runs a second flavour
of the comparison, release_base, which measures against the latest
release build and checks out that release's tests/performance before
running. Nothing in this skill is baseline-aware: the left binary is always
fetched from REFs/master/<ref-sha>/, query indices are positional in the
tests tree of the commit under test, and the reference-SHA lookup cannot
discriminate either, because the query_metrics_v2 table exposed on
play.clickhouse.com has no baseline_kind column to filter on. Rows from
the two baselines share the same (test, query_index) key, so merging them
would adjudicate release-baseline queries against a binary and a query
numbering CI never used. The script refuses such a report instead. In
practice this is unreachable today — only the master workflow schedules
release_base (ARM only), and its reports live under REFs/, which this
skill does not read — so the check is a guard against that changing.
- The work dir is reused, so Keeper state is wiped on every run.
tmp/double_check_perf persists between invocations (that is what makes
the binary cache worth having), but the embedded Keeper's coordination
directories are only valid for the data they were written against. The db
copies are recreated from db0 each run, so the coordination dirs are
removed alongside them — left/coordination, right/coordination and the
preconfig server's coordination0. Without that, alter_select.xml, the
one perf test that creates a
ReplicatedMergeTree('/tables/{database}', '{table}'), hits
REPLICA_ALREADY_EXISTS on its create_query against the previous run's
znodes and the whole test goes unmeasured.
- Some shards upload
all-query-metrics.tsv.zst (zstd-compressed) instead
of plain .tsv — the script detects the URL suffix and decompresses on
the fly (uses the zstandard Python package if available, else shells
out to zstd -dc).
- Wait for merges before measuring on freshly-loaded data. A dataset
tarball drops parts at whatever merge level the snapshot was taken;
ClickHouse queues consolidation merges on startup. While those run the
number of parts drifts (changes plans, prefetch, external-storage cache
reuse) and the merge threads themselves compete for CPU/IO with the
queries being timed. The signal we look for is no new merge scheduled:
the script polls
SELECT min(elapsed) FROM system.merges on both servers
and considers them settled once the youngest in-flight merge has been
running for at least 2 minutes (so nothing new has started in that
window). This is more useful than waiting for count()=0: long-running
merges can stretch that wait by tens of minutes for no real gain once
the rate of new merges has dropped to zero. Pass
--skip-wait-for-merges only when reusing a perf working directory
that already settled in a previous run.
Rules
- Always run
--dry-run first and show the user the plan before downloads.
- When datasets are missing, ask the user before bootstrapping. Don't
silently fire off multi-GB downloads.
- Don't truncate or summarize the result table — every changed query must
be visible, same principle as the
perf-report skill.
- If the script reports
NOT REPRODUCED for a query that has a large CI
delta, suggest re-running with --runs 13 (more samples) before
declaring it flaky. If the PR changes anything that affects how parts
are written, suggest --populate too — the default hardlinked dataset
cannot show a write-path change at all.
1---2name: double-check-perf-tests3description: Locally reproduce ClickHouse CI performance comparison results for a given commit. Fetches the perf CI report, identifies queries categorized as "Changes in Performance", downloads both the patched and reference binaries from S3 (matching the current machine architecture), and re-runs only those queries via `tests/performance/scripts/perf.py` to verify whether each regression/improvement is real. Use this whenever the user wants to "double-check", "reproduce", "verify locally", or "re-run" a perf check result — even if they don't name the skill.4---56# Double-check ClickHouse perf-test results78## What it does910Given a commit SHA from a PR that ran the Performance Comparison check, this11skill:12131. Looks up the PR for the commit (via `gh api`).142. Detects the local machine's architecture (`amd` / `arm`).153. Discards shards CI did not run (`SKIPPED`, `PENDING`, `RUNNING`,16 `DROPPED`) — they publish no artifacts, so a synthesized report URL only17 returns HTTP 403. If *no* shard ran, the skill stops with an error rather18 than reporting "no changes": "CI never ran the comparison" and "CI ran it19 and found nothing" are different answers, and only the second is a20 verdict. It stops the same way when shards ran but none of their reports21 can be read (expired artifacts), and when *some* report is unreadable and22 the readable ones happened to be clean: a missing report contributes no23 changed queries, exactly like a shard that had none, so "clean" would be a24 claim about a part of the comparison nobody looked at. When there are25 changes to rerun, an unreadable shard instead marks the run `INCOMPLETE`26 in the report and makes the exit code non-zero.27 It also stops when any shard carries a baseline other than `master_head`28 — see the `release_base` limitation below.294. For each remaining perf shard, fetches `report.html` and extracts the rows in the30 "Changes in Performance" table (`<tr id="changes-in-performance.<test>.<idx>">`),31 then pulls the timing numbers for those rows from `all-query-metrics.tsv`.32 This matches the report exactly — re-implementing compare.sh's33 `changed_show` predicate locally would require historical thresholds34 and per-test `<report_threshold>` settings we don't have on the client side.355. Runs *every* CI-flagged query locally — even ones flagged only on a36 different architecture. Cross-arch changes still get measured against37 the same two binaries; the report table tags each row with a "CI@"38 column showing which arch(es) CI flagged the query on (e.g. `arm-only`39 means CI saw the change on ARM but the local rerun is on AMD). This40 surfaces silent drift on the local arch and lets the user judge whether41 an `<arch>-only` CI verdict was real or noise. One row per query survives42 the cross-arch dedup, but each arch's CI numbers are kept: when CI called43 the same query slower on one arch and faster on the other, a `CI split`44 line under the row shows both, since the row itself can only carry one.456. Resolves the reference (left/baseline) git SHA used by the CI run by46 querying `query_metrics_v2` on `play.clickhouse.com` for the row with47 `new_sha = <pr-sha>` (the `report.html` "Tested Commits" section is48 unreliable — for official builds `clickhouse --version` does not embed49 the git hash). The lookup is scoped to the pull request as well as the50 commit and architecture, so a run of the same commit under a different51 `pr_number` cannot supply the baseline. Within that scope, a commit52 measured more than once has one reference per run, so the newest one is53 taken — that is the run the54 S3 report reflects, since it is overwritten in place — and the script55 warns when the choice was not unique.567. Downloads both binaries from `clickhouse-builds`:57 - Right: `PRs/<pr>/<sha>/pr/build_{amd,arm}_release/clickhouse`58 - Left: `REFs/master/<ref-sha>/masterci/build_{amd,arm}_release/clickhouse`598. Starts two local `clickhouse-server` processes (ports 9001 + 19001, the60 same ports `CHServer` uses in `ci/jobs/performance_tests.py`).619. Reruns **only** the affected query indices via62 `tests/performance/scripts/perf.py` for each affected XML.6310. Prints a side-by-side comparison: CI numbers vs. local numbers, with a64 verdict per query (`CONFIRMED slower`, `NOT REPRODUCED`, `no local data`).6566## Arguments6768- `$0` (required): commit SHA (full or short — `gh` resolves short hashes).69- `--db-path PATH` (optional): directory with the standard perf datasets70 loaded (`hits10`, `hits100`, `hits_v1`, `values`, `tpch10`, `tpcds1`).71 Must match the layout of `ci/tmp/perf_wd/db0`. If omitted, the script72 probes `ci/tmp/perf_wd/db0`.73- `--pr N`, `--reference-sha SHA`: override auto-detection.74- `--runs N`: minimum measurements per query. Unset by default, as in CI —75 `perf.py`'s adaptive policy decides the counts from its `--min-runs` /76 `--tau` precision stop. Passing a value only widens that policy and77 changes the sampling, and with it the medians, the rerun precision and78 the verdict.79- `--populate`: rebuild the affected `hits` tables on each server80 separately, the way CI's `populate_data_both` does, instead of sharing81 one hardlinked copy. See "Hardlinked data vs. `--populate`" below.82- `--no-cpu-pinning`: don't pin the servers with `taskset` and don't cap83 `max_threads`. Only for a machine where pinning is undesirable — it84 measures under noisier conditions than the report being checked.85- `--use-working-tree-tests`: run this checkout's `tests/performance` and86 configs instead of the ones from the commit under test. Only for iterating87 on a local change to a test — see the note on pinning below.88- `--dry-run`: stop after resolving PR / SHAs / changed queries; do not89 download or run.90- `--port-offset N`: shift every port the script uses. The defaults mirror91 CI, where the left server sits on the standard ClickHouse ports92 (`8123`/`9009`/`9181`/`9234`); on a development machine a local server93 usually owns those and the run is refused. Shifting is the safe fix —94 never stop someone else's server. Does not affect what is measured.9596## Procedure9798### 1. Sanity checks99100- `--dry-run` needs nothing but `python3` and `git`: the query expansion runs101 `perf.py` with stand-ins for `clickhouse_driver` and `scipy`, which it102 imports at module scope but never uses on the metadata path. The rerun103 itself does need them, and is refused up front, before any download, if104 they are missing.105106- The skill must be invoked from the root of a ClickHouse checkout (the107 script verifies `tests/performance/scripts/perf.py` is present).108- The dry-run inspects **only the flagged query indices** of every affected109 XML — asking `perf.py --print-queries` to expand them, since one `<query>`110 element with substitutions becomes several numbered queries — plus every111 `create_query`/`fill_query`/`drop_query`, which run whatever112 `--queries-to-run` says. It prints the list of external datasets they113 actually reference (`hits_*`, `test_values`, `tpch.*`,114 `tpcds.*`). Most perf tests are self-contained — they `CREATE TABLE … FROM115 numbers(…)` and need no preloaded data at all. **Only require the datasets116 the changed queries truly use; do not insist on the full 50 GB bootstrap.**117- If the affected XMLs reference zero external datasets, the script creates118 an empty `ci/tmp/perf_wd/db0` automatically and proceeds.119- If they do reference one or more external datasets and `ci/tmp/perf_wd/db0`120 is missing, the script bails with the *minimal* list of tarball URLs121 needed for this particular run. Ask the user before downloading. Example:122 if the only affected XML uses `hits_100m_single`, just fetch that one123 tarball (~10 GB) into `ci/tmp/perf_wd/db0`, not all six.124125 ```bash126 mkdir -p ci/tmp/perf_wd/db0/data/default127 # extract only the tarballs the dry-run identified as needed128 wget -nv -nd -c "<url-from-dry-run>" -O- | tar --extract -C ci/tmp/perf_wd/db0129 ```130131 Do **not** auto-download — confirm with the user first.132133### 2. Resolve the commit134135Always run a dry-run first so the user can sanity-check what's about to be136rerun before any download starts:137138```bash139python3 .claude/skills/double-check-perf-tests/double_check_perf.py <commit-sha> --dry-run140```141142This prints: PR number, architecture, reference SHA, and the list of143affected XML files with their changed query indices. If anything looks144wrong (wrong arch, wrong reference SHA, wrong PR), pass `--pr` /145`--reference-sha` to override. Resolving the reference SHA needs146`clickhouse client`; on the dry-run path its absence is only a warning and147the line reads `unresolved`, so planning keeps working on a bare checkout.148The real run still refuses to start without it.149150### 3. Run the comparison151152```bash153python3 .claude/skills/double-check-perf-tests/double_check_perf.py <commit-sha>154```155156Working directory defaults to `tmp/double_check_perf/` in the cwd (per157`CLAUDE.md`: don't use `/tmp`). It contains:158159- `left/clickhouse`, `right/clickhouse` — downloaded binaries, each with160 a `.identity` file recording the SHA it was built from. The work dir is161 shared across runs, so a cached binary is reused only when it is the one162 the current invocation asked for; a different commit or163 `--reference-sha` re-downloads.164- `left/db/`, `right/db/` — hardlinked dataset copies165- `left/server.log`, `right/server.log` — server logs166- `raw/<test>-raw.tsv` — `perf.py` output per test167- `result.json` — structured result of the local rerun168169### 4. Present the results170171The script prints a table. For each changed query show:172173- CI old / new / Δ (from the report)174- Local old / new / Δ / p-value (from `perf.py`)175- Verdict: `CONFIRMED slower|faster`, `NOT REPRODUCED`, `no local data`,176 `NO VERDICT` (CI's threshold for a demoted query is unavailable),177 `query ERRORED locally ... NOT MEASURED`, or178 `perf.py FAILED ... NOT MEASURED`. The last two are not verdicts about the179 change: nothing was measured, and `raw/<test>-err.log` says why. Either one180 also makes the script exit non-zero. `ERRORED` is the sneaky case —181 `perf.py` drops a query that failed on *every* server (`if len(no_errors)182 == 0: continue`) and still exits 0, writing only a traceback to stderr, so183 without reading that log it would look like the benign `no local data`. Any184 stderr from an otherwise-clean `perf.py` run is taken as that signal.185186A query counts as `CONFIRMED` when the local rerun passes the same gate187`compare.sh` uses to confirm a flagged query: same direction, `|Δ|` above the188**per-query threshold CI used to flag it**, and `|Δ| >= stat_threshold` of the189rerun itself (non-strict, as in `compare.sh`). Anything else is190`NOT REPRODUCED`, and the verdict says which of the three conditions failed.191192A query CI flagged and then demoted in its own confirmation rerun is kept and193marked `*` in the CI@ column. `compare.sh` retracts such queries from194`all-query-metrics.tsv` while still listing them in the report, so their195numbers are read from `report.html` instead — dropping them would turn a196non-empty CI report into an all-clear, and they are exactly the ambiguous197results a local rerun should settle. A flagged query readable from neither198source is reported as unresolved, and if that leaves nothing to rerun the199skill fails rather than calling the comparison clean.200201Retracting a demoted query from the TSV also takes its `changed_threshold`202with it, and judging it by the bare 0.15 floor would be a weaker gate than203the one CI used — enough to call a historically noisy query `CONFIRMED`. The204threshold is therefore rebuilt the way `compare.sh` builds it,205`ceil(greatest(0.15, historical p99 x 1.5, the test's206max_ignored_relative_change), 2)`, running CI's own historical-thresholds207query against `play.clickhouse.com` with the window anchored on the day that208run happened rather than today, and keyed by209`(test, query_index, query_display_name)` — the join `compare.sh` performs,210with the display name derived from the pinned test tree via211`perf.py --print-queries` rather than scraped from the report (`report.py`212writes query text into the table cell unescaped, so a query containing `<`213and `>` cannot be recovered from the HTML). The historical rows come back as214`JSONEachRow`, not `TSV`: most display names are multi-line — `query_display`215joins statements with `;\n` and keeps the XML body's own newlines — and TSV216output re-escapes those, so a TSV-keyed lookup would miss every multi-line217query and silently drop it to the floor —218so an edited query body at the same positional index falls back to the floor219instead of inheriting the learned threshold of the query that used to be220there. This applies only to rows read from `report.html`; a shard old enough221to predate the `changed_threshold` column keeps the documented 0.15 floor,222since CI exported no threshold for it either. If it cannot be recovered the query is223reported with **no verdict** instead of being judged under a weaker rule.224225`stat_threshold` is the q99 of the balanced-split null — the measurement226precision this rerun actually reached. It is recomputed from the rerun's own227per-run samples (the `query` rows of the raw TSV, the same lines `compare.sh`228collects for its confirmation step), using `perf.py`'s own `stat_threshold`229function, lifted out of the script rather than reimplemented so the two cannot230drift. `perf.py`'s p-value is displayed but does not decide anything: it is a231Welch t-test, not the statistic the CI gate applies.232233The threshold is not a fixed number: `compare.sh` computes it per query as234the 0.15 floor raised by the query's historical p99 and the test's235`<max_ignored_relative_change>`, and exports it as the `changed_threshold`236column of `all-query-metrics.tsv`. A historically noisy query therefore has237to clear a much larger bar than a stable one. Using a flat bar instead would238let the rerun call a change `CONFIRMED` that CI's own gate would not have239flagged — the floor alone is deliberately above the 10–15% that micro240benchmarks swing between two binaries from machine noise and code layout.241Shards predating the column fall back to the 0.15 floor.242243When summarising back to the user, separate the confirmed regressions /244improvements from the not-reproduced cases. Confirmed regressions are the245ones worth investigating further; not-reproduced ones can usually be246treated as CI noise.247248## Notes249250- The same `tests/performance/scripts/perf.py` and the same drop-in config251 files (`tests/performance/scripts/config/{config.d,users.d}`) are used as252 in CI, so the run is as close to CI as possible without Praktika. The253 ports and shared dataset directory match `CHServer` in254 `ci/jobs/performance_tests.py`.255- **The tests come from the commit under test, not from your checkout.**256 `tests/performance` (the XMLs, `perf.py`, the perf config drop-ins),257 `tests/benchmarks` (the SQL and settings `tpch.xml` / `tpcds.xml` /258 `tpch-join_algorithm-*` load through `file="..."`), `programs/server` and259 `tests/config/top_level_domains` are extracted from260 the commit CI measured into `tmp/double_check_perf/perf-tree/<sha>` and261 everything runs from there, fetching the commit if the clone lacks it — and262 if the clone's `.git` cannot be written to, as in some sandboxes, into a263 scratch repository under the work dir instead. Only this checkout's own264 `origin` is fetched into the clone; the scratch repository additionally265 tries the canonical upstream with `--depth=1`, so a fork checkout — whose266 `refs/pull/<n>/head` is a different pull request — still resolves the267 commit. A fetch counts only when the commit is present afterwards, never on268 the fetch's exit code. This269 is not a nicety: query indices are positional and substitutions expand270 them, so an XML that gained or lost a query means index *n* is a different271 query — on a checkout of this repo one commit behind,272 `and_compare_chain_derived.xml` has no query #2 at all while CI flagged273 exactly that. A `refs/pull/<n>/merge` checkout has the same problem, since274 it is not the commit CI measured. `perf.py` and the thresholds it computes275 are pinned for the same reason.276- **CPU pinning.** On Linux x86_64, CI pins both servers with `taskset` to277 one hyperthread per physical core and caps `max_threads` at the size of278 that set, so query threads never share a hyperthread sibling depending on279 scheduler mood — CI's top suspect for the amd-vs-arm A/A noise gap (0.51%280 vs 0.42%). The script does the same, including the same281 `--jemalloc_profiler_sampling_rate`. This matters for the verdicts: an282 unpinned rerun is noisier than the report it is adjudicating, which is how283 a real change ends up looking `NOT REPRODUCED`. `arm` runs on real cores284 and is not pinned, in CI or here.285- The reference (left) binary's git hash is resolved via286 `play.clickhouse.com` (anonymous `explorer` user, no credentials needed),287 using the `query_metrics_v2.old_sha` column for the matching `new_sha`288 and `arch`. If that query fails or returns nothing (e.g. the run never289 finished uploading), pass `--reference-sha` explicitly. The CI sets this290 field from `SELECT value FROM system.build_options WHERE name='GIT_HASH'`291 on the reference binary itself, so the resulting SHA is guaranteed to292 match a buildable commit under `REFs/master/<sha>/masterci/build_*_release/`.293- Datasets are intentionally not downloaded automatically — they are large294 and the user should opt in. Existing data is hardlinked into both server295 dirs via `cp -al` (same trick `performance_tests.py` uses), so disk usage296 stays low.297- The perf framework expects `test.hits` (not `datasets.hits_v1`) for298 several tests (`url_hits`, `count_from_formats`, ...). By default the299 script runs a temporary "preconfig" `clickhouse-server` pointed at `db0`300 and issues `CREATE DATABASE test; RENAME TABLE datasets.hits_v1 TO301 test.hits` via SQL, so one copy of the data is shared by both sides.302 (`ci/jobs/performance_tests.py` instead builds `test.hits` with303 `INSERT SELECT` on each server — that is what `--populate` reproduces,304 and under `--populate` this rename is skipped so the source table stays305 available to both sides.) Doing this via306 filesystem-only moves of the .sql files looks equivalent but leaves307 bookkeeping in a state that crashes the next server start while308 loading `tpcds` (NULL deref in309 `DatabaseOrdinary::getConvertToReplicatedFlagPath`). Always use the SQL310 path. Step is idempotent — skipped if `test.hits` already exists in311 `db0`. After the preconfig server exits, the script strips312 `data/system`, `metadata/system`, `status`, `preprocessed_configs` from313 `db0` since those are per-server state that mustn't be shared between314 the left/right hardlinked copies.315- **Hardlinked data vs. `--populate`.** By default both servers read one316 hardlinked copy of `db0` (`cp -al`, the same trick317 `performance_tests.py` uses), so the parts they read were written by318 whatever binary produced the dataset tarball. CI does not do this: its319 `populate_data_both` re-inserts `hits_10m_single`, `hits_100m_single`320 and `datasets.hits_v1` → `test.hits` on each server, so each side's321 parts carry that side's own write-time defaults (sparse columns,322 statistics, mark format). A regression that lives in the write path,323 or one that only shows on freshly written serialization, therefore324 comes back `NOT REPRODUCED` under the default. Pass `--populate` to325 reproduce CI faithfully; it only rebuilds the `hits` tables the326 affected XMLs actually reference, but each one is a full rewrite per327 side (`hits_100m_single` alone is ~21 GiB and tens of minutes) and328 gives up the hardlink disk saving for those tables. When a confirmed CI329 regression does not reproduce and the PR touches anything on the write330 path, rerun with `--populate` before calling it noise.331- Between test XMLs, everything a test wrote into `user_files` is removed332 from both sides while the seeded fixture symlinks are kept — the same333 cleanup CI runs after every test. Tests write there with `INSERT INTO334 FUNCTION file(...)` (`parquet_read`, `json_type_parsing`,335 `insert_values_with_expressions`, ...) and `drop_query` only drops tables,336 so without it a later XML can read what an earlier one left behind and a337 multi-test rerun becomes order-dependent.338- `--profile-seconds 0` is a deliberate deviation: CI passes 10. The profile339 runs happen after a query's diff has been computed, so they cannot change340 its numbers, and this skill does not collect flamegraphs.341- The skill does **not** attempt to reproduce flamegraphs or profiling —342 for that, use the `perf-report` skill on the same PR.343- **Architecture mismatch is not a blocker.** The script runs queries344 flagged on *either* arch locally, and that rule keeps applying when the345 local arch has no shards at all — an ARM-only report is the common case,346 since the AMD shards run only for a PR labeled `pr-performance`. Only the347 `play.clickhouse.com` lookups are keyed by architecture, so they ask about348 an arch CI measured; what they return is a master *commit*, and every349 master build publishes both arches, so the local-arch binaries for it350 exist regardless. When CI never measured the local arch the script says so351 up front and again under the table: the CI old/new/Δ columns are then the352 other arch's timings, so `NOT REPRODUCED` means "the local arch does not353 show it", not "CI was wrong". For the strictest verification, run the skill354 on each arch separately; otherwise the AMD rerun of an ARM-only change is355 still useful ("local AMD doesn't reproduce the ARM regression" is a356 meaningful and common verdict).357- **Only the `master_head` baseline is supported.** CI runs a second flavour358 of the comparison, `release_base`, which measures against the latest359 release build and checks out that release's `tests/performance` before360 running. Nothing in this skill is baseline-aware: the left binary is always361 fetched from `REFs/master/<ref-sha>/`, query indices are positional in the362 tests tree of the commit under test, and the reference-SHA lookup cannot363 discriminate either, because the `query_metrics_v2` table exposed on364 `play.clickhouse.com` has no `baseline_kind` column to filter on. Rows from365 the two baselines share the same `(test, query_index)` key, so merging them366 would adjudicate release-baseline queries against a binary and a query367 numbering CI never used. The script refuses such a report instead. In368 practice this is unreachable today — only the master workflow schedules369 `release_base` (ARM only), and its reports live under `REFs/`, which this370 skill does not read — so the check is a guard against that changing.371- **The work dir is reused, so Keeper state is wiped on every run.**372 `tmp/double_check_perf` persists between invocations (that is what makes373 the binary cache worth having), but the embedded Keeper's `coordination`374 directories are only valid for the data they were written against. The db375 copies are recreated from `db0` each run, so the coordination dirs are376 removed alongside them — `left/coordination`, `right/coordination` and the377 preconfig server's `coordination0`. Without that, `alter_select.xml`, the378 one perf test that creates a379 `ReplicatedMergeTree('/tables/{database}', '{table}')`, hits380 `REPLICA_ALREADY_EXISTS` on its `create_query` against the previous run's381 znodes and the whole test goes unmeasured.382- Some shards upload `all-query-metrics.tsv.zst` (zstd-compressed) instead383 of plain `.tsv` — the script detects the URL suffix and decompresses on384 the fly (uses the `zstandard` Python package if available, else shells385 out to `zstd -dc`).386- **Wait for merges before measuring on freshly-loaded data.** A dataset387 tarball drops parts at whatever merge level the snapshot was taken;388 ClickHouse queues consolidation merges on startup. While those run the389 number of parts drifts (changes plans, prefetch, external-storage cache390 reuse) and the merge threads themselves compete for CPU/IO with the391 queries being timed. The signal we look for is *no new merge scheduled*:392 the script polls `SELECT min(elapsed) FROM system.merges` on both servers393 and considers them settled once the youngest in-flight merge has been394 running for at least 2 minutes (so nothing new has started in that395 window). This is more useful than waiting for `count()=0`: long-running396 merges can stretch that wait by tens of minutes for no real gain once397 the *rate* of new merges has dropped to zero. Pass398 `--skip-wait-for-merges` only when reusing a perf working directory399 that already settled in a previous run.400401## Rules402403- Always run `--dry-run` first and show the user the plan before downloads.404- When datasets are missing, **ask** the user before bootstrapping. Don't405 silently fire off multi-GB downloads.406- Don't truncate or summarize the result table — every changed query must407 be visible, same principle as the `perf-report` skill.408- If the script reports `NOT REPRODUCED` for a query that has a large CI409 delta, suggest re-running with `--runs 13` (more samples) before410 declaring it flaky. If the PR changes anything that affects how parts411 are written, suggest `--populate` too — the default hardlinked dataset412 cannot show a write-path change at all.