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
@@ -27,7 +27,8 @@
27
27
 
28
28
  import parseWat from 'watr/parse'
29
29
  import { ctx, err, inc, resolveIncludes, PTR, LAYOUT, declGlobal } from '../ctx.js'
30
- import { T, isBlockBody, isReassigned, refsName, REFS_IN_EXPR } from '../ast.js'
30
+ import { T, isBlockBody, isReassigned, refsName, REFS_IN_EXPR, returnExprs } from '../ast.js'
31
+ import { valTypeOf } from '../kind.js'
31
32
  import { intLiteralValue } from '../static.js'
32
33
  import {
33
34
  analyzeBody, unboxablePtrs, cseSafeLoadBases, boxedCaptures,
@@ -38,7 +39,13 @@ import { VAL, updateRep, REP_FIELDS } from '../reps.js'
38
39
  import { inferLocals } from './infer.js'
39
40
  import { optimizeFunc, treeshake } from '../optimize/index.js'
40
41
  import { strengthReduceLoopDivMod } from './loop-divmod.js'
42
+ import { narrowBoundedSquare } from './loop-square.js'
41
43
  import { peelClampedStencil } from './peel-stencil.js'
44
+ import { cseLoads } from './cse-load.js'
45
+
46
+ // Monotonic across all functions so a CSE temp never collides (even after later inlining).
47
+ let __cseCtr = 0
48
+ const freshCseName = () => `${T}cse${__cseCtr++}`
42
49
  import { emit, emitter, emitVoid, emitBlockBody } from './emit.js'
43
50
  import { emitCharDecompPrologue, JSS_IMPORT_SIGS } from '../abi/string.js'
44
51
  import {
@@ -59,8 +66,9 @@ import plan from './plan/index.js'
59
66
  import { foldStaticConstAggregates } from './plan/literals.js'
60
67
  import {
61
68
  buildStartFn, dedupClosureBodies, finalizeClosureTable,
62
- pullStdlib, syncImports, optimizeModule, stripStaticDataPrefix, hoistConstGlobalInits,
69
+ pullStdlib, syncImports, optimizeModule, stripStaticDataPrefix, hoistConstGlobalInits, stripDeadElTable,
63
70
  } from '../wat/assemble.js'
71
+ import { instrumentHelperCallsites } from '../helper-counters.js'
64
72
 
65
73
  // =============================================================================
66
74
  // Single-source export semantics
@@ -133,15 +141,21 @@ const timePhase = (profiler, name, fn) => profiler?.time ? profiler.time(name, f
133
141
  * fractional Number gets the same truncation it would get from `arr[n]`).
134
142
  */
135
143
  const isBoundaryWrapped = (func) => {
136
- if (!isExported(func) || func.raw || func.sig.results.length !== 1) return false
144
+ if (!isExported(func) || func.raw) return false
145
+ // Multi-value return: every lane is an f64 NaN-box carrier (the `return [a,b,…]` emit forces
146
+ // asF64 per lane; result narrowing only touches single-result funcs), so any lane may hold a
147
+ // box whose NaN payload JSC/V8 erases at the boundary — wrap to i64-carry every lane.
148
+ if (func.sig.results.length !== 1) return true
137
149
  if (func.sig.results[0] !== 'f64' || func.sig.ptrKind != null) return true
138
- // A boolean result rides the 0/1 number carrier internally; the export thunk
139
- // boxes it to the TRUE_NAN/FALSE_NAN atom so the host sees a real boolean.
140
- if (func.valResult === VAL.BOOL) return true
141
- // A bigint result rides the i64-reinterpreted-f64 carrier internally; the export thunk converts
142
- // it to a real Number so a JS host doesn't see raw i64 bits (`() => 100n` was returning 4.94e-322).
143
- if (func.valResult === VAL.BIGINT) return true
144
- return func.sig.params.some(p => p.type !== 'f64' || p.ptrKind != null)
150
+ // Any result that isn't a proven plain number can be a NaN-box — a heap pointer,
151
+ // a null/undef/bool atom, a bigint carrier, or a dynamic value — so it crosses as
152
+ // i64 and JSC (Safari) can't canonicalize the payload away. A proven-number result
153
+ // stays f64: free, and a number is never a NaN-box. `_resultNumeric` is set in
154
+ // analyzeFuncForEmit (covers value-bound arrows narrowValResults skips).
155
+ if (!func._resultNumeric) return true
156
+ // Number result, but a param may still carry a box — a pointer-ABI param, or a
157
+ // dynamic f64 param flagged `boundaryI64` during analyze — so wrap for i64 params.
158
+ return func.sig.params.some(p => p.type !== 'f64' || p.ptrKind != null || p.boundaryI64)
145
159
  }
146
160
 
147
161
  // Static-string intern index (the `internStrings` pass). Open-addressing table
@@ -212,20 +226,39 @@ const ensureThrowRuntime = (sec) => {
212
226
  sec.tags.push(['export', '"__jz_last_err_bits"', ['global', '$__jz_last_err_bits']])
213
227
  }
214
228
 
215
- // Drop the $__jz_err tag + __jz_last_err_bits globals when optimization
216
- // eliminated every actual throw site. ensureThrowRuntime runs before
217
- // optimizeModule so dead-throw analysis can see the tag/global as live; once
218
- // opt has finished, an unused tag still forces consumers (wasmtime, wasm2c) to
219
- // enable the exceptions proposal just to parse the module. User-written
220
- // throw/try/catch/finally is an ABI contract (JS-side may inspect
221
- // __jz_last_err_bits), so `userThrows` keeps the runtime declared regardless;
222
- // the prune fires only when `throws` was set purely by stdlib pattern matching
223
- // or compiler-internal coercion sites.
229
+ // Drop the $__jz_err tag + __jz_last_err_bits globals when no throw can be CAUGHT.
230
+ // ensureThrowRuntime runs before optimizeModule so dead-throw analysis sees the
231
+ // tag/global as live; once opt has finished, an unused tag still forces consumers
232
+ // (wasmtime, wasm2c, wabt) to enable the exceptions proposal just to PARSE the module.
233
+ //
234
+ // When `!userThrows`, every `throw` is compiler-internal (bounds / coercion / type
235
+ // errors) and with no user try/catch — uncatchable: nothing inspects the thrown
236
+ // value, so it is semantically a trap. The exceptions proposal is needed only to
237
+ // DECLARE the tag a `throw` references; lowering each surviving uncatchable throw to
238
+ // `unreachable` keeps the module in the wasm MVP, so every runtime can parse it
239
+ // (V8 alone enables exceptions by default, which masked this). A pure-recursion or
240
+ // typed-array kernel (nqueens, anything pulling __to_num) thus stops emitting a Tag
241
+ // section it can never use. User-written throw/try/catch/finally is an ABI contract
242
+ // (JS-side may inspect __jz_last_err_bits), so `userThrows` keeps the runtime intact.
224
243
  const pruneUnusedThrowRuntime = (sec) => {
225
244
  if (!ctx.runtime.throws || ctx.runtime.userThrows) return
226
- const hasThrow = (n) => Array.isArray(n) && (n[0] === 'throw' || n.some(hasThrow))
245
+ // A catch handler (try_table) appears only under userThrows; defensively bail if one
246
+ // is present so a caught throw is never silently turned into a trap.
247
+ const hasCatch = (n) => Array.isArray(n) &&
248
+ (n[0] === 'try_table' || n[0] === 'catch' || n[0] === 'catch_all' || n.some(hasCatch))
227
249
  for (const arr of [sec.funcs, sec.stdlib, sec.start])
228
- for (const f of arr) if (hasThrow(f)) return
250
+ for (const f of arr) if (hasCatch(f)) return
251
+ // Rewrite every surviving `(throw $__jz_err …)` to `(unreachable)` (same polymorphic
252
+ // stack type — a drop-in in any position). The thrown operand is side-effect-free
253
+ // (a local read / const), so dropping it loses nothing.
254
+ const lowerThrows = (n) => {
255
+ if (!Array.isArray(n)) return n
256
+ if (n[0] === 'throw') return ['unreachable']
257
+ for (let i = 1; i < n.length; i++) n[i] = lowerThrows(n[i])
258
+ return n
259
+ }
260
+ for (const arr of [sec.funcs, sec.stdlib, sec.start])
261
+ for (let i = 0; i < arr.length; i++) arr[i] = lowerThrows(arr[i])
229
262
  sec.tags = sec.tags.filter(t => !(Array.isArray(t) &&
230
263
  ((t[0] === 'tag' && t[1] === '$__jz_err') ||
231
264
  (t[0] === 'export' && t[1] === '"__jz_last_err_bits"'))))
@@ -351,6 +384,7 @@ function enterFunc(sig, body, { uniq = 0, directClosures = null } = {}) {
351
384
  ctx.func.stack = []
352
385
  ctx.func.zeroInitSeen = new Set() // names whose `let x=0` zero-init was elided once; a 2nd is a real re-init (unrolled bodies)
353
386
  ctx.func.maybeNullish = new Set() // bindings assigned a nullish literal → coerce in arithmetic (null-flow)
387
+ ctx.func.refinements = new Map() // flow-sensitive type facts (typeof/instanceof guards) — per-function; clear so none leak across bodies
354
388
  ctx.func.pendingLabel = null // label awaiting its loop, for `continue <label>`
355
389
  ctx.func.uniq = uniq
356
390
  ctx.func.current = sig
@@ -388,6 +422,10 @@ function analyzeFuncForEmit(func, programFacts) {
388
422
  // counters are typed/narrowed like any i32 local. Off at L0 / `loopIVDivMod:false`.
389
423
  const _o = ctx.transform.optimize
390
424
  if (_o && _o.loopIVDivMod !== false && isBlockBody(func.body)) func.body = strengthReduceLoopDivMod(func.body)
425
+ // Bounded-square narrowing: `i*i` under an `i*i < CONST` (CONST ≤ 2³⁰) guard → Math.imul,
426
+ // so the sieve's product/counter chain carries i32 instead of f64. Before analyze so the
427
+ // Math.imul typed/narrows like any i32. Off at L0 / `loopSquare:false`.
428
+ if (_o && _o.loopSquare !== false && isBlockBody(func.body)) func.body = narrowBoundedSquare(func.body)
391
429
  // Edge-clamp peeling: split a clamped stencil loop into clamp-free interior + edges
392
430
  // (the interior then lifts to SIMD). Before analyze so the new loops are analyzed.
393
431
  if (_o && _o.clampPeel !== false && isBlockBody(func.body)) func.body = peelClampedStencil(func.body)
@@ -425,7 +463,10 @@ function analyzeFuncForEmit(func, programFacts) {
425
463
  // ToNumber string-parse dep tree (`__to_str`→`__itoa`/`__toExp`/`__mkstr`/…)
426
464
  // treeshake away — a ~4× module shrink that, decisively, lets V8 tier the hot
427
465
  // fill loop up properly (the bloated module JITs the *identical* loop ~2× slower).
428
- if (func.exported && block) {
466
+ // Block AND expression bodies: value-bound arrows (`export let f = (a,b) => a*b`) are
467
+ // skipped by narrowValResults, so without trusting their params here they'd fall to the
468
+ // i64 boundary carrier. The closure path runs the same proof at line ~1300.
469
+ if (func.exported) {
429
470
  for (const p of sig.params) {
430
471
  if (p.type === 'f64' && p.ptrKind == null && !p.jsstring
431
472
  && !func.defaults?.[p.name] && !ctx.func.boxed?.has(p.name)
@@ -438,6 +479,12 @@ function analyzeFuncForEmit(func, programFacts) {
438
479
  updateRep(p.name, { val: VAL.NUMBER })
439
480
  }
440
481
  }
482
+ // Sound load-CSE: cache a repeated pure typed-array load `arr[idx]` when every intervening
483
+ // store writes a provably-different element (idx2 ≠ idx). Recovers the fft butterfly's redundant
484
+ // `re[a]` load. Before analyze so the introduced temp is typed/narrowed like any local.
485
+ if (_o && _o.loadCSE !== false && block && ctx.types.typedElem?.size)
486
+ cseLoads(body, n => ctx.types.typedElem.has(n), freshCseName)
487
+
441
488
  if (block) {
442
489
  seedLocalIntConsts(body)
443
490
  }
@@ -531,6 +578,32 @@ function analyzeFuncForEmit(func, programFacts) {
531
578
  if (ctx.func.localReps?.get(name)?.intCertain === true) cellTypes.add(name)
532
579
  }
533
580
 
581
+ // Snapshot each param's JS-boundary carrier while reps are live — synthesizeBoundaryWrappers
582
+ // runs after they're torn down. A dynamic f64 param crosses as i64 (the carrier JSC can't
583
+ // canonicalize) iff it can hold a NaN-box, i.e. it isn't proven numeric. Numeric (NUMBER /
584
+ // BOOL → 0/1) params keep f64; pointer-ABI (ptrKind, type i32) and jsstring params are
585
+ // classified directly in the wrapper, so leave their flag false here.
586
+ if (isExported(func)) for (const p of sig.params) {
587
+ if (p.jsstring || p.ptrKind != null || p.type !== 'f64') { p.boundaryI64 = false; continue }
588
+ const rv = ctx.func.localReps?.get(p.name)?.val
589
+ p.boundaryI64 = rv !== VAL.NUMBER && rv !== VAL.BOOL
590
+ }
591
+
592
+ // Result-numeric proof for the boundary carrier. Block bodies get func.valResult from
593
+ // narrowValResults; value-bound arrows (`export let f = (a,b) => a*b`) don't, so prove via
594
+ // the return expression(s) with params now trusted numeric. A proven-number f64 result
595
+ // never carries a NaN-box → crosses as plain f64; anything else rides i64 (Safari-safe).
596
+ if (isExported(func))
597
+ func._resultNumeric = func.valResult === VAL.NUMBER ||
598
+ (func.valResult == null && sig.results[0] === 'f64' &&
599
+ (() => {
600
+ const rex = returnExprs(body)
601
+ // Void body (falls off → undefined, which callers ignore) keeps the f64 carrier:
602
+ // undefined isn't a reference, so no i64 is needed and wrapping every void export
603
+ // is pure overhead. A non-empty set must be all-NUMBER to stay f64.
604
+ return rex.length === 0 || rex.every(e => valTypeOf(e) === VAL.NUMBER)
605
+ })())
606
+
534
607
  return {
535
608
  block,
536
609
  locals: new Map(ctx.func.locals),
@@ -539,6 +612,7 @@ function analyzeFuncForEmit(func, programFacts) {
539
612
  flatObjects: new Map(ctx.func.flatObjects),
540
613
  sliceViews: new Set(ctx.func.sliceViews),
541
614
  cseLoadBases,
615
+ distinctParams: func.distinctParams || null,
542
616
  typedElem: ctx.types.typedElem ? new Map(ctx.types.typedElem) : null,
543
617
  localReps: cloneRepMap(ctx.func.localReps),
544
618
  }
@@ -958,6 +1032,11 @@ function emitFunc(func, funcFacts, programFacts) {
958
1032
  // it the pass is a no-op. `$`-prefixed to match WAT local names directly.
959
1033
  if (funcFacts.cseLoadBases?.size)
960
1034
  fn.cseLoadBases = new Set([...funcFacts.cseLoadBases].map(n => `$${n}`))
1035
+ // Param-distinctness fact (alias analysis): typed-array params proven mutually-distinct buffers
1036
+ // at every call site. `$`-prefixed to match WAT param names; read by hoistInvariantLoop to hoist
1037
+ // a load from one such param across a store to another (they can't alias).
1038
+ if (funcFacts.distinctParams?.size)
1039
+ fn.distinctParams = new Set([...funcFacts.distinctParams].map(n => `$${n}`))
961
1040
  // Inline `(export ...)` attribute only for the syntactic inline-export
962
1041
  // form (`export function foo`, snapshot in `func.exported` at defFunc
963
1042
  // time). Re-exports (`function foo; export { foo }`) and aliases (`export
@@ -1098,17 +1177,26 @@ function synthesizeBoundaryWrappers() {
1098
1177
  for (const func of ctx.func.list) {
1099
1178
  if (!isBoundaryWrapped(func)) continue
1100
1179
  const { name, sig } = func
1101
- // Quiet NaN-box ABI: every boundary value is f64. A number is a plain f64; a
1102
- // tagged value (heap pointer, null/undef/bool atom) is an f64 whose quiet-NaN
1103
- // (0x7FF8…) payload carries the tag. Quiet-NaN payloads are preserved across the
1104
- // JS↔wasm call boundary by every real engine (and non-JS hosts don't canonicalize
1105
- // at all), so no i64 carrier is needed the wasm signature is self-describing
1106
- // (f64 everywhere) and a consumer discriminates a tagged value by the NaN prefix.
1107
- // Env requirement: a non-canonicalizing NaN boundary. To support a canonicalizing
1108
- // engine, a per-position i64 carrier would re-enter here (param/result type i64 +
1109
- // `i64.reinterpret_f64`) plus a `jz:i64exp` section for interop.js.
1110
- const resultBool = func.valResult === VAL.BOOL && sig.ptrKind == null
1111
- const resultBigint = func.valResult === VAL.BIGINT && sig.ptrKind == null
1180
+ // i64 boundary carrier (Safari-safe). A genuine number is never a NaN-box, so it crosses
1181
+ // as plain f64 (zero cost). Everything that can be a NaN-box — heap pointer, null/undef/
1182
+ // bool atom, bigint carrier, or a dynamic value crosses as i64: JSC (Safari) canonicalizes
1183
+ // f64 NaN payloads at the JS↔wasm boundary, erasing the box. The wasm signature is
1184
+ // self-describing; interop.js wrap() reinterprets BigInt↔f64 by bits, driven by the
1185
+ // `jz:i64exp` section emitted below. Non-JS hosts (WASI) read the same signature i64 is
1186
+ // just int64 there, no BigInt.
1187
+ const resultPtr = sig.ptrKind != null
1188
+ const resultBool = func.valResult === VAL.BOOL && !resultPtr
1189
+ const resultBigint = func.valResult === VAL.BIGINT && !resultPtr
1190
+ // Dynamic f64 result: not pointer/bool/bigint and not a proven number → may be a NaN-box
1191
+ // at runtime, so i64. (An i32-carrier result is numeric → stays f64 via convert below.)
1192
+ const resultDynamic = !resultPtr && !resultBool && !resultBigint
1193
+ && sig.results[0] === 'f64' && !func._resultNumeric
1194
+ const resultI64 = resultPtr || resultBool || resultBigint || resultDynamic
1195
+ // jz:i64exp `r` marks results interop must reinterpret then `mem.read`. A bigint result is
1196
+ // i64 too, but the BigInt *is* the value (no reinterpret) — so it stays unmarked.
1197
+ const resultReinterpret = resultPtr || resultBool || resultDynamic
1198
+ // i64 carrier per param: pointer-ABI (offset) or a dynamic f64 param (boundaryI64).
1199
+ const paramIsI64 = (p) => !p.jsstring && (p.ptrKind != null || p.boundaryI64)
1112
1200
  // Inline `(export ...)` attribute only when the func decl carried the
1113
1201
  // inline-export keyword (`export function foo`). For re-exports
1114
1202
  // (`function foo; export { foo as bar }`) the `name` is the *internal*
@@ -1118,26 +1206,58 @@ function synthesizeBoundaryWrappers() {
1118
1206
  const wrapNode = func.exported
1119
1207
  ? ['func', `$${name}$exp`, ['export', `"${name}"`]]
1120
1208
  : ['func', `$${name}$exp`]
1121
- // jsstring params flow as externref end-to-end; every other boundary value is f64.
1122
- sig.params.forEach((p) => {
1123
- wrapNode.push(['param', `$${p.name}`, p.jsstring ? 'externref' : 'f64'])
1209
+ // jsstring params flow as externref end-to-end; boxed params ride i64; numbers f64.
1210
+ const i64Params = []
1211
+ sig.params.forEach((p, i) => {
1212
+ wrapNode.push(['param', `$${p.name}`, p.jsstring ? 'externref' : paramIsI64(p) ? 'i64' : 'f64'])
1213
+ if (paramIsI64(p)) i64Params.push(i)
1124
1214
  })
1125
- wrapNode.push(['result', resultBigint ? 'i64' : 'f64'])
1215
+ // Track externref param positions so interop.js can pass JS values raw (skipping
1216
+ // `mem.wrapVal`) at those slots — today only `jsstring` params; future externref carriers
1217
+ // wire here too. `extParams` is per-slot: false | { def: '...' } for a JS-side default.
1218
+ const extParams = sig.params.map(p => !p.jsstring ? false : p.jsstringDefault != null ? { def: p.jsstringDefault } : true)
1219
+ if (extParams.some(Boolean)) func._exportExtParams = extParams
1220
+ // Inner→wrapper argument list, shared by both single- and multi-value result shapes.
1126
1221
  const args = sig.params.map((p) => {
1127
1222
  const get = ['local.get', `$${p.name}`]
1128
- // jsstring: externref flows through unchanged — inner func also takes externref.
1129
- if (p.jsstring) return get
1130
- // ptrKind param: the f64 NaN-box carries the pointer — extract the i32 offset.
1131
- if (p.ptrKind != null) return ['i32.wrap_i64', ['i64.reinterpret_f64', get]]
1223
+ if (p.jsstring) return get // externref flows through unchanged
1224
+ if (p.ptrKind != null) return ['i32.wrap_i64', get] // ptr param: inner takes the i32 offset
1225
+ if (p.boundaryI64) return ['f64.reinterpret_i64', get] // dynamic boxed param f64 NaN-box carrier
1132
1226
  if (p.type === 'f64') return get
1133
- // Numeric narrowing: f64 → i32 truncate
1134
- return ['i32.trunc_sat_f64_s', get]
1227
+ return ['i32.trunc_sat_f64_s', get] // numeric narrowing f64 → i32
1135
1228
  })
1136
1229
  const callIR = ['call', `$${name}`, ...args]
1230
+ // Multi-value return: each lane is an f64 NaN-box carrier (every `return [a,b,…]` lane is
1231
+ // asF64; narrowing only touches single-result funcs). A boxed lane's NaN payload is erased
1232
+ // at the JS boundary, so cross EVERY lane as i64 — capture the inner call's N lanes into f64
1233
+ // locals (last result on top of the stack ⇒ pop in reverse) and re-push each reinterpreted.
1234
+ // interop reads the lane tuple via mem.read / decode (both map over an array result).
1235
+ if (sig.results.length > 1) {
1236
+ sig.results.forEach(() => wrapNode.push(['result', 'i64']))
1237
+ // Lane temporaries — guaranteed distinct from the wrapper's params (jz doesn't reserve
1238
+ // `__`, so a user param could be `__mlane0`): bump the prefix until no lane name collides.
1239
+ const pnames = new Set(sig.params.map((p) => p.name))
1240
+ let pfx = '__mlane'
1241
+ while (sig.results.some((_, i) => pnames.has(`${pfx}${i}`))) pfx = `_${pfx}`
1242
+ const lanes = sig.results.map((_, i) => `$${pfx}${i}`)
1243
+ lanes.forEach((n) => wrapNode.push(['local', n, 'f64']))
1244
+ const stmts = [callIR]
1245
+ for (let i = lanes.length - 1; i >= 0; i--) stmts.push(['local.set', lanes[i]])
1246
+ for (const n of lanes) stmts.push(['i64.reinterpret_f64', ['local.get', n]])
1247
+ wrapNode.push(...stmts)
1248
+ // `m` (lane count) marks a multi-value result so interop / the test adapter decode each
1249
+ // lane (vs `r`'s single reinterpret). Always recorded — even with no i64 params — so the
1250
+ // numeric-only `(a,b)=>[a+1,b+2]` tuple still gets its lanes turned back into numbers.
1251
+ func._exportI64 = { p: i64Params, m: sig.results.length }
1252
+ wrappers.push(wrapNode)
1253
+ continue
1254
+ }
1255
+ wrapNode.push(['result', resultI64 ? 'i64' : 'f64'])
1256
+ const toI64 = (n) => ['i64.reinterpret_f64', n]
1137
1257
  let body
1138
- if (sig.ptrKind != null) {
1258
+ if (resultPtr) {
1139
1259
  const ptrType = valKindToPtr(sig.ptrKind)
1140
- body = mkPtrIR(ptrType, sig.ptrAux ?? 0, callIR)
1260
+ body = toI64(mkPtrIR(ptrType, sig.ptrAux ?? 0, callIR))
1141
1261
  } else if (resultBool) {
1142
1262
  // The inner func returns a clean 0/1 boolean carrier — never NaN. The i32
1143
1263
  // carrier already takes truthyIR's identity path; the f64 carrier would
@@ -1147,28 +1267,22 @@ function synthesizeBoundaryWrappers() {
1147
1267
  const carrier = sig.results[0] === 'i32'
1148
1268
  ? typed(callIR, 'i32')
1149
1269
  : typed(['f64.ne', callIR, ['f64.const', 0]], 'i32')
1150
- body = boolBoxIR(carrier)
1151
- } else if (resultBigint) {
1152
- // BigInt rides the i64-reinterpret-f64 carrier internally; expose the raw i64 at the JS
1153
- // boundary so the host receives a real, lossless BigInt (wasm i64 <-> JS BigInt). Internal
1154
- // callers use `$name` (the f64 carrier) untouched; only the `$exp` export result is i64.
1155
- body = ['i64.reinterpret_f64', callIR]
1270
+ body = toI64(boolBoxIR(carrier))
1271
+ } else if (resultBigint || resultDynamic) {
1272
+ // BigInt rides the i64-reinterpret-f64 carrier internally; a dynamic result is already an
1273
+ // f64 NaN-box carrier. Either way expose the raw i64 at the JS boundary for a lossless
1274
+ // value. Internal callers use `$name` (the f64 carrier) untouched; only `$exp` is i64.
1275
+ body = toI64(callIR)
1156
1276
  } else if (sig.results[0] === 'i32') {
1157
1277
  body = [sig.unsignedResult ? 'f64.convert_i32_u' : 'f64.convert_i32_s', callIR]
1158
1278
  } else {
1159
1279
  body = callIR
1160
1280
  }
1161
1281
  wrapNode.push(body)
1162
- // Track externref param positions so interop.js can pass JS values
1163
- // raw (skipping `mem.wrapVal`) at those slots. Today this only fires
1164
- // for `jsstring`-tagged params; future externref carriers wire here too.
1165
- // `extParams` is per-slot: false (non-ext) | { def: '...' }-bearing object
1166
- // for jsstring params with a JS-side default substitution.
1167
- const extParams = sig.params.map(p => {
1168
- if (!p.jsstring) return false
1169
- return p.jsstringDefault != null ? { def: p.jsstringDefault } : true
1170
- })
1171
- if (extParams.some(Boolean)) func._exportExtParams = extParams
1282
+ // Record the i64 carrier map for interop.js (jz:i64exp). A pure-numeric export
1283
+ // (no i64 params, f64 result) records nothing zero footprint off the box path.
1284
+ if (i64Params.length || resultReinterpret)
1285
+ func._exportI64 = { p: i64Params, r: resultReinterpret ? 1 : 0 }
1172
1286
  wrappers.push(wrapNode)
1173
1287
  }
1174
1288
  return wrappers
@@ -1709,6 +1823,7 @@ export default function compile(ast, profiler) {
1709
1823
  ensureThrowRuntime(sec)
1710
1824
 
1711
1825
  timePhase(profiler, 'optimizeModule', () => optimizeModule(sec, profiler))
1826
+ if (ctx.transform.helperCallsites) instrumentHelperCallsites([...sec.funcs, ...sec.stdlib, ...sec.start])
1712
1827
 
1713
1828
  // Fold constant `__start` global inits into immutable inline decls (drops the
1714
1829
  // store, and `__start` with it when that empties it). Runs HERE — after
@@ -1733,6 +1848,11 @@ export default function compile(ast, profiler) {
1733
1848
  [`${ty}.const`, g.init]]
1734
1849
  }))
1735
1850
 
1851
+ // Drop the Eisel-Lemire decimal table if no live code parses decimals at runtime — must
1852
+ // run after sec.globals/funcs are final (exact reachability) and before the data segment
1853
+ // below serializes ctx.runtime.data. See stripDeadElTable.
1854
+ stripDeadElTable(sec)
1855
+
1736
1856
  // Data segments (after emit — string literals append to ctx.runtime.data / strPool during emit)
1737
1857
  // Active segment at address 0 — skipped for shared memory (would collide across modules)
1738
1858
  const escBytes = (s) => {
@@ -1817,6 +1937,23 @@ export default function compile(ast, profiler) {
1817
1937
  if (extExports.length)
1818
1938
  sec.customs.push(['@custom', '"jz:extparam"', `"${JSON.stringify(extExports).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`])
1819
1939
 
1940
+ // jz:i64exp — per-export i64 carrier map (NaN-canonicalization dodging). Each entry
1941
+ // `{name, p:[i64 param indices], r:1? | m:N?}`: `p` lists params interop must pass as BigInt
1942
+ // (f64ToI64); `r` marks a single result to reinterpret (i64ToF64) before mem.read; `m` marks
1943
+ // an N-lane multi-value result whose lanes interop/the adapter decode element-wise. Pure-
1944
+ // numeric single-result exports emit no entry. A bigint result is i64 but unmarked (the BigInt
1945
+ // is the value). Written under every JS-visible alias, like jz:extparam. Each shape is built as
1946
+ // a direct literal (no spread) — the self-host kernel's fixed schemas don't enumerate post-hoc keys.
1947
+ const i64Exports = []
1948
+ for (const f of ctx.func.list) {
1949
+ if (!isExported(f) || !isBoundaryWrapped(f) || !f._exportI64) continue
1950
+ const { p, r, m } = f._exportI64
1951
+ for (const exportName of exportNamesOf(f.name))
1952
+ i64Exports.push(m ? { name: exportName, p, m } : r ? { name: exportName, p, r } : { name: exportName, p })
1953
+ }
1954
+ if (i64Exports.length)
1955
+ sec.customs.push(['@custom', '"jz:i64exp"', `"${JSON.stringify(i64Exports).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`])
1956
+
1820
1957
  // Named export aliases: export { name } or export { source as alias }
1821
1958
  for (const [name, val] of Object.entries(ctx.func.exports)) {
1822
1959
  if (wasiCommandExports.has(name)) continue
@@ -20,64 +20,33 @@
20
20
  // hole = undefined), so literal tests use `== null`; created literals are bare numbers.
21
21
 
22
22
  import { findMutations } from './analyze-scans.js'
23
+ import { litN, unitIncVar, normalizeLoop, closureMutatedVars, rewriteBlocks, freshLoopId } from './loop-model.js'
23
24
 
24
- const litN = (n, k) => Array.isArray(n) && n.length === 2 && n[0] == null && n[1] === k
25
25
  const isMod = (n, i, w) => Array.isArray(n) && n[0] === '%' && n[1] === i && n[2] === w
26
26
  const isFloorDiv = (n, i, w) =>
27
27
  Array.isArray(n) && n[0] === '|' && litN(n[2], 0) &&
28
28
  Array.isArray(n[1]) && n[1][0] === '/' && n[1][1] === i && n[1][2] === w
29
29
 
30
- // IV a statement increments by exactly +1, or null. Covers `i++` (post-inc desugars
31
- // to `(++i) - 1`), `++i`, `i += 1`, `i = i + 1`.
32
- function incVarOf(stmt) {
33
- if (!Array.isArray(stmt)) return null
34
- let inc = stmt
35
- if (stmt[0] === '-' && litN(stmt[2], 1) && Array.isArray(stmt[1]) && stmt[1][0] === '++') inc = stmt[1]
36
- if (inc[0] === '++' && typeof inc[1] === 'string') return inc[1]
37
- if (stmt[0] === '+=' && typeof stmt[1] === 'string' && litN(stmt[2], 1)) return stmt[1]
38
- if (stmt[0] === '=' && typeof stmt[1] === 'string' && Array.isArray(stmt[2]) && stmt[2][0] === '+') {
39
- const [, a, b] = stmt[2]
40
- if (a === stmt[1] && litN(b, 1)) return stmt[1]
41
- if (b === stmt[1] && litN(a, 1)) return stmt[1]
42
- }
43
- return null
44
- }
45
-
46
30
  const usesPattern = (n, i, w, pred) => Array.isArray(n) && (pred(n, i, w) || n.some(c => usesPattern(c, i, w, pred)))
47
31
  const replace = (n, i, w, cx, cy) =>
48
32
  !Array.isArray(n) ? n : isMod(n, i, w) ? cx : isFloorDiv(n, i, w) ? cy : n.map(c => replace(c, i, w, cx, cy))
49
33
  // a `continue` that targets THIS loop (not one nested inside) — would skip the increment
50
34
  const hasOuterContinue = (n) => Array.isArray(n) &&
51
35
  (n[0] === 'continue' || (n[0] !== 'while' && n[0] !== 'for' && n[0] !== 'do' && n[0] !== '=>' && n.some(hasOuterContinue)))
52
- // Vars assigned anywhere inside a closure in the function. Such a var can be
53
- // mutated by a call in the loop body (the closure may be defined outside the loop,
54
- // so a body-local findMutations misses it), so it is not safe as the IV or divisor.
55
- const ASSIGN_OPS = new Set(['=', '+=', '-=', '*=', '/=', '%=', '**=', '&=', '|=', '^=', '<<=', '>>=', '>>>=', '&&=', '||=', '??='])
56
- const collectAssigns = (n, out) => {
57
- if (!Array.isArray(n)) return
58
- if (typeof n[1] === 'string' && (ASSIGN_OPS.has(n[0]) || n[0] === '++' || n[0] === '--')) out.add(n[1])
59
- n.forEach(c => collectAssigns(c, out))
60
- }
61
- const closureMutated = (n, out) => {
62
- if (!Array.isArray(n)) return out
63
- if (n[0] === '=>') collectAssigns(n, out)
64
- n.forEach(c => closureMutated(c, out))
65
- return out
66
- }
67
36
 
68
- let _uniq = 0
69
- let _cm = new Set() // closure-mutated vars for the function currently being transformed
70
37
 
71
- // Try to strength-reduce one `while` statement. Returns [seed, loop] or null.
72
- function tryReduce(stmt) {
73
- if (!Array.isArray(stmt) || stmt[0] !== 'while') return null
74
- const cond = stmt[1], lbody = stmt[2]
38
+ // Try to strength-reduce one `while` statement. Returns [seed, loop] or null. `cm` is
39
+ // the function's closure-mutated-vars set (an IV/divisor in it is unsafe).
40
+ function tryReduce(stmt, cm) {
41
+ const L = normalizeLoop(stmt)
42
+ if (!L || L.kind !== 'while') return null
43
+ const cond = L.cond, lbody = L.body
75
44
  if (!Array.isArray(lbody) || lbody[0] !== ';') return null
76
45
 
77
46
  // exactly one IV incremented by +1
78
47
  let iv = null, ivIdx = -1
79
48
  for (let k = 1; k < lbody.length; k++) {
80
- const v = incVarOf(lbody[k])
49
+ const v = unitIncVar(lbody[k])
81
50
  if (v) { if (iv) return null; iv = v; ivIdx = k }
82
51
  }
83
52
  if (!iv) return null
@@ -105,9 +74,9 @@ function tryReduce(stmt) {
105
74
  findMutations([';', ...lbody.slice(1).filter((_, k) => k !== ivIdx - 1)], new Set([iv]), ivMut)
106
75
  if (ivMut.has(iv)) return null
107
76
  if (hasOuterContinue(lbody)) return null
108
- if (_cm.has(iv) || _cm.has(w)) return null // IV/divisor mutable via a closure call
77
+ if (cm.has(iv) || cm.has(w)) return null // IV/divisor mutable via a closure call
109
78
 
110
- const id = _uniq++
79
+ const id = freshLoopId()
111
80
  const cx = `__lsrx${id}`, cy = `__lsry${id}`
112
81
  // seed (inside the w>0 branch): cx = (i%w)|0, cy = (i/w)|0 — one-time, i32 via |0
113
82
  const seedDecls = [['=', cx, ['|', ['%', iv, w], 0]]]
@@ -134,22 +103,7 @@ function tryReduce(stmt) {
134
103
  return [['if', ['&&', ['>', w, 0], ['>=', iv, 0]], ['{}', [';', seed, fast]], stmt]]
135
104
  }
136
105
 
137
- // Walk the body; transform `while` loops inside every block (post-order so a nested
138
- // loop is reduced before its enclosing one is examined).
139
- function walk(node) {
140
- if (!Array.isArray(node)) return node
141
- const n = node.map(walk)
142
- if (n[0] !== ';') return n
143
- const out = [';']
144
- for (let k = 1; k < n.length; k++) {
145
- const r = tryReduce(n[k])
146
- if (r) out.push(...r)
147
- else out.push(n[k])
148
- }
149
- return out
150
- }
151
-
152
106
  export function strengthReduceLoopDivMod(body) {
153
- _cm = closureMutated(body, new Set())
154
- return walk(body)
107
+ const cm = closureMutatedVars(body)
108
+ return rewriteBlocks(body, stmt => tryReduce(stmt, cm))
155
109
  }
@@ -0,0 +1,91 @@
1
+ // Shared AST-level loop primitives for the per-function loop transforms
2
+ // (loop-divmod, loop-square, peel-stencil). Operates on the post-prepare AST.
3
+ //
4
+ // Each of those passes recognizes one narrow loop idiom and rewrites it, but they had
5
+ // re-derived the same building blocks verbatim: the `[, v]` number-literal-hole
6
+ // recognizer, the "+1 induction variable" matcher, the closure-mutated-variable
7
+ // analysis (a var assigned inside any `=>` may be mutated by a call in the loop, so it
8
+ // is unsafe as an IV/bound/divisor), the while/for structural normalizer, and the
9
+ // post-order block walk that applies a per-statement rewrite. One home for all of them
10
+ // — adding the next loop transform is then one recognizer over these, not a fourth copy.
11
+
12
+ import { ASSIGN_OPS } from '../ast.js'
13
+ import { ctx } from '../ctx.js'
14
+
15
+ // Fresh id for a loop transform's generated locals (`__lsrx<id>`, `__pks<id>`, …). Backed by
16
+ // a per-compile counter (reset in ctx.reset), NOT a module-global: a module-`let` counter
17
+ // grows unbounded across a long-lived host and makes compile(P) non-deterministic — its output
18
+ // names depend on how many programs were compiled before it. Distinct prefixes keep the shared
19
+ // id space collision-free across transforms.
20
+ export const freshLoopId = () => ctx.transform.loopXformId++
21
+
22
+ // Post-prepare number literals are sparse-array holes `[<hole>, v]` (length 2, the op
23
+ // slot `n[0]` is the elided hole == null). `litVal` returns the numeric value or null;
24
+ // `litN(n, k)` tests for the exact literal `k`.
25
+ export const litVal = (n) => Array.isArray(n) && n.length === 2 && n[0] == null && typeof n[1] === 'number' ? n[1] : null
26
+ export const litN = (n, k) => Array.isArray(n) && n.length === 2 && n[0] == null && n[1] === k
27
+
28
+ // The induction variable a statement increments by exactly +1, else null. Covers
29
+ // `i++` (post-inc desugars to `(++i) - 1`), `++i`, `i += 1`, `i = i + 1` / `i = 1 + i`.
30
+ export function unitIncVar(stmt) {
31
+ if (!Array.isArray(stmt)) return null
32
+ let inc = stmt
33
+ if (stmt[0] === '-' && litN(stmt[2], 1) && Array.isArray(stmt[1]) && stmt[1][0] === '++') inc = stmt[1]
34
+ if (inc[0] === '++' && typeof inc[1] === 'string') return inc[1]
35
+ if (stmt[0] === '+=' && typeof stmt[1] === 'string' && litN(stmt[2], 1)) return stmt[1]
36
+ if (stmt[0] === '=' && typeof stmt[1] === 'string' && Array.isArray(stmt[2]) && stmt[2][0] === '+') {
37
+ const [, a, b] = stmt[2]
38
+ if (a === stmt[1] && litN(b, 1)) return stmt[1]
39
+ if (b === stmt[1] && litN(a, 1)) return stmt[1]
40
+ }
41
+ return null
42
+ }
43
+
44
+ // Normalize a `while` / flat `for` (`['for', init, cond, update, body]`) into a common
45
+ // shape, else null. `init`/`step` are null for a while; a `for` with fewer than the 5
46
+ // flat slots (no body) is not a loop here.
47
+ export function normalizeLoop(stmt) {
48
+ if (!Array.isArray(stmt)) return null
49
+ if (stmt[0] === 'while') return { kind: 'while', init: null, cond: stmt[1], step: null, body: stmt[2] }
50
+ if (stmt[0] === 'for' && stmt.length >= 5) return { kind: 'for', init: stmt[1], cond: stmt[2], step: stmt[3], body: stmt[4] }
51
+ return null
52
+ }
53
+
54
+ // Variables assigned anywhere inside a closure (`=>`) in `body`. A call in the loop can
55
+ // mutate such a var even though a direct-write scan (findMutations) misses it (the
56
+ // closure may be defined outside the loop), so an IV / bound / divisor in this set is
57
+ // unsafe to strength-reduce. (ASSIGN_OPS plus the `++`/`--` updates.)
58
+ export function closureMutatedVars(body) {
59
+ const out = new Set()
60
+ const collect = (n) => {
61
+ if (!Array.isArray(n)) return
62
+ if (typeof n[1] === 'string' && (ASSIGN_OPS.has(n[0]) || n[0] === '++' || n[0] === '--')) out.add(n[1])
63
+ n.forEach(collect)
64
+ }
65
+ const walk = (n) => {
66
+ if (!Array.isArray(n)) return
67
+ if (n[0] === '=>') collect(n)
68
+ n.forEach(walk)
69
+ }
70
+ walk(body)
71
+ return out
72
+ }
73
+
74
+ // Post-order block rewriter: walk `body`; for every `;`-sequence, apply `tryStmt` to
75
+ // each statement. A truthy result is an ARRAY of replacement statements spliced in
76
+ // place; a falsy result keeps the statement unchanged. Children are rewritten first, so
77
+ // a nested loop is transformed before the block that encloses it.
78
+ export function rewriteBlocks(body, tryStmt) {
79
+ const walk = (node) => {
80
+ if (!Array.isArray(node)) return node
81
+ const n = node.map(walk)
82
+ if (n[0] !== ';') return n
83
+ const out = [';']
84
+ for (let k = 1; k < n.length; k++) {
85
+ const r = tryStmt(n[k])
86
+ if (r) out.push(...r); else out.push(n[k])
87
+ }
88
+ return out
89
+ }
90
+ return walk(body)
91
+ }