jz 0.8.1 → 0.9.1

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 (72) hide show
  1. package/README.md +56 -9
  2. package/bench/README.md +121 -50
  3. package/bench/bench.svg +23 -23
  4. package/cli.js +3 -1
  5. package/dist/interop.js +1 -1
  6. package/dist/jz.js +7541 -6271
  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 +299 -44
  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 +892 -32
  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 +301 -84
  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 +71 -5
  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 +979 -54
  45. package/src/compile/index.js +271 -29
  46. package/src/compile/infer.js +34 -3
  47. package/src/compile/inplace-store.js +329 -0
  48. package/src/compile/narrow.js +543 -15
  49. package/src/compile/peel-stencil.js +5 -0
  50. package/src/compile/plan/index.js +45 -3
  51. package/src/compile/plan/inline.js +8 -1
  52. package/src/compile/plan/literals.js +39 -0
  53. package/src/compile/plan/scope.js +430 -23
  54. package/src/compile/program-facts.js +732 -38
  55. package/src/ctx.js +64 -9
  56. package/src/helper-counters.js +7 -1
  57. package/src/ir.js +113 -5
  58. package/src/kind-traits.js +66 -3
  59. package/src/kind.js +84 -12
  60. package/src/op-policy.js +7 -4
  61. package/src/optimize/index.js +1060 -750
  62. package/src/optimize/recurse.js +2 -2
  63. package/src/optimize/vectorize.js +972 -67
  64. package/src/prepare/index.js +792 -63
  65. package/src/prepare/math-kernel.js +331 -0
  66. package/src/prepare/pre-eval.js +714 -0
  67. package/src/snapshot.js +194 -0
  68. package/src/type.js +1170 -56
  69. package/src/wat/assemble.js +403 -65
  70. package/src/wat/codegen.js +74 -14
  71. package/transform.js +113 -4
  72. 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)
@@ -106,20 +105,17 @@ export const PASS_NAMES = [
106
105
  'unrollRecurrence', // unit-stride DP/scan: scalar-replace arr[j-1]/arr[j] recurrence + ×2 unroll
107
106
  'clampPeel', // edge-clamp stencil: split into clamp-free interior (vectorizes) + edges
108
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
109
109
  'fusedRewrite', // peephole + ptr-helper inline + memarg fold
110
110
  'hoistAddrBase',
111
111
  'boolConvertToSelect', // f64 ± (cond?1:0) → branchless select (kills i32↔f64 domain cross on recurrences)
112
112
  'cseScalarLoad',
113
- 'csePureExpr',
114
113
  'unswitchTypedParamLoop', // Float64Array param loop-unswitch → base-hoisted f64.load/store fast path (vectorizes)
115
- 'dropDeadZeroInit',
116
- 'deadStoreElim',
117
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)
118
116
  'promoteGlobals', // read-only global.get → local for multi-read globals
119
117
  'sortLocalsByUse',
120
118
  'specializeMkptr',
121
- 'specializePtrBase',
122
- 'sortStrPoolByFreq',
123
119
  'internStrings', // slice/substring results probe the static-literal pool: equal-content → canonical bits (bit-eq fast paths)
124
120
  'hoistConstantPool',
125
121
  'sourceInline',
@@ -143,7 +139,7 @@ const LEVEL_PRESETS = Object.freeze({
143
139
  // watr pipeline. `inline` stays off by watr's own default — opt-in only.
144
140
  // boolConvertToSelect off at the default level: it's a latency-for-size trade (adds a
145
141
  // const + op per site) that only pays off on serial recurrences — speed-tier only.
146
- 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' }),
147
143
  // L3/'speed' trades a bit of heap headroom for fewer __arr_grow / __hash growth
148
144
  // cycles. arrayMinCap=16 means `[]` and `new Array()` skip the first two doublings
149
145
  // (0→2→4→8→16); hashSmallInitCap=8 keeps per-object __dyn_props at the same load
@@ -159,15 +155,17 @@ const LEVEL_PRESETS = Object.freeze({
159
155
  // closures). Inline `f64.const` is the minimal lowering: V8 CSEs identical
160
156
  // constants for free. Measured −3% on jessie parse for +14% binary — exactly
161
157
  // the size↔speed trade 'speed' exists to make.
162
- 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 }),
163
159
  // 'size' tightens scalar/unroll caps; 'speed' = level 3. There is no 'balanced'
164
160
  // preset — it was a pure synonym for the default level 2 (omit `optimize` or pass 2).
165
161
  size: Object.freeze({
166
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
167
164
  smallConstForUnroll: false, nestedSmallConstForUnroll: false, vectorizeLaneLocal: false, splitCharScan: false,
168
165
  recursionUnroll: false, // body tripling is a size regression — speed-only
169
166
  unrollRecurrence: false, // ×2 body duplication is a size regression — speed-only
170
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
171
169
 
172
170
  boolConvertToSelect: false, // adds a const + op per site — speed-only latency trade
173
171
  devirtIndirect: false, // guards + duplicated args grow bytes — speed-only trade
@@ -175,6 +173,7 @@ const LEVEL_PRESETS = Object.freeze({
175
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
176
174
  hoistInvariantLoop: false,// LICM: hoists loop-invariants to entry temps — a speed/latency trade whose entry cost outweighs the per-iter saving in bytes
177
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.
178
177
  }),
179
178
  // 'speed' === level 3: full watr (inlining on) + L3 cap/hash tuning, pool off.
180
179
  // reduceUnroll: vectorize reductions with N independent accumulators (ILP/latency
@@ -186,7 +185,7 @@ const LEVEL_PRESETS = Object.freeze({
186
185
  // (The stencil + outer-strip vectorizers are NOT level-gated here: they're bit-exact pure wins
187
186
  // like the base lane vectorizer, so they run whenever it does — default-on at level 2+ via
188
187
  // `cfg.experimentalStencil !== false` at the call site, not a speed-only size/precision trade.)
189
- 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 }),
190
189
  })
191
190
 
192
191
  /**
@@ -590,7 +589,11 @@ const CMP_MANTISSA = new Set([
590
589
  // block readonly-mem-call LICM (else any `s += unknown` — which dispatches via
591
590
  // __is_str_key/__str_concat — would pin every invariant array element in-loop:
592
591
  // the jagged-array `grid[i][j]` deopt).
593
- 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'])
594
597
 
595
598
  // Read-only HEAP-MEMORY calls: like SAFE_OFFSET_CALLS but they read element
596
599
  // storage that a direct f64.store/i32.store in the loop could alias. Safe to
@@ -614,9 +617,14 @@ const READONLY_MEM_CALLS = new Set(['$__typed_idx', '$__str_idx'])
614
617
  // it treats every call as opaque and recomputes the search/transcendental each
615
618
  // iteration. (Math.random is INLINED — it mutates a global PRNG seed, never a
616
619
  // `$math.` call — but exclude it by name defensively; $__str_eq_cold is the cold
617
- // half of __str_eq, equally pure.) byteLen/length stay OUT: $__length is polymorphic
618
- // over MUTABLE arrays (push changes it), so it isn't arg-pure.
619
- const PURE_CALL_I32 = new Set(['$__str_indexof', '$__str_lastindexof', '$__str_eq', '$__str_eq_cold', '$__is_str_key'])
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'])
620
628
  const isPureFnCall = (callee) =>
621
629
  typeof callee === 'string' &&
622
630
  ((callee.startsWith('$math.') && !callee.startsWith('$math.random')) || PURE_CALL_I32.has(callee))
@@ -845,10 +853,13 @@ function loopInvariance(loopNode, { distinctParams, baseParamOf }) {
845
853
  const op = node[0]
846
854
  if (op === 'i32.const' || op === 'i64.const' || op === 'f64.const' || op === 'f32.const') return true
847
855
  if (op === 'local.get') return typeof node[1] === 'string' && (bound.has(node[1]) || !locals.has(node[1]))
848
- // A global is invariant only if not set directly AND no call in the loop —
849
- // any callee may mutate it (no interprocedural effect analysis). (Locals are
850
- // frame-private, so calls can't touch them; only direct local.set matters.)
851
- 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
852
863
  if (op === 'local.tee') {
853
864
  if (typeof node[1] !== 'string') return false
854
865
  // The operand is evaluated BEFORE the tee writes $X, so a `local.get $X` inside
@@ -1242,10 +1253,12 @@ export function hoistInvariantLoop(fn) {
1242
1253
  if (!Array.isArray(node)) return
1243
1254
  if (node[0] === 'loop') return // already processed bottom-up
1244
1255
  if (isHoistable(node) && (refcount.get(node) || 0) <= 1 && (refcount.get(parent) || 0) <= 1) {
1245
- // stableKey: hoistable boxed-pointer subtrees carry i64.const NaN-box prefixes
1246
- // (BigInt) that plain JSON.stringify can't serialize, and it also collapses
1247
- // Infinity/-Infinity/NaN→null & -0→0 — both would dedup distinct invariants.
1248
- 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)
1249
1262
  let arr = sites.get(key); if (!arr) { arr = []; sites.set(key, arr) }
1250
1263
  arr.push({ parent, idx, node })
1251
1264
  return
@@ -1666,508 +1679,6 @@ export function cseScalarLoad(fn) {
1666
1679
  * the surrounding `local.set/tee` handles that)
1667
1680
  * - `br/br_if/br_table/return/unreachable` → NO clear (pure values still valid)
1668
1681
  */
1669
- // Commutative WASM binops — shared by csePureExpr + csePureExprLoop for canonical
1670
- // operand-key ordering (a*b and b*a hash to one entry). OP_TYPE tables stay local:
1671
- // the two passes cover deliberately different op sets.
1672
- 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'])
1673
-
1674
- // Presence of one of these arms csePureExprLoop (it CSEs redundant pure f64/i32
1675
- // arithmetic within the loop; the gate is just "is this loop expensive enough to
1676
- // be worth the pass"). The whole class of transcendental helpers qualifies — each
1677
- // is a multi-instruction polynomial approximation, so a loop built around exp/log/
1678
- // pow deserves the same arithmetic-CSE a trig loop already got. Gating on the class,
1679
- // not a benchmark shape; the CSE itself is bit-exact (pure-subexpr dedup only).
1680
- const LOOP_CSE_EXPENSIVE = new Set([
1681
- '$math.sin', '$math.cos', '$math.tan', '$math.sin_core', '$math.cos_core',
1682
- '$math.exp', '$math.expm1', '$math.log', '$math.log2', '$math.log10', '$math.log1p',
1683
- '$math.pow', '$math.atan', '$math.asin', '$math.acos', '$math.atan2',
1684
- '$math.sinh', '$math.cosh', '$math.tanh', '$math.cbrt', '$math.hypot',
1685
- ])
1686
-
1687
- export function csePureExpr(fn) {
1688
- if (!Array.isArray(fn) || fn[0] !== 'func') return
1689
- const bodyStart = findBodyStart(fn)
1690
- if (bodyStart < 0) return
1691
-
1692
- // High-water mark across ALL surviving `$__pe<N>` locals, not the first gap.
1693
- // A prior csePureExpr run + watr.coalesce can leave non-contiguous numbering
1694
- // (e.g. $__pe0,$__pe1,$__pe5,$__pe20 — coalesce removed the merged ones); picking
1695
- // the first gap (2) then allocating sequentially would collide on $__pe5 / $__pe20.
1696
- let snapId = 0
1697
- for (const n of fn) {
1698
- if (!Array.isArray(n) || n[0] !== 'local' || typeof n[1] !== 'string') continue
1699
- const m = /^\$__pe(\d+)$/.exec(n[1])
1700
- if (m) { const k = +m[1]; if (k >= snapId) snapId = k + 1 }
1701
- }
1702
- const newLocals = []
1703
- let refcount = null // lazily built on the first dedup — most fns never CSE
1704
-
1705
- const TARGET_OPS = new Set([
1706
- 'f64.mul', 'f64.add', 'f64.sub',
1707
- 'i32.mul', 'i32.add', 'i32.sub', 'i32.shl', 'i32.shr_u', 'i32.shr_s', 'i32.and', 'i32.or', 'i32.xor',
1708
- 'i64.mul', 'i64.add', 'i64.sub', 'i64.shl', 'i64.shr_u', 'i64.shr_s', 'i64.and', 'i64.or', 'i64.xor',
1709
- ])
1710
- const OP_TYPE = {
1711
- 'f64.mul': 'f64', 'f64.add': 'f64', 'f64.sub': 'f64',
1712
- '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',
1713
- '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',
1714
- }
1715
-
1716
- // Encode a leaf operand to a stable string key. Returns null if not pure-leaf.
1717
- const leafKey = (n) => {
1718
- if (!Array.isArray(n)) return null
1719
- if (n[0] === 'local.get' && typeof n[1] === 'string') return `L:${n[1]}`
1720
- if (n[0] === 'f64.const' || n[0] === 'i32.const' || n[0] === 'i64.const' || n[0] === 'f32.const') return `C:${n[0]}:${n[1]}`
1721
- return null
1722
- }
1723
-
1724
- // table: key → { snapName | null, anchorParent, anchorIdx, locals: Set<string> }
1725
- const table = new Map()
1726
-
1727
- const invalidateLocal = (X) => {
1728
- for (const [key, entry] of table) {
1729
- if (entry.locals.has(X)) table.delete(key)
1730
- }
1731
- }
1732
-
1733
- const walk = (node, parent, idx) => {
1734
- if (!Array.isArray(node)) return
1735
- const op = node[0]
1736
-
1737
- if (op === 'loop' || op === 'if') {
1738
- const saved = new Map(table)
1739
- table.clear()
1740
- for (let i = 1; i < node.length; i++) walk(node[i], node, i)
1741
- table.clear()
1742
- saved.clear()
1743
- return
1744
- }
1745
-
1746
- // `then`/`else` branches of an `if` are mutually exclusive at runtime —
1747
- // a snap tee cached in the `then` branch is unset when the `else` runs.
1748
- // Isolate per-branch tables so a sibling branch can't reach into another's
1749
- // CSE entries.
1750
- if (op === 'then' || op === 'else') {
1751
- table.clear()
1752
- for (let i = 1; i < node.length; i++) walk(node[i], node, i)
1753
- table.clear()
1754
- return
1755
- }
1756
-
1757
- if (op === 'call' || op === 'call_ref' || op === 'call_indirect') {
1758
- // Calls don't write locals; recurse, no clear.
1759
- for (let i = 1; i < node.length; i++) walk(node[i], node, i)
1760
- return
1761
- }
1762
-
1763
- if (op === 'local.set' || op === 'local.tee') {
1764
- for (let i = 2; i < node.length; i++) walk(node[i], node, i)
1765
- const X = node[1]
1766
- if (typeof X === 'string') invalidateLocal(X)
1767
- return
1768
- }
1769
-
1770
- // Try CSE on (OP A B) where A,B are pure leaves.
1771
- if (TARGET_OPS.has(op) && node.length === 3) {
1772
- const ka = leafKey(node[1])
1773
- const kb = leafKey(node[2])
1774
- if (ka && kb) {
1775
- const key = COMMUTATIVE.has(op) && ka > kb ? `${op}|${kb}|${ka}` : `${op}|${ka}|${kb}`
1776
- const entry = table.get(key)
1777
- if (entry) {
1778
- if (!entry.snapName) {
1779
- // A shared (DAG) anchor breaks the in-place tee: the `%` fast-path emits
1780
- // `a - trunc(a/b)*b` reusing ONE `a` node object, so the anchor and the
1781
- // local.get replacement land on the SAME physical slot and the local.get
1782
- // clobbers the tee — orphaning $__pe (reads 0). Skip when the anchor's
1783
- // parent is shared; watr's DAG-aware CSE still dedupes. Mirrors csePureExprLoop.
1784
- if (((refcount ??= buildRefcount(fn)).get(entry.anchorParent) || 0) > 1) return
1785
- const snapName = `$__pe${snapId++}`
1786
- entry.snapName = snapName
1787
- newLocals.push(['local', snapName, OP_TYPE[op] || 'f64'])
1788
- const orig = entry.anchorParent[entry.anchorIdx]
1789
- entry.anchorParent[entry.anchorIdx] = ['local.tee', snapName, orig]
1790
- }
1791
- parent[idx] = ['local.get', entry.snapName]
1792
- return
1793
- } else {
1794
- const locals = new Set()
1795
- if (ka.startsWith('L:')) locals.add(ka.slice(2))
1796
- if (kb.startsWith('L:')) locals.add(kb.slice(2))
1797
- table.set(key, { snapName: null, anchorParent: parent, anchorIdx: idx, locals })
1798
- return
1799
- }
1800
- }
1801
- // Fall through to recurse.
1802
- }
1803
-
1804
- for (let i = 0; i < node.length; i++) walk(node[i], node, i)
1805
- }
1806
-
1807
- for (let i = bodyStart; i < fn.length; i++) walk(fn[i], fn, i)
1808
-
1809
- if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
1810
- }
1811
-
1812
- /**
1813
- * Post-watr nested CSE for hot fill loops (loop + trig). Reuses `$__pe` locals from
1814
- * the pre-watr leaf pass. Deferred to the `phase === 'post'` run so watr's typed-array
1815
- * inlining is not confused by pre-watr IR rewrites (test/mem.js).
1816
- */
1817
- export function csePureExprLoop(fn) {
1818
- if (!Array.isArray(fn) || fn[0] !== 'func') return
1819
- const bodyStart = findBodyStart(fn)
1820
- if (bodyStart < 0) return
1821
-
1822
- let hasLoop = false
1823
- let hasExpensiveCall = false
1824
- const scanShape = (n) => {
1825
- if (!Array.isArray(n)) return
1826
- if (n[0] === 'loop') hasLoop = true
1827
- if (n[0] === 'call' && LOOP_CSE_EXPENSIVE.has(n[1])) hasExpensiveCall = true
1828
- for (let i = 1; i < n.length; i++) scanShape(n[i])
1829
- }
1830
- for (let i = bodyStart; i < fn.length; i++) scanShape(fn[i])
1831
- if (!hasLoop || !hasExpensiveCall) return
1832
-
1833
- let snapId = nextLocalId(fn, 'pe')
1834
- const newLocals = []
1835
-
1836
- const refcount = buildRefcount(fn)
1837
- const canMutateSite = (parent, node) =>
1838
- (refcount.get(node) || 0) <= 1 && (refcount.get(parent) || 0) <= 1
1839
-
1840
- const PURE_F64_BIN = new Set(['f64.mul', 'f64.add', 'f64.sub', 'f64.div'])
1841
- const PURE_F64_UNARY = new Set(['f64.neg', 'f64.abs', 'f64.convert_i32_s', 'f64.convert_i32_u'])
1842
- 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'])
1843
- const PURE_I32_UNARY = new Set(['i32.eqz', 'i32.clz', 'i32.ctz', 'i32.popcnt'])
1844
- const OP_TYPE = {
1845
- 'f64.mul': 'f64', 'f64.add': 'f64', 'f64.sub': 'f64', 'f64.div': 'f64', 'f64.neg': 'f64', 'f64.abs': 'f64',
1846
- 'f64.convert_i32_s': 'f64', 'f64.convert_i32_u': 'f64',
1847
- 'i32.mul': 'i32', 'i32.add': 'i32', 'i32.sub': 'i32', 'i32.shl': 'i32', 'i32.shr_u': 'i32', 'i32.shr_s': 'i32',
1848
- 'i32.and': 'i32', 'i32.or': 'i32', 'i32.xor': 'i32', 'i32.eqz': 'i32',
1849
- }
1850
-
1851
- const table = new Map()
1852
- const keyLocals = new Set()
1853
- const keyGlobals = new Set()
1854
-
1855
- const invalidateLocal = (X) => {
1856
- for (const [key, entry] of table) {
1857
- if (entry.locals.has(X)) table.delete(key)
1858
- }
1859
- }
1860
-
1861
- const invalidateGlobal = (G) => {
1862
- for (const [key, entry] of table) {
1863
- if (entry.globals.has(G)) table.delete(key)
1864
- }
1865
- }
1866
-
1867
- const pureKeyI32 = (n) => {
1868
- if (!Array.isArray(n)) return null
1869
- const op = n[0]
1870
- if (op === 'local.get' && typeof n[1] === 'string') { keyLocals.add(n[1]); return `L:${n[1]}` }
1871
- if (op === 'global.get' && typeof n[1] === 'string') { keyGlobals.add(n[1]); return `G:${n[1]}` }
1872
- if (op === 'i32.const' || op === 'i64.const') return `C:${op}:${n[1]}`
1873
- if (PURE_I32_UNARY.has(op) && n.length === 2) {
1874
- const k = pureKeyI32(n[1]); return k ? `${op}|${k}` : null
1875
- }
1876
- if (PURE_I32_BIN.has(op) && n.length === 3) {
1877
- const ka = pureKeyI32(n[1]), kb = pureKeyI32(n[2])
1878
- if (!ka || !kb) return null
1879
- return COMMUTATIVE.has(op) && ka > kb ? `${op}|${kb}|${ka}` : `${op}|${ka}|${kb}`
1880
- }
1881
- if (op === 'i32.wrap_i64' && n.length === 2) {
1882
- const k = pureKeyI32(n[1]); return k ? `wrap|${k}` : null
1883
- }
1884
- return null
1885
- }
1886
-
1887
- const pureKeyF64 = (n) => {
1888
- if (!Array.isArray(n)) return null
1889
- const op = n[0]
1890
- if (op === 'local.get' && typeof n[1] === 'string') { keyLocals.add(n[1]); return `L:${n[1]}` }
1891
- if (op === 'global.get' && typeof n[1] === 'string') { keyGlobals.add(n[1]); return `G:${n[1]}` }
1892
- if (op === 'f64.const' || op === 'f32.const') return `C:${op}:${n[1]}`
1893
- if (PURE_F64_UNARY.has(op) && n.length === 2) {
1894
- if (op === 'f64.convert_i32_s' || op === 'f64.convert_i32_u') {
1895
- const k = pureKeyI32(n[1]); return k ? `${op}|${k}` : null
1896
- }
1897
- const k = pureKeyF64(n[1]); return k ? `${op}|${k}` : null
1898
- }
1899
- if (PURE_F64_BIN.has(op) && n.length === 3) {
1900
- const ka = pureKeyF64(n[1]), kb = pureKeyF64(n[2])
1901
- if (!ka || !kb) return null
1902
- return COMMUTATIVE.has(op) && ka > kb ? `${op}|${kb}|${ka}` : `${op}|${ka}|${kb}`
1903
- }
1904
- if (op === 'call' && n[1] === '$__to_num' && n.length === 3) {
1905
- const a = n[2]
1906
- if (Array.isArray(a) && a[0] === 'i64.reinterpret_f64' && a.length === 2) {
1907
- const k = pureKeyF64(a[1]); return k ? `tonum|${k}` : null
1908
- }
1909
- }
1910
- return null
1911
- }
1912
-
1913
- const tryCse = (node, parent, idx) => {
1914
- const op = node[0]
1915
- if (op === 'local.get' || op === 'global.get' || op === 'f64.const' || op === 'f32.const') return
1916
- if (!canMutateSite(parent, node)) return
1917
- keyLocals.clear()
1918
- keyGlobals.clear()
1919
- const key = pureKeyF64(node)
1920
- if (!key) return
1921
- const locals = new Set(keyLocals)
1922
- const globals = new Set(keyGlobals)
1923
- const entry = table.get(key)
1924
- if (entry) {
1925
- if (!entry.snapName) {
1926
- if ((refcount.get(entry.anchorParent) || 0) > 1) return
1927
- const snapName = `$__pe${snapId++}`
1928
- entry.snapName = snapName
1929
- newLocals.push(['local', snapName, OP_TYPE[node[0]] || 'f64'])
1930
- const orig = entry.anchorParent[entry.anchorIdx]
1931
- entry.anchorParent[entry.anchorIdx] = ['local.tee', snapName, orig]
1932
- }
1933
- parent[idx] = ['local.get', entry.snapName]
1934
- } else {
1935
- table.set(key, { snapName: null, anchorParent: parent, anchorIdx: idx, locals, globals })
1936
- }
1937
- }
1938
-
1939
- const walk = (node, parent, idx) => {
1940
- if (!Array.isArray(node)) return
1941
- const op = node[0]
1942
-
1943
- if (op === 'loop') {
1944
- table.clear()
1945
- for (let i = 1; i < node.length; i++) walk(node[i], node, i)
1946
- table.clear()
1947
- return
1948
- }
1949
-
1950
- if (op === 'if') {
1951
- table.clear()
1952
- for (let i = 1; i < node.length; i++) walk(node[i], node, i)
1953
- table.clear()
1954
- return
1955
- }
1956
-
1957
- if (op === 'then' || op === 'else') {
1958
- table.clear()
1959
- for (let i = 1; i < node.length; i++) walk(node[i], node, i)
1960
- table.clear()
1961
- return
1962
- }
1963
-
1964
- if (op === 'call' || op === 'call_ref' || op === 'call_indirect') {
1965
- for (let i = 1; i < node.length; i++) walk(node[i], node, i)
1966
- return
1967
- }
1968
-
1969
- if (op === 'local.set' || op === 'local.tee') {
1970
- for (let i = 2; i < node.length; i++) walk(node[i], node, i)
1971
- const X = node[1]
1972
- if (typeof X === 'string') invalidateLocal(X)
1973
- return
1974
- }
1975
-
1976
- if (op === 'global.set') {
1977
- for (let i = 2; i < node.length; i++) walk(node[i], node, i)
1978
- const G = node[1]
1979
- if (typeof G === 'string') invalidateGlobal(G)
1980
- return
1981
- }
1982
-
1983
- for (let i = 1; i < node.length; i++) {
1984
- if (Array.isArray(node[i])) walk(node[i], node, i)
1985
- }
1986
- tryCse(node, parent, idx)
1987
- }
1988
-
1989
- for (let i = bodyStart; i < fn.length; i++) walk(fn[i], fn, i)
1990
-
1991
- if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
1992
- }
1993
-
1994
- /**
1995
- * Drop redundant zero-initialisation of fresh function-scope locals.
1996
- *
1997
- * WASM zero-initialises every local on entry (0 / 0.0 / null). jz lowers source
1998
- * `let x = 0` to `(local $x …)` + `(local.set $x (<zero const>))` at the top of
1999
- * the function body — the explicit set is a no-op when nothing has touched `$x`
2000
- * yet. `wasm-opt -Oz` elides these; do the same so jz's own output is minimal.
2001
- *
2002
- * Only removes a `(local.set $L (i32|i64|f64|f32.const 0))` when:
2003
- * - `$L` is a non-param local (a param's "default" is the incoming arg, not 0),
2004
- * - it is a *top-level* body statement (never descend into block/loop/if — a
2005
- * nested zero-set inside a loop genuinely re-initialises across iterations),
2006
- * - `$L` has not been referenced by any earlier top-level statement (so the
2007
- * local still holds its entry-time zero at this point),
2008
- * - `$L` is read (`local.get`) somewhere in the function (otherwise leave the
2009
- * store for deadStoreElim and avoid orphaning the `(local $L …)` decl),
2010
- * - the constant is +0 / +0.0 (a `-0.0` f64 set is *not* redundant — locals
2011
- * default to +0.0, which differs in bits from -0.0).
2012
- */
2013
- export function dropDeadZeroInit(fn) {
2014
- if (!Array.isArray(fn) || fn[0] !== 'func') return
2015
- const bodyStart = findBodyStart(fn)
2016
- if (bodyStart < 0) return
2017
-
2018
- const seen = new Set() // params + locals referenced by an earlier stmt
2019
- const reads = new Set() // locals read by `local.get` anywhere
2020
- for (const c of fn) if (Array.isArray(c) && c[0] === 'param' && typeof c[1] === 'string') seen.add(c[1])
2021
-
2022
- const collectGets = (node) => {
2023
- if (!Array.isArray(node)) return
2024
- if (node[0] === 'local.get' && typeof node[1] === 'string') reads.add(node[1])
2025
- for (let i = 1; i < node.length; i++) collectGets(node[i])
2026
- }
2027
- for (let i = bodyStart; i < fn.length; i++) collectGets(fn[i])
2028
-
2029
- const collectRefs = (node) => {
2030
- if (!Array.isArray(node)) return
2031
- const op = node[0]
2032
- if ((op === 'local.get' || op === 'local.set' || op === 'local.tee') && typeof node[1] === 'string') seen.add(node[1])
2033
- for (let i = 1; i < node.length; i++) collectRefs(node[i])
2034
- }
2035
- const isPlusZeroConst = (e) => {
2036
- if (!Array.isArray(e) || e.length !== 2) return false
2037
- if (e[0] !== 'i32.const' && e[0] !== 'i64.const' && e[0] !== 'f64.const' && e[0] !== 'f32.const') return false
2038
- const v = e[1]
2039
- if (typeof v === 'bigint') return v === 0n
2040
- if (typeof v === 'number') return v === 0 && !Object.is(v, -0)
2041
- if (typeof v === 'string') { const t = v.trim(); return t === '0' || t === '0.0' || t === '+0' || t === '+0.0' }
2042
- return false
2043
- }
2044
-
2045
- const drop = []
2046
- for (let i = bodyStart; i < fn.length; i++) {
2047
- const node = fn[i]
2048
- if (!Array.isArray(node)) continue
2049
- if (node[0] === 'local.set' && node.length === 3 && typeof node[1] === 'string' &&
2050
- !seen.has(node[1]) && reads.has(node[1]) && isPlusZeroConst(node[2])) {
2051
- drop.push(i)
2052
- seen.add(node[1])
2053
- continue
2054
- }
2055
- collectRefs(node)
2056
- }
2057
- for (let i = drop.length - 1; i >= 0; i--) fn.splice(drop[i], 1)
2058
- }
2059
-
2060
- /**
2061
- * Dead-store elimination: remove `local.set` / `local.tee` and `drop` of pure
2062
- * expressions whose values are never consumed.
2063
- *
2064
- * Conservative single-block analysis: tracks last-write per local within each
2065
- * straight-line sequence. A write is dead if the same local is written again
2066
- * before any intervening read in the same block. Control-flow boundaries
2067
- * (block, loop, if) reset the table — we don't eliminate across branches.
2068
- *
2069
- * Also removes `drop` of pure expressions (e.g. leftover ptr-type calls).
2070
- */
2071
- export function deadStoreElim(fn) {
2072
- if (!Array.isArray(fn) || fn[0] !== 'func') return
2073
- const bodyStart = findBodyStart(fn)
2074
- if (bodyStart < 0) return
2075
-
2076
- const dead = []
2077
-
2078
- const collectGets = (node, out) => {
2079
- if (!Array.isArray(node)) return
2080
- if (node[0] === 'local.get' && typeof node[1] === 'string') { out.add(node[1]); return }
2081
- for (let i = 1; i < node.length; i++) collectGets(node[i], out)
2082
- }
2083
-
2084
- const isPure = (node) => {
2085
- if (!Array.isArray(node)) return true
2086
- const op = node[0]
2087
- if (typeof op === 'string' && MEMOP.test(op)) return false
2088
- if (op === 'call' || op === 'call_indirect' || op === 'call_ref') return false
2089
- if (op === 'global.get' || op === 'global.set') return false
2090
- if (op === 'local.tee') return false
2091
- if (op === 'memory.size' || op === 'memory.grow') return false
2092
- for (let i = 1; i < node.length; i++) if (!isPure(node[i])) return false
2093
- return true
2094
- }
2095
-
2096
- const scanBlock = (items, start, end) => {
2097
- const lastWrite = new Map() // localName → { parent, idx }
2098
-
2099
- for (let i = start; i < end; i++) {
2100
- const node = items[i]
2101
- if (!Array.isArray(node)) continue
2102
- const op = node[0]
2103
-
2104
- // Reads invalidate pending dead writes. For local.tee/local.set, the RHS reads
2105
- // happen BEFORE the write — so a `local.get $x` inside `(local.tee $x ...)` is a
2106
- // real read of the OLD $x and must invalidate any pending dead-write of $x.
2107
- const reads = new Set()
2108
- collectGets(node, reads)
2109
- for (const name of reads) lastWrite.delete(name)
2110
-
2111
- // Drop of pure expr → dead. Only `(drop EXPR)`: a bare `(drop)` consumes
2112
- // an implicit stack value (e.g. a `try_table` catch payload) — removing it
2113
- // would unbalance the stack.
2114
- if (op === 'drop' && node.length === 2 && isPure(node[1])) {
2115
- dead.push({ parent: items, node, drop: true })
2116
- }
2117
-
2118
- // Local write tracking
2119
- if ((op === 'local.set' || op === 'local.tee') && typeof node[1] === 'string') {
2120
- const prev = lastWrite.get(node[1])
2121
- if (prev) {
2122
- // The store-to-local is dead, but a `local.set` is only *removable*
2123
- // if its RHS is pure — `local.set $x (call f …)` where `f` mutates
2124
- // memory must still run. (A `local.tee` is always safe: removal demotes
2125
- // it to its value expression, so any side effects there are preserved.)
2126
- if (prev.node[0] === 'local.tee' || isPure(prev.node[2])) dead.push(prev)
2127
- }
2128
- lastWrite.set(node[1], { parent: items, node })
2129
- }
2130
-
2131
- // Recurse into nested blocks with fresh state
2132
- if (op === 'block' || op === 'loop') {
2133
- let j = 1
2134
- while (j < node.length && Array.isArray(node[j]) && node[j][0] === 'result') j++
2135
- scanBlock(node, j, node.length)
2136
- } else if (op === 'if') {
2137
- let j = 1
2138
- while (j < node.length && Array.isArray(node[j]) && node[j][0] === 'result') j++
2139
- const condReads = new Set()
2140
- collectGets(node[j], condReads)
2141
- for (const name of condReads) lastWrite.delete(name)
2142
- j++
2143
- for (; j < node.length; j++) {
2144
- const c = node[j]
2145
- if (Array.isArray(c) && (c[0] === 'then' || c[0] === 'else')) scanBlock(c, 1, c.length)
2146
- }
2147
- }
2148
- }
2149
- }
2150
-
2151
- scanBlock(fn, bodyStart, fn.length)
2152
-
2153
- // Removal is IDENTITY-based: entries are pushed at SUPERSEDE time, so
2154
- // same-parent indices are not monotonic (name A's earlier write can be
2155
- // superseded after name B's later one). Index-order splicing then shifts
2156
- // remaining entries onto innocent neighbors — the self-host L2 divergence
2157
- // deleted a typed-literal f64.store exactly this way. Re-locating each
2158
- // captured node at removal time is immune to any ordering or prior splice.
2159
- for (const d of dead) {
2160
- const at = d.parent.indexOf(d.node)
2161
- if (at < 0) continue // already removed (nested duplicate) — nothing to do
2162
- if (!d.drop && d.node[0] === 'local.tee') {
2163
- // tee in statement position: replace with just the value (implicitly dropped)
2164
- d.parent[at] = d.node[2]
2165
- } else {
2166
- d.parent.splice(at, 1)
2167
- }
2168
- }
2169
- }
2170
-
2171
1682
  /**
2172
1683
  * Forward-substitute a single-def / single-use local into its sole use, eliminating the local,
2173
1684
  * its `local.set` and its `local.get`. This is watr's "propagate": jz emits short-lived address/
@@ -2283,6 +1794,146 @@ export function propagateSingleUse(fn) {
2283
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) }
2284
1795
  }
2285
1796
 
1797
+ /**
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):
1800
+ *
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) ...
1804
+ *
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.
1809
+ *
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).
1818
+ */
1819
+ export function foldSetToTee(fn) {
1820
+ if (!Array.isArray(fn) || fn[0] !== 'func') return
1821
+ const bodyStart = findBodyStart(fn)
1822
+ if (bodyStart < 0) return
1823
+
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
1829
+
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
1850
+ return true
1851
+ }
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
1871
+ const op = node[0]
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)
1881
+ }
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
+ }
1920
+ }
1921
+ }
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) }
1929
+ }
1930
+ }
1931
+ }
1932
+ optimizeList(fn, bodyStart)
1933
+
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) }
1935
+ }
1936
+
2286
1937
  /**
2287
1938
  * Module-wide scan for "volatile" globals — those mutated (`global.set`) in any
2288
1939
  * function other than `$__start`. Globals written only in `$__start` are
@@ -2503,6 +2154,194 @@ export function hoistGlobalPtrOffset(fn, stablePtrGlobals, reachableWrites) {
2503
2154
  fn.splice(bodyStart, 0, ...decls, ...snaps)
2504
2155
  }
2505
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
+
2506
2345
  /**
2507
2346
  * Promote read-only globals to locals within each function.
2508
2347
  *
@@ -2756,7 +2595,7 @@ export function specializeMkptr(funcs, addFunc, parseWat) {
2756
2595
  // 4 is the measured break-even: a specialized helper (trampoline / inline i64.const
2757
2596
  // template) costs ~12 B to define and saves ~2–4 B per site, so 4 sites amortize it.
2758
2597
  // Lower (3) net-inflates the watr self-host; 5 leaves 4-use combos on the table. The
2759
- // sibling specializePtrBase threshold (20) is already optimal — its combos cluster far
2598
+ // threshold (20) is already optimal — its combos cluster far
2760
2599
  // above 20 (the ~2 k-site $__strBase relativization) with nothing in the 5–19 band.
2761
2600
  const MIN_USES = 4
2762
2601
 
@@ -2869,174 +2708,6 @@ export function specializeMkptr(funcs, addFunc, parseWat) {
2869
2708
  }
2870
2709
  }
2871
2710
 
2872
- /**
2873
- * Specialize `(call $F (i32.add (global.get $G) (i32.const N)))` → `(call $F_rel_$G (i32.const N))`.
2874
- * Helper bakes `(global.get $G) + i32.add` into its body so call sites drop those 3 B.
2875
- * Targets any single-arg call whose arg is `add(global_base, const)` — in practice: $__mkptr_X_Y_d
2876
- * specializations against $__strBase (watr self-host: ~2193 sites × 3 B ≈ 6.5 KB).
2877
- *
2878
- * @param funcs — flat list of func IR nodes
2879
- * @param addFunc — callback `(watString) => void` to register new helpers
2880
- * @param parseWat — `wat → IR` parser (injected)
2881
- */
2882
- export function specializePtrBase(funcs, addFunc, parseWat) {
2883
- const MIN_USES = 20
2884
-
2885
- // Pass 1: count (targetFunc, baseGlobal) pairs AND record candidate sites for direct
2886
- // rewrite in pass 3 (avoids a second full-AST walk).
2887
- const counts = new Map() // 'F##G' → count
2888
- const sites = [] // { parent, idx, key }
2889
- const walk = (node, parent, idx) => {
2890
- if (!Array.isArray(node)) return
2891
- if (parent && node[0] === 'call' && typeof node[1] === 'string' && node.length === 3) {
2892
- const arg = node[2]
2893
- if (Array.isArray(arg) && arg[0] === 'i32.add' && arg.length === 3 &&
2894
- Array.isArray(arg[1]) && arg[1][0] === 'global.get' && typeof arg[1][1] === 'string' &&
2895
- Array.isArray(arg[2]) && arg[2][0] === 'i32.const') {
2896
- const k = node[1] + '##' + arg[1][1]
2897
- counts.set(k, (counts.get(k) || 0) + 1)
2898
- sites.push({ parent, idx, key: k })
2899
- }
2900
- }
2901
- for (let i = 0; i < node.length; i++) walk(node[i], node, i)
2902
- }
2903
- for (let i = 0; i < funcs.length; i++) walk(funcs[i], null, 0)
2904
-
2905
- const specialized = new Set()
2906
- for (const [k, n] of counts) if (n >= MIN_USES) specialized.add(k)
2907
- if (!specialized.size) return
2908
-
2909
- // Find a target func's result-type by locating its decl among `funcs`.
2910
- const funcByName = new Map()
2911
- for (let i = 0; i < funcs.length; i++) {
2912
- const fn = funcs[i]
2913
- if (Array.isArray(fn) && fn[0] === 'func' && typeof fn[1] === 'string') funcByName.set(fn[1], fn)
2914
- }
2915
- const resultOf = (name) => {
2916
- const fn = funcByName.get(name)
2917
- if (!fn) return 'f64' // defensive; mkptr specializations all return f64
2918
- for (let i = 2; i < fn.length; i++) {
2919
- const c = fn[i]
2920
- if (Array.isArray(c) && c[0] === 'result') return c[1]
2921
- if (Array.isArray(c) && c[0] !== 'param') break
2922
- }
2923
- return 'f64'
2924
- }
2925
-
2926
- const sanit = (g) => g.replace(/^\$/, '').replace(/[^a-zA-Z0-9_]/g, '_')
2927
- const variantFor = (F, G) => `${F}_rel_${sanit(G)}`
2928
-
2929
- // Pass 2: emit helpers.
2930
- for (const fullKey of specialized) {
2931
- const [F, G] = fullKey.split('##')
2932
- const rt = resultOf(F)
2933
- const name = variantFor(F, G)
2934
- addFunc(`(func ${name} (param $o i32) (result ${rt}) (call ${F} (i32.add (global.get ${G}) (local.get $o))))`)
2935
- }
2936
-
2937
- // Pass 3: rewrite recorded sites in reverse (leaf-first since pass 1 was pre-order).
2938
- // Idempotency guard: shared IR subtrees can record the same (parent, idx) twice.
2939
- // The first visit rewrites to a 2-arg call; subsequent visits see a shape that
2940
- // doesn't match the original `call F (i32.add (global.get) (i32.const))` pattern.
2941
- for (let i = sites.length - 1; i >= 0; i--) {
2942
- const { parent, idx, key } = sites[i]
2943
- if (!specialized.has(key)) continue
2944
- const c = parent[idx]
2945
- if (!Array.isArray(c) || c[0] !== 'call' || c.length !== 3) continue
2946
- const arg = c[2]
2947
- if (!Array.isArray(arg) || arg[0] !== 'i32.add' || arg.length !== 3) continue
2948
- if (!Array.isArray(arg[1]) || arg[1][0] !== 'global.get') continue
2949
- if (!Array.isArray(arg[2]) || arg[2][0] !== 'i32.const') continue
2950
- const F = c[1]
2951
- const G = arg[1][1]
2952
- const konst = arg[2]
2953
- const newCall = ['call', variantFor(F, G), konst]
2954
- newCall.type = resultOf(F)
2955
- parent[idx] = newCall
2956
- }
2957
- }
2958
-
2959
- /**
2960
- * Reorder strings in `strPool` so most-referenced strings get low byte offsets.
2961
- * 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.
2962
- * Frequent strings migrating from 3-B to 2-B (or 2-B to 1-B) LEB128 saves ~541 B on watr self-host.
2963
- *
2964
- * Pool layout: `[4-byte-len][data-bytes][4-byte-len][data-bytes]...`. Offsets in refs point PAST the len prefix.
2965
- *
2966
- * @param funcs — flat list of func IR nodes (scanned for refs)
2967
- * @param strPoolRef — `{ pool: string }` holder; pool is rewritten in place
2968
- * @param strDedupMap — optional `Map<string, offset>` to update (kept consistent for later queries)
2969
- */
2970
- export function sortStrPoolByFreq(funcs, strPoolRef, strDedupMap) {
2971
- if (!strPoolRef.pool) return
2972
- // Match both specialized and unspecialized strBase refs.
2973
- const isSpecRef = (n) =>
2974
- Array.isArray(n) && n[0] === 'call' && typeof n[1] === 'string' && n[1].includes('_rel___strBase') &&
2975
- n.length === 3 && Array.isArray(n[2]) && n[2][0] === 'i32.const'
2976
- const isUnspecRef = (n) =>
2977
- Array.isArray(n) && n[0] === 'call' && typeof n[1] === 'string' && n[1].startsWith('$__mkptr_') &&
2978
- n.length === 3 && Array.isArray(n[2]) && n[2][0] === 'i32.add' && n[2].length === 3 &&
2979
- Array.isArray(n[2][1]) && n[2][1][0] === 'global.get' && n[2][1][1] === '$__strBase' &&
2980
- Array.isArray(n[2][2]) && n[2][2][0] === 'i32.const'
2981
- const getOff = (n) => isSpecRef(n) ? (n[2][1] | 0) : isUnspecRef(n) ? (n[2][2][1] | 0) : null
2982
- const setOff = (n, v) => { if (isSpecRef(n)) n[2][1] = v; else if (isUnspecRef(n)) n[2][2][1] = v }
2983
-
2984
- // Single walk: count freq AND record each ref site for direct rewrite.
2985
- const freq = new Map()
2986
- const sites = [] // { node, oldOff } — node is the ref node, mutate offset in place
2987
- const walk = (n) => {
2988
- if (!Array.isArray(n)) return
2989
- const o = getOff(n)
2990
- if (o !== null) { freq.set(o, (freq.get(o) || 0) + 1); sites.push({ node: n, oldOff: o }) }
2991
- for (let i = 0; i < n.length; i++) walk(n[i])
2992
- }
2993
- for (let i = 0; i < funcs.length; i++) walk(funcs[i])
2994
- if (!freq.size) return
2995
-
2996
- // Parse pool structure into entries.
2997
- const pool = strPoolRef.pool
2998
- const entries = []
2999
- let i = 0
3000
- while (i < pool.length) {
3001
- const len = pool.charCodeAt(i) | (pool.charCodeAt(i+1) << 8) | (pool.charCodeAt(i+2) << 16) | (pool.charCodeAt(i+3) << 24)
3002
- const oldOff = i + 4
3003
- entries.push({ oldOff, len, str: pool.substring(oldOff, oldOff + len) })
3004
- i = oldOff + len
3005
- }
3006
-
3007
- // Sort by freq descending; tie-break by length ascending (pack short hot strings into low-offset range).
3008
- entries.sort((a, b) => (freq.get(b.oldOff) || 0) - (freq.get(a.oldOff) || 0) || a.len - b.len)
3009
-
3010
- // Rebuild pool; map old → new offsets. Deduplicate identical strings — keep the
3011
- // first (hottest) occurrence as canonical and point duplicates to it.
3012
- const remap = new Map()
3013
- const canon = new Map() // str content → new offset
3014
- let newPool = ''
3015
- for (const e of entries) {
3016
- const existing = canon.get(e.str)
3017
- if (existing !== undefined) {
3018
- remap.set(e.oldOff, existing)
3019
- continue
3020
- }
3021
- newPool += String.fromCharCode(e.len & 0xFF, (e.len >> 8) & 0xFF, (e.len >> 16) & 0xFF, (e.len >> 24) & 0xFF)
3022
- remap.set(e.oldOff, newPool.length)
3023
- canon.set(e.str, newPool.length)
3024
- newPool += e.str
3025
- }
3026
- strPoolRef.pool = newPool
3027
- if (strDedupMap)
3028
- for (const [str, oldOff] of strDedupMap) {
3029
- const newOff = remap.get(oldOff)
3030
- if (newOff !== undefined) strDedupMap.set(str, newOff)
3031
- }
3032
-
3033
- // Rewrite recorded ref sites directly (no second AST walk).
3034
- for (let i = 0; i < sites.length; i++) {
3035
- const { node, oldOff } = sites[i]
3036
- const newO = remap.get(oldOff)
3037
- if (newO !== undefined) setOff(node, newO)
3038
- }
3039
- }
3040
2711
 
3041
2712
  /**
3042
2713
  * Fold dead string-dispatch blocks when the tested operand is a proven-f64 local.
@@ -3064,6 +2735,38 @@ export function sortStrPoolByFreq(funcs, strPoolRef, strDedupMap) {
3064
2735
  * Called in the 'post' phase of optimizeFunc, before vectorizeLaneLocal, so the
3065
2736
  * cleaned IR is what the vectorizer pattern-matches.
3066
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
+
3067
2770
  export function foldStrDispatchF64(fn) {
3068
2771
  if (!Array.isArray(fn) || fn[0] !== 'func') return
3069
2772
  const bodyStart = findBodyStart(fn)
@@ -3161,11 +2864,25 @@ export function foldStrDispatchF64(fn) {
3161
2864
  // then: ['then', ['call','$__str_concat',...]]
3162
2865
  if (!Array.isArray(thenB) || thenB[0] !== 'then') return node
3163
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
+ }
3164
2881
  if (!Array.isArray(elseB) || elseB[0] !== 'else' || elseB.length !== 2) return node
3165
2882
  const addExpr = elseB[1]
3166
2883
  if (!Array.isArray(addExpr) || addExpr[0] !== 'f64.add' || addExpr.length !== 3) return node
3167
2884
  // The two operands of f64.add must be local.get $B and local.get $C (in either order)
3168
- const [lhsAdd, rhsAdd] = [addExpr[1], addExpr[2]]
2885
+ const [lhsAdd, rhsAdd] = [unwrapGuard(addExpr[1]), unwrapGuard(addExpr[2])]
3169
2886
  const lhsIsB = Array.isArray(lhsAdd) && lhsAdd[0] === 'local.get' && lhsAdd[1] === B
3170
2887
  const rhsIsC = Array.isArray(rhsAdd) && rhsAdd[0] === 'local.get' && rhsAdd[1] === C
3171
2888
  const lhsIsC = Array.isArray(lhsAdd) && lhsAdd[0] === 'local.get' && lhsAdd[1] === C
@@ -3202,6 +2919,8 @@ export function foldStrDispatchF64(fn) {
3202
2919
  */
3203
2920
  export function unswitchTypedParamLoop(fn) {
3204
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))
3205
2924
  const bodyStart = findBodyStart(fn)
3206
2925
  if (bodyStart < 0) return
3207
2926
  const f64Params = new Set()
@@ -3253,7 +2972,25 @@ export function unswitchTypedParamLoop(fn) {
3253
2972
  if (!Array.isArray(incNode) || incNode[0] !== 'local.set' || !Array.isArray(incNode[2]) || incNode[2][0] !== 'i32.add') return
3254
2973
  const incVar = incNode[1], inc = incNode[2]
3255
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
3256
- 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)
3257
2994
  if (body.length < 4) return
3258
2995
 
3259
2996
  // Find the polymorphic-store `if` by scanning (it's followed by a `drop` of its
@@ -3265,8 +3002,12 @@ export function unswitchTypedParamLoop(fn) {
3265
3002
  let thenArm = null, elseArm = null
3266
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 } }
3267
3004
  if (!thenArm || !elseArm) continue
3005
+ const thenStmts = flattenStmts(thenArm, 1)
3268
3006
  let p = null
3269
- 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
+ }
3270
3011
  if (!p || !has(thenArm, (x) => x[0] === 'call' && x[1] === '$__arr_set_idx_ptr')) continue
3271
3012
  // The bare `f64.store(__ptr_offset(o)+i<<3)` is the non-ARRAY fallback. It may be
3272
3013
  // nested under an OBJECT/HASH → __dyn_set guard (emitPolymorphicElementStore's
@@ -3358,10 +3099,13 @@ export function unswitchTypedParamLoop(fn) {
3358
3099
  * @param cfg optional resolved config from resolveOptimize() — when omitted, all on.
3359
3100
  * @param globalTypes optional global name → wasm type map (for promoteGlobals)
3360
3101
  * @param volatileGlobals optional set of callee-mutable globals (see collectVolatileGlobals)
3361
- * @param phase 'pre' (default, pre-watr leaf pass) or 'post' (re-run after watr) —
3362
- * 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.)
3363
3107
  */
3364
- export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre', reachableWrites) {
3108
+ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, reachableWrites) {
3365
3109
  if (cfg && cfg.hoistPtrType === false &&
3366
3110
  cfg.hoistInvariantPtrOffset === false &&
3367
3111
  cfg.hoistInvariantLoop === false &&
@@ -3369,18 +3113,20 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
3369
3113
  cfg.fusedRewrite === false &&
3370
3114
  cfg.hoistAddrBase === false &&
3371
3115
  cfg.cseScalarLoad === false &&
3372
- cfg.csePureExpr === false &&
3373
- cfg.dropDeadZeroInit === false &&
3374
- cfg.deadStoreElim === false &&
3375
3116
  cfg.propagateSingleUse === false &&
3376
3117
  cfg.promoteGlobals === false &&
3377
3118
  cfg.sortLocalsByUse === false &&
3378
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)
3379
3125
  // Recursion-unrolling runs first in 'pre': self-calls are still clean `call`
3380
3126
  // nodes (watr's inliner hasn't reshaped them) and the freshly-inlined body then
3381
3127
  // rides every pass below (LICM, fold, sort). Speed-tier only; 'pre' only (so the
3382
3128
  // post-watr re-optimize doesn't unroll a second time).
3383
- if (cfg && cfg.recursionUnroll === true && phase === 'pre') recursionUnroll(fn)
3129
+ if (cfg && cfg.recursionUnroll === true) recursionUnroll(fn)
3384
3130
  if (!cfg || cfg.hoistPtrType !== false) hoistPtrType(fn)
3385
3131
  if (!cfg || cfg.hoistInvariantPtrOffset !== false) hoistInvariantPtrOffset(fn)
3386
3132
  // Before LICM: the snapped i32 bound is itself a hoistable hard-op subtree, so
@@ -3396,27 +3142,17 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
3396
3142
  if (!cfg || cfg.hoistAddrBase !== false) hoistAddrBase(fn)
3397
3143
  if (!cfg || cfg.hoistInvariantLoop !== false) hoistInvariantLoop(fn)
3398
3144
  if (!cfg || cfg.cseScalarLoad !== false) cseScalarLoad(fn)
3399
- if (!cfg || cfg.csePureExpr !== false) {
3400
- if (cfg && (cfg.watr === true || typeof cfg.watr === 'object') && phase === 'post') csePureExprLoop(fn)
3401
- else csePureExpr(fn)
3402
- }
3403
- if (!cfg || cfg.dropDeadZeroInit !== false) dropDeadZeroInit(fn)
3404
- if (!cfg || cfg.deadStoreElim !== false) deadStoreElim(fn)
3405
3145
  if (!cfg || cfg.promoteGlobals !== false) promoteGlobals(fn, globalTypes, volatileGlobals, reachableWrites)
3406
- // Vectorizer runs PRE-watr unless full watr is enabled (`watr: true`). For full watr,
3407
- // defer to post — full passes (notably `inlineOnce` + the post-inline `propagate`
3408
- // sweep) reshape the IR so much that pre-watr SIMD patterns get scrambled. Light
3409
- // watr (or no watr) leaves the lane locals intact for vectorize to pattern-match,
3410
- // and lets a non-trivial chunk of SIMD survive the propagate+fold pipeline.
3411
3146
  if (cfg && cfg.vectorizeLaneLocal === true) {
3412
- const fullWatr = cfg.watr === true || typeof cfg.watr === 'object'
3413
- 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).
3414
3150
  // Phase 1: fold dead string-dispatch blocks on proven-f64 locals BEFORE
3415
3151
  // the vectorizer pattern-matches — dead __is_str_key calls in $fbm-style
3416
3152
  // functions (param f64 + op f64) block liftPPC from recognizing them as pure.
3417
- if (runVectorizer && (!cfg || cfg.unswitchTypedParamLoop !== false)) unswitchTypedParamLoop(fn)
3418
- if (runVectorizer) foldStrDispatchF64(fn)
3419
- if (runVectorizer) vectorizeLaneLocal(fn, {
3153
+ if (!cfg || cfg.unswitchTypedParamLoop !== false) unswitchTypedParamLoop(fn)
3154
+ foldStrDispatchF64(fn)
3155
+ if (vectorizeLaneLocal(fn, {
3420
3156
  multiAcc: cfg.reduceUnroll === true,
3421
3157
  relaxedFma: cfg.relaxedSimd === true,
3422
3158
  blurMP: cfg.blurMultiPixel !== false,
@@ -3426,13 +3162,14 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
3426
3162
  pureFuncMap: cfg._pureFuncMap || null,
3427
3163
  toneMap: cfg.experimentalToneMap !== false,
3428
3164
  slp: cfg.experimentalSlp !== false, // SLP default-on (testing single-use fix)
3429
- })
3165
+ crPow: cfg.crPow === true,
3166
+ }) && typeof fn[1] === 'string') (cfg._vectorizedFnNames ??= new Set()).add(fn[1])
3430
3167
  // The vectorizer emits `v128.load/store (i32.add base K)` for the unrolled
3431
3168
  // multi-accumulator reduction (a[i],a[i+2],a[i+4]…) and stencil/strided reads.
3432
3169
  // fusedRewrite's memarg fold already ran (above, before vectorize), so fold the
3433
3170
  // freshly-created v128 memargs now — one fewer i32.add per accumulator per
3434
3171
  // iteration, the hot-loop waste audit-fixpoint.mjs flagged on dot/sum.
3435
- if (runVectorizer) foldV128Memargs(fn)
3172
+ foldV128Memargs(fn)
3436
3173
  }
3437
3174
  // Forward-substitute single-use temps — AFTER the vectorizer, never before: it pattern-matches a
3438
3175
  // STRAIGHT-LINE `s += a[i]*2`, and folding an address/index temp out scrambles it (the typed-array
@@ -3440,24 +3177,32 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
3440
3177
  // 'pre' phase (no 'post' re-run), so vectorize already ran above; for full watr the vectorizer is
3441
3178
  // deferred to 'post', so skip 'pre' here to stay after it. (propagateSingleUse itself skips any
3442
3179
  // function the vectorizer already lifted to v128.)
3443
- const fullWatr_psu = !!(cfg && (cfg.watr === true || typeof cfg.watr === 'object'))
3444
- if ((phase === 'post' || !fullWatr_psu) && (!cfg || cfg.propagateSingleUse !== false)) propagateSingleUse(fn)
3445
- // SSA-split loop-private unrolled scratch (post-vectorize: vectorized loops now carry
3446
- // v128 and are skipped) so the LICM below hoists the per-iteration invariants the
3447
- // unroller's name-merging hid rust/LLVM's free-after-unroll register hoist (closes
3448
- // the raytrace per-sphere `c_i` recompute). Bit-exact; re-run LICM to lift the splits.
3449
- if (phase === 'post' && (!cfg || cfg.hoistInvariantLoop !== false)) {
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)) {
3450
3192
  splitLoopPrivateScratch(fn)
3451
- // Iterate LICM for the dependency cascade: c = ox²+… is invariant only once ox is
3452
- // itself hoisted out, which the single-pass hoister can't see in one go. 4 climbs
3453
- // covers the deepest scratch chain; idempotent, so it self-terminates.
3454
3193
  for (let k = 0; k < 4; k++) hoistInvariantLoop(fn)
3455
3194
  }
3456
- // Loop rotation the LAST shape pass (post-watr only, so no later pass reverts
3457
- // it and the v128 loops are already formed for the skip-guard). Speed-tier: it
3458
- // duplicates the loop condition for a fused conditional back-edge (1.35× on the
3459
- // lz/qoi scalar scans see rotateLoops).
3460
- 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)
3461
3206
  // Canonicalize boolean conditions (strip redundant `!= 0` / double-`eqz`) — after
3462
3207
  // rotateLoops so its fused back-edges get cleaned too. Tied to the peephole pass.
3463
3208
  if (!cfg || cfg.fusedRewrite !== false) simplifyBoolContexts(fn)
@@ -3715,13 +3460,23 @@ function fusedRewrite(fn, counts) {
3715
3460
  // Single-textual-def locals → their defining value node, so the trunc_sat range fold (below)
3716
3461
  // can see through the temps inlining introduces when proving an index/packed value fits i32.
3717
3462
  // Multi-def (incl. loop-carried self-referential) locals are excluded: their value is not the
3718
- // one def's, so its range wouldn't bound them. Pure read of the IR value-preserving rewrites
3719
- // during this same walk keep the captured def's RANGE intact, so a lazily-built map stays sound.
3720
- // 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.
3721
3472
  let defVal
3722
3473
  const get = (name) => {
3723
3474
  if (defVal === undefined) {
3724
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
+ }
3725
3480
  const scanDefs = (n) => {
3726
3481
  if (!Array.isArray(n)) return
3727
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]) }
@@ -4201,6 +3956,545 @@ export function treeshake(funcSections, allModuleNodes, opts) {
4201
3956
  * Only the decl order changes; refs by name are unchanged and re-resolved by watr.
4202
3957
  * Params are fixed (their slot defines the call ABI) — only `(local …)` nodes move.
4203
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
+
4204
4498
  export function sortLocalsByUse(fn, precomputedCounts) {
4205
4499
  if (!Array.isArray(fn) || fn[0] !== 'func') return
4206
4500
  const localIdxs = []
@@ -4213,7 +4507,19 @@ export function sortLocalsByUse(fn, precomputedCounts) {
4213
4507
  if (c[0] === 'local') { localIdxs.push(i); totalDecls++; continue }
4214
4508
  break
4215
4509
  }
4216
- 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
+ }
4217
4523
  let counts = precomputedCounts
4218
4524
  if (!counts) {
4219
4525
  counts = new Map()
@@ -4226,7 +4532,11 @@ export function sortLocalsByUse(fn, precomputedCounts) {
4226
4532
  for (let i = totalDecls + 2; i < fn.length; i++) visit(fn[i])
4227
4533
  }
4228
4534
  const locals = localIdxs.map(i => fn[i])
4229
- 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)))
4230
4540
  localIdxs.forEach((i, k) => { fn[i] = locals[k] })
4231
4541
  }
4232
4542