jz 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -33
- package/bench/README.md +176 -73
- package/bench/bench.svg +58 -71
- package/cli.js +12 -5
- package/dist/interop.js +1 -1
- package/dist/jz.js +1156 -984
- package/index.js +40 -9
- package/interop.js +193 -138
- package/layout.js +29 -18
- package/module/array.js +29 -73
- package/module/collection.js +83 -25
- package/module/console.js +1 -1
- package/module/core.js +161 -15
- package/module/json.js +3 -3
- package/module/math.js +26 -3
- package/module/number.js +247 -13
- package/module/object.js +11 -5
- package/module/regex.js +8 -7
- package/module/string.js +162 -156
- package/module/typedarray.js +169 -105
- package/package.json +7 -3
- package/src/abi/string.js +29 -29
- package/src/ast.js +19 -2
- package/src/compile/analyze.js +64 -2
- package/src/compile/cse-load.js +200 -0
- package/src/compile/emit-assign.js +73 -14
- package/src/compile/emit.js +253 -23
- package/src/compile/index.js +198 -61
- package/src/compile/loop-divmod.js +12 -58
- package/src/compile/loop-model.js +91 -0
- package/src/compile/loop-square.js +102 -0
- package/src/compile/narrow.js +169 -32
- package/src/compile/peel-stencil.js +18 -64
- package/src/compile/plan/common.js +29 -0
- package/src/compile/plan/index.js +4 -1
- package/src/compile/plan/inline.js +176 -21
- package/src/compile/plan/literals.js +93 -19
- package/src/ctx.js +51 -12
- package/src/helper-counters.js +137 -0
- package/src/ir.js +102 -13
- package/src/kind-traits.js +7 -3
- package/src/kind.js +14 -1
- package/src/op-policy.js +5 -2
- package/src/ops.js +119 -0
- package/src/optimize/index.js +960 -136
- package/src/optimize/recurse.js +182 -0
- package/src/optimize/vectorize.js +1292 -144
- package/src/prepare/index.js +11 -7
- package/src/reps.js +4 -1
- package/src/type.js +53 -45
- package/src/wat/assemble.js +92 -9
- package/src/widen.js +21 -0
- package/src/wat/optimize.js +0 -3938
package/src/optimize/index.js
CHANGED
|
@@ -29,13 +29,15 @@
|
|
|
29
29
|
|
|
30
30
|
import { LAYOUT, ctx } from '../ctx.js'
|
|
31
31
|
import { VAL } from '../reps.js'
|
|
32
|
-
import { findBodyStart, buildRefcount, nextLocalId, verifyFn } from '../ir.js'
|
|
32
|
+
import { findBodyStart, buildRefcount, nextLocalId, verifyFn, isPureIR, f64Range, I32_MIN, I32_MAX } from '../ir.js'
|
|
33
33
|
|
|
34
34
|
// Debug-mode IR structural check (JZ_DEBUG_INVARIANTS=1). Zero production cost.
|
|
35
35
|
const DBG_IR = typeof process !== 'undefined' && process.env?.JZ_DEBUG_INVARIANTS === '1'
|
|
36
|
-
import { T } from '../ast.js'
|
|
36
|
+
import { T, isLeaf, stableKey } from '../ast.js'
|
|
37
37
|
import { vectorizeLaneLocal } from './vectorize.js'
|
|
38
|
-
import {
|
|
38
|
+
import { recursionUnroll } from './recurse.js'
|
|
39
|
+
export { SIMD_PINNED } from './vectorize.js'
|
|
40
|
+
import { nanPrefixHex, atomNanHex, STR_INTERN_BIT, ptrBits, i64Hex, PTR, TYPED_ELEM_CODE, TYPED_ELEM_VIEW_FLAG } from '../../layout.js'
|
|
39
41
|
|
|
40
42
|
const MEMOP = /^[fi](32|64)\.(load|store)(\d+(_[su])?)?$/
|
|
41
43
|
const NAN_BITS = nanPrefixHex()
|
|
@@ -66,7 +68,7 @@ const FALSE_BITS = atomNanHex(4)
|
|
|
66
68
|
* 'speed' — full nested unroll + lane vectorization (= level 3).
|
|
67
69
|
* The default (level 2) has no string name — omit `optimize` or pass `2`.
|
|
68
70
|
*
|
|
69
|
-
* # Two-layer contract (this file vs
|
|
71
|
+
* # Two-layer contract (this file vs watr/optimize)
|
|
70
72
|
* Both layers walk the same S-expression IR; the boundary is KNOWLEDGE, not
|
|
71
73
|
* representation:
|
|
72
74
|
* - THIS layer owns every pass that needs jz semantics — NaN-box layout
|
|
@@ -74,7 +76,7 @@ const FALSE_BITS = atomNanHex(4)
|
|
|
74
76
|
* func nodes (cseScalarLoad's cseLoadBases whitelist), loop shapes as emit
|
|
75
77
|
* produces them (narrowLoopBound, hoistInvariantLoop, vectorizeLaneLocal),
|
|
76
78
|
* and ctx-derived module facts (hoistGlobalPtrOffset's typed-global set).
|
|
77
|
-
* -
|
|
79
|
+
* - watr/optimize owns generic structural rewrites — const folding,
|
|
78
80
|
* copy-prop, branch/DCE/vacuum, dedupe, treeshake, and inlineOnce. One
|
|
79
81
|
* deliberate exception: guardRefine lives there despite NaN-box knowledge,
|
|
80
82
|
* because the dead tag-dispatch shapes it folds only EXIST after that
|
|
@@ -98,8 +100,10 @@ export const PASS_NAMES = [
|
|
|
98
100
|
'hoistGlobalPtrOffset', // stable typed GLOBALS: __ptr_offset resolve → once per function (post-watr, module-level)
|
|
99
101
|
'fusedRewrite', // peephole + ptr-helper inline + memarg fold
|
|
100
102
|
'hoistAddrBase',
|
|
103
|
+
'boolConvertToSelect', // f64 ± (cond?1:0) → branchless select (kills i32↔f64 domain cross on recurrences)
|
|
101
104
|
'cseScalarLoad',
|
|
102
105
|
'csePureExpr',
|
|
106
|
+
'unswitchTypedParamLoop', // Float64Array param loop-unswitch → base-hoisted f64.load/store fast path (vectorizes)
|
|
103
107
|
'dropDeadZeroInit',
|
|
104
108
|
'deadStoreElim',
|
|
105
109
|
'promoteGlobals', // read-only global.get → local for multi-read globals
|
|
@@ -113,6 +117,7 @@ export const PASS_NAMES = [
|
|
|
113
117
|
'smallConstForUnroll',
|
|
114
118
|
'nestedSmallConstForUnroll',
|
|
115
119
|
'vectorizeLaneLocal', // SIMD-128 lift for lane-pure typed-array loops
|
|
120
|
+
'recursionUnroll', // inline a single non-tail self-call to depth N (tree-recursion call-overhead)
|
|
116
121
|
'arenaRewind', // per-call heap rewind for no-arg scalar allocator kernels
|
|
117
122
|
'treeshake',
|
|
118
123
|
'jsstring', // boundary opt-in: flip exported string params to externref
|
|
@@ -127,7 +132,9 @@ const LEVEL_PRESETS = Object.freeze({
|
|
|
127
132
|
// force 'light' mode here (inline / inlineOnce / coalesce all off) to dodge the
|
|
128
133
|
// W1a/W1b miscompiles; watr 4.6.9 fixes both, and the L2 default now runs the full
|
|
129
134
|
// watr pipeline. `inline` stays off by watr's own default — opt-in only.
|
|
130
|
-
|
|
135
|
+
// boolConvertToSelect off at the default level: it's a latency-for-size trade (adds a
|
|
136
|
+
// const + op per site) that only pays off on serial recurrences — speed-tier only.
|
|
137
|
+
2: Object.freeze({ ...ALL_ON, nestedSmallConstForUnroll: 'auto', boolConvertToSelect: false, recursionUnroll: false }),
|
|
131
138
|
// L3/'speed' trades a bit of heap headroom for fewer __arr_grow / __hash growth
|
|
132
139
|
// cycles. arrayMinCap=16 means `[]` and `new Array()` skip the first two doublings
|
|
133
140
|
// (0→2→4→8→16); hashSmallInitCap=8 keeps per-object __dyn_props at the same load
|
|
@@ -143,12 +150,15 @@ const LEVEL_PRESETS = Object.freeze({
|
|
|
143
150
|
// closures). Inline `f64.const` is the minimal lowering: V8 CSEs identical
|
|
144
151
|
// constants for free. Measured −3% on jessie parse for +14% binary — exactly
|
|
145
152
|
// the size↔speed trade 'speed' exists to make.
|
|
146
|
-
3: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8, reduceUnroll: true, relaxedSimd: true, inlineFns: true }),
|
|
153
|
+
3: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8, reduceUnroll: true, relaxedSimd: true, inlineFns: true, rotateLoops: true }),
|
|
147
154
|
// 'size' tightens scalar/unroll caps; 'speed' = level 3. There is no 'balanced'
|
|
148
155
|
// preset — it was a pure synonym for the default level 2 (omit `optimize` or pass 2).
|
|
149
156
|
size: Object.freeze({
|
|
150
157
|
...ALL_ON,
|
|
151
158
|
smallConstForUnroll: false, nestedSmallConstForUnroll: false, vectorizeLaneLocal: false, splitCharScan: false,
|
|
159
|
+
recursionUnroll: false, // body tripling is a size regression — speed-only
|
|
160
|
+
|
|
161
|
+
boolConvertToSelect: false, // adds a const + op per site — speed-only latency trade
|
|
152
162
|
devirtIndirect: false, // guards + duplicated args grow bytes — speed-only trade
|
|
153
163
|
internStrings: false, // the intern index costs ~16 B per eligible literal — speed-only trade
|
|
154
164
|
scalarTypedLoopUnroll: 4, scalarTypedNestedUnroll: 8, scalarTypedArrayLen: 8,
|
|
@@ -163,7 +173,7 @@ const LEVEL_PRESETS = Object.freeze({
|
|
|
163
173
|
// (The stencil + outer-strip vectorizers are NOT level-gated here: they're bit-exact pure wins
|
|
164
174
|
// like the base lane vectorizer, so they run whenever it does — default-on at level 2+ via
|
|
165
175
|
// `cfg.experimentalStencil !== false` at the call site, not a speed-only size/precision trade.)
|
|
166
|
-
speed: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8, reduceUnroll: true, relaxedSimd: true, inlineFns: true }),
|
|
176
|
+
speed: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8, reduceUnroll: true, relaxedSimd: true, inlineFns: true, rotateLoops: true }),
|
|
167
177
|
})
|
|
168
178
|
|
|
169
179
|
/**
|
|
@@ -201,6 +211,11 @@ export function resolveOptimize(opt) {
|
|
|
201
211
|
}
|
|
202
212
|
// Preserve non-pass tuning keys (e.g. plan.js thresholds)
|
|
203
213
|
for (const k of Object.keys(opt)) if (!PASS_NAMES.includes(k)) out[k] = opt[k]
|
|
214
|
+
// noSimd: suppress EVERY jz-emitted v128 — both the lane vectorizer AND the SLP
|
|
215
|
+
// store-pair packer. First-class here so `{ level:'speed', noSimd:true }` is a TRUE
|
|
216
|
+
// scalar baseline whether passed nested or via the top-level opts.noSimd flag; the
|
|
217
|
+
// SIMD-vs-scalar correctness oracles depend on it actually disabling SLP.
|
|
218
|
+
if (out.noSimd) { out.vectorizeLaneLocal = false; out.experimentalSlp = false }
|
|
204
219
|
return out
|
|
205
220
|
}
|
|
206
221
|
return { ...ALL_ON }
|
|
@@ -399,12 +414,39 @@ function regionTrackCSE(fn, { matchSite, localPrefix, localType }) {
|
|
|
399
414
|
* Must run AFTER fusedRewrite — relies on shl-distribution + assoc-lift +
|
|
400
415
|
* foldMemargOffsets having normalized the base shape.
|
|
401
416
|
*/
|
|
417
|
+
// Pure i32 ops whose value is a function of locals/consts alone — no memory read,
|
|
418
|
+
// no call, no global. A subscript expression built only from these is invariant
|
|
419
|
+
// between two sites as long as none of its local deps is rewritten between them,
|
|
420
|
+
// so CSE-ing the WHOLE address (base + shl(idx)) is value-safe — even when `idx`
|
|
421
|
+
// is a compound stencil offset like `(i32.sub (i32.add idx W) 1)` for `arr[idx+W-1]`.
|
|
422
|
+
const PURE_I32_ADDR_OPS = new Set([
|
|
423
|
+
'i32.add', 'i32.sub', 'i32.mul', 'i32.shl', 'i32.shr_s', 'i32.shr_u',
|
|
424
|
+
'i32.and', 'i32.or', 'i32.xor', 'i32.wrap_i64',
|
|
425
|
+
])
|
|
426
|
+
// Serialize a pure-i32 subscript to a stable key, accumulating its local deps.
|
|
427
|
+
// Returns null if any leaf isn't a local.get / i32.const / pure-i32 op (a load,
|
|
428
|
+
// call, or global.get could change between sites — not CSE-safe by local tracking).
|
|
429
|
+
function pureI32AddrKey(node, deps) {
|
|
430
|
+
if (!Array.isArray(node)) return null
|
|
431
|
+
const op = node[0]
|
|
432
|
+
if (op === 'local.get' && typeof node[1] === 'string') { deps.add(node[1]); return `$${node[1]}` }
|
|
433
|
+
if (op === 'i32.const' && typeof node[1] === 'number') return `#${node[1]}`
|
|
434
|
+
if (!PURE_I32_ADDR_OPS.has(op)) return null
|
|
435
|
+
let key = op + '('
|
|
436
|
+
for (let i = 1; i < node.length; i++) {
|
|
437
|
+
const sub = pureI32AddrKey(node[i], deps)
|
|
438
|
+
if (sub == null) return null
|
|
439
|
+
key += sub + ','
|
|
440
|
+
}
|
|
441
|
+
return key + ')'
|
|
442
|
+
}
|
|
443
|
+
|
|
402
444
|
export function hoistAddrBase(fn) {
|
|
403
445
|
return regionTrackCSE(fn, {
|
|
404
446
|
matchSite(node) {
|
|
405
447
|
if (node[0] !== 'i32.add' || node.length !== 3) return null
|
|
406
448
|
const a = node[1], b = node[2]
|
|
407
|
-
// Two orderings: (add (get A) (shl
|
|
449
|
+
// Two orderings: (add (get A) (shl IDX (const K))) or (add (shl …) (get A))
|
|
408
450
|
let baseGet, shlNode
|
|
409
451
|
if (Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' &&
|
|
410
452
|
Array.isArray(b) && b[0] === 'i32.shl' && b.length === 3) {
|
|
@@ -414,15 +456,88 @@ export function hoistAddrBase(fn) {
|
|
|
414
456
|
baseGet = b; shlNode = a
|
|
415
457
|
} else return null
|
|
416
458
|
const idx = shlNode[1], shamt = shlNode[2]
|
|
417
|
-
if (!Array.isArray(idx) || idx[0] !== 'local.get' || typeof idx[1] !== 'string') return null
|
|
418
459
|
if (!Array.isArray(shamt) || shamt[0] !== 'i32.const' || typeof shamt[1] !== 'number') return null
|
|
419
|
-
|
|
460
|
+
// idx may be a plain `local.get` (the original biquad case) or any compound
|
|
461
|
+
// pure-i32 subscript (stencil neighbour `arr[idx+W-1]`); both CSE the same way.
|
|
462
|
+
const deps = new Set([baseGet[1]])
|
|
463
|
+
const idxKey = pureI32AddrKey(idx, deps)
|
|
464
|
+
if (idxKey == null) return null
|
|
465
|
+
return { key: `${baseGet[1]}|${idxKey}|${shamt[1]}`, deps: [...deps] }
|
|
420
466
|
},
|
|
421
467
|
localPrefix: 'ab',
|
|
422
468
|
localType: 'i32',
|
|
423
469
|
})
|
|
424
470
|
}
|
|
425
471
|
|
|
472
|
+
// wasm comparison ops — each yields an i32 that is exactly 0 or 1.
|
|
473
|
+
const BOOL_RESULT_OPS = new Set([
|
|
474
|
+
'i32.eqz', 'i64.eqz',
|
|
475
|
+
'i32.eq', 'i32.ne', 'i32.lt_s', 'i32.lt_u', 'i32.gt_s', 'i32.gt_u', 'i32.le_s', 'i32.le_u', 'i32.ge_s', 'i32.ge_u',
|
|
476
|
+
'i64.eq', 'i64.ne', 'i64.lt_s', 'i64.lt_u', 'i64.gt_s', 'i64.gt_u', 'i64.le_s', 'i64.le_u', 'i64.ge_s', 'i64.ge_u',
|
|
477
|
+
'f32.eq', 'f32.ne', 'f32.lt', 'f32.gt', 'f32.le', 'f32.ge',
|
|
478
|
+
'f64.eq', 'f64.ne', 'f64.lt', 'f64.gt', 'f64.le', 'f64.ge',
|
|
479
|
+
])
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* `f64 ± (cond ? 1 : 0)` → branchless f64 `select`, killing the i32↔f64 domain cross.
|
|
483
|
+
*
|
|
484
|
+
* `err = old - (old >= t)` and friends compile to `f64.sub(X, f64.convert_i32_s(cmp))`.
|
|
485
|
+
* The convert (cvtsi2sd) round-trips the comparison result out of a GPR back into an
|
|
486
|
+
* XMM register — a domain-crossing op that sits ON the value's def chain. In the
|
|
487
|
+
* per-pixel error-diffusion sweeps (Floyd–Steinberg / Atkinson / JJN) and scalar IIR
|
|
488
|
+
* thresholds this chain is the loop-carried critical path, so that one cross roughly
|
|
489
|
+
* doubles the per-step latency (V8 keeps the JS threshold entirely in the FP domain).
|
|
490
|
+
*
|
|
491
|
+
* `X - (B?1:0) ≡ (B ? X-1 : X) ≡ select(X-1, X, B)` (likewise `+` → `select(X+1, X, B)`),
|
|
492
|
+
* which never leaves the f64 domain. `select` evaluates BOTH arms, so X must be a
|
|
493
|
+
* side-effect-free duplicable leaf (a `local.get`/const); B is the i32 condition,
|
|
494
|
+
* evaluated once (exactly as the convert did). A pure win on latency-bound recurrences;
|
|
495
|
+
* speed-gated (it adds a const + an arithmetic op — a size↔speed trade) — off at 'size'.
|
|
496
|
+
*/
|
|
497
|
+
function boolConvertToSelect(fn) {
|
|
498
|
+
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
499
|
+
// Pass 1 — a local whose SOLE definition is a comparison carries a value ∈ {0,1};
|
|
500
|
+
// `err = old - on` (on reused by putBW) reaches us as `convert(local.get $on)`.
|
|
501
|
+
// A param is EXCLUDED even if reassigned once by a comparison: its incoming arg is
|
|
502
|
+
// unconstrained, so a read before the reassignment isn't 0/1. (A plain local read
|
|
503
|
+
// before its def is safe — wasm zero-inits it to 0 = false, which select preserves.)
|
|
504
|
+
const params = new Set()
|
|
505
|
+
for (let i = 2; i < fn.length; i++) if (Array.isArray(fn[i]) && fn[i][0] === 'param') params.add(fn[i][1])
|
|
506
|
+
const defCount = new Map(), defIsCmp = new Map()
|
|
507
|
+
const scan = (n) => {
|
|
508
|
+
if (!Array.isArray(n)) return
|
|
509
|
+
if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') {
|
|
510
|
+
defCount.set(n[1], (defCount.get(n[1]) || 0) + 1)
|
|
511
|
+
const cmp = Array.isArray(n[2]) && BOOL_RESULT_OPS.has(n[2][0])
|
|
512
|
+
defIsCmp.set(n[1], (defIsCmp.has(n[1]) ? defIsCmp.get(n[1]) : true) && cmp)
|
|
513
|
+
}
|
|
514
|
+
for (let i = 1; i < n.length; i++) scan(n[i])
|
|
515
|
+
}
|
|
516
|
+
scan(fn)
|
|
517
|
+
const boolLocals = new Set()
|
|
518
|
+
for (const [name, c] of defCount) if (c === 1 && defIsCmp.get(name) && !params.has(name)) boolLocals.add(name)
|
|
519
|
+
|
|
520
|
+
const isBool01 = (n) => Array.isArray(n) &&
|
|
521
|
+
(BOOL_RESULT_OPS.has(n[0]) || (n[0] === 'local.get' && boolLocals.has(n[1])))
|
|
522
|
+
const dup = (n) => Array.isArray(n) ? n.map(dup) : n
|
|
523
|
+
|
|
524
|
+
// Pass 2 — bottom-up rewrite.
|
|
525
|
+
const rewrite = (n) => {
|
|
526
|
+
if (!Array.isArray(n)) return n
|
|
527
|
+
for (let i = 1; i < n.length; i++) n[i] = rewrite(n[i])
|
|
528
|
+
if ((n[0] === 'f64.sub' || n[0] === 'f64.add') && n.length === 3) {
|
|
529
|
+
const conv = (m) => Array.isArray(m) && (m[0] === 'f64.convert_i32_s' || m[0] === 'f64.convert_i32_u') && isBool01(m[1])
|
|
530
|
+
// `X - bool`, `X + bool`, or (add is commutative) `bool + X`.
|
|
531
|
+
let X = null, B = null
|
|
532
|
+
if (conv(n[2]) && isLeaf(n[1])) { X = n[1]; B = n[2][1] }
|
|
533
|
+
else if (n[0] === 'f64.add' && conv(n[1]) && isLeaf(n[2])) { X = n[2]; B = n[1][1] }
|
|
534
|
+
if (X) return ['select', [n[0], dup(X), ['f64.const', 1]], dup(X), B]
|
|
535
|
+
}
|
|
536
|
+
return n
|
|
537
|
+
}
|
|
538
|
+
rewrite(fn)
|
|
539
|
+
}
|
|
540
|
+
|
|
426
541
|
/**
|
|
427
542
|
* Hoist `(call $__ptr_offset (local.get $X))` to a function-entry snapshot
|
|
428
543
|
* when X is an f64-NaN-boxed parameter that's never reassigned and only ever
|
|
@@ -442,6 +557,20 @@ export function hoistAddrBase(fn) {
|
|
|
442
557
|
// loop-invariant __jss_length in the same loop condition CAN hoist).
|
|
443
558
|
const SAFE_OFFSET_CALLS = new Set(['$__ptr_offset', '$__ptr_type', '$__ptr_aux', '$__len', '$__jss_length', '$__jss_charCodeAt'])
|
|
444
559
|
|
|
560
|
+
// wasm comparison-op mantissas (the part after the `.`): they yield i32 regardless of
|
|
561
|
+
// operand width (i64.eq, f64.lt, i32.ge_s, …). `eq`/`ne` are sign-agnostic; the ordered
|
|
562
|
+
// compares carry `_s`/`_u` for the integer types and none for f64. Used by resultType to
|
|
563
|
+
// type a hoisted subtree by its root op. A Set membership test, NOT a regex
|
|
564
|
+
// (`/^(eq|ne|lt|gt|le|ge)(_[su])?$/`): the regex mis-anchored under self-host −O2 — `nearest`
|
|
565
|
+
// (the f64.nearest mantissa, from Math.round) starts with `ne`, and the embedded −O2 build
|
|
566
|
+
// matched it as a comparison → the LICM hoist local got typed i32, so `local.set $__li
|
|
567
|
+
// (f64.nearest …)` emitted invalid wasm (f64 into i32) only in the kernel. Explicit string
|
|
568
|
+
// membership is both self-host-robust and cheaper in this LICM-hot path.
|
|
569
|
+
const CMP_MANTISSA = new Set([
|
|
570
|
+
'eqz', 'eq', 'ne', 'lt', 'gt', 'le', 'ge',
|
|
571
|
+
'lt_s', 'lt_u', 'gt_s', 'gt_u', 'le_s', 'le_u', 'ge_s', 'ge_u',
|
|
572
|
+
])
|
|
573
|
+
|
|
445
574
|
// Calls that don't modify EXISTING heap memory: they may allocate (bump the heap
|
|
446
575
|
// pointer) or do tag dispatch, but they never write to an address a hoisted
|
|
447
576
|
// __typed_idx/__str_idx element read would revisit. Their presence must not
|
|
@@ -610,6 +739,136 @@ const PURE_LICM_OPS = new Set([
|
|
|
610
739
|
'f64.promote_f32', 'f32.demote_f64', 'select',
|
|
611
740
|
])
|
|
612
741
|
|
|
742
|
+
// Resolve a load/store address back to the single typed-array PARAM it derives from — through
|
|
743
|
+
// `local.get`, the arithmetic in PURE_LICM_OPS, and single-def snap locals ($__li/$__ab) — or
|
|
744
|
+
// null if not exactly one / unprovable (a multi-def or unknown local in the address). Built once
|
|
745
|
+
// per function over the proven-distinct `distinctParams` set; the alias substrate both LICM
|
|
746
|
+
// passes query to hoist a read-only input load across a distinct-buffer store (raytrace's spheres
|
|
747
|
+
// vs framebuffer — the alias-analysis LICM rust/clang get for free).
|
|
748
|
+
function buildBaseParamOf(fn, bodyStart, distinctParams) {
|
|
749
|
+
if (!distinctParams) return () => null
|
|
750
|
+
const paramNames = new Set()
|
|
751
|
+
for (let i = 2; i < bodyStart; i++)
|
|
752
|
+
if (Array.isArray(fn[i]) && fn[i][0] === 'param' && typeof fn[i][1] === 'string') paramNames.add(fn[i][1])
|
|
753
|
+
const singleDef = new Map(), defCount = new Map()
|
|
754
|
+
const scanDefs = (n) => {
|
|
755
|
+
if (!Array.isArray(n)) return
|
|
756
|
+
if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') {
|
|
757
|
+
defCount.set(n[1], (defCount.get(n[1]) || 0) + 1); singleDef.set(n[1], n[2]); scanDefs(n[2]); return
|
|
758
|
+
}
|
|
759
|
+
for (let i = 1; i < n.length; i++) scanDefs(n[i])
|
|
760
|
+
}
|
|
761
|
+
for (let i = bodyStart; i < fn.length; i++) scanDefs(fn[i])
|
|
762
|
+
for (const [k, c] of defCount) if (c > 1) singleDef.delete(k) // multi-def → can't trust the resolution
|
|
763
|
+
return (addr) => {
|
|
764
|
+
const found = new Set(); const seen = new Set(); let bad = false
|
|
765
|
+
const walk = (n) => {
|
|
766
|
+
if (bad || !Array.isArray(n)) return
|
|
767
|
+
if (n[0] === 'local.get' && typeof n[1] === 'string') {
|
|
768
|
+
if (paramNames.has(n[1])) found.add(n[1])
|
|
769
|
+
else if (singleDef.has(n[1]) && !seen.has(n[1])) { seen.add(n[1]); walk(singleDef.get(n[1])) }
|
|
770
|
+
else bad = true // a written/unknown local in the address → base unprovable
|
|
771
|
+
return
|
|
772
|
+
}
|
|
773
|
+
for (let i = 1; i < n.length; i++) walk(n[i])
|
|
774
|
+
}
|
|
775
|
+
walk(addr)
|
|
776
|
+
return !bad && found.size === 1 ? [...found][0] : null
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// Per-loop invariance/purity analysis — the single proven predicate both LICM passes share.
|
|
781
|
+
// Scans the loop into an effect summary (locals/globals it writes, cells/buffers it stores to,
|
|
782
|
+
// whether it has any call / unsafe call / direct store / v128 op), then closes `pureGiven(node,
|
|
783
|
+
// bound)` over it: true iff `node` is side-effect-free AND loop-invariant, given that the locals
|
|
784
|
+
// in `bound` are private to the candidate (a `local.get` of a bound local reads the in-subtree
|
|
785
|
+
// teed invariant; a free `local.get` must be unwritten by the loop). Memory leaves are admitted
|
|
786
|
+
// only under the summary: a `$__cell_`/distinct-param load iff no aliasing store + no call; a
|
|
787
|
+
// SAFE_OFFSET/READONLY_MEM call iff no unsafe call (+ no direct store for heap reads).
|
|
788
|
+
function loopInvariance(loopNode, { distinctParams, baseParamOf }) {
|
|
789
|
+
const locals = new Set(), globals = new Set(), storedCells = new Set(), storedBases = new Set()
|
|
790
|
+
let hasUnsafeCall = false, hasAnyCall = false, hasDirectStore = false, hasV128 = false
|
|
791
|
+
const scan = (node) => {
|
|
792
|
+
if (!Array.isArray(node)) return
|
|
793
|
+
const op = node[0]
|
|
794
|
+
// A vectorized loop (lane/v128 ops) is already register-tight and hand-tuned;
|
|
795
|
+
// extra scalar hoisting there only adds spill pressure — keep it conservative.
|
|
796
|
+
if (op.startsWith('v128.') || /^[if]\d+x\d+\./.test(op)) hasV128 = true
|
|
797
|
+
if (op === 'local.set' || op === 'local.tee') { if (typeof node[1] === 'string') locals.add(node[1]); for (let i = 2; i < node.length; i++) scan(node[i]); return }
|
|
798
|
+
if (op === 'global.set') { if (typeof node[1] === 'string') globals.add(node[1]); for (let i = 2; i < node.length; i++) scan(node[i]); return }
|
|
799
|
+
if (op === 'call') { hasAnyCall = true; if (!SAFE_OFFSET_CALLS.has(node[1]) && !READONLY_MEM_CALLS.has(node[1]) && !NON_MUTATING_CALLS.has(node[1])) hasUnsafeCall = true; for (let i = 2; i < node.length; i++) scan(node[i]); return }
|
|
800
|
+
if (op === 'call_ref' || op === 'call_indirect') { hasAnyCall = hasUnsafeCall = true; for (let i = 1; i < node.length; i++) scan(node[i]); return }
|
|
801
|
+
if ((op === 'f64.store' || op === 'i32.store') && node.length >= 3) {
|
|
802
|
+
hasDirectStore = true
|
|
803
|
+
const a = node[1]
|
|
804
|
+
if (Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' && a[1].startsWith(CELL_PREFIX)) storedCells.add(a[1])
|
|
805
|
+
if (distinctParams) { const sb = baseParamOf(a); if (sb) storedBases.add(sb) } // alias: which buffers this loop writes
|
|
806
|
+
}
|
|
807
|
+
for (let i = 1; i < node.length; i++) scan(node[i])
|
|
808
|
+
}
|
|
809
|
+
for (let i = 1; i < loopNode.length; i++) scan(loopNode[i])
|
|
810
|
+
|
|
811
|
+
const pureGiven = (node, bound) => {
|
|
812
|
+
if (!Array.isArray(node)) return true // bare operand string/number
|
|
813
|
+
const op = node[0]
|
|
814
|
+
if (op === 'i32.const' || op === 'i64.const' || op === 'f64.const' || op === 'f32.const') return true
|
|
815
|
+
if (op === 'local.get') return typeof node[1] === 'string' && (bound.has(node[1]) || !locals.has(node[1]))
|
|
816
|
+
// A global is invariant only if not set directly AND no call in the loop —
|
|
817
|
+
// any callee may mutate it (no interprocedural effect analysis). (Locals are
|
|
818
|
+
// frame-private, so calls can't touch them; only direct local.set matters.)
|
|
819
|
+
if (op === 'global.get') return typeof node[1] === 'string' && !globals.has(node[1]) && !hasAnyCall
|
|
820
|
+
if (op === 'local.tee') {
|
|
821
|
+
if (typeof node[1] !== 'string') return false
|
|
822
|
+
// The operand is evaluated BEFORE the tee writes $X, so a `local.get $X` inside
|
|
823
|
+
// it reads the loop-carried (previous-iteration) value, not the teed one. Drop
|
|
824
|
+
// $X from `bound` for the operand: `local.tee $X (… $X …)` is a loop recurrence
|
|
825
|
+
// (X = f(X) — e.g. the `while ((nn = nn >>> 1))` induction), NOT invariant.
|
|
826
|
+
const inner = bound.has(node[1]) ? new Set([...bound].filter(b => b !== node[1])) : bound
|
|
827
|
+
return pureGiven(node[2], inner)
|
|
828
|
+
}
|
|
829
|
+
if ((op === 'f64.load' || op === 'i32.load') && node.length === 2) {
|
|
830
|
+
const a = node[1]
|
|
831
|
+
if (Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' && a[1].startsWith(CELL_PREFIX)
|
|
832
|
+
&& !hasAnyCall && !storedCells.has(a[1]) && (bound.has(a[1]) || !locals.has(a[1]))) return true
|
|
833
|
+
// Alias-analysis LICM: a load from a typed-array param PROVEN distinct from every buffer
|
|
834
|
+
// this loop writes (base ∉ storedBases) is loop-invariant when its address is invariant —
|
|
835
|
+
// even across the loop's stores, because they can't alias it. This is what lets rust/clang
|
|
836
|
+
// hoist read-only input arrays out of a write loop (raytrace's spheres vs the framebuffer).
|
|
837
|
+
// `pureGiven(a, bound)` proves the address itself invariant (base param unwritten + invariant
|
|
838
|
+
// offset); the calls guard rules out callee memory mutation.
|
|
839
|
+
if (distinctParams && !hasAnyCall) {
|
|
840
|
+
const base = baseParamOf(a)
|
|
841
|
+
if (base && distinctParams.has(base) && !storedBases.has(base) && pureGiven(a, bound)) return true
|
|
842
|
+
}
|
|
843
|
+
return false
|
|
844
|
+
}
|
|
845
|
+
if (op === 'call') {
|
|
846
|
+
if (SAFE_OFFSET_CALLS.has(node[1]))
|
|
847
|
+
return !hasUnsafeCall && node.slice(2).every(c => pureGiven(c, bound))
|
|
848
|
+
// Read-only heap reads: additionally require no direct store (alias-safe).
|
|
849
|
+
if (READONLY_MEM_CALLS.has(node[1]))
|
|
850
|
+
return !hasUnsafeCall && !hasDirectStore && node.slice(2).every(c => pureGiven(c, bound))
|
|
851
|
+
return false
|
|
852
|
+
}
|
|
853
|
+
// A value-producing `if` whose condition and both arms are pure is itself
|
|
854
|
+
// pure — the tag-dispatch idiom `(if (result f64) tag-check (then read-A)
|
|
855
|
+
// (else read-B))` that wraps __typed_idx/__str_idx element access.
|
|
856
|
+
if (op === 'if') {
|
|
857
|
+
for (let i = 1; i < node.length; i++) {
|
|
858
|
+
const c = node[i]
|
|
859
|
+
if (!Array.isArray(c)) continue
|
|
860
|
+
if (c[0] === 'result') continue
|
|
861
|
+
if (c[0] === 'then' || c[0] === 'else') { if (!c.slice(1).every(x => pureGiven(x, bound))) return false }
|
|
862
|
+
else if (!pureGiven(c, bound)) return false // the condition
|
|
863
|
+
}
|
|
864
|
+
return true
|
|
865
|
+
}
|
|
866
|
+
if (PURE_LICM_OPS.has(op)) return node.slice(1).every(c => pureGiven(c, bound))
|
|
867
|
+
return false
|
|
868
|
+
}
|
|
869
|
+
return { pureGiven, locals, globals, storedCells, storedBases, hasUnsafeCall, hasAnyCall, hasDirectStore, hasV128 }
|
|
870
|
+
}
|
|
871
|
+
|
|
613
872
|
/**
|
|
614
873
|
* Unified loop-invariant code motion. One principle replaces the three former
|
|
615
874
|
* pattern hoists (ToInt32 / __ptr_offset / cell-load): a MAXIMAL pure subtree
|
|
@@ -630,6 +889,160 @@ const PURE_LICM_OPS = new Set([
|
|
|
630
889
|
* watr's shared CSE subtrees, snaps spliced before the loop, decls at bodyStart.
|
|
631
890
|
* Idempotent: re-running sees only `(local.get $__liN)` and finds nothing to do.
|
|
632
891
|
*/
|
|
892
|
+
// SSA-split loop-private straight-line multi-def scratch so the LICM below can hoist
|
|
893
|
+
// the invariant versions. jz's unroller MERGES each unrolled iteration's `const x`
|
|
894
|
+
// into one multi-def local (e.g. raytrace's sphere loop unrolls 8× sharing $ox/$c),
|
|
895
|
+
// which the LICM cannot hoist — so the per-sphere invariant `c_i = sx_i²+sy_i²+sz_i²
|
|
896
|
+
// −sr_i²` recomputes every pixel instead of once (the 1.24× rust-wasm gap; rust/LLVM
|
|
897
|
+
// keeps them as distinct SSA values and hoists each). Renaming each def to its own
|
|
898
|
+
// version makes them single-def → hoistInvariantLoop lifts the loop-invariant ones.
|
|
899
|
+
//
|
|
900
|
+
// BIT-EXACT: pure renaming + invariant code motion — the same value computed fewer
|
|
901
|
+
// times, no reassociation. Gated to loops with NO v128, so it never disturbs a
|
|
902
|
+
// vectorized loop (whose unrolled shared names the lane/dot vectorizer relies on).
|
|
903
|
+
//
|
|
904
|
+
// SOUND only for a local that, within the loop body, (a) is referenced NOWHERE else in
|
|
905
|
+
// the function (loop-local lifetime — else a post-loop read of the merged name breaks),
|
|
906
|
+
// (b) has every occurrence STRAIGHT-LINE (never under a nested if/block/loop, so a
|
|
907
|
+
// linear walk assigns each use its unique dominating def), (c) is first accessed by a
|
|
908
|
+
// WRITE (no value carried across the back-edge), (d) is only ever `local.set` (never
|
|
909
|
+
// `local.tee`/conditionally defined). Each condition rejects a class that would miscompile.
|
|
910
|
+
export function splitLoopPrivateScratch(fn) {
|
|
911
|
+
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
912
|
+
const bodyStart = findBodyStart(fn)
|
|
913
|
+
if (bodyStart < 0) return
|
|
914
|
+
const SCALAR = new Set(['i32', 'i64', 'f64', 'f32'])
|
|
915
|
+
const localTypes = new Map()
|
|
916
|
+
for (let i = 2; i < bodyStart; i++) {
|
|
917
|
+
const c = fn[i]
|
|
918
|
+
if (Array.isArray(c) && (c[0] === 'param' || c[0] === 'local') && typeof c[1] === 'string') localTypes.set(c[1], c[2])
|
|
919
|
+
}
|
|
920
|
+
// Whole-function reference count per local (to verify a candidate is loop-local).
|
|
921
|
+
const fnRefs = new Map()
|
|
922
|
+
const countRefs = (n) => {
|
|
923
|
+
if (!Array.isArray(n)) return
|
|
924
|
+
if ((n[0] === 'local.get' || n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string')
|
|
925
|
+
fnRefs.set(n[1], (fnRefs.get(n[1]) || 0) + 1)
|
|
926
|
+
for (let i = 1; i < n.length; i++) countRefs(n[i])
|
|
927
|
+
}
|
|
928
|
+
for (let i = bodyStart; i < fn.length; i++) countRefs(fn[i])
|
|
929
|
+
// Same proven alias substrate hoistInvariantLoop uses (re-attached after watOptimize, so it
|
|
930
|
+
// survives into this 'post' pass) — lets pureGiven prove a read-only input-array load distinct
|
|
931
|
+
// from the loop's output store, the SOUND replacement for the old address-local-disjointness
|
|
932
|
+
// heuristic (which assumed two loads/stores in different locals never alias — false in general).
|
|
933
|
+
const distinctParams = fn.distinctParams || null
|
|
934
|
+
const baseParamOf = buildBaseParamOf(fn, bodyStart, distinctParams)
|
|
935
|
+
|
|
936
|
+
const hasV128 = (n) => {
|
|
937
|
+
let f = false
|
|
938
|
+
const w = (x) => { if (f || !Array.isArray(x)) return; const o = x[0]; if (typeof o === 'string' && (o.startsWith('v128') || /x(2|4|8|16)\b/.test(o) || o.includes('x2.') || o.includes('x4.') || o.includes('x8.') || o.includes('x16.'))) { f = true; return } for (let i = 1; i < x.length; i++) w(x[i]) }
|
|
939
|
+
w(n); return f
|
|
940
|
+
}
|
|
941
|
+
let minted = 0
|
|
942
|
+
const newDecls = []
|
|
943
|
+
|
|
944
|
+
const processLoop = (loop, parent, idx) => {
|
|
945
|
+
if (loop[0] !== 'loop' || hasV128(loop)) return
|
|
946
|
+
// Candidate names: locals set somewhere directly in the loop's statement list.
|
|
947
|
+
const seen = new Set()
|
|
948
|
+
for (let i = 2; i < loop.length; i++) {
|
|
949
|
+
const s = loop[i]
|
|
950
|
+
if (Array.isArray(s) && s[0] === 'local.set' && typeof s[1] === 'string') seen.add(s[1])
|
|
951
|
+
}
|
|
952
|
+
// Stage 1 — collect SAFE candidates (loop-local, straight-line, first-write, set-only,
|
|
953
|
+
// ≥2 defs) and record each one's def RHS list for the invariance fixpoint.
|
|
954
|
+
const cand = new Map() // name → { defs: [rhs…] }
|
|
955
|
+
for (const name of seen) {
|
|
956
|
+
if (!SCALAR.has(localTypes.get(name))) continue
|
|
957
|
+
let inLoop = 0
|
|
958
|
+
const cnt = (n) => { if (!Array.isArray(n)) return; if ((n[0] === 'local.get' || n[0] === 'local.set' || n[0] === 'local.tee') && n[1] === name) inLoop++; for (let i = 1; i < n.length; i++) cnt(n[i]) }
|
|
959
|
+
cnt(loop)
|
|
960
|
+
if (inLoop !== (fnRefs.get(name) || 0)) continue
|
|
961
|
+
let safe = true, first = null, defs = []
|
|
962
|
+
const scan = (n, depth) => {
|
|
963
|
+
if (!safe || !Array.isArray(n)) return
|
|
964
|
+
const op = n[0]
|
|
965
|
+
if (op === 'local.tee' && n[1] === name) { safe = false; return }
|
|
966
|
+
if (op === 'local.set' && n[1] === name) {
|
|
967
|
+
if (depth > 0) { safe = false; return }
|
|
968
|
+
if (first === null) first = 'w'
|
|
969
|
+
defs.push(n[2])
|
|
970
|
+
scan(n[2], depth)
|
|
971
|
+
return
|
|
972
|
+
}
|
|
973
|
+
if (op === 'local.get' && n[1] === name) {
|
|
974
|
+
if (depth > 0) { safe = false; return }
|
|
975
|
+
if (first === null) first = 'r'
|
|
976
|
+
return
|
|
977
|
+
}
|
|
978
|
+
const ctrl = op === 'if' || op === 'then' || op === 'else' || op === 'block' || op === 'loop'
|
|
979
|
+
for (let i = 1; i < n.length; i++) scan(n[i], depth + (ctrl ? 1 : 0))
|
|
980
|
+
}
|
|
981
|
+
for (let i = 2; i < loop.length; i++) scan(loop[i], 0)
|
|
982
|
+
if (safe && first === 'w' && defs.length >= 2) cand.set(name, defs)
|
|
983
|
+
}
|
|
984
|
+
if (!cand.size) return
|
|
985
|
+
// Stage 2 — invariance fixpoint over the SHARED proven predicate. `pureGiven(def, hoistable)`
|
|
986
|
+
// decides loop-invariance with hoistInvariantLoop's exact model: a `$__cell_`/distinct-param
|
|
987
|
+
// read-only load is invariant across the loop's stores (sound alias analysis), a global is
|
|
988
|
+
// invariant only without a loop write or call, and the `bound` set (here `hoistable`) carries
|
|
989
|
+
// the cascade — a def reading an already-split sibling is invariant once that sibling moves out
|
|
990
|
+
// (c = ox²+… invariant only after ox hoists). `motionSafe` adds the one extra obligation a
|
|
991
|
+
// whole-assignment MOTION needs beyond value-invariance: no `local.tee` writing a local read
|
|
992
|
+
// elsewhere (pureGiven already rejects set/store/global.set/unsafe-call). This replaces the old
|
|
993
|
+
// address-local-disjointness load test, which was unsound in general (two distinct locals can
|
|
994
|
+
// hold the same address) and only worked by luck on the bench shapes.
|
|
995
|
+
const { pureGiven } = loopInvariance(loop, { distinctParams, baseParamOf })
|
|
996
|
+
const motionSafe = (n) => { if (!Array.isArray(n)) return true; if (n[0] === 'local.tee') return false; for (let i = 1; i < n.length; i++) if (!motionSafe(n[i])) return false; return true }
|
|
997
|
+
const hoistable = new Set()
|
|
998
|
+
let changed = true
|
|
999
|
+
while (changed) {
|
|
1000
|
+
changed = false
|
|
1001
|
+
for (const [name, defs] of cand) {
|
|
1002
|
+
if (hoistable.has(name)) continue
|
|
1003
|
+
if (defs.every(d => motionSafe(d) && pureGiven(d, hoistable))) { hoistable.add(name); changed = true }
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
// Stage 3 — one linear pass over the loop body: each hoistable def is RENAMED to a
|
|
1007
|
+
// fresh version and MOVED OUT of the loop (before it), in source order so the cascade's
|
|
1008
|
+
// data deps stay intact (c = ox²+… emitted after ox). The cheap arithmetic AND the load
|
|
1009
|
+
// both leave the loop; gets stay, rebound to the moved version. (hoistInvariantLoop only
|
|
1010
|
+
// snapshots expensive subexprs, not whole invariant assignments — so we do the motion.)
|
|
1011
|
+
const curOf = new Map()
|
|
1012
|
+
const rewriteGets = (n) => {
|
|
1013
|
+
if (!Array.isArray(n)) return n
|
|
1014
|
+
if (n[0] === 'local.get' && curOf.has(n[1])) return ['local.get', curOf.get(n[1])]
|
|
1015
|
+
return n.map((c, i) => i === 0 ? c : rewriteGets(c))
|
|
1016
|
+
}
|
|
1017
|
+
const hoisted = []
|
|
1018
|
+
const kept = loop.slice(0, 2) // 'loop' + label
|
|
1019
|
+
for (let i = 2; i < loop.length; i++) {
|
|
1020
|
+
const s = loop[i]
|
|
1021
|
+
if (Array.isArray(s) && s[0] === 'local.set' && hoistable.has(s[1])) {
|
|
1022
|
+
const name = s[1], ty = localTypes.get(name)
|
|
1023
|
+
const nv = `$${name.replace(/^\$/, '')}__sr${minted++}`
|
|
1024
|
+
newDecls.push(['local', nv, ty]); localTypes.set(nv, ty)
|
|
1025
|
+
hoisted.push(['local.set', nv, rewriteGets(s[2])])
|
|
1026
|
+
curOf.set(name, nv)
|
|
1027
|
+
} else {
|
|
1028
|
+
kept.push(rewriteGets(s))
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
loop.length = 0
|
|
1032
|
+
for (const x of kept) loop.push(x)
|
|
1033
|
+
parent.splice(idx, 0, ...hoisted)
|
|
1034
|
+
}
|
|
1035
|
+
const walk = (parent, idx) => {
|
|
1036
|
+
const n = parent[idx]
|
|
1037
|
+
if (!Array.isArray(n)) return
|
|
1038
|
+
// Recurse first so an inner loop's hoists land before we process the outer loop.
|
|
1039
|
+
for (let i = 1; i < n.length; i++) walk(n, i)
|
|
1040
|
+
if (n[0] === 'loop') processLoop(n, parent, idx)
|
|
1041
|
+
}
|
|
1042
|
+
for (let i = bodyStart; i < fn.length; i++) walk(fn, i)
|
|
1043
|
+
if (newDecls.length) fn.splice(bodyStart, 0, ...newDecls)
|
|
1044
|
+
}
|
|
1045
|
+
|
|
633
1046
|
export function hoistInvariantLoop(fn) {
|
|
634
1047
|
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
635
1048
|
const bodyStart = findBodyStart(fn)
|
|
@@ -683,7 +1096,7 @@ export function hoistInvariantLoop(fn) {
|
|
|
683
1096
|
// Comparisons and `eqz` yield i32 regardless of operand type (i64.eq, f64.lt,
|
|
684
1097
|
// i64.eqz, …) — so the operand-type prefix would mistype them. Catch first.
|
|
685
1098
|
const m = op.slice(dot + 1)
|
|
686
|
-
if (
|
|
1099
|
+
if (CMP_MANTISSA.has(m)) return 'i32'
|
|
687
1100
|
const p = op.slice(0, dot)
|
|
688
1101
|
if (p === 'i32' || p === 'i64' || p === 'f64' || p === 'f32') return p
|
|
689
1102
|
return null
|
|
@@ -707,33 +1120,23 @@ export function hoistInvariantLoop(fn) {
|
|
|
707
1120
|
const newLocals = []
|
|
708
1121
|
const refcount = buildRefcount(fn)
|
|
709
1122
|
|
|
1123
|
+
// Alias-analysis substrate for hoisting typed-array PARAM element loads across distinct-base
|
|
1124
|
+
// stores. `distinctParams` (stamped by compile/index.js from the param-distinctness pass) is the
|
|
1125
|
+
// set of typed-array params PROVEN to be mutually-distinct buffers at every call site. To use it,
|
|
1126
|
+
// resolve a load/store address back to the single param it derives from — through `local.get`,
|
|
1127
|
+
// `i32.add/sub`, and single-def snap locals ($__li/$__ab from prior ptr-offset hoisting).
|
|
1128
|
+
const distinctParams = fn.distinctParams || null
|
|
1129
|
+
const baseParamOf = buildBaseParamOf(fn, bodyStart, distinctParams)
|
|
1130
|
+
|
|
710
1131
|
const processLoop = (loopNode, nested) => {
|
|
711
1132
|
// Inner loops first (bottom-up) — an inner hoist creates a local.get the
|
|
712
1133
|
// outer level can hoist further. Children run in a nested context.
|
|
713
1134
|
for (let i = 1; i < loopNode.length; i++)
|
|
714
1135
|
if (Array.isArray(loopNode[i])) processNode(loopNode[i], loopNode, i, true)
|
|
715
1136
|
|
|
716
|
-
// The loop's effect summary
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
const scan = (node) => {
|
|
720
|
-
if (!Array.isArray(node)) return
|
|
721
|
-
const op = node[0]
|
|
722
|
-
// A vectorized loop (lane/v128 ops) is already register-tight and hand-tuned;
|
|
723
|
-
// extra scalar hoisting there only adds spill pressure — keep it conservative.
|
|
724
|
-
if (op.startsWith('v128.') || /^[if]\d+x\d+\./.test(op)) hasV128 = true
|
|
725
|
-
if (op === 'local.set' || op === 'local.tee') { if (typeof node[1] === 'string') locals.add(node[1]); for (let i = 2; i < node.length; i++) scan(node[i]); return }
|
|
726
|
-
if (op === 'global.set') { if (typeof node[1] === 'string') globals.add(node[1]); for (let i = 2; i < node.length; i++) scan(node[i]); return }
|
|
727
|
-
if (op === 'call') { hasAnyCall = true; if (!SAFE_OFFSET_CALLS.has(node[1]) && !READONLY_MEM_CALLS.has(node[1]) && !NON_MUTATING_CALLS.has(node[1])) hasUnsafeCall = true; for (let i = 2; i < node.length; i++) scan(node[i]); return }
|
|
728
|
-
if (op === 'call_ref' || op === 'call_indirect') { hasAnyCall = hasUnsafeCall = true; for (let i = 1; i < node.length; i++) scan(node[i]); return }
|
|
729
|
-
if ((op === 'f64.store' || op === 'i32.store') && node.length >= 3) {
|
|
730
|
-
hasDirectStore = true
|
|
731
|
-
const a = node[1]
|
|
732
|
-
if (Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' && a[1].startsWith(CELL_PREFIX)) storedCells.add(a[1])
|
|
733
|
-
}
|
|
734
|
-
for (let i = 1; i < node.length; i++) scan(node[i])
|
|
735
|
-
}
|
|
736
|
-
for (let i = 1; i < loopNode.length; i++) scan(loopNode[i])
|
|
1137
|
+
// The loop's effect summary + the proven invariance/purity predicate (shared with
|
|
1138
|
+
// splitLoopPrivateScratch — see loopInvariance). `locals` is the loop's whole write-set.
|
|
1139
|
+
const { pureGiven, locals, hasV128 } = loopInvariance(loopNode, { distinctParams, baseParamOf })
|
|
737
1140
|
|
|
738
1141
|
// Per-subtree local-occurrence counts and write-sets, memoized bottom-up —
|
|
739
1142
|
// the tee-privacy check queries them for EVERY candidate node, and the old
|
|
@@ -772,57 +1175,6 @@ export function hoistInvariantLoop(fn) {
|
|
|
772
1175
|
for (let i = 1; i < loopNode.length; i++)
|
|
773
1176
|
for (const [k, v] of countsOf(loopNode[i])) localCount.set(k, (localCount.get(k) || 0) + v)
|
|
774
1177
|
|
|
775
|
-
// Pure & invariant given `bound` (locals written *within* the candidate, hence
|
|
776
|
-
// local to it). A read of a bound local is OK (its in-subtree value is the
|
|
777
|
-
// teed invariant). A free read must be unwritten by the loop.
|
|
778
|
-
const pureGiven = (node, bound) => {
|
|
779
|
-
if (!Array.isArray(node)) return true // bare operand string/number
|
|
780
|
-
const op = node[0]
|
|
781
|
-
if (op === 'i32.const' || op === 'i64.const' || op === 'f64.const' || op === 'f32.const') return true
|
|
782
|
-
if (op === 'local.get') return typeof node[1] === 'string' && (bound.has(node[1]) || !locals.has(node[1]))
|
|
783
|
-
// A global is invariant only if not set directly AND no call in the loop —
|
|
784
|
-
// any callee may mutate it (no interprocedural effect analysis). (Locals are
|
|
785
|
-
// frame-private, so calls can't touch them; only direct local.set matters.)
|
|
786
|
-
if (op === 'global.get') return typeof node[1] === 'string' && !globals.has(node[1]) && !hasAnyCall
|
|
787
|
-
if (op === 'local.tee') {
|
|
788
|
-
if (typeof node[1] !== 'string') return false
|
|
789
|
-
// The operand is evaluated BEFORE the tee writes $X, so a `local.get $X` inside
|
|
790
|
-
// it reads the loop-carried (previous-iteration) value, not the teed one. Drop
|
|
791
|
-
// $X from `bound` for the operand: `local.tee $X (… $X …)` is a loop recurrence
|
|
792
|
-
// (X = f(X) — e.g. the `while ((nn = nn >>> 1))` induction), NOT invariant.
|
|
793
|
-
const inner = bound.has(node[1]) ? new Set([...bound].filter(b => b !== node[1])) : bound
|
|
794
|
-
return pureGiven(node[2], inner)
|
|
795
|
-
}
|
|
796
|
-
if ((op === 'f64.load' || op === 'i32.load') && node.length === 2) {
|
|
797
|
-
const a = node[1]
|
|
798
|
-
return Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' && a[1].startsWith(CELL_PREFIX)
|
|
799
|
-
&& !hasAnyCall && !storedCells.has(a[1]) && (bound.has(a[1]) || !locals.has(a[1]))
|
|
800
|
-
}
|
|
801
|
-
if (op === 'call') {
|
|
802
|
-
if (SAFE_OFFSET_CALLS.has(node[1]))
|
|
803
|
-
return !hasUnsafeCall && node.slice(2).every(c => pureGiven(c, bound))
|
|
804
|
-
// Read-only heap reads: additionally require no direct store (alias-safe).
|
|
805
|
-
if (READONLY_MEM_CALLS.has(node[1]))
|
|
806
|
-
return !hasUnsafeCall && !hasDirectStore && node.slice(2).every(c => pureGiven(c, bound))
|
|
807
|
-
return false
|
|
808
|
-
}
|
|
809
|
-
// A value-producing `if` whose condition and both arms are pure is itself
|
|
810
|
-
// pure — the tag-dispatch idiom `(if (result f64) tag-check (then read-A)
|
|
811
|
-
// (else read-B))` that wraps __typed_idx/__str_idx element access.
|
|
812
|
-
if (op === 'if') {
|
|
813
|
-
for (let i = 1; i < node.length; i++) {
|
|
814
|
-
const c = node[i]
|
|
815
|
-
if (!Array.isArray(c)) continue
|
|
816
|
-
if (c[0] === 'result') continue
|
|
817
|
-
if (c[0] === 'then' || c[0] === 'else') { if (!c.slice(1).every(x => pureGiven(x, bound))) return false }
|
|
818
|
-
else if (!pureGiven(c, bound)) return false // the condition
|
|
819
|
-
}
|
|
820
|
-
return true
|
|
821
|
-
}
|
|
822
|
-
if (PURE_LICM_OPS.has(op)) return node.slice(1).every(c => pureGiven(c, bound))
|
|
823
|
-
return false
|
|
824
|
-
}
|
|
825
|
-
|
|
826
1178
|
const isHoistable = (node) => {
|
|
827
1179
|
if (!Array.isArray(node)) return false
|
|
828
1180
|
const op = node[0]
|
|
@@ -849,9 +1201,10 @@ export function hoistInvariantLoop(fn) {
|
|
|
849
1201
|
if (!Array.isArray(node)) return
|
|
850
1202
|
if (node[0] === 'loop') return // already processed bottom-up
|
|
851
1203
|
if (isHoistable(node) && (refcount.get(node) || 0) <= 1 && (refcount.get(parent) || 0) <= 1) {
|
|
852
|
-
//
|
|
853
|
-
// (BigInt
|
|
854
|
-
|
|
1204
|
+
// stableKey: hoistable boxed-pointer subtrees carry i64.const NaN-box prefixes
|
|
1205
|
+
// (BigInt) that plain JSON.stringify can't serialize, and it also collapses
|
|
1206
|
+
// Infinity/-Infinity/NaN→null & -0→0 — both would dedup distinct invariants.
|
|
1207
|
+
const key = JSON.stringify(node, stableKey)
|
|
855
1208
|
let arr = sites.get(key); if (!arr) { arr = []; sites.set(key, arr) }
|
|
856
1209
|
arr.push({ parent, idx, node })
|
|
857
1210
|
return
|
|
@@ -983,16 +1336,17 @@ export function narrowLoopBound(fn) {
|
|
|
983
1336
|
const newLocals = []
|
|
984
1337
|
const refcount = buildRefcount(fn)
|
|
985
1338
|
|
|
986
|
-
// `(f64.lt (
|
|
987
|
-
// `(f64.
|
|
1339
|
+
// `i < bound` as `(f64.lt (convert i) bound)` or mirrored `(f64.gt bound (convert i))`.
|
|
1340
|
+
// `i <= bound` as `(f64.le (convert i) bound)` or mirrored `(f64.ge bound (convert i))`.
|
|
988
1341
|
const match = (n) => {
|
|
989
|
-
const
|
|
990
|
-
const
|
|
1342
|
+
const lt = n[0] === 'f64.lt', gt = n[0] === 'f64.gt', le = n[0] === 'f64.le', ge = n[0] === 'f64.ge'
|
|
1343
|
+
const conv = lt || le ? n[1] : gt || ge ? n[2] : null
|
|
1344
|
+
const bnd = lt || le ? n[2] : gt || ge ? n[1] : null
|
|
991
1345
|
if (!Array.isArray(conv) || conv[0] !== 'f64.convert_i32_s') return null
|
|
992
1346
|
const ig = conv[1]
|
|
993
1347
|
if (!Array.isArray(ig) || ig[0] !== 'local.get' || typeof ig[1] !== 'string') return null
|
|
994
1348
|
if (!Array.isArray(bnd) || bnd[0] !== 'local.get' || typeof bnd[1] !== 'string') return null
|
|
995
|
-
return { ctr: ig[1], bound: bnd[1] }
|
|
1349
|
+
return { ctr: ig[1], bound: bnd[1], op: le || ge ? 'le' : 'lt' }
|
|
996
1350
|
}
|
|
997
1351
|
|
|
998
1352
|
const processLoop = (loopNode) => {
|
|
@@ -1022,18 +1376,34 @@ export function narrowLoopBound(fn) {
|
|
|
1022
1376
|
}
|
|
1023
1377
|
for (let i = 1; i < loopNode.length; i++) collect(loopNode[i])
|
|
1024
1378
|
|
|
1025
|
-
|
|
1379
|
+
// One snap per distinct (bound, op): `i < n` and `i <= n` of the SAME bound
|
|
1380
|
+
// need different snapped i32 values (ceil vs floor).
|
|
1381
|
+
const snapFor = new Map()
|
|
1026
1382
|
const snaps = []
|
|
1383
|
+
const I32_MIN = -2147483648
|
|
1027
1384
|
for (const { node, m } of sites) {
|
|
1028
|
-
|
|
1385
|
+
const key = `${m.bound}|${m.op}`
|
|
1386
|
+
let snap = snapFor.get(key)
|
|
1029
1387
|
if (!snap) {
|
|
1030
1388
|
snap = freshLb()
|
|
1031
|
-
snapFor.set(
|
|
1389
|
+
snapFor.set(key, snap)
|
|
1032
1390
|
newLocals.push(['local', snap, 'i32'])
|
|
1033
|
-
|
|
1391
|
+
// `i < n` ⟺ `i < ceil(n)`: trunc_sat(NaN)=0 makes `i<0` false — matches `i<NaN`;
|
|
1392
|
+
// ±Inf → I32_MAX/I32_MIN, both correct. NaN-safe for free.
|
|
1393
|
+
// `i <= n` ⟺ `i <= floor(n)`, BUT trunc_sat(floor(NaN))=0 would make `i<=0` run
|
|
1394
|
+
// one iteration at i=0, while JS (`i<=NaN` is false) runs zero. Guard the NaN
|
|
1395
|
+
// case to I32_MIN (below any non-negative counter ⇒ zero iterations). ±Inf are
|
|
1396
|
+
// already correct (floor(+Inf)→I32_MAX, floor(-Inf)→I32_MIN; Inf==Inf is true).
|
|
1397
|
+
snaps.push(['local.set', snap, m.op === 'le'
|
|
1398
|
+
? ['select',
|
|
1399
|
+
['i32.trunc_sat_f64_s', ['f64.floor', ['local.get', m.bound]]],
|
|
1400
|
+
['i32.const', I32_MIN],
|
|
1401
|
+
['f64.eq', ['local.get', m.bound], ['local.get', m.bound]]]
|
|
1402
|
+
: ['i32.trunc_sat_f64_s', ['f64.ceil', ['local.get', m.bound]]]])
|
|
1034
1403
|
}
|
|
1035
1404
|
node.length = 3
|
|
1036
|
-
node[0] =
|
|
1405
|
+
node[0] = m.op === 'le' ? 'i32.le_s' : 'i32.lt_s'
|
|
1406
|
+
node[1] = ['local.get', m.ctr]; node[2] = ['local.get', snap]
|
|
1037
1407
|
}
|
|
1038
1408
|
return snaps
|
|
1039
1409
|
}
|
|
@@ -1260,6 +1630,19 @@ export function cseScalarLoad(fn) {
|
|
|
1260
1630
|
// the two passes cover deliberately different op sets.
|
|
1261
1631
|
const COMMUTATIVE = new Set(['f64.mul', 'f64.add', 'i32.mul', 'i32.add', 'i32.and', 'i32.or', 'i32.xor', 'i64.mul', 'i64.add', 'i64.and', 'i64.or', 'i64.xor'])
|
|
1262
1632
|
|
|
1633
|
+
// Presence of one of these arms csePureExprLoop (it CSEs redundant pure f64/i32
|
|
1634
|
+
// arithmetic within the loop; the gate is just "is this loop expensive enough to
|
|
1635
|
+
// be worth the pass"). The whole class of transcendental helpers qualifies — each
|
|
1636
|
+
// is a multi-instruction polynomial approximation, so a loop built around exp/log/
|
|
1637
|
+
// pow deserves the same arithmetic-CSE a trig loop already got. Gating on the class,
|
|
1638
|
+
// not a benchmark shape; the CSE itself is bit-exact (pure-subexpr dedup only).
|
|
1639
|
+
const LOOP_CSE_EXPENSIVE = new Set([
|
|
1640
|
+
'$math.sin', '$math.cos', '$math.tan', '$math.sin_core', '$math.cos_core',
|
|
1641
|
+
'$math.exp', '$math.expm1', '$math.log', '$math.log2', '$math.log10', '$math.log1p',
|
|
1642
|
+
'$math.pow', '$math.atan', '$math.asin', '$math.acos', '$math.atan2',
|
|
1643
|
+
'$math.sinh', '$math.cosh', '$math.tanh', '$math.cbrt', '$math.hypot',
|
|
1644
|
+
])
|
|
1645
|
+
|
|
1263
1646
|
export function csePureExpr(fn) {
|
|
1264
1647
|
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
1265
1648
|
const bodyStart = findBodyStart(fn)
|
|
@@ -1396,16 +1779,15 @@ export function csePureExprLoop(fn) {
|
|
|
1396
1779
|
if (bodyStart < 0) return
|
|
1397
1780
|
|
|
1398
1781
|
let hasLoop = false
|
|
1399
|
-
let
|
|
1782
|
+
let hasExpensiveCall = false
|
|
1400
1783
|
const scanShape = (n) => {
|
|
1401
1784
|
if (!Array.isArray(n)) return
|
|
1402
1785
|
if (n[0] === 'loop') hasLoop = true
|
|
1403
|
-
if (n[0] === 'call' && (n[1]
|
|
1404
|
-
|| n[1] === '$math.sin_core' || n[1] === '$math.cos_core')) hasTrigCall = true
|
|
1786
|
+
if (n[0] === 'call' && LOOP_CSE_EXPENSIVE.has(n[1])) hasExpensiveCall = true
|
|
1405
1787
|
for (let i = 1; i < n.length; i++) scanShape(n[i])
|
|
1406
1788
|
}
|
|
1407
1789
|
for (let i = bodyStart; i < fn.length; i++) scanShape(fn[i])
|
|
1408
|
-
if (!hasLoop || !
|
|
1790
|
+
if (!hasLoop || !hasExpensiveCall) return
|
|
1409
1791
|
|
|
1410
1792
|
let snapId = nextLocalId(fn, 'pe')
|
|
1411
1793
|
const newLocals = []
|
|
@@ -2100,15 +2482,8 @@ function inferTypeFromContext(fn, gName, bodyStart) {
|
|
|
2100
2482
|
}
|
|
2101
2483
|
if (pOp === 'i32.store' && idx === 2) { inferred = 'i32'; return } // addr
|
|
2102
2484
|
if (pOp === 'f64.store' && idx === 2) { inferred = 'f64'; return } // addr can be i32, but value is f64
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
pOp === 'i32.ge_s' || pOp === 'i32.ge_u') {
|
|
2106
|
-
// Comparison — could be i32, but in jz NaN-boxing scheme most globals are f64
|
|
2107
|
-
// Only if we can confirm from local.set context
|
|
2108
|
-
}
|
|
2109
|
-
if (pOp === 'local.set' && idx === 0) {
|
|
2110
|
-
// Can't determine local type from here easily
|
|
2111
|
-
}
|
|
2485
|
+
// i32 comparisons already matched the `i32.` prefix above; a `local.set`
|
|
2486
|
+
// parent tells us nothing here — both fall through to the f64 default.
|
|
2112
2487
|
}
|
|
2113
2488
|
}
|
|
2114
2489
|
// Default: f64 (the NaN-boxing carrier)
|
|
@@ -2132,22 +2507,33 @@ function inferTypeFromContext(fn, gName, bodyStart) {
|
|
|
2132
2507
|
*
|
|
2133
2508
|
* Mutates `funcs` in place; writes new global decls via `addGlobal(name, constLiteral)`.
|
|
2134
2509
|
*/
|
|
2510
|
+
// `String(number)` keeps only ~9 significant digits in the self-host kernel (jz's number
|
|
2511
|
+
// formatter — see README "differences"). The old pool keyed constants by `n:${c[1]}` (a toString)
|
|
2512
|
+
// and emitted them via that same string, so in the kernel a constant both LOST precision
|
|
2513
|
+
// (0.041666666666666664 → 0x1.5555558325751p-5) and could MERGE with a distinct value sharing its
|
|
2514
|
+
// 9-digit prefix. Key by the exact 64 bits instead (a Float64Array/Uint32Array union — the
|
|
2515
|
+
// numHashLiteral pattern, which self-hosts; the sign bit distinguishes -0/+0 for free) and emit
|
|
2516
|
+
// the original NUMBER, which `declGlobal` lowers to a binary `f64.const` (exact, no string).
|
|
2517
|
+
const _FCB = new Float64Array(1), _FCBu = new Uint32Array(_FCB.buffer)
|
|
2518
|
+
const f64BitsKey = (n) => { _FCB[0] = n; return `n:${_FCBu[0]}:${_FCBu[1]}` }
|
|
2519
|
+
|
|
2135
2520
|
export function hoistConstantPool(funcs, addGlobal) {
|
|
2136
2521
|
const MIN_USES = 2
|
|
2137
2522
|
// Single walk: count occurrences AND record each f64.const site for direct rewrite.
|
|
2138
2523
|
// Avoids a second full-AST traversal in the rewrite phase.
|
|
2139
2524
|
const counts = new Map()
|
|
2525
|
+
// NOTE: not `valueOf` — a local named like an Object method self-host-miscompiles (the
|
|
2526
|
+
// kernel's dynamic dispatch confuses it). key → exact original c[1] (number, or source string).
|
|
2527
|
+
const exactVal = new Map()
|
|
2140
2528
|
const sites = [] // { parent, idx, key }
|
|
2141
2529
|
const walk = (node) => {
|
|
2142
2530
|
if (!Array.isArray(node)) return
|
|
2143
2531
|
for (let i = 0; i < node.length; i++) {
|
|
2144
2532
|
const c = node[i]
|
|
2145
2533
|
if (Array.isArray(c) && c[0] === 'f64.const' && (typeof c[1] === 'number' || typeof c[1] === 'string')) {
|
|
2146
|
-
|
|
2147
|
-
const k = typeof c[1] === 'number'
|
|
2148
|
-
? (Object.is(c[1], -0) ? 'n:-0' : `n:${c[1]}`)
|
|
2149
|
-
: `s:${c[1]}`
|
|
2534
|
+
const k = typeof c[1] === 'number' ? f64BitsKey(c[1]) : `s:${c[1]}`
|
|
2150
2535
|
counts.set(k, (counts.get(k) || 0) + 1)
|
|
2536
|
+
if (!exactVal.has(k)) exactVal.set(k, c[1])
|
|
2151
2537
|
sites.push({ parent: node, idx: i, key: k })
|
|
2152
2538
|
}
|
|
2153
2539
|
walk(c)
|
|
@@ -2160,8 +2546,9 @@ export function hoistConstantPool(funcs, addGlobal) {
|
|
|
2160
2546
|
let gId = 0
|
|
2161
2547
|
for (const [k] of sorted) {
|
|
2162
2548
|
const name = `__fc${gId++}`
|
|
2163
|
-
const
|
|
2164
|
-
|
|
2549
|
+
// The EXACT original c[1] (a number → binary f64.const; or a source hex/decimal string),
|
|
2550
|
+
// never the lossy k-derived toString.
|
|
2551
|
+
addGlobal(name, exactVal.get(k))
|
|
2165
2552
|
hoist.set(k, name)
|
|
2166
2553
|
}
|
|
2167
2554
|
if (!hoist.size) return
|
|
@@ -2269,9 +2656,7 @@ export function specializeMkptr(funcs, addFunc, parseWat) {
|
|
|
2269
2656
|
// $__mkptr inline fast path: bake (type, aux) literals into i64.const template.
|
|
2270
2657
|
if (target === '$__mkptr' && spec.inline && parts[0].startsWith('L:') && parts[1].startsWith('L:')) {
|
|
2271
2658
|
const type = +parts[0].slice(2), aux = +parts[1].slice(2)
|
|
2272
|
-
const tmpl =
|
|
2273
|
-
| ((BigInt(type) & BigInt(LAYOUT.TAG_MASK)) << BigInt(LAYOUT.TAG_SHIFT))
|
|
2274
|
-
| ((BigInt(aux) & BigInt(LAYOUT.AUX_MASK)) << BigInt(LAYOUT.AUX_SHIFT))
|
|
2659
|
+
const tmpl = ptrBits(type, aux) // box prefix (offset OR'd in at runtime below)
|
|
2275
2660
|
// Third arg (offset) may also be literal — emit (f64.const nan:…) then.
|
|
2276
2661
|
if (parts[2].startsWith('L:')) {
|
|
2277
2662
|
// Fully literal: all sites can be f64.const — no helper needed, handled in rewrite below.
|
|
@@ -2313,11 +2698,7 @@ export function specializeMkptr(funcs, addFunc, parseWat) {
|
|
|
2313
2698
|
// $__mkptr fully literal (rare — mkPtrIR usually folds these ahead of us, but defensive):
|
|
2314
2699
|
if (target === '$__mkptr' && parts[0].startsWith('L:') && parts[1].startsWith('L:') && parts[2].startsWith('L:')) {
|
|
2315
2700
|
const type = +parts[0].slice(2), aux = +parts[1].slice(2), off = +parts[2].slice(2)
|
|
2316
|
-
const
|
|
2317
|
-
| ((BigInt(type) & BigInt(LAYOUT.TAG_MASK)) << BigInt(LAYOUT.TAG_SHIFT))
|
|
2318
|
-
| ((BigInt(aux) & BigInt(LAYOUT.AUX_MASK)) << BigInt(LAYOUT.AUX_SHIFT))
|
|
2319
|
-
| (BigInt(off >>> 0) & BigInt(LAYOUT.OFFSET_MASK))
|
|
2320
|
-
const n = ['f64.const', 'nan:0x' + bits.toString(16).toUpperCase().padStart(16, '0')]
|
|
2701
|
+
const n = ['f64.const', 'nan:' + i64Hex(ptrBits(type, aux, off))]
|
|
2321
2702
|
n.type = 'f64'
|
|
2322
2703
|
parent[idx] = n
|
|
2323
2704
|
continue
|
|
@@ -2641,6 +3022,174 @@ export function foldStrDispatchF64(fn) {
|
|
|
2641
3022
|
for (let i = bodyStart; i < fn.length; i++) fn[i] = foldNode(fn[i])
|
|
2642
3023
|
}
|
|
2643
3024
|
|
|
3025
|
+
/**
|
|
3026
|
+
* Loop-unswitch a polymorphic typed-array PARAM loop on the pointer type so the
|
|
3027
|
+
* Float64Array case hoists its base and vectorizes.
|
|
3028
|
+
*
|
|
3029
|
+
* `export function f(buf,n){ for(let i=0;i<n;i++) buf[i]=g(buf[i],i) }` emits a
|
|
3030
|
+
* per-iteration POLYMORPHIC store `(drop (if tag(buf)==ARRAY (then __arr_set_idx_ptr;
|
|
3031
|
+
* local.set $buf) (else f64.store __ptr_offset(buf)+i<<3)))` and a read
|
|
3032
|
+
* `__to_num(reinterpret(__typed_idx(reinterpret(buf), i)))` — re-decoding the NaN-box
|
|
3033
|
+
* base every iteration. The `local.set $buf` realloc reassign marks the param unsafe,
|
|
3034
|
+
* so hoistInvariantPtrOffset bails and the loop never vectorizes.
|
|
3035
|
+
*
|
|
3036
|
+
* Insert a ONCE-before-loop test "is buf a (non-BigInt) Float64Array?": yes → a fast
|
|
3037
|
+
* loop with the base hoisted to an i32 local, the read collapsed to `f64.load`, and the
|
|
3038
|
+
* polymorphic store replaced by a direct `f64.store` (no calls) — which vectorizeLaneLocal
|
|
3039
|
+
* then lifts to f64x2. no → the original block verbatim (bit-exact fallback for ARRAY and
|
|
3040
|
+
* every other element width). Float64Array (owned aux=7 or view aux=15) is the ONLY gated
|
|
3041
|
+
* type: the else-branch f64.store is 8-byte, valid only for f64 elements; Int32Array /
|
|
3042
|
+
* Uint8Array / BigInt64Array (aux 4 / 1 / 23) all fall to the verbatim path. The global-
|
|
3043
|
+
* Float64Array path already lowers reads to f64.load, proving f64.load == the __to_num
|
|
3044
|
+
* read for f64 elements (bit-exact, incl. NaN). All helpers are nested function decls
|
|
3045
|
+
* (no ctx param) per the self-host discipline.
|
|
3046
|
+
*/
|
|
3047
|
+
export function unswitchTypedParamLoop(fn) {
|
|
3048
|
+
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
3049
|
+
const bodyStart = findBodyStart(fn)
|
|
3050
|
+
if (bodyStart < 0) return
|
|
3051
|
+
const f64Params = new Set()
|
|
3052
|
+
for (let i = 2; i < bodyStart; i++) {
|
|
3053
|
+
const c = fn[i]
|
|
3054
|
+
if (Array.isArray(c) && c[0] === 'param' && typeof c[1] === 'string' && c[2] === 'f64') f64Params.add(c[1])
|
|
3055
|
+
}
|
|
3056
|
+
if (!f64Params.size) return
|
|
3057
|
+
|
|
3058
|
+
const F64 = TYPED_ELEM_CODE.Float64Array, F64V = F64 | TYPED_ELEM_VIEW_FLAG
|
|
3059
|
+
const newLocals = []
|
|
3060
|
+
let baseId = nextLocalId(fn, 'utb')
|
|
3061
|
+
|
|
3062
|
+
const clone = (n) => Array.isArray(n) ? n.map(clone) : n
|
|
3063
|
+
const has = (n, pred) => Array.isArray(n) && (pred(n) || n.some((c, i) => i > 0 && has(c, pred)))
|
|
3064
|
+
const writes = (n, name) => has(n, (x) => (x[0] === 'local.set' || x[0] === 'local.tee') && x[1] === name)
|
|
3065
|
+
const reintParam = (n, p) => Array.isArray(n) && n[0] === 'i64.reinterpret_f64' && Array.isArray(n[1]) && n[1][0] === 'local.get' && n[1][1] === p
|
|
3066
|
+
const typedIdx = (n, p) => Array.isArray(n) && n[0] === 'call' && n[1] === '$__typed_idx' && n.length >= 4 && reintParam(n[2], p)
|
|
3067
|
+
|
|
3068
|
+
// Clone, collapsing the typed-array read to a direct f64.load(base + IND<<3):
|
|
3069
|
+
// __to_num(reinterpret(__typed_idx(reinterpret P, IND))) — and the bare form too.
|
|
3070
|
+
function cloneRead(n, p, base) {
|
|
3071
|
+
if (!Array.isArray(n)) return n
|
|
3072
|
+
if (n[0] === 'call' && n[1] === '$__to_num' && n.length === 3
|
|
3073
|
+
&& Array.isArray(n[2]) && n[2][0] === 'i64.reinterpret_f64' && typedIdx(n[2][1], p))
|
|
3074
|
+
return ['f64.load', ['i32.add', ['local.get', base], ['i32.shl', clone(n[2][1][3]), ['i32.const', 3]]]]
|
|
3075
|
+
if (typedIdx(n, p))
|
|
3076
|
+
return ['f64.load', ['i32.add', ['local.get', base], ['i32.shl', clone(n[3]), ['i32.const', 3]]]]
|
|
3077
|
+
return n.map((c, i) => i === 0 ? c : cloneRead(c, p, base))
|
|
3078
|
+
}
|
|
3079
|
+
|
|
3080
|
+
function processBlock(blockNode, parent, idx) {
|
|
3081
|
+
if (!Array.isArray(blockNode) || blockNode[0] !== 'block') return
|
|
3082
|
+
let loopNode = null, blockLabel = null
|
|
3083
|
+
const preamble = []
|
|
3084
|
+
for (let i = 1; i < blockNode.length; i++) {
|
|
3085
|
+
const c = blockNode[i]
|
|
3086
|
+
if (i === 1 && typeof c === 'string' && c.startsWith('$')) { blockLabel = c; continue }
|
|
3087
|
+
if (Array.isArray(c) && c[0] === 'loop') { if (loopNode) return; loopNode = c }
|
|
3088
|
+
else if (Array.isArray(c) && c[0] === 'local.set' && !loopNode) preamble.push(c)
|
|
3089
|
+
else if (Array.isArray(c)) return
|
|
3090
|
+
}
|
|
3091
|
+
if (!loopNode || !blockLabel) return
|
|
3092
|
+
const loopLabel = typeof loopNode[1] === 'string' && loopNode[1].startsWith('$') ? loopNode[1] : null
|
|
3093
|
+
if (!loopLabel) return
|
|
3094
|
+
const endIdx = loopNode.length - 1
|
|
3095
|
+
if (!(Array.isArray(loopNode[endIdx]) && loopNode[endIdx][0] === 'br' && loopNode[endIdx][1] === loopLabel)) return
|
|
3096
|
+
const incNode = loopNode[endIdx - 1]
|
|
3097
|
+
if (!Array.isArray(incNode) || incNode[0] !== 'local.set' || !Array.isArray(incNode[2]) || incNode[2][0] !== 'i32.add') return
|
|
3098
|
+
const incVar = incNode[1], inc = incNode[2]
|
|
3099
|
+
if (!(Array.isArray(inc[1]) && inc[1][0] === 'local.get' && inc[1][1] === incVar && Array.isArray(inc[2]) && inc[2][0] === 'i32.const' && inc[2][1] === 1)) return
|
|
3100
|
+
const body = loopNode.slice(3, endIdx - 1)
|
|
3101
|
+
if (body.length < 4) return
|
|
3102
|
+
|
|
3103
|
+
// Find the polymorphic-store `if` by scanning (it's followed by a `drop` of its
|
|
3104
|
+
// f64 result; in the IR the two are separate statements, not (drop (if …))).
|
|
3105
|
+
let storeIdx = -1, paramName = null, elseStore = null
|
|
3106
|
+
for (let i = 0; i < body.length; i++) {
|
|
3107
|
+
const c = body[i]
|
|
3108
|
+
if (!Array.isArray(c) || c[0] !== 'if' || !Array.isArray(c[1]) || c[1][0] !== 'result' || c[1][1] !== 'f64') continue
|
|
3109
|
+
let thenArm = null, elseArm = null
|
|
3110
|
+
for (let k = 2; k < c.length; k++) { const a = c[k]; if (Array.isArray(a)) { if (a[0] === 'then') thenArm = a; else if (a[0] === 'else') elseArm = a } }
|
|
3111
|
+
if (!thenArm || !elseArm) continue
|
|
3112
|
+
let p = null
|
|
3113
|
+
for (let k = 1; k < thenArm.length; k++) { const a = thenArm[k]; if (Array.isArray(a) && a[0] === 'local.set' && f64Params.has(a[1]) && Array.isArray(a[2]) && a[2][0] === 'local.get') p = a[1] }
|
|
3114
|
+
if (!p || !has(thenArm, (x) => x[0] === 'call' && x[1] === '$__arr_set_idx_ptr')) continue
|
|
3115
|
+
// The bare `f64.store(__ptr_offset(o)+i<<3)` is the non-ARRAY fallback. It may be
|
|
3116
|
+
// nested under an OBJECT/HASH → __dyn_set guard (emitPolymorphicElementStore's
|
|
3117
|
+
// dyn-prop safety fork) — descend to find it; the fast path replaces the whole
|
|
3118
|
+
// store with a direct f64.load/store anyway (a proven Float64Array is never an
|
|
3119
|
+
// OBJECT, so its dyn arm is dead there). Still bail on the 3-way __typed_set_idx
|
|
3120
|
+
// form (mixed element widths — the f64.store fallback isn't the sole non-ARRAY case).
|
|
3121
|
+
const findRawStore = (n) => {
|
|
3122
|
+
if (!Array.isArray(n)) return null
|
|
3123
|
+
if (n[0] === 'f64.store' && Array.isArray(n[1]) && n[1][0] === 'i32.add' &&
|
|
3124
|
+
Array.isArray(n[1][2]) && n[1][2][0] === 'i32.shl' &&
|
|
3125
|
+
has(n[1], (x) => x[0] === 'call' && x[1] === '$__ptr_offset')) return n
|
|
3126
|
+
for (let k = 1; k < n.length; k++) { const r = findRawStore(n[k]); if (r) return r }
|
|
3127
|
+
return null
|
|
3128
|
+
}
|
|
3129
|
+
if (has(elseArm, (x) => x[0] === 'call' && x[1] === '$__typed_set_idx')) continue
|
|
3130
|
+
const es = findRawStore(elseArm)
|
|
3131
|
+
if (!es) continue
|
|
3132
|
+
storeIdx = i; paramName = p; elseStore = es; break
|
|
3133
|
+
}
|
|
3134
|
+
if (storeIdx < 0) return
|
|
3135
|
+
const shiftIdx = elseStore[1][2][1] // the index from the store's (i32.shl IDX 3)
|
|
3136
|
+
// The read uses the IV directly; the store uses a snapshot `$asi = $iv`. Emit the
|
|
3137
|
+
// store against the IV too so the vectorizer unifies the load/store lanes — bit-exact
|
|
3138
|
+
// ($asi == $iv). Bail if the store index isn't the IV or a snapshot of it.
|
|
3139
|
+
let storeIdxName = null
|
|
3140
|
+
if (Array.isArray(shiftIdx) && shiftIdx[0] === 'local.get') storeIdxName = shiftIdx[1]
|
|
3141
|
+
if (storeIdxName !== incVar &&
|
|
3142
|
+
!body.some((st) => Array.isArray(st) && st[0] === 'local.set' && st[1] === storeIdxName && Array.isArray(st[2]) && st[2][0] === 'local.get' && st[2][1] === incVar)) return
|
|
3143
|
+
// The store-if pushes f64; a following `drop` (bare string in stack-style IR, or a
|
|
3144
|
+
// `['drop', …]` node) pops it. The fast store pushes nothing, so the drop must go too.
|
|
3145
|
+
const isDrop = (s) => s === 'drop' || (Array.isArray(s) && s[0] === 'drop')
|
|
3146
|
+
const hasDrop = storeIdx + 1 < body.length && isDrop(body[storeIdx + 1])
|
|
3147
|
+
|
|
3148
|
+
// The stored value is the else-store's operand (a local the body computed); take it
|
|
3149
|
+
// from the store itself, not by guessing which local reads buf — the read may be
|
|
3150
|
+
// nested in a split computation (`$t = buf[i]; $v = $t*2`) whose result is a DIFFERENT
|
|
3151
|
+
// local. cloneRead rewrites any buf reads in that computation to f64.load.
|
|
3152
|
+
if (!(Array.isArray(elseStore[2]) && elseStore[2][0] === 'local.get')) return
|
|
3153
|
+
const valName = elseStore[2][1]
|
|
3154
|
+
// GUARD: param reassigned ONLY inside the matched store-if (else the hoisted base goes stale).
|
|
3155
|
+
for (let i = 0; i < body.length; i++) { if (i === storeIdx) continue; if (writes(body[i], paramName)) return }
|
|
3156
|
+
for (const s of preamble) { if (writes(s, paramName)) return }
|
|
3157
|
+
|
|
3158
|
+
const base = `$__utb${baseId++}`
|
|
3159
|
+
newLocals.push(['local', base, 'i32'])
|
|
3160
|
+
const reint = () => ['i64.reinterpret_f64', ['local.get', paramName]]
|
|
3161
|
+
const tag = ['i32.and', ['i32.wrap_i64', ['i64.shr_u', reint(), ['i64.const', LAYOUT.TAG_SHIFT]]], ['i32.const', LAYOUT.TAG_MASK]]
|
|
3162
|
+
const auxOf = () => ['i32.and', ['i32.wrap_i64', ['i64.shr_u', reint(), ['i64.const', LAYOUT.AUX_SHIFT]]], ['i32.const', LAYOUT.AUX_MASK]]
|
|
3163
|
+
const gate = ['i32.and', ['i32.eq', tag, ['i32.const', PTR.TYPED]],
|
|
3164
|
+
['i32.or', ['i32.eq', auxOf(), ['i32.const', F64]], ['i32.eq', auxOf(), ['i32.const', F64V]]]]
|
|
3165
|
+
const baseSnap = ['local.set', base, ['call', '$__ptr_offset', reint()]]
|
|
3166
|
+
const fastStore = ['f64.store', ['i32.add', ['local.get', base], ['i32.shl', ['local.get', incVar], ['i32.const', 3]]], ['local.get', valName]]
|
|
3167
|
+
// Fast body: keep every statement except the store-if (→ fastStore) and its trailing
|
|
3168
|
+
// drop (the fast store pushes nothing), with the typed-array read collapsed to f64.load.
|
|
3169
|
+
const fastStmts = []
|
|
3170
|
+
for (let i = 0; i < body.length; i++) {
|
|
3171
|
+
if (i === storeIdx) { fastStmts.push(fastStore); continue }
|
|
3172
|
+
if (hasDrop && i === storeIdx + 1) continue
|
|
3173
|
+
fastStmts.push(cloneRead(body[i], paramName, base))
|
|
3174
|
+
}
|
|
3175
|
+
const fastLoop = ['block', blockLabel, ...preamble.map(clone),
|
|
3176
|
+
['loop', loopLabel, clone(loopNode[2]), ...fastStmts, clone(incNode), clone(loopNode[endIdx])]]
|
|
3177
|
+
parent[idx] = ['if', gate, ['then', baseSnap, fastLoop], ['else', blockNode]]
|
|
3178
|
+
}
|
|
3179
|
+
|
|
3180
|
+
function walk(node, parent, idx) {
|
|
3181
|
+
if (!Array.isArray(node)) return
|
|
3182
|
+
if (node[0] === 'block') {
|
|
3183
|
+
const before = parent[idx]
|
|
3184
|
+
processBlock(node, parent, idx)
|
|
3185
|
+
if (parent[idx] !== before) return
|
|
3186
|
+
}
|
|
3187
|
+
for (let i = 0; i < node.length; i++) walk(node[i], node, i)
|
|
3188
|
+
}
|
|
3189
|
+
for (let i = bodyStart; i < fn.length; i++) walk(fn[i], fn, i)
|
|
3190
|
+
if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
|
|
3191
|
+
}
|
|
3192
|
+
|
|
2644
3193
|
/**
|
|
2645
3194
|
* Run all per-function IR optimizations on a single function node.
|
|
2646
3195
|
* hoistPtrType runs first — it introduces new locals (`$__ptN`) that the fused
|
|
@@ -2670,6 +3219,11 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
|
|
|
2670
3219
|
cfg.promoteGlobals === false &&
|
|
2671
3220
|
cfg.sortLocalsByUse === false &&
|
|
2672
3221
|
cfg.vectorizeLaneLocal === false) return
|
|
3222
|
+
// Recursion-unrolling runs first in 'pre': self-calls are still clean `call`
|
|
3223
|
+
// nodes (watr's inliner hasn't reshaped them) and the freshly-inlined body then
|
|
3224
|
+
// rides every pass below (LICM, fold, sort). Speed-tier only; 'pre' only (so the
|
|
3225
|
+
// post-watr re-optimize doesn't unroll a second time).
|
|
3226
|
+
if (cfg && cfg.recursionUnroll === true && phase === 'pre') recursionUnroll(fn)
|
|
2673
3227
|
if (!cfg || cfg.hoistPtrType !== false) hoistPtrType(fn)
|
|
2674
3228
|
if (!cfg || cfg.hoistInvariantPtrOffset !== false) hoistInvariantPtrOffset(fn)
|
|
2675
3229
|
// Before LICM: the snapped i32 bound is itself a hoistable hard-op subtree, so
|
|
@@ -2681,6 +3235,7 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
|
|
|
2681
3235
|
if (!cfg || cfg.hoistInvariantLoop !== false) hoistInvariantLoop(fn)
|
|
2682
3236
|
const counts = new Map()
|
|
2683
3237
|
if (!cfg || cfg.fusedRewrite !== false) fusedRewrite(fn, counts)
|
|
3238
|
+
if (cfg && cfg.boolConvertToSelect === true) boolConvertToSelect(fn)
|
|
2684
3239
|
if (!cfg || cfg.hoistAddrBase !== false) hoistAddrBase(fn)
|
|
2685
3240
|
if (!cfg || cfg.hoistInvariantLoop !== false) hoistInvariantLoop(fn)
|
|
2686
3241
|
if (!cfg || cfg.cseScalarLoad !== false) cseScalarLoad(fn)
|
|
@@ -2702,9 +3257,45 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
|
|
|
2702
3257
|
// Phase 1: fold dead string-dispatch blocks on proven-f64 locals BEFORE
|
|
2703
3258
|
// the vectorizer pattern-matches — dead __is_str_key calls in $fbm-style
|
|
2704
3259
|
// functions (param f64 + op f64) block liftPPC from recognizing them as pure.
|
|
3260
|
+
if (runVectorizer && (!cfg || cfg.unswitchTypedParamLoop !== false)) unswitchTypedParamLoop(fn)
|
|
2705
3261
|
if (runVectorizer) foldStrDispatchF64(fn)
|
|
2706
|
-
if (runVectorizer) vectorizeLaneLocal(fn,
|
|
3262
|
+
if (runVectorizer) vectorizeLaneLocal(fn, {
|
|
3263
|
+
multiAcc: cfg.reduceUnroll === true,
|
|
3264
|
+
relaxedFma: cfg.relaxedSimd === true,
|
|
3265
|
+
blurMP: cfg.blurMultiPixel !== false,
|
|
3266
|
+
whyNot: cfg.whyNotSimd === true,
|
|
3267
|
+
stencil: cfg.experimentalStencil !== false,
|
|
3268
|
+
outerStrip: cfg.experimentalOuterStrip !== false,
|
|
3269
|
+
pureFuncMap: cfg._pureFuncMap || null,
|
|
3270
|
+
toneMap: cfg.experimentalToneMap !== false,
|
|
3271
|
+
slp: cfg.experimentalSlp !== false, // SLP default-on (testing single-use fix)
|
|
3272
|
+
})
|
|
3273
|
+
// The vectorizer emits `v128.load/store (i32.add base K)` for the unrolled
|
|
3274
|
+
// multi-accumulator reduction (a[i],a[i+2],a[i+4]…) and stencil/strided reads.
|
|
3275
|
+
// fusedRewrite's memarg fold already ran (above, before vectorize), so fold the
|
|
3276
|
+
// freshly-created v128 memargs now — one fewer i32.add per accumulator per
|
|
3277
|
+
// iteration, the hot-loop waste audit-fixpoint.mjs flagged on dot/sum.
|
|
3278
|
+
if (runVectorizer) foldV128Memargs(fn)
|
|
3279
|
+
}
|
|
3280
|
+
// SSA-split loop-private unrolled scratch (post-vectorize: vectorized loops now carry
|
|
3281
|
+
// v128 and are skipped) so the LICM below hoists the per-iteration invariants the
|
|
3282
|
+
// unroller's name-merging hid — rust/LLVM's free-after-unroll register hoist (closes
|
|
3283
|
+
// the raytrace per-sphere `c_i` recompute). Bit-exact; re-run LICM to lift the splits.
|
|
3284
|
+
if (phase === 'post' && (!cfg || cfg.hoistInvariantLoop !== false)) {
|
|
3285
|
+
splitLoopPrivateScratch(fn)
|
|
3286
|
+
// Iterate LICM for the dependency cascade: c = ox²+… is invariant only once ox is
|
|
3287
|
+
// itself hoisted out, which the single-pass hoister can't see in one go. 4 climbs
|
|
3288
|
+
// covers the deepest scratch chain; idempotent, so it self-terminates.
|
|
3289
|
+
for (let k = 0; k < 4; k++) hoistInvariantLoop(fn)
|
|
2707
3290
|
}
|
|
3291
|
+
// Loop rotation — the LAST shape pass (post-watr only, so no later pass reverts
|
|
3292
|
+
// it and the v128 loops are already formed for the skip-guard). Speed-tier: it
|
|
3293
|
+
// duplicates the loop condition for a fused conditional back-edge (1.35× on the
|
|
3294
|
+
// lz/qoi scalar scans — see rotateLoops).
|
|
3295
|
+
if (cfg && cfg.rotateLoops === true && phase === 'post') rotateLoops(fn)
|
|
3296
|
+
// Canonicalize boolean conditions (strip redundant `!= 0` / double-`eqz`) — after
|
|
3297
|
+
// rotateLoops so its fused back-edges get cleaned too. Tied to the peephole pass.
|
|
3298
|
+
if (!cfg || cfg.fusedRewrite !== false) simplifyBoolContexts(fn)
|
|
2708
3299
|
if (!cfg || cfg.sortLocalsByUse !== false) sortLocalsByUse(fn, cfg && cfg.fusedRewrite !== false ? counts : null)
|
|
2709
3300
|
// An optimizer pass that emits a malformed local — the class that otherwise dies
|
|
2710
3301
|
// as an opaque watr "Duplicate/Unknown local $x" several phases on — is caught
|
|
@@ -2712,6 +3303,180 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
|
|
|
2712
3303
|
if (DBG_IR) { const bad = verifyFn(fn); if (bad) throw new Error(`[ir verify] optimize produced invalid IR in ${fn[1]}: ${bad}`) }
|
|
2713
3304
|
}
|
|
2714
3305
|
|
|
3306
|
+
// Fold `(v128.load/store (i32.add base K) …)` → `(… offset=K base …)`. Same logic as
|
|
3307
|
+
// walkRewrite's scalar foldMemargOffsets (MEMOP path), but for the v128 loads/stores the
|
|
3308
|
+
// lane vectorizer creates AFTER fusedRewrite has already run — so they'd otherwise keep a
|
|
3309
|
+
// per-iteration i32.add. Bottom-up, in place; an addr already in offset=/align= form is left.
|
|
3310
|
+
function foldV128Memargs(node) {
|
|
3311
|
+
if (!Array.isArray(node)) return
|
|
3312
|
+
const op = node[0]
|
|
3313
|
+
if (op === 'v128.load' || op === 'v128.store') {
|
|
3314
|
+
const m1 = node[1]
|
|
3315
|
+
if (!(typeof m1 === 'string' && (m1.startsWith('offset=') || m1.startsWith('align='))) &&
|
|
3316
|
+
Array.isArray(m1) && m1[0] === 'i32.add' && m1.length === 3) {
|
|
3317
|
+
const a = m1[1], b = m1[2]
|
|
3318
|
+
let base, offset
|
|
3319
|
+
if (Array.isArray(b) && b[0] === 'i32.const' && typeof b[1] === 'number' && b[1] >= 0 && b[1] < 0x100000000) { base = a; offset = b[1] }
|
|
3320
|
+
else if (Array.isArray(a) && a[0] === 'i32.const' && typeof a[1] === 'number' && a[1] >= 0 && a[1] < 0x100000000) { base = b; offset = a[1] }
|
|
3321
|
+
if (base != null) { node[1] = `offset=${offset}`; node.splice(2, 0, base) }
|
|
3322
|
+
}
|
|
3323
|
+
}
|
|
3324
|
+
for (let i = 1; i < node.length; i++) foldV128Memargs(node[i])
|
|
3325
|
+
}
|
|
3326
|
+
|
|
3327
|
+
// i32 comparison/eqz negations — used to flip a break-condition into the
|
|
3328
|
+
// loop-continue condition. f64 compares are deliberately ABSENT: ¬(a<b) ≠ (a≥b)
|
|
3329
|
+
// across NaN, so those fall through to the `i32.eqz` wrap below.
|
|
3330
|
+
const ROT_NEG = {
|
|
3331
|
+
'i32.eqz': null, // sentinel: strip the eqz (handled specially)
|
|
3332
|
+
'i32.eq': 'i32.ne', 'i32.ne': 'i32.eq',
|
|
3333
|
+
'i32.lt_s': 'i32.ge_s', 'i32.ge_s': 'i32.lt_s', 'i32.gt_s': 'i32.le_s', 'i32.le_s': 'i32.gt_s',
|
|
3334
|
+
'i32.lt_u': 'i32.ge_u', 'i32.ge_u': 'i32.lt_u', 'i32.gt_u': 'i32.le_u', 'i32.le_u': 'i32.gt_u',
|
|
3335
|
+
}
|
|
3336
|
+
|
|
3337
|
+
// Boolean-context canonicalization. At a true zero/nonzero position — a `br_if`,
|
|
3338
|
+
// `if`, `i32.eqz`, or `select` CONDITION — these are all equivalent to the inner
|
|
3339
|
+
// value: `i32.ne(X, 0) → X`, `i32.ne(0, X) → X`, `i32.eqz(i32.eqz(X)) → X`. jz
|
|
3340
|
+
// emits the redundant compare from `while (x !== 0)` lowering and from rotateLoops'
|
|
3341
|
+
// `negate` (which strips one `eqz` but leaves the `i32.ne`). V8 happens to fold it,
|
|
3342
|
+
// but JSC/wasmtime needn't — so strip it for MINIMAL output regardless of engine.
|
|
3343
|
+
// Only applied at proven boolean positions (never on a value-position `ne`/`eqz`,
|
|
3344
|
+
// which produce a real 0/1).
|
|
3345
|
+
const boolSimp = (n) => {
|
|
3346
|
+
for (;;) {
|
|
3347
|
+
if (!Array.isArray(n)) return n
|
|
3348
|
+
if (n[0] === 'i32.ne' && n.length === 3) {
|
|
3349
|
+
if (Array.isArray(n[2]) && n[2][0] === 'i32.const' && n[2][1] === 0) { n = n[1]; continue }
|
|
3350
|
+
if (Array.isArray(n[1]) && n[1][0] === 'i32.const' && n[1][1] === 0) { n = n[2]; continue }
|
|
3351
|
+
}
|
|
3352
|
+
if (n[0] === 'i32.eqz' && Array.isArray(n[1]) && n[1][0] === 'i32.eqz' && n[1].length === 2) { n = n[1][1]; continue }
|
|
3353
|
+
return n
|
|
3354
|
+
}
|
|
3355
|
+
}
|
|
3356
|
+
function simplifyBoolContexts(fn) {
|
|
3357
|
+
const walk = (node) => {
|
|
3358
|
+
if (!Array.isArray(node)) return
|
|
3359
|
+
for (let i = 1; i < node.length; i++) walk(node[i])
|
|
3360
|
+
const op = node[0]
|
|
3361
|
+
if (op === 'br_if' && node.length === 3) node[2] = boolSimp(node[2])
|
|
3362
|
+
else if (op === 'i32.eqz' && node.length === 2) node[1] = boolSimp(node[1])
|
|
3363
|
+
else if (op === 'if') { const ci = (Array.isArray(node[1]) && node[1][0] === 'result') ? 2 : 1; if (Array.isArray(node[ci])) node[ci] = boolSimp(node[ci]) }
|
|
3364
|
+
else if (op === 'select' && node.length === 4 && Array.isArray(node[3])) node[3] = boolSimp(node[3])
|
|
3365
|
+
}
|
|
3366
|
+
const bodyStart = findBodyStart(fn)
|
|
3367
|
+
for (let i = bodyStart; i < fn.length; i++) walk(fn[i])
|
|
3368
|
+
}
|
|
3369
|
+
|
|
3370
|
+
/**
|
|
3371
|
+
* Loop rotation (loop inversion). Convert jz's top-test loop idiom
|
|
3372
|
+
* (block $brk (loop $loop (br_if $brk ¬C) BODY… (br $loop)))
|
|
3373
|
+
* into a guarded bottom-test loop with a FUSED conditional back-edge:
|
|
3374
|
+
* (block $brk (br_if $brk ¬C) (loop $loop BODY… (br_if $loop C)))
|
|
3375
|
+
*
|
|
3376
|
+
* V8/TurboFan lowers the fused `br_if $loop C` to one hardware loop branch — the
|
|
3377
|
+
* shape LLVM gives rust/zig, and the reason their hot scalar loops (lz's greedy
|
|
3378
|
+
* match-scan, qoi's run-length scan) beat jz's top-test form, which compiles to a
|
|
3379
|
+
* forward exit-branch PLUS a separate unconditional back-jump. Measured 1.35× on
|
|
3380
|
+
* the lz inner loop; nothing else jz runs reaches this shape — watr's `loopify`
|
|
3381
|
+
* collapses to `loop { if C { …; br } }`, whose back-jump stays UNfused (no win).
|
|
3382
|
+
*
|
|
3383
|
+
* Evaluation count of C is unchanged: guard-once + one back-edge per iteration ==
|
|
3384
|
+
* the top-test form's once-per-loop-top — so it's sound even when C has side
|
|
3385
|
+
* effects (a `local.tee` recurrence, a call). The condition is duplicated only in
|
|
3386
|
+
* the EMITTED text (guard + back-edge), a small size-for-speed trade — speed-tier.
|
|
3387
|
+
*
|
|
3388
|
+
* Conservative skips:
|
|
3389
|
+
* - any v128/SIMD op in the loop — already register-tight; reshaping risks
|
|
3390
|
+
* disturbing the lane structure (mirrors hoistInvariantLoop's hasV128 guard).
|
|
3391
|
+
* - a body that branches to $loop: a `continue` with no step lands on the loop
|
|
3392
|
+
* label, which after rotation sits BEFORE the back-edge test — rotating would
|
|
3393
|
+
* skip it. (jz wraps continue-with-step in a `$cont` block → targets that, not
|
|
3394
|
+
* $loop → still rotatable.)
|
|
3395
|
+
*/
|
|
3396
|
+
function rotateLoops(fn) {
|
|
3397
|
+
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
3398
|
+
const bodyStart = findBodyStart(fn)
|
|
3399
|
+
if (bodyStart < 0) return
|
|
3400
|
+
|
|
3401
|
+
const clone = (n) => Array.isArray(n) ? n.map(clone) : n
|
|
3402
|
+
// Break-condition C → loop-continue condition ¬C for the back-edge. Fold the
|
|
3403
|
+
// i32 forms so the back-edge stays ONE fused compare-and-branch (a wrapping
|
|
3404
|
+
// `i32.eqz` would add an op inside the hot loop); everything else wraps.
|
|
3405
|
+
const negate = (c) => {
|
|
3406
|
+
if (Array.isArray(c) && c[0] === 'i32.eqz' && c.length === 2) return c[1]
|
|
3407
|
+
if (Array.isArray(c) && c.length === 3 && ROT_NEG[c[0]]) return [ROT_NEG[c[0]], c[1], c[2]]
|
|
3408
|
+
return ['i32.eqz', c]
|
|
3409
|
+
}
|
|
3410
|
+
const targetsLabel = (n, label) => {
|
|
3411
|
+
if (!Array.isArray(n)) return false
|
|
3412
|
+
const op = n[0]
|
|
3413
|
+
if (op === 'br' || op === 'br_if') { if (n[1] === label) return true }
|
|
3414
|
+
else if (op === 'br_table') { for (let i = 1; i < n.length; i++) if (n[i] === label) return true }
|
|
3415
|
+
for (let i = 1; i < n.length; i++) if (targetsLabel(n[i], label)) return true
|
|
3416
|
+
return false
|
|
3417
|
+
}
|
|
3418
|
+
const hasV128 = (n) => Array.isArray(n) && (
|
|
3419
|
+
(typeof n[0] === 'string' && (n[0].startsWith('v128.') || /^[if]\d+x\d+\./.test(n[0]))) ||
|
|
3420
|
+
n.some((c, i) => i > 0 && hasV128(c)))
|
|
3421
|
+
|
|
3422
|
+
const tryRotate = (blk) => {
|
|
3423
|
+
let bi = 1, blockLabel = null
|
|
3424
|
+
if (typeof blk[1] === 'string' && blk[1][0] === '$') { blockLabel = blk[1]; bi = 2 }
|
|
3425
|
+
if (!blockLabel) return null
|
|
3426
|
+
// The loop must be the block's final child; LICM may hoist invariant snaps into
|
|
3427
|
+
// a `local.set` pre-header before it — keep those ahead of the guard (the guard
|
|
3428
|
+
// condition can read them). Bail on anything else (typed blocks, side computations).
|
|
3429
|
+
const preamble = []
|
|
3430
|
+
let loop = null
|
|
3431
|
+
for (let i = bi; i < blk.length; i++) {
|
|
3432
|
+
const c = blk[i]
|
|
3433
|
+
if (Array.isArray(c) && c[0] === 'loop') { if (loop || i !== blk.length - 1) return null; loop = c }
|
|
3434
|
+
else if (Array.isArray(c) && c[0] === 'local.set' && !loop) preamble.push(c)
|
|
3435
|
+
else return null
|
|
3436
|
+
}
|
|
3437
|
+
if (!loop) return null
|
|
3438
|
+
let li = 1, loopLabel = null
|
|
3439
|
+
if (typeof loop[1] === 'string' && loop[1][0] === '$') { loopLabel = loop[1]; li = 2 }
|
|
3440
|
+
if (!loopLabel) return null
|
|
3441
|
+
const loopHeader = []
|
|
3442
|
+
while (li < loop.length) {
|
|
3443
|
+
const c = loop[li]
|
|
3444
|
+
if (Array.isArray(c) && c[0] === 'type') { loopHeader.push(c); li++; continue }
|
|
3445
|
+
if (Array.isArray(c) && (c[0] === 'param' || c[0] === 'result')) return null
|
|
3446
|
+
break
|
|
3447
|
+
}
|
|
3448
|
+
const body = loop.slice(li)
|
|
3449
|
+
if (body.length < 2) return null
|
|
3450
|
+
const head = body[0], tail = body[body.length - 1]
|
|
3451
|
+
if (!(Array.isArray(head) && head[0] === 'br_if' && head[1] === blockLabel && head.length === 3)) return null
|
|
3452
|
+
if (!(Array.isArray(tail) && tail[0] === 'br' && tail[1] === loopLabel && tail.length === 2)) return null
|
|
3453
|
+
const inner = body.slice(1, -1)
|
|
3454
|
+
if (inner.some((s) => targetsLabel(s, loopLabel))) return null // continue → loop top: unsafe
|
|
3455
|
+
if (hasV128(head) || inner.some(hasV128)) return null // vectorized: leave tight
|
|
3456
|
+
const cond = head[2]
|
|
3457
|
+
return ['block', blockLabel, ...preamble,
|
|
3458
|
+
['br_if', blockLabel, clone(cond)],
|
|
3459
|
+
['loop', loopLabel, ...loopHeader, ...inner, ['br_if', loopLabel, negate(cond)]]]
|
|
3460
|
+
}
|
|
3461
|
+
|
|
3462
|
+
// Rotate a (block …) at container[i] in place, else descend. Returns true if it fired.
|
|
3463
|
+
const tryAt = (container, i) => {
|
|
3464
|
+
const c = container[i]
|
|
3465
|
+
if (!Array.isArray(c) || c[0] !== 'block') return false
|
|
3466
|
+
const rot = tryRotate(c)
|
|
3467
|
+
if (!rot) return false
|
|
3468
|
+
container[i] = rot
|
|
3469
|
+
walk(rot)
|
|
3470
|
+
return true
|
|
3471
|
+
}
|
|
3472
|
+
const walk = (node) => {
|
|
3473
|
+
if (!Array.isArray(node)) return
|
|
3474
|
+
for (let i = 0; i < node.length; i++) if (!tryAt(node, i)) walk(node[i])
|
|
3475
|
+
}
|
|
3476
|
+
// Top-level statements (a loop block can BE fn[i], not just nested under one).
|
|
3477
|
+
for (let i = bodyStart; i < fn.length; i++) if (!tryAt(fn, i)) walk(fn[i])
|
|
3478
|
+
}
|
|
3479
|
+
|
|
2715
3480
|
// The i32 form of an integer-valued f64 expression, or null. Used to push ToInt32
|
|
2716
3481
|
// through a conditional and to collapse the f64 round-trip on integer `+`/`-`.
|
|
2717
3482
|
// Lossless by construction: `convert_i32(X) → X`; integer `f64.const → i32.const`
|
|
@@ -2732,6 +3497,17 @@ function toI32(n) {
|
|
|
2732
3497
|
const a = toI32(n[1]), b = toI32(n[2])
|
|
2733
3498
|
if (a && b) return [op === 'f64.add' ? 'i32.add' : 'i32.sub', a, b]
|
|
2734
3499
|
}
|
|
3500
|
+
// ToInt32 distributes through a conditional: ToInt32(if C A B) == if(result i32) C
|
|
3501
|
+
// ToInt32(A) ToInt32(B). Recursive — a nested integer `?:` like `((3<a)?(2&a):((7<a)?a:1))|0`
|
|
3502
|
+
// narrows whole to i32 (each arm folded by toI32, incl. nested ifs), so the lane vectorizer
|
|
3503
|
+
// lifts it as i32x4 bitselect instead of bailing on the f64 result. Only reached from
|
|
3504
|
+
// ToInt32 sinks (the select idiom / toI32 recursion), so the i32 result is always wanted.
|
|
3505
|
+
if (op === 'if' && Array.isArray(n[1]) && n[1][0] === 'result' && n[1][1] === 'f64'
|
|
3506
|
+
&& Array.isArray(n[3]) && n[3][0] === 'then' && n[3].length === 2
|
|
3507
|
+
&& Array.isArray(n[4]) && n[4][0] === 'else' && n[4].length === 2) {
|
|
3508
|
+
const t = toI32(n[3][1]), e = toI32(n[4][1])
|
|
3509
|
+
if (t && e) return ['if', ['result', 'i32'], n[2], ['then', t], ['else', e]]
|
|
3510
|
+
}
|
|
2735
3511
|
return null
|
|
2736
3512
|
}
|
|
2737
3513
|
|
|
@@ -2771,18 +3547,38 @@ function fusedRewrite(fn, counts) {
|
|
|
2771
3547
|
}
|
|
2772
3548
|
const freshI64 = () => { const n = `$__eqt${scratchN++}`; newDecls.push(['local', n, 'i64']); return n }
|
|
2773
3549
|
const freshF64 = () => { const n = `$__eqf${scratchN++}`; newDecls.push(['local', n, 'f64']); return n }
|
|
3550
|
+
// Single-textual-def locals → their defining value node, so the trunc_sat range fold (below)
|
|
3551
|
+
// can see through the temps inlining introduces when proving an index/packed value fits i32.
|
|
3552
|
+
// Multi-def (incl. loop-carried self-referential) locals are excluded: their value is not the
|
|
3553
|
+
// one def's, so its range wouldn't bound them. Pure read of the IR — value-preserving rewrites
|
|
3554
|
+
// during this same walk keep the captured def's RANGE intact, so a lazily-built map stays sound.
|
|
3555
|
+
// Built on first query only (most functions carry no guarded-trunc form → zero cost).
|
|
3556
|
+
let defVal
|
|
3557
|
+
const get = (name) => {
|
|
3558
|
+
if (defVal === undefined) {
|
|
3559
|
+
defVal = new Map(); const defCnt = new Map()
|
|
3560
|
+
const scanDefs = (n) => {
|
|
3561
|
+
if (!Array.isArray(n)) return
|
|
3562
|
+
if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') { defCnt.set(n[1], (defCnt.get(n[1]) || 0) + 1); defVal.set(n[1], n[2]) }
|
|
3563
|
+
for (let i = 1; i < n.length; i++) scanDefs(n[i])
|
|
3564
|
+
}
|
|
3565
|
+
for (let i = bodyStart; i < fn.length; i++) scanDefs(fn[i])
|
|
3566
|
+
for (const [k, c] of defCnt) if (c > 1) defVal.delete(k)
|
|
3567
|
+
}
|
|
3568
|
+
return defVal.get(name) || null
|
|
3569
|
+
}
|
|
2774
3570
|
for (let i = bodyStart; i < fn.length; i++) {
|
|
2775
3571
|
const c = fn[i]
|
|
2776
|
-
if (Array.isArray(c)) fn[i] = walkRewrite(c, !skipInline, counts, freshI64, freshF64)
|
|
3572
|
+
if (Array.isArray(c)) fn[i] = walkRewrite(c, !skipInline, counts, freshI64, freshF64, get)
|
|
2777
3573
|
}
|
|
2778
3574
|
if (newDecls.length) fn.splice(bodyStart, 0, ...newDecls)
|
|
2779
3575
|
}
|
|
2780
3576
|
|
|
2781
|
-
function walkRewrite(node, doInline, counts, freshI64, freshF64) {
|
|
3577
|
+
function walkRewrite(node, doInline, counts, freshI64, freshF64, get) {
|
|
2782
3578
|
if (!Array.isArray(node)) return node
|
|
2783
3579
|
for (let i = 0; i < node.length; i++) {
|
|
2784
3580
|
const c = node[i]
|
|
2785
|
-
if (Array.isArray(c)) node[i] = walkRewrite(c, doInline, counts, freshI64, freshF64)
|
|
3581
|
+
if (Array.isArray(c)) node[i] = walkRewrite(c, doInline, counts, freshI64, freshF64, get)
|
|
2786
3582
|
}
|
|
2787
3583
|
const op = node[0]
|
|
2788
3584
|
// Piggyback local-ref counting for sortLocalsByUse. `counts` may be undefined
|
|
@@ -2963,16 +3759,20 @@ function walkRewrite(node, doInline, counts, freshI64, freshF64) {
|
|
|
2963
3759
|
if (Array.isArray(v) && v[0] === 'i32.wrap_i64' && Array.isArray(v[1]) && v[1][0] === 'i64.trunc_sat_f64_s' && v[1].length === 2) {
|
|
2964
3760
|
let inner = v[1][1]
|
|
2965
3761
|
if (Array.isArray(inner) && inner[0] === 'local.tee' && inner.length === 3) inner = inner[2]
|
|
2966
|
-
// ToInt32(
|
|
2967
|
-
|
|
2968
|
-
&& Array.isArray(inner[3]) && inner[3][0] === 'then' && inner[3].length === 2
|
|
2969
|
-
&& Array.isArray(inner[4]) && inner[4][0] === 'else' && inner[4].length === 2) {
|
|
2970
|
-
const t = toI32(inner[3][1]), e = toI32(inner[4][1])
|
|
2971
|
-
if (t && e) return ['if', ['result', 'i32'], inner[2], ['then', t], ['else', e]]
|
|
2972
|
-
}
|
|
2973
|
-
// ToInt32(integer-valued f64 expr) → its i32 form (covers (i32±i32)|0 sums and nests).
|
|
3762
|
+
// ToInt32(integer-valued f64 expr) → its i32 form: covers (i32±i32)|0 sums AND the
|
|
3763
|
+
// conditional `?:` (toI32 distributes through `(if result f64)`, recursively).
|
|
2974
3764
|
const i = toI32(inner)
|
|
2975
3765
|
if (i) return i
|
|
3766
|
+
// Range fallback for the NON-integer-ring values toI32 rejects (`floor(scale·v)`,
|
|
3767
|
+
// `base + scale·v` — every grid/lattice/colour index): when the def chain — resolved
|
|
3768
|
+
// through single-def inlining temps via `get` — provably yields a finite i32-range value,
|
|
3769
|
+
// the +∞ guard is dead AND trunc_sat can't saturate, so the whole guarded select collapses
|
|
3770
|
+
// to one `i32.trunc_sat_f64_s`. SOUND: f64Range admits only pure nodes and proves
|
|
3771
|
+
// finiteness (kills the guard) + in-range (kills saturation), so the result is identical
|
|
3772
|
+
// ToInt32 on every value the program can produce. Drops the i64 round-trip + guard on all
|
|
3773
|
+
// runtimes (this is the post-inline twin of the emit-time fold at ir.js toI32).
|
|
3774
|
+
const rng = f64Range(inner, get)
|
|
3775
|
+
if (rng && rng.lo >= I32_MIN && rng.hi <= I32_MAX) return ['i32.trunc_sat_f64_s', inner]
|
|
2976
3776
|
}
|
|
2977
3777
|
}
|
|
2978
3778
|
// (i32.or X 0) / (i32.or 0 X) → X — drops the redundant source-level `|0` clamp left
|
|
@@ -2983,6 +3783,30 @@ function walkRewrite(node, doInline, counts, freshI64, freshF64) {
|
|
|
2983
3783
|
if (Array.isArray(a) && a[0] === 'i32.const' && a[1] === 0) return b
|
|
2984
3784
|
}
|
|
2985
3785
|
|
|
3786
|
+
// if→select for a value-producing f64 `if` with PURE arms: (if (result f64) COND (then A)
|
|
3787
|
+
// (else B)) → (select A B COND). This is the branchless `cmov` lowering LLVM/clang apply to
|
|
3788
|
+
// every `cond ? a : b` — it removes the conditional branch (and its misprediction cost on
|
|
3789
|
+
// data-unpredictable conditions) on the whole class of float sign/clamp/reflect ternaries.
|
|
3790
|
+
// The flagship: noise's gradient `(h & 1) === 0 ? x : -x` (8 per perlin × 5 octaves × 65k px).
|
|
3791
|
+
// SOUND: wasm `select` evaluates BOTH arms unconditionally, and `isPureIR` admits only
|
|
3792
|
+
// side-effect-free, non-trapping ops (no load/call/div/rem) — so eager evaluation is safe; it
|
|
3793
|
+
// is the exact predicate emit.js uses for the same fold at emit time, now applied post-watr
|
|
3794
|
+
// where the arms (e.g. `f64.neg (local.get $x)`) are clean after canon-DCE. Gated to NOT fire
|
|
3795
|
+
// when BOTH arms are i32-narrowable — those stay an `if` for the ToInt32-through-if fold +
|
|
3796
|
+
// the i32x4-bitselect conditional-map vectorizer (don't steal the integer path).
|
|
3797
|
+
if (op === 'if' && node.length === 5 && Array.isArray(node[1]) && node[1][0] === 'result' && node[1][1] === 'f64'
|
|
3798
|
+
&& Array.isArray(node[3]) && node[3][0] === 'then' && node[3].length === 2
|
|
3799
|
+
&& Array.isArray(node[4]) && node[4][0] === 'else' && node[4].length === 2) {
|
|
3800
|
+
const a = node[3][1], b = node[4][1], cond = node[2]
|
|
3801
|
+
// The COND must also be pure: `if` evaluates cond FIRST then one arm, but wasm `select`
|
|
3802
|
+
// evaluates its arms BEFORE the cond. A short-circuit lowering like `a || b` =
|
|
3803
|
+
// `(if (result f64) is_truthy(local.tee $t a) (then get $t)(else b))` hides a `tee` in the
|
|
3804
|
+
// cond that the then-arm reads — reordering it after the arms reads $t stale. Requiring
|
|
3805
|
+
// isPureIR(cond) excludes every tee/call/short-circuit cond while admitting the pure
|
|
3806
|
+
// comparison conds of real float ternaries (noise's `(h & 1) === 0`).
|
|
3807
|
+
if (isPureIR(a) && isPureIR(b) && isPureIR(cond) && !(toI32(a) && toI32(b))) return ['select', a, b, cond]
|
|
3808
|
+
}
|
|
3809
|
+
|
|
2986
3810
|
// f64.CMP(convert_i32 A, convert_i32 B) → i32.CMP(A, B). Comparing two i32 values is
|
|
2987
3811
|
// identical whether done in exact f64 or in i32 (the converts are lossless and
|
|
2988
3812
|
// order-preserving), so an integer comparison over typed-array loads (reads are f64)
|