Skip to content

Commit 4f24ed7

Browse files
committed
fix benchmarks and add workflow
1 parent 177d99b commit 4f24ed7

11 files changed

Lines changed: 431 additions & 299 deletions

.github/workflows/codspeed.yml

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,12 @@
1-
# Performance-regression benchmarks via CodSpeed, in deterministic instruction-count (simulation) mode.
1+
# Perf-regression benchmarks via CodSpeed in instruction-count (simulation) mode: deterministic, so the whole
2+
# suite is gate-able (no walltime noise, no gated/informational split).
23
#
3-
# TOKENLESS FOR NOW: the CodSpeed action's token is only needed to UPLOAD results to the CodSpeed
4-
# dashboard (the hosted PR-gate). Without it the action still RUNS every benchmark under Valgrind and
5-
# reports the instruction counts in the job log. To turn on the hosted regression gate later: create a
6-
# CodSpeed project for the repo and either (public repo) rely on the OIDC `id-token: write` permission
7-
# below, or add a `CODSPEED_TOKEN` repo secret and pass `token: ${{ secrets.CODSPEED_TOKEN }}` to the action.
4+
# TOKENLESS: the token is only for uploading to the CodSpeed dashboard. Without it the action still runs every
5+
# benchmark and reports counts in the job log. For the hosted gate later, create a CodSpeed project and rely on
6+
# the OIDC id-token permission below (public repo), or add a CODSPEED_TOKEN secret and pass token: to the action.
87
#
9-
# Why simulation (instruction-count) and not walltime: instruction counts are deterministic even for the
10-
# multi-threaded / engine-heavy paths (Valgrind serializes and counts), so the whole suite is gate-able and
11-
# there is no need to split "gated vs informational" the way noisy local walltime required. Instruction count
12-
# is exactly the signal that would have caught the LIST/ARRAY df() regression cleanly.
13-
#
14-
# NOTE: this workflow has not been run in CI yet; the build steps mirror the documented dev build (CLAUDE.md)
15-
# and will likely need a shakeout run. Valgrind is slow (~20-50x); if the full suite is too slow, trim the
16-
# largest-N benchmarks or run a curated subset.
8+
# Not yet run in CI; the build mirrors the dev build (CLAUDE.md) and will need a shakeout. Valgrind is slow
9+
# (~20-50x); trim the largest-N benchmarks if the suite is too slow.
1710

1811
name: Benchmarks
1912

benchmarks/test_arrow_perf.py

Lines changed: 55 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,46 @@
1-
"""Standalone CodSpeed benchmark module for the Arrow read/write binding paths — NOT integrated
2-
(not in pyproject, not in CI, not committed). Run under each build's interpreter and compare:
3-
4-
M=/Users/evert/projects/duckdb-python/main/.venv-release/bin/python
5-
C=/Users/evert/projects/duckdb-python/wt-cutover/.venv-release/bin/python
6-
cd /Users/evert/projects/duckdb-python/wt-cutover
7-
$M -m pytest benchmarks/test_arrow_perf.py --codspeed --codspeed-mode=walltime -o addopts=
8-
$C -m pytest benchmarks/test_arrow_perf.py --codspeed --codspeed-mode=walltime -o addopts=
9-
10-
DESIGN — the data must be FULLY MOVED, not lazily wrapped, or the benchmark measures nothing:
11-
* READ (arrow -> duckdb): the duckdb ENGINE must scan every value. We aggregate over the actual
12-
columns (sum/length), NOT count(*) -- count(*) is answered from arrow metadata without touching data.
13-
* WRITE (duckdb -> arrow): the CONSUMER must materialize everything.
14-
- to_arrow_table() / pl() are EAGER (the full table / polars DataFrame is built).
15-
- to_arrow_reader() is LAZY -- duckdb only produces a batch when it is pulled -- so we iterate the
16-
whole stream to actually exercise and consume the write path.
17-
18-
pyarrow/polars are pinned to the SAME version in both .venv-release, so the A/B delta is purely the binding.
1+
"""CodSpeed benchmark: Arrow read/write paths. Standalone, not in CI.
2+
3+
A/B: run under each build, compare (data libs pinned identically, so the delta is the binding):
4+
cd /Users/evert/projects/duckdb-python/wt-codspeed
5+
for P in ../main/.venv-release/bin/python .venv-release/bin/python; do \
6+
$P -m pytest benchmarks/test_arrow_perf.py \
7+
--codspeed --codspeed-mode=walltime -o addopts= -p no:cacheprovider; \
8+
done
9+
10+
Data must be fully moved or nothing is measured: READ aggregates over real columns (sum/length, not count(*),
11+
which arrow answers from metadata); WRITE materializes the result (to_arrow_reader is lazy, so it is drained).
1912
"""
2013

21-
import duckdb
14+
from __future__ import annotations
15+
16+
from typing import TYPE_CHECKING
17+
2218
import pyarrow as pa
2319
import pytest
2420

21+
import duckdb
22+
23+
if TYPE_CHECKING:
24+
from collections.abc import Iterator
25+
26+
from pytest_codspeed import BenchmarkFixture
27+
2528
N = 500_000
2629
WRITE_Q_NUM = "SELECT i::BIGINT AS a, (i * 1.5)::DOUBLE AS b FROM range(500000) t(i)"
2730
WRITE_Q_STR = "SELECT ('str_value_' || i) AS s FROM range(500000) t(i)"
2831

2932

3033
@pytest.fixture
31-
def con():
34+
def con() -> Iterator[duckdb.DuckDBPyConnection]:
35+
"""Yield a fresh connection, closed on teardown."""
3236
c = duckdb.connect()
3337
yield c
3438
c.close()
3539

3640

3741
@pytest.fixture(scope="module")
38-
def arrow_numeric():
42+
def arrow_numeric() -> pa.Table:
43+
"""Return a two-column numeric arrow table."""
3944
return pa.table(
4045
{
4146
"a": pa.array(range(N), type=pa.int64()),
@@ -45,12 +50,14 @@ def arrow_numeric():
4550

4651

4752
@pytest.fixture(scope="module")
48-
def arrow_string():
53+
def arrow_string() -> pa.Table:
54+
"""Return a single-column string arrow table."""
4955
return pa.table({"s": pa.array([f"str_value_{i}" for i in range(N)], type=pa.string())})
5056

5157

5258
@pytest.fixture(scope="module")
53-
def arrow_numeric_batches(arrow_numeric):
59+
def arrow_numeric_batches(arrow_numeric: pa.Table) -> tuple[pa.Schema, list[pa.RecordBatch]]:
60+
"""Return the schema and record batches for the numeric table."""
5461
# RecordBatches are immutable/re-readable, so a fresh reader can be built from them every round
5562
return arrow_numeric.schema, arrow_numeric.to_batches(max_chunksize=50_000)
5663

@@ -60,12 +67,16 @@ def arrow_numeric_batches(arrow_numeric):
6067
# --------------------------------------------------------------------------- #
6168

6269

63-
def test_read_arrow_numeric(benchmark, con, arrow_numeric):
70+
def test_read_arrow_numeric(
71+
benchmark: BenchmarkFixture, con: duckdb.DuckDBPyConnection, arrow_numeric: pa.Table
72+
) -> None:
73+
"""Benchmark scanning a numeric arrow table."""
6474
con.register("t_num", arrow_numeric)
6575
benchmark(lambda: con.execute("SELECT sum(a), sum(b) FROM t_num").fetchall())
6676

6777

68-
def test_read_arrow_string(benchmark, con, arrow_string):
78+
def test_read_arrow_string(benchmark: BenchmarkFixture, con: duckdb.DuckDBPyConnection, arrow_string: pa.Table) -> None:
79+
"""Benchmark scanning a string arrow table."""
6980
con.register("t_str", arrow_string)
7081
benchmark(lambda: con.execute("SELECT count(s), sum(length(s)) FROM t_str").fetchall())
7182

@@ -75,10 +86,15 @@ def test_read_arrow_string(benchmark, con, arrow_string):
7586
# full scan of every value.
7687

7788

78-
def test_read_arrow_reader_numeric(benchmark, con, arrow_numeric_batches):
89+
def test_read_arrow_reader_numeric(
90+
benchmark: BenchmarkFixture,
91+
con: duckdb.DuckDBPyConnection,
92+
arrow_numeric_batches: tuple[pa.Schema, list[pa.RecordBatch]],
93+
) -> None:
94+
"""Benchmark scanning a streaming record-batch reader."""
7995
schema, batches = arrow_numeric_batches
8096

81-
def run():
97+
def run() -> list:
8298
reader = pa.RecordBatchReader.from_batches(schema, iter(batches))
8399
con.register("t_rdr", reader)
84100
return con.execute("SELECT sum(a), sum(b) FROM t_rdr").fetchall()
@@ -92,16 +108,20 @@ def run():
92108
# --------------------------------------------------------------------------- #
93109

94110

95-
def test_write_arrow_table_numeric(benchmark, con):
111+
def test_write_arrow_table_numeric(benchmark: BenchmarkFixture, con: duckdb.DuckDBPyConnection) -> None:
112+
"""Benchmark materializing a numeric result to an arrow table."""
96113
benchmark(lambda: con.sql(WRITE_Q_NUM).to_arrow_table())
97114

98115

99-
def test_write_arrow_table_string(benchmark, con):
116+
def test_write_arrow_table_string(benchmark: BenchmarkFixture, con: duckdb.DuckDBPyConnection) -> None:
117+
"""Benchmark materializing a string result to an arrow table."""
100118
benchmark(lambda: con.sql(WRITE_Q_STR).to_arrow_table())
101119

102120

103-
def test_write_arrow_reader_consumed(benchmark, con):
104-
def run():
121+
def test_write_arrow_reader_consumed(benchmark: BenchmarkFixture, con: duckdb.DuckDBPyConnection) -> None:
122+
"""Benchmark draining a lazy arrow record-batch reader."""
123+
124+
def run() -> int:
105125
reader = con.sql(WRITE_Q_NUM).to_arrow_reader(100_000)
106126
rows = 0
107127
for batch in reader: # drain the lazy stream so duckdb actually produces every batch
@@ -111,9 +131,11 @@ def run():
111131
benchmark(run)
112132

113133

114-
def test_write_polars_numeric(benchmark, con):
134+
def test_write_polars_numeric(benchmark: BenchmarkFixture, con: duckdb.DuckDBPyConnection) -> None:
135+
"""Benchmark materializing a numeric result to a polars frame."""
115136
benchmark(lambda: con.sql(WRITE_Q_NUM).pl())
116137

117138

118-
def test_write_polars_string(benchmark, con):
139+
def test_write_polars_string(benchmark: BenchmarkFixture, con: duckdb.DuckDBPyConnection) -> None:
140+
"""Benchmark materializing a string result to a polars frame."""
119141
benchmark(lambda: con.sql(WRITE_Q_STR).pl())
Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,38 @@
1-
"""Standalone CodSpeed benchmark: the RESULT-CARDINALITY (rows-to-Python) sweep. Run under each build:
1+
"""CodSpeed benchmark: the result-cardinality (rows-to-Python) sweep. Standalone, not in CI.
22
3-
M=/Users/evert/projects/duckdb-python/main/.venv-release/bin/python
4-
C=/Users/evert/projects/duckdb-python/wt-codspeed/.venv-release/bin/python
3+
A/B: run under each build, compare (data libs pinned identically, so the delta is the binding):
54
cd /Users/evert/projects/duckdb-python/wt-codspeed
6-
$M -m pytest benchmarks/test_cardinality_perf.py --codspeed --codspeed-mode=walltime -o addopts= -p no:cacheprovider
7-
$C -m pytest benchmarks/test_cardinality_perf.py --codspeed --codspeed-mode=walltime -o addopts= -p no:cacheprovider
8-
9-
REDESIGN NOTE: the first version swept `ORDER BY a DESC LIMIT n` over a fixed source. That was wrong:
10-
the engine's full top-N SORT (~3-14ms, itself variable) dominated and swamped the per-row conversion
11-
signal, and the numbers came out non-monotone. This version pre-materializes the fixed source table ONCE
12-
and sweeps `SELECT * FROM src LIMIT n` with NO ORDER BY: a plain LIMIT early-stops the scan at n rows, so
13-
the engine cost is cheap and monotone in n, and the rows-to-Python CONVERSION is the dominant n-varying
14-
cost. That gives a clean, monotone per-row slope; the A/B delta at each n isolates the binding, and a build
15-
whose slope is steeper has a per-row conversion regression. n=100 is the overhead regime (the natural
16-
instruction-count-gate point); n=100_000 is throughput.
17-
18-
3 columns (BIGINT, DOUBLE, VARCHAR) so per-row conversion is non-trivial. numpy/pandas/pyarrow are pinned to
19-
the SAME versions in both .venv-release, so the A/B delta is purely the binding.
5+
for P in ../main/.venv-release/bin/python .venv-release/bin/python; do \
6+
$P -m pytest benchmarks/test_cardinality_perf.py \
7+
--codspeed --codspeed-mode=walltime -o addopts= -p no:cacheprovider; \
8+
done
9+
10+
Sweeps `SELECT * FROM src LIMIT n` (no ORDER BY) over a pre-materialized 3-column source: a plain LIMIT
11+
early-stops the scan, so the per-row conversion dominates and the slope is monotone in n. A steeper slope on
12+
one build is a per-row conversion regression. n=100 is the overhead regime, n=100_000 is throughput.
13+
(An earlier ORDER BY version was dropped: the top-N sort swamped the signal.)
2014
"""
2115

22-
import duckdb
16+
from __future__ import annotations
17+
18+
from typing import TYPE_CHECKING
19+
2320
import pytest
2421

22+
import duckdb
23+
24+
if TYPE_CHECKING:
25+
from collections.abc import Iterator
26+
27+
from pytest_codspeed import BenchmarkFixture
28+
2529
SRC_ROWS = 200_000
2630
LIMITS = [100, 1_000, 10_000, 100_000]
2731

2832

2933
@pytest.fixture(scope="module")
30-
def con():
34+
def con() -> Iterator[duckdb.DuckDBPyConnection]:
35+
"""Yield a connection over a once-materialized source table."""
3136
# Fixed source materialized ONCE (module-scoped): building it per test would add noise, and it must be
3237
# identical across the n sweep. `SELECT * FROM src LIMIT n` then reads only the first n rows.
3338
c = duckdb.connect()
@@ -39,28 +44,31 @@ def con():
3944
c.close()
4045

4146

42-
def _query(n):
47+
def _query(n: int) -> str:
4348
# No ORDER BY: a plain LIMIT early-stops the scan at n rows -> engine cost cheap and monotone in n, so the
4449
# per-row binding conversion dominates the n-varying signal (unlike the old ORDER BY top-N sort).
4550
return f"SELECT a, b, s FROM src LIMIT {n}"
4651

4752

4853
@pytest.mark.parametrize("n", LIMITS)
49-
def test_limit_fetchall(benchmark, con, n):
54+
def test_limit_fetchall(benchmark: BenchmarkFixture, con: duckdb.DuckDBPyConnection, n: int) -> None:
55+
"""Benchmark fetchall over a LIMIT n sweep."""
5056
q = _query(n)
5157
con.execute(q).fetchall() # warm
5258
benchmark(lambda: con.execute(q).fetchall())
5359

5460

5561
@pytest.mark.parametrize("n", LIMITS)
56-
def test_limit_df(benchmark, con, n):
62+
def test_limit_df(benchmark: BenchmarkFixture, con: duckdb.DuckDBPyConnection, n: int) -> None:
63+
"""Benchmark df() over a LIMIT n sweep."""
5764
q = _query(n)
5865
con.sql(q).df() # warm
5966
benchmark(lambda: con.sql(q).df())
6067

6168

6269
@pytest.mark.parametrize("n", LIMITS)
63-
def test_limit_to_arrow(benchmark, con, n):
70+
def test_limit_to_arrow(benchmark: BenchmarkFixture, con: duckdb.DuckDBPyConnection, n: int) -> None:
71+
"""Benchmark to_arrow_table() over a LIMIT n sweep."""
6472
q = _query(n)
6573
con.sql(q).to_arrow_table() # warm
6674
benchmark(lambda: con.sql(q).to_arrow_table())

0 commit comments

Comments
 (0)