jz 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) 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 +1156 -984
  7. package/index.js +40 -9
  8. package/interop.js +193 -138
  9. package/layout.js +29 -18
  10. package/module/array.js +29 -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 +26 -3
  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 +162 -156
  20. package/module/typedarray.js +169 -105
  21. package/package.json +7 -3
  22. package/src/abi/string.js +29 -29
  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 +253 -23
  28. package/src/compile/index.js +198 -61
  29. package/src/compile/loop-divmod.js +12 -58
  30. package/src/compile/loop-model.js +91 -0
  31. package/src/compile/loop-square.js +102 -0
  32. package/src/compile/narrow.js +169 -32
  33. package/src/compile/peel-stencil.js +18 -64
  34. package/src/compile/plan/common.js +29 -0
  35. package/src/compile/plan/index.js +4 -1
  36. package/src/compile/plan/inline.js +176 -21
  37. package/src/compile/plan/literals.js +93 -19
  38. package/src/ctx.js +51 -12
  39. package/src/helper-counters.js +137 -0
  40. package/src/ir.js +102 -13
  41. package/src/kind-traits.js +7 -3
  42. package/src/kind.js +14 -1
  43. package/src/op-policy.js +5 -2
  44. package/src/ops.js +119 -0
  45. package/src/optimize/index.js +960 -136
  46. package/src/optimize/recurse.js +182 -0
  47. package/src/optimize/vectorize.js +1292 -144
  48. package/src/prepare/index.js +11 -7
  49. package/src/reps.js +4 -1
  50. package/src/type.js +53 -45
  51. package/src/wat/assemble.js +92 -9
  52. package/src/widen.js +21 -0
  53. 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
@@ -98,8 +100,10 @@ export const PASS_NAMES = [
98
100
  'hoistGlobalPtrOffset', // stable typed GLOBALS: __ptr_offset resolve → once per function (post-watr, module-level)
99
101
  'fusedRewrite', // peephole + ptr-helper inline + memarg fold
100
102
  'hoistAddrBase',
103
+ 'boolConvertToSelect', // f64 ± (cond?1:0) → branchless select (kills i32↔f64 domain cross on recurrences)
101
104
  'cseScalarLoad',
102
105
  'csePureExpr',
106
+ 'unswitchTypedParamLoop', // Float64Array param loop-unswitch → base-hoisted f64.load/store fast path (vectorizes)
103
107
  'dropDeadZeroInit',
104
108
  'deadStoreElim',
105
109
  'promoteGlobals', // read-only global.get → local for multi-read globals
@@ -113,6 +117,7 @@ export const PASS_NAMES = [
113
117
  'smallConstForUnroll',
114
118
  'nestedSmallConstForUnroll',
115
119
  'vectorizeLaneLocal', // SIMD-128 lift for lane-pure typed-array loops
120
+ 'recursionUnroll', // inline a single non-tail self-call to depth N (tree-recursion call-overhead)
116
121
  'arenaRewind', // per-call heap rewind for no-arg scalar allocator kernels
117
122
  'treeshake',
118
123
  'jsstring', // boundary opt-in: flip exported string params to externref
@@ -127,7 +132,9 @@ const LEVEL_PRESETS = Object.freeze({
127
132
  // force 'light' mode here (inline / inlineOnce / coalesce all off) to dodge the
128
133
  // W1a/W1b miscompiles; watr 4.6.9 fixes both, and the L2 default now runs the full
129
134
  // watr pipeline. `inline` stays off by watr's own default — opt-in only.
130
- 2: Object.freeze({ ...ALL_ON, nestedSmallConstForUnroll: 'auto' }),
135
+ // boolConvertToSelect off at the default level: it's a latency-for-size trade (adds a
136
+ // const + op per site) that only pays off on serial recurrences — speed-tier only.
137
+ 2: Object.freeze({ ...ALL_ON, nestedSmallConstForUnroll: 'auto', boolConvertToSelect: false, recursionUnroll: false }),
131
138
  // L3/'speed' trades a bit of heap headroom for fewer __arr_grow / __hash growth
132
139
  // cycles. arrayMinCap=16 means `[]` and `new Array()` skip the first two doublings
133
140
  // (0→2→4→8→16); hashSmallInitCap=8 keeps per-object __dyn_props at the same load
@@ -143,12 +150,15 @@ const LEVEL_PRESETS = Object.freeze({
143
150
  // closures). Inline `f64.const` is the minimal lowering: V8 CSEs identical
144
151
  // constants for free. Measured −3% on jessie parse for +14% binary — exactly
145
152
  // the size↔speed trade 'speed' exists to make.
146
- 3: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8, reduceUnroll: true, relaxedSimd: true, inlineFns: true }),
153
+ 3: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8, reduceUnroll: true, relaxedSimd: true, inlineFns: true, rotateLoops: true }),
147
154
  // 'size' tightens scalar/unroll caps; 'speed' = level 3. There is no 'balanced'
148
155
  // preset — it was a pure synonym for the default level 2 (omit `optimize` or pass 2).
149
156
  size: Object.freeze({
150
157
  ...ALL_ON,
151
158
  smallConstForUnroll: false, nestedSmallConstForUnroll: false, vectorizeLaneLocal: false, splitCharScan: false,
159
+ recursionUnroll: false, // body tripling is a size regression — speed-only
160
+
161
+ boolConvertToSelect: false, // adds a const + op per site — speed-only latency trade
152
162
  devirtIndirect: false, // guards + duplicated args grow bytes — speed-only trade
153
163
  internStrings: false, // the intern index costs ~16 B per eligible literal — speed-only trade
154
164
  scalarTypedLoopUnroll: 4, scalarTypedNestedUnroll: 8, scalarTypedArrayLen: 8,
@@ -163,7 +173,7 @@ const LEVEL_PRESETS = Object.freeze({
163
173
  // (The stencil + outer-strip vectorizers are NOT level-gated here: they're bit-exact pure wins
164
174
  // like the base lane vectorizer, so they run whenever it does — default-on at level 2+ via
165
175
  // `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 }),
176
+ speed: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8, reduceUnroll: true, relaxedSimd: true, inlineFns: true, rotateLoops: true }),
167
177
  })
168
178
 
169
179
  /**
@@ -201,6 +211,11 @@ export function resolveOptimize(opt) {
201
211
  }
202
212
  // Preserve non-pass tuning keys (e.g. plan.js thresholds)
203
213
  for (const k of Object.keys(opt)) if (!PASS_NAMES.includes(k)) out[k] = opt[k]
214
+ // noSimd: suppress EVERY jz-emitted v128 — both the lane vectorizer AND the SLP
215
+ // store-pair packer. First-class here so `{ level:'speed', noSimd:true }` is a TRUE
216
+ // scalar baseline whether passed nested or via the top-level opts.noSimd flag; the
217
+ // SIMD-vs-scalar correctness oracles depend on it actually disabling SLP.
218
+ if (out.noSimd) { out.vectorizeLaneLocal = false; out.experimentalSlp = false }
204
219
  return out
205
220
  }
206
221
  return { ...ALL_ON }
@@ -399,12 +414,39 @@ function regionTrackCSE(fn, { matchSite, localPrefix, localType }) {
399
414
  * Must run AFTER fusedRewrite — relies on shl-distribution + assoc-lift +
400
415
  * foldMemargOffsets having normalized the base shape.
401
416
  */
417
+ // Pure i32 ops whose value is a function of locals/consts alone — no memory read,
418
+ // no call, no global. A subscript expression built only from these is invariant
419
+ // between two sites as long as none of its local deps is rewritten between them,
420
+ // so CSE-ing the WHOLE address (base + shl(idx)) is value-safe — even when `idx`
421
+ // is a compound stencil offset like `(i32.sub (i32.add idx W) 1)` for `arr[idx+W-1]`.
422
+ const PURE_I32_ADDR_OPS = new Set([
423
+ 'i32.add', 'i32.sub', 'i32.mul', 'i32.shl', 'i32.shr_s', 'i32.shr_u',
424
+ 'i32.and', 'i32.or', 'i32.xor', 'i32.wrap_i64',
425
+ ])
426
+ // Serialize a pure-i32 subscript to a stable key, accumulating its local deps.
427
+ // Returns null if any leaf isn't a local.get / i32.const / pure-i32 op (a load,
428
+ // call, or global.get could change between sites — not CSE-safe by local tracking).
429
+ function pureI32AddrKey(node, deps) {
430
+ if (!Array.isArray(node)) return null
431
+ const op = node[0]
432
+ if (op === 'local.get' && typeof node[1] === 'string') { deps.add(node[1]); return `$${node[1]}` }
433
+ if (op === 'i32.const' && typeof node[1] === 'number') return `#${node[1]}`
434
+ if (!PURE_I32_ADDR_OPS.has(op)) return null
435
+ let key = op + '('
436
+ for (let i = 1; i < node.length; i++) {
437
+ const sub = pureI32AddrKey(node[i], deps)
438
+ if (sub == null) return null
439
+ key += sub + ','
440
+ }
441
+ return key + ')'
442
+ }
443
+
402
444
  export function hoistAddrBase(fn) {
403
445
  return regionTrackCSE(fn, {
404
446
  matchSite(node) {
405
447
  if (node[0] !== 'i32.add' || node.length !== 3) return null
406
448
  const a = node[1], b = node[2]
407
- // Two orderings: (add (get A) (shl (get B) (const K))) or (add (shl …) (get A))
449
+ // Two orderings: (add (get A) (shl IDX (const K))) or (add (shl …) (get A))
408
450
  let baseGet, shlNode
409
451
  if (Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' &&
410
452
  Array.isArray(b) && b[0] === 'i32.shl' && b.length === 3) {
@@ -414,15 +456,88 @@ export function hoistAddrBase(fn) {
414
456
  baseGet = b; shlNode = a
415
457
  } else return null
416
458
  const idx = shlNode[1], shamt = shlNode[2]
417
- if (!Array.isArray(idx) || idx[0] !== 'local.get' || typeof idx[1] !== 'string') return null
418
459
  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]] }
460
+ // idx may be a plain `local.get` (the original biquad case) or any compound
461
+ // pure-i32 subscript (stencil neighbour `arr[idx+W-1]`); both CSE the same way.
462
+ const deps = new Set([baseGet[1]])
463
+ const idxKey = pureI32AddrKey(idx, deps)
464
+ if (idxKey == null) return null
465
+ return { key: `${baseGet[1]}|${idxKey}|${shamt[1]}`, deps: [...deps] }
420
466
  },
421
467
  localPrefix: 'ab',
422
468
  localType: 'i32',
423
469
  })
424
470
  }
425
471
 
472
+ // wasm comparison ops — each yields an i32 that is exactly 0 or 1.
473
+ const BOOL_RESULT_OPS = new Set([
474
+ 'i32.eqz', 'i64.eqz',
475
+ 'i32.eq', 'i32.ne', 'i32.lt_s', 'i32.lt_u', 'i32.gt_s', 'i32.gt_u', 'i32.le_s', 'i32.le_u', 'i32.ge_s', 'i32.ge_u',
476
+ 'i64.eq', 'i64.ne', 'i64.lt_s', 'i64.lt_u', 'i64.gt_s', 'i64.gt_u', 'i64.le_s', 'i64.le_u', 'i64.ge_s', 'i64.ge_u',
477
+ 'f32.eq', 'f32.ne', 'f32.lt', 'f32.gt', 'f32.le', 'f32.ge',
478
+ 'f64.eq', 'f64.ne', 'f64.lt', 'f64.gt', 'f64.le', 'f64.ge',
479
+ ])
480
+
481
+ /**
482
+ * `f64 ± (cond ? 1 : 0)` → branchless f64 `select`, killing the i32↔f64 domain cross.
483
+ *
484
+ * `err = old - (old >= t)` and friends compile to `f64.sub(X, f64.convert_i32_s(cmp))`.
485
+ * The convert (cvtsi2sd) round-trips the comparison result out of a GPR back into an
486
+ * XMM register — a domain-crossing op that sits ON the value's def chain. In the
487
+ * per-pixel error-diffusion sweeps (Floyd–Steinberg / Atkinson / JJN) and scalar IIR
488
+ * thresholds this chain is the loop-carried critical path, so that one cross roughly
489
+ * doubles the per-step latency (V8 keeps the JS threshold entirely in the FP domain).
490
+ *
491
+ * `X - (B?1:0) ≡ (B ? X-1 : X) ≡ select(X-1, X, B)` (likewise `+` → `select(X+1, X, B)`),
492
+ * which never leaves the f64 domain. `select` evaluates BOTH arms, so X must be a
493
+ * side-effect-free duplicable leaf (a `local.get`/const); B is the i32 condition,
494
+ * evaluated once (exactly as the convert did). A pure win on latency-bound recurrences;
495
+ * speed-gated (it adds a const + an arithmetic op — a size↔speed trade) — off at 'size'.
496
+ */
497
+ function boolConvertToSelect(fn) {
498
+ if (!Array.isArray(fn) || fn[0] !== 'func') return
499
+ // Pass 1 — a local whose SOLE definition is a comparison carries a value ∈ {0,1};
500
+ // `err = old - on` (on reused by putBW) reaches us as `convert(local.get $on)`.
501
+ // A param is EXCLUDED even if reassigned once by a comparison: its incoming arg is
502
+ // unconstrained, so a read before the reassignment isn't 0/1. (A plain local read
503
+ // before its def is safe — wasm zero-inits it to 0 = false, which select preserves.)
504
+ const params = new Set()
505
+ for (let i = 2; i < fn.length; i++) if (Array.isArray(fn[i]) && fn[i][0] === 'param') params.add(fn[i][1])
506
+ const defCount = new Map(), defIsCmp = new Map()
507
+ const scan = (n) => {
508
+ if (!Array.isArray(n)) return
509
+ if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') {
510
+ defCount.set(n[1], (defCount.get(n[1]) || 0) + 1)
511
+ const cmp = Array.isArray(n[2]) && BOOL_RESULT_OPS.has(n[2][0])
512
+ defIsCmp.set(n[1], (defIsCmp.has(n[1]) ? defIsCmp.get(n[1]) : true) && cmp)
513
+ }
514
+ for (let i = 1; i < n.length; i++) scan(n[i])
515
+ }
516
+ scan(fn)
517
+ const boolLocals = new Set()
518
+ for (const [name, c] of defCount) if (c === 1 && defIsCmp.get(name) && !params.has(name)) boolLocals.add(name)
519
+
520
+ const isBool01 = (n) => Array.isArray(n) &&
521
+ (BOOL_RESULT_OPS.has(n[0]) || (n[0] === 'local.get' && boolLocals.has(n[1])))
522
+ const dup = (n) => Array.isArray(n) ? n.map(dup) : n
523
+
524
+ // Pass 2 — bottom-up rewrite.
525
+ const rewrite = (n) => {
526
+ if (!Array.isArray(n)) return n
527
+ for (let i = 1; i < n.length; i++) n[i] = rewrite(n[i])
528
+ if ((n[0] === 'f64.sub' || n[0] === 'f64.add') && n.length === 3) {
529
+ const conv = (m) => Array.isArray(m) && (m[0] === 'f64.convert_i32_s' || m[0] === 'f64.convert_i32_u') && isBool01(m[1])
530
+ // `X - bool`, `X + bool`, or (add is commutative) `bool + X`.
531
+ let X = null, B = null
532
+ if (conv(n[2]) && isLeaf(n[1])) { X = n[1]; B = n[2][1] }
533
+ else if (n[0] === 'f64.add' && conv(n[1]) && isLeaf(n[2])) { X = n[2]; B = n[1][1] }
534
+ if (X) return ['select', [n[0], dup(X), ['f64.const', 1]], dup(X), B]
535
+ }
536
+ return n
537
+ }
538
+ rewrite(fn)
539
+ }
540
+
426
541
  /**
427
542
  * Hoist `(call $__ptr_offset (local.get $X))` to a function-entry snapshot
428
543
  * when X is an f64-NaN-boxed parameter that's never reassigned and only ever
@@ -442,6 +557,20 @@ export function hoistAddrBase(fn) {
442
557
  // loop-invariant __jss_length in the same loop condition CAN hoist).
443
558
  const SAFE_OFFSET_CALLS = new Set(['$__ptr_offset', '$__ptr_type', '$__ptr_aux', '$__len', '$__jss_length', '$__jss_charCodeAt'])
444
559
 
560
+ // wasm comparison-op mantissas (the part after the `.`): they yield i32 regardless of
561
+ // operand width (i64.eq, f64.lt, i32.ge_s, …). `eq`/`ne` are sign-agnostic; the ordered
562
+ // compares carry `_s`/`_u` for the integer types and none for f64. Used by resultType to
563
+ // type a hoisted subtree by its root op. A Set membership test, NOT a regex
564
+ // (`/^(eq|ne|lt|gt|le|ge)(_[su])?$/`): the regex mis-anchored under self-host −O2 — `nearest`
565
+ // (the f64.nearest mantissa, from Math.round) starts with `ne`, and the embedded −O2 build
566
+ // matched it as a comparison → the LICM hoist local got typed i32, so `local.set $__li
567
+ // (f64.nearest …)` emitted invalid wasm (f64 into i32) only in the kernel. Explicit string
568
+ // membership is both self-host-robust and cheaper in this LICM-hot path.
569
+ const CMP_MANTISSA = new Set([
570
+ 'eqz', 'eq', 'ne', 'lt', 'gt', 'le', 'ge',
571
+ 'lt_s', 'lt_u', 'gt_s', 'gt_u', 'le_s', 'le_u', 'ge_s', 'ge_u',
572
+ ])
573
+
445
574
  // Calls that don't modify EXISTING heap memory: they may allocate (bump the heap
446
575
  // pointer) or do tag dispatch, but they never write to an address a hoisted
447
576
  // __typed_idx/__str_idx element read would revisit. Their presence must not
@@ -610,6 +739,136 @@ const PURE_LICM_OPS = new Set([
610
739
  'f64.promote_f32', 'f32.demote_f64', 'select',
611
740
  ])
612
741
 
742
+ // Resolve a load/store address back to the single typed-array PARAM it derives from — through
743
+ // `local.get`, the arithmetic in PURE_LICM_OPS, and single-def snap locals ($__li/$__ab) — or
744
+ // null if not exactly one / unprovable (a multi-def or unknown local in the address). Built once
745
+ // per function over the proven-distinct `distinctParams` set; the alias substrate both LICM
746
+ // passes query to hoist a read-only input load across a distinct-buffer store (raytrace's spheres
747
+ // vs framebuffer — the alias-analysis LICM rust/clang get for free).
748
+ function buildBaseParamOf(fn, bodyStart, distinctParams) {
749
+ if (!distinctParams) return () => null
750
+ const paramNames = new Set()
751
+ for (let i = 2; i < bodyStart; i++)
752
+ if (Array.isArray(fn[i]) && fn[i][0] === 'param' && typeof fn[i][1] === 'string') paramNames.add(fn[i][1])
753
+ const singleDef = new Map(), defCount = new Map()
754
+ const scanDefs = (n) => {
755
+ if (!Array.isArray(n)) return
756
+ if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') {
757
+ defCount.set(n[1], (defCount.get(n[1]) || 0) + 1); singleDef.set(n[1], n[2]); scanDefs(n[2]); return
758
+ }
759
+ for (let i = 1; i < n.length; i++) scanDefs(n[i])
760
+ }
761
+ for (let i = bodyStart; i < fn.length; i++) scanDefs(fn[i])
762
+ for (const [k, c] of defCount) if (c > 1) singleDef.delete(k) // multi-def → can't trust the resolution
763
+ return (addr) => {
764
+ const found = new Set(); const seen = new Set(); let bad = false
765
+ const walk = (n) => {
766
+ if (bad || !Array.isArray(n)) return
767
+ if (n[0] === 'local.get' && typeof n[1] === 'string') {
768
+ if (paramNames.has(n[1])) found.add(n[1])
769
+ else if (singleDef.has(n[1]) && !seen.has(n[1])) { seen.add(n[1]); walk(singleDef.get(n[1])) }
770
+ else bad = true // a written/unknown local in the address → base unprovable
771
+ return
772
+ }
773
+ for (let i = 1; i < n.length; i++) walk(n[i])
774
+ }
775
+ walk(addr)
776
+ return !bad && found.size === 1 ? [...found][0] : null
777
+ }
778
+ }
779
+
780
+ // Per-loop invariance/purity analysis — the single proven predicate both LICM passes share.
781
+ // Scans the loop into an effect summary (locals/globals it writes, cells/buffers it stores to,
782
+ // whether it has any call / unsafe call / direct store / v128 op), then closes `pureGiven(node,
783
+ // bound)` over it: true iff `node` is side-effect-free AND loop-invariant, given that the locals
784
+ // in `bound` are private to the candidate (a `local.get` of a bound local reads the in-subtree
785
+ // teed invariant; a free `local.get` must be unwritten by the loop). Memory leaves are admitted
786
+ // only under the summary: a `$__cell_`/distinct-param load iff no aliasing store + no call; a
787
+ // SAFE_OFFSET/READONLY_MEM call iff no unsafe call (+ no direct store for heap reads).
788
+ function loopInvariance(loopNode, { distinctParams, baseParamOf }) {
789
+ const locals = new Set(), globals = new Set(), storedCells = new Set(), storedBases = new Set()
790
+ let hasUnsafeCall = false, hasAnyCall = false, hasDirectStore = false, hasV128 = false
791
+ const scan = (node) => {
792
+ if (!Array.isArray(node)) return
793
+ const op = node[0]
794
+ // A vectorized loop (lane/v128 ops) is already register-tight and hand-tuned;
795
+ // extra scalar hoisting there only adds spill pressure — keep it conservative.
796
+ if (op.startsWith('v128.') || /^[if]\d+x\d+\./.test(op)) hasV128 = true
797
+ if (op === 'local.set' || op === 'local.tee') { if (typeof node[1] === 'string') locals.add(node[1]); for (let i = 2; i < node.length; i++) scan(node[i]); return }
798
+ if (op === 'global.set') { if (typeof node[1] === 'string') globals.add(node[1]); for (let i = 2; i < node.length; i++) scan(node[i]); return }
799
+ if (op === 'call') { hasAnyCall = true; if (!SAFE_OFFSET_CALLS.has(node[1]) && !READONLY_MEM_CALLS.has(node[1]) && !NON_MUTATING_CALLS.has(node[1])) hasUnsafeCall = true; for (let i = 2; i < node.length; i++) scan(node[i]); return }
800
+ if (op === 'call_ref' || op === 'call_indirect') { hasAnyCall = hasUnsafeCall = true; for (let i = 1; i < node.length; i++) scan(node[i]); return }
801
+ if ((op === 'f64.store' || op === 'i32.store') && node.length >= 3) {
802
+ hasDirectStore = true
803
+ const a = node[1]
804
+ if (Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' && a[1].startsWith(CELL_PREFIX)) storedCells.add(a[1])
805
+ if (distinctParams) { const sb = baseParamOf(a); if (sb) storedBases.add(sb) } // alias: which buffers this loop writes
806
+ }
807
+ for (let i = 1; i < node.length; i++) scan(node[i])
808
+ }
809
+ for (let i = 1; i < loopNode.length; i++) scan(loopNode[i])
810
+
811
+ const pureGiven = (node, bound) => {
812
+ if (!Array.isArray(node)) return true // bare operand string/number
813
+ const op = node[0]
814
+ if (op === 'i32.const' || op === 'i64.const' || op === 'f64.const' || op === 'f32.const') return true
815
+ if (op === 'local.get') return typeof node[1] === 'string' && (bound.has(node[1]) || !locals.has(node[1]))
816
+ // A global is invariant only if not set directly AND no call in the loop —
817
+ // any callee may mutate it (no interprocedural effect analysis). (Locals are
818
+ // frame-private, so calls can't touch them; only direct local.set matters.)
819
+ if (op === 'global.get') return typeof node[1] === 'string' && !globals.has(node[1]) && !hasAnyCall
820
+ if (op === 'local.tee') {
821
+ if (typeof node[1] !== 'string') return false
822
+ // The operand is evaluated BEFORE the tee writes $X, so a `local.get $X` inside
823
+ // it reads the loop-carried (previous-iteration) value, not the teed one. Drop
824
+ // $X from `bound` for the operand: `local.tee $X (… $X …)` is a loop recurrence
825
+ // (X = f(X) — e.g. the `while ((nn = nn >>> 1))` induction), NOT invariant.
826
+ const inner = bound.has(node[1]) ? new Set([...bound].filter(b => b !== node[1])) : bound
827
+ return pureGiven(node[2], inner)
828
+ }
829
+ if ((op === 'f64.load' || op === 'i32.load') && node.length === 2) {
830
+ const a = node[1]
831
+ if (Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' && a[1].startsWith(CELL_PREFIX)
832
+ && !hasAnyCall && !storedCells.has(a[1]) && (bound.has(a[1]) || !locals.has(a[1]))) return true
833
+ // Alias-analysis LICM: a load from a typed-array param PROVEN distinct from every buffer
834
+ // this loop writes (base ∉ storedBases) is loop-invariant when its address is invariant —
835
+ // even across the loop's stores, because they can't alias it. This is what lets rust/clang
836
+ // hoist read-only input arrays out of a write loop (raytrace's spheres vs the framebuffer).
837
+ // `pureGiven(a, bound)` proves the address itself invariant (base param unwritten + invariant
838
+ // offset); the calls guard rules out callee memory mutation.
839
+ if (distinctParams && !hasAnyCall) {
840
+ const base = baseParamOf(a)
841
+ if (base && distinctParams.has(base) && !storedBases.has(base) && pureGiven(a, bound)) return true
842
+ }
843
+ return false
844
+ }
845
+ if (op === 'call') {
846
+ if (SAFE_OFFSET_CALLS.has(node[1]))
847
+ return !hasUnsafeCall && node.slice(2).every(c => pureGiven(c, bound))
848
+ // Read-only heap reads: additionally require no direct store (alias-safe).
849
+ if (READONLY_MEM_CALLS.has(node[1]))
850
+ return !hasUnsafeCall && !hasDirectStore && node.slice(2).every(c => pureGiven(c, bound))
851
+ return false
852
+ }
853
+ // A value-producing `if` whose condition and both arms are pure is itself
854
+ // pure — the tag-dispatch idiom `(if (result f64) tag-check (then read-A)
855
+ // (else read-B))` that wraps __typed_idx/__str_idx element access.
856
+ if (op === 'if') {
857
+ for (let i = 1; i < node.length; i++) {
858
+ const c = node[i]
859
+ if (!Array.isArray(c)) continue
860
+ if (c[0] === 'result') continue
861
+ if (c[0] === 'then' || c[0] === 'else') { if (!c.slice(1).every(x => pureGiven(x, bound))) return false }
862
+ else if (!pureGiven(c, bound)) return false // the condition
863
+ }
864
+ return true
865
+ }
866
+ if (PURE_LICM_OPS.has(op)) return node.slice(1).every(c => pureGiven(c, bound))
867
+ return false
868
+ }
869
+ return { pureGiven, locals, globals, storedCells, storedBases, hasUnsafeCall, hasAnyCall, hasDirectStore, hasV128 }
870
+ }
871
+
613
872
  /**
614
873
  * Unified loop-invariant code motion. One principle replaces the three former
615
874
  * pattern hoists (ToInt32 / __ptr_offset / cell-load): a MAXIMAL pure subtree
@@ -630,6 +889,160 @@ const PURE_LICM_OPS = new Set([
630
889
  * watr's shared CSE subtrees, snaps spliced before the loop, decls at bodyStart.
631
890
  * Idempotent: re-running sees only `(local.get $__liN)` and finds nothing to do.
632
891
  */
892
+ // SSA-split loop-private straight-line multi-def scratch so the LICM below can hoist
893
+ // the invariant versions. jz's unroller MERGES each unrolled iteration's `const x`
894
+ // into one multi-def local (e.g. raytrace's sphere loop unrolls 8× sharing $ox/$c),
895
+ // which the LICM cannot hoist — so the per-sphere invariant `c_i = sx_i²+sy_i²+sz_i²
896
+ // −sr_i²` recomputes every pixel instead of once (the 1.24× rust-wasm gap; rust/LLVM
897
+ // keeps them as distinct SSA values and hoists each). Renaming each def to its own
898
+ // version makes them single-def → hoistInvariantLoop lifts the loop-invariant ones.
899
+ //
900
+ // BIT-EXACT: pure renaming + invariant code motion — the same value computed fewer
901
+ // times, no reassociation. Gated to loops with NO v128, so it never disturbs a
902
+ // vectorized loop (whose unrolled shared names the lane/dot vectorizer relies on).
903
+ //
904
+ // SOUND only for a local that, within the loop body, (a) is referenced NOWHERE else in
905
+ // the function (loop-local lifetime — else a post-loop read of the merged name breaks),
906
+ // (b) has every occurrence STRAIGHT-LINE (never under a nested if/block/loop, so a
907
+ // linear walk assigns each use its unique dominating def), (c) is first accessed by a
908
+ // WRITE (no value carried across the back-edge), (d) is only ever `local.set` (never
909
+ // `local.tee`/conditionally defined). Each condition rejects a class that would miscompile.
910
+ export function splitLoopPrivateScratch(fn) {
911
+ if (!Array.isArray(fn) || fn[0] !== 'func') return
912
+ const bodyStart = findBodyStart(fn)
913
+ if (bodyStart < 0) return
914
+ const SCALAR = new Set(['i32', 'i64', 'f64', 'f32'])
915
+ const localTypes = new Map()
916
+ for (let i = 2; i < bodyStart; i++) {
917
+ const c = fn[i]
918
+ if (Array.isArray(c) && (c[0] === 'param' || c[0] === 'local') && typeof c[1] === 'string') localTypes.set(c[1], c[2])
919
+ }
920
+ // Whole-function reference count per local (to verify a candidate is loop-local).
921
+ const fnRefs = new Map()
922
+ const countRefs = (n) => {
923
+ if (!Array.isArray(n)) return
924
+ if ((n[0] === 'local.get' || n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string')
925
+ fnRefs.set(n[1], (fnRefs.get(n[1]) || 0) + 1)
926
+ for (let i = 1; i < n.length; i++) countRefs(n[i])
927
+ }
928
+ for (let i = bodyStart; i < fn.length; i++) countRefs(fn[i])
929
+ // Same proven alias substrate hoistInvariantLoop uses (re-attached after watOptimize, so it
930
+ // survives into this 'post' pass) — lets pureGiven prove a read-only input-array load distinct
931
+ // from the loop's output store, the SOUND replacement for the old address-local-disjointness
932
+ // heuristic (which assumed two loads/stores in different locals never alias — false in general).
933
+ const distinctParams = fn.distinctParams || null
934
+ const baseParamOf = buildBaseParamOf(fn, bodyStart, distinctParams)
935
+
936
+ const hasV128 = (n) => {
937
+ let f = false
938
+ const w = (x) => { if (f || !Array.isArray(x)) return; const o = x[0]; if (typeof o === 'string' && (o.startsWith('v128') || /x(2|4|8|16)\b/.test(o) || o.includes('x2.') || o.includes('x4.') || o.includes('x8.') || o.includes('x16.'))) { f = true; return } for (let i = 1; i < x.length; i++) w(x[i]) }
939
+ w(n); return f
940
+ }
941
+ let minted = 0
942
+ const newDecls = []
943
+
944
+ const processLoop = (loop, parent, idx) => {
945
+ if (loop[0] !== 'loop' || hasV128(loop)) return
946
+ // Candidate names: locals set somewhere directly in the loop's statement list.
947
+ const seen = new Set()
948
+ for (let i = 2; i < loop.length; i++) {
949
+ const s = loop[i]
950
+ if (Array.isArray(s) && s[0] === 'local.set' && typeof s[1] === 'string') seen.add(s[1])
951
+ }
952
+ // Stage 1 — collect SAFE candidates (loop-local, straight-line, first-write, set-only,
953
+ // ≥2 defs) and record each one's def RHS list for the invariance fixpoint.
954
+ const cand = new Map() // name → { defs: [rhs…] }
955
+ for (const name of seen) {
956
+ if (!SCALAR.has(localTypes.get(name))) continue
957
+ let inLoop = 0
958
+ const cnt = (n) => { if (!Array.isArray(n)) return; if ((n[0] === 'local.get' || n[0] === 'local.set' || n[0] === 'local.tee') && n[1] === name) inLoop++; for (let i = 1; i < n.length; i++) cnt(n[i]) }
959
+ cnt(loop)
960
+ if (inLoop !== (fnRefs.get(name) || 0)) continue
961
+ let safe = true, first = null, defs = []
962
+ const scan = (n, depth) => {
963
+ if (!safe || !Array.isArray(n)) return
964
+ const op = n[0]
965
+ if (op === 'local.tee' && n[1] === name) { safe = false; return }
966
+ if (op === 'local.set' && n[1] === name) {
967
+ if (depth > 0) { safe = false; return }
968
+ if (first === null) first = 'w'
969
+ defs.push(n[2])
970
+ scan(n[2], depth)
971
+ return
972
+ }
973
+ if (op === 'local.get' && n[1] === name) {
974
+ if (depth > 0) { safe = false; return }
975
+ if (first === null) first = 'r'
976
+ return
977
+ }
978
+ const ctrl = op === 'if' || op === 'then' || op === 'else' || op === 'block' || op === 'loop'
979
+ for (let i = 1; i < n.length; i++) scan(n[i], depth + (ctrl ? 1 : 0))
980
+ }
981
+ for (let i = 2; i < loop.length; i++) scan(loop[i], 0)
982
+ if (safe && first === 'w' && defs.length >= 2) cand.set(name, defs)
983
+ }
984
+ if (!cand.size) return
985
+ // Stage 2 — invariance fixpoint over the SHARED proven predicate. `pureGiven(def, hoistable)`
986
+ // decides loop-invariance with hoistInvariantLoop's exact model: a `$__cell_`/distinct-param
987
+ // read-only load is invariant across the loop's stores (sound alias analysis), a global is
988
+ // invariant only without a loop write or call, and the `bound` set (here `hoistable`) carries
989
+ // the cascade — a def reading an already-split sibling is invariant once that sibling moves out
990
+ // (c = ox²+… invariant only after ox hoists). `motionSafe` adds the one extra obligation a
991
+ // whole-assignment MOTION needs beyond value-invariance: no `local.tee` writing a local read
992
+ // elsewhere (pureGiven already rejects set/store/global.set/unsafe-call). This replaces the old
993
+ // address-local-disjointness load test, which was unsound in general (two distinct locals can
994
+ // hold the same address) and only worked by luck on the bench shapes.
995
+ const { pureGiven } = loopInvariance(loop, { distinctParams, baseParamOf })
996
+ const motionSafe = (n) => { if (!Array.isArray(n)) return true; if (n[0] === 'local.tee') return false; for (let i = 1; i < n.length; i++) if (!motionSafe(n[i])) return false; return true }
997
+ const hoistable = new Set()
998
+ let changed = true
999
+ while (changed) {
1000
+ changed = false
1001
+ for (const [name, defs] of cand) {
1002
+ if (hoistable.has(name)) continue
1003
+ if (defs.every(d => motionSafe(d) && pureGiven(d, hoistable))) { hoistable.add(name); changed = true }
1004
+ }
1005
+ }
1006
+ // Stage 3 — one linear pass over the loop body: each hoistable def is RENAMED to a
1007
+ // fresh version and MOVED OUT of the loop (before it), in source order so the cascade's
1008
+ // data deps stay intact (c = ox²+… emitted after ox). The cheap arithmetic AND the load
1009
+ // both leave the loop; gets stay, rebound to the moved version. (hoistInvariantLoop only
1010
+ // snapshots expensive subexprs, not whole invariant assignments — so we do the motion.)
1011
+ const curOf = new Map()
1012
+ const rewriteGets = (n) => {
1013
+ if (!Array.isArray(n)) return n
1014
+ if (n[0] === 'local.get' && curOf.has(n[1])) return ['local.get', curOf.get(n[1])]
1015
+ return n.map((c, i) => i === 0 ? c : rewriteGets(c))
1016
+ }
1017
+ const hoisted = []
1018
+ const kept = loop.slice(0, 2) // 'loop' + label
1019
+ for (let i = 2; i < loop.length; i++) {
1020
+ const s = loop[i]
1021
+ if (Array.isArray(s) && s[0] === 'local.set' && hoistable.has(s[1])) {
1022
+ const name = s[1], ty = localTypes.get(name)
1023
+ const nv = `$${name.replace(/^\$/, '')}__sr${minted++}`
1024
+ newDecls.push(['local', nv, ty]); localTypes.set(nv, ty)
1025
+ hoisted.push(['local.set', nv, rewriteGets(s[2])])
1026
+ curOf.set(name, nv)
1027
+ } else {
1028
+ kept.push(rewriteGets(s))
1029
+ }
1030
+ }
1031
+ loop.length = 0
1032
+ for (const x of kept) loop.push(x)
1033
+ parent.splice(idx, 0, ...hoisted)
1034
+ }
1035
+ const walk = (parent, idx) => {
1036
+ const n = parent[idx]
1037
+ if (!Array.isArray(n)) return
1038
+ // Recurse first so an inner loop's hoists land before we process the outer loop.
1039
+ for (let i = 1; i < n.length; i++) walk(n, i)
1040
+ if (n[0] === 'loop') processLoop(n, parent, idx)
1041
+ }
1042
+ for (let i = bodyStart; i < fn.length; i++) walk(fn, i)
1043
+ if (newDecls.length) fn.splice(bodyStart, 0, ...newDecls)
1044
+ }
1045
+
633
1046
  export function hoistInvariantLoop(fn) {
634
1047
  if (!Array.isArray(fn) || fn[0] !== 'func') return
635
1048
  const bodyStart = findBodyStart(fn)
@@ -683,7 +1096,7 @@ export function hoistInvariantLoop(fn) {
683
1096
  // Comparisons and `eqz` yield i32 regardless of operand type (i64.eq, f64.lt,
684
1097
  // i64.eqz, …) — so the operand-type prefix would mistype them. Catch first.
685
1098
  const m = op.slice(dot + 1)
686
- if (m === 'eqz' || /^(eq|ne|lt|gt|le|ge)(_[su])?$/.test(m)) return 'i32'
1099
+ if (CMP_MANTISSA.has(m)) return 'i32'
687
1100
  const p = op.slice(0, dot)
688
1101
  if (p === 'i32' || p === 'i64' || p === 'f64' || p === 'f32') return p
689
1102
  return null
@@ -707,33 +1120,23 @@ export function hoistInvariantLoop(fn) {
707
1120
  const newLocals = []
708
1121
  const refcount = buildRefcount(fn)
709
1122
 
1123
+ // Alias-analysis substrate for hoisting typed-array PARAM element loads across distinct-base
1124
+ // stores. `distinctParams` (stamped by compile/index.js from the param-distinctness pass) is the
1125
+ // set of typed-array params PROVEN to be mutually-distinct buffers at every call site. To use it,
1126
+ // resolve a load/store address back to the single param it derives from — through `local.get`,
1127
+ // `i32.add/sub`, and single-def snap locals ($__li/$__ab from prior ptr-offset hoisting).
1128
+ const distinctParams = fn.distinctParams || null
1129
+ const baseParamOf = buildBaseParamOf(fn, bodyStart, distinctParams)
1130
+
710
1131
  const processLoop = (loopNode, nested) => {
711
1132
  // Inner loops first (bottom-up) — an inner hoist creates a local.get the
712
1133
  // outer level can hoist further. Children run in a nested context.
713
1134
  for (let i = 1; i < loopNode.length; i++)
714
1135
  if (Array.isArray(loopNode[i])) processNode(loopNode[i], loopNode, i, true)
715
1136
 
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])
1137
+ // The loop's effect summary + the proven invariance/purity predicate (shared with
1138
+ // splitLoopPrivateScratch see loopInvariance). `locals` is the loop's whole write-set.
1139
+ const { pureGiven, locals, hasV128 } = loopInvariance(loopNode, { distinctParams, baseParamOf })
737
1140
 
738
1141
  // Per-subtree local-occurrence counts and write-sets, memoized bottom-up —
739
1142
  // the tee-privacy check queries them for EVERY candidate node, and the old
@@ -772,57 +1175,6 @@ export function hoistInvariantLoop(fn) {
772
1175
  for (let i = 1; i < loopNode.length; i++)
773
1176
  for (const [k, v] of countsOf(loopNode[i])) localCount.set(k, (localCount.get(k) || 0) + v)
774
1177
 
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
1178
  const isHoistable = (node) => {
827
1179
  if (!Array.isArray(node)) return false
828
1180
  const op = node[0]
@@ -849,9 +1201,10 @@ export function hoistInvariantLoop(fn) {
849
1201
  if (!Array.isArray(node)) return
850
1202
  if (node[0] === 'loop') return // already processed bottom-up
851
1203
  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)
1204
+ // stableKey: hoistable boxed-pointer subtrees carry i64.const NaN-box prefixes
1205
+ // (BigInt) that plain JSON.stringify can't serialize, and it also collapses
1206
+ // Infinity/-Infinity/NaN→null & -0→0 both would dedup distinct invariants.
1207
+ const key = JSON.stringify(node, stableKey)
855
1208
  let arr = sites.get(key); if (!arr) { arr = []; sites.set(key, arr) }
856
1209
  arr.push({ parent, idx, node })
857
1210
  return
@@ -983,16 +1336,17 @@ export function narrowLoopBound(fn) {
983
1336
  const newLocals = []
984
1337
  const refcount = buildRefcount(fn)
985
1338
 
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)))`.
1339
+ // `i < bound` as `(f64.lt (convert i) bound)` or mirrored `(f64.gt bound (convert i))`.
1340
+ // `i <= bound` as `(f64.le (convert i) bound)` or mirrored `(f64.ge bound (convert i))`.
988
1341
  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
1342
+ const lt = n[0] === 'f64.lt', gt = n[0] === 'f64.gt', le = n[0] === 'f64.le', ge = n[0] === 'f64.ge'
1343
+ const conv = lt || le ? n[1] : gt || ge ? n[2] : null
1344
+ const bnd = lt || le ? n[2] : gt || ge ? n[1] : null
991
1345
  if (!Array.isArray(conv) || conv[0] !== 'f64.convert_i32_s') return null
992
1346
  const ig = conv[1]
993
1347
  if (!Array.isArray(ig) || ig[0] !== 'local.get' || typeof ig[1] !== 'string') return null
994
1348
  if (!Array.isArray(bnd) || bnd[0] !== 'local.get' || typeof bnd[1] !== 'string') return null
995
- return { ctr: ig[1], bound: bnd[1] }
1349
+ return { ctr: ig[1], bound: bnd[1], op: le || ge ? 'le' : 'lt' }
996
1350
  }
997
1351
 
998
1352
  const processLoop = (loopNode) => {
@@ -1022,18 +1376,34 @@ export function narrowLoopBound(fn) {
1022
1376
  }
1023
1377
  for (let i = 1; i < loopNode.length; i++) collect(loopNode[i])
1024
1378
 
1025
- const snapFor = new Map() // bound name snap local (one per distinct bound)
1379
+ // One snap per distinct (bound, op): `i < n` and `i <= n` of the SAME bound
1380
+ // need different snapped i32 values (ceil vs floor).
1381
+ const snapFor = new Map()
1026
1382
  const snaps = []
1383
+ const I32_MIN = -2147483648
1027
1384
  for (const { node, m } of sites) {
1028
- let snap = snapFor.get(m.bound)
1385
+ const key = `${m.bound}|${m.op}`
1386
+ let snap = snapFor.get(key)
1029
1387
  if (!snap) {
1030
1388
  snap = freshLb()
1031
- snapFor.set(m.bound, snap)
1389
+ snapFor.set(key, snap)
1032
1390
  newLocals.push(['local', snap, 'i32'])
1033
- snaps.push(['local.set', snap, ['i32.trunc_sat_f64_s', ['f64.ceil', ['local.get', m.bound]]]])
1391
+ // `i < n` ⟺ `i < ceil(n)`: trunc_sat(NaN)=0 makes `i<0` false — matches `i<NaN`;
1392
+ // ±Inf → I32_MAX/I32_MIN, both correct. NaN-safe for free.
1393
+ // `i <= n` ⟺ `i <= floor(n)`, BUT trunc_sat(floor(NaN))=0 would make `i<=0` run
1394
+ // one iteration at i=0, while JS (`i<=NaN` is false) runs zero. Guard the NaN
1395
+ // case to I32_MIN (below any non-negative counter ⇒ zero iterations). ±Inf are
1396
+ // already correct (floor(+Inf)→I32_MAX, floor(-Inf)→I32_MIN; Inf==Inf is true).
1397
+ snaps.push(['local.set', snap, m.op === 'le'
1398
+ ? ['select',
1399
+ ['i32.trunc_sat_f64_s', ['f64.floor', ['local.get', m.bound]]],
1400
+ ['i32.const', I32_MIN],
1401
+ ['f64.eq', ['local.get', m.bound], ['local.get', m.bound]]]
1402
+ : ['i32.trunc_sat_f64_s', ['f64.ceil', ['local.get', m.bound]]]])
1034
1403
  }
1035
1404
  node.length = 3
1036
- node[0] = 'i32.lt_s'; node[1] = ['local.get', m.ctr]; node[2] = ['local.get', snap]
1405
+ node[0] = m.op === 'le' ? 'i32.le_s' : 'i32.lt_s'
1406
+ node[1] = ['local.get', m.ctr]; node[2] = ['local.get', snap]
1037
1407
  }
1038
1408
  return snaps
1039
1409
  }
@@ -1260,6 +1630,19 @@ export function cseScalarLoad(fn) {
1260
1630
  // the two passes cover deliberately different op sets.
1261
1631
  const COMMUTATIVE = new Set(['f64.mul', 'f64.add', 'i32.mul', 'i32.add', 'i32.and', 'i32.or', 'i32.xor', 'i64.mul', 'i64.add', 'i64.and', 'i64.or', 'i64.xor'])
1262
1632
 
1633
+ // Presence of one of these arms csePureExprLoop (it CSEs redundant pure f64/i32
1634
+ // arithmetic within the loop; the gate is just "is this loop expensive enough to
1635
+ // be worth the pass"). The whole class of transcendental helpers qualifies — each
1636
+ // is a multi-instruction polynomial approximation, so a loop built around exp/log/
1637
+ // pow deserves the same arithmetic-CSE a trig loop already got. Gating on the class,
1638
+ // not a benchmark shape; the CSE itself is bit-exact (pure-subexpr dedup only).
1639
+ const LOOP_CSE_EXPENSIVE = new Set([
1640
+ '$math.sin', '$math.cos', '$math.tan', '$math.sin_core', '$math.cos_core',
1641
+ '$math.exp', '$math.expm1', '$math.log', '$math.log2', '$math.log10', '$math.log1p',
1642
+ '$math.pow', '$math.atan', '$math.asin', '$math.acos', '$math.atan2',
1643
+ '$math.sinh', '$math.cosh', '$math.tanh', '$math.cbrt', '$math.hypot',
1644
+ ])
1645
+
1263
1646
  export function csePureExpr(fn) {
1264
1647
  if (!Array.isArray(fn) || fn[0] !== 'func') return
1265
1648
  const bodyStart = findBodyStart(fn)
@@ -1396,16 +1779,15 @@ export function csePureExprLoop(fn) {
1396
1779
  if (bodyStart < 0) return
1397
1780
 
1398
1781
  let hasLoop = false
1399
- let hasTrigCall = false
1782
+ let hasExpensiveCall = false
1400
1783
  const scanShape = (n) => {
1401
1784
  if (!Array.isArray(n)) return
1402
1785
  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
1786
+ if (n[0] === 'call' && LOOP_CSE_EXPENSIVE.has(n[1])) hasExpensiveCall = true
1405
1787
  for (let i = 1; i < n.length; i++) scanShape(n[i])
1406
1788
  }
1407
1789
  for (let i = bodyStart; i < fn.length; i++) scanShape(fn[i])
1408
- if (!hasLoop || !hasTrigCall) return
1790
+ if (!hasLoop || !hasExpensiveCall) return
1409
1791
 
1410
1792
  let snapId = nextLocalId(fn, 'pe')
1411
1793
  const newLocals = []
@@ -2100,15 +2482,8 @@ function inferTypeFromContext(fn, gName, bodyStart) {
2100
2482
  }
2101
2483
  if (pOp === 'i32.store' && idx === 2) { inferred = 'i32'; return } // addr
2102
2484
  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
- }
2485
+ // i32 comparisons already matched the `i32.` prefix above; a `local.set`
2486
+ // parent tells us nothing here both fall through to the f64 default.
2112
2487
  }
2113
2488
  }
2114
2489
  // Default: f64 (the NaN-boxing carrier)
@@ -2132,22 +2507,33 @@ function inferTypeFromContext(fn, gName, bodyStart) {
2132
2507
  *
2133
2508
  * Mutates `funcs` in place; writes new global decls via `addGlobal(name, constLiteral)`.
2134
2509
  */
2510
+ // `String(number)` keeps only ~9 significant digits in the self-host kernel (jz's number
2511
+ // formatter — see README "differences"). The old pool keyed constants by `n:${c[1]}` (a toString)
2512
+ // and emitted them via that same string, so in the kernel a constant both LOST precision
2513
+ // (0.041666666666666664 → 0x1.5555558325751p-5) and could MERGE with a distinct value sharing its
2514
+ // 9-digit prefix. Key by the exact 64 bits instead (a Float64Array/Uint32Array union — the
2515
+ // numHashLiteral pattern, which self-hosts; the sign bit distinguishes -0/+0 for free) and emit
2516
+ // the original NUMBER, which `declGlobal` lowers to a binary `f64.const` (exact, no string).
2517
+ const _FCB = new Float64Array(1), _FCBu = new Uint32Array(_FCB.buffer)
2518
+ const f64BitsKey = (n) => { _FCB[0] = n; return `n:${_FCBu[0]}:${_FCBu[1]}` }
2519
+
2135
2520
  export function hoistConstantPool(funcs, addGlobal) {
2136
2521
  const MIN_USES = 2
2137
2522
  // Single walk: count occurrences AND record each f64.const site for direct rewrite.
2138
2523
  // Avoids a second full-AST traversal in the rewrite phase.
2139
2524
  const counts = new Map()
2525
+ // NOTE: not `valueOf` — a local named like an Object method self-host-miscompiles (the
2526
+ // kernel's dynamic dispatch confuses it). key → exact original c[1] (number, or source string).
2527
+ const exactVal = new Map()
2140
2528
  const sites = [] // { parent, idx, key }
2141
2529
  const walk = (node) => {
2142
2530
  if (!Array.isArray(node)) return
2143
2531
  for (let i = 0; i < node.length; i++) {
2144
2532
  const c = node[i]
2145
2533
  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]}`
2534
+ const k = typeof c[1] === 'number' ? f64BitsKey(c[1]) : `s:${c[1]}`
2150
2535
  counts.set(k, (counts.get(k) || 0) + 1)
2536
+ if (!exactVal.has(k)) exactVal.set(k, c[1])
2151
2537
  sites.push({ parent: node, idx: i, key: k })
2152
2538
  }
2153
2539
  walk(c)
@@ -2160,8 +2546,9 @@ export function hoistConstantPool(funcs, addGlobal) {
2160
2546
  let gId = 0
2161
2547
  for (const [k] of sorted) {
2162
2548
  const name = `__fc${gId++}`
2163
- const lit = k.slice(2)
2164
- addGlobal(name, lit)
2549
+ // The EXACT original c[1] (a number → binary f64.const; or a source hex/decimal string),
2550
+ // never the lossy k-derived toString.
2551
+ addGlobal(name, exactVal.get(k))
2165
2552
  hoist.set(k, name)
2166
2553
  }
2167
2554
  if (!hoist.size) return
@@ -2269,9 +2656,7 @@ export function specializeMkptr(funcs, addFunc, parseWat) {
2269
2656
  // $__mkptr inline fast path: bake (type, aux) literals into i64.const template.
2270
2657
  if (target === '$__mkptr' && spec.inline && parts[0].startsWith('L:') && parts[1].startsWith('L:')) {
2271
2658
  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))
2659
+ const tmpl = ptrBits(type, aux) // box prefix (offset OR'd in at runtime below)
2275
2660
  // Third arg (offset) may also be literal — emit (f64.const nan:…) then.
2276
2661
  if (parts[2].startsWith('L:')) {
2277
2662
  // Fully literal: all sites can be f64.const — no helper needed, handled in rewrite below.
@@ -2313,11 +2698,7 @@ export function specializeMkptr(funcs, addFunc, parseWat) {
2313
2698
  // $__mkptr fully literal (rare — mkPtrIR usually folds these ahead of us, but defensive):
2314
2699
  if (target === '$__mkptr' && parts[0].startsWith('L:') && parts[1].startsWith('L:') && parts[2].startsWith('L:')) {
2315
2700
  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')]
2701
+ const n = ['f64.const', 'nan:' + i64Hex(ptrBits(type, aux, off))]
2321
2702
  n.type = 'f64'
2322
2703
  parent[idx] = n
2323
2704
  continue
@@ -2641,6 +3022,174 @@ export function foldStrDispatchF64(fn) {
2641
3022
  for (let i = bodyStart; i < fn.length; i++) fn[i] = foldNode(fn[i])
2642
3023
  }
2643
3024
 
3025
+ /**
3026
+ * Loop-unswitch a polymorphic typed-array PARAM loop on the pointer type so the
3027
+ * Float64Array case hoists its base and vectorizes.
3028
+ *
3029
+ * `export function f(buf,n){ for(let i=0;i<n;i++) buf[i]=g(buf[i],i) }` emits a
3030
+ * per-iteration POLYMORPHIC store `(drop (if tag(buf)==ARRAY (then __arr_set_idx_ptr;
3031
+ * local.set $buf) (else f64.store __ptr_offset(buf)+i<<3)))` and a read
3032
+ * `__to_num(reinterpret(__typed_idx(reinterpret(buf), i)))` — re-decoding the NaN-box
3033
+ * base every iteration. The `local.set $buf` realloc reassign marks the param unsafe,
3034
+ * so hoistInvariantPtrOffset bails and the loop never vectorizes.
3035
+ *
3036
+ * Insert a ONCE-before-loop test "is buf a (non-BigInt) Float64Array?": yes → a fast
3037
+ * loop with the base hoisted to an i32 local, the read collapsed to `f64.load`, and the
3038
+ * polymorphic store replaced by a direct `f64.store` (no calls) — which vectorizeLaneLocal
3039
+ * then lifts to f64x2. no → the original block verbatim (bit-exact fallback for ARRAY and
3040
+ * every other element width). Float64Array (owned aux=7 or view aux=15) is the ONLY gated
3041
+ * type: the else-branch f64.store is 8-byte, valid only for f64 elements; Int32Array /
3042
+ * Uint8Array / BigInt64Array (aux 4 / 1 / 23) all fall to the verbatim path. The global-
3043
+ * Float64Array path already lowers reads to f64.load, proving f64.load == the __to_num
3044
+ * read for f64 elements (bit-exact, incl. NaN). All helpers are nested function decls
3045
+ * (no ctx param) per the self-host discipline.
3046
+ */
3047
+ export function unswitchTypedParamLoop(fn) {
3048
+ if (!Array.isArray(fn) || fn[0] !== 'func') return
3049
+ const bodyStart = findBodyStart(fn)
3050
+ if (bodyStart < 0) return
3051
+ const f64Params = new Set()
3052
+ for (let i = 2; i < bodyStart; i++) {
3053
+ const c = fn[i]
3054
+ if (Array.isArray(c) && c[0] === 'param' && typeof c[1] === 'string' && c[2] === 'f64') f64Params.add(c[1])
3055
+ }
3056
+ if (!f64Params.size) return
3057
+
3058
+ const F64 = TYPED_ELEM_CODE.Float64Array, F64V = F64 | TYPED_ELEM_VIEW_FLAG
3059
+ const newLocals = []
3060
+ let baseId = nextLocalId(fn, 'utb')
3061
+
3062
+ const clone = (n) => Array.isArray(n) ? n.map(clone) : n
3063
+ const has = (n, pred) => Array.isArray(n) && (pred(n) || n.some((c, i) => i > 0 && has(c, pred)))
3064
+ const writes = (n, name) => has(n, (x) => (x[0] === 'local.set' || x[0] === 'local.tee') && x[1] === name)
3065
+ const reintParam = (n, p) => Array.isArray(n) && n[0] === 'i64.reinterpret_f64' && Array.isArray(n[1]) && n[1][0] === 'local.get' && n[1][1] === p
3066
+ const typedIdx = (n, p) => Array.isArray(n) && n[0] === 'call' && n[1] === '$__typed_idx' && n.length >= 4 && reintParam(n[2], p)
3067
+
3068
+ // Clone, collapsing the typed-array read to a direct f64.load(base + IND<<3):
3069
+ // __to_num(reinterpret(__typed_idx(reinterpret P, IND))) — and the bare form too.
3070
+ function cloneRead(n, p, base) {
3071
+ if (!Array.isArray(n)) return n
3072
+ if (n[0] === 'call' && n[1] === '$__to_num' && n.length === 3
3073
+ && Array.isArray(n[2]) && n[2][0] === 'i64.reinterpret_f64' && typedIdx(n[2][1], p))
3074
+ return ['f64.load', ['i32.add', ['local.get', base], ['i32.shl', clone(n[2][1][3]), ['i32.const', 3]]]]
3075
+ if (typedIdx(n, p))
3076
+ return ['f64.load', ['i32.add', ['local.get', base], ['i32.shl', clone(n[3]), ['i32.const', 3]]]]
3077
+ return n.map((c, i) => i === 0 ? c : cloneRead(c, p, base))
3078
+ }
3079
+
3080
+ function processBlock(blockNode, parent, idx) {
3081
+ if (!Array.isArray(blockNode) || blockNode[0] !== 'block') return
3082
+ let loopNode = null, blockLabel = null
3083
+ const preamble = []
3084
+ for (let i = 1; i < blockNode.length; i++) {
3085
+ const c = blockNode[i]
3086
+ if (i === 1 && typeof c === 'string' && c.startsWith('$')) { blockLabel = c; continue }
3087
+ if (Array.isArray(c) && c[0] === 'loop') { if (loopNode) return; loopNode = c }
3088
+ else if (Array.isArray(c) && c[0] === 'local.set' && !loopNode) preamble.push(c)
3089
+ else if (Array.isArray(c)) return
3090
+ }
3091
+ if (!loopNode || !blockLabel) return
3092
+ const loopLabel = typeof loopNode[1] === 'string' && loopNode[1].startsWith('$') ? loopNode[1] : null
3093
+ if (!loopLabel) return
3094
+ const endIdx = loopNode.length - 1
3095
+ if (!(Array.isArray(loopNode[endIdx]) && loopNode[endIdx][0] === 'br' && loopNode[endIdx][1] === loopLabel)) return
3096
+ const incNode = loopNode[endIdx - 1]
3097
+ if (!Array.isArray(incNode) || incNode[0] !== 'local.set' || !Array.isArray(incNode[2]) || incNode[2][0] !== 'i32.add') return
3098
+ const incVar = incNode[1], inc = incNode[2]
3099
+ if (!(Array.isArray(inc[1]) && inc[1][0] === 'local.get' && inc[1][1] === incVar && Array.isArray(inc[2]) && inc[2][0] === 'i32.const' && inc[2][1] === 1)) return
3100
+ const body = loopNode.slice(3, endIdx - 1)
3101
+ if (body.length < 4) return
3102
+
3103
+ // Find the polymorphic-store `if` by scanning (it's followed by a `drop` of its
3104
+ // f64 result; in the IR the two are separate statements, not (drop (if …))).
3105
+ let storeIdx = -1, paramName = null, elseStore = null
3106
+ for (let i = 0; i < body.length; i++) {
3107
+ const c = body[i]
3108
+ if (!Array.isArray(c) || c[0] !== 'if' || !Array.isArray(c[1]) || c[1][0] !== 'result' || c[1][1] !== 'f64') continue
3109
+ let thenArm = null, elseArm = null
3110
+ for (let k = 2; k < c.length; k++) { const a = c[k]; if (Array.isArray(a)) { if (a[0] === 'then') thenArm = a; else if (a[0] === 'else') elseArm = a } }
3111
+ if (!thenArm || !elseArm) continue
3112
+ let p = null
3113
+ for (let k = 1; k < thenArm.length; k++) { const a = thenArm[k]; if (Array.isArray(a) && a[0] === 'local.set' && f64Params.has(a[1]) && Array.isArray(a[2]) && a[2][0] === 'local.get') p = a[1] }
3114
+ if (!p || !has(thenArm, (x) => x[0] === 'call' && x[1] === '$__arr_set_idx_ptr')) continue
3115
+ // The bare `f64.store(__ptr_offset(o)+i<<3)` is the non-ARRAY fallback. It may be
3116
+ // nested under an OBJECT/HASH → __dyn_set guard (emitPolymorphicElementStore's
3117
+ // dyn-prop safety fork) — descend to find it; the fast path replaces the whole
3118
+ // store with a direct f64.load/store anyway (a proven Float64Array is never an
3119
+ // OBJECT, so its dyn arm is dead there). Still bail on the 3-way __typed_set_idx
3120
+ // form (mixed element widths — the f64.store fallback isn't the sole non-ARRAY case).
3121
+ const findRawStore = (n) => {
3122
+ if (!Array.isArray(n)) return null
3123
+ if (n[0] === 'f64.store' && Array.isArray(n[1]) && n[1][0] === 'i32.add' &&
3124
+ Array.isArray(n[1][2]) && n[1][2][0] === 'i32.shl' &&
3125
+ has(n[1], (x) => x[0] === 'call' && x[1] === '$__ptr_offset')) return n
3126
+ for (let k = 1; k < n.length; k++) { const r = findRawStore(n[k]); if (r) return r }
3127
+ return null
3128
+ }
3129
+ if (has(elseArm, (x) => x[0] === 'call' && x[1] === '$__typed_set_idx')) continue
3130
+ const es = findRawStore(elseArm)
3131
+ if (!es) continue
3132
+ storeIdx = i; paramName = p; elseStore = es; break
3133
+ }
3134
+ if (storeIdx < 0) return
3135
+ const shiftIdx = elseStore[1][2][1] // the index from the store's (i32.shl IDX 3)
3136
+ // The read uses the IV directly; the store uses a snapshot `$asi = $iv`. Emit the
3137
+ // store against the IV too so the vectorizer unifies the load/store lanes — bit-exact
3138
+ // ($asi == $iv). Bail if the store index isn't the IV or a snapshot of it.
3139
+ let storeIdxName = null
3140
+ if (Array.isArray(shiftIdx) && shiftIdx[0] === 'local.get') storeIdxName = shiftIdx[1]
3141
+ if (storeIdxName !== incVar &&
3142
+ !body.some((st) => Array.isArray(st) && st[0] === 'local.set' && st[1] === storeIdxName && Array.isArray(st[2]) && st[2][0] === 'local.get' && st[2][1] === incVar)) return
3143
+ // The store-if pushes f64; a following `drop` (bare string in stack-style IR, or a
3144
+ // `['drop', …]` node) pops it. The fast store pushes nothing, so the drop must go too.
3145
+ const isDrop = (s) => s === 'drop' || (Array.isArray(s) && s[0] === 'drop')
3146
+ const hasDrop = storeIdx + 1 < body.length && isDrop(body[storeIdx + 1])
3147
+
3148
+ // The stored value is the else-store's operand (a local the body computed); take it
3149
+ // from the store itself, not by guessing which local reads buf — the read may be
3150
+ // nested in a split computation (`$t = buf[i]; $v = $t*2`) whose result is a DIFFERENT
3151
+ // local. cloneRead rewrites any buf reads in that computation to f64.load.
3152
+ if (!(Array.isArray(elseStore[2]) && elseStore[2][0] === 'local.get')) return
3153
+ const valName = elseStore[2][1]
3154
+ // GUARD: param reassigned ONLY inside the matched store-if (else the hoisted base goes stale).
3155
+ for (let i = 0; i < body.length; i++) { if (i === storeIdx) continue; if (writes(body[i], paramName)) return }
3156
+ for (const s of preamble) { if (writes(s, paramName)) return }
3157
+
3158
+ const base = `$__utb${baseId++}`
3159
+ newLocals.push(['local', base, 'i32'])
3160
+ const reint = () => ['i64.reinterpret_f64', ['local.get', paramName]]
3161
+ const tag = ['i32.and', ['i32.wrap_i64', ['i64.shr_u', reint(), ['i64.const', LAYOUT.TAG_SHIFT]]], ['i32.const', LAYOUT.TAG_MASK]]
3162
+ const auxOf = () => ['i32.and', ['i32.wrap_i64', ['i64.shr_u', reint(), ['i64.const', LAYOUT.AUX_SHIFT]]], ['i32.const', LAYOUT.AUX_MASK]]
3163
+ const gate = ['i32.and', ['i32.eq', tag, ['i32.const', PTR.TYPED]],
3164
+ ['i32.or', ['i32.eq', auxOf(), ['i32.const', F64]], ['i32.eq', auxOf(), ['i32.const', F64V]]]]
3165
+ const baseSnap = ['local.set', base, ['call', '$__ptr_offset', reint()]]
3166
+ const fastStore = ['f64.store', ['i32.add', ['local.get', base], ['i32.shl', ['local.get', incVar], ['i32.const', 3]]], ['local.get', valName]]
3167
+ // Fast body: keep every statement except the store-if (→ fastStore) and its trailing
3168
+ // drop (the fast store pushes nothing), with the typed-array read collapsed to f64.load.
3169
+ const fastStmts = []
3170
+ for (let i = 0; i < body.length; i++) {
3171
+ if (i === storeIdx) { fastStmts.push(fastStore); continue }
3172
+ if (hasDrop && i === storeIdx + 1) continue
3173
+ fastStmts.push(cloneRead(body[i], paramName, base))
3174
+ }
3175
+ const fastLoop = ['block', blockLabel, ...preamble.map(clone),
3176
+ ['loop', loopLabel, clone(loopNode[2]), ...fastStmts, clone(incNode), clone(loopNode[endIdx])]]
3177
+ parent[idx] = ['if', gate, ['then', baseSnap, fastLoop], ['else', blockNode]]
3178
+ }
3179
+
3180
+ function walk(node, parent, idx) {
3181
+ if (!Array.isArray(node)) return
3182
+ if (node[0] === 'block') {
3183
+ const before = parent[idx]
3184
+ processBlock(node, parent, idx)
3185
+ if (parent[idx] !== before) return
3186
+ }
3187
+ for (let i = 0; i < node.length; i++) walk(node[i], node, i)
3188
+ }
3189
+ for (let i = bodyStart; i < fn.length; i++) walk(fn[i], fn, i)
3190
+ if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
3191
+ }
3192
+
2644
3193
  /**
2645
3194
  * Run all per-function IR optimizations on a single function node.
2646
3195
  * hoistPtrType runs first — it introduces new locals (`$__ptN`) that the fused
@@ -2670,6 +3219,11 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
2670
3219
  cfg.promoteGlobals === false &&
2671
3220
  cfg.sortLocalsByUse === false &&
2672
3221
  cfg.vectorizeLaneLocal === false) return
3222
+ // Recursion-unrolling runs first in 'pre': self-calls are still clean `call`
3223
+ // nodes (watr's inliner hasn't reshaped them) and the freshly-inlined body then
3224
+ // rides every pass below (LICM, fold, sort). Speed-tier only; 'pre' only (so the
3225
+ // post-watr re-optimize doesn't unroll a second time).
3226
+ if (cfg && cfg.recursionUnroll === true && phase === 'pre') recursionUnroll(fn)
2673
3227
  if (!cfg || cfg.hoistPtrType !== false) hoistPtrType(fn)
2674
3228
  if (!cfg || cfg.hoistInvariantPtrOffset !== false) hoistInvariantPtrOffset(fn)
2675
3229
  // Before LICM: the snapped i32 bound is itself a hoistable hard-op subtree, so
@@ -2681,6 +3235,7 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
2681
3235
  if (!cfg || cfg.hoistInvariantLoop !== false) hoistInvariantLoop(fn)
2682
3236
  const counts = new Map()
2683
3237
  if (!cfg || cfg.fusedRewrite !== false) fusedRewrite(fn, counts)
3238
+ if (cfg && cfg.boolConvertToSelect === true) boolConvertToSelect(fn)
2684
3239
  if (!cfg || cfg.hoistAddrBase !== false) hoistAddrBase(fn)
2685
3240
  if (!cfg || cfg.hoistInvariantLoop !== false) hoistInvariantLoop(fn)
2686
3241
  if (!cfg || cfg.cseScalarLoad !== false) cseScalarLoad(fn)
@@ -2702,9 +3257,45 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
2702
3257
  // Phase 1: fold dead string-dispatch blocks on proven-f64 locals BEFORE
2703
3258
  // the vectorizer pattern-matches — dead __is_str_key calls in $fbm-style
2704
3259
  // functions (param f64 + op f64) block liftPPC from recognizing them as pure.
3260
+ if (runVectorizer && (!cfg || cfg.unswitchTypedParamLoop !== false)) unswitchTypedParamLoop(fn)
2705
3261
  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)
3262
+ if (runVectorizer) vectorizeLaneLocal(fn, {
3263
+ multiAcc: cfg.reduceUnroll === true,
3264
+ relaxedFma: cfg.relaxedSimd === true,
3265
+ blurMP: cfg.blurMultiPixel !== false,
3266
+ whyNot: cfg.whyNotSimd === true,
3267
+ stencil: cfg.experimentalStencil !== false,
3268
+ outerStrip: cfg.experimentalOuterStrip !== false,
3269
+ pureFuncMap: cfg._pureFuncMap || null,
3270
+ toneMap: cfg.experimentalToneMap !== false,
3271
+ slp: cfg.experimentalSlp !== false, // SLP default-on (testing single-use fix)
3272
+ })
3273
+ // The vectorizer emits `v128.load/store (i32.add base K)` for the unrolled
3274
+ // multi-accumulator reduction (a[i],a[i+2],a[i+4]…) and stencil/strided reads.
3275
+ // fusedRewrite's memarg fold already ran (above, before vectorize), so fold the
3276
+ // freshly-created v128 memargs now — one fewer i32.add per accumulator per
3277
+ // iteration, the hot-loop waste audit-fixpoint.mjs flagged on dot/sum.
3278
+ if (runVectorizer) foldV128Memargs(fn)
3279
+ }
3280
+ // SSA-split loop-private unrolled scratch (post-vectorize: vectorized loops now carry
3281
+ // v128 and are skipped) so the LICM below hoists the per-iteration invariants the
3282
+ // unroller's name-merging hid — rust/LLVM's free-after-unroll register hoist (closes
3283
+ // the raytrace per-sphere `c_i` recompute). Bit-exact; re-run LICM to lift the splits.
3284
+ if (phase === 'post' && (!cfg || cfg.hoistInvariantLoop !== false)) {
3285
+ splitLoopPrivateScratch(fn)
3286
+ // Iterate LICM for the dependency cascade: c = ox²+… is invariant only once ox is
3287
+ // itself hoisted out, which the single-pass hoister can't see in one go. 4 climbs
3288
+ // covers the deepest scratch chain; idempotent, so it self-terminates.
3289
+ for (let k = 0; k < 4; k++) hoistInvariantLoop(fn)
2707
3290
  }
3291
+ // Loop rotation — the LAST shape pass (post-watr only, so no later pass reverts
3292
+ // it and the v128 loops are already formed for the skip-guard). Speed-tier: it
3293
+ // duplicates the loop condition for a fused conditional back-edge (1.35× on the
3294
+ // lz/qoi scalar scans — see rotateLoops).
3295
+ if (cfg && cfg.rotateLoops === true && phase === 'post') rotateLoops(fn)
3296
+ // Canonicalize boolean conditions (strip redundant `!= 0` / double-`eqz`) — after
3297
+ // rotateLoops so its fused back-edges get cleaned too. Tied to the peephole pass.
3298
+ if (!cfg || cfg.fusedRewrite !== false) simplifyBoolContexts(fn)
2708
3299
  if (!cfg || cfg.sortLocalsByUse !== false) sortLocalsByUse(fn, cfg && cfg.fusedRewrite !== false ? counts : null)
2709
3300
  // An optimizer pass that emits a malformed local — the class that otherwise dies
2710
3301
  // as an opaque watr "Duplicate/Unknown local $x" several phases on — is caught
@@ -2712,6 +3303,180 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
2712
3303
  if (DBG_IR) { const bad = verifyFn(fn); if (bad) throw new Error(`[ir verify] optimize produced invalid IR in ${fn[1]}: ${bad}`) }
2713
3304
  }
2714
3305
 
3306
+ // Fold `(v128.load/store (i32.add base K) …)` → `(… offset=K base …)`. Same logic as
3307
+ // walkRewrite's scalar foldMemargOffsets (MEMOP path), but for the v128 loads/stores the
3308
+ // lane vectorizer creates AFTER fusedRewrite has already run — so they'd otherwise keep a
3309
+ // per-iteration i32.add. Bottom-up, in place; an addr already in offset=/align= form is left.
3310
+ function foldV128Memargs(node) {
3311
+ if (!Array.isArray(node)) return
3312
+ const op = node[0]
3313
+ if (op === 'v128.load' || op === 'v128.store') {
3314
+ const m1 = node[1]
3315
+ if (!(typeof m1 === 'string' && (m1.startsWith('offset=') || m1.startsWith('align='))) &&
3316
+ Array.isArray(m1) && m1[0] === 'i32.add' && m1.length === 3) {
3317
+ const a = m1[1], b = m1[2]
3318
+ let base, offset
3319
+ if (Array.isArray(b) && b[0] === 'i32.const' && typeof b[1] === 'number' && b[1] >= 0 && b[1] < 0x100000000) { base = a; offset = b[1] }
3320
+ else if (Array.isArray(a) && a[0] === 'i32.const' && typeof a[1] === 'number' && a[1] >= 0 && a[1] < 0x100000000) { base = b; offset = a[1] }
3321
+ if (base != null) { node[1] = `offset=${offset}`; node.splice(2, 0, base) }
3322
+ }
3323
+ }
3324
+ for (let i = 1; i < node.length; i++) foldV128Memargs(node[i])
3325
+ }
3326
+
3327
+ // i32 comparison/eqz negations — used to flip a break-condition into the
3328
+ // loop-continue condition. f64 compares are deliberately ABSENT: ¬(a<b) ≠ (a≥b)
3329
+ // across NaN, so those fall through to the `i32.eqz` wrap below.
3330
+ const ROT_NEG = {
3331
+ 'i32.eqz': null, // sentinel: strip the eqz (handled specially)
3332
+ 'i32.eq': 'i32.ne', 'i32.ne': 'i32.eq',
3333
+ 'i32.lt_s': 'i32.ge_s', 'i32.ge_s': 'i32.lt_s', 'i32.gt_s': 'i32.le_s', 'i32.le_s': 'i32.gt_s',
3334
+ 'i32.lt_u': 'i32.ge_u', 'i32.ge_u': 'i32.lt_u', 'i32.gt_u': 'i32.le_u', 'i32.le_u': 'i32.gt_u',
3335
+ }
3336
+
3337
+ // Boolean-context canonicalization. At a true zero/nonzero position — a `br_if`,
3338
+ // `if`, `i32.eqz`, or `select` CONDITION — these are all equivalent to the inner
3339
+ // value: `i32.ne(X, 0) → X`, `i32.ne(0, X) → X`, `i32.eqz(i32.eqz(X)) → X`. jz
3340
+ // emits the redundant compare from `while (x !== 0)` lowering and from rotateLoops'
3341
+ // `negate` (which strips one `eqz` but leaves the `i32.ne`). V8 happens to fold it,
3342
+ // but JSC/wasmtime needn't — so strip it for MINIMAL output regardless of engine.
3343
+ // Only applied at proven boolean positions (never on a value-position `ne`/`eqz`,
3344
+ // which produce a real 0/1).
3345
+ const boolSimp = (n) => {
3346
+ for (;;) {
3347
+ if (!Array.isArray(n)) return n
3348
+ if (n[0] === 'i32.ne' && n.length === 3) {
3349
+ if (Array.isArray(n[2]) && n[2][0] === 'i32.const' && n[2][1] === 0) { n = n[1]; continue }
3350
+ if (Array.isArray(n[1]) && n[1][0] === 'i32.const' && n[1][1] === 0) { n = n[2]; continue }
3351
+ }
3352
+ if (n[0] === 'i32.eqz' && Array.isArray(n[1]) && n[1][0] === 'i32.eqz' && n[1].length === 2) { n = n[1][1]; continue }
3353
+ return n
3354
+ }
3355
+ }
3356
+ function simplifyBoolContexts(fn) {
3357
+ const walk = (node) => {
3358
+ if (!Array.isArray(node)) return
3359
+ for (let i = 1; i < node.length; i++) walk(node[i])
3360
+ const op = node[0]
3361
+ if (op === 'br_if' && node.length === 3) node[2] = boolSimp(node[2])
3362
+ else if (op === 'i32.eqz' && node.length === 2) node[1] = boolSimp(node[1])
3363
+ else if (op === 'if') { const ci = (Array.isArray(node[1]) && node[1][0] === 'result') ? 2 : 1; if (Array.isArray(node[ci])) node[ci] = boolSimp(node[ci]) }
3364
+ else if (op === 'select' && node.length === 4 && Array.isArray(node[3])) node[3] = boolSimp(node[3])
3365
+ }
3366
+ const bodyStart = findBodyStart(fn)
3367
+ for (let i = bodyStart; i < fn.length; i++) walk(fn[i])
3368
+ }
3369
+
3370
+ /**
3371
+ * Loop rotation (loop inversion). Convert jz's top-test loop idiom
3372
+ * (block $brk (loop $loop (br_if $brk ¬C) BODY… (br $loop)))
3373
+ * into a guarded bottom-test loop with a FUSED conditional back-edge:
3374
+ * (block $brk (br_if $brk ¬C) (loop $loop BODY… (br_if $loop C)))
3375
+ *
3376
+ * V8/TurboFan lowers the fused `br_if $loop C` to one hardware loop branch — the
3377
+ * shape LLVM gives rust/zig, and the reason their hot scalar loops (lz's greedy
3378
+ * match-scan, qoi's run-length scan) beat jz's top-test form, which compiles to a
3379
+ * forward exit-branch PLUS a separate unconditional back-jump. Measured 1.35× on
3380
+ * the lz inner loop; nothing else jz runs reaches this shape — watr's `loopify`
3381
+ * collapses to `loop { if C { …; br } }`, whose back-jump stays UNfused (no win).
3382
+ *
3383
+ * Evaluation count of C is unchanged: guard-once + one back-edge per iteration ==
3384
+ * the top-test form's once-per-loop-top — so it's sound even when C has side
3385
+ * effects (a `local.tee` recurrence, a call). The condition is duplicated only in
3386
+ * the EMITTED text (guard + back-edge), a small size-for-speed trade — speed-tier.
3387
+ *
3388
+ * Conservative skips:
3389
+ * - any v128/SIMD op in the loop — already register-tight; reshaping risks
3390
+ * disturbing the lane structure (mirrors hoistInvariantLoop's hasV128 guard).
3391
+ * - a body that branches to $loop: a `continue` with no step lands on the loop
3392
+ * label, which after rotation sits BEFORE the back-edge test — rotating would
3393
+ * skip it. (jz wraps continue-with-step in a `$cont` block → targets that, not
3394
+ * $loop → still rotatable.)
3395
+ */
3396
+ function rotateLoops(fn) {
3397
+ if (!Array.isArray(fn) || fn[0] !== 'func') return
3398
+ const bodyStart = findBodyStart(fn)
3399
+ if (bodyStart < 0) return
3400
+
3401
+ const clone = (n) => Array.isArray(n) ? n.map(clone) : n
3402
+ // Break-condition C → loop-continue condition ¬C for the back-edge. Fold the
3403
+ // i32 forms so the back-edge stays ONE fused compare-and-branch (a wrapping
3404
+ // `i32.eqz` would add an op inside the hot loop); everything else wraps.
3405
+ const negate = (c) => {
3406
+ if (Array.isArray(c) && c[0] === 'i32.eqz' && c.length === 2) return c[1]
3407
+ if (Array.isArray(c) && c.length === 3 && ROT_NEG[c[0]]) return [ROT_NEG[c[0]], c[1], c[2]]
3408
+ return ['i32.eqz', c]
3409
+ }
3410
+ const targetsLabel = (n, label) => {
3411
+ if (!Array.isArray(n)) return false
3412
+ const op = n[0]
3413
+ if (op === 'br' || op === 'br_if') { if (n[1] === label) return true }
3414
+ else if (op === 'br_table') { for (let i = 1; i < n.length; i++) if (n[i] === label) return true }
3415
+ for (let i = 1; i < n.length; i++) if (targetsLabel(n[i], label)) return true
3416
+ return false
3417
+ }
3418
+ const hasV128 = (n) => Array.isArray(n) && (
3419
+ (typeof n[0] === 'string' && (n[0].startsWith('v128.') || /^[if]\d+x\d+\./.test(n[0]))) ||
3420
+ n.some((c, i) => i > 0 && hasV128(c)))
3421
+
3422
+ const tryRotate = (blk) => {
3423
+ let bi = 1, blockLabel = null
3424
+ if (typeof blk[1] === 'string' && blk[1][0] === '$') { blockLabel = blk[1]; bi = 2 }
3425
+ if (!blockLabel) return null
3426
+ // The loop must be the block's final child; LICM may hoist invariant snaps into
3427
+ // a `local.set` pre-header before it — keep those ahead of the guard (the guard
3428
+ // condition can read them). Bail on anything else (typed blocks, side computations).
3429
+ const preamble = []
3430
+ let loop = null
3431
+ for (let i = bi; i < blk.length; i++) {
3432
+ const c = blk[i]
3433
+ if (Array.isArray(c) && c[0] === 'loop') { if (loop || i !== blk.length - 1) return null; loop = c }
3434
+ else if (Array.isArray(c) && c[0] === 'local.set' && !loop) preamble.push(c)
3435
+ else return null
3436
+ }
3437
+ if (!loop) return null
3438
+ let li = 1, loopLabel = null
3439
+ if (typeof loop[1] === 'string' && loop[1][0] === '$') { loopLabel = loop[1]; li = 2 }
3440
+ if (!loopLabel) return null
3441
+ const loopHeader = []
3442
+ while (li < loop.length) {
3443
+ const c = loop[li]
3444
+ if (Array.isArray(c) && c[0] === 'type') { loopHeader.push(c); li++; continue }
3445
+ if (Array.isArray(c) && (c[0] === 'param' || c[0] === 'result')) return null
3446
+ break
3447
+ }
3448
+ const body = loop.slice(li)
3449
+ if (body.length < 2) return null
3450
+ const head = body[0], tail = body[body.length - 1]
3451
+ if (!(Array.isArray(head) && head[0] === 'br_if' && head[1] === blockLabel && head.length === 3)) return null
3452
+ if (!(Array.isArray(tail) && tail[0] === 'br' && tail[1] === loopLabel && tail.length === 2)) return null
3453
+ const inner = body.slice(1, -1)
3454
+ if (inner.some((s) => targetsLabel(s, loopLabel))) return null // continue → loop top: unsafe
3455
+ if (hasV128(head) || inner.some(hasV128)) return null // vectorized: leave tight
3456
+ const cond = head[2]
3457
+ return ['block', blockLabel, ...preamble,
3458
+ ['br_if', blockLabel, clone(cond)],
3459
+ ['loop', loopLabel, ...loopHeader, ...inner, ['br_if', loopLabel, negate(cond)]]]
3460
+ }
3461
+
3462
+ // Rotate a (block …) at container[i] in place, else descend. Returns true if it fired.
3463
+ const tryAt = (container, i) => {
3464
+ const c = container[i]
3465
+ if (!Array.isArray(c) || c[0] !== 'block') return false
3466
+ const rot = tryRotate(c)
3467
+ if (!rot) return false
3468
+ container[i] = rot
3469
+ walk(rot)
3470
+ return true
3471
+ }
3472
+ const walk = (node) => {
3473
+ if (!Array.isArray(node)) return
3474
+ for (let i = 0; i < node.length; i++) if (!tryAt(node, i)) walk(node[i])
3475
+ }
3476
+ // Top-level statements (a loop block can BE fn[i], not just nested under one).
3477
+ for (let i = bodyStart; i < fn.length; i++) if (!tryAt(fn, i)) walk(fn[i])
3478
+ }
3479
+
2715
3480
  // The i32 form of an integer-valued f64 expression, or null. Used to push ToInt32
2716
3481
  // through a conditional and to collapse the f64 round-trip on integer `+`/`-`.
2717
3482
  // Lossless by construction: `convert_i32(X) → X`; integer `f64.const → i32.const`
@@ -2732,6 +3497,17 @@ function toI32(n) {
2732
3497
  const a = toI32(n[1]), b = toI32(n[2])
2733
3498
  if (a && b) return [op === 'f64.add' ? 'i32.add' : 'i32.sub', a, b]
2734
3499
  }
3500
+ // ToInt32 distributes through a conditional: ToInt32(if C A B) == if(result i32) C
3501
+ // ToInt32(A) ToInt32(B). Recursive — a nested integer `?:` like `((3<a)?(2&a):((7<a)?a:1))|0`
3502
+ // narrows whole to i32 (each arm folded by toI32, incl. nested ifs), so the lane vectorizer
3503
+ // lifts it as i32x4 bitselect instead of bailing on the f64 result. Only reached from
3504
+ // ToInt32 sinks (the select idiom / toI32 recursion), so the i32 result is always wanted.
3505
+ if (op === 'if' && Array.isArray(n[1]) && n[1][0] === 'result' && n[1][1] === 'f64'
3506
+ && Array.isArray(n[3]) && n[3][0] === 'then' && n[3].length === 2
3507
+ && Array.isArray(n[4]) && n[4][0] === 'else' && n[4].length === 2) {
3508
+ const t = toI32(n[3][1]), e = toI32(n[4][1])
3509
+ if (t && e) return ['if', ['result', 'i32'], n[2], ['then', t], ['else', e]]
3510
+ }
2735
3511
  return null
2736
3512
  }
2737
3513
 
@@ -2771,18 +3547,38 @@ function fusedRewrite(fn, counts) {
2771
3547
  }
2772
3548
  const freshI64 = () => { const n = `$__eqt${scratchN++}`; newDecls.push(['local', n, 'i64']); return n }
2773
3549
  const freshF64 = () => { const n = `$__eqf${scratchN++}`; newDecls.push(['local', n, 'f64']); return n }
3550
+ // Single-textual-def locals → their defining value node, so the trunc_sat range fold (below)
3551
+ // can see through the temps inlining introduces when proving an index/packed value fits i32.
3552
+ // Multi-def (incl. loop-carried self-referential) locals are excluded: their value is not the
3553
+ // one def's, so its range wouldn't bound them. Pure read of the IR — value-preserving rewrites
3554
+ // during this same walk keep the captured def's RANGE intact, so a lazily-built map stays sound.
3555
+ // Built on first query only (most functions carry no guarded-trunc form → zero cost).
3556
+ let defVal
3557
+ const get = (name) => {
3558
+ if (defVal === undefined) {
3559
+ defVal = new Map(); const defCnt = new Map()
3560
+ const scanDefs = (n) => {
3561
+ if (!Array.isArray(n)) return
3562
+ if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') { defCnt.set(n[1], (defCnt.get(n[1]) || 0) + 1); defVal.set(n[1], n[2]) }
3563
+ for (let i = 1; i < n.length; i++) scanDefs(n[i])
3564
+ }
3565
+ for (let i = bodyStart; i < fn.length; i++) scanDefs(fn[i])
3566
+ for (const [k, c] of defCnt) if (c > 1) defVal.delete(k)
3567
+ }
3568
+ return defVal.get(name) || null
3569
+ }
2774
3570
  for (let i = bodyStart; i < fn.length; i++) {
2775
3571
  const c = fn[i]
2776
- if (Array.isArray(c)) fn[i] = walkRewrite(c, !skipInline, counts, freshI64, freshF64)
3572
+ if (Array.isArray(c)) fn[i] = walkRewrite(c, !skipInline, counts, freshI64, freshF64, get)
2777
3573
  }
2778
3574
  if (newDecls.length) fn.splice(bodyStart, 0, ...newDecls)
2779
3575
  }
2780
3576
 
2781
- function walkRewrite(node, doInline, counts, freshI64, freshF64) {
3577
+ function walkRewrite(node, doInline, counts, freshI64, freshF64, get) {
2782
3578
  if (!Array.isArray(node)) return node
2783
3579
  for (let i = 0; i < node.length; i++) {
2784
3580
  const c = node[i]
2785
- if (Array.isArray(c)) node[i] = walkRewrite(c, doInline, counts, freshI64, freshF64)
3581
+ if (Array.isArray(c)) node[i] = walkRewrite(c, doInline, counts, freshI64, freshF64, get)
2786
3582
  }
2787
3583
  const op = node[0]
2788
3584
  // Piggyback local-ref counting for sortLocalsByUse. `counts` may be undefined
@@ -2963,16 +3759,20 @@ function walkRewrite(node, doInline, counts, freshI64, freshF64) {
2963
3759
  if (Array.isArray(v) && v[0] === 'i32.wrap_i64' && Array.isArray(v[1]) && v[1][0] === 'i64.trunc_sat_f64_s' && v[1].length === 2) {
2964
3760
  let inner = v[1][1]
2965
3761
  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).
3762
+ // ToInt32(integer-valued f64 expr) → its i32 form: covers (i32±i32)|0 sums AND the
3763
+ // conditional `?:` (toI32 distributes through `(if result f64)`, recursively).
2974
3764
  const i = toI32(inner)
2975
3765
  if (i) return i
3766
+ // Range fallback for the NON-integer-ring values toI32 rejects (`floor(scale·v)`,
3767
+ // `base + scale·v` — every grid/lattice/colour index): when the def chain — resolved
3768
+ // through single-def inlining temps via `get` — provably yields a finite i32-range value,
3769
+ // the +∞ guard is dead AND trunc_sat can't saturate, so the whole guarded select collapses
3770
+ // to one `i32.trunc_sat_f64_s`. SOUND: f64Range admits only pure nodes and proves
3771
+ // finiteness (kills the guard) + in-range (kills saturation), so the result is identical
3772
+ // ToInt32 on every value the program can produce. Drops the i64 round-trip + guard on all
3773
+ // runtimes (this is the post-inline twin of the emit-time fold at ir.js toI32).
3774
+ const rng = f64Range(inner, get)
3775
+ if (rng && rng.lo >= I32_MIN && rng.hi <= I32_MAX) return ['i32.trunc_sat_f64_s', inner]
2976
3776
  }
2977
3777
  }
2978
3778
  // (i32.or X 0) / (i32.or 0 X) → X — drops the redundant source-level `|0` clamp left
@@ -2983,6 +3783,30 @@ function walkRewrite(node, doInline, counts, freshI64, freshF64) {
2983
3783
  if (Array.isArray(a) && a[0] === 'i32.const' && a[1] === 0) return b
2984
3784
  }
2985
3785
 
3786
+ // if→select for a value-producing f64 `if` with PURE arms: (if (result f64) COND (then A)
3787
+ // (else B)) → (select A B COND). This is the branchless `cmov` lowering LLVM/clang apply to
3788
+ // every `cond ? a : b` — it removes the conditional branch (and its misprediction cost on
3789
+ // data-unpredictable conditions) on the whole class of float sign/clamp/reflect ternaries.
3790
+ // The flagship: noise's gradient `(h & 1) === 0 ? x : -x` (8 per perlin × 5 octaves × 65k px).
3791
+ // SOUND: wasm `select` evaluates BOTH arms unconditionally, and `isPureIR` admits only
3792
+ // side-effect-free, non-trapping ops (no load/call/div/rem) — so eager evaluation is safe; it
3793
+ // is the exact predicate emit.js uses for the same fold at emit time, now applied post-watr
3794
+ // where the arms (e.g. `f64.neg (local.get $x)`) are clean after canon-DCE. Gated to NOT fire
3795
+ // when BOTH arms are i32-narrowable — those stay an `if` for the ToInt32-through-if fold +
3796
+ // the i32x4-bitselect conditional-map vectorizer (don't steal the integer path).
3797
+ if (op === 'if' && node.length === 5 && Array.isArray(node[1]) && node[1][0] === 'result' && node[1][1] === 'f64'
3798
+ && Array.isArray(node[3]) && node[3][0] === 'then' && node[3].length === 2
3799
+ && Array.isArray(node[4]) && node[4][0] === 'else' && node[4].length === 2) {
3800
+ const a = node[3][1], b = node[4][1], cond = node[2]
3801
+ // The COND must also be pure: `if` evaluates cond FIRST then one arm, but wasm `select`
3802
+ // evaluates its arms BEFORE the cond. A short-circuit lowering like `a || b` =
3803
+ // `(if (result f64) is_truthy(local.tee $t a) (then get $t)(else b))` hides a `tee` in the
3804
+ // cond that the then-arm reads — reordering it after the arms reads $t stale. Requiring
3805
+ // isPureIR(cond) excludes every tee/call/short-circuit cond while admitting the pure
3806
+ // comparison conds of real float ternaries (noise's `(h & 1) === 0`).
3807
+ if (isPureIR(a) && isPureIR(b) && isPureIR(cond) && !(toI32(a) && toI32(b))) return ['select', a, b, cond]
3808
+ }
3809
+
2986
3810
  // f64.CMP(convert_i32 A, convert_i32 B) → i32.CMP(A, B). Comparing two i32 values is
2987
3811
  // identical whether done in exact f64 or in i32 (the converts are lossless and
2988
3812
  // order-preserving), so an integer comparison over typed-array loads (reads are f64)