jz 0.5.1 → 0.6.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 (80) hide show
  1. package/README.md +288 -314
  2. package/bench/README.md +319 -0
  3. package/bench/bench.svg +112 -0
  4. package/cli.js +32 -24
  5. package/index.js +177 -55
  6. package/interop.js +88 -159
  7. package/jz.svg +5 -0
  8. package/jzify/arguments.js +97 -0
  9. package/jzify/bundler.js +382 -0
  10. package/jzify/classes.js +328 -0
  11. package/jzify/hoist-vars.js +177 -0
  12. package/jzify/index.js +51 -0
  13. package/jzify/names.js +37 -0
  14. package/jzify/switch.js +106 -0
  15. package/jzify/transform.js +349 -0
  16. package/layout.js +179 -0
  17. package/module/array.js +322 -153
  18. package/module/collection.js +603 -145
  19. package/module/console.js +55 -43
  20. package/module/core.js +266 -153
  21. package/module/date.js +15 -3
  22. package/module/function.js +73 -6
  23. package/module/index.js +2 -1
  24. package/module/json.js +226 -61
  25. package/module/math.js +414 -186
  26. package/module/number.js +306 -60
  27. package/module/object.js +448 -184
  28. package/module/regex.js +255 -25
  29. package/module/schema.js +24 -6
  30. package/module/simd.js +85 -0
  31. package/module/string.js +586 -220
  32. package/module/symbol.js +1 -1
  33. package/module/timer.js +9 -14
  34. package/module/typedarray.js +45 -48
  35. package/package.json +41 -12
  36. package/src/abi/index.js +39 -23
  37. package/src/abi/string.js +38 -41
  38. package/src/ast.js +460 -0
  39. package/src/autoload.js +26 -24
  40. package/src/bridge.js +111 -0
  41. package/src/compile/analyze-scans.js +661 -0
  42. package/src/compile/analyze.js +1565 -0
  43. package/src/compile/emit-assign.js +408 -0
  44. package/src/compile/emit.js +3201 -0
  45. package/src/compile/flow-types.js +103 -0
  46. package/src/{compile.js → compile/index.js} +497 -125
  47. package/src/{infer.js → compile/infer.js} +27 -98
  48. package/src/{narrow.js → compile/narrow.js} +302 -96
  49. package/src/compile/plan/advise.js +316 -0
  50. package/src/compile/plan/common.js +150 -0
  51. package/src/compile/plan/index.js +118 -0
  52. package/src/compile/plan/inline.js +679 -0
  53. package/src/compile/plan/literals.js +984 -0
  54. package/src/compile/plan/loops.js +472 -0
  55. package/src/compile/plan/scope.js +573 -0
  56. package/src/compile/program-facts.js +404 -0
  57. package/src/ctx.js +176 -58
  58. package/src/ir.js +540 -171
  59. package/src/kind-traits.js +105 -0
  60. package/src/kind.js +462 -0
  61. package/src/op-policy.js +57 -0
  62. package/src/{optimize.js → optimize/index.js} +1106 -446
  63. package/src/optimize/vectorize.js +1874 -0
  64. package/src/param-reps.js +65 -0
  65. package/src/parse.js +44 -0
  66. package/src/{prepare.js → prepare/index.js} +589 -202
  67. package/src/reps.js +115 -0
  68. package/src/resolve.js +12 -3
  69. package/src/static.js +199 -0
  70. package/src/type.js +647 -0
  71. package/src/{assemble.js → wat/assemble.js} +86 -48
  72. package/src/wat/optimize.js +3760 -0
  73. package/transform.js +21 -0
  74. package/wasi.js +47 -5
  75. package/src/analyze.js +0 -3818
  76. package/src/emit.js +0 -3040
  77. package/src/jzify.js +0 -1580
  78. package/src/plan.js +0 -2132
  79. package/src/vectorize.js +0 -1088
  80. /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
@@ -58,15 +65,39 @@ const UNDEF_BITS = '0x' + (LAYOUT.NAN_PREFIX_BITS | (2n << BigInt(LAYOUT.AUX_SHI
58
65
  * caps. Smallest wasm.
59
66
  * 'balanced' — the default (= level 2).
60
67
  * 'speed' — full nested unroll + lane vectorization (= level 3).
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',
@@ -116,7 +148,9 @@ const LEVEL_PRESETS = Object.freeze({
116
148
  balanced: Object.freeze({ ...ALL_ON, nestedSmallConstForUnroll: 'auto' }),
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.
@@ -137,10 +171,15 @@ const LEVEL_PRESETS = Object.freeze({
137
171
  export function resolveOptimize(opt) {
138
172
  if (opt === false || opt === 0) return { ...ALL_OFF }
139
173
  if (opt === true || opt == null) return { ...LEVEL_PRESETS[2] }
140
- if (typeof opt === 'number' || typeof opt === 'string') return { ...(LEVEL_PRESETS[opt] || LEVEL_PRESETS[2]) }
174
+ // String() the level key: LEVEL_PRESETS has integer-literal keys (0..3), and the
175
+ // self-host kernel's computed member access `obj[numVar]` misreads a numeric VARIABLE
176
+ // index against an object (returns undefined — literal `obj[2]` is fine), so a bare
177
+ // `LEVEL_PRESETS[opt]`/`[baseLevel]` would drop the level-2 default to ALL_ON and
178
+ // (worse, via the partial result) leave `watr` unset — disabling watOptimize.
179
+ if (typeof opt === 'number' || typeof opt === 'string') return { ...(LEVEL_PRESETS[String(opt)] || LEVEL_PRESETS[2]) }
141
180
  if (typeof opt === 'object') {
142
181
  const baseLevel = typeof opt.level === 'number' || typeof opt.level === 'string' ? opt.level : 2
143
- const base = LEVEL_PRESETS[baseLevel] || ALL_ON
182
+ const base = LEVEL_PRESETS[String(baseLevel)] || ALL_ON
144
183
  const out = { ...base }
145
184
  for (const n of PASS_NAMES) {
146
185
  if (!(n in opt)) continue
@@ -148,6 +187,7 @@ export function resolveOptimize(opt) {
148
187
  // Preserve sentinel value `nestedSmallConstForUnroll: 'auto'`
149
188
  // (resolved by a heuristic at emit time).
150
189
  if (n === 'nestedSmallConstForUnroll' && v === 'auto') out[n] = 'auto'
190
+ else if (n === 'watr' && typeof v === 'object') out[n] = v
151
191
  else out[n] = !!v
152
192
  }
153
193
  // Preserve non-pass tuning keys (e.g. plan.js thresholds)
@@ -182,213 +222,76 @@ export function resolveOptimize(opt) {
182
222
  * result is unsafe across realloc, so it isn't hoisted here.)
183
223
  */
184
224
  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) {
225
+ return regionTrackCSE(fn, {
226
+ matchSite(node) {
227
+ // (call $__ptr_type (i64.reinterpret_f64 (local.get X))) — key is X, dep is X.
228
+ if (node[0] !== 'call' || node[1] !== '$__ptr_type' || node.length !== 3) return null
205
229
  const arg = node[2]
206
- // Post-i64 migration: arg is (i64.reinterpret_f64 (local.get X)). Peel both wrappers.
207
230
  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)
231
+ if (!Array.isArray(inner) || inner[0] !== 'local.get' || typeof inner[1] !== 'string') return null
232
+ const x = inner[1]
233
+ return { key: x, deps: [x] }
234
+ },
235
+ localPrefix: 'pt',
236
+ localType: 'i32',
237
+ })
308
238
  }
309
239
 
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.
240
+ /** Region-tracking CSE skeleton shared by hoistPtrType and hoistAddrBase.
241
+ * Walks `fn`, accumulating "regions" sequences of structurally-identical
242
+ * sites along straight-line control flow where the site's value is invariant
243
+ * (no writes to its dependent locals between sites). Per region with ≥2 sites,
244
+ * allocates one `$__<prefix><id>` local and rewrites the first site to
245
+ * `local.tee` and the rest to `local.get`.
317
246
  *
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.
247
+ * Control-flow semantics:
248
+ * - `local.set/tee X` closes every region whose dep set includes X.
249
+ * - `if`/`else` arms walk independently from the if-entry open set; after
250
+ * the if, a region is open iff it was open on BOTH arms (same region ref).
251
+ * - `loop` clears open before AND after — back edges may skip the original tee.
252
+ * - `block` / func body — sequential walk.
320
253
  *
321
- * Must run AFTER fusedRewrite relies on shl-distribution + assoc-lift +
322
- * foldMemargOffsets having normalized the base shape.
323
- */
324
- export function hoistAddrBase(fn) {
254
+ * `matchSite(node, parent, pi)` returns `{ key, deps }` for a CSE-able site
255
+ * (key is a stable string; deps lists locals whose writes invalidate this key)
256
+ * or null. Match-arm sites don't recurse into children. */
257
+ function regionTrackCSE(fn, { matchSite, localPrefix, localType }) {
325
258
  if (!Array.isArray(fn) || fn[0] !== 'func') return
326
259
  const bodyStart = findBodyStart(fn)
327
260
  if (bodyStart < 0) return
328
261
 
329
- // Per key (`$A|$B|K`): array of regions; region: array of {parent, idx, role}.
262
+ // Per key: array of regions; each region is array of {parent, idx, role: 'tee'|'get'}.
330
263
  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).
264
+ // Currently-open region per key. Presence alive.
333
265
  const open = new Map()
266
+ // local-name → keys depending on it (so `local.set X` closes all dependent keys).
334
267
  const localToKeys = new Map()
335
268
 
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) => {
269
+ const addDep = (name, key) => {
342
270
  let s = localToKeys.get(name)
343
271
  if (!s) { s = new Set(); localToKeys.set(name, s) }
344
272
  s.add(key)
345
273
  }
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
274
  const closeForLocal = (name) => {
353
275
  const s = localToKeys.get(name)
354
276
  if (!s) return
355
- for (const k of s) if (open.has(k)) closeKey(k)
277
+ for (const k of s) open.delete(k)
356
278
  localToKeys.delete(name)
357
279
  }
358
280
 
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
281
  const walk = (node, parent, pi) => {
379
282
  if (!Array.isArray(node)) return
380
283
  const op = node[0]
381
284
 
382
- const m = matchPattern(node)
285
+ const m = matchSite(node, parent, pi)
383
286
  if (m) {
384
- const key = `${m.A}|${m.B}|${m.K}`
385
- let region = open.get(key)
287
+ let region = open.get(m.key)
386
288
  if (!region) {
387
289
  region = []
388
- ensureRegions(key).push(region)
389
- open.set(key, region)
390
- addLocalDep(m.A, key)
391
- addLocalDep(m.B, key)
290
+ let regs = regions.get(m.key)
291
+ if (!regs) { regs = []; regions.set(m.key, regs) }
292
+ regs.push(region)
293
+ open.set(m.key, region)
294
+ for (const d of m.deps) addDep(d, m.key)
392
295
  region.push({ parent, idx: pi, role: 'tee' })
393
296
  } else {
394
297
  region.push({ parent, idx: pi, role: 'get' })
@@ -398,7 +301,7 @@ export function hoistAddrBase(fn) {
398
301
 
399
302
  if ((op === 'local.set' || op === 'local.tee') && typeof node[1] === 'string') {
400
303
  const x = node[1]
401
- // Walk value first — it may match patterns referencing pre-write X.
304
+ // Walk value first — it may contain a site referencing pre-write X.
402
305
  for (let i = 2; i < node.length; i++) walk(node[i], node, i)
403
306
  closeForLocal(x)
404
307
  return
@@ -430,6 +333,7 @@ export function hoistAddrBase(fn) {
430
333
  for (let j = 1; j < elseArm.length; j++) walk(elseArm[j], elseArm, j)
431
334
  afterElse = new Map(open)
432
335
  }
336
+ // Merge: alive after if iff alive on BOTH paths with same region ref.
433
337
  open.clear()
434
338
  for (const [k, vT] of afterThen) {
435
339
  if (afterElse.get(k) === vT) open.set(k, vT)
@@ -451,16 +355,15 @@ export function hoistAddrBase(fn) {
451
355
 
452
356
  if (regions.size === 0) return
453
357
 
454
- let hoistId = 0
358
+ // Commit: ≥2 sites per region to be worthwhile (a singleton is pure cost).
359
+ let hoistId = nextLocalId(fn, localPrefix)
455
360
  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
361
  for (const [, regs] of regions) {
459
362
  let usable = false
460
363
  for (const r of regs) if (r.length >= 2) { usable = true; break }
461
364
  if (!usable) continue
462
- const tLocal = `$__ab${hoistId++}`
463
- locals.push(['local', tLocal, 'i32'])
365
+ const tLocal = `$__${localPrefix}${hoistId++}`
366
+ locals.push(['local', tLocal, localType])
464
367
  for (const r of regs) {
465
368
  if (r.length < 2) continue
466
369
  for (let i = 0; i < r.length; i++) {
@@ -473,6 +376,44 @@ export function hoistAddrBase(fn) {
473
376
  if (locals.length) fn.splice(bodyStart, 0, ...locals)
474
377
  }
475
378
 
379
+ /**
380
+ * CSE repeated `(i32.add (local.get $A) (i32.shl (local.get $B) (i32.const K)))`
381
+ * — the shape jz emits for `arr[idx + k]` typed-array reads after foldMemargOffsets
382
+ * absorbs the constant K into `offset=`. The remaining base expression is
383
+ * recomputed once per `arr[…]` read; biquad's inner cascade has 9 such reads
384
+ * sharing 2 base shapes per iteration. V8's CSE usually catches this, but emitting
385
+ * the share explicitly avoids relying on tier-up and helps wasm2c / wasm-opt too.
386
+ *
387
+ * Same region-tracking discipline as hoistPtrType: open region per key, closed
388
+ * by re-assignment to either A or B; loop entry/exit clears all open regions.
389
+ *
390
+ * Must run AFTER fusedRewrite — relies on shl-distribution + assoc-lift +
391
+ * foldMemargOffsets having normalized the base shape.
392
+ */
393
+ export function hoistAddrBase(fn) {
394
+ return regionTrackCSE(fn, {
395
+ matchSite(node) {
396
+ if (node[0] !== 'i32.add' || node.length !== 3) return null
397
+ const a = node[1], b = node[2]
398
+ // Two orderings: (add (get A) (shl (get B) (const K))) or (add (shl …) (get A))
399
+ let baseGet, shlNode
400
+ if (Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' &&
401
+ Array.isArray(b) && b[0] === 'i32.shl' && b.length === 3) {
402
+ baseGet = a; shlNode = b
403
+ } else if (Array.isArray(b) && b[0] === 'local.get' && typeof b[1] === 'string' &&
404
+ Array.isArray(a) && a[0] === 'i32.shl' && a.length === 3) {
405
+ baseGet = b; shlNode = a
406
+ } else return null
407
+ const idx = shlNode[1], shamt = shlNode[2]
408
+ if (!Array.isArray(idx) || idx[0] !== 'local.get' || typeof idx[1] !== 'string') return null
409
+ if (!Array.isArray(shamt) || shamt[0] !== 'i32.const' || typeof shamt[1] !== 'number') return null
410
+ return { key: `${baseGet[1]}|${idx[1]}|${shamt[1]}`, deps: [baseGet[1], idx[1]] }
411
+ },
412
+ localPrefix: 'ab',
413
+ localType: 'i32',
414
+ })
415
+ }
416
+
476
417
  /**
477
418
  * Hoist `(call $__ptr_offset (local.get $X))` to a function-entry snapshot
478
419
  * when X is an f64-NaN-boxed parameter that's never reassigned and only ever
@@ -484,7 +425,13 @@ export function hoistAddrBase(fn) {
484
425
  * sound for the duration. The whitelist below is the read-only set
485
426
  * (no mutation possible); any other callee touching X invalidates hoisting.
486
427
  */
487
- const SAFE_OFFSET_CALLS = new Set(['$__ptr_offset', '$__ptr_type', '$__ptr_aux', '$__len'])
428
+ // Read-only i32-returning calls: safe to hoist when operands are invariant,
429
+ // and their presence in a loop must not block other hoists (hasUnsafeCall).
430
+ // __jss_* are wasm:js-string host builtins over IMMUTABLE JS strings — pure by
431
+ // the same argument as the __ptr_* helpers (charCodeAt won't itself hoist —
432
+ // its index varies — but whitelisting it keeps hasUnsafeCall false so the
433
+ // loop-invariant __jss_length in the same loop condition CAN hoist).
434
+ const SAFE_OFFSET_CALLS = new Set(['$__ptr_offset', '$__ptr_type', '$__ptr_aux', '$__len', '$__jss_length', '$__jss_charCodeAt'])
488
435
 
489
436
  export function hoistInvariantPtrOffset(fn) {
490
437
  if (!Array.isArray(fn) || fn[0] !== 'func') return
@@ -558,8 +505,7 @@ export function hoistInvariantPtrOffset(fn) {
558
505
 
559
506
  if (sites.size === 0) return
560
507
 
561
- let hoistId = 0
562
- while (fn.some(n => Array.isArray(n) && n[0] === 'local' && n[1] === `$__po${hoistId}`)) hoistId++
508
+ let hoistId = nextLocalId(fn, 'po')
563
509
 
564
510
  const newLocals = []
565
511
  const snaps = []
@@ -577,97 +523,245 @@ export function hoistInvariantPtrOffset(fn) {
577
523
  if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals, ...snaps)
578
524
  }
579
525
 
526
+
527
+ // Non-trapping, side-effect-free ops whose result is a pure function of their
528
+ // operands. Hoisting one to the pre-header is sound iff its operands are loop-
529
+ // invariant: same value every iteration, no traps, no memory/global effects.
530
+ // DELIBERATELY EXCLUDES trapping ops — i32/i64 div_s/u & rem_s/u (trap on 0),
531
+ // non-saturating trunc_f64 (trap on overflow/NaN) — because hoisting a trap to
532
+ // the pre-header would fire it even when the loop runs zero times. Loads and
533
+ // calls are NOT here; they are admitted by `pureGiven` only under the loop's
534
+ // effect-summary barriers (cell loads with no aliasing store/call; the read-only
535
+ // __ptr_* call whitelist with no other call).
536
+ // Boxed-capture cells are `freshLocal`-generated, so the name carries the T
537
+ // (U+E000) prefix: `$<T>cell_<var>`. Built from the constant — a hand-typed
538
+ // `'$cell_'` literal silently omits the invisible T and never matches.
539
+ const CELL_PREFIX = '$' + T + 'cell_'
540
+
541
+ // Ops V8's wasm tier (TurboFan) will NOT hoist out of a loop itself: saturating
542
+ // f64→int truncation and `select` are not LICM-eligible there, memory loads are
543
+ // blocked by conservative aliasing, and calls are opaque. These are the ONLY
544
+ // things worth hoisting — V8 already does general arithmetic LICM, and hoisting
545
+ // pure arithmetic ourselves only bloats the body and breaks the lane-vectorizer's
546
+ // straight-line pattern match. So a subtree is hoisted only if it contains one.
547
+ const HARD_OPS = new Set([
548
+ 'i64.trunc_sat_f64_s', 'i64.trunc_sat_f64_u', 'i32.trunc_sat_f64_s', 'i32.trunc_sat_f64_u',
549
+ 'select', 'f64.load', 'i32.load', 'call',
550
+ ])
551
+ const hasHardOp = (n) => Array.isArray(n) && (HARD_OPS.has(n[0]) || n.some((c, i) => i > 0 && hasHardOp(c)))
552
+
553
+ const PURE_LICM_OPS = new Set([
554
+ 'f64.add', 'f64.sub', 'f64.mul', 'f64.div', 'f64.neg', 'f64.abs', 'f64.sqrt',
555
+ 'f64.min', 'f64.max', 'f64.ceil', 'f64.floor', 'f64.trunc', 'f64.nearest', 'f64.copysign',
556
+ 'i32.add', 'i32.sub', 'i32.mul', 'i32.and', 'i32.or', 'i32.xor',
557
+ 'i32.shl', 'i32.shr_s', 'i32.shr_u', 'i32.rotl', 'i32.rotr', 'i32.clz', 'i32.ctz', 'i32.popcnt', 'i32.eqz',
558
+ 'i64.add', 'i64.sub', 'i64.mul', 'i64.and', 'i64.or', 'i64.xor',
559
+ 'i64.shl', 'i64.shr_s', 'i64.shr_u', 'i64.rotl', 'i64.rotr', 'i64.eqz',
560
+ 'f64.eq', 'f64.ne', 'f64.lt', 'f64.gt', 'f64.le', 'f64.ge',
561
+ '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',
562
+ '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',
563
+ 'f64.convert_i32_s', 'f64.convert_i32_u', 'f64.convert_i64_s', 'f64.convert_i64_u',
564
+ 'i32.trunc_sat_f64_s', 'i32.trunc_sat_f64_u', 'i64.trunc_sat_f64_s', 'i64.trunc_sat_f64_u',
565
+ 'i32.wrap_i64', 'i64.extend_i32_s', 'i64.extend_i32_u',
566
+ 'f64.reinterpret_i64', 'i64.reinterpret_f64', 'f32.reinterpret_i32', 'i32.reinterpret_f32',
567
+ 'f64.promote_f32', 'f32.demote_f64', 'select',
568
+ ])
569
+
580
570
  /**
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`.
571
+ * Unified loop-invariant code motion. One principle replaces the three former
572
+ * pattern hoists (ToInt32 / __ptr_offset / cell-load): a MAXIMAL pure subtree
573
+ * whose every free input is loop-invariant is computed once before the loop, in
574
+ * a fresh snap local.
584
575
  *
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.
576
+ * Invariance/purity (`pureGiven`) is closed over PURE_LICM_OPS plus two memory-
577
+ * touching leaves admitted only under the loop's effect summary (`collectMutations`):
578
+ * - (f64.load (local.get $cell_X)) iff no f64.store to $cell_X and no call in loop
579
+ * - (call $__ptr_offset|__ptr_type|__ptr_aux|__len …) iff no non-whitelisted call
580
+ * exactly the old per-pass barriers, generalized. A subtree may also WRITE a
581
+ * local via (local.tee P E) iff P is private to the subtree (occurs nowhere else
582
+ * in the loop); this hoists the guarded-ToInt32 form
583
+ * (select (i32.wrap_i64 (i64.trunc_sat_f64_s (local.tee P E))) 0 (f64.ne (get P) G))
584
+ * as a unit — which the old leaf-only matcher could not (it needed a bare local).
590
585
  *
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.
586
+ * Bottom-up (inner loops first progressive climbing), refcount-guarded against
587
+ * watr's shared CSE subtrees, snaps spliced before the loop, decls at bodyStart.
588
+ * Idempotent: re-running sees only `(local.get $__liN)` and finds nothing to do.
596
589
  */
597
- export function hoistInvariantPtrOffsetLoop(fn) {
590
+ export function hoistInvariantLoop(fn) {
598
591
  if (!Array.isArray(fn) || fn[0] !== 'func') return
599
592
  const bodyStart = findBodyStart(fn)
600
593
  if (bodyStart < 0) return
601
594
 
602
- let snapId = 0
603
- while (fn.some(n => Array.isArray(n) && n[0] === 'local' && n[1] === `$__pol${snapId}`)) snapId++
604
- const newLocals = []
595
+ // Cheap early-out: no loop ⇒ nothing to hoist (skip the buildRefcount walk).
596
+ let hasLoop = false
597
+ const scanLoop = (n) => {
598
+ if (!Array.isArray(n) || hasLoop) return
599
+ if (n[0] === 'loop') { hasLoop = true; return }
600
+ for (let i = 1; i < n.length && !hasLoop; i++) scanLoop(n[i])
601
+ }
602
+ for (let i = bodyStart; i < fn.length && !hasLoop; i++) scanLoop(fn[i])
603
+ if (!hasLoop) return
605
604
 
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])
605
+ // Result wasm type of a hoistable node (for the snap local decl). null ⇒ can't
606
+ // type it don't hoist. Param/local types come from the func header.
607
+ const localTypes = new Map()
608
+ for (let i = 2; i < bodyStart; i++) {
609
+ const c = fn[i]
610
+ if (Array.isArray(c) && (c[0] === 'param' || c[0] === 'local') && typeof c[1] === 'string') localTypes.set(c[1], c[2])
611
+ }
612
+ const resultType = (node) => {
613
+ if (!Array.isArray(node)) return null
614
+ const op = node[0]
615
+ if (op === 'select') return resultType(node[1])
616
+ if (op === 'call') return SAFE_OFFSET_CALLS.has(node[1]) ? 'i32' : null // whitelist all return i32
617
+ if (op === 'local.get' || op === 'local.tee') return localTypes.get(node[1]) ?? null
618
+ const dot = op.indexOf('.')
619
+ if (dot < 0) return null
620
+ // Comparisons and `eqz` yield i32 regardless of operand type (i64.eq, f64.lt,
621
+ // i64.eqz, …) — so the operand-type prefix would mistype them. Catch first.
622
+ const m = op.slice(dot + 1)
623
+ if (m === 'eqz' || /^(eq|ne|lt|gt|le|ge)(_[su])?$/.test(m)) return 'i32'
624
+ const p = op.slice(0, dot)
625
+ if (p === 'i32' || p === 'i64' || p === 'f64' || p === 'f32') return p
626
+ return null
614
627
  }
615
- countRefs(fn)
628
+
629
+ // Collision-proof snap ids: skip EVERY existing $__li id, not just start at the
630
+ // lowest free one. watr can renumber/coalesce locals between the pre- and
631
+ // post-watr optimize phases, leaving a non-contiguous $__li set; a lowest-free +
632
+ // sequential-increment scheme would then re-issue an in-use id (Duplicate local).
633
+ const usedLi = new Set()
634
+ const scanLi = (n) => {
635
+ if (!Array.isArray(n)) return
636
+ if (n[0] === 'local' && typeof n[1] === 'string' && n[1].startsWith('$__li')) {
637
+ const t = n[1].slice(5); if (/^\d+$/.test(t)) usedLi.add(+t)
638
+ }
639
+ for (let i = 0; i < n.length; i++) scanLi(n[i])
640
+ }
641
+ scanLi(fn)
642
+ let snapCounter = 0
643
+ const freshSnap = () => { while (usedLi.has(snapCounter)) snapCounter++; const id = snapCounter++; usedLi.add(id); return `$__li${id}` }
644
+ const newLocals = []
645
+ const refcount = buildRefcount(fn)
616
646
 
617
647
  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
648
+ // Inner loops first (bottom-up) — an inner hoist creates a local.get the
649
+ // outer level can hoist further.
650
+ for (let i = 1; i < loopNode.length; i++)
651
+ if (Array.isArray(loopNode[i])) processNode(loopNode[i], loopNode, i)
652
+
653
+ // The loop's effect summary (scans nested loops too — conservative).
654
+ const locals = new Set(), globals = new Set(), storedCells = new Set()
655
+ let hasUnsafeCall = false, hasAnyCall = false
630
656
  const scan = (node) => {
631
657
  if (!Array.isArray(node)) return
632
658
  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
637
- }
638
- 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
642
- }
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
659
+ 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 }
660
+ 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 }
661
+ if (op === 'call') { hasAnyCall = true; if (!SAFE_OFFSET_CALLS.has(node[1])) hasUnsafeCall = true; for (let i = 2; i < node.length; i++) scan(node[i]); return }
662
+ if (op === 'call_ref' || op === 'call_indirect') { hasAnyCall = hasUnsafeCall = true; for (let i = 1; i < node.length; i++) scan(node[i]); return }
663
+ if ((op === 'f64.store' || op === 'i32.store') && node.length >= 3) {
664
+ const a = node[1]
665
+ if (Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' && a[1].startsWith(CELL_PREFIX)) storedCells.add(a[1])
647
666
  }
648
667
  for (let i = 1; i < node.length; i++) scan(node[i])
649
668
  }
650
669
  for (let i = 1; i < loopNode.length; i++) scan(loopNode[i])
651
- if (unsafe) return []
652
670
 
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 }]
671
+ // Per-subtree local-occurrence counts and write-sets, memoized bottom-up
672
+ // the tee-privacy check queries them for EVERY candidate node, and the old
673
+ // per-query re-walk (countIn/gatherBound) was quadratic on watr-scale loop
674
+ // bodies (the single largest compile-time hotspot, ~200ms/compile). All
675
+ // queries happen during `collect`, before any splice mutates the loop, so
676
+ // the memo cannot go stale; it is dropped with this processLoop frame.
677
+ const countsMemo = new Map() // node → Map(local → occurrences in subtree)
678
+ const writesMemoL = new Map() // node → Set(locals written in subtree)
679
+ const EMPTY_COUNTS = new Map(), EMPTY_WRITES = new Set()
680
+ const countsOf = (node) => {
681
+ if (!Array.isArray(node)) return EMPTY_COUNTS
682
+ let m = countsMemo.get(node)
683
+ if (m) return m
684
+ m = new Map()
685
+ const op = node[0]
686
+ if ((op === 'local.get' || op === 'local.set' || op === 'local.tee') && typeof node[1] === 'string')
687
+ m.set(node[1], 1)
688
+ for (let i = 1; i < node.length; i++)
689
+ for (const [k, v] of countsOf(node[i])) m.set(k, (m.get(k) || 0) + v)
690
+ countsMemo.set(node, m)
691
+ return m
692
+ }
693
+ const writesIn = (node) => {
694
+ if (!Array.isArray(node)) return EMPTY_WRITES
695
+ let s = writesMemoL.get(node)
696
+ if (s) return s
697
+ s = new Set()
698
+ if ((node[0] === 'local.set' || node[0] === 'local.tee') && typeof node[1] === 'string') s.add(node[1])
699
+ for (let i = 1; i < node.length; i++) for (const w of writesIn(node[i])) s.add(w)
700
+ writesMemoL.set(node, s)
701
+ return s
702
+ }
703
+ // Whole-loop counts (the former countLocals walk) — one memoized query.
704
+ const localCount = new Map()
705
+ for (let i = 1; i < loopNode.length; i++)
706
+ for (const [k, v] of countsOf(loopNode[i])) localCount.set(k, (localCount.get(k) || 0) + v)
707
+
708
+ // Pure & invariant given `bound` (locals written *within* the candidate, hence
709
+ // local to it). A read of a bound local is OK (its in-subtree value is the
710
+ // teed invariant). A free read must be unwritten by the loop.
711
+ const pureGiven = (node, bound) => {
712
+ if (!Array.isArray(node)) return true // bare operand string/number
713
+ const op = node[0]
714
+ if (op === 'i32.const' || op === 'i64.const' || op === 'f64.const' || op === 'f32.const') return true
715
+ if (op === 'local.get') return typeof node[1] === 'string' && (bound.has(node[1]) || !locals.has(node[1]))
716
+ // A global is invariant only if not set directly AND no call in the loop —
717
+ // any callee may mutate it (no interprocedural effect analysis). (Locals are
718
+ // frame-private, so calls can't touch them; only direct local.set matters.)
719
+ if (op === 'global.get') return typeof node[1] === 'string' && !globals.has(node[1]) && !hasAnyCall
720
+ if (op === 'local.tee') {
721
+ if (typeof node[1] !== 'string') return false
722
+ // The operand is evaluated BEFORE the tee writes $X, so a `local.get $X` inside
723
+ // it reads the loop-carried (previous-iteration) value, not the teed one. Drop
724
+ // $X from `bound` for the operand: `local.tee $X (… $X …)` is a loop recurrence
725
+ // (X = f(X) — e.g. the `while ((nn = nn >>> 1))` induction), NOT invariant.
726
+ const inner = bound.has(node[1]) ? new Set([...bound].filter(b => b !== node[1])) : bound
727
+ return pureGiven(node[2], inner)
728
+ }
729
+ if ((op === 'f64.load' || op === 'i32.load') && node.length === 2) {
730
+ const a = node[1]
731
+ return Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' && a[1].startsWith(CELL_PREFIX)
732
+ && !hasAnyCall && !storedCells.has(a[1]) && (bound.has(a[1]) || !locals.has(a[1]))
733
+ }
734
+ if (op === 'call') return SAFE_OFFSET_CALLS.has(node[1]) && !hasUnsafeCall && node.slice(2).every(c => pureGiven(c, bound))
735
+ if (PURE_LICM_OPS.has(op)) return node.slice(1).every(c => pureGiven(c, bound))
736
+ return false
737
+ }
738
+
739
+ const isHoistable = (node) => {
740
+ if (!Array.isArray(node)) return false
741
+ const op = node[0]
742
+ // Skip trivial leaves: hoisting a bare get/const buys nothing.
743
+ if (op === 'local.get' || op === 'global.get' || op === 'i32.const' || op === 'i64.const' || op === 'f64.const' || op === 'f32.const') return false
744
+ const bound = writesIn(node)
745
+ // Every local the subtree writes must be private to it (no other use in the
746
+ // loop) — else moving the write to the pre-header changes another reader.
747
+ for (const b of bound) if (localCount.get(b) !== countsOf(node).get(b)) return false
748
+ // Only hoist what V8's wasm tier won't (a HARD_OP) — leave pure arithmetic
749
+ // for V8's own LICM and the lane-vectorizer.
750
+ return hasHardOp(node) && pureGiven(node, bound)
751
+ }
752
+
753
+ // Maximal extraction: take the largest hoistable subtree; don't descend into
754
+ // it. Dedup structurally so a repeated invariant expr shares one snap local.
755
+ const sites = new Map() // structural key → [{ parent, idx, node }]
656
756
  const collect = (node, parent, idx) => {
657
757
  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
- }
758
+ if (node[0] === 'loop') return // already processed bottom-up
759
+ if (isHoistable(node) && (refcount.get(node) || 0) <= 1 && (refcount.get(parent) || 0) <= 1) {
760
+ // BigInt-safe: hoistable boxed-pointer subtrees carry i64.const NaN-box prefixes
761
+ // (BigInt values) that plain JSON.stringify can't serialize — see ast.bigintSafeKey.
762
+ const key = JSON.stringify(node, (_k, v) => typeof v === 'bigint' ? `${v}n` : v)
763
+ let arr = sites.get(key); if (!arr) { arr = []; sites.set(key, arr) }
764
+ arr.push({ parent, idx, node })
671
765
  return
672
766
  }
673
767
  for (let i = 0; i < node.length; i++) collect(node[i], node, i)
@@ -675,22 +769,20 @@ export function hoistInvariantPtrOffsetLoop(fn) {
675
769
  for (let i = 1; i < loopNode.length; i++) collect(loopNode[i], loopNode, i)
676
770
 
677
771
  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
- }
772
+ for (const [, arr] of sites) {
773
+ const type = resultType(arr[0].node)
774
+ if (type == null) continue
775
+ const snapName = freshSnap()
776
+ newLocals.push(['local', snapName, type])
777
+ snaps.push(['local.set', snapName, arr[0].node]) // reuse first node verbatim
778
+ for (const { parent, idx } of arr) parent[idx] = ['local.get', snapName]
686
779
  }
687
780
  return snaps
688
781
  }
689
782
 
690
783
  const processNode = (node, parent, idx) => {
691
784
  if (!Array.isArray(node)) return
692
- const op = node[0]
693
- if (op === 'loop') {
785
+ if (node[0] === 'loop') {
694
786
  const snaps = processLoop(node)
695
787
  if (snaps.length) parent.splice(idx, 0, ...snaps)
696
788
  return
@@ -699,166 +791,181 @@ export function hoistInvariantPtrOffsetLoop(fn) {
699
791
  }
700
792
 
701
793
  for (let i = bodyStart; i < fn.length; i++) processNode(fn[i], fn, i)
702
-
703
794
  if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
704
795
  }
705
796
 
706
797
  /**
707
- * Hoist loop-invariant boxed-cell reads out of loops.
798
+ * Narrow an f64 loop bound to i32. `for (let i = 0; i < n; i++)` with an f64
799
+ * param `n` emits `(f64.lt (f64.convert_i32_s $i) (local.get $n))` — an f64
800
+ * convert+compare every iteration that ALSO blocks the lane-vectorizer (it
801
+ * requires an i32-governed trip count). The naive-DSP export shape
802
+ * `(ptr, n) => { for (i = 0; i < n; i++) … }` therefore never vectorized
803
+ * without a hand-written `n|0`. This pass is that annotation, as a proof.
708
804
  *
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.
805
+ * When $i is a proven-non-negative i32 counter and $n is loop-invariant:
806
+ * convert_i32_s(i) < n ⟺ i < trunc_sat(ceil(n)) for all i 0
807
+ * - fractional n rounds up (i < 5.5 i < 6); integral n exact
808
+ * - NaN: ceil→NaN, trunc_sat→0 ⇒ `i < 0` false — matches the false f64 compare
809
+ * (THIS case is why i ≥ 0 must be proven: a negative i would flip it true)
810
+ * - n ≤ −2³¹ saturates to INT32_MIN ⇒ always false — matches
811
+ * - n ≥ 2³¹ saturates to INT32_MAX ⇒ terminates after 2³¹−1 iterations where
812
+ * the original wrapped $i negative and spun forever — the only divergence,
813
+ * pathological in both versions (a JS double counter would keep counting).
814
+ * Non-negativity proof: $i is a non-param i32 local whose EVERY write in the
815
+ * function (counters get re-zeroed between loops) is a non-negative i32.const
816
+ * or `$i + positive-const`. Wrap-around past 2³¹ needs 2³¹ agreeing iterations
817
+ * first, so trajectories are identical in every non-pathological program.
714
818
  *
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.
819
+ * Snap `(local.set $__lbK (i32.trunc_sat_f64_s (f64.ceil (local.get $n))))`
820
+ * goes in the loop pre-header (re-snapped per outer iteration when nested
821
+ * trunc_sat/ceil are total, safe even for zero-trip loops); the compare becomes
822
+ * `(i32.lt_s $i $__lbK)` the exact shape the lane-vectorizer matches.
823
+ * Bottom-up, refcount-guarded, idempotent (rewritten conds no longer match).
722
824
  */
723
- export function hoistInvariantCellLoads(fn) {
825
+ export function narrowLoopBound(fn) {
724
826
  if (!Array.isArray(fn) || fn[0] !== 'func') return
725
827
  const bodyStart = findBodyStart(fn)
726
828
  if (bodyStart < 0) return
727
829
 
728
- let snapId = 0
729
- while (fn.some(n => Array.isArray(n) && n[0] === 'local' && n[1] === `$__sc${snapId}`)) snapId++
830
+ // Cheap early-out: no loop ⇒ nothing to narrow.
831
+ let hasLoop = false
832
+ const scanLoop = (n) => {
833
+ if (!Array.isArray(n) || hasLoop) return
834
+ if (n[0] === 'loop') { hasLoop = true; return }
835
+ for (let i = 1; i < n.length && !hasLoop; i++) scanLoop(n[i])
836
+ }
837
+ for (let i = bodyStart; i < fn.length && !hasLoop; i++) scanLoop(fn[i])
838
+ if (!hasLoop) return
839
+
840
+ // Header types. Params are excluded as counters: their init is caller-supplied,
841
+ // so non-negativity is unprovable.
842
+ const localTypes = new Map(), params = new Set()
843
+ for (let i = 2; i < bodyStart; i++) {
844
+ const c = fn[i]
845
+ if (!Array.isArray(c) || typeof c[1] !== 'string') continue
846
+ if (c[0] === 'param') params.add(c[1])
847
+ if (c[0] === 'param' || c[0] === 'local') localTypes.set(c[1], c[2])
848
+ }
849
+
850
+ // Every write per local across the WHOLE function — not just in-loop: a counter
851
+ // reused by a later loop is re-zeroed between them, and a negative write
852
+ // anywhere voids the proof.
853
+ const writes = new Map()
854
+ const collectWrites = (n) => {
855
+ if (!Array.isArray(n)) return
856
+ if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') {
857
+ let arr = writes.get(n[1]); if (!arr) writes.set(n[1], arr = [])
858
+ arr.push(n[2])
859
+ }
860
+ for (let i = 1; i < n.length; i++) collectWrites(n[i])
861
+ }
862
+ for (let i = bodyStart; i < fn.length; i++) collectWrites(fn[i])
863
+
864
+ const constVal = (n) => Array.isArray(n) && n[0] === 'i32.const' ? Number(n[1]) : NaN
865
+ const nonNegCounter = (name) => {
866
+ if (params.has(name) || localTypes.get(name) !== 'i32') return false
867
+ const ws = writes.get(name)
868
+ if (!ws) return true // never written ⇒ stays at default 0
869
+ return ws.every(v => {
870
+ if (!Array.isArray(v)) return false
871
+ if (v[0] === 'i32.const') return Number(v[1]) >= 0
872
+ if (v[0] !== 'i32.add') return false
873
+ if (Array.isArray(v[1]) && v[1][0] === 'local.get' && v[1][1] === name) return constVal(v[2]) > 0
874
+ if (Array.isArray(v[2]) && v[2][0] === 'local.get' && v[2][1] === name) return constVal(v[1]) > 0
875
+ return false
876
+ })
877
+ }
878
+
879
+ // Collision-proof snap ids (same scheme as hoistInvariantLoop's $__li).
880
+ const usedLb = new Set()
881
+ const scanLb = (n) => {
882
+ if (!Array.isArray(n)) return
883
+ if (n[0] === 'local' && typeof n[1] === 'string' && n[1].startsWith('$__lb')) {
884
+ const t = n[1].slice(5); if (/^\d+$/.test(t)) usedLb.add(+t)
885
+ }
886
+ for (let i = 0; i < n.length; i++) scanLb(n[i])
887
+ }
888
+ scanLb(fn)
889
+ let lbCounter = 0
890
+ const freshLb = () => { while (usedLb.has(lbCounter)) lbCounter++; const id = lbCounter++; usedLb.add(id); return `$__lb${id}` }
730
891
  const newLocals = []
892
+ const refcount = buildRefcount(fn)
731
893
 
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])
894
+ // `(f64.lt (f64.convert_i32_s (local.get $i)) (local.get $n))` or mirrored
895
+ // `(f64.gt (local.get $n) (f64.convert_i32_s (local.get $i)))`.
896
+ const match = (n) => {
897
+ const conv = n[0] === 'f64.lt' ? n[1] : n[0] === 'f64.gt' ? n[2] : null
898
+ const bnd = n[0] === 'f64.lt' ? n[2] : n[0] === 'f64.gt' ? n[1] : null
899
+ if (!Array.isArray(conv) || conv[0] !== 'f64.convert_i32_s') return null
900
+ const ig = conv[1]
901
+ if (!Array.isArray(ig) || ig[0] !== 'local.get' || typeof ig[1] !== 'string') return null
902
+ if (!Array.isArray(bnd) || bnd[0] !== 'local.get' || typeof bnd[1] !== 'string') return null
903
+ return { ctr: ig[1], bound: bnd[1] }
744
904
  }
745
- countRefs(fn)
746
905
 
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
906
  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])
907
+ // Inner loops first — their sites belong to their own pre-header (the bound
908
+ // may be written by THIS loop between inner runs).
909
+ for (let i = 1; i < loopNode.length; i++)
910
+ if (Array.isArray(loopNode[i])) processNode(loopNode[i], loopNode, i)
911
+
912
+ // Locals written anywhere in this loop (incl. nested) — bound invariance.
913
+ const written = new Set()
914
+ const scanW = (n) => {
915
+ if (!Array.isArray(n)) return
916
+ if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') written.add(n[1])
917
+ for (let i = 1; i < n.length; i++) scanW(n[i])
791
918
  }
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 []
919
+ for (let i = 1; i < loopNode.length; i++) scanW(loopNode[i])
797
920
 
798
- // Parent-tracking pass to collect read sites.
799
- const collect = (node, parent, idx) => {
921
+ const sites = []
922
+ const collect = (node) => {
800
923
  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)
924
+ if (node[0] === 'loop') return // already processed bottom-up
925
+ const m = match(node)
926
+ if (m && (refcount.get(node) || 0) <= 1
927
+ && localTypes.get(m.bound) === 'f64' && !written.has(m.bound)
928
+ && nonNegCounter(m.ctr)) { sites.push({ node, m }); return }
929
+ for (let i = 1; i < node.length; i++) collect(node[i])
822
930
  }
823
- for (let i = 1; i < loopNode.length; i++) collect(loopNode[i], loopNode, i)
931
+ for (let i = 1; i < loopNode.length; i++) collect(loopNode[i])
824
932
 
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.
933
+ const snapFor = new Map() // bound name snap local (one per distinct bound)
829
934
  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]
935
+ for (const { node, m } of sites) {
936
+ let snap = snapFor.get(m.bound)
937
+ if (!snap) {
938
+ snap = freshLb()
939
+ snapFor.set(m.bound, snap)
940
+ newLocals.push(['local', snap, 'i32'])
941
+ snaps.push(['local.set', snap, ['i32.trunc_sat_f64_s', ['f64.ceil', ['local.get', m.bound]]]])
837
942
  }
943
+ node.length = 3
944
+ node[0] = 'i32.lt_s'; node[1] = ['local.get', m.ctr]; node[2] = ['local.get', snap]
838
945
  }
839
946
  return snaps
840
947
  }
841
948
 
842
- // Recursive node walker that splices snap decls before nested loops.
843
949
  const processNode = (node, parent, idx) => {
844
950
  if (!Array.isArray(node)) return
845
- const op = node[0]
846
- if (op === 'loop') {
951
+ // Break-block idiom `(block $brk (loop …))`: snaps go BEFORE the block —
952
+ // any statement between the block label and the loop is "foreign content"
953
+ // to the lane-vectorizer's matcher and would defeat the whole point.
954
+ if (node[0] === 'block' && typeof node[1] === 'string' && node.length === 3
955
+ && Array.isArray(node[2]) && node[2][0] === 'loop') {
956
+ const snaps = processLoop(node[2])
957
+ if (snaps.length) parent.splice(idx, 0, ...snaps)
958
+ return
959
+ }
960
+ if (node[0] === 'loop') {
847
961
  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
- }
962
+ if (snaps.length) parent.splice(idx, 0, ...snaps)
853
963
  return
854
964
  }
855
965
  for (let i = 0; i < node.length; i++) processNode(node[i], node, i)
856
966
  }
857
967
 
858
- for (let i = bodyStart; i < fn.length; i++) {
859
- processNode(fn[i], fn, i)
860
- }
861
-
968
+ for (let i = bodyStart; i < fn.length; i++) processNode(fn[i], fn, i)
862
969
  if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
863
970
  }
864
971
 
@@ -900,8 +1007,7 @@ export function cseScalarLoad(fn) {
900
1007
  const bases = fn.cseLoadBases
901
1008
  if (!(bases instanceof Set) || bases.size === 0) return
902
1009
 
903
- let snapId = 0
904
- while (fn.some(n => Array.isArray(n) && n[0] === 'local' && n[1] === `$__cs${snapId}`)) snapId++
1010
+ let snapId = nextLocalId(fn, 'cs')
905
1011
  const newLocals = []
906
1012
 
907
1013
  // CSE table: key `${X}|${K}` → { snapName | null, anchorParent, anchorIdx }
@@ -1057,6 +1163,11 @@ export function cseScalarLoad(fn) {
1057
1163
  * the surrounding `local.set/tee` handles that)
1058
1164
  * - `br/br_if/br_table/return/unreachable` → NO clear (pure values still valid)
1059
1165
  */
1166
+ // Commutative WASM binops — shared by csePureExpr + csePureExprLoop for canonical
1167
+ // operand-key ordering (a*b and b*a hash to one entry). OP_TYPE tables stay local:
1168
+ // the two passes cover deliberately different op sets.
1169
+ 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'])
1170
+
1060
1171
  export function csePureExpr(fn) {
1061
1172
  if (!Array.isArray(fn) || fn[0] !== 'func') return
1062
1173
  const bodyStart = findBodyStart(fn)
@@ -1073,8 +1184,8 @@ export function csePureExpr(fn) {
1073
1184
  if (m) { const k = +m[1]; if (k >= snapId) snapId = k + 1 }
1074
1185
  }
1075
1186
  const newLocals = []
1187
+ let refcount = null // lazily built on the first dedup — most fns never CSE
1076
1188
 
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
1189
  const TARGET_OPS = new Set([
1079
1190
  'f64.mul', 'f64.add', 'f64.sub',
1080
1191
  'i32.mul', 'i32.add', 'i32.sub', 'i32.shl', 'i32.shr_u', 'i32.shr_s', 'i32.and', 'i32.or', 'i32.xor',
@@ -1149,6 +1260,12 @@ export function csePureExpr(fn) {
1149
1260
  const entry = table.get(key)
1150
1261
  if (entry) {
1151
1262
  if (!entry.snapName) {
1263
+ // A shared (DAG) anchor breaks the in-place tee: the `%` fast-path emits
1264
+ // `a - trunc(a/b)*b` reusing ONE `a` node object, so the anchor and the
1265
+ // local.get replacement land on the SAME physical slot and the local.get
1266
+ // clobbers the tee — orphaning $__pe (reads 0). Skip when the anchor's
1267
+ // parent is shared; watr's DAG-aware CSE still dedupes. Mirrors csePureExprLoop.
1268
+ if (((refcount ??= buildRefcount(fn)).get(entry.anchorParent) || 0) > 1) return
1152
1269
  const snapName = `$__pe${snapId++}`
1153
1270
  entry.snapName = snapName
1154
1271
  newLocals.push(['local', snapName, OP_TYPE[op] || 'f64'])
@@ -1176,6 +1293,189 @@ export function csePureExpr(fn) {
1176
1293
  if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
1177
1294
  }
1178
1295
 
1296
+ /**
1297
+ * Post-watr nested CSE for hot fill loops (loop + trig). Reuses `$__pe` locals from
1298
+ * the pre-watr leaf pass. Deferred to the `phase === 'post'` run so watr's typed-array
1299
+ * inlining is not confused by pre-watr IR rewrites (test/mem.js).
1300
+ */
1301
+ export function csePureExprLoop(fn) {
1302
+ if (!Array.isArray(fn) || fn[0] !== 'func') return
1303
+ const bodyStart = findBodyStart(fn)
1304
+ if (bodyStart < 0) return
1305
+
1306
+ let hasLoop = false
1307
+ let hasTrigCall = false
1308
+ const scanShape = (n) => {
1309
+ if (!Array.isArray(n)) return
1310
+ if (n[0] === 'loop') hasLoop = true
1311
+ if (n[0] === 'call' && (n[1] === '$math.sin' || n[1] === '$math.cos'
1312
+ || n[1] === '$math.sin_core' || n[1] === '$math.cos_core')) hasTrigCall = true
1313
+ for (let i = 1; i < n.length; i++) scanShape(n[i])
1314
+ }
1315
+ for (let i = bodyStart; i < fn.length; i++) scanShape(fn[i])
1316
+ if (!hasLoop || !hasTrigCall) return
1317
+
1318
+ let snapId = nextLocalId(fn, 'pe')
1319
+ const newLocals = []
1320
+
1321
+ const refcount = buildRefcount(fn)
1322
+ const canMutateSite = (parent, node) =>
1323
+ (refcount.get(node) || 0) <= 1 && (refcount.get(parent) || 0) <= 1
1324
+
1325
+ const PURE_F64_BIN = new Set(['f64.mul', 'f64.add', 'f64.sub', 'f64.div'])
1326
+ const PURE_F64_UNARY = new Set(['f64.neg', 'f64.abs', 'f64.convert_i32_s', 'f64.convert_i32_u'])
1327
+ 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'])
1328
+ const PURE_I32_UNARY = new Set(['i32.eqz', 'i32.clz', 'i32.ctz', 'i32.popcnt'])
1329
+ const OP_TYPE = {
1330
+ 'f64.mul': 'f64', 'f64.add': 'f64', 'f64.sub': 'f64', 'f64.div': 'f64', 'f64.neg': 'f64', 'f64.abs': 'f64',
1331
+ 'f64.convert_i32_s': 'f64', 'f64.convert_i32_u': 'f64',
1332
+ 'i32.mul': 'i32', 'i32.add': 'i32', 'i32.sub': 'i32', 'i32.shl': 'i32', 'i32.shr_u': 'i32', 'i32.shr_s': 'i32',
1333
+ 'i32.and': 'i32', 'i32.or': 'i32', 'i32.xor': 'i32', 'i32.eqz': 'i32',
1334
+ }
1335
+
1336
+ const table = new Map()
1337
+ const keyLocals = new Set()
1338
+ const keyGlobals = new Set()
1339
+
1340
+ const invalidateLocal = (X) => {
1341
+ for (const [key, entry] of table) {
1342
+ if (entry.locals.has(X)) table.delete(key)
1343
+ }
1344
+ }
1345
+
1346
+ const invalidateGlobal = (G) => {
1347
+ for (const [key, entry] of table) {
1348
+ if (entry.globals.has(G)) table.delete(key)
1349
+ }
1350
+ }
1351
+
1352
+ const pureKeyI32 = (n) => {
1353
+ if (!Array.isArray(n)) return null
1354
+ const op = n[0]
1355
+ if (op === 'local.get' && typeof n[1] === 'string') { keyLocals.add(n[1]); return `L:${n[1]}` }
1356
+ if (op === 'global.get' && typeof n[1] === 'string') { keyGlobals.add(n[1]); return `G:${n[1]}` }
1357
+ if (op === 'i32.const' || op === 'i64.const') return `C:${op}:${n[1]}`
1358
+ if (PURE_I32_UNARY.has(op) && n.length === 2) {
1359
+ const k = pureKeyI32(n[1]); return k ? `${op}|${k}` : null
1360
+ }
1361
+ if (PURE_I32_BIN.has(op) && n.length === 3) {
1362
+ const ka = pureKeyI32(n[1]), kb = pureKeyI32(n[2])
1363
+ if (!ka || !kb) return null
1364
+ return COMMUTATIVE.has(op) && ka > kb ? `${op}|${kb}|${ka}` : `${op}|${ka}|${kb}`
1365
+ }
1366
+ if (op === 'i32.wrap_i64' && n.length === 2) {
1367
+ const k = pureKeyI32(n[1]); return k ? `wrap|${k}` : null
1368
+ }
1369
+ return null
1370
+ }
1371
+
1372
+ const pureKeyF64 = (n) => {
1373
+ if (!Array.isArray(n)) return null
1374
+ const op = n[0]
1375
+ if (op === 'local.get' && typeof n[1] === 'string') { keyLocals.add(n[1]); return `L:${n[1]}` }
1376
+ if (op === 'global.get' && typeof n[1] === 'string') { keyGlobals.add(n[1]); return `G:${n[1]}` }
1377
+ if (op === 'f64.const' || op === 'f32.const') return `C:${op}:${n[1]}`
1378
+ if (PURE_F64_UNARY.has(op) && n.length === 2) {
1379
+ if (op === 'f64.convert_i32_s' || op === 'f64.convert_i32_u') {
1380
+ const k = pureKeyI32(n[1]); return k ? `${op}|${k}` : null
1381
+ }
1382
+ const k = pureKeyF64(n[1]); return k ? `${op}|${k}` : null
1383
+ }
1384
+ if (PURE_F64_BIN.has(op) && n.length === 3) {
1385
+ const ka = pureKeyF64(n[1]), kb = pureKeyF64(n[2])
1386
+ if (!ka || !kb) return null
1387
+ return COMMUTATIVE.has(op) && ka > kb ? `${op}|${kb}|${ka}` : `${op}|${ka}|${kb}`
1388
+ }
1389
+ if (op === 'call' && n[1] === '$__to_num' && n.length === 3) {
1390
+ const a = n[2]
1391
+ if (Array.isArray(a) && a[0] === 'i64.reinterpret_f64' && a.length === 2) {
1392
+ const k = pureKeyF64(a[1]); return k ? `tonum|${k}` : null
1393
+ }
1394
+ }
1395
+ return null
1396
+ }
1397
+
1398
+ const tryCse = (node, parent, idx) => {
1399
+ const op = node[0]
1400
+ if (op === 'local.get' || op === 'global.get' || op === 'f64.const' || op === 'f32.const') return
1401
+ if (!canMutateSite(parent, node)) return
1402
+ keyLocals.clear()
1403
+ keyGlobals.clear()
1404
+ const key = pureKeyF64(node)
1405
+ if (!key) return
1406
+ const locals = new Set(keyLocals)
1407
+ const globals = new Set(keyGlobals)
1408
+ const entry = table.get(key)
1409
+ if (entry) {
1410
+ if (!entry.snapName) {
1411
+ if ((refcount.get(entry.anchorParent) || 0) > 1) return
1412
+ const snapName = `$__pe${snapId++}`
1413
+ entry.snapName = snapName
1414
+ newLocals.push(['local', snapName, OP_TYPE[node[0]] || 'f64'])
1415
+ const orig = entry.anchorParent[entry.anchorIdx]
1416
+ entry.anchorParent[entry.anchorIdx] = ['local.tee', snapName, orig]
1417
+ }
1418
+ parent[idx] = ['local.get', entry.snapName]
1419
+ } else {
1420
+ table.set(key, { snapName: null, anchorParent: parent, anchorIdx: idx, locals, globals })
1421
+ }
1422
+ }
1423
+
1424
+ const walk = (node, parent, idx) => {
1425
+ if (!Array.isArray(node)) return
1426
+ const op = node[0]
1427
+
1428
+ if (op === 'loop') {
1429
+ table.clear()
1430
+ for (let i = 1; i < node.length; i++) walk(node[i], node, i)
1431
+ table.clear()
1432
+ return
1433
+ }
1434
+
1435
+ if (op === 'if') {
1436
+ table.clear()
1437
+ for (let i = 1; i < node.length; i++) walk(node[i], node, i)
1438
+ table.clear()
1439
+ return
1440
+ }
1441
+
1442
+ if (op === 'then' || op === 'else') {
1443
+ table.clear()
1444
+ for (let i = 1; i < node.length; i++) walk(node[i], node, i)
1445
+ table.clear()
1446
+ return
1447
+ }
1448
+
1449
+ if (op === 'call' || op === 'call_ref' || op === 'call_indirect') {
1450
+ for (let i = 1; i < node.length; i++) walk(node[i], node, i)
1451
+ return
1452
+ }
1453
+
1454
+ if (op === 'local.set' || op === 'local.tee') {
1455
+ for (let i = 2; i < node.length; i++) walk(node[i], node, i)
1456
+ const X = node[1]
1457
+ if (typeof X === 'string') invalidateLocal(X)
1458
+ return
1459
+ }
1460
+
1461
+ if (op === 'global.set') {
1462
+ for (let i = 2; i < node.length; i++) walk(node[i], node, i)
1463
+ const G = node[1]
1464
+ if (typeof G === 'string') invalidateGlobal(G)
1465
+ return
1466
+ }
1467
+
1468
+ for (let i = 1; i < node.length; i++) {
1469
+ if (Array.isArray(node[i])) walk(node[i], node, i)
1470
+ }
1471
+ tryCse(node, parent, idx)
1472
+ }
1473
+
1474
+ for (let i = bodyStart; i < fn.length; i++) walk(fn[i], fn, i)
1475
+
1476
+ if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
1477
+ }
1478
+
1179
1479
  /**
1180
1480
  * Drop redundant zero-initialisation of fresh function-scope locals.
1181
1481
  *
@@ -1297,7 +1597,7 @@ export function deadStoreElim(fn) {
1297
1597
  // an implicit stack value (e.g. a `try_table` catch payload) — removing it
1298
1598
  // would unbalance the stack.
1299
1599
  if (op === 'drop' && node.length === 2 && isPure(node[1])) {
1300
- dead.push({ parent: items, idx: i, drop: true })
1600
+ dead.push({ parent: items, node, drop: true })
1301
1601
  }
1302
1602
 
1303
1603
  // Local write tracking
@@ -1308,10 +1608,9 @@ export function deadStoreElim(fn) {
1308
1608
  // if its RHS is pure — `local.set $x (call f …)` where `f` mutates
1309
1609
  // memory must still run. (A `local.tee` is always safe: removal demotes
1310
1610
  // 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)
1611
+ if (prev.node[0] === 'local.tee' || isPure(prev.node[2])) dead.push(prev)
1313
1612
  }
1314
- lastWrite.set(node[1], { parent: items, idx: i })
1613
+ lastWrite.set(node[1], { parent: items, node })
1315
1614
  }
1316
1615
 
1317
1616
  // Recurse into nested blocks with fresh state
@@ -1336,20 +1635,20 @@ export function deadStoreElim(fn) {
1336
1635
 
1337
1636
  scanBlock(fn, bodyStart, fn.length)
1338
1637
 
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)
1638
+ // Removal is IDENTITY-based: entries are pushed at SUPERSEDE time, so
1639
+ // same-parent indices are not monotonic (name A's earlier write can be
1640
+ // superseded after name B's later one). Index-order splicing then shifts
1641
+ // remaining entries onto innocent neighbors — the self-host L2 divergence
1642
+ // deleted a typed-literal f64.store exactly this way. Re-locating each
1643
+ // captured node at removal time is immune to any ordering or prior splice.
1644
+ for (const d of dead) {
1645
+ const at = d.parent.indexOf(d.node)
1646
+ if (at < 0) continue // already removed (nested duplicate) — nothing to do
1647
+ if (!d.drop && d.node[0] === 'local.tee') {
1648
+ // tee in statement position: replace with just the value (implicitly dropped)
1649
+ d.parent[at] = d.node[2]
1344
1650
  } 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
- }
1651
+ d.parent.splice(at, 1)
1353
1652
  }
1354
1653
  }
1355
1654
  }
@@ -1384,6 +1683,161 @@ export function collectVolatileGlobals(funcs) {
1384
1683
  return volatile
1385
1684
  }
1386
1685
 
1686
+ /**
1687
+ * Transitive global-write sets per function: name → Set of globals the function
1688
+ * writes directly OR through any (transitively) called function. The precise
1689
+ * complement to `collectVolatileGlobals`' coarse module-wide set — a global
1690
+ * written only by `init` is volatile module-wide, yet perfectly stable inside
1691
+ * a function whose call graph never reaches `init`.
1692
+ *
1693
+ * Unknown callees (imports — absent from the module's func list) write nothing:
1694
+ * wasm imports cannot touch module globals. `call_indirect`/`call_ref` targets
1695
+ * are unknown wasm functions — treat as writing every global any function
1696
+ * writes (the sound over-approximation).
1697
+ */
1698
+ export function collectReachableGlobalWrites(funcs) {
1699
+ const writes = new Map(), callees = new Map(), indirect = new Set(), all = new Set()
1700
+ for (const fn of funcs) {
1701
+ if (!Array.isArray(fn) || fn[0] !== 'func' || typeof fn[1] !== 'string') continue
1702
+ const w = new Set(), c = new Set()
1703
+ const scan = (n) => {
1704
+ if (!Array.isArray(n)) return
1705
+ if (n[0] === 'global.set' && typeof n[1] === 'string') { w.add(n[1]); all.add(n[1]) }
1706
+ else if ((n[0] === 'call' || n[0] === 'return_call') && typeof n[1] === 'string') c.add(n[1])
1707
+ else if (n[0] === 'call_indirect' || n[0] === 'call_ref' || n[0] === 'return_call_indirect') indirect.add(fn[1])
1708
+ for (let i = 1; i < n.length; i++) scan(n[i])
1709
+ }
1710
+ for (let i = 2; i < fn.length; i++) scan(fn[i])
1711
+ writes.set(fn[1], w); callees.set(fn[1], c)
1712
+ }
1713
+ // Worklist fixpoint over the call graph.
1714
+ let changed = true
1715
+ while (changed) {
1716
+ changed = false
1717
+ for (const [name, w] of writes) {
1718
+ const before = w.size
1719
+ if (indirect.has(name)) for (const g of all) w.add(g)
1720
+ for (const callee of callees.get(name)) {
1721
+ const cw = writes.get(callee)
1722
+ if (cw) for (const g of cw) w.add(g)
1723
+ }
1724
+ if (w.size !== before) changed = true
1725
+ }
1726
+ }
1727
+ return writes
1728
+ }
1729
+
1730
+ /**
1731
+ * Hoist `__ptr_offset` resolution of stable typed-array GLOBALS to one resolve
1732
+ * per function. Locals get their pointer unboxed once at bind time, but a
1733
+ * module-global typed array (`let x; init = () => { x = new Float64Array(n) }`
1734
+ * — the idiomatic DSP-state shape: rfft, game-of-life, diffusion)
1735
+ * re-resolves on EVERY element access:
1736
+ * (call $__ptr_offset (i64.reinterpret_f64 (global.get $x)))
1737
+ * — 68 such calls in rfft's transform alone, ~7× slower than V8. LICM can't
1738
+ * hoist them out of loops: its global-invariance rule requires a call-free
1739
+ * loop, and the resolve itself is a call. promoteGlobals can't either: `init`
1740
+ * writes the global, so it's volatile module-wide.
1741
+ *
1742
+ * The precise facts make it sound here: TYPED pointees never forward (only
1743
+ * ARRAY/SET/MAP do — same bits ⇒ same offset), so the snapshot is stable iff
1744
+ * the global's VALUE is stable through the function — i.e. the function
1745
+ * neither writes G itself nor (transitively) calls anything that does
1746
+ * (`collectReachableGlobalWrites`). The entry-time resolve is total
1747
+ * (`__ptr_offset` bounds-checks garbage to itself), so hoisting past a
1748
+ * zero-trip loop or an early return is safe.
1749
+ *
1750
+ * @param {Array} fn - func IR node
1751
+ * @param {Set<string>} stablePtrGlobals - '$name's of VAL.TYPED module globals
1752
+ * @param {Map<string,Set<string>>} reachableWrites - from collectReachableGlobalWrites
1753
+ */
1754
+ // Never-forwarding pointee kinds: every PTR tag outside __ptr_offset's
1755
+ // forwarding set {ARRAY, HASH, SET, MAP} — same bits ⇒ same offset.
1756
+ export const STABLE_PTR_VALS = new Set([VAL.TYPED, VAL.STRING, VAL.OBJECT, VAL.BUFFER, VAL.CLOSURE])
1757
+
1758
+ /** '$name' set of stable-pointee module globals (hoistGlobalPtrOffset targets). */
1759
+ export const stablePtrGlobalNames = () => {
1760
+ const out = new Set()
1761
+ if (ctx.scope.globalValTypes)
1762
+ for (const [k, v] of ctx.scope.globalValTypes) if (STABLE_PTR_VALS.has(v)) out.add(`$${k}`)
1763
+ return out
1764
+ }
1765
+
1766
+ export function hoistGlobalPtrOffset(fn, stablePtrGlobals, reachableWrites) {
1767
+ if (!Array.isArray(fn) || fn[0] !== 'func' || !stablePtrGlobals?.size) return
1768
+ const bodyStart = findBodyStart(fn)
1769
+ if (bodyStart < 0) return
1770
+
1771
+ // `(call $__ptr_offset (i64.reinterpret_f64 (global.get $G)))` → G, or null.
1772
+ const siteGlobal = (n) =>
1773
+ Array.isArray(n) && n[0] === 'call' && n[1] === '$__ptr_offset' && n.length === 3
1774
+ && Array.isArray(n[2]) && n[2][0] === 'i64.reinterpret_f64'
1775
+ && Array.isArray(n[2][1]) && n[2][1][0] === 'global.get' && typeof n[2][1][1] === 'string'
1776
+ ? n[2][1][1] : null
1777
+
1778
+ // Per-global: static site count AND whether any site sits inside a loop. A
1779
+ // single in-loop site is a per-ITERATION resolve (lenia's convolution reads
1780
+ // each of kdx/kdy/kw at one site × ~14M taps/frame), so loop placement beats
1781
+ // site count as the hoist criterion.
1782
+ const counts = new Map(), inLoop = new Set(), ownWrites = new Set(), ownCallees = new Set()
1783
+ let hasIndirect = false
1784
+ const scan = (n, loopDepth) => {
1785
+ if (!Array.isArray(n)) return
1786
+ const g = siteGlobal(n)
1787
+ if (g != null) { counts.set(g, (counts.get(g) || 0) + 1); if (loopDepth > 0) inLoop.add(g); return }
1788
+ if (n[0] === 'global.set' && typeof n[1] === 'string') ownWrites.add(n[1])
1789
+ else if ((n[0] === 'call' || n[0] === 'return_call') && typeof n[1] === 'string') ownCallees.add(n[1])
1790
+ else if (n[0] === 'call_indirect' || n[0] === 'call_ref' || n[0] === 'return_call_indirect') hasIndirect = true
1791
+ const d = n[0] === 'loop' ? loopDepth + 1 : loopDepth
1792
+ for (let i = 1; i < n.length; i++) scan(n[i], d)
1793
+ }
1794
+ for (let i = bodyStart; i < fn.length; i++) scan(fn[i], 0)
1795
+ if (!counts.size) return
1796
+
1797
+ const calleeWrites = (g) => {
1798
+ if (hasIndirect) return true // unknown targets — assume they write
1799
+ for (const c of ownCallees) if (reachableWrites?.get(c)?.has(g)) return true
1800
+ return false
1801
+ }
1802
+
1803
+ // Collision-proof snap ids (same scheme as hoistInvariantLoop's $__li).
1804
+ const used = new Set()
1805
+ const scanIds = (n) => {
1806
+ if (!Array.isArray(n)) return
1807
+ if (n[0] === 'local' && typeof n[1] === 'string' && n[1].startsWith('$__go')) {
1808
+ const t = n[1].slice(5); if (/^\d+$/.test(t)) used.add(+t)
1809
+ }
1810
+ for (let i = 0; i < n.length; i++) scanIds(n[i])
1811
+ }
1812
+ scanIds(fn)
1813
+ let idCounter = 0
1814
+ const freshId = () => { while (used.has(idCounter)) idCounter++; const id = idCounter++; used.add(id); return `$__go${id}` }
1815
+
1816
+ const chosen = new Map() // global → snap local
1817
+ for (const [g, c] of counts) {
1818
+ if ((c < 2 && !inLoop.has(g)) || !stablePtrGlobals.has(g) || ownWrites.has(g) || calleeWrites(g)) continue
1819
+ chosen.set(g, freshId())
1820
+ }
1821
+ if (!chosen.size) return
1822
+
1823
+ const replace = (n) => {
1824
+ if (!Array.isArray(n)) return
1825
+ for (let i = 1; i < n.length; i++) {
1826
+ const g = siteGlobal(n[i])
1827
+ if (g != null && chosen.has(g)) n[i] = ['local.get', chosen.get(g)]
1828
+ else replace(n[i])
1829
+ }
1830
+ }
1831
+ for (let i = bodyStart; i < fn.length; i++) replace(fn[i])
1832
+
1833
+ const decls = [], snaps = []
1834
+ for (const [g, name] of chosen) {
1835
+ decls.push(['local', name, 'i32'])
1836
+ snaps.push(['local.set', name, ['call', '$__ptr_offset', ['i64.reinterpret_f64', ['global.get', g]]]])
1837
+ }
1838
+ fn.splice(bodyStart, 0, ...decls, ...snaps)
1839
+ }
1840
+
1387
1841
  /**
1388
1842
  * Promote read-only globals to locals within each function.
1389
1843
  *
@@ -1408,7 +1862,7 @@ export function collectVolatileGlobals(funcs) {
1408
1862
  * @param {Map<string,string>} [globalTypes] - Optional: global name → wasm type ('i32'|'f64'|'i64'|'funcref')
1409
1863
  * @param {Set<string>} [volatileGlobals] - Optional: globals mutated outside `$__start` (see collectVolatileGlobals)
1410
1864
  */
1411
- export function promoteGlobals(fn, globalTypes, volatileGlobals) {
1865
+ export function promoteGlobals(fn, globalTypes, volatileGlobals, reachableWrites) {
1412
1866
  if (!Array.isArray(fn) || fn[0] !== 'func') return
1413
1867
  const bodyStart = findBodyStart(fn)
1414
1868
  if (bodyStart < 0) return
@@ -1416,8 +1870,8 @@ export function promoteGlobals(fn, globalTypes, volatileGlobals) {
1416
1870
  // Collect global.get counts, detect any global.set, and note whether the
1417
1871
  // function makes a call (a callee may mutate a volatile global between reads).
1418
1872
  const getCounts = new Map() // globalName → count
1419
- const written = new Set()
1420
- let hasCall = false
1873
+ const written = new Set(), callees = new Set()
1874
+ let hasCall = false, hasIndirect = false
1421
1875
 
1422
1876
  const scan = (node) => {
1423
1877
  if (!Array.isArray(node)) return
@@ -1431,7 +1885,8 @@ export function promoteGlobals(fn, globalTypes, volatileGlobals) {
1431
1885
  if (node[2]) scan(node[2])
1432
1886
  return
1433
1887
  }
1434
- if (op === 'call' || op === 'call_indirect') hasCall = true
1888
+ if (op === 'call' || op === 'return_call') { hasCall = true; if (typeof node[1] === 'string') callees.add(node[1]) }
1889
+ else if (op === 'call_indirect' || op === 'call_ref' || op === 'return_call_indirect') { hasCall = true; hasIndirect = true }
1435
1890
  for (let i = 1; i < node.length; i++) scan(node[i])
1436
1891
  }
1437
1892
 
@@ -1452,7 +1907,12 @@ export function promoteGlobals(fn, globalTypes, volatileGlobals) {
1452
1907
  for (const [gName, count] of getCounts) {
1453
1908
  if (count < 3 || written.has(gName)) continue
1454
1909
  // Unsound to cache a callee-mutable global across a call in this function.
1455
- if (hasCall && volatileGlobals && volatileGlobals.has(gName)) continue
1910
+ // With reachableWrites the test is exact per call edge (a global written
1911
+ // only by init stays promotable in functions whose call graph never
1912
+ // reaches init); without it, fall back to the coarse module-wide set.
1913
+ if (hasCall && (reachableWrites
1914
+ ? (hasIndirect || [...callees].some(c => reachableWrites.get(c)?.has(gName)))
1915
+ : volatileGlobals?.has(gName))) continue
1456
1916
  // Determine type: use provided map, or infer from context
1457
1917
  const type = globalTypes?.get(gName) || inferTypeFromContext(fn, gName, bodyStart)
1458
1918
  if (!type) continue // can't determine type, skip
@@ -1543,7 +2003,7 @@ function inferTypeFromContext(fn, gName, bodyStart) {
1543
2003
  * Pool entries sorted by usage descending, so hottest get lowest indices (1-byte LEB128).
1544
2004
  * Break-even: N ≥ 2 uses (pool cost: 11 B global decl + 2N bytes vs 9N original).
1545
2005
  *
1546
- * Mutates `funcs` in place; writes new global decls via `addGlobal(name, watString)`.
2006
+ * Mutates `funcs` in place; writes new global decls via `addGlobal(name, constLiteral)`.
1547
2007
  */
1548
2008
  export function hoistConstantPool(funcs, addGlobal) {
1549
2009
  const MIN_USES = 2
@@ -1574,7 +2034,7 @@ export function hoistConstantPool(funcs, addGlobal) {
1574
2034
  for (const [k] of sorted) {
1575
2035
  const name = `__fc${gId++}`
1576
2036
  const lit = k.slice(2)
1577
- addGlobal(name, `(global $${name} (mut f64) (f64.const ${lit}))`)
2037
+ addGlobal(name, lit)
1578
2038
  hoist.set(k, name)
1579
2039
  }
1580
2040
  if (!hoist.size) return
@@ -1623,7 +2083,12 @@ export function specializeMkptr(funcs, addFunc, parseWat) {
1623
2083
  '$__typed_idx': { params: ['i64', 'i32'], result: 'f64' },
1624
2084
  '$__str_idx': { params: ['i64', 'i32'], result: 'f64' },
1625
2085
  }
1626
- const MIN_USES = 5
2086
+ // 4 is the measured break-even: a specialized helper (trampoline / inline i64.const
2087
+ // template) costs ~12 B to define and saves ~2–4 B per site, so 4 sites amortize it.
2088
+ // Lower (3) net-inflates the watr self-host; 5 leaves 4-use combos on the table. The
2089
+ // sibling specializePtrBase threshold (20) is already optimal — its combos cluster far
2090
+ // above 20 (the ~2 k-site $__strBase relativization) with nothing in the 5–19 band.
2091
+ const MIN_USES = 4
1627
2092
 
1628
2093
  // Build literal-arg signature key for a call node. Returns null if no args are literal.
1629
2094
  // Key format: 'T:V' per literal arg, 'D' per dynamic; indexed by position.
@@ -1921,14 +2386,16 @@ export function sortStrPoolByFreq(funcs, strPoolRef, strDedupMap) {
1921
2386
  * @param cfg optional resolved config from resolveOptimize() — when omitted, all on.
1922
2387
  * @param globalTypes optional global name → wasm type map (for promoteGlobals)
1923
2388
  * @param volatileGlobals optional set of callee-mutable globals (see collectVolatileGlobals)
2389
+ * @param phase 'pre' (default, pre-watr leaf pass) or 'post' (re-run after watr) —
2390
+ * gates the passes that only pay off once watr has reshaped the IR.
1924
2391
  */
1925
- export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals) {
2392
+ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre', reachableWrites) {
1926
2393
  if (cfg && cfg.hoistPtrType === false &&
1927
2394
  cfg.hoistInvariantPtrOffset === false &&
1928
- cfg.hoistInvariantPtrOffsetLoop === false &&
2395
+ cfg.hoistInvariantLoop === false &&
2396
+ cfg.narrowLoopBound === false &&
1929
2397
  cfg.fusedRewrite === false &&
1930
2398
  cfg.hoistAddrBase === false &&
1931
- cfg.hoistInvariantCellLoads === false &&
1932
2399
  cfg.cseScalarLoad === false &&
1933
2400
  cfg.csePureExpr === false &&
1934
2401
  cfg.dropDeadZeroInit === false &&
@@ -1938,27 +2405,63 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals) {
1938
2405
  cfg.vectorizeLaneLocal === false) return
1939
2406
  if (!cfg || cfg.hoistPtrType !== false) hoistPtrType(fn)
1940
2407
  if (!cfg || cfg.hoistInvariantPtrOffset !== false) hoistInvariantPtrOffset(fn)
1941
- if (!cfg || cfg.hoistInvariantPtrOffsetLoop !== false) hoistInvariantPtrOffsetLoop(fn)
2408
+ // Before LICM: the snapped i32 bound is itself a hoistable hard-op subtree, so
2409
+ // an outer loop's LICM can lift it further when the bound is outer-invariant.
2410
+ if (!cfg || cfg.narrowLoopBound !== false) narrowLoopBound(fn)
2411
+ // Unified LICM (replaces hoistInvariantToInt32 / PtrOffsetLoop / CellLoads).
2412
+ // Run at both maturity points (idempotent): pre-fusedRewrite catches the raw
2413
+ // ToInt32/ptr-offset/arithmetic shapes; post-hoistAddrBase catches cell loads.
2414
+ if (!cfg || cfg.hoistInvariantLoop !== false) hoistInvariantLoop(fn)
1942
2415
  const counts = new Map()
1943
2416
  if (!cfg || cfg.fusedRewrite !== false) fusedRewrite(fn, counts)
1944
2417
  if (!cfg || cfg.hoistAddrBase !== false) hoistAddrBase(fn)
1945
- if (!cfg || cfg.hoistInvariantCellLoads !== false) hoistInvariantCellLoads(fn)
2418
+ if (!cfg || cfg.hoistInvariantLoop !== false) hoistInvariantLoop(fn)
1946
2419
  if (!cfg || cfg.cseScalarLoad !== false) cseScalarLoad(fn)
1947
- if (!cfg || cfg.csePureExpr !== false) csePureExpr(fn)
2420
+ if (!cfg || cfg.csePureExpr !== false) {
2421
+ if (cfg && (cfg.watr === true || typeof cfg.watr === 'object') && phase === 'post') csePureExprLoop(fn)
2422
+ else csePureExpr(fn)
2423
+ }
1948
2424
  if (!cfg || cfg.dropDeadZeroInit !== false) dropDeadZeroInit(fn)
1949
2425
  if (!cfg || cfg.deadStoreElim !== false) deadStoreElim(fn)
1950
- if (!cfg || cfg.promoteGlobals !== false) promoteGlobals(fn, globalTypes, volatileGlobals)
2426
+ if (!cfg || cfg.promoteGlobals !== false) promoteGlobals(fn, globalTypes, volatileGlobals, reachableWrites)
1951
2427
  // Vectorizer runs PRE-watr unless full watr is enabled (`watr: true`). For full watr,
1952
2428
  // defer to post — full passes (notably `inlineOnce` + the post-inline `propagate`
1953
2429
  // sweep) reshape the IR so much that pre-watr SIMD patterns get scrambled. Light
1954
2430
  // watr (or no watr) leaves the lane locals intact for vectorize to pattern-match,
1955
2431
  // and lets a non-trivial chunk of SIMD survive the propagate+fold pipeline.
1956
2432
  if (cfg && cfg.vectorizeLaneLocal === true) {
1957
- const fullWatr = cfg.watr === true
1958
- const runVectorizer = (fullWatr && cfg.__phase === 'post') || (!fullWatr && cfg.__phase !== 'post')
2433
+ const fullWatr = cfg.watr === true || typeof cfg.watr === 'object'
2434
+ const runVectorizer = (fullWatr && phase === 'post') || (!fullWatr && phase !== 'post')
1959
2435
  if (runVectorizer) vectorizeLaneLocal(fn)
1960
2436
  }
1961
2437
  if (!cfg || cfg.sortLocalsByUse !== false) sortLocalsByUse(fn, cfg && cfg.fusedRewrite !== false ? counts : null)
2438
+ // An optimizer pass that emits a malformed local — the class that otherwise dies
2439
+ // as an opaque watr "Duplicate/Unknown local $x" several phases on — is caught
2440
+ // here, pinned to the function and the bad name.
2441
+ if (DBG_IR) { const bad = verifyFn(fn); if (bad) throw new Error(`[ir verify] optimize produced invalid IR in ${fn[1]}: ${bad}`) }
2442
+ }
2443
+
2444
+ // The i32 form of an integer-valued f64 expression, or null. Used to push ToInt32
2445
+ // through a conditional and to collapse the f64 round-trip on integer `+`/`-`.
2446
+ // Lossless by construction: `convert_i32(X) → X`; integer `f64.const → i32.const`
2447
+ // (ToInt32); `f64.add/sub` of i32-valued operands → `i32.add/sub` (mod-2³² is a ring
2448
+ // homomorphism, and each i32±i32 < 2³² < 2⁵³ so the f64 op is exact). EXCLUDES `mul`
2449
+ // (products can exceed 2⁵³, so the f64 op loses precision and i32.mul wouldn't match)
2450
+ // and anything non-integer or unprovable. Address `local.tee`s inside operands are
2451
+ // preserved (kept as-is in the returned i32 tree).
2452
+ function toI32(n) {
2453
+ if (!Array.isArray(n)) return null
2454
+ const op = n[0]
2455
+ if ((op === 'f64.convert_i32_s' || op === 'f64.convert_i32_u') && n.length === 2) return n[1]
2456
+ // i32-range consts only: keeps every leaf within i32 so f64 add/sub of leaves stays exact
2457
+ // (< 2^53) and ToInt32-homomorphic. A larger const would round in f64.add or saturate in
2458
+ // trunc_sat differently from JS `|0`, breaking the fold.
2459
+ if (op === 'f64.const' && typeof n[1] === 'number' && (n[1] | 0) === n[1]) return ['i32.const', n[1]]
2460
+ if ((op === 'f64.add' || op === 'f64.sub') && n.length === 3) {
2461
+ const a = toI32(n[1]), b = toI32(n[2])
2462
+ if (a && b) return [op === 'f64.add' ? 'i32.add' : 'i32.sub', a, b]
2463
+ }
2464
+ return null
1962
2465
  }
1963
2466
 
1964
2467
  // Fused bottom-up walk applying three orthogonal pattern sets at each node:
@@ -1973,7 +2476,7 @@ function fusedRewrite(fn, counts) {
1973
2476
  if (Array.isArray(fn)) {
1974
2477
  for (let i = 0; i < fn.length; i++) {
1975
2478
  const c = fn[i]
1976
- if (Array.isArray(c)) fn[i] = walkRewrite(c, true, counts)
2479
+ if (Array.isArray(c)) fn[i] = walkRewrite(c, true, counts, null, null)
1977
2480
  }
1978
2481
  }
1979
2482
  return
@@ -1982,17 +2485,33 @@ function fusedRewrite(fn, counts) {
1982
2485
  const name = typeof fn[1] === 'string' ? fn[1] : null
1983
2486
  const skipInline = !!(name && (name.startsWith('$__ptr_') || name === '$__is_nullish' || name === '$__is_truthy' || name === '$__is_null'))
1984
2487
  const bodyStart = findBodyStart(fn)
2488
+ // i64 scratch allocator for the literal-eq inline: any-shaped operand is
2489
+ // tee'd once instead of duplicated. Decls splice in after the walk.
2490
+ const newDecls = []
2491
+ // pre+post phases both run this pass — continue numbering past any scratch
2492
+ // locals the earlier phase already declared, or the decls collide.
2493
+ let scratchN = 0
2494
+ for (let i = 2; i < fn.length; i++) {
2495
+ const d = fn[i]
2496
+ if (Array.isArray(d) && d[0] === 'local' && typeof d[1] === 'string') {
2497
+ const m = d[1].match(/^\$__eq[tf](\d+)$/)
2498
+ if (m) scratchN = Math.max(scratchN, +m[1] + 1)
2499
+ }
2500
+ }
2501
+ const freshI64 = () => { const n = `$__eqt${scratchN++}`; newDecls.push(['local', n, 'i64']); return n }
2502
+ const freshF64 = () => { const n = `$__eqf${scratchN++}`; newDecls.push(['local', n, 'f64']); return n }
1985
2503
  for (let i = bodyStart; i < fn.length; i++) {
1986
2504
  const c = fn[i]
1987
- if (Array.isArray(c)) fn[i] = walkRewrite(c, !skipInline, counts)
2505
+ if (Array.isArray(c)) fn[i] = walkRewrite(c, !skipInline, counts, freshI64, freshF64)
1988
2506
  }
2507
+ if (newDecls.length) fn.splice(bodyStart, 0, ...newDecls)
1989
2508
  }
1990
2509
 
1991
- function walkRewrite(node, doInline, counts) {
2510
+ function walkRewrite(node, doInline, counts, freshI64, freshF64) {
1992
2511
  if (!Array.isArray(node)) return node
1993
2512
  for (let i = 0; i < node.length; i++) {
1994
2513
  const c = node[i]
1995
- if (Array.isArray(c)) node[i] = walkRewrite(c, doInline, counts)
2514
+ if (Array.isArray(c)) node[i] = walkRewrite(c, doInline, counts, freshI64, freshF64)
1996
2515
  }
1997
2516
  const op = node[0]
1998
2517
  // Piggyback local-ref counting for sortLocalsByUse. `counts` may be undefined
@@ -2000,6 +2519,74 @@ function walkRewrite(node, doInline, counts) {
2000
2519
  if (counts && (op === 'local.get' || op === 'local.set' || op === 'local.tee') && typeof node[1] === 'string')
2001
2520
  counts.set(node[1], (counts.get(node[1]) || 0) + 1)
2002
2521
 
2522
+ // Generic-equality bit-eq fast path: $__eq's own first branch hoisted to the
2523
+ // site when both args duplicate cheaply (local.get / reinterpret of one).
2524
+ // Identical bits ⇒ equal-unless-canonical-NaN; static-literal dedup + SSO +
2525
+ // slice interning make the hit dominant in tree-walking code (tag compares),
2526
+ // so most sites skip the call. The else arm keeps the original call.
2527
+ if (doInline && op === 'call' && (node[1] === '$__eq' || node[1] === '$__str_eq')
2528
+ && node.length === 4 && !node._eqFast) {
2529
+ const cheap = (n) => Array.isArray(n) &&
2530
+ (n[0] === 'local.get' ||
2531
+ (n[0] === 'i64.reinterpret_f64' && Array.isArray(n[1]) && n[1][0] === 'local.get'))
2532
+ // i64.const whose bits decode to a CANONICAL interned string (STRING tag,
2533
+ // INTERN_BIT set, SSO/SLICE clear) — i.e. a static-literal operand.
2534
+ const internedLit = (n) => {
2535
+ // (i64.const 0x…) or its f64-carrier form (i64.reinterpret_f64 (f64.const nan:0x…))
2536
+ let tok = null
2537
+ if (Array.isArray(n) && n[0] === 'i64.const') tok = n[1]
2538
+ else if (Array.isArray(n) && n[0] === 'i64.reinterpret_f64' && Array.isArray(n[1])
2539
+ && n[1][0] === 'f64.const' && typeof n[1][1] === 'string' && n[1][1].startsWith('nan:'))
2540
+ tok = n[1][1].slice(4)
2541
+ if (tok == null) return false
2542
+ let v
2543
+ try { v = BigInt(tok) } catch { return false }
2544
+ if (v < 0n) v += 1n << 64n
2545
+ if (((v >> 47n) & 0xFn) !== 4n) return false
2546
+ return ((v >> 32n) & 0x6001n) === BigInt(STR_INTERN_BIT)
2547
+ }
2548
+ // Literal-vs-X inline: bit-eq → 1; X carrying the canonical aux pattern →
2549
+ // 0 (only a canonical string can content-equal a canonical literal, and
2550
+ // canonicals are deduped; every NON-string kind is ≠ a string under ===
2551
+ // as well, so answering 0 on the pattern is sound for ANY value). Slices,
2552
+ // SSO, fresh heap strings and NaN fall through to the call. This is what
2553
+ // makes `op === 'literal'` dispatch ladders cost ~3 ops per rung instead
2554
+ // of a helper call — the V8 interned-pointer-compare equivalent.
2555
+ const a = node[2], b = node[3]
2556
+ const lit = internedLit(b) ? b : internedLit(a) ? a : null
2557
+ const x = lit === b ? a : b
2558
+ if (lit && (cheap(x) || freshI64)) {
2559
+ node._eqFast = true
2560
+ // Cheap operands duplicate; anything else evaluates ONCE into an i64
2561
+ // scratch (tee in the first use), so the inline applies to un-hoisted
2562
+ // shapes like `node[0] === 'lit'` too.
2563
+ let first = x, reuse = x
2564
+ if (!cheap(x)) {
2565
+ const t = freshI64()
2566
+ first = ['local.tee', t, x]
2567
+ reuse = ['local.get', t]
2568
+ node[2] = lit === b ? reuse : lit
2569
+ node[3] = lit === b ? lit : reuse
2570
+ }
2571
+ const auxPat = ['i32.eq',
2572
+ ['i32.and', ['i32.wrap_i64', ['i64.shr_u', reuse, ['i64.const', 32]]], ['i32.const', 0x6001]],
2573
+ ['i32.const', STR_INTERN_BIT]]
2574
+ return ['if', ['result', 'i32'],
2575
+ ['i64.eq', first, lit],
2576
+ ['then', ['i32.const', 1]],
2577
+ ['else', ['if', ['result', 'i32'], auxPat,
2578
+ ['then', ['i32.const', 0]],
2579
+ ['else', node]]]]
2580
+ }
2581
+ if (node[1] === '$__eq' && cheap(a) && cheap(b)) {
2582
+ node._eqFast = true // pre+post phases both run this walk — wrap once
2583
+ return ['if', ['result', 'i32'],
2584
+ ['i64.eq', a, b],
2585
+ ['then', ['i64.ne', a, ['i64.const', NAN_BITS]]],
2586
+ ['else', node]]
2587
+ }
2588
+ }
2589
+
2003
2590
  // Inline-ptr-helpers: $__ptr_type / $__ptr_aux / $__is_nullish / $__is_null / $__is_truthy
2004
2591
  if (doInline && op === 'call' && node.length === 3 && typeof node[1] === 'string') {
2005
2592
  const fname = node[1]
@@ -2014,6 +2601,14 @@ function walkRewrite(node, doInline, counts) {
2014
2601
  && Array.isArray(node[2][1]) && node[2][1][0] === 'local.get') return ['i32.or',
2015
2602
  ['i64.eq', node[2], ['i64.const', NULL_BITS]],
2016
2603
  ['i64.eq', node[2], ['i64.const', UNDEF_BITS]]]
2604
+ // Expression-arg __is_truthy: evaluate once into an f64 scratch via tee —
2605
+ // the local.tee form below then expands inline (covers `(c = next()) || …`
2606
+ // and every condition the emitter didn't pre-hoist).
2607
+ if (fname === '$__is_truthy' && freshF64 && Array.isArray(node[2]) && node[2][0] === 'i64.reinterpret_f64'
2608
+ && Array.isArray(node[2][1])
2609
+ && node[2][1][0] !== 'local.get' && node[2][1][0] !== 'local.tee') {
2610
+ node[2] = ['i64.reinterpret_f64', ['local.tee', freshF64(), node[2][1]]]
2611
+ }
2017
2612
  if (fname === '$__is_truthy' && Array.isArray(node[2]) && node[2][0] === 'i64.reinterpret_f64'
2018
2613
  && Array.isArray(node[2][1]) && (node[2][1][0] === 'local.get' || node[2][1][0] === 'local.tee')) {
2019
2614
  // `local.tee $x SRC` evaluates SRC once, stores to $x, returns the value —
@@ -2025,16 +2620,21 @@ function walkRewrite(node, doInline, counts) {
2025
2620
  const lget = ['local.get', lname]
2026
2621
  const first = ref[0] === 'local.tee' ? ref : lget
2027
2622
  const bits = ['i64.reinterpret_f64', lget]
2623
+ // Mirror $__is_truthy (module/core.js) exactly: FIVE falsy patterns —
2624
+ // canonical NaN, null, undefined, the empty SSO string, AND boolean
2625
+ // false. Omitting FALSE made inlined `x || y` treat false as truthy.
2028
2626
  return ['if', ['result', 'i32'],
2029
2627
  ['f64.eq', first, lget],
2030
2628
  ['then', ['f64.ne', lget, ['f64.const', 0]]],
2031
2629
  ['else', ['i32.and',
2032
2630
  ['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']]]]]]
2631
+ ['i32.and',
2632
+ ['i64.ne', bits, ['i64.const', NAN_BITS]],
2633
+ ['i64.ne', bits, ['i64.const', NULL_BITS]]],
2634
+ ['i32.and',
2635
+ ['i64.ne', bits, ['i64.const', UNDEF_BITS]],
2636
+ ['i64.ne', bits, ['i64.const', '0x7FFA400000000000']]]],
2637
+ ['i64.ne', bits, ['i64.const', FALSE_BITS]]]]]
2038
2638
  }
2039
2639
  }
2040
2640
 
@@ -2078,6 +2678,65 @@ function walkRewrite(node, doInline, counts) {
2078
2678
  return a[1]
2079
2679
  }
2080
2680
 
2681
+ // Push ToInt32 through integer expressions and conditionals. The universal value model
2682
+ // computes integer `+`/`-` and `?:` in f64, then ToInt32-clamps — emitting
2683
+ // (select (i32.wrap_i64 (i64.trunc_sat_f64_s [local.tee T] X)) FALLBACK COND)
2684
+ // whose three arms all compute ToInt32(X). When X is an integer-valued f64 expression,
2685
+ // ToInt32(X) == its i32 form (exact); and ToInt32 distributes through a conditional:
2686
+ // ToInt32(if C A B) == if(result i32) C ToInt32(A) ToInt32(B).
2687
+ // Folding here drops the f64 round-trip AND turns int `s += a[i]` reductions and
2688
+ // `a[i] = cond ? … : …` conditional maps into pure i32 the vectorizer lifts (i32x4.add /
2689
+ // i32x4 bitselect). FALLBACK/COND (which recompute the same ToInt32 from T) are dropped.
2690
+ if (op === 'select' && node.length >= 4) {
2691
+ const v = node[1]
2692
+ 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) {
2693
+ let inner = v[1][1]
2694
+ if (Array.isArray(inner) && inner[0] === 'local.tee' && inner.length === 3) inner = inner[2]
2695
+ // ToInt32(if (result f64) C A B) → if (result i32) C toI32(A) toI32(B), when both arms are integer-valued.
2696
+ if (Array.isArray(inner) && inner[0] === 'if' && Array.isArray(inner[1]) && inner[1][0] === 'result' && inner[1][1] === 'f64'
2697
+ && Array.isArray(inner[3]) && inner[3][0] === 'then' && inner[3].length === 2
2698
+ && Array.isArray(inner[4]) && inner[4][0] === 'else' && inner[4].length === 2) {
2699
+ const t = toI32(inner[3][1]), e = toI32(inner[4][1])
2700
+ if (t && e) return ['if', ['result', 'i32'], inner[2], ['then', t], ['else', e]]
2701
+ }
2702
+ // ToInt32(integer-valued f64 expr) → its i32 form (covers (i32±i32)|0 sums and nests).
2703
+ const i = toI32(inner)
2704
+ if (i) return i
2705
+ }
2706
+ }
2707
+ // (i32.or X 0) / (i32.or 0 X) → X — drops the redundant source-level `|0` clamp left
2708
+ // after the fold above, so the accumulator update is a bare i32.add the recognizer matches.
2709
+ if (op === 'i32.or' && node.length === 3) {
2710
+ const a = node[1], b = node[2]
2711
+ if (Array.isArray(b) && b[0] === 'i32.const' && b[1] === 0) return a
2712
+ if (Array.isArray(a) && a[0] === 'i32.const' && a[1] === 0) return b
2713
+ }
2714
+
2715
+ // f64.CMP(convert_i32 A, convert_i32 B) → i32.CMP(A, B). Comparing two i32 values is
2716
+ // identical whether done in exact f64 or in i32 (the converts are lossless and
2717
+ // order-preserving), so an integer comparison over typed-array loads (reads are f64)
2718
+ // drops its f64 round-trip. eq/ne are sign-agnostic; ordered compares need matching
2719
+ // signedness; an integer comparand constant works for the signed case. Both operands
2720
+ // are kept, so any address `local.tee` inside them survives. Prerequisite for i32
2721
+ // conditional-lane vectorization (the mask becomes an i32x4 compare).
2722
+ if (op === 'f64.eq' || op === 'f64.ne' || op === 'f64.lt' || op === 'f64.gt' || op === 'f64.le' || op === 'f64.ge') {
2723
+ const base = op.slice(4)
2724
+ const cv = (x) => Array.isArray(x) && (x[0] === 'f64.convert_i32_s' || x[0] === 'f64.convert_i32_u') && x.length === 2 ? x : null
2725
+ const intK = (x) => Array.isArray(x) && x[0] === 'f64.const' && Number.isInteger(x[1]) && x[1] >= -2147483648 && x[1] <= 2147483647 ? x[1] : null
2726
+ const a = node[1], b = node[2], ca = cv(a), cb = cv(b)
2727
+ if (ca && cb) {
2728
+ const sa = ca[0] === 'f64.convert_i32_s', sb = cb[0] === 'f64.convert_i32_s'
2729
+ if (base === 'eq' || base === 'ne') return ['i32.' + base, ca[1], cb[1]]
2730
+ if (sa === sb) return ['i32.' + base + (sa ? '_s' : '_u'), ca[1], cb[1]]
2731
+ } else if (ca && ca[0] === 'f64.convert_i32_s') {
2732
+ const k = intK(b)
2733
+ if (k != null) return base === 'eq' || base === 'ne' ? ['i32.' + base, ca[1], ['i32.const', k]] : ['i32.' + base + '_s', ca[1], ['i32.const', k]]
2734
+ } else if (cb && cb[0] === 'f64.convert_i32_s') {
2735
+ const k = intK(a)
2736
+ if (k != null) return base === 'eq' || base === 'ne' ? ['i32.' + base, ['i32.const', k], cb[1]] : ['i32.' + base + '_s', ['i32.const', k], cb[1]]
2737
+ }
2738
+ }
2739
+
2081
2740
  // shl-distribute-over-add: (i32.shl (i32.add x (i32.const K)) (i32.const S))
2082
2741
  // → (i32.add (i32.shl x S) (i32.const K<<S)). Overflow-safe — both forms wrap
2083
2742
  // mod 2^32 identically. Unlocks memarg offset= folding for biquad-style
@@ -2258,7 +2917,8 @@ export function sortLocalsByUse(fn, precomputedCounts) {
2258
2917
  if (!Array.isArray(fn) || fn[0] !== 'func') return
2259
2918
  const localIdxs = []
2260
2919
  let totalDecls = 0
2261
- for (let i = 2; i < fn.length; i++) {
2920
+ let i
2921
+ for (i = 2; i < fn.length; i++) {
2262
2922
  const c = fn[i]
2263
2923
  if (!Array.isArray(c)) continue
2264
2924
  if (c[0] === 'param' || c[0] === 'result') { totalDecls++; continue }