jz 0.6.0 → 0.8.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 (63) hide show
  1. package/README.md +99 -72
  2. package/bench/README.md +253 -100
  3. package/bench/bench.svg +58 -81
  4. package/cli.js +85 -12
  5. package/dist/interop.js +1 -0
  6. package/dist/jz.js +6196 -0
  7. package/index.d.ts +126 -0
  8. package/index.js +222 -35
  9. package/interop.js +240 -141
  10. package/layout.js +34 -18
  11. package/module/array.js +99 -111
  12. package/module/collection.js +124 -30
  13. package/module/console.js +1 -1
  14. package/module/core.js +163 -19
  15. package/module/json.js +3 -3
  16. package/module/math.js +162 -3
  17. package/module/number.js +268 -13
  18. package/module/object.js +37 -5
  19. package/module/regex.js +8 -7
  20. package/module/simd.js +37 -5
  21. package/module/string.js +203 -168
  22. package/module/typedarray.js +565 -112
  23. package/package.json +26 -7
  24. package/src/abi/string.js +29 -29
  25. package/src/ast.js +19 -2
  26. package/src/autoload.js +3 -0
  27. package/src/compile/analyze-scans.js +174 -11
  28. package/src/compile/analyze.js +101 -4
  29. package/src/compile/cse-load.js +200 -0
  30. package/src/compile/emit-assign.js +82 -20
  31. package/src/compile/emit.js +592 -51
  32. package/src/compile/index.js +504 -89
  33. package/src/compile/infer.js +36 -1
  34. package/src/compile/loop-divmod.js +109 -0
  35. package/src/compile/loop-model.js +91 -0
  36. package/src/compile/loop-square.js +102 -0
  37. package/src/compile/narrow.js +275 -41
  38. package/src/compile/peel-stencil.js +215 -0
  39. package/src/compile/plan/advise.js +55 -1
  40. package/src/compile/plan/common.js +29 -0
  41. package/src/compile/plan/index.js +8 -1
  42. package/src/compile/plan/inline.js +180 -22
  43. package/src/compile/plan/literals.js +313 -24
  44. package/src/compile/plan/scope.js +115 -39
  45. package/src/compile/program-facts.js +21 -2
  46. package/src/ctx.js +96 -19
  47. package/src/helper-counters.js +137 -0
  48. package/src/ir.js +157 -20
  49. package/src/kind-traits.js +34 -3
  50. package/src/kind.js +79 -4
  51. package/src/op-policy.js +5 -2
  52. package/src/ops.js +119 -0
  53. package/src/optimize/index.js +1274 -151
  54. package/src/optimize/recurse.js +182 -0
  55. package/src/optimize/vectorize.js +4187 -253
  56. package/src/prepare/index.js +75 -14
  57. package/src/prepare/lift-iife.js +149 -0
  58. package/src/reps.js +6 -2
  59. package/src/static.js +9 -0
  60. package/src/type.js +63 -51
  61. package/src/wat/assemble.js +286 -21
  62. package/src/widen.js +21 -0
  63. package/src/wat/optimize.js +0 -3760
@@ -29,13 +29,15 @@
29
29
 
30
30
  import { LAYOUT, ctx } from '../ctx.js'
31
31
  import { VAL } from '../reps.js'
32
- import { findBodyStart, buildRefcount, nextLocalId, verifyFn } from '../ir.js'
32
+ import { findBodyStart, buildRefcount, nextLocalId, verifyFn, isPureIR, f64Range, I32_MIN, I32_MAX } from '../ir.js'
33
33
 
34
34
  // Debug-mode IR structural check (JZ_DEBUG_INVARIANTS=1). Zero production cost.
35
35
  const DBG_IR = typeof process !== 'undefined' && process.env?.JZ_DEBUG_INVARIANTS === '1'
36
- import { T } from '../ast.js'
36
+ import { T, isLeaf, stableKey } from '../ast.js'
37
37
  import { vectorizeLaneLocal } from './vectorize.js'
38
- import { nanPrefixHex, atomNanHex, STR_INTERN_BIT } from '../../layout.js'
38
+ import { recursionUnroll } from './recurse.js'
39
+ export { SIMD_PINNED } from './vectorize.js'
40
+ import { nanPrefixHex, atomNanHex, STR_INTERN_BIT, ptrBits, i64Hex, PTR, TYPED_ELEM_CODE, TYPED_ELEM_VIEW_FLAG } from '../../layout.js'
39
41
 
40
42
  const MEMOP = /^[fi](32|64)\.(load|store)(\d+(_[su])?)?$/
41
43
  const NAN_BITS = nanPrefixHex()
@@ -59,14 +61,14 @@ const FALSE_BITS = atomNanHex(4)
59
61
  * 3 — level 2 + larger array/hash initial caps + `hoistConstantPool` off
60
62
  * (inline `f64.const` over mutable globals); trades size for speed.
61
63
  *
62
- * String aliases (the size↔speed tradeoff lives entirely in the unroll/scalar
63
- * knobs; watr is on for all three):
64
- * 'size' — loop/const unroll + lane vectorization off, tight scalar-replacement
65
- * caps. Smallest wasm.
66
- * 'balanced' — the default (= level 2).
67
- * 'speed' — full nested unroll + lane vectorization (= level 3).
64
+ * String presets (the size↔speed tradeoff lives entirely in the unroll/scalar
65
+ * knobs; watr is on for both):
66
+ * 'size' — loop/const unroll + lane vectorization off, tight scalar-replacement
67
+ * caps. Smallest wasm.
68
+ * 'speed' — full nested unroll + lane vectorization (= level 3).
69
+ * The default (level 2) has no string name — omit `optimize` or pass `2`.
68
70
  *
69
- * # Two-layer contract (this file vs src/wat/optimize.js)
71
+ * # Two-layer contract (this file vs watr/optimize)
70
72
  * Both layers walk the same S-expression IR; the boundary is KNOWLEDGE, not
71
73
  * representation:
72
74
  * - THIS layer owns every pass that needs jz semantics — NaN-box layout
@@ -74,7 +76,7 @@ const FALSE_BITS = atomNanHex(4)
74
76
  * func nodes (cseScalarLoad's cseLoadBases whitelist), loop shapes as emit
75
77
  * produces them (narrowLoopBound, hoistInvariantLoop, vectorizeLaneLocal),
76
78
  * and ctx-derived module facts (hoistGlobalPtrOffset's typed-global set).
77
- * - wat/optimize.js owns generic structural rewrites — const folding,
79
+ * - watr/optimize owns generic structural rewrites — const folding,
78
80
  * copy-prop, branch/DCE/vacuum, dedupe, treeshake, and inlineOnce. One
79
81
  * deliberate exception: guardRefine lives there despite NaN-box knowledge,
80
82
  * because the dead tag-dispatch shapes it folds only EXIST after that
@@ -98,8 +100,10 @@ export const PASS_NAMES = [
98
100
  'hoistGlobalPtrOffset', // stable typed GLOBALS: __ptr_offset resolve → once per function (post-watr, module-level)
99
101
  'fusedRewrite', // peephole + ptr-helper inline + memarg fold
100
102
  'hoistAddrBase',
103
+ 'boolConvertToSelect', // f64 ± (cond?1:0) → branchless select (kills i32↔f64 domain cross on recurrences)
101
104
  'cseScalarLoad',
102
105
  'csePureExpr',
106
+ 'unswitchTypedParamLoop', // Float64Array param loop-unswitch → base-hoisted f64.load/store fast path (vectorizes)
103
107
  'dropDeadZeroInit',
104
108
  'deadStoreElim',
105
109
  'promoteGlobals', // read-only global.get → local for multi-read globals
@@ -113,6 +117,7 @@ export const PASS_NAMES = [
113
117
  'smallConstForUnroll',
114
118
  'nestedSmallConstForUnroll',
115
119
  'vectorizeLaneLocal', // SIMD-128 lift for lane-pure typed-array loops
120
+ 'recursionUnroll', // inline a single non-tail self-call to depth N (tree-recursion call-overhead)
116
121
  'arenaRewind', // per-call heap rewind for no-arg scalar allocator kernels
117
122
  'treeshake',
118
123
  'jsstring', // boundary opt-in: flip exported string params to externref
@@ -127,7 +132,9 @@ const LEVEL_PRESETS = Object.freeze({
127
132
  // force 'light' mode here (inline / inlineOnce / coalesce all off) to dodge the
128
133
  // W1a/W1b miscompiles; watr 4.6.9 fixes both, and the L2 default now runs the full
129
134
  // watr pipeline. `inline` stays off by watr's own default — opt-in only.
130
- 2: Object.freeze({ ...ALL_ON, nestedSmallConstForUnroll: 'auto' }),
135
+ // boolConvertToSelect off at the default level: it's a latency-for-size trade (adds a
136
+ // const + op per site) that only pays off on serial recurrences — speed-tier only.
137
+ 2: Object.freeze({ ...ALL_ON, nestedSmallConstForUnroll: 'auto', boolConvertToSelect: false, recursionUnroll: false }),
131
138
  // L3/'speed' trades a bit of heap headroom for fewer __arr_grow / __hash growth
132
139
  // cycles. arrayMinCap=16 means `[]` and `new Array()` skip the first two doublings
133
140
  // (0→2→4→8→16); hashSmallInitCap=8 keeps per-object __dyn_props at the same load
@@ -143,18 +150,30 @@ const LEVEL_PRESETS = Object.freeze({
143
150
  // closures). Inline `f64.const` is the minimal lowering: V8 CSEs identical
144
151
  // constants for free. Measured −3% on jessie parse for +14% binary — exactly
145
152
  // the size↔speed trade 'speed' exists to make.
146
- 3: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8 }),
147
- // 'balanced' = level 2; 'size' tightens scalar/unroll caps; 'speed' = level 3.
148
- balanced: Object.freeze({ ...ALL_ON, nestedSmallConstForUnroll: 'auto' }),
153
+ 3: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8, reduceUnroll: true, relaxedSimd: true, inlineFns: true, rotateLoops: true }),
154
+ // 'size' tightens scalar/unroll caps; 'speed' = level 3. There is no 'balanced'
155
+ // preset — it was a pure synonym for the default level 2 (omit `optimize` or pass 2).
149
156
  size: Object.freeze({
150
157
  ...ALL_ON,
151
158
  smallConstForUnroll: false, nestedSmallConstForUnroll: false, vectorizeLaneLocal: false, splitCharScan: false,
159
+ recursionUnroll: false, // body tripling is a size regression — speed-only
160
+
161
+ boolConvertToSelect: false, // adds a const + op per site — speed-only latency trade
152
162
  devirtIndirect: false, // guards + duplicated args grow bytes — speed-only trade
153
163
  internStrings: false, // the intern index costs ~16 B per eligible literal — speed-only trade
154
164
  scalarTypedLoopUnroll: 4, scalarTypedNestedUnroll: 8, scalarTypedArrayLen: 8,
155
165
  }),
156
166
  // 'speed' === level 3: full watr (inlining on) + L3 cap/hash tuning, pool off.
157
- speed: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8 }),
167
+ // reduceUnroll: vectorize reductions with N independent accumulators (ILP/latency
168
+ // hiding, ~3x on dot/FIR sums) — a size↔speed trade like the pool-off above, so
169
+ // speed-only; level 2 / balanced / size keep the single-accumulator reduce.
170
+ // relaxedSimd: fold f64x2 dot-pairs to f64x2.relaxed_madd (single fused VFMADD,
171
+ // one rounding) — faster + more accurate, but the fused result diverges bit-for-bit
172
+ // from the non-fused JS/native reference (bench `fma` parity class). speed-only.
173
+ // (The stencil + outer-strip vectorizers are NOT level-gated here: they're bit-exact pure wins
174
+ // like the base lane vectorizer, so they run whenever it does — default-on at level 2+ via
175
+ // `cfg.experimentalStencil !== false` at the call site, not a speed-only size/precision trade.)
176
+ speed: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8, reduceUnroll: true, relaxedSimd: true, inlineFns: true, rotateLoops: true }),
158
177
  })
159
178
 
160
179
  /**
@@ -163,7 +182,7 @@ const LEVEL_PRESETS = Object.freeze({
163
182
  * resolveOptimize(undefined | true) → level 2 stable defaults
164
183
  * resolveOptimize(false | 0) → all off
165
184
  * resolveOptimize(1 | 2 | 3) → preset for that level
166
- * resolveOptimize('size' | 'speed' | 'balanced') named alias preset
185
+ * resolveOptimize('size' | 'speed') → named preset ('speed' = level 3)
167
186
  * resolveOptimize({ level: 1, watr: true }) → level 1 base, with watr forced on
168
187
  * resolveOptimize({ level: 'size', vectorizeLaneLocal: true }) → 'size' base, override
169
188
  * resolveOptimize({ hoistAddrBase: false }) → level 2 base, hoistAddrBase off
@@ -192,6 +211,11 @@ export function resolveOptimize(opt) {
192
211
  }
193
212
  // Preserve non-pass tuning keys (e.g. plan.js thresholds)
194
213
  for (const k of Object.keys(opt)) if (!PASS_NAMES.includes(k)) out[k] = opt[k]
214
+ // noSimd: suppress EVERY jz-emitted v128 — both the lane vectorizer AND the SLP
215
+ // store-pair packer. First-class here so `{ level:'speed', noSimd:true }` is a TRUE
216
+ // scalar baseline whether passed nested or via the top-level opts.noSimd flag; the
217
+ // SIMD-vs-scalar correctness oracles depend on it actually disabling SLP.
218
+ if (out.noSimd) { out.vectorizeLaneLocal = false; out.experimentalSlp = false }
195
219
  return out
196
220
  }
197
221
  return { ...ALL_ON }
@@ -390,12 +414,39 @@ function regionTrackCSE(fn, { matchSite, localPrefix, localType }) {
390
414
  * Must run AFTER fusedRewrite — relies on shl-distribution + assoc-lift +
391
415
  * foldMemargOffsets having normalized the base shape.
392
416
  */
417
+ // Pure i32 ops whose value is a function of locals/consts alone — no memory read,
418
+ // no call, no global. A subscript expression built only from these is invariant
419
+ // between two sites as long as none of its local deps is rewritten between them,
420
+ // so CSE-ing the WHOLE address (base + shl(idx)) is value-safe — even when `idx`
421
+ // is a compound stencil offset like `(i32.sub (i32.add idx W) 1)` for `arr[idx+W-1]`.
422
+ const PURE_I32_ADDR_OPS = new Set([
423
+ 'i32.add', 'i32.sub', 'i32.mul', 'i32.shl', 'i32.shr_s', 'i32.shr_u',
424
+ 'i32.and', 'i32.or', 'i32.xor', 'i32.wrap_i64',
425
+ ])
426
+ // Serialize a pure-i32 subscript to a stable key, accumulating its local deps.
427
+ // Returns null if any leaf isn't a local.get / i32.const / pure-i32 op (a load,
428
+ // call, or global.get could change between sites — not CSE-safe by local tracking).
429
+ function pureI32AddrKey(node, deps) {
430
+ if (!Array.isArray(node)) return null
431
+ const op = node[0]
432
+ if (op === 'local.get' && typeof node[1] === 'string') { deps.add(node[1]); return `$${node[1]}` }
433
+ if (op === 'i32.const' && typeof node[1] === 'number') return `#${node[1]}`
434
+ if (!PURE_I32_ADDR_OPS.has(op)) return null
435
+ let key = op + '('
436
+ for (let i = 1; i < node.length; i++) {
437
+ const sub = pureI32AddrKey(node[i], deps)
438
+ if (sub == null) return null
439
+ key += sub + ','
440
+ }
441
+ return key + ')'
442
+ }
443
+
393
444
  export function hoistAddrBase(fn) {
394
445
  return regionTrackCSE(fn, {
395
446
  matchSite(node) {
396
447
  if (node[0] !== 'i32.add' || node.length !== 3) return null
397
448
  const a = node[1], b = node[2]
398
- // Two orderings: (add (get A) (shl (get B) (const K))) or (add (shl …) (get A))
449
+ // Two orderings: (add (get A) (shl IDX (const K))) or (add (shl …) (get A))
399
450
  let baseGet, shlNode
400
451
  if (Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' &&
401
452
  Array.isArray(b) && b[0] === 'i32.shl' && b.length === 3) {
@@ -405,15 +456,88 @@ export function hoistAddrBase(fn) {
405
456
  baseGet = b; shlNode = a
406
457
  } else return null
407
458
  const idx = shlNode[1], shamt = shlNode[2]
408
- if (!Array.isArray(idx) || idx[0] !== 'local.get' || typeof idx[1] !== 'string') return null
409
459
  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]] }
460
+ // idx may be a plain `local.get` (the original biquad case) or any compound
461
+ // pure-i32 subscript (stencil neighbour `arr[idx+W-1]`); both CSE the same way.
462
+ const deps = new Set([baseGet[1]])
463
+ const idxKey = pureI32AddrKey(idx, deps)
464
+ if (idxKey == null) return null
465
+ return { key: `${baseGet[1]}|${idxKey}|${shamt[1]}`, deps: [...deps] }
411
466
  },
412
467
  localPrefix: 'ab',
413
468
  localType: 'i32',
414
469
  })
415
470
  }
416
471
 
472
+ // wasm comparison ops — each yields an i32 that is exactly 0 or 1.
473
+ const BOOL_RESULT_OPS = new Set([
474
+ 'i32.eqz', 'i64.eqz',
475
+ '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',
476
+ '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',
477
+ 'f32.eq', 'f32.ne', 'f32.lt', 'f32.gt', 'f32.le', 'f32.ge',
478
+ 'f64.eq', 'f64.ne', 'f64.lt', 'f64.gt', 'f64.le', 'f64.ge',
479
+ ])
480
+
481
+ /**
482
+ * `f64 ± (cond ? 1 : 0)` → branchless f64 `select`, killing the i32↔f64 domain cross.
483
+ *
484
+ * `err = old - (old >= t)` and friends compile to `f64.sub(X, f64.convert_i32_s(cmp))`.
485
+ * The convert (cvtsi2sd) round-trips the comparison result out of a GPR back into an
486
+ * XMM register — a domain-crossing op that sits ON the value's def chain. In the
487
+ * per-pixel error-diffusion sweeps (Floyd–Steinberg / Atkinson / JJN) and scalar IIR
488
+ * thresholds this chain is the loop-carried critical path, so that one cross roughly
489
+ * doubles the per-step latency (V8 keeps the JS threshold entirely in the FP domain).
490
+ *
491
+ * `X - (B?1:0) ≡ (B ? X-1 : X) ≡ select(X-1, X, B)` (likewise `+` → `select(X+1, X, B)`),
492
+ * which never leaves the f64 domain. `select` evaluates BOTH arms, so X must be a
493
+ * side-effect-free duplicable leaf (a `local.get`/const); B is the i32 condition,
494
+ * evaluated once (exactly as the convert did). A pure win on latency-bound recurrences;
495
+ * speed-gated (it adds a const + an arithmetic op — a size↔speed trade) — off at 'size'.
496
+ */
497
+ function boolConvertToSelect(fn) {
498
+ if (!Array.isArray(fn) || fn[0] !== 'func') return
499
+ // Pass 1 — a local whose SOLE definition is a comparison carries a value ∈ {0,1};
500
+ // `err = old - on` (on reused by putBW) reaches us as `convert(local.get $on)`.
501
+ // A param is EXCLUDED even if reassigned once by a comparison: its incoming arg is
502
+ // unconstrained, so a read before the reassignment isn't 0/1. (A plain local read
503
+ // before its def is safe — wasm zero-inits it to 0 = false, which select preserves.)
504
+ const params = new Set()
505
+ for (let i = 2; i < fn.length; i++) if (Array.isArray(fn[i]) && fn[i][0] === 'param') params.add(fn[i][1])
506
+ const defCount = new Map(), defIsCmp = new Map()
507
+ const scan = (n) => {
508
+ if (!Array.isArray(n)) return
509
+ if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') {
510
+ defCount.set(n[1], (defCount.get(n[1]) || 0) + 1)
511
+ const cmp = Array.isArray(n[2]) && BOOL_RESULT_OPS.has(n[2][0])
512
+ defIsCmp.set(n[1], (defIsCmp.has(n[1]) ? defIsCmp.get(n[1]) : true) && cmp)
513
+ }
514
+ for (let i = 1; i < n.length; i++) scan(n[i])
515
+ }
516
+ scan(fn)
517
+ const boolLocals = new Set()
518
+ for (const [name, c] of defCount) if (c === 1 && defIsCmp.get(name) && !params.has(name)) boolLocals.add(name)
519
+
520
+ const isBool01 = (n) => Array.isArray(n) &&
521
+ (BOOL_RESULT_OPS.has(n[0]) || (n[0] === 'local.get' && boolLocals.has(n[1])))
522
+ const dup = (n) => Array.isArray(n) ? n.map(dup) : n
523
+
524
+ // Pass 2 — bottom-up rewrite.
525
+ const rewrite = (n) => {
526
+ if (!Array.isArray(n)) return n
527
+ for (let i = 1; i < n.length; i++) n[i] = rewrite(n[i])
528
+ if ((n[0] === 'f64.sub' || n[0] === 'f64.add') && n.length === 3) {
529
+ const conv = (m) => Array.isArray(m) && (m[0] === 'f64.convert_i32_s' || m[0] === 'f64.convert_i32_u') && isBool01(m[1])
530
+ // `X - bool`, `X + bool`, or (add is commutative) `bool + X`.
531
+ let X = null, B = null
532
+ if (conv(n[2]) && isLeaf(n[1])) { X = n[1]; B = n[2][1] }
533
+ else if (n[0] === 'f64.add' && conv(n[1]) && isLeaf(n[2])) { X = n[2]; B = n[1][1] }
534
+ if (X) return ['select', [n[0], dup(X), ['f64.const', 1]], dup(X), B]
535
+ }
536
+ return n
537
+ }
538
+ rewrite(fn)
539
+ }
540
+
417
541
  /**
418
542
  * Hoist `(call $__ptr_offset (local.get $X))` to a function-entry snapshot
419
543
  * when X is an f64-NaN-boxed parameter that's never reassigned and only ever
@@ -433,6 +557,38 @@ export function hoistAddrBase(fn) {
433
557
  // loop-invariant __jss_length in the same loop condition CAN hoist).
434
558
  const SAFE_OFFSET_CALLS = new Set(['$__ptr_offset', '$__ptr_type', '$__ptr_aux', '$__len', '$__jss_length', '$__jss_charCodeAt'])
435
559
 
560
+ // wasm comparison-op mantissas (the part after the `.`): they yield i32 regardless of
561
+ // operand width (i64.eq, f64.lt, i32.ge_s, …). `eq`/`ne` are sign-agnostic; the ordered
562
+ // compares carry `_s`/`_u` for the integer types and none for f64. Used by resultType to
563
+ // type a hoisted subtree by its root op. A Set membership test, NOT a regex
564
+ // (`/^(eq|ne|lt|gt|le|ge)(_[su])?$/`): the regex mis-anchored under self-host −O2 — `nearest`
565
+ // (the f64.nearest mantissa, from Math.round) starts with `ne`, and the embedded −O2 build
566
+ // matched it as a comparison → the LICM hoist local got typed i32, so `local.set $__li
567
+ // (f64.nearest …)` emitted invalid wasm (f64 into i32) only in the kernel. Explicit string
568
+ // membership is both self-host-robust and cheaper in this LICM-hot path.
569
+ const CMP_MANTISSA = new Set([
570
+ 'eqz', 'eq', 'ne', 'lt', 'gt', 'le', 'ge',
571
+ 'lt_s', 'lt_u', 'gt_s', 'gt_u', 'le_s', 'le_u', 'ge_s', 'ge_u',
572
+ ])
573
+
574
+ // Calls that don't modify EXISTING heap memory: they may allocate (bump the heap
575
+ // pointer) or do tag dispatch, but they never write to an address a hoisted
576
+ // __typed_idx/__str_idx element read would revisit. Their presence must not
577
+ // block readonly-mem-call LICM (else any `s += unknown` — which dispatches via
578
+ // __is_str_key/__str_concat — would pin every invariant array element in-loop:
579
+ // the jagged-array `grid[i][j]` deopt).
580
+ const NON_MUTATING_CALLS = new Set(['$__is_str_key', '$__str_concat', '$__to_num', '$__to_str', '$__str_byteLen'])
581
+
582
+ // Read-only HEAP-MEMORY calls: like SAFE_OFFSET_CALLS but they read element
583
+ // storage that a direct f64.store/i32.store in the loop could alias. Safe to
584
+ // hoist only when the loop has no mutating call AND no direct store at all (we
585
+ // can't do alias analysis at WAT level). __typed_idx/__str_idx read arr[i] /
586
+ // s[i]; plain-array element writes go through calls (caught by hasUnsafeCall),
587
+ // and typed-array writes are direct stores (caught by hasDirectStore) — so the
588
+ // guard covers both. This is what lets LICM hoist `grid[i]` out of a read-only
589
+ // `for(j) { ... grid[i][j] ... }` inner loop (the jagged-array deopt).
590
+ const READONLY_MEM_CALLS = new Set(['$__typed_idx', '$__str_idx'])
591
+
436
592
  export function hoistInvariantPtrOffset(fn) {
437
593
  if (!Array.isArray(fn) || fn[0] !== 'func') return
438
594
  const bodyStart = findBodyStart(fn)
@@ -550,6 +706,22 @@ const HARD_OPS = new Set([
550
706
  ])
551
707
  const hasHardOp = (n) => Array.isArray(n) && (HARD_OPS.has(n[0]) || n.some((c, i) => i > 0 && hasHardOp(c)))
552
708
 
709
+ // The inline typed-array base decode `(i32.wrap_i64 (i64.and (i64.reinterpret_f64
710
+ // (local|global X)) 0xFFFFFFFF))` — what `typedBase` emits for a NaN-boxed pointer.
711
+ // V8's wasm tier does NOT reliably LICM this i64 reinterpret chain, and it carries no
712
+ // HARD_OP, so without this it stays per-element inside the loop. It is the typed-read
713
+ // equivalent of the `__ptr_offset` call (a HARD_OP) that hoistGlobalPtrOffset hoists at
714
+ // function scope; admitting it here also covers a pointer reassigned ELSEWHERE in the
715
+ // function (the ping-pong double-buffer `a = b` in wireworld / any CA), where the base
716
+ // is invariant within each loop but not function-wide.
717
+ const isPtrBaseDecode = (n) =>
718
+ Array.isArray(n) && n[0] === 'i32.wrap_i64' && n.length === 2 &&
719
+ Array.isArray(n[1]) && n[1][0] === 'i64.and' && n[1].length === 3 &&
720
+ Array.isArray(n[1][2]) && n[1][2][0] === 'i64.const' &&
721
+ (typeof n[1][2][1] === 'string' ? Number(n[1][2][1]) : n[1][2][1]) === LAYOUT.OFFSET_MASK &&
722
+ Array.isArray(n[1][1]) && n[1][1][0] === 'i64.reinterpret_f64' && n[1][1].length === 2 &&
723
+ Array.isArray(n[1][1][1]) && (n[1][1][1][0] === 'local.get' || n[1][1][1][0] === 'global.get')
724
+
553
725
  const PURE_LICM_OPS = new Set([
554
726
  'f64.add', 'f64.sub', 'f64.mul', 'f64.div', 'f64.neg', 'f64.abs', 'f64.sqrt',
555
727
  'f64.min', 'f64.max', 'f64.ceil', 'f64.floor', 'f64.trunc', 'f64.nearest', 'f64.copysign',
@@ -567,6 +739,136 @@ const PURE_LICM_OPS = new Set([
567
739
  'f64.promote_f32', 'f32.demote_f64', 'select',
568
740
  ])
569
741
 
742
+ // Resolve a load/store address back to the single typed-array PARAM it derives from — through
743
+ // `local.get`, the arithmetic in PURE_LICM_OPS, and single-def snap locals ($__li/$__ab) — or
744
+ // null if not exactly one / unprovable (a multi-def or unknown local in the address). Built once
745
+ // per function over the proven-distinct `distinctParams` set; the alias substrate both LICM
746
+ // passes query to hoist a read-only input load across a distinct-buffer store (raytrace's spheres
747
+ // vs framebuffer — the alias-analysis LICM rust/clang get for free).
748
+ function buildBaseParamOf(fn, bodyStart, distinctParams) {
749
+ if (!distinctParams) return () => null
750
+ const paramNames = new Set()
751
+ for (let i = 2; i < bodyStart; i++)
752
+ if (Array.isArray(fn[i]) && fn[i][0] === 'param' && typeof fn[i][1] === 'string') paramNames.add(fn[i][1])
753
+ const singleDef = new Map(), defCount = new Map()
754
+ const scanDefs = (n) => {
755
+ if (!Array.isArray(n)) return
756
+ if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') {
757
+ defCount.set(n[1], (defCount.get(n[1]) || 0) + 1); singleDef.set(n[1], n[2]); scanDefs(n[2]); return
758
+ }
759
+ for (let i = 1; i < n.length; i++) scanDefs(n[i])
760
+ }
761
+ for (let i = bodyStart; i < fn.length; i++) scanDefs(fn[i])
762
+ for (const [k, c] of defCount) if (c > 1) singleDef.delete(k) // multi-def → can't trust the resolution
763
+ return (addr) => {
764
+ const found = new Set(); const seen = new Set(); let bad = false
765
+ const walk = (n) => {
766
+ if (bad || !Array.isArray(n)) return
767
+ if (n[0] === 'local.get' && typeof n[1] === 'string') {
768
+ if (paramNames.has(n[1])) found.add(n[1])
769
+ else if (singleDef.has(n[1]) && !seen.has(n[1])) { seen.add(n[1]); walk(singleDef.get(n[1])) }
770
+ else bad = true // a written/unknown local in the address → base unprovable
771
+ return
772
+ }
773
+ for (let i = 1; i < n.length; i++) walk(n[i])
774
+ }
775
+ walk(addr)
776
+ return !bad && found.size === 1 ? [...found][0] : null
777
+ }
778
+ }
779
+
780
+ // Per-loop invariance/purity analysis — the single proven predicate both LICM passes share.
781
+ // Scans the loop into an effect summary (locals/globals it writes, cells/buffers it stores to,
782
+ // whether it has any call / unsafe call / direct store / v128 op), then closes `pureGiven(node,
783
+ // bound)` over it: true iff `node` is side-effect-free AND loop-invariant, given that the locals
784
+ // in `bound` are private to the candidate (a `local.get` of a bound local reads the in-subtree
785
+ // teed invariant; a free `local.get` must be unwritten by the loop). Memory leaves are admitted
786
+ // only under the summary: a `$__cell_`/distinct-param load iff no aliasing store + no call; a
787
+ // SAFE_OFFSET/READONLY_MEM call iff no unsafe call (+ no direct store for heap reads).
788
+ function loopInvariance(loopNode, { distinctParams, baseParamOf }) {
789
+ const locals = new Set(), globals = new Set(), storedCells = new Set(), storedBases = new Set()
790
+ let hasUnsafeCall = false, hasAnyCall = false, hasDirectStore = false, hasV128 = false
791
+ const scan = (node) => {
792
+ if (!Array.isArray(node)) return
793
+ const op = node[0]
794
+ // A vectorized loop (lane/v128 ops) is already register-tight and hand-tuned;
795
+ // extra scalar hoisting there only adds spill pressure — keep it conservative.
796
+ if (op.startsWith('v128.') || /^[if]\d+x\d+\./.test(op)) hasV128 = true
797
+ if (op === 'local.set' || op === 'local.tee') { if (typeof node[1] === 'string') locals.add(node[1]); for (let i = 2; i < node.length; i++) scan(node[i]); return }
798
+ if (op === 'global.set') { if (typeof node[1] === 'string') globals.add(node[1]); for (let i = 2; i < node.length; i++) scan(node[i]); return }
799
+ if (op === 'call') { hasAnyCall = true; if (!SAFE_OFFSET_CALLS.has(node[1]) && !READONLY_MEM_CALLS.has(node[1]) && !NON_MUTATING_CALLS.has(node[1])) hasUnsafeCall = true; for (let i = 2; i < node.length; i++) scan(node[i]); return }
800
+ if (op === 'call_ref' || op === 'call_indirect') { hasAnyCall = hasUnsafeCall = true; for (let i = 1; i < node.length; i++) scan(node[i]); return }
801
+ if ((op === 'f64.store' || op === 'i32.store') && node.length >= 3) {
802
+ hasDirectStore = true
803
+ const a = node[1]
804
+ if (Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' && a[1].startsWith(CELL_PREFIX)) storedCells.add(a[1])
805
+ if (distinctParams) { const sb = baseParamOf(a); if (sb) storedBases.add(sb) } // alias: which buffers this loop writes
806
+ }
807
+ for (let i = 1; i < node.length; i++) scan(node[i])
808
+ }
809
+ for (let i = 1; i < loopNode.length; i++) scan(loopNode[i])
810
+
811
+ const pureGiven = (node, bound) => {
812
+ if (!Array.isArray(node)) return true // bare operand string/number
813
+ const op = node[0]
814
+ if (op === 'i32.const' || op === 'i64.const' || op === 'f64.const' || op === 'f32.const') return true
815
+ if (op === 'local.get') return typeof node[1] === 'string' && (bound.has(node[1]) || !locals.has(node[1]))
816
+ // A global is invariant only if not set directly AND no call in the loop —
817
+ // any callee may mutate it (no interprocedural effect analysis). (Locals are
818
+ // frame-private, so calls can't touch them; only direct local.set matters.)
819
+ if (op === 'global.get') return typeof node[1] === 'string' && !globals.has(node[1]) && !hasAnyCall
820
+ if (op === 'local.tee') {
821
+ if (typeof node[1] !== 'string') return false
822
+ // The operand is evaluated BEFORE the tee writes $X, so a `local.get $X` inside
823
+ // it reads the loop-carried (previous-iteration) value, not the teed one. Drop
824
+ // $X from `bound` for the operand: `local.tee $X (… $X …)` is a loop recurrence
825
+ // (X = f(X) — e.g. the `while ((nn = nn >>> 1))` induction), NOT invariant.
826
+ const inner = bound.has(node[1]) ? new Set([...bound].filter(b => b !== node[1])) : bound
827
+ return pureGiven(node[2], inner)
828
+ }
829
+ if ((op === 'f64.load' || op === 'i32.load') && node.length === 2) {
830
+ const a = node[1]
831
+ if (Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' && a[1].startsWith(CELL_PREFIX)
832
+ && !hasAnyCall && !storedCells.has(a[1]) && (bound.has(a[1]) || !locals.has(a[1]))) return true
833
+ // Alias-analysis LICM: a load from a typed-array param PROVEN distinct from every buffer
834
+ // this loop writes (base ∉ storedBases) is loop-invariant when its address is invariant —
835
+ // even across the loop's stores, because they can't alias it. This is what lets rust/clang
836
+ // hoist read-only input arrays out of a write loop (raytrace's spheres vs the framebuffer).
837
+ // `pureGiven(a, bound)` proves the address itself invariant (base param unwritten + invariant
838
+ // offset); the calls guard rules out callee memory mutation.
839
+ if (distinctParams && !hasAnyCall) {
840
+ const base = baseParamOf(a)
841
+ if (base && distinctParams.has(base) && !storedBases.has(base) && pureGiven(a, bound)) return true
842
+ }
843
+ return false
844
+ }
845
+ if (op === 'call') {
846
+ if (SAFE_OFFSET_CALLS.has(node[1]))
847
+ return !hasUnsafeCall && node.slice(2).every(c => pureGiven(c, bound))
848
+ // Read-only heap reads: additionally require no direct store (alias-safe).
849
+ if (READONLY_MEM_CALLS.has(node[1]))
850
+ return !hasUnsafeCall && !hasDirectStore && node.slice(2).every(c => pureGiven(c, bound))
851
+ return false
852
+ }
853
+ // A value-producing `if` whose condition and both arms are pure is itself
854
+ // pure — the tag-dispatch idiom `(if (result f64) tag-check (then read-A)
855
+ // (else read-B))` that wraps __typed_idx/__str_idx element access.
856
+ if (op === 'if') {
857
+ for (let i = 1; i < node.length; i++) {
858
+ const c = node[i]
859
+ if (!Array.isArray(c)) continue
860
+ if (c[0] === 'result') continue
861
+ if (c[0] === 'then' || c[0] === 'else') { if (!c.slice(1).every(x => pureGiven(x, bound))) return false }
862
+ else if (!pureGiven(c, bound)) return false // the condition
863
+ }
864
+ return true
865
+ }
866
+ if (PURE_LICM_OPS.has(op)) return node.slice(1).every(c => pureGiven(c, bound))
867
+ return false
868
+ }
869
+ return { pureGiven, locals, globals, storedCells, storedBases, hasUnsafeCall, hasAnyCall, hasDirectStore, hasV128 }
870
+ }
871
+
570
872
  /**
571
873
  * Unified loop-invariant code motion. One principle replaces the three former
572
874
  * pattern hoists (ToInt32 / __ptr_offset / cell-load): a MAXIMAL pure subtree
@@ -587,6 +889,160 @@ const PURE_LICM_OPS = new Set([
587
889
  * watr's shared CSE subtrees, snaps spliced before the loop, decls at bodyStart.
588
890
  * Idempotent: re-running sees only `(local.get $__liN)` and finds nothing to do.
589
891
  */
892
+ // SSA-split loop-private straight-line multi-def scratch so the LICM below can hoist
893
+ // the invariant versions. jz's unroller MERGES each unrolled iteration's `const x`
894
+ // into one multi-def local (e.g. raytrace's sphere loop unrolls 8× sharing $ox/$c),
895
+ // which the LICM cannot hoist — so the per-sphere invariant `c_i = sx_i²+sy_i²+sz_i²
896
+ // −sr_i²` recomputes every pixel instead of once (the 1.24× rust-wasm gap; rust/LLVM
897
+ // keeps them as distinct SSA values and hoists each). Renaming each def to its own
898
+ // version makes them single-def → hoistInvariantLoop lifts the loop-invariant ones.
899
+ //
900
+ // BIT-EXACT: pure renaming + invariant code motion — the same value computed fewer
901
+ // times, no reassociation. Gated to loops with NO v128, so it never disturbs a
902
+ // vectorized loop (whose unrolled shared names the lane/dot vectorizer relies on).
903
+ //
904
+ // SOUND only for a local that, within the loop body, (a) is referenced NOWHERE else in
905
+ // the function (loop-local lifetime — else a post-loop read of the merged name breaks),
906
+ // (b) has every occurrence STRAIGHT-LINE (never under a nested if/block/loop, so a
907
+ // linear walk assigns each use its unique dominating def), (c) is first accessed by a
908
+ // WRITE (no value carried across the back-edge), (d) is only ever `local.set` (never
909
+ // `local.tee`/conditionally defined). Each condition rejects a class that would miscompile.
910
+ export function splitLoopPrivateScratch(fn) {
911
+ if (!Array.isArray(fn) || fn[0] !== 'func') return
912
+ const bodyStart = findBodyStart(fn)
913
+ if (bodyStart < 0) return
914
+ const SCALAR = new Set(['i32', 'i64', 'f64', 'f32'])
915
+ const localTypes = new Map()
916
+ for (let i = 2; i < bodyStart; i++) {
917
+ const c = fn[i]
918
+ if (Array.isArray(c) && (c[0] === 'param' || c[0] === 'local') && typeof c[1] === 'string') localTypes.set(c[1], c[2])
919
+ }
920
+ // Whole-function reference count per local (to verify a candidate is loop-local).
921
+ const fnRefs = new Map()
922
+ const countRefs = (n) => {
923
+ if (!Array.isArray(n)) return
924
+ if ((n[0] === 'local.get' || n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string')
925
+ fnRefs.set(n[1], (fnRefs.get(n[1]) || 0) + 1)
926
+ for (let i = 1; i < n.length; i++) countRefs(n[i])
927
+ }
928
+ for (let i = bodyStart; i < fn.length; i++) countRefs(fn[i])
929
+ // Same proven alias substrate hoistInvariantLoop uses (re-attached after watOptimize, so it
930
+ // survives into this 'post' pass) — lets pureGiven prove a read-only input-array load distinct
931
+ // from the loop's output store, the SOUND replacement for the old address-local-disjointness
932
+ // heuristic (which assumed two loads/stores in different locals never alias — false in general).
933
+ const distinctParams = fn.distinctParams || null
934
+ const baseParamOf = buildBaseParamOf(fn, bodyStart, distinctParams)
935
+
936
+ const hasV128 = (n) => {
937
+ let f = false
938
+ const w = (x) => { if (f || !Array.isArray(x)) return; const o = x[0]; if (typeof o === 'string' && (o.startsWith('v128') || /x(2|4|8|16)\b/.test(o) || o.includes('x2.') || o.includes('x4.') || o.includes('x8.') || o.includes('x16.'))) { f = true; return } for (let i = 1; i < x.length; i++) w(x[i]) }
939
+ w(n); return f
940
+ }
941
+ let minted = 0
942
+ const newDecls = []
943
+
944
+ const processLoop = (loop, parent, idx) => {
945
+ if (loop[0] !== 'loop' || hasV128(loop)) return
946
+ // Candidate names: locals set somewhere directly in the loop's statement list.
947
+ const seen = new Set()
948
+ for (let i = 2; i < loop.length; i++) {
949
+ const s = loop[i]
950
+ if (Array.isArray(s) && s[0] === 'local.set' && typeof s[1] === 'string') seen.add(s[1])
951
+ }
952
+ // Stage 1 — collect SAFE candidates (loop-local, straight-line, first-write, set-only,
953
+ // ≥2 defs) and record each one's def RHS list for the invariance fixpoint.
954
+ const cand = new Map() // name → { defs: [rhs…] }
955
+ for (const name of seen) {
956
+ if (!SCALAR.has(localTypes.get(name))) continue
957
+ let inLoop = 0
958
+ const cnt = (n) => { if (!Array.isArray(n)) return; if ((n[0] === 'local.get' || n[0] === 'local.set' || n[0] === 'local.tee') && n[1] === name) inLoop++; for (let i = 1; i < n.length; i++) cnt(n[i]) }
959
+ cnt(loop)
960
+ if (inLoop !== (fnRefs.get(name) || 0)) continue
961
+ let safe = true, first = null, defs = []
962
+ const scan = (n, depth) => {
963
+ if (!safe || !Array.isArray(n)) return
964
+ const op = n[0]
965
+ if (op === 'local.tee' && n[1] === name) { safe = false; return }
966
+ if (op === 'local.set' && n[1] === name) {
967
+ if (depth > 0) { safe = false; return }
968
+ if (first === null) first = 'w'
969
+ defs.push(n[2])
970
+ scan(n[2], depth)
971
+ return
972
+ }
973
+ if (op === 'local.get' && n[1] === name) {
974
+ if (depth > 0) { safe = false; return }
975
+ if (first === null) first = 'r'
976
+ return
977
+ }
978
+ const ctrl = op === 'if' || op === 'then' || op === 'else' || op === 'block' || op === 'loop'
979
+ for (let i = 1; i < n.length; i++) scan(n[i], depth + (ctrl ? 1 : 0))
980
+ }
981
+ for (let i = 2; i < loop.length; i++) scan(loop[i], 0)
982
+ if (safe && first === 'w' && defs.length >= 2) cand.set(name, defs)
983
+ }
984
+ if (!cand.size) return
985
+ // Stage 2 — invariance fixpoint over the SHARED proven predicate. `pureGiven(def, hoistable)`
986
+ // decides loop-invariance with hoistInvariantLoop's exact model: a `$__cell_`/distinct-param
987
+ // read-only load is invariant across the loop's stores (sound alias analysis), a global is
988
+ // invariant only without a loop write or call, and the `bound` set (here `hoistable`) carries
989
+ // the cascade — a def reading an already-split sibling is invariant once that sibling moves out
990
+ // (c = ox²+… invariant only after ox hoists). `motionSafe` adds the one extra obligation a
991
+ // whole-assignment MOTION needs beyond value-invariance: no `local.tee` writing a local read
992
+ // elsewhere (pureGiven already rejects set/store/global.set/unsafe-call). This replaces the old
993
+ // address-local-disjointness load test, which was unsound in general (two distinct locals can
994
+ // hold the same address) and only worked by luck on the bench shapes.
995
+ const { pureGiven } = loopInvariance(loop, { distinctParams, baseParamOf })
996
+ const motionSafe = (n) => { if (!Array.isArray(n)) return true; if (n[0] === 'local.tee') return false; for (let i = 1; i < n.length; i++) if (!motionSafe(n[i])) return false; return true }
997
+ const hoistable = new Set()
998
+ let changed = true
999
+ while (changed) {
1000
+ changed = false
1001
+ for (const [name, defs] of cand) {
1002
+ if (hoistable.has(name)) continue
1003
+ if (defs.every(d => motionSafe(d) && pureGiven(d, hoistable))) { hoistable.add(name); changed = true }
1004
+ }
1005
+ }
1006
+ // Stage 3 — one linear pass over the loop body: each hoistable def is RENAMED to a
1007
+ // fresh version and MOVED OUT of the loop (before it), in source order so the cascade's
1008
+ // data deps stay intact (c = ox²+… emitted after ox). The cheap arithmetic AND the load
1009
+ // both leave the loop; gets stay, rebound to the moved version. (hoistInvariantLoop only
1010
+ // snapshots expensive subexprs, not whole invariant assignments — so we do the motion.)
1011
+ const curOf = new Map()
1012
+ const rewriteGets = (n) => {
1013
+ if (!Array.isArray(n)) return n
1014
+ if (n[0] === 'local.get' && curOf.has(n[1])) return ['local.get', curOf.get(n[1])]
1015
+ return n.map((c, i) => i === 0 ? c : rewriteGets(c))
1016
+ }
1017
+ const hoisted = []
1018
+ const kept = loop.slice(0, 2) // 'loop' + label
1019
+ for (let i = 2; i < loop.length; i++) {
1020
+ const s = loop[i]
1021
+ if (Array.isArray(s) && s[0] === 'local.set' && hoistable.has(s[1])) {
1022
+ const name = s[1], ty = localTypes.get(name)
1023
+ const nv = `$${name.replace(/^\$/, '')}__sr${minted++}`
1024
+ newDecls.push(['local', nv, ty]); localTypes.set(nv, ty)
1025
+ hoisted.push(['local.set', nv, rewriteGets(s[2])])
1026
+ curOf.set(name, nv)
1027
+ } else {
1028
+ kept.push(rewriteGets(s))
1029
+ }
1030
+ }
1031
+ loop.length = 0
1032
+ for (const x of kept) loop.push(x)
1033
+ parent.splice(idx, 0, ...hoisted)
1034
+ }
1035
+ const walk = (parent, idx) => {
1036
+ const n = parent[idx]
1037
+ if (!Array.isArray(n)) return
1038
+ // Recurse first so an inner loop's hoists land before we process the outer loop.
1039
+ for (let i = 1; i < n.length; i++) walk(n, i)
1040
+ if (n[0] === 'loop') processLoop(n, parent, idx)
1041
+ }
1042
+ for (let i = bodyStart; i < fn.length; i++) walk(fn, i)
1043
+ if (newDecls.length) fn.splice(bodyStart, 0, ...newDecls)
1044
+ }
1045
+
590
1046
  export function hoistInvariantLoop(fn) {
591
1047
  if (!Array.isArray(fn) || fn[0] !== 'func') return
592
1048
  const bodyStart = findBodyStart(fn)
@@ -613,14 +1069,34 @@ export function hoistInvariantLoop(fn) {
613
1069
  if (!Array.isArray(node)) return null
614
1070
  const op = node[0]
615
1071
  if (op === 'select') return resultType(node[1])
616
- if (op === 'call') return SAFE_OFFSET_CALLS.has(node[1]) ? 'i32' : null // whitelist all return i32
1072
+ if (op === 'if') {
1073
+ // (if (result T) cond (then ...) (else ...)) — type is the result clause.
1074
+ for (let i = 1; i < node.length; i++) {
1075
+ const c = node[i]
1076
+ if (Array.isArray(c) && c[0] === 'result') return c[1]
1077
+ }
1078
+ return null
1079
+ }
1080
+ if (op === 'block') {
1081
+ for (let i = 1; i < node.length; i++) {
1082
+ const c = node[i]
1083
+ if (Array.isArray(c) && c[0] === 'result') return c[1]
1084
+ }
1085
+ return null
1086
+ }
1087
+ if (op === 'call') {
1088
+ // SAFE_OFFSET_CALLS all return i32; READONLY_MEM_CALLS return f64 (NaN-boxed element)
1089
+ if (SAFE_OFFSET_CALLS.has(node[1])) return 'i32'
1090
+ if (READONLY_MEM_CALLS.has(node[1])) return 'f64'
1091
+ return null
1092
+ }
617
1093
  if (op === 'local.get' || op === 'local.tee') return localTypes.get(node[1]) ?? null
618
1094
  const dot = op.indexOf('.')
619
1095
  if (dot < 0) return null
620
1096
  // Comparisons and `eqz` yield i32 regardless of operand type (i64.eq, f64.lt,
621
1097
  // i64.eqz, …) — so the operand-type prefix would mistype them. Catch first.
622
1098
  const m = op.slice(dot + 1)
623
- if (m === 'eqz' || /^(eq|ne|lt|gt|le|ge)(_[su])?$/.test(m)) return 'i32'
1099
+ if (CMP_MANTISSA.has(m)) return 'i32'
624
1100
  const p = op.slice(0, dot)
625
1101
  if (p === 'i32' || p === 'i64' || p === 'f64' || p === 'f32') return p
626
1102
  return null
@@ -644,29 +1120,23 @@ export function hoistInvariantLoop(fn) {
644
1120
  const newLocals = []
645
1121
  const refcount = buildRefcount(fn)
646
1122
 
647
- const processLoop = (loopNode) => {
1123
+ // Alias-analysis substrate for hoisting typed-array PARAM element loads across distinct-base
1124
+ // stores. `distinctParams` (stamped by compile/index.js from the param-distinctness pass) is the
1125
+ // set of typed-array params PROVEN to be mutually-distinct buffers at every call site. To use it,
1126
+ // resolve a load/store address back to the single param it derives from — through `local.get`,
1127
+ // `i32.add/sub`, and single-def snap locals ($__li/$__ab from prior ptr-offset hoisting).
1128
+ const distinctParams = fn.distinctParams || null
1129
+ const baseParamOf = buildBaseParamOf(fn, bodyStart, distinctParams)
1130
+
1131
+ const processLoop = (loopNode, nested) => {
648
1132
  // Inner loops first (bottom-up) — an inner hoist creates a local.get the
649
- // outer level can hoist further.
1133
+ // outer level can hoist further. Children run in a nested context.
650
1134
  for (let i = 1; i < loopNode.length; i++)
651
- if (Array.isArray(loopNode[i])) processNode(loopNode[i], loopNode, i)
1135
+ if (Array.isArray(loopNode[i])) processNode(loopNode[i], loopNode, i, true)
652
1136
 
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
656
- const scan = (node) => {
657
- if (!Array.isArray(node)) return
658
- const op = node[0]
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])
666
- }
667
- for (let i = 1; i < node.length; i++) scan(node[i])
668
- }
669
- for (let i = 1; i < loopNode.length; i++) scan(loopNode[i])
1137
+ // The loop's effect summary + the proven invariance/purity predicate (shared with
1138
+ // splitLoopPrivateScratch see loopInvariance). `locals` is the loop's whole write-set.
1139
+ const { pureGiven, locals, hasV128 } = loopInvariance(loopNode, { distinctParams, baseParamOf })
670
1140
 
671
1141
  // Per-subtree local-occurrence counts and write-sets, memoized bottom-up —
672
1142
  // the tee-privacy check queries them for EVERY candidate node, and the old
@@ -705,37 +1175,6 @@ export function hoistInvariantLoop(fn) {
705
1175
  for (let i = 1; i < loopNode.length; i++)
706
1176
  for (const [k, v] of countsOf(loopNode[i])) localCount.set(k, (localCount.get(k) || 0) + v)
707
1177
 
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
1178
  const isHoistable = (node) => {
740
1179
  if (!Array.isArray(node)) return false
741
1180
  const op = node[0]
@@ -745,9 +1184,14 @@ export function hoistInvariantLoop(fn) {
745
1184
  // Every local the subtree writes must be private to it (no other use in the
746
1185
  // loop) — else moving the write to the pre-header changes another reader.
747
1186
  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)
1187
+ // Top-level loops: only hoist what V8's wasm tier won't a HARD_OP or the
1188
+ // inline typed-array base decode and leave plain pure arithmetic to V8's own
1189
+ // LICM (which handles single-level loops well). NESTED (inner) loops are
1190
+ // different: V8's wasm tier under-hoists invariants out of them (a nested
1191
+ // rasterizer/convolution recomputes triangle/row-invariant subexpressions every
1192
+ // iteration), so hoist any pure-invariant subtree there. Soundness is unchanged —
1193
+ // `pureGiven` already proves the subtree is loop-invariant and side-effect-free.
1194
+ return ((nested && !hasV128) || hasHardOp(node) || isPtrBaseDecode(node)) && pureGiven(node, bound)
751
1195
  }
752
1196
 
753
1197
  // Maximal extraction: take the largest hoistable subtree; don't descend into
@@ -757,9 +1201,10 @@ export function hoistInvariantLoop(fn) {
757
1201
  if (!Array.isArray(node)) return
758
1202
  if (node[0] === 'loop') return // already processed bottom-up
759
1203
  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)
1204
+ // stableKey: hoistable boxed-pointer subtrees carry i64.const NaN-box prefixes
1205
+ // (BigInt) that plain JSON.stringify can't serialize, and it also collapses
1206
+ // Infinity/-Infinity/NaN→null & -0→0 both would dedup distinct invariants.
1207
+ const key = JSON.stringify(node, stableKey)
763
1208
  let arr = sites.get(key); if (!arr) { arr = []; sites.set(key, arr) }
764
1209
  arr.push({ parent, idx, node })
765
1210
  return
@@ -780,17 +1225,17 @@ export function hoistInvariantLoop(fn) {
780
1225
  return snaps
781
1226
  }
782
1227
 
783
- const processNode = (node, parent, idx) => {
1228
+ const processNode = (node, parent, idx, nested = false) => {
784
1229
  if (!Array.isArray(node)) return
785
1230
  if (node[0] === 'loop') {
786
- const snaps = processLoop(node)
1231
+ const snaps = processLoop(node, nested)
787
1232
  if (snaps.length) parent.splice(idx, 0, ...snaps)
788
1233
  return
789
1234
  }
790
- for (let i = 0; i < node.length; i++) processNode(node[i], node, i)
1235
+ for (let i = 0; i < node.length; i++) processNode(node[i], node, i, nested)
791
1236
  }
792
1237
 
793
- for (let i = bodyStart; i < fn.length; i++) processNode(fn[i], fn, i)
1238
+ for (let i = bodyStart; i < fn.length; i++) processNode(fn[i], fn, i, false)
794
1239
  if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
795
1240
  }
796
1241
 
@@ -891,16 +1336,17 @@ export function narrowLoopBound(fn) {
891
1336
  const newLocals = []
892
1337
  const refcount = buildRefcount(fn)
893
1338
 
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)))`.
1339
+ // `i < bound` as `(f64.lt (convert i) bound)` or mirrored `(f64.gt bound (convert i))`.
1340
+ // `i <= bound` as `(f64.le (convert i) bound)` or mirrored `(f64.ge bound (convert i))`.
896
1341
  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
1342
+ const lt = n[0] === 'f64.lt', gt = n[0] === 'f64.gt', le = n[0] === 'f64.le', ge = n[0] === 'f64.ge'
1343
+ const conv = lt || le ? n[1] : gt || ge ? n[2] : null
1344
+ const bnd = lt || le ? n[2] : gt || ge ? n[1] : null
899
1345
  if (!Array.isArray(conv) || conv[0] !== 'f64.convert_i32_s') return null
900
1346
  const ig = conv[1]
901
1347
  if (!Array.isArray(ig) || ig[0] !== 'local.get' || typeof ig[1] !== 'string') return null
902
1348
  if (!Array.isArray(bnd) || bnd[0] !== 'local.get' || typeof bnd[1] !== 'string') return null
903
- return { ctr: ig[1], bound: bnd[1] }
1349
+ return { ctr: ig[1], bound: bnd[1], op: le || ge ? 'le' : 'lt' }
904
1350
  }
905
1351
 
906
1352
  const processLoop = (loopNode) => {
@@ -930,18 +1376,34 @@ export function narrowLoopBound(fn) {
930
1376
  }
931
1377
  for (let i = 1; i < loopNode.length; i++) collect(loopNode[i])
932
1378
 
933
- const snapFor = new Map() // bound name snap local (one per distinct bound)
1379
+ // One snap per distinct (bound, op): `i < n` and `i <= n` of the SAME bound
1380
+ // need different snapped i32 values (ceil vs floor).
1381
+ const snapFor = new Map()
934
1382
  const snaps = []
1383
+ const I32_MIN = -2147483648
935
1384
  for (const { node, m } of sites) {
936
- let snap = snapFor.get(m.bound)
1385
+ const key = `${m.bound}|${m.op}`
1386
+ let snap = snapFor.get(key)
937
1387
  if (!snap) {
938
1388
  snap = freshLb()
939
- snapFor.set(m.bound, snap)
1389
+ snapFor.set(key, snap)
940
1390
  newLocals.push(['local', snap, 'i32'])
941
- snaps.push(['local.set', snap, ['i32.trunc_sat_f64_s', ['f64.ceil', ['local.get', m.bound]]]])
1391
+ // `i < n` ⟺ `i < ceil(n)`: trunc_sat(NaN)=0 makes `i<0` false — matches `i<NaN`;
1392
+ // ±Inf → I32_MAX/I32_MIN, both correct. NaN-safe for free.
1393
+ // `i <= n` ⟺ `i <= floor(n)`, BUT trunc_sat(floor(NaN))=0 would make `i<=0` run
1394
+ // one iteration at i=0, while JS (`i<=NaN` is false) runs zero. Guard the NaN
1395
+ // case to I32_MIN (below any non-negative counter ⇒ zero iterations). ±Inf are
1396
+ // already correct (floor(+Inf)→I32_MAX, floor(-Inf)→I32_MIN; Inf==Inf is true).
1397
+ snaps.push(['local.set', snap, m.op === 'le'
1398
+ ? ['select',
1399
+ ['i32.trunc_sat_f64_s', ['f64.floor', ['local.get', m.bound]]],
1400
+ ['i32.const', I32_MIN],
1401
+ ['f64.eq', ['local.get', m.bound], ['local.get', m.bound]]]
1402
+ : ['i32.trunc_sat_f64_s', ['f64.ceil', ['local.get', m.bound]]]])
942
1403
  }
943
1404
  node.length = 3
944
- node[0] = 'i32.lt_s'; node[1] = ['local.get', m.ctr]; node[2] = ['local.get', snap]
1405
+ node[0] = m.op === 'le' ? 'i32.le_s' : 'i32.lt_s'
1406
+ node[1] = ['local.get', m.ctr]; node[2] = ['local.get', snap]
945
1407
  }
946
1408
  return snaps
947
1409
  }
@@ -1168,6 +1630,19 @@ export function cseScalarLoad(fn) {
1168
1630
  // the two passes cover deliberately different op sets.
1169
1631
  const COMMUTATIVE = new Set(['f64.mul', 'f64.add', 'i32.mul', 'i32.add', 'i32.and', 'i32.or', 'i32.xor', 'i64.mul', 'i64.add', 'i64.and', 'i64.or', 'i64.xor'])
1170
1632
 
1633
+ // Presence of one of these arms csePureExprLoop (it CSEs redundant pure f64/i32
1634
+ // arithmetic within the loop; the gate is just "is this loop expensive enough to
1635
+ // be worth the pass"). The whole class of transcendental helpers qualifies — each
1636
+ // is a multi-instruction polynomial approximation, so a loop built around exp/log/
1637
+ // pow deserves the same arithmetic-CSE a trig loop already got. Gating on the class,
1638
+ // not a benchmark shape; the CSE itself is bit-exact (pure-subexpr dedup only).
1639
+ const LOOP_CSE_EXPENSIVE = new Set([
1640
+ '$math.sin', '$math.cos', '$math.tan', '$math.sin_core', '$math.cos_core',
1641
+ '$math.exp', '$math.expm1', '$math.log', '$math.log2', '$math.log10', '$math.log1p',
1642
+ '$math.pow', '$math.atan', '$math.asin', '$math.acos', '$math.atan2',
1643
+ '$math.sinh', '$math.cosh', '$math.tanh', '$math.cbrt', '$math.hypot',
1644
+ ])
1645
+
1171
1646
  export function csePureExpr(fn) {
1172
1647
  if (!Array.isArray(fn) || fn[0] !== 'func') return
1173
1648
  const bodyStart = findBodyStart(fn)
@@ -1304,16 +1779,15 @@ export function csePureExprLoop(fn) {
1304
1779
  if (bodyStart < 0) return
1305
1780
 
1306
1781
  let hasLoop = false
1307
- let hasTrigCall = false
1782
+ let hasExpensiveCall = false
1308
1783
  const scanShape = (n) => {
1309
1784
  if (!Array.isArray(n)) return
1310
1785
  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
1786
+ if (n[0] === 'call' && LOOP_CSE_EXPENSIVE.has(n[1])) hasExpensiveCall = true
1313
1787
  for (let i = 1; i < n.length; i++) scanShape(n[i])
1314
1788
  }
1315
1789
  for (let i = bodyStart; i < fn.length; i++) scanShape(fn[i])
1316
- if (!hasLoop || !hasTrigCall) return
1790
+ if (!hasLoop || !hasExpensiveCall) return
1317
1791
 
1318
1792
  let snapId = nextLocalId(fn, 'pe')
1319
1793
  const newLocals = []
@@ -1768,23 +2242,52 @@ export function hoistGlobalPtrOffset(fn, stablePtrGlobals, reachableWrites) {
1768
2242
  const bodyStart = findBodyStart(fn)
1769
2243
  if (bodyStart < 0) return
1770
2244
 
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
2245
+ // `(i64.reinterpret_f64 (global.get $G))` → G, or null.
2246
+ const reintGlobal = (n) =>
2247
+ Array.isArray(n) && n[0] === 'i64.reinterpret_f64'
2248
+ && Array.isArray(n[1]) && n[1][0] === 'global.get' && typeof n[1][1] === 'string'
2249
+ ? n[1][1] : null
2250
+ // A stable-pointee global's byte-base reaches us in two interchangeable shapes:
2251
+ // • forwarding-aware `(call $__ptr_offset (i64.reinterpret_f64 (global.get $G)))`
2252
+ // • inline typed read `(i32.wrap_i64 (i64.and (i64.reinterpret_f64 (global.get $G)) MASK))`
2253
+ // The inline form is what typed-array reads emit (a fixed-size typed array never
2254
+ // relocates, so they skip __ptr_offset's forwarding follow — see module/typedarray.js
2255
+ // `typedBase`). For a never-forwarding pointee both yield the identical offset, so
2256
+ // either site hoists to the one `__ptr_offset` entry snapshot. Matching only the
2257
+ // call form left typed-array globals re-decoding the NaN-box per element in stencil
2258
+ // sweeps (watercolor's pressure solve: 5 reads/cell × millions of cells). → G, or null.
2259
+ const siteGlobal = (n) => {
2260
+ if (!Array.isArray(n)) return null
2261
+ if (n[0] === 'call' && n[1] === '$__ptr_offset' && n.length === 3) return reintGlobal(n[2])
2262
+ if (n[0] === 'i32.wrap_i64' && n.length === 2 && Array.isArray(n[1]) && n[1][0] === 'i64.and' && n[1].length === 3) {
2263
+ const mask = n[1][2]
2264
+ if (Array.isArray(mask) && mask[0] === 'i64.const'
2265
+ && (typeof mask[1] === 'string' ? Number(mask[1]) : mask[1]) === LAYOUT.OFFSET_MASK)
2266
+ return reintGlobal(n[1][1])
2267
+ }
2268
+ return null
2269
+ }
1777
2270
 
1778
2271
  // Per-global: static site count AND whether any site sits inside a loop. A
1779
2272
  // single in-loop site is a per-ITERATION resolve (lenia's convolution reads
1780
2273
  // each of kdx/kdy/kw at one site × ~14M taps/frame), so loop placement beats
1781
2274
  // site count as the hoist criterion.
1782
2275
  const counts = new Map(), inLoop = new Set(), ownWrites = new Set(), ownCallees = new Set()
2276
+ // Globals seen via the `__ptr_offset` call form (vs. only the inline typed mask).
2277
+ // The snapshot reuses an EXISTING form so it never resurrects a treeshaken helper:
2278
+ // a typed-array-only module emits no `__ptr_offset` call, so snapping one in would
2279
+ // reference a function that isn't in the module.
2280
+ const ptrOffsetForm = new Set()
1783
2281
  let hasIndirect = false
1784
2282
  const scan = (n, loopDepth) => {
1785
2283
  if (!Array.isArray(n)) return
1786
2284
  const g = siteGlobal(n)
1787
- if (g != null) { counts.set(g, (counts.get(g) || 0) + 1); if (loopDepth > 0) inLoop.add(g); return }
2285
+ if (g != null) {
2286
+ counts.set(g, (counts.get(g) || 0) + 1)
2287
+ if (loopDepth > 0) inLoop.add(g)
2288
+ if (n[0] === 'call') ptrOffsetForm.add(g)
2289
+ return
2290
+ }
1788
2291
  if (n[0] === 'global.set' && typeof n[1] === 'string') ownWrites.add(n[1])
1789
2292
  else if ((n[0] === 'call' || n[0] === 'return_call') && typeof n[1] === 'string') ownCallees.add(n[1])
1790
2293
  else if (n[0] === 'call_indirect' || n[0] === 'call_ref' || n[0] === 'return_call_indirect') hasIndirect = true
@@ -1833,7 +2336,13 @@ export function hoistGlobalPtrOffset(fn, stablePtrGlobals, reachableWrites) {
1833
2336
  const decls = [], snaps = []
1834
2337
  for (const [g, name] of chosen) {
1835
2338
  decls.push(['local', name, 'i32'])
1836
- snaps.push(['local.set', name, ['call', '$__ptr_offset', ['i64.reinterpret_f64', ['global.get', g]]]])
2339
+ // Match an existing site's form so we never reference a treeshaken helper.
2340
+ // For a never-forwarding pointee both forms compute the same offset, so the
2341
+ // inline mask is a safe (and call-free) snapshot when no __ptr_offset site exists.
2342
+ const snap = ptrOffsetForm.has(g)
2343
+ ? ['call', '$__ptr_offset', ['i64.reinterpret_f64', ['global.get', g]]]
2344
+ : ['i32.wrap_i64', ['i64.and', ['i64.reinterpret_f64', ['global.get', g]], ['i64.const', LAYOUT.OFFSET_MASK]]]
2345
+ snaps.push(['local.set', name, snap])
1837
2346
  }
1838
2347
  fn.splice(bodyStart, 0, ...decls, ...snaps)
1839
2348
  }
@@ -1973,15 +2482,8 @@ function inferTypeFromContext(fn, gName, bodyStart) {
1973
2482
  }
1974
2483
  if (pOp === 'i32.store' && idx === 2) { inferred = 'i32'; return } // addr
1975
2484
  if (pOp === 'f64.store' && idx === 2) { inferred = 'f64'; return } // addr can be i32, but value is f64
1976
- if (pOp === 'i32.eq' || pOp === 'i32.ne' || pOp === 'i32.lt_s' || pOp === 'i32.lt_u' ||
1977
- pOp === 'i32.gt_s' || pOp === 'i32.gt_u' || pOp === 'i32.le_s' || pOp === 'i32.le_u' ||
1978
- pOp === 'i32.ge_s' || pOp === 'i32.ge_u') {
1979
- // Comparison — could be i32, but in jz NaN-boxing scheme most globals are f64
1980
- // Only if we can confirm from local.set context
1981
- }
1982
- if (pOp === 'local.set' && idx === 0) {
1983
- // Can't determine local type from here easily
1984
- }
2485
+ // i32 comparisons already matched the `i32.` prefix above; a `local.set`
2486
+ // parent tells us nothing here both fall through to the f64 default.
1985
2487
  }
1986
2488
  }
1987
2489
  // Default: f64 (the NaN-boxing carrier)
@@ -2005,22 +2507,33 @@ function inferTypeFromContext(fn, gName, bodyStart) {
2005
2507
  *
2006
2508
  * Mutates `funcs` in place; writes new global decls via `addGlobal(name, constLiteral)`.
2007
2509
  */
2510
+ // `String(number)` keeps only ~9 significant digits in the self-host kernel (jz's number
2511
+ // formatter — see README "differences"). The old pool keyed constants by `n:${c[1]}` (a toString)
2512
+ // and emitted them via that same string, so in the kernel a constant both LOST precision
2513
+ // (0.041666666666666664 → 0x1.5555558325751p-5) and could MERGE with a distinct value sharing its
2514
+ // 9-digit prefix. Key by the exact 64 bits instead (a Float64Array/Uint32Array union — the
2515
+ // numHashLiteral pattern, which self-hosts; the sign bit distinguishes -0/+0 for free) and emit
2516
+ // the original NUMBER, which `declGlobal` lowers to a binary `f64.const` (exact, no string).
2517
+ const _FCB = new Float64Array(1), _FCBu = new Uint32Array(_FCB.buffer)
2518
+ const f64BitsKey = (n) => { _FCB[0] = n; return `n:${_FCBu[0]}:${_FCBu[1]}` }
2519
+
2008
2520
  export function hoistConstantPool(funcs, addGlobal) {
2009
2521
  const MIN_USES = 2
2010
2522
  // Single walk: count occurrences AND record each f64.const site for direct rewrite.
2011
2523
  // Avoids a second full-AST traversal in the rewrite phase.
2012
2524
  const counts = new Map()
2525
+ // NOTE: not `valueOf` — a local named like an Object method self-host-miscompiles (the
2526
+ // kernel's dynamic dispatch confuses it). key → exact original c[1] (number, or source string).
2527
+ const exactVal = new Map()
2013
2528
  const sites = [] // { parent, idx, key }
2014
2529
  const walk = (node) => {
2015
2530
  if (!Array.isArray(node)) return
2016
2531
  for (let i = 0; i < node.length; i++) {
2017
2532
  const c = node[i]
2018
2533
  if (Array.isArray(c) && c[0] === 'f64.const' && (typeof c[1] === 'number' || typeof c[1] === 'string')) {
2019
- // Distinguish -0 from +0 by sign: template literal collapses both to "0".
2020
- const k = typeof c[1] === 'number'
2021
- ? (Object.is(c[1], -0) ? 'n:-0' : `n:${c[1]}`)
2022
- : `s:${c[1]}`
2534
+ const k = typeof c[1] === 'number' ? f64BitsKey(c[1]) : `s:${c[1]}`
2023
2535
  counts.set(k, (counts.get(k) || 0) + 1)
2536
+ if (!exactVal.has(k)) exactVal.set(k, c[1])
2024
2537
  sites.push({ parent: node, idx: i, key: k })
2025
2538
  }
2026
2539
  walk(c)
@@ -2033,8 +2546,9 @@ export function hoistConstantPool(funcs, addGlobal) {
2033
2546
  let gId = 0
2034
2547
  for (const [k] of sorted) {
2035
2548
  const name = `__fc${gId++}`
2036
- const lit = k.slice(2)
2037
- addGlobal(name, lit)
2549
+ // The EXACT original c[1] (a number → binary f64.const; or a source hex/decimal string),
2550
+ // never the lossy k-derived toString.
2551
+ addGlobal(name, exactVal.get(k))
2038
2552
  hoist.set(k, name)
2039
2553
  }
2040
2554
  if (!hoist.size) return
@@ -2142,9 +2656,7 @@ export function specializeMkptr(funcs, addFunc, parseWat) {
2142
2656
  // $__mkptr inline fast path: bake (type, aux) literals into i64.const template.
2143
2657
  if (target === '$__mkptr' && spec.inline && parts[0].startsWith('L:') && parts[1].startsWith('L:')) {
2144
2658
  const type = +parts[0].slice(2), aux = +parts[1].slice(2)
2145
- const tmpl = LAYOUT.NAN_PREFIX_BITS
2146
- | ((BigInt(type) & BigInt(LAYOUT.TAG_MASK)) << BigInt(LAYOUT.TAG_SHIFT))
2147
- | ((BigInt(aux) & BigInt(LAYOUT.AUX_MASK)) << BigInt(LAYOUT.AUX_SHIFT))
2659
+ const tmpl = ptrBits(type, aux) // box prefix (offset OR'd in at runtime below)
2148
2660
  // Third arg (offset) may also be literal — emit (f64.const nan:…) then.
2149
2661
  if (parts[2].startsWith('L:')) {
2150
2662
  // Fully literal: all sites can be f64.const — no helper needed, handled in rewrite below.
@@ -2186,11 +2698,7 @@ export function specializeMkptr(funcs, addFunc, parseWat) {
2186
2698
  // $__mkptr fully literal (rare — mkPtrIR usually folds these ahead of us, but defensive):
2187
2699
  if (target === '$__mkptr' && parts[0].startsWith('L:') && parts[1].startsWith('L:') && parts[2].startsWith('L:')) {
2188
2700
  const type = +parts[0].slice(2), aux = +parts[1].slice(2), off = +parts[2].slice(2)
2189
- const bits = LAYOUT.NAN_PREFIX_BITS
2190
- | ((BigInt(type) & BigInt(LAYOUT.TAG_MASK)) << BigInt(LAYOUT.TAG_SHIFT))
2191
- | ((BigInt(aux) & BigInt(LAYOUT.AUX_MASK)) << BigInt(LAYOUT.AUX_SHIFT))
2192
- | (BigInt(off >>> 0) & BigInt(LAYOUT.OFFSET_MASK))
2193
- const n = ['f64.const', 'nan:0x' + bits.toString(16).toUpperCase().padStart(16, '0')]
2701
+ const n = ['f64.const', 'nan:' + i64Hex(ptrBits(type, aux, off))]
2194
2702
  n.type = 'f64'
2195
2703
  parent[idx] = n
2196
2704
  continue
@@ -2374,6 +2882,314 @@ export function sortStrPoolByFreq(funcs, strPoolRef, strDedupMap) {
2374
2882
  }
2375
2883
  }
2376
2884
 
2885
+ /**
2886
+ * Fold dead string-dispatch blocks when the tested operand is a proven-f64 local.
2887
+ *
2888
+ * jz's `+` emitter produces, for every binary addition whose right operand has an
2889
+ * unresolved valType, the pattern:
2890
+ *
2891
+ * (block (result f64)
2892
+ * (local.set $B EXPR_A)
2893
+ * (if (result f64)
2894
+ * (call $__is_str_key (i64.reinterpret_f64 (local.tee $C (local.get $P))))
2895
+ * (then (call $__str_concat …))
2896
+ * (else (f64.add (local.get $B) (local.get $C)))))
2897
+ *
2898
+ * When $P is a proven-f64 local (an f64 param, or an f64-typed local provably set
2899
+ * only from f64 arithmetic) it can never hold a string-key NaN-box, so the
2900
+ * `$__is_str_key` test is provably false and the `then` branch is dead.
2901
+ * Replace the whole block with `(f64.add EXPR_A (local.get $P))`.
2902
+ *
2903
+ * SOUND: f64 params can never hold a string-key NaN-box by construction (jz
2904
+ * only allows strings in f64 slots via explicit mkptr boxing, never a bare
2905
+ * param). This fold is additive/gated (only runs when vectorizeLaneLocal is on)
2906
+ * and only removes provably-dead string-dispatch overhead.
2907
+ *
2908
+ * Called in the 'post' phase of optimizeFunc, before vectorizeLaneLocal, so the
2909
+ * cleaned IR is what the vectorizer pattern-matches.
2910
+ */
2911
+ export function foldStrDispatchF64(fn) {
2912
+ if (!Array.isArray(fn) || fn[0] !== 'func') return
2913
+ const bodyStart = findBodyStart(fn)
2914
+ if (bodyStart < 0) return
2915
+
2916
+ // Collect all f64 params — provably never hold a string-key NaN-box.
2917
+ const rawF64 = new Set()
2918
+ for (let i = 2; i < bodyStart; i++) {
2919
+ const d = fn[i]
2920
+ if (Array.isArray(d) && d[0] === 'param' && typeof d[1] === 'string' && d[2] === 'f64')
2921
+ rawF64.add(d[1])
2922
+ }
2923
+ if (!rawF64.size) return // no f64 params → nothing to fold
2924
+
2925
+ // Transitively extend rawF64: an f64 local set only via f64 arithmetic over rawF64
2926
+ // members is itself provably non-string. One forward pass suffices for DAG-shaped
2927
+ // straight-line code (the common case); a fixed-point loop covers rare mutual defs.
2928
+ // Collect local types first.
2929
+ const localTypeMap = new Map()
2930
+ for (let i = 2; i < bodyStart; i++) {
2931
+ const d = fn[i]
2932
+ if (Array.isArray(d) && (d[0] === 'param' || d[0] === 'local') && typeof d[1] === 'string')
2933
+ localTypeMap.set(d[1], d[2])
2934
+ }
2935
+ // An expression is rawF64-valued if it only uses ops that stay in f64 and
2936
+ // reads only rawF64 locals (or f64.const). Stops early — we only need the
2937
+ // closed set for the pattern's $P operand.
2938
+ const isRawF64Expr = (n) => {
2939
+ if (!Array.isArray(n)) return false
2940
+ const op = n[0]
2941
+ if (op === 'f64.const') return true
2942
+ if (op === 'local.get' && typeof n[1] === 'string') return rawF64.has(n[1])
2943
+ if (op === 'local.tee' && typeof n[1] === 'string') return rawF64.has(n[1]) && isRawF64Expr(n[2])
2944
+ if (op === 'f64.add' || op === 'f64.sub' || op === 'f64.mul' || op === 'f64.div' ||
2945
+ op === 'f64.neg' || op === 'f64.abs' || op === 'f64.sqrt') {
2946
+ return n.slice(1).every(isRawF64Expr)
2947
+ }
2948
+ return false
2949
+ }
2950
+
2951
+ // Single forward pass: a local.set $v EXPR where EXPR is rawF64-valued makes $v rawF64.
2952
+ // Repeat until stable (handles ordering edge cases in non-DAG code).
2953
+ let changed = true
2954
+ while (changed) {
2955
+ changed = false
2956
+ const scan = (node) => {
2957
+ if (!Array.isArray(node)) return
2958
+ if ((node[0] === 'local.set' || node[0] === 'local.tee') && typeof node[1] === 'string' &&
2959
+ localTypeMap.get(node[1]) === 'f64' && !rawF64.has(node[1]) && isRawF64Expr(node[2])) {
2960
+ rawF64.add(node[1]); changed = true
2961
+ }
2962
+ for (let i = 1; i < node.length; i++) scan(node[i])
2963
+ }
2964
+ for (let i = bodyStart; i < fn.length; i++) scan(fn[i])
2965
+ }
2966
+
2967
+ // Pattern-match and fold in-place (bottom-up recursive walk so nested blocks resolve).
2968
+ const foldNode = (node) => {
2969
+ if (!Array.isArray(node)) return node
2970
+ // Recurse children first (bottom-up).
2971
+ for (let i = 0; i < node.length; i++) {
2972
+ const c = node[i]
2973
+ if (Array.isArray(c)) node[i] = foldNode(c)
2974
+ }
2975
+ // Match:
2976
+ // ['block', ['result','f64'],
2977
+ // ['local.set', $B, EXPR_A],
2978
+ // ['if', ['result','f64'],
2979
+ // ['call','$__is_str_key', ['i64.reinterpret_f64', ['local.tee',$C,['local.get',$P]]]],
2980
+ // ['then', ['call','$__str_concat',...]],
2981
+ // ['else', ['f64.add', ['local.get',$B], ['local.get',$C]]]]]
2982
+ if (node[0] !== 'block') return node
2983
+ if (!Array.isArray(node[1]) || node[1][0] !== 'result' || node[1][1] !== 'f64') return node
2984
+ if (node.length !== 4) return node
2985
+ const setStmt = node[2], ifStmt = node[3]
2986
+ if (!Array.isArray(setStmt) || setStmt[0] !== 'local.set' || typeof setStmt[1] !== 'string') return node
2987
+ const B = setStmt[1], exprA = setStmt[2]
2988
+ if (!Array.isArray(ifStmt) || ifStmt[0] !== 'if') return node
2989
+ // if must have: (result f64), cond, then, else — total 5 elements
2990
+ if (ifStmt.length !== 5) return node
2991
+ if (!Array.isArray(ifStmt[1]) || ifStmt[1][0] !== 'result' || ifStmt[1][1] !== 'f64') return node
2992
+ const cond = ifStmt[2], thenB = ifStmt[3], elseB = ifStmt[4]
2993
+ // cond: ['call','$__is_str_key',['i64.reinterpret_f64',['local.tee',$C,['local.get',$P]]]]
2994
+ if (!Array.isArray(cond) || cond[0] !== 'call' || cond[1] !== '$__is_str_key' || cond.length !== 3) return node
2995
+ const reinterpArg = cond[2]
2996
+ if (!Array.isArray(reinterpArg) || reinterpArg[0] !== 'i64.reinterpret_f64' || reinterpArg.length !== 2) return node
2997
+ const teeNode = reinterpArg[1]
2998
+ if (!Array.isArray(teeNode) || teeNode[0] !== 'local.tee' || typeof teeNode[1] !== 'string' || teeNode.length !== 3) return node
2999
+ const C = teeNode[1]
3000
+ const getP = teeNode[2]
3001
+ if (!Array.isArray(getP) || getP[0] !== 'local.get' || typeof getP[1] !== 'string') return node
3002
+ const P = getP[1]
3003
+ // $P must be a proven f64 local (never a string-key NaN-box)
3004
+ if (!rawF64.has(P)) return node
3005
+ // then: ['then', ['call','$__str_concat',...]]
3006
+ if (!Array.isArray(thenB) || thenB[0] !== 'then') return node
3007
+ // else: ['else', ['f64.add', ['local.get',$B], ['local.get',$C]]]
3008
+ if (!Array.isArray(elseB) || elseB[0] !== 'else' || elseB.length !== 2) return node
3009
+ const addExpr = elseB[1]
3010
+ if (!Array.isArray(addExpr) || addExpr[0] !== 'f64.add' || addExpr.length !== 3) return node
3011
+ // The two operands of f64.add must be local.get $B and local.get $C (in either order)
3012
+ const [lhsAdd, rhsAdd] = [addExpr[1], addExpr[2]]
3013
+ const lhsIsB = Array.isArray(lhsAdd) && lhsAdd[0] === 'local.get' && lhsAdd[1] === B
3014
+ const rhsIsC = Array.isArray(rhsAdd) && rhsAdd[0] === 'local.get' && rhsAdd[1] === C
3015
+ const lhsIsC = Array.isArray(lhsAdd) && lhsAdd[0] === 'local.get' && lhsAdd[1] === C
3016
+ const rhsIsB = Array.isArray(rhsAdd) && rhsAdd[0] === 'local.get' && rhsAdd[1] === B
3017
+ if (!((lhsIsB && rhsIsC) || (lhsIsC && rhsIsB))) return node
3018
+ // Match confirmed. Fold to: (f64.add EXPR_A (local.get $P))
3019
+ return ['f64.add', exprA, ['local.get', P]]
3020
+ }
3021
+
3022
+ for (let i = bodyStart; i < fn.length; i++) fn[i] = foldNode(fn[i])
3023
+ }
3024
+
3025
+ /**
3026
+ * Loop-unswitch a polymorphic typed-array PARAM loop on the pointer type so the
3027
+ * Float64Array case hoists its base and vectorizes.
3028
+ *
3029
+ * `export function f(buf,n){ for(let i=0;i<n;i++) buf[i]=g(buf[i],i) }` emits a
3030
+ * per-iteration POLYMORPHIC store `(drop (if tag(buf)==ARRAY (then __arr_set_idx_ptr;
3031
+ * local.set $buf) (else f64.store __ptr_offset(buf)+i<<3)))` and a read
3032
+ * `__to_num(reinterpret(__typed_idx(reinterpret(buf), i)))` — re-decoding the NaN-box
3033
+ * base every iteration. The `local.set $buf` realloc reassign marks the param unsafe,
3034
+ * so hoistInvariantPtrOffset bails and the loop never vectorizes.
3035
+ *
3036
+ * Insert a ONCE-before-loop test "is buf a (non-BigInt) Float64Array?": yes → a fast
3037
+ * loop with the base hoisted to an i32 local, the read collapsed to `f64.load`, and the
3038
+ * polymorphic store replaced by a direct `f64.store` (no calls) — which vectorizeLaneLocal
3039
+ * then lifts to f64x2. no → the original block verbatim (bit-exact fallback for ARRAY and
3040
+ * every other element width). Float64Array (owned aux=7 or view aux=15) is the ONLY gated
3041
+ * type: the else-branch f64.store is 8-byte, valid only for f64 elements; Int32Array /
3042
+ * Uint8Array / BigInt64Array (aux 4 / 1 / 23) all fall to the verbatim path. The global-
3043
+ * Float64Array path already lowers reads to f64.load, proving f64.load == the __to_num
3044
+ * read for f64 elements (bit-exact, incl. NaN). All helpers are nested function decls
3045
+ * (no ctx param) per the self-host discipline.
3046
+ */
3047
+ export function unswitchTypedParamLoop(fn) {
3048
+ if (!Array.isArray(fn) || fn[0] !== 'func') return
3049
+ const bodyStart = findBodyStart(fn)
3050
+ if (bodyStart < 0) return
3051
+ const f64Params = new Set()
3052
+ for (let i = 2; i < bodyStart; i++) {
3053
+ const c = fn[i]
3054
+ if (Array.isArray(c) && c[0] === 'param' && typeof c[1] === 'string' && c[2] === 'f64') f64Params.add(c[1])
3055
+ }
3056
+ if (!f64Params.size) return
3057
+
3058
+ const F64 = TYPED_ELEM_CODE.Float64Array, F64V = F64 | TYPED_ELEM_VIEW_FLAG
3059
+ const newLocals = []
3060
+ let baseId = nextLocalId(fn, 'utb')
3061
+
3062
+ const clone = (n) => Array.isArray(n) ? n.map(clone) : n
3063
+ const has = (n, pred) => Array.isArray(n) && (pred(n) || n.some((c, i) => i > 0 && has(c, pred)))
3064
+ const writes = (n, name) => has(n, (x) => (x[0] === 'local.set' || x[0] === 'local.tee') && x[1] === name)
3065
+ const reintParam = (n, p) => Array.isArray(n) && n[0] === 'i64.reinterpret_f64' && Array.isArray(n[1]) && n[1][0] === 'local.get' && n[1][1] === p
3066
+ const typedIdx = (n, p) => Array.isArray(n) && n[0] === 'call' && n[1] === '$__typed_idx' && n.length >= 4 && reintParam(n[2], p)
3067
+
3068
+ // Clone, collapsing the typed-array read to a direct f64.load(base + IND<<3):
3069
+ // __to_num(reinterpret(__typed_idx(reinterpret P, IND))) — and the bare form too.
3070
+ function cloneRead(n, p, base) {
3071
+ if (!Array.isArray(n)) return n
3072
+ if (n[0] === 'call' && n[1] === '$__to_num' && n.length === 3
3073
+ && Array.isArray(n[2]) && n[2][0] === 'i64.reinterpret_f64' && typedIdx(n[2][1], p))
3074
+ return ['f64.load', ['i32.add', ['local.get', base], ['i32.shl', clone(n[2][1][3]), ['i32.const', 3]]]]
3075
+ if (typedIdx(n, p))
3076
+ return ['f64.load', ['i32.add', ['local.get', base], ['i32.shl', clone(n[3]), ['i32.const', 3]]]]
3077
+ return n.map((c, i) => i === 0 ? c : cloneRead(c, p, base))
3078
+ }
3079
+
3080
+ function processBlock(blockNode, parent, idx) {
3081
+ if (!Array.isArray(blockNode) || blockNode[0] !== 'block') return
3082
+ let loopNode = null, blockLabel = null
3083
+ const preamble = []
3084
+ for (let i = 1; i < blockNode.length; i++) {
3085
+ const c = blockNode[i]
3086
+ if (i === 1 && typeof c === 'string' && c.startsWith('$')) { blockLabel = c; continue }
3087
+ if (Array.isArray(c) && c[0] === 'loop') { if (loopNode) return; loopNode = c }
3088
+ else if (Array.isArray(c) && c[0] === 'local.set' && !loopNode) preamble.push(c)
3089
+ else if (Array.isArray(c)) return
3090
+ }
3091
+ if (!loopNode || !blockLabel) return
3092
+ const loopLabel = typeof loopNode[1] === 'string' && loopNode[1].startsWith('$') ? loopNode[1] : null
3093
+ if (!loopLabel) return
3094
+ const endIdx = loopNode.length - 1
3095
+ if (!(Array.isArray(loopNode[endIdx]) && loopNode[endIdx][0] === 'br' && loopNode[endIdx][1] === loopLabel)) return
3096
+ const incNode = loopNode[endIdx - 1]
3097
+ if (!Array.isArray(incNode) || incNode[0] !== 'local.set' || !Array.isArray(incNode[2]) || incNode[2][0] !== 'i32.add') return
3098
+ const incVar = incNode[1], inc = incNode[2]
3099
+ if (!(Array.isArray(inc[1]) && inc[1][0] === 'local.get' && inc[1][1] === incVar && Array.isArray(inc[2]) && inc[2][0] === 'i32.const' && inc[2][1] === 1)) return
3100
+ const body = loopNode.slice(3, endIdx - 1)
3101
+ if (body.length < 4) return
3102
+
3103
+ // Find the polymorphic-store `if` by scanning (it's followed by a `drop` of its
3104
+ // f64 result; in the IR the two are separate statements, not (drop (if …))).
3105
+ let storeIdx = -1, paramName = null, elseStore = null
3106
+ for (let i = 0; i < body.length; i++) {
3107
+ const c = body[i]
3108
+ if (!Array.isArray(c) || c[0] !== 'if' || !Array.isArray(c[1]) || c[1][0] !== 'result' || c[1][1] !== 'f64') continue
3109
+ let thenArm = null, elseArm = null
3110
+ for (let k = 2; k < c.length; k++) { const a = c[k]; if (Array.isArray(a)) { if (a[0] === 'then') thenArm = a; else if (a[0] === 'else') elseArm = a } }
3111
+ if (!thenArm || !elseArm) continue
3112
+ let p = null
3113
+ for (let k = 1; k < thenArm.length; k++) { const a = thenArm[k]; if (Array.isArray(a) && a[0] === 'local.set' && f64Params.has(a[1]) && Array.isArray(a[2]) && a[2][0] === 'local.get') p = a[1] }
3114
+ if (!p || !has(thenArm, (x) => x[0] === 'call' && x[1] === '$__arr_set_idx_ptr')) continue
3115
+ // The bare `f64.store(__ptr_offset(o)+i<<3)` is the non-ARRAY fallback. It may be
3116
+ // nested under an OBJECT/HASH → __dyn_set guard (emitPolymorphicElementStore's
3117
+ // dyn-prop safety fork) — descend to find it; the fast path replaces the whole
3118
+ // store with a direct f64.load/store anyway (a proven Float64Array is never an
3119
+ // OBJECT, so its dyn arm is dead there). Still bail on the 3-way __typed_set_idx
3120
+ // form (mixed element widths — the f64.store fallback isn't the sole non-ARRAY case).
3121
+ const findRawStore = (n) => {
3122
+ if (!Array.isArray(n)) return null
3123
+ if (n[0] === 'f64.store' && Array.isArray(n[1]) && n[1][0] === 'i32.add' &&
3124
+ Array.isArray(n[1][2]) && n[1][2][0] === 'i32.shl' &&
3125
+ has(n[1], (x) => x[0] === 'call' && x[1] === '$__ptr_offset')) return n
3126
+ for (let k = 1; k < n.length; k++) { const r = findRawStore(n[k]); if (r) return r }
3127
+ return null
3128
+ }
3129
+ if (has(elseArm, (x) => x[0] === 'call' && x[1] === '$__typed_set_idx')) continue
3130
+ const es = findRawStore(elseArm)
3131
+ if (!es) continue
3132
+ storeIdx = i; paramName = p; elseStore = es; break
3133
+ }
3134
+ if (storeIdx < 0) return
3135
+ const shiftIdx = elseStore[1][2][1] // the index from the store's (i32.shl IDX 3)
3136
+ // The read uses the IV directly; the store uses a snapshot `$asi = $iv`. Emit the
3137
+ // store against the IV too so the vectorizer unifies the load/store lanes — bit-exact
3138
+ // ($asi == $iv). Bail if the store index isn't the IV or a snapshot of it.
3139
+ let storeIdxName = null
3140
+ if (Array.isArray(shiftIdx) && shiftIdx[0] === 'local.get') storeIdxName = shiftIdx[1]
3141
+ if (storeIdxName !== incVar &&
3142
+ !body.some((st) => Array.isArray(st) && st[0] === 'local.set' && st[1] === storeIdxName && Array.isArray(st[2]) && st[2][0] === 'local.get' && st[2][1] === incVar)) return
3143
+ // The store-if pushes f64; a following `drop` (bare string in stack-style IR, or a
3144
+ // `['drop', …]` node) pops it. The fast store pushes nothing, so the drop must go too.
3145
+ const isDrop = (s) => s === 'drop' || (Array.isArray(s) && s[0] === 'drop')
3146
+ const hasDrop = storeIdx + 1 < body.length && isDrop(body[storeIdx + 1])
3147
+
3148
+ // The stored value is the else-store's operand (a local the body computed); take it
3149
+ // from the store itself, not by guessing which local reads buf — the read may be
3150
+ // nested in a split computation (`$t = buf[i]; $v = $t*2`) whose result is a DIFFERENT
3151
+ // local. cloneRead rewrites any buf reads in that computation to f64.load.
3152
+ if (!(Array.isArray(elseStore[2]) && elseStore[2][0] === 'local.get')) return
3153
+ const valName = elseStore[2][1]
3154
+ // GUARD: param reassigned ONLY inside the matched store-if (else the hoisted base goes stale).
3155
+ for (let i = 0; i < body.length; i++) { if (i === storeIdx) continue; if (writes(body[i], paramName)) return }
3156
+ for (const s of preamble) { if (writes(s, paramName)) return }
3157
+
3158
+ const base = `$__utb${baseId++}`
3159
+ newLocals.push(['local', base, 'i32'])
3160
+ const reint = () => ['i64.reinterpret_f64', ['local.get', paramName]]
3161
+ const tag = ['i32.and', ['i32.wrap_i64', ['i64.shr_u', reint(), ['i64.const', LAYOUT.TAG_SHIFT]]], ['i32.const', LAYOUT.TAG_MASK]]
3162
+ const auxOf = () => ['i32.and', ['i32.wrap_i64', ['i64.shr_u', reint(), ['i64.const', LAYOUT.AUX_SHIFT]]], ['i32.const', LAYOUT.AUX_MASK]]
3163
+ const gate = ['i32.and', ['i32.eq', tag, ['i32.const', PTR.TYPED]],
3164
+ ['i32.or', ['i32.eq', auxOf(), ['i32.const', F64]], ['i32.eq', auxOf(), ['i32.const', F64V]]]]
3165
+ const baseSnap = ['local.set', base, ['call', '$__ptr_offset', reint()]]
3166
+ const fastStore = ['f64.store', ['i32.add', ['local.get', base], ['i32.shl', ['local.get', incVar], ['i32.const', 3]]], ['local.get', valName]]
3167
+ // Fast body: keep every statement except the store-if (→ fastStore) and its trailing
3168
+ // drop (the fast store pushes nothing), with the typed-array read collapsed to f64.load.
3169
+ const fastStmts = []
3170
+ for (let i = 0; i < body.length; i++) {
3171
+ if (i === storeIdx) { fastStmts.push(fastStore); continue }
3172
+ if (hasDrop && i === storeIdx + 1) continue
3173
+ fastStmts.push(cloneRead(body[i], paramName, base))
3174
+ }
3175
+ const fastLoop = ['block', blockLabel, ...preamble.map(clone),
3176
+ ['loop', loopLabel, clone(loopNode[2]), ...fastStmts, clone(incNode), clone(loopNode[endIdx])]]
3177
+ parent[idx] = ['if', gate, ['then', baseSnap, fastLoop], ['else', blockNode]]
3178
+ }
3179
+
3180
+ function walk(node, parent, idx) {
3181
+ if (!Array.isArray(node)) return
3182
+ if (node[0] === 'block') {
3183
+ const before = parent[idx]
3184
+ processBlock(node, parent, idx)
3185
+ if (parent[idx] !== before) return
3186
+ }
3187
+ for (let i = 0; i < node.length; i++) walk(node[i], node, i)
3188
+ }
3189
+ for (let i = bodyStart; i < fn.length; i++) walk(fn[i], fn, i)
3190
+ if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
3191
+ }
3192
+
2377
3193
  /**
2378
3194
  * Run all per-function IR optimizations on a single function node.
2379
3195
  * hoistPtrType runs first — it introduces new locals (`$__ptN`) that the fused
@@ -2403,6 +3219,11 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
2403
3219
  cfg.promoteGlobals === false &&
2404
3220
  cfg.sortLocalsByUse === false &&
2405
3221
  cfg.vectorizeLaneLocal === false) return
3222
+ // Recursion-unrolling runs first in 'pre': self-calls are still clean `call`
3223
+ // nodes (watr's inliner hasn't reshaped them) and the freshly-inlined body then
3224
+ // rides every pass below (LICM, fold, sort). Speed-tier only; 'pre' only (so the
3225
+ // post-watr re-optimize doesn't unroll a second time).
3226
+ if (cfg && cfg.recursionUnroll === true && phase === 'pre') recursionUnroll(fn)
2406
3227
  if (!cfg || cfg.hoistPtrType !== false) hoistPtrType(fn)
2407
3228
  if (!cfg || cfg.hoistInvariantPtrOffset !== false) hoistInvariantPtrOffset(fn)
2408
3229
  // Before LICM: the snapped i32 bound is itself a hoistable hard-op subtree, so
@@ -2414,6 +3235,7 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
2414
3235
  if (!cfg || cfg.hoistInvariantLoop !== false) hoistInvariantLoop(fn)
2415
3236
  const counts = new Map()
2416
3237
  if (!cfg || cfg.fusedRewrite !== false) fusedRewrite(fn, counts)
3238
+ if (cfg && cfg.boolConvertToSelect === true) boolConvertToSelect(fn)
2417
3239
  if (!cfg || cfg.hoistAddrBase !== false) hoistAddrBase(fn)
2418
3240
  if (!cfg || cfg.hoistInvariantLoop !== false) hoistInvariantLoop(fn)
2419
3241
  if (!cfg || cfg.cseScalarLoad !== false) cseScalarLoad(fn)
@@ -2432,8 +3254,48 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
2432
3254
  if (cfg && cfg.vectorizeLaneLocal === true) {
2433
3255
  const fullWatr = cfg.watr === true || typeof cfg.watr === 'object'
2434
3256
  const runVectorizer = (fullWatr && phase === 'post') || (!fullWatr && phase !== 'post')
2435
- if (runVectorizer) vectorizeLaneLocal(fn)
3257
+ // Phase 1: fold dead string-dispatch blocks on proven-f64 locals BEFORE
3258
+ // the vectorizer pattern-matches — dead __is_str_key calls in $fbm-style
3259
+ // functions (param f64 + op f64) block liftPPC from recognizing them as pure.
3260
+ if (runVectorizer && (!cfg || cfg.unswitchTypedParamLoop !== false)) unswitchTypedParamLoop(fn)
3261
+ if (runVectorizer) foldStrDispatchF64(fn)
3262
+ if (runVectorizer) vectorizeLaneLocal(fn, {
3263
+ multiAcc: cfg.reduceUnroll === true,
3264
+ relaxedFma: cfg.relaxedSimd === true,
3265
+ blurMP: cfg.blurMultiPixel !== false,
3266
+ whyNot: cfg.whyNotSimd === true,
3267
+ stencil: cfg.experimentalStencil !== false,
3268
+ outerStrip: cfg.experimentalOuterStrip !== false,
3269
+ pureFuncMap: cfg._pureFuncMap || null,
3270
+ toneMap: cfg.experimentalToneMap !== false,
3271
+ slp: cfg.experimentalSlp !== false, // SLP default-on (testing single-use fix)
3272
+ })
3273
+ // The vectorizer emits `v128.load/store (i32.add base K)` for the unrolled
3274
+ // multi-accumulator reduction (a[i],a[i+2],a[i+4]…) and stencil/strided reads.
3275
+ // fusedRewrite's memarg fold already ran (above, before vectorize), so fold the
3276
+ // freshly-created v128 memargs now — one fewer i32.add per accumulator per
3277
+ // iteration, the hot-loop waste audit-fixpoint.mjs flagged on dot/sum.
3278
+ if (runVectorizer) foldV128Memargs(fn)
2436
3279
  }
3280
+ // SSA-split loop-private unrolled scratch (post-vectorize: vectorized loops now carry
3281
+ // v128 and are skipped) so the LICM below hoists the per-iteration invariants the
3282
+ // unroller's name-merging hid — rust/LLVM's free-after-unroll register hoist (closes
3283
+ // the raytrace per-sphere `c_i` recompute). Bit-exact; re-run LICM to lift the splits.
3284
+ if (phase === 'post' && (!cfg || cfg.hoistInvariantLoop !== false)) {
3285
+ splitLoopPrivateScratch(fn)
3286
+ // Iterate LICM for the dependency cascade: c = ox²+… is invariant only once ox is
3287
+ // itself hoisted out, which the single-pass hoister can't see in one go. 4 climbs
3288
+ // covers the deepest scratch chain; idempotent, so it self-terminates.
3289
+ for (let k = 0; k < 4; k++) hoistInvariantLoop(fn)
3290
+ }
3291
+ // Loop rotation — the LAST shape pass (post-watr only, so no later pass reverts
3292
+ // it and the v128 loops are already formed for the skip-guard). Speed-tier: it
3293
+ // duplicates the loop condition for a fused conditional back-edge (1.35× on the
3294
+ // lz/qoi scalar scans — see rotateLoops).
3295
+ if (cfg && cfg.rotateLoops === true && phase === 'post') rotateLoops(fn)
3296
+ // Canonicalize boolean conditions (strip redundant `!= 0` / double-`eqz`) — after
3297
+ // rotateLoops so its fused back-edges get cleaned too. Tied to the peephole pass.
3298
+ if (!cfg || cfg.fusedRewrite !== false) simplifyBoolContexts(fn)
2437
3299
  if (!cfg || cfg.sortLocalsByUse !== false) sortLocalsByUse(fn, cfg && cfg.fusedRewrite !== false ? counts : null)
2438
3300
  // An optimizer pass that emits a malformed local — the class that otherwise dies
2439
3301
  // as an opaque watr "Duplicate/Unknown local $x" several phases on — is caught
@@ -2441,6 +3303,180 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
2441
3303
  if (DBG_IR) { const bad = verifyFn(fn); if (bad) throw new Error(`[ir verify] optimize produced invalid IR in ${fn[1]}: ${bad}`) }
2442
3304
  }
2443
3305
 
3306
+ // Fold `(v128.load/store (i32.add base K) …)` → `(… offset=K base …)`. Same logic as
3307
+ // walkRewrite's scalar foldMemargOffsets (MEMOP path), but for the v128 loads/stores the
3308
+ // lane vectorizer creates AFTER fusedRewrite has already run — so they'd otherwise keep a
3309
+ // per-iteration i32.add. Bottom-up, in place; an addr already in offset=/align= form is left.
3310
+ function foldV128Memargs(node) {
3311
+ if (!Array.isArray(node)) return
3312
+ const op = node[0]
3313
+ if (op === 'v128.load' || op === 'v128.store') {
3314
+ const m1 = node[1]
3315
+ if (!(typeof m1 === 'string' && (m1.startsWith('offset=') || m1.startsWith('align='))) &&
3316
+ Array.isArray(m1) && m1[0] === 'i32.add' && m1.length === 3) {
3317
+ const a = m1[1], b = m1[2]
3318
+ let base, offset
3319
+ if (Array.isArray(b) && b[0] === 'i32.const' && typeof b[1] === 'number' && b[1] >= 0 && b[1] < 0x100000000) { base = a; offset = b[1] }
3320
+ else if (Array.isArray(a) && a[0] === 'i32.const' && typeof a[1] === 'number' && a[1] >= 0 && a[1] < 0x100000000) { base = b; offset = a[1] }
3321
+ if (base != null) { node[1] = `offset=${offset}`; node.splice(2, 0, base) }
3322
+ }
3323
+ }
3324
+ for (let i = 1; i < node.length; i++) foldV128Memargs(node[i])
3325
+ }
3326
+
3327
+ // i32 comparison/eqz negations — used to flip a break-condition into the
3328
+ // loop-continue condition. f64 compares are deliberately ABSENT: ¬(a<b) ≠ (a≥b)
3329
+ // across NaN, so those fall through to the `i32.eqz` wrap below.
3330
+ const ROT_NEG = {
3331
+ 'i32.eqz': null, // sentinel: strip the eqz (handled specially)
3332
+ 'i32.eq': 'i32.ne', 'i32.ne': 'i32.eq',
3333
+ 'i32.lt_s': 'i32.ge_s', 'i32.ge_s': 'i32.lt_s', 'i32.gt_s': 'i32.le_s', 'i32.le_s': 'i32.gt_s',
3334
+ 'i32.lt_u': 'i32.ge_u', 'i32.ge_u': 'i32.lt_u', 'i32.gt_u': 'i32.le_u', 'i32.le_u': 'i32.gt_u',
3335
+ }
3336
+
3337
+ // Boolean-context canonicalization. At a true zero/nonzero position — a `br_if`,
3338
+ // `if`, `i32.eqz`, or `select` CONDITION — these are all equivalent to the inner
3339
+ // value: `i32.ne(X, 0) → X`, `i32.ne(0, X) → X`, `i32.eqz(i32.eqz(X)) → X`. jz
3340
+ // emits the redundant compare from `while (x !== 0)` lowering and from rotateLoops'
3341
+ // `negate` (which strips one `eqz` but leaves the `i32.ne`). V8 happens to fold it,
3342
+ // but JSC/wasmtime needn't — so strip it for MINIMAL output regardless of engine.
3343
+ // Only applied at proven boolean positions (never on a value-position `ne`/`eqz`,
3344
+ // which produce a real 0/1).
3345
+ const boolSimp = (n) => {
3346
+ for (;;) {
3347
+ if (!Array.isArray(n)) return n
3348
+ if (n[0] === 'i32.ne' && n.length === 3) {
3349
+ if (Array.isArray(n[2]) && n[2][0] === 'i32.const' && n[2][1] === 0) { n = n[1]; continue }
3350
+ if (Array.isArray(n[1]) && n[1][0] === 'i32.const' && n[1][1] === 0) { n = n[2]; continue }
3351
+ }
3352
+ if (n[0] === 'i32.eqz' && Array.isArray(n[1]) && n[1][0] === 'i32.eqz' && n[1].length === 2) { n = n[1][1]; continue }
3353
+ return n
3354
+ }
3355
+ }
3356
+ function simplifyBoolContexts(fn) {
3357
+ const walk = (node) => {
3358
+ if (!Array.isArray(node)) return
3359
+ for (let i = 1; i < node.length; i++) walk(node[i])
3360
+ const op = node[0]
3361
+ if (op === 'br_if' && node.length === 3) node[2] = boolSimp(node[2])
3362
+ else if (op === 'i32.eqz' && node.length === 2) node[1] = boolSimp(node[1])
3363
+ else if (op === 'if') { const ci = (Array.isArray(node[1]) && node[1][0] === 'result') ? 2 : 1; if (Array.isArray(node[ci])) node[ci] = boolSimp(node[ci]) }
3364
+ else if (op === 'select' && node.length === 4 && Array.isArray(node[3])) node[3] = boolSimp(node[3])
3365
+ }
3366
+ const bodyStart = findBodyStart(fn)
3367
+ for (let i = bodyStart; i < fn.length; i++) walk(fn[i])
3368
+ }
3369
+
3370
+ /**
3371
+ * Loop rotation (loop inversion). Convert jz's top-test loop idiom
3372
+ * (block $brk (loop $loop (br_if $brk ¬C) BODY… (br $loop)))
3373
+ * into a guarded bottom-test loop with a FUSED conditional back-edge:
3374
+ * (block $brk (br_if $brk ¬C) (loop $loop BODY… (br_if $loop C)))
3375
+ *
3376
+ * V8/TurboFan lowers the fused `br_if $loop C` to one hardware loop branch — the
3377
+ * shape LLVM gives rust/zig, and the reason their hot scalar loops (lz's greedy
3378
+ * match-scan, qoi's run-length scan) beat jz's top-test form, which compiles to a
3379
+ * forward exit-branch PLUS a separate unconditional back-jump. Measured 1.35× on
3380
+ * the lz inner loop; nothing else jz runs reaches this shape — watr's `loopify`
3381
+ * collapses to `loop { if C { …; br } }`, whose back-jump stays UNfused (no win).
3382
+ *
3383
+ * Evaluation count of C is unchanged: guard-once + one back-edge per iteration ==
3384
+ * the top-test form's once-per-loop-top — so it's sound even when C has side
3385
+ * effects (a `local.tee` recurrence, a call). The condition is duplicated only in
3386
+ * the EMITTED text (guard + back-edge), a small size-for-speed trade — speed-tier.
3387
+ *
3388
+ * Conservative skips:
3389
+ * - any v128/SIMD op in the loop — already register-tight; reshaping risks
3390
+ * disturbing the lane structure (mirrors hoistInvariantLoop's hasV128 guard).
3391
+ * - a body that branches to $loop: a `continue` with no step lands on the loop
3392
+ * label, which after rotation sits BEFORE the back-edge test — rotating would
3393
+ * skip it. (jz wraps continue-with-step in a `$cont` block → targets that, not
3394
+ * $loop → still rotatable.)
3395
+ */
3396
+ function rotateLoops(fn) {
3397
+ if (!Array.isArray(fn) || fn[0] !== 'func') return
3398
+ const bodyStart = findBodyStart(fn)
3399
+ if (bodyStart < 0) return
3400
+
3401
+ const clone = (n) => Array.isArray(n) ? n.map(clone) : n
3402
+ // Break-condition C → loop-continue condition ¬C for the back-edge. Fold the
3403
+ // i32 forms so the back-edge stays ONE fused compare-and-branch (a wrapping
3404
+ // `i32.eqz` would add an op inside the hot loop); everything else wraps.
3405
+ const negate = (c) => {
3406
+ if (Array.isArray(c) && c[0] === 'i32.eqz' && c.length === 2) return c[1]
3407
+ if (Array.isArray(c) && c.length === 3 && ROT_NEG[c[0]]) return [ROT_NEG[c[0]], c[1], c[2]]
3408
+ return ['i32.eqz', c]
3409
+ }
3410
+ const targetsLabel = (n, label) => {
3411
+ if (!Array.isArray(n)) return false
3412
+ const op = n[0]
3413
+ if (op === 'br' || op === 'br_if') { if (n[1] === label) return true }
3414
+ else if (op === 'br_table') { for (let i = 1; i < n.length; i++) if (n[i] === label) return true }
3415
+ for (let i = 1; i < n.length; i++) if (targetsLabel(n[i], label)) return true
3416
+ return false
3417
+ }
3418
+ const hasV128 = (n) => Array.isArray(n) && (
3419
+ (typeof n[0] === 'string' && (n[0].startsWith('v128.') || /^[if]\d+x\d+\./.test(n[0]))) ||
3420
+ n.some((c, i) => i > 0 && hasV128(c)))
3421
+
3422
+ const tryRotate = (blk) => {
3423
+ let bi = 1, blockLabel = null
3424
+ if (typeof blk[1] === 'string' && blk[1][0] === '$') { blockLabel = blk[1]; bi = 2 }
3425
+ if (!blockLabel) return null
3426
+ // The loop must be the block's final child; LICM may hoist invariant snaps into
3427
+ // a `local.set` pre-header before it — keep those ahead of the guard (the guard
3428
+ // condition can read them). Bail on anything else (typed blocks, side computations).
3429
+ const preamble = []
3430
+ let loop = null
3431
+ for (let i = bi; i < blk.length; i++) {
3432
+ const c = blk[i]
3433
+ if (Array.isArray(c) && c[0] === 'loop') { if (loop || i !== blk.length - 1) return null; loop = c }
3434
+ else if (Array.isArray(c) && c[0] === 'local.set' && !loop) preamble.push(c)
3435
+ else return null
3436
+ }
3437
+ if (!loop) return null
3438
+ let li = 1, loopLabel = null
3439
+ if (typeof loop[1] === 'string' && loop[1][0] === '$') { loopLabel = loop[1]; li = 2 }
3440
+ if (!loopLabel) return null
3441
+ const loopHeader = []
3442
+ while (li < loop.length) {
3443
+ const c = loop[li]
3444
+ if (Array.isArray(c) && c[0] === 'type') { loopHeader.push(c); li++; continue }
3445
+ if (Array.isArray(c) && (c[0] === 'param' || c[0] === 'result')) return null
3446
+ break
3447
+ }
3448
+ const body = loop.slice(li)
3449
+ if (body.length < 2) return null
3450
+ const head = body[0], tail = body[body.length - 1]
3451
+ if (!(Array.isArray(head) && head[0] === 'br_if' && head[1] === blockLabel && head.length === 3)) return null
3452
+ if (!(Array.isArray(tail) && tail[0] === 'br' && tail[1] === loopLabel && tail.length === 2)) return null
3453
+ const inner = body.slice(1, -1)
3454
+ if (inner.some((s) => targetsLabel(s, loopLabel))) return null // continue → loop top: unsafe
3455
+ if (hasV128(head) || inner.some(hasV128)) return null // vectorized: leave tight
3456
+ const cond = head[2]
3457
+ return ['block', blockLabel, ...preamble,
3458
+ ['br_if', blockLabel, clone(cond)],
3459
+ ['loop', loopLabel, ...loopHeader, ...inner, ['br_if', loopLabel, negate(cond)]]]
3460
+ }
3461
+
3462
+ // Rotate a (block …) at container[i] in place, else descend. Returns true if it fired.
3463
+ const tryAt = (container, i) => {
3464
+ const c = container[i]
3465
+ if (!Array.isArray(c) || c[0] !== 'block') return false
3466
+ const rot = tryRotate(c)
3467
+ if (!rot) return false
3468
+ container[i] = rot
3469
+ walk(rot)
3470
+ return true
3471
+ }
3472
+ const walk = (node) => {
3473
+ if (!Array.isArray(node)) return
3474
+ for (let i = 0; i < node.length; i++) if (!tryAt(node, i)) walk(node[i])
3475
+ }
3476
+ // Top-level statements (a loop block can BE fn[i], not just nested under one).
3477
+ for (let i = bodyStart; i < fn.length; i++) if (!tryAt(fn, i)) walk(fn[i])
3478
+ }
3479
+
2444
3480
  // The i32 form of an integer-valued f64 expression, or null. Used to push ToInt32
2445
3481
  // through a conditional and to collapse the f64 round-trip on integer `+`/`-`.
2446
3482
  // Lossless by construction: `convert_i32(X) → X`; integer `f64.const → i32.const`
@@ -2461,6 +3497,17 @@ function toI32(n) {
2461
3497
  const a = toI32(n[1]), b = toI32(n[2])
2462
3498
  if (a && b) return [op === 'f64.add' ? 'i32.add' : 'i32.sub', a, b]
2463
3499
  }
3500
+ // ToInt32 distributes through a conditional: ToInt32(if C A B) == if(result i32) C
3501
+ // ToInt32(A) ToInt32(B). Recursive — a nested integer `?:` like `((3<a)?(2&a):((7<a)?a:1))|0`
3502
+ // narrows whole to i32 (each arm folded by toI32, incl. nested ifs), so the lane vectorizer
3503
+ // lifts it as i32x4 bitselect instead of bailing on the f64 result. Only reached from
3504
+ // ToInt32 sinks (the select idiom / toI32 recursion), so the i32 result is always wanted.
3505
+ if (op === 'if' && Array.isArray(n[1]) && n[1][0] === 'result' && n[1][1] === 'f64'
3506
+ && Array.isArray(n[3]) && n[3][0] === 'then' && n[3].length === 2
3507
+ && Array.isArray(n[4]) && n[4][0] === 'else' && n[4].length === 2) {
3508
+ const t = toI32(n[3][1]), e = toI32(n[4][1])
3509
+ if (t && e) return ['if', ['result', 'i32'], n[2], ['then', t], ['else', e]]
3510
+ }
2464
3511
  return null
2465
3512
  }
2466
3513
 
@@ -2500,18 +3547,38 @@ function fusedRewrite(fn, counts) {
2500
3547
  }
2501
3548
  const freshI64 = () => { const n = `$__eqt${scratchN++}`; newDecls.push(['local', n, 'i64']); return n }
2502
3549
  const freshF64 = () => { const n = `$__eqf${scratchN++}`; newDecls.push(['local', n, 'f64']); return n }
3550
+ // Single-textual-def locals → their defining value node, so the trunc_sat range fold (below)
3551
+ // can see through the temps inlining introduces when proving an index/packed value fits i32.
3552
+ // Multi-def (incl. loop-carried self-referential) locals are excluded: their value is not the
3553
+ // one def's, so its range wouldn't bound them. Pure read of the IR — value-preserving rewrites
3554
+ // during this same walk keep the captured def's RANGE intact, so a lazily-built map stays sound.
3555
+ // Built on first query only (most functions carry no guarded-trunc form → zero cost).
3556
+ let defVal
3557
+ const get = (name) => {
3558
+ if (defVal === undefined) {
3559
+ defVal = new Map(); const defCnt = new Map()
3560
+ const scanDefs = (n) => {
3561
+ if (!Array.isArray(n)) return
3562
+ if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') { defCnt.set(n[1], (defCnt.get(n[1]) || 0) + 1); defVal.set(n[1], n[2]) }
3563
+ for (let i = 1; i < n.length; i++) scanDefs(n[i])
3564
+ }
3565
+ for (let i = bodyStart; i < fn.length; i++) scanDefs(fn[i])
3566
+ for (const [k, c] of defCnt) if (c > 1) defVal.delete(k)
3567
+ }
3568
+ return defVal.get(name) || null
3569
+ }
2503
3570
  for (let i = bodyStart; i < fn.length; i++) {
2504
3571
  const c = fn[i]
2505
- if (Array.isArray(c)) fn[i] = walkRewrite(c, !skipInline, counts, freshI64, freshF64)
3572
+ if (Array.isArray(c)) fn[i] = walkRewrite(c, !skipInline, counts, freshI64, freshF64, get)
2506
3573
  }
2507
3574
  if (newDecls.length) fn.splice(bodyStart, 0, ...newDecls)
2508
3575
  }
2509
3576
 
2510
- function walkRewrite(node, doInline, counts, freshI64, freshF64) {
3577
+ function walkRewrite(node, doInline, counts, freshI64, freshF64, get) {
2511
3578
  if (!Array.isArray(node)) return node
2512
3579
  for (let i = 0; i < node.length; i++) {
2513
3580
  const c = node[i]
2514
- if (Array.isArray(c)) node[i] = walkRewrite(c, doInline, counts, freshI64, freshF64)
3581
+ if (Array.isArray(c)) node[i] = walkRewrite(c, doInline, counts, freshI64, freshF64, get)
2515
3582
  }
2516
3583
  const op = node[0]
2517
3584
  // Piggyback local-ref counting for sortLocalsByUse. `counts` may be undefined
@@ -2692,16 +3759,20 @@ function walkRewrite(node, doInline, counts, freshI64, freshF64) {
2692
3759
  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
3760
  let inner = v[1][1]
2694
3761
  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).
3762
+ // ToInt32(integer-valued f64 expr) → its i32 form: covers (i32±i32)|0 sums AND the
3763
+ // conditional `?:` (toI32 distributes through `(if result f64)`, recursively).
2703
3764
  const i = toI32(inner)
2704
3765
  if (i) return i
3766
+ // Range fallback for the NON-integer-ring values toI32 rejects (`floor(scale·v)`,
3767
+ // `base + scale·v` — every grid/lattice/colour index): when the def chain — resolved
3768
+ // through single-def inlining temps via `get` — provably yields a finite i32-range value,
3769
+ // the +∞ guard is dead AND trunc_sat can't saturate, so the whole guarded select collapses
3770
+ // to one `i32.trunc_sat_f64_s`. SOUND: f64Range admits only pure nodes and proves
3771
+ // finiteness (kills the guard) + in-range (kills saturation), so the result is identical
3772
+ // ToInt32 on every value the program can produce. Drops the i64 round-trip + guard on all
3773
+ // runtimes (this is the post-inline twin of the emit-time fold at ir.js toI32).
3774
+ const rng = f64Range(inner, get)
3775
+ if (rng && rng.lo >= I32_MIN && rng.hi <= I32_MAX) return ['i32.trunc_sat_f64_s', inner]
2705
3776
  }
2706
3777
  }
2707
3778
  // (i32.or X 0) / (i32.or 0 X) → X — drops the redundant source-level `|0` clamp left
@@ -2712,6 +3783,30 @@ function walkRewrite(node, doInline, counts, freshI64, freshF64) {
2712
3783
  if (Array.isArray(a) && a[0] === 'i32.const' && a[1] === 0) return b
2713
3784
  }
2714
3785
 
3786
+ // if→select for a value-producing f64 `if` with PURE arms: (if (result f64) COND (then A)
3787
+ // (else B)) → (select A B COND). This is the branchless `cmov` lowering LLVM/clang apply to
3788
+ // every `cond ? a : b` — it removes the conditional branch (and its misprediction cost on
3789
+ // data-unpredictable conditions) on the whole class of float sign/clamp/reflect ternaries.
3790
+ // The flagship: noise's gradient `(h & 1) === 0 ? x : -x` (8 per perlin × 5 octaves × 65k px).
3791
+ // SOUND: wasm `select` evaluates BOTH arms unconditionally, and `isPureIR` admits only
3792
+ // side-effect-free, non-trapping ops (no load/call/div/rem) — so eager evaluation is safe; it
3793
+ // is the exact predicate emit.js uses for the same fold at emit time, now applied post-watr
3794
+ // where the arms (e.g. `f64.neg (local.get $x)`) are clean after canon-DCE. Gated to NOT fire
3795
+ // when BOTH arms are i32-narrowable — those stay an `if` for the ToInt32-through-if fold +
3796
+ // the i32x4-bitselect conditional-map vectorizer (don't steal the integer path).
3797
+ if (op === 'if' && node.length === 5 && Array.isArray(node[1]) && node[1][0] === 'result' && node[1][1] === 'f64'
3798
+ && Array.isArray(node[3]) && node[3][0] === 'then' && node[3].length === 2
3799
+ && Array.isArray(node[4]) && node[4][0] === 'else' && node[4].length === 2) {
3800
+ const a = node[3][1], b = node[4][1], cond = node[2]
3801
+ // The COND must also be pure: `if` evaluates cond FIRST then one arm, but wasm `select`
3802
+ // evaluates its arms BEFORE the cond. A short-circuit lowering like `a || b` =
3803
+ // `(if (result f64) is_truthy(local.tee $t a) (then get $t)(else b))` hides a `tee` in the
3804
+ // cond that the then-arm reads — reordering it after the arms reads $t stale. Requiring
3805
+ // isPureIR(cond) excludes every tee/call/short-circuit cond while admitting the pure
3806
+ // comparison conds of real float ternaries (noise's `(h & 1) === 0`).
3807
+ if (isPureIR(a) && isPureIR(b) && isPureIR(cond) && !(toI32(a) && toI32(b))) return ['select', a, b, cond]
3808
+ }
3809
+
2715
3810
  // f64.CMP(convert_i32 A, convert_i32 B) → i32.CMP(A, B). Comparing two i32 values is
2716
3811
  // identical whether done in exact f64 or in i32 (the converts are lossless and
2717
3812
  // order-preserving), so an integer comparison over typed-array loads (reads are f64)
@@ -2836,6 +3931,13 @@ export function treeshake(funcSections, allModuleNodes, opts) {
2836
3931
  for (let i = 2; i < fn.length; i++)
2837
3932
  if (Array.isArray(fn[i]) && fn[i][0] === 'export') { addRoot(name); break }
2838
3933
 
3934
+ // When user funcs are NOT being reclaimed (O0/O1 keep declared-but-uncalled ones), they
3935
+ // all survive — so they're roots for the *internal*-func reachability below. Otherwise an
3936
+ // unreachable user func that's kept would still call a `__helper`, yet that helper would be
3937
+ // pruned as unreached-from-exports, leaving a dangling `call $__helper`.
3938
+ if (!removeDead && opts && opts.userFuncs)
3939
+ for (const name of opts.userFuncs) addRoot(name)
3940
+
2839
3941
  const findRoots = (node) => {
2840
3942
  if (!Array.isArray(node)) return
2841
3943
  if (node[0] === 'start' && typeof node[1] === 'string') addRoot(node[1])
@@ -2863,24 +3965,43 @@ export function treeshake(funcSections, allModuleNodes, opts) {
2863
3965
  for (const fn of allFuncs) if (typeof fn[1] !== 'string') visitCalls(fn)
2864
3966
  while (stack.length) visitCalls(funcByName.get(stack.pop()))
2865
3967
 
3968
+ // Compiler-internal funcs (stdlib helpers, allocator wrappers — everything not in the
3969
+ // user's own `ctx.func.list`) carry no source meaning, so an unreachable one is reclaimed
3970
+ // at EVERY opt level: it's never a live-coding aid, just over-production (e.g. `s + '!'`
3971
+ // pulls the alloc trio's `__alloc_hdr`, which string concat never calls, and a dead-branch
3972
+ // dep like `__str_len`). User funcs are reclaimed only when DCE is on, so O0/O1 keep a
3973
+ // declared-but-uncalled user function. Absent the set, fall back to gating everything.
3974
+ const userFuncs = opts && opts.userFuncs
3975
+ const isUserFunc = (name) => userFuncs ? userFuncs.has(name) : true
2866
3976
  let removed = 0
2867
- if (removeDead) {
3977
+ if (removeDead || userFuncs) {
2868
3978
  for (const { arr } of funcSections) {
2869
3979
  for (let i = arr.length - 1; i >= 0; i--) {
2870
3980
  const n = arr[i]
2871
- if (Array.isArray(n) && n[0] === 'func' && typeof n[1] === 'string' && !reachable.has(n[1])) {
3981
+ if (Array.isArray(n) && n[0] === 'func' && typeof n[1] === 'string' && !reachable.has(n[1]) &&
3982
+ (removeDead || !isUserFunc(n[1]))) {
2872
3983
  arr.splice(i, 1); removed++
2873
3984
  }
2874
3985
  }
2875
3986
  }
2876
3987
  }
2877
3988
 
2878
- // Dead-global elimination: after dead funcs are gone, drop `(global $g …)` decls
2879
- // that nothing references (a `global.get`/`global.set` in a remaining func, a kept
2880
- // global's init expr, a data/elem offset, or an `(export … (global $g))`). Imported
2881
- // globals live in `allModuleNodes`, not in `opts.globals`, so they're never touched.
2882
- // Fixpoint: a kept global's init may reference another global.
2883
- const globals = removeDead && opts && Array.isArray(opts.globals) ? opts.globals : null
3989
+ // Dead-global elimination: drop `(global $g …)` decls that nothing references
3990
+ // (a `global.get`/`global.set` in a remaining func, a kept global's init expr, a
3991
+ // data/elem offset, or an `(export … (global $g))`). Imported globals live in
3992
+ // `allModuleNodes`, not in `opts.globals`, so they're never touched. Fixpoint: a
3993
+ // kept global's init may reference another global.
3994
+ //
3995
+ // Compiler-internal globals (support state the user never wrote — e.g. core's
3996
+ // `__heap_start` or the math module's `rng_state`, declared eagerly but read
3997
+ // only by specific fast paths) are reclaimed at *every* level: leaving an
3998
+ // unreferenced one in the output is pure noise, never a live-coding aid. User
3999
+ // globals are reclaimed only when DCE is on, so O0/O1 still preserve declared-
4000
+ // but-unused user bindings. `userGlobals` (names sans `$`) draws the line; absent
4001
+ // it, fall back to the `$__` reserved-prefix heuristic.
4002
+ const userGlobals = opts && opts.userGlobals
4003
+ const isUserGlobal = (name) => userGlobals ? userGlobals.has(name.slice(1)) : !name.startsWith('$__')
4004
+ const globals = opts && Array.isArray(opts.globals) ? opts.globals : null
2884
4005
  if (globals) {
2885
4006
  const collectGlobalRefs = (node, refd) => {
2886
4007
  if (!Array.isArray(node)) return
@@ -2897,9 +4018,11 @@ export function treeshake(funcSections, allModuleNodes, opts) {
2897
4018
  for (const g of globals) collectGlobalRefs(g, refd)
2898
4019
  for (let i = globals.length - 1; i >= 0; i--) {
2899
4020
  const g = globals[i]
2900
- if (Array.isArray(g) && g[0] === 'global' && typeof g[1] === 'string' && !refd.has(g[1])) {
2901
- globals.splice(i, 1); changed = true
2902
- }
4021
+ if (!Array.isArray(g) || g[0] !== 'global' || typeof g[1] !== 'string' || refd.has(g[1])) continue
4022
+ // An inline `(export )` on the decl pins it — it's part of the module's
4023
+ // JS-host surface (e.g. `__heap`), referenced from outside the wasm.
4024
+ if (g.some(c => Array.isArray(c) && c[0] === 'export')) continue
4025
+ if (removeDead || !isUserGlobal(g[1])) { globals.splice(i, 1); changed = true }
2903
4026
  }
2904
4027
  }
2905
4028
  }