jz 0.6.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 +99 -72
- package/bench/README.md +253 -100
- package/bench/bench.svg +58 -81
- package/cli.js +85 -12
- package/dist/interop.js +1 -0
- package/dist/jz.js +6196 -0
- package/index.d.ts +126 -0
- package/index.js +222 -35
- package/interop.js +240 -141
- package/layout.js +34 -18
- package/module/array.js +99 -111
- package/module/collection.js +124 -30
- package/module/console.js +1 -1
- package/module/core.js +163 -19
- package/module/json.js +3 -3
- package/module/math.js +162 -3
- package/module/number.js +268 -13
- package/module/object.js +37 -5
- package/module/regex.js +8 -7
- package/module/simd.js +37 -5
- package/module/string.js +203 -168
- package/module/typedarray.js +565 -112
- package/package.json +26 -7
- package/src/abi/string.js +29 -29
- package/src/ast.js +19 -2
- package/src/autoload.js +3 -0
- package/src/compile/analyze-scans.js +174 -11
- package/src/compile/analyze.js +101 -4
- package/src/compile/cse-load.js +200 -0
- package/src/compile/emit-assign.js +82 -20
- package/src/compile/emit.js +592 -51
- package/src/compile/index.js +504 -89
- package/src/compile/infer.js +36 -1
- package/src/compile/loop-divmod.js +109 -0
- package/src/compile/loop-model.js +91 -0
- package/src/compile/loop-square.js +102 -0
- package/src/compile/narrow.js +275 -41
- package/src/compile/peel-stencil.js +215 -0
- package/src/compile/plan/advise.js +55 -1
- package/src/compile/plan/common.js +29 -0
- package/src/compile/plan/index.js +8 -1
- package/src/compile/plan/inline.js +180 -22
- package/src/compile/plan/literals.js +313 -24
- package/src/compile/plan/scope.js +115 -39
- package/src/compile/program-facts.js +21 -2
- package/src/ctx.js +96 -19
- package/src/helper-counters.js +137 -0
- package/src/ir.js +157 -20
- package/src/kind-traits.js +34 -3
- package/src/kind.js +79 -4
- package/src/op-policy.js +5 -2
- package/src/ops.js +119 -0
- package/src/optimize/index.js +1274 -151
- package/src/optimize/recurse.js +182 -0
- package/src/optimize/vectorize.js +4187 -253
- package/src/prepare/index.js +75 -14
- package/src/prepare/lift-iife.js +149 -0
- package/src/reps.js +6 -2
- package/src/static.js +9 -0
- package/src/type.js +63 -51
- package/src/wat/assemble.js +286 -21
- package/src/widen.js +21 -0
- package/src/wat/optimize.js +0 -3760
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()
|
|
@@ -59,14 +61,14 @@ const FALSE_BITS = atomNanHex(4)
|
|
|
59
61
|
* 3 — level 2 + larger array/hash initial caps + `hoistConstantPool` off
|
|
60
62
|
* (inline `f64.const` over mutable globals); trades size for speed.
|
|
61
63
|
*
|
|
62
|
-
* String
|
|
63
|
-
* knobs; watr is on for
|
|
64
|
-
* 'size'
|
|
65
|
-
*
|
|
66
|
-
* '
|
|
67
|
-
*
|
|
64
|
+
* String presets (the size↔speed tradeoff lives entirely in the unroll/scalar
|
|
65
|
+
* knobs; watr is on for both):
|
|
66
|
+
* 'size' — loop/const unroll + lane vectorization off, tight scalar-replacement
|
|
67
|
+
* caps. Smallest wasm.
|
|
68
|
+
* 'speed' — full nested unroll + lane vectorization (= level 3).
|
|
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,18 +150,30 @@ 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 }),
|
|
147
|
-
// '
|
|
148
|
-
|
|
153
|
+
3: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8, reduceUnroll: true, relaxedSimd: true, inlineFns: true, rotateLoops: true }),
|
|
154
|
+
// 'size' tightens scalar/unroll caps; 'speed' = level 3. There is no 'balanced'
|
|
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,
|
|
155
165
|
}),
|
|
156
166
|
// 'speed' === level 3: full watr (inlining on) + L3 cap/hash tuning, pool off.
|
|
157
|
-
|
|
167
|
+
// reduceUnroll: vectorize reductions with N independent accumulators (ILP/latency
|
|
168
|
+
// hiding, ~3x on dot/FIR sums) — a size↔speed trade like the pool-off above, so
|
|
169
|
+
// speed-only; level 2 / balanced / size keep the single-accumulator reduce.
|
|
170
|
+
// relaxedSimd: fold f64x2 dot-pairs to f64x2.relaxed_madd (single fused VFMADD,
|
|
171
|
+
// one rounding) — faster + more accurate, but the fused result diverges bit-for-bit
|
|
172
|
+
// from the non-fused JS/native reference (bench `fma` parity class). speed-only.
|
|
173
|
+
// (The stencil + outer-strip vectorizers are NOT level-gated here: they're bit-exact pure wins
|
|
174
|
+
// like the base lane vectorizer, so they run whenever it does — default-on at level 2+ via
|
|
175
|
+
// `cfg.experimentalStencil !== false` at the call site, not a speed-only size/precision trade.)
|
|
176
|
+
speed: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8, reduceUnroll: true, relaxedSimd: true, inlineFns: true, rotateLoops: true }),
|
|
158
177
|
})
|
|
159
178
|
|
|
160
179
|
/**
|
|
@@ -163,7 +182,7 @@ const LEVEL_PRESETS = Object.freeze({
|
|
|
163
182
|
* resolveOptimize(undefined | true) → level 2 stable defaults
|
|
164
183
|
* resolveOptimize(false | 0) → all off
|
|
165
184
|
* resolveOptimize(1 | 2 | 3) → preset for that level
|
|
166
|
-
* resolveOptimize('size' | 'speed'
|
|
185
|
+
* resolveOptimize('size' | 'speed') → named preset ('speed' = level 3)
|
|
167
186
|
* resolveOptimize({ level: 1, watr: true }) → level 1 base, with watr forced on
|
|
168
187
|
* resolveOptimize({ level: 'size', vectorizeLaneLocal: true }) → 'size' base, override
|
|
169
188
|
* resolveOptimize({ hoistAddrBase: false }) → level 2 base, hoistAddrBase off
|
|
@@ -192,6 +211,11 @@ export function resolveOptimize(opt) {
|
|
|
192
211
|
}
|
|
193
212
|
// Preserve non-pass tuning keys (e.g. plan.js thresholds)
|
|
194
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 }
|
|
195
219
|
return out
|
|
196
220
|
}
|
|
197
221
|
return { ...ALL_ON }
|
|
@@ -390,12 +414,39 @@ function regionTrackCSE(fn, { matchSite, localPrefix, localType }) {
|
|
|
390
414
|
* Must run AFTER fusedRewrite — relies on shl-distribution + assoc-lift +
|
|
391
415
|
* foldMemargOffsets having normalized the base shape.
|
|
392
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
|
+
|
|
393
444
|
export function hoistAddrBase(fn) {
|
|
394
445
|
return regionTrackCSE(fn, {
|
|
395
446
|
matchSite(node) {
|
|
396
447
|
if (node[0] !== 'i32.add' || node.length !== 3) return null
|
|
397
448
|
const a = node[1], b = node[2]
|
|
398
|
-
// Two orderings: (add (get A) (shl
|
|
449
|
+
// Two orderings: (add (get A) (shl IDX (const K))) or (add (shl …) (get A))
|
|
399
450
|
let baseGet, shlNode
|
|
400
451
|
if (Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' &&
|
|
401
452
|
Array.isArray(b) && b[0] === 'i32.shl' && b.length === 3) {
|
|
@@ -405,15 +456,88 @@ export function hoistAddrBase(fn) {
|
|
|
405
456
|
baseGet = b; shlNode = a
|
|
406
457
|
} else return null
|
|
407
458
|
const idx = shlNode[1], shamt = shlNode[2]
|
|
408
|
-
if (!Array.isArray(idx) || idx[0] !== 'local.get' || typeof idx[1] !== 'string') return null
|
|
409
459
|
if (!Array.isArray(shamt) || shamt[0] !== 'i32.const' || typeof shamt[1] !== 'number') return null
|
|
410
|
-
|
|
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] }
|
|
411
466
|
},
|
|
412
467
|
localPrefix: 'ab',
|
|
413
468
|
localType: 'i32',
|
|
414
469
|
})
|
|
415
470
|
}
|
|
416
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
|
+
|
|
417
541
|
/**
|
|
418
542
|
* Hoist `(call $__ptr_offset (local.get $X))` to a function-entry snapshot
|
|
419
543
|
* when X is an f64-NaN-boxed parameter that's never reassigned and only ever
|
|
@@ -433,6 +557,38 @@ export function hoistAddrBase(fn) {
|
|
|
433
557
|
// loop-invariant __jss_length in the same loop condition CAN hoist).
|
|
434
558
|
const SAFE_OFFSET_CALLS = new Set(['$__ptr_offset', '$__ptr_type', '$__ptr_aux', '$__len', '$__jss_length', '$__jss_charCodeAt'])
|
|
435
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
|
+
|
|
574
|
+
// Calls that don't modify EXISTING heap memory: they may allocate (bump the heap
|
|
575
|
+
// pointer) or do tag dispatch, but they never write to an address a hoisted
|
|
576
|
+
// __typed_idx/__str_idx element read would revisit. Their presence must not
|
|
577
|
+
// block readonly-mem-call LICM (else any `s += unknown` — which dispatches via
|
|
578
|
+
// __is_str_key/__str_concat — would pin every invariant array element in-loop:
|
|
579
|
+
// the jagged-array `grid[i][j]` deopt).
|
|
580
|
+
const NON_MUTATING_CALLS = new Set(['$__is_str_key', '$__str_concat', '$__to_num', '$__to_str', '$__str_byteLen'])
|
|
581
|
+
|
|
582
|
+
// Read-only HEAP-MEMORY calls: like SAFE_OFFSET_CALLS but they read element
|
|
583
|
+
// storage that a direct f64.store/i32.store in the loop could alias. Safe to
|
|
584
|
+
// hoist only when the loop has no mutating call AND no direct store at all (we
|
|
585
|
+
// can't do alias analysis at WAT level). __typed_idx/__str_idx read arr[i] /
|
|
586
|
+
// s[i]; plain-array element writes go through calls (caught by hasUnsafeCall),
|
|
587
|
+
// and typed-array writes are direct stores (caught by hasDirectStore) — so the
|
|
588
|
+
// guard covers both. This is what lets LICM hoist `grid[i]` out of a read-only
|
|
589
|
+
// `for(j) { ... grid[i][j] ... }` inner loop (the jagged-array deopt).
|
|
590
|
+
const READONLY_MEM_CALLS = new Set(['$__typed_idx', '$__str_idx'])
|
|
591
|
+
|
|
436
592
|
export function hoistInvariantPtrOffset(fn) {
|
|
437
593
|
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
438
594
|
const bodyStart = findBodyStart(fn)
|
|
@@ -550,6 +706,22 @@ const HARD_OPS = new Set([
|
|
|
550
706
|
])
|
|
551
707
|
const hasHardOp = (n) => Array.isArray(n) && (HARD_OPS.has(n[0]) || n.some((c, i) => i > 0 && hasHardOp(c)))
|
|
552
708
|
|
|
709
|
+
// The inline typed-array base decode `(i32.wrap_i64 (i64.and (i64.reinterpret_f64
|
|
710
|
+
// (local|global X)) 0xFFFFFFFF))` — what `typedBase` emits for a NaN-boxed pointer.
|
|
711
|
+
// V8's wasm tier does NOT reliably LICM this i64 reinterpret chain, and it carries no
|
|
712
|
+
// HARD_OP, so without this it stays per-element inside the loop. It is the typed-read
|
|
713
|
+
// equivalent of the `__ptr_offset` call (a HARD_OP) that hoistGlobalPtrOffset hoists at
|
|
714
|
+
// function scope; admitting it here also covers a pointer reassigned ELSEWHERE in the
|
|
715
|
+
// function (the ping-pong double-buffer `a = b` in wireworld / any CA), where the base
|
|
716
|
+
// is invariant within each loop but not function-wide.
|
|
717
|
+
const isPtrBaseDecode = (n) =>
|
|
718
|
+
Array.isArray(n) && n[0] === 'i32.wrap_i64' && n.length === 2 &&
|
|
719
|
+
Array.isArray(n[1]) && n[1][0] === 'i64.and' && n[1].length === 3 &&
|
|
720
|
+
Array.isArray(n[1][2]) && n[1][2][0] === 'i64.const' &&
|
|
721
|
+
(typeof n[1][2][1] === 'string' ? Number(n[1][2][1]) : n[1][2][1]) === LAYOUT.OFFSET_MASK &&
|
|
722
|
+
Array.isArray(n[1][1]) && n[1][1][0] === 'i64.reinterpret_f64' && n[1][1].length === 2 &&
|
|
723
|
+
Array.isArray(n[1][1][1]) && (n[1][1][1][0] === 'local.get' || n[1][1][1][0] === 'global.get')
|
|
724
|
+
|
|
553
725
|
const PURE_LICM_OPS = new Set([
|
|
554
726
|
'f64.add', 'f64.sub', 'f64.mul', 'f64.div', 'f64.neg', 'f64.abs', 'f64.sqrt',
|
|
555
727
|
'f64.min', 'f64.max', 'f64.ceil', 'f64.floor', 'f64.trunc', 'f64.nearest', 'f64.copysign',
|
|
@@ -567,6 +739,136 @@ const PURE_LICM_OPS = new Set([
|
|
|
567
739
|
'f64.promote_f32', 'f32.demote_f64', 'select',
|
|
568
740
|
])
|
|
569
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
|
+
|
|
570
872
|
/**
|
|
571
873
|
* Unified loop-invariant code motion. One principle replaces the three former
|
|
572
874
|
* pattern hoists (ToInt32 / __ptr_offset / cell-load): a MAXIMAL pure subtree
|
|
@@ -587,6 +889,160 @@ const PURE_LICM_OPS = new Set([
|
|
|
587
889
|
* watr's shared CSE subtrees, snaps spliced before the loop, decls at bodyStart.
|
|
588
890
|
* Idempotent: re-running sees only `(local.get $__liN)` and finds nothing to do.
|
|
589
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
|
+
|
|
590
1046
|
export function hoistInvariantLoop(fn) {
|
|
591
1047
|
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
592
1048
|
const bodyStart = findBodyStart(fn)
|
|
@@ -613,14 +1069,34 @@ export function hoistInvariantLoop(fn) {
|
|
|
613
1069
|
if (!Array.isArray(node)) return null
|
|
614
1070
|
const op = node[0]
|
|
615
1071
|
if (op === 'select') return resultType(node[1])
|
|
616
|
-
if (op === '
|
|
1072
|
+
if (op === 'if') {
|
|
1073
|
+
// (if (result T) cond (then ...) (else ...)) — type is the result clause.
|
|
1074
|
+
for (let i = 1; i < node.length; i++) {
|
|
1075
|
+
const c = node[i]
|
|
1076
|
+
if (Array.isArray(c) && c[0] === 'result') return c[1]
|
|
1077
|
+
}
|
|
1078
|
+
return null
|
|
1079
|
+
}
|
|
1080
|
+
if (op === 'block') {
|
|
1081
|
+
for (let i = 1; i < node.length; i++) {
|
|
1082
|
+
const c = node[i]
|
|
1083
|
+
if (Array.isArray(c) && c[0] === 'result') return c[1]
|
|
1084
|
+
}
|
|
1085
|
+
return null
|
|
1086
|
+
}
|
|
1087
|
+
if (op === 'call') {
|
|
1088
|
+
// SAFE_OFFSET_CALLS all return i32; READONLY_MEM_CALLS return f64 (NaN-boxed element)
|
|
1089
|
+
if (SAFE_OFFSET_CALLS.has(node[1])) return 'i32'
|
|
1090
|
+
if (READONLY_MEM_CALLS.has(node[1])) return 'f64'
|
|
1091
|
+
return null
|
|
1092
|
+
}
|
|
617
1093
|
if (op === 'local.get' || op === 'local.tee') return localTypes.get(node[1]) ?? null
|
|
618
1094
|
const dot = op.indexOf('.')
|
|
619
1095
|
if (dot < 0) return null
|
|
620
1096
|
// Comparisons and `eqz` yield i32 regardless of operand type (i64.eq, f64.lt,
|
|
621
1097
|
// i64.eqz, …) — so the operand-type prefix would mistype them. Catch first.
|
|
622
1098
|
const m = op.slice(dot + 1)
|
|
623
|
-
if (
|
|
1099
|
+
if (CMP_MANTISSA.has(m)) return 'i32'
|
|
624
1100
|
const p = op.slice(0, dot)
|
|
625
1101
|
if (p === 'i32' || p === 'i64' || p === 'f64' || p === 'f32') return p
|
|
626
1102
|
return null
|
|
@@ -644,29 +1120,23 @@ export function hoistInvariantLoop(fn) {
|
|
|
644
1120
|
const newLocals = []
|
|
645
1121
|
const refcount = buildRefcount(fn)
|
|
646
1122
|
|
|
647
|
-
|
|
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
|
+
|
|
1131
|
+
const processLoop = (loopNode, nested) => {
|
|
648
1132
|
// Inner loops first (bottom-up) — an inner hoist creates a local.get the
|
|
649
|
-
// outer level can hoist further.
|
|
1133
|
+
// outer level can hoist further. Children run in a nested context.
|
|
650
1134
|
for (let i = 1; i < loopNode.length; i++)
|
|
651
|
-
if (Array.isArray(loopNode[i])) processNode(loopNode[i], loopNode, i)
|
|
1135
|
+
if (Array.isArray(loopNode[i])) processNode(loopNode[i], loopNode, i, true)
|
|
652
1136
|
|
|
653
|
-
// The loop's effect summary
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
const scan = (node) => {
|
|
657
|
-
if (!Array.isArray(node)) return
|
|
658
|
-
const op = node[0]
|
|
659
|
-
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 }
|
|
660
|
-
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 }
|
|
661
|
-
if (op === 'call') { hasAnyCall = true; if (!SAFE_OFFSET_CALLS.has(node[1])) hasUnsafeCall = true; for (let i = 2; i < node.length; i++) scan(node[i]); return }
|
|
662
|
-
if (op === 'call_ref' || op === 'call_indirect') { hasAnyCall = hasUnsafeCall = true; for (let i = 1; i < node.length; i++) scan(node[i]); return }
|
|
663
|
-
if ((op === 'f64.store' || op === 'i32.store') && node.length >= 3) {
|
|
664
|
-
const a = node[1]
|
|
665
|
-
if (Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' && a[1].startsWith(CELL_PREFIX)) storedCells.add(a[1])
|
|
666
|
-
}
|
|
667
|
-
for (let i = 1; i < node.length; i++) scan(node[i])
|
|
668
|
-
}
|
|
669
|
-
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 })
|
|
670
1140
|
|
|
671
1141
|
// Per-subtree local-occurrence counts and write-sets, memoized bottom-up —
|
|
672
1142
|
// the tee-privacy check queries them for EVERY candidate node, and the old
|
|
@@ -705,37 +1175,6 @@ export function hoistInvariantLoop(fn) {
|
|
|
705
1175
|
for (let i = 1; i < loopNode.length; i++)
|
|
706
1176
|
for (const [k, v] of countsOf(loopNode[i])) localCount.set(k, (localCount.get(k) || 0) + v)
|
|
707
1177
|
|
|
708
|
-
// Pure & invariant given `bound` (locals written *within* the candidate, hence
|
|
709
|
-
// local to it). A read of a bound local is OK (its in-subtree value is the
|
|
710
|
-
// teed invariant). A free read must be unwritten by the loop.
|
|
711
|
-
const pureGiven = (node, bound) => {
|
|
712
|
-
if (!Array.isArray(node)) return true // bare operand string/number
|
|
713
|
-
const op = node[0]
|
|
714
|
-
if (op === 'i32.const' || op === 'i64.const' || op === 'f64.const' || op === 'f32.const') return true
|
|
715
|
-
if (op === 'local.get') return typeof node[1] === 'string' && (bound.has(node[1]) || !locals.has(node[1]))
|
|
716
|
-
// A global is invariant only if not set directly AND no call in the loop —
|
|
717
|
-
// any callee may mutate it (no interprocedural effect analysis). (Locals are
|
|
718
|
-
// frame-private, so calls can't touch them; only direct local.set matters.)
|
|
719
|
-
if (op === 'global.get') return typeof node[1] === 'string' && !globals.has(node[1]) && !hasAnyCall
|
|
720
|
-
if (op === 'local.tee') {
|
|
721
|
-
if (typeof node[1] !== 'string') return false
|
|
722
|
-
// The operand is evaluated BEFORE the tee writes $X, so a `local.get $X` inside
|
|
723
|
-
// it reads the loop-carried (previous-iteration) value, not the teed one. Drop
|
|
724
|
-
// $X from `bound` for the operand: `local.tee $X (… $X …)` is a loop recurrence
|
|
725
|
-
// (X = f(X) — e.g. the `while ((nn = nn >>> 1))` induction), NOT invariant.
|
|
726
|
-
const inner = bound.has(node[1]) ? new Set([...bound].filter(b => b !== node[1])) : bound
|
|
727
|
-
return pureGiven(node[2], inner)
|
|
728
|
-
}
|
|
729
|
-
if ((op === 'f64.load' || op === 'i32.load') && node.length === 2) {
|
|
730
|
-
const a = node[1]
|
|
731
|
-
return Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' && a[1].startsWith(CELL_PREFIX)
|
|
732
|
-
&& !hasAnyCall && !storedCells.has(a[1]) && (bound.has(a[1]) || !locals.has(a[1]))
|
|
733
|
-
}
|
|
734
|
-
if (op === 'call') return SAFE_OFFSET_CALLS.has(node[1]) && !hasUnsafeCall && node.slice(2).every(c => pureGiven(c, bound))
|
|
735
|
-
if (PURE_LICM_OPS.has(op)) return node.slice(1).every(c => pureGiven(c, bound))
|
|
736
|
-
return false
|
|
737
|
-
}
|
|
738
|
-
|
|
739
1178
|
const isHoistable = (node) => {
|
|
740
1179
|
if (!Array.isArray(node)) return false
|
|
741
1180
|
const op = node[0]
|
|
@@ -745,9 +1184,14 @@ export function hoistInvariantLoop(fn) {
|
|
|
745
1184
|
// Every local the subtree writes must be private to it (no other use in the
|
|
746
1185
|
// loop) — else moving the write to the pre-header changes another reader.
|
|
747
1186
|
for (const b of bound) if (localCount.get(b) !== countsOf(node).get(b)) return false
|
|
748
|
-
//
|
|
749
|
-
//
|
|
750
|
-
|
|
1187
|
+
// Top-level loops: only hoist what V8's wasm tier won't — a HARD_OP or the
|
|
1188
|
+
// inline typed-array base decode — and leave plain pure arithmetic to V8's own
|
|
1189
|
+
// LICM (which handles single-level loops well). NESTED (inner) loops are
|
|
1190
|
+
// different: V8's wasm tier under-hoists invariants out of them (a nested
|
|
1191
|
+
// rasterizer/convolution recomputes triangle/row-invariant subexpressions every
|
|
1192
|
+
// iteration), so hoist any pure-invariant subtree there. Soundness is unchanged —
|
|
1193
|
+
// `pureGiven` already proves the subtree is loop-invariant and side-effect-free.
|
|
1194
|
+
return ((nested && !hasV128) || hasHardOp(node) || isPtrBaseDecode(node)) && pureGiven(node, bound)
|
|
751
1195
|
}
|
|
752
1196
|
|
|
753
1197
|
// Maximal extraction: take the largest hoistable subtree; don't descend into
|
|
@@ -757,9 +1201,10 @@ export function hoistInvariantLoop(fn) {
|
|
|
757
1201
|
if (!Array.isArray(node)) return
|
|
758
1202
|
if (node[0] === 'loop') return // already processed bottom-up
|
|
759
1203
|
if (isHoistable(node) && (refcount.get(node) || 0) <= 1 && (refcount.get(parent) || 0) <= 1) {
|
|
760
|
-
//
|
|
761
|
-
// (BigInt
|
|
762
|
-
|
|
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)
|
|
763
1208
|
let arr = sites.get(key); if (!arr) { arr = []; sites.set(key, arr) }
|
|
764
1209
|
arr.push({ parent, idx, node })
|
|
765
1210
|
return
|
|
@@ -780,17 +1225,17 @@ export function hoistInvariantLoop(fn) {
|
|
|
780
1225
|
return snaps
|
|
781
1226
|
}
|
|
782
1227
|
|
|
783
|
-
const processNode = (node, parent, idx) => {
|
|
1228
|
+
const processNode = (node, parent, idx, nested = false) => {
|
|
784
1229
|
if (!Array.isArray(node)) return
|
|
785
1230
|
if (node[0] === 'loop') {
|
|
786
|
-
const snaps = processLoop(node)
|
|
1231
|
+
const snaps = processLoop(node, nested)
|
|
787
1232
|
if (snaps.length) parent.splice(idx, 0, ...snaps)
|
|
788
1233
|
return
|
|
789
1234
|
}
|
|
790
|
-
for (let i = 0; i < node.length; i++) processNode(node[i], node, i)
|
|
1235
|
+
for (let i = 0; i < node.length; i++) processNode(node[i], node, i, nested)
|
|
791
1236
|
}
|
|
792
1237
|
|
|
793
|
-
for (let i = bodyStart; i < fn.length; i++) processNode(fn[i], fn, i)
|
|
1238
|
+
for (let i = bodyStart; i < fn.length; i++) processNode(fn[i], fn, i, false)
|
|
794
1239
|
if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
|
|
795
1240
|
}
|
|
796
1241
|
|
|
@@ -891,16 +1336,17 @@ export function narrowLoopBound(fn) {
|
|
|
891
1336
|
const newLocals = []
|
|
892
1337
|
const refcount = buildRefcount(fn)
|
|
893
1338
|
|
|
894
|
-
// `(f64.lt (
|
|
895
|
-
// `(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))`.
|
|
896
1341
|
const match = (n) => {
|
|
897
|
-
const
|
|
898
|
-
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
|
|
899
1345
|
if (!Array.isArray(conv) || conv[0] !== 'f64.convert_i32_s') return null
|
|
900
1346
|
const ig = conv[1]
|
|
901
1347
|
if (!Array.isArray(ig) || ig[0] !== 'local.get' || typeof ig[1] !== 'string') return null
|
|
902
1348
|
if (!Array.isArray(bnd) || bnd[0] !== 'local.get' || typeof bnd[1] !== 'string') return null
|
|
903
|
-
return { ctr: ig[1], bound: bnd[1] }
|
|
1349
|
+
return { ctr: ig[1], bound: bnd[1], op: le || ge ? 'le' : 'lt' }
|
|
904
1350
|
}
|
|
905
1351
|
|
|
906
1352
|
const processLoop = (loopNode) => {
|
|
@@ -930,18 +1376,34 @@ export function narrowLoopBound(fn) {
|
|
|
930
1376
|
}
|
|
931
1377
|
for (let i = 1; i < loopNode.length; i++) collect(loopNode[i])
|
|
932
1378
|
|
|
933
|
-
|
|
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()
|
|
934
1382
|
const snaps = []
|
|
1383
|
+
const I32_MIN = -2147483648
|
|
935
1384
|
for (const { node, m } of sites) {
|
|
936
|
-
|
|
1385
|
+
const key = `${m.bound}|${m.op}`
|
|
1386
|
+
let snap = snapFor.get(key)
|
|
937
1387
|
if (!snap) {
|
|
938
1388
|
snap = freshLb()
|
|
939
|
-
snapFor.set(
|
|
1389
|
+
snapFor.set(key, snap)
|
|
940
1390
|
newLocals.push(['local', snap, 'i32'])
|
|
941
|
-
|
|
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]]]])
|
|
942
1403
|
}
|
|
943
1404
|
node.length = 3
|
|
944
|
-
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]
|
|
945
1407
|
}
|
|
946
1408
|
return snaps
|
|
947
1409
|
}
|
|
@@ -1168,6 +1630,19 @@ export function cseScalarLoad(fn) {
|
|
|
1168
1630
|
// the two passes cover deliberately different op sets.
|
|
1169
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'])
|
|
1170
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
|
+
|
|
1171
1646
|
export function csePureExpr(fn) {
|
|
1172
1647
|
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
1173
1648
|
const bodyStart = findBodyStart(fn)
|
|
@@ -1304,16 +1779,15 @@ export function csePureExprLoop(fn) {
|
|
|
1304
1779
|
if (bodyStart < 0) return
|
|
1305
1780
|
|
|
1306
1781
|
let hasLoop = false
|
|
1307
|
-
let
|
|
1782
|
+
let hasExpensiveCall = false
|
|
1308
1783
|
const scanShape = (n) => {
|
|
1309
1784
|
if (!Array.isArray(n)) return
|
|
1310
1785
|
if (n[0] === 'loop') hasLoop = true
|
|
1311
|
-
if (n[0] === 'call' && (n[1]
|
|
1312
|
-
|| 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
|
|
1313
1787
|
for (let i = 1; i < n.length; i++) scanShape(n[i])
|
|
1314
1788
|
}
|
|
1315
1789
|
for (let i = bodyStart; i < fn.length; i++) scanShape(fn[i])
|
|
1316
|
-
if (!hasLoop || !
|
|
1790
|
+
if (!hasLoop || !hasExpensiveCall) return
|
|
1317
1791
|
|
|
1318
1792
|
let snapId = nextLocalId(fn, 'pe')
|
|
1319
1793
|
const newLocals = []
|
|
@@ -1768,23 +2242,52 @@ export function hoistGlobalPtrOffset(fn, stablePtrGlobals, reachableWrites) {
|
|
|
1768
2242
|
const bodyStart = findBodyStart(fn)
|
|
1769
2243
|
if (bodyStart < 0) return
|
|
1770
2244
|
|
|
1771
|
-
// `(
|
|
1772
|
-
const
|
|
1773
|
-
Array.isArray(n) && n[0] === '
|
|
1774
|
-
&& Array.isArray(n[
|
|
1775
|
-
|
|
1776
|
-
|
|
2245
|
+
// `(i64.reinterpret_f64 (global.get $G))` → G, or null.
|
|
2246
|
+
const reintGlobal = (n) =>
|
|
2247
|
+
Array.isArray(n) && n[0] === 'i64.reinterpret_f64'
|
|
2248
|
+
&& Array.isArray(n[1]) && n[1][0] === 'global.get' && typeof n[1][1] === 'string'
|
|
2249
|
+
? n[1][1] : null
|
|
2250
|
+
// A stable-pointee global's byte-base reaches us in two interchangeable shapes:
|
|
2251
|
+
// • forwarding-aware `(call $__ptr_offset (i64.reinterpret_f64 (global.get $G)))`
|
|
2252
|
+
// • inline typed read `(i32.wrap_i64 (i64.and (i64.reinterpret_f64 (global.get $G)) MASK))`
|
|
2253
|
+
// The inline form is what typed-array reads emit (a fixed-size typed array never
|
|
2254
|
+
// relocates, so they skip __ptr_offset's forwarding follow — see module/typedarray.js
|
|
2255
|
+
// `typedBase`). For a never-forwarding pointee both yield the identical offset, so
|
|
2256
|
+
// either site hoists to the one `__ptr_offset` entry snapshot. Matching only the
|
|
2257
|
+
// call form left typed-array globals re-decoding the NaN-box per element in stencil
|
|
2258
|
+
// sweeps (watercolor's pressure solve: 5 reads/cell × millions of cells). → G, or null.
|
|
2259
|
+
const siteGlobal = (n) => {
|
|
2260
|
+
if (!Array.isArray(n)) return null
|
|
2261
|
+
if (n[0] === 'call' && n[1] === '$__ptr_offset' && n.length === 3) return reintGlobal(n[2])
|
|
2262
|
+
if (n[0] === 'i32.wrap_i64' && n.length === 2 && Array.isArray(n[1]) && n[1][0] === 'i64.and' && n[1].length === 3) {
|
|
2263
|
+
const mask = n[1][2]
|
|
2264
|
+
if (Array.isArray(mask) && mask[0] === 'i64.const'
|
|
2265
|
+
&& (typeof mask[1] === 'string' ? Number(mask[1]) : mask[1]) === LAYOUT.OFFSET_MASK)
|
|
2266
|
+
return reintGlobal(n[1][1])
|
|
2267
|
+
}
|
|
2268
|
+
return null
|
|
2269
|
+
}
|
|
1777
2270
|
|
|
1778
2271
|
// Per-global: static site count AND whether any site sits inside a loop. A
|
|
1779
2272
|
// single in-loop site is a per-ITERATION resolve (lenia's convolution reads
|
|
1780
2273
|
// each of kdx/kdy/kw at one site × ~14M taps/frame), so loop placement beats
|
|
1781
2274
|
// site count as the hoist criterion.
|
|
1782
2275
|
const counts = new Map(), inLoop = new Set(), ownWrites = new Set(), ownCallees = new Set()
|
|
2276
|
+
// Globals seen via the `__ptr_offset` call form (vs. only the inline typed mask).
|
|
2277
|
+
// The snapshot reuses an EXISTING form so it never resurrects a treeshaken helper:
|
|
2278
|
+
// a typed-array-only module emits no `__ptr_offset` call, so snapping one in would
|
|
2279
|
+
// reference a function that isn't in the module.
|
|
2280
|
+
const ptrOffsetForm = new Set()
|
|
1783
2281
|
let hasIndirect = false
|
|
1784
2282
|
const scan = (n, loopDepth) => {
|
|
1785
2283
|
if (!Array.isArray(n)) return
|
|
1786
2284
|
const g = siteGlobal(n)
|
|
1787
|
-
if (g != null) {
|
|
2285
|
+
if (g != null) {
|
|
2286
|
+
counts.set(g, (counts.get(g) || 0) + 1)
|
|
2287
|
+
if (loopDepth > 0) inLoop.add(g)
|
|
2288
|
+
if (n[0] === 'call') ptrOffsetForm.add(g)
|
|
2289
|
+
return
|
|
2290
|
+
}
|
|
1788
2291
|
if (n[0] === 'global.set' && typeof n[1] === 'string') ownWrites.add(n[1])
|
|
1789
2292
|
else if ((n[0] === 'call' || n[0] === 'return_call') && typeof n[1] === 'string') ownCallees.add(n[1])
|
|
1790
2293
|
else if (n[0] === 'call_indirect' || n[0] === 'call_ref' || n[0] === 'return_call_indirect') hasIndirect = true
|
|
@@ -1833,7 +2336,13 @@ export function hoistGlobalPtrOffset(fn, stablePtrGlobals, reachableWrites) {
|
|
|
1833
2336
|
const decls = [], snaps = []
|
|
1834
2337
|
for (const [g, name] of chosen) {
|
|
1835
2338
|
decls.push(['local', name, 'i32'])
|
|
1836
|
-
|
|
2339
|
+
// Match an existing site's form so we never reference a treeshaken helper.
|
|
2340
|
+
// For a never-forwarding pointee both forms compute the same offset, so the
|
|
2341
|
+
// inline mask is a safe (and call-free) snapshot when no __ptr_offset site exists.
|
|
2342
|
+
const snap = ptrOffsetForm.has(g)
|
|
2343
|
+
? ['call', '$__ptr_offset', ['i64.reinterpret_f64', ['global.get', g]]]
|
|
2344
|
+
: ['i32.wrap_i64', ['i64.and', ['i64.reinterpret_f64', ['global.get', g]], ['i64.const', LAYOUT.OFFSET_MASK]]]
|
|
2345
|
+
snaps.push(['local.set', name, snap])
|
|
1837
2346
|
}
|
|
1838
2347
|
fn.splice(bodyStart, 0, ...decls, ...snaps)
|
|
1839
2348
|
}
|
|
@@ -1973,15 +2482,8 @@ function inferTypeFromContext(fn, gName, bodyStart) {
|
|
|
1973
2482
|
}
|
|
1974
2483
|
if (pOp === 'i32.store' && idx === 2) { inferred = 'i32'; return } // addr
|
|
1975
2484
|
if (pOp === 'f64.store' && idx === 2) { inferred = 'f64'; return } // addr can be i32, but value is f64
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
pOp === 'i32.ge_s' || pOp === 'i32.ge_u') {
|
|
1979
|
-
// Comparison — could be i32, but in jz NaN-boxing scheme most globals are f64
|
|
1980
|
-
// Only if we can confirm from local.set context
|
|
1981
|
-
}
|
|
1982
|
-
if (pOp === 'local.set' && idx === 0) {
|
|
1983
|
-
// Can't determine local type from here easily
|
|
1984
|
-
}
|
|
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.
|
|
1985
2487
|
}
|
|
1986
2488
|
}
|
|
1987
2489
|
// Default: f64 (the NaN-boxing carrier)
|
|
@@ -2005,22 +2507,33 @@ function inferTypeFromContext(fn, gName, bodyStart) {
|
|
|
2005
2507
|
*
|
|
2006
2508
|
* Mutates `funcs` in place; writes new global decls via `addGlobal(name, constLiteral)`.
|
|
2007
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
|
+
|
|
2008
2520
|
export function hoistConstantPool(funcs, addGlobal) {
|
|
2009
2521
|
const MIN_USES = 2
|
|
2010
2522
|
// Single walk: count occurrences AND record each f64.const site for direct rewrite.
|
|
2011
2523
|
// Avoids a second full-AST traversal in the rewrite phase.
|
|
2012
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()
|
|
2013
2528
|
const sites = [] // { parent, idx, key }
|
|
2014
2529
|
const walk = (node) => {
|
|
2015
2530
|
if (!Array.isArray(node)) return
|
|
2016
2531
|
for (let i = 0; i < node.length; i++) {
|
|
2017
2532
|
const c = node[i]
|
|
2018
2533
|
if (Array.isArray(c) && c[0] === 'f64.const' && (typeof c[1] === 'number' || typeof c[1] === 'string')) {
|
|
2019
|
-
|
|
2020
|
-
const k = typeof c[1] === 'number'
|
|
2021
|
-
? (Object.is(c[1], -0) ? 'n:-0' : `n:${c[1]}`)
|
|
2022
|
-
: `s:${c[1]}`
|
|
2534
|
+
const k = typeof c[1] === 'number' ? f64BitsKey(c[1]) : `s:${c[1]}`
|
|
2023
2535
|
counts.set(k, (counts.get(k) || 0) + 1)
|
|
2536
|
+
if (!exactVal.has(k)) exactVal.set(k, c[1])
|
|
2024
2537
|
sites.push({ parent: node, idx: i, key: k })
|
|
2025
2538
|
}
|
|
2026
2539
|
walk(c)
|
|
@@ -2033,8 +2546,9 @@ export function hoistConstantPool(funcs, addGlobal) {
|
|
|
2033
2546
|
let gId = 0
|
|
2034
2547
|
for (const [k] of sorted) {
|
|
2035
2548
|
const name = `__fc${gId++}`
|
|
2036
|
-
const
|
|
2037
|
-
|
|
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))
|
|
2038
2552
|
hoist.set(k, name)
|
|
2039
2553
|
}
|
|
2040
2554
|
if (!hoist.size) return
|
|
@@ -2142,9 +2656,7 @@ export function specializeMkptr(funcs, addFunc, parseWat) {
|
|
|
2142
2656
|
// $__mkptr inline fast path: bake (type, aux) literals into i64.const template.
|
|
2143
2657
|
if (target === '$__mkptr' && spec.inline && parts[0].startsWith('L:') && parts[1].startsWith('L:')) {
|
|
2144
2658
|
const type = +parts[0].slice(2), aux = +parts[1].slice(2)
|
|
2145
|
-
const tmpl =
|
|
2146
|
-
| ((BigInt(type) & BigInt(LAYOUT.TAG_MASK)) << BigInt(LAYOUT.TAG_SHIFT))
|
|
2147
|
-
| ((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)
|
|
2148
2660
|
// Third arg (offset) may also be literal — emit (f64.const nan:…) then.
|
|
2149
2661
|
if (parts[2].startsWith('L:')) {
|
|
2150
2662
|
// Fully literal: all sites can be f64.const — no helper needed, handled in rewrite below.
|
|
@@ -2186,11 +2698,7 @@ export function specializeMkptr(funcs, addFunc, parseWat) {
|
|
|
2186
2698
|
// $__mkptr fully literal (rare — mkPtrIR usually folds these ahead of us, but defensive):
|
|
2187
2699
|
if (target === '$__mkptr' && parts[0].startsWith('L:') && parts[1].startsWith('L:') && parts[2].startsWith('L:')) {
|
|
2188
2700
|
const type = +parts[0].slice(2), aux = +parts[1].slice(2), off = +parts[2].slice(2)
|
|
2189
|
-
const
|
|
2190
|
-
| ((BigInt(type) & BigInt(LAYOUT.TAG_MASK)) << BigInt(LAYOUT.TAG_SHIFT))
|
|
2191
|
-
| ((BigInt(aux) & BigInt(LAYOUT.AUX_MASK)) << BigInt(LAYOUT.AUX_SHIFT))
|
|
2192
|
-
| (BigInt(off >>> 0) & BigInt(LAYOUT.OFFSET_MASK))
|
|
2193
|
-
const n = ['f64.const', 'nan:0x' + bits.toString(16).toUpperCase().padStart(16, '0')]
|
|
2701
|
+
const n = ['f64.const', 'nan:' + i64Hex(ptrBits(type, aux, off))]
|
|
2194
2702
|
n.type = 'f64'
|
|
2195
2703
|
parent[idx] = n
|
|
2196
2704
|
continue
|
|
@@ -2374,6 +2882,314 @@ export function sortStrPoolByFreq(funcs, strPoolRef, strDedupMap) {
|
|
|
2374
2882
|
}
|
|
2375
2883
|
}
|
|
2376
2884
|
|
|
2885
|
+
/**
|
|
2886
|
+
* Fold dead string-dispatch blocks when the tested operand is a proven-f64 local.
|
|
2887
|
+
*
|
|
2888
|
+
* jz's `+` emitter produces, for every binary addition whose right operand has an
|
|
2889
|
+
* unresolved valType, the pattern:
|
|
2890
|
+
*
|
|
2891
|
+
* (block (result f64)
|
|
2892
|
+
* (local.set $B EXPR_A)
|
|
2893
|
+
* (if (result f64)
|
|
2894
|
+
* (call $__is_str_key (i64.reinterpret_f64 (local.tee $C (local.get $P))))
|
|
2895
|
+
* (then (call $__str_concat …))
|
|
2896
|
+
* (else (f64.add (local.get $B) (local.get $C)))))
|
|
2897
|
+
*
|
|
2898
|
+
* When $P is a proven-f64 local (an f64 param, or an f64-typed local provably set
|
|
2899
|
+
* only from f64 arithmetic) it can never hold a string-key NaN-box, so the
|
|
2900
|
+
* `$__is_str_key` test is provably false and the `then` branch is dead.
|
|
2901
|
+
* Replace the whole block with `(f64.add EXPR_A (local.get $P))`.
|
|
2902
|
+
*
|
|
2903
|
+
* SOUND: f64 params can never hold a string-key NaN-box by construction (jz
|
|
2904
|
+
* only allows strings in f64 slots via explicit mkptr boxing, never a bare
|
|
2905
|
+
* param). This fold is additive/gated (only runs when vectorizeLaneLocal is on)
|
|
2906
|
+
* and only removes provably-dead string-dispatch overhead.
|
|
2907
|
+
*
|
|
2908
|
+
* Called in the 'post' phase of optimizeFunc, before vectorizeLaneLocal, so the
|
|
2909
|
+
* cleaned IR is what the vectorizer pattern-matches.
|
|
2910
|
+
*/
|
|
2911
|
+
export function foldStrDispatchF64(fn) {
|
|
2912
|
+
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
2913
|
+
const bodyStart = findBodyStart(fn)
|
|
2914
|
+
if (bodyStart < 0) return
|
|
2915
|
+
|
|
2916
|
+
// Collect all f64 params — provably never hold a string-key NaN-box.
|
|
2917
|
+
const rawF64 = new Set()
|
|
2918
|
+
for (let i = 2; i < bodyStart; i++) {
|
|
2919
|
+
const d = fn[i]
|
|
2920
|
+
if (Array.isArray(d) && d[0] === 'param' && typeof d[1] === 'string' && d[2] === 'f64')
|
|
2921
|
+
rawF64.add(d[1])
|
|
2922
|
+
}
|
|
2923
|
+
if (!rawF64.size) return // no f64 params → nothing to fold
|
|
2924
|
+
|
|
2925
|
+
// Transitively extend rawF64: an f64 local set only via f64 arithmetic over rawF64
|
|
2926
|
+
// members is itself provably non-string. One forward pass suffices for DAG-shaped
|
|
2927
|
+
// straight-line code (the common case); a fixed-point loop covers rare mutual defs.
|
|
2928
|
+
// Collect local types first.
|
|
2929
|
+
const localTypeMap = new Map()
|
|
2930
|
+
for (let i = 2; i < bodyStart; i++) {
|
|
2931
|
+
const d = fn[i]
|
|
2932
|
+
if (Array.isArray(d) && (d[0] === 'param' || d[0] === 'local') && typeof d[1] === 'string')
|
|
2933
|
+
localTypeMap.set(d[1], d[2])
|
|
2934
|
+
}
|
|
2935
|
+
// An expression is rawF64-valued if it only uses ops that stay in f64 and
|
|
2936
|
+
// reads only rawF64 locals (or f64.const). Stops early — we only need the
|
|
2937
|
+
// closed set for the pattern's $P operand.
|
|
2938
|
+
const isRawF64Expr = (n) => {
|
|
2939
|
+
if (!Array.isArray(n)) return false
|
|
2940
|
+
const op = n[0]
|
|
2941
|
+
if (op === 'f64.const') return true
|
|
2942
|
+
if (op === 'local.get' && typeof n[1] === 'string') return rawF64.has(n[1])
|
|
2943
|
+
if (op === 'local.tee' && typeof n[1] === 'string') return rawF64.has(n[1]) && isRawF64Expr(n[2])
|
|
2944
|
+
if (op === 'f64.add' || op === 'f64.sub' || op === 'f64.mul' || op === 'f64.div' ||
|
|
2945
|
+
op === 'f64.neg' || op === 'f64.abs' || op === 'f64.sqrt') {
|
|
2946
|
+
return n.slice(1).every(isRawF64Expr)
|
|
2947
|
+
}
|
|
2948
|
+
return false
|
|
2949
|
+
}
|
|
2950
|
+
|
|
2951
|
+
// Single forward pass: a local.set $v EXPR where EXPR is rawF64-valued makes $v rawF64.
|
|
2952
|
+
// Repeat until stable (handles ordering edge cases in non-DAG code).
|
|
2953
|
+
let changed = true
|
|
2954
|
+
while (changed) {
|
|
2955
|
+
changed = false
|
|
2956
|
+
const scan = (node) => {
|
|
2957
|
+
if (!Array.isArray(node)) return
|
|
2958
|
+
if ((node[0] === 'local.set' || node[0] === 'local.tee') && typeof node[1] === 'string' &&
|
|
2959
|
+
localTypeMap.get(node[1]) === 'f64' && !rawF64.has(node[1]) && isRawF64Expr(node[2])) {
|
|
2960
|
+
rawF64.add(node[1]); changed = true
|
|
2961
|
+
}
|
|
2962
|
+
for (let i = 1; i < node.length; i++) scan(node[i])
|
|
2963
|
+
}
|
|
2964
|
+
for (let i = bodyStart; i < fn.length; i++) scan(fn[i])
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
// Pattern-match and fold in-place (bottom-up recursive walk so nested blocks resolve).
|
|
2968
|
+
const foldNode = (node) => {
|
|
2969
|
+
if (!Array.isArray(node)) return node
|
|
2970
|
+
// Recurse children first (bottom-up).
|
|
2971
|
+
for (let i = 0; i < node.length; i++) {
|
|
2972
|
+
const c = node[i]
|
|
2973
|
+
if (Array.isArray(c)) node[i] = foldNode(c)
|
|
2974
|
+
}
|
|
2975
|
+
// Match:
|
|
2976
|
+
// ['block', ['result','f64'],
|
|
2977
|
+
// ['local.set', $B, EXPR_A],
|
|
2978
|
+
// ['if', ['result','f64'],
|
|
2979
|
+
// ['call','$__is_str_key', ['i64.reinterpret_f64', ['local.tee',$C,['local.get',$P]]]],
|
|
2980
|
+
// ['then', ['call','$__str_concat',...]],
|
|
2981
|
+
// ['else', ['f64.add', ['local.get',$B], ['local.get',$C]]]]]
|
|
2982
|
+
if (node[0] !== 'block') return node
|
|
2983
|
+
if (!Array.isArray(node[1]) || node[1][0] !== 'result' || node[1][1] !== 'f64') return node
|
|
2984
|
+
if (node.length !== 4) return node
|
|
2985
|
+
const setStmt = node[2], ifStmt = node[3]
|
|
2986
|
+
if (!Array.isArray(setStmt) || setStmt[0] !== 'local.set' || typeof setStmt[1] !== 'string') return node
|
|
2987
|
+
const B = setStmt[1], exprA = setStmt[2]
|
|
2988
|
+
if (!Array.isArray(ifStmt) || ifStmt[0] !== 'if') return node
|
|
2989
|
+
// if must have: (result f64), cond, then, else — total 5 elements
|
|
2990
|
+
if (ifStmt.length !== 5) return node
|
|
2991
|
+
if (!Array.isArray(ifStmt[1]) || ifStmt[1][0] !== 'result' || ifStmt[1][1] !== 'f64') return node
|
|
2992
|
+
const cond = ifStmt[2], thenB = ifStmt[3], elseB = ifStmt[4]
|
|
2993
|
+
// cond: ['call','$__is_str_key',['i64.reinterpret_f64',['local.tee',$C,['local.get',$P]]]]
|
|
2994
|
+
if (!Array.isArray(cond) || cond[0] !== 'call' || cond[1] !== '$__is_str_key' || cond.length !== 3) return node
|
|
2995
|
+
const reinterpArg = cond[2]
|
|
2996
|
+
if (!Array.isArray(reinterpArg) || reinterpArg[0] !== 'i64.reinterpret_f64' || reinterpArg.length !== 2) return node
|
|
2997
|
+
const teeNode = reinterpArg[1]
|
|
2998
|
+
if (!Array.isArray(teeNode) || teeNode[0] !== 'local.tee' || typeof teeNode[1] !== 'string' || teeNode.length !== 3) return node
|
|
2999
|
+
const C = teeNode[1]
|
|
3000
|
+
const getP = teeNode[2]
|
|
3001
|
+
if (!Array.isArray(getP) || getP[0] !== 'local.get' || typeof getP[1] !== 'string') return node
|
|
3002
|
+
const P = getP[1]
|
|
3003
|
+
// $P must be a proven f64 local (never a string-key NaN-box)
|
|
3004
|
+
if (!rawF64.has(P)) return node
|
|
3005
|
+
// then: ['then', ['call','$__str_concat',...]]
|
|
3006
|
+
if (!Array.isArray(thenB) || thenB[0] !== 'then') return node
|
|
3007
|
+
// else: ['else', ['f64.add', ['local.get',$B], ['local.get',$C]]]
|
|
3008
|
+
if (!Array.isArray(elseB) || elseB[0] !== 'else' || elseB.length !== 2) return node
|
|
3009
|
+
const addExpr = elseB[1]
|
|
3010
|
+
if (!Array.isArray(addExpr) || addExpr[0] !== 'f64.add' || addExpr.length !== 3) return node
|
|
3011
|
+
// The two operands of f64.add must be local.get $B and local.get $C (in either order)
|
|
3012
|
+
const [lhsAdd, rhsAdd] = [addExpr[1], addExpr[2]]
|
|
3013
|
+
const lhsIsB = Array.isArray(lhsAdd) && lhsAdd[0] === 'local.get' && lhsAdd[1] === B
|
|
3014
|
+
const rhsIsC = Array.isArray(rhsAdd) && rhsAdd[0] === 'local.get' && rhsAdd[1] === C
|
|
3015
|
+
const lhsIsC = Array.isArray(lhsAdd) && lhsAdd[0] === 'local.get' && lhsAdd[1] === C
|
|
3016
|
+
const rhsIsB = Array.isArray(rhsAdd) && rhsAdd[0] === 'local.get' && rhsAdd[1] === B
|
|
3017
|
+
if (!((lhsIsB && rhsIsC) || (lhsIsC && rhsIsB))) return node
|
|
3018
|
+
// Match confirmed. Fold to: (f64.add EXPR_A (local.get $P))
|
|
3019
|
+
return ['f64.add', exprA, ['local.get', P]]
|
|
3020
|
+
}
|
|
3021
|
+
|
|
3022
|
+
for (let i = bodyStart; i < fn.length; i++) fn[i] = foldNode(fn[i])
|
|
3023
|
+
}
|
|
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
|
+
|
|
2377
3193
|
/**
|
|
2378
3194
|
* Run all per-function IR optimizations on a single function node.
|
|
2379
3195
|
* hoistPtrType runs first — it introduces new locals (`$__ptN`) that the fused
|
|
@@ -2403,6 +3219,11 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
|
|
|
2403
3219
|
cfg.promoteGlobals === false &&
|
|
2404
3220
|
cfg.sortLocalsByUse === false &&
|
|
2405
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)
|
|
2406
3227
|
if (!cfg || cfg.hoistPtrType !== false) hoistPtrType(fn)
|
|
2407
3228
|
if (!cfg || cfg.hoistInvariantPtrOffset !== false) hoistInvariantPtrOffset(fn)
|
|
2408
3229
|
// Before LICM: the snapped i32 bound is itself a hoistable hard-op subtree, so
|
|
@@ -2414,6 +3235,7 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
|
|
|
2414
3235
|
if (!cfg || cfg.hoistInvariantLoop !== false) hoistInvariantLoop(fn)
|
|
2415
3236
|
const counts = new Map()
|
|
2416
3237
|
if (!cfg || cfg.fusedRewrite !== false) fusedRewrite(fn, counts)
|
|
3238
|
+
if (cfg && cfg.boolConvertToSelect === true) boolConvertToSelect(fn)
|
|
2417
3239
|
if (!cfg || cfg.hoistAddrBase !== false) hoistAddrBase(fn)
|
|
2418
3240
|
if (!cfg || cfg.hoistInvariantLoop !== false) hoistInvariantLoop(fn)
|
|
2419
3241
|
if (!cfg || cfg.cseScalarLoad !== false) cseScalarLoad(fn)
|
|
@@ -2432,8 +3254,48 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
|
|
|
2432
3254
|
if (cfg && cfg.vectorizeLaneLocal === true) {
|
|
2433
3255
|
const fullWatr = cfg.watr === true || typeof cfg.watr === 'object'
|
|
2434
3256
|
const runVectorizer = (fullWatr && phase === 'post') || (!fullWatr && phase !== 'post')
|
|
2435
|
-
|
|
3257
|
+
// Phase 1: fold dead string-dispatch blocks on proven-f64 locals BEFORE
|
|
3258
|
+
// the vectorizer pattern-matches — dead __is_str_key calls in $fbm-style
|
|
3259
|
+
// functions (param f64 + op f64) block liftPPC from recognizing them as pure.
|
|
3260
|
+
if (runVectorizer && (!cfg || cfg.unswitchTypedParamLoop !== false)) unswitchTypedParamLoop(fn)
|
|
3261
|
+
if (runVectorizer) foldStrDispatchF64(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)
|
|
2436
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)
|
|
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)
|
|
2437
3299
|
if (!cfg || cfg.sortLocalsByUse !== false) sortLocalsByUse(fn, cfg && cfg.fusedRewrite !== false ? counts : null)
|
|
2438
3300
|
// An optimizer pass that emits a malformed local — the class that otherwise dies
|
|
2439
3301
|
// as an opaque watr "Duplicate/Unknown local $x" several phases on — is caught
|
|
@@ -2441,6 +3303,180 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
|
|
|
2441
3303
|
if (DBG_IR) { const bad = verifyFn(fn); if (bad) throw new Error(`[ir verify] optimize produced invalid IR in ${fn[1]}: ${bad}`) }
|
|
2442
3304
|
}
|
|
2443
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
|
+
|
|
2444
3480
|
// The i32 form of an integer-valued f64 expression, or null. Used to push ToInt32
|
|
2445
3481
|
// through a conditional and to collapse the f64 round-trip on integer `+`/`-`.
|
|
2446
3482
|
// Lossless by construction: `convert_i32(X) → X`; integer `f64.const → i32.const`
|
|
@@ -2461,6 +3497,17 @@ function toI32(n) {
|
|
|
2461
3497
|
const a = toI32(n[1]), b = toI32(n[2])
|
|
2462
3498
|
if (a && b) return [op === 'f64.add' ? 'i32.add' : 'i32.sub', a, b]
|
|
2463
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
|
+
}
|
|
2464
3511
|
return null
|
|
2465
3512
|
}
|
|
2466
3513
|
|
|
@@ -2500,18 +3547,38 @@ function fusedRewrite(fn, counts) {
|
|
|
2500
3547
|
}
|
|
2501
3548
|
const freshI64 = () => { const n = `$__eqt${scratchN++}`; newDecls.push(['local', n, 'i64']); return n }
|
|
2502
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
|
+
}
|
|
2503
3570
|
for (let i = bodyStart; i < fn.length; i++) {
|
|
2504
3571
|
const c = fn[i]
|
|
2505
|
-
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)
|
|
2506
3573
|
}
|
|
2507
3574
|
if (newDecls.length) fn.splice(bodyStart, 0, ...newDecls)
|
|
2508
3575
|
}
|
|
2509
3576
|
|
|
2510
|
-
function walkRewrite(node, doInline, counts, freshI64, freshF64) {
|
|
3577
|
+
function walkRewrite(node, doInline, counts, freshI64, freshF64, get) {
|
|
2511
3578
|
if (!Array.isArray(node)) return node
|
|
2512
3579
|
for (let i = 0; i < node.length; i++) {
|
|
2513
3580
|
const c = node[i]
|
|
2514
|
-
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)
|
|
2515
3582
|
}
|
|
2516
3583
|
const op = node[0]
|
|
2517
3584
|
// Piggyback local-ref counting for sortLocalsByUse. `counts` may be undefined
|
|
@@ -2692,16 +3759,20 @@ function walkRewrite(node, doInline, counts, freshI64, freshF64) {
|
|
|
2692
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) {
|
|
2693
3760
|
let inner = v[1][1]
|
|
2694
3761
|
if (Array.isArray(inner) && inner[0] === 'local.tee' && inner.length === 3) inner = inner[2]
|
|
2695
|
-
// ToInt32(
|
|
2696
|
-
|
|
2697
|
-
&& Array.isArray(inner[3]) && inner[3][0] === 'then' && inner[3].length === 2
|
|
2698
|
-
&& Array.isArray(inner[4]) && inner[4][0] === 'else' && inner[4].length === 2) {
|
|
2699
|
-
const t = toI32(inner[3][1]), e = toI32(inner[4][1])
|
|
2700
|
-
if (t && e) return ['if', ['result', 'i32'], inner[2], ['then', t], ['else', e]]
|
|
2701
|
-
}
|
|
2702
|
-
// 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).
|
|
2703
3764
|
const i = toI32(inner)
|
|
2704
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]
|
|
2705
3776
|
}
|
|
2706
3777
|
}
|
|
2707
3778
|
// (i32.or X 0) / (i32.or 0 X) → X — drops the redundant source-level `|0` clamp left
|
|
@@ -2712,6 +3783,30 @@ function walkRewrite(node, doInline, counts, freshI64, freshF64) {
|
|
|
2712
3783
|
if (Array.isArray(a) && a[0] === 'i32.const' && a[1] === 0) return b
|
|
2713
3784
|
}
|
|
2714
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
|
+
|
|
2715
3810
|
// f64.CMP(convert_i32 A, convert_i32 B) → i32.CMP(A, B). Comparing two i32 values is
|
|
2716
3811
|
// identical whether done in exact f64 or in i32 (the converts are lossless and
|
|
2717
3812
|
// order-preserving), so an integer comparison over typed-array loads (reads are f64)
|
|
@@ -2836,6 +3931,13 @@ export function treeshake(funcSections, allModuleNodes, opts) {
|
|
|
2836
3931
|
for (let i = 2; i < fn.length; i++)
|
|
2837
3932
|
if (Array.isArray(fn[i]) && fn[i][0] === 'export') { addRoot(name); break }
|
|
2838
3933
|
|
|
3934
|
+
// When user funcs are NOT being reclaimed (O0/O1 keep declared-but-uncalled ones), they
|
|
3935
|
+
// all survive — so they're roots for the *internal*-func reachability below. Otherwise an
|
|
3936
|
+
// unreachable user func that's kept would still call a `__helper`, yet that helper would be
|
|
3937
|
+
// pruned as unreached-from-exports, leaving a dangling `call $__helper`.
|
|
3938
|
+
if (!removeDead && opts && opts.userFuncs)
|
|
3939
|
+
for (const name of opts.userFuncs) addRoot(name)
|
|
3940
|
+
|
|
2839
3941
|
const findRoots = (node) => {
|
|
2840
3942
|
if (!Array.isArray(node)) return
|
|
2841
3943
|
if (node[0] === 'start' && typeof node[1] === 'string') addRoot(node[1])
|
|
@@ -2863,24 +3965,43 @@ export function treeshake(funcSections, allModuleNodes, opts) {
|
|
|
2863
3965
|
for (const fn of allFuncs) if (typeof fn[1] !== 'string') visitCalls(fn)
|
|
2864
3966
|
while (stack.length) visitCalls(funcByName.get(stack.pop()))
|
|
2865
3967
|
|
|
3968
|
+
// Compiler-internal funcs (stdlib helpers, allocator wrappers — everything not in the
|
|
3969
|
+
// user's own `ctx.func.list`) carry no source meaning, so an unreachable one is reclaimed
|
|
3970
|
+
// at EVERY opt level: it's never a live-coding aid, just over-production (e.g. `s + '!'`
|
|
3971
|
+
// pulls the alloc trio's `__alloc_hdr`, which string concat never calls, and a dead-branch
|
|
3972
|
+
// dep like `__str_len`). User funcs are reclaimed only when DCE is on, so O0/O1 keep a
|
|
3973
|
+
// declared-but-uncalled user function. Absent the set, fall back to gating everything.
|
|
3974
|
+
const userFuncs = opts && opts.userFuncs
|
|
3975
|
+
const isUserFunc = (name) => userFuncs ? userFuncs.has(name) : true
|
|
2866
3976
|
let removed = 0
|
|
2867
|
-
if (removeDead) {
|
|
3977
|
+
if (removeDead || userFuncs) {
|
|
2868
3978
|
for (const { arr } of funcSections) {
|
|
2869
3979
|
for (let i = arr.length - 1; i >= 0; i--) {
|
|
2870
3980
|
const n = arr[i]
|
|
2871
|
-
if (Array.isArray(n) && n[0] === 'func' && typeof n[1] === 'string' && !reachable.has(n[1])
|
|
3981
|
+
if (Array.isArray(n) && n[0] === 'func' && typeof n[1] === 'string' && !reachable.has(n[1]) &&
|
|
3982
|
+
(removeDead || !isUserFunc(n[1]))) {
|
|
2872
3983
|
arr.splice(i, 1); removed++
|
|
2873
3984
|
}
|
|
2874
3985
|
}
|
|
2875
3986
|
}
|
|
2876
3987
|
}
|
|
2877
3988
|
|
|
2878
|
-
// Dead-global elimination:
|
|
2879
|
-
//
|
|
2880
|
-
//
|
|
2881
|
-
//
|
|
2882
|
-
//
|
|
2883
|
-
|
|
3989
|
+
// Dead-global elimination: drop `(global $g …)` decls that nothing references
|
|
3990
|
+
// (a `global.get`/`global.set` in a remaining func, a kept global's init expr, a
|
|
3991
|
+
// data/elem offset, or an `(export … (global $g))`). Imported globals live in
|
|
3992
|
+
// `allModuleNodes`, not in `opts.globals`, so they're never touched. Fixpoint: a
|
|
3993
|
+
// kept global's init may reference another global.
|
|
3994
|
+
//
|
|
3995
|
+
// Compiler-internal globals (support state the user never wrote — e.g. core's
|
|
3996
|
+
// `__heap_start` or the math module's `rng_state`, declared eagerly but read
|
|
3997
|
+
// only by specific fast paths) are reclaimed at *every* level: leaving an
|
|
3998
|
+
// unreferenced one in the output is pure noise, never a live-coding aid. User
|
|
3999
|
+
// globals are reclaimed only when DCE is on, so O0/O1 still preserve declared-
|
|
4000
|
+
// but-unused user bindings. `userGlobals` (names sans `$`) draws the line; absent
|
|
4001
|
+
// it, fall back to the `$__` reserved-prefix heuristic.
|
|
4002
|
+
const userGlobals = opts && opts.userGlobals
|
|
4003
|
+
const isUserGlobal = (name) => userGlobals ? userGlobals.has(name.slice(1)) : !name.startsWith('$__')
|
|
4004
|
+
const globals = opts && Array.isArray(opts.globals) ? opts.globals : null
|
|
2884
4005
|
if (globals) {
|
|
2885
4006
|
const collectGlobalRefs = (node, refd) => {
|
|
2886
4007
|
if (!Array.isArray(node)) return
|
|
@@ -2897,9 +4018,11 @@ export function treeshake(funcSections, allModuleNodes, opts) {
|
|
|
2897
4018
|
for (const g of globals) collectGlobalRefs(g, refd)
|
|
2898
4019
|
for (let i = globals.length - 1; i >= 0; i--) {
|
|
2899
4020
|
const g = globals[i]
|
|
2900
|
-
if (Array.isArray(g)
|
|
2901
|
-
|
|
2902
|
-
|
|
4021
|
+
if (!Array.isArray(g) || g[0] !== 'global' || typeof g[1] !== 'string' || refd.has(g[1])) continue
|
|
4022
|
+
// An inline `(export …)` on the decl pins it — it's part of the module's
|
|
4023
|
+
// JS-host surface (e.g. `__heap`), referenced from outside the wasm.
|
|
4024
|
+
if (g.some(c => Array.isArray(c) && c[0] === 'export')) continue
|
|
4025
|
+
if (removeDead || !isUserGlobal(g[1])) { globals.splice(i, 1); changed = true }
|
|
2903
4026
|
}
|
|
2904
4027
|
}
|
|
2905
4028
|
}
|