jz 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +66 -43
  2. package/bench/README.md +128 -78
  3. package/bench/bench.svg +32 -42
  4. package/cli.js +73 -7
  5. package/dist/interop.js +1 -0
  6. package/dist/jz.js +6024 -0
  7. package/index.d.ts +126 -0
  8. package/index.js +190 -34
  9. package/interop.js +71 -27
  10. package/layout.js +5 -0
  11. package/module/array.js +71 -39
  12. package/module/collection.js +41 -5
  13. package/module/core.js +2 -4
  14. package/module/math.js +138 -2
  15. package/module/number.js +21 -0
  16. package/module/object.js +26 -0
  17. package/module/simd.js +37 -5
  18. package/module/string.js +41 -12
  19. package/module/typedarray.js +415 -26
  20. package/package.json +21 -6
  21. package/src/autoload.js +3 -0
  22. package/src/compile/analyze-scans.js +174 -11
  23. package/src/compile/analyze.js +38 -3
  24. package/src/compile/emit-assign.js +9 -6
  25. package/src/compile/emit.js +347 -36
  26. package/src/compile/index.js +307 -29
  27. package/src/compile/infer.js +36 -1
  28. package/src/compile/loop-divmod.js +155 -0
  29. package/src/compile/narrow.js +108 -11
  30. package/src/compile/peel-stencil.js +261 -0
  31. package/src/compile/plan/advise.js +55 -1
  32. package/src/compile/plan/index.js +4 -0
  33. package/src/compile/plan/inline.js +5 -2
  34. package/src/compile/plan/literals.js +220 -5
  35. package/src/compile/plan/scope.js +115 -39
  36. package/src/compile/program-facts.js +21 -2
  37. package/src/ctx.js +45 -7
  38. package/src/ir.js +55 -7
  39. package/src/kind-traits.js +27 -0
  40. package/src/kind.js +65 -3
  41. package/src/optimize/index.js +344 -45
  42. package/src/optimize/vectorize.js +2968 -182
  43. package/src/prepare/index.js +64 -7
  44. package/src/prepare/lift-iife.js +149 -0
  45. package/src/reps.js +3 -2
  46. package/src/static.js +9 -0
  47. package/src/type.js +10 -6
  48. package/src/wat/assemble.js +195 -13
  49. package/src/wat/optimize.js +363 -185
@@ -0,0 +1,155 @@
1
+ // Loop induction strength-reduction for `i % w` and `(i / w) | 0`.
2
+ //
3
+ // JS `%` and `/` are float ops; `i % w` with a RUNTIME divisor lowers to f64
4
+ // (i % 0 is NaN, so it can't be soundly typed i32 — see type.js exprType `%`).
5
+ // That makes the column `x = i % w` an f64 local, which cascades: every dependent
6
+ // neighbour index becomes f64 and each typed-array access pays an f64→i32 convert.
7
+ // Measured ~5× slower than V8, which speculates the divisor non-zero and uses i32.
8
+ //
9
+ // This pass replaces the per-iteration division with incremental i32 counters in a
10
+ // unit-stride loop: the column `cx` increments by 1 and wraps to 0 at `w`, bumping
11
+ // the row `cy`. No division survives in the body, so the whole index chain stays
12
+ // i32. Fully sound — counters are pure increment (the divisor-zero question never
13
+ // arises), and if `w == 0` the loop's `i < w*h` guard never admits an iteration, so
14
+ // the one-time seed `(i%w)|0` (NaN→0) is unused.
15
+ //
16
+ // Recognized (post-prepare AST): a `while` whose body increments one IV `i` by +1
17
+ // exactly once, reads `['%', i, w]` and/or `['|', ['/', i, w], LIT0]` with `w`
18
+ // loop-invariant, and has no `continue` / closure-capture of i,w (which could
19
+ // desync the counters). Number literals are sparse-array holes `[, v]` (n[0] is the
20
+ // hole = undefined), so literal tests use `== null`; created literals are bare numbers.
21
+
22
+ import { findMutations } from './analyze-scans.js'
23
+
24
+ const litN = (n, k) => Array.isArray(n) && n.length === 2 && n[0] == null && n[1] === k
25
+ const isMod = (n, i, w) => Array.isArray(n) && n[0] === '%' && n[1] === i && n[2] === w
26
+ const isFloorDiv = (n, i, w) =>
27
+ Array.isArray(n) && n[0] === '|' && litN(n[2], 0) &&
28
+ Array.isArray(n[1]) && n[1][0] === '/' && n[1][1] === i && n[1][2] === w
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
+ const usesPattern = (n, i, w, pred) => Array.isArray(n) && (pred(n, i, w) || n.some(c => usesPattern(c, i, w, pred)))
47
+ const replace = (n, i, w, cx, cy) =>
48
+ !Array.isArray(n) ? n : isMod(n, i, w) ? cx : isFloorDiv(n, i, w) ? cy : n.map(c => replace(c, i, w, cx, cy))
49
+ // a `continue` that targets THIS loop (not one nested inside) — would skip the increment
50
+ const hasOuterContinue = (n) => Array.isArray(n) &&
51
+ (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
+
68
+ let _uniq = 0
69
+ let _cm = new Set() // closure-mutated vars for the function currently being transformed
70
+
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]
75
+ if (!Array.isArray(lbody) || lbody[0] !== ';') return null
76
+
77
+ // exactly one IV incremented by +1
78
+ let iv = null, ivIdx = -1
79
+ for (let k = 1; k < lbody.length; k++) {
80
+ const v = incVarOf(lbody[k])
81
+ if (v) { if (iv) return null; iv = v; ivIdx = k }
82
+ }
83
+ if (!iv) return null
84
+
85
+ // a single invariant divisor `w` (`i % w` / `(i/w)|0` all share it)
86
+ let w = null
87
+ const findW = (n) => {
88
+ if (!Array.isArray(n)) return
89
+ const d = n[0] === '%' && n[1] === iv ? n[2]
90
+ : (n[0] === '|' && litN(n[2], 0) && Array.isArray(n[1]) && n[1][0] === '/' && n[1][1] === iv) ? n[1][2] : null
91
+ if (typeof d === 'string') { if (w == null) w = d; else if (w !== d) w = false }
92
+ n.forEach(findW)
93
+ }
94
+ findW(lbody)
95
+ if (!w || w === iv) return null
96
+
97
+ const usesMod = usesPattern(lbody, iv, w, isMod)
98
+ const usesDiv = usesPattern(lbody, iv, w, isFloorDiv)
99
+ if (!usesMod && !usesDiv) return null
100
+
101
+ // soundness: w invariant, iv written ONLY by the increment, no continue/closure capture
102
+ const wMut = new Set(); findMutations(lbody, new Set([w]), wMut)
103
+ if (wMut.has(w)) return null
104
+ const ivMut = new Set()
105
+ findMutations([';', ...lbody.slice(1).filter((_, k) => k !== ivIdx - 1)], new Set([iv]), ivMut)
106
+ if (ivMut.has(iv)) return null
107
+ if (hasOuterContinue(lbody)) return null
108
+ if (_cm.has(iv) || _cm.has(w)) return null // IV/divisor mutable via a closure call
109
+
110
+ const id = _uniq++
111
+ const cx = `__lsrx${id}`, cy = `__lsry${id}`
112
+ // seed (inside the w>0 branch): cx = (i%w)|0, cy = (i/w)|0 — one-time, i32 via |0
113
+ const seedDecls = [['=', cx, ['|', ['%', iv, w], 0]]]
114
+ if (usesDiv) seedDecls.push(['=', cy, ['|', ['/', iv, w], 0]])
115
+ const seed = ['let', ...seedDecls]
116
+
117
+ // transformed body: replace patterns, inject `cx++; if(cx>=w){cx=0; cy++}` after the increment
118
+ const newBody = [';']
119
+ for (let k = 1; k < lbody.length; k++) {
120
+ newBody.push(replace(lbody[k], iv, w, cx, cy))
121
+ if (k === ivIdx) {
122
+ newBody.push(['=', cx, ['+', cx, 1]])
123
+ if (usesDiv) newBody.push(['=', cy, ['+', cy, ['?:', ['>=', cx, w], 1, 0]]])
124
+ newBody.push(['=', cx, ['?:', ['>=', cx, w], 0, cx]])
125
+ }
126
+ }
127
+ const fast = ['while', replace(cond, iv, w, cx, cy), newBody]
128
+ // The counters track i%w only when w>0 AND i>=0: w<=0 gives NaN / a negative-divisor
129
+ // modulo, and i<0 makes i%w negative (JS modulo takes the dividend's sign) — neither
130
+ // follows the 0..w-1 +1-wrap the counters model. A one-time `w>0 && i>=0` guard (i is
131
+ // the IV's entry value; it only increments, so it stays ≥0) keeps the fast i32 path
132
+ // for the universal positive-dimension, forward-counting case and falls back to the
133
+ // unmodified loop otherwise — sound for any w, any start.
134
+ return [['if', ['&&', ['>', w, 0], ['>=', iv, 0]], ['{}', [';', seed, fast]], stmt]]
135
+ }
136
+
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
+ export function strengthReduceLoopDivMod(body) {
153
+ _cm = closureMutated(body, new Set())
154
+ return walk(body)
155
+ }
@@ -7,7 +7,7 @@
7
7
  * function `sig` records change.
8
8
  */
9
9
 
10
- import { ctx, warn } from '../ctx.js'
10
+ import { ctx, warn, err } from '../ctx.js'
11
11
  import { isBlockBody, alwaysReturns, hasBareReturn, returnExprs } from '../ast.js'
12
12
  import { isLiteralStr, I32_MIN, I32_MAX } from '../ir.js'
13
13
  import {
@@ -24,7 +24,7 @@ import {
24
24
  } from '../param-reps.js'
25
25
  import {
26
26
  inferArrElemSchema, inferArrElemValType,
27
- inferSchemaId, inferValType, inferTypedCtor,
27
+ inferSchemaId, inferValType, inferTypedCtor, inferParams,
28
28
  } from './infer.js'
29
29
 
30
30
  const PTR_ABI_KINDS = new Set([VAL.OBJECT, VAL.SET, VAL.MAP, VAL.BUFFER])
@@ -156,12 +156,32 @@ function refreshCallerValTypes(callerCtx) {
156
156
  }
157
157
  }
158
158
 
159
+ // Per-caller typed-elem context: the caller's body-local typed arrays, layered
160
+ // over the module's typed-array globals so a call like `f(globalArr)` resolves
161
+ // `globalArr`'s ctor (inferTypedCtor reads only this map for a bare-name arg).
162
+ // A global is visible UNLESS the caller shadows the name with a param or local
163
+ // of its own — only then could the name denote a non-typed value. Globals are
164
+ // sound to consult: globalTypedElem holds a name only when EVERY assignment to
165
+ // it is the same single typed-array ctor (scope.js invalidates on any conflict),
166
+ // so it can't denote a different kind at the call site.
167
+ function callerTypedElemsFor(func, globalTE) {
168
+ const local = analyzeBody(func.body).typedElems
169
+ if (!globalTE.size) return local
170
+ const shadowed = new Set(analyzeBody(func.body).locals.keys())
171
+ for (const p of func.sig?.params || []) shadowed.add(p.name)
172
+ const merged = new Map()
173
+ for (const [k, v] of globalTE) if (!shadowed.has(k)) merged.set(k, v)
174
+ for (const [k, v] of local) merged.set(k, v) // local typed binding shadows the global
175
+ return merged
176
+ }
177
+
159
178
  function buildCallerTypedCtx() {
160
179
  const callerTypedCtx = new Map()
161
- callerTypedCtx.set(null, ctx.scope.globalTypedElem || new Map())
180
+ const globalTE = ctx.scope.globalTypedElem || new Map()
181
+ callerTypedCtx.set(null, globalTE)
162
182
  for (const func of ctx.func.list) {
163
183
  if (!func.body || func.raw) continue
164
- callerTypedCtx.set(func, analyzeBody(func.body).typedElems)
184
+ callerTypedCtx.set(func, callerTypedElemsFor(func, globalTE))
165
185
  }
166
186
  return callerTypedCtx
167
187
  }
@@ -1127,6 +1147,87 @@ export function adviseJsstringCarrier(paramReps, valueUsed) {
1127
1147
  }
1128
1148
  }
1129
1149
 
1150
+ // Two value-kinds CONFLICT when passing one where the other is expected would
1151
+ // rely on a JS boundary coercion jz does not implement (so the result diverges).
1152
+ // NUMBER↔STRING is the canonical pair: `"5" * 2` is 10 in JS but NaN in jz, and a
1153
+ // STRING passed to a numeric param reads its NaN-boxed bits as an f64. ARRAY/OBJECT
1154
+ // vs NUMBER/STRING likewise. We treat the four primitive-ish kinds as mutually
1155
+ // exclusive; BOOL is omitted (it nanboxes to 0/1 and numeric code tolerates it).
1156
+ const STRICT_CONFLICT = {
1157
+ [VAL.NUMBER]: new Set([VAL.STRING, VAL.ARRAY, VAL.OBJECT]),
1158
+ [VAL.STRING]: new Set([VAL.NUMBER, VAL.ARRAY, VAL.OBJECT]),
1159
+ [VAL.ARRAY]: new Set([VAL.NUMBER, VAL.STRING]),
1160
+ [VAL.OBJECT]: new Set([VAL.NUMBER, VAL.STRING]),
1161
+ }
1162
+ const kindName = (v) => ({ [VAL.NUMBER]: 'number', [VAL.STRING]: 'string', [VAL.ARRAY]: 'array', [VAL.OBJECT]: 'object' }[v] || 'value')
1163
+
1164
+ /**
1165
+ * Strict-mode boundary type check (standalone; runs in both the full-narrow and
1166
+ * skip-narrow plan paths).
1167
+ *
1168
+ * jz infers a param's type from how it's used (`x * 2` → number, `s.charCodeAt`
1169
+ * → string) or from an explicit default (`x = 0` → number). In permissive mode a
1170
+ * caller may pass any type and jz silently computes a divergent result (`"5"*2`
1171
+ * is 10 in JS, NaN here). Strict mode is the canonical subset where that misuse
1172
+ * is a compile error instead — consistent with strict already rejecting `==`,
1173
+ * dynamic dispatch, and `void`.
1174
+ *
1175
+ * Fires ONLY when BOTH sides are statically certain and conflict:
1176
+ * - the callee param has a known kind (its default-value type, or a settled
1177
+ * `val` rep from body-usage / call-site inference), AND
1178
+ * - the argument expression has a known, conflicting kind (a literal or a
1179
+ * locally-typed binding).
1180
+ * An untyped param or untyped arg is never flagged — no false positives on
1181
+ * genuinely polymorphic code.
1182
+ */
1183
+ export function strictBoundaryTypeCheck(programFacts) {
1184
+ if (!ctx.transform.strict) return
1185
+ const { callSites, paramReps } = programFacts
1186
+
1187
+ // Per-callee body-evidence cache: methodEvidence (.charCodeAt→STRING, .push→
1188
+ // ARRAY) keyed by param name. Computed lazily, once per callee.
1189
+ const bodyEvidence = new Map()
1190
+ const evidenceOf = (func) => {
1191
+ if (!bodyEvidence.has(func.name)) {
1192
+ const names = func.sig.params.map(p => p.name).filter(Boolean)
1193
+ bodyEvidence.set(func.name, func.body ? inferParams(func.body, names) : new Map())
1194
+ }
1195
+ return bodyEvidence.get(func.name)
1196
+ }
1197
+
1198
+ // Expected kind of callee param k, from any statically-certain source:
1199
+ // 1. explicit default value type — the source's own declaration (x = 0 → number)
1200
+ // 2. settled call-site/usage rep `val` (paramReps; present after full narrow)
1201
+ // 3. type-exclusive body evidence (methodEvidence; works in skip-narrow path too)
1202
+ // First certain source wins; null when the param is genuinely polymorphic.
1203
+ const paramKind = (func, k) => {
1204
+ const p = func.sig.params[k]
1205
+ if (!p) return null
1206
+ const def = func.defaults?.[p.name]
1207
+ if (def != null) { const dv = valTypeOf(def); if (dv) return dv }
1208
+ const repVal = paramReps?.get(func.name)?.get(k)?.val
1209
+ if (repVal != null) return repVal
1210
+ return evidenceOf(func).get(p.name)?.val ?? null
1211
+ }
1212
+
1213
+ for (const cs of callSites) {
1214
+ const func = ctx.func.map.get(cs.callee)
1215
+ if (!func || func.raw || !func.sig) continue
1216
+ if (func.rest) continue // rest packs args into an array
1217
+ for (let k = 0; k < cs.argList.length && k < func.sig.params.length; k++) {
1218
+ const want = paramKind(func, k)
1219
+ if (want == null) continue
1220
+ const conflicts = STRICT_CONFLICT[want]
1221
+ if (!conflicts) continue
1222
+ const got = valTypeOf(cs.argList[k])
1223
+ if (got != null && conflicts.has(got)) {
1224
+ const pname = func.sig.params[k].name
1225
+ err(`strict mode: ${kindName(want)} parameter '${pname}' of '${cs.callee}' received a ${kindName(got)} argument — jz does not coerce ${kindName(got)}→${kindName(want)} at the call boundary (the result would diverge from JS). Pass a ${kindName(want)}, or { strict: false }.`)
1226
+ }
1227
+ }
1228
+ }
1229
+ }
1230
+
1130
1231
  /**
1131
1232
  * Phase: bimorphic typed-array param specialization.
1132
1233
  *
@@ -1161,13 +1262,9 @@ export function specializeBimorphicTyped(programFacts) {
1161
1262
  if (list) list.push(cs); else sitesByCallee.set(cs.callee, [cs])
1162
1263
  }
1163
1264
 
1164
- // Per-caller typedElem map (literal `new TypedArray(N)` bindings inside body).
1165
- const callerTypedCtx = new Map()
1166
- callerTypedCtx.set(null, ctx.scope.globalTypedElem || new Map())
1167
- for (const func of ctx.func.list) {
1168
- if (!func.body || func.raw) continue
1169
- callerTypedCtx.set(func, analyzeBody(func.body).typedElems)
1170
- }
1265
+ // Per-caller typedElem map: body-local `new TypedArray(N)` bindings layered
1266
+ // over the module's typed globals (shared with buildCallerTypedCtx).
1267
+ const callerTypedCtx = buildCallerTypedCtx()
1171
1268
  // Per-caller typed-param map: caller's own params that F/G already narrowed
1172
1269
  // (so transitive `sum(arr)` inside a func that took `arr` from above resolves).
1173
1270
  const callerTypedParamsCtx = new Map()
@@ -0,0 +1,261 @@
1
+ // Edge-clamp peeling for box-filter / stencil loops.
2
+ //
3
+ // A stencil loop reads `arr[clamp(iv + k, 0, BOUND-1)]` for a window of taps k ∈
4
+ // [-r, r]. The clamp guards the array edges, but for the interior iv ∈ [r, BOUND-r)
5
+ // every `iv + k` is already in range, so the clamp is a proven no-op there. Per-tap
6
+ // the branch is cheap-but-not-free, and it blocks the marching-pointer / SIMD lift
7
+ // of the inner accumulation. Measured ~18% of the box-blur pass.
8
+ //
9
+ // Split the loop over `iv` (whose bound is the clamp's BOUND) into three runs —
10
+ // left edge [0, xs), clamp-free interior [xs, xe), right edge [xe, BOUND) — where
11
+ // xs = min(r, BOUND), xe = max(xs, BOUND - r). The interior copy has the clamp `if`
12
+ // dropped (the bare `iv + k` index remains). Bit-exact: for iv ∈ [r, BOUND-r),
13
+ // iv+k ∈ [iv-r, iv+r] ⊆ [0, BOUND-1].
14
+ //
15
+ // Recognized (post-prepare AST): a `while (iv < BOUND)` loop whose body contains a
16
+ // clamp `ci = iv + k; if (ci < 0) ci = 0; else if (ci >= BOUND) ci = BOUND-1` whose
17
+ // BOUND is the SAME var as the loop bound and whose `k` is a tap-loop IV ranging
18
+ // [-r, r] (`k = -r … k <= r`). Both hblur (peel the x-loop) and vblur (peel the
19
+ // y-loop) match. Number literals are sparse-array holes (`n[0]` is undefined), so
20
+ // literal tests use `== null`; created literals are bare numbers.
21
+
22
+ import { findMutations } from './analyze-scans.js'
23
+
24
+ const litN = (n, k) => Array.isArray(n) && n.length === 2 && n[0] == null && n[1] === k
25
+ const isVar = (n) => typeof n === 'string'
26
+
27
+ // `k = -r`: prepared as ['u-', r] (unary minus) or ['-', 0, r].
28
+ const negOf = (n) => Array.isArray(n) && n[0] === 'u-' ? n[1]
29
+ : (Array.isArray(n) && n[0] === '-' && litN(n[1], 0) ? n[2] : null)
30
+
31
+ // Every write to `iv` in `node` is a strictly-positive step (++iv / iv+=1 / iv=iv+1),
32
+ // so iv advances monotonically — required so the three split loops partition [0,bound)
33
+ // and the clamp-free interior never re-runs at an edge index. Any other write → false.
34
+ const ASSIGN = new Set(['=', '+=', '-=', '*=', '/=', '%=', '&=', '|=', '^=', '<<=', '>>=', '>>>=', '**=', '&&=', '||=', '??='])
35
+ function ivMonotonic(node, iv) {
36
+ if (!Array.isArray(node)) return true
37
+ if ((node[0] === '++' || node[0] === '--') && node[1] === iv) return node[0] === '++'
38
+ if (ASSIGN.has(node[0]) && node[1] === iv) {
39
+ if (node[0] === '+=' && litN(node[2], 1)) return true
40
+ if (node[0] === '=' && Array.isArray(node[2]) && node[2][0] === '+'
41
+ && ((node[2][1] === iv && litN(node[2][2], 1)) || (node[2][2] === iv && litN(node[2][1], 1)))) return true
42
+ return false // any other assignment to iv (=, -=, *=, …) breaks monotonicity
43
+ }
44
+ return node.every(c => ivMonotonic(c, iv))
45
+ }
46
+
47
+ // Vars assigned inside any closure in the function — a call in the loop can mutate
48
+ // them even though findMutations (direct writes only) misses it. iv/bound/r in this
49
+ // set must bail (same class as the loop-SR closure-mutation guard).
50
+ const collectAssigns = (n, out) => {
51
+ if (!Array.isArray(n)) return
52
+ if (typeof n[1] === 'string' && (ASSIGN.has(n[0]) || n[0] === '++' || n[0] === '--')) out.add(n[1])
53
+ n.forEach(c => collectAssigns(c, out))
54
+ }
55
+ const closureMutated = (n, out) => {
56
+ if (!Array.isArray(n)) return out
57
+ if (n[0] === '=>') collectAssigns(n, out)
58
+ n.forEach(c => closureMutated(c, out))
59
+ return out
60
+ }
61
+ let _cm = new Set()
62
+
63
+ // Find, anywhere in `node`, a clamp `if (ci < 0) ci = 0; else if (ci >= B) ci = B-1`
64
+ // over a var `ci` and bound var `B`. Returns { ci, bound } or null (first match).
65
+ function findClamp(node) {
66
+ if (!Array.isArray(node)) return null
67
+ if (node[0] === 'if') {
68
+ const [, cond, then, els] = node
69
+ // outer: if (ci < 0) ci = 0; else <inner>
70
+ if (Array.isArray(cond) && cond[0] === '<' && isVar(cond[1]) && litN(cond[2], 0)
71
+ && Array.isArray(then) && then[0] === '=' && then[1] === cond[1] && litN(then[2], 0)
72
+ && Array.isArray(els) && els[0] === 'if') {
73
+ const ci = cond[1], [, c2, t2] = els
74
+ // inner: if (ci >= B) ci = B-1
75
+ if (Array.isArray(c2) && c2[0] === '>=' && c2[1] === ci && isVar(c2[2])
76
+ && Array.isArray(t2) && t2[0] === '=' && t2[1] === ci
77
+ && Array.isArray(t2[2]) && t2[2][0] === '-' && t2[2][1] === c2[2] && litN(t2[2][2], 1))
78
+ return { ci, bound: c2[2], node }
79
+ }
80
+ }
81
+ for (const c of node) { const r = findClamp(c); if (r) return r }
82
+ return null
83
+ }
84
+
85
+ // `ci = iv + k` (or `k + iv`) assignment/decl: returns { iv, tap } given the clamp var.
86
+ function clampSource(node, ci) {
87
+ let found = null
88
+ const visit = (n) => {
89
+ if (found || !Array.isArray(n)) return
90
+ if (n[0] === '=' && n[1] === ci && Array.isArray(n[2]) && n[2][0] === '+') {
91
+ const [, a, b] = n[2]
92
+ if (isVar(a) && isVar(b)) found = { a, b }
93
+ }
94
+ n.forEach(visit)
95
+ }
96
+ visit(node)
97
+ return found
98
+ }
99
+
100
+ // The tap IV must range EXACTLY [-r, r]: a loop `while (t <= r)` AND `t` seeded to
101
+ // `-r` (same `r`). Returns the radius var `r` only when both match; null otherwise.
102
+ // Asymmetric / wider ranges would make xs=r, xe=bound-r unsound, so they bail.
103
+ function tapRadius(loopBody, tap) {
104
+ let boundR = null, initR = null
105
+ const visit = (n) => {
106
+ if (!Array.isArray(n)) return
107
+ // tap loop bound `k <= r`: while (cond at [1]) or for (cond at [2]).
108
+ const cond = n[0] === 'while' ? n[1] : n[0] === 'for' ? n[2] : null
109
+ if (Array.isArray(cond) && cond[0] === '<=' && cond[1] === tap && isVar(cond[2])) boundR = cond[2]
110
+ // tap init `k = -r` (a bare assignment, or inside the for's `let` init clause).
111
+ if (n[0] === '=' && n[1] === tap) { const neg = negOf(n[2]); if (isVar(neg)) initR = neg }
112
+ n.forEach(visit)
113
+ }
114
+ visit(loopBody)
115
+ return boundR != null && boundR === initR ? boundR : null
116
+ }
117
+
118
+ // Count every write (=, compound-assign, ++/--) to variable `v` in `node`.
119
+ function countWrites(node, v) {
120
+ let n = 0
121
+ const visit = (x) => {
122
+ if (!Array.isArray(x)) return
123
+ if ((x[0] === '++' || x[0] === '--') && x[1] === v) n++
124
+ else if (ASSIGN.has(x[0]) && x[1] === v) n++
125
+ x.forEach(visit)
126
+ }
127
+ visit(node)
128
+ return n
129
+ }
130
+
131
+ // Count tap-shaped seeds (`tap = -r`) and bounds (`tap <= r`) in `body`. The peel is
132
+ // sound only for a SINGLE tap loop; two loops sharing the tap var make tapRadius pick
133
+ // the wrong (last-seen) radius, so xs=r/xe=bound-r no longer match the clamped loop.
134
+ function tapStructures(body, tap) {
135
+ let seeds = 0, bounds = 0
136
+ const visit = (n) => {
137
+ if (!Array.isArray(n)) return
138
+ const cond = n[0] === 'while' ? n[1] : n[0] === 'for' ? n[2] : null
139
+ if (Array.isArray(cond) && cond[0] === '<=' && cond[1] === tap && isVar(cond[2])) bounds++
140
+ if (n[0] === '=' && n[1] === tap && isVar(negOf(n[2]))) seeds++
141
+ n.forEach(visit)
142
+ }
143
+ visit(body)
144
+ return { seeds, bounds }
145
+ }
146
+
147
+ // True if `iv` is written anywhere INSIDE a nested loop within `body`. Then iv is not
148
+ // constant across the tap accumulation (e.g. an `iv++` living inside the tap loop, so
149
+ // the real outer step is 2r+1), and the per-outer-iteration peel is unsound.
150
+ function ivWrittenInNestedLoop(body, iv) {
151
+ let found = false
152
+ const visit = (n, inLoop) => {
153
+ if (!Array.isArray(n)) return
154
+ if (inLoop && (((n[0] === '++' || n[0] === '--') && n[1] === iv) || (ASSIGN.has(n[0]) && n[1] === iv))) found = true
155
+ const deeper = inLoop || n[0] === 'while' || n[0] === 'for'
156
+ n.forEach(c => visit(c, deeper))
157
+ }
158
+ visit(body, false)
159
+ return found
160
+ }
161
+
162
+ let _uniq = 0
163
+
164
+ // Drop the clamp `if` node from a (cloned) body, leaving the bare `ci = iv + k`.
165
+ const dropClamp = (node, clampNode) =>
166
+ !Array.isArray(node) ? node
167
+ : node === clampNode ? ['{}', [';']] // empty block (the if is a statement)
168
+ : node.map(c => dropClamp(c, clampNode))
169
+
170
+ // A strictly-positive +1 step on `iv` (the for-loop increment): i++, ++i, i+=1, i=i+1.
171
+ const stepIsPosInc = (s, iv) => {
172
+ if (!Array.isArray(s)) return false
173
+ let inc = s
174
+ if (s[0] === '-' && litN(s[2], 1) && Array.isArray(s[1]) && s[1][0] === '++') inc = s[1]
175
+ if (inc[0] === '++' && inc[1] === iv) return true
176
+ if (s[0] === '+=' && s[1] === iv && litN(s[2], 1)) return true
177
+ return s[0] === '=' && s[1] === iv && Array.isArray(s[2]) && s[2][0] === '+'
178
+ && ((s[2][1] === iv && litN(s[2][2], 1)) || (s[2][2] === iv && litN(s[2][1], 1)))
179
+ }
180
+
181
+ function tryPeel(stmt) {
182
+ if (!Array.isArray(stmt)) return null
183
+ // Normalize while / for into (iv, bound, body, init, step). For a `while`, the
184
+ // increment is inside the body; for a `for` it is the separate step clause, which
185
+ // we re-append to each split loop's body (converting the for into init + whiles).
186
+ let iv, bound, body, init = null, step = null
187
+ if (stmt[0] === 'while') {
188
+ const cond = stmt[1]
189
+ if (!Array.isArray(cond) || cond[0] !== '<' || !isVar(cond[1])) return null
190
+ iv = cond[1]; bound = cond[2]; body = stmt[2]
191
+ } else if (stmt[0] === 'for') {
192
+ init = stmt[1]; const cond = stmt[2]; step = stmt[3]; body = stmt[4]
193
+ if (!Array.isArray(cond) || cond[0] !== '<' || !isVar(cond[1])) return null
194
+ iv = cond[1]; bound = cond[2]
195
+ if (!stepIsPosInc(step, iv)) return null
196
+ } else return null
197
+ if (!isVar(bound) || !Array.isArray(body)) return null
198
+ // A loop whose body is a single statement (e.g. an outer row loop wrapping one
199
+ // inner loop — the vertical blur pass) isn't a `;` sequence; normalize it.
200
+ if (body[0] !== ';') body = [';', body]
201
+
202
+ const clamp = findClamp(body)
203
+ if (!clamp || clamp.bound !== bound) return null // clamp bound must be this loop's bound
204
+ const src = clampSource(body, clamp.ci)
205
+ if (!src) return null
206
+ // ci = a + b: one operand is the loop IV, the other the tap IV
207
+ const tap = src.a === iv ? src.b : src.b === iv ? src.a : null
208
+ if (!tap) return null
209
+ const r = tapRadius(body, tap)
210
+ if (r == null) return null
211
+
212
+ // The clamp var must be written EXACTLY three times: its `ci = iv+k` source and the
213
+ // two clamp branches (`ci=0`, `ci=bound-1`). Any extra write — `ci = ci-1`, `ci = -ci`
214
+ // between the source and the clamp — changes what value the clamp actually guards, so
215
+ // dropping the clamp in the interior (which assumes the guarded value is iv+k) is
216
+ // unsound. Three is the exact count for a well-formed clamp; more ⇒ a mutation.
217
+ if (countWrites(body, clamp.ci) !== 3) return null
218
+ // Exactly one tap loop (one seed, one bound): two loops sharing the tap var would let
219
+ // tapRadius pick the wrong radius.
220
+ const ts = tapStructures(body, tap)
221
+ if (ts.seeds !== 1 || ts.bounds !== 1) return null
222
+ // iv must be constant across the tap accumulation — not bumped inside the tap loop.
223
+ if (ivWrittenInNestedLoop(body, iv)) return null
224
+
225
+ // Soundness: iv advances monotonically (else the interior re-runs at an edge index),
226
+ // and bound/r are loop-invariant (else the once-computed xs/xe go stale mid-loop).
227
+ if (!ivMonotonic(body, iv)) return null
228
+ const mut = new Set(); findMutations(body, new Set([bound, r]), mut)
229
+ if (mut.has(bound) || mut.has(r)) return null
230
+ if (_cm.has(iv) || _cm.has(bound) || _cm.has(r)) return null // closure-mutable → unsafe
231
+
232
+ const id = _uniq++
233
+ const xs = `__pks${id}`, xe = `__pke${id}`
234
+ // xs = r < bound ? r : bound ; xe = (bound - r) > xs ? bound - r : xs
235
+ const seed = ['let',
236
+ ['=', xs, ['?:', ['<', r, bound], r, bound]],
237
+ ['=', xe, ['?:', ['>', ['-', bound, r], xs], ['-', bound, r], xs]]]
238
+ const interiorBody = dropClamp(body, clamp.node)
239
+ // for: append the step to each split loop's body; while: body already increments.
240
+ const mk = (B, bod) => ['while', ['<', iv, B], step ? [';', ...bod.slice(1), step] : bod]
241
+ const loops = [mk(xs, body), mk(xe, interiorBody), mk(bound, body)]
242
+ return init ? [init, seed, ...loops] : [seed, ...loops]
243
+ }
244
+
245
+ function walk(node) {
246
+ if (!Array.isArray(node)) return node
247
+ const n = node.map(walk)
248
+ if (n[0] !== ';') return n
249
+ const out = [';']
250
+ for (let k = 1; k < n.length; k++) {
251
+ const r = tryPeel(n[k])
252
+ if (r) out.push(...r)
253
+ else out.push(n[k])
254
+ }
255
+ return out
256
+ }
257
+
258
+ export function peelClampedStencil(body) {
259
+ _cm = closureMutated(body, new Set())
260
+ return walk(body)
261
+ }
@@ -1,4 +1,4 @@
1
- import { ctx, warn } from '../../ctx.js'
1
+ import { ctx, warn, err } from '../../ctx.js'
2
2
  import { refsName, REFS_IN_EXPR } from '../../ast.js'
3
3
  import { intLiteralValue } from '../../static.js'
4
4
  import { VAL } from '../../reps.js'
@@ -307,10 +307,64 @@ function adviseSimdLoops() {
307
307
  }
308
308
 
309
309
  /** Compile-time advisories at end of plan — extensible home for soft warnings. */
310
+ // Generic-dispatch deopt advisory. A module global indexed inside a loop whose type
311
+ // never resolved to a container (its VAL stays null — "any") lowers EVERY `g[i]` to
312
+ // the runtime tag-dispatch path (__typed_idx / string fork): ~13× slower than a proven
313
+ // typed load, and almost always a MISSED type rather than intentional polymorphism — a
314
+ // loop-hot indexed container has one kind in practice (jz's value model already handles
315
+ // genuinely-polymorphic parser data efficiently; that's a different regime). Like TS
316
+ // flagging `any`, surface the bailout so it's fixed at the source — a `new T()`-typed
317
+ // global, or an `instanceof`/`+` guard — instead of silently paying the cliff. Scoped to
318
+ // module globals: their type is final here (params/locals resolve only at emit). Strict
319
+ // mode, which already rejects dynamic features, escalates this to a hard error.
320
+ function adviseGenericDispatch() {
321
+ if (!ctx.warnings && !ctx.transform.strict) return
322
+ const globals = ctx.scope.userGlobals
323
+ if (!globals?.size) return
324
+ const isGeneric = (name) => globals.has(name) && !ctx.scope.globalValTypes?.get(name)
325
+ // A global narrowed by `g instanceof Ctor` / `typeof g` in this function reads
326
+ // through the refined fast path — the user already applied the recommended fix,
327
+ // so flagging it would be noise. (Refinements are emit-time; this AST probe is
328
+ // the sound, conservative suppressor — a guard anywhere in the fn silences it.)
329
+ const guarded = (body) => {
330
+ const set = new Set()
331
+ const scan = (n) => {
332
+ if (!Array.isArray(n)) return
333
+ // Raw forms (strict mode skips jzify, so `instanceof`/`typeof` survive)…
334
+ if ((n[0] === 'instanceof' || n[0] === 'typeof') && typeof n[1] === 'string') set.add(n[1])
335
+ // …and the lowered predicate jzify emits — `g instanceof Float64Array` becomes
336
+ // `__is_typed(g)`, `typeof g === 'string'` becomes `__is_str(g)`, etc.
337
+ else if (n[0] === '()' && typeof n[1] === 'string' && n[1].startsWith('__is') && typeof n[2] === 'string') set.add(n[2])
338
+ for (let i = 1; i < n.length; i++) scan(n[i])
339
+ }
340
+ scan(body)
341
+ return set
342
+ }
343
+ for (const func of ctx.func.list) {
344
+ if (func.raw || !func.body) continue
345
+ const fn = func.name
346
+ const narrowed = guarded(func.body)
347
+ const walk = (node, inLoop) => {
348
+ if (!Array.isArray(node)) return
349
+ const op = node[0]
350
+ if (op === '[]' && inLoop && typeof node[1] === 'string' && isGeneric(node[1]) && !narrowed.has(node[1])) {
351
+ const g = node[1]
352
+ const msg = `'${g}' is indexed (\`${g}[…]\`) in a loop but its type never resolved — every access falls back to runtime dynamic dispatch (~10× slower than a typed load). Give it one provable kind: assign \`${g} = new Float64Array(…)\`, or narrow with \`instanceof\`/\`+\`.`
353
+ if (ctx.transform.strict) err(`strict mode: ${msg} Pass { strict: false } to allow dynamic dispatch.`)
354
+ warn('deopt-generic', msg, { fn }, node.loc)
355
+ }
356
+ const nowLoop = inLoop || HEAP_LOOP_OPS.has(op)
357
+ for (let i = 1; i < node.length; i++) walk(node[i], nowLoop)
358
+ }
359
+ walk(func.body, false)
360
+ }
361
+ }
362
+
310
363
  export function adviseProgram(programFacts) {
311
364
  adviseHeapGrowth()
312
365
  adviseSetMapIterationOrder()
313
366
  if (programFacts) adviseJsstringCarrier(programFacts.paramReps, programFacts.valueUsed)
314
367
  adviseSimdLoops()
368
+ adviseGenericDispatch()
315
369
  }
316
370