jz 0.7.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.
Files changed (55) hide show
  1. package/README.md +37 -33
  2. package/bench/README.md +176 -73
  3. package/bench/bench.svg +58 -71
  4. package/cli.js +12 -5
  5. package/dist/interop.js +1 -1
  6. package/dist/jz.js +1366 -1101
  7. package/index.js +40 -9
  8. package/interop.js +193 -138
  9. package/layout.js +29 -18
  10. package/module/array.js +49 -73
  11. package/module/collection.js +83 -25
  12. package/module/console.js +1 -1
  13. package/module/core.js +161 -15
  14. package/module/json.js +3 -3
  15. package/module/math.js +167 -117
  16. package/module/number.js +247 -13
  17. package/module/object.js +11 -5
  18. package/module/regex.js +8 -7
  19. package/module/string.js +295 -171
  20. package/module/typedarray.js +169 -105
  21. package/package.json +7 -3
  22. package/src/abi/string.js +40 -35
  23. package/src/ast.js +19 -2
  24. package/src/compile/analyze.js +64 -2
  25. package/src/compile/cse-load.js +200 -0
  26. package/src/compile/emit-assign.js +73 -14
  27. package/src/compile/emit.js +324 -34
  28. package/src/compile/index.js +204 -61
  29. package/src/compile/infer.js +8 -1
  30. package/src/compile/loop-divmod.js +12 -58
  31. package/src/compile/loop-model.js +91 -0
  32. package/src/compile/loop-recurrence.js +167 -0
  33. package/src/compile/loop-square.js +102 -0
  34. package/src/compile/narrow.js +180 -34
  35. package/src/compile/peel-stencil.js +18 -64
  36. package/src/compile/plan/common.js +29 -0
  37. package/src/compile/plan/index.js +4 -1
  38. package/src/compile/plan/inline.js +176 -21
  39. package/src/compile/plan/literals.js +93 -19
  40. package/src/ctx.js +51 -12
  41. package/src/helper-counters.js +137 -0
  42. package/src/ir.js +102 -13
  43. package/src/kind-traits.js +7 -3
  44. package/src/kind.js +14 -1
  45. package/src/op-policy.js +5 -2
  46. package/src/ops.js +119 -0
  47. package/src/optimize/index.js +1125 -136
  48. package/src/optimize/recurse.js +182 -0
  49. package/src/optimize/vectorize.js +1302 -144
  50. package/src/prepare/index.js +29 -12
  51. package/src/reps.js +4 -1
  52. package/src/type.js +53 -45
  53. package/src/wat/assemble.js +92 -9
  54. package/src/widen.js +21 -0
  55. package/src/wat/optimize.js +0 -3938
@@ -26,6 +26,8 @@ import {
26
26
  hasOwnContinue, hasLabeledContinueTo, hasOwnBreakOrContinue, extractParams, classifyParam, JZ_UNDEF, TYPEOF,
27
27
  } from '../ast.js'
28
28
  import { ctx, err, inc, warnDeopt, PTR } from '../ctx.js'
29
+ import { includeForStringOnly } from '../autoload.js'
30
+ import { FITS_I32_MAX } from '../widen.js'
29
31
  import { nonNegIntLiteral, intLiteralValue, staticPropertyKey } from '../static.js'
30
32
  import { findFreeVars } from './analyze.js'
31
33
  import {
@@ -76,6 +78,29 @@ const stringOps = (node) => {
76
78
  // instead route through ToNumber (`toNumF64`), which performs ToPrimitive.
77
79
  const isI32Num = (v) => v.type === 'i32' && v.ptrKind == null
78
80
 
81
+ // Peel an emitted operand back to its raw i32 value when it carries one: a value already
82
+ // typed i32 (integer literals included — they emit as i32.const), or an integer read wrapped
83
+ // in f64.convert_i32_s/u (typed-array / i32-global reads default to the f64 rep). Else null.
84
+ const peelI32 = (v) =>
85
+ isI32Num(v) ? v
86
+ : (Array.isArray(v) && (v[0] === 'f64.convert_i32_s' || v[0] === 'f64.convert_i32_u'))
87
+ ? (Array.isArray(v[1]) ? typed(v[1], 'i32') : v[1])
88
+ : null
89
+
90
+ // Native wrapping i32 arithmetic for `+`/`-`/`*` whose result is consumed as i32. Peels the
91
+ // f64.convert_i32_s/u that integer reads (`DX[i]`, a global Int32Array) wrap their load in, so
92
+ // `ax = ax + DX[i]` (ax and DX[i] both i32) lowers to one i32.add instead of the
93
+ // convert → f64.add → trunc_sat round-trip that doubled hot integer loops (ulam's spiral walk,
94
+ // ring-buffer indexing). Bit-identical for an i32 result: ToInt32(exact) ≡ two's-complement wrap.
95
+ // Gated on exprType(whole expr)==='i32' so an f64-consumed sum — or an unsigned-wide (uint32)
96
+ // operand, which exprType already reports as f64 — still widens. Returns null when inapplicable.
97
+ const tryI32Arith = (wasmOp, astOp, a, b, va, vb) => {
98
+ const pa = peelI32(va); if (pa == null) return null
99
+ const pb = peelI32(vb); if (pb == null) return null
100
+ if (exprType([astOp, a, b], ctx.func.locals) !== 'i32') return null
101
+ return typed([wasmOp, pa, pb], 'i32')
102
+ }
103
+
79
104
  // f64 arithmetic that can MINT a sign-nondeterministic NaN (0/0, ∞−∞, 0·∞, x%0): on x86
80
105
  // these are 0xFFF8…, on arm 0x7FF8…. sqrt/min/max/neg are NOT here — they canon at their
81
106
  // own emit (math.js / unary `-`), so they reach canonNum already canonical.
@@ -136,7 +161,27 @@ const widensUnsigned = (v) => v.unsigned && !v.wrapSafe
136
161
  // so the inner per-op canon (local.set + select + f64.ne, ~3 ops) is dead on the
137
162
  // critical path. This is THE gap that put sqrt-heavy kernels ~23% behind V8
138
163
  // (julia/raymarcher/boids); stripping it makes them match native JS.
139
- const stripCanon = (v) => (v && v.canonOf != null) ? typed(v.canonOf, 'f64') : v
164
+ const stripCanon = (v) => {
165
+ if (!v) return v
166
+ if (v.canonOf != null) return typed(v.canonOf, 'f64')
167
+ // A NaN-canon nested in the VALUE arm of a `select` / `(if result f64)` is equally
168
+ // dead: the consumer that called stripCanon (f64.add/sub/mul/div, or a math call)
169
+ // propagates the NaN identically and the outermost escape re-canon-izes. Recurse into
170
+ // the arms so `(cond ? x : -x) + v` (the Perlin-gradient sign-select, and every other
171
+ // conditional negation) drops the per-neg select+f64.ne, same as a bare `x + -y`.
172
+ if (Array.isArray(v)) {
173
+ if (v[0] === 'select' && v.length === 4) {
174
+ const a = stripCanon(v[1]), b = stripCanon(v[2])
175
+ if (a !== v[1] || b !== v[2]) return typed(['select', a, b, v[3]], 'f64')
176
+ } else if (v[0] === 'if' && Array.isArray(v[1]) && v[1][0] === 'result' && v[1][1] === 'f64'
177
+ && Array.isArray(v[3]) && v[3][0] === 'then' && v[3].length === 2
178
+ && Array.isArray(v[4]) && v[4][0] === 'else' && v[4].length === 2) {
179
+ const t = stripCanon(v[3][1]), e = stripCanon(v[4][1])
180
+ if (t !== v[3][1] || e !== v[4][1]) return typed(['if', v[1], v[2], ['then', t], ['else', e]], 'f64')
181
+ }
182
+ }
183
+ return v
184
+ }
140
185
 
141
186
  const FIRST_CLASS_UNARY_MATH = {
142
187
  'math.abs': 'f64.abs',
@@ -177,10 +222,17 @@ const emitNeg = (a) => {
177
222
  // flipped NaN reads as a tagged value (truthy / not-NaN). Fold any NaN result back
178
223
  // to canonical — the same invariant math.sqrt/min/max keep via `canon` (module/math.js).
179
224
  const t = temp('ng')
180
- return typed(['block', ['result', 'f64'],
181
- ['local.set', `$${t}`, ['f64.neg', toNumF64(a, v)]],
225
+ const raw = ['f64.neg', toNumF64(a, v)]
226
+ const ir = typed(['block', ['result', 'f64'],
227
+ ['local.set', `$${t}`, raw],
182
228
  ['select', ['f64.const', 'nan'], ['local.get', `$${t}`],
183
229
  ['f64.ne', ['local.get', `$${t}`], ['local.get', `$${t}`]]]], 'f64')
230
+ // Tag the un-canon'd `f64.neg` so a NaN-propagating consumer (f64.add/sub/mul/div, which
231
+ // canon-ize on their OWN escape) strips this redundant inner canon — same contract as the
232
+ // sqrt/min/max canons in math.js. A bare `x * -y` / `a - -b` then drops the per-neg
233
+ // select + f64.ne instead of carrying it into the multiply/add.
234
+ ir.canonOf = raw
235
+ return ir
184
236
  }
185
237
 
186
238
  /** Try constant-folding binary arith: returns emitNum(result) or null. */
@@ -194,20 +246,38 @@ const foldConst = (va, vb, fn, guard) =>
194
246
 
195
247
  // JS `*` is an f64 multiply; `i32.mul` yields only the exact product mod 2^32.
196
248
  // Those agree under a ToInt32/ToUint32 sink (and as plain numbers) while the
197
- // exact product stays f64-exact, i.e. |product| <= 2^53. Two i32 operands can
198
- // reach 2^62, so `i32.mul` is sound only when one side is bounded small enough
199
- // that, against the full i32 range (2^31) of the other, the product holds within
200
- // 2^53 i.e. its magnitude <= 2^22. A literal qualifies directly; so does a
201
- // masked operand (`x & 63`, `x >>> k`) whose value is provably bounded. Keeps
202
- // index arithmetic (`i*4`) and bitwise-masked scales (bytebeat's `t*(m&63)`) on
203
- // `i32.mul` while routing hash-mix-scale products to `f64.mul`.
204
- const FITS_I32_MAX = 0x400000 // 2^22 — see derivation above
249
+ // exact product stays f64-exact. A literal qualifies directly; so does a masked
250
+ // operand (`x & 63`, `x >>> k`) whose value is provably bounded. Keeps index
251
+ // arithmetic (`i*4`) and bitwise-masked scales (bytebeat's `t*(m&63)`) on
252
+ // `i32.mul` while routing hash-mix-scale products to `f64.mul`. The FITS_I32_MAX
253
+ // threshold (and the soundness contract with type.js exprType) lives in widen.js.
205
254
  const mulFitsI32 = (va, vb) =>
206
255
  (isLit(va) && Math.abs(litVal(va)) <= FITS_I32_MAX) ||
207
256
  (isLit(vb) && Math.abs(litVal(vb)) <= FITS_I32_MAX) ||
208
257
  (!isLit(va) && maskBound(va) <= FITS_I32_MAX) ||
209
258
  (!isLit(vb) && maskBound(vb) <= FITS_I32_MAX)
210
259
 
260
+ // Max |value| of an i32-typed operand from a narrowing typed-array load width — the
261
+ // element-read twin of maskBound's `x & 0xff` case (load8_u and `x & 0xff` carry the
262
+ // SAME [0,255] range). Infinity when the magnitude is unbounded. Signed loads reach
263
+ // −2^(w−1), so the magnitude bound is 2^(w−1).
264
+ const I32_LOAD_MAG = { 'i32.load8_s': 128, 'i32.load8_u': 255, 'i32.load16_s': 32768, 'i32.load16_u': 65535 }
265
+ const i32Mag = (v) =>
266
+ !Array.isArray(v) ? Infinity :
267
+ v[0] in I32_LOAD_MAG ? I32_LOAD_MAG[v[0]] :
268
+ (v[0] === 'i32.const' && typeof v[1] === 'number') ? Math.abs(v[1]) :
269
+ (v[0] === 'i32.and' || v[0] === 'i32.shr_u') ? maskBound(v) :
270
+ Infinity
271
+ // `int8[i]*int8[j]` and friends: a product of two range-bounded integer typed-array
272
+ // elements whose magnitudes multiply to ≤ 2^31−1 is FAITHFUL as i32.mul — the exact
273
+ // product fits signed i32, so i32.mul == the true value in EVERY consumer context
274
+ // (i32 sink AND f64 value), independent of the widen pass. Covers i8/u8/i16 pairs and
275
+ // i16×u16 (32768·65535 < 2^31); correctly EXCLUDES u16×u16 (65535² > 2^31). JS `*` of
276
+ // two such reads — the int-conv / correlation / quantised-MAC kernel shape — then rides
277
+ // the i32 ABI (one op, no f64 round-trip) on V8 / JSC / wasmtime alike, and the i32
278
+ // product is lane-vectorizable where the f64 form was not.
279
+ const mulBoundedFaithful = (va, vb) => i32Mag(va) * i32Mag(vb) <= 0x7fffffff
280
+
211
281
  /** Emit typeof comparison: typeof x == typeCode → type-aware check. */
212
282
  export function emitTypeofCmp(a, b, cmpOp) {
213
283
  let typeofExpr, code
@@ -372,6 +442,27 @@ function tryI32Index(e) {
372
442
  }
373
443
  export const emitIndex = (idx) => tryI32Index(idx) ?? asI32(emit(idx))
374
444
 
445
+ /**
446
+ * True when `e` is a pure integer `+`/`-`/`*` tree whose leaves are all i32-typed
447
+ * names/globals or integer literals — no calls, member reads, or indexed reads, so
448
+ * emitting it twice (or in a different rep) is side-effect-free. Used to recognise
449
+ * an i32-local initializer that `tryI32Index` can lower to native wrapping i32
450
+ * arithmetic instead of the f64 round-trip (`convert … f64.mul/add … trunc_sat`).
451
+ * The same residue-mod-2^32 argument as `tryI32Index`: ToInt32 of the exact integer
452
+ * value equals two's-complement wrapping i32, so for an i32 destination the two are
453
+ * bit-identical — even when an intermediate product overflows.
454
+ */
455
+ function isI32ArithTree(e) {
456
+ if (typeof e === 'number') return Number.isInteger(e)
457
+ if (typeof e === 'string') return exprType(e, ctx.func.locals) === 'i32'
458
+ if (!Array.isArray(e)) return false
459
+ const op = e[0]
460
+ if (op == null) return isI32ArithTree(e[1]) // literal wrapper [, v]
461
+ if ((op === '+' || op === '-' || op === '*') && e[2] != null)
462
+ return isI32ArithTree(e[1]) && isI32ArithTree(e[2])
463
+ return false
464
+ }
465
+
375
466
  function emitSingleCharIndexCmp(a, b, negate = false) {
376
467
  const leftLit = stringLiteral(a)
377
468
  const rightLit = stringLiteral(b)
@@ -1080,6 +1171,12 @@ export function emitDecl(...inits) {
1080
1171
  updateRep(name, { ptrAux: val.closureFuncIdx })
1081
1172
  coerced = val.ptrKind === ptrKind ? val
1082
1173
  : typed(['i32.wrap_i64', ['i64.reinterpret_f64', asF64(val)]], 'i32')
1174
+ } else if (localType === 'i32' && val.type !== 'i32' && isI32ArithTree(init)) {
1175
+ // Integer index feeder (`let idx = py*W + qx`) bound to an i32 local: compute
1176
+ // it in native wrapping i32 instead of the f64 round-trip + trunc_sat. Bit-
1177
+ // identical for an i32 destination (ToInt32 ≡ two's-complement wrap), and the
1178
+ // i32.mul is hoistable when loop-invariant. Falls back to toI32 defensively.
1179
+ coerced = tryI32Index(init) ?? toI32(val)
1083
1180
  } else {
1084
1181
  coerced = localType === 'v128' ? val : localType === 'f64' ? asF64(val) : val.type === 'i32' ? val : toI32(val)
1085
1182
  }
@@ -1136,12 +1233,22 @@ function emitSpreadCopy(dest, posLocal, srcLocal, srcLenLocal, staticVT) {
1136
1233
  const sidx = `${T}sidx${ctx.func.uniq++}`
1137
1234
  ctx.func.locals.set(sidx, 'i32')
1138
1235
  const loopId = ctx.func.uniq++
1139
- const elem = ctx.module.modules['string']
1140
- ? ['if', ['result', 'f64'],
1236
+ // When the source is statically known to be a typed array, __typed_idx suffices.
1237
+ // Otherwise (STRING, or unknown type whose runtime value may be a string) dispatch on
1238
+ // ptr_type: STRING→__str_idx, else→__typed_idx.
1239
+ // The old gate (ctx.module.modules['string']) was wrong: for `[...s]` with an untyped
1240
+ // param the string module is never loaded, so __typed_idx was used for strings —
1241
+ // __typed_idx calls __len which returns 0 for strings, making i>=len always true and
1242
+ // storing UNDEF into every element slot. Pull in the string module here so __str_idx
1243
+ // is registered before inc() adds it to the dependency set.
1244
+ const elem = staticVT === VAL.TYPED
1245
+ ? (inc('__typed_idx'), ['call', '$__typed_idx', srcI64(), ['local.get', `$${sidx}`]])
1246
+ : (includeForStringOnly(),
1247
+ ['if', ['result', 'f64'],
1141
1248
  ['i32.eq', ['call', '$__ptr_type', srcI64()], ['i32.const', PTR.STRING]],
1142
1249
  ['then', (inc('__str_idx'), ['call', '$__str_idx', srcI64(), ['local.get', `$${sidx}`]])],
1143
- ['else', (inc('__typed_idx'), ['call', '$__typed_idx', srcI64(), ['local.get', `$${sidx}`]])]]
1144
- : (inc('__typed_idx'), ['call', '$__typed_idx', srcI64(), ['local.get', `$${sidx}`]])
1250
+ ['else', (inc('__typed_idx'), ['call', '$__typed_idx', srcI64(), ['local.get', `$${sidx}`]])]
1251
+ ])
1145
1252
  // Reset the counter on each entry — WASM zeroes locals once at function
1146
1253
  // entry, but this loop re-executes when the spread sits inside a JS loop;
1147
1254
  // a stale `sidx` (= prior srcLen) would skip the copy entirely.
@@ -1243,8 +1350,25 @@ export function buildArrayWithSpreads(items) {
1243
1350
  // emitSpreadCopy resolve its kind at runtime via its one-time __ptr_type branch.
1244
1351
  sec.val = n ? undefined : valTypeOf(srcExpr)
1245
1352
  ir.push(['local.set', `$${sec.local}`, n ? materializeMulti(sec.expr) : asF64(emit(srcExpr))])
1246
- // Cache __len once per spread; reused below for total-len sum and the copy.
1247
- ir.push(['local.set', `$${sec.lenLocal}`, ['call', '$__len', ['i64.reinterpret_f64', ['local.get', `$${sec.local}`]]]])
1353
+ // Cache the source length once per spread (reused for the total-len sum and the
1354
+ // copy). `__len` is ARRAY/typed length — WRONG for a STRING (returns 0, so `[...str]`
1355
+ // spreads an empty array). Pick the length to MATCH emitSpreadCopy's element decode:
1356
+ // a known string counts chars (__str_len, paired with the __str_idx per-char copy); a
1357
+ // statically-unknown source — `[...x]` / `[...fnParam]`, the compiler's own
1358
+ // `[...key]` — dispatches once at runtime (STRING→__str_len, else→__len), mirroring
1359
+ // emitSpreadCopy's ARRAY-vs-scalar branch. (Not __length: its `off>=8` guard returns
1360
+ // undefined for host/static typed arrays.) Known array/typed/multi keep plain __len.
1361
+ const srcI64 = () => ['i64.reinterpret_f64', ['local.get', `$${sec.local}`]]
1362
+ const lenIR = sec.val === VAL.STRING
1363
+ ? (inc('__str_len'), ['call', '$__str_len', srcI64()])
1364
+ : (sec.val === VAL.ARRAY || sec.val === VAL.TYPED || n)
1365
+ ? (inc('__len'), ['call', '$__len', srcI64()])
1366
+ : (inc('__str_len', '__len', '__ptr_type'),
1367
+ ['if', ['result', 'i32'],
1368
+ ['i32.eq', ['call', '$__ptr_type', srcI64()], ['i32.const', PTR.STRING]],
1369
+ ['then', ['call', '$__str_len', srcI64()]],
1370
+ ['else', ['call', '$__len', srcI64()]]])
1371
+ ir.push(['local.set', `$${sec.lenLocal}`, lenIR])
1248
1372
  }
1249
1373
  }
1250
1374
 
@@ -1387,6 +1511,83 @@ const STRICT_PRIM = new Set([VAL.NUMBER, VAL.BOOL, VAL.STRING, VAL.BIGINT])
1387
1511
  const nullableOperand = (n) =>
1388
1512
  typeof n === 'string' && !!(repOf(n)?.nullable || repOfGlobal(n)?.nullable)
1389
1513
 
1514
+ // An emitted value whose bit pattern is an i32, paired with how it widens to f64: a
1515
+ // `f64.convert_i32_s/u(x)` peels to its i32 source `x`; a bare i32 widens signed. Used to compare
1516
+ // two integer-backed operands directly in i32 instead of widening both to f64.
1517
+ const peelIntCmp = (v) => {
1518
+ if (Array.isArray(v) && (v[0] === 'f64.convert_i32_s' || v[0] === 'f64.convert_i32_u'))
1519
+ return { src: Array.isArray(v[1]) ? typed(v[1], 'i32') : v[1], sign: v[0] === 'f64.convert_i32_u' ? 'u' : 's' }
1520
+ if (v && v.type === 'i32') return { src: v, sign: 's' }
1521
+ return null
1522
+ }
1523
+ // The value's top bit is provably 0 (so its signed and unsigned readings agree): a u8/u16 load,
1524
+ // `>>>` (always clears the sign bit), `& m` with m a non-negative small const, or a small const.
1525
+ const i32TopBitClear = (n) => {
1526
+ if (typeof n === 'number') return n >= 0 && n < 0x80000000
1527
+ if (!Array.isArray(n)) return false
1528
+ if (n[0] == null) return typeof n[1] === 'number' && n[1] >= 0 && n[1] < 0x80000000
1529
+ if (n[0] === 'i32.load8_u' || n[0] === 'i32.load16_u') return true
1530
+ if (n[0] === 'i32.const') return typeof n[1] === 'number' ? (n[1] >= 0 && n[1] < 0x80000000) : false
1531
+ if (n[0] === 'i32.shr_u' || n[0] === '>>>') return true
1532
+ if (n[0] === 'i32.and' || n[0] === '&') return i32TopBitClear(n[1]) || i32TopBitClear(n[2])
1533
+ return false
1534
+ }
1535
+ // i32.eq/ne over the peeled sources equals the f64-widened compare when the signs match, or — for
1536
+ // a mixed signed/unsigned pair — when the unsigned-read source is top-bit-clear (then both readings
1537
+ // of equal bits agree, and unequal bits stay unequal under both).
1538
+ const i32EqSound = (pa, pb) => pa.sign === pb.sign ||
1539
+ i32TopBitClear((pa.sign === 'u' ? pa : pb).src)
1540
+
1541
+ // A memory-free, trap-free, side-effect-free expression — safe to evaluate UNCONDITIONALLY (as a
1542
+ // `select` arm does) and cheap enough that doing so never loses to a branch. Locals/consts and
1543
+ // arithmetic/bitwise/compare/logical over them. Excludes loads (`[]`, may read OOB when the guard
1544
+ // was protecting the access), calls, `.`/`?.` (dispatch), `/` `%` (int trap on 0), assignments.
1545
+ const CHEAP_PURE_OPS = new Set(['+', '-', '*', 'u-', 'u+', '&', '|', '^', '<<', '>>', '>>>', '~',
1546
+ '<', '<=', '>', '>=', '==', '!=', '===', '!==', '&&', '||', '!', '?:'])
1547
+ const isCheapPureVal = (n) => {
1548
+ if (typeof n === 'string' || typeof n === 'number') return true
1549
+ if (!Array.isArray(n)) return false
1550
+ if (n[0] == null) return true // boxed literal [, v]
1551
+ if (n[0] === 'local.get') return true
1552
+ if (CHEAP_PURE_OPS.has(n[0])) { for (let i = 1; i < n.length; i++) if (!isCheapPureVal(n[i])) return false; return true }
1553
+ return false
1554
+ }
1555
+
1556
+ // Side-effect-free: no writes (assignment / ++ / --), no calls, no closures, no throw. UNLIKE
1557
+ // `isCheapPureVal` this ALLOWS loads, member reads, and `/` `%` — a side-effect-free expr may read
1558
+ // memory or trap. It is the right gate for an `if` CONDITION promoted to a `select` condition: the
1559
+ // condition is evaluated exactly once whether the lowering branches or selects (any trap fires the
1560
+ // same in both, the read order vs the pure value arm is immaterial), so it need only avoid MUTATING
1561
+ // state the value arm could read — i.e. be side-effect-free, not unconditionally-evaluable.
1562
+ const SIDE_EFFECT_OPS = new Set(['=', '+=', '-=', '*=', '/=', '%=', '&=', '|=', '^=', '>>=', '<<=',
1563
+ '>>>=', '||=', '&&=', '??=', '++', '--', '()', '=>', 'throw', 'new', 'await', 'yield'])
1564
+ const isSideEffectFree = (n) => {
1565
+ if (!Array.isArray(n)) return true
1566
+ if (typeof n[0] === 'string' && SIDE_EFFECT_OPS.has(n[0])) return false
1567
+ for (let i = 1; i < n.length; i++) if (!isSideEffectFree(n[i])) return false
1568
+ return true
1569
+ }
1570
+
1571
+ const isLit1 = (n) => Array.isArray(n) && n[0] == null && n[1] === 1
1572
+ // A void statement whose whole effect is `x = <cheap pure value>` for a simple local `x` — the
1573
+ // shape if→select can lower to `x = cond ? value : x`. Recognizes the plain assignment plus the
1574
+ // increment forms `++x`/`--x` and their postfix lowerings `(++x) - 1` / `(--x) + 1` (prepare turns
1575
+ // `x++` in statement position into the latter; the discarded ∓1 is dead in void context, so the
1576
+ // net effect is the increment). Returns `{ lhs, val }` or null.
1577
+ function matchVoidLocalStore(s) {
1578
+ if (!Array.isArray(s)) return null
1579
+ if (s[0] === '=' && typeof s[1] === 'string' && isCheapPureVal(s[2])) return { lhs: s[1], val: s[2] }
1580
+ if ((s[0] === '++' || s[0] === '--') && typeof s[1] === 'string')
1581
+ return { lhs: s[1], val: [s[0] === '++' ? '+' : '-', s[1], [, 1]] }
1582
+ // postfix: `x++` → `(++x) - 1`, `x--` → `(--x) + 1`
1583
+ if ((s[0] === '-' || s[0] === '+') && isLit1(s[2]) && Array.isArray(s[1])
1584
+ && (s[1][0] === '++' || s[1][0] === '--') && typeof s[1][1] === 'string') {
1585
+ const inc = s[1][0] === '++'
1586
+ if ((inc && s[0] === '-') || (!inc && s[0] === '+')) return { lhs: s[1][1], val: [inc ? '+' : '-', s[1][1], [, 1]] }
1587
+ }
1588
+ return null
1589
+ }
1590
+
1390
1591
  function emitLooseEq(a, b, negate) {
1391
1592
  const eqOp = negate ? 'ne' : 'eq'
1392
1593
  const sentinel = emitNum(negate ? 1 : 0)
@@ -1405,6 +1606,12 @@ function emitLooseEq(a, b, negate) {
1405
1606
  const tc = emitTypeofCmp(a, b, eqOp); if (tc) return tc
1406
1607
  const va = emit(a), vb = emit(b)
1407
1608
  if (va.type === 'i32' && vb.type === 'i32') return typed([`i32.${eqOp}`, va, vb], 'i32')
1609
+ // Both operands integer-backed (e.g. an i32 local vs a `b[j]` u8 read materialized as f64):
1610
+ // compare the i32 sources directly, skipping the per-op widen to f64. Recovers `intElem ===
1611
+ // intElem` in hot loops (levenshtein's DP cell, where `a[i-1] === b[j-1]` was an f64.eq + 2
1612
+ // converts every iteration). Sound only when the widen can't change the answer (see i32EqSound).
1613
+ const pa = peelIntCmp(va), pb = peelIntCmp(vb)
1614
+ if (pa && pb && i32EqSound(pa, pb)) return typed([`i32.${eqOp}`, pa.src, pb.src], 'i32')
1408
1615
  // Either side known-pure NUMBER (literal or typed) → f64.eq/ne is correct regardless
1409
1616
  // of the other side: jz's `==` is strict (prepare.js:868), and every NaN-boxed pointer
1410
1617
  // reinterprets to a quiet NaN (0x7FF8… prefix) so f64.eq with any normal float is false.
@@ -1425,6 +1632,43 @@ function emitLooseEq(a, b, negate) {
1425
1632
  if (vta && vta === vtb && REF_EQ_KINDS.has(vta)) {
1426
1633
  return typed([`i64.${eqOp}`, ['i64.reinterpret_f64', asF64(va)], ['i64.reinterpret_f64', asF64(vb)]], 'i32')
1427
1634
  }
1635
+ // String-equality specialization — the hot `node[0] === 'literal'` AST-tag dispatch,
1636
+ // the compiler's single most-emitted comparison (5579 of its 6487 __eq sites). When one
1637
+ // side is statically a STRING, skip the generic __eq NaN-box dispatch (the #1 self-host
1638
+ // hot helper). jz's ==/=== never coerce (number-vs-string is false in __eq), so this is
1639
+ // sound for both. Two shapes by what the OTHER side is known to be:
1640
+ // both STRING → __str_eq directly (no number/NaN/tag test needed at all).
1641
+ // STRING vs unknown → i64.eq fast ? equal : (__is_str_key(u) ? __str_eq : not-equal).
1642
+ // Soundness of the fast path: the known string is a non-NaN STRING NaN-box, so a bit
1643
+ // match can ONLY be that same string (a normal f64 can't alias those bits). On bit
1644
+ // MISMATCH the unknown can still content-match — a heap string from `'i'+'f'` shares
1645
+ // content but not bits — so the fallback __str_eq stays (pure i64.eq is unsound here).
1646
+ // __is_str_key rejects the number-whose-bits-alias-the-STRING-tag case that a bare
1647
+ // __ptr_type would misroute into a wild __str_eq deref (see __eq's own guard).
1648
+ // INLINED (not a helper call): a single $__str_eq_lit helper measured 2.4% slower on
1649
+ // the corpus — V8 keeps the call at the hot miss path; inlining lets the optimizer fold
1650
+ // __is_str_key/__str_eq's prefix in, which is where the tag dispatch spends its time.
1651
+ // Behaviorally identical to __eq when one side is a string — proven by a 4584-case
1652
+ // spec-on/spec-off differential (zero divergence at optimize 0 and 2).
1653
+ const strEqResult = (r) => negate ? typed(['i32.eqz', r], 'i32') : r
1654
+ const aStr = rawA === VAL.STRING, bStr = rawB === VAL.STRING
1655
+ if (aStr && bStr) {
1656
+ inc('__str_eq')
1657
+ return strEqResult(typed(['call', '$__str_eq', asI64(va), asI64(vb)], 'i32'))
1658
+ }
1659
+ if ((bStr && rawA == null) || (aStr && rawB == null)) {
1660
+ const uVal = bStr ? va : vb, lVal = bStr ? vb : va // u: unknown side, l: known string
1661
+ inc('__is_str_key', '__str_eq')
1662
+ const u = tempI64('seq'), l = tempI64('seq'), uG = ['local.get', `$${u}`], lG = ['local.get', `$${l}`]
1663
+ return strEqResult(typed(['block', ['result', 'i32'],
1664
+ ['local.set', `$${u}`, asI64(uVal)],
1665
+ ['local.set', `$${l}`, asI64(lVal)],
1666
+ ['if', ['result', 'i32'], ['i64.eq', uG, lG],
1667
+ ['then', ['i32.const', 1]],
1668
+ ['else', ['if', ['result', 'i32'], ['call', '$__is_str_key', uG],
1669
+ ['then', ['call', '$__str_eq', uG, lG]],
1670
+ ['else', ['i32.const', 0]]]]]], 'i32'))
1671
+ }
1428
1672
  inc('__eq')
1429
1673
  const call = typed(['call', '$__eq', asI64(va), asI64(vb)], 'i32')
1430
1674
  return negate ? typed(['i32.eqz', call], 'i32') : call
@@ -1696,6 +1940,13 @@ function emitSpreadElementLoop(spreadExpr, bodyFn, { reverse = false } = {}) {
1696
1940
  ]
1697
1941
  }
1698
1942
 
1943
+ function emitAsValue(fn) {
1944
+ const prev = ctx.func._expect
1945
+ ctx.func._expect = null
1946
+ try { return fn() }
1947
+ finally { ctx.func._expect = prev }
1948
+ }
1949
+
1699
1950
  function emitSingleSpreadMethodCall(objArg, parsed, method, methodEmitter) {
1700
1951
  const inPlace = SPREAD_MUTATORS.has(method)
1701
1952
  // unshift prepends each arg to the front — forward iteration reverses intent.
@@ -1704,11 +1955,11 @@ function emitSingleSpreadMethodCall(objArg, parsed, method, methodEmitter) {
1704
1955
  ctx.func.locals.set(acc, 'f64')
1705
1956
  const ir = [['local.set', `$${acc}`, asF64(emit(objArg))]]
1706
1957
  if (parsed.normal.length > 0) {
1707
- const r = asF64(methodEmitter(objArg, ...parsed.normal))
1958
+ const r = asF64(emitAsValue(() => methodEmitter(objArg, ...parsed.normal)))
1708
1959
  ir.push(inPlace ? ['drop', r] : ['local.set', `$${acc}`, r])
1709
1960
  }
1710
1961
  ir.push(...emitSpreadElementLoop(parsed.spreads[0].expr, (arr, idx) => {
1711
- const body = asF64(methodEmitter(inPlace ? objArg : acc, ['[]', arr, idx]))
1962
+ const body = asF64(emitAsValue(() => methodEmitter(inPlace ? objArg : acc, ['[]', arr, idx])))
1712
1963
  return [inPlace ? ['drop', body] : ['local.set', `$${acc}`, body]]
1713
1964
  }, { reverse }))
1714
1965
  ir.push(inPlace ? asF64(emit(objArg)) : ['local.get', `$${acc}`])
@@ -1730,7 +1981,7 @@ function emitMultiSpreadMethodCall(objArg, parsed, method, methodEmitter) {
1730
1981
  let batch = []
1731
1982
  const flushBatch = () => {
1732
1983
  if (!batch.length) return
1733
- const r = asF64(methodEmitter(recv, ...batch))
1984
+ const r = asF64(emitAsValue(() => methodEmitter(recv, ...batch)))
1734
1985
  ir.push(inPlace ? ['drop', r] : ['local.set', `$${acc}`, r])
1735
1986
  batch = []
1736
1987
  }
@@ -1738,7 +1989,7 @@ function emitMultiSpreadMethodCall(objArg, parsed, method, methodEmitter) {
1738
1989
  if (Array.isArray(item) && item[0] === '__spread') {
1739
1990
  flushBatch()
1740
1991
  ir.push(...emitSpreadElementLoop(item[1], (arr, idx) => {
1741
- const body = asF64(methodEmitter(recv, ['[]', arr, idx]))
1992
+ const body = asF64(emitAsValue(() => methodEmitter(recv, ['[]', arr, idx])))
1742
1993
  return [inPlace ? ['drop', body] : ['local.set', `$${acc}`, body]]
1743
1994
  }))
1744
1995
  } else {
@@ -1815,7 +2066,7 @@ function tryCharCodeAtFast(callee, obj, method, parsed) {
1815
2066
  return typed(['call', '$__jss_charCodeAt', recv, asI32(emit(parsed.normal[0]))], 'i32')
1816
2067
  }
1817
2068
  return typed(stringOps(obj).charCodeAt(
1818
- asF64(recv), asI32(emit(parsed.normal[0])), ctx, false), 'i32')
2069
+ asF64(recv), asI32(emit(parsed.normal[0])), ctx, false, true), 'i32')
1819
2070
  }
1820
2071
  }
1821
2072
 
@@ -2497,7 +2748,15 @@ export const emitter = {
2497
2748
  inc('__to_num')
2498
2749
  return writeVar(name, typed(['call', '$__to_num', asI64(emit(name))], 'f64'), void_)
2499
2750
  }
2500
- return writeVar(name, emit(val), void_)
2751
+ // Self-accumulation `x = x + …` (incl. desugared `x += …`): the new value REPLACES x, so x's
2752
+ // old buffer is dead — the one context where a string concat may bump-EXTEND it in place. The
2753
+ // `+` handler reads this flag for its immediate concat; nested operands clear it (not the target).
2754
+ const selfAccum = Array.isArray(val) && val[0] === '+' && val[1] === name
2755
+ const prevSA = ctx.func._selfAccumConcat
2756
+ ctx.func._selfAccumConcat = selfAccum ? name : null
2757
+ const ev = emit(val)
2758
+ ctx.func._selfAccumConcat = prevSA
2759
+ return writeVar(name, ev, void_)
2501
2760
  },
2502
2761
 
2503
2762
  // Compound assignments: read-modify-write with type coercion
@@ -2587,15 +2846,18 @@ export const emitter = {
2587
2846
  // Postfix in void: (++i)-1 / (--i)+1 → just ++i / --i
2588
2847
  '+': (a, b) => {
2589
2848
  if (ctx.func._expect === 'void' && isPostfix(a, '--', b)) return emit(a, 'void')
2849
+ // A self-accumulation `a = a + …` lets the concat bump-EXTEND `a` in place (a is dead-after).
2850
+ // Read it for THIS concat, then clear so nested operands (not the accumulation target) stay fresh.
2851
+ const selfAccum = typeof a === 'string' && a === ctx.func._selfAccumConcat
2852
+ ctx.func._selfAccumConcat = null
2590
2853
  // String concatenation: pure string operands skip generic ToString coercion.
2591
2854
  const vtA = valTypeOf(a)
2592
2855
  const vtB = valTypeOf(b)
2593
2856
  if (vtA === VAL.STRING && vtB === VAL.STRING) {
2594
- // Fused append-byte: `buf += s[i]` skips 1-char SSO construction +
2595
- // generic concat dispatch when rhs is a string-index. The byte flows
2596
- // straight from __char_at into memory, and the bump-extend path elides
2597
- // the alloc+copy when lhs is the heap-top STRING.
2598
- if (Array.isArray(b) && b[0] === '[]' && ctx.core.stdlib['__str_append_byte'] && ctx.core.stdlib['__char_at']) {
2857
+ // Fused append-byte: `buf += s[i]` skips 1-char SSO construction + generic concat dispatch
2858
+ // when rhs is a string-index. The byte flows straight from __char_at into memory and bump-
2859
+ // EXTENDS the heap-top lhs so only when proven self-accumulating (else it mutates a live s).
2860
+ if (selfAccum && Array.isArray(b) && b[0] === '[]' && ctx.core.stdlib['__str_append_byte'] && ctx.core.stdlib['__char_at']) {
2599
2861
  if (valTypeOf(b[1]) === VAL.STRING) {
2600
2862
  inc('__str_append_byte', '__char_at')
2601
2863
  return typed(['call', '$__str_append_byte',
@@ -2604,7 +2866,7 @@ export const emitter = {
2604
2866
  ], 'f64')
2605
2867
  }
2606
2868
  }
2607
- return typed(ctx.abi.string.ops.concatRaw(asF64(emit(a)), asF64(emit(b)), ctx), 'f64')
2869
+ return typed(ctx.abi.string.ops.concatRaw(asF64(emit(a)), asF64(emit(b)), ctx, selfAccum), 'f64')
2608
2870
  }
2609
2871
  if (vtA === VAL.STRING || vtB === VAL.STRING) {
2610
2872
  // An OBJECT operand coerces via ToPrimitive(string) at compile time —
@@ -2624,10 +2886,10 @@ export const emitter = {
2624
2886
  const coercionFree = (vt) => vt === VAL.STRING || vt === VAL.OBJECT || vt === VAL.BOOL
2625
2887
  const cfA = coercionFree(vtA), cfB = coercionFree(vtB)
2626
2888
  const strI64 = (n) => typed(['f64.reinterpret_i64', toStrI64(n, emit(n))], 'f64')
2627
- if (cfA && cfB) return typed(ctx.abi.string.ops.concatRaw(strOperand(vtA, a), strOperand(vtB, b), ctx), 'f64')
2628
- if (cfA) return typed(ctx.abi.string.ops.concatRaw(strOperand(vtA, a), strI64(b), ctx), 'f64')
2629
- if (cfB) return typed(ctx.abi.string.ops.concatRaw(strI64(a), strOperand(vtB, b), ctx), 'f64')
2630
- return typed(ctx.abi.string.ops.cat(strOperand(vtA, a), strOperand(vtB, b), ctx), 'f64')
2889
+ if (cfA && cfB) return typed(ctx.abi.string.ops.concatRaw(strOperand(vtA, a), strOperand(vtB, b), ctx, selfAccum), 'f64')
2890
+ if (cfA) return typed(ctx.abi.string.ops.concatRaw(strOperand(vtA, a), strI64(b), ctx, selfAccum), 'f64')
2891
+ if (cfB) return typed(ctx.abi.string.ops.concatRaw(strI64(a), strOperand(vtB, b), ctx, selfAccum), 'f64')
2892
+ return typed(ctx.abi.string.ops.cat(strOperand(vtA, a), strOperand(vtB, b), ctx, selfAccum), 'f64')
2631
2893
  }
2632
2894
  if (vtA === VAL.BIGINT || vtB === VAL.BIGINT)
2633
2895
  return fromI64(['i64.add', asI64(emit(a)), asI64(emit(b))])
@@ -2637,6 +2899,12 @@ export const emitter = {
2637
2899
  // operand needs the runtime check.
2638
2900
  if ((vtA == null || vtB == null) && ctx.core.stdlib['__str_concat']) {
2639
2901
  const tA = temp('add'), tB = temp('add')
2902
+ // Fully-untyped `+`: the string arm is a runtime-guarded cold path that the engine reaches
2903
+ // only if BOTH operands are strings at runtime, so it keeps the bump-extend `__str_concat`
2904
+ // (its body stays out-of-line — folding it to the smaller _fresh twin would inline this
2905
+ // never-numeric branch into every hot integer loop). The demonstrated `t = s + "lit"` mutation
2906
+ // is a TYPED concat (handled by concatRaw above); a both-untyped self-mutation stays the
2907
+ // documented rare-aliasing tradeoff. Self-accumulation is still safe to extend.
2640
2908
  inc('__str_concat', '__is_str_key')
2641
2909
  const checkA = vtA == null ? ['call', '$__is_str_key', ['i64.reinterpret_f64', ['local.tee', `$${tA}`, asF64(emit(a))]]] : null
2642
2910
  const checkB = vtB == null ? ['call', '$__is_str_key', ['i64.reinterpret_f64', ['local.tee', `$${tB}`, asF64(emit(b))]]] : null
@@ -2661,6 +2929,7 @@ export const emitter = {
2661
2929
  // op whose result can exceed i32, so `i32.add` would wrap (4294967295+1→0).
2662
2930
  // Widen to f64 — never wrap — matching spec. Only `>>>0`/`|0`/imul wrap.
2663
2931
  if (isI32Num(va) && isI32Num(vb) && !widensUnsigned(va) && !widensUnsigned(vb)) return typed(['i32.add', va, vb], 'i32')
2932
+ const i32add = tryI32Arith('i32.add', '+', a, b, va, vb); if (i32add) return i32add
2664
2933
  return typed(['f64.add', stripCanon(toNumF64(a, va)), stripCanon(toNumF64(b, vb))], 'f64')
2665
2934
  },
2666
2935
  '-': (a, b) => {
@@ -2676,6 +2945,7 @@ export const emitter = {
2676
2945
  // Unsigned uint32 operand: JS `-` is float (can go negative / exceed i32),
2677
2946
  // so avoid the wrapping i32.sub fast-path. See `+` above.
2678
2947
  if (isI32Num(va) && isI32Num(vb) && !widensUnsigned(va) && !widensUnsigned(vb)) return typed(['i32.sub', va, vb], 'i32')
2948
+ const i32sub = tryI32Arith('i32.sub', '-', a, b, va, vb); if (i32sub) return i32sub
2679
2949
  return typed(['f64.sub', stripCanon(toNumF64(a, va)), stripCanon(toNumF64(b, vb))], 'f64')
2680
2950
  },
2681
2951
  'u+': a => {
@@ -2705,7 +2975,8 @@ export const emitter = {
2705
2975
  if (isLit(va) && litVal(va) === 0 && finiteFactor(vb)) return isLit(vb) ? va : typed(['block', ['result', va.type], vb, 'drop', va], va.type)
2706
2976
  // `.unsigned` operand is a uint32 ([0, 2^32)); its product can exceed i32, so
2707
2977
  // `i32.mul` would wrap ((2^32-1)*2 → -2). Widen to f64 — see `+` above.
2708
- if (isI32Num(va) && isI32Num(vb) && !widensUnsigned(va) && !widensUnsigned(vb) && mulFitsI32(va, vb)) return typed(['i32.mul', va, vb], 'i32')
2978
+ if (isI32Num(va) && isI32Num(vb) && !widensUnsigned(va) && !widensUnsigned(vb) && (mulFitsI32(va, vb) || mulBoundedFaithful(va, vb))) return typed(['i32.mul', va, vb], 'i32')
2979
+ const i32mul = tryI32Arith('i32.mul', '*', a, b, va, vb); if (i32mul) return i32mul
2709
2980
  return typed(['f64.mul', stripCanon(toNumF64(a, va)), stripCanon(toNumF64(b, vb))], 'f64')
2710
2981
  },
2711
2982
  '/': (a, b) => {
@@ -3075,6 +3346,21 @@ export const emitter = {
3075
3346
  if (els != null) return emitVoid(els)
3076
3347
  return null
3077
3348
  }
3349
+ // If-conversion (speed tier): `if (cond) x = <cheap pure value>` (no else) → `x = cond ? value
3350
+ // : x`, which lowers to a branchless `select`. Removes the data-dependent branch (and its
3351
+ // misprediction) from min/max/clamp reductions — e.g. levenshtein's `if (ins < m) m = ins`,
3352
+ // ~27% faster — and from heapsort's child pick `if (a[c] < a[c+1]) c++`, the canonical
3353
+ // unpredictable compare that costs jz on x86 (Cranelift/V8-x64 keep the branch; Binaryen, which
3354
+ // AS uses, selects it). The condition is evaluated exactly once whether we branch or select, so
3355
+ // it need only be SIDE-EFFECT-FREE (loads allowed — sort's `a[c] < a[c+1]`); only the assigned
3356
+ // VALUE is evaluated unconditionally, hence must be a cheap, trap-free pure expr. `x++`/`x--`
3357
+ // are admitted as `x = x ± 1`. The already-emitted condition `ce` is reused (`__emitted`), so a
3358
+ // load-bearing condition is not emitted twice.
3359
+ if (els == null && ctx.transform.optimize?.boolConvertToSelect && isSideEffectFree(cond)) {
3360
+ const asg = Array.isArray(then) && then[0] === ';' && then.length === 2 ? then[1] : then
3361
+ const sel = matchVoidLocalStore(asg)
3362
+ if (sel) return emitVoid(['=', sel.lhs, ['?:', ['__emitted', ce], sel.val, sel.lhs]])
3363
+ }
3078
3364
  const c = ce.type === 'i32' ? ce : toBoolFromEmitted(ce)
3079
3365
  // Flow-sensitive type refinement: narrow types within each branch based on the guard.
3080
3366
  const thenRefs = extractRefinements(cond, new Map(), true)
@@ -3336,6 +3622,10 @@ export function emit(node, expect) {
3336
3622
  if (node.loc != null) ctx.error.loc = node.loc
3337
3623
  }
3338
3624
  if (node == null) return null
3625
+ // Pre-emitted IR passthrough: `['__emitted', ir]` returns `ir` untouched. Lets a caller that
3626
+ // already emitted a subtree (e.g. the `if` handler's condition) splice it into an AST-shaped
3627
+ // re-emit (a `?:` for if→select conversion) without emitting it a second time.
3628
+ if (Array.isArray(node) && node[0] === '__emitted') return node[1]
3339
3629
  // Boolean literals carry VAL.BOOL for type observation (valTypeOf reads the
3340
3630
  // AST), but their working representation is the plain number 0/1 — identical
3341
3631
  // codegen to the pre-carrier `[, 1]`/`[, 0]` folding, so no perf is paid.