jz 0.6.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 (63) hide show
  1. package/README.md +99 -72
  2. package/bench/README.md +253 -100
  3. package/bench/bench.svg +58 -81
  4. package/cli.js +85 -12
  5. package/dist/interop.js +1 -0
  6. package/dist/jz.js +6196 -0
  7. package/index.d.ts +126 -0
  8. package/index.js +222 -35
  9. package/interop.js +240 -141
  10. package/layout.js +34 -18
  11. package/module/array.js +99 -111
  12. package/module/collection.js +124 -30
  13. package/module/console.js +1 -1
  14. package/module/core.js +163 -19
  15. package/module/json.js +3 -3
  16. package/module/math.js +162 -3
  17. package/module/number.js +268 -13
  18. package/module/object.js +37 -5
  19. package/module/regex.js +8 -7
  20. package/module/simd.js +37 -5
  21. package/module/string.js +203 -168
  22. package/module/typedarray.js +565 -112
  23. package/package.json +26 -7
  24. package/src/abi/string.js +29 -29
  25. package/src/ast.js +19 -2
  26. package/src/autoload.js +3 -0
  27. package/src/compile/analyze-scans.js +174 -11
  28. package/src/compile/analyze.js +101 -4
  29. package/src/compile/cse-load.js +200 -0
  30. package/src/compile/emit-assign.js +82 -20
  31. package/src/compile/emit.js +592 -51
  32. package/src/compile/index.js +504 -89
  33. package/src/compile/infer.js +36 -1
  34. package/src/compile/loop-divmod.js +109 -0
  35. package/src/compile/loop-model.js +91 -0
  36. package/src/compile/loop-square.js +102 -0
  37. package/src/compile/narrow.js +275 -41
  38. package/src/compile/peel-stencil.js +215 -0
  39. package/src/compile/plan/advise.js +55 -1
  40. package/src/compile/plan/common.js +29 -0
  41. package/src/compile/plan/index.js +8 -1
  42. package/src/compile/plan/inline.js +180 -22
  43. package/src/compile/plan/literals.js +313 -24
  44. package/src/compile/plan/scope.js +115 -39
  45. package/src/compile/program-facts.js +21 -2
  46. package/src/ctx.js +96 -19
  47. package/src/helper-counters.js +137 -0
  48. package/src/ir.js +157 -20
  49. package/src/kind-traits.js +34 -3
  50. package/src/kind.js +79 -4
  51. package/src/op-policy.js +5 -2
  52. package/src/ops.js +119 -0
  53. package/src/optimize/index.js +1274 -151
  54. package/src/optimize/recurse.js +182 -0
  55. package/src/optimize/vectorize.js +4187 -253
  56. package/src/prepare/index.js +75 -14
  57. package/src/prepare/lift-iife.js +149 -0
  58. package/src/reps.js +6 -2
  59. package/src/static.js +9 -0
  60. package/src/type.js +63 -51
  61. package/src/wat/assemble.js +286 -21
  62. package/src/widen.js +21 -0
  63. package/src/wat/optimize.js +0 -3760
@@ -37,8 +37,9 @@ 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
+ import { installHelperCounters, instrumentHelperCounter } from '../helper-counters.js'
42
43
 
43
44
  // NaN-prefix top-13-bits as BigInt — used by the static-prefix-strip pass
44
45
  const NAN_PREFIX = BigInt(LAYOUT.NAN_PREFIX)
@@ -153,6 +154,11 @@ export function buildStartFn(ast, sec, closureFuncs, compilePendingClosures) {
153
154
  analyzeValTypes(ast)
154
155
  const normalizeIR = ir => !ir?.length ? [] : Array.isArray(ir[0]) ? ir : [ir]
155
156
 
157
+ // Mark module-scope emission: top-level statements run exactly once, so a constant
158
+ // array/object literal here is a single instance that can safely live in a static
159
+ // data segment (no per-call freshness to violate). Function bodies — compiled
160
+ // separately, and the late closures below — leave this unset and alloc fresh.
161
+ ctx.func.atModuleScope = true
156
162
  const moduleInits = []
157
163
  if (ctx.module.moduleInits) {
158
164
  for (const mi of ctx.module.moduleInits) {
@@ -162,7 +168,13 @@ export function buildStartFn(ast, sec, closureFuncs, compilePendingClosures) {
162
168
  }
163
169
  }
164
170
  seedGeneratedLocals(ast)
165
- const init = emit(ast)
171
+ // __start has no result: emit the top-level program in void context so a stray
172
+ // value is dropped. `ast` is normally a `;` statement-sequence (each statement
173
+ // already void-dropped), but jzify unwraps a single-statement program to its
174
+ // bare expression — emitting that in value context leaves a value on the stack
175
+ // and the start function fails validation. emitVoid handles both shapes.
176
+ const init = emitVoid(ast)
177
+ ctx.func.atModuleScope = false
166
178
 
167
179
  // Module-scope object literals can create closure bodies while `emit(ast)`
168
180
  // runs. Those late closures may pull in stdlib helpers (notably JSON.parse)
@@ -210,6 +222,7 @@ export function buildStartFn(ast, sec, closureFuncs, compilePendingClosures) {
210
222
  // table holding only empty schemas is pure dead weight there. __json_obj has
211
223
  // no such guard — it must read the table whenever stringify is in play.
212
224
  const tblConsumed = hasStringify ||
225
+ ctx.core.includes.has('__obj_clone') ||
213
226
  ctx.core.includes.has('__dyn_get') ||
214
227
  ctx.core.includes.has('__dyn_get_t') ||
215
228
  ctx.core.includes.has('__dyn_get_t_h') ||
@@ -219,7 +232,13 @@ export function buildStartFn(ast, sec, closureFuncs, compilePendingClosures) {
219
232
  ctx.core.includes.has('__dyn_get_any_t_h') ||
220
233
  ctx.core.includes.has('__dyn_get_expr') ||
221
234
  ctx.core.includes.has('__dyn_get_expr_t') ||
222
- ctx.core.includes.has('__dyn_get_or')
235
+ ctx.core.includes.has('__dyn_get_or') ||
236
+ // A string runtime-key WRITE `o[k]=v` whose `k` matches a schema field must
237
+ // mirror the value into the fixed schema slot (buildObjectSchemaSetArm), or a
238
+ // later static `o.x` read returns the stale slot. That mirror is gated on
239
+ // `$__schema_tbl != 0`, so a write-only module (no `__dyn_get*`) must still
240
+ // build the table. (needsSchemaTbl below skips it when every schema is empty.)
241
+ ctx.core.includes.has('__dyn_set')
223
242
  const needsSchemaTbl = (ctx.schema.list.length && tblConsumed &&
224
243
  (hasStringify || ctx.schema.list.some(s => s.length > 0))) ||
225
244
  hasJpObj
@@ -285,6 +304,47 @@ export function buildStartFn(ast, sec, closureFuncs, compilePendingClosures) {
285
304
  sec.funcs.unshift(...closureFuncs.slice(beforeLateClosures))
286
305
  }
287
306
 
307
+ /**
308
+ * Hoist constant global initializers out of `__start` into immutable inline decls.
309
+ *
310
+ * A top-level `const x = <constant>` for a non-numeric value (atom `true`/`null`/
311
+ * `undefined`/`NaN`, an SSO or static-string NaN-box, a folded pointer) emits a
312
+ * `(global.set $x (f64.const …))` into `__start`, because only *numeric* consts are
313
+ * folded ahead of emit. But the value is a compile-time constant, so it belongs in
314
+ * the decl itself — `(global $x f64 (f64.const …))` — exactly like the numeric path.
315
+ * That drops the store, and when it empties `__start` the start function and its
316
+ * directive go too. Gated to single-assignment user `const`s so we never freeze a
317
+ * binding something else writes.
318
+ */
319
+ export function hoistConstGlobalInits(sec) {
320
+ const startFn = sec.start.find(n => Array.isArray(n) && n[0] === 'func' && n[1] === '$__start')
321
+ if (!startFn) return
322
+ const writes = new Map()
323
+ const scan = (node) => {
324
+ if (!Array.isArray(node)) return
325
+ if (node[0] === 'global.set' && typeof node[1] === 'string') writes.set(node[1], (writes.get(node[1]) || 0) + 1)
326
+ for (const c of node) scan(c)
327
+ }
328
+ for (const arr of [sec.funcs, sec.stdlib, sec.start]) for (const fn of arr) scan(fn)
329
+ for (let i = startFn.length - 1; i >= findBodyStart(startFn); i--) {
330
+ const stmt = startFn[i]
331
+ if (!Array.isArray(stmt) || stmt[0] !== 'global.set' || writes.get(stmt[1]) !== 1) continue
332
+ const name = typeof stmt[1] === 'string' && stmt[1][0] === '$' ? stmt[1].slice(1) : null
333
+ const g = name && ctx.scope.globals.get(name)
334
+ const c = stmt[2]
335
+ if (!g || !g.mut || !ctx.scope.consts?.has(name) || !ctx.scope.userGlobals?.has(name)) continue
336
+ if (!Array.isArray(c) || c[0] !== `${g.type}.const`) continue
337
+ ctx.scope.globals.set(name, { ...g, mut: false, init: c[1] })
338
+ startFn.splice(i, 1)
339
+ }
340
+ // Hoisting can empty `__start`. The O2 watr pass prunes a bodyless start, but at
341
+ // O0/O1 nothing else does — drop it (func + directive) here so a const-only module
342
+ // carries no start at all.
343
+ if (findBodyStart(startFn) >= startFn.length)
344
+ for (let j = sec.start.length - 1; j >= 0; j--)
345
+ if (Array.isArray(sec.start[j]) && sec.start[j][1] === '$__start') sec.start.splice(j, 1)
346
+ }
347
+
288
348
  /**
289
349
  * Phase: closure-body dedup.
290
350
  *
@@ -420,24 +480,188 @@ export function finalizeClosureTable(sec) {
420
480
  for (const fn of sec.start) rewriteCalls(fn)
421
481
  }
422
482
 
483
+ /**
484
+ * Stdlib funcs actually reachable from the emitted program. Seeds from real
485
+ * `call`/`return_call`/`ref.func` sites in the user funcs, `__start`, and the elem
486
+ * table, then closes transitively over the stdlib call graph (each reached helper's
487
+ * template references). Conservative by construction — a template `$__foo` in a
488
+ * feature-dead branch is kept, never dropped — so it's safe to gate inclusion and the
489
+ * memory/allocator decision on it. An eagerly-`inc`'d helper that nothing calls is
490
+ * absent, which is the whole point.
491
+ */
492
+ function reachableStdlib(sec) {
493
+ const stdlib = ctx.core.stdlib
494
+ const reach = new Set(), stack = []
495
+ // Track every reached name (module-namespace `math.sin` included), but only follow
496
+ // those with a stdlib template. Names match `$foo`, `$__foo`, `$math.sin_core` — the
497
+ // dotted module funcs are the ones the `$__`-only regex used to miss, pruning live code.
498
+ const add = (name) => { if (!reach.has(name)) { reach.add(name); if (stdlib[name] != null) stack.push(name) } }
499
+ const scanIR = (node) => {
500
+ if (!Array.isArray(node)) return
501
+ if ((node[0] === 'call' || node[0] === 'return_call' || node[0] === 'ref.func') &&
502
+ typeof node[1] === 'string' && node[1][0] === '$') add(node[1].slice(1))
503
+ for (const c of node) scanIR(c)
504
+ }
505
+ for (const fn of sec.funcs) scanIR(fn)
506
+ for (const fn of sec.start) scanIR(fn)
507
+ for (const e of sec.elem) // closure table: bare `$fn` func refs
508
+ if (Array.isArray(e)) for (const c of e) if (typeof c === 'string' && c[0] === '$') add(c.slice(1))
509
+ // A stdlib func that self-exports (`(export "__invoke_closure")`) is a host-facing
510
+ // entry point — the JS host calls it directly, so it's a root even when nothing in
511
+ // the wasm calls it. Mirrors treeshake's inline-export rooting.
512
+ for (const n of ctx.core.includes) {
513
+ const v = stdlib[n]
514
+ let t = ''
515
+ try { t = typeof v === 'function' ? v() : v } catch { t = '' }
516
+ if (typeof t === 'string' && t.includes('(export "')) add(n)
517
+ }
518
+ while (stack.length) {
519
+ const v = stdlib[stack.pop()]
520
+ let text = ''
521
+ try { text = typeof v === 'function' ? v() : v } catch { text = '' }
522
+ if (typeof text === 'string') for (const m of text.matchAll(/\$([A-Za-z_][A-Za-z0-9_.]*)/g)) add(m[1])
523
+ }
524
+ return reach
525
+ }
526
+
527
+ // The f64x2 stdlib mirrors the lane vectorizer (optimize/vectorize.js) injects in the LATE 'post'
528
+ // pass — after the stdlib was pulled + treeshaken. Keep in sync with that pass's call-rewrite map
529
+ // (PPC_CALL2). These are the ONLY helpers appendLateStdlib may add; restricting to them avoids
530
+ // touching helpers that live in other module sections (ext-stdlib, imports) where a blind
531
+ // referenced-but-absent scan would wrongly re-append and duplicate them.
532
+ 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'])
533
+
534
+ // A late pass can reference one of the f64x2 mirrors that wasn't present when the stdlib was first
535
+ // assembled. Append any referenced-but-missing mirror body (fixpoint over their own calls, though
536
+ // the trig mirrors call nothing). moduleArr is mutated in place; non-mirror references are left for
537
+ // watr to resolve (a genuine missing helper is the kernel's own pull, already satisfied).
538
+ export function appendLateStdlib(moduleArr) {
539
+ const stdlib = ctx.core.stdlib
540
+ const have = new Set()
541
+ for (const n of moduleArr) if (Array.isArray(n) && n[0] === 'func' && typeof n[1] === 'string') have.add(n[1])
542
+ let added = true
543
+ while (added) {
544
+ added = false
545
+ const refs = new Set()
546
+ 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) }
547
+ for (const n of moduleArr) scan(n)
548
+ for (const ref of refs) {
549
+ const name = ref.slice(1)
550
+ if (have.has(ref) || !LATE_VEC_HELPERS.has(name) || stdlib[name] == null) continue
551
+ const node = parseTemplate(typeof stdlib[name] === 'function' ? stdlib[name]() : stdlib[name])
552
+ moduleArr.push(node[0] === 'module' ? node[1] : node)
553
+ have.add(ref)
554
+ added = true
555
+ }
556
+ }
557
+ }
558
+
423
559
  /**
424
560
  * Phase: pull stdlib + memory.
425
561
  */
426
562
  export function pullStdlib(sec) {
563
+ installHelperCounters()
427
564
  resolveIncludes()
428
565
 
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')
566
+ // Reachability, not inclusion, decides what the output needs. `ctx.core.includes`
567
+ // accumulates everything a module *might* use (eager module-load `inc`s + transitive
568
+ // deps), but a const array / static string literal calls none of it. So we seed from
569
+ // the actual call sites in the emitted funcs + __start (+ elem table) and close
570
+ // transitively over the stdlib call graph. An eagerly-included helper that nothing
571
+ // calls never enters this set — so allocator, memory, and exports reflect real use.
572
+ const reachable = reachableStdlib(sec)
573
+ const realize = (n) => { const v = ctx.core.stdlib[n]; try { return typeof v === 'function' ? v() : v } catch { return '' } }
574
+
575
+ // Two distinct needs, kept separate:
576
+ // · needsAlloc — the program allocates at runtime: an allocator func is reachable,
577
+ // or shared-mem string literals seed a pool __start allocs. Drives the bump
578
+ // allocator (`__alloc`/`__alloc_hdr`/`__clear`), the `__heap` pointer, and the
579
+ // `_alloc`/`_clear` marshalling exports.
580
+ // · needsMemory — linear memory must merely *exist*: we allocate, OR a literal lives
581
+ // in a static data segment (a const pointer, no allocator behind it), OR a reached
582
+ // helper / inline body does a load/store, OR `__ptr_type` is reached (the module
583
+ // discriminates heap tags — an `instanceof`/`typeof x==='object'` whose argument the
584
+ // host marshals across the boundary). A data segment with no memory is invalid wasm,
585
+ // so memory can't be gated on allocation alone.
586
+ const ALLOC_FUNCS = ['__alloc', '__alloc_hdr', '__alloc_hdr_n']
587
+ const needsAlloc = !!ctx.runtime.strPool || ALLOC_FUNCS.some(a => reachable.has(a))
588
+ // Memory ops can be emitted *inline* into user/start funcs (a heap-path char read
589
+ // loads without calling a stdlib helper), so scan the emitted bodies too.
590
+ const hasMemOp = (node) => Array.isArray(node) &&
591
+ ((typeof node[0] === 'string' && MEM_OPS.test(node[0])) || node.some(hasMemOp))
592
+ // `ctx.runtime.data` is never empty here — the number module seeds a static stringify
593
+ // prefix (`NaNInfinity…`) at offset 0; stripStaticDataPrefix removes it when unused, so
594
+ // the real question is whether any data lives *beyond* that strippable prefix.
595
+ // An explicit `{ memory: pages }` / shared-memory option is a caller request to own
596
+ // linear memory (e.g. to marshal host values in), independent of what the wasm itself
597
+ // reaches — honour it even for an otherwise-memoryless program.
598
+ const explicitMemory = ctx.memory.pages > 0 || !!ctx.memory.shared
599
+ const needsMemory = needsAlloc || explicitMemory ||
600
+ (ctx.runtime.data?.length || 0) > (ctx.runtime.staticDataLen || 0) ||
601
+ reachable.has('__ptr_type') ||
602
+ [...reachable].some(n => MEM_OPS.test(realize(n))) ||
603
+ sec.funcs.some(hasMemOp) || sec.start.some(hasMemOp)
604
+ // Emit only what's reachable: drop every eagerly-`inc`'d *internal* helper the program
605
+ // never calls. This is what lets a const-array / static-string / atom module shed the
606
+ // allocator, pointer dispatchers, and length helpers that an array/object module load
607
+ // pulled in wholesale — and it keeps the dead allocator from dangling on the `$__heap`
608
+ // we delete below. Scoped to `__`-prefixed names: module-namespace funcs (`math.sin`)
609
+ // are pulled in on demand, never eagerly, so they're already minimal and never pruned
610
+ // here (guarding against any reachability blind spot in a dotted-name template).
611
+ for (const n of [...ctx.core.includes]) if (n.startsWith('__') && !reachable.has(n)) ctx.core.includes.delete(n)
612
+ // Lazy Eisel-Lemire table injection: only when __dec_to_f64 (correctly-rounded
613
+ // decimal→f64, module/number.js) survived pruning — append its trimmed 131-entry
614
+ // (~2KB) power-of-10 table and declare $__el_tbl = that offset. Must run HERE so
615
+ // dataPages (below) accounts for the addition; keeps it out of programs that never
616
+ // parse decimals at runtime.
617
+ if (ctx.core.includes.has('__dec_to_f64') && ctx.runtime.elTable) {
618
+ const elBefore = ctx.runtime.data.length
619
+ while (ctx.runtime.data.length % 8 !== 0) ctx.runtime.data += '\0'
620
+ const elTblOff = ctx.runtime.data.length
621
+ ctx.runtime.data += ctx.runtime.elTable
622
+ declGlobal('__el_tbl', 'i32', elTblOff, { mut: false })
623
+ ctx.runtime.elTable = null // prevent double-injection on re-entry (null-sentinel; jz forbids delete)
624
+ // Reachability here OVER-counts __dec_to_f64: a dead inlined helper's `arr[i] | 0`
625
+ // on an untyped param pulls __to_num → __dec_to_f64, landing the table even when no
626
+ // LIVE code parses decimals. Record the appended span (padding + table, always the
627
+ // data tail — it's the last append) so stripDeadElTable can drop it post-lowering,
628
+ // once reachability is exact. See stripDeadElTable.
629
+ ctx.runtime.elTableLen = ctx.runtime.data.length - elBefore
630
+ }
631
+ if (!needsAlloc) { ctx.scope.globals.delete('__heap'); ctx.scope.globals.delete('__heap_reset') }
431
632
  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)
633
+ if (needsAlloc) {
634
+ for (const fn of ['__alloc', '__alloc_hdr', '__clear']) ctx.core.includes.add(fn)
635
+ // Late-add of allocators may pull in transitive deps (__alloc → __memgrow,
636
+ // etc.) that the initial resolveIncludes did not yet see; re-resolve.
637
+ // No-op when the alloc trio was already present.
638
+ resolveIncludes()
639
+ // Record the post-init heap top into `__heap_reset` so `__clear` rewinds to
640
+ // just above this module's init-time heap state (e.g. the self-host compiler's
641
+ // GLOBALS/atom tables), not into it. Done here where `__heap` is known to
642
+ // survive — as the last `__start` action before any non-returning timer loop.
643
+ // No `__start` ⇒ no init allocations ⇒ `__heap_reset`'s data-end seed is right.
644
+ if (!ctx.memory.shared && ctx.scope.globals.has('__heap_reset')) {
645
+ const startFn = sec.start.find(n => Array.isArray(n) && n[0] === 'func' && n[1] === '$__start')
646
+ if (startFn) {
647
+ const capture = ['global.set', '$__heap_reset', ['global.get', '$__heap']]
648
+ const tail = startFn[startFn.length - 1]
649
+ if (Array.isArray(tail) && tail[0] === 'call' && tail[1] === '$__timer_loop') startFn.splice(startFn.length - 1, 0, capture)
650
+ else startFn.push(capture)
651
+ }
652
+ }
653
+ }
654
+ // Initial pages must cover the static data segment (it loads at instantiation), not
655
+ // just the default 1 — otherwise a module whose constants exceed 64 KiB emits a data
656
+ // segment that overflows its own memory. The heap grows past this on demand via
657
+ // __memgrow. (Shared memory loads literals via memory.init into allocated space, so
658
+ // its initial size isn't pinned by the data length.)
659
+ const dataPages = ctx.memory.shared ? 0 : Math.ceil((ctx.runtime.data?.length || 0) / 65536)
660
+ const pages = Math.max(ctx.memory.pages || 1, dataPages)
661
+ const max = ctx.memory.max || 0 // 0 = no maximum (unbounded growth)
662
+ if (ctx.memory.shared) sec.imports.push(['import', '"env"', '"memory"', max ? ['memory', pages, max] : ['memory', pages]])
663
+ else sec.memory.push(max ? ['memory', ['export', '"memory"'], pages, max] : ['memory', ['export', '"memory"'], pages])
664
+ if (needsAlloc && ctx.transform.alloc !== false && ctx.core._allocRawFuncs)
441
665
  sec.funcs.push(...ctx.core._allocRawFuncs.map(parseTemplate))
442
666
  }
443
667
 
@@ -455,7 +679,7 @@ export function pullStdlib(sec) {
455
679
  }
456
680
  }
457
681
  for (const n of ctx.core.includes) if (!ctx.core.stdlib[n]) err(`internal: stdlib '${n}' was requested but never registered (this is a jz bug — feature pulled in something it can't deliver)`)
458
- sec.stdlib.push(...[...ctx.core.includes].map(n => parseTemplate(stdlibStr(n))))
682
+ sec.stdlib.push(...[...ctx.core.includes].map(n => instrumentHelperCounter(n, parseTemplate(stdlibStr(n)))))
459
683
  }
460
684
 
461
685
  export function syncImports(sec) {
@@ -524,17 +748,58 @@ export function optimizeModule(sec, profiler) {
524
748
  if (dataLen > 1024 && !ctx.memory.shared) {
525
749
  const heapBase = (dataLen + 7) & ~7
526
750
  // Non-shared memory always carries a $__heap global — start it past the
527
- // static data so the bump allocator never overwrites a literal.
751
+ // static data so the bump allocator never overwrites a literal. `__heap_reset`
752
+ // seeds to the same data end (its runtime value is overwritten by `__start`'s
753
+ // tail capture for modules that init-allocate; this seed serves modules with no
754
+ // `__start`, where the data end IS the correct rewind point). `__clear` reads
755
+ // `$__heap_reset` directly, so no per-function constant patch is needed.
528
756
  declGlobal('__heap', 'i32', heapBase, { export: '__heap' })
757
+ if (ctx.scope.globals.has('__heap_reset')) declGlobal('__heap_reset', 'i32', heapBase)
529
758
  if (ctx.scope.globals.has('__heap_start')) declGlobal('__heap_start', 'i32', heapBase)
530
- for (const s of sec.stdlib)
531
- if (s[0] === 'func' && s[1] === '$__clear')
532
- for (let i = 2; i < s.length; i++)
533
- if (Array.isArray(s[i]) && s[i][0] === 'global.set' && Array.isArray(s[i][2]) && s[i][2][0] === 'i32.const')
534
- s[i][2][1] = `${heapBase}`
535
759
  }
536
760
  }
537
761
 
762
+ /**
763
+ * Phase: strip the Eisel-Lemire table when it is dead.
764
+ *
765
+ * pullStdlib injects the ~2 KB power-of-10 table whenever `__dec_to_f64` is *reachable*,
766
+ * but that over-counts: a dead inlined helper's `arr[i] | 0` on an untyped param pulls
767
+ * `__to_num` → `__dec_to_f64`, so the table lands even in a module no live code parses
768
+ * decimals in. watr later treeshakes the dead function + its `$__el_tbl` global, but it
769
+ * does NOT treeshake the data segment — so the orphaned table bloated every module ~2 KB.
770
+ *
771
+ * This runs LAST (after every lowering has emitted its call/ref.func — doing it earlier is
772
+ * unsound: refs like `util.clone` are emitted *after* pullStdlib), so a mark-sweep from the
773
+ * real roots (inline-exported funcs, __start, the closure table, globals/tags/table) gives
774
+ * EXACT liveness. If `__dec_to_f64` is dead, truncate the table from the data tail (it is
775
+ * the last append — see pullStdlib). DATA only: the dead function + global are left for
776
+ * watr, which already removes them. Keeps correctly-rounded decimal parsing wherever it is
777
+ * genuinely live (parseFloat, the self-host compiler's `Number()` on source literals).
778
+ */
779
+ export function stripDeadElTable(sec) {
780
+ if (!ctx.runtime.elTableLen) return
781
+ const byName = new Map()
782
+ for (const arr of [sec.funcs, sec.stdlib, sec.start])
783
+ for (const f of arr || []) if (Array.isArray(f) && f[0] === 'func' && typeof f[1] === 'string') byName.set(f[1], f)
784
+ const live = new Set(), work = []
785
+ const mark = (ref) => { if (typeof ref === 'string' && byName.has(ref) && !live.has(ref)) { live.add(ref); work.push(ref) } }
786
+ const scan = (n) => {
787
+ if (!Array.isArray(n)) return
788
+ if ((n[0] === 'call' || n[0] === 'return_call' || n[0] === 'ref.func') && typeof n[1] === 'string') mark(n[1])
789
+ for (const c of n) scan(c)
790
+ }
791
+ for (const f of sec.funcs) if (f.some(el => Array.isArray(el) && el[0] === 'export')) mark(f[1])
792
+ for (const f of sec.start) scan(f)
793
+ for (const part of [sec.elem, sec.globals, sec.tags, sec.table]) for (const n of part || []) {
794
+ if (!Array.isArray(n)) continue
795
+ for (const c of n) { if (typeof c === 'string' && c[0] === '$') mark(c); else scan(c) }
796
+ }
797
+ while (work.length) scan(byName.get(work.pop()))
798
+ if (live.has('$__dec_to_f64')) return // genuinely parses decimals at runtime — keep it
799
+ ctx.runtime.data = ctx.runtime.data.slice(0, ctx.runtime.data.length - ctx.runtime.elTableLen)
800
+ ctx.runtime.elTableLen = 0
801
+ }
802
+
538
803
  /**
539
804
  * Phase: strip static-data prefix.
540
805
  */
package/src/widen.js ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Numeric widening thresholds — the SINGLE SOURCE for "when does an i32 arithmetic
3
+ * op stay i32 vs widen to f64", shared by the two phases that must agree:
4
+ * • emit.js DECIDES — emits `i32.mul`/`i32.add` or widens to `f64.mul`/`f64.add`
5
+ * • type.js MIRRORS — exprType predicts the same i32/f64 so locals are typed right
6
+ *
7
+ * SOUNDNESS INVARIANT (one-way, unforgiving): exprType's i32 verdict must be a SUBSET
8
+ * of emit's — exprType may answer i32 only where emit DEFINITELY produces i32. If type
9
+ * says i32 but emit yields f64, the result is `trunc_sat`-narrowed back to i32 → silent
10
+ * miscompile. The two predicates can't share a function (emit reads IR values via
11
+ * isLit/maskBound, type reads AST via staticValue), but they MUST share this threshold,
12
+ * or a future edit to one silently drifts the other out of the safe subset.
13
+ *
14
+ * @module widen
15
+ */
16
+
17
+ // JS `*` is an f64 multiply; `i32.mul` agrees only while the exact product stays
18
+ // f64-exact (|product| ≤ 2^53). Against the full i32 range (2^31) of one operand, the
19
+ // other must be bounded |v| ≤ 2^22 for the product to hold within 2^53 — so a literal
20
+ // or provably-masked operand of that magnitude keeps the multiply on `i32.mul`.
21
+ export const FITS_I32_MAX = 0x400000 // 2^22