jz 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +56 -9
  2. package/bench/README.md +121 -50
  3. package/bench/bench.svg +27 -27
  4. package/cli.js +3 -1
  5. package/dist/interop.js +1 -1
  6. package/dist/jz.js +7541 -6178
  7. package/index.js +165 -74
  8. package/interop.js +189 -17
  9. package/jzify/arguments.js +32 -1
  10. package/jzify/async.js +409 -0
  11. package/jzify/classes.js +136 -18
  12. package/jzify/generators.js +639 -0
  13. package/jzify/hoist-vars.js +73 -1
  14. package/jzify/index.js +123 -4
  15. package/jzify/names.js +1 -0
  16. package/jzify/transform.js +239 -1
  17. package/layout.js +48 -3
  18. package/module/array.js +318 -43
  19. package/module/atomics.js +144 -0
  20. package/module/collection.js +1429 -155
  21. package/module/core.js +431 -40
  22. package/module/date.js +142 -120
  23. package/module/fs.js +144 -0
  24. package/module/function.js +6 -3
  25. package/module/index.js +4 -1
  26. package/module/json.js +270 -49
  27. package/module/math.js +1032 -145
  28. package/module/number.js +532 -163
  29. package/module/object.js +353 -95
  30. package/module/regex.js +157 -7
  31. package/module/schema.js +100 -2
  32. package/module/string.js +428 -93
  33. package/module/typedarray.js +264 -72
  34. package/module/web.js +36 -0
  35. package/package.json +5 -5
  36. package/src/abi/string.js +82 -11
  37. package/src/ast.js +11 -5
  38. package/src/autoload.js +28 -1
  39. package/src/bridge.js +7 -2
  40. package/src/compile/analyze-scans.js +22 -7
  41. package/src/compile/analyze.js +136 -30
  42. package/src/compile/dyn-closure-tables.js +277 -0
  43. package/src/compile/emit-assign.js +281 -15
  44. package/src/compile/emit.js +1055 -70
  45. package/src/compile/index.js +277 -29
  46. package/src/compile/infer.js +42 -4
  47. package/src/compile/inplace-store.js +329 -0
  48. package/src/compile/loop-recurrence.js +167 -0
  49. package/src/compile/narrow.js +555 -18
  50. package/src/compile/peel-stencil.js +5 -0
  51. package/src/compile/plan/index.js +45 -3
  52. package/src/compile/plan/inline.js +8 -1
  53. package/src/compile/plan/literals.js +39 -0
  54. package/src/compile/plan/scope.js +430 -23
  55. package/src/compile/program-facts.js +732 -38
  56. package/src/ctx.js +64 -9
  57. package/src/helper-counters.js +7 -1
  58. package/src/ir.js +113 -5
  59. package/src/kind-traits.js +66 -3
  60. package/src/kind.js +84 -12
  61. package/src/op-policy.js +7 -4
  62. package/src/optimize/index.js +1179 -704
  63. package/src/optimize/recurse.js +2 -2
  64. package/src/optimize/vectorize.js +982 -67
  65. package/src/prepare/index.js +809 -67
  66. package/src/prepare/math-kernel.js +331 -0
  67. package/src/prepare/pre-eval.js +714 -0
  68. package/src/snapshot.js +194 -0
  69. package/src/type.js +1170 -56
  70. package/src/wat/assemble.js +403 -65
  71. package/src/wat/codegen.js +74 -14
  72. package/transform.js +113 -4
  73. package/wasi.js +3 -0
@@ -16,8 +16,6 @@
16
16
  * fusedRewrite — peephole rebox folds + inline ptr/is_* helpers + memarg-offset fold (one walk)
17
17
  * sortLocalsByUse — reorder local decls so hot ones get 1-byte LEB128 indices
18
18
  * specializeMkptr — `(call $__mkptr (i32.const T) (i32.const A) X)` → per-combo specialized helper (~4 B/site)
19
- * specializePtrBase — `(call $F (i32.add (global.get $G) (i32.const N)))` → `$F_rel_$G (i32.const N)`
20
- * sortStrPoolByFreq — reorder string pool so hottest strings get small offsets (smaller LEB128)
21
19
  * hoistConstantPool — frequently-repeated f64.const values → mutable globals (~7 B/reuse)
22
20
  * treeshake — drop func decls unreachable from exports / start / elem / ref.func roots
23
21
  *
@@ -33,10 +31,12 @@ import { findBodyStart, buildRefcount, nextLocalId, verifyFn, isPureIR, f64Range
33
31
 
34
32
  // Debug-mode IR structural check (JZ_DEBUG_INVARIANTS=1). Zero production cost.
35
33
  const DBG_IR = typeof process !== 'undefined' && process.env?.JZ_DEBUG_INVARIANTS === '1'
36
- import { T, isLeaf, stableKey } from '../ast.js'
37
- import { vectorizeLaneLocal } from './vectorize.js'
34
+ const DBG_DSR = typeof process !== 'undefined' && !!process.env?.JZ_DBG_DSR
35
+ const DBG_UNSWITCH = typeof process !== 'undefined' && (process.env?.JZ_DBG_UNSWITCH || null)
36
+ import { T, isLeaf, stableNodeKey } from '../ast.js'
37
+ import { vectorizeLaneLocal, inlinePureCallExpr } from './vectorize.js'
38
38
  import { recursionUnroll } from './recurse.js'
39
- export { SIMD_PINNED } from './vectorize.js'
39
+ export { SIMD_PINNED, inlinePureFnsInFn } from './vectorize.js'
40
40
  import { nanPrefixHex, atomNanHex, STR_INTERN_BIT, ptrBits, i64Hex, PTR, TYPED_ELEM_CODE, TYPED_ELEM_VIEW_FLAG } from '../../layout.js'
41
41
 
42
42
  const MEMOP = /^[fi](32|64)\.(load|store)(\d+(_[su])?)?$/
@@ -82,12 +82,11 @@ const FALSE_BITS = atomNanHex(4)
82
82
  * because the dead tag-dispatch shapes it folds only EXIST after that
83
83
  * layer's inlining, and its output feeds that layer's fold/branch cleanup
84
84
  * within the same fixpoint rounds.
85
- * Sequencing (driven by index.js): optimizeFunc 'pre' watOptimize →
86
- * optimizeFunc 'post'. The 'post' re-run exists because watr-layer inlining
87
- * re-introduces rebox/unbox pairs at spliced boundaries that only
88
- * fusedRewrite knows how to fold; csePureExprLoop similarly only pays off
89
- * over the inlined shape. Passes must stay idempotent — both phases may
90
- * see the same function.
85
+ * Sequencing: optimizeFunc runs ONCE, in the 'pre' phase (src/wat/assemble.js
86
+ * optimizeModule), then watOptimize is the sole and final optimizer. There is no
87
+ * post-watr re-run re-running jz's leaf passes on watr's output both violated the
88
+ * "watr is the only optimizer, once, fixpoint" architecture and miscompiled (dropped a
89
+ * reassigned-param tee, corrupted SIMD frames).
91
90
  */
92
91
  export const PASS_NAMES = [
93
92
  'watr', // third-party WAT-level CSE/DCE/inlining (heaviest)
@@ -97,20 +96,26 @@ export const PASS_NAMES = [
97
96
  'hoistInvariantLoop', // unified LICM (subsumes the former ToInt32/PtrOffsetLoop/CellLoads hoists)
98
97
  'narrowLoopBound', // f64 loop bound → hoisted i32 (unblocks the lane-vectorizer)
99
98
  'splitCharScan', // charCodeAt scan loops: split at min(N, s.length) → i32 char carrier (plan-level)
99
+ // Pre-analyze loop-shape transforms — applied in compile/index.js (NOT this pass pipeline), but
100
+ // gated by these flags. Listing them here is load-bearing: ALL_OFF sets them false so level 0/1
101
+ // (the self-host fast path) actually skip them, instead of `undefined !== false` running them
102
+ // unconditionally at every level. ALL_ON keeps them on at level 2+ where they belong.
103
+ 'loopIVDivMod', // strength-reduce per-iter `i%w` / `(i/w)|0` → incremental i32 counters
104
+ 'loopSquare', // bounded `i*i < CONST` → Math.imul (i32 product chain)
105
+ 'unrollRecurrence', // unit-stride DP/scan: scalar-replace arr[j-1]/arr[j] recurrence + ×2 unroll
106
+ 'clampPeel', // edge-clamp stencil: split into clamp-free interior (vectorizes) + edges
100
107
  'hoistGlobalPtrOffset', // stable typed GLOBALS: __ptr_offset resolve → once per function (post-watr, module-level)
108
+ 'hoistLoopGlobalPtrOffset', // per-loop complement: narrower write/call scan lets a clean loop hoist inside an otherwise-poisoned function
101
109
  'fusedRewrite', // peephole + ptr-helper inline + memarg fold
102
110
  'hoistAddrBase',
103
111
  'boolConvertToSelect', // f64 ± (cond?1:0) → branchless select (kills i32↔f64 domain cross on recurrences)
104
112
  'cseScalarLoad',
105
- 'csePureExpr',
106
113
  'unswitchTypedParamLoop', // Float64Array param loop-unswitch → base-hoisted f64.load/store fast path (vectorizes)
107
- 'dropDeadZeroInit',
108
- 'deadStoreElim',
114
+ 'propagateSingleUse', // forward-substitute single-def/single-use pure temps (watr's "propagate")
115
+ 'foldSetToTee', // sink a single-def local's RHS into its first use as a tee (simplify-locals watr leaves)
109
116
  'promoteGlobals', // read-only global.get → local for multi-read globals
110
117
  'sortLocalsByUse',
111
118
  'specializeMkptr',
112
- 'specializePtrBase',
113
- 'sortStrPoolByFreq',
114
119
  'internStrings', // slice/substring results probe the static-literal pool: equal-content → canonical bits (bit-eq fast paths)
115
120
  'hoistConstantPool',
116
121
  'sourceInline',
@@ -134,7 +139,7 @@ const LEVEL_PRESETS = Object.freeze({
134
139
  // watr pipeline. `inline` stays off by watr's own default — opt-in only.
135
140
  // boolConvertToSelect off at the default level: it's a latency-for-size trade (adds a
136
141
  // const + op per site) that only pays off on serial recurrences — speed-tier only.
137
- 2: Object.freeze({ ...ALL_ON, nestedSmallConstForUnroll: 'auto', boolConvertToSelect: false, recursionUnroll: false }),
142
+ 2: Object.freeze({ ...ALL_ON, nestedSmallConstForUnroll: 'auto', boolConvertToSelect: false, recursionUnroll: false, watrProfile: 'speed' }),
138
143
  // L3/'speed' trades a bit of heap headroom for fewer __arr_grow / __hash growth
139
144
  // cycles. arrayMinCap=16 means `[]` and `new Array()` skip the first two doublings
140
145
  // (0→2→4→8→16); hashSmallInitCap=8 keeps per-object __dyn_props at the same load
@@ -150,18 +155,25 @@ const LEVEL_PRESETS = Object.freeze({
150
155
  // closures). Inline `f64.const` is the minimal lowering: V8 CSEs identical
151
156
  // constants for free. Measured −3% on jessie parse for +14% binary — exactly
152
157
  // the size↔speed trade 'speed' exists to make.
153
- 3: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8, reduceUnroll: true, relaxedSimd: true, inlineFns: true, rotateLoops: true }),
158
+ 3: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8, reduceUnroll: true, relaxedSimd: true, inlineFns: true, rotateLoops: true, watrLicm: true, watrProfile: 'speed', watrGuard: false }),
154
159
  // 'size' tightens scalar/unroll caps; 'speed' = level 3. There is no 'balanced'
155
160
  // preset — it was a pure synonym for the default level 2 (omit `optimize` or pass 2).
156
161
  size: Object.freeze({
157
162
  ...ALL_ON,
163
+ watrProfile: null, // keep watr's OWN size-leaning default (outline/tailmerge/rettail on) — the one tier where those size-for-speed folds belong
158
164
  smallConstForUnroll: false, nestedSmallConstForUnroll: false, vectorizeLaneLocal: false, splitCharScan: false,
159
165
  recursionUnroll: false, // body tripling is a size regression — speed-only
166
+ unrollRecurrence: false, // ×2 body duplication is a size regression — speed-only
167
+ clampPeel: false, // edge-clamp peel triples a stencil loop (clamp-free interior + 2 edges) to vectorize — speed-only
168
+ versionTypedBounds: false,// typed-bounds loop versioning duplicates every proven nest (guarded fast arm + checked twin, ×1.5-3 on small kernels) — the branchless checked reads alone are the size-tier lowering; speed-only trade
160
169
 
161
170
  boolConvertToSelect: false, // adds a const + op per site — speed-only latency trade
162
171
  devirtIndirect: false, // guards + duplicated args grow bytes — speed-only trade
163
172
  internStrings: false, // the intern index costs ~16 B per eligible literal — speed-only trade
173
+ promoteGlobals: false, // snapshots a multi-read global into an entry local — pure speed (V8 can't CSE a mutable global); the snapshot is dead size weight at -Os
174
+ hoistInvariantLoop: false,// LICM: hoists loop-invariants to entry temps — a speed/latency trade whose entry cost outweighs the per-iter saving in bytes
164
175
  scalarTypedLoopUnroll: 4, scalarTypedNestedUnroll: 8, scalarTypedArrayLen: 8,
176
+ watrIfset: true, // one-armed if→select IS a size win (~2 B/site — the block framing); wasm-opt -Oz does it too. Speed keeps it via boolConvertToSelect.
165
177
  }),
166
178
  // 'speed' === level 3: full watr (inlining on) + L3 cap/hash tuning, pool off.
167
179
  // reduceUnroll: vectorize reductions with N independent accumulators (ILP/latency
@@ -173,7 +185,7 @@ const LEVEL_PRESETS = Object.freeze({
173
185
  // (The stencil + outer-strip vectorizers are NOT level-gated here: they're bit-exact pure wins
174
186
  // like the base lane vectorizer, so they run whenever it does — default-on at level 2+ via
175
187
  // `cfg.experimentalStencil !== false` at the call site, not a speed-only size/precision trade.)
176
- speed: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8, reduceUnroll: true, relaxedSimd: true, inlineFns: true, rotateLoops: true }),
188
+ speed: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8, reduceUnroll: true, relaxedSimd: true, inlineFns: true, rotateLoops: true, watrLicm: true, watrProfile: 'speed', watrGuard: false }),
177
189
  })
178
190
 
179
191
  /**
@@ -577,7 +589,11 @@ const CMP_MANTISSA = new Set([
577
589
  // block readonly-mem-call LICM (else any `s += unknown` — which dispatches via
578
590
  // __is_str_key/__str_concat — would pin every invariant array element in-loop:
579
591
  // the jagged-array `grid[i][j]` deopt).
580
- const NON_MUTATING_CALLS = new Set(['$__is_str_key', '$__str_concat', '$__to_num', '$__to_str', '$__str_byteLen'])
592
+ // $__mkptr is pure bit-packing over its i32 args (no memory access at all) — its
593
+ // only loop-body producer is the in-place replace-store's re-boxed result, which
594
+ // otherwise pinned the loop's `__ptr_offset(arr)` base resolution in-body (the
595
+ // immutable-update kernel paid the full forwarding+bounds dance per iteration).
596
+ const NON_MUTATING_CALLS = new Set(['$__is_str_key', '$__str_concat', '$__to_num', '$__to_str', '$__str_byteLen', '$__mkptr'])
581
597
 
582
598
  // Read-only HEAP-MEMORY calls: like SAFE_OFFSET_CALLS but they read element
583
599
  // storage that a direct f64.store/i32.store in the loop could alias. Safe to
@@ -589,6 +605,30 @@ const NON_MUTATING_CALLS = new Set(['$__is_str_key', '$__str_concat', '$__to_num
589
605
  // `for(j) { ... grid[i][j] ... }` inner loop (the jagged-array deopt).
590
606
  const READONLY_MEM_CALLS = new Set(['$__typed_idx', '$__str_idx'])
591
607
 
608
+ // PURE FUNCTION calls — result is a function of the ARGUMENTS alone, with no
609
+ // dependence on mutable state: math reads no memory; the string search/compare
610
+ // helpers read only their operands' bytes, and jz strings are IMMUTABLE, so their
611
+ // content can't change under the loop's stores. So on loop-invariant args the
612
+ // RESULT is loop-invariant regardless of anything else the loop does (unlike a
613
+ // READONLY_MEM_CALLS element read, which an aliasing store could invalidate — no
614
+ // !hasDirectStore guard is needed here). Speculative pre-header execution can't
615
+ // trap: math returns NaN/∞ rather than trapping, and a value reaching .indexOf/===
616
+ // is a valid string (in-bounds reads). This is the LICM V8's wasm tier won't do —
617
+ // it treats every call as opaque and recomputes the search/transcendental each
618
+ // iteration. (Math.random is INLINED — it mutates a global PRNG seed, never a
619
+ // `$math.` call — but exclude it by name defensively; $__str_eq_cold is the cold
620
+ // half of __str_eq, equally pure.) $__length stays OUT: it is polymorphic over
621
+ // MUTABLE arrays (push changes it), so it isn't arg-pure. $__str_byteLen is IN:
622
+ // its operand is a string (immutable), and the one in-place length mutator —
623
+ // the heap-top bump-extend twins (module/string.js) — is only emitted where the
624
+ // OLD string value is provably dead, so a live, loop-invariant operand's length
625
+ // cannot change under the loop (`for (j = 0; j < line.length; j++)` hoists to
626
+ // one call instead of one per character — the strbuild row-scan shape).
627
+ const PURE_CALL_I32 = new Set(['$__str_indexof', '$__str_lastindexof', '$__str_eq', '$__str_eq_cold', '$__is_str_key', '$__str_byteLen'])
628
+ const isPureFnCall = (callee) =>
629
+ typeof callee === 'string' &&
630
+ ((callee.startsWith('$math.') && !callee.startsWith('$math.random')) || PURE_CALL_I32.has(callee))
631
+
592
632
  export function hoistInvariantPtrOffset(fn) {
593
633
  if (!Array.isArray(fn) || fn[0] !== 'func') return
594
634
  const bodyStart = findBodyStart(fn)
@@ -796,7 +836,7 @@ function loopInvariance(loopNode, { distinctParams, baseParamOf }) {
796
836
  if (op.startsWith('v128.') || /^[if]\d+x\d+\./.test(op)) hasV128 = true
797
837
  if (op === 'local.set' || op === 'local.tee') { if (typeof node[1] === 'string') locals.add(node[1]); for (let i = 2; i < node.length; i++) scan(node[i]); return }
798
838
  if (op === 'global.set') { if (typeof node[1] === 'string') globals.add(node[1]); for (let i = 2; i < node.length; i++) scan(node[i]); return }
799
- if (op === 'call') { hasAnyCall = true; if (!SAFE_OFFSET_CALLS.has(node[1]) && !READONLY_MEM_CALLS.has(node[1]) && !NON_MUTATING_CALLS.has(node[1])) hasUnsafeCall = true; for (let i = 2; i < node.length; i++) scan(node[i]); return }
839
+ if (op === 'call') { hasAnyCall = true; if (!SAFE_OFFSET_CALLS.has(node[1]) && !READONLY_MEM_CALLS.has(node[1]) && !NON_MUTATING_CALLS.has(node[1]) && !isPureFnCall(node[1])) hasUnsafeCall = true; for (let i = 2; i < node.length; i++) scan(node[i]); return }
800
840
  if (op === 'call_ref' || op === 'call_indirect') { hasAnyCall = hasUnsafeCall = true; for (let i = 1; i < node.length; i++) scan(node[i]); return }
801
841
  if ((op === 'f64.store' || op === 'i32.store') && node.length >= 3) {
802
842
  hasDirectStore = true
@@ -813,10 +853,13 @@ function loopInvariance(loopNode, { distinctParams, baseParamOf }) {
813
853
  const op = node[0]
814
854
  if (op === 'i32.const' || op === 'i64.const' || op === 'f64.const' || op === 'f32.const') return true
815
855
  if (op === 'local.get') return typeof node[1] === 'string' && (bound.has(node[1]) || !locals.has(node[1]))
816
- // A global is invariant only if not set directly AND no call in the loop —
817
- // any callee may mutate it (no interprocedural effect analysis). (Locals are
818
- // frame-private, so calls can't touch them; only direct local.set matters.)
819
- if (op === 'global.get') return typeof node[1] === 'string' && !globals.has(node[1]) && !hasAnyCall
856
+ // A global is invariant only if not set directly AND no UNSAFE call in the loop —
857
+ // an unproven callee may mutate it (no interprocedural effect analysis). SAFE_OFFSET/
858
+ // READONLY_MEM/NON_MUTATING/pure calls are jz's own runtime helpers, audited to never
859
+ // touch a user global, so they don't block this the way an arbitrary call does — same
860
+ // distinction READONLY_MEM_CALLS purity already makes below. (Locals are frame-private,
861
+ // so calls can't touch them; only direct local.set matters.)
862
+ if (op === 'global.get') return typeof node[1] === 'string' && !globals.has(node[1]) && !hasUnsafeCall
820
863
  if (op === 'local.tee') {
821
864
  if (typeof node[1] !== 'string') return false
822
865
  // The operand is evaluated BEFORE the tee writes $X, so a `local.get $X` inside
@@ -843,6 +886,13 @@ function loopInvariance(loopNode, { distinctParams, baseParamOf }) {
843
886
  return false
844
887
  }
845
888
  if (op === 'call') {
889
+ // Pure-function call: invariant iff its ARGS are. No effect-summary barrier —
890
+ // its result depends on nothing the loop can mutate (math: no memory; string
891
+ // search/compare: immutable operands), so neither a store nor another call
892
+ // can invalidate it. This hoists the loop-invariant transcendental / substr
893
+ // search V8's wasm tier recomputes every iteration.
894
+ if (isPureFnCall(node[1]))
895
+ return node.slice(2).every(c => pureGiven(c, bound))
846
896
  if (SAFE_OFFSET_CALLS.has(node[1]))
847
897
  return !hasUnsafeCall && node.slice(2).every(c => pureGiven(c, bound))
848
898
  // Read-only heap reads: additionally require no direct store (alias-safe).
@@ -1088,6 +1138,8 @@ export function hoistInvariantLoop(fn) {
1088
1138
  // SAFE_OFFSET_CALLS all return i32; READONLY_MEM_CALLS return f64 (NaN-boxed element)
1089
1139
  if (SAFE_OFFSET_CALLS.has(node[1])) return 'i32'
1090
1140
  if (READONLY_MEM_CALLS.has(node[1])) return 'f64'
1141
+ if (PURE_CALL_I32.has(node[1])) return 'i32' // string search/compare → i32
1142
+ if (typeof node[1] === 'string' && node[1].startsWith('$math.')) return 'f64' // transcendentals → f64
1091
1143
  return null
1092
1144
  }
1093
1145
  if (op === 'local.get' || op === 'local.tee') return localTypes.get(node[1]) ?? null
@@ -1201,10 +1253,12 @@ export function hoistInvariantLoop(fn) {
1201
1253
  if (!Array.isArray(node)) return
1202
1254
  if (node[0] === 'loop') return // already processed bottom-up
1203
1255
  if (isHoistable(node) && (refcount.get(node) || 0) <= 1 && (refcount.get(parent) || 0) <= 1) {
1204
- // stableKey: hoistable boxed-pointer subtrees carry i64.const NaN-box prefixes
1205
- // (BigInt) that plain JSON.stringify can't serialize, and it also collapses
1206
- // Infinity/-Infinity/NaN→null & -0→0 — both would dedup distinct invariants.
1207
- const key = JSON.stringify(node, stableKey)
1256
+ // stableNodeKey: hoistable boxed-pointer subtrees carry i64.const NaN-box
1257
+ // prefixes (BigInt) that plain JSON.stringify can't serialize, and it also
1258
+ // collapses Infinity/-Infinity/NaN→null & -0→0 — both would dedup distinct
1259
+ // invariants. (A replacer-based stringify was silently replacer-less
1260
+ // in-kernel — the recursive keyer behaves identically host and kernel.)
1261
+ const key = stableNodeKey(node)
1208
1262
  let arr = sites.get(key); if (!arr) { arr = []; sites.set(key, arr) }
1209
1263
  arr.push({ parent, idx, node })
1210
1264
  return
@@ -1625,506 +1679,259 @@ export function cseScalarLoad(fn) {
1625
1679
  * the surrounding `local.set/tee` handles that)
1626
1680
  * - `br/br_if/br_table/return/unreachable` → NO clear (pure values still valid)
1627
1681
  */
1628
- // Commutative WASM binops — shared by csePureExpr + csePureExprLoop for canonical
1629
- // operand-key ordering (a*b and b*a hash to one entry). OP_TYPE tables stay local:
1630
- // the two passes cover deliberately different op sets.
1631
- const COMMUTATIVE = new Set(['f64.mul', 'f64.add', 'i32.mul', 'i32.add', 'i32.and', 'i32.or', 'i32.xor', 'i64.mul', 'i64.add', 'i64.and', 'i64.or', 'i64.xor'])
1632
-
1633
- // Presence of one of these arms csePureExprLoop (it CSEs redundant pure f64/i32
1634
- // arithmetic within the loop; the gate is just "is this loop expensive enough to
1635
- // be worth the pass"). The whole class of transcendental helpers qualifies — each
1636
- // is a multi-instruction polynomial approximation, so a loop built around exp/log/
1637
- // pow deserves the same arithmetic-CSE a trig loop already got. Gating on the class,
1638
- // not a benchmark shape; the CSE itself is bit-exact (pure-subexpr dedup only).
1639
- const LOOP_CSE_EXPENSIVE = new Set([
1640
- '$math.sin', '$math.cos', '$math.tan', '$math.sin_core', '$math.cos_core',
1641
- '$math.exp', '$math.expm1', '$math.log', '$math.log2', '$math.log10', '$math.log1p',
1642
- '$math.pow', '$math.atan', '$math.asin', '$math.acos', '$math.atan2',
1643
- '$math.sinh', '$math.cosh', '$math.tanh', '$math.cbrt', '$math.hypot',
1644
- ])
1645
-
1646
- export function csePureExpr(fn) {
1647
- if (!Array.isArray(fn) || fn[0] !== 'func') return
1648
- const bodyStart = findBodyStart(fn)
1649
- if (bodyStart < 0) return
1650
-
1651
- // High-water mark across ALL surviving `$__pe<N>` locals, not the first gap.
1652
- // A prior csePureExpr run + watr.coalesce can leave non-contiguous numbering
1653
- // (e.g. $__pe0,$__pe1,$__pe5,$__pe20 — coalesce removed the merged ones); picking
1654
- // the first gap (2) then allocating sequentially would collide on $__pe5 / $__pe20.
1655
- let snapId = 0
1656
- for (const n of fn) {
1657
- if (!Array.isArray(n) || n[0] !== 'local' || typeof n[1] !== 'string') continue
1658
- const m = /^\$__pe(\d+)$/.exec(n[1])
1659
- if (m) { const k = +m[1]; if (k >= snapId) snapId = k + 1 }
1660
- }
1661
- const newLocals = []
1662
- let refcount = null // lazily built on the first dedup — most fns never CSE
1663
-
1664
- const TARGET_OPS = new Set([
1665
- 'f64.mul', 'f64.add', 'f64.sub',
1666
- 'i32.mul', 'i32.add', 'i32.sub', 'i32.shl', 'i32.shr_u', 'i32.shr_s', 'i32.and', 'i32.or', 'i32.xor',
1667
- 'i64.mul', 'i64.add', 'i64.sub', 'i64.shl', 'i64.shr_u', 'i64.shr_s', 'i64.and', 'i64.or', 'i64.xor',
1668
- ])
1669
- const OP_TYPE = {
1670
- 'f64.mul': 'f64', 'f64.add': 'f64', 'f64.sub': 'f64',
1671
- 'i32.mul': 'i32', 'i32.add': 'i32', 'i32.sub': 'i32', 'i32.shl': 'i32', 'i32.shr_u': 'i32', 'i32.shr_s': 'i32', 'i32.and': 'i32', 'i32.or': 'i32', 'i32.xor': 'i32',
1672
- 'i64.mul': 'i64', 'i64.add': 'i64', 'i64.sub': 'i64', 'i64.shl': 'i64', 'i64.shr_u': 'i64', 'i64.shr_s': 'i64', 'i64.and': 'i64', 'i64.or': 'i64', 'i64.xor': 'i64',
1673
- }
1674
-
1675
- // Encode a leaf operand to a stable string key. Returns null if not pure-leaf.
1676
- const leafKey = (n) => {
1677
- if (!Array.isArray(n)) return null
1678
- if (n[0] === 'local.get' && typeof n[1] === 'string') return `L:${n[1]}`
1679
- if (n[0] === 'f64.const' || n[0] === 'i32.const' || n[0] === 'i64.const' || n[0] === 'f32.const') return `C:${n[0]}:${n[1]}`
1680
- return null
1681
- }
1682
-
1683
- // table: key → { snapName | null, anchorParent, anchorIdx, locals: Set<string> }
1684
- const table = new Map()
1685
-
1686
- const invalidateLocal = (X) => {
1687
- for (const [key, entry] of table) {
1688
- if (entry.locals.has(X)) table.delete(key)
1689
- }
1690
- }
1691
-
1692
- const walk = (node, parent, idx) => {
1693
- if (!Array.isArray(node)) return
1694
- const op = node[0]
1695
-
1696
- if (op === 'loop' || op === 'if') {
1697
- const saved = new Map(table)
1698
- table.clear()
1699
- for (let i = 1; i < node.length; i++) walk(node[i], node, i)
1700
- table.clear()
1701
- saved.clear()
1702
- return
1703
- }
1704
-
1705
- // `then`/`else` branches of an `if` are mutually exclusive at runtime —
1706
- // a snap tee cached in the `then` branch is unset when the `else` runs.
1707
- // Isolate per-branch tables so a sibling branch can't reach into another's
1708
- // CSE entries.
1709
- if (op === 'then' || op === 'else') {
1710
- table.clear()
1711
- for (let i = 1; i < node.length; i++) walk(node[i], node, i)
1712
- table.clear()
1713
- return
1714
- }
1715
-
1716
- if (op === 'call' || op === 'call_ref' || op === 'call_indirect') {
1717
- // Calls don't write locals; recurse, no clear.
1718
- for (let i = 1; i < node.length; i++) walk(node[i], node, i)
1719
- return
1720
- }
1721
-
1722
- if (op === 'local.set' || op === 'local.tee') {
1723
- for (let i = 2; i < node.length; i++) walk(node[i], node, i)
1724
- const X = node[1]
1725
- if (typeof X === 'string') invalidateLocal(X)
1726
- return
1727
- }
1728
-
1729
- // Try CSE on (OP A B) where A,B are pure leaves.
1730
- if (TARGET_OPS.has(op) && node.length === 3) {
1731
- const ka = leafKey(node[1])
1732
- const kb = leafKey(node[2])
1733
- if (ka && kb) {
1734
- const key = COMMUTATIVE.has(op) && ka > kb ? `${op}|${kb}|${ka}` : `${op}|${ka}|${kb}`
1735
- const entry = table.get(key)
1736
- if (entry) {
1737
- if (!entry.snapName) {
1738
- // A shared (DAG) anchor breaks the in-place tee: the `%` fast-path emits
1739
- // `a - trunc(a/b)*b` reusing ONE `a` node object, so the anchor and the
1740
- // local.get replacement land on the SAME physical slot and the local.get
1741
- // clobbers the tee — orphaning $__pe (reads 0). Skip when the anchor's
1742
- // parent is shared; watr's DAG-aware CSE still dedupes. Mirrors csePureExprLoop.
1743
- if (((refcount ??= buildRefcount(fn)).get(entry.anchorParent) || 0) > 1) return
1744
- const snapName = `$__pe${snapId++}`
1745
- entry.snapName = snapName
1746
- newLocals.push(['local', snapName, OP_TYPE[op] || 'f64'])
1747
- const orig = entry.anchorParent[entry.anchorIdx]
1748
- entry.anchorParent[entry.anchorIdx] = ['local.tee', snapName, orig]
1749
- }
1750
- parent[idx] = ['local.get', entry.snapName]
1751
- return
1752
- } else {
1753
- const locals = new Set()
1754
- if (ka.startsWith('L:')) locals.add(ka.slice(2))
1755
- if (kb.startsWith('L:')) locals.add(kb.slice(2))
1756
- table.set(key, { snapName: null, anchorParent: parent, anchorIdx: idx, locals })
1757
- return
1758
- }
1759
- }
1760
- // Fall through to recurse.
1761
- }
1762
-
1763
- for (let i = 0; i < node.length; i++) walk(node[i], node, i)
1764
- }
1765
-
1766
- for (let i = bodyStart; i < fn.length; i++) walk(fn[i], fn, i)
1767
-
1768
- if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
1769
- }
1770
-
1771
1682
  /**
1772
- * Post-watr nested CSE for hot fill loops (loop + trig). Reuses `$__pe` locals from
1773
- * the pre-watr leaf pass. Deferred to the `phase === 'post'` run so watr's typed-array
1774
- * inlining is not confused by pre-watr IR rewrites (test/mem.js).
1683
+ * Forward-substitute a single-def / single-use local into its sole use, eliminating the local,
1684
+ * its `local.set` and its `local.get`. This is watr's "propagate": jz emits short-lived address/
1685
+ * index temps (`set $t (i32.add …); (load $t) …`) that the WAT optimizer folds away — the
1686
+ * dominant slice of the watr-optimizer-OFF size gap (a matmul carries ~14 such temps, ≈ the whole
1687
+ * delta). Closing it lets jz lean on its own optimizer instead of watr's.
1688
+ *
1689
+ * SOUND only when moving the def's RHS `E` to the use can't change order or value:
1690
+ * - `E` is PURE — reads only locals (no load/store/call/global/memory): its value is a function
1691
+ * of its read-locals alone, and it has no side effects to reorder past intervening statements.
1692
+ * - the use is at the SAME loop nesting — never under a deeper `loop` than the def (which would
1693
+ * re-evaluate `E` per iteration and could read a clobbered input).
1694
+ * - no read-local of `E` is written between the def and the use (incl. the use statement itself).
1695
+ * Anything it can't prove safe is left untouched.
1775
1696
  */
1776
- export function csePureExprLoop(fn) {
1697
+ export function propagateSingleUse(fn) {
1777
1698
  if (!Array.isArray(fn) || fn[0] !== 'func') return
1778
1699
  const bodyStart = findBodyStart(fn)
1779
1700
  if (bodyStart < 0) return
1780
1701
 
1781
- let hasLoop = false
1782
- let hasExpensiveCall = false
1783
- const scanShape = (n) => {
1702
+ // Leave a vectorized function alone: it carries v128 lane sequences that are already register-tight,
1703
+ // and forward-substituting into them only adds pressure (mirrors hoistInvariantLoop's hasV128 gate).
1704
+ let hasV128 = false
1705
+ const scanV = (n) => { if (hasV128 || !Array.isArray(n)) return; const op = n[0]; if (typeof op === 'string' && (op.startsWith('v128.') || /^[if]\d+x\d+\./.test(op))) { hasV128 = true; return } for (let i = 1; i < n.length; i++) scanV(n[i]) }
1706
+ for (let i = bodyStart; i < fn.length && !hasV128; i++) scanV(fn[i])
1707
+ if (hasV128) return
1708
+
1709
+ // def/use tally over the whole body. A `local.tee` is both a read and a write — exclude any
1710
+ // tee'd local from candidacy rather than reason about it.
1711
+ const setN = new Map(), getN = new Map(), teed = new Set()
1712
+ const tally = (n) => {
1784
1713
  if (!Array.isArray(n)) return
1785
- if (n[0] === 'loop') hasLoop = true
1786
- if (n[0] === 'call' && LOOP_CSE_EXPENSIVE.has(n[1])) hasExpensiveCall = true
1787
- for (let i = 1; i < n.length; i++) scanShape(n[i])
1788
- }
1789
- for (let i = bodyStart; i < fn.length; i++) scanShape(fn[i])
1790
- if (!hasLoop || !hasExpensiveCall) return
1791
-
1792
- let snapId = nextLocalId(fn, 'pe')
1793
- const newLocals = []
1794
-
1795
- const refcount = buildRefcount(fn)
1796
- const canMutateSite = (parent, node) =>
1797
- (refcount.get(node) || 0) <= 1 && (refcount.get(parent) || 0) <= 1
1798
-
1799
- const PURE_F64_BIN = new Set(['f64.mul', 'f64.add', 'f64.sub', 'f64.div'])
1800
- const PURE_F64_UNARY = new Set(['f64.neg', 'f64.abs', 'f64.convert_i32_s', 'f64.convert_i32_u'])
1801
- const PURE_I32_BIN = new Set(['i32.mul', 'i32.add', 'i32.sub', 'i32.shl', 'i32.shr_u', 'i32.shr_s', 'i32.and', 'i32.or', 'i32.xor'])
1802
- const PURE_I32_UNARY = new Set(['i32.eqz', 'i32.clz', 'i32.ctz', 'i32.popcnt'])
1803
- const OP_TYPE = {
1804
- 'f64.mul': 'f64', 'f64.add': 'f64', 'f64.sub': 'f64', 'f64.div': 'f64', 'f64.neg': 'f64', 'f64.abs': 'f64',
1805
- 'f64.convert_i32_s': 'f64', 'f64.convert_i32_u': 'f64',
1806
- 'i32.mul': 'i32', 'i32.add': 'i32', 'i32.sub': 'i32', 'i32.shl': 'i32', 'i32.shr_u': 'i32', 'i32.shr_s': 'i32',
1807
- 'i32.and': 'i32', 'i32.or': 'i32', 'i32.xor': 'i32', 'i32.eqz': 'i32',
1808
- }
1809
-
1810
- const table = new Map()
1811
- const keyLocals = new Set()
1812
- const keyGlobals = new Set()
1813
-
1814
- const invalidateLocal = (X) => {
1815
- for (const [key, entry] of table) {
1816
- if (entry.locals.has(X)) table.delete(key)
1817
- }
1714
+ const op = n[0]
1715
+ if (op === 'local.set' && typeof n[1] === 'string') setN.set(n[1], (setN.get(n[1]) || 0) + 1)
1716
+ else if (op === 'local.tee' && typeof n[1] === 'string') teed.add(n[1])
1717
+ else if (op === 'local.get' && typeof n[1] === 'string') getN.set(n[1], (getN.get(n[1]) || 0) + 1)
1718
+ for (let i = 1; i < n.length; i++) tally(n[i])
1818
1719
  }
1720
+ for (let i = bodyStart; i < fn.length; i++) tally(fn[i])
1819
1721
 
1820
- const invalidateGlobal = (G) => {
1821
- for (const [key, entry] of table) {
1822
- if (entry.globals.has(G)) table.delete(key)
1823
- }
1824
- }
1722
+ const cand = new Set()
1723
+ for (const [name, c] of setN) if (c === 1 && getN.get(name) === 1 && !teed.has(name)) cand.add(name)
1724
+ if (!cand.size) return
1825
1725
 
1826
- const pureKeyI32 = (n) => {
1827
- if (!Array.isArray(n)) return null
1726
+ const movablePure = (n) => {
1727
+ if (!Array.isArray(n)) return true
1828
1728
  const op = n[0]
1829
- if (op === 'local.get' && typeof n[1] === 'string') { keyLocals.add(n[1]); return `L:${n[1]}` }
1830
- if (op === 'global.get' && typeof n[1] === 'string') { keyGlobals.add(n[1]); return `G:${n[1]}` }
1831
- if (op === 'i32.const' || op === 'i64.const') return `C:${op}:${n[1]}`
1832
- if (PURE_I32_UNARY.has(op) && n.length === 2) {
1833
- const k = pureKeyI32(n[1]); return k ? `${op}|${k}` : null
1834
- }
1835
- if (PURE_I32_BIN.has(op) && n.length === 3) {
1836
- const ka = pureKeyI32(n[1]), kb = pureKeyI32(n[2])
1837
- if (!ka || !kb) return null
1838
- return COMMUTATIVE.has(op) && ka > kb ? `${op}|${kb}|${ka}` : `${op}|${ka}|${kb}`
1839
- }
1840
- if (op === 'i32.wrap_i64' && n.length === 2) {
1841
- const k = pureKeyI32(n[1]); return k ? `wrap|${k}` : null
1842
- }
1843
- return null
1729
+ if (op === 'local.get') return true
1730
+ if (op === 'local.set' || op === 'local.tee' || op === 'call' || op === 'call_indirect' || op === 'call_ref'
1731
+ || op === 'global.get' || op === 'global.set' || op === 'memory.size' || op === 'memory.grow'
1732
+ || op === 'memory.copy' || op === 'memory.fill') return false
1733
+ if (typeof op === 'string' && (op.includes('.load') || op.includes('.store') || op.includes('.atomic'))) return false
1734
+ for (let i = 1; i < n.length; i++) if (!movablePure(n[i])) return false
1735
+ return true
1844
1736
  }
1845
-
1846
- const pureKeyF64 = (n) => {
1847
- if (!Array.isArray(n)) return null
1848
- const op = n[0]
1849
- if (op === 'local.get' && typeof n[1] === 'string') { keyLocals.add(n[1]); return `L:${n[1]}` }
1850
- if (op === 'global.get' && typeof n[1] === 'string') { keyGlobals.add(n[1]); return `G:${n[1]}` }
1851
- if (op === 'f64.const' || op === 'f32.const') return `C:${op}:${n[1]}`
1852
- if (PURE_F64_UNARY.has(op) && n.length === 2) {
1853
- if (op === 'f64.convert_i32_s' || op === 'f64.convert_i32_u') {
1854
- const k = pureKeyI32(n[1]); return k ? `${op}|${k}` : null
1737
+ const readsOf = (n, out) => { if (!Array.isArray(n)) return; if (n[0] === 'local.get' && typeof n[1] === 'string') out.add(n[1]); for (let i = 1; i < n.length; i++) readsOf(n[i], out) }
1738
+ const writesAny = (n, R) => { if (!Array.isArray(n)) return false; if ((n[0] === 'local.set' || n[0] === 'local.tee') && R.has(n[1])) return true; for (let i = 1; i < n.length; i++) if (writesAny(n[i], R)) return true; return false }
1739
+ // Locate the (local.get $t) within `root`'s subtree (not root itself); flag if it sits under a
1740
+ // `loop` relative to root (→ would re-evaluate the moved RHS each iteration).
1741
+ const locateUse = (root, t) => {
1742
+ let found = null
1743
+ const rec = (node, underLoop) => {
1744
+ if (found || !Array.isArray(node)) return
1745
+ for (let i = 1; i < node.length; i++) {
1746
+ if (found) return
1747
+ const c = node[i]
1748
+ if (Array.isArray(c) && c[0] === 'local.get' && c[1] === t) { found = { parent: node, idx: i, underLoop }; return }
1749
+ rec(c, underLoop || node[0] === 'loop')
1855
1750
  }
1856
- const k = pureKeyF64(n[1]); return k ? `${op}|${k}` : null
1857
1751
  }
1858
- if (PURE_F64_BIN.has(op) && n.length === 3) {
1859
- const ka = pureKeyF64(n[1]), kb = pureKeyF64(n[2])
1860
- if (!ka || !kb) return null
1861
- return COMMUTATIVE.has(op) && ka > kb ? `${op}|${kb}|${ka}` : `${op}|${ka}|${kb}`
1862
- }
1863
- if (op === 'call' && n[1] === '$__to_num' && n.length === 3) {
1864
- const a = n[2]
1865
- if (Array.isArray(a) && a[0] === 'i64.reinterpret_f64' && a.length === 2) {
1866
- const k = pureKeyF64(a[1]); return k ? `tonum|${k}` : null
1752
+ rec(root, false)
1753
+ return found
1754
+ }
1755
+
1756
+ const removed = new Set()
1757
+ const optimizeList = (list, start) => {
1758
+ for (let i = start; i < list.length; i++) {
1759
+ const s = list[i]
1760
+ if (!Array.isArray(s)) continue
1761
+ // s.length === 3: an explicit-RHS `(local.set $t E)`. A bare `(local.set $t)` (length 2)
1762
+ // binds a value already on the stack — e.g. the try_table catch payload — and has no RHS to
1763
+ // move; treating its undefined RHS as movable would substitute `undefined` into the use.
1764
+ if (s[0] === 'local.set' && s.length === 3 && typeof s[1] === 'string' && cand.has(s[1]) && movablePure(s[2])) {
1765
+ const t = s[1], E = s[2], R = new Set(); readsOf(E, R)
1766
+ for (let j = i + 1; j < list.length; j++) {
1767
+ const sj = list[j]
1768
+ const u = Array.isArray(sj) ? locateUse(sj, t) : null
1769
+ if (u) { // found the sole use's statement
1770
+ if (!u.underLoop && !writesAny(sj, R)) { // same nesting + read-locals intact
1771
+ u.parent[u.idx] = E
1772
+ list.splice(i, 1)
1773
+ cand.delete(t); removed.add(t)
1774
+ i-- // re-process from the freed slot (forward chains)
1775
+ }
1776
+ break // use located — stop scanning this candidate
1777
+ }
1778
+ if (writesAny(sj, R)) break // a read-local clobbered before the use → can't move
1779
+ }
1780
+ if (removed.has(s[1])) continue
1867
1781
  }
1868
- }
1869
- return null
1870
- }
1871
-
1872
- const tryCse = (node, parent, idx) => {
1873
- const op = node[0]
1874
- if (op === 'local.get' || op === 'global.get' || op === 'f64.const' || op === 'f32.const') return
1875
- if (!canMutateSite(parent, node)) return
1876
- keyLocals.clear()
1877
- keyGlobals.clear()
1878
- const key = pureKeyF64(node)
1879
- if (!key) return
1880
- const locals = new Set(keyLocals)
1881
- const globals = new Set(keyGlobals)
1882
- const entry = table.get(key)
1883
- if (entry) {
1884
- if (!entry.snapName) {
1885
- if ((refcount.get(entry.anchorParent) || 0) > 1) return
1886
- const snapName = `$__pe${snapId++}`
1887
- entry.snapName = snapName
1888
- newLocals.push(['local', snapName, OP_TYPE[node[0]] || 'f64'])
1889
- const orig = entry.anchorParent[entry.anchorIdx]
1890
- entry.anchorParent[entry.anchorIdx] = ['local.tee', snapName, orig]
1782
+ // recurse into nested statement lists
1783
+ if (s[0] === 'block' || s[0] === 'loop') {
1784
+ let k = 1; while (k < s.length && Array.isArray(s[k]) && s[k][0] === 'result') k++
1785
+ optimizeList(s, k)
1786
+ } else if (s[0] === 'if') {
1787
+ for (let k = 1; k < s.length; k++) { const c = s[k]; if (Array.isArray(c) && (c[0] === 'then' || c[0] === 'else')) optimizeList(c, 1) }
1891
1788
  }
1892
- parent[idx] = ['local.get', entry.snapName]
1893
- } else {
1894
- table.set(key, { snapName: null, anchorParent: parent, anchorIdx: idx, locals, globals })
1895
- }
1896
- }
1897
-
1898
- const walk = (node, parent, idx) => {
1899
- if (!Array.isArray(node)) return
1900
- const op = node[0]
1901
-
1902
- if (op === 'loop') {
1903
- table.clear()
1904
- for (let i = 1; i < node.length; i++) walk(node[i], node, i)
1905
- table.clear()
1906
- return
1907
- }
1908
-
1909
- if (op === 'if') {
1910
- table.clear()
1911
- for (let i = 1; i < node.length; i++) walk(node[i], node, i)
1912
- table.clear()
1913
- return
1914
- }
1915
-
1916
- if (op === 'then' || op === 'else') {
1917
- table.clear()
1918
- for (let i = 1; i < node.length; i++) walk(node[i], node, i)
1919
- table.clear()
1920
- return
1921
- }
1922
-
1923
- if (op === 'call' || op === 'call_ref' || op === 'call_indirect') {
1924
- for (let i = 1; i < node.length; i++) walk(node[i], node, i)
1925
- return
1926
- }
1927
-
1928
- if (op === 'local.set' || op === 'local.tee') {
1929
- for (let i = 2; i < node.length; i++) walk(node[i], node, i)
1930
- const X = node[1]
1931
- if (typeof X === 'string') invalidateLocal(X)
1932
- return
1933
- }
1934
-
1935
- if (op === 'global.set') {
1936
- for (let i = 2; i < node.length; i++) walk(node[i], node, i)
1937
- const G = node[1]
1938
- if (typeof G === 'string') invalidateGlobal(G)
1939
- return
1940
- }
1941
-
1942
- for (let i = 1; i < node.length; i++) {
1943
- if (Array.isArray(node[i])) walk(node[i], node, i)
1944
1789
  }
1945
- tryCse(node, parent, idx)
1946
1790
  }
1791
+ optimizeList(fn, bodyStart)
1947
1792
 
1948
- for (let i = bodyStart; i < fn.length; i++) walk(fn[i], fn, i)
1949
-
1950
- if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
1793
+ // drop the now-orphaned decls (deferred so the body walk above sees stable indices)
1794
+ if (removed.size) for (let i = fn.length - 1; i >= 2; i--) { const c = fn[i]; if (Array.isArray(c) && c[0] === 'local' && removed.has(c[1])) fn.splice(i, 1) }
1951
1795
  }
1952
1796
 
1953
1797
  /**
1954
- * Drop redundant zero-initialisation of fresh function-scope locals.
1955
- *
1956
- * WASM zero-initialises every local on entry (0 / 0.0 / null). jz lowers source
1957
- * `let x = 0` to `(local $x …)` + `(local.set $x (<zero const>))` at the top of
1958
- * the function body — the explicit set is a no-op when nothing has touched `$x`
1959
- * yet. `wasm-opt -Oz` elides these; do the same so jz's own output is minimal.
1798
+ * Sink a single-def local's RHS to its FIRST use as a `local.tee` (the simplify-locals
1799
+ * transform watr's use-count propagate doesn't do, confirmed by diffing jz+watr vs Binaryen):
1960
1800
  *
1961
- * Only removes a `(local.set $L (i32|i64|f64|f32.const 0))` when:
1962
- * - `$L` is a non-param local (a param's "default" is the incoming arg, not 0),
1963
- * - it is a *top-level* body statement (never descend into block/loop/if — a
1964
- * nested zero-set inside a loop genuinely re-initialises across iterations),
1965
- * - `$L` has not been referenced by any earlier top-level statement (so the
1966
- * local still holds its entry-time zero at this point),
1967
- * - `$L` is read (`local.get`) somewhere in the function (otherwise leave the
1968
- * store for deadStoreElim and avoid orphaning the `(local $L …)` decl),
1969
- * - the constant is +0 / +0.0 (a `-0.0` f64 set is *not* redundant — locals
1970
- * default to +0.0, which differs in bits from -0.0).
1971
- */
1972
- export function dropDeadZeroInit(fn) {
1973
- if (!Array.isArray(fn) || fn[0] !== 'func') return
1974
- const bodyStart = findBodyStart(fn)
1975
- if (bodyStart < 0) return
1976
-
1977
- const seen = new Set() // params + locals referenced by an earlier stmt
1978
- const reads = new Set() // locals read by `local.get` anywhere
1979
- for (const c of fn) if (Array.isArray(c) && c[0] === 'param' && typeof c[1] === 'string') seen.add(c[1])
1980
-
1981
- const collectGets = (node) => {
1982
- if (!Array.isArray(node)) return
1983
- if (node[0] === 'local.get' && typeof node[1] === 'string') reads.add(node[1])
1984
- for (let i = 1; i < node.length; i++) collectGets(node[i])
1985
- }
1986
- for (let i = bodyStart; i < fn.length; i++) collectGets(fn[i])
1987
-
1988
- const collectRefs = (node) => {
1989
- if (!Array.isArray(node)) return
1990
- const op = node[0]
1991
- if ((op === 'local.get' || op === 'local.set' || op === 'local.tee') && typeof node[1] === 'string') seen.add(node[1])
1992
- for (let i = 1; i < node.length; i++) collectRefs(node[i])
1993
- }
1994
- const isPlusZeroConst = (e) => {
1995
- if (!Array.isArray(e) || e.length !== 2) return false
1996
- if (e[0] !== 'i32.const' && e[0] !== 'i64.const' && e[0] !== 'f64.const' && e[0] !== 'f32.const') return false
1997
- const v = e[1]
1998
- if (typeof v === 'bigint') return v === 0n
1999
- if (typeof v === 'number') return v === 0 && !Object.is(v, -0)
2000
- if (typeof v === 'string') { const t = v.trim(); return t === '0' || t === '0.0' || t === '+0' || t === '+0.0' }
2001
- return false
2002
- }
2003
-
2004
- const drop = []
2005
- for (let i = bodyStart; i < fn.length; i++) {
2006
- const node = fn[i]
2007
- if (!Array.isArray(node)) continue
2008
- if (node[0] === 'local.set' && node.length === 3 && typeof node[1] === 'string' &&
2009
- !seen.has(node[1]) && reads.has(node[1]) && isPlusZeroConst(node[2])) {
2010
- drop.push(i)
2011
- seen.add(node[1])
2012
- continue
2013
- }
2014
- collectRefs(node)
2015
- }
2016
- for (let i = drop.length - 1; i >= 0; i--) fn.splice(drop[i], 1)
2017
- }
2018
-
2019
- /**
2020
- * Dead-store elimination: remove `local.set` / `local.tee` and `drop` of pure
2021
- * expressions whose values are never consumed.
1801
+ * (local.set $t (call $f ...)) ⇒ (i64.store a (local.tee $t (call $f ...)))
1802
+ * (i64.store a (local.get $t)) ... later (local.get $t) ...
1803
+ * ... later (local.get $t) ...
2022
1804
  *
2023
- * Conservative single-block analysis: tracks last-write per local within each
2024
- * straight-line sequence. A write is dead if the same local is written again
2025
- * before any intervening read in the same block. Control-flow boundaries
2026
- * (block, loop, if) reset the table we don't eliminate across branches.
1805
+ * removes the standalone `set` statement + the first `local.get` (~2–4 B/site). For a
1806
+ * SINGLE-use local the RHS is forwarded outright (no tee, decl dropped) this also catches
1807
+ * effectful single-use temps (`call`/`load` RHS) that `propagateSingleUse` skips. Runs after
1808
+ * `propagateSingleUse`, so it sees only the cases that one leaves: multi-use, and effectful.
2027
1809
  *
2028
- * Also removes `drop` of pure expressions (e.g. leftover ptr-type calls).
1810
+ * Correctness rests on `scanUse`, an eval-order walk that flags a conflict BEFORE the use:
1811
+ * - a write to any local the RHS reads (R),
1812
+ * - a control-flow transfer (br/return/throw/…) that could skip the use while a later use lives,
1813
+ * - for a memory-reading RHS, an intervening memory/global WRITE,
1814
+ * - for a memory-writing RHS (incl. any call), ANY intervening memory/global access.
1815
+ * Plus: a multi-use fold (and an effectful single-use forward) requires the use to be
1816
+ * UNCONDITIONALLY reached (not under if/loop/block) so the tee always runs before later reads;
1817
+ * never sink under a `loop` (would re-evaluate / re-effect the RHS per iteration).
2029
1818
  */
2030
- export function deadStoreElim(fn) {
1819
+ export function foldSetToTee(fn) {
2031
1820
  if (!Array.isArray(fn) || fn[0] !== 'func') return
2032
1821
  const bodyStart = findBodyStart(fn)
2033
1822
  if (bodyStart < 0) return
2034
1823
 
2035
- const dead = []
2036
-
2037
- const collectGets = (node, out) => {
2038
- if (!Array.isArray(node)) return
2039
- if (node[0] === 'local.get' && typeof node[1] === 'string') { out.add(node[1]); return }
2040
- for (let i = 1; i < node.length; i++) collectGets(node[i], out)
2041
- }
1824
+ // v128 lane sequences are already register-tight — folding only adds pressure (mirrors propagateSingleUse).
1825
+ let hasV128 = false
1826
+ const scanV = (n) => { if (hasV128 || !Array.isArray(n)) return; const op = n[0]; if (typeof op === 'string' && (op.startsWith('v128.') || /^[if]\d+x\d+\./.test(op))) { hasV128 = true; return } for (let i = 1; i < n.length; i++) scanV(n[i]) }
1827
+ for (let i = bodyStart; i < fn.length && !hasV128; i++) scanV(fn[i])
1828
+ if (hasV128) return
2042
1829
 
2043
- const isPure = (node) => {
2044
- if (!Array.isArray(node)) return true
2045
- const op = node[0]
2046
- if (typeof op === 'string' && MEMOP.test(op)) return false
2047
- if (op === 'call' || op === 'call_indirect' || op === 'call_ref') return false
2048
- if (op === 'global.get' || op === 'global.set') return false
2049
- if (op === 'local.tee') return false
2050
- if (op === 'memory.size' || op === 'memory.grow') return false
2051
- for (let i = 1; i < node.length; i++) if (!isPure(node[i])) return false
1830
+ const setN = new Map(), getN = new Map(), teed = new Set()
1831
+ const tally = (n) => {
1832
+ if (!Array.isArray(n)) return
1833
+ const op = n[0]
1834
+ if (op === 'local.set' && typeof n[1] === 'string') setN.set(n[1], (setN.get(n[1]) || 0) + 1)
1835
+ else if (op === 'local.tee' && typeof n[1] === 'string') teed.add(n[1])
1836
+ else if (op === 'local.get' && typeof n[1] === 'string') getN.set(n[1], (getN.get(n[1]) || 0) + 1)
1837
+ for (let i = 1; i < n.length; i++) tally(n[i])
1838
+ }
1839
+ for (let i = bodyStart; i < fn.length; i++) tally(fn[i])
1840
+
1841
+ const cand = new Set()
1842
+ for (const [name, c] of setN) if (c === 1 && (getN.get(name) || 0) >= 1 && !teed.has(name)) cand.add(name)
1843
+ if (!cand.size) return
1844
+
1845
+ const readsOf = (n, out) => { if (!Array.isArray(n)) return; if (n[0] === 'local.get' && typeof n[1] === 'string') out.add(n[1]); for (let i = 1; i < n.length; i++) readsOf(n[i], out) }
1846
+ const noLocalWrite = (n) => {
1847
+ if (!Array.isArray(n)) return true
1848
+ if (n[0] === 'local.set' || n[0] === 'local.tee') return false
1849
+ for (let i = 1; i < n.length; i++) if (!noLocalWrite(n[i])) return false
2052
1850
  return true
2053
1851
  }
2054
-
2055
- const scanBlock = (items, start, end) => {
2056
- const lastWrite = new Map() // localName { parent, idx }
2057
-
2058
- for (let i = start; i < end; i++) {
2059
- const node = items[i]
2060
- if (!Array.isArray(node)) continue
1852
+ const isWriteOp = (op) => op === 'call' || op === 'call_indirect' || op === 'call_ref' || op === 'return_call'
1853
+ || op === 'return_call_indirect' || op === 'global.set' || op === 'memory.grow' || op === 'memory.copy'
1854
+ || op === 'memory.fill' || (typeof op === 'string' && (op.includes('.store') || op.includes('.atomic')))
1855
+ const isReadOp = (op) => op === 'global.get' || op === 'memory.size' || (typeof op === 'string' && op.includes('.load'))
1856
+ const isCF = (op) => op === 'if' || op === 'loop' || op === 'block' || op === 'try' || op === 'try_table'
1857
+ const isTransfer = (op) => op === 'br' || op === 'br_if' || op === 'br_table' || op === 'return'
1858
+ || op === 'return_call' || op === 'return_call_indirect' || op === 'unreachable' || op === 'throw' || op === 'rethrow'
1859
+ const rhsKind = (n) => { // 'writes' | 'reads' | 'pure'
1860
+ let w = false, r = false
1861
+ const rec = (x) => { if (!Array.isArray(x)) return; if (isWriteOp(x[0])) w = true; else if (isReadOp(x[0])) r = true; for (let i = 1; i < x.length; i++) rec(x[i]) }
1862
+ rec(n)
1863
+ return w ? 'writes' : r ? 'reads' : 'pure'
1864
+ }
1865
+ // Walk `root` in eval order for the first `(local.get t)`; return {use, conflict}.
1866
+ // `conflict` reflects only what is evaluated strictly BEFORE the use (or the whole node if no use).
1867
+ const scanUse = (root, t, mode, R) => {
1868
+ let use = null, conflict = false
1869
+ const rec = (node, underLoop, underCond) => {
1870
+ if (use || conflict || !Array.isArray(node)) return
2061
1871
  const op = node[0]
2062
-
2063
- // Reads invalidate pending dead writes. For local.tee/local.set, the RHS reads
2064
- // happen BEFORE the write — so a `local.get $x` inside `(local.tee $x ...)` is a
2065
- // real read of the OLD $x and must invalidate any pending dead-write of $x.
2066
- const reads = new Set()
2067
- collectGets(node, reads)
2068
- for (const name of reads) lastWrite.delete(name)
2069
-
2070
- // Drop of pure expr dead. Only `(drop EXPR)`: a bare `(drop)` consumes
2071
- // an implicit stack value (e.g. a `try_table` catch payload) — removing it
2072
- // would unbalance the stack.
2073
- if (op === 'drop' && node.length === 2 && isPure(node[1])) {
2074
- dead.push({ parent: items, node, drop: true })
1872
+ for (let i = 1; i < node.length; i++) {
1873
+ if (use || conflict) return
1874
+ const c = node[i]
1875
+ if (Array.isArray(c) && c[0] === 'local.get' && c[1] === t) { use = { parent: node, idx: i, underLoop, underCond }; return }
1876
+ // an `if`'s CONDITION (child 1) and all `select` operands evaluate UNCONDITIONALLY when
1877
+ // the construct is reached — only then/else bodies are conditional. Treating the condition
1878
+ // as conditional would wrongly refuse the common `if ((local.tee $t E) )` fold.
1879
+ const childCond = (op === 'if' && i === 1) || op === 'select' ? underCond : underCond || isCF(op)
1880
+ rec(c, underLoop || op === 'loop', childCond)
2075
1881
  }
2076
-
2077
- // Local write tracking
2078
- if ((op === 'local.set' || op === 'local.tee') && typeof node[1] === 'string') {
2079
- const prev = lastWrite.get(node[1])
2080
- if (prev) {
2081
- // The store-to-local is dead, but a `local.set` is only *removable*
2082
- // if its RHS is pure — `local.set $x (call f …)` where `f` mutates
2083
- // memory must still run. (A `local.tee` is always safe: removal demotes
2084
- // it to its value expression, so any side effects there are preserved.)
2085
- if (prev.node[0] === 'local.tee' || isPure(prev.node[2])) dead.push(prev)
1882
+ if (use || conflict) return
1883
+ // post-order: this node's own effect, relative to the RHS we want to move past it
1884
+ if ((op === 'local.set' || op === 'local.tee') && R.has(node[1])) conflict = true
1885
+ else if (isTransfer(op)) conflict = true
1886
+ else if (mode === 'writes' && (isWriteOp(op) || isReadOp(op))) conflict = true
1887
+ else if (mode === 'reads' && isWriteOp(op)) conflict = true
1888
+ }
1889
+ rec(root, false, false)
1890
+ return { use, conflict }
1891
+ }
1892
+
1893
+ const removed = new Set()
1894
+ const optimizeList = (list, start) => {
1895
+ for (let i = start; i < list.length; i++) {
1896
+ const s = list[i]
1897
+ if (!Array.isArray(s)) continue
1898
+ let folded = false
1899
+ if (s[0] === 'local.set' && s.length === 3 && typeof s[1] === 'string' && cand.has(s[1]) && noLocalWrite(s[2])) {
1900
+ const t = s[1], E = s[2], R = new Set(); readsOf(E, R)
1901
+ if (!R.has(t)) { // self-ref RHS reads the very local — leave it
1902
+ const single = (getN.get(t) || 0) === 1
1903
+ const mode = rhsKind(E)
1904
+ for (let j = i + 1; j < list.length; j++) {
1905
+ const sj = list[j]
1906
+ if (!Array.isArray(sj)) continue
1907
+ const { use, conflict } = scanUse(sj, t, mode, R)
1908
+ if (conflict) break // unsafe to move RHS this far
1909
+ if (use) {
1910
+ // effectful single-use, or any multi-use, must reach the use unconditionally;
1911
+ // never sink under a loop (re-eval / re-effect).
1912
+ const okCond = (single && mode !== 'writes') ? true : !use.underCond
1913
+ if (!use.underLoop && okCond) {
1914
+ use.parent[use.idx] = single ? E : ['local.tee', t, E]
1915
+ list.splice(i, 1); cand.delete(t); if (single) removed.add(t); i--; folded = true
1916
+ }
1917
+ break
1918
+ }
1919
+ }
2086
1920
  }
2087
- lastWrite.set(node[1], { parent: items, node })
2088
1921
  }
2089
-
2090
- // Recurse into nested blocks with fresh state
2091
- if (op === 'block' || op === 'loop') {
2092
- let j = 1
2093
- while (j < node.length && Array.isArray(node[j]) && node[j][0] === 'result') j++
2094
- scanBlock(node, j, node.length)
2095
- } else if (op === 'if') {
2096
- let j = 1
2097
- while (j < node.length && Array.isArray(node[j]) && node[j][0] === 'result') j++
2098
- const condReads = new Set()
2099
- collectGets(node[j], condReads)
2100
- for (const name of condReads) lastWrite.delete(name)
2101
- j++
2102
- for (; j < node.length; j++) {
2103
- const c = node[j]
2104
- if (Array.isArray(c) && (c[0] === 'then' || c[0] === 'else')) scanBlock(c, 1, c.length)
2105
- }
1922
+ if (folded) continue // re-process from the freed slot
1923
+ // recurse into nested statement lists
1924
+ if (s[0] === 'block' || s[0] === 'loop') {
1925
+ let k = 1; while (k < s.length && Array.isArray(s[k]) && s[k][0] === 'result') k++
1926
+ optimizeList(s, k)
1927
+ } else if (s[0] === 'if') {
1928
+ for (let k = 1; k < s.length; k++) { const c = s[k]; if (Array.isArray(c) && (c[0] === 'then' || c[0] === 'else')) optimizeList(c, 1) }
2106
1929
  }
2107
1930
  }
2108
1931
  }
1932
+ optimizeList(fn, bodyStart)
2109
1933
 
2110
- scanBlock(fn, bodyStart, fn.length)
2111
-
2112
- // Removal is IDENTITY-based: entries are pushed at SUPERSEDE time, so
2113
- // same-parent indices are not monotonic (name A's earlier write can be
2114
- // superseded after name B's later one). Index-order splicing then shifts
2115
- // remaining entries onto innocent neighbors — the self-host L2 divergence
2116
- // deleted a typed-literal f64.store exactly this way. Re-locating each
2117
- // captured node at removal time is immune to any ordering or prior splice.
2118
- for (const d of dead) {
2119
- const at = d.parent.indexOf(d.node)
2120
- if (at < 0) continue // already removed (nested duplicate) — nothing to do
2121
- if (!d.drop && d.node[0] === 'local.tee') {
2122
- // tee in statement position: replace with just the value (implicitly dropped)
2123
- d.parent[at] = d.node[2]
2124
- } else {
2125
- d.parent.splice(at, 1)
2126
- }
2127
- }
1934
+ if (removed.size) for (let i = fn.length - 1; i >= 2; i--) { const c = fn[i]; if (Array.isArray(c) && c[0] === 'local' && removed.has(c[1])) fn.splice(i, 1) }
2128
1935
  }
2129
1936
 
2130
1937
  /**
@@ -2347,6 +2154,194 @@ export function hoistGlobalPtrOffset(fn, stablePtrGlobals, reachableWrites) {
2347
2154
  fn.splice(bodyStart, 0, ...decls, ...snaps)
2348
2155
  }
2349
2156
 
2157
+ /**
2158
+ * Loop-scoped complement to `hoistGlobalPtrOffset`. That pass requires the
2159
+ * WHOLE FUNCTION to be clean w.r.t. a global (no write anywhere, no
2160
+ * call_indirect/call_ref anywhere) — one unrelated indirect call ANYWHERE in
2161
+ * a large function (e.g. a devirtualized Pratt-loop trampoline that inlines
2162
+ * many operator handlers) poisons every stable-pointee global for the WHOLE
2163
+ * function, even a tight char-scan loop inside it that touches nothing
2164
+ * unsafe itself. This pass re-tries per LOOP, narrowing the write/call scan
2165
+ * to just that loop's own subtree (including nested loops, whose dynamic
2166
+ * extent is part of the enclosing loop's).
2167
+ *
2168
+ * Soundness (route (a) from the design note — the simplest sound design):
2169
+ * a global's base is hoisted to a loop's preheader iff, within that loop's
2170
+ * subtree, (1) the global is never `global.set`, (2) no `call_indirect` /
2171
+ * `call_ref` appears at all (fail-closed: an unknown target could write
2172
+ * anything), and (3) every DIRECTLY-called function does not (transitively,
2173
+ * via `reachableWrites` — `collectReachableGlobalWrites`, itself fail-closed
2174
+ * the same way) write the global. No route (b) (re-derive-at-write /
2175
+ * generation guard) — a loop that fails (1)-(3) is left exactly as
2176
+ * `hoistGlobalPtrOffset` left it.
2177
+ *
2178
+ * One extra idiom this pass alone needs: the durable-receiver override probe
2179
+ * (`sidecarOverride`, src/ir.js) reads a global ONCE per iteration into a
2180
+ * local for a NaN/tag check, then reuses that SAME local for the base-decode
2181
+ * a few nodes downstream — `local.tee $vo (global.get $cur)` feeding a later
2182
+ * `(i64.reinterpret_f64 (local.get $vo))`. hoistGlobalPtrOffset's site
2183
+ * matcher only recognizes a DIRECT `global.get`, missing this alias.
2184
+ * `buildLocalGlobalAlias` resolves it: `$vo` aliases `$cur` when every write
2185
+ * to `$vo` within the loop is textually `(global.get $cur)` — since `$cur`
2186
+ * is already proven unwritten in the loop, every such tee assigns the same
2187
+ * invariant value, so any downstream read of `$vo` is as invariant as
2188
+ * `global.get $cur` itself.
2189
+ *
2190
+ * Runs immediately after `hoistGlobalPtrOffset` in the same module pass: any
2191
+ * site the function-wide pass already hoisted is now `local.get $__goN`, so
2192
+ * `siteGlobal` no longer matches it there — the two passes can't double-hoist
2193
+ * the same read.
2194
+ *
2195
+ * @param {Array} fn - func IR node
2196
+ * @param {Set<string>} stablePtrGlobals - '$name's of never-forwarding module globals
2197
+ * @param {Map<string,Set<string>>} reachableWrites - from collectReachableGlobalWrites
2198
+ */
2199
+ export function hoistLoopGlobalPtrOffset(fn, stablePtrGlobals, reachableWrites) {
2200
+ if (!Array.isArray(fn) || fn[0] !== 'func' || !stablePtrGlobals?.size) return
2201
+ const bodyStart = findBodyStart(fn)
2202
+ if (bodyStart < 0) return
2203
+
2204
+ // Per-loop: locals whose every write in `loopNode` is exactly `(global.get
2205
+ // $G)` for one consistent G — a conflicting write (different G, or any
2206
+ // other expression) poisons the name for this loop.
2207
+ const buildLocalGlobalAlias = (loopNode) => {
2208
+ const alias = new Map(), poisoned = new Set()
2209
+ const scan = (n) => {
2210
+ if (!Array.isArray(n)) return
2211
+ if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') {
2212
+ const name = n[1], rhs = n[2]
2213
+ if (!poisoned.has(name)) {
2214
+ const g = Array.isArray(rhs) && rhs[0] === 'global.get' && typeof rhs[1] === 'string' ? rhs[1] : null
2215
+ if (g != null && (!alias.has(name) || alias.get(name) === g)) alias.set(name, g)
2216
+ else { poisoned.add(name); alias.delete(name) }
2217
+ }
2218
+ scan(rhs)
2219
+ return
2220
+ }
2221
+ for (let i = 1; i < n.length; i++) scan(n[i])
2222
+ }
2223
+ for (let i = 1; i < loopNode.length; i++) scan(loopNode[i])
2224
+ return alias
2225
+ }
2226
+
2227
+ // `(i64.reinterpret_f64 X)` → stable-pointee global name, or null. X is a
2228
+ // direct `(global.get $G)`, or a `(local.get $X)` resolved through `alias`.
2229
+ const reintGlobal = (n, alias) => {
2230
+ if (!Array.isArray(n) || n[0] !== 'i64.reinterpret_f64' || n.length !== 2) return null
2231
+ const x = n[1]
2232
+ if (Array.isArray(x) && x[0] === 'global.get' && typeof x[1] === 'string') return x[1]
2233
+ if (Array.isArray(x) && x[0] === 'local.get' && typeof x[1] === 'string') return alias.get(x[1]) ?? null
2234
+ return null
2235
+ }
2236
+ // Same two interchangeable shapes as hoistGlobalPtrOffset.siteGlobal — see
2237
+ // that function's comment for why both forms hoist to one snapshot.
2238
+ const siteGlobal = (n, alias) => {
2239
+ if (!Array.isArray(n)) return null
2240
+ if (n[0] === 'call' && n[1] === '$__ptr_offset' && n.length === 3) return reintGlobal(n[2], alias)
2241
+ if (n[0] === 'i32.wrap_i64' && n.length === 2 && Array.isArray(n[1]) && n[1][0] === 'i64.and' && n[1].length === 3) {
2242
+ const mask = n[1][2]
2243
+ if (Array.isArray(mask) && mask[0] === 'i64.const'
2244
+ && (typeof mask[1] === 'string' ? Number(mask[1]) : mask[1]) === LAYOUT.OFFSET_MASK)
2245
+ return reintGlobal(n[1][1], alias)
2246
+ }
2247
+ return null
2248
+ }
2249
+
2250
+ // Collision-proof snap ids — shared `$__goN` numbering space with
2251
+ // hoistGlobalPtrOffset so re-running either pass never collides.
2252
+ const used = new Set()
2253
+ const scanIds = (n) => {
2254
+ if (!Array.isArray(n)) return
2255
+ if (n[0] === 'local' && typeof n[1] === 'string' && n[1].startsWith('$__go')) {
2256
+ const t = n[1].slice(5); if (/^\d+$/.test(t)) used.add(+t)
2257
+ }
2258
+ for (let i = 0; i < n.length; i++) scanIds(n[i])
2259
+ }
2260
+ scanIds(fn)
2261
+ let idCounter = 0
2262
+ const freshId = () => { while (used.has(idCounter)) idCounter++; const id = idCounter++; used.add(id); return `$__go${id}` }
2263
+
2264
+ const newDecls = []
2265
+
2266
+ // Attempt one loop; returns preheader statements to splice just before it
2267
+ // (possibly empty). Outer-first (top-down): if the outer loop's hoist
2268
+ // succeeds, `replace` below rewrites every matching site in its ENTIRE
2269
+ // subtree — including nested loops — so a later independent attempt on a
2270
+ // nested loop finds nothing left to do for that global (no double-hoist,
2271
+ // no redundant inner snapshot of an outer-invariant value).
2272
+ const processLoop = (loopNode) => {
2273
+ const alias = buildLocalGlobalAlias(loopNode)
2274
+ const sites = new Map(), ownWrites = new Set(), ownCallees = new Set(), ptrOffsetForm = new Set()
2275
+ let hasIndirect = false
2276
+ const scan = (n) => {
2277
+ if (!Array.isArray(n)) return
2278
+ const g = siteGlobal(n, alias)
2279
+ if (g != null) {
2280
+ let arr = sites.get(g); if (!arr) { arr = []; sites.set(g, arr) }
2281
+ arr.push(n)
2282
+ if (n[0] === 'call') ptrOffsetForm.add(g)
2283
+ return
2284
+ }
2285
+ if (n[0] === 'global.set' && typeof n[1] === 'string') ownWrites.add(n[1])
2286
+ else if ((n[0] === 'call' || n[0] === 'return_call') && typeof n[1] === 'string') ownCallees.add(n[1])
2287
+ else if (n[0] === 'call_indirect' || n[0] === 'call_ref' || n[0] === 'return_call_indirect') hasIndirect = true
2288
+ for (let i = 1; i < n.length; i++) scan(n[i])
2289
+ }
2290
+ for (let i = 1; i < loopNode.length; i++) scan(loopNode[i])
2291
+
2292
+ const preheader = []
2293
+ if (sites.size && !hasIndirect) {
2294
+ const calleeWrites = (g) => {
2295
+ for (const c of ownCallees) if (reachableWrites?.get(c)?.has(g)) return true
2296
+ return false
2297
+ }
2298
+ const chosen = new Map()
2299
+ for (const g of sites.keys()) {
2300
+ if (!stablePtrGlobals.has(g) || ownWrites.has(g) || calleeWrites(g)) continue
2301
+ chosen.set(g, freshId())
2302
+ }
2303
+ if (chosen.size) {
2304
+ const replace = (n) => {
2305
+ if (!Array.isArray(n)) return
2306
+ for (let i = 1; i < n.length; i++) {
2307
+ const g = siteGlobal(n[i], alias)
2308
+ if (g != null && chosen.has(g)) n[i] = ['local.get', chosen.get(g)]
2309
+ else replace(n[i])
2310
+ }
2311
+ }
2312
+ for (let i = 1; i < loopNode.length; i++) replace(loopNode[i])
2313
+ for (const [g, name] of chosen) {
2314
+ newDecls.push(['local', name, 'i32'])
2315
+ const snap = ptrOffsetForm.has(g)
2316
+ ? ['call', '$__ptr_offset', ['i64.reinterpret_f64', ['global.get', g]]]
2317
+ : ['i32.wrap_i64', ['i64.and', ['i64.reinterpret_f64', ['global.get', g]], ['i64.const', LAYOUT.OFFSET_MASK]]]
2318
+ preheader.push(['local.set', name, snap])
2319
+ }
2320
+ }
2321
+ }
2322
+ return preheader
2323
+ }
2324
+
2325
+ // Walk every statement-bearing container; splice a loop's preheader into
2326
+ // its own parent array right before it, then recurse into the loop body
2327
+ // (nested loops get their own independent, narrower attempt).
2328
+ const walk = (node) => {
2329
+ if (!Array.isArray(node)) return
2330
+ for (let i = 1; i < node.length; i++) {
2331
+ const c = node[i]
2332
+ if (Array.isArray(c) && c[0] === 'loop') {
2333
+ const pre = processLoop(c)
2334
+ if (pre.length) { node.splice(i, 0, ...pre); i += pre.length }
2335
+ walk(c)
2336
+ } else {
2337
+ walk(c)
2338
+ }
2339
+ }
2340
+ }
2341
+ walk(fn)
2342
+ if (newDecls.length) fn.splice(bodyStart, 0, ...newDecls)
2343
+ }
2344
+
2350
2345
  /**
2351
2346
  * Promote read-only globals to locals within each function.
2352
2347
  *
@@ -2600,7 +2595,7 @@ export function specializeMkptr(funcs, addFunc, parseWat) {
2600
2595
  // 4 is the measured break-even: a specialized helper (trampoline / inline i64.const
2601
2596
  // template) costs ~12 B to define and saves ~2–4 B per site, so 4 sites amortize it.
2602
2597
  // Lower (3) net-inflates the watr self-host; 5 leaves 4-use combos on the table. The
2603
- // sibling specializePtrBase threshold (20) is already optimal — its combos cluster far
2598
+ // threshold (20) is already optimal — its combos cluster far
2604
2599
  // above 20 (the ~2 k-site $__strBase relativization) with nothing in the 5–19 band.
2605
2600
  const MIN_USES = 4
2606
2601
 
@@ -2713,174 +2708,6 @@ export function specializeMkptr(funcs, addFunc, parseWat) {
2713
2708
  }
2714
2709
  }
2715
2710
 
2716
- /**
2717
- * Specialize `(call $F (i32.add (global.get $G) (i32.const N)))` → `(call $F_rel_$G (i32.const N))`.
2718
- * Helper bakes `(global.get $G) + i32.add` into its body so call sites drop those 3 B.
2719
- * Targets any single-arg call whose arg is `add(global_base, const)` — in practice: $__mkptr_X_Y_d
2720
- * specializations against $__strBase (watr self-host: ~2193 sites × 3 B ≈ 6.5 KB).
2721
- *
2722
- * @param funcs — flat list of func IR nodes
2723
- * @param addFunc — callback `(watString) => void` to register new helpers
2724
- * @param parseWat — `wat → IR` parser (injected)
2725
- */
2726
- export function specializePtrBase(funcs, addFunc, parseWat) {
2727
- const MIN_USES = 20
2728
-
2729
- // Pass 1: count (targetFunc, baseGlobal) pairs AND record candidate sites for direct
2730
- // rewrite in pass 3 (avoids a second full-AST walk).
2731
- const counts = new Map() // 'F##G' → count
2732
- const sites = [] // { parent, idx, key }
2733
- const walk = (node, parent, idx) => {
2734
- if (!Array.isArray(node)) return
2735
- if (parent && node[0] === 'call' && typeof node[1] === 'string' && node.length === 3) {
2736
- const arg = node[2]
2737
- if (Array.isArray(arg) && arg[0] === 'i32.add' && arg.length === 3 &&
2738
- Array.isArray(arg[1]) && arg[1][0] === 'global.get' && typeof arg[1][1] === 'string' &&
2739
- Array.isArray(arg[2]) && arg[2][0] === 'i32.const') {
2740
- const k = node[1] + '##' + arg[1][1]
2741
- counts.set(k, (counts.get(k) || 0) + 1)
2742
- sites.push({ parent, idx, key: k })
2743
- }
2744
- }
2745
- for (let i = 0; i < node.length; i++) walk(node[i], node, i)
2746
- }
2747
- for (let i = 0; i < funcs.length; i++) walk(funcs[i], null, 0)
2748
-
2749
- const specialized = new Set()
2750
- for (const [k, n] of counts) if (n >= MIN_USES) specialized.add(k)
2751
- if (!specialized.size) return
2752
-
2753
- // Find a target func's result-type by locating its decl among `funcs`.
2754
- const funcByName = new Map()
2755
- for (let i = 0; i < funcs.length; i++) {
2756
- const fn = funcs[i]
2757
- if (Array.isArray(fn) && fn[0] === 'func' && typeof fn[1] === 'string') funcByName.set(fn[1], fn)
2758
- }
2759
- const resultOf = (name) => {
2760
- const fn = funcByName.get(name)
2761
- if (!fn) return 'f64' // defensive; mkptr specializations all return f64
2762
- for (let i = 2; i < fn.length; i++) {
2763
- const c = fn[i]
2764
- if (Array.isArray(c) && c[0] === 'result') return c[1]
2765
- if (Array.isArray(c) && c[0] !== 'param') break
2766
- }
2767
- return 'f64'
2768
- }
2769
-
2770
- const sanit = (g) => g.replace(/^\$/, '').replace(/[^a-zA-Z0-9_]/g, '_')
2771
- const variantFor = (F, G) => `${F}_rel_${sanit(G)}`
2772
-
2773
- // Pass 2: emit helpers.
2774
- for (const fullKey of specialized) {
2775
- const [F, G] = fullKey.split('##')
2776
- const rt = resultOf(F)
2777
- const name = variantFor(F, G)
2778
- addFunc(`(func ${name} (param $o i32) (result ${rt}) (call ${F} (i32.add (global.get ${G}) (local.get $o))))`)
2779
- }
2780
-
2781
- // Pass 3: rewrite recorded sites in reverse (leaf-first since pass 1 was pre-order).
2782
- // Idempotency guard: shared IR subtrees can record the same (parent, idx) twice.
2783
- // The first visit rewrites to a 2-arg call; subsequent visits see a shape that
2784
- // doesn't match the original `call F (i32.add (global.get) (i32.const))` pattern.
2785
- for (let i = sites.length - 1; i >= 0; i--) {
2786
- const { parent, idx, key } = sites[i]
2787
- if (!specialized.has(key)) continue
2788
- const c = parent[idx]
2789
- if (!Array.isArray(c) || c[0] !== 'call' || c.length !== 3) continue
2790
- const arg = c[2]
2791
- if (!Array.isArray(arg) || arg[0] !== 'i32.add' || arg.length !== 3) continue
2792
- if (!Array.isArray(arg[1]) || arg[1][0] !== 'global.get') continue
2793
- if (!Array.isArray(arg[2]) || arg[2][0] !== 'i32.const') continue
2794
- const F = c[1]
2795
- const G = arg[1][1]
2796
- const konst = arg[2]
2797
- const newCall = ['call', variantFor(F, G), konst]
2798
- newCall.type = resultOf(F)
2799
- parent[idx] = newCall
2800
- }
2801
- }
2802
-
2803
- /**
2804
- * Reorder strings in `strPool` so most-referenced strings get low byte offsets.
2805
- * Each string ref is encoded as `(i32.const off)` with ULEB128: 1 B for off<128, 2 B for off<16384, 3 B for off<2M.
2806
- * Frequent strings migrating from 3-B to 2-B (or 2-B to 1-B) LEB128 saves ~541 B on watr self-host.
2807
- *
2808
- * Pool layout: `[4-byte-len][data-bytes][4-byte-len][data-bytes]...`. Offsets in refs point PAST the len prefix.
2809
- *
2810
- * @param funcs — flat list of func IR nodes (scanned for refs)
2811
- * @param strPoolRef — `{ pool: string }` holder; pool is rewritten in place
2812
- * @param strDedupMap — optional `Map<string, offset>` to update (kept consistent for later queries)
2813
- */
2814
- export function sortStrPoolByFreq(funcs, strPoolRef, strDedupMap) {
2815
- if (!strPoolRef.pool) return
2816
- // Match both specialized and unspecialized strBase refs.
2817
- const isSpecRef = (n) =>
2818
- Array.isArray(n) && n[0] === 'call' && typeof n[1] === 'string' && n[1].includes('_rel___strBase') &&
2819
- n.length === 3 && Array.isArray(n[2]) && n[2][0] === 'i32.const'
2820
- const isUnspecRef = (n) =>
2821
- Array.isArray(n) && n[0] === 'call' && typeof n[1] === 'string' && n[1].startsWith('$__mkptr_') &&
2822
- n.length === 3 && Array.isArray(n[2]) && n[2][0] === 'i32.add' && n[2].length === 3 &&
2823
- Array.isArray(n[2][1]) && n[2][1][0] === 'global.get' && n[2][1][1] === '$__strBase' &&
2824
- Array.isArray(n[2][2]) && n[2][2][0] === 'i32.const'
2825
- const getOff = (n) => isSpecRef(n) ? (n[2][1] | 0) : isUnspecRef(n) ? (n[2][2][1] | 0) : null
2826
- const setOff = (n, v) => { if (isSpecRef(n)) n[2][1] = v; else if (isUnspecRef(n)) n[2][2][1] = v }
2827
-
2828
- // Single walk: count freq AND record each ref site for direct rewrite.
2829
- const freq = new Map()
2830
- const sites = [] // { node, oldOff } — node is the ref node, mutate offset in place
2831
- const walk = (n) => {
2832
- if (!Array.isArray(n)) return
2833
- const o = getOff(n)
2834
- if (o !== null) { freq.set(o, (freq.get(o) || 0) + 1); sites.push({ node: n, oldOff: o }) }
2835
- for (let i = 0; i < n.length; i++) walk(n[i])
2836
- }
2837
- for (let i = 0; i < funcs.length; i++) walk(funcs[i])
2838
- if (!freq.size) return
2839
-
2840
- // Parse pool structure into entries.
2841
- const pool = strPoolRef.pool
2842
- const entries = []
2843
- let i = 0
2844
- while (i < pool.length) {
2845
- const len = pool.charCodeAt(i) | (pool.charCodeAt(i+1) << 8) | (pool.charCodeAt(i+2) << 16) | (pool.charCodeAt(i+3) << 24)
2846
- const oldOff = i + 4
2847
- entries.push({ oldOff, len, str: pool.substring(oldOff, oldOff + len) })
2848
- i = oldOff + len
2849
- }
2850
-
2851
- // Sort by freq descending; tie-break by length ascending (pack short hot strings into low-offset range).
2852
- entries.sort((a, b) => (freq.get(b.oldOff) || 0) - (freq.get(a.oldOff) || 0) || a.len - b.len)
2853
-
2854
- // Rebuild pool; map old → new offsets. Deduplicate identical strings — keep the
2855
- // first (hottest) occurrence as canonical and point duplicates to it.
2856
- const remap = new Map()
2857
- const canon = new Map() // str content → new offset
2858
- let newPool = ''
2859
- for (const e of entries) {
2860
- const existing = canon.get(e.str)
2861
- if (existing !== undefined) {
2862
- remap.set(e.oldOff, existing)
2863
- continue
2864
- }
2865
- newPool += String.fromCharCode(e.len & 0xFF, (e.len >> 8) & 0xFF, (e.len >> 16) & 0xFF, (e.len >> 24) & 0xFF)
2866
- remap.set(e.oldOff, newPool.length)
2867
- canon.set(e.str, newPool.length)
2868
- newPool += e.str
2869
- }
2870
- strPoolRef.pool = newPool
2871
- if (strDedupMap)
2872
- for (const [str, oldOff] of strDedupMap) {
2873
- const newOff = remap.get(oldOff)
2874
- if (newOff !== undefined) strDedupMap.set(str, newOff)
2875
- }
2876
-
2877
- // Rewrite recorded ref sites directly (no second AST walk).
2878
- for (let i = 0; i < sites.length; i++) {
2879
- const { node, oldOff } = sites[i]
2880
- const newO = remap.get(oldOff)
2881
- if (newO !== undefined) setOff(node, newO)
2882
- }
2883
- }
2884
2711
 
2885
2712
  /**
2886
2713
  * Fold dead string-dispatch blocks when the tested operand is a proven-f64 local.
@@ -2908,6 +2735,38 @@ export function sortStrPoolByFreq(funcs, strPoolRef, strDedupMap) {
2908
2735
  * Called in the 'post' phase of optimizeFunc, before vectorizeLaneLocal, so the
2909
2736
  * cleaned IR is what the vectorizer pattern-matches.
2910
2737
  */
2738
+ // Build the "pure for SIMD lane-inline" map consumed by tryPerPixelColor's Phase-2
2739
+ // user-function inline (cfg._pureFuncMap). A user function qualifies when its body has
2740
+ // no side effects: no global.set, no memory store, and no call except $math.* / $__to_num
2741
+ // (a pure numeric coercion the lane lift strips). foldStrDispatchF64 runs first
2742
+ // (idempotent) so the purity check sees the folded body — dead __is_str_key dispatch on a
2743
+ // proven-f64 param would otherwise read as impure. Built in the emit phase (optimizeModule),
2744
+ // BEFORE the per-function lane vectorizer runs, so callee bodies are still clean scalar.
2745
+ export function buildPureFuncMap(funcs) {
2746
+ const pureFuncMap = new Map()
2747
+ const hasSideEffect = (node) => {
2748
+ if (!Array.isArray(node)) return false
2749
+ const op = node[0]
2750
+ if (op === 'global.set') return true
2751
+ if (typeof op === 'string' && (op.endsWith('.store') || op.startsWith('memory.'))) return true
2752
+ if (op === 'call' && typeof node[1] === 'string' && !node[1].startsWith('$math.') && node[1] !== '$__to_num') return true
2753
+ if (op === 'call_indirect' || op === 'call_ref') return true
2754
+ return node.some((c, i) => i > 0 && hasSideEffect(c))
2755
+ }
2756
+ for (const fn of funcs) {
2757
+ if (!Array.isArray(fn) || fn[0] !== 'func') continue
2758
+ const name = fn[1]
2759
+ if (typeof name !== 'string' || name.startsWith('$math.') || name.startsWith('$__')) continue
2760
+ foldStrDispatchF64(fn)
2761
+ const bodyStart = findBodyStart(fn)
2762
+ if (bodyStart < 0) continue
2763
+ let pure = true
2764
+ for (let i = bodyStart; i < fn.length; i++) if (hasSideEffect(fn[i])) { pure = false; break }
2765
+ if (pure) pureFuncMap.set(name, fn)
2766
+ }
2767
+ return pureFuncMap
2768
+ }
2769
+
2911
2770
  export function foldStrDispatchF64(fn) {
2912
2771
  if (!Array.isArray(fn) || fn[0] !== 'func') return
2913
2772
  const bodyStart = findBodyStart(fn)
@@ -3005,11 +2864,25 @@ export function foldStrDispatchF64(fn) {
3005
2864
  // then: ['then', ['call','$__str_concat',...]]
3006
2865
  if (!Array.isArray(thenB) || thenB[0] !== 'then') return node
3007
2866
  // else: ['else', ['f64.add', ['local.get',$B], ['local.get',$C]]]
2867
+ // Each operand may be wrapped in emit '+' numSide's atom-coercion guard —
2868
+ // `(if (f64.eq $X $X) (then $X) (else <atom select ladder>))` — which is dead
2869
+ // once the operand is proven rawF64; unwrap to the inner local.get.
2870
+ const unwrapGuard = (n) => {
2871
+ if (!Array.isArray(n) || n[0] !== 'if' || n.length !== 5) return n
2872
+ const [, res, cnd, thn] = n
2873
+ if (!Array.isArray(res) || res[0] !== 'result' || res[1] !== 'f64') return n
2874
+ if (!Array.isArray(cnd) || cnd[0] !== 'f64.eq') return n
2875
+ if (!Array.isArray(thn) || thn[0] !== 'then' || thn.length !== 2) return n
2876
+ const inner = thn[1]
2877
+ if (!Array.isArray(inner) || inner[0] !== 'local.get') return n
2878
+ if (!Array.isArray(cnd[1]) || cnd[1][0] !== 'local.get' || cnd[1][1] !== inner[1]) return n
2879
+ return inner
2880
+ }
3008
2881
  if (!Array.isArray(elseB) || elseB[0] !== 'else' || elseB.length !== 2) return node
3009
2882
  const addExpr = elseB[1]
3010
2883
  if (!Array.isArray(addExpr) || addExpr[0] !== 'f64.add' || addExpr.length !== 3) return node
3011
2884
  // The two operands of f64.add must be local.get $B and local.get $C (in either order)
3012
- const [lhsAdd, rhsAdd] = [addExpr[1], addExpr[2]]
2885
+ const [lhsAdd, rhsAdd] = [unwrapGuard(addExpr[1]), unwrapGuard(addExpr[2])]
3013
2886
  const lhsIsB = Array.isArray(lhsAdd) && lhsAdd[0] === 'local.get' && lhsAdd[1] === B
3014
2887
  const rhsIsC = Array.isArray(rhsAdd) && rhsAdd[0] === 'local.get' && rhsAdd[1] === C
3015
2888
  const lhsIsC = Array.isArray(lhsAdd) && lhsAdd[0] === 'local.get' && lhsAdd[1] === C
@@ -3046,6 +2919,8 @@ export function foldStrDispatchF64(fn) {
3046
2919
  */
3047
2920
  export function unswitchTypedParamLoop(fn) {
3048
2921
  if (!Array.isArray(fn) || fn[0] !== 'func') return
2922
+ // JZ_DBG_UNSWITCH=<substr>: dump matching fns entering this pass (DBG_DSR-style).
2923
+ if (DBG_UNSWITCH && String(fn[1]).includes(DBG_UNSWITCH)) console.error(JSON.stringify(fn))
3049
2924
  const bodyStart = findBodyStart(fn)
3050
2925
  if (bodyStart < 0) return
3051
2926
  const f64Params = new Set()
@@ -3097,7 +2972,25 @@ export function unswitchTypedParamLoop(fn) {
3097
2972
  if (!Array.isArray(incNode) || incNode[0] !== 'local.set' || !Array.isArray(incNode[2]) || incNode[2][0] !== 'i32.add') return
3098
2973
  const incVar = incNode[1], inc = incNode[2]
3099
2974
  if (!(Array.isArray(inc[1]) && inc[1][0] === 'local.get' && inc[1][1] === incVar && Array.isArray(inc[2]) && inc[2][0] === 'i32.const' && inc[2][1] === 1)) return
3100
- const body = loopNode.slice(3, endIdx - 1)
2975
+ // Pre-watr, jz wraps every multi-statement expression-group in `(block (result T) …)`
2976
+ // — as a dropped expression-statement (drop follows) or as an if-arm's tail value.
2977
+ // watr's own vacuum/mergeBlocks used to flatten this post-hoc (when this pass ran
2978
+ // post-watr); now it runs pre-watr and must see through the wrapper itself. Unlabeled
2979
+ // ⇒ not a branch target (the normalizeTransparentBlocks convention, generalized here to
2980
+ // result-carrying blocks since these are unambiguous STATEMENT-LIST positions — never
2981
+ // operand slots), so a flattened VIEW is exactly equivalent. Non-mutating: the matched
2982
+ // `if` node is shared with the verbatim blockNode preserved as the bit-exact else-
2983
+ // fallback, so the scan must not restructure it in place.
2984
+ const flattenStmts = (arr, from) => {
2985
+ const out = []
2986
+ for (let i = from; i < arr.length; i++) {
2987
+ const c = arr[i]
2988
+ if (Array.isArray(c) && c[0] === 'block' && Array.isArray(c[1]) && c[1][0] === 'result') out.push(...flattenStmts(c, 2))
2989
+ else out.push(c)
2990
+ }
2991
+ return out
2992
+ }
2993
+ const body = flattenStmts(loopNode, 3).slice(0, -2) // drop the trailing incNode/br (already validated above)
3101
2994
  if (body.length < 4) return
3102
2995
 
3103
2996
  // Find the polymorphic-store `if` by scanning (it's followed by a `drop` of its
@@ -3109,8 +3002,12 @@ export function unswitchTypedParamLoop(fn) {
3109
3002
  let thenArm = null, elseArm = null
3110
3003
  for (let k = 2; k < c.length; k++) { const a = c[k]; if (Array.isArray(a)) { if (a[0] === 'then') thenArm = a; else if (a[0] === 'else') elseArm = a } }
3111
3004
  if (!thenArm || !elseArm) continue
3005
+ const thenStmts = flattenStmts(thenArm, 1)
3112
3006
  let p = null
3113
- for (let k = 1; k < thenArm.length; k++) { const a = thenArm[k]; if (Array.isArray(a) && a[0] === 'local.set' && f64Params.has(a[1]) && Array.isArray(a[2]) && a[2][0] === 'local.get') p = a[1] }
3007
+ for (const a of thenStmts) {
3008
+ if (Array.isArray(a) && a[0] === 'local.set' && f64Params.has(a[1]) && Array.isArray(a[2]) &&
3009
+ (a[2][0] === 'local.get' || (a[2][0] === 'call' && a[2][1] === '$__arr_set_idx_ptr'))) p = a[1]
3010
+ }
3114
3011
  if (!p || !has(thenArm, (x) => x[0] === 'call' && x[1] === '$__arr_set_idx_ptr')) continue
3115
3012
  // The bare `f64.store(__ptr_offset(o)+i<<3)` is the non-ARRAY fallback. It may be
3116
3013
  // nested under an OBJECT/HASH → __dyn_set guard (emitPolymorphicElementStore's
@@ -3202,10 +3099,13 @@ export function unswitchTypedParamLoop(fn) {
3202
3099
  * @param cfg optional resolved config from resolveOptimize() — when omitted, all on.
3203
3100
  * @param globalTypes optional global name → wasm type map (for promoteGlobals)
3204
3101
  * @param volatileGlobals optional set of callee-mutable globals (see collectVolatileGlobals)
3205
- * @param phase 'pre' (default, pre-watr leaf pass) or 'post' (re-run after watr) —
3206
- * gates the passes that only pay off once watr has reshaped the IR.
3102
+ * (The former 'post' phase and its csePureExprLoop arm are deleted, and the
3103
+ * straight-line csePureExpr followed in the 2026-07 ablation sweeps watr's
3104
+ * write-clock CSE reaches a smaller fixpoint on its own; jz's optimizer runs
3105
+ * exactly once, before watr. splitLoopPrivateScratch remains as the flag-gated
3106
+ * migration seed; see the splitScratch gate below.)
3207
3107
  */
3208
- export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre', reachableWrites) {
3108
+ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, reachableWrites) {
3209
3109
  if (cfg && cfg.hoistPtrType === false &&
3210
3110
  cfg.hoistInvariantPtrOffset === false &&
3211
3111
  cfg.hoistInvariantLoop === false &&
@@ -3213,17 +3113,20 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
3213
3113
  cfg.fusedRewrite === false &&
3214
3114
  cfg.hoistAddrBase === false &&
3215
3115
  cfg.cseScalarLoad === false &&
3216
- cfg.csePureExpr === false &&
3217
- cfg.dropDeadZeroInit === false &&
3218
- cfg.deadStoreElim === false &&
3116
+ cfg.propagateSingleUse === false &&
3219
3117
  cfg.promoteGlobals === false &&
3220
3118
  cfg.sortLocalsByUse === false &&
3221
3119
  cfg.vectorizeLaneLocal === false) return
3120
+ // Static-const-array base/len fold runs FIRST: it matches the exact emit shape
3121
+ // via node tags (.saArr/.saBits), and any later pass that rebuilds a subtree
3122
+ // (CSE, fused rewrite, LICM temp-splitting) strips array properties — the tag
3123
+ // only survives untouched nodes.
3124
+ if (!cfg || cfg.foldStaticArrReads !== false) foldStaticConstArrayReads(fn)
3222
3125
  // Recursion-unrolling runs first in 'pre': self-calls are still clean `call`
3223
3126
  // nodes (watr's inliner hasn't reshaped them) and the freshly-inlined body then
3224
3127
  // rides every pass below (LICM, fold, sort). Speed-tier only; 'pre' only (so the
3225
3128
  // post-watr re-optimize doesn't unroll a second time).
3226
- if (cfg && cfg.recursionUnroll === true && phase === 'pre') recursionUnroll(fn)
3129
+ if (cfg && cfg.recursionUnroll === true) recursionUnroll(fn)
3227
3130
  if (!cfg || cfg.hoistPtrType !== false) hoistPtrType(fn)
3228
3131
  if (!cfg || cfg.hoistInvariantPtrOffset !== false) hoistInvariantPtrOffset(fn)
3229
3132
  // Before LICM: the snapped i32 bound is itself a hoistable hard-op subtree, so
@@ -3239,27 +3142,17 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
3239
3142
  if (!cfg || cfg.hoistAddrBase !== false) hoistAddrBase(fn)
3240
3143
  if (!cfg || cfg.hoistInvariantLoop !== false) hoistInvariantLoop(fn)
3241
3144
  if (!cfg || cfg.cseScalarLoad !== false) cseScalarLoad(fn)
3242
- if (!cfg || cfg.csePureExpr !== false) {
3243
- if (cfg && (cfg.watr === true || typeof cfg.watr === 'object') && phase === 'post') csePureExprLoop(fn)
3244
- else csePureExpr(fn)
3245
- }
3246
- if (!cfg || cfg.dropDeadZeroInit !== false) dropDeadZeroInit(fn)
3247
- if (!cfg || cfg.deadStoreElim !== false) deadStoreElim(fn)
3248
3145
  if (!cfg || cfg.promoteGlobals !== false) promoteGlobals(fn, globalTypes, volatileGlobals, reachableWrites)
3249
- // Vectorizer runs PRE-watr unless full watr is enabled (`watr: true`). For full watr,
3250
- // defer to post — full passes (notably `inlineOnce` + the post-inline `propagate`
3251
- // sweep) reshape the IR so much that pre-watr SIMD patterns get scrambled. Light
3252
- // watr (or no watr) leaves the lane locals intact for vectorize to pattern-match,
3253
- // and lets a non-trivial chunk of SIMD survive the propagate+fold pipeline.
3254
3146
  if (cfg && cfg.vectorizeLaneLocal === true) {
3255
- const fullWatr = cfg.watr === true || typeof cfg.watr === 'object'
3256
- const runVectorizer = (fullWatr && phase === 'post') || (!fullWatr && phase !== 'post')
3147
+ // Vectorization is jz LOWERING it always runs pre-watr (never in a post-watr
3148
+ // re-optimize). watr is the sole optimizer that runs after, and it preserves the
3149
+ // v128 the lift produces. `phase === 'post'` is now vestigial (no post caller).
3257
3150
  // Phase 1: fold dead string-dispatch blocks on proven-f64 locals BEFORE
3258
3151
  // the vectorizer pattern-matches — dead __is_str_key calls in $fbm-style
3259
3152
  // functions (param f64 + op f64) block liftPPC from recognizing them as pure.
3260
- if (runVectorizer && (!cfg || cfg.unswitchTypedParamLoop !== false)) unswitchTypedParamLoop(fn)
3261
- if (runVectorizer) foldStrDispatchF64(fn)
3262
- if (runVectorizer) vectorizeLaneLocal(fn, {
3153
+ if (!cfg || cfg.unswitchTypedParamLoop !== false) unswitchTypedParamLoop(fn)
3154
+ foldStrDispatchF64(fn)
3155
+ if (vectorizeLaneLocal(fn, {
3263
3156
  multiAcc: cfg.reduceUnroll === true,
3264
3157
  relaxedFma: cfg.relaxedSimd === true,
3265
3158
  blurMP: cfg.blurMultiPixel !== false,
@@ -3269,30 +3162,47 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
3269
3162
  pureFuncMap: cfg._pureFuncMap || null,
3270
3163
  toneMap: cfg.experimentalToneMap !== false,
3271
3164
  slp: cfg.experimentalSlp !== false, // SLP default-on (testing single-use fix)
3272
- })
3165
+ crPow: cfg.crPow === true,
3166
+ }) && typeof fn[1] === 'string') (cfg._vectorizedFnNames ??= new Set()).add(fn[1])
3273
3167
  // The vectorizer emits `v128.load/store (i32.add base K)` for the unrolled
3274
3168
  // multi-accumulator reduction (a[i],a[i+2],a[i+4]…) and stencil/strided reads.
3275
3169
  // fusedRewrite's memarg fold already ran (above, before vectorize), so fold the
3276
3170
  // freshly-created v128 memargs now — one fewer i32.add per accumulator per
3277
3171
  // iteration, the hot-loop waste audit-fixpoint.mjs flagged on dot/sum.
3278
- if (runVectorizer) foldV128Memargs(fn)
3279
- }
3280
- // SSA-split loop-private unrolled scratch (post-vectorize: vectorized loops now carry
3281
- // v128 and are skipped) so the LICM below hoists the per-iteration invariants the
3282
- // unroller's name-merging hid rust/LLVM's free-after-unroll register hoist (closes
3283
- // the raytrace per-sphere `c_i` recompute). Bit-exact; re-run LICM to lift the splits.
3284
- if (phase === 'post' && (!cfg || cfg.hoistInvariantLoop !== false)) {
3172
+ foldV128Memargs(fn)
3173
+ }
3174
+ // Forward-substitute single-use temps AFTER the vectorizer, never before: it pattern-matches a
3175
+ // STRAIGHT-LINE `s += a[i]*2`, and folding an address/index temp out scrambles it (the typed-array
3176
+ // loop fell from a SIMD body to a scalar unroll, +231 B). For watr:false the whole pipeline is the
3177
+ // 'pre' phase (no 'post' re-run), so vectorize already ran above; for full watr the vectorizer is
3178
+ // deferred to 'post', so skip 'pre' here to stay after it. (propagateSingleUse itself skips any
3179
+ // function the vectorizer already lifted to v128.)
3180
+ // Forward-substitute single-use temps AFTER the vectorizer (which now always runs in
3181
+ // 'pre', above) — propagateSingleUse itself skips any function already lifted to v128.
3182
+ if (!cfg || cfg.propagateSingleUse !== false) propagateSingleUse(fn)
3183
+ // Then sink single-def RHS into first use as a tee — captures the simplify-locals slack
3184
+ // watr's use-count propagate leaves (set→tee fold, incl. effectful single-use forward).
3185
+ if (!cfg || cfg.foldSetToTee !== false) foldSetToTee(fn)
3186
+ // SSA-split loop-private unrolled scratch, then re-run LICM to hoist the freed per-iteration
3187
+ // invariants (rust/LLVM's free-after-unroll register hoist — the raytrace per-sphere `c_i`
3188
+ // recompute). Its input is UNROLLED/INLINED scratch, which today only exists after watr's
3189
+ // inlining — so pre-watr it's a no-op (raytrace stays regressed). Gated OFF (`cfg.splitScratch`,
3190
+ // default false) until jz gains pre-watr inlining (phase-2); the logic is the migration target.
3191
+ if (cfg && cfg.splitScratch === true && (!cfg || cfg.hoistInvariantLoop !== false)) {
3285
3192
  splitLoopPrivateScratch(fn)
3286
- // Iterate LICM for the dependency cascade: c = ox²+… is invariant only once ox is
3287
- // itself hoisted out, which the single-pass hoister can't see in one go. 4 climbs
3288
- // covers the deepest scratch chain; idempotent, so it self-terminates.
3289
3193
  for (let k = 0; k < 4; k++) hoistInvariantLoop(fn)
3290
3194
  }
3291
- // Loop rotation the LAST shape pass (post-watr only, so no later pass reverts
3292
- // it and the v128 loops are already formed for the skip-guard). Speed-tier: it
3293
- // duplicates the loop condition for a fused conditional back-edge (1.35× on the
3294
- // lz/qoi scalar scans see rotateLoops).
3295
- if (cfg && cfg.rotateLoops === true && phase === 'post') rotateLoops(fn)
3195
+ // Const-fn-array dispatch devirt: emit tagged the call_indirect of
3196
+ // `constOps[idx](args)` (the decl's candidate set only fills when module init
3197
+ // emits, AFTER function bodies) rewrite to a br_table of direct calls with
3198
+ // the original call_indirect as the always-sound default arm.
3199
+ if (!cfg || cfg.devirtFnArrays !== false) devirtConstFnArrayCalls(fn, cfg)
3200
+ if (!cfg || cfg.devirtSchemaReads !== false) devirtSchemaReads(fn)
3201
+ // Loop rotation — the LAST shape pass. Runs in the pre phase (the only phase now); the
3202
+ // vectorizer above has already formed the v128 loops it skips. Speed-tier: it duplicates the
3203
+ // loop condition for a fused conditional back-edge (1.35× on the lz/qoi scalar scans). watr's
3204
+ // loopify is disabled when vectorizing, so nothing downstream reverts the rotation.
3205
+ if (cfg && cfg.rotateLoops === true) rotateLoops(fn)
3296
3206
  // Canonicalize boolean conditions (strip redundant `!= 0` / double-`eqz`) — after
3297
3207
  // rotateLoops so its fused back-edges get cleaned too. Tied to the peephole pass.
3298
3208
  if (!cfg || cfg.fusedRewrite !== false) simplifyBoolContexts(fn)
@@ -3550,13 +3460,23 @@ function fusedRewrite(fn, counts) {
3550
3460
  // Single-textual-def locals → their defining value node, so the trunc_sat range fold (below)
3551
3461
  // can see through the temps inlining introduces when proving an index/packed value fits i32.
3552
3462
  // Multi-def (incl. loop-carried self-referential) locals are excluded: their value is not the
3553
- // one def's, so its range wouldn't bound them. Pure read of the IR value-preserving rewrites
3554
- // during this same walk keep the captured def's RANGE intact, so a lazily-built map stays sound.
3555
- // Built on first query only (most functions carry no guarded-trunc form zero cost).
3463
+ // one def's, so its range wouldn't bound them. PARAMS are excluded outright: a param carries an
3464
+ // IMPLICIT entry def the textual scan can't see, and it is the one local class whose pre-write
3465
+ // value is externally controlled `f = (p) => { use(1 >>> Math.abs(p)); p = 0 }` resolved p→0,
3466
+ // claimed range [0,0], and the collapsed bare trunc_sat saturated an incoming -Infinity to a
3467
+ // 31-lane shift (ToUint32(∞) is 0; the fuzzer's seed-6465 miscompile). Non-param pre-def reads
3468
+ // can only be the zero/undef-NaN init, and trunc_sat maps BOTH exactly as ToInt32 does, so the
3469
+ // textual rule stays sound for every other local. Pure read of the IR — value-preserving
3470
+ // rewrites during this same walk keep the captured def's RANGE intact, so a lazily-built map
3471
+ // stays sound. Built on first query only.
3556
3472
  let defVal
3557
3473
  const get = (name) => {
3558
3474
  if (defVal === undefined) {
3559
3475
  defVal = new Map(); const defCnt = new Map()
3476
+ for (let i = 2; i < fn.length; i++) {
3477
+ const d = fn[i]
3478
+ if (Array.isArray(d) && d[0] === 'param' && typeof d[1] === 'string') defCnt.set(d[1], 2)
3479
+ }
3560
3480
  const scanDefs = (n) => {
3561
3481
  if (!Array.isArray(n)) return
3562
3482
  if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') { defCnt.set(n[1], (defCnt.get(n[1]) || 0) + 1); defVal.set(n[1], n[2]) }
@@ -4036,6 +3956,545 @@ export function treeshake(funcSections, allModuleNodes, opts) {
4036
3956
  * Only the decl order changes; refs by name are unchanged and re-resolved by watr.
4037
3957
  * Params are fixed (their slot defines the call ABI) — only `(local …)` nodes move.
4038
3958
  */
3959
+ /** `o.x` on a statically-unknown receiver — the megamorphic property read
3960
+ * (shapes bench: 8 record variants at one site, every field load a ~50-op
3961
+ * __dyn_get_any_t_h hash probe). The module's registered schema list is known
3962
+ * and bounded once emission completes: switch on the box's aux schemaId via
3963
+ * br_table into direct slot loads for every schema CARRYING the field — the
3964
+ * static mirror of a polymorphic inline cache. Non-OBJECT tags, alien sids and
3965
+ * schemas lacking the field all take the original call (default arm): a
3966
+ * schema slot is authoritative for its own fields (dyn writes to schema keys
3967
+ * mirror into the slot — buildObjectSchemaSetArm), so the direct load is
3968
+ * bit-identical where it fires. Emit tagged the call (.dvProp) because
3969
+ * schema.list is still growing while function bodies emit. */
3970
+ export function devirtSchemaReads(fn) {
3971
+ if (!Array.isArray(fn) || fn[0] !== 'func') return
3972
+ const schemas = ctx.schema?.list
3973
+ if (!schemas || !schemas.length || schemas.length > 24) return
3974
+ if (!ctx.core.includes.has('__ptr_type')) return
3975
+ let uid = null
3976
+ const newDecls = []
3977
+ // Receiver-stable sid cache: a devirt read whose receiver is a bare local that
3978
+ // is NEVER written in this function (param or single-init const — this pass
3979
+ // runs before watr inlining, so `measure(o)`-style helpers still have their
3980
+ // own frame) has a CONSTANT schemaId for the whole body: the sid lives in the
3981
+ // box's aux bits and a jz OBJECT's shape never changes (dyn writes go to the
3982
+ // sidecar, not the aux). Compute `sid | -1(non-OBJECT)` ONCE at body start;
3983
+ // every read on that receiver drops its per-read __ptr_type guard + aux
3984
+ // extract and br_tables on the cached local (-1 wraps u32-huge → default arm).
3985
+ const assigned = new Set()
3986
+ const scanAssigns = (n) => {
3987
+ if (!Array.isArray(n)) return
3988
+ if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') assigned.add(n[1])
3989
+ for (let k = 1; k < n.length; k++) scanAssigns(n[k])
3990
+ }
3991
+ scanAssigns(fn)
3992
+ // receiver expr → { name, bits } for a bare never-written local (f64 local
3993
+ // wrapped in reinterpret, or an already-i64 local), else null (keep the
3994
+ // per-read spill+guard path)
3995
+ const stableRecv = (r) => {
3996
+ if (Array.isArray(r) && r[0] === 'i64.reinterpret_f64' &&
3997
+ Array.isArray(r[1]) && r[1][0] === 'local.get' && typeof r[1][1] === 'string' &&
3998
+ !assigned.has(r[1][1])) return { name: r[1][1], bits: r }
3999
+ if (Array.isArray(r) && r[0] === 'local.get' && typeof r[1] === 'string' &&
4000
+ !assigned.has(r[1])) return { name: r[1], bits: r }
4001
+ return null
4002
+ }
4003
+ const sidCache = new Map() // receiver local name → sid i32 local
4004
+ const sidInit = []
4005
+ const recvReads = new Map() // receiver local name → tagged-read count (pre-scan)
4006
+ const clone = (n) => Array.isArray(n) ? n.map(clone) : n
4007
+ // select(aux, -1, tag==OBJECT) — both operands pure, no branch
4008
+ const sidExprFor = (bits) => ['select',
4009
+ ['i32.wrap_i64', ['i64.and',
4010
+ ['i64.shr_u', clone(bits), ['i64.const', LAYOUT.AUX_SHIFT]],
4011
+ ['i64.const', LAYOUT.AUX_MASK]]],
4012
+ ['i32.const', -1],
4013
+ ['i32.eq',
4014
+ ['i32.wrap_i64', ['i64.and',
4015
+ ['i64.shr_u', clone(bits), ['i64.const', LAYOUT.TAG_SHIFT]],
4016
+ ['i64.const', LAYOUT.TAG_MASK]]],
4017
+ ['i32.const', PTR.OBJECT]]]
4018
+ // ≥2 reads on the receiver: amortize into an entry-hoisted local. A single
4019
+ // read inlines the select at its site instead — an eager entry compute would
4020
+ // tax every call of a function whose lone read sits on a cold path (the
4021
+ // self-host kernel's shape; measured 0.9% compile-time regression).
4022
+ const sidRead = (stable) => {
4023
+ if ((recvReads.get(stable.name) || 0) < 2) return sidExprFor(stable.bits)
4024
+ let sidT = sidCache.get(stable.name)
4025
+ if (!sidT) {
4026
+ sidT = `$__dsrs${uid++}`
4027
+ newDecls.push(['local', sidT, 'i32'])
4028
+ sidInit.push(['local.set', sidT, sidExprFor(stable.bits)])
4029
+ sidCache.set(stable.name, sidT)
4030
+ }
4031
+ return ['local.get', sidT]
4032
+ }
4033
+ // Evaluation-order safety class shared by the rewrite and the duplicate-read
4034
+ // memo: arms/reuse evaluate ONLY the receiver (or nothing); the original call
4035
+ // also evaluates key/tag/hash operands. All must be pure for the paths to be
4036
+ // observationally identical (they are in practice: local reads + constants —
4037
+ // the emitDynGetAnyTyped shape). `call $__ptr_type` is a pure bit extract.
4038
+ const PURE_I64 = new Set(['i64.const', 'i64.reinterpret_f64', 'f64.reinterpret_i64',
4039
+ 'i64.and', 'i64.or', 'i64.xor', 'i64.shr_u', 'i64.shl', 'i64.eq', 'i64.ne', 'i64.eqz',
4040
+ 'i64.extend_i32_u', 'i64.extend_i32_s',
4041
+ 'i32.const', 'i32.wrap_i64', 'i32.and', 'i32.or', 'i32.xor', 'i32.shr_u', 'i32.shl',
4042
+ 'i32.add', 'i32.sub', 'i32.eq', 'i32.ne', 'i32.eqz'])
4043
+ const pureOp = (n) => !Array.isArray(n) ? true
4044
+ : n[0] === 'call' && n[1] === '$__ptr_type' ? n.slice(2).every(pureOp)
4045
+ : PURE_I64.has(n[0]) ? n.slice(1).every(pureOp)
4046
+ : isPureIR(n)
4047
+ const rewrite = (parent, i) => {
4048
+ const node = parent[i]
4049
+ const prop = node.dvProp
4050
+ const withProp = []
4051
+ for (let sid = 0; sid < schemas.length; sid++) {
4052
+ const slot = schemas[sid].indexOf(prop)
4053
+ if (slot >= 0) withProp.push([sid, slot])
4054
+ }
4055
+ if (!withProp.length) return
4056
+ // `local.tee` operands (foldSetToTee folds shared tag/CSE
4057
+ // locals into the FIRST read's call, possibly nested) are hoisted to
4058
+ // standalone sets before the dispatch, innermost first — the original call
4059
+ // evaluated them unconditionally, so unconditional sets are observationally
4060
+ // identical, later readers of those locals still see them, and the arms
4061
+ // (which skip the default call) stay sound.
4062
+ const teeHoists = []
4063
+ const extractTees = (n) => {
4064
+ if (!Array.isArray(n)) return n
4065
+ if (n[0] === 'local.tee' && typeof n[1] === 'string') {
4066
+ teeHoists.push(['local.set', n[1], extractTees(n[2])])
4067
+ return ['local.get', n[1]]
4068
+ }
4069
+ return n.map((c, k) => k === 0 ? c : extractTees(c))
4070
+ }
4071
+ const operands = node.slice(2).map(extractTees)
4072
+ for (const op of operands) if (!pureOp(op)) { if (DBG_DSR) console.error('[dsr-bail]', prop, 'impure operand:', JSON.stringify(op).slice(0, 200)); return }
4073
+ for (const h of teeHoists) if (!pureOp(h[2])) { if (DBG_DSR) console.error('[dsr-bail]', prop, 'impure tee:', JSON.stringify(h).slice(0, 200)); return }
4074
+ // the dispatch's generic arm — the original call over the tee-free operands
4075
+ const genericCall = [node[0], node[1], ...operands]
4076
+ if (uid === null) uid = nextLocalId(fn, '$__dsr')
4077
+ const stable = stableRecv(genericCall[2])
4078
+ const id = uid++
4079
+ const rT = stable ? null : `$__dsr${id}r`
4080
+ if (rT) newDecls.push(['local', rT, 'i64'])
4081
+ // receiver bits for arms/default: the stable local read inline (fresh clone
4082
+ // per use — IR nodes must not alias), or the spill
4083
+ const recvBits = () => stable ? clone(stable.bits) : ['local.get', rT]
4084
+ const out = `$__dsro${id}`, dflt = `$__dsrd${id}`
4085
+ const lo = withProp[0][0], hi = withProp[withProp.length - 1][0]
4086
+ const bySid = new Map(withProp)
4087
+ // Discriminant-field collapse: when EVERY compile-time schema has the prop
4088
+ // at the SAME slot (the canonical tag-field pattern — `.k`/`.type`/`.kind`
4089
+ // as first key of every variant literal), a known-schema OBJECT resolves to
4090
+ // that slot with no dispatch at all: `(u32)sid < count ? load : generic`.
4091
+ // The unsigned compare routes BOTH the -1 non-OBJECT sentinel and any
4092
+ // runtime-registered alien sid (__jp_obj / host-marshaled shapes mint sids
4093
+ // past the compile-time list) to the generic arm.
4094
+ if (stable && withProp.length === schemas.length &&
4095
+ withProp.every(([, slot]) => slot === withProp[0][1])) {
4096
+ const slot = withProp[0][1]
4097
+ const dispatch = ['if', ['result', 'i64'],
4098
+ ['i32.lt_u', sidRead(stable), ['i32.const', schemas.length]],
4099
+ ['then', ['i64.load',
4100
+ ['i32.add', ['i32.wrap_i64', recvBits()], ['i32.const', slot * 8]]]],
4101
+ ['else', genericCall]]
4102
+ parent[i] = teeHoists.length
4103
+ ? ['block', out, ['result', 'i64'], ...teeHoists, dispatch]
4104
+ : dispatch
4105
+ return
4106
+ }
4107
+ const labels = Array.from({ length: hi - lo + 1 }, (_, k) => bySid.has(lo + k) ? `$__dsr${id}_${lo + k}` : dflt)
4108
+ // arms in sid order: each closes its block, loads its slot, brs out; the
4109
+ // innermost block (first arm's label) carries the br_table — selecting on
4110
+ // the hoisted sid cache when the receiver is stable (its -1 non-OBJECT
4111
+ // sentinel wraps u32-huge → default arm, so no separate tag guard), else
4112
+ // on a per-read aux extract behind a per-read tag guard.
4113
+ const armSids = withProp.map(([sid]) => sid)
4114
+ let inner = ['br_table', ...labels, dflt,
4115
+ ['i32.sub',
4116
+ stable ? sidRead(stable)
4117
+ : ['i32.wrap_i64', ['i64.and',
4118
+ ['i64.shr_u', ['local.get', rT], ['i64.const', LAYOUT.AUX_SHIFT]],
4119
+ ['i64.const', LAYOUT.AUX_MASK]]],
4120
+ ['i32.const', lo]]]
4121
+ inner = ['block', `$__dsr${id}_${armSids[0]}`,
4122
+ ...(stable ? [] : [['br_if', dflt, ['i32.ne',
4123
+ ['call', '$__ptr_type', ['local.get', rT]],
4124
+ ['i32.const', PTR.OBJECT]]]]),
4125
+ inner]
4126
+ for (let k = 0; k < armSids.length; k++) {
4127
+ const sid = armSids[k], slot = bySid.get(sid)
4128
+ const arm = ['br', out, ['i64.load',
4129
+ ['i32.add', ['i32.wrap_i64', recvBits()], ['i32.const', slot * 8]]]]
4130
+ const nextLabel = k + 1 < armSids.length ? `$__dsr${id}_${armSids[k + 1]}` : dflt
4131
+ inner = ['block', nextLabel, inner, arm]
4132
+ }
4133
+ const dfltCall = [...genericCall]
4134
+ if (!stable) dfltCall[2] = ['local.get', rT]
4135
+ parent[i] = ['block', out, ['result', 'i64'],
4136
+ ...teeHoists,
4137
+ ...(stable ? [] : [['local.set', rT, genericCall[2]]]),
4138
+ inner,
4139
+ dfltCall]
4140
+ }
4141
+ let seen = 0
4142
+ // pre-scan: count tagged reads per stable receiver (sidRead's entry-hoist
4143
+ // choice) AND per (receiver, prop) key — only keys read ≥2× tee their result
4144
+ // for the duplicate-read memo below (a lone read must not pay a local write)
4145
+ const keyReads = new Map()
4146
+ const memoKey = (c) => {
4147
+ const st = stableRecv(c[2])
4148
+ return st && c.dvProp != null ? `${st.name} ${c.dvProp}` : null
4149
+ }
4150
+ const countScan = (n) => {
4151
+ if (!Array.isArray(n)) return
4152
+ if (n[0] === 'call' && n.dvProp) {
4153
+ const st = stableRecv(n[2])
4154
+ if (st) {
4155
+ recvReads.set(st.name, (recvReads.get(st.name) || 0) + 1)
4156
+ const k = `${st.name} ${n.dvProp}`
4157
+ keyReads.set(k, (keyReads.get(k) || 0) + 1)
4158
+ }
4159
+ }
4160
+ for (let i = 1; i < n.length; i++) countScan(n[i])
4161
+ }
4162
+ countScan(fn)
4163
+ // Duplicate-read elimination riding the rewrite walk: a SECOND tagged read
4164
+ // of the SAME (stable receiver, prop) in the same straight-line region
4165
+ // reuses the first read's tee'd i64 — the whole sid-dispatch + slot load
4166
+ // drops (measure()'s `imul(o.r, imul(o.r, 3))` pays one read). Soundness:
4167
+ // the receiver is a never-written local and a jz OBJECT's shape never
4168
+ // changes, so only an intervening WRITE could change the value — any
4169
+ // non-readonly call, store, global.set or memory.grow clears the memo.
4170
+ // Conditional regions (if arms, labeled blocks a br may skip) keep entries
4171
+ // born inside them local: snapshot on entry, restore on exit (outer entries
4172
+ // stay usable inside — the first read dominates). A LOOP whose body clobbers
4173
+ // clears up front: an entry born before iteration 1's clobber must not serve
4174
+ // iteration 2. Replacing a read drops its operand evaluation — legal for
4175
+ // exactly the pure class rewrite() enforces; tee'd operands refuse (their
4176
+ // set would vanish).
4177
+ const READONLY_CALL = /^\$(__dyn_get|__ptr_type$|math\.)/
4178
+ const isClobberNode = (x) => {
4179
+ const op = x[0]
4180
+ if (op === 'call' && !x.dvProp && typeof x[1] === 'string' && !READONLY_CALL.test(x[1])) return true
4181
+ return typeof op === 'string' && (op.includes('.store') || op === 'global.set' || op === 'memory.grow')
4182
+ }
4183
+ const hasClobber = (x) => {
4184
+ if (!Array.isArray(x)) return false
4185
+ if (isClobberNode(x)) return true
4186
+ for (let i = 1; i < x.length; i++) if (hasClobber(x[i])) return true
4187
+ return false
4188
+ }
4189
+ const noTee = (x) => !Array.isArray(x) ? true
4190
+ : x[0] === 'local.tee' ? false
4191
+ : x.slice(1).every(noTee)
4192
+ const memo = new Map()
4193
+ let clobbers = 0
4194
+ const scoped = (walkBody) => {
4195
+ const snap = new Map(memo), pre = clobbers
4196
+ walkBody()
4197
+ memo.clear()
4198
+ if (clobbers === pre) for (const [k, v] of snap) memo.set(k, v)
4199
+ }
4200
+ const visitChild = (n, i) => {
4201
+ const c = n[i]
4202
+ if (!Array.isArray(c)) return
4203
+ walkDSR(c)
4204
+ if (c[0] === 'call' && c.dvProp) {
4205
+ seen++
4206
+ const key = memoKey(c)
4207
+ const hit = key && memo.get(key)
4208
+ if (hit && c.slice(2).every(o => pureOp(o) && noTee(o))) { n[i] = ['local.get', hit]; return }
4209
+ rewrite(n, i)
4210
+ if (key && (keyReads.get(key) || 0) >= 2 && Array.isArray(n[i])) {
4211
+ if (uid === null) uid = nextLocalId(fn, '$__dsr')
4212
+ const L = `$__dsrm${uid++}`
4213
+ newDecls.push(['local', L, 'i64'])
4214
+ n[i] = ['local.tee', L, n[i]]
4215
+ memo.set(key, L)
4216
+ }
4217
+ return
4218
+ }
4219
+ if (isClobberNode(c)) { memo.clear(); clobbers++ }
4220
+ }
4221
+ const walkDSR = (n) => {
4222
+ if (!Array.isArray(n)) return
4223
+ if (n[0] === 'if') {
4224
+ for (let i = 1; i < n.length; i++) {
4225
+ const c = n[i]
4226
+ if (!Array.isArray(c)) continue
4227
+ if (c[0] === 'then' || c[0] === 'else') scoped(() => walkDSR(c))
4228
+ else visitChild(n, i)
4229
+ }
4230
+ return
4231
+ }
4232
+ if (n[0] === 'loop') {
4233
+ if (hasClobber(n)) { memo.clear(); clobbers++ }
4234
+ scoped(() => { for (let i = 1; i < n.length; i++) visitChild(n, i) })
4235
+ return
4236
+ }
4237
+ if (n[0] === 'block' && typeof n[1] === 'string') {
4238
+ scoped(() => { for (let i = 1; i < n.length; i++) visitChild(n, i) })
4239
+ return
4240
+ }
4241
+ for (let i = 1; i < n.length; i++) visitChild(n, i)
4242
+ }
4243
+ walkDSR(fn)
4244
+ if (DBG_DSR && String(fn[1]).includes('measure')) console.error('[dsr]', fn[1], 'schemas:', schemas.length, 'tagged seen:', seen)
4245
+ if (newDecls.length) {
4246
+ let at = typeof fn[1] === 'string' ? 2 : 1
4247
+ while (at < fn.length && Array.isArray(fn[at]) &&
4248
+ (fn[at][0] === 'export' || fn[at][0] === 'type' || fn[at][0] === 'param' || fn[at][0] === 'result' || fn[at][0] === 'local')) at++
4249
+ // sid-cache computations go right after the decls, before the first body
4250
+ // statement — stable receivers are never-written names (params), so their
4251
+ // value at body start equals their value at every read
4252
+ fn.splice(at, 0, ...newDecls, ...sidInit)
4253
+ }
4254
+ }
4255
+
4256
+ /** Fold the base/len ceremony of `constArr[i]` element reads whose receiver is a
4257
+ * STATIC array literal bound to a const global (module/array.js tags `.saArr` /
4258
+ * `.saBits` on the read IR; the decl registers ctx.scope.staticArrs). The
4259
+ * data-segment offset and length are compile-time constants, so the per-read
4260
+ * `__ptr_offset` call + header len load collapse to literals — decisive in
4261
+ * loops containing calls, where a callee may write memory and watr's LICM must
4262
+ * keep the loads in place (the devirt'd operator-table dispatch loop is the
4263
+ * canonical victim). Facts gate: any indexed write, resizing method call, or
4264
+ * bare value use of the name anywhere in the program (ctx.types.arrResized /
4265
+ * nameEscapes, collectProgramFacts) keeps the generic form — an alias or a
4266
+ * grow could relocate the payload (header forwarding) or change len, and a
4267
+ * folded base would read stale memory. */
4268
+ export function foldStaticConstArrayReads(fn) {
4269
+ if (!Array.isArray(fn) || fn[0] !== 'func') return
4270
+ const sa = ctx.scope.staticArrs
4271
+ if (!sa || !sa.size) return
4272
+ // Facts must EXIST to fold — an absent fact set means the program was never
4273
+ // walked for resize/escape, not that the name is safe.
4274
+ const resized = ctx.types.arrResized, escapes = ctx.types.nameEscapes
4275
+ if (!resized || !escapes) return
4276
+ const rewrite = (node) => {
4277
+ const st = sa.get(node.saArr)
4278
+ if (!st) return
4279
+ // A bits-form tag (receiver folded to a const box at emit) must match the decl's
4280
+ // recorded bits; a name-form tag (receiver read `global.get $name` directly) IS
4281
+ // the identity — global names are unique.
4282
+ if (node.saBits != null && st.bits !== node.saBits) return
4283
+ if (resized.has(node.saArr) || escapes.has(node.saArr)) return
4284
+ // The base derives from the GLOBAL, not a baked constant: assemble's
4285
+ // static-prefix-strip rebases every static pointer AFTER this pass runs, so a
4286
+ // baked absolute offset goes stale (caught by the module-const table tests).
4287
+ // `global.get` is the strip-safe anchor — the global's init is rebased in
4288
+ // place, jz never folds immutable global reads, and watr (which runs after
4289
+ // the strip) propagates the rebased init into a final constant memarg. The
4290
+ // win stands regardless: the `__ptr_offset` CALL (whose forwarding follow the
4291
+ // never-resized proof makes dead) and the len header load both drop.
4292
+ const baseIR = () => ['i32.wrap_i64', ['i64.reinterpret_f64', ['global.get', `$${node.saArr}`]]]
4293
+ const isBaseIR = (n) => Array.isArray(n) && n[0] === 'i32.wrap_i64' &&
4294
+ Array.isArray(n[1]) && n[1][0] === 'i64.reinterpret_f64' &&
4295
+ Array.isArray(n[1][1]) && n[1][1][0] === 'global.get' && n[1][1][1] === `$${node.saArr}`
4296
+ // 1) base tee → global-derived base: (local.tee $b (call $__ptr_offset …)) → baseIR
4297
+ let baseLocal = null
4298
+ const subBase = (n) => {
4299
+ if (!Array.isArray(n)) return
4300
+ for (let i = 1; i < n.length; i++) {
4301
+ const c = n[i]
4302
+ if (!Array.isArray(c)) continue
4303
+ if (c[0] === 'local.tee' && Array.isArray(c[2]) && c[2][0] === 'call' && c[2][1] === '$__ptr_offset') {
4304
+ baseLocal = c[1]
4305
+ n[i] = baseIR()
4306
+ continue
4307
+ }
4308
+ subBase(c)
4309
+ }
4310
+ }
4311
+ subBase(node)
4312
+ if (!baseLocal) return
4313
+ // 2) len header load over the folded base → literal len (position-independent);
4314
+ // remaining base reads → box-derived base
4315
+ const subLen = (n) => {
4316
+ if (!Array.isArray(n)) return
4317
+ for (let i = 1; i < n.length; i++) {
4318
+ const c = n[i]
4319
+ if (!Array.isArray(c)) continue
4320
+ if (c[0] === 'i32.load' && Array.isArray(c[1]) && c[1][0] === 'i32.sub' &&
4321
+ isBaseIR(c[1][1]) &&
4322
+ Array.isArray(c[1][2]) && c[1][2][0] === 'i32.const' && +c[1][2][1] === 8) {
4323
+ n[i] = ['i32.const', st.len]
4324
+ continue
4325
+ }
4326
+ if (c[0] === 'local.get' && c[1] === baseLocal) { n[i] = baseIR(); continue }
4327
+ subLen(c)
4328
+ }
4329
+ }
4330
+ subLen(node)
4331
+ }
4332
+ const walk = (n) => {
4333
+ if (!Array.isArray(n)) return
4334
+ if (n.saArr != null) rewrite(n)
4335
+ for (let i = 1; i < n.length; i++) walk(n[i])
4336
+ }
4337
+ walk(fn)
4338
+ }
4339
+
4340
+ /** `constOps[idx](args)` — data-driven dispatch through a module-const array of
4341
+ * capture-free arrows (operator tables, strategy maps, bytecode handlers). The
4342
+ * generic lowering pays call_indirect's bounds + signature checks per call and
4343
+ * blocks V8 from inlining the tiny bodies. Emit tagged the call_indirect
4344
+ * (`.dvArr` = receiver name); this pass switches on the closure box's OWN
4345
+ * funcIdx (aux bits) via br_table into direct uniform-ABI calls — an AOT
4346
+ * polymorphic inline cache. The untouched original call_indirect is the
4347
+ * default arm, so any runtime divergence (an element overwritten through an
4348
+ * alias, an out-of-range index yielding the UNDEF box) takes the generic path:
4349
+ * semantics are bit-identical regardless of the candidate set. */
4350
+ export function devirtConstFnArrayCalls(fn, cfg) {
4351
+ if (!Array.isArray(fn) || fn[0] !== 'func') return
4352
+ const cfa = ctx.scope.constFnArrays
4353
+ if (!cfa || !cfa.size) return
4354
+ const armInline = !cfg || cfg.inlineDevirtArms !== false
4355
+ let uid = null
4356
+ const newDecls = []
4357
+ // ONE inline-temp counter for the whole function: two dispatch SITES can share
4358
+ // a `uid` (a const-folded receiver spills nothing, leaving uid untouched), so a
4359
+ // per-site counter would mint the same `$__dvi{uid}_0` twice — duplicate local.
4360
+ const inlRef = { next: 0 }
4361
+ const rewrite = (parent, i) => {
4362
+ const node = parent[i]
4363
+ const cands = cfa.get(node.dvArr)
4364
+ if (!cands) return
4365
+ // shape (module/function.js closure.call inline path):
4366
+ // [call_indirect, [type,$ftN], envExpr, [i32.const,n], ...W slots, idxExtract]
4367
+ if (!Array.isArray(node[1]) || node[1][0] !== 'type') return
4368
+ const env = node[2], argc = node[3]
4369
+ if (!Array.isArray(argc) || argc[0] !== 'i32.const') return
4370
+ const idxExtract = node[node.length - 1]
4371
+ const slots = node.slice(4, node.length - 1)
4372
+ const lo = Math.min(...cands.map(c => c.idx)), hi = Math.max(...cands.map(c => c.idx))
4373
+ if (hi - lo > 32) return
4374
+ if (uid === null) uid = nextLocalId(fn, '$__dv')
4375
+ // Spill env + every non-constant slot once; both the arms and the default read the spills.
4376
+ // An arg that is itself `f64.convert_i32_s(E)` spills the i32 E and re-materializes the
4377
+ // convert at each use — the convert then sits SYNTACTICALLY at every consumer, so the
4378
+ // inlined arms' `trunc∘convert` round-trips and `ne(convert, impossible-const)` guards
4379
+ // fold away (watr identities). Behind an f64 spill local the value-flow is invisible.
4380
+ const spills = []
4381
+ const spill = (expr, tag) => {
4382
+ if (Array.isArray(expr) && (expr[0] === 'f64.const' || expr[0] === 'local.get')) return expr
4383
+ if (Array.isArray(expr) && expr[0] === 'f64.convert_i32_s' && Array.isArray(expr[1])) {
4384
+ const name = `$__dv${uid++}${tag}`
4385
+ newDecls.push(['local', name, 'i32'])
4386
+ spills.push(['local.set', name, expr[1]])
4387
+ return ['f64.convert_i32_s', ['local.get', name]]
4388
+ }
4389
+ const name = `$__dv${uid++}${tag}`
4390
+ newDecls.push(['local', name, 'f64'])
4391
+ spills.push(['local.set', name, expr])
4392
+ return ['local.get', name]
4393
+ }
4394
+ const envG = spill(env, 'e')
4395
+ const slotGs = slots.map((sl, k) => spill(sl, 'a' + k))
4396
+ const out = `$__dvo${uid}`, dflt = `$__dvd${uid}`
4397
+ const byOff = new Map(cands.map(c => [c.idx - lo, c]))
4398
+ const labels = Array.from({ length: hi - lo + 1 }, (_, k) => byOff.has(k) ? `$__dv${uid}_${k}` : dflt)
4399
+ // idxExtract reads the env box — after spilling, re-point its env reference:
4400
+ // the extraction shape is wrap(and(shr(reinterpret(ENV))...)); rebuild it on the spill.
4401
+ const extract = ['i32.sub',
4402
+ ['i32.wrap_i64', ['i64.and',
4403
+ ['i64.shr_u', ['i64.reinterpret_f64', envG], ['i64.const', LAYOUT.AUX_SHIFT]],
4404
+ ['i64.const', LAYOUT.AUX_MASK]]],
4405
+ ['i32.const', lo]]
4406
+ let inner = ['br_table', ...labels, dflt, extract]
4407
+ const armOffsets = [...byOff.keys()].sort((a, b) => a - b)
4408
+ inner = ['block', labels[armOffsets[0]], inner]
4409
+ // Tiny straight-line body → inline it straight into the arm: the uniform-ABI
4410
+ // call (env + argc + W padded f64 slots) vanishes and the arm becomes the
4411
+ // operator body itself — the AOT equivalent of the switch a JIT synthesizes
4412
+ // for a hot polymorphic table. The UNFILTERED candidate map: an arm executes
4413
+ // exactly when the original call did, so a straight-line body with a side
4414
+ // effect (closure0's cold string-concat branch inside a polymorphic `+`) is
4415
+ // safe to substitute verbatim — inlinePureCallExpr itself enforces the
4416
+ // straight-line shape and read-only params, and returns null for anything it
4417
+ // can't prove (the call stays). Purity mattered only for value-motion uses.
4418
+ const bodies = ctx.scope.dvArmFns
4419
+ const nodeCount = (n) => !Array.isArray(n) ? 0 : 1 + n.reduce((a, c, k) => a + (k > 0 ? nodeCount(c) : 0), 0)
4420
+ // i32 block-narrow: when the receiver is a facts-qualified STATIC table (the
4421
+ // same never-resized/never-aliased gate as foldStaticConstArrayReads — its
4422
+ // elements are exactly the original arrows, forever) and EVERY candidate body
4423
+ // exits through `f64.convert_i32_s` (a ToInt32'd result), the dispatch value
4424
+ // is int-valued on every path: arms br the raw i32 (their convert stripped),
4425
+ // call-formed arms and the generic call_indirect wrap in i32.trunc_sat_f64_s
4426
+ // (exact on int-valued f64), and ONE convert re-boxes the block. The
4427
+ // loop-carried receiver of `x = ops[i](x, k)` then has a syntactic-convert
4428
+ // def — watr's narrowLocals retypes it and the x-side ToInt32 guard dies the
4429
+ // same way the k-side did (watr intguard).
4430
+ const convertTopped = (fnNode) => {
4431
+ if (!Array.isArray(fnNode)) return false
4432
+ const exits = []
4433
+ let last = null
4434
+ const walkR = (n) => {
4435
+ if (!Array.isArray(n)) return
4436
+ if (n[0] === 'return') { exits.push(n.length === 2 ? n[1] : null); return }
4437
+ for (let k = 1; k < n.length; k++) walkR(n[k])
4438
+ }
4439
+ for (let k = 2; k < fnNode.length; k++) {
4440
+ const s = fnNode[k]
4441
+ if (!Array.isArray(s) || s[0] === 'param' || s[0] === 'result' || s[0] === 'local' || s[0] === 'export' || s[0] === 'type') continue
4442
+ last = s
4443
+ walkR(s)
4444
+ }
4445
+ if (last && last[0] !== 'return') exits.push(last)
4446
+ return exits.length > 0 && exits.every(e => Array.isArray(e) && e[0] === 'f64.convert_i32_s')
4447
+ }
4448
+ const sa = ctx.scope.staticArrs?.get(node.dvArr)
4449
+ const fns = ctx.scope.dvArmFns
4450
+ const narrow = !!(sa && fns && ctx.types.arrResized && ctx.types.nameEscapes &&
4451
+ !ctx.types.arrResized.has(node.dvArr) && !ctx.types.nameEscapes.has(node.dvArr) &&
4452
+ cands.every(c => convertTopped(fns.get(`$${c.name}`))))
4453
+ const intOf = (v) => {
4454
+ if (Array.isArray(v) && v[0] === 'f64.convert_i32_s') return v[1]
4455
+ if (Array.isArray(v) && v[0] === 'block' && Array.isArray(v[1]) && v[1][0] === 'result' && v[1][1] === 'f64') {
4456
+ const vl = v[v.length - 1]
4457
+ if (Array.isArray(vl) && vl[0] === 'f64.convert_i32_s')
4458
+ return ['block', ['result', 'i32'], ...v.slice(2, -1), vl[1]]
4459
+ }
4460
+ return ['i32.trunc_sat_f64_s', v]
4461
+ }
4462
+ for (let k = 0; k < armOffsets.length; k++) {
4463
+ const cand = byOff.get(armOffsets[k])
4464
+ const call = ['call', `$${cand.name}`, envG, argc, ...slotGs]
4465
+ let armVal = null
4466
+ const bodyFn = armInline ? bodies?.get(`$${cand.name}`) : null
4467
+ if (bodyFn && nodeCount(bodyFn) <= 96)
4468
+ armVal = inlinePureCallExpr(call, bodies, inlRef, newDecls, 'f64', '$__dvi')
4469
+ const armExpr = armVal ?? call
4470
+ const arm = ['br', out, narrow ? intOf(armExpr) : armExpr]
4471
+ const nextLabel = k + 1 < armOffsets.length ? labels[armOffsets[k + 1]] : dflt
4472
+ inner = ['block', nextLabel, inner, arm]
4473
+ }
4474
+ // default: the original call_indirect on the spilled operands
4475
+ const generic = ['call_indirect', node[1], envG, argc, ...slotGs, node[node.length - 1]]
4476
+ parent[i] = narrow
4477
+ ? ['f64.convert_i32_s', ['block', out, ['result', 'i32'], ...spills, inner, ['i32.trunc_sat_f64_s', generic]]]
4478
+ : ['block', out, ['result', 'f64'], ...spills, inner, generic]
4479
+ }
4480
+ const walkDV = (n) => {
4481
+ if (!Array.isArray(n)) return
4482
+ for (let i = 1; i < n.length; i++) {
4483
+ const c = n[i]
4484
+ if (!Array.isArray(c)) continue
4485
+ if (c[0] === 'call_indirect' && c.dvArr) { walkDV(c); rewrite(n, i); continue }
4486
+ walkDV(c)
4487
+ }
4488
+ }
4489
+ walkDV(fn)
4490
+ if (newDecls.length) {
4491
+ let at = typeof fn[1] === 'string' ? 2 : 1
4492
+ while (at < fn.length && Array.isArray(fn[at]) &&
4493
+ (fn[at][0] === 'export' || fn[at][0] === 'type' || fn[at][0] === 'param' || fn[at][0] === 'result' || fn[at][0] === 'local')) at++
4494
+ fn.splice(at, 0, ...newDecls)
4495
+ }
4496
+ }
4497
+
4039
4498
  export function sortLocalsByUse(fn, precomputedCounts) {
4040
4499
  if (!Array.isArray(fn) || fn[0] !== 'func') return
4041
4500
  const localIdxs = []
@@ -4048,7 +4507,19 @@ export function sortLocalsByUse(fn, precomputedCounts) {
4048
4507
  if (c[0] === 'local') { localIdxs.push(i); totalDecls++; continue }
4049
4508
  break
4050
4509
  }
4051
- if (localIdxs.length < 2 || totalDecls <= 128) return
4510
+ if (localIdxs.length < 2) return
4511
+ if (totalDecls <= 128) {
4512
+ // Every index fits 1-byte LEB, so ordering is free for the body — group
4513
+ // same-type runs so the binary locals vector squashes to one (n, type)
4514
+ // entry per type instead of a run per interleaving (wasm-opt emits two
4515
+ // groups here; watr's encoder merges only CONSECUTIVE same-type runs).
4516
+ // Stable within a type: original declaration order.
4517
+ const TYPE_ORDER = { i32: 0, i64: 1, f32: 2, f64: 3, v128: 4 }
4518
+ const keyed = localIdxs.map((i, k) => [fn[i], k])
4519
+ keyed.sort((a, b) => ((TYPE_ORDER[a[0][a[0].length - 1]] ?? 9) - (TYPE_ORDER[b[0][b[0].length - 1]] ?? 9)) || (a[1] - b[1]))
4520
+ localIdxs.forEach((i, k) => { fn[i] = keyed[k][0] })
4521
+ return
4522
+ }
4052
4523
  let counts = precomputedCounts
4053
4524
  if (!counts) {
4054
4525
  counts = new Map()
@@ -4061,7 +4532,11 @@ export function sortLocalsByUse(fn, precomputedCounts) {
4061
4532
  for (let i = totalDecls + 2; i < fn.length; i++) visit(fn[i])
4062
4533
  }
4063
4534
  const locals = localIdxs.map(i => fn[i])
4064
- locals.sort((a, b) => (counts.get(b[1]) || 0) - (counts.get(a[1]) || 0))
4535
+ const TYPE_ORDER = { i32: 0, i64: 1, f32: 2, f64: 3, v128: 4 }
4536
+ // Hot-first for 1-byte LEB coverage; equal counts tie-break by type so the
4537
+ // locals vector still squashes into runs where frequency permits.
4538
+ locals.sort((a, b) => ((counts.get(b[1]) || 0) - (counts.get(a[1]) || 0)) ||
4539
+ ((TYPE_ORDER[a[a.length - 1]] ?? 9) - (TYPE_ORDER[b[b.length - 1]] ?? 9)))
4065
4540
  localIdxs.forEach((i, k) => { fn[i] = locals[k] })
4066
4541
  }
4067
4542