jz 0.4.0 → 0.5.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,7 +27,7 @@
27
27
  * @module optimize
28
28
  */
29
29
 
30
- import { LAYOUT } from './ctx.js'
30
+ import { LAYOUT, ctx } from './ctx.js'
31
31
  import { findBodyStart } from './ir.js'
32
32
  import { vectorizeLaneLocal } from './vectorize.js'
33
33
 
@@ -45,12 +45,12 @@ const UNDEF_BITS = '0x' + (LAYOUT.NAN_PREFIX_BITS | (2n << BigInt(LAYOUT.AUX_SHI
45
45
  * 0 — nothing. Fastest compile, largest output. Useful for live coding.
46
46
  * 1 — encoding-compactness only (treeshake + sortLocalsByUse + fusedRewrite-inline).
47
47
  * Cheap, no IR rewrites that perturb V8's tier-up shape.
48
- * 2 — default. All stable jz passes + watr in 'light' mode (everything except
49
- * `inline` / `inlineOnce`). 'light' delivers most of the size win
50
- * (treeshake / dedupe / dedupTypes / coalesce / propagate / packData / fold /
51
- * peephole / vacuum / mergeBlocks / brif / loopify / …) at essentially zero
52
- * net compile cost the smaller wasm makes watrCompile downstream faster.
53
- * 3 level 2 + full watr (adds inlining) + aggressive experimental tunings.
48
+ * 2 — default. All stable jz passes + full watr (treeshake / dedupe / dedupTypes /
49
+ * coalesce / propagate / packData / fold / peephole / vacuum / mergeBlocks /
50
+ * brif / loopify / inlineOnce / …). `inline` stays off (watr's own default
51
+ * opt-in only; can duplicate bodies).
52
+ * 3 level 2 + larger array/hash initial caps + `hoistConstantPool` off
53
+ * (inline `f64.const` over mutable globals); trades size for speed.
54
54
  *
55
55
  * String aliases (the size↔speed tradeoff lives entirely in the unroll/scalar
56
56
  * knobs; watr is on for all three):
@@ -83,6 +83,7 @@ export const PASS_NAMES = [
83
83
  'vectorizeLaneLocal', // SIMD-128 lift for lane-pure typed-array loops
84
84
  'arenaRewind', // per-call heap rewind for no-arg scalar allocator kernels
85
85
  'treeshake',
86
+ 'jsstring', // boundary opt-in: flip exported string params to externref
86
87
  ]
87
88
 
88
89
  const ALL_ON = Object.freeze(Object.fromEntries(PASS_NAMES.map(n => [n, true])))
@@ -90,13 +91,11 @@ const ALL_OFF = Object.freeze(Object.fromEntries(PASS_NAMES.map(n => [n, false])
90
91
  const LEVEL_PRESETS = Object.freeze({
91
92
  0: ALL_OFF,
92
93
  1: Object.freeze({ ...ALL_OFF, treeshake: true, sortLocalsByUse: true, fusedRewrite: true }),
93
- // Default (level 2 / 'balanced'): every stable pass + watr in 'light' mode.
94
- // 'light' = all watr passes except inlining (`inline` / `inlineOnce`). Inlining is
95
- // skipped at L2 because it breaks regex-split semantics (watr 4.6.4) and reshapes
96
- // codegen tests that assert on pre-inline function structure. The remaining passes
97
- // (treeshake/dedupe/dedupTypes/coalesce/propagate/packData/fold/peephole/...) still
98
- // deliver most of watr's size win at essentially zero compile cost.
99
- 2: Object.freeze({ ...ALL_ON, watr: 'light', nestedSmallConstForUnroll: 'auto' }),
94
+ // Default (level 2 / 'balanced'): every stable pass + full watr. Pre-4.6.9 had to
95
+ // force 'light' mode here (inline / inlineOnce / coalesce all off) to dodge the
96
+ // W1a/W1b miscompiles; watr 4.6.9 fixes both, and the L2 default now runs the full
97
+ // watr pipeline. `inline` stays off by watr's own default opt-in only.
98
+ 2: Object.freeze({ ...ALL_ON, nestedSmallConstForUnroll: 'auto' }),
100
99
  // L3/'speed' trades a bit of heap headroom for fewer __arr_grow / __hash growth
101
100
  // cycles. arrayMinCap=16 means `[]` and `new Array()` skip the first two doublings
102
101
  // (0→2→4→8→16); hashSmallInitCap=8 keeps per-object __dyn_props at the same load
@@ -104,16 +103,24 @@ const LEVEL_PRESETS = Object.freeze({
104
103
  // Net cost: ~128 B per empty array, ~144 B per per-object hash. Net win on the
105
104
  // watr.compile profile: __arr_grow ~6.7% → ~3%, and lower __ihash_get_local
106
105
  // probe depth from a denser-load global hash.
107
- 3: Object.freeze({ ...ALL_ON, arrayMinCap: 16, hashSmallInitCap: 8 }),
106
+ // L3/'speed' also turns hoistConstantPool OFF: pooling repeated `f64.const`
107
+ // into `(mut f64)` globals is a pure size win (~7 B/reuse) but a speed loss —
108
+ // a mutable global can't be constant-folded by V8 (any call may mutate it), so
109
+ // every use becomes a load, and promoteGlobals then snapshots the pool into
110
+ // `_pg` locals at each hot function's entry (register pressure in the big
111
+ // closures). Inline `f64.const` is the minimal lowering: V8 CSEs identical
112
+ // constants for free. Measured −3% on jessie parse for +14% binary — exactly
113
+ // the size↔speed trade 'speed' exists to make.
114
+ 3: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8 }),
108
115
  // 'balanced' = level 2; 'size' tightens scalar/unroll caps; 'speed' = level 3.
109
- balanced: Object.freeze({ ...ALL_ON, watr: 'light', nestedSmallConstForUnroll: 'auto' }),
116
+ balanced: Object.freeze({ ...ALL_ON, nestedSmallConstForUnroll: 'auto' }),
110
117
  size: Object.freeze({
111
118
  ...ALL_ON,
112
119
  smallConstForUnroll: false, nestedSmallConstForUnroll: false, vectorizeLaneLocal: false,
113
120
  scalarTypedLoopUnroll: 4, scalarTypedNestedUnroll: 8, scalarTypedArrayLen: 8,
114
121
  }),
115
- // 'speed' === level 3: full watr (inlining on) + L3 cap/hash tuning.
116
- speed: Object.freeze({ ...ALL_ON, arrayMinCap: 16, hashSmallInitCap: 8 }),
122
+ // 'speed' === level 3: full watr (inlining on) + L3 cap/hash tuning, pool off.
123
+ speed: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8 }),
117
124
  })
118
125
 
119
126
  /**
@@ -138,11 +145,9 @@ export function resolveOptimize(opt) {
138
145
  for (const n of PASS_NAMES) {
139
146
  if (!(n in opt)) continue
140
147
  const v = opt[n]
141
- // Preserve sentinel values that downstream resolution depends on:
142
- // nestedSmallConstForUnroll: 'auto' (heuristic at emit time)
143
- // watr: 'light' (curated subset — see index.js watrOpts)
148
+ // Preserve sentinel value `nestedSmallConstForUnroll: 'auto'`
149
+ // (resolved by a heuristic at emit time).
144
150
  if (n === 'nestedSmallConstForUnroll' && v === 'auto') out[n] = 'auto'
145
- else if (n === 'watr' && v === 'light') out[n] = 'light'
146
151
  else out[n] = !!v
147
152
  }
148
153
  // Preserve non-pass tuning keys (e.g. plan.js thresholds)
@@ -867,44 +872,33 @@ export function hoistInvariantCellLoads(fn) {
867
872
  * — emits 6 f64.load on $p (each of x/y/z twice); collapses to 3 unique loads
868
873
  * shared via tee'd snap locals.
869
874
  *
870
- * Safety: jz's invariant distinct unboxed-pointer locals come from distinct
871
- * fresh allocations (analyzePtrUnboxable refuses to unbox aliased locals).
872
- * So `(f64.store ADDR ...)` with base `(local.get $Y)` for $Y ≠ $X cannot
873
- * touch addresses reachable via `$X + K`. Stores to typed-array slots in the
874
- * loop body don't invalidate row-pointer reads.
875
+ * Safety: candidacy is the emit-side `cseSafeLoadBases` whitelist (src/analyze.js),
876
+ * stamped onto the func node as `fn.cseLoadBases`. Every base in it is a
877
+ * bound-once unboxed pointer used solely as a member-read receiver whose
878
+ * allocation kind is disjoint from every store the function performs. So
879
+ * `(f64.store ADDR ...)` anywhere in the body cannot touch addresses reachable
880
+ * via `$X + K` for a whitelisted $X — the proof is carried from emit, where the
881
+ * VAL kinds and binding shapes are still known, never re-guessed at WAT level.
875
882
  *
876
883
  * Region boundaries that flush the table:
877
884
  * - branch (br/br_if/br_table/return/unreachable)
878
885
  * - non-pure call
879
886
  * - loop / if (control flow)
880
887
  * - local.set/local.tee on a tracked $X (invalidates that X's entries)
881
- * - store whose address tree references a tracked $X
888
+ * - store whose address tree references a tracked $X (defence-in-depth —
889
+ * the whitelist already guarantees this never happens)
882
890
  * Blocks are treated as transparent — recurse into children.
883
891
  */
884
892
  export function cseScalarLoad(fn) {
885
- // DISABLED: the safety claim above relies on `analyzePtrUnboxable` having vetted
886
- // every i32 local as a non-aliased fresh-allocation pointer. But this pass scans
887
- // *all* i32 locals from `(local … i32)` decls — wasm-native i32 scalars (lengths,
888
- // indices), narrow-ABI helper returns, and analyze.js's new arrayElemSchema-driven
889
- // unboxes share the same declaration form. The metacircular path (jz-compiled
890
- // watr.wasm) trips this: CSE'd loads survive across stores that legitimately
891
- // mutate the same memory through a different i32 local, returning stale bytes
892
- // (manifests as "memory access out of bounds" once a corrupted offset is
893
- // dereferenced). Re-enable once candidacy is restricted to vetted pointers
894
- // (e.g. emit-side annotation or rep-derived whitelist).
895
- return
896
893
  if (!Array.isArray(fn) || fn[0] !== 'func') return
897
894
  const bodyStart = findBodyStart(fn)
898
895
  if (bodyStart < 0) return
899
896
 
900
- const i32Locals = new Set()
901
- for (let i = 2; i < fn.length; i++) {
902
- const c = fn[i]
903
- if (Array.isArray(c) && (c[0] === 'local' || c[0] === 'param') && typeof c[1] === 'string' && c[2] === 'i32') {
904
- i32Locals.add(c[1])
905
- }
906
- }
907
- if (!i32Locals.size) return
897
+ // Soundness gate: only the emit-proven non-aliasing bases. Absent the stamp
898
+ // (e.g. a post-watrOptimize re-run on rebuilt nodes) the set is empty and the
899
+ // pass is a strict no-op — never a speculative CSE.
900
+ const bases = fn.cseLoadBases
901
+ if (!(bases instanceof Set) || bases.size === 0) return
908
902
 
909
903
  let snapId = 0
910
904
  while (fn.some(n => Array.isArray(n) && n[0] === 'local' && n[1] === `$__cs${snapId}`)) snapId++
@@ -919,10 +913,10 @@ export function cseScalarLoad(fn) {
919
913
  }
920
914
  }
921
915
 
922
- // Scan a node's subtree and return the set of i32 locals referenced via local.get.
916
+ // Scan a node's subtree and return the set of tracked bases referenced via local.get.
923
917
  const collectGets = (node, out) => {
924
918
  if (!Array.isArray(node)) return
925
- if (node[0] === 'local.get' && typeof node[1] === 'string' && i32Locals.has(node[1])) {
919
+ if (node[0] === 'local.get' && typeof node[1] === 'string' && bases.has(node[1])) {
926
920
  out.add(node[1])
927
921
  return
928
922
  }
@@ -1009,7 +1003,7 @@ export function cseScalarLoad(fn) {
1009
1003
  const lp = parseLoad(node)
1010
1004
  if (lp) {
1011
1005
  const addr = node[lp.addrIdx]
1012
- if (Array.isArray(addr) && addr[0] === 'local.get' && typeof addr[1] === 'string' && i32Locals.has(addr[1])) {
1006
+ if (Array.isArray(addr) && addr[0] === 'local.get' && typeof addr[1] === 'string' && bases.has(addr[1])) {
1013
1007
  const X = addr[1]
1014
1008
  const key = `${X}|${lp.K}`
1015
1009
  const entry = table.get(key)
@@ -1068,8 +1062,16 @@ export function csePureExpr(fn) {
1068
1062
  const bodyStart = findBodyStart(fn)
1069
1063
  if (bodyStart < 0) return
1070
1064
 
1065
+ // High-water mark across ALL surviving `$__pe<N>` locals, not the first gap.
1066
+ // A prior csePureExpr run + watr.coalesce can leave non-contiguous numbering
1067
+ // (e.g. $__pe0,$__pe1,$__pe5,$__pe20 — coalesce removed the merged ones); picking
1068
+ // the first gap (2) then allocating sequentially would collide on $__pe5 / $__pe20.
1071
1069
  let snapId = 0
1072
- while (fn.some(n => Array.isArray(n) && n[0] === 'local' && n[1] === `$__pe${snapId}`)) snapId++
1070
+ for (const n of fn) {
1071
+ if (!Array.isArray(n) || n[0] !== 'local' || typeof n[1] !== 'string') continue
1072
+ const m = /^\$__pe(\d+)$/.exec(n[1])
1073
+ if (m) { const k = +m[1]; if (k >= snapId) snapId = k + 1 }
1074
+ }
1073
1075
  const newLocals = []
1074
1076
 
1075
1077
  const COMMUTATIVE = new Set(['f64.mul', 'f64.add', 'i32.mul', 'i32.add', 'i32.and', 'i32.or', 'i32.xor', 'i64.mul', 'i64.add', 'i64.and', 'i64.or', 'i64.xor'])
@@ -1284,14 +1286,17 @@ export function deadStoreElim(fn) {
1284
1286
  if (!Array.isArray(node)) continue
1285
1287
  const op = node[0]
1286
1288
 
1287
- // Reads invalidate pending dead writes
1289
+ // Reads invalidate pending dead writes. For local.tee/local.set, the RHS reads
1290
+ // happen BEFORE the write — so a `local.get $x` inside `(local.tee $x ...)` is a
1291
+ // real read of the OLD $x and must invalidate any pending dead-write of $x.
1288
1292
  const reads = new Set()
1289
1293
  collectGets(node, reads)
1290
- if (op === 'local.tee') reads.delete(node[1])
1291
1294
  for (const name of reads) lastWrite.delete(name)
1292
1295
 
1293
- // Drop of pure expr → dead
1294
- if (op === 'drop' && isPure(node[1])) {
1296
+ // Drop of pure expr → dead. Only `(drop EXPR)`: a bare `(drop)` consumes
1297
+ // an implicit stack value (e.g. a `try_table` catch payload) — removing it
1298
+ // would unbalance the stack.
1299
+ if (op === 'drop' && node.length === 2 && isPure(node[1])) {
1295
1300
  dead.push({ parent: items, idx: i, drop: true })
1296
1301
  }
1297
1302
 
@@ -1349,6 +1354,36 @@ export function deadStoreElim(fn) {
1349
1354
  }
1350
1355
  }
1351
1356
 
1357
+ /**
1358
+ * Module-wide scan for "volatile" globals — those mutated (`global.set`) in any
1359
+ * function other than `$__start`. Globals written only in `$__start` are
1360
+ * init-once: `$__start` runs to completion before any other function, so they
1361
+ * are effectively read-only afterwards and stay promotable.
1362
+ *
1363
+ * promoteGlobals uses this to avoid caching a callee-mutable global into a
1364
+ * function-entry local across a call (which would leave the local stale).
1365
+ *
1366
+ * @param {Array<Array>} funcs - all module function IR nodes
1367
+ * @returns {Set<string>} volatile global names (with leading `$`)
1368
+ */
1369
+ export function collectVolatileGlobals(funcs) {
1370
+ const volatile = new Set()
1371
+ const scan = (node) => {
1372
+ if (!Array.isArray(node)) return
1373
+ if (node[0] === 'global.set') {
1374
+ if (typeof node[1] === 'string') volatile.add(node[1])
1375
+ for (let i = 2; i < node.length; i++) scan(node[i])
1376
+ return
1377
+ }
1378
+ for (let i = 1; i < node.length; i++) scan(node[i])
1379
+ }
1380
+ for (const fn of funcs) {
1381
+ if (!Array.isArray(fn) || fn[0] !== 'func' || fn[1] === '$__start') continue
1382
+ for (let i = 2; i < fn.length; i++) scan(fn[i])
1383
+ }
1384
+ return volatile
1385
+ }
1386
+
1352
1387
  /**
1353
1388
  * Promote read-only globals to locals within each function.
1354
1389
  *
@@ -1363,17 +1398,26 @@ export function deadStoreElim(fn) {
1363
1398
  * also written (global.set) are left untouched — the promotion would be unsound if
1364
1399
  * the global changes between reads.
1365
1400
  *
1401
+ * A within-function read-only check is NOT sufficient: a callee can mutate the
1402
+ * global between two reads in this function. `volatileGlobals` (globals written
1403
+ * anywhere outside `$__start`) gates that case — a volatile global is not
1404
+ * promoted in any function that makes a call. Init-once globals (written only in
1405
+ * `$__start`) stay promotable everywhere.
1406
+ *
1366
1407
  * @param {Array} fn - Function IR (WAT-as-array)
1367
1408
  * @param {Map<string,string>} [globalTypes] - Optional: global name → wasm type ('i32'|'f64'|'i64'|'funcref')
1409
+ * @param {Set<string>} [volatileGlobals] - Optional: globals mutated outside `$__start` (see collectVolatileGlobals)
1368
1410
  */
1369
- export function promoteGlobals(fn, globalTypes) {
1411
+ export function promoteGlobals(fn, globalTypes, volatileGlobals) {
1370
1412
  if (!Array.isArray(fn) || fn[0] !== 'func') return
1371
1413
  const bodyStart = findBodyStart(fn)
1372
1414
  if (bodyStart < 0) return
1373
1415
 
1374
- // Collect global.get counts and detect any global.set
1416
+ // Collect global.get counts, detect any global.set, and note whether the
1417
+ // function makes a call (a callee may mutate a volatile global between reads).
1375
1418
  const getCounts = new Map() // globalName → count
1376
1419
  const written = new Set()
1420
+ let hasCall = false
1377
1421
 
1378
1422
  const scan = (node) => {
1379
1423
  if (!Array.isArray(node)) return
@@ -1387,6 +1431,7 @@ export function promoteGlobals(fn, globalTypes) {
1387
1431
  if (node[2]) scan(node[2])
1388
1432
  return
1389
1433
  }
1434
+ if (op === 'call' || op === 'call_indirect') hasCall = true
1390
1435
  for (let i = 1; i < node.length; i++) scan(node[i])
1391
1436
  }
1392
1437
 
@@ -1406,6 +1451,8 @@ export function promoteGlobals(fn, globalTypes) {
1406
1451
  const replacements = new Map()
1407
1452
  for (const [gName, count] of getCounts) {
1408
1453
  if (count < 3 || written.has(gName)) continue
1454
+ // Unsound to cache a callee-mutable global across a call in this function.
1455
+ if (hasCall && volatileGlobals && volatileGlobals.has(gName)) continue
1409
1456
  // Determine type: use provided map, or infer from context
1410
1457
  const type = globalTypes?.get(gName) || inferTypeFromContext(fn, gName, bodyStart)
1411
1458
  if (!type) continue // can't determine type, skip
@@ -1872,8 +1919,10 @@ export function sortStrPoolByFreq(funcs, strPoolRef, strDedupMap) {
1872
1919
  *
1873
1920
  * @param fn func IR node
1874
1921
  * @param cfg optional resolved config from resolveOptimize() — when omitted, all on.
1922
+ * @param globalTypes optional global name → wasm type map (for promoteGlobals)
1923
+ * @param volatileGlobals optional set of callee-mutable globals (see collectVolatileGlobals)
1875
1924
  */
1876
- export function optimizeFunc(fn, cfg, globalTypes) {
1925
+ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals) {
1877
1926
  if (cfg && cfg.hoistPtrType === false &&
1878
1927
  cfg.hoistInvariantPtrOffset === false &&
1879
1928
  cfg.hoistInvariantPtrOffsetLoop === false &&
@@ -1898,7 +1947,7 @@ export function optimizeFunc(fn, cfg, globalTypes) {
1898
1947
  if (!cfg || cfg.csePureExpr !== false) csePureExpr(fn)
1899
1948
  if (!cfg || cfg.dropDeadZeroInit !== false) dropDeadZeroInit(fn)
1900
1949
  if (!cfg || cfg.deadStoreElim !== false) deadStoreElim(fn)
1901
- if (!cfg || cfg.promoteGlobals !== false) promoteGlobals(fn, globalTypes)
1950
+ if (!cfg || cfg.promoteGlobals !== false) promoteGlobals(fn, globalTypes, volatileGlobals)
1902
1951
  // Vectorizer runs PRE-watr unless full watr is enabled (`watr: true`). For full watr,
1903
1952
  // defer to post — full passes (notably `inlineOnce` + the post-inline `propagate`
1904
1953
  // sweep) reshape the IR so much that pre-watr SIMD patterns get scrambled. Light
@@ -1966,11 +2015,18 @@ function walkRewrite(node, doInline, counts) {
1966
2015
  ['i64.eq', node[2], ['i64.const', NULL_BITS]],
1967
2016
  ['i64.eq', node[2], ['i64.const', UNDEF_BITS]]]
1968
2017
  if (fname === '$__is_truthy' && Array.isArray(node[2]) && node[2][0] === 'i64.reinterpret_f64'
1969
- && Array.isArray(node[2][1]) && node[2][1][0] === 'local.get') {
1970
- const lget = node[2][1]
1971
- const bits = node[2]
2018
+ && Array.isArray(node[2][1]) && (node[2][1][0] === 'local.get' || node[2][1][0] === 'local.tee')) {
2019
+ // `local.tee $x SRC` evaluates SRC once, stores to $x, returns the value —
2020
+ // hot for `a || b` lowering (`__is_truthy(local.tee $t …)`). Keep the tee
2021
+ // as the first use (the `if` condition runs before then/else, and f64.eq's
2022
+ // left operand runs first), so $x is set before every `local.get` repeat.
2023
+ const ref = node[2][1]
2024
+ const lname = ref[1]
2025
+ const lget = ['local.get', lname]
2026
+ const first = ref[0] === 'local.tee' ? ref : lget
2027
+ const bits = ['i64.reinterpret_f64', lget]
1972
2028
  return ['if', ['result', 'i32'],
1973
- ['f64.eq', lget, lget],
2029
+ ['f64.eq', first, lget],
1974
2030
  ['then', ['f64.ne', lget, ['f64.const', 0]]],
1975
2031
  ['else', ['i32.and',
1976
2032
  ['i32.and',
@@ -2008,70 +2064,18 @@ function walkRewrite(node, doInline, counts) {
2008
2064
  if (Array.isArray(a) && a[0] === 'f64.convert_i32_s' && a.length === 2) return ['i64.extend_i32_s', a[1]]
2009
2065
  if (Array.isArray(a) && a[0] === 'f64.convert_i32_u' && a.length === 2) return ['i64.extend_i32_u', a[1]]
2010
2066
  }
2011
- if (op === 'i64.reinterpret_f64' && node.length === 2) {
2012
- const a = node[1]
2013
- if (Array.isArray(a) && a[0] === 'f64.reinterpret_i64' && a.length === 2) return a[1]
2014
- }
2015
- if (op === 'f64.reinterpret_i64' && node.length === 2) {
2016
- const a = node[1]
2017
- if (Array.isArray(a) && a[0] === 'i64.reinterpret_f64' && a.length === 2) return a[1]
2067
+ // Rep-specific folds (NaN-box layout-aware reinterpret/wrap simplifications under
2068
+ // the nanbox preset). See abi/number/<rep>.js — each rep owns the rules that
2069
+ // depend on its own carrier layout. The universal `i32.wrap_i64 (i64.extend_i32_*)`
2070
+ // fold below stays here because it's pure WASM bit-pattern, ABI-agnostic.
2071
+ if (op === 'i64.reinterpret_f64' || op === 'f64.reinterpret_i64' || op === 'i32.wrap_i64') {
2072
+ const repFold = ctx.abi?.number?.peephole(node)
2073
+ if (repFold != null) return repFold
2018
2074
  }
2019
2075
  if (op === 'i32.wrap_i64' && node.length === 2) {
2020
2076
  const a = node[1]
2021
2077
  if (Array.isArray(a) && (a[0] === 'i64.extend_i32_u' || a[0] === 'i64.extend_i32_s') && a.length === 2)
2022
2078
  return a[1]
2023
- // (i32.wrap_i64 (i64.reinterpret_f64 (f64.load ADDR ?offset))) → (i32.load ADDR ?offset).
2024
- // Wasm is little-endian; the low 32 bits of the f64 at ADDR are exactly
2025
- // i32.load(ADDR). Saves two ops on every NaN-box pointer extraction from
2026
- // an array slot or struct field.
2027
- if (Array.isArray(a) && a[0] === 'i64.reinterpret_f64' && a.length === 2) {
2028
- const inner = a[1]
2029
- if (Array.isArray(inner) && inner[0] === 'f64.load') {
2030
- const out = ['i32.load']
2031
- for (let i = 1; i < inner.length; i++) out.push(inner[i])
2032
- return out
2033
- }
2034
- // (i32.wrap_i64 (i64.reinterpret_f64 (call $__mkptr* … offset))) → offset.
2035
- // A NaN-boxed pointer keeps type/aux in the high bits and the i32 offset in
2036
- // the low 32, so the round-trip through f64 is pure overhead whenever the
2037
- // consumer only wants the offset (typical when the pointer feeds an unboxed
2038
- // i32 local). Covers the generic 3-arg `$__mkptr` and the specialized
2039
- // single-arg `$__mkptr_T_A_d` trampolines alike — offset is the last arg.
2040
- const isMkptr = n => Array.isArray(n) && n[0] === 'call' && typeof n[1] === 'string'
2041
- && (n[1] === '$__mkptr' || n[1].startsWith('$__mkptr_'))
2042
- if (isMkptr(inner)) return inner[inner.length - 1]
2043
- // …and reach through a `(block (result f64) …stmts (call $__mkptr …))` —
2044
- // `new TypedArray(n)` lowers to exactly this shape — by retyping the block
2045
- // to i32 and dropping the box on its tail.
2046
- if (Array.isArray(inner) && inner[0] === 'block' && isMkptr(inner[inner.length - 1])) {
2047
- let ri = -1
2048
- for (let i = 1; i <= 2 && i < inner.length; i++)
2049
- if (Array.isArray(inner[i]) && inner[i][0] === 'result') { ri = i; break }
2050
- if (ri >= 0 && inner[ri][1] === 'f64') {
2051
- const tail = inner[inner.length - 1]
2052
- const nb = inner.slice()
2053
- nb[ri] = ['result', 'i32']
2054
- nb[nb.length - 1] = tail[tail.length - 1]
2055
- return nb
2056
- }
2057
- }
2058
- }
2059
- if (Array.isArray(a) && a[0] === 'i64.or' && a.length === 3) {
2060
- const l = a[1], r = a[2]
2061
- const isHighOnly = (n) => {
2062
- if (!Array.isArray(n) || n[0] !== 'i64.const') return false
2063
- const v = n[1]
2064
- let bi
2065
- if (typeof v === 'number') bi = BigInt(v)
2066
- else if (typeof v === 'string') {
2067
- try { bi = v.startsWith('-') ? -BigInt(v.slice(1)) : BigInt(v) } catch { return false }
2068
- } else return false
2069
- return (bi & 0xFFFFFFFFn) === 0n
2070
- }
2071
- const isExtend = (n) => Array.isArray(n) && (n[0] === 'i64.extend_i32_u' || n[0] === 'i64.extend_i32_s') && n.length === 2
2072
- if (isHighOnly(l) && isExtend(r)) return r[1]
2073
- if (isHighOnly(r) && isExtend(l)) return l[1]
2074
- }
2075
2079
  }
2076
2080
 
2077
2081
  // shl-distribute-over-add: (i32.shl (i32.add x (i32.const K)) (i32.const S))