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+
2218import pyarrow as pa
2319import pytest
2420
21+ import duckdb
22+
23+ if TYPE_CHECKING :
24+ from collections .abc import Iterator
25+
26+ from pytest_codspeed import BenchmarkFixture
27+
2528N = 500_000
2629WRITE_Q_NUM = "SELECT i::BIGINT AS a, (i * 1.5)::DOUBLE AS b FROM range(500000) t(i)"
2730WRITE_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 ())
0 commit comments