|
| 1 | +# Benchmark suite plan |
| 2 | + |
| 3 | +Design rationale for the binding micro-benchmarks. The suite is implemented in `benchmarks/`; CI lives in |
| 4 | +`../.github/workflows/codspeed.yml`; conventions, markers, and the two data-pattern traps are in |
| 5 | +[README.md](README.md). |
| 6 | + |
| 7 | +Priority: **P0** = known-regression or cutover-reworked path (narrow-numeric common case); **P1** = high-traffic |
| 8 | +conversion or per-element Python work; **P2** = correctness-relevant, lower-traffic or engine-dominated. |
| 9 | + |
| 10 | +## Scenarios |
| 11 | + |
| 12 | +PRODUCE (duckdb to Python) is the highest regression risk: `Fetchone` builds a `TupleBuilder` per row and calls |
| 13 | +`FromValue` per cell (O(rows x cols), the shape of the historical ~15% fetchall regression). |
| 14 | + |
| 15 | +- **OUT-row** (`test_fetch_perf`, `test_types_roundtrip_perf`): fetchall / fetchone / fetchmany per type. P0 |
| 16 | + narrow numeric; P1 varchar, list, struct, and the expensive per-row types (decimal `Decimal()`, timestamptz |
| 17 | + pytz, hugeint string round-trip, uuid). Small-N `*_gate` probes isolate the compile+fetch fixed cost. |
| 18 | +- **OUT-col** (`test_produce_numpy_perf`): df() / fetchnumpy() reworked columnar path. P0 numeric no-null vs |
| 19 | + REAL-null (the masked_array branch); plus string, timestamp, and wide-internal (hugeint/uuid/decimal128). |
| 20 | +- **OUT-arrow / polars** (`test_arrow_perf`): to_arrow_table / reader / pl(). Informational (engine-parallel, |
| 21 | + GIL-released). |
| 22 | +- **Cardinality** (`test_cardinality_perf`): a LIMIT-n sweep giving a clean per-row conversion slope. |
| 23 | + |
| 24 | +INGEST (Python to duckdb): |
| 25 | + |
| 26 | +- **numpy / pandas** (`test_ingest_numpy_perf`, `test_pandas_perf`): numpy-backed scan (NaN-to-NULL, masked), |
| 27 | + object-string transcode ladder, arrow-backed zero-copy, and the per-bind PandasAnalyzer. |
| 28 | +- **arrow** (`test_arrow_perf`): Table + RecordBatchReader + dictionary sweep. |
| 29 | +- **native** (`test_ingest_native_perf`): values() list/tuple/dict per-cell TransformPythonValue, executemany. |
| 30 | + |
| 31 | +UDF (`test_udf_perf`, zero coverage before this suite): native scalar per-row (P0, the biggest untested per-call |
| 32 | +path) and vectorized arrow per-chunk. |
| 33 | + |
| 34 | +## Type x direction matrix |
| 35 | + |
| 36 | +Directions: IN-native (TransformPythonValue), IN-numpy (NumpyScan), OUT-row (FromValue), OUT-col (ArrayWrapper), |
| 37 | +OUT-arrow. |
| 38 | + |
| 39 | +| Type | IN-native | IN-numpy | OUT-row | OUT-col | OUT-arrow | |
| 40 | +|------|-----------|----------|---------|---------|-----------| |
| 41 | +| int32/int64 | P1 | **P0** | **P0** | **P0** | P1 | |
| 42 | +| double | P1 | **P0** (NaN->NULL) | P0 | P0 | P1 | |
| 43 | +| varchar | P1 | **P0** (PyUnicode) | P1 | P1 | P1 | |
| 44 | +| bool | P2 | P1 | P2 | P1 | P2 | |
| 45 | +| decimal64/128 | P2 | n/a | **P1** (Python Decimal) | P1 | P2 | |
| 46 | +| date | P2 | P1 | P1 | P1 | P2 | |
| 47 | +| timestamp(tz) | P1 | P1 | **P1** (pytz/row) | P1 | P1 | |
| 48 | +| LIST/STRUCT | P2 | P2 | P1 (recursive) | P1 | P2 | |
| 49 | +| hugeint/uuid | P2 | P2 | **P1** (round-trip) | P1 | P2 | |
| 50 | +| blob/map | P2 | P2 | P2 | P2 | P2 | |
| 51 | +| NULL-heavy | n/a | **P1** | P2 | **P0** (masked_array) | P1 | |
| 52 | + |
| 53 | +## Mechanics |
| 54 | + |
| 55 | +- **Walltime vs instruction-count.** Local A/B is walltime only (no Valgrind on macOS arm64). CI is |
| 56 | + instruction-count via self-hosted Callgrind (near-deterministic, PYTHONHASHSEED pinned), diffed against a |
| 57 | + committed baseline. Report-only until trusted. |
| 58 | +- **Marker split + auto-move.** Every benchmark is `gate` or `informational` (see README). At baseline regen, |
| 59 | + each numeric-produce gate's binding fraction `= 1 - floor_Ir / bench_Ir` is computed against its engine floor |
| 60 | + (`test_engine_control_perf`); a gate below the ~25% cutoff is auto-moved to informational (a threshold on an |
| 61 | + engine-diluted total is not meaningful). OUT-row fetch and UDFs are ~all binding; numeric produce is a bulk |
| 62 | + memcpy of ~engine magnitude (auto-move candidate). |
| 63 | +- **Guards.** compare_baseline.py warns and stops enforcing when BENCH_SCALE, the pin file, or the DuckDB |
| 64 | + submodule SHA differ from the baseline's (any of those makes the counts non-comparable). |
| 65 | +- **Sustained-leak guard** (`tests/fast/test_binding_pressure_leak.py`): a plain RSS + object-count test for the |
| 66 | + object-pinning paths, since a per-call refcount imbalance is invisible to a steady-state benchmark. |
| 67 | +- **Memory mode** (a second Callgrind sweep for O(rows) produce peak-RSS) is designed but deferred; the |
| 68 | + `test_mem_df_with_nulls` tracemalloc guard is the local stand-in. |
| 69 | + |
| 70 | +## Cross-check vs iqmo-org/bareduckdb |
| 71 | + |
| 72 | +Their suite is a SQL-file-driven A/B comparing two clients (production `duckdb` vs the C-API prototype), arrow-in |
| 73 | +/ arrow-out only, no fetchall/df/numpy/native/UDF coverage. So our binding suite is far broader; their genuine |
| 74 | +deltas concentrate in PRODUCE/types. Actionable additions they suggest: |
| 75 | + |
| 76 | +- **hugeint / uuid in the produce matrix** (they select both): OUT-row does a per-value string round-trip, distinct |
| 77 | + from narrow int. Now in `test_produce_numpy_perf` / `test_fetch_perf`. |
| 78 | +- **int128-internal decimal** (`DECIMAL(28,x)`) alongside the int64-internal one: hits a wider cast path. Added. |
| 79 | +- **heterogeneous mixed-type row**: exercises per-cell type dispatch in the Fetchone loop, unlike homogeneous |
| 80 | + columns. Added as `test_fetchall_mixed_wide`. |
| 81 | +- **long varchar (>64 char)** alongside the short string: shifts string copy / transcode toward copy-bound. Added |
| 82 | + as `varchar_long` in the matrix. |
| 83 | +- **result-cardinality (top-N) sweep**: holds engine work ~constant while sweeping rows-to-Python. Adopted as |
| 84 | + `test_cardinality_perf` (plain LIMIT, no ORDER BY; the sort swamped the signal). |
| 85 | +- **peak-memory guard** on the O(rows) produce paths: a conversion regression is often memory-shaped. Partially |
| 86 | + covered by the tracemalloc guard; full coverage waits on memory mode. |
| 87 | + |
| 88 | +Out of scope (theirs, not adopted): pure-engine filter/group/window workloads; 100M+ row scale (IO/engine |
| 89 | +dominated); the free-threading category (unsupported by this client). Do NOT adopt their no-warmup single-run |
| 90 | +methodology (charges import-cache population into the measurement). |
0 commit comments