jz 0.7.0 → 0.8.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.
Files changed (53) 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 +1156 -984
  7. package/index.js +40 -9
  8. package/interop.js +193 -138
  9. package/layout.js +29 -18
  10. package/module/array.js +29 -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 +26 -3
  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 +162 -156
  20. package/module/typedarray.js +169 -105
  21. package/package.json +7 -3
  22. package/src/abi/string.js +29 -29
  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 +253 -23
  28. package/src/compile/index.js +198 -61
  29. package/src/compile/loop-divmod.js +12 -58
  30. package/src/compile/loop-model.js +91 -0
  31. package/src/compile/loop-square.js +102 -0
  32. package/src/compile/narrow.js +169 -32
  33. package/src/compile/peel-stencil.js +18 -64
  34. package/src/compile/plan/common.js +29 -0
  35. package/src/compile/plan/index.js +4 -1
  36. package/src/compile/plan/inline.js +176 -21
  37. package/src/compile/plan/literals.js +93 -19
  38. package/src/ctx.js +51 -12
  39. package/src/helper-counters.js +137 -0
  40. package/src/ir.js +102 -13
  41. package/src/kind-traits.js +7 -3
  42. package/src/kind.js +14 -1
  43. package/src/op-policy.js +5 -2
  44. package/src/ops.js +119 -0
  45. package/src/optimize/index.js +960 -136
  46. package/src/optimize/recurse.js +182 -0
  47. package/src/optimize/vectorize.js +1292 -144
  48. package/src/prepare/index.js +11 -7
  49. package/src/reps.js +4 -1
  50. package/src/type.js +53 -45
  51. package/src/wat/assemble.js +92 -9
  52. package/src/widen.js +21 -0
  53. package/src/wat/optimize.js +0 -3938
@@ -0,0 +1,102 @@
1
+ // Bounded-square narrowing: carry `i*i` as i32 inside a loop guarded by `i*i < CONST`.
2
+ //
3
+ // A PRODUCT `i*i` is f64 in unhinted JS — the integer-overflow contract: a product can
4
+ // exceed 2³¹, where JS keeps a Number, so jz can't blindly use i32.mul (it would wrap).
5
+ // In a Sieve-of-Eratosthenes `for(i=2; i*i<LIMIT; i++) for(j=i*i; j<LIMIT; j+=i) …`, that
6
+ // makes the outer bound, the inner counter `j`'s init, and the whole index chain f64 —
7
+ // each typed-array access then pays an f64→i32 convert.
8
+ //
9
+ // But when the loop GUARD is `i*i < CONST` with CONST a compile-time constant ≤ 2³⁰, the
10
+ // counter is i ≤ ⌈√CONST⌉ ≤ 2¹⁵ throughout the body (the loop is still running, and i is
11
+ // only incremented by +1), so EVERY `i*i` there is < 2³⁰ < 2³¹ and `Math.imul(i,i) == i*i`
12
+ // exactly. The exit overshoot (the first i with i*i ≥ CONST, evaluated in the condition)
13
+ // is ≤ CONST + 2√CONST+1 < 2³¹ for CONST ≤ 2³⁰, so the condition narrows soundly too. The
14
+ // hard cap is 2³⁰ — well under the 2³¹ overflow point, with margin for the overshoot.
15
+ //
16
+ // So we rewrite those `i*i` to `Math.imul(i,i)` (jz's i32 multiply): semantically identical
17
+ // in the proven range, and it lets the EXISTING i32 machinery carry the index chain as i32
18
+ // — the inner `j` (init now i32, step `j+i` i32, bound `j<CONST` i32) cascades on its own.
19
+ //
20
+ // Sound iff: the loop condition is `(i*i) </≤ CONST` (CONST ≤ 2³⁰), and the IV `i` is
21
+ // incremented by +1 and NOT otherwise mutated (so within a body iteration i ∈ {entry, +1},
22
+ // both squares < 2³¹). Mirrors strength-reduce-divmod's structure (post-prepare `while`).
23
+
24
+ import { findMutations } from './analyze-scans.js'
25
+ import { includeMods } from '../autoload.js'
26
+ import { ctx } from '../ctx.js'
27
+ import { litVal, unitIncVar, normalizeLoop, closureMutatedVars, rewriteBlocks } from './loop-model.js'
28
+
29
+ const SQUARE_BOUND_MAX = 2 ** 30
30
+ // The constant numeric value of a bound: a literal, OR a module const folded to an int
31
+ // (`const LIMIT = 1<<20` → ctx.scope.constInts.get('LIMIT') = 1048576 — the bench form).
32
+ const boundVal = (n) => {
33
+ const lit = litVal(n)
34
+ if (lit != null) return lit
35
+ if (typeof n === 'string') { const v = ctx.scope.constInts?.get(n); return typeof v === 'number' ? v : null }
36
+ return null
37
+ }
38
+ // `i * i` — the IV squared.
39
+ const isSquare = (n, iv) => Array.isArray(n) && n[0] === '*' && n[1] === iv && n[2] === iv && typeof iv === 'string'
40
+ // Math.imul(i, i) in CANONICAL post-prepare form: prepare resolves `Math.imul` → the string
41
+ // ref `'math.imul'`, so the call is `['()', 'math.imul', [',', i, i]]`. math.imul emits a
42
+ // primitive `i32.mul` (no stdlib helper / module include needed).
43
+ const imulOf = (iv) => ['()', 'math.imul', [',', iv, iv]]
44
+
45
+ // The IV of a `(i*i) </≤ CONST` (or mirrored `CONST >/≥ (i*i)`) guard, CONST ≤ 2³⁰, else null.
46
+ function boundedSquareIV(cond) {
47
+ if (!Array.isArray(cond)) return null
48
+ let prod, bound
49
+ if (cond[0] === '<' || cond[0] === '<=') { prod = cond[1]; bound = cond[2] }
50
+ else if (cond[0] === '>' || cond[0] === '>=') { prod = cond[2]; bound = cond[1] }
51
+ else return null
52
+ if (!isSquare(prod, prod && prod[1])) return null
53
+ const b = boundVal(bound)
54
+ if (b == null || b < 0 || b > SQUARE_BOUND_MAX) return null
55
+ return prod[1]
56
+ }
57
+
58
+ // Narrow a `for`/`while` whose guard is `(i*i) </≤ CONST` (CONST ≤ 2³⁰) and whose IV `i` is
59
+ // incremented by +1 and not otherwise mutated — then within any body iteration i ∈ {entry,
60
+ // entry+1}, entry ≤ ⌈√CONST⌉ ≤ 2¹⁵, so every `i*i` (and the exit overshoot) is < 2³¹ and
61
+ // Math.imul(i,i) == i*i. Rewrites those products; the dependent counter chain cascades to i32.
62
+ // `cm` is the function's closure-mutated-vars set (an IV in it has an unprovable entry value).
63
+ function tryNarrow(stmt, cm) {
64
+ const L = normalizeLoop(stmt)
65
+ if (!L) return null
66
+ const { kind, cond, step, body } = L, isFor = kind === 'for'
67
+
68
+ const iv = boundedSquareIV(cond)
69
+ if (!iv) return null
70
+ if (cm.has(iv)) return null // mutable via a closure call — entry value unprovable
71
+
72
+ if (isFor) {
73
+ // The `for` update is the IV's sole, +1 mutation; the body must not reassign i.
74
+ if (unitIncVar(step) !== iv) return null
75
+ const ivMut = new Set(); findMutations(body, new Set([iv]), ivMut)
76
+ if (ivMut.has(iv)) return null
77
+ } else {
78
+ // `while`: the increment lives in the body (exactly one +1; nothing else mutates i).
79
+ if (!Array.isArray(body) || body[0] !== ';') return null
80
+ let ivIdx = -1
81
+ for (let k = 1; k < body.length; k++) if (unitIncVar(body[k]) === iv) { if (ivIdx >= 0) return null; ivIdx = k }
82
+ if (ivIdx < 0) return null
83
+ const ivMut = new Set()
84
+ findMutations([';', ...body.slice(1).filter((_, k) => k !== ivIdx - 1)], new Set([iv]), ivMut)
85
+ if (ivMut.has(iv)) return null
86
+ }
87
+
88
+ // We're injecting `math.imul` after prepare's auto-import step, so ensure the math module
89
+ // (which registers the `math.imul` → i32.mul primitive emitter) is included.
90
+ includeMods('math')
91
+ // Rewrite every `i*i` in the condition + body to Math.imul(i,i) (NOT init/update — they're
92
+ // `i=2` / `i++`). The inner counter whose init this feeds cascades to i32 on its own.
93
+ const rw = (n) => !Array.isArray(n) ? n : isSquare(n, iv) ? imulOf(iv) : n.map(rw)
94
+ return [isFor
95
+ ? ['for', L.init, rw(cond), step, rw(body)]
96
+ : ['while', rw(cond), rw(body)]]
97
+ }
98
+
99
+ export function narrowBoundedSquare(body) {
100
+ const cm = closureMutatedVars(body)
101
+ return rewriteBlocks(body, stmt => tryNarrow(stmt, cm))
102
+ }
@@ -28,6 +28,10 @@ import {
28
28
  } from './infer.js'
29
29
 
30
30
  const PTR_ABI_KINDS = new Set([VAL.OBJECT, VAL.SET, VAL.MAP, VAL.BUFFER])
31
+ // Integer-preserving ops: an expr over integers stays integer (ToInt32-consistent) through these.
32
+ // Excludes /, %, ** (fractional). Used to recognize a recursive arg whose i32-ness follows from
33
+ // its inputs' i32-ness (`f(n - 1)`), so it carries no independent type evidence.
34
+ const RECUR_INT_OPS = new Set(['+', '-', '*', 'u-', 'u+', '&', '|', '^', '<<', '>>', '>>>', '~'])
31
35
 
32
36
 
33
37
  function filterLiveCallSites(callSites, valueUsed) {
@@ -165,9 +169,10 @@ function refreshCallerValTypes(callerCtx) {
165
169
  // it is the same single typed-array ctor (scope.js invalidates on any conflict),
166
170
  // so it can't denote a different kind at the call site.
167
171
  function callerTypedElemsFor(func, globalTE) {
168
- const local = analyzeBody(func.body).typedElems
172
+ const facts = analyzeBody(func.body)
173
+ const local = facts.typedElems
169
174
  if (!globalTE.size) return local
170
- const shadowed = new Set(analyzeBody(func.body).locals.keys())
175
+ const shadowed = new Set(facts.locals.keys())
171
176
  for (const p of func.sig?.params || []) shadowed.add(p.name)
172
177
  const merged = new Map()
173
178
  for (const [k, v] of globalTE) if (!shadowed.has(k)) merged.set(k, v)
@@ -223,6 +228,7 @@ function enrichCallerValTypesFromPointerParams(callerCtx) {
223
228
  }
224
229
 
225
230
  function refreshCallerLocals(callerCtx) {
231
+ const prevTE = ctx.types.typedElem
226
232
  for (const func of ctx.func.list) {
227
233
  if (!func.body || func.raw) continue
228
234
  // Seed pointer-narrowed params' val-kind so analyzeBody recognises e.g.
@@ -232,13 +238,24 @@ function refreshCallerLocals(callerCtx) {
232
238
  // (heapsort→siftDown's `end`). analyzeFuncForEmit re-seeds + re-invalidates at
233
239
  // emit time, so this transient localReps doesn't leak past narrowing.
234
240
  ctx.func.localReps = new Map()
235
- for (const p of func.sig.params) if (p.ptrKind != null) ctx.func.localReps.set(p.name, { val: p.ptrKind })
241
+ // Seed the typedElem overlay with this func's TYPED-pointer params (element ctor from
242
+ // ptrAux), exactly as analyzeFuncForEmit does at emit time. Without it, a local bound to
243
+ // an integer typed-array PARAM element — `aa = perm[perm[X]+Y]` (noise), perm an Int32
244
+ // pointer param — types f64 here, so a callee fed it (`grad(aa,…)`, used only as `aa&3`)
245
+ // never narrows its param to i32. Mirrors emit so narrow-time callerLocals agree with it.
246
+ const te = ctx.scope.globalTypedElem ? new Map(ctx.scope.globalTypedElem) : new Map()
247
+ for (const p of func.sig.params) {
248
+ if (p.ptrKind != null) ctx.func.localReps.set(p.name, { val: p.ptrKind })
249
+ if (p.ptrKind === VAL.TYPED && p.ptrAux != null) { const c = ctorFromElemAux(p.ptrAux); if (c != null) te.set(p.name, c) }
250
+ }
251
+ ctx.types.typedElem = te
236
252
  invalidateLocalsCache(func.body)
237
253
  const fresh = analyzeBody(func.body).locals
238
254
  for (const p of func.sig.params) if (!fresh.has(p.name)) fresh.set(p.name, p.type)
239
255
  callerCtx.get(func).callerLocals = fresh
240
256
  }
241
257
  ctx.func.localReps = null
258
+ ctx.types.typedElem = prevTE
242
259
  }
243
260
 
244
261
  function resetParamWasmFacts(paramReps) {
@@ -280,6 +297,35 @@ function narrowI32Results(funcs) {
280
297
  e[0] === '>>>' ||
281
298
  (e[0] === '()' && typeof e[1] === 'string' && ctx.func.map?.get(e[1])?.sig?.unsignedResult === true)
282
299
  )
300
+ const callsSelf = (n, name) => Array.isArray(n) && ((n[0] === '()' && n[1] === name) || n.some(c => callsSelf(c, name)))
301
+ // Classify a func's return tails as all-v128 / all-i32 (+ sign) under the CURRENT sig.results.
302
+ const evalTails = (func, body, exprs) => {
303
+ const savedCurrent = ctx.func.current
304
+ ctx.func.current = func.sig
305
+ const locals = isBlockBody(body) ? analyzeBody(body).locals : new Map()
306
+ for (const p of func.sig.params) if (!locals.has(p.name)) locals.set(p.name, p.type)
307
+ // Seed the typedElem overlay with this func's TYPED-pointer params so a return tail
308
+ // reading a typed-array element — `return vals[h]`, vals an Int32Array param (dict's
309
+ // `lookup`) — types as i32, not NaN-boxed f64. Without it the call site keeps the full
310
+ // __typed_idx/ToNumber unbox dispatch (491520× per dict kernel run). Mirrors
311
+ // refreshCallerLocals + analyzeFuncForEmit. Only meaningful once Phase G has tagged params
312
+ // ptrKind=TYPED (the I2 re-run below); harmless before (no typed params → overlay untouched).
313
+ const savedTE = ctx.types.typedElem
314
+ let te = null
315
+ for (const p of func.sig.params) {
316
+ if (p.ptrKind === VAL.TYPED && p.ptrAux != null) {
317
+ const c = ctorFromElemAux(p.ptrAux)
318
+ if (c != null) { if (!te) te = savedTE ? new Map(savedTE) : new Map(); te.set(p.name, c) }
319
+ }
320
+ }
321
+ if (te) ctx.types.typedElem = te
322
+ const allV128 = exprs.every(e => exprType(e, locals) === 'v128')
323
+ const allI32 = !allV128 && exprs.every(e => exprType(e, locals) === 'i32')
324
+ if (te) ctx.types.typedElem = savedTE
325
+ const r = { allV128, allI32, anyUnsigned: exprs.some(isUnsignedTail), allUnsigned: exprs.every(isUnsignedTail) }
326
+ ctx.func.current = savedCurrent
327
+ return r
328
+ }
283
329
  let changed = true
284
330
  while (changed) {
285
331
  changed = false
@@ -289,22 +335,40 @@ function narrowI32Results(funcs) {
289
335
  if (isBlockBody(body) && hasBareReturn(body)) continue
290
336
  const exprs = returnExprs(body)
291
337
  if (!exprs.length) continue
292
- const savedCurrent = ctx.func.current
293
- ctx.func.current = func.sig
294
- const locals = isBlockBody(body) ? analyzeBody(body).locals : new Map()
295
- for (const p of func.sig.params) if (!locals.has(p.name)) locals.set(p.name, p.type)
296
- const allV128 = exprs.every(e => exprType(e, locals) === 'v128')
297
- const allI32 = !allV128 && exprs.every(e => exprType(e, locals) === 'i32')
298
- const anyUnsigned = exprs.some(isUnsignedTail)
299
- const allUnsigned = exprs.every(isUnsignedTail)
300
- ctx.func.current = savedCurrent
338
+ let r = evalTails(func, body, exprs)
339
+ // Recursive result cycle: a self-call in a return tail — or feeding a returned local
340
+ // (nqueens' `cnt = cnt + solve(); return cnt`) reads solve's own not-yet-narrowed
341
+ // f64 result, so `cnt` widens to f64 and the i32 narrowing never fires. Break the cycle
342
+ // optimistically: tentatively assume the i32 result, re-analyze, and keep it ONLY if every
343
+ // tail is then i32 (else revert). Sound committed only when self-consistent.
344
+ if (!r.allI32 && !r.allV128 && callsSelf(body, func.name)) {
345
+ const saved = func.sig.results
346
+ func.sig.results = ['i32']
347
+ invalidateLocalsCache(body)
348
+ const opt = evalTails(func, body, exprs)
349
+ if (opt.allI32 && (!opt.anyUnsigned || opt.allUnsigned)) {
350
+ if (opt.allUnsigned) func.sig.unsignedResult = true
351
+ changed = true
352
+ continue
353
+ }
354
+ func.sig.results = saved
355
+ invalidateLocalsCache(body)
356
+ r = evalTails(func, body, exprs)
357
+ }
301
358
  // SIMD: every tail returns a lane vector → v128 result.
302
- if (allV128) {
359
+ if (r.allV128) {
303
360
  func.sig.results = ['v128']
304
361
  changed = true
305
- } else if (allI32 && (!anyUnsigned || allUnsigned)) { // sign-consistent i32 tails
362
+ } else if (r.allI32 && (!r.anyUnsigned || r.allUnsigned)) { // sign-consistent i32 tails
306
363
  func.sig.results = ['i32']
307
- if (allUnsigned) func.sig.unsignedResult = true
364
+ if (r.allUnsigned) func.sig.unsignedResult = true
365
+ // A committed i32 result is a genuine NUMBER, so stamp valResult for the call-site
366
+ // VAL dispatch — E2 (narrowValResults) ran ABOVE the param lattice and so couldn't
367
+ // type a `return typedArrayParam[idx]` tail (hashjoin's `probe` → `vals[h]`), leaving
368
+ // valResult unset → the hot `sum + probe()` stayed the polymorphic string-or-number
369
+ // `+`. Only-if-unset: an UNBOXED-pointer i32 result already carries its ARRAY/OBJECT/
370
+ // TYPED valResult (the unboxing ABI needs it), so this never overwrites a pointer kind.
371
+ if (func.valResult == null) func.valResult = VAL.NUMBER
308
372
  changed = true
309
373
  }
310
374
  }
@@ -672,10 +736,20 @@ export default function narrowSignatures(programFacts, ast) {
672
736
  const state = siteState(callSites[s])
673
737
  if (!state) continue
674
738
  const { func, argList } = state
739
+ const recursive = state.callee === state.callerFunc?.name
675
740
  for (let k = 0; k < func.sig.params.length; k++) {
676
741
  const r = ensureParamRep(paramReps, state.callee, k)
677
742
  if (k >= argList.length) { for (const rule of rules) rule.missing(r, k, state); continue }
678
- for (const rule of rules) rule.apply(r, argList[k], k, state)
743
+ const arg = argList[k]
744
+ // Recursive identity arg — `f(…, p, …)` calling itself with its own param p threaded
745
+ // through at the same position — is a fixpoint identity: it carries whatever type p
746
+ // settles to, so it constrains nothing. Skip it, else exprType(p) reads p's not-yet-
747
+ // narrowed f64 and the meet poisons the type the non-recursive call sites would prove
748
+ // (nqueens' `solve(all, …)` — `all` stuck f64 while cols/d1/d2, passed as i32 bitwise
749
+ // exprs, narrowed fine).
750
+ const pname = func.sig.params[k].name
751
+ if (recursive && (arg === pname || (Array.isArray(arg) && arg[0] === 'local.get' && arg[1] === pname))) continue
752
+ for (const rule of rules) rule.apply(r, arg, k, state)
679
753
  }
680
754
  }
681
755
  }
@@ -743,6 +817,46 @@ export default function narrowSignatures(programFacts, ast) {
743
817
  mergeParamFact(r, field, v)
744
818
  },
745
819
  })
820
+ // WASM type of a call arg. exprType resolves most shapes, but an INTEGER typed-array
821
+ // element read `intArr[idx]` (and arithmetic over it, `intArr[idx]+1`) types f64 here:
822
+ // exprType's `[]` rule reads the typedElem OVERLAY, which doesn't see a typedCtor-narrowed
823
+ // PARAM array at fixpoint time — yet the element is a 32-bit machine integer. Install the
824
+ // caller's resolved param-typedCtors (+ module globals) as that overlay for the duration
825
+ // of the type query, so a param fed only such integer elements (dict's key `k` ← src[i],
826
+ // threaded through Math.imul / === keys[h] / keys[h]=k) narrows to i32 instead of paying
827
+ // convert + f64-compare + trunc round-trips through its probe loop.
828
+ // A value built ONLY from the callee's own params + already-i32 locals + integer constants via
829
+ // integer-preserving ops. Its i32-ness follows from its inputs' — for a recursive self-call it
830
+ // carries no INDEPENDENT evidence about whether the params are i32. Used for the optimism below.
831
+ const isRecurIntExpr = (n, pnames, callerLocals) => {
832
+ if (typeof n === 'string') return pnames.has(n) || callerLocals?.get?.(n) === 'i32'
833
+ if (typeof n === 'number') return Number.isInteger(n)
834
+ if (!Array.isArray(n)) return false
835
+ if (n[0] == null) return typeof n[1] === 'number' && Number.isInteger(n[1]) // boxed int literal
836
+ if (n[0] === 'local.get') return pnames.has(n[1]) || callerLocals?.get?.(n[1]) === 'i32'
837
+ if (RECUR_INT_OPS.has(n[0])) return n.slice(1).every(c => isRecurIntExpr(c, pnames, callerLocals))
838
+ return false
839
+ }
840
+ const argWasmType = (arg, state) => {
841
+ // Recursive self-call: an arg built only from the callee's own params + already-i32 locals +
842
+ // int constants (`f(n - 1)`, `f(n - 1 - i)`) is i32 IFF those params are i32 — a fixpoint
843
+ // identity carrying no INDEPENDENT type evidence. Optimistically type it i32 so the NON-
844
+ // recursive call sites decide: all i32 ⇒ the param narrows; any f64 ⇒ the meet still poisons
845
+ // it. Lets a plain decreasing recursion narrow with no `|0` source crutch. (The bare-identity
846
+ // arg `f(n)` is already skipped wholesale in runCallsiteLattice.)
847
+ if (state.callee === state.callerFunc?.name &&
848
+ isRecurIntExpr(arg, new Set(state.func.sig.params.map(p => p.name)), state.callerLocals)) return 'i32'
849
+ if (!state._teOverlay) {
850
+ const m = new Map(ctx.scope.globalTypedElem || [])
851
+ const pf = state.callerParamFacts('typedCtor')
852
+ if (pf) for (const [name, ctor] of pf) if (ctor != null) m.set(name, ctor)
853
+ state._teOverlay = m
854
+ }
855
+ const prev = ctx.func.localTypedElemsOverlay
856
+ ctx.func.localTypedElemsOverlay = state._teOverlay
857
+ try { return exprType(arg, state.callerLocals) }
858
+ finally { ctx.func.localTypedElemsOverlay = prev }
859
+ }
746
860
  const runFixpoint = () => runCallsiteLattice([
747
861
  // val runs SOFT (monotone): a TYPED param's val only becomes inferable after the
748
862
  // typedCtor fixpoint + pointer-ABI enrichment, so an early hard merge would
@@ -754,7 +868,7 @@ export default function narrowSignatures(programFacts, ast) {
754
868
  missing: poison('wasm'),
755
869
  apply(r, arg, _k, state) {
756
870
  if (r.wasm === null) return
757
- const wt = exprType(arg, state.callerLocals)
871
+ const wt = argWasmType(arg, state)
758
872
  if (r.wasm === undefined) r.wasm = wt
759
873
  else if (r.wasm !== wt) r.wasm = null
760
874
  },
@@ -919,6 +1033,16 @@ export default function narrowSignatures(programFacts, ast) {
919
1033
  // (callback bench: mix is FNV — params and result all i32-shaped, but inferred
920
1034
  // only after E phase narrowed mix's result).
921
1035
  phase.refreshLocals()
1036
+ // I2: Re-narrow i32 RESULTS now that Phase G (applyTypedPointerParamAbi) has tagged
1037
+ // typed-array params ptrKind=TYPED. Phase E ran before G, so a function returning a
1038
+ // typed-array element — dict's `lookup = (keys, vals, k) => { … return vals[h] }` with
1039
+ // vals an Int32Array param — had its return tail type as NaN-boxed f64 (vals not yet a
1040
+ // typed pointer), leaving sig.results f64 and the call site running the full
1041
+ // __typed_idx/ToNumber unbox on every probe step (491520× per dict kernel run). Now that
1042
+ // evalTails seeds the typed-param overlay and params carry ptrAux, the fixpoint catches
1043
+ // `vals[h]` as i32, narrows the result, and the dispatch vanishes; the runFixpoint below
1044
+ // then propagates the i32 result into `let v = lookup(...)` at the call sites.
1045
+ narrowI32Results(funcsWithNarrowableResult)
922
1046
  // Reset wasm field unconditionally — first pass populated it from stale callerLocals
923
1047
  // (where `let h = mix(...)` widened h to f64 because mix's result wasn't narrowed
924
1048
  // yet). clearStickyNull only resets null; here we need to reset f64-observed too
@@ -1334,25 +1458,37 @@ export function specializeBimorphicTyped(programFacts) {
1334
1458
 
1335
1459
  // Build one clone per distinct combo.
1336
1460
  const cloneByKey = new Map()
1337
- for (const [key, combo] of distinct) {
1338
- const suffix = combo.map(c => c.replace(/^new\./, '').replace(/\./g, '_')).join('$')
1461
+ for (const [dkey, cmb] of distinct) {
1462
+ // NB: this loop variable must NOT reuse the name `combo` (declared twice above, at the
1463
+ // site loop and the distinct-building loop). The self-host miscompiles a for-of loop
1464
+ // variable whose name collides with an earlier block-scoped declaration — it aliases the
1465
+ // prior binding instead of rebinding per iteration, so `combo` would stay stuck at the
1466
+ // last site's ctor and every clone would get the same (wrong) element type → silent
1467
+ // garbage. A unique name gets a clean per-iteration binding.
1468
+ const suffix = cmb.map(c => c.replace(/^new\./, '').replace(/\./g, '_')).join('$')
1339
1469
  let cloneName = `${func.name}$${suffix}`
1340
1470
  let n = 0
1341
1471
  while (ctx.func.names.has(cloneName)) cloneName = `${func.name}$${suffix}$${++n}`
1342
1472
 
1473
+ // Build cloneSig with clean, fully-formed object literals — never by spreading a
1474
+ // live object and then overriding/extending its keys. A MULTI-prop spread of a
1475
+ // member-access source (`{ ...func.sig, params, results }`) takes the static
1476
+ // allKnown OBJECT-merge path, which trusts func.sig's COMPILE-TIME schema; sig
1477
+ // objects are polymorphic (some carry result/ptrKind/unsignedResult), so that
1478
+ // schema can be a subset of the runtime shape and the slot-copy then faults a
1479
+ // later `sig.params` read out of bounds in the self-host. (The single-unknown
1480
+ // `{ ...x }` clone is fixed at the root — __obj_clone — but the allKnown merge
1481
+ // path is a separate hazard.) Constructing each param with its pointer ABI baked
1482
+ // in sidesteps it; output is unchanged on the host.
1343
1483
  const cloneSig = {
1344
- ...func.sig,
1345
- params: func.sig.params.map(p => ({ ...p })),
1484
+ params: func.sig.params.map((p, idx) => {
1485
+ const bi = bimorphic.indexOf(idx)
1486
+ return bi < 0
1487
+ ? { ...p }
1488
+ : { name: p.name, type: 'i32', ptrKind: VAL.TYPED, ptrAux: typedElemAux(cmb[bi]) }
1489
+ }),
1346
1490
  results: [...func.sig.results],
1347
1491
  }
1348
- for (let i = 0; i < bimorphic.length; i++) {
1349
- const k = bimorphic[i]
1350
- const aux = typedElemAux(combo[i])
1351
- const p = cloneSig.params[k]
1352
- p.type = 'i32'
1353
- p.ptrKind = VAL.TYPED
1354
- p.ptrAux = aux
1355
- }
1356
1492
  const clone = { ...func, name: cloneName, sig: cloneSig }
1357
1493
  ctx.func.list.push(clone)
1358
1494
  ctx.func.map.set(cloneName, clone)
@@ -1360,19 +1496,20 @@ export function specializeBimorphicTyped(programFacts) {
1360
1496
 
1361
1497
  // Mirror per-param reps under the clone's name with mono ctors at bimorphic
1362
1498
  // positions. emitFunc's preseed reads typedCtor → seeds typedElem map →
1363
- // `arr[i]` lowers to direct typed load.
1499
+ // `arr[i]` lowers to direct typed load. Each `{ ...r }` is a true clone, so
1500
+ // pinning typedCtor on it leaves the source rep untouched (__obj_clone).
1364
1501
  const cloneReps = new Map()
1365
1502
  for (const [k, r] of reps) cloneReps.set(k, { ...r })
1366
1503
  for (let i = 0; i < bimorphic.length; i++) {
1367
1504
  const k = bimorphic[i]
1368
1505
  const r = cloneReps.get(k) || {}
1369
- r.typedCtor = combo[i]
1506
+ r.typedCtor = cmb[i]
1370
1507
  r.val = VAL.TYPED
1371
1508
  cloneReps.set(k, r)
1372
1509
  }
1373
1510
  paramReps.set(cloneName, cloneReps)
1374
1511
 
1375
- cloneByKey.set(key, clone)
1512
+ cloneByKey.set(dkey, clone)
1376
1513
  }
1377
1514
 
1378
1515
  // Rewrite each site's call AST to point at the matching clone.
@@ -20,8 +20,9 @@
20
20
  // literal tests use `== null`; created literals are bare numbers.
21
21
 
22
22
  import { findMutations } from './analyze-scans.js'
23
+ import { ASSIGN_OPS } from '../ast.js'
24
+ import { litN, unitIncVar, normalizeLoop, closureMutatedVars, rewriteBlocks, freshLoopId } from './loop-model.js'
23
25
 
24
- const litN = (n, k) => Array.isArray(n) && n.length === 2 && n[0] == null && n[1] === k
25
26
  const isVar = (n) => typeof n === 'string'
26
27
 
27
28
  // `k = -r`: prepared as ['u-', r] (unary minus) or ['-', 0, r].
@@ -31,11 +32,10 @@ const negOf = (n) => Array.isArray(n) && n[0] === 'u-' ? n[1]
31
32
  // Every write to `iv` in `node` is a strictly-positive step (++iv / iv+=1 / iv=iv+1),
32
33
  // so iv advances monotonically — required so the three split loops partition [0,bound)
33
34
  // and the clamp-free interior never re-runs at an edge index. Any other write → false.
34
- const ASSIGN = new Set(['=', '+=', '-=', '*=', '/=', '%=', '&=', '|=', '^=', '<<=', '>>=', '>>>=', '**=', '&&=', '||=', '??='])
35
35
  function ivMonotonic(node, iv) {
36
36
  if (!Array.isArray(node)) return true
37
37
  if ((node[0] === '++' || node[0] === '--') && node[1] === iv) return node[0] === '++'
38
- if (ASSIGN.has(node[0]) && node[1] === iv) {
38
+ if (ASSIGN_OPS.has(node[0]) && node[1] === iv) {
39
39
  if (node[0] === '+=' && litN(node[2], 1)) return true
40
40
  if (node[0] === '=' && Array.isArray(node[2]) && node[2][0] === '+'
41
41
  && ((node[2][1] === iv && litN(node[2][2], 1)) || (node[2][2] === iv && litN(node[2][1], 1)))) return true
@@ -44,22 +44,6 @@ function ivMonotonic(node, iv) {
44
44
  return node.every(c => ivMonotonic(c, iv))
45
45
  }
46
46
 
47
- // Vars assigned inside any closure in the function — a call in the loop can mutate
48
- // them even though findMutations (direct writes only) misses it. iv/bound/r in this
49
- // set must bail (same class as the loop-SR closure-mutation guard).
50
- const collectAssigns = (n, out) => {
51
- if (!Array.isArray(n)) return
52
- if (typeof n[1] === 'string' && (ASSIGN.has(n[0]) || n[0] === '++' || n[0] === '--')) out.add(n[1])
53
- n.forEach(c => collectAssigns(c, out))
54
- }
55
- const closureMutated = (n, out) => {
56
- if (!Array.isArray(n)) return out
57
- if (n[0] === '=>') collectAssigns(n, out)
58
- n.forEach(c => closureMutated(c, out))
59
- return out
60
- }
61
- let _cm = new Set()
62
-
63
47
  // Find, anywhere in `node`, a clamp `if (ci < 0) ci = 0; else if (ci >= B) ci = B-1`
64
48
  // over a var `ci` and bound var `B`. Returns { ci, bound } or null (first match).
65
49
  function findClamp(node) {
@@ -121,7 +105,7 @@ function countWrites(node, v) {
121
105
  const visit = (x) => {
122
106
  if (!Array.isArray(x)) return
123
107
  if ((x[0] === '++' || x[0] === '--') && x[1] === v) n++
124
- else if (ASSIGN.has(x[0]) && x[1] === v) n++
108
+ else if (ASSIGN_OPS.has(x[0]) && x[1] === v) n++
125
109
  x.forEach(visit)
126
110
  }
127
111
  visit(node)
@@ -151,7 +135,7 @@ function ivWrittenInNestedLoop(body, iv) {
151
135
  let found = false
152
136
  const visit = (n, inLoop) => {
153
137
  if (!Array.isArray(n)) return
154
- if (inLoop && (((n[0] === '++' || n[0] === '--') && n[1] === iv) || (ASSIGN.has(n[0]) && n[1] === iv))) found = true
138
+ if (inLoop && (((n[0] === '++' || n[0] === '--') && n[1] === iv) || (ASSIGN_OPS.has(n[0]) && n[1] === iv))) found = true
155
139
  const deeper = inLoop || n[0] === 'while' || n[0] === 'for'
156
140
  n.forEach(c => visit(c, deeper))
157
141
  }
@@ -159,41 +143,24 @@ function ivWrittenInNestedLoop(body, iv) {
159
143
  return found
160
144
  }
161
145
 
162
- let _uniq = 0
163
-
164
146
  // Drop the clamp `if` node from a (cloned) body, leaving the bare `ci = iv + k`.
165
147
  const dropClamp = (node, clampNode) =>
166
148
  !Array.isArray(node) ? node
167
149
  : node === clampNode ? ['{}', [';']] // empty block (the if is a statement)
168
150
  : node.map(c => dropClamp(c, clampNode))
169
151
 
170
- // A strictly-positive +1 step on `iv` (the for-loop increment): i++, ++i, i+=1, i=i+1.
171
- const stepIsPosInc = (s, iv) => {
172
- if (!Array.isArray(s)) return false
173
- let inc = s
174
- if (s[0] === '-' && litN(s[2], 1) && Array.isArray(s[1]) && s[1][0] === '++') inc = s[1]
175
- if (inc[0] === '++' && inc[1] === iv) return true
176
- if (s[0] === '+=' && s[1] === iv && litN(s[2], 1)) return true
177
- return s[0] === '=' && s[1] === iv && Array.isArray(s[2]) && s[2][0] === '+'
178
- && ((s[2][1] === iv && litN(s[2][2], 1)) || (s[2][2] === iv && litN(s[2][1], 1)))
179
- }
180
-
181
- function tryPeel(stmt) {
182
- if (!Array.isArray(stmt)) return null
152
+ function tryPeel(stmt, cm) {
183
153
  // Normalize while / for into (iv, bound, body, init, step). For a `while`, the
184
154
  // increment is inside the body; for a `for` it is the separate step clause, which
185
155
  // we re-append to each split loop's body (converting the for into init + whiles).
186
- let iv, bound, body, init = null, step = null
187
- if (stmt[0] === 'while') {
188
- const cond = stmt[1]
189
- if (!Array.isArray(cond) || cond[0] !== '<' || !isVar(cond[1])) return null
190
- iv = cond[1]; bound = cond[2]; body = stmt[2]
191
- } else if (stmt[0] === 'for') {
192
- init = stmt[1]; const cond = stmt[2]; step = stmt[3]; body = stmt[4]
193
- if (!Array.isArray(cond) || cond[0] !== '<' || !isVar(cond[1])) return null
194
- iv = cond[1]; bound = cond[2]
195
- if (!stepIsPosInc(step, iv)) return null
196
- } else return null
156
+ const L = normalizeLoop(stmt)
157
+ if (!L) return null
158
+ let { init, cond, step, body } = L
159
+ if (!Array.isArray(cond) || cond[0] !== '<' || !isVar(cond[1])) return null
160
+ const iv = cond[1], bound = cond[2]
161
+ // The `for` step must be the IV's strictly-positive +1 increment; a `while` increments in
162
+ // its body (validated below by ivMonotonic / the exactly-one-+1 checks).
163
+ if (L.kind === 'for' && unitIncVar(step) !== iv) return null
197
164
  if (!isVar(bound) || !Array.isArray(body)) return null
198
165
  // A loop whose body is a single statement (e.g. an outer row loop wrapping one
199
166
  // inner loop — the vertical blur pass) isn't a `;` sequence; normalize it.
@@ -227,9 +194,9 @@ function tryPeel(stmt) {
227
194
  if (!ivMonotonic(body, iv)) return null
228
195
  const mut = new Set(); findMutations(body, new Set([bound, r]), mut)
229
196
  if (mut.has(bound) || mut.has(r)) return null
230
- if (_cm.has(iv) || _cm.has(bound) || _cm.has(r)) return null // closure-mutable → unsafe
197
+ if (cm.has(iv) || cm.has(bound) || cm.has(r)) return null // closure-mutable → unsafe
231
198
 
232
- const id = _uniq++
199
+ const id = freshLoopId()
233
200
  const xs = `__pks${id}`, xe = `__pke${id}`
234
201
  // xs = r < bound ? r : bound ; xe = (bound - r) > xs ? bound - r : xs
235
202
  const seed = ['let',
@@ -242,20 +209,7 @@ function tryPeel(stmt) {
242
209
  return init ? [init, seed, ...loops] : [seed, ...loops]
243
210
  }
244
211
 
245
- function walk(node) {
246
- if (!Array.isArray(node)) return node
247
- const n = node.map(walk)
248
- if (n[0] !== ';') return n
249
- const out = [';']
250
- for (let k = 1; k < n.length; k++) {
251
- const r = tryPeel(n[k])
252
- if (r) out.push(...r)
253
- else out.push(n[k])
254
- }
255
- return out
256
- }
257
-
258
212
  export function peelClampedStencil(body) {
259
- _cm = closureMutated(body, new Set())
260
- return walk(body)
213
+ const cm = closureMutatedVars(body)
214
+ return rewriteBlocks(body, stmt => tryPeel(stmt, cm))
261
215
  }
@@ -129,6 +129,35 @@ export const fixedScalarTypedArray = (expr) => {
129
129
  ? { len, coerce: SCALAR_TYPED_COERCE[ctor] } : null
130
130
  }
131
131
 
132
+ /** Does `expr` allocate a FRESH typed-array buffer of static numeric length —
133
+ * `new Float64Array(N)` with N a compile-time integer? Excludes view ctors
134
+ * (`new Float64Array(someBuffer)` — the arg is a buffer ref, not an int), so the
135
+ * result provably aliases no other buffer. Used by param-distinctness. */
136
+ export const isFreshTypedArrayAlloc = (expr) => {
137
+ if (typedElemCtor(expr) == null) return false
138
+ const args = callArgs(expr)
139
+ return !!args && args.length === 1 && constIntExpr(args[0]) != null
140
+ }
141
+
142
+ /** Local names const-bound to a fresh typed-array allocation (each a unique buffer, distinct from
143
+ * every other such binding and from any pre-existing value). `const` only — an immutable binding
144
+ * always holds that fresh buffer. Element writes (`a[i]=…`) don't reassign `a`, so they're fine. */
145
+ export const freshTypedArrayLocals = (body) => {
146
+ const fresh = new Set()
147
+ const walk = node => {
148
+ if (!Array.isArray(node) || node[0] === '=>') return
149
+ if (node[0] === 'const') {
150
+ for (let i = 1; i < node.length; i++) {
151
+ const d = node[i]
152
+ if (Array.isArray(d) && d[0] === '=' && typeof d[1] === 'string' && isFreshTypedArrayAlloc(d[2])) fresh.add(d[1])
153
+ }
154
+ }
155
+ for (let i = 1; i < node.length; i++) walk(node[i])
156
+ }
157
+ walk(body)
158
+ return fresh
159
+ }
160
+
132
161
  /** Map of name → {len, coerce} for every fixed-size typed-array binding declared
133
162
  * via `let`/`const` directly in `body` (no descent into nested arrows). */
134
163
  export const fixedTypedArraysInBody = (body) => {
@@ -43,7 +43,7 @@ import { inlineHotInternalCalls, inlineLocalLambdas, specializeFixedRestCalls }
43
43
  import { bindNestedRowLengths, unrollRowLenPadLoops, splitCharScanLoops } from './loops.js'
44
44
  import {
45
45
  scalarizeFunctionTypedArrays, scalarizeFunctionArrayLiterals,
46
- promoteIntArrayLiterals, scalarizeFunctionObjectLiterals,
46
+ promoteIntArrayLiterals, scalarizeFunctionObjectLiterals, analyzeParamDistinctness,
47
47
  } from './literals.js'
48
48
 
49
49
  export default function plan(ast, profiler) {
@@ -113,6 +113,9 @@ export default function plan(ast, profiler) {
113
113
  }
114
114
 
115
115
  t('narrowSignatures', () => narrowSignatures(programFacts, ast))
116
+ // After narrowSignatures (params now carry ptrKind): mark typed-array params that every call
117
+ // site passes a distinct fresh buffer for → enables alias-aware LICM in the optimizer.
118
+ if (optimizing()) t('analyzeParamDistinctness', () => analyzeParamDistinctness(programFacts))
116
119
  t('specializeBimorphicTyped', () => specializeBimorphicTyped(programFacts))
117
120
  t('refineDynKeys', () => refineDynKeys(programFacts))
118
121
  strictBoundaryTypeCheck(programFacts)