Skip to content

Commit 159757d

Browse files
committed
Add CodSpeed perf-regression benchmark suite
Nine modules over the duckdb-python binding hot paths: fetch (OUT-row), arrow, pandas, produce_numpy (df/fetchnumpy columnar), ingest_native (values/executemany), ingest_numpy (numpy scan + analyzer bind), udf (native + vectorized arrow), types_roundtrip (type x direction matrix), cardinality (LIMIT sweep). Full-consume discipline, warmup, real-null gotchas, tracemalloc memory guard. See benchmarks/PLAN.md. Standalone (not yet wired into CI).
1 parent d7e138f commit 159757d

10 files changed

Lines changed: 1205 additions & 0 deletions

benchmarks/PLAN.md

Lines changed: 178 additions & 0 deletions
Large diffs are not rendered by default.

benchmarks/test_arrow_perf.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
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.
19+
"""
20+
21+
import duckdb
22+
import pyarrow as pa
23+
import pytest
24+
25+
N = 500_000
26+
WRITE_Q_NUM = "SELECT i::BIGINT AS a, (i * 1.5)::DOUBLE AS b FROM range(500000) t(i)"
27+
WRITE_Q_STR = "SELECT ('str_value_' || i) AS s FROM range(500000) t(i)"
28+
29+
30+
@pytest.fixture
31+
def con():
32+
c = duckdb.connect()
33+
yield c
34+
c.close()
35+
36+
37+
@pytest.fixture(scope="module")
38+
def arrow_numeric():
39+
return pa.table(
40+
{
41+
"a": pa.array(range(N), type=pa.int64()),
42+
"b": pa.array([i * 1.5 for i in range(N)], type=pa.float64()),
43+
}
44+
)
45+
46+
47+
@pytest.fixture(scope="module")
48+
def arrow_string():
49+
return pa.table({"s": pa.array([f"str_value_{i}" for i in range(N)], type=pa.string())})
50+
51+
52+
@pytest.fixture(scope="module")
53+
def arrow_numeric_batches(arrow_numeric):
54+
# RecordBatches are immutable/re-readable, so a fresh reader can be built from them every round
55+
return arrow_numeric.schema, arrow_numeric.to_batches(max_chunksize=50_000)
56+
57+
58+
# --------------------------------------------------------------------------- #
59+
# READ: arrow -> duckdb. The engine must scan every value (sum/length force it).
60+
# --------------------------------------------------------------------------- #
61+
62+
63+
def test_read_arrow_numeric(benchmark, con, arrow_numeric):
64+
con.register("t_num", arrow_numeric)
65+
benchmark(lambda: con.execute("SELECT sum(a), sum(b) FROM t_num").fetchall())
66+
67+
68+
def test_read_arrow_string(benchmark, con, arrow_string):
69+
con.register("t_str", arrow_string)
70+
benchmark(lambda: con.execute("SELECT count(s), sum(length(s)) FROM t_str").fetchall())
71+
72+
73+
# ADDED: RecordBatchReader ingest -- the SAME PythonTableArrowArrayStreamFactory but STREAMING (distinct from
74+
# the materialized Table read above). A fresh reader is built per round (the engine drains it); sum() forces a
75+
# full scan of every value.
76+
77+
78+
def test_read_arrow_reader_numeric(benchmark, con, arrow_numeric_batches):
79+
schema, batches = arrow_numeric_batches
80+
81+
def run():
82+
reader = pa.RecordBatchReader.from_batches(schema, iter(batches))
83+
con.register("t_rdr", reader)
84+
return con.execute("SELECT sum(a), sum(b) FROM t_rdr").fetchall()
85+
86+
run() # warm
87+
benchmark(run)
88+
89+
90+
# --------------------------------------------------------------------------- #
91+
# WRITE: duckdb -> arrow, consumer fully materializes / fully drains the stream.
92+
# --------------------------------------------------------------------------- #
93+
94+
95+
def test_write_arrow_table_numeric(benchmark, con):
96+
benchmark(lambda: con.sql(WRITE_Q_NUM).to_arrow_table())
97+
98+
99+
def test_write_arrow_table_string(benchmark, con):
100+
benchmark(lambda: con.sql(WRITE_Q_STR).to_arrow_table())
101+
102+
103+
def test_write_arrow_reader_consumed(benchmark, con):
104+
def run():
105+
reader = con.sql(WRITE_Q_NUM).to_arrow_reader(100_000)
106+
rows = 0
107+
for batch in reader: # drain the lazy stream so duckdb actually produces every batch
108+
rows += batch.num_rows
109+
return rows
110+
111+
benchmark(run)
112+
113+
114+
def test_write_polars_numeric(benchmark, con):
115+
benchmark(lambda: con.sql(WRITE_Q_NUM).pl())
116+
117+
118+
def test_write_polars_string(benchmark, con):
119+
benchmark(lambda: con.sql(WRITE_Q_STR).pl())
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Standalone CodSpeed benchmark module: the RESULT-CARDINALITY (top-N) sweep — NOT integrated (not in
2+
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-codspeed/.venv-release/bin/python
6+
cd /Users/evert/projects/duckdb-python/wt-codspeed
7+
$M -m pytest benchmarks/test_cardinality_perf.py --codspeed --codspeed-mode=walltime -o addopts= -p no:cacheprovider
8+
$C -m pytest benchmarks/test_cardinality_perf.py --codspeed --codspeed-mode=walltime -o addopts= -p no:cacheprovider
9+
10+
WHY THIS MODULE (adopted from iqmo-org/bareduckdb): hold the SOURCE fixed and sweep only the number of rows
11+
materialized to Python via ORDER BY ... LIMIT n for n in {100, 1k, 10k, 100k}, through fetchall / df /
12+
to_arrow_table. The engine cost (scan the fixed SRC + top-N heap) stays ~constant, so the walltime delta
13+
across n is dominated by the per-row binding conversion -> a clean per-row slope. The n=100 end is the
14+
noise-free overhead regime (the natural instruction-count-gate point); the n=100k end is throughput.
15+
16+
A clean monotone slope (and ~parity slope between the two builds) is the signal we report; a build whose slope
17+
is steeper has a per-row conversion regression. Source held constant rules out scan-cost as the confound (a
18+
cleaner axis than varying range(), which also changes scan cost).
19+
20+
numpy/pandas/pyarrow are pinned to the SAME versions in both .venv-release, so the A/B delta is purely the binding.
21+
"""
22+
23+
import duckdb
24+
import pytest
25+
26+
SRC = 200_000 # fixed source size -> constant engine scan + top-N across all n
27+
LIMITS = [100, 1_000, 10_000, 100_000]
28+
29+
# 3 columns (BIGINT, DOUBLE, VARCHAR) so the per-row conversion is non-trivial; source is a fixed inline
30+
# subquery (no table state) and ORDER BY forces a full scan + top-N of the same SRC rows every time.
31+
_SRC_SUBQ = f"(SELECT i::BIGINT AS a, (i * 1.5)::DOUBLE AS b, ('s_' || i) AS s FROM range({SRC}) t(i))"
32+
33+
34+
def _query(n):
35+
return f"SELECT a, b, s FROM {_SRC_SUBQ} ORDER BY a DESC LIMIT {n}"
36+
37+
38+
@pytest.fixture
39+
def con():
40+
c = duckdb.connect()
41+
yield c
42+
c.close()
43+
44+
45+
@pytest.mark.parametrize("n", LIMITS)
46+
def test_limit_fetchall(benchmark, con, n):
47+
q = _query(n)
48+
con.execute(q).fetchall() # warm
49+
benchmark(lambda: con.execute(q).fetchall())
50+
51+
52+
@pytest.mark.parametrize("n", LIMITS)
53+
def test_limit_df(benchmark, con, n):
54+
q = _query(n)
55+
con.sql(q).df() # warm
56+
benchmark(lambda: con.sql(q).df())
57+
58+
59+
@pytest.mark.parametrize("n", LIMITS)
60+
def test_limit_to_arrow(benchmark, con, n):
61+
q = _query(n)
62+
con.sql(q).to_arrow_table() # warm
63+
benchmark(lambda: con.sql(q).to_arrow_table())

benchmarks/test_fetch_perf.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""Standalone CodSpeed benchmark module — NOT integrated (not in pyproject, not in CI, not committed).
2+
3+
Purpose: A/B the binding-layer perf between the two builds (pybind11 `main` vs nanobind cutover), in particular
4+
the narrow-column `fetchall` regression. Run the SAME file under each build's interpreter and compare:
5+
6+
M=/Users/evert/projects/duckdb-python/main/.venv-release/bin/python
7+
C=/Users/evert/projects/duckdb-python/wt-cutover/.venv-release/bin/python
8+
cd /Users/evert/projects/duckdb-python/wt-cutover
9+
$M -m pytest benchmarks/test_fetch_perf.py --codspeed --codspeed-mode=walltime -o addopts=
10+
$C -m pytest benchmarks/test_fetch_perf.py --codspeed --codspeed-mode=walltime -o addopts=
11+
12+
NOTE: macOS arm64 has no Valgrind, so only `--codspeed-mode=walltime` works locally (wall-clock stats). The
13+
deterministic instruction-count mode (`--codspeed-mode=simulation`) needs Linux + the CodSpeed instrument
14+
(CI, or `codspeed run` in a Linux container). In CI/cloud, CodSpeed compares each run against a git baseline;
15+
locally we get the same benchmark workflow but A/B by running the file under the two interpreters by hand.
16+
"""
17+
18+
import duckdb
19+
import pytest
20+
21+
22+
@pytest.fixture
23+
def con():
24+
c = duckdb.connect()
25+
yield c
26+
c.close()
27+
28+
29+
def _bench_fetchall(benchmark, con, query):
30+
con.execute(query).fetchall() # warm the engine before measuring
31+
benchmark(lambda: con.execute(query).fetchall())
32+
33+
34+
def test_fetchall_int(benchmark, con):
35+
_bench_fetchall(benchmark, con, "SELECT i::BIGINT AS a FROM range(200000) t(i)")
36+
37+
38+
def test_fetchall_smallint(benchmark, con):
39+
_bench_fetchall(benchmark, con, "SELECT (i % 100)::INTEGER AS a FROM range(200000) t(i)")
40+
41+
42+
def test_fetchall_double(benchmark, con):
43+
_bench_fetchall(benchmark, con, "SELECT (i * 1.5)::DOUBLE AS a FROM range(200000) t(i)")
44+
45+
46+
def test_fetchall_2int(benchmark, con):
47+
_bench_fetchall(benchmark, con, "SELECT i::BIGINT AS a, (i + 1)::BIGINT AS b FROM range(200000) t(i)")
48+
49+
50+
def test_fetchall_str(benchmark, con):
51+
_bench_fetchall(benchmark, con, "SELECT ('str_value_' || i) AS s FROM range(100000) t(i)")
52+
53+
54+
def test_fetchall_mixed(benchmark, con):
55+
query = (
56+
"SELECT i::BIGINT AS bi, ('str_' || i) AS s, [i, i + 1, i + 2] AS lst, "
57+
"{'a': i, 'b': i + 1} AS st FROM range(50000) t(i)"
58+
)
59+
_bench_fetchall(benchmark, con, query)
60+
61+
62+
def test_fetchone_iter(benchmark, con):
63+
query = "SELECT i::BIGINT AS a, (i * 1.5)::DOUBLE AS b FROM range(100000) t(i)"
64+
65+
def run():
66+
rel = con.execute(query)
67+
while rel.fetchone() is not None:
68+
pass
69+
70+
benchmark(run)
71+
72+
73+
# --------------------------------------------------------------------------- #
74+
# ADDED: small-N instruction-count-gate variants (the narrow-numeric fixed-cost path, noise-free at range(2048)
75+
# under simulation mode in CI), expensive scalar OUT-row types (timestamptz pytz-per-row, blob, null-heavy), a
76+
# heterogeneous per-cell-dispatch row (hugeint+uuid+decimal128+varchar, distinct from homogeneous columns), and
77+
# the batched fetchmany loop.
78+
# --------------------------------------------------------------------------- #
79+
80+
81+
def test_fetchall_int_gate(benchmark, con):
82+
_bench_fetchall(benchmark, con, "SELECT i::BIGINT AS a FROM range(2048) t(i)")
83+
84+
85+
def test_fetchall_2int_gate(benchmark, con):
86+
_bench_fetchall(benchmark, con, "SELECT i::BIGINT AS a, (i + 1)::BIGINT AS b FROM range(2048) t(i)")
87+
88+
89+
def test_fetchall_null_heavy(benchmark, con):
90+
_bench_fetchall(
91+
benchmark, con, "SELECT CASE WHEN i % 2 = 0 THEN NULL ELSE i::BIGINT END FROM range(200000) t(i)"
92+
)
93+
94+
95+
def test_fetchall_timestamptz(benchmark, con):
96+
_bench_fetchall(
97+
benchmark, con, "SELECT (TIMESTAMPTZ '2020-01-01' + (i * INTERVAL 1 SECOND)) FROM range(100000) t(i)"
98+
)
99+
100+
101+
def test_fetchall_decimal128(benchmark, con):
102+
_bench_fetchall(benchmark, con, "SELECT ((i * 1.5)::DECIMAL(28, 6)) FROM range(200000) t(i)")
103+
104+
105+
def test_fetchall_blob(benchmark, con):
106+
_bench_fetchall(benchmark, con, "SELECT ('blob_value_' || i)::BLOB FROM range(100000) t(i)")
107+
108+
109+
def test_fetchall_mixed_wide(benchmark, con):
110+
# heterogeneous row -> per-cell type dispatch in the Fetchone column loop (distinct branch/cache profile
111+
# from the homogeneous single-type columns above)
112+
query = (
113+
"SELECT (i::HUGEINT * 1000000000000) AS h, gen_random_uuid() AS u, "
114+
"((i * 1.5)::DECIMAL(28, 6)) AS d, ('string_' || i) AS s FROM range(100000) t(i)"
115+
)
116+
_bench_fetchall(benchmark, con, query)
117+
118+
119+
def test_fetchmany_batched(benchmark, con):
120+
query = "SELECT i::BIGINT AS a, (i * 1.5)::DOUBLE AS b FROM range(100000) t(i)"
121+
122+
def run():
123+
rel = con.execute(query)
124+
while True:
125+
rows = rel.fetchmany(10_000)
126+
if not rows:
127+
break
128+
129+
benchmark(run)
130+
131+
132+
def test_expr_many(benchmark):
133+
def run():
134+
out = []
135+
for i in range(2000):
136+
col = duckdb.ColumnExpression(f"col_{i}")
137+
const = duckdb.ConstantExpression(i)
138+
out.append(((col + const) * duckdb.ConstantExpression(2)).alias(f"a{i}"))
139+
return len(out)
140+
141+
benchmark(run)

0 commit comments

Comments
 (0)