|
1 | 1 | # Internal Performance Benchmarks |
2 | 2 |
|
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. |
4 | 6 |
|
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`. |
6 | 10 |
|
7 | | -## Setup |
| 11 | +> `benchmark/` (singular) is the legacy external-framework suite. |
| 12 | +> `benchmarks/` (plural) is this internal suite. |
8 | 13 |
|
9 | | -```bash |
10 | | -pip install -e ".[benchmarks]" |
11 | | -``` |
| 14 | +## Layout |
12 | 15 |
|
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. |
14 | 20 |
|
15 | | -```bash |
16 | | -# Quick smoke test (small sizes only) |
17 | | -pytest benchmarks/ --quick |
| 21 | +## Models vs patterns |
18 | 22 |
|
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: |
21 | 25 |
|
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?" |
25 | 32 |
|
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. |
27 | 38 |
|
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(...)`. |
32 | 43 |
|
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 |
36 | 45 |
|
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 |
39 | 49 | ``` |
40 | 50 |
|
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`. |
42 | 53 |
|
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. |
44 | 57 |
|
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 |
48 | 59 |
|
49 | 60 | ```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 |
66 | 64 | ``` |
67 | 65 |
|
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. |
83 | 69 |
|
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 |
89 | 71 |
|
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. |
91 | 79 |
|
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. |
0 commit comments