jz 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +37 -33
  2. package/bench/README.md +176 -73
  3. package/bench/bench.svg +58 -71
  4. package/cli.js +12 -5
  5. package/dist/interop.js +1 -1
  6. package/dist/jz.js +1366 -1101
  7. package/index.js +40 -9
  8. package/interop.js +193 -138
  9. package/layout.js +29 -18
  10. package/module/array.js +49 -73
  11. package/module/collection.js +83 -25
  12. package/module/console.js +1 -1
  13. package/module/core.js +161 -15
  14. package/module/json.js +3 -3
  15. package/module/math.js +167 -117
  16. package/module/number.js +247 -13
  17. package/module/object.js +11 -5
  18. package/module/regex.js +8 -7
  19. package/module/string.js +295 -171
  20. package/module/typedarray.js +169 -105
  21. package/package.json +7 -3
  22. package/src/abi/string.js +40 -35
  23. package/src/ast.js +19 -2
  24. package/src/compile/analyze.js +64 -2
  25. package/src/compile/cse-load.js +200 -0
  26. package/src/compile/emit-assign.js +73 -14
  27. package/src/compile/emit.js +324 -34
  28. package/src/compile/index.js +204 -61
  29. package/src/compile/infer.js +8 -1
  30. package/src/compile/loop-divmod.js +12 -58
  31. package/src/compile/loop-model.js +91 -0
  32. package/src/compile/loop-recurrence.js +167 -0
  33. package/src/compile/loop-square.js +102 -0
  34. package/src/compile/narrow.js +180 -34
  35. package/src/compile/peel-stencil.js +18 -64
  36. package/src/compile/plan/common.js +29 -0
  37. package/src/compile/plan/index.js +4 -1
  38. package/src/compile/plan/inline.js +176 -21
  39. package/src/compile/plan/literals.js +93 -19
  40. package/src/ctx.js +51 -12
  41. package/src/helper-counters.js +137 -0
  42. package/src/ir.js +102 -13
  43. package/src/kind-traits.js +7 -3
  44. package/src/kind.js +14 -1
  45. package/src/op-policy.js +5 -2
  46. package/src/ops.js +119 -0
  47. package/src/optimize/index.js +1125 -136
  48. package/src/optimize/recurse.js +182 -0
  49. package/src/optimize/vectorize.js +1302 -144
  50. package/src/prepare/index.js +29 -12
  51. package/src/reps.js +4 -1
  52. package/src/type.js +53 -45
  53. package/src/wat/assemble.js +92 -9
  54. package/src/widen.js +21 -0
  55. package/src/wat/optimize.js +0 -3938
@@ -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()
@@ -66,7 +68,7 @@ const FALSE_BITS = atomNanHex(4)
66
68
  * 'speed' — full nested unroll + lane vectorization (= level 3).
67
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
@@ -95,13 +97,24 @@ export const PASS_NAMES = [
95
97
  'hoistInvariantLoop', // unified LICM (subsumes the former ToInt32/PtrOffsetLoop/CellLoads hoists)
96
98
  'narrowLoopBound', // f64 loop bound → hoisted i32 (unblocks the lane-vectorizer)
97
99
  'splitCharScan', // charCodeAt scan loops: split at min(N, s.length) → i32 char carrier (plan-level)
100
+ // Pre-analyze loop-shape transforms — applied in compile/index.js (NOT this pass pipeline), but
101
+ // gated by these flags. Listing them here is load-bearing: ALL_OFF sets them false so level 0/1
102
+ // (the self-host fast path) actually skip them, instead of `undefined !== false` running them
103
+ // unconditionally at every level. ALL_ON keeps them on at level 2+ where they belong.
104
+ 'loopIVDivMod', // strength-reduce per-iter `i%w` / `(i/w)|0` → incremental i32 counters
105
+ 'loopSquare', // bounded `i*i < CONST` → Math.imul (i32 product chain)
106
+ 'unrollRecurrence', // unit-stride DP/scan: scalar-replace arr[j-1]/arr[j] recurrence + ×2 unroll
107
+ 'clampPeel', // edge-clamp stencil: split into clamp-free interior (vectorizes) + edges
98
108
  'hoistGlobalPtrOffset', // stable typed GLOBALS: __ptr_offset resolve → once per function (post-watr, module-level)
99
109
  'fusedRewrite', // peephole + ptr-helper inline + memarg fold
100
110
  'hoistAddrBase',
111
+ 'boolConvertToSelect', // f64 ± (cond?1:0) → branchless select (kills i32↔f64 domain cross on recurrences)
101
112
  'cseScalarLoad',
102
113
  'csePureExpr',
114
+ 'unswitchTypedParamLoop', // Float64Array param loop-unswitch → base-hoisted f64.load/store fast path (vectorizes)
103
115
  'dropDeadZeroInit',
104
116
  'deadStoreElim',
117
+ 'propagateSingleUse', // forward-substitute single-def/single-use pure temps (watr's "propagate")
105
118
  'promoteGlobals', // read-only global.get → local for multi-read globals
106
119
  'sortLocalsByUse',
107
120
  'specializeMkptr',
@@ -113,6 +126,7 @@ export const PASS_NAMES = [
113
126
  'smallConstForUnroll',
114
127
  'nestedSmallConstForUnroll',
115
128
  'vectorizeLaneLocal', // SIMD-128 lift for lane-pure typed-array loops
129
+ 'recursionUnroll', // inline a single non-tail self-call to depth N (tree-recursion call-overhead)
116
130
  'arenaRewind', // per-call heap rewind for no-arg scalar allocator kernels
117
131
  'treeshake',
118
132
  'jsstring', // boundary opt-in: flip exported string params to externref
@@ -127,7 +141,9 @@ const LEVEL_PRESETS = Object.freeze({
127
141
  // force 'light' mode here (inline / inlineOnce / coalesce all off) to dodge the
128
142
  // W1a/W1b miscompiles; watr 4.6.9 fixes both, and the L2 default now runs the full
129
143
  // watr pipeline. `inline` stays off by watr's own default — opt-in only.
130
- 2: Object.freeze({ ...ALL_ON, nestedSmallConstForUnroll: 'auto' }),
144
+ // boolConvertToSelect off at the default level: it's a latency-for-size trade (adds a
145
+ // const + op per site) that only pays off on serial recurrences — speed-tier only.
146
+ 2: Object.freeze({ ...ALL_ON, nestedSmallConstForUnroll: 'auto', boolConvertToSelect: false, recursionUnroll: false }),
131
147
  // L3/'speed' trades a bit of heap headroom for fewer __arr_grow / __hash growth
132
148
  // cycles. arrayMinCap=16 means `[]` and `new Array()` skip the first two doublings
133
149
  // (0→2→4→8→16); hashSmallInitCap=8 keeps per-object __dyn_props at the same load
@@ -143,14 +159,21 @@ const LEVEL_PRESETS = Object.freeze({
143
159
  // closures). Inline `f64.const` is the minimal lowering: V8 CSEs identical
144
160
  // constants for free. Measured −3% on jessie parse for +14% binary — exactly
145
161
  // the size↔speed trade 'speed' exists to make.
146
- 3: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8, reduceUnroll: true, relaxedSimd: true, inlineFns: true }),
162
+ 3: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8, reduceUnroll: true, relaxedSimd: true, inlineFns: true, rotateLoops: true }),
147
163
  // 'size' tightens scalar/unroll caps; 'speed' = level 3. There is no 'balanced'
148
164
  // preset — it was a pure synonym for the default level 2 (omit `optimize` or pass 2).
149
165
  size: Object.freeze({
150
166
  ...ALL_ON,
151
167
  smallConstForUnroll: false, nestedSmallConstForUnroll: false, vectorizeLaneLocal: false, splitCharScan: false,
168
+ recursionUnroll: false, // body tripling is a size regression — speed-only
169
+ unrollRecurrence: false, // ×2 body duplication is a size regression — speed-only
170
+ clampPeel: false, // edge-clamp peel triples a stencil loop (clamp-free interior + 2 edges) to vectorize — speed-only
171
+
172
+ boolConvertToSelect: false, // adds a const + op per site — speed-only latency trade
152
173
  devirtIndirect: false, // guards + duplicated args grow bytes — speed-only trade
153
174
  internStrings: false, // the intern index costs ~16 B per eligible literal — speed-only trade
175
+ promoteGlobals: false, // snapshots a multi-read global into an entry local — pure speed (V8 can't CSE a mutable global); the snapshot is dead size weight at -Os
176
+ hoistInvariantLoop: false,// LICM: hoists loop-invariants to entry temps — a speed/latency trade whose entry cost outweighs the per-iter saving in bytes
154
177
  scalarTypedLoopUnroll: 4, scalarTypedNestedUnroll: 8, scalarTypedArrayLen: 8,
155
178
  }),
156
179
  // 'speed' === level 3: full watr (inlining on) + L3 cap/hash tuning, pool off.
@@ -163,7 +186,7 @@ const LEVEL_PRESETS = Object.freeze({
163
186
  // (The stencil + outer-strip vectorizers are NOT level-gated here: they're bit-exact pure wins
164
187
  // like the base lane vectorizer, so they run whenever it does — default-on at level 2+ via
165
188
  // `cfg.experimentalStencil !== false` at the call site, not a speed-only size/precision trade.)
166
- speed: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8, reduceUnroll: true, relaxedSimd: true, inlineFns: true }),
189
+ speed: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8, reduceUnroll: true, relaxedSimd: true, inlineFns: true, rotateLoops: true }),
167
190
  })
168
191
 
169
192
  /**
@@ -201,6 +224,11 @@ export function resolveOptimize(opt) {
201
224
  }
202
225
  // Preserve non-pass tuning keys (e.g. plan.js thresholds)
203
226
  for (const k of Object.keys(opt)) if (!PASS_NAMES.includes(k)) out[k] = opt[k]
227
+ // noSimd: suppress EVERY jz-emitted v128 — both the lane vectorizer AND the SLP
228
+ // store-pair packer. First-class here so `{ level:'speed', noSimd:true }` is a TRUE
229
+ // scalar baseline whether passed nested or via the top-level opts.noSimd flag; the
230
+ // SIMD-vs-scalar correctness oracles depend on it actually disabling SLP.
231
+ if (out.noSimd) { out.vectorizeLaneLocal = false; out.experimentalSlp = false }
204
232
  return out
205
233
  }
206
234
  return { ...ALL_ON }
@@ -399,12 +427,39 @@ function regionTrackCSE(fn, { matchSite, localPrefix, localType }) {
399
427
  * Must run AFTER fusedRewrite — relies on shl-distribution + assoc-lift +
400
428
  * foldMemargOffsets having normalized the base shape.
401
429
  */
430
+ // Pure i32 ops whose value is a function of locals/consts alone — no memory read,
431
+ // no call, no global. A subscript expression built only from these is invariant
432
+ // between two sites as long as none of its local deps is rewritten between them,
433
+ // so CSE-ing the WHOLE address (base + shl(idx)) is value-safe — even when `idx`
434
+ // is a compound stencil offset like `(i32.sub (i32.add idx W) 1)` for `arr[idx+W-1]`.
435
+ const PURE_I32_ADDR_OPS = new Set([
436
+ 'i32.add', 'i32.sub', 'i32.mul', 'i32.shl', 'i32.shr_s', 'i32.shr_u',
437
+ 'i32.and', 'i32.or', 'i32.xor', 'i32.wrap_i64',
438
+ ])
439
+ // Serialize a pure-i32 subscript to a stable key, accumulating its local deps.
440
+ // Returns null if any leaf isn't a local.get / i32.const / pure-i32 op (a load,
441
+ // call, or global.get could change between sites — not CSE-safe by local tracking).
442
+ function pureI32AddrKey(node, deps) {
443
+ if (!Array.isArray(node)) return null
444
+ const op = node[0]
445
+ if (op === 'local.get' && typeof node[1] === 'string') { deps.add(node[1]); return `$${node[1]}` }
446
+ if (op === 'i32.const' && typeof node[1] === 'number') return `#${node[1]}`
447
+ if (!PURE_I32_ADDR_OPS.has(op)) return null
448
+ let key = op + '('
449
+ for (let i = 1; i < node.length; i++) {
450
+ const sub = pureI32AddrKey(node[i], deps)
451
+ if (sub == null) return null
452
+ key += sub + ','
453
+ }
454
+ return key + ')'
455
+ }
456
+
402
457
  export function hoistAddrBase(fn) {
403
458
  return regionTrackCSE(fn, {
404
459
  matchSite(node) {
405
460
  if (node[0] !== 'i32.add' || node.length !== 3) return null
406
461
  const a = node[1], b = node[2]
407
- // Two orderings: (add (get A) (shl (get B) (const K))) or (add (shl …) (get A))
462
+ // Two orderings: (add (get A) (shl IDX (const K))) or (add (shl …) (get A))
408
463
  let baseGet, shlNode
409
464
  if (Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' &&
410
465
  Array.isArray(b) && b[0] === 'i32.shl' && b.length === 3) {
@@ -414,15 +469,88 @@ export function hoistAddrBase(fn) {
414
469
  baseGet = b; shlNode = a
415
470
  } else return null
416
471
  const idx = shlNode[1], shamt = shlNode[2]
417
- if (!Array.isArray(idx) || idx[0] !== 'local.get' || typeof idx[1] !== 'string') return null
418
472
  if (!Array.isArray(shamt) || shamt[0] !== 'i32.const' || typeof shamt[1] !== 'number') return null
419
- return { key: `${baseGet[1]}|${idx[1]}|${shamt[1]}`, deps: [baseGet[1], idx[1]] }
473
+ // idx may be a plain `local.get` (the original biquad case) or any compound
474
+ // pure-i32 subscript (stencil neighbour `arr[idx+W-1]`); both CSE the same way.
475
+ const deps = new Set([baseGet[1]])
476
+ const idxKey = pureI32AddrKey(idx, deps)
477
+ if (idxKey == null) return null
478
+ return { key: `${baseGet[1]}|${idxKey}|${shamt[1]}`, deps: [...deps] }
420
479
  },
421
480
  localPrefix: 'ab',
422
481
  localType: 'i32',
423
482
  })
424
483
  }
425
484
 
485
+ // wasm comparison ops — each yields an i32 that is exactly 0 or 1.
486
+ const BOOL_RESULT_OPS = new Set([
487
+ 'i32.eqz', 'i64.eqz',
488
+ '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',
489
+ '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',
490
+ 'f32.eq', 'f32.ne', 'f32.lt', 'f32.gt', 'f32.le', 'f32.ge',
491
+ 'f64.eq', 'f64.ne', 'f64.lt', 'f64.gt', 'f64.le', 'f64.ge',
492
+ ])
493
+
494
+ /**
495
+ * `f64 ± (cond ? 1 : 0)` → branchless f64 `select`, killing the i32↔f64 domain cross.
496
+ *
497
+ * `err = old - (old >= t)` and friends compile to `f64.sub(X, f64.convert_i32_s(cmp))`.
498
+ * The convert (cvtsi2sd) round-trips the comparison result out of a GPR back into an
499
+ * XMM register — a domain-crossing op that sits ON the value's def chain. In the
500
+ * per-pixel error-diffusion sweeps (Floyd–Steinberg / Atkinson / JJN) and scalar IIR
501
+ * thresholds this chain is the loop-carried critical path, so that one cross roughly
502
+ * doubles the per-step latency (V8 keeps the JS threshold entirely in the FP domain).
503
+ *
504
+ * `X - (B?1:0) ≡ (B ? X-1 : X) ≡ select(X-1, X, B)` (likewise `+` → `select(X+1, X, B)`),
505
+ * which never leaves the f64 domain. `select` evaluates BOTH arms, so X must be a
506
+ * side-effect-free duplicable leaf (a `local.get`/const); B is the i32 condition,
507
+ * evaluated once (exactly as the convert did). A pure win on latency-bound recurrences;
508
+ * speed-gated (it adds a const + an arithmetic op — a size↔speed trade) — off at 'size'.
509
+ */
510
+ function boolConvertToSelect(fn) {
511
+ if (!Array.isArray(fn) || fn[0] !== 'func') return
512
+ // Pass 1 — a local whose SOLE definition is a comparison carries a value ∈ {0,1};
513
+ // `err = old - on` (on reused by putBW) reaches us as `convert(local.get $on)`.
514
+ // A param is EXCLUDED even if reassigned once by a comparison: its incoming arg is
515
+ // unconstrained, so a read before the reassignment isn't 0/1. (A plain local read
516
+ // before its def is safe — wasm zero-inits it to 0 = false, which select preserves.)
517
+ const params = new Set()
518
+ for (let i = 2; i < fn.length; i++) if (Array.isArray(fn[i]) && fn[i][0] === 'param') params.add(fn[i][1])
519
+ const defCount = new Map(), defIsCmp = new Map()
520
+ const scan = (n) => {
521
+ if (!Array.isArray(n)) return
522
+ if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') {
523
+ defCount.set(n[1], (defCount.get(n[1]) || 0) + 1)
524
+ const cmp = Array.isArray(n[2]) && BOOL_RESULT_OPS.has(n[2][0])
525
+ defIsCmp.set(n[1], (defIsCmp.has(n[1]) ? defIsCmp.get(n[1]) : true) && cmp)
526
+ }
527
+ for (let i = 1; i < n.length; i++) scan(n[i])
528
+ }
529
+ scan(fn)
530
+ const boolLocals = new Set()
531
+ for (const [name, c] of defCount) if (c === 1 && defIsCmp.get(name) && !params.has(name)) boolLocals.add(name)
532
+
533
+ const isBool01 = (n) => Array.isArray(n) &&
534
+ (BOOL_RESULT_OPS.has(n[0]) || (n[0] === 'local.get' && boolLocals.has(n[1])))
535
+ const dup = (n) => Array.isArray(n) ? n.map(dup) : n
536
+
537
+ // Pass 2 — bottom-up rewrite.
538
+ const rewrite = (n) => {
539
+ if (!Array.isArray(n)) return n
540
+ for (let i = 1; i < n.length; i++) n[i] = rewrite(n[i])
541
+ if ((n[0] === 'f64.sub' || n[0] === 'f64.add') && n.length === 3) {
542
+ const conv = (m) => Array.isArray(m) && (m[0] === 'f64.convert_i32_s' || m[0] === 'f64.convert_i32_u') && isBool01(m[1])
543
+ // `X - bool`, `X + bool`, or (add is commutative) `bool + X`.
544
+ let X = null, B = null
545
+ if (conv(n[2]) && isLeaf(n[1])) { X = n[1]; B = n[2][1] }
546
+ else if (n[0] === 'f64.add' && conv(n[1]) && isLeaf(n[2])) { X = n[2]; B = n[1][1] }
547
+ if (X) return ['select', [n[0], dup(X), ['f64.const', 1]], dup(X), B]
548
+ }
549
+ return n
550
+ }
551
+ rewrite(fn)
552
+ }
553
+
426
554
  /**
427
555
  * Hoist `(call $__ptr_offset (local.get $X))` to a function-entry snapshot
428
556
  * when X is an f64-NaN-boxed parameter that's never reassigned and only ever
@@ -442,6 +570,20 @@ export function hoistAddrBase(fn) {
442
570
  // loop-invariant __jss_length in the same loop condition CAN hoist).
443
571
  const SAFE_OFFSET_CALLS = new Set(['$__ptr_offset', '$__ptr_type', '$__ptr_aux', '$__len', '$__jss_length', '$__jss_charCodeAt'])
444
572
 
573
+ // wasm comparison-op mantissas (the part after the `.`): they yield i32 regardless of
574
+ // operand width (i64.eq, f64.lt, i32.ge_s, …). `eq`/`ne` are sign-agnostic; the ordered
575
+ // compares carry `_s`/`_u` for the integer types and none for f64. Used by resultType to
576
+ // type a hoisted subtree by its root op. A Set membership test, NOT a regex
577
+ // (`/^(eq|ne|lt|gt|le|ge)(_[su])?$/`): the regex mis-anchored under self-host −O2 — `nearest`
578
+ // (the f64.nearest mantissa, from Math.round) starts with `ne`, and the embedded −O2 build
579
+ // matched it as a comparison → the LICM hoist local got typed i32, so `local.set $__li
580
+ // (f64.nearest …)` emitted invalid wasm (f64 into i32) only in the kernel. Explicit string
581
+ // membership is both self-host-robust and cheaper in this LICM-hot path.
582
+ const CMP_MANTISSA = new Set([
583
+ 'eqz', 'eq', 'ne', 'lt', 'gt', 'le', 'ge',
584
+ 'lt_s', 'lt_u', 'gt_s', 'gt_u', 'le_s', 'le_u', 'ge_s', 'ge_u',
585
+ ])
586
+
445
587
  // Calls that don't modify EXISTING heap memory: they may allocate (bump the heap
446
588
  // pointer) or do tag dispatch, but they never write to an address a hoisted
447
589
  // __typed_idx/__str_idx element read would revisit. Their presence must not
@@ -460,6 +602,25 @@ const NON_MUTATING_CALLS = new Set(['$__is_str_key', '$__str_concat', '$__to_num
460
602
  // `for(j) { ... grid[i][j] ... }` inner loop (the jagged-array deopt).
461
603
  const READONLY_MEM_CALLS = new Set(['$__typed_idx', '$__str_idx'])
462
604
 
605
+ // PURE FUNCTION calls — result is a function of the ARGUMENTS alone, with no
606
+ // dependence on mutable state: math reads no memory; the string search/compare
607
+ // helpers read only their operands' bytes, and jz strings are IMMUTABLE, so their
608
+ // content can't change under the loop's stores. So on loop-invariant args the
609
+ // RESULT is loop-invariant regardless of anything else the loop does (unlike a
610
+ // READONLY_MEM_CALLS element read, which an aliasing store could invalidate — no
611
+ // !hasDirectStore guard is needed here). Speculative pre-header execution can't
612
+ // trap: math returns NaN/∞ rather than trapping, and a value reaching .indexOf/===
613
+ // is a valid string (in-bounds reads). This is the LICM V8's wasm tier won't do —
614
+ // it treats every call as opaque and recomputes the search/transcendental each
615
+ // iteration. (Math.random is INLINED — it mutates a global PRNG seed, never a
616
+ // `$math.` call — but exclude it by name defensively; $__str_eq_cold is the cold
617
+ // half of __str_eq, equally pure.) byteLen/length stay OUT: $__length is polymorphic
618
+ // over MUTABLE arrays (push changes it), so it isn't arg-pure.
619
+ const PURE_CALL_I32 = new Set(['$__str_indexof', '$__str_lastindexof', '$__str_eq', '$__str_eq_cold', '$__is_str_key'])
620
+ const isPureFnCall = (callee) =>
621
+ typeof callee === 'string' &&
622
+ ((callee.startsWith('$math.') && !callee.startsWith('$math.random')) || PURE_CALL_I32.has(callee))
623
+
463
624
  export function hoistInvariantPtrOffset(fn) {
464
625
  if (!Array.isArray(fn) || fn[0] !== 'func') return
465
626
  const bodyStart = findBodyStart(fn)
@@ -610,6 +771,143 @@ const PURE_LICM_OPS = new Set([
610
771
  'f64.promote_f32', 'f32.demote_f64', 'select',
611
772
  ])
612
773
 
774
+ // Resolve a load/store address back to the single typed-array PARAM it derives from — through
775
+ // `local.get`, the arithmetic in PURE_LICM_OPS, and single-def snap locals ($__li/$__ab) — or
776
+ // null if not exactly one / unprovable (a multi-def or unknown local in the address). Built once
777
+ // per function over the proven-distinct `distinctParams` set; the alias substrate both LICM
778
+ // passes query to hoist a read-only input load across a distinct-buffer store (raytrace's spheres
779
+ // vs framebuffer — the alias-analysis LICM rust/clang get for free).
780
+ function buildBaseParamOf(fn, bodyStart, distinctParams) {
781
+ if (!distinctParams) return () => null
782
+ const paramNames = new Set()
783
+ for (let i = 2; i < bodyStart; i++)
784
+ if (Array.isArray(fn[i]) && fn[i][0] === 'param' && typeof fn[i][1] === 'string') paramNames.add(fn[i][1])
785
+ const singleDef = new Map(), defCount = new Map()
786
+ const scanDefs = (n) => {
787
+ if (!Array.isArray(n)) return
788
+ if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') {
789
+ defCount.set(n[1], (defCount.get(n[1]) || 0) + 1); singleDef.set(n[1], n[2]); scanDefs(n[2]); return
790
+ }
791
+ for (let i = 1; i < n.length; i++) scanDefs(n[i])
792
+ }
793
+ for (let i = bodyStart; i < fn.length; i++) scanDefs(fn[i])
794
+ for (const [k, c] of defCount) if (c > 1) singleDef.delete(k) // multi-def → can't trust the resolution
795
+ return (addr) => {
796
+ const found = new Set(); const seen = new Set(); let bad = false
797
+ const walk = (n) => {
798
+ if (bad || !Array.isArray(n)) return
799
+ if (n[0] === 'local.get' && typeof n[1] === 'string') {
800
+ if (paramNames.has(n[1])) found.add(n[1])
801
+ else if (singleDef.has(n[1]) && !seen.has(n[1])) { seen.add(n[1]); walk(singleDef.get(n[1])) }
802
+ else bad = true // a written/unknown local in the address → base unprovable
803
+ return
804
+ }
805
+ for (let i = 1; i < n.length; i++) walk(n[i])
806
+ }
807
+ walk(addr)
808
+ return !bad && found.size === 1 ? [...found][0] : null
809
+ }
810
+ }
811
+
812
+ // Per-loop invariance/purity analysis — the single proven predicate both LICM passes share.
813
+ // Scans the loop into an effect summary (locals/globals it writes, cells/buffers it stores to,
814
+ // whether it has any call / unsafe call / direct store / v128 op), then closes `pureGiven(node,
815
+ // bound)` over it: true iff `node` is side-effect-free AND loop-invariant, given that the locals
816
+ // in `bound` are private to the candidate (a `local.get` of a bound local reads the in-subtree
817
+ // teed invariant; a free `local.get` must be unwritten by the loop). Memory leaves are admitted
818
+ // only under the summary: a `$__cell_`/distinct-param load iff no aliasing store + no call; a
819
+ // SAFE_OFFSET/READONLY_MEM call iff no unsafe call (+ no direct store for heap reads).
820
+ function loopInvariance(loopNode, { distinctParams, baseParamOf }) {
821
+ const locals = new Set(), globals = new Set(), storedCells = new Set(), storedBases = new Set()
822
+ let hasUnsafeCall = false, hasAnyCall = false, hasDirectStore = false, hasV128 = false
823
+ const scan = (node) => {
824
+ if (!Array.isArray(node)) return
825
+ const op = node[0]
826
+ // A vectorized loop (lane/v128 ops) is already register-tight and hand-tuned;
827
+ // extra scalar hoisting there only adds spill pressure — keep it conservative.
828
+ if (op.startsWith('v128.') || /^[if]\d+x\d+\./.test(op)) hasV128 = true
829
+ 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 }
830
+ 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 }
831
+ if (op === 'call') { hasAnyCall = true; if (!SAFE_OFFSET_CALLS.has(node[1]) && !READONLY_MEM_CALLS.has(node[1]) && !NON_MUTATING_CALLS.has(node[1]) && !isPureFnCall(node[1])) hasUnsafeCall = true; for (let i = 2; i < node.length; i++) scan(node[i]); return }
832
+ if (op === 'call_ref' || op === 'call_indirect') { hasAnyCall = hasUnsafeCall = true; for (let i = 1; i < node.length; i++) scan(node[i]); return }
833
+ if ((op === 'f64.store' || op === 'i32.store') && node.length >= 3) {
834
+ hasDirectStore = true
835
+ const a = node[1]
836
+ if (Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' && a[1].startsWith(CELL_PREFIX)) storedCells.add(a[1])
837
+ if (distinctParams) { const sb = baseParamOf(a); if (sb) storedBases.add(sb) } // alias: which buffers this loop writes
838
+ }
839
+ for (let i = 1; i < node.length; i++) scan(node[i])
840
+ }
841
+ for (let i = 1; i < loopNode.length; i++) scan(loopNode[i])
842
+
843
+ const pureGiven = (node, bound) => {
844
+ if (!Array.isArray(node)) return true // bare operand string/number
845
+ const op = node[0]
846
+ if (op === 'i32.const' || op === 'i64.const' || op === 'f64.const' || op === 'f32.const') return true
847
+ if (op === 'local.get') return typeof node[1] === 'string' && (bound.has(node[1]) || !locals.has(node[1]))
848
+ // A global is invariant only if not set directly AND no call in the loop —
849
+ // any callee may mutate it (no interprocedural effect analysis). (Locals are
850
+ // frame-private, so calls can't touch them; only direct local.set matters.)
851
+ if (op === 'global.get') return typeof node[1] === 'string' && !globals.has(node[1]) && !hasAnyCall
852
+ if (op === 'local.tee') {
853
+ if (typeof node[1] !== 'string') return false
854
+ // The operand is evaluated BEFORE the tee writes $X, so a `local.get $X` inside
855
+ // it reads the loop-carried (previous-iteration) value, not the teed one. Drop
856
+ // $X from `bound` for the operand: `local.tee $X (… $X …)` is a loop recurrence
857
+ // (X = f(X) — e.g. the `while ((nn = nn >>> 1))` induction), NOT invariant.
858
+ const inner = bound.has(node[1]) ? new Set([...bound].filter(b => b !== node[1])) : bound
859
+ return pureGiven(node[2], inner)
860
+ }
861
+ if ((op === 'f64.load' || op === 'i32.load') && node.length === 2) {
862
+ const a = node[1]
863
+ if (Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' && a[1].startsWith(CELL_PREFIX)
864
+ && !hasAnyCall && !storedCells.has(a[1]) && (bound.has(a[1]) || !locals.has(a[1]))) return true
865
+ // Alias-analysis LICM: a load from a typed-array param PROVEN distinct from every buffer
866
+ // this loop writes (base ∉ storedBases) is loop-invariant when its address is invariant —
867
+ // even across the loop's stores, because they can't alias it. This is what lets rust/clang
868
+ // hoist read-only input arrays out of a write loop (raytrace's spheres vs the framebuffer).
869
+ // `pureGiven(a, bound)` proves the address itself invariant (base param unwritten + invariant
870
+ // offset); the calls guard rules out callee memory mutation.
871
+ if (distinctParams && !hasAnyCall) {
872
+ const base = baseParamOf(a)
873
+ if (base && distinctParams.has(base) && !storedBases.has(base) && pureGiven(a, bound)) return true
874
+ }
875
+ return false
876
+ }
877
+ if (op === 'call') {
878
+ // Pure-function call: invariant iff its ARGS are. No effect-summary barrier —
879
+ // its result depends on nothing the loop can mutate (math: no memory; string
880
+ // search/compare: immutable operands), so neither a store nor another call
881
+ // can invalidate it. This hoists the loop-invariant transcendental / substr
882
+ // search V8's wasm tier recomputes every iteration.
883
+ if (isPureFnCall(node[1]))
884
+ return node.slice(2).every(c => pureGiven(c, bound))
885
+ if (SAFE_OFFSET_CALLS.has(node[1]))
886
+ return !hasUnsafeCall && node.slice(2).every(c => pureGiven(c, bound))
887
+ // Read-only heap reads: additionally require no direct store (alias-safe).
888
+ if (READONLY_MEM_CALLS.has(node[1]))
889
+ return !hasUnsafeCall && !hasDirectStore && node.slice(2).every(c => pureGiven(c, bound))
890
+ return false
891
+ }
892
+ // A value-producing `if` whose condition and both arms are pure is itself
893
+ // pure — the tag-dispatch idiom `(if (result f64) tag-check (then read-A)
894
+ // (else read-B))` that wraps __typed_idx/__str_idx element access.
895
+ if (op === 'if') {
896
+ for (let i = 1; i < node.length; i++) {
897
+ const c = node[i]
898
+ if (!Array.isArray(c)) continue
899
+ if (c[0] === 'result') continue
900
+ if (c[0] === 'then' || c[0] === 'else') { if (!c.slice(1).every(x => pureGiven(x, bound))) return false }
901
+ else if (!pureGiven(c, bound)) return false // the condition
902
+ }
903
+ return true
904
+ }
905
+ if (PURE_LICM_OPS.has(op)) return node.slice(1).every(c => pureGiven(c, bound))
906
+ return false
907
+ }
908
+ return { pureGiven, locals, globals, storedCells, storedBases, hasUnsafeCall, hasAnyCall, hasDirectStore, hasV128 }
909
+ }
910
+
613
911
  /**
614
912
  * Unified loop-invariant code motion. One principle replaces the three former
615
913
  * pattern hoists (ToInt32 / __ptr_offset / cell-load): a MAXIMAL pure subtree
@@ -630,6 +928,160 @@ const PURE_LICM_OPS = new Set([
630
928
  * watr's shared CSE subtrees, snaps spliced before the loop, decls at bodyStart.
631
929
  * Idempotent: re-running sees only `(local.get $__liN)` and finds nothing to do.
632
930
  */
931
+ // SSA-split loop-private straight-line multi-def scratch so the LICM below can hoist
932
+ // the invariant versions. jz's unroller MERGES each unrolled iteration's `const x`
933
+ // into one multi-def local (e.g. raytrace's sphere loop unrolls 8× sharing $ox/$c),
934
+ // which the LICM cannot hoist — so the per-sphere invariant `c_i = sx_i²+sy_i²+sz_i²
935
+ // −sr_i²` recomputes every pixel instead of once (the 1.24× rust-wasm gap; rust/LLVM
936
+ // keeps them as distinct SSA values and hoists each). Renaming each def to its own
937
+ // version makes them single-def → hoistInvariantLoop lifts the loop-invariant ones.
938
+ //
939
+ // BIT-EXACT: pure renaming + invariant code motion — the same value computed fewer
940
+ // times, no reassociation. Gated to loops with NO v128, so it never disturbs a
941
+ // vectorized loop (whose unrolled shared names the lane/dot vectorizer relies on).
942
+ //
943
+ // SOUND only for a local that, within the loop body, (a) is referenced NOWHERE else in
944
+ // the function (loop-local lifetime — else a post-loop read of the merged name breaks),
945
+ // (b) has every occurrence STRAIGHT-LINE (never under a nested if/block/loop, so a
946
+ // linear walk assigns each use its unique dominating def), (c) is first accessed by a
947
+ // WRITE (no value carried across the back-edge), (d) is only ever `local.set` (never
948
+ // `local.tee`/conditionally defined). Each condition rejects a class that would miscompile.
949
+ export function splitLoopPrivateScratch(fn) {
950
+ if (!Array.isArray(fn) || fn[0] !== 'func') return
951
+ const bodyStart = findBodyStart(fn)
952
+ if (bodyStart < 0) return
953
+ const SCALAR = new Set(['i32', 'i64', 'f64', 'f32'])
954
+ const localTypes = new Map()
955
+ for (let i = 2; i < bodyStart; i++) {
956
+ const c = fn[i]
957
+ if (Array.isArray(c) && (c[0] === 'param' || c[0] === 'local') && typeof c[1] === 'string') localTypes.set(c[1], c[2])
958
+ }
959
+ // Whole-function reference count per local (to verify a candidate is loop-local).
960
+ const fnRefs = new Map()
961
+ const countRefs = (n) => {
962
+ if (!Array.isArray(n)) return
963
+ if ((n[0] === 'local.get' || n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string')
964
+ fnRefs.set(n[1], (fnRefs.get(n[1]) || 0) + 1)
965
+ for (let i = 1; i < n.length; i++) countRefs(n[i])
966
+ }
967
+ for (let i = bodyStart; i < fn.length; i++) countRefs(fn[i])
968
+ // Same proven alias substrate hoistInvariantLoop uses (re-attached after watOptimize, so it
969
+ // survives into this 'post' pass) — lets pureGiven prove a read-only input-array load distinct
970
+ // from the loop's output store, the SOUND replacement for the old address-local-disjointness
971
+ // heuristic (which assumed two loads/stores in different locals never alias — false in general).
972
+ const distinctParams = fn.distinctParams || null
973
+ const baseParamOf = buildBaseParamOf(fn, bodyStart, distinctParams)
974
+
975
+ const hasV128 = (n) => {
976
+ let f = false
977
+ 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]) }
978
+ w(n); return f
979
+ }
980
+ let minted = 0
981
+ const newDecls = []
982
+
983
+ const processLoop = (loop, parent, idx) => {
984
+ if (loop[0] !== 'loop' || hasV128(loop)) return
985
+ // Candidate names: locals set somewhere directly in the loop's statement list.
986
+ const seen = new Set()
987
+ for (let i = 2; i < loop.length; i++) {
988
+ const s = loop[i]
989
+ if (Array.isArray(s) && s[0] === 'local.set' && typeof s[1] === 'string') seen.add(s[1])
990
+ }
991
+ // Stage 1 — collect SAFE candidates (loop-local, straight-line, first-write, set-only,
992
+ // ≥2 defs) and record each one's def RHS list for the invariance fixpoint.
993
+ const cand = new Map() // name → { defs: [rhs…] }
994
+ for (const name of seen) {
995
+ if (!SCALAR.has(localTypes.get(name))) continue
996
+ let inLoop = 0
997
+ 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]) }
998
+ cnt(loop)
999
+ if (inLoop !== (fnRefs.get(name) || 0)) continue
1000
+ let safe = true, first = null, defs = []
1001
+ const scan = (n, depth) => {
1002
+ if (!safe || !Array.isArray(n)) return
1003
+ const op = n[0]
1004
+ if (op === 'local.tee' && n[1] === name) { safe = false; return }
1005
+ if (op === 'local.set' && n[1] === name) {
1006
+ if (depth > 0) { safe = false; return }
1007
+ if (first === null) first = 'w'
1008
+ defs.push(n[2])
1009
+ scan(n[2], depth)
1010
+ return
1011
+ }
1012
+ if (op === 'local.get' && n[1] === name) {
1013
+ if (depth > 0) { safe = false; return }
1014
+ if (first === null) first = 'r'
1015
+ return
1016
+ }
1017
+ const ctrl = op === 'if' || op === 'then' || op === 'else' || op === 'block' || op === 'loop'
1018
+ for (let i = 1; i < n.length; i++) scan(n[i], depth + (ctrl ? 1 : 0))
1019
+ }
1020
+ for (let i = 2; i < loop.length; i++) scan(loop[i], 0)
1021
+ if (safe && first === 'w' && defs.length >= 2) cand.set(name, defs)
1022
+ }
1023
+ if (!cand.size) return
1024
+ // Stage 2 — invariance fixpoint over the SHARED proven predicate. `pureGiven(def, hoistable)`
1025
+ // decides loop-invariance with hoistInvariantLoop's exact model: a `$__cell_`/distinct-param
1026
+ // read-only load is invariant across the loop's stores (sound alias analysis), a global is
1027
+ // invariant only without a loop write or call, and the `bound` set (here `hoistable`) carries
1028
+ // the cascade — a def reading an already-split sibling is invariant once that sibling moves out
1029
+ // (c = ox²+… invariant only after ox hoists). `motionSafe` adds the one extra obligation a
1030
+ // whole-assignment MOTION needs beyond value-invariance: no `local.tee` writing a local read
1031
+ // elsewhere (pureGiven already rejects set/store/global.set/unsafe-call). This replaces the old
1032
+ // address-local-disjointness load test, which was unsound in general (two distinct locals can
1033
+ // hold the same address) and only worked by luck on the bench shapes.
1034
+ const { pureGiven } = loopInvariance(loop, { distinctParams, baseParamOf })
1035
+ 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 }
1036
+ const hoistable = new Set()
1037
+ let changed = true
1038
+ while (changed) {
1039
+ changed = false
1040
+ for (const [name, defs] of cand) {
1041
+ if (hoistable.has(name)) continue
1042
+ if (defs.every(d => motionSafe(d) && pureGiven(d, hoistable))) { hoistable.add(name); changed = true }
1043
+ }
1044
+ }
1045
+ // Stage 3 — one linear pass over the loop body: each hoistable def is RENAMED to a
1046
+ // fresh version and MOVED OUT of the loop (before it), in source order so the cascade's
1047
+ // data deps stay intact (c = ox²+… emitted after ox). The cheap arithmetic AND the load
1048
+ // both leave the loop; gets stay, rebound to the moved version. (hoistInvariantLoop only
1049
+ // snapshots expensive subexprs, not whole invariant assignments — so we do the motion.)
1050
+ const curOf = new Map()
1051
+ const rewriteGets = (n) => {
1052
+ if (!Array.isArray(n)) return n
1053
+ if (n[0] === 'local.get' && curOf.has(n[1])) return ['local.get', curOf.get(n[1])]
1054
+ return n.map((c, i) => i === 0 ? c : rewriteGets(c))
1055
+ }
1056
+ const hoisted = []
1057
+ const kept = loop.slice(0, 2) // 'loop' + label
1058
+ for (let i = 2; i < loop.length; i++) {
1059
+ const s = loop[i]
1060
+ if (Array.isArray(s) && s[0] === 'local.set' && hoistable.has(s[1])) {
1061
+ const name = s[1], ty = localTypes.get(name)
1062
+ const nv = `$${name.replace(/^\$/, '')}__sr${minted++}`
1063
+ newDecls.push(['local', nv, ty]); localTypes.set(nv, ty)
1064
+ hoisted.push(['local.set', nv, rewriteGets(s[2])])
1065
+ curOf.set(name, nv)
1066
+ } else {
1067
+ kept.push(rewriteGets(s))
1068
+ }
1069
+ }
1070
+ loop.length = 0
1071
+ for (const x of kept) loop.push(x)
1072
+ parent.splice(idx, 0, ...hoisted)
1073
+ }
1074
+ const walk = (parent, idx) => {
1075
+ const n = parent[idx]
1076
+ if (!Array.isArray(n)) return
1077
+ // Recurse first so an inner loop's hoists land before we process the outer loop.
1078
+ for (let i = 1; i < n.length; i++) walk(n, i)
1079
+ if (n[0] === 'loop') processLoop(n, parent, idx)
1080
+ }
1081
+ for (let i = bodyStart; i < fn.length; i++) walk(fn, i)
1082
+ if (newDecls.length) fn.splice(bodyStart, 0, ...newDecls)
1083
+ }
1084
+
633
1085
  export function hoistInvariantLoop(fn) {
634
1086
  if (!Array.isArray(fn) || fn[0] !== 'func') return
635
1087
  const bodyStart = findBodyStart(fn)
@@ -675,6 +1127,8 @@ export function hoistInvariantLoop(fn) {
675
1127
  // SAFE_OFFSET_CALLS all return i32; READONLY_MEM_CALLS return f64 (NaN-boxed element)
676
1128
  if (SAFE_OFFSET_CALLS.has(node[1])) return 'i32'
677
1129
  if (READONLY_MEM_CALLS.has(node[1])) return 'f64'
1130
+ if (PURE_CALL_I32.has(node[1])) return 'i32' // string search/compare → i32
1131
+ if (typeof node[1] === 'string' && node[1].startsWith('$math.')) return 'f64' // transcendentals → f64
678
1132
  return null
679
1133
  }
680
1134
  if (op === 'local.get' || op === 'local.tee') return localTypes.get(node[1]) ?? null
@@ -683,7 +1137,7 @@ export function hoistInvariantLoop(fn) {
683
1137
  // Comparisons and `eqz` yield i32 regardless of operand type (i64.eq, f64.lt,
684
1138
  // i64.eqz, …) — so the operand-type prefix would mistype them. Catch first.
685
1139
  const m = op.slice(dot + 1)
686
- if (m === 'eqz' || /^(eq|ne|lt|gt|le|ge)(_[su])?$/.test(m)) return 'i32'
1140
+ if (CMP_MANTISSA.has(m)) return 'i32'
687
1141
  const p = op.slice(0, dot)
688
1142
  if (p === 'i32' || p === 'i64' || p === 'f64' || p === 'f32') return p
689
1143
  return null
@@ -707,33 +1161,23 @@ export function hoistInvariantLoop(fn) {
707
1161
  const newLocals = []
708
1162
  const refcount = buildRefcount(fn)
709
1163
 
1164
+ // Alias-analysis substrate for hoisting typed-array PARAM element loads across distinct-base
1165
+ // stores. `distinctParams` (stamped by compile/index.js from the param-distinctness pass) is the
1166
+ // set of typed-array params PROVEN to be mutually-distinct buffers at every call site. To use it,
1167
+ // resolve a load/store address back to the single param it derives from — through `local.get`,
1168
+ // `i32.add/sub`, and single-def snap locals ($__li/$__ab from prior ptr-offset hoisting).
1169
+ const distinctParams = fn.distinctParams || null
1170
+ const baseParamOf = buildBaseParamOf(fn, bodyStart, distinctParams)
1171
+
710
1172
  const processLoop = (loopNode, nested) => {
711
1173
  // Inner loops first (bottom-up) — an inner hoist creates a local.get the
712
1174
  // outer level can hoist further. Children run in a nested context.
713
1175
  for (let i = 1; i < loopNode.length; i++)
714
1176
  if (Array.isArray(loopNode[i])) processNode(loopNode[i], loopNode, i, true)
715
1177
 
716
- // The loop's effect summary (scans nested loops too conservative).
717
- const locals = new Set(), globals = new Set(), storedCells = new Set()
718
- let hasUnsafeCall = false, hasAnyCall = false, hasDirectStore = false, hasV128 = false
719
- const scan = (node) => {
720
- if (!Array.isArray(node)) return
721
- const op = node[0]
722
- // A vectorized loop (lane/v128 ops) is already register-tight and hand-tuned;
723
- // extra scalar hoisting there only adds spill pressure — keep it conservative.
724
- if (op.startsWith('v128.') || /^[if]\d+x\d+\./.test(op)) hasV128 = true
725
- if (op === 'local.set' || op === 'local.tee') { if (typeof node[1] === 'string') locals.add(node[1]); for (let i = 2; i < node.length; i++) scan(node[i]); return }
726
- if (op === 'global.set') { if (typeof node[1] === 'string') globals.add(node[1]); for (let i = 2; i < node.length; i++) scan(node[i]); return }
727
- if (op === 'call') { hasAnyCall = true; if (!SAFE_OFFSET_CALLS.has(node[1]) && !READONLY_MEM_CALLS.has(node[1]) && !NON_MUTATING_CALLS.has(node[1])) hasUnsafeCall = true; for (let i = 2; i < node.length; i++) scan(node[i]); return }
728
- if (op === 'call_ref' || op === 'call_indirect') { hasAnyCall = hasUnsafeCall = true; for (let i = 1; i < node.length; i++) scan(node[i]); return }
729
- if ((op === 'f64.store' || op === 'i32.store') && node.length >= 3) {
730
- hasDirectStore = true
731
- const a = node[1]
732
- if (Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' && a[1].startsWith(CELL_PREFIX)) storedCells.add(a[1])
733
- }
734
- for (let i = 1; i < node.length; i++) scan(node[i])
735
- }
736
- for (let i = 1; i < loopNode.length; i++) scan(loopNode[i])
1178
+ // The loop's effect summary + the proven invariance/purity predicate (shared with
1179
+ // splitLoopPrivateScratch see loopInvariance). `locals` is the loop's whole write-set.
1180
+ const { pureGiven, locals, hasV128 } = loopInvariance(loopNode, { distinctParams, baseParamOf })
737
1181
 
738
1182
  // Per-subtree local-occurrence counts and write-sets, memoized bottom-up —
739
1183
  // the tee-privacy check queries them for EVERY candidate node, and the old
@@ -772,57 +1216,6 @@ export function hoistInvariantLoop(fn) {
772
1216
  for (let i = 1; i < loopNode.length; i++)
773
1217
  for (const [k, v] of countsOf(loopNode[i])) localCount.set(k, (localCount.get(k) || 0) + v)
774
1218
 
775
- // Pure & invariant given `bound` (locals written *within* the candidate, hence
776
- // local to it). A read of a bound local is OK (its in-subtree value is the
777
- // teed invariant). A free read must be unwritten by the loop.
778
- const pureGiven = (node, bound) => {
779
- if (!Array.isArray(node)) return true // bare operand string/number
780
- const op = node[0]
781
- if (op === 'i32.const' || op === 'i64.const' || op === 'f64.const' || op === 'f32.const') return true
782
- if (op === 'local.get') return typeof node[1] === 'string' && (bound.has(node[1]) || !locals.has(node[1]))
783
- // A global is invariant only if not set directly AND no call in the loop —
784
- // any callee may mutate it (no interprocedural effect analysis). (Locals are
785
- // frame-private, so calls can't touch them; only direct local.set matters.)
786
- if (op === 'global.get') return typeof node[1] === 'string' && !globals.has(node[1]) && !hasAnyCall
787
- if (op === 'local.tee') {
788
- if (typeof node[1] !== 'string') return false
789
- // The operand is evaluated BEFORE the tee writes $X, so a `local.get $X` inside
790
- // it reads the loop-carried (previous-iteration) value, not the teed one. Drop
791
- // $X from `bound` for the operand: `local.tee $X (… $X …)` is a loop recurrence
792
- // (X = f(X) — e.g. the `while ((nn = nn >>> 1))` induction), NOT invariant.
793
- const inner = bound.has(node[1]) ? new Set([...bound].filter(b => b !== node[1])) : bound
794
- return pureGiven(node[2], inner)
795
- }
796
- if ((op === 'f64.load' || op === 'i32.load') && node.length === 2) {
797
- const a = node[1]
798
- return Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' && a[1].startsWith(CELL_PREFIX)
799
- && !hasAnyCall && !storedCells.has(a[1]) && (bound.has(a[1]) || !locals.has(a[1]))
800
- }
801
- if (op === 'call') {
802
- if (SAFE_OFFSET_CALLS.has(node[1]))
803
- return !hasUnsafeCall && node.slice(2).every(c => pureGiven(c, bound))
804
- // Read-only heap reads: additionally require no direct store (alias-safe).
805
- if (READONLY_MEM_CALLS.has(node[1]))
806
- return !hasUnsafeCall && !hasDirectStore && node.slice(2).every(c => pureGiven(c, bound))
807
- return false
808
- }
809
- // A value-producing `if` whose condition and both arms are pure is itself
810
- // pure — the tag-dispatch idiom `(if (result f64) tag-check (then read-A)
811
- // (else read-B))` that wraps __typed_idx/__str_idx element access.
812
- if (op === 'if') {
813
- for (let i = 1; i < node.length; i++) {
814
- const c = node[i]
815
- if (!Array.isArray(c)) continue
816
- if (c[0] === 'result') continue
817
- if (c[0] === 'then' || c[0] === 'else') { if (!c.slice(1).every(x => pureGiven(x, bound))) return false }
818
- else if (!pureGiven(c, bound)) return false // the condition
819
- }
820
- return true
821
- }
822
- if (PURE_LICM_OPS.has(op)) return node.slice(1).every(c => pureGiven(c, bound))
823
- return false
824
- }
825
-
826
1219
  const isHoistable = (node) => {
827
1220
  if (!Array.isArray(node)) return false
828
1221
  const op = node[0]
@@ -849,9 +1242,10 @@ export function hoistInvariantLoop(fn) {
849
1242
  if (!Array.isArray(node)) return
850
1243
  if (node[0] === 'loop') return // already processed bottom-up
851
1244
  if (isHoistable(node) && (refcount.get(node) || 0) <= 1 && (refcount.get(parent) || 0) <= 1) {
852
- // BigInt-safe: hoistable boxed-pointer subtrees carry i64.const NaN-box prefixes
853
- // (BigInt values) that plain JSON.stringify can't serialize see ast.bigintSafeKey.
854
- const key = JSON.stringify(node, (_k, v) => typeof v === 'bigint' ? `${v}n` : v)
1245
+ // stableKey: hoistable boxed-pointer subtrees carry i64.const NaN-box prefixes
1246
+ // (BigInt) that plain JSON.stringify can't serialize, and it also collapses
1247
+ // Infinity/-Infinity/NaN→null & -0→0 both would dedup distinct invariants.
1248
+ const key = JSON.stringify(node, stableKey)
855
1249
  let arr = sites.get(key); if (!arr) { arr = []; sites.set(key, arr) }
856
1250
  arr.push({ parent, idx, node })
857
1251
  return
@@ -983,16 +1377,17 @@ export function narrowLoopBound(fn) {
983
1377
  const newLocals = []
984
1378
  const refcount = buildRefcount(fn)
985
1379
 
986
- // `(f64.lt (f64.convert_i32_s (local.get $i)) (local.get $n))` or mirrored
987
- // `(f64.gt (local.get $n) (f64.convert_i32_s (local.get $i)))`.
1380
+ // `i < bound` as `(f64.lt (convert i) bound)` or mirrored `(f64.gt bound (convert i))`.
1381
+ // `i <= bound` as `(f64.le (convert i) bound)` or mirrored `(f64.ge bound (convert i))`.
988
1382
  const match = (n) => {
989
- const conv = n[0] === 'f64.lt' ? n[1] : n[0] === 'f64.gt' ? n[2] : null
990
- const bnd = n[0] === 'f64.lt' ? n[2] : n[0] === 'f64.gt' ? n[1] : null
1383
+ const lt = n[0] === 'f64.lt', gt = n[0] === 'f64.gt', le = n[0] === 'f64.le', ge = n[0] === 'f64.ge'
1384
+ const conv = lt || le ? n[1] : gt || ge ? n[2] : null
1385
+ const bnd = lt || le ? n[2] : gt || ge ? n[1] : null
991
1386
  if (!Array.isArray(conv) || conv[0] !== 'f64.convert_i32_s') return null
992
1387
  const ig = conv[1]
993
1388
  if (!Array.isArray(ig) || ig[0] !== 'local.get' || typeof ig[1] !== 'string') return null
994
1389
  if (!Array.isArray(bnd) || bnd[0] !== 'local.get' || typeof bnd[1] !== 'string') return null
995
- return { ctr: ig[1], bound: bnd[1] }
1390
+ return { ctr: ig[1], bound: bnd[1], op: le || ge ? 'le' : 'lt' }
996
1391
  }
997
1392
 
998
1393
  const processLoop = (loopNode) => {
@@ -1022,18 +1417,34 @@ export function narrowLoopBound(fn) {
1022
1417
  }
1023
1418
  for (let i = 1; i < loopNode.length; i++) collect(loopNode[i])
1024
1419
 
1025
- const snapFor = new Map() // bound name snap local (one per distinct bound)
1420
+ // One snap per distinct (bound, op): `i < n` and `i <= n` of the SAME bound
1421
+ // need different snapped i32 values (ceil vs floor).
1422
+ const snapFor = new Map()
1026
1423
  const snaps = []
1424
+ const I32_MIN = -2147483648
1027
1425
  for (const { node, m } of sites) {
1028
- let snap = snapFor.get(m.bound)
1426
+ const key = `${m.bound}|${m.op}`
1427
+ let snap = snapFor.get(key)
1029
1428
  if (!snap) {
1030
1429
  snap = freshLb()
1031
- snapFor.set(m.bound, snap)
1430
+ snapFor.set(key, snap)
1032
1431
  newLocals.push(['local', snap, 'i32'])
1033
- snaps.push(['local.set', snap, ['i32.trunc_sat_f64_s', ['f64.ceil', ['local.get', m.bound]]]])
1432
+ // `i < n` ⟺ `i < ceil(n)`: trunc_sat(NaN)=0 makes `i<0` false — matches `i<NaN`;
1433
+ // ±Inf → I32_MAX/I32_MIN, both correct. NaN-safe for free.
1434
+ // `i <= n` ⟺ `i <= floor(n)`, BUT trunc_sat(floor(NaN))=0 would make `i<=0` run
1435
+ // one iteration at i=0, while JS (`i<=NaN` is false) runs zero. Guard the NaN
1436
+ // case to I32_MIN (below any non-negative counter ⇒ zero iterations). ±Inf are
1437
+ // already correct (floor(+Inf)→I32_MAX, floor(-Inf)→I32_MIN; Inf==Inf is true).
1438
+ snaps.push(['local.set', snap, m.op === 'le'
1439
+ ? ['select',
1440
+ ['i32.trunc_sat_f64_s', ['f64.floor', ['local.get', m.bound]]],
1441
+ ['i32.const', I32_MIN],
1442
+ ['f64.eq', ['local.get', m.bound], ['local.get', m.bound]]]
1443
+ : ['i32.trunc_sat_f64_s', ['f64.ceil', ['local.get', m.bound]]]])
1034
1444
  }
1035
1445
  node.length = 3
1036
- node[0] = 'i32.lt_s'; node[1] = ['local.get', m.ctr]; node[2] = ['local.get', snap]
1446
+ node[0] = m.op === 'le' ? 'i32.le_s' : 'i32.lt_s'
1447
+ node[1] = ['local.get', m.ctr]; node[2] = ['local.get', snap]
1037
1448
  }
1038
1449
  return snaps
1039
1450
  }
@@ -1260,6 +1671,19 @@ export function cseScalarLoad(fn) {
1260
1671
  // the two passes cover deliberately different op sets.
1261
1672
  const COMMUTATIVE = new Set(['f64.mul', 'f64.add', 'i32.mul', 'i32.add', 'i32.and', 'i32.or', 'i32.xor', 'i64.mul', 'i64.add', 'i64.and', 'i64.or', 'i64.xor'])
1262
1673
 
1674
+ // Presence of one of these arms csePureExprLoop (it CSEs redundant pure f64/i32
1675
+ // arithmetic within the loop; the gate is just "is this loop expensive enough to
1676
+ // be worth the pass"). The whole class of transcendental helpers qualifies — each
1677
+ // is a multi-instruction polynomial approximation, so a loop built around exp/log/
1678
+ // pow deserves the same arithmetic-CSE a trig loop already got. Gating on the class,
1679
+ // not a benchmark shape; the CSE itself is bit-exact (pure-subexpr dedup only).
1680
+ const LOOP_CSE_EXPENSIVE = new Set([
1681
+ '$math.sin', '$math.cos', '$math.tan', '$math.sin_core', '$math.cos_core',
1682
+ '$math.exp', '$math.expm1', '$math.log', '$math.log2', '$math.log10', '$math.log1p',
1683
+ '$math.pow', '$math.atan', '$math.asin', '$math.acos', '$math.atan2',
1684
+ '$math.sinh', '$math.cosh', '$math.tanh', '$math.cbrt', '$math.hypot',
1685
+ ])
1686
+
1263
1687
  export function csePureExpr(fn) {
1264
1688
  if (!Array.isArray(fn) || fn[0] !== 'func') return
1265
1689
  const bodyStart = findBodyStart(fn)
@@ -1396,16 +1820,15 @@ export function csePureExprLoop(fn) {
1396
1820
  if (bodyStart < 0) return
1397
1821
 
1398
1822
  let hasLoop = false
1399
- let hasTrigCall = false
1823
+ let hasExpensiveCall = false
1400
1824
  const scanShape = (n) => {
1401
1825
  if (!Array.isArray(n)) return
1402
1826
  if (n[0] === 'loop') hasLoop = true
1403
- if (n[0] === 'call' && (n[1] === '$math.sin' || n[1] === '$math.cos'
1404
- || n[1] === '$math.sin_core' || n[1] === '$math.cos_core')) hasTrigCall = true
1827
+ if (n[0] === 'call' && LOOP_CSE_EXPENSIVE.has(n[1])) hasExpensiveCall = true
1405
1828
  for (let i = 1; i < n.length; i++) scanShape(n[i])
1406
1829
  }
1407
1830
  for (let i = bodyStart; i < fn.length; i++) scanShape(fn[i])
1408
- if (!hasLoop || !hasTrigCall) return
1831
+ if (!hasLoop || !hasExpensiveCall) return
1409
1832
 
1410
1833
  let snapId = nextLocalId(fn, 'pe')
1411
1834
  const newLocals = []
@@ -1745,6 +2168,121 @@ export function deadStoreElim(fn) {
1745
2168
  }
1746
2169
  }
1747
2170
 
2171
+ /**
2172
+ * Forward-substitute a single-def / single-use local into its sole use, eliminating the local,
2173
+ * its `local.set` and its `local.get`. This is watr's "propagate": jz emits short-lived address/
2174
+ * index temps (`set $t (i32.add …); … (load $t) …`) that the WAT optimizer folds away — the
2175
+ * dominant slice of the watr-optimizer-OFF size gap (a matmul carries ~14 such temps, ≈ the whole
2176
+ * delta). Closing it lets jz lean on its own optimizer instead of watr's.
2177
+ *
2178
+ * SOUND only when moving the def's RHS `E` to the use can't change order or value:
2179
+ * - `E` is PURE — reads only locals (no load/store/call/global/memory): its value is a function
2180
+ * of its read-locals alone, and it has no side effects to reorder past intervening statements.
2181
+ * - the use is at the SAME loop nesting — never under a deeper `loop` than the def (which would
2182
+ * re-evaluate `E` per iteration and could read a clobbered input).
2183
+ * - no read-local of `E` is written between the def and the use (incl. the use statement itself).
2184
+ * Anything it can't prove safe is left untouched.
2185
+ */
2186
+ export function propagateSingleUse(fn) {
2187
+ if (!Array.isArray(fn) || fn[0] !== 'func') return
2188
+ const bodyStart = findBodyStart(fn)
2189
+ if (bodyStart < 0) return
2190
+
2191
+ // Leave a vectorized function alone: it carries v128 lane sequences that are already register-tight,
2192
+ // and forward-substituting into them only adds pressure (mirrors hoistInvariantLoop's hasV128 gate).
2193
+ let hasV128 = false
2194
+ const scanV = (n) => { if (hasV128 || !Array.isArray(n)) return; const op = n[0]; if (typeof op === 'string' && (op.startsWith('v128.') || /^[if]\d+x\d+\./.test(op))) { hasV128 = true; return } for (let i = 1; i < n.length; i++) scanV(n[i]) }
2195
+ for (let i = bodyStart; i < fn.length && !hasV128; i++) scanV(fn[i])
2196
+ if (hasV128) return
2197
+
2198
+ // def/use tally over the whole body. A `local.tee` is both a read and a write — exclude any
2199
+ // tee'd local from candidacy rather than reason about it.
2200
+ const setN = new Map(), getN = new Map(), teed = new Set()
2201
+ const tally = (n) => {
2202
+ if (!Array.isArray(n)) return
2203
+ const op = n[0]
2204
+ if (op === 'local.set' && typeof n[1] === 'string') setN.set(n[1], (setN.get(n[1]) || 0) + 1)
2205
+ else if (op === 'local.tee' && typeof n[1] === 'string') teed.add(n[1])
2206
+ else if (op === 'local.get' && typeof n[1] === 'string') getN.set(n[1], (getN.get(n[1]) || 0) + 1)
2207
+ for (let i = 1; i < n.length; i++) tally(n[i])
2208
+ }
2209
+ for (let i = bodyStart; i < fn.length; i++) tally(fn[i])
2210
+
2211
+ const cand = new Set()
2212
+ for (const [name, c] of setN) if (c === 1 && getN.get(name) === 1 && !teed.has(name)) cand.add(name)
2213
+ if (!cand.size) return
2214
+
2215
+ const movablePure = (n) => {
2216
+ if (!Array.isArray(n)) return true
2217
+ const op = n[0]
2218
+ if (op === 'local.get') return true
2219
+ if (op === 'local.set' || op === 'local.tee' || op === 'call' || op === 'call_indirect' || op === 'call_ref'
2220
+ || op === 'global.get' || op === 'global.set' || op === 'memory.size' || op === 'memory.grow'
2221
+ || op === 'memory.copy' || op === 'memory.fill') return false
2222
+ if (typeof op === 'string' && (op.includes('.load') || op.includes('.store') || op.includes('.atomic'))) return false
2223
+ for (let i = 1; i < n.length; i++) if (!movablePure(n[i])) return false
2224
+ return true
2225
+ }
2226
+ const readsOf = (n, out) => { if (!Array.isArray(n)) return; if (n[0] === 'local.get' && typeof n[1] === 'string') out.add(n[1]); for (let i = 1; i < n.length; i++) readsOf(n[i], out) }
2227
+ const writesAny = (n, R) => { if (!Array.isArray(n)) return false; if ((n[0] === 'local.set' || n[0] === 'local.tee') && R.has(n[1])) return true; for (let i = 1; i < n.length; i++) if (writesAny(n[i], R)) return true; return false }
2228
+ // Locate the (local.get $t) within `root`'s subtree (not root itself); flag if it sits under a
2229
+ // `loop` relative to root (→ would re-evaluate the moved RHS each iteration).
2230
+ const locateUse = (root, t) => {
2231
+ let found = null
2232
+ const rec = (node, underLoop) => {
2233
+ if (found || !Array.isArray(node)) return
2234
+ for (let i = 1; i < node.length; i++) {
2235
+ if (found) return
2236
+ const c = node[i]
2237
+ if (Array.isArray(c) && c[0] === 'local.get' && c[1] === t) { found = { parent: node, idx: i, underLoop }; return }
2238
+ rec(c, underLoop || node[0] === 'loop')
2239
+ }
2240
+ }
2241
+ rec(root, false)
2242
+ return found
2243
+ }
2244
+
2245
+ const removed = new Set()
2246
+ const optimizeList = (list, start) => {
2247
+ for (let i = start; i < list.length; i++) {
2248
+ const s = list[i]
2249
+ if (!Array.isArray(s)) continue
2250
+ // s.length === 3: an explicit-RHS `(local.set $t E)`. A bare `(local.set $t)` (length 2)
2251
+ // binds a value already on the stack — e.g. the try_table catch payload — and has no RHS to
2252
+ // move; treating its undefined RHS as movable would substitute `undefined` into the use.
2253
+ if (s[0] === 'local.set' && s.length === 3 && typeof s[1] === 'string' && cand.has(s[1]) && movablePure(s[2])) {
2254
+ const t = s[1], E = s[2], R = new Set(); readsOf(E, R)
2255
+ for (let j = i + 1; j < list.length; j++) {
2256
+ const sj = list[j]
2257
+ const u = Array.isArray(sj) ? locateUse(sj, t) : null
2258
+ if (u) { // found the sole use's statement
2259
+ if (!u.underLoop && !writesAny(sj, R)) { // same nesting + read-locals intact
2260
+ u.parent[u.idx] = E
2261
+ list.splice(i, 1)
2262
+ cand.delete(t); removed.add(t)
2263
+ i-- // re-process from the freed slot (forward chains)
2264
+ }
2265
+ break // use located — stop scanning this candidate
2266
+ }
2267
+ if (writesAny(sj, R)) break // a read-local clobbered before the use → can't move
2268
+ }
2269
+ if (removed.has(s[1])) continue
2270
+ }
2271
+ // recurse into nested statement lists
2272
+ if (s[0] === 'block' || s[0] === 'loop') {
2273
+ let k = 1; while (k < s.length && Array.isArray(s[k]) && s[k][0] === 'result') k++
2274
+ optimizeList(s, k)
2275
+ } else if (s[0] === 'if') {
2276
+ for (let k = 1; k < s.length; k++) { const c = s[k]; if (Array.isArray(c) && (c[0] === 'then' || c[0] === 'else')) optimizeList(c, 1) }
2277
+ }
2278
+ }
2279
+ }
2280
+ optimizeList(fn, bodyStart)
2281
+
2282
+ // drop the now-orphaned decls (deferred so the body walk above sees stable indices)
2283
+ if (removed.size) for (let i = fn.length - 1; i >= 2; i--) { const c = fn[i]; if (Array.isArray(c) && c[0] === 'local' && removed.has(c[1])) fn.splice(i, 1) }
2284
+ }
2285
+
1748
2286
  /**
1749
2287
  * Module-wide scan for "volatile" globals — those mutated (`global.set`) in any
1750
2288
  * function other than `$__start`. Globals written only in `$__start` are
@@ -2100,15 +2638,8 @@ function inferTypeFromContext(fn, gName, bodyStart) {
2100
2638
  }
2101
2639
  if (pOp === 'i32.store' && idx === 2) { inferred = 'i32'; return } // addr
2102
2640
  if (pOp === 'f64.store' && idx === 2) { inferred = 'f64'; return } // addr can be i32, but value is f64
2103
- if (pOp === 'i32.eq' || pOp === 'i32.ne' || pOp === 'i32.lt_s' || pOp === 'i32.lt_u' ||
2104
- pOp === 'i32.gt_s' || pOp === 'i32.gt_u' || pOp === 'i32.le_s' || pOp === 'i32.le_u' ||
2105
- pOp === 'i32.ge_s' || pOp === 'i32.ge_u') {
2106
- // Comparison — could be i32, but in jz NaN-boxing scheme most globals are f64
2107
- // Only if we can confirm from local.set context
2108
- }
2109
- if (pOp === 'local.set' && idx === 0) {
2110
- // Can't determine local type from here easily
2111
- }
2641
+ // i32 comparisons already matched the `i32.` prefix above; a `local.set`
2642
+ // parent tells us nothing here both fall through to the f64 default.
2112
2643
  }
2113
2644
  }
2114
2645
  // Default: f64 (the NaN-boxing carrier)
@@ -2132,22 +2663,33 @@ function inferTypeFromContext(fn, gName, bodyStart) {
2132
2663
  *
2133
2664
  * Mutates `funcs` in place; writes new global decls via `addGlobal(name, constLiteral)`.
2134
2665
  */
2666
+ // `String(number)` keeps only ~9 significant digits in the self-host kernel (jz's number
2667
+ // formatter — see README "differences"). The old pool keyed constants by `n:${c[1]}` (a toString)
2668
+ // and emitted them via that same string, so in the kernel a constant both LOST precision
2669
+ // (0.041666666666666664 → 0x1.5555558325751p-5) and could MERGE with a distinct value sharing its
2670
+ // 9-digit prefix. Key by the exact 64 bits instead (a Float64Array/Uint32Array union — the
2671
+ // numHashLiteral pattern, which self-hosts; the sign bit distinguishes -0/+0 for free) and emit
2672
+ // the original NUMBER, which `declGlobal` lowers to a binary `f64.const` (exact, no string).
2673
+ const _FCB = new Float64Array(1), _FCBu = new Uint32Array(_FCB.buffer)
2674
+ const f64BitsKey = (n) => { _FCB[0] = n; return `n:${_FCBu[0]}:${_FCBu[1]}` }
2675
+
2135
2676
  export function hoistConstantPool(funcs, addGlobal) {
2136
2677
  const MIN_USES = 2
2137
2678
  // Single walk: count occurrences AND record each f64.const site for direct rewrite.
2138
2679
  // Avoids a second full-AST traversal in the rewrite phase.
2139
2680
  const counts = new Map()
2681
+ // NOTE: not `valueOf` — a local named like an Object method self-host-miscompiles (the
2682
+ // kernel's dynamic dispatch confuses it). key → exact original c[1] (number, or source string).
2683
+ const exactVal = new Map()
2140
2684
  const sites = [] // { parent, idx, key }
2141
2685
  const walk = (node) => {
2142
2686
  if (!Array.isArray(node)) return
2143
2687
  for (let i = 0; i < node.length; i++) {
2144
2688
  const c = node[i]
2145
2689
  if (Array.isArray(c) && c[0] === 'f64.const' && (typeof c[1] === 'number' || typeof c[1] === 'string')) {
2146
- // Distinguish -0 from +0 by sign: template literal collapses both to "0".
2147
- const k = typeof c[1] === 'number'
2148
- ? (Object.is(c[1], -0) ? 'n:-0' : `n:${c[1]}`)
2149
- : `s:${c[1]}`
2690
+ const k = typeof c[1] === 'number' ? f64BitsKey(c[1]) : `s:${c[1]}`
2150
2691
  counts.set(k, (counts.get(k) || 0) + 1)
2692
+ if (!exactVal.has(k)) exactVal.set(k, c[1])
2151
2693
  sites.push({ parent: node, idx: i, key: k })
2152
2694
  }
2153
2695
  walk(c)
@@ -2160,8 +2702,9 @@ export function hoistConstantPool(funcs, addGlobal) {
2160
2702
  let gId = 0
2161
2703
  for (const [k] of sorted) {
2162
2704
  const name = `__fc${gId++}`
2163
- const lit = k.slice(2)
2164
- addGlobal(name, lit)
2705
+ // The EXACT original c[1] (a number → binary f64.const; or a source hex/decimal string),
2706
+ // never the lossy k-derived toString.
2707
+ addGlobal(name, exactVal.get(k))
2165
2708
  hoist.set(k, name)
2166
2709
  }
2167
2710
  if (!hoist.size) return
@@ -2269,9 +2812,7 @@ export function specializeMkptr(funcs, addFunc, parseWat) {
2269
2812
  // $__mkptr inline fast path: bake (type, aux) literals into i64.const template.
2270
2813
  if (target === '$__mkptr' && spec.inline && parts[0].startsWith('L:') && parts[1].startsWith('L:')) {
2271
2814
  const type = +parts[0].slice(2), aux = +parts[1].slice(2)
2272
- const tmpl = LAYOUT.NAN_PREFIX_BITS
2273
- | ((BigInt(type) & BigInt(LAYOUT.TAG_MASK)) << BigInt(LAYOUT.TAG_SHIFT))
2274
- | ((BigInt(aux) & BigInt(LAYOUT.AUX_MASK)) << BigInt(LAYOUT.AUX_SHIFT))
2815
+ const tmpl = ptrBits(type, aux) // box prefix (offset OR'd in at runtime below)
2275
2816
  // Third arg (offset) may also be literal — emit (f64.const nan:…) then.
2276
2817
  if (parts[2].startsWith('L:')) {
2277
2818
  // Fully literal: all sites can be f64.const — no helper needed, handled in rewrite below.
@@ -2313,11 +2854,7 @@ export function specializeMkptr(funcs, addFunc, parseWat) {
2313
2854
  // $__mkptr fully literal (rare — mkPtrIR usually folds these ahead of us, but defensive):
2314
2855
  if (target === '$__mkptr' && parts[0].startsWith('L:') && parts[1].startsWith('L:') && parts[2].startsWith('L:')) {
2315
2856
  const type = +parts[0].slice(2), aux = +parts[1].slice(2), off = +parts[2].slice(2)
2316
- const bits = LAYOUT.NAN_PREFIX_BITS
2317
- | ((BigInt(type) & BigInt(LAYOUT.TAG_MASK)) << BigInt(LAYOUT.TAG_SHIFT))
2318
- | ((BigInt(aux) & BigInt(LAYOUT.AUX_MASK)) << BigInt(LAYOUT.AUX_SHIFT))
2319
- | (BigInt(off >>> 0) & BigInt(LAYOUT.OFFSET_MASK))
2320
- const n = ['f64.const', 'nan:0x' + bits.toString(16).toUpperCase().padStart(16, '0')]
2857
+ const n = ['f64.const', 'nan:' + i64Hex(ptrBits(type, aux, off))]
2321
2858
  n.type = 'f64'
2322
2859
  parent[idx] = n
2323
2860
  continue
@@ -2641,6 +3178,174 @@ export function foldStrDispatchF64(fn) {
2641
3178
  for (let i = bodyStart; i < fn.length; i++) fn[i] = foldNode(fn[i])
2642
3179
  }
2643
3180
 
3181
+ /**
3182
+ * Loop-unswitch a polymorphic typed-array PARAM loop on the pointer type so the
3183
+ * Float64Array case hoists its base and vectorizes.
3184
+ *
3185
+ * `export function f(buf,n){ for(let i=0;i<n;i++) buf[i]=g(buf[i],i) }` emits a
3186
+ * per-iteration POLYMORPHIC store `(drop (if tag(buf)==ARRAY (then __arr_set_idx_ptr;
3187
+ * local.set $buf) (else f64.store __ptr_offset(buf)+i<<3)))` and a read
3188
+ * `__to_num(reinterpret(__typed_idx(reinterpret(buf), i)))` — re-decoding the NaN-box
3189
+ * base every iteration. The `local.set $buf` realloc reassign marks the param unsafe,
3190
+ * so hoistInvariantPtrOffset bails and the loop never vectorizes.
3191
+ *
3192
+ * Insert a ONCE-before-loop test "is buf a (non-BigInt) Float64Array?": yes → a fast
3193
+ * loop with the base hoisted to an i32 local, the read collapsed to `f64.load`, and the
3194
+ * polymorphic store replaced by a direct `f64.store` (no calls) — which vectorizeLaneLocal
3195
+ * then lifts to f64x2. no → the original block verbatim (bit-exact fallback for ARRAY and
3196
+ * every other element width). Float64Array (owned aux=7 or view aux=15) is the ONLY gated
3197
+ * type: the else-branch f64.store is 8-byte, valid only for f64 elements; Int32Array /
3198
+ * Uint8Array / BigInt64Array (aux 4 / 1 / 23) all fall to the verbatim path. The global-
3199
+ * Float64Array path already lowers reads to f64.load, proving f64.load == the __to_num
3200
+ * read for f64 elements (bit-exact, incl. NaN). All helpers are nested function decls
3201
+ * (no ctx param) per the self-host discipline.
3202
+ */
3203
+ export function unswitchTypedParamLoop(fn) {
3204
+ if (!Array.isArray(fn) || fn[0] !== 'func') return
3205
+ const bodyStart = findBodyStart(fn)
3206
+ if (bodyStart < 0) return
3207
+ const f64Params = new Set()
3208
+ for (let i = 2; i < bodyStart; i++) {
3209
+ const c = fn[i]
3210
+ if (Array.isArray(c) && c[0] === 'param' && typeof c[1] === 'string' && c[2] === 'f64') f64Params.add(c[1])
3211
+ }
3212
+ if (!f64Params.size) return
3213
+
3214
+ const F64 = TYPED_ELEM_CODE.Float64Array, F64V = F64 | TYPED_ELEM_VIEW_FLAG
3215
+ const newLocals = []
3216
+ let baseId = nextLocalId(fn, 'utb')
3217
+
3218
+ const clone = (n) => Array.isArray(n) ? n.map(clone) : n
3219
+ const has = (n, pred) => Array.isArray(n) && (pred(n) || n.some((c, i) => i > 0 && has(c, pred)))
3220
+ const writes = (n, name) => has(n, (x) => (x[0] === 'local.set' || x[0] === 'local.tee') && x[1] === name)
3221
+ 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
3222
+ const typedIdx = (n, p) => Array.isArray(n) && n[0] === 'call' && n[1] === '$__typed_idx' && n.length >= 4 && reintParam(n[2], p)
3223
+
3224
+ // Clone, collapsing the typed-array read to a direct f64.load(base + IND<<3):
3225
+ // __to_num(reinterpret(__typed_idx(reinterpret P, IND))) — and the bare form too.
3226
+ function cloneRead(n, p, base) {
3227
+ if (!Array.isArray(n)) return n
3228
+ if (n[0] === 'call' && n[1] === '$__to_num' && n.length === 3
3229
+ && Array.isArray(n[2]) && n[2][0] === 'i64.reinterpret_f64' && typedIdx(n[2][1], p))
3230
+ return ['f64.load', ['i32.add', ['local.get', base], ['i32.shl', clone(n[2][1][3]), ['i32.const', 3]]]]
3231
+ if (typedIdx(n, p))
3232
+ return ['f64.load', ['i32.add', ['local.get', base], ['i32.shl', clone(n[3]), ['i32.const', 3]]]]
3233
+ return n.map((c, i) => i === 0 ? c : cloneRead(c, p, base))
3234
+ }
3235
+
3236
+ function processBlock(blockNode, parent, idx) {
3237
+ if (!Array.isArray(blockNode) || blockNode[0] !== 'block') return
3238
+ let loopNode = null, blockLabel = null
3239
+ const preamble = []
3240
+ for (let i = 1; i < blockNode.length; i++) {
3241
+ const c = blockNode[i]
3242
+ if (i === 1 && typeof c === 'string' && c.startsWith('$')) { blockLabel = c; continue }
3243
+ if (Array.isArray(c) && c[0] === 'loop') { if (loopNode) return; loopNode = c }
3244
+ else if (Array.isArray(c) && c[0] === 'local.set' && !loopNode) preamble.push(c)
3245
+ else if (Array.isArray(c)) return
3246
+ }
3247
+ if (!loopNode || !blockLabel) return
3248
+ const loopLabel = typeof loopNode[1] === 'string' && loopNode[1].startsWith('$') ? loopNode[1] : null
3249
+ if (!loopLabel) return
3250
+ const endIdx = loopNode.length - 1
3251
+ if (!(Array.isArray(loopNode[endIdx]) && loopNode[endIdx][0] === 'br' && loopNode[endIdx][1] === loopLabel)) return
3252
+ const incNode = loopNode[endIdx - 1]
3253
+ if (!Array.isArray(incNode) || incNode[0] !== 'local.set' || !Array.isArray(incNode[2]) || incNode[2][0] !== 'i32.add') return
3254
+ const incVar = incNode[1], inc = incNode[2]
3255
+ if (!(Array.isArray(inc[1]) && inc[1][0] === 'local.get' && inc[1][1] === incVar && Array.isArray(inc[2]) && inc[2][0] === 'i32.const' && inc[2][1] === 1)) return
3256
+ const body = loopNode.slice(3, endIdx - 1)
3257
+ if (body.length < 4) return
3258
+
3259
+ // Find the polymorphic-store `if` by scanning (it's followed by a `drop` of its
3260
+ // f64 result; in the IR the two are separate statements, not (drop (if …))).
3261
+ let storeIdx = -1, paramName = null, elseStore = null
3262
+ for (let i = 0; i < body.length; i++) {
3263
+ const c = body[i]
3264
+ if (!Array.isArray(c) || c[0] !== 'if' || !Array.isArray(c[1]) || c[1][0] !== 'result' || c[1][1] !== 'f64') continue
3265
+ let thenArm = null, elseArm = null
3266
+ for (let k = 2; k < c.length; k++) { const a = c[k]; if (Array.isArray(a)) { if (a[0] === 'then') thenArm = a; else if (a[0] === 'else') elseArm = a } }
3267
+ if (!thenArm || !elseArm) continue
3268
+ let p = null
3269
+ for (let k = 1; k < thenArm.length; k++) { const a = thenArm[k]; if (Array.isArray(a) && a[0] === 'local.set' && f64Params.has(a[1]) && Array.isArray(a[2]) && a[2][0] === 'local.get') p = a[1] }
3270
+ if (!p || !has(thenArm, (x) => x[0] === 'call' && x[1] === '$__arr_set_idx_ptr')) continue
3271
+ // The bare `f64.store(__ptr_offset(o)+i<<3)` is the non-ARRAY fallback. It may be
3272
+ // nested under an OBJECT/HASH → __dyn_set guard (emitPolymorphicElementStore's
3273
+ // dyn-prop safety fork) — descend to find it; the fast path replaces the whole
3274
+ // store with a direct f64.load/store anyway (a proven Float64Array is never an
3275
+ // OBJECT, so its dyn arm is dead there). Still bail on the 3-way __typed_set_idx
3276
+ // form (mixed element widths — the f64.store fallback isn't the sole non-ARRAY case).
3277
+ const findRawStore = (n) => {
3278
+ if (!Array.isArray(n)) return null
3279
+ if (n[0] === 'f64.store' && Array.isArray(n[1]) && n[1][0] === 'i32.add' &&
3280
+ Array.isArray(n[1][2]) && n[1][2][0] === 'i32.shl' &&
3281
+ has(n[1], (x) => x[0] === 'call' && x[1] === '$__ptr_offset')) return n
3282
+ for (let k = 1; k < n.length; k++) { const r = findRawStore(n[k]); if (r) return r }
3283
+ return null
3284
+ }
3285
+ if (has(elseArm, (x) => x[0] === 'call' && x[1] === '$__typed_set_idx')) continue
3286
+ const es = findRawStore(elseArm)
3287
+ if (!es) continue
3288
+ storeIdx = i; paramName = p; elseStore = es; break
3289
+ }
3290
+ if (storeIdx < 0) return
3291
+ const shiftIdx = elseStore[1][2][1] // the index from the store's (i32.shl IDX 3)
3292
+ // The read uses the IV directly; the store uses a snapshot `$asi = $iv`. Emit the
3293
+ // store against the IV too so the vectorizer unifies the load/store lanes — bit-exact
3294
+ // ($asi == $iv). Bail if the store index isn't the IV or a snapshot of it.
3295
+ let storeIdxName = null
3296
+ if (Array.isArray(shiftIdx) && shiftIdx[0] === 'local.get') storeIdxName = shiftIdx[1]
3297
+ if (storeIdxName !== incVar &&
3298
+ !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
3299
+ // The store-if pushes f64; a following `drop` (bare string in stack-style IR, or a
3300
+ // `['drop', …]` node) pops it. The fast store pushes nothing, so the drop must go too.
3301
+ const isDrop = (s) => s === 'drop' || (Array.isArray(s) && s[0] === 'drop')
3302
+ const hasDrop = storeIdx + 1 < body.length && isDrop(body[storeIdx + 1])
3303
+
3304
+ // The stored value is the else-store's operand (a local the body computed); take it
3305
+ // from the store itself, not by guessing which local reads buf — the read may be
3306
+ // nested in a split computation (`$t = buf[i]; $v = $t*2`) whose result is a DIFFERENT
3307
+ // local. cloneRead rewrites any buf reads in that computation to f64.load.
3308
+ if (!(Array.isArray(elseStore[2]) && elseStore[2][0] === 'local.get')) return
3309
+ const valName = elseStore[2][1]
3310
+ // GUARD: param reassigned ONLY inside the matched store-if (else the hoisted base goes stale).
3311
+ for (let i = 0; i < body.length; i++) { if (i === storeIdx) continue; if (writes(body[i], paramName)) return }
3312
+ for (const s of preamble) { if (writes(s, paramName)) return }
3313
+
3314
+ const base = `$__utb${baseId++}`
3315
+ newLocals.push(['local', base, 'i32'])
3316
+ const reint = () => ['i64.reinterpret_f64', ['local.get', paramName]]
3317
+ const tag = ['i32.and', ['i32.wrap_i64', ['i64.shr_u', reint(), ['i64.const', LAYOUT.TAG_SHIFT]]], ['i32.const', LAYOUT.TAG_MASK]]
3318
+ const auxOf = () => ['i32.and', ['i32.wrap_i64', ['i64.shr_u', reint(), ['i64.const', LAYOUT.AUX_SHIFT]]], ['i32.const', LAYOUT.AUX_MASK]]
3319
+ const gate = ['i32.and', ['i32.eq', tag, ['i32.const', PTR.TYPED]],
3320
+ ['i32.or', ['i32.eq', auxOf(), ['i32.const', F64]], ['i32.eq', auxOf(), ['i32.const', F64V]]]]
3321
+ const baseSnap = ['local.set', base, ['call', '$__ptr_offset', reint()]]
3322
+ const fastStore = ['f64.store', ['i32.add', ['local.get', base], ['i32.shl', ['local.get', incVar], ['i32.const', 3]]], ['local.get', valName]]
3323
+ // Fast body: keep every statement except the store-if (→ fastStore) and its trailing
3324
+ // drop (the fast store pushes nothing), with the typed-array read collapsed to f64.load.
3325
+ const fastStmts = []
3326
+ for (let i = 0; i < body.length; i++) {
3327
+ if (i === storeIdx) { fastStmts.push(fastStore); continue }
3328
+ if (hasDrop && i === storeIdx + 1) continue
3329
+ fastStmts.push(cloneRead(body[i], paramName, base))
3330
+ }
3331
+ const fastLoop = ['block', blockLabel, ...preamble.map(clone),
3332
+ ['loop', loopLabel, clone(loopNode[2]), ...fastStmts, clone(incNode), clone(loopNode[endIdx])]]
3333
+ parent[idx] = ['if', gate, ['then', baseSnap, fastLoop], ['else', blockNode]]
3334
+ }
3335
+
3336
+ function walk(node, parent, idx) {
3337
+ if (!Array.isArray(node)) return
3338
+ if (node[0] === 'block') {
3339
+ const before = parent[idx]
3340
+ processBlock(node, parent, idx)
3341
+ if (parent[idx] !== before) return
3342
+ }
3343
+ for (let i = 0; i < node.length; i++) walk(node[i], node, i)
3344
+ }
3345
+ for (let i = bodyStart; i < fn.length; i++) walk(fn[i], fn, i)
3346
+ if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
3347
+ }
3348
+
2644
3349
  /**
2645
3350
  * Run all per-function IR optimizations on a single function node.
2646
3351
  * hoistPtrType runs first — it introduces new locals (`$__ptN`) that the fused
@@ -2667,9 +3372,15 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
2667
3372
  cfg.csePureExpr === false &&
2668
3373
  cfg.dropDeadZeroInit === false &&
2669
3374
  cfg.deadStoreElim === false &&
3375
+ cfg.propagateSingleUse === false &&
2670
3376
  cfg.promoteGlobals === false &&
2671
3377
  cfg.sortLocalsByUse === false &&
2672
3378
  cfg.vectorizeLaneLocal === false) return
3379
+ // Recursion-unrolling runs first in 'pre': self-calls are still clean `call`
3380
+ // nodes (watr's inliner hasn't reshaped them) and the freshly-inlined body then
3381
+ // rides every pass below (LICM, fold, sort). Speed-tier only; 'pre' only (so the
3382
+ // post-watr re-optimize doesn't unroll a second time).
3383
+ if (cfg && cfg.recursionUnroll === true && phase === 'pre') recursionUnroll(fn)
2673
3384
  if (!cfg || cfg.hoistPtrType !== false) hoistPtrType(fn)
2674
3385
  if (!cfg || cfg.hoistInvariantPtrOffset !== false) hoistInvariantPtrOffset(fn)
2675
3386
  // Before LICM: the snapped i32 bound is itself a hoistable hard-op subtree, so
@@ -2681,6 +3392,7 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
2681
3392
  if (!cfg || cfg.hoistInvariantLoop !== false) hoistInvariantLoop(fn)
2682
3393
  const counts = new Map()
2683
3394
  if (!cfg || cfg.fusedRewrite !== false) fusedRewrite(fn, counts)
3395
+ if (cfg && cfg.boolConvertToSelect === true) boolConvertToSelect(fn)
2684
3396
  if (!cfg || cfg.hoistAddrBase !== false) hoistAddrBase(fn)
2685
3397
  if (!cfg || cfg.hoistInvariantLoop !== false) hoistInvariantLoop(fn)
2686
3398
  if (!cfg || cfg.cseScalarLoad !== false) cseScalarLoad(fn)
@@ -2702,9 +3414,53 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
2702
3414
  // Phase 1: fold dead string-dispatch blocks on proven-f64 locals BEFORE
2703
3415
  // the vectorizer pattern-matches — dead __is_str_key calls in $fbm-style
2704
3416
  // functions (param f64 + op f64) block liftPPC from recognizing them as pure.
3417
+ if (runVectorizer && (!cfg || cfg.unswitchTypedParamLoop !== false)) unswitchTypedParamLoop(fn)
2705
3418
  if (runVectorizer) foldStrDispatchF64(fn)
2706
- if (runVectorizer) vectorizeLaneLocal(fn, cfg.reduceUnroll === true, cfg.relaxedSimd === true, cfg.blurMultiPixel !== false, cfg.whyNotSimd === true, cfg.experimentalStencil !== false, cfg.experimentalOuterStrip !== false, cfg._pureFuncMap || null, cfg.experimentalToneMap !== false)
3419
+ if (runVectorizer) vectorizeLaneLocal(fn, {
3420
+ multiAcc: cfg.reduceUnroll === true,
3421
+ relaxedFma: cfg.relaxedSimd === true,
3422
+ blurMP: cfg.blurMultiPixel !== false,
3423
+ whyNot: cfg.whyNotSimd === true,
3424
+ stencil: cfg.experimentalStencil !== false,
3425
+ outerStrip: cfg.experimentalOuterStrip !== false,
3426
+ pureFuncMap: cfg._pureFuncMap || null,
3427
+ toneMap: cfg.experimentalToneMap !== false,
3428
+ slp: cfg.experimentalSlp !== false, // SLP default-on (testing single-use fix)
3429
+ })
3430
+ // The vectorizer emits `v128.load/store (i32.add base K)` for the unrolled
3431
+ // multi-accumulator reduction (a[i],a[i+2],a[i+4]…) and stencil/strided reads.
3432
+ // fusedRewrite's memarg fold already ran (above, before vectorize), so fold the
3433
+ // freshly-created v128 memargs now — one fewer i32.add per accumulator per
3434
+ // iteration, the hot-loop waste audit-fixpoint.mjs flagged on dot/sum.
3435
+ if (runVectorizer) foldV128Memargs(fn)
2707
3436
  }
3437
+ // Forward-substitute single-use temps — AFTER the vectorizer, never before: it pattern-matches a
3438
+ // STRAIGHT-LINE `s += a[i]*2`, and folding an address/index temp out scrambles it (the typed-array
3439
+ // loop fell from a SIMD body to a scalar unroll, +231 B). For watr:false the whole pipeline is the
3440
+ // 'pre' phase (no 'post' re-run), so vectorize already ran above; for full watr the vectorizer is
3441
+ // deferred to 'post', so skip 'pre' here to stay after it. (propagateSingleUse itself skips any
3442
+ // function the vectorizer already lifted to v128.)
3443
+ const fullWatr_psu = !!(cfg && (cfg.watr === true || typeof cfg.watr === 'object'))
3444
+ if ((phase === 'post' || !fullWatr_psu) && (!cfg || cfg.propagateSingleUse !== false)) propagateSingleUse(fn)
3445
+ // SSA-split loop-private unrolled scratch (post-vectorize: vectorized loops now carry
3446
+ // v128 and are skipped) so the LICM below hoists the per-iteration invariants the
3447
+ // unroller's name-merging hid — rust/LLVM's free-after-unroll register hoist (closes
3448
+ // the raytrace per-sphere `c_i` recompute). Bit-exact; re-run LICM to lift the splits.
3449
+ if (phase === 'post' && (!cfg || cfg.hoistInvariantLoop !== false)) {
3450
+ splitLoopPrivateScratch(fn)
3451
+ // Iterate LICM for the dependency cascade: c = ox²+… is invariant only once ox is
3452
+ // itself hoisted out, which the single-pass hoister can't see in one go. 4 climbs
3453
+ // covers the deepest scratch chain; idempotent, so it self-terminates.
3454
+ for (let k = 0; k < 4; k++) hoistInvariantLoop(fn)
3455
+ }
3456
+ // Loop rotation — the LAST shape pass (post-watr only, so no later pass reverts
3457
+ // it and the v128 loops are already formed for the skip-guard). Speed-tier: it
3458
+ // duplicates the loop condition for a fused conditional back-edge (1.35× on the
3459
+ // lz/qoi scalar scans — see rotateLoops).
3460
+ if (cfg && cfg.rotateLoops === true && phase === 'post') rotateLoops(fn)
3461
+ // Canonicalize boolean conditions (strip redundant `!= 0` / double-`eqz`) — after
3462
+ // rotateLoops so its fused back-edges get cleaned too. Tied to the peephole pass.
3463
+ if (!cfg || cfg.fusedRewrite !== false) simplifyBoolContexts(fn)
2708
3464
  if (!cfg || cfg.sortLocalsByUse !== false) sortLocalsByUse(fn, cfg && cfg.fusedRewrite !== false ? counts : null)
2709
3465
  // An optimizer pass that emits a malformed local — the class that otherwise dies
2710
3466
  // as an opaque watr "Duplicate/Unknown local $x" several phases on — is caught
@@ -2712,6 +3468,180 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
2712
3468
  if (DBG_IR) { const bad = verifyFn(fn); if (bad) throw new Error(`[ir verify] optimize produced invalid IR in ${fn[1]}: ${bad}`) }
2713
3469
  }
2714
3470
 
3471
+ // Fold `(v128.load/store (i32.add base K) …)` → `(… offset=K base …)`. Same logic as
3472
+ // walkRewrite's scalar foldMemargOffsets (MEMOP path), but for the v128 loads/stores the
3473
+ // lane vectorizer creates AFTER fusedRewrite has already run — so they'd otherwise keep a
3474
+ // per-iteration i32.add. Bottom-up, in place; an addr already in offset=/align= form is left.
3475
+ function foldV128Memargs(node) {
3476
+ if (!Array.isArray(node)) return
3477
+ const op = node[0]
3478
+ if (op === 'v128.load' || op === 'v128.store') {
3479
+ const m1 = node[1]
3480
+ if (!(typeof m1 === 'string' && (m1.startsWith('offset=') || m1.startsWith('align='))) &&
3481
+ Array.isArray(m1) && m1[0] === 'i32.add' && m1.length === 3) {
3482
+ const a = m1[1], b = m1[2]
3483
+ let base, offset
3484
+ if (Array.isArray(b) && b[0] === 'i32.const' && typeof b[1] === 'number' && b[1] >= 0 && b[1] < 0x100000000) { base = a; offset = b[1] }
3485
+ else if (Array.isArray(a) && a[0] === 'i32.const' && typeof a[1] === 'number' && a[1] >= 0 && a[1] < 0x100000000) { base = b; offset = a[1] }
3486
+ if (base != null) { node[1] = `offset=${offset}`; node.splice(2, 0, base) }
3487
+ }
3488
+ }
3489
+ for (let i = 1; i < node.length; i++) foldV128Memargs(node[i])
3490
+ }
3491
+
3492
+ // i32 comparison/eqz negations — used to flip a break-condition into the
3493
+ // loop-continue condition. f64 compares are deliberately ABSENT: ¬(a<b) ≠ (a≥b)
3494
+ // across NaN, so those fall through to the `i32.eqz` wrap below.
3495
+ const ROT_NEG = {
3496
+ 'i32.eqz': null, // sentinel: strip the eqz (handled specially)
3497
+ 'i32.eq': 'i32.ne', 'i32.ne': 'i32.eq',
3498
+ 'i32.lt_s': 'i32.ge_s', 'i32.ge_s': 'i32.lt_s', 'i32.gt_s': 'i32.le_s', 'i32.le_s': 'i32.gt_s',
3499
+ 'i32.lt_u': 'i32.ge_u', 'i32.ge_u': 'i32.lt_u', 'i32.gt_u': 'i32.le_u', 'i32.le_u': 'i32.gt_u',
3500
+ }
3501
+
3502
+ // Boolean-context canonicalization. At a true zero/nonzero position — a `br_if`,
3503
+ // `if`, `i32.eqz`, or `select` CONDITION — these are all equivalent to the inner
3504
+ // value: `i32.ne(X, 0) → X`, `i32.ne(0, X) → X`, `i32.eqz(i32.eqz(X)) → X`. jz
3505
+ // emits the redundant compare from `while (x !== 0)` lowering and from rotateLoops'
3506
+ // `negate` (which strips one `eqz` but leaves the `i32.ne`). V8 happens to fold it,
3507
+ // but JSC/wasmtime needn't — so strip it for MINIMAL output regardless of engine.
3508
+ // Only applied at proven boolean positions (never on a value-position `ne`/`eqz`,
3509
+ // which produce a real 0/1).
3510
+ const boolSimp = (n) => {
3511
+ for (;;) {
3512
+ if (!Array.isArray(n)) return n
3513
+ if (n[0] === 'i32.ne' && n.length === 3) {
3514
+ if (Array.isArray(n[2]) && n[2][0] === 'i32.const' && n[2][1] === 0) { n = n[1]; continue }
3515
+ if (Array.isArray(n[1]) && n[1][0] === 'i32.const' && n[1][1] === 0) { n = n[2]; continue }
3516
+ }
3517
+ if (n[0] === 'i32.eqz' && Array.isArray(n[1]) && n[1][0] === 'i32.eqz' && n[1].length === 2) { n = n[1][1]; continue }
3518
+ return n
3519
+ }
3520
+ }
3521
+ function simplifyBoolContexts(fn) {
3522
+ const walk = (node) => {
3523
+ if (!Array.isArray(node)) return
3524
+ for (let i = 1; i < node.length; i++) walk(node[i])
3525
+ const op = node[0]
3526
+ if (op === 'br_if' && node.length === 3) node[2] = boolSimp(node[2])
3527
+ else if (op === 'i32.eqz' && node.length === 2) node[1] = boolSimp(node[1])
3528
+ 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]) }
3529
+ else if (op === 'select' && node.length === 4 && Array.isArray(node[3])) node[3] = boolSimp(node[3])
3530
+ }
3531
+ const bodyStart = findBodyStart(fn)
3532
+ for (let i = bodyStart; i < fn.length; i++) walk(fn[i])
3533
+ }
3534
+
3535
+ /**
3536
+ * Loop rotation (loop inversion). Convert jz's top-test loop idiom
3537
+ * (block $brk (loop $loop (br_if $brk ¬C) BODY… (br $loop)))
3538
+ * into a guarded bottom-test loop with a FUSED conditional back-edge:
3539
+ * (block $brk (br_if $brk ¬C) (loop $loop BODY… (br_if $loop C)))
3540
+ *
3541
+ * V8/TurboFan lowers the fused `br_if $loop C` to one hardware loop branch — the
3542
+ * shape LLVM gives rust/zig, and the reason their hot scalar loops (lz's greedy
3543
+ * match-scan, qoi's run-length scan) beat jz's top-test form, which compiles to a
3544
+ * forward exit-branch PLUS a separate unconditional back-jump. Measured 1.35× on
3545
+ * the lz inner loop; nothing else jz runs reaches this shape — watr's `loopify`
3546
+ * collapses to `loop { if C { …; br } }`, whose back-jump stays UNfused (no win).
3547
+ *
3548
+ * Evaluation count of C is unchanged: guard-once + one back-edge per iteration ==
3549
+ * the top-test form's once-per-loop-top — so it's sound even when C has side
3550
+ * effects (a `local.tee` recurrence, a call). The condition is duplicated only in
3551
+ * the EMITTED text (guard + back-edge), a small size-for-speed trade — speed-tier.
3552
+ *
3553
+ * Conservative skips:
3554
+ * - any v128/SIMD op in the loop — already register-tight; reshaping risks
3555
+ * disturbing the lane structure (mirrors hoistInvariantLoop's hasV128 guard).
3556
+ * - a body that branches to $loop: a `continue` with no step lands on the loop
3557
+ * label, which after rotation sits BEFORE the back-edge test — rotating would
3558
+ * skip it. (jz wraps continue-with-step in a `$cont` block → targets that, not
3559
+ * $loop → still rotatable.)
3560
+ */
3561
+ function rotateLoops(fn) {
3562
+ if (!Array.isArray(fn) || fn[0] !== 'func') return
3563
+ const bodyStart = findBodyStart(fn)
3564
+ if (bodyStart < 0) return
3565
+
3566
+ const clone = (n) => Array.isArray(n) ? n.map(clone) : n
3567
+ // Break-condition C → loop-continue condition ¬C for the back-edge. Fold the
3568
+ // i32 forms so the back-edge stays ONE fused compare-and-branch (a wrapping
3569
+ // `i32.eqz` would add an op inside the hot loop); everything else wraps.
3570
+ const negate = (c) => {
3571
+ if (Array.isArray(c) && c[0] === 'i32.eqz' && c.length === 2) return c[1]
3572
+ if (Array.isArray(c) && c.length === 3 && ROT_NEG[c[0]]) return [ROT_NEG[c[0]], c[1], c[2]]
3573
+ return ['i32.eqz', c]
3574
+ }
3575
+ const targetsLabel = (n, label) => {
3576
+ if (!Array.isArray(n)) return false
3577
+ const op = n[0]
3578
+ if (op === 'br' || op === 'br_if') { if (n[1] === label) return true }
3579
+ else if (op === 'br_table') { for (let i = 1; i < n.length; i++) if (n[i] === label) return true }
3580
+ for (let i = 1; i < n.length; i++) if (targetsLabel(n[i], label)) return true
3581
+ return false
3582
+ }
3583
+ const hasV128 = (n) => Array.isArray(n) && (
3584
+ (typeof n[0] === 'string' && (n[0].startsWith('v128.') || /^[if]\d+x\d+\./.test(n[0]))) ||
3585
+ n.some((c, i) => i > 0 && hasV128(c)))
3586
+
3587
+ const tryRotate = (blk) => {
3588
+ let bi = 1, blockLabel = null
3589
+ if (typeof blk[1] === 'string' && blk[1][0] === '$') { blockLabel = blk[1]; bi = 2 }
3590
+ if (!blockLabel) return null
3591
+ // The loop must be the block's final child; LICM may hoist invariant snaps into
3592
+ // a `local.set` pre-header before it — keep those ahead of the guard (the guard
3593
+ // condition can read them). Bail on anything else (typed blocks, side computations).
3594
+ const preamble = []
3595
+ let loop = null
3596
+ for (let i = bi; i < blk.length; i++) {
3597
+ const c = blk[i]
3598
+ if (Array.isArray(c) && c[0] === 'loop') { if (loop || i !== blk.length - 1) return null; loop = c }
3599
+ else if (Array.isArray(c) && c[0] === 'local.set' && !loop) preamble.push(c)
3600
+ else return null
3601
+ }
3602
+ if (!loop) return null
3603
+ let li = 1, loopLabel = null
3604
+ if (typeof loop[1] === 'string' && loop[1][0] === '$') { loopLabel = loop[1]; li = 2 }
3605
+ if (!loopLabel) return null
3606
+ const loopHeader = []
3607
+ while (li < loop.length) {
3608
+ const c = loop[li]
3609
+ if (Array.isArray(c) && c[0] === 'type') { loopHeader.push(c); li++; continue }
3610
+ if (Array.isArray(c) && (c[0] === 'param' || c[0] === 'result')) return null
3611
+ break
3612
+ }
3613
+ const body = loop.slice(li)
3614
+ if (body.length < 2) return null
3615
+ const head = body[0], tail = body[body.length - 1]
3616
+ if (!(Array.isArray(head) && head[0] === 'br_if' && head[1] === blockLabel && head.length === 3)) return null
3617
+ if (!(Array.isArray(tail) && tail[0] === 'br' && tail[1] === loopLabel && tail.length === 2)) return null
3618
+ const inner = body.slice(1, -1)
3619
+ if (inner.some((s) => targetsLabel(s, loopLabel))) return null // continue → loop top: unsafe
3620
+ if (hasV128(head) || inner.some(hasV128)) return null // vectorized: leave tight
3621
+ const cond = head[2]
3622
+ return ['block', blockLabel, ...preamble,
3623
+ ['br_if', blockLabel, clone(cond)],
3624
+ ['loop', loopLabel, ...loopHeader, ...inner, ['br_if', loopLabel, negate(cond)]]]
3625
+ }
3626
+
3627
+ // Rotate a (block …) at container[i] in place, else descend. Returns true if it fired.
3628
+ const tryAt = (container, i) => {
3629
+ const c = container[i]
3630
+ if (!Array.isArray(c) || c[0] !== 'block') return false
3631
+ const rot = tryRotate(c)
3632
+ if (!rot) return false
3633
+ container[i] = rot
3634
+ walk(rot)
3635
+ return true
3636
+ }
3637
+ const walk = (node) => {
3638
+ if (!Array.isArray(node)) return
3639
+ for (let i = 0; i < node.length; i++) if (!tryAt(node, i)) walk(node[i])
3640
+ }
3641
+ // Top-level statements (a loop block can BE fn[i], not just nested under one).
3642
+ for (let i = bodyStart; i < fn.length; i++) if (!tryAt(fn, i)) walk(fn[i])
3643
+ }
3644
+
2715
3645
  // The i32 form of an integer-valued f64 expression, or null. Used to push ToInt32
2716
3646
  // through a conditional and to collapse the f64 round-trip on integer `+`/`-`.
2717
3647
  // Lossless by construction: `convert_i32(X) → X`; integer `f64.const → i32.const`
@@ -2732,6 +3662,17 @@ function toI32(n) {
2732
3662
  const a = toI32(n[1]), b = toI32(n[2])
2733
3663
  if (a && b) return [op === 'f64.add' ? 'i32.add' : 'i32.sub', a, b]
2734
3664
  }
3665
+ // ToInt32 distributes through a conditional: ToInt32(if C A B) == if(result i32) C
3666
+ // ToInt32(A) ToInt32(B). Recursive — a nested integer `?:` like `((3<a)?(2&a):((7<a)?a:1))|0`
3667
+ // narrows whole to i32 (each arm folded by toI32, incl. nested ifs), so the lane vectorizer
3668
+ // lifts it as i32x4 bitselect instead of bailing on the f64 result. Only reached from
3669
+ // ToInt32 sinks (the select idiom / toI32 recursion), so the i32 result is always wanted.
3670
+ if (op === 'if' && Array.isArray(n[1]) && n[1][0] === 'result' && n[1][1] === 'f64'
3671
+ && Array.isArray(n[3]) && n[3][0] === 'then' && n[3].length === 2
3672
+ && Array.isArray(n[4]) && n[4][0] === 'else' && n[4].length === 2) {
3673
+ const t = toI32(n[3][1]), e = toI32(n[4][1])
3674
+ if (t && e) return ['if', ['result', 'i32'], n[2], ['then', t], ['else', e]]
3675
+ }
2735
3676
  return null
2736
3677
  }
2737
3678
 
@@ -2771,18 +3712,38 @@ function fusedRewrite(fn, counts) {
2771
3712
  }
2772
3713
  const freshI64 = () => { const n = `$__eqt${scratchN++}`; newDecls.push(['local', n, 'i64']); return n }
2773
3714
  const freshF64 = () => { const n = `$__eqf${scratchN++}`; newDecls.push(['local', n, 'f64']); return n }
3715
+ // Single-textual-def locals → their defining value node, so the trunc_sat range fold (below)
3716
+ // can see through the temps inlining introduces when proving an index/packed value fits i32.
3717
+ // Multi-def (incl. loop-carried self-referential) locals are excluded: their value is not the
3718
+ // one def's, so its range wouldn't bound them. Pure read of the IR — value-preserving rewrites
3719
+ // during this same walk keep the captured def's RANGE intact, so a lazily-built map stays sound.
3720
+ // Built on first query only (most functions carry no guarded-trunc form → zero cost).
3721
+ let defVal
3722
+ const get = (name) => {
3723
+ if (defVal === undefined) {
3724
+ defVal = new Map(); const defCnt = new Map()
3725
+ const scanDefs = (n) => {
3726
+ if (!Array.isArray(n)) return
3727
+ 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]) }
3728
+ for (let i = 1; i < n.length; i++) scanDefs(n[i])
3729
+ }
3730
+ for (let i = bodyStart; i < fn.length; i++) scanDefs(fn[i])
3731
+ for (const [k, c] of defCnt) if (c > 1) defVal.delete(k)
3732
+ }
3733
+ return defVal.get(name) || null
3734
+ }
2774
3735
  for (let i = bodyStart; i < fn.length; i++) {
2775
3736
  const c = fn[i]
2776
- if (Array.isArray(c)) fn[i] = walkRewrite(c, !skipInline, counts, freshI64, freshF64)
3737
+ if (Array.isArray(c)) fn[i] = walkRewrite(c, !skipInline, counts, freshI64, freshF64, get)
2777
3738
  }
2778
3739
  if (newDecls.length) fn.splice(bodyStart, 0, ...newDecls)
2779
3740
  }
2780
3741
 
2781
- function walkRewrite(node, doInline, counts, freshI64, freshF64) {
3742
+ function walkRewrite(node, doInline, counts, freshI64, freshF64, get) {
2782
3743
  if (!Array.isArray(node)) return node
2783
3744
  for (let i = 0; i < node.length; i++) {
2784
3745
  const c = node[i]
2785
- if (Array.isArray(c)) node[i] = walkRewrite(c, doInline, counts, freshI64, freshF64)
3746
+ if (Array.isArray(c)) node[i] = walkRewrite(c, doInline, counts, freshI64, freshF64, get)
2786
3747
  }
2787
3748
  const op = node[0]
2788
3749
  // Piggyback local-ref counting for sortLocalsByUse. `counts` may be undefined
@@ -2963,16 +3924,20 @@ function walkRewrite(node, doInline, counts, freshI64, freshF64) {
2963
3924
  if (Array.isArray(v) && v[0] === 'i32.wrap_i64' && Array.isArray(v[1]) && v[1][0] === 'i64.trunc_sat_f64_s' && v[1].length === 2) {
2964
3925
  let inner = v[1][1]
2965
3926
  if (Array.isArray(inner) && inner[0] === 'local.tee' && inner.length === 3) inner = inner[2]
2966
- // ToInt32(if (result f64) C A B) → if (result i32) C toI32(A) toI32(B), when both arms are integer-valued.
2967
- if (Array.isArray(inner) && inner[0] === 'if' && Array.isArray(inner[1]) && inner[1][0] === 'result' && inner[1][1] === 'f64'
2968
- && Array.isArray(inner[3]) && inner[3][0] === 'then' && inner[3].length === 2
2969
- && Array.isArray(inner[4]) && inner[4][0] === 'else' && inner[4].length === 2) {
2970
- const t = toI32(inner[3][1]), e = toI32(inner[4][1])
2971
- if (t && e) return ['if', ['result', 'i32'], inner[2], ['then', t], ['else', e]]
2972
- }
2973
- // ToInt32(integer-valued f64 expr) → its i32 form (covers (i32±i32)|0 sums and nests).
3927
+ // ToInt32(integer-valued f64 expr) → its i32 form: covers (i32±i32)|0 sums AND the
3928
+ // conditional `?:` (toI32 distributes through `(if result f64)`, recursively).
2974
3929
  const i = toI32(inner)
2975
3930
  if (i) return i
3931
+ // Range fallback for the NON-integer-ring values toI32 rejects (`floor(scale·v)`,
3932
+ // `base + scale·v` — every grid/lattice/colour index): when the def chain — resolved
3933
+ // through single-def inlining temps via `get` — provably yields a finite i32-range value,
3934
+ // the +∞ guard is dead AND trunc_sat can't saturate, so the whole guarded select collapses
3935
+ // to one `i32.trunc_sat_f64_s`. SOUND: f64Range admits only pure nodes and proves
3936
+ // finiteness (kills the guard) + in-range (kills saturation), so the result is identical
3937
+ // ToInt32 on every value the program can produce. Drops the i64 round-trip + guard on all
3938
+ // runtimes (this is the post-inline twin of the emit-time fold at ir.js toI32).
3939
+ const rng = f64Range(inner, get)
3940
+ if (rng && rng.lo >= I32_MIN && rng.hi <= I32_MAX) return ['i32.trunc_sat_f64_s', inner]
2976
3941
  }
2977
3942
  }
2978
3943
  // (i32.or X 0) / (i32.or 0 X) → X — drops the redundant source-level `|0` clamp left
@@ -2983,6 +3948,30 @@ function walkRewrite(node, doInline, counts, freshI64, freshF64) {
2983
3948
  if (Array.isArray(a) && a[0] === 'i32.const' && a[1] === 0) return b
2984
3949
  }
2985
3950
 
3951
+ // if→select for a value-producing f64 `if` with PURE arms: (if (result f64) COND (then A)
3952
+ // (else B)) → (select A B COND). This is the branchless `cmov` lowering LLVM/clang apply to
3953
+ // every `cond ? a : b` — it removes the conditional branch (and its misprediction cost on
3954
+ // data-unpredictable conditions) on the whole class of float sign/clamp/reflect ternaries.
3955
+ // The flagship: noise's gradient `(h & 1) === 0 ? x : -x` (8 per perlin × 5 octaves × 65k px).
3956
+ // SOUND: wasm `select` evaluates BOTH arms unconditionally, and `isPureIR` admits only
3957
+ // side-effect-free, non-trapping ops (no load/call/div/rem) — so eager evaluation is safe; it
3958
+ // is the exact predicate emit.js uses for the same fold at emit time, now applied post-watr
3959
+ // where the arms (e.g. `f64.neg (local.get $x)`) are clean after canon-DCE. Gated to NOT fire
3960
+ // when BOTH arms are i32-narrowable — those stay an `if` for the ToInt32-through-if fold +
3961
+ // the i32x4-bitselect conditional-map vectorizer (don't steal the integer path).
3962
+ if (op === 'if' && node.length === 5 && Array.isArray(node[1]) && node[1][0] === 'result' && node[1][1] === 'f64'
3963
+ && Array.isArray(node[3]) && node[3][0] === 'then' && node[3].length === 2
3964
+ && Array.isArray(node[4]) && node[4][0] === 'else' && node[4].length === 2) {
3965
+ const a = node[3][1], b = node[4][1], cond = node[2]
3966
+ // The COND must also be pure: `if` evaluates cond FIRST then one arm, but wasm `select`
3967
+ // evaluates its arms BEFORE the cond. A short-circuit lowering like `a || b` =
3968
+ // `(if (result f64) is_truthy(local.tee $t a) (then get $t)(else b))` hides a `tee` in the
3969
+ // cond that the then-arm reads — reordering it after the arms reads $t stale. Requiring
3970
+ // isPureIR(cond) excludes every tee/call/short-circuit cond while admitting the pure
3971
+ // comparison conds of real float ternaries (noise's `(h & 1) === 0`).
3972
+ if (isPureIR(a) && isPureIR(b) && isPureIR(cond) && !(toI32(a) && toI32(b))) return ['select', a, b, cond]
3973
+ }
3974
+
2986
3975
  // f64.CMP(convert_i32 A, convert_i32 B) → i32.CMP(A, B). Comparing two i32 values is
2987
3976
  // identical whether done in exact f64 or in i32 (the converts are lossless and
2988
3977
  // order-preserving), so an integer comparison over typed-array loads (reads are f64)