jz 0.6.0 → 0.7.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 (49) hide show
  1. package/README.md +66 -43
  2. package/bench/README.md +128 -78
  3. package/bench/bench.svg +32 -42
  4. package/cli.js +73 -7
  5. package/dist/interop.js +1 -0
  6. package/dist/jz.js +6024 -0
  7. package/index.d.ts +126 -0
  8. package/index.js +190 -34
  9. package/interop.js +71 -27
  10. package/layout.js +5 -0
  11. package/module/array.js +71 -39
  12. package/module/collection.js +41 -5
  13. package/module/core.js +2 -4
  14. package/module/math.js +138 -2
  15. package/module/number.js +21 -0
  16. package/module/object.js +26 -0
  17. package/module/simd.js +37 -5
  18. package/module/string.js +41 -12
  19. package/module/typedarray.js +415 -26
  20. package/package.json +21 -6
  21. package/src/autoload.js +3 -0
  22. package/src/compile/analyze-scans.js +174 -11
  23. package/src/compile/analyze.js +38 -3
  24. package/src/compile/emit-assign.js +9 -6
  25. package/src/compile/emit.js +347 -36
  26. package/src/compile/index.js +307 -29
  27. package/src/compile/infer.js +36 -1
  28. package/src/compile/loop-divmod.js +155 -0
  29. package/src/compile/narrow.js +108 -11
  30. package/src/compile/peel-stencil.js +261 -0
  31. package/src/compile/plan/advise.js +55 -1
  32. package/src/compile/plan/index.js +4 -0
  33. package/src/compile/plan/inline.js +5 -2
  34. package/src/compile/plan/literals.js +220 -5
  35. package/src/compile/plan/scope.js +115 -39
  36. package/src/compile/program-facts.js +21 -2
  37. package/src/ctx.js +45 -7
  38. package/src/ir.js +55 -7
  39. package/src/kind-traits.js +27 -0
  40. package/src/kind.js +65 -3
  41. package/src/optimize/index.js +344 -45
  42. package/src/optimize/vectorize.js +2968 -182
  43. package/src/prepare/index.js +64 -7
  44. package/src/prepare/lift-iife.js +149 -0
  45. package/src/reps.js +3 -2
  46. package/src/static.js +9 -0
  47. package/src/type.js +10 -6
  48. package/src/wat/assemble.js +195 -13
  49. package/src/wat/optimize.js +363 -185
@@ -44,7 +44,7 @@ import { recordGlobalRep } from '../compile/infer.js'
44
44
  import { isFuncRef } from '../ir.js'
45
45
  import {
46
46
  CTORS, COLLECTION_CTORS, TIMER_NAMES,
47
- hasModule, includeModule,
47
+ hasModule, includeModule, includeMods,
48
48
  includeForArrayAccess, includeForArrayLiteral, includeForArrayPattern, includeForCallableValue,
49
49
  includeForGenericMethod, includeForKnownKeyIteration, includeForNamedCall, includeForNumericCoercion,
50
50
  includeForObjectLiteral, includeForObjectPattern, includeForOp, includeForProperty, includeForRuntimeCtor,
@@ -321,6 +321,30 @@ function lookupStaticStringArray(name) {
321
321
  return ctx.scope.shapeStrArrays?.get(resolved) ?? null
322
322
  }
323
323
 
324
+ /** Evaluate a constant numeric expression (number literals + basic arithmetic) for
325
+ * compile-time string/template folding. Returns null when it isn't a pure-number
326
+ * constant — string `+` and dynamic parts fall through to the caller's runtime path. */
327
+ function constNum(node) {
328
+ if (Array.isArray(node) && node[0] == null && typeof node[1] === 'number') return node[1]
329
+ if (!Array.isArray(node)) return null
330
+ const [op, a, b] = node
331
+ if ((op === 'u-' || op === '-' || op === '+') && b === undefined) {
332
+ const x = constNum(a)
333
+ return x == null ? null : op === 'u-' || op === '-' ? -x : +x
334
+ }
335
+ const x = constNum(a), y = constNum(b)
336
+ if (x == null || y == null) return null
337
+ switch (op) {
338
+ case '+': return x + y
339
+ case '-': return x - y
340
+ case '*': return x * y
341
+ case '/': return y === 0 ? null : x / y
342
+ case '%': return y === 0 ? null : x % y
343
+ case '**': return x ** y
344
+ }
345
+ return null
346
+ }
347
+
324
348
  function staticStringExpr(node) {
325
349
  const lit = stringValue(node)
326
350
  if (lit != null) return lit
@@ -341,7 +365,11 @@ function staticStringExpr(node) {
341
365
  if (op === '`') {
342
366
  let out = ''
343
367
  for (const part of args) {
344
- const s = staticStringExpr(part)
368
+ let s = staticStringExpr(part)
369
+ // A numeric interpolation (`${123}`, `${1+2}`) is a constant in string context —
370
+ // ToString it so a fully-static template folds to one literal instead of a runtime
371
+ // concat. (Only the template case stringifies numbers; `+` stays polymorphic.)
372
+ if (s == null) { const n = constNum(part); if (n != null) s = String(n) }
345
373
  if (s == null) return null
346
374
  out += s
347
375
  }
@@ -383,7 +411,7 @@ function resolveImportMeta(spec) {
383
411
 
384
412
  function recordModuleInitFacts(root) {
385
413
  const facts = ctx.module.initFacts ||= {
386
- dynVars: new Set(), anyDyn: false, hasSchemaLiterals: false,
414
+ dynVars: new Set(), dynWriteVars: new Set(), anyDyn: false, hasSchemaLiterals: false,
387
415
  hasFuncValue: false, timerNames: new Set(),
388
416
  maxDef: 0, maxCall: 0, hasRest: false, hasSpread: false,
389
417
  writtenProps: new Set(),
@@ -458,6 +486,11 @@ export default function prepare(node) {
458
486
  // import would cycle (autoload imports every module via module/index.js).
459
487
  ctx.module.include = includeModule
460
488
  includeModule('core')
489
+ // Empty or whitespace-only source parses to a bare '' — an empty program, not an
490
+ // identifier reference. Normalize to an empty statement so it compiles to a bare
491
+ // `(module)` instead of a `(local.get $)` against a zero-length name. (A non-empty
492
+ // bare identifier like `foo` parses to `'foo'` and stays a real reference.)
493
+ if (node === '') node = [';']
461
494
  validateCoalesceMixing(node) // ES2020: reject unparenthesized `??` mixed with `||`/`&&`
462
495
  normalizeIdents(node)
463
496
  fuseSparseMapReads(node) // AST-level fusion; needs pre-resolution shape — defined at end of file
@@ -1246,7 +1279,7 @@ function foldNamespaceIntrospection(callee, args) {
1246
1279
  // Compiler-internal synthetic callees: emit-handled intrinsics, never user
1247
1280
  // function values — so a bare reference must not pull in the callable-value
1248
1281
  // (function table / closure) machinery.
1249
- const INTRINSIC_CALLEES = new Set(['__iter_arr'])
1282
+ const INTRINSIC_CALLEES = new Set(['__iter_arr', '__keys_ro'])
1250
1283
 
1251
1284
  function resolveCallee(callee, args) {
1252
1285
  if (typeof callee === 'string') {
@@ -1427,6 +1460,10 @@ const handlers = {
1427
1460
 
1428
1461
  // Template literal: [``, part, ...] → fused single-allocation string concat.
1429
1462
  '`'(...parts) {
1463
+ // Fully-static template (`a${123}b`, `hello ${1+2} world`) folds to a single string
1464
+ // literal — a static data segment / SSO box, no runtime concat and no heap machinery.
1465
+ const folded = staticStringExpr(['`', ...parts])
1466
+ if (folded != null) return staticString(folded)
1430
1467
  includeForStringValue()
1431
1468
  const nodes = parts.map(p =>
1432
1469
  Array.isArray(p) && p[0] == null && typeof p[1] === 'string' ? ['str', p[1]] : prep(p))
@@ -1628,6 +1665,15 @@ const handlers = {
1628
1665
  for (const i of decl.slice(1))
1629
1666
  if (Array.isArray(i) && i[0] === '=' && typeof i[1] === 'string')
1630
1667
  ctx.func.exports[i[1]] = true
1668
+ // export name → bare-identifier re-export (shorthand for `export { name }`).
1669
+ // Register the binding and emit nothing; without this the name falls through
1670
+ // to `prep(decl)` below and compiles as a dead `global.get; drop` statement
1671
+ // while the export itself is silently lost.
1672
+ if (typeof decl === 'string') {
1673
+ const resolved = ctx.scope.chain[decl]
1674
+ ctx.func.exports[decl] = (resolved && resolved !== decl) ? resolved : decl
1675
+ return null
1676
+ }
1631
1677
  // export { name, name as alias } from './mod' or export * from './mod'
1632
1678
  if (Array.isArray(decl) && decl[0] === 'from') {
1633
1679
  const mod = decl[2]?.[1]
@@ -1821,7 +1867,11 @@ const handlers = {
1821
1867
  // alone wrongly folds `-5n`, and negating the bigint here yields garbage (-2^63+5).
1822
1868
  // `typeof !== 'bigint'` excludes it in both engines (real JS: 'bigint'; jz: matches
1823
1869
  // 'bigint'). Bigint negation then flows to emit's i64.sub(0,·) path correctly.
1824
- if (b === undefined) { const na = prep(a); return isLit(na) && typeof na[1] === 'number' && typeof na[1] !== 'bigint' ? [, -na[1]] : ['u-', na] }
1870
+ // `-0` is NOT folded: the self-host kernel evaluates the constant `-na[1]` with
1871
+ // i32 negation (i32 has no signed zero), collapsing -0→+0 — observable via sort's
1872
+ // -0<+0 tiebreak, Object.is, and 1/x. Leaving it as a runtime `u-` emits f64.neg,
1873
+ // which preserves the sign in both engines; V8 re-folds it, so no native cost.
1874
+ if (b === undefined) { const na = prep(a); return isLit(na) && typeof na[1] === 'number' && typeof na[1] !== 'bigint' && na[1] !== 0 ? [, -na[1]] : ['u-', na] }
1825
1875
  return ['-', prep(a), prep(b)]
1826
1876
  },
1827
1877
 
@@ -2123,10 +2173,17 @@ const handlers = {
2123
2173
  // `[null, null]`, `undefined` is `[null]` (empty value slot). A numeric/string literal
2124
2174
  // `[null, v]` has a non-nullish value slot, so `src[1] == null` discriminates them.
2125
2175
  const nullish = Array.isArray(src) && src[0] == null && src[1] == null
2176
+ // `__keys_ro` is for-in's read-only key list: identical to Object.keys, but
2177
+ // when the receiver has a complete static schema the keys are a compile-time
2178
+ // constant, so it pools ONE static-data array instead of allocating a fresh
2179
+ // one each evaluation — the per-iteration heap-growth cliff (jz#deopt-forin).
2180
+ // Sound only because for-in reads ks[i]/ks.length and never mutates (unlike
2181
+ // user Object.keys, which permits in-place `.sort()`/`.reverse()`).
2182
+ includeMods('core', 'object', 'string')
2126
2183
  const keysExpr = nullish ? ['[]', null]
2127
2184
  : typeof src === 'string'
2128
- ? ['?', ['==', src, [null, null]], ['[]', null], ['()', ['.', 'Object', 'keys'], src]]
2129
- : ['()', ['.', 'Object', 'keys'], src]
2185
+ ? ['?', ['==', src, [null, null]], ['[]', null], ['()', '__keys_ro', src]]
2186
+ : ['()', '__keys_ro', src]
2130
2187
  const ks = `${T}fik${ctx.func.uniq++}`, ix = `${T}fii${ctx.func.uniq++}`, lenV = `${T}fil${ctx.func.uniq++}`
2131
2188
  const decls = ['let',
2132
2189
  ['=', ks, keysExpr],
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Lambda-lift immediately-invoked arrow literals (IIFEs).
3
+ *
4
+ * `(params => body)(args)` is lowered by jz's default path as a CLOSURE value invoked
5
+ * via `call_indirect` through the uniform-f64 closure ABI. That ABI can't carry a
6
+ * `v128`, so a SIMD IIFE — `(() => f32x4.…)()` — emits invalid wasm; it also pays a
7
+ * closure alloc + indirect call for what is really a direct, single call.
8
+ *
9
+ * This pass rewrites each such IIFE into a TOP-LEVEL function whose free variables are
10
+ * appended as parameters, and replaces the call with a direct call passing those vars:
11
+ *
12
+ * let r = (() => { let t = f32x4.mul(a, a); return f32x4.add(t, …) })() // captures a
13
+ * ⇓
14
+ * let ⟨lift⟩ = (a) => { let t = f32x4.mul(a, a); return f32x4.add(t, …) } // hoisted to top
15
+ * let r = ⟨lift⟩(a) // direct call
16
+ *
17
+ * The lifted function flows through jz's monomorphic typed-function path (like any
18
+ * `let f = (x) => …; f(v)`), so a captured/returned `v128` is typed `v128` from the
19
+ * single call site — and `inlineOnce` then folds the single-caller body back in, so
20
+ * there's no residual call. Capture is BY VALUE, which is exact for a synchronous
21
+ * immediate invocation (the value at the call instant == what the closure would read);
22
+ * the one divergence — a capture MUTATED inside the body, which a closure writes back
23
+ * through its cell — is the bail below.
24
+ *
25
+ * Bails (leaving the closure path untouched) on: non-plain params (rest / default /
26
+ * destructured), or a body that assigns any captured variable. Everything else folds.
27
+ *
28
+ * @module prepare/lift-iife
29
+ */
30
+ import { T, extractParams, classifyParam } from '../ast.js'
31
+ import { findFreeVars, findMutations } from '../compile/analyze-scans.js'
32
+
33
+ // Build a comma-list operand node (the parser's shape) from an array of nodes.
34
+ const commaList = (items) =>
35
+ items.length === 0 ? null : items.length === 1 ? items[0] : [',', ...items]
36
+
37
+ // Unwrap parenthesis nodes (`['()', x]`, length 2) to reach the inner expression.
38
+ const unwrapParens = (n) => {
39
+ while (Array.isArray(n) && n[0] === '()' && n.length === 2) n = n[1]
40
+ return n
41
+ }
42
+
43
+ // `['()', callee, args]` (length 3) whose callee unwraps to an arrow literal → that arrow.
44
+ const iifeArrow = (n) => {
45
+ if (!Array.isArray(n) || n[0] !== '()' || n.length !== 3) return null
46
+ const callee = unwrapParens(n[1])
47
+ return Array.isArray(callee) && callee[0] === '=>' ? callee : null
48
+ }
49
+
50
+ // Plain-name params only; anything else (rest/default/destructure) → null (bail).
51
+ const plainParamNames = (arrow) => {
52
+ const out = []
53
+ for (const p of extractParams(arrow[1])) {
54
+ const c = classifyParam(p)
55
+ if (c.kind !== 'plain' || typeof c.name !== 'string') return null
56
+ out.push(c.name)
57
+ }
58
+ return out
59
+ }
60
+
61
+ // Names a function binds: its plain params + every let/const name in its body, NOT
62
+ // descending into nested arrows (their locals are their own). Over-approximating
63
+ // within a frame is safe — a free ident only resolves to one of these if it's
64
+ // actually in scope at the reference. Used to scope captures to enclosing LOCALS
65
+ // (module-level names stay global refs in the lifted body, never captured).
66
+ const collectDeclNames = (decl, out) => {
67
+ if (!Array.isArray(decl)) return
68
+ if (decl[0] === '=' && typeof decl[1] === 'string') out.add(decl[1])
69
+ else if (decl[0] === '=' && Array.isArray(decl[1])) collectPatternNames(decl[1], out)
70
+ else if (typeof decl[1] === 'string') out.add(decl[1])
71
+ }
72
+ const collectPatternNames = (pat, out) => {
73
+ if (typeof pat === 'string') { out.add(pat); return }
74
+ if (!Array.isArray(pat)) return
75
+ for (let i = 1; i < pat.length; i++) collectPatternNames(pat[i], out)
76
+ }
77
+ function functionLocals(paramNodes, body) {
78
+ const names = new Set()
79
+ for (const p of paramNodes) { const c = classifyParam(p); if (typeof c.name === 'string') names.add(c.name); else if (c.pattern) collectPatternNames(c.pattern, names) }
80
+ const scan = (n) => {
81
+ if (!Array.isArray(n)) return
82
+ if (n[0] === '=>') return
83
+ if (n[0] === 'let' || n[0] === 'const') for (const d of n.slice(1)) collectDeclNames(d, names)
84
+ for (let i = 1; i < n.length; i++) scan(n[i])
85
+ }
86
+ scan(body)
87
+ return names
88
+ }
89
+
90
+ export function liftIIFEs(ast) {
91
+ if (!Array.isArray(ast)) return ast
92
+ const lifted = [] // hoisted `['let', ['=', name, arrow]]` decls
93
+ let uid = 0
94
+
95
+ // Copy non-index node metadata (parser `.loc`, etc.) onto a rebuilt node so error
96
+ // source-locations survive the transform.
97
+ const copyMeta = (to, from) => { for (const k of Object.keys(from)) if (isNaN(+k)) to[k] = from[k]; return to }
98
+
99
+ // `locals` = union of every enclosing FUNCTION frame's bound names. The transform
100
+ // is post-order (inner IIFEs fold first) so an outer lift sees already-direct inner
101
+ // calls; scope tracking is top-down so each IIFE sees the right enclosing locals.
102
+ // Identity-preserving: a node with no lifted descendant is returned unchanged.
103
+ const visit = (node, locals) => {
104
+ if (!Array.isArray(node)) return node
105
+
106
+ // Descend into an arrow body with the frame extended by this arrow's locals.
107
+ if (node[0] === '=>') {
108
+ const inner = new Set(locals)
109
+ for (const n of functionLocals(extractParams(node[1]), node[2])) inner.add(n)
110
+ let changed = false
111
+ const out = node.map((c, i) => { if (i === 0) return c; const v = visit(c, inner); if (v !== c) changed = true; return v })
112
+ return changed ? copyMeta(out, node) : node
113
+ }
114
+
115
+ // Transform children first (post-order).
116
+ let changed = false
117
+ const mapped = node.map((c, i) => { if (i === 0) return c; const v = visit(c, locals); if (v !== c) changed = true; return v })
118
+ const base = changed ? copyMeta(mapped, node) : node
119
+
120
+ const arrow = iifeArrow(base)
121
+ if (!arrow) return base
122
+
123
+ const paramNames = plainParamNames(arrow)
124
+ if (paramNames === null) return base // bail: non-plain params
125
+
126
+ const callArgs = base[2] == null ? [] : (Array.isArray(base[2]) && base[2][0] === ',') ? base[2].slice(1) : [base[2]]
127
+ if (callArgs.some(a => Array.isArray(a) && a[0] === '...')) return base // bail: spread into a fixed-arity direct call
128
+
129
+ const body = arrow[2]
130
+ const captures = []
131
+ findFreeVars(body, new Set(paramNames), captures, locals)
132
+
133
+ const mutated = new Set()
134
+ findMutations(body, new Set(captures), mutated)
135
+ if (mutated.size) return base // bail: a captured var is written
136
+
137
+ // Lift: `(…params, …captures) => body`, hoisted to module top; call passes captures.
138
+ const name = `${T}lift${uid++}`
139
+ lifted.push(['let', ['=', name, ['=>', ['()', commaList([...paramNames, ...captures])], body]]])
140
+ return copyMeta(['()', name, commaList([...callArgs, ...captures])], base)
141
+ }
142
+
143
+ const result = visit(ast, new Set())
144
+ if (!lifted.length) return result
145
+
146
+ // Prepend the lifted decls to the module body (`[';', …stmts]`).
147
+ if (Array.isArray(result) && result[0] === ';') return [';', ...lifted, ...result.slice(1)]
148
+ return [';', ...lifted, result]
149
+ }
package/src/reps.js CHANGED
@@ -56,6 +56,7 @@ export const VAL = {
56
56
  * @property {boolean} [notString] proven not a string (skips string-path guards).
57
57
  * @property {number} [arrayElemSchema] element object-schema id for arrays.
58
58
  * @property {string} [arrayElemValType] element VAL.* kind for arrays.
59
+ * @property {string} [arrayElemElemValType] nested element VAL.* kind (`X[i][j]`) for arrays of arrays.
59
60
  * @property {string} [carrier] abi carrier id override (e.g. 'jsstring').
60
61
  * @property {boolean} [unsigned] i32 carries an unsigned value (`>>>` result).
61
62
  * @property {*} [jsonShape] inferred shape for the JSON.stringify fast path.
@@ -67,8 +68,8 @@ export const VAL = {
67
68
  */
68
69
  export const REP_FIELDS = new Set([
69
70
  'val', 'ptrKind', 'ptrAux', 'schemaId', 'intConst', 'intCertain', 'notString',
70
- 'arrayElemSchema', 'arrayElemValType', 'carrier', 'unsigned', 'jsonShape',
71
- 'typedCtor', 'wasm', 'nullable',
71
+ 'arrayElemSchema', 'arrayElemValType', 'arrayElemElemValType', 'carrier', 'unsigned', 'jsonShape',
72
+ 'typedCtor', 'wasm', 'nullable', 'neverGrown',
72
73
  ])
73
74
 
74
75
  const DBG_REPS = typeof process !== 'undefined' && process.env?.JZ_DEBUG_INVARIANTS === '1'
package/src/static.js CHANGED
@@ -19,6 +19,15 @@ export function intLiteralValue(expr) {
19
19
  /** Non-negative integer literal — used for string/typed-array index bounds. */
20
20
  export const nonNegIntLiteral = (node) => { const n = intLiteralValue(node); return n != null && n >= 0 ? n : null }
21
21
 
22
+ /** Flat-array slot key for a *bare* non-negative integer index literal `[null, k]`
23
+ * — returns the stringified index ("0","1",…) so an array `a[k]` resolves through
24
+ * the same SRoA `name#i` machinery as an object `o.key`. Only a literal index
25
+ * qualifies (not a const-folded identifier): the key must be unambiguous at scan
26
+ * time, before any rep is known. Null for dynamic / non-integer / huge indices. */
27
+ export const staticIndexKey = (node) =>
28
+ Array.isArray(node) && node[0] == null && Number.isInteger(node[1]) && node[1] >= 0 && node[1] < 0x100000000
29
+ ? String(node[1]) : null
30
+
22
31
  /** Fold compile-time integer expressions (literals, const bindings, + - * <<). */
23
32
  export function constIntExpr(node) {
24
33
  let lit = intLiteralValue(node)
package/src/type.js CHANGED
@@ -401,16 +401,20 @@ export function exprType(expr, locals) {
401
401
 
402
402
  // Always f64
403
403
  if (op === '/' || op === '**' || op === '[' || op === '{}' || op === 'str') return 'f64'
404
- // arr[i] — typed integer arrays return i32. Only Int8/Uint8/Int16/Uint16/Int32
405
- // (every value fits in signed i32). Skip Uint32: 0..2^32-1 overflows signed.
406
- // During analyzeBody the in-progress typedElems is in localTypedElemsOverlay;
407
- // post-analyze passes read from ctx.types.typedElem.
404
+ // arr[i] — integer typed arrays (Int8/Uint8/Int16/Uint16/Int32/Uint32, aux 0..5) read as i32:
405
+ // the element IS a 32-bit machine integer, so a binding used in integer/bitwise ops stays i32
406
+ // instead of round-tripping i32.load → f64 → trunc back (the deopt that made packed-pixel fade
407
+ // loops like lorenz slow). Uint32 reads carry the full 0..2^32-1 range as the i32 bit-pattern;
408
+ // ToInt32-coercing uses (& | ^ << >> >>>, i32.store) are bit-exact, and value uses that need the
409
+ // unsigned magnitude (compare, f64 convert) go through the elem-aux's unsigned path. Floats
410
+ // (Float32/Float64, aux 6/7) genuinely yield f64. typedElems: in-progress reads come from
411
+ // localTypedElemsOverlay during analyzeBody; post-analyze passes read ctx.types.typedElem.
408
412
  if (op === '[]') {
409
413
  if (typeof args[0] === 'string') {
410
414
  const ctor = ctx.func.localTypedElemsOverlay?.get(args[0]) ?? ctx.types.typedElem?.get(args[0])
411
415
  if (ctor) {
412
416
  const aux = typedElemAux(ctor)
413
- if (aux != null && (aux & 7) <= 4) return 'i32'
417
+ if (aux != null && (aux & 7) <= 5) return 'i32'
414
418
  }
415
419
  }
416
420
  return 'f64'
@@ -501,7 +505,7 @@ export function exprType(expr, locals) {
501
505
  // hand a scalar back (i32x4.lane / v128.anyTrue / v128.allTrue → i32;
502
506
  // f32x4.lane → f64). See module/simd.js.
503
507
  if (typeof args[0] === 'string' && (args[0].startsWith('f32x4.') || args[0].startsWith('i32x4.') || args[0].startsWith('f64x2.') || args[0].startsWith('v128.'))) {
504
- if (args[0] === 'f32x4.lane') return 'f64'
508
+ if (args[0] === 'f32x4.lane' || args[0] === 'f64x2.lane') return 'f64'
505
509
  if (args[0] === 'i32x4.lane' || args[0] === 'v128.anyTrue' || args[0] === 'v128.allTrue') return 'i32'
506
510
  return 'v128'
507
511
  }
@@ -37,7 +37,7 @@ import { T } from '../ast.js'
37
37
  import { analyzeValTypes, analyzeBody } from '../compile/analyze.js'
38
38
  import { VAL } from '../reps.js'
39
39
  import { optimizeFunc, collectVolatileGlobals, collectReachableGlobalWrites, hoistGlobalPtrOffset, stablePtrGlobalNames, hoistConstantPool, specializeMkptr, specializePtrBase, sortStrPoolByFreq, arenaRewindModule } from '../optimize/index.js'
40
- import { emit } from '../compile/emit.js'
40
+ import { emit, emitVoid } from '../compile/emit.js'
41
41
  import { mkPtrIR, MAX_CLOSURE_ARITY, MEM_OPS, findBodyStart } from '../ir.js'
42
42
 
43
43
  // NaN-prefix top-13-bits as BigInt — used by the static-prefix-strip pass
@@ -153,6 +153,11 @@ export function buildStartFn(ast, sec, closureFuncs, compilePendingClosures) {
153
153
  analyzeValTypes(ast)
154
154
  const normalizeIR = ir => !ir?.length ? [] : Array.isArray(ir[0]) ? ir : [ir]
155
155
 
156
+ // Mark module-scope emission: top-level statements run exactly once, so a constant
157
+ // array/object literal here is a single instance that can safely live in a static
158
+ // data segment (no per-call freshness to violate). Function bodies — compiled
159
+ // separately, and the late closures below — leave this unset and alloc fresh.
160
+ ctx.func.atModuleScope = true
156
161
  const moduleInits = []
157
162
  if (ctx.module.moduleInits) {
158
163
  for (const mi of ctx.module.moduleInits) {
@@ -162,7 +167,13 @@ export function buildStartFn(ast, sec, closureFuncs, compilePendingClosures) {
162
167
  }
163
168
  }
164
169
  seedGeneratedLocals(ast)
165
- const init = emit(ast)
170
+ // __start has no result: emit the top-level program in void context so a stray
171
+ // value is dropped. `ast` is normally a `;` statement-sequence (each statement
172
+ // already void-dropped), but jzify unwraps a single-statement program to its
173
+ // bare expression — emitting that in value context leaves a value on the stack
174
+ // and the start function fails validation. emitVoid handles both shapes.
175
+ const init = emitVoid(ast)
176
+ ctx.func.atModuleScope = false
166
177
 
167
178
  // Module-scope object literals can create closure bodies while `emit(ast)`
168
179
  // runs. Those late closures may pull in stdlib helpers (notably JSON.parse)
@@ -285,6 +296,47 @@ export function buildStartFn(ast, sec, closureFuncs, compilePendingClosures) {
285
296
  sec.funcs.unshift(...closureFuncs.slice(beforeLateClosures))
286
297
  }
287
298
 
299
+ /**
300
+ * Hoist constant global initializers out of `__start` into immutable inline decls.
301
+ *
302
+ * A top-level `const x = <constant>` for a non-numeric value (atom `true`/`null`/
303
+ * `undefined`/`NaN`, an SSO or static-string NaN-box, a folded pointer) emits a
304
+ * `(global.set $x (f64.const …))` into `__start`, because only *numeric* consts are
305
+ * folded ahead of emit. But the value is a compile-time constant, so it belongs in
306
+ * the decl itself — `(global $x f64 (f64.const …))` — exactly like the numeric path.
307
+ * That drops the store, and when it empties `__start` the start function and its
308
+ * directive go too. Gated to single-assignment user `const`s so we never freeze a
309
+ * binding something else writes.
310
+ */
311
+ export function hoistConstGlobalInits(sec) {
312
+ const startFn = sec.start.find(n => Array.isArray(n) && n[0] === 'func' && n[1] === '$__start')
313
+ if (!startFn) return
314
+ const writes = new Map()
315
+ const scan = (node) => {
316
+ if (!Array.isArray(node)) return
317
+ if (node[0] === 'global.set' && typeof node[1] === 'string') writes.set(node[1], (writes.get(node[1]) || 0) + 1)
318
+ for (const c of node) scan(c)
319
+ }
320
+ for (const arr of [sec.funcs, sec.stdlib, sec.start]) for (const fn of arr) scan(fn)
321
+ for (let i = startFn.length - 1; i >= findBodyStart(startFn); i--) {
322
+ const stmt = startFn[i]
323
+ if (!Array.isArray(stmt) || stmt[0] !== 'global.set' || writes.get(stmt[1]) !== 1) continue
324
+ const name = typeof stmt[1] === 'string' && stmt[1][0] === '$' ? stmt[1].slice(1) : null
325
+ const g = name && ctx.scope.globals.get(name)
326
+ const c = stmt[2]
327
+ if (!g || !g.mut || !ctx.scope.consts?.has(name) || !ctx.scope.userGlobals?.has(name)) continue
328
+ if (!Array.isArray(c) || c[0] !== `${g.type}.const`) continue
329
+ ctx.scope.globals.set(name, { ...g, mut: false, init: c[1] })
330
+ startFn.splice(i, 1)
331
+ }
332
+ // Hoisting can empty `__start`. The O2 watr pass prunes a bodyless start, but at
333
+ // O0/O1 nothing else does — drop it (func + directive) here so a const-only module
334
+ // carries no start at all.
335
+ if (findBodyStart(startFn) >= startFn.length)
336
+ for (let j = sec.start.length - 1; j >= 0; j--)
337
+ if (Array.isArray(sec.start[j]) && sec.start[j][1] === '$__start') sec.start.splice(j, 1)
338
+ }
339
+
288
340
  /**
289
341
  * Phase: closure-body dedup.
290
342
  *
@@ -420,24 +472,154 @@ export function finalizeClosureTable(sec) {
420
472
  for (const fn of sec.start) rewriteCalls(fn)
421
473
  }
422
474
 
475
+ /**
476
+ * Stdlib funcs actually reachable from the emitted program. Seeds from real
477
+ * `call`/`return_call`/`ref.func` sites in the user funcs, `__start`, and the elem
478
+ * table, then closes transitively over the stdlib call graph (each reached helper's
479
+ * template references). Conservative by construction — a template `$__foo` in a
480
+ * feature-dead branch is kept, never dropped — so it's safe to gate inclusion and the
481
+ * memory/allocator decision on it. An eagerly-`inc`'d helper that nothing calls is
482
+ * absent, which is the whole point.
483
+ */
484
+ function reachableStdlib(sec) {
485
+ const stdlib = ctx.core.stdlib
486
+ const reach = new Set(), stack = []
487
+ // Track every reached name (module-namespace `math.sin` included), but only follow
488
+ // those with a stdlib template. Names match `$foo`, `$__foo`, `$math.sin_core` — the
489
+ // dotted module funcs are the ones the `$__`-only regex used to miss, pruning live code.
490
+ const add = (name) => { if (!reach.has(name)) { reach.add(name); if (stdlib[name] != null) stack.push(name) } }
491
+ const scanIR = (node) => {
492
+ if (!Array.isArray(node)) return
493
+ if ((node[0] === 'call' || node[0] === 'return_call' || node[0] === 'ref.func') &&
494
+ typeof node[1] === 'string' && node[1][0] === '$') add(node[1].slice(1))
495
+ for (const c of node) scanIR(c)
496
+ }
497
+ for (const fn of sec.funcs) scanIR(fn)
498
+ for (const fn of sec.start) scanIR(fn)
499
+ for (const e of sec.elem) // closure table: bare `$fn` func refs
500
+ if (Array.isArray(e)) for (const c of e) if (typeof c === 'string' && c[0] === '$') add(c.slice(1))
501
+ // A stdlib func that self-exports (`(export "__invoke_closure")`) is a host-facing
502
+ // entry point — the JS host calls it directly, so it's a root even when nothing in
503
+ // the wasm calls it. Mirrors treeshake's inline-export rooting.
504
+ for (const n of ctx.core.includes) {
505
+ const v = stdlib[n]
506
+ let t = ''
507
+ try { t = typeof v === 'function' ? v() : v } catch { t = '' }
508
+ if (typeof t === 'string' && t.includes('(export "')) add(n)
509
+ }
510
+ while (stack.length) {
511
+ const v = stdlib[stack.pop()]
512
+ let text = ''
513
+ try { text = typeof v === 'function' ? v() : v } catch { text = '' }
514
+ if (typeof text === 'string') for (const m of text.matchAll(/\$([A-Za-z_][A-Za-z0-9_.]*)/g)) add(m[1])
515
+ }
516
+ return reach
517
+ }
518
+
519
+ // The f64x2 stdlib mirrors the lane vectorizer (optimize/vectorize.js) injects in the LATE 'post'
520
+ // pass — after the stdlib was pulled + treeshaken. Keep in sync with that pass's call-rewrite map
521
+ // (PPC_CALL2). These are the ONLY helpers appendLateStdlib may add; restricting to them avoids
522
+ // touching helpers that live in other module sections (ext-stdlib, imports) where a blind
523
+ // referenced-but-absent scan would wrongly re-append and duplicate them.
524
+ const LATE_VEC_HELPERS = new Set(['math.sin2', 'math.cos2', 'math.pow2', 'math.atan2_2', 'math.hypot_2', 'math.log_v', 'math.exp_v', 'math.exp2_v'])
525
+
526
+ // A late pass can reference one of the f64x2 mirrors that wasn't present when the stdlib was first
527
+ // assembled. Append any referenced-but-missing mirror body (fixpoint over their own calls, though
528
+ // the trig mirrors call nothing). moduleArr is mutated in place; non-mirror references are left for
529
+ // watr to resolve (a genuine missing helper is the kernel's own pull, already satisfied).
530
+ export function appendLateStdlib(moduleArr) {
531
+ const stdlib = ctx.core.stdlib
532
+ const have = new Set()
533
+ for (const n of moduleArr) if (Array.isArray(n) && n[0] === 'func' && typeof n[1] === 'string') have.add(n[1])
534
+ let added = true
535
+ while (added) {
536
+ added = false
537
+ const refs = new Set()
538
+ const scan = (n) => { if (!Array.isArray(n)) return; if ((n[0] === 'call' || n[0] === 'return_call' || n[0] === 'ref.func') && typeof n[1] === 'string' && n[1][0] === '$') refs.add(n[1]); for (const c of n) scan(c) }
539
+ for (const n of moduleArr) scan(n)
540
+ for (const ref of refs) {
541
+ const name = ref.slice(1)
542
+ if (have.has(ref) || !LATE_VEC_HELPERS.has(name) || stdlib[name] == null) continue
543
+ const node = parseTemplate(typeof stdlib[name] === 'function' ? stdlib[name]() : stdlib[name])
544
+ moduleArr.push(node[0] === 'module' ? node[1] : node)
545
+ have.add(ref)
546
+ added = true
547
+ }
548
+ }
549
+ }
550
+
423
551
  /**
424
552
  * Phase: pull stdlib + memory.
425
553
  */
426
554
  export function pullStdlib(sec) {
427
555
  resolveIncludes()
428
556
 
429
- const needsMemory = [...ctx.core.includes].some(n => ctx.core.stdlib[n] && MEM_OPS.test(ctx.core.stdlib[n]))
430
- if (!needsMemory) ctx.scope.globals.delete('__heap')
557
+ // Reachability, not inclusion, decides what the output needs. `ctx.core.includes`
558
+ // accumulates everything a module *might* use (eager module-load `inc`s + transitive
559
+ // deps), but a const array / static string literal calls none of it. So we seed from
560
+ // the actual call sites in the emitted funcs + __start (+ elem table) and close
561
+ // transitively over the stdlib call graph. An eagerly-included helper that nothing
562
+ // calls never enters this set — so allocator, memory, and exports reflect real use.
563
+ const reachable = reachableStdlib(sec)
564
+ const realize = (n) => { const v = ctx.core.stdlib[n]; try { return typeof v === 'function' ? v() : v } catch { return '' } }
565
+
566
+ // Two distinct needs, kept separate:
567
+ // · needsAlloc — the program allocates at runtime: an allocator func is reachable,
568
+ // or shared-mem string literals seed a pool __start allocs. Drives the bump
569
+ // allocator (`__alloc`/`__alloc_hdr`/`__clear`), the `__heap` pointer, and the
570
+ // `_alloc`/`_clear` marshalling exports.
571
+ // · needsMemory — linear memory must merely *exist*: we allocate, OR a literal lives
572
+ // in a static data segment (a const pointer, no allocator behind it), OR a reached
573
+ // helper / inline body does a load/store, OR `__ptr_type` is reached (the module
574
+ // discriminates heap tags — an `instanceof`/`typeof x==='object'` whose argument the
575
+ // host marshals across the boundary). A data segment with no memory is invalid wasm,
576
+ // so memory can't be gated on allocation alone.
577
+ const ALLOC_FUNCS = ['__alloc', '__alloc_hdr', '__alloc_hdr_n']
578
+ const needsAlloc = !!ctx.runtime.strPool || ALLOC_FUNCS.some(a => reachable.has(a))
579
+ // Memory ops can be emitted *inline* into user/start funcs (a heap-path char read
580
+ // loads without calling a stdlib helper), so scan the emitted bodies too.
581
+ const hasMemOp = (node) => Array.isArray(node) &&
582
+ ((typeof node[0] === 'string' && MEM_OPS.test(node[0])) || node.some(hasMemOp))
583
+ // `ctx.runtime.data` is never empty here — the number module seeds a static stringify
584
+ // prefix (`NaNInfinity…`) at offset 0; stripStaticDataPrefix removes it when unused, so
585
+ // the real question is whether any data lives *beyond* that strippable prefix.
586
+ // An explicit `{ memory: pages }` / shared-memory option is a caller request to own
587
+ // linear memory (e.g. to marshal host values in), independent of what the wasm itself
588
+ // reaches — honour it even for an otherwise-memoryless program.
589
+ const explicitMemory = ctx.memory.pages > 0 || !!ctx.memory.shared
590
+ const needsMemory = needsAlloc || explicitMemory ||
591
+ (ctx.runtime.data?.length || 0) > (ctx.runtime.staticDataLen || 0) ||
592
+ reachable.has('__ptr_type') ||
593
+ [...reachable].some(n => MEM_OPS.test(realize(n))) ||
594
+ sec.funcs.some(hasMemOp) || sec.start.some(hasMemOp)
595
+ // Emit only what's reachable: drop every eagerly-`inc`'d *internal* helper the program
596
+ // never calls. This is what lets a const-array / static-string / atom module shed the
597
+ // allocator, pointer dispatchers, and length helpers that an array/object module load
598
+ // pulled in wholesale — and it keeps the dead allocator from dangling on the `$__heap`
599
+ // we delete below. Scoped to `__`-prefixed names: module-namespace funcs (`math.sin`)
600
+ // are pulled in on demand, never eagerly, so they're already minimal and never pruned
601
+ // here (guarding against any reachability blind spot in a dotted-name template).
602
+ for (const n of [...ctx.core.includes]) if (n.startsWith('__') && !reachable.has(n)) ctx.core.includes.delete(n)
603
+ if (!needsAlloc) ctx.scope.globals.delete('__heap')
431
604
  if (needsMemory && ctx.module.modules.core) {
432
- for (const fn of ['__alloc', '__alloc_hdr', '__clear']) ctx.core.includes.add(fn)
433
- // Late-add of allocators may pull in transitive deps (__alloc → __memgrow,
434
- // etc.) that the initial resolveIncludes did not yet see; re-resolve.
435
- // No-op when the alloc trio was already present.
436
- resolveIncludes()
437
- const pages = ctx.memory.pages || 1
438
- if (ctx.memory.shared) sec.imports.push(['import', '"env"', '"memory"', ['memory', pages]])
439
- else sec.memory.push(['memory', ['export', '"memory"'], pages])
440
- if (ctx.transform.alloc !== false && ctx.core._allocRawFuncs)
605
+ if (needsAlloc) {
606
+ for (const fn of ['__alloc', '__alloc_hdr', '__clear']) ctx.core.includes.add(fn)
607
+ // Late-add of allocators may pull in transitive deps (__alloc → __memgrow,
608
+ // etc.) that the initial resolveIncludes did not yet see; re-resolve.
609
+ // No-op when the alloc trio was already present.
610
+ resolveIncludes()
611
+ }
612
+ // Initial pages must cover the static data segment (it loads at instantiation), not
613
+ // just the default 1 — otherwise a module whose constants exceed 64 KiB emits a data
614
+ // segment that overflows its own memory. The heap grows past this on demand via
615
+ // __memgrow. (Shared memory loads literals via memory.init into allocated space, so
616
+ // its initial size isn't pinned by the data length.)
617
+ const dataPages = ctx.memory.shared ? 0 : Math.ceil((ctx.runtime.data?.length || 0) / 65536)
618
+ const pages = Math.max(ctx.memory.pages || 1, dataPages)
619
+ const max = ctx.memory.max || 0 // 0 = no maximum (unbounded growth)
620
+ if (ctx.memory.shared) sec.imports.push(['import', '"env"', '"memory"', max ? ['memory', pages, max] : ['memory', pages]])
621
+ else sec.memory.push(max ? ['memory', ['export', '"memory"'], pages, max] : ['memory', ['export', '"memory"'], pages])
622
+ if (needsAlloc && ctx.transform.alloc !== false && ctx.core._allocRawFuncs)
441
623
  sec.funcs.push(...ctx.core._allocRawFuncs.map(parseTemplate))
442
624
  }
443
625