Skip to content

Commit e86f468

Browse files
joaoh82claude
andauthored
feat(bench): Group B workloads — W7..W9 (SQLR-16, sub-phase 9.3) (#104)
Lands the SQL-feature-scaling workloads on top of the 9.2 Group A baseline: W7 aggregate SELECT SUM(v) FROM big over 1M rows. Single-statement full-scan + accumulator. W8 group-by SELECT k, COUNT(*) FROM big GROUP BY k at three cardinalities (10 / 1k / 100k distinct groups). Each cardinality is its own criterion group. W9 inner-join customers ⨝ orders ON c.id = o.customer_id, per-PK-probe shape, single matching order per customer. Two notable plan deviations land alongside the workloads, both captured in code + README: W8 / 100k-cardinality on SQLRite — skipped by default. A first measurement clocked ~245 s per criterion iteration (~41 min for 10 samples), strongly suggesting an O(n × cardinality) GROUP BY path. Setting `SQLRITE_BENCH_W8_CARD_100K_SQLRITE=1` re-enables the bucket. SQLR-19 tracks the investigation. SQLite runs the same bucket fine. W9 dataset scaled 100k rows → 10k rows. Plan target was two 100k-row tables, but a first smoke ran 88 minutes on SQLRite without producing a single sample. SQLRite's join executor (`src/sql/executor.rs::execute_select_rows_joined`) is a left-folded nested-loop driver with no inner-side index probe, so per-PK probe walks every row in `orders`. SQLR-20 tracks the planner / index-pushdown fix; bumping back to 100k follows that + a `W9.v2` tag. Smoke run (M1 Pro, criterion --warm-up-time 1 --measurement-time 1 --sample-size 10): W7 sqlrite ~111 ms / sqlite ~31 ms — ~3.5x W8 card-10 sqlrite ~204 ms / sqlite ~460 ms — sqlrite wins (small samples; SQLite picks sort-then-group) W8 card-1k sqlrite ~1.50 s / sqlite ~242 ms — ~6.2x W8 card-100k sqlrite SKIPPED / sqlite ~241 ms — ⚠ SQLR-19 W9 (10k) sqlrite ~32 s / sqlite ~2.3 us — ~14_000_000x ⚠ SQLR-20 W7's ~3.5x is the *closest* SQLRite has come to SQLite on any workload — the per-row "touch" cost in the executor is tighter than the per-iter parse cost dominates W1 / W6. Encouraging signal for the executor itself. Verification: - cargo fmt --all -- --check clean - cargo clippy -p sqlrite-benchmarks --all-targets clean - Full CI-equivalent build / test / doc green (--exclude desktop / python / nodejs / benchmarks) - Smoke run end-to-end across W7..W9 + aggregator → JSON envelope Refs: SQLR-16 (parent), SQLR-19 (W8 high-cardinality), SQLR-20 (W9 join planner). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1f3e056 commit e86f468

9 files changed

Lines changed: 604 additions & 7 deletions

File tree

benchmarks/README.md

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Benchmark suite for SQLRite vs SQLite (and friends). Tracks task **SQLR-4** / **SQLR-16**.
44

5-
> **Status (2026-05-06):** sub-phases 9.1 + 9.2 landed — harness + Group A (W1–W6). W7–W12 land in 9.3–9.4. The first official pinned-host JSON gets committed in 9.6. See [`docs/benchmarks-plan.md`](../docs/benchmarks-plan.md) for the canonical design + the resolved Q1–Q8 decisions.
5+
> **Status (2026-05-07):** sub-phases 9.1 + 9.2 + 9.3 landed — harness + Group A (W1–W6) + Group B (W7–W9). W10–W12 land in 9.4. The first official pinned-host JSON gets committed in 9.6. See [`docs/benchmarks-plan.md`](../docs/benchmarks-plan.md) for the canonical design + the resolved Q1–Q8 decisions.
66
77
## Quick start
88

@@ -27,7 +27,7 @@ Three drivers, in plan order:
2727
Three groups (full descriptions in [`docs/benchmarks-plan.md`](../docs/benchmarks-plan.md#workloads)):
2828

2929
- **Group A — OLTP baseline.** W1 read-by-PK, W2 range scan, W3 bulk insert, W4 single-row insert, W5 mixed OLTP, W6 index lookup. ✅ shipped (9.1 + 9.2).
30-
- **Group B — SQL-feature scaling.** W7 aggregate, W8 GROUP BY, W9 INNER JOIN. Lands in 9.3.
30+
- **Group B — SQL-feature scaling.** W7 aggregate, W8 GROUP BY, W9 INNER JOIN. ✅ shipped (9.3).
3131
- **Group C — Differentiators.** W10 vector top-10, W11 BM25 top-10, W12 hybrid retrieval. Lands in 9.4.
3232

3333
Workloads are versioned (`W1.v1`, `W1.v2`, …) per Q8 — bumping the version is the explicit "I changed the benchmark" gesture.
@@ -124,6 +124,46 @@ Unique-index probes — every probe matches exactly one row. SQLRite's optimizer
124124

125125
**Read this as:** secondary-index path is healthy — same ~5–8× per-iter-parse-cost band as W1. The index probe itself is fine; what we're measuring is mostly `sqlparser` per call.
126126

127+
### W7 — `SELECT SUM(v) FROM big` (1M-row full-scan aggregate)
128+
129+
Single-statement aggregate. No WHERE, no GROUP BY. Both engines walk every row through their executor; the question is how cheap the per-row "touch" is.
130+
131+
| Engine | Median per SUM | Throughput (rows/s) |
132+
|---|---|---|
133+
| SQLRite | ~111 ms | ~9.0M |
134+
| SQLite (rusqlite, WAL+NORMAL) | ~31 ms | ~32M |
135+
| **Ratio** | **~3.5×** | |
136+
137+
**Read this as:** healthy. The full-scan aggregator is the closest "engine throughput" measurement we have — no parse cache miss to amortize, just per-row work. Closer to SQLite than W1 (~8×) suggests the per-row machinery is tighter than the per-iter parse path. Encouraging; says the executor isn't broadly slow.
138+
139+
### W8 — `SELECT k, COUNT(*) FROM big GROUP BY k` (three cardinalities)
140+
141+
1M-row table, GROUP BY at 10 / 1k / 100k distinct keys.
142+
143+
| Cardinality | SQLRite | SQLite (rusqlite, WAL+NORMAL) | Notes |
144+
|---|---|---|---|
145+
| 10 groups | ~204 ms | ~460 ms | SQLRite faster — likely SQLite's planner picks a sort-then-group path here (small group counts often slower than a hash). Not the headline; small samples. |
146+
| 1,000 groups | ~1.50 s | ~242 ms | ~6.2× |
147+
| 100,000 groups | **skipped** | ~241 ms | SQLRite skipped by default — see note below |
148+
149+
**SQLRite × 100k-cardinality is skipped by default** in `make bench`. A first measurement clocked ~245 s/iter (~41 min for 10 samples), strongly suggesting an O(n × cardinality) path inside the GROUP BY executor (a hash aggregator should be O(n + groups)). **SQLR-19** tracks the investigation. Set `SQLRITE_BENCH_W8_CARD_100K_SQLRITE=1` once that lands to re-include the bucket.
150+
151+
**Read this as:** GROUP BY at low cardinality is fine; high-cardinality work is broken. The fix is probably a `Vec`-backed group store → `HashMap`-backed.
152+
153+
### W9 — INNER JOIN, customer ↔ order, probe by customer PK
154+
155+
Plan target was two **100k-row tables**. v1 ships at **10k rows** because SQLRite's join executor doesn't push the ON predicate down to an index probe on the inner side — at the 100k scale, per-iter cost was >5 minutes (88 minutes of measured runtime didn't produce a single sample before the smoke run was killed). **SQLR-20** tracks the join-planner / inner-side index-probe fix. Until then, v1 = 10k rows; bumping to 100k follows the fix + a `W9.v2` tag.
156+
157+
Even at 10k scale, the gap is large:
158+
159+
| Engine | Median per probe | Throughput (probes/s) |
160+
|---|---|---|
161+
| SQLRite | ~32 s | ~0.03 |
162+
| SQLite (rusqlite, WAL+NORMAL) | ~2.3 µs | ~459k |
163+
| **Ratio** | **~14,000,000×** ⚠️ | |
164+
165+
**Read this as:** SQLRite's join executor scans the entire inner table per outer row (no index probe pushdown), and the per-pair overhead is much higher than a single-table full scan. At 32 s for ~10k inner-row checks, the per-row cost is ~3 ms — about **3,000× slower** than W2's full-scan rate (1 µs/row). The inner-side index probe is the obvious next unlock; the per-pair overhead is the second.
166+
127167
## Adding a workload
128168

129169
Per the [`docs/benchmarks-plan.md`](../docs/benchmarks-plan.md#sub-phases) sequencing:
@@ -165,7 +205,10 @@ benchmarks/
165205
│ │ ├── bulk_insert.rs — W3 bulk insert (100k/txn)
166206
│ │ ├── single_insert.rs — W4 single-row insert
167207
│ │ ├── mixed_oltp.rs — W5 50/50 SELECT+UPDATE
168-
│ │ └── index_lookup.rs — W6 secondary-index lookup
208+
│ │ ├── index_lookup.rs — W6 secondary-index lookup
209+
│ │ ├── aggregate.rs — W7 SUM(v) over 1M rows
210+
│ │ ├── group_by.rs — W8 GROUP BY (10/1k/100k cardinalities)
211+
│ │ └── join.rs — W9 INNER JOIN, customer ↔ order
169212
│ └── bin/
170213
│ └── aggregate.rs — walks target/criterion/ → results/*.json
171214
├── benches/

benchmarks/benches/suite.rs

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ use sqlrite_benchmarks::Driver;
2929
use sqlrite_benchmarks::drivers::sqlite::SQLiteDriver;
3030
use sqlrite_benchmarks::drivers::sqlrite::SQLRiteDriver;
3131
use sqlrite_benchmarks::workloads::{
32-
bulk_insert as w3, index_lookup as w6, kv as w1, mixed_oltp as w5, range_scan as w2,
33-
single_insert as w4,
32+
aggregate as w7, bulk_insert as w3, group_by as w8, index_lookup as w6, join as w9, kv as w1,
33+
mixed_oltp as w5, range_scan as w2, single_insert as w4,
3434
};
3535

3636
/// Pick a DB filename inside `dir` based on the driver — keeps a
@@ -276,6 +276,105 @@ fn register_w6<D: Driver>(c: &mut Criterion, driver: D) {
276276
drop(tmp);
277277
}
278278

279+
// ---------------------------------------------------------------------------
280+
// W7 — SUM aggregate over 1M rows
281+
// ---------------------------------------------------------------------------
282+
283+
fn register_w7<D: Driver>(c: &mut Criterion, driver: D) {
284+
let tmp = tempdir_for("w7", driver.name());
285+
let path = db_path(tmp.path(), driver.name(), "w7");
286+
let (mut conn, dataset) = w7::setup(&driver, &path).expect("W7 setup");
287+
w7::correctness_check(&driver, &mut conn, &dataset).expect("W7 correctness check");
288+
289+
let mut group = c.benchmark_group(w7::W7.full());
290+
// SUM over 1M rows is single-shot heavy work — drop sample size
291+
// so a smoke run finishes in a couple of minutes per driver.
292+
group.sample_size(10);
293+
let bench_id = format!("{}/default", driver.name());
294+
group.bench_function(&bench_id, |b| {
295+
b.iter(|| {
296+
let n = w7::bench_iter(&driver, &mut conn).expect("W7 SUM");
297+
black_box(n)
298+
});
299+
});
300+
group.finish();
301+
drop(conn);
302+
drop(tmp);
303+
}
304+
305+
// ---------------------------------------------------------------------------
306+
// W8 — GROUP BY at three cardinalities (10 / 1k / 100k groups)
307+
// ---------------------------------------------------------------------------
308+
309+
fn register_w8<D: Driver>(c: &mut Criterion, driver: D) {
310+
let tmp = tempdir_for("w8", driver.name());
311+
let path = db_path(tmp.path(), driver.name(), "w8");
312+
let (mut conn, _dataset) = w8::setup(&driver, &path).expect("W8 setup");
313+
w8::correctness_check(&driver, &mut conn).expect("W8 correctness check");
314+
315+
for &(label, bucket, _expected) in &w8::BUCKETS {
316+
// SQLRite's GROUP BY at 100k-cardinality over 1M rows is
317+
// pathologically slow today (~245 s per iter on an M-series
318+
// MBP — see SQLR-19 for the investigation follow-up). That
319+
// would block a smoke run for ~40 min, so we skip the cell
320+
// by default. Set `SQLRITE_BENCH_W8_CARD_100K_SQLRITE=1` to
321+
// force-include it once the blowup is resolved.
322+
if label == "card-100k"
323+
&& driver.name() == "sqlrite"
324+
&& std::env::var("SQLRITE_BENCH_W8_CARD_100K_SQLRITE")
325+
.ok()
326+
.as_deref()
327+
!= Some("1")
328+
{
329+
continue;
330+
}
331+
let group_name = format!("{}/{}", w8::W8.full(), label);
332+
let mut group = c.benchmark_group(&group_name);
333+
group.sample_size(10);
334+
let bench_id = format!("{}/default", driver.name());
335+
group.bench_function(&bench_id, |b| {
336+
b.iter(|| {
337+
let n = w8::bench_iter(&driver, &mut conn, bucket).expect("W8 GROUP BY");
338+
black_box(n)
339+
});
340+
});
341+
group.finish();
342+
}
343+
drop(conn);
344+
drop(tmp);
345+
}
346+
347+
// ---------------------------------------------------------------------------
348+
// W9 — INNER JOIN, customer ↔ order, probe by customer PK
349+
// ---------------------------------------------------------------------------
350+
351+
fn register_w9<D: Driver>(c: &mut Criterion, driver: D) {
352+
let tmp = tempdir_for("w9", driver.name());
353+
let path = db_path(tmp.path(), driver.name(), "w9");
354+
let (mut conn, dataset) = w9::setup(&driver, &path).expect("W9 setup");
355+
w9::correctness_check(&driver, &mut conn, &dataset).expect("W9 correctness check");
356+
357+
let mut group = c.benchmark_group(w9::W9.full());
358+
// SQLRite's nested-loop join + un-indexed inner scan is heavy
359+
// (~100k rows scanned per probe). Cap the sample size so a
360+
// smoke run finishes; override at the CLI for a sharper estimate.
361+
group.sample_size(10);
362+
let bench_id = format!("{}/default", driver.name());
363+
let probes = dataset.probes.clone();
364+
let mut idx = 0usize;
365+
group.bench_function(&bench_id, |b| {
366+
b.iter(|| {
367+
let cid = probes[idx % probes.len()];
368+
idx = idx.wrapping_add(1);
369+
let row = w9::bench_iter(&driver, &mut conn, cid).expect("W9 JOIN");
370+
black_box(row)
371+
});
372+
});
373+
group.finish();
374+
drop(conn);
375+
drop(tmp);
376+
}
377+
279378
// ---------------------------------------------------------------------------
280379
// Entry point
281380
// ---------------------------------------------------------------------------
@@ -293,6 +392,12 @@ fn benches(c: &mut Criterion) {
293392
register_w5(c, SQLiteDriver);
294393
register_w6(c, SQLRiteDriver);
295394
register_w6(c, SQLiteDriver);
395+
register_w7(c, SQLRiteDriver);
396+
register_w7(c, SQLiteDriver);
397+
register_w8(c, SQLRiteDriver);
398+
register_w8(c, SQLiteDriver);
399+
register_w9(c, SQLRiteDriver);
400+
register_w9(c, SQLiteDriver);
296401
}
297402

298403
criterion_group!(suite, benches);

benchmarks/src/data.rs

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,154 @@ fn build_range_probes(rng: &mut ChaCha8Rng, width: i64) -> Vec<(i64, i64)> {
194194
out
195195
}
196196

197+
/// Dataset for the Group-B "SQL-feature scaling" workloads — W7
198+
/// (SUM aggregate) and W8 (GROUP BY at three cardinalities).
199+
///
200+
/// Schema:
201+
///
202+
/// ```sql
203+
/// CREATE TABLE big (
204+
/// id INTEGER PRIMARY KEY,
205+
/// v INTEGER,
206+
/// k_10 INTEGER,
207+
/// k_1k INTEGER,
208+
/// k_100k INTEGER
209+
/// );
210+
/// ```
211+
///
212+
/// - `v = (id * 7) mod 1000` — varied non-monotone integer for SUM.
213+
/// - `k_10 = id mod 10` — 10 distinct groups (W8 low-cardinality).
214+
/// - `k_1k = id mod 1000` — 1k distinct groups.
215+
/// - `k_100k = id mod 100_000` — 100k distinct groups (essentially
216+
/// one group per ~10 rows on a 1M-row table; the high-cardinality
217+
/// stress-test for the hash aggregator).
218+
///
219+
/// 1M rows is the plan's W7 target — the largest single-table dataset
220+
/// in the suite. 1M × ~40 bytes/row ≈ 40 MB on disk; well within
221+
/// SQLRite's whole-DB-in-RAM model on a 32 GiB host.
222+
pub struct GroupBDataset {
223+
pub rows: Vec<GroupBRow>,
224+
/// Pre-computed `SUM(v)` so the W7 correctness gate doesn't have
225+
/// to re-derive it on every probe.
226+
pub sum_v: i64,
227+
}
228+
229+
pub struct GroupBRow {
230+
pub id: i64,
231+
pub v: i64,
232+
pub k_10: i64,
233+
pub k_1k: i64,
234+
pub k_100k: i64,
235+
}
236+
237+
/// 1M rows. Plan target for W7. W8 reuses the same dataset.
238+
pub const GROUP_B_ROW_COUNT: usize = 1_000_000;
239+
240+
const GROUP_B_SEED: u64 = 44;
241+
242+
pub fn group_b_dataset() -> GroupBDataset {
243+
// Seed kept for forward-compat — the dataset is currently fully
244+
// deterministic from `id`, but a future variant may shuffle `v`
245+
// independently and we want the seed surface ready.
246+
let _rng = ChaCha8Rng::seed_from_u64(GROUP_B_SEED);
247+
248+
let mut rows = Vec::with_capacity(GROUP_B_ROW_COUNT);
249+
let mut sum_v: i64 = 0;
250+
for i in 1..=GROUP_B_ROW_COUNT as i64 {
251+
let v = (i.wrapping_mul(7)).rem_euclid(1_000);
252+
sum_v += v;
253+
rows.push(GroupBRow {
254+
id: i,
255+
v,
256+
k_10: i.rem_euclid(10),
257+
k_1k: i.rem_euclid(1_000),
258+
k_100k: i.rem_euclid(100_000),
259+
});
260+
}
261+
GroupBDataset { rows, sum_v }
262+
}
263+
264+
/// W9 INNER JOIN dataset. Two 100k-row tables with a 1:1 PK/FK
265+
/// relationship — every customer has exactly one order. The hot loop
266+
/// probes by customer PK and joins to the matching order.
267+
///
268+
/// Schema:
269+
///
270+
/// ```sql
271+
/// CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT);
272+
/// CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER, amount INTEGER);
273+
/// CREATE INDEX idx_orders_customer ON orders(customer_id);
274+
/// ```
275+
///
276+
/// SQLRite's join is a nested-loop driver without an inner-side index
277+
/// probe (see [`docs/supported-sql.md`](../../docs/supported-sql.md)
278+
/// "JOIN semantics" / `executor.rs::execute_select_rows_joined`). So
279+
/// per-PK-probe joins should show a meaningful gap vs SQLite's
280+
/// indexed-nested-loop join. That gap is informational — the plan
281+
/// flags it as the most useful "what does the join planner cost us?"
282+
/// number.
283+
pub struct JoinDataset {
284+
pub customers: Vec<JoinCustomer>,
285+
pub orders: Vec<JoinOrder>,
286+
/// Random customer-PK probes for the bench hot loop.
287+
pub probes: Vec<i64>,
288+
}
289+
290+
pub struct JoinCustomer {
291+
pub id: i64,
292+
pub name: String,
293+
}
294+
295+
pub struct JoinOrder {
296+
pub id: i64,
297+
pub customer_id: i64,
298+
pub amount: i64,
299+
}
300+
301+
/// Plan-deviation note (W9): the plan spec calls for "two 100k-row
302+
/// tables." A first smoke at that scale measured SQLRite at >5
303+
/// minutes per iteration on the criterion hot loop — the engine's
304+
/// join executor is a left-folded nested-loop driver that doesn't
305+
/// push the ON predicate down to an index probe on the inner side
306+
/// (`src/sql/executor.rs::execute_select_rows_joined`), so each
307+
/// per-PK probe scans the full 100k-row inner table. That scale is
308+
/// untenable for `make bench`.
309+
///
310+
/// v1 ships at **10k rows** instead. SQLRite still scans the whole
311+
/// inner side per probe — the per-probe cost is ~10× lower at this
312+
/// scale, the gap vs SQLite's indexed nested-loop join stays
313+
/// meaningful, and the bench finishes in seconds. Bumping back to
314+
/// 100k follows a SQLRite join-planner / indexed-inner-side
315+
/// improvement (tracked separately) and a `W9.v2` tag.
316+
pub const JOIN_ROW_COUNT: usize = 10_000;
317+
pub const JOIN_PROBE_COUNT: usize = 1_000;
318+
const JOIN_SEED: u64 = 45;
319+
320+
pub fn join_dataset() -> JoinDataset {
321+
let mut rng = ChaCha8Rng::seed_from_u64(JOIN_SEED);
322+
let mut customers = Vec::with_capacity(JOIN_ROW_COUNT);
323+
let mut orders = Vec::with_capacity(JOIN_ROW_COUNT);
324+
for i in 1..=JOIN_ROW_COUNT as i64 {
325+
customers.push(JoinCustomer {
326+
id: i,
327+
name: format!("customer_{i}"),
328+
});
329+
orders.push(JoinOrder {
330+
id: i,
331+
customer_id: i,
332+
amount: i.rem_euclid(10_000),
333+
});
334+
}
335+
let mut probes: Vec<i64> = (1..=JOIN_ROW_COUNT as i64).collect();
336+
probes.shuffle(&mut rng);
337+
probes.truncate(JOIN_PROBE_COUNT);
338+
JoinDataset {
339+
customers,
340+
orders,
341+
probes,
342+
}
343+
}
344+
197345
/// W4 single-row-insert dataset. The bench loop generates rows on the
198346
/// fly with monotonically-increasing PKs starting at this base, so the
199347
/// preload (rows `1..=BASE-1`) sets a stable table size before timing

0 commit comments

Comments
 (0)