jz 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +99 -72
  2. package/bench/README.md +253 -100
  3. package/bench/bench.svg +58 -81
  4. package/cli.js +85 -12
  5. package/dist/interop.js +1 -0
  6. package/dist/jz.js +6196 -0
  7. package/index.d.ts +126 -0
  8. package/index.js +222 -35
  9. package/interop.js +240 -141
  10. package/layout.js +34 -18
  11. package/module/array.js +99 -111
  12. package/module/collection.js +124 -30
  13. package/module/console.js +1 -1
  14. package/module/core.js +163 -19
  15. package/module/json.js +3 -3
  16. package/module/math.js +162 -3
  17. package/module/number.js +268 -13
  18. package/module/object.js +37 -5
  19. package/module/regex.js +8 -7
  20. package/module/simd.js +37 -5
  21. package/module/string.js +203 -168
  22. package/module/typedarray.js +565 -112
  23. package/package.json +26 -7
  24. package/src/abi/string.js +29 -29
  25. package/src/ast.js +19 -2
  26. package/src/autoload.js +3 -0
  27. package/src/compile/analyze-scans.js +174 -11
  28. package/src/compile/analyze.js +101 -4
  29. package/src/compile/cse-load.js +200 -0
  30. package/src/compile/emit-assign.js +82 -20
  31. package/src/compile/emit.js +592 -51
  32. package/src/compile/index.js +504 -89
  33. package/src/compile/infer.js +36 -1
  34. package/src/compile/loop-divmod.js +109 -0
  35. package/src/compile/loop-model.js +91 -0
  36. package/src/compile/loop-square.js +102 -0
  37. package/src/compile/narrow.js +275 -41
  38. package/src/compile/peel-stencil.js +215 -0
  39. package/src/compile/plan/advise.js +55 -1
  40. package/src/compile/plan/common.js +29 -0
  41. package/src/compile/plan/index.js +8 -1
  42. package/src/compile/plan/inline.js +180 -22
  43. package/src/compile/plan/literals.js +313 -24
  44. package/src/compile/plan/scope.js +115 -39
  45. package/src/compile/program-facts.js +21 -2
  46. package/src/ctx.js +96 -19
  47. package/src/helper-counters.js +137 -0
  48. package/src/ir.js +157 -20
  49. package/src/kind-traits.js +34 -3
  50. package/src/kind.js +79 -4
  51. package/src/op-policy.js +5 -2
  52. package/src/ops.js +119 -0
  53. package/src/optimize/index.js +1274 -151
  54. package/src/optimize/recurse.js +182 -0
  55. package/src/optimize/vectorize.js +4187 -253
  56. package/src/prepare/index.js +75 -14
  57. package/src/prepare/lift-iife.js +149 -0
  58. package/src/reps.js +6 -2
  59. package/src/static.js +9 -0
  60. package/src/type.js +63 -51
  61. package/src/wat/assemble.js +286 -21
  62. package/src/widen.js +21 -0
  63. package/src/wat/optimize.js +0 -3760
@@ -1,3760 +0,0 @@
1
- /**
2
- * WAT AST optimizer — size/runtime passes over watr's s-expression IR.
3
- *
4
- * jz owns its optimizer; watr is used *only* as the WAT→binary encoder.
5
- * Pairs with src/optimize/ (jz-IR-level) — folder context disambiguates.
6
- *
7
- * @module wat/optimize
8
- */
9
-
10
- import compile from 'watr/compile'
11
-
12
- // Fixpoint round caps — empirical convergence bounds, not correctness limits.
13
- // Each pass only makes monotonic progress, so hitting a cap merely leaves a few
14
- // residual simplifications for the next compile rather than producing wrong output.
15
- const MAX_PROP_ROUNDS = 6 // forward-prop / set-get / tee fixpoint per scope
16
- const MAX_INLINE_ROUNDS = 16 // single-caller inline-chain depth (deep generated stdlib)
17
-
18
- // === WAT optimizer passes ===
19
-
20
- // — AST helpers (formerly watr/util.js) — every node is an s-expression
21
- // array `[head, ...args]`; non-arrays are immediates.
22
- const clone = (node) => Array.isArray(node) ? node.map(clone) : node
23
-
24
- /** Walk depth-first pre-order, read-only. fn(node, parent, idx). */
25
- const walk = (node, fn, parent, idx) => {
26
- fn(node, parent, idx)
27
- if (Array.isArray(node)) for (let i = 0; i < node.length; i++) walk(node[i], fn, node, i)
28
- }
29
-
30
- /** Walk depth-first post-order. fn may return a replacement node or mutate in place. */
31
- const walkPost = (node, fn, parent, idx) => {
32
- if (Array.isArray(node)) for (let i = 0; i < node.length; i++) walkPost(node[i], fn, node, i)
33
- const result = fn(node, parent, idx)
34
- if (result !== undefined && parent) parent[idx] = result
35
- return result !== undefined ? result : node
36
- }
37
-
38
- /** Result value type of an op from its name prefix (formerly watr/const.js).
39
- * Comparisons/eqz on scalar int/float collapse to i32; else the name prefix. */
40
- const resultType = (op) => {
41
- if (typeof op !== 'string') return null
42
- const dot = op.indexOf('.')
43
- if (dot < 0) return null
44
- const prefix = op.slice(0, dot)
45
- const scalar = prefix === 'i32' || prefix === 'i64' || prefix === 'f32' || prefix === 'f64'
46
- if (scalar && /^(eqz?|ne|[lg][te])(_[su])?$/.test(op.slice(dot + 1))) return 'i32'
47
- if (scalar || prefix === 'v128') return prefix
48
- if (op === 'memory.size' || op === 'memory.grow') return 'i32'
49
- return null
50
- }
51
-
52
- /**
53
- * Recursively count AST nodes — fast size heuristic without compiling.
54
- * @param {any} node
55
- * @returns {number}
56
- */
57
- const count = (node) => {
58
- if (!Array.isArray(node)) return 1
59
- let n = 1
60
- for (let i = 0; i < node.length; i++) n += count(node[i])
61
- return n
62
- }
63
-
64
- /**
65
- * Compile AST and measure binary size in bytes.
66
- * @param {Array} ast
67
- * @returns {number}
68
- */
69
- const binarySize = (ast) => {
70
- try { return compile(ast).length } catch { return Infinity }
71
- }
72
-
73
- /**
74
- * Fast structural equality of two AST nodes.
75
- * Stops at first difference. Handles BigInt without stringification.
76
- */
77
- const equal = (a, b) => {
78
- if (a === b) return true
79
- if (typeof a !== typeof b) return false
80
- if (typeof a === 'bigint') return a === b
81
- if (!Array.isArray(a) || !Array.isArray(b)) return false
82
- if (a.length !== b.length) return false
83
- for (let i = 0; i < a.length; i++) if (!equal(a[i], b[i])) return false
84
- return true
85
- }
86
-
87
- /**
88
- * Locate the parts of an `(if ...)` node:
89
- * condIdx → index of the condition expression
90
- * cond → the condition expression itself
91
- * thenBranch / elseBranch → the (then ...) / (else ...) sub-arrays, or null
92
- * The condition sits after any leading `param`/`result` annotations and before
93
- * the `then`/`else` arms.
94
- */
95
- const parseIf = (node) => {
96
- let condIdx = 1
97
- while (condIdx < node.length) {
98
- const c = node[condIdx]
99
- if (Array.isArray(c) && (c[0] === 'then' || c[0] === 'else' || c[0] === 'result' || c[0] === 'param')) {
100
- condIdx++
101
- continue
102
- }
103
- break
104
- }
105
- let thenBranch = null, elseBranch = null
106
- for (let i = condIdx + 1; i < node.length; i++) {
107
- const c = node[i]
108
- if (!Array.isArray(c)) continue
109
- if (c[0] === 'then') thenBranch = c
110
- else if (c[0] === 'else') elseBranch = c
111
- }
112
- return { condIdx, cond: node[condIdx], thenBranch, elseBranch }
113
- }
114
-
115
- // ==================== TREESHAKE ====================
116
-
117
- /**
118
- * Remove unused functions, globals, types, tables.
119
- * Keeps exports and their transitive dependencies.
120
- * @param {Array} ast
121
- * @returns {Array}
122
- */
123
- const treeshake = (ast) => {
124
- if (!Array.isArray(ast) || ast[0] !== 'module') return ast
125
-
126
- // Index spaces. Each entry is shared between its $name key and its numeric idx
127
- // key, so name/index lookups hit the same record. nodeMap covers reverse lookup
128
- // (used during the filtering pass for unnamed definitions).
129
- const funcs = new Map(), globals = new Map(), types = new Map()
130
- const tables = new Map(), memories = new Map()
131
- const nodeMap = new Map() // node → entry
132
-
133
- const register = (map, node, idx, isImport = false) => {
134
- const named = typeof node[1] === 'string' && node[1][0] === '$'
135
- const name = named ? node[1] : idx
136
- const inlineExported = !isImport && node.some(s => Array.isArray(s) && s[0] === 'export')
137
- const entry = { node, idx, used: inlineExported, isImport }
138
- map.set(name, entry)
139
- if (named) map.set(idx, entry)
140
- nodeMap.set(node, entry)
141
- return entry
142
- }
143
-
144
- let funcIdx = 0, globalIdx = 0, typeIdx = 0, tableIdx = 0, memIdx = 0
145
- const elems = [], data = [], exports = [], starts = []
146
-
147
- for (const node of ast.slice(1)) {
148
- if (!Array.isArray(node)) continue
149
- const kind = node[0]
150
- if (kind === 'type') register(types, node, typeIdx++)
151
- else if (kind === 'func') register(funcs, node, funcIdx++)
152
- else if (kind === 'global') register(globals, node, globalIdx++)
153
- else if (kind === 'table') register(tables, node, tableIdx++)
154
- else if (kind === 'memory') register(memories, node, memIdx++)
155
- else if (kind === 'import') {
156
- // Each import sub-item occupies its own slot in the relevant index space.
157
- for (const sub of node) {
158
- if (!Array.isArray(sub)) continue
159
- if (sub[0] === 'func') register(funcs, sub, funcIdx++, true)
160
- else if (sub[0] === 'global') register(globals, sub, globalIdx++, true)
161
- else if (sub[0] === 'table') register(tables, sub, tableIdx++, true)
162
- else if (sub[0] === 'memory') register(memories, sub, memIdx++, true)
163
- }
164
- }
165
- else if (kind === 'export') exports.push(node)
166
- else if (kind === 'start') starts.push(node)
167
- else if (kind === 'elem') elems.push(node)
168
- else if (kind === 'data') data.push(node)
169
- }
170
-
171
- // Worklist: function entries whose body still needs to be scanned for refs.
172
- const work = []
173
- const enqueue = (entry) => { if (entry && !entry.scanned) work.push(entry) }
174
- const markFunc = (ref) => {
175
- const e = funcs.get(ref); if (!e) return
176
- if (!e.used) e.used = true
177
- enqueue(e)
178
- }
179
- const markGlobal = (ref) => { const e = globals.get(ref); if (e) e.used = true }
180
- const markTable = (ref) => { const e = tables.get(ref); if (e) e.used = true }
181
- const markMemory = (ref) => { if (typeof ref === 'string' && ref[0] !== '$') ref = +ref; const e = memories.get(ref); if (e) e.used = true }
182
- const markType = (ref) => { const e = types.get(ref); if (e) e.used = true }
183
-
184
- // Roots: explicit exports, start funcs, elem-referenced funcs, inline-exported items.
185
- for (const exp of exports) {
186
- for (const sub of exp) {
187
- if (!Array.isArray(sub)) continue
188
- const [kind, ref] = sub
189
- if (kind === 'func') markFunc(ref)
190
- else if (kind === 'global') markGlobal(ref)
191
- else if (kind === 'table') markTable(ref)
192
- else if (kind === 'memory') markMemory(ref)
193
- }
194
- }
195
- for (const start of starts) {
196
- let ref = start[1]
197
- if (typeof ref === 'string' && ref[0] !== '$') ref = +ref
198
- markFunc(ref)
199
- }
200
- for (const elem of elems) {
201
- walk(elem, n => {
202
- if (Array.isArray(n) && n[0] === 'ref.func') markFunc(n[1])
203
- else if (typeof n === 'string' && n[0] === '$') markFunc(n)
204
- })
205
- }
206
- for (const d of data) {
207
- const first = d[1]
208
- if (Array.isArray(first) && first[0] === 'memory') markMemory(first[1])
209
- else if (typeof first === 'string' && first[0] === '$') markMemory(first)
210
- else if (Array.isArray(first)) markMemory(0)
211
- }
212
- for (const m of [funcs, globals, tables, memories]) for (const e of m.values()) if (e.used) enqueue(e)
213
-
214
- // If nothing anchors the module (no exports, start, elem, or inline exports),
215
- // assume the module is consumed elsewhere and keep everything.
216
- const hasAnchor = exports.length > 0 || starts.length > 0 || elems.length > 0 || work.length > 0
217
- if (!hasAnchor) {
218
- for (const m of [funcs, globals, tables, memories]) for (const e of m.values()) e.used = true
219
- return ast
220
- }
221
-
222
- // Drain worklist: each function body gets walked exactly once.
223
- while (work.length) {
224
- const entry = work.pop()
225
- if (entry.scanned) continue
226
- entry.scanned = true
227
- if (entry.isImport) continue
228
- walk(entry.node, n => {
229
- if (!Array.isArray(n)) {
230
- if (typeof n === 'string' && n[0] === '$') markFunc(n)
231
- return
232
- }
233
- const [op, ref] = n
234
- if (op === 'call' || op === 'return_call' || op === 'ref.func') markFunc(ref)
235
- else if (op === 'global.get' || op === 'global.set') markGlobal(ref)
236
- else if (op === 'type') markType(ref)
237
- else if (op === 'call_indirect' || op === 'return_call_indirect') {
238
- for (const sub of n) if (typeof sub === 'string' && sub[0] === '$') markTable(sub)
239
- }
240
- if (typeof op === 'string' && (op.startsWith('memory.') || op.includes('.load') || op.includes('.store'))) {
241
- markMemory(0)
242
- }
243
- })
244
- }
245
-
246
- // Filter: keep used definitions. nodeMap handles unnamed entries directly.
247
- const result = ['module']
248
- for (const node of ast.slice(1)) {
249
- if (!Array.isArray(node)) { result.push(node); continue }
250
- const kind = node[0]
251
- if (kind === 'func' || kind === 'global' || kind === 'type') {
252
- if (nodeMap.get(node)?.used) result.push(node)
253
- } else if (kind === 'import') {
254
- // Keep import only if any of its sub-items is used.
255
- let used = false
256
- for (const sub of node) {
257
- if (!Array.isArray(sub)) continue
258
- const e = nodeMap.get(sub)
259
- if (e?.used) { used = true; break }
260
- }
261
- if (used) result.push(node)
262
- } else {
263
- result.push(node)
264
- }
265
- }
266
- return result
267
- }
268
-
269
- // ==================== CONSTANT FOLDING ====================
270
-
271
- /** IEEE 754 roundTiesToEven (bankers' rounding) */
272
- const roundEven = (x) => x - Math.floor(x) !== 0.5 ? Math.round(x) : 2 * Math.round(x / 2)
273
-
274
- // Bit-exact reinterpret helpers (preserve NaN payloads).
275
- //
276
- // SELF-HOST CONTRACT: this file runs inside the jz kernel, whose BigInt is a
277
- // raw mod-2^64 i64 carrier — BigInt64Array views are a legacy f64-value shim
278
- // (reads return the FLOAT, not the bits), decimal stringification of >2^53
279
- // values and asIntN/asUintN are unfaithful, and adding 2^64 wraps to +0.
280
- // Everything here therefore sticks to the verified-faithful surface:
281
- // Uint32Array aliasing, hex toString(16)/parseInt(,16)/padStart, BigInt('0x…')
282
- // construction, BigInt ===/< comparison, and string arithmetic for two's
283
- // complement. The signed canonicalization `v > MAX_I64 → v − 2^64` is exact
284
- // natively AND a no-op in-kernel (2^64 ≡ 0 there) — correct in both worlds.
285
- const _rb8 = new ArrayBuffer(8)
286
- const _rf64 = new Float64Array(_rb8)
287
- const _ru32 = new Uint32Array(_rb8) // LE halves: [0]=lo, [1]=hi
288
- const _rb4 = new ArrayBuffer(4)
289
- const _rf32 = new Float32Array(_rb4)
290
- const _ri32 = new Int32Array(_rb4)
291
- const _hex8 = (u) => (u >>> 0).toString(16).padStart(8, '0')
292
- /** Two's complement of a 16-digit hex magnitude — pure string math. */
293
- const _twosComp16 = (mag) => {
294
- let out = '', carry = 1
295
- for (let i = 15; i >= 0; i--) {
296
- const d = (15 - parseInt(mag[i], 16)) + carry
297
- out = (d & 15).toString(16) + out
298
- carry = d >> 4
299
- }
300
- return out
301
- }
302
- /** Bits of an i64 BigInt (any sign) as a 16-digit hex string. Takes BigInt,
303
- * returns STRING — safe to call across kernel function boundaries (strings
304
- * are tagged; raw BigInts lose their kind at returns/polymorphic slots). */
305
- const _i64Hex16 = (v) => {
306
- const h = v.toString(16)
307
- return h[0] === '-' ? _twosComp16(h.slice(1).padStart(16, '0')) : h.padStart(16, '0')
308
- }
309
- // ============================== i64 VALUE CONTRACT ==========================
310
- // Within this optimizer an i64 const VALUE is the canonical STRING
311
- // '0x' + 16 lowercase hex digits (the raw bits). Strings survive every kernel
312
- // boundary; a BigInt held in a polymorphic slot ({type,value}), an untyped
313
- // param, or a return value is kind-erased in-kernel and every subsequent
314
- // BigInt op on it misdispatches. BigInt math is constructed AND consumed
315
- // inside single folder bodies only; folders return hex strings (or null).
316
- const ZERO64 = '0x0000000000000000', ONE64 = '0x0000000000000001', NEG164 = '0xffffffffffffffff'
317
- /** Canonicalize any i64.const node value (number | decimal/hex string | bigint).
318
- * EVERY _i64Hex16 argument here is a freshly-constructed BigInt: passing the
319
- * raw polymorphic `val` through would poison _i64Hex16's param kind for ALL
320
- * callers in-kernel (param types are per-function — one kind-erased call site
321
- * degrades v.toString(16) to dynamic dispatch on raw bits everywhere). */
322
- const _i64Canon = (val) => {
323
- if (typeof val === 'string') {
324
- const s = val.replaceAll('_', '')
325
- if (s.length === 18 && s[1] === 'x') return '0x' + s.slice(2).toLowerCase()
326
- return '0x' + _i64Hex16(BigInt(s))
327
- }
328
- // bigint stragglers (native-only defensive) route through String → fresh BigInt.
329
- if (typeof val === 'bigint') return '0x' + _i64Hex16(BigInt(String(val)))
330
- return '0x' + _i64Hex16(BigInt(Math.trunc(val) || 0))
331
- }
332
- /** Signed-order key: flip the sign bit, then equal-length hex compares
333
- * lexicographically in signed order. Pure strings — kernel-safe. */
334
- const _sb = (h) => (parseInt(h[2], 16) ^ 8).toString(16) + h.slice(3)
335
- /** Hex-string i64 ops used by several folders — all pure string/number math. */
336
- const _i64Lo = (h) => parseInt(h.slice(10), 16) | 0
337
- const _i64HiU = (h) => parseInt(h.slice(2, 10), 16) >>> 0
338
- /** Hex-encode an i64 fold result (BigInt, any sign/world — see folder note). */
339
- const _i64Arith = (r) => r == null ? null : '0x' + _i64Hex16(r)
340
- /** SIGNED i64 BigInt from canon hex — exact natively; the subtract arm is
341
- * dead in-kernel, where BigInt('0x…') already arrives as the signed carrier. */
342
- const _sgn = (h) => {
343
- let v = BigInt(h)
344
- if (v > 0x7fffffffffffffffn) v = v - 0x8000000000000000n - 0x8000000000000000n
345
- return v
346
- }
347
- const i64FromF64 = (x) => { _rf64[0] = x; return '0x' + _hex8(_ru32[1]) + _hex8(_ru32[0]) }
348
- const f64FromI64 = (h) => {
349
- _ru32[1] = parseInt(h.slice(2, 10), 16)
350
- _ru32[0] = parseInt(h.slice(10), 16)
351
- return _rf64[0]
352
- }
353
- const i32FromF32 = (x) => { _rf32[0] = x; return _ri32[0] }
354
- const f32FromI32 = (x) => { _ri32[0] = x | 0; return _rf32[0] }
355
-
356
- /** Build i32 comparison folder: returns 1/0 */
357
- const i32c = (fn) => (a, b) => fn(a, b) ? 1 : 0
358
- /** Build unsigned i32 comparison folder */
359
- const u32c = (fn) => (a, b) => fn(a >>> 0, b >>> 0) ? 1 : 0
360
- /** Signed i64 comparison folder — biased-hex lexicographic (kernel-safe). */
361
- const i64c = (fn) => (a, b) => fn(_sb(a), _sb(b)) ? 1 : 0
362
- /** Unsigned i64 comparison folder — canonical hex compares lexicographically. */
363
- const u64c = (fn) => (a, b) => fn(a, b) ? 1 : 0
364
-
365
- /**
366
- * Constant folders, keyed by op. Each entry is the fold function; the result
367
- * value-type is derived once via `resultType` (see `fold`).
368
- */
369
- const FOLDABLE = {
370
- // i32 arithmetic
371
- 'i32.add': (a, b) => (a + b) | 0,
372
- 'i32.sub': (a, b) => (a - b) | 0,
373
- 'i32.mul': (a, b) => Math.imul(a, b),
374
- 'i32.div_s': (a, b) => b !== 0 ? (a / b) | 0 : null,
375
- 'i32.div_u': (a, b) => b !== 0 ? ((a >>> 0) / (b >>> 0)) | 0 : null,
376
- 'i32.rem_s': (a, b) => b !== 0 ? (a % b) | 0 : null,
377
- 'i32.rem_u': (a, b) => b !== 0 ? ((a >>> 0) % (b >>> 0)) | 0 : null,
378
- 'i32.and': (a, b) => a & b,
379
- 'i32.or': (a, b) => a | b,
380
- 'i32.xor': (a, b) => a ^ b,
381
- 'i32.shl': (a, b) => a << (b & 31),
382
- 'i32.shr_s': (a, b) => a >> (b & 31),
383
- 'i32.shr_u': (a, b) => a >>> (b & 31),
384
- 'i32.rotl': (a, b) => { b &= 31; return ((a << b) | (a >>> (32 - b))) | 0 },
385
- 'i32.rotr': (a, b) => { b &= 31; return ((a >>> b) | (a << (32 - b))) | 0 },
386
- 'i32.eq': i32c((a, b) => a === b),
387
- 'i32.ne': i32c((a, b) => a !== b),
388
- 'i32.lt_s': i32c((a, b) => a < b),
389
- 'i32.lt_u': u32c((a, b) => a < b),
390
- 'i32.gt_s': i32c((a, b) => a > b),
391
- 'i32.gt_u': u32c((a, b) => a > b),
392
- 'i32.le_s': i32c((a, b) => a <= b),
393
- 'i32.le_u': u32c((a, b) => a <= b),
394
- 'i32.ge_s': i32c((a, b) => a >= b),
395
- 'i32.ge_u': u32c((a, b) => a >= b),
396
- 'i32.eqz': (a) => a === 0 ? 1 : 0,
397
- 'i32.clz': (a) => Math.clz32(a),
398
- 'i32.ctz': (a) => a === 0 ? 32 : 31 - Math.clz32(a & -a),
399
- 'i32.popcnt': (a) => { let c = 0; while (a) { c += a & 1; a >>>= 1 } return c },
400
- 'i32.wrap_i64': (a) => _i64Lo(a),
401
- 'i32.extend8_s': (a) => (a << 24) >> 24,
402
- 'i32.extend16_s': (a) => (a << 16) >> 16,
403
-
404
- // i64 — hex-string in, hex-string out, BOTH-WORLDS-EXACT arithmetic.
405
- // BigInts construct locally (in-expression — kernel kind erasure never
406
- // applies), but two further kernel facts shape every folder:
407
- // (1) the kernel's BigInt is the mod-2^64 i64 CARRIER: BigInt('0xffff…')
408
- // arrives NEGATIVE there, so sign-sensitive ops (>>, /, %, unsigned
409
- // division) diverge unless the value is sign-canonicalized first;
410
- // (2) BigInt.asIntN/asUintN are unfaithful in-kernel — never used.
411
- // Ring ops {+,−,×,&,|,^,<<} are mod-2^64-compatible: compute then mask with
412
- // `& 0xffffffffffffffffn` (native: the wrap; kernel: AND with −1 ≡ no-op).
413
- // `_sgn` yields the SIGNED value in both worlds (the subtract arm is dead
414
- // in-kernel — same dead-arm trick as slebSize). shr_u is pure u32-half
415
- // number math. div_u/rem_u fold only below 2^63 (signed==unsigned there);
416
- // above, they skip — sound degradation, never a wrong constant.
417
- 'i64.add': (a, b) => _i64Arith((BigInt(a) + BigInt(b)) & 0xffffffffffffffffn),
418
- 'i64.sub': (a, b) => _i64Arith((BigInt(a) - BigInt(b)) & 0xffffffffffffffffn),
419
- 'i64.mul': (a, b) => _i64Arith((BigInt(a) * BigInt(b)) & 0xffffffffffffffffn),
420
- 'i64.div_s': (a, b) => b !== ZERO64 && !(a === '0x8000000000000000' && b === NEG164)
421
- ? _i64Arith((_sgn(a) / _sgn(b)) & 0xffffffffffffffffn) : null,
422
- 'i64.div_u': (a, b) => b !== ZERO64 && !(_i64HiU(a) >>> 31) && !(_i64HiU(b) >>> 31)
423
- ? _i64Arith(BigInt(a) / BigInt(b)) : null,
424
- 'i64.rem_s': (a, b) => b !== ZERO64
425
- ? _i64Arith((_sgn(a) % _sgn(b)) & 0xffffffffffffffffn) : null,
426
- 'i64.rem_u': (a, b) => b !== ZERO64 && !(_i64HiU(a) >>> 31) && !(_i64HiU(b) >>> 31)
427
- ? _i64Arith(BigInt(a) % BigInt(b)) : null,
428
- 'i64.and': (a, b) => _i64Arith(BigInt(a) & BigInt(b) & 0xffffffffffffffffn),
429
- 'i64.or': (a, b) => _i64Arith((BigInt(a) | BigInt(b)) & 0xffffffffffffffffn),
430
- 'i64.xor': (a, b) => _i64Arith((BigInt(a) ^ BigInt(b)) & 0xffffffffffffffffn),
431
- 'i64.shl': (a, b) => _i64Arith((BigInt(a) << (BigInt(b) & 63n)) & 0xffffffffffffffffn),
432
- 'i64.shr_s': (a, b) => _i64Arith((_sgn(a) >> (BigInt(b) & 63n)) & 0xffffffffffffffffn),
433
- 'i64.shr_u': (a, b) => {
434
- const s = parseInt(b.slice(10), 16) & 63
435
- const hi = _i64HiU(a), lo = parseInt(a.slice(10), 16) >>> 0
436
- const rh = s >= 32 ? 0 : hi >>> s
437
- const rl = s === 0 ? lo : s >= 32 ? hi >>> (s - 32) : ((lo >>> s) | (hi << (32 - s))) >>> 0
438
- return '0x' + _hex8(rh) + _hex8(rl)
439
- },
440
- 'i64.eq': (a, b) => a === b ? 1 : 0,
441
- 'i64.ne': (a, b) => a !== b ? 1 : 0,
442
- 'i64.lt_s': i64c((a, b) => a < b),
443
- 'i64.lt_u': u64c((a, b) => a < b),
444
- 'i64.gt_s': i64c((a, b) => a > b),
445
- 'i64.gt_u': u64c((a, b) => a > b),
446
- 'i64.le_s': i64c((a, b) => a <= b),
447
- 'i64.le_u': u64c((a, b) => a <= b),
448
- 'i64.ge_s': i64c((a, b) => a >= b),
449
- 'i64.ge_u': u64c((a, b) => a >= b),
450
- 'i64.eqz': (a) => a === ZERO64 ? 1 : 0,
451
- 'i64.extend_i32_s': (a) => '0x' + _hex8(a >> 31) + _hex8(a),
452
- 'i64.extend_i32_u': (a) => '0x00000000' + _hex8(a),
453
- 'i64.extend8_s': (a) => { const v = (_i64Lo(a) << 24) >> 24; return '0x' + _hex8(v >> 31) + _hex8(v) },
454
- 'i64.extend16_s': (a) => { const v = (_i64Lo(a) << 16) >> 16; return '0x' + _hex8(v >> 31) + _hex8(v) },
455
- 'i64.extend32_s': (a) => { const v = _i64Lo(a); return '0x' + _hex8(v >> 31) + _hex8(v) },
456
-
457
- // f32/f64 (NaN/precision-aware via Math.fround)
458
- 'f32.add': (a, b) => Math.fround(a + b),
459
- 'f32.sub': (a, b) => Math.fround(a - b),
460
- 'f32.mul': (a, b) => Math.fround(a * b),
461
- 'f32.div': (a, b) => Math.fround(a / b),
462
- 'f32.neg': (a) => Math.fround(-a),
463
- 'f32.abs': (a) => Math.fround(Math.abs(a)),
464
- 'f32.sqrt': (a) => Math.fround(Math.sqrt(a)),
465
- 'f32.ceil': (a) => Math.fround(Math.ceil(a)),
466
- 'f32.floor': (a) => Math.fround(Math.floor(a)),
467
- 'f32.trunc': (a) => Math.fround(Math.trunc(a)),
468
- 'f32.nearest': (a) => Math.fround(roundEven(a)),
469
-
470
- 'f64.add': (a, b) => a + b,
471
- 'f64.sub': (a, b) => a - b,
472
- 'f64.mul': (a, b) => a * b,
473
- 'f64.div': (a, b) => a / b,
474
- 'f64.neg': (a) => -a,
475
- 'f64.abs': Math.abs,
476
- 'f64.sqrt': Math.sqrt,
477
- 'f64.ceil': Math.ceil,
478
- 'f64.floor': Math.floor,
479
- 'f64.trunc': Math.trunc,
480
- 'f64.nearest': roundEven,
481
-
482
- // Bit-exact reinterprets (preserve NaN payloads)
483
- 'i32.reinterpret_f32': i32FromF32,
484
- 'f32.reinterpret_i32': f32FromI32,
485
- 'i64.reinterpret_f64': i64FromF64,
486
- 'f64.reinterpret_i64': f64FromI64,
487
-
488
- // Numeric conversions (value-preserving where representable)
489
- 'f32.convert_i32_s': (a) => Math.fround(a | 0),
490
- 'f32.convert_i32_u': (a) => Math.fround(a >>> 0),
491
- // (hi|0)·2^32 + lo is the exact signed value with ONE rounding at the add —
492
- // correct f64 conversion semantics, pure number math (kernel-safe).
493
- 'f32.convert_i64_s': (a) => Math.fround((_i64HiU(a) | 0) * 4294967296 + parseInt(a.slice(10), 16)),
494
- 'f32.convert_i64_u': (a) => Math.fround(_i64HiU(a) * 4294967296 + parseInt(a.slice(10), 16)),
495
- 'f64.convert_i32_s': (a) => (a | 0),
496
- 'f64.convert_i32_u': (a) => (a >>> 0),
497
- 'f64.convert_i64_s': (a) => (_i64HiU(a) | 0) * 4294967296 + parseInt(a.slice(10), 16),
498
- 'f64.convert_i64_u': (a) => _i64HiU(a) * 4294967296 + parseInt(a.slice(10), 16),
499
- 'f32.demote_f64': (a) => Math.fround(a),
500
- 'f64.promote_f32': (a) => Math.fround(a),
501
- }
502
-
503
- /**
504
- * Parse a WAT `nan` / `nan:canonical` / `nan:arithmetic` / `nan:0xPAYLOAD`
505
- * literal to a JS number with the exact bit pattern. `Number('nan:0x…')`
506
- * collapses to canonical NaN, destroying the payload that NaN-boxing schemes
507
- * (jz, etc.) encode their pointer/sentinel bits into. Returns null if `s` is
508
- * not a NaN literal so callers can fall through to plain Number parsing.
509
- */
510
- /** Full 64-bit hex of a WAT f64 NaN literal — pure string/number math, the
511
- * payload double is NEVER materialized. In the self-host kernel a NaN-box bit
512
- * pattern held as a raw f64 VALUE is indistinguishable from a live pointer
513
- * (String / property reads misread it), so reinterpret folding must move the
514
- * bits as TEXT. Returns '0x…' (16 digits) or null when `s` isn't a NaN literal. */
515
- const _nanBitsHex = (s) => {
516
- const i = s?.indexOf?.('nan')
517
- if (i < 0 || i == null) return null
518
- const tail = s.slice(i + 4).replaceAll('_', '')
519
- const payload = (s[i + 3] === ':' && tail !== 'canonical' && tail !== 'arithmetic' ? BigInt(tail) : 0x8000000000000n)
520
- const h = payload.toString(16).padStart(16, '0')
521
- const hi = (parseInt(h.slice(0, 8), 16) | 0x7ff00000 | (s[0] === '-' ? 0x80000000 : 0)) >>> 0
522
- return '0x' + _hex8(hi) + h.slice(8)
523
- }
524
-
525
- const _parseNanF64 = (s, i = s?.indexOf?.('nan')) => {
526
- if (i < 0 || i == null) return null
527
- const tail = s.slice(i + 4).replaceAll('_', '')
528
- const payload = (s[i + 3] === ':' && tail !== 'canonical' && tail !== 'arithmetic' ? BigInt(tail) : 0x8000000000000n)
529
- // Assemble exponent/sign on the u32 halves — kernel-safe (BigInt <</| are not).
530
- const h = payload.toString(16).padStart(16, '0')
531
- _ru32[1] = (parseInt(h.slice(0, 8), 16) | 0x7ff00000 | (s[0] === '-' ? 0x80000000 : 0)) >>> 0
532
- _ru32[0] = parseInt(h.slice(8), 16)
533
- return _rf64[0]
534
- }
535
- const _parseNanF32 = (s, i = s?.indexOf?.('nan')) => {
536
- if (i < 0 || i == null) return null
537
- let tail = s.slice(i + 4).replaceAll('_', ''),
538
- bits = (s[i + 3] === ':' && tail !== 'canonical' && tail !== 'arithmetic' ? parseInt(tail) : 0x400000)
539
- _ri32[0] = (bits | 0x7f800000 | (s[0] === '-' ? 0x80000000 : 0)) | 0
540
- return _rf32[0]
541
- }
542
-
543
- /**
544
- * Extract constant value from node.
545
- * @param {any} node
546
- * @returns {{type: string, value: number|bigint}|null}
547
- */
548
- const getConst = (node) => {
549
- if (!Array.isArray(node) || node.length !== 2) return null
550
- const [op, val] = node
551
- if (op === 'i32.const') return { type: 'i32', value: (typeof val === 'string' ? parseInt(val.replaceAll('_', '')) : val) | 0 }
552
- if (op === 'i64.const') return { type: 'i64', value: _i64Canon(val) }
553
- if (op === 'f32.const') {
554
- const n = _parseNanF32(val)
555
- return { type: 'f32', value: n !== null ? n : Math.fround(Number(val)) }
556
- }
557
- if (op === 'f64.const') {
558
- const n = _parseNanF64(val)
559
- const v = n !== null ? n : Number(val)
560
- // Normalize ANY NaN to the literal NaN — Number.isNaN, NOT `v !== v`:
561
- // in-kernel `!==` routes through __eq's bit-equality, where a sign-set
562
- // qNaN (what x64 wasm arithmetic produces) compares EQUAL to itself (the
563
- // arm that keeps negative i64-carrier BigInts working), so the !== guard
564
- // misses it. Number.isNaN unboxes to f64 and uses f64.ne — catches every
565
- // payload. The literal-NaN assignment rewrites the carrier to the
566
- // canonical atom, so the value can ride kind-erased slots safely (the
567
- // linux-x64-only selfhost OOB; arm64 arithmetic NaNs are already
568
- // canonical). Native no-op.
569
- return { type: 'f64', value: Number.isNaN(v) ? NaN : v }
570
- }
571
- return null
572
- }
573
-
574
- /**
575
- * Create const node from value.
576
- * @param {string} type
577
- * @param {number|bigint} value
578
- * @returns {Array}
579
- */
580
- const makeConst = (type, value) => {
581
- if (type === 'i32') return ['i32.const', value | 0]
582
- if (type === 'i64') return ['i64.const', typeof value === 'number' ? value : _i64Canon(value)] // canonical hex: kernel-safe print, exact round-trip
583
- // NaN travels as the `nan` TOKEN, never a raw number: the canonical-NaN bit
584
- // pattern (0x7FF8…) IS the NaN-box ATOM prefix, so a raw NaN node value
585
- // inside the self-host kernel reads as a pointer and dereferences OOB
586
- // (same contract as emitNum — folding Math.sqrt(-1) used to trap the
587
- // kernel's L2 compile). ±Infinity is outside the box space — safe raw.
588
- if (type === 'f32') { const v = Math.fround(value); return ['f32.const', Number.isNaN(v) ? 'nan' : v] }
589
- if (type === 'f64') return ['f64.const', Number.isNaN(value) ? 'nan' : value]
590
- return null
591
- }
592
-
593
- /**
594
- * Fold constant expressions.
595
- * @param {Array} ast
596
- * @returns {Array}
597
- */
598
- const fold = (ast) => {
599
- return walkPost(ast, (node) => {
600
- if (!Array.isArray(node)) return
601
- const fn = FOLDABLE[node[0]]
602
- if (!fn) return
603
-
604
- // Arity comes from the NODE — every WAT op is fixed-arity, so node.length
605
- // fully determines unary vs binary. NEVER from Function.length: the
606
- // self-host kernel's closures don't carry a faithful `.length`, and the
607
- // old `fn.length === 1/2` checks silently disabled ALL folding in-kernel
608
- // (the L2 self-host divergence — unfolded consts fed the vectorizer
609
- // shapes native never produces).
610
- // Unary
611
- if (node.length === 2) {
612
- // NaN-payload reinterprets fold at the TEXT level — the payload double
613
- // must never ride as a raw f64 value (see _nanBitsHex). Applies in both
614
- // directions: nan: literal → i64 bits, and NaN-pattern i64 → nan: literal.
615
- if (node[0] === 'i64.reinterpret_f64') {
616
- const inner = node[1]
617
- if (Array.isArray(inner) && inner.length === 2 && inner[0] === 'f64.const' && typeof inner[1] === 'string') {
618
- const bits = _nanBitsHex(inner[1])
619
- if (bits) return ['i64.const', bits]
620
- }
621
- }
622
- if (node[0] === 'f64.reinterpret_i64') {
623
- const c = getConst(node[1])
624
- if (c && c.type === 'i64') {
625
- const h = c.value.slice(2)
626
- const hi = parseInt(h.slice(0, 8), 16) >>> 0
627
- const lo = parseInt(h.slice(8), 16) >>> 0
628
- const isNaN64 = (hi & 0x7ff00000) === 0x7ff00000 && ((hi & 0xfffff) !== 0 || lo !== 0)
629
- if (isNaN64) return ['f64.const',
630
- ((hi & 0x80000000) !== 0 ? '-' : '') + 'nan:0x' + (hi & 0xfffff).toString(16).padStart(5, '0') + h.slice(8)]
631
- }
632
- }
633
- const a = getConst(node[1])
634
- if (!a) return
635
- const r = fn(a.value)
636
- if (r === null || r === undefined) return
637
- return makeConst(resultType(node[0]), r)
638
- }
639
- // Binary
640
- if (node.length === 3) {
641
- const a = getConst(node[1]), b = getConst(node[2])
642
- if (!a || !b) return
643
- const r = fn(a.value, b.value)
644
- if (r === null || r === undefined) return
645
- return makeConst(resultType(node[0]), r)
646
- }
647
- })
648
- }
649
-
650
- // ==================== IDENTITY REMOVAL ====================
651
-
652
- /**
653
- * Create identity checker for commutative binary ops:
654
- * neutral op x → x and x op neutral → x
655
- */
656
- const commutativeIdentity = (neutral) => (a, b) => {
657
- const ca = getConst(a), cb = getConst(b)
658
- if (ca?.value === neutral) return b
659
- if (cb?.value === neutral) return a
660
- return null
661
- }
662
-
663
- /**
664
- * Create identity checker for right-neutral binary ops:
665
- * x op neutral → x
666
- */
667
- const rightIdentity = (neutral) => (a, b) => getConst(b)?.value === neutral ? a : null
668
-
669
- /** Identity operations that can be simplified */
670
- const IDENTITIES = {
671
- // x + 0 → x, 0 + x → x
672
- 'i32.add': commutativeIdentity(0),
673
- 'i64.add': commutativeIdentity(ZERO64),
674
- // x - 0 → x
675
- 'i32.sub': rightIdentity(0),
676
- 'i64.sub': rightIdentity(ZERO64),
677
- // x * 1 → x, 1 * x → x
678
- 'i32.mul': commutativeIdentity(1),
679
- 'i64.mul': commutativeIdentity(ONE64),
680
- // x / 1 → x
681
- 'i32.div_s': rightIdentity(1),
682
- 'i32.div_u': rightIdentity(1),
683
- 'i64.div_s': rightIdentity(ONE64),
684
- 'i64.div_u': rightIdentity(ONE64),
685
- // x & -1 → x, -1 & x → x (all bits set)
686
- 'i32.and': commutativeIdentity(-1),
687
- 'i64.and': commutativeIdentity(NEG164),
688
- // x | 0 → x, 0 | x → x
689
- 'i32.or': commutativeIdentity(0),
690
- 'i64.or': commutativeIdentity(ZERO64),
691
- // x ^ 0 → x, 0 ^ x → x
692
- 'i32.xor': commutativeIdentity(0),
693
- 'i64.xor': commutativeIdentity(ZERO64),
694
- // x << 0 → x, x >> 0 → x
695
- 'i32.shl': rightIdentity(0),
696
- 'i32.shr_s': rightIdentity(0),
697
- 'i32.shr_u': rightIdentity(0),
698
- 'i64.shl': rightIdentity(ZERO64),
699
- 'i64.shr_s': rightIdentity(ZERO64),
700
- 'i64.shr_u': rightIdentity(ZERO64),
701
- // f + 0 → x (careful with -0.0, skip for floats)
702
- // f * 1 → x (careful with NaN, skip for floats)
703
- }
704
-
705
- /**
706
- * Remove identity operations.
707
- * @param {Array} ast
708
- * @returns {Array}
709
- */
710
- const identity = (ast) => {
711
- return walkPost(ast, (node) => {
712
- if (!Array.isArray(node) || node.length !== 3) return
713
- const fn = IDENTITIES[node[0]]
714
- if (!fn) return
715
- const result = fn(node[1], node[2])
716
- if (result === null) return // no optimization, keep original
717
- return result
718
- })
719
- }
720
-
721
- // ==================== STRENGTH REDUCTION ====================
722
-
723
- /**
724
- * Strength reduction: replace expensive ops with cheaper equivalents.
725
- * @param {Array} ast
726
- * @returns {Array}
727
- */
728
- const strength = (ast) => {
729
- return walkPost(ast, (node) => {
730
- if (!Array.isArray(node) || node.length !== 3) return
731
- const [op, a, b] = node
732
-
733
- // x * 2^n → x << n
734
- if (op === 'i32.mul') {
735
- const cb = getConst(b)
736
- if (cb && cb.value > 0 && (cb.value & (cb.value - 1)) === 0) {
737
- const shift = Math.log2(cb.value)
738
- if (Number.isInteger(shift)) return ['i32.shl', a, ['i32.const', shift]]
739
- }
740
- const ca = getConst(a)
741
- if (ca && ca.value > 0 && (ca.value & (ca.value - 1)) === 0) {
742
- const shift = Math.log2(ca.value)
743
- if (Number.isInteger(shift)) return ['i32.shl', b, ['i32.const', shift]]
744
- }
745
- }
746
- if (op === 'i64.mul') {
747
- // hex value → LOCAL BigInt (in-expression construction is kernel-safe);
748
- // shift counts emit as plain numbers.
749
- const cb = getConst(b), vb = cb ? BigInt(cb.value) : null
750
- if (vb != null && vb > 0n && (vb & (vb - 1n)) === 0n)
751
- return ['i64.shl', a, ['i64.const', vb.toString(2).length - 1]]
752
- const ca = getConst(a), va = ca ? BigInt(ca.value) : null
753
- if (va != null && va > 0n && (va & (va - 1n)) === 0n)
754
- return ['i64.shl', b, ['i64.const', va.toString(2).length - 1]]
755
- }
756
-
757
- // x / 2^n → x >> n (unsigned only, signed division is more complex)
758
- if (op === 'i32.div_u') {
759
- const cb = getConst(b)
760
- if (cb && cb.value > 0 && (cb.value & (cb.value - 1)) === 0) {
761
- const shift = Math.log2(cb.value)
762
- if (Number.isInteger(shift)) return ['i32.shr_u', a, ['i32.const', shift]]
763
- }
764
- }
765
- if (op === 'i64.div_u') {
766
- const cb = getConst(b), vb = cb ? BigInt(cb.value) : null
767
- if (vb != null && vb > 0n && (vb & (vb - 1n)) === 0n)
768
- return ['i64.shr_u', a, ['i64.const', vb.toString(2).length - 1]]
769
- }
770
-
771
- // x % 2^n → x & (2^n - 1) (unsigned only)
772
- if (op === 'i32.rem_u') {
773
- const cb = getConst(b)
774
- if (cb && cb.value > 0 && (cb.value & (cb.value - 1)) === 0) {
775
- return ['i32.and', a, ['i32.const', cb.value - 1]]
776
- }
777
- }
778
- if (op === 'i64.rem_u') {
779
- const cb = getConst(b), vb = cb ? BigInt(cb.value) : null
780
- if (vb != null && vb > 0n && (vb & (vb - 1n)) === 0n)
781
- return ['i64.and', a, ['i64.const', '0x' + _i64Hex16(vb - 1n)]]
782
- }
783
- })
784
- }
785
-
786
- // ==================== BRANCH SIMPLIFICATION ====================
787
-
788
- /**
789
- * Simplify branches with constant conditions.
790
- * @param {Array} ast
791
- * @returns {Array}
792
- */
793
- const branch = (ast) => {
794
- return walkPost(ast, (node) => {
795
- if (!Array.isArray(node)) return
796
- const op = node[0]
797
-
798
- // (if (i32.const 0) then else) → else
799
- // (if (i32.const N) then else) → then (N != 0)
800
- if (op === 'if') {
801
- const { condIdx, cond, thenBranch, elseBranch } = parseIf(node)
802
- const c = getConst(cond)
803
- if (!c) return
804
- const taken = c.value !== 0 && c.value !== ZERO64 ? thenBranch : elseBranch
805
- if (taken && taken.length > 1) {
806
- const contents = taken.slice(1)
807
- // Preserve the if's block type (result/param). A typed `if` leaves a value
808
- // on the stack; collapsing it to the taken branch must keep that branch's
809
- // value in a same-typed block, else the contents land in a void context and
810
- // the value is left dangling → "expected 0 elements on the stack for fallthru".
811
- const blockType = node.slice(1, condIdx).filter(p => Array.isArray(p) && (p[0] === 'result' || p[0] === 'param'))
812
- if (blockType.length) return ['block', ...blockType, ...contents]
813
- return contents.length === 1 ? contents[0] : ['block', ...contents]
814
- }
815
- return ['nop']
816
- }
817
-
818
- // (br_if $label (i32.const 0)) → nop
819
- // (br_if $label (i32.const N)) → br $label (N != 0)
820
- if (op === 'br_if' && node.length >= 3) {
821
- const cond = node[node.length - 1]
822
- const c = getConst(cond)
823
- if (!c) return
824
- if (c.value === 0 || c.value === ZERO64) return ['nop']
825
- return ['br', node[1]]
826
- }
827
-
828
- // (select a b (i32.const 0)) → b
829
- // (select a b (i32.const N)) → a (N != 0)
830
- if (op === 'select' && node.length >= 4) {
831
- const cond = node[node.length - 1]
832
- const c = getConst(cond)
833
- if (!c) return
834
- if (c.value === 0 || c.value === ZERO64) return node[2] // b
835
- return node[1] // a
836
- }
837
- })
838
- }
839
-
840
- // ==================== GUARD-AWARE TAG REFINEMENT ====================
841
-
842
- /**
843
- * Fold NaN-box tag reads under dominating tag guards (jz-domain knowledge).
844
- *
845
- * jz reads a value's 4-bit NaN-box tag in three equivalent forms:
846
- * A. (i32.and (i32.wrap_i64 (i64.shr_u PTR (i64.const 47))) (i32.const 15))
847
- * B. (i32.wrap_i64 (i64.and (i64.shr_u PTR (i64.const 47)) (i64.const 15)))
848
- * C. (call $__ptr_type PTR)
849
- * where PTR is (i64.reinterpret_f64 (local.get $X)) or an i64 local copy of it.
850
- *
851
- * After `inlineOnce` splices a generic helper (e.g. $__len's 5-way tag
852
- * dispatch) into an arm already guarded by `tag(X) == K`, the recomputed tag
853
- * is a known constant — but no structural pass can see it: forms A and B
854
- * differ shape-wise, and the value flows through reinterpret/copy locals.
855
- * This pass tracks tag-of-X facts through if-arms and folds tag reads to
856
- * constants; the regular fold/branch/vacuum passes then delete the dead
857
- * dispatch arms. This is the single biggest source of wasm-opt's remaining
858
- * slack on jz output (~10% on typed-array modules).
859
- *
860
- * Soundness model — facts and aliases are keyed by the f64 SOURCE local $X
861
- * (tags live in the value's bits, so only local writes can invalidate, never
862
- * calls/stores):
863
- * - any local.set/tee of $X kills its fact and every alias derived from it
864
- * - leaving a block kills facts/aliases for locals written inside it
865
- * (a br may have skipped the write)
866
- * - entering a loop kills facts for locals written anywhere in it
867
- * (the back edge re-enters after the write)
868
- * - then/else facts are layered over a snapshot and restored on exit;
869
- * writes inside either arm kill outer facts afterward
870
- * - within straight-line code, sequential registration is exact: wasm has
871
- * no goto, so execution between branch points is linear
872
- */
873
- const guardRefine = (ast) => {
874
- if (Array.isArray(ast)) for (const node of ast) if (Array.isArray(node) && node[0] === 'func') refineGuards(node)
875
- return ast
876
- }
877
-
878
- const EMPTY_SET = new Set()
879
-
880
- const refineGuards = (fn) => {
881
- const ptrAlias = new Map() // i64 local → f64 source local (reinterpret copy)
882
- const tagAlias = new Map() // i32 local → f64 source local (holds tag(X))
883
- const eqFact = new Map() // f64 local → known tag K
884
- const neFact = new Map() // f64 local → Set of excluded tags
885
-
886
- const intVal = (n) => {
887
- if (!Array.isArray(n) || n.length !== 2 || (n[0] !== 'i32.const' && n[0] !== 'i64.const')) return null
888
- const v = typeof n[1] === 'string' ? Number(n[1].replaceAll('_', '')) : Number(n[1])
889
- return Number.isFinite(v) ? v : null
890
- }
891
- const i32Val = (n) => Array.isArray(n) && n[0] === 'i32.const' ? intVal(n) : null
892
-
893
- // PTR node → f64 source local, or null.
894
- const ptrSrc = (n) => {
895
- if (!Array.isArray(n)) return null
896
- if (n[0] === 'i64.reinterpret_f64' && Array.isArray(n[1]) && n[1][0] === 'local.get' && typeof n[1][1] === 'string') return n[1][1]
897
- if (n[0] === 'local.get' && typeof n[1] === 'string') return ptrAlias.get(n[1]) ?? null
898
- return null
899
- }
900
- // tag-of-X node (forms A/B/C or a tag-alias local read) → X, or null.
901
- const tagSrc = (n) => {
902
- if (!Array.isArray(n)) return null
903
- const op = n[0]
904
- if (op === 'local.get' && typeof n[1] === 'string') return tagAlias.get(n[1]) ?? null
905
- if (op === 'call' && n[1] === '$__ptr_type' && n.length === 3) return ptrSrc(n[2])
906
- const shifted = (m) => Array.isArray(m) && m[0] === 'i64.shr_u' && intVal(m[2]) === 47 ? ptrSrc(m[1]) : null
907
- if (op === 'i32.and' && n.length === 3) { // form A (mask either side)
908
- const [a, b] = i32Val(n[2]) === 15 ? [n[1], null] : i32Val(n[1]) === 15 ? [n[2], null] : [null, null]
909
- if (a && Array.isArray(a) && a[0] === 'i32.wrap_i64') return shifted(a[1])
910
- }
911
- if (op === 'i32.wrap_i64' && Array.isArray(n[1]) && n[1][0] === 'i64.and') { // form B
912
- const m = n[1]
913
- if (intVal(m[2]) === 15) return shifted(m[1])
914
- if (intVal(m[1]) === 15) return shifted(m[2])
915
- }
916
- return null
917
- }
918
-
919
- const killLocal = (name) => {
920
- ptrAlias.delete(name); tagAlias.delete(name); eqFact.delete(name); neFact.delete(name)
921
- for (const [p, x] of ptrAlias) if (x === name) ptrAlias.delete(p)
922
- for (const [t, x] of tagAlias) if (x === name) tagAlias.delete(t)
923
- }
924
- // Write-sets are queried per if/loop/block; memoize bottom-up so each node is
925
- // visited once per function, not once per enclosing construct. Keyed by node
926
- // identity — sound because walkSeq only ever *replaces* whole subtrees
927
- // (parent[idx] = const), never adds writes to an existing one.
928
- const writesMemo = new Map()
929
- const writesOf = (n) => {
930
- if (!Array.isArray(n)) return EMPTY_SET
931
- let s = writesMemo.get(n)
932
- if (s) return s
933
- s = new Set()
934
- if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') s.add(n[1])
935
- for (let i = 1; i < n.length; i++) for (const w of writesOf(n[i])) s.add(w)
936
- writesMemo.set(n, s)
937
- return s
938
- }
939
- const snap = () => [new Map(eqFact), new Map([...neFact].map(([k, s]) => [k, new Set(s)])), new Map(ptrAlias), new Map(tagAlias)]
940
- const reset = (m, src) => { m.clear(); for (const [k, v] of src) m.set(k, v) }
941
- const restore = ([e, n, p, t]) => { reset(eqFact, e); reset(neFact, n); reset(ptrAlias, p); reset(tagAlias, t) }
942
-
943
- // Facts implied by `cond` being truthy (sense=true) / falsy (sense=false).
944
- const condFacts = (cond, sense, out) => {
945
- if (!Array.isArray(cond)) return out
946
- const op = cond[0]
947
- if (op === 'i32.eqz') return condFacts(cond[1], !sense, out)
948
- if (op === 'i32.and' && sense && cond.length === 3) { condFacts(cond[1], true, out); condFacts(cond[2], true, out); return out }
949
- if (op === 'i32.or' && !sense && cond.length === 3) { condFacts(cond[1], false, out); condFacts(cond[2], false, out); return out }
950
- if ((op === 'i32.eq' || op === 'i32.ne') && cond.length === 3) {
951
- let x = tagSrc(cond[1]), k = i32Val(cond[2])
952
- if (x == null || k == null) { x = tagSrc(cond[2]); k = i32Val(cond[1]) }
953
- if (x != null && k != null) out.push({ x, k, eq: (op === 'i32.eq') === sense })
954
- return out
955
- }
956
- const x = tagSrc(cond) // bare tag as condition: truthy ⇒ tag≠0, falsy ⇒ tag==0
957
- if (x != null) out.push({ x, k: 0, eq: !sense })
958
- return out
959
- }
960
- const addFacts = (fs) => {
961
- for (const { x, k, eq } of fs) {
962
- if (eq) eqFact.set(x, k)
963
- else { let s = neFact.get(x); if (!s) neFact.set(x, s = new Set()); s.add(k) }
964
- }
965
- }
966
-
967
- const walkSeq = (node, parent, idx) => {
968
- if (!Array.isArray(node)) return
969
- const op = node[0]
970
-
971
- if (op === 'local.set' || op === 'local.tee') {
972
- if (Array.isArray(node[2])) walkSeq(node[2], node, 2)
973
- const name = node[1]
974
- if (typeof name !== 'string') return
975
- killLocal(name)
976
- const v = node[2]
977
- if (Array.isArray(v)) {
978
- if (v[0] === 'i64.reinterpret_f64' && Array.isArray(v[1]) && v[1][0] === 'local.get' && typeof v[1][1] === 'string') ptrAlias.set(name, v[1][1])
979
- else if (v[0] === 'local.get' && typeof v[1] === 'string' && ptrAlias.has(v[1])) ptrAlias.set(name, ptrAlias.get(v[1]))
980
- else { const tx = tagSrc(v); if (tx != null) tagAlias.set(name, tx) }
981
- }
982
- return
983
- }
984
-
985
- if (op === 'if') {
986
- const { condIdx } = parseIf(node)
987
- if (Array.isArray(node[condIdx])) walkSeq(node[condIdx], node, condIdx)
988
- const cond = node[condIdx] // re-read: the walk may have folded it
989
- const { thenBranch, elseBranch } = parseIf(node)
990
- const writes = writesOf(node)
991
- const pre = snap()
992
- addFacts(condFacts(cond, true, []))
993
- if (thenBranch) for (let i = 1; i < thenBranch.length; i++) walkSeq(thenBranch[i], thenBranch, i)
994
- restore(pre)
995
- addFacts(condFacts(cond, false, []))
996
- if (elseBranch) for (let i = 1; i < elseBranch.length; i++) walkSeq(elseBranch[i], elseBranch, i)
997
- restore(pre)
998
- for (const w of writes) killLocal(w)
999
- return
1000
- }
1001
-
1002
- if (op === 'loop') {
1003
- const writes = writesOf(node)
1004
- for (const w of writes) killLocal(w)
1005
- for (let i = 1; i < node.length; i++) walkSeq(node[i], node, i)
1006
- for (const w of writes) killLocal(w)
1007
- return
1008
- }
1009
-
1010
- if (op === 'block') {
1011
- for (let i = 1; i < node.length; i++) walkSeq(node[i], node, i)
1012
- for (const w of writesOf(node)) killLocal(w)
1013
- return
1014
- }
1015
-
1016
- // Whole-node tag read under an equality fact → constant.
1017
- const tx = tagSrc(node)
1018
- if (tx != null && eqFact.has(tx) && parent) { parent[idx] = ['i32.const', eqFact.get(tx)]; return }
1019
-
1020
- // eq/ne against a constant under a ne-fact (eq-facts are covered by the
1021
- // tag-read fold above plus the regular `fold` pass).
1022
- if ((op === 'i32.eq' || op === 'i32.ne') && node.length === 3) {
1023
- let x = tagSrc(node[1]), k = i32Val(node[2])
1024
- if (x == null || k == null) { x = tagSrc(node[2]); k = i32Val(node[1]) }
1025
- if (x != null && k != null && neFact.get(x)?.has(k) && parent) {
1026
- parent[idx] = ['i32.const', op === 'i32.eq' ? 0 : 1]
1027
- return
1028
- }
1029
- }
1030
-
1031
- for (let i = 1; i < node.length; i++) walkSeq(node[i], node, i)
1032
- }
1033
-
1034
- for (let i = 1; i < fn.length; i++) walkSeq(fn[i], fn, i)
1035
- }
1036
-
1037
- // ==================== DEAD CODE ELIMINATION ====================
1038
-
1039
- /** Control flow terminators */
1040
- const TERMINATORS = new Set(['unreachable', 'return', 'br', 'br_table'])
1041
-
1042
- /**
1043
- * Remove dead code after control flow terminators.
1044
- * @param {Array} ast
1045
- * @returns {Array}
1046
- */
1047
- const deadcode = (ast) => {
1048
- // Process each function body
1049
- walk(ast, (node) => {
1050
- if (!Array.isArray(node)) return
1051
- const kind = node[0]
1052
-
1053
- // Process blocks: func, block, loop, if branches
1054
- if (kind === 'func' || kind === 'block' || kind === 'loop') {
1055
- eliminateDeadInBlock(node)
1056
- }
1057
- if (kind === 'if') {
1058
- // Process then/else branches
1059
- for (let i = 1; i < node.length; i++) {
1060
- if (Array.isArray(node[i]) && (node[i][0] === 'then' || node[i][0] === 'else')) {
1061
- eliminateDeadInBlock(node[i])
1062
- }
1063
- }
1064
- }
1065
- })
1066
-
1067
- return ast
1068
- }
1069
-
1070
- /**
1071
- * Remove instructions after terminators within a block.
1072
- * @param {Array} block
1073
- */
1074
- const eliminateDeadInBlock = (block) => {
1075
- let terminated = false
1076
- let firstTerminator = -1
1077
-
1078
- for (let i = 1; i < block.length; i++) {
1079
- const node = block[i]
1080
-
1081
- // Skip type annotations
1082
- if (Array.isArray(node)) {
1083
- const op = node[0]
1084
- if (op === 'param' || op === 'result' || op === 'local' || op === 'type' || op === 'export') continue
1085
-
1086
- if (terminated) {
1087
- if (firstTerminator === -1) firstTerminator = i
1088
- }
1089
-
1090
- if (TERMINATORS.has(op)) {
1091
- terminated = true
1092
- firstTerminator = i + 1
1093
- }
1094
- } else if (typeof node === 'string') {
1095
- // String instructions like 'unreachable', 'return', 'drop', 'nop'
1096
- if (terminated) {
1097
- if (firstTerminator === -1) firstTerminator = i
1098
- }
1099
-
1100
- if (TERMINATORS.has(node)) {
1101
- terminated = true
1102
- firstTerminator = i + 1
1103
- }
1104
- }
1105
- }
1106
-
1107
- // Remove dead code
1108
- if (firstTerminator > 0 && firstTerminator < block.length) {
1109
- block.splice(firstTerminator)
1110
- }
1111
- }
1112
-
1113
- // ==================== LOCAL REUSE ====================
1114
-
1115
- /**
1116
- * Reuse locals of the same type to reduce total local count.
1117
- * Basic version: deduplicate unused locals.
1118
- * @param {Array} ast
1119
- * @returns {Array}
1120
- */
1121
- const localReuse = (ast) => {
1122
- walk(ast, (node) => {
1123
- if (!Array.isArray(node) || node[0] !== 'func') return
1124
-
1125
- // Collect local declarations and their types
1126
- const localDecls = []
1127
- const localTypes = new Map() // $name → type
1128
- const usedLocals = new Set()
1129
-
1130
- // Find all local declarations and usages
1131
- for (let i = 1; i < node.length; i++) {
1132
- const sub = node[i]
1133
- if (!Array.isArray(sub)) continue
1134
-
1135
- if (sub[0] === 'local') {
1136
- localDecls.push({ node: sub, idx: i })
1137
- // (local $name type) or (local type)
1138
- if (typeof sub[1] === 'string' && sub[1][0] === '$') {
1139
- localTypes.set(sub[1], sub[2])
1140
- }
1141
- }
1142
- if (sub[0] === 'param') {
1143
- // Params are also locals
1144
- if (typeof sub[1] === 'string' && sub[1][0] === '$') {
1145
- localTypes.set(sub[1], sub[2])
1146
- usedLocals.add(sub[1]) // params always used
1147
- }
1148
- }
1149
- }
1150
-
1151
- // Find which locals are actually used
1152
- walk(node, (n) => {
1153
- if (!Array.isArray(n)) return
1154
- const op = n[0]
1155
- if (op === 'local.get' || op === 'local.set' || op === 'local.tee') {
1156
- const ref = n[1]
1157
- if (typeof ref === 'string') usedLocals.add(ref)
1158
- }
1159
- })
1160
-
1161
- // Remove unused local declarations
1162
- for (let i = localDecls.length - 1; i >= 0; i--) {
1163
- const { idx, node: decl } = localDecls[i]
1164
- const name = typeof decl[1] === 'string' && decl[1][0] === '$' ? decl[1] : null
1165
- if (name && !usedLocals.has(name)) {
1166
- node.splice(idx, 1)
1167
- }
1168
- }
1169
- })
1170
-
1171
- return ast
1172
- }
1173
-
1174
- // ==================== PROPAGATION & LOCAL ELIMINATION ====================
1175
-
1176
- /** Operators with side effects: calls, mutators, control flow, exceptions, drops. */
1177
- const IMPURE_OPS = new Set([
1178
- 'call', 'call_indirect', 'return_call', 'return_call_indirect',
1179
- 'table.set', 'table.grow', 'table.fill', 'table.copy', 'table.init',
1180
- 'struct.set', 'struct.new',
1181
- 'array.set', 'array.new', 'array.new_fixed', 'array.new_data', 'array.new_elem',
1182
- 'array.init_data', 'array.init_elem', 'ref.i31',
1183
- 'global.set', 'local.set', 'local.tee',
1184
- 'unreachable', 'return',
1185
- 'br', 'br_if', 'br_table', 'br_on_null', 'br_on_non_null', 'br_on_cast', 'br_on_cast_fail',
1186
- 'throw', 'rethrow', 'throw_ref', 'try_table',
1187
- 'data.drop', 'elem.drop',
1188
- ])
1189
-
1190
- /** Substrings that flag an op as side-effecting (loads can trap, stores/atomics/memory ops mutate). */
1191
- const IMPURE_SUBSTRINGS = ['.store', 'memory.', '.atomic.']
1192
-
1193
- /**
1194
- * Pure means: no side effects, no traps we care about, no control flow.
1195
- * Conservative — returns false for anything that might trap, mutate state, or branch.
1196
- */
1197
- const isPure = (node) => {
1198
- if (!Array.isArray(node)) return true
1199
- const op = node[0]
1200
- if (typeof op !== 'string') return false
1201
- if (IMPURE_OPS.has(op)) return false
1202
- for (const sub of IMPURE_SUBSTRINGS) if (op.includes(sub)) return false
1203
- for (let i = 1; i < node.length; i++) if (Array.isArray(node[i]) && !isPure(node[i])) return false
1204
- return true
1205
- }
1206
-
1207
- /** Count all local.get/set/tee occurrences in one walk */
1208
- const countLocalUses = (node) => {
1209
- const counts = new Map()
1210
- const ensure = name => { if (!counts.has(name)) counts.set(name, { gets: 0, sets: 0, tees: 0 }); return counts.get(name) }
1211
- walk(node, n => {
1212
- if (!Array.isArray(n) || n.length < 2 || typeof n[1] !== 'string') return
1213
- if (n[0] === 'local.get') ensure(n[1]).gets++
1214
- else if (n[0] === 'local.set') ensure(n[1]).sets++
1215
- else if (n[0] === 'local.tee') ensure(n[1]).tees++
1216
- })
1217
- return counts
1218
- }
1219
-
1220
- /** A constant whose inlined form (opcode + immediate) is no wider than the ~2 B
1221
- * `local.get` it would replace — so propagating it to every use is byte-neutral
1222
- * at worst, and still drops the `local.set` + the `local` decl. f32/f64 consts
1223
- * (5/9 B) lose on reuse, so only narrow i32/i64 literals qualify. */
1224
- const isTinyConst = (node) => {
1225
- const c = getConst(node)
1226
- if (!c) return false
1227
- if (c.type === 'i32') { const v = c.value | 0; return v >= -64 && v <= 63 }
1228
- if (c.type === 'i64') { const v = BigInt(c.value); return v <= 63n || v >= 0xffffffffffffffc0n } // unsigned bits: [0,63] or two's-comp [−64,−1]
1229
- return false
1230
- }
1231
-
1232
- /** A pure local→local copy value `(local.get $src)`, with $src ≠ the local being set.
1233
- * Substituting it for a `(local.get $dst)` is byte-neutral (local.get for local.get),
1234
- * so — unlike a reused wide constant — it can never grow an instruction, and it turns
1235
- * the copy `$dst = $src` into a dead store the next pass drops. Self-copies are
1236
- * excluded: they're no-ops that would re-trigger `changed` every round. (Propagating a
1237
- * copy lengthens $src's live range, which can rarely cost coalesceLocals a slot — a
1238
- * few bytes — but net-shrinks across the corpus, e.g. −1.7 KB on the watr self-host.) */
1239
- const isLocalCopy = (val, dest) =>
1240
- Array.isArray(val) && val[0] === 'local.get' && val.length === 2 &&
1241
- typeof val[1] === 'string' && val[1] !== dest
1242
-
1243
- /** Can this tracked value be substituted for a local.get?
1244
- * - single use of a pure value: always shrinks (drops the set, the lone get, the decl);
1245
- * - any use of a tiny constant: byte-neutral at worst, still drops the set + decl;
1246
- * - any use of a pure local copy: byte-neutral, frees the copy as a dead store.
1247
- * Anything else (a wide constant reused many times, an impure expr) could inflate
1248
- * or reorder side effects, so it's left alone. Copy validity (the source not being
1249
- * reassigned between copy and use) is enforced by the same purgeRefs/branch-clear
1250
- * machinery that guards every tracked value. */
1251
- const canSubst = (k) => (k.pure && k.singleUse) || isTinyConst(k.val) || k.copy
1252
-
1253
- /** Drop tracked values that read `$name`: rewriting `$name` makes them stale. */
1254
- const purgeRefs = (known, name) => {
1255
- for (const [key, tracked] of known) {
1256
- let refs = false
1257
- walk(tracked.val, n => { if (Array.isArray(n) && (n[0] === 'local.get' || n[0] === 'local.tee') && n[1] === name) refs = true })
1258
- if (refs) known.delete(key)
1259
- }
1260
- }
1261
-
1262
- /** True if `node` recursively contains an op that may read linear memory.
1263
- * Tracked values whose RHS reads memory go stale after any intervening
1264
- * memory-mutating op (`*.store`, `memory.copy/fill/init`, atomic stores/rmw). */
1265
- const readsMemory = (node) => {
1266
- if (!Array.isArray(node)) return false
1267
- const op = node[0]
1268
- if (typeof op === 'string') {
1269
- if (op.includes('.load') || op === 'memory.copy' || op === 'memory.size') return true
1270
- }
1271
- for (let i = 1; i < node.length; i++) if (readsMemory(node[i])) return true
1272
- return false
1273
- }
1274
-
1275
- /** True if `node` references state a `call` could mutate.
1276
- * Calls cannot touch caller locals (those live in the function frame), so
1277
- * pure expressions over locals + constants survive any intervening call; only
1278
- * memory loads, global reads, and table reads (or further calls) can be stale
1279
- * after one. */
1280
- const readsCallableState = (node) => {
1281
- if (!Array.isArray(node)) return false
1282
- const op = node[0]
1283
- if (typeof op === 'string') {
1284
- if (op === 'global.get' || op === 'table.get' || op === 'table.size') return true
1285
- if (op === 'call' || op === 'call_indirect' || op === 'return_call' || op === 'return_call_indirect') return true
1286
- if (op.includes('.load') || op === 'memory.copy' || op === 'memory.size') return true
1287
- }
1288
- for (let i = 1; i < node.length; i++) if (readsCallableState(node[i])) return true
1289
- return false
1290
- }
1291
-
1292
- /** True if `node` recursively contains an op that may write linear memory. */
1293
- const writesMemory = (node) => {
1294
- if (!Array.isArray(node)) return false
1295
- const op = node[0]
1296
- if (typeof op === 'string') {
1297
- if (op.endsWith('.store') || op === 'memory.copy' || op === 'memory.fill' || op === 'memory.init') return true
1298
- // Atomic RMW / store / notify all mutate memory; `.atomic.load` doesn't.
1299
- if (op.includes('.atomic.') && !op.endsWith('.load')) return true
1300
- }
1301
- for (let i = 1; i < node.length; i++) if (writesMemory(node[i])) return true
1302
- return false
1303
- }
1304
-
1305
- /** Try substitute local.get nodes with known values.
1306
- * When entering a nested scope (block/loop/if), drop tracking for any local
1307
- * that's re-assigned inside the subtree — the outer-tracked value is stale
1308
- * there. Without this, an outer `(local.set $x C)` would clobber an inner
1309
- * `(local.set $x V) (local.get $x)` (the inner get rewritten to `C` instead
1310
- * of `V`). Mostly latent until something — typically coalesceLocals — reuses
1311
- * one slot for the outer and inner roles, after which it surfaces as silent
1312
- * memory corruption. */
1313
- const substGets = (node, known) => {
1314
- if (!Array.isArray(node)) return node
1315
- const op = node[0]
1316
- if (op === 'local.get' && node.length === 2) {
1317
- const k = typeof node[1] === 'string' && known.get(node[1])
1318
- if (k && canSubst(k)) return clone(k.val)
1319
- return node
1320
- }
1321
- let inner = known
1322
- if (isBranchScope(op)) {
1323
- let cloned = null
1324
- walk(node, n => {
1325
- if (!Array.isArray(n)) return
1326
- if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string' && known.has(n[1])) {
1327
- if (!cloned) cloned = new Map(known)
1328
- cloned.delete(n[1])
1329
- }
1330
- })
1331
- if (cloned) inner = cloned
1332
- }
1333
- for (let i = 1; i < node.length; i++) {
1334
- const r = substGets(node[i], inner)
1335
- if (r !== node[i]) node[i] = r
1336
- // WASM evaluates operands left-to-right. A `local.set`/`local.tee` in this
1337
- // child updates the local before the next sibling reads it — drop tracked
1338
- // entries that are now stale, else a pre-tee constant leaks into the next
1339
- // sibling's `local.get` (visible after `coalesceLocals` aliases the tee'd
1340
- // local with a sibling-read local, e.g. `alloc($x<<3, $x)` collapsing to
1341
- // `alloc(BIG, SMALL)`).
1342
- if (i + 1 < node.length && Array.isArray(node[i])) {
1343
- walk(node[i], n => {
1344
- if (!Array.isArray(n)) return
1345
- if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') {
1346
- if (inner === known) inner = new Map(known)
1347
- inner.delete(n[1])
1348
- purgeRefs(inner, n[1])
1349
- }
1350
- })
1351
- }
1352
- }
1353
- return node
1354
- }
1355
-
1356
- /**
1357
- * Forward propagation pass: track local.set values and substitute local.gets.
1358
- * Returns true if any substitution was made.
1359
- * @param {Array} funcNode
1360
- * @param {Set<string>} params
1361
- * @param {Map<string,{gets:number,sets:number,tees:number}>} useCounts
1362
- */
1363
- const forwardPropagate = (funcNode, params, useCounts) => {
1364
- let changed = false
1365
- const getUseCount = name => useCounts.get(name) || { gets: 0, sets: 0, tees: 0 }
1366
- const known = new Map()
1367
-
1368
- for (let i = 1; i < funcNode.length; i++) {
1369
- const instr = funcNode[i]
1370
- if (!Array.isArray(instr)) continue
1371
- const op = instr[0]
1372
-
1373
- if (op === 'param' || op === 'result' || op === 'local' || op === 'type' || op === 'export') continue
1374
-
1375
- // Track local.set / local.tee values (tee writes too — its result also leaves
1376
- // the value on the stack but the local is updated identically to set).
1377
- if ((op === 'local.set' || op === 'local.tee') && instr.length === 3 && typeof instr[1] === 'string') {
1378
- // substGets returns its argument unchanged unless the whole subtree
1379
- // resolves to a substitution (bare `(local.get $x)` root case) — assign
1380
- // back so the bare-RHS pattern actually propagates.
1381
- const sr = substGets(instr[2], known)
1382
- if (sr !== instr[2]) { instr[2] = sr; changed = true }
1383
- // Nested `local.set`/`local.tee` inside the RHS already ran when the next
1384
- // statement begins — drop tracked values that read those locals, else a
1385
- // later `local.get` substitutes a stale expression (e.g. `$ptr`'s
1386
- // `(local.get $ai0)` after a nested `(local.tee $ai0 …)` overwrites it).
1387
- walk(instr[2], n => {
1388
- if (!Array.isArray(n)) return
1389
- if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string')
1390
- { known.delete(n[1]); purgeRefs(known, n[1]) }
1391
- })
1392
- const uses = getUseCount(instr[1])
1393
- purgeRefs(known, instr[1]) // entries that read this local just went stale
1394
- // Any tracked value whose RHS reads memory must be invalidated by the
1395
- // RHS itself if it writes memory (rare — only via nested store/copy/etc.,
1396
- // which would also pass through the post-statement purge below).
1397
- if (writesMemory(instr[2])) {
1398
- for (const [key, tracked] of known) if (tracked.readsMem) known.delete(key)
1399
- }
1400
- known.set(instr[1], {
1401
- val: instr[2], pure: isPure(instr[2]),
1402
- readsMem: readsMemory(instr[2]),
1403
- singleUse: uses.gets <= 1 && uses.sets <= 1 && uses.tees === 0,
1404
- copy: isLocalCopy(instr[2], instr[1])
1405
- })
1406
- continue
1407
- }
1408
-
1409
- // Invalidate at control-flow boundaries
1410
- if (isBranchScope(op)) known.clear()
1411
- // Calls invalidate tracked values that read state a callee can mutate
1412
- // (memory, globals, tables, nested calls). Pure expressions over locals
1413
- // and constants survive — callees can't reach caller locals.
1414
- if (op === 'call' || op === 'call_indirect' || op === 'return_call' || op === 'return_call_indirect')
1415
- for (const [key, tracked] of known) if (readsCallableState(tracked.val)) known.delete(key)
1416
-
1417
- // Substitute: standalone local.get (walkPost can't replace root)
1418
- if (op === 'local.get' && instr.length === 2 && typeof instr[1] === 'string') {
1419
- const tracked = known.get(instr[1])
1420
- if (tracked && canSubst(tracked)) {
1421
- const replacement = clone(tracked.val)
1422
- instr.length = 0; instr.push(...(Array.isArray(replacement) ? replacement : [replacement]))
1423
- changed = true; continue
1424
- }
1425
- }
1426
-
1427
- // Substitute nested local.gets (skip control-flow nodes — locals may be reassigned inside)
1428
- if (op !== 'block' && op !== 'loop' && op !== 'if') {
1429
- const prev = clone(instr)
1430
- substGets(instr, known)
1431
- if (!equal(prev, instr)) changed = true
1432
- // Invalidate tracking for any names written by a nested set/tee — those
1433
- // writes happened mid-expression and the substGets above used the
1434
- // pre-write tracked value (correct), but later reads must see the new
1435
- // (untracked) value, not the stale constant.
1436
- walk(instr, n => {
1437
- if (Array.isArray(n) && (n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string')
1438
- { known.delete(n[1]); purgeRefs(known, n[1]) }
1439
- })
1440
- // Memory write in this statement (any nested store / memory.copy / etc.)
1441
- // invalidates every tracked value whose RHS reads memory: inlining one
1442
- // later would substitute a now-stale load. Without this, a swap idiom
1443
- // (local.set $t (f64.load $p)) (f64.store $p (f64.load $q)) (f64.store $q (local.get $t))
1444
- // collapses to two stores that round-trip the same value:
1445
- // (f64.store $p (f64.load $q)) (f64.store $q (f64.load $p)) ;; bug
1446
- if (writesMemory(instr)) {
1447
- for (const [key, tracked] of known) if (tracked.readsMem) known.delete(key)
1448
- }
1449
- }
1450
- }
1451
-
1452
- return changed
1453
- }
1454
-
1455
- /**
1456
- * Remove adjacent (local.set $x expr) (local.get $x) pairs when $x has no other uses.
1457
- * Returns true if any pair was removed.
1458
- * @param {Array} funcNode
1459
- * @param {Set<string>} params
1460
- * @param {Map<string,{gets:number,sets:number,tees:number}>} useCounts
1461
- */
1462
- const eliminateSetGetPairs = (funcNode, params, useCounts) => {
1463
- let changed = false
1464
-
1465
- for (let i = 1; i < funcNode.length - 1; i++) {
1466
- const setNode = funcNode[i]
1467
- const getNode = funcNode[i + 1]
1468
- if (!Array.isArray(setNode) || setNode[0] !== 'local.set' || setNode.length !== 3) continue
1469
- if (!Array.isArray(getNode) || getNode[0] !== 'local.get' || getNode.length !== 2) continue
1470
- const name = setNode[1]
1471
- if (getNode[1] !== name || params.has(name)) continue
1472
- const uses = useCounts.get(name) || { gets: 0, sets: 0, tees: 0 }
1473
- // Must be exactly 1 set and 1 get (the pair), no tees
1474
- if (uses.sets !== 1 || uses.gets !== 1 || uses.tees !== 0) continue
1475
- // Replace the pair with just the expression
1476
- const expr = clone(setNode[2])
1477
- funcNode.splice(i, 2, ...(Array.isArray(expr) ? [expr] : [expr]))
1478
- changed = true
1479
- i-- // adjust index because we removed 2 and inserted 1
1480
- }
1481
-
1482
- return changed
1483
- }
1484
-
1485
- /**
1486
- * Convert (local.set $x expr) (local.get $x) to (local.tee $x expr)
1487
- * when $x has additional uses beyond this pair.
1488
- * @param {Array} funcNode
1489
- * @param {Set<string>} params
1490
- * @param {Map<string,{gets:number,sets:number,tees:number}>} useCounts
1491
- */
1492
- const createLocalTees = (funcNode, params, useCounts) => {
1493
- let changed = false
1494
-
1495
- for (let i = 1; i < funcNode.length - 1; i++) {
1496
- const setNode = funcNode[i]
1497
- const getNode = funcNode[i + 1]
1498
- if (!Array.isArray(setNode) || setNode[0] !== 'local.set' || setNode.length !== 3) continue
1499
- if (!Array.isArray(getNode) || getNode[0] !== 'local.get' || getNode.length !== 2) continue
1500
- const name = setNode[1]
1501
- if (getNode[1] !== name || params.has(name)) continue
1502
- const uses = useCounts.get(name) || { gets: 0, sets: 0, tees: 0 }
1503
- // Only if there's more than just this set+get pair
1504
- if (uses.sets + uses.gets + uses.tees <= 2) continue
1505
- // Replace with local.tee (set+get combined)
1506
- funcNode.splice(i, 2, ['local.tee', name, clone(setNode[2])])
1507
- changed = true
1508
- }
1509
-
1510
- return changed
1511
- }
1512
-
1513
- /**
1514
- * Remove dead stores and unused local declarations in a reverse pass.
1515
- * Returns true if anything was removed.
1516
- * @param {Array} funcNode
1517
- * @param {Set<string>} params
1518
- * @param {Map<string,{gets:number,sets:number,tees:number}>} useCounts
1519
- */
1520
- const eliminateDeadStores = (funcNode, params, useCounts) => {
1521
- let changed = false
1522
- const getPostUseCount = name => useCounts.get(name) || { gets: 0, sets: 0, tees: 0 }
1523
-
1524
- for (let i = funcNode.length - 1; i >= 1; i--) {
1525
- const sub = funcNode[i]
1526
- if (!Array.isArray(sub)) continue
1527
- const name = typeof sub[1] === 'string' ? sub[1] : null
1528
- if (!name || params.has(name)) continue
1529
- const uses = getPostUseCount(name)
1530
- // Dead store: set but never read.
1531
- if (sub[0] === 'local.set' && uses.gets === 0 && uses.tees === 0) {
1532
- // `(local.set $x VALUE)` — drop the store with its value, but only when
1533
- // VALUE is pure (its side effects would otherwise still need to run).
1534
- if (sub.length === 3) {
1535
- if (isPure(sub[2])) { funcNode.splice(i, 1); changed = true }
1536
- }
1537
- // Bare `(local.set $x)` — the value is implicit on the stack (e.g. an
1538
- // exception payload landing from a `try_table` catch). Demote to `drop`
1539
- // so the dead store goes away without unbalancing the stack.
1540
- else if (sub.length === 2) {
1541
- funcNode[i] = ['drop']; changed = true
1542
- }
1543
- }
1544
- // Unused local declaration
1545
- else if (sub[0] === 'local' && name[0] === '$' && uses.gets === 0 && uses.sets === 0 && uses.tees === 0) {
1546
- funcNode.splice(i, 1); changed = true
1547
- }
1548
- }
1549
-
1550
- return changed
1551
- }
1552
-
1553
- /**
1554
- * Drop `(local.set $x A)` when the very next statement re-sets $x without reading it
1555
- * first (A pure). The two writes are adjacent, so A's value is overwritten before any
1556
- * observation — it's dead. The whole-function {@link eliminateDeadStores} misses this:
1557
- * it only fires when $x is read NOWHERE, whereas here $x is live later, just not
1558
- * between these two writes. Pairs with copy-propagation, which rewrites
1559
- * `$x=$y; $x=f($x)` to `$x=$y; $x=f($y)` — an adjacent dead store this removes,
1560
- * collapsing the round-trip jz's value-model lowering leaves behind.
1561
- * @param {Array} funcNode a straight-line scope (body / block / loop / then / else)
1562
- * @param {Set<string>} params
1563
- */
1564
- const eliminateAdjacentDeadStores = (funcNode, params) => {
1565
- let changed = false
1566
- for (let i = 1; i < funcNode.length - 1; i++) {
1567
- const a = funcNode[i], b = funcNode[i + 1]
1568
- // `a` must be a plain set (a tee leaves its value on the stack — not removable);
1569
- // `b` may be a set OR a tee (both overwrite the local before `a`'s value is read).
1570
- if (!Array.isArray(a) || a[0] !== 'local.set' || a.length !== 3) continue
1571
- if (!Array.isArray(b) || (b[0] !== 'local.set' && b[0] !== 'local.tee') || b.length !== 3 || b[1] !== a[1]) continue
1572
- if (params.has(a[1]) || !isPure(a[2])) continue
1573
- // Dead only if b's value doesn't read $x before overwriting it.
1574
- let reads = false
1575
- walk(b[2], n => { if (Array.isArray(n) && (n[0] === 'local.get' || n[0] === 'local.tee') && n[1] === a[1]) reads = true })
1576
- if (reads) continue
1577
- funcNode.splice(i, 1); changed = true; i--
1578
- }
1579
- return changed
1580
- }
1581
-
1582
- /**
1583
- * Propagate values through locals and eliminate single-use/dead locals.
1584
- * Constants propagate to all uses; pure single-use exprs inline into get site.
1585
- * Multi-pass with batch counting for convergence.
1586
- */
1587
- /** Block-like nodes whose body is a straight-line instruction list (after any header). */
1588
- const isScopeNode = (n) => Array.isArray(n) &&
1589
- (n[0] === 'func' || n[0] === 'block' || n[0] === 'loop' || n[0] === 'then' || n[0] === 'else')
1590
-
1591
- /** Branch-target scopes: ops that carry an optional label/result header and can be jumped to via br/br_if. */
1592
- const isBranchScope = (op) => op === 'block' || op === 'loop' || op === 'if'
1593
-
1594
- const propagate = (ast) => {
1595
- walk(ast, (funcNode) => {
1596
- if (!Array.isArray(funcNode) || funcNode[0] !== 'func') return
1597
-
1598
- const params = new Set()
1599
- for (const sub of funcNode)
1600
- if (Array.isArray(sub) && sub[0] === 'param' && typeof sub[1] === 'string') params.add(sub[1])
1601
-
1602
- // Propagation runs per straight-line scope: the function body and every nested
1603
- // `block`/`loop`/`then`/`else` (including ones embedded in an expression, e.g. the
1604
- // `(block (result i32) …)` an inlined call leaves behind). Collect scopes deepest-
1605
- // first so inner simplifications shrink the use-counts the outer scopes see.
1606
- // Use-counts are always whole-function — a set/get pair or dead store is only
1607
- // touched when it's globally the sole occurrence, so per-scope work stays sound.
1608
- const scopes = []
1609
- walkPost(funcNode, n => { if (isScopeNode(n)) scopes.push(n) })
1610
-
1611
- // One use-count per round, shared by every scope: substitutions only ever
1612
- // *drop* gets, so a stale count can only make a sub-pass act more cautiously
1613
- // (skip a not-yet-provably-dead store, decline a not-yet-provably-single use) —
1614
- // never wrongly. The next round re-counts and mops up. (Recounting per sub-pass
1615
- // per scope is O(scopes·funcSize) and crippling on big modules.)
1616
- for (let round = 0; round < MAX_PROP_ROUNDS; round++) {
1617
- const useCounts = countLocalUses(funcNode)
1618
- let progressed = false
1619
- for (const scope of scopes) {
1620
- if (forwardPropagate(scope, params, useCounts)) progressed = true
1621
- if (eliminateSetGetPairs(scope, params, useCounts)) progressed = true
1622
- if (createLocalTees(scope, params, useCounts)) progressed = true
1623
- if (eliminateDeadStores(scope, params, useCounts)) progressed = true
1624
- if (eliminateAdjacentDeadStores(scope, params)) progressed = true
1625
- }
1626
- if (!progressed) break
1627
- }
1628
- })
1629
-
1630
- return ast
1631
- }
1632
-
1633
- // ==================== FUNCTION INLINING ====================
1634
-
1635
- /**
1636
- * Inline tiny functions (single expression, no locals, no params or simple params).
1637
- * @param {Array} ast
1638
- * @returns {Array}
1639
- */
1640
- const inline = (ast) => {
1641
- if (!Array.isArray(ast) || ast[0] !== 'module') return ast
1642
-
1643
- // Collect inlinable functions
1644
- const inlinable = new Map() // $name → { body, params }
1645
-
1646
- for (const node of ast.slice(1)) {
1647
- if (!Array.isArray(node) || node[0] !== 'func') continue
1648
-
1649
- const name = typeof node[1] === 'string' && node[1][0] === '$' ? node[1] : null
1650
- if (!name) continue
1651
-
1652
- // Check if function is small enough to inline
1653
- let params = []
1654
- let body = []
1655
- let hasLocals = false
1656
- let hasExport = false
1657
-
1658
- for (let i = 1; i < node.length; i++) {
1659
- const sub = node[i]
1660
- if (!Array.isArray(sub)) continue
1661
- if (sub[0] === 'param') {
1662
- // Collect param names and types
1663
- if (typeof sub[1] === 'string' && sub[1][0] === '$') {
1664
- params.push({ name: sub[1], type: sub[2] })
1665
- } else {
1666
- // Unnamed params - harder to inline
1667
- params = null
1668
- break
1669
- }
1670
- } else if (sub[0] === 'local') {
1671
- hasLocals = true
1672
- } else if (sub[0] === 'export') {
1673
- hasExport = true
1674
- } else if (sub[0] !== 'result' && sub[0] !== 'type') {
1675
- body.push(sub)
1676
- }
1677
- }
1678
-
1679
- // Inline: no locals, <= 4 params, single expression body, not exported
1680
- if (params && !hasLocals && !hasExport && params.length <= 4 && body.length === 1) {
1681
- // Check if function mutates any of its params (local.set/tee on param),
1682
- // or contains a control-transfer op (`return`, `return_call`,
1683
- // `return_call_indirect`). Inlining such bodies into a different-typed
1684
- // caller would propagate the transfer to the caller, returning from the
1685
- // wrong function with the wrong type. Lifting the body into a
1686
- // `(block $exit ...)` and rewriting returns to `(br $exit X)` would
1687
- // unlock these — left for a future pass.
1688
- const paramNames = new Set(params.map(p => p.name))
1689
- let mutatesParam = false
1690
- let hasReturn = false
1691
- walk(body[0], (n) => {
1692
- if (!Array.isArray(n)) return
1693
- if ((n[0] === 'local.set' || n[0] === 'local.tee') && paramNames.has(n[1])) {
1694
- mutatesParam = true
1695
- }
1696
- if (n[0] === 'return' || n[0] === 'return_call' || n[0] === 'return_call_indirect') {
1697
- hasReturn = true
1698
- }
1699
- })
1700
- if (!mutatesParam && !hasReturn) {
1701
- inlinable.set(name, { body: body[0], params })
1702
- }
1703
- }
1704
- }
1705
-
1706
- // Replace calls with inlined body
1707
- if (inlinable.size === 0) return ast
1708
-
1709
- walkPost(ast, (node) => {
1710
- if (!Array.isArray(node) || node[0] !== 'call') return
1711
- const fname = node[1]
1712
- if (!inlinable.has(fname)) return
1713
-
1714
- const { body, params } = inlinable.get(fname)
1715
- const args = node.slice(2)
1716
-
1717
- // Simple case: no params
1718
- if (params.length === 0) {
1719
- return clone(body)
1720
- }
1721
-
1722
- // Substitute params with args
1723
- const substituted = walkPost(clone(body), (n) => {
1724
- if (!Array.isArray(n) || n[0] !== 'local.get') return
1725
- const local = n[1]
1726
- const paramIdx = params.findIndex(p => p.name === local)
1727
- if (paramIdx !== -1 && args[paramIdx]) {
1728
- return clone(args[paramIdx])
1729
- }
1730
- })
1731
-
1732
- return substituted
1733
- })
1734
-
1735
- return ast
1736
- }
1737
-
1738
- // ==================== INLINE-ONCE ====================
1739
-
1740
- let inlineUid = 0
1741
-
1742
- /**
1743
- * Devirtualize `call_indirect` through NaN-boxed closure values with a statically
1744
- * known candidate set. `let f = c ? a : b; … f(x)` emits a select of two i64
1745
- * closure constants into an f64 local; every call site then derives the table
1746
- * slot from that local's bits:
1747
- * (i32.wrap_i64 (i64.and (i64.shr_u (i64.reinterpret_f64 (local.get $f))
1748
- * (i64.const 32)) (i64.const 32767)))
1749
- * When EVERY write to $f in the function is such a constant set (≤2 candidates),
1750
- * each call site becomes a guarded direct call —
1751
- * (if (result …) (i64.eq (i64.reinterpret_f64 (local.get $f)) (i64.const C1))
1752
- * (then (call $tramp1 …args)) (else <next guard | original call_indirect>))
1753
- * — with the ORIGINAL call_indirect kept as the final arm, so unknown flows
1754
- * (zero-init paths the analysis can't see) behave exactly as before: the rewrite
1755
- * is a pure branch-predicted fast path, ~25% on callback loops, and the direct
1756
- * calls participate in inlining. A trivially-constant slot ((i32.const N) after
1757
- * fold) becomes a bare direct call with no guard.
1758
- *
1759
- * Soundness: the guard compares the SAME bits the slot extraction reads, so
1760
- * whichever constant flows to the call dispatches identically in both forms;
1761
- * candidates that don't resolve to an elem entry (or whose target's signature
1762
- * differs from the call's type — would-be runtime trap) disable the site. Any
1763
- * table mutation op in the module disables the pass entirely. The function
1764
- * table is exported for host-side closure invocation (reads); host mutation of
1765
- * it is outside the ABI contract, same as the closure-constant model itself.
1766
- */
1767
- const devirt = (ast) => {
1768
- if (!Array.isArray(ast) || ast[0] !== 'module') return ast
1769
- // Module facts: elem slot → func name (constant offsets only), type defs,
1770
- // named funcs. Bail on dynamic elem offsets or table mutation anywhere.
1771
- const slots = new Map(), typeDefs = new Map(), funcsByName = new Map()
1772
- let tableMutated = false
1773
- walk(ast, n => {
1774
- if (Array.isArray(n) && typeof n[0] === 'string' &&
1775
- (n[0] === 'table.set' || n[0] === 'table.grow' || n[0] === 'table.init' ||
1776
- n[0] === 'table.copy' || n[0] === 'table.fill')) tableMutated = true
1777
- })
1778
- if (tableMutated) return ast
1779
- for (const node of ast.slice(1)) {
1780
- if (!Array.isArray(node)) continue
1781
- if (node[0] === 'elem') {
1782
- const off = node[1]
1783
- if (!Array.isArray(off) || off[0] !== 'i32.const') return ast
1784
- let base = Number(off[1])
1785
- for (let i = 2; i < node.length; i++)
1786
- if (typeof node[i] === 'string' && node[i][0] === '$') slots.set(base++, node[i])
1787
- }
1788
- else if (node[0] === 'type' && typeof node[1] === 'string') typeDefs.set(node[1], node[2])
1789
- else if (node[0] === 'func' && typeof node[1] === 'string') funcsByName.set(node[1], node)
1790
- }
1791
- if (!slots.size) return ast
1792
-
1793
- // Closure-valued GLOBALS: multiProp function-property slots dissolve into
1794
- // f64 module globals (plan/scope.js flattenFuncNamespaces) — the subscript
1795
- // hook pattern (`parse.space = fn`, overridden per feature at module init).
1796
- // Every observed `global.set $G <closure-const>` contributes a candidate;
1797
- // a non-const store poisons the global. The guard ladder stays SOUND even
1798
- // with an incomplete set — unknown values take the original call_indirect
1799
- // fallback arm — so candidates only need to cover the hot value.
1800
- const globalCands = new Map()
1801
-
1802
- // All i64 const handling is canonical-hex STRING math (see the i64 VALUE
1803
- // CONTRACT above): a helper RETURNING a BigInt is kind-erased in-kernel and
1804
- // every op on it misdispatches — devirt silently no-ops.
1805
- const isC64 = (n, hex) => Array.isArray(n) && n[0] === 'i64.const' && _i64Canon(n[1]) === hex
1806
- const MASK15 = '0x0000000000007fff', SHIFT32 = '0x0000000000000020'
1807
- // Collect the i64 constants reachable through reinterpret/select arms.
1808
- const boxConsts = (v, out) => {
1809
- if (!Array.isArray(v)) return false
1810
- if (v[0] === 'i64.const') { out.push(v); return true }
1811
- // f64-carrier closure const (`f64.const nan:0xHEX`) — the form module-init
1812
- // global.set stores for hook slots; normalize to its i64 bits.
1813
- if (v[0] === 'f64.const' && typeof v[1] === 'string' && v[1].startsWith('nan:')) {
1814
- out.push(['i64.const', _i64Canon(v[1].slice(4))])
1815
- return true
1816
- }
1817
- if (v[0] === 'f64.reinterpret_i64' && v.length === 2) return boxConsts(v[1], out)
1818
- if (v[0] === 'select' && v.length === 4) return boxConsts(v[1], out) && boxConsts(v[2], out)
1819
- return false
1820
- }
1821
- // Per-global write VALUES collected first; candidates resolved by fixpoint so
1822
- // the hook-alias pattern works: `baseSpace = parse.space ?? default` stores a
1823
- // select/if whose arms are a GLOBAL READ of another const slot plus a const —
1824
- // candidates = union through the alias edge. Soundness is unchanged (the
1825
- // guard ladder keeps the original indirect fallback for unknown values); the
1826
- // fixpoint only widens the candidate set. `if (result f64)` arms and
1827
- // `__is_nullish`-style guard CONDITIONS are skipped — only VALUE positions
1828
- // contribute. A write that contains anything else poisons the global.
1829
- const globalWrites = new Map()
1830
- walk(ast, n => {
1831
- if (!Array.isArray(n) || n[0] !== 'global.set' || typeof n[1] !== 'string') return
1832
- if (!globalWrites.has(n[1])) globalWrites.set(n[1], [])
1833
- globalWrites.get(n[1]).push(n[2])
1834
- })
1835
- // Value-position scan: consts and global.get leaves, through reinterprets,
1836
- // select arms and if/result arms. Returns false (poison) on anything else.
1837
- const candLeaves = (v, consts, reads) => {
1838
- if (!Array.isArray(v)) return false
1839
- if (v[0] === 'i64.const') { consts.push(v); return true }
1840
- if (v[0] === 'f64.const' && typeof v[1] === 'string' && v[1].startsWith('nan:')) {
1841
- consts.push(['i64.const', _i64Canon(v[1].slice(4))]); return true
1842
- }
1843
- if ((v[0] === 'f64.reinterpret_i64' || v[0] === 'i64.reinterpret_f64') && v.length === 2)
1844
- return candLeaves(v[1], consts, reads)
1845
- if (v[0] === 'global.get' && typeof v[1] === 'string') { reads.push(v[1]); return true }
1846
- if (v[0] === 'local.get' || v[0] === 'local.tee') {
1847
- // a tee'd copy of one of the above — the tee VALUE was already scanned
1848
- // where it was written; the bare read alone proves nothing → poison
1849
- return v[0] === 'local.tee' && v.length === 3 ? candLeaves(v[2], consts, reads) : false
1850
- }
1851
- if (v[0] === 'select' && v.length === 4)
1852
- return candLeaves(v[1], consts, reads) && candLeaves(v[2], consts, reads)
1853
- if (v[0] === 'if') {
1854
- // (if (result T) COND (then A) (else B)) — arms are value positions
1855
- let ok = true, seenArm = false
1856
- for (let i = 1; i < v.length; i++) {
1857
- const p = v[i]
1858
- if (!Array.isArray(p)) continue
1859
- if (p[0] === 'then' || p[0] === 'else') {
1860
- seenArm = true
1861
- if (p.length !== 2 || !candLeaves(p[1], consts, reads)) ok = false
1862
- }
1863
- }
1864
- return ok && seenArm
1865
- }
1866
- return false
1867
- }
1868
- const writeFacts = new Map() // global → { consts: [...], reads: [...] } | null
1869
- for (const [g, ws] of globalWrites) {
1870
- let consts = [], reads = [], ok = true
1871
- for (const w of ws) if (!candLeaves(w, consts, reads)) { ok = false; break }
1872
- writeFacts.set(g, ok ? { consts, reads } : null)
1873
- }
1874
- // Fixpoint: a global's candidates = its const writes ∪ candidates of every
1875
- // global it reads in value position. A poisoned alias poisons the reader.
1876
- let changed = true
1877
- const resolved = new Map()
1878
- while (changed) {
1879
- changed = false
1880
- for (const [g, f] of writeFacts) {
1881
- if (resolved.get(g) === null) continue
1882
- if (f === null) { if (resolved.get(g) !== null) { resolved.set(g, null); changed = true } continue }
1883
- const m = resolved.get(g) || new Map()
1884
- const before = m.size
1885
- let poisoned = false
1886
- for (const c of f.consts) m.set(_i64Canon(c[1]), c)
1887
- for (const r of f.reads) {
1888
- if (writeFacts.get(r) === null || resolved.get(r) === null) { poisoned = true; break }
1889
- const rm = resolved.get(r)
1890
- if (rm) for (const [hex, c] of rm) m.set(hex, c)
1891
- }
1892
- if (poisoned) { resolved.set(g, null); changed = true; continue }
1893
- if (!resolved.has(g) || m.size !== before) { resolved.set(g, m); changed = true }
1894
- }
1895
- }
1896
- for (const [g, m] of resolved) globalCands.set(g, m)
1897
-
1898
- // The slot-extraction idiom — returns the source local name or null.
1899
- const matchSlotOfLocal = (e) => {
1900
- if (!Array.isArray(e) || e[0] !== 'i32.wrap_i64') return null
1901
- const a = e[1]
1902
- if (!Array.isArray(a) || a[0] !== 'i64.and') return null
1903
- let sh = a[1], mk = a[2]
1904
- if (!isC64(mk, MASK15)) { sh = a[2]; mk = a[1] }
1905
- if (!isC64(mk, MASK15) || !Array.isArray(sh) || sh[0] !== 'i64.shr_u' || !isC64(sh[2], SHIFT32)) return null
1906
- const ri = sh[1]
1907
- if (!Array.isArray(ri) || ri[0] !== 'i64.reinterpret_f64') return null
1908
- const leaf = ri[1]
1909
- if (Array.isArray(leaf) && leaf[0] === 'local.get' && typeof leaf[1] === 'string') return { local: leaf[1] }
1910
- if (Array.isArray(leaf) && leaf[0] === 'global.get' && typeof leaf[1] === 'string') return { global: leaf[1] }
1911
- return null
1912
- }
1913
- // Canonical "params -> results" token string for signature comparison.
1914
- const tokSig = (parts) => {
1915
- const ps = [], rs = []
1916
- for (const p of parts) {
1917
- if (!Array.isArray(p)) continue
1918
- if (p[0] === 'param') { for (const t of p.slice(1)) if (typeof t === 'string' && t[0] !== '$') ps.push(t) }
1919
- else if (p[0] === 'result') rs.push(...p.slice(1))
1920
- }
1921
- return ps.join(',') + '->' + rs.join(',')
1922
- }
1923
-
1924
- for (const fn of funcsByName.values()) {
1925
- // Candidate sets: local → Map<bits, constNode>, or null once poisoned.
1926
- // Params are poisoned (incoming value unknown).
1927
- const cands = new Map()
1928
- for (const part of fn)
1929
- if (Array.isArray(part) && part[0] === 'param' && typeof part[1] === 'string') cands.set(part[1], null)
1930
- walk(fn, n => {
1931
- if (!Array.isArray(n) || (n[0] !== 'local.set' && n[0] !== 'local.tee') || typeof n[1] !== 'string') return
1932
- if (cands.get(n[1]) === null) return
1933
- const out = []
1934
- if (boxConsts(n[2], out)) {
1935
- const m = cands.get(n[1]) || new Map()
1936
- for (const c of out) m.set(_i64Canon(c[1]), c)
1937
- cands.set(n[1], m)
1938
- } else if (Array.isArray(n[2]) && n[2][0] === 'global.get' && typeof n[2][1] === 'string'
1939
- && globalCands.get(n[2][1])) {
1940
- // promoteGlobals snapshot (`$_pg = global.get $G`) — inherit G's set
1941
- const g = globalCands.get(n[2][1])
1942
- const m = cands.get(n[1]) || new Map()
1943
- for (const [hex, c] of g) m.set(hex, c)
1944
- cands.set(n[1], m)
1945
- } else cands.set(n[1], null)
1946
- })
1947
-
1948
- walkPost(fn, (n, parent) => {
1949
- if (!Array.isArray(n) || n[0] !== 'call_indirect') return
1950
- // A call_indirect sitting directly under an `else` is (or looks exactly
1951
- // like) the fallback arm of an existing guard — never re-wrap it, so the
1952
- // pass is idempotent across repeated optimize() runs.
1953
- if (parent && parent[0] === 'else') return
1954
- const typeUse = Array.isArray(n[1]) && n[1][0] === 'type' ? n[1] : null
1955
- if (!typeUse) return
1956
- const sig = typeDefs.get(typeUse[1])
1957
- const callSig = Array.isArray(sig) ? tokSig(sig.slice(1)) : null
1958
- if (callSig == null) return
1959
- const results = []
1960
- for (const s of sig.slice(1)) if (Array.isArray(s) && s[0] === 'result') results.push(...s.slice(1))
1961
- const args = n.slice(2, -1)
1962
- const idx = n[n.length - 1]
1963
- const sigOk = (name) => {
1964
- const target = funcsByName.get(name)
1965
- if (!target) return false
1966
- const tu = target.find(p => Array.isArray(p) && p[0] === 'type')
1967
- if (tu) return tu[1] === typeUse[1] ||
1968
- (typeDefs.get(tu[1]) && tokSig(typeDefs.get(tu[1]).slice(1)) === callSig)
1969
- return tokSig(target.slice(2)) === callSig
1970
- }
1971
- // Constant slot → bare direct call.
1972
- if (Array.isArray(idx) && idx[0] === 'i32.const') {
1973
- const name = slots.get(Number(idx[1]))
1974
- return name && sigOk(name) ? ['call', name, ...args] : undefined
1975
- }
1976
- const f = matchSlotOfLocal(idx)
1977
- if (!f) return
1978
- const m = f.local != null ? cands.get(f.local) : globalCands.get(f.global)
1979
- if (!m || m.size === 0 || m.size > 4) return
1980
- const arms = []
1981
- for (const cNode of m.values()) {
1982
- const name = slots.get(_i64HiU(_i64Canon(cNode[1])) & 32767)
1983
- if (!name || !sigOk(name)) return
1984
- arms.push([cNode, name])
1985
- }
1986
- const readBack = f.local != null ? ['local.get', f.local] : ['global.get', f.global]
1987
- let out = n
1988
- for (let i = arms.length - 1; i >= 0; i--) {
1989
- const [cNode, name] = arms[i]
1990
- out = ['if', ...(results.length ? [['result', ...results]] : []),
1991
- ['i64.eq', ['i64.reinterpret_f64', clone(readBack)], clone(cNode)],
1992
- ['then', ['call', name, ...args.map(clone)]],
1993
- ['else', out]]
1994
- }
1995
- return out
1996
- })
1997
- }
1998
- return ast
1999
- }
2000
-
2001
- /**
2002
- * Inline functions that are called from exactly one place into their lone caller,
2003
- * then delete them. Unlike {@link inline} (which duplicates tiny stateless bodies),
2004
- * this never duplicates code and never inflates: each inlined function drops a
2005
- * function-section entry, a type-section entry (if now unused), and a `call`
2006
- * instruction, paying back only a `block`/`local.set` wrapper. This is what
2007
- * `wasm-opt -Oz` does — collapsing helper chains down to a couple of functions —
2008
- * and it's the bulk of the gap between hand-tuned WASM and naive codegen.
2009
- *
2010
- * A function `$f` qualifies when it is, all of:
2011
- * • named, with named params and locals (numeric indices can't be safely renamed);
2012
- * • referenced exactly once across the whole module, by a plain `call` (no
2013
- * `return_call`, `ref.func`, `elem`, `export`, or `start` reference, and not
2014
- * recursive);
2015
- * • single-result or void (a multi-value result can't be modeled as `(block (result …))`);
2016
- * • free of numeric (depth-relative) branch labels — those would shift under the
2017
- * extra block nesting — and of `return_call*` in its body.
2018
- *
2019
- * `(call $f a0 a1 …)` becomes
2020
- * (block $__inlN (result T)?
2021
- * (local.set $__inlN_p0 a0) (local.set $__inlN_p1 a1) … ;; args evaluated once, in order
2022
- * …body, params/locals renamed to $__inlN_*, `return X` → `br $__inlN X`…)
2023
- * and the renamed params+locals are appended to the caller's `local` decls; the
2024
- * body's own block/loop/if labels are renamed too so they can't shadow the caller's.
2025
- * Runs to a fixpoint so helper chains fully collapse.
2026
- *
2027
- * @param {Array} ast
2028
- * @returns {Array}
2029
- */
2030
- const inlineOnce = (ast) => {
2031
- if (!Array.isArray(ast) || ast[0] !== 'module') return ast
2032
-
2033
- const HEAD = new Set(['export', 'type', 'param', 'result', 'local'])
2034
- const bodyStart = (fn) => {
2035
- let i = 2
2036
- while (i < fn.length && (typeof fn[i] === 'string' || (Array.isArray(fn[i]) && HEAD.has(fn[i][0])))) i++
2037
- return i
2038
- }
2039
- const isBranch = op => op === 'br' || op === 'br_if' || op === 'br_table'
2040
- // A subtree we can't lift into a (block …): depth-relative branch labels (shift
2041
- // under added nesting) or tail calls (would escape the wrapping block).
2042
- const unsafe = (n) => {
2043
- if (!Array.isArray(n)) return false
2044
- const op = n[0]
2045
- if (op === 'return_call' || op === 'return_call_indirect' || op === 'return_call_ref') return true
2046
- if (op === 'try' || op === 'try_table' || op === 'delegate' || op === 'rethrow') return true // exception labels — not handled by the relabeler below
2047
- if (isBranch(op)) for (let i = 1; i < n.length; i++) if (typeof n[i] === 'number' || (typeof n[i] === 'string' && /^\d+$/.test(n[i]))) return true
2048
- for (let i = 1; i < n.length; i++) if (unsafe(n[i])) return true
2049
- return false
2050
- }
2051
- const callsSelf = (n, name) => {
2052
- if (!Array.isArray(n)) return false
2053
- if ((n[0] === 'call' || n[0] === 'return_call') && n[1] === name) return true
2054
- for (let i = 1; i < n.length; i++) if (callsSelf(n[i], name)) return true
2055
- return false
2056
- }
2057
- // Locals must be re-zeroed each time the inlined block is entered IF the
2058
- // callee body actually relies on zero-init — i.e. some path reads the local
2059
- // before any unconditional write. In the original callee they got fresh
2060
- // zero-init per call; after inlining they're outer-func locals, zeroed only
2061
- // at outer entry, so a caller-loop that re-enters the inlined block reads
2062
- // stale values otherwise. Returns null for any type we can't safely
2063
- // zero-init here (skip inlining such callees).
2064
- const zeroFor = (t) => {
2065
- if (t === 'i32') return ['i32.const', 0]
2066
- if (t === 'i64') return ['i64.const', 0]
2067
- if (t === 'f32') return ['f32.const', 0]
2068
- if (t === 'f64') return ['f64.const', 0]
2069
- if (t === 'v128') return ['v128.const', 'i64x2', 0, 0]
2070
- // Nullable ref types (`(ref null …)`, `funcref`, `externref`, `anyref`, etc.)
2071
- // zero-init to `ref.null …` per call; emitting that here would need the exact
2072
- // heap-type. Non-nullable refs aren't zero-init at all (codegen must seed
2073
- // them). Either way, skip — let the call survive.
2074
- return null
2075
- }
2076
-
2077
- // Locals whose first observed use is a read — or whose first write is inside
2078
- // a conditional branch, where the alternate path bypasses it — depend on
2079
- // zero-init and need a reset when inlined into a caller-loop. Locals that
2080
- // are unconditionally written before any read (the common scratch pattern,
2081
- // e.g. `(local.set $bits (local.get $ptr))` opening a helper) don't, and
2082
- // emitting a spurious reset would only inflate that local's set-count and
2083
- // block downstream propagation/coalescing. Mirrors `coalesceLocals`'
2084
- // `readsZero` heuristic.
2085
- const needsReset = (body, name) => {
2086
- let seen = false, conditional = false, depth = 0
2087
- const visit = (n) => {
2088
- if (seen || !Array.isArray(n)) return
2089
- const op = n[0]
2090
- const isSet = op === 'local.set' || op === 'local.tee'
2091
- if ((isSet || op === 'local.get') && n[1] === name) {
2092
- if (isSet) for (let i = 2; i < n.length && !seen; i++) visit(n[i])
2093
- if (seen) return
2094
- seen = true
2095
- if (op === 'local.get' || depth > 0) conditional = true
2096
- return
2097
- }
2098
- const isIf = op === 'if'
2099
- for (let i = 1; i < n.length && !seen; i++) {
2100
- const c = n[i]
2101
- const cond = isIf && Array.isArray(c) && (c[0] === 'then' || c[0] === 'else')
2102
- if (cond) depth++
2103
- visit(c)
2104
- if (cond) depth--
2105
- }
2106
- }
2107
- for (const n of body) { if (seen) break; visit(n) }
2108
- // If the local is never used (dead), no reset; the dead decl will be pruned.
2109
- if (!seen) return false
2110
- return conditional
2111
- }
2112
-
2113
- // Module-level references that pin a function (can't be removed/inlined-away).
2114
- const collectPinned = (n, pinned) => {
2115
- if (!Array.isArray(n)) return
2116
- const op = n[0]
2117
- if (op === 'export' && Array.isArray(n[2]) && n[2][0] === 'func' && typeof n[2][1] === 'string') pinned.add(n[2][1])
2118
- else if (op === 'start' && typeof n[1] === 'string') pinned.add(n[1])
2119
- else if (op === 'ref.func' && typeof n[1] === 'string') pinned.add(n[1])
2120
- else if (op === 'elem') for (const c of n) if (typeof c === 'string' && c[0] === '$') pinned.add(c)
2121
- for (const c of n) collectPinned(c, pinned)
2122
- }
2123
-
2124
- for (let round = 0; round < MAX_INLINE_ROUNDS; round++) {
2125
- const funcs = ast.filter(n => Array.isArray(n) && n[0] === 'func')
2126
- const funcByName = new Map()
2127
- for (const n of funcs) if (typeof n[1] === 'string') funcByName.set(n[1], n)
2128
-
2129
- // Count plain-call references across the WHOLE module (anonymous exported funcs
2130
- // call helpers too); flag any non-call reference (return_call etc.).
2131
- const callRefs = new Map(), otherRef = new Set()
2132
- const countRefs = (n) => {
2133
- if (!Array.isArray(n)) return
2134
- const op = n[0]
2135
- if (op === 'call' && typeof n[1] === 'string') callRefs.set(n[1], (callRefs.get(n[1]) || 0) + 1)
2136
- else if (op === 'return_call' && typeof n[1] === 'string') otherRef.add(n[1])
2137
- for (let i = 1; i < n.length; i++) countRefs(n[i])
2138
- }
2139
- countRefs(ast)
2140
- const pinned = new Set()
2141
- for (const n of ast) if (!Array.isArray(n) || n[0] !== 'func') collectPinned(n, pinned)
2142
- // a func may carry its own (export "name") — the signature scan below rejects those too
2143
-
2144
- // Pick a callee.
2145
- let calleeName = null
2146
- for (const [name, fn] of funcByName) {
2147
- if (pinned.has(name) || otherRef.has(name)) continue
2148
- if (callRefs.get(name) !== 1) continue
2149
- if (callsSelf(fn, name)) continue
2150
- // named params/locals only (we'll rename them); reject locals with types
2151
- // we can't zero-init on block re-entry.
2152
- let ok = true, nResult = 0
2153
- for (let i = 2; i < fn.length; i++) {
2154
- const c = fn[i]
2155
- if (typeof c === 'string') continue
2156
- if (!Array.isArray(c)) { ok = false; break }
2157
- if (c[0] === 'param' || c[0] === 'local') {
2158
- if (typeof c[1] !== 'string' || c[1][0] !== '$') { ok = false; break }
2159
- if (c[0] === 'local' && !zeroFor(c[2])) { ok = false; break }
2160
- }
2161
- else if (c[0] === 'result') nResult += c.length - 1
2162
- else if (c[0] === 'export') { ok = false; break }
2163
- else if (c[0] === 'type') continue
2164
- else break
2165
- }
2166
- if (!ok || nResult > 1) continue
2167
- let bad = false
2168
- for (let i = bodyStart(fn); i < fn.length; i++) if (unsafe(fn[i])) { bad = true; break }
2169
- if (bad) continue
2170
- calleeName = name; break
2171
- }
2172
- if (!calleeName) break
2173
-
2174
- const callee = funcByName.get(calleeName)
2175
- const params = [], locals = []
2176
- let inlResult = null
2177
- for (let i = 2; i < callee.length; i++) {
2178
- const c = callee[i]
2179
- if (typeof c === 'string' || !Array.isArray(c)) continue
2180
- if (c[0] === 'param') params.push({ name: c[1], type: c[2] })
2181
- else if (c[0] === 'result') { if (c.length > 1) inlResult = c[1] }
2182
- else if (c[0] === 'local') locals.push({ name: c[1], type: c[2] })
2183
- else if (c[0] === 'export' || c[0] === 'type') continue
2184
- else break
2185
- }
2186
- const cBody = callee.slice(bodyStart(callee))
2187
-
2188
- const uid = ++inlineUid
2189
- const exit = `$__inl${uid}`
2190
- const rename = new Map()
2191
- for (const p of params) rename.set(p.name, `$__inl${uid}_${p.name.slice(1)}`)
2192
- for (const l of locals) rename.set(l.name, `$__inl${uid}_${l.name.slice(1)}`)
2193
- // The callee's own block/loop/if labels would shadow same-named labels in the
2194
- // caller after nesting (and break depth resolution) — give them fresh names too.
2195
- const labelRename = new Map()
2196
- const collectLabels = (n) => {
2197
- if (!Array.isArray(n)) return
2198
- if (isBranchScope(n[0]) && typeof n[1] === 'string' && n[1][0] === '$' && !labelRename.has(n[1]))
2199
- labelRename.set(n[1], `$__inl${uid}L_${n[1].slice(1)}`)
2200
- for (let i = 1; i < n.length; i++) collectLabels(n[i])
2201
- }
2202
- for (const n of cBody) collectLabels(n)
2203
- const sub = (n) => {
2204
- if (!Array.isArray(n)) return n
2205
- const op = n[0]
2206
- if ((op === 'local.get' || op === 'local.set' || op === 'local.tee') && typeof n[1] === 'string' && rename.has(n[1]))
2207
- return [op, rename.get(n[1]), ...n.slice(2).map(sub)]
2208
- if (op === 'return') return ['br', exit, ...n.slice(1).map(sub)]
2209
- if (isBranchScope(op) && typeof n[1] === 'string' && labelRename.has(n[1]))
2210
- return [op, labelRename.get(n[1]), ...n.slice(2).map(sub)]
2211
- if (isBranch(op)) return [op, ...n.slice(1).map(c => (typeof c === 'string' && labelRename.has(c)) ? labelRename.get(c) : sub(c))]
2212
- return n.map((c, i) => i === 0 ? c : sub(c))
2213
- }
2214
-
2215
- // Splice into the (unique) caller (which may be an anonymous exported func).
2216
- let done = false
2217
- for (const fn of funcs) {
2218
- if (fn === callee || done) continue
2219
- const start = bodyStart(fn)
2220
- for (let i = start; i < fn.length; i++) {
2221
- const replaced = walkPost(fn[i], (n) => {
2222
- if (done || !Array.isArray(n) || n[0] !== 'call' || n[1] !== calleeName) return
2223
- const args = n.slice(2)
2224
- if (args.length !== params.length) return // arity mismatch — leave it
2225
- const setup = params.map((p, k) => ['local.set', rename.get(p.name), args[k]])
2226
- // Re-zero only the callee locals that actually depend on the per-call
2227
- // zero-init (read-before-write, or first-write inside a conditional
2228
- // branch). Unconditionally-written-before-read scratch locals don't
2229
- // need a reset, and emitting one inflates their set-count enough to
2230
- // break propagation/coalescing of the helper that follows.
2231
- const resets = locals
2232
- .filter(l => needsReset(cBody, l.name))
2233
- .map(l => ['local.set', rename.get(l.name), zeroFor(l.type)])
2234
- const inner = cBody.map(sub)
2235
- done = true
2236
- return inlResult
2237
- ? ['block', exit, ['result', inlResult], ...setup, ...resets, ...inner]
2238
- : ['block', exit, ...setup, ...resets, ...inner]
2239
- })
2240
- if (replaced !== fn[i]) fn[i] = replaced
2241
- if (done) {
2242
- const decls = [...params, ...locals].map(p => ['local', rename.get(p.name), p.type])
2243
- if (decls.length) fn.splice(bodyStart(fn), 0, ...decls)
2244
- break
2245
- }
2246
- }
2247
- if (done) break
2248
- }
2249
- if (!done) break // call site not found inside a func body — give up
2250
-
2251
- const idx = ast.indexOf(callee)
2252
- if (idx >= 0) ast.splice(idx, 1)
2253
- }
2254
-
2255
- return ast
2256
- }
2257
-
2258
- // ==================== MERGE BLOCKS ====================
2259
-
2260
- /**
2261
- * Does `body` contain a branch instruction targeting `label`, ignoring inner
2262
- * blocks/loops that re-bind the same label?
2263
- */
2264
- const targetsLabel = (body, label) => {
2265
- let found = false
2266
- const search = (n, shadowed) => {
2267
- if (found || !Array.isArray(n)) return
2268
- const op = n[0]
2269
- let inner = shadowed
2270
- if ((op === 'block' || op === 'loop') && typeof n[1] === 'string' && n[1] === label) inner = true
2271
- if (!shadowed) {
2272
- if (op === 'br' || op === 'br_if' || op === 'br_on_null' || op === 'br_on_non_null' ||
2273
- op === 'br_on_cast' || op === 'br_on_cast_fail') {
2274
- if (n[1] === label) { found = true; return }
2275
- } else if (op === 'br_table') {
2276
- for (let j = 1; j < n.length; j++) {
2277
- if (typeof n[j] === 'string') { if (n[j] === label) { found = true; return } }
2278
- else break
2279
- }
2280
- } else if (op === 'catch' || op === 'catch_ref') {
2281
- // `try_table` catch clause `(catch $tag $label)` / `(catch_ref $tag $label)`
2282
- // branches to an enclosing block label just like `br` does.
2283
- if (n[2] === label) { found = true; return }
2284
- } else if (op === 'catch_all' || op === 'catch_all_ref') {
2285
- // `(catch_all $label)` / `(catch_all_ref $label)`
2286
- if (n[1] === label) { found = true; return }
2287
- }
2288
- }
2289
- for (let i = 1; i < n.length; i++) search(n[i], inner)
2290
- }
2291
- for (const node of body) search(node, false)
2292
- return found
2293
- }
2294
-
2295
- /**
2296
- * Unwrap redundant blocks whose label is never targeted. The block's stack
2297
- * effect is determined entirely by its body, so removing the `block`/`end`
2298
- * framing is sound as long as no `br` reaches into the block from inside.
2299
- *
2300
- * Three complementary patterns:
2301
- *
2302
- * 1. **Block at scope level** (sibling in `func`/`block`/`loop`/`then`/`else`):
2303
- * splice body into the parent scope. Works for untyped, `(result T)`-typed,
2304
- * or even `(param …)`-typed blocks — in all cases the body produces the
2305
- * same net stack effect as the framed block did, at the same position.
2306
- * 2. **Result-typed block in expression position** (`(block (result T) expr)`
2307
- * as the value of some operand): collapse to `expr` if the body is a
2308
- * single value expression. Catches the wrappers jz codegen leaves around
2309
- * arena allocations once `propagate` has folded the intermediate
2310
- * set/get pairs to a single call.
2311
- * 3. **Result-typed block as the sole operand of a void consumer** at scope:
2312
- * `(local.set $x (block (result T) stmt* expr))` → splice `stmt*` into
2313
- * the parent scope and rewrite the consumer to `(local.set $x expr)`.
2314
- * Same shape for `global.set` and `drop`. Cleans up the multi-stmt
2315
- * wrappers `inlineOnce` leaves when inlining helpers whose return value
2316
- * is fed into a single set/drop.
2317
- *
2318
- * Pattern 2 runs first (post-order) so patterns 1+3 see cleaned-up parents.
2319
- * @param {Array} ast
2320
- * @returns {Array}
2321
- */
2322
- const mergeBlocks = (ast) => {
2323
- walkPost(ast, (node) => {
2324
- if (!Array.isArray(node) || node[0] !== 'block') return
2325
- let bi = 1, label = null
2326
- if (typeof node[1] === 'string' && node[1][0] === '$') { label = node[1]; bi = 2 }
2327
- let hasResult = false
2328
- while (bi < node.length) {
2329
- const c = node[bi]
2330
- if (Array.isArray(c) && (c[0] === 'param' || c[0] === 'type')) { bi++; continue }
2331
- if (Array.isArray(c) && c[0] === 'result') { hasResult = true; bi++; continue }
2332
- break
2333
- }
2334
- const body = node.slice(bi)
2335
- if (!hasResult || body.length !== 1) return
2336
- const only = body[0]
2337
- if (!Array.isArray(only)) return
2338
- if (label && targetsLabel(body, label)) return
2339
- node.length = 0
2340
- for (const tok of only) node.push(tok)
2341
- })
2342
-
2343
- walk(ast, (node) => {
2344
- if (!isScopeNode(node)) return
2345
- let i = 1
2346
- while (i < node.length) {
2347
- const child = node[i]
2348
- if (!Array.isArray(child)) { i++; continue }
2349
-
2350
- // Pattern 3: void-consumer wrapping a result-typed block at scope level.
2351
- // (local.set $x (block $L (result T) stmt* expr)) → stmt* (local.set $x expr)
2352
- // Same logic for `global.set` and `drop`. The block's body produces a
2353
- // single value at the end; the leading stmts run for side-effect and
2354
- // can move into the parent scope unchanged. Label must be unreferenced
2355
- // (an inner `br $L value` would skip later stmts after splicing).
2356
- // Catches the (block (result T) … (local.get $tmp)) wrappers inlineOnce
2357
- // leaves around inlined helper bodies.
2358
- {
2359
- const cop = child[0]
2360
- const oi = (cop === 'local.set' || cop === 'global.set') ? 2
2361
- : cop === 'drop' ? 1 : -1
2362
- if (oi >= 0 && child.length === oi + 1) {
2363
- const operand = child[oi]
2364
- if (Array.isArray(operand) && operand[0] === 'block') {
2365
- let bi = 1, label = null
2366
- if (typeof operand[1] === 'string' && operand[1][0] === '$') { label = operand[1]; bi = 2 }
2367
- let hasResult = false
2368
- while (bi < operand.length) {
2369
- const c = operand[bi]
2370
- if (Array.isArray(c) && (c[0] === 'param' || c[0] === 'type')) { bi++; continue }
2371
- if (Array.isArray(c) && c[0] === 'result') { hasResult = true; bi++; continue }
2372
- break
2373
- }
2374
- const body = hasResult ? operand.slice(bi) : null
2375
- if (body && body.length >= 2 && !(label && targetsLabel(body, label))) {
2376
- const expr = body[body.length - 1]
2377
- const setup = body.slice(0, -1)
2378
- child[oi] = expr
2379
- node.splice(i, 1, ...setup, child)
2380
- continue // re-examine position i (now setup[0]) — may itself be a splice candidate
2381
- }
2382
- }
2383
- }
2384
- }
2385
-
2386
- if (child[0] !== 'block') { i++; continue }
2387
- let bi = 1, label = null
2388
- if (typeof child[1] === 'string' && child[1][0] === '$') { label = child[1]; bi = 2 }
2389
- // Skip leading typing annotations; they describe the block's stack effect,
2390
- // which the body already produces verbatim, so they're discarded on splice.
2391
- while (bi < child.length) {
2392
- const c = child[bi]
2393
- if (Array.isArray(c) && (c[0] === 'param' || c[0] === 'result' || c[0] === 'type')) { bi++; continue }
2394
- break
2395
- }
2396
- const body = child.slice(bi)
2397
- if (label && targetsLabel(body, label)) { i++; continue }
2398
- node.splice(i, 1, ...body)
2399
- i += body.length
2400
- }
2401
- })
2402
- return ast
2403
- }
2404
-
2405
- // ==================== COALESCE LOCALS ====================
2406
-
2407
- /**
2408
- * Share local slots between same-type locals with non-overlapping live ranges.
2409
- * Live range = [first pos, last pos] of any local.get/set/tee, extended over
2410
- * any loop containing a reference (so a value read across loop iterations stays
2411
- * intact). Greedy slot assignment by start position. Params and unnamed/numeric
2412
- * references are left alone; `localReuse` later removes the renamed-away decls.
2413
- *
2414
- * Soundness: WASM zero-initializes locals at function entry, so a local whose
2415
- * first reference (in walk order) is a `local.get` *relies* on that implicit
2416
- * zero — coalescing it into a slot whose previous user left a non-zero residue
2417
- * would silently change behavior (e.g. a `for (let i=0; …)` loop counter
2418
- * inheriting `N*4` from a sibling temp). Such "read-first" locals can still
2419
- * serve as a slot's *primary* (the slot then keeps the function's zero start),
2420
- * but can never be a donor merged into an existing slot.
2421
- * @param {Array} ast
2422
- * @returns {Array}
2423
- */
2424
- const coalesceLocals = (ast) => {
2425
- walk(ast, (funcNode) => {
2426
- if (!Array.isArray(funcNode) || funcNode[0] !== 'func') return
2427
-
2428
- const decls = new Map()
2429
- for (const sub of funcNode) {
2430
- if (Array.isArray(sub) && sub[0] === 'local' &&
2431
- typeof sub[1] === 'string' && sub[1][0] === '$' && typeof sub[2] === 'string') {
2432
- decls.set(sub[1], sub[2])
2433
- }
2434
- }
2435
- if (decls.size < 2) return
2436
-
2437
- const uses = new Map()
2438
- const loopStack = []
2439
- let pos = 0, abort = false, condDepth = 0
2440
-
2441
- const visit = (n) => {
2442
- if (abort || !Array.isArray(n)) return
2443
- const op = n[0]
2444
- const isLoop = op === 'loop'
2445
- if (isLoop) loopStack.push({ start: pos, end: pos })
2446
- const isSet = op === 'local.set' || op === 'local.tee'
2447
-
2448
- if (isSet || op === 'local.get') {
2449
- const name = n[1]
2450
- if (typeof name !== 'string' || name[0] !== '$') { abort = true; return }
2451
- // Execution order: evaluate set/tee value BEFORE recording the write,
2452
- // so a `(local.set $x (… (local.get $x) …))` is correctly seen as a
2453
- // read-then-write of $x (firstOp = local.get).
2454
- if (isSet) for (let i = 2; i < n.length; i++) visit(n[i])
2455
- const here = pos++
2456
- if (decls.has(name)) {
2457
- let u = uses.get(name)
2458
- if (!u) { u = { start: here, end: here, firstOp: op, firstCond: condDepth > 0, loops: new Set() }; uses.set(name, u) }
2459
- if (here > u.end) u.end = here
2460
- for (const ls of loopStack) u.loops.add(ls)
2461
- }
2462
- } else {
2463
- pos++
2464
- const isIf = op === 'if'
2465
- for (let i = 1; i < n.length; i++) {
2466
- const c = n[i]
2467
- const cond = isIf && Array.isArray(c) && (c[0] === 'then' || c[0] === 'else')
2468
- if (cond) condDepth++
2469
- visit(c)
2470
- if (cond) condDepth--
2471
- }
2472
- }
2473
-
2474
- if (isLoop) { const ls = loopStack.pop(); ls.end = pos }
2475
- }
2476
- visit(funcNode)
2477
- if (abort) return
2478
-
2479
- // A use inside a loop must stay live for the whole loop — the next
2480
- // iteration could read what this iteration wrote.
2481
- for (const u of uses.values()) {
2482
- for (const ls of u.loops) {
2483
- if (ls.start < u.start) u.start = ls.start
2484
- if (ls.end > u.end) u.end = ls.end
2485
- }
2486
- }
2487
-
2488
- const ordered = [...uses.entries()].sort((a, b) => a[1].start - b[1].start)
2489
- const rename = new Map()
2490
- const slots = []
2491
- for (const [name, range] of ordered) {
2492
- // Read-first locals depend on the implicit zero; locals first seen inside
2493
- // an if/else branch may be skipped on the alternate path — either way
2494
- // they'd observe a prior slot's residue if reused. They may *start* a
2495
- // fresh slot (the function's zero init), but never *join* one.
2496
- const readsZero = range.firstOp === 'local.get' || range.firstCond
2497
- const type = decls.get(name)
2498
- const slot = readsZero ? null : slots.find(s => s.type === type && s.end < range.start)
2499
- if (slot) { rename.set(name, slot.primary); if (range.end > slot.end) slot.end = range.end }
2500
- else slots.push({ primary: name, type, end: range.end })
2501
- }
2502
- if (rename.size === 0) return
2503
-
2504
- walk(funcNode, (n) => {
2505
- if (Array.isArray(n) &&
2506
- (n[0] === 'local.get' || n[0] === 'local.set' || n[0] === 'local.tee') &&
2507
- rename.has(n[1])) {
2508
- n[1] = rename.get(n[1])
2509
- }
2510
- })
2511
- })
2512
- return ast
2513
- }
2514
-
2515
- // ==================== VACUUM ====================
2516
-
2517
- /**
2518
- * Remove no-op code: nops, drop of pure expressions, empty branches,
2519
- * and select with identical arms.
2520
- * @param {Array} ast
2521
- * @returns {Array}
2522
- */
2523
- const vacuum = (ast) => {
2524
- return walkPost(ast, (node) => {
2525
- if (!Array.isArray(node)) return
2526
- const op = node[0]
2527
-
2528
- // Remove nop entirely (return array marker; parent or post-pass cleans it)
2529
- if (op === 'nop') return ['nop']
2530
-
2531
- // (drop PURE) → nop
2532
- if (op === 'drop' && node.length === 2 && isPure(node[1])) {
2533
- return ['nop']
2534
- }
2535
-
2536
- // (select x x cond) → x — only when cond is PURE. An impure cond may set a
2537
- // local that a later op reads (e.g. an address `local.tee` the matching store
2538
- // reuses); dropping it would leave that local stale. Keep the select otherwise.
2539
- if (op === 'select' && node.length >= 4 && equal(node[1], node[2]) && isPure(node[3])) return node[1]
2540
-
2541
- if (op === 'if') {
2542
- const { cond, thenBranch, elseBranch } = parseIf(node)
2543
- const thenEmpty = !thenBranch || thenBranch.length <= 1
2544
- const elseEmpty = !elseBranch || elseBranch.length <= 1
2545
-
2546
- // (if cond () ()) → nop or (drop cond)
2547
- if (thenEmpty && elseEmpty) return isPure(cond) ? ['nop'] : ['drop', cond]
2548
-
2549
- // (if cond (then X) (else)) → drop the empty else
2550
- if (elseBranch && elseEmpty && !thenEmpty) {
2551
- return node.filter(c => c !== elseBranch)
2552
- }
2553
- }
2554
-
2555
- // Clean out nops, drop-of-pure sequences, and empty annotations from blocks
2556
- if (isScopeNode(node)) {
2557
- const cleaned = [op]
2558
- for (let i = 1; i < node.length; i++) {
2559
- const child = node[i]
2560
- if (child === 'nop' || (Array.isArray(child) && child[0] === 'nop')) continue
2561
- // Pure expression followed by standalone drop → remove both
2562
- const next = node[i + 1]
2563
- const isDrop = next === 'drop' || (Array.isArray(next) && next[0] === 'drop' && next.length === 1)
2564
- if (Array.isArray(child) && isPure(child) && isDrop) {
2565
- i++ // skip the drop too
2566
- continue
2567
- }
2568
- cleaned.push(child)
2569
- }
2570
- if (cleaned.length !== node.length) return cleaned
2571
- }
2572
- })
2573
- }
2574
-
2575
- // ==================== PEEPHOLE ====================
2576
-
2577
- /** Peephole optimizations: simple algebraic identities.
2578
- * Every rule that DROPS an operand guards on isPure: an impure operand must still
2579
- * be evaluated for its side effects. The load-bearing case is a typed-array element
2580
- * store, whose address is a `local.tee` inside the value expression (the element's
2581
- * own read); dropping that operand (e.g. `(a[i] op a[i]) & 0`) would strand the
2582
- * store with a stale address — a silent miscompile. When impure, keep the op (it
2583
- * still yields the same value AND runs the operand). */
2584
- const selfFold = (val) => (a, b) => equal(a, b) && isPure(a) ? val : null
2585
- const PEEPHOLE = {
2586
- // Self-cancelling / tautological binary ops — drop both (equal) operands.
2587
- 'i32.sub': selfFold(['i32.const', 0]),
2588
- 'i64.sub': selfFold(['i64.const', 0]),
2589
- 'i32.xor': selfFold(['i32.const', 0]),
2590
- 'i64.xor': selfFold(['i64.const', 0]),
2591
- 'i32.eq': selfFold(['i32.const', 1]),
2592
- 'i64.eq': selfFold(['i32.const', 1]),
2593
- 'i32.ne': selfFold(['i32.const', 0]),
2594
- 'i64.ne': selfFold(['i32.const', 0]),
2595
- 'i32.lt_s': selfFold(['i32.const', 0]),
2596
- 'i32.lt_u': selfFold(['i32.const', 0]),
2597
- 'i32.gt_s': selfFold(['i32.const', 0]),
2598
- 'i32.gt_u': selfFold(['i32.const', 0]),
2599
- 'i32.le_s': selfFold(['i32.const', 1]),
2600
- 'i32.le_u': selfFold(['i32.const', 1]),
2601
- 'i32.ge_s': selfFold(['i32.const', 1]),
2602
- 'i32.ge_u': selfFold(['i32.const', 1]),
2603
- 'i64.lt_s': selfFold(['i32.const', 0]),
2604
- 'i64.lt_u': selfFold(['i32.const', 0]),
2605
- 'i64.gt_s': selfFold(['i32.const', 0]),
2606
- 'i64.gt_u': selfFold(['i32.const', 0]),
2607
- 'i64.le_s': selfFold(['i32.const', 1]),
2608
- 'i64.le_u': selfFold(['i32.const', 1]),
2609
- 'i64.ge_s': selfFold(['i32.const', 1]),
2610
- 'i64.ge_u': selfFold(['i32.const', 1]),
2611
-
2612
- // Zero/all-bits absorption — drops the NON-const operand, so guard its purity.
2613
- 'i32.mul': (a, b) => {
2614
- if (getConst(b)?.value === 0 && isPure(a)) return ['i32.const', 0]
2615
- if (getConst(a)?.value === 0 && isPure(b)) return ['i32.const', 0]
2616
- return null
2617
- },
2618
- 'i64.mul': (a, b) => {
2619
- if (getConst(b)?.value === ZERO64 && isPure(a)) return ['i64.const', 0]
2620
- if (getConst(a)?.value === ZERO64 && isPure(b)) return ['i64.const', 0]
2621
- return null
2622
- },
2623
- 'i32.and': (a, b) => {
2624
- if (equal(a, b) && isPure(b)) return a
2625
- if (getConst(b)?.value === 0 && isPure(a)) return ['i32.const', 0]
2626
- if (getConst(a)?.value === 0 && isPure(b)) return ['i32.const', 0]
2627
- return null
2628
- },
2629
- 'i64.and': (a, b) => {
2630
- if (equal(a, b) && isPure(b)) return a
2631
- if (getConst(b)?.value === ZERO64 && isPure(a)) return ['i64.const', 0]
2632
- if (getConst(a)?.value === ZERO64 && isPure(b)) return ['i64.const', 0]
2633
- return null
2634
- },
2635
- 'i32.or': (a, b) => {
2636
- if (equal(a, b) && isPure(b)) return a
2637
- if (getConst(b)?.value === -1 && isPure(a)) return ['i32.const', -1]
2638
- if (getConst(a)?.value === -1 && isPure(b)) return ['i32.const', -1]
2639
- return null
2640
- },
2641
- 'i64.or': (a, b) => {
2642
- if (equal(a, b) && isPure(b)) return a
2643
- if (getConst(b)?.value === NEG164 && isPure(a)) return ['i64.const', -1]
2644
- if (getConst(a)?.value === NEG164 && isPure(b)) return ['i64.const', -1]
2645
- return null
2646
- },
2647
-
2648
- // (local.set $x (local.get $x)) → nop
2649
- 'local.set': (a, b) => Array.isArray(b) && b[0] === 'local.get' && b[1] === a ? ['nop'] : null,
2650
- }
2651
-
2652
- /**
2653
- * Apply peephole optimizations.
2654
- * @param {Array} ast
2655
- * @returns {Array}
2656
- */
2657
- const peephole = (ast) => {
2658
- return walkPost(ast, (node) => {
2659
- if (!Array.isArray(node) || node.length !== 3) return
2660
- const fn = PEEPHOLE[node[0]]
2661
- if (!fn) return
2662
- const result = fn(node[1], node[2])
2663
- if (result !== null) return result
2664
- })
2665
- }
2666
-
2667
- // ==================== GLOBAL CONSTANT PROPAGATION ====================
2668
-
2669
- /** Bytes a signed-LEB128 integer encodes to. */
2670
- const slebSize = (v) => {
2671
- let x = typeof v === 'bigint' ? v
2672
- : typeof v === 'string' ? BigInt(v.replaceAll('_', ''))
2673
- : BigInt(Math.trunc(Number(v) || 0))
2674
- // Signed view of raw bits — exact natively; in-kernel the arm is dead
2675
- // (BigInt('0x…') already arrives as the signed i64 carrier there).
2676
- if (x > 0x7fffffffffffffffn) x = x - 0x8000000000000000n - 0x8000000000000000n
2677
- let n = 1
2678
- while (true) {
2679
- const b = x & 0x7fn
2680
- x >>= 7n
2681
- if ((x === 0n && (b & 0x40n) === 0n) || (x === -1n && (b & 0x40n) !== 0n)) return n
2682
- n++
2683
- }
2684
- }
2685
- /** Encoded byte size of a constant init instruction (opcode + immediate). */
2686
- const constInstrSize = (node) => {
2687
- if (!Array.isArray(node)) return 4
2688
- switch (node[0]) {
2689
- case 'i32.const': case 'i64.const': return 1 + slebSize(node[1])
2690
- case 'f32.const': return 5
2691
- case 'f64.const': return 9
2692
- case 'v128.const': return 18
2693
- default: return 4 // ref.null/ref.func/global.get — conservative
2694
- }
2695
- }
2696
- const GLOBAL_GET_SIZE = 2 // 0x23 opcode + 1-byte globalidx (typical)
2697
-
2698
- /**
2699
- * Replace `global.get` of an immutable, const-initialised global with the
2700
- * constant — but only when it doesn't grow the module. A `global.get` costs
2701
- * ~2 B; an `i32.const 12345` costs 4 B; an `f64.const` costs 9 B. Naively
2702
- * inlining a big constant read from many sites trades a few cheap reads + one
2703
- * global decl for many fat immediates — pure bloat (and the node-count size
2704
- * guard can't see it: same number of AST nodes). So we only propagate a global
2705
- * when `refs·constSize ≤ refs·2 + declSize`; when every read is replaced and
2706
- * the global isn't exported, its now-dead decl is dropped here too.
2707
- * @param {Array} ast
2708
- * @returns {Array}
2709
- */
2710
- const globals = (ast) => {
2711
- if (!Array.isArray(ast) || ast[0] !== 'module') return ast
2712
-
2713
- // Immutable globals with a constant init: name → init node.
2714
- const constGlobals = new Map()
2715
- const exported = new Set() // globals pinned by an export — keep the decl
2716
-
2717
- for (const node of ast.slice(1)) {
2718
- if (!Array.isArray(node)) continue
2719
- if (node[0] === 'export' && Array.isArray(node[2]) && node[2][0] === 'global' && typeof node[2][1] === 'string') { exported.add(node[2][1]); continue }
2720
- if (node[0] !== 'global') continue
2721
- const name = typeof node[1] === 'string' && node[1][0] === '$' ? node[1] : null
2722
- if (!name) continue
2723
- // (global $g (export "x") …) inline export → pinned
2724
- if (node.some(c => Array.isArray(c) && c[0] === 'export')) exported.add(name)
2725
- const typeSlot = node[2]
2726
- if (Array.isArray(typeSlot) && typeSlot[0] === 'mut') continue // mutable
2727
- if (Array.isArray(typeSlot) && typeSlot[0] === 'import') continue // imported
2728
- const init = node[3]
2729
- if (getConst(init)) constGlobals.set(name, init)
2730
- }
2731
- if (constGlobals.size === 0) return ast
2732
-
2733
- // Drop any global that is ever written (defensive — an immutable global can't
2734
- // be, but a malformed module might) and tally read counts.
2735
- const reads = new Map()
2736
- walk(ast, (n) => {
2737
- if (!Array.isArray(n)) return
2738
- const ref = n[1]
2739
- if (typeof ref !== 'string' || ref[0] !== '$') return
2740
- if (n[0] === 'global.set') constGlobals.delete(ref)
2741
- else if (n[0] === 'global.get') reads.set(ref, (reads.get(ref) || 0) + 1)
2742
- })
2743
-
2744
- // Keep only globals where propagation is size-neutral or better.
2745
- const propagate = new Set()
2746
- for (const [name, init] of constGlobals) {
2747
- const r = reads.get(name) || 0
2748
- if (r === 0) continue // dead anyway — leave to treeshake
2749
- const cs = constInstrSize(init)
2750
- const declSize = cs + 2 // valtype + mutability byte + init expr + `end`
2751
- const before = r * GLOBAL_GET_SIZE + declSize
2752
- const after = r * cs + (exported.has(name) ? declSize : 0)
2753
- if (after <= before) propagate.add(name)
2754
- }
2755
- if (propagate.size === 0) return ast
2756
-
2757
- walkPost(ast, (node) => {
2758
- if (!Array.isArray(node) || node[0] !== 'global.get' || node.length !== 2) return
2759
- if (propagate.has(node[1])) return clone(constGlobals.get(node[1]))
2760
- })
2761
- // Their reads are all gone now — remove the decls we're free to remove.
2762
- for (let i = ast.length - 1; i >= 1; i--) {
2763
- const n = ast[i]
2764
- if (Array.isArray(n) && n[0] === 'global' && typeof n[1] === 'string' && propagate.has(n[1]) && !exported.has(n[1])) ast.splice(i, 1)
2765
- }
2766
- return ast
2767
- }
2768
-
2769
- // ==================== LOAD/STORE OFFSET FOLDING ====================
2770
-
2771
- /** Match (type.load/store (i32.add ptr (type.const N))) and fold offset */
2772
- const offset = (ast) => {
2773
- return walkPost(ast, (node) => {
2774
- if (!Array.isArray(node)) return
2775
- const op = node[0]
2776
- if (typeof op !== 'string' || (!op.endsWith('load') && !op.endsWith('store'))) return
2777
-
2778
- // Memory ops have memarg as first immediate after optional memoryidx, then operands
2779
- // In AST form from parse: (i32.load offset=4 align=8 ptr) or (i32.load ptr)
2780
- // Store: (i32.store offset=4 ptr val) — ptr is second-to-last, val is last
2781
- // Load: (i32.load offset=4 ptr) — ptr is last
2782
- const isStore = op.endsWith('store')
2783
-
2784
- // Find current offset from memparams
2785
- let currentOffset = 0
2786
- let memIdx = null
2787
- let argStart = 1
2788
-
2789
- // Check for memory index
2790
- if (typeof node[1] === 'string' && (node[1][0] === '$' || !isNaN(node[1]))) {
2791
- memIdx = node[1]
2792
- argStart = 2
2793
- }
2794
-
2795
- // Check for memparams (offset=, align=)
2796
- while (argStart < node.length && typeof node[argStart] === 'string' &&
2797
- (node[argStart].startsWith('offset=') || node[argStart].startsWith('align='))) {
2798
- if (node[argStart].startsWith('offset=')) {
2799
- currentOffset = +node[argStart].slice(7)
2800
- }
2801
- argStart++
2802
- }
2803
-
2804
- // Determine pointer index
2805
- const ptrIdx = isStore ? node.length - 2 : node.length - 1
2806
- const valIdx = isStore ? node.length - 1 : -1
2807
- if (ptrIdx < argStart) return
2808
-
2809
- const ptr = node[ptrIdx]
2810
- if (!Array.isArray(ptr) || ptr[0] !== 'i32.add' || ptr.length !== 3) return
2811
-
2812
- const a = ptr[1], b = ptr[2]
2813
- const ca = getConst(a), cb = getConst(b)
2814
-
2815
- let base = null, addend = null
2816
- if (ca && ca.type === 'i32') { addend = ca.value; base = b }
2817
- else if (cb && cb.type === 'i32') { addend = cb.value; base = a }
2818
- if (base === null || addend === null) return
2819
-
2820
- const newOffset = currentOffset + addend
2821
- const newNode = [op]
2822
- if (memIdx !== null) newNode.push(memIdx)
2823
- newNode.push(`offset=${newOffset}`)
2824
- // Preserve align if present
2825
- let alignParam = null
2826
- for (let i = argStart; i < ptrIdx; i++) {
2827
- if (typeof node[i] === 'string' && node[i].startsWith('align=')) {
2828
- alignParam = node[i]
2829
- }
2830
- }
2831
- if (alignParam) newNode.push(alignParam)
2832
- newNode.push(base)
2833
- if (isStore) newNode.push(node[valIdx])
2834
- return newNode
2835
- })
2836
- }
2837
-
2838
- // ==================== REDUNDANT BR REMOVAL ====================
2839
-
2840
- /**
2841
- * Remove br to a block's own label when it is the last instruction.
2842
- * @param {Array} ast
2843
- * @returns {Array}
2844
- */
2845
- const unbranch = (ast) => {
2846
- walk(ast, (node) => {
2847
- if (!Array.isArray(node)) return
2848
- const op = node[0]
2849
- // Loops: `br $loop_label` jumps BACK to loop top (continue), not out.
2850
- // Only `block` allows trailing-br elision because `br $block_label` exits the block.
2851
- if (op !== 'block') return
2852
-
2853
- // Get the block's label
2854
- let labelIdx = 1
2855
- let label = null
2856
- if (typeof node[1] === 'string' && node[1][0] === '$') {
2857
- label = node[1]
2858
- labelIdx = 2
2859
- }
2860
- if (!label) return
2861
-
2862
- // Find the last executable instruction (skip result/type annotations)
2863
- let lastIdx = -1
2864
- for (let i = node.length - 1; i >= labelIdx; i--) {
2865
- const child = node[i]
2866
- if (!Array.isArray(child)) {
2867
- if (child !== 'nop' && child !== 'end') lastIdx = i
2868
- continue
2869
- }
2870
- const cop = child[0]
2871
- if (cop === 'param' || cop === 'result' || cop === 'local' || cop === 'type' || cop === 'export') continue
2872
- lastIdx = i
2873
- break
2874
- }
2875
- if (lastIdx < 0) return
2876
-
2877
- const last = node[lastIdx]
2878
- if (Array.isArray(last) && last[0] === 'br' && last[1] === label) {
2879
- // `(br $L v…)` as a block's last instruction just leaves v… as the block's
2880
- // result — splice the value operand(s) in its place (none → plain removal).
2881
- node.splice(lastIdx, 1, ...last.slice(2))
2882
- }
2883
- })
2884
-
2885
- return ast
2886
- }
2887
-
2888
- // ==================== WHILE-LOOP CANONICALIZATION ====================
2889
-
2890
- /**
2891
- * Collapse the `while`-emit idiom into a single loop.
2892
- *
2893
- * (block $A
2894
- * (loop $B
2895
- * (br_if $A (i32.eqz cond)) ;; exit when cond is false
2896
- * …body…
2897
- * (br $B) ;; continue
2898
- * ))
2899
- *
2900
- * becomes
2901
- *
2902
- * (loop $B
2903
- * (if cond (then …body… (br $B))))
2904
- *
2905
- * Saves ~3 B per while-loop (drop the outer block framing + the `i32.eqz`,
2906
- * trade `br_if`→`if`). Safe only when:
2907
- * - the block contains nothing but the loop (plus optional `type` slot),
2908
- * - block / loop are void (no result),
2909
- * - $A is never targeted from within body (only the head `br_if` uses it).
2910
- *
2911
- * @param {Array} ast
2912
- * @returns {Array}
2913
- */
2914
- const loopify = (ast) => {
2915
- walk(ast, (node) => {
2916
- if (!Array.isArray(node) || node[0] !== 'block') return
2917
- let bi = 1, label = null
2918
- if (typeof node[1] === 'string' && node[1][0] === '$') { label = node[1]; bi = 2 }
2919
- if (!label) return
2920
- while (bi < node.length) {
2921
- const c = node[bi]
2922
- if (Array.isArray(c) && c[0] === 'type') { bi++; continue }
2923
- if (Array.isArray(c) && (c[0] === 'param' || c[0] === 'result')) return // typed → skip
2924
- break
2925
- }
2926
- if (node.length - bi !== 1) return
2927
- const loop = node[bi]
2928
- if (!Array.isArray(loop) || loop[0] !== 'loop') return
2929
- let li = 1, loopLabel = null
2930
- if (typeof loop[1] === 'string' && loop[1][0] === '$') { loopLabel = loop[1]; li = 2 }
2931
- const loopHeader = []
2932
- while (li < loop.length) {
2933
- const c = loop[li]
2934
- if (Array.isArray(c) && c[0] === 'type') { loopHeader.push(c); li++; continue }
2935
- if (Array.isArray(c) && (c[0] === 'param' || c[0] === 'result')) return // typed → skip
2936
- break
2937
- }
2938
- const body = loop.slice(li)
2939
- if (body.length < 2) return
2940
- const head = body[0]
2941
- const tail = body[body.length - 1]
2942
- if (!Array.isArray(head) || head[0] !== 'br_if' || head[1] !== label || head.length !== 3) return
2943
- if (!Array.isArray(tail) || tail[0] !== 'br' || tail[1] !== loopLabel || tail.length !== 2) return
2944
- const inner = body.slice(1, -1)
2945
- if (targetsLabel(inner, label)) return
2946
-
2947
- // br_if exits when `cond` is non-zero — `if`'s then-arm runs when its
2948
- // condition is non-zero. So the if-condition is the negation. Strip a
2949
- // wrapping `i32.eqz` if present; otherwise wrap.
2950
- let cond = head[2]
2951
- if (Array.isArray(cond) && cond[0] === 'i32.eqz' && cond.length === 2) cond = cond[1]
2952
- else cond = ['i32.eqz', cond]
2953
-
2954
- const newLoop = ['loop']
2955
- if (loopLabel) newLoop.push(loopLabel)
2956
- for (const h of loopHeader) newLoop.push(h)
2957
- newLoop.push(['if', cond, ['then', ...inner, tail]])
2958
-
2959
- node.length = 0
2960
- for (const tok of newLoop) node.push(tok)
2961
- })
2962
- return ast
2963
- }
2964
-
2965
- // ==================== STRIP MUT FROM GLOBALS ====================
2966
-
2967
- /**
2968
- * Strip mutability from globals that are never written.
2969
- * Enables globals constant-propagation for more globals.
2970
- * @param {Array} ast
2971
- * @returns {Array}
2972
- */
2973
- const stripmut = (ast) => {
2974
- if (!Array.isArray(ast) || ast[0] !== 'module') return ast
2975
-
2976
- const written = new Set()
2977
- walk(ast, (n) => {
2978
- if (Array.isArray(n) && n[0] === 'global.set' && typeof n[1] === 'string') written.add(n[1])
2979
- })
2980
-
2981
- return walkPost(ast, (node) => {
2982
- if (!Array.isArray(node) || node[0] !== 'global') return
2983
- const name = typeof node[1] === 'string' && node[1][0] === '$' ? node[1] : null
2984
- if (!name || written.has(name)) return
2985
-
2986
- const hasName = typeof node[1] === 'string' && node[1][0] === '$'
2987
- const typeSlot = hasName ? node[2] : node[1]
2988
- if (Array.isArray(typeSlot) && typeSlot[0] === 'mut') {
2989
- const newNode = [...node]
2990
- newNode[hasName ? 2 : 1] = typeSlot[1] // replace (mut T) with T
2991
- return newNode
2992
- }
2993
- })
2994
- }
2995
-
2996
- // ==================== IF-THEN-BR → BR_IF ====================
2997
-
2998
- /**
2999
- * Simplify (if cond (then (br $label))) → (br_if $label cond)
3000
- * and (if cond (then) (else (br $label))) → (br_if $label (i32.eqz cond))
3001
- * Only when the br is the sole instruction in the arm.
3002
- * @param {Array} ast
3003
- * @returns {Array}
3004
- */
3005
- const brif = (ast) => {
3006
- return walkPost(ast, (node) => {
3007
- if (!Array.isArray(node) || node[0] !== 'if') return
3008
- const { cond, thenBranch, elseBranch } = parseIf(node)
3009
- const thenEmpty = !thenBranch || thenBranch.length <= 1
3010
- const elseEmpty = !elseBranch || elseBranch.length <= 1
3011
-
3012
- // (if cond (then (br $l))) → (br_if $l cond)
3013
- if (!thenEmpty && elseEmpty && thenBranch.length === 2) {
3014
- const t = thenBranch[1]
3015
- if (Array.isArray(t) && t[0] === 'br' && t.length === 2) return ['br_if', t[1], cond]
3016
- }
3017
-
3018
- // (if cond (then) (else (br $l))) → (br_if $l (i32.eqz cond))
3019
- if (thenEmpty && !elseEmpty && elseBranch.length === 2) {
3020
- const e = elseBranch[1]
3021
- if (Array.isArray(e) && e[0] === 'br' && e.length === 2) return ['br_if', e[1], ['i32.eqz', cond]]
3022
- }
3023
- })
3024
- }
3025
-
3026
- // ==================== MERGE IDENTICAL IF ARMS ====================
3027
-
3028
- /**
3029
- * Fold identical trailing code out of if/else arms.
3030
- * (if cond (then A X) (else B X)) → (if cond (then A) (else B)) X
3031
- * @param {Array} ast
3032
- * @returns {Array}
3033
- */
3034
- const foldarms = (ast) => {
3035
- return walkPost(ast, (node) => {
3036
- if (!Array.isArray(node) || node[0] !== 'if') return
3037
- const { thenBranch, elseBranch } = parseIf(node)
3038
- if (!thenBranch || !elseBranch) return
3039
- if (thenBranch.length <= 1 || elseBranch.length <= 1) return
3040
-
3041
- // Only fold when the if has an explicit result type.
3042
- // Without a result annotation the branches are void; hoisting a suffix
3043
- // like `drop` can expose a value and leave the if branches ill-typed.
3044
- const hasResult = node.some(c => Array.isArray(c) && c[0] === 'result')
3045
- if (!hasResult) return
3046
-
3047
- let common = 0
3048
- const minLen = Math.min(thenBranch.length, elseBranch.length)
3049
- for (let i = 1; i < minLen; i++) {
3050
- if (!equal(thenBranch[thenBranch.length - i], elseBranch[elseBranch.length - i])) break
3051
- common++
3052
- }
3053
- if (common === 0) return
3054
-
3055
- const hoisted = thenBranch.slice(thenBranch.length - common)
3056
- const newThen = thenBranch.slice(0, thenBranch.length - common)
3057
- const newElse = elseBranch.slice(0, elseBranch.length - common)
3058
-
3059
- const block = ['block']
3060
- for (let i = 1; i < node.length; i++) {
3061
- const c = node[i]
3062
- if (Array.isArray(c) && (c[0] === 'then' || c[0] === 'else')) break
3063
- if (Array.isArray(c) && (c[0] === 'result' || c[0] === 'type')) block.push(c)
3064
- }
3065
-
3066
- // Inner if becomes void: the result/type annotation now lives on the outer block,
3067
- // since the value-producing trailing instructions have moved there. Without
3068
- // stripping, the inner (if (result f64)) claims to produce f64 from branches
3069
- // whose trailing value-producing instructions just got hoisted out — invalid.
3070
- const newIf = ['if']
3071
- for (let i = 1; i < node.length; i++) {
3072
- const c = node[i]
3073
- if (Array.isArray(c) && (c[0] === 'then' || c[0] === 'else')) break
3074
- if (Array.isArray(c) && (c[0] === 'result' || c[0] === 'type')) continue
3075
- newIf.push(c)
3076
- }
3077
- newIf.push(newThen.length > 1 ? newThen : ['then'])
3078
- newIf.push(newElse.length > 1 ? newElse : ['else'])
3079
-
3080
- block.push(newIf, ...hoisted)
3081
- return block
3082
- })
3083
- }
3084
-
3085
- // ==================== DUPLICATE FUNCTION ELIMINATION ====================
3086
-
3087
- /**
3088
- * Fast structural hash for a function node, normalizing local names.
3089
- * Uses a stack-based walk to avoid expensive JSON.stringify.
3090
- */
3091
- const hashFunc = (node, localNames) => {
3092
- const parts = []
3093
- const stack = [node]
3094
- while (stack.length) {
3095
- const v = stack.pop()
3096
- if (Array.isArray(v)) {
3097
- stack.push('|')
3098
- for (let i = v.length - 1; i >= 0; i--) stack.push(v[i])
3099
- stack.push('[')
3100
- } else if (typeof v === 'string') {
3101
- parts.push(localNames.has(v) ? '$__L' : v)
3102
- } else if (typeof v === 'bigint') {
3103
- parts.push(v.toString() + 'n')
3104
- } else if (typeof v === 'number') {
3105
- parts.push(v.toString())
3106
- } else if (v === null) {
3107
- parts.push('null')
3108
- } else if (v === true) {
3109
- parts.push('t')
3110
- } else if (v === false) {
3111
- parts.push('f')
3112
- } else {
3113
- parts.push(String(v))
3114
- }
3115
- }
3116
- return parts.join(',')
3117
- }
3118
-
3119
- /**
3120
- * Eliminate duplicate functions by hashing bodies.
3121
- * Keeps the first occurrence and redirects all references to it.
3122
- * @param {Array} ast
3123
- * @returns {Array}
3124
- */
3125
- const dedupe = (ast) => {
3126
- if (!Array.isArray(ast) || ast[0] !== 'module') return ast
3127
-
3128
- // Hash function bodies (normalize local/param names to avoid false negatives)
3129
- const signatures = new Map() // hash → canonical $name
3130
- const redirects = new Map() // duplicate $name → canonical $name
3131
-
3132
- for (const node of ast.slice(1)) {
3133
- if (!Array.isArray(node) || node[0] !== 'func') continue
3134
- const name = typeof node[1] === 'string' && node[1][0] === '$' ? node[1] : null
3135
- if (!name) continue
3136
-
3137
- // Collect names that are internal to this function: the func name itself,
3138
- // its params, locals, and any block/loop labels nested in the body. All of
3139
- // these get normalized to a single token in the hash so that two funcs
3140
- // differing only in identifier choices still dedupe.
3141
- const localNames = new Set()
3142
- if (typeof node[1] === 'string' && node[1][0] === '$') localNames.add(node[1])
3143
- walk(node, (n) => {
3144
- if (!Array.isArray(n) || typeof n[1] !== 'string' || n[1][0] !== '$') return
3145
- const op = n[0]
3146
- if (op === 'param' || op === 'local' || isBranchScope(op)) {
3147
- localNames.add(n[1])
3148
- }
3149
- })
3150
-
3151
- const hash = hashFunc(node, localNames)
3152
-
3153
- if (signatures.has(hash)) {
3154
- redirects.set(name, signatures.get(hash))
3155
- } else {
3156
- signatures.set(hash, name)
3157
- }
3158
- }
3159
-
3160
- if (redirects.size === 0) return ast
3161
-
3162
- // Rewrite all references: calls, ref.func, elem segments, call_indirect type
3163
- walkPost(ast, (node) => {
3164
- if (!Array.isArray(node)) return
3165
- const op = node[0]
3166
- if ((op === 'call' || op === 'return_call') && redirects.has(node[1])) {
3167
- return [op, redirects.get(node[1]), ...node.slice(2)]
3168
- }
3169
- if (op === 'ref.func' && redirects.has(node[1])) {
3170
- return ['ref.func', redirects.get(node[1])]
3171
- }
3172
- if (op === 'elem') {
3173
- const funcs = node[node.length - 1]
3174
- if (Array.isArray(funcs)) {
3175
- return [...node.slice(0, -1), funcs.map(f => redirects.get(f) || f)]
3176
- }
3177
- }
3178
- if (op === 'call_indirect' && node.length >= 3) {
3179
- const typeRef = node[1]
3180
- if (typeof typeRef === 'string' && redirects.has(typeRef)) {
3181
- return ['call_indirect', redirects.get(typeRef), ...node.slice(2)]
3182
- }
3183
- }
3184
- })
3185
-
3186
- return ast
3187
- }
3188
-
3189
- // ==================== TYPE DEDUPLICATION ====================
3190
-
3191
- /**
3192
- * Merge structurally identical (type ...) definitions.
3193
- * Keeps the first occurrence and redirects all references.
3194
- * @param {Array} ast
3195
- * @returns {Array}
3196
- */
3197
- const dedupTypes = (ast) => {
3198
- if (!Array.isArray(ast) || ast[0] !== 'module') return ast
3199
-
3200
- const signatures = new Map() // hash → canonical $name
3201
- const redirects = new Map() // duplicate $name → canonical $name
3202
-
3203
- for (const node of ast.slice(1)) {
3204
- if (!Array.isArray(node) || node[0] !== 'type') continue
3205
- const name = typeof node[1] === 'string' && node[1][0] === '$' ? node[1] : null
3206
- if (!name) continue
3207
-
3208
- // Hash the type body, normalizing only the type's own name
3209
- const hash = hashFunc(node, new Set([name]))
3210
-
3211
- if (signatures.has(hash)) {
3212
- redirects.set(name, signatures.get(hash))
3213
- } else {
3214
- signatures.set(hash, name)
3215
- }
3216
- }
3217
-
3218
- if (redirects.size === 0) return ast
3219
-
3220
- // Remove duplicate type nodes
3221
- for (let i = ast.length - 1; i >= 0; i--) {
3222
- const node = ast[i]
3223
- if (Array.isArray(node) && node[0] === 'type') {
3224
- const name = typeof node[1] === 'string' && node[1][0] === '$' ? node[1] : null
3225
- if (name && redirects.has(name)) ast.splice(i, 1)
3226
- }
3227
- }
3228
-
3229
- walkPost(ast, (node) => {
3230
- if (!Array.isArray(node)) return
3231
- const op = node[0]
3232
-
3233
- // (func $f (type $t) ...)
3234
- if (op === 'func') {
3235
- for (let i = 1; i < node.length; i++) {
3236
- const sub = node[i]
3237
- if (Array.isArray(sub) && sub[0] === 'type' && typeof sub[1] === 'string' && redirects.has(sub[1])) {
3238
- node[i] = ['type', redirects.get(sub[1])]
3239
- }
3240
- }
3241
- }
3242
-
3243
- // (import "m" "n" (func (type $t)))
3244
- if (op === 'import') {
3245
- for (let i = 1; i < node.length; i++) {
3246
- const sub = node[i]
3247
- if (Array.isArray(sub)) {
3248
- for (let j = 1; j < sub.length; j++) {
3249
- const inner = sub[j]
3250
- if (Array.isArray(inner) && inner[0] === 'type' && typeof inner[1] === 'string' && redirects.has(inner[1])) {
3251
- sub[j] = ['type', redirects.get(inner[1])]
3252
- }
3253
- }
3254
- }
3255
- }
3256
- }
3257
-
3258
- // call_indirect $t or (call_indirect (type $t) ...)
3259
- if (op === 'call_indirect' || op === 'return_call_indirect') {
3260
- if (typeof node[1] === 'string' && redirects.has(node[1])) {
3261
- return [op, redirects.get(node[1]), ...node.slice(2)]
3262
- }
3263
- if (Array.isArray(node[1]) && node[1][0] === 'type' && typeof node[1][1] === 'string' && redirects.has(node[1][1])) {
3264
- return [op, ['type', redirects.get(node[1][1])], ...node.slice(2)]
3265
- }
3266
- }
3267
- })
3268
-
3269
- return ast
3270
- }
3271
-
3272
- // ==================== DATA SEGMENT PACKING ====================
3273
-
3274
- /** Parse a WAT data string literal into a plain byte array. Plain arrays —
3275
- * not Uint8Array — throughout the data codecs: typed-array views/methods have
3276
- * spotty native lowerings (subarray dispatches to the HOST, in-situ variable-
3277
- * index reads misread in the kernel), while plain number arrays are the
3278
- * optimizer's lingua franca and proven kernel-faithful. */
3279
- const parseDataString = (str) => {
3280
- if (typeof str !== 'string' || str.length < 2 || str[0] !== '"') return []
3281
- const bytes = []
3282
- // Hex digit value by char code, −1 for non-hex — pure number math (no
3283
- // regex/slice/parseInt on string views; see contract note above).
3284
- const hexv = (c) => c >= 48 && c <= 57 ? c - 48 : c >= 97 && c <= 102 ? c - 87 : c >= 65 && c <= 70 ? c - 55 : -1
3285
- const end = str.length - 1 // skip surrounding quotes
3286
- for (let i = 1; i < end; i++) {
3287
- const c = str.charCodeAt(i)
3288
- if (c !== 92) { bytes.push(c); continue }
3289
- const n = str.charCodeAt(++i)
3290
- if (n === 120 || n === 88) { // \xHH
3291
- bytes.push((hexv(str.charCodeAt(i + 1)) << 4) | hexv(str.charCodeAt(i + 2)))
3292
- i += 2
3293
- } else {
3294
- const h1 = hexv(n), h2 = i + 1 < end ? hexv(str.charCodeAt(i + 1)) : -1
3295
- if (h1 >= 0 && h2 >= 0) { bytes.push((h1 << 4) | h2); i++ }
3296
- else if (n === 110) bytes.push(10) // \n
3297
- else if (n === 116) bytes.push(9) // \t
3298
- else if (n === 114) bytes.push(13) // \r
3299
- else bytes.push(n) // \\ \" and any other escaped char
3300
- }
3301
- }
3302
- return bytes
3303
- }
3304
-
3305
- /** Encode a plain byte array as a WAT data string literal; `end` bounds the
3306
- * bytes (always passed explicitly — see parseDataString's contract note).
3307
- * (`b` comes from a plain-array element read, i.e. an untyped receiver — the
3308
- * `.toString(16)` here is exactly the dispatch the tryRuntimeNumberMethod /
3309
- * runtime-string-fork number arm exists for; it used to yield `undefined`
3310
- * in-kernel and zeroed every escaped byte of the emitted data segment.) */
3311
- const encodeDataString = (bytes, end) => {
3312
- let str = '"'
3313
- for (let i = 0; i < end; i++) {
3314
- const b = bytes[i]
3315
- if (b >= 32 && b < 127 && b !== 34 && b !== 92) str += String.fromCharCode(b)
3316
- else str += '\\' + b.toString(16).padStart(2, '0')
3317
- }
3318
- return str + '"'
3319
- }
3320
-
3321
- /** Trim trailing zeros from data content items. Per-byte pushes (never
3322
- * push(...spread) — a segment can be hundreds of KB and spreading overflows
3323
- * V8's argument stack; never Uint8Array — see parseDataString's note). */
3324
- const trimTrailingZeros = (items) => {
3325
- const bytes = []
3326
- for (const item of items) {
3327
- if (typeof item === 'string') {
3328
- const chunk = parseDataString(item)
3329
- for (let i = 0; i < chunk.length; i++) bytes.push(chunk[i])
3330
- } else if (Array.isArray(item) && item[0] === 'i8') {
3331
- for (let i = 1; i < item.length; i++) bytes.push(Number(item[i]) & 0xff)
3332
- } else {
3333
- return items // non-trimmable item
3334
- }
3335
- }
3336
- let end = bytes.length
3337
- while (end > 0 && bytes[end - 1] === 0) end--
3338
- if (end === bytes.length) return items
3339
- if (end === 0) return []
3340
- return [encodeDataString(bytes, end)]
3341
- }
3342
-
3343
- /** Extract { memidx, offset } from an active data segment with constant offset */
3344
- const getDataOffset = (node) => {
3345
- let idx = 1
3346
- if (typeof node[idx] === 'string' && node[idx][0] === '$') idx++
3347
- if (Array.isArray(node[idx]) && node[idx][0] === 'memory') {
3348
- const mem = node[idx][1]
3349
- idx++
3350
- const off = node[idx]
3351
- if (Array.isArray(off) && (off[0] === 'i32.const' || off[0] === 'i64.const')) {
3352
- return { memidx: mem, offset: Number(off[1]) }
3353
- }
3354
- return null
3355
- }
3356
- const off = node[idx]
3357
- if (Array.isArray(off) && (off[0] === 'i32.const' || off[0] === 'i64.const')) {
3358
- return { memidx: 0, offset: Number(off[1]) }
3359
- }
3360
- return null
3361
- }
3362
-
3363
- /** Get byte length of data segment content */
3364
- const getDataLength = (node) => {
3365
- let idx = 1
3366
- if (typeof node[idx] === 'string' && node[idx][0] === '$') idx++
3367
- if (Array.isArray(node[idx]) && node[idx][0] === 'memory') idx++
3368
- if (Array.isArray(node[idx]) && typeof node[idx][0] === 'string' && !node[idx][0].startsWith('"')) idx++
3369
- let len = 0
3370
- for (let i = idx; i < node.length; i++) {
3371
- const item = node[i]
3372
- if (typeof item === 'string') len += parseDataString(item).length
3373
- else if (Array.isArray(item) && item[0] === 'i8') len += item.length - 1
3374
- else return null
3375
- }
3376
- return len
3377
- }
3378
-
3379
- /** Merge segment b into a (consecutive offsets, same memory) */
3380
- const mergeDataSegments = (a, b) => {
3381
- let aIdx = 1
3382
- if (typeof a[aIdx] === 'string' && a[aIdx][0] === '$') aIdx++
3383
- if (Array.isArray(a[aIdx]) && a[aIdx][0] === 'memory') aIdx++
3384
- if (Array.isArray(a[aIdx]) && typeof a[aIdx][0] === 'string' && !a[aIdx][0].startsWith('"')) aIdx++
3385
-
3386
- let bIdx = 1
3387
- if (typeof b[bIdx] === 'string' && b[bIdx][0] === '$') bIdx++
3388
- if (Array.isArray(b[bIdx]) && b[bIdx][0] === 'memory') bIdx++
3389
- if (Array.isArray(b[bIdx]) && typeof b[bIdx][0] === 'string' && !b[bIdx][0].startsWith('"')) bIdx++
3390
-
3391
- const aContent = a.slice(aIdx)
3392
- const bContent = b.slice(bIdx)
3393
-
3394
- if (aContent.length === 1 && bContent.length === 1 &&
3395
- typeof aContent[0] === 'string' && typeof bContent[0] === 'string') {
3396
- const aBytes = parseDataString(aContent[0])
3397
- const bBytes = parseDataString(bContent[0])
3398
- for (let i = 0; i < bBytes.length; i++) aBytes.push(bBytes[i])
3399
- a.length = aIdx
3400
- a.push(encodeDataString(aBytes, aBytes.length))
3401
- return true
3402
- }
3403
-
3404
- a.length = aIdx
3405
- a.push(...aContent, ...bContent)
3406
- return true
3407
- }
3408
-
3409
- /**
3410
- * Pack data segments: trim trailing zeros and merge adjacent constant-offset segments.
3411
- * @param {Array} ast
3412
- * @returns {Array}
3413
- */
3414
- const packData = (ast) => {
3415
- if (!Array.isArray(ast) || ast[0] !== 'module') return ast
3416
-
3417
- // Trim trailing zeros
3418
- for (const node of ast) {
3419
- if (!Array.isArray(node) || node[0] !== 'data') continue
3420
- let contentStart = 1
3421
- if (typeof node[1] === 'string' && node[1][0] === '$') contentStart = 2
3422
- if (contentStart < node.length && Array.isArray(node[contentStart]) &&
3423
- typeof node[contentStart][0] === 'string' && !node[contentStart][0].startsWith('"')) {
3424
- contentStart++
3425
- }
3426
- const content = node.slice(contentStart)
3427
- if (content.length === 0) continue
3428
- const trimmed = trimTrailingZeros(content)
3429
- if (trimmed.length !== content.length || (trimmed.length > 0 && trimmed[0] !== content[0])) {
3430
- node.length = contentStart
3431
- node.push(...trimmed)
3432
- }
3433
- }
3434
-
3435
- // Merge adjacent active segments with same memory and consecutive offsets
3436
- const dataNodes = []
3437
- for (let i = 0; i < ast.length; i++) {
3438
- const node = ast[i]
3439
- if (Array.isArray(node) && node[0] === 'data') {
3440
- const info = getDataOffset(node)
3441
- if (info) {
3442
- const len = getDataLength(node)
3443
- if (len !== null) dataNodes.push({ ...info, node, index: i, len })
3444
- }
3445
- }
3446
- }
3447
-
3448
- dataNodes.sort((a, b) => {
3449
- const ma = String(a.memidx), mb = String(b.memidx)
3450
- if (ma !== mb) return ma.localeCompare(mb)
3451
- return a.offset - b.offset
3452
- })
3453
-
3454
- const toRemove = new Set()
3455
- for (let i = 0; i < dataNodes.length - 1; i++) {
3456
- const a = dataNodes[i]
3457
- const b = dataNodes[i + 1]
3458
- if (toRemove.has(a.index) || String(a.memidx) !== String(b.memidx)) continue
3459
- if (a.offset + a.len !== b.offset) continue
3460
- if (mergeDataSegments(a.node, b.node)) {
3461
- toRemove.add(b.index)
3462
- a.len = getDataLength(a.node)
3463
- }
3464
- }
3465
-
3466
- if (toRemove.size > 0) {
3467
- ast = ast.filter((_, i) => !toRemove.has(i))
3468
- }
3469
-
3470
- return ast
3471
- }
3472
-
3473
- // ==================== IMPORT FIELD MINIFICATION ====================
3474
-
3475
- /** Create a shortener that maps names to a, b, ..., z, aa, ab, ... */
3476
- const makeShortener = () => {
3477
- const map = new Map()
3478
- let n = 0
3479
- return (name) => {
3480
- if (!map.has(name)) {
3481
- let id = '', x = n++
3482
- do {
3483
- id = String.fromCharCode(97 + (x % 26)) + id
3484
- x = Math.floor(x / 26) - 1
3485
- } while (x >= 0)
3486
- map.set(name, id || 'a')
3487
- }
3488
- return map.get(name)
3489
- }
3490
- }
3491
-
3492
- /**
3493
- * Minify import module and field names for smaller binaries.
3494
- * Only safe when you control the host environment.
3495
- * @param {Array} ast
3496
- * @returns {Array}
3497
- */
3498
- const minifyImports = (ast) => {
3499
- if (!Array.isArray(ast) || ast[0] !== 'module') return ast
3500
- const shortMod = makeShortener()
3501
- const shortField = makeShortener()
3502
-
3503
- for (const node of ast) {
3504
- if (!Array.isArray(node) || node[0] !== 'import') continue
3505
- if (typeof node[1] === 'string' && node[1][0] === '"') {
3506
- node[1] = '"' + shortMod(node[1].slice(1, -1)) + '"'
3507
- }
3508
- if (typeof node[2] === 'string' && node[2][0] === '"') {
3509
- node[2] = '"' + shortField(node[2].slice(1, -1)) + '"'
3510
- }
3511
- }
3512
-
3513
- return ast
3514
- }
3515
-
3516
- // ==================== REORDER FUNCTIONS ====================
3517
-
3518
- /**
3519
- * Count direct calls and sort functions so hot ones come first.
3520
- * Smaller LEB128 indices for frequent calls reduce binary size.
3521
- * Imports must stay before defined functions to preserve the index space.
3522
- * @param {Array} ast
3523
- * @returns {Array}
3524
- */
3525
- /** True iff every defined func has a $name and every func reference is by $name */
3526
- const reorderSafe = (ast) => {
3527
- let safe = true
3528
- walk(ast, (n) => {
3529
- if (!safe || !Array.isArray(n)) return
3530
- const op = n[0]
3531
- if (op === 'func' && (typeof n[1] !== 'string' || n[1][0] !== '$')) safe = false
3532
- else if ((op === 'call' || op === 'return_call' || op === 'ref.func') &&
3533
- (typeof n[1] !== 'string' || n[1][0] !== '$')) safe = false
3534
- else if (op === 'start' && (typeof n[1] !== 'string' || n[1][0] !== '$')) safe = false
3535
- else if (op === 'elem') {
3536
- // Numeric func indices in elem segments would break too
3537
- for (const sub of n) {
3538
- if (typeof sub === 'string' && sub[0] !== '$' && /^\d/.test(sub)) { safe = false; break }
3539
- if (Array.isArray(sub) && sub[0] === 'ref.func' &&
3540
- (typeof sub[1] !== 'string' || sub[1][0] !== '$')) { safe = false; break }
3541
- }
3542
- }
3543
- })
3544
- return safe
3545
- }
3546
-
3547
- const reorder = (ast) => {
3548
- if (!Array.isArray(ast) || ast[0] !== 'module') return ast
3549
- // Sorting changes the function index space. Skip if any reference is numeric,
3550
- // since we'd silently retarget unnamed callers/start/elem entries.
3551
- if (!reorderSafe(ast)) return ast
3552
-
3553
- const callCounts = new Map()
3554
- walk(ast, (n) => {
3555
- if (!Array.isArray(n)) return
3556
- if (n[0] === 'call' || n[0] === 'return_call') {
3557
- callCounts.set(n[1], (callCounts.get(n[1]) || 0) + 1)
3558
- }
3559
- })
3560
-
3561
- // Imports must precede defined funcs (compile.js assigns indices in AST order).
3562
- const imports = [], funcs = [], others = []
3563
- for (const node of ast.slice(1)) {
3564
- if (!Array.isArray(node)) { others.push(node); continue }
3565
- if (node[0] === 'import') imports.push(node)
3566
- else if (node[0] === 'func') funcs.push(node)
3567
- else others.push(node)
3568
- }
3569
-
3570
- funcs.sort((a, b) => (callCounts.get(b[1]) || 0) - (callCounts.get(a[1]) || 0))
3571
- return ['module', ...imports, ...funcs, ...others]
3572
- }
3573
-
3574
- // ==================== MAIN ====================
3575
-
3576
- /**
3577
- * Optimization passes, in the order they run within each round. Each entry is
3578
- * `[optionKey, fn, defaultOn, doc]` — the single source of truth that the
3579
- * dispatch loop, the `OPTS` catalogue, and `normalize` all derive from.
3580
- * Passes that are off by default can bloat output or are expensive — opt-in.
3581
- */
3582
- const PASSES = [
3583
- ['stripmut', stripmut, true, 'strip mut from never-written globals'],
3584
- ['globals', globals, true, 'propagate immutable global constants'],
3585
- ['guardRefine', guardRefine, true, 'fold NaN-box tag reads under dominating tag guards'],
3586
- ['fold', fold, true, 'constant folding'],
3587
- ['identity', identity, true, 'remove identity ops (x + 0 → x)'],
3588
- ['peephole', peephole, true, 'x-x→0, x&0→0, etc.'],
3589
- ['strength', strength, true, 'strength reduction (x * 2 → x << 1)'],
3590
- ['branch', branch, true, 'simplify constant branches'],
3591
- ['propagate', propagate, true, 'forward-propagate single-use locals & tiny consts (never inflates)'],
3592
- ['devirt', devirt, false, 'call_indirect with known closure constants → guarded direct calls — grows bytes for speed'],
3593
- ['inlineOnce', inlineOnce, true, 'inline single-call functions into their lone caller (never duplicates)'],
3594
- ['inline', inline, false, 'inline tiny functions — can duplicate bodies'],
3595
- ['offset', offset, true, 'fold add+const into load/store offset'],
3596
- ['unbranch', unbranch, true, 'remove redundant br at end of own block'],
3597
- ['loopify', loopify, true, 'collapse block+loop+brif while-idiom into loop+if'],
3598
- ['brif', brif, true, 'if-then-br → br_if'],
3599
- ['foldarms', foldarms, false, 'merge identical trailing if arms — can add block wrapper'],
3600
- ['deadcode', deadcode, true, 'eliminate dead code after unreachable/br/return'],
3601
- ['vacuum', vacuum, true, 'remove nops, drop-of-pure, empty branches'],
3602
- ['mergeBlocks', mergeBlocks, true, 'unwrap `(block $L …)` whose label is never targeted'],
3603
- ['coalesce', coalesceLocals, true, 'share local slots between same-type non-overlapping locals'],
3604
- ['locals', localReuse, true, 'remove unused locals'],
3605
- ['dedupe', dedupe, true, 'eliminate duplicate functions'],
3606
- ['dedupTypes', dedupTypes, true, 'merge identical type definitions'],
3607
- ['packData', packData, true, 'trim trailing zeros, merge adjacent data segments'],
3608
- ['reorder', reorder, false, 'put hot functions first — no AST reduction'],
3609
- ['treeshake', treeshake, true, 'remove unused funcs/globals/types/tables'],
3610
- ['minifyImports', minifyImports, false, 'shorten import names — enable only when you control the host'],
3611
- ]
3612
-
3613
- /** Option name → default-on map — the public catalogue of passes. */
3614
- const OPTS = Object.fromEntries(PASSES.map(p => [p[0], p[2]]))
3615
-
3616
- /**
3617
- * Normalize options to a { passName: bool } map. An explicit object is kept
3618
- * as-is (preserving `log`/`verbose`), with any unmentioned pass filled to its
3619
- * default; `true` selects the defaults; a string selects only the named
3620
- * passes (or all of them via `'all'`).
3621
- *
3622
- * @param {boolean|string|Object} opts
3623
- * @returns {Object}
3624
- */
3625
- const normalize = (opts) => {
3626
- if (opts === false) return {}
3627
- if (opts !== true && typeof opts !== 'string') {
3628
- const m = { ...opts }
3629
- for (const p of PASSES) if (m[p[0]] === undefined) m[p[0]] = p[2]
3630
- return m
3631
- }
3632
- const set = typeof opts === 'string' ? new Set(opts.split(/\s+/).filter(Boolean)) : null
3633
- const m = {}
3634
- for (const p of PASSES) m[p[0]] = set ? (set.has('all') || set.has(p[0])) : p[2]
3635
- return m
3636
- }
3637
-
3638
- /**
3639
- * Optimize AST.
3640
- *
3641
- * @param {Array|string} ast - AST or WAT source
3642
- * @param {boolean|string|Object} [opts=true] - Optimization options
3643
- * @returns {Array} Optimized AST
3644
- *
3645
- * @example
3646
- * optimize(ast) // all optimizations
3647
- * optimize(ast, 'treeshake') // only treeshake
3648
- * optimize(ast, { fold: true }) // explicit
3649
- */
3650
- /**
3651
- * Could `inlineOnce`/`inline` grow the binary on this module? They are the only
3652
- * size-*increasing* passes: splicing a callee body plus its `block`/param-setup
3653
- * wrapper can exceed the `call` it removes. Every other pass strictly shrinks or
3654
- * holds. So if no function is even a candidate (called exactly once, not pinned
3655
- * by export/start/elem/ref.func, not its own exporter), nothing can inflate and
3656
- * the size guard is dead weight. Cheap over-approximation of inlineOnce's own
3657
- * gating — a false positive only costs the guarded path (correct, just slower).
3658
- */
3659
- const mayInline = (ast) => {
3660
- if (!Array.isArray(ast)) return false
3661
- const callRefs = new Map(), pinned = new Set(), other = new Set()
3662
- const scan = (n) => {
3663
- if (!Array.isArray(n)) return
3664
- const op = n[0]
3665
- if (op === 'call' && typeof n[1] === 'string') callRefs.set(n[1], (callRefs.get(n[1]) || 0) + 1)
3666
- else if (op === 'return_call' && typeof n[1] === 'string') other.add(n[1])
3667
- else if (op === 'ref.func' && typeof n[1] === 'string') pinned.add(n[1])
3668
- else if (op === 'export' && Array.isArray(n[2]) && n[2][0] === 'func' && typeof n[2][1] === 'string') pinned.add(n[2][1])
3669
- else if (op === 'start' && typeof n[1] === 'string') pinned.add(n[1])
3670
- else if (op === 'elem') for (const c of n) if (typeof c === 'string' && c[0] === '$') pinned.add(c)
3671
- for (let i = 1; i < n.length; i++) scan(n[i])
3672
- }
3673
- scan(ast)
3674
- for (const n of ast) {
3675
- if (!Array.isArray(n) || n[0] !== 'func' || typeof n[1] !== 'string') continue
3676
- const name = n[1]
3677
- if (callRefs.get(name) !== 1 || pinned.has(name) || other.has(name)) continue
3678
- if (n.some(c => Array.isArray(c) && c[0] === 'export')) continue // self-exporting func
3679
- return true
3680
- }
3681
- return false
3682
- }
3683
-
3684
- export default function optimize(ast, opts = true) {
3685
- const strictGuard = opts === true // default: zero tolerance for bloat
3686
- opts = normalize(opts)
3687
-
3688
- const log = opts.log ? (msg, delta) => opts.log(msg, delta) : () => {}
3689
- const verbose = opts.verbose || opts.log
3690
-
3691
- ast = clone(ast)
3692
-
3693
- // devirt trades bytes for speed by design (guards + duplicated args), so it
3694
- // runs ONCE after the rounds — its candidate shape (select of two i64 closure
3695
- // constants) only emerges from fold/propagate, and its intended growth must
3696
- // not trip the size-guard into reverting a whole round. A single sweep is
3697
- // complete: every call_indirect is visited; rewritten sites keep the original
3698
- // as the guarded fallback arm.
3699
- const finish = (a) => opts.devirt ? devirt(a) : a
3700
-
3701
- // Fast path: jz owns this optimizer and feeds it a controlled, type-aware IR.
3702
- // The only passes that can *grow* the binary are inlineOnce/inline; when no
3703
- // function is an inline candidate (the common case for scalar REPL kernels)
3704
- // nothing can inflate, so we skip watr's per-round `binarySize` re-compile
3705
- // guard — up to four full encodes per call — and iterate to a fixpoint with
3706
- // zero compiles. A round that changes nothing is the natural exit.
3707
- if (!((opts.inlineOnce || opts.inline) && mayInline(ast))) {
3708
- // inlineOnce/inline can't fire here, so skip them — their candidate scan
3709
- // (a 16-round whole-module walk) is the second-costliest thing after
3710
- // propagate, and it would only confirm what `mayInline` already proved.
3711
- for (let round = 0; round < 3; round++) {
3712
- const beforeRound = clone(ast)
3713
- for (const [key, fn] of PASSES) if (opts[key] && key !== 'inlineOnce' && key !== 'inline' && key !== 'devirt') ast = fn(ast)
3714
- if (equal(beforeRound, ast)) break // fixpoint
3715
- if (verbose) log(` round ${round + 1} applied`)
3716
- }
3717
- return finish(ast)
3718
- }
3719
-
3720
- // Guarded path: inlining can inflate (a body bigger than the call it replaces),
3721
- // so score each round on encoded bytes and revert any that grows the binary.
3722
- // `binarySize` returns Infinity for invalid wat, so a broken round reverts too.
3723
- // A round's starting size equals the prior round's ending size, so carry it
3724
- // forward and compile once per round.
3725
- let beforeRound = null
3726
- let sizeBefore = binarySize(ast)
3727
- for (let round = 0; round < 3; round++) {
3728
- beforeRound = clone(ast)
3729
-
3730
- for (const [key, fn] of PASSES) if (opts[key] && key !== 'devirt') ast = fn(ast)
3731
- // Second propagate sweep: `inlineOnce`/`inline` (above) leave fresh
3732
- // `(local.set $p arg) … (local.get $p)` wrappers around each inlined call;
3733
- // re-running propagation collapses them within this same round, so the size
3734
- // guard scores the cleaned result.
3735
- if (opts.propagate && (opts.inlineOnce || opts.inline)) ast = propagate(ast)
3736
-
3737
- // A round that changed nothing can't have inflated — check convergence
3738
- // before compiling so the fixpoint-confirming round costs zero compiles.
3739
- if (equal(beforeRound, ast)) break
3740
-
3741
- const sizeAfter = binarySize(ast)
3742
- const delta = sizeAfter - sizeBefore
3743
- if (verbose || delta !== 0) log(` round ${round + 1}: ${delta > 0 ? '+' : ''}${delta} bytes`, delta)
3744
-
3745
- // Default optimize must never inflate; explicit passes get slight leniency.
3746
- const tolerance = strictGuard ? 0 : 16
3747
- if (delta > tolerance) {
3748
- if (verbose) log(` ⚠ round ${round + 1} inflated by ${delta} bytes, reverting`, delta)
3749
- ast = beforeRound
3750
- break
3751
- }
3752
- sizeBefore = sizeAfter
3753
- }
3754
-
3755
- return finish(ast)
3756
- }
3757
-
3758
- /** Count AST nodes (fast size heuristic). */
3759
- export { count as size, count, binarySize }
3760
- export { optimize, treeshake, fold, deadcode, localReuse, identity, strength, branch, propagate, inline, inlineOnce, devirt, normalize, OPTS, vacuum, peephole, globals, offset, unbranch, loopify, stripmut, brif, foldarms, dedupe, reorder, dedupTypes, packData, minifyImports, mergeBlocks, coalesceLocals }