This document records reproducible local benchmark snapshots. Completed release-performance history belongs in the CHANGELOG; continuous CI history is available on Bencher.
The X08 map assigns invalid bounds, windows and binds, callback errors, exhaustion, corrupt pages, failed-handle retry, v1–v6 refusal and heavy single-entry WAL replay to correctness tests rather than nightly IDs. The C08 rebuild and C30 rollback IDs time successful work around one fixed failure; they are not failure-latency measurements.
The X07 map assigns numeric, bind, cross-kind join and forced-planner permutations to correctness tests rather than nightly IDs. Hash-control ratios include a broader scan, and literal/dynamic keyword ratios include binding and clone work: neither is an isolated optimization measurement.
The shape and retention report records native sort/window, wider-set, sparse/repeated-reference, recursive-control and sustained-reader experiments. It separates query latency, process RSS, query-meter observations and quarantine/reuse state, retaining rejected inputs and capped attempts. Its finite selection and attribution limits do not expand nightly coverage or establish universal memory bounds.
The manual migration report retains frozen v7 fixtures, native timings through 200k facts and Chromium/Firefox/WebKit timings through 20k facts. Migration, nonmigrating read-only open, v8 reopen and bounded post-migration cursor churn are separate operations. The report records the larger browser exclusions; it adds no nightly coverage or sustained-reader bound.
Seven insert_file/chunked/ cases cover G10 using the default 65,536-fact
transaction chunk size, with file-backed databases and automatic WAL checkpoints
disabled. The existing insert_file job's ^insert_file/ filter selects all
seven on v3; main's checkout has no new cases and keeps its existing selection.
ID suffix under insert_file/chunked/ |
Measured operation |
|---|---|
execute/{65535,65537} |
Parse and execute one assertion statement, including WAL/fsync below the boundary or chunk writes and checkpoint commit above it |
retract/{65535,65537} |
Parse and execute one retraction statement against a fully checkpointed source with the same number of live facts |
write_tx/{65535,65537} |
Begin, execute two individually small statements and commit; their combined staged count crosses the boundary only at 65,537, and the second statement replaces one earlier triple's valid-time window |
statement_rollback/65537 |
Stage a marker, reject a 65,537-fact statement with cross-chunk API-011, accept another marker and commit the surviving statements |
Each iteration starts from a fresh file handle. Commands, empty-file creation,
retraction template construction/copy/fsync/open, and handle/directory teardown
are outside timing (iter_batched_ref, one input per iteration). Retraction
templates and commands are initialized lazily on the first selected callback
and cached across warm-up/sample callbacks, never during ID enumeration.
Untimed preflights run once per case and check live results, transaction counts and
WAL presence to distinguish the small WAL path from the large checkpoint path;
they also check retraction history, window replacement and rollback survival.
The write-transaction sizes count submitted facts, including the replacement,
so they leave 65,534 / 65,536 live values. The rollback conflict is the last fact
of the second chunk; it measures undo after writes, rather than parse failure.
Reserve 5–20 additional job-minutes, pending serial phase-B Actions timing. Ten flat samples, one second of warm-up and a three-second measurement target per case keep the selection small; file writes/fsync and untimed setup can exceed those targets. No million-fact iteration or custom chunk-size/test-hooks target is added. No timing evidence has been collected for these new cases.
Untimed local execution (private build directory):
CARGO_TARGET_DIR="$PWD/target/p09" cargo bench --bench minigraf_bench --no-run
CARGO_TARGET_DIR="$PWD/target/p09" cargo bench --bench minigraf_bench -- --test '^insert_file/chunked/'Measurements and reproduction cover sum/avg, scalar extrema, integer distinct sums and safe effective grouping tuples after #789. Fixed-group Aggregate peaks remain 480–1,200 bytes from 100K to 10M facts, compared with 285–755 MB for forced full-input evaluation. Separate growing distinct controls retain linear state. At 10M facts, the bounded full drains improve by 17.4–31.2% and limit-10 queries take 0.60–0.81 ms. Small grouped point queries instead pay about 176–188 µs of proof startup work. The release binary remains below the unchanged 1,263,616-byte cap.
On 2026-10-29, caching expression literal resolutions reduced the measured
= / != keyword/ref queries by 9.0–11.0% at 100K and 1M facts, in both
operand orders. Four paired runs per shape swapped sockets between baseline
5f8073250 and the change. The report and raw results
include the initial measurements, UUID-keyword controls, point/scan/join guards,
a focused repeat of noisy point timings, and the scope of the improvement.
Date: 2026-10-29 · Baseline: v3 at 756ffea20. Both revisions
used the same dependency lockfile and the existing bench profile. Prebuilt
Criterion executables ran sequentially on the idle bench machine, pinned
to the same NUMA node, with 10 samples, 3 seconds of warm-up and 5 seconds
of requested measurement time. Builds and tests finished before timing.
The estimates below are Criterion's fitted per-query times, not individual
query percentiles.
| Workload | Baseline | Change |
|---|---|---|
recursion/ancestors/w10_d4 |
15.049 ms | 0.297 ms |
| same ancestors, second pair | 14.487 ms | 0.286 ms |
recursion/chain/depth_10 |
0.522 ms | 0.542 ms |
recursion/chain/depth_100 |
21.515 ms | 21.651 ms |
recursion/fanout/w10_d3 |
19.663 ms | 19.817 ms |
The ancestor query is about 50 times faster in both pairs. Criterion found no significant change for the depth-100 chain or fanout. The depth-10 chain regressed by about 4% (Criterion's mean-change estimate); the added planning and selection work is visible on this small query. General value joins retain their existing behavior. Populated committed generations continue to use AVET probes; this optimization targets eligible positive three-pattern recursive bodies with small derived demand and preserves the original value-head binding.
The regression test returns three ancestors in a 1,999-edge tree with a
128 KiB query budget, including a staged edge replacement. The baseline
fails with QRY-012 while building the pending value map. No persistent
value index or write-time cost is added.
Release size with the same toolchain and lockfile: baseline 1,258,712 bytes, change 1,261,432 bytes (+2,720). The maintainer approved raising the v3 gate from 1,230 to 1,234 KiB (1,263,616 bytes), leaving 2,184 bytes of headroom. The budget and its rationale are recorded in PHILOSOPHY.md §4.
Reproduce with cargo bench --bench minigraf_bench -- '^recursion/' on each
revision. For an isolated timed phase, build with --no-run first and invoke
the resulting benchmark executable with '^recursion/' --bench under the
same CPU/memory affinity. The raw Criterion output
contains both ancestor pairs and the control shapes.
Date: 2026-10-28 · Baseline: v3 at d0ad89c1d · Machine:
Intel Xeon E5-2696 v4, 88 logical CPUs, Rust 1.99.1. Baseline and change used
the same bench machine and fixture, with no other benchmark running. The
ignored 5M acceptance test ran once on the baseline and twice on the change
with --profile bench:
| Workload | Baseline | Change |
|---|---|---|
reach_5m: 4,882,500 rows, 62 rounds |
302.66 s | 74.40 / 70.91 s (4.1–4.3× faster) |
| Added peak RSS per derived fact | 172 bytes | 172 / 165 bytes |
The 1M-fact performance envelope used
--profile envelope. Four recursive pairs ran sequentially, alternating
baseline/change order. The table gives the mean of the four p50s; each
recursive process warmed three queries and timed five. The point shape has
three pairs. The raw results include
each process's peak RSS and row count.
| Shape | Baseline p50 | Change p50 | Difference |
|---|---|---|---|
recursive, 888,885 rows |
2,808.89 ms | 2,761.06 ms | -1.7% |
point_entity, 1 row |
17.87 µs | 17.86 µs | within run-to-run noise |
The recursive shape's process peak RSS varied: the change was lower in the first two pairs (360,080 → 358,948 KiB and 353,880 → 352,212 KiB) and higher in the later two (338,916 → 351,808 KiB and 340,864 → 350,156 KiB). It does not establish a memory improvement for this shape. The point query did not use the new rule-query cache; its latency remains around 18 µs. The small envelope latency delta is a direction check rather than a stable estimate of speedup. The 5M test supplies the larger repeated-probe workload and its query-specific added-RSS measurement.
To reproduce, build both revisions with cargo build --profile envelope --example perf_envelope, build one 1M fixture with perf_envelope __build,
then run each executable with __query fixture-1m.graph 11 8 60 for recursion
and __query fixture-1m.graph 0 2 60 for the point lookup. The 5M command is
cargo test --profile bench --test recursive_acceptance_test -- --ignored --nocapture. Builds and fixture creation are outside the timed phase.
Date: 2026-10-28 · Parent: slice 2 at e6670c82d · Change: #641,
third slice. The build manifest
records the exact source fingerprint, matched parent/new library hashes,
compiler, lockfile, probe and fixture-builder hashes, and release size.
The snapshot resolver can prove adjacency for one nonconstant partition key
shared by all built-in windows of a plain query. It prefers an existing entity
prefix; otherwise a numeric/Boolean or uniform Ref/Keyword value prefix can
qualify. DICT order proves adjacency only, never semantic Sort or Aggregate
order. Pending/staged records in the captured source range, aggregate queries,
unpartitioned or incompatible windows, callbacks and collect-set keep the
full-input strategy. No public syntax or setting changes.
Each certified partition uses the existing evaluator for its own ordering,
peers, frames and neighbors. The operator retains one partition and a charged
lookahead batch, and coalesces completed partitions into bounded passing output
batches. It stops at an already-read child-batch boundary when output is ready,
so a small limit does not pull more input merely to complete an unneeded later
partition. A surrounding Sort/Distinct can still dominate memory. Row order
across physical plans requires final query :order-by.
All builds, tests and measurements ran on the designated bench machine. The
immutable group fixtures from slice 2 contain exactly N committed facts: entity
UUID i+1 has integer :group floor(i/16) for fixed groups, or group zero for one
growing partition. Fixture construction runs separately from query timing/RSS.
The window probe is linked unchanged
against both frozen libraries with the envelope opt-level-3/frame-pointer flags,
a 256 KiB worker stack, one warmup and at least three timed cursor drains per
process. Open/thread creation are outside query timing. Each drain checks every
entity, group value and partition row number, not just row count.
Six idle-machine A/B process rounds alternate execution order and CPU/memory
NUMA binding. Results below are medians of six process medians; all raw records
retain each process's minimum/median/maximum, repeat count and peak RSS.
value partitions by ?g (16 rows), entity by ?e (one row). Both use
(row-number :over (... :order-by ?e)), project ?e ?g before the window
column, and read [?e :group ?g]. Limited shapes add :limit 10 without a final
Sort; the full query strings are in the records.
| Shape | Facts | Parent ms | Partitioned ms | Time change | New peak RSS KiB |
|---|---|---|---|---|---|
value |
100k | 244.509 | 243.214 | -0.53% | 5,484 |
value |
1m | 2989.030 | 2497.180 | -16.46% | 5,604 |
value |
10m | 33536.737 | 25958.366 | -22.60% | 5,588 |
entity |
100k | 247.649 | 255.748 | +3.27% | 5,408 |
entity |
1m | 2971.102 | 2641.718 | -11.09% | 5,520 |
entity |
10m | 33461.808 | 27378.896 | -18.18% | 5,680 |
value_limit |
100k | 218.354 | 2.066 | -99.05% | 3,996 |
value_limit |
1m | 2563.673 | 2.131 | -99.92% | 3,992 |
value_limit |
10m | 28386.299 | 2.212 | -99.99% | 4,044 |
entity_limit |
100k | 217.361 | 2.073 | -99.05% | 3,944 |
entity_limit |
1m | 2559.089 | 2.146 | -99.92% | 3,900 |
entity_limit |
10m | 28347.589 | 2.235 | -99.99% | 3,952 |
The bounded limit 10M/100K time ratios are 1.071 (value) and 1.078 (entity), both within the 2x gate. The largest new 10M RSS increase over 100K across all four shapes is 272 KiB, within the 16 MiB allowance. All 252 window records are retained.
| Shape | Window peak bytes (all scales) | IdSpace peak bytes (all scales) | Max partition rows | Limited input rows |
|---|---|---|---|---|
value |
52,464 | 81,920 | 16 | full drain |
value_limit |
52,464 | 1,280 | 16 | 1,024 |
entity |
49,464 | 81,920 | 1 | full drain |
entity_limit |
49,464 | 800 | 1 | 1,024 |
All 18 memory records are retained. Both fixed-partition peaks are exactly flat from 100K to 10M, within the 100K peak plus two charged input batches (98,464 bytes, computed with the fixture’s cell/row helpers). A single growing partition instead reaches 200 × N + 32 Window bytes, including with a limit, while IdSpace remains 81,920 bytes. Finishing the first partition still requires all N rows; that is a linear control, not a bounded-limit claim.
Input lookahead, keys, kept-cell entries, named values and result columns are
charged while retained. Shared payload estimates follow the existing
conservative per-entry accounting when both lookahead and kept copies exist;
passing output batches follow existing uncharged accounting. DICT ids remain
valid under the pin while transient lookup maps are evicted at internal input
batch and result-conversion boundaries. Provisional ids survive eviction.
A separate reference-heavy ?e/lag ?e/lead ?e regression bounds lookups by
three output reference columns per 1,024-row batch; disabling result-conversion
eviction makes that regression fail. Window and IdSpace peaks are traced
separately in test builds, with no production instrumentation.
| Shape | Facts | Parent ms | Partitioned ms | Time change | New peak RSS KiB |
|---|---|---|---|---|---|
growing_value |
100k | 247.919 | 266.611 | +7.54% | 34,896 |
growing_value |
1m | 3006.784 | 2935.370 | -2.38% | 313,072 |
growing_value |
10m | 33637.928 | 33139.746 | -1.48% | 2,897,984 |
growing_value_limit |
100k | 216.650 | 203.653 | -6.00% | 35,064 |
growing_value_limit |
1m | 2580.660 | 2190.145 | -15.13% | 319,284 |
growing_value_limit |
10m | 28506.157 | 23415.299 | -17.86% | 2,897,296 |
unpartitioned_control |
100k | 244.699 | 245.854 | +0.47% | 43,572 |
unpartitioned_control |
1m | 2983.375 | 2976.743 | -0.22% | 444,780 |
unpartitioned_control |
10m | 33287.533 | 33508.387 | +0.66% | 3,715,432 |
A limit can avoid unread later input and its I/O/allocation errors. Coalescing can still encounter an error while gathering one output batch after completing some partitions. Built-in window pipelines contain no user callbacks or fallible aggregate validation whose later data error would be hidden.
All 12 exact #379 history guards pass: time deltas -2.17% to +0.49%, within the +5% gate. Ordinary envelope points range from -2.31% to +0.61%. Existing ordered limit/offset cases range from -1.66% to +1.99%, and other controls from -1.49% to +3.37%. All 360 envelope/control records and 144 history records are retained.
| Startup workload | Facts | Parent µs | Partitioned µs | Added µs |
|---|---|---|---|---|
value_window_point |
100k | 24.139 | 136.399 | +112.261 |
value_window_point |
1m | 23.355 | 121.472 | +98.117 |
value_window_point |
10m | 23.604 | 124.117 | +100.514 |
unpartitioned_window_point |
100k | 23.471 | 23.123 | -0.347 |
unpartitioned_window_point |
1m | 23.610 | 23.769 | +0.159 |
unpartitioned_window_point |
10m | 23.683 | 23.733 | +0.050 |
The 72 startup records use the unchanged guard probe (three warmups, at least five timed drains, six balanced process rounds). Both queries read [:pM :age ?a] and project ?a plus row-number ordered by ?a; only the value-partition case adds :partition-by ?a. M is the midpoint entity. The source remains selective EAVT.
No ordinary query without an applicable demand performs proof reads. Entity partition certification needs no optional value-kind seeks. A newly eligible selective value-partition window still pays eight whole-attribute proof seeks; there is no cost-based strategy selection. The startup records report that cost separately from existing point guards and unpartitioned-window controls.
The full default suite passes 1,972 tests (17 ignored), test-hooks 2,005 (18 ignored), WASI 1,182 (seven ignored), and Firefox 61. The committed-reader window fixture runs on both portable targets. Strict native/browser/WASI clippy, the WASI release build and unchanged branch-coverage floors pass (95.44% lines / 86.50% branches). Golden-file/migration/fault-injection checks and frozen answer comparisons pass. The final focused window and doc-test reruns also pass.
The initial native release binary exceeded the unchanged 1,259,520-byte cap. Sharing the identical stable transaction-order sort across legacy-open paths, and stable clause-cost sorting across ascending/descending call sites, brings the final binary to 1,258,040 bytes. Equal-key order, flags, dependencies, public API, file/WAL formats and frozen answers remain unchanged.
Build the group fixture with the parent library, and link the same window probe against both matched libraries using the slice-1 rustc/envelope recipe. For example:
group_fixture fixed.graph 100000 16
window_probe fixed.graph '(query [:find ?e ?g (row-number :over (:partition-by ?g :order-by ?e)) :where [?e :group ?g] :limit 10])' 10 16 16Use :partition-by ?e and final probe argument 1 for one-row entity
partitions. Use fixture group size N and final probe argument N for the growing
partition, or omit :partition-by with final probe argument N for its
unpartitioned control. Alternate bindings/order over six rounds.
All sixteen Criterion IDs execute with
cargo bench --bench minigraf_bench -- '^query/partition_windows/' --test:
query/partition_windows/{value,entity,value_limit,entity_limit}/{1k,10k,100k,1m}.
They use committed one-row partitions, with value/entity certificates and
full/limited drains. The existing nightly ^query/ route selects every new ID
(#776). Scheduled Bencher history becomes available after merge; none is
claimed here.
Date: 2026-10-28 · Parent: slice 1 at 366f662df · Change: #641,
second slice. The build manifest
records the matched library, source, dependency-lock and probe fingerprints.
Both libraries use opt-level 3 (--profile envelope), frame pointers and Rust
1.99.1. The final appended native cache regression leaves the production library
byte-identical to the measured build.
Six balanced rounds run on an idle bench, with CPU and memory bound together and the nodes and execution order swapped each round. Each query has its own process and a 256 KiB worker stack. One warmup precedes 3–20,000 timed drains within a one-second budget (at least three for slow queries). Tables report the median of six process medians and the maximum of six process RSS peaks. Open and fixture construction are outside timing; fixtures are prebuilt and read-only. The existing envelope/history guards use their unchanged probe (three warmups, at least five timed drains per process). All six process samples per case are retained, including each median, range and run count.
The group fixture has exactly N committed facts: one integer :group per UUID
entity, floor(i / 16) for fixed groups. There are 6,250 / 62,500 / 625,000 groups
at 100K / 1M / 10M rows, each of size 16. Counts read ?e; distinct counts read
its distinct entity ids. Each group-fixture probe checks the ordered group key and aggregate
value, as well as row count. The materialized sum-distinct control binds the
constant 1 and sums its distinct value per group, checking a result of 1.
| Committed rows | Count full drain, ms (parent → change) | Distinct full drain, ms | Count limit 10, ms | Distinct limit 10, ms |
|---|---|---|---|---|
| 100K | 50.395 → 48.880 | 63.671 → 60.007 | 48.157 → 0.652 | 60.922 → 0.762 |
| 1M | 555.213 → 484.644 | 753.668 → 597.925 | 520.168 → 0.645 | 713.856 → 0.755 |
| 10M | 7,397.820 → 4,925.882 | 10,111.836 → 6,042.124 | 6,951.004 → 0.644 | 9,522.604 → 0.758 |
| Committed rows | Count full RSS, KiB (parent → change) | Distinct full RSS, KiB | Count limit RSS, KiB | Distinct limit RSS, KiB |
|---|---|---|---|---|
| 100K | 10,080 → 5,072 | 13,416 → 5,052 | 9,912 → 3,840 | 13,460 → 3,816 |
| 1M | 59,664 → 5,040 | 87,888 → 5,008 | 56,648 → 3,848 | 87,972 → 3,812 |
| 10M | 500,376 → 5,188 | 904,552 → 5,204 | 506,456 → 3,816 | 894,320 → 3,860 |
The bounded limit 10M/100K time ratios are 0.988 and 0.994, below the 2x gate. Across the four fixed-group shapes, the largest 10M RSS increase over 100K is 152 KiB, within the 16 MiB allowance. At 10M, full-drain count is 33.41% faster and distinct count 40.25% faster. All 252 grouped records are retained.
| Committed rows | Count peak | Distinct-count peak | Active groups | Scan rows, limit 10 |
|---|---|---|---|---|
| 100K | 480 B | 1,120 B | 1 | 1,024 |
| 1M | 480 B | 1,120 B | 1 | 1,024 |
| 10M | 480 B | 1,120 B | 1 | 1,024 |
These are query-meter peaks, including IdSpace, from the
24 diagnostic records. Both count
modes release every completed group and retain no finished-results list. Zero
peak growth fits the 100K baseline plus two charged batches; the test computes
that allowance from Batch, Vec<Cell> and cell_bytes for the fixture's two
columns, rather than a guessed byte count. The requested groups contain 160 rows;
1,024 reads fit 160 + BATCH_ROWS. A probe-join regression checks the same bound
at Aggregate's input (the join may also hold an outer batch).
Input and output batches that pass within one operator pull follow the engine's existing uncharged-batch convention. No batch is retained between calls by the ordered Aggregate. The active group, distinct members, finish scratch and a result row until it moves into the passing output batch remain charged. Internal child-batch boundaries evict DICT lookup caches even when one group spans many pulls; a targeted regression fails when that eviction is disabled.
| Committed rows | Count peak | Distinct-count peak |
|---|---|---|
| 100K | 480 B | 4,000,480 B |
| 1M | 480 B | 40,000,480 B |
| 10M | 480 B | 400,000,480 B |
The linear distinct-member control is expected. A limit cannot finish this one group before it has read every member. Other holders, provisional ids and final sorting can still dominate query memory; mixed integer/float grouping keeps its numeric Sort, and fallible aggregates retain full-input validation.
| Committed rows | One-group count, ms (parent → change) | One-group distinct count, ms | Materialized sum-distinct control, ms |
|---|---|---|---|
| 100K | 39.487 → 44.752 | 53.622 → 58.114 | 61.088 → 62.489 |
| 1M | 401.147 → 447.729 | 639.518 → 674.656 | 679.843 → 690.527 |
| 10M | 4,066.914 → 4,519.806 | 6,921.283 → 7,348.321 | 7,344.431 → 7,553.146 |
For one growing group, count is 11.14–13.33% slower and distinct count 5.49–8.38% slower. The ordered path checks its active key on every input row, and this control releases no completed groups until EOF. No cost-based strategy selection is added. The materialized sum-distinct control changes by +1.57–2.84%; its memory still grows with the number of groups. These costs are separate from the bounded-limit and ordinary-point gates.
The ordinary envelope entity/exact-value/hot-point deltas are −1.16% to +1.60%. The exact #379 history guards have a worst delta of +0.68%, below 5%; all 12 workload/depth combinations pass. Full scan, plain limit, UUID-tie and descending top-k controls span −2.40% to +2.41%. The earlier ordered value limit/offset controls span -3.24% to +0.32%. The existing ordered selective-point startup cost remains visible separately:
| Nominal envelope facts | Ordered point parent | Change |
|---|---|---|
| 100K | 130.22 µs | 130.22 µs |
| 1M | 112.67 µs | 112.57 µs |
| 10M | 117.34 µs | 119.28 µs |
The eight optional whole-attribute proof seeks are unchanged from slice 1. All 360 envelope/control records and 144 history records are retained. No ordinary query without an applicable demand makes proof reads.
The newly eligible grouped queries also pay that proof cost when there was no previous :order-by. On a one-row entity range:
| Nominal envelope facts | Grouped count parent → change | Grouped distinct count parent → change |
|---|---|---|
| 100K | 23.19 → 133.54 µs | 24.03 → 134.88 µs |
| 1M | 22.94 → 118.55 µs | 24.80 → 121.45 µs |
| 10M | 22.99 → 120.19 µs | 24.49 → 121.67 µs |
These use [:find ?a (count ?a) :where [:pM :age ?a]] and its count-distinct variant, where M is the midpoint entity. EAVT remains selective, but the eight whole-attribute seeks add roughly 96–111 µs. The 72 startup records use the unchanged guard probe (three warmups, at least five timed drains per process, six balanced process samples). A tiny group gets little benefit from releasing completed groups; there is no cost-based selection. This is reported separately from ordinary points with no applicable consumer demand.
The final default test set is 1,947 passing / 16 ignored (the full instrumented
suite plus rustdoc and the appended filtered cache regression); with test-hooks,
1,980 pass / 17 are ignored. The full default and hooks runs preceded the final
native-only cache test, which passed separately in both configurations and was
added to the preserved coverage report. All unchanged floors pass: 95.40%
lines / 86.38% branches. Native, browser and WASI clippy pass; WASI has 1,179
passing / 7 ignored tests, Firefox has 60 passing tests, and the WASI REPL builds
in release mode. Frozen answers are unchanged. The updated ignored million-group
acceptance test passes: ordered count fits its 32 MiB budget, while grouped sum
remains a materialized QRY-012 control.
The default release binary is 1,259,368 bytes, below the unchanged 1,259,520-byte cap. This slice changes no storage/key encoding, dependency, public API or file format. Window buffering is addressed by the third slice.
Compile the fixture builder against
the parent library and the same group probe
against both libraries, using the rustc/envelope flags in the slice-1 recipe.
Run group_
10BC8
fixture <new.graph> N 16, then, for example:
group_probe groups.graph '(query [:find ?g (count ?e) :where [?e :group ?g] :order-by [?g] :limit 10])' 10 16Use group size N instead of 16 for the single-growing-group control. Build fixtures in separate processes and alternate CPU/memory binding and library execution order over six rounds. Raw records contain every complete query.
All sixteen Criterion cases execute with
cargo bench --bench minigraf_bench -- '^query/ordered_groups/' --test:
query/ordered_groups/{count,distinct,count_limit,distinct_limit}/{1k,10k,100k,1m}.
The existing nightly query matrix's ^query/ route selects every new ID (#776).
Scheduled Bencher history is available after merge; none is claimed here.
Date: 2026-10-28 · Base: v3 at 2abf53df · Change: #641,
first slice. The build manifest records
source, dependency-lock, library and probe fingerprints. Both libraries used
--profile envelope (opt-level 3) and -C force-frame-pointers=yes, with Rust
1.99.1 and the same dependency lock.
Six rounds ran on an idle benchmark machine, binding CPU and memory to the same NUMA node and swapping nodes and execution order between base and change each round. Every query ran in a fresh process on a prebuilt, checkpointed file opened read-only, on a 256 KiB worker stack. Three warmups preceded 5–20,000 measured cursor drains within a one-second budget (at least five even for slower queries). Open, fixture construction and profiling are outside timing. Tables give the median of six process p50s; raw records retain each p50, its per-process range, run count and query-only process peak RSS. No samples were discarded.
The common envelope fixtures use floor(N / 6) ordinary entities and
approximately N currently visible facts, plus expired valid-time records and
hot-entity history. The
scale labels are nominal current-fact counts. The age queries read 16,666,
166,666 and 1,666,666 rows respectively. Every measured result checks its row
count; the ordered limit/offset probes also check that all returned ages are zero.
| Nominal current facts | Ordered limit: base | Change | Ordered offset: base | Change | Change limit max peak RSS |
|---|---|---|---|---|---|
| 100K | 5.996 ms | 0.458 ms | 6.022 ms | 0.457 ms | 3,784 KiB |
| 1M | 62.260 ms | 0.455 ms | 62.739 ms | 0.457 ms | 3,776 KiB |
| 10M | 643.947 ms | 0.463 ms | 644.929 ms | 0.452 ms | 3,796 KiB |
The RSS column is the maximum of the six process peaks.
The limit query is [:find ?a :where [?e :age ?a] :order-by [?a] :limit 10];
the offset query adds :offset 13. At 10M, the changed p50 ranges were
0.459–0.476 ms and 0.448–0.459 ms. The 10M/100K ratios are 1.012 and 0.989,
within the 2x gate. Peak RSS increases by 12 KiB and 40 KiB, within the
16 MiB allowance. Internal tests separately verify Sort omission, restored
projection slots, input read at most offset + limit + 1,024 rows on the positive
fixture, at most eight raw kind-prefix seeks, and zero proof reads for zero limit
including a nonzero offset.
The entity-and-attribute range remains EAVT. Its order proof still makes eight whole-attribute kind-prefix seeks, whose startup cost dominates a one-row query:
| Nominal current facts | Base | Change |
|---|---|---|
| 100K | 21.13 µs | 129.89 µs |
| 1M | 20.72 µs | 112.29 µs |
| 10M | 20.99 µs | 117.39 µs |
Query: [:find ?a :where [:pM :age ?a] :order-by [?a] :limit 1], where
M = floor(entity_count / 2). This cost is reported separately from the ordinary
point-query guards. There is no cost-based selection or cross-query proof cache
in this slice.
The envelope's ordinary entity point, exact attribute/value point and current hot-history point changed by −0.65% to +1.03% across the three scales. The attribute scan, plain limit, ascending UUID-tie Sort and descending top-k control changed by −3.28% to +2.00%. All measurements are in the 360 envelope records.
The exact #323/#379 history fixture and four query shapes were also measured
through the same cursor probe: 2,000 filler facts, a never-changed sibling
attribute, and one live string value after depth - 1 retract/reassert pairs.
The :as-of 2002 query selects the first assertion with :any-valid-time.
| Guard | Depth | Base p50 | Change p50 | Delta |
|---|---|---|---|---|
| Churned attribute | 1 | 16.83 µs | 16.70 µs | −0.80% |
| 500 | 151.83 µs | 153.69 µs | +1.23% | |
| 2,000 | 602.70 µs | 599.46 µs | −0.54% | |
| Sibling attribute | 1 | 17.12 µs | 17.10 µs | −0.15% |
| 500 | 22.53 µs | 22.58 µs | +0.24% | |
| 2,000 | 22.85 µs | 22.81 µs | −0.17% | |
| Attribute scan | 1 | 17.26 µs | 17.35 µs | +0.50% |
| 500 | 152.62 µs | 152.65 µs | +0.02% | |
| 2,000 | 595.38 µs | 603.65 µs | +1.39% | |
| Churned attribute, as-of | 1 | 17.85 µs | 17.76 µs | −0.48% |
| 500 | 141.32 µs | 141.59 µs | +0.19% | |
| 2,000 | 554.11 µs | 560.41 µs | +1.14% |
The worst guard delta is +1.39%, within the 5% gate. All 144 history records are retained.
Build the immutable base and the change once, then compile the same query probe against each library:
RUSTFLAGS='-C force-frame-pointers=yes' cargo build --profile envelope --example perf_envelope
rustc --edition=2024 -C opt-level=3 -C panic=abort -C force-frame-pointers=yes \
-L dependency="$target_dir/envelope/deps" \
--extern minigraf="$target_dir/envelope/deps/libminigraf.rlib" \
scripts/input_order_probe.rs -o "$probe_out"Build envelope fixtures separately with perf_envelope __build <new.graph> N.
Run probe <fixture.graph> '(query [...])' <expected_rows> [zeros] with alternating
CPU/memory binding as described above. Build the
history fixture helper against the
base library with the same rustc command; run it with <new.graph> <depth>.
Raw records contain each complete query.
All eight Criterion cases were executed on the bench with
cargo bench --bench minigraf_bench -- '^query/ordered_values/' --test:
query/ordered_values/{index,uuid_ties}/{1k,10k,100k,1m}. The existing query
nightly matrix uses ^query/, which selects every new ID (#776); no workflow
change is needed. Scheduled Bencher history becomes available after merge.
perf_envelope also includes ordered_values beside its existing top_k control.
The ordinary release binary is 1,254,928 bytes, below the unchanged 1,259,520-byte cap (base: 1,258,784). Sharing the existing set/window sort comparators removes duplicate generated code while preserving their order. At this snapshot, only Sort selection changed; Aggregate and Window still buffered their full input. The later #641 sections describe the other slices.
Date: 2026-10-27 · Baseline: v3 at 80120758 · Change: #530.
Two rounds on an idle benchmark server, CPU and memory bound to the same NUMA
node, running baseline then change and reversing the order in round two. Builds
and tests finished before timing. Both libraries used --profile envelope
(opt-level 3) and -C force-frame-pointers=yes. Queries opened checkpointed files
read-only and warmed their caches. Profiling ran separately. The tables show the
mean of each broad round's p50; raw results
retain run counts, maxima and the envelope's process-wide peak RSS, plus the
repeated-ref follow-up described below.
| Workload | Baseline p50 | Change p50 | Latency delta |
|---|---|---|---|
| Attribute scan, 1M facts / 166,666 rows | 261.81 ms | 135.55 ms | -48.2% |
| Point lookup, 1M facts / 1 row | 19.08 µs | 18.23 µs | -4.4% |
| Sparse entities, 1,000 rows | 3.09 ms | 2.91 ms | -5.8% |
| Descending refs, 100,000 rows | 163.51 ms | 89.16 ms | -45.5% |
| Random refs, 100,000 rows | 388.97 ms | 279.13 ms | -28.2% |
| Repeated refs, 100,000 rows | 46.37 ms | 50.17 ms | +8.2% |
| Two entity columns, 100,000 rows | 524.05 ms | 474.02 ms | -9.5% |
| Duplicate columns, 100,000 rows | 396.10 ms | 284.36 ms | -28.2% |
The original 1M-fact envelope scan has 48.2% lower p50 latency. Its process
peak RSS remains about 7 MiB in both versions, about 0.2 MiB higher in the changed
runs. Point lookups remain around 18–19 µs.
The broad repeated-ref rounds varied, including one slower changed run. Four
additional interleaved pairs measured 45.55 → 45.78 ms (+0.5%); those records have
phase: repeated_refs_recheck and trial instead of round in the raw file.
The initial +8.2% result remains in the table and raw rounds.
The reference queries use a separate 400,000-fact fixture: 100,000 UUID entities
with :friend pointing to (i * 7919 + 1) % 100000, :boss to i / 100,
:reverse to 99999 - i, and :bucket equal to i % 100. Subject ids are assigned
and checked in order before any ref facts are added, making :reverse strictly
descending in internal-id order. Expected row counts are checked on every run.
Query workers use a 256 KiB stack, including the two-column and duplicate cases.
| Probe | Query |
|---|---|
| Sparse entities | [:find ?e :where [?e :bucket 7]] |
| Descending refs | [:find ?f :where [?e :reverse ?f]] |
| Random refs | [:find ?f :where [?e :friend ?f]] |
| Repeated refs | [:find ?f :where [?e :boss ?f]] |
| Two entity columns | [:find ?e ?f :where [?e :friend ?f]] |
| Duplicate columns | [:find ?f ?f :where [?e :friend ?f]] |
A separate 8,895-fact fixture assigns 8,192 subject ids before adding Boolean markers for the cutoff probes. A chunk is dense at 64 or more distinct ids with numeric span at most twice its count. Small/sparse chunks use scalar exact reads; dense chunks walk leaves. The 64-id dense cutoff adds about 7.6 µs here; the small and sparse cases improve. This is a measured cost of the fixed cutoff.
| Cutoff probe | Baseline p50 | Change p50 | Latency delta |
|---|---|---|---|
| 63 ids, span 62 | 104.69 µs | 101.05 µs | -3.5% |
| 64 ids, span 128 (dense) | 105.62 µs | 113.27 µs | +7.2% |
| 64 ids, span 129 (sparse) | 116.87 µs | 115.45 µs | -1.2% |
| 512 ids, stride 16 (sparse) | 0.73 ms | 0.71 ms | -2.1% |
Separate user-cycle profiles of the 1M scan put scalar DictReader::uuid_of at
58.5% before the change and batched DictReader::uuids_of at 20.9% afterward.
Profile reports are retained locally.
Dense batches reuse the existing leaf cursor; sparse/small batches retain scalar
restart-point searches without a temporary per-id memo map. Sorted hints are
bounded to 1,024 distinct committed ids and keep cache/meter changes in projection
order. Joins, collect-set and sort/aggregate conversions retain their scalar paths.
To reproduce, build both revisions once with the envelope profile and frame
pointers. Compile the same probe source
against each revision's library (set target_dir, probe_src and probe_out
for that revision):
rustc --edition=2024 -C opt-level=3 -C panic=abort -C force-frame-pointers=yes \
-L dependency="$target_dir/envelope/deps" \
--extern minigraf="$target_dir/envelope/deps/libminigraf.rlib" \
"$probe_src" -o "$probe_out"Use a fresh scratch directory, place the two envelope/probe executables there as
baseline-envelope, changed-envelope, baseline-probe and changed-probe, and
build the common fixtures outside the timed phase. For example, with run_dir
pointing to that directory and bench_node selecting one NUMA node:
"$run_dir/baseline-envelope" __build "$run_dir/fixture-1m.graph" 1000000 \
> "$run_dir/fixture-1m.json"
"$run_dir/baseline-probe" build "$run_dir/refs.graph" 100000
"$run_dir/baseline-probe" build-boundaries "$run_dir/boundaries.graph" 8192
numactl --cpunodebind="$bench_node" --membind="$bench_node" \
"$run_dir/changed-envelope" __query "$run_dir/fixture-1m.graph" 2 8 60
numactl --cpunodebind="$bench_node" --membind="$bench_node" \
"$run_dir/changed-probe" query "$run_dir/refs.graph" \
'(query [:find ?f :where [?e :friend ?f]])' 100000
numactl --cpunodebind="$bench_node" --membind="$bench_node" \
"$run_dir/changed-probe" query "$run_dir/boundaries.graph" \
'(query [:find ?e :where [?e :dense-threshold true]])' 64Run every case with both pairs sequentially, reversing the order for round two.
The envelope's arguments 2 8 60 select attribute scan, an eight-second budget
and a 60-second cap check; use 0 2 60 for point lookup. It warms three runs and
measures up to 200 runs or its budget, at least five. The reference probe warms
three runs and measures up to 20,000 runs or five seconds, at least five. The
higher cap gives small cutoff queries seconds of samples. For the repeated-ref
follow-up, run that query four additional pairs with alternating order. Remove
fixtures, executables and build outputs after retaining reports and CI passing.
Date: 2026-10-27 · Branch: feat/183-row-frames on v3 · Machine: Intel Xeon E5-2696 v4, Linux 6.8, Rust 1.99.1, idle during timing. Reproduction command: numactl --cpunodebind=1 --membind=1 cargo bench --bench minigraf_bench -- window_rows --sample-size 10 --warm-up-time 0.1 --measurement-time 0.2 --nresamples 1000 --noplot. The recorded run invoked the prebuilt Criterion binary directly with its --bench switch after checking the machine was idle. Criterion extended the measurement time for slow cases to obtain ten samples. The committed harness uses the same in-memory facts for a default-frame and each bounded-frame comparison. Each query scans and joins the facts, sorts window input, evaluates one aggregate, and materializes the result.
The per-case CSV contains the median and full range of the ten per-call samples, plus the peak bytes charged to the query engine's memory meter. The peak comes from the ignored window_rows_benchmark_meter_peaks diagnostic using identical fixtures and queries. It is a separate run from Criterion. The timed benchmark process's maximum RSS was 1,671,892 KiB across both fixture sizes; this is a process-wide high-water mark, not a per-query peak. The engine meter omits unrelated process and user-function allocations.
| Built-in gate | Worst observed | Limit |
|---|---|---|
100,000 rows: bounded/default median, same function and fixture (min, one partition, prefix) |
1.010× | 5× |
100,000/10,000 row median, same function, frame and partition shape (sum, 100 partitions, 2 preceding) |
12.885× | 15× |
At 100,000 rows in one partition, a zero-width min frame peaked at 25,600,040 engine-meter bytes, only 8 bytes more than the default frame. Its prefix-covering frame peaked at 26,400,032 bytes, an 800,000-byte deque charge. The corresponding 100-partition deque charges were 8 and 8,000 bytes. This confirms deque memory follows the frame's maximum width rather than always reserving the whole partition.
The UDF benchmark registers an integer sum with a fresh accumulator per bounded frame. Its middle-sized frames deliberately use replay; a prefix-covering frame uses one running accumulator. Callback counts follow from the frame widths and partition sizes.
| Rows | Partitions | Frame | Callbacks | Median (ms) |
|---|---|---|---|---|
| 10,000 | 1 | 0 preceding |
10,000 | 38.152 |
| 10,000 | 1 | 2 preceding |
29,997 | 38.275 |
| 10,000 | 1 | 5,000 preceding |
37,507,500 | 611.494 |
| 10,000 | 100 | 0 preceding |
10,000 | 38.478 |
| 10,000 | 100 | 2 preceding |
29,700 | 38.786 |
| 10,000 | 100 | 50 preceding |
382,500 | 41.717 |
| 100,000 | 1 | prefix-covering | 100,000 | 459.714 |
| 100,000 | 100 | prefix-covering | 100,000 | 467.106 |
The harness omits the quadratic 100,000-row, half-partition UDF case. The large-case query scan and result materialization dominate the built-in timings; this snapshot checks relative growth of this complete query shape, not isolated deque or sum-step throughput.
The bench / window_rows job selects 18 existing IDs with this exact filter:
^window_rows/((10000|100000)/(one|hundred)/sum/(zero|two|prefix)|100000/hundred/(min|max)/(two|prefix)|10000/hundred/frame_bench_sum/two|100000/hundred/frame_bench_sum/prefix)$
This covers sum frames of zero, two and prefix width at 10k/100k rows with one/100 partitions (12 cases), plus min/max two-versus-prefix deque controls at 100k rows with 100 partitions (4 cases). The remaining two cases exercise the bounded UDF replay at 10k rows and its full-prefix running-accumulator control at 100k rows, both with 100 partitions. The UDF pair covers different execution paths and scales; it is not a same-size latency comparison. Fixtures and UDF registration remain outside the timed loop; IDs are unchanged.
The workflow checks the selected checkout for bench_window_rows and skips
this job's measurement and Bencher upload when it is absent, including main's
02:00 UTC run. The v3 run is at 14:00 UTC. Results use
bench_window_rows.txt, the existing latency reporting path and shared
0.999 threshold. Reserve 5–15 job-minutes, an unverified planning allowance;
phase B must dispatch serially and verify executed IDs, uploads and runtime
before this route is treated as measured nightly coverage.
The other 110 registered cases remain a deliberate manual sweep: default,
sqrt and half widths, count/avg, the remaining min/max combinations and UDF
zero/half/one-partition variants. Run the full ^window_rows/ selection only
on idle hardware under the release performance review; expensive replay
cases do not belong in every nightly run. The unregistered 100k half-partition
UDF case remains excluded for quadratic callback cost. An untimed local check
of the shortlist uses cargo bench --bench minigraf_bench -- --test '<filter>'
with the exact filter above.
The #836 disposition report retains the 110 manual cases and reconciles all 156 unselected registrations at a fresh pin. It records per-shape costs, not new timings. In particular, full-prefix UDF frames use a running accumulator; only bounded replay has the quadratic half-frame cost. The one-partition prefix remains manual.
Version: v3 branch (format v8), unreleased · Date: 2026-10-11 · Command: cargo run --profile envelope --example perf_envelope -- --scales 100k,1m,10m · Intel Core i7-1065G7 (4 cores / 8 threads, 16 GB RAM), Rust 1.94.0, Linux 6.12.108, on AC power and otherwise idle; the .graph file on an SSD with the OS page cache warm.
This section lists the graph sizes and query shapes that are tested, with their p50 and p99 latency, and the ones that are not supported yet. Re-run it for each minor release (CONTRIBUTING.md).
Every query runs on the streaming engine since #551. The figures in this table are from the #551 A/B on an idle server (below); the storage and latency tables after it are the laptop run of 2026-10-11, before the engine.
| Workload | Tested up to | p50 / p99 at 10M facts | Notes |
|---|---|---|---|
Point lookup: bound entity and attribute, including :as-of a counter and :valid-at |
10M facts | 18–20 µs / 22–27 µs | Flat from 100K to 10M |
Point lookup :as-of a time |
10M facts | 0.92 ms / 0.93–0.96 ms | Resolving the time reads the DICT's transactions: 0.7 ms at 100K |
Entity with a long history (1,000 versions), current and :as-of |
10M facts | 0.29–0.30 ms / 0.32–0.37 ms | Grows with that entity's history, not the graph |
| Attribute + value, joins that probe by entity or value | 10M facts | 17 µs–0.2 ms | attribute_value, join_2, join_3_selective |
| Open a checkpointed file | 10M facts | 0.13–0.23 ms, 3 MiB RSS | Indexes are paged in on demand |
| Checkpoint after 1 new fact | 10M facts | 1.1–1.5 ms / 2.2–2.9 ms | Copy-on-write: cost follows the change |
Attribute scan, aggregates, not, or, joins that scan |
10M facts | see below | Linear in the facts they read, in bounded memory: 0.3–0.9 s for not/or/count_grouped, 2.5 s for a 1.67M-row scan in 7 MiB, 13 s for join_3_scan in 62 MiB |
| Rule queries (recursion) | 10M facts | see below | A rule with a constant (magic sets) takes 1.9 ms; a whole transitive closure grows with the facts it derives, about 5 µs and 250 bytes of RSS per derived fact |
| Window functions | 10M facts | 5.9–6.0 s | A window holds its whole input: 0.9 GiB for 1.67M rows |
Not supported yet:
- Graphs over 10M facts are not tested.
- Interactive latency for queries that read a whole attribute at 10M facts: 0.3–14 s. Their memory is bounded per operator, except windows and rule relations, which hold their rows;
OpenOptions::query_memory_limitstops a query that passes it withQRY-012(#549).
| Metric | 100K | 1M | 10M |
|---|---|---|---|
| File size | 10 MiB | 120 MiB | 1,202 MiB |
| Build time (200-entity transactions, checkpoint every 200K facts) | 3.2 s | 12.7 s | 195 s |
| Build peak RSS | 185 MiB | 403 MiB | 421 MiB |
| Open | 120 µs | 136 µs | 124 µs |
| RSS after open | 3 MiB | 3 MiB | 3 MiB |
| Checkpoint, 1 new fact (p50 / p99) | 3.1 ms / 5.1 ms | 2.9 ms / 4.3 ms | 3.8 ms / 20.5 ms |
| Checkpoint, 10K new facts | 41 ms | 83 ms | 722 ms |
| Peak RSS (open + 21 checkpoints) | 20 MiB | 21 MiB | 21 MiB |
| Shape | Query | 100K | 1M | 10M |
|---|---|---|---|---|
point_entity |
[:p{m} :age ?a] |
13 µs / 16 µs | 12 µs / 48 µs | 12 µs / 46 µs |
as_of |
:as-of {tx}, [:p{m/4} :age ?a] |
14 µs / 36 µs | 14 µs / 20 µs | 14 µs / 30 µs |
valid_at |
:valid-at "2021-06-01…", [:p{m} :status ?s] |
15 µs / 49 µs | 14 µs / 20 µs | 14 µs / 23 µs |
hot_current |
[:hot :counter ?v], 1,000 versions |
263 µs / 301 µs | 256 µs / 293 µs | 268 µs / 300 µs |
hot_as_of |
same, :as-of halfway |
257 µs / 284 µs | 262 µs / 293 µs | 264 µs / 304 µs |
attribute_value |
[?e :name "p{m}"] (1 row) |
24.5 ms / 25.6 ms | 288 ms / 297 ms | 3.16 s / 3.16 s |
attribute_scan |
[?e :age ?a] (N/6 rows) |
43.4 ms / 49.4 ms | 462 ms / 484 ms | 4.83 s / 5.18 s |
count_grouped |
[:find ?g (count ?e) :where [?e :group ?g]] |
46.3 ms / 48.6 ms | 521 ms / 524 ms | 5.66 s / 6.20 s |
limit |
[:find ?e ?a :where [?e :age ?a] :limit 10] (#306) |
307 µs / 502 µs | 317 µs / 348 µs | 318 µs / 391 µs |
join_2 |
[:p{m} :follows ?f] [?f :name ?n] (1 row) |
54.0 ms / 63.0 ms | 606 ms / 610 ms | 6.50 s / 7.02 s |
join_3_selective |
[:p{m} :manager ?b] [?s :manager ?b] [?s :name ?n] (10 rows) |
117 ms / 125 ms | 1.24 s / 1.28 s | 13.5 s / 13.8 s |
join_3_scan |
[?e :follows ?f] [?f :group 7] [?f :name ?n] |
150 ms / 155 ms | 1.92 s / 1.96 s | 20.9 s / 21.2 s |
recursive_ancestors |
(boss :p{leaf} ?b) over :manager (5–7 rows) |
2.71 s / 2.72 s | 40.1 s / 40.8 s | > 60 s (abandoned) |
These are the executor's figures, before the streaming engine; the engine's are in the sections below, and every shape on today's code in Executor removal (#551). recursive_ancestors at 1M was measured after the #528 fix, on the same machine and fixture. Below 100 runs, p99 is the slowest run. Shapes slower than about 75 ms ran fewer than 200 times; the full JSON report has run counts.
Peak RSS per shape, each in its own process (baseline 4 MiB):
| Shape | 100K | 1M | 10M |
|---|---|---|---|
point lookups, :as-of, :valid-at, hot entity |
4 MiB | 4 MiB | 4 MiB |
attribute_value |
22 MiB | 137 MiB | 729 MiB |
attribute_scan |
22 MiB | 169 MiB | 1,665 MiB |
count_grouped |
24 MiB | 198 MiB | 1,783 MiB |
join_2 |
33 MiB | 307 MiB | 2,342 MiB |
join_3_selective |
45 MiB | 376 MiB | 2,856 MiB |
join_3_scan |
59 MiB | 456 MiB | 3,610 MiB |
recursive_ancestors |
375 MiB | 3,259 MiB | 4,832 MiB (at abandonment) |
N facts over N/6 entities :p{i}, six facts each: :name "p{i}", :age i%90, :group i%100, :follows :p{(7919·i+1) mod E}, :manager :p{i/10} (a tree with fan-out 10), and :status in two valid-time windows ("a" for 2020–2023, "b" from 2023). Plus :hot :counter, retracted and reasserted 1,000 times. m = E/2. Every query drains its cursor with next_batch(1000). Shapes run in their own process on the fixture opened read-only. The storage rows use a copy.
The harness builds with --profile envelope: release at opt-level 3, which is how an application's release build compiles the library. release and bench (which inherits it) are opt-level "z" for the REPL binary's size, and run these queries about 1.5× slower (attribute_scan at 1M facts: 703 ms against 462 ms). The Criterion numbers elsewhere in this file are opt-level "z".
On the executor, before #544 (the executor's query path is gone since #551). perf with frame pointers on attribute_scan and join_3_selective:
- Up-front materialization (50–60%). Before matching, the executor turns every live fact of each pattern's attribute into a
Fact(selective_fact_fetch→live_scan). A pattern without a bound entity is read in full, so a selective join costs as much as scanning its widest attribute. The streaming engine (#473:ScanandIndexNestedLoopJoinoperators) replaces this. - DICT lookup per row (26–35%).
uuid_ofdoes a B+treegetfor every row's entity id (#530). - Bindings as
HashMap<String, Value>(about 25%). SipHash insert, lookup and drop per row; #473's columnar batches replace them. net_asserted_factsover committed facts thatlive_factsalready resolved (about 10%) (#532). Superseded by #551's removal of the executor's query path; see the source audit and fresh envelope/profiles. Pending records still need fact-level net-assert.- Keyword constants hashed to a UUID per fact (about 8% of the old join) (#531). The streaming engine resolves pattern constants while planning; see the post-#551 audit and profiles. The separate expression-literal path is measured and optimized in #774; dynamic expression values can still require per-row conversion.
attribute_valuereads the whole attribute instead of seeking AVET (#518). Fixed by the streaming engine'sScan(#544): see below.
Single-pattern queries run on the streaming engine since #544 (with expressions and pseudo-attributes since its second PR); a cursor pulls one batch of up to 1,024 rows at a time. A/B on an otherwise idle two-socket server, each run pinned to its own NUMA node, base and change on different sockets and swapped between rounds; v3 before #544 PR 3 (ce2e320) against #544 PR 3, two rounds each, p50 shown (the table above is an older run on a different machine).
| Shape | 100K | 1M | 10M |
|---|---|---|---|
attribute_value p50 (both sides, since PR 1) |
24 µs | 22 µs | 22 µs |
attribute_scan p50, v3 → PR 3 |
24.0 → 23.2 ms | 268 → 248 ms | 3.12 → 2.59 s |
attribute_scan peak RSS, v3 → PR 3 |
11 → 6 MiB | 58 → 7 MiB | 458 → 7 MiB |
point, :as-of, :valid-at, hot entity |
unchanged (16–26 µs; hot 0.3 ms) | unchanged | unchanged |
attribute_value reads only the AVET range of its value since #544 PR 1 (#518): 22–24 µs at every scale here, where the table above, before #544, has 24.5 ms, 288 ms and 3.16 s. An attribute scan drained with next_batch(1000) now holds one batch: peak RSS no longer grows with the number of rows (1.67M rows at 10M facts in 7 MiB, against a 4 MiB baseline). Before PR 3 the cursor held the whole answer, and in PR 3's first commit the query's id cache still held every entity it returned (189 MiB at 10M). Aggregates and rules still run on the executor and are unchanged (#546–#548). Joins: see below.
Queries of several patterns run on the engine since #545 PR 2: an IndexNestedLoopJoin when the next pattern's entity is bound, a HashJoin otherwise. A/B on the same idle server, unpinned, v3 before PR 2 (eab15bb) and PR 2 run one after the other, two rounds each; p50 of the two rounds, peak RSS per shape.
| Shape | 100K | 1M |
|---|---|---|
join_2 p50, v3 → PR 2 |
58.2 ms → 51 µs | 796 ms → 50 µs |
join_2 peak RSS |
38 → 4 MiB | 322 → 4 MiB |
join_3_selective p50 |
125 ms → 10.6 ms | 1.67 s → 148 ms |
join_3_selective peak RSS |
40–41 → 7 MiB | 392–462 → 14 MiB |
join_3_scan p50 |
165 ms → 41.1 ms | 2.64 s → 1.12 s |
join_3_scan peak RSS |
48–67 → 14 MiB | 468–480 → 42 MiB |
point, :as-of, :valid-at, hot entity, attribute scan |
unchanged | unchanged |
join_2 probes one entity's range for its one outer row. join_3_selective joins on the value position, so it is a HashJoin that reads every :manager fact into its table: a value probe would need AVET over pending facts, which the pending store does not have (decision 3 of the #545 plan). join_3_scan scans the AVET range of :group 7, joins :follows on its value with a HashJoin built on those rows (it streams every :follows fact), then probes :name per row.
At 10M facts with PR 3 (same server, two rounds; the PR 2 code is unchanged for these shapes, and both builds agree within 2%): join_2 51 µs and 4 MiB; join_3_selective 2.48–2.52 s and 66 MiB, its HashJoin table holding the 10M-fact graph's 1.67M :manager facts; join_3_scan 13.6 s and 59–63 MiB. The 10M figures in the first table above, before #545 and on the laptop, are 6.50 s, 13.5 s and 20.9 s with 2.3–3.6 GiB of RSS.
join_2_pseudo is [:p{m} :follows ?f] [?f :name ?n] [?f :db/tx-count ?t] with :valid-at :any-valid-time (1 row). Before PR 3 a pseudo-attribute pattern in a query of several patterns sent the whole query to the executor, which materialises every fact. A/B on the same idle server, v3 (86ad20b, with the envelope shape added) on one socket and PR 3 on the other, swapped for the second round; p50 of each round, peak RSS.
join_2_pseudo |
100K | 1M | 10M |
|---|---|---|---|
p50, v3 → PR 3 |
211 ms → 40–58 µs | 3.19 s → 55–56 µs | 33.8–34.1 s → 56 µs |
| peak RSS | 122 → 4 MiB | 857–858 → 4 MiB | 6.0–7.5 GiB → 4 MiB |
| every other shape | unchanged | unchanged | unchanged |
The pseudo-attribute pattern reads the :db/tx-count column that the :name probe fills for its one row.
count is [:find (count ?v) :where [?e ?a ?v]] (every fact, 1 row); count_grouped is [:find ?g (count ?e) :where [?e :group ?g]] (100 rows). Before PR 2 every aggregate query ran on the executor, which holds every binding before grouping. A/B on the same idle server, v3 (234cc3e, with the count shape added) then PR 2; p50 and peak RSS.
| Shape | 100K | 1M | 10M |
|---|---|---|---|
count p50, v3 → PR 2 |
207 → 44 ms | 3.01 s → 457 ms | 29.6 s → 4.46 s |
count peak RSS |
97 → 7 MiB | 728 → 7 MiB | 6,619 → 7 MiB |
count_grouped p50 |
31.4 → 5.9 ms | 397 → 61 ms | 4.51 s → 614 ms |
count_grouped peak RSS |
14 → 5 MiB | 97 → 7 MiB | 901 → 7 MiB |
| every other shape | unchanged | unchanged | unchanged |
An ungrouped aggregate holds one set of accumulators, and count reads no value, so memory is flat across scales; a grouped one holds one entry per group.
window is [:find ?e (rank :over (:order-by ?a)) :where [?e :age ?a]] (every :age row: 16,666, 166,666 and 1,666,666 rows). Before PR 3 every window query ran on the executor. A/B on the same idle server, v3 (58c19dd, with the window shape added) and PR 3 run in parallel on different sockets, swapping nodes between the two rounds; p50 and peak RSS, mean of the rounds.
| Shape | 100K | 1M | 10M |
|---|---|---|---|
window p50, v3 → PR 3 |
41.5 → 32.8 ms | 601 → 434 ms | 7.89 → 5.65 s |
window peak RSS |
19 → 16 MiB | 140 → 109 MiB | 1,257 → 1,030 MiB |
| every other shape | unchanged | unchanged | unchanged |
A window needs its whole partition, so Window holds every row and memory grows with the input. It turns each row's ids into values in its first pull, for the windows' sort; the stream keeps those DICT lookups until it ends, so returning the rows does not look them up again. Without that, the first measurement of PR 3 was 17% slower than the executor at 100K (48.2 ms).
recursive_ancestors is (boss :p{leaf} ?b) (5–7 rows, through the magic-set rewrite); recursive is [:find ?x ?b :where (boss ?x ?b)], the whole transitive closure of :manager with no constant, so every entity's chain up to :p0 is derived (72,219, 888,885 and 10,555,551 rows). boss is the two-rule closure over :manager; the envelope opens with max_derived_facts unlimited. A/B on the same idle server (2026-10-21), v3 before PR 2 (84ac845, rule queries on the executor, with the recursive shape added) and PR 3 run in parallel on different sockets, swapped for the second round; p50 of either round (they agree within 2%) and peak RSS. The executor runs were stopped after 60 s at 10M.
| Shape | 100K | 1M | 10M |
|---|---|---|---|
recursive_ancestors p50, executor → engine |
2.71 s → 1.45 ms | 43.9 s → 2.14 ms | > 60 s → 3.04 ms |
recursive_ancestors peak RSS |
253 → 4 MiB | 2,663 → 4 MiB | > 8.8 GiB → 4 MiB |
recursive p50 |
3.04 s → 211 ms | 51.0 s → 3.21 s | > 60 s → 53.8 s |
recursive peak RSS |
484 → 45 MiB | 4,786 → 318 MiB | > 8.8 GiB → 2,557 MiB |
| every other shape | unchanged | unchanged | unchanged |
The executor copied the database into a FactStorage for every rule query; the engine reads the stored facts where they are and holds only the derived relations (about 250 bytes per derived fact at 10M), so recursive memory follows its 10.6M derived facts and not the 10M stored ones. The point queries (#379) are bimodal per process on this server, about 20 µs or about 50 µs at 1M facts, in both builds: six fresh processes of each shape per build gave the same medians within 7 µs.
The acceptance test (tests/recursive_acceptance_test.rs, ignored; the nightly smoke workflow runs it) derives 4,882,500 reach facts over a layered graph of 155,000 edges (63 layers of 50 nodes, complete bipartite between neighbours) in 62 rounds: 226 s and 169 bytes of RSS per derived fact on a laptop. Each fact is derived through 50 middle nodes, so the rounds probe about 250M candidate rows; the time is in those entity probes and the identity set, not in memory.
Command: cargo test --profile bench --test memory_limit_acceptance_test -- --ignored --nocapture --test-threads=1 (the nightly smoke workflow runs it) and cargo test --profile bench --lib meter_peak_against_rss -- --ignored --nocapture, on a dedicated 88-vCPU Linux server, pinned to one NUMA node, three runs each.
| Shape | Rows | Time | Meter peak | Peak RSS added |
|---|---|---|---|---|
1M groups, [:find ?g (count ?e) :where [?e :g ?g]] |
1,000,000 | 3.5–3.6 s | 435 MiB | 542–549 MiB |
| #575 cartesian, 13 patterns sharing no variable over 3 facts | 1,594,323 | 2.6 s | 4 KiB | 9.6–9.7 MiB in a fresh process; under 50 KiB after another query |
The meter's estimate is within the spec's factor of two of the RSS a query adds (1.25× for the groups). The rest is allocator rounding, hash-table capacity beyond its entries, and the reader's pages, which no holder counts. The cartesian product held more than 5.7 GB on the executor (#575); on the engine every join builds on its 3-row inner pattern and the 1.6M rows stream through, so what a fresh process adds is the reader and the cursor, not the rows. With query_memory_limit at 32 MiB the groups query stops with QRY-012 naming Aggregate, and at 64 bytes the product stops with QRY-012 naming HashJoin.
Measuring this found that Aggregate built its sorted map of groups uncharged while its hash table was still alive: the meter read 282 MiB against 651 MiB of RSS. The map is now charged, and the table goes first, which also cut the real peak to the figure above.
limit is attribute_scan with :limit 10: Limit closes the scan after its first internal batch, so the time does not grow with the facts (p50 307 µs, 317 µs and 318 µs at 100K, 1M and 10M, 5 MiB peak RSS each; attribute_scan reads the whole attribute in 43 ms, 462 ms and 4.83 s). Same idle server, one NUMA node, 2026-10-24. The acceptance test (cargo test --profile bench --test limit_acceptance_test -- --ignored --nocapture, nightly) runs :limit 10 over 10M facts of one attribute in 0.8 ms under a 64 KiB query_memory_limit, adding 132 KiB of RSS.
top_k is attribute_scan with :order-by [?a :desc] :limit 10: Sort reads the whole attribute but holds ten rows, so its time follows the scan and its memory does not (p50 22.4 ms, 241 ms and 2.54 s at 100K, 1M and 10M against attribute_scan's 23.1 ms, 250 ms and 2.63 s on the same run; 6–7 MiB peak RSS at every scale). Same server, one NUMA node, 2026-10-25. The acceptance test runs :order-by [?v :desc] :limit 10 over the same 10M facts in 28.5 s (bench profile, opt-level z) under a 1 MiB query_memory_limit, adding no measurable RSS. Skipping the read when an index returns the rows in order is #641.
Since #551 every query runs on the engine and the executor's query path is gone. Before it, the queries the engine could not plan ran on the executor, which reads every fact of the patterns' attributes into memory; the envelope's case is :as-of with a time. as_of_ts is the as_of point query with :as-of "<time of transaction tx_half>"; as_of_ts_scan is [:find ?e ?a :as-of "<…>" :where [?e :age ?a]] (8,400, 83,400 and 833,400 rows; 83,600 on the irregular copy). The fixture's tx_ids rise with tx_count, so the time becomes a counter bound. 1m-irregular is a copy of the 1M fixture written with LogWriter, with each transaction's tx_id taken from a fixed permutation of the source's, so they fall and rise; there the engine filters on tx_count ranges instead.
A/B on the same idle server (2026-10-24), v3 before #551 PR 1 (1096e6a2, with this PR's envelope shapes and fixture) and #551 PR 3, run in parallel on two sockets, swapped for the second round; --scales 100k,1m,1m-irregular,10m; p50, mean of the rounds (they agree within 9%, most within 2%), and peak RSS.
| Shape | 100K | 1M | 1M irregular | 10M |
|---|---|---|---|---|
as_of_ts p50, v3 → PR 3 |
111 ms → 693 µs | 1.65 s → 809 µs | 1.67 s → 656 µs | 17.2 s → 920 µs |
as_of_ts peak RSS |
57 → 4 MiB | 435 → 4 MiB | 436 → 4 MiB | 3,623 → 5 MiB |
as_of_ts_scan p50 |
110 → 13.4 ms | 1.72 s → 136 ms | 1.71 s → 136 ms | 17.8 → 1.40 s |
as_of_ts_scan peak RSS |
57 → 5 MiB | 435 → 6 MiB | 435 → 6 MiB | 3,623 → 7 MiB |
recursive_ancestors p50 (#721) |
100 ms → 1.14 ms | 1.81 s → 1.46 ms | 1.81 s → 1.39 ms | 35.7 s → 1.93 ms |
recursive_ancestors peak RSS |
11 → 5 MiB | 69 → 5 MiB | 67 → 5 MiB | 628 → 5 MiB |
join_3_selective p50 (#721) |
10.4 ms → 210 µs | 145 ms → 204 µs | 145 ms → 223 µs | 2.43 s → 195 µs |
join_3_selective peak RSS |
7 → 5 MiB | 14 → 5 MiB | 14 → 4 MiB | 66 → 5 MiB |
point lookups (point_entity, attribute_value, as_of, valid_at) |
17–19 → 18–21 µs | 16–19 → 17–20 µs | 17–19 → 18–20 µs | 16–19 → 17–20 µs |
join_2 |
37.5 → 38.6 µs | 35.7 → 37.3 µs | 36.0 → 36.9 µs | 35.7 → 37.2 µs |
| every other shape | within 2%, not_probe +4% |
within 2% | within 3% | within 2% |
Every shape on PR 3, from the same run (before #726, see the notes; p50 of the two rounds, peak RSS):
| Shape | 100K | 1M | 10M | Peak RSS at 10M |
|---|---|---|---|---|
point_entity, as_of, valid_at |
18–21 µs | 18–20 µs | 18–20 µs | 4–5 MiB |
attribute_value |
18 µs | 17 µs | 17 µs | 5 MiB |
hot_current, hot_as_of |
0.29–0.30 ms | 0.29–0.30 ms | 0.29–0.30 ms | 5 MiB |
join_2, join_2_pseudo |
39–44 µs | 37–43 µs | 37–43 µs | 5 MiB |
join_3_selective |
210 µs | 204 µs | 195 µs | 5 MiB |
recursive_ancestors |
1.14 ms | 1.46 ms | 1.93 ms | 5 MiB |
as_of_ts |
693 µs | 809 µs | 920 µs | 5 MiB |
not_probe |
2.97 ms | 27.0 ms | 291 ms | 7 MiB |
count_grouped |
5.92 ms | 60.8 ms | 610 ms | 7 MiB |
not_join_hash |
5.88 ms | 52.9 ms | 569 ms | 9 MiB |
or_2 |
8.61 ms | 80.3 ms | 864 ms | 13 MiB |
as_of_ts_scan |
13.4 ms | 136 ms | 1.40 s | 7 MiB |
attribute_scan |
23.1 ms | 248 ms | 2.55 s | 7 MiB |
count |
44.6 ms | 442 ms | 4.52 s | 7 MiB |
window |
35.1 ms | 448 ms | 5.95 s | 893 MiB |
join_3_scan |
41.4 ms | 1.13 s | 13.2 s | 61 MiB |
recursive |
223 ms | 3.44 s | 57.4 s | 2,610 MiB |
Notes:
:as-ofwith a time is the change #551 makes: the executor read every fact of the pattern's attribute, 3.6 GiB at 10M facts; the engine resolves the time once when the query opens and reads only what the pattern reads. Resolving it reads the DICT's transaction records, which is the 0.7–0.9 ms ofas_of_tsagainst 18 µs foras_ofwith a counter. On the irregular copy, where the time selectstx_countranges instead of a bound, both shapes take the same time as on the 1M fixture.recursive_ancestorsandjoin_3_selectivechanged through #721, merged onv3after the baseline: a join on a value probes the AVET ranges of that value instead of hashing the whole attribute.recursive_ancestorshad been 50–10,000× slower since #700 (#721); it is back to the #548 figures above.- Point lookups and
join_2are 2–7% slower than the baseline in this run (about 1 µs). The cause is #721 too: its per-query value-probe state made every query'sStream1,056 bytes, past the 1,032 bytes glibc serves from its per-thread cache, so each query took the slower allocator path twice. #726 boxes that state and builds it only for queries that probe values (888 bytes); a loop of one point query per call at 100K takes 14.8–15.1 µs before #721, 15.7–16.6 µs after it and 15.3–15.8 µs after #726. The remaining 3% is #721's scan code;stream_fits_the_allocator_thread_cachekeeps the size under the limit on 64-bit glibc. - No other shape is more than 3% slower at any scale, except
not_probeat 100K (2.84 → 2.97 ms, +4%; within 1% at 1M and 10M).
Command: python bench/envelope.py <fixture> --report envelope.json --scale 1m in minigraf-python (v3), on the 1M fixture and the PR 3 report of the #551 A/B, on the same server, pinned to one NUMA node, one process per shape. The binding is built with maturin develop --release against #551 PR 3. It runs the same queries through db.query(...), drains them with next_batch(1000), and decodes each JSON batch with json.loads. Python 3.12.3.
| Shape (1M facts) | Rows | Rust p50 | Python p50 / p99 | Rust peak RSS | Python peak RSS |
|---|---|---|---|---|---|
point_entity |
1 | 18 µs | 114 µs / 125 µs | 4 MiB | 17 MiB |
attribute_value |
1 | 17 µs | 115 µs / 131 µs | 4 MiB | 17 MiB |
as_of |
1 | 19 µs | 115 µs / 135 µs | 4 MiB | 17 MiB |
valid_at |
1 | 20 µs | 129 µs / 157 µs | 4 MiB | 17 MiB |
join_2 |
1 | 38 µs | 145 µs / 157 µs | 4 MiB | 17 MiB |
join_2_pseudo |
1 | 43 µs | 174 µs / 203 µs | 4 MiB | 17 MiB |
join_3_selective |
10 | 203 µs | 327 µs / 348 µs | 5 MiB | 17 MiB |
hot_current |
1 | 298 µs | 395 µs / 426 µs | 5 MiB | 17 MiB |
hot_as_of |
1 | 289 µs | 399 µs / 432 µs | 5 MiB | 17 MiB |
as_of_ts |
1 | 848 µs | 771 µs / 793 µs | 4 MiB | 17 MiB |
recursive_ancestors |
6 | 1.43 ms | 1.54 ms / 1.96 ms | 5 MiB | 17 MiB |
not_probe |
1,481 | 27.1 ms | 27.4 ms / 29.5 ms | 6 MiB | 19 MiB |
not_join_hash |
740 | 53.1 ms | 54.7 ms / 61.7 ms | 7 MiB | 19 MiB |
count_grouped |
100 | 61.0 ms | 65.4 ms / 73.1 ms | 6 MiB | 18 MiB |
or_2 |
1,112 | 81.5 ms | 84.0 ms / 94.7 ms | 7 MiB | 19 MiB |
as_of_ts_scan |
83,400 | 136 ms | 192 ms / 205 ms | 6 MiB | 19 MiB |
attribute_scan |
166,666 | 249 ms | 356 ms / 412 ms | 7 MiB | 19 MiB |
count |
1 | 444 ms | 490 ms / 504 ms | 6 MiB | 18 MiB |
window |
166,666 | 452 ms | 599 ms / 611 ms | 103 MiB | 81 MiB |
join_3_scan |
1,667 | 1.13 s | 1.17 s / 1.18 s | 41 MiB | 49 MiB |
recursive |
888,885 | 3.45 s | 4.29 s / 4.32 s | 345 MiB | 272 MiB |
The binding adds about 100 µs per query, plus about 0.6–1 µs per returned row for the JSON batches (0.1 s for attribute_scan's 166,666 rows, 0.8 s for recursive's 888,885). The interpreter adds about 13 MiB of RSS; past that, peak RSS is the query's own, and the cursor holds one batch: attribute_scan returns its 166,666 rows in 19 MiB, where the executor's cursor held the whole answer (169 MiB at 1M in the first tables). window and recursive hold their rows in both; the binding is its own build of the library (its release profile), which holds less for them here than the envelope's build. Built against 1096e6a2, the same binding gave attribute_scan 355 ms / 19 MiB and count_grouped 64.9 ms / 18 MiB, against 358 ms / 19 MiB and 65.2 ms / 18 MiB on PR 3 (#473 acceptance: no change at the FFI).
Version: v2.0.0
Date: 2026-08-26
Command: cargo bench -- 'query/(point_entity|point_attribute|join_3pattern)'
| Property | Value |
|---|---|
| CPU | Intel Core i7-1065G7 @ 1.30 GHz (4 cores / 8 threads) |
| OS | Manjaro Linux 6.12.101-1 |
| Rust | 1.94.0 |
| Benchmark framework | Criterion 0.8 |
| Profile | bench (optimized) |
Each value is Criterion's estimated per-query latency; the range is its 95% confidence interval. The current query fixture covers 1K and 10K facts.
| Benchmark | 1K facts | 10K facts |
|---|---|---|
point_entity (bound entity + attribute) |
6.05 µs (5.91–6.25) | 5.88 µs (5.86–5.89) |
point_attribute (bound attribute) |
2.12 ms (2.11–2.14) | 26.09 ms (25.68–26.34) |
join_3pattern (three-clause join) |
5.94 ms (5.90–6.00) | 75.88 ms (71.22–82.39) |
point_entity uses a selective index-backed lookup. point_attribute and join_3pattern return larger result sets and therefore scale with the number of matching facts. Do not compare these local measurements directly with CI runs or earlier releases: host load, CPU-frequency policy, toolchain, and implementation all affect the result.
Date: 2026-09-26 · Command: cargo bench --bench minigraf_bench -- point_query_chain_depth · same host as above, on AC power and otherwise idle.
One entity's :hash is retracted and reasserted depth times (exactly one live value), plus a never-changed :other on the same entity and 2,000 filler facts; checkpointed file database. "Before" is v2.0.1; "after" is the #323 change.
| Query | Depth | Before | After |
|---|---|---|---|
[:e/hot :hash ?v] (churned attribute) |
1 | 19.5 µs | 18.3 µs |
| 500 | 1.18 ms | 855 µs | |
| 2000 | 4.64 ms | 3.36 ms | |
[:e/hot :other ?v] (sibling attribute) |
1 | 20.0 µs | 19.2 µs |
| 500 | 1.17 ms | 20.6 µs | |
| 2000 | 4.58 ms | 19.0 µs | |
[?e :hash ?v] (attribute scan) |
1 | 17.5 µs | 17.8 µs |
| 500 | 1.14 ms | 841 µs | |
| 2000 | 4.49 ms | 3.33 ms |
The churned attribute still scales with its own history on v2.x; see #379 for the v3.0.0 fix.
Date: 2026-10-06 · Command: cargo bench --bench minigraf_bench -- point_query_chain_depth --warm-up-time 1 --measurement-time 3 · same fixture as above, on format v8. "Before" is v3 at 74310ee; "after" decides net-assert on index keys and translates only surviving entries. The :as-of row is new: [:find ?v :as-of 2002 :valid-at :any-valid-time :where [:e/hot :hash ?v]], which used to scan every fact in the file.
| Query | Depth | Before | After |
|---|---|---|---|
[:e/hot :hash ?v] (churned attribute) |
1 | 19.9 µs | 21.1 µs |
| 500 | 1.10 ms | 262 µs | |
| 2000 | 4.61 ms | 1.03 ms | |
[:e/hot :other ?v] (sibling attribute) |
1 | 21.6 µs | 22.6 µs |
| 2000 | 28.8 µs | 30.1 µs | |
[?e :hash ?v] (attribute scan) |
1 | 22.7 µs | 23.5 µs |
| 500 | 1.20 ms | 294 µs | |
| 2000 | 5.87 ms | 1.24 ms | |
:as-of 2002 on the churned attribute |
1 | 5.59 ms | 23.4 µs |
| 500 | 6.30 ms | 216 µs | |
| 2000 | 8.55 ms | 845 µs |
Depth-1 differences are within run-to-run noise: two back-to-back A/B runs measured 19.0–19.3 µs before and 19.8–19.9 µs after for the churned attribute, and 22.6 µs before and 21.2–21.5 µs after for the sibling attribute. In this fixture every retraction and re-assertion uses a new value, so each history entry is its own two-entry triple. The remaining cost is the walk over those keys. A triple with a long history of its own is skipped with one seek.
Date: 2026-09-26 · Command: cargo bench --bench minigraf_bench -- checkpoint/after_1_fact · same host as above; file on tmpfs, so fsync cost is excluded.
An already-checkpointed file database of n facts receives one new fact, then checkpoint() runs; every iteration has exactly one dirty fact. "Before" is v2.0.1, which decoded and re-encoded every index entry. "After" copies index leaves that receive no new entries.
| Facts | Before | After |
|---|---|---|
| 10k | 23.9 ms | 4.01 ms |
| 100k | 246 ms | 26.3 ms |
Cost still grows with graph size, because index pages are copied on every checkpoint. Checkpoints proportional to the change alone need copy-on-write pages, tracked with #374 for v3.0.0.
Criterion warms up each benchmark, collects ten samples for this query group, and estimates per-call latency from repeated iterations. The reported confidence intervals describe measurement uncertainty on this host; they are not cross-machine performance guarantees.
For changes that may affect performance, record the exact command, version, toolchain, host characteristics, and fixture size alongside the result. Use the Bencher history for trend and regression analysis across CI runs.
# The performance envelope (#394): builds and caches fixtures in target/perf-envelope
# (10M facts: about 1.2 GiB on disk, 3 minutes to build, up to 5 GiB RSS while measuring).
cargo run --profile envelope --example perf_envelope -- --scales 100k,1m,10m --json envelope.json
# Run the current documented query snapshot.
cargo bench -- 'query/(point_entity|point_attribute|join_3pattern)'
# Run every Criterion benchmark (the full suite includes slow large-fixture cases).
cargo bench
# Run an individual group.
cargo bench -- 'insert'
cargo bench -- 'concurrent_btree_scan'Criterion writes HTML reports to target/criterion/. A benchmark result should be refreshed after changes to the query, storage, synchronization, or compiler/toolchain paths it exercises.
- Results are measurements, not service-level guarantees.
- The full suite intentionally includes expensive large-fixture and quadratic workloads; run focused groups during development and the complete suite for a release-level performance review.
- Criterion does not collect heap or RSS itself. The #183 snapshot adds a process-wide RSS high-water mark from
/usr/bin/timeand separate engine-meter peaks; the performance envelope reports RSS from/proc(Linux only). The weekly soak test (#392) also records RSS, file size, and open and checkpoint time at 10M facts.