Performance Guide¶
This guide covers observed performance for sqlalchemy-cubrid and practical optimization patterns.
Table of Contents¶
- Overview
- Current Baseline (2026-10-12)
- Benchmark Artifact Contract
- Benchmark Results
- Performance Characteristics
- Optimization Tips
- Running Benchmarks
Overview¶
sqlalchemy-cubrid adds SQLAlchemy Core/ORM behavior on top of the CUBRID Python driver.
flowchart LR
App[Application / ORM Models] --> SA[SQLAlchemy Core / ORM]
SA --> Dialect[sqlalchemy-cubrid Dialect]
Dialect --> Driver[pycubrid DBAPI]
Driver --> CAS[CAS Binary Protocol]
CAS --> Server[(CUBRID Server)]
flowchart TD
Query[ORM Query] --> Compile[SQL compilation]
Compile --> Bind[Parameter binding]
Bind --> Execute[DBAPI execute]
Execute --> Fetch[Row fetch + object materialization]
Fetch --> AppOut[Application objects]
Current Baseline (2026-10-12)¶
A same-host, same-CUBRID baseline of the current dialect (#830): raw pycubrid, SQLAlchemy Core and the ORM run identical workloads against one CUBRID server. It is a reference for later comparisons, not a performance target, and it does not compare CUBRID with another database.
Conditions. sqlalchemy-cubrid main at a570ea4 (the sqlalchemy_cubrid/
source equals v1.11.0 apart from one typing import in dml.py, #827; v1.10.0
and v1.11.0 have identical dialect source), pycubrid 1.12.0 through
cubrid+pycubrid://, SQLAlchemy 2.1.4 and 2.0.54, CPython 3.12.13, CUBRID
11.4.6.1963 (cubrid/cubrid:11.4 image sha256:1248b77ad398 in Docker, port bound
to 127.0.0.1), Intel
Core i5-9400F (6 cores), Linux 5.15. The host was shared with other CUBRID
containers. Autocommit off, default QueuePool, one connection per variant,
no pool_pre_ping. Each figure is the median of 5 trials, with the
interquartile range (IQR) of the trial values; every trial first runs one
unrecorded warm-up pass.
Raw artifacts and commands:
benchmarks/2026-10-12-dialect-baseline/
(run.sh, the worker input.json, raw/, summary.json and aggregate.py).
The tools are scripts/bench_dialect_baseline.py (statement cache, fetch,
batch DML and reflection; every result is checked), scripts/bench_compile.py,
scripts/profile_orm.py and the cubrid-benchmark orm-overhead workers at
f5510d0 (single-row CRUD, 10 s per operation after 2 s warm-up, 1,000 seeded
rows, batches of 100, zero errors in every trial).
Single-row operations and fetch¶
Per-operation latency in ms (median of the trial p50 values, IQR in brackets), SQLAlchemy 2.1.4; SQLAlchemy 2.0.54 is in the last two columns.
| Operation (orm-overhead workers) | raw pycubrid | Core | ORM | Core 2.0 | ORM 2.0 |
|---|---|---|---|---|---|
| select_by_pk | 0.62 [0.61–0.63] | 0.75 [0.73–0.77] | 1.05 [1.01–1.09] | 0.78 | 1.11 |
| select_full_scan (1,000 rows) | 3.86 [3.82–3.93] | 4.22 [4.20–4.27] | 7.61 [7.59–7.69] | 4.26 | 8.02 |
| insert_single (with commit) | 59.2 [58.5–84.0] | 58.7 [58.5–61.8] | 73.5 [66.9–74.9] | 58.5 | 75.0 |
| update_by_pk (with commit) | 50.5 [50.1–58.4] | 58.0 [58.0–58.0] | 66.6 [66.5–66.7] | 58.3 | 66.6 |
| delete_by_pk (with commit) | 66.7 [50.2–67.4] | 58.5 [58.4–58.6] | 74.9 [67.2–75.0] | 58.5 | 74.8 |
- Reads isolate the client layers: Core adds about 0.13 ms (21%) to a primary-key SELECT and 9% to a 1,000-row fetch; the ORM roughly doubles the fetch (object materialization).
- Every committed write takes 50–75 ms in steps of about 8.3 ms whatever the layer, so the server commit dominates and Core equals raw pycubrid within that step. Write medians are a few hundred operations per trial and move by a whole step between trials.
- The workers'
insert_batchsteps are not like for like (raw usesexecutemany(), Core one insertmanyvalues INSERT, the ORM a unit-of-work flush that also deletes object by object), so they are only in the raw files.
Statement cache, fetch and batch DML¶
scripts/bench_dialect_baseline.py, SQLAlchemy 2.1.4 (2.0.54 in the last
column). One-row SELECT by primary key, 1,000 calls per variant, in µs:
| Variant | 2.1.4 median [IQR] | 2.0.54 median |
|---|---|---|
| raw pycubrid | 661 [646–664] | 637 |
| Core, statement cache (default) | 775 [748–847] | 838 |
Core, compiled_cache=None |
996 [984–1012] | 1035 |
ORM select(Item) |
999 [938–1098] | 951 |
Turning the statement cache off costs about 220 µs per execution here
(compilation plus result-metadata setup); offline, scripts/bench_compile.py
(10,000 compilations, 5 trials) compiles a SELECT with LIMIT/OFFSET in 91.2 µs
(2.0.54: 84.7), a simple INSERT in 65.4 µs (60.7), an INSERT … ON DUPLICATE KEY
UPDATE in 124.5 µs (117.7) and a SELECT … FOR UPDATE in 70.8 µs (67.0).
Batches of 1,000 rows, each statement timed with its commit and the table checked afterwards (row count and sum), in ms:
| Variant | INSERT | UPDATE | DELETE |
|---|---|---|---|
raw executemany() |
480 [459–666] | 471 [441–497] | 619 [446–653] |
raw executemany(prepared=True) |
180 [174–195] | 221 [209–247] | 244 [238–288] |
Core (insert() list: insertmanyvalues; update()/delete() list: executemany()) |
99 [95–121] | 577 [421–590] | 567 [456–615] |
Core, experimental prepared=True in do_executemany |
116 [112–117] | 239 [223–245] | 280 [216–289] |
| ORM (bulk INSERT, bulk UPDATE by primary key, unit-of-work DELETE) | 108 [105–129] | 466 [447–701] | 498 [440–533] |
- A Core or ORM bulk INSERT is 4.4–4.8 times faster than the driver's own
executemany(): insertmanyvalues sends a few multi-row INSERT statements instead of 1,000 single-row statements in one literal batch. - Core UPDATE/DELETE batches go through
PyCubridDialect.do_executemany(), which callscursor.executemany()unchanged, so they cost the same as the raw literal batch (the 2.0.54 medians, 974 and 948 ms, had an IQR of 421–1190 and 484–1065 ms on the shared host). - The experimental
prepared=Trueoverride affects onlydo_executemany(), so it changes the Core UPDATE and DELETE columns; its INSERT still goes through insertmanyvalues (do_execute()), and the 116 versus 99 ms INSERT difference is run-to-run variation, not an effect of the prepared path. - Reflecting the table (columns, primary key, foreign keys, indexes and unique
constraints with a fresh
Inspector) takes 16.5 ms and 8 SQL statements;MetaData.reflect()of the table 20.9 ms and 10 statements.
Where the time goes¶
scripts/profile_orm.py (default workload: 20 single-row ORM CRUD loops and
3 bulk inserts of 300 rows under cProfile) attributes 0.003% of the profiled
request time to the dialect's own modules, 5.9% (2.0.54: 6.7%) to SQLAlchemy
self time (Core and ORM together) and 5.9% to pycubrid self time; the remaining self time is outside
those packages (standard library, socket and builtin calls, which include the
wait for the server's replies).
The script's separate compile-only pass (250 loops of five statements) ranks
the dialect functions by cumulative time as visit_insert, visit_update,
visit_delete, visit_on_duplicate_key_update and limit_clause. No dialect-specific hotspot or regression was found, so no
dialect change is proposed from this baseline.
Prepared batch feasibility¶
pycubrid 1.12.0's opt-in executemany(..., prepared=True) (its large-batch
safety work, cubrid-lab/pycubrid#854, is complete) took 0.37–0.47 times the
literal batch's time above, and about 0.4–0.5 times for Core UPDATE/DELETE
through the benchmark-only do_executemany override. The dialect keeps the
default path. Its contracts would allow an opt-in, but not a silent default:
- SQLAlchemy sends
executemany()for UPDATE and DELETE parameter lists and for the INSERT fallback of #421; these start withUPDATE,DELETEorINSERT, which the prepared path accepts. A statement starting withWITHis rejected withNotSupportedErrorand there is no fallback. - On success
rowcountis the sum of the per-row counts, as the dialect'ssupports_sane_multi_rowcountneeds; partial-failure, commit and rollback behavior and the exception mapping match the literal batch. - Accepted values are narrower: empty
bytes, NaN/infinity, collection elements other thanstr/int/Noneand (with autocommit on)Lobvalues are rejected before sending, and conversion errors read "Cannot coerce host var" instead of the literal text. Every placeholder must be typable by the server.
Follow-up: CUBRID versions, drivers, async and concurrency (2026-10-13)¶
The same scripts/bench_dialect_baseline.py, extended with a CUBRIDdb route, an
async mode and a concurrent-connections workload, was run on CUBRID 10.2.18.9024,
11.0.16.0419, 11.2.9.0866 and 11.4.6.1963 (official cubrid/cubrid images in
Docker, one benchmark server at a time, port bound to 127.0.0.1) through
cubrid+pycubrid://, cubrid:// (CUBRIDdb built from cubrid-python v11.3.0.51,
reported as 11.3.0.1) and cubrid+aiopycubrid://. Same host, CPython 3.12.13,
pycubrid 1.12.0 and SQLAlchemy 2.1.4 as above. From the 11.0 async run onward, one
to five CUBRID containers of unrelated work also ran on the host (mostly idle,
3–5% CPU each when sampled). Each figure is the median of 5 trials; the IQRs,
every trial value and the exact commands are in
benchmarks/2026-10-13-dialect-baseline-followup/.
The CUBRID 11.4 pycubrid reads match the 2026-10-12 run within the trial
spread; batch timings vary widely in both runs.
One connection, one-row SELECT by primary key in µs and SELECT of 1,000 rows in ms:
| Server | Driver | raw | Core | Core, no cache | ORM | Core fetch | ORM fetch |
|---|---|---|---|---|---|---|---|
| 10.2 | pycubrid | 506 | 627 | 892 | 760 | 4.57 | 7.83 |
| 10.2 | CUBRIDdb | 455 | 646 | 842 | 748 | 4.24 | 7.46 |
| 10.2 | aiopycubrid | 553 | 767 | 946 | 986 | 4.51 | 8.15 |
| 11.0 | pycubrid | 435 | 579 | 767 | 649 | 3.98 | 7.29 |
| 11.0 | CUBRIDdb | 447 | 582 | 789 | 736 | 4.09 | 7.33 |
| 11.0 | aiopycubrid | 635 | 859 | 1123 | 988 | 4.65 | 8.31 |
| 11.2 | pycubrid | 730 | 762 | 1019 | 905 | 4.35 | 7.99 |
| 11.2 | CUBRIDdb | 509 | 651 | 863 | 753 | 4.06 | 7.52 |
| 11.2 | aiopycubrid | 1295 | 1367 | 1456 | 1508 | 4.84 | 8.30 |
| 11.4 | pycubrid | 595 | 764 | 973 | 956 | 4.90 | 8.11 |
| 11.4 | CUBRIDdb | 551 | 691 | 899 | 765 | 3.79 | 7.25 |
| 11.4 | aiopycubrid | 653 | 876 | 1158 | 1062 | 4.49 | 8.03 |
- No server version is consistently faster or slower; the differences between rows are within the trial-to-trial spread.
- The two sync drivers are close: a raw one-row SELECT takes 447–551 µs through CUBRIDdb and 435–730 µs through pycubrid, and Core and ORM differ by less than 0.2 ms.
- The async route adds 0.1–0.6 ms to a Core one-row SELECT on one connection (event loop and greenlet bridge) and at most 0.7 ms to a Core 1,000-row fetch.
- Reflection (sync drivers only) takes 13.3–16.2 ms with a fresh
Inspectorand 16.4–21.1 ms withMetaData.reflect(), with the same 8 and 10 SQL statements on every server and both drivers.
Batch UPDATE of 1,000 rows with its commit, in ms (INSERT through insertmanyvalues is in the last column):
| Server | raw executemany() |
raw prepared=True |
Core, pycubrid | Core, CUBRIDdb | Core, async | Core INSERT, any driver |
|---|---|---|---|---|---|---|
| 10.2 | 798 | 257 | 1032 | 1047 | 351 | 126–190 |
| 11.0 | 413 | 259 | 633 | 556 | 391 | 116–264 |
| 11.2 | 703 | 231 | 778 | 497 | 805 | 82–125 |
| 11.4 | 637 | 220 | 409 | 805 | 398 | 81–124 |
- Literal batches (raw
executemany()and Core UPDATE/DELETE lists) took 231–1,296 ms with wide IQRs and no consistent order by server or driver. The prepared INSERT and UPDATE (164–261 ms) and the insertmanyvalues INSERT (72–264 ms, mostly 81–131 ms) stay the cheaper paths on every server and driver. - On CUBRIDdb, Core UPDATE/DELETE lists run one
execute()per row (the dialect'sdo_executemany()guard for CUBRIDdb's unboundNoneand last-rowrowcount), not the driver's ownexecutemany().
Concurrent connections on CUBRID 11.4: N workers start together, each on its own
connection (threads for the sync drivers, asyncio tasks for async); 500 one-row
SELECTs or 50 committed single-row UPDATEs per worker, in operations per second
from the first counted operation's start to the last one's end:
| Driver | Connections | raw SELECT | Core SELECT | ORM SELECT | Core UPDATE + commit |
|---|---|---|---|---|---|
| pycubrid (threads) | 4 | 4781 | 3672 | 3017 | 64 |
| pycubrid (threads) | 8 | 5450 | 3814 | 3047 | 107 |
| CUBRIDdb (threads) | 4 | 1623 | 390 | 433 | 28 |
| CUBRIDdb (threads) | 8 | 839 | 479 | 581 | 25 |
| aiopycubrid (tasks) | 4 | 4222 | 3030 | 2536 | 52 |
| aiopycubrid (tasks) | 8 | 4814 | 3238 | 2598 | 89 |
- pycubrid threads scale on every server: 4,781–5,771 raw and 3,672–3,966 Core SELECTs/s with 4 connections, 5,450–5,982 and 3,587–3,916 with 8. Core and ORM level off at about 3,600–3,900 and 2,700–3,100 SELECTs/s, which suggests the client process (one Python interpreter) is the limit there.
- The async route scales the same way, slightly lower: 4,222–5,184 raw and 3,030–3,378 Core SELECTs/s with 4 tasks, 4,814–5,955 and 3,238–3,405 with 8.
- Committed UPDATEs, about 22/s on one connection, mostly rise to 30–64/s with 4 connections and 89–110/s with 8 through pycubrid and the async route, with a p50 of 66–83 ms, so the server overlaps the commits. Two runs did not (11.0 pycubrid threads: 6, 15 and 30/s with 1, 4 and 8 connections, p50 155–218 ms; 10.2 async: 15 and 34/s with 4 and 8, p50 202–224 ms); the cause was not investigated.
- CUBRIDdb does not scale: with 4 or 8 threads it never exceeded one
connection's throughput (278–1,782 SELECTs/s; one connection alone does about
1,800–2,200 raw SELECTs/s, first table) and stayed at 20–28 committed UPDATEs/s
on every server. Its C extension (
cubrid_ext/python_cubrid.cat v11.3.0.51) contains noPy_BEGIN_ALLOW_THREADS, so it holds the GIL during server round trips and the threads wait for each other. Multi-threaded applications should use pycubrid. - The one-connection row of this workload varied widely between servers and trials (433–2,046 raw SELECTs/s), so the single-connection comparison uses the first table.
Not measured: pool_pre_ping, Python other than 3.12, SQLAlchemy 2.0 on the
follow-up's servers and drivers, more than 8 connections or several client
processes, the async route's reflection (it runs the same synchronous dialect
code through run_sync), remote networks and server-side resource use.
Benchmark Artifact Contract¶
A versioned manifest (#901) lets an external consumer, such as cubrid-benchmark, check a baseline or candidate run without unrecorded assumptions. It is advisory: no CI job requires it, and no threshold fails a pull request.
scripts/bench_dialect_baseline.py produces the report. It now also records:
workload.script_sha256, the SHA-256 of the script, as the workload identity;target, the DSN without its password, as the connection identity;pool_pre_ping(off) and autocommit (off);- the dialect's
use_insertmanyvaluesandinsertmanyvalues_page_size; "status": "complete". A report is written only after every check of the run passed;- the provenance of the measured dialect (#903):
dialect_package_sha256, a SHA-256 over the installedsqlalchemy_cubrid/*.pyfiles, andsource_verification; - the
--server-image-digest,--host-labeland--comparable-groupyou pass to the benchmark.
Before measuring, the benchmark checks the installed dialect against
--source-sha, and a mismatch stops the run:
- For an install from a local git checkout (
pip/uv pip install <dir>, editable or not), the installed*.pyfiles must equal those of that commit in the checkout (source_verification: local_checkout). - For a VCS install (
git+https://...@<sha>), the recorded commit must equal it (vcs_info). - Any other install, such as a wheel from PyPI, is recorded as
unverified, andexportrejects it.
Run the benchmark with python -I. Otherwise an sqlalchemy_cubrid.egg-info
in the current directory can hide the installed distribution's metadata.
scripts/bench_artifact.py handles the manifest:
exportturns one complete, verified report intomanifest.json.validatechecks a manifest. It re-hashes the files it lists and rebuilds the manifest from them. An edited, corrupt or mixed-revision input therefore fails, and so does an in-place edit of the image digest, host label, group, source SHA or package hash, because those now come from the hashed report. With--source-repo DIR, it also recomputes the package hash from the source SHA in that git repository. Editing the report and the manifest together is not detected, so keep artifacts in git.comparereports per-metric medians and ratios for a baseline and a candidate. It does so only when they share a group and differ in nothing but the dialect (source SHA, version, package hash and verification method); otherwise it printsnot comparablewith the reasons. It takes--source-repoas well.
Exit codes: 0 valid or comparable, 1 invalid, 2 usage error, 3 not comparable.
python -I scripts/bench_dialect_baseline.py --dsn <dsn> --trials 5 \
--source-sha <40-character SHA of the installed dialect> \
--server-image-digest sha256:<64 hex> --host-label <label> --comparable-group <name> \
--output raw/bench_dialect_baseline.json > raw/bench_dialect_baseline.txt
python scripts/bench_artifact.py export raw/bench_dialect_baseline.json \
--log raw/bench_dialect_baseline.txt --output manifest.json
python scripts/bench_artifact.py validate manifest.json --source-repo <checkout>
python scripts/bench_artifact.py compare baseline/manifest.json candidate/manifest.json
Contract sqlalchemy-cubrid-benchmark-artifact/2. Version 2 (#903) adds the
provenance fields and moves the digest, host label and group into the report.
validate rejects /1 manifests.
environment:- the dialect source SHA (40 characters) and sqlalchemy-cubrid version;
dialect_package_sha256andsource_verification(local_checkoutorvcs_info);- SQLAlchemy, 2.0.x or 2.1.x only;
- the driver and its version, and the pycubrid version;
- the CUBRID server version and its image digest;
- Python, implementation, platform, CPU and the host label;
- the connection target (the DSN without its password);
- the pool,
pool_pre_ping, autocommit and the insertmanyvalues settings. workload: the script, its SHA-256, the table and every argument exceptsource_sha. The argumentrowsmust be 1,000.trials: at least 3 independent trials.files: the report (raw) and its log (log), each with a relative path and SHA-256. Duplicate paths are rejected.metrics: the advisory set. Each metric has its unit, rows per timed call, SQL shape, commit policy, warm-up, verified condition, every trial value and the median.
| Metric | Unit | SQL shape | Commit |
|---|---|---|---|
select_pk.raw, .core_cached, .core_uncached, .orm |
µs per statement | one-row SELECT by primary key: raw cursor, Core with the compiled cache, Core with compiled_cache=None, ORM |
none |
fetch_all.raw, .core, .orm |
µs per fetch | SELECT of 1,000 rows: fetchall(), .all(), scalars().all() |
none |
batch.raw.{insert,update,delete} |
ms per batch | raw executemany() of literal-rendered rows: one EXECUTE_BATCH (FC20) |
timed, one per batch |
batch.raw_prepared.{insert,update,delete} (pycubrid only) |
ms per batch | opt-in executemany(prepared=True): PREPARE + EXECUTE_ARRAY (FC2 + FC21) |
timed, one per batch |
batch.core.insert |
ms per batch | Core insert() executemany through insertmanyvalues: multi-row INSERT ... VALUES |
timed, one per batch |
batch.core.update, batch.core.delete |
ms per batch | Core executemany through PyCubridDialect.do_executemany (literal FC20; the dialect passes no prepared flag) |
timed, one per batch |
batch.orm.insert, .update, .delete |
ms per batch | ORM bulk INSERT (insertmanyvalues), bulk UPDATE by primary key (do_executemany), unit-of-work DELETE |
timed, one per batch |
For cubrid:// (CUBRIDdb) reports, the SQL shapes follow that driver:
batch.raw.* is CUBRIDdb's own executemany(), and batch.core.update,
batch.core.delete and batch.orm.update go through
CubridDialect.do_executemany, one cursor.execute() per parameter set (#502).
The experimental core_prepared_experiment batch, reflection and the concurrency
workload stay in the report but are not part of the contract.
Pinned pair (2026-10-14, contract 2).
benchmarks/2026-10-14-artifact-pair-v2/
holds run.sh, both raw reports and logs, both manifests and compare.json.
Only the installed dialect varies, and both installs were verified as
local_checkout:
- baseline: v1.11.0,
3a0c289; - candidate:
mainat0dfe938.
Everything else is shared: the same script, pycubrid 1.12.0, SQLAlchemy 2.1.4,
CPython 3.12.13, and CUBRID 11.4.6.1963 from image sha256:1248b77ad398 on one
host. Each run had 5 trials. compare reports comparable. Figures are medians,
with the interquartile range of the trial values:
| Metric | Unit | Baseline v1.11.0 | Candidate 0dfe938 |
Ratio |
|---|---|---|---|---|
select_pk.raw |
us | 732.6 (582.2–2963.6) | 561.8 (558.9–566.3) | 0.77 |
select_pk.core_cached |
us | 802.2 (727.2–803.9) | 752.3 (743.7–793.9) | 0.94 |
select_pk.core_uncached |
us | 916.1 (912.2–1012.8) | 1007.1 (988.3–1049.2) | 1.10 |
select_pk.orm |
us | 837.9 (804.0–872.9) | 849.8 (826.1–865.0) | 1.01 |
fetch_all.raw |
us | 3948.9 (3830.1–3971.4) | 3923.6 (3848.8–4026.4) | 0.99 |
fetch_all.core |
us | 4303.6 (4228.0–4539.3) | 4340.0 (4111.8–4525.4) | 1.01 |
fetch_all.orm |
us | 7781.8 (7596.0–7869.8) | 7734.3 (7730.4–7775.9) | 0.99 |
batch.raw.insert |
ms | 1758.5 (1740.3–1954.4) | 1472.8 (1073.9–1531.7) | 0.84 |
batch.raw_prepared.insert |
ms | 157.1 (152.5–197.6) | 205.9 (197.5–215.0) | 1.31 |
batch.raw.update |
ms | 1146.3 (1112.4–1455.3) | 1346.3 (854.3–1547.2) | 1.17 |
batch.raw_prepared.update |
ms | 178.9 (170.6–196.1) | 188.2 (179.9–204.6) | 1.05 |
batch.raw.delete |
ms | 1429.5 (880.6–1463.6) | 1818.0 (1371.4–1888.1) | 1.27 |
batch.raw_prepared.delete |
ms | 288.9 (256.3–315.8) | 554.4 (355.4–637.7) | 1.92 |
batch.core.insert |
ms | 114.4 (99.5–115.7) | 131.7 (116.0–132.4) | 1.15 |
batch.core.update |
ms | 798.0 (597.4–880.1) | 1513.4 (1506.0–1531.7) | 1.90 |
batch.core.delete |
ms | 1507.8 (1281.9–1515.1) | 1531.8 (781.9–2098.6) | 1.02 |
batch.orm.insert |
ms | 91.2 (89.6–107.2) | 114.5 (108.8–132.3) | 1.26 |
batch.orm.update |
ms | 622.5 (443.9–636.2) | 767.5 (655.3–1108.7) | 1.23 |
batch.orm.delete |
ms | 444.2 (377.5–460.0) | 476.0 (403.4–487.0) | 1.07 |
These ratios show the noise floor of one host. They are not a regression
finding. The raw and raw_prepared rows do not run the dialect at all, yet
select_pk.raw moved by 0.77 and the raw batch rows between 0.84 and 1.92.
batch.orm.update is 1.23 here and was 1.84 in the contract 1 pair of the same
day. Batch DML varies widely from trial to trial on this host. A future candidate result means something only against this kind
of spread, from the same environment.
The first pinned pair, 2026-10-14-artifact-pair (contract 1, #901), is kept
unchanged. validate rejects it as /1: the benchmark did not verify its installs, and its
digest, host label and group were export arguments.
The #830 archives (2026-10-12-dialect-baseline and
2026-10-13-dialect-baseline-followup) stay unchanged and historical. Their
reports predate the contract: they have no status, workload identity or pool
fields. export rejects them, so they cannot be compared with a new artifact.
Benchmark Results¶
The results in this section are historical. They were measured on earlier releases and other hosts and do not describe v1.11.0; see Current Baseline (2026-10-12) for current numbers.
Source: cubrid-benchmark
Environment: Intel Core i5-9400F @ 2.90GHz, 6 cores, Linux x86_64, Docker containers.
Baseline driver workload (2026-03-16, cubrid-benchmark baseline-multilang): Python pycubrid vs PyMySQL, 10000 rows x 5 rounds.
| Scenario | CUBRID (pycubrid baseline) | MySQL (PyMySQL) | Ratio (CUBRID/MySQL) |
|---|---|---|---|
| insert_sequential | 10.47s | 1.74s | 6.0x |
| select_by_pk | 15.99s | 3.52s | 4.5x |
| select_full_scan | 10.31s | 1.86s | 5.5x |
| update_indexed | 10.70s | 2.19s | 4.9x |
| delete_sequential | 10.75s | 2.10s | 5.1x |
Note: SQLAlchemy adds extra overhead for SQL compilation, ORM identity mapping, and object creation.
Issue #104 — ORM dialect profile and optimization¶
Measured in April 2026 on the local benchmark environment used for Issue #104:
| Scenario | Before | After | Delta |
|---|---|---|---|
| Tier 2 single-row CRUD (mean) | 155.99 ms | 268.41 ms | 72.1% slower* |
| Tier 2 bulk insert (100 rows, mean) | 370.33 ms | 356.09 ms | 3.9% faster |
| Tier 2 bulk insert (1000 rows, mean) | 2864.56 ms | 2511.52 ms | 12.3% faster |
| Tier 2 query builder select-all (mean) | 39.76 ms | 31.83 ms | 19.9% faster |
Profiling with scripts/profile_orm.py showed that dialect-layer self time is a very small fraction of total
request time on CRUD-heavy ORM paths, with most elapsed time still spent in SQLAlchemy ORM/Core coordination
and the pycubrid driver. In the profiled workload captured for Issue #104:
- dialect-layer self time was 0.0009% of total request time
- SQLAlchemy Core self time was 6.55% of total request time
pycubridself time was 7.01% of total request time- ORM select overhead vs raw SQL select was 18.08% of ORM select time
Top dialect-layer hotspots from the compilation-focused profiler pass were:
compiler.py:visit_on_duplicate_key_updatecompiler.py:limit_clausecompiler.py:update_limit_clausecompiler.py:<dictcomp>insidevisit_on_duplicate_key_updatecompiler.py:replaceinsidevisit_on_duplicate_key_update
Shipped optimization:
- Enabled SQLAlchemy 2.x
insertmanyvaluesfor the CUBRID dialect (use_insertmanyvalues=Trueanduse_insertmanyvalues_wo_returning=True) so ORM bulk inserts can batch into multi-values INSERT forms without changing the public API.
* The single-row CRUD benchmark showed high variance in this local run, so the clear repeatable improvement from this change set is the bulk insert path rather than singleton unit-of-work latency.
Performance Characteristics¶
- The dialect inherits
pycubridtransport behavior and CAS protocol costs. - Core query compilation is fast but non-zero; repeated dynamic SQL can accumulate overhead.
- ORM paths add identity map and model materialization costs compared to raw DBAPI usage.
- Pool configuration strongly impacts latency under concurrency.
- Bulk APIs and Core statements generally outperform row-by-row ORM unit-of-work patterns.
Optimization Tips¶
- Configure pooling explicitly (example:
pool_size,max_overflow,pool_pre_ping=True). - Use SQLAlchemy Core for high-volume bulk writes and large read pipelines.
- Use
executemany-friendly patterns for insert/update bursts. - Keep transactions explicit and avoid autocommit-style tiny transactions.
- Limit ORM object hydration when only scalar/tuple output is needed.
flowchart TD
Start[Performance tuning] --> Pool{Pool saturated?}
Pool -->|Yes| TunePool[Increase pool_size / max_overflow]
Pool -->|No| ORM{Heavy ORM hydration?}
ORM -->|Yes| CorePath[Switch hot path to Core statements]
ORM -->|No| Batch{Batch operations possible?}
Batch -->|Yes| Bulk[Use bulk operations / executemany]
Batch -->|No| IndexCheck[Validate SQL plans and indexes]
Running Benchmarks¶
- Clone:
git clone https://github.com/cubrid-lab/cubrid-benchmark. - Start the benchmark database containers per the benchmark documentation.
- Run the Python benchmark suite to establish DBAPI baseline metrics.
- Run SQLAlchemy-specific scenarios on the same host and dataset shape.
- Compare driver baseline vs ORM/Core runs to isolate framework overhead.
Use the benchmark repository documentation for the exact command set and runner scripts.
The current baseline is reproduced with
benchmarks/2026-10-12-dialect-baseline/run.sh, which runs
scripts/bench_dialect_baseline.py, scripts/bench_compile.py,
scripts/profile_orm.py and the cubrid-benchmark orm-overhead workers against
one CUBRID container; python aggregate.py in that directory rebuilds
summary.json from raw/. The 2026-10-13 follow-up is reproduced with
benchmarks/2026-10-13-dialect-baseline-followup/run.sh, which starts each CUBRID
version in turn and runs scripts/bench_dialect_baseline.py through the three
drivers with --concurrency 1,4,8.
The pinned artifact pair is reproduced with
benchmarks/2026-10-14-artifact-pair-v2/run.sh. It runs one script against two
installed dialect revisions, then exports, validates and compares their
manifests (see Benchmark Artifact Contract).