Benchmarks
Measured, published, reproducible — including the numbers that flatter us and the ones that don't.
The suite lives in benchmarks/eQuantic.Core.Data.Benchmarks
and runs with one command:
dotnet run -c Release --project benchmarks/eQuantic.Core.Data.Benchmarks -- --filter "*Compare*" --job short
Methodology
Four stacks, one PostgreSQL 17 (Testcontainers), one table (bench_products, 10 000 seeded rows,
indexed category), each stack set up the way its own documentation recommends:
- raw Npgsql — the baseline: hand-written SQL over a shared
NpgsqlDataSource, manual materialization. The floor any abstraction pays against. - Dapper — the same SQL through
Query*/Execute*over the same data source. - EF Core (Npgsql provider) — pooled
DbContextFactory,AsNoTrackingreads,ExecuteUpdateAsyncfor the set-based update. - eQuantic.Core.Data — the PostgreSQL provider through DI, a fresh scope per operation (the way a request runs), repositories resolved per call.
BenchmarkDotNet ShortRun (1 launch, 3 warmup, 3 iterations) with MemoryDiagnoser. Environment:
Apple M4 Pro (arm64), .NET 10.0.9, macOS, PostgreSQL 17 in Docker on the same machine. Short runs
trade tight confidence intervals for practical runtime; treat single-digit-percent deltas as noise
and the shape of the table as the signal. The eQuantic rows run through the package's
source-generated entity accessors (reflection-free materialization — see
the architecture guide);
measured effect vs the reflection path is within noise, which is itself the honest finding: the
accessors exist for trimming/AOT readiness, not for these tables.
Reads
| Scenario | raw Npgsql | Dapper | EF Core | eQuantic |
|---|---|---|---|---|
| Point read by key | 190.1 µs / 2.7 KB | 1.03× / 3.4 KB | 1.25× / 8.7 KB | 0.92× / 8.9 KB |
| Filtered 500 rows (entities) | 454.0 µs / 97 KB | 1.05× / 152 KB | 1.13× / 198 KB | 1.04× / 175 KB |
| Projection 500 rows (3 columns) | 438.2 µs / 62 KB | 0.90× / 93 KB | 1.14× / 166 KB | 0.92× / 130 KB |
| Offset page (count + 20 rows) | 572.6 µs / 8.4 KB | 1.07× / 10.8 KB | 1.07× / 29.4 KB | 0.94× / 22.3 KB |
Reading it honestly:
- Every read runs at raw-driver speed — statistically indistinguishable from hand-written Npgsql (the sub-1.0 ratios are ShortRun noise, not magic), ahead of EF Core in all four scenarios, tied with Dapper — while going through DI scoping, the repository contract, expression interpretation and the pushdown pipeline. Translation costs single-digit microseconds (see the translation table); the database dominates.
- Projection was our one slow read, and the first run of this suite caught it. The original
GetMappedAsyncmaterialized entity shells and then applied the map — 1.29× and the worst line of the first published table. Two engine changes later it ties the floor: the common map shapes (constructor projections, member inits, single members) compile into reader-direct projectors — cached constructor invocation, no per-query expression compilation — and the projectors themselves are cached by the map's structure (the structuralExpressionEqualityComparerfrom eQuantic.Linq.Expressions), since C# rebuilds the expression tree on every call even for a lexically identical lambda. Reader-direct maps are closure-free by construction, so a cached projector can never smuggle a stale captured value; shapes the projector cannot prove fall back to the entity path, with identical results.
Writes
| Scenario | raw Npgsql | Dapper | EF Core | eQuantic |
|---|---|---|---|---|
| Insert 1 row + commit | 165.1 µs / 3.7 KB | 1.02× / 4.8 KB | 1.05× / 14.6 KB | 1.00× / 8.3 KB |
| Insert 100 rows, one commit | 1 496 µs / 175 KB | 9.86× (per-row) / 251 KB | 1.68× / 878 KB | 0.90× / 456 KB |
| Set-based update (500 rows) | 627.1 µs / 2.6 KB | 1.00× / 3.1 KB | 1.10× / 10.1 KB | 1.13× / 10.4 KB |
Bulk loading 1 000 rows gets its own table, because the comparison is between mechanisms, not
stacks — the baseline is a hand-written 1 000-statement DbBatch:
| Scenario | raw Npgsql (batch) | EF Core | eQuantic (staged commit) | eQuantic (BulkInsertAsync) |
|---|---|---|---|---|
| Load 1 000 rows | 13.18 ms / 1 725 KB | 3.06× / 9 168 KB | 0.95× / 4 510 KB | 0.49× / 491 KB |
Reading it honestly:
- A single insert now runs at raw speed (1.00×) — the fastest of the four stacks in this run.
The first published table had it at 1.84×: the flush wrapped even a one-statement commit in an
explicit transaction, paying
BEGIN/COMMITround trips a single atomic statement never needed. The engine now skips the local transaction for one-statement flushes — the same optimization EF Core applies, with identical all-or-nothing semantics. - The batch flush is the engine's home ground. 100 staged inserts commit in one
DbBatchat hand-written-batch speed (0.90×, within short-run noise of 1.0) — 1.9× faster than EF Core and 11× faster than Dapper's idiomatic per-rowExecuteAsync(sql, list)(that asymmetry is Dapper's usage pattern, not a rigged comparison — batching by hand in Dapper means writing theDbBatchyourself, which is the baseline column). BulkInsertAsyncis worth the extra API. BinaryCOPYloads 1 000 rows in half the time of a hand-written 1 000-statement batch (0.49×) and 6.2× faster than EF Core, at 0.28× the baseline's allocations — it streams rows instead of building a command object per row. That gap is why the method exists and why a dialect without a native path refuses rather than faking it: a "bulk" API that is secretly row-by-row would report the same call and deliver none of this.- Set-based updates are server-dominated. Across runs this scenario oscillates between 0.95×
and 1.13× (ShortRun jitter on a ~0.6 ms server-bound statement); the typed
UpdateManyAsynctranslation itself costs single-digit microseconds. - Allocations: the engine allocates 2–3× raw Npgsql (scope + options + interpretation) — consistently less than EF Core (8.3 vs 14.6 KB single insert; 456 vs 878 KB batch; 129 vs 165 KB projection), more than Dapper everywhere. Driving this toward Dapper's numbers is a declared goal of the planned source-generator work.
These two engine optimizations exist because this suite ran: the first published table named projection (1.29×) and single-insert (1.84×) as the weak spots, both were fixed at the engine level, and the numbers above are the re-measurement. That loop — measure, publish the losses, fix, re-measure — is the point of keeping benchmarks in the repository.
Translation microbenchmarks
The recurring client-side cost the engine adds is translation (predicate → IR → provider plan) —
measured separately, no I/O (TranslationBenchmarks):
| Benchmark | Mean | Allocated |
|---|---|---|
| Interpret — simple equality | ~0.4 µs | 1.7 KB |
| Interpret — composite (3 clauses) | ~2.9 µs | 8.9 KB |
| Cassandra plan — fully pushed down | ~3.3 µs | 10.4 KB |
| Cassandra plan — with residual rebuild | ~4.3 µs | 7.9 KB |
| Cassandra plan — OR-split (2 branches) | ~7.6 µs | 13.9 KB |
| Update — set only | ~0.4 µs | 1.8 KB |
The most expensive translation the engine performs — an OR-split plan — costs single-digit microseconds: noise against any network round trip, which is why the end-to-end tables above sit at driver speed.
Reproducing
git clone https://github.com/eQuantic/core-data && cd core-data
dotnet run -c Release --project benchmarks/eQuantic.Core.Data.Benchmarks -- --filter "*Compare*" --job short
Docker required (the run starts and disposes its own PostgreSQL container). Results land in
BenchmarkDotNet.Artifacts/results/. Numbers will differ on your hardware; the ratios are the
portable part.