Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 36 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,40 +286,50 @@ MariaDB and uses its RETURNING dialect. Piccolo has no MySQL backend:

| operation | yara-orm | tortoise | sqlalchemy | pony | django | peewee | sqlobject | ormar |
|---------------|---------:|---------:|-----------:|-----:|-------:|-------:|----------:|------:|
| bulk_insert | 30.5 | 41.5 | 99.0 | 455.0 | 87.7 | 59.5 | 1257.2 | 191.2 |
| single_insert | 392.8 | 304.8 | 430.8 | 329.6 | 343.6 | 367.3 | 359.8 | 555.3 |
| fetch_all | 5.5 | 34.0 | 42.5 | 47.9 | 28.4 | 30.4 | 44.9 | 72.8 |
| count | 0.5 | 0.8 | 1.2 | 0.7 | 0.7 | 0.8 | 0.7 | 4.7 |
| group_by | 1.3 | 1.3 | 2.1 | 2.2 | 1.5 | 1.1 | 0.9 | - |
| filter | 3.2 | 17.8 | 16.0 | 24.5 | 15.5 | 14.7 | 16.9 | 31.9 |
| get_by_pk | 123.3 | 228.7 | 534.1 | 310.7 | 214.5 | 206.0 | 66.0 | 916.1 |
| update | 3.8 | 3.3 | 6.5 | 265.9 | 4.3 | 4.3 | 4.2 | 7.6 |
| delete | 3.2 | 3.2 | 3.3 | 249.9 | 3.3 | 3.0 | 2.9 | 3.6 |

(MariaDB's `single_insert` ~390 ms is markedly faster than MySQL 8's — a lighter
| bulk_insert | 23.3 | 37.5 | 105.4 | 475.4 | 96.4 | 71.8 | 1266.5 | 209.9 |
| single_insert | 264.9 | 311.6 | 531.7 | 391.5 | 388.2 | 372.2 | 390.3 | 660.0 |
| fetch_all | 5.7 | 35.0 | 43.8 | 48.1 | 28.1 | 37.0 | 42.6 | 72.1 |
| count | 0.5 | 0.8 | 1.2 | 0.7 | 0.7 | 0.8 | 0.6 | 6.7 |
| group_by | 1.2 | 1.3 | 2.3 | 2.2 | 1.5 | 1.4 | 0.9 | - |
| filter | 3.2 | 17.5 | 16.3 | 24.8 | 15.1 | 14.7 | 17.1 | 32.6 |
| get_by_pk | 132.9 | 240.8 | 575.0 | 306.7 | 220.7 | 206.5 | 64.4 | 895.2 |
| update | 4.0 | 4.4 | 7.8 | 266.3 | 5.4 | 4.2 | 3.7 | 8.2 |
| delete | 3.0 | 3.0 | 3.6 | 249.5 | 3.6 | 3.0 | 3.0 | 4.0 |

(`single_insert`, `update` and `delete` are near ties across every ORM here
because they're database-bound — single inserts are paced by MariaDB's
per-commit disk fsync, and `update`/`delete` are one server-side set statement
each — so there's no client-side marshaling for the Rust hot path to speed up.
MariaDB's `single_insert` ~265 ms is markedly faster than MySQL 8's — a lighter
default commit path.)

### SQLite

![Yara ORM vs eight Python ORMs on SQLite — latency per operation, log scale, lower is better](docs/assets/benchmark-sqlite.png)

SQLite is in-process, so these use its recommended `sync_fast_path=1` config
(statements run synchronously on the calling thread — no I/O to overlap):

| operation | yara-orm | tortoise | sqlalchemy | pony | django | peewee | sqlobject | ormar | piccolo |
|---------------|---------:|---------:|-----------:|-----:|-------:|-------:|----------:|------:|--------:|
| bulk_insert | 7.9 | 14.4 | 612.7 | 51.0 | 58.1 | 30.7 | 223.1 | 158.0 | 78.8 |
| single_insert | 32.6 | 29.3 | 240.0 | 128.3 | 139.0 | 114.7 | 139.9 | 323.2 | 259.1 |
| fetch_all | 3.4 | 39.7 | 28.8 | 51.0 | 16.3 | 12.5 | 44.9 | 54.8 | 9.1 |
| count | 0.1 | 0.3 | 0.7 | 0.2 | 0.2 | 0.1 | 0.1 | 1.7 | 0.5 |
| group_by | 0.5 | 0.8 | 1.4 | 1.5 | 0.9 | 0.7 | 0.5 | - | 1.0 |
| filter | 2.0 | 20.5 | 7.7 | 26.2 | 8.5 | 6.7 | 17.3 | 19.6 | 5.1 |
| get_by_pk | 47.4 | 87.5 | 330.9 | 30.7 | 83.6 | 77.7 | 13.3 | 501.8 | 359.5 |
| update | 0.6 | 0.5 | 1.8 | 43.1 | 1.3 | 1.2 | 1.2 | 1.6 | 1.4 |
| delete | 0.4 | 0.4 | 1.2 | 36.3 | 0.9 | 0.7 | 0.8 | 1.3 | 1.2 |

Yara ORM wins everything throughput-shaped (fetch_all 2.7–16×, filter 2.5–13×,
bulk_insert 1.8–77×) and trails only the latency-bound point reads, where the
per-statement asyncio bridge costs tens of µs against in-process sync ORMs
(SQLObject and Pony on `get_by_pk`) — the opt-in `sqlite://...?sync_fast_path=1`
URL flag removes that bridge entirely (point queries ~7× faster).
| bulk_insert | 7.5 | 13.6 | 607.9 | 50.1 | 55.8 | 29.0 | 218.3 | 143.8 | 73.9 |
| single_insert | 15.3 | 26.9 | 234.5 | 107.0 | 120.1 | 113.5 | 124.8 | 296.7 | 240.4 |
| fetch_all | 3.4 | 38.6 | 27.1 | 51.0 | 16.4 | 12.2 | 44.2 | 52.0 | 9.2 |
| count | 0.0 | 0.2 | 0.7 | 0.2 | 0.3 | 0.1 | 0.1 | 1.6 | 0.5 |
| group_by | 0.6 | 0.7 | 1.3 | 1.4 | 0.9 | 0.6 | 0.5 | - | 1.0 |
| filter | 2.0 | 20.2 | 7.3 | 25.8 | 8.7 | 6.6 | 17.4 | 19.2 | 5.0 |
| get_by_pk | 12.5 | 79.4 | 329.6 | 31.3 | 84.6 | 75.5 | 13.3 | 484.1 | 357.2 |
| update | 0.5 | 0.5 | 1.7 | 43.0 | 1.3 | 1.2 | 1.1 | 1.7 | 1.5 |
| delete | 0.3 | 0.4 | 1.1 | 35.9 | 0.8 | 0.7 | 0.7 | 1.1 | 1.1 |

With the fast path Yara ORM is fastest on **every** operation except the
sub-millisecond `group_by` (SQLObject's hand-written raw SQL, 0.8×) — including
the point reads it trailed on under the default async bridge (`get_by_pk` 1.1× vs
SQLObject, 2.5× vs Pony), while staying far ahead on throughput (bulk_insert
1.8–81×, fetch_all 2.7–15×, filter 2.5–13×). On the **default** async path the
per-statement bridge costs tens of µs on sequential point reads, so SQLObject's
lean sync active-record leads `get_by_pk` there; `sqlite://...?sync_fast_path=1`
removes that bridge (~7× faster point queries).

Speed comes from the Rust hot path, **positional row decoding** (no per-row dict
or column-name allocation), **compiled-SQL + prepared-statement caching**, and
Expand Down
79 changes: 43 additions & 36 deletions benchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,50 +190,57 @@ Piccolo has no MySQL backend, so it is absent here:

| operation | yara-orm | tortoise | sqlalchemy | pony | django | peewee | sqlobject | ormar |
|---------------|---------:|---------:|-----------:|-----:|-------:|-------:|----------:|------:|
| bulk_insert | 30.5 | 41.5 | 99.0 | 455.0 | 87.7 | 59.5 | 1257.2 | 191.2 |
| single_insert | 392.8 | 304.8 | 430.8 | 329.6 | 343.6 | 367.3 | 359.8 | 555.3 |
| fetch_all | 5.5 | 34.0 | 42.5 | 47.9 | 28.4 | 30.4 | 44.9 | 72.8 |
| count | 0.5 | 0.8 | 1.2 | 0.7 | 0.7 | 0.8 | 0.7 | 4.7 |
| group_by | 1.3 | 1.3 | 2.1 | 2.2 | 1.5 | 1.1 | 0.9 | - |
| filter | 3.2 | 17.8 | 16.0 | 24.5 | 15.5 | 14.7 | 16.9 | 31.9 |
| get_by_pk | 123.3 | 228.7 | 534.1 | 310.7 | 214.5 | 206.0 | 66.0 | 916.1 |
| update | 3.8 | 3.3 | 6.5 | 265.9 | 4.3 | 4.3 | 4.2 | 7.6 |
| delete | 3.2 | 3.2 | 3.3 | 249.9 | 3.3 | 3.0 | 2.9 | 3.6 |
| bulk_insert | 23.3 | 37.5 | 105.4 | 475.4 | 96.4 | 71.8 | 1266.5 | 209.9 |
| single_insert | 264.9 | 311.6 | 531.7 | 391.5 | 388.2 | 372.2 | 390.3 | 660.0 |
| fetch_all | 5.7 | 35.0 | 43.8 | 48.1 | 28.1 | 37.0 | 42.6 | 72.1 |
| count | 0.5 | 0.8 | 1.2 | 0.7 | 0.7 | 0.8 | 0.6 | 6.7 |
| group_by | 1.2 | 1.3 | 2.3 | 2.2 | 1.5 | 1.4 | 0.9 | - |
| filter | 3.2 | 17.5 | 16.3 | 24.8 | 15.1 | 14.7 | 17.1 | 32.6 |
| get_by_pk | 132.9 | 240.8 | 575.0 | 306.7 | 220.7 | 206.5 | 64.4 | 895.2 |
| update | 4.0 | 4.4 | 7.8 | 266.3 | 5.4 | 4.2 | 3.7 | 8.2 |
| delete | 3.0 | 3.0 | 3.6 | 249.5 | 3.6 | 3.0 | 3.0 | 4.0 |

`yara-orm` leads or ties every operation except SQLObject's leaner `get_by_pk`
(0.5×), the sub-millisecond `group_by` (SQLObject 0.8× via raw SQL), and a
statistical tie with Tortoise on `update`/`delete`. It wins the throughput ops
decisively (`fetch_all` 5.0–12.9×, `filter` 4.4–9.3×, `bulk_insert` up to 46× vs
SQLObject's row-by-row inserts). MariaDB's `single_insert` (~390 ms) is notably
faster than MySQL 8's (~630 ms) here — a lighter default commit path.
(0.5×) and the sub-millisecond `group_by` (SQLObject 0.8× via raw SQL).
`single_insert`, `update` and `delete` are near ties across every ORM because
they are **database-bound**, not client-bound: single inserts are paced by
MariaDB's per-commit disk fsync (high run-to-run variance — it swings ±40%
between runs), and `update`/`delete` are one server-side set statement each, so
there's no client-side row marshaling for the Rust hot path to accelerate. It
still wins the throughput ops decisively (`fetch_all` 4.9–12.6×, `filter`
4.6–10.2×, `bulk_insert` up to 54× vs SQLObject's row-by-row inserts). MariaDB's
`single_insert` (~265 ms) is notably faster than MySQL 8's (~630 ms) here — a
lighter default commit path.

### SQLite results

`BENCH_BACKEND=sqlite`, Python 3.12, N=5000, median of 5 (ms, lower is better):
`BENCH_BACKEND=sqlite`, Python 3.12, N=5000, median of 5 (ms, lower is better).
SQLite is in-process, so `bench.py` uses its recommended `sync_fast_path=1`
config (statements run synchronously on the calling thread — an embedded
database has no I/O to overlap, so the async bridge is pure overhead):

| operation | yara-orm | tortoise | sqlalchemy | pony | django | peewee | sqlobject | ormar | piccolo |
|---------------|---------:|---------:|-----------:|-----:|-------:|-------:|----------:|------:|--------:|
| bulk_insert | 7.9 | 14.4 | 612.7 | 51.0 | 58.1 | 30.7 | 223.1 | 158.0 | 78.8 |
| single_insert | 32.6 | 29.3 | 240.0 | 128.3 | 139.0 | 114.7 | 139.9 | 323.2 | 259.1 |
| fetch_all | 3.4 | 39.7 | 28.8 | 51.0 | 16.3 | 12.5 | 44.9 | 54.8 | 9.1 |
| count | 0.1 | 0.3 | 0.7 | 0.2 | 0.2 | 0.1 | 0.1 | 1.7 | 0.5 |
| group_by | 0.5 | 0.8 | 1.4 | 1.5 | 0.9 | 0.7 | 0.5 | - | 1.0 |
| filter | 2.0 | 20.5 | 7.7 | 26.2 | 8.5 | 6.7 | 17.3 | 19.6 | 5.1 |
| get_by_pk | 47.4 | 87.5 | 330.9 | 30.7 | 83.6 | 77.7 | 13.3 | 501.8 | 359.5 |
| update | 0.6 | 0.5 | 1.8 | 43.1 | 1.3 | 1.2 | 1.2 | 1.6 | 1.4 |
| delete | 0.4 | 0.4 | 1.2 | 36.3 | 0.9 | 0.7 | 0.8 | 1.3 | 1.2 |

`yara-orm` wins the throughput-bound operations decisively (`bulk_insert`
1.8–77×, `fetch_all` 2.6–15×, `filter` 2.5–13× across the field). It trails only
on **latency-bound point reads**: in-process sync ORMs — SQLObject (`get_by_pk`
0.3×) and Pony (0.7×) — beat us there (plus the microsecond `group_by`, where
SQLObject's raw-SQL path is a hair ahead at 0.9×). The cost is the per-statement asyncio
bridge (scheduling on the runtime + waking the event loop), tens of µs a
synchronous in-process driver avoids on sequential point queries. Real workloads
rarely fire thousands of sequential point reads, and everything throughput-shaped
is far ahead. The opt-in
`sqlite://...?sync_fast_path=1` URL flag removes that bridge (point queries ~7×
faster) when those ops dominate.
| bulk_insert | 7.5 | 13.6 | 607.9 | 50.1 | 55.8 | 29.0 | 218.3 | 143.8 | 73.9 |
| single_insert | 15.3 | 26.9 | 234.5 | 107.0 | 120.1 | 113.5 | 124.8 | 296.7 | 240.4 |
| fetch_all | 3.4 | 38.6 | 27.1 | 51.0 | 16.4 | 12.2 | 44.2 | 52.0 | 9.2 |
| count | 0.0 | 0.2 | 0.7 | 0.2 | 0.3 | 0.1 | 0.1 | 1.6 | 0.5 |
| group_by | 0.6 | 0.7 | 1.3 | 1.4 | 0.9 | 0.6 | 0.5 | - | 1.0 |
| filter | 2.0 | 20.2 | 7.3 | 25.8 | 8.7 | 6.6 | 17.4 | 19.2 | 5.0 |
| get_by_pk | 12.5 | 79.4 | 329.6 | 31.3 | 84.6 | 75.5 | 13.3 | 484.1 | 357.2 |
| update | 0.5 | 0.5 | 1.7 | 43.0 | 1.3 | 1.2 | 1.1 | 1.7 | 1.5 |
| delete | 0.3 | 0.4 | 1.1 | 35.9 | 0.8 | 0.7 | 0.7 | 1.1 | 1.1 |

`yara-orm` is fastest on **every** operation except the sub-millisecond
`group_by`, where SQLObject's hand-written raw-SQL aggregate (bypassing its ORM
entirely) edges it at 0.8×. With the fast path it wins the point reads it
trailed on under the default async bridge — `get_by_pk` **1.1× vs SQLObject**
and **2.5× vs Pony** — and stays far ahead on throughput (`bulk_insert` 1.8–81×,
`fetch_all` 2.7–15×, `filter` 2.5–13×, `single_insert` 1.8× vs Tortoise). On the
**default** async path (no fast path), the per-statement asyncio bridge costs
tens of µs on sequential point reads, so SQLObject's lean sync active-record
leads `get_by_pk` there instead; `sqlite://...?sync_fast_path=1` removes that
bridge (~7× faster point queries).

## Why `yara-orm` is fast here

Expand Down
7 changes: 6 additions & 1 deletion benchmarks/bench.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,12 @@ def drop_sql(table: str) -> str:

def ours_url() -> str:
if BACKEND == "sqlite":
return f"sqlite://{SQLITE_DIR}/bench_ours.db"
# SQLite is in-process (no I/O to overlap), so the recommended config
# drives statements synchronously on the calling thread instead of the
# async bridge — the right choice for an embedded DB and how yara-orm is
# deployed on SQLite. The competitors' aiosqlite pays the async cost for
# no benefit here.
return f"sqlite://{SQLITE_DIR}/bench_ours.db?sync_fast_path=1"
if BACKEND in ("mysql", "mariadb"):
return mysql_family_url()
if BACKEND == "oracle":
Expand Down
36 changes: 18 additions & 18 deletions benchmarks/plot_benchmarks.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,28 +75,28 @@
"mariadb": (
"Yara ORM vs 7 Python ORMs — MariaDB 11, 5000 rows, median of 5",
{
"yara-orm": [30.5, 392.8, 5.5, 0.5, 1.3, 3.2, 123.3, 3.8, 3.2],
"tortoise": [41.5, 304.8, 34.0, 0.8, 1.3, 17.8, 228.7, 3.3, 3.2],
"sqlalchemy": [99.0, 430.8, 42.5, 1.2, 2.1, 16.0, 534.1, 6.5, 3.3],
"pony": [455.0, 329.6, 47.9, 0.7, 2.2, 24.5, 310.7, 265.9, 249.9],
"django": [87.7, 343.6, 28.4, 0.7, 1.5, 15.5, 214.5, 4.3, 3.3],
"peewee": [59.5, 367.3, 30.4, 0.8, 1.1, 14.7, 206.0, 4.3, 3.0],
"sqlobject": [1257.2, 359.8, 44.9, 0.7, 0.9, 16.9, 66.0, 4.2, 2.9],
"ormar": [191.2, 555.3, 72.8, 4.7, NA, 31.9, 916.1, 7.6, 3.6],
"yara-orm": [23.3, 264.9, 5.7, 0.5, 1.2, 3.2, 132.9, 4.0, 3.0],
"tortoise": [37.5, 311.6, 35.0, 0.8, 1.3, 17.5, 240.8, 4.4, 3.0],
"sqlalchemy": [105.4, 531.7, 43.8, 1.2, 2.3, 16.3, 575.0, 7.8, 3.6],
"pony": [475.4, 391.5, 48.1, 0.7, 2.2, 24.8, 306.7, 266.3, 249.5],
"django": [96.4, 388.2, 28.1, 0.7, 1.5, 15.1, 220.7, 5.4, 3.6],
"peewee": [71.8, 372.2, 37.0, 0.8, 1.4, 14.7, 206.5, 4.2, 3.0],
"sqlobject": [1266.5, 390.3, 42.6, 0.6, 0.9, 17.1, 64.4, 3.7, 3.0],
"ormar": [209.9, 660.0, 72.1, 6.7, NA, 32.6, 895.2, 8.2, 4.0],
},
),
"sqlite": (
"Yara ORM vs 8 Python ORMs — SQLite, 5000 rows, median of 5",
"Yara ORM vs 8 Python ORMs — SQLite (sync_fast_path), 5000 rows, median of 5",
{
"yara-orm": [7.9, 32.6, 3.4, 0.1, 0.5, 2.0, 47.4, 0.6, 0.4],
"tortoise": [14.4, 29.3, 39.7, 0.3, 0.8, 20.5, 87.5, 0.5, 0.4],
"sqlalchemy": [612.7, 240.0, 28.8, 0.7, 1.4, 7.7, 330.9, 1.8, 1.2],
"pony": [51.0, 128.3, 51.0, 0.2, 1.5, 26.2, 30.7, 43.1, 36.3],
"django": [58.1, 139.0, 16.3, 0.2, 0.9, 8.5, 83.6, 1.3, 0.9],
"peewee": [30.7, 114.7, 12.5, 0.1, 0.7, 6.7, 77.7, 1.2, 0.7],
"sqlobject": [223.1, 139.9, 44.9, 0.1, 0.5, 17.3, 13.3, 1.2, 0.8],
"ormar": [158.0, 323.2, 54.8, 1.7, NA, 19.6, 501.8, 1.6, 1.3],
"piccolo": [78.8, 259.1, 9.1, 0.5, 1.0, 5.1, 359.5, 1.4, 1.2],
"yara-orm": [7.5, 15.3, 3.4, 0.04, 0.6, 2.0, 12.5, 0.5, 0.3],
"tortoise": [13.6, 26.9, 38.6, 0.2, 0.7, 20.2, 79.4, 0.5, 0.4],
"sqlalchemy": [607.9, 234.5, 27.1, 0.7, 1.3, 7.3, 329.6, 1.7, 1.1],
"pony": [50.1, 107.0, 51.0, 0.2, 1.4, 25.8, 31.3, 43.0, 35.9],
"django": [55.8, 120.1, 16.4, 0.3, 0.9, 8.7, 84.6, 1.3, 0.8],
"peewee": [29.0, 113.5, 12.2, 0.1, 0.6, 6.6, 75.5, 1.2, 0.7],
"sqlobject": [218.3, 124.8, 44.2, 0.1, 0.5, 17.4, 13.3, 1.1, 0.7],
"ormar": [143.8, 296.7, 52.0, 1.6, NA, 19.2, 484.1, 1.7, 1.1],
"piccolo": [73.9, 240.4, 9.2, 0.5, 1.0, 5.0, 357.2, 1.5, 1.1],
},
),
}
Expand Down
Binary file modified docs/assets/benchmark-mariadb.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/benchmark-sqlite.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading