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
@@ -59,12 +59,12 @@ const FALSE_BITS = atomNanHex(4)
59
59
  * 3 — level 2 + larger array/hash initial caps + `hoistConstantPool` off
60
60
  * (inline `f64.const` over mutable globals); trades size for speed.
61
61
  *
62
- * String aliases (the size↔speed tradeoff lives entirely in the unroll/scalar
63
- * knobs; watr is on for all three):
64
- * 'size' — loop/const unroll + lane vectorization off, tight scalar-replacement
65
- * caps. Smallest wasm.
66
- * 'balanced' — the default (= level 2).
67
- * 'speed' — full nested unroll + lane vectorization (= level 3).
62
+ * String presets (the size↔speed tradeoff lives entirely in the unroll/scalar
63
+ * knobs; watr is on for both):
64
+ * 'size' — loop/const unroll + lane vectorization off, tight scalar-replacement
65
+ * caps. Smallest wasm.
66
+ * 'speed' — full nested unroll + lane vectorization (= level 3).
67
+ * The default (level 2) has no string name — omit `optimize` or pass `2`.
68
68
  *
69
69
  * # Two-layer contract (this file vs src/wat/optimize.js)
70
70
  * Both layers walk the same S-expression IR; the boundary is KNOWLEDGE, not
@@ -143,9 +143,9 @@ const LEVEL_PRESETS = Object.freeze({
143
143
  // closures). Inline `f64.const` is the minimal lowering: V8 CSEs identical
144
144
  // constants for free. Measured −3% on jessie parse for +14% binary — exactly
145
145
  // the size↔speed trade 'speed' exists to make.
146
- 3: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8 }),
147
- // 'balanced' = level 2; 'size' tightens scalar/unroll caps; 'speed' = level 3.
148
- balanced: Object.freeze({ ...ALL_ON, nestedSmallConstForUnroll: 'auto' }),
146
+ 3: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8, reduceUnroll: true, relaxedSimd: true, inlineFns: true }),
147
+ // 'size' tightens scalar/unroll caps; 'speed' = level 3. There is no 'balanced'
148
+ // preset — it was a pure synonym for the default level 2 (omit `optimize` or pass 2).
149
149
  size: Object.freeze({
150
150
  ...ALL_ON,
151
151
  smallConstForUnroll: false, nestedSmallConstForUnroll: false, vectorizeLaneLocal: false, splitCharScan: false,
@@ -154,7 +154,16 @@ const LEVEL_PRESETS = Object.freeze({
154
154
  scalarTypedLoopUnroll: 4, scalarTypedNestedUnroll: 8, scalarTypedArrayLen: 8,
155
155
  }),
156
156
  // 'speed' === level 3: full watr (inlining on) + L3 cap/hash tuning, pool off.
157
- speed: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8 }),
157
+ // reduceUnroll: vectorize reductions with N independent accumulators (ILP/latency
158
+ // hiding, ~3x on dot/FIR sums) — a size↔speed trade like the pool-off above, so
159
+ // speed-only; level 2 / balanced / size keep the single-accumulator reduce.
160
+ // relaxedSimd: fold f64x2 dot-pairs to f64x2.relaxed_madd (single fused VFMADD,
161
+ // one rounding) — faster + more accurate, but the fused result diverges bit-for-bit
162
+ // from the non-fused JS/native reference (bench `fma` parity class). speed-only.
163
+ // (The stencil + outer-strip vectorizers are NOT level-gated here: they're bit-exact pure wins
164
+ // like the base lane vectorizer, so they run whenever it does — default-on at level 2+ via
165
+ // `cfg.experimentalStencil !== false` at the call site, not a speed-only size/precision trade.)
166
+ speed: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8, reduceUnroll: true, relaxedSimd: true, inlineFns: true }),
158
167
  })
159
168
 
160
169
  /**
@@ -163,7 +172,7 @@ const LEVEL_PRESETS = Object.freeze({
163
172
  * resolveOptimize(undefined | true) → level 2 stable defaults
164
173
  * resolveOptimize(false | 0) → all off
165
174
  * resolveOptimize(1 | 2 | 3) → preset for that level
166
- * resolveOptimize('size' | 'speed' | 'balanced') named alias preset
175
+ * resolveOptimize('size' | 'speed') → named preset ('speed' = level 3)
167
176
  * resolveOptimize({ level: 1, watr: true }) → level 1 base, with watr forced on
168
177
  * resolveOptimize({ level: 'size', vectorizeLaneLocal: true }) → 'size' base, override
169
178
  * resolveOptimize({ hoistAddrBase: false }) → level 2 base, hoistAddrBase off
@@ -433,6 +442,24 @@ export function hoistAddrBase(fn) {
433
442
  // loop-invariant __jss_length in the same loop condition CAN hoist).
434
443
  const SAFE_OFFSET_CALLS = new Set(['$__ptr_offset', '$__ptr_type', '$__ptr_aux', '$__len', '$__jss_length', '$__jss_charCodeAt'])
435
444
 
445
+ // Calls that don't modify EXISTING heap memory: they may allocate (bump the heap
446
+ // pointer) or do tag dispatch, but they never write to an address a hoisted
447
+ // __typed_idx/__str_idx element read would revisit. Their presence must not
448
+ // block readonly-mem-call LICM (else any `s += unknown` — which dispatches via
449
+ // __is_str_key/__str_concat — would pin every invariant array element in-loop:
450
+ // the jagged-array `grid[i][j]` deopt).
451
+ const NON_MUTATING_CALLS = new Set(['$__is_str_key', '$__str_concat', '$__to_num', '$__to_str', '$__str_byteLen'])
452
+
453
+ // Read-only HEAP-MEMORY calls: like SAFE_OFFSET_CALLS but they read element
454
+ // storage that a direct f64.store/i32.store in the loop could alias. Safe to
455
+ // hoist only when the loop has no mutating call AND no direct store at all (we
456
+ // can't do alias analysis at WAT level). __typed_idx/__str_idx read arr[i] /
457
+ // s[i]; plain-array element writes go through calls (caught by hasUnsafeCall),
458
+ // and typed-array writes are direct stores (caught by hasDirectStore) — so the
459
+ // guard covers both. This is what lets LICM hoist `grid[i]` out of a read-only
460
+ // `for(j) { ... grid[i][j] ... }` inner loop (the jagged-array deopt).
461
+ const READONLY_MEM_CALLS = new Set(['$__typed_idx', '$__str_idx'])
462
+
436
463
  export function hoistInvariantPtrOffset(fn) {
437
464
  if (!Array.isArray(fn) || fn[0] !== 'func') return
438
465
  const bodyStart = findBodyStart(fn)
@@ -550,6 +577,22 @@ const HARD_OPS = new Set([
550
577
  ])
551
578
  const hasHardOp = (n) => Array.isArray(n) && (HARD_OPS.has(n[0]) || n.some((c, i) => i > 0 && hasHardOp(c)))
552
579
 
580
+ // The inline typed-array base decode `(i32.wrap_i64 (i64.and (i64.reinterpret_f64
581
+ // (local|global X)) 0xFFFFFFFF))` — what `typedBase` emits for a NaN-boxed pointer.
582
+ // V8's wasm tier does NOT reliably LICM this i64 reinterpret chain, and it carries no
583
+ // HARD_OP, so without this it stays per-element inside the loop. It is the typed-read
584
+ // equivalent of the `__ptr_offset` call (a HARD_OP) that hoistGlobalPtrOffset hoists at
585
+ // function scope; admitting it here also covers a pointer reassigned ELSEWHERE in the
586
+ // function (the ping-pong double-buffer `a = b` in wireworld / any CA), where the base
587
+ // is invariant within each loop but not function-wide.
588
+ const isPtrBaseDecode = (n) =>
589
+ Array.isArray(n) && n[0] === 'i32.wrap_i64' && n.length === 2 &&
590
+ Array.isArray(n[1]) && n[1][0] === 'i64.and' && n[1].length === 3 &&
591
+ Array.isArray(n[1][2]) && n[1][2][0] === 'i64.const' &&
592
+ (typeof n[1][2][1] === 'string' ? Number(n[1][2][1]) : n[1][2][1]) === LAYOUT.OFFSET_MASK &&
593
+ Array.isArray(n[1][1]) && n[1][1][0] === 'i64.reinterpret_f64' && n[1][1].length === 2 &&
594
+ Array.isArray(n[1][1][1]) && (n[1][1][1][0] === 'local.get' || n[1][1][1][0] === 'global.get')
595
+
553
596
  const PURE_LICM_OPS = new Set([
554
597
  'f64.add', 'f64.sub', 'f64.mul', 'f64.div', 'f64.neg', 'f64.abs', 'f64.sqrt',
555
598
  'f64.min', 'f64.max', 'f64.ceil', 'f64.floor', 'f64.trunc', 'f64.nearest', 'f64.copysign',
@@ -613,7 +656,27 @@ export function hoistInvariantLoop(fn) {
613
656
  if (!Array.isArray(node)) return null
614
657
  const op = node[0]
615
658
  if (op === 'select') return resultType(node[1])
616
- if (op === 'call') return SAFE_OFFSET_CALLS.has(node[1]) ? 'i32' : null // whitelist all return i32
659
+ if (op === 'if') {
660
+ // (if (result T) cond (then ...) (else ...)) — type is the result clause.
661
+ for (let i = 1; i < node.length; i++) {
662
+ const c = node[i]
663
+ if (Array.isArray(c) && c[0] === 'result') return c[1]
664
+ }
665
+ return null
666
+ }
667
+ if (op === 'block') {
668
+ for (let i = 1; i < node.length; i++) {
669
+ const c = node[i]
670
+ if (Array.isArray(c) && c[0] === 'result') return c[1]
671
+ }
672
+ return null
673
+ }
674
+ if (op === 'call') {
675
+ // SAFE_OFFSET_CALLS all return i32; READONLY_MEM_CALLS return f64 (NaN-boxed element)
676
+ if (SAFE_OFFSET_CALLS.has(node[1])) return 'i32'
677
+ if (READONLY_MEM_CALLS.has(node[1])) return 'f64'
678
+ return null
679
+ }
617
680
  if (op === 'local.get' || op === 'local.tee') return localTypes.get(node[1]) ?? null
618
681
  const dot = op.indexOf('.')
619
682
  if (dot < 0) return null
@@ -644,23 +707,27 @@ export function hoistInvariantLoop(fn) {
644
707
  const newLocals = []
645
708
  const refcount = buildRefcount(fn)
646
709
 
647
- const processLoop = (loopNode) => {
710
+ const processLoop = (loopNode, nested) => {
648
711
  // Inner loops first (bottom-up) — an inner hoist creates a local.get the
649
- // outer level can hoist further.
712
+ // outer level can hoist further. Children run in a nested context.
650
713
  for (let i = 1; i < loopNode.length; i++)
651
- if (Array.isArray(loopNode[i])) processNode(loopNode[i], loopNode, i)
714
+ if (Array.isArray(loopNode[i])) processNode(loopNode[i], loopNode, i, true)
652
715
 
653
716
  // The loop's effect summary (scans nested loops too — conservative).
654
717
  const locals = new Set(), globals = new Set(), storedCells = new Set()
655
- let hasUnsafeCall = false, hasAnyCall = false
718
+ let hasUnsafeCall = false, hasAnyCall = false, hasDirectStore = false, hasV128 = false
656
719
  const scan = (node) => {
657
720
  if (!Array.isArray(node)) return
658
721
  const op = node[0]
722
+ // A vectorized loop (lane/v128 ops) is already register-tight and hand-tuned;
723
+ // extra scalar hoisting there only adds spill pressure — keep it conservative.
724
+ if (op.startsWith('v128.') || /^[if]\d+x\d+\./.test(op)) hasV128 = true
659
725
  if (op === 'local.set' || op === 'local.tee') { if (typeof node[1] === 'string') locals.add(node[1]); for (let i = 2; i < node.length; i++) scan(node[i]); return }
660
726
  if (op === 'global.set') { if (typeof node[1] === 'string') globals.add(node[1]); for (let i = 2; i < node.length; i++) scan(node[i]); return }
661
- if (op === 'call') { hasAnyCall = true; if (!SAFE_OFFSET_CALLS.has(node[1])) hasUnsafeCall = true; for (let i = 2; i < node.length; i++) scan(node[i]); return }
727
+ if (op === 'call') { hasAnyCall = true; if (!SAFE_OFFSET_CALLS.has(node[1]) && !READONLY_MEM_CALLS.has(node[1]) && !NON_MUTATING_CALLS.has(node[1])) hasUnsafeCall = true; for (let i = 2; i < node.length; i++) scan(node[i]); return }
662
728
  if (op === 'call_ref' || op === 'call_indirect') { hasAnyCall = hasUnsafeCall = true; for (let i = 1; i < node.length; i++) scan(node[i]); return }
663
729
  if ((op === 'f64.store' || op === 'i32.store') && node.length >= 3) {
730
+ hasDirectStore = true
664
731
  const a = node[1]
665
732
  if (Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' && a[1].startsWith(CELL_PREFIX)) storedCells.add(a[1])
666
733
  }
@@ -731,7 +798,27 @@ export function hoistInvariantLoop(fn) {
731
798
  return Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' && a[1].startsWith(CELL_PREFIX)
732
799
  && !hasAnyCall && !storedCells.has(a[1]) && (bound.has(a[1]) || !locals.has(a[1]))
733
800
  }
734
- if (op === 'call') return SAFE_OFFSET_CALLS.has(node[1]) && !hasUnsafeCall && node.slice(2).every(c => pureGiven(c, bound))
801
+ if (op === 'call') {
802
+ if (SAFE_OFFSET_CALLS.has(node[1]))
803
+ return !hasUnsafeCall && node.slice(2).every(c => pureGiven(c, bound))
804
+ // Read-only heap reads: additionally require no direct store (alias-safe).
805
+ if (READONLY_MEM_CALLS.has(node[1]))
806
+ return !hasUnsafeCall && !hasDirectStore && node.slice(2).every(c => pureGiven(c, bound))
807
+ return false
808
+ }
809
+ // A value-producing `if` whose condition and both arms are pure is itself
810
+ // pure — the tag-dispatch idiom `(if (result f64) tag-check (then read-A)
811
+ // (else read-B))` that wraps __typed_idx/__str_idx element access.
812
+ if (op === 'if') {
813
+ for (let i = 1; i < node.length; i++) {
814
+ const c = node[i]
815
+ if (!Array.isArray(c)) continue
816
+ if (c[0] === 'result') continue
817
+ if (c[0] === 'then' || c[0] === 'else') { if (!c.slice(1).every(x => pureGiven(x, bound))) return false }
818
+ else if (!pureGiven(c, bound)) return false // the condition
819
+ }
820
+ return true
821
+ }
735
822
  if (PURE_LICM_OPS.has(op)) return node.slice(1).every(c => pureGiven(c, bound))
736
823
  return false
737
824
  }
@@ -745,9 +832,14 @@ export function hoistInvariantLoop(fn) {
745
832
  // Every local the subtree writes must be private to it (no other use in the
746
833
  // loop) — else moving the write to the pre-header changes another reader.
747
834
  for (const b of bound) if (localCount.get(b) !== countsOf(node).get(b)) return false
748
- // Only hoist what V8's wasm tier won't (a HARD_OP) leave pure arithmetic
749
- // for V8's own LICM and the lane-vectorizer.
750
- return hasHardOp(node) && pureGiven(node, bound)
835
+ // Top-level loops: only hoist what V8's wasm tier won't a HARD_OP or the
836
+ // inline typed-array base decode and leave plain pure arithmetic to V8's own
837
+ // LICM (which handles single-level loops well). NESTED (inner) loops are
838
+ // different: V8's wasm tier under-hoists invariants out of them (a nested
839
+ // rasterizer/convolution recomputes triangle/row-invariant subexpressions every
840
+ // iteration), so hoist any pure-invariant subtree there. Soundness is unchanged —
841
+ // `pureGiven` already proves the subtree is loop-invariant and side-effect-free.
842
+ return ((nested && !hasV128) || hasHardOp(node) || isPtrBaseDecode(node)) && pureGiven(node, bound)
751
843
  }
752
844
 
753
845
  // Maximal extraction: take the largest hoistable subtree; don't descend into
@@ -780,17 +872,17 @@ export function hoistInvariantLoop(fn) {
780
872
  return snaps
781
873
  }
782
874
 
783
- const processNode = (node, parent, idx) => {
875
+ const processNode = (node, parent, idx, nested = false) => {
784
876
  if (!Array.isArray(node)) return
785
877
  if (node[0] === 'loop') {
786
- const snaps = processLoop(node)
878
+ const snaps = processLoop(node, nested)
787
879
  if (snaps.length) parent.splice(idx, 0, ...snaps)
788
880
  return
789
881
  }
790
- for (let i = 0; i < node.length; i++) processNode(node[i], node, i)
882
+ for (let i = 0; i < node.length; i++) processNode(node[i], node, i, nested)
791
883
  }
792
884
 
793
- for (let i = bodyStart; i < fn.length; i++) processNode(fn[i], fn, i)
885
+ for (let i = bodyStart; i < fn.length; i++) processNode(fn[i], fn, i, false)
794
886
  if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
795
887
  }
796
888
 
@@ -1768,23 +1860,52 @@ export function hoistGlobalPtrOffset(fn, stablePtrGlobals, reachableWrites) {
1768
1860
  const bodyStart = findBodyStart(fn)
1769
1861
  if (bodyStart < 0) return
1770
1862
 
1771
- // `(call $__ptr_offset (i64.reinterpret_f64 (global.get $G)))` → G, or null.
1772
- const siteGlobal = (n) =>
1773
- Array.isArray(n) && n[0] === 'call' && n[1] === '$__ptr_offset' && n.length === 3
1774
- && Array.isArray(n[2]) && n[2][0] === 'i64.reinterpret_f64'
1775
- && Array.isArray(n[2][1]) && n[2][1][0] === 'global.get' && typeof n[2][1][1] === 'string'
1776
- ? n[2][1][1] : null
1863
+ // `(i64.reinterpret_f64 (global.get $G))` → G, or null.
1864
+ const reintGlobal = (n) =>
1865
+ Array.isArray(n) && n[0] === 'i64.reinterpret_f64'
1866
+ && Array.isArray(n[1]) && n[1][0] === 'global.get' && typeof n[1][1] === 'string'
1867
+ ? n[1][1] : null
1868
+ // A stable-pointee global's byte-base reaches us in two interchangeable shapes:
1869
+ // • forwarding-aware `(call $__ptr_offset (i64.reinterpret_f64 (global.get $G)))`
1870
+ // • inline typed read `(i32.wrap_i64 (i64.and (i64.reinterpret_f64 (global.get $G)) MASK))`
1871
+ // The inline form is what typed-array reads emit (a fixed-size typed array never
1872
+ // relocates, so they skip __ptr_offset's forwarding follow — see module/typedarray.js
1873
+ // `typedBase`). For a never-forwarding pointee both yield the identical offset, so
1874
+ // either site hoists to the one `__ptr_offset` entry snapshot. Matching only the
1875
+ // call form left typed-array globals re-decoding the NaN-box per element in stencil
1876
+ // sweeps (watercolor's pressure solve: 5 reads/cell × millions of cells). → G, or null.
1877
+ const siteGlobal = (n) => {
1878
+ if (!Array.isArray(n)) return null
1879
+ if (n[0] === 'call' && n[1] === '$__ptr_offset' && n.length === 3) return reintGlobal(n[2])
1880
+ if (n[0] === 'i32.wrap_i64' && n.length === 2 && Array.isArray(n[1]) && n[1][0] === 'i64.and' && n[1].length === 3) {
1881
+ const mask = n[1][2]
1882
+ if (Array.isArray(mask) && mask[0] === 'i64.const'
1883
+ && (typeof mask[1] === 'string' ? Number(mask[1]) : mask[1]) === LAYOUT.OFFSET_MASK)
1884
+ return reintGlobal(n[1][1])
1885
+ }
1886
+ return null
1887
+ }
1777
1888
 
1778
1889
  // Per-global: static site count AND whether any site sits inside a loop. A
1779
1890
  // single in-loop site is a per-ITERATION resolve (lenia's convolution reads
1780
1891
  // each of kdx/kdy/kw at one site × ~14M taps/frame), so loop placement beats
1781
1892
  // site count as the hoist criterion.
1782
1893
  const counts = new Map(), inLoop = new Set(), ownWrites = new Set(), ownCallees = new Set()
1894
+ // Globals seen via the `__ptr_offset` call form (vs. only the inline typed mask).
1895
+ // The snapshot reuses an EXISTING form so it never resurrects a treeshaken helper:
1896
+ // a typed-array-only module emits no `__ptr_offset` call, so snapping one in would
1897
+ // reference a function that isn't in the module.
1898
+ const ptrOffsetForm = new Set()
1783
1899
  let hasIndirect = false
1784
1900
  const scan = (n, loopDepth) => {
1785
1901
  if (!Array.isArray(n)) return
1786
1902
  const g = siteGlobal(n)
1787
- if (g != null) { counts.set(g, (counts.get(g) || 0) + 1); if (loopDepth > 0) inLoop.add(g); return }
1903
+ if (g != null) {
1904
+ counts.set(g, (counts.get(g) || 0) + 1)
1905
+ if (loopDepth > 0) inLoop.add(g)
1906
+ if (n[0] === 'call') ptrOffsetForm.add(g)
1907
+ return
1908
+ }
1788
1909
  if (n[0] === 'global.set' && typeof n[1] === 'string') ownWrites.add(n[1])
1789
1910
  else if ((n[0] === 'call' || n[0] === 'return_call') && typeof n[1] === 'string') ownCallees.add(n[1])
1790
1911
  else if (n[0] === 'call_indirect' || n[0] === 'call_ref' || n[0] === 'return_call_indirect') hasIndirect = true
@@ -1833,7 +1954,13 @@ export function hoistGlobalPtrOffset(fn, stablePtrGlobals, reachableWrites) {
1833
1954
  const decls = [], snaps = []
1834
1955
  for (const [g, name] of chosen) {
1835
1956
  decls.push(['local', name, 'i32'])
1836
- snaps.push(['local.set', name, ['call', '$__ptr_offset', ['i64.reinterpret_f64', ['global.get', g]]]])
1957
+ // Match an existing site's form so we never reference a treeshaken helper.
1958
+ // For a never-forwarding pointee both forms compute the same offset, so the
1959
+ // inline mask is a safe (and call-free) snapshot when no __ptr_offset site exists.
1960
+ const snap = ptrOffsetForm.has(g)
1961
+ ? ['call', '$__ptr_offset', ['i64.reinterpret_f64', ['global.get', g]]]
1962
+ : ['i32.wrap_i64', ['i64.and', ['i64.reinterpret_f64', ['global.get', g]], ['i64.const', LAYOUT.OFFSET_MASK]]]
1963
+ snaps.push(['local.set', name, snap])
1837
1964
  }
1838
1965
  fn.splice(bodyStart, 0, ...decls, ...snaps)
1839
1966
  }
@@ -2374,6 +2501,146 @@ export function sortStrPoolByFreq(funcs, strPoolRef, strDedupMap) {
2374
2501
  }
2375
2502
  }
2376
2503
 
2504
+ /**
2505
+ * Fold dead string-dispatch blocks when the tested operand is a proven-f64 local.
2506
+ *
2507
+ * jz's `+` emitter produces, for every binary addition whose right operand has an
2508
+ * unresolved valType, the pattern:
2509
+ *
2510
+ * (block (result f64)
2511
+ * (local.set $B EXPR_A)
2512
+ * (if (result f64)
2513
+ * (call $__is_str_key (i64.reinterpret_f64 (local.tee $C (local.get $P))))
2514
+ * (then (call $__str_concat …))
2515
+ * (else (f64.add (local.get $B) (local.get $C)))))
2516
+ *
2517
+ * When $P is a proven-f64 local (an f64 param, or an f64-typed local provably set
2518
+ * only from f64 arithmetic) it can never hold a string-key NaN-box, so the
2519
+ * `$__is_str_key` test is provably false and the `then` branch is dead.
2520
+ * Replace the whole block with `(f64.add EXPR_A (local.get $P))`.
2521
+ *
2522
+ * SOUND: f64 params can never hold a string-key NaN-box by construction (jz
2523
+ * only allows strings in f64 slots via explicit mkptr boxing, never a bare
2524
+ * param). This fold is additive/gated (only runs when vectorizeLaneLocal is on)
2525
+ * and only removes provably-dead string-dispatch overhead.
2526
+ *
2527
+ * Called in the 'post' phase of optimizeFunc, before vectorizeLaneLocal, so the
2528
+ * cleaned IR is what the vectorizer pattern-matches.
2529
+ */
2530
+ export function foldStrDispatchF64(fn) {
2531
+ if (!Array.isArray(fn) || fn[0] !== 'func') return
2532
+ const bodyStart = findBodyStart(fn)
2533
+ if (bodyStart < 0) return
2534
+
2535
+ // Collect all f64 params — provably never hold a string-key NaN-box.
2536
+ const rawF64 = new Set()
2537
+ for (let i = 2; i < bodyStart; i++) {
2538
+ const d = fn[i]
2539
+ if (Array.isArray(d) && d[0] === 'param' && typeof d[1] === 'string' && d[2] === 'f64')
2540
+ rawF64.add(d[1])
2541
+ }
2542
+ if (!rawF64.size) return // no f64 params → nothing to fold
2543
+
2544
+ // Transitively extend rawF64: an f64 local set only via f64 arithmetic over rawF64
2545
+ // members is itself provably non-string. One forward pass suffices for DAG-shaped
2546
+ // straight-line code (the common case); a fixed-point loop covers rare mutual defs.
2547
+ // Collect local types first.
2548
+ const localTypeMap = new Map()
2549
+ for (let i = 2; i < bodyStart; i++) {
2550
+ const d = fn[i]
2551
+ if (Array.isArray(d) && (d[0] === 'param' || d[0] === 'local') && typeof d[1] === 'string')
2552
+ localTypeMap.set(d[1], d[2])
2553
+ }
2554
+ // An expression is rawF64-valued if it only uses ops that stay in f64 and
2555
+ // reads only rawF64 locals (or f64.const). Stops early — we only need the
2556
+ // closed set for the pattern's $P operand.
2557
+ const isRawF64Expr = (n) => {
2558
+ if (!Array.isArray(n)) return false
2559
+ const op = n[0]
2560
+ if (op === 'f64.const') return true
2561
+ if (op === 'local.get' && typeof n[1] === 'string') return rawF64.has(n[1])
2562
+ if (op === 'local.tee' && typeof n[1] === 'string') return rawF64.has(n[1]) && isRawF64Expr(n[2])
2563
+ if (op === 'f64.add' || op === 'f64.sub' || op === 'f64.mul' || op === 'f64.div' ||
2564
+ op === 'f64.neg' || op === 'f64.abs' || op === 'f64.sqrt') {
2565
+ return n.slice(1).every(isRawF64Expr)
2566
+ }
2567
+ return false
2568
+ }
2569
+
2570
+ // Single forward pass: a local.set $v EXPR where EXPR is rawF64-valued makes $v rawF64.
2571
+ // Repeat until stable (handles ordering edge cases in non-DAG code).
2572
+ let changed = true
2573
+ while (changed) {
2574
+ changed = false
2575
+ const scan = (node) => {
2576
+ if (!Array.isArray(node)) return
2577
+ if ((node[0] === 'local.set' || node[0] === 'local.tee') && typeof node[1] === 'string' &&
2578
+ localTypeMap.get(node[1]) === 'f64' && !rawF64.has(node[1]) && isRawF64Expr(node[2])) {
2579
+ rawF64.add(node[1]); changed = true
2580
+ }
2581
+ for (let i = 1; i < node.length; i++) scan(node[i])
2582
+ }
2583
+ for (let i = bodyStart; i < fn.length; i++) scan(fn[i])
2584
+ }
2585
+
2586
+ // Pattern-match and fold in-place (bottom-up recursive walk so nested blocks resolve).
2587
+ const foldNode = (node) => {
2588
+ if (!Array.isArray(node)) return node
2589
+ // Recurse children first (bottom-up).
2590
+ for (let i = 0; i < node.length; i++) {
2591
+ const c = node[i]
2592
+ if (Array.isArray(c)) node[i] = foldNode(c)
2593
+ }
2594
+ // Match:
2595
+ // ['block', ['result','f64'],
2596
+ // ['local.set', $B, EXPR_A],
2597
+ // ['if', ['result','f64'],
2598
+ // ['call','$__is_str_key', ['i64.reinterpret_f64', ['local.tee',$C,['local.get',$P]]]],
2599
+ // ['then', ['call','$__str_concat',...]],
2600
+ // ['else', ['f64.add', ['local.get',$B], ['local.get',$C]]]]]
2601
+ if (node[0] !== 'block') return node
2602
+ if (!Array.isArray(node[1]) || node[1][0] !== 'result' || node[1][1] !== 'f64') return node
2603
+ if (node.length !== 4) return node
2604
+ const setStmt = node[2], ifStmt = node[3]
2605
+ if (!Array.isArray(setStmt) || setStmt[0] !== 'local.set' || typeof setStmt[1] !== 'string') return node
2606
+ const B = setStmt[1], exprA = setStmt[2]
2607
+ if (!Array.isArray(ifStmt) || ifStmt[0] !== 'if') return node
2608
+ // if must have: (result f64), cond, then, else — total 5 elements
2609
+ if (ifStmt.length !== 5) return node
2610
+ if (!Array.isArray(ifStmt[1]) || ifStmt[1][0] !== 'result' || ifStmt[1][1] !== 'f64') return node
2611
+ const cond = ifStmt[2], thenB = ifStmt[3], elseB = ifStmt[4]
2612
+ // cond: ['call','$__is_str_key',['i64.reinterpret_f64',['local.tee',$C,['local.get',$P]]]]
2613
+ if (!Array.isArray(cond) || cond[0] !== 'call' || cond[1] !== '$__is_str_key' || cond.length !== 3) return node
2614
+ const reinterpArg = cond[2]
2615
+ if (!Array.isArray(reinterpArg) || reinterpArg[0] !== 'i64.reinterpret_f64' || reinterpArg.length !== 2) return node
2616
+ const teeNode = reinterpArg[1]
2617
+ if (!Array.isArray(teeNode) || teeNode[0] !== 'local.tee' || typeof teeNode[1] !== 'string' || teeNode.length !== 3) return node
2618
+ const C = teeNode[1]
2619
+ const getP = teeNode[2]
2620
+ if (!Array.isArray(getP) || getP[0] !== 'local.get' || typeof getP[1] !== 'string') return node
2621
+ const P = getP[1]
2622
+ // $P must be a proven f64 local (never a string-key NaN-box)
2623
+ if (!rawF64.has(P)) return node
2624
+ // then: ['then', ['call','$__str_concat',...]]
2625
+ if (!Array.isArray(thenB) || thenB[0] !== 'then') return node
2626
+ // else: ['else', ['f64.add', ['local.get',$B], ['local.get',$C]]]
2627
+ if (!Array.isArray(elseB) || elseB[0] !== 'else' || elseB.length !== 2) return node
2628
+ const addExpr = elseB[1]
2629
+ if (!Array.isArray(addExpr) || addExpr[0] !== 'f64.add' || addExpr.length !== 3) return node
2630
+ // The two operands of f64.add must be local.get $B and local.get $C (in either order)
2631
+ const [lhsAdd, rhsAdd] = [addExpr[1], addExpr[2]]
2632
+ const lhsIsB = Array.isArray(lhsAdd) && lhsAdd[0] === 'local.get' && lhsAdd[1] === B
2633
+ const rhsIsC = Array.isArray(rhsAdd) && rhsAdd[0] === 'local.get' && rhsAdd[1] === C
2634
+ const lhsIsC = Array.isArray(lhsAdd) && lhsAdd[0] === 'local.get' && lhsAdd[1] === C
2635
+ const rhsIsB = Array.isArray(rhsAdd) && rhsAdd[0] === 'local.get' && rhsAdd[1] === B
2636
+ if (!((lhsIsB && rhsIsC) || (lhsIsC && rhsIsB))) return node
2637
+ // Match confirmed. Fold to: (f64.add EXPR_A (local.get $P))
2638
+ return ['f64.add', exprA, ['local.get', P]]
2639
+ }
2640
+
2641
+ for (let i = bodyStart; i < fn.length; i++) fn[i] = foldNode(fn[i])
2642
+ }
2643
+
2377
2644
  /**
2378
2645
  * Run all per-function IR optimizations on a single function node.
2379
2646
  * hoistPtrType runs first — it introduces new locals (`$__ptN`) that the fused
@@ -2432,7 +2699,11 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
2432
2699
  if (cfg && cfg.vectorizeLaneLocal === true) {
2433
2700
  const fullWatr = cfg.watr === true || typeof cfg.watr === 'object'
2434
2701
  const runVectorizer = (fullWatr && phase === 'post') || (!fullWatr && phase !== 'post')
2435
- if (runVectorizer) vectorizeLaneLocal(fn)
2702
+ // Phase 1: fold dead string-dispatch blocks on proven-f64 locals BEFORE
2703
+ // the vectorizer pattern-matches — dead __is_str_key calls in $fbm-style
2704
+ // functions (param f64 + op f64) block liftPPC from recognizing them as pure.
2705
+ if (runVectorizer) foldStrDispatchF64(fn)
2706
+ if (runVectorizer) vectorizeLaneLocal(fn, cfg.reduceUnroll === true, cfg.relaxedSimd === true, cfg.blurMultiPixel !== false, cfg.whyNotSimd === true, cfg.experimentalStencil !== false, cfg.experimentalOuterStrip !== false, cfg._pureFuncMap || null, cfg.experimentalToneMap !== false)
2436
2707
  }
2437
2708
  if (!cfg || cfg.sortLocalsByUse !== false) sortLocalsByUse(fn, cfg && cfg.fusedRewrite !== false ? counts : null)
2438
2709
  // An optimizer pass that emits a malformed local — the class that otherwise dies
@@ -2836,6 +3107,13 @@ export function treeshake(funcSections, allModuleNodes, opts) {
2836
3107
  for (let i = 2; i < fn.length; i++)
2837
3108
  if (Array.isArray(fn[i]) && fn[i][0] === 'export') { addRoot(name); break }
2838
3109
 
3110
+ // When user funcs are NOT being reclaimed (O0/O1 keep declared-but-uncalled ones), they
3111
+ // all survive — so they're roots for the *internal*-func reachability below. Otherwise an
3112
+ // unreachable user func that's kept would still call a `__helper`, yet that helper would be
3113
+ // pruned as unreached-from-exports, leaving a dangling `call $__helper`.
3114
+ if (!removeDead && opts && opts.userFuncs)
3115
+ for (const name of opts.userFuncs) addRoot(name)
3116
+
2839
3117
  const findRoots = (node) => {
2840
3118
  if (!Array.isArray(node)) return
2841
3119
  if (node[0] === 'start' && typeof node[1] === 'string') addRoot(node[1])
@@ -2863,24 +3141,43 @@ export function treeshake(funcSections, allModuleNodes, opts) {
2863
3141
  for (const fn of allFuncs) if (typeof fn[1] !== 'string') visitCalls(fn)
2864
3142
  while (stack.length) visitCalls(funcByName.get(stack.pop()))
2865
3143
 
3144
+ // Compiler-internal funcs (stdlib helpers, allocator wrappers — everything not in the
3145
+ // user's own `ctx.func.list`) carry no source meaning, so an unreachable one is reclaimed
3146
+ // at EVERY opt level: it's never a live-coding aid, just over-production (e.g. `s + '!'`
3147
+ // pulls the alloc trio's `__alloc_hdr`, which string concat never calls, and a dead-branch
3148
+ // dep like `__str_len`). User funcs are reclaimed only when DCE is on, so O0/O1 keep a
3149
+ // declared-but-uncalled user function. Absent the set, fall back to gating everything.
3150
+ const userFuncs = opts && opts.userFuncs
3151
+ const isUserFunc = (name) => userFuncs ? userFuncs.has(name) : true
2866
3152
  let removed = 0
2867
- if (removeDead) {
3153
+ if (removeDead || userFuncs) {
2868
3154
  for (const { arr } of funcSections) {
2869
3155
  for (let i = arr.length - 1; i >= 0; i--) {
2870
3156
  const n = arr[i]
2871
- if (Array.isArray(n) && n[0] === 'func' && typeof n[1] === 'string' && !reachable.has(n[1])) {
3157
+ if (Array.isArray(n) && n[0] === 'func' && typeof n[1] === 'string' && !reachable.has(n[1]) &&
3158
+ (removeDead || !isUserFunc(n[1]))) {
2872
3159
  arr.splice(i, 1); removed++
2873
3160
  }
2874
3161
  }
2875
3162
  }
2876
3163
  }
2877
3164
 
2878
- // Dead-global elimination: after dead funcs are gone, drop `(global $g …)` decls
2879
- // that nothing references (a `global.get`/`global.set` in a remaining func, a kept
2880
- // global's init expr, a data/elem offset, or an `(export … (global $g))`). Imported
2881
- // globals live in `allModuleNodes`, not in `opts.globals`, so they're never touched.
2882
- // Fixpoint: a kept global's init may reference another global.
2883
- const globals = removeDead && opts && Array.isArray(opts.globals) ? opts.globals : null
3165
+ // Dead-global elimination: drop `(global $g …)` decls that nothing references
3166
+ // (a `global.get`/`global.set` in a remaining func, a kept global's init expr, a
3167
+ // data/elem offset, or an `(export … (global $g))`). Imported globals live in
3168
+ // `allModuleNodes`, not in `opts.globals`, so they're never touched. Fixpoint: a
3169
+ // kept global's init may reference another global.
3170
+ //
3171
+ // Compiler-internal globals (support state the user never wrote — e.g. core's
3172
+ // `__heap_start` or the math module's `rng_state`, declared eagerly but read
3173
+ // only by specific fast paths) are reclaimed at *every* level: leaving an
3174
+ // unreferenced one in the output is pure noise, never a live-coding aid. User
3175
+ // globals are reclaimed only when DCE is on, so O0/O1 still preserve declared-
3176
+ // but-unused user bindings. `userGlobals` (names sans `$`) draws the line; absent
3177
+ // it, fall back to the `$__` reserved-prefix heuristic.
3178
+ const userGlobals = opts && opts.userGlobals
3179
+ const isUserGlobal = (name) => userGlobals ? userGlobals.has(name.slice(1)) : !name.startsWith('$__')
3180
+ const globals = opts && Array.isArray(opts.globals) ? opts.globals : null
2884
3181
  if (globals) {
2885
3182
  const collectGlobalRefs = (node, refd) => {
2886
3183
  if (!Array.isArray(node)) return
@@ -2897,9 +3194,11 @@ export function treeshake(funcSections, allModuleNodes, opts) {
2897
3194
  for (const g of globals) collectGlobalRefs(g, refd)
2898
3195
  for (let i = globals.length - 1; i >= 0; i--) {
2899
3196
  const g = globals[i]
2900
- if (Array.isArray(g) && g[0] === 'global' && typeof g[1] === 'string' && !refd.has(g[1])) {
2901
- globals.splice(i, 1); changed = true
2902
- }
3197
+ if (!Array.isArray(g) || g[0] !== 'global' || typeof g[1] !== 'string' || refd.has(g[1])) continue
3198
+ // An inline `(export )` on the decl pins it — it's part of the module's
3199
+ // JS-host surface (e.g. `__heap`), referenced from outside the wasm.
3200
+ if (g.some(c => Array.isArray(c) && c[0] === 'export')) continue
3201
+ if (removeDead || !isUserGlobal(g[1])) { globals.splice(i, 1); changed = true }
2903
3202
  }
2904
3203
  }
2905
3204
  }