jz 0.0.0 → 0.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/LICENSE +21 -0
- package/README.md +373 -0
- package/cli.js +163 -0
- package/index.js +247 -0
- package/module/array.js +1322 -0
- package/module/collection.js +793 -0
- package/module/console.js +192 -0
- package/module/core.js +644 -0
- package/module/function.js +181 -0
- package/module/index.js +15 -0
- package/module/json.js +506 -0
- package/module/math.js +390 -0
- package/module/number.js +601 -0
- package/module/object.js +359 -0
- package/module/regex.js +914 -0
- package/module/schema.js +106 -0
- package/module/string.js +985 -0
- package/module/symbol.js +55 -0
- package/module/timer.js +253 -0
- package/module/typedarray.js +713 -0
- package/package.json +56 -5
- package/src/analyze.js +1927 -0
- package/src/autoload.js +175 -0
- package/src/compile.js +1211 -0
- package/src/ctx.js +244 -0
- package/src/emit.js +2105 -0
- package/src/host.js +536 -0
- package/src/ir.js +649 -0
- package/src/jzify.js +418 -0
- package/src/key.js +73 -0
- package/src/narrow.js +928 -0
- package/src/optimize.js +1352 -0
- package/src/plan.js +105 -0
- package/src/prepare.js +1534 -0
- package/src/source.js +76 -0
- package/wasi.js +80 -0
package/src/optimize.js
ADDED
|
@@ -0,0 +1,1352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WASM IR post-emission optimizations.
|
|
3
|
+
*
|
|
4
|
+
* # Stage contract
|
|
5
|
+
* IN: WAT-as-array IR (function body or module-level).
|
|
6
|
+
* OUT: equivalent WAT-as-array IR (same semantics, smaller encoding).
|
|
7
|
+
* INVARIANTS: pure IR→IR rewrite. No ctx reads/writes. No new top-level declarations except
|
|
8
|
+
* the ones explicitly surfaced via `addGlobal` (hoistConstantPool only).
|
|
9
|
+
*
|
|
10
|
+
* Each pass is orthogonal. Apply order matters: structural hoists (hoistPtrType) introduce
|
|
11
|
+
* new locals before the fused walk, which mixes peephole rebox folds, ptr-helper inlining,
|
|
12
|
+
* and memarg-offset folding in one bottom-up traversal.
|
|
13
|
+
*
|
|
14
|
+
* Passes:
|
|
15
|
+
* hoistPtrType — repeated `(call $__ptr_type X)` on same X → single local.tee + local.get reuse
|
|
16
|
+
* fusedRewrite — peephole rebox folds + inline ptr/is_* helpers + memarg-offset fold (one walk)
|
|
17
|
+
* sortLocalsByUse — reorder local decls so hot ones get 1-byte LEB128 indices
|
|
18
|
+
* specializeMkptr — `(call $__mkptr (i32.const T) (i32.const A) X)` → per-combo specialized helper (~4 B/site)
|
|
19
|
+
* specializePtrBase — `(call $F (i32.add (global.get $G) (i32.const N)))` → `$F_rel_$G (i32.const N)`
|
|
20
|
+
* sortStrPoolByFreq — reorder string pool so hottest strings get small offsets (smaller LEB128)
|
|
21
|
+
* hoistConstantPool — frequently-repeated f64.const values → mutable globals (~7 B/reuse)
|
|
22
|
+
* treeshake — drop func decls unreachable from exports / start / elem / ref.func roots
|
|
23
|
+
*
|
|
24
|
+
* Per-function passes run over sec.funcs + sec.stdlib + sec.start.
|
|
25
|
+
* Whole-module passes see the full function list + globals map.
|
|
26
|
+
*
|
|
27
|
+
* @module optimize
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const MEMOP = /^[fi](32|64)\.(load|store)(\d+(_[su])?)?$/
|
|
31
|
+
const NAN_PREFIX_BITS = 0x7FF8n
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Optimization passes, partitioned by phase. The `level` presets pick which
|
|
35
|
+
* passes are on by default; the user can override individual passes via an
|
|
36
|
+
* object form (`{ level: 1, hoistAddrBase: true }`).
|
|
37
|
+
*
|
|
38
|
+
* Levels:
|
|
39
|
+
* 0 — nothing. Fastest compile, largest output. Useful for live coding.
|
|
40
|
+
* 1 — encoding-compactness only (treeshake + sortLocalsByUse + fusedRewrite-inline).
|
|
41
|
+
* Cheap, no IR rewrites that perturb V8's tier-up shape.
|
|
42
|
+
* 2 — default. All current passes (watr CSE/DCE/inline + every jz pass).
|
|
43
|
+
* 3 — reserved for future aggressive passes (constant-arg propagation,
|
|
44
|
+
* inlining, unrolling). Currently == level 2.
|
|
45
|
+
*/
|
|
46
|
+
export const PASS_NAMES = [
|
|
47
|
+
'watr', // third-party WAT-level CSE/DCE/inlining (heaviest)
|
|
48
|
+
'hoistPtrType',
|
|
49
|
+
'hoistInvariantPtrOffset',
|
|
50
|
+
'fusedRewrite', // peephole + ptr-helper inline + memarg fold
|
|
51
|
+
'hoistAddrBase',
|
|
52
|
+
'hoistInvariantCellLoads',
|
|
53
|
+
'sortLocalsByUse',
|
|
54
|
+
'specializeMkptr',
|
|
55
|
+
'specializePtrBase',
|
|
56
|
+
'sortStrPoolByFreq',
|
|
57
|
+
'hoistConstantPool',
|
|
58
|
+
'smallConstForUnroll',
|
|
59
|
+
'treeshake',
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
const ALL_ON = Object.freeze(Object.fromEntries(PASS_NAMES.map(n => [n, true])))
|
|
63
|
+
const ALL_OFF = Object.freeze(Object.fromEntries(PASS_NAMES.map(n => [n, false])))
|
|
64
|
+
const LEVEL_PRESETS = Object.freeze({
|
|
65
|
+
0: ALL_OFF,
|
|
66
|
+
1: Object.freeze({ ...ALL_OFF, treeshake: true, sortLocalsByUse: true, fusedRewrite: true }),
|
|
67
|
+
2: ALL_ON,
|
|
68
|
+
3: ALL_ON,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Normalize the user's `opts.optimize` value into a flat config object.
|
|
73
|
+
*
|
|
74
|
+
* resolveOptimize(undefined | true) → all on (level 2)
|
|
75
|
+
* resolveOptimize(false | 0) → all off
|
|
76
|
+
* resolveOptimize(1 | 2 | 3) → preset for that level
|
|
77
|
+
* resolveOptimize({ level: 1, watr: true }) → level 1 base, with watr forced on
|
|
78
|
+
* resolveOptimize({ hoistAddrBase: false }) → level 2 base, hoistAddrBase off
|
|
79
|
+
*/
|
|
80
|
+
export function resolveOptimize(opt) {
|
|
81
|
+
if (opt === false || opt === 0) return { ...ALL_OFF }
|
|
82
|
+
if (opt === true || opt == null) return { ...ALL_ON }
|
|
83
|
+
if (typeof opt === 'number') return { ...(LEVEL_PRESETS[opt] || ALL_ON) }
|
|
84
|
+
if (typeof opt === 'object') {
|
|
85
|
+
const baseLevel = typeof opt.level === 'number' ? opt.level : 2
|
|
86
|
+
const base = LEVEL_PRESETS[baseLevel] || ALL_ON
|
|
87
|
+
const out = { ...base }
|
|
88
|
+
for (const n of PASS_NAMES) if (n in opt) out[n] = !!opt[n]
|
|
89
|
+
return out
|
|
90
|
+
}
|
|
91
|
+
return { ...ALL_ON }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* CSE repeated `(call $__ptr_type X)` on same X across stable regions.
|
|
96
|
+
*
|
|
97
|
+
* A stable region for var X is a maximal CFG segment where X is not written.
|
|
98
|
+
* Within each region, the first `__ptr_type X` becomes `(local.tee $__ptN ...)`,
|
|
99
|
+
* subsequent ones become `(local.get $__ptN)`. One hoist local per X is shared
|
|
100
|
+
* across regions (each region's tee re-initializes it).
|
|
101
|
+
*
|
|
102
|
+
* Region boundaries:
|
|
103
|
+
* - `local.set` / `local.tee` of X → close region, alive[X] = false
|
|
104
|
+
* - `if` arms processed independently from the if-entry alive state; on merge,
|
|
105
|
+
* a var is alive after the `if` only if alive in BOTH arms with the same region
|
|
106
|
+
* (so the same tee was reachable on every path).
|
|
107
|
+
* - `loop` body walks with empty alive (next iteration may re-enter after a write)
|
|
108
|
+
* - `block` is sequential (br jumps out, never in)
|
|
109
|
+
*
|
|
110
|
+
* Threshold: a region is committed only when it has ≥2 sites. Singleton regions
|
|
111
|
+
* (one tee with no follow-up gets) are pure cost and skipped.
|
|
112
|
+
*
|
|
113
|
+
* Safety: __ptr_type extracts type tag bits, which never change for a given
|
|
114
|
+
* NaN-boxed f64. Caching is safe inside any region where X isn't rewritten.
|
|
115
|
+
* (Contrast __ptr_offset, which has a forwarding loop for ARRAY — caching its
|
|
116
|
+
* result is unsafe across realloc, so it isn't hoisted here.)
|
|
117
|
+
*/
|
|
118
|
+
export function hoistPtrType(fn) {
|
|
119
|
+
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
120
|
+
const bodyStart = findBodyStart(fn)
|
|
121
|
+
if (bodyStart < 0) return
|
|
122
|
+
|
|
123
|
+
// Per X: array of regions; each region is array of {parent, idx, role: 'tee'|'get'}.
|
|
124
|
+
const regions = new Map()
|
|
125
|
+
// Currently-open region per X (X → region array). Presence ⇔ alive.
|
|
126
|
+
const open = new Map()
|
|
127
|
+
|
|
128
|
+
const ensureRegions = (x) => {
|
|
129
|
+
let arr = regions.get(x)
|
|
130
|
+
if (!arr) { arr = []; regions.set(x, arr) }
|
|
131
|
+
return arr
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const walk = (node, parent, pi) => {
|
|
135
|
+
if (!Array.isArray(node)) return
|
|
136
|
+
const op = node[0]
|
|
137
|
+
|
|
138
|
+
if (op === 'call' && node[1] === '$__ptr_type' && node.length === 3) {
|
|
139
|
+
const arg = node[2]
|
|
140
|
+
if (Array.isArray(arg) && arg[0] === 'local.get' && typeof arg[1] === 'string') {
|
|
141
|
+
const x = arg[1]
|
|
142
|
+
let region = open.get(x)
|
|
143
|
+
if (!region) {
|
|
144
|
+
region = []
|
|
145
|
+
ensureRegions(x).push(region)
|
|
146
|
+
open.set(x, region)
|
|
147
|
+
region.push({ parent, idx: pi, role: 'tee' })
|
|
148
|
+
} else {
|
|
149
|
+
region.push({ parent, idx: pi, role: 'get' })
|
|
150
|
+
}
|
|
151
|
+
return // don't recurse — local.get inside is a read, not interesting
|
|
152
|
+
}
|
|
153
|
+
// Non-trivial arg: walk children normally
|
|
154
|
+
for (let i = 2; i < node.length; i++) walk(node[i], node, i)
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if ((op === 'local.set' || op === 'local.tee') && typeof node[1] === 'string') {
|
|
159
|
+
const x = node[1]
|
|
160
|
+
// Walk value first — it may contain __ptr_type X, which sees pre-write X.
|
|
161
|
+
for (let i = 2; i < node.length; i++) walk(node[i], node, i)
|
|
162
|
+
// Then close any open region for X.
|
|
163
|
+
open.delete(x)
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (op === 'if') {
|
|
168
|
+
// Skip optional `(result T)` siblings to find cond / then / else.
|
|
169
|
+
let i = 1
|
|
170
|
+
while (i < node.length && Array.isArray(node[i]) && node[i][0] === 'result') i++
|
|
171
|
+
if (i < node.length) walk(node[i], node, i) // cond
|
|
172
|
+
i++
|
|
173
|
+
let thenArm = null, elseArm = null
|
|
174
|
+
for (; i < node.length; i++) {
|
|
175
|
+
const c = node[i]
|
|
176
|
+
if (Array.isArray(c)) {
|
|
177
|
+
if (c[0] === 'then') thenArm = c
|
|
178
|
+
else if (c[0] === 'else') elseArm = c
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const beforeArms = new Map(open)
|
|
182
|
+
let afterThen = beforeArms
|
|
183
|
+
if (thenArm) {
|
|
184
|
+
for (let j = 1; j < thenArm.length; j++) walk(thenArm[j], thenArm, j)
|
|
185
|
+
afterThen = new Map(open)
|
|
186
|
+
}
|
|
187
|
+
open.clear()
|
|
188
|
+
for (const [k, v] of beforeArms) open.set(k, v)
|
|
189
|
+
let afterElse = beforeArms
|
|
190
|
+
if (elseArm) {
|
|
191
|
+
for (let j = 1; j < elseArm.length; j++) walk(elseArm[j], elseArm, j)
|
|
192
|
+
afterElse = new Map(open)
|
|
193
|
+
}
|
|
194
|
+
// Merge: alive after if iff alive on BOTH paths with same region ref
|
|
195
|
+
// (so the same tee was reachable regardless of which arm executed).
|
|
196
|
+
open.clear()
|
|
197
|
+
for (const [k, vT] of afterThen) {
|
|
198
|
+
if (afterElse.get(k) === vT) open.set(k, vT)
|
|
199
|
+
}
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (op === 'loop') {
|
|
204
|
+
// Conservative: any tee installed in iter N may not have run in iter N+1
|
|
205
|
+
// before reaching the same site (back-edge to loop header). Clear before+after.
|
|
206
|
+
open.clear()
|
|
207
|
+
for (let i = 1; i < node.length; i++) walk(node[i], node, i)
|
|
208
|
+
open.clear()
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// block / func-body / generic: walk children sequentially.
|
|
213
|
+
for (let i = 0; i < node.length; i++) walk(node[i], node, i)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
for (let i = bodyStart; i < fn.length; i++) walk(fn[i], fn, i)
|
|
217
|
+
|
|
218
|
+
if (regions.size === 0) return
|
|
219
|
+
|
|
220
|
+
// Commit: for each X with ≥1 usable region, allocate one shared local and rewrite.
|
|
221
|
+
// Per-region threshold ≥2 (a singleton would be pure cost).
|
|
222
|
+
let hoistId = 0
|
|
223
|
+
const locals = []
|
|
224
|
+
for (const [, regs] of regions) {
|
|
225
|
+
let usable = false
|
|
226
|
+
for (const r of regs) if (r.length >= 2) { usable = true; break }
|
|
227
|
+
if (!usable) continue
|
|
228
|
+
const tLocal = `$__pt${hoistId++}`
|
|
229
|
+
locals.push(['local', tLocal, 'i32'])
|
|
230
|
+
for (const r of regs) {
|
|
231
|
+
if (r.length < 2) continue
|
|
232
|
+
for (let i = 0; i < r.length; i++) {
|
|
233
|
+
const { parent, idx, role } = r[i]
|
|
234
|
+
if (role === 'tee') parent[idx] = ['local.tee', tLocal, parent[idx]]
|
|
235
|
+
else parent[idx] = ['local.get', tLocal]
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (locals.length) fn.splice(bodyStart, 0, ...locals)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* CSE repeated `(i32.add (local.get $A) (i32.shl (local.get $B) (i32.const K)))`
|
|
244
|
+
* — the shape jz emits for `arr[idx + k]` typed-array reads after foldMemargOffsets
|
|
245
|
+
* absorbs the constant K into `offset=`. The remaining base expression is
|
|
246
|
+
* recomputed once per `arr[…]` read; biquad's inner cascade has 9 such reads
|
|
247
|
+
* sharing 2 base shapes per iteration. V8's CSE usually catches this, but emitting
|
|
248
|
+
* the share explicitly avoids relying on tier-up and helps wasm2c / wasm-opt too.
|
|
249
|
+
*
|
|
250
|
+
* Same region-tracking discipline as hoistPtrType: open region per key, closed
|
|
251
|
+
* by re-assignment to either A or B; loop entry/exit clears all open regions.
|
|
252
|
+
*
|
|
253
|
+
* Must run AFTER fusedRewrite — relies on shl-distribution + assoc-lift +
|
|
254
|
+
* foldMemargOffsets having normalized the base shape.
|
|
255
|
+
*/
|
|
256
|
+
export function hoistAddrBase(fn) {
|
|
257
|
+
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
258
|
+
const bodyStart = findBodyStart(fn)
|
|
259
|
+
if (bodyStart < 0) return
|
|
260
|
+
|
|
261
|
+
// Per key (`$A|$B|K`): array of regions; region: array of {parent, idx, role}.
|
|
262
|
+
const regions = new Map()
|
|
263
|
+
// Open regions keyed by string key; also indexed by local name → set of keys
|
|
264
|
+
// depending on it (so `local.set X` can close any region whose key references X).
|
|
265
|
+
const open = new Map()
|
|
266
|
+
const localToKeys = new Map()
|
|
267
|
+
|
|
268
|
+
const ensureRegions = (k) => {
|
|
269
|
+
let arr = regions.get(k)
|
|
270
|
+
if (!arr) { arr = []; regions.set(k, arr) }
|
|
271
|
+
return arr
|
|
272
|
+
}
|
|
273
|
+
const addLocalDep = (name, key) => {
|
|
274
|
+
let s = localToKeys.get(name)
|
|
275
|
+
if (!s) { s = new Set(); localToKeys.set(name, s) }
|
|
276
|
+
s.add(key)
|
|
277
|
+
}
|
|
278
|
+
const closeKey = (key) => {
|
|
279
|
+
const r = open.get(key)
|
|
280
|
+
if (!r) return
|
|
281
|
+
open.delete(key)
|
|
282
|
+
// Don't bother removing from localToKeys; stale entries are filtered on close.
|
|
283
|
+
}
|
|
284
|
+
const closeForLocal = (name) => {
|
|
285
|
+
const s = localToKeys.get(name)
|
|
286
|
+
if (!s) return
|
|
287
|
+
for (const k of s) if (open.has(k)) closeKey(k)
|
|
288
|
+
localToKeys.delete(name)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Returns { A, B, K } if node matches the pattern, else null.
|
|
292
|
+
const matchPattern = (node) => {
|
|
293
|
+
if (!Array.isArray(node) || node[0] !== 'i32.add' || node.length !== 3) return null
|
|
294
|
+
const a = node[1], b = node[2]
|
|
295
|
+
// Two orderings: (add (get A) (shl (get B) (const K))) or (add (shl …) (get A))
|
|
296
|
+
let baseGet, shlNode
|
|
297
|
+
if (Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' &&
|
|
298
|
+
Array.isArray(b) && b[0] === 'i32.shl' && b.length === 3) {
|
|
299
|
+
baseGet = a; shlNode = b
|
|
300
|
+
} else if (Array.isArray(b) && b[0] === 'local.get' && typeof b[1] === 'string' &&
|
|
301
|
+
Array.isArray(a) && a[0] === 'i32.shl' && a.length === 3) {
|
|
302
|
+
baseGet = b; shlNode = a
|
|
303
|
+
} else return null
|
|
304
|
+
const idx = shlNode[1], shamt = shlNode[2]
|
|
305
|
+
if (!Array.isArray(idx) || idx[0] !== 'local.get' || typeof idx[1] !== 'string') return null
|
|
306
|
+
if (!Array.isArray(shamt) || shamt[0] !== 'i32.const' || typeof shamt[1] !== 'number') return null
|
|
307
|
+
return { A: baseGet[1], B: idx[1], K: shamt[1] }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const walk = (node, parent, pi) => {
|
|
311
|
+
if (!Array.isArray(node)) return
|
|
312
|
+
const op = node[0]
|
|
313
|
+
|
|
314
|
+
const m = matchPattern(node)
|
|
315
|
+
if (m) {
|
|
316
|
+
const key = `${m.A}|${m.B}|${m.K}`
|
|
317
|
+
let region = open.get(key)
|
|
318
|
+
if (!region) {
|
|
319
|
+
region = []
|
|
320
|
+
ensureRegions(key).push(region)
|
|
321
|
+
open.set(key, region)
|
|
322
|
+
addLocalDep(m.A, key)
|
|
323
|
+
addLocalDep(m.B, key)
|
|
324
|
+
region.push({ parent, idx: pi, role: 'tee' })
|
|
325
|
+
} else {
|
|
326
|
+
region.push({ parent, idx: pi, role: 'get' })
|
|
327
|
+
}
|
|
328
|
+
return // children are local.gets — they're reads, not interesting
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if ((op === 'local.set' || op === 'local.tee') && typeof node[1] === 'string') {
|
|
332
|
+
const x = node[1]
|
|
333
|
+
// Walk value first — it may match patterns referencing pre-write X.
|
|
334
|
+
for (let i = 2; i < node.length; i++) walk(node[i], node, i)
|
|
335
|
+
closeForLocal(x)
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (op === 'if') {
|
|
340
|
+
let i = 1
|
|
341
|
+
while (i < node.length && Array.isArray(node[i]) && node[i][0] === 'result') i++
|
|
342
|
+
if (i < node.length) walk(node[i], node, i)
|
|
343
|
+
i++
|
|
344
|
+
let thenArm = null, elseArm = null
|
|
345
|
+
for (; i < node.length; i++) {
|
|
346
|
+
const c = node[i]
|
|
347
|
+
if (Array.isArray(c)) {
|
|
348
|
+
if (c[0] === 'then') thenArm = c
|
|
349
|
+
else if (c[0] === 'else') elseArm = c
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const beforeArms = new Map(open)
|
|
353
|
+
let afterThen = beforeArms
|
|
354
|
+
if (thenArm) {
|
|
355
|
+
for (let j = 1; j < thenArm.length; j++) walk(thenArm[j], thenArm, j)
|
|
356
|
+
afterThen = new Map(open)
|
|
357
|
+
}
|
|
358
|
+
open.clear()
|
|
359
|
+
for (const [k, v] of beforeArms) open.set(k, v)
|
|
360
|
+
let afterElse = beforeArms
|
|
361
|
+
if (elseArm) {
|
|
362
|
+
for (let j = 1; j < elseArm.length; j++) walk(elseArm[j], elseArm, j)
|
|
363
|
+
afterElse = new Map(open)
|
|
364
|
+
}
|
|
365
|
+
open.clear()
|
|
366
|
+
for (const [k, vT] of afterThen) {
|
|
367
|
+
if (afterElse.get(k) === vT) open.set(k, vT)
|
|
368
|
+
}
|
|
369
|
+
return
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (op === 'loop') {
|
|
373
|
+
open.clear()
|
|
374
|
+
for (let i = 1; i < node.length; i++) walk(node[i], node, i)
|
|
375
|
+
open.clear()
|
|
376
|
+
return
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
for (let i = 0; i < node.length; i++) walk(node[i], node, i)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
for (let i = bodyStart; i < fn.length; i++) walk(fn[i], fn, i)
|
|
383
|
+
|
|
384
|
+
if (regions.size === 0) return
|
|
385
|
+
|
|
386
|
+
let hoistId = 0
|
|
387
|
+
const locals = []
|
|
388
|
+
// Find next free $__abN id by scanning existing locals.
|
|
389
|
+
while (fn.some(n => Array.isArray(n) && n[0] === 'local' && n[1] === `$__ab${hoistId}`)) hoistId++
|
|
390
|
+
for (const [, regs] of regions) {
|
|
391
|
+
let usable = false
|
|
392
|
+
for (const r of regs) if (r.length >= 2) { usable = true; break }
|
|
393
|
+
if (!usable) continue
|
|
394
|
+
const tLocal = `$__ab${hoistId++}`
|
|
395
|
+
locals.push(['local', tLocal, 'i32'])
|
|
396
|
+
for (const r of regs) {
|
|
397
|
+
if (r.length < 2) continue
|
|
398
|
+
for (let i = 0; i < r.length; i++) {
|
|
399
|
+
const { parent, idx, role } = r[i]
|
|
400
|
+
if (role === 'tee') parent[idx] = ['local.tee', tLocal, parent[idx]]
|
|
401
|
+
else parent[idx] = ['local.get', tLocal]
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (locals.length) fn.splice(bodyStart, 0, ...locals)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Hoist `(call $__ptr_offset (local.get $X))` to a function-entry snapshot
|
|
410
|
+
* when X is an f64-NaN-boxed parameter that's never reassigned and only ever
|
|
411
|
+
* passed to known-pure helpers. Aos-style hot loops read `rows[i]` once per
|
|
412
|
+
* iteration; without this, V8 keeps re-extracting the offset each time.
|
|
413
|
+
*
|
|
414
|
+
* Safety: __ptr_offset on an Array follows the realloc-forwarding chain. Once
|
|
415
|
+
* a function commits to "this param won't realloc inside me", caching is
|
|
416
|
+
* sound for the duration. The whitelist below is the read-only set
|
|
417
|
+
* (no mutation possible); any other callee touching X invalidates hoisting.
|
|
418
|
+
*/
|
|
419
|
+
const SAFE_OFFSET_CALLS = new Set(['$__ptr_offset', '$__ptr_type', '$__ptr_aux', '$__len'])
|
|
420
|
+
|
|
421
|
+
export function hoistInvariantPtrOffset(fn) {
|
|
422
|
+
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
423
|
+
const bodyStart = findBodyStart(fn)
|
|
424
|
+
if (bodyStart < 0) return
|
|
425
|
+
|
|
426
|
+
const params = new Set()
|
|
427
|
+
for (let i = 2; i < fn.length; i++) {
|
|
428
|
+
const c = fn[i]
|
|
429
|
+
if (!Array.isArray(c)) continue
|
|
430
|
+
if (c[0] !== 'param') continue
|
|
431
|
+
if (typeof c[1] === 'string' && c[2] === 'f64') params.add(c[1])
|
|
432
|
+
}
|
|
433
|
+
if (!params.size) return
|
|
434
|
+
|
|
435
|
+
const sites = new Map()
|
|
436
|
+
const unsafe = new Set()
|
|
437
|
+
|
|
438
|
+
const walk = (node, parent, pi) => {
|
|
439
|
+
if (!Array.isArray(node)) return
|
|
440
|
+
const op = node[0]
|
|
441
|
+
|
|
442
|
+
if (op === 'local.set' || op === 'local.tee') {
|
|
443
|
+
if (typeof node[1] === 'string' && params.has(node[1])) unsafe.add(node[1])
|
|
444
|
+
for (let i = 2; i < node.length; i++) walk(node[i], node, i)
|
|
445
|
+
return
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (op === 'call') {
|
|
449
|
+
const callee = node[1]
|
|
450
|
+
if (callee === '$__ptr_offset' && node.length === 3) {
|
|
451
|
+
const a = node[2]
|
|
452
|
+
if (Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' && params.has(a[1])) {
|
|
453
|
+
let arr = sites.get(a[1])
|
|
454
|
+
if (!arr) { arr = []; sites.set(a[1], arr) }
|
|
455
|
+
arr.push({ parent, idx: pi })
|
|
456
|
+
return
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
const isSafe = SAFE_OFFSET_CALLS.has(callee)
|
|
460
|
+
for (let i = 2; i < node.length; i++) {
|
|
461
|
+
const arg = node[i]
|
|
462
|
+
if (Array.isArray(arg) && arg[0] === 'local.get' && typeof arg[1] === 'string' && params.has(arg[1])) {
|
|
463
|
+
if (!isSafe) unsafe.add(arg[1])
|
|
464
|
+
continue
|
|
465
|
+
}
|
|
466
|
+
walk(arg, node, i)
|
|
467
|
+
}
|
|
468
|
+
return
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (op === 'call_indirect' || op === 'call_ref') {
|
|
472
|
+
for (let i = 1; i < node.length; i++) {
|
|
473
|
+
const arg = node[i]
|
|
474
|
+
if (Array.isArray(arg) && arg[0] === 'local.get' && typeof arg[1] === 'string' && params.has(arg[1])) {
|
|
475
|
+
unsafe.add(arg[1])
|
|
476
|
+
continue
|
|
477
|
+
}
|
|
478
|
+
walk(arg, node, i)
|
|
479
|
+
}
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
for (let i = 0; i < node.length; i++) walk(node[i], node, i)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
for (let i = bodyStart; i < fn.length; i++) walk(fn[i], fn, i)
|
|
487
|
+
|
|
488
|
+
if (sites.size === 0) return
|
|
489
|
+
|
|
490
|
+
let hoistId = 0
|
|
491
|
+
while (fn.some(n => Array.isArray(n) && n[0] === 'local' && n[1] === `$__po${hoistId}`)) hoistId++
|
|
492
|
+
|
|
493
|
+
const newLocals = []
|
|
494
|
+
const snaps = []
|
|
495
|
+
for (const [X, arr] of sites) {
|
|
496
|
+
if (unsafe.has(X)) continue
|
|
497
|
+
if (arr.length < 2) continue
|
|
498
|
+
const tLocal = `$__po${hoistId++}`
|
|
499
|
+
newLocals.push(['local', tLocal, 'i32'])
|
|
500
|
+
snaps.push(['local.set', tLocal, ['call', '$__ptr_offset', ['local.get', X]]])
|
|
501
|
+
for (const { parent, idx } of arr) {
|
|
502
|
+
parent[idx] = ['local.get', tLocal]
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals, ...snaps)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Hoist loop-invariant boxed-cell reads out of loops.
|
|
511
|
+
*
|
|
512
|
+
* Boxed-capture cells (`$cell_X`, allocated by the closure-capture pass) are
|
|
513
|
+
* private to the enclosing function — no other code path can write to that
|
|
514
|
+
* memory. So if a loop body contains `(f64.load (local.get $cell_X))` reads
|
|
515
|
+
* and *no* `(f64.store (local.get $cell_X) …)` writes, the load is loop-
|
|
516
|
+
* invariant and can be hoisted to a snapshot local set just before the loop.
|
|
517
|
+
*
|
|
518
|
+
* Necessary because V8's wasm tier doesn't perform LICM across f64.load:
|
|
519
|
+
* memory may alias with f64.stores in the loop body, and even though we
|
|
520
|
+
* know the cell can't alias with array stores, the engine has to assume it
|
|
521
|
+
* can. Hand-hoisting unblocks register-keeping of the captured value.
|
|
522
|
+
*
|
|
523
|
+
* Inside-out per-loop processing — inner loops handled first, so reads
|
|
524
|
+
* already replaced by snap-locals don't appear as cell reads at outer levels.
|
|
525
|
+
*/
|
|
526
|
+
export function hoistInvariantCellLoads(fn) {
|
|
527
|
+
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
528
|
+
const bodyStart = findBodyStart(fn)
|
|
529
|
+
if (bodyStart < 0) return
|
|
530
|
+
|
|
531
|
+
let snapId = 0
|
|
532
|
+
while (fn.some(n => Array.isArray(n) && n[0] === 'local' && n[1] === `$__sc${snapId}`)) snapId++
|
|
533
|
+
const newLocals = []
|
|
534
|
+
|
|
535
|
+
// Build refcount of array nodes: how many positions in `fn` reference each
|
|
536
|
+
// array. Earlier passes (fusedRewrite, hoistAddrBase) introduce shared
|
|
537
|
+
// subtrees; mutating `parent[idx]` for a shared parent would also affect
|
|
538
|
+
// references outside the current loop. Sites whose immediate parent has
|
|
539
|
+
// refcount > 1 are skipped.
|
|
540
|
+
const refcount = new Map()
|
|
541
|
+
const countRefs = (node) => {
|
|
542
|
+
if (!Array.isArray(node)) return
|
|
543
|
+
const n = (refcount.get(node) || 0) + 1
|
|
544
|
+
refcount.set(node, n)
|
|
545
|
+
if (n > 1) return // already counted children below
|
|
546
|
+
for (let i = 0; i < node.length; i++) countRefs(node[i])
|
|
547
|
+
}
|
|
548
|
+
countRefs(fn)
|
|
549
|
+
|
|
550
|
+
// Process one loop node: find cell_X reads, check no writes, hoist.
|
|
551
|
+
// Returns { snapDecls } — list of (local.set $snap (f64.load (local.get $cell_X))) IR
|
|
552
|
+
// to emit before the loop in its parent.
|
|
553
|
+
const processLoop = (loopNode) => {
|
|
554
|
+
// Recurse first — inner loops handled bottom-up. Each inner-loop processor
|
|
555
|
+
// returns a list of pre-loop snap decls; we splice them just before the inner
|
|
556
|
+
// loop within this loop's body.
|
|
557
|
+
for (let i = 1; i < loopNode.length; i++) {
|
|
558
|
+
const child = loopNode[i]
|
|
559
|
+
if (!Array.isArray(child)) continue
|
|
560
|
+
processNode(child, loopNode, i)
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Scan this loop's body for cell reads & writes (excluding nested loop bodies,
|
|
564
|
+
// since their reads were already hoisted at their level).
|
|
565
|
+
const reads = new Map() // cellName → array of {parent, idx}
|
|
566
|
+
const writes = new Set()
|
|
567
|
+
let hasCall = false
|
|
568
|
+
const scanWrites = (node) => {
|
|
569
|
+
if (!Array.isArray(node)) return
|
|
570
|
+
const op = node[0]
|
|
571
|
+
if (op === 'call' || op === 'call_ref' || op === 'call_indirect') {
|
|
572
|
+
hasCall = true
|
|
573
|
+
}
|
|
574
|
+
// DESCEND into nested loops here — we need to know if any nested-loop
|
|
575
|
+
// body writes to cell_X (which would invalidate hoisting THIS loop's reads).
|
|
576
|
+
if (op === 'f64.store' && node.length >= 3) {
|
|
577
|
+
const addr = node[1]
|
|
578
|
+
if (Array.isArray(addr) && addr[0] === 'local.get' && typeof addr[1] === 'string'
|
|
579
|
+
&& addr[1].startsWith('$cell_')) {
|
|
580
|
+
writes.add(addr[1])
|
|
581
|
+
}
|
|
582
|
+
// Continue scan into value expr
|
|
583
|
+
for (let i = 2; i < node.length; i++) scanWrites(node[i])
|
|
584
|
+
return
|
|
585
|
+
}
|
|
586
|
+
if (op === 'f64.load' && node.length === 2) {
|
|
587
|
+
const addr = node[1]
|
|
588
|
+
if (Array.isArray(addr) && addr[0] === 'local.get' && typeof addr[1] === 'string'
|
|
589
|
+
&& addr[1].startsWith('$cell_')) {
|
|
590
|
+
// Defer; we'll handle in a parent-tracking second pass.
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
for (let i = 1; i < node.length; i++) scanWrites(node[i])
|
|
594
|
+
}
|
|
595
|
+
for (let i = 1; i < loopNode.length; i++) scanWrites(loopNode[i])
|
|
596
|
+
// Sound bailout: a call inside the loop could mutate a captured cell
|
|
597
|
+
// via a closure we can't see. Without escape analysis we can't prove
|
|
598
|
+
// non-aliasing, so we skip hoisting from any loop containing calls.
|
|
599
|
+
if (hasCall) return []
|
|
600
|
+
|
|
601
|
+
// Parent-tracking pass to collect read sites.
|
|
602
|
+
const collect = (node, parent, idx) => {
|
|
603
|
+
if (!Array.isArray(node)) return
|
|
604
|
+
const op = node[0]
|
|
605
|
+
if (op === 'loop') return
|
|
606
|
+
if (op === 'f64.load' && node.length === 2) {
|
|
607
|
+
const addr = node[1]
|
|
608
|
+
if (Array.isArray(addr) && addr[0] === 'local.get' && typeof addr[1] === 'string'
|
|
609
|
+
&& addr[1].startsWith('$cell_')) {
|
|
610
|
+
const cell = addr[1]
|
|
611
|
+
// Skip if the f64.load node or its immediate parent is shared
|
|
612
|
+
// (refcount>1): mutating parent[idx] would propagate the rewrite to
|
|
613
|
+
// references outside this loop.
|
|
614
|
+
if (!writes.has(cell)
|
|
615
|
+
&& (refcount.get(node) || 0) <= 1
|
|
616
|
+
&& (refcount.get(parent) || 0) <= 1) {
|
|
617
|
+
let arr = reads.get(cell)
|
|
618
|
+
if (!arr) { arr = []; reads.set(cell, arr) }
|
|
619
|
+
arr.push({ parent, idx })
|
|
620
|
+
}
|
|
621
|
+
return
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
for (let i = 0; i < node.length; i++) collect(node[i], node, i)
|
|
625
|
+
}
|
|
626
|
+
for (let i = 1; i < loopNode.length; i++) collect(loopNode[i], loopNode, i)
|
|
627
|
+
|
|
628
|
+
// For each cell with reads but no writes (and confirmed no calls above),
|
|
629
|
+
// hoist a snap. Single-read hoist is fine semantically: the cell address
|
|
630
|
+
// doesn't change once allocated, and snap is loaded unconditionally before
|
|
631
|
+
// the loop, then the body uses the snap local.
|
|
632
|
+
const snaps = []
|
|
633
|
+
for (const [cell, sites] of reads) {
|
|
634
|
+
if (sites.length < 1) continue
|
|
635
|
+
const snapName = `$__sc${snapId++}`
|
|
636
|
+
newLocals.push(['local', snapName, 'f64'])
|
|
637
|
+
snaps.push(['local.set', snapName, ['f64.load', ['local.get', cell]]])
|
|
638
|
+
for (const { parent, idx } of sites) {
|
|
639
|
+
parent[idx] = ['local.get', snapName]
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return snaps
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Recursive node walker that splices snap decls before nested loops.
|
|
646
|
+
const processNode = (node, parent, idx) => {
|
|
647
|
+
if (!Array.isArray(node)) return
|
|
648
|
+
const op = node[0]
|
|
649
|
+
if (op === 'loop') {
|
|
650
|
+
const snaps = processLoop(node)
|
|
651
|
+
if (snaps.length) {
|
|
652
|
+
// Splice snaps just before this loop in its parent. The parent could be
|
|
653
|
+
// a `block` or a top-level func body or any other container.
|
|
654
|
+
parent.splice(idx, 0, ...snaps)
|
|
655
|
+
}
|
|
656
|
+
return
|
|
657
|
+
}
|
|
658
|
+
for (let i = 0; i < node.length; i++) processNode(node[i], node, i)
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
for (let i = bodyStart; i < fn.length; i++) {
|
|
662
|
+
processNode(fn[i], fn, i)
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Find the index of the first body-content child in a func node.
|
|
670
|
+
* Skips `$name`, (export …), (import …), (type …), (param …), (result …), (local …).
|
|
671
|
+
*/
|
|
672
|
+
function findBodyStart(fn) {
|
|
673
|
+
for (let i = 2; i < fn.length; i++) {
|
|
674
|
+
const c = fn[i]
|
|
675
|
+
if (!Array.isArray(c)) continue
|
|
676
|
+
if (c[0] === 'export' || c[0] === 'import' || c[0] === 'type' || c[0] === 'param' || c[0] === 'result' || c[0] === 'local') continue
|
|
677
|
+
return i
|
|
678
|
+
}
|
|
679
|
+
return fn.length
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Hoist frequently-repeated f64 constants into mutable globals.
|
|
684
|
+
* f64.const is 9 bytes; global.get with idx<128 is 2 bytes — saves 7 B per reuse.
|
|
685
|
+
* Pool entries sorted by usage descending, so hottest get lowest indices (1-byte LEB128).
|
|
686
|
+
* Break-even: N ≥ 2 uses (pool cost: 11 B global decl + 2N bytes vs 9N original).
|
|
687
|
+
*
|
|
688
|
+
* Mutates `funcs` in place; writes new global decls via `addGlobal(name, watString)`.
|
|
689
|
+
*/
|
|
690
|
+
export function hoistConstantPool(funcs, addGlobal) {
|
|
691
|
+
const MIN_USES = 2
|
|
692
|
+
// Single walk: count occurrences AND record each f64.const site for direct rewrite.
|
|
693
|
+
// Avoids a second full-AST traversal in the rewrite phase.
|
|
694
|
+
const counts = new Map()
|
|
695
|
+
const sites = [] // { parent, idx, key }
|
|
696
|
+
const walk = (node) => {
|
|
697
|
+
if (!Array.isArray(node)) return
|
|
698
|
+
for (let i = 0; i < node.length; i++) {
|
|
699
|
+
const c = node[i]
|
|
700
|
+
if (Array.isArray(c) && c[0] === 'f64.const' && (typeof c[1] === 'number' || typeof c[1] === 'string')) {
|
|
701
|
+
const k = typeof c[1] === 'number' ? `n:${c[1]}` : `s:${c[1]}`
|
|
702
|
+
counts.set(k, (counts.get(k) || 0) + 1)
|
|
703
|
+
sites.push({ parent: node, idx: i, key: k })
|
|
704
|
+
}
|
|
705
|
+
walk(c)
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
for (let i = 0; i < funcs.length; i++) walk(funcs[i])
|
|
709
|
+
|
|
710
|
+
const hoist = new Map()
|
|
711
|
+
const sorted = [...counts].filter(([, n]) => n >= MIN_USES).sort((a, b) => b[1] - a[1])
|
|
712
|
+
let gId = 0
|
|
713
|
+
for (const [k] of sorted) {
|
|
714
|
+
const name = `__fc${gId++}`
|
|
715
|
+
const lit = k.slice(2)
|
|
716
|
+
addGlobal(name, `(global $${name} (mut f64) (f64.const ${lit}))`)
|
|
717
|
+
hoist.set(k, name)
|
|
718
|
+
}
|
|
719
|
+
if (!hoist.size) return
|
|
720
|
+
|
|
721
|
+
// Rewrite recorded sites directly. Idempotent: if parent[idx] is no longer the
|
|
722
|
+
// f64.const we recorded (shared subtrees), skip.
|
|
723
|
+
for (let i = 0; i < sites.length; i++) {
|
|
724
|
+
const { parent, idx, key } = sites[i]
|
|
725
|
+
const g = hoist.get(key)
|
|
726
|
+
if (!g) continue
|
|
727
|
+
const c = parent[idx]
|
|
728
|
+
if (!Array.isArray(c) || c[0] !== 'f64.const') continue
|
|
729
|
+
parent[idx] = ['global.get', `$${g}`]
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Specialize `(call $F arg1 arg2 …)` call sites by literal-arg signature.
|
|
735
|
+
*
|
|
736
|
+
* For each call target with a stable (param-types, result-type) signature,
|
|
737
|
+
* scan all call sites and group by "literal-arg signature" (which args are
|
|
738
|
+
* `i32.const N` literals vs runtime-dynamic). For groups with ≥ MIN_USES, emit
|
|
739
|
+
* a specialized trampoline `$F_L1_L2_…` that bakes literals into the call:
|
|
740
|
+
*
|
|
741
|
+
* (func $F_L1_L2 (param $a2 T2) (result R)
|
|
742
|
+
* (call $F (i32.const L1) (local.get $a2)))
|
|
743
|
+
*
|
|
744
|
+
* Call sites are rewritten `(call $F (i32.const L1) a2)` → `(call $F_L1_L2 a2)`.
|
|
745
|
+
* Savings per site: ~2 B per dropped literal arg.
|
|
746
|
+
*
|
|
747
|
+
* For `$__mkptr`, every combo has type+aux literal so we special-case the body:
|
|
748
|
+
* fold the prefix into `(i64.const TEMPLATE)` instead of a trampoline call —
|
|
749
|
+
* avoids a runtime indirection for the hottest path.
|
|
750
|
+
*
|
|
751
|
+
* @param funcs — flat list of func IR nodes (sec.funcs + sec.stdlib + sec.start)
|
|
752
|
+
* @param addFunc — callback `(watString) => void` to register new helpers
|
|
753
|
+
* @param parseWat — `wat → IR` parser (injected to avoid circular imports)
|
|
754
|
+
*/
|
|
755
|
+
export function specializeMkptr(funcs, addFunc, parseWat) {
|
|
756
|
+
// Per-target specification: param-types, result-type. Threshold tuned so helper cost amortizes.
|
|
757
|
+
// Any target not listed here is left untouched. Order matters only for readability.
|
|
758
|
+
const SPECS = {
|
|
759
|
+
'$__mkptr': { params: ['i32', 'i32', 'i32'], result: 'f64', inline: true },
|
|
760
|
+
'$__alloc_hdr': { params: ['i32', 'i32', 'i32'], result: 'i32' },
|
|
761
|
+
'$__typed_idx': { params: ['f64', 'i32'], result: 'f64' },
|
|
762
|
+
'$__str_idx': { params: ['f64', 'i32'], result: 'f64' },
|
|
763
|
+
}
|
|
764
|
+
const MIN_USES = 5
|
|
765
|
+
|
|
766
|
+
// Build literal-arg signature key for a call node. Returns null if no args are literal.
|
|
767
|
+
// Key format: 'T:V' per literal arg, 'D' per dynamic; indexed by position.
|
|
768
|
+
const sigKey = (call, nParams) => {
|
|
769
|
+
const key = []
|
|
770
|
+
let anyLit = false
|
|
771
|
+
for (let i = 0; i < nParams; i++) {
|
|
772
|
+
const a = call[2 + i]
|
|
773
|
+
if (Array.isArray(a) && a[0] === 'i32.const' && typeof a[1] === 'number') { key.push('L:' + a[1]); anyLit = true }
|
|
774
|
+
else key.push('D')
|
|
775
|
+
}
|
|
776
|
+
return anyLit ? key.join('|') : null
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// Pass 1: count per (target, sig) AND record candidate site locations for direct
|
|
780
|
+
// rewrite in pass 3. Pre-order push means nested candidates appear later in `sites`,
|
|
781
|
+
// so reverse iteration in pass 3 yields leaf-first rewrite order (inner before outer).
|
|
782
|
+
const counts = new Map() // 'target##sig' → count
|
|
783
|
+
const sites = [] // { parent, idx, fullKey, parts }
|
|
784
|
+
const walk = (node, parent, idx) => {
|
|
785
|
+
if (!Array.isArray(node)) return
|
|
786
|
+
if (parent && node[0] === 'call' && typeof node[1] === 'string' && SPECS[node[1]]) {
|
|
787
|
+
const spec = SPECS[node[1]]
|
|
788
|
+
if (node.length === 2 + spec.params.length) {
|
|
789
|
+
const k = sigKey(node, spec.params.length)
|
|
790
|
+
if (k) {
|
|
791
|
+
const fullKey = node[1] + '##' + k
|
|
792
|
+
counts.set(fullKey, (counts.get(fullKey) || 0) + 1)
|
|
793
|
+
sites.push({ parent, idx, fullKey, parts: k.split('|') })
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
for (let i = 0; i < node.length; i++) walk(node[i], node, i)
|
|
798
|
+
}
|
|
799
|
+
for (let i = 0; i < funcs.length; i++) walk(funcs[i], null, 0)
|
|
800
|
+
|
|
801
|
+
// Pass 2: for each eligible (target, sig), emit helper.
|
|
802
|
+
const specialized = new Set()
|
|
803
|
+
for (const [k, n] of counts) if (n >= MIN_USES) specialized.add(k)
|
|
804
|
+
if (!specialized.size) return
|
|
805
|
+
|
|
806
|
+
const variantName = (target, sigParts) => target.slice(1) + '_' + sigParts
|
|
807
|
+
.map(p => p === 'D' ? 'd' : p.slice(2)).join('_')
|
|
808
|
+
|
|
809
|
+
for (const fullKey of specialized) {
|
|
810
|
+
const [target, sig] = fullKey.split('##')
|
|
811
|
+
const parts = sig.split('|')
|
|
812
|
+
const spec = SPECS[target]
|
|
813
|
+
const name = variantName(target, parts)
|
|
814
|
+
|
|
815
|
+
// $__mkptr inline fast path: bake (type, aux) literals into i64.const template.
|
|
816
|
+
if (target === '$__mkptr' && spec.inline && parts[0].startsWith('L:') && parts[1].startsWith('L:')) {
|
|
817
|
+
const type = +parts[0].slice(2), aux = +parts[1].slice(2)
|
|
818
|
+
const tmpl = (NAN_PREFIX_BITS << 48n)
|
|
819
|
+
| ((BigInt(type) & 0xFn) << 47n)
|
|
820
|
+
| ((BigInt(aux) & 0x7FFFn) << 32n)
|
|
821
|
+
// Third arg (offset) may also be literal — emit (f64.const nan:…) then.
|
|
822
|
+
if (parts[2].startsWith('L:')) {
|
|
823
|
+
// Fully literal: all sites can be f64.const — no helper needed, handled in rewrite below.
|
|
824
|
+
continue
|
|
825
|
+
}
|
|
826
|
+
addFunc(`(func $${name} (param $o i32) (result f64)
|
|
827
|
+
(f64.reinterpret_i64 (i64.or (i64.const 0x${tmpl.toString(16).toUpperCase()}) (i64.extend_i32_u (local.get $o)))))`)
|
|
828
|
+
continue
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Generic trampoline: (func $F_LITS (param …dyn) (result R) (call $F lits+dyn))
|
|
832
|
+
const dynArgs = []
|
|
833
|
+
const callArgs = []
|
|
834
|
+
for (let i = 0; i < parts.length; i++) {
|
|
835
|
+
if (parts[i].startsWith('L:')) {
|
|
836
|
+
callArgs.push(`(i32.const ${parts[i].slice(2)})`)
|
|
837
|
+
} else {
|
|
838
|
+
dynArgs.push(`(param $a${i} ${spec.params[i]})`)
|
|
839
|
+
callArgs.push(`(local.get $a${i})`)
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
addFunc(`(func $${name} ${dynArgs.join(' ')} (result ${spec.result}) (call ${target} ${callArgs.join(' ')}))`)
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Pass 3: rewrite recorded sites in reverse (leaf-first since pass 1 was pre-order).
|
|
846
|
+
// Iterating the captured site list avoids a second full-AST walk.
|
|
847
|
+
// Idempotency guard: shared subtrees in the IR cause the same (parent, idx) to be
|
|
848
|
+
// recorded as multiple sites. The first visit rewrites; subsequent visits see the
|
|
849
|
+
// rewritten call (target no longer in SPECS) and skip — same behavior as the
|
|
850
|
+
// recursive rewrite this replaces.
|
|
851
|
+
for (let i = sites.length - 1; i >= 0; i--) {
|
|
852
|
+
const { parent, idx, fullKey, parts } = sites[i]
|
|
853
|
+
if (!specialized.has(fullKey)) continue
|
|
854
|
+
const c = parent[idx]
|
|
855
|
+
const target = c[1]
|
|
856
|
+
const spec = SPECS[target]
|
|
857
|
+
if (!spec || c.length !== 2 + spec.params.length) continue
|
|
858
|
+
|
|
859
|
+
// $__mkptr fully literal (rare — mkPtrIR usually folds these ahead of us, but defensive):
|
|
860
|
+
if (target === '$__mkptr' && parts[0].startsWith('L:') && parts[1].startsWith('L:') && parts[2].startsWith('L:')) {
|
|
861
|
+
const type = +parts[0].slice(2), aux = +parts[1].slice(2), off = +parts[2].slice(2)
|
|
862
|
+
const bits = (NAN_PREFIX_BITS << 48n)
|
|
863
|
+
| ((BigInt(type) & 0xFn) << 47n)
|
|
864
|
+
| ((BigInt(aux) & 0x7FFFn) << 32n)
|
|
865
|
+
| (BigInt(off >>> 0) & 0xFFFFFFFFn)
|
|
866
|
+
const n = ['f64.const', 'nan:0x' + bits.toString(16).toUpperCase().padStart(16, '0')]
|
|
867
|
+
n.type = 'f64'
|
|
868
|
+
parent[idx] = n
|
|
869
|
+
continue
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const name = variantName(target, parts)
|
|
873
|
+
const dynArgs = []
|
|
874
|
+
for (let j = 0; j < parts.length; j++) if (parts[j] === 'D') dynArgs.push(c[2 + j])
|
|
875
|
+
const newCall = ['call', '$' + name, ...dynArgs]
|
|
876
|
+
newCall.type = spec.result
|
|
877
|
+
parent[idx] = newCall
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Specialize `(call $F (i32.add (global.get $G) (i32.const N)))` → `(call $F_rel_$G (i32.const N))`.
|
|
883
|
+
* Helper bakes `(global.get $G) + i32.add` into its body so call sites drop those 3 B.
|
|
884
|
+
* Targets any single-arg call whose arg is `add(global_base, const)` — in practice: $__mkptr_X_Y_d
|
|
885
|
+
* specializations against $__strBase (watr self-host: ~2193 sites × 3 B ≈ 6.5 KB).
|
|
886
|
+
*
|
|
887
|
+
* @param funcs — flat list of func IR nodes
|
|
888
|
+
* @param addFunc — callback `(watString) => void` to register new helpers
|
|
889
|
+
* @param parseWat — `wat → IR` parser (injected)
|
|
890
|
+
*/
|
|
891
|
+
export function specializePtrBase(funcs, addFunc, parseWat) {
|
|
892
|
+
const MIN_USES = 20
|
|
893
|
+
|
|
894
|
+
// Pass 1: count (targetFunc, baseGlobal) pairs AND record candidate sites for direct
|
|
895
|
+
// rewrite in pass 3 (avoids a second full-AST walk).
|
|
896
|
+
const counts = new Map() // 'F##G' → count
|
|
897
|
+
const sites = [] // { parent, idx, key }
|
|
898
|
+
const walk = (node, parent, idx) => {
|
|
899
|
+
if (!Array.isArray(node)) return
|
|
900
|
+
if (parent && node[0] === 'call' && typeof node[1] === 'string' && node.length === 3) {
|
|
901
|
+
const arg = node[2]
|
|
902
|
+
if (Array.isArray(arg) && arg[0] === 'i32.add' && arg.length === 3 &&
|
|
903
|
+
Array.isArray(arg[1]) && arg[1][0] === 'global.get' && typeof arg[1][1] === 'string' &&
|
|
904
|
+
Array.isArray(arg[2]) && arg[2][0] === 'i32.const') {
|
|
905
|
+
const k = node[1] + '##' + arg[1][1]
|
|
906
|
+
counts.set(k, (counts.get(k) || 0) + 1)
|
|
907
|
+
sites.push({ parent, idx, key: k })
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
for (let i = 0; i < node.length; i++) walk(node[i], node, i)
|
|
911
|
+
}
|
|
912
|
+
for (let i = 0; i < funcs.length; i++) walk(funcs[i], null, 0)
|
|
913
|
+
|
|
914
|
+
const specialized = new Set()
|
|
915
|
+
for (const [k, n] of counts) if (n >= MIN_USES) specialized.add(k)
|
|
916
|
+
if (!specialized.size) return
|
|
917
|
+
|
|
918
|
+
// Find a target func's result-type by locating its decl among `funcs`.
|
|
919
|
+
const funcByName = new Map()
|
|
920
|
+
for (let i = 0; i < funcs.length; i++) {
|
|
921
|
+
const fn = funcs[i]
|
|
922
|
+
if (Array.isArray(fn) && fn[0] === 'func' && typeof fn[1] === 'string') funcByName.set(fn[1], fn)
|
|
923
|
+
}
|
|
924
|
+
const resultOf = (name) => {
|
|
925
|
+
const fn = funcByName.get(name)
|
|
926
|
+
if (!fn) return 'f64' // defensive; mkptr specializations all return f64
|
|
927
|
+
for (let i = 2; i < fn.length; i++) {
|
|
928
|
+
const c = fn[i]
|
|
929
|
+
if (Array.isArray(c) && c[0] === 'result') return c[1]
|
|
930
|
+
if (Array.isArray(c) && c[0] !== 'param') break
|
|
931
|
+
}
|
|
932
|
+
return 'f64'
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const sanit = (g) => g.replace(/^\$/, '').replace(/[^a-zA-Z0-9_]/g, '_')
|
|
936
|
+
const variantFor = (F, G) => `${F}_rel_${sanit(G)}`
|
|
937
|
+
|
|
938
|
+
// Pass 2: emit helpers.
|
|
939
|
+
for (const fullKey of specialized) {
|
|
940
|
+
const [F, G] = fullKey.split('##')
|
|
941
|
+
const rt = resultOf(F)
|
|
942
|
+
const name = variantFor(F, G)
|
|
943
|
+
addFunc(`(func ${name} (param $o i32) (result ${rt}) (call ${F} (i32.add (global.get ${G}) (local.get $o))))`)
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// Pass 3: rewrite recorded sites in reverse (leaf-first since pass 1 was pre-order).
|
|
947
|
+
// Idempotency guard: shared IR subtrees can record the same (parent, idx) twice.
|
|
948
|
+
// The first visit rewrites to a 2-arg call; subsequent visits see a shape that
|
|
949
|
+
// doesn't match the original `call F (i32.add (global.get) (i32.const))` pattern.
|
|
950
|
+
for (let i = sites.length - 1; i >= 0; i--) {
|
|
951
|
+
const { parent, idx, key } = sites[i]
|
|
952
|
+
if (!specialized.has(key)) continue
|
|
953
|
+
const c = parent[idx]
|
|
954
|
+
if (!Array.isArray(c) || c[0] !== 'call' || c.length !== 3) continue
|
|
955
|
+
const arg = c[2]
|
|
956
|
+
if (!Array.isArray(arg) || arg[0] !== 'i32.add' || arg.length !== 3) continue
|
|
957
|
+
if (!Array.isArray(arg[1]) || arg[1][0] !== 'global.get') continue
|
|
958
|
+
if (!Array.isArray(arg[2]) || arg[2][0] !== 'i32.const') continue
|
|
959
|
+
const F = c[1]
|
|
960
|
+
const G = arg[1][1]
|
|
961
|
+
const konst = arg[2]
|
|
962
|
+
const newCall = ['call', variantFor(F, G), konst]
|
|
963
|
+
newCall.type = resultOf(F)
|
|
964
|
+
parent[idx] = newCall
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* Reorder strings in `strPool` so most-referenced strings get low byte offsets.
|
|
970
|
+
* Each string ref is encoded as `(i32.const off)` with ULEB128: 1 B for off<128, 2 B for off<16384, 3 B for off<2M.
|
|
971
|
+
* Frequent strings migrating from 3-B to 2-B (or 2-B to 1-B) LEB128 saves ~541 B on watr self-host.
|
|
972
|
+
*
|
|
973
|
+
* Pool layout: `[4-byte-len][data-bytes][4-byte-len][data-bytes]...`. Offsets in refs point PAST the len prefix.
|
|
974
|
+
*
|
|
975
|
+
* @param funcs — flat list of func IR nodes (scanned for refs)
|
|
976
|
+
* @param strPoolRef — `{ pool: string }` holder; pool is rewritten in place
|
|
977
|
+
* @param strDedupMap — optional `Map<string, offset>` to update (kept consistent for later queries)
|
|
978
|
+
*/
|
|
979
|
+
export function sortStrPoolByFreq(funcs, strPoolRef, strDedupMap) {
|
|
980
|
+
if (!strPoolRef.pool) return
|
|
981
|
+
// Match both specialized and unspecialized strBase refs.
|
|
982
|
+
const isSpecRef = (n) =>
|
|
983
|
+
Array.isArray(n) && n[0] === 'call' && typeof n[1] === 'string' && n[1].includes('_rel___strBase') &&
|
|
984
|
+
n.length === 3 && Array.isArray(n[2]) && n[2][0] === 'i32.const'
|
|
985
|
+
const isUnspecRef = (n) =>
|
|
986
|
+
Array.isArray(n) && n[0] === 'call' && typeof n[1] === 'string' && n[1].startsWith('$__mkptr_') &&
|
|
987
|
+
n.length === 3 && Array.isArray(n[2]) && n[2][0] === 'i32.add' && n[2].length === 3 &&
|
|
988
|
+
Array.isArray(n[2][1]) && n[2][1][0] === 'global.get' && n[2][1][1] === '$__strBase' &&
|
|
989
|
+
Array.isArray(n[2][2]) && n[2][2][0] === 'i32.const'
|
|
990
|
+
const getOff = (n) => isSpecRef(n) ? (n[2][1] | 0) : isUnspecRef(n) ? (n[2][2][1] | 0) : null
|
|
991
|
+
const setOff = (n, v) => { if (isSpecRef(n)) n[2][1] = v; else if (isUnspecRef(n)) n[2][2][1] = v }
|
|
992
|
+
|
|
993
|
+
// Single walk: count freq AND record each ref site for direct rewrite.
|
|
994
|
+
const freq = new Map()
|
|
995
|
+
const sites = [] // { node, oldOff } — node is the ref node, mutate offset in place
|
|
996
|
+
const walk = (n) => {
|
|
997
|
+
if (!Array.isArray(n)) return
|
|
998
|
+
const o = getOff(n)
|
|
999
|
+
if (o !== null) { freq.set(o, (freq.get(o) || 0) + 1); sites.push({ node: n, oldOff: o }) }
|
|
1000
|
+
for (let i = 0; i < n.length; i++) walk(n[i])
|
|
1001
|
+
}
|
|
1002
|
+
for (let i = 0; i < funcs.length; i++) walk(funcs[i])
|
|
1003
|
+
if (!freq.size) return
|
|
1004
|
+
|
|
1005
|
+
// Parse pool structure into entries.
|
|
1006
|
+
const pool = strPoolRef.pool
|
|
1007
|
+
const entries = []
|
|
1008
|
+
let i = 0
|
|
1009
|
+
while (i < pool.length) {
|
|
1010
|
+
const len = pool.charCodeAt(i) | (pool.charCodeAt(i+1) << 8) | (pool.charCodeAt(i+2) << 16) | (pool.charCodeAt(i+3) << 24)
|
|
1011
|
+
const oldOff = i + 4
|
|
1012
|
+
entries.push({ oldOff, len, str: pool.substring(oldOff, oldOff + len) })
|
|
1013
|
+
i = oldOff + len
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// Sort by freq descending; tie-break by length ascending (pack short hot strings into low-offset range).
|
|
1017
|
+
entries.sort((a, b) => (freq.get(b.oldOff) || 0) - (freq.get(a.oldOff) || 0) || a.len - b.len)
|
|
1018
|
+
|
|
1019
|
+
// Rebuild pool; map old → new offsets.
|
|
1020
|
+
const remap = new Map()
|
|
1021
|
+
let newPool = ''
|
|
1022
|
+
for (const e of entries) {
|
|
1023
|
+
newPool += String.fromCharCode(e.len & 0xFF, (e.len >> 8) & 0xFF, (e.len >> 16) & 0xFF, (e.len >> 24) & 0xFF)
|
|
1024
|
+
remap.set(e.oldOff, newPool.length)
|
|
1025
|
+
newPool += e.str
|
|
1026
|
+
}
|
|
1027
|
+
strPoolRef.pool = newPool
|
|
1028
|
+
if (strDedupMap)
|
|
1029
|
+
for (const [str, oldOff] of strDedupMap) {
|
|
1030
|
+
const newOff = remap.get(oldOff)
|
|
1031
|
+
if (newOff !== undefined) strDedupMap.set(str, newOff)
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// Rewrite recorded ref sites directly (no second AST walk).
|
|
1035
|
+
for (let i = 0; i < sites.length; i++) {
|
|
1036
|
+
const { node, oldOff } = sites[i]
|
|
1037
|
+
const newO = remap.get(oldOff)
|
|
1038
|
+
if (newO !== undefined) setOff(node, newO)
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* Run all per-function IR optimizations on a single function node.
|
|
1044
|
+
* hoistPtrType runs first — it introduces new locals (`$__ptN`) that the fused
|
|
1045
|
+
* walk should see in their final form. fusedRewrite then collapses rebox/unbox
|
|
1046
|
+
* round-trips, inlines tiny ptr/is_* helpers, and folds (i32.add base const)
|
|
1047
|
+
* into memarg offset= form, all in a single bottom-up traversal — and
|
|
1048
|
+
* piggybacks local-ref counting so sortLocalsByUse skips its own walk.
|
|
1049
|
+
*
|
|
1050
|
+
* @param fn func IR node
|
|
1051
|
+
* @param cfg optional resolved config from resolveOptimize() — when omitted, all on.
|
|
1052
|
+
*/
|
|
1053
|
+
export function optimizeFunc(fn, cfg) {
|
|
1054
|
+
if (cfg && cfg.hoistPtrType === false &&
|
|
1055
|
+
cfg.hoistInvariantPtrOffset === false &&
|
|
1056
|
+
cfg.fusedRewrite === false &&
|
|
1057
|
+
cfg.hoistAddrBase === false &&
|
|
1058
|
+
cfg.hoistInvariantCellLoads === false &&
|
|
1059
|
+
cfg.sortLocalsByUse === false) return
|
|
1060
|
+
if (!cfg || cfg.hoistPtrType !== false) hoistPtrType(fn)
|
|
1061
|
+
if (!cfg || cfg.hoistInvariantPtrOffset !== false) hoistInvariantPtrOffset(fn)
|
|
1062
|
+
const counts = new Map()
|
|
1063
|
+
if (!cfg || cfg.fusedRewrite !== false) fusedRewrite(fn, counts)
|
|
1064
|
+
if (!cfg || cfg.hoistAddrBase !== false) hoistAddrBase(fn)
|
|
1065
|
+
if (!cfg || cfg.hoistInvariantCellLoads !== false) hoistInvariantCellLoads(fn)
|
|
1066
|
+
if (!cfg || cfg.sortLocalsByUse !== false) sortLocalsByUse(fn, cfg && cfg.fusedRewrite !== false ? counts : null)
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// Fused bottom-up walk applying three orthogonal pattern sets at each node:
|
|
1070
|
+
// inlinePtrType — call $__ptr_type / __ptr_aux / __is_nullish / __is_null / __is_truthy
|
|
1071
|
+
// (skipped inside $__ptr_*/__is_* helper bodies themselves)
|
|
1072
|
+
// peephole — rebox/unbox round-trips: i64.reinterpret_f64 / f64.reinterpret_i64 /
|
|
1073
|
+
// i32.wrap_i64 over (i64.extend_i32_u/_s X) or (i64.or HIGH_ONLY extend X)
|
|
1074
|
+
// foldMemarg — (load/store (i32.add base (i32.const N)) …) → (load/store offset=N base …)
|
|
1075
|
+
// They discriminate on node[0] and don't overlap, so one visit suffices for all three.
|
|
1076
|
+
function fusedRewrite(fn, counts) {
|
|
1077
|
+
if (!Array.isArray(fn) || fn[0] !== 'func') {
|
|
1078
|
+
if (Array.isArray(fn)) {
|
|
1079
|
+
for (let i = 0; i < fn.length; i++) {
|
|
1080
|
+
const c = fn[i]
|
|
1081
|
+
if (Array.isArray(c)) fn[i] = walkRewrite(c, true, counts)
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
return
|
|
1085
|
+
}
|
|
1086
|
+
// Skip __ptr_*/is_* bodies for inline pattern (they ARE the helpers).
|
|
1087
|
+
const name = typeof fn[1] === 'string' ? fn[1] : null
|
|
1088
|
+
const skipInline = !!(name && (name.startsWith('$__ptr_') || name === '$__is_nullish' || name === '$__is_truthy' || name === '$__is_null'))
|
|
1089
|
+
const bodyStart = findBodyStart(fn)
|
|
1090
|
+
for (let i = bodyStart; i < fn.length; i++) {
|
|
1091
|
+
const c = fn[i]
|
|
1092
|
+
if (Array.isArray(c)) fn[i] = walkRewrite(c, !skipInline, counts)
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function walkRewrite(node, doInline, counts) {
|
|
1097
|
+
if (!Array.isArray(node)) return node
|
|
1098
|
+
for (let i = 0; i < node.length; i++) {
|
|
1099
|
+
const c = node[i]
|
|
1100
|
+
if (Array.isArray(c)) node[i] = walkRewrite(c, doInline, counts)
|
|
1101
|
+
}
|
|
1102
|
+
const op = node[0]
|
|
1103
|
+
// Piggyback local-ref counting for sortLocalsByUse. `counts` may be undefined
|
|
1104
|
+
// when fusedRewrite is called outside optimizeFunc (whole-module pass).
|
|
1105
|
+
if (counts && (op === 'local.get' || op === 'local.set' || op === 'local.tee') && typeof node[1] === 'string')
|
|
1106
|
+
counts.set(node[1], (counts.get(node[1]) || 0) + 1)
|
|
1107
|
+
|
|
1108
|
+
// Inline-ptr-helpers: $__ptr_type / $__ptr_aux / $__is_nullish / $__is_null / $__is_truthy
|
|
1109
|
+
if (doInline && op === 'call' && node.length === 3 && typeof node[1] === 'string') {
|
|
1110
|
+
const fname = node[1]
|
|
1111
|
+
if (fname === '$__ptr_type') return ['i32.and',
|
|
1112
|
+
['i32.wrap_i64', ['i64.shr_u', ['i64.reinterpret_f64', node[2]], ['i64.const', 47]]],
|
|
1113
|
+
['i32.const', 0xF]]
|
|
1114
|
+
if (fname === '$__ptr_aux') return ['i32.and',
|
|
1115
|
+
['i32.wrap_i64', ['i64.shr_u', ['i64.reinterpret_f64', node[2]], ['i64.const', 32]]],
|
|
1116
|
+
['i32.const', 0x7FFF]]
|
|
1117
|
+
if (fname === '$__is_null') return ['i64.eq', ['i64.reinterpret_f64', node[2]], ['i64.const', '0x7FF8000100000000']]
|
|
1118
|
+
if (fname === '$__is_nullish' && Array.isArray(node[2]) && node[2][0] === 'local.get') return ['i32.or',
|
|
1119
|
+
['i64.eq', ['i64.reinterpret_f64', node[2]], ['i64.const', '0x7FF8000100000000']],
|
|
1120
|
+
['i64.eq', ['i64.reinterpret_f64', node[2]], ['i64.const', '0x7FF8000000000001']]]
|
|
1121
|
+
if (fname === '$__is_truthy' && Array.isArray(node[2]) && node[2][0] === 'local.get') {
|
|
1122
|
+
const lget = node[2]
|
|
1123
|
+
const bits = ['i64.reinterpret_f64', lget]
|
|
1124
|
+
return ['if', ['result', 'i32'],
|
|
1125
|
+
['f64.eq', lget, lget],
|
|
1126
|
+
['then', ['f64.ne', lget, ['f64.const', 0]]],
|
|
1127
|
+
['else', ['i32.and',
|
|
1128
|
+
['i32.and',
|
|
1129
|
+
['i64.ne', bits, ['i64.const', '0x7FF8000000000000']],
|
|
1130
|
+
['i64.ne', bits, ['i64.const', '0x7FF8000100000000']]],
|
|
1131
|
+
['i32.and',
|
|
1132
|
+
['i64.ne', bits, ['i64.const', '0x7FF8000000000001']],
|
|
1133
|
+
['i64.ne', bits, ['i64.const', '0x7FFA800000000000']]]]]]
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// Peephole: rebox/unbox round-trips
|
|
1138
|
+
if (op === 'i64.reinterpret_f64' && node.length === 2) {
|
|
1139
|
+
const a = node[1]
|
|
1140
|
+
if (Array.isArray(a) && a[0] === 'f64.reinterpret_i64' && a.length === 2) return a[1]
|
|
1141
|
+
}
|
|
1142
|
+
if (op === 'f64.reinterpret_i64' && node.length === 2) {
|
|
1143
|
+
const a = node[1]
|
|
1144
|
+
if (Array.isArray(a) && a[0] === 'i64.reinterpret_f64' && a.length === 2) return a[1]
|
|
1145
|
+
}
|
|
1146
|
+
if (op === 'i32.wrap_i64' && node.length === 2) {
|
|
1147
|
+
const a = node[1]
|
|
1148
|
+
if (Array.isArray(a) && (a[0] === 'i64.extend_i32_u' || a[0] === 'i64.extend_i32_s') && a.length === 2)
|
|
1149
|
+
return a[1]
|
|
1150
|
+
// (i32.wrap_i64 (i64.reinterpret_f64 (f64.load ADDR ?offset))) → (i32.load ADDR ?offset).
|
|
1151
|
+
// Wasm is little-endian; the low 32 bits of the f64 at ADDR are exactly
|
|
1152
|
+
// i32.load(ADDR). Saves two ops on every NaN-box pointer extraction from
|
|
1153
|
+
// an array slot or struct field.
|
|
1154
|
+
if (Array.isArray(a) && a[0] === 'i64.reinterpret_f64' && a.length === 2) {
|
|
1155
|
+
const inner = a[1]
|
|
1156
|
+
if (Array.isArray(inner) && inner[0] === 'f64.load') {
|
|
1157
|
+
const out = ['i32.load']
|
|
1158
|
+
for (let i = 1; i < inner.length; i++) out.push(inner[i])
|
|
1159
|
+
return out
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
if (Array.isArray(a) && a[0] === 'i64.or' && a.length === 3) {
|
|
1163
|
+
const l = a[1], r = a[2]
|
|
1164
|
+
const isHighOnly = (n) => {
|
|
1165
|
+
if (!Array.isArray(n) || n[0] !== 'i64.const') return false
|
|
1166
|
+
const v = n[1]
|
|
1167
|
+
let bi
|
|
1168
|
+
if (typeof v === 'number') bi = BigInt(v)
|
|
1169
|
+
else if (typeof v === 'string') {
|
|
1170
|
+
try { bi = v.startsWith('-') ? -BigInt(v.slice(1)) : BigInt(v) } catch { return false }
|
|
1171
|
+
} else return false
|
|
1172
|
+
return (bi & 0xFFFFFFFFn) === 0n
|
|
1173
|
+
}
|
|
1174
|
+
const isExtend = (n) => Array.isArray(n) && (n[0] === 'i64.extend_i32_u' || n[0] === 'i64.extend_i32_s') && n.length === 2
|
|
1175
|
+
if (isHighOnly(l) && isExtend(r)) return r[1]
|
|
1176
|
+
if (isHighOnly(r) && isExtend(l)) return l[1]
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// shl-distribute-over-add: (i32.shl (i32.add x (i32.const K)) (i32.const S))
|
|
1181
|
+
// → (i32.add (i32.shl x S) (i32.const K<<S)). Overflow-safe — both forms wrap
|
|
1182
|
+
// mod 2^32 identically. Unlocks memarg offset= folding for biquad-style
|
|
1183
|
+
// `arr[c+K0..KN]` reads where idx is precomputed but K is a small literal.
|
|
1184
|
+
if (op === 'i32.shl' && node.length === 3) {
|
|
1185
|
+
const a = node[1], b = node[2]
|
|
1186
|
+
// shl-shl-merge: (i32.shl (i32.shl x K1) K2) → (i32.shl x (K1+K2))
|
|
1187
|
+
// when K1+K2 < 32. Biquad: `sb = s<<2` then `__ab1 = state + (sb<<3)` ⇒
|
|
1188
|
+
// `s<<5` directly.
|
|
1189
|
+
if (Array.isArray(a) && a[0] === 'i32.shl' && a.length === 3 &&
|
|
1190
|
+
Array.isArray(b) && b[0] === 'i32.const' && typeof b[1] === 'number' &&
|
|
1191
|
+
Array.isArray(a[2]) && a[2][0] === 'i32.const' && typeof a[2][1] === 'number') {
|
|
1192
|
+
const sum = a[2][1] + b[1]
|
|
1193
|
+
if (sum >= 0 && sum < 32) return ['i32.shl', a[1], ['i32.const', sum]]
|
|
1194
|
+
}
|
|
1195
|
+
if (Array.isArray(a) && a[0] === 'i32.add' && a.length === 3 &&
|
|
1196
|
+
Array.isArray(b) && b[0] === 'i32.const' && typeof b[1] === 'number' && b[1] >= 0 && b[1] < 32) {
|
|
1197
|
+
const ka = a[1], kb = a[2]
|
|
1198
|
+
let inner, k
|
|
1199
|
+
if (Array.isArray(kb) && kb[0] === 'i32.const' && typeof kb[1] === 'number') { inner = ka; k = kb[1] }
|
|
1200
|
+
else if (Array.isArray(ka) && ka[0] === 'i32.const' && typeof ka[1] === 'number') { inner = kb; k = ka[1] }
|
|
1201
|
+
if (inner != null) {
|
|
1202
|
+
const shifted = (k * (1 << b[1])) | 0
|
|
1203
|
+
return ['i32.add', ['i32.shl', inner, b], ['i32.const', shifted]]
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// assoc-lift-const-add: (i32.add A (i32.add B (i32.const K))) → (i32.add (i32.add A B) (i32.const K))
|
|
1209
|
+
// and mirror for left side. Lifts constant to top level so foldMemargOffsets
|
|
1210
|
+
// recognizes the canonical (i32.add base const) shape.
|
|
1211
|
+
if (op === 'i32.add' && node.length === 3) {
|
|
1212
|
+
const a = node[1], b = node[2]
|
|
1213
|
+
if (Array.isArray(b) && b[0] === 'i32.add' && b.length === 3) {
|
|
1214
|
+
const bb1 = b[1], bb2 = b[2]
|
|
1215
|
+
if (Array.isArray(bb2) && bb2[0] === 'i32.const') return ['i32.add', ['i32.add', a, bb1], bb2]
|
|
1216
|
+
if (Array.isArray(bb1) && bb1[0] === 'i32.const') return ['i32.add', ['i32.add', a, bb2], bb1]
|
|
1217
|
+
}
|
|
1218
|
+
if (Array.isArray(a) && a[0] === 'i32.add' && a.length === 3) {
|
|
1219
|
+
const aa1 = a[1], aa2 = a[2]
|
|
1220
|
+
if (Array.isArray(aa2) && aa2[0] === 'i32.const') return ['i32.add', ['i32.add', aa1, b], aa2]
|
|
1221
|
+
if (Array.isArray(aa1) && aa1[0] === 'i32.const') return ['i32.add', ['i32.add', aa2, b], aa1]
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// foldMemargOffsets: (load/store (i32.add base const) ...) → (load/store offset=N base ...)
|
|
1226
|
+
if (typeof op === 'string' && MEMOP.test(op)) {
|
|
1227
|
+
const m1 = node[1]
|
|
1228
|
+
if (!(typeof m1 === 'string' && (m1.startsWith('offset=') || m1.startsWith('align=')))) {
|
|
1229
|
+
const addr = m1
|
|
1230
|
+
if (Array.isArray(addr) && addr[0] === 'i32.add' && addr.length === 3) {
|
|
1231
|
+
const a = addr[1], b = addr[2]
|
|
1232
|
+
let base, offset
|
|
1233
|
+
if (Array.isArray(b) && b[0] === 'i32.const' && typeof b[1] === 'number' && b[1] >= 0 && b[1] < 0x100000000) { base = a; offset = b[1] }
|
|
1234
|
+
else if (Array.isArray(a) && a[0] === 'i32.const' && typeof a[1] === 'number' && a[1] >= 0 && a[1] < 0x100000000) { base = b; offset = a[1] }
|
|
1235
|
+
if (base != null) {
|
|
1236
|
+
node[1] = `offset=${offset}`
|
|
1237
|
+
node.splice(2, 0, base)
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
return node
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/**
|
|
1246
|
+
* Dead-code elimination: remove func decls not reachable from any entry point.
|
|
1247
|
+
* Roots: `(start $X)`, `(export "n" (func $X))`, `(elem … $X …)`, `(ref.func $X)`.
|
|
1248
|
+
* Iteratively adds funcs called from reachable ones. Mutates arrays in place.
|
|
1249
|
+
* Typical win: watr's optimize.js has orphan top-level consts (e.g. `hoist` = 26 KB).
|
|
1250
|
+
*
|
|
1251
|
+
* @param funcSections — array of { arr, isStartContainer? }. Each `arr` holds func IR nodes
|
|
1252
|
+
* (may be interleaved with other nodes like `(start $X)` for sec.start).
|
|
1253
|
+
* @param allModuleNodes — flat iterable of all module-level nodes for root discovery
|
|
1254
|
+
* (exports, elem, start directive are elsewhere than funcSections).
|
|
1255
|
+
* @param opts — optional `{ removeDead: bool }`. When `removeDead` is false, the
|
|
1256
|
+
* reachability walk still runs (so `callCount` is populated for the
|
|
1257
|
+
* funcidx sort downstream) but unreachable funcs are kept. Default true.
|
|
1258
|
+
*/
|
|
1259
|
+
export function treeshake(funcSections, allModuleNodes, opts) {
|
|
1260
|
+
const removeDead = !opts || opts.removeDead !== false
|
|
1261
|
+
const funcByName = new Map()
|
|
1262
|
+
const allFuncs = []
|
|
1263
|
+
for (const { arr } of funcSections)
|
|
1264
|
+
for (const n of arr)
|
|
1265
|
+
if (Array.isArray(n) && n[0] === 'func') {
|
|
1266
|
+
allFuncs.push(n)
|
|
1267
|
+
if (typeof n[1] === 'string') funcByName.set(n[1], n)
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
const reachable = new Set()
|
|
1271
|
+
const stack = []
|
|
1272
|
+
const addRoot = (name) => { if (funcByName.has(name) && !reachable.has(name)) { reachable.add(name); stack.push(name) } }
|
|
1273
|
+
|
|
1274
|
+
// Named funcs with inline `(export "name")` are module-export roots.
|
|
1275
|
+
for (const [name, fn] of funcByName)
|
|
1276
|
+
for (let i = 2; i < fn.length; i++)
|
|
1277
|
+
if (Array.isArray(fn[i]) && fn[i][0] === 'export') { addRoot(name); break }
|
|
1278
|
+
|
|
1279
|
+
const findRoots = (node) => {
|
|
1280
|
+
if (!Array.isArray(node)) return
|
|
1281
|
+
if (node[0] === 'start' && typeof node[1] === 'string') addRoot(node[1])
|
|
1282
|
+
else if (node[0] === 'export' && Array.isArray(node[2]) && node[2][0] === 'func') addRoot(node[2][1])
|
|
1283
|
+
else if (node[0] === 'elem') for (const c of node) if (typeof c === 'string' && c.startsWith('$')) addRoot(c)
|
|
1284
|
+
for (const c of node) findRoots(c)
|
|
1285
|
+
}
|
|
1286
|
+
for (const n of allModuleNodes) findRoots(n)
|
|
1287
|
+
|
|
1288
|
+
// Side-output: per-callee call counts over all reachable + anonymous funcs.
|
|
1289
|
+
// Caller uses this to sort funcs by hotness for low-LEB128-funcidx packing.
|
|
1290
|
+
// Counting here is free — we already visit every node in these funcs.
|
|
1291
|
+
const callCount = new Map()
|
|
1292
|
+
const CALL_OPS = new Set(['call', 'return_call', 'ref.func'])
|
|
1293
|
+
const visitCalls = (node) => {
|
|
1294
|
+
if (!Array.isArray(node)) return
|
|
1295
|
+
if (CALL_OPS.has(node[0]) && typeof node[1] === 'string') {
|
|
1296
|
+
addRoot(node[1])
|
|
1297
|
+
if (node[0] === 'call' || node[0] === 'return_call')
|
|
1298
|
+
callCount.set(node[1], (callCount.get(node[1]) || 0) + 1)
|
|
1299
|
+
}
|
|
1300
|
+
for (const c of node) visitCalls(c)
|
|
1301
|
+
}
|
|
1302
|
+
// Anonymous funcs can't be pruned (no name) — walk them to seed roots.
|
|
1303
|
+
for (const fn of allFuncs) if (typeof fn[1] !== 'string') visitCalls(fn)
|
|
1304
|
+
while (stack.length) visitCalls(funcByName.get(stack.pop()))
|
|
1305
|
+
|
|
1306
|
+
let removed = 0
|
|
1307
|
+
if (removeDead) {
|
|
1308
|
+
for (const { arr } of funcSections) {
|
|
1309
|
+
for (let i = arr.length - 1; i >= 0; i--) {
|
|
1310
|
+
const n = arr[i]
|
|
1311
|
+
if (Array.isArray(n) && n[0] === 'func' && typeof n[1] === 'string' && !reachable.has(n[1])) {
|
|
1312
|
+
arr.splice(i, 1); removed++
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
return { removed, callCount }
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
/**
|
|
1321
|
+
* Reorder non-param local decls by reference count (hot locals first).
|
|
1322
|
+
* WASM `local.get/set/tee` encode local idx as ULEB128 — 1 B for idx < 128, else 2 B.
|
|
1323
|
+
* Only the decl order changes; refs by name are unchanged and re-resolved by watr.
|
|
1324
|
+
* Params are fixed (their slot defines the call ABI) — only `(local …)` nodes move.
|
|
1325
|
+
*/
|
|
1326
|
+
export function sortLocalsByUse(fn, precomputedCounts) {
|
|
1327
|
+
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
1328
|
+
const localIdxs = []
|
|
1329
|
+
let totalDecls = 0
|
|
1330
|
+
for (let i = 2; i < fn.length; i++) {
|
|
1331
|
+
const c = fn[i]
|
|
1332
|
+
if (!Array.isArray(c)) continue
|
|
1333
|
+
if (c[0] === 'param' || c[0] === 'result') { totalDecls++; continue }
|
|
1334
|
+
if (c[0] === 'local') { localIdxs.push(i); totalDecls++; continue }
|
|
1335
|
+
break
|
|
1336
|
+
}
|
|
1337
|
+
if (localIdxs.length < 2 || totalDecls <= 128) return
|
|
1338
|
+
let counts = precomputedCounts
|
|
1339
|
+
if (!counts) {
|
|
1340
|
+
counts = new Map()
|
|
1341
|
+
const visit = (n) => {
|
|
1342
|
+
if (!Array.isArray(n)) return
|
|
1343
|
+
if ((n[0] === 'local.get' || n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string')
|
|
1344
|
+
counts.set(n[1], (counts.get(n[1]) || 0) + 1)
|
|
1345
|
+
for (const c of n) visit(c)
|
|
1346
|
+
}
|
|
1347
|
+
for (let i = totalDecls + 2; i < fn.length; i++) visit(fn[i])
|
|
1348
|
+
}
|
|
1349
|
+
const locals = localIdxs.map(i => fn[i])
|
|
1350
|
+
locals.sort((a, b) => (counts.get(b[1]) || 0) - (counts.get(a[1]) || 0))
|
|
1351
|
+
localIdxs.forEach((i, k) => { fn[i] = locals[k] })
|
|
1352
|
+
}
|