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
@@ -28,7 +28,7 @@ import {
28
28
  some, T, stmtList, refsName, REFS_IN_EXPR, ASSIGN_OPS, isReassigned, hasControlTransfer,
29
29
  } from '../../ast.js'
30
30
  import {
31
- intLiteralValue, constIntExpr, staticObjectProps, staticPropertyKey,
31
+ intLiteralValue, nonNegIntLiteral, constIntExpr, staticObjectProps, staticPropertyKey,
32
32
  } from '../../static.js'
33
33
  import {
34
34
  smallConstForTripCount, containsDeclOf, cloneWithSubst,
@@ -37,7 +37,7 @@ import { VAL } from '../../reps.js'
37
37
  import { includeModule } from '../../autoload.js'
38
38
  import { analyzeBody, invalidateLocalsCache } from '../analyze.js'
39
39
  import {
40
- isSimpleArg, fixedScalarTypedArray, fixedTypedArraysInBody, maxScalarTypedArrayLen,
40
+ isSimpleArg, fixedScalarTypedArray, fixedTypedArraysInBody, maxScalarTypedArrayLen, freshTypedArrayLocals,
41
41
  } from './common.js'
42
42
 
43
43
  // === Loop unrolling & scalarization ===
@@ -526,6 +526,44 @@ export const scalarizeFunctionTypedArrays = (programFacts) => {
526
526
  return changed
527
527
  }
528
528
 
529
+ // Param-distinctness (alias analysis). Marks a function's typed-array params MUTUALLY DISTINCT
530
+ // (provably different buffers) when EVERY call site passes a distinct fresh `new TypedArray` local
531
+ // for each of them. This is what lets the optimizer's LICM hoist a load from one such param across
532
+ // a store to another (the load can't be clobbered) — the alias-analysis-enabled LICM that
533
+ // rust/clang get for free (raytrace's sphere loads vs the framebuffer store). Sound because:
534
+ // • a fresh `new TypedArray(N)` is a unique buffer (the allocator returns fresh memory);
535
+ // • requiring ALL typed-array-param args to be fresh-new excludes views/subarrays (not fresh)
536
+ // and forwarded params (not fresh), the only ways two args could alias;
537
+ // • pairwise-distinct arg names rule out the same buffer passed twice;
538
+ // • scalar (non-TYPED) params are ignored — they can't alias a buffer.
539
+ // Conservatively all-or-nothing per function: any non-fresh/duplicate typed arg ⇒ no fact.
540
+ export const analyzeParamDistinctness = (programFacts) => {
541
+ const freshByFunc = new Map(ctx.func.list.map(func => [func, freshTypedArrayLocals(func.body)]))
542
+ const sitesByCallee = new Map()
543
+ for (const site of programFacts.callSites) {
544
+ if (!site.callerFunc) continue
545
+ const l = sitesByCallee.get(site.callee); l ? l.push(site) : sitesByCallee.set(site.callee, [site])
546
+ }
547
+ for (const func of ctx.func.list) {
548
+ const params = func.sig?.params, sites = sitesByCallee.get(func.name)
549
+ if (!params || !sites?.length) continue
550
+ const typedIdx = []
551
+ for (let i = 0; i < params.length; i++) if (params[i].ptrKind === VAL.TYPED) typedIdx.push(i)
552
+ if (typedIdx.length < 2) continue // distinctness only matters with ≥2 typed-array params
553
+ let ok = true
554
+ for (const site of sites) {
555
+ const seen = new Set()
556
+ for (const i of typedIdx) {
557
+ const arg = site.argList?.[i]
558
+ if (typeof arg !== 'string' || !freshByFunc.get(site.callerFunc)?.has(arg) || seen.has(arg)) { ok = false; break }
559
+ seen.add(arg)
560
+ }
561
+ if (!ok) break
562
+ }
563
+ if (ok) func.distinctParams = new Set(typedIdx.map(i => params[i].name))
564
+ }
565
+ }
566
+
529
567
  const scalarizeArrayLiteralSeq = (seq) => {
530
568
  if (!Array.isArray(seq) || seq[0] !== ';') return { node: seq, changed: false }
531
569
  let changed = false
@@ -940,13 +978,41 @@ const _intArrayLitElems = (expr) => {
940
978
  return out
941
979
  }
942
980
 
981
+ // Arithmetic and bitwise operators that always produce a numeric result —
982
+ // regardless of operand types — so `name[expr]` is index-safe after promotion.
983
+ // Bitwise ops (`&`, `|`, etc.) ToInt32 their operands; pure-arithmetic ops with
984
+ // numeric leaves stay numeric. `+` is excluded: `"a" + "b"` is a string.
985
+ const _NUMERIC_INDEX_OPS = new Set(['-', '*', '/', '%', '&', '|', '^', '<<', '>>', '>>>', 'u-', 'u+'])
986
+
987
+ // Returns true when `key` is provably a numeric index at plan time: an integer
988
+ // literal, a local name whose val-type is VAL.NUMBER in `valTypes`, or a
989
+ // compound expression that always produces a number (arithmetic/bitwise ops).
990
+ // Mirrors the `idxNumericName` / `intIndexIR` guard in emit so that the same
991
+ // index shapes that skip `__is_str_key` at emit-time also pass here. Used by
992
+ // `_disqualifyPromotion` to gate index reads: a non-numeric key on a promoted
993
+ // Int32Array NaN-coerces to 0 (trunc_sat_f64_s(NaN) = 0) instead of returning
994
+ // undefined — the correct JS behaviour for an out-of-range or string index.
995
+ const _isNumericKey = (key, valTypes) => {
996
+ if (key == null) return false
997
+ if (nonNegIntLiteral(key) != null) return true // literal integer
998
+ if (typeof key === 'string') return valTypes?.get(key) === VAL.NUMBER
999
+ if (!Array.isArray(key)) return false
1000
+ const op = key[0]
1001
+ if (op == null) return typeof key[1] === 'number' // [null, n] literal
1002
+ if (_NUMERIC_INDEX_OPS.has(op)) return true // always produces Number
1003
+ // `+` is numeric only when both operands are proven numeric.
1004
+ if (op === '+' && key.length === 3)
1005
+ return _isNumericKey(key[1], valTypes) && _isNumericKey(key[2], valTypes)
1006
+ return false
1007
+ }
1008
+
943
1009
  // Walks `node` and disqualifies every candidate name that appears in an
944
1010
  // unsafe context. `initSet` holds the candidate's own init-decl AST nodes
945
1011
  // (their LHS reference is the binding being defined, not an escape).
946
- const _disqualifyPromotion = (node, candidates, disqualified, initSet) => {
1012
+ const _disqualifyPromotion = (node, candidates, disqualified, initSet, valTypes) => {
947
1013
  if (initSet.has(node)) {
948
1014
  // The init decl itself: only walk the RHS (skip the LHS `name`).
949
- return _disqualifyPromotion(node[2], candidates, disqualified, initSet)
1015
+ return _disqualifyPromotion(node[2], candidates, disqualified, initSet, valTypes)
950
1016
  }
951
1017
  if (typeof node === 'string') {
952
1018
  // Bare identifier outside any handled parent context — escape.
@@ -972,7 +1038,7 @@ const _disqualifyPromotion = (node, candidates, disqualified, initSet) => {
972
1038
  Array.isArray(node[1]) && (node[1][0] === '.' || node[1][0] === '?.') &&
973
1039
  typeof node[1][1] === 'string' && candidates.has(node[1][1])) {
974
1040
  disqualified.add(node[1][1])
975
- for (let i = 2; i < node.length; i++) _disqualifyPromotion(node[i], candidates, disqualified, initSet)
1041
+ for (let i = 2; i < node.length; i++) _disqualifyPromotion(node[i], candidates, disqualified, initSet, valTypes)
976
1042
  return
977
1043
  }
978
1044
 
@@ -994,7 +1060,7 @@ const _disqualifyPromotion = (node, candidates, disqualified, initSet) => {
994
1060
  typeof callee[1] === 'string' && candidates.has(callee[1])) {
995
1061
  if (!_TYPED_SAFE_METHODS.has(callee[2])) disqualified.add(callee[1])
996
1062
  // Walk method args (skip the receiver — already validated above).
997
- for (let i = 2; i < node.length; i++) _disqualifyPromotion(node[i], candidates, disqualified, initSet)
1063
+ for (let i = 2; i < node.length; i++) _disqualifyPromotion(node[i], candidates, disqualified, initSet, valTypes)
998
1064
  return
999
1065
  }
1000
1066
  // Array.isArray flips true→false under promotion.
@@ -1003,7 +1069,7 @@ const _disqualifyPromotion = (node, candidates, disqualified, initSet) => {
1003
1069
  const list = raw == null ? [] : (Array.isArray(raw) && raw[0] === ',') ? raw.slice(1) : [raw]
1004
1070
  for (const a of list) {
1005
1071
  if (typeof a === 'string' && candidates.has(a)) disqualified.add(a)
1006
- else _disqualifyPromotion(a, candidates, disqualified, initSet)
1072
+ else _disqualifyPromotion(a, candidates, disqualified, initSet, valTypes)
1007
1073
  }
1008
1074
  return
1009
1075
  }
@@ -1011,10 +1077,15 @@ const _disqualifyPromotion = (node, candidates, disqualified, initSet) => {
1011
1077
  // bare-name leaf above and disqualify.
1012
1078
  }
1013
1079
 
1014
- // Index read `name[k]` — read access is TYPED-safe. Walk the key in case
1015
- // it contains references to other candidate names.
1080
+ // Index read `name[k]` — TYPED-safe only when the key is provably numeric.
1081
+ // A string or unknown key on a promoted Int32Array would NaN-coerce to 0
1082
+ // (i32.trunc_sat_f64_s(NaN) = 0) instead of returning undefined — silently
1083
+ // wrong. Mirror the `idxNumericName` / `intIndexIR` guard in emit: disqualify
1084
+ // the candidate unless `k` is an integer literal, a VAL.NUMBER local, or an
1085
+ // expression that always produces a Number (bitwise/arithmetic ops).
1016
1086
  if (op === '[]' && typeof node[1] === 'string' && candidates.has(node[1])) {
1017
- _disqualifyPromotion(node[2], candidates, disqualified, initSet)
1087
+ _disqualifyPromotion(node[2], candidates, disqualified, initSet, valTypes)
1088
+ if (!_isNumericKey(node[2], valTypes)) disqualified.add(node[1])
1018
1089
  return
1019
1090
  }
1020
1091
 
@@ -1023,15 +1094,15 @@ const _disqualifyPromotion = (node, candidates, disqualified, initSet) => {
1023
1094
  if (ASSIGN_OPS.has(op) && Array.isArray(node[1]) && node[1][0] === '[]' &&
1024
1095
  typeof node[1][1] === 'string' && candidates.has(node[1][1])) {
1025
1096
  disqualified.add(node[1][1])
1026
- _disqualifyPromotion(node[1][2], candidates, disqualified, initSet)
1027
- _disqualifyPromotion(node[2], candidates, disqualified, initSet)
1097
+ _disqualifyPromotion(node[1][2], candidates, disqualified, initSet, valTypes)
1098
+ _disqualifyPromotion(node[2], candidates, disqualified, initSet, valTypes)
1028
1099
  return
1029
1100
  }
1030
1101
 
1031
1102
  // Whole-binding reassign: `name = …` / `name += …` / etc.
1032
1103
  if (ASSIGN_OPS.has(op) && typeof node[1] === 'string' && candidates.has(node[1])) {
1033
1104
  disqualified.add(node[1])
1034
- _disqualifyPromotion(node[2], candidates, disqualified, initSet)
1105
+ _disqualifyPromotion(node[2], candidates, disqualified, initSet, valTypes)
1035
1106
  return
1036
1107
  }
1037
1108
 
@@ -1041,7 +1112,7 @@ const _disqualifyPromotion = (node, candidates, disqualified, initSet) => {
1041
1112
  if (typeof t === 'string' && candidates.has(t)) { disqualified.add(t); return }
1042
1113
  if (Array.isArray(t) && t[0] === '[]' && typeof t[1] === 'string' && candidates.has(t[1])) {
1043
1114
  disqualified.add(t[1])
1044
- _disqualifyPromotion(t[2], candidates, disqualified, initSet)
1115
+ _disqualifyPromotion(t[2], candidates, disqualified, initSet, valTypes)
1045
1116
  return
1046
1117
  }
1047
1118
  }
@@ -1056,9 +1127,9 @@ const _disqualifyPromotion = (node, candidates, disqualified, initSet) => {
1056
1127
  else if (Array.isArray(d) && d[0] === '=' && typeof d[1] === 'string' &&
1057
1128
  candidates.has(d[1]) && !initSet.has(d)) {
1058
1129
  disqualified.add(d[1])
1059
- _disqualifyPromotion(d[2], candidates, disqualified, initSet)
1130
+ _disqualifyPromotion(d[2], candidates, disqualified, initSet, valTypes)
1060
1131
  } else {
1061
- _disqualifyPromotion(d, candidates, disqualified, initSet)
1132
+ _disqualifyPromotion(d, candidates, disqualified, initSet, valTypes)
1062
1133
  }
1063
1134
  }
1064
1135
  return
@@ -1080,14 +1151,14 @@ const _disqualifyPromotion = (node, candidates, disqualified, initSet) => {
1080
1151
  for (let i = 1; i < node.length; i++) {
1081
1152
  const child = node[i]
1082
1153
  if (i === 2 && typeof child === 'string' && candidates.has(child)) continue
1083
- _disqualifyPromotion(child, candidates, disqualified, initSet)
1154
+ _disqualifyPromotion(child, candidates, disqualified, initSet, valTypes)
1084
1155
  }
1085
1156
  return
1086
1157
  }
1087
1158
 
1088
1159
  // Generic — recurse into children. Bare-name refs at unhandled positions
1089
1160
  // hit the string-leaf branch above and disqualify on contact.
1090
- for (let i = 1; i < node.length; i++) _disqualifyPromotion(node[i], candidates, disqualified, initSet)
1161
+ for (let i = 1; i < node.length; i++) _disqualifyPromotion(node[i], candidates, disqualified, initSet, valTypes)
1091
1162
  }
1092
1163
 
1093
1164
  // Walk `body` to collect every `let X = [intLit, …]` candidate. Each entry
@@ -1155,8 +1226,11 @@ const promoteIntArrayLiteralsInBody = (body) => {
1155
1226
  if (!candidates.size) return { node: body, changed: false }
1156
1227
  const initSet = new Set()
1157
1228
  for (const { initDecl } of candidates.values()) initSet.add(initDecl)
1229
+ // valTypes from analyzeBody gives per-local VAL.* kinds, used by
1230
+ // _disqualifyPromotion to prove index keys are numeric (see _isNumericKey).
1231
+ const { valTypes } = analyzeBody(body)
1158
1232
  const disqualified = new Set()
1159
- _disqualifyPromotion(body, candidates, disqualified, initSet)
1233
+ _disqualifyPromotion(body, candidates, disqualified, initSet, valTypes)
1160
1234
  const validated = new Set()
1161
1235
  for (const name of candidates.keys()) if (!disqualified.has(name)) validated.add(name)
1162
1236
  if (!validated.size) return { node: body, changed: false }
package/src/ctx.js CHANGED
@@ -38,11 +38,11 @@ export { HEAP, LAYOUT, PTR, ATOM, FORWARDING_MASK, nanPrefixHex, atomNanHex, sso
38
38
  // |-----------|----------|---------------------------------|---------------------------|
39
39
  // | core | compile | reset, modules, inc(), emit* | emit, compile, modules |
40
40
  // | module | compile | prepare, index.js | prepare, compile, emit |
41
- // | scope | compile | analyze, compile, plan, modules | compile, emit |
42
- // | func | function | compile, narrow | emit, modules |
41
+ // | scope | compile | analyze, compile, plan, modules, assemble | compile, emit |
42
+ // | func | function | compile, narrow, assemble | emit, modules |
43
43
  // | types | function | analyze, plan | emit, modules |
44
44
  // | schema | compile | prepare, analyze, compile | prepare, analyze, emit |
45
- // | closure | init | modules (fn plugin) | emit, compile |
45
+ // | closure | init | modules (fn plugin), plan, emit | emit, compile |
46
46
  // | runtime | compile | emit, modules | emit, compile |
47
47
  // | memory | compile | index.js | compile |
48
48
  // | error | compile | prepare, compile, emit | err() |
@@ -63,6 +63,11 @@ export { HEAP, LAYOUT, PTR, ATOM, FORWARDING_MASK, nanPrefixHex, atomNanHex, sso
63
63
  // narrow-phase writers: narrowSignatures (under plan) temporarily swaps
64
64
  // ctx.func.{localReps, locals, current} per-function with save/restore
65
65
  // so per-call-site signature inference sees the right scope.
66
+ // assemble-phase writers: buildStartFn (wat/assemble.js) re-owns the ctx.func frame
67
+ // (locals/stack/refinements/…) to emit the module-init `start` fn, save/restoring
68
+ // around it; the data pass also const-folds ctx.scope.globals (mut→false) and
69
+ // declares the __heap* globals. emit seeds ctx.closure.{paramTypes,paramTypedCtors}
70
+ // at direct-call sites (read by emitClosureBody); plan sets ctx.closure.{floor,width}.
66
71
  export const ctx = {
67
72
  core: {}, // emitter table + stdlib registry (seeded by reset + modules)
68
73
  module: {}, // module graph: imports, resolved sources, module-init blocks
@@ -92,9 +97,14 @@ export const ctx = {
92
97
  // the defaults; see reset() for the field list and who flips each.
93
98
  }
94
99
 
95
- /** Create a child scope via shallow flat copy (metacircular-safe: no prototype chain).
100
+ /** Create a child scope via shallow flat copy with NO prototype chain. Critical:
101
+ * `{ ...parent }` would inherit Object.prototype in V8 (jz.js), so a name-keyed lookup
102
+ * like `chain['valueOf']`/`emit['toString']` returns the inherited method instead of
103
+ * undefined — corrupting resolution of any identifier named like an Object method. The
104
+ * kernel's jz objects are already prototype-less, so this was a jz.js-ONLY footgun. A
105
+ * prototype-less dict (Object.create(null) + assign) is correct in both engines.
96
106
  * Mutations to the child do not affect the parent; lookups work via direct property access. */
97
- export const derive = (parent) => ({ ...parent })
107
+ export const derive = (parent) => Object.assign(Object.create(null), parent)
98
108
 
99
109
  /** Include stdlib names for emission. */
100
110
  export const inc = (...names) => names.forEach(n => ctx.core.includes.add(n))
@@ -133,12 +143,19 @@ export const emitter = (deps, fn) => {
133
143
  * carry it as `.argc`; bare ones expose it as the function's own `.length`. */
134
144
  export const emitArity = (h) => h?.argc ?? h?.length
135
145
 
136
- /** Tag an emit handler as a property getter — it yields a value when the
137
- * property is *read* (`re.source`, `m.size`), so the `.`-read path may fire it.
138
- * Untagged handlers are methods: a bare read of `m.values` must not invoke them
139
- * (that would materialize a view instead of reading the `"values"` property);
140
- * they fire only from the method-call path. Apply outermost: `getter(emitter(…))`. */
141
- export const getter = (fn) => (fn.getter = true, fn)
146
+ /** Register `fn` as a property-GETTER emitter for `key` — it yields a value when
147
+ * the property is *read* (`re.source`, `m.size`, `a.byteOffset`), so the `.`-read
148
+ * path fires it. (Untagged `ctx.core.emit` handlers are methods: a bare read of
149
+ * `m.values` must NOT invoke them — that would materialize a view they fire only
150
+ * from the method-call path.) Getter-ness lives in `ctx.core.getters` (a plain Set),
151
+ * NOT as a flag on the emitter closure: the self-host kernel can't reliably read a
152
+ * dynamic property off a closure returned via a dynamic-key lookup, so a closure tag
153
+ * silently read `undefined` and every getter fell through to `__dyn_get`. A Set
154
+ * key-lookup is kernel-safe. Dispatch (module/core.js) checks `ctx.core.getters.has(key)`. */
155
+ export const registerGetter = (key, fn) => {
156
+ ctx.core.emit[key] = fn
157
+ ctx.core.getters.add(key)
158
+ }
142
159
 
143
160
  /** Expand ctx.core.includes transitively via ctx.core.stdlibDeps. Call before WASM assembly.
144
161
  * Each module co-locates its own deps with its stdlib registrations at init time. */
@@ -207,6 +224,13 @@ export function reset(proto, globals, bridge) {
207
224
  // `(import "env" "name" (global $name i64))` at assembly. Same
208
225
  // usage-gated pattern as jsstring — emit records, assembly owns
209
226
  // the ctx.module.imports write.
227
+ getters: new Set(), // keys of emit entries that are property getters — the
228
+ // kernel-safe authority for getter dispatch (a closure-attached
229
+ // flag was unreadable in the self-host kernel after a dynamic-key
230
+ // lookup, so every getter silently fell through to __dyn_get).
231
+ // MUST remain last: adding fields before stdlib/stdlibDeps/… shifts
232
+ // their slot indices and breaks the self-host compiled kernel's reads.
233
+ // Populated by registerGetter(); checked by module/core.js dispatch.
210
234
  }
211
235
 
212
236
 
@@ -234,6 +258,10 @@ export function reset(proto, globals, bridge) {
234
258
  globalTypedElem: null,
235
259
  globalReps: null, // Map<name, ValueRep> — module-level pointer reps (TYPED const globals stored as raw i32 offset, etc.)
236
260
  consts: null,
261
+ constInts: null, // Map<name, int> — module const folded to an integer literal (prepare/plan seed; static/ir read)
262
+ constStrs: null, // Map<name, string> — module const folded to a string literal
263
+ shapeStrs: null, // Map<expr, string> / shapeStrArrays: Map<name, string[]> — schema-shape string folds
264
+ shapeStrArrays: null,
237
265
  }
238
266
 
239
267
  ctx.func = {
@@ -241,7 +269,7 @@ export function reset(proto, globals, bridge) {
241
269
  names: new Set(), // Set<string> — known func names (list + imported funcs); populated at compile() start
242
270
  map: new Map(), // Map<string, func> — name → func entry; populated at compile() start
243
271
  multiProp: new Set(), // Set<"obj.prop"> — function-properties assigned >1× (wrapper composition); suppresses the static fn.prop() direct call
244
- exports: {},
272
+ exports: Object.create(null), // name-keyed: prototype-less (see derive) — `export let valueOf` must not hit Object.prototype
245
273
  current: null,
246
274
  locals: new Map(),
247
275
  localReps: null,
@@ -324,6 +352,8 @@ export function reset(proto, globals, bridge) {
324
352
  minArgc: null, // Map<closureBodyName, number> — fewest args any direct call passed.
325
353
  // A slot at index ≥ minArgc is omitted by some call (→ may be undefined),
326
354
  // so it must NOT be typed NUMBER, else `x === undefined` mis-folds to false.
355
+ floor: null, // min closure-table arity (modules: fn/timer/typedarray/array; read in plan). null ⇒ 0.
356
+ width: null, // closure call/make signature width (plan/scope sets; emit/assemble read). null ⇒ MAX_CLOSURE_ARITY.
327
357
  }
328
358
 
329
359
  ctx.runtime = {
@@ -370,6 +400,15 @@ export function reset(proto, globals, bridge) {
370
400
  inspect: false, // when true, compile() additionally populates ctx.inspect with the inferred
371
401
  // per-function signatures, locals, and JSON shapes — readable by editor
372
402
  // hosts for inlay hints / hover types without re-running the analyzer.
403
+ helperCounters: false, // internal profiling mode: export mutable i64 counters for selected
404
+ // runtime helpers and instrument their entry blocks. Build-time opt-in
405
+ // only; normal output is byte-identical and pays no counter cost.
406
+ helperCallsites: false, // profiling-only: export mutable i64 counters for selected runtime
407
+ // helper callsites after optimization, so hot helpers can be traced
408
+ // back to the compiled function that calls them.
409
+ loopXformId: 0, // monotonic id for the per-function loop transforms' generated locals
410
+ // (loop-model freshLoopId). Per-compile (reset here), not a module-global —
411
+ // so compile(P) is deterministic regardless of prior compiles in the process.
373
412
  }
374
413
 
375
414
  // Inspection sink. Populated by compile() only when transform.inspect is true.
@@ -0,0 +1,137 @@
1
+ import { ctx, declGlobal, inc } from './ctx.js'
2
+ import { findBodyStart } from './ir.js'
3
+
4
+ export const HELPER_COUNTERS = [
5
+ ['__eq', 'eq'],
6
+ ['__same_value_zero', 'same_value_zero'],
7
+ ['__is_truthy', 'is_truthy'],
8
+ ['__ptr_type', 'ptr_type'],
9
+ ['__ptr_offset', 'ptr_offset'],
10
+ ['__len', 'len'],
11
+ ['__length', 'length'],
12
+ ['__typed_idx', 'typed_idx'],
13
+ ['__str_len', 'str_len'],
14
+ ['__str_eq', 'str_eq'],
15
+ ['__str_eq_cold', 'str_eq_cold'],
16
+ ['__str_hash', 'str_hash'],
17
+ ['__map_hash', 'map_hash'],
18
+ ['__hash_get_local', 'hash_get_local'],
19
+ ['__hash_set_local', 'hash_set_local'],
20
+ ['__ihash_get_local', 'ihash_get_local'],
21
+ ['__ihash_set_local', 'ihash_set_local'],
22
+ ['__dyn_get', 'dyn_get'],
23
+ ['__dyn_get_t', 'dyn_get_t'],
24
+ ['__dyn_get_t_h', 'dyn_get_t_h'],
25
+ ['__dyn_set', 'dyn_set'],
26
+ ['__arr_grow', 'arr_grow'],
27
+ ['__arr_grow_known', 'arr_grow_known'],
28
+ ['__arr_push1', 'arr_push1'],
29
+ ['__arr_shift', 'arr_shift'],
30
+ ['__alloc', 'alloc'],
31
+ ['__alloc_hdr', 'alloc_hdr'],
32
+ ['__alloc_hdr_n', 'alloc_hdr_n'],
33
+ ['__memgrow', 'memgrow'],
34
+ ]
35
+
36
+ const COUNTER_BY_HELPER = new Map(HELPER_COUNTERS.map(([helper, label]) => [helper, `__hc_${label}`]))
37
+ const LABEL_BY_WAT_HELPER = new Map(HELPER_COUNTERS.map(([helper, label]) => [`$${helper}`, label]))
38
+
39
+ export const HELPER_SITE_PREFIX = '__hcs_'
40
+
41
+ export const helperCounterName = helper => COUNTER_BY_HELPER.get(helper)
42
+
43
+ export function installHelperCounters() {
44
+ if (!ctx.transform.helperCounters) return
45
+ for (const counter of COUNTER_BY_HELPER.values()) {
46
+ if (!ctx.scope.globals.has(counter)) declGlobal(counter, 'i64', 0, { export: counter })
47
+ }
48
+ ctx.core.stdlib.__helper_counts_reset = `(func $__helper_counts_reset (export "__helper_counts_reset")
49
+ ${[...COUNTER_BY_HELPER.values()].map(counter => ` (global.set $${counter} (i64.const 0))`).join('\n')})`
50
+ inc('__helper_counts_reset')
51
+ }
52
+
53
+ // Bump the helper's counter once on entry. NOTE the semantics: this counts FUNCTION
54
+ // ENTRIES at runtime — a call site that jz inlined or specialized away (fusedRewrite,
55
+ // specializeMkptr/specializePtrBase, …) never enters the function and is NOT counted. So
56
+ // the numbers are a relative ranking / lower bound for picking hot helpers, not exact
57
+ // operation counts. Good enough to choose targets; don't read them as call totals.
58
+ export function instrumentHelperCounter(helper, fn) {
59
+ const counter = ctx.transform.helperCounters && helperCounterName(helper)
60
+ if (!counter || !Array.isArray(fn) || fn[0] !== 'func') return fn
61
+ fn.splice(findBodyStart(fn), 0,
62
+ ['global.set', `$${counter}`, ['i64.add', ['global.get', `$${counter}`], ['i64.const', 1]]])
63
+ return fn
64
+ }
65
+
66
+ const safeExportPart = name => String(name || 'anon')
67
+ .replace(/^\$/, '')
68
+ .replace(/[^A-Za-z0-9_.-]+/g, '_')
69
+ .slice(0, 80) || 'anon'
70
+
71
+ const helperSiteFilter = () => {
72
+ const opt = ctx.transform.helperCallsites
73
+ if (opt === true) return null
74
+ const raw = Array.isArray(opt) ? opt : String(opt || '').split(',')
75
+ const labels = raw.map(s => String(s).trim()).filter(Boolean).map(s => s.replace(/^\$?__/, ''))
76
+ return labels.length ? new Set(labels) : null
77
+ }
78
+
79
+ const funcResults = fn => {
80
+ const out = []
81
+ if (!Array.isArray(fn) || fn[0] !== 'func') return out
82
+ for (let i = 2; i < fn.length; i++) {
83
+ const n = fn[i]
84
+ if (Array.isArray(n) && n[0] === 'result') out.push(...n.slice(1))
85
+ }
86
+ return out
87
+ }
88
+
89
+ const bumpCounter = counter =>
90
+ ['global.set', `$${counter}`, ['i64.add', ['global.get', `$${counter}`], ['i64.const', 1]]]
91
+
92
+ // Profiling-only helper-callsite counters. Unlike instrumentHelperCounter(), this
93
+ // answers "which compiled function executed the helper call?" by wrapping each
94
+ // final `(call $__helper ...)` with a tiny counter block:
95
+ // (block (result T) (global.set $__hcs_N ...) (call $__helper ...))
96
+ //
97
+ // This intentionally runs after whole-module optimization. The profile should
98
+ // observe final codegen, while production output remains byte-identical because
99
+ // ctx.transform.helperCallsites is build-time opt-in.
100
+ export function instrumentHelperCallsites(funcs) {
101
+ if (!ctx.transform.helperCallsites) return 0
102
+ const only = helperSiteFilter()
103
+
104
+ const resultsByName = new Map()
105
+ for (const fn of funcs) {
106
+ if (Array.isArray(fn) && fn[0] === 'func' && typeof fn[1] === 'string')
107
+ resultsByName.set(fn[1], funcResults(fn))
108
+ }
109
+
110
+ let id = 0
111
+ const wrap = (node, owner) => {
112
+ if (!Array.isArray(node)) return node
113
+ for (let i = 1; i < node.length; i++) node[i] = wrap(node[i], owner)
114
+
115
+ if (node[0] !== 'call' || typeof node[1] !== 'string') return node
116
+ const label = LABEL_BY_WAT_HELPER.get(node[1])
117
+ if (!label) return node
118
+ if (only && !only.has(label) && !only.has(node[1].replace(/^\$?__/, ''))) return node
119
+ const results = resultsByName.get(node[1])
120
+ if (!results) return node
121
+
122
+ const counter = `${HELPER_SITE_PREFIX}${id++}`
123
+ const ownerPart = safeExportPart(owner)
124
+ declGlobal(counter, 'i64', 0, { export: `${counter}:${label}:${ownerPart}` })
125
+ const block = ['block']
126
+ for (const type of results) block.push(['result', type])
127
+ block.push(bumpCounter(counter), node)
128
+ return block
129
+ }
130
+
131
+ for (const fn of funcs) {
132
+ if (!Array.isArray(fn) || fn[0] !== 'func' || typeof fn[1] !== 'string') continue
133
+ const bodyStart = findBodyStart(fn)
134
+ for (let i = bodyStart; i < fn.length; i++) fn[i] = wrap(fn[i], fn[1])
135
+ }
136
+ return id
137
+ }
package/src/ir.js CHANGED
@@ -21,8 +21,8 @@
21
21
  */
22
22
 
23
23
  import { ctx, err, inc, PTR, LAYOUT } from './ctx.js'
24
- import { ptrBoxPrefixBigInt, atomNanHex, nanPrefixHex } from '../layout.js'
25
- import { I32_MIN, I32_MAX, isI32, isLiteralStr, isFuncRef } from './ast.js'
24
+ import { ptrBoxPrefixBigInt, ptrBits, i64Hex, atomNanHex, nanPrefixHex } from '../layout.js'
25
+ import { I32_MIN, I32_MAX, isI32, isLiteralStr, isFuncRef, isLeaf } from './ast.js'
26
26
  import { VAL, lookupValType, repOf, repOfGlobal } from './reps.js'
27
27
  import { valTypeOf } from './kind.js'
28
28
  import { T } from './ast.js'
@@ -182,6 +182,82 @@ const narrowI32 = (x, isRoot) => {
182
182
  return null
183
183
  }
184
184
 
185
+ // Conservative VALUE-RANGE for a pure f64 expression tree: returns { lo, hi } bounding
186
+ // every value the node can take, or null when unknown. SOUND by construction — each rule
187
+ // over-approximates using the SAME f64 ops the runtime uses (f64 +/−/× are monotone in
188
+ // each argument, so combining endpoint-bounds with plain JS doubles yields bounds that
189
+ // contain the true value). A null/non-finite endpoint anywhere collapses to null, so a
190
+ // non-null result also PROVES the value is finite (never NaN, never ±∞). Used by toI32 to
191
+ // drop the +∞-guard `select` (and the i64 round-trip when the value fits i32) — the guard
192
+ // exists only to remap +∞→0, so a proof of finiteness retires it.
193
+ const fin = (lo, hi) => (Number.isFinite(lo) && Number.isFinite(hi) && lo <= hi) ? { lo, hi } : null
194
+ // Range of the i32 that feeds an `f64.convert_i32_*`, refined by a narrowing load width.
195
+ const convRange = (child, signed) => {
196
+ if (Array.isArray(child)) {
197
+ const o = child[0]
198
+ if (o === 'i32.load8_u') return { lo: 0, hi: 255 }
199
+ if (o === 'i32.load8_s') return { lo: -128, hi: 127 }
200
+ if (o === 'i32.load16_u') return { lo: 0, hi: 65535 }
201
+ if (o === 'i32.load16_s') return { lo: -32768, hi: 32767 }
202
+ if (o === 'i32.const' && typeof child[1] === 'number') return signed ? { lo: child[1] | 0, hi: child[1] | 0 } : { lo: child[1] >>> 0, hi: child[1] >>> 0 }
203
+ }
204
+ return signed ? { lo: I32_MIN, hi: I32_MAX } : { lo: 0, hi: 4294967295 }
205
+ }
206
+ // `get`, when supplied, resolves a `(local.get $V)` to $V's single defining value node
207
+ // (a `name → defExpr | null` map/function built from a one-def-per-local scan). This lets the
208
+ // range see through the temps that inlining introduces — e.g. `floor(mul(convert($px),0.03125))`
209
+ // stashed in `$xi` before truncation — so the i32-fit proof survives the indirection. SOUND
210
+ // without code motion: a single-textual-def local holds exactly the value its def computes, so
211
+ // the def's range bounds every value the local can take, even if the def's inputs vary across
212
+ // iterations. A self-referential (loop-carried) single def is caught by the `seen` cycle guard
213
+ // and yields null (unknown), which is conservative.
214
+ export const f64Range = (n, get) => {
215
+ const seen = get ? new Set() : null
216
+ const r = (n) => {
217
+ if (!Array.isArray(n)) return null
218
+ const op = n[0]
219
+ if (op === 'local.get' && get && typeof n[1] === 'string') {
220
+ if (seen.has(n[1])) return null // loop-carried / cyclic def → unknown
221
+ const def = typeof get === 'function' ? get(n[1]) : get.get(n[1])
222
+ if (!def) return null
223
+ seen.add(n[1]); const rng = r(def); seen.delete(n[1])
224
+ return rng
225
+ }
226
+ if (op === 'f64.const') return typeof n[1] === 'number' ? fin(n[1], n[1]) : null // `nan:…`/Inf literal strings → null
227
+ if (op === 'f64.convert_i32_s') return convRange(n[1], true)
228
+ if (op === 'f64.convert_i32_u') return convRange(n[1], false)
229
+ if (op === 'f64.neg') { const a = r(n[1]); return a && fin(-a.hi, -a.lo) }
230
+ if (op === 'f64.abs') { const a = r(n[1]); return a && fin(a.lo > 0 ? a.lo : a.hi < 0 ? -a.hi : 0, Math.max(-a.lo, a.hi)) }
231
+ if (op === 'f64.sqrt') { const a = r(n[1]); return a && a.lo >= 0 && fin(Math.sqrt(a.lo), Math.sqrt(a.hi)) }
232
+ // Rounding ops preserve finiteness and are monotonic, so the range maps elementwise. This lets
233
+ // `Math.floor(x)|0` over a bounded x (every grid/image/audio index: `px*scale`, perm[] lookups)
234
+ // drop the +∞-guard + i64 round-trip in toI32 down to a single i32.trunc_sat_f64_s. `nearest`
235
+ // (round-half-to-even) lands in {floor,ceil} so its bounds are floor(lo)..ceil(hi).
236
+ if (op === 'f64.floor') { const a = r(n[1]); return a && fin(Math.floor(a.lo), Math.floor(a.hi)) }
237
+ if (op === 'f64.ceil') { const a = r(n[1]); return a && fin(Math.ceil(a.lo), Math.ceil(a.hi)) }
238
+ if (op === 'f64.trunc') { const a = r(n[1]); return a && fin(Math.trunc(a.lo), Math.trunc(a.hi)) }
239
+ if (op === 'f64.nearest') { const a = r(n[1]); return a && fin(Math.floor(a.lo), Math.ceil(a.hi)) }
240
+ if (op === 'f64.add') { const a = r(n[1]), b = r(n[2]); return a && b && fin(a.lo + b.lo, a.hi + b.hi) }
241
+ if (op === 'f64.sub') { const a = r(n[1]), b = r(n[2]); return a && b && fin(a.lo - b.hi, a.hi - b.lo) }
242
+ if (op === 'f64.mul') {
243
+ const a = r(n[1]), b = r(n[2]); if (!a || !b) return null
244
+ const p = [a.lo * b.lo, a.lo * b.hi, a.hi * b.lo, a.hi * b.hi]
245
+ return fin(Math.min(...p), Math.max(...p))
246
+ }
247
+ if (op === 'f64.div') {
248
+ const c = Array.isArray(n[2]) && n[2][0] === 'f64.const' && typeof n[2][1] === 'number' ? n[2][1] : null
249
+ if (c == null || c === 0) return null // variable / zero divisor → may be ±∞
250
+ const a = r(n[1]); if (!a) return null
251
+ const p = [a.lo / c, a.hi / c]
252
+ return fin(Math.min(...p), Math.max(...p))
253
+ }
254
+ if (op === 'f64.min') { const a = r(n[1]), b = r(n[2]); return a && b && fin(Math.min(a.lo, b.lo), Math.min(a.hi, b.hi)) }
255
+ if (op === 'f64.max') { const a = r(n[1]), b = r(n[2]); return a && b && fin(Math.max(a.lo, b.lo), Math.max(a.hi, b.hi)) }
256
+ return null
257
+ }
258
+ return r(n)
259
+ }
260
+
185
261
  /** Coerce node to i32 with wrapping (JS `|0` semantics: values > 2^31 wrap to negative).
186
262
  * Per ECMAScript ToInt32, NaN and ±∞ map to 0. `i64.trunc_sat_f64_s` handles NaN
187
263
  * and -∞ correctly, but +∞ saturates to i64_max which wraps to -1 — guard +∞ via
@@ -204,15 +280,27 @@ export const toI32 = n => {
204
280
  // computes in i32 (mod-2^32 ring) — no trunc/guard at all.
205
281
  const nw = narrowI32(n, true)
206
282
  if (nw) return nw.node
283
+ // Value-range narrowing: a NON-integer f64 tree (e.g. `10 + 200·(u8[i]/255)`) the ring
284
+ // path rejects, but whose value is PROVABLY FINITE — so the +∞-guard `select` is dead.
285
+ // When the value also provably fits i32, a single `i32.trunc_sat_f64_s` IS exact ToInt32
286
+ // (no saturation can fire in-range, no NaN, no ±∞) — dropping the i64 round-trip AND the
287
+ // guard. Pervasive in pixel/colour packing: `(base + scale·v)|0`.
288
+ const rng = f64Range(n)
289
+ if (rng) {
290
+ if (rng.lo >= I32_MIN && rng.hi <= I32_MAX) return typed(['i32.trunc_sat_f64_s', n], 'i32')
291
+ // Finite and within (−2^63, 2^63): keep the mod-2^32 wrap, drop the (now-dead) +∞ guard.
292
+ // i64.trunc_sat does not saturate in this window, so wrap_i64 == ToInt32. Beyond ±2^63 we
293
+ // fall through to the guarded path (which already saturates there — the documented boundary).
294
+ if (rng.lo >= -9223372036854775808 && rng.hi < 9223372036854775808)
295
+ return typed(['i32.wrap_i64', ['i64.trunc_sat_f64_s', n]], 'i32')
296
+ }
207
297
  // Leaf nodes are cheap to duplicate; for everything else, evaluate once via local.tee.
208
- const isLeaf = Array.isArray(n) && n.length <= 2 &&
209
- (n[0] === 'f64.const' || n[0] === 'local.get' || n[0] === 'global.get')
210
298
  // `i32.wrap_i64(i64.trunc_sat_f64_s x)` is exact ToInt32 for |x| < 2^63 (the
211
299
  // overwhelming common range), maps NaN/−∞→0, and +∞ is guarded to 0 by the
212
300
  // select. For |x| ≥ 2^63 it saturates rather than wrapping mod 2^32 — a
213
301
  // deliberately-allowed asm.js-style boundary (no per-`|0` helper/guard cost).
214
302
  const wrap = x => typed(['i32.wrap_i64', ['i64.trunc_sat_f64_s', x]], 'i32')
215
- if (isLeaf) {
303
+ if (isLeaf(n)) {
216
304
  return typed(['select', wrap(n), ['i32.const', 0], ['f64.ne', n, ['f64.const', Infinity]]], 'i32')
217
305
  }
218
306
  const t = temp('inf')
@@ -317,13 +405,7 @@ export const BOXED_MUTATORS = new Set(['push', 'pop', 'shift', 'unshift', 'splic
317
405
  const litI32 = n => Array.isArray(n) && n[0] === 'i32.const' && typeof n[1] === 'number' ? n[1] : null
318
406
 
319
407
  /** Pack (type, aux, offset) into the f64 NaN-box bit pattern as a hex string. */
320
- function packPtrBits(type, aux, offset) {
321
- const bits = LAYOUT.NAN_PREFIX_BITS
322
- | ((BigInt(type) & BigInt(LAYOUT.TAG_MASK)) << BigInt(LAYOUT.TAG_SHIFT))
323
- | ((BigInt(aux) & BigInt(LAYOUT.AUX_MASK)) << BigInt(LAYOUT.AUX_SHIFT))
324
- | (BigInt(offset >>> 0) & BigInt(LAYOUT.OFFSET_MASK))
325
- return '0x' + bits.toString(16).toUpperCase().padStart(16, '0')
326
- }
408
+ const packPtrBits = (type, aux, offset) => i64Hex(ptrBits(type, aux, offset))
327
409
 
328
410
  /** Build `__mkptr(type, aux, offset)` IR. Folds to `(f64.const nan:0x...)` — 9 bytes
329
411
  * vs 12 for `f64.reinterpret_i64 (i64.const ...)` — when all args are i32 literals.
@@ -384,7 +466,7 @@ export function ptrTypeIR(valIR, valType) {
384
466
  // op on it misdispatches; BigInt64Array/BigUint64Array views and
385
467
  // DataView.{get,set}BigUint64 are a legacy f64-value shim there. Strings are
386
468
  // tagged and survive every boundary; BigInt math happens only inside single
387
- // expressions. (Same contract as wat/optimize.js's i64 VALUE CONTRACT.)
469
+ // expressions. (Same contract as watr/optimize's i64 VALUE CONTRACT.)
388
470
  const _F64_BITS_BUF = new ArrayBuffer(8)
389
471
  const _F64_BITS_F = new Float64Array(_F64_BITS_BUF)
390
472
  const _F64_BITS_U32 = new Uint32Array(_F64_BITS_BUF) // LE halves: [0]=lo, [1]=hi
@@ -1009,6 +1091,13 @@ export function readVar(name) {
1009
1091
  return typed(['f64.load', boxedAddr(name)], 'f64')
1010
1092
  }
1011
1093
  if (isGlobal(name)) {
1094
+ // A module-level integer const (`const N = 16384`) is an immutable compile-time
1095
+ // value: emit i32.const directly (when it fits i32) so `x % N` / `x & N` / `x / N`
1096
+ // and counters bounded by N take the native integer path, instead of the global
1097
+ // folding to an f64 constant and routing through the f64 round-trip. Value-preserving
1098
+ // — an f64 consumer widens the i32.const via convert, which folds back to f64.const.
1099
+ const ci = ctx.scope.constInts?.get?.(name)
1100
+ if (ci != null && isI32(ci)) return typed(['i32.const', ci], 'i32')
1012
1101
  const gt = ctx.scope.globalTypes.get(name) || 'f64'
1013
1102
  const node = typed(['global.get', dollar(name)], gt)
1014
1103
  const grep = repOfGlobal(name)