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.
@@ -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
+ }