Performance¶
pycatdap ships a benchmark suite (benchmarks/) that tracks the runtime of the
hot paths so performance regressions can be caught. This page documents how to
run it and records a baseline.
Scope
Phase 1 (issue #29) added the locally runnable harness and the
baseline below. Phase 2 + 3 (issue #161) add a non-blocking
benchmark workflow (see below): a nightly
run plus a delta comment on code-changing PRs. Benchmarks are intentionally
never part of make ci — they do not run in the standard test gate, and
the workflow never blocks a PR or commit.
Running the benchmarks¶
This runs every benchmark under benchmarks/ via
pytest-benchmark (a dev-only dependency in the bench
dependency group; it is never installed for make ci and never shipped in the
wheel). The suite lives outside testpaths, so make test and make ci do not
collect it.
Pass extra flags with BENCH_ARGS:
# Skip the slow slice-discovery benchmark
make bench BENCH_ARGS="-m 'not slow'"
# Save a named baseline and compare a later run against it
make bench BENCH_ARGS="--benchmark-save=baseline"
make bench BENCH_ARGS="--benchmark-compare=0001 --benchmark-compare-fail=mean:20%"
# Emit machine-readable JSON
make bench BENCH_ARGS="--benchmark-json=results.json"
What is measured¶
| Benchmark | Function | Workload |
|---|---|---|
bench_catdap1 |
catdap1 |
100 / 1k / 10k / 100k rows × 10 all-categorical columns (default all-response call) |
bench_catdap2 |
catdap2 |
100 / 1k / 10k rows × 5 / 10 mixed columns, nvar=5 |
bench_pooling |
optimal_binning |
1k / 10k / 100k rows, bottom-up (unequal) pooling |
bench_discovery |
discover_error_slices |
Adult-Income-like synthetic frame (5k rows × 14 mixed columns), max_vars=2 — marked slow |
All inputs are generated by deterministic, fixed-seed synthetic generators
(benchmarks/_data.py), so the workload is identical across runs and machines;
only timing varies.
Baseline¶
Recorded with make bench on the following machine. Absolute numbers are
hardware-dependent — treat them as a relative reference, not a contract.
- CPU: Intel Core i9-14900K
- OS / arch: Linux x86_64 (WSL2)
- Python: 3.11.14
- pycatdap: 0.13.x (develop)
- Date: 2026-06-07
Times are the minimum over the measured rounds (least noisy estimator), in seconds.
catdap1 — pairwise categorical AIC (10 columns)¶
| Rows | Min (s) |
|---|---|
| 100 | 0.36 |
| 1,000 | 0.39 |
| 10,000 | 0.62 |
| 100,000 | 2.79 |
catdap2 — AIC subset search (nvar=5, mixed types)¶
| Rows | 5 cols (s) | 10 cols (s) |
|---|---|---|
| 100 | 0.08 | 0.20 |
| 1,000 | 0.26 | 0.64 |
| 10,000 | 1.81 | 4.30 |
optimal_binning — AIC-optimal continuous binning (bottom-up)¶
| Rows | Min (s) |
|---|---|
| 1,000 | 0.010 |
| 10,000 | 0.017 |
| 100,000 | 0.055 |
discover_error_slices — bounded slice discovery (14 columns)¶
| Workload | Min (s) |
|---|---|
Adult-Income-like, 5k rows, max_vars=2 |
6.98 |
This is the #20 acceptance scenario (CATDAP-02 on a 14-column dataset). At these bounded parameters it completes well under the 30 s target. See the caveats below for why it is bounded and synthetic.
Methodology and caveats¶
- Synthetic, not real datasets.
bench_discoverymimics the Adult Income shape (6 continuous + 8 categorical columns) rather than callingfetch_adult_income. Fetching from OpenML would inject network-timing variance into the measurement and, with scikit-learn installed, can hang the full test run for hours. - Bounded by design. Unbounded slice discovery on the full Adult Income has
caused out-of-memory crashes. The discovery benchmark is therefore subsampled
to 5k rows with
max_vars=2and a cappedmax_candidates, and is markedslowso it is opt-out viaBENCH_ARGS="-m 'not slow'". nvaris pinned for catdap2. Withnvar=None,catdap2searches every subset of all columns (2^cols), which is intractable past ~10 columns. The benchmark pinsnvar=5to measure a realistic, bounded search.- Continuous data has finite precision.
optimal_binningstarts from fine-grained bins of widthaccuracy(auto-detected as the smallest gap between sorted unique values), then greedily merges. All-unique random floats would explode the initial bin count, making the merge ~O(n_bins³). The auto-accuracy path therefore caps the initial grid at 256 bins and warns when it does so (issue #164 / H-0024), keeping the cost bounded for any input; pass an explicitaccuracyfor finer control. The generators still use bounded-precision (rounded) continuous values, so the benchmark measures the genuine O(rows) cost rather than the capped fallback. - Hosted-runner noise. GitHub-hosted runners share CPU and show high timing variance, which is why the CI regression check is a non-blocking nightly alert rather than a per-PR gate.
Continuous benchmarking (nightly)¶
The Benchmarks workflow (.github/workflows/benchmarks.yml) runs the suite on
a GitHub-hosted runner and records the results so trends and regressions are
visible over time. It is non-blocking by design — it never fails a PR or
commit.
- Schedule: nightly (03:00 UTC) plus manual
workflow_dispatch. Scheduled runs fire from the default branch (main), so the nightly cadence activates once this workflow reachesmain(a release); until then, trigger it manually ondevelop. - History + dashboard: stored by
github-action-benchmarkin thegh-pagesbranch underdev/bench/. The public docs site is served from the GitHub Actions artifact (not a branch), so the chart is not on the public site — view it by checking out thegh-pagesbranch. The rawoutput.jsonis also uploaded as a workflow artifact each run. - Regression alert: if a benchmark is ≥ 1.5× slower than the previous
recorded run, the action posts a commit comment (
alert-threshold: 150%, intentionally above +20% because hosted-runner timing is noisy). It does not fail the run (fail-on-alert: false). - On PRs touching
src/pycatdap/**(or the benchmark plumbing), the workflow runs the non-slow subset, compares against thegh-pagesbaseline without writing history, and leaves a delta commit comment on the PR head commit (same-repo PRs; fork PRs validate read-only with no comment). It never blocks the PR.