Work deliberately left out of the encoding-rework PR, which is already large.
proof_encoding_rework.md on that branch is the PR's plan and record; this issue is
what comes next, with the evidence for each so nobody has to re-derive it.
Where a number appears here it was measured on math-microbenchmark.egg under
--proofs -j 1 unless said otherwise, against that PR's final state.
1. A write-only relation still gets a full table
Every table is a SortedWritesTable (egglog-bridge/src/lib.rs:748), so a proof
relation pays for FD key semantics over its key columns, sorted storage,
dedup-on-merge, a rebuild column list, and a merge callback.
None of that is needed for one:
- No rule body joins a proof relation. Verified independently twice — every
occurrence of @Sym / @Trans / @Congr / @Rule_k / @RebuildEq_k /
@DisplacedShared* / @Fiat / @MergeIdx / @MergeRow in the generated
program is inside a (set …).
- There is no functional dependency to enforce. Every row's output is a fresh id
from get-fresh!, so keys are unique by construction.
- Nothing unions a
@Proof value, so there is nothing to rebuild.
- Extraction reaches these rows only by following a proof id it already holds, so
a row nothing names is unreachable rather than merely unread.
The cost is not in Apply. Instr::Insert is already a plain append into a
per-table staging buffer; FD enforcement and merge dispatch happen in
SortedWritesTable::merge, and index maintenance is lazy, on read during Search.
So look in Merge and in index construction, not in the write.
Supporting evidence that Merge is not paying for row volume. Packing each
merge collision's Sym + Trans into one row halved merge-body proof rows
(corpus-wide 2920 statements to 1472; 2 rows per collision to 1) and moved merge
time by only -116 ms of 2.02 s, 5.7% (a932b22 to 70479df, 8 rounds,
disjoint intervals). Merge cost is therefore mostly per-row machinery rather than
the number of proof rows, which is exactly what a cheaper table for these
relations would address.
Two scoping corrections to make before acting. "Term tables are write-only"
is true only of the proof relations above:
<S>Proof (@MathProof) is read — rebuild heads look it up action-side
((let @pv13 (@MathProof c0_))), which is 8% of Apply on its own.
- The FD view tables (
@AddView, @NumView) and @UF_<S> are queried in every
rule body and are not candidates.
2. stage_batch on the merge path
The Apply work hoisted three per-row costs out of Instr::Insert / Remove: the
NotificationList::notify (an ArcSwap guard plus a ConcurrentVec::read, for
an idempotent dirty flag), the Mask::iter_dynamic pool round-trip that every
table wider than four columns paid to hold one row, and the mutation-buffer
resolution. That was -31% Apply (1.848 s to 1.267 s; eggcc-2mm 0.348 s to
0.235 s).
Merge functions, container rebuild and TableAction::insert all still go through
ExecutionState::stage_insert one row at a time, so they still pay all three.
ExecutionState::stage_batch now exists. Every proof relation is 5-9 columns
wide, so the iter_dynamic path applies.
This is the best-evidenced remaining lever on Merge, and it is independent of
item 1.
3. Updating a row in place
A view rebuild emits a delete and an insert for the same logical row:
(delete (@AddView c0_ c1_))
(set (@AddView c0_canon_ c1_canon_) (values e2_canon_ @pv32))
Eight such pairs in a two-constructor program. An in-place update would drop the
delete. Sizing: Instr::Remove is 34 ms, 2% of Apply (0.74 M row-lanes at
45 ns), so the Apply win is small on its own — the open question is whether it
also avoids re-sorting and re-dedup in Merge, which is where item 1 says the cost
lives. Worth measuring together with item 1 rather than separately.
Note the delete must stay ordered before the insert: when only the e-class moved,
the canonical key equals the old one, so deleting afterwards would drop the row
just written.
4. get-fresh! as an instruction
9.6 M calls at 12 ns each, ~110 ms, about 8% of Apply — all dyn invoke overhead
around one fetch_add. It is not a dispatch-on-string problem: the sort name is
a constant QueryEntry resolved at rule-build time and never hashed at runtime,
and 12 ns is the invoke_batch floor.
WriteVal::IncCounter and Instr::ReadCounter already exist, so the lowering
machinery is there; the work is threading the primitive through typechecking into
the rule builder.
5. Deferred from the rework plan
- Phase 3b — siting the top-level actions, a per-base-sort
Fiat, then
deleting the @Ast sort. All three wait on one decision: which program the
Fiat column counts against, since lower_inputs runs before
remove_globals and the encoder's column counter lives after it.
- Phase 5 — re-enable CSE as a pass over the encoded program (it currently
runs before proof encoding and reshapes rule heads, so the encoder and the
checker would see different heads), and restore the two dd nightly
endpoints.
6. Known and not fixed
- A non-eq container built in a rule head passes
file_supports_proofs and then
panics in the checker (Fiat proof claims 10 = 10, which is not established by globals), because reflexive_value_term recognizes neither the container head
nor a non-eq container as a value. Pre-existing; no corpus file does it.
Mask::iter_dynamic's per-row pool round-trip is still on the
LookupOrInsertDefault and external-function paths, so a future wide-arity hot
instruction will re-pay what the Apply work removed from Insert.
set-if-empty and view-proof each take an RwLock::read() plus a
HashMap<String, _> lookup per row in ActionRegistry, worth ~43 ms (3%).
Measured and rejected: EGraph::remove_last_table can replace or remove a
name's TableAction, so a memo can go stale, and making it safe needs a
generation counter threaded outside the lock.
7. How to measure without fooling yourself
- Six of the largest corpus files (
eggcc-2mm, hardboiled_conv1d_32,
integer_math, math-microbenchmark, math-microbenchmark-mini,
web-demo/eqsolve) do not reproduce their own proof-table row counts, since
those take a row per merge collision. The e-graph is deterministic:
(print-size) and every rule's num matches reproduce exactly. Diff the
(print-size) block alone — a whole-output diff is dominated by timing lines
and a hash-ordered rule report, and looks nondeterministic on every file.
- A contended box cannot resolve a 100 ms phase effect. A -219 ms merge result
taken under load became -116 ms when the same pair was re-run alone. Check load
before trusting a timing, and prefer a single-file run at more rounds over a
six-file run when the effect is small.
perf, gdb and samply do not work in the container (seccomp blocks
perf_event_open). Deterministic instrumentation inside
ExecutionState::run_instrs — the function the engine's own Apply timer wraps —
reproduced the reported Apply total to within 3%, and is the way to get a
breakdown here.
Work deliberately left out of the encoding-rework PR, which is already large.
proof_encoding_rework.mdon that branch is the PR's plan and record; this issue iswhat comes next, with the evidence for each so nobody has to re-derive it.
Where a number appears here it was measured on
math-microbenchmark.eggunder--proofs -j 1unless said otherwise, against that PR's final state.1. A write-only relation still gets a full table
Every table is a
SortedWritesTable(egglog-bridge/src/lib.rs:748), so a proofrelation pays for FD key semantics over its key columns, sorted storage,
dedup-on-merge, a rebuild column list, and a merge callback.
None of that is needed for one:
occurrence of
@Sym/@Trans/@Congr/@Rule_k/@RebuildEq_k/@DisplacedShared*/@Fiat/@MergeIdx/@MergeRowin the generatedprogram is inside a
(set …).from
get-fresh!, so keys are unique by construction.@Proofvalue, so there is nothing to rebuild.a row nothing names is unreachable rather than merely unread.
The cost is not in Apply.
Instr::Insertis already a plain append into aper-table staging buffer; FD enforcement and merge dispatch happen in
SortedWritesTable::merge, and index maintenance is lazy, on read during Search.So look in Merge and in index construction, not in the write.
Supporting evidence that Merge is not paying for row volume. Packing each
merge collision's
Sym+Transinto one row halved merge-body proof rows(corpus-wide 2920 statements to 1472; 2 rows per collision to 1) and moved merge
time by only -116 ms of 2.02 s, 5.7% (
a932b22to70479df, 8 rounds,disjoint intervals). Merge cost is therefore mostly per-row machinery rather than
the number of proof rows, which is exactly what a cheaper table for these
relations would address.
Two scoping corrections to make before acting. "Term tables are write-only"
is true only of the proof relations above:
<S>Proof(@MathProof) is read — rebuild heads look it up action-side(
(let @pv13 (@MathProof c0_))), which is 8% of Apply on its own.@AddView,@NumView) and@UF_<S>are queried in everyrule body and are not candidates.
2.
stage_batchon the merge pathThe Apply work hoisted three per-row costs out of
Instr::Insert/Remove: theNotificationList::notify(anArcSwapguard plus aConcurrentVec::read, foran idempotent dirty flag), the
Mask::iter_dynamicpool round-trip that everytable wider than four columns paid to hold one row, and the mutation-buffer
resolution. That was -31% Apply (1.848 s to 1.267 s;
eggcc-2mm0.348 s to0.235 s).
Merge functions, container rebuild and
TableAction::insertall still go throughExecutionState::stage_insertone row at a time, so they still pay all three.ExecutionState::stage_batchnow exists. Every proof relation is 5-9 columnswide, so the
iter_dynamicpath applies.This is the best-evidenced remaining lever on Merge, and it is independent of
item 1.
3. Updating a row in place
A view rebuild emits a delete and an insert for the same logical row:
Eight such pairs in a two-constructor program. An in-place update would drop the
delete. Sizing:
Instr::Removeis 34 ms, 2% of Apply (0.74 M row-lanes at45 ns), so the Apply win is small on its own — the open question is whether it
also avoids re-sorting and re-dedup in Merge, which is where item 1 says the cost
lives. Worth measuring together with item 1 rather than separately.
Note the delete must stay ordered before the insert: when only the e-class moved,
the canonical key equals the old one, so deleting afterwards would drop the row
just written.
4.
get-fresh!as an instruction9.6 M calls at 12 ns each, ~110 ms, about 8% of Apply — all
dyn invokeoverheadaround one
fetch_add. It is not a dispatch-on-string problem: the sort name isa constant
QueryEntryresolved at rule-build time and never hashed at runtime,and 12 ns is the
invoke_batchfloor.WriteVal::IncCounterandInstr::ReadCounteralready exist, so the loweringmachinery is there; the work is threading the primitive through typechecking into
the rule builder.
5. Deferred from the rework plan
Fiat, thendeleting the
@Astsort. All three wait on one decision: which program theFiatcolumn counts against, sincelower_inputsruns beforeremove_globalsand the encoder's column counter lives after it.runs before proof encoding and reshapes rule heads, so the encoder and the
checker would see different heads), and restore the two
ddnightlyendpoints.
6. Known and not fixed
file_supports_proofsand thenpanics in the checker (
Fiat proof claims 10 = 10, which is not established by globals), becausereflexive_value_termrecognizes neither the container headnor a non-eq container as a value. Pre-existing; no corpus file does it.
Mask::iter_dynamic's per-row pool round-trip is still on theLookupOrInsertDefaultand external-function paths, so a future wide-arity hotinstruction will re-pay what the Apply work removed from
Insert.set-if-emptyandview-proofeach take anRwLock::read()plus aHashMap<String, _>lookup per row inActionRegistry, worth ~43 ms (3%).Measured and rejected:
EGraph::remove_last_tablecan replace or remove aname's
TableAction, so a memo can go stale, and making it safe needs ageneration counter threaded outside the lock.
7. How to measure without fooling yourself
eggcc-2mm,hardboiled_conv1d_32,integer_math,math-microbenchmark,math-microbenchmark-mini,web-demo/eqsolve) do not reproduce their own proof-table row counts, sincethose take a row per merge collision. The e-graph is deterministic:
(print-size)and every rule'snum matchesreproduce exactly. Diff the(print-size)block alone — a whole-output diff is dominated by timing linesand a hash-ordered rule report, and looks nondeterministic on every file.
taken under load became -116 ms when the same pair was re-run alone. Check load
before trusting a timing, and prefer a single-file run at more rounds over a
six-file run when the effect is small.
perf,gdbandsamplydo not work in the container (seccomp blocksperf_event_open). Deterministic instrumentation insideExecutionState::run_instrs— the function the engine's own Apply timer wraps —reproduced the reported Apply total to within 3%, and is the way to get a
breakdown here.