jz 0.8.0 → 0.8.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.
@@ -97,6 +97,14 @@ export const PASS_NAMES = [
97
97
  'hoistInvariantLoop', // unified LICM (subsumes the former ToInt32/PtrOffsetLoop/CellLoads hoists)
98
98
  'narrowLoopBound', // f64 loop bound → hoisted i32 (unblocks the lane-vectorizer)
99
99
  'splitCharScan', // charCodeAt scan loops: split at min(N, s.length) → i32 char carrier (plan-level)
100
+ // Pre-analyze loop-shape transforms — applied in compile/index.js (NOT this pass pipeline), but
101
+ // gated by these flags. Listing them here is load-bearing: ALL_OFF sets them false so level 0/1
102
+ // (the self-host fast path) actually skip them, instead of `undefined !== false` running them
103
+ // unconditionally at every level. ALL_ON keeps them on at level 2+ where they belong.
104
+ 'loopIVDivMod', // strength-reduce per-iter `i%w` / `(i/w)|0` → incremental i32 counters
105
+ 'loopSquare', // bounded `i*i < CONST` → Math.imul (i32 product chain)
106
+ 'unrollRecurrence', // unit-stride DP/scan: scalar-replace arr[j-1]/arr[j] recurrence + ×2 unroll
107
+ 'clampPeel', // edge-clamp stencil: split into clamp-free interior (vectorizes) + edges
100
108
  'hoistGlobalPtrOffset', // stable typed GLOBALS: __ptr_offset resolve → once per function (post-watr, module-level)
101
109
  'fusedRewrite', // peephole + ptr-helper inline + memarg fold
102
110
  'hoistAddrBase',
@@ -106,6 +114,7 @@ export const PASS_NAMES = [
106
114
  'unswitchTypedParamLoop', // Float64Array param loop-unswitch → base-hoisted f64.load/store fast path (vectorizes)
107
115
  'dropDeadZeroInit',
108
116
  'deadStoreElim',
117
+ 'propagateSingleUse', // forward-substitute single-def/single-use pure temps (watr's "propagate")
109
118
  'promoteGlobals', // read-only global.get → local for multi-read globals
110
119
  'sortLocalsByUse',
111
120
  'specializeMkptr',
@@ -157,10 +166,14 @@ const LEVEL_PRESETS = Object.freeze({
157
166
  ...ALL_ON,
158
167
  smallConstForUnroll: false, nestedSmallConstForUnroll: false, vectorizeLaneLocal: false, splitCharScan: false,
159
168
  recursionUnroll: false, // body tripling is a size regression — speed-only
169
+ unrollRecurrence: false, // ×2 body duplication is a size regression — speed-only
170
+ clampPeel: false, // edge-clamp peel triples a stencil loop (clamp-free interior + 2 edges) to vectorize — speed-only
160
171
 
161
172
  boolConvertToSelect: false, // adds a const + op per site — speed-only latency trade
162
173
  devirtIndirect: false, // guards + duplicated args grow bytes — speed-only trade
163
174
  internStrings: false, // the intern index costs ~16 B per eligible literal — speed-only trade
175
+ promoteGlobals: false, // snapshots a multi-read global into an entry local — pure speed (V8 can't CSE a mutable global); the snapshot is dead size weight at -Os
176
+ hoistInvariantLoop: false,// LICM: hoists loop-invariants to entry temps — a speed/latency trade whose entry cost outweighs the per-iter saving in bytes
164
177
  scalarTypedLoopUnroll: 4, scalarTypedNestedUnroll: 8, scalarTypedArrayLen: 8,
165
178
  }),
166
179
  // 'speed' === level 3: full watr (inlining on) + L3 cap/hash tuning, pool off.
@@ -589,6 +602,25 @@ const NON_MUTATING_CALLS = new Set(['$__is_str_key', '$__str_concat', '$__to_num
589
602
  // `for(j) { ... grid[i][j] ... }` inner loop (the jagged-array deopt).
590
603
  const READONLY_MEM_CALLS = new Set(['$__typed_idx', '$__str_idx'])
591
604
 
605
+ // PURE FUNCTION calls — result is a function of the ARGUMENTS alone, with no
606
+ // dependence on mutable state: math reads no memory; the string search/compare
607
+ // helpers read only their operands' bytes, and jz strings are IMMUTABLE, so their
608
+ // content can't change under the loop's stores. So on loop-invariant args the
609
+ // RESULT is loop-invariant regardless of anything else the loop does (unlike a
610
+ // READONLY_MEM_CALLS element read, which an aliasing store could invalidate — no
611
+ // !hasDirectStore guard is needed here). Speculative pre-header execution can't
612
+ // trap: math returns NaN/∞ rather than trapping, and a value reaching .indexOf/===
613
+ // is a valid string (in-bounds reads). This is the LICM V8's wasm tier won't do —
614
+ // it treats every call as opaque and recomputes the search/transcendental each
615
+ // iteration. (Math.random is INLINED — it mutates a global PRNG seed, never a
616
+ // `$math.` call — but exclude it by name defensively; $__str_eq_cold is the cold
617
+ // half of __str_eq, equally pure.) byteLen/length stay OUT: $__length is polymorphic
618
+ // over MUTABLE arrays (push changes it), so it isn't arg-pure.
619
+ const PURE_CALL_I32 = new Set(['$__str_indexof', '$__str_lastindexof', '$__str_eq', '$__str_eq_cold', '$__is_str_key'])
620
+ const isPureFnCall = (callee) =>
621
+ typeof callee === 'string' &&
622
+ ((callee.startsWith('$math.') && !callee.startsWith('$math.random')) || PURE_CALL_I32.has(callee))
623
+
592
624
  export function hoistInvariantPtrOffset(fn) {
593
625
  if (!Array.isArray(fn) || fn[0] !== 'func') return
594
626
  const bodyStart = findBodyStart(fn)
@@ -796,7 +828,7 @@ function loopInvariance(loopNode, { distinctParams, baseParamOf }) {
796
828
  if (op.startsWith('v128.') || /^[if]\d+x\d+\./.test(op)) hasV128 = true
797
829
  if (op === 'local.set' || op === 'local.tee') { if (typeof node[1] === 'string') locals.add(node[1]); for (let i = 2; i < node.length; i++) scan(node[i]); return }
798
830
  if (op === 'global.set') { if (typeof node[1] === 'string') globals.add(node[1]); for (let i = 2; i < node.length; i++) scan(node[i]); return }
799
- if (op === 'call') { hasAnyCall = true; if (!SAFE_OFFSET_CALLS.has(node[1]) && !READONLY_MEM_CALLS.has(node[1]) && !NON_MUTATING_CALLS.has(node[1])) hasUnsafeCall = true; for (let i = 2; i < node.length; i++) scan(node[i]); return }
831
+ if (op === 'call') { hasAnyCall = true; if (!SAFE_OFFSET_CALLS.has(node[1]) && !READONLY_MEM_CALLS.has(node[1]) && !NON_MUTATING_CALLS.has(node[1]) && !isPureFnCall(node[1])) hasUnsafeCall = true; for (let i = 2; i < node.length; i++) scan(node[i]); return }
800
832
  if (op === 'call_ref' || op === 'call_indirect') { hasAnyCall = hasUnsafeCall = true; for (let i = 1; i < node.length; i++) scan(node[i]); return }
801
833
  if ((op === 'f64.store' || op === 'i32.store') && node.length >= 3) {
802
834
  hasDirectStore = true
@@ -843,6 +875,13 @@ function loopInvariance(loopNode, { distinctParams, baseParamOf }) {
843
875
  return false
844
876
  }
845
877
  if (op === 'call') {
878
+ // Pure-function call: invariant iff its ARGS are. No effect-summary barrier —
879
+ // its result depends on nothing the loop can mutate (math: no memory; string
880
+ // search/compare: immutable operands), so neither a store nor another call
881
+ // can invalidate it. This hoists the loop-invariant transcendental / substr
882
+ // search V8's wasm tier recomputes every iteration.
883
+ if (isPureFnCall(node[1]))
884
+ return node.slice(2).every(c => pureGiven(c, bound))
846
885
  if (SAFE_OFFSET_CALLS.has(node[1]))
847
886
  return !hasUnsafeCall && node.slice(2).every(c => pureGiven(c, bound))
848
887
  // Read-only heap reads: additionally require no direct store (alias-safe).
@@ -1088,6 +1127,8 @@ export function hoistInvariantLoop(fn) {
1088
1127
  // SAFE_OFFSET_CALLS all return i32; READONLY_MEM_CALLS return f64 (NaN-boxed element)
1089
1128
  if (SAFE_OFFSET_CALLS.has(node[1])) return 'i32'
1090
1129
  if (READONLY_MEM_CALLS.has(node[1])) return 'f64'
1130
+ if (PURE_CALL_I32.has(node[1])) return 'i32' // string search/compare → i32
1131
+ if (typeof node[1] === 'string' && node[1].startsWith('$math.')) return 'f64' // transcendentals → f64
1091
1132
  return null
1092
1133
  }
1093
1134
  if (op === 'local.get' || op === 'local.tee') return localTypes.get(node[1]) ?? null
@@ -2127,6 +2168,121 @@ export function deadStoreElim(fn) {
2127
2168
  }
2128
2169
  }
2129
2170
 
2171
+ /**
2172
+ * Forward-substitute a single-def / single-use local into its sole use, eliminating the local,
2173
+ * its `local.set` and its `local.get`. This is watr's "propagate": jz emits short-lived address/
2174
+ * index temps (`set $t (i32.add …); … (load $t) …`) that the WAT optimizer folds away — the
2175
+ * dominant slice of the watr-optimizer-OFF size gap (a matmul carries ~14 such temps, ≈ the whole
2176
+ * delta). Closing it lets jz lean on its own optimizer instead of watr's.
2177
+ *
2178
+ * SOUND only when moving the def's RHS `E` to the use can't change order or value:
2179
+ * - `E` is PURE — reads only locals (no load/store/call/global/memory): its value is a function
2180
+ * of its read-locals alone, and it has no side effects to reorder past intervening statements.
2181
+ * - the use is at the SAME loop nesting — never under a deeper `loop` than the def (which would
2182
+ * re-evaluate `E` per iteration and could read a clobbered input).
2183
+ * - no read-local of `E` is written between the def and the use (incl. the use statement itself).
2184
+ * Anything it can't prove safe is left untouched.
2185
+ */
2186
+ export function propagateSingleUse(fn) {
2187
+ if (!Array.isArray(fn) || fn[0] !== 'func') return
2188
+ const bodyStart = findBodyStart(fn)
2189
+ if (bodyStart < 0) return
2190
+
2191
+ // Leave a vectorized function alone: it carries v128 lane sequences that are already register-tight,
2192
+ // and forward-substituting into them only adds pressure (mirrors hoistInvariantLoop's hasV128 gate).
2193
+ let hasV128 = false
2194
+ const scanV = (n) => { if (hasV128 || !Array.isArray(n)) return; const op = n[0]; if (typeof op === 'string' && (op.startsWith('v128.') || /^[if]\d+x\d+\./.test(op))) { hasV128 = true; return } for (let i = 1; i < n.length; i++) scanV(n[i]) }
2195
+ for (let i = bodyStart; i < fn.length && !hasV128; i++) scanV(fn[i])
2196
+ if (hasV128) return
2197
+
2198
+ // def/use tally over the whole body. A `local.tee` is both a read and a write — exclude any
2199
+ // tee'd local from candidacy rather than reason about it.
2200
+ const setN = new Map(), getN = new Map(), teed = new Set()
2201
+ const tally = (n) => {
2202
+ if (!Array.isArray(n)) return
2203
+ const op = n[0]
2204
+ if (op === 'local.set' && typeof n[1] === 'string') setN.set(n[1], (setN.get(n[1]) || 0) + 1)
2205
+ else if (op === 'local.tee' && typeof n[1] === 'string') teed.add(n[1])
2206
+ else if (op === 'local.get' && typeof n[1] === 'string') getN.set(n[1], (getN.get(n[1]) || 0) + 1)
2207
+ for (let i = 1; i < n.length; i++) tally(n[i])
2208
+ }
2209
+ for (let i = bodyStart; i < fn.length; i++) tally(fn[i])
2210
+
2211
+ const cand = new Set()
2212
+ for (const [name, c] of setN) if (c === 1 && getN.get(name) === 1 && !teed.has(name)) cand.add(name)
2213
+ if (!cand.size) return
2214
+
2215
+ const movablePure = (n) => {
2216
+ if (!Array.isArray(n)) return true
2217
+ const op = n[0]
2218
+ if (op === 'local.get') return true
2219
+ if (op === 'local.set' || op === 'local.tee' || op === 'call' || op === 'call_indirect' || op === 'call_ref'
2220
+ || op === 'global.get' || op === 'global.set' || op === 'memory.size' || op === 'memory.grow'
2221
+ || op === 'memory.copy' || op === 'memory.fill') return false
2222
+ if (typeof op === 'string' && (op.includes('.load') || op.includes('.store') || op.includes('.atomic'))) return false
2223
+ for (let i = 1; i < n.length; i++) if (!movablePure(n[i])) return false
2224
+ return true
2225
+ }
2226
+ const readsOf = (n, out) => { if (!Array.isArray(n)) return; if (n[0] === 'local.get' && typeof n[1] === 'string') out.add(n[1]); for (let i = 1; i < n.length; i++) readsOf(n[i], out) }
2227
+ const writesAny = (n, R) => { if (!Array.isArray(n)) return false; if ((n[0] === 'local.set' || n[0] === 'local.tee') && R.has(n[1])) return true; for (let i = 1; i < n.length; i++) if (writesAny(n[i], R)) return true; return false }
2228
+ // Locate the (local.get $t) within `root`'s subtree (not root itself); flag if it sits under a
2229
+ // `loop` relative to root (→ would re-evaluate the moved RHS each iteration).
2230
+ const locateUse = (root, t) => {
2231
+ let found = null
2232
+ const rec = (node, underLoop) => {
2233
+ if (found || !Array.isArray(node)) return
2234
+ for (let i = 1; i < node.length; i++) {
2235
+ if (found) return
2236
+ const c = node[i]
2237
+ if (Array.isArray(c) && c[0] === 'local.get' && c[1] === t) { found = { parent: node, idx: i, underLoop }; return }
2238
+ rec(c, underLoop || node[0] === 'loop')
2239
+ }
2240
+ }
2241
+ rec(root, false)
2242
+ return found
2243
+ }
2244
+
2245
+ const removed = new Set()
2246
+ const optimizeList = (list, start) => {
2247
+ for (let i = start; i < list.length; i++) {
2248
+ const s = list[i]
2249
+ if (!Array.isArray(s)) continue
2250
+ // s.length === 3: an explicit-RHS `(local.set $t E)`. A bare `(local.set $t)` (length 2)
2251
+ // binds a value already on the stack — e.g. the try_table catch payload — and has no RHS to
2252
+ // move; treating its undefined RHS as movable would substitute `undefined` into the use.
2253
+ if (s[0] === 'local.set' && s.length === 3 && typeof s[1] === 'string' && cand.has(s[1]) && movablePure(s[2])) {
2254
+ const t = s[1], E = s[2], R = new Set(); readsOf(E, R)
2255
+ for (let j = i + 1; j < list.length; j++) {
2256
+ const sj = list[j]
2257
+ const u = Array.isArray(sj) ? locateUse(sj, t) : null
2258
+ if (u) { // found the sole use's statement
2259
+ if (!u.underLoop && !writesAny(sj, R)) { // same nesting + read-locals intact
2260
+ u.parent[u.idx] = E
2261
+ list.splice(i, 1)
2262
+ cand.delete(t); removed.add(t)
2263
+ i-- // re-process from the freed slot (forward chains)
2264
+ }
2265
+ break // use located — stop scanning this candidate
2266
+ }
2267
+ if (writesAny(sj, R)) break // a read-local clobbered before the use → can't move
2268
+ }
2269
+ if (removed.has(s[1])) continue
2270
+ }
2271
+ // recurse into nested statement lists
2272
+ if (s[0] === 'block' || s[0] === 'loop') {
2273
+ let k = 1; while (k < s.length && Array.isArray(s[k]) && s[k][0] === 'result') k++
2274
+ optimizeList(s, k)
2275
+ } else if (s[0] === 'if') {
2276
+ for (let k = 1; k < s.length; k++) { const c = s[k]; if (Array.isArray(c) && (c[0] === 'then' || c[0] === 'else')) optimizeList(c, 1) }
2277
+ }
2278
+ }
2279
+ }
2280
+ optimizeList(fn, bodyStart)
2281
+
2282
+ // drop the now-orphaned decls (deferred so the body walk above sees stable indices)
2283
+ if (removed.size) for (let i = fn.length - 1; i >= 2; i--) { const c = fn[i]; if (Array.isArray(c) && c[0] === 'local' && removed.has(c[1])) fn.splice(i, 1) }
2284
+ }
2285
+
2130
2286
  /**
2131
2287
  * Module-wide scan for "volatile" globals — those mutated (`global.set`) in any
2132
2288
  * function other than `$__start`. Globals written only in `$__start` are
@@ -3216,6 +3372,7 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
3216
3372
  cfg.csePureExpr === false &&
3217
3373
  cfg.dropDeadZeroInit === false &&
3218
3374
  cfg.deadStoreElim === false &&
3375
+ cfg.propagateSingleUse === false &&
3219
3376
  cfg.promoteGlobals === false &&
3220
3377
  cfg.sortLocalsByUse === false &&
3221
3378
  cfg.vectorizeLaneLocal === false) return
@@ -3277,6 +3434,14 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
3277
3434
  // iteration, the hot-loop waste audit-fixpoint.mjs flagged on dot/sum.
3278
3435
  if (runVectorizer) foldV128Memargs(fn)
3279
3436
  }
3437
+ // Forward-substitute single-use temps — AFTER the vectorizer, never before: it pattern-matches a
3438
+ // STRAIGHT-LINE `s += a[i]*2`, and folding an address/index temp out scrambles it (the typed-array
3439
+ // loop fell from a SIMD body to a scalar unroll, +231 B). For watr:false the whole pipeline is the
3440
+ // 'pre' phase (no 'post' re-run), so vectorize already ran above; for full watr the vectorizer is
3441
+ // deferred to 'post', so skip 'pre' here to stay after it. (propagateSingleUse itself skips any
3442
+ // function the vectorizer already lifted to v128.)
3443
+ const fullWatr_psu = !!(cfg && (cfg.watr === true || typeof cfg.watr === 'object'))
3444
+ if ((phase === 'post' || !fullWatr_psu) && (!cfg || cfg.propagateSingleUse !== false)) propagateSingleUse(fn)
3280
3445
  // SSA-split loop-private unrolled scratch (post-vectorize: vectorized loops now carry
3281
3446
  // v128 and are skipped) so the LICM below hoists the per-iteration invariants the
3282
3447
  // unroller's name-merging hid — rust/LLVM's free-after-unroll register hoist (closes
@@ -4061,6 +4061,16 @@ function tryDivergentEscapeVectorize(blockNode, fnLocals, freshIdRef) {
4061
4061
  const kTop = kindOf(keepTopC)
4062
4062
  const limitKeep = keepTopC // initialised here; may be reassigned below for single-break
4063
4063
  let kMid = null, boundExpr, boundI32, itLeft
4064
+ // Compound-top guard `while (A && B)`: classify the SECOND keep too. Left unclassified (kMid=null),
4065
+ // the downstream lift/keepMask treats keepMidC as a per-lane f64 escape — right for the Julia order
4066
+ // (limit, escape) but it then lifts the i32 LIMIT of the mandelbrot order (escape, `it<MAX`) into an
4067
+ // f64 lane and crashes. Setting kMid routes the limit through the scalar guard instead; for the
4068
+ // Julia order kMid='escape' is identical to the old null (both lift). Bail if neither keep is an
4069
+ // escape — a two-limit guard has no per-lane divergence for this vectorizer to exploit.
4070
+ if (compoundTop) {
4071
+ kMid = kindOf(keepMidC)
4072
+ if (kTop === 'limit' && kMid === 'limit') return null
4073
+ }
4064
4074
  if (midBreaks.length === 1) {
4065
4075
  kMid = kindOf(keepMidC)
4066
4076
  if (kTop === kMid) return null
@@ -196,6 +196,19 @@ const stripBoolNot = c => {
196
196
  while (Array.isArray(c) && c[0] === '!' && Array.isArray(c[1]) && c[1][0] === '!') c = c[1][1]
197
197
  return c
198
198
  }
199
+ // In a statement (value-discarded) position, postfix `x++`/`x--` is lowered to `(++x) − 1` /
200
+ // `(--x) + 1` to recover the old value — but nobody reads it, so drop the ∓1 and keep the bare
201
+ // increment. (`obj.p++` lowers via `obj.p = obj.p + 1`, also wrapped.) Cleaner AST for the loop/
202
+ // recurrence passes; codegen already discarded the ∓1, so this is purely canonicalization.
203
+ const isOne = n => Array.isArray(n) && n[0] == null && n[1] === 1
204
+ const dropDeadPostfix = s => {
205
+ if (Array.isArray(s) && s.length === 3 && isOne(s[2]) && Array.isArray(s[1])) {
206
+ const inner = s[1][0]
207
+ if ((s[0] === '-' && (inner === '++' || inner === '=')) ||
208
+ (s[0] === '+' && (inner === '--' || inner === '='))) return s[1]
209
+ }
210
+ return s
211
+ }
199
212
  const stringValue = node => Array.isArray(node) && node[0] == null && typeof node[1] === 'string' ? node[1] : null
200
213
  const MUTATING_ARRAY_METHODS = new Set(['copyWithin', 'fill', 'pop', 'push', 'reverse', 'shift', 'sort', 'splice', 'unshift'])
201
214
 
@@ -1636,20 +1649,20 @@ const handlers = {
1636
1649
  '!=='(a, b) { return prepStrictEq('!==', a, b) },
1637
1650
 
1638
1651
  // Statements
1639
- ';': (...stmts) => [';', ...stmts.map(prep).filter(x => x != null)],
1652
+ ';': (...stmts) => [';', ...stmts.map(prep).filter(x => x != null).map(dropDeadPostfix)],
1640
1653
  'let': (...inits) => prepDecl('let', ...inits),
1641
1654
  'const': (...inits) => prepDecl('const', ...inits),
1642
1655
 
1643
1656
  // Block-scoped control flow: push scope for bodies so inner let/const shadows correctly
1644
1657
  'if': (cond, then, els) => {
1645
1658
  const c = prep(stripBoolNot(cond))
1646
- pushScope(); const t = prep(then); popScope()
1647
- if (els != null) { pushScope(); const e = prep(els); popScope(); return ['if', c, t, e] }
1659
+ pushScope(); const t = dropDeadPostfix(prep(then)); popScope()
1660
+ if (els != null) { pushScope(); const e = dropDeadPostfix(prep(els)); popScope(); return ['if', c, t, e] }
1648
1661
  return ['if', c, t]
1649
1662
  },
1650
1663
  'while': (cond, body) => {
1651
1664
  const c = prep(stripBoolNot(cond))
1652
- pushScope(); const b = prep(body); popScope()
1665
+ pushScope(); const b = dropDeadPostfix(prep(body)); popScope()
1653
1666
  return ['while', c, b]
1654
1667
  },
1655
1668
  // do { body } while (cond) → flag-guarded while: `flag=true; while (flag||cond) { flag=false; body }`.
@@ -2127,7 +2140,7 @@ const handlers = {
2127
2140
  }
2128
2141
  }
2129
2142
  }
2130
- r = ['for', init ? prep(init) : null, cond ? prep(cond) : null, step ? prep(step) : null, prep(body)]
2143
+ r = ['for', init ? prep(init) : null, cond ? prep(cond) : null, step ? dropDeadPostfix(prep(step)) : null, dropDeadPostfix(prep(body))]
2131
2144
  } else if (Array.isArray(head) && head[0] === 'of') {
2132
2145
  // for (let x of arr) → hoist arr (if non-trivial) and arr.length once, iterate by index.
2133
2146
  // Divergence from JS: mutating arr during iteration won't extend/shorten the loop.