watr 5.0.0 → 5.1.1
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/dist/watr.js +825 -907
- package/dist/watr.min.js +5 -6
- package/dist/watr.wasm +0 -0
- package/package.json +3 -3
- package/readme.md +19 -5
- package/src/compile.js +236 -110
- package/src/const.js +125 -131
- package/src/encode.js +28 -3
- package/src/optimize.js +2909 -421
- package/src/print.js +8 -3
- package/src/util.js +33 -9
- package/types/src/compile.d.ts +1 -0
- package/types/src/compile.d.ts.map +1 -1
- package/types/src/const.d.ts +2 -1
- package/types/src/const.d.ts.map +1 -1
- package/types/src/encode.d.ts +5 -26
- package/types/src/encode.d.ts.map +1 -1
- package/types/src/optimize.d.ts +41 -33
- package/types/src/optimize.d.ts.map +1 -1
- package/types/src/print.d.ts.map +1 -1
- package/types/src/util.d.ts +5 -10
- package/types/src/util.d.ts.map +1 -1
package/src/optimize.js
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
* @module wat/optimize
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import
|
|
10
|
+
import { numdata, size } from './compile.js'
|
|
11
|
+
import { IMM, OPCODE, resultType } from './const.js'
|
|
11
12
|
import parse from './parse.js'
|
|
13
|
+
import { clone, walk, walkPost } from './util.js'
|
|
12
14
|
|
|
13
15
|
// Fixpoint round caps — empirical convergence bounds, not correctness limits.
|
|
14
16
|
// Each pass only makes monotonic progress, so hitting a cap merely leaves a few
|
|
@@ -18,38 +20,6 @@ const MAX_INLINE_ROUNDS = 16 // single-caller inline-chain depth (deep generate
|
|
|
18
20
|
|
|
19
21
|
// === WAT optimizer passes ===
|
|
20
22
|
|
|
21
|
-
// — AST helpers (formerly watr/util.js) — every node is an s-expression
|
|
22
|
-
// array `[head, ...args]`; non-arrays are immediates.
|
|
23
|
-
const clone = (node) => Array.isArray(node) ? node.map(clone) : node
|
|
24
|
-
|
|
25
|
-
/** Walk depth-first pre-order, read-only. fn(node, parent, idx). */
|
|
26
|
-
const walk = (node, fn, parent, idx) => {
|
|
27
|
-
fn(node, parent, idx)
|
|
28
|
-
if (Array.isArray(node)) for (let i = 0; i < node.length; i++) walk(node[i], fn, node, i)
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Walk depth-first post-order. fn may return a replacement node or mutate in place. */
|
|
32
|
-
const walkPost = (node, fn, parent, idx) => {
|
|
33
|
-
if (Array.isArray(node)) for (let i = 0; i < node.length; i++) walkPost(node[i], fn, node, i)
|
|
34
|
-
const result = fn(node, parent, idx)
|
|
35
|
-
if (result !== undefined && parent) parent[idx] = result
|
|
36
|
-
return result !== undefined ? result : node
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Result value type of an op from its name prefix (formerly watr/const.js).
|
|
40
|
-
* Comparisons/eqz on scalar int/float collapse to i32; else the name prefix. */
|
|
41
|
-
const resultType = (op) => {
|
|
42
|
-
if (typeof op !== 'string') return null
|
|
43
|
-
const dot = op.indexOf('.')
|
|
44
|
-
if (dot < 0) return null
|
|
45
|
-
const prefix = op.slice(0, dot)
|
|
46
|
-
const scalar = prefix === 'i32' || prefix === 'i64' || prefix === 'f32' || prefix === 'f64'
|
|
47
|
-
if (scalar && /^(eqz?|ne|[lg][te])(_[su])?$/.test(op.slice(dot + 1))) return 'i32'
|
|
48
|
-
if (scalar || prefix === 'v128') return prefix
|
|
49
|
-
if (op === 'memory.size' || op === 'memory.grow') return 'i32'
|
|
50
|
-
return null
|
|
51
|
-
}
|
|
52
|
-
|
|
53
23
|
/**
|
|
54
24
|
* Recursively count AST nodes — fast size heuristic without compiling.
|
|
55
25
|
* @param {any} node
|
|
@@ -147,13 +117,22 @@ const treeshake = (ast) => {
|
|
|
147
117
|
|
|
148
118
|
let funcIdx = 0, globalIdx = 0, typeIdx = 0, tableIdx = 0, memIdx = 0
|
|
149
119
|
const elems = [], data = [], exports = [], starts = []
|
|
120
|
+
// Highest index referenced by a bare NUMERAL per space, from surviving sites only.
|
|
121
|
+
// Removing entry i shifts every later index down, so numeric refs cap removal:
|
|
122
|
+
// only entries above the cap may be dropped (named refs re-resolve; numerals don't).
|
|
123
|
+
const numRef = { func: -1, global: -1, type: -1, table: -1, memory: -1 }
|
|
124
|
+
// Inline-import defs ((global $g (import …) …)) occupy the import-first region of the
|
|
125
|
+
// binary index space, so declaration-order idx diverges from binary idx — numeric
|
|
126
|
+
// comparisons are unreliable there. Numeric refs + inline imports → freeze the space.
|
|
127
|
+
const inlineImport = { func: false, global: false, type: false }
|
|
150
128
|
|
|
151
129
|
for (const node of ast.slice(1)) {
|
|
152
130
|
if (!Array.isArray(node)) continue
|
|
153
131
|
const kind = node[0]
|
|
132
|
+
const inlImp = node.some(s => Array.isArray(s) && s[0] === 'import')
|
|
154
133
|
if (kind === 'type') register(types, node, typeIdx++)
|
|
155
|
-
else if (kind === 'func') register(funcs, node, funcIdx++)
|
|
156
|
-
else if (kind === 'global') register(globals, node, globalIdx++)
|
|
134
|
+
else if (kind === 'func') register(funcs, node, funcIdx++), inlImp && (inlineImport.func = true)
|
|
135
|
+
else if (kind === 'global') register(globals, node, globalIdx++), inlImp && (inlineImport.global = true)
|
|
157
136
|
else if (kind === 'table') register(tables, node, tableIdx++)
|
|
158
137
|
else if (kind === 'memory') register(memories, node, memIdx++)
|
|
159
138
|
else if (kind === 'import') {
|
|
@@ -172,20 +151,23 @@ const treeshake = (ast) => {
|
|
|
172
151
|
else if (kind === 'data') data.push(node)
|
|
173
152
|
}
|
|
174
153
|
|
|
175
|
-
// Worklist:
|
|
154
|
+
// Worklist: entries (funcs, globals, tables, types) whose node awaits a ref scan.
|
|
176
155
|
const work = []
|
|
177
156
|
const enqueue = (entry) => { if (entry && !entry.scanned) work.push(entry) }
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
157
|
+
// Resolve a ref, coercing bare numerals ('3' → 3) onto the shared idx key and
|
|
158
|
+
// recording the numeric-removal cap for the space.
|
|
159
|
+
const deref = (map, space, ref) => {
|
|
160
|
+
if (typeof ref === 'string' && ref[0] !== '$' && ref !== '' && !isNaN(ref)) ref = +ref
|
|
161
|
+
if (typeof ref === 'number') numRef[space] = Math.max(numRef[space], ref)
|
|
162
|
+
return map.get(ref)
|
|
182
163
|
}
|
|
183
|
-
const
|
|
184
|
-
const
|
|
185
|
-
const
|
|
186
|
-
const
|
|
164
|
+
const markFunc = (ref) => { const e = deref(funcs, 'func', ref); if (e) e.used = true, enqueue(e) }
|
|
165
|
+
const markGlobal = (ref) => { const e = deref(globals, 'global', ref); if (e) e.used = true, enqueue(e) }
|
|
166
|
+
const markTable = (ref) => { const e = deref(tables, 'table', ref); if (e) e.used = true, enqueue(e) }
|
|
167
|
+
const markMemory = (ref) => { const e = deref(memories, 'memory', ref); if (e) e.used = true }
|
|
168
|
+
const markType = (ref) => { const e = deref(types, 'type', ref); if (e) e.used = true, enqueue(e) }
|
|
187
169
|
|
|
188
|
-
// Roots: explicit exports, start funcs, elem
|
|
170
|
+
// Roots: explicit exports, start funcs, elem/data segments, inline-exported items.
|
|
189
171
|
for (const exp of exports) {
|
|
190
172
|
for (const sub of exp) {
|
|
191
173
|
if (!Array.isArray(sub)) continue
|
|
@@ -196,50 +178,101 @@ const treeshake = (ast) => {
|
|
|
196
178
|
else if (kind === 'memory') markMemory(ref)
|
|
197
179
|
}
|
|
198
180
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
181
|
+
// A start func with an empty body is a no-op: drop the (start) root itself and
|
|
182
|
+
// let ordinary liveness collect the func (renumbering absorbs the index shift).
|
|
183
|
+
const START_HEAD = new Set(['export', 'type', 'param', 'result', 'local'])
|
|
184
|
+
const emptyBody = (fn) => {
|
|
185
|
+
let b = typeof fn[1] === 'string' && fn[1][0] === '$' ? 2 : 1
|
|
186
|
+
while (b < fn.length && Array.isArray(fn[b]) && START_HEAD.has(fn[b][0])) b++
|
|
187
|
+
return b >= fn.length
|
|
188
|
+
}
|
|
189
|
+
const deadStarts = new Set()
|
|
190
|
+
for (const st of starts) {
|
|
191
|
+
const e = deref(funcs, 'func', st[1])
|
|
192
|
+
if (e && !e.isImport && emptyBody(e.node)) deadStarts.add(st)
|
|
193
|
+
else markFunc(st[1])
|
|
203
194
|
}
|
|
195
|
+
const elemTarget = new Map() // active elem node → target table entry (write-only edge, not liveness)
|
|
196
|
+
const elemKind = new Map() // elem node → 'active' | 'passive' | 'declare'
|
|
204
197
|
for (const elem of elems) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
198
|
+
// (elem declare? (table t)? offset-expr? reftype? item*) — items are bare
|
|
199
|
+
// $names/numerals or (item …)/(ref.func …) exprs; offsets may read globals.
|
|
200
|
+
let target, active = false
|
|
201
|
+
if (elem.includes('declare')) elemKind.set(elem, 'declare')
|
|
202
|
+
for (const part of elem.slice(1)) {
|
|
203
|
+
if (Array.isArray(part)) {
|
|
204
|
+
// the TARGET ref is a write edge — it caps the index space (deref) but does
|
|
205
|
+
// not make the table live; only reads (code/exports) do
|
|
206
|
+
if (part[0] === 'table') target = deref(tables, 'table', part[1])
|
|
207
|
+
else {
|
|
208
|
+
if (part[0] === 'offset' || (part[0] !== 'item' && typeof part[0] === 'string' && !part[0].startsWith('ref'))) active = true
|
|
209
|
+
walk(part, n => {
|
|
210
|
+
if (Array.isArray(n) && n[0] === 'ref.func') markFunc(n[1])
|
|
211
|
+
else if (Array.isArray(n) && n[0] === 'global.get') markGlobal(n[1])
|
|
212
|
+
else if (typeof n === 'string' && n[0] === '$') markFunc(n)
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
else if (typeof part === 'string' && part !== 'func' && part !== 'declare' && (part[0] === '$' || !isNaN(part))) markFunc(part)
|
|
217
|
+
}
|
|
218
|
+
if (active) elemTarget.set(elem, target ?? tables.get(0))
|
|
219
|
+
if (!elemKind.has(elem)) elemKind.set(elem, active ? 'active' : 'passive')
|
|
209
220
|
}
|
|
210
221
|
for (const d of data) {
|
|
211
222
|
const first = d[1]
|
|
212
223
|
if (Array.isArray(first) && first[0] === 'memory') markMemory(first[1])
|
|
213
224
|
else if (typeof first === 'string' && first[0] === '$') markMemory(first)
|
|
214
225
|
else if (Array.isArray(first)) markMemory(0)
|
|
226
|
+
walk(d, n => { if (Array.isArray(n) && n[0] === 'global.get') markGlobal(n[1]) })
|
|
215
227
|
}
|
|
216
228
|
for (const m of [funcs, globals, tables, memories]) for (const e of m.values()) if (e.used) enqueue(e)
|
|
217
229
|
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
// Drain worklist: each function body gets walked exactly once.
|
|
230
|
+
// Drain worklist: each live node (func body, global/table init, type def) is
|
|
231
|
+
// walked exactly once. IMM (the instruction registry's immediate types) says
|
|
232
|
+
// which index space an op's first immediate addresses — one source of truth
|
|
233
|
+
// for call/ref.func/global.get/struct.new/call_ref/…, named or numeric.
|
|
234
|
+
let elemIdxUsed = false // table.init/elem.drop consume element indices — forbids elem removal
|
|
235
|
+
let flatForm = false // bare instruction tokens in bodies — operands unattributable
|
|
236
|
+
const refFunced = new Set() // funcs referenced by ref.func in live code — they must STAY declared
|
|
227
237
|
while (work.length) {
|
|
228
238
|
const entry = work.pop()
|
|
229
239
|
if (entry.scanned) continue
|
|
230
240
|
entry.scanned = true
|
|
231
241
|
if (entry.isImport) continue
|
|
232
|
-
walk(entry.node, n => {
|
|
242
|
+
walk(entry.node, (n, parent, idx) => {
|
|
243
|
+
if (Array.isArray(n)) {
|
|
244
|
+
const op = n[0]
|
|
245
|
+
if (op === 'table.init' || op === 'elem.drop' || op === 'array.new_elem' || op === 'array.init_elem') elemIdxUsed = true
|
|
246
|
+
else if (op === 'ref.func') refFunced.add(deref(funcs, 'func', n[1]))
|
|
247
|
+
}
|
|
248
|
+
// a bare token OUTSIDE op position is flat-form usage whose operands we can't
|
|
249
|
+
// attribute — freeze segment removal and index renumbering
|
|
250
|
+
else if (typeof n === 'string' && idx !== 0 && OPCODE[n] !== undefined) {
|
|
251
|
+
flatForm = true
|
|
252
|
+
if (n === 'table.init' || n === 'elem.drop' || n === 'array.new_elem' || n === 'array.init_elem' || n === 'ref.func') elemIdxUsed = true
|
|
253
|
+
}
|
|
233
254
|
if (!Array.isArray(n)) {
|
|
234
|
-
|
|
255
|
+
// Bare $name (flat-form immediate, label, any position): the flat style
|
|
256
|
+
// gives no structure to say which space it addresses — mark them all.
|
|
257
|
+
// A named global.set target is exempt: a write alone doesn't keep a
|
|
258
|
+
// global alive (its decl + sets are dropped together below).
|
|
259
|
+
if (typeof n === 'string' && n[0] === '$' && !(parent?.[0] === 'global.set' && idx === 1))
|
|
260
|
+
markFunc(n), markGlobal(n), markTable(n), markMemory(n), markType(n)
|
|
235
261
|
return
|
|
236
262
|
}
|
|
237
263
|
const [op, ref] = n
|
|
238
|
-
if (op === '
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
264
|
+
if (op === 'type') return markType(ref)
|
|
265
|
+
if (op === 'call_indirect' || op === 'return_call_indirect') {
|
|
266
|
+
for (const sub of n) if (typeof sub === 'string' && sub[0] === '$') return markTable(sub)
|
|
267
|
+
return markTable(0) // implicit table 0
|
|
268
|
+
}
|
|
269
|
+
if (op === 'global.set') { if (!(typeof ref === 'string' && ref[0] === '$')) markGlobal(ref); return }
|
|
270
|
+
const imm = typeof op === 'string' ? IMM[op] : null
|
|
271
|
+
if (imm) {
|
|
272
|
+
if (imm.startsWith('funcidx')) markFunc(ref)
|
|
273
|
+
else if (imm.startsWith('globalidx')) markGlobal(ref)
|
|
274
|
+
else if (imm.startsWith('typeidx')) markType(ref)
|
|
275
|
+
else if (imm.startsWith('tableidx')) markTable(ref)
|
|
243
276
|
}
|
|
244
277
|
if (typeof op === 'string' && (op.startsWith('memory.') || op.includes('.load') || op.includes('.store'))) {
|
|
245
278
|
markMemory(0)
|
|
@@ -247,26 +280,110 @@ const treeshake = (ast) => {
|
|
|
247
280
|
})
|
|
248
281
|
}
|
|
249
282
|
|
|
283
|
+
// Removal gate. func/global/table indices are RENUMBERED after filtering, so their
|
|
284
|
+
// numeric refs don't gate removal — unless bodies use flat tokens (operands
|
|
285
|
+
// unattributable) or inline imports skew declaration order vs binary order. Types
|
|
286
|
+
// keep the cap: type refs embed in type definitions, ref annotations and field
|
|
287
|
+
// types, far beyond what the renumberer rewrites.
|
|
288
|
+
const renumberable = (space) => (space === 'func' || space === 'global' || space === 'table') && !flatForm && !inlineImport[space]
|
|
289
|
+
const cap = (space) => inlineImport[space] && numRef[space] >= 0 ? Infinity : numRef[space]
|
|
290
|
+
const droppable = (sub, space) => {
|
|
291
|
+
const e = nodeMap.get(sub)
|
|
292
|
+
return e && !e.used && (renumberable(space) || e.idx > cap(space))
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Dead tables: never read by code or exports — their active elem segments only fill
|
|
296
|
+
// unobservable slots, so table + segments go together (funcs those segments pinned
|
|
297
|
+
// are re-judged next round). Element-index consumers forbid segment removal.
|
|
298
|
+
const dropNodes = new Set()
|
|
299
|
+
if (!elemIdxUsed) {
|
|
300
|
+
for (const e of new Set(tables.values())) {
|
|
301
|
+
if (e.used || e.isImport || e.idx <= cap('table')) continue
|
|
302
|
+
dropNodes.add(e.node)
|
|
303
|
+
for (const [elem, t] of elemTarget) if (t === e) dropNodes.add(elem)
|
|
304
|
+
}
|
|
305
|
+
// With no element-index consumers, a passive segment is unreachable outright and a
|
|
306
|
+
// declare segment carries no runtime data — BUT both also serve as the declaration
|
|
307
|
+
// that validates in-code ref.func. Droppable only when every ref.func'd function in
|
|
308
|
+
// the segment stays anchored elsewhere (export or surviving active segment).
|
|
309
|
+
const elemFuncs = (elem) => {
|
|
310
|
+
const out = []
|
|
311
|
+
walk(elem, n => { const e = typeof n === 'string' && n !== 'declare' && n !== 'func' && (n[0] === '$' || !isNaN(n)) ? deref(funcs, 'func', n) : Array.isArray(n) && n[0] === 'ref.func' ? deref(funcs, 'func', n[1]) : null; if (e) out.push(e) })
|
|
312
|
+
return out
|
|
313
|
+
}
|
|
314
|
+
const anchored = new Set()
|
|
315
|
+
for (const [elem, kind] of elemKind) if (kind === 'active' && !dropNodes.has(elem)) for (const e of elemFuncs(elem)) anchored.add(e)
|
|
316
|
+
for (const exp of exports) for (const sub of exp) if (Array.isArray(sub) && sub[0] === 'func') { const e = deref(funcs, 'func', sub[1]); if (e) anchored.add(e) }
|
|
317
|
+
for (const [elem, kind] of elemKind) {
|
|
318
|
+
if (kind !== 'passive' && kind !== 'declare') continue
|
|
319
|
+
if (elemFuncs(elem).every(e => !refFunced.has(e) || anchored.has(e))) dropNodes.add(elem)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
250
323
|
// Filter: keep used definitions. nodeMap handles unnamed entries directly.
|
|
251
324
|
const result = ['module']
|
|
325
|
+
const deadGlobals = new Set() // named write-only globals whose decl is dropped
|
|
252
326
|
for (const node of ast.slice(1)) {
|
|
253
327
|
if (!Array.isArray(node)) { result.push(node); continue }
|
|
328
|
+
if (dropNodes.has(node) || deadStarts.has(node)) continue
|
|
254
329
|
const kind = node[0]
|
|
255
330
|
if (kind === 'func' || kind === 'global' || kind === 'type') {
|
|
256
|
-
if (
|
|
331
|
+
if (!droppable(node, kind)) result.push(node)
|
|
332
|
+
else if (kind === 'global' && typeof node[1] === 'string' && node[1][0] === '$') deadGlobals.add(node[1])
|
|
257
333
|
} else if (kind === 'import') {
|
|
258
|
-
// Keep import
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
if (!Array.isArray(sub)) continue
|
|
262
|
-
const e = nodeMap.get(sub)
|
|
263
|
-
if (e?.used) { used = true; break }
|
|
264
|
-
}
|
|
265
|
-
if (used) result.push(node)
|
|
334
|
+
// Keep import unless every tracked sub-item is droppable (untracked kinds — tag — stay).
|
|
335
|
+
const subs = node.filter(sub => Array.isArray(sub) && nodeMap.has(sub))
|
|
336
|
+
if (!subs.length || subs.some(sub => !droppable(sub, sub[0]))) result.push(node)
|
|
266
337
|
} else {
|
|
267
338
|
result.push(node)
|
|
268
339
|
}
|
|
269
340
|
}
|
|
341
|
+
// Neuter writes to dropped write-only globals: (global.set $dead V) → (drop V)
|
|
342
|
+
// (vacuum erases the drop when V is pure).
|
|
343
|
+
if (deadGlobals.size) walkPost(result, n => {
|
|
344
|
+
if (Array.isArray(n) && n[0] === 'global.set' && deadGlobals.has(n[1])) return ['drop', n[2]]
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
// Renumber surviving bare-numeric refs for the shifted spaces. New indices come
|
|
348
|
+
// from the RESULT's declaration order (imports interleave in watr's index model);
|
|
349
|
+
// named refs re-resolve on their own.
|
|
350
|
+
const remap = { func: new Map(), global: new Map(), table: new Map() }
|
|
351
|
+
const counters = { func: 0, global: 0, table: 0 }
|
|
352
|
+
const note = (node, space) => { const e = nodeMap.get(node); e ? remap[space].set(e.idx, counters[space]++) : counters[space]++ }
|
|
353
|
+
for (const node of result.slice(1)) {
|
|
354
|
+
if (!Array.isArray(node)) continue
|
|
355
|
+
const k = node[0]
|
|
356
|
+
if (k === 'func' || k === 'global' || k === 'table') note(node, k)
|
|
357
|
+
else if (k === 'import') for (const sub of node)
|
|
358
|
+
if (Array.isArray(sub) && (sub[0] === 'func' || sub[0] === 'global' || sub[0] === 'table')) note(sub, sub[0])
|
|
359
|
+
}
|
|
360
|
+
const shifted = (space) => renumberable(space) && [...remap[space]].some(([o, n]) => o !== n)
|
|
361
|
+
if (shifted('func') || shifted('global') || shifted('table')) {
|
|
362
|
+
const isNum = (r) => typeof r === 'number' || (typeof r === 'string' && r !== '' && r[0] !== '$' && !isNaN(r))
|
|
363
|
+
const renum = (space, ref) => {
|
|
364
|
+
if (!renumberable(space) || !isNum(ref)) return ref
|
|
365
|
+
const n = remap[space].get(+ref)
|
|
366
|
+
return n === undefined ? ref : typeof ref === 'number' ? n : String(n)
|
|
367
|
+
}
|
|
368
|
+
walkPost(result, n => {
|
|
369
|
+
if (!Array.isArray(n)) return
|
|
370
|
+
const op = n[0]
|
|
371
|
+
if (op === 'start' || op === 'ref.func' || op === 'call' || op === 'return_call') n[1] = renum('func', n[1])
|
|
372
|
+
else if (op === 'global.get' || op === 'global.set') n[1] = renum('global', n[1])
|
|
373
|
+
else if (op === 'export' && Array.isArray(n[2]) && remap[n[2][0]]) n[2][1] = renum(n[2][0], n[2][1])
|
|
374
|
+
else if (op === 'elem') for (let i = 1; i < n.length; i++) {
|
|
375
|
+
const part = n[i]
|
|
376
|
+
if (Array.isArray(part) && part[0] === 'table') part[1] = renum('table', part[1])
|
|
377
|
+
else if (typeof part === 'string' && part !== 'func' && part !== 'declare' && isNum(part)) n[i] = renum('func', part)
|
|
378
|
+
}
|
|
379
|
+
else if (op === 'call_indirect' || op === 'return_call_indirect') { if (isNum(n[1])) n[1] = renum('table', n[1]) }
|
|
380
|
+
else if (op === 'table.init') { if (n.length > 2) n[1] = renum('table', n[1]) }
|
|
381
|
+
else if (typeof op === 'string' && IMM[op] && IMM[op].startsWith('tableidx')) {
|
|
382
|
+
n[1] = renum('table', n[1])
|
|
383
|
+
if (IMM[op] === 'tableidx_tableidx') n[2] = renum('table', n[2])
|
|
384
|
+
}
|
|
385
|
+
})
|
|
386
|
+
}
|
|
270
387
|
return result
|
|
271
388
|
}
|
|
272
389
|
|
|
@@ -601,23 +718,13 @@ const makeConst = (type, value) => {
|
|
|
601
718
|
* @param {Array} ast
|
|
602
719
|
* @returns {Array}
|
|
603
720
|
*/
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
// fully determines unary vs binary. NEVER from Function.length: the
|
|
612
|
-
// self-host kernel's closures don't carry a faithful `.length`, and the
|
|
613
|
-
// old `fn.length === 1/2` checks silently disabled ALL folding in-kernel
|
|
614
|
-
// (the L2 self-host divergence — unfolded consts fed the vectorizer
|
|
615
|
-
// shapes native never produces).
|
|
616
|
-
// Unary
|
|
617
|
-
if (node.length === 2) {
|
|
618
|
-
// NaN-payload reinterprets fold at the TEXT level — the payload double
|
|
619
|
-
// must never ride as a raw f64 value (see _nanBitsHex). Applies in both
|
|
620
|
-
// directions: nan: literal → i64 bits, and NaN-pattern i64 → nan: literal.
|
|
721
|
+
// NaN-payload reinterprets fold at the TEXT level — the payload double must never
|
|
722
|
+
// ride as a raw f64 value (see _nanBitsHex). Applies in both directions: nan:
|
|
723
|
+
// literal → i64 bits, and NaN-pattern i64 → nan: literal. These are MANDATORY
|
|
724
|
+
// (correctness, not size — the i64 sleb may out-size the f64 form), so the driver
|
|
725
|
+
// runs them once up front, keeping the rounds size-monotone for the guard.
|
|
726
|
+
const nanFoldNode = (node) => {
|
|
727
|
+
if (!Array.isArray(node) || node.length !== 2) return
|
|
621
728
|
if (node[0] === 'i64.reinterpret_f64') {
|
|
622
729
|
const inner = node[1]
|
|
623
730
|
if (Array.isArray(inner) && inner.length === 2 && inner[0] === 'f64.const' && typeof inner[1] === 'string') {
|
|
@@ -636,11 +743,66 @@ const fold = (ast) => {
|
|
|
636
743
|
((hi & 0x80000000) !== 0 ? '-' : '') + 'nan:0x' + (hi & 0xfffff).toString(16).padStart(5, '0') + h.slice(8)]
|
|
637
744
|
}
|
|
638
745
|
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Associative-chain reassociation: (op (op x C1) C2) → (op x (C1 <combine> C2))
|
|
749
|
+
// when x is NOT itself const (the all-const case is ordinary folding). sub is
|
|
750
|
+
// non-commutative but still associates against its own const RHS:
|
|
751
|
+
// (x − C1) − C2 = x − (C1 + C2), so sub's combine step is add, not sub.
|
|
752
|
+
const CHAIN_COMBINE = {
|
|
753
|
+
'i32.add': 'i32.add', 'i32.sub': 'i32.add', 'i32.and': 'i32.and', 'i32.or': 'i32.or', 'i32.xor': 'i32.xor',
|
|
754
|
+
'i64.add': 'i64.add', 'i64.sub': 'i64.add', 'i64.and': 'i64.and', 'i64.or': 'i64.or', 'i64.xor': 'i64.xor',
|
|
755
|
+
}
|
|
756
|
+
const chainFoldNode = (node) => {
|
|
757
|
+
if (node.length !== 3) return
|
|
758
|
+
const op = node[0], inner = node[1]
|
|
759
|
+
if (!Array.isArray(inner) || inner.length !== 3) return
|
|
760
|
+
let combineOp = null
|
|
761
|
+
if (inner[0] === op) combineOp = CHAIN_COMBINE[op]
|
|
762
|
+
else if (CHAIN_COMBINE[op]) {
|
|
763
|
+
// mixed add/sub chains associate too: (sub (add x C1) C2) = add x (C1−C2),
|
|
764
|
+
// (add (sub x C1) C2) = sub x (C1−C2) — the output keeps the INNER op
|
|
765
|
+
const t = op.slice(0, 4)
|
|
766
|
+
if ((op === t + 'add' && inner[0] === t + 'sub') || (op === t + 'sub' && inner[0] === t + 'add'))
|
|
767
|
+
combineOp = t + 'sub'
|
|
768
|
+
}
|
|
769
|
+
if (!combineOp) return
|
|
770
|
+
if (getConst(inner[1])) return // fully const — the ordinary fold path handles it
|
|
771
|
+
const c1 = getConst(inner[2]), c2 = getConst(node[2])
|
|
772
|
+
if (!c1 || !c2) return
|
|
773
|
+
const r = FOLDABLE[combineOp](c1.value, c2.value)
|
|
774
|
+
if (r === null || r === undefined) return
|
|
775
|
+
const out = makeConst(resultType(op), r)
|
|
776
|
+
// never inflate: 2 opcodes + 2 consts become 1 opcode + 1 const — gate on the
|
|
777
|
+
// consts alone (strictly conservative; the opcode is always a net win)
|
|
778
|
+
if (constInstrSize(out) > constInstrSize(inner[2]) + constInstrSize(node[2])) return
|
|
779
|
+
return [inner[0], inner[1], out]
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const foldNode = (node) => {
|
|
783
|
+
if (!Array.isArray(node)) return
|
|
784
|
+
const nan = nanFoldNode(node)
|
|
785
|
+
if (nan) return nan
|
|
786
|
+
const chain = chainFoldNode(node)
|
|
787
|
+
if (chain) return chain
|
|
788
|
+
const fn = FOLDABLE[node[0]]
|
|
789
|
+
if (!fn) return
|
|
790
|
+
// Arity comes from the NODE — every WAT op is fixed-arity, so node.length
|
|
791
|
+
// fully determines unary vs binary (never Function.length: self-host closures
|
|
792
|
+
// don't carry a faithful one).
|
|
793
|
+
// Unary
|
|
794
|
+
if (node.length === 2) {
|
|
639
795
|
const a = getConst(node[1])
|
|
640
796
|
if (!a) return
|
|
641
797
|
const r = fn(a.value)
|
|
642
798
|
if (r === null || r === undefined) return
|
|
643
|
-
|
|
799
|
+
// Never inflate: a fixed-width f32/f64.const (5/9 B) or wide-sleb i64.const can
|
|
800
|
+
// out-size the 1-byte op + small const it replaces — (f32.convert_i32_s
|
|
801
|
+
// (i32.const 22)) is 4 B, (f32.const 22) is 5 B. The driver's mayInline fast
|
|
802
|
+
// path (and standalone pass callers) rely on fold being monotonic.
|
|
803
|
+
const out = makeConst(resultType(node[0]), r)
|
|
804
|
+
if (constInstrSize(out) > 1 + constInstrSize(node[1])) return
|
|
805
|
+
return out
|
|
644
806
|
}
|
|
645
807
|
// Binary
|
|
646
808
|
if (node.length === 3) {
|
|
@@ -648,10 +810,15 @@ const fold = (ast) => {
|
|
|
648
810
|
if (!a || !b) return
|
|
649
811
|
const r = fn(a.value, b.value)
|
|
650
812
|
if (r === null || r === undefined) return
|
|
651
|
-
|
|
813
|
+
// same monotonicity gate: e.g. (i64.shl (i64.const 1) (i64.const 63)) is 5 B,
|
|
814
|
+
// its folded i64.const is 11 B
|
|
815
|
+
const out = makeConst(resultType(node[0]), r)
|
|
816
|
+
if (constInstrSize(out) > 1 + constInstrSize(node[1]) + constInstrSize(node[2])) return
|
|
817
|
+
return out
|
|
652
818
|
}
|
|
653
|
-
})
|
|
654
819
|
}
|
|
820
|
+
/** Constant folding as a standalone pass. */
|
|
821
|
+
const fold = (ast) => walkPost(ast, foldNode)
|
|
655
822
|
|
|
656
823
|
// ==================== IDENTITY REMOVAL ====================
|
|
657
824
|
|
|
@@ -708,21 +875,57 @@ const IDENTITIES = {
|
|
|
708
875
|
// f * 1 → x (careful with NaN, skip for floats)
|
|
709
876
|
}
|
|
710
877
|
|
|
878
|
+
// Unary cast round-trips `outer(inner(x)) → x`. Each pair is bit-for-bit identity:
|
|
879
|
+
// reinterpret∘reinterpret — a value bit-cast to the other repr and back is unchanged.
|
|
880
|
+
// wrap_i64∘extend_i32_{s,u} — extend fills the high 32 bits, wrap drops them, low 32 = x.
|
|
881
|
+
// Generic wasm identities (Binaryen folds them); a NaN-box language leans on them heavily,
|
|
882
|
+
// since (un)boxing a pointer is exactly a wrap∘reinterpret∘reinterpret∘extend chain that
|
|
883
|
+
// collapses to nothing once these fire bottom-up.
|
|
884
|
+
const ROUNDTRIP = {
|
|
885
|
+
'i64.reinterpret_f64': 'f64.reinterpret_i64',
|
|
886
|
+
'f64.reinterpret_i64': 'i64.reinterpret_f64',
|
|
887
|
+
'i32.reinterpret_f32': 'f32.reinterpret_i32',
|
|
888
|
+
'f32.reinterpret_i32': 'i32.reinterpret_f32',
|
|
889
|
+
'i32.wrap_i64': new Set(['i64.extend_i32_u', 'i64.extend_i32_s']),
|
|
890
|
+
}
|
|
891
|
+
|
|
711
892
|
/**
|
|
712
893
|
* Remove identity operations.
|
|
713
894
|
* @param {Array} ast
|
|
714
895
|
* @returns {Array}
|
|
715
896
|
*/
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
897
|
+
// Integer comparison complements: eqz(REL a b) ≡ INVERT(REL) a b — ints partition the
|
|
898
|
+
// whole domain with no third outcome. Floats are EXCLUDED: eqz(lt(NaN,x))=1 but ge(NaN,x)=0.
|
|
899
|
+
const INVERT = {
|
|
900
|
+
'i32.eq': 'i32.ne', 'i32.ne': 'i32.eq', 'i32.lt_s': 'i32.ge_s', 'i32.ge_s': 'i32.lt_s',
|
|
901
|
+
'i32.lt_u': 'i32.ge_u', 'i32.ge_u': 'i32.lt_u', 'i32.gt_s': 'i32.le_s', 'i32.le_s': 'i32.gt_s',
|
|
902
|
+
'i32.gt_u': 'i32.le_u', 'i32.le_u': 'i32.gt_u',
|
|
903
|
+
'i64.eq': 'i64.ne', 'i64.ne': 'i64.eq', 'i64.lt_s': 'i64.ge_s', 'i64.ge_s': 'i64.lt_s',
|
|
904
|
+
'i64.lt_u': 'i64.ge_u', 'i64.ge_u': 'i64.lt_u', 'i64.gt_s': 'i64.le_s', 'i64.le_s': 'i64.gt_s',
|
|
905
|
+
'i64.gt_u': 'i64.le_u', 'i64.le_u': 'i64.gt_u',
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const identityNode = (node) => {
|
|
909
|
+
if (!Array.isArray(node)) return
|
|
910
|
+
// (i32.eqz (REL a b)) → (INVREL a b) — one byte, same operands, same order
|
|
911
|
+
if (node[0] === 'i32.eqz' && node.length === 2 && Array.isArray(node[1]) && INVERT[node[1][0]] && node[1].length === 3)
|
|
912
|
+
return [INVERT[node[1][0]], node[1][1], node[1][2]]
|
|
913
|
+
// Unary cast round-trip: outer(inner(x)) → x (post-order, so an inner pair already
|
|
914
|
+
// collapsed before the outer op sees it — the whole box/unbox chain unwinds in one walk).
|
|
915
|
+
if (node.length === 2 && Array.isArray(node[1]) && node[1].length === 2) {
|
|
916
|
+
const inv = ROUNDTRIP[node[0]]
|
|
917
|
+
if (inv && (typeof inv === 'string' ? node[1][0] === inv : inv.has(node[1][0]))) return node[1][1]
|
|
918
|
+
return
|
|
919
|
+
}
|
|
920
|
+
if (node.length !== 3) return
|
|
719
921
|
const fn = IDENTITIES[node[0]]
|
|
720
922
|
if (!fn) return
|
|
721
923
|
const result = fn(node[1], node[2])
|
|
722
924
|
if (result === null) return // no optimization, keep original
|
|
723
925
|
return result
|
|
724
|
-
})
|
|
725
926
|
}
|
|
927
|
+
/** Identity elimination as a standalone pass. */
|
|
928
|
+
const identity = (ast) => walkPost(ast, identityNode)
|
|
726
929
|
|
|
727
930
|
// ==================== STRENGTH REDUCTION ====================
|
|
728
931
|
|
|
@@ -731,8 +934,7 @@ const identity = (ast) => {
|
|
|
731
934
|
* @param {Array} ast
|
|
732
935
|
* @returns {Array}
|
|
733
936
|
*/
|
|
734
|
-
const
|
|
735
|
-
return walkPost(ast, (node) => {
|
|
937
|
+
const strengthNode = (node) => {
|
|
736
938
|
if (!Array.isArray(node) || node.length !== 3) return
|
|
737
939
|
const [op, a, b] = node
|
|
738
940
|
|
|
@@ -786,8 +988,9 @@ const strength = (ast) => {
|
|
|
786
988
|
if (vb != null && vb > 0n && (vb & (vb - 1n)) === 0n)
|
|
787
989
|
return ['i64.and', a, ['i64.const', '0x' + _i64Hex16(vb - 1n)]]
|
|
788
990
|
}
|
|
789
|
-
})
|
|
790
991
|
}
|
|
992
|
+
/** Strength reduction as a standalone pass. */
|
|
993
|
+
const strength = (ast) => walkPost(ast, strengthNode)
|
|
791
994
|
|
|
792
995
|
// ==================== BRANCH SIMPLIFICATION ====================
|
|
793
996
|
|
|
@@ -797,6 +1000,87 @@ const strength = (ast) => {
|
|
|
797
1000
|
* @returns {Array}
|
|
798
1001
|
*/
|
|
799
1002
|
const branch = (ast) => {
|
|
1003
|
+
// if-arm value threading: (if C (then (local.set $x A)) (else (local.set $x B)))
|
|
1004
|
+
// → (local.set $x (if (result T) C (then A) (else B))) — the set happens on both
|
|
1005
|
+
// paths anyway, and the value-form if is one select-promotion away from collapsing.
|
|
1006
|
+
// Needs the local's declared type for the result annotation, hence the per-func walk.
|
|
1007
|
+
walk(ast, (fn) => {
|
|
1008
|
+
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
1009
|
+
const ltype = new Map()
|
|
1010
|
+
for (const c of fn)
|
|
1011
|
+
if (Array.isArray(c) && (c[0] === 'local' || c[0] === 'param') && typeof c[1] === 'string' && typeof c[2] === 'string') ltype.set(c[1], c[2])
|
|
1012
|
+
if (!ltype.size) return
|
|
1013
|
+
walkPost(fn, (node) => {
|
|
1014
|
+
if (!Array.isArray(node) || node[0] !== 'if' || node.length !== 4) return
|
|
1015
|
+
const { cond, thenBranch, elseBranch } = parseIf(node)
|
|
1016
|
+
if (!Array.isArray(cond) || !(thenBranch?.length >= 2) || !(elseBranch?.length >= 2)) return
|
|
1017
|
+
const a = thenBranch[thenBranch.length - 1], b = elseBranch[elseBranch.length - 1]
|
|
1018
|
+
if (!Array.isArray(a) || a[0] !== 'local.set' || a.length !== 3 || !Array.isArray(a[2])) return
|
|
1019
|
+
if (!Array.isArray(b) || b[0] !== 'local.set' || b.length !== 3 || b[1] !== a[1] || !Array.isArray(b[2])) return
|
|
1020
|
+
const t = ltype.get(a[1])
|
|
1021
|
+
if (typeof t !== 'string' || !/^([if](32|64)|v128)$/.test(t)) return
|
|
1022
|
+
// an early exit inside an arm would skip the hoisted set — arms must be branch-free
|
|
1023
|
+
let jumps = false
|
|
1024
|
+
walk([thenBranch, elseBranch], n => {
|
|
1025
|
+
const o = Array.isArray(n) ? n[0] : n
|
|
1026
|
+
if (o === 'br' || o === 'br_if' || o === 'br_table' || o === 'return' || o === 'unreachable' ||
|
|
1027
|
+
o === 'throw' || o === 'return_call' || o === 'return_call_indirect' || o === 'try_table') jumps = true
|
|
1028
|
+
})
|
|
1029
|
+
if (jumps) return
|
|
1030
|
+
return ['local.set', a[1], ['if', ['result', t], cond,
|
|
1031
|
+
['then', ...thenBranch.slice(1, -1), a[2]],
|
|
1032
|
+
['else', ...elseBranch.slice(1, -1), b[2]]]]
|
|
1033
|
+
})
|
|
1034
|
+
})
|
|
1035
|
+
// dead local round-trip: (if C (then STMTS (local.set $x A))) with NO else, sitting
|
|
1036
|
+
// as the function's second-to-last statement, immediately followed by the function's
|
|
1037
|
+
// own tail (local.get $x) — when that trailing get is $x's ONLY read anywhere in the
|
|
1038
|
+
// function, $x never needs to leave the stack at all: fuse the pair into one tail
|
|
1039
|
+
// if-expression (explicit else = keep $x), dropping the local.set that fed the
|
|
1040
|
+
// trailing get. A `(i32.eqz Y)` condition additionally swaps arms to drop the eqz
|
|
1041
|
+
// (i32 only — an i64 value is not a valid if condition).
|
|
1042
|
+
walk(ast, (fn) => {
|
|
1043
|
+
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
1044
|
+
const bodyStart = typeof fn[1] === 'string' && fn[1][0] === '$' ? 2 : 1
|
|
1045
|
+
const results = fn.reduce((k, c) => Array.isArray(c) && c[0] === 'result' ? k + c.length - 1 : k, 0)
|
|
1046
|
+
if (results !== 1 || fn.length < bodyStart + 2) return
|
|
1047
|
+
const last = fn[fn.length - 1], prev = fn[fn.length - 2]
|
|
1048
|
+
if (!Array.isArray(last) || last[0] !== 'local.get' || last.length !== 2) return
|
|
1049
|
+
const x = last[1]
|
|
1050
|
+
if (typeof x !== 'string' || x[0] !== '$') return
|
|
1051
|
+
if (fn.some(c => Array.isArray(c) && c[0] === 'param' && c[1] === x)) return
|
|
1052
|
+
if (!Array.isArray(prev) || prev[0] !== 'if') return
|
|
1053
|
+
const { cond, thenBranch, elseBranch } = parseIf(prev)
|
|
1054
|
+
if (elseBranch || !Array.isArray(cond) || !(thenBranch?.length >= 2)) return
|
|
1055
|
+
const a = thenBranch[thenBranch.length - 1]
|
|
1056
|
+
if (!Array.isArray(a) || a[0] !== 'local.set' || a.length !== 3 || a[1] !== x || !Array.isArray(a[2])) return
|
|
1057
|
+
let t = null
|
|
1058
|
+
for (const c of fn) if (Array.isArray(c) && (c[0] === 'local' || c[0] === 'param') && c[1] === x) t = c[2]
|
|
1059
|
+
if (typeof t !== 'string' || !/^([if](32|64)|v128)$/.test(t)) return
|
|
1060
|
+
// an early exit inside the arm would skip the fused tail value — must be branch-free
|
|
1061
|
+
let jumps = false
|
|
1062
|
+
walk(thenBranch, n => {
|
|
1063
|
+
const o = Array.isArray(n) ? n[0] : n
|
|
1064
|
+
if (o === 'br' || o === 'br_if' || o === 'br_table' || o === 'return' || o === 'unreachable' ||
|
|
1065
|
+
o === 'throw' || o === 'return_call' || o === 'return_call_indirect' || o === 'try_table') jumps = true
|
|
1066
|
+
})
|
|
1067
|
+
if (jumps) return
|
|
1068
|
+
// $x must be dead outside this exact pair: its only static read anywhere in the
|
|
1069
|
+
// function is the trailing get we're about to consume (a `local.tee $x` inside
|
|
1070
|
+
// `cond` is a legitimate write for the condition test, untouched by this fusion)
|
|
1071
|
+
const uses = countLocalUses(fn).get(x)
|
|
1072
|
+
if (!uses || uses.gets !== 1) return
|
|
1073
|
+
let ifCond = cond
|
|
1074
|
+
let thenArm = ['then', ...thenBranch.slice(1, -1), a[2]]
|
|
1075
|
+
let elseArm = ['else', ['local.get', x]]
|
|
1076
|
+
if (Array.isArray(cond) && cond.length === 2 && cond[0] === 'i32.eqz') {
|
|
1077
|
+
ifCond = cond[1]
|
|
1078
|
+
const swappedThen = ['then', ...elseArm.slice(1)]
|
|
1079
|
+
elseArm = ['else', ...thenArm.slice(1)]
|
|
1080
|
+
thenArm = swappedThen
|
|
1081
|
+
}
|
|
1082
|
+
fn.splice(fn.length - 2, 2, ['if', ['result', t], ifCond, thenArm, elseArm])
|
|
1083
|
+
})
|
|
800
1084
|
return walkPost(ast, (node) => {
|
|
801
1085
|
if (!Array.isArray(node)) return
|
|
802
1086
|
const op = node[0]
|
|
@@ -806,7 +1090,45 @@ const branch = (ast) => {
|
|
|
806
1090
|
if (op === 'if') {
|
|
807
1091
|
const { condIdx, cond, thenBranch, elseBranch } = parseIf(node)
|
|
808
1092
|
const c = getConst(cond)
|
|
809
|
-
if (
|
|
1093
|
+
// (if (result T) c (then A) (else B)) → (select A B c) for small pure numeric
|
|
1094
|
+
// arms — drops ~3 B of block framing per site. select evaluates BOTH arms, so
|
|
1095
|
+
// each must be pure AND trap-free (no int div/rem, no float→int trunc), and
|
|
1096
|
+
// cheap enough that speculating it costs nothing.
|
|
1097
|
+
if (!c) {
|
|
1098
|
+
if (!Array.isArray(cond)) return
|
|
1099
|
+
const rt = node.find(p => Array.isArray(p) && p[0] === 'result')
|
|
1100
|
+
if (!rt || rt.length !== 2 || !/^[if](32|64)$/.test(rt[1])) return
|
|
1101
|
+
if (thenBranch?.length !== 2 || elseBranch?.length !== 2) return
|
|
1102
|
+
const a = thenBranch[1], b = elseBranch[1]
|
|
1103
|
+
if (!isPure(a) || !isPure(b) || count(a) > 6 || count(b) > 6) return
|
|
1104
|
+
// speculation gate: the if ran only the TAKEN arm — select runs both, so a
|
|
1105
|
+
// trap-capable op in either arm (int div/rem, float trunc, and LOADS — an
|
|
1106
|
+
// out-of-bounds address on the untaken path is a brand-new trap) is out
|
|
1107
|
+
if (hasTrap(a) || hasTrap(b) || readsMemory(a) || readsMemory(b)) return
|
|
1108
|
+
// evaluation-order gate: the if evaluated its CONDITION first, select
|
|
1109
|
+
// evaluates it LAST — an arm must not read anything the condition writes
|
|
1110
|
+
// (a tee'd local, a set global, memory a callee stores to), else the arm
|
|
1111
|
+
// sees pre-condition state (a NaN-boxed local read before its tee fed the
|
|
1112
|
+
// kernel a stale pointer — one wrong byte in a self-hosted compile)
|
|
1113
|
+
if (!isPure(cond)) {
|
|
1114
|
+
const aw = scanVal(a), bw = scanVal(b)
|
|
1115
|
+
let clash = false
|
|
1116
|
+
walk(cond, (n, p2, i2) => {
|
|
1117
|
+
if (!Array.isArray(n)) { if (i2 !== 0 && typeof n === 'string' && OPCODE[n] !== undefined) clash = true; return }
|
|
1118
|
+
const o = n[0]
|
|
1119
|
+
if (typeof o !== 'string') return
|
|
1120
|
+
if ((o === 'local.set' || o === 'local.tee') && (aw.refs.has(n[1]) || bw.refs.has(n[1]))) clash = true
|
|
1121
|
+
else if (o === 'global.set' && (aw.grefs.has(n[1]) || bw.grefs.has(n[1]))) clash = true
|
|
1122
|
+
else if (o === 'call' || o === 'call_indirect' || o === 'return_call' || o === 'return_call_indirect') {
|
|
1123
|
+
const e = callFx(n)
|
|
1124
|
+
if (!e) { if (aw.grefs.size || bw.grefs.size) clash = true }
|
|
1125
|
+
else if ([...aw.grefs, ...bw.grefs].some(g => e.wGlob.has(g))) clash = true
|
|
1126
|
+
}
|
|
1127
|
+
})
|
|
1128
|
+
if (clash) return
|
|
1129
|
+
}
|
|
1130
|
+
return ['select', a, b, cond]
|
|
1131
|
+
}
|
|
810
1132
|
const taken = c.value !== 0 && c.value !== ZERO64 ? thenBranch : elseBranch
|
|
811
1133
|
if (taken && taken.length > 1) {
|
|
812
1134
|
const contents = taken.slice(1)
|
|
@@ -833,12 +1155,18 @@ const branch = (ast) => {
|
|
|
833
1155
|
|
|
834
1156
|
// (select a b (i32.const 0)) → b
|
|
835
1157
|
// (select a b (i32.const N)) → a (N != 0)
|
|
1158
|
+
// `select` evaluates BOTH arms before choosing, so a side effect in the DISCARDED
|
|
1159
|
+
// arm (a `local.tee`/`local.set`, store, or call) must still happen — folding to the
|
|
1160
|
+
// kept arm would drop it (e.g. `p=0` compiled as the else arm of `cond?0:p`). Only
|
|
1161
|
+
// fold when the discarded arm is pure; otherwise leave the select for a later pass.
|
|
836
1162
|
if (op === 'select' && node.length >= 4) {
|
|
837
1163
|
const cond = node[node.length - 1]
|
|
838
1164
|
const c = getConst(cond)
|
|
839
1165
|
if (!c) return
|
|
840
|
-
|
|
841
|
-
|
|
1166
|
+
const zero = c.value === 0 || c.value === ZERO64
|
|
1167
|
+
const keep = zero ? node[2] : node[1], discard = zero ? node[1] : node[2]
|
|
1168
|
+
if (!isPure(discard)) return
|
|
1169
|
+
return keep
|
|
842
1170
|
}
|
|
843
1171
|
})
|
|
844
1172
|
}
|
|
@@ -1074,46 +1402,35 @@ const deadcode = (ast) => {
|
|
|
1074
1402
|
}
|
|
1075
1403
|
|
|
1076
1404
|
/**
|
|
1077
|
-
* Remove instructions after
|
|
1405
|
+
* Remove instructions after the first terminator within a block.
|
|
1406
|
+
*
|
|
1407
|
+
* Depth-aware of WAT's flat (unfolded) control style, which parse keeps as bare
|
|
1408
|
+
* sibling STRINGS: 'block'/'loop'/'if' open a sub-block whose matching bare 'end'
|
|
1409
|
+
* closes a live branch-landing label — a terminator inside it only ends that
|
|
1410
|
+
* sub-block's own tail, and anything after a bare 'end'/'else' is reachable again
|
|
1411
|
+
* (Duff's-device br_table dispatch relies on exactly this). So a cut only starts
|
|
1412
|
+
* at depth 0 and is cancelled by any later flat landing token.
|
|
1413
|
+
* Bare 'br'/'br_table' strings carry their immediates as following siblings, so
|
|
1414
|
+
* they never start a cut (their extent is unknowable here); folded arrays and the
|
|
1415
|
+
* immediate-less 'return'/'unreachable' do.
|
|
1078
1416
|
* @param {Array} block
|
|
1079
1417
|
*/
|
|
1080
1418
|
const eliminateDeadInBlock = (block) => {
|
|
1081
|
-
let
|
|
1082
|
-
let firstTerminator = -1
|
|
1083
|
-
|
|
1419
|
+
let cut = -1, depth = 0
|
|
1084
1420
|
for (let i = 1; i < block.length; i++) {
|
|
1085
|
-
const node = block[i]
|
|
1086
|
-
|
|
1087
|
-
//
|
|
1088
|
-
if (
|
|
1089
|
-
|
|
1090
|
-
if (op === '
|
|
1091
|
-
|
|
1092
|
-
if (
|
|
1093
|
-
if (firstTerminator === -1) firstTerminator = i
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
if (TERMINATORS.has(op)) {
|
|
1097
|
-
terminated = true
|
|
1098
|
-
firstTerminator = i + 1
|
|
1099
|
-
}
|
|
1100
|
-
} else if (typeof node === 'string') {
|
|
1101
|
-
// String instructions like 'unreachable', 'return', 'drop', 'nop'
|
|
1102
|
-
if (terminated) {
|
|
1103
|
-
if (firstTerminator === -1) firstTerminator = i
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
if (TERMINATORS.has(node)) {
|
|
1107
|
-
terminated = true
|
|
1108
|
-
firstTerminator = i + 1
|
|
1109
|
-
}
|
|
1421
|
+
const node = block[i], op = Array.isArray(node) ? node[0] : node
|
|
1422
|
+
if (typeof op !== 'string') continue
|
|
1423
|
+
// skip head annotations
|
|
1424
|
+
if (op === 'param' || op === 'result' || op === 'local' || op === 'type' || op === 'export') continue
|
|
1425
|
+
if (typeof node === 'string') {
|
|
1426
|
+
if (op === 'block' || op === 'loop' || op === 'if') { depth++; continue }
|
|
1427
|
+
if (op === 'end') { depth && depth--, cut = -1; continue }
|
|
1428
|
+
if (op === 'else') { cut = -1; continue }
|
|
1110
1429
|
}
|
|
1430
|
+
if (depth || cut >= 0) continue
|
|
1431
|
+
if (TERMINATORS.has(op) && (Array.isArray(node) || op === 'return' || op === 'unreachable')) cut = i + 1
|
|
1111
1432
|
}
|
|
1112
|
-
|
|
1113
|
-
// Remove dead code
|
|
1114
|
-
if (firstTerminator > 0 && firstTerminator < block.length) {
|
|
1115
|
-
block.splice(firstTerminator)
|
|
1116
|
-
}
|
|
1433
|
+
if (cut > 0 && cut < block.length) block.splice(cut)
|
|
1117
1434
|
}
|
|
1118
1435
|
|
|
1119
1436
|
// ==================== LOCAL REUSE ====================
|
|
@@ -1155,23 +1472,54 @@ const localReuse = (ast) => {
|
|
|
1155
1472
|
}
|
|
1156
1473
|
|
|
1157
1474
|
// Find which locals are actually used
|
|
1475
|
+
let numericRef = false
|
|
1158
1476
|
walk(node, (n) => {
|
|
1159
1477
|
if (!Array.isArray(n)) return
|
|
1160
1478
|
const op = n[0]
|
|
1161
1479
|
if (op === 'local.get' || op === 'local.set' || op === 'local.tee') {
|
|
1162
1480
|
const ref = n[1]
|
|
1163
|
-
if (typeof ref === 'string') usedLocals.add(ref)
|
|
1481
|
+
if (typeof ref === 'string') { usedLocals.add(ref); if (ref[0] !== '$') numericRef = true }
|
|
1482
|
+
else if (typeof ref === 'number') { usedLocals.add(String(ref)); numericRef = true }
|
|
1164
1483
|
}
|
|
1165
1484
|
})
|
|
1166
1485
|
|
|
1167
|
-
// Remove unused
|
|
1168
|
-
|
|
1486
|
+
// Remove unused named declarations — but any bare-numeric ref pins the whole
|
|
1487
|
+
// index layout (removal would shift every later slot), so only the trailing
|
|
1488
|
+
// prune below applies then.
|
|
1489
|
+
if (!numericRef) for (let i = localDecls.length - 1; i >= 0; i--) {
|
|
1169
1490
|
const { idx, node: decl } = localDecls[i]
|
|
1170
1491
|
const name = typeof decl[1] === 'string' && decl[1][0] === '$' ? decl[1] : null
|
|
1171
1492
|
if (name && !usedLocals.has(name)) {
|
|
1172
1493
|
node.splice(idx, 1)
|
|
1173
1494
|
}
|
|
1174
1495
|
}
|
|
1496
|
+
|
|
1497
|
+
// Trailing UNNAMED slots prune from the end only — no later index can shift.
|
|
1498
|
+
let params = 0
|
|
1499
|
+
for (const sub of node) if (Array.isArray(sub) && sub[0] === 'param')
|
|
1500
|
+
params += typeof sub[1] === 'string' && sub[1][0] === '$' ? 1 : sub.length - 1
|
|
1501
|
+
const decls = []
|
|
1502
|
+
for (let i = 1; i < node.length; i++) if (Array.isArray(node[i]) && node[i][0] === 'local') decls.push(node[i])
|
|
1503
|
+
let slot = params
|
|
1504
|
+
const slotOf = new Map() // decl → first slot index
|
|
1505
|
+
for (const d of decls) {
|
|
1506
|
+
slotOf.set(d, slot)
|
|
1507
|
+
slot += typeof d[1] === 'string' && d[1][0] === '$' ? 1 : d.length - 1
|
|
1508
|
+
}
|
|
1509
|
+
for (let i = decls.length - 1; i >= 0; i--) {
|
|
1510
|
+
const d = decls[i]
|
|
1511
|
+
if (typeof d[1] === 'string' && d[1][0] === '$') {
|
|
1512
|
+
if (usedLocals.has(d[1]) || usedLocals.has(String(slotOf.get(d)))) break
|
|
1513
|
+
node.splice(node.indexOf(d), 1)
|
|
1514
|
+
continue
|
|
1515
|
+
}
|
|
1516
|
+
let len = d.length
|
|
1517
|
+
while (len > 1 && !usedLocals.has(String(slotOf.get(d) + len - 2))) len--
|
|
1518
|
+
if (len === d.length) break
|
|
1519
|
+
d.length = len
|
|
1520
|
+
if (len === 1) node.splice(node.indexOf(d), 1)
|
|
1521
|
+
else break
|
|
1522
|
+
}
|
|
1175
1523
|
})
|
|
1176
1524
|
|
|
1177
1525
|
return ast
|
|
@@ -1201,6 +1549,9 @@ const IMPURE_SUBSTRINGS = ['.store', 'memory.', '.atomic.']
|
|
|
1201
1549
|
* Conservative — returns false for anything that might trap, mutate state, or branch.
|
|
1202
1550
|
*/
|
|
1203
1551
|
const isPure = (node) => {
|
|
1552
|
+
// A bare string can be a stack-style INSTRUCTION token ('return', 'drop', …), not
|
|
1553
|
+
// just an immediate — judge it by the same op tables as the folded form.
|
|
1554
|
+
if (typeof node === 'string') return !IMPURE_OPS.has(node) && !IMPURE_SUBSTRINGS.some(s => node.includes(s))
|
|
1204
1555
|
if (!Array.isArray(node)) return true
|
|
1205
1556
|
const op = node[0]
|
|
1206
1557
|
if (typeof op !== 'string') return false
|
|
@@ -1210,6 +1561,75 @@ const isPure = (node) => {
|
|
|
1210
1561
|
return true
|
|
1211
1562
|
}
|
|
1212
1563
|
|
|
1564
|
+
// ==================== INTERPROCEDURAL EFFECT SUMMARY ====================
|
|
1565
|
+
// Transitive per-function write effects: does calling $f (and everything it can
|
|
1566
|
+
// reach) write linear memory, or which globals can it set? Any escape from the
|
|
1567
|
+
// analyzable world — an imported func, call_indirect, a table/exception op, a
|
|
1568
|
+
// numeric or unresolvable callee — marks the function unknown (= writes
|
|
1569
|
+
// everything). Computed once per optimize() entry; passes only ever use it to
|
|
1570
|
+
// RELAX a conservative default, and optimization never adds effects to a body
|
|
1571
|
+
// that didn't have them, so a stale summary stays sound.
|
|
1572
|
+
let CALLFX = null
|
|
1573
|
+
|
|
1574
|
+
const computeCallEffects = (ast) => {
|
|
1575
|
+
const fx = new Map() // name → { wMem, wGlob:Set, unknown, calls:Set }
|
|
1576
|
+
if (!Array.isArray(ast) || ast[0] !== 'module') return fx
|
|
1577
|
+
for (const n of ast.slice(1)) {
|
|
1578
|
+
if (!Array.isArray(n)) continue
|
|
1579
|
+
if (n[0] === 'import') {
|
|
1580
|
+
const f = n.find(c => Array.isArray(c) && c[0] === 'func')
|
|
1581
|
+
if (f && typeof f[1] === 'string' && f[1][0] === '$')
|
|
1582
|
+
fx.set(f[1], { wMem: true, rMem: true, wGlob: new Set(), rGlob: new Set(), unknown: true, calls: new Set() })
|
|
1583
|
+
continue
|
|
1584
|
+
}
|
|
1585
|
+
if (n[0] !== 'func' || typeof n[1] !== 'string' || n[1][0] !== '$') continue
|
|
1586
|
+
const e = { wMem: false, rMem: false, wGlob: new Set(), rGlob: new Set(), unknown: false, calls: new Set() }
|
|
1587
|
+
const op = (o, tgt) => {
|
|
1588
|
+
if (o === 'call' || o === 'return_call') { typeof tgt === 'string' && tgt[0] === '$' ? e.calls.add(tgt) : e.unknown = true }
|
|
1589
|
+
else if (o === 'call_indirect' || o === 'return_call_indirect' || o === 'throw' || o === 'throw_ref') e.unknown = true
|
|
1590
|
+
else if (o === 'global.set') { typeof tgt === 'string' ? e.wGlob.add(tgt) : e.unknown = true }
|
|
1591
|
+
else if (o === 'global.get') { typeof tgt === 'string' ? e.rGlob.add(tgt) : e.unknown = true }
|
|
1592
|
+
else if (o.includes('.store') || o === 'memory.copy' || o === 'memory.fill' ||
|
|
1593
|
+
o === 'memory.init' || o === 'memory.grow' || o.includes('table.') || o.includes('.atomic')) e.wMem = e.rMem = true
|
|
1594
|
+
else if (o.includes('.load') || o === 'memory.size') e.rMem = true
|
|
1595
|
+
}
|
|
1596
|
+
walk(n, (c, parent, idx) => {
|
|
1597
|
+
if (!Array.isArray(c)) {
|
|
1598
|
+
// bare flat tokens carry the same effects as their folded forms
|
|
1599
|
+
if (idx === 0 || typeof c !== 'string' || OPCODE[c] === undefined) return
|
|
1600
|
+
op(c, parent[idx + 1])
|
|
1601
|
+
return
|
|
1602
|
+
}
|
|
1603
|
+
if (typeof c[0] === 'string') op(c[0], c[1])
|
|
1604
|
+
})
|
|
1605
|
+
fx.set(n[1], e)
|
|
1606
|
+
}
|
|
1607
|
+
// propagate through call edges to a fixpoint (effects only grow — terminates)
|
|
1608
|
+
for (let dirty = true; dirty;) {
|
|
1609
|
+
dirty = false
|
|
1610
|
+
for (const e of fx.values()) {
|
|
1611
|
+
if (e.unknown) continue
|
|
1612
|
+
for (const callee of e.calls) {
|
|
1613
|
+
const t = fx.get(callee)
|
|
1614
|
+
if (!t) { e.unknown = true; dirty = true; break }
|
|
1615
|
+
if (t.unknown && !e.unknown) { e.unknown = true; dirty = true; break }
|
|
1616
|
+
if (t.wMem && !e.wMem) { e.wMem = true; dirty = true }
|
|
1617
|
+
if (t.rMem && !e.rMem) { e.rMem = true; dirty = true }
|
|
1618
|
+
for (const g of t.wGlob) if (!e.wGlob.has(g)) { e.wGlob.add(g); dirty = true }
|
|
1619
|
+
for (const g of t.rGlob) if (!e.rGlob.has(g)) { e.rGlob.add(g); dirty = true }
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
return fx
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
/** Effect summary for a call NODE — null when the callee can't be summarized. */
|
|
1627
|
+
const callFx = (n) => {
|
|
1628
|
+
if (!CALLFX || !Array.isArray(n) || (n[0] !== 'call' && n[0] !== 'return_call') || typeof n[1] !== 'string') return null
|
|
1629
|
+
const e = CALLFX.get(n[1])
|
|
1630
|
+
return e && !e.unknown ? e : null
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1213
1633
|
// Structured / control-flow forms: they do NOT evaluate all children eagerly
|
|
1214
1634
|
// (an `if` runs one arm; a `block`/`loop` scopes branches), so their side effects
|
|
1215
1635
|
// can't be flattened to the children's — they stay whole under a drop. (`br*`,
|
|
@@ -1243,17 +1663,51 @@ const dropEffects = (node) => {
|
|
|
1243
1663
|
}
|
|
1244
1664
|
|
|
1245
1665
|
/** Count all local.get/set/tee occurrences in one walk */
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
if (
|
|
1252
|
-
|
|
1253
|
-
|
|
1666
|
+
// One tally walker serves the from-scratch count AND the maintained-count
|
|
1667
|
+
// deltas — a second implementation would drift.
|
|
1668
|
+
const tallyLocals = (node, counts, d) => {
|
|
1669
|
+
const ensure = name => { let c = counts.get(name); if (!c) counts.set(name, c = { gets: 0, sets: 0, tees: 0 }); return c }
|
|
1670
|
+
walk(node, (n, parent, idx) => {
|
|
1671
|
+
if (Array.isArray(n)) {
|
|
1672
|
+
if (n.length < 2 || typeof n[1] !== 'string') return
|
|
1673
|
+
if (n[0] === 'local.get') ensure(n[1]).gets += d
|
|
1674
|
+
else if (n[0] === 'local.set') ensure(n[1]).sets += d
|
|
1675
|
+
else if (n[0] === 'local.tee') ensure(n[1]).tees += d
|
|
1676
|
+
return
|
|
1677
|
+
}
|
|
1678
|
+
// bare flat form: `local.get` `$x` as sibling tokens (idx 0 is a folded
|
|
1679
|
+
// node's own head). Uncounted refs would let exact-occurrence passes treat
|
|
1680
|
+
// a flat-referenced local as dead.
|
|
1681
|
+
if (idx > 0 && (n === 'local.get' || n === 'local.set' || n === 'local.tee')) {
|
|
1682
|
+
const tgt = parent[idx + 1]
|
|
1683
|
+
if (typeof tgt === 'string' && tgt[0] === '$')
|
|
1684
|
+
ensure(tgt)[n === 'local.get' ? 'gets' : n === 'local.set' ? 'sets' : 'tees'] += d
|
|
1685
|
+
}
|
|
1254
1686
|
})
|
|
1255
1687
|
return counts
|
|
1256
1688
|
}
|
|
1689
|
+
const countLocalUses = (node) => tallyLocals(node, new Map(), 1)
|
|
1690
|
+
|
|
1691
|
+
// Maintained whole-function use-counts for the propagate family: every mutation
|
|
1692
|
+
// site reports the removed/inserted subtree instead of the driver re-counting
|
|
1693
|
+
// the function each round. Null outside propagate — the helpers no-op. The
|
|
1694
|
+
// recount ORACLE (globalThis.__CNT_ORACLE) re-derives from scratch after every
|
|
1695
|
+
// sub-pass and throws on divergence; the test battery runs with it enabled.
|
|
1696
|
+
let CNT = null, CNT_FN = null
|
|
1697
|
+
const cntSub = (node) => { if (CNT) tallyLocals(node, CNT, -1) }
|
|
1698
|
+
const cntAdd = (node) => { if (CNT) tallyLocals(node, CNT, 1) }
|
|
1699
|
+
const cntOracle = (funcNode, site) => {
|
|
1700
|
+
if (!CNT || !globalThis.__CNT_ORACLE) return
|
|
1701
|
+
const fresh = countLocalUses(funcNode)
|
|
1702
|
+
const names = new Set([...fresh.keys(), ...CNT.keys()])
|
|
1703
|
+
for (const k of names) {
|
|
1704
|
+
const a = fresh.get(k) || { gets: 0, sets: 0, tees: 0 }
|
|
1705
|
+
const b = CNT.get(k) || { gets: 0, sets: 0, tees: 0 }
|
|
1706
|
+
if (a.gets !== b.gets || a.sets !== b.sets || a.tees !== b.tees) {
|
|
1707
|
+
throw Error(`counts drift after ${site} in ${funcNode[1]}: ${k} fresh ${JSON.stringify(a)} maintained ${JSON.stringify(b)}`)
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1257
1711
|
|
|
1258
1712
|
/** A constant whose inlined form (opcode + immediate) is no wider than the ~2 B
|
|
1259
1713
|
* `local.get` it would replace — so propagating it to every use is byte-neutral
|
|
@@ -1290,11 +1744,7 @@ const canSubst = (k) => (k.pure && k.singleUse) || isTinyConst(k.val) || k.copy
|
|
|
1290
1744
|
|
|
1291
1745
|
/** Drop tracked values that read `$name`: rewriting `$name` makes them stale. */
|
|
1292
1746
|
const purgeRefs = (known, name) => {
|
|
1293
|
-
for (const [key, tracked] of known)
|
|
1294
|
-
let refs = false
|
|
1295
|
-
walk(tracked.val, n => { if (Array.isArray(n) && (n[0] === 'local.get' || n[0] === 'local.tee') && n[1] === name) refs = true })
|
|
1296
|
-
if (refs) known.delete(key)
|
|
1297
|
-
}
|
|
1747
|
+
for (const [key, tracked] of known) if (tracked.refs.has(name)) known.delete(key)
|
|
1298
1748
|
}
|
|
1299
1749
|
|
|
1300
1750
|
/** Drop tracked values that read global `$name`: a `global.set $name` makes them stale.
|
|
@@ -1303,11 +1753,25 @@ const purgeRefs = (known, name) => {
|
|
|
1303
1753
|
* intervening `f = …` and substitute the NEW global. That silently breaks the canonical
|
|
1304
1754
|
* pointer swap `let s = f; f = g; g = s` (g would read post-swap f, i.e. itself). */
|
|
1305
1755
|
const purgeGlobalRefs = (known, name) => {
|
|
1306
|
-
for (const [key, tracked] of known)
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1756
|
+
for (const [key, tracked] of known) if (tracked.grefs.has(name)) known.delete(key)
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
/** One walk over a tracked value collecting every fact the invalidation paths ask
|
|
1760
|
+
* later: which locals/globals it reads, whether it reads memory, and whether it
|
|
1761
|
+
* reads any state a call could mutate (memory, globals, tables, nested calls). */
|
|
1762
|
+
const scanVal = (val) => {
|
|
1763
|
+
const refs = new Set(), grefs = new Set()
|
|
1764
|
+
let mem = false, ext = false
|
|
1765
|
+
walk(val, n => {
|
|
1766
|
+
if (!Array.isArray(n)) return
|
|
1767
|
+
const o = n[0]
|
|
1768
|
+
if (o === 'local.get' || o === 'local.tee') { if (typeof n[1] === 'string') refs.add(n[1]) }
|
|
1769
|
+
else if (o === 'global.get') { if (typeof n[1] === 'string') grefs.add(n[1]); ext = true }
|
|
1770
|
+
else if (o === 'call' || o === 'call_indirect' || o === 'return_call' || o === 'return_call_indirect' ||
|
|
1771
|
+
o === 'table.get' || o === 'table.size') ext = true
|
|
1772
|
+
else if (typeof o === 'string' && (o.includes('.load') || o === 'memory.copy' || o === 'memory.size')) mem = ext = true
|
|
1773
|
+
})
|
|
1774
|
+
return { refs, grefs, mem, ext }
|
|
1311
1775
|
}
|
|
1312
1776
|
|
|
1313
1777
|
/** True if `node` recursively contains an op that may read linear memory.
|
|
@@ -1345,7 +1809,7 @@ const writesMemory = (node) => {
|
|
|
1345
1809
|
if (!Array.isArray(node)) return false
|
|
1346
1810
|
const op = node[0]
|
|
1347
1811
|
if (typeof op === 'string') {
|
|
1348
|
-
if (op.
|
|
1812
|
+
if (op.includes('.store') || op === 'memory.copy' || op === 'memory.fill' || op === 'memory.init' || op === 'memory.grow') return true
|
|
1349
1813
|
// Atomic RMW / store / notify all mutate memory; `.atomic.load` doesn't.
|
|
1350
1814
|
if (op.includes('.atomic.') && !op.endsWith('.load')) return true
|
|
1351
1815
|
}
|
|
@@ -1383,7 +1847,7 @@ const substGets = (node, known) => {
|
|
|
1383
1847
|
}
|
|
1384
1848
|
for (let i = 1; i < node.length; i++) {
|
|
1385
1849
|
const r = substGets(node[i], inner)
|
|
1386
|
-
if (r !== node[i]) node[i] = r
|
|
1850
|
+
if (r !== node[i]) { cntSub(node[i]); cntAdd(r); node[i] = r }
|
|
1387
1851
|
// WASM evaluates operands left-to-right. A `local.set`/`local.tee` in this
|
|
1388
1852
|
// child updates the local before the next sibling reads it — drop tracked
|
|
1389
1853
|
// entries that are now stale, else a pre-tee constant leaks into the next
|
|
@@ -1418,10 +1882,56 @@ const forwardPropagate = (funcNode, params, useCounts) => {
|
|
|
1418
1882
|
let changed = false
|
|
1419
1883
|
const getUseCount = name => useCounts.get(name) || { gets: 0, sets: 0, tees: 0 }
|
|
1420
1884
|
const known = new Map()
|
|
1885
|
+
// depth of flat (bare-token) control nesting; each binding records the depth it
|
|
1886
|
+
// was created at — see the bare-token barrier below for why that bounds validity
|
|
1887
|
+
let depth = 0
|
|
1421
1888
|
|
|
1422
1889
|
for (let i = 1; i < funcNode.length; i++) {
|
|
1423
1890
|
const instr = funcNode[i]
|
|
1424
|
-
|
|
1891
|
+
// Flat-form instructions live as bare sibling TOKENS, invisible to the array
|
|
1892
|
+
// handlers below (wax: folded exprs inside a flat control skeleton). Only
|
|
1893
|
+
// tokens that can invalidate a binding are barriers; bare VALUE ops can't
|
|
1894
|
+
// touch locals, so tracking flows straight across them. Validity across flat
|
|
1895
|
+
// control rests on one structural fact: wasm forward-branches only to ends of
|
|
1896
|
+
// ENCLOSING blocks, so any path that skips a write also skips every later
|
|
1897
|
+
// point inside the write's block — a binding is path-valid anywhere within
|
|
1898
|
+
// the flat block that created it, and dies at that block's 'end'/'else'.
|
|
1899
|
+
// A 'loop' back-edge re-executes an unknown suffix, so only bindings whose
|
|
1900
|
+
// local has no other write and whose value reads no mutable state (locals,
|
|
1901
|
+
// globals, memory, calls) may cross a loop header.
|
|
1902
|
+
if (!Array.isArray(instr)) {
|
|
1903
|
+
if (typeof instr !== 'string') continue
|
|
1904
|
+
if (instr === 'block' || instr === 'if') depth++
|
|
1905
|
+
else if (instr === 'loop') {
|
|
1906
|
+
depth++
|
|
1907
|
+
for (const [key, t] of known) {
|
|
1908
|
+
const uses = getUseCount(key)
|
|
1909
|
+
if (!(uses.sets + uses.tees <= 1 && t.pure && !t.refs.size && !t.ext)) known.delete(key)
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
else if (instr === 'else') { for (const [key, t] of known) if (t.depth >= depth) known.delete(key) }
|
|
1913
|
+
else if (instr === 'end') { depth = depth > 0 ? depth - 1 : 0; for (const [key, t] of known) if (t.depth > depth) known.delete(key) }
|
|
1914
|
+
else if (instr === 'try' || instr === 'try_table') { depth++; known.clear() }
|
|
1915
|
+
else if (instr === 'delegate') { depth = depth > 0 ? depth - 1 : 0; known.clear() }
|
|
1916
|
+
else if (instr === 'catch' || instr === 'catch_all') known.clear()
|
|
1917
|
+
else if (instr === 'local.set' || instr === 'local.tee') {
|
|
1918
|
+
const tgt = funcNode[i + 1]
|
|
1919
|
+
if (typeof tgt === 'string') { known.delete(tgt); purgeRefs(known, tgt) }
|
|
1920
|
+
else known.clear() // numeric index may alias a tracked name — give up
|
|
1921
|
+
}
|
|
1922
|
+
else if (instr === 'global.set') {
|
|
1923
|
+
const tgt = funcNode[i + 1]
|
|
1924
|
+
typeof tgt === 'string' ? purgeGlobalRefs(known, tgt) : known.clear()
|
|
1925
|
+
}
|
|
1926
|
+
else if (instr === 'call' || instr === 'call_indirect' || instr === 'return_call' || instr === 'return_call_indirect') {
|
|
1927
|
+
for (const [key, tracked] of known) if (tracked.ext) known.delete(key)
|
|
1928
|
+
}
|
|
1929
|
+
else if (instr.includes('.store') || instr === 'memory.copy' || instr === 'memory.fill' || instr === 'memory.init' ||
|
|
1930
|
+
(instr.includes('.atomic.') && !instr.endsWith('.load'))) {
|
|
1931
|
+
for (const [key, tracked] of known) if (tracked.readsMem) known.delete(key)
|
|
1932
|
+
}
|
|
1933
|
+
continue
|
|
1934
|
+
}
|
|
1425
1935
|
const op = instr[0]
|
|
1426
1936
|
|
|
1427
1937
|
if (op === 'param' || op === 'result' || op === 'local' || op === 'type' || op === 'export') continue
|
|
@@ -1433,7 +1943,7 @@ const forwardPropagate = (funcNode, params, useCounts) => {
|
|
|
1433
1943
|
// resolves to a substitution (bare `(local.get $x)` root case) — assign
|
|
1434
1944
|
// back so the bare-RHS pattern actually propagates.
|
|
1435
1945
|
const sr = substGets(instr[2], known)
|
|
1436
|
-
if (sr !== instr[2]) { instr[2] = sr; changed = true }
|
|
1946
|
+
if (sr !== instr[2]) { cntSub(instr[2]); cntAdd(sr); instr[2] = sr; changed = true }
|
|
1437
1947
|
// Nested `local.set`/`local.tee` inside the RHS already ran when the next
|
|
1438
1948
|
// statement begins — drop tracked values that read those locals, else a
|
|
1439
1949
|
// later `local.get` substitutes a stale expression (e.g. `$ptr`'s
|
|
@@ -1452,29 +1962,49 @@ const forwardPropagate = (funcNode, params, useCounts) => {
|
|
|
1452
1962
|
if (writesMemory(instr[2])) {
|
|
1453
1963
|
for (const [key, tracked] of known) if (tracked.readsMem) known.delete(key)
|
|
1454
1964
|
}
|
|
1965
|
+
const facts = scanVal(instr[2])
|
|
1455
1966
|
known.set(instr[1], {
|
|
1456
1967
|
val: instr[2], pure: isPure(instr[2]),
|
|
1457
|
-
readsMem:
|
|
1968
|
+
refs: facts.refs, grefs: facts.grefs, readsMem: facts.mem, ext: facts.ext,
|
|
1458
1969
|
singleUse: uses.gets <= 1 && uses.sets <= 1 && uses.tees === 0,
|
|
1459
|
-
copy: isLocalCopy(instr[2], instr[1])
|
|
1970
|
+
copy: isLocalCopy(instr[2], instr[1]),
|
|
1971
|
+
depth
|
|
1460
1972
|
})
|
|
1461
1973
|
continue
|
|
1462
1974
|
}
|
|
1463
1975
|
|
|
1976
|
+
// An if's TEST evaluates before the branch is entered — substitute into it
|
|
1977
|
+
// with the pre-branch knowledge before invalidating. substGets mutates
|
|
1978
|
+
// INTERIOR nodes in place and returns the same root — a root compare misses
|
|
1979
|
+
// those, and an unreported substitution skips the driver's use-count refresh,
|
|
1980
|
+
// handing sinkSets stale counts (its single-use substitute then deletes a
|
|
1981
|
+
// store whose second, freshly-substituted read still exists: an orphaned get
|
|
1982
|
+
// reading zero — one wrong NaN-boxed byte in a self-hosted compile).
|
|
1983
|
+
if (op === 'if') {
|
|
1984
|
+
const { condIdx, cond } = parseIf(instr)
|
|
1985
|
+
if (Array.isArray(cond)) {
|
|
1986
|
+
const prev = clone(cond)
|
|
1987
|
+
const r = substGets(cond, known)
|
|
1988
|
+
if (r !== cond) { cntSub(cond); cntAdd(r); instr[condIdx] = r; changed = true }
|
|
1989
|
+
else if (!equal(prev, cond)) changed = true
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1464
1992
|
// Invalidate at control-flow boundaries
|
|
1465
1993
|
if (isBranchScope(op)) known.clear()
|
|
1466
1994
|
// Calls invalidate tracked values that read state a callee can mutate
|
|
1467
1995
|
// (memory, globals, tables, nested calls). Pure expressions over locals
|
|
1468
1996
|
// and constants survive — callees can't reach caller locals.
|
|
1469
1997
|
if (op === 'call' || op === 'call_indirect' || op === 'return_call' || op === 'return_call_indirect')
|
|
1470
|
-
for (const [key, tracked] of known) if (
|
|
1998
|
+
for (const [key, tracked] of known) if (tracked.ext) known.delete(key)
|
|
1471
1999
|
|
|
1472
2000
|
// Substitute: standalone local.get (walkPost can't replace root)
|
|
1473
2001
|
if (op === 'local.get' && instr.length === 2 && typeof instr[1] === 'string') {
|
|
1474
2002
|
const tracked = known.get(instr[1])
|
|
1475
2003
|
if (tracked && canSubst(tracked)) {
|
|
1476
2004
|
const replacement = clone(tracked.val)
|
|
2005
|
+
cntSub(instr)
|
|
1477
2006
|
instr.length = 0; instr.push(...(Array.isArray(replacement) ? replacement : [replacement]))
|
|
2007
|
+
cntAdd(instr)
|
|
1478
2008
|
changed = true; continue
|
|
1479
2009
|
}
|
|
1480
2010
|
}
|
|
@@ -1510,63 +2040,423 @@ const forwardPropagate = (funcNode, params, useCounts) => {
|
|
|
1510
2040
|
}
|
|
1511
2041
|
|
|
1512
2042
|
/**
|
|
1513
|
-
*
|
|
1514
|
-
*
|
|
1515
|
-
*
|
|
2043
|
+
* A `(local.set $x V)` (pure V) immediately before a `loop` is dead when the write can
|
|
2044
|
+
* never be observed: (a) inside the loop, every path from the top reaches a write of $x
|
|
2045
|
+
* before any read — checked by an evaluation-order scan where if-arms fork (a read
|
|
2046
|
+
* counts against EITHER arm, a def only when BOTH arms guarantee it), nested loops and
|
|
2047
|
+
* try_table are opaque (reads fail, defs aren't credited), br/br_table/return end their
|
|
2048
|
+
* path, br_if falls through, and any br inside a block resets the def state after it —
|
|
2049
|
+
* and (b) $x is read NOWHERE outside that loop body, so the loop's exit-time value is
|
|
2050
|
+
* unobservable regardless of which exit path ran (the counterexample class: a zero-trip
|
|
2051
|
+
* inner loop leaking the previous outer iteration's value to a post-loop read).
|
|
2052
|
+
*/
|
|
2053
|
+
const deadThroughLoop = (funcNode, name, loop) => {
|
|
2054
|
+
let total = 0, inside = 0
|
|
2055
|
+
walk(funcNode, n => { if (Array.isArray(n) && n[0] === 'local.get' && n[1] === name) total++ })
|
|
2056
|
+
walk(loop, n => { if (Array.isArray(n) && n[0] === 'local.get' && n[1] === name) inside++ })
|
|
2057
|
+
if (total !== inside) return false // (b) — read outside the loop may observe it
|
|
2058
|
+
const readsAny = (n) => { let r = false; walk(n, c => { if (Array.isArray(c) && c[0] === 'local.get' && c[1] === name) r = true }); return r }
|
|
2059
|
+
const hasBr = (n) => { let r = false; walk(n, c => { const o = Array.isArray(c) ? c[0] : c; if (o === 'br' || o === 'br_if' || o === 'br_table') r = true }); return r }
|
|
2060
|
+
const scanList = (list, from, def) => {
|
|
2061
|
+
for (let i = from; i < list.length; i++) {
|
|
2062
|
+
const r = scanExpr(list[i], def)
|
|
2063
|
+
if (!r.ok) return r
|
|
2064
|
+
def = r.def
|
|
2065
|
+
if (r.end) return { ok: true, def, end: true }
|
|
2066
|
+
}
|
|
2067
|
+
return { ok: true, def }
|
|
2068
|
+
}
|
|
2069
|
+
const scanExpr = (n, def) => {
|
|
2070
|
+
if (!Array.isArray(n)) {
|
|
2071
|
+
if (typeof n === 'string' && OPCODE[n] !== undefined) return { ok: false } // flat token — order unattributable
|
|
2072
|
+
return { ok: true, def }
|
|
2073
|
+
}
|
|
2074
|
+
const op = n[0]
|
|
2075
|
+
if (op === 'param' || op === 'result' || op === 'type' || op === 'local' || op === 'export') return { ok: true, def }
|
|
2076
|
+
if (op === 'local.get') return n[1] === name && !def ? { ok: false } : { ok: true, def }
|
|
2077
|
+
if (op === 'local.set' || op === 'local.tee') {
|
|
2078
|
+
const r = n.length > 2 ? scanExpr(n[2], def) : { ok: true, def }
|
|
2079
|
+
if (!r.ok || r.end) return r
|
|
2080
|
+
return { ok: true, def: r.def || n[1] === name }
|
|
2081
|
+
}
|
|
2082
|
+
if (op === 'if') {
|
|
2083
|
+
const { condIdx, thenBranch, elseBranch } = parseIf(n)
|
|
2084
|
+
let d = def
|
|
2085
|
+
for (let k = 1; k <= condIdx && k < n.length; k++) {
|
|
2086
|
+
if (n[k] === thenBranch || n[k] === elseBranch) continue
|
|
2087
|
+
const r = scanExpr(n[k], d)
|
|
2088
|
+
if (!r.ok || r.end) return r
|
|
2089
|
+
d = r.def
|
|
2090
|
+
}
|
|
2091
|
+
const t = thenBranch ? scanList(thenBranch, 1, d) : { ok: true, def: d }
|
|
2092
|
+
if (!t.ok) return t
|
|
2093
|
+
const e = elseBranch ? scanList(elseBranch, 1, d) : { ok: true, def: d }
|
|
2094
|
+
if (!e.ok) return e
|
|
2095
|
+
return { ok: true, def: (t.end ? d : t.def) && (e.end ? d : e.def) }
|
|
2096
|
+
}
|
|
2097
|
+
if (op === 'loop' || op === 'try_table') return readsAny(n) ? { ok: false } : { ok: true, def }
|
|
2098
|
+
if (op === 'block') {
|
|
2099
|
+
const r = scanList(n, 1, def)
|
|
2100
|
+
if (!r.ok) return r
|
|
2101
|
+
// brs inside a block reconverge after it: a def already made before the block
|
|
2102
|
+
// survives; one made inside only counts when nothing could jump around it
|
|
2103
|
+
return { ok: true, def: def || (r.def && !hasBr(n)) }
|
|
2104
|
+
}
|
|
2105
|
+
if (op === 'br' || op === 'br_table' || op === 'return' || op === 'unreachable' || op === 'throw' ||
|
|
2106
|
+
op === 'return_call' || op === 'return_call_indirect') {
|
|
2107
|
+
let d = def
|
|
2108
|
+
for (let k = 1; k < n.length; k++) { const r = scanExpr(n[k], d); if (!r.ok) return r; d = r.def }
|
|
2109
|
+
return { ok: true, def: d, end: true }
|
|
2110
|
+
}
|
|
2111
|
+
let d = def
|
|
2112
|
+
for (let k = 1; k < n.length; k++) {
|
|
2113
|
+
const r = scanExpr(n[k], d)
|
|
2114
|
+
if (!r.ok || r.end) return r
|
|
2115
|
+
d = r.def
|
|
2116
|
+
}
|
|
2117
|
+
return { ok: true, def: d }
|
|
2118
|
+
}
|
|
2119
|
+
return scanList(loop, 1, false).ok
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
/**
|
|
2123
|
+
* Sink (local.set $x V) into the next statement when that statement's FIRST-evaluated
|
|
2124
|
+
* instruction is (local.get $x): sole-use pairs substitute V outright, multi-use ones
|
|
2125
|
+
* fuse into (local.tee $x V). Subsumes the old adjacent-only set/get-pair and tee
|
|
2126
|
+
* passes — the get may sit arbitrarily deep (call argument, store address, br_if
|
|
2127
|
+
* condition) as long as it is on the first-evaluated path, which keeps V's
|
|
2128
|
+
* evaluation order identical.
|
|
2129
|
+
* @param {Array} funcNode - straight-line scope (body / block / then / else)
|
|
1516
2130
|
* @param {Set<string>} params
|
|
1517
2131
|
* @param {Map<string,{gets:number,sets:number,tees:number}>} useCounts
|
|
1518
2132
|
*/
|
|
1519
|
-
|
|
1520
|
-
|
|
2133
|
+
/** True if any conditionally- or repeatedly-entered construct appears anywhere in `n`
|
|
2134
|
+
* — a `local.tee` found inside one does not dominate its following siblings. */
|
|
2135
|
+
const hasConditional = (n) => {
|
|
2136
|
+
let found = false
|
|
2137
|
+
walk(n, x => { if (Array.isArray(x) && (x[0] === 'if' || x[0] === 'loop' || x[0] === 'block' ||
|
|
2138
|
+
x[0] === 'then' || x[0] === 'else' || x[0] === 'try_table')) found = true })
|
|
2139
|
+
return found
|
|
2140
|
+
}
|
|
1521
2141
|
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
2142
|
+
/**
|
|
2143
|
+
* Sink a pure, trap-free `(local.set $X Expr)` into the sole `if`-arm that reads it,
|
|
2144
|
+
* when it sits immediately before that `if`. An eagerly-computed pre-branch value that
|
|
2145
|
+
* only one arm ever consumes gains nothing from running on the other path — moving it
|
|
2146
|
+
* to be the arm's first statement is execution-order-neutral (it was already the next
|
|
2147
|
+
* thing to run on that path) and lets a later `sinkSets` sweep fuse it with the arm's
|
|
2148
|
+
* first use into a `tee`. Requires: a PURE condition (Expr crosses its evaluation — a
|
|
2149
|
+
* cond that writes state Expr reads would change the sunk value); exactly one arm
|
|
2150
|
+
* (not both, not neither) references $X; and $X has no other occurrence anywhere in
|
|
2151
|
+
* the function (so nothing after the `if`, and no other arm, needs it).
|
|
2152
|
+
*/
|
|
2153
|
+
const sinkIntoBranch = (scope, params, counts) => {
|
|
2154
|
+
let changed = false
|
|
2155
|
+
for (let i = 1; i < scope.length - 1; i++) {
|
|
2156
|
+
const st = scope[i]
|
|
2157
|
+
if (!(Array.isArray(st) && st[0] === 'local.set' && st.length === 3 && typeof st[1] === 'string' &&
|
|
2158
|
+
Array.isArray(st[2]) && isPure(st[2]) && !hasTrap(st[2]))) continue
|
|
2159
|
+
const name = st[1]
|
|
2160
|
+
const nxt = scope[i + 1]
|
|
2161
|
+
if (!(Array.isArray(nxt) && nxt[0] === 'if')) continue
|
|
2162
|
+
const { cond, thenBranch, elseBranch } = parseIf(nxt)
|
|
2163
|
+
if (!Array.isArray(cond) || !isPure(cond)) continue
|
|
2164
|
+
let condTouches = false
|
|
2165
|
+
walk(cond, n => { if (Array.isArray(n) && typeof n[1] === 'string' && n[1] === name &&
|
|
2166
|
+
(n[0] === 'local.get' || n[0] === 'local.set' || n[0] === 'local.tee')) condTouches = true })
|
|
2167
|
+
if (condTouches) continue
|
|
2168
|
+
const touchCount = (branch) => {
|
|
2169
|
+
if (!branch) return 0
|
|
2170
|
+
let c = 0
|
|
2171
|
+
walk(branch, n => { if (Array.isArray(n) && typeof n[1] === 'string' && n[1] === name &&
|
|
2172
|
+
(n[0] === 'local.get' || n[0] === 'local.set' || n[0] === 'local.tee')) c++ })
|
|
2173
|
+
return c
|
|
2174
|
+
}
|
|
2175
|
+
const inThen = touchCount(thenBranch), inElse = touchCount(elseBranch)
|
|
2176
|
+
if ((inThen > 0) === (inElse > 0)) continue // must be exactly one arm
|
|
2177
|
+
const target = inThen ? thenBranch : elseBranch
|
|
2178
|
+
// total occurrences in the whole function must equal exactly this set + the
|
|
2179
|
+
// uses inside the target arm — nothing else anywhere reads/writes $X
|
|
2180
|
+
const total = counts.get(name)
|
|
2181
|
+
const totalOccur = total ? total.gets + total.sets + total.tees : 0
|
|
2182
|
+
if (totalOccur !== 1 + (inThen || inElse)) continue
|
|
2183
|
+
scope.splice(i, 1)
|
|
2184
|
+
target.splice(1, 0, st)
|
|
1535
2185
|
changed = true
|
|
1536
|
-
i--
|
|
2186
|
+
i--
|
|
1537
2187
|
}
|
|
1538
|
-
|
|
1539
2188
|
return changed
|
|
1540
2189
|
}
|
|
1541
2190
|
|
|
1542
2191
|
/**
|
|
1543
|
-
*
|
|
1544
|
-
*
|
|
1545
|
-
*
|
|
1546
|
-
*
|
|
1547
|
-
*
|
|
2192
|
+
* Fuse a plain copy `(local.set $A (local.get $B))` into an earlier, dominating
|
|
2193
|
+
* `(local.tee $B V)` found by scanning backward through a short run of straight-line
|
|
2194
|
+
* (branch-free) sibling statements: rename that tee's target to $A and drop the copy.
|
|
2195
|
+
* Sound when — $B is a non-param written exactly once (that tee) and read exactly
|
|
2196
|
+
* once (this copy, so nothing survives $B's name afterward); the scan never crosses
|
|
2197
|
+
* a statement containing a conditional/loop (a tee inside one wouldn't dominate);
|
|
2198
|
+
* and NOTHING between the tee and the copy touches $A — including the tee's own
|
|
2199
|
+
* statement (an $A operand evaluated after the tee would read the renamed write).
|
|
1548
2200
|
*/
|
|
1549
|
-
const
|
|
2201
|
+
const mergeCopyThroughTee = (scope, params, counts) => {
|
|
2202
|
+
let changed = false
|
|
2203
|
+
for (let i = 1; i < scope.length; i++) {
|
|
2204
|
+
const st = scope[i]
|
|
2205
|
+
if (!(Array.isArray(st) && st[0] === 'local.set' && st.length === 3 &&
|
|
2206
|
+
Array.isArray(st[2]) && st[2][0] === 'local.get' && st[2].length === 2)) continue
|
|
2207
|
+
const A = st[1], B = st[2][1]
|
|
2208
|
+
if (typeof A !== 'string' || typeof B !== 'string' || A === B) continue
|
|
2209
|
+
if (params.has(B)) continue
|
|
2210
|
+
const cb = counts.get(B)
|
|
2211
|
+
if (!cb || cb.sets !== 0 || cb.tees !== 1 || cb.gets !== 1) continue
|
|
2212
|
+
let teeNode = null
|
|
2213
|
+
for (let j = i - 1; j >= 1 && j >= i - 5; j--) {
|
|
2214
|
+
const prev = scope[j]
|
|
2215
|
+
if (!Array.isArray(prev) || hasConditional(prev)) break
|
|
2216
|
+
let touchesA = false, tee = null
|
|
2217
|
+
walk(prev, n => {
|
|
2218
|
+
if (!Array.isArray(n)) return
|
|
2219
|
+
if (n[0] === 'local.tee' && n.length === 3 && n[1] === B) tee = n
|
|
2220
|
+
else if (typeof n[1] === 'string' && n[1] === A &&
|
|
2221
|
+
(n[0] === 'local.get' || n[0] === 'local.set' || n[0] === 'local.tee')) touchesA = true
|
|
2222
|
+
})
|
|
2223
|
+
if (tee) { if (!touchesA) teeNode = tee; break }
|
|
2224
|
+
if (touchesA) break
|
|
2225
|
+
}
|
|
2226
|
+
if (!teeNode) continue
|
|
2227
|
+
cntSub(teeNode); teeNode[1] = A; cntAdd(teeNode)
|
|
2228
|
+
cntSub(st)
|
|
2229
|
+
scope.splice(i, 1)
|
|
2230
|
+
changed = true
|
|
2231
|
+
i--
|
|
2232
|
+
}
|
|
2233
|
+
return changed
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
// Two adjacent sets whose reads sit under one commutative integer node in
|
|
2237
|
+
// ANTI-order can never both sink — each value would have to cross the other's
|
|
2238
|
+
// effects. Swapping the node's (pure, trap-free) operands reorders the reads to
|
|
2239
|
+
// match statement order; the ordinary sink then fuses both, preserving the
|
|
2240
|
+
// original side-effect order. Pure arms make the swap itself unobservable, and
|
|
2241
|
+
// the anti-order trigger makes it idempotent.
|
|
2242
|
+
const COMMUTATIVE = new Set(['i32.add', 'i32.mul', 'i32.and', 'i32.or', 'i32.xor',
|
|
2243
|
+
'i64.add', 'i64.mul', 'i64.and', 'i64.or', 'i64.xor'])
|
|
2244
|
+
const commuteForSink = (scope) => {
|
|
1550
2245
|
let changed = false
|
|
2246
|
+
for (let i = 1; i < scope.length - 2; i++) {
|
|
2247
|
+
const a = scope[i], b = scope[i + 1], stmt = scope[i + 2]
|
|
2248
|
+
if (!Array.isArray(a) || a[0] !== 'local.set' || a.length !== 3 || typeof a[1] !== 'string') continue
|
|
2249
|
+
if (!Array.isArray(b) || b[0] !== 'local.set' || b.length !== 3 || typeof b[1] !== 'string' || a[1] === b[1]) continue
|
|
2250
|
+
if (!Array.isArray(stmt)) continue
|
|
2251
|
+
const gets = (sub, name) => { let k = 0; walk(sub, c => { if (Array.isArray(c) && c[0] === 'local.get' && c[1] === name) k++ }); return k }
|
|
2252
|
+
walk(stmt, n => {
|
|
2253
|
+
if (!Array.isArray(n) || !COMMUTATIVE.has(n[0]) || n.length !== 3) return
|
|
2254
|
+
const l = n[1], r = n[2]
|
|
2255
|
+
if (!Array.isArray(l) || !Array.isArray(r)) return
|
|
2256
|
+
if (gets(l, b[1]) && gets(r, a[1]) && !gets(l, a[1]) && !gets(r, b[1]) &&
|
|
2257
|
+
isPure(l) && isPure(r) && !hasTrap(l) && !hasTrap(r)) {
|
|
2258
|
+
n[1] = r; n[2] = l
|
|
2259
|
+
changed = true
|
|
2260
|
+
}
|
|
2261
|
+
})
|
|
2262
|
+
}
|
|
2263
|
+
return changed
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
const sinkSets = (funcNode, params, useCounts) => {
|
|
2267
|
+
let changed = false
|
|
2268
|
+
|
|
2269
|
+
// Per-statement interference summaries, memoized by statement IDENTITY (splice-
|
|
2270
|
+
// proof): each candidate's bounded lookahead re-examined the same statements —
|
|
2271
|
+
// one walk per statement now serves every candidate that crosses it.
|
|
2272
|
+
const SINFO = new Map()
|
|
2273
|
+
const sinfo = (stmt) => {
|
|
2274
|
+
let S = SINFO.get(stmt)
|
|
2275
|
+
if (S) return S
|
|
2276
|
+
S = { flat: false, touch: new Set(), wL: new Set(), gS: new Set(), gSAny: false, gG: new Set(),
|
|
2277
|
+
calls: false, branchy: false, wMem: false, loadish: false, storeish: false }
|
|
2278
|
+
walk(stmt, (n, p2, i2) => {
|
|
2279
|
+
if (!Array.isArray(n)) { if (i2 !== 0 && typeof n === 'string' && OPCODE[n] !== undefined) S.flat = true; return }
|
|
2280
|
+
const o = n[0]
|
|
2281
|
+
if (typeof o !== 'string') return
|
|
2282
|
+
if (o === 'local.get' || o === 'local.set' || o === 'local.tee') {
|
|
2283
|
+
if (typeof n[1] === 'string') { S.touch.add(n[1]); if (o !== 'local.get') S.wL.add(n[1]) }
|
|
2284
|
+
}
|
|
2285
|
+
else if (o === 'global.set') { S.gSAny = true; if (typeof n[1] === 'string') S.gS.add(n[1]) }
|
|
2286
|
+
else if (o === 'global.get') { if (typeof n[1] === 'string') S.gG.add(n[1]) }
|
|
2287
|
+
else if (o === 'call' || o === 'call_indirect' || o === 'return_call' || o === 'return_call_indirect') S.calls = true
|
|
2288
|
+
else if (isBranchScope(o) || o === 'br' || o === 'br_if' || o === 'br_table' || o === 'return' || o === 'unreachable' || o === 'throw') S.branchy = true
|
|
2289
|
+
else {
|
|
2290
|
+
if (o.includes('.store') || o === 'memory.copy' || o === 'memory.fill' || o === 'memory.init' || o === 'memory.grow' ||
|
|
2291
|
+
(o.includes('.atomic.') && !o.endsWith('.load'))) S.wMem = true
|
|
2292
|
+
if (o.includes('.load') || o === 'memory.size') S.loadish = true
|
|
2293
|
+
if (o.includes('.store') || o === 'memory.copy' || o === 'memory.fill' || o === 'memory.init' || o === 'memory.grow' ||
|
|
2294
|
+
o.includes('.atomic') || o.includes('table.')) S.storeish = true
|
|
2295
|
+
}
|
|
2296
|
+
})
|
|
2297
|
+
SINFO.set(stmt, S)
|
|
2298
|
+
return S
|
|
2299
|
+
}
|
|
1551
2300
|
|
|
1552
2301
|
for (let i = 1; i < funcNode.length - 1; i++) {
|
|
1553
2302
|
const setNode = funcNode[i]
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
if (!Array.isArray(
|
|
2303
|
+
// node[2] must be the folded value EXPRESSION — a bare string there is a
|
|
2304
|
+
// trailing stack-style instruction riding the same node, not a value
|
|
2305
|
+
if (!Array.isArray(setNode) || setNode[0] !== 'local.set' || setNode.length !== 3 || !Array.isArray(setNode[2])) continue
|
|
1557
2306
|
const name = setNode[1]
|
|
1558
|
-
if (
|
|
2307
|
+
if (typeof name !== 'string') continue // params sink like locals: past this set the argument value is dead
|
|
2308
|
+
const val = setNode[2]
|
|
2309
|
+
// The landing site is the statement that first touches $name — a PURE value may
|
|
2310
|
+
// cross up to a few non-interfering statements to reach it (bounded lookahead:
|
|
2311
|
+
// the win class is near-adjacent; unbounded scans cost quadratic time for
|
|
2312
|
+
// nothing). Crossed statements must not write the value's inputs, and when the
|
|
2313
|
+
// value reads memory they must not write memory or call out.
|
|
2314
|
+
const vLocals = new Set(), vGlobals = new Set()
|
|
2315
|
+
walk(val, n => {
|
|
2316
|
+
if (!Array.isArray(n)) return
|
|
2317
|
+
if ((n[0] === 'local.get' || n[0] === 'local.tee') && typeof n[1] === 'string') vLocals.add(n[1])
|
|
2318
|
+
else if (n[0] === 'global.get' && typeof n[1] === 'string') vGlobals.add(n[1])
|
|
2319
|
+
})
|
|
2320
|
+
const vMem = readsMemory(val)
|
|
2321
|
+
const vPure = isPure(val)
|
|
2322
|
+
// A value impure ONLY through calls with a known read-only-ish summary may
|
|
2323
|
+
// still cross local-only statements — collect the union of callee effects,
|
|
2324
|
+
// or null when anything unsummarizable makes the value opaque.
|
|
2325
|
+
let vFx = null
|
|
2326
|
+
if (!vPure) {
|
|
2327
|
+
const u = { wMem: false, rMem: false, wGlob: new Set(), rGlob: new Set() }
|
|
2328
|
+
let opaque = false
|
|
2329
|
+
walk(val, (n, parent, idx) => {
|
|
2330
|
+
if (!Array.isArray(n)) { if (idx !== 0 && typeof n === 'string' && OPCODE[n] !== undefined) opaque = true; return }
|
|
2331
|
+
const o = n[0]
|
|
2332
|
+
if (typeof o !== 'string') { opaque = true; return }
|
|
2333
|
+
if (o === 'call' || o === 'return_call') {
|
|
2334
|
+
const e = callFx(n)
|
|
2335
|
+
if (!e) { opaque = true; return }
|
|
2336
|
+
u.wMem ||= e.wMem; u.rMem ||= e.rMem
|
|
2337
|
+
for (const g of e.wGlob) u.wGlob.add(g)
|
|
2338
|
+
for (const g of e.rGlob) u.rGlob.add(g)
|
|
2339
|
+
}
|
|
2340
|
+
else if (IMPURE_OPS.has(o) || IMPURE_SUBSTRINGS.some(sub => o.includes(sub))) opaque = true
|
|
2341
|
+
})
|
|
2342
|
+
if (!opaque) vFx = u
|
|
2343
|
+
}
|
|
2344
|
+
let hit = null, skipped = null, hitJ = -1
|
|
2345
|
+
for (let j = i + 1; j < funcNode.length && j <= i + 5; j++) {
|
|
2346
|
+
const stmt = funcNode[j]
|
|
2347
|
+
if (!Array.isArray(stmt)) break // flat token — order unattributable
|
|
2348
|
+
const h0 = stmt[0]
|
|
2349
|
+
if (h0 === 'param' || h0 === 'result' || h0 === 'local' || h0 === 'type' || h0 === 'export') continue
|
|
2350
|
+
const S = sinfo(stmt)
|
|
2351
|
+
if (S.touch.has(name)) {
|
|
2352
|
+
skipped = new Set()
|
|
2353
|
+
hitJ = j
|
|
2354
|
+
const state = { reads: false }
|
|
2355
|
+
hit = stmt[0] === 'local.get' && stmt[1] === name && stmt.length === 2
|
|
2356
|
+
? [funcNode, j] : firstEvalGet(stmt, name, skipped, state)
|
|
2357
|
+
// crossed pure subtrees may read globals/memory — an effectful value must
|
|
2358
|
+
// not move past them unless its summarized callees write neither
|
|
2359
|
+
if (hit && state.reads && !vPure && !(vFx && !vFx.wMem && !vFx.wGlob.size)) hit = null
|
|
2360
|
+
break
|
|
2361
|
+
}
|
|
2362
|
+
// a PURE value crosses on interference rules alone; a summarized call-valued
|
|
2363
|
+
// one additionally requires the crossed statement to be free of OBSERVABLE
|
|
2364
|
+
// writes and calls (the call may trap — a skipped store would be visible
|
|
2365
|
+
// post-trap), with reads disjoint from the callee-side writes
|
|
2366
|
+
let bad = (!vPure && !vFx) || (vMem && S.wMem) || S.flat || S.branchy ||
|
|
2367
|
+
(S.calls && (vMem || vGlobals.size))
|
|
2368
|
+
if (!bad) for (const x of vLocals) if (S.wL.has(x)) { bad = true; break }
|
|
2369
|
+
if (!bad && S.gS.size) for (const x of vGlobals) if (S.gS.has(x)) { bad = true; break }
|
|
2370
|
+
if (!bad && !vPure) {
|
|
2371
|
+
bad = S.calls || S.gSAny || S.storeish || (vFx.wMem && S.loadish)
|
|
2372
|
+
if (!bad && vFx.wGlob.size) for (const g of S.gG) if (vFx.wGlob.has(g)) { bad = true; break }
|
|
2373
|
+
}
|
|
2374
|
+
if (bad) break
|
|
2375
|
+
}
|
|
2376
|
+
if (!hit) continue
|
|
2377
|
+
// the sunk value now evaluates AFTER the skipped leaves — it must not write them
|
|
2378
|
+
if (skipped.size) {
|
|
2379
|
+
let writes = false
|
|
2380
|
+
walk(setNode[2], n => { if (Array.isArray(n) && (n[0] === 'local.set' || n[0] === 'local.tee') && skipped.has(n[1])) writes = true })
|
|
2381
|
+
if (writes) continue
|
|
2382
|
+
}
|
|
1559
2383
|
const uses = useCounts.get(name) || { gets: 0, sets: 0, tees: 0 }
|
|
1560
|
-
//
|
|
1561
|
-
|
|
1562
|
-
//
|
|
1563
|
-
|
|
2384
|
+
// Sole set+get pair → substitute the value outright (decl dies next sweep);
|
|
2385
|
+
// otherwise fuse into a tee at the get site. Either way the value's evaluation
|
|
2386
|
+
// point is unchanged — the get was the first instruction executed after the set.
|
|
2387
|
+
let single = uses.sets === 1 && uses.gets === 1 && uses.tees === 0
|
|
2388
|
+
// defense in depth against stale counts: the landing statement itself may hold
|
|
2389
|
+
// a SECOND read of the local beyond the hit (the exact shape a missed count
|
|
2390
|
+
// refresh produces) — a substitute would orphan it, a tee stays correct
|
|
2391
|
+
if (single) {
|
|
2392
|
+
let extra = 0
|
|
2393
|
+
walk(funcNode[hitJ], n => { if (Array.isArray(n) && n[0] === 'local.get' && n[1] === name) extra++ })
|
|
2394
|
+
if (extra > 1) single = false
|
|
2395
|
+
}
|
|
2396
|
+
cntSub(hit[0][hit[1]])
|
|
2397
|
+
hit[0][hit[1]] = single ? clone(setNode[2]) : ['local.tee', name, clone(setNode[2])]
|
|
2398
|
+
cntAdd(hit[0][hit[1]])
|
|
2399
|
+
SINFO.delete(funcNode[hitJ]) // the landing statement changed under its summary
|
|
2400
|
+
cntSub(setNode)
|
|
2401
|
+
funcNode.splice(i, 1)
|
|
1564
2402
|
changed = true
|
|
2403
|
+
i--
|
|
1565
2404
|
}
|
|
1566
2405
|
|
|
1567
2406
|
return changed
|
|
1568
2407
|
}
|
|
1569
2408
|
|
|
2409
|
+
/**
|
|
2410
|
+
* Locate the (local.get $name) that is provably the FIRST instruction executed in
|
|
2411
|
+
* `stmt`, descending the first-evaluated path: at each level the first non-immediate
|
|
2412
|
+
* (array) child of a left-to-right op is what runs first. Annotation heads are
|
|
2413
|
+
* skipped; bodies that are entered conditionally or repeatedly (then/else arms,
|
|
2414
|
+
* loops, try_table) are opaque — descent stops there. → [parent, idx] or null.
|
|
2415
|
+
*/
|
|
2416
|
+
const firstEvalGet = (stmt, name, skipped, state) => {
|
|
2417
|
+
// Full evaluation-order scan: operands are visited left-to-right; the target get
|
|
2418
|
+
// may sit at any nesting level. A completed subtree may be CROSSED (the sunk value
|
|
2419
|
+
// then evaluates after it) only when it is pure and trap-free — its local reads are
|
|
2420
|
+
// recorded in `skipped` (the caller verifies the value doesn't write them) and any
|
|
2421
|
+
// global/memory read sets `state.reads` (the caller then requires a pure value).
|
|
2422
|
+
// Conditionally or repeatedly (re-)entered bodies stay opaque.
|
|
2423
|
+
let hit = null
|
|
2424
|
+
const scan = (cur) => {
|
|
2425
|
+
if (hit) return true
|
|
2426
|
+
if (!Array.isArray(cur)) return true
|
|
2427
|
+
const h = cur[0]
|
|
2428
|
+
if (h === 'loop' || h === 'then' || h === 'else' || h === 'try_table') return false
|
|
2429
|
+
for (let i = 1; i < cur.length; i++) {
|
|
2430
|
+
const c = cur[i]
|
|
2431
|
+
if (!Array.isArray(c)) continue // strings/numbers: immediates, labels, memargs
|
|
2432
|
+
const ch = c[0]
|
|
2433
|
+
if (ch === 'result' || ch === 'param' || ch === 'type' || ch === 'local' || ch === 'export') continue
|
|
2434
|
+
if (ch === 'local.get' && c.length === 2) {
|
|
2435
|
+
if (c[1] === name) { hit = [cur, i]; return true }
|
|
2436
|
+
skipped?.add(c[1])
|
|
2437
|
+
continue
|
|
2438
|
+
}
|
|
2439
|
+
if (isPure(c) && !hasTrap(c)) {
|
|
2440
|
+
let containsTarget = false
|
|
2441
|
+
walk(c, x => { if (Array.isArray(x) && x[0] === 'local.get' && x[1] === name) containsTarget = true })
|
|
2442
|
+
if (containsTarget) return scan(c) // the target lives here — descend, same rules
|
|
2443
|
+
// crossable — collect what it observes
|
|
2444
|
+
walk(c, x => {
|
|
2445
|
+
if (!Array.isArray(x)) return
|
|
2446
|
+
if (x[0] === 'local.get' && typeof x[1] === 'string') skipped?.add(x[1])
|
|
2447
|
+
else if (x[0] === 'global.get' || (typeof x[0] === 'string' && x[0].includes('.load'))) state && (state.reads = true)
|
|
2448
|
+
})
|
|
2449
|
+
continue
|
|
2450
|
+
}
|
|
2451
|
+
// first non-crossable child: the spine — descend; failure inside blocks the scan
|
|
2452
|
+
return scan(c)
|
|
2453
|
+
}
|
|
2454
|
+
return true
|
|
2455
|
+
}
|
|
2456
|
+
const ok = scan(stmt)
|
|
2457
|
+
return ok && hit ? hit : null
|
|
2458
|
+
}
|
|
2459
|
+
|
|
1570
2460
|
/**
|
|
1571
2461
|
* Remove dead stores and unused local declarations in a reverse pass.
|
|
1572
2462
|
* Returns true if anything was removed.
|
|
@@ -1574,66 +2464,957 @@ const createLocalTees = (funcNode, params, useCounts) => {
|
|
|
1574
2464
|
* @param {Set<string>} params
|
|
1575
2465
|
* @param {Map<string,{gets:number,sets:number,tees:number}>} useCounts
|
|
1576
2466
|
*/
|
|
1577
|
-
const eliminateDeadStores = (funcNode, params, useCounts) => {
|
|
1578
|
-
let changed = false
|
|
1579
|
-
const getPostUseCount = name => useCounts.get(name) || { gets: 0, sets: 0, tees: 0 }
|
|
2467
|
+
const eliminateDeadStores = (funcNode, params, useCounts) => {
|
|
2468
|
+
let changed = false
|
|
2469
|
+
const getPostUseCount = name => useCounts.get(name) || { gets: 0, sets: 0, tees: 0 }
|
|
2470
|
+
|
|
2471
|
+
// Wasm zero-initializes locals — an explicit `(local.set $x (T.const 0))` whose
|
|
2472
|
+
// local was never touched before restates the default. The default only holds at
|
|
2473
|
+
// FUNCTION entry (this runs per scope — a loop-body scope re-enters with carried
|
|
2474
|
+
// values). Candidates: top-level statements only, before any flat control token
|
|
2475
|
+
// (inside a loop a reset is load-bearing). Touches: EVERY get/set/tee anywhere
|
|
2476
|
+
// reached so far — including nested arms (`(if P (then (set $x 5))) (set $x 0)`
|
|
2477
|
+
// keeps the reset) and bare flat-form refs (target = next sibling token).
|
|
2478
|
+
if (funcNode[0] === 'func') {
|
|
2479
|
+
const touched = new Set()
|
|
2480
|
+
// flat control nesting at the current statement — a candidate is valid only at
|
|
2481
|
+
// depth 0 (outermost sequence, executed at most once per call; a zero-init
|
|
2482
|
+
// inside a flat loop is an each-iteration reset)
|
|
2483
|
+
let depth = 0
|
|
2484
|
+
const isZero = (v) => {
|
|
2485
|
+
const c = getConst(v)
|
|
2486
|
+
if (!c) return false
|
|
2487
|
+
return c.type === 'i32' ? c.value === 0 : c.type === 'i64' ? c.value === 0n :
|
|
2488
|
+
Object.is(c.value, 0) // floats: +0 only — -0 is not the default
|
|
2489
|
+
}
|
|
2490
|
+
const mark = (n, parent, idx) => {
|
|
2491
|
+
if (Array.isArray(n)) {
|
|
2492
|
+
if ((n[0] === 'local.get' || n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') touched.add(n[1])
|
|
2493
|
+
} else if (idx > 0 && (n === 'local.get' || n === 'local.set' || n === 'local.tee')) {
|
|
2494
|
+
const tgt = parent[idx + 1]
|
|
2495
|
+
typeof tgt === 'string' ? touched.add(tgt) : touched.add('*') // numeric ref: give up
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
for (let i = 1; i < funcNode.length && !touched.has('*'); i++) {
|
|
2499
|
+
const n = funcNode[i]
|
|
2500
|
+
if (!Array.isArray(n)) {
|
|
2501
|
+
if (typeof n !== 'string') continue
|
|
2502
|
+
if (n === 'block' || n === 'loop' || n === 'if' || n === 'try' || n === 'try_table') depth++
|
|
2503
|
+
else if (n === 'end' || n === 'delegate') depth = depth > 0 ? depth - 1 : 0
|
|
2504
|
+
else mark(n, funcNode, i)
|
|
2505
|
+
continue
|
|
2506
|
+
}
|
|
2507
|
+
const op = n[0]
|
|
2508
|
+
if (op === 'param' || op === 'result' || op === 'local' || op === 'type' || op === 'export') continue
|
|
2509
|
+
if (depth === 0 && op === 'local.set' && n.length === 3 && typeof n[1] === 'string' &&
|
|
2510
|
+
!params.has(n[1]) && !touched.has(n[1]) && isZero(n[2])) {
|
|
2511
|
+
cntSub(n)
|
|
2512
|
+
funcNode.splice(i, 1); i--; changed = true
|
|
2513
|
+
continue
|
|
2514
|
+
}
|
|
2515
|
+
walk(n, mark)
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
// Dead tee (statement-level or nested): written but never read back — the value on
|
|
2520
|
+
// the stack is all that matters, so unwrap it (the decl dies via the unused sweep).
|
|
2521
|
+
walkPost(funcNode, n => {
|
|
2522
|
+
if (Array.isArray(n) && n[0] === 'local.tee' && n.length === 3 &&
|
|
2523
|
+
typeof n[1] === 'string' && getPostUseCount(n[1]).gets === 0) {
|
|
2524
|
+
changed = true
|
|
2525
|
+
if (CNT) { const c = CNT.get(n[1]); if (c) c.tees-- }
|
|
2526
|
+
return n[2]
|
|
2527
|
+
}
|
|
2528
|
+
})
|
|
2529
|
+
|
|
2530
|
+
// Locally-dead tee: the local IS read somewhere, but every path from this tee
|
|
2531
|
+
// reaches an unconditional top-level rewrite (or the function's end) before any
|
|
2532
|
+
// read — the whole-function gate above can't see that. Conservative forward
|
|
2533
|
+
// proof over the FUNC body's top-level siblings only: the tee's own statement
|
|
2534
|
+
// may hold no other reference to the local (an operand evaluated after the tee
|
|
2535
|
+
// reads the teed value), no sibling may touch the local or branch (recursively,
|
|
2536
|
+
// bare flat tokens included) before the kill.
|
|
2537
|
+
if (funcNode[0] === 'func') {
|
|
2538
|
+
// One summary pass over the body (per-statement local-touch counts, branch/flat
|
|
2539
|
+
// flags, tee sites), then candidate scans are index lookups — the naive version
|
|
2540
|
+
// re-walked the whole tail of the function per candidate tee.
|
|
2541
|
+
const info = []
|
|
2542
|
+
let anyTee = false
|
|
2543
|
+
for (let i = 0; i < funcNode.length; i++) {
|
|
2544
|
+
const stmt = funcNode[i]
|
|
2545
|
+
if (!Array.isArray(stmt)) { info.push(null); continue }
|
|
2546
|
+
const touches = new Map()
|
|
2547
|
+
let branchy = false, flat = false, tees = null
|
|
2548
|
+
walk(stmt, (c, p2, i2) => {
|
|
2549
|
+
if (!Array.isArray(c)) { if (i2 !== 0 && typeof c === 'string' && OPCODE[c] !== undefined) flat = true; return }
|
|
2550
|
+
const o = c[0]
|
|
2551
|
+
if ((o === 'local.get' || o === 'local.set' || o === 'local.tee') && typeof c[1] === 'string') {
|
|
2552
|
+
touches.set(c[1], (touches.get(c[1]) || 0) + 1)
|
|
2553
|
+
if (o === 'local.tee' && c.length === 3 && p2) (tees ??= []).push([c, p2, i2])
|
|
2554
|
+
}
|
|
2555
|
+
else if (o === 'br' || o === 'br_if' || o === 'br_table' || o === 'return' || o === 'unreachable' ||
|
|
2556
|
+
o === 'throw' || o === 'return_call' || o === 'return_call_indirect' || o === 'try_table') branchy = true
|
|
2557
|
+
})
|
|
2558
|
+
if (tees) anyTee = true
|
|
2559
|
+
info.push({ touches, branchy, flat, tees,
|
|
2560
|
+
setTarget: stmt[0] === 'local.set' && stmt.length === 3 && typeof stmt[1] === 'string' ? stmt[1] : null })
|
|
2561
|
+
}
|
|
2562
|
+
if (anyTee) for (let i = 1; i < funcNode.length; i++) {
|
|
2563
|
+
const inf = info[i]
|
|
2564
|
+
if (!inf?.tees || inf.flat) continue
|
|
2565
|
+
for (const [n, parent, idx] of inf.tees) {
|
|
2566
|
+
if (parent[idx] !== n) continue // an earlier replacement rewrote this slot
|
|
2567
|
+
const x = n[1]
|
|
2568
|
+
if (inf.touches.get(x) !== 1) continue
|
|
2569
|
+
let kill = false, unsafe = false
|
|
2570
|
+
for (let j = i + 1; j < funcNode.length && !kill && !unsafe; j++) {
|
|
2571
|
+
const jn = info[j]
|
|
2572
|
+
if (!jn) {
|
|
2573
|
+
const t = funcNode[j]
|
|
2574
|
+
if (typeof t === 'string' && OPCODE[t] !== undefined) unsafe = true
|
|
2575
|
+
continue
|
|
2576
|
+
}
|
|
2577
|
+
if (jn.flat) { unsafe = true; continue }
|
|
2578
|
+
if (jn.setTarget === x) {
|
|
2579
|
+
// the rewrite's RHS must not read x (its own touch is the 1)
|
|
2580
|
+
jn.touches.get(x) > 1 ? unsafe = true : kill = true
|
|
2581
|
+
continue
|
|
2582
|
+
}
|
|
2583
|
+
if (jn.branchy || jn.touches.has(x)) unsafe = true
|
|
2584
|
+
}
|
|
2585
|
+
// reaching the function's end without a read is a kill too — the frame dies
|
|
2586
|
+
if (!unsafe) {
|
|
2587
|
+
if (CNT) { const c = CNT.get(x); if (c) c.tees-- }
|
|
2588
|
+
parent[idx] = n[2]; changed = true
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
for (let i = funcNode.length - 1; i >= 1; i--) {
|
|
2595
|
+
const sub = funcNode[i]
|
|
2596
|
+
if (!Array.isArray(sub)) continue
|
|
2597
|
+
const name = typeof sub[1] === 'string' ? sub[1] : null
|
|
2598
|
+
if (!name) continue
|
|
2599
|
+
const uses = getPostUseCount(name)
|
|
2600
|
+
// Dead store: set but never read.
|
|
2601
|
+
if (sub[0] === 'local.set' && uses.gets === 0 && uses.tees === 0) {
|
|
2602
|
+
// `(local.set $D (CMP (local.tee $L X) CONST))` with $D never read: the
|
|
2603
|
+
// comparison is non-trapping and its other operand a literal — only the
|
|
2604
|
+
// tee's store matters. Collapse to `(local.set $L X)`.
|
|
2605
|
+
if (sub.length === 3 && Array.isArray(sub[2]) && sub[2].length === 3 &&
|
|
2606
|
+
/\.(eq|ne|[lg][te](_[su])?)$/.test(sub[2][0])) {
|
|
2607
|
+
const [, a, b] = sub[2]
|
|
2608
|
+
const tee = Array.isArray(a) && a[0] === 'local.tee' ? a : Array.isArray(b) && b[0] === 'local.tee' ? b : null
|
|
2609
|
+
const other = tee === a ? b : a
|
|
2610
|
+
if (tee && tee.length === 3 && tee[1] !== name && Array.isArray(other) && other[0]?.endsWith?.('.const')) {
|
|
2611
|
+
cntSub(funcNode[i])
|
|
2612
|
+
funcNode[i] = ['local.set', tee[1], tee[2]]
|
|
2613
|
+
cntAdd(funcNode[i])
|
|
2614
|
+
changed = true
|
|
2615
|
+
continue
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
// `(local.set $x VALUE)` — drop the store with its value, but only when
|
|
2619
|
+
// VALUE is pure (its side effects would otherwise still need to run);
|
|
2620
|
+
// an impure but trap-free VALUE reduces to its side-effect core.
|
|
2621
|
+
if (sub.length === 3) {
|
|
2622
|
+
if (isPure(sub[2])) { cntSub(sub); funcNode.splice(i, 1); changed = true }
|
|
2623
|
+
else if (!hasTrap(sub[2])) {
|
|
2624
|
+
cntSub(sub)
|
|
2625
|
+
const eff = dropEffects(sub[2])
|
|
2626
|
+
for (const e of eff) cntAdd(e)
|
|
2627
|
+
funcNode.splice(i, 1, ...eff)
|
|
2628
|
+
changed = true
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
// Bare `(local.set $x)` — the value is implicit on the stack (e.g. an
|
|
2632
|
+
// exception payload landing from a `try_table` catch). Demote to `drop`
|
|
2633
|
+
// so the dead store goes away without unbalancing the stack.
|
|
2634
|
+
else if (sub.length === 2) {
|
|
2635
|
+
cntSub(sub)
|
|
2636
|
+
funcNode[i] = ['drop']; changed = true
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2639
|
+
// Unused local declaration
|
|
2640
|
+
else if (sub[0] === 'local' && name[0] === '$' && uses.gets === 0 && uses.sets === 0 && uses.tees === 0) {
|
|
2641
|
+
funcNode.splice(i, 1); changed = true
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2645
|
+
return changed
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
/**
|
|
2649
|
+
* Drop `(local.set $x A)` when the very next statement re-sets $x without reading it
|
|
2650
|
+
* first (A pure). The two writes are adjacent, so A's value is overwritten before any
|
|
2651
|
+
* observation — it's dead. The whole-function {@link eliminateDeadStores} misses this:
|
|
2652
|
+
* it only fires when $x is read NOWHERE, whereas here $x is live later, just not
|
|
2653
|
+
* between these two writes. Pairs with copy-propagation, which rewrites
|
|
2654
|
+
* `$x=$y; $x=f($x)` to `$x=$y; $x=f($y)` — an adjacent dead store this removes,
|
|
2655
|
+
* collapsing the round-trip jz's value-model lowering leaves behind.
|
|
2656
|
+
* @param {Array} funcNode a straight-line scope (body / block / loop / then / else)
|
|
2657
|
+
* @param {Set<string>} params
|
|
2658
|
+
*/
|
|
2659
|
+
const eliminateAdjacentDeadStores = (funcNode, params) => {
|
|
2660
|
+
let changed = false
|
|
2661
|
+
for (let i = 1; i < funcNode.length - 1; i++) {
|
|
2662
|
+
const a = funcNode[i], b = funcNode[i + 1]
|
|
2663
|
+
// `a` must be a plain set (a tee leaves its value on the stack — not removable);
|
|
2664
|
+
// `b` may be a set OR a tee (both overwrite the local before `a`'s value is read).
|
|
2665
|
+
if (!Array.isArray(a) || a[0] !== 'local.set' || a.length !== 3) continue
|
|
2666
|
+
if (!Array.isArray(b) || (b[0] !== 'local.set' && b[0] !== 'local.tee') || b.length !== 3 || b[1] !== a[1]) continue
|
|
2667
|
+
if (params.has(a[1]) || !isPure(a[2])) continue
|
|
2668
|
+
// Dead only if b's value doesn't read $x before overwriting it.
|
|
2669
|
+
let reads = false
|
|
2670
|
+
walk(b[2], n => { if (Array.isArray(n) && (n[0] === 'local.get' || n[0] === 'local.tee') && n[1] === a[1]) reads = true })
|
|
2671
|
+
if (reads) continue
|
|
2672
|
+
cntSub(a)
|
|
2673
|
+
funcNode.splice(i, 1); changed = true; i--
|
|
2674
|
+
}
|
|
2675
|
+
return changed
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
// Conservative LOW estimate of a subtree's encoded bytes (under-estimating keeps the
|
|
2679
|
+
// CSE profit gate honest: never fire on a loss).
|
|
2680
|
+
const estBytes = (n) => {
|
|
2681
|
+
if (!Array.isArray(n)) return typeof n === 'number' ? 2 : 1
|
|
2682
|
+
let b = OPCODE[n[0]] > 0xffff ? 2 : 1
|
|
2683
|
+
for (let i = 1; i < n.length; i++) b += estBytes(n[i])
|
|
2684
|
+
return b
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
/**
|
|
2688
|
+
* Local common-subexpression elimination: identical pure subtrees repeated within a
|
|
2689
|
+
* straight-line scope compute once into a fresh local (first site tees, later sites
|
|
2690
|
+
* get). Grouping stops at every invalidation: a statement that writes a local the
|
|
2691
|
+
* expression reads, writes memory (for memory-reading exprs), or calls out (memory/
|
|
2692
|
+
* global-reading exprs). Candidates come only from the unconditional part of each
|
|
2693
|
+
* statement — nested control bodies are separate scopes with their own table.
|
|
2694
|
+
* Fires only when the byte win is provable: (n−1)·bytes(expr) > tee+gets+decl cost.
|
|
2695
|
+
* @param {Array} ast
|
|
2696
|
+
* @returns {Array}
|
|
2697
|
+
*/
|
|
2698
|
+
const cse = (ast) => {
|
|
2699
|
+
let uid = 0
|
|
2700
|
+
walk(ast, (fn) => {
|
|
2701
|
+
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
2702
|
+
const all = []
|
|
2703
|
+
// Scope tree with availability inheritance: an if's arms and a block's body
|
|
2704
|
+
// are entered with everything still live at that point — a group tee'd in the
|
|
2705
|
+
// parent DOMINATES its arm sites, so cross-block repeats share one local.
|
|
2706
|
+
// A loop body starts FRESH: a tee outside the loop goes stale on iteration 2
|
|
2707
|
+
// if anything the expression reads is rewritten later in the body (linear
|
|
2708
|
+
// invalidation can't see the back edge). try_table bodies likewise fresh.
|
|
2709
|
+
const processScope = (scope, inherited) => {
|
|
2710
|
+
const live = new Map(inherited)
|
|
2711
|
+
let si = 1
|
|
2712
|
+
// leading label of a block/loop is structure, not a flat token — skip
|
|
2713
|
+
// without clearing what was inherited
|
|
2714
|
+
if (typeof scope[si] === 'string' && scope[si][0] === '$') si++
|
|
2715
|
+
for (; si < scope.length; si++) {
|
|
2716
|
+
const stmt = scope[si]
|
|
2717
|
+
if (!Array.isArray(stmt)) { live.clear(); continue } // flat token — unknown order
|
|
2718
|
+
const h = stmt[0]
|
|
2719
|
+
if (h === 'param' || h === 'result' || h === 'local' || h === 'type' || h === 'export') continue
|
|
2720
|
+
// this statement's write effects
|
|
2721
|
+
const wLocals = new Set(), wGlobals = new Set()
|
|
2722
|
+
let wMem = writesMemory(stmt), call = false
|
|
2723
|
+
walk(stmt, n => {
|
|
2724
|
+
if (!Array.isArray(n)) return
|
|
2725
|
+
const o = n[0]
|
|
2726
|
+
if ((o === 'local.set' || o === 'local.tee') && typeof n[1] === 'string') wLocals.add(n[1])
|
|
2727
|
+
else if (o === 'global.set' && typeof n[1] === 'string') wGlobals.add(n[1])
|
|
2728
|
+
else if (o === 'call' || o === 'call_indirect' || o === 'return_call' || o === 'return_call_indirect') call = true
|
|
2729
|
+
})
|
|
2730
|
+
// candidates from the unconditional spine of the statement
|
|
2731
|
+
const collect = (n, parent, idx) => {
|
|
2732
|
+
if (!Array.isArray(n)) return
|
|
2733
|
+
const o = n[0]
|
|
2734
|
+
// an if's CONDITION evaluates unconditionally — candidates there are as
|
|
2735
|
+
// dominant as any statement's; the arms stay opaque here (recursed below
|
|
2736
|
+
// for top-level statements, fresh for expression-nested ifs)
|
|
2737
|
+
if (o === 'if') {
|
|
2738
|
+
const { condIdx, cond, thenBranch, elseBranch } = parseIf(n)
|
|
2739
|
+
if (Array.isArray(cond)) collect(cond, n, condIdx)
|
|
2740
|
+
if (n !== stmt) { // expression-nested arms: own fresh scopes (the
|
|
2741
|
+
// statement-level branch below owns top-level arms, with inheritance)
|
|
2742
|
+
if (thenBranch) processScope(thenBranch, EMPTY_MAP)
|
|
2743
|
+
if (elseBranch) processScope(elseBranch, EMPTY_MAP)
|
|
2744
|
+
}
|
|
2745
|
+
return
|
|
2746
|
+
}
|
|
2747
|
+
if (o === 'block' || o === 'loop' || o === 'try_table') {
|
|
2748
|
+
if (n !== stmt) processScope(n, EMPTY_MAP)
|
|
2749
|
+
return
|
|
2750
|
+
}
|
|
2751
|
+
if (o === 'then' || o === 'else') return
|
|
2752
|
+
if (typeof o === 'string' && resultType(o) && isPure(n)) {
|
|
2753
|
+
const est = estBytes(n)
|
|
2754
|
+
if (est >= 4) {
|
|
2755
|
+
const key = hashFunc(n, EMPTY_SET)
|
|
2756
|
+
let g = live.get(key)
|
|
2757
|
+
if (!g) {
|
|
2758
|
+
g = { expr: n, sites: [], est, type: resultType(o), reads: new Set(), mem: readsMemory(n), glob: false }
|
|
2759
|
+
walk(n, c => {
|
|
2760
|
+
if (!Array.isArray(c)) return
|
|
2761
|
+
if (c[0] === 'local.get' && typeof c[1] === 'string') g.reads.add(c[1])
|
|
2762
|
+
else if (c[0] === 'global.get') g.glob = true
|
|
2763
|
+
})
|
|
2764
|
+
live.set(key, g); all.push(g)
|
|
2765
|
+
}
|
|
2766
|
+
g.sites.push([parent, idx])
|
|
2767
|
+
// repeats never yield sub-candidates: a group seeded inside a repeat
|
|
2768
|
+
// would tee into a subtree the outer conversion detaches
|
|
2769
|
+
if (g.sites.length > 1) return
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
for (let i = 1; i < n.length; i++) collect(n[i], n, i)
|
|
2773
|
+
}
|
|
2774
|
+
collect(stmt, scope, si)
|
|
2775
|
+
// recurse structured bodies. An arm enters with what was live AFTER the
|
|
2776
|
+
// condition evaluated: the condition may tee a local or call out (jz
|
|
2777
|
+
// emits `(if (tee …) …)` pervasively), and an arm site reusing the
|
|
2778
|
+
// parent's pre-condition value would read stale state — fence the
|
|
2779
|
+
// inherited copy with the CONDITION's effects only (an arm's own writes
|
|
2780
|
+
// are handled by its linear scan; the sibling arm never ran first). A
|
|
2781
|
+
// block has no condition: its body inherits everything as-is.
|
|
2782
|
+
if (h === 'if') {
|
|
2783
|
+
const { cond, thenBranch, elseBranch } = parseIf(stmt)
|
|
2784
|
+
const cw = new Set(); let cGlob = false, cMemCall = false
|
|
2785
|
+
walk(cond, n => {
|
|
2786
|
+
if (!Array.isArray(n)) return
|
|
2787
|
+
const o = n[0]
|
|
2788
|
+
if ((o === 'local.set' || o === 'local.tee') && typeof n[1] === 'string') cw.add(n[1])
|
|
2789
|
+
else if (o === 'global.set') cGlob = true
|
|
2790
|
+
else if (o === 'call' || o === 'call_indirect' || o === 'return_call' || o === 'return_call_indirect') { cGlob = true; cMemCall = true }
|
|
2791
|
+
else if (typeof o === 'string' && (o.includes('.store') || o === 'memory.copy' || o === 'memory.fill' ||
|
|
2792
|
+
o === 'memory.init' || o === 'memory.grow' || (o.includes('.atomic.') && !o.endsWith('.load')))) cMemCall = true
|
|
2793
|
+
})
|
|
2794
|
+
const armLive = new Map()
|
|
2795
|
+
for (const [k, g] of live) {
|
|
2796
|
+
if (g.mem && cMemCall) continue
|
|
2797
|
+
if (g.glob && cGlob) continue
|
|
2798
|
+
let stale = false
|
|
2799
|
+
for (const l of cw) if (g.reads.has(l)) { stale = true; break }
|
|
2800
|
+
if (!stale) armLive.set(k, g)
|
|
2801
|
+
}
|
|
2802
|
+
if (thenBranch) processScope(thenBranch, armLive)
|
|
2803
|
+
if (elseBranch) processScope(elseBranch, armLive)
|
|
2804
|
+
} else if (h === 'block') {
|
|
2805
|
+
processScope(stmt, live)
|
|
2806
|
+
} else if (h === 'loop' || h === 'try_table') {
|
|
2807
|
+
processScope(stmt, EMPTY_MAP)
|
|
2808
|
+
}
|
|
2809
|
+
// invalidate against this statement's writes (arms included — a
|
|
2810
|
+
// conditional write still poisons everything after the statement)
|
|
2811
|
+
for (const [k, g] of live) {
|
|
2812
|
+
if (g.mem && (wMem || call)) { live.delete(k); continue }
|
|
2813
|
+
if (g.glob && (call || wGlobals.size)) { live.delete(k); continue }
|
|
2814
|
+
for (const l of wLocals) if (g.reads.has(l)) { live.delete(k); break }
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
processScope(fn, EMPTY_MAP)
|
|
2819
|
+
const decls = []
|
|
2820
|
+
// A fresh local appended to the locals vector costs 2 B (new count+type run) —
|
|
2821
|
+
// unless its type matches the run it lands after, where the count just
|
|
2822
|
+
// increments for free. Track the run tail (locals only — params aren't in the
|
|
2823
|
+
// binary locals vector) so the gate prices each candidate exactly.
|
|
2824
|
+
let tailType = null
|
|
2825
|
+
for (let i = 1; i < fn.length; i++) if (Array.isArray(fn[i]) && fn[i][0] === 'local') {
|
|
2826
|
+
const t = fn[i][fn[i].length - 1]
|
|
2827
|
+
tailType = typeof t === 'string' ? t : null
|
|
2828
|
+
}
|
|
2829
|
+
for (const g of all) {
|
|
2830
|
+
const n = g.sites.length
|
|
2831
|
+
const declCost = g.type === tailType ? 0 : 2
|
|
2832
|
+
if (n < 2 || (n - 1) * (g.est - 2) <= 2 + declCost) continue
|
|
2833
|
+
const name = '$cse' + uid++
|
|
2834
|
+
decls.push(['local', name, g.type])
|
|
2835
|
+
tailType = g.type
|
|
2836
|
+
const [p0, i0] = g.sites[0]
|
|
2837
|
+
p0[i0] = ['local.tee', name, g.expr]
|
|
2838
|
+
for (let k = 1; k < n; k++) { const [p, i] = g.sites[k]; p[i] = ['local.get', name] }
|
|
2839
|
+
}
|
|
2840
|
+
if (decls.length) {
|
|
2841
|
+
let at = typeof fn[1] === 'string' && fn[1][0] === '$' ? 2 : 1
|
|
2842
|
+
while (at < fn.length && Array.isArray(fn[at]) &&
|
|
2843
|
+
(fn[at][0] === 'export' || fn[at][0] === 'type' || fn[at][0] === 'param' || fn[at][0] === 'result' || fn[at][0] === 'local')) at++
|
|
2844
|
+
fn.splice(at, 0, ...decls)
|
|
2845
|
+
}
|
|
2846
|
+
})
|
|
2847
|
+
return ast
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
/**
|
|
2851
|
+
* Macro inlining: a function whose whole body is ONE small expression using each
|
|
2852
|
+
* param exactly once, in declaration order, expands at every call site by
|
|
2853
|
+
* substituting the arguments positionally — no wrapper, no locals, argument
|
|
2854
|
+
* evaluation order preserved verbatim (so impure args stay sound). The husk loses
|
|
2855
|
+
* its callers and treeshake collects it; the expansion feeds fold/offset/cse.
|
|
2856
|
+
* @param {Array} ast
|
|
2857
|
+
* @returns {Array}
|
|
2858
|
+
*/
|
|
2859
|
+
const inlineMacro = (ast, { pin = EMPTY_SET } = {}) => {
|
|
2860
|
+
if (!Array.isArray(ast) || ast[0] !== 'module') return ast
|
|
2861
|
+
// expansion trades ~2B call for the body's own bytes per site — profitable only
|
|
2862
|
+
// below a small call count (the def's fixed ~9B amortizes). Empirical, not
|
|
2863
|
+
// byte-exact: two static models measured WORSE — body-size alone (20 B+ off on
|
|
2864
|
+
// wax modules) and per-site fold-SIMULATED expansion (still +40 B net: the
|
|
2865
|
+
// profit comes from folding around the expansion in caller context, which no
|
|
2866
|
+
// expression-local model sees). The outline pass re-extracts any expansion
|
|
2867
|
+
// that turns out not to pay, so over-expanding here is recoverable.
|
|
2868
|
+
const CAP = 3
|
|
2869
|
+
const callCount = new Map()
|
|
2870
|
+
walk(ast, n => { if (Array.isArray(n) && n[0] === 'call' && typeof n[1] === 'string') callCount.set(n[1], (callCount.get(n[1]) || 0) + 1) })
|
|
2871
|
+
const macros = new Map()
|
|
2872
|
+
for (const n of ast.slice(1)) {
|
|
2873
|
+
if (!Array.isArray(n) || n[0] !== 'func' || typeof n[1] !== 'string' || n[1][0] !== '$') continue
|
|
2874
|
+
if (n.some(c => Array.isArray(c) && c[0] === 'export')) continue
|
|
2875
|
+
if (pin.has(n[1]) || (callCount.get(n[1]) || 0) > CAP) continue
|
|
2876
|
+
const params = [], body = []
|
|
2877
|
+
let results = 0, ok = true
|
|
2878
|
+
for (let i = 2; i < n.length && ok; i++) {
|
|
2879
|
+
const c = n[i]
|
|
2880
|
+
if (c === 'return' && i === n.length - 1) continue // trailing bare return is a no-op
|
|
2881
|
+
if (!Array.isArray(c)) ok = false
|
|
2882
|
+
else if (c[0] === 'param') (typeof c[1] === 'string' && c[1][0] === '$' && c.length === 3) ? params.push(c[1]) : ok = false
|
|
2883
|
+
else if (c[0] === 'result') results += c.length - 1
|
|
2884
|
+
else if (c[0] === 'local') ok = false
|
|
2885
|
+
else if (c[0] === 'type') continue
|
|
2886
|
+
else body.push(c)
|
|
2887
|
+
}
|
|
2888
|
+
if (!ok || body.length !== 1 || results !== 1) continue
|
|
2889
|
+
const expr = body[0]
|
|
2890
|
+
if (estBytes(expr) > 12) continue
|
|
2891
|
+
// params each read once, in order; no writes, control, or self-recursion inside
|
|
2892
|
+
const seq = []
|
|
2893
|
+
let bad = false
|
|
2894
|
+
walk(expr, c => {
|
|
2895
|
+
// arrays only: walk also hands back each node's own head token as a leaf,
|
|
2896
|
+
// which would double-count every get ('local.get' matching again as a string)
|
|
2897
|
+
if (!Array.isArray(c)) { if (c === 'return' || (typeof c === 'string' && FLAT_CTRL.has(c))) bad = true; return }
|
|
2898
|
+
const o = c[0]
|
|
2899
|
+
if (o === 'local.get') seq.push(c[1])
|
|
2900
|
+
else if (o === 'local.set' || o === 'local.tee' || o === 'return' || o === 'br' || o === 'br_if' ||
|
|
2901
|
+
o === 'br_table' || o === 'block' || o === 'loop' || o === 'if' || o === 'try_table' ||
|
|
2902
|
+
((o === 'call' || o === 'return_call') && c[1] === n[1])) bad = true
|
|
2903
|
+
})
|
|
2904
|
+
if (bad || seq.length !== params.length || seq.some((x, i) => x !== params[i])) continue
|
|
2905
|
+
macros.set(n[1], { params, expr })
|
|
2906
|
+
}
|
|
2907
|
+
if (!macros.size) return ast
|
|
2908
|
+
walkPost(ast, n => {
|
|
2909
|
+
if (!Array.isArray(n) || n[0] !== 'call' || !macros.has(n[1])) return
|
|
2910
|
+
const m = macros.get(n[1])
|
|
2911
|
+
if (n.length - 2 !== m.params.length) return
|
|
2912
|
+
inflations++
|
|
2913
|
+
const idx = new Map(m.params.map((p, i) => [p, i]))
|
|
2914
|
+
const out = clone(m.expr)
|
|
2915
|
+
const expanded = walkPost(out, c => {
|
|
2916
|
+
if (Array.isArray(c) && c[0] === 'local.get' && idx.has(c[1])) return n[2 + idx.get(c[1])]
|
|
2917
|
+
})
|
|
2918
|
+
return expanded
|
|
2919
|
+
})
|
|
2920
|
+
return ast
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
/**
|
|
2924
|
+
* Specialize parameters that every call site passes the same constant: the param
|
|
2925
|
+
* becomes a local initialized to that constant, and the argument disappears from
|
|
2926
|
+
* every site. Named, unexported callees whose name appears ONLY as folded
|
|
2927
|
+
* (call $f …) sites qualify; int constants compare by canonical value, floats by
|
|
2928
|
+
* Object.is on non-NaN (per the signed-zero refutation — -0/NaN defer).
|
|
2929
|
+
* @param {Array} ast
|
|
2930
|
+
* @returns {Array}
|
|
2931
|
+
*/
|
|
2932
|
+
const specializeParams = (ast) => {
|
|
2933
|
+
if (!Array.isArray(ast) || ast[0] !== 'module') return ast
|
|
2934
|
+
const sameConst = (a, b) => {
|
|
2935
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a[0] !== b[0] || !a[0].endsWith?.('.const')) return false
|
|
2936
|
+
const ca = getConst(a), cb = getConst(b)
|
|
2937
|
+
if (!ca || !cb) return false
|
|
2938
|
+
if (a[0] === 'i32.const' || a[0] === 'i64.const') return String(ca.value) === String(cb.value)
|
|
2939
|
+
const na = Number(ca.value), nb = Number(cb.value)
|
|
2940
|
+
return !Number.isNaN(na) && !Number.isNaN(nb) && Object.is(na, nb)
|
|
2941
|
+
}
|
|
2942
|
+
// one module walk: call sites and raw name occurrences for every func
|
|
2943
|
+
const allSites = new Map(), occ = new Map()
|
|
2944
|
+
walk(ast, n => {
|
|
2945
|
+
if (Array.isArray(n)) {
|
|
2946
|
+
if (n[0] === 'call' && typeof n[1] === 'string') {
|
|
2947
|
+
let a = allSites.get(n[1])
|
|
2948
|
+
if (!a) allSites.set(n[1], a = [])
|
|
2949
|
+
a.push(n)
|
|
2950
|
+
}
|
|
2951
|
+
return
|
|
2952
|
+
}
|
|
2953
|
+
if (typeof n === 'string' && n[0] === '$') occ.set(n, (occ.get(n) || 0) + 1)
|
|
2954
|
+
})
|
|
2955
|
+
for (const fn of ast.slice(1)) {
|
|
2956
|
+
if (!Array.isArray(fn) || fn[0] !== 'func' || typeof fn[1] !== 'string' || fn[1][0] !== '$') continue
|
|
2957
|
+
if (fn.some(c => Array.isArray(c) && (c[0] === 'export' || c[0] === 'type'))) continue
|
|
2958
|
+
const name = fn[1]
|
|
2959
|
+
const params = []
|
|
2960
|
+
for (const c of fn) if (Array.isArray(c) && c[0] === 'param') {
|
|
2961
|
+
if (typeof c[1] !== 'string' || c[1][0] !== '$' || c.length !== 3) { params.length = 0; break }
|
|
2962
|
+
params.push(c)
|
|
2963
|
+
}
|
|
2964
|
+
if (!params.length) continue
|
|
2965
|
+
// every textual occurrence of the name must be a folded call head or the def
|
|
2966
|
+
const sites = allSites.get(name) || []
|
|
2967
|
+
if (!sites.length || (occ.get(name) || 0) !== sites.length + 1) continue
|
|
2968
|
+
if (sites.some(c => c.length - 2 !== params.length)) continue
|
|
2969
|
+
for (let k = params.length - 1; k >= 0; k--) {
|
|
2970
|
+
const first = sites[0][2 + k]
|
|
2971
|
+
if (!sites.every(c => sameConst(c[2 + k], first))) continue
|
|
2972
|
+
const pd = params[k]
|
|
2973
|
+
// never-written single-read param: splice the const straight onto the one
|
|
2974
|
+
// get — no local, no set, no coalesce slot. A const is a pure leaf, so any
|
|
2975
|
+
// position the read occupied is equivalent (invariance guaranteed by
|
|
2976
|
+
// writes === 0, including across loop back-edges).
|
|
2977
|
+
let reads = 0, writes = 0, readSite = null
|
|
2978
|
+
walk(fn, n => {
|
|
2979
|
+
if (!Array.isArray(n) || n[1] !== pd[1]) return
|
|
2980
|
+
if (n[0] === 'local.get') { reads++; readSite = n }
|
|
2981
|
+
else if (n[0] === 'local.set' || n[0] === 'local.tee') writes++
|
|
2982
|
+
})
|
|
2983
|
+
const direct = writes === 0 && reads <= 1
|
|
2984
|
+
const cost = direct ? (reads === 0 ? 0 : Math.max(0, constInstrSize(first) - 2))
|
|
2985
|
+
: 4 + constInstrSize(first) // local decl + set + const at callee
|
|
2986
|
+
const gain = sites.length * constInstrSize(first) + 1
|
|
2987
|
+
if (gain <= cost) continue
|
|
2988
|
+
const pi = fn.indexOf(pd)
|
|
2989
|
+
fn.splice(pi, 1)
|
|
2990
|
+
if (direct) {
|
|
2991
|
+
if (readSite) { readSite.length = 0; readSite.push(...clone(first)) }
|
|
2992
|
+
} else {
|
|
2993
|
+
let at = 2
|
|
2994
|
+
while (at < fn.length && Array.isArray(fn[at]) &&
|
|
2995
|
+
(fn[at][0] === 'export' || fn[at][0] === 'param' || fn[at][0] === 'result' || fn[at][0] === 'local')) at++
|
|
2996
|
+
fn.splice(at, 0, ['local', pd[1], pd[2]], ['local.set', pd[1], clone(first)])
|
|
2997
|
+
}
|
|
2998
|
+
for (const c of sites) c.splice(2 + k, 1)
|
|
2999
|
+
params.splice(k, 1)
|
|
3000
|
+
}
|
|
3001
|
+
}
|
|
3002
|
+
return ast
|
|
3003
|
+
}
|
|
3004
|
+
|
|
3005
|
+
/**
|
|
3006
|
+
* Elide a function-tail `return`: falling off the body returns the same values, so
|
|
3007
|
+
* the explicit return (folded, or wax's stacked VALUE-`return` pair) is framing.
|
|
3008
|
+
* Sound only when everything before the producer is provably stack-neutral —
|
|
3009
|
+
* `return` DISCARDS extra stack values, fall-through does not. The statement that
|
|
3010
|
+
* produces the result value is exempt from neutrality exactly when its arity is
|
|
3011
|
+
* known to match the function's single result (plain value ops; block/loop/if with
|
|
3012
|
+
* an explicit matching (result); calls to known-void functions never qualify).
|
|
3013
|
+
* Runs AFTER tailmerge — the return markers key its epilogue matching.
|
|
3014
|
+
* @param {Array} ast
|
|
3015
|
+
* @returns {Array}
|
|
3016
|
+
*/
|
|
3017
|
+
const rettail = (ast) => {
|
|
3018
|
+
if (!Array.isArray(ast) || ast[0] !== 'module') return ast
|
|
3019
|
+
const voidFuncs = collectVoidFuncs(ast)
|
|
3020
|
+
const stackNeutral = (n) => {
|
|
3021
|
+
if (n === 'nop' || n === 'unreachable' || n === 'return') return true
|
|
3022
|
+
if (!Array.isArray(n)) return false
|
|
3023
|
+
const op = n[0]
|
|
3024
|
+
if (op === 'param' || op === 'result' || op === 'local' || op === 'type' || op === 'export') return true
|
|
3025
|
+
if (op === 'local.set' || op === 'global.set' || op === 'drop' || op === 'nop' || op === 'return' ||
|
|
3026
|
+
op === 'br' || op === 'unreachable' || op === 'memory.copy' || op === 'memory.fill' ||
|
|
3027
|
+
op?.includes?.('.store')) return true
|
|
3028
|
+
if (op === 'br_if') return n.length <= 3
|
|
3029
|
+
if (op === 'block' || op === 'loop' || op === 'if')
|
|
3030
|
+
return !n.some(c => Array.isArray(c) && c[0] === 'result')
|
|
3031
|
+
if (op === 'call') return voidFuncs.has(n[1])
|
|
3032
|
+
return false
|
|
3033
|
+
}
|
|
3034
|
+
// does this statement produce exactly ONE value (the single-result case)?
|
|
3035
|
+
const producesOne = (n) => {
|
|
3036
|
+
if (!Array.isArray(n)) return false
|
|
3037
|
+
const op = n[0]
|
|
3038
|
+
if (op === 'block' || op === 'loop' || op === 'if')
|
|
3039
|
+
return n.some(c => Array.isArray(c) && c[0] === 'result' && c.length === 2)
|
|
3040
|
+
if (op === 'call' || op === 'call_indirect' || op === 'return_call' || op === 'return_call_indirect')
|
|
3041
|
+
return false // result arity unknown here — only the void set is tracked
|
|
3042
|
+
if (op === 'local.set' || op === 'global.set' || op === 'drop' || op?.includes?.('.store')) return false
|
|
3043
|
+
return true // plain value op: exactly one value in this IR
|
|
3044
|
+
}
|
|
3045
|
+
// Convert a tail-position statement into the value form it feeds a (result T)
|
|
3046
|
+
// scope: an unwrapped `return V` becomes V; a diverging br/unreachable stays; an
|
|
3047
|
+
// untyped if recurses into both arms (each must convert). null = doesn't qualify.
|
|
3048
|
+
const convertTail = (nd, T) => {
|
|
3049
|
+
if (!Array.isArray(nd)) return null // bare/unfolded token forms: bail (soundness fence)
|
|
3050
|
+
const op = nd[0]
|
|
3051
|
+
if (op === 'br' || op === 'unreachable') return nd // diverging — still valid under (result T)
|
|
3052
|
+
if (op === 'return') {
|
|
3053
|
+
if (nd.length !== 2 || !producesOne(nd[1])) return null
|
|
3054
|
+
return nd[1]
|
|
3055
|
+
}
|
|
3056
|
+
if (op === 'if') {
|
|
3057
|
+
if (nd.some(c => Array.isArray(c) && c[0] === 'result')) return null // already typed — not our shape
|
|
3058
|
+
const { cond, thenBranch, elseBranch } = parseIf(nd)
|
|
3059
|
+
if (!thenBranch || !elseBranch || thenBranch.length < 2 || elseBranch.length < 2) return null
|
|
3060
|
+
const thenBody = thenBranch.slice(1), elseBody = elseBranch.slice(1)
|
|
3061
|
+
if (!thenBody.slice(0, -1).every(stackNeutral)) return null
|
|
3062
|
+
if (!elseBody.slice(0, -1).every(stackNeutral)) return null
|
|
3063
|
+
const thenConv = convertTail(thenBody[thenBody.length - 1], T)
|
|
3064
|
+
const elseConv = convertTail(elseBody[elseBody.length - 1], T)
|
|
3065
|
+
if (thenConv === null || elseConv === null) return null
|
|
3066
|
+
return ['if', ['result', T], cond,
|
|
3067
|
+
['then', ...thenBody.slice(0, -1), thenConv],
|
|
3068
|
+
['else', ...elseBody.slice(0, -1), elseConv]]
|
|
3069
|
+
}
|
|
3070
|
+
return null
|
|
3071
|
+
}
|
|
3072
|
+
// A void trailing loop whose only exits are `br` back to itself or `return V` at
|
|
3073
|
+
// (possibly if-nested) tail positions: type the loop (result T), unwrap the
|
|
3074
|
+
// returns, and drop the dead trailing filler that only satisfied the void shape.
|
|
3075
|
+
const tryLoopLift = (node, bodyStart, T) => {
|
|
3076
|
+
const n = node.length
|
|
3077
|
+
const loopIdx = Array.isArray(node[n - 1]) && node[n - 1][0] === 'loop' ? n - 1
|
|
3078
|
+
: n >= 2 && Array.isArray(node[n - 2]) && node[n - 2][0] === 'loop' ? n - 2 : -1
|
|
3079
|
+
if (loopIdx < 0) return
|
|
3080
|
+
const loop = node[loopIdx]
|
|
3081
|
+
if (loop.some(c => Array.isArray(c) && (c[0] === 'result' || c[0] === 'param'))) return
|
|
3082
|
+
// every statement before the loop must be stack-neutral — the lifted loop's
|
|
3083
|
+
// value becomes the function's fall-through result, alone on the stack
|
|
3084
|
+
if (!node.slice(bodyStart, loopIdx).every(stackNeutral)) return
|
|
3085
|
+
const label = typeof loop[1] === 'string' && loop[1][0] === '$' ? loop[1] : null
|
|
3086
|
+
if (!label) return // named label required — branch-target safety is checked by identity
|
|
3087
|
+
const body = loop.slice(2)
|
|
3088
|
+
if (!body.length) return
|
|
3089
|
+
// conservative fence: every branch anywhere in the loop must target only this
|
|
3090
|
+
// loop's own label (never escape outward, never a sibling scope)
|
|
3091
|
+
let branchesSafe = true
|
|
3092
|
+
walk(loop, (nd, parent, idx) => {
|
|
3093
|
+
// bare flat-form tokens (idx 0 is a folded node's own head, not a flat token)
|
|
3094
|
+
if (!Array.isArray(nd)) { if (idx !== 0 && typeof nd === 'string' && OPCODE[nd] !== undefined) branchesSafe = false; return }
|
|
3095
|
+
const o = nd[0]
|
|
3096
|
+
if (o === 'br' || o === 'br_if') { if (nd[1] !== label) branchesSafe = false }
|
|
3097
|
+
else if (o === 'br_table') { for (const t of nd.slice(1, -1)) if (t !== label) branchesSafe = false }
|
|
3098
|
+
else if (o === 'return_call' || o === 'return_call_indirect' || o === 'try_table' || o === 'throw') branchesSafe = false
|
|
3099
|
+
// nested scopes (their own labels, own exits) are conservatively out of scope
|
|
3100
|
+
else if (nd !== loop && (o === 'block' || o === 'loop')) branchesSafe = false
|
|
3101
|
+
})
|
|
3102
|
+
if (!branchesSafe) return
|
|
3103
|
+
if (!body.slice(0, -1).every(stackNeutral)) return
|
|
3104
|
+
const converted = convertTail(body[body.length - 1], T)
|
|
3105
|
+
if (converted === null) return
|
|
3106
|
+
// every convertible tail diverges or returns — the original loop never fell
|
|
3107
|
+
// through, so a trailing filler statement was unreachable: delete it
|
|
3108
|
+
loop.splice(2 + body.length - 1, 1, converted)
|
|
3109
|
+
loop.splice(2, 0, ['result', T])
|
|
3110
|
+
if (loopIdx === n - 2) node.pop()
|
|
3111
|
+
}
|
|
3112
|
+
for (const node of ast.slice(1)) {
|
|
3113
|
+
if (!Array.isArray(node) || node[0] !== 'func') continue
|
|
3114
|
+
const results = node.reduce((k, c) => Array.isArray(c) && c[0] === 'result' ? k + c.length - 1 : k, 0)
|
|
3115
|
+
if (results > 1) continue
|
|
3116
|
+
const bodyStart = typeof node[1] === 'string' && node[1][0] === '$' ? 2 : 1
|
|
3117
|
+
const last = node[node.length - 1]
|
|
3118
|
+
const isRet = last === 'return' ? 1 : Array.isArray(last) && last[0] === 'return' ? 2 : 0
|
|
3119
|
+
if (!isRet) {
|
|
3120
|
+
if (results === 1) {
|
|
3121
|
+
const rc = node.find(c => Array.isArray(c) && c[0] === 'result' && c.length === 2)
|
|
3122
|
+
if (rc) tryLoopLift(node, bodyStart, rc[1])
|
|
3123
|
+
}
|
|
3124
|
+
continue
|
|
3125
|
+
}
|
|
3126
|
+
if (isRet === 1) {
|
|
3127
|
+
// stacked form: the statement before `return` is the value producer — exempt
|
|
3128
|
+
// it only when its single-value arity matches the declared single result
|
|
3129
|
+
const end = results === 1 ? node.length - 2 : node.length - 1
|
|
3130
|
+
if (results === 1 && !producesOne(node[end])) continue
|
|
3131
|
+
if (!node.slice(bodyStart, end).every(stackNeutral)) continue
|
|
3132
|
+
node.pop()
|
|
3133
|
+
} else {
|
|
3134
|
+
if (!node.slice(bodyStart, -1).every(stackNeutral)) continue
|
|
3135
|
+
node.splice(node.length - 1, 1, ...last.slice(1))
|
|
3136
|
+
}
|
|
3137
|
+
}
|
|
3138
|
+
return ast
|
|
3139
|
+
}
|
|
3140
|
+
|
|
3141
|
+
/**
|
|
3142
|
+
* Tail-merge duplicated early-exit epilogues: several `(if C (then STMTS… (return V)))`
|
|
3143
|
+
* sites whose arm bodies are byte-identical collapse to `(br_if $L C)` into one shared
|
|
3144
|
+
* copy placed after a block wrapping the body. Sound under the verifier's contract:
|
|
3145
|
+
* arm bodies are strictly branch-free straight-line code (calls / local & global
|
|
3146
|
+
* accesses / numeric ops) ending in a function-level `return` — the only relocated
|
|
3147
|
+
* instructions are position-independent, and each branch arrives with whatever state
|
|
3148
|
+
* its site had, exactly as the duplicated copy would. The function's own last
|
|
3149
|
+
* statement must already terminate, so the block can never fall through into the
|
|
3150
|
+
* shared copy.
|
|
3151
|
+
* @param {Array} ast
|
|
3152
|
+
* @returns {Array}
|
|
3153
|
+
*/
|
|
3154
|
+
// ==================== OUTLINING ====================
|
|
1580
3155
|
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
3156
|
+
/**
|
|
3157
|
+
* Extract repeated pure expressions into a shared helper function — CSE across
|
|
3158
|
+
* function boundaries. A candidate is a pure value expression (trap ops allowed:
|
|
3159
|
+
* the call evaluates at the same point, so a div/load traps identically) whose
|
|
3160
|
+
* only variable leaves are typed named locals; those become the helper's params,
|
|
3161
|
+
* positionally canonicalized so sites in different functions with different
|
|
3162
|
+
* local names share one helper. Greedy: per round, collect all groups, apply the
|
|
3163
|
+
* single most profitable, re-collect (an applied group changes what overlapping
|
|
3164
|
+
* candidates still exist). Runs LAST in finish(): inlineOnce ignores multi-call
|
|
3165
|
+
* helpers, and inlineMacro never reruns after the rounds, so nothing re-expands
|
|
3166
|
+
* the extraction.
|
|
3167
|
+
*/
|
|
3168
|
+
// Encoding-aware expression size for outline decisions — estBytes prices every
|
|
3169
|
+
// number at 2 (an f64.const is really 9), which flips boundary calls. Index
|
|
3170
|
+
// widths use the small-index optimism (1 byte): that UNDER-counts a site's
|
|
3171
|
+
// bytes, so gains are under- not over-estimated — refusals stay safe.
|
|
3172
|
+
// per-node immediate width (children excluded) — the facts pass sums bottom-up
|
|
3173
|
+
const ownBytes = (n) => {
|
|
3174
|
+
const op = n[0], code = OPCODE[op]
|
|
3175
|
+
let b = typeof code === 'number' ? (code > 0xffff ? ((code & 0xffff) > 127 ? 3 : 2) : 1) : 1
|
|
3176
|
+
if (op === 'i32.const' || op === 'i64.const') return b + slebSize(n[1])
|
|
3177
|
+
if (op === 'f32.const') return b + 4
|
|
3178
|
+
if (op === 'f64.const') return b + 8
|
|
3179
|
+
if (op === 'local.get' || op === 'local.set' || op === 'local.tee' || op === 'global.get') return b + 1
|
|
3180
|
+
let alignTok = false, offTok = false
|
|
3181
|
+
for (let i = 1; i < n.length; i++) {
|
|
3182
|
+
const c = n[i]
|
|
3183
|
+
if (Array.isArray(c)) continue
|
|
3184
|
+
if (typeof c === 'string' && c.startsWith('offset=')) { offTok = true; b += ulebBytes(+c.slice(7)) }
|
|
3185
|
+
else if (typeof c === 'string' && c.startsWith('align=')) { alignTok = true; b += 1 }
|
|
3186
|
+
else b += 1 // name/label/small immediate — optimistic
|
|
3187
|
+
}
|
|
3188
|
+
if (op.includes('.load') || op.includes('.store')) b += (alignTok ? 0 : 1) + (offTok ? 0 : 1)
|
|
3189
|
+
return b
|
|
3190
|
+
}
|
|
3191
|
+
const ulebBytes = (v) => { let k = 1; v = v >>> 7; while (v) k++, v >>>= 7; return k }
|
|
3192
|
+
|
|
3193
|
+
let outUid = 0
|
|
3194
|
+
const outline = (ast) => {
|
|
3195
|
+
if (!Array.isArray(ast) || ast[0] !== 'module') return ast
|
|
3196
|
+
let rescan = null // functions containing applied sites — later rounds rescan only these
|
|
3197
|
+
for (let round = 0; round < 3; round++) {
|
|
3198
|
+
let funcCount = 0
|
|
3199
|
+
for (const n of ast) if (Array.isArray(n) && (n[0] === 'func' ||
|
|
3200
|
+
(n[0] === 'import' && n.some(c => Array.isArray(c) && c[0] === 'func')))) funcCount++
|
|
3201
|
+
const callB = funcCount > 127 ? 3 : 2
|
|
3202
|
+
// One bottom-up pass per function computes composable facts — purity, encoded
|
|
3203
|
+
// size (own width + children, no per-root re-recursion), name-blind hash —
|
|
3204
|
+
// for every subtree. Candidates group by the cheap hash; exact positional
|
|
3205
|
+
// canonicalization is DEFERRED to apply time (once per chosen group), and
|
|
3206
|
+
// param collection to groups that actually collided.
|
|
3207
|
+
const facts = new Map()
|
|
3208
|
+
const groups = new Map()
|
|
3209
|
+
for (const fn of ast) {
|
|
3210
|
+
if (!Array.isArray(fn) || fn[0] !== 'func') continue
|
|
3211
|
+
if (rescan && !rescan.has(fn)) continue
|
|
3212
|
+
const ltype = new Map()
|
|
3213
|
+
for (const c of fn) if (Array.isArray(c) && (c[0] === 'param' || c[0] === 'local') &&
|
|
3214
|
+
typeof c[1] === 'string' && typeof c[2] === 'string') ltype.set(c[1], c[2])
|
|
3215
|
+
walkPost(fn, (n, parent, idx) => {
|
|
3216
|
+
if (!Array.isArray(n) || typeof n[0] !== 'string') return
|
|
3217
|
+
const op = n[0]
|
|
3218
|
+
let pure = !IMPURE_OPS.has(op) && !IMPURE_SUBSTRINGS.some(sub => op.includes(sub)) &&
|
|
3219
|
+
op !== 'if' && op !== 'block' && op !== 'loop' && op !== 'then' && op !== 'else' && op !== 'try_table'
|
|
3220
|
+
let b = ownBytes(n)
|
|
3221
|
+
let h = op
|
|
3222
|
+
for (let i = 1; i < n.length; i++) {
|
|
3223
|
+
const c = n[i]
|
|
3224
|
+
if (Array.isArray(c)) {
|
|
3225
|
+
const f = facts.get(c)
|
|
3226
|
+
if (!f) { pure = false; continue }
|
|
3227
|
+
pure &&= f.pure
|
|
3228
|
+
b += f.b
|
|
3229
|
+
h += ',' + (c[0] === 'local.get' && c.length === 2 ? 'L' : f.h)
|
|
3230
|
+
} else h += ',' + c
|
|
3231
|
+
}
|
|
3232
|
+
const rec = { pure, b, h: h.length > 64 ? 'H' + hash32(h) : h }
|
|
3233
|
+
facts.set(n, rec)
|
|
3234
|
+
if (!parent || !pure || rec.b < 10 || !resultType(op)) return
|
|
3235
|
+
let g = groups.get(rec.h)
|
|
3236
|
+
if (!g) groups.set(rec.h, g = [])
|
|
3237
|
+
g.push({ node: n, parent, idx, fn, ltype, bytes: rec.b })
|
|
3238
|
+
})
|
|
3239
|
+
}
|
|
3240
|
+
// exact grouping only within collisions: params + types collected per site,
|
|
3241
|
+
// canonical body built once per chosen group at apply time
|
|
3242
|
+
const exact = new Map()
|
|
3243
|
+
for (const cands of groups.values()) {
|
|
3244
|
+
if (cands.length < 2) continue
|
|
3245
|
+
for (const cand of cands) {
|
|
3246
|
+
const params = []
|
|
3247
|
+
let ok = true
|
|
3248
|
+
walk(cand.node, (c) => {
|
|
3249
|
+
if (!ok || !Array.isArray(c)) return
|
|
3250
|
+
if (c[0] === 'local.get') {
|
|
3251
|
+
if (typeof c[1] !== 'string') { ok = false; return }
|
|
3252
|
+
if (!params.some(p => p.name === c[1])) {
|
|
3253
|
+
const t = cand.ltype.get(c[1])
|
|
3254
|
+
if (!t) { ok = false; return }
|
|
3255
|
+
params.push({ name: c[1], type: t })
|
|
3256
|
+
}
|
|
3257
|
+
}
|
|
3258
|
+
else if (c[0] === 'local.tee' || c[0] === 'local.set') ok = false
|
|
3259
|
+
})
|
|
3260
|
+
if (!ok || params.length > 4) continue
|
|
3261
|
+
const rt = resultType(cand.node[0])
|
|
3262
|
+
const key = facts.get(cand.node).h + '|' + params.map(p => p.type).join(',') + '|' + rt
|
|
3263
|
+
let g = exact.get(key)
|
|
3264
|
+
if (!g) exact.set(key, g = { first: cand, ptypes: params.map(p => p.type), rt, bytes: cand.bytes, arity: params.length, sites: [] })
|
|
3265
|
+
g.sites.push({ node: cand.node, parent: cand.parent, idx: cand.idx, fn: cand.fn, args: params.map(p => p.name) })
|
|
1599
3266
|
}
|
|
1600
3267
|
}
|
|
1601
|
-
//
|
|
1602
|
-
|
|
1603
|
-
|
|
3268
|
+
// apply every profitable group, best first, skipping consumed overlaps
|
|
3269
|
+
const chosen = [...exact.values()]
|
|
3270
|
+
.map(g => ({ g, net: g.sites.length * (g.bytes - callB - 2 * g.arity) - (g.bytes + 5 + 3) }))
|
|
3271
|
+
.filter(x => x.g.sites.length >= 2 && x.net >= 4)
|
|
3272
|
+
.sort((a, b) => b.net - a.net)
|
|
3273
|
+
let applied = 0
|
|
3274
|
+
const consumed = new Set()
|
|
3275
|
+
const touched = new Set()
|
|
3276
|
+
for (const { g } of chosen) {
|
|
3277
|
+
const live = g.sites.filter(st => {
|
|
3278
|
+
if (st.parent[st.idx] !== st.node) return false // ancestor already replaced this slot
|
|
3279
|
+
if (consumed.has(st.node)) return false // inside an applied site's detached subtree
|
|
3280
|
+
let hit = false
|
|
3281
|
+
walk(st.node, c => { if (consumed.has(c)) hit = true })
|
|
3282
|
+
return !hit
|
|
3283
|
+
})
|
|
3284
|
+
const net = live.length * (g.bytes - callB - 2 * g.arity) - (g.bytes + 5 + 3)
|
|
3285
|
+
if (live.length < 2 || net < 4) continue
|
|
3286
|
+
// canonicalize the FIRST live site once: positional params, shared body
|
|
3287
|
+
const params = []
|
|
3288
|
+
const canon = walkPost(clone(live[0].node), (c) => {
|
|
3289
|
+
if (!Array.isArray(c)) return
|
|
3290
|
+
if (c[0] === 'local.get') {
|
|
3291
|
+
let i = params.indexOf(c[1])
|
|
3292
|
+
if (i < 0) { params.push(c[1]); i = params.length - 1 }
|
|
3293
|
+
return ['local.get', '$' + i]
|
|
3294
|
+
}
|
|
3295
|
+
})
|
|
3296
|
+
const name = '$__out' + outUid++
|
|
3297
|
+
ast.push(['func', name,
|
|
3298
|
+
...g.ptypes.map((t, i) => ['param', '$' + i, t]),
|
|
3299
|
+
['result', g.rt], canon])
|
|
3300
|
+
for (const st of live) {
|
|
3301
|
+
// mark the WHOLE subtree: a smaller candidate nested inside this site
|
|
3302
|
+
// still passes the parent-slot identity check on the detached tree
|
|
3303
|
+
walk(st.node, c => { if (Array.isArray(c)) consumed.add(c) })
|
|
3304
|
+
st.parent[st.idx] = ['call', name, ...st.args.map(a => ['local.get', a])]
|
|
3305
|
+
touched.add(st.fn)
|
|
3306
|
+
}
|
|
3307
|
+
applied++
|
|
1604
3308
|
}
|
|
3309
|
+
if (!applied) break
|
|
3310
|
+
rescan = touched
|
|
1605
3311
|
}
|
|
3312
|
+
return ast
|
|
3313
|
+
}
|
|
1606
3314
|
|
|
1607
|
-
|
|
3315
|
+
// tiny string hash for composable weak keys (values only ever compared, never decoded)
|
|
3316
|
+
const hash32 = (s) => {
|
|
3317
|
+
let h = 2166136261
|
|
3318
|
+
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) }
|
|
3319
|
+
return (h >>> 0).toString(36)
|
|
3320
|
+
}
|
|
3321
|
+
|
|
3322
|
+
let tmUid = 0
|
|
3323
|
+
const tailmerge = (ast) => {
|
|
3324
|
+
walk(ast, (fn) => {
|
|
3325
|
+
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
3326
|
+
const isTerm = (n) => n === 'unreachable' || n === 'return' ||
|
|
3327
|
+
(Array.isArray(n) && (n[0] === 'return' || n[0] === 'unreachable' || n[0] === 'br'))
|
|
3328
|
+
const last = fn[fn.length - 1]
|
|
3329
|
+
if (!isTerm(last)) return
|
|
3330
|
+
// linear, position-independent snippet: no branch/control may relocate
|
|
3331
|
+
const movable = (body) => {
|
|
3332
|
+
let ok = true
|
|
3333
|
+
walk(body, x => {
|
|
3334
|
+
const o = Array.isArray(x) ? x[0] : (typeof x === 'string' && OPCODE[x] !== undefined ? x : null)
|
|
3335
|
+
if (o === 'br' || o === 'br_if' || o === 'br_table' || o === 'return_call' || o === 'return_call_indirect' ||
|
|
3336
|
+
o === 'loop' || o === 'block' || o === 'if' || o === 'try_table' || o === 'unreachable') ok = false
|
|
3337
|
+
})
|
|
3338
|
+
return ok
|
|
3339
|
+
}
|
|
3340
|
+
const EMPTY_MAP = new Map()
|
|
3341
|
+
const groups = new Map()
|
|
3342
|
+
// sites may sit at any depth: `return` exits the function from anywhere, and the
|
|
3343
|
+
// replacement br_if targets the wrapper block by NAME
|
|
3344
|
+
walk(fn, (st, parent, idx) => {
|
|
3345
|
+
if (!Array.isArray(st) || st[0] !== 'if' || st.length !== 3 || !parent) return
|
|
3346
|
+
const { cond, thenBranch, elseBranch } = parseIf(st)
|
|
3347
|
+
if (!Array.isArray(cond) || !thenBranch || elseBranch) return
|
|
3348
|
+
const body = thenBranch.slice(1)
|
|
3349
|
+
if (!body.length) return
|
|
3350
|
+
// folded (return V) or wax's stacked `VALUE return` pair
|
|
3351
|
+
const ret = body[body.length - 1]
|
|
3352
|
+
if (!(Array.isArray(ret) && ret[0] === 'return') && ret !== 'return') return
|
|
3353
|
+
if (!movable(body) || estBytes(['b', ...body]) < 5) return
|
|
3354
|
+
const key = hashFunc(['b', ...body], EMPTY_MAP)
|
|
3355
|
+
let g = groups.get(key)
|
|
3356
|
+
if (!g) groups.set(key, g = { body, sites: [] })
|
|
3357
|
+
g.sites.push(st)
|
|
3358
|
+
})
|
|
3359
|
+
// every qualifying group wraps its own labeled block (inner groups nest inside
|
|
3360
|
+
// the earlier wrappers; br_if targets by name, so depth is irrelevant).
|
|
3361
|
+
// Sites are replaced by NODE IDENTITY: each wrap below splices the whole body
|
|
3362
|
+
// into a fresh block, so positions recorded during collection go stale — a
|
|
3363
|
+
// stale [parent, idx] would rewrite whatever statement NOW sits there into a
|
|
3364
|
+
// br_if with this group's label (silent misdirection, or a crash on a hole).
|
|
3365
|
+
const chosen = [...groups.values()].filter(g => g.sites.length >= 2)
|
|
3366
|
+
for (const g of chosen) {
|
|
3367
|
+
const label = '$__tm' + tmUid++
|
|
3368
|
+
const siteSet = new Set(g.sites)
|
|
3369
|
+
walk(fn, (n, parent, idx) => {
|
|
3370
|
+
if (parent && siteSet.has(n)) {
|
|
3371
|
+
const { cond } = parseIf(n)
|
|
3372
|
+
parent[idx] = ['br_if', label, cond]
|
|
3373
|
+
}
|
|
3374
|
+
})
|
|
3375
|
+
let at = typeof fn[1] === 'string' && fn[1][0] === '$' ? 2 : 1
|
|
3376
|
+
while (at < fn.length && Array.isArray(fn[at]) &&
|
|
3377
|
+
(fn[at][0] === 'export' || fn[at][0] === 'type' || fn[at][0] === 'param' || fn[at][0] === 'result' || fn[at][0] === 'local')) at++
|
|
3378
|
+
const stmts = fn.splice(at, fn.length - at)
|
|
3379
|
+
fn.push(['block', label, ...stmts], ...g.body)
|
|
3380
|
+
}
|
|
3381
|
+
})
|
|
3382
|
+
return ast
|
|
1608
3383
|
}
|
|
1609
3384
|
|
|
1610
3385
|
/**
|
|
1611
|
-
*
|
|
1612
|
-
*
|
|
1613
|
-
*
|
|
1614
|
-
*
|
|
1615
|
-
*
|
|
1616
|
-
*
|
|
1617
|
-
*
|
|
1618
|
-
* @
|
|
1619
|
-
* @param {Set<string>} params
|
|
3386
|
+
* Merge alias locals: `(local.set $A (local.tee $B V))` writes the same value into
|
|
3387
|
+
* two slots. When that is the ONLY write either local ever gets, their write
|
|
3388
|
+
* histories are identical — every read of $A (even one sequenced before the def,
|
|
3389
|
+
* which sees the zero default on both) equals the same read of $B. So $A's reads
|
|
3390
|
+
* rename to $B and the alias write drops. Params are excluded ($B would carry a
|
|
3391
|
+
* call-argument value where $A held zero).
|
|
3392
|
+
* @param {Array} ast
|
|
3393
|
+
* @returns {Array}
|
|
1620
3394
|
*/
|
|
1621
|
-
const
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
const
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
3395
|
+
const mergeLocals = (ast) => {
|
|
3396
|
+
walk(ast, (fn) => {
|
|
3397
|
+
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
3398
|
+
const params = new Set()
|
|
3399
|
+
for (const c of fn) if (Array.isArray(c) && c[0] === 'param' && typeof c[1] === 'string') params.add(c[1])
|
|
3400
|
+
const counts = countLocalUses(fn)
|
|
3401
|
+
const renames = new Map()
|
|
3402
|
+
walkPost(fn, (n) => {
|
|
3403
|
+
if (!Array.isArray(n) || n[0] !== 'local.set' || n.length !== 3 ||
|
|
3404
|
+
!Array.isArray(n[2]) || n[2][0] !== 'local.tee' || n[2].length !== 3) return
|
|
3405
|
+
const A = n[1], B = n[2][1]
|
|
3406
|
+
if (typeof A !== 'string' || typeof B !== 'string' || A === B) return
|
|
3407
|
+
if (params.has(A) || params.has(B) || renames.has(A) || renames.has(B)) return
|
|
3408
|
+
const ca = counts.get(A), cb = counts.get(B)
|
|
3409
|
+
if (!ca || !cb || ca.sets !== 1 || ca.tees !== 0 || cb.sets !== 0 || cb.tees !== 1) return
|
|
3410
|
+
renames.set(A, B)
|
|
3411
|
+
return ['local.set', B, n[2][2]]
|
|
3412
|
+
})
|
|
3413
|
+
if (renames.size) walkPost(fn, (n) => {
|
|
3414
|
+
if (Array.isArray(n) && n[0] === 'local.get' && renames.has(n[1])) return ['local.get', renames.get(n[1])]
|
|
3415
|
+
})
|
|
3416
|
+
})
|
|
3417
|
+
return ast
|
|
1637
3418
|
}
|
|
1638
3419
|
|
|
1639
3420
|
/**
|
|
@@ -1662,26 +3443,67 @@ const propagate = (ast) => {
|
|
|
1662
3443
|
// first so inner simplifications shrink the use-counts the outer scopes see.
|
|
1663
3444
|
// Use-counts are always whole-function — a set/get pair or dead store is only
|
|
1664
3445
|
// touched when it's globally the sole occurrence, so per-scope work stays sound.
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
//
|
|
1669
|
-
//
|
|
1670
|
-
//
|
|
1671
|
-
//
|
|
1672
|
-
//
|
|
3446
|
+
|
|
3447
|
+
// One use-count per propagation sweep: for the cautious sub-passes a stale count
|
|
3448
|
+
// only over-counts (skip a not-yet-provably-dead store) — never wrongly. The
|
|
3449
|
+
// exception is sinkSets' sole-use substitution: copy-propagation REPLICATES gets
|
|
3450
|
+
// of a copy's source local, so an under-count there would orphan a surviving get —
|
|
3451
|
+
// recount once after the propagation sweep before sinking. (Recounting per
|
|
3452
|
+
// sub-pass per scope is O(scopes·funcSize) and crippling on big modules.)
|
|
3453
|
+
// Maintained counts: one from-scratch tally per FUNCTION, then every mutation
|
|
3454
|
+
// in the family reports its delta — the old per-round double recount (and its
|
|
3455
|
+
// missed-refresh bug class) goes away. The oracle flag re-derives and compares
|
|
3456
|
+
// after every sub-pass; the test battery runs with it on.
|
|
3457
|
+
CNT = countLocalUses(funcNode)
|
|
3458
|
+
CNT_FN = funcNode
|
|
1673
3459
|
for (let round = 0; round < MAX_PROP_ROUNDS; round++) {
|
|
1674
|
-
|
|
3460
|
+
// Scopes RE-COLLECT each round: a spliced statement detaches any scope
|
|
3461
|
+
// nested in its value — the once-collected list kept mutating dead trees
|
|
3462
|
+
// (wasted work always; corrupted counts once they were maintained) and
|
|
3463
|
+
// never saw scopes newly created by substitution clones.
|
|
3464
|
+
const scopes = []
|
|
3465
|
+
walkPost(funcNode, n => { if (isScopeNode(n)) scopes.push(n) })
|
|
3466
|
+
const useCounts = CNT
|
|
1675
3467
|
let progressed = false
|
|
3468
|
+
for (const scope of scopes) if (forwardPropagate(scope, params, useCounts)) progressed = true
|
|
3469
|
+
cntOracle(funcNode, 'forwardPropagate')
|
|
3470
|
+
const counts = CNT
|
|
1676
3471
|
for (const scope of scopes) {
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
3472
|
+
// pure set feeding a loop (possibly through a run of other pure sets that
|
|
3473
|
+
// don't read it) which provably clobbers it on every path before any read,
|
|
3474
|
+
// with no reads outside the loop: dead
|
|
3475
|
+
for (let i = 1; i < scope.length - 1; i++) {
|
|
3476
|
+
const st = scope[i]
|
|
3477
|
+
if (!(Array.isArray(st) && st[0] === 'local.set' && st.length === 3 && typeof st[1] === 'string' &&
|
|
3478
|
+
Array.isArray(st[2]) && isPure(st[2]) && !hasTrap(st[2]))) continue
|
|
3479
|
+
let j = i + 1, blocked = false
|
|
3480
|
+
while (j < scope.length) {
|
|
3481
|
+
const nx = scope[j]
|
|
3482
|
+
if (Array.isArray(nx) && nx[0] === 'loop') break
|
|
3483
|
+
// an intervening statement may only be another pure set that neither
|
|
3484
|
+
// reads nor writes this local
|
|
3485
|
+
if (!(Array.isArray(nx) && nx[0] === 'local.set' && nx.length === 3 && nx[1] !== st[1] &&
|
|
3486
|
+
Array.isArray(nx[2]) && isPure(nx[2]))) { blocked = true; break }
|
|
3487
|
+
let touches = false
|
|
3488
|
+
walk(nx[2], c => { if (Array.isArray(c) && (c[0] === 'local.get' || c[0] === 'local.tee') && c[1] === st[1]) touches = true })
|
|
3489
|
+
if (touches) { blocked = true; break }
|
|
3490
|
+
j++
|
|
3491
|
+
}
|
|
3492
|
+
if (blocked || j >= scope.length || !Array.isArray(scope[j]) || scope[j][0] !== 'loop') continue
|
|
3493
|
+
if (deadThroughLoop(funcNode, st[1], scope[j])) { cntSub(st); scope.splice(i, 1); i--; progressed = true }
|
|
3494
|
+
}
|
|
3495
|
+
cntOracle(funcNode, 'deadThroughLoop')
|
|
3496
|
+
if (commuteForSink(scope)) { progressed = true; cntOracle(funcNode, 'commuteForSink') }
|
|
3497
|
+
if (sinkIntoBranch(scope, params, counts)) { progressed = true; cntOracle(funcNode, 'sinkIntoBranch') }
|
|
3498
|
+
if (mergeCopyThroughTee(scope, params, counts)) { progressed = true; cntOracle(funcNode, 'mergeCopyThroughTee') }
|
|
3499
|
+
if (sinkSets(scope, params, counts)) { progressed = true; cntOracle(funcNode, 'sinkSets') }
|
|
3500
|
+
if (eliminateDeadStores(scope, params, counts)) { progressed = true; cntOracle(funcNode, 'eliminateDeadStores') }
|
|
3501
|
+
if (eliminateAdjacentDeadStores(scope, params)) { progressed = true; cntOracle(funcNode, 'adjacentDSE') }
|
|
1682
3502
|
}
|
|
1683
3503
|
if (!progressed) break
|
|
1684
3504
|
}
|
|
3505
|
+
CNT = null
|
|
3506
|
+
CNT_FN = null
|
|
1685
3507
|
})
|
|
1686
3508
|
|
|
1687
3509
|
return ast
|
|
@@ -1697,7 +3519,13 @@ const propagate = (ast) => {
|
|
|
1697
3519
|
// `(block $__inlN (result T)? …)`. Only the SELECTION policy differs (one caller vs
|
|
1698
3520
|
// every caller of a small body), so the lift lives here once.
|
|
1699
3521
|
|
|
3522
|
+
const EMPTY_MAP = new Map()
|
|
1700
3523
|
let inlineUid = 0
|
|
3524
|
+
// Bumped by every splice that can GROW the binary (inline / inlineOnce /
|
|
3525
|
+
// inlineMacro expansion). The driver's exit size-guard exists solely for these —
|
|
3526
|
+
// when none fired during rounds, every executed pass was size-monotone and both
|
|
3527
|
+
// guard encodes are skipped.
|
|
3528
|
+
let inflations = 0
|
|
1701
3529
|
const INL_HEAD = new Set(['export', 'type', 'param', 'result', 'local'])
|
|
1702
3530
|
const inlBodyStart = (fn) => {
|
|
1703
3531
|
let i = 2
|
|
@@ -1707,12 +3535,20 @@ const inlBodyStart = (fn) => {
|
|
|
1707
3535
|
const inlIsBranch = op => op === 'br' || op === 'br_if' || op === 'br_table'
|
|
1708
3536
|
// A subtree we can't lift into a (block …): depth-relative branch labels (which would
|
|
1709
3537
|
// shift under the added nesting) or tail calls (which would escape the wrapping block).
|
|
3538
|
+
// Flat (unfolded) control tokens can't be relabeled under the inline wrapper's added
|
|
3539
|
+
// nesting — bodies carrying them don't lift. Bare 'return' is exempt: buildInline
|
|
3540
|
+
// rewrites it to a br. Bare value ops ('drop', 'i32.add', …) are position-independent.
|
|
3541
|
+
const FLAT_CTRL = new Set(['block', 'loop', 'if', 'else', 'end', 'br', 'br_if', 'br_table',
|
|
3542
|
+
'try_table', 'catch', 'catch_all', 'delegate', 'rethrow', 'return_call', 'return_call_indirect'])
|
|
1710
3543
|
const inlUnsafe = (n) => {
|
|
3544
|
+
if (typeof n === 'string') return FLAT_CTRL.has(n)
|
|
1711
3545
|
if (!Array.isArray(n)) return false
|
|
1712
3546
|
const op = n[0]
|
|
1713
3547
|
if (op === 'return_call' || op === 'return_call_indirect' || op === 'return_call_ref') return true
|
|
1714
3548
|
if (op === 'try' || op === 'try_table' || op === 'delegate' || op === 'rethrow') return true // exception labels — not handled by the relabeler below
|
|
1715
|
-
|
|
3549
|
+
// NUMERIC branch labels are safe to splice: internal depths are preserved verbatim,
|
|
3550
|
+
// and a function-frame branch (depth past every callee block) lands exactly on the
|
|
3551
|
+
// single wrapper block buildInline adds — the same return-to-exit semantics.
|
|
1716
3552
|
for (let i = 1; i < n.length; i++) if (inlUnsafe(n[i])) return true
|
|
1717
3553
|
return false
|
|
1718
3554
|
}
|
|
@@ -1830,6 +3666,7 @@ const inlBodySize = (fn) => {
|
|
|
1830
3666
|
* …body, renamed, `return X` → `br $__inlN X`…)
|
|
1831
3667
|
*/
|
|
1832
3668
|
const buildInline = (params, locals, inlResult, cBody, args) => {
|
|
3669
|
+
inflations++
|
|
1833
3670
|
const uid = ++inlineUid
|
|
1834
3671
|
const exit = `$__inl${uid}`
|
|
1835
3672
|
const rename = new Map()
|
|
@@ -1846,6 +3683,7 @@ const buildInline = (params, locals, inlResult, cBody, args) => {
|
|
|
1846
3683
|
}
|
|
1847
3684
|
for (const n of cBody) collectLabels(n)
|
|
1848
3685
|
const sub = (n) => {
|
|
3686
|
+
if (n === 'return') return ['br', exit] // bare stack-style return — value already on stack
|
|
1849
3687
|
if (!Array.isArray(n)) return n
|
|
1850
3688
|
const op = n[0]
|
|
1851
3689
|
if ((op === 'local.get' || op === 'local.set' || op === 'local.tee') && typeof n[1] === 'string' && rename.has(n[1]))
|
|
@@ -2265,24 +4103,27 @@ const inlineOnce = (ast, { pin = EMPTY_SET } = {}) => {
|
|
|
2265
4103
|
const bodyStart = inlBodyStart, callsSelf = inlCallsSelf, unsafe = inlUnsafe, isBranch = inlIsBranch
|
|
2266
4104
|
const zeroFor = inlZeroFor, needsReset = inlNeedsReset
|
|
2267
4105
|
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
4106
|
+
// Count plain-call references across the WHOLE module ONCE (anonymous exported
|
|
4107
|
+
// funcs call helpers too); flag any non-call reference (return_call etc.).
|
|
4108
|
+
// Splicing one callee into its lone caller MOVES the callee's own calls — the
|
|
4109
|
+
// module-wide counts are unchanged except the dissolved call itself, so the
|
|
4110
|
+
// maps are maintained incrementally instead of re-walked per round.
|
|
4111
|
+
const funcs = ast.filter(n => Array.isArray(n) && n[0] === 'func')
|
|
4112
|
+
const funcByName = new Map()
|
|
4113
|
+
for (const n of funcs) if (typeof n[1] === 'string') funcByName.set(n[1], n)
|
|
4114
|
+
const callRefs = new Map(), otherRef = new Set()
|
|
4115
|
+
const countRefs = (n) => {
|
|
4116
|
+
if (!Array.isArray(n)) return
|
|
4117
|
+
const op = n[0]
|
|
4118
|
+
if (op === 'call' && typeof n[1] === 'string') callRefs.set(n[1], (callRefs.get(n[1]) || 0) + 1)
|
|
4119
|
+
else if (op === 'return_call' && typeof n[1] === 'string') otherRef.add(n[1])
|
|
4120
|
+
for (let i = 1; i < n.length; i++) countRefs(n[i])
|
|
4121
|
+
}
|
|
4122
|
+
countRefs(ast)
|
|
4123
|
+
const pinned = inlBuildPinned(ast)
|
|
4124
|
+
// a func may carry its own (export "name") — the signature scan below rejects those too
|
|
2272
4125
|
|
|
2273
|
-
|
|
2274
|
-
// call helpers too); flag any non-call reference (return_call etc.).
|
|
2275
|
-
const callRefs = new Map(), otherRef = new Set()
|
|
2276
|
-
const countRefs = (n) => {
|
|
2277
|
-
if (!Array.isArray(n)) return
|
|
2278
|
-
const op = n[0]
|
|
2279
|
-
if (op === 'call' && typeof n[1] === 'string') callRefs.set(n[1], (callRefs.get(n[1]) || 0) + 1)
|
|
2280
|
-
else if (op === 'return_call' && typeof n[1] === 'string') otherRef.add(n[1])
|
|
2281
|
-
for (let i = 1; i < n.length; i++) countRefs(n[i])
|
|
2282
|
-
}
|
|
2283
|
-
countRefs(ast)
|
|
2284
|
-
const pinned = inlBuildPinned(ast)
|
|
2285
|
-
// a func may carry its own (export "name") — the signature scan below rejects those too
|
|
4126
|
+
for (let round = 0; round < MAX_INLINE_ROUNDS; round++) {
|
|
2286
4127
|
|
|
2287
4128
|
// Pick a callee.
|
|
2288
4129
|
let calleeName = null
|
|
@@ -2332,6 +4173,7 @@ const inlineOnce = (ast, { pin = EMPTY_SET } = {}) => {
|
|
|
2332
4173
|
}
|
|
2333
4174
|
const cBody = callee.slice(bodyStart(callee))
|
|
2334
4175
|
|
|
4176
|
+
inflations++
|
|
2335
4177
|
const uid = ++inlineUid
|
|
2336
4178
|
const exit = `$__inl${uid}`
|
|
2337
4179
|
const rename = new Map()
|
|
@@ -2348,6 +4190,10 @@ const inlineOnce = (ast, { pin = EMPTY_SET } = {}) => {
|
|
|
2348
4190
|
}
|
|
2349
4191
|
for (const n of cBody) collectLabels(n)
|
|
2350
4192
|
const sub = (n) => {
|
|
4193
|
+
// bare stack-style `return` (flat/wax form) copied verbatim would become a
|
|
4194
|
+
// hard FUNCTION return inside the caller, truncating everything after the
|
|
4195
|
+
// splice — same rewrite as the folded form: exit the inline block instead
|
|
4196
|
+
if (n === 'return') return ['br', exit]
|
|
2351
4197
|
if (!Array.isArray(n)) return n
|
|
2352
4198
|
const op = n[0]
|
|
2353
4199
|
if ((op === 'local.get' || op === 'local.set' || op === 'local.tee') && typeof n[1] === 'string' && rename.has(n[1]))
|
|
@@ -2397,6 +4243,10 @@ const inlineOnce = (ast, { pin = EMPTY_SET } = {}) => {
|
|
|
2397
4243
|
|
|
2398
4244
|
const idx = ast.indexOf(callee)
|
|
2399
4245
|
if (idx >= 0) ast.splice(idx, 1)
|
|
4246
|
+
funcByName.delete(calleeName)
|
|
4247
|
+
callRefs.delete(calleeName)
|
|
4248
|
+
const fi = funcs.indexOf(callee)
|
|
4249
|
+
if (fi >= 0) funcs.splice(fi, 1)
|
|
2400
4250
|
}
|
|
2401
4251
|
|
|
2402
4252
|
return ast
|
|
@@ -2572,21 +4422,48 @@ const coalesceLocals = (ast) => {
|
|
|
2572
4422
|
walk(ast, (funcNode) => {
|
|
2573
4423
|
if (!Array.isArray(funcNode) || funcNode[0] !== 'func') return
|
|
2574
4424
|
|
|
2575
|
-
const decls = new Map()
|
|
4425
|
+
const decls = new Map(), params = new Map()
|
|
2576
4426
|
for (const sub of funcNode) {
|
|
2577
|
-
if (Array.isArray(sub)
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
}
|
|
4427
|
+
if (!Array.isArray(sub) || typeof sub[1] !== 'string' || sub[1][0] !== '$' || typeof sub[2] !== 'string') continue
|
|
4428
|
+
if (sub[0] === 'local') decls.set(sub[1], sub[2])
|
|
4429
|
+
else if (sub[0] === 'param') params.set(sub[1], sub[2])
|
|
2581
4430
|
}
|
|
2582
|
-
if (decls.size < 2) return
|
|
4431
|
+
if (!decls.size || decls.size + params.size < 2) return
|
|
2583
4432
|
|
|
2584
4433
|
const uses = new Map()
|
|
2585
|
-
const loopStack = []
|
|
2586
|
-
let pos = 0, abort = false
|
|
4434
|
+
const loopStack = [], condStack = []
|
|
4435
|
+
let pos = 0, abort = false
|
|
4436
|
+
// effective innermost arm for a local: frames where BOTH sibling arms write it
|
|
4437
|
+
// at statement level are transparent (the write happens on every path)
|
|
4438
|
+
const effArm = (name) => {
|
|
4439
|
+
let k = condStack.length - 1
|
|
4440
|
+
while (k >= 0 && condStack[k].bothW && condStack[k].bothW.has(name)) k--
|
|
4441
|
+
return k >= 0 ? condStack[k] : null
|
|
4442
|
+
}
|
|
4443
|
+
// statement-level writes of a branch-free arm; null when the arm can exit early
|
|
4444
|
+
const armSets = (arm) => {
|
|
4445
|
+
let ok = true
|
|
4446
|
+
walk(arm, x => {
|
|
4447
|
+
const o = Array.isArray(x) ? x[0] : x
|
|
4448
|
+
if (o === 'br' || o === 'br_if' || o === 'br_table' || o === 'return' || o === 'unreachable' ||
|
|
4449
|
+
o === 'throw' || o === 'return_call' || o === 'return_call_indirect') ok = false
|
|
4450
|
+
})
|
|
4451
|
+
if (!ok) return null
|
|
4452
|
+
const set = new Set()
|
|
4453
|
+
for (let k = 1; k < arm.length; k++) {
|
|
4454
|
+
const st = arm[k]
|
|
4455
|
+
if (Array.isArray(st) && (st[0] === 'local.set' || st[0] === 'local.tee') && typeof st[1] === 'string') set.add(st[1])
|
|
4456
|
+
}
|
|
4457
|
+
return set
|
|
4458
|
+
}
|
|
2587
4459
|
|
|
2588
4460
|
const visit = (n) => {
|
|
2589
|
-
if (abort
|
|
4461
|
+
if (abort) return
|
|
4462
|
+
// flat-form control tokens make loop/arm boundaries invisible to the interval
|
|
4463
|
+
// model — a joined slot could leak residue across an unseen back-edge
|
|
4464
|
+
if (typeof n === 'string' && (n === 'loop' || n === 'block' || n === 'if' || n === 'else' || n === 'end' ||
|
|
4465
|
+
n === 'br' || n === 'br_if' || n === 'br_table')) { abort = true; return }
|
|
4466
|
+
if (!Array.isArray(n)) return
|
|
2590
4467
|
const op = n[0]
|
|
2591
4468
|
const isLoop = op === 'loop'
|
|
2592
4469
|
if (isLoop) loopStack.push({ start: pos, end: pos })
|
|
@@ -2600,21 +4477,52 @@ const coalesceLocals = (ast) => {
|
|
|
2600
4477
|
// read-then-write of $x (firstOp = local.get).
|
|
2601
4478
|
if (isSet) for (let i = 2; i < n.length; i++) visit(n[i])
|
|
2602
4479
|
const here = pos++
|
|
2603
|
-
if (decls.has(name)) {
|
|
4480
|
+
if (decls.has(name) || params.has(name)) {
|
|
2604
4481
|
let u = uses.get(name)
|
|
2605
|
-
|
|
4482
|
+
// A first WRITE only licenses slot-joining when it executes on EVERY path that
|
|
4483
|
+
// reaches a later read — else the read must see the local's implicit ZERO, and a
|
|
4484
|
+
// joined slot would leak the previous occupant's residue. Three skippable
|
|
4485
|
+
// contexts break the guarantee: an if/else arm (condDepth), a LOOP body (a
|
|
4486
|
+
// zero-trip loop skips the write but a read after the loop still runs — the
|
|
4487
|
+
// mat4 `iters=0` miscompile), and any statement AFTER a br/br_if/return in the
|
|
4488
|
+
// same list (the rotated-loop entry guard `(block (br_if $out …) …)` shape).
|
|
4489
|
+
if (!u) {
|
|
4490
|
+
u = { start: here, end: here, firstOp: op,
|
|
4491
|
+
firstArm: effArm(name), armEscapes: false,
|
|
4492
|
+
firstLoop: loopStack[loopStack.length - 1] ?? null, escapes: false, loops: new Set() }
|
|
4493
|
+
uses.set(name, u)
|
|
4494
|
+
} else {
|
|
4495
|
+
// a use outside the first write's innermost loop makes zero-trip skips
|
|
4496
|
+
// observable; a use outside its EFFECTIVE arm can execute on a path that
|
|
4497
|
+
// never wrote — either way the slot must not be joined. (A local written
|
|
4498
|
+
// at statement level in BOTH branch-free arms of an if is written on
|
|
4499
|
+
// every path through it, so those arm frames are transparent for it.)
|
|
4500
|
+
if ((loopStack[loopStack.length - 1] ?? null) !== u.firstLoop) u.escapes = true
|
|
4501
|
+
if (effArm(name) !== u.firstArm) u.armEscapes = true
|
|
4502
|
+
}
|
|
2606
4503
|
if (here > u.end) u.end = here
|
|
2607
4504
|
for (const ls of loopStack) u.loops.add(ls)
|
|
2608
4505
|
}
|
|
2609
4506
|
} else {
|
|
2610
4507
|
pos++
|
|
2611
4508
|
const isIf = op === 'if'
|
|
4509
|
+
let bothW = null
|
|
4510
|
+
if (isIf) {
|
|
4511
|
+
const { thenBranch, elseBranch } = parseIf(n)
|
|
4512
|
+
if (thenBranch && elseBranch) {
|
|
4513
|
+
const a = armSets(thenBranch), b = armSets(elseBranch)
|
|
4514
|
+
if (a && b) { bothW = new Set(); for (const x of a) if (b.has(x)) bothW.add(x); if (!bothW.size) bothW = null }
|
|
4515
|
+
}
|
|
4516
|
+
}
|
|
4517
|
+
let branched = false // a direct-child br/br_if/return makes the REST of this list conditional
|
|
2612
4518
|
for (let i = 1; i < n.length; i++) {
|
|
2613
4519
|
const c = n[i]
|
|
2614
|
-
const
|
|
2615
|
-
|
|
4520
|
+
const isArm = isIf && Array.isArray(c) && (c[0] === 'then' || c[0] === 'else')
|
|
4521
|
+
const cond = isArm || branched
|
|
4522
|
+
if (cond) condStack.push({ bothW: isArm ? bothW : null })
|
|
2616
4523
|
visit(c)
|
|
2617
|
-
if (cond)
|
|
4524
|
+
if (cond) condStack.pop()
|
|
4525
|
+
if (Array.isArray(c) && (c[0] === 'br_if' || c[0] === 'br' || c[0] === 'br_table' || c[0] === 'return' || c[0] === 'return_call' || c[0] === 'return_call_indirect' || c[0] === 'unreachable')) branched = true
|
|
2618
4526
|
}
|
|
2619
4527
|
}
|
|
2620
4528
|
|
|
@@ -2623,24 +4531,35 @@ const coalesceLocals = (ast) => {
|
|
|
2623
4531
|
visit(funcNode)
|
|
2624
4532
|
if (abort) return
|
|
2625
4533
|
|
|
2626
|
-
// A use inside a loop must stay live for the whole loop — the next
|
|
2627
|
-
//
|
|
4534
|
+
// A use inside a loop must stay live for the whole loop — the next iteration
|
|
4535
|
+
// could read what this iteration wrote. EXCEPT a write-first, unconditional,
|
|
4536
|
+
// non-escaping local: every trip rewrites it before any read, so its lifetime
|
|
4537
|
+
// is per-iteration and its raw [write, lastUse] range is the true one.
|
|
2628
4538
|
for (const u of uses.values()) {
|
|
4539
|
+
if (u.firstOp !== 'local.get' && !u.escapes && (u.firstArm === null || !u.armEscapes) && u.firstLoop !== null) continue
|
|
2629
4540
|
for (const ls of u.loops) {
|
|
2630
4541
|
if (ls.start < u.start) u.start = ls.start
|
|
2631
4542
|
if (ls.end > u.end) u.end = ls.end
|
|
2632
4543
|
}
|
|
2633
4544
|
}
|
|
2634
4545
|
|
|
2635
|
-
const ordered = [...uses.entries()].sort((a, b) => a[1].start - b[1].start)
|
|
4546
|
+
const ordered = [...uses.entries()].filter(([n]) => decls.has(n)).sort((a, b) => a[1].start - b[1].start)
|
|
2636
4547
|
const rename = new Map()
|
|
2637
|
-
|
|
4548
|
+
// Params seed pre-colored slots: the argument value is live from entry to its last
|
|
4549
|
+
// use — after that the slot is free scratch for a same-typed local. Zero-reading
|
|
4550
|
+
// locals still never join (a param holds the caller's residue, not zero).
|
|
4551
|
+
const slots = [...params].map(([name, type]) => ({ primary: name, type, end: uses.get(name)?.end ?? -1 }))
|
|
2638
4552
|
for (const [name, range] of ordered) {
|
|
2639
|
-
// Read-first locals depend on the implicit zero
|
|
2640
|
-
//
|
|
2641
|
-
//
|
|
2642
|
-
//
|
|
2643
|
-
|
|
4553
|
+
// Read-first locals depend on the implicit zero — they may *start* a fresh
|
|
4554
|
+
// slot (the function's zero init) but never *join* one. A first WRITE joins
|
|
4555
|
+
// when it dominates every read: uses confined to the write's own if-arm run
|
|
4556
|
+
// only on the path that wrote (arm-contained), and uses confined to the
|
|
4557
|
+
// write's loop are rewritten every trip (loop-carried read-before-write
|
|
4558
|
+
// shows up as a firstOp get). A use escaping either region could observe a
|
|
4559
|
+
// skipped write — the previous occupant's residue — so it blocks joining.
|
|
4560
|
+
const readsZero = range.firstOp === 'local.get' ||
|
|
4561
|
+
(range.firstArm !== null && range.armEscapes) ||
|
|
4562
|
+
(range.firstLoop !== null && range.escapes)
|
|
2644
4563
|
const type = decls.get(name)
|
|
2645
4564
|
const slot = readsZero ? null : slots.find(s => s.type === type && s.end < range.start)
|
|
2646
4565
|
if (slot) { rename.set(name, slot.primary); if (range.end > slot.end) slot.end = range.end }
|
|
@@ -2685,10 +4604,12 @@ const vacuum = (ast) => {
|
|
|
2685
4604
|
return ['block', ...eff]
|
|
2686
4605
|
}
|
|
2687
4606
|
|
|
2688
|
-
// (select x x cond) → x — only when cond
|
|
2689
|
-
//
|
|
2690
|
-
//
|
|
2691
|
-
|
|
4607
|
+
// (select x x cond) → x — only when the arm AND cond are PURE. select evaluates BOTH
|
|
4608
|
+
// arms, so collapsing two identical IMPURE arms to one would drop a side effect (run it
|
|
4609
|
+
// once, not twice); and an impure cond may set a local a later op reads (an address
|
|
4610
|
+
// `local.tee` the matching store reuses) — dropping it leaves that local stale. Keep
|
|
4611
|
+
// the select unless everything discarded is pure.
|
|
4612
|
+
if (op === 'select' && node.length >= 4 && equal(node[1], node[2]) && isPure(node[1]) && isPure(node[3])) return node[1]
|
|
2692
4613
|
|
|
2693
4614
|
if (op === 'if') {
|
|
2694
4615
|
const { cond, thenBranch, elseBranch } = parseIf(node)
|
|
@@ -2702,6 +4623,15 @@ const vacuum = (ast) => {
|
|
|
2702
4623
|
if (elseBranch && elseEmpty && !thenEmpty) {
|
|
2703
4624
|
return node.filter(c => c !== elseBranch)
|
|
2704
4625
|
}
|
|
4626
|
+
|
|
4627
|
+
// (if cond (then) (else X)) → (if (i32.eqz cond) (then X)) — void ifs only
|
|
4628
|
+
// (a result-typed if needs both arms); the eqz byte costs less than the
|
|
4629
|
+
// else marker + arm framing it removes.
|
|
4630
|
+
if (thenEmpty && thenBranch && elseBranch && !elseEmpty && Array.isArray(cond) &&
|
|
4631
|
+
!node.some(c => Array.isArray(c) && c[0] === 'result')) {
|
|
4632
|
+
return node.filter(c => c !== thenBranch && c !== elseBranch && c !== cond)
|
|
4633
|
+
.concat([['i32.eqz', cond], ['then', ...elseBranch.slice(1)]])
|
|
4634
|
+
}
|
|
2705
4635
|
}
|
|
2706
4636
|
|
|
2707
4637
|
// Clean out nops, drop-of-pure sequences, and empty annotations from blocks
|
|
@@ -2742,6 +4672,9 @@ const vacuum = (ast) => {
|
|
|
2742
4672
|
* still yields the same value AND runs the operand). */
|
|
2743
4673
|
const selfFold = (val) => (a, b) => equal(a, b) && isPure(a) ? val : null
|
|
2744
4674
|
const PEEPHOLE = {
|
|
4675
|
+
// (local.tee $x (local.get $x)) re-stores the exact value already held — for any
|
|
4676
|
+
// bit pattern — so it is the bare get.
|
|
4677
|
+
'local.tee': (a, b) => Array.isArray(b) && b[0] === 'local.get' && b[1] === a ? b : null,
|
|
2745
4678
|
// Self-cancelling / tautological binary ops — drop both (equal) operands.
|
|
2746
4679
|
'i32.sub': selfFold(['i32.const', 0]),
|
|
2747
4680
|
'i64.sub': selfFold(['i64.const', 0]),
|
|
@@ -2813,13 +4746,35 @@ const PEEPHOLE = {
|
|
|
2813
4746
|
* @param {Array} ast
|
|
2814
4747
|
* @returns {Array}
|
|
2815
4748
|
*/
|
|
2816
|
-
const
|
|
2817
|
-
return walkPost(ast, (node) => {
|
|
4749
|
+
const peepholeNode = (node) => {
|
|
2818
4750
|
if (!Array.isArray(node) || node.length !== 3) return
|
|
2819
4751
|
const fn = PEEPHOLE[node[0]]
|
|
2820
4752
|
if (!fn) return
|
|
2821
4753
|
const result = fn(node[1], node[2])
|
|
2822
4754
|
if (result !== null) return result
|
|
4755
|
+
}
|
|
4756
|
+
/** Peephole rules as a standalone pass. */
|
|
4757
|
+
const peephole = (ast) => walkPost(ast, peepholeNode)
|
|
4758
|
+
|
|
4759
|
+
/**
|
|
4760
|
+
* Fused algebraic sweep — fold → identity → strength → peephole applied per node in
|
|
4761
|
+
* ONE bottom-up traversal instead of four, re-running the family on a node until it
|
|
4762
|
+
* stabilizes (children are final when the parent is visited, so cross-rule cascades
|
|
4763
|
+
* converge in-walk instead of across driver rounds). The rule set follows the same
|
|
4764
|
+
* option keys as the standalone passes.
|
|
4765
|
+
*/
|
|
4766
|
+
const SIMPLIFY = [['fold', foldNode], ['identity', identityNode], ['strength', strengthNode], ['peephole', peepholeNode]]
|
|
4767
|
+
const SIMPLIFY_KEYS = new Set(SIMPLIFY.map(([k]) => k))
|
|
4768
|
+
const simplify = (ast, opts) => {
|
|
4769
|
+
const rules = SIMPLIFY.filter(([k]) => opts[k]).map(([, f]) => f)
|
|
4770
|
+
if (!rules.length) return ast
|
|
4771
|
+
return walkPost(ast, (node) => {
|
|
4772
|
+
let out
|
|
4773
|
+
for (let i = 0, spins = 0; i < rules.length; i++) {
|
|
4774
|
+
const r = rules[i](out === undefined ? node : out)
|
|
4775
|
+
if (r !== undefined && spins++ < 10) { out = r, i = -1 } // a hit re-runs the family
|
|
4776
|
+
}
|
|
4777
|
+
return out
|
|
2823
4778
|
})
|
|
2824
4779
|
}
|
|
2825
4780
|
|
|
@@ -2827,8 +4782,9 @@ const peephole = (ast) => {
|
|
|
2827
4782
|
|
|
2828
4783
|
/** Bytes a signed-LEB128 integer encodes to. */
|
|
2829
4784
|
const slebSize = (v) => {
|
|
4785
|
+
// BigInt() rejects signed hex ('-0x1', '+0x2') — strip the sign and reapply.
|
|
2830
4786
|
let x = typeof v === 'bigint' ? v
|
|
2831
|
-
: typeof v === 'string' ?
|
|
4787
|
+
: typeof v === 'string' ? (v = v.replaceAll('_', ''), v[0] === '-' ? -BigInt(v.slice(1)) : BigInt(v[0] === '+' ? v.slice(1) : v))
|
|
2832
4788
|
: BigInt(Math.trunc(Number(v) || 0))
|
|
2833
4789
|
// Signed view of raw bits — exact natively; in-kernel the arm is dead
|
|
2834
4790
|
// (BigInt('0x…') already arrives as the signed i64 carrier there).
|
|
@@ -2900,6 +4856,49 @@ const globals = (ast) => {
|
|
|
2900
4856
|
else if (n[0] === 'global.get') reads.set(ref, (reads.get(ref) || 0) + 1)
|
|
2901
4857
|
})
|
|
2902
4858
|
|
|
4859
|
+
// Local encoded-size estimate for a small cloned expression subtree — consts and
|
|
4860
|
+
// global.get price at their real encoded size; every other op is 1 opcode byte +
|
|
4861
|
+
// its operands. Approximate (ignores multi-byte prefixed opcodes and memarg
|
|
4862
|
+
// immediates) but only ever used to COMPARE before/after shapes of the same
|
|
4863
|
+
// subtree, so the approximation errors cancel.
|
|
4864
|
+
const estSize = (node) => {
|
|
4865
|
+
if (!Array.isArray(node)) return 0
|
|
4866
|
+
if (node[0] === 'i32.const' || node[0] === 'i64.const' || node[0] === 'f32.const' || node[0] === 'f64.const') return constInstrSize(node)
|
|
4867
|
+
if (node[0] === 'global.get') return GLOBAL_GET_SIZE
|
|
4868
|
+
let n = 1
|
|
4869
|
+
for (let i = 1; i < node.length; i++) n += estSize(node[i])
|
|
4870
|
+
return n
|
|
4871
|
+
}
|
|
4872
|
+
// Ancestor-aware read pricing: a global.get nested under const-chain ops may fold
|
|
4873
|
+
// away almost entirely once literalized (chain reassociation collapses the ops).
|
|
4874
|
+
// Pricing every read at a flat const size misses this — clone each read's small
|
|
4875
|
+
// enclosing subtree, splice in the literal, fold it locally, and measure the
|
|
4876
|
+
// REAL shape instead of guessing. One walk collects every global's sites.
|
|
4877
|
+
// BOUNDED: a widely-read global (a kernel NaN-box constant with thousands of
|
|
4878
|
+
// reads) or a huge anchor prices flat — unbounded per-read clones of a multi-MB
|
|
4879
|
+
// module held at once ran a self-host build out of memory.
|
|
4880
|
+
const ANCESTOR_DEPTH = 2, MAX_PRICED_READS = 64, MAX_ANCHOR_NODES = 256
|
|
4881
|
+
const gsites = new Map()
|
|
4882
|
+
{
|
|
4883
|
+
const stack = []
|
|
4884
|
+
const visit = (node) => {
|
|
4885
|
+
if (!Array.isArray(node)) return
|
|
4886
|
+
if (node[0] === 'global.get' && typeof node[1] === 'string' && constGlobals.has(node[1])) {
|
|
4887
|
+
if ((reads.get(node[1]) || 0) <= MAX_PRICED_READS) {
|
|
4888
|
+
const anchor = stack.length >= ANCESTOR_DEPTH ? stack[stack.length - ANCESTOR_DEPTH] : stack[0]
|
|
4889
|
+
const site = anchor ?? node
|
|
4890
|
+
let a = gsites.get(node[1])
|
|
4891
|
+
if (!a) gsites.set(node[1], a = [])
|
|
4892
|
+
a.push(count(site) <= MAX_ANCHOR_NODES ? clone(site) : ['global.get', node[1]])
|
|
4893
|
+
}
|
|
4894
|
+
return
|
|
4895
|
+
}
|
|
4896
|
+
stack.push(node)
|
|
4897
|
+
for (let i = 0; i < node.length; i++) visit(node[i])
|
|
4898
|
+
stack.pop()
|
|
4899
|
+
}
|
|
4900
|
+
visit(ast)
|
|
4901
|
+
}
|
|
2903
4902
|
// Keep only globals where propagation is size-neutral or better.
|
|
2904
4903
|
const propagate = new Set()
|
|
2905
4904
|
for (const [name, init] of constGlobals) {
|
|
@@ -2907,8 +4906,21 @@ const globals = (ast) => {
|
|
|
2907
4906
|
if (r === 0) continue // dead anyway — leave to treeshake
|
|
2908
4907
|
const cs = constInstrSize(init)
|
|
2909
4908
|
const declSize = cs + 2 // valtype + mutability byte + init expr + `end`
|
|
2910
|
-
const
|
|
2911
|
-
|
|
4909
|
+
const sites = gsites.get(name)
|
|
4910
|
+
let before, after
|
|
4911
|
+
if (sites && sites.length === r) {
|
|
4912
|
+
before = declSize; after = exported.has(name) ? declSize : 0
|
|
4913
|
+
for (const site of sites) {
|
|
4914
|
+
before += estSize(site)
|
|
4915
|
+
const spliced = walkPost(clone(site), (n) => {
|
|
4916
|
+
if (Array.isArray(n) && n[0] === 'global.get' && n[1] === name) return clone(init)
|
|
4917
|
+
})
|
|
4918
|
+
after += estSize(fold(spliced))
|
|
4919
|
+
}
|
|
4920
|
+
} else { // beyond the pricing bounds — flat model
|
|
4921
|
+
before = r * GLOBAL_GET_SIZE + declSize
|
|
4922
|
+
after = r * cs + (exported.has(name) ? declSize : 0)
|
|
4923
|
+
}
|
|
2912
4924
|
if (after <= before) propagate.add(name)
|
|
2913
4925
|
}
|
|
2914
4926
|
if (propagate.size === 0) return ast
|
|
@@ -3001,7 +5013,41 @@ const offset = (ast) => {
|
|
|
3001
5013
|
* @param {Array} ast
|
|
3002
5014
|
* @returns {Array}
|
|
3003
5015
|
*/
|
|
5016
|
+
// Func names whose signature has no result — their calls are stack-neutral
|
|
5017
|
+
// statements. Named only: declaration order diverges from binary index under
|
|
5018
|
+
// inline imports, so numeric call refs are never trusted.
|
|
5019
|
+
const collectVoidFuncs = (ast) => {
|
|
5020
|
+
const voidFuncs = new Set()
|
|
5021
|
+
if (Array.isArray(ast) && ast[0] === 'module') {
|
|
5022
|
+
for (const n of ast.slice(1)) {
|
|
5023
|
+
if (!Array.isArray(n)) continue
|
|
5024
|
+
const fn = n[0] === 'func' ? n : n[0] === 'import' ? n.find(s => Array.isArray(s) && s[0] === 'func') : null
|
|
5025
|
+
if (!fn) continue
|
|
5026
|
+
if (typeof fn[1] === 'string' && fn[1][0] === '$' &&
|
|
5027
|
+
!fn.some(c => Array.isArray(c) && (c[0] === 'result' || c[0] === 'type'))) voidFuncs.add(fn[1])
|
|
5028
|
+
}
|
|
5029
|
+
}
|
|
5030
|
+
return voidFuncs
|
|
5031
|
+
}
|
|
5032
|
+
|
|
3004
5033
|
const unbranch = (ast) => {
|
|
5034
|
+
const voidFuncs = collectVoidFuncs(ast)
|
|
5035
|
+
// A statement that provably leaves nothing on the stack. `return` mid-body is fine
|
|
5036
|
+
// (dead tail); anything unrecognized — including bare tokens — bails the transform.
|
|
5037
|
+
const stackNeutral = (n) => {
|
|
5038
|
+
if (n === 'nop' || n === 'unreachable' || n === 'return') return true
|
|
5039
|
+
if (!Array.isArray(n)) return false
|
|
5040
|
+
const op = n[0]
|
|
5041
|
+
if (op === 'param' || op === 'result' || op === 'local' || op === 'type' || op === 'export') return true
|
|
5042
|
+
if (op === 'local.set' || op === 'global.set' || op === 'drop' || op === 'nop' || op === 'return' ||
|
|
5043
|
+
op === 'br' || op === 'unreachable' || op === 'memory.copy' || op === 'memory.fill' ||
|
|
5044
|
+
op === 'store' || op?.includes?.('.store')) return true
|
|
5045
|
+
if (op === 'br_if') return n.length <= 3 // (br_if l cond) — no extra value operand
|
|
5046
|
+
if (op === 'block' || op === 'loop' || op === 'if')
|
|
5047
|
+
return !n.some(c => Array.isArray(c) && c[0] === 'result')
|
|
5048
|
+
if (op === 'call') return voidFuncs.has(n[1])
|
|
5049
|
+
return false
|
|
5050
|
+
}
|
|
3005
5051
|
walk(ast, (node) => {
|
|
3006
5052
|
if (!Array.isArray(node)) return
|
|
3007
5053
|
const op = node[0]
|
|
@@ -3161,8 +5207,46 @@ const stripmut = (ast) => {
|
|
|
3161
5207
|
* @param {Array} ast
|
|
3162
5208
|
* @returns {Array}
|
|
3163
5209
|
*/
|
|
5210
|
+
// Dissolving an `if` removes one level of branch-target nesting (the if's own
|
|
5211
|
+
// implicit label). A $name label re-resolves relative to its new depth, but a raw
|
|
5212
|
+
// numeric (depth-relative) label goes stale and must drop by 1. Label 0 targets the
|
|
5213
|
+
// if's own end — no equivalent exists one level up, so that shape is not rewritten.
|
|
5214
|
+
const unnest = (l) => typeof l === 'string' && l[0] === '$' ? l : +l > 0 ? +l - 1 : null
|
|
5215
|
+
|
|
5216
|
+
/** Ops that can trap even when 'pure': int div/rem, float→int trunc. */
|
|
5217
|
+
const hasTrap = (n) => {
|
|
5218
|
+
let t = false
|
|
5219
|
+
walk(n, c => { const o = Array.isArray(c) ? c[0] : c; if (typeof o === 'string' && /\.(div|rem)_[su]$|\.trunc_f/.test(o)) t = true })
|
|
5220
|
+
return t
|
|
5221
|
+
}
|
|
5222
|
+
|
|
5223
|
+
// In TEST position (if/br_if/select condition) only non-zero-ness matters, so a
|
|
5224
|
+
// double eqz is a no-op there: (i32.eqz (i32.eqz X)) → X. (In value contexts it
|
|
5225
|
+
// normalizes to 0/1 and must stay.)
|
|
5226
|
+
const untest = (c) => Array.isArray(c) && c[0] === 'i32.eqz' && Array.isArray(c[1]) && c[1][0] === 'i32.eqz' ? untest(c[1][1]) : c
|
|
5227
|
+
|
|
3164
5228
|
const brif = (ast) => {
|
|
3165
5229
|
return walkPost(ast, (node) => {
|
|
5230
|
+
// (br_if $L A) (br_if $L B) → (br_if $L (i32.or A B)) — one branch instruction
|
|
5231
|
+
// instead of two. B now evaluates even when A already branches, so it must be
|
|
5232
|
+
// pure and trap-free; evaluation order A-then-B is preserved.
|
|
5233
|
+
if (Array.isArray(node) && (isScopeNode(node) || node[0] === 'if')) {
|
|
5234
|
+
for (let i = 1; i < node.length - 1; i++) {
|
|
5235
|
+
const a = node[i], b = node[i + 1]
|
|
5236
|
+
if (Array.isArray(a) && a[0] === 'br_if' && a.length === 3 && Array.isArray(a[2]) &&
|
|
5237
|
+
Array.isArray(b) && b[0] === 'br_if' && b.length === 3 && b[1] === a[1] &&
|
|
5238
|
+
Array.isArray(b[2]) && isPure(b[2]) && !hasTrap(b[2])) {
|
|
5239
|
+
node.splice(i, 2, ['br_if', a[1], ['i32.or', a[2], b[2]]])
|
|
5240
|
+
i--
|
|
5241
|
+
}
|
|
5242
|
+
}
|
|
5243
|
+
}
|
|
5244
|
+
// double-eqz vanishes in test position
|
|
5245
|
+
if (Array.isArray(node)) {
|
|
5246
|
+
if (node[0] === 'br_if' && node.length === 3) { const c = untest(node[2]); if (c !== node[2]) node[2] = c }
|
|
5247
|
+
else if (node[0] === 'if') { const { condIdx, cond } = parseIf(node); const c = untest(cond); if (c !== cond) node[condIdx] = c }
|
|
5248
|
+
else if (node[0] === 'select' && node.length === 4) { const c = untest(node[3]); if (c !== node[3]) node[3] = c }
|
|
5249
|
+
}
|
|
3166
5250
|
if (!Array.isArray(node) || node[0] !== 'if') return
|
|
3167
5251
|
const { cond, thenBranch, elseBranch } = parseIf(node)
|
|
3168
5252
|
const thenEmpty = !thenBranch || thenBranch.length <= 1
|
|
@@ -3170,14 +5254,14 @@ const brif = (ast) => {
|
|
|
3170
5254
|
|
|
3171
5255
|
// (if cond (then (br $l))) → (br_if $l cond)
|
|
3172
5256
|
if (!thenEmpty && elseEmpty && thenBranch.length === 2) {
|
|
3173
|
-
const t = thenBranch[1]
|
|
3174
|
-
if (
|
|
5257
|
+
const t = thenBranch[1], l = Array.isArray(t) && t[0] === 'br' && t.length === 2 && unnest(t[1])
|
|
5258
|
+
if (l != null && l !== false) return ['br_if', l, cond]
|
|
3175
5259
|
}
|
|
3176
5260
|
|
|
3177
5261
|
// (if cond (then) (else (br $l))) → (br_if $l (i32.eqz cond))
|
|
3178
5262
|
if (thenEmpty && !elseEmpty && elseBranch.length === 2) {
|
|
3179
|
-
const e = elseBranch[1]
|
|
3180
|
-
if (
|
|
5263
|
+
const e = elseBranch[1], l = Array.isArray(e) && e[0] === 'br' && e.length === 2 && unnest(e[1])
|
|
5264
|
+
if (l != null && l !== false) return ['br_if', l, ['i32.eqz', cond]]
|
|
3181
5265
|
}
|
|
3182
5266
|
})
|
|
3183
5267
|
}
|
|
@@ -3253,11 +5337,14 @@ const hashFunc = (node, localNames) => {
|
|
|
3253
5337
|
while (stack.length) {
|
|
3254
5338
|
const v = stack.pop()
|
|
3255
5339
|
if (Array.isArray(v)) {
|
|
5340
|
+
if (v[0] === 'export') continue // export names are identity, not body
|
|
3256
5341
|
stack.push('|')
|
|
3257
5342
|
for (let i = v.length - 1; i >= 0; i--) stack.push(v[i])
|
|
3258
5343
|
stack.push('[')
|
|
3259
5344
|
} else if (typeof v === 'string') {
|
|
3260
|
-
|
|
5345
|
+
const t = localNames.get ? localNames.get(v) : localNames.has(v) ? '$__L' : undefined
|
|
5346
|
+
// a bare-numeric ref right after a local op is the same slot as its named twin
|
|
5347
|
+
parts.push(t ?? ((parts[parts.length - 1] === 'local.get' || parts[parts.length - 1] === 'local.set' || parts[parts.length - 1] === 'local.tee') && v !== '' && !isNaN(v) ? 'L' + +v : v))
|
|
3261
5348
|
} else if (typeof v === 'bigint') {
|
|
3262
5349
|
parts.push(v.toString() + 'n')
|
|
3263
5350
|
} else if (typeof v === 'number') {
|
|
@@ -3293,21 +5380,29 @@ const dedupe = (ast) => {
|
|
|
3293
5380
|
const name = typeof node[1] === 'string' && node[1][0] === '$' ? node[1] : null
|
|
3294
5381
|
if (!name) continue
|
|
3295
5382
|
|
|
3296
|
-
//
|
|
3297
|
-
//
|
|
3298
|
-
//
|
|
3299
|
-
//
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
5383
|
+
// Canonical positional identity: params/locals map to their INDEX ('L0', 'L1'…
|
|
5384
|
+
// — bare-numeric refs land on the same tokens), labels to first-occurrence
|
|
5385
|
+
// order ('B0'…), the func's own name to 'F'. Positional tokens make the hash a
|
|
5386
|
+
// faithful canonical form: same string ⇔ structurally equivalent modulo naming
|
|
5387
|
+
// (the old single-token collapse hashed (sub $a $b) equal to (sub $b $a)).
|
|
5388
|
+
// Declarations leave the body hash and return as a positional type vector, so
|
|
5389
|
+
// a named-decl clone matches its unnamed twin.
|
|
5390
|
+
const canon = new Map([[name, 'F']])
|
|
5391
|
+
const types = []
|
|
5392
|
+
let li = 0, bi = 0
|
|
5393
|
+
const body = ['func']
|
|
5394
|
+
for (let i = 2; i < node.length; i++) {
|
|
5395
|
+
const c = node[i]
|
|
5396
|
+
if (Array.isArray(c) && (c[0] === 'param' || c[0] === 'local')) {
|
|
5397
|
+
if (typeof c[1] === 'string' && c[1][0] === '$') { canon.set(c[1], 'L' + li++); types.push(c[0] === 'param' ? 'p' + c[2] : c[2]) }
|
|
5398
|
+
else for (let k = 1; k < c.length; k++) { types.push(c[0] === 'param' ? 'p' + c[k] : c[k]); li++ }
|
|
3307
5399
|
}
|
|
5400
|
+
else body.push(c)
|
|
5401
|
+
}
|
|
5402
|
+
walk(node, (n) => {
|
|
5403
|
+
if (Array.isArray(n) && isBranchScope(n[0]) && typeof n[1] === 'string' && n[1][0] === '$' && !canon.has(n[1])) canon.set(n[1], 'B' + bi++)
|
|
3308
5404
|
})
|
|
3309
|
-
|
|
3310
|
-
const hash = hashFunc(node, localNames)
|
|
5405
|
+
const hash = types.join(' ') + '#' + hashFunc(body, canon)
|
|
3311
5406
|
|
|
3312
5407
|
if (signatures.has(hash)) {
|
|
3313
5408
|
redirects.set(name, signatures.get(hash))
|
|
@@ -3318,6 +5413,20 @@ const dedupe = (ast) => {
|
|
|
3318
5413
|
|
|
3319
5414
|
if (redirects.size === 0) return ast
|
|
3320
5415
|
|
|
5416
|
+
// A duplicate's inline exports move to standalone exports of the canonical —
|
|
5417
|
+
// the husk loses its pin and treeshake collects it next sweep.
|
|
5418
|
+
for (const node of ast.slice(1)) {
|
|
5419
|
+
if (!Array.isArray(node) || node[0] !== 'func') continue
|
|
5420
|
+
const name = typeof node[1] === 'string' && node[1][0] === '$' ? node[1] : null
|
|
5421
|
+
if (!name || !redirects.has(name)) continue
|
|
5422
|
+
for (let i = node.length - 1; i >= 2; i--) {
|
|
5423
|
+
if (Array.isArray(node[i]) && node[i][0] === 'export') {
|
|
5424
|
+
ast.push(['export', node[i][1], ['func', redirects.get(name)]])
|
|
5425
|
+
node.splice(i, 1)
|
|
5426
|
+
}
|
|
5427
|
+
}
|
|
5428
|
+
}
|
|
5429
|
+
|
|
3321
5430
|
// Rewrite all references: calls, ref.func, elem segments, call_indirect type
|
|
3322
5431
|
walkPost(ast, (node) => {
|
|
3323
5432
|
if (!Array.isArray(node)) return
|
|
@@ -3329,10 +5438,11 @@ const dedupe = (ast) => {
|
|
|
3329
5438
|
return ['ref.func', redirects.get(node[1])]
|
|
3330
5439
|
}
|
|
3331
5440
|
if (op === 'elem') {
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
5441
|
+
return node.map(p => typeof p === 'string' && redirects.has(p) ? redirects.get(p) : p)
|
|
5442
|
+
}
|
|
5443
|
+
if (op === 'start' && redirects.has(node[1])) return ['start', redirects.get(node[1])]
|
|
5444
|
+
if (op === 'export' && Array.isArray(node[2]) && node[2][0] === 'func' && redirects.has(node[2][1])) {
|
|
5445
|
+
return ['export', node[1], ['func', redirects.get(node[2][1])]]
|
|
3336
5446
|
}
|
|
3337
5447
|
if (op === 'call_indirect' && node.length >= 3) {
|
|
3338
5448
|
const typeRef = node[1]
|
|
@@ -3484,12 +5594,13 @@ const trimTrailingZeros = (items) => {
|
|
|
3484
5594
|
const bytes = []
|
|
3485
5595
|
for (const item of items) {
|
|
3486
5596
|
if (typeof item === 'string') {
|
|
5597
|
+
if (item[0] !== '"') continue // comment token — contributes no bytes
|
|
3487
5598
|
const chunk = parseDataString(item)
|
|
3488
5599
|
for (let i = 0; i < chunk.length; i++) bytes.push(chunk[i])
|
|
3489
|
-
} else if (Array.isArray(item) && item[0] === 'i8') {
|
|
3490
|
-
for (let i = 1; i < item.length; i++) bytes.push(Number(item[i]) & 0xff)
|
|
3491
5600
|
} else {
|
|
3492
|
-
|
|
5601
|
+
const chunk = numdata(item) // i8/i16/i32/i64/f32/f64 lanes — compile's own encoder
|
|
5602
|
+
if (!chunk) return items // non-trimmable item
|
|
5603
|
+
for (let i = 0; i < chunk.length; i++) bytes.push(chunk[i])
|
|
3493
5604
|
}
|
|
3494
5605
|
}
|
|
3495
5606
|
let end = bytes.length
|
|
@@ -3528,13 +5639,50 @@ const getDataLength = (node) => {
|
|
|
3528
5639
|
let len = 0
|
|
3529
5640
|
for (let i = idx; i < node.length; i++) {
|
|
3530
5641
|
const item = node[i]
|
|
3531
|
-
if (typeof item === 'string') len += parseDataString(item).length
|
|
3532
|
-
else
|
|
3533
|
-
else return null
|
|
5642
|
+
if (typeof item === 'string') { if (item[0] === '"') len += parseDataString(item).length }
|
|
5643
|
+
else { const chunk = numdata(item); if (!chunk) return null; len += chunk.length }
|
|
3534
5644
|
}
|
|
3535
5645
|
return len
|
|
3536
5646
|
}
|
|
3537
5647
|
|
|
5648
|
+
// Zero-assumption fences shared by the trailing-zero trim and the zero-run split:
|
|
5649
|
+
// both rewrite segments on the premise that unwritten memory IS zero. That premise
|
|
5650
|
+
// needs (each refuted by a live counterexample when absent): a single plain
|
|
5651
|
+
// module-DEFINED memory (imported/shared memory is not guaranteed zero), every
|
|
5652
|
+
// segment active with const offset and known length, and per segment — fully
|
|
5653
|
+
// inside the INITIAL memory (a shrunk segment must not un-trap an out-of-bounds
|
|
5654
|
+
// write) and disjoint from every other segment (a later zero run may deliberately
|
|
5655
|
+
// overwrite an earlier segment's bytes). Returns null when the module as a whole
|
|
5656
|
+
// fails the shared preconditions.
|
|
5657
|
+
const zeroFence = (ast) => {
|
|
5658
|
+
let mem = null
|
|
5659
|
+
for (const node of ast) {
|
|
5660
|
+
if (!Array.isArray(node)) continue
|
|
5661
|
+
if (node[0] === 'memory') {
|
|
5662
|
+
if (mem) return null
|
|
5663
|
+
const rest = node.filter((c, i) => i > 0 && !(typeof c === 'string' && c[0] === '$') && !(Array.isArray(c) && c[0] === 'export'))
|
|
5664
|
+
const plain = rest.length >= 1 && rest.length <= 2 && rest.every(v => !Array.isArray(v) && !isNaN(Number(v))) &&
|
|
5665
|
+
!node.some(c => Array.isArray(c) && c[0] === 'import')
|
|
5666
|
+
if (!plain) return null
|
|
5667
|
+
mem = { min: Number(rest[0]) }
|
|
5668
|
+
} else if (node[0] === 'import' && node.some(c => Array.isArray(c) && c[0] === 'memory')) return null
|
|
5669
|
+
}
|
|
5670
|
+
if (!mem) return null
|
|
5671
|
+
const segs = []
|
|
5672
|
+
for (const node of ast) {
|
|
5673
|
+
if (!Array.isArray(node) || node[0] !== 'data') continue
|
|
5674
|
+
const info = getDataOffset(node), len = getDataLength(node)
|
|
5675
|
+
if (!info || len === null) return null
|
|
5676
|
+
segs.push({ node, offset: info.offset, len })
|
|
5677
|
+
}
|
|
5678
|
+
return { min: mem.min, segs }
|
|
5679
|
+
}
|
|
5680
|
+
const segSafe = (fence, node) => {
|
|
5681
|
+
const seg = fence.segs.find(o => o.node === node)
|
|
5682
|
+
return seg && seg.offset + seg.len <= fence.min * 65536 &&
|
|
5683
|
+
!fence.segs.some(o => o.node !== node && o.offset < seg.offset + seg.len && seg.offset < o.offset + o.len)
|
|
5684
|
+
}
|
|
5685
|
+
|
|
3538
5686
|
/** Merge segment b into a (consecutive offsets, same memory) */
|
|
3539
5687
|
const mergeDataSegments = (a, b) => {
|
|
3540
5688
|
let aIdx = 1
|
|
@@ -3573,21 +5721,33 @@ const mergeDataSegments = (a, b) => {
|
|
|
3573
5721
|
const packData = (ast) => {
|
|
3574
5722
|
if (!Array.isArray(ast) || ast[0] !== 'module') return ast
|
|
3575
5723
|
|
|
3576
|
-
// Trim trailing zeros
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
const
|
|
3588
|
-
|
|
3589
|
-
node
|
|
3590
|
-
|
|
5724
|
+
// Trim trailing zeros. Wasm zero-fills a module-DEFINED memory, so a segment's
|
|
5725
|
+
// trailing zero run restates the default — but ONLY under fences (each refuted
|
|
5726
|
+
// by a live counterexample when absent): the segment must be ACTIVE with a
|
|
5727
|
+
// constant offset (a passive segment's length is memory.init semantics); the
|
|
5728
|
+
// module's sole memory must be plain and module-defined (an imported/shared
|
|
5729
|
+
// memory is not guaranteed zero where the segment lands); the full segment must
|
|
5730
|
+
// fit the memory's INITIAL size (a shrunk segment must not un-trap an
|
|
5731
|
+
// out-of-bounds write); and no other active segment may touch this one's range
|
|
5732
|
+
// (a later zero run may deliberately overwrite an earlier segment's bytes).
|
|
5733
|
+
{
|
|
5734
|
+
const fence = zeroFence(ast)
|
|
5735
|
+
for (const seg of fence ? fence.segs : []) {
|
|
5736
|
+
if (!segSafe(fence, seg.node)) continue
|
|
5737
|
+
const node = seg.node
|
|
5738
|
+
let contentStart = 1
|
|
5739
|
+
if (typeof node[1] === 'string' && node[1][0] === '$') contentStart = 2
|
|
5740
|
+
if (contentStart < node.length && Array.isArray(node[contentStart]) &&
|
|
5741
|
+
typeof node[contentStart][0] === 'string' && !node[contentStart][0].startsWith('"')) {
|
|
5742
|
+
contentStart++
|
|
5743
|
+
}
|
|
5744
|
+
const content = node.slice(contentStart)
|
|
5745
|
+
if (content.length === 0) continue
|
|
5746
|
+
const trimmed = trimTrailingZeros(content)
|
|
5747
|
+
if (trimmed.length !== content.length || (trimmed.length > 0 && trimmed[0] !== content[0])) {
|
|
5748
|
+
node.length = contentStart
|
|
5749
|
+
node.push(...trimmed)
|
|
5750
|
+
}
|
|
3591
5751
|
}
|
|
3592
5752
|
}
|
|
3593
5753
|
|
|
@@ -3626,7 +5786,56 @@ const packData = (ast) => {
|
|
|
3626
5786
|
ast = ast.filter((_, i) => !toRemove.has(i))
|
|
3627
5787
|
}
|
|
3628
5788
|
|
|
3629
|
-
|
|
5789
|
+
// Split active segments at long interior zero runs and shift leading zeros into
|
|
5790
|
+
// the offset — unwritten memory is zero-initialized anyway. Sound only when no
|
|
5791
|
+
// instruction references data indices (splitting renumbers segments) and no
|
|
5792
|
+
// memory is imported (an import's pre-existing bytes must be overwritten, not
|
|
5793
|
+
// skipped). Named segments are left whole (a name implies index identity).
|
|
5794
|
+
let refsData = false, importedMem = false
|
|
5795
|
+
walk(ast, n => {
|
|
5796
|
+
const op = Array.isArray(n) ? n[0] : n
|
|
5797
|
+
if (op === 'memory.init' || op === 'data.drop' || op === 'array.new_data' || op === 'array.init_data') refsData = true
|
|
5798
|
+
if (!Array.isArray(n)) return
|
|
5799
|
+
if ((op === 'import' || op === 'memory') && n.some(s => Array.isArray(s) && (s[0] === 'memory' || s[0] === 'import'))) importedMem = true
|
|
5800
|
+
})
|
|
5801
|
+
if (refsData || importedMem) return ast
|
|
5802
|
+
|
|
5803
|
+
// splitting drops zero runs — same zero-assumption fences as the trim above
|
|
5804
|
+
const fence = zeroFence(ast)
|
|
5805
|
+
const out = []
|
|
5806
|
+
for (const node of ast) {
|
|
5807
|
+
if (!Array.isArray(node) || node[0] !== 'data' || (typeof node[1] === 'string' && node[1][0] === '$')) { out.push(node); continue }
|
|
5808
|
+
if (!fence || !segSafe(fence, node)) { out.push(node); continue }
|
|
5809
|
+
let idx = 1
|
|
5810
|
+
const mem = Array.isArray(node[idx]) && node[idx][0] === 'memory' ? node[idx++] : null
|
|
5811
|
+
const off = node[idx]
|
|
5812
|
+
if (!Array.isArray(off) || (off[0] !== 'i32.const' && off[0] !== 'i64.const')) { out.push(node); continue }
|
|
5813
|
+
const items = node.slice(idx + 1)
|
|
5814
|
+
if (!items.length || !items.every(s => typeof s === 'string')) { out.push(node); continue }
|
|
5815
|
+
const bytes = items.flatMap(parseDataString)
|
|
5816
|
+
const base = Number(off[1])
|
|
5817
|
+
// collect [start, end) pieces separated by zero runs longer than the cost of a
|
|
5818
|
+
// fresh segment header (mode + offset expr + end + length prefix)
|
|
5819
|
+
const pieces = []
|
|
5820
|
+
let s = -1
|
|
5821
|
+
for (let i = 0; i <= bytes.length; i++) {
|
|
5822
|
+
if (i < bytes.length && bytes[i] !== 0) { s < 0 && (s = i); continue }
|
|
5823
|
+
if (s < 0) continue
|
|
5824
|
+
// extend over short zero runs: find next nonzero
|
|
5825
|
+
let j = i
|
|
5826
|
+
while (j < bytes.length && bytes[j] === 0) j++
|
|
5827
|
+
const segCost = 4 + slebSize(base + j) + 1
|
|
5828
|
+
if (j - i > segCost || j >= bytes.length) pieces.push([s, i]), s = -1
|
|
5829
|
+
i = j - 1
|
|
5830
|
+
}
|
|
5831
|
+
if (!pieces.length) continue // all zeros — segment is a no-op, drop it
|
|
5832
|
+
if (pieces.length === 1 && pieces[0][0] === 0 && pieces[0][1] === bytes.length) { out.push(node); continue }
|
|
5833
|
+
for (const [ps, pe] of pieces) {
|
|
5834
|
+
const head = mem ? ['data', mem] : ['data']
|
|
5835
|
+
out.push([...head, [off[0], String(base + ps)], encodeDataString(bytes.slice(ps), pe - ps)])
|
|
5836
|
+
}
|
|
5837
|
+
}
|
|
5838
|
+
return out
|
|
3630
5839
|
}
|
|
3631
5840
|
|
|
3632
5841
|
// ==================== IMPORT FIELD MINIFICATION ====================
|
|
@@ -3730,6 +5939,177 @@ const reorder = (ast) => {
|
|
|
3730
5939
|
return ['module', ...imports, ...funcs, ...others]
|
|
3731
5940
|
}
|
|
3732
5941
|
|
|
5942
|
+
// ==================== LOOP-INVARIANT CODE MOTION ====================
|
|
5943
|
+
|
|
5944
|
+
/**
|
|
5945
|
+
* licm — hoist loop-invariant PURE, NON-TRAPPING value expressions out of loops into
|
|
5946
|
+
* fresh locals computed once before the loop. The mechanical half of LICM: no alias
|
|
5947
|
+
* analysis, so memory loads and calls are never hoisted; only arithmetic/compare/
|
|
5948
|
+
* select/conversion/SIMD subtrees whose leaves are constants, loop-unwritten locals,
|
|
5949
|
+
* or immutable globals. Hoisting is speculation-safe (an `if`-arm expression now runs
|
|
5950
|
+
* every entry, a zero-trip loop runs it once) because every whitelisted op is pure and
|
|
5951
|
+
* cannot trap — trapping ops (int div/rem, non-saturating float→int trunc) and effects
|
|
5952
|
+
* (loads/stores/calls/tees) are excluded, so the only cost is wasted work, never a
|
|
5953
|
+
* changed result. Identical hoists within one loop share one local (loop-level CSE —
|
|
5954
|
+
* repeated `f64x2.splat (f64.const C)` broadcasts collapse to a single set).
|
|
5955
|
+
*
|
|
5956
|
+
* Runs ONCE after the rounds (like `inline`): the invariants worth hoisting only exist
|
|
5957
|
+
* after inlineOnce/inline splice callee bodies into the loop, and a later `propagate`
|
|
5958
|
+
* round would forward a single-use hoist straight back into the loop, undoing it.
|
|
5959
|
+
* Opt-in (speed-for-size: new locals + sets grow the binary slightly).
|
|
5960
|
+
*
|
|
5961
|
+
* Loops are processed innermost-first; a hoisted inner set whose RHS is invariant to
|
|
5962
|
+
* the outer loop hoists again on the outer pass (the RHS is a value position of the
|
|
5963
|
+
* set), cascading multi-level invariants outward one level per enclosing loop.
|
|
5964
|
+
*/
|
|
5965
|
+
// Pure & non-trapping op test. v128/SIMD ops are all non-trapping except memory ops;
|
|
5966
|
+
// scalar arithmetic is safe except int div/rem (trap on 0/overflow) and the trapping
|
|
5967
|
+
// float→int truncations (`iNN.trunc_fMM_x`; the `trunc_sat` forms saturate instead).
|
|
5968
|
+
const licmPure = (op) => {
|
|
5969
|
+
if (typeof op !== 'string') return false
|
|
5970
|
+
if (op === 'select') return true
|
|
5971
|
+
if (/^(v128|[if](8x16|16x8|32x4|64x2))\./.test(op)) return !/\.(load|store)/.test(op)
|
|
5972
|
+
if (!/^[if](32|64)\./.test(op) && !/^f(32|64)\./.test(op)) return false
|
|
5973
|
+
if (/\.(load|store)/.test(op)) return false
|
|
5974
|
+
if (/\.(div_[su]|rem_[su])$/.test(op)) return false
|
|
5975
|
+
if (/^i(32|64)\.trunc_f/.test(op) && !op.includes('trunc_sat')) return false
|
|
5976
|
+
return true
|
|
5977
|
+
}
|
|
5978
|
+
|
|
5979
|
+
const licm = (ast) => {
|
|
5980
|
+
for (const func of ast) {
|
|
5981
|
+
if (!Array.isArray(func) || func[0] !== 'func') continue
|
|
5982
|
+
|
|
5983
|
+
// Local/param types (for typing hoisted expressions through local.get leaves).
|
|
5984
|
+
const localTypes = new Map()
|
|
5985
|
+
let declEnd = 1 // insertion point for fresh (local …) decls: after params/result/locals
|
|
5986
|
+
for (let i = 1; i < func.length; i++) {
|
|
5987
|
+
const c = func[i]
|
|
5988
|
+
if (!Array.isArray(c)) { if (typeof c === 'string' && c[0] === '$') declEnd = i + 1; continue }
|
|
5989
|
+
if (c[0] === 'param' || c[0] === 'local') {
|
|
5990
|
+
if (typeof c[1] === 'string' && c[1][0] === '$') localTypes.set(c[1], c[2])
|
|
5991
|
+
declEnd = i + 1
|
|
5992
|
+
} else if (c[0] === 'result' || c[0] === 'export' || c[0] === 'type') declEnd = i + 1
|
|
5993
|
+
else break
|
|
5994
|
+
}
|
|
5995
|
+
|
|
5996
|
+
// Immutable module globals: declared without (mut …) — reads are invariant everywhere.
|
|
5997
|
+
const immutGlobals = new Set()
|
|
5998
|
+
for (const g of ast) {
|
|
5999
|
+
if (!Array.isArray(g)) continue
|
|
6000
|
+
if (g[0] === 'global' && typeof g[1] === 'string' && !g.some(c => Array.isArray(c) && c[0] === 'mut')) immutGlobals.add(g[1])
|
|
6001
|
+
if (g[0] === 'import') { const d = g[g.length - 1]; if (Array.isArray(d) && d[0] === 'global' && typeof d[1] === 'string' && !d.some(c => Array.isArray(c) && c[0] === 'mut')) immutGlobals.add(d[1]) }
|
|
6002
|
+
}
|
|
6003
|
+
|
|
6004
|
+
// Result type of a hoistable subtree (null ⇒ untypeable ⇒ don't hoist).
|
|
6005
|
+
const typeOf = (n) => {
|
|
6006
|
+
if (!Array.isArray(n)) return null
|
|
6007
|
+
const op = n[0]
|
|
6008
|
+
if (op === 'select') return typeOf(n[1])
|
|
6009
|
+
if (op === 'local.get') return localTypes.get(n[1]) ?? null
|
|
6010
|
+
if (/\.extract_lane/.test(op)) { const p = op.slice(0, op.indexOf('.')); return p === 'f64x2' ? 'f64' : p === 'f32x4' ? 'f32' : p === 'i64x2' ? 'i64' : 'i32' }
|
|
6011
|
+
if (/^(v128|[if](8x16|16x8|32x4|64x2))\./.test(op)) return op.endsWith('any_true') || op.endsWith('all_true') || op.endsWith('bitmask') ? 'i32' : 'v128'
|
|
6012
|
+
return resultType(op)
|
|
6013
|
+
}
|
|
6014
|
+
|
|
6015
|
+
let minted = 0
|
|
6016
|
+
const freshName = () => {
|
|
6017
|
+
let n
|
|
6018
|
+
do { n = `$__licm${minted++}` } while (localTypes.has(n))
|
|
6019
|
+
return n
|
|
6020
|
+
}
|
|
6021
|
+
const newDecls = []
|
|
6022
|
+
|
|
6023
|
+
const processLoop = (loop, parent, idx) => {
|
|
6024
|
+
// Only hoist when the loop sits in a statement list we can splice into.
|
|
6025
|
+
const pop = Array.isArray(parent) ? parent[0] : null
|
|
6026
|
+
if (pop !== 'func' && pop !== 'block' && pop !== 'loop' && pop !== 'then' && pop !== 'else') return
|
|
6027
|
+
|
|
6028
|
+
// Loop effect summary: the write-set of locals, and whether globals stay stable.
|
|
6029
|
+
const writes = new Set()
|
|
6030
|
+
let hasCall = false, hasGlobalSet = false
|
|
6031
|
+
walk(loop, (n) => {
|
|
6032
|
+
if (!Array.isArray(n)) return
|
|
6033
|
+
const op = n[0]
|
|
6034
|
+
if ((op === 'local.set' || op === 'local.tee') && typeof n[1] === 'string') writes.add(n[1])
|
|
6035
|
+
else if (op === 'global.set') hasGlobalSet = true
|
|
6036
|
+
else if (op === 'call' || op === 'call_indirect' || op === 'call_ref' || op === 'return_call' || op === 'return_call_indirect') hasCall = true
|
|
6037
|
+
})
|
|
6038
|
+
|
|
6039
|
+
// Invariant: every node in the subtree is a const, an unwritten local, a stable
|
|
6040
|
+
// global read, or a whitelisted pure op. Non-array children of whitelisted ops
|
|
6041
|
+
// are immediates (lane indices, shuffle masks, v128.const payload) — allowed.
|
|
6042
|
+
const inv = (n) => {
|
|
6043
|
+
if (!Array.isArray(n)) return false
|
|
6044
|
+
const op = n[0]
|
|
6045
|
+
if (typeof op !== 'string') return false
|
|
6046
|
+
if (op.endsWith('.const')) return true
|
|
6047
|
+
if (op === 'local.get') return typeof n[1] === 'string' && !writes.has(n[1])
|
|
6048
|
+
if (op === 'global.get') return typeof n[1] === 'string' && (immutGlobals.has(n[1]) || (!hasGlobalSet && !hasCall))
|
|
6049
|
+
if (!licmPure(op)) return false
|
|
6050
|
+
for (let i = 1; i < n.length; i++) if (Array.isArray(n[i]) && !inv(n[i])) return false
|
|
6051
|
+
return true
|
|
6052
|
+
}
|
|
6053
|
+
// Cost gate: ≥2 real ops (consts/gets free) — hoisting a lone add trades a local
|
|
6054
|
+
// for one op and mostly just grows the binary; a guard pair or splat-chain pays.
|
|
6055
|
+
const opCount = (n) => {
|
|
6056
|
+
if (!Array.isArray(n)) return 0
|
|
6057
|
+
const op = n[0]
|
|
6058
|
+
let c = (typeof op === 'string' && !op.endsWith('.const') && op !== 'local.get') ? 1 : 0
|
|
6059
|
+
for (let i = 1; i < n.length; i++) c += opCount(n[i])
|
|
6060
|
+
return c
|
|
6061
|
+
}
|
|
6062
|
+
|
|
6063
|
+
const hoisted = [] // [ ['local.set', name, expr] … ]
|
|
6064
|
+
const byKey = new Map() // stringified expr → local name (dedupe within this loop)
|
|
6065
|
+
const tryHoist = (node, par, i) => {
|
|
6066
|
+
if (!Array.isArray(node)) return
|
|
6067
|
+
const op = node[0]
|
|
6068
|
+
// Never lift a bare statement/decl; only value expressions match the pure set.
|
|
6069
|
+
// Cost gate: ≥2 real ops — except a v128 SPLAT root, worth hoisting even alone: a
|
|
6070
|
+
// broadcast re-materialized per iteration occupies a vector unit + widens live ranges
|
|
6071
|
+
// (the colorpq 1M-iteration splat(coeff) recompute), where scalar 1-op trees are
|
|
6072
|
+
// register-trivial and V8 folds them anyway.
|
|
6073
|
+
const isSplat = typeof node[0] === 'string' && node[0].endsWith('.splat')
|
|
6074
|
+
if (inv(node) && (opCount(node) >= 2 || isSplat)) {
|
|
6075
|
+
const ty = typeOf(node)
|
|
6076
|
+
if (ty) {
|
|
6077
|
+
let key
|
|
6078
|
+
try { key = JSON.stringify(node) } catch { key = null }
|
|
6079
|
+
let name = key != null ? byKey.get(key) : undefined
|
|
6080
|
+
if (name === undefined) {
|
|
6081
|
+
name = freshName()
|
|
6082
|
+
localTypes.set(name, ty)
|
|
6083
|
+
newDecls.push(['local', name, ty])
|
|
6084
|
+
hoisted.push(['local.set', name, node])
|
|
6085
|
+
if (key != null) byKey.set(key, name)
|
|
6086
|
+
}
|
|
6087
|
+
par[i] = ['local.get', name]
|
|
6088
|
+
return
|
|
6089
|
+
}
|
|
6090
|
+
}
|
|
6091
|
+
for (let i2 = 1; i2 < node.length; i2++) tryHoist(node[i2], node, i2)
|
|
6092
|
+
}
|
|
6093
|
+
for (let i = 1; i < loop.length; i++) if (Array.isArray(loop[i])) tryHoist(loop[i], loop, i)
|
|
6094
|
+
if (hoisted.length) parent.splice(idx, 0, ...hoisted)
|
|
6095
|
+
}
|
|
6096
|
+
|
|
6097
|
+
// Innermost-first: recurse before processing, and track (parent, idx) for splicing.
|
|
6098
|
+
// Indices shift as hoists are spliced in — iterate by live position.
|
|
6099
|
+
const visit = (parent) => {
|
|
6100
|
+
for (let i = 1; i < parent.length; i++) {
|
|
6101
|
+
const n = parent[i]
|
|
6102
|
+
if (!Array.isArray(n)) continue
|
|
6103
|
+
visit(n)
|
|
6104
|
+
if (n[0] === 'loop') { const before = parent.length; processLoop(n, parent, i); i += parent.length - before }
|
|
6105
|
+
}
|
|
6106
|
+
}
|
|
6107
|
+
visit(func)
|
|
6108
|
+
if (newDecls.length) func.splice(declEnd, 0, ...newDecls)
|
|
6109
|
+
}
|
|
6110
|
+
return ast
|
|
6111
|
+
}
|
|
6112
|
+
|
|
3733
6113
|
// ==================== MAIN ====================
|
|
3734
6114
|
|
|
3735
6115
|
/**
|
|
@@ -3748,20 +6128,28 @@ const PASSES = [
|
|
|
3748
6128
|
['strength', strength, true, 'strength reduction (x * 2 → x << 1)'],
|
|
3749
6129
|
['branch', branch, true, 'simplify constant branches'],
|
|
3750
6130
|
['propagate', propagate, true, 'forward-propagate single-use locals & tiny consts (never inflates)'],
|
|
6131
|
+
['merge', mergeLocals, true, 'merge alias locals written once by the same set(tee) value'],
|
|
6132
|
+
['macro', inlineMacro, true, 'expand single-expression functions at call sites (positional, zero wrapper)'],
|
|
6133
|
+
['spec', specializeParams, true, 'drop parameters every call site passes the same constant'],
|
|
3751
6134
|
['devirt', devirt, false, 'call_indirect with a constant or known closure-const index → direct/guarded calls — grows bytes for speed'],
|
|
6135
|
+
['dedupe', dedupe, true, 'eliminate duplicate functions (before inlineOnce dissolves identical single-caller helpers)'],
|
|
3752
6136
|
['inlineOnce', inlineOnce, true, 'inline single-call functions into their lone caller (never duplicates)'],
|
|
3753
6137
|
['inline', inline, false, 'inline tiny functions — can duplicate bodies'],
|
|
6138
|
+
['licm', licm, false, 'hoist loop-invariant pure arithmetic out of loops — adds locals (speed-for-size); runs once after rounds'],
|
|
3754
6139
|
['offset', offset, true, 'fold add+const into load/store offset'],
|
|
6140
|
+
['cse', cse, true, 'reuse repeated pure subexpressions via a tee\'d local — runs once after rounds (byte-profit gated)'],
|
|
3755
6141
|
['unbranch', unbranch, true, 'remove redundant br at end of own block'],
|
|
3756
6142
|
['loopify', loopify, true, 'collapse block+loop+brif while-idiom into loop+if'],
|
|
3757
6143
|
['brif', brif, true, 'if-then-br → br_if'],
|
|
6144
|
+
['tailmerge', tailmerge, true, 'share byte-identical early-exit epilogues via block + br_if'],
|
|
6145
|
+
['rettail', rettail, true, 'elide a function-tail return once epilogues are merged'],
|
|
3758
6146
|
['foldarms', foldarms, false, 'merge identical trailing if arms — can add block wrapper'],
|
|
3759
6147
|
['deadcode', deadcode, true, 'eliminate dead code after unreachable/br/return'],
|
|
3760
6148
|
['vacuum', vacuum, true, 'remove nops, drop-of-pure, empty branches'],
|
|
3761
6149
|
['mergeBlocks', mergeBlocks, true, 'unwrap `(block $L …)` whose label is never targeted'],
|
|
3762
6150
|
['coalesce', coalesceLocals, true, 'share local slots between same-type non-overlapping locals'],
|
|
3763
6151
|
['locals', localReuse, true, 'remove unused locals'],
|
|
3764
|
-
['
|
|
6152
|
+
['outline', outline, true, 'extract repeated pure expressions into shared helper functions'],
|
|
3765
6153
|
['dedupTypes', dedupTypes, true, 'merge identical type definitions'],
|
|
3766
6154
|
['packData', packData, true, 'trim trailing zeros, merge adjacent data segments'],
|
|
3767
6155
|
['reorder', reorder, false, 'put hot functions first — no AST reduction'],
|
|
@@ -3846,21 +6234,8 @@ const mayInline = (ast) => {
|
|
|
3846
6234
|
// artifact (e.g. a top-level `1 + 2;` whose dropped value the dead-code pass
|
|
3847
6235
|
// removed). Drop both. Header nodes (param/result/local/export/type) don't count
|
|
3848
6236
|
// as a body — a function carrying only locals still does nothing.
|
|
3849
|
-
const START_HEAD = new Set(['export', 'type', 'param', 'result', 'local'])
|
|
3850
|
-
function pruneEmptyStart(ast) {
|
|
3851
|
-
if (!Array.isArray(ast) || ast[0] !== 'module') return ast
|
|
3852
|
-
const fn = ast.find(n => Array.isArray(n) && n[0] === 'func' && n[1] === '$__start')
|
|
3853
|
-
if (!fn) return ast
|
|
3854
|
-
// Skip the name (index 1) and header nodes; a bare-string node past them is a
|
|
3855
|
-
// real instruction (`drop`/`nop`/`unreachable`), so it stops the scan.
|
|
3856
|
-
let b = 2
|
|
3857
|
-
while (b < fn.length && Array.isArray(fn[b]) && START_HEAD.has(fn[b][0])) b++
|
|
3858
|
-
if (b < fn.length) return ast // real instructions remain
|
|
3859
|
-
return ast.filter(n => !(Array.isArray(n) &&
|
|
3860
|
-
((n[0] === 'func' && n[1] === '$__start') || (n[0] === 'start' && n[1] === '$__start'))))
|
|
3861
|
-
}
|
|
3862
|
-
|
|
3863
6237
|
export default function optimize(ast, opts = true) {
|
|
6238
|
+
CALLFX = null
|
|
3864
6239
|
if (typeof ast === 'string') ast = parse(ast) // accept WAT source directly
|
|
3865
6240
|
const strictGuard = opts === true // default: zero tolerance for bloat
|
|
3866
6241
|
opts = normalize(opts)
|
|
@@ -3873,6 +6248,7 @@ export default function optimize(ast, opts = true) {
|
|
|
3873
6248
|
const verbose = opts.verbose || opts.log
|
|
3874
6249
|
|
|
3875
6250
|
ast = clone(ast)
|
|
6251
|
+
CALLFX = computeCallEffects(ast)
|
|
3876
6252
|
|
|
3877
6253
|
// devirt trades bytes for speed by design (guards + duplicated args), so it
|
|
3878
6254
|
// runs ONCE after the rounds — its candidate shape (select of two i64 closure
|
|
@@ -3896,7 +6272,51 @@ export default function optimize(ast, opts = true) {
|
|
|
3896
6272
|
if (opts.coalesceLocals) a = coalesceLocals(a)
|
|
3897
6273
|
return a
|
|
3898
6274
|
}
|
|
3899
|
-
|
|
6275
|
+
// parse() returns [comment…, ['module',…]] when top-level trivia precedes the module —
|
|
6276
|
+
// whole-module passes key on ast[0] === 'module' and would silently no-op on the wrapper.
|
|
6277
|
+
// Run the pipeline on the module node itself and splice it back, so comments survive.
|
|
6278
|
+
let wrapper = null, slot = -1
|
|
6279
|
+
if (Array.isArray(ast) && ast[0] !== 'module') {
|
|
6280
|
+
const i = ast.findIndex(n => Array.isArray(n) && n[0] === 'module')
|
|
6281
|
+
if (i >= 0) wrapper = ast, slot = i, ast = ast[i]
|
|
6282
|
+
}
|
|
6283
|
+
|
|
6284
|
+
// Strip comment trivia inside the module: parse keeps `;;`/`(;` as string children,
|
|
6285
|
+
// which blocks every adjacency-windowed pass (set→get fusion, dead-store pairs).
|
|
6286
|
+
// Top-level banner comments survive in the wrapper above.
|
|
6287
|
+
walkPost(ast, n => {
|
|
6288
|
+
if (!Array.isArray(n)) return
|
|
6289
|
+
for (let i = n.length - 1; i >= 0; i--) {
|
|
6290
|
+
const c = n[i]
|
|
6291
|
+
if (typeof c === 'string' && (c[0] === ';' || (c[0] === '(' && c[1] === ';'))) n.splice(i, 1)
|
|
6292
|
+
}
|
|
6293
|
+
})
|
|
6294
|
+
|
|
6295
|
+
// Mandatory NaN-payload canonicalization, ONCE before the rounds: these folds may
|
|
6296
|
+
// grow bytes yet must never revert — hoisting keeps the rounds size-monotone, so
|
|
6297
|
+
// the guard below can compare entry vs exit without penalizing required folds.
|
|
6298
|
+
if (opts.fold) walkPost(ast, nanFoldNode)
|
|
6299
|
+
|
|
6300
|
+
// licm runs ONCE after the rounds + inline: its invariants only exist after inlining, and a
|
|
6301
|
+
// later propagate round would forward a single-use hoist back into the loop, undoing it.
|
|
6302
|
+
// devirt must run BEFORE licm: its collector pattern-matches the in-loop closure-const
|
|
6303
|
+
// select chain feeding a call_indirect, and licm hoists exactly that chain into a local —
|
|
6304
|
+
// hoist first and no call site ever devirtualizes (jz speed tier, closures devirt tests).
|
|
6305
|
+
const finish = (a) => {
|
|
6306
|
+
a = runInline(a)
|
|
6307
|
+
if (opts.devirt) a = devirt(a)
|
|
6308
|
+
if (opts.licm) a = licm(a)
|
|
6309
|
+
// cse runs ONCE at fixpoint: inside the rounds its tee'd locals block the very
|
|
6310
|
+
// collapses (propagate/sink/merge) that would erase the expressions outright,
|
|
6311
|
+
// and any net wobble trips the exit guard into unwinding a whole round.
|
|
6312
|
+
if (opts.cse) {
|
|
6313
|
+
a = cse(a)
|
|
6314
|
+
if (opts.coalesce) a = coalesceLocals(a)
|
|
6315
|
+
if (opts.locals) a = localReuse(a)
|
|
6316
|
+
}
|
|
6317
|
+
if (opts.outline) a = outline(a)
|
|
6318
|
+
return wrapper ? (wrapper[slot] = a, wrapper) : a
|
|
6319
|
+
}
|
|
3900
6320
|
|
|
3901
6321
|
// Fast path: jz owns this optimizer and feeds it a controlled, type-aware IR.
|
|
3902
6322
|
// The only passes that can *grow* the binary are inlineOnce/inline; when no
|
|
@@ -3904,52 +6324,120 @@ export default function optimize(ast, opts = true) {
|
|
|
3904
6324
|
// nothing can inflate, so we skip watr's per-round `binarySize` re-compile
|
|
3905
6325
|
// guard — up to four full encodes per call — and iterate to a fixpoint with
|
|
3906
6326
|
// zero compiles. A round that changes nothing is the natural exit.
|
|
6327
|
+
// Per-function convergence: passes that look only inside one function run over a
|
|
6328
|
+
// DIRTY set — a function untouched since the previous round is skipped (on a large
|
|
6329
|
+
// module the bulk converges in round one, so later rounds cost almost nothing).
|
|
6330
|
+
// Module-scoped passes always run; their mutations re-dirty functions through the
|
|
6331
|
+
// per-func snapshots.
|
|
6332
|
+
const MODULE_SCOPE = new Set(['stripmut', 'globals', 'guardRefine', 'macro', 'spec', 'inlineOnce',
|
|
6333
|
+
'dedupe', 'dedupTypes', 'packData', 'treeshake', 'minifyImports', 'reorder', 'unbranch', 'rettail'])
|
|
6334
|
+
const collectFuncs = () => { const out = []; for (const n of ast) if (Array.isArray(n) && n[0] === 'func') out.push(n); return out }
|
|
6335
|
+
const snapshots = new Map()
|
|
6336
|
+
let dirty = null // null = everything
|
|
6337
|
+
const runRounds = (skipInline, onRoundStart) => {
|
|
6338
|
+
// dirty sets make extra rounds nearly free — the cap is a safety net, not a cost
|
|
6339
|
+
let prevLen = -1
|
|
6340
|
+
for (let round = 0; round < 6; round++) {
|
|
6341
|
+
onRoundStart?.()
|
|
6342
|
+
const funcsNow = collectFuncs()
|
|
6343
|
+
const work = dirty ? funcsNow.filter(f => dirty.has(f)) : funcsNow
|
|
6344
|
+
// a walkPost-shaped pass may rebuild the func ROOT (e.g. vacuum cleaning the
|
|
6345
|
+
// body list) — write the replacement back and carry the identity forward
|
|
6346
|
+
const per = (fn) => {
|
|
6347
|
+
for (let wi = 0; wi < work.length; wi++) {
|
|
6348
|
+
const f = work[wi], r = fn(f, opts)
|
|
6349
|
+
if (r && r !== f && Array.isArray(r)) {
|
|
6350
|
+
const i = ast.indexOf(f)
|
|
6351
|
+
if (i >= 0) ast[i] = r
|
|
6352
|
+
work[wi] = r
|
|
6353
|
+
if (dirty?.has(f)) { dirty.delete(f); dirty.add(r) }
|
|
6354
|
+
const snap = snapshots.get(f)
|
|
6355
|
+
if (snap !== undefined) { snapshots.delete(f); snapshots.set(r, snap) }
|
|
6356
|
+
}
|
|
6357
|
+
}
|
|
6358
|
+
}
|
|
6359
|
+
let fused = false
|
|
6360
|
+
for (const [key, fn] of PASSES) {
|
|
6361
|
+
if (!opts[key] || key === 'inline' || key === 'devirt' || key === 'licm' || key === 'cse' ||
|
|
6362
|
+
(skipInline && key === 'inlineOnce')) continue
|
|
6363
|
+
if (SIMPLIFY_KEYS.has(key)) {
|
|
6364
|
+
if (!fused) {
|
|
6365
|
+
fused = true
|
|
6366
|
+
per((f) => simplify(f, opts))
|
|
6367
|
+
for (const n of ast) if (Array.isArray(n) && n[0] !== 'func') simplify(n, opts)
|
|
6368
|
+
}
|
|
6369
|
+
continue
|
|
6370
|
+
}
|
|
6371
|
+
if (MODULE_SCOPE.has(key)) { ast = fn(ast, opts); continue }
|
|
6372
|
+
per(fn)
|
|
6373
|
+
}
|
|
6374
|
+
const next = new Set()
|
|
6375
|
+
// convergence via content hash, not clone+equal: one pass per function, no
|
|
6376
|
+
// retained deep copies. A collision only marks a changed function clean —
|
|
6377
|
+
// it skips further rounds, never alters what already-applied passes did.
|
|
6378
|
+
const nextHash = new Map()
|
|
6379
|
+
for (const f of collectFuncs()) {
|
|
6380
|
+
const h = hashFunc(f, EMPTY_MAP)
|
|
6381
|
+
nextHash.set(f, h)
|
|
6382
|
+
if (snapshots.get(f) !== h) next.add(f)
|
|
6383
|
+
}
|
|
6384
|
+
const topStable = ast.length === prevLen
|
|
6385
|
+
prevLen = ast.length
|
|
6386
|
+
for (const [f, h] of nextHash) snapshots.set(f, h)
|
|
6387
|
+
dirty = next
|
|
6388
|
+
if (verbose) log(` round ${round + 1}: ${next.size} dirty funcs`)
|
|
6389
|
+
if (!next.size && topStable) break
|
|
6390
|
+
}
|
|
6391
|
+
}
|
|
6392
|
+
|
|
3907
6393
|
if (!((opts.inlineOnce || opts.inline) && mayInline(ast))) {
|
|
3908
6394
|
// inlineOnce/inline can't fire here, so skip them — their candidate scan
|
|
3909
6395
|
// (a 16-round whole-module walk) is the second-costliest thing after
|
|
3910
6396
|
// propagate, and it would only confirm what `mayInline` already proved.
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
|
|
3918
|
-
|
|
3919
|
-
|
|
3920
|
-
//
|
|
3921
|
-
//
|
|
3922
|
-
//
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
let sizeBefore =
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
}
|
|
3952
|
-
|
|
6397
|
+
runRounds(true)
|
|
6398
|
+
// treeshake/dedupe can EXPOSE single-caller candidates mid-rounds (dropping the
|
|
6399
|
+
// other callers) — the entry check was a snapshot; recheck before finishing
|
|
6400
|
+
if (!((opts.inlineOnce || opts.inline) && mayInline(ast))) return finish(ast)
|
|
6401
|
+
}
|
|
6402
|
+
|
|
6403
|
+
// Guarded path: inlining can inflate (a body bigger than the call it replaces).
|
|
6404
|
+
// Rounds run unmeasured — every non-inline pass is size-monotone (NaN folds were
|
|
6405
|
+
// hoisted above) — and a single exit encode compares against the entry size.
|
|
6406
|
+
// Net growth unwinds the last round, then everything: two encodes in the common
|
|
6407
|
+
// case instead of one per round; `binarySize` returns Infinity for invalid wat,
|
|
6408
|
+
// so a broken round unwinds the same way.
|
|
6409
|
+
const pristine = clone(ast)
|
|
6410
|
+
const countBefore = count(ast)
|
|
6411
|
+
const inflBefore = inflations
|
|
6412
|
+
let sizeBefore = null
|
|
6413
|
+
let beforeRound = null, cur = null
|
|
6414
|
+
// round-start clones exist only so the guard can unwind ONE round instead of
|
|
6415
|
+
// all of them — worth taking only once an inflating splice has actually fired
|
|
6416
|
+
// (before that, every executed pass was size-monotone; if inflation first
|
|
6417
|
+
// fires mid-round the unwind falls back to pristine, which is still correct)
|
|
6418
|
+
runRounds(false, () => { beforeRound = cur; cur = inflations !== inflBefore ? clone(ast) : null })
|
|
6419
|
+
// `cur` is the clone taken at the LAST round's start; if that round changed
|
|
6420
|
+
// nothing (converged), unwinding to it is identity — beforeRound covers the case
|
|
6421
|
+
if (cur && !equal(cur, ast)) beforeRound = cur
|
|
6422
|
+
|
|
6423
|
+
// Second propagate sweep on whatever remained dirty: inlineOnce leaves fresh
|
|
6424
|
+
// (local.set $p arg) … (local.get $p) wrappers around each inlined call
|
|
6425
|
+
if (opts.propagate && dirty) for (const f of dirty) propagate(f)
|
|
6426
|
+
|
|
6427
|
+
// No inflating splice fired and the node count didn't grow: every executed pass
|
|
6428
|
+
// was size-monotone, so both guard encodes (the optimizer's dominant cost on a
|
|
6429
|
+
// large module) are provably redundant.
|
|
6430
|
+
if (inflations === inflBefore && count(ast) <= countBefore) return finish(ast)
|
|
6431
|
+
|
|
6432
|
+
// Default optimize must never inflate; explicit passes get slight leniency.
|
|
6433
|
+
const tolerance = strictGuard ? 0 : 16
|
|
6434
|
+
sizeBefore = binarySize(pristine)
|
|
6435
|
+
let sizeAfter = binarySize(ast)
|
|
6436
|
+
if (sizeAfter - sizeBefore > tolerance && beforeRound) {
|
|
6437
|
+
if (verbose) log(` ⚠ net +${sizeAfter - sizeBefore} bytes — unwinding last round`, sizeAfter - sizeBefore)
|
|
6438
|
+
ast = beforeRound
|
|
6439
|
+
sizeAfter = binarySize(ast)
|
|
6440
|
+
if (sizeAfter - sizeBefore > tolerance) ast = pristine
|
|
3953
6441
|
}
|
|
3954
6442
|
|
|
3955
6443
|
return finish(ast)
|
|
@@ -3957,4 +6445,4 @@ export default function optimize(ast, opts = true) {
|
|
|
3957
6445
|
|
|
3958
6446
|
/** Count AST nodes (fast size heuristic). */
|
|
3959
6447
|
export { count as size, count, binarySize }
|
|
3960
|
-
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 }
|
|
6448
|
+
export { optimize, treeshake, fold, deadcode, localReuse, identity, strength, branch, propagate, mergeLocals, cse, inlineMacro, tailmerge, inline, inlineOnce, devirt, normalize, OPTS, vacuum, peephole, globals, offset, unbranch, loopify, stripmut, brif, foldarms, dedupe, reorder, dedupTypes, packData, minifyImports, mergeBlocks, coalesceLocals }
|