Skip to content

Commit 792e6d8

Browse files
authored
bench: arithmetic operation micro-benchmarks (#805)
* bench: add arithmetic operation micro-benchmarks An op-level tier alongside the whole-model builds: one benchmark per (operation, size profile), operands built outside the measured region so a run isolates a single op rather than a whole build. This attributes perf changes to a specific arithmetic path — a build benchmark says "kvl got heavier", an op benchmark says "expr+expr broadcast got heavier". - benchmarks/ops.py: op registry (OpSpec) + size profiles (small 1D×2000; large 3D×3×4×1000 — differ in element count *and* dim count; the asymmetric shape also catches dim-order bugs) + ~30 ops across scaling, var/expr arithmetic, quadratic, reductions and constraint construction. Binary labelled ops carry match/broadcast variants — the alignment-path axis where the interesting regressions live. - benchmarks/drivers/test_ops.py: parametrized driver, one benchmark per (op, profile). - conftest: add test_ops to CODSPEED_MODULES (tracked; memory advisory). 60 benchmarks, ~80s/run with memory. Signal validates: large ≈ 6× small, broadcast ≈ 5× match (the §9 cross-product). * bench(ops): single 3-D profile; add masking/groupby/merge ops Collapse to one 3-D profile (3×4×1000, ~12 K elements) — CodSpeed records time *and* memory per benchmark, so a second size wasn't buying a separate signal; one multi-dim profile keeps broadcast/alignment coverage with MB-scale ops above the noise floor, and halves the matrix. Benchmark ids drop the size suffix. Add three categories: absence/masking (expr.where / fillna / absence propagation — §4–§7, the semantics-heavy surface), groupby.sum, and an N-way merge (constraint-assembly cost). 35 ops, ~45 s/run with memory.
1 parent 848366b commit 792e6d8

3 files changed

Lines changed: 327 additions & 0 deletions

File tree

benchmarks/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"test_build",
2121
"test_to_lp",
2222
"test_to_solver",
23+
"test_ops",
2324
)
2425

2526

benchmarks/drivers/test_ops.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""
2+
Arithmetic operation micro-benchmarks.
3+
4+
One benchmark per ``(operation, size profile)`` — the operands are built in
5+
setup (not measured) and the fixture measures a single ``op(*operands)``. See
6+
:mod:`benchmarks.ops` for the op registry and the size / alignment axes.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from collections.abc import Callable
12+
13+
import pytest
14+
15+
from benchmarks.ops import GRID, OpSpec, iter_ops
16+
17+
_OPS = iter_ops()
18+
19+
20+
@pytest.mark.parametrize("op", _OPS, ids=[op.name for op in _OPS])
21+
def test_op(benchmark: Callable[..., object], op: OpSpec) -> None:
22+
operands = op.setup(GRID)
23+
benchmark(op.op, *operands)

benchmarks/ops.py

Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
"""
2+
Registry of arithmetic *operation* micro-benchmarks.
3+
4+
Where :mod:`benchmarks.registry` benchmarks whole model builds, this benchmarks
5+
single operations — ``var * array``, ``expr + expr``, ``expr <= c`` — with the
6+
operands built *outside* the measured region, so a run isolates one op rather
7+
than a whole build. That granularity attributes regressions to a specific path
8+
(a whole-build benchmark says "kvl got heavier"; an op benchmark says "expr+expr
9+
broadcast got heavier").
10+
11+
One 3-D size profile (``3×4×1000``, ~12 K elements): multi-dim so it exercises
12+
broadcast/alignment across dims; ~MB-scale ops sit above the memory-measurement
13+
noise floor; the asymmetric shape catches dim-order/transpose bugs. CodSpeed
14+
records time *and* memory on every benchmark, so a second size isn't needed to
15+
separate the two signals.
16+
17+
The one axis beyond the op itself is **alignment** — for binary labelled ops,
18+
``match`` (identical coords, the fast path) vs ``broadcast`` (an extra dim → §9
19+
cross-product). That's where the alignment-path regressions live, so it's
20+
first-class, not incidental.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
from collections.abc import Callable
26+
from dataclasses import dataclass
27+
28+
import numpy as np
29+
import pandas as pd
30+
import xarray as xr
31+
32+
import linopy
33+
34+
# --- size profiles ----------------------------------------------------------
35+
36+
37+
@dataclass(frozen=True)
38+
class Profile:
39+
"""A benchmark size: named dimensions and their lengths."""
40+
41+
key: str
42+
dims: tuple[str, ...]
43+
shape: tuple[int, ...]
44+
45+
@property
46+
def size(self) -> int:
47+
return int(np.prod(self.shape))
48+
49+
50+
GRID = Profile("grid", ("d0", "d1", "d2"), (3, 4, 1000))
51+
52+
# a broadcast operand always adds this one extra dim (kept small so the
53+
# cross-product stays cheap while still exercising the broadcast path)
54+
EXTRA_DIM = "b"
55+
EXTRA_LEN = 5
56+
57+
58+
# --- operand builders (run in setup, never measured) ------------------------
59+
60+
61+
def _coords(dims: tuple[str, ...], shape: tuple[int, ...]) -> dict[str, pd.Index]:
62+
return {d: pd.RangeIndex(n, name=d) for d, n in zip(dims, shape)}
63+
64+
65+
def var(profile: Profile, name: str = "x") -> linopy.Variable:
66+
"""A variable spanning the profile's dimensions."""
67+
m = linopy.Model()
68+
return m.add_variables(
69+
coords=list(_coords(profile.dims, profile.shape).values()),
70+
dims=list(profile.dims),
71+
name=name,
72+
)
73+
74+
75+
def array(profile: Profile) -> xr.DataArray:
76+
"""A coefficient array matching the profile's dims (the ``match`` case)."""
77+
return xr.DataArray(
78+
np.linspace(-1.0, 1.0, profile.size).reshape(profile.shape),
79+
dims=list(profile.dims),
80+
coords=_coords(profile.dims, profile.shape),
81+
)
82+
83+
84+
def extra_array(_: Profile) -> xr.DataArray:
85+
"""An array on a *new* dim — broadcasting it introduces that dim (§9)."""
86+
return xr.DataArray(
87+
np.linspace(1.0, 2.0, EXTRA_LEN),
88+
dims=[EXTRA_DIM],
89+
coords={EXTRA_DIM: pd.RangeIndex(EXTRA_LEN, name=EXTRA_DIM)},
90+
)
91+
92+
93+
def extra_var(profile: Profile, name: str = "z") -> linopy.Variable:
94+
"""A variable on a *new* dim — for var+var broadcast."""
95+
m = linopy.Model()
96+
return m.add_variables(
97+
coords=[pd.RangeIndex(EXTRA_LEN, name=EXTRA_DIM)], dims=[EXTRA_DIM], name=name
98+
)
99+
100+
101+
def expr(profile: Profile) -> linopy.LinearExpression:
102+
"""A linear expression spanning the profile's dims (coeffs vary)."""
103+
return array(profile) * var(profile)
104+
105+
106+
def cond(profile: Profile) -> xr.DataArray:
107+
"""A boolean mask over the profile's dims (~half the slots)."""
108+
return array(profile) > 0.0
109+
110+
111+
def masked_expr(profile: Profile) -> linopy.LinearExpression:
112+
"""An expression carrying absence (§4) — masked in place."""
113+
return expr(profile).where(cond(profile))
114+
115+
116+
def grouped_expr(profile: Profile) -> linopy.LinearExpression:
117+
"""An expression with a coarse ``g`` group coord on the last dim (8 groups)."""
118+
last, n = profile.dims[-1], profile.shape[-1]
119+
g = xr.DataArray(
120+
np.arange(n) * 8 // n,
121+
dims=[last],
122+
coords={last: pd.RangeIndex(n, name=last)},
123+
)
124+
return expr(profile).assign_coords(g=g)
125+
126+
127+
# --- op registry ------------------------------------------------------------
128+
129+
130+
@dataclass(frozen=True)
131+
class OpSpec:
132+
"""One operation benchmark: build operands, then measure ``op(*operands)``."""
133+
134+
name: str
135+
group: str
136+
setup: Callable[[Profile], tuple]
137+
op: Callable[..., object]
138+
139+
140+
OP_REGISTRY: dict[str, OpSpec] = {}
141+
142+
143+
def register_op(
144+
name: str,
145+
group: str,
146+
setup: Callable[[Profile], tuple],
147+
op: Callable[..., object],
148+
) -> None:
149+
if name in OP_REGISTRY:
150+
raise ValueError(f"op {name!r} already registered")
151+
OP_REGISTRY[name] = OpSpec(name, group, setup, op)
152+
153+
154+
def iter_ops() -> list[OpSpec]:
155+
"""Every registered op — the pytest parametrize source."""
156+
return list(OP_REGISTRY.values())
157+
158+
159+
# --- the operations ---------------------------------------------------------
160+
# Binary labelled ops register a `match` and a `broadcast` variant; the
161+
# alignment case is baked into the operands the setup builds.
162+
163+
# scaling / construction
164+
register_op("var_mul_scalar", "scale", lambda p: (var(p),), lambda x: 2.0 * x)
165+
register_op("var_div_scalar", "scale", lambda p: (var(p),), lambda x: x / 2.0)
166+
register_op("var_neg", "scale", lambda p: (var(p),), lambda x: -x)
167+
register_op("var_to_linexpr", "scale", lambda p: (var(p),), lambda x: 1 * x)
168+
register_op(
169+
"var_mul_array_match", "scale", lambda p: (var(p), array(p)), lambda x, a: a * x
170+
)
171+
register_op(
172+
"var_mul_array_bcast",
173+
"scale",
174+
lambda p: (var(p), extra_array(p)),
175+
lambda x, a: a * x,
176+
)
177+
178+
# variable arithmetic
179+
register_op("var_add_scalar", "var_arith", lambda p: (var(p),), lambda x: x + 2.0)
180+
register_op(
181+
"var_add_array_match", "var_arith", lambda p: (var(p), array(p)), lambda x, a: x + a
182+
)
183+
register_op(
184+
"var_add_array_bcast",
185+
"var_arith",
186+
lambda p: (var(p), extra_array(p)),
187+
lambda x, a: x + a,
188+
)
189+
register_op(
190+
"var_add_var_match",
191+
"var_arith",
192+
lambda p: (var(p, "x"), var(p, "y")),
193+
lambda x, y: x + y,
194+
)
195+
register_op(
196+
"var_add_var_bcast",
197+
"var_arith",
198+
lambda p: (var(p, "x"), extra_var(p)),
199+
lambda x, z: x + z,
200+
)
201+
register_op(
202+
"var_sub_var_match",
203+
"var_arith",
204+
lambda p: (var(p, "x"), var(p, "y")),
205+
lambda x, y: x - y,
206+
)
207+
208+
# quadratic
209+
register_op(
210+
"var_mul_var", "quad", lambda p: (var(p, "x"), var(p, "y")), lambda x, y: x * y
211+
)
212+
register_op(
213+
"expr_mul_var", "quad", lambda p: (expr(p), var(p, "y")), lambda e, y: e * y
214+
)
215+
216+
# expression arithmetic
217+
register_op("expr_add_scalar", "expr_arith", lambda p: (expr(p),), lambda e: e + 2.0)
218+
register_op(
219+
"expr_add_array_match",
220+
"expr_arith",
221+
lambda p: (expr(p), array(p)),
222+
lambda e, a: e + a,
223+
)
224+
register_op(
225+
"expr_add_array_bcast",
226+
"expr_arith",
227+
lambda p: (expr(p), extra_array(p)),
228+
lambda e, a: e + a,
229+
)
230+
register_op(
231+
"expr_add_var", "expr_arith", lambda p: (expr(p), var(p, "y")), lambda e, y: e + y
232+
)
233+
register_op(
234+
"expr_add_expr_match",
235+
"expr_arith",
236+
lambda p: (expr(p), expr(p)),
237+
lambda a, b: a + b,
238+
)
239+
register_op(
240+
"expr_add_expr_bcast",
241+
"expr_arith",
242+
lambda p: (expr(p), extra_array(p) * var(p)),
243+
lambda a, b: a + b,
244+
)
245+
register_op(
246+
"expr_sub_expr_match",
247+
"expr_arith",
248+
lambda p: (expr(p), expr(p)),
249+
lambda a, b: a - b,
250+
)
251+
register_op("expr_mul_scalar", "expr_arith", lambda p: (expr(p),), lambda e: 2.0 * e)
252+
register_op(
253+
"expr_mul_array_match",
254+
"expr_arith",
255+
lambda p: (expr(p), array(p)),
256+
lambda e, a: a * e,
257+
)
258+
register_op(
259+
"expr_mul_array_bcast",
260+
"expr_arith",
261+
lambda p: (expr(p), extra_array(p)),
262+
lambda e, a: a * e,
263+
)
264+
265+
# reductions
266+
register_op("var_sum_dim", "reduce", lambda p: (var(p),), lambda x: x.sum("d0"))
267+
register_op("expr_sum_dim", "reduce", lambda p: (expr(p),), lambda e: e.sum("d0"))
268+
register_op("expr_sum_all", "reduce", lambda p: (expr(p),), lambda e: e.sum())
269+
270+
# constraint construction
271+
register_op("con_le_scalar", "constraint", lambda p: (expr(p),), lambda e: e <= 2.0)
272+
register_op(
273+
"con_le_array", "constraint", lambda p: (expr(p), array(p)), lambda e, a: e <= a
274+
)
275+
register_op(
276+
"con_eq_expr", "constraint", lambda p: (expr(p), expr(p)), lambda a, b: a == b
277+
)
278+
279+
# absence / masking (§4–§7)
280+
register_op("expr_where", "mask", lambda p: (expr(p), cond(p)), lambda e, c: e.where(c))
281+
register_op("expr_fillna", "mask", lambda p: (masked_expr(p),), lambda e: e.fillna(0.0))
282+
register_op(
283+
"expr_add_masked",
284+
"mask",
285+
lambda p: (expr(p), masked_expr(p)),
286+
lambda a, b: a + b,
287+
)
288+
289+
# groupby
290+
register_op(
291+
"expr_groupby_sum",
292+
"groupby",
293+
lambda p: (grouped_expr(p),),
294+
lambda e: e.groupby("g").sum(),
295+
)
296+
297+
# N-way assembly (constraint building sums many terms)
298+
register_op(
299+
"merge_sum",
300+
"merge",
301+
lambda p: tuple(expr(p) for _ in range(8)),
302+
lambda *es: sum(es[1:], es[0]),
303+
)

0 commit comments

Comments
 (0)