This repository is a workspace for iterating on proof and term encoding changes
across egglog and egglog-experimental.
The goal is to make proof generation work with a reasonable slowdown before porting the winning strategy back upstream. The current success target is:
- proof-mode wall time is under
2xthe non-proof baseline; and - that claim is supported by benchmark reports with confidence intervals, not just point estimates.
Run the complete Python and Rust validation suite through the root Makefile:
make checkHygiene checks and tests can be run independently. nits never runs tests:
make nits # lockfile, formatting checks, linting, and type checking
make test # Python and Rust tests
make python-check # complete Python validation
make rust-check # complete Rust validation
make python-nits # Python hygiene only
make rust-nits # rustfmt check, Clippy, and doc-link check only
make proof-tests # proof-focused subset of the workspace tests
make benchmark-smoke
make nightly # benchmark the nightly endpoints and publish nightly/output/
make nightly-local # the same run at one round, for trying it out
make update-snapshots
make format # apply Ruff and rustfmt formattingmake benchmark-smoke uses a one-round comparison and writes its disposable
JSONL report to /tmp/egglog-encoding-bench-smoke.jsonl. Override
BENCHMARK_SMOKE_REPORT to choose another path. make update-snapshots is the
explicit review action for accepting intentional Markdown report changes.
The root workspace includes both egglog and egglog-experimental.
egglog-experimental depends on the workspace egglog crate, keeping proof
changes and downstream behavior in one reviewable unit.
Proof-specific file tests use the proofs/ filter: explicit (prove ...)
fixtures under tests/proofs plus every proof-compatible file under
proof-testing mode. Proof testing turns checks into proof queries and snapshots
the generated proofs. Use make proof-tests for focused iteration and
make rust-check or make check for the final compatibility gate.
The public entrypoint is:
./bench.py [FILE ...] [OPTIONS]Every benchmark invocation compares exactly two endpoints over the same ordered files: a candidate and a baseline. An endpoint is one target, backend, and treatment. There are no implicit matrices or multi-way comparisons.
The default command compares proof mode with ordinary mode in the current checkout:
./bench.py
# Equivalent endpoint selection:
./bench.py \
--target . --backend main --treatment proofs \
--compare-target . --compare-backend main --compare-treatment offThe endpoint defaults are:
| Endpoint | Target | Backend | Treatment |
|---|---|---|---|
| Candidate | . |
main |
proofs |
| Baseline | candidate target | main |
off |
In particular, --compare-backend always defaults to main, even when the
candidate uses another backend. --compare-target alone inherits the candidate
target. Every rendered report states the exact endpoints, files, rounds,
timeout, and report path so every ratio is interpretable.
Treatments map directly to engine modes:
| Treatment | Engine behavior |
|---|---|
off |
no term or proof encoding |
term |
--term-encoding |
proofs |
--proofs |
proof-extraction |
--proof-extraction; rewrite checks, then extract, materialize, clean, and simplify proofs without verifying them |
The main backend supports all four treatments. The dd backend supports
term and proofs; an explicit proof-extraction treatment is rejected. The
engine's --proof-testing option is a strict correctness mode that rewrites
checks, extracts proofs, and verifies them, not a benchmark treatment. Results
from proof-extraction are performance evidence only, not proof-validity
evidence.
Proof overhead in the current checkout is the default:
./bench.pyMeasure non-validating proof-extraction overhead explicitly:
./bench.py --treatment proof-extractionCompare the current proof implementation with proof mode on origin/main:
./bench.py --compare-target @origin/main --compare-treatment proofsCompare the DD backend with main while holding proof mode fixed:
./bench.py --backend dd --compare-treatment proofsCompare term encoding with ordinary mode:
./bench.py --treatment termCompare current proofs with a previously cached, labeled ordinary baseline:
./bench.py --compare-target old-off=That last form reuses the newest cache identity carrying old-off when it has
enough matching rows. First create the label with a concrete source, for
example --compare-target old-off=@origin/main. Newest means the greatest
started_at, with later JSONL order breaking ties. If a command changes more
than one of target, backend, and treatment, the report warns that the ratio is
a joint endpoint change and does not attribute the effect to one cause.
Both --target and --compare-target accept the same syntax:
.for the current checkout;/path/to/repofor another local checkout;@main,@origin/main, or@abc123for a git ref from this repository;'#33'for the latest head of pull request 33 fromorigin;label=SOURCEto assign a stable report label to any source; orlabel=to reuse the newest cached identity with that explicit label.
Quote # targets so the shell does not treat them as comments. Paths are used
directly. Git refs and pull requests use an existing clean worktree at the
resolved commit when available, otherwise the runner creates or reuses an
isolated temporary worktree. It never stashes the main checkout.
If differently spelled target selectors resolve to the same checkout, the runner builds that checkout once with the union of their required backend features, so neither endpoint can overwrite the binary recorded for the other.
A cache-only label= target skips materialization and building when every
requested endpoint/file already has enough rows. If more rows are required, a
clean cached git revision can be rebuilt; a label pointing to a dirty checkout
requires a new label=SOURCE request.
The baseline and candidate may share a binary, as the default proof-overhead comparison does, but their complete cache identities—binary SHA-256, backend, and treatment—must differ.
Positional paths select benchmark files. Use --fact-directory for explicitly
selected workloads containing (input ...) commands:
./bench.py egglog/tests/foo.egg egglog/tests/bar.egg
./bench.py benchmarks/pointer.egg --fact-directory benchmarks/data/pointerPaths are resolved relative to the command invocation directory, not relative to either target. Both endpoints therefore run the exact same file and fact directory contents. Their SHA-256 hashes are part of the cache identity.
With no positional files, the representative suite is:
egglog/tests/math-microbenchmark.eggegglog-experimental/tests/fixtures/eggcc-2mm-pass1.eggbenchmarks/pointer-analysis-small.egg, withbenchmarks/data/pointer-analysis-smallegglog/tests/hardboiled_conv1d_32.eggbenchmarks/luminal-llama.eggegglog/tests/web-demo/herbie.egg
The workloads are intentionally bounded proxies rather than an undifferentiated corpus:
| Workload | Adaptation or scope | Correctness signal |
|---|---|---|
| Math | Existing synthetic stress fixture | Existing file-test snapshot |
| eggcc 2mm | Bounded pass-one fixture with ordinary constructor-valued merges | Generated main function type is checked |
| Pointer analysis | First 100 rows from 23 relations; three legacy functions are constructors for current egglog compatibility | Known constant_points_to row is derived |
| Hardboiled | Dormant canonicalization rules using unsupported unstable helpers are omitted | Extracted WMMA store result is checked |
| Luminal | Static Llama graph from egglog_repro commit 7fb0194 |
t712 is checked after kernel lowering |
| Herbie | Static engine proxy without Racket orchestration or an FPCore corpus | All 14 checks exercise the selected treatment |
Benchmark files must not contain executable (prove ...) commands. Use
(check ...) in timed workloads so the selected treatment controls whether
proof extraction is included in the timing boundary.
Reports normally identify a selected file by filename. If names collide, they use the shortest distinguishing path suffix; persisted rows retain the invoked path.
--detail is cumulative:
| Value | Added report content |
|---|---|
summary |
comparison selection and headline summary |
files |
per-file wall time and peak RSS estimates |
phases |
per-file phase confidence intervals, wall shares, and change contributions |
rulesets |
the top 10 changed rulesets per file, including phase deltas |
The default is summary. For example:
./bench.py --detail files
./bench.py --detail phases
./bench.py --detail rulesets --format markdown > benchmark-report.mdThe headline summary answers the most common questions with a small fixed set of rows:
- the fixed-suite wall-time ratio, using the sum of the per-file means;
- the lowest and highest per-file wall-time ratios; and
- the lowest and highest per-file peak-RSS ratios.
For a one-file run, redundant lowest/highest rows collapse to one result. The
ratio is always candidate / baseline: below 1x is faster or uses less RSS,
and above 1x is slower or uses more RSS. Each statistical estimate shows its
95% confidence interval when defined and otherwise shows its point estimate.
Rich output goes to stderr. From top to bottom it prints rulesets, phases, files, the compact comparison definition, and the headline summary, omitting sections above the requested detail level. The most specific diagnostics are therefore above their broader context, while the decision remains at the bottom of terminal output. Detailed Rich tables are designed for terminals at least 120 columns wide; a narrower terminal receives one warning and still renders best-effort without hiding file names. Markdown goes to stdout, is independent of terminal width, and keeps the canonical comparison, summary, files, phases, rulesets order.
The remaining collection options are:
--report PATH: append-only report/cache path; default.reports.jsonl. A filesystem path is required;-is not a streaming destination.--rounds N: selected observations required for every endpoint/file; default6.--timeout-sec N: per-process timeout; default120.--force-run: appendNfresh rows for both endpoints before selecting the newest rows.--format rich|markdown: final human report format.--open: write the complete cache snapshot to an interactive HTML file and open it. For the default cache, the output is.reports.html.
Use ./bench.py --help for the complete option reference.
Every successful benchmark observation records timing from the same measured process. Timing collection is always enabled; requesting a detailed report does not rerun a diagnostic process or change the cache key.
The engine records these components per ruleset and the JSONL stores their raw nanosecond totals:
- Search: matching and join execution.
- Apply: executing rule-head instructions and staging updates.
- Unattributed: measured pre-merge work that cannot be accurately classified as Search or Apply.
- Merge: resolving and installing staged updates.
- Rebuild: rebuilding indexes and e-graph state.
The pre-merge boundary is backend-defined. Main egglog measures one contiguous pre-merge interval and records the remainder after Search and Apply as Unattributed. DD directly times its native Search and Apply regions and defines its pre-merge total as their sum, so DD records zero Unattributed time. DD work outside those phase boundaries remains part of Outside recorded rulesets.
The phase report aggregates all rulesets and keeps two kinds of otherwise hidden time distinct:
- Execution overhead is the stored Unattributed component: measured work inside ruleset execution that cannot be split accurately into Search or Apply.
- Outside recorded rulesets is derived as process wall time minus Search, Apply, Execution overhead, Merge, and Rebuild. It includes process setup, reporting, teardown, and other work outside timed ruleset execution.
Each file gets its own phase table. For both endpoints, it displays the phase
mean's 95% confidence interval and the phase's share of that endpoint's wall
time. It also displays the signed mean change and that phase's contribution to
the file's total wall-time change. Contributions may be negative or exceed
100% when phases offset each other. A negative Outside recorded rulesets value
is prefixed with !; it means recorded phase totals exceed wall time and should
be treated as an attribution warning.
The ruleset report totals all five stored components for each ruleset, aligns
the union of names across the two endpoints, and ranks by the absolute
candidate-minus-baseline total difference. It omits exact-zero changes and
displays at most 10 rulesets per file. Each row includes the baseline and
candidate total confidence intervals, total change, and descriptive Search,
Apply, Execution overhead, Merge, and Rebuild changes. Timings are aggregated
across the selected observations; iterations are not separate report rows. A
ruleset absent from one endpoint is displayed as —, while a measured zero
remains 0 ns.
Benchmarks run single-threaded. This keeps Search and Apply attribution additive for main egglog's interleaved executor. The DD backend records the same schema at its natural search, apply, merge, and rebuild boundaries.
Add --open to print the ordinary report, write one single-file HTML
snapshot next to the JSONL cache, open it, and exit normally:
./bench.py --open
./bench.py --report results.jsonl --open # writes results.htmlThe HTML embeds the complete loaded cache snapshot and the same Python analysis and catalog code used by the CLI. It immediately displays the invocation's full ruleset-detail report, then loads a pinned Pyodide runtime and SciPy over HTTPS to enable retargeting. The initial report preserves the invocation's endpoint provenance and file order; after Apply, retargeted endpoints and files use the latest cached provenance for each cache identity within that immutable snapshot. The page can select any two cached endpoints, any nonempty file subset, a cached timeout, and an available round count; it always shows all report sections. Combinations without enough matching observations remain selectable and produce explicit missing-result states. A failed request leaves the prior report and selectors intact.
The snapshot never resolves a target, builds a binary, runs a benchmark, modifies the JSONL, or observes rows appended after it was created. If the network runtime cannot load, the initial static report remains readable and only retargeting is unavailable. The HTML contains the full cache, including machine-local paths and provenance, so treat it as potentially sensitive when sharing it.
make nightly benchmarks each endpoint in ENDPOINTS — the main backend's
term, proofs, and proof-extraction, with the dd backend disabled for
now — on the current checkout and on the latest main, accumulating them all in
the ordinary report cache, and copies the resulting interactive page and its
cache to nightly/output/index.html and index.jsonl:
make nightly
uv run --locked python scripts/nightly_bench.py /path/to/output # alternate output directoryEndpoints are labelled by target (branch / main) and commit hash, so the
page's dropdown can compare any two of them and it is clear which commit each
side is; endpoints with identical binaries collapse to one option. The page
opens on proof overhead of the current checkout. Populating is best effort: an
endpoint that fails to build or run drops one dropdown option rather than
failing the run, and the output directory is only overwritten after a
successful run. Edit TARGETS and ENDPOINTS in scripts/nightly_bench.py to
change what is measured.
make nightly-local is the same run at --rounds 1, for trying the whole
pipeline out without waiting for a full nightly.
The egraphs-good nightly service checks out
this repository, runs make nightly, and serves nightly/output/, matching the
report= entry in the nightly configuration. Two things that runner does not
provide, make nightly arranges itself: it installs a pinned uv into .uv/
when uv is missing from PATH (uv then downloads its own CPython; bump
UV_VERSION in the Makefile to move that pin), and it installs rustup when
the box has none. The runner also leaves rustup's ~/.cargo/bin off PATH,
which would leave cargo resolving to Ubuntu's — too old for
rust-toolchain.toml's pin — so nightly_bench.py puts those shims first.
Both installers run curl … | sh, so on a developer box prefer installing uv
and rustup yourself: make nightly and make nightly-local skip each one that
is already there. Only a bare CI runner needs them.
Benchmark reports answer whether and where performance changed. Use the separate Samply command when a particular endpoint needs call-stack diagnosis:
./bench.py profile FILE
./bench.py profile FILE --target @origin/main --treatment proofs --open
./bench.py profile FILE --backend dd --profile-seconds 20Profiling selects one target, backend, treatment, and file. Artifacts are cached
under .profiles/ by binary, file, fact-directory, backend, treatment, and
iteration policy. --open loads the artifact in Samply; --force-run replaces
the cached profile. Profiling is deliberately separate from the repeated-run
benchmark statistics and pair report.
On macOS, the default summary uses Samply leaf-sample CPU deltas to split
observed CPU into application, other-library, and unattributed CPU, report
application-symbol coverage, and rank top functions by self CPU. Profile
Unattributed means a leaf sample could not be mapped to a library; it is not the
benchmark's pre-merge Unattributed/Execution overhead. These are sampled CPU
totals and shares, not wall time, engine phases, or confidence intervals. Other
platforms still record and cache the artifact and print its viewer handoff
without a CPU breakdown. --top limits functions, --format selects Rich or
Markdown, and --no-summary prints only the artifact path.
.reports.jsonl is an append-only, disposable local cache. Each line is one
measured process observation. The runner parses the file once into an indexed
ReportStore; appends update both the JSONL and those indexes. Normal
collection creates no database or second cache representation; --open
separately exports an HTML snapshot.
Cache reuse is keyed by:
- binary SHA-256;
- file SHA-256;
- fact-directory SHA-256;
- backend;
- treatment; and
- timeout.
Target source, path, git revision, dirty state, labels, and display paths are
provenance rather than cache-key dimensions. The runner selects the newest
--rounds rows for each exact endpoint/file, ordering first by started_at and
then by physical JSONL order. Without --force-run, it appends only missing
rows. With --force-run, it appends a full fresh set and then selects the
newest rows.
Before any measured row is appended, all targets needing collection are built.
Fresh collection is serial; one untimed <binary> --help capability preflight
per target verifies the required timing-summary interface. Each target prints
one compact cached/required line and either nothing to collect or the fresh-run
count; cached failures and timeouts are explicit. Fresh TTY runs use transient
progress with elapsed time, redirected output logs each completed run, and both
end with status counts. All operational output goes to stderr.
benchmarking/reports/store.py defines the sole trusted TypedDict schema,
standard-library JSON codec, cache key, and append/index/select operations.
Each observation contains target and workload
provenance, exact cache coordinates, status, wall time, peak RSS, and failure
details. A top-level report schema version covers both the persisted shape and
measurement semantics, so methodology changes cannot silently reuse stale
measurements. Successful observations also contain the version-2 per-ruleset
timing summary: name plus Search, Apply, Unattributed, Merge, and Rebuild
nanoseconds.
Timed-out rows have null wall time, peak RSS, and timing summary. Failed rows have no timing summary and retain whatever process measurements the operating system supplied. Either status makes a dependent statistical comparison incomplete instead of averaging only successful rows.
This tool is the only supported reader and writer. The codec rejects old report
and timing-summary schema versions and requires successful rows to contain
timing data. It trusts the tool's typed writer rather than repeating the
TypedDict as runtime field-by-field validation. A schema change intentionally
invalidates existing caches: move or remove an incompatible report and recompute
it.
ComparisonSpec owns the exact endpoints, files, rounds, and timeout;
store.py owns physical row order and cache selection. analysis.py computes
immutable summary, file, phase, and ruleset rows, while presentation.py maps
them to the renderer-neutral catalog. Rich, Markdown, and the interactive page
serialize that catalog without recomputing report facts.
The benchmark unit is a complete egglog-experimental process for one file,
endpoint, and round. Python orchestration is outside the measured child
process. The analysis keeps all selected observations; it does not report
best-of-N timings or discard outliers by folklore.
The JSONL does not retain round-pair identities. Fresh and cached reports both analyze the selected baseline and candidate observations as independent, unpaired samples.
For one successful endpoint/file result, wall time and peak RSS use the
arithmetic mean. With at least two samples, the report computes the ordinary
95% t confidence interval for that mean using the unbiased sample variance.
With one sample it reports a point estimate labeled point only.
Pair comparisons use the candidate-to-baseline ratio of arithmetic means and a
Fieller-style 95% confidence interval. If the interval is wholly below 1, the
candidate is faster or uses less RSS; if wholly above 1, it is slower or uses
more RSS; if it includes 1, the direction is unclear at this confidence
level. If a valid interval cannot be calculated, the point estimate remains
visible with its availability explanation.
The fixed-suite wall result sums each endpoint's per-file means and divides the candidate sum by the baseline sum. Its uncertainty propagates the per-file mean variances through the same ratio method. It answers whether the selected suite's total runtime changed. The lowest/highest file rows answer whether any individual workload improved and where the worst relative regression occurred. Peak RSS has no additive suite total, so only its lowest/highest file ratios are shown. No median or geometric mean is mixed into this minimal headline.
A timed-out, failed, or otherwise incomplete selected result invalidates the suite result that depends on it. Valid per-file tail comparisons remain useful when an unrelated file is incomplete. Phase endpoint means and ruleset totals receive confidence intervals; phase contributions and individual ruleset component deltas are descriptive diagnostics.
The <2x proof goal is established only when the upper bound of the suite wall
ratio's 95% confidence interval is below 2x for a proofs-versus-off
comparison.
Methodology references:
- Kalibera and Jones, “Rigorous Benchmarking in Reasonable Time” (ISMM 2013).
- Kalibera and Jones, “Quantifying Performance Changes with Effect Size Confidence Intervals” (University of Kent Technical Report 4-12).
- Fieller, “Some Problems in Interval Estimation” (1954).
- Chen and Revels, “Robust Benchmarking in Noisy Environments” (2016).
- Georges, Buytaert, and Eeckhout, “Statistically Rigorous Java Performance Evaluation” (OOPSLA 2007).
Top-level ownership:
egglog/: subtree ofhttps://github.com/egraphs-good/egglog.egglog-experimental/: subtree ofhttps://github.com/egraphs-good/egglog-experimental.Cargo.toml: root Cargo workspace integrating both subtrees.bench.py: executable uv entrypoint.benchmarking/: Python collection, reporting, interactive-view, and profiling code.tests/: domain-focused pytest and Syrupy snapshot coverage.Makefile: canonical validation and maintenance commands.pyproject.tomlanduv.lock: Python configuration and locked environment..reports.jsonl: ignored, disposable benchmark cache..profiles/: ignored Samply artifact cache.
Python ownership is layered so new code has one clear home:
- Commands:
bench.pydispatches lazily;benchmark.pycomposes pair benchmark requests;profile.pyowns profile requests and Samply artifact caching. - Inputs and execution:
models.pydefines dependency-free identities and invariants;workloads.pyresolves and validates inputs;targets.pymaterializes and builds targets and constructs commands;collection.pyplans and records observations;processes.pymeasures children. Commands share one Rich stderr console directly. - Profile analysis:
samply_analysis.pyreads and presents Samply artifacts. - Report data:
reports/store.pyowns the JSONL schema, codec, cache key, and append/index/select operations;reports/analysis.pycomputes renderer-independent statistics. - Report presentation:
reports/catalog.pydefines the document model;reports/presentation.pysupplies wording and maps analysis into that model;reports/render.pyowns Rich and Markdown;reports/interactive_runtime.pyowns cache-only retargeting;reports/interactive.pyembeds the cache, Python modules, and adjacent browser assets into the HTML artifact, then writes and opens it.
Every module has a more precise ownership docstring. Package __init__.py
files are documentation-only; tests enforce that rule, the data-layer boundary,
and an acyclic static import graph.
egglog/ and egglog-experimental/ are git subtrees, not submodules. The root
workspace owns integration, CI, benchmarking, and local documentation, while
subtree-local changes can be synchronized with their upstream repositories.
To pull upstream changes:
git remote add upstream-egglog https://github.com/egraphs-good/egglog.git
git fetch upstream-egglog main
git subtree pull --prefix=egglog upstream-egglog main --squash
git remote add upstream-egglog-experimental \
https://github.com/egraphs-good/egglog-experimental.git
git fetch upstream-egglog-experimental main
git subtree pull --prefix=egglog-experimental \
upstream-egglog-experimental main --squashIf the remotes already exist, skip git remote add. Commit or stash local
subtree edits before pulling, then run the root workspace checks.
To create an upstreamable branch containing only one subtree:
git subtree split --prefix=egglog -b split/egglog
git push https://github.com/<you>/egglog.git split/egglog:<branch-name>
git subtree split --prefix=egglog-experimental -b split/egglog-experimental
git push https://github.com/<you>/egglog-experimental.git \
split/egglog-experimental:<branch-name>Root-only files such as bench.py, this README, and CI configuration are not
included in the split branch.
dev:opt-level = 3, withline-tables-onlydebug information.test: Cargo's built-in test profile, inheriting the optimized development configuration.release: the optimized benchmark build.profiling: release optimization plus symbols for profilers and samplers.
Cargo's profile behavior is documented in the Cargo profile reference.
Each report row records the exact benchmark binary hash.
CI runs on pull requests, manual dispatches, and pushes to main:
python:make python-nits, thenmake python-test.rust:make rust-nits, thenmake rust-test.benchmark-smoke: a one-round pair comparison onegglog/tests/integer_math.eggthroughmake benchmark-smoke.codspeed: an in-process, proofs-only benchmark over a smaller workload set in simulation and memory modes. CodSpeed includes phase-clock execution but does not persist phase reports;./bench.pyremains the source for off/proofs, commit, and backend comparisons with stored observations.
Ruff and Mypy discover all repository-owned Python files from project
configuration, so new modules under benchmarking/ or tests/ require no
Makefile edits.