Cayenne 5.0-M3 against 5.0-SNAPSHOT · Strimillinn's model and data · 15 October 2026
Cayenne 5 under load
Twelve query workloads on Strimillinn's model and its database. Each was run at 1, 4 and 16 threads on both Cayenne versions, with no rendering involved. The concurrency work in SNAPSHOT is real and large, but pays off where Strimillinn rarely goes: a single context shared between threads, so most of the performance we gained came from a plainer reason: SNAPSHOT builds objects from rows with half the garbage.
M3 5.0-M3 release
SNAPSHOT 5.0-SNAPSHOT at 78785e4e4
The dictionary page
The heavy dictionary page query (pageJoint) is 1.13× faster at one thread and 1.14× at four. The gain is flat across thread counts and matches the 11% measured on the real page last night, which is the signature of cheaper per-thread work, not of less contention. Allocation per query falls from 295 MB to 137 MB. The page's parallel part (twins and trigram-similar texts on virtual threads, pageFanOut) is no faster at any thread count, because Postgres is the cost there.
Concurrency really starts to show once the database is taken out. Served from the query cache into fresh contexts, the same query goes from 2.0× faster at one thread to 2.9× at sixteen: M3 slows from 68 to 197 ms under load, SNAPSHOT only from 34 to 67 ms. With one context shared by every thread, SNAPSHOT is 8–17× faster at 4 and 16 threads. Strimillinn neither uses the query cache nor shares contexts between threads (every StrimillinnCore.newContext() is fresh), so those gains are available but not yet collected.
Shared context
17.5×
Faults in a shared context at 16 threads
385 µs on M3, 22 µs on SNAPSHOT. M3 serialises threads on the whole context; SNAPSHOT's per-object locks (CAY-3048) let them pass each other. From the cache it's 11.6×, and 7.9× already at 4 threads.
Building objects
2.0–2.9×
Cached rows to objects
The page query from the query cache: 68 → 34 ms at one thread, 197 → 67 ms at sixteen, with 79% less allocation. This is the part of every fetch that SNAPSHOT rewrote, and it scales better too.
Under load
−62%
Garbage collection on the page query
At 16 threads the page query's GC time drops from 14.4 s to 5.4 s over the run (disjoint prefetches: 10.3 s → 2.5 s). Wall time there is flat because 16 threads plus Postgres saturate 14 cores, but a busy server would feel the lighter heap.
Speed
How much faster, by thread count
Each workload at 1, 4 and 16 threads. Right of the line is faster on SNAPSHOT; the scale is logarithmic, so 2× and ½× are the same distance from 1×. Whiskers run from the slowest to the fastest ratio the two 99.9% confidence intervals allow; a whisker crossing 1× means no measurable difference.
1 thread
4 threads
16 threads
Scaling
Throughput as threads are added
Operations per second across all threads. A line that keeps rising scales; a flat line means the threads are waiting on each other or on the database.
Memory
Allocation per operation
Bytes allocated per operation at one thread, measured by JMH's GC profiler. Less allocation means less garbage collection, which is where much of the time goes under load.
Retained heap, measured three times per version: data rows are less than half the size (1,101 → 525 bytes per row, CAY-3050). Objects and their snapshots cost the same on both versions, about 470 bytes per object retained and 603 including the snapshot cache. One M3 object run read 588 B/object twice; SNAPSHOT read 471 every time, so treat that spread as heap measurement noise rather than a difference.
Reading it
What the results say
- The floor is JDBC. Reading the page's 17,367 rows as data rows allocates 27% less on SNAPSHOT but takes the same 55–58 ms, so what the upgrade saves sits above that floor, in turning rows into objects. A four-column query (
pageColumns) is unchanged.
- Disjoint-by-id prefetches gain more than joint ones at one thread (1.25× against 1.13×; allocation 154 → 84 MB), which suits
DictionaryEntryInfoProvider.
- At 16 threads the page queries are a wash (1.04× and 0.95×, both well inside their error bars). The machine is saturated, and the variance on M3's joint query is ±20%.
- Method. JMH 1.37 with 2 forks × 5 iterations of 4 s after 4 warmups, average time per operation, GC profiler. Versions alternated per workload. The 4 GB heap was fixed and HikariCP had 48 connections. The local Postgres 17 copy of the Strimillinn database was used, the Eclipse app was stopped during the run, and there were 72 runs with 0 failures (03:51–05:28). Same generated model classes and datamap on both versions.
All results
Every workload, every thread count
| Workload | Threads | M3, µs/op | SNAPSHOT, µs/op | Speedup | M3 alloc | SNAPSHOT alloc |
Average time per operation as each thread sees it, ± the 99.9% confidence interval. A speedup counts only when the confidence intervals don't overlap; otherwise it's shown in grey.
The workloads
What each one does
Method
How it was measured
- Project
~/git/cayenne-bench: JMH benchmarks on Strimillinn's datamap, with the generated model classes of each version (a441cba00 for M3, befbb0bb9 for SNAPSHOT). No WebObjects, no templates. Build with -Pm3 or -Psnapshot, run with ./run.sh full.
- Database
- The local copy of Strimillinn's database: 2.1 million entries, 280,000 receipts. PostgreSQL 17 on the same machine. Every workload is read-only.
- Runtime
- As Strimillinn sets it up: same project file, 100,000-row snapshot cache, Hikari pool, minus the commit listeners. 4 GB heap, G1.
- JMH
- Order
- For each workload and thread count, M3 then SNAPSHOT, so slow drift over the night affects both.
- Checks
- Each trial first checks that the page query returns 17,367 rows as data rows and as objects in that version, and that the id pools are complete. A wrong answer stops the trial.
- Machine