jz 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/optimize.js CHANGED
@@ -27,8 +27,13 @@
27
27
  * @module optimize
28
28
  */
29
29
 
30
+ import { LAYOUT } from './ctx.js'
31
+ import { vectorizeLaneLocal } from './vectorize.js'
32
+
30
33
  const MEMOP = /^[fi](32|64)\.(load|store)(\d+(_[su])?)?$/
31
- const NAN_PREFIX_BITS = 0x7FF8n
34
+ const NAN_BITS = '0x' + LAYOUT.NAN_PREFIX_BITS.toString(16).toUpperCase().padStart(16, '0')
35
+ const NULL_BITS = '0x' + (LAYOUT.NAN_PREFIX_BITS | (1n << BigInt(LAYOUT.AUX_SHIFT))).toString(16).toUpperCase().padStart(16, '0')
36
+ const UNDEF_BITS = '0x' + (LAYOUT.NAN_PREFIX_BITS | (2n << BigInt(LAYOUT.AUX_SHIFT))).toString(16).toUpperCase().padStart(16, '0')
32
37
 
33
38
  /**
34
39
  * Optimization passes, partitioned by phase. The `level` presets pick which
@@ -39,23 +44,30 @@ const NAN_PREFIX_BITS = 0x7FF8n
39
44
  * 0 — nothing. Fastest compile, largest output. Useful for live coding.
40
45
  * 1 — encoding-compactness only (treeshake + sortLocalsByUse + fusedRewrite-inline).
41
46
  * 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.
47
+ * 2 — default. Stable passes (watr CSE/DCE/inline + every jz pass that has
48
+ * benchmark proof).
49
+ * 3 aggressive experimental passes in addition to level 2.
45
50
  */
46
51
  export const PASS_NAMES = [
47
52
  'watr', // third-party WAT-level CSE/DCE/inlining (heaviest)
48
53
  'hoistPtrType',
49
54
  'hoistInvariantPtrOffset',
55
+ 'hoistInvariantPtrOffsetLoop',
50
56
  'fusedRewrite', // peephole + ptr-helper inline + memarg fold
51
57
  'hoistAddrBase',
52
58
  'hoistInvariantCellLoads',
59
+ 'cseScalarLoad',
60
+ 'csePureExpr',
53
61
  'sortLocalsByUse',
54
62
  'specializeMkptr',
55
63
  'specializePtrBase',
56
64
  'sortStrPoolByFreq',
57
65
  'hoistConstantPool',
66
+ 'sourceInline',
58
67
  'smallConstForUnroll',
68
+ 'nestedSmallConstForUnroll',
69
+ 'vectorizeLaneLocal', // SIMD-128 lift for lane-pure typed-array loops
70
+ 'arenaRewind', // per-call heap rewind for no-arg scalar allocator kernels
59
71
  'treeshake',
60
72
  ]
61
73
 
@@ -64,14 +76,14 @@ const ALL_OFF = Object.freeze(Object.fromEntries(PASS_NAMES.map(n => [n, false])
64
76
  const LEVEL_PRESETS = Object.freeze({
65
77
  0: ALL_OFF,
66
78
  1: Object.freeze({ ...ALL_OFF, treeshake: true, sortLocalsByUse: true, fusedRewrite: true }),
67
- 2: ALL_ON,
79
+ 2: Object.freeze({ ...ALL_ON, nestedSmallConstForUnroll: 'auto' }),
68
80
  3: ALL_ON,
69
81
  })
70
82
 
71
83
  /**
72
84
  * Normalize the user's `opts.optimize` value into a flat config object.
73
85
  *
74
- * resolveOptimize(undefined | true) → all on (level 2)
86
+ * resolveOptimize(undefined | true) → level 2 stable defaults
75
87
  * resolveOptimize(false | 0) → all off
76
88
  * resolveOptimize(1 | 2 | 3) → preset for that level
77
89
  * resolveOptimize({ level: 1, watr: true }) → level 1 base, with watr forced on
@@ -79,13 +91,13 @@ const LEVEL_PRESETS = Object.freeze({
79
91
  */
80
92
  export function resolveOptimize(opt) {
81
93
  if (opt === false || opt === 0) return { ...ALL_OFF }
82
- if (opt === true || opt == null) return { ...ALL_ON }
94
+ if (opt === true || opt == null) return { ...LEVEL_PRESETS[2] }
83
95
  if (typeof opt === 'number') return { ...(LEVEL_PRESETS[opt] || ALL_ON) }
84
96
  if (typeof opt === 'object') {
85
97
  const baseLevel = typeof opt.level === 'number' ? opt.level : 2
86
98
  const base = LEVEL_PRESETS[baseLevel] || ALL_ON
87
99
  const out = { ...base }
88
- for (const n of PASS_NAMES) if (n in opt) out[n] = !!opt[n]
100
+ for (const n of PASS_NAMES) if (n in opt) out[n] = n === 'nestedSmallConstForUnroll' && opt[n] === 'auto' ? 'auto' : !!opt[n]
89
101
  return out
90
102
  }
91
103
  return { ...ALL_ON }
@@ -137,8 +149,10 @@ export function hoistPtrType(fn) {
137
149
 
138
150
  if (op === 'call' && node[1] === '$__ptr_type' && node.length === 3) {
139
151
  const arg = node[2]
140
- if (Array.isArray(arg) && arg[0] === 'local.get' && typeof arg[1] === 'string') {
141
- const x = arg[1]
152
+ // Post-i64 migration: arg is (i64.reinterpret_f64 (local.get X)). Peel both wrappers.
153
+ const inner = (Array.isArray(arg) && arg[0] === 'i64.reinterpret_f64' && arg.length === 2) ? arg[1] : arg
154
+ if (Array.isArray(inner) && inner[0] === 'local.get' && typeof inner[1] === 'string') {
155
+ const x = inner[1]
142
156
  let region = open.get(x)
143
157
  if (!region) {
144
158
  region = []
@@ -449,9 +463,11 @@ export function hoistInvariantPtrOffset(fn) {
449
463
  const callee = node[1]
450
464
  if (callee === '$__ptr_offset' && node.length === 3) {
451
465
  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) }
466
+ // Post-i64 migration: arg may be (i64.reinterpret_f64 (local.get X)).
467
+ const inner = (Array.isArray(a) && a[0] === 'i64.reinterpret_f64' && a.length === 2) ? a[1] : a
468
+ if (Array.isArray(inner) && inner[0] === 'local.get' && typeof inner[1] === 'string' && params.has(inner[1])) {
469
+ let arr = sites.get(inner[1])
470
+ if (!arr) { arr = []; sites.set(inner[1], arr) }
455
471
  arr.push({ parent, idx: pi })
456
472
  return
457
473
  }
@@ -459,8 +475,9 @@ export function hoistInvariantPtrOffset(fn) {
459
475
  const isSafe = SAFE_OFFSET_CALLS.has(callee)
460
476
  for (let i = 2; i < node.length; i++) {
461
477
  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])
478
+ const inner = (Array.isArray(arg) && arg[0] === 'i64.reinterpret_f64' && arg.length === 2) ? arg[1] : arg
479
+ if (Array.isArray(inner) && inner[0] === 'local.get' && typeof inner[1] === 'string' && params.has(inner[1])) {
480
+ if (!isSafe) unsafe.add(inner[1])
464
481
  continue
465
482
  }
466
483
  walk(arg, node, i)
@@ -497,7 +514,7 @@ export function hoistInvariantPtrOffset(fn) {
497
514
  if (arr.length < 2) continue
498
515
  const tLocal = `$__po${hoistId++}`
499
516
  newLocals.push(['local', tLocal, 'i32'])
500
- snaps.push(['local.set', tLocal, ['call', '$__ptr_offset', ['local.get', X]]])
517
+ snaps.push(['local.set', tLocal, ['call', '$__ptr_offset', ['i64.reinterpret_f64', ['local.get', X]]]])
501
518
  for (const { parent, idx } of arr) {
502
519
  parent[idx] = ['local.get', tLocal]
503
520
  }
@@ -506,6 +523,132 @@ export function hoistInvariantPtrOffset(fn) {
506
523
  if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals, ...snaps)
507
524
  }
508
525
 
526
+ /**
527
+ * Per-loop hoist of `(call $__ptr_offset (local.get X))` for locals X that are
528
+ * loop-invariant (no `local.set/local.tee` to X anywhere in the loop body, no
529
+ * non-safe call inside the loop). Mirrors `hoistInvariantCellLoads`.
530
+ *
531
+ * The function-level pass above only handles params (single hoist at func
532
+ * entry). This pass handles assigned locals that go invariant within a loop
533
+ * scope — the aos pattern: `let rows = []; for (...) rows.push(...)` — outside
534
+ * the build loop, `rows` is no longer reassigned, so `__ptr_offset(rows)` is
535
+ * loop-invariant in the consumer loop.
536
+ *
537
+ * Inside-out: inner loops first. After an inner-loop hoist, the outer loop now
538
+ * contains a `(local.set $__pol (call $__ptr_offset ...))` whose call gets
539
+ * hoisted again at the outer level, climbing the snap up to the outermost loop
540
+ * where X is invariant. Cleanup of the chained `local.set` movs is handled by
541
+ * watr CSE/DCE.
542
+ */
543
+ export function hoistInvariantPtrOffsetLoop(fn) {
544
+ if (!Array.isArray(fn) || fn[0] !== 'func') return
545
+ const bodyStart = findBodyStart(fn)
546
+ if (bodyStart < 0) return
547
+
548
+ let snapId = 0
549
+ while (fn.some(n => Array.isArray(n) && n[0] === 'local' && n[1] === `$__pol${snapId}`)) snapId++
550
+ const newLocals = []
551
+
552
+ // Refcount across the whole fn to skip shared subtrees (watr CSE may leave them).
553
+ const refcount = new Map()
554
+ const countRefs = (node) => {
555
+ if (!Array.isArray(node)) return
556
+ const n = (refcount.get(node) || 0) + 1
557
+ refcount.set(node, n)
558
+ if (n > 1) return
559
+ for (let i = 0; i < node.length; i++) countRefs(node[i])
560
+ }
561
+ countRefs(fn)
562
+
563
+ const processLoop = (loopNode) => {
564
+ // Recurse into inner loops first (bottom-up).
565
+ for (let i = 1; i < loopNode.length; i++) {
566
+ const child = loopNode[i]
567
+ if (!Array.isArray(child)) continue
568
+ processNode(child, loopNode, i)
569
+ }
570
+
571
+ // Scan loop body (including nested loops) for writes to any local and for
572
+ // any non-safe call. Non-safe calls could realloc/move the underlying
573
+ // array storage and invalidate cached offsets.
574
+ const writes = new Set()
575
+ let unsafe = false
576
+ const scan = (node) => {
577
+ if (!Array.isArray(node)) return
578
+ const op = node[0]
579
+ if (op === 'local.set' || op === 'local.tee') {
580
+ if (typeof node[1] === 'string') writes.add(node[1])
581
+ for (let i = 2; i < node.length; i++) scan(node[i])
582
+ return
583
+ }
584
+ if (op === 'call') {
585
+ if (!SAFE_OFFSET_CALLS.has(node[1])) unsafe = true
586
+ for (let i = 2; i < node.length; i++) scan(node[i])
587
+ return
588
+ }
589
+ if (op === 'call_ref' || op === 'call_indirect') {
590
+ unsafe = true
591
+ for (let i = 1; i < node.length; i++) scan(node[i])
592
+ return
593
+ }
594
+ for (let i = 1; i < node.length; i++) scan(node[i])
595
+ }
596
+ for (let i = 1; i < loopNode.length; i++) scan(loopNode[i])
597
+ if (unsafe) return []
598
+
599
+ // Collect call sites for invariant locals. Skip nested loops (already
600
+ // processed) but recurse through everything else.
601
+ const sites = new Map() // localName → [{ parent, idx }]
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 === 'call' && node[1] === '$__ptr_offset' && node.length === 3) {
607
+ const a = node[2]
608
+ const inner = (Array.isArray(a) && a[0] === 'i64.reinterpret_f64' && a.length === 2) ? a[1] : a
609
+ if (Array.isArray(inner) && inner[0] === 'local.get' && typeof inner[1] === 'string'
610
+ && !writes.has(inner[1])
611
+ && (refcount.get(node) || 0) <= 1
612
+ && (refcount.get(parent) || 0) <= 1) {
613
+ let arr = sites.get(inner[1])
614
+ if (!arr) { arr = []; sites.set(inner[1], arr) }
615
+ arr.push({ parent, idx })
616
+ }
617
+ return
618
+ }
619
+ for (let i = 0; i < node.length; i++) collect(node[i], node, i)
620
+ }
621
+ for (let i = 1; i < loopNode.length; i++) collect(loopNode[i], loopNode, i)
622
+
623
+ const snaps = []
624
+ for (const [X, arr] of sites) {
625
+ if (arr.length < 1) continue
626
+ const snapName = `$__pol${snapId++}`
627
+ newLocals.push(['local', snapName, 'i32'])
628
+ snaps.push(['local.set', snapName, ['call', '$__ptr_offset', ['i64.reinterpret_f64', ['local.get', X]]]])
629
+ for (const { parent, idx } of arr) {
630
+ parent[idx] = ['local.get', snapName]
631
+ }
632
+ }
633
+ return snaps
634
+ }
635
+
636
+ const processNode = (node, parent, idx) => {
637
+ if (!Array.isArray(node)) return
638
+ const op = node[0]
639
+ if (op === 'loop') {
640
+ const snaps = processLoop(node)
641
+ if (snaps.length) parent.splice(idx, 0, ...snaps)
642
+ return
643
+ }
644
+ for (let i = 0; i < node.length; i++) processNode(node[i], node, i)
645
+ }
646
+
647
+ for (let i = bodyStart; i < fn.length; i++) processNode(fn[i], fn, i)
648
+
649
+ if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
650
+ }
651
+
509
652
  /**
510
653
  * Hoist loop-invariant boxed-cell reads out of loops.
511
654
  *
@@ -665,6 +808,314 @@ export function hoistInvariantCellLoads(fn) {
665
808
  if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
666
809
  }
667
810
 
811
+ /**
812
+ * CSE for `(f64.load offset=K (local.get $X))` over straight-line regions
813
+ * where $X is an i32-typed local (an unboxed pointer in jz's value model).
814
+ *
815
+ * Aos hot path: `let p = rows[i]; xs[i] = p.x + p.y*0.25 + r;
816
+ * ys[i] = p.y - p.z*0.5;
817
+ * zs[i] = p.z + p.x*0.125`
818
+ * — emits 6 f64.load on $p (each of x/y/z twice); collapses to 3 unique loads
819
+ * shared via tee'd snap locals.
820
+ *
821
+ * Safety: jz's invariant — distinct unboxed-pointer locals come from distinct
822
+ * fresh allocations (analyzePtrUnboxable refuses to unbox aliased locals).
823
+ * So `(f64.store ADDR ...)` with base `(local.get $Y)` for $Y ≠ $X cannot
824
+ * touch addresses reachable via `$X + K`. Stores to typed-array slots in the
825
+ * loop body don't invalidate row-pointer reads.
826
+ *
827
+ * Region boundaries that flush the table:
828
+ * - branch (br/br_if/br_table/return/unreachable)
829
+ * - non-pure call
830
+ * - loop / if (control flow)
831
+ * - local.set/local.tee on a tracked $X (invalidates that X's entries)
832
+ * - store whose address tree references a tracked $X
833
+ * Blocks are treated as transparent — recurse into children.
834
+ */
835
+ export function cseScalarLoad(fn) {
836
+ // DISABLED: the safety claim above relies on `analyzePtrUnboxable` having vetted
837
+ // every i32 local as a non-aliased fresh-allocation pointer. But this pass scans
838
+ // *all* i32 locals from `(local … i32)` decls — wasm-native i32 scalars (lengths,
839
+ // indices), narrow-ABI helper returns, and analyze.js's new arrayElemSchema-driven
840
+ // unboxes share the same declaration form. The metacircular path (jz-compiled
841
+ // watr.wasm) trips this: CSE'd loads survive across stores that legitimately
842
+ // mutate the same memory through a different i32 local, returning stale bytes
843
+ // (manifests as "memory access out of bounds" once a corrupted offset is
844
+ // dereferenced). Re-enable once candidacy is restricted to vetted pointers
845
+ // (e.g. emit-side annotation or rep-derived whitelist).
846
+ return
847
+ if (!Array.isArray(fn) || fn[0] !== 'func') return
848
+ const bodyStart = findBodyStart(fn)
849
+ if (bodyStart < 0) return
850
+
851
+ const i32Locals = new Set()
852
+ for (let i = 2; i < fn.length; i++) {
853
+ const c = fn[i]
854
+ if (Array.isArray(c) && (c[0] === 'local' || c[0] === 'param') && typeof c[1] === 'string' && c[2] === 'i32') {
855
+ i32Locals.add(c[1])
856
+ }
857
+ }
858
+ if (!i32Locals.size) return
859
+
860
+ let snapId = 0
861
+ while (fn.some(n => Array.isArray(n) && n[0] === 'local' && n[1] === `$__cs${snapId}`)) snapId++
862
+ const newLocals = []
863
+
864
+ // CSE table: key `${X}|${K}` → { snapName | null, anchorParent, anchorIdx }
865
+ const table = new Map()
866
+
867
+ const invalidateLocal = (X) => {
868
+ for (const key of table.keys()) {
869
+ if (key.startsWith(`${X}|`)) table.delete(key)
870
+ }
871
+ }
872
+
873
+ // Scan a node's subtree and return the set of i32 locals referenced via local.get.
874
+ const collectGets = (node, out) => {
875
+ if (!Array.isArray(node)) return
876
+ if (node[0] === 'local.get' && typeof node[1] === 'string' && i32Locals.has(node[1])) {
877
+ out.add(node[1])
878
+ return
879
+ }
880
+ for (let i = 1; i < node.length; i++) collectGets(node[i], out)
881
+ }
882
+
883
+ // Parse f64.load shape; returns { K, addrIdx } or null.
884
+ const parseLoad = (node) => {
885
+ if (!Array.isArray(node) || node[0] !== 'f64.load') return null
886
+ let K = 0, addrIdx = 1
887
+ if (typeof node[1] === 'string' && node[1].startsWith('offset=')) {
888
+ K = parseInt(node[1].slice(7), 10) | 0
889
+ addrIdx = 2
890
+ }
891
+ if (node.length <= addrIdx) return null
892
+ return { K, addrIdx }
893
+ }
894
+
895
+ const walk = (node, parent, idx) => {
896
+ if (!Array.isArray(node)) return
897
+ const op = node[0]
898
+
899
+ // Control-flow boundaries: clear table.
900
+ if (op === 'br' || op === 'br_if' || op === 'br_table' || op === 'return' || op === 'unreachable') {
901
+ // Process args first (a br_if value, br arg, etc. could still benefit from current table)
902
+ for (let i = 1; i < node.length; i++) walk(node[i], node, i)
903
+ table.clear()
904
+ return
905
+ }
906
+
907
+ if (op === 'loop' || op === 'if') {
908
+ // Save table state isn't useful; recurse with cleared table, then clear after.
909
+ const saved = new Map(table)
910
+ table.clear()
911
+ for (let i = 1; i < node.length; i++) walk(node[i], node, i)
912
+ // After leaving compound, conservatively assume invalidation.
913
+ table.clear()
914
+ // Restore? No — restoring would be unsafe since the compound may have written.
915
+ saved.clear()
916
+ return
917
+ }
918
+
919
+ if (op === 'call') {
920
+ const callee = node[1]
921
+ // Process args first.
922
+ for (let i = 2; i < node.length; i++) walk(node[i], node, i)
923
+ if (!SAFE_OFFSET_CALLS.has(callee)) table.clear()
924
+ return
925
+ }
926
+
927
+ if (op === 'call_ref' || op === 'call_indirect') {
928
+ for (let i = 1; i < node.length; i++) walk(node[i], node, i)
929
+ table.clear()
930
+ return
931
+ }
932
+
933
+ if (op === 'local.set' || op === 'local.tee') {
934
+ // Process value first.
935
+ for (let i = 2; i < node.length; i++) walk(node[i], node, i)
936
+ const X = node[1]
937
+ if (typeof X === 'string') invalidateLocal(X)
938
+ return
939
+ }
940
+
941
+ // Stores: process operands first; if address tree references any tracked X,
942
+ // invalidate that X's entries.
943
+ if (op === 'f64.store' || op === 'i32.store' || op === 'i64.store'
944
+ || op === 'i32.store8' || op === 'i32.store16'
945
+ || op === 'i64.store8' || op === 'i64.store16' || op === 'i64.store32'
946
+ || op === 'f32.store') {
947
+ // Address may be node[1] (raw) or node[2] (when node[1] is offset=/align= attr).
948
+ let addrIdx = 1
949
+ if (typeof node[1] === 'string' && (node[1].startsWith('offset=') || node[1].startsWith('align='))) {
950
+ addrIdx = 2
951
+ }
952
+ for (let i = 1; i < node.length; i++) walk(node[i], node, i)
953
+ const dirty = new Set()
954
+ collectGets(node[addrIdx], dirty)
955
+ for (const X of dirty) invalidateLocal(X)
956
+ return
957
+ }
958
+
959
+ // f64.load: try CSE.
960
+ const lp = parseLoad(node)
961
+ if (lp) {
962
+ const addr = node[lp.addrIdx]
963
+ if (Array.isArray(addr) && addr[0] === 'local.get' && typeof addr[1] === 'string' && i32Locals.has(addr[1])) {
964
+ const X = addr[1]
965
+ const key = `${X}|${lp.K}`
966
+ const entry = table.get(key)
967
+ if (entry) {
968
+ if (!entry.snapName) {
969
+ const snapName = `$__cs${snapId++}`
970
+ entry.snapName = snapName
971
+ newLocals.push(['local', snapName, 'f64'])
972
+ // Wrap anchor with (local.tee $snap originalLoad).
973
+ const orig = entry.anchorParent[entry.anchorIdx]
974
+ entry.anchorParent[entry.anchorIdx] = ['local.tee', snapName, orig]
975
+ }
976
+ parent[idx] = ['local.get', entry.snapName]
977
+ return
978
+ } else {
979
+ table.set(key, { snapName: null, anchorParent: parent, anchorIdx: idx })
980
+ // Don't recurse; (local.get $X) has no children of interest.
981
+ return
982
+ }
983
+ }
984
+ // Non-CSE'able address; recurse to find inner loads.
985
+ for (let i = 1; i < node.length; i++) walk(node[i], node, i)
986
+ return
987
+ }
988
+
989
+ // Default: recurse.
990
+ for (let i = 0; i < node.length; i++) walk(node[i], node, i)
991
+ }
992
+
993
+ for (let i = bodyStart; i < fn.length; i++) walk(fn[i], fn, i)
994
+
995
+ if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
996
+ }
997
+
998
+ /**
999
+ * CSE for pure f64 binary ops on local-only operands.
1000
+ *
1001
+ * Mandelbrot loop: condition computes `(f64.mul $zx $zx)` and `(f64.mul $zy $zy)`;
1002
+ * body recomputes both inside `tx = zx*zx - zy*zy + cx`. Pure ops on locals can't
1003
+ * alias memory — only `local.set/tee X` invalidates entries referencing X. Unlike
1004
+ * `cseScalarLoad`, br_if doesn't need to clear (no memory aliasing concern).
1005
+ *
1006
+ * Targets nodes of shape `(OP A B)` where OP ∈ {f64.mul, f64.add, f64.sub} and
1007
+ * A,B ∈ `(local.get X)` | `(f64.const N)`. Commutative ops (mul, add) sort
1008
+ * operand keys for canonical form.
1009
+ *
1010
+ * Region boundaries:
1011
+ * - `local.set/tee X` → invalidates entries referencing X
1012
+ * - `loop`, `if` → recurse with cleared table; clear after (compound may have written)
1013
+ * - `call`, `call_ref`, `call_indirect` → no clear (calls don't write locals directly;
1014
+ * the surrounding `local.set/tee` handles that)
1015
+ * - `br/br_if/br_table/return/unreachable` → NO clear (pure values still valid)
1016
+ */
1017
+ export function csePureExpr(fn) {
1018
+ if (!Array.isArray(fn) || fn[0] !== 'func') return
1019
+ const bodyStart = findBodyStart(fn)
1020
+ if (bodyStart < 0) return
1021
+
1022
+ let snapId = 0
1023
+ while (fn.some(n => Array.isArray(n) && n[0] === 'local' && n[1] === `$__pe${snapId}`)) snapId++
1024
+ const newLocals = []
1025
+
1026
+ const COMMUTATIVE = new Set(['f64.mul', 'f64.add'])
1027
+ const TARGET_OPS = new Set(['f64.mul', 'f64.add', 'f64.sub'])
1028
+
1029
+ // Encode a leaf operand to a stable string key. Returns null if not pure-leaf.
1030
+ const leafKey = (n) => {
1031
+ if (!Array.isArray(n)) return null
1032
+ if (n[0] === 'local.get' && typeof n[1] === 'string') return `L:${n[1]}`
1033
+ if (n[0] === 'f64.const') return `C:${n[1]}`
1034
+ return null
1035
+ }
1036
+
1037
+ // table: key → { snapName | null, anchorParent, anchorIdx, locals: Set<string> }
1038
+ const table = new Map()
1039
+
1040
+ const invalidateLocal = (X) => {
1041
+ for (const [key, entry] of table) {
1042
+ if (entry.locals.has(X)) table.delete(key)
1043
+ }
1044
+ }
1045
+
1046
+ const walk = (node, parent, idx) => {
1047
+ if (!Array.isArray(node)) return
1048
+ const op = node[0]
1049
+
1050
+ if (op === 'loop' || op === 'if') {
1051
+ const saved = new Map(table)
1052
+ table.clear()
1053
+ for (let i = 1; i < node.length; i++) walk(node[i], node, i)
1054
+ table.clear()
1055
+ saved.clear()
1056
+ return
1057
+ }
1058
+
1059
+ // `then`/`else` branches of an `if` are mutually exclusive at runtime —
1060
+ // a snap tee cached in the `then` branch is unset when the `else` runs.
1061
+ // Isolate per-branch tables so a sibling branch can't reach into another's
1062
+ // CSE entries.
1063
+ if (op === 'then' || op === 'else') {
1064
+ table.clear()
1065
+ for (let i = 1; i < node.length; i++) walk(node[i], node, i)
1066
+ table.clear()
1067
+ return
1068
+ }
1069
+
1070
+ if (op === 'call' || op === 'call_ref' || op === 'call_indirect') {
1071
+ // Calls don't write locals; recurse, no clear.
1072
+ for (let i = 1; i < node.length; i++) walk(node[i], node, i)
1073
+ return
1074
+ }
1075
+
1076
+ if (op === 'local.set' || op === 'local.tee') {
1077
+ for (let i = 2; i < node.length; i++) walk(node[i], node, i)
1078
+ const X = node[1]
1079
+ if (typeof X === 'string') invalidateLocal(X)
1080
+ return
1081
+ }
1082
+
1083
+ // Try CSE on (OP A B) where A,B are pure leaves.
1084
+ if (TARGET_OPS.has(op) && node.length === 3) {
1085
+ const ka = leafKey(node[1])
1086
+ const kb = leafKey(node[2])
1087
+ if (ka && kb) {
1088
+ const key = COMMUTATIVE.has(op) && ka > kb ? `${op}|${kb}|${ka}` : `${op}|${ka}|${kb}`
1089
+ const entry = table.get(key)
1090
+ if (entry) {
1091
+ if (!entry.snapName) {
1092
+ const snapName = `$__pe${snapId++}`
1093
+ entry.snapName = snapName
1094
+ newLocals.push(['local', snapName, 'f64'])
1095
+ const orig = entry.anchorParent[entry.anchorIdx]
1096
+ entry.anchorParent[entry.anchorIdx] = ['local.tee', snapName, orig]
1097
+ }
1098
+ parent[idx] = ['local.get', entry.snapName]
1099
+ return
1100
+ } else {
1101
+ const locals = new Set()
1102
+ if (ka.startsWith('L:')) locals.add(ka.slice(2))
1103
+ if (kb.startsWith('L:')) locals.add(kb.slice(2))
1104
+ table.set(key, { snapName: null, anchorParent: parent, anchorIdx: idx, locals })
1105
+ return
1106
+ }
1107
+ }
1108
+ // Fall through to recurse.
1109
+ }
1110
+
1111
+ for (let i = 0; i < node.length; i++) walk(node[i], node, i)
1112
+ }
1113
+
1114
+ for (let i = bodyStart; i < fn.length; i++) walk(fn[i], fn, i)
1115
+
1116
+ if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
1117
+ }
1118
+
668
1119
  /**
669
1120
  * Find the index of the first body-content child in a func node.
670
1121
  * Skips `$name`, (export …), (import …), (type …), (param …), (result …), (local …).
@@ -698,7 +1149,10 @@ export function hoistConstantPool(funcs, addGlobal) {
698
1149
  for (let i = 0; i < node.length; i++) {
699
1150
  const c = node[i]
700
1151
  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]}`
1152
+ // Distinguish -0 from +0 by sign: template literal collapses both to "0".
1153
+ const k = typeof c[1] === 'number'
1154
+ ? (Object.is(c[1], -0) ? 'n:-0' : `n:${c[1]}`)
1155
+ : `s:${c[1]}`
702
1156
  counts.set(k, (counts.get(k) || 0) + 1)
703
1157
  sites.push({ parent: node, idx: i, key: k })
704
1158
  }
@@ -758,8 +1212,8 @@ export function specializeMkptr(funcs, addFunc, parseWat) {
758
1212
  const SPECS = {
759
1213
  '$__mkptr': { params: ['i32', 'i32', 'i32'], result: 'f64', inline: true },
760
1214
  '$__alloc_hdr': { params: ['i32', 'i32', 'i32'], result: 'i32' },
761
- '$__typed_idx': { params: ['f64', 'i32'], result: 'f64' },
762
- '$__str_idx': { params: ['f64', 'i32'], result: 'f64' },
1215
+ '$__typed_idx': { params: ['i64', 'i32'], result: 'f64' },
1216
+ '$__str_idx': { params: ['i64', 'i32'], result: 'f64' },
763
1217
  }
764
1218
  const MIN_USES = 5
765
1219
 
@@ -815,9 +1269,9 @@ export function specializeMkptr(funcs, addFunc, parseWat) {
815
1269
  // $__mkptr inline fast path: bake (type, aux) literals into i64.const template.
816
1270
  if (target === '$__mkptr' && spec.inline && parts[0].startsWith('L:') && parts[1].startsWith('L:')) {
817
1271
  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)
1272
+ const tmpl = LAYOUT.NAN_PREFIX_BITS
1273
+ | ((BigInt(type) & BigInt(LAYOUT.TAG_MASK)) << BigInt(LAYOUT.TAG_SHIFT))
1274
+ | ((BigInt(aux) & BigInt(LAYOUT.AUX_MASK)) << BigInt(LAYOUT.AUX_SHIFT))
821
1275
  // Third arg (offset) may also be literal — emit (f64.const nan:…) then.
822
1276
  if (parts[2].startsWith('L:')) {
823
1277
  // Fully literal: all sites can be f64.const — no helper needed, handled in rewrite below.
@@ -859,10 +1313,10 @@ export function specializeMkptr(funcs, addFunc, parseWat) {
859
1313
  // $__mkptr fully literal (rare — mkPtrIR usually folds these ahead of us, but defensive):
860
1314
  if (target === '$__mkptr' && parts[0].startsWith('L:') && parts[1].startsWith('L:') && parts[2].startsWith('L:')) {
861
1315
  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)
1316
+ const bits = LAYOUT.NAN_PREFIX_BITS
1317
+ | ((BigInt(type) & BigInt(LAYOUT.TAG_MASK)) << BigInt(LAYOUT.TAG_SHIFT))
1318
+ | ((BigInt(aux) & BigInt(LAYOUT.AUX_MASK)) << BigInt(LAYOUT.AUX_SHIFT))
1319
+ | (BigInt(off >>> 0) & BigInt(LAYOUT.OFFSET_MASK))
866
1320
  const n = ['f64.const', 'nan:0x' + bits.toString(16).toUpperCase().padStart(16, '0')]
867
1321
  n.type = 'f64'
868
1322
  parent[idx] = n
@@ -1053,16 +1507,28 @@ export function sortStrPoolByFreq(funcs, strPoolRef, strDedupMap) {
1053
1507
  export function optimizeFunc(fn, cfg) {
1054
1508
  if (cfg && cfg.hoistPtrType === false &&
1055
1509
  cfg.hoistInvariantPtrOffset === false &&
1510
+ cfg.hoistInvariantPtrOffsetLoop === false &&
1056
1511
  cfg.fusedRewrite === false &&
1057
1512
  cfg.hoistAddrBase === false &&
1058
1513
  cfg.hoistInvariantCellLoads === false &&
1059
- cfg.sortLocalsByUse === false) return
1514
+ cfg.cseScalarLoad === false &&
1515
+ cfg.csePureExpr === false &&
1516
+ cfg.sortLocalsByUse === false &&
1517
+ cfg.vectorizeLaneLocal === false) return
1060
1518
  if (!cfg || cfg.hoistPtrType !== false) hoistPtrType(fn)
1061
1519
  if (!cfg || cfg.hoistInvariantPtrOffset !== false) hoistInvariantPtrOffset(fn)
1520
+ if (!cfg || cfg.hoistInvariantPtrOffsetLoop !== false) hoistInvariantPtrOffsetLoop(fn)
1062
1521
  const counts = new Map()
1063
1522
  if (!cfg || cfg.fusedRewrite !== false) fusedRewrite(fn, counts)
1064
1523
  if (!cfg || cfg.hoistAddrBase !== false) hoistAddrBase(fn)
1065
1524
  if (!cfg || cfg.hoistInvariantCellLoads !== false) hoistInvariantCellLoads(fn)
1525
+ if (!cfg || cfg.cseScalarLoad !== false) cseScalarLoad(fn)
1526
+ if (!cfg || cfg.csePureExpr !== false) csePureExpr(fn)
1527
+ // Vectorizer runs only in the POST-watr phase (`__phase === 'post'`), where
1528
+ // narrow.js + watr have produced the canonical lane-local shape. Running it
1529
+ // pre-watr matches a noisier IR and would re-fire post-watr on the residual
1530
+ // tail — producing nested SIMD blocks. Single-pass via phase gating.
1531
+ if (cfg && cfg.vectorizeLaneLocal === true && cfg.__phase === 'post') vectorizeLaneLocal(fn)
1066
1532
  if (!cfg || cfg.sortLocalsByUse !== false) sortLocalsByUse(fn, cfg && cfg.fusedRewrite !== false ? counts : null)
1067
1533
  }
1068
1534
 
@@ -1109,32 +1575,59 @@ function walkRewrite(node, doInline, counts) {
1109
1575
  if (doInline && op === 'call' && node.length === 3 && typeof node[1] === 'string') {
1110
1576
  const fname = node[1]
1111
1577
  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]]
1578
+ ['i32.wrap_i64', ['i64.shr_u', node[2], ['i64.const', LAYOUT.TAG_SHIFT]]],
1579
+ ['i32.const', LAYOUT.TAG_MASK]]
1114
1580
  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]
1581
+ ['i32.wrap_i64', ['i64.shr_u', node[2], ['i64.const', LAYOUT.AUX_SHIFT]]],
1582
+ ['i32.const', LAYOUT.AUX_MASK]]
1583
+ if (fname === '$__is_null') return ['i64.eq', node[2], ['i64.const', NULL_BITS]]
1584
+ if (fname === '$__is_nullish' && Array.isArray(node[2]) && node[2][0] === 'i64.reinterpret_f64'
1585
+ && Array.isArray(node[2][1]) && node[2][1][0] === 'local.get') return ['i32.or',
1586
+ ['i64.eq', node[2], ['i64.const', NULL_BITS]],
1587
+ ['i64.eq', node[2], ['i64.const', UNDEF_BITS]]]
1588
+ if (fname === '$__is_truthy' && Array.isArray(node[2]) && node[2][0] === 'i64.reinterpret_f64'
1589
+ && Array.isArray(node[2][1]) && node[2][1][0] === 'local.get') {
1590
+ const lget = node[2][1]
1591
+ const bits = node[2]
1124
1592
  return ['if', ['result', 'i32'],
1125
1593
  ['f64.eq', lget, lget],
1126
1594
  ['then', ['f64.ne', lget, ['f64.const', 0]]],
1127
1595
  ['else', ['i32.and',
1128
1596
  ['i32.and',
1129
- ['i64.ne', bits, ['i64.const', '0x7FF8000000000000']],
1130
- ['i64.ne', bits, ['i64.const', '0x7FF8000100000000']]],
1597
+ ['i64.ne', bits, ['i64.const', NAN_BITS]],
1598
+ ['i64.ne', bits, ['i64.const', NULL_BITS]]],
1131
1599
  ['i32.and',
1132
- ['i64.ne', bits, ['i64.const', '0x7FF8000000000001']],
1133
- ['i64.ne', bits, ['i64.const', '0x7FFA800000000000']]]]]]
1600
+ ['i64.ne', bits, ['i64.const', UNDEF_BITS]],
1601
+ ['i64.ne', bits, ['i64.const', '0x7FFA400000000000']]]]]]
1134
1602
  }
1135
1603
  }
1136
1604
 
1137
1605
  // Peephole: rebox/unbox round-trips
1606
+ if ((op === 'f64.convert_i32_s' || op === 'f64.convert_i32_u') && node.length === 2) {
1607
+ const a = node[1]
1608
+ if (Array.isArray(a) && a[0] === 'i32.const') {
1609
+ const n = typeof a[1] === 'number' ? a[1] : typeof a[1] === 'string' ? Number(a[1]) : NaN
1610
+ if (Number.isFinite(n)) return ['f64.const', op === 'f64.convert_i32_u' ? n >>> 0 : n]
1611
+ }
1612
+ }
1613
+ if (op === 'f64.mul' && node.length === 3) {
1614
+ const a = node[1], b = node[2]
1615
+ const isTwo = x => Array.isArray(x) && x[0] === 'f64.const' && x[1] === 2
1616
+ const isCheapF64 = x => Array.isArray(x) &&
1617
+ ((x[0] === 'local.get' && typeof x[1] === 'string') ||
1618
+ (x[0] === 'f64.const' && typeof x[1] === 'number'))
1619
+ if (isTwo(a) && isCheapF64(b)) return ['f64.add', b, b]
1620
+ if (isTwo(b) && isCheapF64(a)) return ['f64.add', a, a]
1621
+ }
1622
+ if (op === 'i32.trunc_sat_f64_s' && node.length === 2) {
1623
+ const a = node[1]
1624
+ if (Array.isArray(a) && a[0] === 'f64.convert_i32_s' && a.length === 2) return a[1]
1625
+ }
1626
+ if (op === 'i64.trunc_sat_f64_s' && node.length === 2) {
1627
+ const a = node[1]
1628
+ if (Array.isArray(a) && a[0] === 'f64.convert_i32_s' && a.length === 2) return ['i64.extend_i32_s', a[1]]
1629
+ if (Array.isArray(a) && a[0] === 'f64.convert_i32_u' && a.length === 2) return ['i64.extend_i32_u', a[1]]
1630
+ }
1138
1631
  if (op === 'i64.reinterpret_f64' && node.length === 2) {
1139
1632
  const a = node[1]
1140
1633
  if (Array.isArray(a) && a[0] === 'f64.reinterpret_i64' && a.length === 2) return a[1]
@@ -1350,3 +1843,96 @@ export function sortLocalsByUse(fn, precomputedCounts) {
1350
1843
  locals.sort((a, b) => (counts.get(b[1]) || 0) - (counts.get(a[1]) || 0))
1351
1844
  localIdxs.forEach((i, k) => { fn[i] = locals[k] })
1352
1845
  }
1846
+
1847
+ /**
1848
+ * Module-level arena rewind: transitive escape analysis.
1849
+ *
1850
+ * Per-function `applyArenaRewind` in compile.js is limited to a static whitelist
1851
+ * of internal helpers. This pass generalizes by building a call graph and
1852
+ * propagating "arena-safe callee" status via fixed-point iteration.
1853
+ *
1854
+ * A function is an arena-safe callee if:
1855
+ * - no global.set, call_indirect, call_ref in body
1856
+ * - all user-function calls are to other arena-safe callees
1857
+ *
1858
+ * A function is arena-rewindable (gets heap save/restore injected) if:
1859
+ * - single scalar result (f64 or i32)
1860
+ * - contains allocation ($__alloc / $__alloc_hdr)
1861
+ * - no global.set, return_call, call_indirect, call_ref
1862
+ * - all user-function calls are to arena-safe callees
1863
+ * - does NOT return a pointer (checked via ptrTypes map from compile.js)
1864
+ *
1865
+ * Unlike the per-function pass, this does NOT require 0 params.
1866
+ *
1867
+ * @param {Array[]} fns - Array of func IR nodes (sec.funcs + sec.stdlib + sec.start)
1868
+ * @param {boolean} sharedMemory - Whether memory is shared (affects heap get/set IR)
1869
+ * @param {Map<string, {ptrKind: *}|null>} [ptrTypes] - Map from func name to ptrKind info.
1870
+ * Functions with ptrKind != null return pointers and cannot be rewound.
1871
+ * If omitted, no pointer-return check is done (conservative: fewer functions rewound).
1872
+ */
1873
+ export function arenaRewindModule(fns) {
1874
+ const BUILTIN_SAFE = new Set([
1875
+ '$__alloc', '$__alloc_hdr', '$__mkptr',
1876
+ '$__ptr_offset', '$__ptr_type', '$__ptr_aux',
1877
+ '$__len', '$__cap', '$__typed_shift', '$__typed_data',
1878
+ ])
1879
+
1880
+ // Phase 1: collect per-function metadata
1881
+ const fnMap = new Map()
1882
+ for (const fn of fns) {
1883
+ if (!Array.isArray(fn) || fn[0] !== 'func') continue
1884
+ const name = fn[1]
1885
+ if (typeof name !== 'string') continue
1886
+
1887
+ let results = [], hasGlobalSet = false, hasReturnCall = false
1888
+ let hasCallIndirect = false, hasCallRef = false, hasAlloc = false
1889
+ const calls = new Set()
1890
+ const bodyStart = findBodyStart(fn)
1891
+
1892
+ for (let i = 2; i < fn.length; i++) {
1893
+ const c = fn[i]
1894
+ if (!Array.isArray(c)) continue
1895
+ if (c[0] === 'result') { results.push(c[1] || c[2]); continue }
1896
+ if (i >= bodyStart) break
1897
+ }
1898
+
1899
+ const scan = node => {
1900
+ if (!Array.isArray(node)) return
1901
+ const op = node[0]
1902
+ if (op === 'global.set') hasGlobalSet = true
1903
+ else if (op === 'return_call') hasReturnCall = true
1904
+ else if (op === 'call_indirect') hasCallIndirect = true
1905
+ else if (op === 'call_ref') hasCallRef = true
1906
+ else if (op === 'call') {
1907
+ const callee = node[1]
1908
+ if (callee === '$__alloc' || callee === '$__alloc_hdr') hasAlloc = true
1909
+ if (typeof callee === 'string' && !BUILTIN_SAFE.has(callee)) calls.add(callee)
1910
+ }
1911
+ for (let i = 1; i < node.length; i++) scan(node[i])
1912
+ }
1913
+ for (let i = bodyStart; i < fn.length; i++) scan(fn[i])
1914
+
1915
+ fnMap.set(name, {
1916
+ fn, results,
1917
+ hasGlobalSet, hasReturnCall, hasCallIndirect, hasCallRef, hasAlloc,
1918
+ calls: [...calls],
1919
+ })
1920
+ }
1921
+
1922
+ // Phase 2: fixed-point transitive safety analysis
1923
+ const safeCallees = new Set(BUILTIN_SAFE)
1924
+ let changed = true
1925
+ while (changed) {
1926
+ changed = false
1927
+ for (const [name, info] of fnMap) {
1928
+ if (safeCallees.has(name)) continue
1929
+ if (info.hasGlobalSet || info.hasCallIndirect || info.hasCallRef) continue
1930
+ if (info.calls.every(c => safeCallees.has(c) || !fnMap.has(c))) {
1931
+ safeCallees.add(name)
1932
+ changed = true
1933
+ }
1934
+ }
1935
+ }
1936
+
1937
+ return safeCallees
1938
+ }