jz 0.1.1 → 0.2.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.
@@ -0,0 +1,1016 @@
1
+ /**
2
+ * Lane-local SIMD-128 vectorizer.
3
+ *
4
+ * Recognizes inner loops of shape:
5
+ * for (let i = 0; i < N; i++) arr[i] = f(arr[i], …)
6
+ * where every body op is "lane-pure" — its k-th lane output depends only
7
+ * on k-th lane inputs. Lifts the body to SIMD-128, prefixed before the
8
+ * original (now tail) loop. Original loop runs the remainder.
9
+ *
10
+ * Design:
11
+ * • Lane-purity is a structural property, not a benchmark match. The op
12
+ * whitelist is the single source of truth (one entry per (lane-type, op)).
13
+ * • Lift is mechanical. The recognizer either matches the structure — in
14
+ * which case lifting is unambiguous — or skips. No bench-specific
15
+ * heuristics.
16
+ * • Tail loop is the original WAT, untouched. If anything regresses the
17
+ * SIMD recognizer just doesn't match, never miscompiles.
18
+ *
19
+ * Match conditions:
20
+ * 1. (block $brk (loop $L (br_if $brk !cond) BODY (i = i+1) (br $L)))
21
+ * 2. cond is `(i32.lt_s i BOUND)` or `i32.lt_u`; BOUND is loop-invariant.
22
+ * 3. All loads/stores in BODY use address `(add base (shl i K))` where
23
+ * base is loop-invariant and K matches the elem stride. Optional
24
+ * enclosing `local.tee` is allowed (and reused).
25
+ * 4. All loads share the same opcode → defines lane type.
26
+ * 5. All other ops in BODY are in the lane-pure whitelist for that type.
27
+ * 6. Each non-induction local in BODY is either purely loop-invariant
28
+ * (only read) or purely lane-local (first action is a write). Never
29
+ * both — that's a loop-carried scalar (reduction / stencil) → bail.
30
+ *
31
+ * Lift produces, before the original block:
32
+ * (local.set $__simd_bound{N} (i32.and BOUND (i32.const ~(LANES-1))))
33
+ * (block $__simd_brk{N}
34
+ * (loop $__simd_loop{N}
35
+ * (br_if $__simd_brk{N} (i32.eqz (i32.lt_s i $__simd_bound{N})))
36
+ * <body lifted op-by-op; lane-local locals routed to v128 shadows>
37
+ * (local.set $i (i32.add i (i32.const LANES)))
38
+ * (br $__simd_loop{N})))
39
+ *
40
+ * The original block runs immediately after with i pre-advanced; its own
41
+ * `i < BOUND` guard handles the tail.
42
+ */
43
+
44
+ // Local copy of findBodyStart (mirrors optimize.js): index of first non-decl
45
+ // child in a (func ...) form — past type/param/result/local declarations.
46
+ function findBodyStart(fn) {
47
+ for (let i = 2; i < fn.length; i++) {
48
+ const c = fn[i]
49
+ if (!Array.isArray(c)) continue
50
+ if (c[0] === 'export' || c[0] === 'import' || c[0] === 'type' ||
51
+ c[0] === 'param' || c[0] === 'result' || c[0] === 'local') continue
52
+ return i
53
+ }
54
+ return fn.length
55
+ }
56
+
57
+ const isArr = Array.isArray
58
+
59
+ const exprEq = (a, b) => JSON.stringify(a) === JSON.stringify(b)
60
+ const localGetName = n => isArr(n) && n[0] === 'local.get' && typeof n[1] === 'string' ? n[1] : null
61
+ const f64Zero = n => isArr(n) && n[0] === 'f64.const' && Number(n[1]) === 0
62
+
63
+ const matchF64MulLocals = n => {
64
+ if (!isArr(n) || n[0] !== 'f64.mul') return null
65
+ const a = localGetName(n[1])
66
+ const b = localGetName(n[2])
67
+ return a && b ? [a, b] : null
68
+ }
69
+
70
+ const matchAccumStep = (n, acc) => {
71
+ if (!isArr(n) || n[0] !== 'local.set' || n[1] !== acc) return null
72
+ const e = n[2]
73
+ if (!isArr(e) || e[0] !== 'f64.add') return null
74
+ if (localGetName(e[1]) === acc) return matchF64MulLocals(e[2])
75
+ if (localGetName(e[2]) === acc) return matchF64MulLocals(e[1])
76
+ return null
77
+ }
78
+
79
+ const matchDotStore = (n, acc) => {
80
+ if (!isArr(n) || n[0] !== 'local.set' || typeof n[1] !== 'string') return null
81
+ const e = n[2]
82
+ if (localGetName(e) === acc) return { out: n[1], addend: null }
83
+ if (!isArr(e) || e[0] !== 'f64.add') return null
84
+ if (localGetName(e[1]) === acc) return { out: n[1], addend: e[2] }
85
+ if (localGetName(e[2]) === acc) return { out: n[1], addend: e[1] }
86
+ return null
87
+ }
88
+
89
+ const matchF64DotSeq = (stmts, i) => {
90
+ const reset = stmts[i]
91
+ if (!isArr(reset) || reset[0] !== 'local.set' || typeof reset[1] !== 'string' || !f64Zero(reset[2])) return null
92
+ const acc = reset[1]
93
+ const left = [], right = []
94
+ for (let k = 0; k < 4; k++) {
95
+ const pair = matchAccumStep(stmts[i + 1 + k], acc)
96
+ if (!pair) return null
97
+ left.push(pair[0])
98
+ right.push(pair[1])
99
+ }
100
+ const store = matchDotStore(stmts[i + 5], acc)
101
+ return store ? { end: i + 6, acc, left, right, ...store } : null
102
+ }
103
+
104
+ const f64x2Pair = (lo, hi) => ['f64x2.replace_lane', 1, ['f64x2.splat', ['local.get', lo]], ['local.get', hi]]
105
+
106
+ const dotPairExpr = (a, pairs) => {
107
+ let expr = ['f64x2.mul', ['f64x2.splat', ['local.get', a[0]]], pairs[0]]
108
+ for (let i = 1; i < 4; i++) {
109
+ expr = ['f64x2.add', expr, ['f64x2.mul', ['f64x2.splat', ['local.get', a[i]]], pairs[i]]]
110
+ }
111
+ return expr
112
+ }
113
+
114
+ const vectorizeStraightLineF64DotPairsIn = (node, fnLocals, freshIdRef, newLocalDecls) => {
115
+ if (!isArr(node)) return
116
+ for (let i = 0; i < node.length; i++) {
117
+ const child = node[i]
118
+ if (isArr(child)) vectorizeStraightLineF64DotPairsIn(child, fnLocals, freshIdRef, newLocalDecls)
119
+ }
120
+ const addendTemps = new Map()
121
+ const pairTemps = new Map()
122
+ for (let i = 0; i < node.length;) {
123
+ const a = matchF64DotSeq(node, i)
124
+ if (!a) { i++; continue }
125
+ const b = matchF64DotSeq(node, a.end)
126
+ if (!b || a.acc !== b.acc || !exprEq(a.left, b.left) || !exprEq(a.addend, b.addend) ||
127
+ fnLocals.get(a.out) !== 'f64' || fnLocals.get(b.out) !== 'f64') {
128
+ i++
129
+ continue
130
+ }
131
+ const v = `$__dot2_${freshIdRef.next++}`
132
+ newLocalDecls.push(['local', v, 'v128'])
133
+ fnLocals.set(v, 'v128')
134
+ let prefix = []
135
+ let addend = a.addend
136
+ if (addend) {
137
+ const key = JSON.stringify(addend)
138
+ let tmp = addendTemps.get(key)
139
+ if (!tmp) {
140
+ tmp = `$__dotadd_${freshIdRef.next++}`
141
+ addendTemps.set(key, tmp)
142
+ newLocalDecls.push(['local', tmp, 'f64'])
143
+ fnLocals.set(tmp, 'f64')
144
+ prefix = [['local.set', tmp, addend]]
145
+ }
146
+ addend = ['local.get', tmp]
147
+ }
148
+ const pairs = []
149
+ for (let k = 0; k < 4; k++) {
150
+ const key = `${a.right[k]}\0${b.right[k]}`
151
+ let tmp = pairTemps.get(key)
152
+ if (!tmp) {
153
+ tmp = `$__dotpair_${freshIdRef.next++}`
154
+ pairTemps.set(key, tmp)
155
+ newLocalDecls.push(['local', tmp, 'v128'])
156
+ fnLocals.set(tmp, 'v128')
157
+ prefix.push(['local.set', tmp, f64x2Pair(a.right[k], b.right[k])])
158
+ }
159
+ pairs.push(['local.get', tmp])
160
+ }
161
+ const dot = dotPairExpr(a.left, pairs)
162
+ const expr = addend ? ['f64x2.add', dot, ['f64x2.splat', addend]] : dot
163
+ node.splice(i, b.end - i,
164
+ ...prefix,
165
+ ['local.set', v, expr],
166
+ ['local.set', a.out, ['f64x2.extract_lane', 0, ['local.get', v]]],
167
+ ['local.set', b.out, ['f64x2.extract_lane', 1, ['local.get', v]]],
168
+ )
169
+ i += prefix.length + 3
170
+ }
171
+ }
172
+
173
+ // ---- Lane type tables ------------------------------------------------------
174
+
175
+ const LANE_INFO = {
176
+ i8: { lanes: 16, strideLog2: 0, stride: 1, splat: 'i8x16.splat', constOp: 'i32.const' },
177
+ i16: { lanes: 8, strideLog2: 1, stride: 2, splat: 'i16x8.splat', constOp: 'i32.const' },
178
+ i32: { lanes: 4, strideLog2: 2, stride: 4, splat: 'i32x4.splat', constOp: 'i32.const' },
179
+ i64: { lanes: 2, strideLog2: 3, stride: 8, splat: 'i64x2.splat', constOp: 'i64.const' },
180
+ f32: { lanes: 4, strideLog2: 2, stride: 4, splat: 'f32x4.splat', constOp: 'f32.const' },
181
+ f64: { lanes: 2, strideLog2: 3, stride: 8, splat: 'f64x2.splat', constOp: 'f64.const' },
182
+ }
183
+
184
+ // Narrow loads/stores (i32.load8_u etc.) define i8 / i16 lane types — values
185
+ // computed in i32 then truncated by store{8,16}, which matches i{8,16}xN wrap
186
+ // semantics exactly.
187
+ const LOAD_OPS = {
188
+ 'i32.load8_u': 'i8', 'i32.load8_s': 'i8',
189
+ 'i32.load16_u': 'i16','i32.load16_s': 'i16',
190
+ 'i32.load': 'i32', 'i64.load': 'i64', 'f32.load': 'f32', 'f64.load': 'f64',
191
+ }
192
+ const STORE_OPS = {
193
+ 'i32.store8': 'i8', 'i32.store16': 'i16',
194
+ 'i32.store': 'i32', 'i64.store': 'i64', 'f32.store': 'f32', 'f64.store': 'f64',
195
+ }
196
+
197
+ // scalar op → SIMD op. shamtScalar:true means second operand stays scalar i32.
198
+ //
199
+ // For i8/i16 lanes the SCALAR ops are i32.* — wasm has no native i8/i16 ops,
200
+ // values flow as i32 and the trailing store{8,16} truncates. i{8,16}x{N}.add
201
+ // wraps within each lane the same way, so the observable result matches.
202
+ // Note: wasm SIMD has no i8x16.mul, so multiplication on byte arrays bails.
203
+ const LANE_PURE = {
204
+ // Right shifts intentionally omitted for narrow lanes: scalar emits
205
+ // i32.shr_{s,u} on a load8/load16 i32 (zero- or sign-extended), while
206
+ // i{8,16}x{N}.shr_{s,u} treats lanes as their narrow type. The two diverge
207
+ // when load and shift signedness mismatch (e.g. load8_u + shr_s on byte
208
+ // 0xFF: scalar=0x7F, SIMD=0xFF). Safe set excludes shr_*.
209
+ i8: new Map([
210
+ ['i32.add', { simd: 'i8x16.add' }],
211
+ ['i32.sub', { simd: 'i8x16.sub' }],
212
+ ['i32.and', { simd: 'v128.and' }],
213
+ ['i32.or', { simd: 'v128.or' }],
214
+ ['i32.xor', { simd: 'v128.xor' }],
215
+ ['i32.shl', { simd: 'i8x16.shl', shamtScalar: true }],
216
+ ]),
217
+ i16: new Map([
218
+ ['i32.add', { simd: 'i16x8.add' }],
219
+ ['i32.sub', { simd: 'i16x8.sub' }],
220
+ ['i32.mul', { simd: 'i16x8.mul' }],
221
+ ['i32.and', { simd: 'v128.and' }],
222
+ ['i32.or', { simd: 'v128.or' }],
223
+ ['i32.xor', { simd: 'v128.xor' }],
224
+ ['i32.shl', { simd: 'i16x8.shl', shamtScalar: true }],
225
+ ]),
226
+ i32: new Map([
227
+ ['i32.add', { simd: 'i32x4.add' }],
228
+ ['i32.sub', { simd: 'i32x4.sub' }],
229
+ ['i32.mul', { simd: 'i32x4.mul' }],
230
+ ['i32.and', { simd: 'v128.and' }],
231
+ ['i32.or', { simd: 'v128.or' }],
232
+ ['i32.xor', { simd: 'v128.xor' }],
233
+ ['i32.shl', { simd: 'i32x4.shl', shamtScalar: true }],
234
+ ['i32.shr_s', { simd: 'i32x4.shr_s', shamtScalar: true }],
235
+ ['i32.shr_u', { simd: 'i32x4.shr_u', shamtScalar: true }],
236
+ ]),
237
+ i64: new Map([
238
+ ['i64.add', { simd: 'i64x2.add' }],
239
+ ['i64.sub', { simd: 'i64x2.sub' }],
240
+ ['i64.mul', { simd: 'i64x2.mul' }],
241
+ ['i64.and', { simd: 'v128.and' }],
242
+ ['i64.or', { simd: 'v128.or' }],
243
+ ['i64.xor', { simd: 'v128.xor' }],
244
+ ['i64.shl', { simd: 'i64x2.shl', shamtScalar: true }],
245
+ ['i64.shr_s', { simd: 'i64x2.shr_s', shamtScalar: true }],
246
+ ['i64.shr_u', { simd: 'i64x2.shr_u', shamtScalar: true }],
247
+ ]),
248
+ f32: new Map([
249
+ ['f32.add', { simd: 'f32x4.add' }],
250
+ ['f32.sub', { simd: 'f32x4.sub' }],
251
+ ['f32.mul', { simd: 'f32x4.mul' }],
252
+ ['f32.div', { simd: 'f32x4.div' }],
253
+ ['f32.min', { simd: 'f32x4.min' }],
254
+ ['f32.max', { simd: 'f32x4.max' }],
255
+ ['f32.neg', { simd: 'f32x4.neg' }],
256
+ ['f32.abs', { simd: 'f32x4.abs' }],
257
+ ['f32.sqrt', { simd: 'f32x4.sqrt' }],
258
+ ]),
259
+ f64: new Map([
260
+ ['f64.add', { simd: 'f64x2.add' }],
261
+ ['f64.sub', { simd: 'f64x2.sub' }],
262
+ ['f64.mul', { simd: 'f64x2.mul' }],
263
+ ['f64.div', { simd: 'f64x2.div' }],
264
+ ['f64.min', { simd: 'f64x2.min' }],
265
+ ['f64.max', { simd: 'f64x2.max' }],
266
+ ['f64.neg', { simd: 'f64x2.neg' }],
267
+ ['f64.abs', { simd: 'f64x2.abs' }],
268
+ ['f64.sqrt', { simd: 'f64x2.sqrt' }],
269
+ ]),
270
+ }
271
+
272
+ // Horizontal reductions: associative+commutative ops applied to one
273
+ // loop-carried accumulator. Each entry maps the SCALAR op (which is also
274
+ // the op used to combine the SIMD result back into the accumulator at the
275
+ // end) to its SIMD lane op, lane extractor, and identity element.
276
+ //
277
+ // Floats (add) are not strictly associative — vectorized order produces
278
+ // ulp-level differences from scalar order. Acceptable for typical use
279
+ // (reductions over typed arrays of well-conditioned data); strict-equal
280
+ // callers must keep the pass off.
281
+ //
282
+ // Narrow lanes (i8/i16) intentionally absent: `s += a[i]` with a u8/u16
283
+ // load expands the value to i32 before the add, so the accumulator's lane
284
+ // type is always wider than the load's element type. That widening would
285
+ // require pairwise/extending-add ops (i16x8.extadd_pairwise_*) — separate
286
+ // recognizer.
287
+ const REDUCE_OPS = {
288
+ i32: {
289
+ 'i32.add': { simd: 'i32x4.add', extract: 'i32x4.extract_lane', laneType: 'i32', constNode: ['i32.const', 0] },
290
+ 'i32.xor': { simd: 'v128.xor', extract: 'i32x4.extract_lane', laneType: 'i32', constNode: ['i32.const', 0] },
291
+ 'i32.and': { simd: 'v128.and', extract: 'i32x4.extract_lane', laneType: 'i32', constNode: ['i32.const', -1] },
292
+ 'i32.or': { simd: 'v128.or', extract: 'i32x4.extract_lane', laneType: 'i32', constNode: ['i32.const', 0] },
293
+ },
294
+ i64: {
295
+ 'i64.add': { simd: 'i64x2.add', extract: 'i64x2.extract_lane', laneType: 'i64', constNode: ['i64.const', 0] },
296
+ 'i64.xor': { simd: 'v128.xor', extract: 'i64x2.extract_lane', laneType: 'i64', constNode: ['i64.const', 0] },
297
+ 'i64.and': { simd: 'v128.and', extract: 'i64x2.extract_lane', laneType: 'i64', constNode: ['i64.const', -1] },
298
+ 'i64.or': { simd: 'v128.or', extract: 'i64x2.extract_lane', laneType: 'i64', constNode: ['i64.const', 0] },
299
+ },
300
+ f32: {
301
+ 'f32.add': { simd: 'f32x4.add', extract: 'f32x4.extract_lane', laneType: 'f32', constNode: ['f32.const', 0] },
302
+ },
303
+ f64: {
304
+ 'f64.add': { simd: 'f64x2.add', extract: 'f64x2.extract_lane', laneType: 'f64', constNode: ['f64.const', 0] },
305
+ },
306
+ }
307
+
308
+ // op-name → REDUCE entry across all lane types (the op-name itself encodes
309
+ // the lane type prefix, e.g. `i32.add` ⇒ i32 lanes).
310
+ const REDUCE_OP_LOOKUP = (() => {
311
+ const m = new Map()
312
+ for (const lt of Object.keys(REDUCE_OPS))
313
+ for (const op of Object.keys(REDUCE_OPS[lt]))
314
+ m.set(op, REDUCE_OPS[lt][op])
315
+ return m
316
+ })()
317
+
318
+ // ---- Recognizer ------------------------------------------------------------
319
+
320
+ function isLocalGet(node, name) {
321
+ return isArr(node) && node[0] === 'local.get' && (name == null || node[1] === name)
322
+ }
323
+ function isI32Const(node) {
324
+ return isArr(node) && node[0] === 'i32.const'
325
+ }
326
+ function constNum(node) {
327
+ if (!isI32Const(node)) return null
328
+ const v = node[1]
329
+ return typeof v === 'number' ? v : (typeof v === 'string' ? parseInt(v, 10) : null)
330
+ }
331
+
332
+ /**
333
+ * Match increment shape `(local.set $X (i32.add (local.get $X) (i32.const 1)))`.
334
+ * Returns $X or null.
335
+ */
336
+ function matchInc1(stmt) {
337
+ if (!isArr(stmt) || stmt[0] !== 'local.set' || stmt.length !== 3) return null
338
+ const x = stmt[1]
339
+ const v = stmt[2]
340
+ if (!isArr(v) || v[0] !== 'i32.add' || v.length !== 3) return null
341
+ if (!isLocalGet(v[1], x)) return null
342
+ if (constNum(v[2]) !== 1) return null
343
+ return x
344
+ }
345
+
346
+ /**
347
+ * Match `(br_if $LABEL (i32.eqz (i32.lt_{s,u} (local.get $I) BOUND)))`.
348
+ * Returns { ind, bound } or null.
349
+ */
350
+ function matchExitBrIf(stmt, label) {
351
+ if (!isArr(stmt) || stmt[0] !== 'br_if' || stmt[1] !== label) return null
352
+ const cond = stmt[2]
353
+ if (!isArr(cond) || cond[0] !== 'i32.eqz') return null
354
+ const cmp = cond[1]
355
+ if (!isArr(cmp) || (cmp[0] !== 'i32.lt_s' && cmp[0] !== 'i32.lt_u')) return null
356
+ if (!isLocalGet(cmp[1])) return null
357
+ return { ind: cmp[1][1], bound: cmp[2] }
358
+ }
359
+
360
+ /**
361
+ * Walk node, collect set of local names that are written via local.set/local.tee
362
+ * anywhere within. Used to detect loop-invariant locals.
363
+ */
364
+ function collectWrites(node, out) {
365
+ if (!isArr(node)) return
366
+ const op = node[0]
367
+ if ((op === 'local.set' || op === 'local.tee') && typeof node[1] === 'string') {
368
+ out.add(node[1])
369
+ }
370
+ for (let i = 0; i < node.length; i++) collectWrites(node[i], out)
371
+ }
372
+
373
+ /**
374
+ * Return the FIRST kind of access for `name` in straight-line walk order.
375
+ * 'write' — local.set/local.tee seen first
376
+ * 'read' — local.get seen first
377
+ * null — not referenced
378
+ */
379
+ function firstAccess(node, name) {
380
+ if (!isArr(node)) return null
381
+ const op = node[0]
382
+ // Walk children first — operands evaluate before the op. For local.set/tee
383
+ // the VALUE child (idx 2) runs before the write, so a `local.get name` in
384
+ // the value of `local.set name` is a read-before-write.
385
+ if ((op === 'local.set' || op === 'local.tee') && node[1] === name) {
386
+ if (node.length >= 3) {
387
+ const r = firstAccess(node[2], name)
388
+ if (r) return r
389
+ }
390
+ return 'write'
391
+ }
392
+ if (op === 'local.get' && node[1] === name) return 'read'
393
+ for (let i = 1; i < node.length; i++) {
394
+ const r = firstAccess(node[i], name)
395
+ if (r) return r
396
+ }
397
+ return null
398
+ }
399
+
400
+ /**
401
+ * Match an address expression `(i32.add base (i32.shl (local.get IND) (i32.const K)))`,
402
+ * with optional outer `(local.tee $A ...)`. Stride 1 fallback: `(i32.add base (local.get IND))`.
403
+ * Also accepts `(local.get $A)` when $A is a previously-recorded address tee.
404
+ *
405
+ * Returns { strideLog2, base, teeName?: string, viaLocal?: string } or null.
406
+ * `strideLog2` = K for i32.shl form, 0 for plain add form.
407
+ * `base` is the loop-invariant base subtree.
408
+ */
409
+ function matchLaneAddr(addr, ind, addrLocals) {
410
+ let teeName = null
411
+ let n = addr
412
+ // (local.get $A) where $A holds a previously-tee'd lane-address.
413
+ if (isArr(n) && n[0] === 'local.get' && typeof n[1] === 'string' && addrLocals && addrLocals.has(n[1])) {
414
+ const e = addrLocals.get(n[1])
415
+ return { strideLog2: e.strideLog2, base: e.base, teeName: null, viaLocal: n[1] }
416
+ }
417
+ if (isArr(n) && n[0] === 'local.tee' && n.length === 3) {
418
+ teeName = n[1]
419
+ n = n[2]
420
+ }
421
+ if (!isArr(n) || n[0] !== 'i32.add' || n.length !== 3) return null
422
+ const a = n[1], b = n[2]
423
+ // case 1: (i32.add base (i32.shl (local.get ind) (i32.const K)))
424
+ if (isArr(b) && b[0] === 'i32.shl' && b.length === 3 && isLocalGet(b[1], ind)) {
425
+ const k = constNum(b[2])
426
+ if (k != null && k >= 0 && k <= 3) return { strideLog2: k, base: a, teeName }
427
+ }
428
+ // case 2: (i32.add base (local.get ind)) — stride 1
429
+ if (isLocalGet(b, ind)) return { strideLog2: 0, base: a, teeName }
430
+ return null
431
+ }
432
+
433
+ // ---- Recognize a (block (loop)) pair --------------------------------------
434
+
435
+ /**
436
+ * Try to vectorize the inner loop. Returns the replacement node array
437
+ * (synthetic outer block) or null on no match.
438
+ */
439
+ function tryVectorize(blockNode, fnLocals, freshIdRef) {
440
+ if (!isArr(blockNode) || blockNode[0] !== 'block') return null
441
+ // Find label and inner loop.
442
+ let blockLabel = null
443
+ let loopIdx = -1, loopNode = null
444
+ for (let i = 1; i < blockNode.length; i++) {
445
+ const c = blockNode[i]
446
+ if (typeof c === 'string' && c.startsWith('$') && blockLabel == null && i === 1) {
447
+ blockLabel = c; continue
448
+ }
449
+ if (isArr(c) && c[0] === 'loop') {
450
+ if (loopNode) return null // multiple loops
451
+ loopIdx = i; loopNode = c
452
+ } else if (isArr(c)) {
453
+ return null // foreign content alongside the loop
454
+ }
455
+ }
456
+ if (!loopNode || !blockLabel) return null
457
+
458
+ // Loop layout: ['loop', '$label', ...stmts]
459
+ const loopLabel = typeof loopNode[1] === 'string' && loopNode[1].startsWith('$') ? loopNode[1] : null
460
+ if (!loopLabel) return null
461
+
462
+ // Find induction increment + back-branch at the END.
463
+ let endIdx = loopNode.length - 1
464
+ if (!(isArr(loopNode[endIdx]) && loopNode[endIdx][0] === 'br' && loopNode[endIdx][1] === loopLabel)) return null
465
+ const incIdx = endIdx - 1
466
+ const incVar = matchInc1(loopNode[incIdx])
467
+ if (!incVar) return null
468
+
469
+ // First stmt must be the exit br_if.
470
+ const exitInfo = matchExitBrIf(loopNode[2], blockLabel)
471
+ if (!exitInfo) return null
472
+ if (exitInfo.ind !== incVar) return null
473
+
474
+ // Body = stmts between exit and increment.
475
+ const body = []
476
+ for (let i = 3; i < incIdx; i++) body.push(loopNode[i])
477
+
478
+ // Bound must be loop-invariant. For now, accept (local.get $L) where $L
479
+ // is declared but not written inside the body, OR (i32.const N).
480
+ let bound = exitInfo.bound
481
+ let boundLocal = null
482
+ if (isArr(bound) && bound[0] === 'local.get' && typeof bound[1] === 'string') {
483
+ boundLocal = bound[1]
484
+ } else if (isI32Const(bound)) {
485
+ // ok
486
+ } else {
487
+ return null
488
+ }
489
+
490
+ // Detect lane type from the FIRST load in body.
491
+ let laneType = null
492
+ let stride = -1
493
+ const loadStoreSites = [] // {parent, idx, kind:'load'|'store'}
494
+ // Address tees: name → {strideLog2, base}. A `(local.tee NAME (lane-addr))`
495
+ // both validates the load's address AND records NAME so the matching store's
496
+ // `(local.get NAME)` is accepted as the same lane address.
497
+ const addrLocals = new Map()
498
+
499
+ function scanForLoadsStores(node, parent, pi) {
500
+ if (!isArr(node)) return true
501
+ const op = node[0]
502
+ if (LOAD_OPS[op]) {
503
+ if (laneType == null) {
504
+ laneType = LOAD_OPS[op]
505
+ stride = LANE_INFO[laneType].stride
506
+ } else if (LOAD_OPS[op] !== laneType) {
507
+ return false
508
+ }
509
+ const m = matchLaneAddr(node[1], incVar, addrLocals)
510
+ if (!m) return false
511
+ if ((1 << m.strideLog2) !== stride) return false
512
+ if (m.teeName) addrLocals.set(m.teeName, { strideLog2: m.strideLog2, base: m.base })
513
+ loadStoreSites.push({ parent, idx: pi, kind: 'load' })
514
+ return true
515
+ }
516
+ if (STORE_OPS[op]) {
517
+ const sty = STORE_OPS[op]
518
+ if (laneType != null && sty !== laneType) return false
519
+ if (laneType == null) { laneType = sty; stride = LANE_INFO[laneType].stride }
520
+ const m = matchLaneAddr(node[1], incVar, addrLocals)
521
+ if (!m) return false
522
+ if ((1 << m.strideLog2) !== stride) return false
523
+ if (m.teeName) addrLocals.set(m.teeName, { strideLog2: m.strideLog2, base: m.base })
524
+ loadStoreSites.push({ parent, idx: pi, kind: 'store' })
525
+ // Recurse into VALUE child (idx 2) — it's data, not address.
526
+ if (!scanForLoadsStores(node[2], node, 2)) return false
527
+ return true
528
+ }
529
+ // local.set/tee of an address local outside a load/store context (e.g.
530
+ // `(local.set $a (i32.add base (i32.shl i 2)))` as a standalone stmt) —
531
+ // record so a later `(local.get $a)` resolves.
532
+ if ((op === 'local.set' || op === 'local.tee') && typeof node[1] === 'string' && node.length === 3) {
533
+ const valM = matchLaneAddr(['local.tee', node[1], node[2]], incVar, addrLocals)
534
+ if (valM && valM.teeName) {
535
+ addrLocals.set(valM.teeName, { strideLog2: valM.strideLog2, base: valM.base })
536
+ }
537
+ }
538
+ // Recurse into all children
539
+ for (let i = 1; i < node.length; i++) {
540
+ if (!scanForLoadsStores(node[i], node, i)) return false
541
+ }
542
+ return true
543
+ }
544
+ for (const stmt of body) {
545
+ if (!scanForLoadsStores(stmt, null, -1)) return null
546
+ }
547
+ if (!laneType) return null // no memory ops — vectorizing buys nothing
548
+ if (loadStoreSites.length === 0) return null
549
+
550
+ // Classify all locals referenced in body.
551
+ // - induction var (incVar): exempt
552
+ // - bound local (if any): must be invariant
553
+ // - each other local: first access must not be a read-then-written pattern
554
+ const writes = new Set()
555
+ for (const s of body) collectWrites(s, writes)
556
+ if (boundLocal && writes.has(boundLocal)) return null // bound varies in body → bail
557
+
558
+ const localKind = new Map() // name → 'lane' | 'invariant' | 'addr'
559
+ // Walk to collect ALL referenced names
560
+ const referenced = new Set()
561
+ const collectRefs = (n) => {
562
+ if (!isArr(n)) return
563
+ const op = n[0]
564
+ if ((op === 'local.get' || op === 'local.set' || op === 'local.tee') && typeof n[1] === 'string')
565
+ referenced.add(n[1])
566
+ for (let i = 1; i < n.length; i++) collectRefs(n[i])
567
+ }
568
+ for (const s of body) collectRefs(s)
569
+
570
+ for (const name of referenced) {
571
+ if (name === incVar) continue
572
+ if (writes.has(name)) {
573
+ // Must be lane-local: first access is a write.
574
+ let firstKind = null
575
+ for (const s of body) {
576
+ const k = firstAccess(s, name)
577
+ if (k) { firstKind = k; break }
578
+ }
579
+ if (firstKind === 'read') return null // loop-carried (reduction or stencil)
580
+ // Discriminate lane-data vs address-tee. Address tees hold i32 addresses,
581
+ // not vector data. We classify by checking the local's declared type.
582
+ const decl = fnLocals.get(name)
583
+ if (decl === 'i32' && _isAddressLocal(body, name, incVar)) {
584
+ localKind.set(name, 'addr')
585
+ } else {
586
+ localKind.set(name, 'lane')
587
+ }
588
+ } else {
589
+ localKind.set(name, 'invariant')
590
+ }
591
+ }
592
+
593
+ // Build lifted body. If anything fails to lift, bail.
594
+ const newLanedLocals = new Map() // origName → { laneName, simdType }
595
+ const ctx = { laneType, incVar, localKind, newLanedLocals, fail: false, failReason: null }
596
+ const lifted = []
597
+ for (const s of body) {
598
+ const r = liftStmt(s, ctx)
599
+ if (ctx.fail) return null
600
+ if (r != null) {
601
+ if (Array.isArray(r) && r[0] === '__seq__') lifted.push(...r.slice(1))
602
+ else lifted.push(r)
603
+ }
604
+ }
605
+ if (lifted.length === 0) return null
606
+
607
+ // Generate fresh names
608
+ const id = freshIdRef.next++
609
+ const simdBoundName = `$__simd_bound${id}`
610
+ const simdBrkLabel = `$__simd_brk${id}`
611
+ const simdLoopLabel = `$__simd_loop${id}`
612
+
613
+ const info = LANE_INFO[laneType]
614
+ const lanes = info.lanes
615
+ const mask = -lanes // bit pattern ~(lanes-1) in i32 two's complement
616
+
617
+ // Build SIMD prefix block.
618
+ const boundExpr = boundLocal
619
+ ? ['local.get', boundLocal]
620
+ : bound // i32.const N
621
+ const simdBlock = ['block', simdBrkLabel,
622
+ ['loop', simdLoopLabel,
623
+ ['br_if', simdBrkLabel,
624
+ ['i32.eqz', ['i32.lt_s', ['local.get', incVar], ['local.get', simdBoundName]]]],
625
+ ...lifted,
626
+ ['local.set', incVar,
627
+ ['i32.add', ['local.get', incVar], ['i32.const', lanes]]],
628
+ ['br', simdLoopLabel]
629
+ ]
630
+ ]
631
+
632
+ // Bound setup: simdBoundName = bound & ~(lanes-1)
633
+ const boundSetup = ['local.set', simdBoundName,
634
+ ['i32.and', boundExpr, ['i32.const', mask]]]
635
+
636
+ // Synthetic outer wrapper — has no result, no label, just sequences.
637
+ // The original block is preserved unchanged as the tail.
638
+ const wrapper = ['block', boundSetup, simdBlock, blockNode]
639
+
640
+ // Locals to add to function header.
641
+ const newLocalDecls = [
642
+ ['local', simdBoundName, 'i32'],
643
+ ...[...newLanedLocals.values()].map(({ laneName }) => ['local', laneName, 'v128'])
644
+ ]
645
+
646
+ return { wrapper, newLocalDecls }
647
+ }
648
+
649
+ // ---- Reduction recognizer -------------------------------------------------
650
+ //
651
+ // Matches inner loops of shape:
652
+ // for (let i = 0; i < N; i++) S = OP(S, EXPR(arr[i], ...))
653
+ // where OP is associative+commutative (REDUCE_OPS table) and EXPR is lane-
654
+ // pure (operates on the loaded element with at most loop-invariant data).
655
+ // S is a SCALAR loop-carried accumulator — exempt from the lane-local
656
+ // "first access must be a write" check.
657
+ //
658
+ // Lift:
659
+ // acc = splat(IDENTITY)
660
+ // for (i = 0; i < bound & ~(L-1); i += L) acc = OP_v(acc, lifted EXPR)
661
+ // S = OP(S, horizontal_reduce(acc))
662
+ // <original scalar tail handles the remainder>
663
+ //
664
+ // Float adds are not strictly associative — vectorized reduction differs
665
+ // from scalar reduction by ulps. Acceptable when bit-exact equality is not
666
+ // required (which it isn't, by spec, in JS engines either).
667
+ function tryReduceVectorize(blockNode, fnLocals, freshIdRef) {
668
+ if (!isArr(blockNode) || blockNode[0] !== 'block') return null
669
+
670
+ // Match outer (block (loop)) structure. Same loop-shape as tryVectorize.
671
+ let blockLabel = null
672
+ let loopNode = null
673
+ for (let i = 1; i < blockNode.length; i++) {
674
+ const c = blockNode[i]
675
+ if (typeof c === 'string' && c.startsWith('$') && blockLabel == null && i === 1) { blockLabel = c; continue }
676
+ if (isArr(c) && c[0] === 'loop') {
677
+ if (loopNode) return null
678
+ loopNode = c
679
+ } else if (isArr(c)) return null
680
+ }
681
+ if (!loopNode || !blockLabel) return null
682
+ const loopLabel = typeof loopNode[1] === 'string' && loopNode[1].startsWith('$') ? loopNode[1] : null
683
+ if (!loopLabel) return null
684
+ const endIdx = loopNode.length - 1
685
+ if (!(isArr(loopNode[endIdx]) && loopNode[endIdx][0] === 'br' && loopNode[endIdx][1] === loopLabel)) return null
686
+ const incIdx = endIdx - 1
687
+ const incVar = matchInc1(loopNode[incIdx])
688
+ if (!incVar) return null
689
+ const exitInfo = matchExitBrIf(loopNode[2], blockLabel)
690
+ if (!exitInfo) return null
691
+ if (exitInfo.ind !== incVar) return null
692
+
693
+ // Body must be a single statement: the accumulator update.
694
+ if (incIdx - 3 !== 1) return null
695
+ const stmt = loopNode[3]
696
+ if (!isArr(stmt) || stmt[0] !== 'local.set' || stmt.length !== 3) return null
697
+ const accName = stmt[1]
698
+ if (typeof accName !== 'string') return null
699
+ const rhs = stmt[2]
700
+ if (!isArr(rhs) || rhs.length !== 3) return null
701
+ const opName = rhs[0]
702
+ const reduceEntry = REDUCE_OP_LOOKUP.get(opName)
703
+ if (!reduceEntry) return null
704
+ if (!isLocalGet(rhs[1], accName)) return null
705
+ const exprNode = rhs[2]
706
+
707
+ // Accumulator's declared local type must match the lane element type.
708
+ const accType = fnLocals.get(accName)
709
+ if (accType !== reduceEntry.laneType) return null
710
+
711
+ // Bound classification (same as tryVectorize).
712
+ let bound = exitInfo.bound
713
+ let boundLocal = null
714
+ if (isArr(bound) && bound[0] === 'local.get' && typeof bound[1] === 'string') boundLocal = bound[1]
715
+ else if (!isI32Const(bound)) return null
716
+
717
+ // Scan EXPR for lane-aligned loads. Stores forbidden. Re-references of
718
+ // accName forbidden (the accumulator only appears in the outer wrapper).
719
+ const laneType = reduceEntry.laneType
720
+ const stride = LANE_INFO[laneType].stride
721
+ const addrLocals = new Map()
722
+ let loadCount = 0
723
+ function scanExpr(node) {
724
+ if (!isArr(node)) return true
725
+ const op = node[0]
726
+ if (LOAD_OPS[op]) {
727
+ if (LOAD_OPS[op] !== laneType) return false
728
+ const m = matchLaneAddr(node[1], incVar, addrLocals)
729
+ if (!m) return false
730
+ if ((1 << m.strideLog2) !== stride) return false
731
+ if (m.teeName) addrLocals.set(m.teeName, { strideLog2: m.strideLog2, base: m.base })
732
+ loadCount++
733
+ return true
734
+ }
735
+ if (STORE_OPS[op]) return false
736
+ if (op === 'local.set' || op === 'local.tee') return false // no intermediates
737
+ if (op === 'local.get' && node[1] === accName) return false
738
+ for (let i = 1; i < node.length; i++) if (!scanExpr(node[i])) return false
739
+ return true
740
+ }
741
+ if (!scanExpr(exprNode)) return null
742
+ if (loadCount === 0) return null
743
+
744
+ // Classify locals referenced in EXPR. Anything not the induction var or an
745
+ // address-tee is invariant (we forbade local.set/tee in scanExpr).
746
+ const referenced = new Set()
747
+ const collectRefs = (n) => {
748
+ if (!isArr(n)) return
749
+ if (n[0] === 'local.get' && typeof n[1] === 'string') referenced.add(n[1])
750
+ for (let i = 1; i < n.length; i++) collectRefs(n[i])
751
+ }
752
+ collectRefs(exprNode)
753
+ const localKind = new Map()
754
+ for (const name of referenced) {
755
+ if (name === incVar) continue
756
+ if (addrLocals.has(name)) { localKind.set(name, 'addr'); continue }
757
+ localKind.set(name, 'invariant')
758
+ }
759
+ for (const name of addrLocals.keys()) localKind.set(name, 'addr')
760
+
761
+ const ctx = { laneType, incVar, localKind, newLanedLocals: new Map(), fail: false, failReason: null }
762
+ const liftedExpr = liftExprV(exprNode, ctx)
763
+ if (ctx.fail) return null
764
+ if (ctx.newLanedLocals.size > 0) return null
765
+
766
+ // Synthesize SIMD prefix block + horizontal reduce + (preserved scalar tail).
767
+ const id = freshIdRef.next++
768
+ const simdBoundName = `$__simd_bound${id}`
769
+ const simdAccName = `$__simd_acc${id}`
770
+ const simdBrkLabel = `$__simd_brk${id}`
771
+ const simdLoopLabel = `$__simd_loop${id}`
772
+ const info = LANE_INFO[laneType]
773
+ const lanes = info.lanes
774
+ const mask = -lanes
775
+ const boundExpr = boundLocal ? ['local.get', boundLocal] : bound
776
+
777
+ const initAcc = ['local.set', simdAccName, [info.splat, reduceEntry.constNode]]
778
+ const simdBlock = ['block', simdBrkLabel,
779
+ ['loop', simdLoopLabel,
780
+ ['br_if', simdBrkLabel,
781
+ ['i32.eqz', ['i32.lt_s', ['local.get', incVar], ['local.get', simdBoundName]]]],
782
+ ['local.set', simdAccName,
783
+ [reduceEntry.simd, ['local.get', simdAccName], liftedExpr]],
784
+ ['local.set', incVar, ['i32.add', ['local.get', incVar], ['i32.const', lanes]]],
785
+ ['br', simdLoopLabel]
786
+ ]
787
+ ]
788
+
789
+ // Horizontal fold: scalar.op(extract 0, extract 1, …, extract L-1).
790
+ let horiz = [reduceEntry.extract, 0, ['local.get', simdAccName]]
791
+ for (let k = 1; k < lanes; k++) {
792
+ horiz = [opName, horiz, [reduceEntry.extract, k, ['local.get', simdAccName]]]
793
+ }
794
+ const mergeBack = ['local.set', accName, [opName, ['local.get', accName], horiz]]
795
+ const boundSetup = ['local.set', simdBoundName, ['i32.and', boundExpr, ['i32.const', mask]]]
796
+
797
+ const wrapper = ['block', boundSetup, initAcc, simdBlock, mergeBack, blockNode]
798
+ const newLocalDecls = [
799
+ ['local', simdBoundName, 'i32'],
800
+ ['local', simdAccName, 'v128'],
801
+ ]
802
+ return { wrapper, newLocalDecls }
803
+ }
804
+
805
+ // Scalar locals that are ALWAYS computed as `(i32.add base (i32.shl ind K))`
806
+ // or aliased to such an address are "address tees", not lane data. They stay
807
+ // scalar i32 in the lifted body.
808
+ function _isAddressLocal(body, name, ind) {
809
+ let onlyAsAddrTee = true
810
+ let foundTee = false
811
+ function walk(n) {
812
+ if (!isArr(n)) return
813
+ if (n[0] === 'local.tee' && n[1] === name) {
814
+ foundTee = true
815
+ // Check the value is a lane-address shape
816
+ const m = matchLaneAddr(['local.tee', name, n[2]], ind)
817
+ if (!m) onlyAsAddrTee = false
818
+ return
819
+ }
820
+ if (n[0] === 'local.set' && n[1] === name) {
821
+ // A set-not-tee: check value shape
822
+ const m = matchLaneAddr(['local.tee', name, n[2]], ind)
823
+ if (!m) onlyAsAddrTee = false
824
+ foundTee = true
825
+ return
826
+ }
827
+ for (let i = 1; i < n.length; i++) walk(n[i])
828
+ }
829
+ for (const s of body) walk(s)
830
+ return foundTee && onlyAsAddrTee
831
+ }
832
+
833
+ // ---- Lifter ----------------------------------------------------------------
834
+
835
+ function getOrAllocLanedLocal(name, ctx) {
836
+ let r = ctx.newLanedLocals.get(name)
837
+ if (!r) {
838
+ r = { laneName: `${name}__v`, origName: name }
839
+ ctx.newLanedLocals.set(name, r)
840
+ }
841
+ return r
842
+ }
843
+
844
+ /** Lift a statement. Returns lifted stmt, or null to skip, or ['__seq__', ...] for multiple. */
845
+ function liftStmt(stmt, ctx) {
846
+ if (!isArr(stmt)) {
847
+ // Bare strings like "drop" — produced by stack-form WAT. We unwrap value-blocks
848
+ // separately so an isolated "drop" should not appear here, but tolerate it.
849
+ if (stmt === 'drop') return null
850
+ ctx.fail = true; return null
851
+ }
852
+ const op = stmt[0]
853
+
854
+ if (op === 'local.set' && typeof stmt[1] === 'string' && stmt.length === 3) {
855
+ const name = stmt[1]
856
+ const kind = ctx.localKind.get(name)
857
+ if (kind === 'addr') {
858
+ // Address-only local: lift the value as-is (it's i32 arithmetic on ind).
859
+ return ['local.set', name, stmt[2]]
860
+ }
861
+ if (kind === 'lane') {
862
+ const { laneName } = getOrAllocLanedLocal(name, ctx)
863
+ const v = liftExprV(stmt[2], ctx)
864
+ if (ctx.fail) return null
865
+ return ['local.set', laneName, v]
866
+ }
867
+ ctx.fail = true; return null
868
+ }
869
+
870
+ if (STORE_OPS[op]) {
871
+ const simdStore = 'v128.store'
872
+ const addr = stmt[1] // we leave addresses as-is (scalar i32 expressions)
873
+ const val = liftExprV(stmt[2], ctx)
874
+ if (ctx.fail) return null
875
+ // Handle memarg if present (last positional after addr/val): unlikely in
876
+ // pre-watr IR for this shape; bail if more than 3 children.
877
+ if (stmt.length !== 3) { ctx.fail = true; return null }
878
+ return [simdStore, addr, val]
879
+ }
880
+
881
+ // (block (result T) STMTS... TAIL_EXPR) followed by sibling "drop" — we get
882
+ // the block alone here; the "drop" is a separate sibling and is returned as
883
+ // null by the next call. Strip the wrapper, lift the inner stmts; the
884
+ // dropped-tail expr is discarded.
885
+ if (op === 'block') {
886
+ // Block may be: ['block', LABEL?, RESULT?, ...stmts]
887
+ let i = 1
888
+ if (typeof stmt[i] === 'string' && stmt[i].startsWith('$')) i++
889
+ const hasResult = isArr(stmt[i]) && stmt[i][0] === 'result'
890
+ if (hasResult) i++
891
+ const inner = stmt.slice(i)
892
+ const stmts = hasResult ? inner.slice(0, inner.length - 1) : inner
893
+ const out = ['__seq__']
894
+ for (const s of stmts) {
895
+ const lifted = liftStmt(s, ctx)
896
+ if (ctx.fail) return null
897
+ if (lifted == null) continue
898
+ if (Array.isArray(lifted) && lifted[0] === '__seq__') out.push(...lifted.slice(1))
899
+ else out.push(lifted)
900
+ }
901
+ return out
902
+ }
903
+
904
+ // Standalone expression-as-statement (e.g. a load that gets dropped) — bail.
905
+ ctx.fail = true; return null
906
+ }
907
+
908
+ /** Lift a value expression into v128 context. */
909
+ function liftExprV(expr, ctx) {
910
+ if (!isArr(expr)) { ctx.fail = true; return null }
911
+ const op = expr[0]
912
+ const info = LANE_INFO[ctx.laneType]
913
+
914
+ // Loads → v128.load (preserving address, including any local.tee).
915
+ if (LOAD_OPS[op]) {
916
+ if (LOAD_OPS[op] !== ctx.laneType) { ctx.fail = true; return null }
917
+ return ['v128.load', expr[1]]
918
+ }
919
+
920
+ // Constants → splat.
921
+ if (op === info.constOp) {
922
+ return [info.splat, expr]
923
+ }
924
+
925
+ // local.get
926
+ if (op === 'local.get' && typeof expr[1] === 'string') {
927
+ const name = expr[1]
928
+ const kind = ctx.localKind.get(name)
929
+ if (kind === 'lane') {
930
+ const { laneName } = getOrAllocLanedLocal(name, ctx)
931
+ return ['local.get', laneName]
932
+ }
933
+ if (kind === 'invariant') {
934
+ return [info.splat, ['local.get', name]]
935
+ }
936
+ if (kind === 'addr' || name === ctx.incVar) {
937
+ ctx.fail = true; return null // can't be in a value position
938
+ }
939
+ ctx.fail = true; return null
940
+ }
941
+
942
+ // Lane-pure op?
943
+ const table = LANE_PURE[ctx.laneType]
944
+ const entry = table?.get(op)
945
+ if (entry) {
946
+ const a = liftExprV(expr[1], ctx)
947
+ if (ctx.fail) return null
948
+ if (entry.shamtScalar) {
949
+ // Second operand stays scalar i32 — must be const or invariant local.
950
+ const b = expr[2]
951
+ if (!isI32Const(b) && !(isArr(b) && b[0] === 'local.get' && ctx.localKind.get(b[1]) === 'invariant')) {
952
+ ctx.fail = true; return null
953
+ }
954
+ return [entry.simd, a, b]
955
+ }
956
+ if (expr.length === 2) { // unary (neg, abs, sqrt)
957
+ return [entry.simd, a]
958
+ }
959
+ const b = liftExprV(expr[2], ctx)
960
+ if (ctx.fail) return null
961
+ return [entry.simd, a, b]
962
+ }
963
+
964
+ ctx.fail = true; return null
965
+ }
966
+
967
+ // ---- Pass entry ------------------------------------------------------------
968
+
969
+ /**
970
+ * Walk a function looking for vectorizable (block (loop)) pairs, in-place.
971
+ * Adds new locals to the function header.
972
+ */
973
+ export function vectorizeLaneLocal(fn) {
974
+ if (!isArr(fn) || fn[0] !== 'func') return
975
+ const bodyStart = findBodyStart(fn)
976
+ if (bodyStart < 0) return
977
+
978
+ // Build local-name → wasm-type map.
979
+ const fnLocals = new Map()
980
+ for (let i = 2; i < bodyStart; i++) {
981
+ const d = fn[i]
982
+ if (isArr(d) && d[0] === 'local' && typeof d[1] === 'string' && typeof d[2] === 'string') {
983
+ fnLocals.set(d[1], d[2])
984
+ } else if (isArr(d) && d[0] === 'param' && typeof d[1] === 'string' && typeof d[2] === 'string') {
985
+ fnLocals.set(d[1], d[2])
986
+ }
987
+ }
988
+
989
+ const freshIdRef = { next: 0 }
990
+ const newLocalDeclsAll = []
991
+
992
+ vectorizeStraightLineF64DotPairsIn(fn, fnLocals, freshIdRef, newLocalDeclsAll)
993
+
994
+ // Walk body recursively. Process inner-most matches first (post-order)
995
+ // so we don't try to vectorize an outer loop whose inner is the lane-local one.
996
+ function walk(parent, idx) {
997
+ const node = parent[idx]
998
+ if (!isArr(node)) return
999
+ for (let i = 0; i < node.length; i++) {
1000
+ if (isArr(node[i])) walk(node, i)
1001
+ }
1002
+ if (node[0] === 'block') {
1003
+ const r = tryVectorize(node, fnLocals, freshIdRef)
1004
+ ?? tryReduceVectorize(node, fnLocals, freshIdRef)
1005
+ if (r) {
1006
+ parent[idx] = r.wrapper
1007
+ newLocalDeclsAll.push(...r.newLocalDecls)
1008
+ }
1009
+ }
1010
+ }
1011
+ for (let i = bodyStart; i < fn.length; i++) walk(fn, i)
1012
+
1013
+ if (newLocalDeclsAll.length) {
1014
+ fn.splice(bodyStart, 0, ...newLocalDeclsAll)
1015
+ }
1016
+ }