jz 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +315 -318
  2. package/bench/README.md +369 -0
  3. package/bench/bench.svg +102 -0
  4. package/cli.js +104 -30
  5. package/dist/interop.js +1 -0
  6. package/dist/jz.js +6024 -0
  7. package/index.d.ts +126 -0
  8. package/index.js +362 -84
  9. package/interop.js +159 -186
  10. package/jz.svg +5 -0
  11. package/jzify/arguments.js +97 -0
  12. package/jzify/bundler.js +382 -0
  13. package/jzify/classes.js +328 -0
  14. package/jzify/hoist-vars.js +177 -0
  15. package/jzify/index.js +51 -0
  16. package/jzify/names.js +37 -0
  17. package/jzify/switch.js +106 -0
  18. package/jzify/transform.js +349 -0
  19. package/layout.js +184 -0
  20. package/module/array.js +377 -176
  21. package/module/collection.js +639 -145
  22. package/module/console.js +55 -43
  23. package/module/core.js +264 -153
  24. package/module/date.js +15 -3
  25. package/module/function.js +73 -6
  26. package/module/index.js +2 -1
  27. package/module/json.js +226 -61
  28. package/module/math.js +551 -187
  29. package/module/number.js +327 -60
  30. package/module/object.js +474 -184
  31. package/module/regex.js +255 -25
  32. package/module/schema.js +24 -6
  33. package/module/simd.js +117 -0
  34. package/module/string.js +621 -226
  35. package/module/symbol.js +1 -1
  36. package/module/timer.js +9 -14
  37. package/module/typedarray.js +459 -73
  38. package/package.json +58 -14
  39. package/src/abi/index.js +39 -23
  40. package/src/abi/string.js +38 -41
  41. package/src/ast.js +460 -0
  42. package/src/autoload.js +29 -24
  43. package/src/bridge.js +111 -0
  44. package/src/compile/analyze-scans.js +824 -0
  45. package/src/compile/analyze.js +1600 -0
  46. package/src/compile/emit-assign.js +411 -0
  47. package/src/compile/emit.js +3512 -0
  48. package/src/compile/flow-types.js +103 -0
  49. package/src/{compile.js → compile/index.js} +778 -128
  50. package/src/{infer.js → compile/infer.js} +62 -98
  51. package/src/compile/loop-divmod.js +155 -0
  52. package/src/{narrow.js → compile/narrow.js} +409 -106
  53. package/src/compile/peel-stencil.js +261 -0
  54. package/src/compile/plan/advise.js +370 -0
  55. package/src/compile/plan/common.js +150 -0
  56. package/src/compile/plan/index.js +122 -0
  57. package/src/compile/plan/inline.js +682 -0
  58. package/src/compile/plan/literals.js +1199 -0
  59. package/src/compile/plan/loops.js +472 -0
  60. package/src/compile/plan/scope.js +649 -0
  61. package/src/compile/program-facts.js +423 -0
  62. package/src/ctx.js +220 -64
  63. package/src/ir.js +589 -172
  64. package/src/kind-traits.js +132 -0
  65. package/src/kind.js +524 -0
  66. package/src/op-policy.js +57 -0
  67. package/src/{optimize.js → optimize/index.js} +1432 -473
  68. package/src/optimize/vectorize.js +4660 -0
  69. package/src/param-reps.js +65 -0
  70. package/src/parse.js +44 -0
  71. package/src/{prepare.js → prepare/index.js} +649 -205
  72. package/src/prepare/lift-iife.js +149 -0
  73. package/src/reps.js +116 -0
  74. package/src/resolve.js +12 -3
  75. package/src/static.js +208 -0
  76. package/src/type.js +651 -0
  77. package/src/{assemble.js → wat/assemble.js} +275 -55
  78. package/src/wat/optimize.js +3938 -0
  79. package/transform.js +21 -0
  80. package/wasi.js +47 -5
  81. package/src/analyze.js +0 -3818
  82. package/src/emit.js +0 -3040
  83. package/src/jzify.js +0 -1580
  84. package/src/plan.js +0 -2132
  85. package/src/vectorize.js +0 -1088
  86. /package/src/{codegen.js → wat/codegen.js} +0 -0
@@ -27,14 +27,21 @@
27
27
  * @module optimize
28
28
  */
29
29
 
30
- import { LAYOUT, ctx } from './ctx.js'
31
- import { findBodyStart } from './ir.js'
30
+ import { LAYOUT, ctx } from '../ctx.js'
31
+ import { VAL } from '../reps.js'
32
+ import { findBodyStart, buildRefcount, nextLocalId, verifyFn } from '../ir.js'
33
+
34
+ // Debug-mode IR structural check (JZ_DEBUG_INVARIANTS=1). Zero production cost.
35
+ const DBG_IR = typeof process !== 'undefined' && process.env?.JZ_DEBUG_INVARIANTS === '1'
36
+ import { T } from '../ast.js'
32
37
  import { vectorizeLaneLocal } from './vectorize.js'
38
+ import { nanPrefixHex, atomNanHex, STR_INTERN_BIT } from '../../layout.js'
33
39
 
34
40
  const MEMOP = /^[fi](32|64)\.(load|store)(\d+(_[su])?)?$/
35
- const NAN_BITS = '0x' + LAYOUT.NAN_PREFIX_BITS.toString(16).toUpperCase().padStart(16, '0')
36
- const NULL_BITS = '0x' + (LAYOUT.NAN_PREFIX_BITS | (1n << BigInt(LAYOUT.AUX_SHIFT))).toString(16).toUpperCase().padStart(16, '0')
37
- const UNDEF_BITS = '0x' + (LAYOUT.NAN_PREFIX_BITS | (2n << BigInt(LAYOUT.AUX_SHIFT))).toString(16).toUpperCase().padStart(16, '0')
41
+ const NAN_BITS = nanPrefixHex()
42
+ const NULL_BITS = atomNanHex(1)
43
+ const UNDEF_BITS = atomNanHex(2)
44
+ const FALSE_BITS = atomNanHex(4)
38
45
 
39
46
  /**
40
47
  * Optimization passes, partitioned by phase. The `level` presets pick which
@@ -52,21 +59,45 @@ const UNDEF_BITS = '0x' + (LAYOUT.NAN_PREFIX_BITS | (2n << BigInt(LAYOUT.AUX_SHI
52
59
  * 3 — level 2 + larger array/hash initial caps + `hoistConstantPool` off
53
60
  * (inline `f64.const` over mutable globals); trades size for speed.
54
61
  *
55
- * String aliases (the size↔speed tradeoff lives entirely in the unroll/scalar
56
- * knobs; watr is on for all three):
57
- * 'size' — loop/const unroll + lane vectorization off, tight scalar-replacement
58
- * caps. Smallest wasm.
59
- * 'balanced' — the default (= level 2).
60
- * 'speed' — full nested unroll + lane vectorization (= level 3).
62
+ * String presets (the size↔speed tradeoff lives entirely in the unroll/scalar
63
+ * knobs; watr is on for both):
64
+ * 'size' — loop/const unroll + lane vectorization off, tight scalar-replacement
65
+ * caps. Smallest wasm.
66
+ * 'speed' — full nested unroll + lane vectorization (= level 3).
67
+ * The default (level 2) has no string name — omit `optimize` or pass `2`.
68
+ *
69
+ * # Two-layer contract (this file vs src/wat/optimize.js)
70
+ * Both layers walk the same S-expression IR; the boundary is KNOWLEDGE, not
71
+ * representation:
72
+ * - THIS layer owns every pass that needs jz semantics — NaN-box layout
73
+ * (fusedRewrite's rebox folds, hoistPtrType), emit-side proofs stamped on
74
+ * func nodes (cseScalarLoad's cseLoadBases whitelist), loop shapes as emit
75
+ * produces them (narrowLoopBound, hoistInvariantLoop, vectorizeLaneLocal),
76
+ * and ctx-derived module facts (hoistGlobalPtrOffset's typed-global set).
77
+ * - wat/optimize.js owns generic structural rewrites — const folding,
78
+ * copy-prop, branch/DCE/vacuum, dedupe, treeshake, and inlineOnce. One
79
+ * deliberate exception: guardRefine lives there despite NaN-box knowledge,
80
+ * because the dead tag-dispatch shapes it folds only EXIST after that
81
+ * layer's inlining, and its output feeds that layer's fold/branch cleanup
82
+ * within the same fixpoint rounds.
83
+ * Sequencing (driven by index.js): optimizeFunc 'pre' → watOptimize →
84
+ * optimizeFunc 'post'. The 'post' re-run exists because watr-layer inlining
85
+ * re-introduces rebox/unbox pairs at spliced boundaries that only
86
+ * fusedRewrite knows how to fold; csePureExprLoop similarly only pays off
87
+ * over the inlined shape. Passes must stay idempotent — both phases may
88
+ * see the same function.
61
89
  */
62
90
  export const PASS_NAMES = [
63
91
  'watr', // third-party WAT-level CSE/DCE/inlining (heaviest)
92
+ 'devirtIndirect', // call_indirect w/ known closure consts → guarded direct calls (WAT-level, grows bytes)
64
93
  'hoistPtrType',
65
94
  'hoistInvariantPtrOffset',
66
- 'hoistInvariantPtrOffsetLoop',
95
+ 'hoistInvariantLoop', // unified LICM (subsumes the former ToInt32/PtrOffsetLoop/CellLoads hoists)
96
+ 'narrowLoopBound', // f64 loop bound → hoisted i32 (unblocks the lane-vectorizer)
97
+ 'splitCharScan', // charCodeAt scan loops: split at min(N, s.length) → i32 char carrier (plan-level)
98
+ 'hoistGlobalPtrOffset', // stable typed GLOBALS: __ptr_offset resolve → once per function (post-watr, module-level)
67
99
  'fusedRewrite', // peephole + ptr-helper inline + memarg fold
68
100
  'hoistAddrBase',
69
- 'hoistInvariantCellLoads',
70
101
  'cseScalarLoad',
71
102
  'csePureExpr',
72
103
  'dropDeadZeroInit',
@@ -76,6 +107,7 @@ export const PASS_NAMES = [
76
107
  'specializeMkptr',
77
108
  'specializePtrBase',
78
109
  'sortStrPoolByFreq',
110
+ 'internStrings', // slice/substring results probe the static-literal pool: equal-content → canonical bits (bit-eq fast paths)
79
111
  'hoistConstantPool',
80
112
  'sourceInline',
81
113
  'smallConstForUnroll',
@@ -111,16 +143,27 @@ const LEVEL_PRESETS = Object.freeze({
111
143
  // closures). Inline `f64.const` is the minimal lowering: V8 CSEs identical
112
144
  // constants for free. Measured −3% on jessie parse for +14% binary — exactly
113
145
  // the size↔speed trade 'speed' exists to make.
114
- 3: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8 }),
115
- // 'balanced' = level 2; 'size' tightens scalar/unroll caps; 'speed' = level 3.
116
- balanced: Object.freeze({ ...ALL_ON, nestedSmallConstForUnroll: 'auto' }),
146
+ 3: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8, reduceUnroll: true, relaxedSimd: true, inlineFns: true }),
147
+ // 'size' tightens scalar/unroll caps; 'speed' = level 3. There is no 'balanced'
148
+ // preset — it was a pure synonym for the default level 2 (omit `optimize` or pass 2).
117
149
  size: Object.freeze({
118
150
  ...ALL_ON,
119
- smallConstForUnroll: false, nestedSmallConstForUnroll: false, vectorizeLaneLocal: false,
151
+ smallConstForUnroll: false, nestedSmallConstForUnroll: false, vectorizeLaneLocal: false, splitCharScan: false,
152
+ devirtIndirect: false, // guards + duplicated args grow bytes — speed-only trade
153
+ internStrings: false, // the intern index costs ~16 B per eligible literal — speed-only trade
120
154
  scalarTypedLoopUnroll: 4, scalarTypedNestedUnroll: 8, scalarTypedArrayLen: 8,
121
155
  }),
122
156
  // 'speed' === level 3: full watr (inlining on) + L3 cap/hash tuning, pool off.
123
- speed: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8 }),
157
+ // reduceUnroll: vectorize reductions with N independent accumulators (ILP/latency
158
+ // hiding, ~3x on dot/FIR sums) — a size↔speed trade like the pool-off above, so
159
+ // speed-only; level 2 / balanced / size keep the single-accumulator reduce.
160
+ // relaxedSimd: fold f64x2 dot-pairs to f64x2.relaxed_madd (single fused VFMADD,
161
+ // one rounding) — faster + more accurate, but the fused result diverges bit-for-bit
162
+ // from the non-fused JS/native reference (bench `fma` parity class). speed-only.
163
+ // (The stencil + outer-strip vectorizers are NOT level-gated here: they're bit-exact pure wins
164
+ // like the base lane vectorizer, so they run whenever it does — default-on at level 2+ via
165
+ // `cfg.experimentalStencil !== false` at the call site, not a speed-only size/precision trade.)
166
+ speed: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8, reduceUnroll: true, relaxedSimd: true, inlineFns: true }),
124
167
  })
125
168
 
126
169
  /**
@@ -129,7 +172,7 @@ const LEVEL_PRESETS = Object.freeze({
129
172
  * resolveOptimize(undefined | true) → level 2 stable defaults
130
173
  * resolveOptimize(false | 0) → all off
131
174
  * resolveOptimize(1 | 2 | 3) → preset for that level
132
- * resolveOptimize('size' | 'speed' | 'balanced') named alias preset
175
+ * resolveOptimize('size' | 'speed') → named preset ('speed' = level 3)
133
176
  * resolveOptimize({ level: 1, watr: true }) → level 1 base, with watr forced on
134
177
  * resolveOptimize({ level: 'size', vectorizeLaneLocal: true }) → 'size' base, override
135
178
  * resolveOptimize({ hoistAddrBase: false }) → level 2 base, hoistAddrBase off
@@ -137,10 +180,15 @@ const LEVEL_PRESETS = Object.freeze({
137
180
  export function resolveOptimize(opt) {
138
181
  if (opt === false || opt === 0) return { ...ALL_OFF }
139
182
  if (opt === true || opt == null) return { ...LEVEL_PRESETS[2] }
140
- if (typeof opt === 'number' || typeof opt === 'string') return { ...(LEVEL_PRESETS[opt] || LEVEL_PRESETS[2]) }
183
+ // String() the level key: LEVEL_PRESETS has integer-literal keys (0..3), and the
184
+ // self-host kernel's computed member access `obj[numVar]` misreads a numeric VARIABLE
185
+ // index against an object (returns undefined — literal `obj[2]` is fine), so a bare
186
+ // `LEVEL_PRESETS[opt]`/`[baseLevel]` would drop the level-2 default to ALL_ON and
187
+ // (worse, via the partial result) leave `watr` unset — disabling watOptimize.
188
+ if (typeof opt === 'number' || typeof opt === 'string') return { ...(LEVEL_PRESETS[String(opt)] || LEVEL_PRESETS[2]) }
141
189
  if (typeof opt === 'object') {
142
190
  const baseLevel = typeof opt.level === 'number' || typeof opt.level === 'string' ? opt.level : 2
143
- const base = LEVEL_PRESETS[baseLevel] || ALL_ON
191
+ const base = LEVEL_PRESETS[String(baseLevel)] || ALL_ON
144
192
  const out = { ...base }
145
193
  for (const n of PASS_NAMES) {
146
194
  if (!(n in opt)) continue
@@ -148,6 +196,7 @@ export function resolveOptimize(opt) {
148
196
  // Preserve sentinel value `nestedSmallConstForUnroll: 'auto'`
149
197
  // (resolved by a heuristic at emit time).
150
198
  if (n === 'nestedSmallConstForUnroll' && v === 'auto') out[n] = 'auto'
199
+ else if (n === 'watr' && typeof v === 'object') out[n] = v
151
200
  else out[n] = !!v
152
201
  }
153
202
  // Preserve non-pass tuning keys (e.g. plan.js thresholds)
@@ -182,213 +231,76 @@ export function resolveOptimize(opt) {
182
231
  * result is unsafe across realloc, so it isn't hoisted here.)
183
232
  */
184
233
  export function hoistPtrType(fn) {
185
- if (!Array.isArray(fn) || fn[0] !== 'func') return
186
- const bodyStart = findBodyStart(fn)
187
- if (bodyStart < 0) return
188
-
189
- // Per X: array of regions; each region is array of {parent, idx, role: 'tee'|'get'}.
190
- const regions = new Map()
191
- // Currently-open region per X (X → region array). Presence ⇔ alive.
192
- const open = new Map()
193
-
194
- const ensureRegions = (x) => {
195
- let arr = regions.get(x)
196
- if (!arr) { arr = []; regions.set(x, arr) }
197
- return arr
198
- }
199
-
200
- const walk = (node, parent, pi) => {
201
- if (!Array.isArray(node)) return
202
- const op = node[0]
203
-
204
- if (op === 'call' && node[1] === '$__ptr_type' && node.length === 3) {
234
+ return regionTrackCSE(fn, {
235
+ matchSite(node) {
236
+ // (call $__ptr_type (i64.reinterpret_f64 (local.get X))) — key is X, dep is X.
237
+ if (node[0] !== 'call' || node[1] !== '$__ptr_type' || node.length !== 3) return null
205
238
  const arg = node[2]
206
- // Post-i64 migration: arg is (i64.reinterpret_f64 (local.get X)). Peel both wrappers.
207
239
  const inner = (Array.isArray(arg) && arg[0] === 'i64.reinterpret_f64' && arg.length === 2) ? arg[1] : arg
208
- if (Array.isArray(inner) && inner[0] === 'local.get' && typeof inner[1] === 'string') {
209
- const x = inner[1]
210
- let region = open.get(x)
211
- if (!region) {
212
- region = []
213
- ensureRegions(x).push(region)
214
- open.set(x, region)
215
- region.push({ parent, idx: pi, role: 'tee' })
216
- } else {
217
- region.push({ parent, idx: pi, role: 'get' })
218
- }
219
- return // don't recurse — local.get inside is a read, not interesting
220
- }
221
- // Non-trivial arg: walk children normally
222
- for (let i = 2; i < node.length; i++) walk(node[i], node, i)
223
- return
224
- }
225
-
226
- if ((op === 'local.set' || op === 'local.tee') && typeof node[1] === 'string') {
227
- const x = node[1]
228
- // Walk value first — it may contain __ptr_type X, which sees pre-write X.
229
- for (let i = 2; i < node.length; i++) walk(node[i], node, i)
230
- // Then close any open region for X.
231
- open.delete(x)
232
- return
233
- }
234
-
235
- if (op === 'if') {
236
- // Skip optional `(result T)` siblings to find cond / then / else.
237
- let i = 1
238
- while (i < node.length && Array.isArray(node[i]) && node[i][0] === 'result') i++
239
- if (i < node.length) walk(node[i], node, i) // cond
240
- i++
241
- let thenArm = null, elseArm = null
242
- for (; i < node.length; i++) {
243
- const c = node[i]
244
- if (Array.isArray(c)) {
245
- if (c[0] === 'then') thenArm = c
246
- else if (c[0] === 'else') elseArm = c
247
- }
248
- }
249
- const beforeArms = new Map(open)
250
- let afterThen = beforeArms
251
- if (thenArm) {
252
- for (let j = 1; j < thenArm.length; j++) walk(thenArm[j], thenArm, j)
253
- afterThen = new Map(open)
254
- }
255
- open.clear()
256
- for (const [k, v] of beforeArms) open.set(k, v)
257
- let afterElse = beforeArms
258
- if (elseArm) {
259
- for (let j = 1; j < elseArm.length; j++) walk(elseArm[j], elseArm, j)
260
- afterElse = new Map(open)
261
- }
262
- // Merge: alive after if iff alive on BOTH paths with same region ref
263
- // (so the same tee was reachable regardless of which arm executed).
264
- open.clear()
265
- for (const [k, vT] of afterThen) {
266
- if (afterElse.get(k) === vT) open.set(k, vT)
267
- }
268
- return
269
- }
270
-
271
- if (op === 'loop') {
272
- // Conservative: any tee installed in iter N may not have run in iter N+1
273
- // before reaching the same site (back-edge to loop header). Clear before+after.
274
- open.clear()
275
- for (let i = 1; i < node.length; i++) walk(node[i], node, i)
276
- open.clear()
277
- return
278
- }
279
-
280
- // block / func-body / generic: walk children sequentially.
281
- for (let i = 0; i < node.length; i++) walk(node[i], node, i)
282
- }
283
-
284
- for (let i = bodyStart; i < fn.length; i++) walk(fn[i], fn, i)
285
-
286
- if (regions.size === 0) return
287
-
288
- // Commit: for each X with ≥1 usable region, allocate one shared local and rewrite.
289
- // Per-region threshold ≥2 (a singleton would be pure cost).
290
- let hoistId = 0
291
- const locals = []
292
- for (const [, regs] of regions) {
293
- let usable = false
294
- for (const r of regs) if (r.length >= 2) { usable = true; break }
295
- if (!usable) continue
296
- const tLocal = `$__pt${hoistId++}`
297
- locals.push(['local', tLocal, 'i32'])
298
- for (const r of regs) {
299
- if (r.length < 2) continue
300
- for (let i = 0; i < r.length; i++) {
301
- const { parent, idx, role } = r[i]
302
- if (role === 'tee') parent[idx] = ['local.tee', tLocal, parent[idx]]
303
- else parent[idx] = ['local.get', tLocal]
304
- }
305
- }
306
- }
307
- if (locals.length) fn.splice(bodyStart, 0, ...locals)
240
+ if (!Array.isArray(inner) || inner[0] !== 'local.get' || typeof inner[1] !== 'string') return null
241
+ const x = inner[1]
242
+ return { key: x, deps: [x] }
243
+ },
244
+ localPrefix: 'pt',
245
+ localType: 'i32',
246
+ })
308
247
  }
309
248
 
310
- /**
311
- * CSE repeated `(i32.add (local.get $A) (i32.shl (local.get $B) (i32.const K)))`
312
- * the shape jz emits for `arr[idx + k]` typed-array reads after foldMemargOffsets
313
- * absorbs the constant K into `offset=`. The remaining base expression is
314
- * recomputed once per `arr[…]` read; biquad's inner cascade has 9 such reads
315
- * sharing 2 base shapes per iteration. V8's CSE usually catches this, but emitting
316
- * the share explicitly avoids relying on tier-up and helps wasm2c / wasm-opt too.
249
+ /** Region-tracking CSE skeleton shared by hoistPtrType and hoistAddrBase.
250
+ * Walks `fn`, accumulating "regions" sequences of structurally-identical
251
+ * sites along straight-line control flow where the site's value is invariant
252
+ * (no writes to its dependent locals between sites). Per region with ≥2 sites,
253
+ * allocates one `$__<prefix><id>` local and rewrites the first site to
254
+ * `local.tee` and the rest to `local.get`.
317
255
  *
318
- * Same region-tracking discipline as hoistPtrType: open region per key, closed
319
- * by re-assignment to either A or B; loop entry/exit clears all open regions.
256
+ * Control-flow semantics:
257
+ * - `local.set/tee X` closes every region whose dep set includes X.
258
+ * - `if`/`else` arms walk independently from the if-entry open set; after
259
+ * the if, a region is open iff it was open on BOTH arms (same region ref).
260
+ * - `loop` clears open before AND after — back edges may skip the original tee.
261
+ * - `block` / func body — sequential walk.
320
262
  *
321
- * Must run AFTER fusedRewrite relies on shl-distribution + assoc-lift +
322
- * foldMemargOffsets having normalized the base shape.
323
- */
324
- export function hoistAddrBase(fn) {
263
+ * `matchSite(node, parent, pi)` returns `{ key, deps }` for a CSE-able site
264
+ * (key is a stable string; deps lists locals whose writes invalidate this key)
265
+ * or null. Match-arm sites don't recurse into children. */
266
+ function regionTrackCSE(fn, { matchSite, localPrefix, localType }) {
325
267
  if (!Array.isArray(fn) || fn[0] !== 'func') return
326
268
  const bodyStart = findBodyStart(fn)
327
269
  if (bodyStart < 0) return
328
270
 
329
- // Per key (`$A|$B|K`): array of regions; region: array of {parent, idx, role}.
271
+ // Per key: array of regions; each region is array of {parent, idx, role: 'tee'|'get'}.
330
272
  const regions = new Map()
331
- // Open regions keyed by string key; also indexed by local name → set of keys
332
- // depending on it (so `local.set X` can close any region whose key references X).
273
+ // Currently-open region per key. Presence alive.
333
274
  const open = new Map()
275
+ // local-name → keys depending on it (so `local.set X` closes all dependent keys).
334
276
  const localToKeys = new Map()
335
277
 
336
- const ensureRegions = (k) => {
337
- let arr = regions.get(k)
338
- if (!arr) { arr = []; regions.set(k, arr) }
339
- return arr
340
- }
341
- const addLocalDep = (name, key) => {
278
+ const addDep = (name, key) => {
342
279
  let s = localToKeys.get(name)
343
280
  if (!s) { s = new Set(); localToKeys.set(name, s) }
344
281
  s.add(key)
345
282
  }
346
- const closeKey = (key) => {
347
- const r = open.get(key)
348
- if (!r) return
349
- open.delete(key)
350
- // Don't bother removing from localToKeys; stale entries are filtered on close.
351
- }
352
283
  const closeForLocal = (name) => {
353
284
  const s = localToKeys.get(name)
354
285
  if (!s) return
355
- for (const k of s) if (open.has(k)) closeKey(k)
286
+ for (const k of s) open.delete(k)
356
287
  localToKeys.delete(name)
357
288
  }
358
289
 
359
- // Returns { A, B, K } if node matches the pattern, else null.
360
- const matchPattern = (node) => {
361
- if (!Array.isArray(node) || node[0] !== 'i32.add' || node.length !== 3) return null
362
- const a = node[1], b = node[2]
363
- // Two orderings: (add (get A) (shl (get B) (const K))) or (add (shl …) (get A))
364
- let baseGet, shlNode
365
- if (Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' &&
366
- Array.isArray(b) && b[0] === 'i32.shl' && b.length === 3) {
367
- baseGet = a; shlNode = b
368
- } else if (Array.isArray(b) && b[0] === 'local.get' && typeof b[1] === 'string' &&
369
- Array.isArray(a) && a[0] === 'i32.shl' && a.length === 3) {
370
- baseGet = b; shlNode = a
371
- } else return null
372
- const idx = shlNode[1], shamt = shlNode[2]
373
- if (!Array.isArray(idx) || idx[0] !== 'local.get' || typeof idx[1] !== 'string') return null
374
- if (!Array.isArray(shamt) || shamt[0] !== 'i32.const' || typeof shamt[1] !== 'number') return null
375
- return { A: baseGet[1], B: idx[1], K: shamt[1] }
376
- }
377
-
378
290
  const walk = (node, parent, pi) => {
379
291
  if (!Array.isArray(node)) return
380
292
  const op = node[0]
381
293
 
382
- const m = matchPattern(node)
294
+ const m = matchSite(node, parent, pi)
383
295
  if (m) {
384
- const key = `${m.A}|${m.B}|${m.K}`
385
- let region = open.get(key)
296
+ let region = open.get(m.key)
386
297
  if (!region) {
387
298
  region = []
388
- ensureRegions(key).push(region)
389
- open.set(key, region)
390
- addLocalDep(m.A, key)
391
- addLocalDep(m.B, key)
299
+ let regs = regions.get(m.key)
300
+ if (!regs) { regs = []; regions.set(m.key, regs) }
301
+ regs.push(region)
302
+ open.set(m.key, region)
303
+ for (const d of m.deps) addDep(d, m.key)
392
304
  region.push({ parent, idx: pi, role: 'tee' })
393
305
  } else {
394
306
  region.push({ parent, idx: pi, role: 'get' })
@@ -398,7 +310,7 @@ export function hoistAddrBase(fn) {
398
310
 
399
311
  if ((op === 'local.set' || op === 'local.tee') && typeof node[1] === 'string') {
400
312
  const x = node[1]
401
- // Walk value first — it may match patterns referencing pre-write X.
313
+ // Walk value first — it may contain a site referencing pre-write X.
402
314
  for (let i = 2; i < node.length; i++) walk(node[i], node, i)
403
315
  closeForLocal(x)
404
316
  return
@@ -430,6 +342,7 @@ export function hoistAddrBase(fn) {
430
342
  for (let j = 1; j < elseArm.length; j++) walk(elseArm[j], elseArm, j)
431
343
  afterElse = new Map(open)
432
344
  }
345
+ // Merge: alive after if iff alive on BOTH paths with same region ref.
433
346
  open.clear()
434
347
  for (const [k, vT] of afterThen) {
435
348
  if (afterElse.get(k) === vT) open.set(k, vT)
@@ -451,16 +364,15 @@ export function hoistAddrBase(fn) {
451
364
 
452
365
  if (regions.size === 0) return
453
366
 
454
- let hoistId = 0
367
+ // Commit: ≥2 sites per region to be worthwhile (a singleton is pure cost).
368
+ let hoistId = nextLocalId(fn, localPrefix)
455
369
  const locals = []
456
- // Find next free $__abN id by scanning existing locals.
457
- while (fn.some(n => Array.isArray(n) && n[0] === 'local' && n[1] === `$__ab${hoistId}`)) hoistId++
458
370
  for (const [, regs] of regions) {
459
371
  let usable = false
460
372
  for (const r of regs) if (r.length >= 2) { usable = true; break }
461
373
  if (!usable) continue
462
- const tLocal = `$__ab${hoistId++}`
463
- locals.push(['local', tLocal, 'i32'])
374
+ const tLocal = `$__${localPrefix}${hoistId++}`
375
+ locals.push(['local', tLocal, localType])
464
376
  for (const r of regs) {
465
377
  if (r.length < 2) continue
466
378
  for (let i = 0; i < r.length; i++) {
@@ -473,6 +385,44 @@ export function hoistAddrBase(fn) {
473
385
  if (locals.length) fn.splice(bodyStart, 0, ...locals)
474
386
  }
475
387
 
388
+ /**
389
+ * CSE repeated `(i32.add (local.get $A) (i32.shl (local.get $B) (i32.const K)))`
390
+ * — the shape jz emits for `arr[idx + k]` typed-array reads after foldMemargOffsets
391
+ * absorbs the constant K into `offset=`. The remaining base expression is
392
+ * recomputed once per `arr[…]` read; biquad's inner cascade has 9 such reads
393
+ * sharing 2 base shapes per iteration. V8's CSE usually catches this, but emitting
394
+ * the share explicitly avoids relying on tier-up and helps wasm2c / wasm-opt too.
395
+ *
396
+ * Same region-tracking discipline as hoistPtrType: open region per key, closed
397
+ * by re-assignment to either A or B; loop entry/exit clears all open regions.
398
+ *
399
+ * Must run AFTER fusedRewrite — relies on shl-distribution + assoc-lift +
400
+ * foldMemargOffsets having normalized the base shape.
401
+ */
402
+ export function hoistAddrBase(fn) {
403
+ return regionTrackCSE(fn, {
404
+ matchSite(node) {
405
+ if (node[0] !== 'i32.add' || node.length !== 3) return null
406
+ const a = node[1], b = node[2]
407
+ // Two orderings: (add (get A) (shl (get B) (const K))) or (add (shl …) (get A))
408
+ let baseGet, shlNode
409
+ if (Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' &&
410
+ Array.isArray(b) && b[0] === 'i32.shl' && b.length === 3) {
411
+ baseGet = a; shlNode = b
412
+ } else if (Array.isArray(b) && b[0] === 'local.get' && typeof b[1] === 'string' &&
413
+ Array.isArray(a) && a[0] === 'i32.shl' && a.length === 3) {
414
+ baseGet = b; shlNode = a
415
+ } else return null
416
+ const idx = shlNode[1], shamt = shlNode[2]
417
+ if (!Array.isArray(idx) || idx[0] !== 'local.get' || typeof idx[1] !== 'string') return null
418
+ if (!Array.isArray(shamt) || shamt[0] !== 'i32.const' || typeof shamt[1] !== 'number') return null
419
+ return { key: `${baseGet[1]}|${idx[1]}|${shamt[1]}`, deps: [baseGet[1], idx[1]] }
420
+ },
421
+ localPrefix: 'ab',
422
+ localType: 'i32',
423
+ })
424
+ }
425
+
476
426
  /**
477
427
  * Hoist `(call $__ptr_offset (local.get $X))` to a function-entry snapshot
478
428
  * when X is an f64-NaN-boxed parameter that's never reassigned and only ever
@@ -484,7 +434,31 @@ export function hoistAddrBase(fn) {
484
434
  * sound for the duration. The whitelist below is the read-only set
485
435
  * (no mutation possible); any other callee touching X invalidates hoisting.
486
436
  */
487
- const SAFE_OFFSET_CALLS = new Set(['$__ptr_offset', '$__ptr_type', '$__ptr_aux', '$__len'])
437
+ // Read-only i32-returning calls: safe to hoist when operands are invariant,
438
+ // and their presence in a loop must not block other hoists (hasUnsafeCall).
439
+ // __jss_* are wasm:js-string host builtins over IMMUTABLE JS strings — pure by
440
+ // the same argument as the __ptr_* helpers (charCodeAt won't itself hoist —
441
+ // its index varies — but whitelisting it keeps hasUnsafeCall false so the
442
+ // loop-invariant __jss_length in the same loop condition CAN hoist).
443
+ const SAFE_OFFSET_CALLS = new Set(['$__ptr_offset', '$__ptr_type', '$__ptr_aux', '$__len', '$__jss_length', '$__jss_charCodeAt'])
444
+
445
+ // Calls that don't modify EXISTING heap memory: they may allocate (bump the heap
446
+ // pointer) or do tag dispatch, but they never write to an address a hoisted
447
+ // __typed_idx/__str_idx element read would revisit. Their presence must not
448
+ // block readonly-mem-call LICM (else any `s += unknown` — which dispatches via
449
+ // __is_str_key/__str_concat — would pin every invariant array element in-loop:
450
+ // the jagged-array `grid[i][j]` deopt).
451
+ const NON_MUTATING_CALLS = new Set(['$__is_str_key', '$__str_concat', '$__to_num', '$__to_str', '$__str_byteLen'])
452
+
453
+ // Read-only HEAP-MEMORY calls: like SAFE_OFFSET_CALLS but they read element
454
+ // storage that a direct f64.store/i32.store in the loop could alias. Safe to
455
+ // hoist only when the loop has no mutating call AND no direct store at all (we
456
+ // can't do alias analysis at WAT level). __typed_idx/__str_idx read arr[i] /
457
+ // s[i]; plain-array element writes go through calls (caught by hasUnsafeCall),
458
+ // and typed-array writes are direct stores (caught by hasDirectStore) — so the
459
+ // guard covers both. This is what lets LICM hoist `grid[i]` out of a read-only
460
+ // `for(j) { ... grid[i][j] ... }` inner loop (the jagged-array deopt).
461
+ const READONLY_MEM_CALLS = new Set(['$__typed_idx', '$__str_idx'])
488
462
 
489
463
  export function hoistInvariantPtrOffset(fn) {
490
464
  if (!Array.isArray(fn) || fn[0] !== 'func') return
@@ -558,8 +532,7 @@ export function hoistInvariantPtrOffset(fn) {
558
532
 
559
533
  if (sites.size === 0) return
560
534
 
561
- let hoistId = 0
562
- while (fn.some(n => Array.isArray(n) && n[0] === 'local' && n[1] === `$__po${hoistId}`)) hoistId++
535
+ let hoistId = nextLocalId(fn, 'po')
563
536
 
564
537
  const newLocals = []
565
538
  const snaps = []
@@ -577,97 +550,310 @@ export function hoistInvariantPtrOffset(fn) {
577
550
  if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals, ...snaps)
578
551
  }
579
552
 
553
+
554
+ // Non-trapping, side-effect-free ops whose result is a pure function of their
555
+ // operands. Hoisting one to the pre-header is sound iff its operands are loop-
556
+ // invariant: same value every iteration, no traps, no memory/global effects.
557
+ // DELIBERATELY EXCLUDES trapping ops — i32/i64 div_s/u & rem_s/u (trap on 0),
558
+ // non-saturating trunc_f64 (trap on overflow/NaN) — because hoisting a trap to
559
+ // the pre-header would fire it even when the loop runs zero times. Loads and
560
+ // calls are NOT here; they are admitted by `pureGiven` only under the loop's
561
+ // effect-summary barriers (cell loads with no aliasing store/call; the read-only
562
+ // __ptr_* call whitelist with no other call).
563
+ // Boxed-capture cells are `freshLocal`-generated, so the name carries the T
564
+ // (U+E000) prefix: `$<T>cell_<var>`. Built from the constant — a hand-typed
565
+ // `'$cell_'` literal silently omits the invisible T and never matches.
566
+ const CELL_PREFIX = '$' + T + 'cell_'
567
+
568
+ // Ops V8's wasm tier (TurboFan) will NOT hoist out of a loop itself: saturating
569
+ // f64→int truncation and `select` are not LICM-eligible there, memory loads are
570
+ // blocked by conservative aliasing, and calls are opaque. These are the ONLY
571
+ // things worth hoisting — V8 already does general arithmetic LICM, and hoisting
572
+ // pure arithmetic ourselves only bloats the body and breaks the lane-vectorizer's
573
+ // straight-line pattern match. So a subtree is hoisted only if it contains one.
574
+ const HARD_OPS = new Set([
575
+ 'i64.trunc_sat_f64_s', 'i64.trunc_sat_f64_u', 'i32.trunc_sat_f64_s', 'i32.trunc_sat_f64_u',
576
+ 'select', 'f64.load', 'i32.load', 'call',
577
+ ])
578
+ const hasHardOp = (n) => Array.isArray(n) && (HARD_OPS.has(n[0]) || n.some((c, i) => i > 0 && hasHardOp(c)))
579
+
580
+ // The inline typed-array base decode `(i32.wrap_i64 (i64.and (i64.reinterpret_f64
581
+ // (local|global X)) 0xFFFFFFFF))` — what `typedBase` emits for a NaN-boxed pointer.
582
+ // V8's wasm tier does NOT reliably LICM this i64 reinterpret chain, and it carries no
583
+ // HARD_OP, so without this it stays per-element inside the loop. It is the typed-read
584
+ // equivalent of the `__ptr_offset` call (a HARD_OP) that hoistGlobalPtrOffset hoists at
585
+ // function scope; admitting it here also covers a pointer reassigned ELSEWHERE in the
586
+ // function (the ping-pong double-buffer `a = b` in wireworld / any CA), where the base
587
+ // is invariant within each loop but not function-wide.
588
+ const isPtrBaseDecode = (n) =>
589
+ Array.isArray(n) && n[0] === 'i32.wrap_i64' && n.length === 2 &&
590
+ Array.isArray(n[1]) && n[1][0] === 'i64.and' && n[1].length === 3 &&
591
+ Array.isArray(n[1][2]) && n[1][2][0] === 'i64.const' &&
592
+ (typeof n[1][2][1] === 'string' ? Number(n[1][2][1]) : n[1][2][1]) === LAYOUT.OFFSET_MASK &&
593
+ Array.isArray(n[1][1]) && n[1][1][0] === 'i64.reinterpret_f64' && n[1][1].length === 2 &&
594
+ Array.isArray(n[1][1][1]) && (n[1][1][1][0] === 'local.get' || n[1][1][1][0] === 'global.get')
595
+
596
+ const PURE_LICM_OPS = new Set([
597
+ 'f64.add', 'f64.sub', 'f64.mul', 'f64.div', 'f64.neg', 'f64.abs', 'f64.sqrt',
598
+ 'f64.min', 'f64.max', 'f64.ceil', 'f64.floor', 'f64.trunc', 'f64.nearest', 'f64.copysign',
599
+ 'i32.add', 'i32.sub', 'i32.mul', 'i32.and', 'i32.or', 'i32.xor',
600
+ 'i32.shl', 'i32.shr_s', 'i32.shr_u', 'i32.rotl', 'i32.rotr', 'i32.clz', 'i32.ctz', 'i32.popcnt', 'i32.eqz',
601
+ 'i64.add', 'i64.sub', 'i64.mul', 'i64.and', 'i64.or', 'i64.xor',
602
+ 'i64.shl', 'i64.shr_s', 'i64.shr_u', 'i64.rotl', 'i64.rotr', 'i64.eqz',
603
+ 'f64.eq', 'f64.ne', 'f64.lt', 'f64.gt', 'f64.le', 'f64.ge',
604
+ 'i32.eq', 'i32.ne', 'i32.lt_s', 'i32.lt_u', 'i32.gt_s', 'i32.gt_u', 'i32.le_s', 'i32.le_u', 'i32.ge_s', 'i32.ge_u',
605
+ 'i64.eq', 'i64.ne', 'i64.lt_s', 'i64.lt_u', 'i64.gt_s', 'i64.gt_u', 'i64.le_s', 'i64.le_u', 'i64.ge_s', 'i64.ge_u',
606
+ 'f64.convert_i32_s', 'f64.convert_i32_u', 'f64.convert_i64_s', 'f64.convert_i64_u',
607
+ 'i32.trunc_sat_f64_s', 'i32.trunc_sat_f64_u', 'i64.trunc_sat_f64_s', 'i64.trunc_sat_f64_u',
608
+ 'i32.wrap_i64', 'i64.extend_i32_s', 'i64.extend_i32_u',
609
+ 'f64.reinterpret_i64', 'i64.reinterpret_f64', 'f32.reinterpret_i32', 'i32.reinterpret_f32',
610
+ 'f64.promote_f32', 'f32.demote_f64', 'select',
611
+ ])
612
+
580
613
  /**
581
- * Per-loop hoist of `(call $__ptr_offset (local.get X))` for locals X that are
582
- * loop-invariant (no `local.set/local.tee` to X anywhere in the loop body, no
583
- * non-safe call inside the loop). Mirrors `hoistInvariantCellLoads`.
614
+ * Unified loop-invariant code motion. One principle replaces the three former
615
+ * pattern hoists (ToInt32 / __ptr_offset / cell-load): a MAXIMAL pure subtree
616
+ * whose every free input is loop-invariant is computed once before the loop, in
617
+ * a fresh snap local.
584
618
  *
585
- * The function-level pass above only handles params (single hoist at func
586
- * entry). This pass handles assigned locals that go invariant within a loop
587
- * scope the aos pattern: `let rows = []; for (...) rows.push(...)` — outside
588
- * the build loop, `rows` is no longer reassigned, so `__ptr_offset(rows)` is
589
- * loop-invariant in the consumer loop.
619
+ * Invariance/purity (`pureGiven`) is closed over PURE_LICM_OPS plus two memory-
620
+ * touching leaves admitted only under the loop's effect summary (`collectMutations`):
621
+ * - (f64.load (local.get $cell_X)) iff no f64.store to $cell_X and no call in loop
622
+ * - (call $__ptr_offset|__ptr_type|__ptr_aux|__len …) iff no non-whitelisted call
623
+ * exactly the old per-pass barriers, generalized. A subtree may also WRITE a
624
+ * local via (local.tee P E) iff P is private to the subtree (occurs nowhere else
625
+ * in the loop); this hoists the guarded-ToInt32 form
626
+ * (select (i32.wrap_i64 (i64.trunc_sat_f64_s (local.tee P E))) 0 (f64.ne (get P) G))
627
+ * as a unit — which the old leaf-only matcher could not (it needed a bare local).
590
628
  *
591
- * Inside-out: inner loops first. After an inner-loop hoist, the outer loop now
592
- * contains a `(local.set $__pol (call $__ptr_offset ...))` whose call gets
593
- * hoisted again at the outer level, climbing the snap up to the outermost loop
594
- * where X is invariant. Cleanup of the chained `local.set` movs is handled by
595
- * watr CSE/DCE.
629
+ * Bottom-up (inner loops first progressive climbing), refcount-guarded against
630
+ * watr's shared CSE subtrees, snaps spliced before the loop, decls at bodyStart.
631
+ * Idempotent: re-running sees only `(local.get $__liN)` and finds nothing to do.
596
632
  */
597
- export function hoistInvariantPtrOffsetLoop(fn) {
633
+ export function hoistInvariantLoop(fn) {
598
634
  if (!Array.isArray(fn) || fn[0] !== 'func') return
599
635
  const bodyStart = findBodyStart(fn)
600
636
  if (bodyStart < 0) return
601
637
 
602
- let snapId = 0
603
- while (fn.some(n => Array.isArray(n) && n[0] === 'local' && n[1] === `$__pol${snapId}`)) snapId++
604
- const newLocals = []
638
+ // Cheap early-out: no loop ⇒ nothing to hoist (skip the buildRefcount walk).
639
+ let hasLoop = false
640
+ const scanLoop = (n) => {
641
+ if (!Array.isArray(n) || hasLoop) return
642
+ if (n[0] === 'loop') { hasLoop = true; return }
643
+ for (let i = 1; i < n.length && !hasLoop; i++) scanLoop(n[i])
644
+ }
645
+ for (let i = bodyStart; i < fn.length && !hasLoop; i++) scanLoop(fn[i])
646
+ if (!hasLoop) return
605
647
 
606
- // Refcount across the whole fn to skip shared subtrees (watr CSE may leave them).
607
- const refcount = new Map()
608
- const countRefs = (node) => {
609
- if (!Array.isArray(node)) return
610
- const n = (refcount.get(node) || 0) + 1
611
- refcount.set(node, n)
612
- if (n > 1) return
613
- for (let i = 0; i < node.length; i++) countRefs(node[i])
648
+ // Result wasm type of a hoistable node (for the snap local decl). null ⇒ can't
649
+ // type it don't hoist. Param/local types come from the func header.
650
+ const localTypes = new Map()
651
+ for (let i = 2; i < bodyStart; i++) {
652
+ const c = fn[i]
653
+ if (Array.isArray(c) && (c[0] === 'param' || c[0] === 'local') && typeof c[1] === 'string') localTypes.set(c[1], c[2])
654
+ }
655
+ const resultType = (node) => {
656
+ if (!Array.isArray(node)) return null
657
+ const op = node[0]
658
+ if (op === 'select') return resultType(node[1])
659
+ if (op === 'if') {
660
+ // (if (result T) cond (then ...) (else ...)) — type is the result clause.
661
+ for (let i = 1; i < node.length; i++) {
662
+ const c = node[i]
663
+ if (Array.isArray(c) && c[0] === 'result') return c[1]
664
+ }
665
+ return null
666
+ }
667
+ if (op === 'block') {
668
+ for (let i = 1; i < node.length; i++) {
669
+ const c = node[i]
670
+ if (Array.isArray(c) && c[0] === 'result') return c[1]
671
+ }
672
+ return null
673
+ }
674
+ if (op === 'call') {
675
+ // SAFE_OFFSET_CALLS all return i32; READONLY_MEM_CALLS return f64 (NaN-boxed element)
676
+ if (SAFE_OFFSET_CALLS.has(node[1])) return 'i32'
677
+ if (READONLY_MEM_CALLS.has(node[1])) return 'f64'
678
+ return null
679
+ }
680
+ if (op === 'local.get' || op === 'local.tee') return localTypes.get(node[1]) ?? null
681
+ const dot = op.indexOf('.')
682
+ if (dot < 0) return null
683
+ // Comparisons and `eqz` yield i32 regardless of operand type (i64.eq, f64.lt,
684
+ // i64.eqz, …) — so the operand-type prefix would mistype them. Catch first.
685
+ const m = op.slice(dot + 1)
686
+ if (m === 'eqz' || /^(eq|ne|lt|gt|le|ge)(_[su])?$/.test(m)) return 'i32'
687
+ const p = op.slice(0, dot)
688
+ if (p === 'i32' || p === 'i64' || p === 'f64' || p === 'f32') return p
689
+ return null
614
690
  }
615
- countRefs(fn)
616
691
 
617
- const processLoop = (loopNode) => {
618
- // Recurse into inner loops first (bottom-up).
619
- for (let i = 1; i < loopNode.length; i++) {
620
- const child = loopNode[i]
621
- if (!Array.isArray(child)) continue
622
- processNode(child, loopNode, i)
623
- }
624
-
625
- // Scan loop body (including nested loops) for writes to any local and for
626
- // any non-safe call. Non-safe calls could realloc/move the underlying
627
- // array storage and invalidate cached offsets.
628
- const writes = new Set()
629
- let unsafe = false
692
+ // Collision-proof snap ids: skip EVERY existing $__li id, not just start at the
693
+ // lowest free one. watr can renumber/coalesce locals between the pre- and
694
+ // post-watr optimize phases, leaving a non-contiguous $__li set; a lowest-free +
695
+ // sequential-increment scheme would then re-issue an in-use id (Duplicate local).
696
+ const usedLi = new Set()
697
+ const scanLi = (n) => {
698
+ if (!Array.isArray(n)) return
699
+ if (n[0] === 'local' && typeof n[1] === 'string' && n[1].startsWith('$__li')) {
700
+ const t = n[1].slice(5); if (/^\d+$/.test(t)) usedLi.add(+t)
701
+ }
702
+ for (let i = 0; i < n.length; i++) scanLi(n[i])
703
+ }
704
+ scanLi(fn)
705
+ let snapCounter = 0
706
+ const freshSnap = () => { while (usedLi.has(snapCounter)) snapCounter++; const id = snapCounter++; usedLi.add(id); return `$__li${id}` }
707
+ const newLocals = []
708
+ const refcount = buildRefcount(fn)
709
+
710
+ const processLoop = (loopNode, nested) => {
711
+ // Inner loops first (bottom-up) — an inner hoist creates a local.get the
712
+ // outer level can hoist further. Children run in a nested context.
713
+ for (let i = 1; i < loopNode.length; i++)
714
+ if (Array.isArray(loopNode[i])) processNode(loopNode[i], loopNode, i, true)
715
+
716
+ // The loop's effect summary (scans nested loops too — conservative).
717
+ const locals = new Set(), globals = new Set(), storedCells = new Set()
718
+ let hasUnsafeCall = false, hasAnyCall = false, hasDirectStore = false, hasV128 = false
630
719
  const scan = (node) => {
631
720
  if (!Array.isArray(node)) return
632
721
  const op = node[0]
633
- if (op === 'local.set' || op === 'local.tee') {
634
- if (typeof node[1] === 'string') writes.add(node[1])
635
- for (let i = 2; i < node.length; i++) scan(node[i])
636
- return
722
+ // A vectorized loop (lane/v128 ops) is already register-tight and hand-tuned;
723
+ // extra scalar hoisting there only adds spill pressure — keep it conservative.
724
+ if (op.startsWith('v128.') || /^[if]\d+x\d+\./.test(op)) hasV128 = true
725
+ if (op === 'local.set' || op === 'local.tee') { if (typeof node[1] === 'string') locals.add(node[1]); for (let i = 2; i < node.length; i++) scan(node[i]); return }
726
+ if (op === 'global.set') { if (typeof node[1] === 'string') globals.add(node[1]); for (let i = 2; i < node.length; i++) scan(node[i]); return }
727
+ if (op === 'call') { hasAnyCall = true; if (!SAFE_OFFSET_CALLS.has(node[1]) && !READONLY_MEM_CALLS.has(node[1]) && !NON_MUTATING_CALLS.has(node[1])) hasUnsafeCall = true; for (let i = 2; i < node.length; i++) scan(node[i]); return }
728
+ if (op === 'call_ref' || op === 'call_indirect') { hasAnyCall = hasUnsafeCall = true; for (let i = 1; i < node.length; i++) scan(node[i]); return }
729
+ if ((op === 'f64.store' || op === 'i32.store') && node.length >= 3) {
730
+ hasDirectStore = true
731
+ const a = node[1]
732
+ if (Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' && a[1].startsWith(CELL_PREFIX)) storedCells.add(a[1])
733
+ }
734
+ for (let i = 1; i < node.length; i++) scan(node[i])
735
+ }
736
+ for (let i = 1; i < loopNode.length; i++) scan(loopNode[i])
737
+
738
+ // Per-subtree local-occurrence counts and write-sets, memoized bottom-up —
739
+ // the tee-privacy check queries them for EVERY candidate node, and the old
740
+ // per-query re-walk (countIn/gatherBound) was quadratic on watr-scale loop
741
+ // bodies (the single largest compile-time hotspot, ~200ms/compile). All
742
+ // queries happen during `collect`, before any splice mutates the loop, so
743
+ // the memo cannot go stale; it is dropped with this processLoop frame.
744
+ const countsMemo = new Map() // node → Map(local → occurrences in subtree)
745
+ const writesMemoL = new Map() // node → Set(locals written in subtree)
746
+ const EMPTY_COUNTS = new Map(), EMPTY_WRITES = new Set()
747
+ const countsOf = (node) => {
748
+ if (!Array.isArray(node)) return EMPTY_COUNTS
749
+ let m = countsMemo.get(node)
750
+ if (m) return m
751
+ m = new Map()
752
+ const op = node[0]
753
+ if ((op === 'local.get' || op === 'local.set' || op === 'local.tee') && typeof node[1] === 'string')
754
+ m.set(node[1], 1)
755
+ for (let i = 1; i < node.length; i++)
756
+ for (const [k, v] of countsOf(node[i])) m.set(k, (m.get(k) || 0) + v)
757
+ countsMemo.set(node, m)
758
+ return m
759
+ }
760
+ const writesIn = (node) => {
761
+ if (!Array.isArray(node)) return EMPTY_WRITES
762
+ let s = writesMemoL.get(node)
763
+ if (s) return s
764
+ s = new Set()
765
+ if ((node[0] === 'local.set' || node[0] === 'local.tee') && typeof node[1] === 'string') s.add(node[1])
766
+ for (let i = 1; i < node.length; i++) for (const w of writesIn(node[i])) s.add(w)
767
+ writesMemoL.set(node, s)
768
+ return s
769
+ }
770
+ // Whole-loop counts (the former countLocals walk) — one memoized query.
771
+ const localCount = new Map()
772
+ for (let i = 1; i < loopNode.length; i++)
773
+ for (const [k, v] of countsOf(loopNode[i])) localCount.set(k, (localCount.get(k) || 0) + v)
774
+
775
+ // Pure & invariant given `bound` (locals written *within* the candidate, hence
776
+ // local to it). A read of a bound local is OK (its in-subtree value is the
777
+ // teed invariant). A free read must be unwritten by the loop.
778
+ const pureGiven = (node, bound) => {
779
+ if (!Array.isArray(node)) return true // bare operand string/number
780
+ const op = node[0]
781
+ if (op === 'i32.const' || op === 'i64.const' || op === 'f64.const' || op === 'f32.const') return true
782
+ if (op === 'local.get') return typeof node[1] === 'string' && (bound.has(node[1]) || !locals.has(node[1]))
783
+ // A global is invariant only if not set directly AND no call in the loop —
784
+ // any callee may mutate it (no interprocedural effect analysis). (Locals are
785
+ // frame-private, so calls can't touch them; only direct local.set matters.)
786
+ if (op === 'global.get') return typeof node[1] === 'string' && !globals.has(node[1]) && !hasAnyCall
787
+ if (op === 'local.tee') {
788
+ if (typeof node[1] !== 'string') return false
789
+ // The operand is evaluated BEFORE the tee writes $X, so a `local.get $X` inside
790
+ // it reads the loop-carried (previous-iteration) value, not the teed one. Drop
791
+ // $X from `bound` for the operand: `local.tee $X (… $X …)` is a loop recurrence
792
+ // (X = f(X) — e.g. the `while ((nn = nn >>> 1))` induction), NOT invariant.
793
+ const inner = bound.has(node[1]) ? new Set([...bound].filter(b => b !== node[1])) : bound
794
+ return pureGiven(node[2], inner)
795
+ }
796
+ if ((op === 'f64.load' || op === 'i32.load') && node.length === 2) {
797
+ const a = node[1]
798
+ return Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' && a[1].startsWith(CELL_PREFIX)
799
+ && !hasAnyCall && !storedCells.has(a[1]) && (bound.has(a[1]) || !locals.has(a[1]))
637
800
  }
638
801
  if (op === 'call') {
639
- if (!SAFE_OFFSET_CALLS.has(node[1])) unsafe = true
640
- for (let i = 2; i < node.length; i++) scan(node[i])
641
- return
802
+ if (SAFE_OFFSET_CALLS.has(node[1]))
803
+ return !hasUnsafeCall && node.slice(2).every(c => pureGiven(c, bound))
804
+ // Read-only heap reads: additionally require no direct store (alias-safe).
805
+ if (READONLY_MEM_CALLS.has(node[1]))
806
+ return !hasUnsafeCall && !hasDirectStore && node.slice(2).every(c => pureGiven(c, bound))
807
+ return false
642
808
  }
643
- if (op === 'call_ref' || op === 'call_indirect') {
644
- unsafe = true
645
- for (let i = 1; i < node.length; i++) scan(node[i])
646
- return
809
+ // A value-producing `if` whose condition and both arms are pure is itself
810
+ // pure — the tag-dispatch idiom `(if (result f64) tag-check (then read-A)
811
+ // (else read-B))` that wraps __typed_idx/__str_idx element access.
812
+ if (op === 'if') {
813
+ for (let i = 1; i < node.length; i++) {
814
+ const c = node[i]
815
+ if (!Array.isArray(c)) continue
816
+ if (c[0] === 'result') continue
817
+ if (c[0] === 'then' || c[0] === 'else') { if (!c.slice(1).every(x => pureGiven(x, bound))) return false }
818
+ else if (!pureGiven(c, bound)) return false // the condition
819
+ }
820
+ return true
647
821
  }
648
- for (let i = 1; i < node.length; i++) scan(node[i])
822
+ if (PURE_LICM_OPS.has(op)) return node.slice(1).every(c => pureGiven(c, bound))
823
+ return false
649
824
  }
650
- for (let i = 1; i < loopNode.length; i++) scan(loopNode[i])
651
- if (unsafe) return []
652
825
 
653
- // Collect call sites for invariant locals. Skip nested loops (already
654
- // processed) but recurse through everything else.
655
- const sites = new Map() // localName → [{ parent, idx }]
826
+ const isHoistable = (node) => {
827
+ if (!Array.isArray(node)) return false
828
+ const op = node[0]
829
+ // Skip trivial leaves: hoisting a bare get/const buys nothing.
830
+ if (op === 'local.get' || op === 'global.get' || op === 'i32.const' || op === 'i64.const' || op === 'f64.const' || op === 'f32.const') return false
831
+ const bound = writesIn(node)
832
+ // Every local the subtree writes must be private to it (no other use in the
833
+ // loop) — else moving the write to the pre-header changes another reader.
834
+ for (const b of bound) if (localCount.get(b) !== countsOf(node).get(b)) return false
835
+ // Top-level loops: only hoist what V8's wasm tier won't — a HARD_OP or the
836
+ // inline typed-array base decode — and leave plain pure arithmetic to V8's own
837
+ // LICM (which handles single-level loops well). NESTED (inner) loops are
838
+ // different: V8's wasm tier under-hoists invariants out of them (a nested
839
+ // rasterizer/convolution recomputes triangle/row-invariant subexpressions every
840
+ // iteration), so hoist any pure-invariant subtree there. Soundness is unchanged —
841
+ // `pureGiven` already proves the subtree is loop-invariant and side-effect-free.
842
+ return ((nested && !hasV128) || hasHardOp(node) || isPtrBaseDecode(node)) && pureGiven(node, bound)
843
+ }
844
+
845
+ // Maximal extraction: take the largest hoistable subtree; don't descend into
846
+ // it. Dedup structurally so a repeated invariant expr shares one snap local.
847
+ const sites = new Map() // structural key → [{ parent, idx, node }]
656
848
  const collect = (node, parent, idx) => {
657
849
  if (!Array.isArray(node)) return
658
- const op = node[0]
659
- if (op === 'loop') return
660
- if (op === 'call' && node[1] === '$__ptr_offset' && node.length === 3) {
661
- const a = node[2]
662
- const inner = (Array.isArray(a) && a[0] === 'i64.reinterpret_f64' && a.length === 2) ? a[1] : a
663
- if (Array.isArray(inner) && inner[0] === 'local.get' && typeof inner[1] === 'string'
664
- && !writes.has(inner[1])
665
- && (refcount.get(node) || 0) <= 1
666
- && (refcount.get(parent) || 0) <= 1) {
667
- let arr = sites.get(inner[1])
668
- if (!arr) { arr = []; sites.set(inner[1], arr) }
669
- arr.push({ parent, idx })
670
- }
850
+ if (node[0] === 'loop') return // already processed bottom-up
851
+ if (isHoistable(node) && (refcount.get(node) || 0) <= 1 && (refcount.get(parent) || 0) <= 1) {
852
+ // BigInt-safe: hoistable boxed-pointer subtrees carry i64.const NaN-box prefixes
853
+ // (BigInt values) that plain JSON.stringify can't serialize — see ast.bigintSafeKey.
854
+ const key = JSON.stringify(node, (_k, v) => typeof v === 'bigint' ? `${v}n` : v)
855
+ let arr = sites.get(key); if (!arr) { arr = []; sites.set(key, arr) }
856
+ arr.push({ parent, idx, node })
671
857
  return
672
858
  }
673
859
  for (let i = 0; i < node.length; i++) collect(node[i], node, i)
@@ -675,190 +861,203 @@ export function hoistInvariantPtrOffsetLoop(fn) {
675
861
  for (let i = 1; i < loopNode.length; i++) collect(loopNode[i], loopNode, i)
676
862
 
677
863
  const snaps = []
678
- for (const [X, arr] of sites) {
679
- if (arr.length < 1) continue
680
- const snapName = `$__pol${snapId++}`
681
- newLocals.push(['local', snapName, 'i32'])
682
- snaps.push(['local.set', snapName, ['call', '$__ptr_offset', ['i64.reinterpret_f64', ['local.get', X]]]])
683
- for (const { parent, idx } of arr) {
684
- parent[idx] = ['local.get', snapName]
685
- }
864
+ for (const [, arr] of sites) {
865
+ const type = resultType(arr[0].node)
866
+ if (type == null) continue
867
+ const snapName = freshSnap()
868
+ newLocals.push(['local', snapName, type])
869
+ snaps.push(['local.set', snapName, arr[0].node]) // reuse first node verbatim
870
+ for (const { parent, idx } of arr) parent[idx] = ['local.get', snapName]
686
871
  }
687
872
  return snaps
688
873
  }
689
874
 
690
- const processNode = (node, parent, idx) => {
875
+ const processNode = (node, parent, idx, nested = false) => {
691
876
  if (!Array.isArray(node)) return
692
- const op = node[0]
693
- if (op === 'loop') {
694
- const snaps = processLoop(node)
877
+ if (node[0] === 'loop') {
878
+ const snaps = processLoop(node, nested)
695
879
  if (snaps.length) parent.splice(idx, 0, ...snaps)
696
880
  return
697
881
  }
698
- for (let i = 0; i < node.length; i++) processNode(node[i], node, i)
882
+ for (let i = 0; i < node.length; i++) processNode(node[i], node, i, nested)
699
883
  }
700
884
 
701
- for (let i = bodyStart; i < fn.length; i++) processNode(fn[i], fn, i)
702
-
885
+ for (let i = bodyStart; i < fn.length; i++) processNode(fn[i], fn, i, false)
703
886
  if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
704
887
  }
705
888
 
706
889
  /**
707
- * Hoist loop-invariant boxed-cell reads out of loops.
890
+ * Narrow an f64 loop bound to i32. `for (let i = 0; i < n; i++)` with an f64
891
+ * param `n` emits `(f64.lt (f64.convert_i32_s $i) (local.get $n))` — an f64
892
+ * convert+compare every iteration that ALSO blocks the lane-vectorizer (it
893
+ * requires an i32-governed trip count). The naive-DSP export shape
894
+ * `(ptr, n) => { for (i = 0; i < n; i++) … }` therefore never vectorized
895
+ * without a hand-written `n|0`. This pass is that annotation, as a proof.
708
896
  *
709
- * Boxed-capture cells (`$cell_X`, allocated by the closure-capture pass) are
710
- * private to the enclosing function no other code path can write to that
711
- * memory. So if a loop body contains `(f64.load (local.get $cell_X))` reads
712
- * and *no* `(f64.store (local.get $cell_X) …)` writes, the load is loop-
713
- * invariant and can be hoisted to a snapshot local set just before the loop.
897
+ * When $i is a proven-non-negative i32 counter and $n is loop-invariant:
898
+ * convert_i32_s(i) < n ⟺ i < trunc_sat(ceil(n)) for all i 0
899
+ * - fractional n rounds up (i < 5.5 i < 6); integral n exact
900
+ * - NaN: ceil→NaN, trunc_sat→0 ⇒ `i < 0` false — matches the false f64 compare
901
+ * (THIS case is why i ≥ 0 must be proven: a negative i would flip it true)
902
+ * - n ≤ −2³¹ saturates to INT32_MIN ⇒ always false — matches
903
+ * - n ≥ 2³¹ saturates to INT32_MAX ⇒ terminates after 2³¹−1 iterations where
904
+ * the original wrapped $i negative and spun forever — the only divergence,
905
+ * pathological in both versions (a JS double counter would keep counting).
906
+ * Non-negativity proof: $i is a non-param i32 local whose EVERY write in the
907
+ * function (counters get re-zeroed between loops) is a non-negative i32.const
908
+ * or `$i + positive-const`. Wrap-around past 2³¹ needs 2³¹ agreeing iterations
909
+ * first, so trajectories are identical in every non-pathological program.
714
910
  *
715
- * Necessary because V8's wasm tier doesn't perform LICM across f64.load:
716
- * memory may alias with f64.stores in the loop body, and even though we
717
- * know the cell can't alias with array stores, the engine has to assume it
718
- * can. Hand-hoisting unblocks register-keeping of the captured value.
719
- *
720
- * Inside-out per-loop processing — inner loops handled first, so reads
721
- * already replaced by snap-locals don't appear as cell reads at outer levels.
911
+ * Snap `(local.set $__lbK (i32.trunc_sat_f64_s (f64.ceil (local.get $n))))`
912
+ * goes in the loop pre-header (re-snapped per outer iteration when nested
913
+ * trunc_sat/ceil are total, safe even for zero-trip loops); the compare becomes
914
+ * `(i32.lt_s $i $__lbK)` the exact shape the lane-vectorizer matches.
915
+ * Bottom-up, refcount-guarded, idempotent (rewritten conds no longer match).
722
916
  */
723
- export function hoistInvariantCellLoads(fn) {
917
+ export function narrowLoopBound(fn) {
724
918
  if (!Array.isArray(fn) || fn[0] !== 'func') return
725
919
  const bodyStart = findBodyStart(fn)
726
920
  if (bodyStart < 0) return
727
921
 
728
- let snapId = 0
729
- while (fn.some(n => Array.isArray(n) && n[0] === 'local' && n[1] === `$__sc${snapId}`)) snapId++
922
+ // Cheap early-out: no loop ⇒ nothing to narrow.
923
+ let hasLoop = false
924
+ const scanLoop = (n) => {
925
+ if (!Array.isArray(n) || hasLoop) return
926
+ if (n[0] === 'loop') { hasLoop = true; return }
927
+ for (let i = 1; i < n.length && !hasLoop; i++) scanLoop(n[i])
928
+ }
929
+ for (let i = bodyStart; i < fn.length && !hasLoop; i++) scanLoop(fn[i])
930
+ if (!hasLoop) return
931
+
932
+ // Header types. Params are excluded as counters: their init is caller-supplied,
933
+ // so non-negativity is unprovable.
934
+ const localTypes = new Map(), params = new Set()
935
+ for (let i = 2; i < bodyStart; i++) {
936
+ const c = fn[i]
937
+ if (!Array.isArray(c) || typeof c[1] !== 'string') continue
938
+ if (c[0] === 'param') params.add(c[1])
939
+ if (c[0] === 'param' || c[0] === 'local') localTypes.set(c[1], c[2])
940
+ }
941
+
942
+ // Every write per local across the WHOLE function — not just in-loop: a counter
943
+ // reused by a later loop is re-zeroed between them, and a negative write
944
+ // anywhere voids the proof.
945
+ const writes = new Map()
946
+ const collectWrites = (n) => {
947
+ if (!Array.isArray(n)) return
948
+ if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') {
949
+ let arr = writes.get(n[1]); if (!arr) writes.set(n[1], arr = [])
950
+ arr.push(n[2])
951
+ }
952
+ for (let i = 1; i < n.length; i++) collectWrites(n[i])
953
+ }
954
+ for (let i = bodyStart; i < fn.length; i++) collectWrites(fn[i])
955
+
956
+ const constVal = (n) => Array.isArray(n) && n[0] === 'i32.const' ? Number(n[1]) : NaN
957
+ const nonNegCounter = (name) => {
958
+ if (params.has(name) || localTypes.get(name) !== 'i32') return false
959
+ const ws = writes.get(name)
960
+ if (!ws) return true // never written ⇒ stays at default 0
961
+ return ws.every(v => {
962
+ if (!Array.isArray(v)) return false
963
+ if (v[0] === 'i32.const') return Number(v[1]) >= 0
964
+ if (v[0] !== 'i32.add') return false
965
+ if (Array.isArray(v[1]) && v[1][0] === 'local.get' && v[1][1] === name) return constVal(v[2]) > 0
966
+ if (Array.isArray(v[2]) && v[2][0] === 'local.get' && v[2][1] === name) return constVal(v[1]) > 0
967
+ return false
968
+ })
969
+ }
970
+
971
+ // Collision-proof snap ids (same scheme as hoistInvariantLoop's $__li).
972
+ const usedLb = new Set()
973
+ const scanLb = (n) => {
974
+ if (!Array.isArray(n)) return
975
+ if (n[0] === 'local' && typeof n[1] === 'string' && n[1].startsWith('$__lb')) {
976
+ const t = n[1].slice(5); if (/^\d+$/.test(t)) usedLb.add(+t)
977
+ }
978
+ for (let i = 0; i < n.length; i++) scanLb(n[i])
979
+ }
980
+ scanLb(fn)
981
+ let lbCounter = 0
982
+ const freshLb = () => { while (usedLb.has(lbCounter)) lbCounter++; const id = lbCounter++; usedLb.add(id); return `$__lb${id}` }
730
983
  const newLocals = []
984
+ const refcount = buildRefcount(fn)
731
985
 
732
- // Build refcount of array nodes: how many positions in `fn` reference each
733
- // array. Earlier passes (fusedRewrite, hoistAddrBase) introduce shared
734
- // subtrees; mutating `parent[idx]` for a shared parent would also affect
735
- // references outside the current loop. Sites whose immediate parent has
736
- // refcount > 1 are skipped.
737
- const refcount = new Map()
738
- const countRefs = (node) => {
739
- if (!Array.isArray(node)) return
740
- const n = (refcount.get(node) || 0) + 1
741
- refcount.set(node, n)
742
- if (n > 1) return // already counted children below
743
- for (let i = 0; i < node.length; i++) countRefs(node[i])
986
+ // `(f64.lt (f64.convert_i32_s (local.get $i)) (local.get $n))` or mirrored
987
+ // `(f64.gt (local.get $n) (f64.convert_i32_s (local.get $i)))`.
988
+ const match = (n) => {
989
+ const conv = n[0] === 'f64.lt' ? n[1] : n[0] === 'f64.gt' ? n[2] : null
990
+ const bnd = n[0] === 'f64.lt' ? n[2] : n[0] === 'f64.gt' ? n[1] : null
991
+ if (!Array.isArray(conv) || conv[0] !== 'f64.convert_i32_s') return null
992
+ const ig = conv[1]
993
+ if (!Array.isArray(ig) || ig[0] !== 'local.get' || typeof ig[1] !== 'string') return null
994
+ if (!Array.isArray(bnd) || bnd[0] !== 'local.get' || typeof bnd[1] !== 'string') return null
995
+ return { ctr: ig[1], bound: bnd[1] }
744
996
  }
745
- countRefs(fn)
746
997
 
747
- // Process one loop node: find cell_X reads, check no writes, hoist.
748
- // Returns { snapDecls } — list of (local.set $snap (f64.load (local.get $cell_X))) IR
749
- // to emit before the loop in its parent.
750
998
  const processLoop = (loopNode) => {
751
- // Recurse first — inner loops handled bottom-up. Each inner-loop processor
752
- // returns a list of pre-loop snap decls; we splice them just before the inner
753
- // loop within this loop's body.
754
- for (let i = 1; i < loopNode.length; i++) {
755
- const child = loopNode[i]
756
- if (!Array.isArray(child)) continue
757
- processNode(child, loopNode, i)
758
- }
759
-
760
- // Scan this loop's body for cell reads & writes (excluding nested loop bodies,
761
- // since their reads were already hoisted at their level).
762
- const reads = new Map() // cellName → array of {parent, idx}
763
- const writes = new Set()
764
- let hasCall = false
765
- const scanWrites = (node) => {
766
- if (!Array.isArray(node)) return
767
- const op = node[0]
768
- if (op === 'call' || op === 'call_ref' || op === 'call_indirect') {
769
- hasCall = true
770
- }
771
- // DESCEND into nested loops here — we need to know if any nested-loop
772
- // body writes to cell_X (which would invalidate hoisting THIS loop's reads).
773
- if (op === 'f64.store' && node.length >= 3) {
774
- const addr = node[1]
775
- if (Array.isArray(addr) && addr[0] === 'local.get' && typeof addr[1] === 'string'
776
- && addr[1].startsWith('$cell_')) {
777
- writes.add(addr[1])
778
- }
779
- // Continue scan into value expr
780
- for (let i = 2; i < node.length; i++) scanWrites(node[i])
781
- return
782
- }
783
- if (op === 'f64.load' && node.length === 2) {
784
- const addr = node[1]
785
- if (Array.isArray(addr) && addr[0] === 'local.get' && typeof addr[1] === 'string'
786
- && addr[1].startsWith('$cell_')) {
787
- // Defer; we'll handle in a parent-tracking second pass.
788
- }
789
- }
790
- for (let i = 1; i < node.length; i++) scanWrites(node[i])
999
+ // Inner loops first — their sites belong to their own pre-header (the bound
1000
+ // may be written by THIS loop between inner runs).
1001
+ for (let i = 1; i < loopNode.length; i++)
1002
+ if (Array.isArray(loopNode[i])) processNode(loopNode[i], loopNode, i)
1003
+
1004
+ // Locals written anywhere in this loop (incl. nested) — bound invariance.
1005
+ const written = new Set()
1006
+ const scanW = (n) => {
1007
+ if (!Array.isArray(n)) return
1008
+ if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') written.add(n[1])
1009
+ for (let i = 1; i < n.length; i++) scanW(n[i])
791
1010
  }
792
- for (let i = 1; i < loopNode.length; i++) scanWrites(loopNode[i])
793
- // Sound bailout: a call inside the loop could mutate a captured cell
794
- // via a closure we can't see. Without escape analysis we can't prove
795
- // non-aliasing, so we skip hoisting from any loop containing calls.
796
- if (hasCall) return []
1011
+ for (let i = 1; i < loopNode.length; i++) scanW(loopNode[i])
797
1012
 
798
- // Parent-tracking pass to collect read sites.
799
- const collect = (node, parent, idx) => {
1013
+ const sites = []
1014
+ const collect = (node) => {
800
1015
  if (!Array.isArray(node)) return
801
- const op = node[0]
802
- if (op === 'loop') return
803
- if (op === 'f64.load' && node.length === 2) {
804
- const addr = node[1]
805
- if (Array.isArray(addr) && addr[0] === 'local.get' && typeof addr[1] === 'string'
806
- && addr[1].startsWith('$cell_')) {
807
- const cell = addr[1]
808
- // Skip if the f64.load node or its immediate parent is shared
809
- // (refcount>1): mutating parent[idx] would propagate the rewrite to
810
- // references outside this loop.
811
- if (!writes.has(cell)
812
- && (refcount.get(node) || 0) <= 1
813
- && (refcount.get(parent) || 0) <= 1) {
814
- let arr = reads.get(cell)
815
- if (!arr) { arr = []; reads.set(cell, arr) }
816
- arr.push({ parent, idx })
817
- }
818
- return
819
- }
820
- }
821
- for (let i = 0; i < node.length; i++) collect(node[i], node, i)
1016
+ if (node[0] === 'loop') return // already processed bottom-up
1017
+ const m = match(node)
1018
+ if (m && (refcount.get(node) || 0) <= 1
1019
+ && localTypes.get(m.bound) === 'f64' && !written.has(m.bound)
1020
+ && nonNegCounter(m.ctr)) { sites.push({ node, m }); return }
1021
+ for (let i = 1; i < node.length; i++) collect(node[i])
822
1022
  }
823
- for (let i = 1; i < loopNode.length; i++) collect(loopNode[i], loopNode, i)
1023
+ for (let i = 1; i < loopNode.length; i++) collect(loopNode[i])
824
1024
 
825
- // For each cell with reads but no writes (and confirmed no calls above),
826
- // hoist a snap. Single-read hoist is fine semantically: the cell address
827
- // doesn't change once allocated, and snap is loaded unconditionally before
828
- // the loop, then the body uses the snap local.
1025
+ const snapFor = new Map() // bound name snap local (one per distinct bound)
829
1026
  const snaps = []
830
- for (const [cell, sites] of reads) {
831
- if (sites.length < 1) continue
832
- const snapName = `$__sc${snapId++}`
833
- newLocals.push(['local', snapName, 'f64'])
834
- snaps.push(['local.set', snapName, ['f64.load', ['local.get', cell]]])
835
- for (const { parent, idx } of sites) {
836
- parent[idx] = ['local.get', snapName]
1027
+ for (const { node, m } of sites) {
1028
+ let snap = snapFor.get(m.bound)
1029
+ if (!snap) {
1030
+ snap = freshLb()
1031
+ snapFor.set(m.bound, snap)
1032
+ newLocals.push(['local', snap, 'i32'])
1033
+ snaps.push(['local.set', snap, ['i32.trunc_sat_f64_s', ['f64.ceil', ['local.get', m.bound]]]])
837
1034
  }
1035
+ node.length = 3
1036
+ node[0] = 'i32.lt_s'; node[1] = ['local.get', m.ctr]; node[2] = ['local.get', snap]
838
1037
  }
839
1038
  return snaps
840
1039
  }
841
1040
 
842
- // Recursive node walker that splices snap decls before nested loops.
843
1041
  const processNode = (node, parent, idx) => {
844
1042
  if (!Array.isArray(node)) return
845
- const op = node[0]
846
- if (op === 'loop') {
1043
+ // Break-block idiom `(block $brk (loop …))`: snaps go BEFORE the block —
1044
+ // any statement between the block label and the loop is "foreign content"
1045
+ // to the lane-vectorizer's matcher and would defeat the whole point.
1046
+ if (node[0] === 'block' && typeof node[1] === 'string' && node.length === 3
1047
+ && Array.isArray(node[2]) && node[2][0] === 'loop') {
1048
+ const snaps = processLoop(node[2])
1049
+ if (snaps.length) parent.splice(idx, 0, ...snaps)
1050
+ return
1051
+ }
1052
+ if (node[0] === 'loop') {
847
1053
  const snaps = processLoop(node)
848
- if (snaps.length) {
849
- // Splice snaps just before this loop in its parent. The parent could be
850
- // a `block` or a top-level func body or any other container.
851
- parent.splice(idx, 0, ...snaps)
852
- }
1054
+ if (snaps.length) parent.splice(idx, 0, ...snaps)
853
1055
  return
854
1056
  }
855
1057
  for (let i = 0; i < node.length; i++) processNode(node[i], node, i)
856
1058
  }
857
1059
 
858
- for (let i = bodyStart; i < fn.length; i++) {
859
- processNode(fn[i], fn, i)
860
- }
861
-
1060
+ for (let i = bodyStart; i < fn.length; i++) processNode(fn[i], fn, i)
862
1061
  if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
863
1062
  }
864
1063
 
@@ -900,8 +1099,7 @@ export function cseScalarLoad(fn) {
900
1099
  const bases = fn.cseLoadBases
901
1100
  if (!(bases instanceof Set) || bases.size === 0) return
902
1101
 
903
- let snapId = 0
904
- while (fn.some(n => Array.isArray(n) && n[0] === 'local' && n[1] === `$__cs${snapId}`)) snapId++
1102
+ let snapId = nextLocalId(fn, 'cs')
905
1103
  const newLocals = []
906
1104
 
907
1105
  // CSE table: key `${X}|${K}` → { snapName | null, anchorParent, anchorIdx }
@@ -1057,6 +1255,11 @@ export function cseScalarLoad(fn) {
1057
1255
  * the surrounding `local.set/tee` handles that)
1058
1256
  * - `br/br_if/br_table/return/unreachable` → NO clear (pure values still valid)
1059
1257
  */
1258
+ // Commutative WASM binops — shared by csePureExpr + csePureExprLoop for canonical
1259
+ // operand-key ordering (a*b and b*a hash to one entry). OP_TYPE tables stay local:
1260
+ // the two passes cover deliberately different op sets.
1261
+ 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'])
1262
+
1060
1263
  export function csePureExpr(fn) {
1061
1264
  if (!Array.isArray(fn) || fn[0] !== 'func') return
1062
1265
  const bodyStart = findBodyStart(fn)
@@ -1073,8 +1276,8 @@ export function csePureExpr(fn) {
1073
1276
  if (m) { const k = +m[1]; if (k >= snapId) snapId = k + 1 }
1074
1277
  }
1075
1278
  const newLocals = []
1279
+ let refcount = null // lazily built on the first dedup — most fns never CSE
1076
1280
 
1077
- 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'])
1078
1281
  const TARGET_OPS = new Set([
1079
1282
  'f64.mul', 'f64.add', 'f64.sub',
1080
1283
  'i32.mul', 'i32.add', 'i32.sub', 'i32.shl', 'i32.shr_u', 'i32.shr_s', 'i32.and', 'i32.or', 'i32.xor',
@@ -1149,6 +1352,12 @@ export function csePureExpr(fn) {
1149
1352
  const entry = table.get(key)
1150
1353
  if (entry) {
1151
1354
  if (!entry.snapName) {
1355
+ // A shared (DAG) anchor breaks the in-place tee: the `%` fast-path emits
1356
+ // `a - trunc(a/b)*b` reusing ONE `a` node object, so the anchor and the
1357
+ // local.get replacement land on the SAME physical slot and the local.get
1358
+ // clobbers the tee — orphaning $__pe (reads 0). Skip when the anchor's
1359
+ // parent is shared; watr's DAG-aware CSE still dedupes. Mirrors csePureExprLoop.
1360
+ if (((refcount ??= buildRefcount(fn)).get(entry.anchorParent) || 0) > 1) return
1152
1361
  const snapName = `$__pe${snapId++}`
1153
1362
  entry.snapName = snapName
1154
1363
  newLocals.push(['local', snapName, OP_TYPE[op] || 'f64'])
@@ -1176,6 +1385,189 @@ export function csePureExpr(fn) {
1176
1385
  if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
1177
1386
  }
1178
1387
 
1388
+ /**
1389
+ * Post-watr nested CSE for hot fill loops (loop + trig). Reuses `$__pe` locals from
1390
+ * the pre-watr leaf pass. Deferred to the `phase === 'post'` run so watr's typed-array
1391
+ * inlining is not confused by pre-watr IR rewrites (test/mem.js).
1392
+ */
1393
+ export function csePureExprLoop(fn) {
1394
+ if (!Array.isArray(fn) || fn[0] !== 'func') return
1395
+ const bodyStart = findBodyStart(fn)
1396
+ if (bodyStart < 0) return
1397
+
1398
+ let hasLoop = false
1399
+ let hasTrigCall = false
1400
+ const scanShape = (n) => {
1401
+ if (!Array.isArray(n)) return
1402
+ if (n[0] === 'loop') hasLoop = true
1403
+ if (n[0] === 'call' && (n[1] === '$math.sin' || n[1] === '$math.cos'
1404
+ || n[1] === '$math.sin_core' || n[1] === '$math.cos_core')) hasTrigCall = true
1405
+ for (let i = 1; i < n.length; i++) scanShape(n[i])
1406
+ }
1407
+ for (let i = bodyStart; i < fn.length; i++) scanShape(fn[i])
1408
+ if (!hasLoop || !hasTrigCall) return
1409
+
1410
+ let snapId = nextLocalId(fn, 'pe')
1411
+ const newLocals = []
1412
+
1413
+ const refcount = buildRefcount(fn)
1414
+ const canMutateSite = (parent, node) =>
1415
+ (refcount.get(node) || 0) <= 1 && (refcount.get(parent) || 0) <= 1
1416
+
1417
+ const PURE_F64_BIN = new Set(['f64.mul', 'f64.add', 'f64.sub', 'f64.div'])
1418
+ const PURE_F64_UNARY = new Set(['f64.neg', 'f64.abs', 'f64.convert_i32_s', 'f64.convert_i32_u'])
1419
+ 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'])
1420
+ const PURE_I32_UNARY = new Set(['i32.eqz', 'i32.clz', 'i32.ctz', 'i32.popcnt'])
1421
+ const OP_TYPE = {
1422
+ 'f64.mul': 'f64', 'f64.add': 'f64', 'f64.sub': 'f64', 'f64.div': 'f64', 'f64.neg': 'f64', 'f64.abs': 'f64',
1423
+ 'f64.convert_i32_s': 'f64', 'f64.convert_i32_u': 'f64',
1424
+ 'i32.mul': 'i32', 'i32.add': 'i32', 'i32.sub': 'i32', 'i32.shl': 'i32', 'i32.shr_u': 'i32', 'i32.shr_s': 'i32',
1425
+ 'i32.and': 'i32', 'i32.or': 'i32', 'i32.xor': 'i32', 'i32.eqz': 'i32',
1426
+ }
1427
+
1428
+ const table = new Map()
1429
+ const keyLocals = new Set()
1430
+ const keyGlobals = new Set()
1431
+
1432
+ const invalidateLocal = (X) => {
1433
+ for (const [key, entry] of table) {
1434
+ if (entry.locals.has(X)) table.delete(key)
1435
+ }
1436
+ }
1437
+
1438
+ const invalidateGlobal = (G) => {
1439
+ for (const [key, entry] of table) {
1440
+ if (entry.globals.has(G)) table.delete(key)
1441
+ }
1442
+ }
1443
+
1444
+ const pureKeyI32 = (n) => {
1445
+ if (!Array.isArray(n)) return null
1446
+ const op = n[0]
1447
+ if (op === 'local.get' && typeof n[1] === 'string') { keyLocals.add(n[1]); return `L:${n[1]}` }
1448
+ if (op === 'global.get' && typeof n[1] === 'string') { keyGlobals.add(n[1]); return `G:${n[1]}` }
1449
+ if (op === 'i32.const' || op === 'i64.const') return `C:${op}:${n[1]}`
1450
+ if (PURE_I32_UNARY.has(op) && n.length === 2) {
1451
+ const k = pureKeyI32(n[1]); return k ? `${op}|${k}` : null
1452
+ }
1453
+ if (PURE_I32_BIN.has(op) && n.length === 3) {
1454
+ const ka = pureKeyI32(n[1]), kb = pureKeyI32(n[2])
1455
+ if (!ka || !kb) return null
1456
+ return COMMUTATIVE.has(op) && ka > kb ? `${op}|${kb}|${ka}` : `${op}|${ka}|${kb}`
1457
+ }
1458
+ if (op === 'i32.wrap_i64' && n.length === 2) {
1459
+ const k = pureKeyI32(n[1]); return k ? `wrap|${k}` : null
1460
+ }
1461
+ return null
1462
+ }
1463
+
1464
+ const pureKeyF64 = (n) => {
1465
+ if (!Array.isArray(n)) return null
1466
+ const op = n[0]
1467
+ if (op === 'local.get' && typeof n[1] === 'string') { keyLocals.add(n[1]); return `L:${n[1]}` }
1468
+ if (op === 'global.get' && typeof n[1] === 'string') { keyGlobals.add(n[1]); return `G:${n[1]}` }
1469
+ if (op === 'f64.const' || op === 'f32.const') return `C:${op}:${n[1]}`
1470
+ if (PURE_F64_UNARY.has(op) && n.length === 2) {
1471
+ if (op === 'f64.convert_i32_s' || op === 'f64.convert_i32_u') {
1472
+ const k = pureKeyI32(n[1]); return k ? `${op}|${k}` : null
1473
+ }
1474
+ const k = pureKeyF64(n[1]); return k ? `${op}|${k}` : null
1475
+ }
1476
+ if (PURE_F64_BIN.has(op) && n.length === 3) {
1477
+ const ka = pureKeyF64(n[1]), kb = pureKeyF64(n[2])
1478
+ if (!ka || !kb) return null
1479
+ return COMMUTATIVE.has(op) && ka > kb ? `${op}|${kb}|${ka}` : `${op}|${ka}|${kb}`
1480
+ }
1481
+ if (op === 'call' && n[1] === '$__to_num' && n.length === 3) {
1482
+ const a = n[2]
1483
+ if (Array.isArray(a) && a[0] === 'i64.reinterpret_f64' && a.length === 2) {
1484
+ const k = pureKeyF64(a[1]); return k ? `tonum|${k}` : null
1485
+ }
1486
+ }
1487
+ return null
1488
+ }
1489
+
1490
+ const tryCse = (node, parent, idx) => {
1491
+ const op = node[0]
1492
+ if (op === 'local.get' || op === 'global.get' || op === 'f64.const' || op === 'f32.const') return
1493
+ if (!canMutateSite(parent, node)) return
1494
+ keyLocals.clear()
1495
+ keyGlobals.clear()
1496
+ const key = pureKeyF64(node)
1497
+ if (!key) return
1498
+ const locals = new Set(keyLocals)
1499
+ const globals = new Set(keyGlobals)
1500
+ const entry = table.get(key)
1501
+ if (entry) {
1502
+ if (!entry.snapName) {
1503
+ if ((refcount.get(entry.anchorParent) || 0) > 1) return
1504
+ const snapName = `$__pe${snapId++}`
1505
+ entry.snapName = snapName
1506
+ newLocals.push(['local', snapName, OP_TYPE[node[0]] || 'f64'])
1507
+ const orig = entry.anchorParent[entry.anchorIdx]
1508
+ entry.anchorParent[entry.anchorIdx] = ['local.tee', snapName, orig]
1509
+ }
1510
+ parent[idx] = ['local.get', entry.snapName]
1511
+ } else {
1512
+ table.set(key, { snapName: null, anchorParent: parent, anchorIdx: idx, locals, globals })
1513
+ }
1514
+ }
1515
+
1516
+ const walk = (node, parent, idx) => {
1517
+ if (!Array.isArray(node)) return
1518
+ const op = node[0]
1519
+
1520
+ if (op === 'loop') {
1521
+ table.clear()
1522
+ for (let i = 1; i < node.length; i++) walk(node[i], node, i)
1523
+ table.clear()
1524
+ return
1525
+ }
1526
+
1527
+ if (op === 'if') {
1528
+ table.clear()
1529
+ for (let i = 1; i < node.length; i++) walk(node[i], node, i)
1530
+ table.clear()
1531
+ return
1532
+ }
1533
+
1534
+ if (op === 'then' || op === 'else') {
1535
+ table.clear()
1536
+ for (let i = 1; i < node.length; i++) walk(node[i], node, i)
1537
+ table.clear()
1538
+ return
1539
+ }
1540
+
1541
+ if (op === 'call' || op === 'call_ref' || op === 'call_indirect') {
1542
+ for (let i = 1; i < node.length; i++) walk(node[i], node, i)
1543
+ return
1544
+ }
1545
+
1546
+ if (op === 'local.set' || op === 'local.tee') {
1547
+ for (let i = 2; i < node.length; i++) walk(node[i], node, i)
1548
+ const X = node[1]
1549
+ if (typeof X === 'string') invalidateLocal(X)
1550
+ return
1551
+ }
1552
+
1553
+ if (op === 'global.set') {
1554
+ for (let i = 2; i < node.length; i++) walk(node[i], node, i)
1555
+ const G = node[1]
1556
+ if (typeof G === 'string') invalidateGlobal(G)
1557
+ return
1558
+ }
1559
+
1560
+ for (let i = 1; i < node.length; i++) {
1561
+ if (Array.isArray(node[i])) walk(node[i], node, i)
1562
+ }
1563
+ tryCse(node, parent, idx)
1564
+ }
1565
+
1566
+ for (let i = bodyStart; i < fn.length; i++) walk(fn[i], fn, i)
1567
+
1568
+ if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
1569
+ }
1570
+
1179
1571
  /**
1180
1572
  * Drop redundant zero-initialisation of fresh function-scope locals.
1181
1573
  *
@@ -1297,7 +1689,7 @@ export function deadStoreElim(fn) {
1297
1689
  // an implicit stack value (e.g. a `try_table` catch payload) — removing it
1298
1690
  // would unbalance the stack.
1299
1691
  if (op === 'drop' && node.length === 2 && isPure(node[1])) {
1300
- dead.push({ parent: items, idx: i, drop: true })
1692
+ dead.push({ parent: items, node, drop: true })
1301
1693
  }
1302
1694
 
1303
1695
  // Local write tracking
@@ -1308,10 +1700,9 @@ export function deadStoreElim(fn) {
1308
1700
  // if its RHS is pure — `local.set $x (call f …)` where `f` mutates
1309
1701
  // memory must still run. (A `local.tee` is always safe: removal demotes
1310
1702
  // it to its value expression, so any side effects there are preserved.)
1311
- const pn = prev.parent[prev.idx]
1312
- if (pn[0] === 'local.tee' || isPure(pn[2])) dead.push(prev)
1703
+ if (prev.node[0] === 'local.tee' || isPure(prev.node[2])) dead.push(prev)
1313
1704
  }
1314
- lastWrite.set(node[1], { parent: items, idx: i })
1705
+ lastWrite.set(node[1], { parent: items, node })
1315
1706
  }
1316
1707
 
1317
1708
  // Recurse into nested blocks with fresh state
@@ -1336,20 +1727,20 @@ export function deadStoreElim(fn) {
1336
1727
 
1337
1728
  scanBlock(fn, bodyStart, fn.length)
1338
1729
 
1339
- // Remove in reverse order so indices stay valid
1340
- for (let i = dead.length - 1; i >= 0; i--) {
1341
- const d = dead[i]
1342
- if (d.drop) {
1343
- d.parent.splice(d.idx, 1)
1730
+ // Removal is IDENTITY-based: entries are pushed at SUPERSEDE time, so
1731
+ // same-parent indices are not monotonic (name A's earlier write can be
1732
+ // superseded after name B's later one). Index-order splicing then shifts
1733
+ // remaining entries onto innocent neighbors — the self-host L2 divergence
1734
+ // deleted a typed-literal f64.store exactly this way. Re-locating each
1735
+ // captured node at removal time is immune to any ordering or prior splice.
1736
+ for (const d of dead) {
1737
+ const at = d.parent.indexOf(d.node)
1738
+ if (at < 0) continue // already removed (nested duplicate) — nothing to do
1739
+ if (!d.drop && d.node[0] === 'local.tee') {
1740
+ // tee in statement position: replace with just the value (implicitly dropped)
1741
+ d.parent[at] = d.node[2]
1344
1742
  } else {
1345
- const node = d.parent[d.idx]
1346
- if (node[0] === 'local.tee') {
1347
- // tee in statement position: replace with just the value (implicitly dropped)
1348
- d.parent[d.idx] = node[2]
1349
- } else {
1350
- // set in statement position: remove entirely
1351
- d.parent.splice(d.idx, 1)
1352
- }
1743
+ d.parent.splice(at, 1)
1353
1744
  }
1354
1745
  }
1355
1746
  }
@@ -1384,6 +1775,196 @@ export function collectVolatileGlobals(funcs) {
1384
1775
  return volatile
1385
1776
  }
1386
1777
 
1778
+ /**
1779
+ * Transitive global-write sets per function: name → Set of globals the function
1780
+ * writes directly OR through any (transitively) called function. The precise
1781
+ * complement to `collectVolatileGlobals`' coarse module-wide set — a global
1782
+ * written only by `init` is volatile module-wide, yet perfectly stable inside
1783
+ * a function whose call graph never reaches `init`.
1784
+ *
1785
+ * Unknown callees (imports — absent from the module's func list) write nothing:
1786
+ * wasm imports cannot touch module globals. `call_indirect`/`call_ref` targets
1787
+ * are unknown wasm functions — treat as writing every global any function
1788
+ * writes (the sound over-approximation).
1789
+ */
1790
+ export function collectReachableGlobalWrites(funcs) {
1791
+ const writes = new Map(), callees = new Map(), indirect = new Set(), all = new Set()
1792
+ for (const fn of funcs) {
1793
+ if (!Array.isArray(fn) || fn[0] !== 'func' || typeof fn[1] !== 'string') continue
1794
+ const w = new Set(), c = new Set()
1795
+ const scan = (n) => {
1796
+ if (!Array.isArray(n)) return
1797
+ if (n[0] === 'global.set' && typeof n[1] === 'string') { w.add(n[1]); all.add(n[1]) }
1798
+ else if ((n[0] === 'call' || n[0] === 'return_call') && typeof n[1] === 'string') c.add(n[1])
1799
+ else if (n[0] === 'call_indirect' || n[0] === 'call_ref' || n[0] === 'return_call_indirect') indirect.add(fn[1])
1800
+ for (let i = 1; i < n.length; i++) scan(n[i])
1801
+ }
1802
+ for (let i = 2; i < fn.length; i++) scan(fn[i])
1803
+ writes.set(fn[1], w); callees.set(fn[1], c)
1804
+ }
1805
+ // Worklist fixpoint over the call graph.
1806
+ let changed = true
1807
+ while (changed) {
1808
+ changed = false
1809
+ for (const [name, w] of writes) {
1810
+ const before = w.size
1811
+ if (indirect.has(name)) for (const g of all) w.add(g)
1812
+ for (const callee of callees.get(name)) {
1813
+ const cw = writes.get(callee)
1814
+ if (cw) for (const g of cw) w.add(g)
1815
+ }
1816
+ if (w.size !== before) changed = true
1817
+ }
1818
+ }
1819
+ return writes
1820
+ }
1821
+
1822
+ /**
1823
+ * Hoist `__ptr_offset` resolution of stable typed-array GLOBALS to one resolve
1824
+ * per function. Locals get their pointer unboxed once at bind time, but a
1825
+ * module-global typed array (`let x; init = () => { x = new Float64Array(n) }`
1826
+ * — the idiomatic DSP-state shape: rfft, game-of-life, diffusion)
1827
+ * re-resolves on EVERY element access:
1828
+ * (call $__ptr_offset (i64.reinterpret_f64 (global.get $x)))
1829
+ * — 68 such calls in rfft's transform alone, ~7× slower than V8. LICM can't
1830
+ * hoist them out of loops: its global-invariance rule requires a call-free
1831
+ * loop, and the resolve itself is a call. promoteGlobals can't either: `init`
1832
+ * writes the global, so it's volatile module-wide.
1833
+ *
1834
+ * The precise facts make it sound here: TYPED pointees never forward (only
1835
+ * ARRAY/SET/MAP do — same bits ⇒ same offset), so the snapshot is stable iff
1836
+ * the global's VALUE is stable through the function — i.e. the function
1837
+ * neither writes G itself nor (transitively) calls anything that does
1838
+ * (`collectReachableGlobalWrites`). The entry-time resolve is total
1839
+ * (`__ptr_offset` bounds-checks garbage to itself), so hoisting past a
1840
+ * zero-trip loop or an early return is safe.
1841
+ *
1842
+ * @param {Array} fn - func IR node
1843
+ * @param {Set<string>} stablePtrGlobals - '$name's of VAL.TYPED module globals
1844
+ * @param {Map<string,Set<string>>} reachableWrites - from collectReachableGlobalWrites
1845
+ */
1846
+ // Never-forwarding pointee kinds: every PTR tag outside __ptr_offset's
1847
+ // forwarding set {ARRAY, HASH, SET, MAP} — same bits ⇒ same offset.
1848
+ export const STABLE_PTR_VALS = new Set([VAL.TYPED, VAL.STRING, VAL.OBJECT, VAL.BUFFER, VAL.CLOSURE])
1849
+
1850
+ /** '$name' set of stable-pointee module globals (hoistGlobalPtrOffset targets). */
1851
+ export const stablePtrGlobalNames = () => {
1852
+ const out = new Set()
1853
+ if (ctx.scope.globalValTypes)
1854
+ for (const [k, v] of ctx.scope.globalValTypes) if (STABLE_PTR_VALS.has(v)) out.add(`$${k}`)
1855
+ return out
1856
+ }
1857
+
1858
+ export function hoistGlobalPtrOffset(fn, stablePtrGlobals, reachableWrites) {
1859
+ if (!Array.isArray(fn) || fn[0] !== 'func' || !stablePtrGlobals?.size) return
1860
+ const bodyStart = findBodyStart(fn)
1861
+ if (bodyStart < 0) return
1862
+
1863
+ // `(i64.reinterpret_f64 (global.get $G))` → G, or null.
1864
+ const reintGlobal = (n) =>
1865
+ Array.isArray(n) && n[0] === 'i64.reinterpret_f64'
1866
+ && Array.isArray(n[1]) && n[1][0] === 'global.get' && typeof n[1][1] === 'string'
1867
+ ? n[1][1] : null
1868
+ // A stable-pointee global's byte-base reaches us in two interchangeable shapes:
1869
+ // • forwarding-aware `(call $__ptr_offset (i64.reinterpret_f64 (global.get $G)))`
1870
+ // • inline typed read `(i32.wrap_i64 (i64.and (i64.reinterpret_f64 (global.get $G)) MASK))`
1871
+ // The inline form is what typed-array reads emit (a fixed-size typed array never
1872
+ // relocates, so they skip __ptr_offset's forwarding follow — see module/typedarray.js
1873
+ // `typedBase`). For a never-forwarding pointee both yield the identical offset, so
1874
+ // either site hoists to the one `__ptr_offset` entry snapshot. Matching only the
1875
+ // call form left typed-array globals re-decoding the NaN-box per element in stencil
1876
+ // sweeps (watercolor's pressure solve: 5 reads/cell × millions of cells). → G, or null.
1877
+ const siteGlobal = (n) => {
1878
+ if (!Array.isArray(n)) return null
1879
+ if (n[0] === 'call' && n[1] === '$__ptr_offset' && n.length === 3) return reintGlobal(n[2])
1880
+ if (n[0] === 'i32.wrap_i64' && n.length === 2 && Array.isArray(n[1]) && n[1][0] === 'i64.and' && n[1].length === 3) {
1881
+ const mask = n[1][2]
1882
+ if (Array.isArray(mask) && mask[0] === 'i64.const'
1883
+ && (typeof mask[1] === 'string' ? Number(mask[1]) : mask[1]) === LAYOUT.OFFSET_MASK)
1884
+ return reintGlobal(n[1][1])
1885
+ }
1886
+ return null
1887
+ }
1888
+
1889
+ // Per-global: static site count AND whether any site sits inside a loop. A
1890
+ // single in-loop site is a per-ITERATION resolve (lenia's convolution reads
1891
+ // each of kdx/kdy/kw at one site × ~14M taps/frame), so loop placement beats
1892
+ // site count as the hoist criterion.
1893
+ const counts = new Map(), inLoop = new Set(), ownWrites = new Set(), ownCallees = new Set()
1894
+ // Globals seen via the `__ptr_offset` call form (vs. only the inline typed mask).
1895
+ // The snapshot reuses an EXISTING form so it never resurrects a treeshaken helper:
1896
+ // a typed-array-only module emits no `__ptr_offset` call, so snapping one in would
1897
+ // reference a function that isn't in the module.
1898
+ const ptrOffsetForm = new Set()
1899
+ let hasIndirect = false
1900
+ const scan = (n, loopDepth) => {
1901
+ if (!Array.isArray(n)) return
1902
+ const g = siteGlobal(n)
1903
+ if (g != null) {
1904
+ counts.set(g, (counts.get(g) || 0) + 1)
1905
+ if (loopDepth > 0) inLoop.add(g)
1906
+ if (n[0] === 'call') ptrOffsetForm.add(g)
1907
+ return
1908
+ }
1909
+ if (n[0] === 'global.set' && typeof n[1] === 'string') ownWrites.add(n[1])
1910
+ else if ((n[0] === 'call' || n[0] === 'return_call') && typeof n[1] === 'string') ownCallees.add(n[1])
1911
+ else if (n[0] === 'call_indirect' || n[0] === 'call_ref' || n[0] === 'return_call_indirect') hasIndirect = true
1912
+ const d = n[0] === 'loop' ? loopDepth + 1 : loopDepth
1913
+ for (let i = 1; i < n.length; i++) scan(n[i], d)
1914
+ }
1915
+ for (let i = bodyStart; i < fn.length; i++) scan(fn[i], 0)
1916
+ if (!counts.size) return
1917
+
1918
+ const calleeWrites = (g) => {
1919
+ if (hasIndirect) return true // unknown targets — assume they write
1920
+ for (const c of ownCallees) if (reachableWrites?.get(c)?.has(g)) return true
1921
+ return false
1922
+ }
1923
+
1924
+ // Collision-proof snap ids (same scheme as hoistInvariantLoop's $__li).
1925
+ const used = new Set()
1926
+ const scanIds = (n) => {
1927
+ if (!Array.isArray(n)) return
1928
+ if (n[0] === 'local' && typeof n[1] === 'string' && n[1].startsWith('$__go')) {
1929
+ const t = n[1].slice(5); if (/^\d+$/.test(t)) used.add(+t)
1930
+ }
1931
+ for (let i = 0; i < n.length; i++) scanIds(n[i])
1932
+ }
1933
+ scanIds(fn)
1934
+ let idCounter = 0
1935
+ const freshId = () => { while (used.has(idCounter)) idCounter++; const id = idCounter++; used.add(id); return `$__go${id}` }
1936
+
1937
+ const chosen = new Map() // global → snap local
1938
+ for (const [g, c] of counts) {
1939
+ if ((c < 2 && !inLoop.has(g)) || !stablePtrGlobals.has(g) || ownWrites.has(g) || calleeWrites(g)) continue
1940
+ chosen.set(g, freshId())
1941
+ }
1942
+ if (!chosen.size) return
1943
+
1944
+ const replace = (n) => {
1945
+ if (!Array.isArray(n)) return
1946
+ for (let i = 1; i < n.length; i++) {
1947
+ const g = siteGlobal(n[i])
1948
+ if (g != null && chosen.has(g)) n[i] = ['local.get', chosen.get(g)]
1949
+ else replace(n[i])
1950
+ }
1951
+ }
1952
+ for (let i = bodyStart; i < fn.length; i++) replace(fn[i])
1953
+
1954
+ const decls = [], snaps = []
1955
+ for (const [g, name] of chosen) {
1956
+ decls.push(['local', name, 'i32'])
1957
+ // Match an existing site's form so we never reference a treeshaken helper.
1958
+ // For a never-forwarding pointee both forms compute the same offset, so the
1959
+ // inline mask is a safe (and call-free) snapshot when no __ptr_offset site exists.
1960
+ const snap = ptrOffsetForm.has(g)
1961
+ ? ['call', '$__ptr_offset', ['i64.reinterpret_f64', ['global.get', g]]]
1962
+ : ['i32.wrap_i64', ['i64.and', ['i64.reinterpret_f64', ['global.get', g]], ['i64.const', LAYOUT.OFFSET_MASK]]]
1963
+ snaps.push(['local.set', name, snap])
1964
+ }
1965
+ fn.splice(bodyStart, 0, ...decls, ...snaps)
1966
+ }
1967
+
1387
1968
  /**
1388
1969
  * Promote read-only globals to locals within each function.
1389
1970
  *
@@ -1408,7 +1989,7 @@ export function collectVolatileGlobals(funcs) {
1408
1989
  * @param {Map<string,string>} [globalTypes] - Optional: global name → wasm type ('i32'|'f64'|'i64'|'funcref')
1409
1990
  * @param {Set<string>} [volatileGlobals] - Optional: globals mutated outside `$__start` (see collectVolatileGlobals)
1410
1991
  */
1411
- export function promoteGlobals(fn, globalTypes, volatileGlobals) {
1992
+ export function promoteGlobals(fn, globalTypes, volatileGlobals, reachableWrites) {
1412
1993
  if (!Array.isArray(fn) || fn[0] !== 'func') return
1413
1994
  const bodyStart = findBodyStart(fn)
1414
1995
  if (bodyStart < 0) return
@@ -1416,8 +1997,8 @@ export function promoteGlobals(fn, globalTypes, volatileGlobals) {
1416
1997
  // Collect global.get counts, detect any global.set, and note whether the
1417
1998
  // function makes a call (a callee may mutate a volatile global between reads).
1418
1999
  const getCounts = new Map() // globalName → count
1419
- const written = new Set()
1420
- let hasCall = false
2000
+ const written = new Set(), callees = new Set()
2001
+ let hasCall = false, hasIndirect = false
1421
2002
 
1422
2003
  const scan = (node) => {
1423
2004
  if (!Array.isArray(node)) return
@@ -1431,7 +2012,8 @@ export function promoteGlobals(fn, globalTypes, volatileGlobals) {
1431
2012
  if (node[2]) scan(node[2])
1432
2013
  return
1433
2014
  }
1434
- if (op === 'call' || op === 'call_indirect') hasCall = true
2015
+ if (op === 'call' || op === 'return_call') { hasCall = true; if (typeof node[1] === 'string') callees.add(node[1]) }
2016
+ else if (op === 'call_indirect' || op === 'call_ref' || op === 'return_call_indirect') { hasCall = true; hasIndirect = true }
1435
2017
  for (let i = 1; i < node.length; i++) scan(node[i])
1436
2018
  }
1437
2019
 
@@ -1452,7 +2034,12 @@ export function promoteGlobals(fn, globalTypes, volatileGlobals) {
1452
2034
  for (const [gName, count] of getCounts) {
1453
2035
  if (count < 3 || written.has(gName)) continue
1454
2036
  // Unsound to cache a callee-mutable global across a call in this function.
1455
- if (hasCall && volatileGlobals && volatileGlobals.has(gName)) continue
2037
+ // With reachableWrites the test is exact per call edge (a global written
2038
+ // only by init stays promotable in functions whose call graph never
2039
+ // reaches init); without it, fall back to the coarse module-wide set.
2040
+ if (hasCall && (reachableWrites
2041
+ ? (hasIndirect || [...callees].some(c => reachableWrites.get(c)?.has(gName)))
2042
+ : volatileGlobals?.has(gName))) continue
1456
2043
  // Determine type: use provided map, or infer from context
1457
2044
  const type = globalTypes?.get(gName) || inferTypeFromContext(fn, gName, bodyStart)
1458
2045
  if (!type) continue // can't determine type, skip
@@ -1543,7 +2130,7 @@ function inferTypeFromContext(fn, gName, bodyStart) {
1543
2130
  * Pool entries sorted by usage descending, so hottest get lowest indices (1-byte LEB128).
1544
2131
  * Break-even: N ≥ 2 uses (pool cost: 11 B global decl + 2N bytes vs 9N original).
1545
2132
  *
1546
- * Mutates `funcs` in place; writes new global decls via `addGlobal(name, watString)`.
2133
+ * Mutates `funcs` in place; writes new global decls via `addGlobal(name, constLiteral)`.
1547
2134
  */
1548
2135
  export function hoistConstantPool(funcs, addGlobal) {
1549
2136
  const MIN_USES = 2
@@ -1574,7 +2161,7 @@ export function hoistConstantPool(funcs, addGlobal) {
1574
2161
  for (const [k] of sorted) {
1575
2162
  const name = `__fc${gId++}`
1576
2163
  const lit = k.slice(2)
1577
- addGlobal(name, `(global $${name} (mut f64) (f64.const ${lit}))`)
2164
+ addGlobal(name, lit)
1578
2165
  hoist.set(k, name)
1579
2166
  }
1580
2167
  if (!hoist.size) return
@@ -1623,7 +2210,12 @@ export function specializeMkptr(funcs, addFunc, parseWat) {
1623
2210
  '$__typed_idx': { params: ['i64', 'i32'], result: 'f64' },
1624
2211
  '$__str_idx': { params: ['i64', 'i32'], result: 'f64' },
1625
2212
  }
1626
- const MIN_USES = 5
2213
+ // 4 is the measured break-even: a specialized helper (trampoline / inline i64.const
2214
+ // template) costs ~12 B to define and saves ~2–4 B per site, so 4 sites amortize it.
2215
+ // Lower (3) net-inflates the watr self-host; 5 leaves 4-use combos on the table. The
2216
+ // sibling specializePtrBase threshold (20) is already optimal — its combos cluster far
2217
+ // above 20 (the ~2 k-site $__strBase relativization) with nothing in the 5–19 band.
2218
+ const MIN_USES = 4
1627
2219
 
1628
2220
  // Build literal-arg signature key for a call node. Returns null if no args are literal.
1629
2221
  // Key format: 'T:V' per literal arg, 'D' per dynamic; indexed by position.
@@ -1909,6 +2501,146 @@ export function sortStrPoolByFreq(funcs, strPoolRef, strDedupMap) {
1909
2501
  }
1910
2502
  }
1911
2503
 
2504
+ /**
2505
+ * Fold dead string-dispatch blocks when the tested operand is a proven-f64 local.
2506
+ *
2507
+ * jz's `+` emitter produces, for every binary addition whose right operand has an
2508
+ * unresolved valType, the pattern:
2509
+ *
2510
+ * (block (result f64)
2511
+ * (local.set $B EXPR_A)
2512
+ * (if (result f64)
2513
+ * (call $__is_str_key (i64.reinterpret_f64 (local.tee $C (local.get $P))))
2514
+ * (then (call $__str_concat …))
2515
+ * (else (f64.add (local.get $B) (local.get $C)))))
2516
+ *
2517
+ * When $P is a proven-f64 local (an f64 param, or an f64-typed local provably set
2518
+ * only from f64 arithmetic) it can never hold a string-key NaN-box, so the
2519
+ * `$__is_str_key` test is provably false and the `then` branch is dead.
2520
+ * Replace the whole block with `(f64.add EXPR_A (local.get $P))`.
2521
+ *
2522
+ * SOUND: f64 params can never hold a string-key NaN-box by construction (jz
2523
+ * only allows strings in f64 slots via explicit mkptr boxing, never a bare
2524
+ * param). This fold is additive/gated (only runs when vectorizeLaneLocal is on)
2525
+ * and only removes provably-dead string-dispatch overhead.
2526
+ *
2527
+ * Called in the 'post' phase of optimizeFunc, before vectorizeLaneLocal, so the
2528
+ * cleaned IR is what the vectorizer pattern-matches.
2529
+ */
2530
+ export function foldStrDispatchF64(fn) {
2531
+ if (!Array.isArray(fn) || fn[0] !== 'func') return
2532
+ const bodyStart = findBodyStart(fn)
2533
+ if (bodyStart < 0) return
2534
+
2535
+ // Collect all f64 params — provably never hold a string-key NaN-box.
2536
+ const rawF64 = new Set()
2537
+ for (let i = 2; i < bodyStart; i++) {
2538
+ const d = fn[i]
2539
+ if (Array.isArray(d) && d[0] === 'param' && typeof d[1] === 'string' && d[2] === 'f64')
2540
+ rawF64.add(d[1])
2541
+ }
2542
+ if (!rawF64.size) return // no f64 params → nothing to fold
2543
+
2544
+ // Transitively extend rawF64: an f64 local set only via f64 arithmetic over rawF64
2545
+ // members is itself provably non-string. One forward pass suffices for DAG-shaped
2546
+ // straight-line code (the common case); a fixed-point loop covers rare mutual defs.
2547
+ // Collect local types first.
2548
+ const localTypeMap = new Map()
2549
+ for (let i = 2; i < bodyStart; i++) {
2550
+ const d = fn[i]
2551
+ if (Array.isArray(d) && (d[0] === 'param' || d[0] === 'local') && typeof d[1] === 'string')
2552
+ localTypeMap.set(d[1], d[2])
2553
+ }
2554
+ // An expression is rawF64-valued if it only uses ops that stay in f64 and
2555
+ // reads only rawF64 locals (or f64.const). Stops early — we only need the
2556
+ // closed set for the pattern's $P operand.
2557
+ const isRawF64Expr = (n) => {
2558
+ if (!Array.isArray(n)) return false
2559
+ const op = n[0]
2560
+ if (op === 'f64.const') return true
2561
+ if (op === 'local.get' && typeof n[1] === 'string') return rawF64.has(n[1])
2562
+ if (op === 'local.tee' && typeof n[1] === 'string') return rawF64.has(n[1]) && isRawF64Expr(n[2])
2563
+ if (op === 'f64.add' || op === 'f64.sub' || op === 'f64.mul' || op === 'f64.div' ||
2564
+ op === 'f64.neg' || op === 'f64.abs' || op === 'f64.sqrt') {
2565
+ return n.slice(1).every(isRawF64Expr)
2566
+ }
2567
+ return false
2568
+ }
2569
+
2570
+ // Single forward pass: a local.set $v EXPR where EXPR is rawF64-valued makes $v rawF64.
2571
+ // Repeat until stable (handles ordering edge cases in non-DAG code).
2572
+ let changed = true
2573
+ while (changed) {
2574
+ changed = false
2575
+ const scan = (node) => {
2576
+ if (!Array.isArray(node)) return
2577
+ if ((node[0] === 'local.set' || node[0] === 'local.tee') && typeof node[1] === 'string' &&
2578
+ localTypeMap.get(node[1]) === 'f64' && !rawF64.has(node[1]) && isRawF64Expr(node[2])) {
2579
+ rawF64.add(node[1]); changed = true
2580
+ }
2581
+ for (let i = 1; i < node.length; i++) scan(node[i])
2582
+ }
2583
+ for (let i = bodyStart; i < fn.length; i++) scan(fn[i])
2584
+ }
2585
+
2586
+ // Pattern-match and fold in-place (bottom-up recursive walk so nested blocks resolve).
2587
+ const foldNode = (node) => {
2588
+ if (!Array.isArray(node)) return node
2589
+ // Recurse children first (bottom-up).
2590
+ for (let i = 0; i < node.length; i++) {
2591
+ const c = node[i]
2592
+ if (Array.isArray(c)) node[i] = foldNode(c)
2593
+ }
2594
+ // Match:
2595
+ // ['block', ['result','f64'],
2596
+ // ['local.set', $B, EXPR_A],
2597
+ // ['if', ['result','f64'],
2598
+ // ['call','$__is_str_key', ['i64.reinterpret_f64', ['local.tee',$C,['local.get',$P]]]],
2599
+ // ['then', ['call','$__str_concat',...]],
2600
+ // ['else', ['f64.add', ['local.get',$B], ['local.get',$C]]]]]
2601
+ if (node[0] !== 'block') return node
2602
+ if (!Array.isArray(node[1]) || node[1][0] !== 'result' || node[1][1] !== 'f64') return node
2603
+ if (node.length !== 4) return node
2604
+ const setStmt = node[2], ifStmt = node[3]
2605
+ if (!Array.isArray(setStmt) || setStmt[0] !== 'local.set' || typeof setStmt[1] !== 'string') return node
2606
+ const B = setStmt[1], exprA = setStmt[2]
2607
+ if (!Array.isArray(ifStmt) || ifStmt[0] !== 'if') return node
2608
+ // if must have: (result f64), cond, then, else — total 5 elements
2609
+ if (ifStmt.length !== 5) return node
2610
+ if (!Array.isArray(ifStmt[1]) || ifStmt[1][0] !== 'result' || ifStmt[1][1] !== 'f64') return node
2611
+ const cond = ifStmt[2], thenB = ifStmt[3], elseB = ifStmt[4]
2612
+ // cond: ['call','$__is_str_key',['i64.reinterpret_f64',['local.tee',$C,['local.get',$P]]]]
2613
+ if (!Array.isArray(cond) || cond[0] !== 'call' || cond[1] !== '$__is_str_key' || cond.length !== 3) return node
2614
+ const reinterpArg = cond[2]
2615
+ if (!Array.isArray(reinterpArg) || reinterpArg[0] !== 'i64.reinterpret_f64' || reinterpArg.length !== 2) return node
2616
+ const teeNode = reinterpArg[1]
2617
+ if (!Array.isArray(teeNode) || teeNode[0] !== 'local.tee' || typeof teeNode[1] !== 'string' || teeNode.length !== 3) return node
2618
+ const C = teeNode[1]
2619
+ const getP = teeNode[2]
2620
+ if (!Array.isArray(getP) || getP[0] !== 'local.get' || typeof getP[1] !== 'string') return node
2621
+ const P = getP[1]
2622
+ // $P must be a proven f64 local (never a string-key NaN-box)
2623
+ if (!rawF64.has(P)) return node
2624
+ // then: ['then', ['call','$__str_concat',...]]
2625
+ if (!Array.isArray(thenB) || thenB[0] !== 'then') return node
2626
+ // else: ['else', ['f64.add', ['local.get',$B], ['local.get',$C]]]
2627
+ if (!Array.isArray(elseB) || elseB[0] !== 'else' || elseB.length !== 2) return node
2628
+ const addExpr = elseB[1]
2629
+ if (!Array.isArray(addExpr) || addExpr[0] !== 'f64.add' || addExpr.length !== 3) return node
2630
+ // The two operands of f64.add must be local.get $B and local.get $C (in either order)
2631
+ const [lhsAdd, rhsAdd] = [addExpr[1], addExpr[2]]
2632
+ const lhsIsB = Array.isArray(lhsAdd) && lhsAdd[0] === 'local.get' && lhsAdd[1] === B
2633
+ const rhsIsC = Array.isArray(rhsAdd) && rhsAdd[0] === 'local.get' && rhsAdd[1] === C
2634
+ const lhsIsC = Array.isArray(lhsAdd) && lhsAdd[0] === 'local.get' && lhsAdd[1] === C
2635
+ const rhsIsB = Array.isArray(rhsAdd) && rhsAdd[0] === 'local.get' && rhsAdd[1] === B
2636
+ if (!((lhsIsB && rhsIsC) || (lhsIsC && rhsIsB))) return node
2637
+ // Match confirmed. Fold to: (f64.add EXPR_A (local.get $P))
2638
+ return ['f64.add', exprA, ['local.get', P]]
2639
+ }
2640
+
2641
+ for (let i = bodyStart; i < fn.length; i++) fn[i] = foldNode(fn[i])
2642
+ }
2643
+
1912
2644
  /**
1913
2645
  * Run all per-function IR optimizations on a single function node.
1914
2646
  * hoistPtrType runs first — it introduces new locals (`$__ptN`) that the fused
@@ -1921,14 +2653,16 @@ export function sortStrPoolByFreq(funcs, strPoolRef, strDedupMap) {
1921
2653
  * @param cfg optional resolved config from resolveOptimize() — when omitted, all on.
1922
2654
  * @param globalTypes optional global name → wasm type map (for promoteGlobals)
1923
2655
  * @param volatileGlobals optional set of callee-mutable globals (see collectVolatileGlobals)
2656
+ * @param phase 'pre' (default, pre-watr leaf pass) or 'post' (re-run after watr) —
2657
+ * gates the passes that only pay off once watr has reshaped the IR.
1924
2658
  */
1925
- export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals) {
2659
+ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre', reachableWrites) {
1926
2660
  if (cfg && cfg.hoistPtrType === false &&
1927
2661
  cfg.hoistInvariantPtrOffset === false &&
1928
- cfg.hoistInvariantPtrOffsetLoop === false &&
2662
+ cfg.hoistInvariantLoop === false &&
2663
+ cfg.narrowLoopBound === false &&
1929
2664
  cfg.fusedRewrite === false &&
1930
2665
  cfg.hoistAddrBase === false &&
1931
- cfg.hoistInvariantCellLoads === false &&
1932
2666
  cfg.cseScalarLoad === false &&
1933
2667
  cfg.csePureExpr === false &&
1934
2668
  cfg.dropDeadZeroInit === false &&
@@ -1938,27 +2672,67 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals) {
1938
2672
  cfg.vectorizeLaneLocal === false) return
1939
2673
  if (!cfg || cfg.hoistPtrType !== false) hoistPtrType(fn)
1940
2674
  if (!cfg || cfg.hoistInvariantPtrOffset !== false) hoistInvariantPtrOffset(fn)
1941
- if (!cfg || cfg.hoistInvariantPtrOffsetLoop !== false) hoistInvariantPtrOffsetLoop(fn)
2675
+ // Before LICM: the snapped i32 bound is itself a hoistable hard-op subtree, so
2676
+ // an outer loop's LICM can lift it further when the bound is outer-invariant.
2677
+ if (!cfg || cfg.narrowLoopBound !== false) narrowLoopBound(fn)
2678
+ // Unified LICM (replaces hoistInvariantToInt32 / PtrOffsetLoop / CellLoads).
2679
+ // Run at both maturity points (idempotent): pre-fusedRewrite catches the raw
2680
+ // ToInt32/ptr-offset/arithmetic shapes; post-hoistAddrBase catches cell loads.
2681
+ if (!cfg || cfg.hoistInvariantLoop !== false) hoistInvariantLoop(fn)
1942
2682
  const counts = new Map()
1943
2683
  if (!cfg || cfg.fusedRewrite !== false) fusedRewrite(fn, counts)
1944
2684
  if (!cfg || cfg.hoistAddrBase !== false) hoistAddrBase(fn)
1945
- if (!cfg || cfg.hoistInvariantCellLoads !== false) hoistInvariantCellLoads(fn)
2685
+ if (!cfg || cfg.hoistInvariantLoop !== false) hoistInvariantLoop(fn)
1946
2686
  if (!cfg || cfg.cseScalarLoad !== false) cseScalarLoad(fn)
1947
- if (!cfg || cfg.csePureExpr !== false) csePureExpr(fn)
2687
+ if (!cfg || cfg.csePureExpr !== false) {
2688
+ if (cfg && (cfg.watr === true || typeof cfg.watr === 'object') && phase === 'post') csePureExprLoop(fn)
2689
+ else csePureExpr(fn)
2690
+ }
1948
2691
  if (!cfg || cfg.dropDeadZeroInit !== false) dropDeadZeroInit(fn)
1949
2692
  if (!cfg || cfg.deadStoreElim !== false) deadStoreElim(fn)
1950
- if (!cfg || cfg.promoteGlobals !== false) promoteGlobals(fn, globalTypes, volatileGlobals)
2693
+ if (!cfg || cfg.promoteGlobals !== false) promoteGlobals(fn, globalTypes, volatileGlobals, reachableWrites)
1951
2694
  // Vectorizer runs PRE-watr unless full watr is enabled (`watr: true`). For full watr,
1952
2695
  // defer to post — full passes (notably `inlineOnce` + the post-inline `propagate`
1953
2696
  // sweep) reshape the IR so much that pre-watr SIMD patterns get scrambled. Light
1954
2697
  // watr (or no watr) leaves the lane locals intact for vectorize to pattern-match,
1955
2698
  // and lets a non-trivial chunk of SIMD survive the propagate+fold pipeline.
1956
2699
  if (cfg && cfg.vectorizeLaneLocal === true) {
1957
- const fullWatr = cfg.watr === true
1958
- const runVectorizer = (fullWatr && cfg.__phase === 'post') || (!fullWatr && cfg.__phase !== 'post')
1959
- if (runVectorizer) vectorizeLaneLocal(fn)
2700
+ const fullWatr = cfg.watr === true || typeof cfg.watr === 'object'
2701
+ const runVectorizer = (fullWatr && phase === 'post') || (!fullWatr && phase !== 'post')
2702
+ // Phase 1: fold dead string-dispatch blocks on proven-f64 locals BEFORE
2703
+ // the vectorizer pattern-matches — dead __is_str_key calls in $fbm-style
2704
+ // functions (param f64 + op f64) block liftPPC from recognizing them as pure.
2705
+ if (runVectorizer) foldStrDispatchF64(fn)
2706
+ if (runVectorizer) vectorizeLaneLocal(fn, cfg.reduceUnroll === true, cfg.relaxedSimd === true, cfg.blurMultiPixel !== false, cfg.whyNotSimd === true, cfg.experimentalStencil !== false, cfg.experimentalOuterStrip !== false, cfg._pureFuncMap || null, cfg.experimentalToneMap !== false)
1960
2707
  }
1961
2708
  if (!cfg || cfg.sortLocalsByUse !== false) sortLocalsByUse(fn, cfg && cfg.fusedRewrite !== false ? counts : null)
2709
+ // An optimizer pass that emits a malformed local — the class that otherwise dies
2710
+ // as an opaque watr "Duplicate/Unknown local $x" several phases on — is caught
2711
+ // here, pinned to the function and the bad name.
2712
+ if (DBG_IR) { const bad = verifyFn(fn); if (bad) throw new Error(`[ir verify] optimize produced invalid IR in ${fn[1]}: ${bad}`) }
2713
+ }
2714
+
2715
+ // The i32 form of an integer-valued f64 expression, or null. Used to push ToInt32
2716
+ // through a conditional and to collapse the f64 round-trip on integer `+`/`-`.
2717
+ // Lossless by construction: `convert_i32(X) → X`; integer `f64.const → i32.const`
2718
+ // (ToInt32); `f64.add/sub` of i32-valued operands → `i32.add/sub` (mod-2³² is a ring
2719
+ // homomorphism, and each i32±i32 < 2³² < 2⁵³ so the f64 op is exact). EXCLUDES `mul`
2720
+ // (products can exceed 2⁵³, so the f64 op loses precision and i32.mul wouldn't match)
2721
+ // and anything non-integer or unprovable. Address `local.tee`s inside operands are
2722
+ // preserved (kept as-is in the returned i32 tree).
2723
+ function toI32(n) {
2724
+ if (!Array.isArray(n)) return null
2725
+ const op = n[0]
2726
+ if ((op === 'f64.convert_i32_s' || op === 'f64.convert_i32_u') && n.length === 2) return n[1]
2727
+ // i32-range consts only: keeps every leaf within i32 so f64 add/sub of leaves stays exact
2728
+ // (< 2^53) and ToInt32-homomorphic. A larger const would round in f64.add or saturate in
2729
+ // trunc_sat differently from JS `|0`, breaking the fold.
2730
+ if (op === 'f64.const' && typeof n[1] === 'number' && (n[1] | 0) === n[1]) return ['i32.const', n[1]]
2731
+ if ((op === 'f64.add' || op === 'f64.sub') && n.length === 3) {
2732
+ const a = toI32(n[1]), b = toI32(n[2])
2733
+ if (a && b) return [op === 'f64.add' ? 'i32.add' : 'i32.sub', a, b]
2734
+ }
2735
+ return null
1962
2736
  }
1963
2737
 
1964
2738
  // Fused bottom-up walk applying three orthogonal pattern sets at each node:
@@ -1973,7 +2747,7 @@ function fusedRewrite(fn, counts) {
1973
2747
  if (Array.isArray(fn)) {
1974
2748
  for (let i = 0; i < fn.length; i++) {
1975
2749
  const c = fn[i]
1976
- if (Array.isArray(c)) fn[i] = walkRewrite(c, true, counts)
2750
+ if (Array.isArray(c)) fn[i] = walkRewrite(c, true, counts, null, null)
1977
2751
  }
1978
2752
  }
1979
2753
  return
@@ -1982,17 +2756,33 @@ function fusedRewrite(fn, counts) {
1982
2756
  const name = typeof fn[1] === 'string' ? fn[1] : null
1983
2757
  const skipInline = !!(name && (name.startsWith('$__ptr_') || name === '$__is_nullish' || name === '$__is_truthy' || name === '$__is_null'))
1984
2758
  const bodyStart = findBodyStart(fn)
2759
+ // i64 scratch allocator for the literal-eq inline: any-shaped operand is
2760
+ // tee'd once instead of duplicated. Decls splice in after the walk.
2761
+ const newDecls = []
2762
+ // pre+post phases both run this pass — continue numbering past any scratch
2763
+ // locals the earlier phase already declared, or the decls collide.
2764
+ let scratchN = 0
2765
+ for (let i = 2; i < fn.length; i++) {
2766
+ const d = fn[i]
2767
+ if (Array.isArray(d) && d[0] === 'local' && typeof d[1] === 'string') {
2768
+ const m = d[1].match(/^\$__eq[tf](\d+)$/)
2769
+ if (m) scratchN = Math.max(scratchN, +m[1] + 1)
2770
+ }
2771
+ }
2772
+ const freshI64 = () => { const n = `$__eqt${scratchN++}`; newDecls.push(['local', n, 'i64']); return n }
2773
+ const freshF64 = () => { const n = `$__eqf${scratchN++}`; newDecls.push(['local', n, 'f64']); return n }
1985
2774
  for (let i = bodyStart; i < fn.length; i++) {
1986
2775
  const c = fn[i]
1987
- if (Array.isArray(c)) fn[i] = walkRewrite(c, !skipInline, counts)
2776
+ if (Array.isArray(c)) fn[i] = walkRewrite(c, !skipInline, counts, freshI64, freshF64)
1988
2777
  }
2778
+ if (newDecls.length) fn.splice(bodyStart, 0, ...newDecls)
1989
2779
  }
1990
2780
 
1991
- function walkRewrite(node, doInline, counts) {
2781
+ function walkRewrite(node, doInline, counts, freshI64, freshF64) {
1992
2782
  if (!Array.isArray(node)) return node
1993
2783
  for (let i = 0; i < node.length; i++) {
1994
2784
  const c = node[i]
1995
- if (Array.isArray(c)) node[i] = walkRewrite(c, doInline, counts)
2785
+ if (Array.isArray(c)) node[i] = walkRewrite(c, doInline, counts, freshI64, freshF64)
1996
2786
  }
1997
2787
  const op = node[0]
1998
2788
  // Piggyback local-ref counting for sortLocalsByUse. `counts` may be undefined
@@ -2000,6 +2790,74 @@ function walkRewrite(node, doInline, counts) {
2000
2790
  if (counts && (op === 'local.get' || op === 'local.set' || op === 'local.tee') && typeof node[1] === 'string')
2001
2791
  counts.set(node[1], (counts.get(node[1]) || 0) + 1)
2002
2792
 
2793
+ // Generic-equality bit-eq fast path: $__eq's own first branch hoisted to the
2794
+ // site when both args duplicate cheaply (local.get / reinterpret of one).
2795
+ // Identical bits ⇒ equal-unless-canonical-NaN; static-literal dedup + SSO +
2796
+ // slice interning make the hit dominant in tree-walking code (tag compares),
2797
+ // so most sites skip the call. The else arm keeps the original call.
2798
+ if (doInline && op === 'call' && (node[1] === '$__eq' || node[1] === '$__str_eq')
2799
+ && node.length === 4 && !node._eqFast) {
2800
+ const cheap = (n) => Array.isArray(n) &&
2801
+ (n[0] === 'local.get' ||
2802
+ (n[0] === 'i64.reinterpret_f64' && Array.isArray(n[1]) && n[1][0] === 'local.get'))
2803
+ // i64.const whose bits decode to a CANONICAL interned string (STRING tag,
2804
+ // INTERN_BIT set, SSO/SLICE clear) — i.e. a static-literal operand.
2805
+ const internedLit = (n) => {
2806
+ // (i64.const 0x…) or its f64-carrier form (i64.reinterpret_f64 (f64.const nan:0x…))
2807
+ let tok = null
2808
+ if (Array.isArray(n) && n[0] === 'i64.const') tok = n[1]
2809
+ else if (Array.isArray(n) && n[0] === 'i64.reinterpret_f64' && Array.isArray(n[1])
2810
+ && n[1][0] === 'f64.const' && typeof n[1][1] === 'string' && n[1][1].startsWith('nan:'))
2811
+ tok = n[1][1].slice(4)
2812
+ if (tok == null) return false
2813
+ let v
2814
+ try { v = BigInt(tok) } catch { return false }
2815
+ if (v < 0n) v += 1n << 64n
2816
+ if (((v >> 47n) & 0xFn) !== 4n) return false
2817
+ return ((v >> 32n) & 0x6001n) === BigInt(STR_INTERN_BIT)
2818
+ }
2819
+ // Literal-vs-X inline: bit-eq → 1; X carrying the canonical aux pattern →
2820
+ // 0 (only a canonical string can content-equal a canonical literal, and
2821
+ // canonicals are deduped; every NON-string kind is ≠ a string under ===
2822
+ // as well, so answering 0 on the pattern is sound for ANY value). Slices,
2823
+ // SSO, fresh heap strings and NaN fall through to the call. This is what
2824
+ // makes `op === 'literal'` dispatch ladders cost ~3 ops per rung instead
2825
+ // of a helper call — the V8 interned-pointer-compare equivalent.
2826
+ const a = node[2], b = node[3]
2827
+ const lit = internedLit(b) ? b : internedLit(a) ? a : null
2828
+ const x = lit === b ? a : b
2829
+ if (lit && (cheap(x) || freshI64)) {
2830
+ node._eqFast = true
2831
+ // Cheap operands duplicate; anything else evaluates ONCE into an i64
2832
+ // scratch (tee in the first use), so the inline applies to un-hoisted
2833
+ // shapes like `node[0] === 'lit'` too.
2834
+ let first = x, reuse = x
2835
+ if (!cheap(x)) {
2836
+ const t = freshI64()
2837
+ first = ['local.tee', t, x]
2838
+ reuse = ['local.get', t]
2839
+ node[2] = lit === b ? reuse : lit
2840
+ node[3] = lit === b ? lit : reuse
2841
+ }
2842
+ const auxPat = ['i32.eq',
2843
+ ['i32.and', ['i32.wrap_i64', ['i64.shr_u', reuse, ['i64.const', 32]]], ['i32.const', 0x6001]],
2844
+ ['i32.const', STR_INTERN_BIT]]
2845
+ return ['if', ['result', 'i32'],
2846
+ ['i64.eq', first, lit],
2847
+ ['then', ['i32.const', 1]],
2848
+ ['else', ['if', ['result', 'i32'], auxPat,
2849
+ ['then', ['i32.const', 0]],
2850
+ ['else', node]]]]
2851
+ }
2852
+ if (node[1] === '$__eq' && cheap(a) && cheap(b)) {
2853
+ node._eqFast = true // pre+post phases both run this walk — wrap once
2854
+ return ['if', ['result', 'i32'],
2855
+ ['i64.eq', a, b],
2856
+ ['then', ['i64.ne', a, ['i64.const', NAN_BITS]]],
2857
+ ['else', node]]
2858
+ }
2859
+ }
2860
+
2003
2861
  // Inline-ptr-helpers: $__ptr_type / $__ptr_aux / $__is_nullish / $__is_null / $__is_truthy
2004
2862
  if (doInline && op === 'call' && node.length === 3 && typeof node[1] === 'string') {
2005
2863
  const fname = node[1]
@@ -2014,6 +2872,14 @@ function walkRewrite(node, doInline, counts) {
2014
2872
  && Array.isArray(node[2][1]) && node[2][1][0] === 'local.get') return ['i32.or',
2015
2873
  ['i64.eq', node[2], ['i64.const', NULL_BITS]],
2016
2874
  ['i64.eq', node[2], ['i64.const', UNDEF_BITS]]]
2875
+ // Expression-arg __is_truthy: evaluate once into an f64 scratch via tee —
2876
+ // the local.tee form below then expands inline (covers `(c = next()) || …`
2877
+ // and every condition the emitter didn't pre-hoist).
2878
+ if (fname === '$__is_truthy' && freshF64 && Array.isArray(node[2]) && node[2][0] === 'i64.reinterpret_f64'
2879
+ && Array.isArray(node[2][1])
2880
+ && node[2][1][0] !== 'local.get' && node[2][1][0] !== 'local.tee') {
2881
+ node[2] = ['i64.reinterpret_f64', ['local.tee', freshF64(), node[2][1]]]
2882
+ }
2017
2883
  if (fname === '$__is_truthy' && Array.isArray(node[2]) && node[2][0] === 'i64.reinterpret_f64'
2018
2884
  && Array.isArray(node[2][1]) && (node[2][1][0] === 'local.get' || node[2][1][0] === 'local.tee')) {
2019
2885
  // `local.tee $x SRC` evaluates SRC once, stores to $x, returns the value —
@@ -2025,16 +2891,21 @@ function walkRewrite(node, doInline, counts) {
2025
2891
  const lget = ['local.get', lname]
2026
2892
  const first = ref[0] === 'local.tee' ? ref : lget
2027
2893
  const bits = ['i64.reinterpret_f64', lget]
2894
+ // Mirror $__is_truthy (module/core.js) exactly: FIVE falsy patterns —
2895
+ // canonical NaN, null, undefined, the empty SSO string, AND boolean
2896
+ // false. Omitting FALSE made inlined `x || y` treat false as truthy.
2028
2897
  return ['if', ['result', 'i32'],
2029
2898
  ['f64.eq', first, lget],
2030
2899
  ['then', ['f64.ne', lget, ['f64.const', 0]]],
2031
2900
  ['else', ['i32.and',
2032
2901
  ['i32.and',
2033
- ['i64.ne', bits, ['i64.const', NAN_BITS]],
2034
- ['i64.ne', bits, ['i64.const', NULL_BITS]]],
2035
- ['i32.and',
2036
- ['i64.ne', bits, ['i64.const', UNDEF_BITS]],
2037
- ['i64.ne', bits, ['i64.const', '0x7FFA400000000000']]]]]]
2902
+ ['i32.and',
2903
+ ['i64.ne', bits, ['i64.const', NAN_BITS]],
2904
+ ['i64.ne', bits, ['i64.const', NULL_BITS]]],
2905
+ ['i32.and',
2906
+ ['i64.ne', bits, ['i64.const', UNDEF_BITS]],
2907
+ ['i64.ne', bits, ['i64.const', '0x7FFA400000000000']]]],
2908
+ ['i64.ne', bits, ['i64.const', FALSE_BITS]]]]]
2038
2909
  }
2039
2910
  }
2040
2911
 
@@ -2078,6 +2949,65 @@ function walkRewrite(node, doInline, counts) {
2078
2949
  return a[1]
2079
2950
  }
2080
2951
 
2952
+ // Push ToInt32 through integer expressions and conditionals. The universal value model
2953
+ // computes integer `+`/`-` and `?:` in f64, then ToInt32-clamps — emitting
2954
+ // (select (i32.wrap_i64 (i64.trunc_sat_f64_s [local.tee T] X)) FALLBACK COND)
2955
+ // whose three arms all compute ToInt32(X). When X is an integer-valued f64 expression,
2956
+ // ToInt32(X) == its i32 form (exact); and ToInt32 distributes through a conditional:
2957
+ // ToInt32(if C A B) == if(result i32) C ToInt32(A) ToInt32(B).
2958
+ // Folding here drops the f64 round-trip AND turns int `s += a[i]` reductions and
2959
+ // `a[i] = cond ? … : …` conditional maps into pure i32 the vectorizer lifts (i32x4.add /
2960
+ // i32x4 bitselect). FALLBACK/COND (which recompute the same ToInt32 from T) are dropped.
2961
+ if (op === 'select' && node.length >= 4) {
2962
+ const v = node[1]
2963
+ if (Array.isArray(v) && v[0] === 'i32.wrap_i64' && Array.isArray(v[1]) && v[1][0] === 'i64.trunc_sat_f64_s' && v[1].length === 2) {
2964
+ let inner = v[1][1]
2965
+ if (Array.isArray(inner) && inner[0] === 'local.tee' && inner.length === 3) inner = inner[2]
2966
+ // ToInt32(if (result f64) C A B) → if (result i32) C toI32(A) toI32(B), when both arms are integer-valued.
2967
+ if (Array.isArray(inner) && inner[0] === 'if' && Array.isArray(inner[1]) && inner[1][0] === 'result' && inner[1][1] === 'f64'
2968
+ && Array.isArray(inner[3]) && inner[3][0] === 'then' && inner[3].length === 2
2969
+ && Array.isArray(inner[4]) && inner[4][0] === 'else' && inner[4].length === 2) {
2970
+ const t = toI32(inner[3][1]), e = toI32(inner[4][1])
2971
+ if (t && e) return ['if', ['result', 'i32'], inner[2], ['then', t], ['else', e]]
2972
+ }
2973
+ // ToInt32(integer-valued f64 expr) → its i32 form (covers (i32±i32)|0 sums and nests).
2974
+ const i = toI32(inner)
2975
+ if (i) return i
2976
+ }
2977
+ }
2978
+ // (i32.or X 0) / (i32.or 0 X) → X — drops the redundant source-level `|0` clamp left
2979
+ // after the fold above, so the accumulator update is a bare i32.add the recognizer matches.
2980
+ if (op === 'i32.or' && node.length === 3) {
2981
+ const a = node[1], b = node[2]
2982
+ if (Array.isArray(b) && b[0] === 'i32.const' && b[1] === 0) return a
2983
+ if (Array.isArray(a) && a[0] === 'i32.const' && a[1] === 0) return b
2984
+ }
2985
+
2986
+ // f64.CMP(convert_i32 A, convert_i32 B) → i32.CMP(A, B). Comparing two i32 values is
2987
+ // identical whether done in exact f64 or in i32 (the converts are lossless and
2988
+ // order-preserving), so an integer comparison over typed-array loads (reads are f64)
2989
+ // drops its f64 round-trip. eq/ne are sign-agnostic; ordered compares need matching
2990
+ // signedness; an integer comparand constant works for the signed case. Both operands
2991
+ // are kept, so any address `local.tee` inside them survives. Prerequisite for i32
2992
+ // conditional-lane vectorization (the mask becomes an i32x4 compare).
2993
+ if (op === 'f64.eq' || op === 'f64.ne' || op === 'f64.lt' || op === 'f64.gt' || op === 'f64.le' || op === 'f64.ge') {
2994
+ const base = op.slice(4)
2995
+ const cv = (x) => Array.isArray(x) && (x[0] === 'f64.convert_i32_s' || x[0] === 'f64.convert_i32_u') && x.length === 2 ? x : null
2996
+ const intK = (x) => Array.isArray(x) && x[0] === 'f64.const' && Number.isInteger(x[1]) && x[1] >= -2147483648 && x[1] <= 2147483647 ? x[1] : null
2997
+ const a = node[1], b = node[2], ca = cv(a), cb = cv(b)
2998
+ if (ca && cb) {
2999
+ const sa = ca[0] === 'f64.convert_i32_s', sb = cb[0] === 'f64.convert_i32_s'
3000
+ if (base === 'eq' || base === 'ne') return ['i32.' + base, ca[1], cb[1]]
3001
+ if (sa === sb) return ['i32.' + base + (sa ? '_s' : '_u'), ca[1], cb[1]]
3002
+ } else if (ca && ca[0] === 'f64.convert_i32_s') {
3003
+ const k = intK(b)
3004
+ if (k != null) return base === 'eq' || base === 'ne' ? ['i32.' + base, ca[1], ['i32.const', k]] : ['i32.' + base + '_s', ca[1], ['i32.const', k]]
3005
+ } else if (cb && cb[0] === 'f64.convert_i32_s') {
3006
+ const k = intK(a)
3007
+ if (k != null) return base === 'eq' || base === 'ne' ? ['i32.' + base, ['i32.const', k], cb[1]] : ['i32.' + base + '_s', ['i32.const', k], cb[1]]
3008
+ }
3009
+ }
3010
+
2081
3011
  // shl-distribute-over-add: (i32.shl (i32.add x (i32.const K)) (i32.const S))
2082
3012
  // → (i32.add (i32.shl x S) (i32.const K<<S)). Overflow-safe — both forms wrap
2083
3013
  // mod 2^32 identically. Unlocks memarg offset= folding for biquad-style
@@ -2177,6 +3107,13 @@ export function treeshake(funcSections, allModuleNodes, opts) {
2177
3107
  for (let i = 2; i < fn.length; i++)
2178
3108
  if (Array.isArray(fn[i]) && fn[i][0] === 'export') { addRoot(name); break }
2179
3109
 
3110
+ // When user funcs are NOT being reclaimed (O0/O1 keep declared-but-uncalled ones), they
3111
+ // all survive — so they're roots for the *internal*-func reachability below. Otherwise an
3112
+ // unreachable user func that's kept would still call a `__helper`, yet that helper would be
3113
+ // pruned as unreached-from-exports, leaving a dangling `call $__helper`.
3114
+ if (!removeDead && opts && opts.userFuncs)
3115
+ for (const name of opts.userFuncs) addRoot(name)
3116
+
2180
3117
  const findRoots = (node) => {
2181
3118
  if (!Array.isArray(node)) return
2182
3119
  if (node[0] === 'start' && typeof node[1] === 'string') addRoot(node[1])
@@ -2204,24 +3141,43 @@ export function treeshake(funcSections, allModuleNodes, opts) {
2204
3141
  for (const fn of allFuncs) if (typeof fn[1] !== 'string') visitCalls(fn)
2205
3142
  while (stack.length) visitCalls(funcByName.get(stack.pop()))
2206
3143
 
3144
+ // Compiler-internal funcs (stdlib helpers, allocator wrappers — everything not in the
3145
+ // user's own `ctx.func.list`) carry no source meaning, so an unreachable one is reclaimed
3146
+ // at EVERY opt level: it's never a live-coding aid, just over-production (e.g. `s + '!'`
3147
+ // pulls the alloc trio's `__alloc_hdr`, which string concat never calls, and a dead-branch
3148
+ // dep like `__str_len`). User funcs are reclaimed only when DCE is on, so O0/O1 keep a
3149
+ // declared-but-uncalled user function. Absent the set, fall back to gating everything.
3150
+ const userFuncs = opts && opts.userFuncs
3151
+ const isUserFunc = (name) => userFuncs ? userFuncs.has(name) : true
2207
3152
  let removed = 0
2208
- if (removeDead) {
3153
+ if (removeDead || userFuncs) {
2209
3154
  for (const { arr } of funcSections) {
2210
3155
  for (let i = arr.length - 1; i >= 0; i--) {
2211
3156
  const n = arr[i]
2212
- if (Array.isArray(n) && n[0] === 'func' && typeof n[1] === 'string' && !reachable.has(n[1])) {
3157
+ if (Array.isArray(n) && n[0] === 'func' && typeof n[1] === 'string' && !reachable.has(n[1]) &&
3158
+ (removeDead || !isUserFunc(n[1]))) {
2213
3159
  arr.splice(i, 1); removed++
2214
3160
  }
2215
3161
  }
2216
3162
  }
2217
3163
  }
2218
3164
 
2219
- // Dead-global elimination: after dead funcs are gone, drop `(global $g …)` decls
2220
- // that nothing references (a `global.get`/`global.set` in a remaining func, a kept
2221
- // global's init expr, a data/elem offset, or an `(export … (global $g))`). Imported
2222
- // globals live in `allModuleNodes`, not in `opts.globals`, so they're never touched.
2223
- // Fixpoint: a kept global's init may reference another global.
2224
- const globals = removeDead && opts && Array.isArray(opts.globals) ? opts.globals : null
3165
+ // Dead-global elimination: drop `(global $g …)` decls that nothing references
3166
+ // (a `global.get`/`global.set` in a remaining func, a kept global's init expr, a
3167
+ // data/elem offset, or an `(export … (global $g))`). Imported globals live in
3168
+ // `allModuleNodes`, not in `opts.globals`, so they're never touched. Fixpoint: a
3169
+ // kept global's init may reference another global.
3170
+ //
3171
+ // Compiler-internal globals (support state the user never wrote — e.g. core's
3172
+ // `__heap_start` or the math module's `rng_state`, declared eagerly but read
3173
+ // only by specific fast paths) are reclaimed at *every* level: leaving an
3174
+ // unreferenced one in the output is pure noise, never a live-coding aid. User
3175
+ // globals are reclaimed only when DCE is on, so O0/O1 still preserve declared-
3176
+ // but-unused user bindings. `userGlobals` (names sans `$`) draws the line; absent
3177
+ // it, fall back to the `$__` reserved-prefix heuristic.
3178
+ const userGlobals = opts && opts.userGlobals
3179
+ const isUserGlobal = (name) => userGlobals ? userGlobals.has(name.slice(1)) : !name.startsWith('$__')
3180
+ const globals = opts && Array.isArray(opts.globals) ? opts.globals : null
2225
3181
  if (globals) {
2226
3182
  const collectGlobalRefs = (node, refd) => {
2227
3183
  if (!Array.isArray(node)) return
@@ -2238,9 +3194,11 @@ export function treeshake(funcSections, allModuleNodes, opts) {
2238
3194
  for (const g of globals) collectGlobalRefs(g, refd)
2239
3195
  for (let i = globals.length - 1; i >= 0; i--) {
2240
3196
  const g = globals[i]
2241
- if (Array.isArray(g) && g[0] === 'global' && typeof g[1] === 'string' && !refd.has(g[1])) {
2242
- globals.splice(i, 1); changed = true
2243
- }
3197
+ if (!Array.isArray(g) || g[0] !== 'global' || typeof g[1] !== 'string' || refd.has(g[1])) continue
3198
+ // An inline `(export )` on the decl pins it — it's part of the module's
3199
+ // JS-host surface (e.g. `__heap`), referenced from outside the wasm.
3200
+ if (g.some(c => Array.isArray(c) && c[0] === 'export')) continue
3201
+ if (removeDead || !isUserGlobal(g[1])) { globals.splice(i, 1); changed = true }
2244
3202
  }
2245
3203
  }
2246
3204
  }
@@ -2258,7 +3216,8 @@ export function sortLocalsByUse(fn, precomputedCounts) {
2258
3216
  if (!Array.isArray(fn) || fn[0] !== 'func') return
2259
3217
  const localIdxs = []
2260
3218
  let totalDecls = 0
2261
- for (let i = 2; i < fn.length; i++) {
3219
+ let i
3220
+ for (i = 2; i < fn.length; i++) {
2262
3221
  const c = fn[i]
2263
3222
  if (!Array.isArray(c)) continue
2264
3223
  if (c[0] === 'param' || c[0] === 'result') { totalDecls++; continue }