jz 0.3.1 → 0.5.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.
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,17 +45,19 @@ 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. Stable jz passes (every pass with benchmark proof). watr disabled
49
- * by default adds ~2s compile time for identical runtime performance.
50
- * 3 aggressive experimental passes in addition to level 2.
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.
51
54
  *
52
55
  * String aliases (the size↔speed tradeoff lives entirely in the unroll/scalar
53
- * knobs, so the aliases just dial those):
54
- * 'size' — level 2 with loop/const unroll + lane vectorization off and tight
55
- * scalar-replacement caps. Smallest wasm; still fully optimized.
56
+ * knobs; watr is on for all three):
57
+ * 'size' — loop/const unroll + lane vectorization off, tight scalar-replacement
58
+ * caps. Smallest wasm.
56
59
  * 'balanced' — the default (= level 2).
57
- * 'speed' — level 2 with full nested unroll + lane vectorization on (= level 3
58
- * minus the heavy third-party watr pass).
60
+ * 'speed' — full nested unroll + lane vectorization (= level 3).
59
61
  */
60
62
  export const PASS_NAMES = [
61
63
  'watr', // third-party WAT-level CSE/DCE/inlining (heaviest)
@@ -67,6 +69,7 @@ export const PASS_NAMES = [
67
69
  'hoistInvariantCellLoads',
68
70
  'cseScalarLoad',
69
71
  'csePureExpr',
72
+ 'dropDeadZeroInit',
70
73
  'deadStoreElim',
71
74
  'promoteGlobals', // read-only global.get → local for multi-read globals
72
75
  'sortLocalsByUse',
@@ -80,6 +83,7 @@ export const PASS_NAMES = [
80
83
  'vectorizeLaneLocal', // SIMD-128 lift for lane-pure typed-array loops
81
84
  'arenaRewind', // per-call heap rewind for no-arg scalar allocator kernels
82
85
  'treeshake',
86
+ 'jsstring', // boundary opt-in: flip exported string params to externref
83
87
  ]
84
88
 
85
89
  const ALL_ON = Object.freeze(Object.fromEntries(PASS_NAMES.map(n => [n, true])))
@@ -87,16 +91,36 @@ const ALL_OFF = Object.freeze(Object.fromEntries(PASS_NAMES.map(n => [n, false])
87
91
  const LEVEL_PRESETS = Object.freeze({
88
92
  0: ALL_OFF,
89
93
  1: Object.freeze({ ...ALL_OFF, treeshake: true, sortLocalsByUse: true, fusedRewrite: true }),
90
- 2: Object.freeze({ ...ALL_ON, watr: false, nestedSmallConstForUnroll: 'auto' }),
91
- 3: ALL_ON,
92
- // Named aliases dial the size↔speed knobs on top of the level-2 base.
93
- balanced: Object.freeze({ ...ALL_ON, watr: false, 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' }),
99
+ // L3/'speed' trades a bit of heap headroom for fewer __arr_grow / __hash growth
100
+ // cycles. arrayMinCap=16 means `[]` and `new Array()` skip the first two doublings
101
+ // (0→2→4→8→16); hashSmallInitCap=8 keeps per-object __dyn_props at the same load
102
+ // factor as the global __hash_new on first set, avoiding the 2→4→8 grow chain.
103
+ // Net cost: ~128 B per empty array, ~144 B per per-object hash. Net win on the
104
+ // watr.compile profile: __arr_grow ~6.7% → ~3%, and lower __ihash_get_local
105
+ // probe depth from a denser-load global hash.
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 }),
115
+ // 'balanced' = level 2; 'size' tightens scalar/unroll caps; 'speed' = level 3.
116
+ balanced: Object.freeze({ ...ALL_ON, nestedSmallConstForUnroll: 'auto' }),
94
117
  size: Object.freeze({
95
- ...ALL_ON, watr: false,
118
+ ...ALL_ON,
96
119
  smallConstForUnroll: false, nestedSmallConstForUnroll: false, vectorizeLaneLocal: false,
97
120
  scalarTypedLoopUnroll: 4, scalarTypedNestedUnroll: 8, scalarTypedArrayLen: 8,
98
121
  }),
99
- speed: Object.freeze({ ...ALL_ON, watr: false }),
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 }),
100
124
  })
101
125
 
102
126
  /**
@@ -118,7 +142,14 @@ export function resolveOptimize(opt) {
118
142
  const baseLevel = typeof opt.level === 'number' || typeof opt.level === 'string' ? opt.level : 2
119
143
  const base = LEVEL_PRESETS[baseLevel] || ALL_ON
120
144
  const out = { ...base }
121
- for (const n of PASS_NAMES) if (n in opt) out[n] = n === 'nestedSmallConstForUnroll' && opt[n] === 'auto' ? 'auto' : !!opt[n]
145
+ for (const n of PASS_NAMES) {
146
+ if (!(n in opt)) continue
147
+ const v = opt[n]
148
+ // Preserve sentinel value `nestedSmallConstForUnroll: 'auto'`
149
+ // (resolved by a heuristic at emit time).
150
+ if (n === 'nestedSmallConstForUnroll' && v === 'auto') out[n] = 'auto'
151
+ else out[n] = !!v
152
+ }
122
153
  // Preserve non-pass tuning keys (e.g. plan.js thresholds)
123
154
  for (const k of Object.keys(opt)) if (!PASS_NAMES.includes(k)) out[k] = opt[k]
124
155
  return out
@@ -841,44 +872,33 @@ export function hoistInvariantCellLoads(fn) {
841
872
  * — emits 6 f64.load on $p (each of x/y/z twice); collapses to 3 unique loads
842
873
  * shared via tee'd snap locals.
843
874
  *
844
- * Safety: jz's invariant distinct unboxed-pointer locals come from distinct
845
- * fresh allocations (analyzePtrUnboxable refuses to unbox aliased locals).
846
- * So `(f64.store ADDR ...)` with base `(local.get $Y)` for $Y ≠ $X cannot
847
- * touch addresses reachable via `$X + K`. Stores to typed-array slots in the
848
- * 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.
849
882
  *
850
883
  * Region boundaries that flush the table:
851
884
  * - branch (br/br_if/br_table/return/unreachable)
852
885
  * - non-pure call
853
886
  * - loop / if (control flow)
854
887
  * - local.set/local.tee on a tracked $X (invalidates that X's entries)
855
- * - 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)
856
890
  * Blocks are treated as transparent — recurse into children.
857
891
  */
858
892
  export function cseScalarLoad(fn) {
859
- // DISABLED: the safety claim above relies on `analyzePtrUnboxable` having vetted
860
- // every i32 local as a non-aliased fresh-allocation pointer. But this pass scans
861
- // *all* i32 locals from `(local … i32)` decls — wasm-native i32 scalars (lengths,
862
- // indices), narrow-ABI helper returns, and analyze.js's new arrayElemSchema-driven
863
- // unboxes share the same declaration form. The metacircular path (jz-compiled
864
- // watr.wasm) trips this: CSE'd loads survive across stores that legitimately
865
- // mutate the same memory through a different i32 local, returning stale bytes
866
- // (manifests as "memory access out of bounds" once a corrupted offset is
867
- // dereferenced). Re-enable once candidacy is restricted to vetted pointers
868
- // (e.g. emit-side annotation or rep-derived whitelist).
869
- return
870
893
  if (!Array.isArray(fn) || fn[0] !== 'func') return
871
894
  const bodyStart = findBodyStart(fn)
872
895
  if (bodyStart < 0) return
873
896
 
874
- const i32Locals = new Set()
875
- for (let i = 2; i < fn.length; i++) {
876
- const c = fn[i]
877
- if (Array.isArray(c) && (c[0] === 'local' || c[0] === 'param') && typeof c[1] === 'string' && c[2] === 'i32') {
878
- i32Locals.add(c[1])
879
- }
880
- }
881
- 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
882
902
 
883
903
  let snapId = 0
884
904
  while (fn.some(n => Array.isArray(n) && n[0] === 'local' && n[1] === `$__cs${snapId}`)) snapId++
@@ -893,10 +913,10 @@ export function cseScalarLoad(fn) {
893
913
  }
894
914
  }
895
915
 
896
- // 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.
897
917
  const collectGets = (node, out) => {
898
918
  if (!Array.isArray(node)) return
899
- 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])) {
900
920
  out.add(node[1])
901
921
  return
902
922
  }
@@ -983,7 +1003,7 @@ export function cseScalarLoad(fn) {
983
1003
  const lp = parseLoad(node)
984
1004
  if (lp) {
985
1005
  const addr = node[lp.addrIdx]
986
- 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])) {
987
1007
  const X = addr[1]
988
1008
  const key = `${X}|${lp.K}`
989
1009
  const entry = table.get(key)
@@ -1042,8 +1062,16 @@ export function csePureExpr(fn) {
1042
1062
  const bodyStart = findBodyStart(fn)
1043
1063
  if (bodyStart < 0) return
1044
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.
1045
1069
  let snapId = 0
1046
- 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
+ }
1047
1075
  const newLocals = []
1048
1076
 
1049
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'])
@@ -1148,6 +1176,72 @@ export function csePureExpr(fn) {
1148
1176
  if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
1149
1177
  }
1150
1178
 
1179
+ /**
1180
+ * Drop redundant zero-initialisation of fresh function-scope locals.
1181
+ *
1182
+ * WASM zero-initialises every local on entry (0 / 0.0 / null). jz lowers source
1183
+ * `let x = 0` to `(local $x …)` + `(local.set $x (<zero const>))` at the top of
1184
+ * the function body — the explicit set is a no-op when nothing has touched `$x`
1185
+ * yet. `wasm-opt -Oz` elides these; do the same so jz's own output is minimal.
1186
+ *
1187
+ * Only removes a `(local.set $L (i32|i64|f64|f32.const 0))` when:
1188
+ * - `$L` is a non-param local (a param's "default" is the incoming arg, not 0),
1189
+ * - it is a *top-level* body statement (never descend into block/loop/if — a
1190
+ * nested zero-set inside a loop genuinely re-initialises across iterations),
1191
+ * - `$L` has not been referenced by any earlier top-level statement (so the
1192
+ * local still holds its entry-time zero at this point),
1193
+ * - `$L` is read (`local.get`) somewhere in the function (otherwise leave the
1194
+ * store for deadStoreElim and avoid orphaning the `(local $L …)` decl),
1195
+ * - the constant is +0 / +0.0 (a `-0.0` f64 set is *not* redundant — locals
1196
+ * default to +0.0, which differs in bits from -0.0).
1197
+ */
1198
+ export function dropDeadZeroInit(fn) {
1199
+ if (!Array.isArray(fn) || fn[0] !== 'func') return
1200
+ const bodyStart = findBodyStart(fn)
1201
+ if (bodyStart < 0) return
1202
+
1203
+ const seen = new Set() // params + locals referenced by an earlier stmt
1204
+ const reads = new Set() // locals read by `local.get` anywhere
1205
+ for (const c of fn) if (Array.isArray(c) && c[0] === 'param' && typeof c[1] === 'string') seen.add(c[1])
1206
+
1207
+ const collectGets = (node) => {
1208
+ if (!Array.isArray(node)) return
1209
+ if (node[0] === 'local.get' && typeof node[1] === 'string') reads.add(node[1])
1210
+ for (let i = 1; i < node.length; i++) collectGets(node[i])
1211
+ }
1212
+ for (let i = bodyStart; i < fn.length; i++) collectGets(fn[i])
1213
+
1214
+ const collectRefs = (node) => {
1215
+ if (!Array.isArray(node)) return
1216
+ const op = node[0]
1217
+ if ((op === 'local.get' || op === 'local.set' || op === 'local.tee') && typeof node[1] === 'string') seen.add(node[1])
1218
+ for (let i = 1; i < node.length; i++) collectRefs(node[i])
1219
+ }
1220
+ const isPlusZeroConst = (e) => {
1221
+ if (!Array.isArray(e) || e.length !== 2) return false
1222
+ if (e[0] !== 'i32.const' && e[0] !== 'i64.const' && e[0] !== 'f64.const' && e[0] !== 'f32.const') return false
1223
+ const v = e[1]
1224
+ if (typeof v === 'bigint') return v === 0n
1225
+ if (typeof v === 'number') return v === 0 && !Object.is(v, -0)
1226
+ if (typeof v === 'string') { const t = v.trim(); return t === '0' || t === '0.0' || t === '+0' || t === '+0.0' }
1227
+ return false
1228
+ }
1229
+
1230
+ const drop = []
1231
+ for (let i = bodyStart; i < fn.length; i++) {
1232
+ const node = fn[i]
1233
+ if (!Array.isArray(node)) continue
1234
+ if (node[0] === 'local.set' && node.length === 3 && typeof node[1] === 'string' &&
1235
+ !seen.has(node[1]) && reads.has(node[1]) && isPlusZeroConst(node[2])) {
1236
+ drop.push(i)
1237
+ seen.add(node[1])
1238
+ continue
1239
+ }
1240
+ collectRefs(node)
1241
+ }
1242
+ for (let i = drop.length - 1; i >= 0; i--) fn.splice(drop[i], 1)
1243
+ }
1244
+
1151
1245
  /**
1152
1246
  * Dead-store elimination: remove `local.set` / `local.tee` and `drop` of pure
1153
1247
  * expressions whose values are never consumed.
@@ -1192,21 +1286,31 @@ export function deadStoreElim(fn) {
1192
1286
  if (!Array.isArray(node)) continue
1193
1287
  const op = node[0]
1194
1288
 
1195
- // 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.
1196
1292
  const reads = new Set()
1197
1293
  collectGets(node, reads)
1198
- if (op === 'local.tee') reads.delete(node[1])
1199
1294
  for (const name of reads) lastWrite.delete(name)
1200
1295
 
1201
- // Drop of pure expr → dead
1202
- 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])) {
1203
1300
  dead.push({ parent: items, idx: i, drop: true })
1204
1301
  }
1205
1302
 
1206
1303
  // Local write tracking
1207
1304
  if ((op === 'local.set' || op === 'local.tee') && typeof node[1] === 'string') {
1208
1305
  const prev = lastWrite.get(node[1])
1209
- if (prev) dead.push(prev)
1306
+ if (prev) {
1307
+ // The store-to-local is dead, but a `local.set` is only *removable*
1308
+ // if its RHS is pure — `local.set $x (call f …)` where `f` mutates
1309
+ // memory must still run. (A `local.tee` is always safe: removal demotes
1310
+ // it to its value expression, so any side effects there are preserved.)
1311
+ const pn = prev.parent[prev.idx]
1312
+ if (pn[0] === 'local.tee' || isPure(pn[2])) dead.push(prev)
1313
+ }
1210
1314
  lastWrite.set(node[1], { parent: items, idx: i })
1211
1315
  }
1212
1316
 
@@ -1250,6 +1354,36 @@ export function deadStoreElim(fn) {
1250
1354
  }
1251
1355
  }
1252
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
+
1253
1387
  /**
1254
1388
  * Promote read-only globals to locals within each function.
1255
1389
  *
@@ -1264,17 +1398,26 @@ export function deadStoreElim(fn) {
1264
1398
  * also written (global.set) are left untouched — the promotion would be unsound if
1265
1399
  * the global changes between reads.
1266
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
+ *
1267
1407
  * @param {Array} fn - Function IR (WAT-as-array)
1268
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)
1269
1410
  */
1270
- export function promoteGlobals(fn, globalTypes) {
1411
+ export function promoteGlobals(fn, globalTypes, volatileGlobals) {
1271
1412
  if (!Array.isArray(fn) || fn[0] !== 'func') return
1272
1413
  const bodyStart = findBodyStart(fn)
1273
1414
  if (bodyStart < 0) return
1274
1415
 
1275
- // 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).
1276
1418
  const getCounts = new Map() // globalName → count
1277
1419
  const written = new Set()
1420
+ let hasCall = false
1278
1421
 
1279
1422
  const scan = (node) => {
1280
1423
  if (!Array.isArray(node)) return
@@ -1288,6 +1431,7 @@ export function promoteGlobals(fn, globalTypes) {
1288
1431
  if (node[2]) scan(node[2])
1289
1432
  return
1290
1433
  }
1434
+ if (op === 'call' || op === 'call_indirect') hasCall = true
1291
1435
  for (let i = 1; i < node.length; i++) scan(node[i])
1292
1436
  }
1293
1437
 
@@ -1307,6 +1451,8 @@ export function promoteGlobals(fn, globalTypes) {
1307
1451
  const replacements = new Map()
1308
1452
  for (const [gName, count] of getCounts) {
1309
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
1310
1456
  // Determine type: use provided map, or infer from context
1311
1457
  const type = globalTypes?.get(gName) || inferTypeFromContext(fn, gName, bodyStart)
1312
1458
  if (!type) continue // can't determine type, skip
@@ -1472,7 +1618,8 @@ export function specializeMkptr(funcs, addFunc, parseWat) {
1472
1618
  // Any target not listed here is left untouched. Order matters only for readability.
1473
1619
  const SPECS = {
1474
1620
  '$__mkptr': { params: ['i32', 'i32', 'i32'], result: 'f64', inline: true },
1475
- '$__alloc_hdr': { params: ['i32', 'i32', 'i32'], result: 'i32' },
1621
+ '$__alloc_hdr': { params: ['i32', 'i32'], result: 'i32' },
1622
+ '$__alloc_hdr_n': { params: ['i32', 'i32', 'i32'], result: 'i32' },
1476
1623
  '$__typed_idx': { params: ['i64', 'i32'], result: 'f64' },
1477
1624
  '$__str_idx': { params: ['i64', 'i32'], result: 'f64' },
1478
1625
  }
@@ -1772,8 +1919,10 @@ export function sortStrPoolByFreq(funcs, strPoolRef, strDedupMap) {
1772
1919
  *
1773
1920
  * @param fn func IR node
1774
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)
1775
1924
  */
1776
- export function optimizeFunc(fn, cfg, globalTypes) {
1925
+ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals) {
1777
1926
  if (cfg && cfg.hoistPtrType === false &&
1778
1927
  cfg.hoistInvariantPtrOffset === false &&
1779
1928
  cfg.hoistInvariantPtrOffsetLoop === false &&
@@ -1782,6 +1931,7 @@ export function optimizeFunc(fn, cfg, globalTypes) {
1782
1931
  cfg.hoistInvariantCellLoads === false &&
1783
1932
  cfg.cseScalarLoad === false &&
1784
1933
  cfg.csePureExpr === false &&
1934
+ cfg.dropDeadZeroInit === false &&
1785
1935
  cfg.deadStoreElim === false &&
1786
1936
  cfg.promoteGlobals === false &&
1787
1937
  cfg.sortLocalsByUse === false &&
@@ -1795,12 +1945,17 @@ export function optimizeFunc(fn, cfg, globalTypes) {
1795
1945
  if (!cfg || cfg.hoistInvariantCellLoads !== false) hoistInvariantCellLoads(fn)
1796
1946
  if (!cfg || cfg.cseScalarLoad !== false) cseScalarLoad(fn)
1797
1947
  if (!cfg || cfg.csePureExpr !== false) csePureExpr(fn)
1948
+ if (!cfg || cfg.dropDeadZeroInit !== false) dropDeadZeroInit(fn)
1798
1949
  if (!cfg || cfg.deadStoreElim !== false) deadStoreElim(fn)
1799
- if (!cfg || cfg.promoteGlobals !== false) promoteGlobals(fn, globalTypes)
1800
- // Vectorizer runs in POST-watr when watr is enabled, or in PRE when watr is
1801
- // disabled (there is no post pass then). This keeps single-pass semantics.
1950
+ if (!cfg || cfg.promoteGlobals !== false) promoteGlobals(fn, globalTypes, volatileGlobals)
1951
+ // Vectorizer runs PRE-watr unless full watr is enabled (`watr: true`). For full watr,
1952
+ // defer to post full passes (notably `inlineOnce` + the post-inline `propagate`
1953
+ // sweep) reshape the IR so much that pre-watr SIMD patterns get scrambled. Light
1954
+ // watr (or no watr) leaves the lane locals intact for vectorize to pattern-match,
1955
+ // and lets a non-trivial chunk of SIMD survive the propagate+fold pipeline.
1802
1956
  if (cfg && cfg.vectorizeLaneLocal === true) {
1803
- const runVectorizer = cfg.__phase === 'post' || (cfg.__phase !== 'post' && cfg.watr === false)
1957
+ const fullWatr = cfg.watr === true
1958
+ const runVectorizer = (fullWatr && cfg.__phase === 'post') || (!fullWatr && cfg.__phase !== 'post')
1804
1959
  if (runVectorizer) vectorizeLaneLocal(fn)
1805
1960
  }
1806
1961
  if (!cfg || cfg.sortLocalsByUse !== false) sortLocalsByUse(fn, cfg && cfg.fusedRewrite !== false ? counts : null)
@@ -1860,11 +2015,18 @@ function walkRewrite(node, doInline, counts) {
1860
2015
  ['i64.eq', node[2], ['i64.const', NULL_BITS]],
1861
2016
  ['i64.eq', node[2], ['i64.const', UNDEF_BITS]]]
1862
2017
  if (fname === '$__is_truthy' && Array.isArray(node[2]) && node[2][0] === 'i64.reinterpret_f64'
1863
- && Array.isArray(node[2][1]) && node[2][1][0] === 'local.get') {
1864
- const lget = node[2][1]
1865
- 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]
1866
2028
  return ['if', ['result', 'i32'],
1867
- ['f64.eq', lget, lget],
2029
+ ['f64.eq', first, lget],
1868
2030
  ['then', ['f64.ne', lget, ['f64.const', 0]]],
1869
2031
  ['else', ['i32.and',
1870
2032
  ['i32.and',
@@ -1902,46 +2064,18 @@ function walkRewrite(node, doInline, counts) {
1902
2064
  if (Array.isArray(a) && a[0] === 'f64.convert_i32_s' && a.length === 2) return ['i64.extend_i32_s', a[1]]
1903
2065
  if (Array.isArray(a) && a[0] === 'f64.convert_i32_u' && a.length === 2) return ['i64.extend_i32_u', a[1]]
1904
2066
  }
1905
- if (op === 'i64.reinterpret_f64' && node.length === 2) {
1906
- const a = node[1]
1907
- if (Array.isArray(a) && a[0] === 'f64.reinterpret_i64' && a.length === 2) return a[1]
1908
- }
1909
- if (op === 'f64.reinterpret_i64' && node.length === 2) {
1910
- const a = node[1]
1911
- 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
1912
2074
  }
1913
2075
  if (op === 'i32.wrap_i64' && node.length === 2) {
1914
2076
  const a = node[1]
1915
2077
  if (Array.isArray(a) && (a[0] === 'i64.extend_i32_u' || a[0] === 'i64.extend_i32_s') && a.length === 2)
1916
2078
  return a[1]
1917
- // (i32.wrap_i64 (i64.reinterpret_f64 (f64.load ADDR ?offset))) → (i32.load ADDR ?offset).
1918
- // Wasm is little-endian; the low 32 bits of the f64 at ADDR are exactly
1919
- // i32.load(ADDR). Saves two ops on every NaN-box pointer extraction from
1920
- // an array slot or struct field.
1921
- if (Array.isArray(a) && a[0] === 'i64.reinterpret_f64' && a.length === 2) {
1922
- const inner = a[1]
1923
- if (Array.isArray(inner) && inner[0] === 'f64.load') {
1924
- const out = ['i32.load']
1925
- for (let i = 1; i < inner.length; i++) out.push(inner[i])
1926
- return out
1927
- }
1928
- }
1929
- if (Array.isArray(a) && a[0] === 'i64.or' && a.length === 3) {
1930
- const l = a[1], r = a[2]
1931
- const isHighOnly = (n) => {
1932
- if (!Array.isArray(n) || n[0] !== 'i64.const') return false
1933
- const v = n[1]
1934
- let bi
1935
- if (typeof v === 'number') bi = BigInt(v)
1936
- else if (typeof v === 'string') {
1937
- try { bi = v.startsWith('-') ? -BigInt(v.slice(1)) : BigInt(v) } catch { return false }
1938
- } else return false
1939
- return (bi & 0xFFFFFFFFn) === 0n
1940
- }
1941
- const isExtend = (n) => Array.isArray(n) && (n[0] === 'i64.extend_i32_u' || n[0] === 'i64.extend_i32_s') && n.length === 2
1942
- if (isHighOnly(l) && isExtend(r)) return r[1]
1943
- if (isHighOnly(r) && isExtend(l)) return l[1]
1944
- }
1945
2079
  }
1946
2080
 
1947
2081
  // shl-distribute-over-add: (i32.shl (i32.add x (i32.const K)) (i32.const S))
@@ -2081,6 +2215,36 @@ export function treeshake(funcSections, allModuleNodes, opts) {
2081
2215
  }
2082
2216
  }
2083
2217
  }
2218
+
2219
+ // Dead-global elimination: after dead funcs are gone, drop `(global $g …)` decls
2220
+ // that nothing references (a `global.get`/`global.set` in a remaining func, a kept
2221
+ // global's init expr, a data/elem offset, or an `(export … (global $g))`). Imported
2222
+ // globals live in `allModuleNodes`, not in `opts.globals`, so they're never touched.
2223
+ // Fixpoint: a kept global's init may reference another global.
2224
+ const globals = removeDead && opts && Array.isArray(opts.globals) ? opts.globals : null
2225
+ if (globals) {
2226
+ const collectGlobalRefs = (node, refd) => {
2227
+ if (!Array.isArray(node)) return
2228
+ if ((node[0] === 'global.get' || node[0] === 'global.set') && typeof node[1] === 'string') refd.add(node[1])
2229
+ else if (node[0] === 'export' && Array.isArray(node[2]) && node[2][0] === 'global' && typeof node[2][1] === 'string') refd.add(node[2][1])
2230
+ for (const c of node) collectGlobalRefs(c, refd)
2231
+ }
2232
+ let changed = true
2233
+ while (changed) {
2234
+ changed = false
2235
+ const refd = new Set()
2236
+ for (const { arr } of funcSections) for (const n of arr) collectGlobalRefs(n, refd)
2237
+ for (const n of allModuleNodes) collectGlobalRefs(n, refd)
2238
+ for (const g of globals) collectGlobalRefs(g, refd)
2239
+ for (let i = globals.length - 1; i >= 0; i--) {
2240
+ const g = globals[i]
2241
+ if (Array.isArray(g) && g[0] === 'global' && typeof g[1] === 'string' && !refd.has(g[1])) {
2242
+ globals.splice(i, 1); changed = true
2243
+ }
2244
+ }
2245
+ }
2246
+ }
2247
+
2084
2248
  return { removed, callCount }
2085
2249
  }
2086
2250
 
@@ -2146,7 +2310,7 @@ export function sortLocalsByUse(fn, precomputedCounts) {
2146
2310
  */
2147
2311
  export function arenaRewindModule(fns) {
2148
2312
  const BUILTIN_SAFE = new Set([
2149
- '$__alloc', '$__alloc_hdr', '$__mkptr',
2313
+ '$__alloc', '$__alloc_hdr', '$__alloc_hdr_n', '$__mkptr',
2150
2314
  '$__ptr_offset', '$__ptr_type', '$__ptr_aux',
2151
2315
  '$__len', '$__cap', '$__typed_shift', '$__typed_data',
2152
2316
  ])
@@ -2179,7 +2343,7 @@ export function arenaRewindModule(fns) {
2179
2343
  else if (op === 'call_ref') hasCallRef = true
2180
2344
  else if (op === 'call') {
2181
2345
  const callee = node[1]
2182
- if (callee === '$__alloc' || callee === '$__alloc_hdr') hasAlloc = true
2346
+ if (callee === '$__alloc' || callee === '$__alloc_hdr' || callee === '$__alloc_hdr_n') hasAlloc = true
2183
2347
  if (typeof callee === 'string' && !BUILTIN_SAFE.has(callee)) calls.add(callee)
2184
2348
  }
2185
2349
  for (let i = 1; i < node.length; i++) scan(node[i])