jz 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -33
- package/bench/README.md +176 -73
- package/bench/bench.svg +58 -71
- package/cli.js +12 -5
- package/dist/interop.js +1 -1
- package/dist/jz.js +1366 -1101
- package/index.js +40 -9
- package/interop.js +193 -138
- package/layout.js +29 -18
- package/module/array.js +49 -73
- package/module/collection.js +83 -25
- package/module/console.js +1 -1
- package/module/core.js +161 -15
- package/module/json.js +3 -3
- package/module/math.js +167 -117
- package/module/number.js +247 -13
- package/module/object.js +11 -5
- package/module/regex.js +8 -7
- package/module/string.js +295 -171
- package/module/typedarray.js +169 -105
- package/package.json +7 -3
- package/src/abi/string.js +40 -35
- package/src/ast.js +19 -2
- package/src/compile/analyze.js +64 -2
- package/src/compile/cse-load.js +200 -0
- package/src/compile/emit-assign.js +73 -14
- package/src/compile/emit.js +324 -34
- package/src/compile/index.js +204 -61
- package/src/compile/infer.js +8 -1
- package/src/compile/loop-divmod.js +12 -58
- package/src/compile/loop-model.js +91 -0
- package/src/compile/loop-recurrence.js +167 -0
- package/src/compile/loop-square.js +102 -0
- package/src/compile/narrow.js +180 -34
- package/src/compile/peel-stencil.js +18 -64
- package/src/compile/plan/common.js +29 -0
- package/src/compile/plan/index.js +4 -1
- package/src/compile/plan/inline.js +176 -21
- package/src/compile/plan/literals.js +93 -19
- package/src/ctx.js +51 -12
- package/src/helper-counters.js +137 -0
- package/src/ir.js +102 -13
- package/src/kind-traits.js +7 -3
- package/src/kind.js +14 -1
- package/src/op-policy.js +5 -2
- package/src/ops.js +119 -0
- package/src/optimize/index.js +1125 -136
- package/src/optimize/recurse.js +182 -0
- package/src/optimize/vectorize.js +1302 -144
- package/src/prepare/index.js +29 -12
- package/src/reps.js +4 -1
- package/src/type.js +53 -45
- package/src/wat/assemble.js +92 -9
- package/src/widen.js +21 -0
- package/src/wat/optimize.js +0 -3938
package/src/optimize/index.js
CHANGED
|
@@ -29,13 +29,15 @@
|
|
|
29
29
|
|
|
30
30
|
import { LAYOUT, ctx } from '../ctx.js'
|
|
31
31
|
import { VAL } from '../reps.js'
|
|
32
|
-
import { findBodyStart, buildRefcount, nextLocalId, verifyFn } from '../ir.js'
|
|
32
|
+
import { findBodyStart, buildRefcount, nextLocalId, verifyFn, isPureIR, f64Range, I32_MIN, I32_MAX } from '../ir.js'
|
|
33
33
|
|
|
34
34
|
// Debug-mode IR structural check (JZ_DEBUG_INVARIANTS=1). Zero production cost.
|
|
35
35
|
const DBG_IR = typeof process !== 'undefined' && process.env?.JZ_DEBUG_INVARIANTS === '1'
|
|
36
|
-
import { T } from '../ast.js'
|
|
36
|
+
import { T, isLeaf, stableKey } from '../ast.js'
|
|
37
37
|
import { vectorizeLaneLocal } from './vectorize.js'
|
|
38
|
-
import {
|
|
38
|
+
import { recursionUnroll } from './recurse.js'
|
|
39
|
+
export { SIMD_PINNED } from './vectorize.js'
|
|
40
|
+
import { nanPrefixHex, atomNanHex, STR_INTERN_BIT, ptrBits, i64Hex, PTR, TYPED_ELEM_CODE, TYPED_ELEM_VIEW_FLAG } from '../../layout.js'
|
|
39
41
|
|
|
40
42
|
const MEMOP = /^[fi](32|64)\.(load|store)(\d+(_[su])?)?$/
|
|
41
43
|
const NAN_BITS = nanPrefixHex()
|
|
@@ -66,7 +68,7 @@ const FALSE_BITS = atomNanHex(4)
|
|
|
66
68
|
* 'speed' — full nested unroll + lane vectorization (= level 3).
|
|
67
69
|
* The default (level 2) has no string name — omit `optimize` or pass `2`.
|
|
68
70
|
*
|
|
69
|
-
* # Two-layer contract (this file vs
|
|
71
|
+
* # Two-layer contract (this file vs watr/optimize)
|
|
70
72
|
* Both layers walk the same S-expression IR; the boundary is KNOWLEDGE, not
|
|
71
73
|
* representation:
|
|
72
74
|
* - THIS layer owns every pass that needs jz semantics — NaN-box layout
|
|
@@ -74,7 +76,7 @@ const FALSE_BITS = atomNanHex(4)
|
|
|
74
76
|
* func nodes (cseScalarLoad's cseLoadBases whitelist), loop shapes as emit
|
|
75
77
|
* produces them (narrowLoopBound, hoistInvariantLoop, vectorizeLaneLocal),
|
|
76
78
|
* and ctx-derived module facts (hoistGlobalPtrOffset's typed-global set).
|
|
77
|
-
* -
|
|
79
|
+
* - watr/optimize owns generic structural rewrites — const folding,
|
|
78
80
|
* copy-prop, branch/DCE/vacuum, dedupe, treeshake, and inlineOnce. One
|
|
79
81
|
* deliberate exception: guardRefine lives there despite NaN-box knowledge,
|
|
80
82
|
* because the dead tag-dispatch shapes it folds only EXIST after that
|
|
@@ -95,13 +97,24 @@ export const PASS_NAMES = [
|
|
|
95
97
|
'hoistInvariantLoop', // unified LICM (subsumes the former ToInt32/PtrOffsetLoop/CellLoads hoists)
|
|
96
98
|
'narrowLoopBound', // f64 loop bound → hoisted i32 (unblocks the lane-vectorizer)
|
|
97
99
|
'splitCharScan', // charCodeAt scan loops: split at min(N, s.length) → i32 char carrier (plan-level)
|
|
100
|
+
// Pre-analyze loop-shape transforms — applied in compile/index.js (NOT this pass pipeline), but
|
|
101
|
+
// gated by these flags. Listing them here is load-bearing: ALL_OFF sets them false so level 0/1
|
|
102
|
+
// (the self-host fast path) actually skip them, instead of `undefined !== false` running them
|
|
103
|
+
// unconditionally at every level. ALL_ON keeps them on at level 2+ where they belong.
|
|
104
|
+
'loopIVDivMod', // strength-reduce per-iter `i%w` / `(i/w)|0` → incremental i32 counters
|
|
105
|
+
'loopSquare', // bounded `i*i < CONST` → Math.imul (i32 product chain)
|
|
106
|
+
'unrollRecurrence', // unit-stride DP/scan: scalar-replace arr[j-1]/arr[j] recurrence + ×2 unroll
|
|
107
|
+
'clampPeel', // edge-clamp stencil: split into clamp-free interior (vectorizes) + edges
|
|
98
108
|
'hoistGlobalPtrOffset', // stable typed GLOBALS: __ptr_offset resolve → once per function (post-watr, module-level)
|
|
99
109
|
'fusedRewrite', // peephole + ptr-helper inline + memarg fold
|
|
100
110
|
'hoistAddrBase',
|
|
111
|
+
'boolConvertToSelect', // f64 ± (cond?1:0) → branchless select (kills i32↔f64 domain cross on recurrences)
|
|
101
112
|
'cseScalarLoad',
|
|
102
113
|
'csePureExpr',
|
|
114
|
+
'unswitchTypedParamLoop', // Float64Array param loop-unswitch → base-hoisted f64.load/store fast path (vectorizes)
|
|
103
115
|
'dropDeadZeroInit',
|
|
104
116
|
'deadStoreElim',
|
|
117
|
+
'propagateSingleUse', // forward-substitute single-def/single-use pure temps (watr's "propagate")
|
|
105
118
|
'promoteGlobals', // read-only global.get → local for multi-read globals
|
|
106
119
|
'sortLocalsByUse',
|
|
107
120
|
'specializeMkptr',
|
|
@@ -113,6 +126,7 @@ export const PASS_NAMES = [
|
|
|
113
126
|
'smallConstForUnroll',
|
|
114
127
|
'nestedSmallConstForUnroll',
|
|
115
128
|
'vectorizeLaneLocal', // SIMD-128 lift for lane-pure typed-array loops
|
|
129
|
+
'recursionUnroll', // inline a single non-tail self-call to depth N (tree-recursion call-overhead)
|
|
116
130
|
'arenaRewind', // per-call heap rewind for no-arg scalar allocator kernels
|
|
117
131
|
'treeshake',
|
|
118
132
|
'jsstring', // boundary opt-in: flip exported string params to externref
|
|
@@ -127,7 +141,9 @@ const LEVEL_PRESETS = Object.freeze({
|
|
|
127
141
|
// force 'light' mode here (inline / inlineOnce / coalesce all off) to dodge the
|
|
128
142
|
// W1a/W1b miscompiles; watr 4.6.9 fixes both, and the L2 default now runs the full
|
|
129
143
|
// watr pipeline. `inline` stays off by watr's own default — opt-in only.
|
|
130
|
-
|
|
144
|
+
// boolConvertToSelect off at the default level: it's a latency-for-size trade (adds a
|
|
145
|
+
// const + op per site) that only pays off on serial recurrences — speed-tier only.
|
|
146
|
+
2: Object.freeze({ ...ALL_ON, nestedSmallConstForUnroll: 'auto', boolConvertToSelect: false, recursionUnroll: false }),
|
|
131
147
|
// L3/'speed' trades a bit of heap headroom for fewer __arr_grow / __hash growth
|
|
132
148
|
// cycles. arrayMinCap=16 means `[]` and `new Array()` skip the first two doublings
|
|
133
149
|
// (0→2→4→8→16); hashSmallInitCap=8 keeps per-object __dyn_props at the same load
|
|
@@ -143,14 +159,21 @@ const LEVEL_PRESETS = Object.freeze({
|
|
|
143
159
|
// closures). Inline `f64.const` is the minimal lowering: V8 CSEs identical
|
|
144
160
|
// constants for free. Measured −3% on jessie parse for +14% binary — exactly
|
|
145
161
|
// the size↔speed trade 'speed' exists to make.
|
|
146
|
-
3: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8, reduceUnroll: true, relaxedSimd: true, inlineFns: true }),
|
|
162
|
+
3: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8, reduceUnroll: true, relaxedSimd: true, inlineFns: true, rotateLoops: true }),
|
|
147
163
|
// 'size' tightens scalar/unroll caps; 'speed' = level 3. There is no 'balanced'
|
|
148
164
|
// preset — it was a pure synonym for the default level 2 (omit `optimize` or pass 2).
|
|
149
165
|
size: Object.freeze({
|
|
150
166
|
...ALL_ON,
|
|
151
167
|
smallConstForUnroll: false, nestedSmallConstForUnroll: false, vectorizeLaneLocal: false, splitCharScan: false,
|
|
168
|
+
recursionUnroll: false, // body tripling is a size regression — speed-only
|
|
169
|
+
unrollRecurrence: false, // ×2 body duplication is a size regression — speed-only
|
|
170
|
+
clampPeel: false, // edge-clamp peel triples a stencil loop (clamp-free interior + 2 edges) to vectorize — speed-only
|
|
171
|
+
|
|
172
|
+
boolConvertToSelect: false, // adds a const + op per site — speed-only latency trade
|
|
152
173
|
devirtIndirect: false, // guards + duplicated args grow bytes — speed-only trade
|
|
153
174
|
internStrings: false, // the intern index costs ~16 B per eligible literal — speed-only trade
|
|
175
|
+
promoteGlobals: false, // snapshots a multi-read global into an entry local — pure speed (V8 can't CSE a mutable global); the snapshot is dead size weight at -Os
|
|
176
|
+
hoistInvariantLoop: false,// LICM: hoists loop-invariants to entry temps — a speed/latency trade whose entry cost outweighs the per-iter saving in bytes
|
|
154
177
|
scalarTypedLoopUnroll: 4, scalarTypedNestedUnroll: 8, scalarTypedArrayLen: 8,
|
|
155
178
|
}),
|
|
156
179
|
// 'speed' === level 3: full watr (inlining on) + L3 cap/hash tuning, pool off.
|
|
@@ -163,7 +186,7 @@ const LEVEL_PRESETS = Object.freeze({
|
|
|
163
186
|
// (The stencil + outer-strip vectorizers are NOT level-gated here: they're bit-exact pure wins
|
|
164
187
|
// like the base lane vectorizer, so they run whenever it does — default-on at level 2+ via
|
|
165
188
|
// `cfg.experimentalStencil !== false` at the call site, not a speed-only size/precision trade.)
|
|
166
|
-
speed: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8, reduceUnroll: true, relaxedSimd: true, inlineFns: true }),
|
|
189
|
+
speed: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8, reduceUnroll: true, relaxedSimd: true, inlineFns: true, rotateLoops: true }),
|
|
167
190
|
})
|
|
168
191
|
|
|
169
192
|
/**
|
|
@@ -201,6 +224,11 @@ export function resolveOptimize(opt) {
|
|
|
201
224
|
}
|
|
202
225
|
// Preserve non-pass tuning keys (e.g. plan.js thresholds)
|
|
203
226
|
for (const k of Object.keys(opt)) if (!PASS_NAMES.includes(k)) out[k] = opt[k]
|
|
227
|
+
// noSimd: suppress EVERY jz-emitted v128 — both the lane vectorizer AND the SLP
|
|
228
|
+
// store-pair packer. First-class here so `{ level:'speed', noSimd:true }` is a TRUE
|
|
229
|
+
// scalar baseline whether passed nested or via the top-level opts.noSimd flag; the
|
|
230
|
+
// SIMD-vs-scalar correctness oracles depend on it actually disabling SLP.
|
|
231
|
+
if (out.noSimd) { out.vectorizeLaneLocal = false; out.experimentalSlp = false }
|
|
204
232
|
return out
|
|
205
233
|
}
|
|
206
234
|
return { ...ALL_ON }
|
|
@@ -399,12 +427,39 @@ function regionTrackCSE(fn, { matchSite, localPrefix, localType }) {
|
|
|
399
427
|
* Must run AFTER fusedRewrite — relies on shl-distribution + assoc-lift +
|
|
400
428
|
* foldMemargOffsets having normalized the base shape.
|
|
401
429
|
*/
|
|
430
|
+
// Pure i32 ops whose value is a function of locals/consts alone — no memory read,
|
|
431
|
+
// no call, no global. A subscript expression built only from these is invariant
|
|
432
|
+
// between two sites as long as none of its local deps is rewritten between them,
|
|
433
|
+
// so CSE-ing the WHOLE address (base + shl(idx)) is value-safe — even when `idx`
|
|
434
|
+
// is a compound stencil offset like `(i32.sub (i32.add idx W) 1)` for `arr[idx+W-1]`.
|
|
435
|
+
const PURE_I32_ADDR_OPS = new Set([
|
|
436
|
+
'i32.add', 'i32.sub', 'i32.mul', 'i32.shl', 'i32.shr_s', 'i32.shr_u',
|
|
437
|
+
'i32.and', 'i32.or', 'i32.xor', 'i32.wrap_i64',
|
|
438
|
+
])
|
|
439
|
+
// Serialize a pure-i32 subscript to a stable key, accumulating its local deps.
|
|
440
|
+
// Returns null if any leaf isn't a local.get / i32.const / pure-i32 op (a load,
|
|
441
|
+
// call, or global.get could change between sites — not CSE-safe by local tracking).
|
|
442
|
+
function pureI32AddrKey(node, deps) {
|
|
443
|
+
if (!Array.isArray(node)) return null
|
|
444
|
+
const op = node[0]
|
|
445
|
+
if (op === 'local.get' && typeof node[1] === 'string') { deps.add(node[1]); return `$${node[1]}` }
|
|
446
|
+
if (op === 'i32.const' && typeof node[1] === 'number') return `#${node[1]}`
|
|
447
|
+
if (!PURE_I32_ADDR_OPS.has(op)) return null
|
|
448
|
+
let key = op + '('
|
|
449
|
+
for (let i = 1; i < node.length; i++) {
|
|
450
|
+
const sub = pureI32AddrKey(node[i], deps)
|
|
451
|
+
if (sub == null) return null
|
|
452
|
+
key += sub + ','
|
|
453
|
+
}
|
|
454
|
+
return key + ')'
|
|
455
|
+
}
|
|
456
|
+
|
|
402
457
|
export function hoistAddrBase(fn) {
|
|
403
458
|
return regionTrackCSE(fn, {
|
|
404
459
|
matchSite(node) {
|
|
405
460
|
if (node[0] !== 'i32.add' || node.length !== 3) return null
|
|
406
461
|
const a = node[1], b = node[2]
|
|
407
|
-
// Two orderings: (add (get A) (shl
|
|
462
|
+
// Two orderings: (add (get A) (shl IDX (const K))) or (add (shl …) (get A))
|
|
408
463
|
let baseGet, shlNode
|
|
409
464
|
if (Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' &&
|
|
410
465
|
Array.isArray(b) && b[0] === 'i32.shl' && b.length === 3) {
|
|
@@ -414,15 +469,88 @@ export function hoistAddrBase(fn) {
|
|
|
414
469
|
baseGet = b; shlNode = a
|
|
415
470
|
} else return null
|
|
416
471
|
const idx = shlNode[1], shamt = shlNode[2]
|
|
417
|
-
if (!Array.isArray(idx) || idx[0] !== 'local.get' || typeof idx[1] !== 'string') return null
|
|
418
472
|
if (!Array.isArray(shamt) || shamt[0] !== 'i32.const' || typeof shamt[1] !== 'number') return null
|
|
419
|
-
|
|
473
|
+
// idx may be a plain `local.get` (the original biquad case) or any compound
|
|
474
|
+
// pure-i32 subscript (stencil neighbour `arr[idx+W-1]`); both CSE the same way.
|
|
475
|
+
const deps = new Set([baseGet[1]])
|
|
476
|
+
const idxKey = pureI32AddrKey(idx, deps)
|
|
477
|
+
if (idxKey == null) return null
|
|
478
|
+
return { key: `${baseGet[1]}|${idxKey}|${shamt[1]}`, deps: [...deps] }
|
|
420
479
|
},
|
|
421
480
|
localPrefix: 'ab',
|
|
422
481
|
localType: 'i32',
|
|
423
482
|
})
|
|
424
483
|
}
|
|
425
484
|
|
|
485
|
+
// wasm comparison ops — each yields an i32 that is exactly 0 or 1.
|
|
486
|
+
const BOOL_RESULT_OPS = new Set([
|
|
487
|
+
'i32.eqz', 'i64.eqz',
|
|
488
|
+
'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',
|
|
489
|
+
'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',
|
|
490
|
+
'f32.eq', 'f32.ne', 'f32.lt', 'f32.gt', 'f32.le', 'f32.ge',
|
|
491
|
+
'f64.eq', 'f64.ne', 'f64.lt', 'f64.gt', 'f64.le', 'f64.ge',
|
|
492
|
+
])
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* `f64 ± (cond ? 1 : 0)` → branchless f64 `select`, killing the i32↔f64 domain cross.
|
|
496
|
+
*
|
|
497
|
+
* `err = old - (old >= t)` and friends compile to `f64.sub(X, f64.convert_i32_s(cmp))`.
|
|
498
|
+
* The convert (cvtsi2sd) round-trips the comparison result out of a GPR back into an
|
|
499
|
+
* XMM register — a domain-crossing op that sits ON the value's def chain. In the
|
|
500
|
+
* per-pixel error-diffusion sweeps (Floyd–Steinberg / Atkinson / JJN) and scalar IIR
|
|
501
|
+
* thresholds this chain is the loop-carried critical path, so that one cross roughly
|
|
502
|
+
* doubles the per-step latency (V8 keeps the JS threshold entirely in the FP domain).
|
|
503
|
+
*
|
|
504
|
+
* `X - (B?1:0) ≡ (B ? X-1 : X) ≡ select(X-1, X, B)` (likewise `+` → `select(X+1, X, B)`),
|
|
505
|
+
* which never leaves the f64 domain. `select` evaluates BOTH arms, so X must be a
|
|
506
|
+
* side-effect-free duplicable leaf (a `local.get`/const); B is the i32 condition,
|
|
507
|
+
* evaluated once (exactly as the convert did). A pure win on latency-bound recurrences;
|
|
508
|
+
* speed-gated (it adds a const + an arithmetic op — a size↔speed trade) — off at 'size'.
|
|
509
|
+
*/
|
|
510
|
+
function boolConvertToSelect(fn) {
|
|
511
|
+
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
512
|
+
// Pass 1 — a local whose SOLE definition is a comparison carries a value ∈ {0,1};
|
|
513
|
+
// `err = old - on` (on reused by putBW) reaches us as `convert(local.get $on)`.
|
|
514
|
+
// A param is EXCLUDED even if reassigned once by a comparison: its incoming arg is
|
|
515
|
+
// unconstrained, so a read before the reassignment isn't 0/1. (A plain local read
|
|
516
|
+
// before its def is safe — wasm zero-inits it to 0 = false, which select preserves.)
|
|
517
|
+
const params = new Set()
|
|
518
|
+
for (let i = 2; i < fn.length; i++) if (Array.isArray(fn[i]) && fn[i][0] === 'param') params.add(fn[i][1])
|
|
519
|
+
const defCount = new Map(), defIsCmp = new Map()
|
|
520
|
+
const scan = (n) => {
|
|
521
|
+
if (!Array.isArray(n)) return
|
|
522
|
+
if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') {
|
|
523
|
+
defCount.set(n[1], (defCount.get(n[1]) || 0) + 1)
|
|
524
|
+
const cmp = Array.isArray(n[2]) && BOOL_RESULT_OPS.has(n[2][0])
|
|
525
|
+
defIsCmp.set(n[1], (defIsCmp.has(n[1]) ? defIsCmp.get(n[1]) : true) && cmp)
|
|
526
|
+
}
|
|
527
|
+
for (let i = 1; i < n.length; i++) scan(n[i])
|
|
528
|
+
}
|
|
529
|
+
scan(fn)
|
|
530
|
+
const boolLocals = new Set()
|
|
531
|
+
for (const [name, c] of defCount) if (c === 1 && defIsCmp.get(name) && !params.has(name)) boolLocals.add(name)
|
|
532
|
+
|
|
533
|
+
const isBool01 = (n) => Array.isArray(n) &&
|
|
534
|
+
(BOOL_RESULT_OPS.has(n[0]) || (n[0] === 'local.get' && boolLocals.has(n[1])))
|
|
535
|
+
const dup = (n) => Array.isArray(n) ? n.map(dup) : n
|
|
536
|
+
|
|
537
|
+
// Pass 2 — bottom-up rewrite.
|
|
538
|
+
const rewrite = (n) => {
|
|
539
|
+
if (!Array.isArray(n)) return n
|
|
540
|
+
for (let i = 1; i < n.length; i++) n[i] = rewrite(n[i])
|
|
541
|
+
if ((n[0] === 'f64.sub' || n[0] === 'f64.add') && n.length === 3) {
|
|
542
|
+
const conv = (m) => Array.isArray(m) && (m[0] === 'f64.convert_i32_s' || m[0] === 'f64.convert_i32_u') && isBool01(m[1])
|
|
543
|
+
// `X - bool`, `X + bool`, or (add is commutative) `bool + X`.
|
|
544
|
+
let X = null, B = null
|
|
545
|
+
if (conv(n[2]) && isLeaf(n[1])) { X = n[1]; B = n[2][1] }
|
|
546
|
+
else if (n[0] === 'f64.add' && conv(n[1]) && isLeaf(n[2])) { X = n[2]; B = n[1][1] }
|
|
547
|
+
if (X) return ['select', [n[0], dup(X), ['f64.const', 1]], dup(X), B]
|
|
548
|
+
}
|
|
549
|
+
return n
|
|
550
|
+
}
|
|
551
|
+
rewrite(fn)
|
|
552
|
+
}
|
|
553
|
+
|
|
426
554
|
/**
|
|
427
555
|
* Hoist `(call $__ptr_offset (local.get $X))` to a function-entry snapshot
|
|
428
556
|
* when X is an f64-NaN-boxed parameter that's never reassigned and only ever
|
|
@@ -442,6 +570,20 @@ export function hoistAddrBase(fn) {
|
|
|
442
570
|
// loop-invariant __jss_length in the same loop condition CAN hoist).
|
|
443
571
|
const SAFE_OFFSET_CALLS = new Set(['$__ptr_offset', '$__ptr_type', '$__ptr_aux', '$__len', '$__jss_length', '$__jss_charCodeAt'])
|
|
444
572
|
|
|
573
|
+
// wasm comparison-op mantissas (the part after the `.`): they yield i32 regardless of
|
|
574
|
+
// operand width (i64.eq, f64.lt, i32.ge_s, …). `eq`/`ne` are sign-agnostic; the ordered
|
|
575
|
+
// compares carry `_s`/`_u` for the integer types and none for f64. Used by resultType to
|
|
576
|
+
// type a hoisted subtree by its root op. A Set membership test, NOT a regex
|
|
577
|
+
// (`/^(eq|ne|lt|gt|le|ge)(_[su])?$/`): the regex mis-anchored under self-host −O2 — `nearest`
|
|
578
|
+
// (the f64.nearest mantissa, from Math.round) starts with `ne`, and the embedded −O2 build
|
|
579
|
+
// matched it as a comparison → the LICM hoist local got typed i32, so `local.set $__li
|
|
580
|
+
// (f64.nearest …)` emitted invalid wasm (f64 into i32) only in the kernel. Explicit string
|
|
581
|
+
// membership is both self-host-robust and cheaper in this LICM-hot path.
|
|
582
|
+
const CMP_MANTISSA = new Set([
|
|
583
|
+
'eqz', 'eq', 'ne', 'lt', 'gt', 'le', 'ge',
|
|
584
|
+
'lt_s', 'lt_u', 'gt_s', 'gt_u', 'le_s', 'le_u', 'ge_s', 'ge_u',
|
|
585
|
+
])
|
|
586
|
+
|
|
445
587
|
// Calls that don't modify EXISTING heap memory: they may allocate (bump the heap
|
|
446
588
|
// pointer) or do tag dispatch, but they never write to an address a hoisted
|
|
447
589
|
// __typed_idx/__str_idx element read would revisit. Their presence must not
|
|
@@ -460,6 +602,25 @@ const NON_MUTATING_CALLS = new Set(['$__is_str_key', '$__str_concat', '$__to_num
|
|
|
460
602
|
// `for(j) { ... grid[i][j] ... }` inner loop (the jagged-array deopt).
|
|
461
603
|
const READONLY_MEM_CALLS = new Set(['$__typed_idx', '$__str_idx'])
|
|
462
604
|
|
|
605
|
+
// PURE FUNCTION calls — result is a function of the ARGUMENTS alone, with no
|
|
606
|
+
// dependence on mutable state: math reads no memory; the string search/compare
|
|
607
|
+
// helpers read only their operands' bytes, and jz strings are IMMUTABLE, so their
|
|
608
|
+
// content can't change under the loop's stores. So on loop-invariant args the
|
|
609
|
+
// RESULT is loop-invariant regardless of anything else the loop does (unlike a
|
|
610
|
+
// READONLY_MEM_CALLS element read, which an aliasing store could invalidate — no
|
|
611
|
+
// !hasDirectStore guard is needed here). Speculative pre-header execution can't
|
|
612
|
+
// trap: math returns NaN/∞ rather than trapping, and a value reaching .indexOf/===
|
|
613
|
+
// is a valid string (in-bounds reads). This is the LICM V8's wasm tier won't do —
|
|
614
|
+
// it treats every call as opaque and recomputes the search/transcendental each
|
|
615
|
+
// iteration. (Math.random is INLINED — it mutates a global PRNG seed, never a
|
|
616
|
+
// `$math.` call — but exclude it by name defensively; $__str_eq_cold is the cold
|
|
617
|
+
// half of __str_eq, equally pure.) byteLen/length stay OUT: $__length is polymorphic
|
|
618
|
+
// over MUTABLE arrays (push changes it), so it isn't arg-pure.
|
|
619
|
+
const PURE_CALL_I32 = new Set(['$__str_indexof', '$__str_lastindexof', '$__str_eq', '$__str_eq_cold', '$__is_str_key'])
|
|
620
|
+
const isPureFnCall = (callee) =>
|
|
621
|
+
typeof callee === 'string' &&
|
|
622
|
+
((callee.startsWith('$math.') && !callee.startsWith('$math.random')) || PURE_CALL_I32.has(callee))
|
|
623
|
+
|
|
463
624
|
export function hoistInvariantPtrOffset(fn) {
|
|
464
625
|
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
465
626
|
const bodyStart = findBodyStart(fn)
|
|
@@ -610,6 +771,143 @@ const PURE_LICM_OPS = new Set([
|
|
|
610
771
|
'f64.promote_f32', 'f32.demote_f64', 'select',
|
|
611
772
|
])
|
|
612
773
|
|
|
774
|
+
// Resolve a load/store address back to the single typed-array PARAM it derives from — through
|
|
775
|
+
// `local.get`, the arithmetic in PURE_LICM_OPS, and single-def snap locals ($__li/$__ab) — or
|
|
776
|
+
// null if not exactly one / unprovable (a multi-def or unknown local in the address). Built once
|
|
777
|
+
// per function over the proven-distinct `distinctParams` set; the alias substrate both LICM
|
|
778
|
+
// passes query to hoist a read-only input load across a distinct-buffer store (raytrace's spheres
|
|
779
|
+
// vs framebuffer — the alias-analysis LICM rust/clang get for free).
|
|
780
|
+
function buildBaseParamOf(fn, bodyStart, distinctParams) {
|
|
781
|
+
if (!distinctParams) return () => null
|
|
782
|
+
const paramNames = new Set()
|
|
783
|
+
for (let i = 2; i < bodyStart; i++)
|
|
784
|
+
if (Array.isArray(fn[i]) && fn[i][0] === 'param' && typeof fn[i][1] === 'string') paramNames.add(fn[i][1])
|
|
785
|
+
const singleDef = new Map(), defCount = new Map()
|
|
786
|
+
const scanDefs = (n) => {
|
|
787
|
+
if (!Array.isArray(n)) return
|
|
788
|
+
if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') {
|
|
789
|
+
defCount.set(n[1], (defCount.get(n[1]) || 0) + 1); singleDef.set(n[1], n[2]); scanDefs(n[2]); return
|
|
790
|
+
}
|
|
791
|
+
for (let i = 1; i < n.length; i++) scanDefs(n[i])
|
|
792
|
+
}
|
|
793
|
+
for (let i = bodyStart; i < fn.length; i++) scanDefs(fn[i])
|
|
794
|
+
for (const [k, c] of defCount) if (c > 1) singleDef.delete(k) // multi-def → can't trust the resolution
|
|
795
|
+
return (addr) => {
|
|
796
|
+
const found = new Set(); const seen = new Set(); let bad = false
|
|
797
|
+
const walk = (n) => {
|
|
798
|
+
if (bad || !Array.isArray(n)) return
|
|
799
|
+
if (n[0] === 'local.get' && typeof n[1] === 'string') {
|
|
800
|
+
if (paramNames.has(n[1])) found.add(n[1])
|
|
801
|
+
else if (singleDef.has(n[1]) && !seen.has(n[1])) { seen.add(n[1]); walk(singleDef.get(n[1])) }
|
|
802
|
+
else bad = true // a written/unknown local in the address → base unprovable
|
|
803
|
+
return
|
|
804
|
+
}
|
|
805
|
+
for (let i = 1; i < n.length; i++) walk(n[i])
|
|
806
|
+
}
|
|
807
|
+
walk(addr)
|
|
808
|
+
return !bad && found.size === 1 ? [...found][0] : null
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// Per-loop invariance/purity analysis — the single proven predicate both LICM passes share.
|
|
813
|
+
// Scans the loop into an effect summary (locals/globals it writes, cells/buffers it stores to,
|
|
814
|
+
// whether it has any call / unsafe call / direct store / v128 op), then closes `pureGiven(node,
|
|
815
|
+
// bound)` over it: true iff `node` is side-effect-free AND loop-invariant, given that the locals
|
|
816
|
+
// in `bound` are private to the candidate (a `local.get` of a bound local reads the in-subtree
|
|
817
|
+
// teed invariant; a free `local.get` must be unwritten by the loop). Memory leaves are admitted
|
|
818
|
+
// only under the summary: a `$__cell_`/distinct-param load iff no aliasing store + no call; a
|
|
819
|
+
// SAFE_OFFSET/READONLY_MEM call iff no unsafe call (+ no direct store for heap reads).
|
|
820
|
+
function loopInvariance(loopNode, { distinctParams, baseParamOf }) {
|
|
821
|
+
const locals = new Set(), globals = new Set(), storedCells = new Set(), storedBases = new Set()
|
|
822
|
+
let hasUnsafeCall = false, hasAnyCall = false, hasDirectStore = false, hasV128 = false
|
|
823
|
+
const scan = (node) => {
|
|
824
|
+
if (!Array.isArray(node)) return
|
|
825
|
+
const op = node[0]
|
|
826
|
+
// A vectorized loop (lane/v128 ops) is already register-tight and hand-tuned;
|
|
827
|
+
// extra scalar hoisting there only adds spill pressure — keep it conservative.
|
|
828
|
+
if (op.startsWith('v128.') || /^[if]\d+x\d+\./.test(op)) hasV128 = true
|
|
829
|
+
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 }
|
|
830
|
+
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 }
|
|
831
|
+
if (op === 'call') { hasAnyCall = true; if (!SAFE_OFFSET_CALLS.has(node[1]) && !READONLY_MEM_CALLS.has(node[1]) && !NON_MUTATING_CALLS.has(node[1]) && !isPureFnCall(node[1])) hasUnsafeCall = true; for (let i = 2; i < node.length; i++) scan(node[i]); return }
|
|
832
|
+
if (op === 'call_ref' || op === 'call_indirect') { hasAnyCall = hasUnsafeCall = true; for (let i = 1; i < node.length; i++) scan(node[i]); return }
|
|
833
|
+
if ((op === 'f64.store' || op === 'i32.store') && node.length >= 3) {
|
|
834
|
+
hasDirectStore = true
|
|
835
|
+
const a = node[1]
|
|
836
|
+
if (Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' && a[1].startsWith(CELL_PREFIX)) storedCells.add(a[1])
|
|
837
|
+
if (distinctParams) { const sb = baseParamOf(a); if (sb) storedBases.add(sb) } // alias: which buffers this loop writes
|
|
838
|
+
}
|
|
839
|
+
for (let i = 1; i < node.length; i++) scan(node[i])
|
|
840
|
+
}
|
|
841
|
+
for (let i = 1; i < loopNode.length; i++) scan(loopNode[i])
|
|
842
|
+
|
|
843
|
+
const pureGiven = (node, bound) => {
|
|
844
|
+
if (!Array.isArray(node)) return true // bare operand string/number
|
|
845
|
+
const op = node[0]
|
|
846
|
+
if (op === 'i32.const' || op === 'i64.const' || op === 'f64.const' || op === 'f32.const') return true
|
|
847
|
+
if (op === 'local.get') return typeof node[1] === 'string' && (bound.has(node[1]) || !locals.has(node[1]))
|
|
848
|
+
// A global is invariant only if not set directly AND no call in the loop —
|
|
849
|
+
// any callee may mutate it (no interprocedural effect analysis). (Locals are
|
|
850
|
+
// frame-private, so calls can't touch them; only direct local.set matters.)
|
|
851
|
+
if (op === 'global.get') return typeof node[1] === 'string' && !globals.has(node[1]) && !hasAnyCall
|
|
852
|
+
if (op === 'local.tee') {
|
|
853
|
+
if (typeof node[1] !== 'string') return false
|
|
854
|
+
// The operand is evaluated BEFORE the tee writes $X, so a `local.get $X` inside
|
|
855
|
+
// it reads the loop-carried (previous-iteration) value, not the teed one. Drop
|
|
856
|
+
// $X from `bound` for the operand: `local.tee $X (… $X …)` is a loop recurrence
|
|
857
|
+
// (X = f(X) — e.g. the `while ((nn = nn >>> 1))` induction), NOT invariant.
|
|
858
|
+
const inner = bound.has(node[1]) ? new Set([...bound].filter(b => b !== node[1])) : bound
|
|
859
|
+
return pureGiven(node[2], inner)
|
|
860
|
+
}
|
|
861
|
+
if ((op === 'f64.load' || op === 'i32.load') && node.length === 2) {
|
|
862
|
+
const a = node[1]
|
|
863
|
+
if (Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' && a[1].startsWith(CELL_PREFIX)
|
|
864
|
+
&& !hasAnyCall && !storedCells.has(a[1]) && (bound.has(a[1]) || !locals.has(a[1]))) return true
|
|
865
|
+
// Alias-analysis LICM: a load from a typed-array param PROVEN distinct from every buffer
|
|
866
|
+
// this loop writes (base ∉ storedBases) is loop-invariant when its address is invariant —
|
|
867
|
+
// even across the loop's stores, because they can't alias it. This is what lets rust/clang
|
|
868
|
+
// hoist read-only input arrays out of a write loop (raytrace's spheres vs the framebuffer).
|
|
869
|
+
// `pureGiven(a, bound)` proves the address itself invariant (base param unwritten + invariant
|
|
870
|
+
// offset); the calls guard rules out callee memory mutation.
|
|
871
|
+
if (distinctParams && !hasAnyCall) {
|
|
872
|
+
const base = baseParamOf(a)
|
|
873
|
+
if (base && distinctParams.has(base) && !storedBases.has(base) && pureGiven(a, bound)) return true
|
|
874
|
+
}
|
|
875
|
+
return false
|
|
876
|
+
}
|
|
877
|
+
if (op === 'call') {
|
|
878
|
+
// Pure-function call: invariant iff its ARGS are. No effect-summary barrier —
|
|
879
|
+
// its result depends on nothing the loop can mutate (math: no memory; string
|
|
880
|
+
// search/compare: immutable operands), so neither a store nor another call
|
|
881
|
+
// can invalidate it. This hoists the loop-invariant transcendental / substr
|
|
882
|
+
// search V8's wasm tier recomputes every iteration.
|
|
883
|
+
if (isPureFnCall(node[1]))
|
|
884
|
+
return node.slice(2).every(c => pureGiven(c, bound))
|
|
885
|
+
if (SAFE_OFFSET_CALLS.has(node[1]))
|
|
886
|
+
return !hasUnsafeCall && node.slice(2).every(c => pureGiven(c, bound))
|
|
887
|
+
// Read-only heap reads: additionally require no direct store (alias-safe).
|
|
888
|
+
if (READONLY_MEM_CALLS.has(node[1]))
|
|
889
|
+
return !hasUnsafeCall && !hasDirectStore && node.slice(2).every(c => pureGiven(c, bound))
|
|
890
|
+
return false
|
|
891
|
+
}
|
|
892
|
+
// A value-producing `if` whose condition and both arms are pure is itself
|
|
893
|
+
// pure — the tag-dispatch idiom `(if (result f64) tag-check (then read-A)
|
|
894
|
+
// (else read-B))` that wraps __typed_idx/__str_idx element access.
|
|
895
|
+
if (op === 'if') {
|
|
896
|
+
for (let i = 1; i < node.length; i++) {
|
|
897
|
+
const c = node[i]
|
|
898
|
+
if (!Array.isArray(c)) continue
|
|
899
|
+
if (c[0] === 'result') continue
|
|
900
|
+
if (c[0] === 'then' || c[0] === 'else') { if (!c.slice(1).every(x => pureGiven(x, bound))) return false }
|
|
901
|
+
else if (!pureGiven(c, bound)) return false // the condition
|
|
902
|
+
}
|
|
903
|
+
return true
|
|
904
|
+
}
|
|
905
|
+
if (PURE_LICM_OPS.has(op)) return node.slice(1).every(c => pureGiven(c, bound))
|
|
906
|
+
return false
|
|
907
|
+
}
|
|
908
|
+
return { pureGiven, locals, globals, storedCells, storedBases, hasUnsafeCall, hasAnyCall, hasDirectStore, hasV128 }
|
|
909
|
+
}
|
|
910
|
+
|
|
613
911
|
/**
|
|
614
912
|
* Unified loop-invariant code motion. One principle replaces the three former
|
|
615
913
|
* pattern hoists (ToInt32 / __ptr_offset / cell-load): a MAXIMAL pure subtree
|
|
@@ -630,6 +928,160 @@ const PURE_LICM_OPS = new Set([
|
|
|
630
928
|
* watr's shared CSE subtrees, snaps spliced before the loop, decls at bodyStart.
|
|
631
929
|
* Idempotent: re-running sees only `(local.get $__liN)` and finds nothing to do.
|
|
632
930
|
*/
|
|
931
|
+
// SSA-split loop-private straight-line multi-def scratch so the LICM below can hoist
|
|
932
|
+
// the invariant versions. jz's unroller MERGES each unrolled iteration's `const x`
|
|
933
|
+
// into one multi-def local (e.g. raytrace's sphere loop unrolls 8× sharing $ox/$c),
|
|
934
|
+
// which the LICM cannot hoist — so the per-sphere invariant `c_i = sx_i²+sy_i²+sz_i²
|
|
935
|
+
// −sr_i²` recomputes every pixel instead of once (the 1.24× rust-wasm gap; rust/LLVM
|
|
936
|
+
// keeps them as distinct SSA values and hoists each). Renaming each def to its own
|
|
937
|
+
// version makes them single-def → hoistInvariantLoop lifts the loop-invariant ones.
|
|
938
|
+
//
|
|
939
|
+
// BIT-EXACT: pure renaming + invariant code motion — the same value computed fewer
|
|
940
|
+
// times, no reassociation. Gated to loops with NO v128, so it never disturbs a
|
|
941
|
+
// vectorized loop (whose unrolled shared names the lane/dot vectorizer relies on).
|
|
942
|
+
//
|
|
943
|
+
// SOUND only for a local that, within the loop body, (a) is referenced NOWHERE else in
|
|
944
|
+
// the function (loop-local lifetime — else a post-loop read of the merged name breaks),
|
|
945
|
+
// (b) has every occurrence STRAIGHT-LINE (never under a nested if/block/loop, so a
|
|
946
|
+
// linear walk assigns each use its unique dominating def), (c) is first accessed by a
|
|
947
|
+
// WRITE (no value carried across the back-edge), (d) is only ever `local.set` (never
|
|
948
|
+
// `local.tee`/conditionally defined). Each condition rejects a class that would miscompile.
|
|
949
|
+
export function splitLoopPrivateScratch(fn) {
|
|
950
|
+
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
951
|
+
const bodyStart = findBodyStart(fn)
|
|
952
|
+
if (bodyStart < 0) return
|
|
953
|
+
const SCALAR = new Set(['i32', 'i64', 'f64', 'f32'])
|
|
954
|
+
const localTypes = new Map()
|
|
955
|
+
for (let i = 2; i < bodyStart; i++) {
|
|
956
|
+
const c = fn[i]
|
|
957
|
+
if (Array.isArray(c) && (c[0] === 'param' || c[0] === 'local') && typeof c[1] === 'string') localTypes.set(c[1], c[2])
|
|
958
|
+
}
|
|
959
|
+
// Whole-function reference count per local (to verify a candidate is loop-local).
|
|
960
|
+
const fnRefs = new Map()
|
|
961
|
+
const countRefs = (n) => {
|
|
962
|
+
if (!Array.isArray(n)) return
|
|
963
|
+
if ((n[0] === 'local.get' || n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string')
|
|
964
|
+
fnRefs.set(n[1], (fnRefs.get(n[1]) || 0) + 1)
|
|
965
|
+
for (let i = 1; i < n.length; i++) countRefs(n[i])
|
|
966
|
+
}
|
|
967
|
+
for (let i = bodyStart; i < fn.length; i++) countRefs(fn[i])
|
|
968
|
+
// Same proven alias substrate hoistInvariantLoop uses (re-attached after watOptimize, so it
|
|
969
|
+
// survives into this 'post' pass) — lets pureGiven prove a read-only input-array load distinct
|
|
970
|
+
// from the loop's output store, the SOUND replacement for the old address-local-disjointness
|
|
971
|
+
// heuristic (which assumed two loads/stores in different locals never alias — false in general).
|
|
972
|
+
const distinctParams = fn.distinctParams || null
|
|
973
|
+
const baseParamOf = buildBaseParamOf(fn, bodyStart, distinctParams)
|
|
974
|
+
|
|
975
|
+
const hasV128 = (n) => {
|
|
976
|
+
let f = false
|
|
977
|
+
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]) }
|
|
978
|
+
w(n); return f
|
|
979
|
+
}
|
|
980
|
+
let minted = 0
|
|
981
|
+
const newDecls = []
|
|
982
|
+
|
|
983
|
+
const processLoop = (loop, parent, idx) => {
|
|
984
|
+
if (loop[0] !== 'loop' || hasV128(loop)) return
|
|
985
|
+
// Candidate names: locals set somewhere directly in the loop's statement list.
|
|
986
|
+
const seen = new Set()
|
|
987
|
+
for (let i = 2; i < loop.length; i++) {
|
|
988
|
+
const s = loop[i]
|
|
989
|
+
if (Array.isArray(s) && s[0] === 'local.set' && typeof s[1] === 'string') seen.add(s[1])
|
|
990
|
+
}
|
|
991
|
+
// Stage 1 — collect SAFE candidates (loop-local, straight-line, first-write, set-only,
|
|
992
|
+
// ≥2 defs) and record each one's def RHS list for the invariance fixpoint.
|
|
993
|
+
const cand = new Map() // name → { defs: [rhs…] }
|
|
994
|
+
for (const name of seen) {
|
|
995
|
+
if (!SCALAR.has(localTypes.get(name))) continue
|
|
996
|
+
let inLoop = 0
|
|
997
|
+
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]) }
|
|
998
|
+
cnt(loop)
|
|
999
|
+
if (inLoop !== (fnRefs.get(name) || 0)) continue
|
|
1000
|
+
let safe = true, first = null, defs = []
|
|
1001
|
+
const scan = (n, depth) => {
|
|
1002
|
+
if (!safe || !Array.isArray(n)) return
|
|
1003
|
+
const op = n[0]
|
|
1004
|
+
if (op === 'local.tee' && n[1] === name) { safe = false; return }
|
|
1005
|
+
if (op === 'local.set' && n[1] === name) {
|
|
1006
|
+
if (depth > 0) { safe = false; return }
|
|
1007
|
+
if (first === null) first = 'w'
|
|
1008
|
+
defs.push(n[2])
|
|
1009
|
+
scan(n[2], depth)
|
|
1010
|
+
return
|
|
1011
|
+
}
|
|
1012
|
+
if (op === 'local.get' && n[1] === name) {
|
|
1013
|
+
if (depth > 0) { safe = false; return }
|
|
1014
|
+
if (first === null) first = 'r'
|
|
1015
|
+
return
|
|
1016
|
+
}
|
|
1017
|
+
const ctrl = op === 'if' || op === 'then' || op === 'else' || op === 'block' || op === 'loop'
|
|
1018
|
+
for (let i = 1; i < n.length; i++) scan(n[i], depth + (ctrl ? 1 : 0))
|
|
1019
|
+
}
|
|
1020
|
+
for (let i = 2; i < loop.length; i++) scan(loop[i], 0)
|
|
1021
|
+
if (safe && first === 'w' && defs.length >= 2) cand.set(name, defs)
|
|
1022
|
+
}
|
|
1023
|
+
if (!cand.size) return
|
|
1024
|
+
// Stage 2 — invariance fixpoint over the SHARED proven predicate. `pureGiven(def, hoistable)`
|
|
1025
|
+
// decides loop-invariance with hoistInvariantLoop's exact model: a `$__cell_`/distinct-param
|
|
1026
|
+
// read-only load is invariant across the loop's stores (sound alias analysis), a global is
|
|
1027
|
+
// invariant only without a loop write or call, and the `bound` set (here `hoistable`) carries
|
|
1028
|
+
// the cascade — a def reading an already-split sibling is invariant once that sibling moves out
|
|
1029
|
+
// (c = ox²+… invariant only after ox hoists). `motionSafe` adds the one extra obligation a
|
|
1030
|
+
// whole-assignment MOTION needs beyond value-invariance: no `local.tee` writing a local read
|
|
1031
|
+
// elsewhere (pureGiven already rejects set/store/global.set/unsafe-call). This replaces the old
|
|
1032
|
+
// address-local-disjointness load test, which was unsound in general (two distinct locals can
|
|
1033
|
+
// hold the same address) and only worked by luck on the bench shapes.
|
|
1034
|
+
const { pureGiven } = loopInvariance(loop, { distinctParams, baseParamOf })
|
|
1035
|
+
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 }
|
|
1036
|
+
const hoistable = new Set()
|
|
1037
|
+
let changed = true
|
|
1038
|
+
while (changed) {
|
|
1039
|
+
changed = false
|
|
1040
|
+
for (const [name, defs] of cand) {
|
|
1041
|
+
if (hoistable.has(name)) continue
|
|
1042
|
+
if (defs.every(d => motionSafe(d) && pureGiven(d, hoistable))) { hoistable.add(name); changed = true }
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
// Stage 3 — one linear pass over the loop body: each hoistable def is RENAMED to a
|
|
1046
|
+
// fresh version and MOVED OUT of the loop (before it), in source order so the cascade's
|
|
1047
|
+
// data deps stay intact (c = ox²+… emitted after ox). The cheap arithmetic AND the load
|
|
1048
|
+
// both leave the loop; gets stay, rebound to the moved version. (hoistInvariantLoop only
|
|
1049
|
+
// snapshots expensive subexprs, not whole invariant assignments — so we do the motion.)
|
|
1050
|
+
const curOf = new Map()
|
|
1051
|
+
const rewriteGets = (n) => {
|
|
1052
|
+
if (!Array.isArray(n)) return n
|
|
1053
|
+
if (n[0] === 'local.get' && curOf.has(n[1])) return ['local.get', curOf.get(n[1])]
|
|
1054
|
+
return n.map((c, i) => i === 0 ? c : rewriteGets(c))
|
|
1055
|
+
}
|
|
1056
|
+
const hoisted = []
|
|
1057
|
+
const kept = loop.slice(0, 2) // 'loop' + label
|
|
1058
|
+
for (let i = 2; i < loop.length; i++) {
|
|
1059
|
+
const s = loop[i]
|
|
1060
|
+
if (Array.isArray(s) && s[0] === 'local.set' && hoistable.has(s[1])) {
|
|
1061
|
+
const name = s[1], ty = localTypes.get(name)
|
|
1062
|
+
const nv = `$${name.replace(/^\$/, '')}__sr${minted++}`
|
|
1063
|
+
newDecls.push(['local', nv, ty]); localTypes.set(nv, ty)
|
|
1064
|
+
hoisted.push(['local.set', nv, rewriteGets(s[2])])
|
|
1065
|
+
curOf.set(name, nv)
|
|
1066
|
+
} else {
|
|
1067
|
+
kept.push(rewriteGets(s))
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
loop.length = 0
|
|
1071
|
+
for (const x of kept) loop.push(x)
|
|
1072
|
+
parent.splice(idx, 0, ...hoisted)
|
|
1073
|
+
}
|
|
1074
|
+
const walk = (parent, idx) => {
|
|
1075
|
+
const n = parent[idx]
|
|
1076
|
+
if (!Array.isArray(n)) return
|
|
1077
|
+
// Recurse first so an inner loop's hoists land before we process the outer loop.
|
|
1078
|
+
for (let i = 1; i < n.length; i++) walk(n, i)
|
|
1079
|
+
if (n[0] === 'loop') processLoop(n, parent, idx)
|
|
1080
|
+
}
|
|
1081
|
+
for (let i = bodyStart; i < fn.length; i++) walk(fn, i)
|
|
1082
|
+
if (newDecls.length) fn.splice(bodyStart, 0, ...newDecls)
|
|
1083
|
+
}
|
|
1084
|
+
|
|
633
1085
|
export function hoistInvariantLoop(fn) {
|
|
634
1086
|
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
635
1087
|
const bodyStart = findBodyStart(fn)
|
|
@@ -675,6 +1127,8 @@ export function hoistInvariantLoop(fn) {
|
|
|
675
1127
|
// SAFE_OFFSET_CALLS all return i32; READONLY_MEM_CALLS return f64 (NaN-boxed element)
|
|
676
1128
|
if (SAFE_OFFSET_CALLS.has(node[1])) return 'i32'
|
|
677
1129
|
if (READONLY_MEM_CALLS.has(node[1])) return 'f64'
|
|
1130
|
+
if (PURE_CALL_I32.has(node[1])) return 'i32' // string search/compare → i32
|
|
1131
|
+
if (typeof node[1] === 'string' && node[1].startsWith('$math.')) return 'f64' // transcendentals → f64
|
|
678
1132
|
return null
|
|
679
1133
|
}
|
|
680
1134
|
if (op === 'local.get' || op === 'local.tee') return localTypes.get(node[1]) ?? null
|
|
@@ -683,7 +1137,7 @@ export function hoistInvariantLoop(fn) {
|
|
|
683
1137
|
// Comparisons and `eqz` yield i32 regardless of operand type (i64.eq, f64.lt,
|
|
684
1138
|
// i64.eqz, …) — so the operand-type prefix would mistype them. Catch first.
|
|
685
1139
|
const m = op.slice(dot + 1)
|
|
686
|
-
if (
|
|
1140
|
+
if (CMP_MANTISSA.has(m)) return 'i32'
|
|
687
1141
|
const p = op.slice(0, dot)
|
|
688
1142
|
if (p === 'i32' || p === 'i64' || p === 'f64' || p === 'f32') return p
|
|
689
1143
|
return null
|
|
@@ -707,33 +1161,23 @@ export function hoistInvariantLoop(fn) {
|
|
|
707
1161
|
const newLocals = []
|
|
708
1162
|
const refcount = buildRefcount(fn)
|
|
709
1163
|
|
|
1164
|
+
// Alias-analysis substrate for hoisting typed-array PARAM element loads across distinct-base
|
|
1165
|
+
// stores. `distinctParams` (stamped by compile/index.js from the param-distinctness pass) is the
|
|
1166
|
+
// set of typed-array params PROVEN to be mutually-distinct buffers at every call site. To use it,
|
|
1167
|
+
// resolve a load/store address back to the single param it derives from — through `local.get`,
|
|
1168
|
+
// `i32.add/sub`, and single-def snap locals ($__li/$__ab from prior ptr-offset hoisting).
|
|
1169
|
+
const distinctParams = fn.distinctParams || null
|
|
1170
|
+
const baseParamOf = buildBaseParamOf(fn, bodyStart, distinctParams)
|
|
1171
|
+
|
|
710
1172
|
const processLoop = (loopNode, nested) => {
|
|
711
1173
|
// Inner loops first (bottom-up) — an inner hoist creates a local.get the
|
|
712
1174
|
// outer level can hoist further. Children run in a nested context.
|
|
713
1175
|
for (let i = 1; i < loopNode.length; i++)
|
|
714
1176
|
if (Array.isArray(loopNode[i])) processNode(loopNode[i], loopNode, i, true)
|
|
715
1177
|
|
|
716
|
-
// The loop's effect summary
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
const scan = (node) => {
|
|
720
|
-
if (!Array.isArray(node)) return
|
|
721
|
-
const op = node[0]
|
|
722
|
-
// A vectorized loop (lane/v128 ops) is already register-tight and hand-tuned;
|
|
723
|
-
// extra scalar hoisting there only adds spill pressure — keep it conservative.
|
|
724
|
-
if (op.startsWith('v128.') || /^[if]\d+x\d+\./.test(op)) hasV128 = true
|
|
725
|
-
if (op === 'local.set' || op === 'local.tee') { if (typeof node[1] === 'string') locals.add(node[1]); for (let i = 2; i < node.length; i++) scan(node[i]); return }
|
|
726
|
-
if (op === 'global.set') { if (typeof node[1] === 'string') globals.add(node[1]); for (let i = 2; i < node.length; i++) scan(node[i]); return }
|
|
727
|
-
if (op === 'call') { hasAnyCall = true; if (!SAFE_OFFSET_CALLS.has(node[1]) && !READONLY_MEM_CALLS.has(node[1]) && !NON_MUTATING_CALLS.has(node[1])) hasUnsafeCall = true; for (let i = 2; i < node.length; i++) scan(node[i]); return }
|
|
728
|
-
if (op === 'call_ref' || op === 'call_indirect') { hasAnyCall = hasUnsafeCall = true; for (let i = 1; i < node.length; i++) scan(node[i]); return }
|
|
729
|
-
if ((op === 'f64.store' || op === 'i32.store') && node.length >= 3) {
|
|
730
|
-
hasDirectStore = true
|
|
731
|
-
const a = node[1]
|
|
732
|
-
if (Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' && a[1].startsWith(CELL_PREFIX)) storedCells.add(a[1])
|
|
733
|
-
}
|
|
734
|
-
for (let i = 1; i < node.length; i++) scan(node[i])
|
|
735
|
-
}
|
|
736
|
-
for (let i = 1; i < loopNode.length; i++) scan(loopNode[i])
|
|
1178
|
+
// The loop's effect summary + the proven invariance/purity predicate (shared with
|
|
1179
|
+
// splitLoopPrivateScratch — see loopInvariance). `locals` is the loop's whole write-set.
|
|
1180
|
+
const { pureGiven, locals, hasV128 } = loopInvariance(loopNode, { distinctParams, baseParamOf })
|
|
737
1181
|
|
|
738
1182
|
// Per-subtree local-occurrence counts and write-sets, memoized bottom-up —
|
|
739
1183
|
// the tee-privacy check queries them for EVERY candidate node, and the old
|
|
@@ -772,57 +1216,6 @@ export function hoistInvariantLoop(fn) {
|
|
|
772
1216
|
for (let i = 1; i < loopNode.length; i++)
|
|
773
1217
|
for (const [k, v] of countsOf(loopNode[i])) localCount.set(k, (localCount.get(k) || 0) + v)
|
|
774
1218
|
|
|
775
|
-
// Pure & invariant given `bound` (locals written *within* the candidate, hence
|
|
776
|
-
// local to it). A read of a bound local is OK (its in-subtree value is the
|
|
777
|
-
// teed invariant). A free read must be unwritten by the loop.
|
|
778
|
-
const pureGiven = (node, bound) => {
|
|
779
|
-
if (!Array.isArray(node)) return true // bare operand string/number
|
|
780
|
-
const op = node[0]
|
|
781
|
-
if (op === 'i32.const' || op === 'i64.const' || op === 'f64.const' || op === 'f32.const') return true
|
|
782
|
-
if (op === 'local.get') return typeof node[1] === 'string' && (bound.has(node[1]) || !locals.has(node[1]))
|
|
783
|
-
// A global is invariant only if not set directly AND no call in the loop —
|
|
784
|
-
// any callee may mutate it (no interprocedural effect analysis). (Locals are
|
|
785
|
-
// frame-private, so calls can't touch them; only direct local.set matters.)
|
|
786
|
-
if (op === 'global.get') return typeof node[1] === 'string' && !globals.has(node[1]) && !hasAnyCall
|
|
787
|
-
if (op === 'local.tee') {
|
|
788
|
-
if (typeof node[1] !== 'string') return false
|
|
789
|
-
// The operand is evaluated BEFORE the tee writes $X, so a `local.get $X` inside
|
|
790
|
-
// it reads the loop-carried (previous-iteration) value, not the teed one. Drop
|
|
791
|
-
// $X from `bound` for the operand: `local.tee $X (… $X …)` is a loop recurrence
|
|
792
|
-
// (X = f(X) — e.g. the `while ((nn = nn >>> 1))` induction), NOT invariant.
|
|
793
|
-
const inner = bound.has(node[1]) ? new Set([...bound].filter(b => b !== node[1])) : bound
|
|
794
|
-
return pureGiven(node[2], inner)
|
|
795
|
-
}
|
|
796
|
-
if ((op === 'f64.load' || op === 'i32.load') && node.length === 2) {
|
|
797
|
-
const a = node[1]
|
|
798
|
-
return Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' && a[1].startsWith(CELL_PREFIX)
|
|
799
|
-
&& !hasAnyCall && !storedCells.has(a[1]) && (bound.has(a[1]) || !locals.has(a[1]))
|
|
800
|
-
}
|
|
801
|
-
if (op === 'call') {
|
|
802
|
-
if (SAFE_OFFSET_CALLS.has(node[1]))
|
|
803
|
-
return !hasUnsafeCall && node.slice(2).every(c => pureGiven(c, bound))
|
|
804
|
-
// Read-only heap reads: additionally require no direct store (alias-safe).
|
|
805
|
-
if (READONLY_MEM_CALLS.has(node[1]))
|
|
806
|
-
return !hasUnsafeCall && !hasDirectStore && node.slice(2).every(c => pureGiven(c, bound))
|
|
807
|
-
return false
|
|
808
|
-
}
|
|
809
|
-
// A value-producing `if` whose condition and both arms are pure is itself
|
|
810
|
-
// pure — the tag-dispatch idiom `(if (result f64) tag-check (then read-A)
|
|
811
|
-
// (else read-B))` that wraps __typed_idx/__str_idx element access.
|
|
812
|
-
if (op === 'if') {
|
|
813
|
-
for (let i = 1; i < node.length; i++) {
|
|
814
|
-
const c = node[i]
|
|
815
|
-
if (!Array.isArray(c)) continue
|
|
816
|
-
if (c[0] === 'result') continue
|
|
817
|
-
if (c[0] === 'then' || c[0] === 'else') { if (!c.slice(1).every(x => pureGiven(x, bound))) return false }
|
|
818
|
-
else if (!pureGiven(c, bound)) return false // the condition
|
|
819
|
-
}
|
|
820
|
-
return true
|
|
821
|
-
}
|
|
822
|
-
if (PURE_LICM_OPS.has(op)) return node.slice(1).every(c => pureGiven(c, bound))
|
|
823
|
-
return false
|
|
824
|
-
}
|
|
825
|
-
|
|
826
1219
|
const isHoistable = (node) => {
|
|
827
1220
|
if (!Array.isArray(node)) return false
|
|
828
1221
|
const op = node[0]
|
|
@@ -849,9 +1242,10 @@ export function hoistInvariantLoop(fn) {
|
|
|
849
1242
|
if (!Array.isArray(node)) return
|
|
850
1243
|
if (node[0] === 'loop') return // already processed bottom-up
|
|
851
1244
|
if (isHoistable(node) && (refcount.get(node) || 0) <= 1 && (refcount.get(parent) || 0) <= 1) {
|
|
852
|
-
//
|
|
853
|
-
// (BigInt
|
|
854
|
-
|
|
1245
|
+
// stableKey: hoistable boxed-pointer subtrees carry i64.const NaN-box prefixes
|
|
1246
|
+
// (BigInt) that plain JSON.stringify can't serialize, and it also collapses
|
|
1247
|
+
// Infinity/-Infinity/NaN→null & -0→0 — both would dedup distinct invariants.
|
|
1248
|
+
const key = JSON.stringify(node, stableKey)
|
|
855
1249
|
let arr = sites.get(key); if (!arr) { arr = []; sites.set(key, arr) }
|
|
856
1250
|
arr.push({ parent, idx, node })
|
|
857
1251
|
return
|
|
@@ -983,16 +1377,17 @@ export function narrowLoopBound(fn) {
|
|
|
983
1377
|
const newLocals = []
|
|
984
1378
|
const refcount = buildRefcount(fn)
|
|
985
1379
|
|
|
986
|
-
// `(f64.lt (
|
|
987
|
-
// `(f64.
|
|
1380
|
+
// `i < bound` as `(f64.lt (convert i) bound)` or mirrored `(f64.gt bound (convert i))`.
|
|
1381
|
+
// `i <= bound` as `(f64.le (convert i) bound)` or mirrored `(f64.ge bound (convert i))`.
|
|
988
1382
|
const match = (n) => {
|
|
989
|
-
const
|
|
990
|
-
const
|
|
1383
|
+
const lt = n[0] === 'f64.lt', gt = n[0] === 'f64.gt', le = n[0] === 'f64.le', ge = n[0] === 'f64.ge'
|
|
1384
|
+
const conv = lt || le ? n[1] : gt || ge ? n[2] : null
|
|
1385
|
+
const bnd = lt || le ? n[2] : gt || ge ? n[1] : null
|
|
991
1386
|
if (!Array.isArray(conv) || conv[0] !== 'f64.convert_i32_s') return null
|
|
992
1387
|
const ig = conv[1]
|
|
993
1388
|
if (!Array.isArray(ig) || ig[0] !== 'local.get' || typeof ig[1] !== 'string') return null
|
|
994
1389
|
if (!Array.isArray(bnd) || bnd[0] !== 'local.get' || typeof bnd[1] !== 'string') return null
|
|
995
|
-
return { ctr: ig[1], bound: bnd[1] }
|
|
1390
|
+
return { ctr: ig[1], bound: bnd[1], op: le || ge ? 'le' : 'lt' }
|
|
996
1391
|
}
|
|
997
1392
|
|
|
998
1393
|
const processLoop = (loopNode) => {
|
|
@@ -1022,18 +1417,34 @@ export function narrowLoopBound(fn) {
|
|
|
1022
1417
|
}
|
|
1023
1418
|
for (let i = 1; i < loopNode.length; i++) collect(loopNode[i])
|
|
1024
1419
|
|
|
1025
|
-
|
|
1420
|
+
// One snap per distinct (bound, op): `i < n` and `i <= n` of the SAME bound
|
|
1421
|
+
// need different snapped i32 values (ceil vs floor).
|
|
1422
|
+
const snapFor = new Map()
|
|
1026
1423
|
const snaps = []
|
|
1424
|
+
const I32_MIN = -2147483648
|
|
1027
1425
|
for (const { node, m } of sites) {
|
|
1028
|
-
|
|
1426
|
+
const key = `${m.bound}|${m.op}`
|
|
1427
|
+
let snap = snapFor.get(key)
|
|
1029
1428
|
if (!snap) {
|
|
1030
1429
|
snap = freshLb()
|
|
1031
|
-
snapFor.set(
|
|
1430
|
+
snapFor.set(key, snap)
|
|
1032
1431
|
newLocals.push(['local', snap, 'i32'])
|
|
1033
|
-
|
|
1432
|
+
// `i < n` ⟺ `i < ceil(n)`: trunc_sat(NaN)=0 makes `i<0` false — matches `i<NaN`;
|
|
1433
|
+
// ±Inf → I32_MAX/I32_MIN, both correct. NaN-safe for free.
|
|
1434
|
+
// `i <= n` ⟺ `i <= floor(n)`, BUT trunc_sat(floor(NaN))=0 would make `i<=0` run
|
|
1435
|
+
// one iteration at i=0, while JS (`i<=NaN` is false) runs zero. Guard the NaN
|
|
1436
|
+
// case to I32_MIN (below any non-negative counter ⇒ zero iterations). ±Inf are
|
|
1437
|
+
// already correct (floor(+Inf)→I32_MAX, floor(-Inf)→I32_MIN; Inf==Inf is true).
|
|
1438
|
+
snaps.push(['local.set', snap, m.op === 'le'
|
|
1439
|
+
? ['select',
|
|
1440
|
+
['i32.trunc_sat_f64_s', ['f64.floor', ['local.get', m.bound]]],
|
|
1441
|
+
['i32.const', I32_MIN],
|
|
1442
|
+
['f64.eq', ['local.get', m.bound], ['local.get', m.bound]]]
|
|
1443
|
+
: ['i32.trunc_sat_f64_s', ['f64.ceil', ['local.get', m.bound]]]])
|
|
1034
1444
|
}
|
|
1035
1445
|
node.length = 3
|
|
1036
|
-
node[0] =
|
|
1446
|
+
node[0] = m.op === 'le' ? 'i32.le_s' : 'i32.lt_s'
|
|
1447
|
+
node[1] = ['local.get', m.ctr]; node[2] = ['local.get', snap]
|
|
1037
1448
|
}
|
|
1038
1449
|
return snaps
|
|
1039
1450
|
}
|
|
@@ -1260,6 +1671,19 @@ export function cseScalarLoad(fn) {
|
|
|
1260
1671
|
// the two passes cover deliberately different op sets.
|
|
1261
1672
|
const COMMUTATIVE = new Set(['f64.mul', 'f64.add', 'i32.mul', 'i32.add', 'i32.and', 'i32.or', 'i32.xor', 'i64.mul', 'i64.add', 'i64.and', 'i64.or', 'i64.xor'])
|
|
1262
1673
|
|
|
1674
|
+
// Presence of one of these arms csePureExprLoop (it CSEs redundant pure f64/i32
|
|
1675
|
+
// arithmetic within the loop; the gate is just "is this loop expensive enough to
|
|
1676
|
+
// be worth the pass"). The whole class of transcendental helpers qualifies — each
|
|
1677
|
+
// is a multi-instruction polynomial approximation, so a loop built around exp/log/
|
|
1678
|
+
// pow deserves the same arithmetic-CSE a trig loop already got. Gating on the class,
|
|
1679
|
+
// not a benchmark shape; the CSE itself is bit-exact (pure-subexpr dedup only).
|
|
1680
|
+
const LOOP_CSE_EXPENSIVE = new Set([
|
|
1681
|
+
'$math.sin', '$math.cos', '$math.tan', '$math.sin_core', '$math.cos_core',
|
|
1682
|
+
'$math.exp', '$math.expm1', '$math.log', '$math.log2', '$math.log10', '$math.log1p',
|
|
1683
|
+
'$math.pow', '$math.atan', '$math.asin', '$math.acos', '$math.atan2',
|
|
1684
|
+
'$math.sinh', '$math.cosh', '$math.tanh', '$math.cbrt', '$math.hypot',
|
|
1685
|
+
])
|
|
1686
|
+
|
|
1263
1687
|
export function csePureExpr(fn) {
|
|
1264
1688
|
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
1265
1689
|
const bodyStart = findBodyStart(fn)
|
|
@@ -1396,16 +1820,15 @@ export function csePureExprLoop(fn) {
|
|
|
1396
1820
|
if (bodyStart < 0) return
|
|
1397
1821
|
|
|
1398
1822
|
let hasLoop = false
|
|
1399
|
-
let
|
|
1823
|
+
let hasExpensiveCall = false
|
|
1400
1824
|
const scanShape = (n) => {
|
|
1401
1825
|
if (!Array.isArray(n)) return
|
|
1402
1826
|
if (n[0] === 'loop') hasLoop = true
|
|
1403
|
-
if (n[0] === 'call' && (n[1]
|
|
1404
|
-
|| n[1] === '$math.sin_core' || n[1] === '$math.cos_core')) hasTrigCall = true
|
|
1827
|
+
if (n[0] === 'call' && LOOP_CSE_EXPENSIVE.has(n[1])) hasExpensiveCall = true
|
|
1405
1828
|
for (let i = 1; i < n.length; i++) scanShape(n[i])
|
|
1406
1829
|
}
|
|
1407
1830
|
for (let i = bodyStart; i < fn.length; i++) scanShape(fn[i])
|
|
1408
|
-
if (!hasLoop || !
|
|
1831
|
+
if (!hasLoop || !hasExpensiveCall) return
|
|
1409
1832
|
|
|
1410
1833
|
let snapId = nextLocalId(fn, 'pe')
|
|
1411
1834
|
const newLocals = []
|
|
@@ -1745,6 +2168,121 @@ export function deadStoreElim(fn) {
|
|
|
1745
2168
|
}
|
|
1746
2169
|
}
|
|
1747
2170
|
|
|
2171
|
+
/**
|
|
2172
|
+
* Forward-substitute a single-def / single-use local into its sole use, eliminating the local,
|
|
2173
|
+
* its `local.set` and its `local.get`. This is watr's "propagate": jz emits short-lived address/
|
|
2174
|
+
* index temps (`set $t (i32.add …); … (load $t) …`) that the WAT optimizer folds away — the
|
|
2175
|
+
* dominant slice of the watr-optimizer-OFF size gap (a matmul carries ~14 such temps, ≈ the whole
|
|
2176
|
+
* delta). Closing it lets jz lean on its own optimizer instead of watr's.
|
|
2177
|
+
*
|
|
2178
|
+
* SOUND only when moving the def's RHS `E` to the use can't change order or value:
|
|
2179
|
+
* - `E` is PURE — reads only locals (no load/store/call/global/memory): its value is a function
|
|
2180
|
+
* of its read-locals alone, and it has no side effects to reorder past intervening statements.
|
|
2181
|
+
* - the use is at the SAME loop nesting — never under a deeper `loop` than the def (which would
|
|
2182
|
+
* re-evaluate `E` per iteration and could read a clobbered input).
|
|
2183
|
+
* - no read-local of `E` is written between the def and the use (incl. the use statement itself).
|
|
2184
|
+
* Anything it can't prove safe is left untouched.
|
|
2185
|
+
*/
|
|
2186
|
+
export function propagateSingleUse(fn) {
|
|
2187
|
+
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
2188
|
+
const bodyStart = findBodyStart(fn)
|
|
2189
|
+
if (bodyStart < 0) return
|
|
2190
|
+
|
|
2191
|
+
// Leave a vectorized function alone: it carries v128 lane sequences that are already register-tight,
|
|
2192
|
+
// and forward-substituting into them only adds pressure (mirrors hoistInvariantLoop's hasV128 gate).
|
|
2193
|
+
let hasV128 = false
|
|
2194
|
+
const scanV = (n) => { if (hasV128 || !Array.isArray(n)) return; const op = n[0]; if (typeof op === 'string' && (op.startsWith('v128.') || /^[if]\d+x\d+\./.test(op))) { hasV128 = true; return } for (let i = 1; i < n.length; i++) scanV(n[i]) }
|
|
2195
|
+
for (let i = bodyStart; i < fn.length && !hasV128; i++) scanV(fn[i])
|
|
2196
|
+
if (hasV128) return
|
|
2197
|
+
|
|
2198
|
+
// def/use tally over the whole body. A `local.tee` is both a read and a write — exclude any
|
|
2199
|
+
// tee'd local from candidacy rather than reason about it.
|
|
2200
|
+
const setN = new Map(), getN = new Map(), teed = new Set()
|
|
2201
|
+
const tally = (n) => {
|
|
2202
|
+
if (!Array.isArray(n)) return
|
|
2203
|
+
const op = n[0]
|
|
2204
|
+
if (op === 'local.set' && typeof n[1] === 'string') setN.set(n[1], (setN.get(n[1]) || 0) + 1)
|
|
2205
|
+
else if (op === 'local.tee' && typeof n[1] === 'string') teed.add(n[1])
|
|
2206
|
+
else if (op === 'local.get' && typeof n[1] === 'string') getN.set(n[1], (getN.get(n[1]) || 0) + 1)
|
|
2207
|
+
for (let i = 1; i < n.length; i++) tally(n[i])
|
|
2208
|
+
}
|
|
2209
|
+
for (let i = bodyStart; i < fn.length; i++) tally(fn[i])
|
|
2210
|
+
|
|
2211
|
+
const cand = new Set()
|
|
2212
|
+
for (const [name, c] of setN) if (c === 1 && getN.get(name) === 1 && !teed.has(name)) cand.add(name)
|
|
2213
|
+
if (!cand.size) return
|
|
2214
|
+
|
|
2215
|
+
const movablePure = (n) => {
|
|
2216
|
+
if (!Array.isArray(n)) return true
|
|
2217
|
+
const op = n[0]
|
|
2218
|
+
if (op === 'local.get') return true
|
|
2219
|
+
if (op === 'local.set' || op === 'local.tee' || op === 'call' || op === 'call_indirect' || op === 'call_ref'
|
|
2220
|
+
|| op === 'global.get' || op === 'global.set' || op === 'memory.size' || op === 'memory.grow'
|
|
2221
|
+
|| op === 'memory.copy' || op === 'memory.fill') return false
|
|
2222
|
+
if (typeof op === 'string' && (op.includes('.load') || op.includes('.store') || op.includes('.atomic'))) return false
|
|
2223
|
+
for (let i = 1; i < n.length; i++) if (!movablePure(n[i])) return false
|
|
2224
|
+
return true
|
|
2225
|
+
}
|
|
2226
|
+
const readsOf = (n, out) => { if (!Array.isArray(n)) return; if (n[0] === 'local.get' && typeof n[1] === 'string') out.add(n[1]); for (let i = 1; i < n.length; i++) readsOf(n[i], out) }
|
|
2227
|
+
const writesAny = (n, R) => { if (!Array.isArray(n)) return false; if ((n[0] === 'local.set' || n[0] === 'local.tee') && R.has(n[1])) return true; for (let i = 1; i < n.length; i++) if (writesAny(n[i], R)) return true; return false }
|
|
2228
|
+
// Locate the (local.get $t) within `root`'s subtree (not root itself); flag if it sits under a
|
|
2229
|
+
// `loop` relative to root (→ would re-evaluate the moved RHS each iteration).
|
|
2230
|
+
const locateUse = (root, t) => {
|
|
2231
|
+
let found = null
|
|
2232
|
+
const rec = (node, underLoop) => {
|
|
2233
|
+
if (found || !Array.isArray(node)) return
|
|
2234
|
+
for (let i = 1; i < node.length; i++) {
|
|
2235
|
+
if (found) return
|
|
2236
|
+
const c = node[i]
|
|
2237
|
+
if (Array.isArray(c) && c[0] === 'local.get' && c[1] === t) { found = { parent: node, idx: i, underLoop }; return }
|
|
2238
|
+
rec(c, underLoop || node[0] === 'loop')
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
rec(root, false)
|
|
2242
|
+
return found
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
const removed = new Set()
|
|
2246
|
+
const optimizeList = (list, start) => {
|
|
2247
|
+
for (let i = start; i < list.length; i++) {
|
|
2248
|
+
const s = list[i]
|
|
2249
|
+
if (!Array.isArray(s)) continue
|
|
2250
|
+
// s.length === 3: an explicit-RHS `(local.set $t E)`. A bare `(local.set $t)` (length 2)
|
|
2251
|
+
// binds a value already on the stack — e.g. the try_table catch payload — and has no RHS to
|
|
2252
|
+
// move; treating its undefined RHS as movable would substitute `undefined` into the use.
|
|
2253
|
+
if (s[0] === 'local.set' && s.length === 3 && typeof s[1] === 'string' && cand.has(s[1]) && movablePure(s[2])) {
|
|
2254
|
+
const t = s[1], E = s[2], R = new Set(); readsOf(E, R)
|
|
2255
|
+
for (let j = i + 1; j < list.length; j++) {
|
|
2256
|
+
const sj = list[j]
|
|
2257
|
+
const u = Array.isArray(sj) ? locateUse(sj, t) : null
|
|
2258
|
+
if (u) { // found the sole use's statement
|
|
2259
|
+
if (!u.underLoop && !writesAny(sj, R)) { // same nesting + read-locals intact
|
|
2260
|
+
u.parent[u.idx] = E
|
|
2261
|
+
list.splice(i, 1)
|
|
2262
|
+
cand.delete(t); removed.add(t)
|
|
2263
|
+
i-- // re-process from the freed slot (forward chains)
|
|
2264
|
+
}
|
|
2265
|
+
break // use located — stop scanning this candidate
|
|
2266
|
+
}
|
|
2267
|
+
if (writesAny(sj, R)) break // a read-local clobbered before the use → can't move
|
|
2268
|
+
}
|
|
2269
|
+
if (removed.has(s[1])) continue
|
|
2270
|
+
}
|
|
2271
|
+
// recurse into nested statement lists
|
|
2272
|
+
if (s[0] === 'block' || s[0] === 'loop') {
|
|
2273
|
+
let k = 1; while (k < s.length && Array.isArray(s[k]) && s[k][0] === 'result') k++
|
|
2274
|
+
optimizeList(s, k)
|
|
2275
|
+
} else if (s[0] === 'if') {
|
|
2276
|
+
for (let k = 1; k < s.length; k++) { const c = s[k]; if (Array.isArray(c) && (c[0] === 'then' || c[0] === 'else')) optimizeList(c, 1) }
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
optimizeList(fn, bodyStart)
|
|
2281
|
+
|
|
2282
|
+
// drop the now-orphaned decls (deferred so the body walk above sees stable indices)
|
|
2283
|
+
if (removed.size) for (let i = fn.length - 1; i >= 2; i--) { const c = fn[i]; if (Array.isArray(c) && c[0] === 'local' && removed.has(c[1])) fn.splice(i, 1) }
|
|
2284
|
+
}
|
|
2285
|
+
|
|
1748
2286
|
/**
|
|
1749
2287
|
* Module-wide scan for "volatile" globals — those mutated (`global.set`) in any
|
|
1750
2288
|
* function other than `$__start`. Globals written only in `$__start` are
|
|
@@ -2100,15 +2638,8 @@ function inferTypeFromContext(fn, gName, bodyStart) {
|
|
|
2100
2638
|
}
|
|
2101
2639
|
if (pOp === 'i32.store' && idx === 2) { inferred = 'i32'; return } // addr
|
|
2102
2640
|
if (pOp === 'f64.store' && idx === 2) { inferred = 'f64'; return } // addr can be i32, but value is f64
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
pOp === 'i32.ge_s' || pOp === 'i32.ge_u') {
|
|
2106
|
-
// Comparison — could be i32, but in jz NaN-boxing scheme most globals are f64
|
|
2107
|
-
// Only if we can confirm from local.set context
|
|
2108
|
-
}
|
|
2109
|
-
if (pOp === 'local.set' && idx === 0) {
|
|
2110
|
-
// Can't determine local type from here easily
|
|
2111
|
-
}
|
|
2641
|
+
// i32 comparisons already matched the `i32.` prefix above; a `local.set`
|
|
2642
|
+
// parent tells us nothing here — both fall through to the f64 default.
|
|
2112
2643
|
}
|
|
2113
2644
|
}
|
|
2114
2645
|
// Default: f64 (the NaN-boxing carrier)
|
|
@@ -2132,22 +2663,33 @@ function inferTypeFromContext(fn, gName, bodyStart) {
|
|
|
2132
2663
|
*
|
|
2133
2664
|
* Mutates `funcs` in place; writes new global decls via `addGlobal(name, constLiteral)`.
|
|
2134
2665
|
*/
|
|
2666
|
+
// `String(number)` keeps only ~9 significant digits in the self-host kernel (jz's number
|
|
2667
|
+
// formatter — see README "differences"). The old pool keyed constants by `n:${c[1]}` (a toString)
|
|
2668
|
+
// and emitted them via that same string, so in the kernel a constant both LOST precision
|
|
2669
|
+
// (0.041666666666666664 → 0x1.5555558325751p-5) and could MERGE with a distinct value sharing its
|
|
2670
|
+
// 9-digit prefix. Key by the exact 64 bits instead (a Float64Array/Uint32Array union — the
|
|
2671
|
+
// numHashLiteral pattern, which self-hosts; the sign bit distinguishes -0/+0 for free) and emit
|
|
2672
|
+
// the original NUMBER, which `declGlobal` lowers to a binary `f64.const` (exact, no string).
|
|
2673
|
+
const _FCB = new Float64Array(1), _FCBu = new Uint32Array(_FCB.buffer)
|
|
2674
|
+
const f64BitsKey = (n) => { _FCB[0] = n; return `n:${_FCBu[0]}:${_FCBu[1]}` }
|
|
2675
|
+
|
|
2135
2676
|
export function hoistConstantPool(funcs, addGlobal) {
|
|
2136
2677
|
const MIN_USES = 2
|
|
2137
2678
|
// Single walk: count occurrences AND record each f64.const site for direct rewrite.
|
|
2138
2679
|
// Avoids a second full-AST traversal in the rewrite phase.
|
|
2139
2680
|
const counts = new Map()
|
|
2681
|
+
// NOTE: not `valueOf` — a local named like an Object method self-host-miscompiles (the
|
|
2682
|
+
// kernel's dynamic dispatch confuses it). key → exact original c[1] (number, or source string).
|
|
2683
|
+
const exactVal = new Map()
|
|
2140
2684
|
const sites = [] // { parent, idx, key }
|
|
2141
2685
|
const walk = (node) => {
|
|
2142
2686
|
if (!Array.isArray(node)) return
|
|
2143
2687
|
for (let i = 0; i < node.length; i++) {
|
|
2144
2688
|
const c = node[i]
|
|
2145
2689
|
if (Array.isArray(c) && c[0] === 'f64.const' && (typeof c[1] === 'number' || typeof c[1] === 'string')) {
|
|
2146
|
-
|
|
2147
|
-
const k = typeof c[1] === 'number'
|
|
2148
|
-
? (Object.is(c[1], -0) ? 'n:-0' : `n:${c[1]}`)
|
|
2149
|
-
: `s:${c[1]}`
|
|
2690
|
+
const k = typeof c[1] === 'number' ? f64BitsKey(c[1]) : `s:${c[1]}`
|
|
2150
2691
|
counts.set(k, (counts.get(k) || 0) + 1)
|
|
2692
|
+
if (!exactVal.has(k)) exactVal.set(k, c[1])
|
|
2151
2693
|
sites.push({ parent: node, idx: i, key: k })
|
|
2152
2694
|
}
|
|
2153
2695
|
walk(c)
|
|
@@ -2160,8 +2702,9 @@ export function hoistConstantPool(funcs, addGlobal) {
|
|
|
2160
2702
|
let gId = 0
|
|
2161
2703
|
for (const [k] of sorted) {
|
|
2162
2704
|
const name = `__fc${gId++}`
|
|
2163
|
-
const
|
|
2164
|
-
|
|
2705
|
+
// The EXACT original c[1] (a number → binary f64.const; or a source hex/decimal string),
|
|
2706
|
+
// never the lossy k-derived toString.
|
|
2707
|
+
addGlobal(name, exactVal.get(k))
|
|
2165
2708
|
hoist.set(k, name)
|
|
2166
2709
|
}
|
|
2167
2710
|
if (!hoist.size) return
|
|
@@ -2269,9 +2812,7 @@ export function specializeMkptr(funcs, addFunc, parseWat) {
|
|
|
2269
2812
|
// $__mkptr inline fast path: bake (type, aux) literals into i64.const template.
|
|
2270
2813
|
if (target === '$__mkptr' && spec.inline && parts[0].startsWith('L:') && parts[1].startsWith('L:')) {
|
|
2271
2814
|
const type = +parts[0].slice(2), aux = +parts[1].slice(2)
|
|
2272
|
-
const tmpl =
|
|
2273
|
-
| ((BigInt(type) & BigInt(LAYOUT.TAG_MASK)) << BigInt(LAYOUT.TAG_SHIFT))
|
|
2274
|
-
| ((BigInt(aux) & BigInt(LAYOUT.AUX_MASK)) << BigInt(LAYOUT.AUX_SHIFT))
|
|
2815
|
+
const tmpl = ptrBits(type, aux) // box prefix (offset OR'd in at runtime below)
|
|
2275
2816
|
// Third arg (offset) may also be literal — emit (f64.const nan:…) then.
|
|
2276
2817
|
if (parts[2].startsWith('L:')) {
|
|
2277
2818
|
// Fully literal: all sites can be f64.const — no helper needed, handled in rewrite below.
|
|
@@ -2313,11 +2854,7 @@ export function specializeMkptr(funcs, addFunc, parseWat) {
|
|
|
2313
2854
|
// $__mkptr fully literal (rare — mkPtrIR usually folds these ahead of us, but defensive):
|
|
2314
2855
|
if (target === '$__mkptr' && parts[0].startsWith('L:') && parts[1].startsWith('L:') && parts[2].startsWith('L:')) {
|
|
2315
2856
|
const type = +parts[0].slice(2), aux = +parts[1].slice(2), off = +parts[2].slice(2)
|
|
2316
|
-
const
|
|
2317
|
-
| ((BigInt(type) & BigInt(LAYOUT.TAG_MASK)) << BigInt(LAYOUT.TAG_SHIFT))
|
|
2318
|
-
| ((BigInt(aux) & BigInt(LAYOUT.AUX_MASK)) << BigInt(LAYOUT.AUX_SHIFT))
|
|
2319
|
-
| (BigInt(off >>> 0) & BigInt(LAYOUT.OFFSET_MASK))
|
|
2320
|
-
const n = ['f64.const', 'nan:0x' + bits.toString(16).toUpperCase().padStart(16, '0')]
|
|
2857
|
+
const n = ['f64.const', 'nan:' + i64Hex(ptrBits(type, aux, off))]
|
|
2321
2858
|
n.type = 'f64'
|
|
2322
2859
|
parent[idx] = n
|
|
2323
2860
|
continue
|
|
@@ -2641,6 +3178,174 @@ export function foldStrDispatchF64(fn) {
|
|
|
2641
3178
|
for (let i = bodyStart; i < fn.length; i++) fn[i] = foldNode(fn[i])
|
|
2642
3179
|
}
|
|
2643
3180
|
|
|
3181
|
+
/**
|
|
3182
|
+
* Loop-unswitch a polymorphic typed-array PARAM loop on the pointer type so the
|
|
3183
|
+
* Float64Array case hoists its base and vectorizes.
|
|
3184
|
+
*
|
|
3185
|
+
* `export function f(buf,n){ for(let i=0;i<n;i++) buf[i]=g(buf[i],i) }` emits a
|
|
3186
|
+
* per-iteration POLYMORPHIC store `(drop (if tag(buf)==ARRAY (then __arr_set_idx_ptr;
|
|
3187
|
+
* local.set $buf) (else f64.store __ptr_offset(buf)+i<<3)))` and a read
|
|
3188
|
+
* `__to_num(reinterpret(__typed_idx(reinterpret(buf), i)))` — re-decoding the NaN-box
|
|
3189
|
+
* base every iteration. The `local.set $buf` realloc reassign marks the param unsafe,
|
|
3190
|
+
* so hoistInvariantPtrOffset bails and the loop never vectorizes.
|
|
3191
|
+
*
|
|
3192
|
+
* Insert a ONCE-before-loop test "is buf a (non-BigInt) Float64Array?": yes → a fast
|
|
3193
|
+
* loop with the base hoisted to an i32 local, the read collapsed to `f64.load`, and the
|
|
3194
|
+
* polymorphic store replaced by a direct `f64.store` (no calls) — which vectorizeLaneLocal
|
|
3195
|
+
* then lifts to f64x2. no → the original block verbatim (bit-exact fallback for ARRAY and
|
|
3196
|
+
* every other element width). Float64Array (owned aux=7 or view aux=15) is the ONLY gated
|
|
3197
|
+
* type: the else-branch f64.store is 8-byte, valid only for f64 elements; Int32Array /
|
|
3198
|
+
* Uint8Array / BigInt64Array (aux 4 / 1 / 23) all fall to the verbatim path. The global-
|
|
3199
|
+
* Float64Array path already lowers reads to f64.load, proving f64.load == the __to_num
|
|
3200
|
+
* read for f64 elements (bit-exact, incl. NaN). All helpers are nested function decls
|
|
3201
|
+
* (no ctx param) per the self-host discipline.
|
|
3202
|
+
*/
|
|
3203
|
+
export function unswitchTypedParamLoop(fn) {
|
|
3204
|
+
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
3205
|
+
const bodyStart = findBodyStart(fn)
|
|
3206
|
+
if (bodyStart < 0) return
|
|
3207
|
+
const f64Params = new Set()
|
|
3208
|
+
for (let i = 2; i < bodyStart; i++) {
|
|
3209
|
+
const c = fn[i]
|
|
3210
|
+
if (Array.isArray(c) && c[0] === 'param' && typeof c[1] === 'string' && c[2] === 'f64') f64Params.add(c[1])
|
|
3211
|
+
}
|
|
3212
|
+
if (!f64Params.size) return
|
|
3213
|
+
|
|
3214
|
+
const F64 = TYPED_ELEM_CODE.Float64Array, F64V = F64 | TYPED_ELEM_VIEW_FLAG
|
|
3215
|
+
const newLocals = []
|
|
3216
|
+
let baseId = nextLocalId(fn, 'utb')
|
|
3217
|
+
|
|
3218
|
+
const clone = (n) => Array.isArray(n) ? n.map(clone) : n
|
|
3219
|
+
const has = (n, pred) => Array.isArray(n) && (pred(n) || n.some((c, i) => i > 0 && has(c, pred)))
|
|
3220
|
+
const writes = (n, name) => has(n, (x) => (x[0] === 'local.set' || x[0] === 'local.tee') && x[1] === name)
|
|
3221
|
+
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
|
|
3222
|
+
const typedIdx = (n, p) => Array.isArray(n) && n[0] === 'call' && n[1] === '$__typed_idx' && n.length >= 4 && reintParam(n[2], p)
|
|
3223
|
+
|
|
3224
|
+
// Clone, collapsing the typed-array read to a direct f64.load(base + IND<<3):
|
|
3225
|
+
// __to_num(reinterpret(__typed_idx(reinterpret P, IND))) — and the bare form too.
|
|
3226
|
+
function cloneRead(n, p, base) {
|
|
3227
|
+
if (!Array.isArray(n)) return n
|
|
3228
|
+
if (n[0] === 'call' && n[1] === '$__to_num' && n.length === 3
|
|
3229
|
+
&& Array.isArray(n[2]) && n[2][0] === 'i64.reinterpret_f64' && typedIdx(n[2][1], p))
|
|
3230
|
+
return ['f64.load', ['i32.add', ['local.get', base], ['i32.shl', clone(n[2][1][3]), ['i32.const', 3]]]]
|
|
3231
|
+
if (typedIdx(n, p))
|
|
3232
|
+
return ['f64.load', ['i32.add', ['local.get', base], ['i32.shl', clone(n[3]), ['i32.const', 3]]]]
|
|
3233
|
+
return n.map((c, i) => i === 0 ? c : cloneRead(c, p, base))
|
|
3234
|
+
}
|
|
3235
|
+
|
|
3236
|
+
function processBlock(blockNode, parent, idx) {
|
|
3237
|
+
if (!Array.isArray(blockNode) || blockNode[0] !== 'block') return
|
|
3238
|
+
let loopNode = null, blockLabel = null
|
|
3239
|
+
const preamble = []
|
|
3240
|
+
for (let i = 1; i < blockNode.length; i++) {
|
|
3241
|
+
const c = blockNode[i]
|
|
3242
|
+
if (i === 1 && typeof c === 'string' && c.startsWith('$')) { blockLabel = c; continue }
|
|
3243
|
+
if (Array.isArray(c) && c[0] === 'loop') { if (loopNode) return; loopNode = c }
|
|
3244
|
+
else if (Array.isArray(c) && c[0] === 'local.set' && !loopNode) preamble.push(c)
|
|
3245
|
+
else if (Array.isArray(c)) return
|
|
3246
|
+
}
|
|
3247
|
+
if (!loopNode || !blockLabel) return
|
|
3248
|
+
const loopLabel = typeof loopNode[1] === 'string' && loopNode[1].startsWith('$') ? loopNode[1] : null
|
|
3249
|
+
if (!loopLabel) return
|
|
3250
|
+
const endIdx = loopNode.length - 1
|
|
3251
|
+
if (!(Array.isArray(loopNode[endIdx]) && loopNode[endIdx][0] === 'br' && loopNode[endIdx][1] === loopLabel)) return
|
|
3252
|
+
const incNode = loopNode[endIdx - 1]
|
|
3253
|
+
if (!Array.isArray(incNode) || incNode[0] !== 'local.set' || !Array.isArray(incNode[2]) || incNode[2][0] !== 'i32.add') return
|
|
3254
|
+
const incVar = incNode[1], inc = incNode[2]
|
|
3255
|
+
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
|
|
3256
|
+
const body = loopNode.slice(3, endIdx - 1)
|
|
3257
|
+
if (body.length < 4) return
|
|
3258
|
+
|
|
3259
|
+
// Find the polymorphic-store `if` by scanning (it's followed by a `drop` of its
|
|
3260
|
+
// f64 result; in the IR the two are separate statements, not (drop (if …))).
|
|
3261
|
+
let storeIdx = -1, paramName = null, elseStore = null
|
|
3262
|
+
for (let i = 0; i < body.length; i++) {
|
|
3263
|
+
const c = body[i]
|
|
3264
|
+
if (!Array.isArray(c) || c[0] !== 'if' || !Array.isArray(c[1]) || c[1][0] !== 'result' || c[1][1] !== 'f64') continue
|
|
3265
|
+
let thenArm = null, elseArm = null
|
|
3266
|
+
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 } }
|
|
3267
|
+
if (!thenArm || !elseArm) continue
|
|
3268
|
+
let p = null
|
|
3269
|
+
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] }
|
|
3270
|
+
if (!p || !has(thenArm, (x) => x[0] === 'call' && x[1] === '$__arr_set_idx_ptr')) continue
|
|
3271
|
+
// The bare `f64.store(__ptr_offset(o)+i<<3)` is the non-ARRAY fallback. It may be
|
|
3272
|
+
// nested under an OBJECT/HASH → __dyn_set guard (emitPolymorphicElementStore's
|
|
3273
|
+
// dyn-prop safety fork) — descend to find it; the fast path replaces the whole
|
|
3274
|
+
// store with a direct f64.load/store anyway (a proven Float64Array is never an
|
|
3275
|
+
// OBJECT, so its dyn arm is dead there). Still bail on the 3-way __typed_set_idx
|
|
3276
|
+
// form (mixed element widths — the f64.store fallback isn't the sole non-ARRAY case).
|
|
3277
|
+
const findRawStore = (n) => {
|
|
3278
|
+
if (!Array.isArray(n)) return null
|
|
3279
|
+
if (n[0] === 'f64.store' && Array.isArray(n[1]) && n[1][0] === 'i32.add' &&
|
|
3280
|
+
Array.isArray(n[1][2]) && n[1][2][0] === 'i32.shl' &&
|
|
3281
|
+
has(n[1], (x) => x[0] === 'call' && x[1] === '$__ptr_offset')) return n
|
|
3282
|
+
for (let k = 1; k < n.length; k++) { const r = findRawStore(n[k]); if (r) return r }
|
|
3283
|
+
return null
|
|
3284
|
+
}
|
|
3285
|
+
if (has(elseArm, (x) => x[0] === 'call' && x[1] === '$__typed_set_idx')) continue
|
|
3286
|
+
const es = findRawStore(elseArm)
|
|
3287
|
+
if (!es) continue
|
|
3288
|
+
storeIdx = i; paramName = p; elseStore = es; break
|
|
3289
|
+
}
|
|
3290
|
+
if (storeIdx < 0) return
|
|
3291
|
+
const shiftIdx = elseStore[1][2][1] // the index from the store's (i32.shl IDX 3)
|
|
3292
|
+
// The read uses the IV directly; the store uses a snapshot `$asi = $iv`. Emit the
|
|
3293
|
+
// store against the IV too so the vectorizer unifies the load/store lanes — bit-exact
|
|
3294
|
+
// ($asi == $iv). Bail if the store index isn't the IV or a snapshot of it.
|
|
3295
|
+
let storeIdxName = null
|
|
3296
|
+
if (Array.isArray(shiftIdx) && shiftIdx[0] === 'local.get') storeIdxName = shiftIdx[1]
|
|
3297
|
+
if (storeIdxName !== incVar &&
|
|
3298
|
+
!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
|
|
3299
|
+
// The store-if pushes f64; a following `drop` (bare string in stack-style IR, or a
|
|
3300
|
+
// `['drop', …]` node) pops it. The fast store pushes nothing, so the drop must go too.
|
|
3301
|
+
const isDrop = (s) => s === 'drop' || (Array.isArray(s) && s[0] === 'drop')
|
|
3302
|
+
const hasDrop = storeIdx + 1 < body.length && isDrop(body[storeIdx + 1])
|
|
3303
|
+
|
|
3304
|
+
// The stored value is the else-store's operand (a local the body computed); take it
|
|
3305
|
+
// from the store itself, not by guessing which local reads buf — the read may be
|
|
3306
|
+
// nested in a split computation (`$t = buf[i]; $v = $t*2`) whose result is a DIFFERENT
|
|
3307
|
+
// local. cloneRead rewrites any buf reads in that computation to f64.load.
|
|
3308
|
+
if (!(Array.isArray(elseStore[2]) && elseStore[2][0] === 'local.get')) return
|
|
3309
|
+
const valName = elseStore[2][1]
|
|
3310
|
+
// GUARD: param reassigned ONLY inside the matched store-if (else the hoisted base goes stale).
|
|
3311
|
+
for (let i = 0; i < body.length; i++) { if (i === storeIdx) continue; if (writes(body[i], paramName)) return }
|
|
3312
|
+
for (const s of preamble) { if (writes(s, paramName)) return }
|
|
3313
|
+
|
|
3314
|
+
const base = `$__utb${baseId++}`
|
|
3315
|
+
newLocals.push(['local', base, 'i32'])
|
|
3316
|
+
const reint = () => ['i64.reinterpret_f64', ['local.get', paramName]]
|
|
3317
|
+
const tag = ['i32.and', ['i32.wrap_i64', ['i64.shr_u', reint(), ['i64.const', LAYOUT.TAG_SHIFT]]], ['i32.const', LAYOUT.TAG_MASK]]
|
|
3318
|
+
const auxOf = () => ['i32.and', ['i32.wrap_i64', ['i64.shr_u', reint(), ['i64.const', LAYOUT.AUX_SHIFT]]], ['i32.const', LAYOUT.AUX_MASK]]
|
|
3319
|
+
const gate = ['i32.and', ['i32.eq', tag, ['i32.const', PTR.TYPED]],
|
|
3320
|
+
['i32.or', ['i32.eq', auxOf(), ['i32.const', F64]], ['i32.eq', auxOf(), ['i32.const', F64V]]]]
|
|
3321
|
+
const baseSnap = ['local.set', base, ['call', '$__ptr_offset', reint()]]
|
|
3322
|
+
const fastStore = ['f64.store', ['i32.add', ['local.get', base], ['i32.shl', ['local.get', incVar], ['i32.const', 3]]], ['local.get', valName]]
|
|
3323
|
+
// Fast body: keep every statement except the store-if (→ fastStore) and its trailing
|
|
3324
|
+
// drop (the fast store pushes nothing), with the typed-array read collapsed to f64.load.
|
|
3325
|
+
const fastStmts = []
|
|
3326
|
+
for (let i = 0; i < body.length; i++) {
|
|
3327
|
+
if (i === storeIdx) { fastStmts.push(fastStore); continue }
|
|
3328
|
+
if (hasDrop && i === storeIdx + 1) continue
|
|
3329
|
+
fastStmts.push(cloneRead(body[i], paramName, base))
|
|
3330
|
+
}
|
|
3331
|
+
const fastLoop = ['block', blockLabel, ...preamble.map(clone),
|
|
3332
|
+
['loop', loopLabel, clone(loopNode[2]), ...fastStmts, clone(incNode), clone(loopNode[endIdx])]]
|
|
3333
|
+
parent[idx] = ['if', gate, ['then', baseSnap, fastLoop], ['else', blockNode]]
|
|
3334
|
+
}
|
|
3335
|
+
|
|
3336
|
+
function walk(node, parent, idx) {
|
|
3337
|
+
if (!Array.isArray(node)) return
|
|
3338
|
+
if (node[0] === 'block') {
|
|
3339
|
+
const before = parent[idx]
|
|
3340
|
+
processBlock(node, parent, idx)
|
|
3341
|
+
if (parent[idx] !== before) return
|
|
3342
|
+
}
|
|
3343
|
+
for (let i = 0; i < node.length; i++) walk(node[i], node, i)
|
|
3344
|
+
}
|
|
3345
|
+
for (let i = bodyStart; i < fn.length; i++) walk(fn[i], fn, i)
|
|
3346
|
+
if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
|
|
3347
|
+
}
|
|
3348
|
+
|
|
2644
3349
|
/**
|
|
2645
3350
|
* Run all per-function IR optimizations on a single function node.
|
|
2646
3351
|
* hoistPtrType runs first — it introduces new locals (`$__ptN`) that the fused
|
|
@@ -2667,9 +3372,15 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
|
|
|
2667
3372
|
cfg.csePureExpr === false &&
|
|
2668
3373
|
cfg.dropDeadZeroInit === false &&
|
|
2669
3374
|
cfg.deadStoreElim === false &&
|
|
3375
|
+
cfg.propagateSingleUse === false &&
|
|
2670
3376
|
cfg.promoteGlobals === false &&
|
|
2671
3377
|
cfg.sortLocalsByUse === false &&
|
|
2672
3378
|
cfg.vectorizeLaneLocal === false) return
|
|
3379
|
+
// Recursion-unrolling runs first in 'pre': self-calls are still clean `call`
|
|
3380
|
+
// nodes (watr's inliner hasn't reshaped them) and the freshly-inlined body then
|
|
3381
|
+
// rides every pass below (LICM, fold, sort). Speed-tier only; 'pre' only (so the
|
|
3382
|
+
// post-watr re-optimize doesn't unroll a second time).
|
|
3383
|
+
if (cfg && cfg.recursionUnroll === true && phase === 'pre') recursionUnroll(fn)
|
|
2673
3384
|
if (!cfg || cfg.hoistPtrType !== false) hoistPtrType(fn)
|
|
2674
3385
|
if (!cfg || cfg.hoistInvariantPtrOffset !== false) hoistInvariantPtrOffset(fn)
|
|
2675
3386
|
// Before LICM: the snapped i32 bound is itself a hoistable hard-op subtree, so
|
|
@@ -2681,6 +3392,7 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
|
|
|
2681
3392
|
if (!cfg || cfg.hoistInvariantLoop !== false) hoistInvariantLoop(fn)
|
|
2682
3393
|
const counts = new Map()
|
|
2683
3394
|
if (!cfg || cfg.fusedRewrite !== false) fusedRewrite(fn, counts)
|
|
3395
|
+
if (cfg && cfg.boolConvertToSelect === true) boolConvertToSelect(fn)
|
|
2684
3396
|
if (!cfg || cfg.hoistAddrBase !== false) hoistAddrBase(fn)
|
|
2685
3397
|
if (!cfg || cfg.hoistInvariantLoop !== false) hoistInvariantLoop(fn)
|
|
2686
3398
|
if (!cfg || cfg.cseScalarLoad !== false) cseScalarLoad(fn)
|
|
@@ -2702,9 +3414,53 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
|
|
|
2702
3414
|
// Phase 1: fold dead string-dispatch blocks on proven-f64 locals BEFORE
|
|
2703
3415
|
// the vectorizer pattern-matches — dead __is_str_key calls in $fbm-style
|
|
2704
3416
|
// functions (param f64 + op f64) block liftPPC from recognizing them as pure.
|
|
3417
|
+
if (runVectorizer && (!cfg || cfg.unswitchTypedParamLoop !== false)) unswitchTypedParamLoop(fn)
|
|
2705
3418
|
if (runVectorizer) foldStrDispatchF64(fn)
|
|
2706
|
-
if (runVectorizer) vectorizeLaneLocal(fn,
|
|
3419
|
+
if (runVectorizer) vectorizeLaneLocal(fn, {
|
|
3420
|
+
multiAcc: cfg.reduceUnroll === true,
|
|
3421
|
+
relaxedFma: cfg.relaxedSimd === true,
|
|
3422
|
+
blurMP: cfg.blurMultiPixel !== false,
|
|
3423
|
+
whyNot: cfg.whyNotSimd === true,
|
|
3424
|
+
stencil: cfg.experimentalStencil !== false,
|
|
3425
|
+
outerStrip: cfg.experimentalOuterStrip !== false,
|
|
3426
|
+
pureFuncMap: cfg._pureFuncMap || null,
|
|
3427
|
+
toneMap: cfg.experimentalToneMap !== false,
|
|
3428
|
+
slp: cfg.experimentalSlp !== false, // SLP default-on (testing single-use fix)
|
|
3429
|
+
})
|
|
3430
|
+
// The vectorizer emits `v128.load/store (i32.add base K)` for the unrolled
|
|
3431
|
+
// multi-accumulator reduction (a[i],a[i+2],a[i+4]…) and stencil/strided reads.
|
|
3432
|
+
// fusedRewrite's memarg fold already ran (above, before vectorize), so fold the
|
|
3433
|
+
// freshly-created v128 memargs now — one fewer i32.add per accumulator per
|
|
3434
|
+
// iteration, the hot-loop waste audit-fixpoint.mjs flagged on dot/sum.
|
|
3435
|
+
if (runVectorizer) foldV128Memargs(fn)
|
|
2707
3436
|
}
|
|
3437
|
+
// Forward-substitute single-use temps — AFTER the vectorizer, never before: it pattern-matches a
|
|
3438
|
+
// STRAIGHT-LINE `s += a[i]*2`, and folding an address/index temp out scrambles it (the typed-array
|
|
3439
|
+
// loop fell from a SIMD body to a scalar unroll, +231 B). For watr:false the whole pipeline is the
|
|
3440
|
+
// 'pre' phase (no 'post' re-run), so vectorize already ran above; for full watr the vectorizer is
|
|
3441
|
+
// deferred to 'post', so skip 'pre' here to stay after it. (propagateSingleUse itself skips any
|
|
3442
|
+
// function the vectorizer already lifted to v128.)
|
|
3443
|
+
const fullWatr_psu = !!(cfg && (cfg.watr === true || typeof cfg.watr === 'object'))
|
|
3444
|
+
if ((phase === 'post' || !fullWatr_psu) && (!cfg || cfg.propagateSingleUse !== false)) propagateSingleUse(fn)
|
|
3445
|
+
// SSA-split loop-private unrolled scratch (post-vectorize: vectorized loops now carry
|
|
3446
|
+
// v128 and are skipped) so the LICM below hoists the per-iteration invariants the
|
|
3447
|
+
// unroller's name-merging hid — rust/LLVM's free-after-unroll register hoist (closes
|
|
3448
|
+
// the raytrace per-sphere `c_i` recompute). Bit-exact; re-run LICM to lift the splits.
|
|
3449
|
+
if (phase === 'post' && (!cfg || cfg.hoistInvariantLoop !== false)) {
|
|
3450
|
+
splitLoopPrivateScratch(fn)
|
|
3451
|
+
// Iterate LICM for the dependency cascade: c = ox²+… is invariant only once ox is
|
|
3452
|
+
// itself hoisted out, which the single-pass hoister can't see in one go. 4 climbs
|
|
3453
|
+
// covers the deepest scratch chain; idempotent, so it self-terminates.
|
|
3454
|
+
for (let k = 0; k < 4; k++) hoistInvariantLoop(fn)
|
|
3455
|
+
}
|
|
3456
|
+
// Loop rotation — the LAST shape pass (post-watr only, so no later pass reverts
|
|
3457
|
+
// it and the v128 loops are already formed for the skip-guard). Speed-tier: it
|
|
3458
|
+
// duplicates the loop condition for a fused conditional back-edge (1.35× on the
|
|
3459
|
+
// lz/qoi scalar scans — see rotateLoops).
|
|
3460
|
+
if (cfg && cfg.rotateLoops === true && phase === 'post') rotateLoops(fn)
|
|
3461
|
+
// Canonicalize boolean conditions (strip redundant `!= 0` / double-`eqz`) — after
|
|
3462
|
+
// rotateLoops so its fused back-edges get cleaned too. Tied to the peephole pass.
|
|
3463
|
+
if (!cfg || cfg.fusedRewrite !== false) simplifyBoolContexts(fn)
|
|
2708
3464
|
if (!cfg || cfg.sortLocalsByUse !== false) sortLocalsByUse(fn, cfg && cfg.fusedRewrite !== false ? counts : null)
|
|
2709
3465
|
// An optimizer pass that emits a malformed local — the class that otherwise dies
|
|
2710
3466
|
// as an opaque watr "Duplicate/Unknown local $x" several phases on — is caught
|
|
@@ -2712,6 +3468,180 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
|
|
|
2712
3468
|
if (DBG_IR) { const bad = verifyFn(fn); if (bad) throw new Error(`[ir verify] optimize produced invalid IR in ${fn[1]}: ${bad}`) }
|
|
2713
3469
|
}
|
|
2714
3470
|
|
|
3471
|
+
// Fold `(v128.load/store (i32.add base K) …)` → `(… offset=K base …)`. Same logic as
|
|
3472
|
+
// walkRewrite's scalar foldMemargOffsets (MEMOP path), but for the v128 loads/stores the
|
|
3473
|
+
// lane vectorizer creates AFTER fusedRewrite has already run — so they'd otherwise keep a
|
|
3474
|
+
// per-iteration i32.add. Bottom-up, in place; an addr already in offset=/align= form is left.
|
|
3475
|
+
function foldV128Memargs(node) {
|
|
3476
|
+
if (!Array.isArray(node)) return
|
|
3477
|
+
const op = node[0]
|
|
3478
|
+
if (op === 'v128.load' || op === 'v128.store') {
|
|
3479
|
+
const m1 = node[1]
|
|
3480
|
+
if (!(typeof m1 === 'string' && (m1.startsWith('offset=') || m1.startsWith('align='))) &&
|
|
3481
|
+
Array.isArray(m1) && m1[0] === 'i32.add' && m1.length === 3) {
|
|
3482
|
+
const a = m1[1], b = m1[2]
|
|
3483
|
+
let base, offset
|
|
3484
|
+
if (Array.isArray(b) && b[0] === 'i32.const' && typeof b[1] === 'number' && b[1] >= 0 && b[1] < 0x100000000) { base = a; offset = b[1] }
|
|
3485
|
+
else if (Array.isArray(a) && a[0] === 'i32.const' && typeof a[1] === 'number' && a[1] >= 0 && a[1] < 0x100000000) { base = b; offset = a[1] }
|
|
3486
|
+
if (base != null) { node[1] = `offset=${offset}`; node.splice(2, 0, base) }
|
|
3487
|
+
}
|
|
3488
|
+
}
|
|
3489
|
+
for (let i = 1; i < node.length; i++) foldV128Memargs(node[i])
|
|
3490
|
+
}
|
|
3491
|
+
|
|
3492
|
+
// i32 comparison/eqz negations — used to flip a break-condition into the
|
|
3493
|
+
// loop-continue condition. f64 compares are deliberately ABSENT: ¬(a<b) ≠ (a≥b)
|
|
3494
|
+
// across NaN, so those fall through to the `i32.eqz` wrap below.
|
|
3495
|
+
const ROT_NEG = {
|
|
3496
|
+
'i32.eqz': null, // sentinel: strip the eqz (handled specially)
|
|
3497
|
+
'i32.eq': 'i32.ne', 'i32.ne': 'i32.eq',
|
|
3498
|
+
'i32.lt_s': 'i32.ge_s', 'i32.ge_s': 'i32.lt_s', 'i32.gt_s': 'i32.le_s', 'i32.le_s': 'i32.gt_s',
|
|
3499
|
+
'i32.lt_u': 'i32.ge_u', 'i32.ge_u': 'i32.lt_u', 'i32.gt_u': 'i32.le_u', 'i32.le_u': 'i32.gt_u',
|
|
3500
|
+
}
|
|
3501
|
+
|
|
3502
|
+
// Boolean-context canonicalization. At a true zero/nonzero position — a `br_if`,
|
|
3503
|
+
// `if`, `i32.eqz`, or `select` CONDITION — these are all equivalent to the inner
|
|
3504
|
+
// value: `i32.ne(X, 0) → X`, `i32.ne(0, X) → X`, `i32.eqz(i32.eqz(X)) → X`. jz
|
|
3505
|
+
// emits the redundant compare from `while (x !== 0)` lowering and from rotateLoops'
|
|
3506
|
+
// `negate` (which strips one `eqz` but leaves the `i32.ne`). V8 happens to fold it,
|
|
3507
|
+
// but JSC/wasmtime needn't — so strip it for MINIMAL output regardless of engine.
|
|
3508
|
+
// Only applied at proven boolean positions (never on a value-position `ne`/`eqz`,
|
|
3509
|
+
// which produce a real 0/1).
|
|
3510
|
+
const boolSimp = (n) => {
|
|
3511
|
+
for (;;) {
|
|
3512
|
+
if (!Array.isArray(n)) return n
|
|
3513
|
+
if (n[0] === 'i32.ne' && n.length === 3) {
|
|
3514
|
+
if (Array.isArray(n[2]) && n[2][0] === 'i32.const' && n[2][1] === 0) { n = n[1]; continue }
|
|
3515
|
+
if (Array.isArray(n[1]) && n[1][0] === 'i32.const' && n[1][1] === 0) { n = n[2]; continue }
|
|
3516
|
+
}
|
|
3517
|
+
if (n[0] === 'i32.eqz' && Array.isArray(n[1]) && n[1][0] === 'i32.eqz' && n[1].length === 2) { n = n[1][1]; continue }
|
|
3518
|
+
return n
|
|
3519
|
+
}
|
|
3520
|
+
}
|
|
3521
|
+
function simplifyBoolContexts(fn) {
|
|
3522
|
+
const walk = (node) => {
|
|
3523
|
+
if (!Array.isArray(node)) return
|
|
3524
|
+
for (let i = 1; i < node.length; i++) walk(node[i])
|
|
3525
|
+
const op = node[0]
|
|
3526
|
+
if (op === 'br_if' && node.length === 3) node[2] = boolSimp(node[2])
|
|
3527
|
+
else if (op === 'i32.eqz' && node.length === 2) node[1] = boolSimp(node[1])
|
|
3528
|
+
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]) }
|
|
3529
|
+
else if (op === 'select' && node.length === 4 && Array.isArray(node[3])) node[3] = boolSimp(node[3])
|
|
3530
|
+
}
|
|
3531
|
+
const bodyStart = findBodyStart(fn)
|
|
3532
|
+
for (let i = bodyStart; i < fn.length; i++) walk(fn[i])
|
|
3533
|
+
}
|
|
3534
|
+
|
|
3535
|
+
/**
|
|
3536
|
+
* Loop rotation (loop inversion). Convert jz's top-test loop idiom
|
|
3537
|
+
* (block $brk (loop $loop (br_if $brk ¬C) BODY… (br $loop)))
|
|
3538
|
+
* into a guarded bottom-test loop with a FUSED conditional back-edge:
|
|
3539
|
+
* (block $brk (br_if $brk ¬C) (loop $loop BODY… (br_if $loop C)))
|
|
3540
|
+
*
|
|
3541
|
+
* V8/TurboFan lowers the fused `br_if $loop C` to one hardware loop branch — the
|
|
3542
|
+
* shape LLVM gives rust/zig, and the reason their hot scalar loops (lz's greedy
|
|
3543
|
+
* match-scan, qoi's run-length scan) beat jz's top-test form, which compiles to a
|
|
3544
|
+
* forward exit-branch PLUS a separate unconditional back-jump. Measured 1.35× on
|
|
3545
|
+
* the lz inner loop; nothing else jz runs reaches this shape — watr's `loopify`
|
|
3546
|
+
* collapses to `loop { if C { …; br } }`, whose back-jump stays UNfused (no win).
|
|
3547
|
+
*
|
|
3548
|
+
* Evaluation count of C is unchanged: guard-once + one back-edge per iteration ==
|
|
3549
|
+
* the top-test form's once-per-loop-top — so it's sound even when C has side
|
|
3550
|
+
* effects (a `local.tee` recurrence, a call). The condition is duplicated only in
|
|
3551
|
+
* the EMITTED text (guard + back-edge), a small size-for-speed trade — speed-tier.
|
|
3552
|
+
*
|
|
3553
|
+
* Conservative skips:
|
|
3554
|
+
* - any v128/SIMD op in the loop — already register-tight; reshaping risks
|
|
3555
|
+
* disturbing the lane structure (mirrors hoistInvariantLoop's hasV128 guard).
|
|
3556
|
+
* - a body that branches to $loop: a `continue` with no step lands on the loop
|
|
3557
|
+
* label, which after rotation sits BEFORE the back-edge test — rotating would
|
|
3558
|
+
* skip it. (jz wraps continue-with-step in a `$cont` block → targets that, not
|
|
3559
|
+
* $loop → still rotatable.)
|
|
3560
|
+
*/
|
|
3561
|
+
function rotateLoops(fn) {
|
|
3562
|
+
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
3563
|
+
const bodyStart = findBodyStart(fn)
|
|
3564
|
+
if (bodyStart < 0) return
|
|
3565
|
+
|
|
3566
|
+
const clone = (n) => Array.isArray(n) ? n.map(clone) : n
|
|
3567
|
+
// Break-condition C → loop-continue condition ¬C for the back-edge. Fold the
|
|
3568
|
+
// i32 forms so the back-edge stays ONE fused compare-and-branch (a wrapping
|
|
3569
|
+
// `i32.eqz` would add an op inside the hot loop); everything else wraps.
|
|
3570
|
+
const negate = (c) => {
|
|
3571
|
+
if (Array.isArray(c) && c[0] === 'i32.eqz' && c.length === 2) return c[1]
|
|
3572
|
+
if (Array.isArray(c) && c.length === 3 && ROT_NEG[c[0]]) return [ROT_NEG[c[0]], c[1], c[2]]
|
|
3573
|
+
return ['i32.eqz', c]
|
|
3574
|
+
}
|
|
3575
|
+
const targetsLabel = (n, label) => {
|
|
3576
|
+
if (!Array.isArray(n)) return false
|
|
3577
|
+
const op = n[0]
|
|
3578
|
+
if (op === 'br' || op === 'br_if') { if (n[1] === label) return true }
|
|
3579
|
+
else if (op === 'br_table') { for (let i = 1; i < n.length; i++) if (n[i] === label) return true }
|
|
3580
|
+
for (let i = 1; i < n.length; i++) if (targetsLabel(n[i], label)) return true
|
|
3581
|
+
return false
|
|
3582
|
+
}
|
|
3583
|
+
const hasV128 = (n) => Array.isArray(n) && (
|
|
3584
|
+
(typeof n[0] === 'string' && (n[0].startsWith('v128.') || /^[if]\d+x\d+\./.test(n[0]))) ||
|
|
3585
|
+
n.some((c, i) => i > 0 && hasV128(c)))
|
|
3586
|
+
|
|
3587
|
+
const tryRotate = (blk) => {
|
|
3588
|
+
let bi = 1, blockLabel = null
|
|
3589
|
+
if (typeof blk[1] === 'string' && blk[1][0] === '$') { blockLabel = blk[1]; bi = 2 }
|
|
3590
|
+
if (!blockLabel) return null
|
|
3591
|
+
// The loop must be the block's final child; LICM may hoist invariant snaps into
|
|
3592
|
+
// a `local.set` pre-header before it — keep those ahead of the guard (the guard
|
|
3593
|
+
// condition can read them). Bail on anything else (typed blocks, side computations).
|
|
3594
|
+
const preamble = []
|
|
3595
|
+
let loop = null
|
|
3596
|
+
for (let i = bi; i < blk.length; i++) {
|
|
3597
|
+
const c = blk[i]
|
|
3598
|
+
if (Array.isArray(c) && c[0] === 'loop') { if (loop || i !== blk.length - 1) return null; loop = c }
|
|
3599
|
+
else if (Array.isArray(c) && c[0] === 'local.set' && !loop) preamble.push(c)
|
|
3600
|
+
else return null
|
|
3601
|
+
}
|
|
3602
|
+
if (!loop) return null
|
|
3603
|
+
let li = 1, loopLabel = null
|
|
3604
|
+
if (typeof loop[1] === 'string' && loop[1][0] === '$') { loopLabel = loop[1]; li = 2 }
|
|
3605
|
+
if (!loopLabel) return null
|
|
3606
|
+
const loopHeader = []
|
|
3607
|
+
while (li < loop.length) {
|
|
3608
|
+
const c = loop[li]
|
|
3609
|
+
if (Array.isArray(c) && c[0] === 'type') { loopHeader.push(c); li++; continue }
|
|
3610
|
+
if (Array.isArray(c) && (c[0] === 'param' || c[0] === 'result')) return null
|
|
3611
|
+
break
|
|
3612
|
+
}
|
|
3613
|
+
const body = loop.slice(li)
|
|
3614
|
+
if (body.length < 2) return null
|
|
3615
|
+
const head = body[0], tail = body[body.length - 1]
|
|
3616
|
+
if (!(Array.isArray(head) && head[0] === 'br_if' && head[1] === blockLabel && head.length === 3)) return null
|
|
3617
|
+
if (!(Array.isArray(tail) && tail[0] === 'br' && tail[1] === loopLabel && tail.length === 2)) return null
|
|
3618
|
+
const inner = body.slice(1, -1)
|
|
3619
|
+
if (inner.some((s) => targetsLabel(s, loopLabel))) return null // continue → loop top: unsafe
|
|
3620
|
+
if (hasV128(head) || inner.some(hasV128)) return null // vectorized: leave tight
|
|
3621
|
+
const cond = head[2]
|
|
3622
|
+
return ['block', blockLabel, ...preamble,
|
|
3623
|
+
['br_if', blockLabel, clone(cond)],
|
|
3624
|
+
['loop', loopLabel, ...loopHeader, ...inner, ['br_if', loopLabel, negate(cond)]]]
|
|
3625
|
+
}
|
|
3626
|
+
|
|
3627
|
+
// Rotate a (block …) at container[i] in place, else descend. Returns true if it fired.
|
|
3628
|
+
const tryAt = (container, i) => {
|
|
3629
|
+
const c = container[i]
|
|
3630
|
+
if (!Array.isArray(c) || c[0] !== 'block') return false
|
|
3631
|
+
const rot = tryRotate(c)
|
|
3632
|
+
if (!rot) return false
|
|
3633
|
+
container[i] = rot
|
|
3634
|
+
walk(rot)
|
|
3635
|
+
return true
|
|
3636
|
+
}
|
|
3637
|
+
const walk = (node) => {
|
|
3638
|
+
if (!Array.isArray(node)) return
|
|
3639
|
+
for (let i = 0; i < node.length; i++) if (!tryAt(node, i)) walk(node[i])
|
|
3640
|
+
}
|
|
3641
|
+
// Top-level statements (a loop block can BE fn[i], not just nested under one).
|
|
3642
|
+
for (let i = bodyStart; i < fn.length; i++) if (!tryAt(fn, i)) walk(fn[i])
|
|
3643
|
+
}
|
|
3644
|
+
|
|
2715
3645
|
// The i32 form of an integer-valued f64 expression, or null. Used to push ToInt32
|
|
2716
3646
|
// through a conditional and to collapse the f64 round-trip on integer `+`/`-`.
|
|
2717
3647
|
// Lossless by construction: `convert_i32(X) → X`; integer `f64.const → i32.const`
|
|
@@ -2732,6 +3662,17 @@ function toI32(n) {
|
|
|
2732
3662
|
const a = toI32(n[1]), b = toI32(n[2])
|
|
2733
3663
|
if (a && b) return [op === 'f64.add' ? 'i32.add' : 'i32.sub', a, b]
|
|
2734
3664
|
}
|
|
3665
|
+
// ToInt32 distributes through a conditional: ToInt32(if C A B) == if(result i32) C
|
|
3666
|
+
// ToInt32(A) ToInt32(B). Recursive — a nested integer `?:` like `((3<a)?(2&a):((7<a)?a:1))|0`
|
|
3667
|
+
// narrows whole to i32 (each arm folded by toI32, incl. nested ifs), so the lane vectorizer
|
|
3668
|
+
// lifts it as i32x4 bitselect instead of bailing on the f64 result. Only reached from
|
|
3669
|
+
// ToInt32 sinks (the select idiom / toI32 recursion), so the i32 result is always wanted.
|
|
3670
|
+
if (op === 'if' && Array.isArray(n[1]) && n[1][0] === 'result' && n[1][1] === 'f64'
|
|
3671
|
+
&& Array.isArray(n[3]) && n[3][0] === 'then' && n[3].length === 2
|
|
3672
|
+
&& Array.isArray(n[4]) && n[4][0] === 'else' && n[4].length === 2) {
|
|
3673
|
+
const t = toI32(n[3][1]), e = toI32(n[4][1])
|
|
3674
|
+
if (t && e) return ['if', ['result', 'i32'], n[2], ['then', t], ['else', e]]
|
|
3675
|
+
}
|
|
2735
3676
|
return null
|
|
2736
3677
|
}
|
|
2737
3678
|
|
|
@@ -2771,18 +3712,38 @@ function fusedRewrite(fn, counts) {
|
|
|
2771
3712
|
}
|
|
2772
3713
|
const freshI64 = () => { const n = `$__eqt${scratchN++}`; newDecls.push(['local', n, 'i64']); return n }
|
|
2773
3714
|
const freshF64 = () => { const n = `$__eqf${scratchN++}`; newDecls.push(['local', n, 'f64']); return n }
|
|
3715
|
+
// Single-textual-def locals → their defining value node, so the trunc_sat range fold (below)
|
|
3716
|
+
// can see through the temps inlining introduces when proving an index/packed value fits i32.
|
|
3717
|
+
// Multi-def (incl. loop-carried self-referential) locals are excluded: their value is not the
|
|
3718
|
+
// one def's, so its range wouldn't bound them. Pure read of the IR — value-preserving rewrites
|
|
3719
|
+
// during this same walk keep the captured def's RANGE intact, so a lazily-built map stays sound.
|
|
3720
|
+
// Built on first query only (most functions carry no guarded-trunc form → zero cost).
|
|
3721
|
+
let defVal
|
|
3722
|
+
const get = (name) => {
|
|
3723
|
+
if (defVal === undefined) {
|
|
3724
|
+
defVal = new Map(); const defCnt = new Map()
|
|
3725
|
+
const scanDefs = (n) => {
|
|
3726
|
+
if (!Array.isArray(n)) return
|
|
3727
|
+
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]) }
|
|
3728
|
+
for (let i = 1; i < n.length; i++) scanDefs(n[i])
|
|
3729
|
+
}
|
|
3730
|
+
for (let i = bodyStart; i < fn.length; i++) scanDefs(fn[i])
|
|
3731
|
+
for (const [k, c] of defCnt) if (c > 1) defVal.delete(k)
|
|
3732
|
+
}
|
|
3733
|
+
return defVal.get(name) || null
|
|
3734
|
+
}
|
|
2774
3735
|
for (let i = bodyStart; i < fn.length; i++) {
|
|
2775
3736
|
const c = fn[i]
|
|
2776
|
-
if (Array.isArray(c)) fn[i] = walkRewrite(c, !skipInline, counts, freshI64, freshF64)
|
|
3737
|
+
if (Array.isArray(c)) fn[i] = walkRewrite(c, !skipInline, counts, freshI64, freshF64, get)
|
|
2777
3738
|
}
|
|
2778
3739
|
if (newDecls.length) fn.splice(bodyStart, 0, ...newDecls)
|
|
2779
3740
|
}
|
|
2780
3741
|
|
|
2781
|
-
function walkRewrite(node, doInline, counts, freshI64, freshF64) {
|
|
3742
|
+
function walkRewrite(node, doInline, counts, freshI64, freshF64, get) {
|
|
2782
3743
|
if (!Array.isArray(node)) return node
|
|
2783
3744
|
for (let i = 0; i < node.length; i++) {
|
|
2784
3745
|
const c = node[i]
|
|
2785
|
-
if (Array.isArray(c)) node[i] = walkRewrite(c, doInline, counts, freshI64, freshF64)
|
|
3746
|
+
if (Array.isArray(c)) node[i] = walkRewrite(c, doInline, counts, freshI64, freshF64, get)
|
|
2786
3747
|
}
|
|
2787
3748
|
const op = node[0]
|
|
2788
3749
|
// Piggyback local-ref counting for sortLocalsByUse. `counts` may be undefined
|
|
@@ -2963,16 +3924,20 @@ function walkRewrite(node, doInline, counts, freshI64, freshF64) {
|
|
|
2963
3924
|
if (Array.isArray(v) && v[0] === 'i32.wrap_i64' && Array.isArray(v[1]) && v[1][0] === 'i64.trunc_sat_f64_s' && v[1].length === 2) {
|
|
2964
3925
|
let inner = v[1][1]
|
|
2965
3926
|
if (Array.isArray(inner) && inner[0] === 'local.tee' && inner.length === 3) inner = inner[2]
|
|
2966
|
-
// ToInt32(
|
|
2967
|
-
|
|
2968
|
-
&& Array.isArray(inner[3]) && inner[3][0] === 'then' && inner[3].length === 2
|
|
2969
|
-
&& Array.isArray(inner[4]) && inner[4][0] === 'else' && inner[4].length === 2) {
|
|
2970
|
-
const t = toI32(inner[3][1]), e = toI32(inner[4][1])
|
|
2971
|
-
if (t && e) return ['if', ['result', 'i32'], inner[2], ['then', t], ['else', e]]
|
|
2972
|
-
}
|
|
2973
|
-
// ToInt32(integer-valued f64 expr) → its i32 form (covers (i32±i32)|0 sums and nests).
|
|
3927
|
+
// ToInt32(integer-valued f64 expr) → its i32 form: covers (i32±i32)|0 sums AND the
|
|
3928
|
+
// conditional `?:` (toI32 distributes through `(if result f64)`, recursively).
|
|
2974
3929
|
const i = toI32(inner)
|
|
2975
3930
|
if (i) return i
|
|
3931
|
+
// Range fallback for the NON-integer-ring values toI32 rejects (`floor(scale·v)`,
|
|
3932
|
+
// `base + scale·v` — every grid/lattice/colour index): when the def chain — resolved
|
|
3933
|
+
// through single-def inlining temps via `get` — provably yields a finite i32-range value,
|
|
3934
|
+
// the +∞ guard is dead AND trunc_sat can't saturate, so the whole guarded select collapses
|
|
3935
|
+
// to one `i32.trunc_sat_f64_s`. SOUND: f64Range admits only pure nodes and proves
|
|
3936
|
+
// finiteness (kills the guard) + in-range (kills saturation), so the result is identical
|
|
3937
|
+
// ToInt32 on every value the program can produce. Drops the i64 round-trip + guard on all
|
|
3938
|
+
// runtimes (this is the post-inline twin of the emit-time fold at ir.js toI32).
|
|
3939
|
+
const rng = f64Range(inner, get)
|
|
3940
|
+
if (rng && rng.lo >= I32_MIN && rng.hi <= I32_MAX) return ['i32.trunc_sat_f64_s', inner]
|
|
2976
3941
|
}
|
|
2977
3942
|
}
|
|
2978
3943
|
// (i32.or X 0) / (i32.or 0 X) → X — drops the redundant source-level `|0` clamp left
|
|
@@ -2983,6 +3948,30 @@ function walkRewrite(node, doInline, counts, freshI64, freshF64) {
|
|
|
2983
3948
|
if (Array.isArray(a) && a[0] === 'i32.const' && a[1] === 0) return b
|
|
2984
3949
|
}
|
|
2985
3950
|
|
|
3951
|
+
// if→select for a value-producing f64 `if` with PURE arms: (if (result f64) COND (then A)
|
|
3952
|
+
// (else B)) → (select A B COND). This is the branchless `cmov` lowering LLVM/clang apply to
|
|
3953
|
+
// every `cond ? a : b` — it removes the conditional branch (and its misprediction cost on
|
|
3954
|
+
// data-unpredictable conditions) on the whole class of float sign/clamp/reflect ternaries.
|
|
3955
|
+
// The flagship: noise's gradient `(h & 1) === 0 ? x : -x` (8 per perlin × 5 octaves × 65k px).
|
|
3956
|
+
// SOUND: wasm `select` evaluates BOTH arms unconditionally, and `isPureIR` admits only
|
|
3957
|
+
// side-effect-free, non-trapping ops (no load/call/div/rem) — so eager evaluation is safe; it
|
|
3958
|
+
// is the exact predicate emit.js uses for the same fold at emit time, now applied post-watr
|
|
3959
|
+
// where the arms (e.g. `f64.neg (local.get $x)`) are clean after canon-DCE. Gated to NOT fire
|
|
3960
|
+
// when BOTH arms are i32-narrowable — those stay an `if` for the ToInt32-through-if fold +
|
|
3961
|
+
// the i32x4-bitselect conditional-map vectorizer (don't steal the integer path).
|
|
3962
|
+
if (op === 'if' && node.length === 5 && Array.isArray(node[1]) && node[1][0] === 'result' && node[1][1] === 'f64'
|
|
3963
|
+
&& Array.isArray(node[3]) && node[3][0] === 'then' && node[3].length === 2
|
|
3964
|
+
&& Array.isArray(node[4]) && node[4][0] === 'else' && node[4].length === 2) {
|
|
3965
|
+
const a = node[3][1], b = node[4][1], cond = node[2]
|
|
3966
|
+
// The COND must also be pure: `if` evaluates cond FIRST then one arm, but wasm `select`
|
|
3967
|
+
// evaluates its arms BEFORE the cond. A short-circuit lowering like `a || b` =
|
|
3968
|
+
// `(if (result f64) is_truthy(local.tee $t a) (then get $t)(else b))` hides a `tee` in the
|
|
3969
|
+
// cond that the then-arm reads — reordering it after the arms reads $t stale. Requiring
|
|
3970
|
+
// isPureIR(cond) excludes every tee/call/short-circuit cond while admitting the pure
|
|
3971
|
+
// comparison conds of real float ternaries (noise's `(h & 1) === 0`).
|
|
3972
|
+
if (isPureIR(a) && isPureIR(b) && isPureIR(cond) && !(toI32(a) && toI32(b))) return ['select', a, b, cond]
|
|
3973
|
+
}
|
|
3974
|
+
|
|
2986
3975
|
// f64.CMP(convert_i32 A, convert_i32 B) → i32.CMP(A, B). Comparing two i32 values is
|
|
2987
3976
|
// identical whether done in exact f64 or in i32 (the converts are lossless and
|
|
2988
3977
|
// order-preserving), so an integer comparison over typed-array loads (reads are f64)
|