| name | perf-comparison |
| description | Evaluate ClickHouse performance test results from existing CI/dashboard data or local perf.py runs. Use to check PR performance changes, run local performance tests, compare with history, assess flakiness, inspect coverage/PR relevance, and summarize whether a result is actionable. |
| argument-hint | <PR number | performance.ci URL | runId | tests/performance/*.xml> [test/query/metric/arch] |
| disable-model-invocation | false |
| allowed-tools | Bash, Read, Grep, Glob, WebFetch |
ClickHouse Performance Comparison
Use this skill whenever the goal is to answer:
Is this ClickHouse performance result real, flaky/noisy, already known from history, or worth deeper investigation?
The evidence may come from existing performance CI/dashboard data, a local perf.py run, or both. Do not treat this as two separate modes; use whichever evidence is available and add missing evidence only when it changes the decision.
Core principles
- Keep the current accepted evidence separate from stale exploratory data. If a newer rerun/confidence/history check supersedes an older table, say so and show the newer numbers.
- Do not call a regression real from one changed row. Check repeated PR runs, confidence, master/history trend, and test stability.
- Do not dismiss a regression as flaky without evidence. Show the history/trend or repeated-run data that justifies the verdict.
- Do not emit opaque dashboard internals as evidence. Raw strings like
M1:downgrade, M2:neutral are not report-ready; translate confidence into tier/reason and named evidence checks.
- Metric, arch, query index, and run identity must match. Do not mix ARM with AMD or
client_time with CPU/real/memory metrics.
- Coverage/PR-diff overlap is supporting evidence, not proof. It raises or lowers plausibility but does not replace measurements.
- Local runs are validation evidence, not a substitute for CI history. Local setup differences must be reported.
- Never file issues, comment on PRs, or edit source unless explicitly asked.
Useful helper scripts in this skill
python3 scripts/perf_api.py runs --pr 104350
python3 scripts/perf_api.py pr-inventory --pr 104350 --limit 20 --with-confidence
python3 scripts/perf_api.py master-checks --pr 104350 --limit 50
python3 scripts/perf_api.py tsv-inventory --tsv pr_104350_amd.tsv pr_104350_arm.tsv --limit 20
python3 scripts/perf_api.py changes --run-id "$RUN_ID" --with-confidence
python3 scripts/perf_api.py query \
--run-id "$RUN_ID" \
--test aggregation_in_order_2 \
--query-index 4 \
--metric client_time \
--arch arm \
--with-confidence --with-trend --with-history --with-coverage --with-flamegraph-diff
python3 scripts/perf_api.py pr-query-history \
--pr 104350 \
--test aggregation_in_order_2 \
--query-index 4 \
--metric client_time \
--arch arm
mkdir -p tmp
python3 scripts/parse_perf_py.py tmp/perf-output.tsv
References:
references/ci-api.md — performance.ci API and dashboard workflow.
references/local-perf.md — local perf.py, optional server startup, datasets, and profiling.
references/verdict-rules.md — classification rules and reporting checklist.
Data source priority
Use the richest reliable source for each question:
- performance.ci API/dashboard first for current PR runs, full changed-row inventory, confidence, query details, trend/history charts, coverage overlap, and flamegraph-diff links.
- ClickHouse Play HTTP (
default.checks) for 30-day master status counts: how often the same test/query appeared slower, faster, or unstable on master. Use master-checks for this.
- CI artifacts/logs when dashboard/API cannot provide root-cause data: server logs, job logs, raw trace logs, raw profile-event TSVs, exact binary revision/build ID, or complete artifact-manifest details.
- Local
perf.py only when explicit old/new binaries are provided and local validation is useful; report it as non-CI-equivalent.
Standard performance.ci API
BASE="https://performance.ci.clickhouse.com/api/v1"
Find PR runs:
PR=104350
curl -fsS "$BASE/runs?q=$PR" | jq .
Get run overview:
curl -fsS "$BASE/runs/$RUN_ID?metrics=client_time,real_time,cpu_time,memory" | jq .
Get query detail:
curl -fsS "$BASE/runs/$RUN_ID/tests/$TEST/queries/$QUERY_INDEX?metrics=client_time,real_time,cpu_time,memory" | jq .
Get trend/history:
curl -fsS "$BASE/runs/$RUN_ID/tests/$TEST/trend?metric=$METRIC&queryIndex=$QUERY_INDEX&arch=$ARCH" | jq .
curl -fsS "$BASE/history/$TEST/$QUERY_INDEX?metric=$METRIC&arch=$ARCH" | jq .
Get PR coverage overlap for a test:
curl -fsS "$BASE/runs/$RUN_ID/tests/$TEST/coverage?fileSet=intersecting" | jq .
Open the UI flamegraph page first:
https://performance.ci.clickhouse.com/runs/$RUN_ID/tests/$TEST/queries/$QUERY_INDEX
Use the raw flamegraph-diff API only for machine-readable sample deltas:
curl -fsS "$BASE/runs/$RUN_ID/tests/$TEST/queries/$QUERY_INDEX/flamegraph-diff?metric=$METRIC&arch=$ARCH&traceType=CPU" | jq .
API caveats observed in live testing:
unstableQueries[] can contain high-noise/below-threshold rows even when the summary card says Unstable queries: 0; do not treat them as actionable changed rows unless they are over threshold.
trend.selectedRunPoint.value may be a zero marker; use query-detail old/new values for the measurement.
coverage?fileSet=intersecting may omit totals.intersectingFiles when there are zero intersecting files; use len(files) as the fallback.
- Flamegraph endpoints can return 404 or no frames; missing flamegraph data is absence of evidence, not proof of noise.
Evidence ladder
For each suspicious change, collect as much of this ladder as needed:
- Complete changed-row inventory, including improvements: before picking representative examples, list changed slowdowns, changed improvements/speedups, and high-noise/unstable-threshold rows separately for the relevant PR/run/arch. Start with
python3 scripts/perf_api.py pr-inventory --pr <PR> --with-confidence. The inventory must begin with a small generated summary layer: Top likely signal, Top likely noise, and Top improvements. Changed-row tables must separate signal evidence from noise/uncertain evidence. Do not mix high-noise rows into changed slowdown/speedup tables. Use CI artifacts only when dashboard/API cannot provide required raw data.
- Changed row details: test, query, metric, arch, old/new, diff, threshold.
- Repeated PR runs: did the same query/metric/arch change repeatedly over several dashboard runs?
- Confidence data: report confidence tier/reason and named checks (
Raw Sample Evidence, History Adaptive Threshold, Recent Master Variation, etc.). Never leave raw M1/M2/... codes unexplained.
- Master/history trend: report period, all-history p05/p25/p50/p75/p95, recent-window p05/p50/p95, and selected old/new values versus p50/p95. Do not use only min/max/median/last.
- Master status counts when needed: run
python3 scripts/perf_api.py master-checks --pr <PR> to query play.clickhouse.com default.checks over master-only runs and classify rows as new/rare/flaky/unstable/fixes-known-regression. The output should be grouped by signal vs noise/uncertain evidence and link to the test page with the query selected.
- Related metrics: do CPU/real/client/memory move consistently or contradict each other?
- Coverage/PR relation: did the PR touch code covered by this test? does the query text match the changed subsystem?
- Flamegraph/profile: if available, does a stack/function explain the changed metric?
- Artifacts/logs for root cause: if dashboard/API cannot answer why, download CI artifacts for server logs, trace logs, raw profile events, SVG flamegraphs, and exact binary/build metadata.
- Local reproduction: if needed, run
perf.py against old/new binaries with isolated servers.
Stop when the verdict is clear; do not gather expensive evidence just to decorate a report. Exceptions: always inventory improvements as well as regressions; for large PRs or many changed rows, do not stop after a few representative examples; produce a portfolio/ranking summary first. Avoid repetitive all-run top tables unless the caller asks for verbose output or rerun history is central to the conclusion.
Dashboard run stability over multiple days
For a PR, inspect all runs returned by:
curl -fsS "$BASE/runs?q=$PR" | jq '.items[] | {runId: .identity.runId, time: .identity.runTime, changed: .changedQueries, slowdowns: .slowdownQueries, speedups: .speedupQueries}'
For a specific query:
python3 scripts/perf_api.py pr-query-history --pr "$PR" --test "$TEST" --query-index "$QUERY_INDEX" --metric "$METRIC" --arch "$ARCH"
The output must include performance.ci UI links and an oldest→newest diff chart. A missing old run endpoint is not available; do not turn an HTTP status into an investigation conclusion.
Interpretation:
- Appears in every/most PR runs with similar direction: stronger signal.
- Appears once, disappears later: likely noise or needs rerun.
- Flips direction: unstable or measurement-sensitive.
- Appears only on one architecture: still can be real, but must be reported as arch-specific.
Coverage and PR-diff relevance
Fetch coverage overlap:
curl -fsS "$BASE/runs/$RUN_ID/tests/$TEST/coverage?fileSet=intersecting" | jq .
Use:
totals.intersectingFiles > 0: PR touched files executed by the perf test.
files[].path, files[].patch, coverageRanges: identify touched covered lines.
If coverage is unavailable, fall back to:
gh pr diff "$PR" --name-only
gh pr diff "$PR"
Relate query to changes:
- aggregation query + aggregate/Aggregator pipeline changes: plausible.
- join query + analyzer/join/filter changes: plausible.
- formatting/parsing query + IO/read/write helper changes: plausible.
- no overlap and unrelated files: lower confidence, but not automatic dismissal.
Local performance tests
If the user wants to run a test locally or validate a patch, use ClickHouse perf.py.
Print queries first:
tests/performance/scripts/perf.py --print-queries tests/performance/$TEST.xml
Compare two already-running servers:
mkdir -p tmp
tests/performance/scripts/perf.py \
--host 127.0.0.1 127.0.0.1 \
--port ${OLD_TCP_PORT:-9000} ${NEW_TCP_PORT:-9001} \
--runs 7 \
tests/performance/$TEST.xml | tee tmp/${TEST}.perf.tsv
Run one query index:
mkdir -p tmp
tests/performance/scripts/perf.py \
--host 127.0.0.1 127.0.0.1 \
--port ${OLD_TCP_PORT:-9000} ${NEW_TCP_PORT:-9001} \
--runs 7 \
--queries-to-run "$QUERY_INDEX" \
tests/performance/$TEST.xml | tee tmp/${TEST}_${QUERY_INDEX}.perf.tsv
Parse output:
python3 scripts/parse_perf_py.py tmp/${TEST}_${QUERY_INDEX}.perf.tsv
If servers are not running and the user wants help, use references/local-perf.md and optionally scripts/local_servers.sh. Prefer release/static builds. Do not compare debug/sanitizer builds unless the user explicitly wants that.
Local binary contract:
scripts/local_servers.sh requires explicit local paths: OLD_CLICKHOUSE NEW_CLICKHOUSE [WORKDIR].
- If default ports are busy, set
OLD_TCP_PORT, NEW_TCP_PORT, OLD_HTTP_PORT, NEW_HTTP_PORT.
- If paths are missing, ask the user/caller for the old/base and new/candidate binary paths. A skill invocation is just an instruction context; there is no magic binary provider, so ask the human or calling agent rather than guessing.
- Do not silently download or build binaries.
- If the user does not have binaries, suggest exact Praktika CI artifacts and ask for approval before downloading.
Praktika CI artifact lookup for performance-style release binaries:
python3 scripts/perf_api.py runs --pr "$PR"
PR=<pr-number>
SHA=<candidate-sha>
ARCH=arm
JOB="build_${ARCH}_release"
REPORT="https://clickhouse-builds.s3.amazonaws.com/PRs/${PR}/${SHA}/${JOB}/artifact_report_${JOB}.json"
curl -fsS "$REPORT" | jq -r '.build_urls[] | select(endswith("/clickhouse"))'
SHA=<reference-sha>
URL="https://clickhouse-builds.s3.amazonaws.com/REFs/master/${SHA}/${JOB}/clickhouse"
curl -sfI "$URL"
If the user approves a URL, download it to a named path and chmod +x; then pass those paths to local_servers.sh. Record binary path, SHA, architecture, build job, source URL, and version()/buildId() output in the report. Avoid non-SHA-pinned master/amd64/clickhouse or master/aarch64/clickhouse fallbacks for validation unless the user explicitly accepts them.
Local result interpretation
perf.py prints important lines like:
report-threshold <relative_threshold>
diff <query_index> <old_median> <new_median> <relative_diff> <pvalue>
Treat a local result as meaningful when:
abs(relative_diff) exceeds the report threshold.
pvalue <= 0.05.
- enough runs were collected.
- repeat runs agree if the result is borderline.
- machine load/config/dataset differences are not obvious confounders.
Verdict labels
Use one of:
real regression
likely noise
needs rerun
unstable test
real improvement
local-only evidence
not enough evidence
Required report format
## Performance verdict
Short verdict: <label>
Confidence: <high | medium | low>
## Evidence checked
- Run/API URLs:
- Test/query/metric/arch:
- Old/new/diff/threshold:
- Repeated PR runs:
- Confidence:
- History/trend:
- Coverage/PR relation:
- Flamegraph/profile: exact UI query-page link plus top sample deltas; raw API link only as backup, or explicit `not available` / `no frames returned`.
- Local perf.py, if any:
## Changed metrics
<table>
## Why this verdict
Short explanation that distinguishes current accepted evidence from stale/exploratory measurements.
## Recommended next step
- no action
- rerun performance CI
- run local perf.py
- inspect flamegraph hotspot
- inspect PR code around covered files
- bisect / targeted local validation
Common mistakes to avoid
- Showing stale candidate measurements under a newer rerun/history verdict.
- Calling a PR regression real without checking repeated PR runs and history.
- Dismissing as flaky without dashboard/history evidence.
- Mixing metrics or architectures.
- Treating coverage overlap as proof of causality.
- Treating local-only results as CI-confirmed.
- Hiding changed rows that CI marked as changed.