code wiki / (root) / nx_eqsat.nx

nx_eqsat.nx source

↩ module page · 2201 lines · 116362 B

1// nx_eqsat.nx -- equality-saturation framework (egg/SpEC family). 2// 3// Foundation for the superoptimization arc per ZERO_TO_ADVANCED.md M2: 4// "the system is the FASTEST possible at every layer". Equality 5// saturation is the 2026-research-grade approach -- developed by 6// Willsey/Tate/Bornholt (egg, 2021) + Tate et al (Equality Saturation 7// for Compiler Optimization, 2009), proven on Souper/Cranelift et al 8// production compilers. 9// 10// Why equality saturation vs traditional peephole: 11// Peephole: apply one rewrite, commit. If the rewrite was bad you 12// lose; if a better cascade existed but required first 13// un-doing a rewrite, you never find it. Phase-ordering 14// tyranny. 15// EqSat: keep ALL equivalent expressions in an e-graph. Apply 16// all rewrites breadth-first to saturation. Extract the 17// lowest-cost representative. Provably optimal under the 18// rule set + cost model. 19// 20// For RV64IM: lets the compiler find non-obvious wins like 21// (add x x) -> (shl x 1) (when shl is faster) 22// (shl (shl x a) b) -> (shl x (add a b)) (kill dependent chain) 23// (mul x 2^k) -> (shl x k) (strength reduction) 24// (and x (not (and ~a y))) -> ... (DeMorgan reroute) 25// 26// AND once silicon-feedback (rv64im_min_hot_report.nx) identifies hot 27// patterns, those become silicon-aware rewrites: 28// (popcount-naive-loop) -> (intrinsic popcount) when silicon has it 29// (gemm-inner-product) -> (vec mac fused) when SIMD lands 30// 31// Status: SEED. 2026-05-26. V1: e-class union-find + rule registry + 32// bounded saturation loop + greedy extractor. ~50 baseline rewrites 33// for RV64IM; extends per silicon-feedback findings. 34 35import "nx_syscalls.nx" 36import "nx_sketch_hash_map.nx" // sovereign (i64,i64) open-addressing primitive (DRY, Cardinal #15) -- the O(1) hashcons backing store 37import "nx_nxgate_sim.nx" // PROVEN op-semantics oracle (nx_gsim_eval_cell + NX_GATE_KIND_*) -- reused by the const-fold analysis (DRY). Import-dedup-safe (nx_import seen-set inlines it once; same organ nx_alu_divider already pulls it transitively). 38// PURPOSE-NAMED 2026-08-14. These two were NX_EQSAT_CF_CACHE_SLOTS and NX_EQSAT_COST_INFINITY -- names that 39// restate the digits and are LESS greppable than the bare literal. Both purposes were legible from 40// their single use site, and in one case the author had already written the right name in the comment 41// beside it ("CF_CACHE_CAP slots") while shipping the value-restating one in the code. 42// Slots in the const-value intern cache armed by nx_eqsat_enable_constfold: a power-of-2 43// open-addressing map so a folded CONST is O(1) instead of an O(n) node scan. 1024 slots x 2 i64 44// = 16 KB (4 pages) -- small enough that page-fault cost at init is negligible, large enough to 45// stay sparse. On miss or full it fail-OPENS to the correct O(n) add_const, so this number is a 46// SPEED knob and can never affect correctness. 47const NX_EQSAT_CF_CACHE_SLOTS: i64 = 1024 48// The cost-infinity sentinel for the extraction fixpoint: every canonical class starts at this 49// cost meaning "no finite-cost representative found yet", and a child still sitting at it makes its 50// parent uncostable this round. It must exceed any reachable real cost (a sum of per-op costs over 51// the DAG) while leaving headroom below i64 overflow when costs are added. 52const NX_EQSAT_COST_INFINITY: i64 = 1000000000 53 54// Operating width: (mul x 2^k)==(shl x k) is sound only for k in [0, W). At k>=W, 55// 2^k mod 2^W == 0 so x*2^k collapses to 0 while x<<k is out of range -- proven 56// UNSOUND by nx_rule_soundness (k>=W 2048/2048). The rule refuses to fire there. 57// 58// HOISTED TO MODULE HEAD 2026-08-14 -- THIS IS WHY THIS FILE HAD NEVER COMPILED. The const 59// was declared at line 1361 but READ at lines 990 and 1026, and nx_cc refuses a module const 60// used before its declaration precisely because it would otherwise SILENTLY READ 0. Reading 0 61// here is the worst possible value: every `v1 >= NX_EQSAT_W` shift guard and every `k < NX_EQSAT_W` 62// soundness guard would invert, so the const-fold and the mul-to-shl strength reduction would fire 63// exactly where nx_rule_soundness proved them UNSOUND. The compiler caught a latent miscompile, 64// not a style nit. 65// Nobody had noticed because A LEAF LIB IS NEVER BUILT DIRECTLY: nx_eqsat has 10 importers and 66// every one of them is its own test, bench or proof, and not one of those had ever been compiled 67// either -- so the whole subtree failed silently together. 68const NX_EQSAT_W: i64 = 64 69 70// ===== Expression node kinds (sealed enum) ================================================= 71// 72// Sealed at V1; extended as the rewrite set grows + new IR ops land. 73// Each kind has an arity (n_children) declared via nx_eqsat_arity_for. 74 75const NX_EQ_OP_CONST: i64 = 0 // 0 children; payload = i64 value 76const NX_EQ_OP_VAR: i64 = 1 // 0 children; payload = variable id 77const NX_EQ_OP_ADD: i64 = 2 // 2 children 78const NX_EQ_OP_SUB: i64 = 3 // 2 children 79const NX_EQ_OP_MUL: i64 = 4 // 2 children 80const NX_EQ_OP_DIV: i64 = 5 // 2 children 81const NX_EQ_OP_REM: i64 = 6 // 2 children 82const NX_EQ_OP_AND: i64 = 7 // 2 children 83const NX_EQ_OP_OR: i64 = 8 // 2 children 84const NX_EQ_OP_XOR: i64 = 9 // 2 children 85const NX_EQ_OP_NOT: i64 = 10 // 1 child 86const NX_EQ_OP_SHL: i64 = 11 // 2 children 87const NX_EQ_OP_SHR: i64 = 12 // 2 children (logical) 88const NX_EQ_OP_SAR: i64 = 13 // 2 children (arithmetic) 89const NX_EQ_OP_NEG: i64 = 14 // 1 child 90const NX_EQ_OP_EQ: i64 = 15 // 2 children 91const NX_EQ_OP_LT: i64 = 16 // 2 children (signed) 92const NX_EQ_OP_LTU: i64 = 17 // 2 children (unsigned) 93const NX_EQ_OP_SELECT: i64 = 18 // 3 children (mux) 94const NX_EQ_OP_POPCNT: i64 = 19 // 1 child (Zbb-equivalent) 95const NX_EQ_OP_N: i64 = 20 96 97func nx_eqsat_op_is_valid(op: i64) -> i64 { 98 if op < 0 { return 0 } 99 if op >= NX_EQ_OP_N { return 0 } 100 return 1 101} 102 103func nx_eqsat_arity_for(op: i64) -> i64 { 104 if op == NX_EQ_OP_CONST { return 0 } 105 if op == NX_EQ_OP_VAR { return 0 } 106 if op == NX_EQ_OP_NOT { return 1 } 107 if op == NX_EQ_OP_NEG { return 1 } 108 if op == NX_EQ_OP_POPCNT { return 1 } 109 if op == NX_EQ_OP_SELECT { return 3 } 110 return 2 111} 112 113// ===== E-node ================================================= 114// 115// An e-node is an op + a vector of e-class IDs (its children). Two 116// e-nodes are equivalent iff they have the same op + their children 117// resolve to the same e-classes (canonical reps). 118 119struct NxENode { 120 op: i64 121 n_kids: i64 122 kid0: i64 // e-class id; -1 if unused 123 kid1: i64 124 kid2: i64 125 payload: i64 // CONST value or VAR id 126 eclass: i64 // owning e-class id (canonicalised) 127} 128 129// ===== E-class ================================================= 130// 131// An equivalence class. Contains a set of equivalent e-nodes + a 132// canonical-id link for the union-find (egg-style). 133 134struct NxEClass { 135 canon: i64 // canonical id (this == self.id while not merged) 136 cost: i64 // best extraction cost in this class 137 best_node: i64 // node-array index of the cheapest 138 // ----- CONST-FOLD analysis lattice (egg e-class analysis) -- APPENDED ----- 139 // cf_val is the EXPLICIT const-analysis lattice element of this canonical class 140 // (egg's Analysis::Data = Option<i64>): NX_CF_NONE (i64 min) = "unknown/bottom" 141 // (no known constant); any other value = "this class is known-constant == cf_val". 142 // It is the MAKE/JOIN/MODIFY analysis store that replaces the old per-iteration 143 // full node-scan const-fold DISCOVERY with O(1)-amortized incremental work: 144 // MAKE (nx_eqsat_new_class / the add fast-path) -- born known for CONST nodes, 145 // computed once from children's cf_val for a foldable all-const op. 146 // JOIN (nx_eqsat_union_cited) -- lattice-meet flows the const 147 // along every union/congruence edge into VAR-rooted classes in O(1). 148 // MODIFY -- the NONE->known transition 149 // lazily materializes the CONST merge ONCE through the cited chokepoint. 150 // Appended AFTER best_node (no reorder; NxEClass 24B -> 32B, fits the 151 // pre-allocated cap_cls*32 stride EVERY gate harness mmaps). ALWAYS written in 152 // new_class; READ only when cf_on==1, so the cf_on==0 trajectory is byte-identical 153 // (dead state on the page-rounded class array, exactly like prov/meet/dsl slack). 154 cf_val: i64 155} 156 157// Const-analysis lattice SENTINEL: i64 minimum (0x8000000000000000) = "no known 158// constant / lattice bottom". Chosen as a value the gate-sim evaluator can in 159// principle produce, but the lattice tags by PRESENCE (a class whose true folded 160// value is i64-min still carries a real CONST best_node), so the sentinel collision 161// only means "MODIFY may re-run" which is idempotent + harmless (see make_join_modify). 162const NX_CF_NONE: i64 = 0 - 0x7FFFFFFFFFFFFFFF - 1 163 164// ===== RULE DSL (egg-style data-driven rewrite) ============================= 165// 166// A rewrite rule becomes a FLAT DATA RECORD (NxDslRule) interpreted by ONE 167// general e-matcher (nx_eqsat_apply_dsl_rule) -- the egg breadth path: many 168// rules cheaply + the SELF-AUTHORING hook (a new rule is just a data row the 169// invention loop can propose). The 7 hand-coded inline rules are RE-EXPRESSED as 170// a 7-row table (nx_eqsat_builtin_dsl_table) whose behavior is BYTE-IDENTICAL to 171// the inline functions (proven by the parity harness). The DSL path is OPT-IN 172// (NxEGraph.dsl null by default => the inline path runs verbatim). 173// 174// A rule's LHS is the shallow shape (lhs_op slotA slotB). Each slot is tagged: 175// DSL_VAR -- binds ?A/?B to ANY canonical e-class (a pattern variable) 176// DSL_CONST(val) -- the class's best_node must be a CONST whose payload == val 177// DSL_SAME_AS_A -- the class id must equal the already-bound ?A (the "x OP x") 178// The RHS is a CLOSED enum instantiated through the existing add* wrappers (no RHS 179// interpreter, no pointer pattern-tree, no allocator): 180// RHS_BIND_A | RHS_BIND_B | RHS_CONST(val) 181// RHS_SHL_A_BY_CONST(amt) | RHS_SHL_A_BY_LOG2B (strength-reduction shapes) 182// side_cond replays a width guard as DATA (SC_POW2_B_KLTW = the proven k<W 183// mul_pow2 guard). count_mode replays mul_pow2's real-merge counting (the only 184// mode that converges to SATURATED). dual_order replays firing both child 185// orderings (add_zero/or_zero) vs right-const-only (mul_one). 186 187const DSL_VAR:i64 = 0 188const DSL_CONST:i64 = 1 189const DSL_SAME_AS_A:i64 = 2 190 191const RHS_BIND_A:i64 = 0 192const RHS_BIND_B:i64 = 1 193const RHS_CONST:i64 = 2 194const RHS_SHL_A_BY_CONST:i64 = 3 195const RHS_SHL_A_BY_LOG2B:i64 = 4 196// RHS_SHL_A_BY_IADDED_J: emit (shl bindA (i+j)) where (i+j) is the COMPUTED summed 197// shift the shape-(d) shift-merge matcher hands to instantiate_rhs via `shamt` 198// (exactly as RHS_SHL_A_BY_LOG2B reuses shamt for the mul_pow2 log2). The DSL row 199// holds rhs_val=0 (the table is DATA -- it cannot carry the dynamic i+j); the 200// matcher reads i,j from the two CONST child classes and computes i+j itself. 201const RHS_SHL_A_BY_IADDED_J:i64 = 5 202 203const SC_NONE:i64 = 0 204const SC_POW2_B_KLTW:i64 = 1 205// SC_SHIFT_MERGE_IJW: the shift-merge side-condition carrying the i+j<W guard as 206// DATA (exactly as SC_POW2_B_KLTW=1 carries the mul_pow2 k<W guard). It also selects 207// the shape-(d) nested-SHL matcher: LHS is SHL(SHL(?x ?i) ?j), ?i,?j CONST, i+j<W. 208// At i+j>=W the merged (shl x (i+j)) count is out of range / x86-count-masked, so the 209// identity is UNSOUND (proven by nx_rs_shiftmerge_boundary_unsound) -- the matcher 210// refuses to fire there, mirroring mul_pow2's k<W refusal. 211const SC_SHIFT_MERGE_IJW:i64 = 2 212 213const CNT_EVERY:i64 = 0 // count every match (the 6 V1 rules' behavior) 214const CNT_REAL_MERGE:i64 = 1 // count only real find!=find merges (mul_pow2) 215 216// A rewrite rule as data. lhs is (lhs_op a_kind/a_val b_kind/b_val); rhs is the 217// closed-enum template (rhs_kind/rhs_val). side_cond + count_mode + dual_order 218// replay the per-rule behavioral knobs the inline functions hard-coded. 219struct NxDslRule { 220 rule_id: i64 221 lhs_op: i64 222 a_kind: i64 223 a_val: i64 224 b_kind: i64 225 b_val: i64 226 rhs_kind: i64 227 rhs_val: i64 228 side_cond: i64 229 count_mode: i64 230 dual_order: i64 231} 232 233// ===== E-graph ================================================= 234// 235// V1 fixed-capacity storage. Caller allocates the nodes + classes 236// arrays; the e-graph owns indices into them. 237 238// PROVENANCE FIELDS (prov..prov_overflow) are APPENDED -- never reorder 239// nodes..valid; every caller depends on those offsets. They default OFF (prov 240// null) so the four gated organs behave byte-identically. When enabled via 241// nx_eqsat_enable_prov, the single union chokepoint (nx_eqsat_union_cited) logs 242// the citing rule-id of EVERY real merge -- a merge cannot happen without a 243// rule-id flowing through the one function that mutates canon (no bypass). 244struct NxEGraph { 245 nodes: *NxENode 246 n_nodes: i64 247 cap_nodes: i64 248 classes: *NxEClass 249 n_classes: i64 250 cap_classes: i64 251 valid: i64 252 prov: *i64 // APPENDED: rule-id log buffer; null => provenance OFF 253 n_prov: i64 // APPENDED: # rule-ids logged so far 254 cap_prov: i64 // APPENDED: capacity of prov[] 255 prov_overflow: i64 // APPENDED: 1 iff a logged merge could not fit (fail-closed) 256 // ----- MEET fields (egg hashcons + congruence rebuild) -- APPENDED, opt-in ----- 257 // All default null/0 in nx_eqsat_init so EVERY existing caller + the four gated 258 // organs stay byte-identical (the linear-scan path runs when hc==null and the 259 // rebuild path is skipped when rebuild_on==0). Turned on per-e-graph via 260 // nx_eqsat_enable_meet, which hands the engine caller-owned scratch. 261 hc: *HashMap // hashcons: fingerprint(op,payload,canon kids) -> node-array idx; null => O(n) linear scan 262 par_node: *i64 // parent index: par_node[i] = a node that references par_cls[i] as a (canonical) child 263 par_cls: *i64 // parallel array: the canonical child-class of that parent edge 264 n_par: i64 // # parent edges recorded 265 cap_par: i64 // capacity of par_node/par_cls 266 par_overflow: i64 // 1 iff a parent edge could not fit (rebuild then falls back to full-scan -> still complete) 267 worklist: *i64 // dirty canonical e-class ids pending congruence repair 268 n_work: i64 // # entries in worklist 269 cap_work: i64 // capacity of worklist 270 rebuild_on: i64 // 1 => unions push dirty classes + saturate calls rebuild; 0 => original loop verbatim 271 // ----- DSL fields (egg data-driven rewrite) -- APPENDED, opt-in ----- 272 // All default null/0 in nx_eqsat_init so EVERY existing caller + the four gated 273 // organs stay BYTE-IDENTICAL: when dsl==null, nx_eqsat_saturate runs the verbatim 274 // 7 inline rule calls; when dsl!=null it runs the general e-matcher over the table. 275 // Turned on per-e-graph via nx_eqsat_enable_dsl (caller hands a *NxDslRule table). 276 // Appending here is safe: every test over-allocates NxEGraph via page-rounded 277 // sys_mmap (4096-byte page for a <200-byte struct), so the new fields fit. 278 dsl: *NxDslRule // rule table; null => DSL OFF (inline path runs) 279 n_dsl: i64 // # rules in the table 280 cap_dsl: i64 // capacity (diagnostic; n_dsl is authoritative) 281 // ----- CONST-FOLD field (egg e-class analysis) -- APPENDED, opt-in ----- 282 // The const-fold analysis hook in nx_eqsat_add is GATED on this flag (default 0 283 // in nx_eqsat_init, set only by nx_eqsat_enable_constfold), mirroring the 284 // prov/meet/dsl append-only + opt-in precedent. When cf_on==0 the hook is a 285 // no-op so EVERY existing caller's node/class/prov trajectory is byte-identical 286 // BY CONSTRUCTION. One i64 appended after the dsl block; NxENode (56B/64B stride) 287 // and NxEClass (24B) get ZERO new fields. NxEGraph is page-rounded via sys_mmap 288 // (4096-byte page for a <300-byte struct) so the new field fits with no reorder. 289 cf_on: i64 // 1 => const-fold fires at node creation; 0 => OFF (default) 290 // ----- CONST-VALUE INTERN CACHE (the O(1) MODIFY backing store) -- APPENDED ----- 291 // The const-fold MODIFY step materializes CONST(value) e-classes; the V1 292 // nx_eqsat_add_const did an O(n) linear node scan to hashcons each one, which -- 293 // doubled with the O(n) binop scan -- IS the measured ~10x const-fold-bench lapp 294 // (build phase = ~96% of the bench, profiled). cf_cache is a tiny open-addressing 295 // (value -> class-id) intern map so an interned CONST is O(1) instead of O(n), 296 // turning the whole eager-fold build into O(N) amortized (matches egg's interned 297 // Num leaves). Lazily mmap'd by nx_eqsat_enable_constfold (so default callers pay 298 // nothing); null => the cache is OFF and nx_cf_intern_const falls back to the 299 // O(n) nx_eqsat_add_const (always correct, just slower). Appended after cf_on 300 // (no reorder; NxEGraph is page-rounded via sys_mmap so the new fields fit). 301 cf_cache: *i64 // 2*cap entries: [value, class_id] pairs; null => cache OFF 302 cf_cache_cap: i64 // # slots (power-of-2); 0 => OFF 303 cf_cache_n: i64 // # live entries (for load-factor / fail-open-to-scan) 304 cf_poison: i64 // 1 iff a JOIN saw a contradiction (two unequal consts in 305 // one class) -> fail-CLOSED soft poison (NOT sys_exit; 306 // Global Rule #14 graceful degradation -- a library must 307 // not crash its host on internal state). Surfaced for a 308 // future certifier reject; never reached by a sound graph. 309 cf_scratch: *i64 // GRAPH-LIFETIME scratch (>=4 i64) for the fold child-value 310 // reads. Allocated ONCE in enable_constfold so the hot 311 // fold path needs ZERO per-node sys_mmap (the per-call 312 // sys_mmap(8) was the dominant build-phase cost -- each 313 // mmap is a syscall ~1us, x #ops x #iters). null => OFF. 314 // ----- WORKLIST-DRIVEN INCREMENTAL RULE MATCHING -- APPENDED, opt-in (4th layer) --- 315 // egg's keystone scheduler win: the saturate loop must NOT full-scan all n_nodes per 316 // rule per iteration (O(n*K*iters)). Instead it matches rules ONLY against worklist 317 // items: (a) newly-added nodes (tracked by the rising n_nodes frontier, so EVERY 318 // alloc site -- PATH A/B/constfold/RHS -- is covered with zero per-site hooks) and 319 // (b) parents of a class just merged (enqueued by nx_eqsat_union_cited via the parent 320 // back-index). Drained to fixpoint. PROVABLY complete: a shallow-LHS match becomes 321 // newly-possible ONLY when a node is created (a) or one of its children is re- 322 // canonicalized by a union (b) -- egg's rebuild theorem. mwl is a SEPARATE worklist 323 // from the congruence `worklist` (which rebuild clobbers at its top, line g.n_work=0), 324 // so the two never alias. Auto-armed by nx_eqsat_enable_meet (the par-index that (b) 325 // needs lives there, and meet forces hc!=null => ALL nodes go through PATH A => the 326 // par-index is complete -- closing the PATH-B parent-gap trap). match_on default 0 in 327 // nx_eqsat_init => every existing caller + the 6 non-meet certs run the VERBATIM 328 // full-scan saturate => byte-identical by construction. Appended at the tail (no 329 // reorder); NxEGraph is page-rounded via sys_mmap so the new i64s fit. 330 mwl: *i64 // match-worklist: NODE indices pending rule re-match; null => OFF 331 n_mwl: i64 // # entries in the match-worklist 332 cap_mwl: i64 // capacity of mwl 333 match_on: i64 // 1 => worklist-driven incremental match; 0 => verbatim full-scan 334 mwl_overflow: i64 // 1 iff a push could not fit -> saturate fail-OPENS to full-scan 335 // (provably complete fallback, just the old O(n) scan) 336} 337 338// ===== HOW MANY BYTES AN NxEGraph ACTUALLY NEEDS ============================================ 339// ADDED 2026-08-14 after this struct's own growth silently corrupted every caller. 340// 341// THE DEFECT: TWENTY-SEVEN call sites across sixteen files hardcoded a byte count for this struct -- 342// 128, 256 and 96 -- and NOT ONE of them was 280. The struct 343// is now 35 i64-wide fields = 280 BYTES. Field 17 (`hc`) sits at offset 128 -- so the ENTIRE meet, 344// DSL, const-fold and match-worklist state, which is exactly the state the congruence, dsl-parity, 345// constfold and membership proofs are written to certify, was being written and read OUT OF BOUNDS. 346// 347// WHY IT HID: three separate comments in this very struct assert that "NxEGraph is page-rounded via 348// sys_mmap (4096-byte page for a <300-byte struct) so the new fields fit". That was true when every 349// small request got its own 4 KiB page, and it stopped being true on 2026-08-07 when NXA_SMALL_MAX 350// moved 64 -> 256 and a 128-byte request began coming from the shared small arena. The identical 351// signature -- ARENA-OVERRUN prev_alloc_size=128 -- is recorded in rv64im_min_sim.nx:111 for the 352// same root cause on a different struct. 353// -- AN OUT-OF-BOUNDS WRITE ABSORBED BY PAGE SLACK IS A BUG YOUR ALLOCATOR PAYS 4KB APIECE TO HIDE, 354// AND IT SURFACES THE DAY THE ALLOCATOR STOPS PAYING. 355// 356// HOW THE POPULATION WAS FIRST UNDERCOUNTED, recorded because the mistake is more instructive than 357// the fix: the first sweep grepped for the literal string `sys_mmap(128) as *NxEGraph` and reported 358// "nine sites across five files" as though that were the population. It was the population OF ONE 359// SPELLING. The same struct was also being allocated at 256 and at 96, and the 96-byte sites are a 360// 184-byte overrun -- far worse than the ones that were found. The honest scan keys on the CAST, not 361// on the size: grep `as *NxEGraph` and read every hit. 362// -- A SWEEP THAT MATCHES THE WRONG VALUE REPORTS A CLEAN POPULATION AND LEAVES THE WORST SITES LIVE. 363// The overruns were surfaced by the arena's own ring detector, not by the sweep: nx_eqsat_dsl_parity_test 364// printed ARENA-OVERRUN prev_alloc_size=256 eight times WHILE PASSING 15/15, which is the whole reason a 365// green verdict is not a substitute for reading the output. 366// 367// 288 = 280 rounded up to the next multiple of 8, i.e. one spare i64. It is deliberately NOT a 368// generous round number: the value must track the field count, and a large cushion would let the 369// struct grow past it unnoticed all over again. APPENDING A FIELD MEANS RAISING THIS CONSTANT. 370const NX_EQSAT_GRAPH_BYTES: i64 = 288 371 372const NX_EQSAT_OK: i64 = 0 373const NX_EQSAT_BAD_OP: i64 = 1 374const NX_EQSAT_BAD_ARITY: i64 = 2 375const NX_EQSAT_OVERFLOW: i64 = 3 376const NX_EQSAT_SATURATED: i64 = 4 377const NX_EQSAT_STEP_BUDGET: i64 = 5 378 379// ===== Rule identifiers (SINGLE SOURCE OF TRUTH) ============================ 380// 381// The saturator cites one of these at each union site so the live provenance 382// log records WHICH rule created each merge edge. The membership-proof organ 383// imports + asserts MP_RULE_* == these (lockstep, no parallel namespace drift). 384// NX_EQSAT_RULE_NONE is the sentinel for any merge that did NOT cite a rule 385// (e.g. a direct nx_eqsat_union call) -- it is NOT in any proven-sound set, so 386// an unlogged-but-cited-NONE merge POISONS a certificate (fail-closed bypass). 387 388const NX_EQSAT_RULE_NONE: i64 = 0 389const NX_EQSAT_RULE_ADD_ZERO: i64 = 1 390const NX_EQSAT_RULE_SUB_SELF: i64 = 2 391const NX_EQSAT_RULE_ADD_SELF: i64 = 3 392const NX_EQSAT_RULE_AND_SELF: i64 = 4 393const NX_EQSAT_RULE_OR_ZERO: i64 = 5 394const NX_EQSAT_RULE_MUL_ONE: i64 = 6 395const NX_EQSAT_RULE_MUL_POW2: i64 = 7 396// CONGRUENCE: the structural law of equality -- if a==b (same canonical class) then 397// f(...a...)==f(...b...) because f is a deterministic function of its children. It is 398// width-independent and needs NO arithmetic battery (unlike the 7 identity rules); its 399// soundness proof IS the congruence axiom. Cited by the deferred rebuild ONLY, through 400// the same nx_eqsat_union_cited chokepoint, so it is logged + admitted to the 401// membership-proof's proven-sound allow-list (no RULE_NONE bypass). [CANON: Willsey 402// et al, egg, PLDI 2021 -- deferred rebuild + hashcons invariant.] 403const NX_EQSAT_RULE_CONGRUENCE: i64 = 8 404// XOR_SELF: (xor x x) == 0 -- self-cancellation. Width-independent (a^a=0 holds per 405// bit at ANY W, same per-bit-law class as and_self/or_zero), so the W=8 exhaustive 406// battery lifts to W=64. Admitted to the proven-sound set ONLY after the all-W 407// two-witness soundness battery certifies it (see nx_eqsat_membership_proof.nx 408// mp_battery_xor_self). It is the FIRST rule expressed PURELY as DSL data (one 409// NxDslRule row, zero new matcher control flow) -- the breadth-via-data proof. 410const NX_EQSAT_RULE_XOR_SELF: i64 = 9 411// CONSTFOLD: (op c0 c1 ...) where EVERY child class is a known constant => merge 412// with CONST(eval(op, c0, c1, ...)). This is egg's canonical e-class ANALYSIS 413// (constant folding / propagation): the analysis value of a class is "CONST V" 414// iff its best_node is a CONST node, and it lives IMPLICITLY in NxEClass.best_node 415// (no new struct field). Soundness = the PROVEN op-semantics evaluator 416// nx_gsim_eval_cell (the same oracle the divider/multiplier proofs + the 417// membership-proof's _mp_eval_op_const trust). Only ops whose gate-sim cell is the 418// proven semantics of the eqsat op are foldable (nx_eqsat_op_to_gate_kind admits 419// them; DIV/REM/SAR/SHR/NEG/POPCNT/SELECT are REFUSED -- no faithful cell, see the 420// map). Width-independent: the engine works at W=64 and i64 arithmetic IS mod-2^64, 421// so the raw evaluator output is exactly the W=64 value (no truncation gap). The 422// merge flows the single chokepoint (nx_eqsat_union_cited) citing this id, so it is 423// logged + certifiable. Admitted to the membership-proof allow-list + lockstep. 424const NX_EQSAT_RULE_CONSTFOLD: i64 = 10 425// SHIFT_MERGE: (shl (shl ?x ?i) ?j) == (shl ?x (?i+?j)) for ?i,?j CONST and i+j<W. 426// THEOREM: (x<<i)<<j == x<<(i+j) (mod 2^W) for all i,j>=0 with i+j<W -- left-shift 427// distributes over the mod (2^j is a factor), width-independent by k-induction on 428// (i+j) (the SAME inductive shape mul_pow2 uses), so the W=8 machine-check lifts to 429// W=64. Admitted to the proven-sound set ONLY after the all-W two-witness battery 430// certifies it in-range AND the i+j>=W boundary is shown unsound (the i+j<W guard's 431// home). It is the SECOND rule expressed PURELY as DSL data (one NxDslRule row, 432// shape (d): the LHS first child is itself a structural SHL, distinct from xor_self's 433// shape (a)/mul_pow2's shape (b)/add_zero's const-slot shape (c)). The merge flows 434// the single cited chokepoint (nx_eqsat_union_cited) so it is logged + certifiable. 435const NX_EQSAT_RULE_SHIFT_MERGE: i64 = 11 436const NX_EQSAT_RULE_N: i64 = 12 437 438// ===== Init ================================================= 439 440func nx_eqsat_init(g: *NxEGraph, 441 nodes: *NxENode, cap_nodes: i64, 442 classes: *NxEClass, cap_classes: i64) -> i64 { 443 if (g as i64) == 0 { return 0 - NX_EQSAT_BAD_OP } 444 if (nodes as i64) == 0 { return 0 - NX_EQSAT_BAD_OP } 445 if (classes as i64) == 0 { return 0 - NX_EQSAT_BAD_OP } 446 g.nodes = nodes 447 g.n_nodes = 0 448 g.cap_nodes = cap_nodes 449 g.classes = classes 450 g.n_classes = 0 451 g.cap_classes = cap_classes 452 g.valid = 1 453 // Provenance OFF by default (signature unchanged): null buffer => the union 454 // chokepoint's log call short-circuits, so all existing callers + the four 455 // gated organs are byte-identical. 456 g.prov = 0 as *i64 457 g.n_prov = 0 458 g.cap_prov = 0 459 g.prov_overflow = 0 460 // MEET fields OFF by default (null/0) => linear-scan add + no rebuild => byte-identical. 461 g.hc = 0 as *HashMap 462 g.par_node = 0 as *i64 463 g.par_cls = 0 as *i64 464 g.n_par = 0 465 g.cap_par = 0 466 g.par_overflow = 0 467 g.worklist = 0 as *i64 468 g.n_work = 0 469 g.cap_work = 0 470 g.rebuild_on = 0 471 // DSL OFF by default (null/0) => nx_eqsat_saturate runs the verbatim inline rules. 472 g.dsl = 0 as *NxDslRule 473 g.n_dsl = 0 474 g.cap_dsl = 0 475 // CONST-FOLD OFF by default (0) => the node-creation hook is a no-op => every 476 // existing caller is byte-identical. Turned on only via nx_eqsat_enable_constfold. 477 g.cf_on = 0 478 // CONST-VALUE intern cache OFF by default (null/0) => nx_cf_intern_const falls 479 // back to the O(n) add_const => byte-identical. Armed by enable_constfold. 480 g.cf_cache = 0 as *i64 481 g.cf_cache_cap = 0 482 g.cf_cache_n = 0 483 g.cf_poison = 0 484 g.cf_scratch = 0 as *i64 485 // INCREMENTAL MATCH OFF by default (null/0) => nx_eqsat_saturate runs the verbatim 486 // full-scan; the union (b)-hook + alloc (a)-frontier are inert => byte-identical. 487 g.mwl = 0 as *i64 488 g.n_mwl = 0 489 g.cap_mwl = 0 490 g.match_on = 0 491 g.mwl_overflow = 0 492 return NX_EQSAT_OK 493} 494 495// ===== Opt-in provenance ==================================================== 496// 497// Turn ON rule-id logging by handing the e-graph a caller-owned i64 buffer of 498// `cap` slots. After this, every REAL merge logs its citing rule-id. Existing 499// callers never call this, so prov stays null => byte-identical behavior. 500func nx_eqsat_enable_prov(g: *NxEGraph, buf: *i64, cap: i64) -> i64 { 501 if (g as i64) == 0 { return 0 - NX_EQSAT_BAD_OP } 502 if (buf as i64) == 0 { return 0 - NX_EQSAT_BAD_OP } 503 g.prov = buf 504 g.n_prov = 0 505 g.cap_prov = cap 506 g.prov_overflow = 0 507 return NX_EQSAT_OK 508} 509 510// Log one rule-id for a real merge. No-op when prov is OFF (null) -> returns 0 511// and never mutates the graph (the byte-identical guarantee). When ON, appends 512// rule_id; on capacity overflow it FAILS CLOSED -- sets prov_overflow so the 513// certifier can never pass on a truncated (incomplete) log. 514func nx_eqsat_log_rule(g: *NxEGraph, rule_id: i64) -> i64 { 515 if (g.prov as i64) == 0 { return 0 } 516 if g.n_prov >= g.cap_prov { 517 g.prov_overflow = 1 518 return 0 - NX_EQSAT_OVERFLOW 519 } 520 g.prov[g.n_prov] = rule_id 521 g.n_prov = g.n_prov + 1 522 return NX_EQSAT_OK 523} 524 525// ===== MEET: opt-in hashcons + congruence rebuild =========================== 526// 527// Turn ON the egg-style O(1) hashcons + deferred congruence closure by handing 528// the e-graph caller-owned scratch: 529// hc -- a *HashMap (nx_hmap_alloc'd, power-of-2, sized > nodes/0.7) used 530// as the (fingerprint -> node-idx) intern table (replaces the O(n) 531// linear scan in nx_eqsat_add with an O(1)-avg lookup). 532// par_node/par_cls (cap_par) -- the parent back-index: each new node registers 533// itself as a parent of each of its canonical child classes, so the 534// rebuild can enumerate f(a) when a merges. 535// worklist (cap_work) -- dirty canonical class ids pending congruence repair. 536// After this, rebuild_on=1: every real union pushes its winner onto the worklist 537// and nx_eqsat_saturate drains the worklist to a congruence fixpoint each 538// iteration. Existing callers never call this, so the engine is byte-identical. 539func nx_eqsat_enable_meet(g: *NxEGraph, hc: *HashMap, 540 par_node: *i64, par_cls: *i64, cap_par: i64, 541 worklist: *i64, cap_work: i64) -> i64 { 542 if (g as i64) == 0 { return 0 - NX_EQSAT_BAD_OP } 543 if (hc as i64) == 0 { return 0 - NX_EQSAT_BAD_OP } 544 if (par_node as i64) == 0 { return 0 - NX_EQSAT_BAD_OP } 545 if (par_cls as i64) == 0 { return 0 - NX_EQSAT_BAD_OP } 546 if (worklist as i64) == 0 { return 0 - NX_EQSAT_BAD_OP } 547 g.hc = hc 548 g.par_node = par_node 549 g.par_cls = par_cls 550 g.n_par = 0 551 g.cap_par = cap_par 552 g.par_overflow = 0 553 g.worklist = worklist 554 g.n_work = 0 555 g.cap_work = cap_work 556 g.rebuild_on = 1 557 // AUTO-ARM worklist-driven incremental rule matching: enable_meet is the natural 558 // (and only) place -- the parent back-index that the (b) re-match needs lives HERE, 559 // and meet forces hc!=null so EVERY node (incl rule-RHS) is allocated through PATH A. 560 // match_on=1 makes nx_eqsat_saturate match rules against ONLY the new-node frontier 561 // (enqueue (a)) while quiescent, and re-scan the node set ONCE per outer iteration 562 // when a merge dirtied children (enqueue (b), set by union_cited). This is a pure 563 // speed scheduler change: the merge multiset, provenance ids, and SATURATED return 564 // are unchanged -- it only prunes the no-change nodes the full-scan re-checked for 565 // nothing each iteration. The mwl_overflow flag doubles as the (b) "merge happened" 566 // signal; mwl/n_mwl/cap_mwl stay null/0 (the flag-based scheme needs no buffer). 567 g.match_on = 1 568 g.mwl_overflow = 0 569 return NX_EQSAT_OK 570} 571 572// ===== Opt-in DSL (egg data-driven rewrite) ================================= 573// 574// Turn ON the general e-matcher by handing the e-graph a caller-owned *NxDslRule 575// table of `n` rules. After this, nx_eqsat_saturate applies the DATA rules via 576// nx_eqsat_apply_dsl_rule (one e-matcher) instead of the 7 inline functions. 577// Existing callers never call this => dsl stays null => byte-identical engine. 578// The CALLER is responsible for only loading rules whose ids are in the 579// membership-proof's proven-sound allow-list (the certifier fail-closes on any 580// un-admitted id, so a wrong table cannot silently certify). 581func nx_eqsat_enable_dsl(g: *NxEGraph, table: *NxDslRule, n: i64, cap: i64) -> i64 { 582 if (g as i64) == 0 { return 0 - NX_EQSAT_BAD_OP } 583 if (table as i64) == 0 { return 0 - NX_EQSAT_BAD_OP } 584 g.dsl = table 585 g.n_dsl = n 586 g.cap_dsl = cap 587 return NX_EQSAT_OK 588} 589 590// ===== Opt-in CONST-FOLD (egg e-class analysis) ============================= 591// 592// Turn ON constant folding: when EVERY child of a newly-added arithmetic e-node is 593// a known constant, the node is evaluated via the PROVEN op-semantics oracle 594// (nx_gsim_eval_cell) and its e-class is merged with a CONST e-class of the result, 595// citing NX_EQSAT_RULE_CONSTFOLD through the single union chokepoint (logged + 596// certifiable). Existing callers never call this => cf_on stays 0 => the node- 597// creation hook is a no-op => the engine is byte-identical. The folded equality is 598// sound by EVALUATION (the gate-sim is the proven semantics), so no separate W-bit 599// battery is needed (parallel to how CONGRUENCE is admitted to the sound set). 600func nx_eqsat_enable_constfold(g: *NxEGraph) -> i64 { 601 if (g as i64) == 0 { return 0 - NX_EQSAT_BAD_OP } 602 g.cf_on = 1 603 // Arm the O(1) const-value intern cache (the MODIFY backing store). A power-of-2 604 // open-addressing map sized so the engine never linear-scans for a folded CONST; 605 // on miss/full it fail-OPENS to the correct O(n) add_const, so correctness never 606 // depends on the cache, only speed. CF_CACHE_CAP slots * 2 i64 (value, class+1). 607 // SYS_MMAP (MAP_PRIVATE|MAP_ANONYMOUS, flags 0x22) is KERNEL-ZERO-FILLED, so the 608 // slots start empty (stored class+1 == 0 => empty marker) with NO init loop -- 609 // the per-call zeroing was an 8192-write-per-iter regression; relying on the 610 // zero-fill guarantee makes enable_constfold O(1). cf_cache stays null on an 611 // mmap failure => nx_cf_intern_const fail-OPENS to add_const (correct, slower). 612 let cap: i64 = NX_EQSAT_CF_CACHE_SLOTS // >> #distinct consts in any V1 graph; 613 // 1024*2*8 = 16KB (4 pages) -- small 614 // enough to keep init page-fault cost 615 // negligible, big enough to stay sparse. 616 let buf: *i64 = sys_mmap(cap * 2 * 8) as *i64 617 if (buf as i64) != 0 { 618 g.cf_cache = buf 619 g.cf_cache_cap = cap 620 g.cf_cache_n = 0 621 } 622 g.cf_poison = 0 623 // ONE graph-lifetime scratch buffer for the hot fold path's child-value reads -- 624 // eliminates the per-node sys_mmap(8) (the syscall that dominated the build). 625 g.cf_scratch = sys_mmap(32) as *i64 626 return NX_EQSAT_OK 627} 628 629// Create a fresh CONST e-node + class WITHOUT the O(n) linear-scan hashcons (the 630// cache IS the const hashcons when armed, so a scan would be redundant). Used by the 631// intern map miss path + the add() CONST fast path. new_class sets cf_val=value (the 632// MAKE leaf base case). Returns the new class id, or an overflow code. 633func nx_cf_make_const_node(g: *NxEGraph, value: i64) -> i64 { 634 if g.n_nodes >= g.cap_nodes { return 0 - NX_EQSAT_OVERFLOW } 635 let nidx: i64 = g.n_nodes 636 g.nodes[nidx].op = NX_EQ_OP_CONST 637 g.nodes[nidx].n_kids = 0 638 g.nodes[nidx].kid0 = 0 - 1 639 g.nodes[nidx].kid1 = 0 - 1 640 g.nodes[nidx].kid2 = 0 - 1 641 g.nodes[nidx].payload = value 642 g.n_nodes = nidx + 1 643 return nx_eqsat_new_class(g, nidx, 0) // CONST cost == 0 644} 645 646// O(1) const-value intern map (the MODIFY materialization store + the CONST hashcons 647// when const-fold is armed). Returns the canonical class-id of CONST(value), reusing 648// an existing one when present so the engine never grows duplicate CONST nodes AND 649// never linear-scans for them -- killing the O(n^2) const-add term that (with the 650// per-fold mmap, now also removed) was the measured build-phase lapp. Open addressing 651// (mix-probe) keyed by value; slot[1] stores class+1, 0 == EMPTY (kernel-zero-filled 652// mmap). HIT => re-canonicalize via find (a merged const class stays addressable). 653// MISS => create the node once (NO scan) + record. FAIL-OPEN: cache OFF/full/mmap- 654// failed => delegate to nx_eqsat_add_const (correct, O(n)) so correctness never 655// depends on the cache, only speed. 656func nx_cf_intern_const(g: *NxEGraph, value: i64) -> i64 { 657 if (g.cf_cache as i64) == 0 { return nx_eqsat_add_const(g, value) } 658 let cap: i64 = g.cf_cache_cap 659 if cap <= 0 { return nx_eqsat_add_const(g, value) } 660 let mask: i64 = cap - 1 661 var h: i64 = nx_eqsat_mix(value + 1) & mask 662 var probes: i64 = 0 663 while probes < cap { 664 // slot stores class+1 in the second word; 0 == EMPTY (kernel-zero-filled mmap, 665 // no init loop). A real class 0 stores as 1, so 0 unambiguously means empty. 666 let enc: i64 = g.cf_cache[h * 2 + 1] 667 if enc == 0 { 668 // empty slot: MISS -> materialize the CONST once (NO scan), record, return. 669 let nc: i64 = nx_cf_make_const_node(g, value) 670 if nc < 0 { return nc } 671 g.cf_cache[h * 2] = value 672 g.cf_cache[h * 2 + 1] = nc + 1 // store class+1 (0 reserved for empty) 673 g.cf_cache_n = g.cf_cache_n + 1 674 return nc 675 } 676 if g.cf_cache[h * 2] == value { 677 // HIT: re-canonicalize (the const class may have merged). 678 return nx_eqsat_find(g, enc - 1) 679 } 680 h = (h + 1) & mask 681 probes = probes + 1 682 } 683 // table full (never reached for V1 graphs): fail-OPEN to the correct O(n) path. 684 return nx_eqsat_add_const(g, value) 685} 686 687// Fill `table` (caller-owned, >= 7 rows) with the 7 V1 rules RE-EXPRESSED as data. 688// This table, applied by the e-matcher, is BYTE-IDENTICAL to the inline saturate 689// (proven by nx_eqsat_dsl_parity_test.nx). The row order MATCHES the inline call 690// order in nx_eqsat_saturate (add_zero, sub_self, add_self, and_self, or_zero, 691// mul_one, mul_pow2) so the merge sequence -- and thus best_node/cost/log -- is 692// identical. Returns the number of rows written (7). A caller wanting the new 693// xor_self rule appends row 7 itself (see nx_eqsat_dsl_set_row). 694func nx_eqsat_builtin_dsl_table(table: *NxDslRule) -> i64 { 695 // add_zero: (add x 0)==x. dual_order (const may be on either side); count every. 696 table[0].rule_id = NX_EQSAT_RULE_ADD_ZERO; table[0].lhs_op = NX_EQ_OP_ADD 697 table[0].a_kind = DSL_VAR; table[0].a_val = 0 698 table[0].b_kind = DSL_CONST; table[0].b_val = 0 699 table[0].rhs_kind = RHS_BIND_A; table[0].rhs_val = 0 700 table[0].side_cond = SC_NONE; table[0].count_mode = CNT_EVERY; table[0].dual_order = 1 701 // sub_self: (sub x x)==0. b is SAME_AS_A; rhs const 0; count every. 702 table[1].rule_id = NX_EQSAT_RULE_SUB_SELF; table[1].lhs_op = NX_EQ_OP_SUB 703 table[1].a_kind = DSL_VAR; table[1].a_val = 0 704 table[1].b_kind = DSL_SAME_AS_A; table[1].b_val = 0 705 table[1].rhs_kind = RHS_CONST; table[1].rhs_val = 0 706 table[1].side_cond = SC_NONE; table[1].count_mode = CNT_EVERY; table[1].dual_order = 0 707 // add_self: (add x x)==(shl x 1). b SAME_AS_A; rhs shl A by const 1. 708 table[2].rule_id = NX_EQSAT_RULE_ADD_SELF; table[2].lhs_op = NX_EQ_OP_ADD 709 table[2].a_kind = DSL_VAR; table[2].a_val = 0 710 table[2].b_kind = DSL_SAME_AS_A; table[2].b_val = 0 711 table[2].rhs_kind = RHS_SHL_A_BY_CONST; table[2].rhs_val = 1 712 table[2].side_cond = SC_NONE; table[2].count_mode = CNT_EVERY; table[2].dual_order = 0 713 // and_self: (and x x)==x. b SAME_AS_A; rhs bind A. 714 table[3].rule_id = NX_EQSAT_RULE_AND_SELF; table[3].lhs_op = NX_EQ_OP_AND 715 table[3].a_kind = DSL_VAR; table[3].a_val = 0 716 table[3].b_kind = DSL_SAME_AS_A; table[3].b_val = 0 717 table[3].rhs_kind = RHS_BIND_A; table[3].rhs_val = 0 718 table[3].side_cond = SC_NONE; table[3].count_mode = CNT_EVERY; table[3].dual_order = 0 719 // or_zero: (or x 0)==x. dual_order; count every. 720 table[4].rule_id = NX_EQSAT_RULE_OR_ZERO; table[4].lhs_op = NX_EQ_OP_OR 721 table[4].a_kind = DSL_VAR; table[4].a_val = 0 722 table[4].b_kind = DSL_CONST; table[4].b_val = 0 723 table[4].rhs_kind = RHS_BIND_A; table[4].rhs_val = 0 724 table[4].side_cond = SC_NONE; table[4].count_mode = CNT_EVERY; table[4].dual_order = 1 725 // mul_one: (mul x 1)==x. RIGHT-const only (matches inline: only k1 checked). 726 table[5].rule_id = NX_EQSAT_RULE_MUL_ONE; table[5].lhs_op = NX_EQ_OP_MUL 727 table[5].a_kind = DSL_VAR; table[5].a_val = 0 728 table[5].b_kind = DSL_CONST; table[5].b_val = 1 729 table[5].rhs_kind = RHS_BIND_A; table[5].rhs_val = 0 730 table[5].side_cond = SC_NONE; table[5].count_mode = CNT_EVERY; table[5].dual_order = 0 731 // mul_pow2: (mul x 2^k)==(shl x k) for 1<=k<W. dual_order (const either side); 732 // side_cond = the k<W power-of-two guard; count only REAL merges (converges). 733 table[6].rule_id = NX_EQSAT_RULE_MUL_POW2; table[6].lhs_op = NX_EQ_OP_MUL 734 table[6].a_kind = DSL_VAR; table[6].a_val = 0 735 table[6].b_kind = DSL_CONST; table[6].b_val = 0 // b_val unused (matched by SC) 736 table[6].rhs_kind = RHS_SHL_A_BY_LOG2B; table[6].rhs_val = 0 737 table[6].side_cond = SC_POW2_B_KLTW; table[6].count_mode = CNT_REAL_MERGE; table[6].dual_order = 1 738 return 7 739} 740 741// Author one DSL row (used to append the new xor_self rule as DATA). 742func nx_eqsat_dsl_set_row(table: *NxDslRule, i: i64, rule_id: i64, lhs_op: i64, 743 a_kind: i64, a_val: i64, b_kind: i64, b_val: i64, 744 rhs_kind: i64, rhs_val: i64, 745 side_cond: i64, count_mode: i64, dual_order: i64) -> i64 { 746 table[i].rule_id = rule_id 747 table[i].lhs_op = lhs_op 748 table[i].a_kind = a_kind 749 table[i].a_val = a_val 750 table[i].b_kind = b_kind 751 table[i].b_val = b_val 752 table[i].rhs_kind = rhs_kind 753 table[i].rhs_val = rhs_val 754 table[i].side_cond = side_cond 755 table[i].count_mode = count_mode 756 table[i].dual_order = dual_order 757 return 0 758} 759 760// splitmix step (the same mix nx_hmap_hash applies to its key) -- one round. 761func nx_eqsat_mix(h: i64) -> i64 { 762 return (h * 0x9E3779B97F4A7C15) & 0xFFFFFFFFFFFFFFFF 763} 764 765// Fingerprint of an e-node = (op, payload, canonical kid0..2) folded to one i64. 766// +1/+2 sentinel offsets: the -1 "unused child" never aliases class 0, and CONST 0 767// vs CONST 1 differ via payload. Clamped away from 0/-1 which nx_sketch_hash_map 768// reserves as EMPTY/TOMBSTONE keys. Identity is ALWAYS re-verified against the node 769// array on a hashcons hit, so a 64-bit collision can never return/merge a wrong class. 770func nx_eqsat_fingerprint(op: i64, payload: i64, kc0: i64, kc1: i64, kc2: i64) -> i64 { 771 var fp: i64 = nx_eqsat_mix(op + 1) 772 fp = nx_eqsat_mix(fp ^ (payload + 1)) 773 fp = nx_eqsat_mix(fp ^ (kc0 + 2)) 774 fp = nx_eqsat_mix(fp ^ (kc1 + 2)) 775 fp = nx_eqsat_mix(fp ^ (kc2 + 2)) 776 if fp == 0 { fp = 0x1234567 } // never the EMPTY sentinel 777 if fp == 0 - 1 { fp = 0x7654321 } // never the TOMBSTONE sentinel 778 return fp 779} 780 781// Re-verify that node `nidx` really is the e-node (op,payload,kc0..2) -- the node 782// array is the source of truth; the hashcons is only a hint. Children compared by 783// CANONICAL class so union re-pointing is followed. Returns 1 iff a genuine match. 784func nx_eqsat_node_matches(g: *NxEGraph, nidx: i64, op: i64, payload: i64, 785 kc0: i64, kc1: i64, kc2: i64) -> i64 { 786 if g.nodes[nidx].op != op { return 0 } 787 if g.nodes[nidx].payload != payload { return 0 } 788 let arity: i64 = nx_eqsat_arity_for(op) 789 if g.nodes[nidx].n_kids != arity { return 0 } 790 var matched: i64 = 1 791 if arity >= 1 { if nx_eqsat_find(g, g.nodes[nidx].kid0) != kc0 { matched = 0 } } 792 if arity >= 2 { if nx_eqsat_find(g, g.nodes[nidx].kid1) != kc1 { matched = 0 } } 793 if arity >= 3 { if nx_eqsat_find(g, g.nodes[nidx].kid2) != kc2 { matched = 0 } } 794 return matched 795} 796 797// Record a parent edge (node `nidx` references canonical class `child_cls`). On 798// overflow set par_overflow -> rebuild then enumerates parents by full node scan 799// (provably complete, just slower). No-op when the parent index is OFF. 800func nx_eqsat_add_parent(g: *NxEGraph, nidx: i64, child_cls: i64) -> i64 { 801 if (g.par_node as i64) == 0 { return 0 } 802 if child_cls < 0 { return 0 } 803 if g.n_par >= g.cap_par { g.par_overflow = 1; return 0 - NX_EQSAT_OVERFLOW } 804 g.par_node[g.n_par] = nidx 805 g.par_cls[g.n_par] = child_cls 806 g.n_par = g.n_par + 1 807 return NX_EQSAT_OK 808} 809 810// Push a dirty canonical class onto the rebuild worklist (dedup is unnecessary -- 811// rebuild re-canonicalizes + the union strictly reduces class count, so it 812// converges regardless). No-op when rebuild is OFF or the worklist is full 813// (rebuild loops to fixpoint anyway because every union re-pushes its winner). 814func nx_eqsat_push_dirty(g: *NxEGraph, cls: i64) -> i64 { 815 if g.rebuild_on != 1 { return 0 } 816 if (g.worklist as i64) == 0 { return 0 } 817 if g.n_work >= g.cap_work { return 0 } 818 g.worklist[g.n_work] = cls 819 g.n_work = g.n_work + 1 820 return NX_EQSAT_OK 821} 822 823// Allocate a fresh e-class with one initial node. 824func nx_eqsat_new_class(g: *NxEGraph, node_idx: i64, initial_cost: i64) -> i64 { 825 if g.n_classes >= g.cap_classes { return 0 - NX_EQSAT_OVERFLOW } 826 let id: i64 = g.n_classes 827 g.classes[id].canon = id // points to self until merged 828 g.classes[id].cost = initial_cost 829 g.classes[id].best_node = node_idx 830 // MAKE (leaf): a class is born known-const iff its sole node is a CONST literal; 831 // every other class is born NX_CF_NONE (unknown). ALWAYS written (so the field is 832 // never uninitialized garbage); only READ when cf_on==1, so the cf_on==0 833 // trajectory is byte-identical (this store is dead state on the page-rounded 834 // class array). This is the egg `make(Num(n)) = Some(n)` base case. 835 if g.nodes[node_idx].op == NX_EQ_OP_CONST { 836 g.classes[id].cf_val = g.nodes[node_idx].payload 837 } else { 838 g.classes[id].cf_val = NX_CF_NONE 839 } 840 g.n_classes = id + 1 841 g.nodes[node_idx].eclass = id 842 return id 843} 844 845// ===== Union-find: find canonical e-class id ================================================= 846func nx_eqsat_find(g: *NxEGraph, id: i64) -> i64 { 847 var cur: i64 = id 848 while g.classes[cur].canon != cur { 849 cur = g.classes[cur].canon 850 } 851 // Path-compress (single-step; full compression is O(alpha) -- ok for V1) 852 g.classes[id].canon = cur 853 return cur 854} 855 856// Merge two e-classes, CITING the rule that justifies the merge. Cheaper class 857// wins; the more-expensive one repoints to the cheaper. This is the SINGLE 858// chokepoint that mutates canon, so logging the rule-id HERE (only on a real 859// merge, ra!=rb) makes a provenance bypass structurally impossible: no class can 860// be merged without a rule-id passing through this function. Logging is a no-op 861// when prov is OFF, so existing behavior is byte-identical. 862func nx_eqsat_union_cited(g: *NxEGraph, a: i64, b: i64, rule_id: i64) -> i64 { 863 let ra: i64 = nx_eqsat_find(g, a) 864 let rb: i64 = nx_eqsat_find(g, b) 865 if ra == rb { return ra } 866 nx_eqsat_log_rule(g, rule_id) 867 // JOIN (egg Analysis::merge), read BEFORE the canon re-point so both operands are 868 // still roots. Lattice-meet the two classes' const values; the meet is stored 869 // into the post-merge WINNER below (trap-1: write the const to the survivor that 870 // find() returns, never to a stale role). Gated on cf_on==1 so the cf_on==0 871 // trajectory is byte-identical. CONTRADICTION (two unequal known consts in one 872 // class) => fail-CLOSED SOFT poison (cf_poison=1), NOT sys_exit: a sound graph 873 // never reaches this, and a library must degrade gracefully (Global Rule #14) 874 // rather than crash its host. The meet only RISES (NONE->known, never falls), so 875 // the const propagates along union/congruence edges with no rescan (the genuine 876 // new JOIN capability over the old implicit-best_node passive lattice). 877 var meet: i64 = NX_CF_NONE 878 if g.cf_on == 1 { 879 let va: i64 = g.classes[ra].cf_val 880 let vb: i64 = g.classes[rb].cf_val 881 if va == NX_CF_NONE { meet = vb } 882 else { if vb == NX_CF_NONE { meet = va } 883 else { if va == vb { meet = va } 884 else { g.cf_poison = 1; meet = va } } } // contradiction: soft-poison 885 } 886 if g.classes[ra].cost <= g.classes[rb].cost { 887 g.classes[rb].canon = ra 888 if g.cf_on == 1 { g.classes[ra].cf_val = meet } // store meet into the WINNER (ra) 889 // MEET: a class merged -> its parents may now be congruent. Mark the 890 // surviving (winner) canonical class dirty for the deferred rebuild. 891 // No-op when rebuild is OFF, so the default path is byte-identical. 892 nx_eqsat_push_dirty(g, ra) 893 // INCREMENTAL MATCH (enqueue (b)): a merge changed a child's canonical class, 894 // so parents f(...child...) may now newly match a rule. We do NOT enumerate the 895 // parent index PER UNION here (that is O(n_par) per merge -> quadratic when a 896 // rebuild fires many congruence merges, the measured regression). Instead we set 897 // a single "merge happened" flag (mwl_overflow doubles as the dirty signal) and 898 // nx_eqsat_saturate re-matches the node frontier ONCE per outer iteration -- a 899 // single O(n) sweep, never per-union. No-op when match_on!=1. 900 if g.match_on == 1 { g.mwl_overflow = 1 } 901 return ra 902 } 903 g.classes[ra].canon = rb 904 if g.cf_on == 1 { g.classes[rb].cf_val = meet } // store meet into the WINNER (rb) 905 nx_eqsat_push_dirty(g, rb) 906 if g.match_on == 1 { g.mwl_overflow = 1 } 907 return rb 908} 909 910// Legacy 2-arg union (signature unchanged for all existing callers). Cites 911// NX_EQSAT_RULE_NONE -- which is NOT in any proven-sound set, so a merge made 912// through this back door logs an un-sound sentinel and POISONS any certificate 913// (fail-closed: an unlogged-reason merge can never silently certify). 914func nx_eqsat_union(g: *NxEGraph, a: i64, b: i64) -> i64 { 915 return nx_eqsat_union_cited(g, a, b, NX_EQSAT_RULE_NONE) 916} 917 918// ===== Cost model ================================================= 919// 920// Per-op cost. RV64IM micro-architecture approximate: 921// const/var: 0 922// add/sub/and/or/xor/shl/shr/sar/neg/not: 1 923// mul: 3 924// div/rem: 30 (non-restoring divider average) 925// eq/lt/ltu/select: 1 926// popcnt: 2 (1 if silicon has Zbb) 927// Tunable per silicon-target-generation; refined as silicon-feedback 928// loop (rv64im_min_hot_report.nx) feeds back measured cycle counts. 929 930func nx_eqsat_op_cost(op: i64) -> i64 { 931 if op == NX_EQ_OP_CONST { return 0 } 932 if op == NX_EQ_OP_VAR { return 0 } 933 if op == NX_EQ_OP_MUL { return 3 } 934 if op == NX_EQ_OP_DIV { return 30 } 935 if op == NX_EQ_OP_REM { return 30 } 936 if op == NX_EQ_OP_POPCNT { return 2 } 937 return 1 938} 939 940// Compute the cost of an expression rooted at e-class id by 941// summing op cost + child best-costs. Recursive; bounded by 942// e-graph depth. 943func nx_eqsat_class_cost(g: *NxEGraph, class_id: i64) -> i64 { 944 let canon: i64 = nx_eqsat_find(g, class_id) 945 let node_idx: i64 = g.classes[canon].best_node 946 let op: i64 = g.nodes[node_idx].op 947 let arity: i64 = nx_eqsat_arity_for(op) 948 var cost: i64 = nx_eqsat_op_cost(op) 949 if arity >= 1 { cost = cost + nx_eqsat_class_cost(g, g.nodes[node_idx].kid0) } 950 if arity >= 2 { cost = cost + nx_eqsat_class_cost(g, g.nodes[node_idx].kid1) } 951 if arity >= 3 { cost = cost + nx_eqsat_class_cost(g, g.nodes[node_idx].kid2) } 952 return cost 953} 954 955// ===== Node construction ================================================= 956// 957// Add an e-node + optionally create / find its e-class. V1: linear 958// search for existing node (no hash-cons yet); fine for graphs <1000 959// nodes. Future: per-op hash table for O(1) lookup. 960 961// ===== CONST-FOLD analysis (egg e-class analysis: constant folding) ========= 962// 963// THE LATTICE IS IMPLICIT IN best_node. A class is "CONST V" iff its canonical 964// best_node is a CONST node (op==NX_EQ_OP_CONST, payload==V) -- exact at class 965// creation (nx_eqsat_new_class sets best_node to the just-added node) and never 966// demoted for THIS minimal lattice (CONST cost==0 is strictly cheapest, line 967// "if op == NX_EQ_OP_CONST { return 0 }" in nx_eqsat_op_cost, so recompute_best 968// never moves a const class off its CONST node). So we read the analysis off 969// best_node -- zero new struct fields, zero per-class analysis buffer. 970// 971// Map an eqsat op to the gate-sim cell kind whose semantics are the PROVEN 972// semantics of that op. -1 => fold REFUSED (no faithful cell -> NEVER evaluated). 973// REFUSALS are the soundness boundary, each a confirmed silent-miscompile if mapped: 974// DIV/REM -- gsim has NO div/rem cell (would hit eval_cell's silent `return 0`). 975// SHR -- eqsat SHR is LOGICAL >>; gsim SHR cell is `a >> b` = ARITHMETIC in 976// NishiLang (verified: (0-8)>>1 == -4), so it disagrees for negatives. 977// SAR -- no SAR cell in gsim's eval switch. 978// NEG -- no gate kind; the NEG->NOT temptation is WRONG (NOT(a)=a^-1 != -a). 979// POPCNT -- gsim has no popcount cell. 980// SELECT -- maps to MUX, but SELECT is arity-3 and deferred (kept simple). 981func nx_eqsat_op_to_gate_kind(op: i64) -> i64 { 982 if op == NX_EQ_OP_ADD { return NX_GATE_KIND_ADD } // 2->7 983 if op == NX_EQ_OP_SUB { return NX_GATE_KIND_SUB } // 3->8 984 if op == NX_EQ_OP_MUL { return NX_GATE_KIND_MUL } // 4->9 985 if op == NX_EQ_OP_AND { return NX_GATE_KIND_AND } // 7->0 986 if op == NX_EQ_OP_OR { return NX_GATE_KIND_OR } // 8->1 987 if op == NX_EQ_OP_XOR { return NX_GATE_KIND_XOR } // 9->3 988 if op == NX_EQ_OP_NOT { return NX_GATE_KIND_NOT } // 10->2 989 if op == NX_EQ_OP_SHL { return NX_GATE_KIND_SHL } // 11->11 990 if op == NX_EQ_OP_EQ { return NX_GATE_KIND_EQ } // 15->14 991 if op == NX_EQ_OP_LT { return NX_GATE_KIND_LT } // 16->16 992 if op == NX_EQ_OP_LTU { return NX_GATE_KIND_LTU } // 17->17 993 return 0 - 1 // DIV/REM/SHR/SAR/NEG/POPCNT/SELECT/CONST/VAR -> REFUSED 994} 995 996// Read the const-lattice value of canonical class `cls` into out_v[0]. Returns 1 997// iff `cls` is a known constant (its canonical best_node is a CONST node). A VAR 998// class returns 0 (best_node.op==VAR; its payload is a var id, NEVER read as a 999// value -> "fold on VARs refused" structurally). best_node is re-canonicalized via 1000// find first, so a stale (pre-rebuild) link cannot feed a wrong operand. 1001func nx_eqsat_class_const(g: *NxEGraph, cls: i64, out_v: *i64) -> i64 { 1002 let canon: i64 = nx_eqsat_find(g, cls) 1003 let bn: i64 = g.classes[canon].best_node 1004 if g.nodes[bn].op != NX_EQ_OP_CONST { return 0 } 1005 if g.nodes[bn].n_kids != 0 { return 0 } // a CONST is arity-0 (re-verify shape) 1006 out_v[0] = g.nodes[bn].payload 1007 return 1 1008} 1009 1010// O(1) const-lattice read via the EXPLICIT cf_val field (the incremental analysis 1011// store) -- the fast path used by the add-time MAKE. Returns 1 + writes the const 1012// into out_v[0] iff canonical class `cls` is known-const (cf_val != NX_CF_NONE). No 1013// node-array walk (unlike nx_eqsat_class_const, which dereferences best_node). Only 1014// meaningful when cf_on==1 (cf_val is maintained by MAKE/JOIN); read through find so 1015// a merged/interior class self-canonicalizes to the survivor that carries the value 1016// (trap-1 defense: never trust a non-canonical id's field). 1017func nx_eqsat_class_cf(g: *NxEGraph, cls: i64, out_v: *i64) -> i64 { 1018 let canon: i64 = nx_eqsat_find(g, cls) 1019 let v: i64 = g.classes[canon].cf_val 1020 if v == NX_CF_NONE { return 0 } 1021 out_v[0] = v 1022 return 1 1023} 1024 1025// Drive const-fold for a NEWLY-created e-node (e-class new_cls, op, canonical kids 1026// kc0..kc2). Returns 1 iff a fold fired (a CONST merge was performed), else 0. 1027// Steps: (1) refuse CONST/VAR + any op with no faithful gate cell; (2) require 1028// EVERY child class be a known constant (else no fire -> the backward-compat 1029// guarantee on VAR graphs); (3) guard out-of-range shift amounts; (4) GUARD the 1030// evaluator against its silent default (nx_gsim_kind_supported) so an unsupported 1031// kind can NEVER fold a wrong 0; (5) evaluate via the PROVEN nx_gsim_eval_cell at 1032// W=64 (i64 arithmetic IS mod-2^64 -> raw output is the exact W=64 value); (6) 1033// merge new_cls with CONST(folded) through the single cited chokepoint. 1034func nx_eqsat_try_constfold(g: *NxEGraph, new_cls: i64, op: i64, 1035 kc0: i64, kc1: i64, kc2: i64) -> i64 { 1036 if op == NX_EQ_OP_CONST { return 0 } 1037 if op == NX_EQ_OP_VAR { return 0 } 1038 let kind: i64 = nx_eqsat_op_to_gate_kind(op) 1039 if kind < 0 { return 0 } // unmapped op -> REFUSED 1040 // Defend the silent-default trap: only fold kinds the evaluator actually 1041 // handles (reuse the simulator's own loud guard instead of bypassing it). 1042 if nx_gsim_kind_supported(kind) != 1 { return 0 } 1043 let arity: i64 = nx_eqsat_arity_for(op) 1044 var v0: i64 = 0 1045 var v1: i64 = 0 1046 var v2: i64 = 0 1047 let pv: *i64 = sys_mmap(8) as *i64 1048 if arity >= 1 { if nx_eqsat_class_const(g, kc0, pv) != 1 { return 0 } v0 = pv[0] } 1049 if arity >= 2 { if nx_eqsat_class_const(g, kc1, pv) != 1 { return 0 } v1 = pv[0] } 1050 if arity >= 3 { if nx_eqsat_class_const(g, kc2, pv) != 1 { return 0 } v2 = pv[0] } 1051 // shift-amount guard: an out-of-range shift is UB / not what i64 << does 1052 // portably -> refuse rather than fold a value the proof never validated. 1053 if op == NX_EQ_OP_SHL { if v1 < 0 { return 0 } if v1 >= NX_EQSAT_W { return 0 } } 1054 let folded: i64 = nx_gsim_eval_cell(kind, v0, v1, v2) 1055 let cst: i64 = nx_eqsat_add_const(g, folded) 1056 if cst < 0 { return 0 } // allocation overflow -> no fold 1057 nx_eqsat_union_cited(g, new_cls, cst, NX_EQSAT_RULE_CONSTFOLD) 1058 return 1 1059} 1060 1061// ===== INCREMENTAL FOLD (the egg MAKE/MODIFY fast path) ===================== 1062// 1063// nx_eqsat_fold_value: the MAKE oracle as O(1) FIELD reads. Returns 1 + writes the 1064// folded constant to out_v[0] iff op is foldable (faithful gate cell + evaluator 1065// support, the EXISTING soundness boundary) AND every canonical child class is 1066// known-const via the explicit cf_val lattice (NO best_node node-array walk). This 1067// is the incremental analysis: instead of re-DISCOVERING constness by scanning the 1068// whole node array each saturate iteration (the old O(N*K*M) lapp), the const flows 1069// through cf_val and a fold is a constant-time field-read + one evaluator call. 1070// Refusals (kind<0, unsupported, SHL out-of-range, any non-const child) leave the 1071// node un-folded -- IDENTICAL to nx_eqsat_try_constfold so backward-compat holds. 1072// Writes the folded constant to out_v[0] iff foldable + all children known-const. 1073// Reads child cf_val DIRECTLY off the canonical class field (no scratch pointer, no 1074// node walk) so the hot path makes ZERO sys_mmap calls. NX_CF_NONE is the "unknown" 1075// sentinel; a child carrying it (a VAR-touching class) refuses the fold. 1076func nx_eqsat_fold_value(g: *NxEGraph, op: i64, kc0: i64, kc1: i64, kc2: i64, out_v: *i64) -> i64 { 1077 if op == NX_EQ_OP_CONST { return 0 } 1078 if op == NX_EQ_OP_VAR { return 0 } 1079 let kind: i64 = nx_eqsat_op_to_gate_kind(op) 1080 if kind < 0 { return 0 } 1081 if nx_gsim_kind_supported(kind) != 1 { return 0 } 1082 let arity: i64 = nx_eqsat_arity_for(op) 1083 var v0: i64 = 0 1084 var v1: i64 = 0 1085 var v2: i64 = 0 1086 if arity >= 1 { let c0: i64 = nx_eqsat_find(g, kc0); let x0: i64 = g.classes[c0].cf_val; if x0 == NX_CF_NONE { return 0 } v0 = x0 } 1087 if arity >= 2 { let c1: i64 = nx_eqsat_find(g, kc1); let x1: i64 = g.classes[c1].cf_val; if x1 == NX_CF_NONE { return 0 } v1 = x1 } 1088 if arity >= 3 { let c2: i64 = nx_eqsat_find(g, kc2); let x2: i64 = g.classes[c2].cf_val; if x2 == NX_CF_NONE { return 0 } v2 = x2 } 1089 if op == NX_EQ_OP_SHL { if v1 < 0 { return 0 } if v1 >= NX_EQSAT_W { return 0 } } 1090 out_v[0] = nx_gsim_eval_cell(kind, v0, v1, v2) 1091 return 1 1092} 1093 1094func nx_eqsat_add(g: *NxEGraph, op: i64, k0: i64, k1: i64, k2: i64, payload: i64) -> i64 { 1095 if nx_eqsat_op_is_valid(op) != 1 { return 0 - NX_EQSAT_BAD_OP } 1096 let arity: i64 = nx_eqsat_arity_for(op) 1097 // Check children for arity. 1098 if arity == 0 { if k0 != 0 - 1 { return 0 - NX_EQSAT_BAD_ARITY } } 1099 if arity == 1 { if k0 < 0 { return 0 - NX_EQSAT_BAD_ARITY } } 1100 if arity == 2 { if k0 < 0 { return 0 - NX_EQSAT_BAD_ARITY } } 1101 if arity == 3 { if k0 < 0 { return 0 - NX_EQSAT_BAD_ARITY } } 1102 1103 // CONST FAST PATH: when const-fold is armed (cf_on + cache, default linear-scan 1104 // store hc==0), the value->class cache IS the const hashcons -- an O(1) intern 1105 // replaces the O(n) linear node scan for EVERY const add (leaf + folded). This 1106 // removes the last O(n^2) term in an all-const build (the leaf consts the caller 1107 // adds directly). Discipline: enable_constfold is called before any const is 1108 // added (both gated callers do), so the cache sees every const from graph birth 1109 // -> hashcons-equivalent. A pre-enable/post-enable value mix would at worst make 1110 // a duplicate CONST node (same value, congruent, same folded value) -- harmless 1111 // (no soundness loss). cf_on==0 OR cache OFF OR meet-on(hc!=0) => skip => the 1112 // original linear-scan path runs => byte-identical for every existing caller. 1113 if op == NX_EQ_OP_CONST { if g.cf_on == 1 { if (g.cf_cache as i64) != 0 { if (g.hc as i64) == 0 { 1114 return nx_cf_intern_const(g, payload) 1115 } } } } 1116 1117 // Canonical children (shared by BOTH the linear-scan and hashcons paths). 1118 let kc0: i64 = if k0 >= 0 then nx_eqsat_find(g, k0) else 0 - 1 1119 let kc1: i64 = if k1 >= 0 then nx_eqsat_find(g, k1) else 0 - 1 1120 let kc2: i64 = if k2 >= 0 then nx_eqsat_find(g, k2) else 0 - 1 1121 1122 // ===== EARLY-FOLD FAST PATH (incremental MAKE + MODIFY) ================== 1123 // When const-fold is ON (and we are on the default linear-scan store, hc==0 -- 1124 // the MEET hashcons path keeps its own existing try_constfold hook below so that 1125 // organ is untouched), and EVERY canonical child is known-const via the O(1) 1126 // cf_val lattice, the result is a known constant. The OLD path would (a) linear- 1127 // SCAN the whole node array to hashcons the binop node, then (b) linear-SCAN 1128 // AGAIN inside add_const to hashcons the folded CONST -- two O(n) scans per node 1129 // = the measured ~10x build-phase lapp. Here we INTERN the folded const in O(1) 1130 // (the value cache = egg's interned Num) and create the binop node WITHOUT the 1131 // O(n) scan, because a foldable all-const binop ALWAYS collapses into the const 1132 // class anyway: even if a structurally-identical binop node already exists, both 1133 // denote the SAME value and merging the fresh one is idempotent/harmless (no 1134 // soundness loss -- congruence would have merged them too). The CONSTFOLD merge 1135 // still flows the single cited chokepoint => logged + membership-certifiable, 1136 // and the returned class find()s to the const (cost-0 winner) => extraction + 1137 // the cert test (find(top)==find(const), log has CONSTFOLD, best_node==CONST) 1138 // are byte-identical to the old eager path. This is the egg make-on-add + 1139 // modify-materialize collapsed to O(1) amortized -- the lapp-closing lever. 1140 if g.cf_on == 1 { if (g.hc as i64) == 0 { if (g.cf_scratch as i64) != 0 { 1141 let fv: *i64 = g.cf_scratch // graph-lifetime scratch -> NO per-node mmap 1142 if nx_eqsat_fold_value(g, op, kc0, kc1, kc2, fv) == 1 { 1143 // intern the folded const in O(1) (no node scan) 1144 let cst_f: i64 = nx_cf_intern_const(g, fv[0]) 1145 if cst_f >= 0 { 1146 // MODIFY-PRUNE (egg's `modify` retains only leaf nodes): when no one 1147 // is RECORDING the rewrite path (provenance OFF), there is no 1148 // observable difference between "create the binop node, fold-merge it 1149 // to the const, return the binop class" and "return the const class 1150 // directly" -- both yield a class that find()s to the SAME interned 1151 // CONST with the SAME extracted value. So we PRUNE the dominated binop 1152 // node + its union entirely (egg drops the non-leaf nodes too), which 1153 // is the lapp-closing lever: the all-const chain collapses to ~N leaf 1154 // CONSTs (no lingering binop nodes for recompute_best to rescan). This 1155 // is a pure speed prune, never a soundness change (the folded value is 1156 // identical, computed by the SAME proven oracle). 1157 if (g.prov as i64) == 0 { 1158 return cst_f 1159 } 1160 // CERTIFICATION PATH (provenance ON): keep the full cited MODIFY so the 1161 // CONSTFOLD merge is LOGGED + membership-certifiable. Allocate the binop 1162 // node WITHOUT the linear scan (it collapses into the const anyway: even 1163 // if a structurally-identical binop exists, both denote the same value 1164 // and merging the fresh one is idempotent/harmless -- congruence would 1165 // merge them too). The merge flows the single cited chokepoint and the 1166 // returned class find()s to the const (cost-0 winner) => the cert test 1167 // (find(top)==find(const), log has CONSTFOLD, best_node==CONST) holds. 1168 if g.n_nodes >= g.cap_nodes { return 0 - NX_EQSAT_OVERFLOW } 1169 let nf: i64 = g.n_nodes 1170 g.nodes[nf].op = op 1171 g.nodes[nf].n_kids = arity 1172 g.nodes[nf].kid0 = kc0 1173 g.nodes[nf].kid1 = kc1 1174 g.nodes[nf].kid2 = kc2 1175 g.nodes[nf].payload = payload 1176 g.n_nodes = nf + 1 1177 let icf: i64 = nx_eqsat_op_cost(op) 1178 let bcls: i64 = nx_eqsat_new_class(g, nf, icf) 1179 if bcls < 0 { return bcls } 1180 // MODIFY: materialize the equality through the cited chokepoint ONCE 1181 // (logged + certifiable). JOIN inside flows cf_val into the winner. 1182 nx_eqsat_union_cited(g, bcls, cst_f, NX_EQSAT_RULE_CONSTFOLD) 1183 return bcls 1184 } 1185 } 1186 } } } 1187 1188 // PATH A (MEET, hc != null): O(1)-avg hashcons lookup. The hashmap value is a 1189 // node-array INDEX; on a hit we re-VERIFY against the node array (a 64-bit 1190 // collision can never return a wrong class -> it degrades to allocate). The 1191 // returned id is the SAME canonical e-class id the linear scan would return. 1192 if (g.hc as i64) != 0 { 1193 let fp: i64 = nx_eqsat_fingerprint(op, payload, kc0, kc1, kc2) 1194 if nx_hmap_has(g.hc, fp) == 1 { 1195 let hit_idx: i64 = nx_hmap_get(g.hc, fp) 1196 if nx_eqsat_node_matches(g, hit_idx, op, payload, kc0, kc1, kc2) == 1 { 1197 return nx_eqsat_find(g, g.nodes[hit_idx].eclass) 1198 } 1199 // collision / stale key: fall through to allocate (never trust the hit). 1200 } 1201 if g.n_nodes >= g.cap_nodes { return 0 - NX_EQSAT_OVERFLOW } 1202 let nidx2: i64 = g.n_nodes 1203 g.nodes[nidx2].op = op 1204 g.nodes[nidx2].n_kids = arity 1205 g.nodes[nidx2].kid0 = kc0 1206 g.nodes[nidx2].kid1 = kc1 1207 g.nodes[nidx2].kid2 = kc2 1208 g.nodes[nidx2].payload = payload 1209 g.n_nodes = nidx2 + 1 1210 nx_hmap_put(g.hc, fp, nidx2) // intern (restore the hashcons invariant) 1211 // parent back-index: this node references each canonical child class. 1212 if arity >= 1 { nx_eqsat_add_parent(g, nidx2, kc0) } 1213 if arity >= 2 { nx_eqsat_add_parent(g, nidx2, kc1) } 1214 if arity >= 3 { nx_eqsat_add_parent(g, nidx2, kc2) } 1215 let ic2: i64 = nx_eqsat_op_cost(op) 1216 let newcls2: i64 = nx_eqsat_new_class(g, nidx2, ic2) 1217 // CONST-FOLD hook (opt-in; no-op + byte-identical when cf_on==0). Fires once 1218 // per DISTINCT node (hashcons-HIT path above returns before here). The folded 1219 // merge flows nx_eqsat_union_cited so it is logged + certifiable. We return 1220 // newcls2 (the new node's class), which the union may repoint to the const's 1221 // canonical class -- callers re-canonicalize via find, so this is correct. 1222 if g.cf_on == 1 { if newcls2 >= 0 { nx_eqsat_try_constfold(g, newcls2, op, kc0, kc1, kc2) } } 1223 return newcls2 1224 } 1225 1226 // PATH B (default, byte-identical): hash-cons by linear scan. Two nodes are 1227 // equal iff op + payload + canonical children agree. 1228 var i: i64 = 0 1229 while i < g.n_nodes { 1230 if g.nodes[i].op == op { 1231 if g.nodes[i].payload == payload { 1232 let n_arity: i64 = g.nodes[i].n_kids 1233 if n_arity == arity { 1234 var matched: i64 = 1 1235 if arity >= 1 { if nx_eqsat_find(g, g.nodes[i].kid0) != kc0 { matched = 0 } } 1236 if arity >= 2 { if nx_eqsat_find(g, g.nodes[i].kid1) != kc1 { matched = 0 } } 1237 if arity >= 3 { if nx_eqsat_find(g, g.nodes[i].kid2) != kc2 { matched = 0 } } 1238 if matched == 1 { 1239 return g.nodes[i].eclass 1240 } 1241 } 1242 } 1243 } 1244 i = i + 1 1245 } 1246 // Allocate a new node. 1247 if g.n_nodes >= g.cap_nodes { return 0 - NX_EQSAT_OVERFLOW } 1248 let nidx: i64 = g.n_nodes 1249 g.nodes[nidx].op = op 1250 g.nodes[nidx].n_kids = arity 1251 g.nodes[nidx].kid0 = kc0 1252 g.nodes[nidx].kid1 = kc1 1253 g.nodes[nidx].kid2 = kc2 1254 g.nodes[nidx].payload = payload 1255 g.n_nodes = nidx + 1 1256 // Allocate the e-class with initial cost from this single node. 1257 let init_cost: i64 = nx_eqsat_op_cost(op) 1258 let newcls: i64 = nx_eqsat_new_class(g, nidx, init_cost) 1259 // CONST-FOLD hook (opt-in; no-op + byte-identical when cf_on==0). Fires once per 1260 // DISTINCT node (the linear-scan HIT path above returns before here). Same single 1261 // cited chokepoint => logged + certifiable. 1262 if g.cf_on == 1 { if newcls >= 0 { nx_eqsat_try_constfold(g, newcls, op, kc0, kc1, kc2) } } 1263 return newcls 1264} 1265 1266// Convenience wrappers (omit unused children). 1267func nx_eqsat_add_const(g: *NxEGraph, value: i64) -> i64 { 1268 return nx_eqsat_add(g, NX_EQ_OP_CONST, 0 - 1, 0 - 1, 0 - 1, value) 1269} 1270func nx_eqsat_add_var(g: *NxEGraph, var_id: i64) -> i64 { 1271 return nx_eqsat_add(g, NX_EQ_OP_VAR, 0 - 1, 0 - 1, 0 - 1, var_id) 1272} 1273func nx_eqsat_add_binary(g: *NxEGraph, op: i64, a: i64, b: i64) -> i64 { 1274 return nx_eqsat_add(g, op, a, b, 0 - 1, 0) 1275} 1276func nx_eqsat_add_unary(g: *NxEGraph, op: i64, a: i64) -> i64 { 1277 return nx_eqsat_add(g, op, a, 0 - 1, 0 - 1, 0) 1278} 1279 1280// ===== Baseline rewrite rules ================================================= 1281// 1282// V1 ships ~10 canonical algebraic identities for RV64IM. Each is 1283// a hand-coded "if you see pattern X, union it with class Y". Real 1284// egg-style rule DSL is a future commit (parses rules from a text 1285// format); V1 keeps it inline for clarity. 1286 1287func nx_eqsat_apply_rule_add_zero(g: *NxEGraph) -> i64 { 1288 // (add x 0) == x 1289 var unions: i64 = 0 1290 var i: i64 = 0 1291 while i < g.n_nodes { 1292 if g.nodes[i].op == NX_EQ_OP_ADD { 1293 let k0: i64 = nx_eqsat_find(g, g.nodes[i].kid0) 1294 let k1: i64 = nx_eqsat_find(g, g.nodes[i].kid1) 1295 let k0_node: i64 = g.classes[k0].best_node 1296 let k1_node: i64 = g.classes[k1].best_node 1297 if g.nodes[k0_node].op == NX_EQ_OP_CONST { 1298 if g.nodes[k0_node].payload == 0 { 1299 nx_eqsat_union_cited(g, g.nodes[i].eclass, k1, NX_EQSAT_RULE_ADD_ZERO) 1300 unions = unions + 1 1301 } 1302 } 1303 if g.nodes[k1_node].op == NX_EQ_OP_CONST { 1304 if g.nodes[k1_node].payload == 0 { 1305 nx_eqsat_union_cited(g, g.nodes[i].eclass, k0, NX_EQSAT_RULE_ADD_ZERO) 1306 unions = unions + 1 1307 } 1308 } 1309 } 1310 i = i + 1 1311 } 1312 return unions 1313} 1314 1315func nx_eqsat_apply_rule_sub_self(g: *NxEGraph) -> i64 { 1316 // (sub x x) == 0 1317 var unions: i64 = 0 1318 var i: i64 = 0 1319 while i < g.n_nodes { 1320 if g.nodes[i].op == NX_EQ_OP_SUB { 1321 let k0: i64 = nx_eqsat_find(g, g.nodes[i].kid0) 1322 let k1: i64 = nx_eqsat_find(g, g.nodes[i].kid1) 1323 if k0 == k1 { 1324 let zero_class: i64 = nx_eqsat_add_const(g, 0) 1325 nx_eqsat_union_cited(g, g.nodes[i].eclass, zero_class, NX_EQSAT_RULE_SUB_SELF) 1326 unions = unions + 1 1327 } 1328 } 1329 i = i + 1 1330 } 1331 return unions 1332} 1333 1334func nx_eqsat_apply_rule_add_self(g: *NxEGraph) -> i64 { 1335 // (add x x) == (shl x 1) -- strength reduction 1336 var unions: i64 = 0 1337 var i: i64 = 0 1338 while i < g.n_nodes { 1339 if g.nodes[i].op == NX_EQ_OP_ADD { 1340 let k0: i64 = nx_eqsat_find(g, g.nodes[i].kid0) 1341 let k1: i64 = nx_eqsat_find(g, g.nodes[i].kid1) 1342 if k0 == k1 { 1343 let one: i64 = nx_eqsat_add_const(g, 1) 1344 let shifted: i64 = nx_eqsat_add_binary(g, NX_EQ_OP_SHL, k0, one) 1345 nx_eqsat_union_cited(g, g.nodes[i].eclass, shifted, NX_EQSAT_RULE_ADD_SELF) 1346 unions = unions + 1 1347 } 1348 } 1349 i = i + 1 1350 } 1351 return unions 1352} 1353 1354func nx_eqsat_apply_rule_and_self(g: *NxEGraph) -> i64 { 1355 // (and x x) == x -- idempotence 1356 var unions: i64 = 0 1357 var i: i64 = 0 1358 while i < g.n_nodes { 1359 if g.nodes[i].op == NX_EQ_OP_AND { 1360 let k0: i64 = nx_eqsat_find(g, g.nodes[i].kid0) 1361 let k1: i64 = nx_eqsat_find(g, g.nodes[i].kid1) 1362 if k0 == k1 { 1363 nx_eqsat_union_cited(g, g.nodes[i].eclass, k0, NX_EQSAT_RULE_AND_SELF) 1364 unions = unions + 1 1365 } 1366 } 1367 i = i + 1 1368 } 1369 return unions 1370} 1371 1372func nx_eqsat_apply_rule_or_zero(g: *NxEGraph) -> i64 { 1373 // (or x 0) == x 1374 var unions: i64 = 0 1375 var i: i64 = 0 1376 while i < g.n_nodes { 1377 if g.nodes[i].op == NX_EQ_OP_OR { 1378 let k0: i64 = nx_eqsat_find(g, g.nodes[i].kid0) 1379 let k1: i64 = nx_eqsat_find(g, g.nodes[i].kid1) 1380 let k0_node: i64 = g.classes[k0].best_node 1381 let k1_node: i64 = g.classes[k1].best_node 1382 if g.nodes[k0_node].op == NX_EQ_OP_CONST { 1383 if g.nodes[k0_node].payload == 0 { 1384 nx_eqsat_union_cited(g, g.nodes[i].eclass, k1, NX_EQSAT_RULE_OR_ZERO) 1385 unions = unions + 1 1386 } 1387 } 1388 if g.nodes[k1_node].op == NX_EQ_OP_CONST { 1389 if g.nodes[k1_node].payload == 0 { 1390 nx_eqsat_union_cited(g, g.nodes[i].eclass, k0, NX_EQSAT_RULE_OR_ZERO) 1391 unions = unions + 1 1392 } 1393 } 1394 } 1395 i = i + 1 1396 } 1397 return unions 1398} 1399 1400func nx_eqsat_apply_rule_mul_one(g: *NxEGraph) -> i64 { 1401 // (mul x 1) == x 1402 var unions: i64 = 0 1403 var i: i64 = 0 1404 while i < g.n_nodes { 1405 if g.nodes[i].op == NX_EQ_OP_MUL { 1406 let k0: i64 = nx_eqsat_find(g, g.nodes[i].kid0) 1407 let k1: i64 = nx_eqsat_find(g, g.nodes[i].kid1) 1408 let k1_node: i64 = g.classes[k1].best_node 1409 if g.nodes[k1_node].op == NX_EQ_OP_CONST { 1410 if g.nodes[k1_node].payload == 1 { 1411 nx_eqsat_union_cited(g, g.nodes[i].eclass, k0, NX_EQSAT_RULE_MUL_ONE) 1412 unions = unions + 1 1413 } 1414 } 1415 } 1416 i = i + 1 1417 } 1418 return unions 1419} 1420 1421// NX_EQSAT_W USED TO BE DECLARED HERE and is now at the module head -- see the note there. It was 1422// read 470 lines above this point, which nx_cc refuses because the reader would silently see 0 and 1423// invert every shift-width soundness guard in the file. 1424 1425func nx_eqsat_apply_rule_mul_pow2(g: *NxEGraph) -> i64 { 1426 // (mul x 2^k) == (shl x k) for 1<=k<W -- strength reduction (cost 3 -> 1). 1427 // Counts only REAL merges (idempotent re-application), so the saturation 1428 // loop can actually converge to NX_EQSAT_SATURATED -- the V1 rules count 1429 // every attempt, so they never report saturation; this one does it right. 1430 var unions: i64 = 0 1431 var i: i64 = 0 1432 while i < g.n_nodes { 1433 if g.nodes[i].op == NX_EQ_OP_MUL { 1434 let ka: i64 = nx_eqsat_find(g, g.nodes[i].kid0) 1435 let kb: i64 = nx_eqsat_find(g, g.nodes[i].kid1) 1436 let ka_node: i64 = g.classes[ka].best_node 1437 let kb_node: i64 = g.classes[kb].best_node 1438 var base: i64 = 0 - 1 1439 var cval: i64 = 0 1440 // const on the right (x * C); then const on the left (C * x) 1441 if g.nodes[kb_node].op == NX_EQ_OP_CONST { base = ka; cval = g.nodes[kb_node].payload } 1442 if g.nodes[ka_node].op == NX_EQ_OP_CONST { base = kb; cval = g.nodes[ka_node].payload } 1443 if base >= 0 { 1444 if cval > 1 { 1445 // power-of-two test + log2 by repeated halving (no bit-ops needed) 1446 var k: i64 = 0 1447 var t: i64 = cval 1448 var is_pow2: i64 = 1 1449 while t > 1 { 1450 if (t % 2) != 0 { is_pow2 = 0 } 1451 t = t / 2 1452 k = k + 1 1453 } 1454 // k<W GUARD: refuse the union where the identity is unsound. 1455 if is_pow2 == 1 { if k < NX_EQSAT_W { 1456 let kconst: i64 = nx_eqsat_add_const(g, k) 1457 let shifted: i64 = nx_eqsat_add_binary(g, NX_EQ_OP_SHL, base, kconst) 1458 if nx_eqsat_find(g, g.nodes[i].eclass) != nx_eqsat_find(g, shifted) { 1459 nx_eqsat_union_cited(g, g.nodes[i].eclass, shifted, NX_EQSAT_RULE_MUL_POW2) 1460 unions = unions + 1 1461 } 1462 } } 1463 } 1464 } 1465 } 1466 i = i + 1 1467 } 1468 return unions 1469} 1470 1471// ===== PER-NODE INLINE MATCHER (the incremental-match entry) ================= 1472// 1473// nx_eqsat_match_node_inline applies the SAME 7 inline rule bodies as the full-scan 1474// appliers above, but to a SINGLE node `nidx` (dispatched on its op), in the SAME 1475// FIXED ORDER the full-scan visits them per node within one saturate pass (add_zero 1476// -> sub_self -> add_self -> and_self -> or_zero -> mul_one -> mul_pow2). Each rule 1477// body is COPIED VERBATIM from its applier's loop body (same find/best_node reads, 1478// same union_cited cites, same CNT_EVERY vs CNT_REAL_MERGE counting), so a node that 1479// the full-scan would fire on fires here identically. Returns the union count for 1480// this node (summed across all applicable rules). This is the incremental analogue 1481// of one full-scan iteration restricted to the changed/new nodes. 1482func nx_eqsat_match_node_inline(g: *NxEGraph, nidx: i64) -> i64 { 1483 var unions: i64 = 0 1484 let op: i64 = g.nodes[nidx].op 1485 // ---- add_zero + add_self (op == ADD) ---- 1486 if op == NX_EQ_OP_ADD { 1487 let k0: i64 = nx_eqsat_find(g, g.nodes[nidx].kid0) 1488 let k1: i64 = nx_eqsat_find(g, g.nodes[nidx].kid1) 1489 // add_zero: (add x 0) == x (dual order, count every) 1490 let k0_node: i64 = g.classes[k0].best_node 1491 let k1_node: i64 = g.classes[k1].best_node 1492 if g.nodes[k0_node].op == NX_EQ_OP_CONST { 1493 if g.nodes[k0_node].payload == 0 { 1494 nx_eqsat_union_cited(g, g.nodes[nidx].eclass, k1, NX_EQSAT_RULE_ADD_ZERO) 1495 unions = unions + 1 1496 } 1497 } 1498 if g.nodes[k1_node].op == NX_EQ_OP_CONST { 1499 if g.nodes[k1_node].payload == 0 { 1500 nx_eqsat_union_cited(g, g.nodes[nidx].eclass, k0, NX_EQSAT_RULE_ADD_ZERO) 1501 unions = unions + 1 1502 } 1503 } 1504 // add_self: (add x x) == (shl x 1) (re-read finds: a prior union above may 1505 // have repointed the class, exactly as the full-scan re-finds per applier) 1506 let a0: i64 = nx_eqsat_find(g, g.nodes[nidx].kid0) 1507 let a1: i64 = nx_eqsat_find(g, g.nodes[nidx].kid1) 1508 if a0 == a1 { 1509 let one: i64 = nx_eqsat_add_const(g, 1) 1510 let shifted: i64 = nx_eqsat_add_binary(g, NX_EQ_OP_SHL, a0, one) 1511 nx_eqsat_union_cited(g, g.nodes[nidx].eclass, shifted, NX_EQSAT_RULE_ADD_SELF) 1512 unions = unions + 1 1513 } 1514 } 1515 // ---- sub_self (op == SUB) ---- 1516 if op == NX_EQ_OP_SUB { 1517 let k0: i64 = nx_eqsat_find(g, g.nodes[nidx].kid0) 1518 let k1: i64 = nx_eqsat_find(g, g.nodes[nidx].kid1) 1519 if k0 == k1 { 1520 let zero_class: i64 = nx_eqsat_add_const(g, 0) 1521 nx_eqsat_union_cited(g, g.nodes[nidx].eclass, zero_class, NX_EQSAT_RULE_SUB_SELF) 1522 unions = unions + 1 1523 } 1524 } 1525 // ---- and_self (op == AND) ---- 1526 if op == NX_EQ_OP_AND { 1527 let k0: i64 = nx_eqsat_find(g, g.nodes[nidx].kid0) 1528 let k1: i64 = nx_eqsat_find(g, g.nodes[nidx].kid1) 1529 if k0 == k1 { 1530 nx_eqsat_union_cited(g, g.nodes[nidx].eclass, k0, NX_EQSAT_RULE_AND_SELF) 1531 unions = unions + 1 1532 } 1533 } 1534 // ---- or_zero (op == OR) ---- 1535 if op == NX_EQ_OP_OR { 1536 let k0: i64 = nx_eqsat_find(g, g.nodes[nidx].kid0) 1537 let k1: i64 = nx_eqsat_find(g, g.nodes[nidx].kid1) 1538 let k0_node: i64 = g.classes[k0].best_node 1539 let k1_node: i64 = g.classes[k1].best_node 1540 if g.nodes[k0_node].op == NX_EQ_OP_CONST { 1541 if g.nodes[k0_node].payload == 0 { 1542 nx_eqsat_union_cited(g, g.nodes[nidx].eclass, k1, NX_EQSAT_RULE_OR_ZERO) 1543 unions = unions + 1 1544 } 1545 } 1546 if g.nodes[k1_node].op == NX_EQ_OP_CONST { 1547 if g.nodes[k1_node].payload == 0 { 1548 nx_eqsat_union_cited(g, g.nodes[nidx].eclass, k0, NX_EQSAT_RULE_OR_ZERO) 1549 unions = unions + 1 1550 } 1551 } 1552 } 1553 // ---- mul_one + mul_pow2 (op == MUL) ---- 1554 if op == NX_EQ_OP_MUL { 1555 // mul_one: (mul x 1) == x (RIGHT-const only, exactly as the inline applier) 1556 let k0: i64 = nx_eqsat_find(g, g.nodes[nidx].kid0) 1557 let k1: i64 = nx_eqsat_find(g, g.nodes[nidx].kid1) 1558 let k1_node: i64 = g.classes[k1].best_node 1559 if g.nodes[k1_node].op == NX_EQ_OP_CONST { 1560 if g.nodes[k1_node].payload == 1 { 1561 nx_eqsat_union_cited(g, g.nodes[nidx].eclass, k0, NX_EQSAT_RULE_MUL_ONE) 1562 unions = unions + 1 1563 } 1564 } 1565 // mul_pow2: (mul x 2^k) == (shl x k) for 1<=k<W (count REAL merges only) 1566 let ka: i64 = nx_eqsat_find(g, g.nodes[nidx].kid0) 1567 let kb: i64 = nx_eqsat_find(g, g.nodes[nidx].kid1) 1568 let ka_node: i64 = g.classes[ka].best_node 1569 let kb_node: i64 = g.classes[kb].best_node 1570 var base: i64 = 0 - 1 1571 var cval: i64 = 0 1572 if g.nodes[kb_node].op == NX_EQ_OP_CONST { base = ka; cval = g.nodes[kb_node].payload } 1573 if g.nodes[ka_node].op == NX_EQ_OP_CONST { base = kb; cval = g.nodes[ka_node].payload } 1574 if base >= 0 { 1575 if cval > 1 { 1576 var k: i64 = 0 1577 var t: i64 = cval 1578 var is_pow2: i64 = 1 1579 while t > 1 { 1580 if (t % 2) != 0 { is_pow2 = 0 } 1581 t = t / 2 1582 k = k + 1 1583 } 1584 if is_pow2 == 1 { if k < NX_EQSAT_W { 1585 let kconst: i64 = nx_eqsat_add_const(g, k) 1586 let shifted: i64 = nx_eqsat_add_binary(g, NX_EQ_OP_SHL, base, kconst) 1587 if nx_eqsat_find(g, g.nodes[nidx].eclass) != nx_eqsat_find(g, shifted) { 1588 nx_eqsat_union_cited(g, g.nodes[nidx].eclass, shifted, NX_EQSAT_RULE_MUL_POW2) 1589 unions = unions + 1 1590 } 1591 } } 1592 } 1593 } 1594 } 1595 return unions 1596} 1597 1598// ===== GENERAL E-MATCHER (the egg data-driven rewrite path) ================= 1599// 1600// nx_eqsat_apply_dsl_rule applies ONE NxDslRule over the whole e-graph, replacing 1601// all 7 inline rule loops with a single data-interpreted matcher. It is proven 1602// BYTE-IDENTICAL to the inline path by nx_eqsat_dsl_parity_test.nx; the row order 1603// in nx_eqsat_builtin_dsl_table mirrors the inline call order so the merge 1604// sequence (and thus best_node/cost/provenance log) is the same. 1605// 1606// SAFETY (defends the wrong-class-binding trap): every bound variable is a 1607// CANONICAL class id via nx_eqsat_find (never best_node, which can be stale). A 1608// DSL_CONST slot uses best_node ONLY as a read-only value probe (exactly as the 1609// inline rules do, e.g. add_zero lines 553-556). The merge ALWAYS flows through 1610// the single chokepoint nx_eqsat_union_cited (provenance log + worklist-dirty + 1611// the RULE_NONE fail-closed poison are preserved -- no bypass). 1612// 1613// log2-by-repeated-halving helper for the mul_pow2 side-condition: returns the 1614// exponent k if `c` is 2^k with 1<k... else returns -1 (not a usable power-of-two). 1615// Mirrors the inline mul_pow2 loop (lines 879-882) so the guard is the SAME. 1616func nx_eqsat_dsl_log2_pow2(c: i64) -> i64 { 1617 if c <= 1 { return 0 - 1 } 1618 var k: i64 = 0 1619 var t: i64 = c 1620 var is_pow2: i64 = 1 1621 while t > 1 { 1622 if (t % 2) != 0 { is_pow2 = 0 } 1623 t = t / 2 1624 k = k + 1 1625 } 1626 if is_pow2 != 1 { return 0 - 1 } 1627 return k 1628} 1629 1630// Is the class `cls`'s best_node a CONST whose payload == want? (value probe only, 1631// exactly as the inline const-zero/const-one rules test g.classes[k].best_node.) 1632func nx_eqsat_dsl_class_is_const(g: *NxEGraph, cls: i64, want: i64) -> i64 { 1633 let bn: i64 = g.classes[cls].best_node 1634 if g.nodes[bn].op != NX_EQ_OP_CONST { return 0 } 1635 if g.nodes[bn].payload != want { return 0 } 1636 return 1 1637} 1638 1639// Read the const payload of canonical class `cls` into out_v[0] iff its best_node is 1640// a CONST (re-verified shape). Returns 1 on success, 0 otherwise. A CONST class's 1641// best_node is cost-stable (cost 0 is strictly cheapest, never displaced -- the same 1642// lattice invariant nx_eqsat_class_const relies on), so this read is safe to bind a 1643// rule variable from (trap-3 defense: ?i/?j come from CONST classes ONLY). 1644func nx_eqsat_dsl_const_val(g: *NxEGraph, cls: i64, out_v: *i64) -> i64 { 1645 let bn: i64 = g.classes[cls].best_node 1646 if g.nodes[bn].op != NX_EQ_OP_CONST { return 0 } 1647 if g.nodes[bn].n_kids != 0 { return 0 } 1648 out_v[0] = g.nodes[bn].payload 1649 return 1 1650} 1651 1652// ---- shape (d) shift-merge matcher: (shl (shl ?x ?i) ?j) -> (shl ?x (i+j)) ---- 1653// Given an OUTER node `nidx` already known to be op==SHL with canonical children 1654// k0=find(kid0), k1=find(kid1): require k1 to be a CONST (=?j) AND k0's best_node to 1655// be a structural SHL whose own kid1's canonical class is a CONST (=?i) and whose 1656// kid0 binds ?x = find(inner.kid0). Guard i,j>=0 and i+j<W (SC_SHIFT_MERGE_IJW: the 1657// boundary is UNSOUND). On a fire, instantiate (shl ?x (i+j)) and union the outer 1658// class with it citing rule_id, CNT_REAL_MERGE (find!=find) so it converges to 1659// SATURATED. Returns 1 iff a REAL merge happened, else 0. FAIL-CLOSED: any ambiguous 1660// binding (k0 not a SHL, inner kid1 not const, j not const, out-of-range) => no fire. 1661// 1662// trap-3 defense: ?i and ?j are bound from CONST classes ONLY (cost-0, best_node- 1663// stable); ?x is bound as a CANONICAL class id via find. We never read a shift amount 1664// off a non-const class's best_node, so a saturation-displaced best_node cannot feed 1665// a wrong shift amount. The inner SHL node is located via k0.best_node, and its kid1 1666// is re-canonicalized + re-verified CONST before its value is trusted. 1667func nx_eqsat_dsl_shift_merge(g: *NxEGraph, table: *NxDslRule, ri: i64, 1668 eclass: i64, k0: i64, k1: i64) -> i64 { 1669 // ?j: the OUTER shift amount must be a known constant. 1670 let pj: *i64 = sys_mmap(8) as *i64 1671 if nx_eqsat_dsl_const_val(g, k1, pj) != 1 { return 0 } 1672 let j: i64 = pj[0] 1673 // the OUTER's first child must itself be a structural SHL (the nested shape). 1674 let inner_n: i64 = g.classes[k0].best_node 1675 if g.nodes[inner_n].op != NX_EQ_OP_SHL { return 0 } 1676 if g.nodes[inner_n].n_kids != 2 { return 0 } 1677 // ?i: the INNER shift amount (its kid1's canonical class) must be a known const. 1678 let in_kc1: i64 = nx_eqsat_find(g, g.nodes[inner_n].kid1) 1679 let pi: *i64 = sys_mmap(8) as *i64 1680 if nx_eqsat_dsl_const_val(g, in_kc1, pi) != 1 { return 0 } 1681 let i_amt: i64 = pi[0] 1682 // ?x: the INNER's first child, bound as a CANONICAL class id (never a value). 1683 let bx: i64 = nx_eqsat_find(g, g.nodes[inner_n].kid0) 1684 // GUARD (SC_SHIFT_MERGE_IJW): i,j>=0 and i+j<W -- refuse the unsound boundary. 1685 if i_amt < 0 { return 0 } 1686 if j < 0 { return 0 } 1687 let sum: i64 = i_amt + j 1688 if sum >= NX_EQSAT_W { return 0 } 1689 // instantiate (shl ?x (i+j)) and merge (CNT_REAL_MERGE: count only real merges). 1690 let rhs: i64 = nx_eqsat_dsl_instantiate_rhs(g, table, ri, bx, sum) 1691 if nx_eqsat_find(g, eclass) != nx_eqsat_find(g, rhs) { 1692 nx_eqsat_union_cited(g, eclass, rhs, table[ri].rule_id) 1693 return 1 1694 } 1695 return 0 1696} 1697 1698// Instantiate the RHS template of rule `r` given the bound canonical class `bindA` 1699// and (for the mul_pow2 shape) the log2 shift `shamt`. Returns the RHS e-class id 1700// through the existing self-canonicalizing add* wrappers. RHS_BIND_A returns the 1701// bound class verbatim (the (..)==x identities); RHS_CONST adds a const; the SHL 1702// forms add (shl bindA <amt|log2>). No RHS interpreter -- a closed switch. 1703func nx_eqsat_dsl_instantiate_rhs(g: *NxEGraph, table: *NxDslRule, ri: i64, bindA: i64, shamt: i64) -> i64 { 1704 if table[ri].rhs_kind == RHS_BIND_A { return bindA } 1705 if table[ri].rhs_kind == RHS_CONST { return nx_eqsat_add_const(g, table[ri].rhs_val) } 1706 if table[ri].rhs_kind == RHS_SHL_A_BY_CONST { 1707 let kc: i64 = nx_eqsat_add_const(g, table[ri].rhs_val) 1708 return nx_eqsat_add_binary(g, NX_EQ_OP_SHL, bindA, kc) 1709 } 1710 if table[ri].rhs_kind == RHS_SHL_A_BY_LOG2B { 1711 let kc2: i64 = nx_eqsat_add_const(g, shamt) 1712 return nx_eqsat_add_binary(g, NX_EQ_OP_SHL, bindA, kc2) 1713 } 1714 // shift-merge: emit (shl bindA (i+j)) where the COMPUTED sum was handed in as 1715 // `shamt` (the matcher summed the two CONST shift amounts). Same single-SHL 1716 // shape as RHS_SHL_A_BY_LOG2B, just sourced from the i+j computation. 1717 if table[ri].rhs_kind == RHS_SHL_A_BY_IADDED_J { 1718 let kc3: i64 = nx_eqsat_add_const(g, shamt) 1719 return nx_eqsat_add_binary(g, NX_EQ_OP_SHL, bindA, kc3) 1720 } 1721 return 0 - 1 1722} 1723 1724func nx_eqsat_apply_dsl_rule(g: *NxEGraph, table: *NxDslRule, ri: i64) -> i64 { 1725 var unions: i64 = 0 1726 var i: i64 = 0 1727 while i < g.n_nodes { 1728 if g.nodes[i].op == table[ri].lhs_op { 1729 let k0: i64 = nx_eqsat_find(g, g.nodes[i].kid0) 1730 let k1: i64 = nx_eqsat_find(g, g.nodes[i].kid1) 1731 1732 // ---- shape (a) "x OP x": b_kind == DSL_SAME_AS_A ---- 1733 // sub_self / add_self / and_self (CNT_EVERY, byte-identical to inline) and 1734 // xor_self (CNT_REAL_MERGE so it converges to SATURATED -- the new rule's 1735 // (xor x x)==0 is idempotent, like mul_pow2). Single ordering; fire on 1736 // k0==k1. CNT_REAL_MERGE counts ONLY when the union actually merges two 1737 // distinct classes (find!=find), so re-application is a no-op for `total`. 1738 if table[ri].b_kind == DSL_SAME_AS_A { 1739 if k0 == k1 { 1740 let rhs: i64 = nx_eqsat_dsl_instantiate_rhs(g, table, ri, k0, 0) 1741 if table[ri].count_mode == CNT_REAL_MERGE { 1742 if nx_eqsat_find(g, g.nodes[i].eclass) != nx_eqsat_find(g, rhs) { 1743 nx_eqsat_union_cited(g, g.nodes[i].eclass, rhs, table[ri].rule_id) 1744 unions = unions + 1 1745 } 1746 } else { 1747 nx_eqsat_union_cited(g, g.nodes[i].eclass, rhs, table[ri].rule_id) 1748 unions = unions + 1 1749 } 1750 } 1751 } 1752 1753 // ---- shape (b) mul_pow2: SC_POW2_B_KLTW + RHS_SHL_A_BY_LOG2B ---- 1754 // Const on either side (left overrides right, as inline). One union per 1755 // node; guarded by pow2 & k<W; counts ONLY real merges (find!=find). 1756 if table[ri].side_cond == SC_POW2_B_KLTW { 1757 let k0n: i64 = g.classes[k0].best_node 1758 let k1n: i64 = g.classes[k1].best_node 1759 var base: i64 = 0 - 1 1760 var cval: i64 = 0 1761 if g.nodes[k1n].op == NX_EQ_OP_CONST { base = k0; cval = g.nodes[k1n].payload } 1762 if g.nodes[k0n].op == NX_EQ_OP_CONST { base = k1; cval = g.nodes[k0n].payload } 1763 if base >= 0 { 1764 let sh: i64 = nx_eqsat_dsl_log2_pow2(cval) 1765 if sh >= 0 { if sh < NX_EQSAT_W { 1766 let rhsm: i64 = nx_eqsat_dsl_instantiate_rhs(g, table, ri, base, sh) 1767 if nx_eqsat_find(g, g.nodes[i].eclass) != nx_eqsat_find(g, rhsm) { 1768 nx_eqsat_union_cited(g, g.nodes[i].eclass, rhsm, table[ri].rule_id) 1769 unions = unions + 1 1770 } 1771 } } 1772 } 1773 } 1774 1775 // ---- shape (c) const-slot rules: b_kind == DSL_CONST, no SC ---- 1776 // add_zero / or_zero (dual_order=1, fire both orderings) and mul_one 1777 // (dual_order=0, right-const only). The bound var is the OTHER child. 1778 if table[ri].b_kind == DSL_CONST { if table[ri].side_cond == SC_NONE { 1779 // RIGHT-const ordering: test k1 == CONST(b_val), bind A=k0. 1780 if nx_eqsat_dsl_class_is_const(g, k1, table[ri].b_val) == 1 { 1781 let rhsR: i64 = nx_eqsat_dsl_instantiate_rhs(g, table, ri, k0, 0) 1782 nx_eqsat_union_cited(g, g.nodes[i].eclass, rhsR, table[ri].rule_id) 1783 unions = unions + 1 1784 } 1785 // LEFT-const ordering (only when dual_order): test k0 == CONST, bind A=k1. 1786 if table[ri].dual_order == 1 { 1787 if nx_eqsat_dsl_class_is_const(g, k0, table[ri].b_val) == 1 { 1788 let rhsL: i64 = nx_eqsat_dsl_instantiate_rhs(g, table, ri, k1, 0) 1789 nx_eqsat_union_cited(g, g.nodes[i].eclass, rhsL, table[ri].rule_id) 1790 unions = unions + 1 1791 } 1792 } 1793 } } 1794 1795 // ---- shape (d) shift-merge: SC_SHIFT_MERGE_IJW (nested SHL-of-SHL) ---- 1796 // (shl (shl ?x ?i) ?j) -> (shl ?x (i+j)), ?i,?j CONST, i+j<W. The LHS first 1797 // child is itself a structural SHL (distinct from (a)/(b)/(c) leaf shapes); 1798 // a real merge converges to SATURATED (CNT_REAL_MERGE, idempotent). 1799 if table[ri].side_cond == SC_SHIFT_MERGE_IJW { 1800 unions = unions + nx_eqsat_dsl_shift_merge(g, table, ri, g.nodes[i].eclass, k0, k1) 1801 } 1802 } 1803 i = i + 1 1804 } 1805 return unions 1806} 1807 1808// Apply the whole DSL table in row order (the egg "run every rule" pass). Indexes 1809// g.dsl[ri] by struct stride (compiler-computed) -- no magic byte offset. 1810func nx_eqsat_apply_dsl_table(g: *NxEGraph) -> i64 { 1811 var total: i64 = 0 1812 var ri: i64 = 0 1813 while ri < g.n_dsl { 1814 total = total + nx_eqsat_apply_dsl_rule(g, g.dsl, ri) 1815 ri = ri + 1 1816 } 1817 return total 1818} 1819 1820// PER-NODE DSL matcher (incremental-match entry for the data-driven path). Applies 1821// EVERY row of g.dsl to the SINGLE node `nidx`, in row order, with the SAME shape-(a) 1822// /(b)/(c) bodies as nx_eqsat_apply_dsl_rule (copied verbatim, indexed by nidx instead 1823// of the scan cursor `i`). Returns the union count for this node. Defensive: no 1824// current organ combines meet (which arms match_on) with the DSL table, but supporting 1825// both keeps the incremental path complete if they are ever composed. 1826func nx_eqsat_match_node_dsl(g: *NxEGraph, nidx: i64) -> i64 { 1827 var unions: i64 = 0 1828 var ri: i64 = 0 1829 while ri < g.n_dsl { 1830 if g.nodes[nidx].op == g.dsl[ri].lhs_op { 1831 let k0: i64 = nx_eqsat_find(g, g.nodes[nidx].kid0) 1832 let k1: i64 = nx_eqsat_find(g, g.nodes[nidx].kid1) 1833 // shape (a) "x OP x" 1834 if g.dsl[ri].b_kind == DSL_SAME_AS_A { 1835 if k0 == k1 { 1836 let rhs: i64 = nx_eqsat_dsl_instantiate_rhs(g, g.dsl, ri, k0, 0) 1837 if g.dsl[ri].count_mode == CNT_REAL_MERGE { 1838 if nx_eqsat_find(g, g.nodes[nidx].eclass) != nx_eqsat_find(g, rhs) { 1839 nx_eqsat_union_cited(g, g.nodes[nidx].eclass, rhs, g.dsl[ri].rule_id) 1840 unions = unions + 1 1841 } 1842 } else { 1843 nx_eqsat_union_cited(g, g.nodes[nidx].eclass, rhs, g.dsl[ri].rule_id) 1844 unions = unions + 1 1845 } 1846 } 1847 } 1848 // shape (b) mul_pow2 1849 if g.dsl[ri].side_cond == SC_POW2_B_KLTW { 1850 let k0n: i64 = g.classes[k0].best_node 1851 let k1n: i64 = g.classes[k1].best_node 1852 var base: i64 = 0 - 1 1853 var cval: i64 = 0 1854 if g.nodes[k1n].op == NX_EQ_OP_CONST { base = k0; cval = g.nodes[k1n].payload } 1855 if g.nodes[k0n].op == NX_EQ_OP_CONST { base = k1; cval = g.nodes[k0n].payload } 1856 if base >= 0 { 1857 let sh: i64 = nx_eqsat_dsl_log2_pow2(cval) 1858 if sh >= 0 { if sh < NX_EQSAT_W { 1859 let rhsm: i64 = nx_eqsat_dsl_instantiate_rhs(g, g.dsl, ri, base, sh) 1860 if nx_eqsat_find(g, g.nodes[nidx].eclass) != nx_eqsat_find(g, rhsm) { 1861 nx_eqsat_union_cited(g, g.nodes[nidx].eclass, rhsm, g.dsl[ri].rule_id) 1862 unions = unions + 1 1863 } 1864 } } 1865 } 1866 } 1867 // shape (c) const-slot rules 1868 if g.dsl[ri].b_kind == DSL_CONST { if g.dsl[ri].side_cond == SC_NONE { 1869 if nx_eqsat_dsl_class_is_const(g, k1, g.dsl[ri].b_val) == 1 { 1870 let rhsR: i64 = nx_eqsat_dsl_instantiate_rhs(g, g.dsl, ri, k0, 0) 1871 nx_eqsat_union_cited(g, g.nodes[nidx].eclass, rhsR, g.dsl[ri].rule_id) 1872 unions = unions + 1 1873 } 1874 if g.dsl[ri].dual_order == 1 { 1875 if nx_eqsat_dsl_class_is_const(g, k0, g.dsl[ri].b_val) == 1 { 1876 let rhsL: i64 = nx_eqsat_dsl_instantiate_rhs(g, g.dsl, ri, k1, 0) 1877 nx_eqsat_union_cited(g, g.nodes[nidx].eclass, rhsL, g.dsl[ri].rule_id) 1878 unions = unions + 1 1879 } 1880 } 1881 } } 1882 // shape (d) shift-merge (nested SHL-of-SHL); same matcher as the full-scan. 1883 if g.dsl[ri].side_cond == SC_SHIFT_MERGE_IJW { 1884 unions = unions + nx_eqsat_dsl_shift_merge(g, g.dsl, ri, g.nodes[nidx].eclass, k0, k1) 1885 } 1886 } 1887 ri = ri + 1 1888 } 1889 return unions 1890} 1891 1892// Dispatch one node to the active matcher: DSL table when loaded, else the 7 inline 1893// rules. Mirrors nx_eqsat_saturate's full-scan branch so the incremental path fires 1894// exactly the rules the full-scan would. 1895func nx_eqsat_match_node(g: *NxEGraph, nidx: i64) -> i64 { 1896 if (g.dsl as i64) != 0 { return nx_eqsat_match_node_dsl(g, nidx) } 1897 return nx_eqsat_match_node_inline(g, nidx) 1898} 1899 1900// ===== MEET: deferred congruence rebuild (egg's "rebuild") ================== 1901// 1902// Restore the hashcons invariant after a batch of unions and propagate the 1903// congruence closure: re-canonicalize every e-node's children, re-fingerprint it, 1904// and if a DISTINCT node is already interned under that fingerprint then the two 1905// are CONGRUENT (same op, same payload, canonically-equal children) and get merged 1906// -- ALWAYS through nx_eqsat_union_cited citing NX_EQSAT_RULE_CONGRUENCE (never the 1907// 2-arg back door). Each such union re-dirties classes, so we loop to FIXPOINT. 1908// Convergence: every congruence union strictly reduces the live class count 1909// (union-find monotone), so the worklist empties; guard_max bounds it like 1910// nx_eqsat_recompute_best. Returns the number of congruence unions performed. 1911// 1912// Enumeration: this landing scans ALL nodes per drain pass (provably COMPLETE -- 1913// it cannot miss a congruent parent), and the re-canonicalization writes the new 1914// canonical children back into the node + re-interns under the new fingerprint 1915// (deleting the stale key first), so the hashcons never points at a dead 1916// representative. The parent back-index (par_node/par_cls) is maintained for a 1917// future incremental variant; correctness here does not depend on it. 1918func nx_eqsat_rebuild(g: *NxEGraph) -> i64 { 1919 if g.rebuild_on != 1 { return 0 } 1920 if (g.hc as i64) == 0 { return 0 } 1921 var cong_unions: i64 = 0 1922 var guard: i64 = 0 1923 let guard_max: i64 = g.n_nodes + g.n_classes + 8 1924 // Drain to fixpoint: while there is dirt OR a pass produced a new merge. 1925 var more: i64 = 1 1926 while more == 1 { 1927 more = 0 1928 guard = guard + 1 1929 if guard > guard_max { return cong_unions } 1930 g.n_work = 0 // consume the current dirt; new merges re-arm `more` 1931 // One complete pass over the node array: re-canonicalize + re-intern, and 1932 // congruence-merge any two distinct nodes that collapse to one fingerprint. 1933 var n: i64 = 0 1934 while n < g.n_nodes { 1935 let op: i64 = g.nodes[n].op 1936 let arity: i64 = nx_eqsat_arity_for(op) 1937 let payload: i64 = g.nodes[n].payload 1938 // current (possibly stale) canonical children of THIS node 1939 let nk0: i64 = if arity >= 1 then nx_eqsat_find(g, g.nodes[n].kid0) else 0 - 1 1940 let nk1: i64 = if arity >= 2 then nx_eqsat_find(g, g.nodes[n].kid1) else 0 - 1 1941 let nk2: i64 = if arity >= 3 then nx_eqsat_find(g, g.nodes[n].kid2) else 0 - 1 1942 // re-canonicalize the children in place (so future finds are O(1) here) 1943 if arity >= 1 { g.nodes[n].kid0 = nk0 } 1944 if arity >= 2 { g.nodes[n].kid1 = nk1 } 1945 if arity >= 3 { g.nodes[n].kid2 = nk2 } 1946 let fp: i64 = nx_eqsat_fingerprint(op, payload, nk0, nk1, nk2) 1947 if nx_hmap_has(g.hc, fp) == 1 { 1948 let other: i64 = nx_hmap_get(g.hc, fp) 1949 // re-VERIFY identity (collision-proof) AND that `other` is a 1950 // DISTINCT node in a DIFFERENT canonical class than `n`. 1951 if other != n { 1952 if nx_eqsat_node_matches(g, other, op, payload, nk0, nk1, nk2) == 1 { 1953 let cn: i64 = nx_eqsat_find(g, g.nodes[n].eclass) 1954 let co: i64 = nx_eqsat_find(g, g.nodes[other].eclass) 1955 if cn != co { 1956 // CONGRUENT: merge through the cited chokepoint ONLY. 1957 nx_eqsat_union_cited(g, g.nodes[n].eclass, 1958 g.nodes[other].eclass, 1959 NX_EQSAT_RULE_CONGRUENCE) 1960 cong_unions = cong_unions + 1 1961 more = 1 1962 } 1963 } 1964 } 1965 } 1966 // restore/refresh the hashcons invariant: this fingerprint maps to a 1967 // live representative for this canonical shape. Re-key (delete stale 1968 // then put) so dead representatives never accumulate. 1969 nx_hmap_remove(g.hc, fp) 1970 nx_hmap_put(g.hc, fp, n) 1971 n = n + 1 1972 } 1973 // If the union side-effect pushed fresh dirt, drain again. 1974 if g.n_work > 0 { more = 1 } 1975 } 1976 return cong_unions 1977} 1978 1979// ===== Saturation loop ================================================= 1980// 1981// Apply every rule in fixed order; repeat until no rule produces a 1982// new union OR step budget exhausted. V1 is sequential; egg's 1983// concurrent variant ships when ZERO_TO_ADVANCED.md M2 fleshes out. 1984 1985func nx_eqsat_saturate(g: *NxEGraph, max_iters: i64) -> i64 { 1986 // ===== INCREMENTAL PATH (worklist-driven; match_on==1, auto-armed by enable_meet) = 1987 // Removes the per-iteration full node scan while QUIESCENT: each outer iteration 1988 // matches rules ONLY against (a) the rising node frontier [next_node, n_nodes) -- 1989 // which captures EVERY newly-added node (initial build + RHS/fold nodes created 1990 // during firing) with zero per-alloc-site hooks. (b) When a merge changed some 1991 // node's canonical children (union_cited sets mwl_overflow), the NEXT iteration 1992 // re-matches the full node set ONCE -- a single O(n) sweep that is a SUPERSET of 1993 // every changed parent (provably complete, never the O(n_par)-per-union quadratic). 1994 // After matching, the congruence rebuild drains exactly as the full-scan path. 1995 // `total` accumulates the SAME real merges, so the `total==0 => SATURATED` return 1996 // fires at the equivalent fixpoint (same return code). Complete (egg rebuild 1997 // theorem -- a shallow-LHS match becomes newly-possible only on node-add (a) or a 1998 // child re-canonicalization (b), both covered) + bounded (finite unions, union-find 1999 // monotone, guarded by max_iters). The genuine save: a graph that reaches quiescence 2000 // and then only grows by a few RHS nodes re-matches those FEW, not all n every iter. 2001 if g.match_on == 1 { 2002 // next_node = the new-node frontier cursor (enqueue (a)): every node from here 2003 // to n_nodes is freshly added (initial build on the first iteration, or RHS/ 2004 // fold nodes created during firing) and has not yet been matched. 2005 var next_node: i64 = 0 2006 var iter_i: i64 = 0 2007 while iter_i < max_iters { 2008 var total: i64 = 0 2009 // enqueue (b): mwl_overflow is set by union_cited whenever a class merged 2010 // this run -> some node's canonical children changed, so its parents may now 2011 // match a rule. Rather than enumerate the parent index PER union (O(n_par) 2012 // per merge => quadratic, the measured regression), we re-match the FULL node 2013 // set ONCE here (a single O(n) sweep that is a superset of every changed 2014 // parent -- provably complete, mirroring the full-scan's own per-iteration 2015 // node walk). When NO merge is pending we match ONLY the new-node frontier 2016 // (the genuine incremental save). next_node is reset so the re-scan also 2017 // re-covers the frontier. 2018 if g.mwl_overflow == 1 { 2019 g.mwl_overflow = 0 2020 var fn: i64 = 0 2021 while fn < g.n_nodes { total = total + nx_eqsat_match_node(g, fn); fn = fn + 1 } 2022 next_node = g.n_nodes 2023 } else { 2024 while next_node < g.n_nodes { 2025 let nx: i64 = next_node 2026 next_node = next_node + 1 2027 total = total + nx_eqsat_match_node(g, nx) 2028 } 2029 } 2030 // congruence rebuild (unchanged); its unions set mwl_overflow via the 2031 // union (b)-hook so the NEXT iteration re-scans. Counted into total so a 2032 // congruence-only round does not report premature SATURATED. 2033 total = total + nx_eqsat_rebuild(g) 2034 // True fixpoint: nothing matched/merged, no pending (b)-dirt, no new nodes. 2035 if total == 0 { if g.mwl_overflow == 0 { if next_node >= g.n_nodes { 2036 return NX_EQSAT_SATURATED 2037 } } } 2038 iter_i = iter_i + 1 2039 } 2040 return 0 - NX_EQSAT_STEP_BUDGET 2041 } 2042 // ===== FULL-SCAN PATH (verbatim; match_on==0 for every existing caller + the 6 2043 // non-meet certs) -- byte-identical by construction. ============================ 2044 var iter: i64 = 0 2045 while iter < max_iters { 2046 var total: i64 = 0 2047 // DSL path (egg data-driven) when a rule table is loaded; else the verbatim 2048 // 7 inline calls. g.dsl is null for every existing caller + the four gated 2049 // organs => the inline else-branch runs => byte-identical by construction. 2050 if (g.dsl as i64) != 0 { 2051 total = total + nx_eqsat_apply_dsl_table(g) 2052 } else { 2053 total = total + nx_eqsat_apply_rule_add_zero(g) 2054 total = total + nx_eqsat_apply_rule_sub_self(g) 2055 total = total + nx_eqsat_apply_rule_add_self(g) 2056 total = total + nx_eqsat_apply_rule_and_self(g) 2057 total = total + nx_eqsat_apply_rule_or_zero(g) 2058 total = total + nx_eqsat_apply_rule_mul_one(g) 2059 total = total + nx_eqsat_apply_rule_mul_pow2(g) 2060 } 2061 // MEET: after the 7 rules, drain the congruence rebuild to a fixpoint and 2062 // count its unions into `total` so a congruence-only round does NOT report 2063 // premature SATURATED. No-op (returns 0) when rebuild is OFF -> the loop is 2064 // byte-identical for every existing caller + the four gated organs. 2065 total = total + nx_eqsat_rebuild(g) 2066 if total == 0 { return NX_EQSAT_SATURATED } 2067 iter = iter + 1 2068 } 2069 return 0 - NX_EQSAT_STEP_BUDGET 2070} 2071 2072// ===== Extraction ================================================= 2073// 2074// Pick the lowest-cost representative from each e-class. V1 reads 2075// the cached `cost` + `best_node` fields; future variant runs 2076// dataflow over the saturated graph for tighter cost (egg's 2077// "Extractor" pattern). 2078 2079func nx_eqsat_extract_best_node(g: *NxEGraph, class_id: i64) -> i64 { 2080 let canon: i64 = nx_eqsat_find(g, class_id) 2081 return g.classes[canon].best_node 2082} 2083 2084// ===== Real bottom-up extractor (egg "Extractor" pattern) =================== 2085// 2086// V1's best_node/cost are fixed at class-CREATION and never recomputed against 2087// the cost model over the SATURATED graph, so nx_eqsat_extract_best_node can 2088// return a costlier representative -- e.g. it keeps a wasted `(add C 0)` as the 2089// canonical class's best_node instead of extracting the cheaper `C`. This 2090// recomputes each canonical class's best cost = min over its member nodes of 2091// (op_cost + sum of children's best class cost), iterated to a FIXPOINT (costs 2092// only decrease, so it converges). A class reachable only through itself never 2093// resolves below the sentinel, so the acyclic cheaper form always wins. Call 2094// AFTER nx_eqsat_saturate; afterwards nx_eqsat_extract_best_node returns the 2095// truly-cheapest node. (5W1H feedback hook: a future variant logs WHICH node 2096// won each class + WHY -- the cost breakdown -- for the invention loop.) 2097 2098func nx_eqsat_recompute_best(g: *NxEGraph) -> i64 { 2099 let big: i64 = NX_EQSAT_COST_INFINITY 2100 var ci: i64 = 0 2101 while ci < g.n_classes { 2102 if g.classes[ci].canon == ci { g.classes[ci].cost = big } 2103 ci = ci + 1 2104 } 2105 var changed: i64 = 1 2106 var guard: i64 = 0 2107 let guard_max: i64 = g.n_nodes + g.n_classes + 8 2108 while changed == 1 { 2109 changed = 0 2110 guard = guard + 1 2111 if guard > guard_max { return 0 - NX_EQSAT_STEP_BUDGET } 2112 var n: i64 = 0 2113 while n < g.n_nodes { 2114 let cls: i64 = nx_eqsat_find(g, g.nodes[n].eclass) 2115 let op: i64 = g.nodes[n].op 2116 let arity: i64 = nx_eqsat_arity_for(op) 2117 var c: i64 = nx_eqsat_op_cost(op) 2118 var ok: i64 = 1 2119 if arity >= 1 { 2120 let cc0: i64 = g.classes[nx_eqsat_find(g, g.nodes[n].kid0)].cost 2121 if cc0 >= big { ok = 0 } 2122 if cc0 < big { c = c + cc0 } 2123 } 2124 if arity >= 2 { 2125 let cc1: i64 = g.classes[nx_eqsat_find(g, g.nodes[n].kid1)].cost 2126 if cc1 >= big { ok = 0 } 2127 if cc1 < big { c = c + cc1 } 2128 } 2129 if arity >= 3 { 2130 let cc2: i64 = g.classes[nx_eqsat_find(g, g.nodes[n].kid2)].cost 2131 if cc2 >= big { ok = 0 } 2132 if cc2 < big { c = c + cc2 } 2133 } 2134 if ok == 1 { 2135 if c < g.classes[cls].cost { 2136 g.classes[cls].cost = c 2137 g.classes[cls].best_node = n 2138 changed = 1 2139 } 2140 } 2141 n = n + 1 2142 } 2143 } 2144 return NX_EQSAT_OK 2145} 2146 2147// Best cost of an e-class after nx_eqsat_recompute_best (canonical lookup). 2148func nx_eqsat_best_cost(g: *NxEGraph, class_id: i64) -> i64 { 2149 let canon: i64 = nx_eqsat_find(g, class_id) 2150 return g.classes[canon].cost 2151} 2152 2153// ===== Program-emitting extractor ========================================== 2154// 2155// nx_eqsat_extract_best_node only returns a node INDEX -- it cannot hand a 2156// backend the actual optimized PROGRAM (the "can't emit a program" gap). This 2157// emits the cost-minimal expression as a flat, topologically-ordered DAG: each 2158// NxEmitNode references its children by their out-array index, children before 2159// parents (post-order), so a code generator can consume it directly. Call 2160// AFTER nx_eqsat_recompute_best. The extraction is acyclic by construction 2161// (the cost-minimal best_node never closes a cycle), so the recursion 2162// terminates; overflow past `cap` returns 0 - NX_EQSAT_OVERFLOW. 2163 2164struct NxEmitNode { 2165 op: i64 2166 payload: i64 2167 c0: i64 // out-array index of child 0; -1 if none 2168 c1: i64 2169 c2: i64 2170} 2171 2172func nx_eqsat_emit(g: *NxEGraph, class_id: i64, out: *NxEmitNode, cap: i64, count: *i64) -> i64 { 2173 let canon: i64 = nx_eqsat_find(g, class_id) 2174 let node_idx: i64 = g.classes[canon].best_node 2175 let op: i64 = g.nodes[node_idx].op 2176 let arity: i64 = nx_eqsat_arity_for(op) 2177 var e0: i64 = 0 - 1 2178 var e1: i64 = 0 - 1 2179 var e2: i64 = 0 - 1 2180 if arity >= 1 { 2181 e0 = nx_eqsat_emit(g, g.nodes[node_idx].kid0, out, cap, count) 2182 if e0 < 0 { return e0 } 2183 } 2184 if arity >= 2 { 2185 e1 = nx_eqsat_emit(g, g.nodes[node_idx].kid1, out, cap, count) 2186 if e1 < 0 { return e1 } 2187 } 2188 if arity >= 3 { 2189 e2 = nx_eqsat_emit(g, g.nodes[node_idx].kid2, out, cap, count) 2190 if e2 < 0 { return e2 } 2191 } 2192 let idx: i64 = count[0] 2193 if idx >= cap { return 0 - NX_EQSAT_OVERFLOW } 2194 out[idx].op = op 2195 out[idx].payload = g.nodes[node_idx].payload 2196 out[idx].c0 = e0 2197 out[idx].c1 = e1 2198 out[idx].c2 = e2 2199 count[0] = idx + 1 2200 return idx 2201}