Adds tests/benchmarks/ with pytest-benchmark coverage of the hot codec paths and end-to-end SELECT/INSERT/pool/async round-trips. Establishes a committed baseline.json so PRs can be regression-checked at review via --benchmark-compare. * test_codec_perf.py (16): decode/encode_param/parse_tuple_payload micro-benchmarks - run without container, suitable for pre-merge CI. * test_select_perf.py (4): SELECT round-trips - 1-row latency floor, 10-row, 1k-row full fetch, parameterized. * test_insert_perf.py (3): single-row INSERT, executemany 100 / 1000. * test_pool_perf.py (3): cold connect, pool acquire/release, pool acquire + query + release. * test_async_perf.py (2): async round-trip overhead, 10x concurrent. * baseline.json: committed snapshot, 28 measurements. * benchmark pytest marker, gated off by default. * Makefile: bench / bench-codec / bench-save targets; test-integration excludes benchmarks for speed. Headline numbers (dev container loopback): * decode(int): 181 ns * parse_tuple 5 cols: 2.87 µs/row * SELECT 1 round-trip: 177 µs * Pool acquire+query+release: 295 µs * Cold connect: 11.2 ms (72x slower than pool) UTF-8 decode carries no measurable cost vs iso-8859-1 - confirms Phase 20 didn't regress anything. Total: 69 unit + 211 integration + 28 benchmark = 308 tests.
200 lines
5.7 KiB
Python
200 lines
5.7 KiB
Python
"""Codec micro-benchmarks — no server required.
|
|
|
|
These measure the tight inner loops the driver hits on every row:
|
|
``decode()`` per cell, ``parse_tuple_payload()`` per row,
|
|
``encode_param()`` per parameter. A 1M-row fetch hits ``decode()``
|
|
5-10M times; a 1% slowdown there is *visible*.
|
|
|
|
The fixtures synthesize realistic byte payloads — no need for the
|
|
Docker container. This makes the benchmarks usable in CI's pre-merge
|
|
job (which doesn't run integration tests).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
import struct
|
|
from io import BytesIO
|
|
|
|
import pytest
|
|
|
|
from informix_db._protocol import IfxStreamReader
|
|
from informix_db._resultset import ColumnInfo, parse_tuple_payload
|
|
from informix_db._types import IfxType
|
|
from informix_db.converters import decode, encode_param
|
|
|
|
pytestmark = pytest.mark.benchmark
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# decode() — per-value dispatch
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_decode_int(benchmark) -> None:
|
|
"""Hot path: per-cell INT decode. ~5M calls/sec is the kind of speed
|
|
a 1M-row fetch with 5 INT columns needs."""
|
|
raw = struct.pack("!i", 42)
|
|
benchmark(decode, int(IfxType.INT), raw)
|
|
|
|
|
|
def test_decode_smallint(benchmark) -> None:
|
|
raw = struct.pack("!h", 100)
|
|
benchmark(decode, int(IfxType.SMALLINT), raw)
|
|
|
|
|
|
def test_decode_bigint(benchmark) -> None:
|
|
raw = struct.pack("!q", 1234567890123)
|
|
benchmark(decode, int(IfxType.BIGINT), raw)
|
|
|
|
|
|
def test_decode_float(benchmark) -> None:
|
|
raw = struct.pack("!d", 3.14159)
|
|
benchmark(decode, int(IfxType.FLOAT), raw)
|
|
|
|
|
|
def test_decode_date(benchmark) -> None:
|
|
raw = struct.pack("!i", 45678)
|
|
benchmark(decode, int(IfxType.DATE), raw)
|
|
|
|
|
|
def test_decode_varchar_short(benchmark) -> None:
|
|
"""20-byte ASCII string — typical name column."""
|
|
raw = b"hello world example "
|
|
benchmark(decode, int(IfxType.VARCHAR), raw)
|
|
|
|
|
|
def test_decode_varchar_long(benchmark) -> None:
|
|
"""255-byte VARCHAR — max non-LVARCHAR length."""
|
|
raw = b"x" * 255
|
|
benchmark(decode, int(IfxType.VARCHAR), raw)
|
|
|
|
|
|
def test_decode_varchar_utf8(benchmark) -> None:
|
|
"""Multi-byte UTF-8 decode — exercise Phase 20 path."""
|
|
raw = "café résumé naïve Zürich".encode()
|
|
benchmark(decode, int(IfxType.VARCHAR), raw, "utf-8")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# encode_param() — parameter-binding hot path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_encode_int(benchmark) -> None:
|
|
benchmark(encode_param, 42)
|
|
|
|
|
|
def test_encode_str_ascii(benchmark) -> None:
|
|
benchmark(encode_param, "hello world example", "iso-8859-1")
|
|
|
|
|
|
def test_encode_str_utf8(benchmark) -> None:
|
|
benchmark(encode_param, "café résumé naïve", "utf-8")
|
|
|
|
|
|
def test_encode_float(benchmark) -> None:
|
|
benchmark(encode_param, 3.14159)
|
|
|
|
|
|
def test_encode_date(benchmark) -> None:
|
|
benchmark(encode_param, datetime.date(2026, 5, 4))
|
|
|
|
|
|
def test_encode_datetime(benchmark) -> None:
|
|
benchmark(encode_param, datetime.datetime(2026, 5, 4, 12, 30, 45))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# parse_tuple_payload() — per-row decode
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _build_systables_row_payload() -> bytes:
|
|
"""Synthesize the SQ_TUPLE bytes a typical systables row produces.
|
|
|
|
Layout: [short warn=0][int size][payload][optional pad]
|
|
Payload has columns: tabname VARCHAR(128), owner VARCHAR(32),
|
|
tabid INT, partnum INT, ncols INT.
|
|
"""
|
|
payload = bytearray()
|
|
# tabname VARCHAR: [byte len][bytes] — single-byte length prefix per
|
|
# the discovered tuple format
|
|
name = b"systables"
|
|
payload.append(len(name))
|
|
payload.extend(name)
|
|
# owner VARCHAR
|
|
owner = b"informix"
|
|
payload.append(len(owner))
|
|
payload.extend(owner)
|
|
# tabid INT
|
|
payload.extend(struct.pack("!i", 1))
|
|
# partnum INT
|
|
payload.extend(struct.pack("!i", 1048578))
|
|
# ncols INT
|
|
payload.extend(struct.pack("!i", 32))
|
|
|
|
out = bytearray()
|
|
out.extend(struct.pack("!h", 0)) # warn
|
|
out.extend(struct.pack("!i", len(payload)))
|
|
out.extend(payload)
|
|
if len(payload) & 1:
|
|
out.append(0) # even-byte pad
|
|
return bytes(out)
|
|
|
|
|
|
_SYSTABLES_COLUMNS = [
|
|
ColumnInfo(
|
|
name="tabname",
|
|
type_code=int(IfxType.VARCHAR),
|
|
raw_type_code=int(IfxType.VARCHAR),
|
|
encoded_length=128,
|
|
),
|
|
ColumnInfo(
|
|
name="owner",
|
|
type_code=int(IfxType.VARCHAR),
|
|
raw_type_code=int(IfxType.VARCHAR),
|
|
encoded_length=32,
|
|
),
|
|
ColumnInfo(
|
|
name="tabid",
|
|
type_code=int(IfxType.INT),
|
|
raw_type_code=int(IfxType.INT),
|
|
encoded_length=4,
|
|
),
|
|
ColumnInfo(
|
|
name="partnum",
|
|
type_code=int(IfxType.INT),
|
|
raw_type_code=int(IfxType.INT),
|
|
encoded_length=4,
|
|
),
|
|
ColumnInfo(
|
|
name="ncols",
|
|
type_code=int(IfxType.INT),
|
|
raw_type_code=int(IfxType.INT),
|
|
encoded_length=4,
|
|
),
|
|
]
|
|
|
|
|
|
def test_parse_tuple_5cols_iso8859(benchmark) -> None:
|
|
"""Decode a 5-column row (2 VARCHAR + 3 INT) — typical `systables` shape."""
|
|
payload = _build_systables_row_payload()
|
|
|
|
def run() -> tuple:
|
|
reader = IfxStreamReader(BytesIO(payload))
|
|
return parse_tuple_payload(reader, _SYSTABLES_COLUMNS)
|
|
|
|
benchmark(run)
|
|
|
|
|
|
def test_parse_tuple_5cols_utf8(benchmark) -> None:
|
|
"""Same shape, UTF-8 codec path — verify Phase 20 isn't a bottleneck."""
|
|
payload = _build_systables_row_payload()
|
|
|
|
def run() -> tuple:
|
|
reader = IfxStreamReader(BytesIO(payload))
|
|
return parse_tuple_payload(reader, _SYSTABLES_COLUMNS, encoding="utf-8")
|
|
|
|
benchmark(run)
|