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
@@ -0,0 +1,167 @@
1
+ // Partial unroll (×2) + scalar replacement of a unit-stride ARRAY RECURRENCE.
2
+ //
3
+ // A DP/scan loop that reads `arr[j-1]` and writes `arr[j]` (unit stride) carries the
4
+ // just-written value to the next iteration THROUGH MEMORY: it stores `arr[j]`, then the next
5
+ // iteration loads `arr[j-1]` — the very cell it just wrote. V8/TurboFan forwards that store→load
6
+ // and unrolls the loop internally; Cranelift/wasmtime and the baseline tiers do neither, so the
7
+ // loop pays a store→load round trip plus full per-iteration overhead on every cell. clang/gcc
8
+ // fix exactly this with this transform — measured 2.15× on wasmtime for the Levenshtein DP
9
+ // (V8-neutral, bit-exact).
10
+ //
11
+ // Recognized (post-prepare AST): a unit-stride `for (let j = LO; j </<= HI; j++)` whose body, for
12
+ // ONE array `arr`, has a single store `arr[j] = <var>` and ≥1 read `arr[j-1]`, accesses `arr` at
13
+ // no other index, never aliases `arr` elsewhere, and contains no call / nested loop / break /
14
+ // continue / return / closure. The `arr[j-1]` read becomes a scalar `left` seeded from `arr[LO-1]`
15
+ // and refreshed after each store; the body is then unrolled ×2 (with a 1-cell tail) so the carry
16
+ // between the paired cells lives in a register and the loop overhead is halved. A `LO <= HI` guard
17
+ // keeps the seed load in step with the original (which reads `arr[LO-1]` only when it iterates),
18
+ // and falls back to the untouched loop on the empty range — sound for any trip count.
19
+
20
+ import { findMutations } from './analyze-scans.js'
21
+ import { litVal, litN, unitIncVar, normalizeLoop, freshLoopId } from './loop-model.js'
22
+ import { rewriteBlocks, closureMutatedVars } from './loop-model.js'
23
+
24
+ const isArr = (n) => Array.isArray(n) // wrap (not alias): the self-host kernel rejects a builtin used as a first-class value
25
+ const clone = (n) => isArr(n) ? n.map(clone) : n
26
+ const isIvMinus1 = (n, iv) => isArr(n) && n[0] === '-' && n[1] === iv && litN(n[2], 1) // (iv - 1)
27
+
28
+ // Ops whose presence makes duplicating the body in place unsound (control that escapes the cell,
29
+ // or a call that could alias/mutate `arr` or reorder side effects).
30
+ const REJECT = new Set(['for', 'while', 'do', 'for-in', 'for-of', 'break', 'continue', 'return',
31
+ 'throw', 'switch', 'try', 'catch', 'finally', '=>', 'label'])
32
+ const hasUnsafe = (n) => {
33
+ if (!isArr(n)) return false
34
+ if (REJECT.has(n[0])) return true
35
+ if (n[0] === '()' && typeof n[1] === 'string') return true // function call `f(args)`
36
+ return n.some(hasUnsafe)
37
+ }
38
+
39
+ // Substitute every value-reference of `iv` with (iv + 1); leave the op slot and property keys.
40
+ const subPlus1 = (n, iv) => {
41
+ if (n === iv) return ['+', iv, 1]
42
+ if (!isArr(n)) return n
43
+ if (n[0] === '.' && n.length === 3) return ['.', subPlus1(n[1], iv), n[2]]
44
+ return [n[0], ...n.slice(1).map(c => subPlus1(c, iv))]
45
+ }
46
+
47
+ // Rename every let/const-DECLARED var in `stmts` with a suffix, throughout — so the 2nd unrolled
48
+ // cell's locals don't collide with the 1st. Loop-carried outer vars (assigned, not declared here)
49
+ // are untouched, so the recurrence still threads through them.
50
+ function renameDecls(stmts, suf) {
51
+ const declared = new Set()
52
+ const collect = (n) => {
53
+ if (!isArr(n)) return
54
+ if (n[0] === 'let' || n[0] === 'const')
55
+ for (let k = 1; k < n.length; k++) if (isArr(n[k]) && n[k][0] === '=' && typeof n[k][1] === 'string') declared.add(n[k][1])
56
+ n.forEach(collect)
57
+ }
58
+ stmts.forEach(collect)
59
+ if (!declared.size) return stmts
60
+ const ren = (n) => {
61
+ if (typeof n === 'string') return declared.has(n) ? n + suf : n
62
+ if (!isArr(n)) return n
63
+ if (n[0] === '.' && n.length === 3) return ['.', ren(n[1]), n[2]]
64
+ return [n[0], ...n.slice(1).map(ren)]
65
+ }
66
+ return stmts.map(ren)
67
+ }
68
+
69
+ // Replace `arr[iv-1]` reads with `left`; keep the store; emit `left = storeVal` after each store.
70
+ function scalarReplace(stmts, arr, iv, left, storeVal) {
71
+ const repl = (n) => {
72
+ if (!isArr(n)) return n
73
+ if (n[0] === '[]' && n[1] === arr && isIvMinus1(n[2], iv)) return left
74
+ if (n[0] === '.' && n.length === 3) return ['.', repl(n[1]), n[2]]
75
+ return [n[0], ...n.slice(1).map(repl)]
76
+ }
77
+ const out = []
78
+ for (const s of stmts) {
79
+ out.push(repl(s))
80
+ if (isArr(s) && s[0] === '=' && isArr(s[1]) && s[1][0] === '[]' && s[1][1] === arr && s[1][2] === iv)
81
+ out.push(['=', left, storeVal])
82
+ }
83
+ return out
84
+ }
85
+
86
+ function tryUnroll(stmt, cm) {
87
+ const L = normalizeLoop(stmt)
88
+ if (!L || L.kind !== 'for') return null
89
+ const body = L.body
90
+ if (!isArr(body) || body[0] !== ';') return null
91
+ const iv = unitIncVar(L.step)
92
+ if (!iv) return null
93
+
94
+ // init `let iv = LO`, LO a literal ≥ 1 (so arr[LO-1] is a valid in-bounds index)
95
+ if (!(isArr(L.init) && L.init[0] === 'let' && isArr(L.init[1]) && L.init[1][0] === '=' && L.init[1][1] === iv)) return null
96
+ const LO = L.init[1][2], loVal = litVal(LO)
97
+ if (loVal == null || loVal < 1) return null
98
+
99
+ // cond `iv <= HI` / `iv < HI`, HI loop-invariant
100
+ if (!(isArr(L.cond) && (L.cond[0] === '<=' || L.cond[0] === '<') && L.cond[1] === iv)) return null
101
+ const cmpOp = L.cond[0], HI = L.cond[2]
102
+ if (!(typeof HI === 'string' || litVal(HI) != null)) return null
103
+
104
+ if (hasUnsafe(body)) return null
105
+ const stmts = body.slice(1)
106
+
107
+ // exactly one store `arr[iv] = <var>` — the recurrence array + carried value
108
+ let arr = null, storeVal = null, nStore = 0
109
+ for (const s of stmts)
110
+ if (isArr(s) && s[0] === '=' && isArr(s[1]) && s[1][0] === '[]' && s[1][2] === iv) {
111
+ if (typeof s[2] !== 'string') return null
112
+ arr = s[1][1]; storeVal = s[2]; nStore++
113
+ }
114
+ if (nStore !== 1 || typeof arr !== 'string') return null
115
+ if (storeVal === iv || storeVal === arr) return null
116
+
117
+ // every `arr[...]` is `arr[iv]` or `arr[iv-1]`, the only write is the store, ≥1 recurrence read,
118
+ // and `arr` never appears bare (passed/aliased)
119
+ let hasRec = false, bad = false
120
+ const scan = (n) => {
121
+ if (!isArr(n)) return
122
+ if (n[0] === '[]' && n[1] === arr) {
123
+ if (n[2] === iv) {} else if (isIvMinus1(n[2], iv)) hasRec = true; else bad = true
124
+ }
125
+ if (n[0] === '=' && isArr(n[1]) && n[1][0] === '[]' && n[1][1] === arr && n[1][2] !== iv) bad = true
126
+ if (!(n[0] === '[]' || n[0] === '.')) for (let k = 1; k < n.length; k++) if (n[k] === arr) bad = true
127
+ n.forEach(scan)
128
+ }
129
+ scan(body)
130
+ if (bad || !hasRec) return null
131
+
132
+ // The carry `left = storeVal` is emitted right after the store, so a recurrence read AFTER the
133
+ // store would see this cell's value, not arr[iv-1]. Require every arr[iv-1] read to precede it.
134
+ const storeIdx = stmts.findIndex(s => isArr(s) && s[0] === '=' && isArr(s[1]) && s[1][0] === '[]' && s[1][1] === arr && s[1][2] === iv)
135
+ const readsRec = (n) => isArr(n) && ((n[0] === '[]' && n[1] === arr && isIvMinus1(n[2], iv)) || n.some(readsRec))
136
+ for (let k = storeIdx + 1; k < stmts.length; k++) if (readsRec(stmts[k])) return null
137
+
138
+ // iv assigned only by the step; iv/arr/HI loop-invariant (not mutated, incl. via a closure call)
139
+ const ivMut = new Set(); findMutations(body, new Set([iv]), ivMut)
140
+ if (ivMut.has(iv)) return null
141
+ if (cm.has(iv) || cm.has(arr)) return null
142
+ if (typeof HI === 'string') { const hiMut = new Set(); findMutations(body, new Set([HI]), hiMut); if (hiMut.has(HI) || cm.has(HI)) return null }
143
+
144
+ // --- transform ---
145
+ const id = freshLoopId()
146
+ const left = `__rec${id}`
147
+ const bodyS = scalarReplace(stmts, arr, iv, left, storeVal)
148
+ const cellJ = () => bodyS.map(clone)
149
+ const cellJ1 = renameDecls(bodyS.map(s => subPlus1(clone(s), iv)), `$r${id}`)
150
+
151
+ const seed = ['let', ['=', left, ['[]', arr, loVal - 1]]] // left = arr[LO-1]
152
+ const letIv = ['let', ['=', iv, clone(LO)]] // let iv = LO
153
+ const twoFit = cmpOp === '<=' ? ['<', iv, clone(HI)] : ['<', iv, ['-', clone(HI), 1]]
154
+ const main = ['while', twoFit,
155
+ [';', ['{}', [';', ...cellJ()]], ['{}', [';', ...cellJ1]], ['=', iv, ['+', iv, 2]]]]
156
+ const tail = ['if', [cmpOp, iv, clone(HI)],
157
+ ['{}', [';', ...cellJ(), ['=', iv, ['+', iv, 1]]]]]
158
+ const block = ['{}', [';', letIv, seed, main, tail]]
159
+ // Run the unrolled form only on a non-empty range (so the seed's arr[LO-1] load matches the
160
+ // original, which reads it only when it iterates); otherwise the untouched loop.
161
+ return [['if', [cmpOp, clone(LO), clone(HI)], block, stmt]]
162
+ }
163
+
164
+ export function unrollRecurrence(body) {
165
+ const cm = closureMutatedVars(body)
166
+ return rewriteBlocks(body, stmt => tryUnroll(stmt, cm))
167
+ }
@@ -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) {
@@ -58,12 +62,17 @@ function filterLiveCallSites(callSites, valueUsed) {
58
62
 
59
63
  function buildCallerCtx() {
60
64
  const callerCtx = new Map()
61
- callerCtx.set(null, { callerLocals: ctx.scope.globalTypes, callerValTypes: ctx.scope.globalValTypes })
65
+ const globalTE = ctx.scope.globalTypedElem || new Map()
66
+ callerCtx.set(null, { callerLocals: ctx.scope.globalTypes, callerValTypes: ctx.scope.globalValTypes, callerTypedElems: globalTE })
62
67
  for (const func of ctx.func.list) {
63
68
  if (!func.body || func.raw) continue
64
69
  const facts = analyzeBody(func.body)
65
70
  for (const p of func.sig.params) if (!facts.locals.has(p.name)) facts.locals.set(p.name, p.type)
66
- callerCtx.set(func, { callerLocals: facts.locals, callerValTypes: facts.valTypes })
71
+ // Shadow-aware local+global typed-array map: a `const buf = new Int32Array(…)`
72
+ // local makes `buf[i]` arg reads type i32 at this caller's sites, so a callee
73
+ // param fed only such elements narrows (else it stays f64 and `1 << p` drags in
74
+ // __to_num → the whole string↔number stdlib). Mirrors callerTypedElemsFor.
75
+ callerCtx.set(func, { callerLocals: facts.locals, callerValTypes: facts.valTypes, callerTypedElems: callerTypedElemsFor(func, globalTE) })
67
76
  }
68
77
  return callerCtx
69
78
  }
@@ -165,9 +174,10 @@ function refreshCallerValTypes(callerCtx) {
165
174
  // it is the same single typed-array ctor (scope.js invalidates on any conflict),
166
175
  // so it can't denote a different kind at the call site.
167
176
  function callerTypedElemsFor(func, globalTE) {
168
- const local = analyzeBody(func.body).typedElems
177
+ const facts = analyzeBody(func.body)
178
+ const local = facts.typedElems
169
179
  if (!globalTE.size) return local
170
- const shadowed = new Set(analyzeBody(func.body).locals.keys())
180
+ const shadowed = new Set(facts.locals.keys())
171
181
  for (const p of func.sig?.params || []) shadowed.add(p.name)
172
182
  const merged = new Map()
173
183
  for (const [k, v] of globalTE) if (!shadowed.has(k)) merged.set(k, v)
@@ -223,6 +233,7 @@ function enrichCallerValTypesFromPointerParams(callerCtx) {
223
233
  }
224
234
 
225
235
  function refreshCallerLocals(callerCtx) {
236
+ const prevTE = ctx.types.typedElem
226
237
  for (const func of ctx.func.list) {
227
238
  if (!func.body || func.raw) continue
228
239
  // Seed pointer-narrowed params' val-kind so analyzeBody recognises e.g.
@@ -232,13 +243,24 @@ function refreshCallerLocals(callerCtx) {
232
243
  // (heapsort→siftDown's `end`). analyzeFuncForEmit re-seeds + re-invalidates at
233
244
  // emit time, so this transient localReps doesn't leak past narrowing.
234
245
  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 })
246
+ // Seed the typedElem overlay with this func's TYPED-pointer params (element ctor from
247
+ // ptrAux), exactly as analyzeFuncForEmit does at emit time. Without it, a local bound to
248
+ // an integer typed-array PARAM element — `aa = perm[perm[X]+Y]` (noise), perm an Int32
249
+ // pointer param — types f64 here, so a callee fed it (`grad(aa,…)`, used only as `aa&3`)
250
+ // never narrows its param to i32. Mirrors emit so narrow-time callerLocals agree with it.
251
+ const te = ctx.scope.globalTypedElem ? new Map(ctx.scope.globalTypedElem) : new Map()
252
+ for (const p of func.sig.params) {
253
+ if (p.ptrKind != null) ctx.func.localReps.set(p.name, { val: p.ptrKind })
254
+ if (p.ptrKind === VAL.TYPED && p.ptrAux != null) { const c = ctorFromElemAux(p.ptrAux); if (c != null) te.set(p.name, c) }
255
+ }
256
+ ctx.types.typedElem = te
236
257
  invalidateLocalsCache(func.body)
237
258
  const fresh = analyzeBody(func.body).locals
238
259
  for (const p of func.sig.params) if (!fresh.has(p.name)) fresh.set(p.name, p.type)
239
260
  callerCtx.get(func).callerLocals = fresh
240
261
  }
241
262
  ctx.func.localReps = null
263
+ ctx.types.typedElem = prevTE
242
264
  }
243
265
 
244
266
  function resetParamWasmFacts(paramReps) {
@@ -280,6 +302,35 @@ function narrowI32Results(funcs) {
280
302
  e[0] === '>>>' ||
281
303
  (e[0] === '()' && typeof e[1] === 'string' && ctx.func.map?.get(e[1])?.sig?.unsignedResult === true)
282
304
  )
305
+ const callsSelf = (n, name) => Array.isArray(n) && ((n[0] === '()' && n[1] === name) || n.some(c => callsSelf(c, name)))
306
+ // Classify a func's return tails as all-v128 / all-i32 (+ sign) under the CURRENT sig.results.
307
+ const evalTails = (func, body, exprs) => {
308
+ const savedCurrent = ctx.func.current
309
+ ctx.func.current = func.sig
310
+ const locals = isBlockBody(body) ? analyzeBody(body).locals : new Map()
311
+ for (const p of func.sig.params) if (!locals.has(p.name)) locals.set(p.name, p.type)
312
+ // Seed the typedElem overlay with this func's TYPED-pointer params so a return tail
313
+ // reading a typed-array element — `return vals[h]`, vals an Int32Array param (dict's
314
+ // `lookup`) — types as i32, not NaN-boxed f64. Without it the call site keeps the full
315
+ // __typed_idx/ToNumber unbox dispatch (491520× per dict kernel run). Mirrors
316
+ // refreshCallerLocals + analyzeFuncForEmit. Only meaningful once Phase G has tagged params
317
+ // ptrKind=TYPED (the I2 re-run below); harmless before (no typed params → overlay untouched).
318
+ const savedTE = ctx.types.typedElem
319
+ let te = null
320
+ for (const p of func.sig.params) {
321
+ if (p.ptrKind === VAL.TYPED && p.ptrAux != null) {
322
+ const c = ctorFromElemAux(p.ptrAux)
323
+ if (c != null) { if (!te) te = savedTE ? new Map(savedTE) : new Map(); te.set(p.name, c) }
324
+ }
325
+ }
326
+ if (te) ctx.types.typedElem = te
327
+ const allV128 = exprs.every(e => exprType(e, locals) === 'v128')
328
+ const allI32 = !allV128 && exprs.every(e => exprType(e, locals) === 'i32')
329
+ if (te) ctx.types.typedElem = savedTE
330
+ const r = { allV128, allI32, anyUnsigned: exprs.some(isUnsignedTail), allUnsigned: exprs.every(isUnsignedTail) }
331
+ ctx.func.current = savedCurrent
332
+ return r
333
+ }
283
334
  let changed = true
284
335
  while (changed) {
285
336
  changed = false
@@ -289,22 +340,40 @@ function narrowI32Results(funcs) {
289
340
  if (isBlockBody(body) && hasBareReturn(body)) continue
290
341
  const exprs = returnExprs(body)
291
342
  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
343
+ let r = evalTails(func, body, exprs)
344
+ // Recursive result cycle: a self-call in a return tail — or feeding a returned local
345
+ // (nqueens' `cnt = cnt + solve(); return cnt`) reads solve's own not-yet-narrowed
346
+ // f64 result, so `cnt` widens to f64 and the i32 narrowing never fires. Break the cycle
347
+ // optimistically: tentatively assume the i32 result, re-analyze, and keep it ONLY if every
348
+ // tail is then i32 (else revert). Sound committed only when self-consistent.
349
+ if (!r.allI32 && !r.allV128 && callsSelf(body, func.name)) {
350
+ const saved = func.sig.results
351
+ func.sig.results = ['i32']
352
+ invalidateLocalsCache(body)
353
+ const opt = evalTails(func, body, exprs)
354
+ if (opt.allI32 && (!opt.anyUnsigned || opt.allUnsigned)) {
355
+ if (opt.allUnsigned) func.sig.unsignedResult = true
356
+ changed = true
357
+ continue
358
+ }
359
+ func.sig.results = saved
360
+ invalidateLocalsCache(body)
361
+ r = evalTails(func, body, exprs)
362
+ }
301
363
  // SIMD: every tail returns a lane vector → v128 result.
302
- if (allV128) {
364
+ if (r.allV128) {
303
365
  func.sig.results = ['v128']
304
366
  changed = true
305
- } else if (allI32 && (!anyUnsigned || allUnsigned)) { // sign-consistent i32 tails
367
+ } else if (r.allI32 && (!r.anyUnsigned || r.allUnsigned)) { // sign-consistent i32 tails
306
368
  func.sig.results = ['i32']
307
- if (allUnsigned) func.sig.unsignedResult = true
369
+ if (r.allUnsigned) func.sig.unsignedResult = true
370
+ // A committed i32 result is a genuine NUMBER, so stamp valResult for the call-site
371
+ // VAL dispatch — E2 (narrowValResults) ran ABOVE the param lattice and so couldn't
372
+ // type a `return typedArrayParam[idx]` tail (hashjoin's `probe` → `vals[h]`), leaving
373
+ // valResult unset → the hot `sum + probe()` stayed the polymorphic string-or-number
374
+ // `+`. Only-if-unset: an UNBOXED-pointer i32 result already carries its ARRAY/OBJECT/
375
+ // TYPED valResult (the unboxing ABI needs it), so this never overwrites a pointer kind.
376
+ if (func.valResult == null) func.valResult = VAL.NUMBER
308
377
  changed = true
309
378
  }
310
379
  }
@@ -661,6 +730,7 @@ export default function narrowSignatures(programFacts, ast) {
661
730
  callee, callerFunc, argList, func, restIdx,
662
731
  callerLocals: ctxEntry.callerLocals,
663
732
  callerValTypes: ctxEntry.callerValTypes,
733
+ callerTypedElems: ctxEntry.callerTypedElems,
664
734
  callerParamFacts(key) {
665
735
  if (!paramFacts.has(key)) paramFacts.set(key, paramFactsOf(paramReps, callerFunc, key))
666
736
  return paramFacts.get(key)
@@ -672,10 +742,20 @@ export default function narrowSignatures(programFacts, ast) {
672
742
  const state = siteState(callSites[s])
673
743
  if (!state) continue
674
744
  const { func, argList } = state
745
+ const recursive = state.callee === state.callerFunc?.name
675
746
  for (let k = 0; k < func.sig.params.length; k++) {
676
747
  const r = ensureParamRep(paramReps, state.callee, k)
677
748
  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)
749
+ const arg = argList[k]
750
+ // Recursive identity arg — `f(…, p, …)` calling itself with its own param p threaded
751
+ // through at the same position — is a fixpoint identity: it carries whatever type p
752
+ // settles to, so it constrains nothing. Skip it, else exprType(p) reads p's not-yet-
753
+ // narrowed f64 and the meet poisons the type the non-recursive call sites would prove
754
+ // (nqueens' `solve(all, …)` — `all` stuck f64 while cols/d1/d2, passed as i32 bitwise
755
+ // exprs, narrowed fine).
756
+ const pname = func.sig.params[k].name
757
+ if (recursive && (arg === pname || (Array.isArray(arg) && arg[0] === 'local.get' && arg[1] === pname))) continue
758
+ for (const rule of rules) rule.apply(r, arg, k, state)
679
759
  }
680
760
  }
681
761
  }
@@ -743,6 +823,49 @@ export default function narrowSignatures(programFacts, ast) {
743
823
  mergeParamFact(r, field, v)
744
824
  },
745
825
  })
826
+ // WASM type of a call arg. exprType resolves most shapes, but an INTEGER typed-array
827
+ // element read `intArr[idx]` (and arithmetic over it, `intArr[idx]+1`) types f64 here:
828
+ // exprType's `[]` rule reads the typedElem OVERLAY, which doesn't see a typedCtor-narrowed
829
+ // PARAM array at fixpoint time — yet the element is a 32-bit machine integer. Install the
830
+ // caller's resolved param-typedCtors (+ module globals) as that overlay for the duration
831
+ // of the type query, so a param fed only such integer elements (dict's key `k` ← src[i],
832
+ // threaded through Math.imul / === keys[h] / keys[h]=k) narrows to i32 instead of paying
833
+ // convert + f64-compare + trunc round-trips through its probe loop.
834
+ // A value built ONLY from the callee's own params + already-i32 locals + integer constants via
835
+ // integer-preserving ops. Its i32-ness follows from its inputs' — for a recursive self-call it
836
+ // carries no INDEPENDENT evidence about whether the params are i32. Used for the optimism below.
837
+ const isRecurIntExpr = (n, pnames, callerLocals) => {
838
+ if (typeof n === 'string') return pnames.has(n) || callerLocals?.get?.(n) === 'i32'
839
+ if (typeof n === 'number') return Number.isInteger(n)
840
+ if (!Array.isArray(n)) return false
841
+ if (n[0] == null) return typeof n[1] === 'number' && Number.isInteger(n[1]) // boxed int literal
842
+ if (n[0] === 'local.get') return pnames.has(n[1]) || callerLocals?.get?.(n[1]) === 'i32'
843
+ if (RECUR_INT_OPS.has(n[0])) return n.slice(1).every(c => isRecurIntExpr(c, pnames, callerLocals))
844
+ return false
845
+ }
846
+ const argWasmType = (arg, state) => {
847
+ // Recursive self-call: an arg built only from the callee's own params + already-i32 locals +
848
+ // int constants (`f(n - 1)`, `f(n - 1 - i)`) is i32 IFF those params are i32 — a fixpoint
849
+ // identity carrying no INDEPENDENT type evidence. Optimistically type it i32 so the NON-
850
+ // recursive call sites decide: all i32 ⇒ the param narrows; any f64 ⇒ the meet still poisons
851
+ // it. Lets a plain decreasing recursion narrow with no `|0` source crutch. (The bare-identity
852
+ // arg `f(n)` is already skipped wholesale in runCallsiteLattice.)
853
+ if (state.callee === state.callerFunc?.name &&
854
+ isRecurIntExpr(arg, new Set(state.func.sig.params.map(p => p.name)), state.callerLocals)) return 'i32'
855
+ if (!state._teOverlay) {
856
+ // Caller's typed arrays (locals + non-shadowed globals, precomputed shadow-aware
857
+ // in buildCallerCtx) so a LOCAL `const buf = new Int32Array(…)` makes `buf[i]`
858
+ // type i32 here — not just module globals / typedCtor-narrowed params.
859
+ const m = new Map(state.callerTypedElems || ctx.scope.globalTypedElem || [])
860
+ const pf = state.callerParamFacts('typedCtor')
861
+ if (pf) for (const [name, ctor] of pf) if (ctor != null) m.set(name, ctor)
862
+ state._teOverlay = m
863
+ }
864
+ const prev = ctx.func.localTypedElemsOverlay
865
+ ctx.func.localTypedElemsOverlay = state._teOverlay
866
+ try { return exprType(arg, state.callerLocals) }
867
+ finally { ctx.func.localTypedElemsOverlay = prev }
868
+ }
746
869
  const runFixpoint = () => runCallsiteLattice([
747
870
  // val runs SOFT (monotone): a TYPED param's val only becomes inferable after the
748
871
  // typedCtor fixpoint + pointer-ABI enrichment, so an early hard merge would
@@ -754,7 +877,7 @@ export default function narrowSignatures(programFacts, ast) {
754
877
  missing: poison('wasm'),
755
878
  apply(r, arg, _k, state) {
756
879
  if (r.wasm === null) return
757
- const wt = exprType(arg, state.callerLocals)
880
+ const wt = argWasmType(arg, state)
758
881
  if (r.wasm === undefined) r.wasm = wt
759
882
  else if (r.wasm !== wt) r.wasm = null
760
883
  },
@@ -919,6 +1042,16 @@ export default function narrowSignatures(programFacts, ast) {
919
1042
  // (callback bench: mix is FNV — params and result all i32-shaped, but inferred
920
1043
  // only after E phase narrowed mix's result).
921
1044
  phase.refreshLocals()
1045
+ // I2: Re-narrow i32 RESULTS now that Phase G (applyTypedPointerParamAbi) has tagged
1046
+ // typed-array params ptrKind=TYPED. Phase E ran before G, so a function returning a
1047
+ // typed-array element — dict's `lookup = (keys, vals, k) => { … return vals[h] }` with
1048
+ // vals an Int32Array param — had its return tail type as NaN-boxed f64 (vals not yet a
1049
+ // typed pointer), leaving sig.results f64 and the call site running the full
1050
+ // __typed_idx/ToNumber unbox on every probe step (491520× per dict kernel run). Now that
1051
+ // evalTails seeds the typed-param overlay and params carry ptrAux, the fixpoint catches
1052
+ // `vals[h]` as i32, narrows the result, and the dispatch vanishes; the runFixpoint below
1053
+ // then propagates the i32 result into `let v = lookup(...)` at the call sites.
1054
+ narrowI32Results(funcsWithNarrowableResult)
922
1055
  // Reset wasm field unconditionally — first pass populated it from stale callerLocals
923
1056
  // (where `let h = mix(...)` widened h to f64 because mix's result wasn't narrowed
924
1057
  // yet). clearStickyNull only resets null; here we need to reset f64-observed too
@@ -1334,25 +1467,37 @@ export function specializeBimorphicTyped(programFacts) {
1334
1467
 
1335
1468
  // Build one clone per distinct combo.
1336
1469
  const cloneByKey = new Map()
1337
- for (const [key, combo] of distinct) {
1338
- const suffix = combo.map(c => c.replace(/^new\./, '').replace(/\./g, '_')).join('$')
1470
+ for (const [dkey, cmb] of distinct) {
1471
+ // NB: this loop variable must NOT reuse the name `combo` (declared twice above, at the
1472
+ // site loop and the distinct-building loop). The self-host miscompiles a for-of loop
1473
+ // variable whose name collides with an earlier block-scoped declaration — it aliases the
1474
+ // prior binding instead of rebinding per iteration, so `combo` would stay stuck at the
1475
+ // last site's ctor and every clone would get the same (wrong) element type → silent
1476
+ // garbage. A unique name gets a clean per-iteration binding.
1477
+ const suffix = cmb.map(c => c.replace(/^new\./, '').replace(/\./g, '_')).join('$')
1339
1478
  let cloneName = `${func.name}$${suffix}`
1340
1479
  let n = 0
1341
1480
  while (ctx.func.names.has(cloneName)) cloneName = `${func.name}$${suffix}$${++n}`
1342
1481
 
1482
+ // Build cloneSig with clean, fully-formed object literals — never by spreading a
1483
+ // live object and then overriding/extending its keys. A MULTI-prop spread of a
1484
+ // member-access source (`{ ...func.sig, params, results }`) takes the static
1485
+ // allKnown OBJECT-merge path, which trusts func.sig's COMPILE-TIME schema; sig
1486
+ // objects are polymorphic (some carry result/ptrKind/unsignedResult), so that
1487
+ // schema can be a subset of the runtime shape and the slot-copy then faults a
1488
+ // later `sig.params` read out of bounds in the self-host. (The single-unknown
1489
+ // `{ ...x }` clone is fixed at the root — __obj_clone — but the allKnown merge
1490
+ // path is a separate hazard.) Constructing each param with its pointer ABI baked
1491
+ // in sidesteps it; output is unchanged on the host.
1343
1492
  const cloneSig = {
1344
- ...func.sig,
1345
- params: func.sig.params.map(p => ({ ...p })),
1493
+ params: func.sig.params.map((p, idx) => {
1494
+ const bi = bimorphic.indexOf(idx)
1495
+ return bi < 0
1496
+ ? { ...p }
1497
+ : { name: p.name, type: 'i32', ptrKind: VAL.TYPED, ptrAux: typedElemAux(cmb[bi]) }
1498
+ }),
1346
1499
  results: [...func.sig.results],
1347
1500
  }
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
1501
  const clone = { ...func, name: cloneName, sig: cloneSig }
1357
1502
  ctx.func.list.push(clone)
1358
1503
  ctx.func.map.set(cloneName, clone)
@@ -1360,19 +1505,20 @@ export function specializeBimorphicTyped(programFacts) {
1360
1505
 
1361
1506
  // Mirror per-param reps under the clone's name with mono ctors at bimorphic
1362
1507
  // positions. emitFunc's preseed reads typedCtor → seeds typedElem map →
1363
- // `arr[i]` lowers to direct typed load.
1508
+ // `arr[i]` lowers to direct typed load. Each `{ ...r }` is a true clone, so
1509
+ // pinning typedCtor on it leaves the source rep untouched (__obj_clone).
1364
1510
  const cloneReps = new Map()
1365
1511
  for (const [k, r] of reps) cloneReps.set(k, { ...r })
1366
1512
  for (let i = 0; i < bimorphic.length; i++) {
1367
1513
  const k = bimorphic[i]
1368
1514
  const r = cloneReps.get(k) || {}
1369
- r.typedCtor = combo[i]
1515
+ r.typedCtor = cmb[i]
1370
1516
  r.val = VAL.TYPED
1371
1517
  cloneReps.set(k, r)
1372
1518
  }
1373
1519
  paramReps.set(cloneName, cloneReps)
1374
1520
 
1375
- cloneByKey.set(key, clone)
1521
+ cloneByKey.set(dkey, clone)
1376
1522
  }
1377
1523
 
1378
1524
  // Rewrite each site's call AST to point at the matching clone.