jz 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +288 -314
  2. package/bench/README.md +319 -0
  3. package/bench/bench.svg +112 -0
  4. package/cli.js +32 -24
  5. package/index.js +177 -55
  6. package/interop.js +88 -159
  7. package/jz.svg +5 -0
  8. package/jzify/arguments.js +97 -0
  9. package/jzify/bundler.js +382 -0
  10. package/jzify/classes.js +328 -0
  11. package/jzify/hoist-vars.js +177 -0
  12. package/jzify/index.js +51 -0
  13. package/jzify/names.js +37 -0
  14. package/jzify/switch.js +106 -0
  15. package/jzify/transform.js +349 -0
  16. package/layout.js +179 -0
  17. package/module/array.js +322 -153
  18. package/module/collection.js +603 -145
  19. package/module/console.js +55 -43
  20. package/module/core.js +266 -153
  21. package/module/date.js +15 -3
  22. package/module/function.js +73 -6
  23. package/module/index.js +2 -1
  24. package/module/json.js +226 -61
  25. package/module/math.js +414 -186
  26. package/module/number.js +306 -60
  27. package/module/object.js +448 -184
  28. package/module/regex.js +255 -25
  29. package/module/schema.js +24 -6
  30. package/module/simd.js +85 -0
  31. package/module/string.js +586 -220
  32. package/module/symbol.js +1 -1
  33. package/module/timer.js +9 -14
  34. package/module/typedarray.js +45 -48
  35. package/package.json +41 -12
  36. package/src/abi/index.js +39 -23
  37. package/src/abi/string.js +38 -41
  38. package/src/ast.js +460 -0
  39. package/src/autoload.js +26 -24
  40. package/src/bridge.js +111 -0
  41. package/src/compile/analyze-scans.js +661 -0
  42. package/src/compile/analyze.js +1565 -0
  43. package/src/compile/emit-assign.js +408 -0
  44. package/src/compile/emit.js +3201 -0
  45. package/src/compile/flow-types.js +103 -0
  46. package/src/{compile.js → compile/index.js} +497 -125
  47. package/src/{infer.js → compile/infer.js} +27 -98
  48. package/src/{narrow.js → compile/narrow.js} +302 -96
  49. package/src/compile/plan/advise.js +316 -0
  50. package/src/compile/plan/common.js +150 -0
  51. package/src/compile/plan/index.js +118 -0
  52. package/src/compile/plan/inline.js +679 -0
  53. package/src/compile/plan/literals.js +984 -0
  54. package/src/compile/plan/loops.js +472 -0
  55. package/src/compile/plan/scope.js +573 -0
  56. package/src/compile/program-facts.js +404 -0
  57. package/src/ctx.js +176 -58
  58. package/src/ir.js +540 -171
  59. package/src/kind-traits.js +105 -0
  60. package/src/kind.js +462 -0
  61. package/src/op-policy.js +57 -0
  62. package/src/{optimize.js → optimize/index.js} +1106 -446
  63. package/src/optimize/vectorize.js +1874 -0
  64. package/src/param-reps.js +65 -0
  65. package/src/parse.js +44 -0
  66. package/src/{prepare.js → prepare/index.js} +589 -202
  67. package/src/reps.js +115 -0
  68. package/src/resolve.js +12 -3
  69. package/src/static.js +199 -0
  70. package/src/type.js +647 -0
  71. package/src/{assemble.js → wat/assemble.js} +86 -48
  72. package/src/wat/optimize.js +3760 -0
  73. package/transform.js +21 -0
  74. package/wasi.js +47 -5
  75. package/src/analyze.js +0 -3818
  76. package/src/emit.js +0 -3040
  77. package/src/jzify.js +0 -1580
  78. package/src/plan.js +0 -2132
  79. package/src/vectorize.js +0 -1088
  80. /package/src/{codegen.js → wat/codegen.js} +0 -0
@@ -0,0 +1,1874 @@
1
+ import { findBodyStart } from '../ir.js'
2
+
3
+ /**
4
+ * Lane-local SIMD-128 vectorizer.
5
+ *
6
+ * Recognizes inner loops of shape:
7
+ * for (let i = 0; i < N; i++) arr[i] = f(arr[i], …)
8
+ * where every body op is "lane-pure" — its k-th lane output depends only
9
+ * on k-th lane inputs. Lifts the body to SIMD-128, prefixed before the
10
+ * original (now tail) loop. Original loop runs the remainder.
11
+ *
12
+ * Design:
13
+ * • Lane-purity is a structural property, not a benchmark match. The op
14
+ * whitelist is the single source of truth (one entry per (lane-type, op)).
15
+ * • Lift is mechanical. The recognizer either matches the structure — in
16
+ * which case lifting is unambiguous — or skips. No bench-specific
17
+ * heuristics.
18
+ * • Tail loop is the original WAT, untouched. If anything regresses the
19
+ * SIMD recognizer just doesn't match, never miscompiles.
20
+ *
21
+ * Match conditions:
22
+ * 1. (block $brk (loop $L (br_if $brk !cond) BODY (i = i+1) (br $L)))
23
+ * 2. cond is `(i32.lt_s i BOUND)` or `i32.lt_u`; BOUND is loop-invariant.
24
+ * 3. All loads/stores in BODY use address `(add base (shl i K))` where
25
+ * base is loop-invariant and K matches the elem stride. Optional
26
+ * enclosing `local.tee` is allowed (and reused).
27
+ * 4. All loads share the same opcode → defines lane type.
28
+ * 5. All other ops in BODY are in the lane-pure whitelist for that type.
29
+ * 6. Each non-induction local in BODY is either purely loop-invariant
30
+ * (only read) or purely lane-local (first action is a write). Never
31
+ * both — that's a loop-carried scalar (reduction / stencil) → bail.
32
+ *
33
+ * Lift produces, before the original block:
34
+ * (local.set $__simd_bound{N} (i32.and BOUND (i32.const ~(LANES-1))))
35
+ * (block $__simd_brk{N}
36
+ * (loop $__simd_loop{N}
37
+ * (br_if $__simd_brk{N} (i32.eqz (i32.lt_s i $__simd_bound{N})))
38
+ * <body lifted op-by-op; lane-local locals routed to v128 shadows>
39
+ * (local.set $i (i32.add i (i32.const LANES)))
40
+ * (br $__simd_loop{N})))
41
+ *
42
+ * The original block runs immediately after with i pre-advanced; its own
43
+ * `i < BOUND` guard handles the tail.
44
+ */
45
+
46
+
47
+
48
+ const isArr = n => Array.isArray(n)
49
+
50
+ const exprEq = (a, b) => JSON.stringify(a) === JSON.stringify(b)
51
+ const localGetName = n => isArr(n) && n[0] === 'local.get' && typeof n[1] === 'string' ? n[1] : null
52
+ const f64Zero = n => isArr(n) && n[0] === 'f64.const' && Number(n[1]) === 0
53
+
54
+ // jz wraps every NaN-producing float builtin (Math.sqrt/min/max/…) in a
55
+ // canonicalizing select so a non-canonical NaN never crosses to JS:
56
+ // (select C X (T.ne X X)) — "use C where X is NaN, else X".
57
+ // The condition `X != X` is true iff X is NaN, so this shape is unambiguously
58
+ // the canonicalization idiom. C is the canonical-NaN value, materialized either
59
+ // inline (T.const) or hoisted into a const-pool global (global.get $__fcN) when
60
+ // reused. We splat C verbatim — faithful regardless of what C holds — so the
61
+ // recognizer never needs to resolve the global's value.
62
+ const isSplatConst = (n, constOp) =>
63
+ isArr(n) && (n[0] === constOp || n[0] === 'global.get')
64
+
65
+ // Match `(select C X (T.ne X X))`. Returns { val: X, C } or null.
66
+ function matchCanonSelect(sel, laneType) {
67
+ if (!isArr(sel) || sel[0] !== 'select') return null
68
+ const C = sel[1], val = sel[2], cond = sel[3]
69
+ const neOp = laneType === 'f32' ? 'f32.ne' : 'f64.ne'
70
+ if (!isSplatConst(C, LANE_INFO[laneType].constOp)) return null
71
+ if (!(isArr(cond) && cond[0] === neOp && exprEq(cond[1], val) && exprEq(cond[2], val))) return null
72
+ return { val, C }
73
+ }
74
+
75
+ // Replace every `(local.tee N v)` with `(local.get N)` so a value that tee's its
76
+ // address in one place (the comparison) and reloads it in another (the chosen branch)
77
+ // compares structurally equal. Used only for matching — emission keeps the tee.
78
+ function normTee(n) {
79
+ if (!isArr(n)) return n
80
+ if (n[0] === 'local.tee' && n.length === 3) return ['local.get', n[1]]
81
+ return n.map(normTee)
82
+ }
83
+
84
+ // Recognize an integer min/max reduction body. WASM has no scalar i32.min/max, so
85
+ // `m = max(m, a[i])` — written `Math.max(m,a[i])|0` or `a[i]>m?a[i]:m` — lowers, after
86
+ // the ToInt32-through-`?:` fold, to a select-shaped body:
87
+ // (local.set m (if (result i32) COND (then BR_T) (else BR_E))) [or the (select …) form]
88
+ // where {BR_T,BR_E} = {laneLoad, m} and COND is a signed i32 comparison of the two.
89
+ // Returns { exprNode, isMax } — exprNode is the lane expr carrying the address tee (fed
90
+ // to liftExprV); null when not a clean min/max. All four comparison directions × two
91
+ // branch orderings collapse to `isMax` below. gt/ge (and lt/le) are equivalent for the
92
+ // RESULT — equal operands tie to the same value — so only the direction axis matters.
93
+ function matchIntMinMaxReduce(rhs, accName) {
94
+ if (!isArr(rhs)) return null
95
+ let cond, T, E
96
+ if (rhs[0] === 'if') {
97
+ let i = 1
98
+ if (!(isArr(rhs[i]) && rhs[i][0] === 'result' && rhs[i][1] === 'i32')) return null
99
+ i++
100
+ if (rhs.length !== i + 3) return null
101
+ cond = rhs[i]
102
+ const thenB = rhs[i + 1], elseB = rhs[i + 2]
103
+ if (!(isArr(thenB) && thenB[0] === 'then' && thenB.length === 2)) return null
104
+ if (!(isArr(elseB) && elseB[0] === 'else' && elseB.length === 2)) return null
105
+ T = thenB[1]; E = elseB[1]
106
+ } else if (rhs[0] === 'select' && rhs.length === 4) {
107
+ T = rhs[1]; E = rhs[2]; cond = rhs[3] // (select a b c) = a if c else b
108
+ } else return null
109
+ // Which branch is the accumulator, which is the lane EXPR.
110
+ let exprBr, takeExprWhenTrue
111
+ if (isLocalGet(E, accName) && !isLocalGet(T, accName)) { exprBr = T; takeExprWhenTrue = true }
112
+ else if (isLocalGet(T, accName) && !isLocalGet(E, accName)) { exprBr = E; takeExprWhenTrue = false }
113
+ else return null
114
+ // Strip a boolean-normalizing `(i32.ne X 0)` around the comparison (as liftExprV does).
115
+ let cmp = cond
116
+ if (isArr(cmp) && cmp[0] === 'i32.ne' && isI32Const(cmp[2]) && cmp[2][1] === 0) cmp = cmp[1]
117
+ if (!isArr(cmp) || cmp.length !== 3) return null
118
+ const dir = { 'i32.gt_s': 'gt', 'i32.ge_s': 'gt', 'i32.lt_s': 'lt', 'i32.le_s': 'lt' }[cmp[0]]
119
+ if (!dir) return null
120
+ // Comparison operands must be {acc, EXPR}; take the non-acc side as the canonical lane
121
+ // expr (it carries the address tee). exprIsLeftOfCmp records its position.
122
+ let condExpr, exprIsLeftOfCmp
123
+ if (isLocalGet(cmp[2], accName)) { condExpr = cmp[1]; exprIsLeftOfCmp = true }
124
+ else if (isLocalGet(cmp[1], accName)) { condExpr = cmp[2]; exprIsLeftOfCmp = false }
125
+ else return null
126
+ // The compared expr and the chosen branch must be the SAME lane (tee vs reload aside).
127
+ if (!exprEq(normTee(condExpr), normTee(exprBr))) return null
128
+ // cond true ⟺ EXPR > acc ⇒ picking EXPR-when-true is a max; picking-when-false a min.
129
+ const predExprGreater = dir === 'gt' ? exprIsLeftOfCmp : !exprIsLeftOfCmp
130
+ return { exprNode: condExpr, isMax: takeExprWhenTrue === predExprGreater }
131
+ }
132
+
133
+ // Match the un-flattened canon, emitted when a Math.* result feeds another op
134
+ // in expression position:
135
+ // (block (result T) (local.set $t CORE) (select C (local.get $t) (T.ne …)))
136
+ // Returns { core: CORE, C } or null.
137
+ function matchCanonBlock(blk, laneType) {
138
+ if (!isArr(blk) || blk[0] !== 'block') return null
139
+ let i = 1
140
+ if (typeof blk[i] === 'string' && blk[i].startsWith('$')) i++
141
+ if (!(isArr(blk[i]) && blk[i][0] === 'result')) return null
142
+ i++
143
+ if (blk.length - i !== 2) return null
144
+ const setStmt = blk[i]
145
+ if (!isArr(setStmt) || setStmt[0] !== 'local.set' || typeof setStmt[1] !== 'string') return null
146
+ const m = matchCanonSelect(blk[i + 1], laneType)
147
+ if (!m || !isLocalGet(m.val, setStmt[1])) return null
148
+ return { core: setStmt[2], C: m.C }
149
+ }
150
+
151
+ const matchF64MulLocals = n => {
152
+ if (!isArr(n) || n[0] !== 'f64.mul') return null
153
+ const a = localGetName(n[1])
154
+ const b = localGetName(n[2])
155
+ return a && b ? [a, b] : null
156
+ }
157
+
158
+ const matchAccumStep = (n, acc) => {
159
+ if (!isArr(n) || n[0] !== 'local.set' || n[1] !== acc) return null
160
+ const e = n[2]
161
+ if (!isArr(e) || e[0] !== 'f64.add') return null
162
+ if (localGetName(e[1]) === acc) return matchF64MulLocals(e[2])
163
+ if (localGetName(e[2]) === acc) return matchF64MulLocals(e[1])
164
+ return null
165
+ }
166
+
167
+ const matchDotStore = (n, acc) => {
168
+ if (!isArr(n) || n[0] !== 'local.set' || typeof n[1] !== 'string') return null
169
+ const e = n[2]
170
+ if (localGetName(e) === acc) return { out: n[1], addend: null }
171
+ if (!isArr(e) || e[0] !== 'f64.add') return null
172
+ if (localGetName(e[1]) === acc) return { out: n[1], addend: e[2] }
173
+ if (localGetName(e[2]) === acc) return { out: n[1], addend: e[1] }
174
+ return null
175
+ }
176
+
177
+ const matchF64DotSeq = (stmts, i) => {
178
+ const reset = stmts[i]
179
+ if (!isArr(reset) || reset[0] !== 'local.set' || typeof reset[1] !== 'string' || !f64Zero(reset[2])) return null
180
+ const acc = reset[1]
181
+ const left = [], right = []
182
+ for (let k = 0; k < 4; k++) {
183
+ const pair = matchAccumStep(stmts[i + 1 + k], acc)
184
+ if (!pair) return null
185
+ left.push(pair[0])
186
+ right.push(pair[1])
187
+ }
188
+ const store = matchDotStore(stmts[i + 5], acc)
189
+ return store ? { end: i + 6, acc, left, right, ...store } : null
190
+ }
191
+
192
+ const f64x2Pair = (lo, hi) => ['f64x2.replace_lane', 1, ['f64x2.splat', ['local.get', lo]], ['local.get', hi]]
193
+
194
+ const dotPairExpr = (a, pairs) => {
195
+ let expr = ['f64x2.mul', ['f64x2.splat', ['local.get', a[0]]], pairs[0]]
196
+ for (let i = 1; i < 4; i++) {
197
+ expr = ['f64x2.add', expr, ['f64x2.mul', ['f64x2.splat', ['local.get', a[i]]], pairs[i]]]
198
+ }
199
+ return expr
200
+ }
201
+
202
+ const vectorizeStraightLineF64DotPairsIn = (node, fnLocals, freshIdRef, newLocalDecls) => {
203
+ if (!isArr(node)) return
204
+ for (let i = 0; i < node.length; i++) {
205
+ const child = node[i]
206
+ if (isArr(child)) vectorizeStraightLineF64DotPairsIn(child, fnLocals, freshIdRef, newLocalDecls)
207
+ }
208
+ const addendTemps = new Map()
209
+ const pairTemps = new Map()
210
+ for (let i = 0; i < node.length;) {
211
+ const a = matchF64DotSeq(node, i)
212
+ if (!a) { i++; continue }
213
+ const b = matchF64DotSeq(node, a.end)
214
+ if (!b || a.acc !== b.acc || !exprEq(a.left, b.left) || !exprEq(a.addend, b.addend) ||
215
+ fnLocals.get(a.out) !== 'f64' || fnLocals.get(b.out) !== 'f64') {
216
+ i++
217
+ continue
218
+ }
219
+ const v = `$__dot2_${freshIdRef.next++}`
220
+ newLocalDecls.push(['local', v, 'v128'])
221
+ fnLocals.set(v, 'v128')
222
+ let prefix = []
223
+ let addend = a.addend
224
+ if (addend) {
225
+ const key = JSON.stringify(addend)
226
+ let tmp = addendTemps.get(key)
227
+ if (!tmp) {
228
+ tmp = `$__dotadd_${freshIdRef.next++}`
229
+ addendTemps.set(key, tmp)
230
+ newLocalDecls.push(['local', tmp, 'f64'])
231
+ fnLocals.set(tmp, 'f64')
232
+ prefix = [['local.set', tmp, addend]]
233
+ }
234
+ addend = ['local.get', tmp]
235
+ }
236
+ const pairs = []
237
+ for (let k = 0; k < 4; k++) {
238
+ const key = `${a.right[k]}\0${b.right[k]}`
239
+ let tmp = pairTemps.get(key)
240
+ if (!tmp) {
241
+ tmp = `$__dotpair_${freshIdRef.next++}`
242
+ pairTemps.set(key, tmp)
243
+ newLocalDecls.push(['local', tmp, 'v128'])
244
+ fnLocals.set(tmp, 'v128')
245
+ prefix.push(['local.set', tmp, f64x2Pair(a.right[k], b.right[k])])
246
+ }
247
+ pairs.push(['local.get', tmp])
248
+ }
249
+ const dot = dotPairExpr(a.left, pairs)
250
+ const expr = addend ? ['f64x2.add', dot, ['f64x2.splat', addend]] : dot
251
+ node.splice(i, b.end - i,
252
+ ...prefix,
253
+ ['local.set', v, expr],
254
+ ['local.set', a.out, ['f64x2.extract_lane', 0, ['local.get', v]]],
255
+ ['local.set', b.out, ['f64x2.extract_lane', 1, ['local.get', v]]],
256
+ )
257
+ i += prefix.length + 3
258
+ }
259
+ }
260
+
261
+ // ---- Lane type tables ------------------------------------------------------
262
+
263
+ const LANE_INFO = {
264
+ i8: { lanes: 16, strideLog2: 0, stride: 1, splat: 'i8x16.splat', constOp: 'i32.const' },
265
+ i16: { lanes: 8, strideLog2: 1, stride: 2, splat: 'i16x8.splat', constOp: 'i32.const' },
266
+ i32: { lanes: 4, strideLog2: 2, stride: 4, splat: 'i32x4.splat', constOp: 'i32.const' },
267
+ i64: { lanes: 2, strideLog2: 3, stride: 8, splat: 'i64x2.splat', constOp: 'i64.const' },
268
+ f32: { lanes: 4, strideLog2: 2, stride: 4, splat: 'f32x4.splat', constOp: 'f32.const' },
269
+ f64: { lanes: 2, strideLog2: 3, stride: 8, splat: 'f64x2.splat', constOp: 'f64.const' },
270
+ }
271
+
272
+ // Narrow loads/stores (i32.load8_u etc.) define i8 / i16 lane types — values
273
+ // computed in i32 then truncated by store{8,16}, which matches i{8,16}xN wrap
274
+ // semantics exactly.
275
+ const LOAD_OPS = {
276
+ 'i32.load8_u': 'i8', 'i32.load8_s': 'i8',
277
+ 'i32.load16_u': 'i16','i32.load16_s': 'i16',
278
+ 'i32.load': 'i32', 'i64.load': 'i64', 'f32.load': 'f32', 'f64.load': 'f64',
279
+ }
280
+ const STORE_OPS = {
281
+ 'i32.store8': 'i8', 'i32.store16': 'i16',
282
+ 'i32.store': 'i32', 'i64.store': 'i64', 'f32.store': 'f32', 'f64.store': 'f64',
283
+ }
284
+
285
+ // scalar op → SIMD op. shamtScalar:true means second operand stays scalar i32.
286
+ //
287
+ // For i8/i16 lanes the SCALAR ops are i32.* — wasm has no native i8/i16 ops,
288
+ // values flow as i32 and the trailing store{8,16} truncates. i{8,16}x{N}.add
289
+ // wraps within each lane the same way, so the observable result matches.
290
+ // Note: wasm SIMD has no i8x16.mul, so multiplication on byte arrays bails.
291
+ const LANE_PURE = {
292
+ // Right shifts intentionally omitted for narrow lanes: scalar emits
293
+ // i32.shr_{s,u} on a load8/load16 i32 (zero- or sign-extended), while
294
+ // i{8,16}x{N}.shr_{s,u} treats lanes as their narrow type. The two diverge
295
+ // when load and shift signedness mismatch (e.g. load8_u + shr_s on byte
296
+ // 0xFF: scalar=0x7F, SIMD=0xFF). Safe set excludes shr_*.
297
+ i8: new Map([
298
+ ['i32.add', { simd: 'i8x16.add' }],
299
+ ['i32.sub', { simd: 'i8x16.sub' }],
300
+ ['i32.and', { simd: 'v128.and' }],
301
+ ['i32.or', { simd: 'v128.or' }],
302
+ ['i32.xor', { simd: 'v128.xor' }],
303
+ ['i32.shl', { simd: 'i8x16.shl', shamtScalar: true }],
304
+ ]),
305
+ i16: new Map([
306
+ ['i32.add', { simd: 'i16x8.add' }],
307
+ ['i32.sub', { simd: 'i16x8.sub' }],
308
+ ['i32.mul', { simd: 'i16x8.mul' }],
309
+ ['i32.and', { simd: 'v128.and' }],
310
+ ['i32.or', { simd: 'v128.or' }],
311
+ ['i32.xor', { simd: 'v128.xor' }],
312
+ ['i32.shl', { simd: 'i16x8.shl', shamtScalar: true }],
313
+ ]),
314
+ i32: new Map([
315
+ ['i32.add', { simd: 'i32x4.add' }],
316
+ ['i32.sub', { simd: 'i32x4.sub' }],
317
+ ['i32.mul', { simd: 'i32x4.mul' }],
318
+ ['i32.and', { simd: 'v128.and' }],
319
+ ['i32.or', { simd: 'v128.or' }],
320
+ ['i32.xor', { simd: 'v128.xor' }],
321
+ ['i32.shl', { simd: 'i32x4.shl', shamtScalar: true }],
322
+ ['i32.shr_s', { simd: 'i32x4.shr_s', shamtScalar: true }],
323
+ ['i32.shr_u', { simd: 'i32x4.shr_u', shamtScalar: true }],
324
+ ]),
325
+ i64: new Map([
326
+ ['i64.add', { simd: 'i64x2.add' }],
327
+ ['i64.sub', { simd: 'i64x2.sub' }],
328
+ ['i64.mul', { simd: 'i64x2.mul' }],
329
+ ['i64.and', { simd: 'v128.and' }],
330
+ ['i64.or', { simd: 'v128.or' }],
331
+ ['i64.xor', { simd: 'v128.xor' }],
332
+ ['i64.shl', { simd: 'i64x2.shl', shamtScalar: true }],
333
+ ['i64.shr_s', { simd: 'i64x2.shr_s', shamtScalar: true }],
334
+ ['i64.shr_u', { simd: 'i64x2.shr_u', shamtScalar: true }],
335
+ ]),
336
+ f32: new Map([
337
+ ['f32.add', { simd: 'f32x4.add' }],
338
+ ['f32.sub', { simd: 'f32x4.sub' }],
339
+ ['f32.mul', { simd: 'f32x4.mul' }],
340
+ ['f32.div', { simd: 'f32x4.div' }],
341
+ ['f32.min', { simd: 'f32x4.min' }],
342
+ ['f32.max', { simd: 'f32x4.max' }],
343
+ ['f32.neg', { simd: 'f32x4.neg' }],
344
+ ['f32.abs', { simd: 'f32x4.abs' }],
345
+ ['f32.sqrt', { simd: 'f32x4.sqrt' }],
346
+ ]),
347
+ f64: new Map([
348
+ ['f64.add', { simd: 'f64x2.add' }],
349
+ ['f64.sub', { simd: 'f64x2.sub' }],
350
+ ['f64.mul', { simd: 'f64x2.mul' }],
351
+ ['f64.div', { simd: 'f64x2.div' }],
352
+ ['f64.min', { simd: 'f64x2.min' }],
353
+ ['f64.max', { simd: 'f64x2.max' }],
354
+ ['f64.neg', { simd: 'f64x2.neg' }],
355
+ ['f64.abs', { simd: 'f64x2.abs' }],
356
+ ['f64.sqrt', { simd: 'f64x2.sqrt' }],
357
+ ]),
358
+ }
359
+
360
+ // Horizontal reductions: associative+commutative ops applied to one
361
+ // loop-carried accumulator. Each entry maps the SCALAR op (which is also
362
+ // the op used to combine the SIMD result back into the accumulator at the
363
+ // end) to its SIMD lane op, lane extractor, and identity element.
364
+ //
365
+ // Floats (add, mul) are not strictly associative — vectorized order produces
366
+ // ulp-level differences from scalar order. Acceptable for typical use
367
+ // (reductions over typed arrays of well-conditioned data); strict-equal
368
+ // callers must keep the pass off.
369
+ //
370
+ // Integer mul (`p *= a[i]`) IS associative+commutative mod 2³² / 2⁶⁴, so its
371
+ // vectorization is value-exact. Identity is 1 (the multiplicative neutral).
372
+ //
373
+ // Narrow lanes (i8/i16) intentionally absent: `s += a[i]` with a u8/u16
374
+ // load expands the value to i32 before the add, so the accumulator's lane
375
+ // type is always wider than the load's element type. That widening would
376
+ // require pairwise/extending-add ops (i16x8.extadd_pairwise_*) — separate
377
+ // recognizer. Integer min/max likewise: WASM has no scalar i32.min, so they
378
+ // arrive as a `select`, not a binary op — a separate recognizer branch.
379
+ const REDUCE_OPS = {
380
+ i32: {
381
+ 'i32.add': { simd: 'i32x4.add', extract: 'i32x4.extract_lane', laneType: 'i32', constNode: ['i32.const', 0] },
382
+ 'i32.mul': { simd: 'i32x4.mul', extract: 'i32x4.extract_lane', laneType: 'i32', constNode: ['i32.const', 1] },
383
+ 'i32.xor': { simd: 'v128.xor', extract: 'i32x4.extract_lane', laneType: 'i32', constNode: ['i32.const', 0] },
384
+ 'i32.and': { simd: 'v128.and', extract: 'i32x4.extract_lane', laneType: 'i32', constNode: ['i32.const', -1] },
385
+ 'i32.or': { simd: 'v128.or', extract: 'i32x4.extract_lane', laneType: 'i32', constNode: ['i32.const', 0] },
386
+ },
387
+ i64: {
388
+ 'i64.add': { simd: 'i64x2.add', extract: 'i64x2.extract_lane', laneType: 'i64', constNode: ['i64.const', 0] },
389
+ 'i64.mul': { simd: 'i64x2.mul', extract: 'i64x2.extract_lane', laneType: 'i64', constNode: ['i64.const', 1] },
390
+ 'i64.xor': { simd: 'v128.xor', extract: 'i64x2.extract_lane', laneType: 'i64', constNode: ['i64.const', 0] },
391
+ 'i64.and': { simd: 'v128.and', extract: 'i64x2.extract_lane', laneType: 'i64', constNode: ['i64.const', -1] },
392
+ 'i64.or': { simd: 'v128.or', extract: 'i64x2.extract_lane', laneType: 'i64', constNode: ['i64.const', 0] },
393
+ },
394
+ f32: {
395
+ 'f32.add': { simd: 'f32x4.add', extract: 'f32x4.extract_lane', laneType: 'f32', constNode: ['f32.const', 0] },
396
+ 'f32.mul': { simd: 'f32x4.mul', extract: 'f32x4.extract_lane', laneType: 'f32', constNode: ['f32.const', 1] },
397
+ },
398
+ f64: {
399
+ 'f64.add': { simd: 'f64x2.add', extract: 'f64x2.extract_lane', laneType: 'f64', constNode: ['f64.const', 0] },
400
+ 'f64.mul': { simd: 'f64x2.mul', extract: 'f64x2.extract_lane', laneType: 'f64', constNode: ['f64.const', 1] },
401
+ },
402
+ }
403
+
404
+ // Widening byte/short sums: an i32 accumulator fed by ONE bare narrow load
405
+ // (`s += u8[i]`). The lane data is i8/i16 but the accumulator is i32, so the
406
+ // plain lane-add path can't apply — instead each 16-byte vector collapses via
407
+ // extadd_pairwise into i32x4 partial sums. VALUE-EXACT mod 2³² (unlike float
408
+ // reductions): pairwise intermediates can't overflow (2×255 < 2¹⁶, 2×(−128)
409
+ // fits i16; the i16→i32 step extends before adding), and wrap-add is
410
+ // associative+commutative. Restricted to a BARE load: arithmetic on the
411
+ // narrow lanes before widening would wrap at lane width where the scalar
412
+ // code widens first.
413
+ const WIDEN_LOADS = {
414
+ 'i32.load8_u': { laneType: 'i8', steps: ['i16x8.extadd_pairwise_i8x16_u', 'i32x4.extadd_pairwise_i16x8_u'] },
415
+ 'i32.load8_s': { laneType: 'i8', steps: ['i16x8.extadd_pairwise_i8x16_s', 'i32x4.extadd_pairwise_i16x8_s'] },
416
+ 'i32.load16_u': { laneType: 'i16', steps: ['i32x4.extadd_pairwise_i16x8_u'] },
417
+ 'i32.load16_s': { laneType: 'i16', steps: ['i32x4.extadd_pairwise_i16x8_s'] },
418
+ }
419
+
420
+ // Widening min/max over a BARE narrow load (`m = Math.max(m, u8[i])` with an
421
+ // i32 accumulator). Unlike the widening SUM there is no overflow concern:
422
+ // min/max at the load's own lane width over its own sign is value-exact, so
423
+ // the fold stays at lane width (16/8 lanes per vector) and only the final
424
+ // horizontal merge widens, via the sign-matched extract. Identity seeds the
425
+ // vector accumulator with the op's neutral: type-min for max, type-max for min.
426
+ const MINMAX_WIDEN = {
427
+ 'i32.load8_u': { pre: 'i8x16', sign: 'u', laneType: 'i8', lo: 0, hi: 255 },
428
+ 'i32.load8_s': { pre: 'i8x16', sign: 's', laneType: 'i8', lo: -128, hi: 127 },
429
+ 'i32.load16_u': { pre: 'i16x8', sign: 'u', laneType: 'i16', lo: 0, hi: 65535 },
430
+ 'i32.load16_s': { pre: 'i16x8', sign: 's', laneType: 'i16', lo: -32768, hi: 32767 },
431
+ }
432
+ // jz's number model converts narrow loads to f64 before Math.min/max, so the
433
+ // canon reduce arrives as (f64.max acc (f64.convert_i32_x LOAD)). The convert
434
+ // sign must match the load sign for the lane fold to be value-exact.
435
+ const MINMAX_CVT = { 'f64.convert_i32_u': 'u', 'f64.convert_i32_s': 's' }
436
+
437
+ // op-name → REDUCE entry across all lane types (the op-name itself encodes
438
+ // the lane type prefix, e.g. `i32.add` ⇒ i32 lanes).
439
+ const REDUCE_OP_LOOKUP = (() => {
440
+ const m = new Map()
441
+ for (const lt of Object.keys(REDUCE_OPS))
442
+ for (const op of Object.keys(REDUCE_OPS[lt]))
443
+ m.set(op, REDUCE_OPS[lt][op])
444
+ return m
445
+ })()
446
+
447
+ // Min/max reductions (`m = Math.max(m, a[i])`). jz wraps every Math.min/max in
448
+ // a NaN-canonicalizing select, so these arrive as a TWO-statement body —
449
+ // (local.set $cn (OP (local.get $acc) EXPR))
450
+ // (local.set $acc (select C (local.get $cn) (OP-type.ne $cn $cn)))
451
+ // — handled separately from the bare single-statement reductions above.
452
+ //
453
+ // max/min ARE associative and commutative (exact reassociation, unlike add),
454
+ // so vectorization is value-exact, INCLUDING NaN: f64x2.max/min propagate a
455
+ // NaN lane just as scalar does, and we re-apply the canon to the merged result
456
+ // so the final NaN bit pattern is canonical even when N is a multiple of LANES
457
+ // (zero tail iterations). Identity is the op's annihilator-free neutral:
458
+ // -inf for max, +inf for min.
459
+ const REDUCE_CANON = {
460
+ 'f64.max': { simd: 'f64x2.max', extract: 'f64x2.extract_lane', laneType: 'f64', identity: ['f64.const', '-inf'] },
461
+ 'f64.min': { simd: 'f64x2.min', extract: 'f64x2.extract_lane', laneType: 'f64', identity: ['f64.const', 'inf'] },
462
+ 'f32.max': { simd: 'f32x4.max', extract: 'f32x4.extract_lane', laneType: 'f32', identity: ['f32.const', '-inf'] },
463
+ 'f32.min': { simd: 'f32x4.min', extract: 'f32x4.extract_lane', laneType: 'f32', identity: ['f32.const', 'inf'] },
464
+ }
465
+
466
+ // Scalar comparison op → SIMD lane comparison, per lane type. Used to vectorize a
467
+ // conditional map `buf[i] = cond ? X : Y`, which jz lowers to `(if (result T) COND
468
+ // (then X)(else Y))`: COND becomes an all-ones/all-zeros lane mask fed to
469
+ // `v128.bitselect`. NaN behaves identically lane-wise — every ordered compare is
470
+ // false on a NaN operand in both scalar and SIMD, and `ne` is true — so no
471
+ // canonicalization is needed. i64x2 has no unsigned compares in baseline SIMD, so
472
+ // those simply aren't listed (the loop stays scalar).
473
+ const LANE_COMPARE = {
474
+ f64: { 'f64.eq': 'f64x2.eq', 'f64.ne': 'f64x2.ne', 'f64.lt': 'f64x2.lt', 'f64.gt': 'f64x2.gt', 'f64.le': 'f64x2.le', 'f64.ge': 'f64x2.ge' },
475
+ f32: { 'f32.eq': 'f32x4.eq', 'f32.ne': 'f32x4.ne', 'f32.lt': 'f32x4.lt', 'f32.gt': 'f32x4.gt', 'f32.le': 'f32x4.le', 'f32.ge': 'f32x4.ge' },
476
+ i32: { 'i32.eq': 'i32x4.eq', 'i32.ne': 'i32x4.ne', 'i32.lt_s': 'i32x4.lt_s', 'i32.lt_u': 'i32x4.lt_u', 'i32.gt_s': 'i32x4.gt_s', 'i32.gt_u': 'i32x4.gt_u', 'i32.le_s': 'i32x4.le_s', 'i32.le_u': 'i32x4.le_u', 'i32.ge_s': 'i32x4.ge_s', 'i32.ge_u': 'i32x4.ge_u' },
477
+ i64: { 'i64.eq': 'i64x2.eq', 'i64.ne': 'i64x2.ne', 'i64.lt_s': 'i64x2.lt_s', 'i64.gt_s': 'i64x2.gt_s', 'i64.le_s': 'i64x2.le_s', 'i64.ge_s': 'i64x2.ge_s' },
478
+ }
479
+
480
+ // ---- Recognizer ------------------------------------------------------------
481
+
482
+ function isLocalGet(node, name) {
483
+ return isArr(node) && node[0] === 'local.get' && (name == null || node[1] === name)
484
+ }
485
+ function isI32Const(node) {
486
+ return isArr(node) && node[0] === 'i32.const'
487
+ }
488
+ function constNum(node) {
489
+ if (!isI32Const(node)) return null
490
+ const v = node[1]
491
+ return typeof v === 'number' ? v : (typeof v === 'string' ? parseInt(v, 10) : null)
492
+ }
493
+
494
+ /**
495
+ * Match increment shape `(local.set $X (i32.add (local.get $X) (i32.const 1)))`.
496
+ * Returns $X or null.
497
+ */
498
+ function matchInc1(stmt) {
499
+ if (!isArr(stmt) || stmt[0] !== 'local.set' || stmt.length !== 3) return null
500
+ const x = stmt[1]
501
+ const v = stmt[2]
502
+ if (!isArr(v) || v[0] !== 'i32.add' || v.length !== 3) return null
503
+ if (!isLocalGet(v[1], x)) return null
504
+ if (constNum(v[2]) !== 1) return null
505
+ return x
506
+ }
507
+
508
+ /**
509
+ * Match `(br_if $LABEL (i32.eqz (i32.lt_{s,u} (local.get $I) BOUND)))`.
510
+ * Returns { ind, bound } or null.
511
+ */
512
+ function matchExitBrIf(stmt, label) {
513
+ if (!isArr(stmt) || stmt[0] !== 'br_if' || stmt[1] !== label) return null
514
+ const cond = stmt[2]
515
+ if (!isArr(cond) || cond[0] !== 'i32.eqz') return null
516
+ const cmp = cond[1]
517
+ if (!isArr(cmp) || (cmp[0] !== 'i32.lt_s' && cmp[0] !== 'i32.lt_u')) return null
518
+ if (!isLocalGet(cmp[1])) return null
519
+ return { ind: cmp[1][1], bound: cmp[2] }
520
+ }
521
+
522
+ /**
523
+ * Walk node, collect set of local names that are written via local.set/local.tee
524
+ * anywhere within. Used to detect loop-invariant locals.
525
+ */
526
+ function collectWrites(node, out) {
527
+ if (!isArr(node)) return
528
+ const op = node[0]
529
+ if ((op === 'local.set' || op === 'local.tee') && typeof node[1] === 'string') {
530
+ out.add(node[1])
531
+ }
532
+ for (let i = 0; i < node.length; i++) collectWrites(node[i], out)
533
+ }
534
+
535
+ /**
536
+ * Return the FIRST kind of access for `name` in straight-line walk order.
537
+ * 'write' — local.set/local.tee seen first
538
+ * 'read' — local.get seen first
539
+ * null — not referenced
540
+ */
541
+ function firstAccess(node, name) {
542
+ if (!isArr(node)) return null
543
+ const op = node[0]
544
+ // Walk children first — operands evaluate before the op. For local.set/tee
545
+ // the VALUE child (idx 2) runs before the write, so a `local.get name` in
546
+ // the value of `local.set name` is a read-before-write.
547
+ if ((op === 'local.set' || op === 'local.tee') && node[1] === name) {
548
+ if (node.length >= 3) {
549
+ const r = firstAccess(node[2], name)
550
+ if (r) return r
551
+ }
552
+ return 'write'
553
+ }
554
+ if (op === 'local.get' && node[1] === name) return 'read'
555
+ for (let i = 1; i < node.length; i++) {
556
+ const r = firstAccess(node[i], name)
557
+ if (r) return r
558
+ }
559
+ return null
560
+ }
561
+
562
+ /**
563
+ * Match the index-offset operand of a lane address — the part that scales the
564
+ * induction variable inside `(i32.add base OFFSET)`. OFFSET is one of:
565
+ * (i32.shl (local.get IND) (i32.const K)) → strideLog2 K
566
+ * (local.get IND) → strideLog2 0
567
+ * (local.tee $T <either of the above>) → records $T
568
+ * (local.get $T) where $T is a recorded offset-tee → that tee's strideLog2
569
+ *
570
+ * The tee'd form arises from CSE: a map loop `b[i] = f(a[i])` over two distinct
571
+ * base pointers shares one `i << K` offset (`(local.tee $T (i32.shl i K))` in
572
+ * the first address, `(local.get $T)` in the second). `offsetTees` (Map
573
+ * name→strideLog2) carries that across calls.
574
+ *
575
+ * Returns { strideLog2, teeName?: string } or null.
576
+ */
577
+ function matchLaneOffset(off, ind, offsetTees) {
578
+ if (isArr(off) && off[0] === 'local.get' && typeof off[1] === 'string' &&
579
+ offsetTees && offsetTees.has(off[1])) {
580
+ return { strideLog2: offsetTees.get(off[1]), teeName: null }
581
+ }
582
+ let teeName = null
583
+ let n = off
584
+ if (isArr(n) && n[0] === 'local.tee' && n.length === 3) { teeName = n[1]; n = n[2] }
585
+ // (i32.shl (local.get ind) (i32.const K))
586
+ if (isArr(n) && n[0] === 'i32.shl' && n.length === 3 && isLocalGet(n[1], ind)) {
587
+ const k = constNum(n[2])
588
+ if (k != null && k >= 0 && k <= 3) return { strideLog2: k, teeName }
589
+ }
590
+ // (local.get ind) — stride 1
591
+ if (isLocalGet(n, ind)) return { strideLog2: 0, teeName }
592
+ return null
593
+ }
594
+
595
+ /**
596
+ * Match an address expression `(i32.add base OFFSET)`, with optional outer
597
+ * `(local.tee $A ...)`. OFFSET is matched by matchLaneOffset (which also
598
+ * accepts a CSE'd `(local.tee $T (i32.shl ind K))` / `(local.get $T)` pair).
599
+ * Also accepts `(local.get $A)` when $A is a previously-recorded address tee.
600
+ *
601
+ * Returns { strideLog2, base, teeName?, offsetTeeName?, viaLocal? } or null.
602
+ * `strideLog2` = K for i32.shl form, 0 for plain add form.
603
+ * `base` is the loop-invariant base subtree.
604
+ */
605
+ function matchLaneAddr(addr, ind, addrLocals, offsetTees) {
606
+ let teeName = null
607
+ let n = addr
608
+ // (local.get $A) where $A holds a previously-tee'd FULL lane-address.
609
+ if (isArr(n) && n[0] === 'local.get' && typeof n[1] === 'string' && addrLocals && addrLocals.has(n[1])) {
610
+ const e = addrLocals.get(n[1])
611
+ return { strideLog2: e.strideLog2, base: e.base, teeName: null, viaLocal: n[1] }
612
+ }
613
+ if (isArr(n) && n[0] === 'local.tee' && n.length === 3) {
614
+ teeName = n[1]
615
+ n = n[2]
616
+ }
617
+ if (!isArr(n) || n[0] !== 'i32.add' || n.length !== 3) return null
618
+ const a = n[1], b = n[2]
619
+ const off = matchLaneOffset(b, ind, offsetTees)
620
+ if (!off) return null
621
+ return { strideLog2: off.strideLog2, base: a, teeName, offsetTeeName: off.teeName }
622
+ }
623
+
624
+ /**
625
+ * A scalar i32 local that is ONLY ever assigned a lane offset — `(i32.shl ind K)`
626
+ * (or bare `ind` for stride 0) — is a CSE'd offset shared across base pointers.
627
+ * Returns the consistent strideLog2, or null if any write to it diverges.
628
+ * This soundness check backs every `(local.get $T)` resolved via `offsetTees`.
629
+ */
630
+ function _offsetLocalStride(body, name, ind) {
631
+ let stride = null, found = false, ok = true
632
+ function walk(n) {
633
+ if (!isArr(n)) return
634
+ if ((n[0] === 'local.tee' || n[0] === 'local.set') && n[1] === name && n.length === 3) {
635
+ found = true
636
+ const v = n[2]
637
+ let k = null
638
+ if (isArr(v) && v[0] === 'i32.shl' && v.length === 3 && isLocalGet(v[1], ind)) {
639
+ k = constNum(v[2])
640
+ if (k == null || k < 0 || k > 3) ok = false
641
+ } else if (isLocalGet(v, ind)) {
642
+ k = 0
643
+ } else ok = false
644
+ if (k != null) {
645
+ if (stride == null) stride = k
646
+ else if (stride !== k) ok = false
647
+ }
648
+ }
649
+ for (let i = 1; i < n.length; i++) walk(n[i])
650
+ }
651
+ for (const s of body) walk(s)
652
+ return found && ok ? stride : null
653
+ }
654
+
655
+ // True if any node in the tree writes a global. When false for a loop body,
656
+ // every `global.get` inside it is loop-invariant — safe to splat for SIMD.
657
+ const hasGlobalSet = (node) => {
658
+ if (!isArr(node)) return false
659
+ if (node[0] === 'global.set') return true
660
+ for (let i = 1; i < node.length; i++) if (hasGlobalSet(node[i])) return true
661
+ return false
662
+ }
663
+
664
+ // ---- Recognize a (block (loop)) pair --------------------------------------
665
+
666
+ /**
667
+ * Try to vectorize the inner loop. Returns the replacement node array
668
+ * (synthetic outer block) or null on no match.
669
+ */
670
+ function tryVectorize(blockNode, fnLocals, freshIdRef) {
671
+ if (!isArr(blockNode) || blockNode[0] !== 'block') return null
672
+ // Find label and inner loop.
673
+ let blockLabel = null
674
+ let loopIdx = -1, loopNode = null
675
+ for (let i = 1; i < blockNode.length; i++) {
676
+ const c = blockNode[i]
677
+ if (typeof c === 'string' && c.startsWith('$') && blockLabel == null && i === 1) {
678
+ blockLabel = c; continue
679
+ }
680
+ if (isArr(c) && c[0] === 'loop') {
681
+ if (loopNode) return null // multiple loops
682
+ loopIdx = i; loopNode = c
683
+ } else if (isArr(c)) {
684
+ return null // foreign content alongside the loop
685
+ }
686
+ }
687
+ if (!loopNode || !blockLabel) return null
688
+
689
+ // Loop layout: ['loop', '$label', ...stmts]
690
+ const loopLabel = typeof loopNode[1] === 'string' && loopNode[1].startsWith('$') ? loopNode[1] : null
691
+ if (!loopLabel) return null
692
+
693
+ // Find induction increment + back-branch at the END.
694
+ let endIdx = loopNode.length - 1
695
+ if (!(isArr(loopNode[endIdx]) && loopNode[endIdx][0] === 'br' && loopNode[endIdx][1] === loopLabel)) return null
696
+ const incIdx = endIdx - 1
697
+ const incVar = matchInc1(loopNode[incIdx])
698
+ if (!incVar) return null
699
+
700
+ // First stmt must be the exit br_if.
701
+ const exitInfo = matchExitBrIf(loopNode[2], blockLabel)
702
+ if (!exitInfo) return null
703
+ if (exitInfo.ind !== incVar) return null
704
+
705
+ // Body = stmts between exit and increment.
706
+ const body = []
707
+ for (let i = 3; i < incIdx; i++) body.push(loopNode[i])
708
+
709
+ // Bound must be loop-invariant. For now, accept (local.get $L) where $L
710
+ // is declared but not written inside the body, OR (i32.const N).
711
+ let bound = exitInfo.bound
712
+ let boundLocal = null
713
+ if (isArr(bound) && bound[0] === 'local.get' && typeof bound[1] === 'string') {
714
+ boundLocal = bound[1]
715
+ } else if (isI32Const(bound)) {
716
+ // ok
717
+ } else {
718
+ return null
719
+ }
720
+
721
+ // Detect lane type from the FIRST load in body.
722
+ let laneType = null
723
+ let stride = -1
724
+ const loadStoreSites = [] // {parent, idx, kind:'load'|'store'}
725
+ // Address tees: name → {strideLog2, base}. A `(local.tee NAME (lane-addr))`
726
+ // both validates the load's address AND records NAME so the matching store's
727
+ // `(local.get NAME)` is accepted as the same lane address.
728
+ const addrLocals = new Map()
729
+ // Offset tees: name → strideLog2. A CSE'd `i << K` shared across base
730
+ // pointers (map loops over distinct arrays). Soundness re-checked post-scan.
731
+ const offsetTees = new Map()
732
+
733
+ function scanForLoadsStores(node, parent, pi) {
734
+ if (!isArr(node)) return true
735
+ const op = node[0]
736
+ if (LOAD_OPS[op]) {
737
+ if (laneType == null) {
738
+ laneType = LOAD_OPS[op]
739
+ stride = LANE_INFO[laneType].stride
740
+ } else if (LOAD_OPS[op] !== laneType) {
741
+ return false
742
+ }
743
+ const m = matchLaneAddr(node[1], incVar, addrLocals, offsetTees)
744
+ if (!m) return false
745
+ if ((1 << m.strideLog2) !== stride) return false
746
+ if (m.teeName) addrLocals.set(m.teeName, { strideLog2: m.strideLog2, base: m.base })
747
+ if (m.offsetTeeName) offsetTees.set(m.offsetTeeName, m.strideLog2)
748
+ loadStoreSites.push({ parent, idx: pi, kind: 'load' })
749
+ return true
750
+ }
751
+ if (STORE_OPS[op]) {
752
+ const sty = STORE_OPS[op]
753
+ if (laneType != null && sty !== laneType) return false
754
+ if (laneType == null) { laneType = sty; stride = LANE_INFO[laneType].stride }
755
+ const m = matchLaneAddr(node[1], incVar, addrLocals, offsetTees)
756
+ if (!m) return false
757
+ if ((1 << m.strideLog2) !== stride) return false
758
+ if (m.teeName) addrLocals.set(m.teeName, { strideLog2: m.strideLog2, base: m.base })
759
+ if (m.offsetTeeName) offsetTees.set(m.offsetTeeName, m.strideLog2)
760
+ loadStoreSites.push({ parent, idx: pi, kind: 'store' })
761
+ // Recurse into VALUE child (idx 2) — it's data, not address.
762
+ if (!scanForLoadsStores(node[2], node, 2)) return false
763
+ return true
764
+ }
765
+ // local.set/tee of an address local outside a load/store context (e.g.
766
+ // `(local.set $a (i32.add base (i32.shl i 2)))` as a standalone stmt) —
767
+ // record so a later `(local.get $a)` resolves.
768
+ if ((op === 'local.set' || op === 'local.tee') && typeof node[1] === 'string' && node.length === 3) {
769
+ const valM = matchLaneAddr(['local.tee', node[1], node[2]], incVar, addrLocals, offsetTees)
770
+ if (valM && valM.teeName) {
771
+ addrLocals.set(valM.teeName, { strideLog2: valM.strideLog2, base: valM.base })
772
+ }
773
+ // Standalone offset compute: `(local.set $t (i32.shl i K))`.
774
+ const offM = matchLaneOffset(node[2], incVar, offsetTees)
775
+ if (offM) offsetTees.set(node[1], offM.strideLog2)
776
+ }
777
+ // Recurse into all children
778
+ for (let i = 1; i < node.length; i++) {
779
+ if (!scanForLoadsStores(node[i], node, i)) return false
780
+ }
781
+ return true
782
+ }
783
+ for (const stmt of body) {
784
+ if (!scanForLoadsStores(stmt, null, -1)) return null
785
+ }
786
+ if (body.some(hasGlobalSet)) return null // a global write breaks the "global.get is invariant" splat
787
+ if (!laneType) return null // no memory ops — vectorizing buys nothing
788
+ if (loadStoreSites.length === 0) return null
789
+
790
+ // Soundness gate for offset-tee resolution: every `(local.get $T)` we
791
+ // accepted as `i << K` is only valid if EVERY write of $T is that offset.
792
+ for (const [name, k] of offsetTees) {
793
+ if (_offsetLocalStride(body, name, incVar) !== k) return null
794
+ }
795
+
796
+ // Classify all locals referenced in body.
797
+ // - induction var (incVar): exempt
798
+ // - bound local (if any): must be invariant
799
+ // - each other local: first access must not be a read-then-written pattern
800
+ const writes = new Set()
801
+ for (const s of body) collectWrites(s, writes)
802
+ if (boundLocal && writes.has(boundLocal)) return null // bound varies in body → bail
803
+
804
+ const localKind = new Map() // name → 'lane' | 'invariant' | 'addr'
805
+ // Walk to collect ALL referenced names
806
+ const referenced = new Set()
807
+ const collectRefs = (n) => {
808
+ if (!isArr(n)) return
809
+ const op = n[0]
810
+ if ((op === 'local.get' || op === 'local.set' || op === 'local.tee') && typeof n[1] === 'string')
811
+ referenced.add(n[1])
812
+ for (let i = 1; i < n.length; i++) collectRefs(n[i])
813
+ }
814
+ for (const s of body) collectRefs(s)
815
+
816
+ for (const name of referenced) {
817
+ if (name === incVar) continue
818
+ if (writes.has(name)) {
819
+ // Must be lane-local: first access is a write.
820
+ let firstKind = null
821
+ for (const s of body) {
822
+ const k = firstAccess(s, name)
823
+ if (k) { firstKind = k; break }
824
+ }
825
+ if (firstKind === 'read') return null // loop-carried (reduction or stencil)
826
+ // Discriminate lane-data vs address-tee. Address tees hold i32 addresses,
827
+ // not vector data. We classify by checking the local's declared type.
828
+ const decl = fnLocals.get(name)
829
+ if (decl === 'i32' && (offsetTees.has(name) || _isAddressLocal(body, name, incVar))) {
830
+ localKind.set(name, 'addr')
831
+ } else {
832
+ localKind.set(name, 'lane')
833
+ }
834
+ } else {
835
+ localKind.set(name, 'invariant')
836
+ }
837
+ }
838
+
839
+ // Build lifted body. If anything fails to lift, bail.
840
+ const newLanedLocals = new Map() // origName → { laneName, simdType }
841
+ const extraLocals = [] // canon temps allocated during lift
842
+ const ctx = { laneType, incVar, localKind, newLanedLocals, extraLocals, freshIdRef, fail: false, failReason: null }
843
+ const lifted = []
844
+ for (const s of body) {
845
+ const r = liftStmt(s, ctx)
846
+ if (ctx.fail) return null
847
+ if (r != null) {
848
+ if (Array.isArray(r) && r[0] === '__seq__') lifted.push(...r.slice(1))
849
+ else lifted.push(r)
850
+ }
851
+ }
852
+ if (lifted.length === 0) return null
853
+
854
+ // Generate fresh names
855
+ const id = freshIdRef.next++
856
+ const simdBoundName = `$__simd_bound${id}`
857
+ const simdBrkLabel = `$__simd_brk${id}`
858
+ const simdLoopLabel = `$__simd_loop${id}`
859
+
860
+ const info = LANE_INFO[laneType]
861
+ const lanes = info.lanes
862
+ const mask = -lanes // bit pattern ~(lanes-1) in i32 two's complement
863
+
864
+ // Build SIMD prefix block.
865
+ const boundExpr = boundLocal
866
+ ? ['local.get', boundLocal]
867
+ : bound // i32.const N
868
+ const simdBlock = ['block', simdBrkLabel,
869
+ ['loop', simdLoopLabel,
870
+ ['br_if', simdBrkLabel,
871
+ ['i32.eqz', ['i32.lt_s', ['local.get', incVar], ['local.get', simdBoundName]]]],
872
+ ...lifted,
873
+ ['local.set', incVar,
874
+ ['i32.add', ['local.get', incVar], ['i32.const', lanes]]],
875
+ ['br', simdLoopLabel]
876
+ ]
877
+ ]
878
+
879
+ // Bound setup: simdBoundName = bound & ~(lanes-1)
880
+ const boundSetup = ['local.set', simdBoundName,
881
+ ['i32.and', boundExpr, ['i32.const', mask]]]
882
+
883
+ // Synthetic outer wrapper — has no result, no label, just sequences.
884
+ // The original block is preserved unchanged as the tail.
885
+ const wrapper = ['block', boundSetup, simdBlock, blockNode]
886
+
887
+ // Locals to add to function header.
888
+ const newLocalDecls = [
889
+ ['local', simdBoundName, 'i32'],
890
+ ...[...newLanedLocals.values()].map(({ laneName }) => ['local', laneName, 'v128']),
891
+ ...extraLocals,
892
+ ]
893
+
894
+ return { wrapper, newLocalDecls }
895
+ }
896
+
897
+ // ---- Reduction recognizer -------------------------------------------------
898
+ //
899
+ // Matches inner loops of shape:
900
+ // for (let i = 0; i < N; i++) S = OP(S, EXPR(arr[i], ...))
901
+ // where OP is associative+commutative (REDUCE_OPS table) and EXPR is lane-
902
+ // pure (operates on the loaded element with at most loop-invariant data).
903
+ // S is a SCALAR loop-carried accumulator — exempt from the lane-local
904
+ // "first access must be a write" check.
905
+ //
906
+ // Lift:
907
+ // acc = splat(IDENTITY)
908
+ // for (i = 0; i < bound & ~(L-1); i += L) acc = OP_v(acc, lifted EXPR)
909
+ // S = OP(S, horizontal_reduce(acc))
910
+ // <original scalar tail handles the remainder>
911
+ //
912
+ // Float adds are not strictly associative — vectorized reduction differs
913
+ // from scalar reduction by ulps. Acceptable when bit-exact equality is not
914
+ // required (which it isn't, by spec, in JS engines either).
915
+ function tryReduceVectorize(blockNode, fnLocals, freshIdRef) {
916
+ if (!isArr(blockNode) || blockNode[0] !== 'block') return null
917
+
918
+ // Match outer (block (loop)) structure. Same loop-shape as tryVectorize.
919
+ let blockLabel = null
920
+ let loopNode = null
921
+ for (let i = 1; i < blockNode.length; i++) {
922
+ const c = blockNode[i]
923
+ if (typeof c === 'string' && c.startsWith('$') && blockLabel == null && i === 1) { blockLabel = c; continue }
924
+ if (isArr(c) && c[0] === 'loop') {
925
+ if (loopNode) return null
926
+ loopNode = c
927
+ } else if (isArr(c)) return null
928
+ }
929
+ if (!loopNode || !blockLabel) return null
930
+ const loopLabel = typeof loopNode[1] === 'string' && loopNode[1].startsWith('$') ? loopNode[1] : null
931
+ if (!loopLabel) return null
932
+ const endIdx = loopNode.length - 1
933
+ if (!(isArr(loopNode[endIdx]) && loopNode[endIdx][0] === 'br' && loopNode[endIdx][1] === loopLabel)) return null
934
+ const incIdx = endIdx - 1
935
+ const incVar = matchInc1(loopNode[incIdx])
936
+ if (!incVar) return null
937
+ const exitInfo = matchExitBrIf(loopNode[2], blockLabel)
938
+ if (!exitInfo) return null
939
+ if (exitInfo.ind !== incVar) return null
940
+
941
+ // Body is either a bare single-statement reduction —
942
+ // (local.set $acc (OP (local.get $acc) EXPR)) add/xor/and/or
943
+ // — or a NaN-canonicalized two-statement min/max reduction —
944
+ // (local.set $cn (OP (local.get $acc) EXPR))
945
+ // (local.set $acc (select C (local.get $cn) (T.ne $cn $cn)))
946
+ const bodyLen = incIdx - 3
947
+ let accName, opName, reduceEntry, exprNode, canonC = null
948
+ if (bodyLen === 1) {
949
+ const stmt = loopNode[3]
950
+ if (!isArr(stmt) || stmt[0] !== 'local.set' || stmt.length !== 3) return null
951
+ accName = stmt[1]
952
+ if (typeof accName !== 'string') return null
953
+ const rhs = stmt[2]
954
+ if (!isArr(rhs)) return null
955
+ const minmax = matchIntMinMaxReduce(rhs, accName)
956
+ if (minmax) {
957
+ // Synthetic entry: WASM has the SIMD i32x4.max_s/min_s but no scalar i32.max, so the
958
+ // horizontal fold + merge below use select (flagged by minmaxSelect). Identity is the
959
+ // op's neutral — INT_MIN for max, INT_MAX for min. A bare narrow load instead folds
960
+ // at its own lane width/sign (MINMAX_WIDEN), 16 or 8 lanes per vector.
961
+ const w = isArr(minmax.exprNode) && minmax.exprNode.length === 2
962
+ ? MINMAX_WIDEN[minmax.exprNode[0]] : null
963
+ reduceEntry = w ? {
964
+ simd: `${w.pre}.${minmax.isMax ? 'max' : 'min'}_${w.sign}`,
965
+ extract: `${w.pre}.extract_lane_${w.sign}`, laneType: w.laneType,
966
+ identity: ['i32.const', minmax.isMax ? w.lo : w.hi],
967
+ minmaxSelect: true, isMax: minmax.isMax, accI32: true,
968
+ } : {
969
+ simd: minmax.isMax ? 'i32x4.max_s' : 'i32x4.min_s',
970
+ extract: 'i32x4.extract_lane', laneType: 'i32',
971
+ identity: ['i32.const', minmax.isMax ? -2147483648 : 2147483647],
972
+ minmaxSelect: true, isMax: minmax.isMax,
973
+ }
974
+ exprNode = minmax.exprNode
975
+ } else if (rhs[0] === 'block') {
976
+ // Un-flattened NaN-canon float min/max — the same reduction as the two-statement
977
+ // canon (bodyLen===2 below), but with the cn-temp set + select still wrapped in a
978
+ // value-block: (local.set acc (block (result T) (local.set cn (OP acc expr))
979
+ // (select C (local.get cn) (T.ne cn cn)))). mergeBlocks normally hoists this to the
980
+ // flat form; recognize the block form directly so vectorization doesn't hinge on
981
+ // that hoist having run.
982
+ let bi = 1
983
+ if (typeof rhs[bi] === 'string' && rhs[bi].startsWith('$')) bi++
984
+ if (isArr(rhs[bi]) && rhs[bi][0] === 'result') bi++
985
+ const inner = rhs[bi]
986
+ const op = isArr(inner) && inner[0] === 'local.set' && isArr(inner[2]) ? inner[2][0] : null
987
+ reduceEntry = op ? REDUCE_CANON[op] : null
988
+ if (!reduceEntry) return null
989
+ const cb = matchCanonBlock(rhs, reduceEntry.laneType)
990
+ if (!cb || !isArr(cb.core) || cb.core.length !== 3 || !isLocalGet(cb.core[1], accName)) return null
991
+ opName = op
992
+ exprNode = cb.core[2]
993
+ canonC = cb.C
994
+ } else {
995
+ if (rhs.length !== 3) return null
996
+ opName = rhs[0]
997
+ reduceEntry = REDUCE_OP_LOOKUP.get(opName)
998
+ if (!reduceEntry || !isLocalGet(rhs[1], accName)) return null
999
+ exprNode = rhs[2]
1000
+ }
1001
+ } else if (bodyLen === 2) {
1002
+ const s1 = loopNode[3], s2 = loopNode[4]
1003
+ if (!isArr(s1) || s1[0] !== 'local.set' || s1.length !== 3) return null
1004
+ if (!isArr(s2) || s2[0] !== 'local.set' || s2.length !== 3) return null
1005
+ const cnName = s1[1], rhs = s1[2]
1006
+ if (typeof cnName !== 'string' || !isArr(rhs) || rhs.length !== 3) return null
1007
+ opName = rhs[0]
1008
+ reduceEntry = REDUCE_CANON[opName]
1009
+ if (!reduceEntry) return null
1010
+ accName = s2[1]
1011
+ if (typeof accName !== 'string' || accName === cnName) return null
1012
+ const canon = matchCanonSelect(s2[2], reduceEntry.laneType)
1013
+ if (!canon || !isLocalGet(canon.val, cnName)) return null
1014
+ if (!isLocalGet(rhs[1], accName)) return null
1015
+ canonC = canon.C
1016
+ exprNode = rhs[2]
1017
+ } else return null
1018
+
1019
+ // Accumulator's declared local type must match the lane element type.
1020
+ // Exception: the widening byte/short sum — i32 accumulator fed by ONE bare
1021
+ // narrow load (`s += u8[i]`), whose LANE type is i8/i16 but reduces into i32.
1022
+ const accType = fnLocals.get(accName)
1023
+ const widen = (opName === 'i32.add' && accType === 'i32' && canonC == null
1024
+ && isArr(exprNode) && exprNode.length === 2 && WIDEN_LOADS[exprNode[0]]) || null
1025
+ // Widening float min/max: the canon over a sign-matched converted narrow load
1026
+ // (`m = Math.max(m, u8[i])`, acc f64) folds at the load's own width — exact,
1027
+ // since min/max never rounds and u8…i16 values are exact in f64. Only the one
1028
+ // horizontal result converts to f64 for the merge (+ re-canon for a NaN acc).
1029
+ if (canonC != null && (opName === 'f64.max' || opName === 'f64.min') && accType === 'f64'
1030
+ && isArr(exprNode) && exprNode.length === 2 && MINMAX_CVT[exprNode[0]] && isArr(exprNode[1])) {
1031
+ const w = MINMAX_WIDEN[exprNode[1][0]]
1032
+ if (w && w.sign === MINMAX_CVT[exprNode[0]]) {
1033
+ const isMax = opName === 'f64.max'
1034
+ reduceEntry = {
1035
+ simd: `${w.pre}.${isMax ? 'max' : 'min'}_${w.sign}`,
1036
+ extract: `${w.pre}.extract_lane_${w.sign}`, laneType: w.laneType,
1037
+ identity: ['i32.const', isMax ? w.lo : w.hi],
1038
+ minmaxSelect: true, isMax, accF64: exprNode[0], canonC,
1039
+ }
1040
+ exprNode = exprNode[1]
1041
+ canonC = null
1042
+ }
1043
+ }
1044
+ if (!widen && accType !== (reduceEntry.accI32 ? 'i32' : reduceEntry.accF64 ? 'f64' : reduceEntry.laneType)) return null
1045
+
1046
+ // Bound classification (same as tryVectorize).
1047
+ let bound = exitInfo.bound
1048
+ let boundLocal = null
1049
+ if (isArr(bound) && bound[0] === 'local.get' && typeof bound[1] === 'string') boundLocal = bound[1]
1050
+ else if (!isI32Const(bound)) return null
1051
+
1052
+ // Scan EXPR for lane-aligned loads. Stores forbidden. Re-references of
1053
+ // accName forbidden (the accumulator only appears in the outer wrapper).
1054
+ const laneType = widen ? widen.laneType : reduceEntry.laneType
1055
+ const stride = LANE_INFO[laneType].stride
1056
+ const addrLocals = new Map()
1057
+ const offsetTees = new Map()
1058
+ let loadCount = 0
1059
+ function scanExpr(node) {
1060
+ if (!isArr(node)) return true
1061
+ const op = node[0]
1062
+ if (LOAD_OPS[op]) {
1063
+ if (LOAD_OPS[op] !== laneType) return false
1064
+ const m = matchLaneAddr(node[1], incVar, addrLocals, offsetTees)
1065
+ if (!m) return false
1066
+ if ((1 << m.strideLog2) !== stride) return false
1067
+ if (m.teeName) addrLocals.set(m.teeName, { strideLog2: m.strideLog2, base: m.base })
1068
+ if (m.offsetTeeName) offsetTees.set(m.offsetTeeName, m.strideLog2)
1069
+ loadCount++
1070
+ return true
1071
+ }
1072
+ if (STORE_OPS[op]) return false
1073
+ if (op === 'local.set' || op === 'local.tee') return false // no intermediates
1074
+ if (op === 'local.get' && node[1] === accName) return false
1075
+ for (let i = 1; i < node.length; i++) if (!scanExpr(node[i])) return false
1076
+ return true
1077
+ }
1078
+ if (!scanExpr(exprNode)) return null
1079
+ if (loadCount === 0) return null
1080
+ // Soundness gate for offset-tee resolution (see tryVectorize).
1081
+ for (const [name, k] of offsetTees) {
1082
+ if (_offsetLocalStride([exprNode], name, incVar) !== k) return null
1083
+ }
1084
+
1085
+ // Classify locals referenced in EXPR. Anything not the induction var or an
1086
+ // address-tee is invariant (we forbade local.set/tee in scanExpr).
1087
+ const referenced = new Set()
1088
+ const collectRefs = (n) => {
1089
+ if (!isArr(n)) return
1090
+ if (n[0] === 'local.get' && typeof n[1] === 'string') referenced.add(n[1])
1091
+ for (let i = 1; i < n.length; i++) collectRefs(n[i])
1092
+ }
1093
+ collectRefs(exprNode)
1094
+ const localKind = new Map()
1095
+ for (const name of referenced) {
1096
+ if (name === incVar) continue
1097
+ if (addrLocals.has(name) || offsetTees.has(name)) { localKind.set(name, 'addr'); continue }
1098
+ localKind.set(name, 'invariant')
1099
+ }
1100
+ for (const name of addrLocals.keys()) localKind.set(name, 'addr')
1101
+ for (const name of offsetTees.keys()) localKind.set(name, 'addr')
1102
+
1103
+ const ctx = { laneType, incVar, localKind, newLanedLocals: new Map(), extraLocals: [], freshIdRef, fail: false, failReason: null }
1104
+ const liftedExpr = liftExprV(exprNode, ctx)
1105
+ if (ctx.fail) return null
1106
+ if (ctx.newLanedLocals.size > 0 || ctx.extraLocals.length > 0) return null
1107
+
1108
+ // Synthesize SIMD prefix block + horizontal reduce + (preserved scalar tail).
1109
+ const id = freshIdRef.next++
1110
+ const simdBoundName = `$__simd_bound${id}`
1111
+ const simdAccName = `$__simd_acc${id}`
1112
+ const simdBrkLabel = `$__simd_brk${id}`
1113
+ const simdLoopLabel = `$__simd_loop${id}`
1114
+ const info = LANE_INFO[laneType]
1115
+ const lanes = info.lanes
1116
+ const boundExpr = boundLocal ? ['local.get', boundLocal] : bound
1117
+
1118
+ // Widening sum: the ACCUMULATOR vector is i32x4 regardless of the (narrow)
1119
+ // lane type; each iteration's 16-byte load collapses via extadd_pairwise.
1120
+ const accSplat = widen ? 'i32x4.splat' : info.splat
1121
+ const accumOperand = widen ? widen.steps.reduce((e, s) => [s, e], liftedExpr) : liftedExpr
1122
+ const initAcc = ['local.set', simdAccName, [accSplat, reduceEntry.constNode ?? reduceEntry.identity]]
1123
+ const simdBlock = ['block', simdBrkLabel,
1124
+ ['loop', simdLoopLabel,
1125
+ ['br_if', simdBrkLabel,
1126
+ ['i32.eqz', ['i32.lt_s', ['local.get', incVar], ['local.get', simdBoundName]]]],
1127
+ ['local.set', simdAccName,
1128
+ [reduceEntry.simd, ['local.get', simdAccName], accumOperand]],
1129
+ ['local.set', incVar, ['i32.add', ['local.get', incVar], ['i32.const', lanes]]],
1130
+ ['br', simdLoopLabel]
1131
+ ]
1132
+ ]
1133
+
1134
+ // Horizontal fold + merge into the live accumulator.
1135
+ const extraDecls = []
1136
+ let mergeStmts
1137
+ if (reduceEntry.minmaxSelect) {
1138
+ // No scalar i32.max/min — fold via select through an i32 temp (no exponential
1139
+ // operand duplication): ht = lane0; ht = minmax(ht, lane_k); acc = minmax(acc, ht).
1140
+ // `select(a,b,(gt|lt)_s a b)` = a when it's the larger/smaller, i.e. minmax(a,b).
1141
+ const cmpOp = reduceEntry.isMax ? 'i32.gt_s' : 'i32.lt_s'
1142
+ const ht = `$__simd_h${id}`
1143
+ extraDecls.push(['local', ht, 'i32'])
1144
+ const lane = (k) => [reduceEntry.extract, k, ['local.get', simdAccName]]
1145
+ const minmaxSel = (a, b) => ['select', a, b, [cmpOp, a, b]]
1146
+ mergeStmts = [['local.set', ht, lane(0)]]
1147
+ for (let k = 1; k < lanes; k++) mergeStmts.push(['local.set', ht, minmaxSel(lane(k), ['local.get', ht])])
1148
+ if (reduceEntry.accF64) {
1149
+ // Widening canon merge: one convert of the horizontal result, the scalar
1150
+ // f64 op against the live acc, then re-canon (a NaN-seeded acc must still
1151
+ // cross as the canonical NaN when the scalar tail is empty).
1152
+ mergeStmts.push(['local.set', accName,
1153
+ [opName, ['local.get', accName], [reduceEntry.accF64, ['local.get', ht]]]])
1154
+ mergeStmts.push(['local.set', accName,
1155
+ ['select', reduceEntry.canonC, ['local.get', accName],
1156
+ ['f64.ne', ['local.get', accName], ['local.get', accName]]]])
1157
+ } else {
1158
+ mergeStmts.push(['local.set', accName, minmaxSel(['local.get', accName], ['local.get', ht])])
1159
+ }
1160
+ } else {
1161
+ // Horizontal fold: scalar.op(extract 0, extract 1, …, extract L-1).
1162
+ // Widening sum folds the 4 i32x4 PARTIALS, not the (narrow) data lanes.
1163
+ const foldLanes = widen ? 4 : lanes
1164
+ let horiz = [reduceEntry.extract, 0, ['local.get', simdAccName]]
1165
+ for (let k = 1; k < foldLanes; k++) {
1166
+ horiz = [opName, horiz, [reduceEntry.extract, k, ['local.get', simdAccName]]]
1167
+ }
1168
+ // Merge the SIMD result into the live accumulator. For canon (min/max) the
1169
+ // merged value is re-canonicalized so a NaN that surfaced only in the SIMD
1170
+ // range still crosses as the canonical NaN when the scalar tail is empty.
1171
+ const merged = [opName, ['local.get', accName], horiz]
1172
+ mergeStmts = canonC == null
1173
+ ? [['local.set', accName, merged]]
1174
+ : [['local.set', accName, merged],
1175
+ ['local.set', accName,
1176
+ ['select', canonC, ['local.get', accName],
1177
+ [`${laneType}.ne`, ['local.get', accName], ['local.get', accName]]]]]
1178
+ }
1179
+ // Overshoot-safe SIMD bound: stop while a full `lanes`-wide load stays in
1180
+ // range, for ANY induction start (the min/max idiom seeds m=a[0] and starts
1181
+ // at i=1, which `& ~(lanes-1)` masking would run one lane past the end). For
1182
+ // a lane-aligned start this yields the same iteration set as masking; the
1183
+ // scalar tail (original `i<bound` guard) cleans up regardless.
1184
+ const boundSetup = ['local.set', simdBoundName, ['i32.sub', boundExpr, ['i32.const', lanes - 1]]]
1185
+
1186
+ // Narrow-widened entries seed the vector acc with a LANE-domain neutral (e.g.
1187
+ // 0 for u8-max) — only neutral once real lanes fold in. Guard the whole SIMD
1188
+ // prefix incl. the merge so a zero-iteration range can't clamp the live acc
1189
+ // toward the identity. Full-width entries use absolute neutrals; unguarded.
1190
+ const core = reduceEntry.accI32 || reduceEntry.accF64
1191
+ ? [['if', ['i32.lt_s', ['local.get', incVar], ['local.get', simdBoundName]],
1192
+ ['then', initAcc, simdBlock, ...mergeStmts]]]
1193
+ : [initAcc, simdBlock, ...mergeStmts]
1194
+ const wrapper = ['block', boundSetup, ...core, blockNode]
1195
+ const newLocalDecls = [
1196
+ ['local', simdBoundName, 'i32'],
1197
+ ['local', simdAccName, 'v128'],
1198
+ ...extraDecls,
1199
+ ]
1200
+ return { wrapper, newLocalDecls }
1201
+ }
1202
+
1203
+ // Scalar locals that are ALWAYS computed as `(i32.add base (i32.shl ind K))`
1204
+ // or aliased to such an address are "address tees", not lane data. They stay
1205
+ // scalar i32 in the lifted body.
1206
+ function _isAddressLocal(body, name, ind) {
1207
+ let onlyAsAddrTee = true
1208
+ let foundTee = false
1209
+ function walk(n) {
1210
+ if (!isArr(n)) return
1211
+ if (n[0] === 'local.tee' && n[1] === name) {
1212
+ foundTee = true
1213
+ // Check the value is a lane-address shape
1214
+ const m = matchLaneAddr(['local.tee', name, n[2]], ind)
1215
+ if (!m) onlyAsAddrTee = false
1216
+ return
1217
+ }
1218
+ if (n[0] === 'local.set' && n[1] === name) {
1219
+ // A set-not-tee: check value shape
1220
+ const m = matchLaneAddr(['local.tee', name, n[2]], ind)
1221
+ if (!m) onlyAsAddrTee = false
1222
+ foundTee = true
1223
+ return
1224
+ }
1225
+ for (let i = 1; i < n.length; i++) walk(n[i])
1226
+ }
1227
+ for (const s of body) walk(s)
1228
+ return foundTee && onlyAsAddrTee
1229
+ }
1230
+
1231
+ // ---- Lifter ----------------------------------------------------------------
1232
+
1233
+ function getOrAllocLanedLocal(name, ctx) {
1234
+ let r = ctx.newLanedLocals.get(name)
1235
+ if (!r) {
1236
+ r = { laneName: `${name}__v`, origName: name }
1237
+ ctx.newLanedLocals.set(name, r)
1238
+ }
1239
+ return r
1240
+ }
1241
+
1242
+ // Wrap an already-lifted v128 value `coreV` in per-lane NaN canonicalization:
1243
+ // v128.bitselect(splat(C), coreV, laneNe(coreV, coreV))
1244
+ // coreV is referenced three times. When it's a bare local.get (the common
1245
+ // flattened form, where the core was already hoisted to a temp) we share it
1246
+ // directly — matching the scalar select, which likewise reads the temp thrice.
1247
+ // Otherwise we materialize a fresh v128 temp so the core evaluates once.
1248
+ function liftCanon(coreV, C, ctx, info) {
1249
+ const laneNe = ctx.laneType === 'f32' ? 'f32x4.ne' : 'f64x2.ne'
1250
+ const splatC = [info.splat, C]
1251
+ if (isArr(coreV) && coreV[0] === 'local.get') {
1252
+ return ['v128.bitselect', splatC, coreV, [laneNe, coreV, coreV]]
1253
+ }
1254
+ const tmp = `$__canon${ctx.freshIdRef.next++}`
1255
+ ctx.extraLocals.push(['local', tmp, 'v128'])
1256
+ const g = ['local.get', tmp]
1257
+ return ['block', ['result', 'v128'],
1258
+ ['local.set', tmp, coreV],
1259
+ ['v128.bitselect', splatC, g, [laneNe, g, g]]]
1260
+ }
1261
+
1262
+ /** Lift a statement. Returns lifted stmt, or null to skip, or ['__seq__', ...] for multiple. */
1263
+ function liftStmt(stmt, ctx) {
1264
+ if (!isArr(stmt)) {
1265
+ // Bare strings like "drop" — produced by stack-form WAT. We unwrap value-blocks
1266
+ // separately so an isolated "drop" should not appear here, but tolerate it.
1267
+ if (stmt === 'drop') return null
1268
+ ctx.fail = true; return null
1269
+ }
1270
+ const op = stmt[0]
1271
+
1272
+ if (op === 'local.set' && typeof stmt[1] === 'string' && stmt.length === 3) {
1273
+ const name = stmt[1]
1274
+ const kind = ctx.localKind.get(name)
1275
+ if (kind === 'addr') {
1276
+ // Address-only local: lift the value as-is (it's i32 arithmetic on ind).
1277
+ return ['local.set', name, stmt[2]]
1278
+ }
1279
+ if (kind === 'lane') {
1280
+ const { laneName } = getOrAllocLanedLocal(name, ctx)
1281
+ const v = liftExprV(stmt[2], ctx)
1282
+ if (ctx.fail) return null
1283
+ return ['local.set', laneName, v]
1284
+ }
1285
+ ctx.fail = true; return null
1286
+ }
1287
+
1288
+ if (STORE_OPS[op]) {
1289
+ const simdStore = 'v128.store'
1290
+ const addr = stmt[1] // we leave addresses as-is (scalar i32 expressions)
1291
+ const val = liftExprV(stmt[2], ctx)
1292
+ if (ctx.fail) return null
1293
+ // Handle memarg if present (last positional after addr/val): unlikely in
1294
+ // pre-watr IR for this shape; bail if more than 3 children.
1295
+ if (stmt.length !== 3) { ctx.fail = true; return null }
1296
+ return [simdStore, addr, val]
1297
+ }
1298
+
1299
+ // (block (result T) STMTS... TAIL_EXPR) followed by sibling "drop" — we get
1300
+ // the block alone here; the "drop" is a separate sibling and is returned as
1301
+ // null by the next call. Strip the wrapper, lift the inner stmts; the
1302
+ // dropped-tail expr is discarded.
1303
+ if (op === 'block') {
1304
+ // Block may be: ['block', LABEL?, RESULT?, ...stmts]
1305
+ let i = 1
1306
+ if (typeof stmt[i] === 'string' && stmt[i].startsWith('$')) i++
1307
+ const hasResult = isArr(stmt[i]) && stmt[i][0] === 'result'
1308
+ if (hasResult) i++
1309
+ const inner = stmt.slice(i)
1310
+ const stmts = hasResult ? inner.slice(0, inner.length - 1) : inner
1311
+ const out = ['__seq__']
1312
+ for (const s of stmts) {
1313
+ const lifted = liftStmt(s, ctx)
1314
+ if (ctx.fail) return null
1315
+ if (lifted == null) continue
1316
+ if (Array.isArray(lifted) && lifted[0] === '__seq__') out.push(...lifted.slice(1))
1317
+ else out.push(lifted)
1318
+ }
1319
+ return out
1320
+ }
1321
+
1322
+ // Standalone expression-as-statement (e.g. a load that gets dropped) — bail.
1323
+ ctx.fail = true; return null
1324
+ }
1325
+
1326
+ /** Lift a value expression into v128 context. */
1327
+ function liftExprV(expr, ctx) {
1328
+ if (!isArr(expr)) { ctx.fail = true; return null }
1329
+ const op = expr[0]
1330
+ const info = LANE_INFO[ctx.laneType]
1331
+
1332
+ // Loads → v128.load (preserving address, including any local.tee).
1333
+ if (LOAD_OPS[op]) {
1334
+ if (LOAD_OPS[op] !== ctx.laneType) { ctx.fail = true; return null }
1335
+ return ['v128.load', expr[1]]
1336
+ }
1337
+
1338
+ // Constants → splat.
1339
+ if (op === info.constOp) {
1340
+ return [info.splat, expr]
1341
+ }
1342
+
1343
+ // local.get
1344
+ if (op === 'local.get' && typeof expr[1] === 'string') {
1345
+ const name = expr[1]
1346
+ const kind = ctx.localKind.get(name)
1347
+ if (kind === 'lane') {
1348
+ const { laneName } = getOrAllocLanedLocal(name, ctx)
1349
+ return ['local.get', laneName]
1350
+ }
1351
+ if (kind === 'invariant') {
1352
+ return [info.splat, ['local.get', name]]
1353
+ }
1354
+ if (kind === 'addr' || name === ctx.incVar) {
1355
+ ctx.fail = true; return null // can't be in a value position
1356
+ }
1357
+ ctx.fail = true; return null
1358
+ }
1359
+
1360
+ // Loop-invariant global (e.g. a hoistConstantPool'd const, or any global the
1361
+ // loop never writes) → splat. The recognizer bails when the body contains a
1362
+ // global.set, so every global.get reaching here is invariant across lanes.
1363
+ if (op === 'global.get' && typeof expr[1] === 'string') {
1364
+ return [info.splat, expr]
1365
+ }
1366
+
1367
+ // NaN-canonicalization wrapper (float lanes only; integer lanes never carry
1368
+ // it). Both the flattened `select` form and the un-flattened `block` form
1369
+ // lift to a per-lane v128.bitselect — canonical value in NaN lanes, X
1370
+ // elsewhere — exactly reproducing the scalar canonicalization lane-by-lane.
1371
+ if (ctx.laneType === 'f64' || ctx.laneType === 'f32') {
1372
+ if (op === 'select') {
1373
+ const m = matchCanonSelect(expr, ctx.laneType)
1374
+ if (!m) { ctx.fail = true; return null }
1375
+ const coreV = liftExprV(m.val, ctx)
1376
+ return ctx.fail ? null : liftCanon(coreV, m.C, ctx, info)
1377
+ }
1378
+ if (op === 'block') {
1379
+ const m = matchCanonBlock(expr, ctx.laneType)
1380
+ if (!m) { ctx.fail = true; return null }
1381
+ const coreV = liftExprV(m.core, ctx)
1382
+ return ctx.fail ? null : liftCanon(coreV, m.C, ctx, info)
1383
+ }
1384
+ }
1385
+
1386
+ // Conditional select — jz lowers `cond ? X : Y` to (if (result LT) COND (then X)
1387
+ // (else Y)). Lift to v128.bitselect(X, Y, mask), where mask is COND as an
1388
+ // all-ones/all-zeros lane comparison. Both branches are lane-pure (recursion
1389
+ // forbids stores/sets) and trap-free (no liftable op traps — int div/rem aren't
1390
+ // lane-pure), so speculatively evaluating both is safe; bitselect keeps the
1391
+ // chosen lane. The mask is hoisted to a temp and computed FIRST: bitselect
1392
+ // evaluates X,Y before its 3rd operand, but any address `local.tee` lives in
1393
+ // COND and must run before the branches read it (matching scalar order).
1394
+ if (op === 'if') {
1395
+ if (!isArr(expr[1]) || expr[1][0] !== 'result' || expr[1][1] !== ctx.laneType) { ctx.fail = true; return null }
1396
+ const thenN = expr[3], elseN = expr[4]
1397
+ if (!isArr(thenN) || thenN[0] !== 'then' || thenN.length !== 2) { ctx.fail = true; return null }
1398
+ if (!isArr(elseN) || elseN[0] !== 'else' || elseN.length !== 2) { ctx.fail = true; return null }
1399
+ let cond = expr[2]
1400
+ if (isArr(cond) && cond[0] === 'i32.ne' && isI32Const(cond[2]) && cond[2][1] === 0) cond = cond[1] // strip `!= 0`
1401
+ const cmpSimd = isArr(cond) && cond.length === 3 ? LANE_COMPARE[ctx.laneType]?.[cond[0]] : null
1402
+ if (!cmpSimd) { ctx.fail = true; return null }
1403
+ const ca = liftExprV(cond[1], ctx); if (ctx.fail) return null
1404
+ const cb = liftExprV(cond[2], ctx); if (ctx.fail) return null
1405
+ const x = liftExprV(thenN[1], ctx); if (ctx.fail) return null
1406
+ const y = liftExprV(elseN[1], ctx); if (ctx.fail) return null
1407
+ const mtmp = `$__mask${ctx.freshIdRef.next++}`
1408
+ ctx.extraLocals.push(['local', mtmp, 'v128'])
1409
+ return ['block', ['result', 'v128'],
1410
+ ['local.set', mtmp, [cmpSimd, ca, cb]],
1411
+ ['v128.bitselect', x, y, ['local.get', mtmp]]]
1412
+ }
1413
+
1414
+ // Lane-pure op?
1415
+ const table = LANE_PURE[ctx.laneType]
1416
+ const entry = table?.get(op)
1417
+ if (entry) {
1418
+ const a = liftExprV(expr[1], ctx)
1419
+ if (ctx.fail) return null
1420
+ if (entry.shamtScalar) {
1421
+ // Second operand stays scalar i32 — must be const or invariant local.
1422
+ const b = expr[2]
1423
+ if (!isI32Const(b) && !(isArr(b) && b[0] === 'local.get' && ctx.localKind.get(b[1]) === 'invariant')) {
1424
+ ctx.fail = true; return null
1425
+ }
1426
+ return [entry.simd, a, b]
1427
+ }
1428
+ if (expr.length === 2) { // unary (neg, abs, sqrt)
1429
+ return [entry.simd, a]
1430
+ }
1431
+ const b = liftExprV(expr[2], ctx)
1432
+ if (ctx.fail) return null
1433
+ return [entry.simd, a, b]
1434
+ }
1435
+
1436
+ ctx.fail = true; return null
1437
+ }
1438
+
1439
+ // ---- Induction-variable strength reduction --------------------------------
1440
+
1441
+ // Match `(i32.add (local.get $base) (i32.shl (local.get $ind) (i32.const K)))` in either
1442
+ // operand order, or `(i32.add (local.get $base) (local.get $ind))` (K=0). Returns
1443
+ // {base, k} — the address of element $ind in array $base, byte stride 1<<k — or null.
1444
+ function matchAffineAddr(node, ind) {
1445
+ if (!isArr(node) || node[0] !== 'i32.add' || node.length !== 3) return null
1446
+ const pair = (baseN, offN) => {
1447
+ if (!isLocalGet(baseN) || baseN[1] === ind) return null
1448
+ if (isLocalGet(offN, ind)) return { base: baseN[1], k: 0 }
1449
+ if (isArr(offN) && offN[0] === 'i32.shl' && offN.length === 3 && isLocalGet(offN[1], ind)) {
1450
+ const k = constNum(offN[2])
1451
+ if (k != null && k >= 0 && k <= 3) return { base: baseN[1], k }
1452
+ }
1453
+ return null
1454
+ }
1455
+ return pair(node[1], node[2]) || pair(node[2], node[1])
1456
+ }
1457
+
1458
+ /**
1459
+ * Strength-reduce induction-variable addressing in an affine loop the vectorizer
1460
+ * couldn't lift (an early `break`, a call, a non-lane body). For each loop-invariant
1461
+ * array `base` and shift `K`, every `base + (i<<K)` in the body is replaced by a strided
1462
+ * pointer `$p`, initialized to `base + (i<<K)` before the loop and bumped by `1<<K` in
1463
+ * lockstep with `i`. Drops the per-iteration shift+add — V8 does NOT strength-reduce this
1464
+ * itself (measured ~6% faster, the additive keep-`i` form used here). Canonical shape only
1465
+ * (single +1 IV, bottom increment, br_if exit). Bails if the body writes `i` or any `base`
1466
+ * (not invariant), or branches to the loop label (which would skip the pointer bump — a
1467
+ * `br_if` to the *block* label, i.e. an early break, is fine: the loop is exiting). Runs
1468
+ * only where the vectorizer runs (speed levels), so it never grows the size-tuned build.
1469
+ */
1470
+ function tryStrengthReduceIV(blockNode, fnLocals, freshIdRef) {
1471
+ if (!isArr(blockNode) || blockNode[0] !== 'block') return null
1472
+ let blockLabel = null, loopNode = null
1473
+ for (let i = 1; i < blockNode.length; i++) {
1474
+ const c = blockNode[i]
1475
+ if (typeof c === 'string' && c.startsWith('$') && blockLabel == null && i === 1) { blockLabel = c; continue }
1476
+ if (isArr(c) && c[0] === 'loop') { if (loopNode) return null; loopNode = c }
1477
+ else if (isArr(c)) return null
1478
+ }
1479
+ if (!loopNode || !blockLabel) return null
1480
+ const loopLabel = typeof loopNode[1] === 'string' && loopNode[1].startsWith('$') ? loopNode[1] : null
1481
+ if (!loopLabel) return null
1482
+ const endIdx = loopNode.length - 1
1483
+ if (!(isArr(loopNode[endIdx]) && loopNode[endIdx][0] === 'br' && loopNode[endIdx][1] === loopLabel)) return null
1484
+ const incIdx = endIdx - 1
1485
+ const incVar = matchInc1(loopNode[incIdx])
1486
+ if (!incVar) return null
1487
+ const exitInfo = matchExitBrIf(loopNode[2], blockLabel)
1488
+ if (!exitInfo || exitInfo.ind !== incVar) return null
1489
+
1490
+ // Scan the body (stmts between the exit br_if and the bottom increment): collect
1491
+ // affine-address sites, track every written local, and bail on a loop-label branch.
1492
+ const sites = [] // { parent, idx, base, k }
1493
+ const written = new Set()
1494
+ let bail = false
1495
+ const scan = (node, parent, pi) => {
1496
+ if (bail || !isArr(node)) return
1497
+ const op = node[0]
1498
+ if ((op === 'br' || op === 'br_if') && node[1] === loopLabel) { bail = true; return }
1499
+ if ((op === 'local.set' || op === 'local.tee') && typeof node[1] === 'string') written.add(node[1])
1500
+ const m = matchAffineAddr(node, incVar)
1501
+ if (m) sites.push({ parent, idx: pi, base: m.base, k: m.k })
1502
+ for (let i = 1; i < node.length; i++) scan(node[i], node, i)
1503
+ }
1504
+ for (let i = 3; i < incIdx; i++) scan(loopNode[i], loopNode, i)
1505
+ if (bail || !sites.length || written.has(incVar)) return null
1506
+
1507
+ // Group by (base, k); keep only loop-invariant i32 bases.
1508
+ const groups = new Map() // `base|k` → { base, k, sites }
1509
+ for (const s of sites) {
1510
+ if (written.has(s.base) || fnLocals.get(s.base) !== 'i32') continue
1511
+ const key = s.base + '|' + s.k
1512
+ let g = groups.get(key)
1513
+ if (!g) groups.set(key, g = { base: s.base, k: s.k, sites: [] })
1514
+ g.sites.push(s)
1515
+ }
1516
+ if (!groups.size) return null
1517
+
1518
+ // One strided pointer per group: init before the block, bump after the i increment,
1519
+ // every matched address → (local.get $p).
1520
+ const id = freshIdRef.next++
1521
+ const preInits = [], bumps = [], newLocalDecls = []
1522
+ let gi = 0
1523
+ for (const g of groups.values()) {
1524
+ const p = `$__iv${id}_${gi++}`
1525
+ newLocalDecls.push(['local', p, 'i32'])
1526
+ const off = g.k === 0 ? ['local.get', incVar] : ['i32.shl', ['local.get', incVar], ['i32.const', g.k]]
1527
+ preInits.push(['local.set', p, ['i32.add', ['local.get', g.base], off]])
1528
+ bumps.push(['local.set', p, ['i32.add', ['local.get', p], ['i32.const', 1 << g.k]]])
1529
+ for (const s of g.sites) s.parent[s.idx] = ['local.get', p]
1530
+ }
1531
+ loopNode.splice(incIdx + 1, 0, ...bumps) // after the induction increment, before the br
1532
+ return { wrapper: ['block', ...preInits, blockNode], newLocalDecls }
1533
+ }
1534
+
1535
+ // ---- memory.copy / memory.fill loop idioms ---------------------------------
1536
+
1537
+ // Same-width store←load pairs (byte-window moves) and their element stride.
1538
+ // Sign-variant narrow loads are interchangeable for a MOVE: load8_u/load8_s
1539
+ // then store8 write the same byte back.
1540
+ const MEMOP_STORES = {
1541
+ 'f64.store': { k: 3, loads: new Set(['f64.load']) },
1542
+ 'i64.store': { k: 3, loads: new Set(['i64.load']) },
1543
+ 'f32.store': { k: 2, loads: new Set(['f32.load']) },
1544
+ 'i32.store': { k: 2, loads: new Set(['i32.load']) },
1545
+ 'i32.store16': { k: 1, loads: new Set(['i32.load16_u', 'i32.load16_s']) },
1546
+ 'i32.store8': { k: 0, loads: new Set(['i32.load8_u', 'i32.load8_s']) },
1547
+ }
1548
+
1549
+ /**
1550
+ * Replace whole copy/fill loops with the engine's bulk-memory ops:
1551
+ *
1552
+ * for (i < N) a[i] = b[i] → memory.copy (overlap-guarded)
1553
+ * for (i < N) a[i] = 0 → memory.fill 0 (any element width)
1554
+ * for (i < N) u8[i] = C → memory.fill C (byte stores only)
1555
+ *
1556
+ * V8 lowers memory.copy/fill to memmove/memset — typically several times the
1557
+ * throughput of even a SIMD lane loop, and a handful of bytes instead of one.
1558
+ * Runs BEFORE the lane vectorizer so these loops never pay the lift.
1559
+ *
1560
+ * Exactness:
1561
+ * - COPY moves the same byte window the scalar loop wrote (same-width
1562
+ * store←load pairs only — see MEMOP_STORES; f64 load/store round-trips are
1563
+ * bit-exact per spec, so NaN payloads survive). memory.copy is memmove
1564
+ * (as-if-buffered); the FORWARD loop differs from that exactly when the
1565
+ * destination starts strictly inside the source window (dst reads bytes an
1566
+ * earlier iteration already overwrote), so that case keeps the original
1567
+ * loop behind a two-compare runtime guard — every other layout (disjoint,
1568
+ * dst ≤ src, same array) is bit-identical.
1569
+ * - FILL with 0 is width-agnostic (all-zero bytes; −0.0 is excluded — its
1570
+ * sign bit is not zero). Non-zero fills only for byte stores with a
1571
+ * loop-invariant constant ∈ [0,255].
1572
+ * - The induction variable ends at `bound` exactly as the loop would leave
1573
+ * it; a zero-trip range (i ≥ bound) leaves everything untouched.
1574
+ * - In-bounds for the same reason the lane vectorizer's wide loads are: the
1575
+ * moved window is exactly the byte range the scalar iterations touch.
1576
+ */
1577
+ function tryMemCopyFill(blockNode, fnLocals, freshIdRef) {
1578
+ if (!isArr(blockNode) || blockNode[0] !== 'block') return null
1579
+ let blockLabel = null, loopNode = null
1580
+ for (let i = 1; i < blockNode.length; i++) {
1581
+ const c = blockNode[i]
1582
+ if (typeof c === 'string' && c.startsWith('$') && blockLabel == null && i === 1) { blockLabel = c; continue }
1583
+ if (isArr(c) && c[0] === 'loop') { if (loopNode) return null; loopNode = c }
1584
+ else if (isArr(c)) return null
1585
+ }
1586
+ if (!loopNode || !blockLabel) return null
1587
+ const loopLabel = typeof loopNode[1] === 'string' && loopNode[1].startsWith('$') ? loopNode[1] : null
1588
+ if (!loopLabel) return null
1589
+ // Shape: [loop, $l, boundExit, (set,)? store, inc, br] — 1- or 2-statement body.
1590
+ if (loopNode.length !== 6 && loopNode.length !== 7) return null
1591
+ const endIdx = loopNode.length - 1
1592
+ if (!(isArr(loopNode[endIdx]) && loopNode[endIdx][0] === 'br' && loopNode[endIdx][1] === loopLabel)) return null
1593
+ const incVar = matchInc1(loopNode[endIdx - 1])
1594
+ if (!incVar) return null
1595
+ const exitInfo = matchExitBrIf(loopNode[2], blockLabel)
1596
+ if (!exitInfo || exitInfo.ind !== incVar) return null
1597
+ const bound = exitInfo.bound
1598
+ if (!(isI32Const(bound) || (isArr(bound) && bound[0] === 'local.get' && typeof bound[1] === 'string' && bound[1] !== incVar))) return null
1599
+
1600
+ // Body shapes (emit produces the two-statement form with a temp + shared
1601
+ // offset tee; fold/propagate sometimes collapse it to the bare store):
1602
+ // 1-stmt: (T.store DADDR CONST | (T.load SADDR))
1603
+ // 2-stmt: (local.set $t (T.load SADDR)) (T.store DADDR (local.get $t))
1604
+ const addrLocals = new Map(), offsetTees = new Map()
1605
+ const laneAddr = (a) => {
1606
+ const m = matchLaneAddr(a, incVar, addrLocals, offsetTees)
1607
+ if (!m || m.viaLocal) return null
1608
+ if (m.offsetTeeName) offsetTees.set(m.offsetTeeName, m.strideLog2)
1609
+ if (!(isArr(m.base) && m.base[0] === 'local.get' && typeof m.base[1] === 'string' && m.base[1] !== incVar)) return null
1610
+ if (fnLocals.get(m.base[1]) !== 'i32') return null
1611
+ return m
1612
+ }
1613
+ let storeStmt, valNode, tempName = null
1614
+ if (loopNode.length === 6) {
1615
+ storeStmt = loopNode[3]
1616
+ if (!isArr(storeStmt) || storeStmt.length !== 3) return null
1617
+ valNode = storeStmt[2]
1618
+ } else {
1619
+ const s1 = loopNode[3]
1620
+ storeStmt = loopNode[4]
1621
+ if (!isArr(s1) || s1[0] !== 'local.set' || s1.length !== 3 || typeof s1[1] !== 'string') return null
1622
+ if (!isArr(storeStmt) || storeStmt.length !== 3) return null
1623
+ if (!(isArr(storeStmt[2]) && storeStmt[2][0] === 'local.get' && storeStmt[2][1] === s1[1])) return null
1624
+ tempName = s1[1]
1625
+ if (tempName === incVar) return null
1626
+ valNode = s1[2]
1627
+ }
1628
+ const entry = MEMOP_STORES[storeStmt[0]]
1629
+ if (!entry) return null
1630
+
1631
+ // Classify VALUE first (the load side carries the offset tee in the 2-stmt form).
1632
+ let fillByte = null, srcM = null
1633
+ if (isArr(valNode) && valNode.length === 2 && (valNode[0] === 'i32.const' || valNode[0] === 'i64.const' || valNode[0] === 'f64.const' || valNode[0] === 'f32.const') && typeof valNode[1] === 'number') {
1634
+ if (valNode[1] === 0 && !Object.is(valNode[1], -0)) fillByte = 0
1635
+ else if (storeStmt[0] === 'i32.store8' && valNode[0] === 'i32.const' && Number.isInteger(valNode[1]) && valNode[1] >= 0 && valNode[1] <= 255) fillByte = valNode[1]
1636
+ else return null
1637
+ } else if (isArr(valNode) && valNode.length === 2 && entry.loads.has(valNode[0])) {
1638
+ srcM = laneAddr(valNode[1])
1639
+ if (!srcM || srcM.strideLog2 !== entry.k) return null
1640
+ } else return null
1641
+
1642
+ const dstM = laneAddr(storeStmt[1])
1643
+ if (!dstM || dstM.strideLog2 !== entry.k) return null
1644
+ // Soundness for any shared offset tee resolved via `(local.get $T)` (see tryVectorize).
1645
+ for (const [name, k] of offsetTees) {
1646
+ if (_offsetLocalStride([loopNode[3], storeStmt], name, incVar) !== k) return null
1647
+ }
1648
+
1649
+ const id = freshIdRef.next++
1650
+ const lenB = `$__mc${id}_len`, dstA = `$__mc${id}_dst`
1651
+ const newLocalDecls = [['local', lenB, 'i32'], ['local', dstA, 'i32']]
1652
+ const shl = (x) => entry.k === 0 ? x : ['i32.shl', x, ['i32.const', entry.k]]
1653
+ const boundC = () => cloneNode(bound)
1654
+ const setup = [
1655
+ ['local.set', lenB, shl(['i32.sub', boundC(), ['local.get', incVar]])],
1656
+ ['local.set', dstA, ['i32.add', ['local.get', dstM.base[1]], shl(['local.get', incVar])]],
1657
+ ]
1658
+ // Exact loop-exit state: i ends at bound; a matched offset tee holds the
1659
+ // LAST iteration's offset; the value temp holds the last element moved.
1660
+ const finish = [['local.set', incVar, boundC()]]
1661
+ const lastOff = () => shl(['i32.sub', boundC(), ['i32.const', 1]])
1662
+ for (const name of offsetTees.keys()) finish.push(['local.set', name, lastOff()])
1663
+ if (tempName != null && srcM) finish.push(['local.set', tempName,
1664
+ [valNode[0], ['i32.add', ['local.get', srcM.base[1]], lastOff()]]])
1665
+ if (tempName != null && srcM == null) finish.push(['local.set', tempName, cloneNode(valNode)])
1666
+
1667
+ let action
1668
+ if (srcM == null) {
1669
+ action = [['memory.fill', ['local.get', dstA], ['i32.const', fillByte], ['local.get', lenB]], ...finish]
1670
+ } else {
1671
+ const srcA = `$__mc${id}_src`
1672
+ newLocalDecls.push(['local', srcA, 'i32'])
1673
+ setup.push(['local.set', srcA, ['i32.add', ['local.get', srcM.base[1]], shl(['local.get', incVar])]])
1674
+ // Forward-loop ≡ memmove unless dst starts strictly inside (src, src+len).
1675
+ action = [['if',
1676
+ ['i32.or',
1677
+ ['i32.le_u', ['local.get', dstA], ['local.get', srcA]],
1678
+ ['i32.ge_u', ['local.get', dstA], ['i32.add', ['local.get', srcA], ['local.get', lenB]]]],
1679
+ ['then',
1680
+ ['memory.copy', ['local.get', dstA], ['local.get', srcA], ['local.get', lenB]],
1681
+ ...finish],
1682
+ ['else', blockNode]]]
1683
+ }
1684
+
1685
+ const wrapper = ['block',
1686
+ ['if', ['i32.lt_s', ['local.get', incVar], boundC()],
1687
+ ['then', ...setup, ...action]]]
1688
+ return { wrapper, newLocalDecls }
1689
+ }
1690
+
1691
+ // ---- Byte-scan (memchr) vectorization -------------------------------------
1692
+
1693
+ // Match a single-byte compare against a constant or a loop-invariant target:
1694
+ // (f64.eq|ne (f64.convert_i32_u|s (i32.load8_u|s (base + i))) TARGET) [value-model form]
1695
+ // (i32.eq|ne (i32.load8_u|s (base + i)) TARGET) [folded i32 form]
1696
+ // TARGET is a const byte or a `local.get` (the `memchr(buf, delim)` runtime case).
1697
+ // Returns { base, eq, isF64, c, targetLocal } — exactly one of c (∈[0,255]) / targetLocal set.
1698
+ function matchByteCompare(node, ind) {
1699
+ if (!isArr(node) || node.length !== 3) return null
1700
+ let eq
1701
+ if (node[0] === 'f64.eq' || node[0] === 'i32.eq') eq = true
1702
+ else if (node[0] === 'f64.ne' || node[0] === 'i32.ne') eq = false
1703
+ else return null
1704
+ const isF64 = node[0][0] === 'f'
1705
+ const constOf = (x) => isF64
1706
+ ? (isArr(x) && x[0] === 'f64.const' && typeof x[1] === 'number' && Number.isInteger(x[1]) ? x[1] : null)
1707
+ : constNum(x)
1708
+ // Identify which operand is the byte load and which is the target.
1709
+ const isLoadSide = (x) => {
1710
+ let l = x
1711
+ if (isF64) { if (!(isArr(l) && (l[0] === 'f64.convert_i32_u' || l[0] === 'f64.convert_i32_s') && l.length === 2)) return null; l = l[1] }
1712
+ if (!(isArr(l) && (l[0] === 'i32.load8_u' || l[0] === 'i32.load8_s') && l.length === 2)) return null
1713
+ const m = matchAffineAddr(l[1], ind)
1714
+ return m && m.k === 0 ? m.base : null // byte stride only
1715
+ }
1716
+ let base = isLoadSide(node[1]), target = node[2]
1717
+ if (base == null) { base = isLoadSide(node[2]); target = node[1] }
1718
+ if (base == null) return null
1719
+ const c = constOf(target)
1720
+ if (c != null) return c >= 0 && c <= 255 ? { base, eq, isF64, c } : null
1721
+ // Runtime target: a loop-invariant local (the minimal scan body writes only `i`).
1722
+ if (isArr(target) && target[0] === 'local.get' && typeof target[1] === 'string' && target[1] !== ind)
1723
+ return { base, eq, isF64, targetLocal: target[1] }
1724
+ return null
1725
+ }
1726
+
1727
+ /**
1728
+ * SIMD byte scan — vectorize a memchr-shaped loop the engine runs one byte at a time.
1729
+ * Recognizes the pure scan
1730
+ * (block $b (loop $l (br_if $b (eqz (i<bound))) (br_if $b (buf[i] ==/!= C)) (i := i+1) (br $l)))
1731
+ * — "find the first index where buf[i] (Uint8/Int8Array) ==/!= a constant byte" — and
1732
+ * rewrites it to scan 16 bytes per step with `i8x16.eq` + `i8x16.bitmask`, locating the
1733
+ * exact first match via `i32.ctz`, with the original loop kept as the <16-byte tail.
1734
+ * Measured ~8× over the scalar scan on V8 (which doesn't auto-vectorize it). Fails closed:
1735
+ * any deviation from the exact shape leaves the scalar loop. The 16-wide `v128.load` is
1736
+ * in-bounds because it only fires while `i+16 <= bound` and `bound` bounds the scalar
1737
+ * reads too. (charCodeAt over a jz string is out of scope — it lowers to per-char
1738
+ * bounds/SSO/heap/decode branches, not a flat byte load.)
1739
+ */
1740
+ function tryByteScan(blockNode, fnLocals, freshIdRef) {
1741
+ if (!isArr(blockNode) || blockNode[0] !== 'block') return null
1742
+ let blockLabel = null, loopNode = null
1743
+ for (let i = 1; i < blockNode.length; i++) {
1744
+ const c = blockNode[i]
1745
+ if (typeof c === 'string' && c.startsWith('$') && blockLabel == null && i === 1) { blockLabel = c; continue }
1746
+ if (isArr(c) && c[0] === 'loop') { if (loopNode) return null; loopNode = c }
1747
+ else if (isArr(c)) return null
1748
+ }
1749
+ if (!loopNode || !blockLabel) return null
1750
+ const loopLabel = typeof loopNode[1] === 'string' && loopNode[1].startsWith('$') ? loopNode[1] : null
1751
+ if (!loopLabel) return null
1752
+ // Exact shape: [loop, $l, boundExit, matchExit, inc, br] — nothing else.
1753
+ if (loopNode.length !== 6) return null
1754
+ if (!(isArr(loopNode[5]) && loopNode[5][0] === 'br' && loopNode[5][1] === loopLabel)) return null
1755
+ const incVar = matchInc1(loopNode[4])
1756
+ if (!incVar) return null
1757
+ const exitInfo = matchExitBrIf(loopNode[2], blockLabel) // (br_if $b (eqz (i < bound)))
1758
+ if (!exitInfo || exitInfo.ind !== incVar) return null
1759
+ const matchExit = loopNode[3]
1760
+ if (!(isArr(matchExit) && matchExit[0] === 'br_if' && matchExit[1] === blockLabel && matchExit.length === 3)) return null
1761
+ const bc = matchByteCompare(matchExit[2], incVar)
1762
+ if (!bc) return null
1763
+ if (fnLocals.get(bc.base) !== 'i32' || bc.base === incVar) return null
1764
+
1765
+ const id = freshIdRef.next++
1766
+ const sd = `$__bscan_brk${id}`, sl = `$__bscan_loop${id}`, mask = `$__bscan_m${id}`
1767
+ const baseGet = ['local.get', bc.base]
1768
+ const iGet = ['local.get', incVar]
1769
+ const bound = exitInfo.bound
1770
+ const newLocalDecls = [['local', mask, 'i32']]
1771
+
1772
+ // The byte to splat across 16 lanes, plus a runtime guard. A constant needs none.
1773
+ // A runtime `delim` (f64-boxed) is only a valid SIMD target when it's an integer in
1774
+ // [0,255]; outside that, NO byte (0–255) equals it, so the scalar tail — which we keep —
1775
+ // reproduces the exact result. The guard makes that branch explicit; cb caches the byte.
1776
+ let splat, guard = null, cbInit = null
1777
+ if (bc.c != null) {
1778
+ splat = ['i32.const', bc.c]
1779
+ } else {
1780
+ const cb = `$__bscan_c${id}`
1781
+ newLocalDecls.push(['local', cb, 'i32'])
1782
+ const tGet = ['local.get', bc.targetLocal]
1783
+ const inRange = ['i32.and', ['i32.ge_s', ['local.get', cb], ['i32.const', 0]], ['i32.le_s', ['local.get', cb], ['i32.const', 255]]]
1784
+ if (bc.isF64) {
1785
+ // cb = (i32)delim; valid iff it round-trips (delim is an integer) and is in [0,255].
1786
+ cbInit = ['local.set', cb, ['i32.trunc_sat_f64_s', cloneNode(tGet)]]
1787
+ guard = ['i32.and', ['f64.eq', ['f64.convert_i32_s', ['local.get', cb]], tGet], inRange]
1788
+ } else {
1789
+ // i32 delim: valid iff already in [0,255] (splat takes the low byte regardless).
1790
+ cbInit = ['local.set', cb, tGet]
1791
+ guard = inRange
1792
+ }
1793
+ splat = ['local.get', cb]
1794
+ }
1795
+
1796
+ // bitmask of the 16-lane eq; for `!=` flip the low 16 bits so ctz finds the first non-match.
1797
+ const eqMask = ['i8x16.bitmask', ['i8x16.eq', ['v128.load', ['i32.add', baseGet, iGet]], ['i8x16.splat', splat]]]
1798
+ const scanMask = bc.eq ? eqMask : ['i32.xor', eqMask, ['i32.const', 0xffff]]
1799
+ const simdBlock = ['block', sd,
1800
+ ['loop', sl,
1801
+ // Stop before a 16-wide load would pass `bound`; the scalar tail mops up the rest.
1802
+ ['br_if', sd, ['i32.gt_s', ['i32.add', iGet, ['i32.const', 16]], cloneNode(bound)]],
1803
+ ['local.set', mask, scanMask],
1804
+ ['if', ['local.get', mask],
1805
+ ['then',
1806
+ ['local.set', incVar, ['i32.add', iGet, ['i32.ctz', ['local.get', mask]]]],
1807
+ ['br', blockLabel]]],
1808
+ ['local.set', incVar, ['i32.add', iGet, ['i32.const', 16]]],
1809
+ ['br', sl]
1810
+ ]
1811
+ ]
1812
+ // Const target: SIMD then scalar tail. Runtime target: cache cb, guard the SIMD, tail.
1813
+ const pre = guard ? [cbInit, ['if', guard, ['then', simdBlock]]] : [simdBlock]
1814
+ return {
1815
+ wrapper: ['block', blockLabel, ...pre, loopNode],
1816
+ newLocalDecls,
1817
+ }
1818
+ }
1819
+
1820
+ const cloneNode = (n) => Array.isArray(n) ? n.map(cloneNode) : n
1821
+
1822
+ // ---- Pass entry ------------------------------------------------------------
1823
+
1824
+ /**
1825
+ * Walk a function looking for vectorizable (block (loop)) pairs, in-place.
1826
+ * Adds new locals to the function header.
1827
+ */
1828
+ export function vectorizeLaneLocal(fn) {
1829
+ if (!isArr(fn) || fn[0] !== 'func') return
1830
+ const bodyStart = findBodyStart(fn)
1831
+ if (bodyStart < 0) return
1832
+
1833
+ // Build local-name → wasm-type map.
1834
+ const fnLocals = new Map()
1835
+ for (let i = 2; i < bodyStart; i++) {
1836
+ const d = fn[i]
1837
+ if (isArr(d) && d[0] === 'local' && typeof d[1] === 'string' && typeof d[2] === 'string') {
1838
+ fnLocals.set(d[1], d[2])
1839
+ } else if (isArr(d) && d[0] === 'param' && typeof d[1] === 'string' && typeof d[2] === 'string') {
1840
+ fnLocals.set(d[1], d[2])
1841
+ }
1842
+ }
1843
+
1844
+ const freshIdRef = { next: 0 }
1845
+ const newLocalDeclsAll = []
1846
+
1847
+ vectorizeStraightLineF64DotPairsIn(fn, fnLocals, freshIdRef, newLocalDeclsAll)
1848
+
1849
+ // Walk body recursively. Process inner-most matches first (post-order)
1850
+ // so we don't try to vectorize an outer loop whose inner is the lane-local one.
1851
+ function walk(parent, idx) {
1852
+ const node = parent[idx]
1853
+ if (!isArr(node)) return
1854
+ for (let i = 0; i < node.length; i++) {
1855
+ if (isArr(node[i])) walk(node, i)
1856
+ }
1857
+ if (node[0] === 'block') {
1858
+ const r = tryMemCopyFill(node, fnLocals, freshIdRef)
1859
+ ?? tryVectorize(node, fnLocals, freshIdRef)
1860
+ ?? tryReduceVectorize(node, fnLocals, freshIdRef)
1861
+ ?? tryByteScan(node, fnLocals, freshIdRef)
1862
+ ?? tryStrengthReduceIV(node, fnLocals, freshIdRef)
1863
+ if (r) {
1864
+ parent[idx] = r.wrapper
1865
+ newLocalDeclsAll.push(...r.newLocalDecls)
1866
+ }
1867
+ }
1868
+ }
1869
+ for (let i = bodyStart; i < fn.length; i++) walk(fn, i)
1870
+
1871
+ if (newLocalDeclsAll.length) {
1872
+ fn.splice(bodyStart, 0, ...newLocalDeclsAll)
1873
+ }
1874
+ }