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
@@ -39,12 +39,25 @@ import {
39
39
  // expression — null if void or no trailing return value.
40
40
  const inlinedBody = (func, args) => {
41
41
  const params = func.sig.params
42
- if (args.length !== params.length || !args.every(isSimpleArg)) return null
42
+ if (args.length !== params.length) return null
43
43
  const paramNames = new Set(params.map(p => p.name))
44
44
  if (mutatesAny(func.body, paramNames)) return null
45
45
 
46
+ // A simple arg (ident / literal / arithmetic) is cheap to substitute directly, even when its
47
+ // param is used several times. A NON-simple arg (a call, `?:`, indexed load) is bound to a fresh
48
+ // temp evaluated ONCE in call order — preserving evaluation count + left-to-right order, and
49
+ // never duplicating the expression. This lets nested calls inline: `lerp(grad(a), grad(b), u)`
50
+ // binds `t0 = grad(a); t1 = grad(b)` and substitutes the body with t0/t1; a later inliner pass
51
+ // then folds grad into those temp decls (the fixpoint in inlineHotInternalCalls).
46
52
  const subst = new Map()
47
- for (let i = 0; i < params.length; i++) subst.set(params[i].name, args[i])
53
+ const argPrefix = []
54
+ for (let i = 0; i < params.length; i++) {
55
+ const arg = args[i]
56
+ if (isSimpleArg(arg)) { subst.set(params[i].name, arg); continue }
57
+ const tmp = `${T}inarg${ctx.func.uniq++}`
58
+ argPrefix.push(['const', ['=', tmp, arg]])
59
+ subst.set(params[i].name, tmp)
60
+ }
48
61
 
49
62
  const locals = new Set()
50
63
  collectBindings(func.body, locals)
@@ -56,13 +69,13 @@ const inlinedBody = (func, args) => {
56
69
  const stmts = blockStmts(func.body)
57
70
  // Expression-bodied arrow `(c) => expr`: no statement block; the whole body
58
71
  // *is* the return value. Treat as zero-prefix + value.
59
- if (!stmts) return { prefix: [], value: cloneWithSubst(func.body, subst, rename) }
72
+ if (!stmts) return { prefix: argPrefix, value: cloneWithSubst(func.body, subst, rename) }
60
73
  const last = stmts.length ? stmts[stmts.length - 1] : null
61
74
  const isTrailingReturn = Array.isArray(last) && last[0] === 'return'
62
75
  const prefixSrc = isTrailingReturn ? stmts.slice(0, -1) : stmts
63
76
  const prefix = prefixSrc.map(stmt => cloneWithSubst(stmt, subst, rename))
64
77
  const value = isTrailingReturn && last.length > 1 ? cloneWithSubst(last[1], subst, rename) : null
65
- return { prefix, value }
78
+ return { prefix: argPrefix.length ? [...argPrefix, ...prefix] : prefix, value }
66
79
  }
67
80
 
68
81
  const stmtDeclName = (stmt) => {
@@ -109,7 +122,14 @@ const isCandidateCall = (node, candidates) =>
109
122
  // arithmetic>`, substituting the decls into the return value turns it into a
110
123
  // zero-prefix expression. Duplicated subtrees (`dx` used twice → `x1-x2`
111
124
  // twice) are pure, and the watr-layer CSE collapses the copies.
112
- const PURE_FLATTEN_OPS = new Set(['+', '-', '*', '/', '%', 'u-', 'u+', '&', '|', '^', '<<', '>>', '>>>'])
125
+ const PURE_FLATTEN_OPS = new Set([
126
+ '+', '-', '*', '/', '%', 'u-', 'u+', '&', '|', '^', '<<', '>>', '>>>',
127
+ // pure value-producing ops with no side effects — safe to duplicate (CSE collapses copies):
128
+ // comparisons, logical, bit-not, and the conditional. Lets a branchy leaf like noise's
129
+ // `grad(h,x,y) => { …; u = (h&1)===0 ? x : -x; … }` flatten to an expression so it inlines
130
+ // into its multi-call caller `perlin` — `lerp(grad(a), grad(b), u)` then collapses end to end.
131
+ '<', '<=', '>', '>=', '==', '!=', '===', '!==', '&&', '||', '!', '~', '?:',
132
+ ])
113
133
  const pureFlattenExpr = (n) => {
114
134
  if (typeof n === 'number' || typeof n === 'string') return true // literal or ident
115
135
  if (!Array.isArray(n)) return false
@@ -163,13 +183,19 @@ const inlineInExpr = (node, candidates) => {
163
183
 
164
184
  const inlineInStmt = (stmt, candidates, loopVariantNames = null) => {
165
185
  if (!Array.isArray(stmt)) return { node: stmt, changed: false }
166
- // Statement-position call: discard return value, splice prefix in place.
186
+ // Statement-position call: the result is unused, but the callee's return
187
+ // EXPRESSION may still carry side effects — an expression-bodied arrow whose body
188
+ // is itself effectful (`seek = n => idx = n` inlines to the assignment `idx = i`;
189
+ // a one-liner that calls another fn) puts the effect in `value`, not `prefix`.
190
+ // Emit it as a trailing statement so the effect runs; a pure value is dropped
191
+ // later by vacuum/DCE. (Dropping it lost the parser's seek() idx-advance → ∞ loop.)
167
192
  if (isCandidateCall(stmt, candidates)) {
168
193
  const args = callArgs(stmt)
169
194
  const shape = args && inlinedBody(candidates.get(stmt[1]), args)
170
195
  if (shape) {
171
196
  const { hoisted, rest } = partitionInvariantPrefix(shape.prefix, loopVariantNames)
172
- return { node: ['{}', [';', ...rest]], changed: true, splice: rest, hoisted }
197
+ const splice = shape.value !== null ? [...rest, shape.value] : rest
198
+ return { node: ['{}', [';', ...splice]], changed: true, splice, hoisted }
173
199
  }
174
200
  }
175
201
  // `let/const X = call(...)` with single decl: inline as prefix + decl(value).
@@ -259,9 +285,99 @@ const inlineInStmt = (stmt, candidates, loopVariantNames = null) => {
259
285
  return { node: stmt, changed: false }
260
286
  }
261
287
 
288
+ // Short-circuit operators: only the FIRST operand is unconditionally evaluated; a call in
289
+ // a later operand might not run, so it can't be hoisted.
290
+ const SHORT_CIRCUIT = new Set(['?:', '?', '&&', '||', '??'])
291
+ // Optional chaining: jz's own desugaring already tees the base to evaluate it once, and
292
+ // the key/args run conditionally — so the hoist treats the WHOLE expression as opaque (no
293
+ // operand, not even the base, is hoisted out) to avoid colliding with that desugaring.
294
+ const OPTIONAL_CHAIN = new Set(['?.', '?.[]', '?.()'])
295
+ // Mutating expression operators — evaluating one is an observable side effect.
296
+ const ASSIGN_OPS = new Set(['=', '+=', '-=', '*=', '/=', '%=', '&=', '|=', '^=', '<<=', '>>=', '>>>=', '**=', '&&=', '||=', '??=', '++', '--'])
297
+ // Does evaluating this expression have an observable side effect (a call or assignment)?
298
+ const containsEffect = (n) => Array.isArray(n) && n[0] !== '=>' &&
299
+ (n[0] === '()' || n[0] === '?.()' || ASSIGN_OPS.has(n[0]) || n.slice(1).some(containsEffect))
300
+
301
+ // Hoist an unconditionally-evaluated NESTED call to a block-body candidate out to a
302
+ // preceding `const __h = call(...)` temp. inlineInStmt folds block-body candidates only at
303
+ // a DIRECT `const X = call` / `X = call`; a call buried in an expression (noise's
304
+ // `sum = sum + amp * perlin(x)`) is reached by neither that path nor inlineInExpr (which
305
+ // only substitutes zero-prefix expr-bodies). Hoisting normalizes it to the direct form.
306
+ // Only block-body candidates and only unconditional positions — preserving evaluation order
307
+ // + count. Statement HEADERS that are expression positions (for-init/update, while/if test)
308
+ // are left untouched: there's no sound place for a hoisted decl there, so those calls just
309
+ // stay outlined. Conservatively leaves unrecognized statement shapes alone.
310
+ const hoistNestedCalls = (body, blockNames) => {
311
+ if (!blockNames.size || !Array.isArray(body)) return { node: body, changed: false }
312
+ let changed = false
313
+ const seq = (stmts) => stmts.length === 1 ? stmts[0] : [';', ...stmts]
314
+ // Lifting a call to the pre-decl block moves its evaluation to the TOP of the statement.
315
+ // Sound only if no observable side effect is evaluated BEFORE it — else its effect jumps
316
+ // ahead of that one (`a() + helper(x)` must keep a()'s effect first). `eff.seen` threads
317
+ // left-to-right through evaluation order: a call or assignment LEFT IN PLACE marks every
318
+ // later position. A hoisted call moves as a unit — its args run in a fresh inner eff, and
319
+ // it does NOT advance the outer eff (the whole unit relocates together, order intact).
320
+ const hExpr = (n, pre, cond, eff) => {
321
+ if (!Array.isArray(n) || n[0] === '=>') return n
322
+ if (!cond && !eff.seen && n[0] === '()' && typeof n[1] === 'string' && blockNames.has(n[1])) {
323
+ const call = [n[0], n[1], ...n.slice(2).map(a => hExpr(a, pre, false, { seen: false }))]
324
+ const tmp = `${T}inl${ctx.func.uniq++}_h`
325
+ pre.push(['const', ['=', tmp, call]])
326
+ changed = true
327
+ return [null, tmp]
328
+ }
329
+ if (OPTIONAL_CHAIN.has(n[0])) {
330
+ const out = [n[0], ...n.slice(1).map(c => hExpr(c, pre, true, eff))]
331
+ if (n[0] === '?.()') eff.seen = true // optional CALL may run
332
+ return out
333
+ }
334
+ if (SHORT_CIRCUIT.has(n[0]))
335
+ return [n[0], hExpr(n[1], pre, cond, eff), ...n.slice(2).map(c => hExpr(c, pre, true, eff))]
336
+ const out = [n[0], ...n.slice(1).map(c => hExpr(c, pre, cond, eff))]
337
+ if (n[0] === '()' || ASSIGN_OPS.has(n[0])) eff.seen = true // a call/assign left in place is an effect
338
+ return out
339
+ }
340
+ // A RHS that is DIRECTLY a candidate call is already folded by inlineInStmt's
341
+ // `const X = call` / `X = call` paths — hoisting it would be redundant and (for an
342
+ // object/array-literal `{}`-bodied factory) would break the post-inline alias chain.
343
+ // Only hoist NESTED calls; leave a top-level direct call to those paths.
344
+ const directCall = (e) => Array.isArray(e) && e[0] === '()' && typeof e[1] === 'string' && blockNames.has(e[1])
345
+ const hStmt = (s) => { // → array of statements (hoisted decls prepended)
346
+ if (!Array.isArray(s)) return [s]
347
+ switch (s[0]) {
348
+ case ';': return [[';', ...s.slice(1).flatMap(hStmt)]]
349
+ case '{}': return [['{}', seq(hStmt(s[1]))]]
350
+ case 'if': return [s.length > 3
351
+ ? ['if', s[1], seq(hStmt(s[2])), seq(hStmt(s[3]))]
352
+ : ['if', s[1], seq(hStmt(s[2]))]]
353
+ case 'for': { const i = forLoopBodyIndex(s); return [withForLoopBody(s, seq(hStmt(s[i])))] }
354
+ case 'while': return [['while', s[1], seq(hStmt(s[2]))]]
355
+ case 'let': case 'const': {
356
+ if (s.length === 2 && Array.isArray(s[1]) && s[1][0] === '=' && typeof s[1][1] === 'string' && !directCall(s[1][2])) {
357
+ const pre = []; const rhs = hExpr(s[1][2], pre, false, { seen: false })
358
+ return pre.length ? [...pre, [s[0], ['=', s[1][1], rhs]]] : [s]
359
+ }
360
+ return [s]
361
+ }
362
+ // A computed assign target (`a[i]=…`) evaluates its index BEFORE the RHS, so an effect
363
+ // there (`a[j++]=…`) must block hoisting too — seed eff.seen from the LHS.
364
+ case '=': { if (directCall(s[2])) return [s]; const pre = []; const rhs = hExpr(s[2], pre, false, { seen: containsEffect(s[1]) }); return pre.length ? [...pre, ['=', s[1], rhs]] : [s] }
365
+ case 'return': { if (s.length < 2 || directCall(s[1])) return [s]; const pre = []; const v = hExpr(s[1], pre, false, { seen: false }); return pre.length ? [...pre, ['return', v]] : [s] }
366
+ default: return [s] // unrecognized shape (break/continue/throw/try/switch): leave alone
367
+ }
368
+ }
369
+ const out = hStmt(body)
370
+ return { node: changed ? seq(out) : body, changed }
371
+ }
372
+
262
373
  export const inlineHotInternalCalls = (programFacts, ast) => {
263
374
  const cfg = ctx.transform.optimize
264
375
  if (cfg && cfg.sourceInline === false) return false
376
+ // Transitive candidacy + expression-position hoisting are a size↔speed trade (they
377
+ // pull a large multi-call leaf like noise's perlin fully into its hot caller, where
378
+ // the lower tiers prefer to keep multi-caller helpers outlined for V8 tier-up). Gate
379
+ // both on the speed tier so levels ≤2 keep their conservative inlining policy.
380
+ const speedTier = !!(cfg && cfg.inlineFns)
265
381
 
266
382
  const fixedByFunc = new Map(ctx.func.list.map(func => [func, fixedTypedArraysInBody(func.body)]))
267
383
  const typedByFunc = new Map(ctx.func.list.map(func => [func, analyzeBody(func.body).typedElems]))
@@ -315,7 +431,14 @@ export const inlineHotInternalCalls = (programFacts, ast) => {
315
431
  // entry points, not pulling a leaf INTO an export's hot loop (game-of-life's
316
432
  // step calls rot per cell; pre-Turboshaft wasm tiers never inline calls).
317
433
  const leaves = new Set()
434
+ // Transitive candidacy via fixpoint: a function whose only user-callees are THEMSELVES
435
+ // candidates (so they inline away) can be inlined too. noise's `perlin` calls grad/fade/
436
+ // lerp (loop-free leaves) — once those are candidates, perlin clears the call-bearing-body
437
+ // gate and becomes a leaf candidate. Each pass adds ≥1 or stops, so it's bounded.
438
+ for (let recollect = true; recollect;) {
439
+ recollect = false
318
440
  for (const func of ctx.func.list) {
441
+ if (candidates.has(func.name)) continue
319
442
  const sites = sitesByCallee.get(func.name)
320
443
  // Exported leaf/kernel with exactly one internal caller (e.g. fill→beat in
321
444
  // floatbeat): inline into the caller's loop but keep the export for external
@@ -336,7 +459,13 @@ export const inlineHotInternalCalls = (programFacts, ast) => {
336
459
  const fullyFixedTypedArraySite = hasFullyFixedTypedArraySites(func, sites)
337
460
  const hasLoop = some(func.body, n => LOOP_OPS.has(n[0]))
338
461
  const isTinyLeaf = !hasLoop && nodeSize(func.body) <= 15
339
- if (!sites || sites.length < 1 || (!isTinyLeaf && !fixedTypedArraySite && sites.length > 2) || sites.length > 8) continue
462
+ // A small leaf (no loop, ≤40 nodes) is cheap to splice even when called several times its
463
+ // per-call overhead + lost cross-call fusion dwarfs the ≤8× duplication, and temp-binding +
464
+ // flattenPrefix keep the spliced body bounded (no arg re-evaluation, CSE collapses copies).
465
+ // The 2-site non-tiny-leaf cap would otherwise outline a hot helper like noise's `grad`
466
+ // (~30 nodes, called 4× from perlin) and freeze the call overhead per pixel.
467
+ const isSmallLeaf = !hasLoop && nodeSize(func.body) <= 48
468
+ if (!sites || sites.length < 1 || (!isTinyLeaf && !isSmallLeaf && !fixedTypedArraySite && sites.length > 2) || sites.length > 8) continue
340
469
  const stmts = blockStmts(func.body)
341
470
  // Expression-bodied arrow funcs (`(c) => expr`) have no block — body IS the
342
471
  // return value. Treat as a "tiny leaf" branch handled below; force hasLoop=false.
@@ -356,7 +485,10 @@ export const inlineHotInternalCalls = (programFacts, ast) => {
356
485
  // that get hammered from a hot caller's loop — replacing the call with its
357
486
  // body saves the per-iteration call+reinterpret overhead (tokenizer hot path).
358
487
  if (!hasLoop) {
359
- if (some(func.body, n => n[0] === '()' && typeof n[1] === 'string' && ctx.func.names.has(n[1]))) continue
488
+ // Calls to functions that are THEMSELVES candidates are fine they inline away;
489
+ // only a call to a non-candidate user function blocks (a later fixpoint pass re-checks).
490
+ // Speed-tier only; lower tiers keep the strict "any user call ⇒ outline" rule.
491
+ if (some(func.body, n => n[0] === '()' && typeof n[1] === 'string' && ctx.func.names.has(n[1]) && !(speedTier && candidates.has(n[1])))) continue
360
492
  // Per-iteration call overhead dwarfs body-size bloat when EVERY site sits
361
493
  // inside a caller's loop (game-of-life's rot: ~40 nodes × 2 sites, fired
362
494
  // for most of 260k cells/frame; cloth's relax: ~160 nodes × 2 sites, fired
@@ -367,7 +499,10 @@ export const inlineHotInternalCalls = (programFacts, ast) => {
367
499
  // to ≤2 sites, so the spliced duplication is at most ~2× a bounded body.
368
500
  const allSitesInLoop = sites.every(site =>
369
501
  site.callerFunc?.body && containsNode(site.callerFunc.body, site.node, false))
370
- if (nodeSize(func.body) > (allSitesInLoop ? 200 : 30)) continue
502
+ // Non-in-loop cap is 40 (not 30) so a small leaf called from a straight-line but
503
+ // transitively-hot caller still inlines (noise's grad is called from perlin, which has
504
+ // no loop of its own but is itself the per-pixel kernel). Still tightly bounded.
505
+ if (nodeSize(func.body) > (allSitesInLoop ? 200 : 48)) continue
371
506
  }
372
507
  if (some(func.body, n => n[0] === '()' && n[1] === func.name)) continue
373
508
  // Kernels with nested loops (depth ≥ 2) are typically large and the inner
@@ -386,6 +521,8 @@ export const inlineHotInternalCalls = (programFacts, ast) => {
386
521
  forwarders.add(func.name)
387
522
  if (!hasLoop) leaves.add(func.name)
388
523
  candidates.set(func.name, func)
524
+ if (speedTier) recollect = true // only the speed-tier transitive relaxation needs a re-pass
525
+ }
389
526
  }
390
527
  if (!candidates.size) return false
391
528
 
@@ -436,20 +573,38 @@ export const inlineHotInternalCalls = (programFacts, ast) => {
436
573
  // Route those through inlineInExpr so the call is replaced by the inlined
437
574
  // value expression instead.
438
575
  const isExprBody = !Array.isArray(func.body) || func.body[0] !== '{}'
439
- const r = isExprBody
440
- ? inlineInExpr(func.body, activeCandidates)
441
- : inlineInStmt(func.body, activeCandidates)
442
- let body = r.changed ? r.node : func.body
443
- let bodyChanged = r.changed
444
- // Expression-position pass. Exported callers take the leaf-safe subset —
445
- // the same tier-up rationale as the statement path (leaves into exports
446
- // are fine; relocated kernels are not).
576
+ // Expression-position pass takes the leaf-safe subset for exports — the same tier-up
577
+ // rationale as the statement path (leaves into exports are fine; relocated kernels are not).
447
578
  const exprActive = func.exported
448
579
  ? new Map([...exprOnlyCandidates].filter(([n]) => exportedCandidates.has(n)))
449
580
  : exprOnlyCandidates
450
- if (exprActive.size) {
451
- const e = inlineInExpr(body, exprActive)
452
- if (e.changed) { body = e.node; bodyChanged = true }
581
+ // Iterate to a (bounded) fixpoint: inlining a call whose args are themselves candidate calls
582
+ // binds those args to temps (`t0 = grad(a)`); the next pass folds the candidate into the temp
583
+ // decl. Depth is bounded by call nesting (a small constant), capped here so a pathological
584
+ // chain can't loop unbounded.
585
+ // Loop-free block-body LEAVES: the stmt path folds them only at a DIRECT `const X =
586
+ // call`, never nested in an expression. Hoisting such a call to a temp (in the iter
587
+ // fixpoint below) lets inlineInStmt then fold it — the noise `sum + amp*perlin(x)`
588
+ // shape. Restricted to LEAVES (no own loop): a loop kernel called in expression
589
+ // position (e.g. a 2-site `reduce`) was deliberately staying outlined for V8 tier-up,
590
+ // and hoisting it would pull the loop into a cold caller.
591
+ const blockNames = new Set()
592
+ for (const n of activeCandidates.keys()) if (leaves.has(n) && !exprActive.has(n)) blockNames.add(n)
593
+ let body = func.body, bodyChanged = false
594
+ for (let iter = 0; iter < 4; iter++) {
595
+ let iterChanged = false
596
+ if (speedTier && !isExprBody && blockNames.size) {
597
+ const h = hoistNestedCalls(body, blockNames)
598
+ if (h.changed) { body = h.node; iterChanged = true }
599
+ }
600
+ const r = isExprBody ? inlineInExpr(body, activeCandidates) : inlineInStmt(body, activeCandidates)
601
+ if (r.changed) { body = r.node; iterChanged = true }
602
+ if (exprActive.size) {
603
+ const e = inlineInExpr(body, exprActive)
604
+ if (e.changed) { body = e.node; iterChanged = true }
605
+ }
606
+ if (!iterChanged) break
607
+ bodyChanged = true
453
608
  }
454
609
  if (bodyChanged) { func.body = body; changed = true }
455
610
  }
@@ -28,7 +28,7 @@ import {
28
28
  some, T, stmtList, refsName, REFS_IN_EXPR, ASSIGN_OPS, isReassigned, hasControlTransfer,
29
29
  } from '../../ast.js'
30
30
  import {
31
- intLiteralValue, constIntExpr, staticObjectProps, staticPropertyKey,
31
+ intLiteralValue, nonNegIntLiteral, constIntExpr, staticObjectProps, staticPropertyKey,
32
32
  } from '../../static.js'
33
33
  import {
34
34
  smallConstForTripCount, containsDeclOf, cloneWithSubst,
@@ -37,7 +37,7 @@ import { VAL } from '../../reps.js'
37
37
  import { includeModule } from '../../autoload.js'
38
38
  import { analyzeBody, invalidateLocalsCache } from '../analyze.js'
39
39
  import {
40
- isSimpleArg, fixedScalarTypedArray, fixedTypedArraysInBody, maxScalarTypedArrayLen,
40
+ isSimpleArg, fixedScalarTypedArray, fixedTypedArraysInBody, maxScalarTypedArrayLen, freshTypedArrayLocals,
41
41
  } from './common.js'
42
42
 
43
43
  // === Loop unrolling & scalarization ===
@@ -526,6 +526,44 @@ export const scalarizeFunctionTypedArrays = (programFacts) => {
526
526
  return changed
527
527
  }
528
528
 
529
+ // Param-distinctness (alias analysis). Marks a function's typed-array params MUTUALLY DISTINCT
530
+ // (provably different buffers) when EVERY call site passes a distinct fresh `new TypedArray` local
531
+ // for each of them. This is what lets the optimizer's LICM hoist a load from one such param across
532
+ // a store to another (the load can't be clobbered) — the alias-analysis-enabled LICM that
533
+ // rust/clang get for free (raytrace's sphere loads vs the framebuffer store). Sound because:
534
+ // • a fresh `new TypedArray(N)` is a unique buffer (the allocator returns fresh memory);
535
+ // • requiring ALL typed-array-param args to be fresh-new excludes views/subarrays (not fresh)
536
+ // and forwarded params (not fresh), the only ways two args could alias;
537
+ // • pairwise-distinct arg names rule out the same buffer passed twice;
538
+ // • scalar (non-TYPED) params are ignored — they can't alias a buffer.
539
+ // Conservatively all-or-nothing per function: any non-fresh/duplicate typed arg ⇒ no fact.
540
+ export const analyzeParamDistinctness = (programFacts) => {
541
+ const freshByFunc = new Map(ctx.func.list.map(func => [func, freshTypedArrayLocals(func.body)]))
542
+ const sitesByCallee = new Map()
543
+ for (const site of programFacts.callSites) {
544
+ if (!site.callerFunc) continue
545
+ const l = sitesByCallee.get(site.callee); l ? l.push(site) : sitesByCallee.set(site.callee, [site])
546
+ }
547
+ for (const func of ctx.func.list) {
548
+ const params = func.sig?.params, sites = sitesByCallee.get(func.name)
549
+ if (!params || !sites?.length) continue
550
+ const typedIdx = []
551
+ for (let i = 0; i < params.length; i++) if (params[i].ptrKind === VAL.TYPED) typedIdx.push(i)
552
+ if (typedIdx.length < 2) continue // distinctness only matters with ≥2 typed-array params
553
+ let ok = true
554
+ for (const site of sites) {
555
+ const seen = new Set()
556
+ for (const i of typedIdx) {
557
+ const arg = site.argList?.[i]
558
+ if (typeof arg !== 'string' || !freshByFunc.get(site.callerFunc)?.has(arg) || seen.has(arg)) { ok = false; break }
559
+ seen.add(arg)
560
+ }
561
+ if (!ok) break
562
+ }
563
+ if (ok) func.distinctParams = new Set(typedIdx.map(i => params[i].name))
564
+ }
565
+ }
566
+
529
567
  const scalarizeArrayLiteralSeq = (seq) => {
530
568
  if (!Array.isArray(seq) || seq[0] !== ';') return { node: seq, changed: false }
531
569
  let changed = false
@@ -940,13 +978,41 @@ const _intArrayLitElems = (expr) => {
940
978
  return out
941
979
  }
942
980
 
981
+ // Arithmetic and bitwise operators that always produce a numeric result —
982
+ // regardless of operand types — so `name[expr]` is index-safe after promotion.
983
+ // Bitwise ops (`&`, `|`, etc.) ToInt32 their operands; pure-arithmetic ops with
984
+ // numeric leaves stay numeric. `+` is excluded: `"a" + "b"` is a string.
985
+ const _NUMERIC_INDEX_OPS = new Set(['-', '*', '/', '%', '&', '|', '^', '<<', '>>', '>>>', 'u-', 'u+'])
986
+
987
+ // Returns true when `key` is provably a numeric index at plan time: an integer
988
+ // literal, a local name whose val-type is VAL.NUMBER in `valTypes`, or a
989
+ // compound expression that always produces a number (arithmetic/bitwise ops).
990
+ // Mirrors the `idxNumericName` / `intIndexIR` guard in emit so that the same
991
+ // index shapes that skip `__is_str_key` at emit-time also pass here. Used by
992
+ // `_disqualifyPromotion` to gate index reads: a non-numeric key on a promoted
993
+ // Int32Array NaN-coerces to 0 (trunc_sat_f64_s(NaN) = 0) instead of returning
994
+ // undefined — the correct JS behaviour for an out-of-range or string index.
995
+ const _isNumericKey = (key, valTypes) => {
996
+ if (key == null) return false
997
+ if (nonNegIntLiteral(key) != null) return true // literal integer
998
+ if (typeof key === 'string') return valTypes?.get(key) === VAL.NUMBER
999
+ if (!Array.isArray(key)) return false
1000
+ const op = key[0]
1001
+ if (op == null) return typeof key[1] === 'number' // [null, n] literal
1002
+ if (_NUMERIC_INDEX_OPS.has(op)) return true // always produces Number
1003
+ // `+` is numeric only when both operands are proven numeric.
1004
+ if (op === '+' && key.length === 3)
1005
+ return _isNumericKey(key[1], valTypes) && _isNumericKey(key[2], valTypes)
1006
+ return false
1007
+ }
1008
+
943
1009
  // Walks `node` and disqualifies every candidate name that appears in an
944
1010
  // unsafe context. `initSet` holds the candidate's own init-decl AST nodes
945
1011
  // (their LHS reference is the binding being defined, not an escape).
946
- const _disqualifyPromotion = (node, candidates, disqualified, initSet) => {
1012
+ const _disqualifyPromotion = (node, candidates, disqualified, initSet, valTypes) => {
947
1013
  if (initSet.has(node)) {
948
1014
  // The init decl itself: only walk the RHS (skip the LHS `name`).
949
- return _disqualifyPromotion(node[2], candidates, disqualified, initSet)
1015
+ return _disqualifyPromotion(node[2], candidates, disqualified, initSet, valTypes)
950
1016
  }
951
1017
  if (typeof node === 'string') {
952
1018
  // Bare identifier outside any handled parent context — escape.
@@ -972,7 +1038,7 @@ const _disqualifyPromotion = (node, candidates, disqualified, initSet) => {
972
1038
  Array.isArray(node[1]) && (node[1][0] === '.' || node[1][0] === '?.') &&
973
1039
  typeof node[1][1] === 'string' && candidates.has(node[1][1])) {
974
1040
  disqualified.add(node[1][1])
975
- for (let i = 2; i < node.length; i++) _disqualifyPromotion(node[i], candidates, disqualified, initSet)
1041
+ for (let i = 2; i < node.length; i++) _disqualifyPromotion(node[i], candidates, disqualified, initSet, valTypes)
976
1042
  return
977
1043
  }
978
1044
 
@@ -994,7 +1060,7 @@ const _disqualifyPromotion = (node, candidates, disqualified, initSet) => {
994
1060
  typeof callee[1] === 'string' && candidates.has(callee[1])) {
995
1061
  if (!_TYPED_SAFE_METHODS.has(callee[2])) disqualified.add(callee[1])
996
1062
  // Walk method args (skip the receiver — already validated above).
997
- for (let i = 2; i < node.length; i++) _disqualifyPromotion(node[i], candidates, disqualified, initSet)
1063
+ for (let i = 2; i < node.length; i++) _disqualifyPromotion(node[i], candidates, disqualified, initSet, valTypes)
998
1064
  return
999
1065
  }
1000
1066
  // Array.isArray flips true→false under promotion.
@@ -1003,7 +1069,7 @@ const _disqualifyPromotion = (node, candidates, disqualified, initSet) => {
1003
1069
  const list = raw == null ? [] : (Array.isArray(raw) && raw[0] === ',') ? raw.slice(1) : [raw]
1004
1070
  for (const a of list) {
1005
1071
  if (typeof a === 'string' && candidates.has(a)) disqualified.add(a)
1006
- else _disqualifyPromotion(a, candidates, disqualified, initSet)
1072
+ else _disqualifyPromotion(a, candidates, disqualified, initSet, valTypes)
1007
1073
  }
1008
1074
  return
1009
1075
  }
@@ -1011,10 +1077,15 @@ const _disqualifyPromotion = (node, candidates, disqualified, initSet) => {
1011
1077
  // bare-name leaf above and disqualify.
1012
1078
  }
1013
1079
 
1014
- // Index read `name[k]` — read access is TYPED-safe. Walk the key in case
1015
- // it contains references to other candidate names.
1080
+ // Index read `name[k]` — TYPED-safe only when the key is provably numeric.
1081
+ // A string or unknown key on a promoted Int32Array would NaN-coerce to 0
1082
+ // (i32.trunc_sat_f64_s(NaN) = 0) instead of returning undefined — silently
1083
+ // wrong. Mirror the `idxNumericName` / `intIndexIR` guard in emit: disqualify
1084
+ // the candidate unless `k` is an integer literal, a VAL.NUMBER local, or an
1085
+ // expression that always produces a Number (bitwise/arithmetic ops).
1016
1086
  if (op === '[]' && typeof node[1] === 'string' && candidates.has(node[1])) {
1017
- _disqualifyPromotion(node[2], candidates, disqualified, initSet)
1087
+ _disqualifyPromotion(node[2], candidates, disqualified, initSet, valTypes)
1088
+ if (!_isNumericKey(node[2], valTypes)) disqualified.add(node[1])
1018
1089
  return
1019
1090
  }
1020
1091
 
@@ -1023,15 +1094,15 @@ const _disqualifyPromotion = (node, candidates, disqualified, initSet) => {
1023
1094
  if (ASSIGN_OPS.has(op) && Array.isArray(node[1]) && node[1][0] === '[]' &&
1024
1095
  typeof node[1][1] === 'string' && candidates.has(node[1][1])) {
1025
1096
  disqualified.add(node[1][1])
1026
- _disqualifyPromotion(node[1][2], candidates, disqualified, initSet)
1027
- _disqualifyPromotion(node[2], candidates, disqualified, initSet)
1097
+ _disqualifyPromotion(node[1][2], candidates, disqualified, initSet, valTypes)
1098
+ _disqualifyPromotion(node[2], candidates, disqualified, initSet, valTypes)
1028
1099
  return
1029
1100
  }
1030
1101
 
1031
1102
  // Whole-binding reassign: `name = …` / `name += …` / etc.
1032
1103
  if (ASSIGN_OPS.has(op) && typeof node[1] === 'string' && candidates.has(node[1])) {
1033
1104
  disqualified.add(node[1])
1034
- _disqualifyPromotion(node[2], candidates, disqualified, initSet)
1105
+ _disqualifyPromotion(node[2], candidates, disqualified, initSet, valTypes)
1035
1106
  return
1036
1107
  }
1037
1108
 
@@ -1041,7 +1112,7 @@ const _disqualifyPromotion = (node, candidates, disqualified, initSet) => {
1041
1112
  if (typeof t === 'string' && candidates.has(t)) { disqualified.add(t); return }
1042
1113
  if (Array.isArray(t) && t[0] === '[]' && typeof t[1] === 'string' && candidates.has(t[1])) {
1043
1114
  disqualified.add(t[1])
1044
- _disqualifyPromotion(t[2], candidates, disqualified, initSet)
1115
+ _disqualifyPromotion(t[2], candidates, disqualified, initSet, valTypes)
1045
1116
  return
1046
1117
  }
1047
1118
  }
@@ -1056,9 +1127,9 @@ const _disqualifyPromotion = (node, candidates, disqualified, initSet) => {
1056
1127
  else if (Array.isArray(d) && d[0] === '=' && typeof d[1] === 'string' &&
1057
1128
  candidates.has(d[1]) && !initSet.has(d)) {
1058
1129
  disqualified.add(d[1])
1059
- _disqualifyPromotion(d[2], candidates, disqualified, initSet)
1130
+ _disqualifyPromotion(d[2], candidates, disqualified, initSet, valTypes)
1060
1131
  } else {
1061
- _disqualifyPromotion(d, candidates, disqualified, initSet)
1132
+ _disqualifyPromotion(d, candidates, disqualified, initSet, valTypes)
1062
1133
  }
1063
1134
  }
1064
1135
  return
@@ -1080,14 +1151,14 @@ const _disqualifyPromotion = (node, candidates, disqualified, initSet) => {
1080
1151
  for (let i = 1; i < node.length; i++) {
1081
1152
  const child = node[i]
1082
1153
  if (i === 2 && typeof child === 'string' && candidates.has(child)) continue
1083
- _disqualifyPromotion(child, candidates, disqualified, initSet)
1154
+ _disqualifyPromotion(child, candidates, disqualified, initSet, valTypes)
1084
1155
  }
1085
1156
  return
1086
1157
  }
1087
1158
 
1088
1159
  // Generic — recurse into children. Bare-name refs at unhandled positions
1089
1160
  // hit the string-leaf branch above and disqualify on contact.
1090
- for (let i = 1; i < node.length; i++) _disqualifyPromotion(node[i], candidates, disqualified, initSet)
1161
+ for (let i = 1; i < node.length; i++) _disqualifyPromotion(node[i], candidates, disqualified, initSet, valTypes)
1091
1162
  }
1092
1163
 
1093
1164
  // Walk `body` to collect every `let X = [intLit, …]` candidate. Each entry
@@ -1155,8 +1226,11 @@ const promoteIntArrayLiteralsInBody = (body) => {
1155
1226
  if (!candidates.size) return { node: body, changed: false }
1156
1227
  const initSet = new Set()
1157
1228
  for (const { initDecl } of candidates.values()) initSet.add(initDecl)
1229
+ // valTypes from analyzeBody gives per-local VAL.* kinds, used by
1230
+ // _disqualifyPromotion to prove index keys are numeric (see _isNumericKey).
1231
+ const { valTypes } = analyzeBody(body)
1158
1232
  const disqualified = new Set()
1159
- _disqualifyPromotion(body, candidates, disqualified, initSet)
1233
+ _disqualifyPromotion(body, candidates, disqualified, initSet, valTypes)
1160
1234
  const validated = new Set()
1161
1235
  for (const name of candidates.keys()) if (!disqualified.has(name)) validated.add(name)
1162
1236
  if (!validated.size) return { node: body, changed: false }
package/src/ctx.js CHANGED
@@ -38,11 +38,11 @@ export { HEAP, LAYOUT, PTR, ATOM, FORWARDING_MASK, nanPrefixHex, atomNanHex, sso
38
38
  // |-----------|----------|---------------------------------|---------------------------|
39
39
  // | core | compile | reset, modules, inc(), emit* | emit, compile, modules |
40
40
  // | module | compile | prepare, index.js | prepare, compile, emit |
41
- // | scope | compile | analyze, compile, plan, modules | compile, emit |
42
- // | func | function | compile, narrow | emit, modules |
41
+ // | scope | compile | analyze, compile, plan, modules, assemble | compile, emit |
42
+ // | func | function | compile, narrow, assemble | emit, modules |
43
43
  // | types | function | analyze, plan | emit, modules |
44
44
  // | schema | compile | prepare, analyze, compile | prepare, analyze, emit |
45
- // | closure | init | modules (fn plugin) | emit, compile |
45
+ // | closure | init | modules (fn plugin), plan, emit | emit, compile |
46
46
  // | runtime | compile | emit, modules | emit, compile |
47
47
  // | memory | compile | index.js | compile |
48
48
  // | error | compile | prepare, compile, emit | err() |
@@ -63,6 +63,11 @@ export { HEAP, LAYOUT, PTR, ATOM, FORWARDING_MASK, nanPrefixHex, atomNanHex, sso
63
63
  // narrow-phase writers: narrowSignatures (under plan) temporarily swaps
64
64
  // ctx.func.{localReps, locals, current} per-function with save/restore
65
65
  // so per-call-site signature inference sees the right scope.
66
+ // assemble-phase writers: buildStartFn (wat/assemble.js) re-owns the ctx.func frame
67
+ // (locals/stack/refinements/…) to emit the module-init `start` fn, save/restoring
68
+ // around it; the data pass also const-folds ctx.scope.globals (mut→false) and
69
+ // declares the __heap* globals. emit seeds ctx.closure.{paramTypes,paramTypedCtors}
70
+ // at direct-call sites (read by emitClosureBody); plan sets ctx.closure.{floor,width}.
66
71
  export const ctx = {
67
72
  core: {}, // emitter table + stdlib registry (seeded by reset + modules)
68
73
  module: {}, // module graph: imports, resolved sources, module-init blocks
@@ -92,9 +97,14 @@ export const ctx = {
92
97
  // the defaults; see reset() for the field list and who flips each.
93
98
  }
94
99
 
95
- /** Create a child scope via shallow flat copy (metacircular-safe: no prototype chain).
100
+ /** Create a child scope via shallow flat copy with NO prototype chain. Critical:
101
+ * `{ ...parent }` would inherit Object.prototype in V8 (jz.js), so a name-keyed lookup
102
+ * like `chain['valueOf']`/`emit['toString']` returns the inherited method instead of
103
+ * undefined — corrupting resolution of any identifier named like an Object method. The
104
+ * kernel's jz objects are already prototype-less, so this was a jz.js-ONLY footgun. A
105
+ * prototype-less dict (Object.create(null) + assign) is correct in both engines.
96
106
  * Mutations to the child do not affect the parent; lookups work via direct property access. */
97
- export const derive = (parent) => ({ ...parent })
107
+ export const derive = (parent) => Object.assign(Object.create(null), parent)
98
108
 
99
109
  /** Include stdlib names for emission. */
100
110
  export const inc = (...names) => names.forEach(n => ctx.core.includes.add(n))
@@ -133,12 +143,19 @@ export const emitter = (deps, fn) => {
133
143
  * carry it as `.argc`; bare ones expose it as the function's own `.length`. */
134
144
  export const emitArity = (h) => h?.argc ?? h?.length
135
145
 
136
- /** Tag an emit handler as a property getter — it yields a value when the
137
- * property is *read* (`re.source`, `m.size`), so the `.`-read path may fire it.
138
- * Untagged handlers are methods: a bare read of `m.values` must not invoke them
139
- * (that would materialize a view instead of reading the `"values"` property);
140
- * they fire only from the method-call path. Apply outermost: `getter(emitter(…))`. */
141
- export const getter = (fn) => (fn.getter = true, fn)
146
+ /** Register `fn` as a property-GETTER emitter for `key` — it yields a value when
147
+ * the property is *read* (`re.source`, `m.size`, `a.byteOffset`), so the `.`-read
148
+ * path fires it. (Untagged `ctx.core.emit` handlers are methods: a bare read of
149
+ * `m.values` must NOT invoke them — that would materialize a view they fire only
150
+ * from the method-call path.) Getter-ness lives in `ctx.core.getters` (a plain Set),
151
+ * NOT as a flag on the emitter closure: the self-host kernel can't reliably read a
152
+ * dynamic property off a closure returned via a dynamic-key lookup, so a closure tag
153
+ * silently read `undefined` and every getter fell through to `__dyn_get`. A Set
154
+ * key-lookup is kernel-safe. Dispatch (module/core.js) checks `ctx.core.getters.has(key)`. */
155
+ export const registerGetter = (key, fn) => {
156
+ ctx.core.emit[key] = fn
157
+ ctx.core.getters.add(key)
158
+ }
142
159
 
143
160
  /** Expand ctx.core.includes transitively via ctx.core.stdlibDeps. Call before WASM assembly.
144
161
  * Each module co-locates its own deps with its stdlib registrations at init time. */
@@ -207,6 +224,13 @@ export function reset(proto, globals, bridge) {
207
224
  // `(import "env" "name" (global $name i64))` at assembly. Same
208
225
  // usage-gated pattern as jsstring — emit records, assembly owns
209
226
  // the ctx.module.imports write.
227
+ getters: new Set(), // keys of emit entries that are property getters — the
228
+ // kernel-safe authority for getter dispatch (a closure-attached
229
+ // flag was unreadable in the self-host kernel after a dynamic-key
230
+ // lookup, so every getter silently fell through to __dyn_get).
231
+ // MUST remain last: adding fields before stdlib/stdlibDeps/… shifts
232
+ // their slot indices and breaks the self-host compiled kernel's reads.
233
+ // Populated by registerGetter(); checked by module/core.js dispatch.
210
234
  }
211
235
 
212
236
 
@@ -234,6 +258,10 @@ export function reset(proto, globals, bridge) {
234
258
  globalTypedElem: null,
235
259
  globalReps: null, // Map<name, ValueRep> — module-level pointer reps (TYPED const globals stored as raw i32 offset, etc.)
236
260
  consts: null,
261
+ constInts: null, // Map<name, int> — module const folded to an integer literal (prepare/plan seed; static/ir read)
262
+ constStrs: null, // Map<name, string> — module const folded to a string literal
263
+ shapeStrs: null, // Map<expr, string> / shapeStrArrays: Map<name, string[]> — schema-shape string folds
264
+ shapeStrArrays: null,
237
265
  }
238
266
 
239
267
  ctx.func = {
@@ -241,7 +269,7 @@ export function reset(proto, globals, bridge) {
241
269
  names: new Set(), // Set<string> — known func names (list + imported funcs); populated at compile() start
242
270
  map: new Map(), // Map<string, func> — name → func entry; populated at compile() start
243
271
  multiProp: new Set(), // Set<"obj.prop"> — function-properties assigned >1× (wrapper composition); suppresses the static fn.prop() direct call
244
- exports: {},
272
+ exports: Object.create(null), // name-keyed: prototype-less (see derive) — `export let valueOf` must not hit Object.prototype
245
273
  current: null,
246
274
  locals: new Map(),
247
275
  localReps: null,
@@ -324,6 +352,8 @@ export function reset(proto, globals, bridge) {
324
352
  minArgc: null, // Map<closureBodyName, number> — fewest args any direct call passed.
325
353
  // A slot at index ≥ minArgc is omitted by some call (→ may be undefined),
326
354
  // so it must NOT be typed NUMBER, else `x === undefined` mis-folds to false.
355
+ floor: null, // min closure-table arity (modules: fn/timer/typedarray/array; read in plan). null ⇒ 0.
356
+ width: null, // closure call/make signature width (plan/scope sets; emit/assemble read). null ⇒ MAX_CLOSURE_ARITY.
327
357
  }
328
358
 
329
359
  ctx.runtime = {
@@ -370,6 +400,15 @@ export function reset(proto, globals, bridge) {
370
400
  inspect: false, // when true, compile() additionally populates ctx.inspect with the inferred
371
401
  // per-function signatures, locals, and JSON shapes — readable by editor
372
402
  // hosts for inlay hints / hover types without re-running the analyzer.
403
+ helperCounters: false, // internal profiling mode: export mutable i64 counters for selected
404
+ // runtime helpers and instrument their entry blocks. Build-time opt-in
405
+ // only; normal output is byte-identical and pays no counter cost.
406
+ helperCallsites: false, // profiling-only: export mutable i64 counters for selected runtime
407
+ // helper callsites after optimization, so hot helpers can be traced
408
+ // back to the compiled function that calls them.
409
+ loopXformId: 0, // monotonic id for the per-function loop transforms' generated locals
410
+ // (loop-model freshLoopId). Per-compile (reset here), not a module-global —
411
+ // so compile(P) is deterministic regardless of prior compiles in the process.
373
412
  }
374
413
 
375
414
  // Inspection sink. Populated by compile() only when transform.inspect is true.