Semi-naive evaluation for the metta_calculus loop (opt-in semi_naive_ic): process_calculus 127x - #128
Semi-naive evaluation for the metta_calculus loop (opt-in semi_naive_ic): process_calculus 127x#128MesTTo wants to merge 5 commits into
Conversation
query_multi_raw_with_unification_sources lets a caller descend RENORMALIZED factors (reordered, variables renumbered so the leading factor introduces the NewVars and later factors back-reference them) while the per-candidate pairs are built from the ORIGINAL sources, so bindings stay keyed in the pattern's own namespace. query_multi_raw delegates with identity sources; behavior is unchanged for every existing caller. renormalize_query_factors (+ helpers) re-encodes factors in a plan order with that renumbering, declining plans whose variables do not re-encode (>= 64 distinct). Groundwork for matching a body with one factor sourced from a different map and made the descent primary.
…tion The bound branch of the VarRef arm captured a raw pointer into the ProductZipper's path buffer for the recorded data subterm and recursed while holding it. The recursion grows the matched path; past the buffer's reserved capacity it reallocates and the pointer dangles, so the re-match decodes garbage tag bytes (a byte_item "reserved" panic on deep terms). Latent today because current descents rarely re-check a deep bound payload; a reordered descent (one that seeks a small factor first and re-checks the rest) hits it deterministically, e.g. process_calculus at n >= ~172 where the concatenated product path crosses the reserve mid-recheck. Copy the bound subterm into an owned buffer that lives across the recursion and point the re-match at that. Guarded by coreferential_recheck_survives_path_buffer_realloc_on_deep_terms in the semi-naive delta's test battery: it panics on the un-fixed matcher and is byte-identical on the fixed one.
…aive_ic) Every step of the metta_calculus loop re-matched each exec's ,-rule against the whole accumulating space: n steps over a dish growing to ~n facts is ~n^2 work, and most of it re-derives what earlier firings already produced. With the feature on, the loop keeps a per-rule frontier (the match input the last time THAT rule fired, keyed by pattern+template bytes so the exec re-arming does not reset it) and fires the standard m-delta-rule expansion instead: factor j matches only the delta, the other factors the full space, unioned over j (the idempotent insert dedups). Per pass the factors are reordered so the delta factor drives the descent and re-encoded via renormalize_query_factors; the per-candidate unification runs over the ORIGINAL sources, so bindings stay in the pattern's namespace and the emit is the naive path's own, per-match seed recompute included. Routing is hybrid per firing: m * delta_count < dish_count sends the firing to the delta (process_calculus: all but the first firings), everything else to the untouched naive path (clique, finite_domain: every firing). Deltas come from PathMap::subtract on COW clones, which prunes shared subtrees (measured ~5us diffing a 500k-fact clone-plus-one; pinned by tests/subtract_probe.rs), and dish counts are cached and maintained from delta/removed counts, never re-walked. Any O(-) removal latches sni_removal_seen and the rest of the loop stays naive: the delta recurrence is byte-identical to naive only while evaluation is add-only. MORK_SNI=0 (or the thread-local SNI_DISARM) reverts to naive at runtime. Measured on this box: process_calculus 32.4s -> 1.70s (19x; unifications 201401 -> 1602, instructions 428M -> 2.5M), exponential 0.6s -> 0.1s. Oracles: per-step m-delta invariants (ground, schematic, and a two-template split with a free data variable), an end-to-end byte-identity oracle over the full process_calculus loop (x=y=8,16), a per-step LOCKSTEP oracle that pins any divergence to the first offending step with raw byte dumps, a removal-latch test, and the deep-term realloc guard. The default build compiles the feature out and is untouched.
The m*delta < dish size model mis-prices narrow-prefix rules: their naive match visits a small subtrie of a large dish, so the model routed firings to the delta that lost wall time to its own bookkeeping (counter_machine +15%, tile +5%). A descent-step meter has the mirror blind spot: the delta route's frontier bookkeeping (clone, subtracts, counts) walks no trie, so rules stuck on semi while losing wall time. Route by measured WALL cost instead: each route's last firing records nanos -- the delta route charged from fire entry so bookkeeping is included, the naive route charged for the transform alone (its bookkeeping decays away below) -- and the cheaper measured route wins. Every 257th firing runs the losing route once so a workload shift can flip the routing back (both routes emit identical facts, so probing is free). The size model survives as a bootstrap pre-filter and a delta-spike guard (m*delta >= dish forces naive, e.g. bulk loads). Bound the cost of rules that never profit: - dish counts seed lazily on a rule's first diffed firing, so one-shot rules never pay a count walk (snapshots DO start on first sighting: deferring one costs an extra naive firing, which on a doubling dish is half the total work); - a rule SETTLED on naive (both routes measured, naive cheaper) drops its snapshot and re-arms one probe pair per 257-firing cycle, so frontier maintenance decays to 0.8% once exploration stops paying; - a loop that routes nothing to the delta in its first 512 firings disarms entirely (respawn-heavy programs, or all-spike workloads like clique); - the frontier map is bounded (self-healing clear at 256 rules); - passes whose factor prefix has no delta facts are skipped; - the match-input clone is lazy (naive-routed firings never build it). Measured across the bench suite (MORK_SNI=0 as the same-binary control): process_calculus 31.8s -> 1.2s, exponential 0.6s -> 0.1s, counter_machine / tile / clique / odd_even_sort / finite_domain / taxi_lts / transitive at parity, bfc +0.7% worst case on its respawn-heavy shape. Oracles unchanged and green; the IC oracle also asserts engagement via the per-space sni_semi_firings counter.
|
Awesome! Will leave feedback on the code tomorrow; this is doing more than it needs. |
|
Current numbers on this tip (1ca077e, same box, release, target-cpu=native): |
|
@Adam-Vandervorst Could you be specific in which area that its doing more than it needs? |
The router timed each rule's naive and delta firings with Instant::elapsed and swapped routes on the measured cost, re-probing the loser every 257 firings. Measured interleaved, it costs ~30% on bench process_calculus (0.30s with, 0.23s without) and buys 2-5% on bench bfc, while transitive and clique sit at parity. It also makes the work nondeterministic: the same binary on the same input produces 2069 or 2219 unifications where the deterministic path produces 1602 every run. MORK's validation rests on counters being byte-identical, so a route chosen by wall clock cannot be part of it. The deterministic guards stay: the 512-firing disarm and SNI_MAX_RULES are load-bearing (without them bfc regresses 35%), and they are counters, not clocks. No Datalog engine routes this way. AHV Algorithm 13.1.1 applies delta unconditionally, and Souffle, Differential Dataflow, DDlog, egglog, Ascent, Crepe, CozoDB and Datafrog all gate structurally or on cardinality.
|
the part it doesn't need is the wall-clock router. it times each naive and delta firing with by instruction count:
what's left is the delta loop, the reorder, and the 512-firing disarm + fwiw nothing gates delta on a clock. semi-naive applies it unconditionally, and the engines i looked at gate on structure or cardinality. |
Semi-naive evaluation for the metta_calculus loop (opt-in
semi_naive_ic)What
Every step of the
metta_calculusloop re-matches each exec's,-rule againstthe whole accumulating space. Over n steps with a dish growing to ~n facts that
is ~n² match work, and most of it re-derives facts earlier firings already
produced: on
bench process_calculus, 98.8% of match candidates are redundantre-derivation (201,401 unifications naive vs 2,377 with this PR).
With the feature on, the loop keeps a per-rule frontier: the match input the
last time THAT rule fired, keyed by pattern+template bytes so the exec
re-arming does not reset it. A firing then runs the standard semi-naive
delta-rule expansion (one pass per body factor j: factor j matches only the
delta, the others the full space, unioned; the idempotent insert dedups),
instead of the naive full match. This is the classic Datalog/egglog semi-naive
transform applied per-rule inside MORK's immediate-consequence loop; the
transform itself is expressible as an MM2 source rewrite, this PR just fires it
engine-side where the frontiers live.
Per pass the factors are reordered so the delta factor drives the descent, and
re-encoded (
renormalize_query_factors) so its variables become the leadingNewVars; the per-candidate unification still runs over the ORIGINAL sources
(
query_multi_raw_with_unification_sources), so bindings stay keyed in thepattern's own namespace and the emit is exactly the naive path's, per-match
seed recompute included.
Routing: measured wall cost, per rule, self-correcting
Whether the delta pays is a property of the rule AND the instance, so routing
is hybrid per firing and calibrated by measurement, not a size model (a model
in fact counts mis-prices narrow-prefix rules; a descent-step meter is blind
to the delta route's bookkeeping):
from fire entry so its frontier bookkeeping is included, the naive route
charged for the transform alone;
once to refresh its measurement (both routes emit identical facts, so a
probe never changes results);
m*delta < dishallows,and a delta spike (
m*delta >= dish, e.g. a bulk load) forces naive;cycle, so frontier maintenance decays to 0.8% when it stops paying;
entirely; passes whose factor prefix has no delta facts are skipped.
Deltas come from
PathMap::subtracton COW clones, which prunes sharedsubtrees (measured ~5µs diffing a 500k-fact clone-plus-one; pinned by
kernel/tests/subtract_probe.rs). Dish counts seed lazily on a rule's firstdiffed firing, so one-shot respawned rules (bfc's counter-carrying inner
execs) never pay a count walk, and the frontier map is bounded (self-healing
clear at 256 rules).
Any
O (- ...)removal latchessni_removal_seenand the rest of the loopstays naive: the delta recurrence is byte-identical to naive only while
evaluation is add-only.
MORK_SNI=0(or the thread-localSNI_DISARM)reverts to naive at runtime; the default build compiles the feature out and
its naive path is untouched.
Measured (this box, same binary, MORK_SNI=0 as control)
process_calculus counters: unifications 201,401 → 2,377; transitions 428M → 4.4M.
bfc is the adversarial shape for this feature (it respawns fresh-keyed
counter-carrying rules every round, so nothing recurs for a frontier to pay
off); the disarm caps its overhead at a profile-flat ~0.7%.
Correctness
factor, and a two-template split with a free data variable):
delta_emit ⊆ naive(post)anddelta_emit ∪ naive(pre) == naive(post),plus the exact-difference form where instantiation is injective.
metta_calculus, byte-identical final dish (x=y=8,16), result channelchecked against peano(x+y), and an engagement assert (the router actually
fired the delta).
every exec; a divergence is pinned to the first offending step with raw
byte dumps.
Also in this PR
coreferential_transition's VarRef recheck(own commit): the bound branch captured a raw pointer into the
ProductZipper's path buffer and recursed while holding it; past the buffer's
reserved capacity the recursion reallocates it and the pointer dangles
(
byte_item"reserved" panic on deep terms). Latent on current descentorders; the delta's reordered descent hits it deterministically
(process_calculus n≥~172). The fix copies the bound subterm to an owned
buffer before recursing.
query_multi_raw_with_unification_sources+renormalize_query_factors(own commit): descend renormalized/reordered factors while unifying in the
original namespace.
query_multi_rawdelegates with identity sources;behavior unchanged for every existing caller.
The delta transform intentionally duplicates the naive emit body rather than
abstracting it: the naive path stays textually untouched (reviewable as a pure
extract-rename), and the lockstep oracle pins the two emits to byte-equality
so they cannot drift silently.