jz 0.8.1 → 0.9.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 (72) hide show
  1. package/README.md +56 -9
  2. package/bench/README.md +121 -50
  3. package/bench/bench.svg +23 -23
  4. package/cli.js +3 -1
  5. package/dist/interop.js +1 -1
  6. package/dist/jz.js +7541 -6271
  7. package/index.js +165 -74
  8. package/interop.js +189 -17
  9. package/jzify/arguments.js +32 -1
  10. package/jzify/async.js +409 -0
  11. package/jzify/classes.js +136 -18
  12. package/jzify/generators.js +639 -0
  13. package/jzify/hoist-vars.js +73 -1
  14. package/jzify/index.js +123 -4
  15. package/jzify/names.js +1 -0
  16. package/jzify/transform.js +239 -1
  17. package/layout.js +48 -3
  18. package/module/array.js +299 -44
  19. package/module/atomics.js +144 -0
  20. package/module/collection.js +1429 -155
  21. package/module/core.js +431 -40
  22. package/module/date.js +142 -120
  23. package/module/fs.js +144 -0
  24. package/module/function.js +6 -3
  25. package/module/index.js +4 -1
  26. package/module/json.js +270 -49
  27. package/module/math.js +892 -32
  28. package/module/number.js +532 -163
  29. package/module/object.js +353 -95
  30. package/module/regex.js +157 -7
  31. package/module/schema.js +100 -2
  32. package/module/string.js +301 -84
  33. package/module/typedarray.js +264 -72
  34. package/module/web.js +36 -0
  35. package/package.json +5 -5
  36. package/src/abi/string.js +71 -5
  37. package/src/ast.js +11 -5
  38. package/src/autoload.js +28 -1
  39. package/src/bridge.js +7 -2
  40. package/src/compile/analyze-scans.js +22 -7
  41. package/src/compile/analyze.js +136 -30
  42. package/src/compile/dyn-closure-tables.js +277 -0
  43. package/src/compile/emit-assign.js +281 -15
  44. package/src/compile/emit.js +979 -54
  45. package/src/compile/index.js +271 -29
  46. package/src/compile/infer.js +34 -3
  47. package/src/compile/inplace-store.js +329 -0
  48. package/src/compile/narrow.js +543 -15
  49. package/src/compile/peel-stencil.js +5 -0
  50. package/src/compile/plan/index.js +45 -3
  51. package/src/compile/plan/inline.js +8 -1
  52. package/src/compile/plan/literals.js +39 -0
  53. package/src/compile/plan/scope.js +430 -23
  54. package/src/compile/program-facts.js +732 -38
  55. package/src/ctx.js +64 -9
  56. package/src/helper-counters.js +7 -1
  57. package/src/ir.js +113 -5
  58. package/src/kind-traits.js +66 -3
  59. package/src/kind.js +84 -12
  60. package/src/op-policy.js +7 -4
  61. package/src/optimize/index.js +1060 -750
  62. package/src/optimize/recurse.js +2 -2
  63. package/src/optimize/vectorize.js +972 -67
  64. package/src/prepare/index.js +792 -63
  65. package/src/prepare/math-kernel.js +331 -0
  66. package/src/prepare/pre-eval.js +714 -0
  67. package/src/snapshot.js +194 -0
  68. package/src/type.js +1170 -56
  69. package/src/wat/assemble.js +403 -65
  70. package/src/wat/codegen.js +74 -14
  71. package/transform.js +113 -4
  72. package/wasi.js +3 -0
@@ -533,7 +533,11 @@ const slpStorePairsIn = (node, fnLocals, freshIdRef, newLocalDecls, getCounts) =
533
533
  for (let i = 0; i < node.length; i++) {
534
534
  const u0 = slpUnitAt(node, i, getCounts)
535
535
  if (!u0) continue
536
- const u1 = slpUnitAt(node, u0.hi + 1, getCounts)
536
+ // u1's MATCH index is its store's index, which for the flat tee'd shape is one PAST
537
+ // its span's lo (the tee'd `local.set` precedes it) — try both hi+1 (inline/block-wrapped,
538
+ // where lo===hi) and hi+2 (flat tee'd, where the store sits at lo+1) and keep whichever
539
+ // yields a unit that actually starts right after u0.
540
+ const u1 = slpUnitAt(node, u0.hi + 1, getCounts) || slpUnitAt(node, u0.hi + 2, getCounts)
537
541
  if (!u1 || u1.lo !== u0.hi + 1) continue
538
542
  if (u1.off - u0.off !== 8 || !slpSameBase(u0.addr, u1.addr)) continue
539
543
  // RAW hazard: the high store's value must not read the low store's target — the pack
@@ -916,21 +920,61 @@ function firstAccess(node, name) {
916
920
  *
917
921
  * Returns { strideLog2, teeName?: string } or null.
918
922
  */
919
- function matchLaneOffset(off, ind, offsetTees) {
923
+ // The per-iteration element count P (≥2) of an array-of-structs index `P*ind`, or null. Accepts the
924
+ // `(i32.mul P ind)`/`(i32.mul ind P)` form (non-power-of-2 P — RGB's 3) AND the strength-reduced
925
+ // power-of-2 form `(i32.shl ind S)` = ind·2^S (a `const j = 2*i`/`4*i` folds to a shift — complex/
926
+ // RGBA). P is the ELEMENT stride, not a byte offset.
927
+ function matchConstMulIV(node, ind) {
928
+ if (!isArr(node) || node.length !== 3) return null
929
+ if (node[0] === 'i32.mul') {
930
+ let p = null
931
+ if (isLocalGet(node[1], ind)) p = constNum(node[2])
932
+ else if (isLocalGet(node[2], ind)) p = constNum(node[1])
933
+ return (p != null && p >= 2 && p <= 64) ? p : null
934
+ }
935
+ if (node[0] === 'i32.shl' && isLocalGet(node[1], ind)) {
936
+ const s = constNum(node[2])
937
+ if (s != null && s >= 1 && s <= 6) return 1 << s // ind·2^S, stride 2..64
938
+ }
939
+ return null
940
+ }
941
+
942
+ function matchLaneOffset(off, ind, offsetTees, allowAos, aosPix, idxTees) {
920
943
  if (isArr(off) && off[0] === 'local.get' && typeof off[1] === 'string' &&
921
944
  offsetTees && offsetTees.has(off[1])) {
922
- return { strideLog2: offsetTees.get(off[1]), teeName: null }
945
+ return { strideLog2: offsetTees.get(off[1]), pixelStride: (aosPix && aosPix.get(off[1])) || 1, teeName: null }
923
946
  }
924
947
  let teeName = null
925
948
  let n = off
926
949
  if (isArr(n) && n[0] === 'local.tee' && n.length === 3) { teeName = n[1]; n = n[2] }
927
- // (i32.shl (local.get ind) (i32.const K))
928
- if (isArr(n) && n[0] === 'i32.shl' && n.length === 3 && isLocalGet(n[1], ind)) {
950
+ // (i32.shl <ind-scaled> (i32.const K))
951
+ if (isArr(n) && n[0] === 'i32.shl' && n.length === 3) {
929
952
  const k = constNum(n[2])
930
- if (k != null && k >= 0 && k <= 3) return { strideLog2: k, teeName }
953
+ if (k != null && k >= 0 && k <= 3) {
954
+ if (isLocalGet(n[1], ind)) return { strideLog2: k, pixelStride: 1, teeName }
955
+ // AoS (array-of-structs / interleaved channels), P elements per iteration, element
956
+ // byte-size 1<<K. Only under allowAos (tryVectorize gathers/scatters the lanes); every
957
+ // other recognizer returns null here. Two shapes: the folded `(i32.mul P ind)`, and a
958
+ // pixel-index local `(local.get $J)` with $J = P*ind (the `const j = P*i`, via idxTees).
959
+ if (allowAos) {
960
+ const p = matchConstMulIV(n[1], ind)
961
+ if (p != null) return { strideLog2: k, pixelStride: p, teeName }
962
+ if (isArr(n[1]) && n[1][0] === 'local.get' && idxTees && idxTees.has(n[1][1])) {
963
+ const pj = idxTees.get(n[1][1])
964
+ if (pj > 1) return { strideLog2: k, pixelStride: pj, teeName }
965
+ }
966
+ }
967
+ }
968
+ // POWER-OF-2 AoS stride: `a[P*i]` (P = 2,4,8 — complex/RGBA/…) folds `(P*i)<<3` to a single
969
+ // `(i32.shl ind K)` with K = 3 + log2(P) > 3. The excess shift over the f64 element (3) is the
970
+ // pixel stride. f64 lane only — scanForLoadsStores' `1<<strideLog2 === elemSize` gate keeps
971
+ // strideLog2=3 (=8 bytes), so a folded narrower-lane stride can't be mis-accepted here.
972
+ else if (allowAos && k != null && k >= 4 && k <= 9 && isLocalGet(n[1], ind)) {
973
+ return { strideLog2: 3, pixelStride: 1 << (k - 3), teeName }
974
+ }
931
975
  }
932
976
  // (local.get ind) — stride 1
933
- if (isLocalGet(n, ind)) return { strideLog2: 0, teeName }
977
+ if (isLocalGet(n, ind)) return { strideLog2: 0, pixelStride: 1, teeName }
934
978
  return null
935
979
  }
936
980
 
@@ -944,13 +988,13 @@ function matchLaneOffset(off, ind, offsetTees) {
944
988
  * `strideLog2` = K for i32.shl form, 0 for plain add form.
945
989
  * `base` is the loop-invariant base subtree.
946
990
  */
947
- function matchLaneAddr(addr, ind, addrLocals, offsetTees) {
991
+ function matchLaneAddr(addr, ind, addrLocals, offsetTees, allowAos, aosPix, idxTees) {
948
992
  let teeName = null
949
993
  let n = addr
950
994
  // (local.get $A) where $A holds a previously-tee'd FULL lane-address.
951
995
  if (isArr(n) && n[0] === 'local.get' && typeof n[1] === 'string' && addrLocals && addrLocals.has(n[1])) {
952
996
  const e = addrLocals.get(n[1])
953
- return { strideLog2: e.strideLog2, base: e.base, teeName: null, viaLocal: n[1] }
997
+ return { strideLog2: e.strideLog2, pixelStride: e.pixelStride || 1, base: e.base, teeName: null, viaLocal: n[1] }
954
998
  }
955
999
  if (isArr(n) && n[0] === 'local.tee' && n.length === 3) {
956
1000
  teeName = n[1]
@@ -958,9 +1002,9 @@ function matchLaneAddr(addr, ind, addrLocals, offsetTees) {
958
1002
  }
959
1003
  if (!isArr(n) || n[0] !== 'i32.add' || n.length !== 3) return null
960
1004
  const a = n[1], b = n[2]
961
- const off = matchLaneOffset(b, ind, offsetTees)
1005
+ const off = matchLaneOffset(b, ind, offsetTees, allowAos, aosPix, idxTees)
962
1006
  if (!off) return null
963
- return { strideLog2: off.strideLog2, base: a, teeName, offsetTeeName: off.teeName }
1007
+ return { strideLog2: off.strideLog2, pixelStride: off.pixelStride || 1, base: a, teeName, offsetTeeName: off.teeName }
964
1008
  }
965
1009
 
966
1010
  /**
@@ -969,17 +1013,30 @@ function matchLaneAddr(addr, ind, addrLocals, offsetTees) {
969
1013
  * Returns the consistent strideLog2, or null if any write to it diverges.
970
1014
  * This soundness check backs every `(local.get $T)` resolved via `offsetTees`.
971
1015
  */
972
- function _offsetLocalStride(body, name, ind) {
973
- let stride = null, found = false, ok = true
1016
+ function _offsetLocalStride(body, name, ind, allowAos, idxTees) {
1017
+ let stride = null, found = false, ok = true, pix = null
974
1018
  function walk(n) {
975
1019
  if (!isArr(n)) return
976
1020
  if ((n[0] === 'local.tee' || n[0] === 'local.set') && n[1] === name && n.length === 3) {
977
1021
  found = true
978
1022
  const v = n[2]
979
1023
  let k = null
980
- if (isArr(v) && v[0] === 'i32.shl' && v.length === 3 && isLocalGet(v[1], ind)) {
981
- k = constNum(v[2])
982
- if (k == null || k < 0 || k > 3) ok = false
1024
+ if (isArr(v) && v[0] === 'i32.shl' && v.length === 3) {
1025
+ const kk = constNum(v[2])
1026
+ // Power-of-2 AoS offset local `(i32.shl ind K)` with K>3 matches matchLaneOffset's
1027
+ // power-of-2 arm: element shift 3, pixel stride 2^(K-3). Verify all writes share one P.
1028
+ if (allowAos && isLocalGet(v[1], ind) && kk != null && kk >= 4 && kk <= 9) {
1029
+ k = 3; const p = 1 << (kk - 3); if (pix == null) pix = p; else if (pix !== p) ok = false
1030
+ }
1031
+ else if (isLocalGet(v[1], ind)) { k = kk; if (k == null || k < 0 || k > 3) ok = false }
1032
+ else if (allowAos && kk != null && kk >= 0 && kk <= 3) {
1033
+ // AoS offset local (i32.shl <P·ind> K) — folded `(i32.mul P ind)` or a pixel-index
1034
+ // local `(local.get $J)` with $J = P·ind (idxTees). Verify every write shares one P.
1035
+ let p = matchConstMulIV(v[1], ind)
1036
+ if (p == null && isArr(v[1]) && v[1][0] === 'local.get' && idxTees && idxTees.get(v[1][1]) > 1) p = idxTees.get(v[1][1])
1037
+ if (p != null) { k = kk; if (pix == null) pix = p; else if (pix !== p) ok = false }
1038
+ else ok = false
1039
+ } else ok = false
983
1040
  } else if (isLocalGet(v, ind)) {
984
1041
  k = 0
985
1042
  } else ok = false
@@ -994,6 +1051,16 @@ function _offsetLocalStride(body, name, ind) {
994
1051
  return found && ok ? stride : null
995
1052
  }
996
1053
 
1054
+ // True if the tree contains any branch or return — control flow that a flattened value-block
1055
+ // lift can't preserve (an early `br`/`return` out of the block changes which value is produced).
1056
+ const hasBranchOrReturn = (node) => {
1057
+ if (!isArr(node)) return false
1058
+ const op = node[0]
1059
+ if (op === 'br' || op === 'br_if' || op === 'br_table' || op === 'return') return true
1060
+ for (let i = 1; i < node.length; i++) if (hasBranchOrReturn(node[i])) return true
1061
+ return false
1062
+ }
1063
+
997
1064
  // True if any node in the tree writes a global. When false for a loop body,
998
1065
  // every `global.get` inside it is loop-invariant — safe to splat for SIMD.
999
1066
  const hasGlobalSet = (node) => {
@@ -1044,6 +1111,74 @@ const hasSideEffect = (node) => {
1044
1111
  * a non-`$__li` preamble, an impure value, or any array content AFTER the loop
1045
1112
  * bails. When false, ANY non-loop array content in the block bails.
1046
1113
  */
1114
+ // A transparent block — no label (first child isn't a `$label` string) and no result — is
1115
+ // pure statement grouping: wasm locals are function-scoped, and an unlabeled resultless block
1116
+ // is neither a branch target nor a value producer, so it can ONLY appear in statement position
1117
+ // (a resultless block in value position is a type error). jz emits one per source statement
1118
+ // group; watr's mergeBlocks/vacuum flattens them post-hoc. The vectorizer is jz LOWERING
1119
+ // (pre-watr), so it normalizes them itself IN PLACE — splicing each transparent block's
1120
+ // children into its parent's statement list — so every recognizer (scaffold-consuming AND
1121
+ // raw-node) reads the flat statement lists they were tuned against. Post-order: children are
1122
+ // already flat when a block is spliced up. A labeled block (branch target) or result-carrying
1123
+ // block (value producer) is kept — including the `(block $brk (loop …))` SIMD scaffold itself.
1124
+ function normalizeTransparentBlocks(node) {
1125
+ if (!isArr(node)) return
1126
+ for (let i = 1; i < node.length; i++) normalizeTransparentBlocks(node[i])
1127
+ for (let i = node.length - 1; i >= 1; i--) {
1128
+ const c = node[i]
1129
+ if (isArr(c) && c[0] === 'block' &&
1130
+ !(typeof c[1] === 'string' && c[1].startsWith('$')) &&
1131
+ !(isArr(c[1]) && c[1][0] === 'result'))
1132
+ node.splice(i, 1, ...c.slice(1))
1133
+ }
1134
+ }
1135
+
1136
+ // Fold the arithmetic identities watr's `identity` pass removes but jz emits raw — most
1137
+ // importantly `i<<0` (a byte-stride address for a u8/i8 array: `base + (i << 0)`), which the
1138
+ // vectorizer's address matchers, tuned on watr's folded IR, read as bare `i`. Also the trivial
1139
+ // `x±0`, `x|0`, `x^0`, `x<<0/>>0`, `x*1`. In place, bottom-up; returns the (possibly folded)
1140
+ // node so a parent can rebind. Pure syntactic identities — always sound, watr-equivalent.
1141
+ function foldVecIdentities(node) {
1142
+ if (!isArr(node)) return node
1143
+ for (let i = 1; i < node.length; i++) node[i] = foldVecIdentities(node[i])
1144
+ if (node.length !== 3) return node
1145
+ const op = node[0], a = node[1], b = node[2]
1146
+ const ci = (n) => isArr(n) && (n[0] === 'i32.const' || n[0] === 'i64.const') ? Number(n[1]) : NaN
1147
+ const rb = ci(b), ra = ci(a)
1148
+ switch (op) {
1149
+ case 'i32.shl': case 'i32.shr_s': case 'i32.shr_u':
1150
+ case 'i64.shl': case 'i64.shr_s': case 'i64.shr_u':
1151
+ return rb === 0 ? a : node // x << 0 = x (right-identity only)
1152
+ case 'i32.add': case 'i32.or': case 'i32.xor':
1153
+ case 'i64.add': case 'i64.or': case 'i64.xor':
1154
+ return rb === 0 ? a : (ra === 0 ? b : node) // x±0, x|0, x^0 (either side)
1155
+ case 'i32.sub': case 'i64.sub':
1156
+ return rb === 0 ? a : node // x - 0 = x
1157
+ case 'i32.mul': case 'i64.mul':
1158
+ return rb === 1 ? a : (ra === 1 ? b : node) // x*1
1159
+ default: return node
1160
+ }
1161
+ }
1162
+
1163
+ // Canonicalize jz's `if COND (then (br L))` break-idiom to watr's `br_if L COND` — the shape the
1164
+ // loop-scan recognizers (byte-scan, divergent-escape) match. watr's `brif` pass does this
1165
+ // post-hoc; the pre-watr vectorizer needs it now. Only the statement-form (no result), no-else,
1166
+ // single-`br`-then shape becomes a br_if — anything richer is left untouched for watr. In place,
1167
+ // top-down (a converted br_if has no nested `if` to revisit).
1168
+ function canonicalizeIfBr(node) {
1169
+ if (!isArr(node)) return
1170
+ for (let i = 1; i < node.length; i++) {
1171
+ const c = node[i]
1172
+ if (isArr(c) && c[0] === 'if' && c.length === 3 &&
1173
+ !(isArr(c[1]) && c[1][0] === 'result') &&
1174
+ isArr(c[2]) && c[2][0] === 'then' && c[2].length === 2 &&
1175
+ isArr(c[2][1]) && c[2][1][0] === 'br' && c[2][1].length === 2 && typeof c[2][1][1] === 'string')
1176
+ // `(br L)` only — a value-carrying `(br L v)` would lose its operand under br_if's 2-arg form.
1177
+ node[i] = ['br_if', c[2][1][1], c[1]]
1178
+ else canonicalizeIfBr(c)
1179
+ }
1180
+ }
1181
+
1047
1182
  function matchBlockLoop(blockNode, opts = {}) {
1048
1183
  if (!isArr(blockNode) || blockNode[0] !== 'block') return null
1049
1184
  const allowPreamble = !!opts.allowPreamble
@@ -1104,7 +1239,7 @@ function matchBlockLoop(blockNode, opts = {}) {
1104
1239
  * Try to vectorize the inner loop. Returns the replacement node array
1105
1240
  * (synthetic outer block) or null on no match.
1106
1241
  */
1107
- function tryVectorize(bl, fnLocals, freshIdRef) {
1242
+ function tryVectorize(bl, fnLocals, freshIdRef, pureFuncMap, constLocals) {
1108
1243
  // Consumes the shared scaffold descriptor (matchBlockLoop, computed once by the
1109
1244
  // dispatch). The LICM `$__li` preamble is cloned ahead of the SIMD block; each
1110
1245
  // set is pure & loop-invariant, so the kept scalar tail harmlessly re-runs it.
@@ -1125,6 +1260,28 @@ function tryVectorize(bl, fnLocals, freshIdRef) {
1125
1260
  // Offset tees: name → strideLog2. A CSE'd `i << K` shared across base
1126
1261
  // pointers (map loops over distinct arrays). Soundness re-checked post-scan.
1127
1262
  const offsetTees = new Map()
1263
+ // AoS (array-of-structs) de-interleave: this recognizer alone accepts a pixel-stride
1264
+ // access `base[P*i + c]` (interleaved RGB/vec3/complex). allowAos enables it in every
1265
+ // shared matcher; aosPix carries P for a CSE'd offset tee; aosPixelStride is the loop's
1266
+ // single P (1 = plain stride-1, unchanged). Set once, verified equal across all sites.
1267
+ const allowAos = true
1268
+ const aosPix = new Map()
1269
+ let aosPixelStride = 1
1270
+ // Pixel-INDEX locals: `$J = P*i` (the `const j = 3*i` of an AoS loop, kept as its own local
1271
+ // pre-watr). A channel address is then `base + ((local.get $J) << K)`; idxTees lets
1272
+ // matchLaneOffset resolve $J → pixel-stride P. Value -1 marks an inconsistent local (bail).
1273
+ const idxTees = new Map()
1274
+ {
1275
+ const walk = (n) => {
1276
+ if (!isArr(n)) return
1277
+ if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string' && n.length === 3) {
1278
+ const p = matchConstMulIV(n[2], incVar)
1279
+ if (p != null) idxTees.set(n[1], idxTees.has(n[1]) && idxTees.get(n[1]) !== p ? -1 : p)
1280
+ }
1281
+ for (let i = 1; i < n.length; i++) walk(n[i])
1282
+ }
1283
+ for (const s of body) walk(s)
1284
+ }
1128
1285
 
1129
1286
  // The compute/lane type is the WIDEST FLOAT among all loads+stores. A narrower
1130
1287
  // float/int LOAD is then a widening read (INT_WIDEN_F32 / f32→f64), a narrower
@@ -1146,6 +1303,26 @@ function tryVectorize(bl, fnLocals, freshIdRef) {
1146
1303
  for (const s of body) scanFloatWidth(s)
1147
1304
  if (preFloat) { laneType = preFloat; stride = LANE_INFO[preFloat].stride }
1148
1305
 
1306
+ // Record a memory site's pixel stride. An AoS stride (P>1) is f64-lane only (the gather/scatter
1307
+ // lifts 2 f64 lanes). Strides are collected for the post-scan uniformity gate: EVERY site must
1308
+ // share one stride, else the loop mixes stride-1 and stride-P accesses (e.g. AoS-struct loads
1309
+ // feeding stride-1 array stores) and a single gather/scatter delta would corrupt the odd sites.
1310
+ const siteStrides = []
1311
+ const recordAos = (m) => {
1312
+ const ps = m.pixelStride || 1
1313
+ if (ps > 1) {
1314
+ if (laneType !== 'f64') return false
1315
+ if (aosPixelStride === 1) aosPixelStride = ps
1316
+ if (m.offsetTeeName) aosPix.set(m.offsetTeeName, ps)
1317
+ }
1318
+ siteStrides.push(ps)
1319
+ return true
1320
+ }
1321
+ // The real address is node[1], unless a folded `offset=N` memarg precedes it (node[1] is the
1322
+ // string `offset=N`, node[2] the address) — the AoS channels `d[j+1]`,`d[j+2]` and stencil
1323
+ // neighbours arrive this way.
1324
+ const memAddr = (node) => (typeof node[1] === 'string' && node[1].startsWith('offset=')) ? node[2] : node[1]
1325
+
1149
1326
  function scanForLoadsStores(node, parent, pi) {
1150
1327
  if (!isArr(node)) return true
1151
1328
  const op = node[0]
@@ -1162,10 +1339,11 @@ function tryVectorize(bl, fnLocals, freshIdRef) {
1162
1339
  } else if (lt !== laneType && !widenInt) {
1163
1340
  return false
1164
1341
  }
1165
- const m = matchLaneAddr(node[1], incVar, addrLocals, offsetTees)
1342
+ const m = matchLaneAddr(memAddr(node), incVar, addrLocals, offsetTees, allowAos, aosPix, idxTees)
1166
1343
  if (!m) return false
1167
1344
  if ((1 << m.strideLog2) !== (widenInt ? LANE_INFO[lt].stride : stride)) return false
1168
- if (m.teeName) addrLocals.set(m.teeName, { strideLog2: m.strideLog2, base: m.base })
1345
+ if (!recordAos(m)) return false
1346
+ if (m.teeName) addrLocals.set(m.teeName, { strideLog2: m.strideLog2, pixelStride: m.pixelStride, base: m.base })
1169
1347
  if (m.offsetTeeName) offsetTees.set(m.offsetTeeName, m.strideLog2)
1170
1348
  loadStoreSites.push({ parent, idx: pi, kind: 'load' })
1171
1349
  return true
@@ -1178,27 +1356,30 @@ function tryVectorize(bl, fnLocals, freshIdRef) {
1178
1356
  const narrowing = laneType != null && sty !== laneType && isNarrowStore(laneType, sty)
1179
1357
  if (laneType != null && sty !== laneType && !narrowing) return false
1180
1358
  if (laneType == null) { laneType = sty; stride = LANE_INFO[laneType].stride }
1181
- const m = matchLaneAddr(node[1], incVar, addrLocals, offsetTees)
1359
+ const memarg = typeof node[1] === 'string' && node[1].startsWith('offset=')
1360
+ const m = matchLaneAddr(memAddr(node), incVar, addrLocals, offsetTees, allowAos, aosPix, idxTees)
1182
1361
  if (!m) return false
1183
1362
  if ((1 << m.strideLog2) !== (narrowing ? LANE_INFO[sty].stride : stride)) return false
1184
- if (m.teeName) addrLocals.set(m.teeName, { strideLog2: m.strideLog2, base: m.base })
1363
+ if (!recordAos(m)) return false
1364
+ if (m.teeName) addrLocals.set(m.teeName, { strideLog2: m.strideLog2, pixelStride: m.pixelStride, base: m.base })
1185
1365
  if (m.offsetTeeName) offsetTees.set(m.offsetTeeName, m.strideLog2)
1186
1366
  loadStoreSites.push({ parent, idx: pi, kind: 'store' })
1187
- // Recurse into VALUE child (idx 2) — it's data, not address.
1188
- if (!scanForLoadsStores(node[2], node, 2)) return false
1367
+ // Recurse into VALUE child (idx 2, or 3 past an offset= memarg) — it's data, not address.
1368
+ const valIdx = memarg ? 3 : 2
1369
+ if (!scanForLoadsStores(node[valIdx], node, valIdx)) return false
1189
1370
  return true
1190
1371
  }
1191
1372
  // local.set/tee of an address local outside a load/store context (e.g.
1192
1373
  // `(local.set $a (i32.add base (i32.shl i 2)))` as a standalone stmt) —
1193
1374
  // record so a later `(local.get $a)` resolves.
1194
1375
  if ((op === 'local.set' || op === 'local.tee') && typeof node[1] === 'string' && node.length === 3) {
1195
- const valM = matchLaneAddr(['local.tee', node[1], node[2]], incVar, addrLocals, offsetTees)
1376
+ const valM = matchLaneAddr(['local.tee', node[1], node[2]], incVar, addrLocals, offsetTees, allowAos, aosPix, idxTees)
1196
1377
  if (valM && valM.teeName) {
1197
- addrLocals.set(valM.teeName, { strideLog2: valM.strideLog2, base: valM.base })
1378
+ addrLocals.set(valM.teeName, { strideLog2: valM.strideLog2, pixelStride: valM.pixelStride, base: valM.base })
1198
1379
  }
1199
- // Standalone offset compute: `(local.set $t (i32.shl i K))`.
1200
- const offM = matchLaneOffset(node[2], incVar, offsetTees)
1201
- if (offM) offsetTees.set(node[1], offM.strideLog2)
1380
+ // Standalone offset compute: `(local.set $t (i32.shl i K))` (or AoS `(i32.shl (mul P i) K)`).
1381
+ const offM = matchLaneOffset(node[2], incVar, offsetTees, allowAos, aosPix, idxTees)
1382
+ if (offM) { offsetTees.set(node[1], offM.strideLog2); if (offM.pixelStride > 1) aosPix.set(node[1], offM.pixelStride) }
1202
1383
  }
1203
1384
  // Recurse into all children
1204
1385
  for (let i = 1; i < node.length; i++) {
@@ -1212,11 +1393,14 @@ function tryVectorize(bl, fnLocals, freshIdRef) {
1212
1393
  if (body.some(hasGlobalSet)) return null // a global write breaks the "global.get is invariant" splat
1213
1394
  if (!laneType) return null // no memory ops — vectorizing buys nothing
1214
1395
  if (loadStoreSites.length === 0) return null
1396
+ // Uniform stride gate: an AoS loop must have EVERY load/store at the same pixel stride. A mix of
1397
+ // stride-1 and stride-P sites can't share one lift stride — bail (stays scalar, always correct).
1398
+ if (aosPixelStride > 1 && siteStrides.some(s => s !== aosPixelStride)) return null
1215
1399
 
1216
1400
  // Soundness gate for offset-tee resolution: every `(local.get $T)` we
1217
1401
  // accepted as `i << K` is only valid if EVERY write of $T is that offset.
1218
1402
  for (const [name, k] of offsetTees) {
1219
- if (_offsetLocalStride(body, name, incVar) !== k) return null
1403
+ if (_offsetLocalStride(body, name, incVar, allowAos, idxTees) !== k) return null
1220
1404
  }
1221
1405
 
1222
1406
  // Classify all locals referenced in body.
@@ -1252,7 +1436,7 @@ function tryVectorize(bl, fnLocals, freshIdRef) {
1252
1436
  // Discriminate lane-data vs address-tee. Address tees hold i32 addresses,
1253
1437
  // not vector data. We classify by checking the local's declared type.
1254
1438
  const decl = fnLocals.get(name)
1255
- if (decl === 'i32' && (offsetTees.has(name) || _isAddressLocal(body, name, incVar))) {
1439
+ if (decl === 'i32' && (addrLocals.has(name) || offsetTees.has(name) || _isAddressLocal(body, name, incVar) || _isPixelIndexLocal(body, name, incVar))) {
1256
1440
  localKind.set(name, 'addr')
1257
1441
  } else {
1258
1442
  localKind.set(name, 'lane')
@@ -1285,10 +1469,43 @@ function tryVectorize(bl, fnLocals, freshIdRef) {
1285
1469
  if (dropped.size) body2 = inlined.filter(s => !dropped.has(s))
1286
1470
  }
1287
1471
 
1472
+ // A signum ternary (`a<0?-1:1`) is commonly CSE'd into its own lane-local just before its
1473
+ // sole use (`set $s (select (i32.const -1)(i32.const 1) COND); … f64.convert_i32_s($s) …`),
1474
+ // hiding it from liftExprV's `f64.convert_i32_s(select …)` fusion (below), which only matches
1475
+ // the select INLINE. Post-watr this local hop would already be copy-propagated away; pre-watr
1476
+ // it survives. When such a lane-local is read exactly once, and that read is inside a
1477
+ // `f64.convert_i32_s`, inline the select back — same "sink a single-use def into its sole
1478
+ // specialized consumer" trick as the narrowing-store case above.
1479
+ {
1480
+ const getCount2 = new Map()
1481
+ const countGets2 = (n) => { if (!isArr(n)) return; if (n[0] === 'local.get' && typeof n[1] === 'string') getCount2.set(n[1], (getCount2.get(n[1]) || 0) + 1); for (let i = 1; i < n.length; i++) countGets2(n[i]) }
1482
+ for (const s of body2) countGets2(s)
1483
+ const dropDefs = new Set()
1484
+ const inlineSign = (n) => {
1485
+ if (!isArr(n)) return n
1486
+ if (n[0] === 'f64.convert_i32_s' && n.length === 2 && isArr(n[1]) && n[1][0] === 'local.get' &&
1487
+ typeof n[1][1] === 'string' && getCount2.get(n[1][1]) === 1) {
1488
+ const nm = n[1][1]
1489
+ const def = body2.find(x => isArr(x) && x[0] === 'local.set' && x[1] === nm && x.length === 3)
1490
+ if (def && isArr(def[2]) && def[2][0] === 'select' && def[2].length === 4 && isI32Const(def[2][1]) && isI32Const(def[2][2])) {
1491
+ dropDefs.add(def)
1492
+ return ['f64.convert_i32_s', def[2]]
1493
+ }
1494
+ }
1495
+ return n.map((c, i) => i === 0 ? c : inlineSign(c))
1496
+ }
1497
+ const inlined2 = body2.map(inlineSign)
1498
+ // Two-pass: inlineSign allocates a fresh array for every node it visits (even unchanged
1499
+ // ones), so filtering `inlined2` by `dropDefs.has(...)` would fail on reference identity —
1500
+ // `dropDefs` holds references into the PRE-map `body2`, so the drop-filter must run against
1501
+ // `body2` (matching indices into `inlined2`), not against the mapped output.
1502
+ if (dropDefs.size) body2 = body2.map((s, i) => dropDefs.has(s) ? null : inlined2[i]).filter(s => s != null)
1503
+ }
1504
+
1288
1505
  // Build lifted body. If anything fails to lift, bail.
1289
1506
  const newLanedLocals = new Map() // origName → laneName (bare string; see getOrAllocLanedLocal)
1290
1507
  const extraLocals = [] // canon temps allocated during lift
1291
- const ctx = { laneType, incVar, rampVar: null, rampTemp: null, widenLoads: false, localKind, fnLocals, newLanedLocals, extraLocals, freshIdRef, fail: false, failReason: null }
1508
+ const ctx = { laneType, incVar, rampVar: null, rampTemp: null, widenLoads: false, localKind, fnLocals, newLanedLocals, extraLocals, freshIdRef, fail: false, failReason: null, aosPixelStride, pureFuncMap, inlineDepth: 0, constLocals }
1292
1509
  const lifted = []
1293
1510
  for (const s of body2) {
1294
1511
  const r = liftStmt(s, ctx)
@@ -2175,6 +2392,24 @@ function _isAddressLocal(body, name, ind) {
2175
2392
  return foundTee && onlyAsAddrTee
2176
2393
  }
2177
2394
 
2395
+ // A pixel-INDEX local — every write is `(i32.mul P ind)` — is the `const j = P*i` of an
2396
+ // AoS loop (feeds channel addresses `base + ((j+c)<<K)`). Classified as 'addr' so the lift
2397
+ // keeps it a recomputed scalar i32, never a v128 lane. (Only tryVectorize consults this.)
2398
+ function _isPixelIndexLocal(body, name, ind) {
2399
+ let found = false, ok = true
2400
+ function walk(n) {
2401
+ if (!isArr(n)) return
2402
+ if ((n[0] === 'local.set' || n[0] === 'local.tee') && n[1] === name && n.length === 3) {
2403
+ found = true
2404
+ if (matchConstMulIV(n[2], ind) == null) ok = false
2405
+ return
2406
+ }
2407
+ for (let i = 1; i < n.length; i++) walk(n[i])
2408
+ }
2409
+ for (const s of body) walk(s)
2410
+ return found && ok
2411
+ }
2412
+
2178
2413
  // ---- Lifter ----------------------------------------------------------------
2179
2414
 
2180
2415
  // Returns the v128 lane-local NAME (a string) for `name`, allocating once. We store the bare
@@ -2190,6 +2425,217 @@ function getOrAllocLanedLocal(name, newLanedLocals) {
2190
2425
  return laneName
2191
2426
  }
2192
2427
 
2428
+ // AoS de-interleave gather/scatter (ctx.aosPixelStride P > 1). The SIMD block steps the IV
2429
+ // by `lanes`, so a scalar address `A` points at pixel i, channel c; pixel i+1's same channel
2430
+ // is P elements = P*elemSize bytes further — reachable as a static load/store `offset`.
2431
+ // aosAddrPair yields two address forms that evaluate `A` exactly ONCE (teeing when needed).
2432
+ function aosAddrPair(addr, ctx) {
2433
+ if (isArr(addr) && addr[0] === 'local.get') return { a0: addr, a1: addr } // live local — read twice, free
2434
+ if (isArr(addr) && addr[0] === 'local.tee' && addr.length === 3) return { a0: addr, a1: ['local.get', addr[1]] }
2435
+ const g = `$__aosa${ctx.freshIdRef.next++}` // bare expr — tee into a scratch
2436
+ ctx.extraLocals.push(['local', g, 'i32'])
2437
+ return { a0: ['local.tee', g, addr], a1: ['local.get', g] }
2438
+ }
2439
+ const aosLoad = (off, addr) => off ? ['f64.load', `offset=${off}`, addr] : ['f64.load', addr]
2440
+ const aosStore = (off, addr, val) => off ? ['f64.store', `offset=${off}`, addr, val] : ['f64.store', addr, val]
2441
+
2442
+ // A scalar `(f64.load [offset=X] A)` → the f64x2 [pixel i chan, pixel i+1 chan]. Bit-exact:
2443
+ // the two lanes are the exact bytes the two scalar iterations read.
2444
+ function aosGather(expr, ctx) {
2445
+ const delta = ctx.aosPixelStride * LANE_INFO.f64.stride
2446
+ let baseOff = 0, addr
2447
+ if (typeof expr[1] === 'string' && expr[1].startsWith('offset=')) { baseOff = parseInt(expr[1].slice(7)) || 0; addr = expr[2] }
2448
+ else addr = expr[1]
2449
+ const { a0, a1 } = aosAddrPair(addr, ctx)
2450
+ return ['f64x2.replace_lane', 1, ['f64x2.splat', aosLoad(baseOff, a0)], aosLoad(baseOff + delta, a1)]
2451
+ }
2452
+
2453
+ // Inline a PURE user function call `(call $f ARG…)` into a single scalar value-BLOCK, feeding
2454
+ // the result back through liftExprV so the callee's ternaries/compares/transcendentals lift via
2455
+ // the SAME machinery (no separate restricted inliner). Bails (null) on any non-value statement
2456
+ // (store/loop/impure) — only straight-line pure helpers (spow, a signed-power, …) inline.
2457
+ //
2458
+ // Every argument AND every callee local is bound ONCE to a fresh block-local; param/local reads
2459
+ // substitute to `(local.get bind)`. This is critical for NESTED calls (spow whose ratio arg is
2460
+ // used 3× and itself nests spow): naive expr substitution would duplicate each arg per use and
2461
+ // blow up exponentially (there is no CSE pass after the 'post' vectorizer). Binding keeps the
2462
+ // SIMD body the same size as the scalar call graph.
2463
+ // Infer the wasm type of a value node — from its `.type` expando (jz stamps every instruction) or
2464
+ // the op prefix (`f64.add`→f64, `i32.mul`→i32, v128 ops→v128). Used to declare inline temps.
2465
+ function nodeWasmType(n) {
2466
+ if (isArr(n)) {
2467
+ if (typeof n.type === 'string') return n.type
2468
+ const op = n[0]
2469
+ if (typeof op === 'string') {
2470
+ if (op.startsWith('f64.') || op === 'f64x2.extract_lane') return 'f64'
2471
+ if (op.startsWith('f32.')) return 'f32'
2472
+ if (op.startsWith('i64.')) return 'i64'
2473
+ if (op.startsWith('i32.')) return 'i32'
2474
+ if (op.startsWith('f64x2.') || op.startsWith('f32x4.') || op.startsWith('i32x4.') || op.startsWith('i8x16.') || op.startsWith('i16x8.') || op.startsWith('i64x2.') || op.startsWith('v128.')) return 'v128'
2475
+ }
2476
+ }
2477
+ return null
2478
+ }
2479
+
2480
+ // Inline a pure function call into an expression, returning a `(block (result T) …binds… value)`
2481
+ // (or the bare value if no binding was needed) — or null if the callee isn't straight-line pure.
2482
+ // `resultType` is the callee's result type ('f64' by default, the vectorizer's only use). When a
2483
+ // `localSink` array is passed, the fresh `$__ia` binding temps are declared into it (`['local', n, T]`)
2484
+ // so a general caller can hoist them into the enclosing function; the vectorizer omits it (its lane
2485
+ // lift re-types the block). Params must be read-only (else the substitution model breaks).
2486
+ export function inlinePureCallExpr(callNode, pureFuncMap, freshIdRef, localSink = null, resultType = 'f64', tempPrefix = '$__ia') {
2487
+ const callee = pureFuncMap && pureFuncMap.get(callNode[1])
2488
+ if (!callee) return null
2489
+ const bodyStart = findBodyStart(callee)
2490
+ if (bodyStart < 0) return null
2491
+ const params = [], paramType = new Map(), localType = new Map()
2492
+ for (let i = 2; i < bodyStart; i++) {
2493
+ const d = callee[i]
2494
+ if (isArr(d) && d[0] === 'param' && typeof d[1] === 'string') { params.push(d[1]); paramType.set(d[1], d[2]) }
2495
+ else if (isArr(d) && d[0] === 'local' && typeof d[1] === 'string') localType.set(d[1], d[2])
2496
+ }
2497
+ const args = callNode.slice(2)
2498
+ if (args.length !== params.length) return null
2499
+ const body = callee.slice(bodyStart)
2500
+ for (const p of params) if (writesName(body, p)) return null // params must be read-only
2501
+ const subst = new Map()
2502
+ // Callee-local RENAMING (general-inliner path, localSink passed): a callee local
2503
+ // reached via `local.tee` / control-flow `local.set` isn't captured by bindOnce,
2504
+ // so its NAME would collide with same-named caller locals (the canonical trap:
2505
+ // arrow `(x,k)=>…` inlined into a caller whose variable is also `x`). Rename at
2506
+ // substitution time — sub() returns substituted caller-arg nodes WHOLE without
2507
+ // descending, so a rename can never touch a caller node. A tee/set of a name
2508
+ // bindOnce already substituted means reads and writes diverged — bail (broken).
2509
+ let broken = false
2510
+ // Caller-origin subtrees injected by substitution, tracked by node IDENTITY: the
2511
+ // leak backstop must skip them — a caller local legitimately named like a callee
2512
+ // local (`x`/`k` args into an `(x,k)=>…` arrow) is not a leak.
2513
+ const injected = new Set()
2514
+ const renames = localSink ? new Map() : null
2515
+ const renameOf = (name) => {
2516
+ let r = renames.get(name)
2517
+ if (!r) {
2518
+ r = `${tempPrefix}${freshIdRef.next++}`
2519
+ renames.set(name, r)
2520
+ localSink.push(['local', r, localType.get(name) || 'f64'])
2521
+ }
2522
+ return r
2523
+ }
2524
+ const sub = (n) => {
2525
+ if (!isArr(n)) return n
2526
+ if (n[0] === 'local.get' && typeof n[1] === 'string') {
2527
+ if (subst.has(n[1])) { const v = subst.get(n[1]); injected.add(v); return v }
2528
+ if (renames && localType.has(n[1])) return ['local.get', renameOf(n[1])]
2529
+ }
2530
+ if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') {
2531
+ if (subst.has(n[1])) { broken = true; return n }
2532
+ if (renames && localType.has(n[1])) return [n[0], renameOf(n[1]), ...n.slice(2).map(sub)]
2533
+ }
2534
+ return n.map((c, i) => i === 0 ? c : sub(c))
2535
+ }
2536
+ // A constant / bare local read is free to duplicate — substitute it directly (no binding),
2537
+ // which also keeps a constant exponent literal at the `pow` node so it can lower to 2-wide exp∘log.
2538
+ // convert-of-local too: one op over a register read, and keeping the convert SYNTACTIC at
2539
+ // every use is what lets the trunc∘convert / guard-vs-impossible-const identities fire
2540
+ // (the devirt arm-inline spills i32 args in that exact shape).
2541
+ const isTrivial = (n) => isArr(n) && (n[0] === 'f64.const' || n[0] === 'i32.const' ||
2542
+ (n[0] === 'local.get' && typeof n[1] === 'string') || (n[0] === 'global.get' && typeof n[1] === 'string') ||
2543
+ (n[0] === 'f64.convert_i32_s' && isArr(n[1]) && n[1][0] === 'local.get'))
2544
+ const pre = []
2545
+ const bindOnce = (name, valueExpr, declType, alreadySubbed) => {
2546
+ const v = alreadySubbed ? valueExpr : sub(valueExpr)
2547
+ if (isTrivial(v)) { subst.set(name, v); return } // cheap → substitute directly, no temp
2548
+ const bn = `${tempPrefix}${freshIdRef.next++}`
2549
+ const t = declType || nodeWasmType(v) || 'f64'
2550
+ if (localSink) localSink.push(['local', bn, t])
2551
+ pre.push(['local.set', bn, v])
2552
+ subst.set(name, ['local.get', bn])
2553
+ }
2554
+ params.forEach((p, i) => bindOnce(p, args[i], paramType.get(p), true)) // args live in the OUTER scope — do NOT sub
2555
+ // Leak guard: a callee local reached only via `local.tee` (a CSE'd subexpression) or set inside
2556
+ // control flow is NOT captured by the top-level bindOnce, so its name would survive into the caller
2557
+ // where it isn't declared ("$x not in scope"). For the general inliner (localSink passed): RENAME
2558
+ // any surviving TRUE-local name to a fresh caller-scope local declared into the sink — sound,
2559
+ // locals are function-scoped names (a tee'd NaN-guard local `(x,k)=>(x??0)|0` is the canonical
2560
+ // shape). Params are read-only and fully substituted by bindOnce, so a surviving PARAM name means
2561
+ // the model broke — bail (keep the call) as the backstop. The VECTORIZER path (localSink == null)
2562
+ // re-processes the returned expression in its lane context — a tee'd callee local becomes a lane
2563
+ // local there — so it must NOT bail or rename, or pure helpers with a CSE'd tee (spow's `av`)
2564
+ // stop vectorizing.
2565
+ const calleeLocals = new Set([...paramType.keys(), ...localType.keys()])
2566
+ // Backstop: with renaming inlined into sub(), the only way a callee name survives
2567
+ // into a sink-spliced result is a broken substitution model (e.g. a param name in
2568
+ // write position, or a bindOnce'd local later tee'd). Bail — keep the call. The
2569
+ // VECTORIZER path (localSink == null) neither renames nor bails: it re-processes
2570
+ // the expression in its lane context, where a tee'd callee local becomes a lane
2571
+ // local — bailing there would stop pure helpers with a CSE'd tee (spow's `av`)
2572
+ // from vectorizing.
2573
+ const leaks = (n) => isArr(n) && !injected.has(n) &&
2574
+ (((n[0] === 'local.get' || n[0] === 'local.set' || n[0] === 'local.tee') && calleeLocals.has(n[1])) || n.some((c, i) => i > 0 && leaks(c)))
2575
+ const wrap = (val) => {
2576
+ const r = pre.length ? ['block', ['result', resultType], ...pre, val] : val
2577
+ return (localSink && (broken || leaks(r))) ? null : r
2578
+ }
2579
+ for (let k = 0; k < body.length; k++) {
2580
+ const stmt = body[k]
2581
+ if (!isArr(stmt)) return null
2582
+ if (stmt[0] === 'local.set' && typeof stmt[1] === 'string' && stmt.length === 3) { bindOnce(stmt[1], stmt[2], localType.get(stmt[1]), false); continue }
2583
+ if (stmt[0] === 'return' && stmt.length === 2) return wrap(sub(stmt[1]))
2584
+ // Trailing value expression = implicit return (a bare `if`/`block`/… as the function's last
2585
+ // statement, `(v) => cond ? a : b`). Earlier non-set/non-return statements can't be values.
2586
+ if (k === body.length - 1) return wrap(sub(stmt))
2587
+ return null
2588
+ }
2589
+ return null
2590
+ }
2591
+
2592
+ // Statement-position containers: a call that is a DIRECT child here may be a statement (void /
2593
+ // block-fallthrough), where the value-producing `(block (result T) …)` inline form is ill-typed.
2594
+ // The general inliner only rewrites calls in operand (value) position — the common `x = f(…)`,
2595
+ // `a[i] = f(…)`, `f(…) * k` shapes — and recurses into these so nested-in-operand calls still inline.
2596
+ const INLINE_STMT_CTX = new Set(['block', 'loop', 'func', 'then', 'else', 'if'])
2597
+
2598
+ // General pre-watr pure-function inlining — jz LOWERING (runs before the vectorizer). Replaces a
2599
+ // `(call $g …)` in value position with $g's inlined body when $g is PURE (pureFuncMap) and
2600
+ // straight-line. jz decides by PURITY + TYPES — knowledge watr's untyped, size-gated inliner lacks —
2601
+ // exposing the callee's arithmetic to the vectorizer / narrower / const-folder. watr keeps only the
2602
+ // mechanical residual. Bit-exact: params are read-only, args bind once (or substitute if trivial),
2603
+ // the callee's straight-line body becomes a result-typed block. Fresh temps are declared into `fn`.
2604
+ export function inlinePureFnsInFn(fn, pureFuncMap, freshIdRef, canInline) {
2605
+ if (!isArr(fn) || fn[0] !== 'func' || !pureFuncMap || !pureFuncMap.size || !canInline || !canInline.size) return
2606
+ const selfName = fn[1]
2607
+ const bodyStart = findBodyStart(fn)
2608
+ if (bodyStart < 0) return
2609
+ const newLocals = []
2610
+ const resultTypeOf = (callee) => {
2611
+ for (let i = 2; i < callee.length; i++) {
2612
+ const d = callee[i]
2613
+ if (!isArr(d)) break
2614
+ if (d[0] === 'result') return d[1]
2615
+ if (d[0] !== 'param' && d[0] !== 'export' && d[0] !== 'local' && d[0] !== 'type') break
2616
+ }
2617
+ return 'f64'
2618
+ }
2619
+ const walk = (node) => {
2620
+ if (!isArr(node)) return node
2621
+ const parentIsStmt = INLINE_STMT_CTX.has(node[0])
2622
+ for (let i = 1; i < node.length; i++) {
2623
+ let child = node[i]
2624
+ if (!isArr(child)) continue
2625
+ child = walk(child) // recurse first → inline nested calls (e.g. in this call's args)
2626
+ node[i] = child
2627
+ if (!parentIsStmt && child[0] === 'call' && typeof child[1] === 'string' &&
2628
+ child[1] !== selfName && canInline.has(child[1]) && pureFuncMap.has(child[1])) {
2629
+ const inlined = inlinePureCallExpr(child, pureFuncMap, freshIdRef, newLocals, resultTypeOf(pureFuncMap.get(child[1])), '$__gi')
2630
+ if (inlined != null) node[i] = inlined
2631
+ }
2632
+ }
2633
+ return node
2634
+ }
2635
+ for (let i = bodyStart; i < fn.length; i++) fn[i] = walk(fn[i])
2636
+ if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
2637
+ }
2638
+
2193
2639
  // Wrap an already-lifted v128 value `coreV` in per-lane NaN canonicalization:
2194
2640
  // v128.bitselect(splat(C), coreV, laneNe(coreV, coreV))
2195
2641
  // coreV is referenced three times. When it's a bare local.get (the common
@@ -2229,6 +2675,10 @@ let _whyNotReason = null
2229
2675
  // on unconditionally. Armed for the duration of a vectorizeLaneLocal call.
2230
2676
  let _relaxF32 = false
2231
2677
 
2678
+ // optimize.crPow, armed the same way — the const-exponent pow arm picks its lowering
2679
+ // from it (lift ctx objects don't carry the optimize config; module flag is the pattern).
2680
+ let _crPow = false
2681
+
2232
2682
  // Mark a lift bail and record its reason. First-write-wins: the innermost failing op
2233
2683
  // sets ctx.failReason; outer frames see ctx.fail already set and return without
2234
2684
  // overwriting, so the reason names the actual blocking op, not a wrapper.
@@ -2320,7 +2770,10 @@ function liftStmt(stmt, ctx) {
2320
2770
  // Address-only local: lift the value as-is (it's i32 arithmetic on ind).
2321
2771
  return ['local.set', name, stmt[2]]
2322
2772
  }
2323
- if (kind === 'lane') {
2773
+ // 'lane', or an UNCLASSIFIED local — which can only be one introduced by an inlined pure
2774
+ // callee (classification covers every original body local; a pure helper's temps are fresh
2775
+ // per-iteration lane values, never loop-carried). Both lift as lane data.
2776
+ if (kind === 'lane' || kind === undefined) {
2324
2777
  const laneName = getOrAllocLanedLocal(name, ctx.newLanedLocals)
2325
2778
  const v = liftExprV(stmt[2], ctx)
2326
2779
  if (ctx.fail) return null
@@ -2330,11 +2783,30 @@ function liftStmt(stmt, ctx) {
2330
2783
  }
2331
2784
 
2332
2785
  if (STORE_OPS[op]) {
2786
+ const sty = STORE_OPS[op]
2787
+ // AoS de-interleave scatter: `(f64.store [offset=X] A V)` → tee the f64x2 V once, then
2788
+ // write lane 0 at X (pixel i) and lane 1 at X + P*elemSize (pixel i+1) — the exact two
2789
+ // scalar stores. Handles the folded `offset=` memarg form (channels d[j+1], d[j+2]).
2790
+ if (ctx.aosPixelStride > 1) {
2791
+ if (sty !== ctx.laneType) return liftFail(ctx, 'AoS narrowing store unsupported')
2792
+ let baseOff = 0, addr, val
2793
+ if (typeof stmt[1] === 'string' && stmt[1].startsWith('offset=')) { baseOff = parseInt(stmt[1].slice(7)) || 0; addr = stmt[2]; val = stmt[3] }
2794
+ else { addr = stmt[1]; val = stmt[2] }
2795
+ const v = liftExprV(val, ctx)
2796
+ if (ctx.fail) return null
2797
+ const delta = ctx.aosPixelStride * LANE_INFO.f64.stride
2798
+ const { a0, a1 } = aosAddrPair(addr, ctx)
2799
+ const vt = `$__aosv${ctx.freshIdRef.next++}`
2800
+ ctx.extraLocals.push(['local', vt, 'v128'])
2801
+ return ['__seq__',
2802
+ ['local.set', vt, v],
2803
+ aosStore(baseOff, a0, ['f64x2.extract_lane', 0, ['local.get', vt]]),
2804
+ aosStore(baseOff + delta, a1, ['f64x2.extract_lane', 1, ['local.get', vt]])]
2805
+ }
2333
2806
  const addr = stmt[1] // we leave addresses as-is (scalar i32 expressions)
2334
2807
  // Handle memarg if present (last positional after addr/val): unlikely in
2335
2808
  // pre-watr IR for this shape; bail if more than 3 children.
2336
2809
  if (stmt.length !== 3) return liftFail(ctx, `${op} with memarg`)
2337
- const sty = STORE_OPS[op]
2338
2810
  // Narrowing store: a narrower element written from a wider float lane (`o[i] =
2339
2811
  // narrow(f(x))` — codec encode / downsample). The scalar store value carries a
2340
2812
  // conversion (f32.demote_f64, or the float→int ToInt32 idiom); peel it, lift the
@@ -2494,6 +2966,9 @@ function liftExprV(expr, ctx) {
2494
2966
  // Loads → v128.load (preserving address, including any local.tee).
2495
2967
  if (LOAD_OPS[op]) {
2496
2968
  if (LOAD_OPS[op] !== ctx.laneType) return liftFail(ctx, `${op}: load type ≠ lane type ${ctx.laneType}`)
2969
+ // AoS de-interleave: consecutive elements are DIFFERENT channels, so a plain v128.load
2970
+ // would mix channels — gather the same channel of pixels i, i+1 into the f64x2 instead.
2971
+ if (ctx.aosPixelStride > 1) return aosGather(expr, ctx)
2497
2972
  // memarg form `(T.load offset=N addr)` — the stencil neighbour `a[i+1]` jz folds
2498
2973
  // onto `a[i]`'s address tee. `v128.load offset=N` reads the N-byte-shifted vector,
2499
2974
  // i.e. the (a[i+1], a[i+2]) pair — exactly the δ-shifted lane data. Preserve it.
@@ -2536,7 +3011,22 @@ function liftExprV(expr, ctx) {
2536
3011
  if (kind === 'addr' || name === ctx.incVar) {
2537
3012
  return liftFail(ctx, `${name}: address/induction var used as lane data`)
2538
3013
  }
2539
- return liftFail(ctx, `${name}: unclassified local in value position`)
3014
+ // Unclassified (undefined) & not the IV/addr: a local introduced by an inlined pure callee —
3015
+ // read its lane shadow (the matching lane-set default above allocated it).
3016
+ return ['local.get', getOrAllocLanedLocal(name, ctx.newLanedLocals)]
3017
+ }
3018
+
3019
+ // `(local.tee $x V)` in value position — a CSE temp inside a value expression (e.g. the base
3020
+ // teed for reuse in an inlined `x**(k/5)` fifthroot / a repeated subexpression). Lift V into the
3021
+ // lane shadow of $x and tee it: later `(local.get $x)` reads resolve to the same shadow.
3022
+ if (op === 'local.tee' && typeof expr[1] === 'string' && expr.length === 3) {
3023
+ const name = expr[1]
3024
+ const kind = ctx.localKind.get(name)
3025
+ if (kind === 'lane' || kind === undefined) {
3026
+ const v = liftExprV(expr[2], ctx); if (ctx.fail) return null
3027
+ return ['local.tee', getOrAllocLanedLocal(name, ctx.newLanedLocals), v]
3028
+ }
3029
+ return liftFail(ctx, `local.tee ${name}: non-lane local in value position`)
2540
3030
  }
2541
3031
 
2542
3032
  // Loop-invariant global (e.g. a hoistConstantPool'd const, or any global the
@@ -2553,6 +3043,24 @@ function liftExprV(expr, ctx) {
2553
3043
  const inner = expr[1]
2554
3044
  const inv = isArr(inner) && (inner[0] === 'global.get' || (inner[0] === 'local.get' && ctx.localKind.get(inner[1]) === 'invariant'))
2555
3045
  if (inv) return [info.splat, expr]
3046
+ // Sign / small-int ternary `cond ? A : B` (A,B integer literals) lowered to
3047
+ // `(f64.convert_i32_s (select (i32.const A) (i32.const B) COND))` — e.g. `a<0 ? -1 : 1`.
3048
+ // Convert the literals to f64 and bitselect by COND's lane mask (the f64-lane ternary).
3049
+ if (ctx.laneType === 'f64' && isArr(inner) && inner[0] === 'select' && inner.length === 4 &&
3050
+ isI32Const(inner[1]) && isI32Const(inner[2])) {
3051
+ let cond = inner[3]
3052
+ if (isArr(cond) && cond[0] === 'i32.ne' && isI32Const(cond[2]) && cond[2][1] === 0) cond = cond[1]
3053
+ const cmpS = isArr(cond) && cond.length === 3 ? LANE_COMPARE.f64?.[cond[0]] : null
3054
+ if (cmpS) {
3055
+ const ca = liftExprV(cond[1], ctx); if (ctx.fail) return null
3056
+ const cb = liftExprV(cond[2], ctx); if (ctx.fail) return null
3057
+ const mtmp = `$__mask${ctx.freshIdRef.next++}`
3058
+ ctx.extraLocals.push(['local', mtmp, 'v128'])
3059
+ return ['block', ['result', 'v128'],
3060
+ ['local.set', mtmp, [cmpS, ca, cb]],
3061
+ ['v128.bitselect', ['f64x2.splat', ['f64.const', inner[1][1]]], ['f64x2.splat', ['f64.const', inner[2][1]]], ['local.get', mtmp]]]
3062
+ }
3063
+ }
2556
3064
  }
2557
3065
 
2558
3066
  // NaN-canonicalization wrapper (float lanes only; integer lanes never carry
@@ -2594,9 +3102,24 @@ function liftExprV(expr, ctx) {
2594
3102
  }
2595
3103
  if ((ctx.laneType === 'f64' || ctx.laneType === 'f32') && op === 'block') {
2596
3104
  const m = matchCanonBlock(expr, ctx.laneType)
2597
- if (!m) return liftFail(ctx, 'non-canonical value-block')
2598
- const coreV = liftExprV(m.core, ctx)
2599
- return ctx.fail ? null : liftCanon(coreV, m.C, ctx, info)
3105
+ if (m) { const coreV = liftExprV(m.core, ctx); return ctx.fail ? null : liftCanon(coreV, m.C, ctx, info) }
3106
+ // General value-block (a let-binding): `(block [label] (result T) …laneSets… TAILVALUE)`.
3107
+ // jz emits these for an inlined value function (e.g. `av ** e` → an exp∘log block). Lift the
3108
+ // intermediate lane-local sets, then the tail value. Sound ONLY when the block is straight-line
3109
+ // (no br/br_if/br_table/return targeting it — an early-exit can't be flattened) — bail otherwise.
3110
+ let bi = 1
3111
+ if (typeof expr[bi] === 'string') bi++
3112
+ if (isArr(expr[bi]) && expr[bi][0] === 'result') bi++
3113
+ const parts = expr.slice(bi)
3114
+ if (parts.length === 0 || parts.some(hasBranchOrReturn)) return liftFail(ctx, 'non-canonical value-block')
3115
+ const out = ['block', ['result', 'v128']]
3116
+ for (let k = 0; k < parts.length - 1; k++) {
3117
+ const l = liftStmt(parts[k], ctx); if (ctx.fail) return null
3118
+ if (l != null) { if (Array.isArray(l) && l[0] === '__seq__') out.push(...l.slice(1)); else out.push(l) }
3119
+ }
3120
+ const tail = liftExprV(parts[parts.length - 1], ctx); if (ctx.fail) return null
3121
+ out.push(tail)
3122
+ return out
2600
3123
  }
2601
3124
 
2602
3125
  // Conditional select — jz lowers `cond ? X : Y` to (if (result LT) COND (then X)
@@ -2615,8 +3138,13 @@ function liftExprV(expr, ctx) {
2615
3138
  const resTy = isArr(expr[1]) && expr[1][0] === 'result' ? expr[1][1] : null
2616
3139
  if (resTy !== ctx.laneType && !(ctx.laneType === 'f32' && resTy === 'f64')) return liftFail(ctx, 'conditional without lane-typed result')
2617
3140
  const thenN = expr[3], elseN = expr[4]
2618
- if (!isArr(thenN) || thenN[0] !== 'then' || thenN.length !== 2) return liftFail(ctx, 'malformed conditional then-branch')
2619
- if (!isArr(elseN) || elseN[0] !== 'else' || elseN.length !== 2) return liftFail(ctx, 'malformed conditional else-branch')
3141
+ // A branch is `(then …preludeSets… TAILVALUE)` usually just the tail (length 2), but jz's
3142
+ // NaN-canonicalization of a negation tees the value first (`(then (set $t (neg a)) (canon $t))`),
3143
+ // so accept intermediate lane-local sets before the tail. Both branches evaluate speculatively
3144
+ // (lane-pure ⇒ trap-free); each tail is snapshotted into its own temp BEFORE the other branch's
3145
+ // prelude runs, so a shared prelude local can't clobber the already-computed value.
3146
+ if (!isArr(thenN) || thenN[0] !== 'then' || thenN.length < 2) return liftFail(ctx, 'malformed conditional then-branch')
3147
+ if (!isArr(elseN) || elseN[0] !== 'else' || elseN.length < 2) return liftFail(ctx, 'malformed conditional else-branch')
2620
3148
  let cond = expr[2]
2621
3149
  if (isArr(cond) && cond[0] === 'i32.ne' && isI32Const(cond[2]) && cond[2][1] === 0) cond = cond[1] // strip `!= 0`
2622
3150
  // f32 lane: operands were promoted, so the compare is `f64.*` — use its f32x4 form
@@ -2626,13 +3154,28 @@ function liftExprV(expr, ctx) {
2626
3154
  if (!cmpSimd) return liftFail(ctx, `${isArr(cond) ? cond[0] : 'condition'}: not a lane-vectorizable comparison`)
2627
3155
  const ca = liftExprV(cond[1], ctx); if (ctx.fail) return null
2628
3156
  const cb = liftExprV(cond[2], ctx); if (ctx.fail) return null
2629
- const x = liftExprV(thenN[1], ctx); if (ctx.fail) return null
2630
- const y = liftExprV(elseN[1], ctx); if (ctx.fail) return null
2631
- const mtmp = `$__mask${ctx.freshIdRef.next++}`
2632
- ctx.extraLocals.push(['local', mtmp, 'v128'])
3157
+ // Lift a branch: its prelude sets, then its tail value snapshotted into `outTmp`.
3158
+ const liftArm = (arm, outTmp) => {
3159
+ const out = []
3160
+ for (let i = 1; i < arm.length - 1; i++) {
3161
+ const l = liftStmt(arm[i], ctx); if (ctx.fail) return null
3162
+ if (l != null) { if (Array.isArray(l) && l[0] === '__seq__') out.push(...l.slice(1)); else out.push(l) }
3163
+ }
3164
+ const v = liftExprV(arm[arm.length - 1], ctx); if (ctx.fail) return null
3165
+ out.push(['local.set', outTmp, v])
3166
+ return out
3167
+ }
3168
+ const id = ctx.freshIdRef.next++
3169
+ const tv = `$__then${id}`, ev = `$__else${id}`, mtmp = `$__mask${id}`
3170
+ ctx.extraLocals.push(['local', tv, 'v128'], ['local', ev, 'v128'], ['local', mtmp, 'v128'])
3171
+ // Mask FIRST: COND may carry an address `local.tee` the branch values read, so it must run
3172
+ // before them (matching scalar order — COND evaluates before the taken branch).
3173
+ const maskSet = ['local.set', mtmp, [cmpSimd, ca, cb]]
3174
+ const thenSeq = liftArm(thenN, tv); if (ctx.fail) return null
3175
+ const elseSeq = liftArm(elseN, ev); if (ctx.fail) return null
2633
3176
  return ['block', ['result', 'v128'],
2634
- ['local.set', mtmp, [cmpSimd, ca, cb]],
2635
- ['v128.bitselect', x, y, ['local.get', mtmp]]]
3177
+ maskSet, ...thenSeq, ...elseSeq,
3178
+ ['v128.bitselect', ['local.get', tv], ['local.get', ev], ['local.get', mtmp]]]
2636
3179
  }
2637
3180
 
2638
3181
  // Lane-pure op?
@@ -2657,6 +3200,64 @@ function liftExprV(expr, ctx) {
2657
3200
  return [entry.simd, a, b]
2658
3201
  }
2659
3202
 
3203
+ // Transcendental call → its bit-exact f64x2 mirror (pow/exp/log/exp2/sin/cos/atan2/hypot).
3204
+ // f64 lane only (the *2/_v helpers are f64x2). SIMD_PINNED keeps the scalar target alive
3205
+ // through watr's single-caller inlining so the `call` node still exists at lift time.
3206
+ // `$__to_num` is a numeric coercion jz wraps around a helper param it couldn't prove is f64
3207
+ // (e.g. `decode(src[j])`), boxing it via `i64.reinterpret_f64` first. In the lane every value
3208
+ // is already a genuine finite f64, so `__to_num(reinterpret_i64(x)) == x` — lift straight
3209
+ // through, peeling the box round-trip.
3210
+ if (op === 'call' && expr[1] === '$__to_num' && expr.length === 3) {
3211
+ let arg = expr[2]
3212
+ if (isArr(arg) && arg[0] === 'i64.reinterpret_f64' && arg.length === 2) arg = arg[1]
3213
+ return liftExprV(arg, ctx)
3214
+ }
3215
+
3216
+ // `$math.pow(x, c)` with a CONSTANT non-integer exponent, found only during vectorization
3217
+ // (`ctx.constLocals`) — e.g. spow's `av ** nv` after pure-function inlining substitutes the
3218
+ // literal (module/math.js's own `emitPow` const-exponent fold never reaches here: its constant
3219
+ // exponent is known at EMIT time, so it already lowers straight to the scalar const-exponent
3220
+ // path, picked up by the generic PPC_CALL2 lift below). `optimize.crPow` picks the lowering,
3221
+ // mirroring emitPow's own default/crPow split (see the authoritative comment above emitPow):
3222
+ // OFF (DEFAULT): truly-2-wide `exp_v(c · log_v(x))` — bit-identical to the scalar `$math.pow`
3223
+ // for EVERY x when c is non-integer (verified: negative base → NaN and x=0 → 0/∞ both carry
3224
+ // through log/exp identically; only the integer fast path differs, and it is excluded).
3225
+ // ON: the truly-2-wide correctly-rounded `$math.pow_fold_v` (module/math.js) — the SIMD twin
3226
+ // of the scalar `$math.pow_fold` (c needs no pre-split; the shared kernel twoProd-splits
3227
+ // both multiply operands internally — see its own comment). Bit-identical to the scalar
3228
+ // `$math.pow_fold` for every x — same function, called on both lanes.
3229
+ if (op === 'call' && ctx.laneType === 'f64' && expr[1] === '$math.pow' && expr.length === 4) {
3230
+ const ex = expr[3]
3231
+ let c = null
3232
+ if (isArr(ex) && ex[0] === 'f64.const') c = +ex[1]
3233
+ else if (isArr(ex) && ex[0] === 'local.get' && ctx.constLocals && ctx.constLocals.has(ex[1])) c = ctx.constLocals.get(ex[1])
3234
+ if (c != null && Number.isFinite(c) && !Number.isInteger(c)) {
3235
+ const base = liftExprV(expr[2], ctx); if (ctx.fail) return null
3236
+ if (_crPow) {
3237
+ return ['call', '$math.pow_fold_v', base, ['f64x2.splat', ['f64.const', c]]]
3238
+ }
3239
+ return ['call', '$math.exp_v', ['f64x2.mul', ['f64x2.splat', ex], ['call', '$math.log_v', base]]]
3240
+ }
3241
+ }
3242
+
3243
+ if (op === 'call' && ctx.laneType === 'f64' && PPC_CALL2[expr[1]]) {
3244
+ const args = []
3245
+ for (let i = 2; i < expr.length; i++) { const a = liftExprV(expr[i], ctx); if (ctx.fail) return null; args.push(a) }
3246
+ return ['call', PPC_CALL2[expr[1]], ...args]
3247
+ }
3248
+
3249
+ // Pure user-function call → inline its body as a value-expr and lift that (handles the callee's
3250
+ // ternaries/compares/pow via the arms above). Depth-guarded against pure→pure recursion.
3251
+ if (op === 'call' && ctx.laneType === 'f64' && ctx.pureFuncMap && ctx.pureFuncMap.has(expr[1]) && ctx.inlineDepth < 8) {
3252
+ const inlined = inlinePureCallExpr(expr, ctx.pureFuncMap, ctx.freshIdRef)
3253
+ if (inlined != null) {
3254
+ ctx.inlineDepth++
3255
+ const v = liftExprV(inlined, ctx)
3256
+ ctx.inlineDepth--
3257
+ return v
3258
+ }
3259
+ }
3260
+
2660
3261
  return liftFail(ctx, `${op}: no lane-pure SIMD mapping for ${ctx.laneType}`)
2661
3262
  }
2662
3263
 
@@ -3598,7 +4199,26 @@ function tryBlurMultiPixel(blockNode, fnLocals, freshIdRef) {
3598
4199
  const rBound = ic[2][1][2]
3599
4200
  if (!(isLocalGet(rBound) || isI32Const(rBound))) return null
3600
4201
 
3601
- // the RGBA store: 4 consecutive i32.store8 at `ab1 + c`, ab1 tee'd in the first.
4202
+ // the RGBA store: 4 i32.store8 at `ab1 + c`, ab1 tee'd in the first. jz's raw pre-watr
4203
+ // emission of `dst[o]=(sr/win)|0` materializes the divide into its own single-use temp
4204
+ // (`tw = sr/win; store(tw)`) — pre-watr propagateSingleUse runs AFTER the vectorizer
4205
+ // (ordered there so it doesn't scramble the dot-pair matcher), so this indirection is
4206
+ // never folded before this recognizer sees it, unlike the old post-watr pipeline where
4207
+ // watr's own copy-prop had already inlined it into the store operand. Resolve that one-hop
4208
+ // indirection here: if a store's value is a bare local.get and the statement immediately
4209
+ // before it is that local's SOLE def in the loop, substitute the def's RHS.
4210
+ const resolvedTemps = new Set()
4211
+ const resolveStoreVal = (idx, val) => {
4212
+ if (!(isArr(val) && val[0] === 'local.get')) return val
4213
+ const t = val[1], prev = loopNode[idx - 1]
4214
+ if (!(isArr(prev) && prev[0] === 'local.set' && prev.length === 3 && prev[1] === t)) return val
4215
+ let uses = 0
4216
+ const count = (n) => { if (!isArr(n)) return; if (n[0] === 'local.get' && n[1] === t) uses++; n.forEach(count) }
4217
+ count(loopNode)
4218
+ if (uses !== 1) return val
4219
+ resolvedTemps.add(t)
4220
+ return prev[2]
4221
+ }
3602
4222
  let storeIdx = -1
3603
4223
  for (let i = innerIdx + 1; i + 3 <= bodyEnd; i++) {
3604
4224
  const s0 = loopNode[i]
@@ -3607,11 +4227,14 @@ function tryBlurMultiPixel(blockNode, fnLocals, freshIdRef) {
3607
4227
  if (storeIdx < 0) return null
3608
4228
  const s0 = loopNode[storeIdx]
3609
4229
  const ab1 = s0[1][1], dstExpr = s0[1][2] // ab1 local, its address expr
3610
- const storeVals = [s0[2]]
4230
+ const storeVals = [resolveStoreVal(storeIdx, s0[2])]
4231
+ let scanIdx = storeIdx + 1
3611
4232
  for (let c = 1; c < 4; c++) {
3612
- const s = loopNode[storeIdx + c]
4233
+ if (isArr(loopNode[scanIdx]) && loopNode[scanIdx][0] === 'local.set') scanIdx++ // skip the next store's div-into-temp
4234
+ const s = loopNode[scanIdx]
3613
4235
  if (!(isArr(s) && s[0] === 'i32.store8' && s[1] === `offset=${c}` && isLocalGet(s[2], ab1))) return null
3614
- storeVals.push(s[3])
4236
+ storeVals.push(resolveStoreVal(scanIdx, s[3]))
4237
+ scanIdx++
3615
4238
  }
3616
4239
  // each store value must read its accumulator (the divided sum)
3617
4240
  for (let c = 0; c < 4; c++) if (!readsVar(storeVals[c], accInits[c])) return null
@@ -3666,7 +4289,10 @@ function tryBlurMultiPixel(blockNode, fnLocals, freshIdRef) {
3666
4289
  const newInnerBlock = innerBlock.map(c => c === innerLoop ? newInnerLoop : c)
3667
4290
  // store epilogue: the o=(row+x)<<2 setup, then ab1, then per pixel j set acc_c to
3668
4291
  // its lane and reuse the scalar store template at byte offset j*4+c.
3669
- const preStore = loopNode.slice(innerIdx + 1, storeIdx) // `o = (row+x)<<2` etc.
4292
+ // `o = (row+x)<<2` etc. — drop resolved-away div-into-temp defs (unused now; storeVals
4293
+ // carries their RHS directly), else a dead `tw15 = sr/win` reading a stale `sr` gets
4294
+ // carried into the 4-pixel loop (harmless — unused — but unnecessary bytes/work).
4295
+ const preStore = loopNode.slice(innerIdx + 1, storeIdx).filter(s => !(isArr(s) && s[0] === 'local.set' && resolvedTemps.has(s[1])))
3670
4296
  const epilogue = [...preStore, ['local.set', ab1, dstExpr]]
3671
4297
  for (let j = 0; j < 4; j++) {
3672
4298
  const vec = j < 2 ? accLo : accHi, base = (j % 2) * 4
@@ -4127,22 +4753,49 @@ function tryDivergentEscapeVectorize(blockNode, fnLocals, freshIdRef) {
4127
4753
  const outcomeVarSetEarly = new Set(midBreaks.flatMap(mb => mb.assigns.map(a => a.name)))
4128
4754
  const carriedInit = new Map() // carried var → its f64.const seed (before the loop)
4129
4755
  const perPxInit = new Map() // c-var → its per-pixel init expr
4756
+ // Pre-watr, jz's raw lowering hasn't run DCE yet — obody[0..innerIdx) may hold pre-loop f64
4757
+ // locals the OLD post-watr recognizer never saw (watr had already deleted them). Two live
4758
+ // shapes beyond direct escape-loop reads (cVars, already populated by the liftable() walk
4759
+ // above): (a) truly dead — a per-pixel grid coord the update ignores (Julia fixed-c: cx/cy
4760
+ // computed but the orbit never reads them — mandelbrot's dual DOES read them); safe to drop.
4761
+ // (b) a carried var's z0 seed reads it INDIRECTLY through one extra local (Julia/Newton
4762
+ // per-pixel z0: x0 = <ramp>; zx = x0) — cVars only sees reads INSIDE the escape loop, so x0
4763
+ // is invisible to it even though it IS the real seed expression. Close that gap with a
4764
+ // needed-set fixpoint before classifying, so x0 promotes to a c-var exactly like a directly
4765
+ // read grid coord — liftCLane already resolves a cVar reference at emit.
4766
+ const epilogue = obody.slice(innerIdx + 1)
4767
+ const preStmts = []
4130
4768
  for (let i = 0; i < innerIdx; i++) {
4131
4769
  const s = obody[i]
4132
4770
  if (!isArr(s) || s[0] !== 'local.set' || s.length !== 3) return null
4133
- const tgt = s[1]
4771
+ preStmts.push({ tgt: s[1], expr: s[2] })
4772
+ }
4773
+ const preTgt = new Set(preStmts.map(p => p.tgt))
4774
+ // A pre-loop local promotes to a c-var only if some OTHER classified site actually reads it —
4775
+ // never one of the reserved roles (carried/temp/itVar/outcome), which already have their own
4776
+ // per-lane home and must keep taking their existing branch below.
4777
+ const promotable = (v) => !carried.has(v) && !temp.has(v) && v !== itVar && !outcomeVarSetEarly.has(v)
4778
+ const needed = new Set(cVars)
4779
+ for (const { tgt, expr } of preStmts) if (carried.has(tgt)) for (const v of preTgt) if (promotable(v) && readsVar(expr, v)) needed.add(v)
4780
+ for (const e of epilogue) for (const v of preTgt) if (promotable(v) && readsVar(e, v)) needed.add(v)
4781
+ for (let changed = true; changed;) {
4782
+ changed = false
4783
+ for (const { tgt, expr } of preStmts) if (needed.has(tgt))
4784
+ for (const v of preTgt) if (v !== tgt && promotable(v) && !needed.has(v) && readsVar(expr, v)) { needed.add(v); changed = true }
4785
+ }
4786
+ for (const { tgt, expr } of preStmts) {
4134
4787
  if (carried.has(tgt)) {
4135
4788
  // z₀ seed: a constant (mandelbrot/burning-ship z₀=0) OR a per-pixel expr (Julia set, where
4136
4789
  // z₀ = the pixel and c is constant — the dual of mandelbrot). liftCLane handles both at emit.
4137
- carriedInit.set(tgt, s[2])
4790
+ carriedInit.set(tgt, expr)
4138
4791
  } else if (temp.has(tgt)) { /* recomputed each iteration — init ignored */ }
4139
- else if (tgt === itVar) { if (constNum(s[2]) !== 0) return null }
4140
- else if (cVars.has(tgt)) perPxInit.set(tgt, s[2])
4792
+ else if (tgt === itVar) { if (constNum(expr) !== 0) return null }
4793
+ else if (needed.has(tgt)) { cVars.add(tgt); perPxInit.set(tgt, expr) }
4141
4794
  else if (outcomeVarSetEarly.has(tgt)) { /* i32 outcome var default init — ignored, handled per-lane */ }
4795
+ else if (!hasSideEffect(expr)) { /* dead per-pixel local — the escape update never reads it, drop */ }
4142
4796
  else return null
4143
4797
  }
4144
4798
  for (const c of carried) if (!carriedInit.has(c)) return null
4145
- const epilogue = obody.slice(innerIdx + 1)
4146
4799
  // The epilogue runs scalar per lane; it may only read carried/it/pixel-IV/invariant
4147
4800
  // values (each statement's reads, before that statement's writes). A read of an
4148
4801
  // inner-loop temp or a per-pixel c-var has no post-loop per-lane value → bail.
@@ -4468,15 +5121,20 @@ const PPC_CALL2 = {
4468
5121
  '$math.sin': '$math.sin2', '$math.cos': '$math.cos2',
4469
5122
  '$math.pow': '$math.pow2', // 2-arg; bit-exact per-lane scalar (cancellation-sensitive — see module/math.js)
4470
5123
  '$math.atan2': '$math.atan2_2', '$math.hypot': '$math.hypot_2', // 2-arg; bit-exact extract/repack
5124
+ '$math.cbrt': '$math.cbrt_v', '$math.fifthroot': '$math.fifthroot_v', // 1-arg; per-lane scalar repack
5125
+ '$math.pow_fold': '$math.pow_fold_v', // 2-arg (x, c); only reachable under optimize.crPow — see module/math.js
4471
5126
  // log/exp/exp2: TRUE f64x2 polys — both lanes one evaluation (≈2×, beats V8 native log). Bit-exact
4472
5127
  // via hot-path-vectorized + scalar-edge-fallback ($math.log_v/exp_v/exp2_v, module/math.js).
4473
5128
  '$math.log': '$math.log_v', '$math.exp': '$math.exp_v', '$math.exp2': '$math.exp2_v',
4474
5129
  }
4475
5130
 
4476
- // Scalar transcendentals the auto-vectorizer rewrites to f64x2 mirrors above. watr's inline
4477
- // passes must NOT dissolve their (single-caller) call nodes before this lift runs jz passes
4478
- // these to watOptimize's `pin` option (the protection policy lives here, not hardcoded in watr).
4479
- export const SIMD_PINNED = Object.keys(PPC_CALL2)
5131
+ // Transcendentals the auto-vectorizer bridges to f64x2 mirrors BOTH the scalar sources (kept
5132
+ // intact in the vectorized loop's scalar tail) AND the f64x2 mirrors themselves (the calls the
5133
+ // SIMD path emits). jz passes this to watOptimize's `pin` option so watr's inliner dissolves
5134
+ // NEITHER: the scalar tail keeps calling `$math.cbrt` and the SIMD body keeps calling
5135
+ // `$math.cbrt_v` (inlining the small per-lane repack mirror would erase the vectorized call the
5136
+ // lift produced). The protection policy lives here in jz, not hardcoded in watr.
5137
+ export const SIMD_PINNED = [...new Set([...Object.keys(PPC_CALL2), ...Object.values(PPC_CALL2)])]
4480
5138
 
4481
5139
  // Per-pixel-color vectorizer. The dual of tryDivergentEscapeVectorize for kernels with NO inner
4482
5140
  // escape loop: an outer pixel loop whose body computes an f64 value from the pixel index (via
@@ -5208,16 +5866,23 @@ function tryConvColumn(blockNode, fnLocals, freshIdRef, enabled) {
5208
5866
 
5209
5867
  // A byte tap operand: convert_i32_{s,u}(i32.load8_{s,u}(addr)). Returns { load, addr, signed }.
5210
5868
  const matchByteLoad = (n) => {
5211
- if (!isArr(n) || (n[0] !== 'f64.convert_i32_s' && n[0] !== 'f64.convert_i32_u') || !isArr(n[1])) return null
5212
- const ld = n[1]
5213
- if (ld[0] !== 'i32.load8_s' && ld[0] !== 'i32.load8_u') return null
5869
+ // Accept the f64 form (convert_i32_{s,u}(load8)) AND the bare i32 load
5870
+ // the emit-level convert-peel narrows int8·int8 to i32.mul(load8, load8),
5871
+ // so the taps arrive unconverted (the better shape: no f64 detour to undo).
5872
+ let ld = null
5873
+ if (isArr(n) && (n[0] === 'f64.convert_i32_s' || n[0] === 'f64.convert_i32_u') && isArr(n[1])) ld = n[1]
5874
+ else if (isArr(n) && (n[0] === 'i32.load8_s' || n[0] === 'i32.load8_u')) ld = n
5875
+ if (!ld || (ld[0] !== 'i32.load8_s' && ld[0] !== 'i32.load8_u')) return null
5214
5876
  const addr = (typeof ld[1] === 'string' && ld[1].startsWith('offset=')) ? ld[2] : ld[1]
5215
5877
  return { load: ld, addr, signed: ld[0] === 'i32.load8_s' }
5216
5878
  }
5217
5879
  const load64 = (ld) => (typeof ld[1] === 'string' && ld[1].startsWith('offset=')) ? ['v128.load64_zero', ld[1], ld[2]] : ['v128.load64_zero', ld[1]]
5218
5880
  // Lift a single product addend `inp·wt` (exactly one side gathers on ox) to an i16x8 of 8 products.
5219
5881
  const liftProduct = (prod) => {
5220
- if (!isArr(prod) || prod[0] !== 'f64.mul') return null
5882
+ // f64.mul(cvt(load), cvt(load)) pre-peel — or f64.convert_i32_s(i32.mul(
5883
+ // load, load)) / bare i32.mul(load, load) — the peeled faithful product.
5884
+ if (isArr(prod) && prod[0] === 'f64.convert_i32_s' && isArr(prod[1]) && prod[1][0] === 'i32.mul') prod = prod[1]
5885
+ if (!isArr(prod) || (prod[0] !== 'f64.mul' && prod[0] !== 'i32.mul')) return null
5221
5886
  const a = matchByteLoad(prod[1]), b = matchByteLoad(prod[2])
5222
5887
  if (!a || !b) return null
5223
5888
  const ag = isGatherAddr(a.addr), bg = isGatherAddr(b.addr)
@@ -5702,6 +6367,183 @@ function tryToneMap(bl, fnLocals, freshIdRef, enabled) {
5702
6367
  return { wrapper, newLocalDecls }
5703
6368
  }
5704
6369
 
6370
+ // ---- Radix-2 butterfly 2-wide lift (tryButterfly) ----
6371
+ //
6372
+ // The Cooley-Tukey inner loop — the one shape the generic lane lift can never take:
6373
+ // a DUAL-IV scaffold (`j++` carries the exit test, `k += STEP` walks the twiddle
6374
+ // table) with an in-place complex-rotation update over four disjoint streams
6375
+ // (re/im × a/b, b = a + HALF, twiddles wre/wim read-only). Lanes j and j+1:
6376
+ // - every a/b access is an ADJACENT pair → one v128.load / v128.store,
6377
+ // - the twiddle pair is strided by STEP → two scalar f64.loads + lane combine
6378
+ // (same load count as two scalar iterations),
6379
+ // - the +/−/× rotation lanes with NO reassociation and NO fusion — each lane
6380
+ // computes the exact scalar sequence, so the result is bit-identical and the
6381
+ // cross-engine checksum contract holds.
6382
+ // LEGALITY. The strip body runs only while j+1 < half, so b = a + half ≥ a + 2:
6383
+ // {a,a+1} and {b,b+1} never overlap — the b-pair stores cannot clobber lane 1's
6384
+ // a-pair loads, and the single im[a] pair load legitimately serves both the
6385
+ // im[b] store and the im[a] writeback (im[a] ∉ {im[b−1], im[b]}). re/im/wre/wim
6386
+ // are distinct base locals under the vectorizer's standing distinct-base
6387
+ // non-aliasing model. HALF/STEP and the four bases must not be written in the
6388
+ // loop (checked); j/k are exactly reproduced for the scalar tail (each strip
6389
+ // iteration consumes two scalar iterations), and the tail IS the original loop,
6390
+ // so an odd half (or half < 2) falls through untouched.
6391
+ function tryButterfly(blockNode, fnLocals, freshIdRef) {
6392
+ if (!isArr(blockNode) || blockNode[0] !== 'block' || typeof blockNode[1] !== 'string') return null
6393
+ const brk = blockNode[1]
6394
+ if (blockNode.length !== 3 || !isArr(blockNode[2]) || blockNode[2][0] !== 'loop') return null
6395
+ const loop = blockNode[2]
6396
+ const lbl = loop[1]
6397
+ if (typeof lbl !== 'string') return null
6398
+ // scaffold: (loop $L (br_if $brk (i32.eqz (i32.lt_s J HALF))) BODY×17 INC 'drop' (br $L))
6399
+ const exit = loop[2]
6400
+ if (!isArr(exit) || exit[0] !== 'br_if' || exit[1] !== brk) return null
6401
+ const ez = exit[2]
6402
+ if (!isArr(ez) || ez[0] !== 'i32.eqz' || !isArr(ez[1]) || ez[1][0] !== 'i32.lt_s') return null
6403
+ const [, jGet, halfGet] = ez[1]
6404
+ if (!isLocalGet(jGet) || !isLocalGet(halfGet)) return null
6405
+ const J = jGet[1], HALF = halfGet[1]
6406
+ const end = loop.length - 1
6407
+ if (!isArr(loop[end]) || loop[end][0] !== 'br' || loop[end][1] !== lbl) return null
6408
+ if (loop[end - 1] !== 'drop') return null
6409
+ // inc: (block (result i32) (drop (i32.sub (local.tee J (i32.add J 1)) 1)) (local.tee K (i32.add K STEP)))
6410
+ const inc = loop[end - 2]
6411
+ if (!isArr(inc) || inc[0] !== 'block' || !isArr(inc[1]) || inc[1][0] !== 'result' || inc.length !== 4) return null
6412
+ const jInc = inc[2], kInc = inc[3]
6413
+ if (!isArr(jInc) || jInc[0] !== 'drop' || !isArr(jInc[1]) || jInc[1][0] !== 'i32.sub') return null
6414
+ const jTee = jInc[1][1]
6415
+ if (!isArr(jTee) || jTee[0] !== 'local.tee' || jTee[1] !== J || !isArr(jTee[2]) || jTee[2][0] !== 'i32.add'
6416
+ || !isLocalGet(jTee[2][1], J) || constNum(jTee[2][2]) !== 1) return null
6417
+ if (!isArr(kInc) || kInc[0] !== 'local.tee' || !isArr(kInc[2]) || kInc[2][0] !== 'i32.add') return null
6418
+ const K = kInc[1]
6419
+ if (typeof K !== 'string' || !isLocalGet(kInc[2][1], K) || !isLocalGet(kInc[2][2])) return null
6420
+ const STEP = kInc[2][2][1]
6421
+ const body = loop.slice(3, end - 2)
6422
+ if (body.length !== 17) return null
6423
+
6424
+ // unification environment over the exact emit shapes
6425
+ const U = {}
6426
+ const bind = (name, v) => U[name] === undefined ? (U[name] = v, true) : U[name] === v
6427
+ const idx8 = (n, base, iv) => isArr(n) && n[0] === 'i32.add'
6428
+ && isLocalGet(n[1]) && bind(base, n[1][1])
6429
+ && isArr(n[2]) && n[2][0] === 'i32.shl' && isLocalGet(n[2][1]) && bind(iv, n[2][1][1])
6430
+ && constNum(n[2][2]) === 3
6431
+ const setF64Load = (st, name, base, iv, ab) => {
6432
+ if (!isArr(st) || st[0] !== 'local.set' || st.length !== 3 || !isArr(st[2]) || st[2][0] !== 'f64.load') return false
6433
+ let addr = st[2][1]
6434
+ if (ab != null) {
6435
+ if (!isArr(addr) || addr[0] !== 'local.tee') return false
6436
+ if (!bind(ab, addr[1])) return false
6437
+ addr = addr[2]
6438
+ }
6439
+ if (!idx8(addr, base, iv)) return false
6440
+ return bind(name, st[1])
6441
+ }
6442
+ const g = (n, name) => isLocalGet(n) && U[name] !== undefined && n[1] === U[name]
6443
+ const mulPair = (n, x, y) => isArr(n) && n[0] === 'f64.mul' && g(n[1], x) && g(n[2], y)
6444
+ const setArith = (st, name, op, mk) => {
6445
+ if (!isArr(st) || st[0] !== 'local.set' || st.length !== 3 || !isArr(st[2]) || st[2][0] !== op) return false
6446
+ if (!mk(st[2])) return false
6447
+ return bind(name, st[1])
6448
+ }
6449
+ // flat pair: (local.set T (op LHS VAL)) ; (f64.store (local.get AB) (local.get T))
6450
+ const storePair = (setSt, stoSt, op, lhs, val2, ab) => {
6451
+ if (!isArr(setSt) || setSt[0] !== 'local.set' || !isArr(setSt[2]) || setSt[2][0] !== op) return false
6452
+ const e = setSt[2]
6453
+ if (!lhs(e[1]) || !g(e[2], val2)) return false
6454
+ if (!isArr(stoSt) || stoSt[0] !== 'f64.store' || !g(stoSt[1], ab) || !isLocalGet(stoSt[2], setSt[1])) return false
6455
+ return true
6456
+ }
6457
+
6458
+ if (!setF64Load(body[0], 'WR', 'WRE', 'K0', null) || U.K0 !== K) return null
6459
+ if (!setF64Load(body[1], 'WI', 'WIM', 'K1', null) || U.K1 !== K) return null
6460
+ { // a = I + j (either order), I ≠ J
6461
+ const st = body[2]
6462
+ if (!isArr(st) || st[0] !== 'local.set' || !isArr(st[2]) || st[2][0] !== 'i32.add') return null
6463
+ const [, l, r] = st[2]
6464
+ if (isLocalGet(l) && isLocalGet(r, J) && l[1] !== J) U.I = l[1]
6465
+ else if (isLocalGet(r) && isLocalGet(l, J) && r[1] !== J) U.I = r[1]
6466
+ else return null
6467
+ U.A = st[1]
6468
+ }
6469
+ { // b = a + half (either order)
6470
+ const st = body[3]
6471
+ if (!isArr(st) || st[0] !== 'local.set' || !isArr(st[2]) || st[2][0] !== 'i32.add') return null
6472
+ const [, l, r] = st[2]
6473
+ if (!((isLocalGet(l, U.A) && isLocalGet(r, HALF)) || (isLocalGet(r, U.A) && isLocalGet(l, HALF)))) return null
6474
+ U.B = st[1]
6475
+ }
6476
+ if (!setF64Load(body[4], 'XR', 'RE', 'B0', 'AB4') || U.B0 !== U.B) return null
6477
+ if (!setF64Load(body[5], 'XI', 'IM', 'B1', 'AB5') || U.B1 !== U.B) return null
6478
+ if (!setArith(body[6], 'TR', 'f64.sub', e => mulPair(e[1], 'WR', 'XR') && mulPair(e[2], 'WI', 'XI'))) return null
6479
+ if (!setArith(body[7], 'TI', 'f64.add', e => mulPair(e[1], 'WR', 'XI') && mulPair(e[2], 'WI', 'XR'))) return null
6480
+ if (!setF64Load(body[8], 'C0', 'RE', 'A0', 'AB6') || U.A0 !== U.A) return null
6481
+ const c0lhs = (n) => g(n, 'C0')
6482
+ const ab7teeLhs = (n) => { // (f64.load (local.tee AB7 (im + a<<3)))
6483
+ if (!isArr(n) || n[0] !== 'f64.load' || !isArr(n[1]) || n[1][0] !== 'local.tee') return false
6484
+ if (!bind('AB7', n[1][1])) return false
6485
+ return idx8(n[1][2], 'IM', 'A2') && U.A2 === U.A
6486
+ }
6487
+ const ab7getLhs = (n) => isArr(n) && n[0] === 'f64.load' && g(n[1], 'AB7')
6488
+ if (!storePair(body[9], body[10], 'f64.sub', c0lhs, 'TR', 'AB4')) return null
6489
+ if (!storePair(body[11], body[12], 'f64.sub', ab7teeLhs, 'TI', 'AB5')) return null
6490
+ if (!storePair(body[13], body[14], 'f64.add', c0lhs, 'TR', 'AB6')) return null
6491
+ if (!storePair(body[15], body[16], 'f64.add', ab7getLhs, 'TI', 'AB7')) return null
6492
+ // loop-invariance: the four bases, HALF/STEP and the outer offset I are never written in the body
6493
+ const invariants = new Set([U.RE, U.IM, U.WRE, U.WIM, HALF, STEP, U.I].filter(x => typeof x === 'string'))
6494
+ if (invariants.size !== 7) return null
6495
+ let clobbered = false
6496
+ const wscan = (n) => { if (clobbered || !isArr(n)) return
6497
+ if ((n[0] === 'local.set' || n[0] === 'local.tee') && invariants.has(n[1])) { clobbered = true; return }
6498
+ for (let i = 1; i < n.length; i++) wscan(n[i]) }
6499
+ for (const st of body) wscan(st)
6500
+ if (clobbered) return null
6501
+ if (new Set([U.RE, U.IM, U.WRE, U.WIM]).size !== 4) return null
6502
+
6503
+ const id = freshIdRef.next++
6504
+ const nm = (t) => `$__bf${id}_${t}`
6505
+ const L = (x) => ['local.get', x]
6506
+ const addr = (base, iv) => ['i32.add', L(base), ['i32.shl', L(iv), ['i32.const', 3]]]
6507
+ const twiddle = (base) => ['f64x2.replace_lane', 1,
6508
+ ['f64x2.splat', ['f64.load', addr(base, K)]],
6509
+ ['f64.load', ['i32.add', L(base), ['i32.shl', ['i32.add', L(K), L(STEP)], ['i32.const', 3]]]]]
6510
+ const wrv = nm('wrv'), wiv = nm('wiv'), xrv = nm('xrv'), xiv = nm('xiv')
6511
+ const trv = nm('trv'), tiv = nm('tiv'), c0v = nm('c0v'), iav = nm('iav')
6512
+ const av = nm('a'), bv = nm('b'), vl = nm('L'), strip = nm('go')
6513
+ const newLocalDecls = [
6514
+ ['local', wrv, 'v128'], ['local', wiv, 'v128'], ['local', xrv, 'v128'], ['local', xiv, 'v128'],
6515
+ ['local', trv, 'v128'], ['local', tiv, 'v128'], ['local', c0v, 'v128'], ['local', iav, 'v128'],
6516
+ ['local', av, 'i32'], ['local', bv, 'i32'],
6517
+ ]
6518
+ const stripGuard = () => ['i32.lt_s', ['i32.add', L(J), ['i32.const', 1]], L(HALF)]
6519
+ const vbody = [
6520
+ ['local.set', wrv, twiddle(U.WRE)],
6521
+ ['local.set', wiv, twiddle(U.WIM)],
6522
+ ['local.set', av, ['i32.add', L(U.I), L(J)]],
6523
+ ['local.set', bv, ['i32.add', L(av), L(HALF)]],
6524
+ ['local.set', xrv, ['v128.load', addr(U.RE, bv)]],
6525
+ ['local.set', xiv, ['v128.load', addr(U.IM, bv)]],
6526
+ ['local.set', trv, ['f64x2.sub', ['f64x2.mul', L(wrv), L(xrv)], ['f64x2.mul', L(wiv), L(xiv)]]],
6527
+ ['local.set', tiv, ['f64x2.add', ['f64x2.mul', L(wrv), L(xiv)], ['f64x2.mul', L(wiv), L(xrv)]]],
6528
+ ['local.set', c0v, ['v128.load', addr(U.RE, av)]],
6529
+ ['v128.store', addr(U.RE, bv), ['f64x2.sub', L(c0v), L(trv)]],
6530
+ ['local.set', iav, ['v128.load', addr(U.IM, av)]],
6531
+ ['v128.store', addr(U.IM, bv), ['f64x2.sub', L(iav), L(tiv)]],
6532
+ ['v128.store', addr(U.RE, av), ['f64x2.add', L(c0v), L(trv)]],
6533
+ ['v128.store', addr(U.IM, av), ['f64x2.add', L(iav), L(tiv)]],
6534
+ ['local.set', J, ['i32.add', L(J), ['i32.const', 2]]],
6535
+ ['local.set', K, ['i32.add', L(K), ['i32.add', L(STEP), L(STEP)]]],
6536
+ ['br_if', vl, stripGuard()],
6537
+ ]
6538
+ const wrapper = ['block',
6539
+ ['block', strip,
6540
+ ['br_if', strip, ['i32.eqz', stripGuard()]],
6541
+ ['loop', vl, ...vbody]],
6542
+ blockNode] // the ORIGINAL loop is the scalar tail: 0..1 leftover iterations, or everything when half < 2
6543
+ return { wrapper, newLocalDecls }
6544
+ }
6545
+
6546
+
5705
6547
  // ---- Pass entry ------------------------------------------------------------
5706
6548
 
5707
6549
  /**
@@ -5713,13 +6555,27 @@ function tryToneMap(bl, fnLocals, freshIdRef, enabled) {
5713
6555
  // multiAcc, relaxedFma, blurMP, whyNot, stencil, outerStrip, pureFuncMap, toneMap.
5714
6556
  export function vectorizeLaneLocal(fn, opts = {}) {
5715
6557
  const { multiAcc = false, relaxedFma = false, blurMP = true, whyNot = false,
5716
- stencil = false, outerStrip = false, pureFuncMap = null, toneMap = false, slp = false } = opts
6558
+ stencil = false, outerStrip = false, pureFuncMap = null, toneMap = false, slp = false, crPow = false } = opts
5717
6559
  if (!isArr(fn) || fn[0] !== 'func') return
5718
6560
  const bodyStart = findBodyStart(fn)
5719
6561
  if (bodyStart < 0) return
5720
6562
  const fnName = typeof fn[1] === 'string' ? fn[1] : '(anon)'
5721
6563
  let whyNotN = 0
5722
6564
 
6565
+ // Normalize jz's per-statement `block` grouping into flat statement lists ONCE, up front —
6566
+ // the recognizers below (both the scaffold consumers and the raw-node matchers like ramp-map,
6567
+ // stencil, per-pixel) were tuned on watr's flattened shape. Pre-watr, jz wraps each source
6568
+ // statement group in a transparent block; without this every loop body would arrive as a
6569
+ // single opaque `block` node and no lift would fire. Walking `fn` itself also flattens a
6570
+ // top-level body block (decls never match — they aren't blocks).
6571
+ normalizeTransparentBlocks(fn)
6572
+ // Canonicalize the raw arithmetic identities watr would fold (chiefly `i<<0` byte addresses),
6573
+ // so the address/value matchers read dataflow, not jz's un-folded emission.
6574
+ for (let i = bodyStart; i < fn.length; i++) fn[i] = foldVecIdentities(fn[i])
6575
+ // Canonicalize the `if COND (then (br L))` break idiom to `br_if L COND` (watr's brif shape),
6576
+ // so the loop-scan recognizers see the branch form they were tuned against.
6577
+ canonicalizeIfBr(fn)
6578
+
5723
6579
  // Build local-name → wasm-type map.
5724
6580
  const fnLocals = new Map()
5725
6581
  for (let i = 2; i < bodyStart; i++) {
@@ -5731,8 +6587,31 @@ export function vectorizeLaneLocal(fn, opts = {}) {
5731
6587
  }
5732
6588
  }
5733
6589
 
6590
+ // Loop-invariant constant locals — hoistConstantPool's `$…_pg` pool (`set $L (f64.const C)`,
6591
+ // written exactly once). name → numeric value, used to resolve a constant `pow` exponent that
6592
+ // reached the loop as a pooled local rather than a literal.
6593
+ const constLocals = new Map()
6594
+ {
6595
+ const setCount = new Map()
6596
+ const walkC = (n) => {
6597
+ if (!isArr(n)) return
6598
+ if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string' && n.length === 3) {
6599
+ setCount.set(n[1], (setCount.get(n[1]) || 0) + 1)
6600
+ if (isArr(n[2]) && n[2][0] === 'f64.const') constLocals.set(n[1], +n[2][1])
6601
+ }
6602
+ for (let i = 1; i < n.length; i++) walkC(n[i])
6603
+ }
6604
+ for (let i = bodyStart; i < fn.length; i++) walkC(fn[i])
6605
+ for (const [k, c] of setCount) if (c !== 1) constLocals.delete(k) // multiply-written → not invariant
6606
+ }
6607
+
5734
6608
  const freshIdRef = { next: 0 }
5735
6609
  const newLocalDeclsAll = []
6610
+ // Whether a REAL SIMD lift happened (as opposed to the scalar tryStrengthReduceIV
6611
+ // fallback below, which also populates newLocalDeclsAll with plain i32 locals) —
6612
+ // the caller pins the function's $name/$name$exp boundary wrapper on this, so a
6613
+ // false positive here would needlessly block watr's inlineOnce on a non-SIMD fn.
6614
+ let simdFired = false
5736
6615
 
5737
6616
  // Hoist loop-invariant partial products out of unrolled dot reductions (rust/LLVM's
5738
6617
  // mat4 prologue trick). Reassociates the float sum, so tied to the relaxedFma tier;
@@ -5743,6 +6622,7 @@ export function vectorizeLaneLocal(fn, opts = {}) {
5743
6622
  // SLP within-iteration store pairs. Sound only with no aliasing typed-array view
5744
6623
  // in the module (else a shifted view could reorder-hazard the packed read/write).
5745
6624
  if (slp && !ctx.features.typedView) slpStorePairsIn(fn, fnLocals, freshIdRef, newLocalDeclsAll, slpGetCounts(fn))
6625
+ if (newLocalDeclsAll.length) simdFired = true
5746
6626
 
5747
6627
  // Walk body recursively. Process inner-most matches first (post-order)
5748
6628
  // so we don't try to vectorize an outer loop whose inner is the lane-local one.
@@ -5769,7 +6649,7 @@ export function vectorizeLaneLocal(fn, opts = {}) {
5769
6649
  const bl = matchBlockLoop(node, { allowPreamble: true, allowInlinedLi: true })
5770
6650
  let r = tryDivergentEscapeVectorize(node, fnLocals, freshIdRef)
5771
6651
  ?? tryMemCopyFill(bl, fnLocals, freshIdRef)
5772
- ?? tryVectorize(bl, fnLocals, freshIdRef)
6652
+ ?? tryVectorize(bl, fnLocals, freshIdRef, pureFuncMap, constLocals)
5773
6653
  ?? tryReduceVectorize(bl, fnLocals, freshIdRef, multiAcc)
5774
6654
  ?? tryMapReduceVectorize(bl, fnLocals, freshIdRef)
5775
6655
  ?? tryStencil(node, fnLocals, freshIdRef, stencil)
@@ -5782,6 +6662,7 @@ export function vectorizeLaneLocal(fn, opts = {}) {
5782
6662
  ?? tryIteratedReduce(node, fnLocals, freshIdRef, outerStrip)
5783
6663
  ?? tryConvColumn(node, fnLocals, freshIdRef, outerStrip)
5784
6664
  ?? tryToneMap(bl, fnLocals, freshIdRef, toneMap)
6665
+ ?? tryButterfly(node, fnLocals, freshIdRef)
5785
6666
  // --why-not-simd: a canonical loop-shaped candidate that no SIMD pass took.
5786
6667
  // Reported BEFORE the scalar strength-reduce fallback (which fires on most
5787
6668
  // affine loops and would otherwise mask "didn't vectorize"). Diagnostic only.
@@ -5791,19 +6672,42 @@ export function vectorizeLaneLocal(fn, opts = {}) {
5791
6672
  `${fnName}: loop #${whyNotN} not vectorized — ${_whyNotReason || 'no SIMD-liftable shape (loop-carried dependency, non-affine address, or unsupported control flow)'}`,
5792
6673
  { fn: `${fnName}#${whyNotN}` })
5793
6674
  }
5794
- // Scalar IV strength-reduction is a non-SIMD fallback; it may still fire.
5795
- r = r ?? tryStrengthReduceIV(bl, fnLocals, freshIdRef)
6675
+ if (r) simdFired = true // one of the real SIMD recognizers above matched
5796
6676
  if (r) {
6677
+ // Mark the consumed subtree: the wrapper REUSES these nodes (scalar tail, and the
6678
+ // lane splats alias the original load nodes), so a deferred strength-reduce must
6679
+ // never rewrite inside them — it would mutate the lifted lanes through the alias.
6680
+ const mark = (n) => { if (isArr(n)) { srConsumed.add(n); for (let i = 0; i < n.length; i++) mark(n[i]) } }
6681
+ mark(node)
5797
6682
  parent[idx] = r.wrapper
5798
6683
  newLocalDeclsAll.push(...r.newLocalDecls)
6684
+ } else if (bl) {
6685
+ // Scalar IV strength-reduction is a non-SIMD fallback — DEFERRED to after the
6686
+ // whole walk. Applied eagerly here (post-order = innermost first) it rewrites an
6687
+ // inner reduction loop into its wrapper before the ENCLOSING loop's outer
6688
+ // recognizers (outer-strip / iterated-reduce / conv-column / tone-map) ever run,
6689
+ // and they bail on the non-canonical inner shape — metaballs' pixel loop lost its
6690
+ // whole f64x2 outer-strip to an eager strength-reduce of the blob loop.
6691
+ deferredSR.push([node, parent, idx, bl])
5799
6692
  }
5800
6693
  }
5801
6694
  }
6695
+ const deferredSR = [], srConsumed = new Set()
5802
6696
  _whyNotActive = whyNot
5803
6697
  _relaxF32 = relaxedFma
6698
+ _crPow = crPow
5804
6699
  for (let i = bodyStart; i < fn.length; i++) walk(fn, i)
5805
6700
  _whyNotActive = false
5806
6701
  _relaxF32 = false
6702
+ _crPow = false
6703
+ // Apply the deferred scalar fallback innermost-first (push order), skipping candidates
6704
+ // inside any SIMD-consumed subtree (see the mark above), plus a same-slot check for
6705
+ // wrappers that replaced the candidate node itself.
6706
+ for (const [node, parent, idx, bl] of deferredSR) {
6707
+ if (srConsumed.has(node) || parent[idx] !== node) continue
6708
+ const r = tryStrengthReduceIV(bl, fnLocals, freshIdRef)
6709
+ if (r) { parent[idx] = r.wrapper; newLocalDeclsAll.push(...r.newLocalDecls) }
6710
+ }
5807
6711
 
5808
6712
  if (newLocalDeclsAll.length) {
5809
6713
  // Sibling loops (and the straight-line dot pass) can each lift the SAME source
@@ -5815,4 +6719,5 @@ export function vectorizeLaneLocal(fn, opts = {}) {
5815
6719
  // so keep one decl per name (all dups are the identical `['local', name, 'v128']`).
5816
6720
  fn.splice(bodyStart, 0, ...new Map(newLocalDeclsAll.map(d => [d[1], d])).values())
5817
6721
  }
6722
+ return simdFired
5818
6723
  }