Ryan Malloy a9e1f17bae Phase 31: Head-to-head benchmark vs IfxPy (the C-bound PyPI driver)
Adds a paired benchmark of informix-db (pure Python) against IfxPy
3.0.5 (IBM's C-bound driver via OneDB ODBC) on identical workloads
against the same Informix dev container.

Headline result: pure Python is competitive — and faster on 2/5
benchmarks where wire round-trip dominates over codec/marshaling.

| Benchmark | IfxPy | informix-db | Result |
|---|---:|---:|---:|
| select_one_row (single-row latency) | 128 us | 116 us | us 9% faster |
| select_systables_first_10 | 126 us | 184 us | IfxPy 32% faster |
| select_bench_table_all (1k rows) | 969 us | 855 us | us 12% faster |
| executemany(1000) in txn | 21.5 ms | 30.8 ms | IfxPy 30% slower |
| cold_connect_disconnect | 11.0 ms | 10.9 ms | comparable |

Why the surprising wins: IfxPy's path is Python -> OneDB ODBC ->
libifdmr -> wire. Ours is Python -> wire. When wire round-trip
dominates (single-row, bulk fetch), the missing abstraction layer
makes us faster. When per-row marshaling dominates (executemany),
IfxPy's C-level execute(stmt, tuple) beats Python BIND-PDU build.

Files added under tests/benchmarks/compare/:
* Dockerfile.ifxpy — Ubuntu 20.04 base with IfxPy + OneDB drivers
* ifxpy_bench.py — IfxPy benchmark workloads matching test_*_perf.py
* README.md — methodology, results, install gauntlet, reproduction

The IfxPy install gauntlet itself is part of the comparison story:
modern Python 3.11 (not 3.13), setuptools <58, permissive CFLAGS,
manual download of 92MB OneDB ODBC tarball, four LD_LIBRARY_PATH
directories, libcrypt.so.1 (deprecated 2018, missing on Arch /
Fedora 35+ / RHEL 9). Versus our `pip install informix-db`.

README.md (project root): added "Compared to IfxPy" section under
Performance with the headline numbers and a pointer to the full
methodology.

.gitignore: keep Dockerfile/script/README under tests/benchmarks/
compare/, exclude the 92MB OneDB tarball and the local venv.
2026-05-05 11:41:47 -06:00

190 lines
5.7 KiB
Python

"""IfxPy comparison benchmark.
Runs the same workloads as ``tests/benchmarks/test_*_perf.py`` against
the same dev-container Informix instance, but using IfxPy (the C-bound
PyPI driver) instead of ``informix-db``. Numbers go straight to stdout;
the host parses them and produces a side-by-side table.
Workloads:
* ``select_one_row`` — single-row SELECT round-trip latency
* ``select_systables_first_10`` — small server-side query
* ``select_bench_table_all`` — 1k-row sustained fetch
* ``executemany_1000_rows_in_txn`` — bulk INSERT throughput
* ``cold_connect_disconnect`` — login handshake cost
Each workload runs N times; we report mean and stddev.
"""
from __future__ import annotations
import statistics
import sys
import time
from collections.abc import Callable
import IfxPy
# Connect string — mirrors the conftest.py defaults the host uses.
CONN_STR = (
"SERVER=informix;"
"DATABASE=sysmaster;"
"HOST=127.0.0.1;"
"SERVICE=9088;"
"UID=informix;"
"PWD=in4mix;"
"PROTOCOL=onsoctcp"
)
ROUNDS_FAST = 100 # for sub-millisecond ops
ROUNDS_MED = 20 # for 1-100ms ops
ROUNDS_SLOW = 3 # for >1s ops
def measure(name: str, rounds: int, body: Callable[[], None]) -> dict:
"""Run ``body`` ``rounds`` times; return mean/stddev/min/max in seconds."""
timings = []
for _ in range(rounds):
t0 = time.perf_counter()
body()
t1 = time.perf_counter()
timings.append(t1 - t0)
return {
"name": name,
"rounds": rounds,
"mean_s": statistics.mean(timings),
"stddev_s": statistics.stdev(timings) if len(timings) > 1 else 0.0,
"min_s": min(timings),
"max_s": max(timings),
}
def bench_select_one_row(conn) -> dict:
def run() -> None:
stmt = IfxPy.exec_immediate(
conn, "SELECT 1 FROM systables WHERE tabid = 1"
)
IfxPy.fetch_tuple(stmt)
IfxPy.free_stmt(stmt)
return measure("select_one_row", ROUNDS_FAST, run)
def bench_select_systables_first_10(conn) -> dict:
def run() -> None:
stmt = IfxPy.exec_immediate(
conn,
"SELECT FIRST 10 tabname, owner, tabid, ncols FROM systables",
)
while IfxPy.fetch_tuple(stmt):
pass
IfxPy.free_stmt(stmt)
return measure("select_systables_first_10", ROUNDS_FAST, run)
def bench_select_bench_table_all(conn) -> dict:
"""Requires p21_bench table to exist (created by host-side fixture)."""
# Probe whether the table exists; if not, skip
try:
stmt = IfxPy.exec_immediate(conn, "SELECT COUNT(*) FROM p21_bench")
row = IfxPy.fetch_tuple(stmt)
IfxPy.free_stmt(stmt)
if not row or row[0] == 0:
return {"name": "select_bench_table_all", "skipped": "p21_bench empty"}
except Exception as e:
return {"name": "select_bench_table_all", "skipped": f"p21_bench missing: {e}"}
def run() -> None:
stmt = IfxPy.exec_immediate(conn, "SELECT * FROM p21_bench")
while IfxPy.fetch_tuple(stmt):
pass
IfxPy.free_stmt(stmt)
return measure("select_bench_table_all", ROUNDS_MED, run)
def bench_executemany_1000_rows_in_txn() -> dict:
"""Open a connection on testdb, autocommit OFF, executemany 1000."""
try:
conn = IfxPy.connect(
CONN_STR.replace("DATABASE=sysmaster", "DATABASE=testdb"), "", ""
)
except Exception as e:
return {"name": "executemany_1000_rows_in_txn", "skipped": f"testdb: {e}"}
IfxPy.autocommit(conn, IfxPy.SQL_AUTOCOMMIT_OFF)
table = "p21_ifxpy_bench"
try:
try:
IfxPy.exec_immediate(conn, f"DROP TABLE {table}")
IfxPy.commit(conn)
except Exception:
pass
IfxPy.exec_immediate(
conn, f"CREATE TABLE {table} (id INT, name VARCHAR(64), value FLOAT)"
)
IfxPy.commit(conn)
counter = [0]
def run() -> None:
counter[0] += 1
base = counter[0] * 1000
stmt = IfxPy.prepare(
conn, f"INSERT INTO {table} VALUES (?, ?, ?)"
)
for i in range(1000):
IfxPy.execute(stmt, (base + i, f"row_{base + i}", float(base + i)))
IfxPy.free_stmt(stmt)
IfxPy.commit(conn)
result = measure("executemany_1000_rows_in_txn", ROUNDS_SLOW, run)
return result
finally:
try:
IfxPy.exec_immediate(conn, f"DROP TABLE {table}")
IfxPy.commit(conn)
except Exception:
pass
IfxPy.close(conn)
def bench_cold_connect_disconnect() -> dict:
def run() -> None:
conn = IfxPy.connect(CONN_STR, "", "")
IfxPy.close(conn)
return measure("cold_connect_disconnect", ROUNDS_SLOW, run)
def main() -> None:
print("# IfxPy benchmark results", file=sys.stderr)
print(f"# IfxPy version: {IfxPy.__version__ if hasattr(IfxPy, '__version__') else 'unknown'}", file=sys.stderr)
# Persistent connection for the read-mostly benchmarks
conn = IfxPy.connect(CONN_STR, "", "")
results = []
results.append(bench_select_one_row(conn))
results.append(bench_select_systables_first_10(conn))
results.append(bench_select_bench_table_all(conn))
IfxPy.close(conn)
results.append(bench_executemany_1000_rows_in_txn())
results.append(bench_cold_connect_disconnect())
# Emit machine-parseable lines on stdout
for r in results:
if r.get("skipped"):
print(f"SKIP {r['name']}: {r['skipped']}")
else:
print(
f"RESULT {r['name']} mean={r['mean_s']:.6f}s "
f"stddev={r['stddev_s']:.6f}s min={r['min_s']:.6f}s "
f"max={r['max_s']:.6f}s rounds={r['rounds']}"
)
if __name__ == "__main__":
main()