EQL v3 benchmark suite with v2 regression comparison#23
Conversation
Adds a parallel v3 bench suite targeting the upcoming EQL v3 release
(domain-specific types replacing the eql_v2_encrypted composite). The v2
benches and their committed results are untouched — they are the
regression baseline; v3 results land in results/{query,ingest}/v3/.
Harness (src/v3.rs): v3 payloads come from eql-bindings::from_v2 over the
pinned cipherstash-client's v2.3 output (the supported migration path).
No v3 scalar query wire shape exists, so all query parameters are
stored-shape payloads; the OPE benches synthesize the `op` term
(fixed-width order-preserving hex) until a client emits CLLW-OPE.
Benches: exact/match/ore/group_by/json/combo mapped onto v3 domains with
scenario ids preserved for the report join, plus new ope (ORE-vs-OPE
ordering), scalar_smoke (date/timestamp/numeric/bigint/double/boolean),
and plaintext baselines for the overhead-vs-native story.
Report (report_v3_compare.py, mise run report:v3-compare): regression
table with semantics-changed annotations, index-engagement audit, ingest
comparison, index build times, and PNG+SVG charts.
Headlines at 1M rows: OPE range/ordered queries at 0.12ms (~1.2x
plaintext, 6x faster than ORE; index builds 1s vs 44s); ORE LIMIT-100
ranges 4x faster than v2; bloom GIN engages via native array_ops.
Findings for the EQL team: text_search requires the ORE term (string
ingest capped at ~1.3k rec/s, -87% vs v2 — an hm+bf-only domain would
restore it); ORE ordered scans +43% vs v2 (plpgsql opclass comparator);
diffuse +12-44% on JSON extractor costs. Full detail and diagnosed bench
artifacts in report/V3_COMPARISON.md.
Claude-Session: https://claude.ai/code/session_01StQnoycoFXMDdSpQ6zKDav
The report / report:slow / report:ingest tasks scan only the v2 baseline directories (results/query, results/ingest) — v3 results live in v3/ subdirectories and were invisible to them. Add --v3 flags to both terminal reporters and v3 sibling tasks: report:v3 all v3 scenarios, slowest first report:v3:slow v3 scenarios over N ms (default 100) report:v3:ingest v3 ingest throughput report:v3-compare now prints the v2/v3 side-by-side join on the CLI (medians + delta + flag per scenario/tier, plus v3-only scenarios); the Markdown+charts generator moves to report:build:v3-compare, matching the report / report:build split the v2 tasks already use. Claude-Session: https://claude.ai/code/session_01StQnoycoFXMDdSpQ6zKDav
Adds v3-regressions-report.md: scenario-by-scenario analysis of the flagged v2->v3 regressions, with the exact queries both versions ran, function-level attribution (track_functions), and validated fixes. Key findings, each empirically validated on the live bench DB: - The ORE ordered-scan regression (+43%) is NOT the opclass comparator (per-query comparator time is identical across versions). It is the term-extraction helpers (jsonb_array_to_bytea_array, jsonb_array_to_ore_block_256) being rewritten from v2's plpgsql to LANGUAGE sql: in the opclass call path they cannot inline and pay the per-call SQL-function executor (3.5x per-call cost, identical logic). A local logic-identical plpgsql swap (A->B->A) eliminated the entire regression; end-to-end criterion reruns with the patch: ORE/range_lt_ordered_10 0.736 -> 0.553 ms, COMBO/bloom_ore_order_limit 22.70 -> 13.97 ms (16% faster than v2). Controls unmoved. - JSON/field_eq/functional (+19%) is entirely the eql_v3.jsonb_entry domain CHECK calling a non-inlinable LANGUAGE sql validator (18us per needle cast); v2's CHECK was an inline expression. Third instance of the same pattern. - btree-vs-hash accounts for only ~0.004 ms of the EXACT/eql_hash delta (direct A/B); the btree choice stands for build-time reasons. Ends with an Issues section (4 items, incl. the team's k="q" query payload variant) ready to lift into Linear. All experiment state was reverted after measurement; committed bench results are untouched (SVGs re-rendered by the report build, build-times log gains the v2 repopulation entries - note v2 ORE 1M index build 42s vs v3 44s, corroborating comparator parity). Claude-Session: https://claude.ai/code/session_01StQnoycoFXMDdSpQ6zKDav
…s approach) Reviewing James's PR #22 (stacked on #21/#20, a parallel v3 bench implementation) surfaced two adoptable facts: cipherstash-client 0.38.1 emits the scalar OPE-CLLW `op` term via Index::new_ope() (CIP-3280), and eql-bindings is published on crates.io. Adopt both into this branch rather than merging the parallel machinery (which duplicates ours under different table/task names and has no results): - Bump cipherstash-client + stack-profile to =0.38.1 (v2 wire format unchanged); switch eql-bindings from the git branch dep to crates.io =0.4.2. - encrypt_int_ope_v3 + benches/ope_v3.rs now use Index::new_ope() for REAL op terms end-to-end; the synthetic-op machinery is removed from src/v3.rs (to_v3_stored_with_synth_ope, ingest_v3_synth_ope, i32_order_key, SYNTH_OPE_HEX_WIDTH). The startup order-parity gate now verifies real crypto ordering. - OPE tiers re-ingested with real ciphertexts (33-byte op terms, ~2x the synthetic width) and all OPE query benches re-run: medians unchanged (0.118-0.124 ms flat at every tier) — the headline OPE numbers hold with real crypto. - All v3 ingest benches re-run under 0.38.1: ORE int and string at parity with the alpha.9 numbers (client bump doesn't shift existing comparisons), and a NEW headline: real OPE ingest runs 10,933 rec/s at 10k — 5.3x ORE int ingest (2,076 rec/s), since OPE term generation is far cheaper client-side than ORE block generation. - Synthetic-op caveats removed across README, reports, task descriptions. Claude-Session: https://claude.ai/code/session_01StQnoycoFXMDdSpQ6zKDav
…ipeline Replace the parallel IngestOptions::ingest_v3(f, "<target>") method with James's versioning-as-configuration shape from PR #21: the builder gains .convert_to_v3(TargetDomain) and the single ingest() branches on the stored eql_target. One batch loop instead of a duplicated one, and the target is a typed TargetDomain resolved at the builder call site (bad domain names fail at build time, not mid-ingest). TargetDomain is re-exported from dbbenches::v3 so binaries don't depend on eql-bindings directly; to_v3_stored_target() is the pre-parsed hot-loop conversion. The V3_CONVERT_ONLY decomposition flag moves into the Some(target) branch unchanged. The custom multi-column binaries (encrypt_combo_v3, encrypt_scalar_smoke_v3) keep calling to_v3_stored directly - they never used IngestOptions. Smoked both branches at 100 records each: v2 (None), integer_ord, integer_ord_ope (real op, 33-byte terms), text_search - all insert cleanly. This supersedes the ingest side of PRs #21/#22, which can now be closed (#20's 0.38.1 bump is merged and adopted here). Claude-Session: https://claude.ai/code/session_01StQnoycoFXMDdSpQ6zKDav
…ere) # Conflicts: # Cargo.toml # src/lib.rs
…ssions Bench DB patched to the eql_v3 release-tip SQL surface (surgical apply of the two merged fixes; object-state verified identical to a fresh tip install, incl. search_path pin states) and the affected families (ore, combo, json) re-run at 10k/100k/1M. Flagged regressions: 14 -> 3. At 1M: ORE ordered scan 0.736 -> 0.548ms (+6.8% vs v2, within the stored-needle overhead tracked as CIP-3383), COMBO bloom+ORE-order 22.70 -> 14.21ms (14.6% FASTER than v2), JSON field_eq at parity, JSON field_order 23.7% faster than v2. The remaining flags are the bloom GIN's array_ops delta at two tiers and the 10k ordered-scan residual - all small-absolute, all tracked. The upgrade exercise itself surfaced a release-process gap (no safe patch/minor upgrade path for installed EQL; uninstall is destructive for v3 domain columns) - discussed separately for the issue list. Claude-Session: https://claude.ai/code/session_01StQnoycoFXMDdSpQ6zKDav
…0M groundwork - report_flat_latency.py: theme-aware light/dark SVG pair for the cipherstash/stack README Performance section (Asset 3 spec). Palette matches the stack architecture diagrams; fixed 2-accent brand palette gets CVD relief via distinct markers, dashed ink line, and direct labels. Tiers auto-detected so the chart and callout upgrade from 10k->1M to 10k->10M when the 10M results land. Plaintext band uses the two btree-indexed baselines only (the unindexed json baseline's growth is a baseline artifact, not a fair reference). - Full clean query sweep: every family re-run at 10k/100k/1M on the release-tip EQL surface under client 0.38.1. Final state: 3 flags, all <= 72us absolute, all tracked (stored-needle cost at small tiers, CIP-3383; bloom GIN array_ops delta at 10k). _decrypt variants are now informational-only in the regression gate (they measure ZeroKMS round-trips + client version, not EQL). - 10M tier groundwork for the chart tables (string/integer/ope/json encrypted + plaintext baselines): schema, index DDL. Claude-Session: https://claude.ai/code/session_01StQnoycoFXMDdSpQ6zKDav
…lete) 10M ingest + benches for exact/ore/ope/json/plaintext. The chart now carries the spec's full claim with real EQL v3 data: "Latency stays flat from 10k -> 10M rows" - equality 0.14ms, JSON field equality 0.11ms, ORE range+ORDER BY 0.53ms, OPE 0.115ms, all flat across three orders of magnitude, plaintext band 0.09-0.10ms. 10M highlights vs the committed v2 baseline: ORE LIMIT-100 ranges 75-79% faster; field_order 32% faster; ordered scan at parity (+3.6%); field_eq at parity. Index builds at 10M: OPE seconds vs ORE 474s (integer) / 896s total for string's three indexes. New flags to triage: eql_cast +25.8% at 10M (+0.03ms absolute; stored-needle + wider text_search rows) and JSON contains/functional 3.36ms vs v2's 0.85ms (semantics-changed recipe; GIN posting-list growth at 10M - worth an upstream look before quoting containment numbers at scale). Claude-Session: https://claude.ai/code/session_01StQnoycoFXMDdSpQ6zKDav
report:build was v2-only (non-recursive results/{query,ingest} layout;
left untouched as the generator of the committed v2 report). Add a
--v3 mode to report_benchmarks.py: reads the v3/ result subdirs, uses
the v3 ingest inventory (int, int_ope, string, category,
ste_vec_small with the _v3 filename suffix), titles the report
"(EQL v3)", and writes everything into report/v3/full/ so the ~45
chart PNGs can't collide with the committed v2 report's files.
New task: mise run report:build:v3. Generated output committed:
per-family pages incl. the v3-only OPE family, all four tiers for the
chart scenarios.
Claude-Session: https://claude.ai/code/session_01StQnoycoFXMDdSpQ6zKDav
…t comparisons Restructure per review: the main report should carry the v3 numbers with the comparisons folded in, giving the full picture in one place. - report/BENCHMARK_REPORT.md: EQL v3 ingest + per-family query pages, plus two new sections — "Comparison vs EQL 2.3" (flagged deltas table, summary counts, v3_vs_v2 charts; full 115-pair table stays in V3_COMPARISON.md) and "Comparison vs plaintext PostgreSQL" (encrypted-vs-plaintext ratios per scenario x tier + overhead charts). Sections are built from report_v3_compare's loaders so the two reports cannot drift. - report_benchmarks.py: v3 is now the DEFAULT mode; --v2 rebuilds the archived EQL 2.3 report. - The committed v2 report moved wholesale to report/v2/ (links intact); report/v3/full/ removed (superseded by the main report). - mise: report:build = the main (v3) report; report:build:v2 replaces report:build:v3. README links updated. Claude-Session: https://claude.ai/code/session_01StQnoycoFXMDdSpQ6zKDav
1. Ingest report sections/charts in v3 mode are now named by the EQL v3
domain type they write (eql_v3.text_eq, eql_v3.integer_ord_ope, ...)
instead of bench shorthand ("Category", "Int Ope") — the question the
ingest report answers is "how does each type perform for ingest".
2. GROUP BY at 10M: category_encrypted_v3 + category_plaintext 10M
tables added and populated; group_by bench run at 10M fills the two
missing plaintext-comparison cells (encrypted ~2.7x plaintext at
10M). First-run v2 deltas (+13-16%) are single datapoints — a
stability re-run is in flight before treating them as real.
3. Noise re-checks on the four flagged pairs: eql_cast@100k (+1.1%),
eql_bloom@10k (+7.6%), and ore-ordered@10k (+6.1%) all drop below
the threshold on fresh runs — small-tier flags oscillate +-5-8%
around small true deltas. eql_cast@10M persists at +14.8% (was
+25.8%): ~16us residual, consistent with wider text_search rows +
the stored-shape query-needle freight (CIP-3383).
4. Terminology: "stored-needle" -> "stored-shape query needle"
throughout the regression report (the needle is the passed query
term; only its wire shape is the stored one).
Claude-Session: https://claude.ai/code/session_01StQnoycoFXMDdSpQ6zKDav
Second group_by run at 10M reproduces the first within 2% (887/901 ms vs 902/918 ms) — the +11-16% delta vs the May-measured v2 baseline is consistent, not run noise. A same-day v2 re-measurement is in flight to rule out cross-session environment drift before the delta is treated as a real v3 cost; verdict to follow. Reports rebuilt with the run-2 numbers and current flag state (3 flags: eql_cast@10M +14.8%, GROUP BY 10M x2). Claude-Session: https://claude.ai/code/session_01StQnoycoFXMDdSpQ6zKDav
…asurement) Repopulated the v2 category/string tables at 10M and re-ran v2's group_by and exact benches under today's conditions (committed baseline files captured and restored). Verdict: the May-measured baseline drifts +-8-19% from same-day v2 at the 10M tier, and against SAME-DAY v2 the three remaining flags disappear — eql_cast -3.3% (v3 faster), top_n GROUP BY -0.7%, low-cardinality GROUP BY +6.4% (under threshold). v3 is regression-clean vs same-day v2, apart from documented semantics-changed items (eql_hash's hash->btree trade measures +36% vs a fresh same-day hash index, as expected) and the sub-threshold small-tier deltas tracked by CIP-3383. Comparison notes + methodology updated so the flags render with their adjudication inline. Claude-Session: https://claude.ai/code/session_01StQnoycoFXMDdSpQ6zKDav
The same-day v2 re-measurement repopulated category/string v2 tables at 10M; their index builds (148s / 342s) logged here alongside the v3 build times. Claude-Session: https://claude.ai/code/session_01StQnoycoFXMDdSpQ6zKDav
|
Cross-checked this suite against the current state of the EQL v3 stack (post Actionable now (this PR or immediately after merge):
Post-merge follow-ups worth tracking (upstream-gated):
None of these block this PR — items 1–2 could ride along if convenient, 3–6 are follow-ups. The suite itself is the missing piece: everything above hangs off it landing. |
… + field_gt Addresses PR #23 review (James's items #1 and #2): - setup-db-v3 now installs the pinned eql-3.0.0-alpha.3 release bundle instead of building from a local eql_v3 checkout. alpha.3 moved the per-domain types eql_v3.* -> public.* and removed jsonb_query, so the whole v3 surface is migrated to public.<type> (schema_v3.sql, sql/indexes, *_v3 benches, encrypt binaries, src/v3.rs); eql_v3.* FUNCTIONS (eq_term/ord_term/match_term/ord_ope_term/ore_cllw/jsonb_*) are unchanged. - Verified no bench/index/schema calls a moved eql_v3_internal extractor (hmac_256/bloom_filter/ore_block_256/ope_cllw) directly — item #2. JSON bench now queries through the named EQL JSON functions rather than raw jsonb @>/-> operators: - contains/extractor: eql_v3.jsonb_contains(value, $1) on GIN(eql_v3.jsonb_array(value)) - field access: eql_v3.jsonb_path_query_first(value, sel) feeding eql_v3.eq_term / eql_v3.ore_cllw - new scenario json/field_gt/functional: the encrypted `x->'y' > 10` range query -- `>` on public.jsonb_entry inlines to ore_cllw compare, reusing field_order_idx (no new index). Re-ingested + re-ran the JSON tiers 10k-1M-10M against alpha.3 in an isolated DB and regenerated report/V3_COMPARISON.md + report/BENCHMARK_REPORT.md. contains/functional @10m improves 3.36ms -> 1.05ms (jsonb_array recipe). Non-JSON v3 numbers remain the alpha.2-equivalent baseline pending a full re-baseline (flagged by a "Re-baseline status" banner in the report).
|
Thanks for the thorough cross-check, James — pushed as #1 — released bundle. #2 — JSON bench. Two changes beyond the migration:
#3 — 10M tier. The JSON tiers are now re-ingested + re-run against alpha.3 at 10k/100k/1M/10M and both reports ( #4–6 — ack'd as upstream-gated. FYI the local checkout already has the scalar query-operand terms in flight (CIP-3432), which is the #5 ( |
…ded allowlist The V3_COMPARISON.md 'v3-only scenarios' section used a hardcoded prefix allowlist (OPE/, SMOKE_V3/, PLAINTEXT/, _noindex/) that predated the new JSON field_gt scenario, so field_gt showed only in the index-engagement audit and its medians were absent. Derive v3-only the same way the CLI table already does — any v3 scenario whose prefix never matched a v2 counterpart — so new v3-only scenarios appear automatically. Regenerates report/V3_COMPARISON.md (field_gt medians now listed).
- Re-ran all five v3 ingest benches (int, int_ope, category, JSON ste_vec, string) against the eql-3.0.0-alpha.3 release in an isolated DB and refreshed results/ingest/v3/*. Numbers match the alpha.2-era run within noise (int 10k ~2154 rec/s, JSON ~4461, string ~1037) — the type-schema move eql_v3.* -> public.* and the alpha.3 CHECKs add negligible ingest cost. - report_benchmarks.py now labels v3 domains public.<type> (was eql_v3.<type>), fixing the stale type names in BENCHMARK_REPORT.md and the per-bench pages. - Regenerated report/BENCHMARK_REPORT.md + report/V3_COMPARISON.md. Non-JSON *query* pages still show eql_v3.* in their captured SQL text — those metadata are alpha.2-era and update on the deferred full re-baseline (flagged by the report's Re-baseline status banner).
What
Parallel benchmark suite for the upcoming EQL v3 release (domain-specific types —
eql_v3.text_search,eql_v3.integer_ord,eql_v3.integer_ord_ope, … — replacing the singleeql_v2_encryptedcomposite). The v2 benches and committed results are untouched: they are the regression baseline. v3 results land inresults/{query,ingest}/v3/, joined to v2 by identical criterion scenario ids.Full comparison: report/V3_COMPARISON.md (regression tables, index-engagement audit, ingest deltas, index build times) with PNG+SVG charts in
report/v3/for docs/marketing.Headlines (1M rows, M1 Max, PG 17)
LIMIT 100range queries 4× faster than v2 (functional index engages where v2 seq-scanned).array_ops(v3 ships no GIN opclass — this was the biggest open risk) and is worth 100–400× vs no index.Findings for the EQL team (pre-release)
text_search(the only eq+match text domain) requires the ORE term, capping string ingest at ORE-generation speed (~1.3k rec/s vs 9.6k on v2's unique+match). An hm+bf-only text domain would restore it.ore_block_256opclass comparator; OPE is the escape hatch.field_eq/extractoroutlier is a diagnosed bench artifact (every-row needle × sqlx generic-plan flip → full 1M-row GIN bitmap before LIMIT), documented in the report — not a v3 defect.Methodology / caveats
eql-bindings::from_v2over the pinned cipherstash-client 0.34.1-alpha.9 (the supported migration path); conversion is inside the measured ingest path (int ingest: ±2% vs v2, so conversion+CHECK cost ≈ 0).opterms are synthetic (fixed-width order-preserving hex; order-parity asserted at bench startup) until a client emits CLLW-OPE (CIP-3348) — plan shapes faithful, ciphertext size approximate.mise run setup-db-v3builds the bundle from a localeql_v3checkout until eql-3.x is released; the Cargo git dep should move to the release tag then.Running
https://claude.ai/code/session_01StQnoycoFXMDdSpQ6zKDav