Skip to content

Commit 35bd6bf

Browse files
authored
bench: internal performance benchmark suite + CodSpeed CI (#781)
1 parent 86baf59 commit 35bd6bf

41 files changed

Lines changed: 1655 additions & 529 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/dependabot.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,30 @@ updates:
1212
github-actions:
1313
patterns:
1414
- '*'
15+
16+
# Pinned ``[benchmarks]`` extra in pyproject.toml. One PR per dep bump
17+
# → CodSpeed CI runs and attributes any perf delta to that specific
18+
# bump. Keeps the cross-version ``sweep`` baseline (lockfile-pinned)
19+
# stable while still surfacing upstream perf changes per-PR with
20+
# eyes-open review. Loose ``[project.dependencies]`` (numpy, scipy, ...)
21+
# have no version specifier so Dependabot leaves them alone — only the
22+
# ``==`` pins in ``[benchmarks]`` produce PRs.
23+
- package-ecosystem: pip
24+
directory: /
25+
schedule:
26+
interval: monthly
27+
open-pull-requests-limit: 5
28+
groups:
29+
# Measurement scaffolding + CLI/notebook tooling. Perf-irrelevant —
30+
# they don't move CodSpeed signal, so batching into one PR cuts
31+
# review noise. Perf-relevant deps (numpy, xarray, highspy, …) stay
32+
# un-grouped so each gets its own attributed CodSpeed delta.
33+
benchmark-tooling:
34+
patterns:
35+
- pytest
36+
- pytest-benchmark
37+
- pytest-memray
38+
- pytest-codspeed
39+
- nbconvert
40+
- typer
41+
- plotly
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: Benchmark smoke
2+
3+
# Builds every spec and fires every phase once (--benchmark-disable):
4+
# a "did a refactor break a spec?" check, not timing.
5+
6+
on:
7+
push:
8+
branches: [ master ]
9+
pull_request:
10+
branches: [ '*' ]
11+
12+
concurrency:
13+
group: ${{ github.workflow }}-${{ github.ref }}
14+
cancel-in-progress: true
15+
16+
jobs:
17+
smoke:
18+
name: Benchmark smoke (quick)
19+
runs-on: ubuntu-latest
20+
21+
steps:
22+
- uses: actions/checkout@v6
23+
with:
24+
fetch-depth: 0 # setuptools_scm
25+
26+
- name: Set up Python 3.13
27+
uses: actions/setup-python@v6
28+
with:
29+
python-version: "3.13"
30+
31+
- name: Install package and benchmark dependencies
32+
run: |
33+
python -m pip install uv
34+
uv pip install --system -e ".[dev,benchmarks]"
35+
36+
- name: Run benchmark smoke
37+
# Every spec builds at one size and every phase fires once, no timings.
38+
run: |
39+
pytest benchmarks/ --benchmark-disable -q

.github/workflows/codspeed.yml

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
name: CodSpeed
2+
3+
# Both instruments in one workflow (CodSpeed aggregates per-commit results from a
4+
# single workflow). Memory: heap tracking on a free GitHub runner, every PR +
5+
# master. Walltime: bare-metal macro runner, master + the maintainer-gated
6+
# `trigger:benchmark` label only.
7+
8+
on:
9+
push:
10+
branches: [ master ]
11+
pull_request:
12+
branches: [ master ]
13+
types: [ opened, synchronize, reopened, labeled ]
14+
workflow_dispatch:
15+
16+
concurrency:
17+
group: ${{ github.workflow }}-${{ github.ref }}
18+
cancel-in-progress: true
19+
20+
jobs:
21+
memory:
22+
name: Memory (heap)
23+
runs-on: ubuntu-latest
24+
continue-on-error: true # informational, never blocks a merge
25+
permissions:
26+
contents: read # actions/checkout
27+
id-token: write # OIDC auth with CodSpeed — no token secret
28+
steps:
29+
- uses: actions/checkout@v6
30+
with:
31+
fetch-depth: 0 # setuptools_scm
32+
- name: Set up Python 3.13
33+
uses: actions/setup-python@v6
34+
with:
35+
python-version: "3.13"
36+
- name: Install pinned benchmark environment
37+
run: |
38+
python -m pip install uv
39+
uv pip install --system -e ".[dev,benchmarks]"
40+
- name: Run benchmarks under CodSpeed (memory)
41+
uses: CodSpeedHQ/action@v4
42+
with:
43+
mode: memory
44+
run: |
45+
pytest benchmarks/ --codspeed
46+
47+
walltime:
48+
name: Walltime (macro runner)
49+
# Master push / dispatch always; PRs only when explicitly labelled — macro
50+
# minutes are metered and bare-metal shouldn't run arbitrary PR code.
51+
if: >-
52+
${{ github.event_name != 'pull_request' ||
53+
contains(github.event.pull_request.labels.*.name, 'trigger:benchmark') }}
54+
runs-on: codspeed-macro
55+
continue-on-error: true
56+
permissions:
57+
contents: read
58+
id-token: write
59+
steps:
60+
- uses: actions/checkout@v6
61+
with:
62+
fetch-depth: 0 # setuptools_scm
63+
- name: Set up Python 3.13
64+
uses: actions/setup-python@v6
65+
with:
66+
python-version: "3.13"
67+
- name: Install pinned benchmark environment
68+
run: |
69+
python -m pip install uv
70+
uv pip install --system -e ".[dev,benchmarks]"
71+
- name: Run benchmarks under CodSpeed (walltime)
72+
uses: CodSpeedHQ/action@v4
73+
with:
74+
mode: walltime
75+
run: |
76+
pytest benchmarks/ --codspeed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ benchmark/scripts/__pycache__
4545
benchmark/scripts/benchmarks-pypsa-eur/__pycache__
4646
benchmark/scripts/leftovers/
4747

48+
# Benchmarks (internal suite): regenerable .ipynb viewing artifacts
49+
benchmarks/walkthrough.ipynb
50+
benchmarks/.ipynb_checkpoints/
51+
4852
# IDE
4953
.idea/
5054

benchmarks/README.md

Lines changed: 57 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,94 +1,81 @@
11
# Internal Performance Benchmarks
22

3-
Measures linopy's own performance (build time, LP write speed, memory usage) across problem sizes using [pytest-benchmark](https://pytest-benchmark.readthedocs.io/) and [pytest-memray](https://pytest-memray.readthedocs.io/). Use these to check whether a code change introduces a regression or improvement.
3+
End-to-end performance tracking for `linopy` — build → matrix generation →
4+
LP / netCDF (de)serialization → solver handoff → a fixed PyPSA model. Solver
5+
algorithm runtime is out of scope.
46

5-
> **Note:** The `benchmark/` directory (singular) contains *external* benchmarks comparing linopy against other modeling frameworks. This directory (`benchmarks/`) is for *internal* performance tracking only.
7+
The suite is a set of `pytest-benchmark` tests driven by a model registry.
8+
**CodSpeed** measures them in CI (walltime on dedicated runners, memory on every
9+
PR); locally you just run `pytest`.
610

7-
## Setup
11+
> `benchmark/` (singular) is the legacy external-framework suite.
12+
> `benchmarks/` (plural) is this internal suite.
813
9-
```bash
10-
pip install -e ".[benchmarks]"
11-
```
14+
## Layout
1215

13-
## Running benchmarks
16+
- `registry.py`, `phases.py`, `conftest.py` — the harness (specs, measured
17+
verbs, pytest wiring).
18+
- `models/`, `patterns/` — the subjects; each file self-registers one `BenchSpec`.
19+
- `drivers/` — one `test_<phase>.py` per measured phase.
1420

15-
```bash
16-
# Quick smoke test (small sizes only)
17-
pytest benchmarks/ --quick
21+
## Models vs patterns
1822

19-
# Full timing benchmarks
20-
pytest benchmarks/test_build.py benchmarks/test_lp_write.py benchmarks/test_matrices.py
23+
Two kinds of benchmark spec, same harness and same phases, distinguished by
24+
their sweep axis:
2125

22-
# Run a specific model
23-
pytest benchmarks/test_build.py -k basic
24-
```
26+
- **Models** (`models/`, `REGISTRY`) — whole `linopy.Model`s swept over
27+
`size` (axis `n`): "how does cost scale with the problem?"
28+
- **Patterns** (`patterns/`, `PATTERNS`) — fragments of realistic modelling
29+
code (a balance constraint, a KVL contraction) swept over `severity`
30+
(0–100, axis `severity`): "how does cost respond as one data shape goes
31+
from benign to pathological?"
2532

26-
## Comparing timing between branches
33+
Both kinds build a complete `linopy.Model`, so both run the **same phases** and
34+
share the phase drivers (`drivers/test_build.py`, `drivers/test_matrices.py`, …)
35+
— they're just more `(spec, value)` rows, tagged by `axis`. There is no separate
36+
pattern driver. Running a pattern through `build` *and* `to_lp` shows whether a
37+
dense-`_term` blow-up propagates to export or collapses.
2738

28-
```bash
29-
# Save baseline results on master
30-
git checkout master
31-
pytest benchmarks/test_build.py --benchmark-save=master
39+
Patterns target the operations where the dense-`_term` representation forces
40+
materialisation — `groupby().sum()` padding, sparse `@` densification — so a
41+
`severity` sweep draws the cost cliff. Adding either kind is one file: drop it
42+
in `models/` or `patterns/`, call `register(...)` / `register_pattern(...)`.
3243

33-
# Switch to feature branch and compare
34-
git checkout my-feature
35-
pytest benchmarks/test_build.py --benchmark-save=my-feature --benchmark-compare=0001_master
44+
## Install
3645

37-
# Compare saved results without re-running
38-
pytest-benchmark compare 0001_master 0002_my-feature --columns=median,iqr
46+
```bash
47+
uv sync --extra dev --extra benchmarks
48+
source .venv/bin/activate
3949
```
4050

41-
Results are stored in `.benchmarks/` (gitignored).
51+
`pypsa` is optional — `pypsa_scigrid` and `drivers/test_pypsa_carbon_management.py`
52+
skip gracefully without it: `uv pip install pypsa`.
4253

43-
## Memory benchmarks
54+
The `[benchmarks]` extra in `pyproject.toml` pins every direct dep that affects
55+
measurement (`numpy`, `scipy`, `xarray`, `pandas`, `polars`, `dask`, …) so
56+
run-to-run deltas reflect linopy changes, not dependency bumps.
4457

45-
`memory.py` runs each test in a separate process with pytest-memray to get accurate per-test peak memory (including C/numpy allocations). Results are saved as JSON and can be compared across branches.
46-
47-
By default, only the build phase (`test_build.py`) is measured. Unlike timing benchmarks where `benchmark()` isolates the measured function, memray tracks all allocations within a test — including model construction in setup. This means LP write and matrix tests would report build + phase memory combined, making the phase-specific contribution impossible to isolate. Since model construction dominates memory usage, measuring build alone gives the most actionable numbers.
58+
## Running
4859

4960
```bash
50-
# Save baseline on master
51-
git checkout master
52-
python benchmarks/memory.py save master
53-
54-
# Save feature branch
55-
git checkout my-feature
56-
python benchmarks/memory.py save my-feature
57-
58-
# Compare
59-
python benchmarks/memory.py compare master my-feature
60-
61-
# Quick mode (smaller sizes, faster)
62-
python benchmarks/memory.py save master --quick
63-
64-
# Measure a specific phase (includes build overhead)
65-
python benchmarks/memory.py save master --test-path benchmarks/test_lp_write.py
61+
pytest benchmarks/ # the suite
62+
pytest benchmarks/ --benchmark-disable -q # smoke: every spec builds once
63+
pytest benchmarks/ --pipeline # + the opt-in end-to-end pipeline test
6664
```
6765

68-
Results are stored in `.benchmarks/memory/` (gitignored). Requires Linux or macOS (memray is not available on Windows).
69-
70-
> **Note:** Small tests (~5 MiB) are near the import-overhead floor and may show noise of ~1 MiB between runs. Focus on larger tests for meaningful memory comparisons. Do not combine `--memray` with timing benchmarks — memray adds ~2x overhead that invalidates timing results.
71-
72-
## Models
73-
74-
| Model | Description | Sizes |
75-
|-------|-------------|-------|
76-
| `basic` | Dense N*N model, 2*N^2 vars/cons | 10 — 1600 |
77-
| `knapsack` | N binary variables, 1 constraint | 100 — 1M |
78-
| `expression_arithmetic` | Broadcasting, scaling, summation across dims | 10 — 1000 |
79-
| `sparse_network` | Ring network with mismatched bus/line coords | 10 — 1000 |
80-
| `pypsa_scigrid` | Real power system (requires `pypsa`) | 10 — 200 snapshots |
81-
82-
## Phases
66+
Each spec declares one `sizes` (models) / `severities` (patterns) tuple — a
67+
small representative set, kept tight because CodSpeed measures it on every PR.
68+
Need a scaling curve? That's a local pytest-benchmem job, not this suite.
8369

84-
| Phase | File | What it measures |
85-
|-------|------|------------------|
86-
| Build | `test_build.py` | Model construction (add_variables, add_constraints, add_objective) |
87-
| LP write | `test_lp_write.py` | Writing the model to an LP file |
88-
| Matrices | `test_matrices.py` | Generating sparse matrices (A, b, c, bounds) from the model |
70+
## CI
8971

90-
## Adding a new model
72+
- **Smoke** (`benchmark-smoke.yml`) — every PR: every spec builds and every
73+
phase fires once under `--benchmark-disable`. A "did a refactor break a
74+
spec?" check, not timing.
75+
- **CodSpeed** (`codspeed.yml`) — two jobs: **memory** (heap-allocation
76+
tracking, every PR, free GitHub runner) and **walltime** (bare-metal macro
77+
runner, on `master` or a PR labelled `trigger:benchmark`). Informational,
78+
non-gating.
9179

92-
1. Create `benchmarks/models/my_model.py` with a `build_my_model(n)` function and a `SIZES` list
93-
2. Add parametrized tests in the relevant `test_*.py` files
94-
3. Add a quick threshold in `conftest.py`
80+
Activating CodSpeed upstream needs a maintainer to connect the repo to the
81+
CodSpeed app (OIDC auth, no token secret); the workflows are already wired.

benchmarks/__init__.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,15 @@
1-
"""Linopy benchmark suite — run with ``pytest benchmarks/`` (use ``--quick`` for smaller sizes)."""
1+
"""
2+
Linopy benchmark suite — run with ``pytest benchmarks/``.
3+
4+
The model registry it drives is reusable on its own::
5+
6+
from benchmarks import REGISTRY
7+
model = REGISTRY["basic"].build(100)
8+
"""
9+
10+
# Importing the models / patterns packages triggers each module's
11+
# ``register(...)`` / ``register_pattern(...)`` call at import time.
12+
from benchmarks import models, patterns # noqa: F401
13+
from benchmarks.registry import PATTERNS, REGISTRY
14+
15+
__all__ = ["PATTERNS", "REGISTRY"]

0 commit comments

Comments
 (0)