jz 0.3.0 → 0.4.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/narrow.js CHANGED
@@ -192,11 +192,20 @@ function enrichCallerValTypesFromPointerParams(callerCtx) {
192
192
  function refreshCallerLocals(callerCtx) {
193
193
  for (const func of ctx.func.list) {
194
194
  if (!func.body || func.raw) continue
195
+ // Seed pointer-narrowed params' val-kind so analyzeBody recognises e.g.
196
+ // `n = arr.length` (arr a TYPED/BUFFER pointer param) as an i32 local — without
197
+ // this, post-G `refreshCallerLocals` still walks bodies with arr untyped, the
198
+ // length stays f64, and any callee taking that length never gets an i32 param
199
+ // (heapsort→siftDown's `end`). analyzeFuncForEmit re-seeds + re-invalidates at
200
+ // emit time, so this transient repByLocal doesn't leak past narrowing.
201
+ ctx.func.repByLocal = new Map()
202
+ for (const p of func.sig.params) if (p.ptrKind != null) ctx.func.repByLocal.set(p.name, { val: p.ptrKind })
195
203
  invalidateLocalsCache(func.body)
196
204
  const fresh = analyzeLocals(func.body)
197
205
  for (const p of func.sig.params) if (!fresh.has(p.name)) fresh.set(p.name, p.type)
198
206
  callerCtx.get(func).callerLocals = fresh
199
207
  }
208
+ ctx.func.repByLocal = null
200
209
  }
201
210
 
202
211
  function resetParamWasmFacts(paramReps) {
@@ -361,10 +370,24 @@ export default function narrowSignatures(programFacts, ast) {
361
370
  },
362
371
  },
363
372
  ])
373
+ // Transitive ctor/schema propagation down call chains. A naive single-pass
374
+ // mergeRule poisons a callee's param on the *first* sweep if the caller's own
375
+ // param (the very thing that supplies the ctor) hasn't been typed yet — and the
376
+ // poison is sticky, so later sweeps can't recover. Two-pass was the old patch;
377
+ // it still loses any chain deeper than `main→f→g→h` (e.g. heapsort's siftDown).
378
+ // Fix: iterate a *soft* merge — propagate known ctors, treat "can't tell yet"
379
+ // as skip (no poison) — to a fixpoint, then one *hard* validating sweep that
380
+ // poisons params whose call sites still can't be proven (genuinely-untyped args).
364
381
  const runArrElemFixpoint = (field, inferFn, elemsCtxMap) => {
365
- runCallsiteLattice([mergeRule(field, (arg, _k, state) =>
366
- inferFn(arg, elemsCtxMap.get(state.callerFunc), state.callerParamFacts(field))
367
- )])
382
+ const infer = (arg, _k, state) => inferFn(arg, elemsCtxMap.get(state.callerFunc), state.callerParamFacts(field))
383
+ let changed
384
+ const bump = (r, v) => { if (v == null || r[field] === null) return; const b = r[field]; mergeParamFact(r, field, v); if (r[field] !== b) changed = true }
385
+ const soft = {
386
+ missing(r, k, state) { const def = defaultArg(state, k); if (def != null) bump(r, infer(def, k, state)) },
387
+ apply(r, arg, k, state) { bump(r, infer(arg, k, state)) },
388
+ }
389
+ do { changed = false; runCallsiteLattice([soft]) } while (changed)
390
+ runCallsiteLattice([mergeRule(field, infer)])
368
391
  }
369
392
  const runArrFixpoint = () => runArrElemFixpoint('arrayElemSchema', inferArgArrElemSchema, phase.callerElems('arrElemSchemas'))
370
393
  const runArrValTypeFixpoint = () => runArrElemFixpoint('arrayElemValType', inferArgArrElemValType, phase.callerElems('arrElemValTypes'))
@@ -423,18 +446,22 @@ export default function narrowSignatures(programFacts, ast) {
423
446
  if (isBlockBody(body) && hasBareReturn(body)) continue
424
447
  const exprs = returnExprs(body)
425
448
  if (!exprs.length) continue
426
- // Skip narrowing when any return-tail is `>>>` (unsigned uint32). Narrowing to i32
427
- // loses the unsigned interpretation: the wrapper rebox via `f64.convert_i32_s` would
428
- // sign-flip values with bit 31 set, breaking the canonical `(x >>> 0)` uint32 idiom.
429
- // A future pass could track sig.unsignedResult and emit `f64.convert_i32_u` instead.
430
- if (exprs.some(e => Array.isArray(e) && e[0] === '>>>')) continue
449
+ // Narrow result to i32 when every return-tail types as i32 (incl. bitwise ops, `|0`,
450
+ // and `>>>`). When ANY tail is `>>>` (unsigned uint32 idiom) we still narrow, but tag
451
+ // `sig.unsignedResult` so the call-site rebox uses `f64.convert_i32_u` preserving
452
+ // the [0, 2^32) range that `(x >>> 0)` carries through hash/CRC finalizers.
453
+ const anyUnsigned = exprs.some(e => Array.isArray(e) && e[0] === '>>>')
431
454
  const savedCurrent = ctx.func.current
432
455
  ctx.func.current = func.sig
433
456
  const locals = isBlockBody(body) ? analyzeLocals(body) : new Map()
434
457
  for (const p of func.sig.params) if (!locals.has(p.name)) locals.set(p.name, p.type)
435
458
  const allI32 = exprs.every(e => exprTypeWithCalls(e, locals) === 'i32')
436
459
  ctx.func.current = savedCurrent
437
- if (allI32) { func.sig.results = ['i32']; changed = true }
460
+ if (allI32) {
461
+ func.sig.results = ['i32']
462
+ if (anyUnsigned) func.sig.unsignedResult = true
463
+ changed = true
464
+ }
438
465
  }
439
466
  }
440
467
 
package/src/optimize.js CHANGED
@@ -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 + 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.
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',
@@ -87,16 +90,30 @@ const ALL_OFF = Object.freeze(Object.fromEntries(PASS_NAMES.map(n => [n, false])
87
90
  const LEVEL_PRESETS = Object.freeze({
88
91
  0: ALL_OFF,
89
92
  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' }),
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' }),
100
+ // L3/'speed' trades a bit of heap headroom for fewer __arr_grow / __hash growth
101
+ // cycles. arrayMinCap=16 means `[]` and `new Array()` skip the first two doublings
102
+ // (0→2→4→8→16); hashSmallInitCap=8 keeps per-object __dyn_props at the same load
103
+ // factor as the global __hash_new on first set, avoiding the 2→4→8 grow chain.
104
+ // Net cost: ~128 B per empty array, ~144 B per per-object hash. Net win on the
105
+ // watr.compile profile: __arr_grow ~6.7% → ~3%, and lower __ihash_get_local
106
+ // probe depth from a denser-load global hash.
107
+ 3: Object.freeze({ ...ALL_ON, arrayMinCap: 16, hashSmallInitCap: 8 }),
108
+ // 'balanced' = level 2; 'size' tightens scalar/unroll caps; 'speed' = level 3.
109
+ balanced: Object.freeze({ ...ALL_ON, watr: 'light', nestedSmallConstForUnroll: 'auto' }),
94
110
  size: Object.freeze({
95
- ...ALL_ON, watr: false,
111
+ ...ALL_ON,
96
112
  smallConstForUnroll: false, nestedSmallConstForUnroll: false, vectorizeLaneLocal: false,
97
113
  scalarTypedLoopUnroll: 4, scalarTypedNestedUnroll: 8, scalarTypedArrayLen: 8,
98
114
  }),
99
- speed: Object.freeze({ ...ALL_ON, watr: false }),
115
+ // 'speed' === level 3: full watr (inlining on) + L3 cap/hash tuning.
116
+ speed: Object.freeze({ ...ALL_ON, arrayMinCap: 16, hashSmallInitCap: 8 }),
100
117
  })
101
118
 
102
119
  /**
@@ -118,7 +135,16 @@ export function resolveOptimize(opt) {
118
135
  const baseLevel = typeof opt.level === 'number' || typeof opt.level === 'string' ? opt.level : 2
119
136
  const base = LEVEL_PRESETS[baseLevel] || ALL_ON
120
137
  const out = { ...base }
121
- for (const n of PASS_NAMES) if (n in opt) out[n] = n === 'nestedSmallConstForUnroll' && opt[n] === 'auto' ? 'auto' : !!opt[n]
138
+ for (const n of PASS_NAMES) {
139
+ if (!(n in opt)) continue
140
+ 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)
144
+ if (n === 'nestedSmallConstForUnroll' && v === 'auto') out[n] = 'auto'
145
+ else if (n === 'watr' && v === 'light') out[n] = 'light'
146
+ else out[n] = !!v
147
+ }
122
148
  // Preserve non-pass tuning keys (e.g. plan.js thresholds)
123
149
  for (const k of Object.keys(opt)) if (!PASS_NAMES.includes(k)) out[k] = opt[k]
124
150
  return out
@@ -1148,6 +1174,72 @@ export function csePureExpr(fn) {
1148
1174
  if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
1149
1175
  }
1150
1176
 
1177
+ /**
1178
+ * Drop redundant zero-initialisation of fresh function-scope locals.
1179
+ *
1180
+ * WASM zero-initialises every local on entry (0 / 0.0 / null). jz lowers source
1181
+ * `let x = 0` to `(local $x …)` + `(local.set $x (<zero const>))` at the top of
1182
+ * the function body — the explicit set is a no-op when nothing has touched `$x`
1183
+ * yet. `wasm-opt -Oz` elides these; do the same so jz's own output is minimal.
1184
+ *
1185
+ * Only removes a `(local.set $L (i32|i64|f64|f32.const 0))` when:
1186
+ * - `$L` is a non-param local (a param's "default" is the incoming arg, not 0),
1187
+ * - it is a *top-level* body statement (never descend into block/loop/if — a
1188
+ * nested zero-set inside a loop genuinely re-initialises across iterations),
1189
+ * - `$L` has not been referenced by any earlier top-level statement (so the
1190
+ * local still holds its entry-time zero at this point),
1191
+ * - `$L` is read (`local.get`) somewhere in the function (otherwise leave the
1192
+ * store for deadStoreElim and avoid orphaning the `(local $L …)` decl),
1193
+ * - the constant is +0 / +0.0 (a `-0.0` f64 set is *not* redundant — locals
1194
+ * default to +0.0, which differs in bits from -0.0).
1195
+ */
1196
+ export function dropDeadZeroInit(fn) {
1197
+ if (!Array.isArray(fn) || fn[0] !== 'func') return
1198
+ const bodyStart = findBodyStart(fn)
1199
+ if (bodyStart < 0) return
1200
+
1201
+ const seen = new Set() // params + locals referenced by an earlier stmt
1202
+ const reads = new Set() // locals read by `local.get` anywhere
1203
+ for (const c of fn) if (Array.isArray(c) && c[0] === 'param' && typeof c[1] === 'string') seen.add(c[1])
1204
+
1205
+ const collectGets = (node) => {
1206
+ if (!Array.isArray(node)) return
1207
+ if (node[0] === 'local.get' && typeof node[1] === 'string') reads.add(node[1])
1208
+ for (let i = 1; i < node.length; i++) collectGets(node[i])
1209
+ }
1210
+ for (let i = bodyStart; i < fn.length; i++) collectGets(fn[i])
1211
+
1212
+ const collectRefs = (node) => {
1213
+ if (!Array.isArray(node)) return
1214
+ const op = node[0]
1215
+ if ((op === 'local.get' || op === 'local.set' || op === 'local.tee') && typeof node[1] === 'string') seen.add(node[1])
1216
+ for (let i = 1; i < node.length; i++) collectRefs(node[i])
1217
+ }
1218
+ const isPlusZeroConst = (e) => {
1219
+ if (!Array.isArray(e) || e.length !== 2) return false
1220
+ if (e[0] !== 'i32.const' && e[0] !== 'i64.const' && e[0] !== 'f64.const' && e[0] !== 'f32.const') return false
1221
+ const v = e[1]
1222
+ if (typeof v === 'bigint') return v === 0n
1223
+ if (typeof v === 'number') return v === 0 && !Object.is(v, -0)
1224
+ if (typeof v === 'string') { const t = v.trim(); return t === '0' || t === '0.0' || t === '+0' || t === '+0.0' }
1225
+ return false
1226
+ }
1227
+
1228
+ const drop = []
1229
+ for (let i = bodyStart; i < fn.length; i++) {
1230
+ const node = fn[i]
1231
+ if (!Array.isArray(node)) continue
1232
+ if (node[0] === 'local.set' && node.length === 3 && typeof node[1] === 'string' &&
1233
+ !seen.has(node[1]) && reads.has(node[1]) && isPlusZeroConst(node[2])) {
1234
+ drop.push(i)
1235
+ seen.add(node[1])
1236
+ continue
1237
+ }
1238
+ collectRefs(node)
1239
+ }
1240
+ for (let i = drop.length - 1; i >= 0; i--) fn.splice(drop[i], 1)
1241
+ }
1242
+
1151
1243
  /**
1152
1244
  * Dead-store elimination: remove `local.set` / `local.tee` and `drop` of pure
1153
1245
  * expressions whose values are never consumed.
@@ -1206,7 +1298,14 @@ export function deadStoreElim(fn) {
1206
1298
  // Local write tracking
1207
1299
  if ((op === 'local.set' || op === 'local.tee') && typeof node[1] === 'string') {
1208
1300
  const prev = lastWrite.get(node[1])
1209
- if (prev) dead.push(prev)
1301
+ if (prev) {
1302
+ // The store-to-local is dead, but a `local.set` is only *removable*
1303
+ // if its RHS is pure — `local.set $x (call f …)` where `f` mutates
1304
+ // memory must still run. (A `local.tee` is always safe: removal demotes
1305
+ // it to its value expression, so any side effects there are preserved.)
1306
+ const pn = prev.parent[prev.idx]
1307
+ if (pn[0] === 'local.tee' || isPure(pn[2])) dead.push(prev)
1308
+ }
1210
1309
  lastWrite.set(node[1], { parent: items, idx: i })
1211
1310
  }
1212
1311
 
@@ -1472,7 +1571,8 @@ export function specializeMkptr(funcs, addFunc, parseWat) {
1472
1571
  // Any target not listed here is left untouched. Order matters only for readability.
1473
1572
  const SPECS = {
1474
1573
  '$__mkptr': { params: ['i32', 'i32', 'i32'], result: 'f64', inline: true },
1475
- '$__alloc_hdr': { params: ['i32', 'i32', 'i32'], result: 'i32' },
1574
+ '$__alloc_hdr': { params: ['i32', 'i32'], result: 'i32' },
1575
+ '$__alloc_hdr_n': { params: ['i32', 'i32', 'i32'], result: 'i32' },
1476
1576
  '$__typed_idx': { params: ['i64', 'i32'], result: 'f64' },
1477
1577
  '$__str_idx': { params: ['i64', 'i32'], result: 'f64' },
1478
1578
  }
@@ -1782,6 +1882,7 @@ export function optimizeFunc(fn, cfg, globalTypes) {
1782
1882
  cfg.hoistInvariantCellLoads === false &&
1783
1883
  cfg.cseScalarLoad === false &&
1784
1884
  cfg.csePureExpr === false &&
1885
+ cfg.dropDeadZeroInit === false &&
1785
1886
  cfg.deadStoreElim === false &&
1786
1887
  cfg.promoteGlobals === false &&
1787
1888
  cfg.sortLocalsByUse === false &&
@@ -1795,12 +1896,17 @@ export function optimizeFunc(fn, cfg, globalTypes) {
1795
1896
  if (!cfg || cfg.hoistInvariantCellLoads !== false) hoistInvariantCellLoads(fn)
1796
1897
  if (!cfg || cfg.cseScalarLoad !== false) cseScalarLoad(fn)
1797
1898
  if (!cfg || cfg.csePureExpr !== false) csePureExpr(fn)
1899
+ if (!cfg || cfg.dropDeadZeroInit !== false) dropDeadZeroInit(fn)
1798
1900
  if (!cfg || cfg.deadStoreElim !== false) deadStoreElim(fn)
1799
1901
  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.
1902
+ // Vectorizer runs PRE-watr unless full watr is enabled (`watr: true`). For full watr,
1903
+ // defer to post full passes (notably `inlineOnce` + the post-inline `propagate`
1904
+ // sweep) reshape the IR so much that pre-watr SIMD patterns get scrambled. Light
1905
+ // watr (or no watr) leaves the lane locals intact for vectorize to pattern-match,
1906
+ // and lets a non-trivial chunk of SIMD survive the propagate+fold pipeline.
1802
1907
  if (cfg && cfg.vectorizeLaneLocal === true) {
1803
- const runVectorizer = cfg.__phase === 'post' || (cfg.__phase !== 'post' && cfg.watr === false)
1908
+ const fullWatr = cfg.watr === true
1909
+ const runVectorizer = (fullWatr && cfg.__phase === 'post') || (!fullWatr && cfg.__phase !== 'post')
1804
1910
  if (runVectorizer) vectorizeLaneLocal(fn)
1805
1911
  }
1806
1912
  if (!cfg || cfg.sortLocalsByUse !== false) sortLocalsByUse(fn, cfg && cfg.fusedRewrite !== false ? counts : null)
@@ -1925,6 +2031,30 @@ function walkRewrite(node, doInline, counts) {
1925
2031
  for (let i = 1; i < inner.length; i++) out.push(inner[i])
1926
2032
  return out
1927
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
+ }
1928
2058
  }
1929
2059
  if (Array.isArray(a) && a[0] === 'i64.or' && a.length === 3) {
1930
2060
  const l = a[1], r = a[2]
@@ -2081,6 +2211,36 @@ export function treeshake(funcSections, allModuleNodes, opts) {
2081
2211
  }
2082
2212
  }
2083
2213
  }
2214
+
2215
+ // Dead-global elimination: after dead funcs are gone, drop `(global $g …)` decls
2216
+ // that nothing references (a `global.get`/`global.set` in a remaining func, a kept
2217
+ // global's init expr, a data/elem offset, or an `(export … (global $g))`). Imported
2218
+ // globals live in `allModuleNodes`, not in `opts.globals`, so they're never touched.
2219
+ // Fixpoint: a kept global's init may reference another global.
2220
+ const globals = removeDead && opts && Array.isArray(opts.globals) ? opts.globals : null
2221
+ if (globals) {
2222
+ const collectGlobalRefs = (node, refd) => {
2223
+ if (!Array.isArray(node)) return
2224
+ if ((node[0] === 'global.get' || node[0] === 'global.set') && typeof node[1] === 'string') refd.add(node[1])
2225
+ else if (node[0] === 'export' && Array.isArray(node[2]) && node[2][0] === 'global' && typeof node[2][1] === 'string') refd.add(node[2][1])
2226
+ for (const c of node) collectGlobalRefs(c, refd)
2227
+ }
2228
+ let changed = true
2229
+ while (changed) {
2230
+ changed = false
2231
+ const refd = new Set()
2232
+ for (const { arr } of funcSections) for (const n of arr) collectGlobalRefs(n, refd)
2233
+ for (const n of allModuleNodes) collectGlobalRefs(n, refd)
2234
+ for (const g of globals) collectGlobalRefs(g, refd)
2235
+ for (let i = globals.length - 1; i >= 0; i--) {
2236
+ const g = globals[i]
2237
+ if (Array.isArray(g) && g[0] === 'global' && typeof g[1] === 'string' && !refd.has(g[1])) {
2238
+ globals.splice(i, 1); changed = true
2239
+ }
2240
+ }
2241
+ }
2242
+ }
2243
+
2084
2244
  return { removed, callCount }
2085
2245
  }
2086
2246
 
@@ -2146,7 +2306,7 @@ export function sortLocalsByUse(fn, precomputedCounts) {
2146
2306
  */
2147
2307
  export function arenaRewindModule(fns) {
2148
2308
  const BUILTIN_SAFE = new Set([
2149
- '$__alloc', '$__alloc_hdr', '$__mkptr',
2309
+ '$__alloc', '$__alloc_hdr', '$__alloc_hdr_n', '$__mkptr',
2150
2310
  '$__ptr_offset', '$__ptr_type', '$__ptr_aux',
2151
2311
  '$__len', '$__cap', '$__typed_shift', '$__typed_data',
2152
2312
  ])
@@ -2179,7 +2339,7 @@ export function arenaRewindModule(fns) {
2179
2339
  else if (op === 'call_ref') hasCallRef = true
2180
2340
  else if (op === 'call') {
2181
2341
  const callee = node[1]
2182
- if (callee === '$__alloc' || callee === '$__alloc_hdr') hasAlloc = true
2342
+ if (callee === '$__alloc' || callee === '$__alloc_hdr' || callee === '$__alloc_hdr_n') hasAlloc = true
2183
2343
  if (typeof callee === 'string' && !BUILTIN_SAFE.has(callee)) calls.add(callee)
2184
2344
  }
2185
2345
  for (let i = 1; i < node.length; i++) scan(node[i])
package/src/plan.js CHANGED
@@ -935,7 +935,12 @@ const inlineInStmt = (stmt, candidates) => {
935
935
  }
936
936
  if (op === '{}') {
937
937
  const r = inlineInStmt(stmt[1], candidates)
938
- return r.changed ? { node: ['{}', r.node], changed: true } : { node: stmt, changed: false }
938
+ if (!r.changed) return { node: stmt, changed: false }
939
+ // If the child was itself a candidate call (or a let/assign-of-call), it
940
+ // already returned a `['{}', [';', ...prefix]]` shape. Re-wrapping here
941
+ // would yield `['{}', ['{}', …]]`, which codegen rejects ("Unknown op: {}").
942
+ if (Array.isArray(r.node) && r.node[0] === '{}') return { node: r.node, changed: true }
943
+ return { node: ['{}', r.node], changed: true }
939
944
  }
940
945
  if (op === 'for') {
941
946
  const r = inlineInStmt(stmt[4], candidates)
@@ -1433,6 +1438,10 @@ export default function plan(ast) {
1433
1438
  unboxConstTypedGlobals()
1434
1439
 
1435
1440
  let programFacts = collectProgramFacts(ast)
1441
+ // The call-inlining family (`inlineHotInternalCalls` self-gates on `sourceInline`)
1442
+ // is a pure speed optimization — the un-inlined calls emit correctly. Scalar
1443
+ // replacement (`scalarize*`) is *not* gated on `sourceInline`: callers turn it on
1444
+ // independently via `optimize: { sourceInline: false }` to test heap elision alone.
1436
1445
  if (inlineHotInternalCalls(programFacts, ast)) programFacts = collectProgramFacts(ast)
1437
1446
  if (inlineLocalLambdas()) programFacts = collectProgramFacts(ast)
1438
1447
  if (specializeFixedRestCalls(programFacts)) programFacts = collectProgramFacts(ast)
package/src/prepare.js CHANGED
@@ -26,6 +26,7 @@ import { parse } from 'subscript/feature/jessie'
26
26
  import { ctx, err, derive } from './ctx.js'
27
27
  import { T, STMT_OPS, VAL, valTypeOf, typedElemCtor, extractParams, collectParamNames, classifyParam, observeNodeFacts, staticPropertyKey } from './analyze.js'
28
28
  import { isFuncRef } from './ir.js'
29
+ import { fuseSparseMapReads } from './fuse.js'
29
30
  import {
30
31
  CTORS, TIMER_NAMES,
31
32
  hasModule, includeModule,
@@ -184,7 +185,7 @@ export default function prepare(node) {
184
185
  funcLocalNames = [new Set()]
185
186
  includeModule('core')
186
187
  normalizeIdents(node)
187
- fuseSparseMapReads(node)
188
+ fuseSparseMapReads(node) // AST-level fusion; needs pre-resolution shape — see src/fuse.js
188
189
  const ast = prep(node)
189
190
  // Top-level functions referenced as first-class values (e.g. `let o = { fn: g }`,
190
191
  // `arr.push(g)`, `return g`) need trampoline emission, which depends on the fn
@@ -340,154 +341,6 @@ const cloneNode = (node) => {
340
341
  return copy
341
342
  }
342
343
 
343
- /** Sparse-read .map fusion: rewrite `const b = a.map(arrow); for(...; j<b.length; ...) USE(b[j])`
344
- * into a fused for-loop that inlines `arrow(a[j])` at the read site, eliminating the materialized
345
- * intermediate array. Pre-prep AST mutation; only fires on shapes where every use of `b` is a
346
- * numeric `b[idx]` read or a `b.length` read, the arrow is pure with a single named param, and
347
- * `b` is not referenced after the consumer for-loop. Preserves observable behavior because the
348
- * arrow's pure-expression body has no order-dependent effects. */
349
- function fuseSparseMapReads(root) {
350
- walkSparse(root)
351
- }
352
- function walkSparse(node) {
353
- if (!Array.isArray(node)) return
354
- for (let i = 1; i < node.length; i++) walkSparse(node[i])
355
- if (node[0] === ';') tryFuseInBlock(node)
356
- }
357
- function tryFuseInBlock(seq) {
358
- for (let i = 1; i < seq.length - 1; i++) {
359
- const fused = tryFusePair(seq[i], seq[i + 1], seq, i)
360
- if (fused) {
361
- seq.splice(i, 2, ...fused)
362
- i-- // re-examine same position (chained fusions)
363
- }
364
- }
365
- }
366
- function tryFusePair(decl, forNode, seq, declIdx) {
367
- if (!Array.isArray(decl) || (decl[0] !== 'const' && decl[0] !== 'let')) return null
368
- if (decl.length !== 2) return null // single binding only
369
- const bind = decl[1]
370
- if (!Array.isArray(bind) || bind[0] !== '=' || typeof bind[1] !== 'string') return null
371
- const NAME = bind[1], rhs = bind[2]
372
- if (!Array.isArray(rhs) || rhs[0] !== '()') return null
373
- const callee = rhs[1]
374
- if (!Array.isArray(callee) || callee[0] !== '.' || callee[2] !== 'map') return null
375
- const RECV = callee[1]
376
- if (typeof RECV !== 'string' || RECV === NAME) return null
377
- const arrow = rhs[2]
378
- if (!Array.isArray(arrow) || arrow[0] !== '=>') return null
379
- // Single-name param only: `x => …` or `(x) => …`
380
- const ap = arrow[1]
381
- const PARAM = typeof ap === 'string' ? ap :
382
- (Array.isArray(ap) && ap[0] === '()' && typeof ap[1] === 'string' ? ap[1] : null)
383
- if (!PARAM || PARAM === NAME || PARAM === RECV) return null
384
- // Body: single-expression arrow only (block bodies skipped — could extend later).
385
- const aBody = arrow[2]
386
- if (Array.isArray(aBody) && aBody[0] === '{}') return null
387
- if (!isPureSparseArrowBody(aBody, PARAM)) return null
388
- // For-loop: ['for', [';', initStmt, cond, inc], body]
389
- if (!Array.isArray(forNode) || forNode[0] !== 'for' || forNode.length !== 3) return null
390
- const head = forNode[1]
391
- if (!Array.isArray(head) || head[0] !== ';' || head.length !== 4) return null
392
- const cond = head[2], forBody = forNode[2]
393
- // Verify `NAME` is used only as `NAME[idx]` or `NAME.length` inside cond+forBody.
394
- if (!hasOnlySparseUses(cond, NAME)) return null
395
- if (!hasOnlySparseUses(forBody, NAME)) return null
396
- if (!hasAnyIndexedRead(forBody, NAME) && !hasAnyIndexedRead(cond, NAME)) return null
397
- // `NAME` must not be read after the for-loop in the same block.
398
- for (let k = declIdx + 2; k < seq.length; k++) {
399
- if (refsName(seq[k], NAME)) return null
400
- }
401
- // RECV must not be reassigned inside the for-loop (would invalidate substitution).
402
- if (assignsName(forNode, RECV) || assignsName(forNode, NAME)) return null
403
- // PARAM must not collide with any binding inside forBody (otherwise substitution shadows wrongly).
404
- if (bindsName(forNode, PARAM)) return null
405
- // Apply substitution: NAME.length → RECV.length; NAME[idx] → arrowBody[PARAM ← RECV[idx]].
406
- const newCond = substSparse(cond, NAME, RECV, PARAM, aBody)
407
- const newBody = substSparse(forBody, NAME, RECV, PARAM, aBody)
408
- const newHead = [';', head[1], newCond, head[3]]
409
- return [['for', newHead, newBody]]
410
- }
411
- function isPureSparseArrowBody(n, PARAM) {
412
- if (typeof n === 'string') return true
413
- if (!Array.isArray(n)) return true
414
- const op = n[0]
415
- // Calls / new / assignments / increments are unsafe for repeated-substitution semantics.
416
- if (op === '()' || op === '?.()' || op === 'new' || op === '++' || op === '--') return false
417
- if (op === '=>') return false // nested closure is opaque
418
- if (typeof op === 'string' && op !== '=>' && op !== '===' && op !== '!==' && op !== '==' && op !== '!=' && op !== '<=' && op !== '>=' && op.endsWith('=') && op !== '=') return false
419
- if (op === '=') return false
420
- for (let i = 1; i < n.length; i++) if (!isPureSparseArrowBody(n[i], PARAM)) return false
421
- return true
422
- }
423
- function hasOnlySparseUses(n, NAME) {
424
- if (typeof n === 'string') return n !== NAME
425
- if (!Array.isArray(n)) return true
426
- const op = n[0]
427
- if (op === '[]' && n.length === 3 && n[1] === NAME) return hasOnlySparseUses(n[2], NAME) // NAME[idx] — idx must not reference NAME
428
- if (op === '.' && n[1] === NAME) {
429
- if (n[2] === 'length') return true
430
- return false // any other property access on NAME is opaque
431
- }
432
- for (let i = 1; i < n.length; i++) if (!hasOnlySparseUses(n[i], NAME)) return false
433
- return true
434
- }
435
- function hasAnyIndexedRead(n, NAME) {
436
- if (!Array.isArray(n)) return false
437
- if (n[0] === '[]' && n.length === 3 && n[1] === NAME) return true
438
- for (let i = 1; i < n.length; i++) if (hasAnyIndexedRead(n[i], NAME)) return true
439
- return false
440
- }
441
- function refsName(n, NAME) {
442
- if (typeof n === 'string') return n === NAME
443
- if (!Array.isArray(n)) return false
444
- for (let i = 1; i < n.length; i++) if (refsName(n[i], NAME)) return true
445
- return false
446
- }
447
- function assignsName(n, NAME) {
448
- if (!Array.isArray(n)) return false
449
- const op = n[0]
450
- if ((op === '=' || op === '++' || op === '--' ||
451
- (typeof op === 'string' && op.endsWith('=') && op !== '==' && op !== '===' && op !== '!=' && op !== '!==' && op !== '<=' && op !== '>='))
452
- && n[1] === NAME) return true
453
- for (let i = 1; i < n.length; i++) if (assignsName(n[i], NAME)) return true
454
- return false
455
- }
456
- function bindsName(n, NAME) {
457
- if (!Array.isArray(n)) return false
458
- const op = n[0]
459
- if ((op === 'let' || op === 'const')) {
460
- for (let i = 1; i < n.length; i++) {
461
- const bind = n[i]
462
- if (Array.isArray(bind) && bind[0] === '=' && bind[1] === NAME) return true
463
- }
464
- }
465
- if (op === '=>') {
466
- const p = n[1]
467
- if (p === NAME) return true
468
- if (Array.isArray(p)) {
469
- if (p[0] === '()' && p[1] === NAME) return true
470
- // skip deeper destructuring forms — conservative
471
- }
472
- }
473
- for (let i = 1; i < n.length; i++) if (bindsName(n[i], NAME)) return true
474
- return false
475
- }
476
- function substSparse(n, NAME, RECV, PARAM, arrowBody) {
477
- if (typeof n !== 'object' || n === null || !Array.isArray(n)) return n
478
- if (n[0] === '.' && n[1] === NAME && n[2] === 'length') return ['.', RECV, 'length']
479
- if (n[0] === '[]' && n.length === 3 && n[1] === NAME) {
480
- const idx = substSparse(n[2], NAME, RECV, PARAM, arrowBody)
481
- return cloneAndBind(arrowBody, PARAM, ['[]', RECV, idx])
482
- }
483
- return n.map((c, i) => i === 0 ? c : substSparse(c, NAME, RECV, PARAM, arrowBody))
484
- }
485
- function cloneAndBind(node, PARAM, replacement) {
486
- if (node === PARAM) return replacement
487
- if (!Array.isArray(node)) return node
488
- return node.map((c, i) => i === 0 ? c : cloneAndBind(c, PARAM, replacement))
489
- }
490
-
491
344
  function prep(node) {
492
345
  if (Array.isArray(node)) includeForOp(node[0])
493
346
  if (Array.isArray(node) && node.loc != null) ctx.error.loc = node.loc