jz 0.5.1 → 0.7.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.
- package/README.md +315 -318
- package/bench/README.md +369 -0
- package/bench/bench.svg +102 -0
- package/cli.js +104 -30
- package/dist/interop.js +1 -0
- package/dist/jz.js +6024 -0
- package/index.d.ts +126 -0
- package/index.js +362 -84
- package/interop.js +159 -186
- package/jz.svg +5 -0
- package/jzify/arguments.js +97 -0
- package/jzify/bundler.js +382 -0
- package/jzify/classes.js +328 -0
- package/jzify/hoist-vars.js +177 -0
- package/jzify/index.js +51 -0
- package/jzify/names.js +37 -0
- package/jzify/switch.js +106 -0
- package/jzify/transform.js +349 -0
- package/layout.js +184 -0
- package/module/array.js +377 -176
- package/module/collection.js +639 -145
- package/module/console.js +55 -43
- package/module/core.js +264 -153
- package/module/date.js +15 -3
- package/module/function.js +73 -6
- package/module/index.js +2 -1
- package/module/json.js +226 -61
- package/module/math.js +551 -187
- package/module/number.js +327 -60
- package/module/object.js +474 -184
- package/module/regex.js +255 -25
- package/module/schema.js +24 -6
- package/module/simd.js +117 -0
- package/module/string.js +621 -226
- package/module/symbol.js +1 -1
- package/module/timer.js +9 -14
- package/module/typedarray.js +459 -73
- package/package.json +58 -14
- package/src/abi/index.js +39 -23
- package/src/abi/string.js +38 -41
- package/src/ast.js +460 -0
- package/src/autoload.js +29 -24
- package/src/bridge.js +111 -0
- package/src/compile/analyze-scans.js +824 -0
- package/src/compile/analyze.js +1600 -0
- package/src/compile/emit-assign.js +411 -0
- package/src/compile/emit.js +3512 -0
- package/src/compile/flow-types.js +103 -0
- package/src/{compile.js → compile/index.js} +778 -128
- package/src/{infer.js → compile/infer.js} +62 -98
- package/src/compile/loop-divmod.js +155 -0
- package/src/{narrow.js → compile/narrow.js} +409 -106
- package/src/compile/peel-stencil.js +261 -0
- package/src/compile/plan/advise.js +370 -0
- package/src/compile/plan/common.js +150 -0
- package/src/compile/plan/index.js +122 -0
- package/src/compile/plan/inline.js +682 -0
- package/src/compile/plan/literals.js +1199 -0
- package/src/compile/plan/loops.js +472 -0
- package/src/compile/plan/scope.js +649 -0
- package/src/compile/program-facts.js +423 -0
- package/src/ctx.js +220 -64
- package/src/ir.js +589 -172
- package/src/kind-traits.js +132 -0
- package/src/kind.js +524 -0
- package/src/op-policy.js +57 -0
- package/src/{optimize.js → optimize/index.js} +1432 -473
- package/src/optimize/vectorize.js +4660 -0
- package/src/param-reps.js +65 -0
- package/src/parse.js +44 -0
- package/src/{prepare.js → prepare/index.js} +649 -205
- package/src/prepare/lift-iife.js +149 -0
- package/src/reps.js +116 -0
- package/src/resolve.js +12 -3
- package/src/static.js +208 -0
- package/src/type.js +651 -0
- package/src/{assemble.js → wat/assemble.js} +275 -55
- package/src/wat/optimize.js +3938 -0
- package/transform.js +21 -0
- package/wasi.js +47 -5
- package/src/analyze.js +0 -3818
- package/src/emit.js +0 -3040
- package/src/jzify.js +0 -1580
- package/src/plan.js +0 -2132
- package/src/vectorize.js +0 -1088
- /package/src/{codegen.js → wat/codegen.js} +0 -0
|
@@ -0,0 +1,3938 @@
|
|
|
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
|
+
// Structured / control-flow forms: they do NOT evaluate all children eagerly
|
|
1208
|
+
// (an `if` runs one arm; a `block`/`loop` scopes branches), so their side effects
|
|
1209
|
+
// can't be flattened to the children's — they stay whole under a drop. (`br*`,
|
|
1210
|
+
// `try_table` are already in IMPURE_OPS.)
|
|
1211
|
+
const STRUCTURED_OPS = new Set(['if', 'then', 'else', 'block', 'loop', 'try'])
|
|
1212
|
+
|
|
1213
|
+
// `op` is an EAGER value operation: it evaluates every operand unconditionally
|
|
1214
|
+
// and only computes a result (arithmetic, compare, convert, select, load) — so
|
|
1215
|
+
// discarding its value leaves just the operands' side effects. Excludes impure
|
|
1216
|
+
// ops and the structured forms above.
|
|
1217
|
+
const isEagerValueOp = (op) => typeof op === 'string' && !IMPURE_OPS.has(op) &&
|
|
1218
|
+
!STRUCTURED_OPS.has(op) && !IMPURE_SUBSTRINGS.some(s => op.includes(s))
|
|
1219
|
+
|
|
1220
|
+
// Statements that preserve `node`'s side effects when its VALUE is discarded.
|
|
1221
|
+
// A fully-pure value contributes nothing; an eager value op contributes only its
|
|
1222
|
+
// operands' effects (the op result is dead); a `local.tee` keeps the store as a
|
|
1223
|
+
// `local.set`; anything else (call, store-expr, structured form) stays under a drop.
|
|
1224
|
+
// This turns `drop(i32.sub(tee X V, 1))` — the post-increment's dropped old value
|
|
1225
|
+
// — into the bare `local.set X V`, eliminating dead arithmetic the plain
|
|
1226
|
+
// `drop(PURE)→nop` rule can't (the tee makes the whole subtree impure).
|
|
1227
|
+
const dropEffects = (node) => {
|
|
1228
|
+
if (!Array.isArray(node) || isPure(node)) return []
|
|
1229
|
+
const op = node[0]
|
|
1230
|
+
if (op === 'local.tee' && node.length === 3) return [['local.set', node[1], node[2]]]
|
|
1231
|
+
if (isEagerValueOp(op)) {
|
|
1232
|
+
const eff = []
|
|
1233
|
+
for (let i = 1; i < node.length; i++) eff.push(...dropEffects(node[i]))
|
|
1234
|
+
return eff
|
|
1235
|
+
}
|
|
1236
|
+
return [['drop', node]]
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/** Count all local.get/set/tee occurrences in one walk */
|
|
1240
|
+
const countLocalUses = (node) => {
|
|
1241
|
+
const counts = new Map()
|
|
1242
|
+
const ensure = name => { if (!counts.has(name)) counts.set(name, { gets: 0, sets: 0, tees: 0 }); return counts.get(name) }
|
|
1243
|
+
walk(node, n => {
|
|
1244
|
+
if (!Array.isArray(n) || n.length < 2 || typeof n[1] !== 'string') return
|
|
1245
|
+
if (n[0] === 'local.get') ensure(n[1]).gets++
|
|
1246
|
+
else if (n[0] === 'local.set') ensure(n[1]).sets++
|
|
1247
|
+
else if (n[0] === 'local.tee') ensure(n[1]).tees++
|
|
1248
|
+
})
|
|
1249
|
+
return counts
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/** A constant whose inlined form (opcode + immediate) is no wider than the ~2 B
|
|
1253
|
+
* `local.get` it would replace — so propagating it to every use is byte-neutral
|
|
1254
|
+
* at worst, and still drops the `local.set` + the `local` decl. f32/f64 consts
|
|
1255
|
+
* (5/9 B) lose on reuse, so only narrow i32/i64 literals qualify. */
|
|
1256
|
+
const isTinyConst = (node) => {
|
|
1257
|
+
const c = getConst(node)
|
|
1258
|
+
if (!c) return false
|
|
1259
|
+
if (c.type === 'i32') { const v = c.value | 0; return v >= -64 && v <= 63 }
|
|
1260
|
+
if (c.type === 'i64') { const v = BigInt(c.value); return v <= 63n || v >= 0xffffffffffffffc0n } // unsigned bits: [0,63] or two's-comp [−64,−1]
|
|
1261
|
+
return false
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/** A pure local→local copy value `(local.get $src)`, with $src ≠ the local being set.
|
|
1265
|
+
* Substituting it for a `(local.get $dst)` is byte-neutral (local.get for local.get),
|
|
1266
|
+
* so — unlike a reused wide constant — it can never grow an instruction, and it turns
|
|
1267
|
+
* the copy `$dst = $src` into a dead store the next pass drops. Self-copies are
|
|
1268
|
+
* excluded: they're no-ops that would re-trigger `changed` every round. (Propagating a
|
|
1269
|
+
* copy lengthens $src's live range, which can rarely cost coalesceLocals a slot — a
|
|
1270
|
+
* few bytes — but net-shrinks across the corpus, e.g. −1.7 KB on the watr self-host.) */
|
|
1271
|
+
const isLocalCopy = (val, dest) =>
|
|
1272
|
+
Array.isArray(val) && val[0] === 'local.get' && val.length === 2 &&
|
|
1273
|
+
typeof val[1] === 'string' && val[1] !== dest
|
|
1274
|
+
|
|
1275
|
+
/** Can this tracked value be substituted for a local.get?
|
|
1276
|
+
* - single use of a pure value: always shrinks (drops the set, the lone get, the decl);
|
|
1277
|
+
* - any use of a tiny constant: byte-neutral at worst, still drops the set + decl;
|
|
1278
|
+
* - any use of a pure local copy: byte-neutral, frees the copy as a dead store.
|
|
1279
|
+
* Anything else (a wide constant reused many times, an impure expr) could inflate
|
|
1280
|
+
* or reorder side effects, so it's left alone. Copy validity (the source not being
|
|
1281
|
+
* reassigned between copy and use) is enforced by the same purgeRefs/branch-clear
|
|
1282
|
+
* machinery that guards every tracked value. */
|
|
1283
|
+
const canSubst = (k) => (k.pure && k.singleUse) || isTinyConst(k.val) || k.copy
|
|
1284
|
+
|
|
1285
|
+
/** Drop tracked values that read `$name`: rewriting `$name` makes them stale. */
|
|
1286
|
+
const purgeRefs = (known, name) => {
|
|
1287
|
+
for (const [key, tracked] of known) {
|
|
1288
|
+
let refs = false
|
|
1289
|
+
walk(tracked.val, n => { if (Array.isArray(n) && (n[0] === 'local.get' || n[0] === 'local.tee') && n[1] === name) refs = true })
|
|
1290
|
+
if (refs) known.delete(key)
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
/** Drop tracked values that read global `$name`: a `global.set $name` makes them stale.
|
|
1295
|
+
* The local-only {@link purgeRefs} misses this — so a value captured from a global
|
|
1296
|
+
* (`let s = f`, where `f` is a reassignable module-level binding) would survive an
|
|
1297
|
+
* intervening `f = …` and substitute the NEW global. That silently breaks the canonical
|
|
1298
|
+
* pointer swap `let s = f; f = g; g = s` (g would read post-swap f, i.e. itself). */
|
|
1299
|
+
const purgeGlobalRefs = (known, name) => {
|
|
1300
|
+
for (const [key, tracked] of known) {
|
|
1301
|
+
let refs = false
|
|
1302
|
+
walk(tracked.val, n => { if (Array.isArray(n) && n[0] === 'global.get' && n[1] === name) refs = true })
|
|
1303
|
+
if (refs) known.delete(key)
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/** True if `node` recursively contains an op that may read linear memory.
|
|
1308
|
+
* Tracked values whose RHS reads memory go stale after any intervening
|
|
1309
|
+
* memory-mutating op (`*.store`, `memory.copy/fill/init`, atomic stores/rmw). */
|
|
1310
|
+
const readsMemory = (node) => {
|
|
1311
|
+
if (!Array.isArray(node)) return false
|
|
1312
|
+
const op = node[0]
|
|
1313
|
+
if (typeof op === 'string') {
|
|
1314
|
+
if (op.includes('.load') || op === 'memory.copy' || op === 'memory.size') return true
|
|
1315
|
+
}
|
|
1316
|
+
for (let i = 1; i < node.length; i++) if (readsMemory(node[i])) return true
|
|
1317
|
+
return false
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
/** True if `node` references state a `call` could mutate.
|
|
1321
|
+
* Calls cannot touch caller locals (those live in the function frame), so
|
|
1322
|
+
* pure expressions over locals + constants survive any intervening call; only
|
|
1323
|
+
* memory loads, global reads, and table reads (or further calls) can be stale
|
|
1324
|
+
* after one. */
|
|
1325
|
+
const readsCallableState = (node) => {
|
|
1326
|
+
if (!Array.isArray(node)) return false
|
|
1327
|
+
const op = node[0]
|
|
1328
|
+
if (typeof op === 'string') {
|
|
1329
|
+
if (op === 'global.get' || op === 'table.get' || op === 'table.size') return true
|
|
1330
|
+
if (op === 'call' || op === 'call_indirect' || op === 'return_call' || op === 'return_call_indirect') return true
|
|
1331
|
+
if (op.includes('.load') || op === 'memory.copy' || op === 'memory.size') return true
|
|
1332
|
+
}
|
|
1333
|
+
for (let i = 1; i < node.length; i++) if (readsCallableState(node[i])) return true
|
|
1334
|
+
return false
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
/** True if `node` recursively contains an op that may write linear memory. */
|
|
1338
|
+
const writesMemory = (node) => {
|
|
1339
|
+
if (!Array.isArray(node)) return false
|
|
1340
|
+
const op = node[0]
|
|
1341
|
+
if (typeof op === 'string') {
|
|
1342
|
+
if (op.endsWith('.store') || op === 'memory.copy' || op === 'memory.fill' || op === 'memory.init') return true
|
|
1343
|
+
// Atomic RMW / store / notify all mutate memory; `.atomic.load` doesn't.
|
|
1344
|
+
if (op.includes('.atomic.') && !op.endsWith('.load')) return true
|
|
1345
|
+
}
|
|
1346
|
+
for (let i = 1; i < node.length; i++) if (writesMemory(node[i])) return true
|
|
1347
|
+
return false
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
/** Try substitute local.get nodes with known values.
|
|
1351
|
+
* When entering a nested scope (block/loop/if), drop tracking for any local
|
|
1352
|
+
* that's re-assigned inside the subtree — the outer-tracked value is stale
|
|
1353
|
+
* there. Without this, an outer `(local.set $x C)` would clobber an inner
|
|
1354
|
+
* `(local.set $x V) (local.get $x)` (the inner get rewritten to `C` instead
|
|
1355
|
+
* of `V`). Mostly latent until something — typically coalesceLocals — reuses
|
|
1356
|
+
* one slot for the outer and inner roles, after which it surfaces as silent
|
|
1357
|
+
* memory corruption. */
|
|
1358
|
+
const substGets = (node, known) => {
|
|
1359
|
+
if (!Array.isArray(node)) return node
|
|
1360
|
+
const op = node[0]
|
|
1361
|
+
if (op === 'local.get' && node.length === 2) {
|
|
1362
|
+
const k = typeof node[1] === 'string' && known.get(node[1])
|
|
1363
|
+
if (k && canSubst(k)) return clone(k.val)
|
|
1364
|
+
return node
|
|
1365
|
+
}
|
|
1366
|
+
let inner = known
|
|
1367
|
+
if (isBranchScope(op)) {
|
|
1368
|
+
let cloned = null
|
|
1369
|
+
walk(node, n => {
|
|
1370
|
+
if (!Array.isArray(n)) return
|
|
1371
|
+
if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string' && known.has(n[1])) {
|
|
1372
|
+
if (!cloned) cloned = new Map(known)
|
|
1373
|
+
cloned.delete(n[1])
|
|
1374
|
+
}
|
|
1375
|
+
})
|
|
1376
|
+
if (cloned) inner = cloned
|
|
1377
|
+
}
|
|
1378
|
+
for (let i = 1; i < node.length; i++) {
|
|
1379
|
+
const r = substGets(node[i], inner)
|
|
1380
|
+
if (r !== node[i]) node[i] = r
|
|
1381
|
+
// WASM evaluates operands left-to-right. A `local.set`/`local.tee` in this
|
|
1382
|
+
// child updates the local before the next sibling reads it — drop tracked
|
|
1383
|
+
// entries that are now stale, else a pre-tee constant leaks into the next
|
|
1384
|
+
// sibling's `local.get` (visible after `coalesceLocals` aliases the tee'd
|
|
1385
|
+
// local with a sibling-read local, e.g. `alloc($x<<3, $x)` collapsing to
|
|
1386
|
+
// `alloc(BIG, SMALL)`).
|
|
1387
|
+
if (i + 1 < node.length && Array.isArray(node[i])) {
|
|
1388
|
+
walk(node[i], n => {
|
|
1389
|
+
if (!Array.isArray(n)) return
|
|
1390
|
+
if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') {
|
|
1391
|
+
if (inner === known) inner = new Map(known)
|
|
1392
|
+
inner.delete(n[1])
|
|
1393
|
+
purgeRefs(inner, n[1])
|
|
1394
|
+
} else if (n[0] === 'global.set' && typeof n[1] === 'string') { // same staleness as a local write — a sibling operand's
|
|
1395
|
+
if (inner === known) inner = new Map(known) // global write invalidates a later operand's global-sourced copy
|
|
1396
|
+
purgeGlobalRefs(inner, n[1])
|
|
1397
|
+
}
|
|
1398
|
+
})
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
return node
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
/**
|
|
1405
|
+
* Forward propagation pass: track local.set values and substitute local.gets.
|
|
1406
|
+
* Returns true if any substitution was made.
|
|
1407
|
+
* @param {Array} funcNode
|
|
1408
|
+
* @param {Set<string>} params
|
|
1409
|
+
* @param {Map<string,{gets:number,sets:number,tees:number}>} useCounts
|
|
1410
|
+
*/
|
|
1411
|
+
const forwardPropagate = (funcNode, params, useCounts) => {
|
|
1412
|
+
let changed = false
|
|
1413
|
+
const getUseCount = name => useCounts.get(name) || { gets: 0, sets: 0, tees: 0 }
|
|
1414
|
+
const known = new Map()
|
|
1415
|
+
|
|
1416
|
+
for (let i = 1; i < funcNode.length; i++) {
|
|
1417
|
+
const instr = funcNode[i]
|
|
1418
|
+
if (!Array.isArray(instr)) continue
|
|
1419
|
+
const op = instr[0]
|
|
1420
|
+
|
|
1421
|
+
if (op === 'param' || op === 'result' || op === 'local' || op === 'type' || op === 'export') continue
|
|
1422
|
+
|
|
1423
|
+
// Track local.set / local.tee values (tee writes too — its result also leaves
|
|
1424
|
+
// the value on the stack but the local is updated identically to set).
|
|
1425
|
+
if ((op === 'local.set' || op === 'local.tee') && instr.length === 3 && typeof instr[1] === 'string') {
|
|
1426
|
+
// substGets returns its argument unchanged unless the whole subtree
|
|
1427
|
+
// resolves to a substitution (bare `(local.get $x)` root case) — assign
|
|
1428
|
+
// back so the bare-RHS pattern actually propagates.
|
|
1429
|
+
const sr = substGets(instr[2], known)
|
|
1430
|
+
if (sr !== instr[2]) { instr[2] = sr; changed = true }
|
|
1431
|
+
// Nested `local.set`/`local.tee` inside the RHS already ran when the next
|
|
1432
|
+
// statement begins — drop tracked values that read those locals, else a
|
|
1433
|
+
// later `local.get` substitutes a stale expression (e.g. `$ptr`'s
|
|
1434
|
+
// `(local.get $ai0)` after a nested `(local.tee $ai0 …)` overwrites it).
|
|
1435
|
+
walk(instr[2], n => {
|
|
1436
|
+
if (!Array.isArray(n)) return
|
|
1437
|
+
if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string')
|
|
1438
|
+
{ known.delete(n[1]); purgeRefs(known, n[1]) }
|
|
1439
|
+
else if (n[0] === 'global.set' && typeof n[1] === 'string') purgeGlobalRefs(known, n[1])
|
|
1440
|
+
})
|
|
1441
|
+
const uses = getUseCount(instr[1])
|
|
1442
|
+
purgeRefs(known, instr[1]) // entries that read this local just went stale
|
|
1443
|
+
// Any tracked value whose RHS reads memory must be invalidated by the
|
|
1444
|
+
// RHS itself if it writes memory (rare — only via nested store/copy/etc.,
|
|
1445
|
+
// which would also pass through the post-statement purge below).
|
|
1446
|
+
if (writesMemory(instr[2])) {
|
|
1447
|
+
for (const [key, tracked] of known) if (tracked.readsMem) known.delete(key)
|
|
1448
|
+
}
|
|
1449
|
+
known.set(instr[1], {
|
|
1450
|
+
val: instr[2], pure: isPure(instr[2]),
|
|
1451
|
+
readsMem: readsMemory(instr[2]),
|
|
1452
|
+
singleUse: uses.gets <= 1 && uses.sets <= 1 && uses.tees === 0,
|
|
1453
|
+
copy: isLocalCopy(instr[2], instr[1])
|
|
1454
|
+
})
|
|
1455
|
+
continue
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
// Invalidate at control-flow boundaries
|
|
1459
|
+
if (isBranchScope(op)) known.clear()
|
|
1460
|
+
// Calls invalidate tracked values that read state a callee can mutate
|
|
1461
|
+
// (memory, globals, tables, nested calls). Pure expressions over locals
|
|
1462
|
+
// and constants survive — callees can't reach caller locals.
|
|
1463
|
+
if (op === 'call' || op === 'call_indirect' || op === 'return_call' || op === 'return_call_indirect')
|
|
1464
|
+
for (const [key, tracked] of known) if (readsCallableState(tracked.val)) known.delete(key)
|
|
1465
|
+
|
|
1466
|
+
// Substitute: standalone local.get (walkPost can't replace root)
|
|
1467
|
+
if (op === 'local.get' && instr.length === 2 && typeof instr[1] === 'string') {
|
|
1468
|
+
const tracked = known.get(instr[1])
|
|
1469
|
+
if (tracked && canSubst(tracked)) {
|
|
1470
|
+
const replacement = clone(tracked.val)
|
|
1471
|
+
instr.length = 0; instr.push(...(Array.isArray(replacement) ? replacement : [replacement]))
|
|
1472
|
+
changed = true; continue
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
// Substitute nested local.gets (skip control-flow nodes — locals may be reassigned inside)
|
|
1477
|
+
if (op !== 'block' && op !== 'loop' && op !== 'if') {
|
|
1478
|
+
const prev = clone(instr)
|
|
1479
|
+
substGets(instr, known)
|
|
1480
|
+
if (!equal(prev, instr)) changed = true
|
|
1481
|
+
// Invalidate tracking for any names written by a nested set/tee — those
|
|
1482
|
+
// writes happened mid-expression and the substGets above used the
|
|
1483
|
+
// pre-write tracked value (correct), but later reads must see the new
|
|
1484
|
+
// (untracked) value, not the stale constant.
|
|
1485
|
+
walk(instr, n => {
|
|
1486
|
+
if (!Array.isArray(n)) return
|
|
1487
|
+
if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string')
|
|
1488
|
+
{ known.delete(n[1]); purgeRefs(known, n[1]) }
|
|
1489
|
+
else if (n[0] === 'global.set' && typeof n[1] === 'string') purgeGlobalRefs(known, n[1])
|
|
1490
|
+
})
|
|
1491
|
+
// Memory write in this statement (any nested store / memory.copy / etc.)
|
|
1492
|
+
// invalidates every tracked value whose RHS reads memory: inlining one
|
|
1493
|
+
// later would substitute a now-stale load. Without this, a swap idiom
|
|
1494
|
+
// (local.set $t (f64.load $p)) (f64.store $p (f64.load $q)) (f64.store $q (local.get $t))
|
|
1495
|
+
// collapses to two stores that round-trip the same value:
|
|
1496
|
+
// (f64.store $p (f64.load $q)) (f64.store $q (f64.load $p)) ;; bug
|
|
1497
|
+
if (writesMemory(instr)) {
|
|
1498
|
+
for (const [key, tracked] of known) if (tracked.readsMem) known.delete(key)
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
return changed
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
/**
|
|
1507
|
+
* Remove adjacent (local.set $x expr) (local.get $x) pairs when $x has no other uses.
|
|
1508
|
+
* Returns true if any pair was removed.
|
|
1509
|
+
* @param {Array} funcNode
|
|
1510
|
+
* @param {Set<string>} params
|
|
1511
|
+
* @param {Map<string,{gets:number,sets:number,tees:number}>} useCounts
|
|
1512
|
+
*/
|
|
1513
|
+
const eliminateSetGetPairs = (funcNode, params, useCounts) => {
|
|
1514
|
+
let changed = false
|
|
1515
|
+
|
|
1516
|
+
for (let i = 1; i < funcNode.length - 1; i++) {
|
|
1517
|
+
const setNode = funcNode[i]
|
|
1518
|
+
const getNode = funcNode[i + 1]
|
|
1519
|
+
if (!Array.isArray(setNode) || setNode[0] !== 'local.set' || setNode.length !== 3) continue
|
|
1520
|
+
if (!Array.isArray(getNode) || getNode[0] !== 'local.get' || getNode.length !== 2) continue
|
|
1521
|
+
const name = setNode[1]
|
|
1522
|
+
if (getNode[1] !== name || params.has(name)) continue
|
|
1523
|
+
const uses = useCounts.get(name) || { gets: 0, sets: 0, tees: 0 }
|
|
1524
|
+
// Must be exactly 1 set and 1 get (the pair), no tees
|
|
1525
|
+
if (uses.sets !== 1 || uses.gets !== 1 || uses.tees !== 0) continue
|
|
1526
|
+
// Replace the pair with just the expression
|
|
1527
|
+
const expr = clone(setNode[2])
|
|
1528
|
+
funcNode.splice(i, 2, ...(Array.isArray(expr) ? [expr] : [expr]))
|
|
1529
|
+
changed = true
|
|
1530
|
+
i-- // adjust index because we removed 2 and inserted 1
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
return changed
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
/**
|
|
1537
|
+
* Convert (local.set $x expr) (local.get $x) to (local.tee $x expr)
|
|
1538
|
+
* when $x has additional uses beyond this pair.
|
|
1539
|
+
* @param {Array} funcNode
|
|
1540
|
+
* @param {Set<string>} params
|
|
1541
|
+
* @param {Map<string,{gets:number,sets:number,tees:number}>} useCounts
|
|
1542
|
+
*/
|
|
1543
|
+
const createLocalTees = (funcNode, params, useCounts) => {
|
|
1544
|
+
let changed = false
|
|
1545
|
+
|
|
1546
|
+
for (let i = 1; i < funcNode.length - 1; i++) {
|
|
1547
|
+
const setNode = funcNode[i]
|
|
1548
|
+
const getNode = funcNode[i + 1]
|
|
1549
|
+
if (!Array.isArray(setNode) || setNode[0] !== 'local.set' || setNode.length !== 3) continue
|
|
1550
|
+
if (!Array.isArray(getNode) || getNode[0] !== 'local.get' || getNode.length !== 2) continue
|
|
1551
|
+
const name = setNode[1]
|
|
1552
|
+
if (getNode[1] !== name || params.has(name)) continue
|
|
1553
|
+
const uses = useCounts.get(name) || { gets: 0, sets: 0, tees: 0 }
|
|
1554
|
+
// Only if there's more than just this set+get pair
|
|
1555
|
+
if (uses.sets + uses.gets + uses.tees <= 2) continue
|
|
1556
|
+
// Replace with local.tee (set+get combined)
|
|
1557
|
+
funcNode.splice(i, 2, ['local.tee', name, clone(setNode[2])])
|
|
1558
|
+
changed = true
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
return changed
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
/**
|
|
1565
|
+
* Remove dead stores and unused local declarations in a reverse pass.
|
|
1566
|
+
* Returns true if anything was removed.
|
|
1567
|
+
* @param {Array} funcNode
|
|
1568
|
+
* @param {Set<string>} params
|
|
1569
|
+
* @param {Map<string,{gets:number,sets:number,tees:number}>} useCounts
|
|
1570
|
+
*/
|
|
1571
|
+
const eliminateDeadStores = (funcNode, params, useCounts) => {
|
|
1572
|
+
let changed = false
|
|
1573
|
+
const getPostUseCount = name => useCounts.get(name) || { gets: 0, sets: 0, tees: 0 }
|
|
1574
|
+
|
|
1575
|
+
for (let i = funcNode.length - 1; i >= 1; i--) {
|
|
1576
|
+
const sub = funcNode[i]
|
|
1577
|
+
if (!Array.isArray(sub)) continue
|
|
1578
|
+
const name = typeof sub[1] === 'string' ? sub[1] : null
|
|
1579
|
+
if (!name || params.has(name)) continue
|
|
1580
|
+
const uses = getPostUseCount(name)
|
|
1581
|
+
// Dead store: set but never read.
|
|
1582
|
+
if (sub[0] === 'local.set' && uses.gets === 0 && uses.tees === 0) {
|
|
1583
|
+
// `(local.set $x VALUE)` — drop the store with its value, but only when
|
|
1584
|
+
// VALUE is pure (its side effects would otherwise still need to run).
|
|
1585
|
+
if (sub.length === 3) {
|
|
1586
|
+
if (isPure(sub[2])) { funcNode.splice(i, 1); changed = true }
|
|
1587
|
+
}
|
|
1588
|
+
// Bare `(local.set $x)` — the value is implicit on the stack (e.g. an
|
|
1589
|
+
// exception payload landing from a `try_table` catch). Demote to `drop`
|
|
1590
|
+
// so the dead store goes away without unbalancing the stack.
|
|
1591
|
+
else if (sub.length === 2) {
|
|
1592
|
+
funcNode[i] = ['drop']; changed = true
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
// Unused local declaration
|
|
1596
|
+
else if (sub[0] === 'local' && name[0] === '$' && uses.gets === 0 && uses.sets === 0 && uses.tees === 0) {
|
|
1597
|
+
funcNode.splice(i, 1); changed = true
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
return changed
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
/**
|
|
1605
|
+
* Drop `(local.set $x A)` when the very next statement re-sets $x without reading it
|
|
1606
|
+
* first (A pure). The two writes are adjacent, so A's value is overwritten before any
|
|
1607
|
+
* observation — it's dead. The whole-function {@link eliminateDeadStores} misses this:
|
|
1608
|
+
* it only fires when $x is read NOWHERE, whereas here $x is live later, just not
|
|
1609
|
+
* between these two writes. Pairs with copy-propagation, which rewrites
|
|
1610
|
+
* `$x=$y; $x=f($x)` to `$x=$y; $x=f($y)` — an adjacent dead store this removes,
|
|
1611
|
+
* collapsing the round-trip jz's value-model lowering leaves behind.
|
|
1612
|
+
* @param {Array} funcNode a straight-line scope (body / block / loop / then / else)
|
|
1613
|
+
* @param {Set<string>} params
|
|
1614
|
+
*/
|
|
1615
|
+
const eliminateAdjacentDeadStores = (funcNode, params) => {
|
|
1616
|
+
let changed = false
|
|
1617
|
+
for (let i = 1; i < funcNode.length - 1; i++) {
|
|
1618
|
+
const a = funcNode[i], b = funcNode[i + 1]
|
|
1619
|
+
// `a` must be a plain set (a tee leaves its value on the stack — not removable);
|
|
1620
|
+
// `b` may be a set OR a tee (both overwrite the local before `a`'s value is read).
|
|
1621
|
+
if (!Array.isArray(a) || a[0] !== 'local.set' || a.length !== 3) continue
|
|
1622
|
+
if (!Array.isArray(b) || (b[0] !== 'local.set' && b[0] !== 'local.tee') || b.length !== 3 || b[1] !== a[1]) continue
|
|
1623
|
+
if (params.has(a[1]) || !isPure(a[2])) continue
|
|
1624
|
+
// Dead only if b's value doesn't read $x before overwriting it.
|
|
1625
|
+
let reads = false
|
|
1626
|
+
walk(b[2], n => { if (Array.isArray(n) && (n[0] === 'local.get' || n[0] === 'local.tee') && n[1] === a[1]) reads = true })
|
|
1627
|
+
if (reads) continue
|
|
1628
|
+
funcNode.splice(i, 1); changed = true; i--
|
|
1629
|
+
}
|
|
1630
|
+
return changed
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
/**
|
|
1634
|
+
* Propagate values through locals and eliminate single-use/dead locals.
|
|
1635
|
+
* Constants propagate to all uses; pure single-use exprs inline into get site.
|
|
1636
|
+
* Multi-pass with batch counting for convergence.
|
|
1637
|
+
*/
|
|
1638
|
+
/** Block-like nodes whose body is a straight-line instruction list (after any header). */
|
|
1639
|
+
const isScopeNode = (n) => Array.isArray(n) &&
|
|
1640
|
+
(n[0] === 'func' || n[0] === 'block' || n[0] === 'loop' || n[0] === 'then' || n[0] === 'else')
|
|
1641
|
+
|
|
1642
|
+
/** Branch-target scopes: ops that carry an optional label/result header and can be jumped to via br/br_if. */
|
|
1643
|
+
const isBranchScope = (op) => op === 'block' || op === 'loop' || op === 'if'
|
|
1644
|
+
|
|
1645
|
+
const propagate = (ast) => {
|
|
1646
|
+
walk(ast, (funcNode) => {
|
|
1647
|
+
if (!Array.isArray(funcNode) || funcNode[0] !== 'func') return
|
|
1648
|
+
|
|
1649
|
+
const params = new Set()
|
|
1650
|
+
for (const sub of funcNode)
|
|
1651
|
+
if (Array.isArray(sub) && sub[0] === 'param' && typeof sub[1] === 'string') params.add(sub[1])
|
|
1652
|
+
|
|
1653
|
+
// Propagation runs per straight-line scope: the function body and every nested
|
|
1654
|
+
// `block`/`loop`/`then`/`else` (including ones embedded in an expression, e.g. the
|
|
1655
|
+
// `(block (result i32) …)` an inlined call leaves behind). Collect scopes deepest-
|
|
1656
|
+
// first so inner simplifications shrink the use-counts the outer scopes see.
|
|
1657
|
+
// Use-counts are always whole-function — a set/get pair or dead store is only
|
|
1658
|
+
// touched when it's globally the sole occurrence, so per-scope work stays sound.
|
|
1659
|
+
const scopes = []
|
|
1660
|
+
walkPost(funcNode, n => { if (isScopeNode(n)) scopes.push(n) })
|
|
1661
|
+
|
|
1662
|
+
// One use-count per round, shared by every scope: substitutions only ever
|
|
1663
|
+
// *drop* gets, so a stale count can only make a sub-pass act more cautiously
|
|
1664
|
+
// (skip a not-yet-provably-dead store, decline a not-yet-provably-single use) —
|
|
1665
|
+
// never wrongly. The next round re-counts and mops up. (Recounting per sub-pass
|
|
1666
|
+
// per scope is O(scopes·funcSize) and crippling on big modules.)
|
|
1667
|
+
for (let round = 0; round < MAX_PROP_ROUNDS; round++) {
|
|
1668
|
+
const useCounts = countLocalUses(funcNode)
|
|
1669
|
+
let progressed = false
|
|
1670
|
+
for (const scope of scopes) {
|
|
1671
|
+
if (forwardPropagate(scope, params, useCounts)) progressed = true
|
|
1672
|
+
if (eliminateSetGetPairs(scope, params, useCounts)) progressed = true
|
|
1673
|
+
if (createLocalTees(scope, params, useCounts)) progressed = true
|
|
1674
|
+
if (eliminateDeadStores(scope, params, useCounts)) progressed = true
|
|
1675
|
+
if (eliminateAdjacentDeadStores(scope, params)) progressed = true
|
|
1676
|
+
}
|
|
1677
|
+
if (!progressed) break
|
|
1678
|
+
}
|
|
1679
|
+
})
|
|
1680
|
+
|
|
1681
|
+
return ast
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
// ==================== FUNCTION INLINING ====================
|
|
1685
|
+
|
|
1686
|
+
// Shared inliner primitives, used by BOTH passes below: `inline` (duplicates tiny
|
|
1687
|
+
// multi-caller bodies — size-for-speed) and `inlineOnce` (splices single-caller
|
|
1688
|
+
// bodies, never duplicates). The lift technique is identical — rename the callee's
|
|
1689
|
+
// params/locals/labels to fresh `$__inlN_*` names, evaluate args once into the
|
|
1690
|
+
// renamed param locals, turn `return X` into `br $__inlN X`, wrap the body in a
|
|
1691
|
+
// `(block $__inlN (result T)? …)`. Only the SELECTION policy differs (one caller vs
|
|
1692
|
+
// every caller of a small body), so the lift lives here once.
|
|
1693
|
+
|
|
1694
|
+
let inlineUid = 0
|
|
1695
|
+
const INL_HEAD = new Set(['export', 'type', 'param', 'result', 'local'])
|
|
1696
|
+
const inlBodyStart = (fn) => {
|
|
1697
|
+
let i = 2
|
|
1698
|
+
while (i < fn.length && (typeof fn[i] === 'string' || (Array.isArray(fn[i]) && INL_HEAD.has(fn[i][0])))) i++
|
|
1699
|
+
return i
|
|
1700
|
+
}
|
|
1701
|
+
const inlIsBranch = op => op === 'br' || op === 'br_if' || op === 'br_table'
|
|
1702
|
+
// A subtree we can't lift into a (block …): depth-relative branch labels (which would
|
|
1703
|
+
// shift under the added nesting) or tail calls (which would escape the wrapping block).
|
|
1704
|
+
const inlUnsafe = (n) => {
|
|
1705
|
+
if (!Array.isArray(n)) return false
|
|
1706
|
+
const op = n[0]
|
|
1707
|
+
if (op === 'return_call' || op === 'return_call_indirect' || op === 'return_call_ref') return true
|
|
1708
|
+
if (op === 'try' || op === 'try_table' || op === 'delegate' || op === 'rethrow') return true // exception labels — not handled by the relabeler below
|
|
1709
|
+
if (inlIsBranch(op)) for (let i = 1; i < n.length; i++) if (typeof n[i] === 'number' || (typeof n[i] === 'string' && /^\d+$/.test(n[i]))) return true
|
|
1710
|
+
for (let i = 1; i < n.length; i++) if (inlUnsafe(n[i])) return true
|
|
1711
|
+
return false
|
|
1712
|
+
}
|
|
1713
|
+
const inlCallsSelf = (n, name) => {
|
|
1714
|
+
if (!Array.isArray(n)) return false
|
|
1715
|
+
if ((n[0] === 'call' || n[0] === 'return_call') && n[1] === name) return true
|
|
1716
|
+
for (let i = 1; i < n.length; i++) if (inlCallsSelf(n[i], name)) return true
|
|
1717
|
+
return false
|
|
1718
|
+
}
|
|
1719
|
+
// Per-call zero-init constant for a callee local re-entered from a caller loop.
|
|
1720
|
+
// null ⇒ a type we can't safely zero here (skip inlining such a callee).
|
|
1721
|
+
const inlZeroFor = (t) => {
|
|
1722
|
+
if (t === 'i32') return ['i32.const', 0]
|
|
1723
|
+
if (t === 'i64') return ['i64.const', 0]
|
|
1724
|
+
if (t === 'f32') return ['f32.const', 0]
|
|
1725
|
+
if (t === 'f64') return ['f64.const', 0]
|
|
1726
|
+
if (t === 'v128') return ['v128.const', 'i64x2', '0', '0'] // STRING lanes — watr's v128 encoder calls .replaceAll
|
|
1727
|
+
return null
|
|
1728
|
+
}
|
|
1729
|
+
// A callee local needs a per-entry reset only if some path reads it before any
|
|
1730
|
+
// unconditional write (so it relied on the callee's fresh zero-init). Mirrors
|
|
1731
|
+
// coalesceLocals' readsZero heuristic; unconditionally-written scratch needs none.
|
|
1732
|
+
const inlNeedsReset = (body, name) => {
|
|
1733
|
+
let seen = false, conditional = false, depth = 0
|
|
1734
|
+
const visit = (n) => {
|
|
1735
|
+
if (seen || !Array.isArray(n)) return
|
|
1736
|
+
const op = n[0]
|
|
1737
|
+
const isSet = op === 'local.set' || op === 'local.tee'
|
|
1738
|
+
if ((isSet || op === 'local.get') && n[1] === name) {
|
|
1739
|
+
if (isSet) for (let i = 2; i < n.length && !seen; i++) visit(n[i])
|
|
1740
|
+
if (seen) return
|
|
1741
|
+
seen = true
|
|
1742
|
+
if (op === 'local.get' || depth > 0) conditional = true
|
|
1743
|
+
return
|
|
1744
|
+
}
|
|
1745
|
+
const isIf = op === 'if'
|
|
1746
|
+
for (let i = 1; i < n.length && !seen; i++) {
|
|
1747
|
+
const c = n[i]
|
|
1748
|
+
const cond = isIf && Array.isArray(c) && (c[0] === 'then' || c[0] === 'else')
|
|
1749
|
+
if (cond) depth++
|
|
1750
|
+
visit(c)
|
|
1751
|
+
if (cond) depth--
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
for (const n of body) { if (seen) break; visit(n) }
|
|
1755
|
+
if (!seen) return false
|
|
1756
|
+
return conditional
|
|
1757
|
+
}
|
|
1758
|
+
// Module-level references that pin a function (can't be inlined-away/removed).
|
|
1759
|
+
const inlCollectPinned = (n, pinned) => {
|
|
1760
|
+
if (!Array.isArray(n)) return
|
|
1761
|
+
const op = n[0]
|
|
1762
|
+
if (op === 'export' && Array.isArray(n[2]) && n[2][0] === 'func' && typeof n[2][1] === 'string') pinned.add(n[2][1])
|
|
1763
|
+
else if (op === 'start' && typeof n[1] === 'string') pinned.add(n[1])
|
|
1764
|
+
else if (op === 'ref.func' && typeof n[1] === 'string') pinned.add(n[1])
|
|
1765
|
+
else if (op === 'elem') for (const c of n) if (typeof c === 'string' && c[0] === '$') pinned.add(c)
|
|
1766
|
+
for (const c of n) inlCollectPinned(c, pinned)
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
// Parse a func node into { params, locals, inlResult } once, enforcing the
|
|
1770
|
+
// liftability contract (named params/locals, zero-init-able local types, ≤1
|
|
1771
|
+
// result, no inline export). Returns null if the func can't be lifted.
|
|
1772
|
+
const inlParse = (fn) => {
|
|
1773
|
+
const params = [], locals = []
|
|
1774
|
+
let inlResult = null, ok = true, nResult = 0
|
|
1775
|
+
for (let i = 2; i < fn.length; i++) {
|
|
1776
|
+
const c = fn[i]
|
|
1777
|
+
if (typeof c === 'string') continue
|
|
1778
|
+
if (!Array.isArray(c)) { ok = false; break }
|
|
1779
|
+
if (c[0] === 'param') { if (typeof c[1] !== 'string' || c[1][0] !== '$') { ok = false; break } params.push({ name: c[1], type: c[2] }) }
|
|
1780
|
+
else if (c[0] === 'local') { if (typeof c[1] !== 'string' || c[1][0] !== '$' || !inlZeroFor(c[2])) { ok = false; break } locals.push({ name: c[1], type: c[2] }) }
|
|
1781
|
+
else if (c[0] === 'result') { nResult += c.length - 1; if (c.length > 1) inlResult = c[1] }
|
|
1782
|
+
else if (c[0] === 'export') { ok = false; break }
|
|
1783
|
+
else if (c[0] === 'type') continue
|
|
1784
|
+
else break
|
|
1785
|
+
}
|
|
1786
|
+
if (nResult > 1) ok = false
|
|
1787
|
+
return ok ? { params, locals, inlResult } : null
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
// IR-node count of a callee body — the cheap size proxy gating multi-caller inline.
|
|
1791
|
+
const inlBodySize = (fn) => {
|
|
1792
|
+
let n = 0
|
|
1793
|
+
const count = (x) => { if (!Array.isArray(x)) return; n++; for (let i = 1; i < x.length; i++) count(x[i]) }
|
|
1794
|
+
for (let i = inlBodyStart(fn); i < fn.length; i++) count(fn[i])
|
|
1795
|
+
return n
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
/**
|
|
1799
|
+
* Lift one callee into ONE `(call …)` node. Returns `{ block, decls }` — `block`
|
|
1800
|
+
* replaces the call; `decls` are the renamed param+local declarations to splice into
|
|
1801
|
+
* the caller's local list. A fresh uid per invocation keeps every inlined copy's
|
|
1802
|
+
* locals/labels unique, so the same body can be lifted into many sites.
|
|
1803
|
+
*
|
|
1804
|
+
* (call $f a0 a1 …) → (block $__inlN (result T)?
|
|
1805
|
+
* (local.set $__inlN_p0 a0) … ;; args evaluated once, in order
|
|
1806
|
+
* (local.set $__inlN_l reset) … ;; only locals that rely on zero-init
|
|
1807
|
+
* …body, renamed, `return X` → `br $__inlN X`…)
|
|
1808
|
+
*/
|
|
1809
|
+
const buildInline = (params, locals, inlResult, cBody, args) => {
|
|
1810
|
+
const uid = ++inlineUid
|
|
1811
|
+
const exit = `$__inl${uid}`
|
|
1812
|
+
const rename = new Map()
|
|
1813
|
+
for (const p of params) rename.set(p.name, `$__inl${uid}_${p.name.slice(1)}`)
|
|
1814
|
+
for (const l of locals) rename.set(l.name, `$__inl${uid}_${l.name.slice(1)}`)
|
|
1815
|
+
// The callee's own block/loop/if labels would shadow same-named caller labels (and
|
|
1816
|
+
// break depth resolution) under the added nesting — give them fresh names too.
|
|
1817
|
+
const labelRename = new Map()
|
|
1818
|
+
const collectLabels = (n) => {
|
|
1819
|
+
if (!Array.isArray(n)) return
|
|
1820
|
+
if (isBranchScope(n[0]) && typeof n[1] === 'string' && n[1][0] === '$' && !labelRename.has(n[1]))
|
|
1821
|
+
labelRename.set(n[1], `$__inl${uid}L_${n[1].slice(1)}`)
|
|
1822
|
+
for (let i = 1; i < n.length; i++) collectLabels(n[i])
|
|
1823
|
+
}
|
|
1824
|
+
for (const n of cBody) collectLabels(n)
|
|
1825
|
+
const sub = (n) => {
|
|
1826
|
+
if (!Array.isArray(n)) return n
|
|
1827
|
+
const op = n[0]
|
|
1828
|
+
if ((op === 'local.get' || op === 'local.set' || op === 'local.tee') && typeof n[1] === 'string' && rename.has(n[1]))
|
|
1829
|
+
return [op, rename.get(n[1]), ...n.slice(2).map(sub)]
|
|
1830
|
+
if (op === 'return') return ['br', exit, ...n.slice(1).map(sub)]
|
|
1831
|
+
if (isBranchScope(op) && typeof n[1] === 'string' && labelRename.has(n[1]))
|
|
1832
|
+
return [op, labelRename.get(n[1]), ...n.slice(2).map(sub)]
|
|
1833
|
+
if (inlIsBranch(op)) return [op, ...n.slice(1).map(c => (typeof c === 'string' && labelRename.has(c)) ? labelRename.get(c) : sub(c))]
|
|
1834
|
+
return n.map((c, i) => i === 0 ? c : sub(c))
|
|
1835
|
+
}
|
|
1836
|
+
const setup = params.map((p, k) => ['local.set', rename.get(p.name), args[k]])
|
|
1837
|
+
const resets = locals.filter(l => inlNeedsReset(cBody, l.name)).map(l => ['local.set', rename.get(l.name), inlZeroFor(l.type)])
|
|
1838
|
+
const inner = cBody.map(sub)
|
|
1839
|
+
const block = inlResult
|
|
1840
|
+
? ['block', exit, ['result', inlResult], ...setup, ...resets, ...inner]
|
|
1841
|
+
: ['block', exit, ...setup, ...resets, ...inner]
|
|
1842
|
+
const decls = [...params, ...locals].map(p => ['local', rename.get(p.name), p.type])
|
|
1843
|
+
return { block, decls }
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
/**
|
|
1847
|
+
* Inline SMALL functions into every caller, then delete them — the multi-caller
|
|
1848
|
+
* complement to {@link inlineOnce}. inlineOnce only fires for a lone caller (so it
|
|
1849
|
+
* never duplicates); this duplicates a tiny body across ALL its sites, trading a
|
|
1850
|
+
* bounded amount of size to remove call overhead from hot inner loops (e.g. a
|
|
1851
|
+
* raymarcher's per-step SDF, evaluated 4-wide but still paying a wasm call each
|
|
1852
|
+
* march step). Size-for-speed — opt-in, on at the 'speed' level only.
|
|
1853
|
+
*
|
|
1854
|
+
* A callee qualifies when it is small (≤ INLINE_MAX_NODES IR nodes), named with
|
|
1855
|
+
* named params/locals, single-result-or-void, non-recursive, not pinned
|
|
1856
|
+
* (export/start/elem/ref.func), not a SIMD_PROTECTED transcendental, and free of
|
|
1857
|
+
* depth-relative branches / tail calls (the inlParse + inlUnsafe liftability
|
|
1858
|
+
* contract). Runs to a fixpoint so small-helper chains (sdf → sdRep) collapse.
|
|
1859
|
+
*
|
|
1860
|
+
* `simdOnly` (the speed-level default) restricts inlining to pure SIMD helpers —
|
|
1861
|
+
* every param and the result are `v128`. That targets the case this exists for —
|
|
1862
|
+
* a hand-vectorized hot loop's per-step helper (a raymarcher's SDF), where the call
|
|
1863
|
+
* overhead is paid every iteration and V8's wasm JIT won't inline it — while leaving
|
|
1864
|
+
* SCALAR helpers untouched: those are where jz's codegen-shape/size tuning and the
|
|
1865
|
+
* auto-vectorizer's call-lifting (plasma's fbm → sin2) live, and duplicating them
|
|
1866
|
+
* both bloats and perturbs that machinery for no gain (V8's JIT inlines scalar
|
|
1867
|
+
* helpers itself). The unrestricted form stays available as `watr: { inline: true }`.
|
|
1868
|
+
*
|
|
1869
|
+
* @param {Array} ast
|
|
1870
|
+
* @param {{simdOnly?: boolean}} [opts]
|
|
1871
|
+
* @returns {Array}
|
|
1872
|
+
*/
|
|
1873
|
+
const INLINE_MAX_NODES = 90
|
|
1874
|
+
const isV128SimdHelper = (params, inlResult) =>
|
|
1875
|
+
inlResult === 'v128' && params.length > 0 && params.every(p => p.type === 'v128')
|
|
1876
|
+
const inline = (ast, { simdOnly = false } = {}) => {
|
|
1877
|
+
if (!Array.isArray(ast) || ast[0] !== 'module') return ast
|
|
1878
|
+
|
|
1879
|
+
const skip = new Set() // callees with a non-inlinable site (arity mismatch) — don't re-pick
|
|
1880
|
+
for (let round = 0; round < MAX_INLINE_ROUNDS; round++) {
|
|
1881
|
+
const funcs = ast.filter(n => Array.isArray(n) && n[0] === 'func')
|
|
1882
|
+
const funcByName = new Map()
|
|
1883
|
+
for (const n of funcs) if (typeof n[1] === 'string') funcByName.set(n[1], n)
|
|
1884
|
+
|
|
1885
|
+
const callRefs = new Map(), otherRef = new Set()
|
|
1886
|
+
const countRefs = (n) => {
|
|
1887
|
+
if (!Array.isArray(n)) return
|
|
1888
|
+
const op = n[0]
|
|
1889
|
+
if (op === 'call' && typeof n[1] === 'string') callRefs.set(n[1], (callRefs.get(n[1]) || 0) + 1)
|
|
1890
|
+
else if (op === 'return_call' && typeof n[1] === 'string') otherRef.add(n[1])
|
|
1891
|
+
for (let i = 1; i < n.length; i++) countRefs(n[i])
|
|
1892
|
+
}
|
|
1893
|
+
countRefs(ast)
|
|
1894
|
+
const pinned = new Set()
|
|
1895
|
+
for (const n of ast) if (!Array.isArray(n) || n[0] !== 'func') inlCollectPinned(n, pinned)
|
|
1896
|
+
|
|
1897
|
+
// Pick a small, liftable, non-recursive callee with ≥1 plain-call site.
|
|
1898
|
+
let calleeName = null, parsed = null
|
|
1899
|
+
for (const [name, fn] of funcByName) {
|
|
1900
|
+
if (skip.has(name) || pinned.has(name) || otherRef.has(name) || SIMD_PROTECTED.has(name)) continue
|
|
1901
|
+
if (!(callRefs.get(name) >= 1)) continue
|
|
1902
|
+
if (inlBodySize(fn) > INLINE_MAX_NODES) continue
|
|
1903
|
+
if (inlCallsSelf(fn, name)) continue
|
|
1904
|
+
const p = inlParse(fn)
|
|
1905
|
+
if (!p) continue
|
|
1906
|
+
if (simdOnly && !isV128SimdHelper(p.params, p.inlResult)) continue
|
|
1907
|
+
let bad = false
|
|
1908
|
+
for (let i = inlBodyStart(fn); i < fn.length; i++) if (inlUnsafe(fn[i])) { bad = true; break }
|
|
1909
|
+
if (bad) continue
|
|
1910
|
+
calleeName = name; parsed = p; break
|
|
1911
|
+
}
|
|
1912
|
+
if (!calleeName) break
|
|
1913
|
+
|
|
1914
|
+
const callee = funcByName.get(calleeName)
|
|
1915
|
+
const { params, locals, inlResult } = parsed
|
|
1916
|
+
const cBody = callee.slice(inlBodyStart(callee))
|
|
1917
|
+
const expected = callRefs.get(calleeName) || 0 // callee is non-recursive ⇒ all sites are in other funcs
|
|
1918
|
+
let replaced = 0
|
|
1919
|
+
|
|
1920
|
+
// Splice into EVERY caller. A body that itself still calls an as-yet-uninlined
|
|
1921
|
+
// helper is fine — later rounds collapse it (or it stays a call).
|
|
1922
|
+
for (const fn of funcs) {
|
|
1923
|
+
if (fn === callee) continue
|
|
1924
|
+
const addDecls = []
|
|
1925
|
+
for (let i = inlBodyStart(fn); i < fn.length; i++) {
|
|
1926
|
+
fn[i] = walkPost(fn[i], (n) => {
|
|
1927
|
+
if (!Array.isArray(n) || n[0] !== 'call' || n[1] !== calleeName) return
|
|
1928
|
+
const args = n.slice(2)
|
|
1929
|
+
if (args.length !== params.length) return // arity mismatch — leave the call
|
|
1930
|
+
const { block, decls } = buildInline(params, locals, inlResult, cBody, args)
|
|
1931
|
+
addDecls.push(...decls)
|
|
1932
|
+
replaced++
|
|
1933
|
+
return block
|
|
1934
|
+
})
|
|
1935
|
+
}
|
|
1936
|
+
if (addDecls.length) fn.splice(inlBodyStart(fn), 0, ...addDecls)
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
// Drop the callee only if every site inlined; else keep it and stop re-picking it.
|
|
1940
|
+
if (replaced === expected) { const idx = ast.indexOf(callee); if (idx >= 0) ast.splice(idx, 1) }
|
|
1941
|
+
else skip.add(calleeName)
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
return ast
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
// ==================== INLINE-ONCE ====================
|
|
1948
|
+
|
|
1949
|
+
/**
|
|
1950
|
+
* Devirtualize `call_indirect` through NaN-boxed closure values with a statically
|
|
1951
|
+
* known candidate set. `let f = c ? a : b; … f(x)` emits a select of two i64
|
|
1952
|
+
* closure constants into an f64 local; every call site then derives the table
|
|
1953
|
+
* slot from that local's bits:
|
|
1954
|
+
* (i32.wrap_i64 (i64.and (i64.shr_u (i64.reinterpret_f64 (local.get $f))
|
|
1955
|
+
* (i64.const 32)) (i64.const 32767)))
|
|
1956
|
+
* When EVERY write to $f in the function is such a constant set (≤2 candidates),
|
|
1957
|
+
* each call site becomes a guarded direct call —
|
|
1958
|
+
* (if (result …) (i64.eq (i64.reinterpret_f64 (local.get $f)) (i64.const C1))
|
|
1959
|
+
* (then (call $tramp1 …args)) (else <next guard | original call_indirect>))
|
|
1960
|
+
* — with the ORIGINAL call_indirect kept as the final arm, so unknown flows
|
|
1961
|
+
* (zero-init paths the analysis can't see) behave exactly as before: the rewrite
|
|
1962
|
+
* is a pure branch-predicted fast path, ~25% on callback loops, and the direct
|
|
1963
|
+
* calls participate in inlining. A trivially-constant slot ((i32.const N) after
|
|
1964
|
+
* fold) becomes a bare direct call with no guard.
|
|
1965
|
+
*
|
|
1966
|
+
* Soundness: the guard compares the SAME bits the slot extraction reads, so
|
|
1967
|
+
* whichever constant flows to the call dispatches identically in both forms;
|
|
1968
|
+
* candidates that don't resolve to an elem entry (or whose target's signature
|
|
1969
|
+
* differs from the call's type — would-be runtime trap) disable the site. Any
|
|
1970
|
+
* table mutation op in the module disables the pass entirely. The function
|
|
1971
|
+
* table is exported for host-side closure invocation (reads); host mutation of
|
|
1972
|
+
* it is outside the ABI contract, same as the closure-constant model itself.
|
|
1973
|
+
*/
|
|
1974
|
+
const devirt = (ast) => {
|
|
1975
|
+
if (!Array.isArray(ast) || ast[0] !== 'module') return ast
|
|
1976
|
+
// Module facts: elem slot → func name (constant offsets only), type defs,
|
|
1977
|
+
// named funcs. Bail on dynamic elem offsets or table mutation anywhere.
|
|
1978
|
+
const slots = new Map(), typeDefs = new Map(), funcsByName = new Map()
|
|
1979
|
+
let tableMutated = false
|
|
1980
|
+
walk(ast, n => {
|
|
1981
|
+
if (Array.isArray(n) && typeof n[0] === 'string' &&
|
|
1982
|
+
(n[0] === 'table.set' || n[0] === 'table.grow' || n[0] === 'table.init' ||
|
|
1983
|
+
n[0] === 'table.copy' || n[0] === 'table.fill')) tableMutated = true
|
|
1984
|
+
})
|
|
1985
|
+
if (tableMutated) return ast
|
|
1986
|
+
for (const node of ast.slice(1)) {
|
|
1987
|
+
if (!Array.isArray(node)) continue
|
|
1988
|
+
if (node[0] === 'elem') {
|
|
1989
|
+
const off = node[1]
|
|
1990
|
+
if (!Array.isArray(off) || off[0] !== 'i32.const') return ast
|
|
1991
|
+
let base = Number(off[1])
|
|
1992
|
+
for (let i = 2; i < node.length; i++)
|
|
1993
|
+
if (typeof node[i] === 'string' && node[i][0] === '$') slots.set(base++, node[i])
|
|
1994
|
+
}
|
|
1995
|
+
else if (node[0] === 'type' && typeof node[1] === 'string') typeDefs.set(node[1], node[2])
|
|
1996
|
+
else if (node[0] === 'func' && typeof node[1] === 'string') funcsByName.set(node[1], node)
|
|
1997
|
+
}
|
|
1998
|
+
if (!slots.size) return ast
|
|
1999
|
+
|
|
2000
|
+
// Closure-valued GLOBALS: multiProp function-property slots dissolve into
|
|
2001
|
+
// f64 module globals (plan/scope.js flattenFuncNamespaces) — the subscript
|
|
2002
|
+
// hook pattern (`parse.space = fn`, overridden per feature at module init).
|
|
2003
|
+
// Every observed `global.set $G <closure-const>` contributes a candidate;
|
|
2004
|
+
// a non-const store poisons the global. The guard ladder stays SOUND even
|
|
2005
|
+
// with an incomplete set — unknown values take the original call_indirect
|
|
2006
|
+
// fallback arm — so candidates only need to cover the hot value.
|
|
2007
|
+
const globalCands = new Map()
|
|
2008
|
+
|
|
2009
|
+
// All i64 const handling is canonical-hex STRING math (see the i64 VALUE
|
|
2010
|
+
// CONTRACT above): a helper RETURNING a BigInt is kind-erased in-kernel and
|
|
2011
|
+
// every op on it misdispatches — devirt silently no-ops.
|
|
2012
|
+
const isC64 = (n, hex) => Array.isArray(n) && n[0] === 'i64.const' && _i64Canon(n[1]) === hex
|
|
2013
|
+
const MASK15 = '0x0000000000007fff', SHIFT32 = '0x0000000000000020'
|
|
2014
|
+
// Collect the i64 constants reachable through reinterpret/select arms.
|
|
2015
|
+
const boxConsts = (v, out) => {
|
|
2016
|
+
if (!Array.isArray(v)) return false
|
|
2017
|
+
if (v[0] === 'i64.const') { out.push(v); return true }
|
|
2018
|
+
// f64-carrier closure const (`f64.const nan:0xHEX`) — the form module-init
|
|
2019
|
+
// global.set stores for hook slots; normalize to its i64 bits.
|
|
2020
|
+
if (v[0] === 'f64.const' && typeof v[1] === 'string' && v[1].startsWith('nan:')) {
|
|
2021
|
+
out.push(['i64.const', _i64Canon(v[1].slice(4))])
|
|
2022
|
+
return true
|
|
2023
|
+
}
|
|
2024
|
+
if (v[0] === 'f64.reinterpret_i64' && v.length === 2) return boxConsts(v[1], out)
|
|
2025
|
+
if (v[0] === 'select' && v.length === 4) return boxConsts(v[1], out) && boxConsts(v[2], out)
|
|
2026
|
+
return false
|
|
2027
|
+
}
|
|
2028
|
+
// Per-global write VALUES collected first; candidates resolved by fixpoint so
|
|
2029
|
+
// the hook-alias pattern works: `baseSpace = parse.space ?? default` stores a
|
|
2030
|
+
// select/if whose arms are a GLOBAL READ of another const slot plus a const —
|
|
2031
|
+
// candidates = union through the alias edge. Soundness is unchanged (the
|
|
2032
|
+
// guard ladder keeps the original indirect fallback for unknown values); the
|
|
2033
|
+
// fixpoint only widens the candidate set. `if (result f64)` arms and
|
|
2034
|
+
// `__is_nullish`-style guard CONDITIONS are skipped — only VALUE positions
|
|
2035
|
+
// contribute. A write that contains anything else poisons the global.
|
|
2036
|
+
const globalWrites = new Map()
|
|
2037
|
+
walk(ast, n => {
|
|
2038
|
+
if (!Array.isArray(n) || n[0] !== 'global.set' || typeof n[1] !== 'string') return
|
|
2039
|
+
if (!globalWrites.has(n[1])) globalWrites.set(n[1], [])
|
|
2040
|
+
globalWrites.get(n[1]).push(n[2])
|
|
2041
|
+
})
|
|
2042
|
+
// Value-position scan: consts and global.get leaves, through reinterprets,
|
|
2043
|
+
// select arms and if/result arms. Returns false (poison) on anything else.
|
|
2044
|
+
const candLeaves = (v, consts, reads) => {
|
|
2045
|
+
if (!Array.isArray(v)) return false
|
|
2046
|
+
if (v[0] === 'i64.const') { consts.push(v); return true }
|
|
2047
|
+
if (v[0] === 'f64.const' && typeof v[1] === 'string' && v[1].startsWith('nan:')) {
|
|
2048
|
+
consts.push(['i64.const', _i64Canon(v[1].slice(4))]); return true
|
|
2049
|
+
}
|
|
2050
|
+
if ((v[0] === 'f64.reinterpret_i64' || v[0] === 'i64.reinterpret_f64') && v.length === 2)
|
|
2051
|
+
return candLeaves(v[1], consts, reads)
|
|
2052
|
+
if (v[0] === 'global.get' && typeof v[1] === 'string') { reads.push(v[1]); return true }
|
|
2053
|
+
if (v[0] === 'local.get' || v[0] === 'local.tee') {
|
|
2054
|
+
// a tee'd copy of one of the above — the tee VALUE was already scanned
|
|
2055
|
+
// where it was written; the bare read alone proves nothing → poison
|
|
2056
|
+
return v[0] === 'local.tee' && v.length === 3 ? candLeaves(v[2], consts, reads) : false
|
|
2057
|
+
}
|
|
2058
|
+
if (v[0] === 'select' && v.length === 4)
|
|
2059
|
+
return candLeaves(v[1], consts, reads) && candLeaves(v[2], consts, reads)
|
|
2060
|
+
if (v[0] === 'if') {
|
|
2061
|
+
// (if (result T) COND (then A) (else B)) — arms are value positions
|
|
2062
|
+
let ok = true, seenArm = false
|
|
2063
|
+
for (let i = 1; i < v.length; i++) {
|
|
2064
|
+
const p = v[i]
|
|
2065
|
+
if (!Array.isArray(p)) continue
|
|
2066
|
+
if (p[0] === 'then' || p[0] === 'else') {
|
|
2067
|
+
seenArm = true
|
|
2068
|
+
if (p.length !== 2 || !candLeaves(p[1], consts, reads)) ok = false
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
return ok && seenArm
|
|
2072
|
+
}
|
|
2073
|
+
return false
|
|
2074
|
+
}
|
|
2075
|
+
const writeFacts = new Map() // global → { consts: [...], reads: [...] } | null
|
|
2076
|
+
for (const [g, ws] of globalWrites) {
|
|
2077
|
+
let consts = [], reads = [], ok = true
|
|
2078
|
+
for (const w of ws) if (!candLeaves(w, consts, reads)) { ok = false; break }
|
|
2079
|
+
writeFacts.set(g, ok ? { consts, reads } : null)
|
|
2080
|
+
}
|
|
2081
|
+
// Fixpoint: a global's candidates = its const writes ∪ candidates of every
|
|
2082
|
+
// global it reads in value position. A poisoned alias poisons the reader.
|
|
2083
|
+
let changed = true
|
|
2084
|
+
const resolved = new Map()
|
|
2085
|
+
while (changed) {
|
|
2086
|
+
changed = false
|
|
2087
|
+
for (const [g, f] of writeFacts) {
|
|
2088
|
+
if (resolved.get(g) === null) continue
|
|
2089
|
+
if (f === null) { if (resolved.get(g) !== null) { resolved.set(g, null); changed = true } continue }
|
|
2090
|
+
const m = resolved.get(g) || new Map()
|
|
2091
|
+
const before = m.size
|
|
2092
|
+
let poisoned = false
|
|
2093
|
+
for (const c of f.consts) m.set(_i64Canon(c[1]), c)
|
|
2094
|
+
for (const r of f.reads) {
|
|
2095
|
+
if (writeFacts.get(r) === null || resolved.get(r) === null) { poisoned = true; break }
|
|
2096
|
+
const rm = resolved.get(r)
|
|
2097
|
+
if (rm) for (const [hex, c] of rm) m.set(hex, c)
|
|
2098
|
+
}
|
|
2099
|
+
if (poisoned) { resolved.set(g, null); changed = true; continue }
|
|
2100
|
+
if (!resolved.has(g) || m.size !== before) { resolved.set(g, m); changed = true }
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
for (const [g, m] of resolved) globalCands.set(g, m)
|
|
2104
|
+
|
|
2105
|
+
// The slot-extraction idiom — returns the source local name or null.
|
|
2106
|
+
const matchSlotOfLocal = (e) => {
|
|
2107
|
+
if (!Array.isArray(e) || e[0] !== 'i32.wrap_i64') return null
|
|
2108
|
+
const a = e[1]
|
|
2109
|
+
if (!Array.isArray(a) || a[0] !== 'i64.and') return null
|
|
2110
|
+
let sh = a[1], mk = a[2]
|
|
2111
|
+
if (!isC64(mk, MASK15)) { sh = a[2]; mk = a[1] }
|
|
2112
|
+
if (!isC64(mk, MASK15) || !Array.isArray(sh) || sh[0] !== 'i64.shr_u' || !isC64(sh[2], SHIFT32)) return null
|
|
2113
|
+
const ri = sh[1]
|
|
2114
|
+
if (!Array.isArray(ri) || ri[0] !== 'i64.reinterpret_f64') return null
|
|
2115
|
+
const leaf = ri[1]
|
|
2116
|
+
if (Array.isArray(leaf) && leaf[0] === 'local.get' && typeof leaf[1] === 'string') return { local: leaf[1] }
|
|
2117
|
+
if (Array.isArray(leaf) && leaf[0] === 'global.get' && typeof leaf[1] === 'string') return { global: leaf[1] }
|
|
2118
|
+
return null
|
|
2119
|
+
}
|
|
2120
|
+
// Canonical "params -> results" token string for signature comparison.
|
|
2121
|
+
const tokSig = (parts) => {
|
|
2122
|
+
const ps = [], rs = []
|
|
2123
|
+
for (const p of parts) {
|
|
2124
|
+
if (!Array.isArray(p)) continue
|
|
2125
|
+
if (p[0] === 'param') { for (const t of p.slice(1)) if (typeof t === 'string' && t[0] !== '$') ps.push(t) }
|
|
2126
|
+
else if (p[0] === 'result') rs.push(...p.slice(1))
|
|
2127
|
+
}
|
|
2128
|
+
return ps.join(',') + '->' + rs.join(',')
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
for (const fn of funcsByName.values()) {
|
|
2132
|
+
// Candidate sets: local → Map<bits, constNode>, or null once poisoned.
|
|
2133
|
+
// Params are poisoned (incoming value unknown).
|
|
2134
|
+
const cands = new Map()
|
|
2135
|
+
for (const part of fn)
|
|
2136
|
+
if (Array.isArray(part) && part[0] === 'param' && typeof part[1] === 'string') cands.set(part[1], null)
|
|
2137
|
+
walk(fn, n => {
|
|
2138
|
+
if (!Array.isArray(n) || (n[0] !== 'local.set' && n[0] !== 'local.tee') || typeof n[1] !== 'string') return
|
|
2139
|
+
if (cands.get(n[1]) === null) return
|
|
2140
|
+
const out = []
|
|
2141
|
+
if (boxConsts(n[2], out)) {
|
|
2142
|
+
const m = cands.get(n[1]) || new Map()
|
|
2143
|
+
for (const c of out) m.set(_i64Canon(c[1]), c)
|
|
2144
|
+
cands.set(n[1], m)
|
|
2145
|
+
} else if (Array.isArray(n[2]) && n[2][0] === 'global.get' && typeof n[2][1] === 'string'
|
|
2146
|
+
&& globalCands.get(n[2][1])) {
|
|
2147
|
+
// promoteGlobals snapshot (`$_pg = global.get $G`) — inherit G's set
|
|
2148
|
+
const g = globalCands.get(n[2][1])
|
|
2149
|
+
const m = cands.get(n[1]) || new Map()
|
|
2150
|
+
for (const [hex, c] of g) m.set(hex, c)
|
|
2151
|
+
cands.set(n[1], m)
|
|
2152
|
+
} else cands.set(n[1], null)
|
|
2153
|
+
})
|
|
2154
|
+
|
|
2155
|
+
walkPost(fn, (n, parent) => {
|
|
2156
|
+
if (!Array.isArray(n) || n[0] !== 'call_indirect') return
|
|
2157
|
+
// A call_indirect sitting directly under an `else` is (or looks exactly
|
|
2158
|
+
// like) the fallback arm of an existing guard — never re-wrap it, so the
|
|
2159
|
+
// pass is idempotent across repeated optimize() runs.
|
|
2160
|
+
if (parent && parent[0] === 'else') return
|
|
2161
|
+
const typeUse = Array.isArray(n[1]) && n[1][0] === 'type' ? n[1] : null
|
|
2162
|
+
if (!typeUse) return
|
|
2163
|
+
const sig = typeDefs.get(typeUse[1])
|
|
2164
|
+
const callSig = Array.isArray(sig) ? tokSig(sig.slice(1)) : null
|
|
2165
|
+
if (callSig == null) return
|
|
2166
|
+
const results = []
|
|
2167
|
+
for (const s of sig.slice(1)) if (Array.isArray(s) && s[0] === 'result') results.push(...s.slice(1))
|
|
2168
|
+
const args = n.slice(2, -1)
|
|
2169
|
+
const idx = n[n.length - 1]
|
|
2170
|
+
const sigOk = (name) => {
|
|
2171
|
+
const target = funcsByName.get(name)
|
|
2172
|
+
if (!target) return false
|
|
2173
|
+
const tu = target.find(p => Array.isArray(p) && p[0] === 'type')
|
|
2174
|
+
if (tu) return tu[1] === typeUse[1] ||
|
|
2175
|
+
(typeDefs.get(tu[1]) && tokSig(typeDefs.get(tu[1]).slice(1)) === callSig)
|
|
2176
|
+
return tokSig(target.slice(2)) === callSig
|
|
2177
|
+
}
|
|
2178
|
+
// Constant slot → bare direct call.
|
|
2179
|
+
if (Array.isArray(idx) && idx[0] === 'i32.const') {
|
|
2180
|
+
const name = slots.get(Number(idx[1]))
|
|
2181
|
+
return name && sigOk(name) ? ['call', name, ...args] : undefined
|
|
2182
|
+
}
|
|
2183
|
+
const f = matchSlotOfLocal(idx)
|
|
2184
|
+
if (!f) return
|
|
2185
|
+
const m = f.local != null ? cands.get(f.local) : globalCands.get(f.global)
|
|
2186
|
+
if (!m || m.size === 0 || m.size > 4) return
|
|
2187
|
+
const arms = []
|
|
2188
|
+
for (const cNode of m.values()) {
|
|
2189
|
+
const name = slots.get(_i64HiU(_i64Canon(cNode[1])) & 32767)
|
|
2190
|
+
if (!name || !sigOk(name)) return
|
|
2191
|
+
arms.push([cNode, name])
|
|
2192
|
+
}
|
|
2193
|
+
const readBack = f.local != null ? ['local.get', f.local] : ['global.get', f.global]
|
|
2194
|
+
let out = n
|
|
2195
|
+
for (let i = arms.length - 1; i >= 0; i--) {
|
|
2196
|
+
const [cNode, name] = arms[i]
|
|
2197
|
+
out = ['if', ...(results.length ? [['result', ...results]] : []),
|
|
2198
|
+
['i64.eq', ['i64.reinterpret_f64', clone(readBack)], clone(cNode)],
|
|
2199
|
+
['then', ['call', name, ...args.map(clone)]],
|
|
2200
|
+
['else', out]]
|
|
2201
|
+
}
|
|
2202
|
+
return out
|
|
2203
|
+
})
|
|
2204
|
+
}
|
|
2205
|
+
return ast
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
/**
|
|
2209
|
+
* Inline functions that are called from exactly one place into their lone caller,
|
|
2210
|
+
* then delete them. Unlike {@link inline} (which duplicates tiny stateless bodies),
|
|
2211
|
+
* this never duplicates code and never inflates: each inlined function drops a
|
|
2212
|
+
* function-section entry, a type-section entry (if now unused), and a `call`
|
|
2213
|
+
* instruction, paying back only a `block`/`local.set` wrapper. This is what
|
|
2214
|
+
* `wasm-opt -Oz` does — collapsing helper chains down to a couple of functions —
|
|
2215
|
+
* and it's the bulk of the gap between hand-tuned WASM and naive codegen.
|
|
2216
|
+
*
|
|
2217
|
+
* A function `$f` qualifies when it is, all of:
|
|
2218
|
+
* • named, with named params and locals (numeric indices can't be safely renamed);
|
|
2219
|
+
* • referenced exactly once across the whole module, by a plain `call` (no
|
|
2220
|
+
* `return_call`, `ref.func`, `elem`, `export`, or `start` reference, and not
|
|
2221
|
+
* recursive);
|
|
2222
|
+
* • single-result or void (a multi-value result can't be modeled as `(block (result …))`);
|
|
2223
|
+
* • free of numeric (depth-relative) branch labels — those would shift under the
|
|
2224
|
+
* extra block nesting — and of `return_call*` in its body.
|
|
2225
|
+
*
|
|
2226
|
+
* `(call $f a0 a1 …)` becomes
|
|
2227
|
+
* (block $__inlN (result T)?
|
|
2228
|
+
* (local.set $__inlN_p0 a0) (local.set $__inlN_p1 a1) … ;; args evaluated once, in order
|
|
2229
|
+
* …body, params/locals renamed to $__inlN_*, `return X` → `br $__inlN X`…)
|
|
2230
|
+
* and the renamed params+locals are appended to the caller's `local` decls; the
|
|
2231
|
+
* body's own block/loop/if labels are renamed too so they can't shadow the caller's.
|
|
2232
|
+
* Runs to a fixpoint so helper chains fully collapse.
|
|
2233
|
+
*
|
|
2234
|
+
* @param {Array} ast
|
|
2235
|
+
* @returns {Array}
|
|
2236
|
+
*/
|
|
2237
|
+
// Scalar transcendental helpers the auto-vectorizer rewrites to f64x2 mirrors (PPC_CALL2 in
|
|
2238
|
+
// src/optimize/vectorize.js). inlineOnce must NOT dissolve their call nodes when single-caller —
|
|
2239
|
+
// the post-phase lift needs the call to rewrite it. Keep in sync with PPC_CALL2's keys.
|
|
2240
|
+
const SIMD_PROTECTED = new Set(['$math.sin_core', '$math.cos_core', '$math.sin', '$math.cos', '$math.pow', '$math.atan2', '$math.hypot', '$math.log'])
|
|
2241
|
+
|
|
2242
|
+
const inlineOnce = (ast) => {
|
|
2243
|
+
if (!Array.isArray(ast) || ast[0] !== 'module') return ast
|
|
2244
|
+
|
|
2245
|
+
// Lift primitives are shared with `inline` (defined once above buildInline). inlineOnce
|
|
2246
|
+
// splices into a SINGLE caller (never duplicating); `inline` duplicates into every caller.
|
|
2247
|
+
const bodyStart = inlBodyStart, callsSelf = inlCallsSelf, unsafe = inlUnsafe, isBranch = inlIsBranch
|
|
2248
|
+
const zeroFor = inlZeroFor, needsReset = inlNeedsReset, collectPinned = inlCollectPinned
|
|
2249
|
+
|
|
2250
|
+
for (let round = 0; round < MAX_INLINE_ROUNDS; round++) {
|
|
2251
|
+
const funcs = ast.filter(n => Array.isArray(n) && n[0] === 'func')
|
|
2252
|
+
const funcByName = new Map()
|
|
2253
|
+
for (const n of funcs) if (typeof n[1] === 'string') funcByName.set(n[1], n)
|
|
2254
|
+
|
|
2255
|
+
// Count plain-call references across the WHOLE module (anonymous exported funcs
|
|
2256
|
+
// call helpers too); flag any non-call reference (return_call etc.).
|
|
2257
|
+
const callRefs = new Map(), otherRef = new Set()
|
|
2258
|
+
const countRefs = (n) => {
|
|
2259
|
+
if (!Array.isArray(n)) return
|
|
2260
|
+
const op = n[0]
|
|
2261
|
+
if (op === 'call' && typeof n[1] === 'string') callRefs.set(n[1], (callRefs.get(n[1]) || 0) + 1)
|
|
2262
|
+
else if (op === 'return_call' && typeof n[1] === 'string') otherRef.add(n[1])
|
|
2263
|
+
for (let i = 1; i < n.length; i++) countRefs(n[i])
|
|
2264
|
+
}
|
|
2265
|
+
countRefs(ast)
|
|
2266
|
+
const pinned = new Set()
|
|
2267
|
+
for (const n of ast) if (!Array.isArray(n) || n[0] !== 'func') collectPinned(n, pinned)
|
|
2268
|
+
// a func may carry its own (export "name") — the signature scan below rejects those too
|
|
2269
|
+
|
|
2270
|
+
// Pick a callee.
|
|
2271
|
+
let calleeName = null
|
|
2272
|
+
for (const [name, fn] of funcByName) {
|
|
2273
|
+
if (pinned.has(name) || otherRef.has(name)) continue
|
|
2274
|
+
if (callRefs.get(name) !== 1) continue
|
|
2275
|
+
// Keep the scalar transcendentals that the auto-vectorizer maps to f64x2 mirrors (PPC_CALL2 in
|
|
2276
|
+
// src/optimize/vectorize.js): inlining a single-caller $math.atan2/hypot/log would dissolve the
|
|
2277
|
+
// call node before the post-phase lift can rewrite it to $math.atan2_2/hypot_2/log_v. Keep in
|
|
2278
|
+
// sync with PPC_CALL2's keys.
|
|
2279
|
+
if (SIMD_PROTECTED.has(name)) continue
|
|
2280
|
+
if (callsSelf(fn, name)) continue
|
|
2281
|
+
// named params/locals only (we'll rename them); reject locals with types
|
|
2282
|
+
// we can't zero-init on block re-entry.
|
|
2283
|
+
let ok = true, nResult = 0
|
|
2284
|
+
for (let i = 2; i < fn.length; i++) {
|
|
2285
|
+
const c = fn[i]
|
|
2286
|
+
if (typeof c === 'string') continue
|
|
2287
|
+
if (!Array.isArray(c)) { ok = false; break }
|
|
2288
|
+
if (c[0] === 'param' || c[0] === 'local') {
|
|
2289
|
+
if (typeof c[1] !== 'string' || c[1][0] !== '$') { ok = false; break }
|
|
2290
|
+
if (c[0] === 'local' && !zeroFor(c[2])) { ok = false; break }
|
|
2291
|
+
}
|
|
2292
|
+
else if (c[0] === 'result') nResult += c.length - 1
|
|
2293
|
+
else if (c[0] === 'export') { ok = false; break }
|
|
2294
|
+
else if (c[0] === 'type') continue
|
|
2295
|
+
else break
|
|
2296
|
+
}
|
|
2297
|
+
if (!ok || nResult > 1) continue
|
|
2298
|
+
let bad = false
|
|
2299
|
+
for (let i = bodyStart(fn); i < fn.length; i++) if (unsafe(fn[i])) { bad = true; break }
|
|
2300
|
+
if (bad) continue
|
|
2301
|
+
calleeName = name; break
|
|
2302
|
+
}
|
|
2303
|
+
if (!calleeName) break
|
|
2304
|
+
|
|
2305
|
+
const callee = funcByName.get(calleeName)
|
|
2306
|
+
const params = [], locals = []
|
|
2307
|
+
let inlResult = null
|
|
2308
|
+
for (let i = 2; i < callee.length; i++) {
|
|
2309
|
+
const c = callee[i]
|
|
2310
|
+
if (typeof c === 'string' || !Array.isArray(c)) continue
|
|
2311
|
+
if (c[0] === 'param') params.push({ name: c[1], type: c[2] })
|
|
2312
|
+
else if (c[0] === 'result') { if (c.length > 1) inlResult = c[1] }
|
|
2313
|
+
else if (c[0] === 'local') locals.push({ name: c[1], type: c[2] })
|
|
2314
|
+
else if (c[0] === 'export' || c[0] === 'type') continue
|
|
2315
|
+
else break
|
|
2316
|
+
}
|
|
2317
|
+
const cBody = callee.slice(bodyStart(callee))
|
|
2318
|
+
|
|
2319
|
+
const uid = ++inlineUid
|
|
2320
|
+
const exit = `$__inl${uid}`
|
|
2321
|
+
const rename = new Map()
|
|
2322
|
+
for (const p of params) rename.set(p.name, `$__inl${uid}_${p.name.slice(1)}`)
|
|
2323
|
+
for (const l of locals) rename.set(l.name, `$__inl${uid}_${l.name.slice(1)}`)
|
|
2324
|
+
// The callee's own block/loop/if labels would shadow same-named labels in the
|
|
2325
|
+
// caller after nesting (and break depth resolution) — give them fresh names too.
|
|
2326
|
+
const labelRename = new Map()
|
|
2327
|
+
const collectLabels = (n) => {
|
|
2328
|
+
if (!Array.isArray(n)) return
|
|
2329
|
+
if (isBranchScope(n[0]) && typeof n[1] === 'string' && n[1][0] === '$' && !labelRename.has(n[1]))
|
|
2330
|
+
labelRename.set(n[1], `$__inl${uid}L_${n[1].slice(1)}`)
|
|
2331
|
+
for (let i = 1; i < n.length; i++) collectLabels(n[i])
|
|
2332
|
+
}
|
|
2333
|
+
for (const n of cBody) collectLabels(n)
|
|
2334
|
+
const sub = (n) => {
|
|
2335
|
+
if (!Array.isArray(n)) return n
|
|
2336
|
+
const op = n[0]
|
|
2337
|
+
if ((op === 'local.get' || op === 'local.set' || op === 'local.tee') && typeof n[1] === 'string' && rename.has(n[1]))
|
|
2338
|
+
return [op, rename.get(n[1]), ...n.slice(2).map(sub)]
|
|
2339
|
+
if (op === 'return') return ['br', exit, ...n.slice(1).map(sub)]
|
|
2340
|
+
if (isBranchScope(op) && typeof n[1] === 'string' && labelRename.has(n[1]))
|
|
2341
|
+
return [op, labelRename.get(n[1]), ...n.slice(2).map(sub)]
|
|
2342
|
+
if (isBranch(op)) return [op, ...n.slice(1).map(c => (typeof c === 'string' && labelRename.has(c)) ? labelRename.get(c) : sub(c))]
|
|
2343
|
+
return n.map((c, i) => i === 0 ? c : sub(c))
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
// Splice into the (unique) caller (which may be an anonymous exported func).
|
|
2347
|
+
let done = false
|
|
2348
|
+
for (const fn of funcs) {
|
|
2349
|
+
if (fn === callee || done) continue
|
|
2350
|
+
const start = bodyStart(fn)
|
|
2351
|
+
for (let i = start; i < fn.length; i++) {
|
|
2352
|
+
const replaced = walkPost(fn[i], (n) => {
|
|
2353
|
+
if (done || !Array.isArray(n) || n[0] !== 'call' || n[1] !== calleeName) return
|
|
2354
|
+
const args = n.slice(2)
|
|
2355
|
+
if (args.length !== params.length) return // arity mismatch — leave it
|
|
2356
|
+
const setup = params.map((p, k) => ['local.set', rename.get(p.name), args[k]])
|
|
2357
|
+
// Re-zero only the callee locals that actually depend on the per-call
|
|
2358
|
+
// zero-init (read-before-write, or first-write inside a conditional
|
|
2359
|
+
// branch). Unconditionally-written-before-read scratch locals don't
|
|
2360
|
+
// need a reset, and emitting one inflates their set-count enough to
|
|
2361
|
+
// break propagation/coalescing of the helper that follows.
|
|
2362
|
+
const resets = locals
|
|
2363
|
+
.filter(l => needsReset(cBody, l.name))
|
|
2364
|
+
.map(l => ['local.set', rename.get(l.name), zeroFor(l.type)])
|
|
2365
|
+
const inner = cBody.map(sub)
|
|
2366
|
+
done = true
|
|
2367
|
+
return inlResult
|
|
2368
|
+
? ['block', exit, ['result', inlResult], ...setup, ...resets, ...inner]
|
|
2369
|
+
: ['block', exit, ...setup, ...resets, ...inner]
|
|
2370
|
+
})
|
|
2371
|
+
if (replaced !== fn[i]) fn[i] = replaced
|
|
2372
|
+
if (done) {
|
|
2373
|
+
const decls = [...params, ...locals].map(p => ['local', rename.get(p.name), p.type])
|
|
2374
|
+
if (decls.length) fn.splice(bodyStart(fn), 0, ...decls)
|
|
2375
|
+
break
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
if (done) break
|
|
2379
|
+
}
|
|
2380
|
+
if (!done) break // call site not found inside a func body — give up
|
|
2381
|
+
|
|
2382
|
+
const idx = ast.indexOf(callee)
|
|
2383
|
+
if (idx >= 0) ast.splice(idx, 1)
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
return ast
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
// ==================== MERGE BLOCKS ====================
|
|
2390
|
+
|
|
2391
|
+
/**
|
|
2392
|
+
* Does `body` contain a branch instruction targeting `label`, ignoring inner
|
|
2393
|
+
* blocks/loops that re-bind the same label?
|
|
2394
|
+
*/
|
|
2395
|
+
const targetsLabel = (body, label) => {
|
|
2396
|
+
let found = false
|
|
2397
|
+
const search = (n, shadowed) => {
|
|
2398
|
+
if (found || !Array.isArray(n)) return
|
|
2399
|
+
const op = n[0]
|
|
2400
|
+
let inner = shadowed
|
|
2401
|
+
if ((op === 'block' || op === 'loop') && typeof n[1] === 'string' && n[1] === label) inner = true
|
|
2402
|
+
if (!shadowed) {
|
|
2403
|
+
if (op === 'br' || op === 'br_if' || op === 'br_on_null' || op === 'br_on_non_null' ||
|
|
2404
|
+
op === 'br_on_cast' || op === 'br_on_cast_fail') {
|
|
2405
|
+
if (n[1] === label) { found = true; return }
|
|
2406
|
+
} else if (op === 'br_table') {
|
|
2407
|
+
for (let j = 1; j < n.length; j++) {
|
|
2408
|
+
if (typeof n[j] === 'string') { if (n[j] === label) { found = true; return } }
|
|
2409
|
+
else break
|
|
2410
|
+
}
|
|
2411
|
+
} else if (op === 'catch' || op === 'catch_ref') {
|
|
2412
|
+
// `try_table` catch clause `(catch $tag $label)` / `(catch_ref $tag $label)`
|
|
2413
|
+
// branches to an enclosing block label just like `br` does.
|
|
2414
|
+
if (n[2] === label) { found = true; return }
|
|
2415
|
+
} else if (op === 'catch_all' || op === 'catch_all_ref') {
|
|
2416
|
+
// `(catch_all $label)` / `(catch_all_ref $label)`
|
|
2417
|
+
if (n[1] === label) { found = true; return }
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
for (let i = 1; i < n.length; i++) search(n[i], inner)
|
|
2421
|
+
}
|
|
2422
|
+
for (const node of body) search(node, false)
|
|
2423
|
+
return found
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
/**
|
|
2427
|
+
* Unwrap redundant blocks whose label is never targeted. The block's stack
|
|
2428
|
+
* effect is determined entirely by its body, so removing the `block`/`end`
|
|
2429
|
+
* framing is sound as long as no `br` reaches into the block from inside.
|
|
2430
|
+
*
|
|
2431
|
+
* Three complementary patterns:
|
|
2432
|
+
*
|
|
2433
|
+
* 1. **Block at scope level** (sibling in `func`/`block`/`loop`/`then`/`else`):
|
|
2434
|
+
* splice body into the parent scope. Works for untyped, `(result T)`-typed,
|
|
2435
|
+
* or even `(param …)`-typed blocks — in all cases the body produces the
|
|
2436
|
+
* same net stack effect as the framed block did, at the same position.
|
|
2437
|
+
* 2. **Result-typed block in expression position** (`(block (result T) expr)`
|
|
2438
|
+
* as the value of some operand): collapse to `expr` if the body is a
|
|
2439
|
+
* single value expression. Catches the wrappers jz codegen leaves around
|
|
2440
|
+
* arena allocations once `propagate` has folded the intermediate
|
|
2441
|
+
* set/get pairs to a single call.
|
|
2442
|
+
* 3. **Result-typed block as the sole operand of a void consumer** at scope:
|
|
2443
|
+
* `(local.set $x (block (result T) stmt* expr))` → splice `stmt*` into
|
|
2444
|
+
* the parent scope and rewrite the consumer to `(local.set $x expr)`.
|
|
2445
|
+
* Same shape for `global.set` and `drop`. Cleans up the multi-stmt
|
|
2446
|
+
* wrappers `inlineOnce` leaves when inlining helpers whose return value
|
|
2447
|
+
* is fed into a single set/drop.
|
|
2448
|
+
*
|
|
2449
|
+
* Pattern 2 runs first (post-order) so patterns 1+3 see cleaned-up parents.
|
|
2450
|
+
* @param {Array} ast
|
|
2451
|
+
* @returns {Array}
|
|
2452
|
+
*/
|
|
2453
|
+
const mergeBlocks = (ast) => {
|
|
2454
|
+
walkPost(ast, (node) => {
|
|
2455
|
+
if (!Array.isArray(node) || node[0] !== 'block') return
|
|
2456
|
+
let bi = 1, label = null
|
|
2457
|
+
if (typeof node[1] === 'string' && node[1][0] === '$') { label = node[1]; bi = 2 }
|
|
2458
|
+
let hasResult = false
|
|
2459
|
+
while (bi < node.length) {
|
|
2460
|
+
const c = node[bi]
|
|
2461
|
+
if (Array.isArray(c) && (c[0] === 'param' || c[0] === 'type')) { bi++; continue }
|
|
2462
|
+
if (Array.isArray(c) && c[0] === 'result') { hasResult = true; bi++; continue }
|
|
2463
|
+
break
|
|
2464
|
+
}
|
|
2465
|
+
const body = node.slice(bi)
|
|
2466
|
+
if (!hasResult || body.length !== 1) return
|
|
2467
|
+
const only = body[0]
|
|
2468
|
+
if (!Array.isArray(only)) return
|
|
2469
|
+
if (label && targetsLabel(body, label)) return
|
|
2470
|
+
node.length = 0
|
|
2471
|
+
for (const tok of only) node.push(tok)
|
|
2472
|
+
})
|
|
2473
|
+
|
|
2474
|
+
walk(ast, (node) => {
|
|
2475
|
+
if (!isScopeNode(node)) return
|
|
2476
|
+
let i = 1
|
|
2477
|
+
while (i < node.length) {
|
|
2478
|
+
const child = node[i]
|
|
2479
|
+
if (!Array.isArray(child)) { i++; continue }
|
|
2480
|
+
|
|
2481
|
+
// Pattern 3: void-consumer wrapping a result-typed block at scope level.
|
|
2482
|
+
// (local.set $x (block $L (result T) stmt* expr)) → stmt* (local.set $x expr)
|
|
2483
|
+
// Same logic for `global.set` and `drop`. The block's body produces a
|
|
2484
|
+
// single value at the end; the leading stmts run for side-effect and
|
|
2485
|
+
// can move into the parent scope unchanged. Label must be unreferenced
|
|
2486
|
+
// (an inner `br $L value` would skip later stmts after splicing).
|
|
2487
|
+
// Catches the (block (result T) … (local.get $tmp)) wrappers inlineOnce
|
|
2488
|
+
// leaves around inlined helper bodies.
|
|
2489
|
+
{
|
|
2490
|
+
const cop = child[0]
|
|
2491
|
+
const oi = (cop === 'local.set' || cop === 'global.set') ? 2
|
|
2492
|
+
: cop === 'drop' ? 1 : -1
|
|
2493
|
+
if (oi >= 0 && child.length === oi + 1) {
|
|
2494
|
+
const operand = child[oi]
|
|
2495
|
+
if (Array.isArray(operand) && operand[0] === 'block') {
|
|
2496
|
+
let bi = 1, label = null
|
|
2497
|
+
if (typeof operand[1] === 'string' && operand[1][0] === '$') { label = operand[1]; bi = 2 }
|
|
2498
|
+
let hasResult = false
|
|
2499
|
+
while (bi < operand.length) {
|
|
2500
|
+
const c = operand[bi]
|
|
2501
|
+
if (Array.isArray(c) && (c[0] === 'param' || c[0] === 'type')) { bi++; continue }
|
|
2502
|
+
if (Array.isArray(c) && c[0] === 'result') { hasResult = true; bi++; continue }
|
|
2503
|
+
break
|
|
2504
|
+
}
|
|
2505
|
+
const body = hasResult ? operand.slice(bi) : null
|
|
2506
|
+
if (body && body.length >= 2 && !(label && targetsLabel(body, label))) {
|
|
2507
|
+
const expr = body[body.length - 1]
|
|
2508
|
+
const setup = body.slice(0, -1)
|
|
2509
|
+
child[oi] = expr
|
|
2510
|
+
node.splice(i, 1, ...setup, child)
|
|
2511
|
+
continue // re-examine position i (now setup[0]) — may itself be a splice candidate
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
if (child[0] !== 'block') { i++; continue }
|
|
2518
|
+
let bi = 1, label = null
|
|
2519
|
+
if (typeof child[1] === 'string' && child[1][0] === '$') { label = child[1]; bi = 2 }
|
|
2520
|
+
// Skip leading typing annotations; they describe the block's stack effect,
|
|
2521
|
+
// which the body already produces verbatim, so they're discarded on splice.
|
|
2522
|
+
while (bi < child.length) {
|
|
2523
|
+
const c = child[bi]
|
|
2524
|
+
if (Array.isArray(c) && (c[0] === 'param' || c[0] === 'result' || c[0] === 'type')) { bi++; continue }
|
|
2525
|
+
break
|
|
2526
|
+
}
|
|
2527
|
+
const body = child.slice(bi)
|
|
2528
|
+
if (label && targetsLabel(body, label)) { i++; continue }
|
|
2529
|
+
node.splice(i, 1, ...body)
|
|
2530
|
+
i += body.length
|
|
2531
|
+
}
|
|
2532
|
+
})
|
|
2533
|
+
return ast
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
// ==================== COALESCE LOCALS ====================
|
|
2537
|
+
|
|
2538
|
+
/**
|
|
2539
|
+
* Share local slots between same-type locals with non-overlapping live ranges.
|
|
2540
|
+
* Live range = [first pos, last pos] of any local.get/set/tee, extended over
|
|
2541
|
+
* any loop containing a reference (so a value read across loop iterations stays
|
|
2542
|
+
* intact). Greedy slot assignment by start position. Params and unnamed/numeric
|
|
2543
|
+
* references are left alone; `localReuse` later removes the renamed-away decls.
|
|
2544
|
+
*
|
|
2545
|
+
* Soundness: WASM zero-initializes locals at function entry, so a local whose
|
|
2546
|
+
* first reference (in walk order) is a `local.get` *relies* on that implicit
|
|
2547
|
+
* zero — coalescing it into a slot whose previous user left a non-zero residue
|
|
2548
|
+
* would silently change behavior (e.g. a `for (let i=0; …)` loop counter
|
|
2549
|
+
* inheriting `N*4` from a sibling temp). Such "read-first" locals can still
|
|
2550
|
+
* serve as a slot's *primary* (the slot then keeps the function's zero start),
|
|
2551
|
+
* but can never be a donor merged into an existing slot.
|
|
2552
|
+
* @param {Array} ast
|
|
2553
|
+
* @returns {Array}
|
|
2554
|
+
*/
|
|
2555
|
+
const coalesceLocals = (ast) => {
|
|
2556
|
+
walk(ast, (funcNode) => {
|
|
2557
|
+
if (!Array.isArray(funcNode) || funcNode[0] !== 'func') return
|
|
2558
|
+
|
|
2559
|
+
const decls = new Map()
|
|
2560
|
+
for (const sub of funcNode) {
|
|
2561
|
+
if (Array.isArray(sub) && sub[0] === 'local' &&
|
|
2562
|
+
typeof sub[1] === 'string' && sub[1][0] === '$' && typeof sub[2] === 'string') {
|
|
2563
|
+
decls.set(sub[1], sub[2])
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
if (decls.size < 2) return
|
|
2567
|
+
|
|
2568
|
+
const uses = new Map()
|
|
2569
|
+
const loopStack = []
|
|
2570
|
+
let pos = 0, abort = false, condDepth = 0
|
|
2571
|
+
|
|
2572
|
+
const visit = (n) => {
|
|
2573
|
+
if (abort || !Array.isArray(n)) return
|
|
2574
|
+
const op = n[0]
|
|
2575
|
+
const isLoop = op === 'loop'
|
|
2576
|
+
if (isLoop) loopStack.push({ start: pos, end: pos })
|
|
2577
|
+
const isSet = op === 'local.set' || op === 'local.tee'
|
|
2578
|
+
|
|
2579
|
+
if (isSet || op === 'local.get') {
|
|
2580
|
+
const name = n[1]
|
|
2581
|
+
if (typeof name !== 'string' || name[0] !== '$') { abort = true; return }
|
|
2582
|
+
// Execution order: evaluate set/tee value BEFORE recording the write,
|
|
2583
|
+
// so a `(local.set $x (… (local.get $x) …))` is correctly seen as a
|
|
2584
|
+
// read-then-write of $x (firstOp = local.get).
|
|
2585
|
+
if (isSet) for (let i = 2; i < n.length; i++) visit(n[i])
|
|
2586
|
+
const here = pos++
|
|
2587
|
+
if (decls.has(name)) {
|
|
2588
|
+
let u = uses.get(name)
|
|
2589
|
+
if (!u) { u = { start: here, end: here, firstOp: op, firstCond: condDepth > 0, loops: new Set() }; uses.set(name, u) }
|
|
2590
|
+
if (here > u.end) u.end = here
|
|
2591
|
+
for (const ls of loopStack) u.loops.add(ls)
|
|
2592
|
+
}
|
|
2593
|
+
} else {
|
|
2594
|
+
pos++
|
|
2595
|
+
const isIf = op === 'if'
|
|
2596
|
+
for (let i = 1; i < n.length; i++) {
|
|
2597
|
+
const c = n[i]
|
|
2598
|
+
const cond = isIf && Array.isArray(c) && (c[0] === 'then' || c[0] === 'else')
|
|
2599
|
+
if (cond) condDepth++
|
|
2600
|
+
visit(c)
|
|
2601
|
+
if (cond) condDepth--
|
|
2602
|
+
}
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
if (isLoop) { const ls = loopStack.pop(); ls.end = pos }
|
|
2606
|
+
}
|
|
2607
|
+
visit(funcNode)
|
|
2608
|
+
if (abort) return
|
|
2609
|
+
|
|
2610
|
+
// A use inside a loop must stay live for the whole loop — the next
|
|
2611
|
+
// iteration could read what this iteration wrote.
|
|
2612
|
+
for (const u of uses.values()) {
|
|
2613
|
+
for (const ls of u.loops) {
|
|
2614
|
+
if (ls.start < u.start) u.start = ls.start
|
|
2615
|
+
if (ls.end > u.end) u.end = ls.end
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
const ordered = [...uses.entries()].sort((a, b) => a[1].start - b[1].start)
|
|
2620
|
+
const rename = new Map()
|
|
2621
|
+
const slots = []
|
|
2622
|
+
for (const [name, range] of ordered) {
|
|
2623
|
+
// Read-first locals depend on the implicit zero; locals first seen inside
|
|
2624
|
+
// an if/else branch may be skipped on the alternate path — either way
|
|
2625
|
+
// they'd observe a prior slot's residue if reused. They may *start* a
|
|
2626
|
+
// fresh slot (the function's zero init), but never *join* one.
|
|
2627
|
+
const readsZero = range.firstOp === 'local.get' || range.firstCond
|
|
2628
|
+
const type = decls.get(name)
|
|
2629
|
+
const slot = readsZero ? null : slots.find(s => s.type === type && s.end < range.start)
|
|
2630
|
+
if (slot) { rename.set(name, slot.primary); if (range.end > slot.end) slot.end = range.end }
|
|
2631
|
+
else slots.push({ primary: name, type, end: range.end })
|
|
2632
|
+
}
|
|
2633
|
+
if (rename.size === 0) return
|
|
2634
|
+
|
|
2635
|
+
walk(funcNode, (n) => {
|
|
2636
|
+
if (Array.isArray(n) &&
|
|
2637
|
+
(n[0] === 'local.get' || n[0] === 'local.set' || n[0] === 'local.tee') &&
|
|
2638
|
+
rename.has(n[1])) {
|
|
2639
|
+
n[1] = rename.get(n[1])
|
|
2640
|
+
}
|
|
2641
|
+
})
|
|
2642
|
+
})
|
|
2643
|
+
return ast
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
// ==================== VACUUM ====================
|
|
2647
|
+
|
|
2648
|
+
/**
|
|
2649
|
+
* Remove no-op code: nops, drop of pure expressions, empty branches,
|
|
2650
|
+
* and select with identical arms.
|
|
2651
|
+
* @param {Array} ast
|
|
2652
|
+
* @returns {Array}
|
|
2653
|
+
*/
|
|
2654
|
+
const vacuum = (ast) => {
|
|
2655
|
+
return walkPost(ast, (node) => {
|
|
2656
|
+
if (!Array.isArray(node)) return
|
|
2657
|
+
const op = node[0]
|
|
2658
|
+
|
|
2659
|
+
// Remove nop entirely (return array marker; parent or post-pass cleans it)
|
|
2660
|
+
if (op === 'nop') return ['nop']
|
|
2661
|
+
|
|
2662
|
+
// (drop V) → just V's side effects. Pure V vanishes (→ nop); a pure op over
|
|
2663
|
+
// a `local.tee` collapses to the bare store (kills the post-increment's dead
|
|
2664
|
+
// old-value arithmetic); impure V is kept under a drop.
|
|
2665
|
+
if (op === 'drop' && node.length === 2) {
|
|
2666
|
+
const eff = dropEffects(node[1])
|
|
2667
|
+
if (eff.length === 0) return ['nop']
|
|
2668
|
+
if (eff.length === 1) return eff[0]
|
|
2669
|
+
return ['block', ...eff]
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
// (select x x cond) → x — only when cond is PURE. An impure cond may set a
|
|
2673
|
+
// local that a later op reads (e.g. an address `local.tee` the matching store
|
|
2674
|
+
// reuses); dropping it would leave that local stale. Keep the select otherwise.
|
|
2675
|
+
if (op === 'select' && node.length >= 4 && equal(node[1], node[2]) && isPure(node[3])) return node[1]
|
|
2676
|
+
|
|
2677
|
+
if (op === 'if') {
|
|
2678
|
+
const { cond, thenBranch, elseBranch } = parseIf(node)
|
|
2679
|
+
const thenEmpty = !thenBranch || thenBranch.length <= 1
|
|
2680
|
+
const elseEmpty = !elseBranch || elseBranch.length <= 1
|
|
2681
|
+
|
|
2682
|
+
// (if cond () ()) → nop or (drop cond)
|
|
2683
|
+
if (thenEmpty && elseEmpty) return isPure(cond) ? ['nop'] : ['drop', cond]
|
|
2684
|
+
|
|
2685
|
+
// (if cond (then X) (else)) → drop the empty else
|
|
2686
|
+
if (elseBranch && elseEmpty && !thenEmpty) {
|
|
2687
|
+
return node.filter(c => c !== elseBranch)
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
// Clean out nops, drop-of-pure sequences, and empty annotations from blocks
|
|
2692
|
+
if (isScopeNode(node)) {
|
|
2693
|
+
const cleaned = [op]
|
|
2694
|
+
for (let i = 1; i < node.length; i++) {
|
|
2695
|
+
const child = node[i]
|
|
2696
|
+
if (child === 'nop' || (Array.isArray(child) && child[0] === 'nop')) continue
|
|
2697
|
+
// Stack-form `EXPR drop`: a pure EXPR drops out entirely; a bare
|
|
2698
|
+
// `tee X V drop` keeps just the store (`set X V`) — the dropped value
|
|
2699
|
+
// was the only reason it was a tee.
|
|
2700
|
+
const next = node[i + 1]
|
|
2701
|
+
const isDrop = next === 'drop' || (Array.isArray(next) && next[0] === 'drop' && next.length === 1)
|
|
2702
|
+
if (Array.isArray(child) && isDrop && isPure(child)) {
|
|
2703
|
+
i++ // skip the drop too
|
|
2704
|
+
continue
|
|
2705
|
+
}
|
|
2706
|
+
if (Array.isArray(child) && isDrop && child[0] === 'local.tee' && child.length === 3) {
|
|
2707
|
+
cleaned.push(['local.set', child[1], child[2]])
|
|
2708
|
+
i++ // skip the drop
|
|
2709
|
+
continue
|
|
2710
|
+
}
|
|
2711
|
+
cleaned.push(child)
|
|
2712
|
+
}
|
|
2713
|
+
if (cleaned.length !== node.length) return cleaned
|
|
2714
|
+
}
|
|
2715
|
+
})
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2718
|
+
// ==================== PEEPHOLE ====================
|
|
2719
|
+
|
|
2720
|
+
/** Peephole optimizations: simple algebraic identities.
|
|
2721
|
+
* Every rule that DROPS an operand guards on isPure: an impure operand must still
|
|
2722
|
+
* be evaluated for its side effects. The load-bearing case is a typed-array element
|
|
2723
|
+
* store, whose address is a `local.tee` inside the value expression (the element's
|
|
2724
|
+
* own read); dropping that operand (e.g. `(a[i] op a[i]) & 0`) would strand the
|
|
2725
|
+
* store with a stale address — a silent miscompile. When impure, keep the op (it
|
|
2726
|
+
* still yields the same value AND runs the operand). */
|
|
2727
|
+
const selfFold = (val) => (a, b) => equal(a, b) && isPure(a) ? val : null
|
|
2728
|
+
const PEEPHOLE = {
|
|
2729
|
+
// Self-cancelling / tautological binary ops — drop both (equal) operands.
|
|
2730
|
+
'i32.sub': selfFold(['i32.const', 0]),
|
|
2731
|
+
'i64.sub': selfFold(['i64.const', 0]),
|
|
2732
|
+
'i32.xor': selfFold(['i32.const', 0]),
|
|
2733
|
+
'i64.xor': selfFold(['i64.const', 0]),
|
|
2734
|
+
'i32.eq': selfFold(['i32.const', 1]),
|
|
2735
|
+
'i64.eq': selfFold(['i32.const', 1]),
|
|
2736
|
+
'i32.ne': selfFold(['i32.const', 0]),
|
|
2737
|
+
'i64.ne': selfFold(['i32.const', 0]),
|
|
2738
|
+
'i32.lt_s': selfFold(['i32.const', 0]),
|
|
2739
|
+
'i32.lt_u': selfFold(['i32.const', 0]),
|
|
2740
|
+
'i32.gt_s': selfFold(['i32.const', 0]),
|
|
2741
|
+
'i32.gt_u': selfFold(['i32.const', 0]),
|
|
2742
|
+
'i32.le_s': selfFold(['i32.const', 1]),
|
|
2743
|
+
'i32.le_u': selfFold(['i32.const', 1]),
|
|
2744
|
+
'i32.ge_s': selfFold(['i32.const', 1]),
|
|
2745
|
+
'i32.ge_u': selfFold(['i32.const', 1]),
|
|
2746
|
+
'i64.lt_s': selfFold(['i32.const', 0]),
|
|
2747
|
+
'i64.lt_u': selfFold(['i32.const', 0]),
|
|
2748
|
+
'i64.gt_s': selfFold(['i32.const', 0]),
|
|
2749
|
+
'i64.gt_u': selfFold(['i32.const', 0]),
|
|
2750
|
+
'i64.le_s': selfFold(['i32.const', 1]),
|
|
2751
|
+
'i64.le_u': selfFold(['i32.const', 1]),
|
|
2752
|
+
'i64.ge_s': selfFold(['i32.const', 1]),
|
|
2753
|
+
'i64.ge_u': selfFold(['i32.const', 1]),
|
|
2754
|
+
|
|
2755
|
+
// Zero/all-bits absorption — drops the NON-const operand, so guard its purity.
|
|
2756
|
+
'i32.mul': (a, b) => {
|
|
2757
|
+
if (getConst(b)?.value === 0 && isPure(a)) return ['i32.const', 0]
|
|
2758
|
+
if (getConst(a)?.value === 0 && isPure(b)) return ['i32.const', 0]
|
|
2759
|
+
return null
|
|
2760
|
+
},
|
|
2761
|
+
'i64.mul': (a, b) => {
|
|
2762
|
+
if (getConst(b)?.value === ZERO64 && isPure(a)) return ['i64.const', 0]
|
|
2763
|
+
if (getConst(a)?.value === ZERO64 && isPure(b)) return ['i64.const', 0]
|
|
2764
|
+
return null
|
|
2765
|
+
},
|
|
2766
|
+
'i32.and': (a, b) => {
|
|
2767
|
+
if (equal(a, b) && isPure(b)) return a
|
|
2768
|
+
if (getConst(b)?.value === 0 && isPure(a)) return ['i32.const', 0]
|
|
2769
|
+
if (getConst(a)?.value === 0 && isPure(b)) return ['i32.const', 0]
|
|
2770
|
+
return null
|
|
2771
|
+
},
|
|
2772
|
+
'i64.and': (a, b) => {
|
|
2773
|
+
if (equal(a, b) && isPure(b)) return a
|
|
2774
|
+
if (getConst(b)?.value === ZERO64 && isPure(a)) return ['i64.const', 0]
|
|
2775
|
+
if (getConst(a)?.value === ZERO64 && isPure(b)) return ['i64.const', 0]
|
|
2776
|
+
return null
|
|
2777
|
+
},
|
|
2778
|
+
'i32.or': (a, b) => {
|
|
2779
|
+
if (equal(a, b) && isPure(b)) return a
|
|
2780
|
+
if (getConst(b)?.value === -1 && isPure(a)) return ['i32.const', -1]
|
|
2781
|
+
if (getConst(a)?.value === -1 && isPure(b)) return ['i32.const', -1]
|
|
2782
|
+
return null
|
|
2783
|
+
},
|
|
2784
|
+
'i64.or': (a, b) => {
|
|
2785
|
+
if (equal(a, b) && isPure(b)) return a
|
|
2786
|
+
if (getConst(b)?.value === NEG164 && isPure(a)) return ['i64.const', -1]
|
|
2787
|
+
if (getConst(a)?.value === NEG164 && isPure(b)) return ['i64.const', -1]
|
|
2788
|
+
return null
|
|
2789
|
+
},
|
|
2790
|
+
|
|
2791
|
+
// (local.set $x (local.get $x)) → nop
|
|
2792
|
+
'local.set': (a, b) => Array.isArray(b) && b[0] === 'local.get' && b[1] === a ? ['nop'] : null,
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
/**
|
|
2796
|
+
* Apply peephole optimizations.
|
|
2797
|
+
* @param {Array} ast
|
|
2798
|
+
* @returns {Array}
|
|
2799
|
+
*/
|
|
2800
|
+
const peephole = (ast) => {
|
|
2801
|
+
return walkPost(ast, (node) => {
|
|
2802
|
+
if (!Array.isArray(node) || node.length !== 3) return
|
|
2803
|
+
const fn = PEEPHOLE[node[0]]
|
|
2804
|
+
if (!fn) return
|
|
2805
|
+
const result = fn(node[1], node[2])
|
|
2806
|
+
if (result !== null) return result
|
|
2807
|
+
})
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
// ==================== GLOBAL CONSTANT PROPAGATION ====================
|
|
2811
|
+
|
|
2812
|
+
/** Bytes a signed-LEB128 integer encodes to. */
|
|
2813
|
+
const slebSize = (v) => {
|
|
2814
|
+
let x = typeof v === 'bigint' ? v
|
|
2815
|
+
: typeof v === 'string' ? BigInt(v.replaceAll('_', ''))
|
|
2816
|
+
: BigInt(Math.trunc(Number(v) || 0))
|
|
2817
|
+
// Signed view of raw bits — exact natively; in-kernel the arm is dead
|
|
2818
|
+
// (BigInt('0x…') already arrives as the signed i64 carrier there).
|
|
2819
|
+
if (x > 0x7fffffffffffffffn) x = x - 0x8000000000000000n - 0x8000000000000000n
|
|
2820
|
+
let n = 1
|
|
2821
|
+
while (true) {
|
|
2822
|
+
const b = x & 0x7fn
|
|
2823
|
+
x >>= 7n
|
|
2824
|
+
if ((x === 0n && (b & 0x40n) === 0n) || (x === -1n && (b & 0x40n) !== 0n)) return n
|
|
2825
|
+
n++
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2828
|
+
/** Encoded byte size of a constant init instruction (opcode + immediate). */
|
|
2829
|
+
const constInstrSize = (node) => {
|
|
2830
|
+
if (!Array.isArray(node)) return 4
|
|
2831
|
+
switch (node[0]) {
|
|
2832
|
+
case 'i32.const': case 'i64.const': return 1 + slebSize(node[1])
|
|
2833
|
+
case 'f32.const': return 5
|
|
2834
|
+
case 'f64.const': return 9
|
|
2835
|
+
case 'v128.const': return 18
|
|
2836
|
+
default: return 4 // ref.null/ref.func/global.get — conservative
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2839
|
+
const GLOBAL_GET_SIZE = 2 // 0x23 opcode + 1-byte globalidx (typical)
|
|
2840
|
+
|
|
2841
|
+
/**
|
|
2842
|
+
* Replace `global.get` of an immutable, const-initialised global with the
|
|
2843
|
+
* constant — but only when it doesn't grow the module. A `global.get` costs
|
|
2844
|
+
* ~2 B; an `i32.const 12345` costs 4 B; an `f64.const` costs 9 B. Naively
|
|
2845
|
+
* inlining a big constant read from many sites trades a few cheap reads + one
|
|
2846
|
+
* global decl for many fat immediates — pure bloat (and the node-count size
|
|
2847
|
+
* guard can't see it: same number of AST nodes). So we only propagate a global
|
|
2848
|
+
* when `refs·constSize ≤ refs·2 + declSize`; when every read is replaced and
|
|
2849
|
+
* the global isn't exported, its now-dead decl is dropped here too.
|
|
2850
|
+
* @param {Array} ast
|
|
2851
|
+
* @returns {Array}
|
|
2852
|
+
*/
|
|
2853
|
+
const globals = (ast) => {
|
|
2854
|
+
if (!Array.isArray(ast) || ast[0] !== 'module') return ast
|
|
2855
|
+
|
|
2856
|
+
// Immutable globals with a constant init: name → init node.
|
|
2857
|
+
const constGlobals = new Map()
|
|
2858
|
+
const exported = new Set() // globals pinned by an export — keep the decl
|
|
2859
|
+
|
|
2860
|
+
for (const node of ast.slice(1)) {
|
|
2861
|
+
if (!Array.isArray(node)) continue
|
|
2862
|
+
if (node[0] === 'export' && Array.isArray(node[2]) && node[2][0] === 'global' && typeof node[2][1] === 'string') { exported.add(node[2][1]); continue }
|
|
2863
|
+
if (node[0] !== 'global') continue
|
|
2864
|
+
const name = typeof node[1] === 'string' && node[1][0] === '$' ? node[1] : null
|
|
2865
|
+
if (!name) continue
|
|
2866
|
+
// (global $g (export "x") …) inline export → pinned
|
|
2867
|
+
if (node.some(c => Array.isArray(c) && c[0] === 'export')) exported.add(name)
|
|
2868
|
+
const typeSlot = node[2]
|
|
2869
|
+
if (Array.isArray(typeSlot) && typeSlot[0] === 'mut') continue // mutable
|
|
2870
|
+
if (Array.isArray(typeSlot) && typeSlot[0] === 'import') continue // imported
|
|
2871
|
+
const init = node[3]
|
|
2872
|
+
if (getConst(init)) constGlobals.set(name, init)
|
|
2873
|
+
}
|
|
2874
|
+
if (constGlobals.size === 0) return ast
|
|
2875
|
+
|
|
2876
|
+
// Drop any global that is ever written (defensive — an immutable global can't
|
|
2877
|
+
// be, but a malformed module might) and tally read counts.
|
|
2878
|
+
const reads = new Map()
|
|
2879
|
+
walk(ast, (n) => {
|
|
2880
|
+
if (!Array.isArray(n)) return
|
|
2881
|
+
const ref = n[1]
|
|
2882
|
+
if (typeof ref !== 'string' || ref[0] !== '$') return
|
|
2883
|
+
if (n[0] === 'global.set') constGlobals.delete(ref)
|
|
2884
|
+
else if (n[0] === 'global.get') reads.set(ref, (reads.get(ref) || 0) + 1)
|
|
2885
|
+
})
|
|
2886
|
+
|
|
2887
|
+
// Keep only globals where propagation is size-neutral or better.
|
|
2888
|
+
const propagate = new Set()
|
|
2889
|
+
for (const [name, init] of constGlobals) {
|
|
2890
|
+
const r = reads.get(name) || 0
|
|
2891
|
+
if (r === 0) continue // dead anyway — leave to treeshake
|
|
2892
|
+
const cs = constInstrSize(init)
|
|
2893
|
+
const declSize = cs + 2 // valtype + mutability byte + init expr + `end`
|
|
2894
|
+
const before = r * GLOBAL_GET_SIZE + declSize
|
|
2895
|
+
const after = r * cs + (exported.has(name) ? declSize : 0)
|
|
2896
|
+
if (after <= before) propagate.add(name)
|
|
2897
|
+
}
|
|
2898
|
+
if (propagate.size === 0) return ast
|
|
2899
|
+
|
|
2900
|
+
walkPost(ast, (node) => {
|
|
2901
|
+
if (!Array.isArray(node) || node[0] !== 'global.get' || node.length !== 2) return
|
|
2902
|
+
if (propagate.has(node[1])) return clone(constGlobals.get(node[1]))
|
|
2903
|
+
})
|
|
2904
|
+
// Their reads are all gone now — remove the decls we're free to remove.
|
|
2905
|
+
for (let i = ast.length - 1; i >= 1; i--) {
|
|
2906
|
+
const n = ast[i]
|
|
2907
|
+
if (Array.isArray(n) && n[0] === 'global' && typeof n[1] === 'string' && propagate.has(n[1]) && !exported.has(n[1])) ast.splice(i, 1)
|
|
2908
|
+
}
|
|
2909
|
+
return ast
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
// ==================== LOAD/STORE OFFSET FOLDING ====================
|
|
2913
|
+
|
|
2914
|
+
/** Match (type.load/store (i32.add ptr (type.const N))) and fold offset */
|
|
2915
|
+
const offset = (ast) => {
|
|
2916
|
+
return walkPost(ast, (node) => {
|
|
2917
|
+
if (!Array.isArray(node)) return
|
|
2918
|
+
const op = node[0]
|
|
2919
|
+
if (typeof op !== 'string' || (!op.endsWith('load') && !op.endsWith('store'))) return
|
|
2920
|
+
|
|
2921
|
+
// Memory ops have memarg as first immediate after optional memoryidx, then operands
|
|
2922
|
+
// In AST form from parse: (i32.load offset=4 align=8 ptr) or (i32.load ptr)
|
|
2923
|
+
// Store: (i32.store offset=4 ptr val) — ptr is second-to-last, val is last
|
|
2924
|
+
// Load: (i32.load offset=4 ptr) — ptr is last
|
|
2925
|
+
const isStore = op.endsWith('store')
|
|
2926
|
+
|
|
2927
|
+
// Find current offset from memparams
|
|
2928
|
+
let currentOffset = 0
|
|
2929
|
+
let memIdx = null
|
|
2930
|
+
let argStart = 1
|
|
2931
|
+
|
|
2932
|
+
// Check for memory index
|
|
2933
|
+
if (typeof node[1] === 'string' && (node[1][0] === '$' || !isNaN(node[1]))) {
|
|
2934
|
+
memIdx = node[1]
|
|
2935
|
+
argStart = 2
|
|
2936
|
+
}
|
|
2937
|
+
|
|
2938
|
+
// Check for memparams (offset=, align=)
|
|
2939
|
+
while (argStart < node.length && typeof node[argStart] === 'string' &&
|
|
2940
|
+
(node[argStart].startsWith('offset=') || node[argStart].startsWith('align='))) {
|
|
2941
|
+
if (node[argStart].startsWith('offset=')) {
|
|
2942
|
+
currentOffset = +node[argStart].slice(7)
|
|
2943
|
+
}
|
|
2944
|
+
argStart++
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
// Determine pointer index
|
|
2948
|
+
const ptrIdx = isStore ? node.length - 2 : node.length - 1
|
|
2949
|
+
const valIdx = isStore ? node.length - 1 : -1
|
|
2950
|
+
if (ptrIdx < argStart) return
|
|
2951
|
+
|
|
2952
|
+
const ptr = node[ptrIdx]
|
|
2953
|
+
if (!Array.isArray(ptr) || ptr[0] !== 'i32.add' || ptr.length !== 3) return
|
|
2954
|
+
|
|
2955
|
+
const a = ptr[1], b = ptr[2]
|
|
2956
|
+
const ca = getConst(a), cb = getConst(b)
|
|
2957
|
+
|
|
2958
|
+
let base = null, addend = null
|
|
2959
|
+
if (ca && ca.type === 'i32') { addend = ca.value; base = b }
|
|
2960
|
+
else if (cb && cb.type === 'i32') { addend = cb.value; base = a }
|
|
2961
|
+
if (base === null || addend === null) return
|
|
2962
|
+
|
|
2963
|
+
const newOffset = currentOffset + addend
|
|
2964
|
+
const newNode = [op]
|
|
2965
|
+
if (memIdx !== null) newNode.push(memIdx)
|
|
2966
|
+
newNode.push(`offset=${newOffset}`)
|
|
2967
|
+
// Preserve align if present
|
|
2968
|
+
let alignParam = null
|
|
2969
|
+
for (let i = argStart; i < ptrIdx; i++) {
|
|
2970
|
+
if (typeof node[i] === 'string' && node[i].startsWith('align=')) {
|
|
2971
|
+
alignParam = node[i]
|
|
2972
|
+
}
|
|
2973
|
+
}
|
|
2974
|
+
if (alignParam) newNode.push(alignParam)
|
|
2975
|
+
newNode.push(base)
|
|
2976
|
+
if (isStore) newNode.push(node[valIdx])
|
|
2977
|
+
return newNode
|
|
2978
|
+
})
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2981
|
+
// ==================== REDUNDANT BR REMOVAL ====================
|
|
2982
|
+
|
|
2983
|
+
/**
|
|
2984
|
+
* Remove br to a block's own label when it is the last instruction.
|
|
2985
|
+
* @param {Array} ast
|
|
2986
|
+
* @returns {Array}
|
|
2987
|
+
*/
|
|
2988
|
+
const unbranch = (ast) => {
|
|
2989
|
+
walk(ast, (node) => {
|
|
2990
|
+
if (!Array.isArray(node)) return
|
|
2991
|
+
const op = node[0]
|
|
2992
|
+
// Loops: `br $loop_label` jumps BACK to loop top (continue), not out.
|
|
2993
|
+
// Only `block` allows trailing-br elision because `br $block_label` exits the block.
|
|
2994
|
+
if (op !== 'block') return
|
|
2995
|
+
|
|
2996
|
+
// Get the block's label
|
|
2997
|
+
let labelIdx = 1
|
|
2998
|
+
let label = null
|
|
2999
|
+
if (typeof node[1] === 'string' && node[1][0] === '$') {
|
|
3000
|
+
label = node[1]
|
|
3001
|
+
labelIdx = 2
|
|
3002
|
+
}
|
|
3003
|
+
if (!label) return
|
|
3004
|
+
|
|
3005
|
+
// Find the last executable instruction (skip result/type annotations)
|
|
3006
|
+
let lastIdx = -1
|
|
3007
|
+
for (let i = node.length - 1; i >= labelIdx; i--) {
|
|
3008
|
+
const child = node[i]
|
|
3009
|
+
if (!Array.isArray(child)) {
|
|
3010
|
+
if (child !== 'nop' && child !== 'end') lastIdx = i
|
|
3011
|
+
continue
|
|
3012
|
+
}
|
|
3013
|
+
const cop = child[0]
|
|
3014
|
+
if (cop === 'param' || cop === 'result' || cop === 'local' || cop === 'type' || cop === 'export') continue
|
|
3015
|
+
lastIdx = i
|
|
3016
|
+
break
|
|
3017
|
+
}
|
|
3018
|
+
if (lastIdx < 0) return
|
|
3019
|
+
|
|
3020
|
+
const last = node[lastIdx]
|
|
3021
|
+
if (Array.isArray(last) && last[0] === 'br' && last[1] === label) {
|
|
3022
|
+
// `(br $L v…)` as a block's last instruction just leaves v… as the block's
|
|
3023
|
+
// result — splice the value operand(s) in its place (none → plain removal).
|
|
3024
|
+
node.splice(lastIdx, 1, ...last.slice(2))
|
|
3025
|
+
}
|
|
3026
|
+
})
|
|
3027
|
+
|
|
3028
|
+
return ast
|
|
3029
|
+
}
|
|
3030
|
+
|
|
3031
|
+
// ==================== WHILE-LOOP CANONICALIZATION ====================
|
|
3032
|
+
|
|
3033
|
+
/**
|
|
3034
|
+
* Collapse the `while`-emit idiom into a single loop.
|
|
3035
|
+
*
|
|
3036
|
+
* (block $A
|
|
3037
|
+
* (loop $B
|
|
3038
|
+
* (br_if $A (i32.eqz cond)) ;; exit when cond is false
|
|
3039
|
+
* …body…
|
|
3040
|
+
* (br $B) ;; continue
|
|
3041
|
+
* ))
|
|
3042
|
+
*
|
|
3043
|
+
* becomes
|
|
3044
|
+
*
|
|
3045
|
+
* (loop $B
|
|
3046
|
+
* (if cond (then …body… (br $B))))
|
|
3047
|
+
*
|
|
3048
|
+
* Saves ~3 B per while-loop (drop the outer block framing + the `i32.eqz`,
|
|
3049
|
+
* trade `br_if`→`if`). Safe only when:
|
|
3050
|
+
* - the block contains nothing but the loop (plus optional `type` slot),
|
|
3051
|
+
* - block / loop are void (no result),
|
|
3052
|
+
* - $A is never targeted from within body (only the head `br_if` uses it).
|
|
3053
|
+
*
|
|
3054
|
+
* @param {Array} ast
|
|
3055
|
+
* @returns {Array}
|
|
3056
|
+
*/
|
|
3057
|
+
const loopify = (ast) => {
|
|
3058
|
+
walk(ast, (node) => {
|
|
3059
|
+
if (!Array.isArray(node) || node[0] !== 'block') return
|
|
3060
|
+
let bi = 1, label = null
|
|
3061
|
+
if (typeof node[1] === 'string' && node[1][0] === '$') { label = node[1]; bi = 2 }
|
|
3062
|
+
if (!label) return
|
|
3063
|
+
while (bi < node.length) {
|
|
3064
|
+
const c = node[bi]
|
|
3065
|
+
if (Array.isArray(c) && c[0] === 'type') { bi++; continue }
|
|
3066
|
+
if (Array.isArray(c) && (c[0] === 'param' || c[0] === 'result')) return // typed → skip
|
|
3067
|
+
break
|
|
3068
|
+
}
|
|
3069
|
+
if (node.length - bi !== 1) return
|
|
3070
|
+
const loop = node[bi]
|
|
3071
|
+
if (!Array.isArray(loop) || loop[0] !== 'loop') return
|
|
3072
|
+
let li = 1, loopLabel = null
|
|
3073
|
+
if (typeof loop[1] === 'string' && loop[1][0] === '$') { loopLabel = loop[1]; li = 2 }
|
|
3074
|
+
const loopHeader = []
|
|
3075
|
+
while (li < loop.length) {
|
|
3076
|
+
const c = loop[li]
|
|
3077
|
+
if (Array.isArray(c) && c[0] === 'type') { loopHeader.push(c); li++; continue }
|
|
3078
|
+
if (Array.isArray(c) && (c[0] === 'param' || c[0] === 'result')) return // typed → skip
|
|
3079
|
+
break
|
|
3080
|
+
}
|
|
3081
|
+
const body = loop.slice(li)
|
|
3082
|
+
if (body.length < 2) return
|
|
3083
|
+
const head = body[0]
|
|
3084
|
+
const tail = body[body.length - 1]
|
|
3085
|
+
if (!Array.isArray(head) || head[0] !== 'br_if' || head[1] !== label || head.length !== 3) return
|
|
3086
|
+
if (!Array.isArray(tail) || tail[0] !== 'br' || tail[1] !== loopLabel || tail.length !== 2) return
|
|
3087
|
+
const inner = body.slice(1, -1)
|
|
3088
|
+
if (targetsLabel(inner, label)) return
|
|
3089
|
+
|
|
3090
|
+
// br_if exits when `cond` is non-zero — `if`'s then-arm runs when its
|
|
3091
|
+
// condition is non-zero. So the if-condition is the negation. Strip a
|
|
3092
|
+
// wrapping `i32.eqz` if present; otherwise wrap.
|
|
3093
|
+
let cond = head[2]
|
|
3094
|
+
if (Array.isArray(cond) && cond[0] === 'i32.eqz' && cond.length === 2) cond = cond[1]
|
|
3095
|
+
else cond = ['i32.eqz', cond]
|
|
3096
|
+
|
|
3097
|
+
const newLoop = ['loop']
|
|
3098
|
+
if (loopLabel) newLoop.push(loopLabel)
|
|
3099
|
+
for (const h of loopHeader) newLoop.push(h)
|
|
3100
|
+
newLoop.push(['if', cond, ['then', ...inner, tail]])
|
|
3101
|
+
|
|
3102
|
+
node.length = 0
|
|
3103
|
+
for (const tok of newLoop) node.push(tok)
|
|
3104
|
+
})
|
|
3105
|
+
return ast
|
|
3106
|
+
}
|
|
3107
|
+
|
|
3108
|
+
// ==================== STRIP MUT FROM GLOBALS ====================
|
|
3109
|
+
|
|
3110
|
+
/**
|
|
3111
|
+
* Strip mutability from globals that are never written.
|
|
3112
|
+
* Enables globals constant-propagation for more globals.
|
|
3113
|
+
* @param {Array} ast
|
|
3114
|
+
* @returns {Array}
|
|
3115
|
+
*/
|
|
3116
|
+
const stripmut = (ast) => {
|
|
3117
|
+
if (!Array.isArray(ast) || ast[0] !== 'module') return ast
|
|
3118
|
+
|
|
3119
|
+
const written = new Set()
|
|
3120
|
+
walk(ast, (n) => {
|
|
3121
|
+
if (Array.isArray(n) && n[0] === 'global.set' && typeof n[1] === 'string') written.add(n[1])
|
|
3122
|
+
})
|
|
3123
|
+
|
|
3124
|
+
return walkPost(ast, (node) => {
|
|
3125
|
+
if (!Array.isArray(node) || node[0] !== 'global') return
|
|
3126
|
+
const name = typeof node[1] === 'string' && node[1][0] === '$' ? node[1] : null
|
|
3127
|
+
if (!name || written.has(name)) return
|
|
3128
|
+
|
|
3129
|
+
const hasName = typeof node[1] === 'string' && node[1][0] === '$'
|
|
3130
|
+
const typeSlot = hasName ? node[2] : node[1]
|
|
3131
|
+
if (Array.isArray(typeSlot) && typeSlot[0] === 'mut') {
|
|
3132
|
+
const newNode = [...node]
|
|
3133
|
+
newNode[hasName ? 2 : 1] = typeSlot[1] // replace (mut T) with T
|
|
3134
|
+
return newNode
|
|
3135
|
+
}
|
|
3136
|
+
})
|
|
3137
|
+
}
|
|
3138
|
+
|
|
3139
|
+
// ==================== IF-THEN-BR → BR_IF ====================
|
|
3140
|
+
|
|
3141
|
+
/**
|
|
3142
|
+
* Simplify (if cond (then (br $label))) → (br_if $label cond)
|
|
3143
|
+
* and (if cond (then) (else (br $label))) → (br_if $label (i32.eqz cond))
|
|
3144
|
+
* Only when the br is the sole instruction in the arm.
|
|
3145
|
+
* @param {Array} ast
|
|
3146
|
+
* @returns {Array}
|
|
3147
|
+
*/
|
|
3148
|
+
const brif = (ast) => {
|
|
3149
|
+
return walkPost(ast, (node) => {
|
|
3150
|
+
if (!Array.isArray(node) || node[0] !== 'if') return
|
|
3151
|
+
const { cond, thenBranch, elseBranch } = parseIf(node)
|
|
3152
|
+
const thenEmpty = !thenBranch || thenBranch.length <= 1
|
|
3153
|
+
const elseEmpty = !elseBranch || elseBranch.length <= 1
|
|
3154
|
+
|
|
3155
|
+
// (if cond (then (br $l))) → (br_if $l cond)
|
|
3156
|
+
if (!thenEmpty && elseEmpty && thenBranch.length === 2) {
|
|
3157
|
+
const t = thenBranch[1]
|
|
3158
|
+
if (Array.isArray(t) && t[0] === 'br' && t.length === 2) return ['br_if', t[1], cond]
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3161
|
+
// (if cond (then) (else (br $l))) → (br_if $l (i32.eqz cond))
|
|
3162
|
+
if (thenEmpty && !elseEmpty && elseBranch.length === 2) {
|
|
3163
|
+
const e = elseBranch[1]
|
|
3164
|
+
if (Array.isArray(e) && e[0] === 'br' && e.length === 2) return ['br_if', e[1], ['i32.eqz', cond]]
|
|
3165
|
+
}
|
|
3166
|
+
})
|
|
3167
|
+
}
|
|
3168
|
+
|
|
3169
|
+
// ==================== MERGE IDENTICAL IF ARMS ====================
|
|
3170
|
+
|
|
3171
|
+
/**
|
|
3172
|
+
* Fold identical trailing code out of if/else arms.
|
|
3173
|
+
* (if cond (then A X) (else B X)) → (if cond (then A) (else B)) X
|
|
3174
|
+
* @param {Array} ast
|
|
3175
|
+
* @returns {Array}
|
|
3176
|
+
*/
|
|
3177
|
+
const foldarms = (ast) => {
|
|
3178
|
+
return walkPost(ast, (node) => {
|
|
3179
|
+
if (!Array.isArray(node) || node[0] !== 'if') return
|
|
3180
|
+
const { thenBranch, elseBranch } = parseIf(node)
|
|
3181
|
+
if (!thenBranch || !elseBranch) return
|
|
3182
|
+
if (thenBranch.length <= 1 || elseBranch.length <= 1) return
|
|
3183
|
+
|
|
3184
|
+
// Only fold when the if has an explicit result type.
|
|
3185
|
+
// Without a result annotation the branches are void; hoisting a suffix
|
|
3186
|
+
// like `drop` can expose a value and leave the if branches ill-typed.
|
|
3187
|
+
const hasResult = node.some(c => Array.isArray(c) && c[0] === 'result')
|
|
3188
|
+
if (!hasResult) return
|
|
3189
|
+
|
|
3190
|
+
let common = 0
|
|
3191
|
+
const minLen = Math.min(thenBranch.length, elseBranch.length)
|
|
3192
|
+
for (let i = 1; i < minLen; i++) {
|
|
3193
|
+
if (!equal(thenBranch[thenBranch.length - i], elseBranch[elseBranch.length - i])) break
|
|
3194
|
+
common++
|
|
3195
|
+
}
|
|
3196
|
+
if (common === 0) return
|
|
3197
|
+
|
|
3198
|
+
const hoisted = thenBranch.slice(thenBranch.length - common)
|
|
3199
|
+
const newThen = thenBranch.slice(0, thenBranch.length - common)
|
|
3200
|
+
const newElse = elseBranch.slice(0, elseBranch.length - common)
|
|
3201
|
+
|
|
3202
|
+
const block = ['block']
|
|
3203
|
+
for (let i = 1; i < node.length; i++) {
|
|
3204
|
+
const c = node[i]
|
|
3205
|
+
if (Array.isArray(c) && (c[0] === 'then' || c[0] === 'else')) break
|
|
3206
|
+
if (Array.isArray(c) && (c[0] === 'result' || c[0] === 'type')) block.push(c)
|
|
3207
|
+
}
|
|
3208
|
+
|
|
3209
|
+
// Inner if becomes void: the result/type annotation now lives on the outer block,
|
|
3210
|
+
// since the value-producing trailing instructions have moved there. Without
|
|
3211
|
+
// stripping, the inner (if (result f64)) claims to produce f64 from branches
|
|
3212
|
+
// whose trailing value-producing instructions just got hoisted out — invalid.
|
|
3213
|
+
const newIf = ['if']
|
|
3214
|
+
for (let i = 1; i < node.length; i++) {
|
|
3215
|
+
const c = node[i]
|
|
3216
|
+
if (Array.isArray(c) && (c[0] === 'then' || c[0] === 'else')) break
|
|
3217
|
+
if (Array.isArray(c) && (c[0] === 'result' || c[0] === 'type')) continue
|
|
3218
|
+
newIf.push(c)
|
|
3219
|
+
}
|
|
3220
|
+
newIf.push(newThen.length > 1 ? newThen : ['then'])
|
|
3221
|
+
newIf.push(newElse.length > 1 ? newElse : ['else'])
|
|
3222
|
+
|
|
3223
|
+
block.push(newIf, ...hoisted)
|
|
3224
|
+
return block
|
|
3225
|
+
})
|
|
3226
|
+
}
|
|
3227
|
+
|
|
3228
|
+
// ==================== DUPLICATE FUNCTION ELIMINATION ====================
|
|
3229
|
+
|
|
3230
|
+
/**
|
|
3231
|
+
* Fast structural hash for a function node, normalizing local names.
|
|
3232
|
+
* Uses a stack-based walk to avoid expensive JSON.stringify.
|
|
3233
|
+
*/
|
|
3234
|
+
const hashFunc = (node, localNames) => {
|
|
3235
|
+
const parts = []
|
|
3236
|
+
const stack = [node]
|
|
3237
|
+
while (stack.length) {
|
|
3238
|
+
const v = stack.pop()
|
|
3239
|
+
if (Array.isArray(v)) {
|
|
3240
|
+
stack.push('|')
|
|
3241
|
+
for (let i = v.length - 1; i >= 0; i--) stack.push(v[i])
|
|
3242
|
+
stack.push('[')
|
|
3243
|
+
} else if (typeof v === 'string') {
|
|
3244
|
+
parts.push(localNames.has(v) ? '$__L' : v)
|
|
3245
|
+
} else if (typeof v === 'bigint') {
|
|
3246
|
+
parts.push(v.toString() + 'n')
|
|
3247
|
+
} else if (typeof v === 'number') {
|
|
3248
|
+
parts.push(v.toString())
|
|
3249
|
+
} else if (v === null) {
|
|
3250
|
+
parts.push('null')
|
|
3251
|
+
} else if (v === true) {
|
|
3252
|
+
parts.push('t')
|
|
3253
|
+
} else if (v === false) {
|
|
3254
|
+
parts.push('f')
|
|
3255
|
+
} else {
|
|
3256
|
+
parts.push(String(v))
|
|
3257
|
+
}
|
|
3258
|
+
}
|
|
3259
|
+
return parts.join(',')
|
|
3260
|
+
}
|
|
3261
|
+
|
|
3262
|
+
/**
|
|
3263
|
+
* Eliminate duplicate functions by hashing bodies.
|
|
3264
|
+
* Keeps the first occurrence and redirects all references to it.
|
|
3265
|
+
* @param {Array} ast
|
|
3266
|
+
* @returns {Array}
|
|
3267
|
+
*/
|
|
3268
|
+
const dedupe = (ast) => {
|
|
3269
|
+
if (!Array.isArray(ast) || ast[0] !== 'module') return ast
|
|
3270
|
+
|
|
3271
|
+
// Hash function bodies (normalize local/param names to avoid false negatives)
|
|
3272
|
+
const signatures = new Map() // hash → canonical $name
|
|
3273
|
+
const redirects = new Map() // duplicate $name → canonical $name
|
|
3274
|
+
|
|
3275
|
+
for (const node of ast.slice(1)) {
|
|
3276
|
+
if (!Array.isArray(node) || node[0] !== 'func') continue
|
|
3277
|
+
const name = typeof node[1] === 'string' && node[1][0] === '$' ? node[1] : null
|
|
3278
|
+
if (!name) continue
|
|
3279
|
+
|
|
3280
|
+
// Collect names that are internal to this function: the func name itself,
|
|
3281
|
+
// its params, locals, and any block/loop labels nested in the body. All of
|
|
3282
|
+
// these get normalized to a single token in the hash so that two funcs
|
|
3283
|
+
// differing only in identifier choices still dedupe.
|
|
3284
|
+
const localNames = new Set()
|
|
3285
|
+
if (typeof node[1] === 'string' && node[1][0] === '$') localNames.add(node[1])
|
|
3286
|
+
walk(node, (n) => {
|
|
3287
|
+
if (!Array.isArray(n) || typeof n[1] !== 'string' || n[1][0] !== '$') return
|
|
3288
|
+
const op = n[0]
|
|
3289
|
+
if (op === 'param' || op === 'local' || isBranchScope(op)) {
|
|
3290
|
+
localNames.add(n[1])
|
|
3291
|
+
}
|
|
3292
|
+
})
|
|
3293
|
+
|
|
3294
|
+
const hash = hashFunc(node, localNames)
|
|
3295
|
+
|
|
3296
|
+
if (signatures.has(hash)) {
|
|
3297
|
+
redirects.set(name, signatures.get(hash))
|
|
3298
|
+
} else {
|
|
3299
|
+
signatures.set(hash, name)
|
|
3300
|
+
}
|
|
3301
|
+
}
|
|
3302
|
+
|
|
3303
|
+
if (redirects.size === 0) return ast
|
|
3304
|
+
|
|
3305
|
+
// Rewrite all references: calls, ref.func, elem segments, call_indirect type
|
|
3306
|
+
walkPost(ast, (node) => {
|
|
3307
|
+
if (!Array.isArray(node)) return
|
|
3308
|
+
const op = node[0]
|
|
3309
|
+
if ((op === 'call' || op === 'return_call') && redirects.has(node[1])) {
|
|
3310
|
+
return [op, redirects.get(node[1]), ...node.slice(2)]
|
|
3311
|
+
}
|
|
3312
|
+
if (op === 'ref.func' && redirects.has(node[1])) {
|
|
3313
|
+
return ['ref.func', redirects.get(node[1])]
|
|
3314
|
+
}
|
|
3315
|
+
if (op === 'elem') {
|
|
3316
|
+
const funcs = node[node.length - 1]
|
|
3317
|
+
if (Array.isArray(funcs)) {
|
|
3318
|
+
return [...node.slice(0, -1), funcs.map(f => redirects.get(f) || f)]
|
|
3319
|
+
}
|
|
3320
|
+
}
|
|
3321
|
+
if (op === 'call_indirect' && node.length >= 3) {
|
|
3322
|
+
const typeRef = node[1]
|
|
3323
|
+
if (typeof typeRef === 'string' && redirects.has(typeRef)) {
|
|
3324
|
+
return ['call_indirect', redirects.get(typeRef), ...node.slice(2)]
|
|
3325
|
+
}
|
|
3326
|
+
}
|
|
3327
|
+
})
|
|
3328
|
+
|
|
3329
|
+
return ast
|
|
3330
|
+
}
|
|
3331
|
+
|
|
3332
|
+
// ==================== TYPE DEDUPLICATION ====================
|
|
3333
|
+
|
|
3334
|
+
/**
|
|
3335
|
+
* Merge structurally identical (type ...) definitions.
|
|
3336
|
+
* Keeps the first occurrence and redirects all references.
|
|
3337
|
+
* @param {Array} ast
|
|
3338
|
+
* @returns {Array}
|
|
3339
|
+
*/
|
|
3340
|
+
const dedupTypes = (ast) => {
|
|
3341
|
+
if (!Array.isArray(ast) || ast[0] !== 'module') return ast
|
|
3342
|
+
|
|
3343
|
+
const signatures = new Map() // hash → canonical $name
|
|
3344
|
+
const redirects = new Map() // duplicate $name → canonical $name
|
|
3345
|
+
|
|
3346
|
+
for (const node of ast.slice(1)) {
|
|
3347
|
+
if (!Array.isArray(node) || node[0] !== 'type') continue
|
|
3348
|
+
const name = typeof node[1] === 'string' && node[1][0] === '$' ? node[1] : null
|
|
3349
|
+
if (!name) continue
|
|
3350
|
+
|
|
3351
|
+
// Hash the type body, normalizing only the type's own name
|
|
3352
|
+
const hash = hashFunc(node, new Set([name]))
|
|
3353
|
+
|
|
3354
|
+
if (signatures.has(hash)) {
|
|
3355
|
+
redirects.set(name, signatures.get(hash))
|
|
3356
|
+
} else {
|
|
3357
|
+
signatures.set(hash, name)
|
|
3358
|
+
}
|
|
3359
|
+
}
|
|
3360
|
+
|
|
3361
|
+
if (redirects.size === 0) return ast
|
|
3362
|
+
|
|
3363
|
+
// Remove duplicate type nodes
|
|
3364
|
+
for (let i = ast.length - 1; i >= 0; i--) {
|
|
3365
|
+
const node = ast[i]
|
|
3366
|
+
if (Array.isArray(node) && node[0] === 'type') {
|
|
3367
|
+
const name = typeof node[1] === 'string' && node[1][0] === '$' ? node[1] : null
|
|
3368
|
+
if (name && redirects.has(name)) ast.splice(i, 1)
|
|
3369
|
+
}
|
|
3370
|
+
}
|
|
3371
|
+
|
|
3372
|
+
walkPost(ast, (node) => {
|
|
3373
|
+
if (!Array.isArray(node)) return
|
|
3374
|
+
const op = node[0]
|
|
3375
|
+
|
|
3376
|
+
// (func $f (type $t) ...)
|
|
3377
|
+
if (op === 'func') {
|
|
3378
|
+
for (let i = 1; i < node.length; i++) {
|
|
3379
|
+
const sub = node[i]
|
|
3380
|
+
if (Array.isArray(sub) && sub[0] === 'type' && typeof sub[1] === 'string' && redirects.has(sub[1])) {
|
|
3381
|
+
node[i] = ['type', redirects.get(sub[1])]
|
|
3382
|
+
}
|
|
3383
|
+
}
|
|
3384
|
+
}
|
|
3385
|
+
|
|
3386
|
+
// (import "m" "n" (func (type $t)))
|
|
3387
|
+
if (op === 'import') {
|
|
3388
|
+
for (let i = 1; i < node.length; i++) {
|
|
3389
|
+
const sub = node[i]
|
|
3390
|
+
if (Array.isArray(sub)) {
|
|
3391
|
+
for (let j = 1; j < sub.length; j++) {
|
|
3392
|
+
const inner = sub[j]
|
|
3393
|
+
if (Array.isArray(inner) && inner[0] === 'type' && typeof inner[1] === 'string' && redirects.has(inner[1])) {
|
|
3394
|
+
sub[j] = ['type', redirects.get(inner[1])]
|
|
3395
|
+
}
|
|
3396
|
+
}
|
|
3397
|
+
}
|
|
3398
|
+
}
|
|
3399
|
+
}
|
|
3400
|
+
|
|
3401
|
+
// call_indirect $t or (call_indirect (type $t) ...)
|
|
3402
|
+
if (op === 'call_indirect' || op === 'return_call_indirect') {
|
|
3403
|
+
if (typeof node[1] === 'string' && redirects.has(node[1])) {
|
|
3404
|
+
return [op, redirects.get(node[1]), ...node.slice(2)]
|
|
3405
|
+
}
|
|
3406
|
+
if (Array.isArray(node[1]) && node[1][0] === 'type' && typeof node[1][1] === 'string' && redirects.has(node[1][1])) {
|
|
3407
|
+
return [op, ['type', redirects.get(node[1][1])], ...node.slice(2)]
|
|
3408
|
+
}
|
|
3409
|
+
}
|
|
3410
|
+
})
|
|
3411
|
+
|
|
3412
|
+
return ast
|
|
3413
|
+
}
|
|
3414
|
+
|
|
3415
|
+
// ==================== DATA SEGMENT PACKING ====================
|
|
3416
|
+
|
|
3417
|
+
/** Parse a WAT data string literal into a plain byte array. Plain arrays —
|
|
3418
|
+
* not Uint8Array — throughout the data codecs: typed-array views/methods have
|
|
3419
|
+
* spotty native lowerings (subarray dispatches to the HOST, in-situ variable-
|
|
3420
|
+
* index reads misread in the kernel), while plain number arrays are the
|
|
3421
|
+
* optimizer's lingua franca and proven kernel-faithful. */
|
|
3422
|
+
const parseDataString = (str) => {
|
|
3423
|
+
if (typeof str !== 'string' || str.length < 2 || str[0] !== '"') return []
|
|
3424
|
+
const bytes = []
|
|
3425
|
+
// Hex digit value by char code, −1 for non-hex — pure number math (no
|
|
3426
|
+
// regex/slice/parseInt on string views; see contract note above).
|
|
3427
|
+
const hexv = (c) => c >= 48 && c <= 57 ? c - 48 : c >= 97 && c <= 102 ? c - 87 : c >= 65 && c <= 70 ? c - 55 : -1
|
|
3428
|
+
const end = str.length - 1 // skip surrounding quotes
|
|
3429
|
+
for (let i = 1; i < end; i++) {
|
|
3430
|
+
const c = str.charCodeAt(i)
|
|
3431
|
+
if (c !== 92) { bytes.push(c); continue }
|
|
3432
|
+
const n = str.charCodeAt(++i)
|
|
3433
|
+
if (n === 120 || n === 88) { // \xHH
|
|
3434
|
+
bytes.push((hexv(str.charCodeAt(i + 1)) << 4) | hexv(str.charCodeAt(i + 2)))
|
|
3435
|
+
i += 2
|
|
3436
|
+
} else {
|
|
3437
|
+
const h1 = hexv(n), h2 = i + 1 < end ? hexv(str.charCodeAt(i + 1)) : -1
|
|
3438
|
+
if (h1 >= 0 && h2 >= 0) { bytes.push((h1 << 4) | h2); i++ }
|
|
3439
|
+
else if (n === 110) bytes.push(10) // \n
|
|
3440
|
+
else if (n === 116) bytes.push(9) // \t
|
|
3441
|
+
else if (n === 114) bytes.push(13) // \r
|
|
3442
|
+
else bytes.push(n) // \\ \" and any other escaped char
|
|
3443
|
+
}
|
|
3444
|
+
}
|
|
3445
|
+
return bytes
|
|
3446
|
+
}
|
|
3447
|
+
|
|
3448
|
+
/** Encode a plain byte array as a WAT data string literal; `end` bounds the
|
|
3449
|
+
* bytes (always passed explicitly — see parseDataString's contract note).
|
|
3450
|
+
* (`b` comes from a plain-array element read, i.e. an untyped receiver — the
|
|
3451
|
+
* `.toString(16)` here is exactly the dispatch the tryRuntimeNumberMethod /
|
|
3452
|
+
* runtime-string-fork number arm exists for; it used to yield `undefined`
|
|
3453
|
+
* in-kernel and zeroed every escaped byte of the emitted data segment.) */
|
|
3454
|
+
const encodeDataString = (bytes, end) => {
|
|
3455
|
+
let str = '"'
|
|
3456
|
+
for (let i = 0; i < end; i++) {
|
|
3457
|
+
const b = bytes[i]
|
|
3458
|
+
if (b >= 32 && b < 127 && b !== 34 && b !== 92) str += String.fromCharCode(b)
|
|
3459
|
+
else str += '\\' + b.toString(16).padStart(2, '0')
|
|
3460
|
+
}
|
|
3461
|
+
return str + '"'
|
|
3462
|
+
}
|
|
3463
|
+
|
|
3464
|
+
/** Trim trailing zeros from data content items. Per-byte pushes (never
|
|
3465
|
+
* push(...spread) — a segment can be hundreds of KB and spreading overflows
|
|
3466
|
+
* V8's argument stack; never Uint8Array — see parseDataString's note). */
|
|
3467
|
+
const trimTrailingZeros = (items) => {
|
|
3468
|
+
const bytes = []
|
|
3469
|
+
for (const item of items) {
|
|
3470
|
+
if (typeof item === 'string') {
|
|
3471
|
+
const chunk = parseDataString(item)
|
|
3472
|
+
for (let i = 0; i < chunk.length; i++) bytes.push(chunk[i])
|
|
3473
|
+
} else if (Array.isArray(item) && item[0] === 'i8') {
|
|
3474
|
+
for (let i = 1; i < item.length; i++) bytes.push(Number(item[i]) & 0xff)
|
|
3475
|
+
} else {
|
|
3476
|
+
return items // non-trimmable item
|
|
3477
|
+
}
|
|
3478
|
+
}
|
|
3479
|
+
let end = bytes.length
|
|
3480
|
+
while (end > 0 && bytes[end - 1] === 0) end--
|
|
3481
|
+
if (end === bytes.length) return items
|
|
3482
|
+
if (end === 0) return []
|
|
3483
|
+
return [encodeDataString(bytes, end)]
|
|
3484
|
+
}
|
|
3485
|
+
|
|
3486
|
+
/** Extract { memidx, offset } from an active data segment with constant offset */
|
|
3487
|
+
const getDataOffset = (node) => {
|
|
3488
|
+
let idx = 1
|
|
3489
|
+
if (typeof node[idx] === 'string' && node[idx][0] === '$') idx++
|
|
3490
|
+
if (Array.isArray(node[idx]) && node[idx][0] === 'memory') {
|
|
3491
|
+
const mem = node[idx][1]
|
|
3492
|
+
idx++
|
|
3493
|
+
const off = node[idx]
|
|
3494
|
+
if (Array.isArray(off) && (off[0] === 'i32.const' || off[0] === 'i64.const')) {
|
|
3495
|
+
return { memidx: mem, offset: Number(off[1]) }
|
|
3496
|
+
}
|
|
3497
|
+
return null
|
|
3498
|
+
}
|
|
3499
|
+
const off = node[idx]
|
|
3500
|
+
if (Array.isArray(off) && (off[0] === 'i32.const' || off[0] === 'i64.const')) {
|
|
3501
|
+
return { memidx: 0, offset: Number(off[1]) }
|
|
3502
|
+
}
|
|
3503
|
+
return null
|
|
3504
|
+
}
|
|
3505
|
+
|
|
3506
|
+
/** Get byte length of data segment content */
|
|
3507
|
+
const getDataLength = (node) => {
|
|
3508
|
+
let idx = 1
|
|
3509
|
+
if (typeof node[idx] === 'string' && node[idx][0] === '$') idx++
|
|
3510
|
+
if (Array.isArray(node[idx]) && node[idx][0] === 'memory') idx++
|
|
3511
|
+
if (Array.isArray(node[idx]) && typeof node[idx][0] === 'string' && !node[idx][0].startsWith('"')) idx++
|
|
3512
|
+
let len = 0
|
|
3513
|
+
for (let i = idx; i < node.length; i++) {
|
|
3514
|
+
const item = node[i]
|
|
3515
|
+
if (typeof item === 'string') len += parseDataString(item).length
|
|
3516
|
+
else if (Array.isArray(item) && item[0] === 'i8') len += item.length - 1
|
|
3517
|
+
else return null
|
|
3518
|
+
}
|
|
3519
|
+
return len
|
|
3520
|
+
}
|
|
3521
|
+
|
|
3522
|
+
/** Merge segment b into a (consecutive offsets, same memory) */
|
|
3523
|
+
const mergeDataSegments = (a, b) => {
|
|
3524
|
+
let aIdx = 1
|
|
3525
|
+
if (typeof a[aIdx] === 'string' && a[aIdx][0] === '$') aIdx++
|
|
3526
|
+
if (Array.isArray(a[aIdx]) && a[aIdx][0] === 'memory') aIdx++
|
|
3527
|
+
if (Array.isArray(a[aIdx]) && typeof a[aIdx][0] === 'string' && !a[aIdx][0].startsWith('"')) aIdx++
|
|
3528
|
+
|
|
3529
|
+
let bIdx = 1
|
|
3530
|
+
if (typeof b[bIdx] === 'string' && b[bIdx][0] === '$') bIdx++
|
|
3531
|
+
if (Array.isArray(b[bIdx]) && b[bIdx][0] === 'memory') bIdx++
|
|
3532
|
+
if (Array.isArray(b[bIdx]) && typeof b[bIdx][0] === 'string' && !b[bIdx][0].startsWith('"')) bIdx++
|
|
3533
|
+
|
|
3534
|
+
const aContent = a.slice(aIdx)
|
|
3535
|
+
const bContent = b.slice(bIdx)
|
|
3536
|
+
|
|
3537
|
+
if (aContent.length === 1 && bContent.length === 1 &&
|
|
3538
|
+
typeof aContent[0] === 'string' && typeof bContent[0] === 'string') {
|
|
3539
|
+
const aBytes = parseDataString(aContent[0])
|
|
3540
|
+
const bBytes = parseDataString(bContent[0])
|
|
3541
|
+
for (let i = 0; i < bBytes.length; i++) aBytes.push(bBytes[i])
|
|
3542
|
+
a.length = aIdx
|
|
3543
|
+
a.push(encodeDataString(aBytes, aBytes.length))
|
|
3544
|
+
return true
|
|
3545
|
+
}
|
|
3546
|
+
|
|
3547
|
+
a.length = aIdx
|
|
3548
|
+
a.push(...aContent, ...bContent)
|
|
3549
|
+
return true
|
|
3550
|
+
}
|
|
3551
|
+
|
|
3552
|
+
/**
|
|
3553
|
+
* Pack data segments: trim trailing zeros and merge adjacent constant-offset segments.
|
|
3554
|
+
* @param {Array} ast
|
|
3555
|
+
* @returns {Array}
|
|
3556
|
+
*/
|
|
3557
|
+
const packData = (ast) => {
|
|
3558
|
+
if (!Array.isArray(ast) || ast[0] !== 'module') return ast
|
|
3559
|
+
|
|
3560
|
+
// Trim trailing zeros
|
|
3561
|
+
for (const node of ast) {
|
|
3562
|
+
if (!Array.isArray(node) || node[0] !== 'data') continue
|
|
3563
|
+
let contentStart = 1
|
|
3564
|
+
if (typeof node[1] === 'string' && node[1][0] === '$') contentStart = 2
|
|
3565
|
+
if (contentStart < node.length && Array.isArray(node[contentStart]) &&
|
|
3566
|
+
typeof node[contentStart][0] === 'string' && !node[contentStart][0].startsWith('"')) {
|
|
3567
|
+
contentStart++
|
|
3568
|
+
}
|
|
3569
|
+
const content = node.slice(contentStart)
|
|
3570
|
+
if (content.length === 0) continue
|
|
3571
|
+
const trimmed = trimTrailingZeros(content)
|
|
3572
|
+
if (trimmed.length !== content.length || (trimmed.length > 0 && trimmed[0] !== content[0])) {
|
|
3573
|
+
node.length = contentStart
|
|
3574
|
+
node.push(...trimmed)
|
|
3575
|
+
}
|
|
3576
|
+
}
|
|
3577
|
+
|
|
3578
|
+
// Merge adjacent active segments with same memory and consecutive offsets
|
|
3579
|
+
const dataNodes = []
|
|
3580
|
+
for (let i = 0; i < ast.length; i++) {
|
|
3581
|
+
const node = ast[i]
|
|
3582
|
+
if (Array.isArray(node) && node[0] === 'data') {
|
|
3583
|
+
const info = getDataOffset(node)
|
|
3584
|
+
if (info) {
|
|
3585
|
+
const len = getDataLength(node)
|
|
3586
|
+
if (len !== null) dataNodes.push({ ...info, node, index: i, len })
|
|
3587
|
+
}
|
|
3588
|
+
}
|
|
3589
|
+
}
|
|
3590
|
+
|
|
3591
|
+
dataNodes.sort((a, b) => {
|
|
3592
|
+
const ma = String(a.memidx), mb = String(b.memidx)
|
|
3593
|
+
if (ma !== mb) return ma.localeCompare(mb)
|
|
3594
|
+
return a.offset - b.offset
|
|
3595
|
+
})
|
|
3596
|
+
|
|
3597
|
+
const toRemove = new Set()
|
|
3598
|
+
for (let i = 0; i < dataNodes.length - 1; i++) {
|
|
3599
|
+
const a = dataNodes[i]
|
|
3600
|
+
const b = dataNodes[i + 1]
|
|
3601
|
+
if (toRemove.has(a.index) || String(a.memidx) !== String(b.memidx)) continue
|
|
3602
|
+
if (a.offset + a.len !== b.offset) continue
|
|
3603
|
+
if (mergeDataSegments(a.node, b.node)) {
|
|
3604
|
+
toRemove.add(b.index)
|
|
3605
|
+
a.len = getDataLength(a.node)
|
|
3606
|
+
}
|
|
3607
|
+
}
|
|
3608
|
+
|
|
3609
|
+
if (toRemove.size > 0) {
|
|
3610
|
+
ast = ast.filter((_, i) => !toRemove.has(i))
|
|
3611
|
+
}
|
|
3612
|
+
|
|
3613
|
+
return ast
|
|
3614
|
+
}
|
|
3615
|
+
|
|
3616
|
+
// ==================== IMPORT FIELD MINIFICATION ====================
|
|
3617
|
+
|
|
3618
|
+
/** Create a shortener that maps names to a, b, ..., z, aa, ab, ... */
|
|
3619
|
+
const makeShortener = () => {
|
|
3620
|
+
const map = new Map()
|
|
3621
|
+
let n = 0
|
|
3622
|
+
return (name) => {
|
|
3623
|
+
if (!map.has(name)) {
|
|
3624
|
+
let id = '', x = n++
|
|
3625
|
+
do {
|
|
3626
|
+
id = String.fromCharCode(97 + (x % 26)) + id
|
|
3627
|
+
x = Math.floor(x / 26) - 1
|
|
3628
|
+
} while (x >= 0)
|
|
3629
|
+
map.set(name, id || 'a')
|
|
3630
|
+
}
|
|
3631
|
+
return map.get(name)
|
|
3632
|
+
}
|
|
3633
|
+
}
|
|
3634
|
+
|
|
3635
|
+
/**
|
|
3636
|
+
* Minify import module and field names for smaller binaries.
|
|
3637
|
+
* Only safe when you control the host environment.
|
|
3638
|
+
* @param {Array} ast
|
|
3639
|
+
* @returns {Array}
|
|
3640
|
+
*/
|
|
3641
|
+
const minifyImports = (ast) => {
|
|
3642
|
+
if (!Array.isArray(ast) || ast[0] !== 'module') return ast
|
|
3643
|
+
const shortMod = makeShortener()
|
|
3644
|
+
const shortField = makeShortener()
|
|
3645
|
+
|
|
3646
|
+
for (const node of ast) {
|
|
3647
|
+
if (!Array.isArray(node) || node[0] !== 'import') continue
|
|
3648
|
+
if (typeof node[1] === 'string' && node[1][0] === '"') {
|
|
3649
|
+
node[1] = '"' + shortMod(node[1].slice(1, -1)) + '"'
|
|
3650
|
+
}
|
|
3651
|
+
if (typeof node[2] === 'string' && node[2][0] === '"') {
|
|
3652
|
+
node[2] = '"' + shortField(node[2].slice(1, -1)) + '"'
|
|
3653
|
+
}
|
|
3654
|
+
}
|
|
3655
|
+
|
|
3656
|
+
return ast
|
|
3657
|
+
}
|
|
3658
|
+
|
|
3659
|
+
// ==================== REORDER FUNCTIONS ====================
|
|
3660
|
+
|
|
3661
|
+
/**
|
|
3662
|
+
* Count direct calls and sort functions so hot ones come first.
|
|
3663
|
+
* Smaller LEB128 indices for frequent calls reduce binary size.
|
|
3664
|
+
* Imports must stay before defined functions to preserve the index space.
|
|
3665
|
+
* @param {Array} ast
|
|
3666
|
+
* @returns {Array}
|
|
3667
|
+
*/
|
|
3668
|
+
/** True iff every defined func has a $name and every func reference is by $name */
|
|
3669
|
+
const reorderSafe = (ast) => {
|
|
3670
|
+
let safe = true
|
|
3671
|
+
walk(ast, (n) => {
|
|
3672
|
+
if (!safe || !Array.isArray(n)) return
|
|
3673
|
+
const op = n[0]
|
|
3674
|
+
if (op === 'func' && (typeof n[1] !== 'string' || n[1][0] !== '$')) safe = false
|
|
3675
|
+
else if ((op === 'call' || op === 'return_call' || op === 'ref.func') &&
|
|
3676
|
+
(typeof n[1] !== 'string' || n[1][0] !== '$')) safe = false
|
|
3677
|
+
else if (op === 'start' && (typeof n[1] !== 'string' || n[1][0] !== '$')) safe = false
|
|
3678
|
+
else if (op === 'elem') {
|
|
3679
|
+
// Numeric func indices in elem segments would break too
|
|
3680
|
+
for (const sub of n) {
|
|
3681
|
+
if (typeof sub === 'string' && sub[0] !== '$' && /^\d/.test(sub)) { safe = false; break }
|
|
3682
|
+
if (Array.isArray(sub) && sub[0] === 'ref.func' &&
|
|
3683
|
+
(typeof sub[1] !== 'string' || sub[1][0] !== '$')) { safe = false; break }
|
|
3684
|
+
}
|
|
3685
|
+
}
|
|
3686
|
+
})
|
|
3687
|
+
return safe
|
|
3688
|
+
}
|
|
3689
|
+
|
|
3690
|
+
const reorder = (ast) => {
|
|
3691
|
+
if (!Array.isArray(ast) || ast[0] !== 'module') return ast
|
|
3692
|
+
// Sorting changes the function index space. Skip if any reference is numeric,
|
|
3693
|
+
// since we'd silently retarget unnamed callers/start/elem entries.
|
|
3694
|
+
if (!reorderSafe(ast)) return ast
|
|
3695
|
+
|
|
3696
|
+
const callCounts = new Map()
|
|
3697
|
+
walk(ast, (n) => {
|
|
3698
|
+
if (!Array.isArray(n)) return
|
|
3699
|
+
if (n[0] === 'call' || n[0] === 'return_call') {
|
|
3700
|
+
callCounts.set(n[1], (callCounts.get(n[1]) || 0) + 1)
|
|
3701
|
+
}
|
|
3702
|
+
})
|
|
3703
|
+
|
|
3704
|
+
// Imports must precede defined funcs (compile.js assigns indices in AST order).
|
|
3705
|
+
const imports = [], funcs = [], others = []
|
|
3706
|
+
for (const node of ast.slice(1)) {
|
|
3707
|
+
if (!Array.isArray(node)) { others.push(node); continue }
|
|
3708
|
+
if (node[0] === 'import') imports.push(node)
|
|
3709
|
+
else if (node[0] === 'func') funcs.push(node)
|
|
3710
|
+
else others.push(node)
|
|
3711
|
+
}
|
|
3712
|
+
|
|
3713
|
+
funcs.sort((a, b) => (callCounts.get(b[1]) || 0) - (callCounts.get(a[1]) || 0))
|
|
3714
|
+
return ['module', ...imports, ...funcs, ...others]
|
|
3715
|
+
}
|
|
3716
|
+
|
|
3717
|
+
// ==================== MAIN ====================
|
|
3718
|
+
|
|
3719
|
+
/**
|
|
3720
|
+
* Optimization passes, in the order they run within each round. Each entry is
|
|
3721
|
+
* `[optionKey, fn, defaultOn, doc]` — the single source of truth that the
|
|
3722
|
+
* dispatch loop, the `OPTS` catalogue, and `normalize` all derive from.
|
|
3723
|
+
* Passes that are off by default can bloat output or are expensive — opt-in.
|
|
3724
|
+
*/
|
|
3725
|
+
const PASSES = [
|
|
3726
|
+
['stripmut', stripmut, true, 'strip mut from never-written globals'],
|
|
3727
|
+
['globals', globals, true, 'propagate immutable global constants'],
|
|
3728
|
+
['guardRefine', guardRefine, true, 'fold NaN-box tag reads under dominating tag guards'],
|
|
3729
|
+
['fold', fold, true, 'constant folding'],
|
|
3730
|
+
['identity', identity, true, 'remove identity ops (x + 0 → x)'],
|
|
3731
|
+
['peephole', peephole, true, 'x-x→0, x&0→0, etc.'],
|
|
3732
|
+
['strength', strength, true, 'strength reduction (x * 2 → x << 1)'],
|
|
3733
|
+
['branch', branch, true, 'simplify constant branches'],
|
|
3734
|
+
['propagate', propagate, true, 'forward-propagate single-use locals & tiny consts (never inflates)'],
|
|
3735
|
+
['devirt', devirt, false, 'call_indirect with known closure constants → guarded direct calls — grows bytes for speed'],
|
|
3736
|
+
['inlineOnce', inlineOnce, true, 'inline single-call functions into their lone caller (never duplicates)'],
|
|
3737
|
+
['inline', inline, false, 'inline tiny functions — can duplicate bodies'],
|
|
3738
|
+
['offset', offset, true, 'fold add+const into load/store offset'],
|
|
3739
|
+
['unbranch', unbranch, true, 'remove redundant br at end of own block'],
|
|
3740
|
+
['loopify', loopify, true, 'collapse block+loop+brif while-idiom into loop+if'],
|
|
3741
|
+
['brif', brif, true, 'if-then-br → br_if'],
|
|
3742
|
+
['foldarms', foldarms, false, 'merge identical trailing if arms — can add block wrapper'],
|
|
3743
|
+
['deadcode', deadcode, true, 'eliminate dead code after unreachable/br/return'],
|
|
3744
|
+
['vacuum', vacuum, true, 'remove nops, drop-of-pure, empty branches'],
|
|
3745
|
+
['mergeBlocks', mergeBlocks, true, 'unwrap `(block $L …)` whose label is never targeted'],
|
|
3746
|
+
['coalesce', coalesceLocals, true, 'share local slots between same-type non-overlapping locals'],
|
|
3747
|
+
['locals', localReuse, true, 'remove unused locals'],
|
|
3748
|
+
['dedupe', dedupe, true, 'eliminate duplicate functions'],
|
|
3749
|
+
['dedupTypes', dedupTypes, true, 'merge identical type definitions'],
|
|
3750
|
+
['packData', packData, true, 'trim trailing zeros, merge adjacent data segments'],
|
|
3751
|
+
['reorder', reorder, false, 'put hot functions first — no AST reduction'],
|
|
3752
|
+
['treeshake', treeshake, true, 'remove unused funcs/globals/types/tables'],
|
|
3753
|
+
['minifyImports', minifyImports, false, 'shorten import names — enable only when you control the host'],
|
|
3754
|
+
]
|
|
3755
|
+
|
|
3756
|
+
/** Option name → default-on map — the public catalogue of passes. */
|
|
3757
|
+
const OPTS = Object.fromEntries(PASSES.map(p => [p[0], p[2]]))
|
|
3758
|
+
|
|
3759
|
+
/**
|
|
3760
|
+
* Normalize options to a { passName: bool } map. An explicit object is kept
|
|
3761
|
+
* as-is (preserving `log`/`verbose`), with any unmentioned pass filled to its
|
|
3762
|
+
* default; `true` selects the defaults; a string selects only the named
|
|
3763
|
+
* passes (or all of them via `'all'`).
|
|
3764
|
+
*
|
|
3765
|
+
* @param {boolean|string|Object} opts
|
|
3766
|
+
* @returns {Object}
|
|
3767
|
+
*/
|
|
3768
|
+
const normalize = (opts) => {
|
|
3769
|
+
if (opts === false) return {}
|
|
3770
|
+
if (opts !== true && typeof opts !== 'string') {
|
|
3771
|
+
const m = { ...opts }
|
|
3772
|
+
for (const p of PASSES) if (m[p[0]] === undefined) m[p[0]] = p[2]
|
|
3773
|
+
return m
|
|
3774
|
+
}
|
|
3775
|
+
const set = typeof opts === 'string' ? new Set(opts.split(/\s+/).filter(Boolean)) : null
|
|
3776
|
+
const m = {}
|
|
3777
|
+
for (const p of PASSES) m[p[0]] = set ? (set.has('all') || set.has(p[0])) : p[2]
|
|
3778
|
+
return m
|
|
3779
|
+
}
|
|
3780
|
+
|
|
3781
|
+
/**
|
|
3782
|
+
* Optimize AST.
|
|
3783
|
+
*
|
|
3784
|
+
* @param {Array|string} ast - AST or WAT source
|
|
3785
|
+
* @param {boolean|string|Object} [opts=true] - Optimization options
|
|
3786
|
+
* @returns {Array} Optimized AST
|
|
3787
|
+
*
|
|
3788
|
+
* @example
|
|
3789
|
+
* optimize(ast) // all optimizations
|
|
3790
|
+
* optimize(ast, 'treeshake') // only treeshake
|
|
3791
|
+
* optimize(ast, { fold: true }) // explicit
|
|
3792
|
+
*/
|
|
3793
|
+
/**
|
|
3794
|
+
* Could `inlineOnce`/`inline` grow the binary on this module? They are the only
|
|
3795
|
+
* size-*increasing* passes: splicing a callee body plus its `block`/param-setup
|
|
3796
|
+
* wrapper can exceed the `call` it removes. Every other pass strictly shrinks or
|
|
3797
|
+
* holds. So if no function is even a candidate (called exactly once, not pinned
|
|
3798
|
+
* by export/start/elem/ref.func, not its own exporter), nothing can inflate and
|
|
3799
|
+
* the size guard is dead weight. Cheap over-approximation of inlineOnce's own
|
|
3800
|
+
* gating — a false positive only costs the guarded path (correct, just slower).
|
|
3801
|
+
*/
|
|
3802
|
+
const mayInline = (ast) => {
|
|
3803
|
+
if (!Array.isArray(ast)) return false
|
|
3804
|
+
const callRefs = new Map(), pinned = new Set(), other = new Set()
|
|
3805
|
+
const scan = (n) => {
|
|
3806
|
+
if (!Array.isArray(n)) return
|
|
3807
|
+
const op = n[0]
|
|
3808
|
+
if (op === 'call' && typeof n[1] === 'string') callRefs.set(n[1], (callRefs.get(n[1]) || 0) + 1)
|
|
3809
|
+
else if (op === 'return_call' && typeof n[1] === 'string') other.add(n[1])
|
|
3810
|
+
else if (op === 'ref.func' && typeof n[1] === 'string') pinned.add(n[1])
|
|
3811
|
+
else if (op === 'export' && Array.isArray(n[2]) && n[2][0] === 'func' && typeof n[2][1] === 'string') pinned.add(n[2][1])
|
|
3812
|
+
else if (op === 'start' && typeof n[1] === 'string') pinned.add(n[1])
|
|
3813
|
+
else if (op === 'elem') for (const c of n) if (typeof c === 'string' && c[0] === '$') pinned.add(c)
|
|
3814
|
+
for (let i = 1; i < n.length; i++) scan(n[i])
|
|
3815
|
+
}
|
|
3816
|
+
scan(ast)
|
|
3817
|
+
for (const n of ast) {
|
|
3818
|
+
if (!Array.isArray(n) || n[0] !== 'func' || typeof n[1] !== 'string') continue
|
|
3819
|
+
const name = n[1]
|
|
3820
|
+
if (callRefs.get(name) !== 1 || pinned.has(name) || other.has(name)) continue
|
|
3821
|
+
if (n.some(c => Array.isArray(c) && c[0] === 'export')) continue // self-exporting func
|
|
3822
|
+
return true
|
|
3823
|
+
}
|
|
3824
|
+
return false
|
|
3825
|
+
}
|
|
3826
|
+
|
|
3827
|
+
// A `__start` whose body DCE emptied down to nothing — plus its `(start)`
|
|
3828
|
+
// directive — is pure noise: the directive invokes a no-op. jz's buildStartFn
|
|
3829
|
+
// only emits `__start` when it has content, so an empty one is always a post-DCE
|
|
3830
|
+
// artifact (e.g. a top-level `1 + 2;` whose dropped value the dead-code pass
|
|
3831
|
+
// removed). Drop both. Header nodes (param/result/local/export/type) don't count
|
|
3832
|
+
// as a body — a function carrying only locals still does nothing.
|
|
3833
|
+
const START_HEAD = new Set(['export', 'type', 'param', 'result', 'local'])
|
|
3834
|
+
function pruneEmptyStart(ast) {
|
|
3835
|
+
if (!Array.isArray(ast) || ast[0] !== 'module') return ast
|
|
3836
|
+
const fn = ast.find(n => Array.isArray(n) && n[0] === 'func' && n[1] === '$__start')
|
|
3837
|
+
if (!fn) return ast
|
|
3838
|
+
// Skip the name (index 1) and header nodes; a bare-string node past them is a
|
|
3839
|
+
// real instruction (`drop`/`nop`/`unreachable`), so it stops the scan.
|
|
3840
|
+
let b = 2
|
|
3841
|
+
while (b < fn.length && Array.isArray(fn[b]) && START_HEAD.has(fn[b][0])) b++
|
|
3842
|
+
if (b < fn.length) return ast // real instructions remain
|
|
3843
|
+
return ast.filter(n => !(Array.isArray(n) &&
|
|
3844
|
+
((n[0] === 'func' && n[1] === '$__start') || (n[0] === 'start' && n[1] === '$__start'))))
|
|
3845
|
+
}
|
|
3846
|
+
|
|
3847
|
+
export default function optimize(ast, opts = true) {
|
|
3848
|
+
const strictGuard = opts === true // default: zero tolerance for bloat
|
|
3849
|
+
opts = normalize(opts)
|
|
3850
|
+
|
|
3851
|
+
const log = opts.log ? (msg, delta) => opts.log(msg, delta) : () => {}
|
|
3852
|
+
const verbose = opts.verbose || opts.log
|
|
3853
|
+
|
|
3854
|
+
ast = clone(ast)
|
|
3855
|
+
|
|
3856
|
+
// devirt trades bytes for speed by design (guards + duplicated args), so it
|
|
3857
|
+
// runs ONCE after the rounds — its candidate shape (select of two i64 closure
|
|
3858
|
+
// constants) only emerges from fold/propagate, and its intended growth must
|
|
3859
|
+
// not trip the size-guard into reverting a whole round. A single sweep is
|
|
3860
|
+
// complete: every call_indirect is visited; rewritten sites keep the original
|
|
3861
|
+
// as the guarded fallback arm.
|
|
3862
|
+
// `inline` (multi-caller, size-for-speed) is like `devirt`: it INTENTIONALLY grows
|
|
3863
|
+
// the binary, so it must run OUTSIDE the per-round size-revert guard below (which
|
|
3864
|
+
// would otherwise undo it). Run it once after the rounds converge, then tidy the
|
|
3865
|
+
// (block (local.set $p arg) … body) wrappers it leaves with the same cleanup passes
|
|
3866
|
+
// a normal round would. opt-in (speed level); a no-op when no small callee qualifies.
|
|
3867
|
+
const runInline = (a) => {
|
|
3868
|
+
if (!opts.inline) return a
|
|
3869
|
+
// `inline: true` → safe SIMD-helper-only default; `inline: 'all'` → unrestricted.
|
|
3870
|
+
a = inline(a, { simdOnly: opts.inline !== 'all' })
|
|
3871
|
+
if (opts.propagate) a = propagate(a)
|
|
3872
|
+
if (opts.mergeBlocks) a = mergeBlocks(a)
|
|
3873
|
+
if (opts.vacuum) a = vacuum(a)
|
|
3874
|
+
if (opts.coalesceLocals) a = coalesceLocals(a)
|
|
3875
|
+
return a
|
|
3876
|
+
}
|
|
3877
|
+
const finish = (a) => { a = runInline(a); return pruneEmptyStart(opts.devirt ? devirt(a) : a) }
|
|
3878
|
+
|
|
3879
|
+
// Fast path: jz owns this optimizer and feeds it a controlled, type-aware IR.
|
|
3880
|
+
// The only passes that can *grow* the binary are inlineOnce/inline; when no
|
|
3881
|
+
// function is an inline candidate (the common case for scalar REPL kernels)
|
|
3882
|
+
// nothing can inflate, so we skip watr's per-round `binarySize` re-compile
|
|
3883
|
+
// guard — up to four full encodes per call — and iterate to a fixpoint with
|
|
3884
|
+
// zero compiles. A round that changes nothing is the natural exit.
|
|
3885
|
+
if (!((opts.inlineOnce || opts.inline) && mayInline(ast))) {
|
|
3886
|
+
// inlineOnce/inline can't fire here, so skip them — their candidate scan
|
|
3887
|
+
// (a 16-round whole-module walk) is the second-costliest thing after
|
|
3888
|
+
// propagate, and it would only confirm what `mayInline` already proved.
|
|
3889
|
+
for (let round = 0; round < 3; round++) {
|
|
3890
|
+
const beforeRound = clone(ast)
|
|
3891
|
+
for (const [key, fn] of PASSES) if (opts[key] && key !== 'inlineOnce' && key !== 'inline' && key !== 'devirt') ast = fn(ast)
|
|
3892
|
+
if (equal(beforeRound, ast)) break // fixpoint
|
|
3893
|
+
if (verbose) log(` round ${round + 1} applied`)
|
|
3894
|
+
}
|
|
3895
|
+
return finish(ast)
|
|
3896
|
+
}
|
|
3897
|
+
|
|
3898
|
+
// Guarded path: inlining can inflate (a body bigger than the call it replaces),
|
|
3899
|
+
// so score each round on encoded bytes and revert any that grows the binary.
|
|
3900
|
+
// `binarySize` returns Infinity for invalid wat, so a broken round reverts too.
|
|
3901
|
+
// A round's starting size equals the prior round's ending size, so carry it
|
|
3902
|
+
// forward and compile once per round.
|
|
3903
|
+
let beforeRound = null
|
|
3904
|
+
let sizeBefore = binarySize(ast)
|
|
3905
|
+
for (let round = 0; round < 3; round++) {
|
|
3906
|
+
beforeRound = clone(ast)
|
|
3907
|
+
|
|
3908
|
+
for (const [key, fn] of PASSES) if (opts[key] && key !== 'devirt' && key !== 'inline') ast = fn(ast)
|
|
3909
|
+
// Second propagate sweep: `inlineOnce`/`inline` (above) leave fresh
|
|
3910
|
+
// `(local.set $p arg) … (local.get $p)` wrappers around each inlined call;
|
|
3911
|
+
// re-running propagation collapses them within this same round, so the size
|
|
3912
|
+
// guard scores the cleaned result.
|
|
3913
|
+
if (opts.propagate && (opts.inlineOnce || opts.inline)) ast = propagate(ast)
|
|
3914
|
+
|
|
3915
|
+
// A round that changed nothing can't have inflated — check convergence
|
|
3916
|
+
// before compiling so the fixpoint-confirming round costs zero compiles.
|
|
3917
|
+
if (equal(beforeRound, ast)) break
|
|
3918
|
+
|
|
3919
|
+
const sizeAfter = binarySize(ast)
|
|
3920
|
+
const delta = sizeAfter - sizeBefore
|
|
3921
|
+
if (verbose || delta !== 0) log(` round ${round + 1}: ${delta > 0 ? '+' : ''}${delta} bytes`, delta)
|
|
3922
|
+
|
|
3923
|
+
// Default optimize must never inflate; explicit passes get slight leniency.
|
|
3924
|
+
const tolerance = strictGuard ? 0 : 16
|
|
3925
|
+
if (delta > tolerance) {
|
|
3926
|
+
if (verbose) log(` ⚠ round ${round + 1} inflated by ${delta} bytes, reverting`, delta)
|
|
3927
|
+
ast = beforeRound
|
|
3928
|
+
break
|
|
3929
|
+
}
|
|
3930
|
+
sizeBefore = sizeAfter
|
|
3931
|
+
}
|
|
3932
|
+
|
|
3933
|
+
return finish(ast)
|
|
3934
|
+
}
|
|
3935
|
+
|
|
3936
|
+
/** Count AST nodes (fast size heuristic). */
|
|
3937
|
+
export { count as size, count, binarySize }
|
|
3938
|
+
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 }
|