Skip to content

Commit 943969e

Browse files
FrancescAltedclaude
andcommitted
Add a throwaway cross-platform abi3 performance check
The benchmarking behind the abi3 switch covered exactly one cell: macOS arm64, CPython 3.14, Apple clang. That is thin evidence for a change whose whole mechanism is platform-dependent. The generated C is byte-identical between the two builds -- CYTHON_LIMITED_API is a preprocessor macro, so the divergence happens entirely in the C compiler. It is not a small divergence: the abi3 objects are 3-10% smaller (blosc2_ext -10.2%) because Cython's inlined fast paths become calls into libpython. So abi3 moves work onto libpython calls, and the cost of such a call is not the same everywhere. Windows is the cell that matters. There an abi3 extension links python3.dll, a forwarder DLL, so each of those newly-added calls takes an extra thunk into python3XY.dll. POSIX has no equivalent -- symbols resolve straight from the loaded interpreter. 3.11 and 3.14 are both built because one abi3 binary serves the whole range while the interpreter-side handling differs by version. Rounds are interleaved abi3/base/abi3/... so a slow patch on a shared runner hits both builds instead of biasing whichever ran first, and the threshold is a deliberately loose 1.25x: this environment can resolve an extra indirection on every call, not a 3% difference. Benchmarks under 5 ms are reported but never fail the job. The report is posted as a PR comment because job logs and step summaries are not readable through the public API without admin rights. Also extends the benchmark to the paths the local run skipped: utf8 ingest, group_by+agg, multi-key group_by and where() over a 200k-row CTable, which is what actually exercises utf8_ext, groupby_ext and indexing_ext. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent c3aafba commit 943969e

3 files changed

Lines changed: 529 additions & 0 deletions

File tree

.github/bench-abi3/bench.py

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
"""Micro-benchmark comparing a normal build against an abi3 (Limited API) build.
2+
3+
Deliberately weighted towards *per-call* overhead, because that is where the
4+
Limited API is expected to cost something: macros that used to be direct struct
5+
accesses become real function calls, and `cdef class` instances become heap
6+
types whose attribute lookups no longer go through a static type slot.
7+
8+
Bulk operations (large slices, big compress) are included as controls: they
9+
spend nearly all their time inside C-Blosc2, so they should show no difference.
10+
Any regression there would point at something other than the ABI.
11+
12+
Run inside a venv with blosc2 installed; writes JSON to stdout.
13+
"""
14+
15+
import gc
16+
import json
17+
import statistics
18+
import sys
19+
import time
20+
21+
import numpy as np
22+
23+
import blosc2
24+
25+
REPEAT = 7 # timed repetitions; we report the minimum
26+
27+
28+
def bench(fn, *, repeat=REPEAT):
29+
"""Return the minimum wall time of `repeat` runs, in seconds."""
30+
gc.collect()
31+
gc.disable()
32+
try:
33+
times = []
34+
for _ in range(repeat):
35+
t0 = time.perf_counter()
36+
fn()
37+
times.append(time.perf_counter() - t0)
38+
finally:
39+
gc.enable()
40+
return min(times), statistics.median(times)
41+
42+
43+
RESULTS = {}
44+
45+
46+
def record(name, fn, **kw):
47+
try:
48+
lo, med = bench(fn, **kw)
49+
RESULTS[name] = {"min": lo, "median": med}
50+
print(f" {name:34s} {lo * 1e3:9.3f} ms", file=sys.stderr)
51+
except Exception as e: # keep going; a missing API shouldn't kill the run
52+
RESULTS[name] = {"error": f"{type(e).__name__}: {e}"}
53+
print(f" {name:34s} SKIP ({type(e).__name__}: {e})", file=sys.stderr)
54+
55+
56+
# --------------------------------------------------------------------------
57+
# per-call overhead: small payloads, many crossings of the Python/C boundary
58+
# --------------------------------------------------------------------------
59+
60+
small = np.arange(1024, dtype=np.int64) # 8 KB
61+
small_bytes = small.tobytes()
62+
small_c = blosc2.compress2(small_bytes)
63+
64+
65+
def compress_small():
66+
for _ in range(5000):
67+
blosc2.compress2(small_bytes)
68+
69+
70+
def decompress_small():
71+
for _ in range(5000):
72+
blosc2.decompress2(small_c)
73+
74+
75+
record("compress2 8KB x5000", compress_small)
76+
record("decompress2 8KB x5000", decompress_small)
77+
78+
# --------------------------------------------------------------------------
79+
# cdef class attribute access -- SChunk is a `cdef class`, so under the Limited
80+
# API it is built with PyType_FromSpec and its attributes are looked up through
81+
# the generic heap-type path rather than a static slot. This is the single
82+
# most direct probe of the abi3 cost.
83+
# --------------------------------------------------------------------------
84+
85+
schunk = blosc2.SChunk(chunksize=8 * 1024)
86+
for _ in range(64):
87+
schunk.append_data(small)
88+
89+
90+
def schunk_attrs():
91+
# Collected into a tuple rather than left as bare expressions so ruff's B018
92+
# stays quiet. The extra tuple build is identical in both builds, so it
93+
# cancels in the ratio, which is all this script reports.
94+
s = schunk
95+
last = None
96+
for _ in range(200_000):
97+
last = (s.nchunks, s.cbytes, s.nbytes)
98+
return last
99+
100+
101+
def schunk_decompress():
102+
s = schunk
103+
for i in range(64):
104+
s.decompress_chunk(i)
105+
106+
107+
record("SChunk attr access x600k", schunk_attrs)
108+
record("SChunk decompress_chunk x64", schunk_decompress)
109+
110+
# --------------------------------------------------------------------------
111+
# NDArray: scalar getitem is call-overhead bound, big slice is C bound
112+
# --------------------------------------------------------------------------
113+
114+
arr = blosc2.arange(0, 1000 * 1000, dtype=np.int64, shape=(1000, 1000))
115+
116+
117+
def nd_scalar_getitem():
118+
a = arr
119+
for i in range(5000):
120+
a[i % 1000, 0]
121+
122+
123+
def nd_big_slice():
124+
arr[:, :]
125+
126+
127+
def nd_row_slices():
128+
a = arr
129+
for i in range(1000):
130+
a[i]
131+
132+
133+
record("NDArray scalar getitem x5000", nd_scalar_getitem)
134+
record("NDArray full slice (control)", nd_big_slice)
135+
record("NDArray row slice x1000", nd_row_slices)
136+
137+
# --------------------------------------------------------------------------
138+
# compute engine / lazy expressions
139+
# --------------------------------------------------------------------------
140+
141+
a = blosc2.linspace(0, 1, 4_000_000, dtype=np.float64, shape=(2000, 2000))
142+
b = blosc2.linspace(1, 2, 4_000_000, dtype=np.float64, shape=(2000, 2000))
143+
144+
145+
def lazyexpr_eval():
146+
(a**2 + b * 2).compute()
147+
148+
149+
def lazyexpr_where():
150+
blosc2.where(a > 0.5, a, b).compute()
151+
152+
153+
def reduction_sum():
154+
(a + b).sum()
155+
156+
157+
record("lazyexpr a**2+b*2 (4M f64)", lazyexpr_eval, repeat=5)
158+
record("where(a>0.5,a,b) (4M f64)", lazyexpr_where, repeat=5)
159+
record("sum(a+b) (4M f64)", reduction_sum, repeat=5)
160+
161+
# --------------------------------------------------------------------------
162+
# bulk compress control -- almost entirely inside C-Blosc2
163+
# --------------------------------------------------------------------------
164+
165+
big = np.arange(8 * 1024 * 1024, dtype=np.int64) # 64 MB
166+
big_bytes = big.tobytes()
167+
168+
169+
def compress_big():
170+
blosc2.compress2(big_bytes)
171+
172+
173+
record("compress2 64MB (control)", compress_big, repeat=5)
174+
175+
# --------------------------------------------------------------------------
176+
# CTable: utf8 ingest, groupby and where(). These exercise utf8_ext,
177+
# groupby_ext and indexing_ext, which the earlier set only touched indirectly.
178+
# --------------------------------------------------------------------------
179+
180+
try:
181+
from dataclasses import dataclass
182+
183+
@dataclass
184+
class SalesRow:
185+
city: str = blosc2.field(blosc2.utf8())
186+
category: int = blosc2.field(blosc2.int32())
187+
sales: float = blosc2.field(blosc2.float64(), default=0.0)
188+
qty: int = blosc2.field(blosc2.int32(), default=0)
189+
190+
CITIES = ["Paris", "Rome", "Berlin", "Madrid", "Lisbon", "Vienna", "Oslo", "Prague"]
191+
NROWS = 200_000
192+
rng = np.random.default_rng(42)
193+
ROWS = [
194+
(
195+
CITIES[i % len(CITIES)],
196+
int(rng.integers(0, 8)),
197+
float(i % 1000),
198+
int(i % 97),
199+
)
200+
for i in range(NROWS)
201+
]
202+
203+
def utf8_ingest():
204+
blosc2.CTable(SalesRow, new_data=ROWS)
205+
206+
record("CTable utf8 ingest 200k rows", utf8_ingest, repeat=3)
207+
208+
table = blosc2.CTable(SalesRow, new_data=ROWS)
209+
210+
def ctable_groupby():
211+
table.group_by("city", sort=True).agg({"sales": ["sum", "mean", "count"]})
212+
213+
def ctable_groupby_multi():
214+
table.group_by(["city", "category"], sort=True).size()
215+
216+
def ctable_where():
217+
table.where(table["sales"] > 500.0)
218+
219+
record("CTable group_by+agg 200k", ctable_groupby, repeat=5)
220+
record("CTable group_by 2 keys 200k", ctable_groupby_multi, repeat=5)
221+
record("CTable where() 200k", ctable_where, repeat=5)
222+
except Exception as e:
223+
print(f" CTable benchmarks skipped: {type(e).__name__}: {e}", file=sys.stderr)
224+
225+
# --------------------------------------------------------------------------
226+
# provenance: prove which build actually got measured
227+
# --------------------------------------------------------------------------
228+
229+
meta = {
230+
"python": sys.version.split()[0],
231+
"blosc2": blosc2.__version__,
232+
"numpy": np.__version__,
233+
}
234+
try:
235+
from blosc2 import blosc2_ext
236+
237+
meta["ext_file"] = blosc2_ext.__file__
238+
meta["abi3"] = ".abi3." in blosc2_ext.__file__
239+
except Exception as e:
240+
meta["ext_file"] = f"unknown: {e}"
241+
242+
print(json.dumps({"meta": meta, "results": RESULTS}, indent=2))

.github/bench-abi3/compare.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""Aggregate bench.py results into one markdown report.
2+
3+
Expects a directory of artifacts laid out as::
4+
5+
<root>/bench-<os>-<pyver>/{abi3,base}-<round>.json
6+
7+
For each (platform, version) cell it takes the minimum across rounds per
8+
benchmark -- rounds are interleaved abi3/base/abi3/... in the workflow, so a
9+
slow patch of a noisy runner hits both builds rather than biasing one.
10+
11+
Exits non-zero if any benchmark regresses past THRESHOLD, so the job's
12+
pass/fail conclusion is meaningful even without reading the log.
13+
"""
14+
15+
import glob
16+
import json
17+
import os
18+
import sys
19+
20+
# GitHub runners are shared vCPUs with noisy neighbours; ratios wobble by
21+
# double-digit percentages between rounds. This job is looking for an
22+
# *important* regression -- an extra indirection on every libpython call --
23+
# not a 3% one, which this environment cannot resolve.
24+
THRESHOLD = 1.25
25+
26+
# Benchmarks whose absolute time is small enough that runner noise dominates.
27+
# Still reported, just not allowed to fail the job on their own.
28+
NOISE_FLOOR_MS = 5.0
29+
30+
31+
def load_cell(cell_dir):
32+
out = {}
33+
for build in ("base", "abi3"):
34+
runs = []
35+
for path in sorted(glob.glob(os.path.join(cell_dir, f"{build}-*.json"))):
36+
with open(path) as fh:
37+
runs.append(json.load(fh))
38+
out[build] = runs
39+
return out
40+
41+
42+
def best(runs, name):
43+
vals = [r["results"][name]["min"] for r in runs if name in r["results"] and "min" in r["results"][name]]
44+
return min(vals) if vals else None
45+
46+
47+
def main(root):
48+
cells = sorted(d for d in glob.glob(os.path.join(root, "bench-*")) if os.path.isdir(d))
49+
if not cells:
50+
print(f"no result directories under {root}", file=sys.stderr)
51+
return 1
52+
53+
lines = ["## abi3 vs. version-specific build\n"]
54+
lines.append(
55+
f"Minimum of interleaved rounds. Regression threshold **{THRESHOLD:.2f}x** "
56+
f"(benchmarks under {NOISE_FLOOR_MS:g} ms are reported but never fail the "
57+
"job -- CI runners cannot resolve them).\n"
58+
)
59+
60+
worst_overall = None
61+
failures = []
62+
63+
for cell in cells:
64+
label = os.path.basename(cell).removeprefix("bench-")
65+
data = load_cell(cell)
66+
if not data["base"] or not data["abi3"]:
67+
lines.append(f"\n### {label}\n\n_missing results_\n")
68+
continue
69+
70+
meta = data["abi3"][0]["meta"]
71+
lines.append(f"\n### {label}\n")
72+
lines.append(
73+
f"Python {meta.get('python')}, blosc2 {meta.get('blosc2')}, "
74+
f"numpy {meta.get('numpy')} — module `{os.path.basename(meta.get('ext_file', '?'))}`\n"
75+
)
76+
lines.append("| benchmark | base | abi3 | ratio |")
77+
lines.append("|---|---:|---:|---:|")
78+
79+
names = list(data["base"][0]["results"])
80+
for name in names:
81+
b = best(data["base"], name)
82+
a = best(data["abi3"], name)
83+
if b is None or a is None:
84+
lines.append(f"| {name} | — | — | skipped |")
85+
continue
86+
ratio = a / b
87+
noisy = b * 1e3 < NOISE_FLOOR_MS
88+
mark = ""
89+
if ratio > THRESHOLD:
90+
mark = " ⚠️" if noisy else " ❌"
91+
if not noisy:
92+
failures.append((label, name, ratio))
93+
lines.append(f"| {name} | {b * 1e3:.3f} ms | {a * 1e3:.3f} ms | {ratio:.3f}x{mark} |")
94+
if not noisy and (worst_overall is None or ratio > worst_overall[2]):
95+
worst_overall = (label, name, ratio)
96+
97+
lines.append("\n---\n")
98+
if worst_overall:
99+
lines.append(
100+
f"**Worst non-noise ratio:** {worst_overall[2]:.3f}x "
101+
f"({worst_overall[1]} on {worst_overall[0]})\n"
102+
)
103+
if failures:
104+
lines.append(f"\n**{len(failures)} benchmark(s) past threshold:**\n")
105+
for label, name, ratio in failures:
106+
lines.append(f"- `{label}` — {name}: {ratio:.3f}x")
107+
else:
108+
lines.append("\nNo regression past threshold on any platform. ✅\n")
109+
110+
report = "\n".join(lines)
111+
print(report)
112+
113+
summary = os.environ.get("GITHUB_STEP_SUMMARY")
114+
if summary:
115+
with open(summary, "a") as fh:
116+
fh.write(report + "\n")
117+
out = os.environ.get("GITHUB_OUTPUT")
118+
if out:
119+
with open(out, "a") as fh:
120+
fh.write(f"failures={len(failures)}\n")
121+
122+
with open("abi3-bench-report.md", "w") as fh:
123+
fh.write(report + "\n")
124+
125+
return 1 if failures else 0
126+
127+
128+
if __name__ == "__main__":
129+
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "results"))

0 commit comments

Comments
 (0)