jz 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +37 -33
  2. package/bench/README.md +176 -73
  3. package/bench/bench.svg +58 -71
  4. package/cli.js +12 -5
  5. package/dist/interop.js +1 -1
  6. package/dist/jz.js +1366 -1101
  7. package/index.js +40 -9
  8. package/interop.js +193 -138
  9. package/layout.js +29 -18
  10. package/module/array.js +49 -73
  11. package/module/collection.js +83 -25
  12. package/module/console.js +1 -1
  13. package/module/core.js +161 -15
  14. package/module/json.js +3 -3
  15. package/module/math.js +167 -117
  16. package/module/number.js +247 -13
  17. package/module/object.js +11 -5
  18. package/module/regex.js +8 -7
  19. package/module/string.js +295 -171
  20. package/module/typedarray.js +169 -105
  21. package/package.json +7 -3
  22. package/src/abi/string.js +40 -35
  23. package/src/ast.js +19 -2
  24. package/src/compile/analyze.js +64 -2
  25. package/src/compile/cse-load.js +200 -0
  26. package/src/compile/emit-assign.js +73 -14
  27. package/src/compile/emit.js +324 -34
  28. package/src/compile/index.js +204 -61
  29. package/src/compile/infer.js +8 -1
  30. package/src/compile/loop-divmod.js +12 -58
  31. package/src/compile/loop-model.js +91 -0
  32. package/src/compile/loop-recurrence.js +167 -0
  33. package/src/compile/loop-square.js +102 -0
  34. package/src/compile/narrow.js +180 -34
  35. package/src/compile/peel-stencil.js +18 -64
  36. package/src/compile/plan/common.js +29 -0
  37. package/src/compile/plan/index.js +4 -1
  38. package/src/compile/plan/inline.js +176 -21
  39. package/src/compile/plan/literals.js +93 -19
  40. package/src/ctx.js +51 -12
  41. package/src/helper-counters.js +137 -0
  42. package/src/ir.js +102 -13
  43. package/src/kind-traits.js +7 -3
  44. package/src/kind.js +14 -1
  45. package/src/op-policy.js +5 -2
  46. package/src/ops.js +119 -0
  47. package/src/optimize/index.js +1125 -136
  48. package/src/optimize/recurse.js +182 -0
  49. package/src/optimize/vectorize.js +1302 -144
  50. package/src/prepare/index.js +29 -12
  51. package/src/reps.js +4 -1
  52. package/src/type.js +53 -45
  53. package/src/wat/assemble.js +92 -9
  54. package/src/widen.js +21 -0
  55. package/src/wat/optimize.js +0 -3938
@@ -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,14 @@ 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'
43
+ import { unrollRecurrence } from './loop-recurrence.js'
41
44
  import { peelClampedStencil } from './peel-stencil.js'
45
+ import { cseLoads } from './cse-load.js'
46
+
47
+ // Monotonic across all functions so a CSE temp never collides (even after later inlining).
48
+ let __cseCtr = 0
49
+ const freshCseName = () => `${T}cse${__cseCtr++}`
42
50
  import { emit, emitter, emitVoid, emitBlockBody } from './emit.js'
43
51
  import { emitCharDecompPrologue, JSS_IMPORT_SIGS } from '../abi/string.js'
44
52
  import {
@@ -59,8 +67,9 @@ import plan from './plan/index.js'
59
67
  import { foldStaticConstAggregates } from './plan/literals.js'
60
68
  import {
61
69
  buildStartFn, dedupClosureBodies, finalizeClosureTable,
62
- pullStdlib, syncImports, optimizeModule, stripStaticDataPrefix, hoistConstGlobalInits,
70
+ pullStdlib, syncImports, optimizeModule, stripStaticDataPrefix, hoistConstGlobalInits, stripDeadElTable,
63
71
  } from '../wat/assemble.js'
72
+ import { instrumentHelperCallsites } from '../helper-counters.js'
64
73
 
65
74
  // =============================================================================
66
75
  // Single-source export semantics
@@ -133,15 +142,21 @@ const timePhase = (profiler, name, fn) => profiler?.time ? profiler.time(name, f
133
142
  * fractional Number gets the same truncation it would get from `arr[n]`).
134
143
  */
135
144
  const isBoundaryWrapped = (func) => {
136
- if (!isExported(func) || func.raw || func.sig.results.length !== 1) return false
145
+ if (!isExported(func) || func.raw) return false
146
+ // Multi-value return: every lane is an f64 NaN-box carrier (the `return [a,b,…]` emit forces
147
+ // asF64 per lane; result narrowing only touches single-result funcs), so any lane may hold a
148
+ // box whose NaN payload JSC/V8 erases at the boundary — wrap to i64-carry every lane.
149
+ if (func.sig.results.length !== 1) return true
137
150
  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)
151
+ // Any result that isn't a proven plain number can be a NaN-box — a heap pointer,
152
+ // a null/undef/bool atom, a bigint carrier, or a dynamic value — so it crosses as
153
+ // i64 and JSC (Safari) can't canonicalize the payload away. A proven-number result
154
+ // stays f64: free, and a number is never a NaN-box. `_resultNumeric` is set in
155
+ // analyzeFuncForEmit (covers value-bound arrows narrowValResults skips).
156
+ if (!func._resultNumeric) return true
157
+ // Number result, but a param may still carry a box — a pointer-ABI param, or a
158
+ // dynamic f64 param flagged `boundaryI64` during analyze — so wrap for i64 params.
159
+ return func.sig.params.some(p => p.type !== 'f64' || p.ptrKind != null || p.boundaryI64)
145
160
  }
146
161
 
147
162
  // Static-string intern index (the `internStrings` pass). Open-addressing table
@@ -212,20 +227,39 @@ const ensureThrowRuntime = (sec) => {
212
227
  sec.tags.push(['export', '"__jz_last_err_bits"', ['global', '$__jz_last_err_bits']])
213
228
  }
214
229
 
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.
230
+ // Drop the $__jz_err tag + __jz_last_err_bits globals when no throw can be CAUGHT.
231
+ // ensureThrowRuntime runs before optimizeModule so dead-throw analysis sees the
232
+ // tag/global as live; once opt has finished, an unused tag still forces consumers
233
+ // (wasmtime, wasm2c, wabt) to enable the exceptions proposal just to PARSE the module.
234
+ //
235
+ // When `!userThrows`, every `throw` is compiler-internal (bounds / coercion / type
236
+ // errors) and with no user try/catch — uncatchable: nothing inspects the thrown
237
+ // value, so it is semantically a trap. The exceptions proposal is needed only to
238
+ // DECLARE the tag a `throw` references; lowering each surviving uncatchable throw to
239
+ // `unreachable` keeps the module in the wasm MVP, so every runtime can parse it
240
+ // (V8 alone enables exceptions by default, which masked this). A pure-recursion or
241
+ // typed-array kernel (nqueens, anything pulling __to_num) thus stops emitting a Tag
242
+ // section it can never use. User-written throw/try/catch/finally is an ABI contract
243
+ // (JS-side may inspect __jz_last_err_bits), so `userThrows` keeps the runtime intact.
224
244
  const pruneUnusedThrowRuntime = (sec) => {
225
245
  if (!ctx.runtime.throws || ctx.runtime.userThrows) return
226
- const hasThrow = (n) => Array.isArray(n) && (n[0] === 'throw' || n.some(hasThrow))
246
+ // A catch handler (try_table) appears only under userThrows; defensively bail if one
247
+ // is present so a caught throw is never silently turned into a trap.
248
+ const hasCatch = (n) => Array.isArray(n) &&
249
+ (n[0] === 'try_table' || n[0] === 'catch' || n[0] === 'catch_all' || n.some(hasCatch))
227
250
  for (const arr of [sec.funcs, sec.stdlib, sec.start])
228
- for (const f of arr) if (hasThrow(f)) return
251
+ for (const f of arr) if (hasCatch(f)) return
252
+ // Rewrite every surviving `(throw $__jz_err …)` to `(unreachable)` (same polymorphic
253
+ // stack type — a drop-in in any position). The thrown operand is side-effect-free
254
+ // (a local read / const), so dropping it loses nothing.
255
+ const lowerThrows = (n) => {
256
+ if (!Array.isArray(n)) return n
257
+ if (n[0] === 'throw') return ['unreachable']
258
+ for (let i = 1; i < n.length; i++) n[i] = lowerThrows(n[i])
259
+ return n
260
+ }
261
+ for (const arr of [sec.funcs, sec.stdlib, sec.start])
262
+ for (let i = 0; i < arr.length; i++) arr[i] = lowerThrows(arr[i])
229
263
  sec.tags = sec.tags.filter(t => !(Array.isArray(t) &&
230
264
  ((t[0] === 'tag' && t[1] === '$__jz_err') ||
231
265
  (t[0] === 'export' && t[1] === '"__jz_last_err_bits"'))))
@@ -351,6 +385,7 @@ function enterFunc(sig, body, { uniq = 0, directClosures = null } = {}) {
351
385
  ctx.func.stack = []
352
386
  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
387
  ctx.func.maybeNullish = new Set() // bindings assigned a nullish literal → coerce in arithmetic (null-flow)
388
+ ctx.func.refinements = new Map() // flow-sensitive type facts (typeof/instanceof guards) — per-function; clear so none leak across bodies
354
389
  ctx.func.pendingLabel = null // label awaiting its loop, for `continue <label>`
355
390
  ctx.func.uniq = uniq
356
391
  ctx.func.current = sig
@@ -388,6 +423,15 @@ function analyzeFuncForEmit(func, programFacts) {
388
423
  // counters are typed/narrowed like any i32 local. Off at L0 / `loopIVDivMod:false`.
389
424
  const _o = ctx.transform.optimize
390
425
  if (_o && _o.loopIVDivMod !== false && isBlockBody(func.body)) func.body = strengthReduceLoopDivMod(func.body)
426
+ // Bounded-square narrowing: `i*i` under an `i*i < CONST` (CONST ≤ 2³⁰) guard → Math.imul,
427
+ // so the sieve's product/counter chain carries i32 instead of f64. Before analyze so the
428
+ // Math.imul typed/narrows like any i32. Off at L0 / `loopSquare:false`.
429
+ if (_o && _o.loopSquare !== false && isBlockBody(func.body)) func.body = narrowBoundedSquare(func.body)
430
+ // Array-recurrence unroll: a unit-stride DP/scan that reads arr[j-1] and writes arr[j] carries
431
+ // its value through memory (store→load) and re-pays loop overhead per cell — both of which V8
432
+ // hides but Cranelift/baseline don't. Scalar-replace the recurrence + unroll ×2 (clang's fix).
433
+ // Off at L0 / `unrollRecurrence:false`.
434
+ if (_o && _o.unrollRecurrence !== false && isBlockBody(func.body)) func.body = unrollRecurrence(func.body)
391
435
  // Edge-clamp peeling: split a clamped stencil loop into clamp-free interior + edges
392
436
  // (the interior then lifts to SIMD). Before analyze so the new loops are analyzed.
393
437
  if (_o && _o.clampPeel !== false && isBlockBody(func.body)) func.body = peelClampedStencil(func.body)
@@ -425,7 +469,10 @@ function analyzeFuncForEmit(func, programFacts) {
425
469
  // ToNumber string-parse dep tree (`__to_str`→`__itoa`/`__toExp`/`__mkstr`/…)
426
470
  // treeshake away — a ~4× module shrink that, decisively, lets V8 tier the hot
427
471
  // fill loop up properly (the bloated module JITs the *identical* loop ~2× slower).
428
- if (func.exported && block) {
472
+ // Block AND expression bodies: value-bound arrows (`export let f = (a,b) => a*b`) are
473
+ // skipped by narrowValResults, so without trusting their params here they'd fall to the
474
+ // i64 boundary carrier. The closure path runs the same proof at line ~1300.
475
+ if (func.exported) {
429
476
  for (const p of sig.params) {
430
477
  if (p.type === 'f64' && p.ptrKind == null && !p.jsstring
431
478
  && !func.defaults?.[p.name] && !ctx.func.boxed?.has(p.name)
@@ -438,6 +485,12 @@ function analyzeFuncForEmit(func, programFacts) {
438
485
  updateRep(p.name, { val: VAL.NUMBER })
439
486
  }
440
487
  }
488
+ // Sound load-CSE: cache a repeated pure typed-array load `arr[idx]` when every intervening
489
+ // store writes a provably-different element (idx2 ≠ idx). Recovers the fft butterfly's redundant
490
+ // `re[a]` load. Before analyze so the introduced temp is typed/narrowed like any local.
491
+ if (_o && _o.loadCSE !== false && block && ctx.types.typedElem?.size)
492
+ cseLoads(body, n => ctx.types.typedElem.has(n), freshCseName)
493
+
441
494
  if (block) {
442
495
  seedLocalIntConsts(body)
443
496
  }
@@ -531,6 +584,32 @@ function analyzeFuncForEmit(func, programFacts) {
531
584
  if (ctx.func.localReps?.get(name)?.intCertain === true) cellTypes.add(name)
532
585
  }
533
586
 
587
+ // Snapshot each param's JS-boundary carrier while reps are live — synthesizeBoundaryWrappers
588
+ // runs after they're torn down. A dynamic f64 param crosses as i64 (the carrier JSC can't
589
+ // canonicalize) iff it can hold a NaN-box, i.e. it isn't proven numeric. Numeric (NUMBER /
590
+ // BOOL → 0/1) params keep f64; pointer-ABI (ptrKind, type i32) and jsstring params are
591
+ // classified directly in the wrapper, so leave their flag false here.
592
+ if (isExported(func)) for (const p of sig.params) {
593
+ if (p.jsstring || p.ptrKind != null || p.type !== 'f64') { p.boundaryI64 = false; continue }
594
+ const rv = ctx.func.localReps?.get(p.name)?.val
595
+ p.boundaryI64 = rv !== VAL.NUMBER && rv !== VAL.BOOL
596
+ }
597
+
598
+ // Result-numeric proof for the boundary carrier. Block bodies get func.valResult from
599
+ // narrowValResults; value-bound arrows (`export let f = (a,b) => a*b`) don't, so prove via
600
+ // the return expression(s) with params now trusted numeric. A proven-number f64 result
601
+ // never carries a NaN-box → crosses as plain f64; anything else rides i64 (Safari-safe).
602
+ if (isExported(func))
603
+ func._resultNumeric = func.valResult === VAL.NUMBER ||
604
+ (func.valResult == null && sig.results[0] === 'f64' &&
605
+ (() => {
606
+ const rex = returnExprs(body)
607
+ // Void body (falls off → undefined, which callers ignore) keeps the f64 carrier:
608
+ // undefined isn't a reference, so no i64 is needed and wrapping every void export
609
+ // is pure overhead. A non-empty set must be all-NUMBER to stay f64.
610
+ return rex.length === 0 || rex.every(e => valTypeOf(e) === VAL.NUMBER)
611
+ })())
612
+
534
613
  return {
535
614
  block,
536
615
  locals: new Map(ctx.func.locals),
@@ -539,6 +618,7 @@ function analyzeFuncForEmit(func, programFacts) {
539
618
  flatObjects: new Map(ctx.func.flatObjects),
540
619
  sliceViews: new Set(ctx.func.sliceViews),
541
620
  cseLoadBases,
621
+ distinctParams: func.distinctParams || null,
542
622
  typedElem: ctx.types.typedElem ? new Map(ctx.types.typedElem) : null,
543
623
  localReps: cloneRepMap(ctx.func.localReps),
544
624
  }
@@ -958,6 +1038,11 @@ function emitFunc(func, funcFacts, programFacts) {
958
1038
  // it the pass is a no-op. `$`-prefixed to match WAT local names directly.
959
1039
  if (funcFacts.cseLoadBases?.size)
960
1040
  fn.cseLoadBases = new Set([...funcFacts.cseLoadBases].map(n => `$${n}`))
1041
+ // Param-distinctness fact (alias analysis): typed-array params proven mutually-distinct buffers
1042
+ // at every call site. `$`-prefixed to match WAT param names; read by hoistInvariantLoop to hoist
1043
+ // a load from one such param across a store to another (they can't alias).
1044
+ if (funcFacts.distinctParams?.size)
1045
+ fn.distinctParams = new Set([...funcFacts.distinctParams].map(n => `$${n}`))
961
1046
  // Inline `(export ...)` attribute only for the syntactic inline-export
962
1047
  // form (`export function foo`, snapshot in `func.exported` at defFunc
963
1048
  // time). Re-exports (`function foo; export { foo }`) and aliases (`export
@@ -1098,17 +1183,26 @@ function synthesizeBoundaryWrappers() {
1098
1183
  for (const func of ctx.func.list) {
1099
1184
  if (!isBoundaryWrapped(func)) continue
1100
1185
  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
1186
+ // i64 boundary carrier (Safari-safe). A genuine number is never a NaN-box, so it crosses
1187
+ // as plain f64 (zero cost). Everything that can be a NaN-box — heap pointer, null/undef/
1188
+ // bool atom, bigint carrier, or a dynamic value crosses as i64: JSC (Safari) canonicalizes
1189
+ // f64 NaN payloads at the JS↔wasm boundary, erasing the box. The wasm signature is
1190
+ // self-describing; interop.js wrap() reinterprets BigInt↔f64 by bits, driven by the
1191
+ // `jz:i64exp` section emitted below. Non-JS hosts (WASI) read the same signature i64 is
1192
+ // just int64 there, no BigInt.
1193
+ const resultPtr = sig.ptrKind != null
1194
+ const resultBool = func.valResult === VAL.BOOL && !resultPtr
1195
+ const resultBigint = func.valResult === VAL.BIGINT && !resultPtr
1196
+ // Dynamic f64 result: not pointer/bool/bigint and not a proven number → may be a NaN-box
1197
+ // at runtime, so i64. (An i32-carrier result is numeric → stays f64 via convert below.)
1198
+ const resultDynamic = !resultPtr && !resultBool && !resultBigint
1199
+ && sig.results[0] === 'f64' && !func._resultNumeric
1200
+ const resultI64 = resultPtr || resultBool || resultBigint || resultDynamic
1201
+ // jz:i64exp `r` marks results interop must reinterpret then `mem.read`. A bigint result is
1202
+ // i64 too, but the BigInt *is* the value (no reinterpret) — so it stays unmarked.
1203
+ const resultReinterpret = resultPtr || resultBool || resultDynamic
1204
+ // i64 carrier per param: pointer-ABI (offset) or a dynamic f64 param (boundaryI64).
1205
+ const paramIsI64 = (p) => !p.jsstring && (p.ptrKind != null || p.boundaryI64)
1112
1206
  // Inline `(export ...)` attribute only when the func decl carried the
1113
1207
  // inline-export keyword (`export function foo`). For re-exports
1114
1208
  // (`function foo; export { foo as bar }`) the `name` is the *internal*
@@ -1118,26 +1212,58 @@ function synthesizeBoundaryWrappers() {
1118
1212
  const wrapNode = func.exported
1119
1213
  ? ['func', `$${name}$exp`, ['export', `"${name}"`]]
1120
1214
  : ['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'])
1215
+ // jsstring params flow as externref end-to-end; boxed params ride i64; numbers f64.
1216
+ const i64Params = []
1217
+ sig.params.forEach((p, i) => {
1218
+ wrapNode.push(['param', `$${p.name}`, p.jsstring ? 'externref' : paramIsI64(p) ? 'i64' : 'f64'])
1219
+ if (paramIsI64(p)) i64Params.push(i)
1124
1220
  })
1125
- wrapNode.push(['result', resultBigint ? 'i64' : 'f64'])
1221
+ // Track externref param positions so interop.js can pass JS values raw (skipping
1222
+ // `mem.wrapVal`) at those slots — today only `jsstring` params; future externref carriers
1223
+ // wire here too. `extParams` is per-slot: false | { def: '...' } for a JS-side default.
1224
+ const extParams = sig.params.map(p => !p.jsstring ? false : p.jsstringDefault != null ? { def: p.jsstringDefault } : true)
1225
+ if (extParams.some(Boolean)) func._exportExtParams = extParams
1226
+ // Inner→wrapper argument list, shared by both single- and multi-value result shapes.
1126
1227
  const args = sig.params.map((p) => {
1127
1228
  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]]
1229
+ if (p.jsstring) return get // externref flows through unchanged
1230
+ if (p.ptrKind != null) return ['i32.wrap_i64', get] // ptr param: inner takes the i32 offset
1231
+ if (p.boundaryI64) return ['f64.reinterpret_i64', get] // dynamic boxed param f64 NaN-box carrier
1132
1232
  if (p.type === 'f64') return get
1133
- // Numeric narrowing: f64 → i32 truncate
1134
- return ['i32.trunc_sat_f64_s', get]
1233
+ return ['i32.trunc_sat_f64_s', get] // numeric narrowing f64 → i32
1135
1234
  })
1136
1235
  const callIR = ['call', `$${name}`, ...args]
1236
+ // Multi-value return: each lane is an f64 NaN-box carrier (every `return [a,b,…]` lane is
1237
+ // asF64; narrowing only touches single-result funcs). A boxed lane's NaN payload is erased
1238
+ // at the JS boundary, so cross EVERY lane as i64 — capture the inner call's N lanes into f64
1239
+ // locals (last result on top of the stack ⇒ pop in reverse) and re-push each reinterpreted.
1240
+ // interop reads the lane tuple via mem.read / decode (both map over an array result).
1241
+ if (sig.results.length > 1) {
1242
+ sig.results.forEach(() => wrapNode.push(['result', 'i64']))
1243
+ // Lane temporaries — guaranteed distinct from the wrapper's params (jz doesn't reserve
1244
+ // `__`, so a user param could be `__mlane0`): bump the prefix until no lane name collides.
1245
+ const pnames = new Set(sig.params.map((p) => p.name))
1246
+ let pfx = '__mlane'
1247
+ while (sig.results.some((_, i) => pnames.has(`${pfx}${i}`))) pfx = `_${pfx}`
1248
+ const lanes = sig.results.map((_, i) => `$${pfx}${i}`)
1249
+ lanes.forEach((n) => wrapNode.push(['local', n, 'f64']))
1250
+ const stmts = [callIR]
1251
+ for (let i = lanes.length - 1; i >= 0; i--) stmts.push(['local.set', lanes[i]])
1252
+ for (const n of lanes) stmts.push(['i64.reinterpret_f64', ['local.get', n]])
1253
+ wrapNode.push(...stmts)
1254
+ // `m` (lane count) marks a multi-value result so interop / the test adapter decode each
1255
+ // lane (vs `r`'s single reinterpret). Always recorded — even with no i64 params — so the
1256
+ // numeric-only `(a,b)=>[a+1,b+2]` tuple still gets its lanes turned back into numbers.
1257
+ func._exportI64 = { p: i64Params, m: sig.results.length }
1258
+ wrappers.push(wrapNode)
1259
+ continue
1260
+ }
1261
+ wrapNode.push(['result', resultI64 ? 'i64' : 'f64'])
1262
+ const toI64 = (n) => ['i64.reinterpret_f64', n]
1137
1263
  let body
1138
- if (sig.ptrKind != null) {
1264
+ if (resultPtr) {
1139
1265
  const ptrType = valKindToPtr(sig.ptrKind)
1140
- body = mkPtrIR(ptrType, sig.ptrAux ?? 0, callIR)
1266
+ body = toI64(mkPtrIR(ptrType, sig.ptrAux ?? 0, callIR))
1141
1267
  } else if (resultBool) {
1142
1268
  // The inner func returns a clean 0/1 boolean carrier — never NaN. The i32
1143
1269
  // carrier already takes truthyIR's identity path; the f64 carrier would
@@ -1147,28 +1273,22 @@ function synthesizeBoundaryWrappers() {
1147
1273
  const carrier = sig.results[0] === 'i32'
1148
1274
  ? typed(callIR, 'i32')
1149
1275
  : 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]
1276
+ body = toI64(boolBoxIR(carrier))
1277
+ } else if (resultBigint || resultDynamic) {
1278
+ // BigInt rides the i64-reinterpret-f64 carrier internally; a dynamic result is already an
1279
+ // f64 NaN-box carrier. Either way expose the raw i64 at the JS boundary for a lossless
1280
+ // value. Internal callers use `$name` (the f64 carrier) untouched; only `$exp` is i64.
1281
+ body = toI64(callIR)
1156
1282
  } else if (sig.results[0] === 'i32') {
1157
1283
  body = [sig.unsignedResult ? 'f64.convert_i32_u' : 'f64.convert_i32_s', callIR]
1158
1284
  } else {
1159
1285
  body = callIR
1160
1286
  }
1161
1287
  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
1288
+ // Record the i64 carrier map for interop.js (jz:i64exp). A pure-numeric export
1289
+ // (no i64 params, f64 result) records nothing zero footprint off the box path.
1290
+ if (i64Params.length || resultReinterpret)
1291
+ func._exportI64 = { p: i64Params, r: resultReinterpret ? 1 : 0 }
1172
1292
  wrappers.push(wrapNode)
1173
1293
  }
1174
1294
  return wrappers
@@ -1709,6 +1829,7 @@ export default function compile(ast, profiler) {
1709
1829
  ensureThrowRuntime(sec)
1710
1830
 
1711
1831
  timePhase(profiler, 'optimizeModule', () => optimizeModule(sec, profiler))
1832
+ if (ctx.transform.helperCallsites) instrumentHelperCallsites([...sec.funcs, ...sec.stdlib, ...sec.start])
1712
1833
 
1713
1834
  // Fold constant `__start` global inits into immutable inline decls (drops the
1714
1835
  // store, and `__start` with it when that empties it). Runs HERE — after
@@ -1733,6 +1854,11 @@ export default function compile(ast, profiler) {
1733
1854
  [`${ty}.const`, g.init]]
1734
1855
  }))
1735
1856
 
1857
+ // Drop the Eisel-Lemire decimal table if no live code parses decimals at runtime — must
1858
+ // run after sec.globals/funcs are final (exact reachability) and before the data segment
1859
+ // below serializes ctx.runtime.data. See stripDeadElTable.
1860
+ stripDeadElTable(sec)
1861
+
1736
1862
  // Data segments (after emit — string literals append to ctx.runtime.data / strPool during emit)
1737
1863
  // Active segment at address 0 — skipped for shared memory (would collide across modules)
1738
1864
  const escBytes = (s) => {
@@ -1817,6 +1943,23 @@ export default function compile(ast, profiler) {
1817
1943
  if (extExports.length)
1818
1944
  sec.customs.push(['@custom', '"jz:extparam"', `"${JSON.stringify(extExports).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`])
1819
1945
 
1946
+ // jz:i64exp — per-export i64 carrier map (NaN-canonicalization dodging). Each entry
1947
+ // `{name, p:[i64 param indices], r:1? | m:N?}`: `p` lists params interop must pass as BigInt
1948
+ // (f64ToI64); `r` marks a single result to reinterpret (i64ToF64) before mem.read; `m` marks
1949
+ // an N-lane multi-value result whose lanes interop/the adapter decode element-wise. Pure-
1950
+ // numeric single-result exports emit no entry. A bigint result is i64 but unmarked (the BigInt
1951
+ // is the value). Written under every JS-visible alias, like jz:extparam. Each shape is built as
1952
+ // a direct literal (no spread) — the self-host kernel's fixed schemas don't enumerate post-hoc keys.
1953
+ const i64Exports = []
1954
+ for (const f of ctx.func.list) {
1955
+ if (!isExported(f) || !isBoundaryWrapped(f) || !f._exportI64) continue
1956
+ const { p, r, m } = f._exportI64
1957
+ for (const exportName of exportNamesOf(f.name))
1958
+ i64Exports.push(m ? { name: exportName, p, m } : r ? { name: exportName, p, r } : { name: exportName, p })
1959
+ }
1960
+ if (i64Exports.length)
1961
+ sec.customs.push(['@custom', '"jz:i64exp"', `"${JSON.stringify(i64Exports).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`])
1962
+
1820
1963
  // Named export aliases: export { name } or export { source as alias }
1821
1964
  for (const [name, val] of Object.entries(ctx.func.exports)) {
1822
1965
  if (wasiCommandExports.has(name)) continue
@@ -130,11 +130,18 @@ export const inferParams = (body, candidates) => {
130
130
  // regardless of prior evidence — a later method call can't re-induce a shape
131
131
  // already contradicted by an earlier scalar assignment.
132
132
 
133
+ // Methods that exist ONLY on String.prototype — seeing one on a bare binding proves
134
+ // it is a string. `indexOf`/`includes`/`lastIndexOf`/`concat`/`slice`/`at` are NOT here:
135
+ // Array.prototype has them too, so the receiver is genuinely ambiguous (and the argument
136
+ // can't disambiguate — String coerces it, Arrays hold strings). Those keep the runtime
137
+ // __ptr_type fork, which is correct for both; forcing `lastIndexOf` to string here used to
138
+ // miscompile `arr.lastIndexOf(x)` to -1. A string-using param still narrows via any of the
139
+ // real discriminators below (charCodeAt, a string assignment, a string-passing call site).
133
140
  const STRING_ONLY_METHODS = new Set([
134
141
  'charCodeAt', 'charAt', 'codePointAt', 'startsWith', 'endsWith',
135
142
  'toUpperCase', 'toLowerCase', 'toLocaleLowerCase', 'normalize', 'localeCompare',
136
143
  'padStart', 'padEnd', 'repeat', 'trimStart', 'trimEnd', 'trim',
137
- 'matchAll', 'match', 'replace', 'replaceAll', 'split', 'lastIndexOf',
144
+ 'matchAll', 'match', 'replace', 'replaceAll', 'split',
138
145
  ])
139
146
  const ARRAY_ONLY_POISON = new Set([
140
147
  'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'fill', 'reverse',
@@ -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
+ }