jz 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +66 -43
  2. package/bench/README.md +128 -78
  3. package/bench/bench.svg +32 -42
  4. package/cli.js +73 -7
  5. package/dist/interop.js +1 -0
  6. package/dist/jz.js +6024 -0
  7. package/index.d.ts +126 -0
  8. package/index.js +190 -34
  9. package/interop.js +71 -27
  10. package/layout.js +5 -0
  11. package/module/array.js +71 -39
  12. package/module/collection.js +41 -5
  13. package/module/core.js +2 -4
  14. package/module/math.js +138 -2
  15. package/module/number.js +21 -0
  16. package/module/object.js +26 -0
  17. package/module/simd.js +37 -5
  18. package/module/string.js +41 -12
  19. package/module/typedarray.js +415 -26
  20. package/package.json +21 -6
  21. package/src/autoload.js +3 -0
  22. package/src/compile/analyze-scans.js +174 -11
  23. package/src/compile/analyze.js +38 -3
  24. package/src/compile/emit-assign.js +9 -6
  25. package/src/compile/emit.js +347 -36
  26. package/src/compile/index.js +307 -29
  27. package/src/compile/infer.js +36 -1
  28. package/src/compile/loop-divmod.js +155 -0
  29. package/src/compile/narrow.js +108 -11
  30. package/src/compile/peel-stencil.js +261 -0
  31. package/src/compile/plan/advise.js +55 -1
  32. package/src/compile/plan/index.js +4 -0
  33. package/src/compile/plan/inline.js +5 -2
  34. package/src/compile/plan/literals.js +220 -5
  35. package/src/compile/plan/scope.js +115 -39
  36. package/src/compile/program-facts.js +21 -2
  37. package/src/ctx.js +45 -7
  38. package/src/ir.js +55 -7
  39. package/src/kind-traits.js +27 -0
  40. package/src/kind.js +65 -3
  41. package/src/optimize/index.js +344 -45
  42. package/src/optimize/vectorize.js +2968 -182
  43. package/src/prepare/index.js +64 -7
  44. package/src/prepare/lift-iife.js +149 -0
  45. package/src/reps.js +3 -2
  46. package/src/static.js +9 -0
  47. package/src/type.js +10 -6
  48. package/src/wat/assemble.js +195 -13
  49. package/src/wat/optimize.js +363 -185
@@ -1204,6 +1204,38 @@ const isPure = (node) => {
1204
1204
  return true
1205
1205
  }
1206
1206
 
1207
+ // Structured / control-flow forms: they do NOT evaluate all children eagerly
1208
+ // (an `if` runs one arm; a `block`/`loop` scopes branches), so their side effects
1209
+ // can't be flattened to the children's — they stay whole under a drop. (`br*`,
1210
+ // `try_table` are already in IMPURE_OPS.)
1211
+ const STRUCTURED_OPS = new Set(['if', 'then', 'else', 'block', 'loop', 'try'])
1212
+
1213
+ // `op` is an EAGER value operation: it evaluates every operand unconditionally
1214
+ // and only computes a result (arithmetic, compare, convert, select, load) — so
1215
+ // discarding its value leaves just the operands' side effects. Excludes impure
1216
+ // ops and the structured forms above.
1217
+ const isEagerValueOp = (op) => typeof op === 'string' && !IMPURE_OPS.has(op) &&
1218
+ !STRUCTURED_OPS.has(op) && !IMPURE_SUBSTRINGS.some(s => op.includes(s))
1219
+
1220
+ // Statements that preserve `node`'s side effects when its VALUE is discarded.
1221
+ // A fully-pure value contributes nothing; an eager value op contributes only its
1222
+ // operands' effects (the op result is dead); a `local.tee` keeps the store as a
1223
+ // `local.set`; anything else (call, store-expr, structured form) stays under a drop.
1224
+ // This turns `drop(i32.sub(tee X V, 1))` — the post-increment's dropped old value
1225
+ // — into the bare `local.set X V`, eliminating dead arithmetic the plain
1226
+ // `drop(PURE)→nop` rule can't (the tee makes the whole subtree impure).
1227
+ const dropEffects = (node) => {
1228
+ if (!Array.isArray(node) || isPure(node)) return []
1229
+ const op = node[0]
1230
+ if (op === 'local.tee' && node.length === 3) return [['local.set', node[1], node[2]]]
1231
+ if (isEagerValueOp(op)) {
1232
+ const eff = []
1233
+ for (let i = 1; i < node.length; i++) eff.push(...dropEffects(node[i]))
1234
+ return eff
1235
+ }
1236
+ return [['drop', node]]
1237
+ }
1238
+
1207
1239
  /** Count all local.get/set/tee occurrences in one walk */
1208
1240
  const countLocalUses = (node) => {
1209
1241
  const counts = new Map()
@@ -1259,6 +1291,19 @@ const purgeRefs = (known, name) => {
1259
1291
  }
1260
1292
  }
1261
1293
 
1294
+ /** Drop tracked values that read global `$name`: a `global.set $name` makes them stale.
1295
+ * The local-only {@link purgeRefs} misses this — so a value captured from a global
1296
+ * (`let s = f`, where `f` is a reassignable module-level binding) would survive an
1297
+ * intervening `f = …` and substitute the NEW global. That silently breaks the canonical
1298
+ * pointer swap `let s = f; f = g; g = s` (g would read post-swap f, i.e. itself). */
1299
+ const purgeGlobalRefs = (known, name) => {
1300
+ for (const [key, tracked] of known) {
1301
+ let refs = false
1302
+ walk(tracked.val, n => { if (Array.isArray(n) && n[0] === 'global.get' && n[1] === name) refs = true })
1303
+ if (refs) known.delete(key)
1304
+ }
1305
+ }
1306
+
1262
1307
  /** True if `node` recursively contains an op that may read linear memory.
1263
1308
  * Tracked values whose RHS reads memory go stale after any intervening
1264
1309
  * memory-mutating op (`*.store`, `memory.copy/fill/init`, atomic stores/rmw). */
@@ -1346,6 +1391,9 @@ const substGets = (node, known) => {
1346
1391
  if (inner === known) inner = new Map(known)
1347
1392
  inner.delete(n[1])
1348
1393
  purgeRefs(inner, n[1])
1394
+ } else if (n[0] === 'global.set' && typeof n[1] === 'string') { // same staleness as a local write — a sibling operand's
1395
+ if (inner === known) inner = new Map(known) // global write invalidates a later operand's global-sourced copy
1396
+ purgeGlobalRefs(inner, n[1])
1349
1397
  }
1350
1398
  })
1351
1399
  }
@@ -1388,6 +1436,7 @@ const forwardPropagate = (funcNode, params, useCounts) => {
1388
1436
  if (!Array.isArray(n)) return
1389
1437
  if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string')
1390
1438
  { known.delete(n[1]); purgeRefs(known, n[1]) }
1439
+ else if (n[0] === 'global.set' && typeof n[1] === 'string') purgeGlobalRefs(known, n[1])
1391
1440
  })
1392
1441
  const uses = getUseCount(instr[1])
1393
1442
  purgeRefs(known, instr[1]) // entries that read this local just went stale
@@ -1434,8 +1483,10 @@ const forwardPropagate = (funcNode, params, useCounts) => {
1434
1483
  // pre-write tracked value (correct), but later reads must see the new
1435
1484
  // (untracked) value, not the stale constant.
1436
1485
  walk(instr, n => {
1437
- if (Array.isArray(n) && (n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string')
1486
+ if (!Array.isArray(n)) return
1487
+ if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string')
1438
1488
  { known.delete(n[1]); purgeRefs(known, n[1]) }
1489
+ else if (n[0] === 'global.set' && typeof n[1] === 'string') purgeGlobalRefs(known, n[1])
1439
1490
  })
1440
1491
  // Memory write in this statement (any nested store / memory.copy / etc.)
1441
1492
  // invalidates every tracked value whose RHS reads memory: inlining one
@@ -1632,113 +1683,269 @@ const propagate = (ast) => {
1632
1683
 
1633
1684
  // ==================== FUNCTION INLINING ====================
1634
1685
 
1686
+ // Shared inliner primitives, used by BOTH passes below: `inline` (duplicates tiny
1687
+ // multi-caller bodies — size-for-speed) and `inlineOnce` (splices single-caller
1688
+ // bodies, never duplicates). The lift technique is identical — rename the callee's
1689
+ // params/locals/labels to fresh `$__inlN_*` names, evaluate args once into the
1690
+ // renamed param locals, turn `return X` into `br $__inlN X`, wrap the body in a
1691
+ // `(block $__inlN (result T)? …)`. Only the SELECTION policy differs (one caller vs
1692
+ // every caller of a small body), so the lift lives here once.
1693
+
1694
+ let inlineUid = 0
1695
+ const INL_HEAD = new Set(['export', 'type', 'param', 'result', 'local'])
1696
+ const inlBodyStart = (fn) => {
1697
+ let i = 2
1698
+ while (i < fn.length && (typeof fn[i] === 'string' || (Array.isArray(fn[i]) && INL_HEAD.has(fn[i][0])))) i++
1699
+ return i
1700
+ }
1701
+ const inlIsBranch = op => op === 'br' || op === 'br_if' || op === 'br_table'
1702
+ // A subtree we can't lift into a (block …): depth-relative branch labels (which would
1703
+ // shift under the added nesting) or tail calls (which would escape the wrapping block).
1704
+ const inlUnsafe = (n) => {
1705
+ if (!Array.isArray(n)) return false
1706
+ const op = n[0]
1707
+ if (op === 'return_call' || op === 'return_call_indirect' || op === 'return_call_ref') return true
1708
+ if (op === 'try' || op === 'try_table' || op === 'delegate' || op === 'rethrow') return true // exception labels — not handled by the relabeler below
1709
+ if (inlIsBranch(op)) for (let i = 1; i < n.length; i++) if (typeof n[i] === 'number' || (typeof n[i] === 'string' && /^\d+$/.test(n[i]))) return true
1710
+ for (let i = 1; i < n.length; i++) if (inlUnsafe(n[i])) return true
1711
+ return false
1712
+ }
1713
+ const inlCallsSelf = (n, name) => {
1714
+ if (!Array.isArray(n)) return false
1715
+ if ((n[0] === 'call' || n[0] === 'return_call') && n[1] === name) return true
1716
+ for (let i = 1; i < n.length; i++) if (inlCallsSelf(n[i], name)) return true
1717
+ return false
1718
+ }
1719
+ // Per-call zero-init constant for a callee local re-entered from a caller loop.
1720
+ // null ⇒ a type we can't safely zero here (skip inlining such a callee).
1721
+ const inlZeroFor = (t) => {
1722
+ if (t === 'i32') return ['i32.const', 0]
1723
+ if (t === 'i64') return ['i64.const', 0]
1724
+ if (t === 'f32') return ['f32.const', 0]
1725
+ if (t === 'f64') return ['f64.const', 0]
1726
+ if (t === 'v128') return ['v128.const', 'i64x2', '0', '0'] // STRING lanes — watr's v128 encoder calls .replaceAll
1727
+ return null
1728
+ }
1729
+ // A callee local needs a per-entry reset only if some path reads it before any
1730
+ // unconditional write (so it relied on the callee's fresh zero-init). Mirrors
1731
+ // coalesceLocals' readsZero heuristic; unconditionally-written scratch needs none.
1732
+ const inlNeedsReset = (body, name) => {
1733
+ let seen = false, conditional = false, depth = 0
1734
+ const visit = (n) => {
1735
+ if (seen || !Array.isArray(n)) return
1736
+ const op = n[0]
1737
+ const isSet = op === 'local.set' || op === 'local.tee'
1738
+ if ((isSet || op === 'local.get') && n[1] === name) {
1739
+ if (isSet) for (let i = 2; i < n.length && !seen; i++) visit(n[i])
1740
+ if (seen) return
1741
+ seen = true
1742
+ if (op === 'local.get' || depth > 0) conditional = true
1743
+ return
1744
+ }
1745
+ const isIf = op === 'if'
1746
+ for (let i = 1; i < n.length && !seen; i++) {
1747
+ const c = n[i]
1748
+ const cond = isIf && Array.isArray(c) && (c[0] === 'then' || c[0] === 'else')
1749
+ if (cond) depth++
1750
+ visit(c)
1751
+ if (cond) depth--
1752
+ }
1753
+ }
1754
+ for (const n of body) { if (seen) break; visit(n) }
1755
+ if (!seen) return false
1756
+ return conditional
1757
+ }
1758
+ // Module-level references that pin a function (can't be inlined-away/removed).
1759
+ const inlCollectPinned = (n, pinned) => {
1760
+ if (!Array.isArray(n)) return
1761
+ const op = n[0]
1762
+ if (op === 'export' && Array.isArray(n[2]) && n[2][0] === 'func' && typeof n[2][1] === 'string') pinned.add(n[2][1])
1763
+ else if (op === 'start' && typeof n[1] === 'string') pinned.add(n[1])
1764
+ else if (op === 'ref.func' && typeof n[1] === 'string') pinned.add(n[1])
1765
+ else if (op === 'elem') for (const c of n) if (typeof c === 'string' && c[0] === '$') pinned.add(c)
1766
+ for (const c of n) inlCollectPinned(c, pinned)
1767
+ }
1768
+
1769
+ // Parse a func node into { params, locals, inlResult } once, enforcing the
1770
+ // liftability contract (named params/locals, zero-init-able local types, ≤1
1771
+ // result, no inline export). Returns null if the func can't be lifted.
1772
+ const inlParse = (fn) => {
1773
+ const params = [], locals = []
1774
+ let inlResult = null, ok = true, nResult = 0
1775
+ for (let i = 2; i < fn.length; i++) {
1776
+ const c = fn[i]
1777
+ if (typeof c === 'string') continue
1778
+ if (!Array.isArray(c)) { ok = false; break }
1779
+ if (c[0] === 'param') { if (typeof c[1] !== 'string' || c[1][0] !== '$') { ok = false; break } params.push({ name: c[1], type: c[2] }) }
1780
+ else if (c[0] === 'local') { if (typeof c[1] !== 'string' || c[1][0] !== '$' || !inlZeroFor(c[2])) { ok = false; break } locals.push({ name: c[1], type: c[2] }) }
1781
+ else if (c[0] === 'result') { nResult += c.length - 1; if (c.length > 1) inlResult = c[1] }
1782
+ else if (c[0] === 'export') { ok = false; break }
1783
+ else if (c[0] === 'type') continue
1784
+ else break
1785
+ }
1786
+ if (nResult > 1) ok = false
1787
+ return ok ? { params, locals, inlResult } : null
1788
+ }
1789
+
1790
+ // IR-node count of a callee body — the cheap size proxy gating multi-caller inline.
1791
+ const inlBodySize = (fn) => {
1792
+ let n = 0
1793
+ const count = (x) => { if (!Array.isArray(x)) return; n++; for (let i = 1; i < x.length; i++) count(x[i]) }
1794
+ for (let i = inlBodyStart(fn); i < fn.length; i++) count(fn[i])
1795
+ return n
1796
+ }
1797
+
1635
1798
  /**
1636
- * Inline tiny functions (single expression, no locals, no params or simple params).
1799
+ * Lift one callee into ONE `(call …)` node. Returns `{ block, decls }` `block`
1800
+ * replaces the call; `decls` are the renamed param+local declarations to splice into
1801
+ * the caller's local list. A fresh uid per invocation keeps every inlined copy's
1802
+ * locals/labels unique, so the same body can be lifted into many sites.
1803
+ *
1804
+ * (call $f a0 a1 …) → (block $__inlN (result T)?
1805
+ * (local.set $__inlN_p0 a0) … ;; args evaluated once, in order
1806
+ * (local.set $__inlN_l reset) … ;; only locals that rely on zero-init
1807
+ * …body, renamed, `return X` → `br $__inlN X`…)
1808
+ */
1809
+ const buildInline = (params, locals, inlResult, cBody, args) => {
1810
+ const uid = ++inlineUid
1811
+ const exit = `$__inl${uid}`
1812
+ const rename = new Map()
1813
+ for (const p of params) rename.set(p.name, `$__inl${uid}_${p.name.slice(1)}`)
1814
+ for (const l of locals) rename.set(l.name, `$__inl${uid}_${l.name.slice(1)}`)
1815
+ // The callee's own block/loop/if labels would shadow same-named caller labels (and
1816
+ // break depth resolution) under the added nesting — give them fresh names too.
1817
+ const labelRename = new Map()
1818
+ const collectLabels = (n) => {
1819
+ if (!Array.isArray(n)) return
1820
+ if (isBranchScope(n[0]) && typeof n[1] === 'string' && n[1][0] === '$' && !labelRename.has(n[1]))
1821
+ labelRename.set(n[1], `$__inl${uid}L_${n[1].slice(1)}`)
1822
+ for (let i = 1; i < n.length; i++) collectLabels(n[i])
1823
+ }
1824
+ for (const n of cBody) collectLabels(n)
1825
+ const sub = (n) => {
1826
+ if (!Array.isArray(n)) return n
1827
+ const op = n[0]
1828
+ if ((op === 'local.get' || op === 'local.set' || op === 'local.tee') && typeof n[1] === 'string' && rename.has(n[1]))
1829
+ return [op, rename.get(n[1]), ...n.slice(2).map(sub)]
1830
+ if (op === 'return') return ['br', exit, ...n.slice(1).map(sub)]
1831
+ if (isBranchScope(op) && typeof n[1] === 'string' && labelRename.has(n[1]))
1832
+ return [op, labelRename.get(n[1]), ...n.slice(2).map(sub)]
1833
+ if (inlIsBranch(op)) return [op, ...n.slice(1).map(c => (typeof c === 'string' && labelRename.has(c)) ? labelRename.get(c) : sub(c))]
1834
+ return n.map((c, i) => i === 0 ? c : sub(c))
1835
+ }
1836
+ const setup = params.map((p, k) => ['local.set', rename.get(p.name), args[k]])
1837
+ const resets = locals.filter(l => inlNeedsReset(cBody, l.name)).map(l => ['local.set', rename.get(l.name), inlZeroFor(l.type)])
1838
+ const inner = cBody.map(sub)
1839
+ const block = inlResult
1840
+ ? ['block', exit, ['result', inlResult], ...setup, ...resets, ...inner]
1841
+ : ['block', exit, ...setup, ...resets, ...inner]
1842
+ const decls = [...params, ...locals].map(p => ['local', rename.get(p.name), p.type])
1843
+ return { block, decls }
1844
+ }
1845
+
1846
+ /**
1847
+ * Inline SMALL functions into every caller, then delete them — the multi-caller
1848
+ * complement to {@link inlineOnce}. inlineOnce only fires for a lone caller (so it
1849
+ * never duplicates); this duplicates a tiny body across ALL its sites, trading a
1850
+ * bounded amount of size to remove call overhead from hot inner loops (e.g. a
1851
+ * raymarcher's per-step SDF, evaluated 4-wide but still paying a wasm call each
1852
+ * march step). Size-for-speed — opt-in, on at the 'speed' level only.
1853
+ *
1854
+ * A callee qualifies when it is small (≤ INLINE_MAX_NODES IR nodes), named with
1855
+ * named params/locals, single-result-or-void, non-recursive, not pinned
1856
+ * (export/start/elem/ref.func), not a SIMD_PROTECTED transcendental, and free of
1857
+ * depth-relative branches / tail calls (the inlParse + inlUnsafe liftability
1858
+ * contract). Runs to a fixpoint so small-helper chains (sdf → sdRep) collapse.
1859
+ *
1860
+ * `simdOnly` (the speed-level default) restricts inlining to pure SIMD helpers —
1861
+ * every param and the result are `v128`. That targets the case this exists for —
1862
+ * a hand-vectorized hot loop's per-step helper (a raymarcher's SDF), where the call
1863
+ * overhead is paid every iteration and V8's wasm JIT won't inline it — while leaving
1864
+ * SCALAR helpers untouched: those are where jz's codegen-shape/size tuning and the
1865
+ * auto-vectorizer's call-lifting (plasma's fbm → sin2) live, and duplicating them
1866
+ * both bloats and perturbs that machinery for no gain (V8's JIT inlines scalar
1867
+ * helpers itself). The unrestricted form stays available as `watr: { inline: true }`.
1868
+ *
1637
1869
  * @param {Array} ast
1870
+ * @param {{simdOnly?: boolean}} [opts]
1638
1871
  * @returns {Array}
1639
1872
  */
1640
- const inline = (ast) => {
1873
+ const INLINE_MAX_NODES = 90
1874
+ const isV128SimdHelper = (params, inlResult) =>
1875
+ inlResult === 'v128' && params.length > 0 && params.every(p => p.type === 'v128')
1876
+ const inline = (ast, { simdOnly = false } = {}) => {
1641
1877
  if (!Array.isArray(ast) || ast[0] !== 'module') return ast
1642
1878
 
1643
- // Collect inlinable functions
1644
- const inlinable = new Map() // $name { body, params }
1645
-
1646
- for (const node of ast.slice(1)) {
1647
- if (!Array.isArray(node) || node[0] !== 'func') continue
1648
-
1649
- const name = typeof node[1] === 'string' && node[1][0] === '$' ? node[1] : null
1650
- if (!name) continue
1651
-
1652
- // Check if function is small enough to inline
1653
- let params = []
1654
- let body = []
1655
- let hasLocals = false
1656
- let hasExport = false
1879
+ const skip = new Set() // callees with a non-inlinable site (arity mismatch) — don't re-pick
1880
+ for (let round = 0; round < MAX_INLINE_ROUNDS; round++) {
1881
+ const funcs = ast.filter(n => Array.isArray(n) && n[0] === 'func')
1882
+ const funcByName = new Map()
1883
+ for (const n of funcs) if (typeof n[1] === 'string') funcByName.set(n[1], n)
1657
1884
 
1658
- for (let i = 1; i < node.length; i++) {
1659
- const sub = node[i]
1660
- if (!Array.isArray(sub)) continue
1661
- if (sub[0] === 'param') {
1662
- // Collect param names and types
1663
- if (typeof sub[1] === 'string' && sub[1][0] === '$') {
1664
- params.push({ name: sub[1], type: sub[2] })
1665
- } else {
1666
- // Unnamed params - harder to inline
1667
- params = null
1668
- break
1669
- }
1670
- } else if (sub[0] === 'local') {
1671
- hasLocals = true
1672
- } else if (sub[0] === 'export') {
1673
- hasExport = true
1674
- } else if (sub[0] !== 'result' && sub[0] !== 'type') {
1675
- body.push(sub)
1676
- }
1885
+ const callRefs = new Map(), otherRef = new Set()
1886
+ const countRefs = (n) => {
1887
+ if (!Array.isArray(n)) return
1888
+ const op = n[0]
1889
+ if (op === 'call' && typeof n[1] === 'string') callRefs.set(n[1], (callRefs.get(n[1]) || 0) + 1)
1890
+ else if (op === 'return_call' && typeof n[1] === 'string') otherRef.add(n[1])
1891
+ for (let i = 1; i < n.length; i++) countRefs(n[i])
1677
1892
  }
1893
+ countRefs(ast)
1894
+ const pinned = new Set()
1895
+ for (const n of ast) if (!Array.isArray(n) || n[0] !== 'func') inlCollectPinned(n, pinned)
1678
1896
 
1679
- // Inline: no locals, <= 4 params, single expression body, not exported
1680
- if (params && !hasLocals && !hasExport && params.length <= 4 && body.length === 1) {
1681
- // Check if function mutates any of its params (local.set/tee on param),
1682
- // or contains a control-transfer op (`return`, `return_call`,
1683
- // `return_call_indirect`). Inlining such bodies into a different-typed
1684
- // caller would propagate the transfer to the caller, returning from the
1685
- // wrong function with the wrong type. Lifting the body into a
1686
- // `(block $exit ...)` and rewriting returns to `(br $exit X)` would
1687
- // unlock these — left for a future pass.
1688
- const paramNames = new Set(params.map(p => p.name))
1689
- let mutatesParam = false
1690
- let hasReturn = false
1691
- walk(body[0], (n) => {
1692
- if (!Array.isArray(n)) return
1693
- if ((n[0] === 'local.set' || n[0] === 'local.tee') && paramNames.has(n[1])) {
1694
- mutatesParam = true
1695
- }
1696
- if (n[0] === 'return' || n[0] === 'return_call' || n[0] === 'return_call_indirect') {
1697
- hasReturn = true
1698
- }
1699
- })
1700
- if (!mutatesParam && !hasReturn) {
1701
- inlinable.set(name, { body: body[0], params })
1702
- }
1897
+ // Pick a small, liftable, non-recursive callee with ≥1 plain-call site.
1898
+ let calleeName = null, parsed = null
1899
+ for (const [name, fn] of funcByName) {
1900
+ if (skip.has(name) || pinned.has(name) || otherRef.has(name) || SIMD_PROTECTED.has(name)) continue
1901
+ if (!(callRefs.get(name) >= 1)) continue
1902
+ if (inlBodySize(fn) > INLINE_MAX_NODES) continue
1903
+ if (inlCallsSelf(fn, name)) continue
1904
+ const p = inlParse(fn)
1905
+ if (!p) continue
1906
+ if (simdOnly && !isV128SimdHelper(p.params, p.inlResult)) continue
1907
+ let bad = false
1908
+ for (let i = inlBodyStart(fn); i < fn.length; i++) if (inlUnsafe(fn[i])) { bad = true; break }
1909
+ if (bad) continue
1910
+ calleeName = name; parsed = p; break
1703
1911
  }
1704
- }
1705
-
1706
- // Replace calls with inlined body
1707
- if (inlinable.size === 0) return ast
1708
-
1709
- walkPost(ast, (node) => {
1710
- if (!Array.isArray(node) || node[0] !== 'call') return
1711
- const fname = node[1]
1712
- if (!inlinable.has(fname)) return
1713
-
1714
- const { body, params } = inlinable.get(fname)
1715
- const args = node.slice(2)
1912
+ if (!calleeName) break
1716
1913
 
1717
- // Simple case: no params
1718
- if (params.length === 0) {
1719
- return clone(body)
1720
- }
1914
+ const callee = funcByName.get(calleeName)
1915
+ const { params, locals, inlResult } = parsed
1916
+ const cBody = callee.slice(inlBodyStart(callee))
1917
+ const expected = callRefs.get(calleeName) || 0 // callee is non-recursive ⇒ all sites are in other funcs
1918
+ let replaced = 0
1721
1919
 
1722
- // Substitute params with args
1723
- const substituted = walkPost(clone(body), (n) => {
1724
- if (!Array.isArray(n) || n[0] !== 'local.get') return
1725
- const local = n[1]
1726
- const paramIdx = params.findIndex(p => p.name === local)
1727
- if (paramIdx !== -1 && args[paramIdx]) {
1728
- return clone(args[paramIdx])
1920
+ // Splice into EVERY caller. A body that itself still calls an as-yet-uninlined
1921
+ // helper is fine — later rounds collapse it (or it stays a call).
1922
+ for (const fn of funcs) {
1923
+ if (fn === callee) continue
1924
+ const addDecls = []
1925
+ for (let i = inlBodyStart(fn); i < fn.length; i++) {
1926
+ fn[i] = walkPost(fn[i], (n) => {
1927
+ if (!Array.isArray(n) || n[0] !== 'call' || n[1] !== calleeName) return
1928
+ const args = n.slice(2)
1929
+ if (args.length !== params.length) return // arity mismatch — leave the call
1930
+ const { block, decls } = buildInline(params, locals, inlResult, cBody, args)
1931
+ addDecls.push(...decls)
1932
+ replaced++
1933
+ return block
1934
+ })
1729
1935
  }
1730
- })
1936
+ if (addDecls.length) fn.splice(inlBodyStart(fn), 0, ...addDecls)
1937
+ }
1731
1938
 
1732
- return substituted
1733
- })
1939
+ // Drop the callee only if every site inlined; else keep it and stop re-picking it.
1940
+ if (replaced === expected) { const idx = ast.indexOf(callee); if (idx >= 0) ast.splice(idx, 1) }
1941
+ else skip.add(calleeName)
1942
+ }
1734
1943
 
1735
1944
  return ast
1736
1945
  }
1737
1946
 
1738
1947
  // ==================== INLINE-ONCE ====================
1739
1948
 
1740
- let inlineUid = 0
1741
-
1742
1949
  /**
1743
1950
  * Devirtualize `call_indirect` through NaN-boxed closure values with a statically
1744
1951
  * known candidate set. `let f = c ? a : b; … f(x)` emits a select of two i64
@@ -2027,99 +2234,18 @@ const devirt = (ast) => {
2027
2234
  * @param {Array} ast
2028
2235
  * @returns {Array}
2029
2236
  */
2237
+ // Scalar transcendental helpers the auto-vectorizer rewrites to f64x2 mirrors (PPC_CALL2 in
2238
+ // src/optimize/vectorize.js). inlineOnce must NOT dissolve their call nodes when single-caller —
2239
+ // the post-phase lift needs the call to rewrite it. Keep in sync with PPC_CALL2's keys.
2240
+ const SIMD_PROTECTED = new Set(['$math.sin_core', '$math.cos_core', '$math.sin', '$math.cos', '$math.pow', '$math.atan2', '$math.hypot', '$math.log'])
2241
+
2030
2242
  const inlineOnce = (ast) => {
2031
2243
  if (!Array.isArray(ast) || ast[0] !== 'module') return ast
2032
2244
 
2033
- const HEAD = new Set(['export', 'type', 'param', 'result', 'local'])
2034
- const bodyStart = (fn) => {
2035
- let i = 2
2036
- while (i < fn.length && (typeof fn[i] === 'string' || (Array.isArray(fn[i]) && HEAD.has(fn[i][0])))) i++
2037
- return i
2038
- }
2039
- const isBranch = op => op === 'br' || op === 'br_if' || op === 'br_table'
2040
- // A subtree we can't lift into a (block …): depth-relative branch labels (shift
2041
- // under added nesting) or tail calls (would escape the wrapping block).
2042
- const unsafe = (n) => {
2043
- if (!Array.isArray(n)) return false
2044
- const op = n[0]
2045
- if (op === 'return_call' || op === 'return_call_indirect' || op === 'return_call_ref') return true
2046
- if (op === 'try' || op === 'try_table' || op === 'delegate' || op === 'rethrow') return true // exception labels — not handled by the relabeler below
2047
- if (isBranch(op)) for (let i = 1; i < n.length; i++) if (typeof n[i] === 'number' || (typeof n[i] === 'string' && /^\d+$/.test(n[i]))) return true
2048
- for (let i = 1; i < n.length; i++) if (unsafe(n[i])) return true
2049
- return false
2050
- }
2051
- const callsSelf = (n, name) => {
2052
- if (!Array.isArray(n)) return false
2053
- if ((n[0] === 'call' || n[0] === 'return_call') && n[1] === name) return true
2054
- for (let i = 1; i < n.length; i++) if (callsSelf(n[i], name)) return true
2055
- return false
2056
- }
2057
- // Locals must be re-zeroed each time the inlined block is entered IF the
2058
- // callee body actually relies on zero-init — i.e. some path reads the local
2059
- // before any unconditional write. In the original callee they got fresh
2060
- // zero-init per call; after inlining they're outer-func locals, zeroed only
2061
- // at outer entry, so a caller-loop that re-enters the inlined block reads
2062
- // stale values otherwise. Returns null for any type we can't safely
2063
- // zero-init here (skip inlining such callees).
2064
- const zeroFor = (t) => {
2065
- if (t === 'i32') return ['i32.const', 0]
2066
- if (t === 'i64') return ['i64.const', 0]
2067
- if (t === 'f32') return ['f32.const', 0]
2068
- if (t === 'f64') return ['f64.const', 0]
2069
- if (t === 'v128') return ['v128.const', 'i64x2', 0, 0]
2070
- // Nullable ref types (`(ref null …)`, `funcref`, `externref`, `anyref`, etc.)
2071
- // zero-init to `ref.null …` per call; emitting that here would need the exact
2072
- // heap-type. Non-nullable refs aren't zero-init at all (codegen must seed
2073
- // them). Either way, skip — let the call survive.
2074
- return null
2075
- }
2076
-
2077
- // Locals whose first observed use is a read — or whose first write is inside
2078
- // a conditional branch, where the alternate path bypasses it — depend on
2079
- // zero-init and need a reset when inlined into a caller-loop. Locals that
2080
- // are unconditionally written before any read (the common scratch pattern,
2081
- // e.g. `(local.set $bits (local.get $ptr))` opening a helper) don't, and
2082
- // emitting a spurious reset would only inflate that local's set-count and
2083
- // block downstream propagation/coalescing. Mirrors `coalesceLocals`'
2084
- // `readsZero` heuristic.
2085
- const needsReset = (body, name) => {
2086
- let seen = false, conditional = false, depth = 0
2087
- const visit = (n) => {
2088
- if (seen || !Array.isArray(n)) return
2089
- const op = n[0]
2090
- const isSet = op === 'local.set' || op === 'local.tee'
2091
- if ((isSet || op === 'local.get') && n[1] === name) {
2092
- if (isSet) for (let i = 2; i < n.length && !seen; i++) visit(n[i])
2093
- if (seen) return
2094
- seen = true
2095
- if (op === 'local.get' || depth > 0) conditional = true
2096
- return
2097
- }
2098
- const isIf = op === 'if'
2099
- for (let i = 1; i < n.length && !seen; i++) {
2100
- const c = n[i]
2101
- const cond = isIf && Array.isArray(c) && (c[0] === 'then' || c[0] === 'else')
2102
- if (cond) depth++
2103
- visit(c)
2104
- if (cond) depth--
2105
- }
2106
- }
2107
- for (const n of body) { if (seen) break; visit(n) }
2108
- // If the local is never used (dead), no reset; the dead decl will be pruned.
2109
- if (!seen) return false
2110
- return conditional
2111
- }
2112
-
2113
- // Module-level references that pin a function (can't be removed/inlined-away).
2114
- const collectPinned = (n, pinned) => {
2115
- if (!Array.isArray(n)) return
2116
- const op = n[0]
2117
- if (op === 'export' && Array.isArray(n[2]) && n[2][0] === 'func' && typeof n[2][1] === 'string') pinned.add(n[2][1])
2118
- else if (op === 'start' && typeof n[1] === 'string') pinned.add(n[1])
2119
- else if (op === 'ref.func' && typeof n[1] === 'string') pinned.add(n[1])
2120
- else if (op === 'elem') for (const c of n) if (typeof c === 'string' && c[0] === '$') pinned.add(c)
2121
- for (const c of n) collectPinned(c, pinned)
2122
- }
2245
+ // Lift primitives are shared with `inline` (defined once above buildInline). inlineOnce
2246
+ // splices into a SINGLE caller (never duplicating); `inline` duplicates into every caller.
2247
+ const bodyStart = inlBodyStart, callsSelf = inlCallsSelf, unsafe = inlUnsafe, isBranch = inlIsBranch
2248
+ const zeroFor = inlZeroFor, needsReset = inlNeedsReset, collectPinned = inlCollectPinned
2123
2249
 
2124
2250
  for (let round = 0; round < MAX_INLINE_ROUNDS; round++) {
2125
2251
  const funcs = ast.filter(n => Array.isArray(n) && n[0] === 'func')
@@ -2146,6 +2272,11 @@ const inlineOnce = (ast) => {
2146
2272
  for (const [name, fn] of funcByName) {
2147
2273
  if (pinned.has(name) || otherRef.has(name)) continue
2148
2274
  if (callRefs.get(name) !== 1) continue
2275
+ // Keep the scalar transcendentals that the auto-vectorizer maps to f64x2 mirrors (PPC_CALL2 in
2276
+ // src/optimize/vectorize.js): inlining a single-caller $math.atan2/hypot/log would dissolve the
2277
+ // call node before the post-phase lift can rewrite it to $math.atan2_2/hypot_2/log_v. Keep in
2278
+ // sync with PPC_CALL2's keys.
2279
+ if (SIMD_PROTECTED.has(name)) continue
2149
2280
  if (callsSelf(fn, name)) continue
2150
2281
  // named params/locals only (we'll rename them); reject locals with types
2151
2282
  // we can't zero-init on block re-entry.
@@ -2528,9 +2659,14 @@ const vacuum = (ast) => {
2528
2659
  // Remove nop entirely (return array marker; parent or post-pass cleans it)
2529
2660
  if (op === 'nop') return ['nop']
2530
2661
 
2531
- // (drop PURE) → nop
2532
- if (op === 'drop' && node.length === 2 && isPure(node[1])) {
2533
- return ['nop']
2662
+ // (drop V) → just V's side effects. Pure V vanishes (→ nop); a pure op over
2663
+ // a `local.tee` collapses to the bare store (kills the post-increment's dead
2664
+ // old-value arithmetic); impure V is kept under a drop.
2665
+ if (op === 'drop' && node.length === 2) {
2666
+ const eff = dropEffects(node[1])
2667
+ if (eff.length === 0) return ['nop']
2668
+ if (eff.length === 1) return eff[0]
2669
+ return ['block', ...eff]
2534
2670
  }
2535
2671
 
2536
2672
  // (select x x cond) → x — only when cond is PURE. An impure cond may set a
@@ -2558,13 +2694,20 @@ const vacuum = (ast) => {
2558
2694
  for (let i = 1; i < node.length; i++) {
2559
2695
  const child = node[i]
2560
2696
  if (child === 'nop' || (Array.isArray(child) && child[0] === 'nop')) continue
2561
- // Pure expression followed by standalone drop remove both
2697
+ // Stack-form `EXPR drop`: a pure EXPR drops out entirely; a bare
2698
+ // `tee X V drop` keeps just the store (`set X V`) — the dropped value
2699
+ // was the only reason it was a tee.
2562
2700
  const next = node[i + 1]
2563
2701
  const isDrop = next === 'drop' || (Array.isArray(next) && next[0] === 'drop' && next.length === 1)
2564
- if (Array.isArray(child) && isPure(child) && isDrop) {
2702
+ if (Array.isArray(child) && isDrop && isPure(child)) {
2565
2703
  i++ // skip the drop too
2566
2704
  continue
2567
2705
  }
2706
+ if (Array.isArray(child) && isDrop && child[0] === 'local.tee' && child.length === 3) {
2707
+ cleaned.push(['local.set', child[1], child[2]])
2708
+ i++ // skip the drop
2709
+ continue
2710
+ }
2568
2711
  cleaned.push(child)
2569
2712
  }
2570
2713
  if (cleaned.length !== node.length) return cleaned
@@ -3681,6 +3824,26 @@ const mayInline = (ast) => {
3681
3824
  return false
3682
3825
  }
3683
3826
 
3827
+ // A `__start` whose body DCE emptied down to nothing — plus its `(start)`
3828
+ // directive — is pure noise: the directive invokes a no-op. jz's buildStartFn
3829
+ // only emits `__start` when it has content, so an empty one is always a post-DCE
3830
+ // artifact (e.g. a top-level `1 + 2;` whose dropped value the dead-code pass
3831
+ // removed). Drop both. Header nodes (param/result/local/export/type) don't count
3832
+ // as a body — a function carrying only locals still does nothing.
3833
+ const START_HEAD = new Set(['export', 'type', 'param', 'result', 'local'])
3834
+ function pruneEmptyStart(ast) {
3835
+ if (!Array.isArray(ast) || ast[0] !== 'module') return ast
3836
+ const fn = ast.find(n => Array.isArray(n) && n[0] === 'func' && n[1] === '$__start')
3837
+ if (!fn) return ast
3838
+ // Skip the name (index 1) and header nodes; a bare-string node past them is a
3839
+ // real instruction (`drop`/`nop`/`unreachable`), so it stops the scan.
3840
+ let b = 2
3841
+ while (b < fn.length && Array.isArray(fn[b]) && START_HEAD.has(fn[b][0])) b++
3842
+ if (b < fn.length) return ast // real instructions remain
3843
+ return ast.filter(n => !(Array.isArray(n) &&
3844
+ ((n[0] === 'func' && n[1] === '$__start') || (n[0] === 'start' && n[1] === '$__start'))))
3845
+ }
3846
+
3684
3847
  export default function optimize(ast, opts = true) {
3685
3848
  const strictGuard = opts === true // default: zero tolerance for bloat
3686
3849
  opts = normalize(opts)
@@ -3696,7 +3859,22 @@ export default function optimize(ast, opts = true) {
3696
3859
  // not trip the size-guard into reverting a whole round. A single sweep is
3697
3860
  // complete: every call_indirect is visited; rewritten sites keep the original
3698
3861
  // as the guarded fallback arm.
3699
- const finish = (a) => opts.devirt ? devirt(a) : a
3862
+ // `inline` (multi-caller, size-for-speed) is like `devirt`: it INTENTIONALLY grows
3863
+ // the binary, so it must run OUTSIDE the per-round size-revert guard below (which
3864
+ // would otherwise undo it). Run it once after the rounds converge, then tidy the
3865
+ // (block (local.set $p arg) … body) wrappers it leaves with the same cleanup passes
3866
+ // a normal round would. opt-in (speed level); a no-op when no small callee qualifies.
3867
+ const runInline = (a) => {
3868
+ if (!opts.inline) return a
3869
+ // `inline: true` → safe SIMD-helper-only default; `inline: 'all'` → unrestricted.
3870
+ a = inline(a, { simdOnly: opts.inline !== 'all' })
3871
+ if (opts.propagate) a = propagate(a)
3872
+ if (opts.mergeBlocks) a = mergeBlocks(a)
3873
+ if (opts.vacuum) a = vacuum(a)
3874
+ if (opts.coalesceLocals) a = coalesceLocals(a)
3875
+ return a
3876
+ }
3877
+ const finish = (a) => { a = runInline(a); return pruneEmptyStart(opts.devirt ? devirt(a) : a) }
3700
3878
 
3701
3879
  // Fast path: jz owns this optimizer and feeds it a controlled, type-aware IR.
3702
3880
  // The only passes that can *grow* the binary are inlineOnce/inline; when no
@@ -3727,7 +3905,7 @@ export default function optimize(ast, opts = true) {
3727
3905
  for (let round = 0; round < 3; round++) {
3728
3906
  beforeRound = clone(ast)
3729
3907
 
3730
- for (const [key, fn] of PASSES) if (opts[key] && key !== 'devirt') ast = fn(ast)
3908
+ for (const [key, fn] of PASSES) if (opts[key] && key !== 'devirt' && key !== 'inline') ast = fn(ast)
3731
3909
  // Second propagate sweep: `inlineOnce`/`inline` (above) leave fresh
3732
3910
  // `(local.set $p arg) … (local.get $p)` wrappers around each inlined call;
3733
3911
  // re-running propagation collapses them within this same round, so the size