jz 0.8.1 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -9
- package/bench/README.md +121 -50
- package/bench/bench.svg +23 -23
- package/cli.js +3 -1
- package/dist/interop.js +1 -1
- package/dist/jz.js +7541 -6271
- package/index.js +165 -74
- package/interop.js +189 -17
- package/jzify/arguments.js +32 -1
- package/jzify/async.js +409 -0
- package/jzify/classes.js +136 -18
- package/jzify/generators.js +639 -0
- package/jzify/hoist-vars.js +73 -1
- package/jzify/index.js +123 -4
- package/jzify/names.js +1 -0
- package/jzify/transform.js +239 -1
- package/layout.js +48 -3
- package/module/array.js +299 -44
- package/module/atomics.js +144 -0
- package/module/collection.js +1429 -155
- package/module/core.js +431 -40
- package/module/date.js +142 -120
- package/module/fs.js +144 -0
- package/module/function.js +6 -3
- package/module/index.js +4 -1
- package/module/json.js +270 -49
- package/module/math.js +892 -32
- package/module/number.js +532 -163
- package/module/object.js +353 -95
- package/module/regex.js +157 -7
- package/module/schema.js +100 -2
- package/module/string.js +301 -84
- package/module/typedarray.js +264 -72
- package/module/web.js +36 -0
- package/package.json +5 -5
- package/src/abi/string.js +71 -5
- package/src/ast.js +11 -5
- package/src/autoload.js +28 -1
- package/src/bridge.js +7 -2
- package/src/compile/analyze-scans.js +22 -7
- package/src/compile/analyze.js +136 -30
- package/src/compile/dyn-closure-tables.js +277 -0
- package/src/compile/emit-assign.js +281 -15
- package/src/compile/emit.js +979 -54
- package/src/compile/index.js +271 -29
- package/src/compile/infer.js +34 -3
- package/src/compile/inplace-store.js +329 -0
- package/src/compile/narrow.js +543 -15
- package/src/compile/peel-stencil.js +5 -0
- package/src/compile/plan/index.js +45 -3
- package/src/compile/plan/inline.js +8 -1
- package/src/compile/plan/literals.js +39 -0
- package/src/compile/plan/scope.js +430 -23
- package/src/compile/program-facts.js +732 -38
- package/src/ctx.js +64 -9
- package/src/helper-counters.js +7 -1
- package/src/ir.js +113 -5
- package/src/kind-traits.js +66 -3
- package/src/kind.js +84 -12
- package/src/op-policy.js +7 -4
- package/src/optimize/index.js +1060 -750
- package/src/optimize/recurse.js +2 -2
- package/src/optimize/vectorize.js +972 -67
- package/src/prepare/index.js +792 -63
- package/src/prepare/math-kernel.js +331 -0
- package/src/prepare/pre-eval.js +714 -0
- package/src/snapshot.js +194 -0
- package/src/type.js +1170 -56
- package/src/wat/assemble.js +403 -65
- package/src/wat/codegen.js +74 -14
- package/transform.js +113 -4
- package/wasi.js +3 -0
package/src/optimize/index.js
CHANGED
|
@@ -16,8 +16,6 @@
|
|
|
16
16
|
* fusedRewrite — peephole rebox folds + inline ptr/is_* helpers + memarg-offset fold (one walk)
|
|
17
17
|
* sortLocalsByUse — reorder local decls so hot ones get 1-byte LEB128 indices
|
|
18
18
|
* specializeMkptr — `(call $__mkptr (i32.const T) (i32.const A) X)` → per-combo specialized helper (~4 B/site)
|
|
19
|
-
* specializePtrBase — `(call $F (i32.add (global.get $G) (i32.const N)))` → `$F_rel_$G (i32.const N)`
|
|
20
|
-
* sortStrPoolByFreq — reorder string pool so hottest strings get small offsets (smaller LEB128)
|
|
21
19
|
* hoistConstantPool — frequently-repeated f64.const values → mutable globals (~7 B/reuse)
|
|
22
20
|
* treeshake — drop func decls unreachable from exports / start / elem / ref.func roots
|
|
23
21
|
*
|
|
@@ -33,10 +31,12 @@ import { findBodyStart, buildRefcount, nextLocalId, verifyFn, isPureIR, f64Range
|
|
|
33
31
|
|
|
34
32
|
// Debug-mode IR structural check (JZ_DEBUG_INVARIANTS=1). Zero production cost.
|
|
35
33
|
const DBG_IR = typeof process !== 'undefined' && process.env?.JZ_DEBUG_INVARIANTS === '1'
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
const DBG_DSR = typeof process !== 'undefined' && !!process.env?.JZ_DBG_DSR
|
|
35
|
+
const DBG_UNSWITCH = typeof process !== 'undefined' && (process.env?.JZ_DBG_UNSWITCH || null)
|
|
36
|
+
import { T, isLeaf, stableNodeKey } from '../ast.js'
|
|
37
|
+
import { vectorizeLaneLocal, inlinePureCallExpr } from './vectorize.js'
|
|
38
38
|
import { recursionUnroll } from './recurse.js'
|
|
39
|
-
export { SIMD_PINNED } from './vectorize.js'
|
|
39
|
+
export { SIMD_PINNED, inlinePureFnsInFn } from './vectorize.js'
|
|
40
40
|
import { nanPrefixHex, atomNanHex, STR_INTERN_BIT, ptrBits, i64Hex, PTR, TYPED_ELEM_CODE, TYPED_ELEM_VIEW_FLAG } from '../../layout.js'
|
|
41
41
|
|
|
42
42
|
const MEMOP = /^[fi](32|64)\.(load|store)(\d+(_[su])?)?$/
|
|
@@ -82,12 +82,11 @@ const FALSE_BITS = atomNanHex(4)
|
|
|
82
82
|
* because the dead tag-dispatch shapes it folds only EXIST after that
|
|
83
83
|
* layer's inlining, and its output feeds that layer's fold/branch cleanup
|
|
84
84
|
* within the same fixpoint rounds.
|
|
85
|
-
* Sequencing
|
|
86
|
-
*
|
|
87
|
-
* re-
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
* see the same function.
|
|
85
|
+
* Sequencing: optimizeFunc runs ONCE, in the 'pre' phase (src/wat/assemble.js
|
|
86
|
+
* optimizeModule), then watOptimize is the sole and final optimizer. There is no
|
|
87
|
+
* post-watr re-run — re-running jz's leaf passes on watr's output both violated the
|
|
88
|
+
* "watr is the only optimizer, once, fixpoint" architecture and miscompiled (dropped a
|
|
89
|
+
* reassigned-param tee, corrupted SIMD frames).
|
|
91
90
|
*/
|
|
92
91
|
export const PASS_NAMES = [
|
|
93
92
|
'watr', // third-party WAT-level CSE/DCE/inlining (heaviest)
|
|
@@ -106,20 +105,17 @@ export const PASS_NAMES = [
|
|
|
106
105
|
'unrollRecurrence', // unit-stride DP/scan: scalar-replace arr[j-1]/arr[j] recurrence + ×2 unroll
|
|
107
106
|
'clampPeel', // edge-clamp stencil: split into clamp-free interior (vectorizes) + edges
|
|
108
107
|
'hoistGlobalPtrOffset', // stable typed GLOBALS: __ptr_offset resolve → once per function (post-watr, module-level)
|
|
108
|
+
'hoistLoopGlobalPtrOffset', // per-loop complement: narrower write/call scan lets a clean loop hoist inside an otherwise-poisoned function
|
|
109
109
|
'fusedRewrite', // peephole + ptr-helper inline + memarg fold
|
|
110
110
|
'hoistAddrBase',
|
|
111
111
|
'boolConvertToSelect', // f64 ± (cond?1:0) → branchless select (kills i32↔f64 domain cross on recurrences)
|
|
112
112
|
'cseScalarLoad',
|
|
113
|
-
'csePureExpr',
|
|
114
113
|
'unswitchTypedParamLoop', // Float64Array param loop-unswitch → base-hoisted f64.load/store fast path (vectorizes)
|
|
115
|
-
'dropDeadZeroInit',
|
|
116
|
-
'deadStoreElim',
|
|
117
114
|
'propagateSingleUse', // forward-substitute single-def/single-use pure temps (watr's "propagate")
|
|
115
|
+
'foldSetToTee', // sink a single-def local's RHS into its first use as a tee (simplify-locals watr leaves)
|
|
118
116
|
'promoteGlobals', // read-only global.get → local for multi-read globals
|
|
119
117
|
'sortLocalsByUse',
|
|
120
118
|
'specializeMkptr',
|
|
121
|
-
'specializePtrBase',
|
|
122
|
-
'sortStrPoolByFreq',
|
|
123
119
|
'internStrings', // slice/substring results probe the static-literal pool: equal-content → canonical bits (bit-eq fast paths)
|
|
124
120
|
'hoistConstantPool',
|
|
125
121
|
'sourceInline',
|
|
@@ -143,7 +139,7 @@ const LEVEL_PRESETS = Object.freeze({
|
|
|
143
139
|
// watr pipeline. `inline` stays off by watr's own default — opt-in only.
|
|
144
140
|
// boolConvertToSelect off at the default level: it's a latency-for-size trade (adds a
|
|
145
141
|
// 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 }),
|
|
142
|
+
2: Object.freeze({ ...ALL_ON, nestedSmallConstForUnroll: 'auto', boolConvertToSelect: false, recursionUnroll: false, watrProfile: 'speed' }),
|
|
147
143
|
// L3/'speed' trades a bit of heap headroom for fewer __arr_grow / __hash growth
|
|
148
144
|
// cycles. arrayMinCap=16 means `[]` and `new Array()` skip the first two doublings
|
|
149
145
|
// (0→2→4→8→16); hashSmallInitCap=8 keeps per-object __dyn_props at the same load
|
|
@@ -159,15 +155,17 @@ const LEVEL_PRESETS = Object.freeze({
|
|
|
159
155
|
// closures). Inline `f64.const` is the minimal lowering: V8 CSEs identical
|
|
160
156
|
// constants for free. Measured −3% on jessie parse for +14% binary — exactly
|
|
161
157
|
// the size↔speed trade 'speed' exists to make.
|
|
162
|
-
3: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8, reduceUnroll: true, relaxedSimd: true, inlineFns: true, rotateLoops: true }),
|
|
158
|
+
3: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8, reduceUnroll: true, relaxedSimd: true, inlineFns: true, rotateLoops: true, watrLicm: true, watrProfile: 'speed', watrGuard: false }),
|
|
163
159
|
// 'size' tightens scalar/unroll caps; 'speed' = level 3. There is no 'balanced'
|
|
164
160
|
// preset — it was a pure synonym for the default level 2 (omit `optimize` or pass 2).
|
|
165
161
|
size: Object.freeze({
|
|
166
162
|
...ALL_ON,
|
|
163
|
+
watrProfile: null, // keep watr's OWN size-leaning default (outline/tailmerge/rettail on) — the one tier where those size-for-speed folds belong
|
|
167
164
|
smallConstForUnroll: false, nestedSmallConstForUnroll: false, vectorizeLaneLocal: false, splitCharScan: false,
|
|
168
165
|
recursionUnroll: false, // body tripling is a size regression — speed-only
|
|
169
166
|
unrollRecurrence: false, // ×2 body duplication is a size regression — speed-only
|
|
170
167
|
clampPeel: false, // edge-clamp peel triples a stencil loop (clamp-free interior + 2 edges) to vectorize — speed-only
|
|
168
|
+
versionTypedBounds: false,// typed-bounds loop versioning duplicates every proven nest (guarded fast arm + checked twin, ×1.5-3 on small kernels) — the branchless checked reads alone are the size-tier lowering; speed-only trade
|
|
171
169
|
|
|
172
170
|
boolConvertToSelect: false, // adds a const + op per site — speed-only latency trade
|
|
173
171
|
devirtIndirect: false, // guards + duplicated args grow bytes — speed-only trade
|
|
@@ -175,6 +173,7 @@ const LEVEL_PRESETS = Object.freeze({
|
|
|
175
173
|
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
174
|
hoistInvariantLoop: false,// LICM: hoists loop-invariants to entry temps — a speed/latency trade whose entry cost outweighs the per-iter saving in bytes
|
|
177
175
|
scalarTypedLoopUnroll: 4, scalarTypedNestedUnroll: 8, scalarTypedArrayLen: 8,
|
|
176
|
+
watrIfset: true, // one-armed if→select IS a size win (~2 B/site — the block framing); wasm-opt -Oz does it too. Speed keeps it via boolConvertToSelect.
|
|
178
177
|
}),
|
|
179
178
|
// 'speed' === level 3: full watr (inlining on) + L3 cap/hash tuning, pool off.
|
|
180
179
|
// reduceUnroll: vectorize reductions with N independent accumulators (ILP/latency
|
|
@@ -186,7 +185,7 @@ const LEVEL_PRESETS = Object.freeze({
|
|
|
186
185
|
// (The stencil + outer-strip vectorizers are NOT level-gated here: they're bit-exact pure wins
|
|
187
186
|
// like the base lane vectorizer, so they run whenever it does — default-on at level 2+ via
|
|
188
187
|
// `cfg.experimentalStencil !== false` at the call site, not a speed-only size/precision trade.)
|
|
189
|
-
speed: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8, reduceUnroll: true, relaxedSimd: true, inlineFns: true, rotateLoops: true }),
|
|
188
|
+
speed: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8, reduceUnroll: true, relaxedSimd: true, inlineFns: true, rotateLoops: true, watrLicm: true, watrProfile: 'speed', watrGuard: false }),
|
|
190
189
|
})
|
|
191
190
|
|
|
192
191
|
/**
|
|
@@ -590,7 +589,11 @@ const CMP_MANTISSA = new Set([
|
|
|
590
589
|
// block readonly-mem-call LICM (else any `s += unknown` — which dispatches via
|
|
591
590
|
// __is_str_key/__str_concat — would pin every invariant array element in-loop:
|
|
592
591
|
// the jagged-array `grid[i][j]` deopt).
|
|
593
|
-
|
|
592
|
+
// $__mkptr is pure bit-packing over its i32 args (no memory access at all) — its
|
|
593
|
+
// only loop-body producer is the in-place replace-store's re-boxed result, which
|
|
594
|
+
// otherwise pinned the loop's `__ptr_offset(arr)` base resolution in-body (the
|
|
595
|
+
// immutable-update kernel paid the full forwarding+bounds dance per iteration).
|
|
596
|
+
const NON_MUTATING_CALLS = new Set(['$__is_str_key', '$__str_concat', '$__to_num', '$__to_str', '$__str_byteLen', '$__mkptr'])
|
|
594
597
|
|
|
595
598
|
// Read-only HEAP-MEMORY calls: like SAFE_OFFSET_CALLS but they read element
|
|
596
599
|
// storage that a direct f64.store/i32.store in the loop could alias. Safe to
|
|
@@ -614,9 +617,14 @@ const READONLY_MEM_CALLS = new Set(['$__typed_idx', '$__str_idx'])
|
|
|
614
617
|
// it treats every call as opaque and recomputes the search/transcendental each
|
|
615
618
|
// iteration. (Math.random is INLINED — it mutates a global PRNG seed, never a
|
|
616
619
|
// `$math.` call — but exclude it by name defensively; $__str_eq_cold is the cold
|
|
617
|
-
// half of __str_eq, equally pure.)
|
|
618
|
-
//
|
|
619
|
-
|
|
620
|
+
// half of __str_eq, equally pure.) $__length stays OUT: it is polymorphic over
|
|
621
|
+
// MUTABLE arrays (push changes it), so it isn't arg-pure. $__str_byteLen is IN:
|
|
622
|
+
// its operand is a string (immutable), and the one in-place length mutator —
|
|
623
|
+
// the heap-top bump-extend twins (module/string.js) — is only emitted where the
|
|
624
|
+
// OLD string value is provably dead, so a live, loop-invariant operand's length
|
|
625
|
+
// cannot change under the loop (`for (j = 0; j < line.length; j++)` hoists to
|
|
626
|
+
// one call instead of one per character — the strbuild row-scan shape).
|
|
627
|
+
const PURE_CALL_I32 = new Set(['$__str_indexof', '$__str_lastindexof', '$__str_eq', '$__str_eq_cold', '$__is_str_key', '$__str_byteLen'])
|
|
620
628
|
const isPureFnCall = (callee) =>
|
|
621
629
|
typeof callee === 'string' &&
|
|
622
630
|
((callee.startsWith('$math.') && !callee.startsWith('$math.random')) || PURE_CALL_I32.has(callee))
|
|
@@ -845,10 +853,13 @@ function loopInvariance(loopNode, { distinctParams, baseParamOf }) {
|
|
|
845
853
|
const op = node[0]
|
|
846
854
|
if (op === 'i32.const' || op === 'i64.const' || op === 'f64.const' || op === 'f32.const') return true
|
|
847
855
|
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
|
-
//
|
|
850
|
-
//
|
|
851
|
-
|
|
856
|
+
// A global is invariant only if not set directly AND no UNSAFE call in the loop —
|
|
857
|
+
// an unproven callee may mutate it (no interprocedural effect analysis). SAFE_OFFSET/
|
|
858
|
+
// READONLY_MEM/NON_MUTATING/pure calls are jz's own runtime helpers, audited to never
|
|
859
|
+
// touch a user global, so they don't block this the way an arbitrary call does — same
|
|
860
|
+
// distinction READONLY_MEM_CALLS purity already makes below. (Locals are frame-private,
|
|
861
|
+
// so calls can't touch them; only direct local.set matters.)
|
|
862
|
+
if (op === 'global.get') return typeof node[1] === 'string' && !globals.has(node[1]) && !hasUnsafeCall
|
|
852
863
|
if (op === 'local.tee') {
|
|
853
864
|
if (typeof node[1] !== 'string') return false
|
|
854
865
|
// The operand is evaluated BEFORE the tee writes $X, so a `local.get $X` inside
|
|
@@ -1242,10 +1253,12 @@ export function hoistInvariantLoop(fn) {
|
|
|
1242
1253
|
if (!Array.isArray(node)) return
|
|
1243
1254
|
if (node[0] === 'loop') return // already processed bottom-up
|
|
1244
1255
|
if (isHoistable(node) && (refcount.get(node) || 0) <= 1 && (refcount.get(parent) || 0) <= 1) {
|
|
1245
|
-
//
|
|
1246
|
-
// (BigInt) that plain JSON.stringify can't serialize, and it also
|
|
1247
|
-
// Infinity/-Infinity/NaN→null & -0→0 — both would dedup distinct
|
|
1248
|
-
|
|
1256
|
+
// stableNodeKey: hoistable boxed-pointer subtrees carry i64.const NaN-box
|
|
1257
|
+
// prefixes (BigInt) that plain JSON.stringify can't serialize, and it also
|
|
1258
|
+
// collapses Infinity/-Infinity/NaN→null & -0→0 — both would dedup distinct
|
|
1259
|
+
// invariants. (A replacer-based stringify was silently replacer-less
|
|
1260
|
+
// in-kernel — the recursive keyer behaves identically host and kernel.)
|
|
1261
|
+
const key = stableNodeKey(node)
|
|
1249
1262
|
let arr = sites.get(key); if (!arr) { arr = []; sites.set(key, arr) }
|
|
1250
1263
|
arr.push({ parent, idx, node })
|
|
1251
1264
|
return
|
|
@@ -1666,508 +1679,6 @@ export function cseScalarLoad(fn) {
|
|
|
1666
1679
|
* the surrounding `local.set/tee` handles that)
|
|
1667
1680
|
* - `br/br_if/br_table/return/unreachable` → NO clear (pure values still valid)
|
|
1668
1681
|
*/
|
|
1669
|
-
// Commutative WASM binops — shared by csePureExpr + csePureExprLoop for canonical
|
|
1670
|
-
// operand-key ordering (a*b and b*a hash to one entry). OP_TYPE tables stay local:
|
|
1671
|
-
// the two passes cover deliberately different op sets.
|
|
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'])
|
|
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
|
-
|
|
1687
|
-
export function csePureExpr(fn) {
|
|
1688
|
-
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
1689
|
-
const bodyStart = findBodyStart(fn)
|
|
1690
|
-
if (bodyStart < 0) return
|
|
1691
|
-
|
|
1692
|
-
// High-water mark across ALL surviving `$__pe<N>` locals, not the first gap.
|
|
1693
|
-
// A prior csePureExpr run + watr.coalesce can leave non-contiguous numbering
|
|
1694
|
-
// (e.g. $__pe0,$__pe1,$__pe5,$__pe20 — coalesce removed the merged ones); picking
|
|
1695
|
-
// the first gap (2) then allocating sequentially would collide on $__pe5 / $__pe20.
|
|
1696
|
-
let snapId = 0
|
|
1697
|
-
for (const n of fn) {
|
|
1698
|
-
if (!Array.isArray(n) || n[0] !== 'local' || typeof n[1] !== 'string') continue
|
|
1699
|
-
const m = /^\$__pe(\d+)$/.exec(n[1])
|
|
1700
|
-
if (m) { const k = +m[1]; if (k >= snapId) snapId = k + 1 }
|
|
1701
|
-
}
|
|
1702
|
-
const newLocals = []
|
|
1703
|
-
let refcount = null // lazily built on the first dedup — most fns never CSE
|
|
1704
|
-
|
|
1705
|
-
const TARGET_OPS = new Set([
|
|
1706
|
-
'f64.mul', 'f64.add', 'f64.sub',
|
|
1707
|
-
'i32.mul', 'i32.add', 'i32.sub', 'i32.shl', 'i32.shr_u', 'i32.shr_s', 'i32.and', 'i32.or', 'i32.xor',
|
|
1708
|
-
'i64.mul', 'i64.add', 'i64.sub', 'i64.shl', 'i64.shr_u', 'i64.shr_s', 'i64.and', 'i64.or', 'i64.xor',
|
|
1709
|
-
])
|
|
1710
|
-
const OP_TYPE = {
|
|
1711
|
-
'f64.mul': 'f64', 'f64.add': 'f64', 'f64.sub': 'f64',
|
|
1712
|
-
'i32.mul': 'i32', 'i32.add': 'i32', 'i32.sub': 'i32', 'i32.shl': 'i32', 'i32.shr_u': 'i32', 'i32.shr_s': 'i32', 'i32.and': 'i32', 'i32.or': 'i32', 'i32.xor': 'i32',
|
|
1713
|
-
'i64.mul': 'i64', 'i64.add': 'i64', 'i64.sub': 'i64', 'i64.shl': 'i64', 'i64.shr_u': 'i64', 'i64.shr_s': 'i64', 'i64.and': 'i64', 'i64.or': 'i64', 'i64.xor': 'i64',
|
|
1714
|
-
}
|
|
1715
|
-
|
|
1716
|
-
// Encode a leaf operand to a stable string key. Returns null if not pure-leaf.
|
|
1717
|
-
const leafKey = (n) => {
|
|
1718
|
-
if (!Array.isArray(n)) return null
|
|
1719
|
-
if (n[0] === 'local.get' && typeof n[1] === 'string') return `L:${n[1]}`
|
|
1720
|
-
if (n[0] === 'f64.const' || n[0] === 'i32.const' || n[0] === 'i64.const' || n[0] === 'f32.const') return `C:${n[0]}:${n[1]}`
|
|
1721
|
-
return null
|
|
1722
|
-
}
|
|
1723
|
-
|
|
1724
|
-
// table: key → { snapName | null, anchorParent, anchorIdx, locals: Set<string> }
|
|
1725
|
-
const table = new Map()
|
|
1726
|
-
|
|
1727
|
-
const invalidateLocal = (X) => {
|
|
1728
|
-
for (const [key, entry] of table) {
|
|
1729
|
-
if (entry.locals.has(X)) table.delete(key)
|
|
1730
|
-
}
|
|
1731
|
-
}
|
|
1732
|
-
|
|
1733
|
-
const walk = (node, parent, idx) => {
|
|
1734
|
-
if (!Array.isArray(node)) return
|
|
1735
|
-
const op = node[0]
|
|
1736
|
-
|
|
1737
|
-
if (op === 'loop' || op === 'if') {
|
|
1738
|
-
const saved = new Map(table)
|
|
1739
|
-
table.clear()
|
|
1740
|
-
for (let i = 1; i < node.length; i++) walk(node[i], node, i)
|
|
1741
|
-
table.clear()
|
|
1742
|
-
saved.clear()
|
|
1743
|
-
return
|
|
1744
|
-
}
|
|
1745
|
-
|
|
1746
|
-
// `then`/`else` branches of an `if` are mutually exclusive at runtime —
|
|
1747
|
-
// a snap tee cached in the `then` branch is unset when the `else` runs.
|
|
1748
|
-
// Isolate per-branch tables so a sibling branch can't reach into another's
|
|
1749
|
-
// CSE entries.
|
|
1750
|
-
if (op === 'then' || op === 'else') {
|
|
1751
|
-
table.clear()
|
|
1752
|
-
for (let i = 1; i < node.length; i++) walk(node[i], node, i)
|
|
1753
|
-
table.clear()
|
|
1754
|
-
return
|
|
1755
|
-
}
|
|
1756
|
-
|
|
1757
|
-
if (op === 'call' || op === 'call_ref' || op === 'call_indirect') {
|
|
1758
|
-
// Calls don't write locals; recurse, no clear.
|
|
1759
|
-
for (let i = 1; i < node.length; i++) walk(node[i], node, i)
|
|
1760
|
-
return
|
|
1761
|
-
}
|
|
1762
|
-
|
|
1763
|
-
if (op === 'local.set' || op === 'local.tee') {
|
|
1764
|
-
for (let i = 2; i < node.length; i++) walk(node[i], node, i)
|
|
1765
|
-
const X = node[1]
|
|
1766
|
-
if (typeof X === 'string') invalidateLocal(X)
|
|
1767
|
-
return
|
|
1768
|
-
}
|
|
1769
|
-
|
|
1770
|
-
// Try CSE on (OP A B) where A,B are pure leaves.
|
|
1771
|
-
if (TARGET_OPS.has(op) && node.length === 3) {
|
|
1772
|
-
const ka = leafKey(node[1])
|
|
1773
|
-
const kb = leafKey(node[2])
|
|
1774
|
-
if (ka && kb) {
|
|
1775
|
-
const key = COMMUTATIVE.has(op) && ka > kb ? `${op}|${kb}|${ka}` : `${op}|${ka}|${kb}`
|
|
1776
|
-
const entry = table.get(key)
|
|
1777
|
-
if (entry) {
|
|
1778
|
-
if (!entry.snapName) {
|
|
1779
|
-
// A shared (DAG) anchor breaks the in-place tee: the `%` fast-path emits
|
|
1780
|
-
// `a - trunc(a/b)*b` reusing ONE `a` node object, so the anchor and the
|
|
1781
|
-
// local.get replacement land on the SAME physical slot and the local.get
|
|
1782
|
-
// clobbers the tee — orphaning $__pe (reads 0). Skip when the anchor's
|
|
1783
|
-
// parent is shared; watr's DAG-aware CSE still dedupes. Mirrors csePureExprLoop.
|
|
1784
|
-
if (((refcount ??= buildRefcount(fn)).get(entry.anchorParent) || 0) > 1) return
|
|
1785
|
-
const snapName = `$__pe${snapId++}`
|
|
1786
|
-
entry.snapName = snapName
|
|
1787
|
-
newLocals.push(['local', snapName, OP_TYPE[op] || 'f64'])
|
|
1788
|
-
const orig = entry.anchorParent[entry.anchorIdx]
|
|
1789
|
-
entry.anchorParent[entry.anchorIdx] = ['local.tee', snapName, orig]
|
|
1790
|
-
}
|
|
1791
|
-
parent[idx] = ['local.get', entry.snapName]
|
|
1792
|
-
return
|
|
1793
|
-
} else {
|
|
1794
|
-
const locals = new Set()
|
|
1795
|
-
if (ka.startsWith('L:')) locals.add(ka.slice(2))
|
|
1796
|
-
if (kb.startsWith('L:')) locals.add(kb.slice(2))
|
|
1797
|
-
table.set(key, { snapName: null, anchorParent: parent, anchorIdx: idx, locals })
|
|
1798
|
-
return
|
|
1799
|
-
}
|
|
1800
|
-
}
|
|
1801
|
-
// Fall through to recurse.
|
|
1802
|
-
}
|
|
1803
|
-
|
|
1804
|
-
for (let i = 0; i < node.length; i++) walk(node[i], node, i)
|
|
1805
|
-
}
|
|
1806
|
-
|
|
1807
|
-
for (let i = bodyStart; i < fn.length; i++) walk(fn[i], fn, i)
|
|
1808
|
-
|
|
1809
|
-
if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
|
|
1810
|
-
}
|
|
1811
|
-
|
|
1812
|
-
/**
|
|
1813
|
-
* Post-watr nested CSE for hot fill loops (loop + trig). Reuses `$__pe` locals from
|
|
1814
|
-
* the pre-watr leaf pass. Deferred to the `phase === 'post'` run so watr's typed-array
|
|
1815
|
-
* inlining is not confused by pre-watr IR rewrites (test/mem.js).
|
|
1816
|
-
*/
|
|
1817
|
-
export function csePureExprLoop(fn) {
|
|
1818
|
-
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
1819
|
-
const bodyStart = findBodyStart(fn)
|
|
1820
|
-
if (bodyStart < 0) return
|
|
1821
|
-
|
|
1822
|
-
let hasLoop = false
|
|
1823
|
-
let hasExpensiveCall = false
|
|
1824
|
-
const scanShape = (n) => {
|
|
1825
|
-
if (!Array.isArray(n)) return
|
|
1826
|
-
if (n[0] === 'loop') hasLoop = true
|
|
1827
|
-
if (n[0] === 'call' && LOOP_CSE_EXPENSIVE.has(n[1])) hasExpensiveCall = true
|
|
1828
|
-
for (let i = 1; i < n.length; i++) scanShape(n[i])
|
|
1829
|
-
}
|
|
1830
|
-
for (let i = bodyStart; i < fn.length; i++) scanShape(fn[i])
|
|
1831
|
-
if (!hasLoop || !hasExpensiveCall) return
|
|
1832
|
-
|
|
1833
|
-
let snapId = nextLocalId(fn, 'pe')
|
|
1834
|
-
const newLocals = []
|
|
1835
|
-
|
|
1836
|
-
const refcount = buildRefcount(fn)
|
|
1837
|
-
const canMutateSite = (parent, node) =>
|
|
1838
|
-
(refcount.get(node) || 0) <= 1 && (refcount.get(parent) || 0) <= 1
|
|
1839
|
-
|
|
1840
|
-
const PURE_F64_BIN = new Set(['f64.mul', 'f64.add', 'f64.sub', 'f64.div'])
|
|
1841
|
-
const PURE_F64_UNARY = new Set(['f64.neg', 'f64.abs', 'f64.convert_i32_s', 'f64.convert_i32_u'])
|
|
1842
|
-
const PURE_I32_BIN = new Set(['i32.mul', 'i32.add', 'i32.sub', 'i32.shl', 'i32.shr_u', 'i32.shr_s', 'i32.and', 'i32.or', 'i32.xor'])
|
|
1843
|
-
const PURE_I32_UNARY = new Set(['i32.eqz', 'i32.clz', 'i32.ctz', 'i32.popcnt'])
|
|
1844
|
-
const OP_TYPE = {
|
|
1845
|
-
'f64.mul': 'f64', 'f64.add': 'f64', 'f64.sub': 'f64', 'f64.div': 'f64', 'f64.neg': 'f64', 'f64.abs': 'f64',
|
|
1846
|
-
'f64.convert_i32_s': 'f64', 'f64.convert_i32_u': 'f64',
|
|
1847
|
-
'i32.mul': 'i32', 'i32.add': 'i32', 'i32.sub': 'i32', 'i32.shl': 'i32', 'i32.shr_u': 'i32', 'i32.shr_s': 'i32',
|
|
1848
|
-
'i32.and': 'i32', 'i32.or': 'i32', 'i32.xor': 'i32', 'i32.eqz': 'i32',
|
|
1849
|
-
}
|
|
1850
|
-
|
|
1851
|
-
const table = new Map()
|
|
1852
|
-
const keyLocals = new Set()
|
|
1853
|
-
const keyGlobals = new Set()
|
|
1854
|
-
|
|
1855
|
-
const invalidateLocal = (X) => {
|
|
1856
|
-
for (const [key, entry] of table) {
|
|
1857
|
-
if (entry.locals.has(X)) table.delete(key)
|
|
1858
|
-
}
|
|
1859
|
-
}
|
|
1860
|
-
|
|
1861
|
-
const invalidateGlobal = (G) => {
|
|
1862
|
-
for (const [key, entry] of table) {
|
|
1863
|
-
if (entry.globals.has(G)) table.delete(key)
|
|
1864
|
-
}
|
|
1865
|
-
}
|
|
1866
|
-
|
|
1867
|
-
const pureKeyI32 = (n) => {
|
|
1868
|
-
if (!Array.isArray(n)) return null
|
|
1869
|
-
const op = n[0]
|
|
1870
|
-
if (op === 'local.get' && typeof n[1] === 'string') { keyLocals.add(n[1]); return `L:${n[1]}` }
|
|
1871
|
-
if (op === 'global.get' && typeof n[1] === 'string') { keyGlobals.add(n[1]); return `G:${n[1]}` }
|
|
1872
|
-
if (op === 'i32.const' || op === 'i64.const') return `C:${op}:${n[1]}`
|
|
1873
|
-
if (PURE_I32_UNARY.has(op) && n.length === 2) {
|
|
1874
|
-
const k = pureKeyI32(n[1]); return k ? `${op}|${k}` : null
|
|
1875
|
-
}
|
|
1876
|
-
if (PURE_I32_BIN.has(op) && n.length === 3) {
|
|
1877
|
-
const ka = pureKeyI32(n[1]), kb = pureKeyI32(n[2])
|
|
1878
|
-
if (!ka || !kb) return null
|
|
1879
|
-
return COMMUTATIVE.has(op) && ka > kb ? `${op}|${kb}|${ka}` : `${op}|${ka}|${kb}`
|
|
1880
|
-
}
|
|
1881
|
-
if (op === 'i32.wrap_i64' && n.length === 2) {
|
|
1882
|
-
const k = pureKeyI32(n[1]); return k ? `wrap|${k}` : null
|
|
1883
|
-
}
|
|
1884
|
-
return null
|
|
1885
|
-
}
|
|
1886
|
-
|
|
1887
|
-
const pureKeyF64 = (n) => {
|
|
1888
|
-
if (!Array.isArray(n)) return null
|
|
1889
|
-
const op = n[0]
|
|
1890
|
-
if (op === 'local.get' && typeof n[1] === 'string') { keyLocals.add(n[1]); return `L:${n[1]}` }
|
|
1891
|
-
if (op === 'global.get' && typeof n[1] === 'string') { keyGlobals.add(n[1]); return `G:${n[1]}` }
|
|
1892
|
-
if (op === 'f64.const' || op === 'f32.const') return `C:${op}:${n[1]}`
|
|
1893
|
-
if (PURE_F64_UNARY.has(op) && n.length === 2) {
|
|
1894
|
-
if (op === 'f64.convert_i32_s' || op === 'f64.convert_i32_u') {
|
|
1895
|
-
const k = pureKeyI32(n[1]); return k ? `${op}|${k}` : null
|
|
1896
|
-
}
|
|
1897
|
-
const k = pureKeyF64(n[1]); return k ? `${op}|${k}` : null
|
|
1898
|
-
}
|
|
1899
|
-
if (PURE_F64_BIN.has(op) && n.length === 3) {
|
|
1900
|
-
const ka = pureKeyF64(n[1]), kb = pureKeyF64(n[2])
|
|
1901
|
-
if (!ka || !kb) return null
|
|
1902
|
-
return COMMUTATIVE.has(op) && ka > kb ? `${op}|${kb}|${ka}` : `${op}|${ka}|${kb}`
|
|
1903
|
-
}
|
|
1904
|
-
if (op === 'call' && n[1] === '$__to_num' && n.length === 3) {
|
|
1905
|
-
const a = n[2]
|
|
1906
|
-
if (Array.isArray(a) && a[0] === 'i64.reinterpret_f64' && a.length === 2) {
|
|
1907
|
-
const k = pureKeyF64(a[1]); return k ? `tonum|${k}` : null
|
|
1908
|
-
}
|
|
1909
|
-
}
|
|
1910
|
-
return null
|
|
1911
|
-
}
|
|
1912
|
-
|
|
1913
|
-
const tryCse = (node, parent, idx) => {
|
|
1914
|
-
const op = node[0]
|
|
1915
|
-
if (op === 'local.get' || op === 'global.get' || op === 'f64.const' || op === 'f32.const') return
|
|
1916
|
-
if (!canMutateSite(parent, node)) return
|
|
1917
|
-
keyLocals.clear()
|
|
1918
|
-
keyGlobals.clear()
|
|
1919
|
-
const key = pureKeyF64(node)
|
|
1920
|
-
if (!key) return
|
|
1921
|
-
const locals = new Set(keyLocals)
|
|
1922
|
-
const globals = new Set(keyGlobals)
|
|
1923
|
-
const entry = table.get(key)
|
|
1924
|
-
if (entry) {
|
|
1925
|
-
if (!entry.snapName) {
|
|
1926
|
-
if ((refcount.get(entry.anchorParent) || 0) > 1) return
|
|
1927
|
-
const snapName = `$__pe${snapId++}`
|
|
1928
|
-
entry.snapName = snapName
|
|
1929
|
-
newLocals.push(['local', snapName, OP_TYPE[node[0]] || 'f64'])
|
|
1930
|
-
const orig = entry.anchorParent[entry.anchorIdx]
|
|
1931
|
-
entry.anchorParent[entry.anchorIdx] = ['local.tee', snapName, orig]
|
|
1932
|
-
}
|
|
1933
|
-
parent[idx] = ['local.get', entry.snapName]
|
|
1934
|
-
} else {
|
|
1935
|
-
table.set(key, { snapName: null, anchorParent: parent, anchorIdx: idx, locals, globals })
|
|
1936
|
-
}
|
|
1937
|
-
}
|
|
1938
|
-
|
|
1939
|
-
const walk = (node, parent, idx) => {
|
|
1940
|
-
if (!Array.isArray(node)) return
|
|
1941
|
-
const op = node[0]
|
|
1942
|
-
|
|
1943
|
-
if (op === 'loop') {
|
|
1944
|
-
table.clear()
|
|
1945
|
-
for (let i = 1; i < node.length; i++) walk(node[i], node, i)
|
|
1946
|
-
table.clear()
|
|
1947
|
-
return
|
|
1948
|
-
}
|
|
1949
|
-
|
|
1950
|
-
if (op === 'if') {
|
|
1951
|
-
table.clear()
|
|
1952
|
-
for (let i = 1; i < node.length; i++) walk(node[i], node, i)
|
|
1953
|
-
table.clear()
|
|
1954
|
-
return
|
|
1955
|
-
}
|
|
1956
|
-
|
|
1957
|
-
if (op === 'then' || op === 'else') {
|
|
1958
|
-
table.clear()
|
|
1959
|
-
for (let i = 1; i < node.length; i++) walk(node[i], node, i)
|
|
1960
|
-
table.clear()
|
|
1961
|
-
return
|
|
1962
|
-
}
|
|
1963
|
-
|
|
1964
|
-
if (op === 'call' || op === 'call_ref' || op === 'call_indirect') {
|
|
1965
|
-
for (let i = 1; i < node.length; i++) walk(node[i], node, i)
|
|
1966
|
-
return
|
|
1967
|
-
}
|
|
1968
|
-
|
|
1969
|
-
if (op === 'local.set' || op === 'local.tee') {
|
|
1970
|
-
for (let i = 2; i < node.length; i++) walk(node[i], node, i)
|
|
1971
|
-
const X = node[1]
|
|
1972
|
-
if (typeof X === 'string') invalidateLocal(X)
|
|
1973
|
-
return
|
|
1974
|
-
}
|
|
1975
|
-
|
|
1976
|
-
if (op === 'global.set') {
|
|
1977
|
-
for (let i = 2; i < node.length; i++) walk(node[i], node, i)
|
|
1978
|
-
const G = node[1]
|
|
1979
|
-
if (typeof G === 'string') invalidateGlobal(G)
|
|
1980
|
-
return
|
|
1981
|
-
}
|
|
1982
|
-
|
|
1983
|
-
for (let i = 1; i < node.length; i++) {
|
|
1984
|
-
if (Array.isArray(node[i])) walk(node[i], node, i)
|
|
1985
|
-
}
|
|
1986
|
-
tryCse(node, parent, idx)
|
|
1987
|
-
}
|
|
1988
|
-
|
|
1989
|
-
for (let i = bodyStart; i < fn.length; i++) walk(fn[i], fn, i)
|
|
1990
|
-
|
|
1991
|
-
if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
|
|
1992
|
-
}
|
|
1993
|
-
|
|
1994
|
-
/**
|
|
1995
|
-
* Drop redundant zero-initialisation of fresh function-scope locals.
|
|
1996
|
-
*
|
|
1997
|
-
* WASM zero-initialises every local on entry (0 / 0.0 / null). jz lowers source
|
|
1998
|
-
* `let x = 0` to `(local $x …)` + `(local.set $x (<zero const>))` at the top of
|
|
1999
|
-
* the function body — the explicit set is a no-op when nothing has touched `$x`
|
|
2000
|
-
* yet. `wasm-opt -Oz` elides these; do the same so jz's own output is minimal.
|
|
2001
|
-
*
|
|
2002
|
-
* Only removes a `(local.set $L (i32|i64|f64|f32.const 0))` when:
|
|
2003
|
-
* - `$L` is a non-param local (a param's "default" is the incoming arg, not 0),
|
|
2004
|
-
* - it is a *top-level* body statement (never descend into block/loop/if — a
|
|
2005
|
-
* nested zero-set inside a loop genuinely re-initialises across iterations),
|
|
2006
|
-
* - `$L` has not been referenced by any earlier top-level statement (so the
|
|
2007
|
-
* local still holds its entry-time zero at this point),
|
|
2008
|
-
* - `$L` is read (`local.get`) somewhere in the function (otherwise leave the
|
|
2009
|
-
* store for deadStoreElim and avoid orphaning the `(local $L …)` decl),
|
|
2010
|
-
* - the constant is +0 / +0.0 (a `-0.0` f64 set is *not* redundant — locals
|
|
2011
|
-
* default to +0.0, which differs in bits from -0.0).
|
|
2012
|
-
*/
|
|
2013
|
-
export function dropDeadZeroInit(fn) {
|
|
2014
|
-
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
2015
|
-
const bodyStart = findBodyStart(fn)
|
|
2016
|
-
if (bodyStart < 0) return
|
|
2017
|
-
|
|
2018
|
-
const seen = new Set() // params + locals referenced by an earlier stmt
|
|
2019
|
-
const reads = new Set() // locals read by `local.get` anywhere
|
|
2020
|
-
for (const c of fn) if (Array.isArray(c) && c[0] === 'param' && typeof c[1] === 'string') seen.add(c[1])
|
|
2021
|
-
|
|
2022
|
-
const collectGets = (node) => {
|
|
2023
|
-
if (!Array.isArray(node)) return
|
|
2024
|
-
if (node[0] === 'local.get' && typeof node[1] === 'string') reads.add(node[1])
|
|
2025
|
-
for (let i = 1; i < node.length; i++) collectGets(node[i])
|
|
2026
|
-
}
|
|
2027
|
-
for (let i = bodyStart; i < fn.length; i++) collectGets(fn[i])
|
|
2028
|
-
|
|
2029
|
-
const collectRefs = (node) => {
|
|
2030
|
-
if (!Array.isArray(node)) return
|
|
2031
|
-
const op = node[0]
|
|
2032
|
-
if ((op === 'local.get' || op === 'local.set' || op === 'local.tee') && typeof node[1] === 'string') seen.add(node[1])
|
|
2033
|
-
for (let i = 1; i < node.length; i++) collectRefs(node[i])
|
|
2034
|
-
}
|
|
2035
|
-
const isPlusZeroConst = (e) => {
|
|
2036
|
-
if (!Array.isArray(e) || e.length !== 2) return false
|
|
2037
|
-
if (e[0] !== 'i32.const' && e[0] !== 'i64.const' && e[0] !== 'f64.const' && e[0] !== 'f32.const') return false
|
|
2038
|
-
const v = e[1]
|
|
2039
|
-
if (typeof v === 'bigint') return v === 0n
|
|
2040
|
-
if (typeof v === 'number') return v === 0 && !Object.is(v, -0)
|
|
2041
|
-
if (typeof v === 'string') { const t = v.trim(); return t === '0' || t === '0.0' || t === '+0' || t === '+0.0' }
|
|
2042
|
-
return false
|
|
2043
|
-
}
|
|
2044
|
-
|
|
2045
|
-
const drop = []
|
|
2046
|
-
for (let i = bodyStart; i < fn.length; i++) {
|
|
2047
|
-
const node = fn[i]
|
|
2048
|
-
if (!Array.isArray(node)) continue
|
|
2049
|
-
if (node[0] === 'local.set' && node.length === 3 && typeof node[1] === 'string' &&
|
|
2050
|
-
!seen.has(node[1]) && reads.has(node[1]) && isPlusZeroConst(node[2])) {
|
|
2051
|
-
drop.push(i)
|
|
2052
|
-
seen.add(node[1])
|
|
2053
|
-
continue
|
|
2054
|
-
}
|
|
2055
|
-
collectRefs(node)
|
|
2056
|
-
}
|
|
2057
|
-
for (let i = drop.length - 1; i >= 0; i--) fn.splice(drop[i], 1)
|
|
2058
|
-
}
|
|
2059
|
-
|
|
2060
|
-
/**
|
|
2061
|
-
* Dead-store elimination: remove `local.set` / `local.tee` and `drop` of pure
|
|
2062
|
-
* expressions whose values are never consumed.
|
|
2063
|
-
*
|
|
2064
|
-
* Conservative single-block analysis: tracks last-write per local within each
|
|
2065
|
-
* straight-line sequence. A write is dead if the same local is written again
|
|
2066
|
-
* before any intervening read in the same block. Control-flow boundaries
|
|
2067
|
-
* (block, loop, if) reset the table — we don't eliminate across branches.
|
|
2068
|
-
*
|
|
2069
|
-
* Also removes `drop` of pure expressions (e.g. leftover ptr-type calls).
|
|
2070
|
-
*/
|
|
2071
|
-
export function deadStoreElim(fn) {
|
|
2072
|
-
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
2073
|
-
const bodyStart = findBodyStart(fn)
|
|
2074
|
-
if (bodyStart < 0) return
|
|
2075
|
-
|
|
2076
|
-
const dead = []
|
|
2077
|
-
|
|
2078
|
-
const collectGets = (node, out) => {
|
|
2079
|
-
if (!Array.isArray(node)) return
|
|
2080
|
-
if (node[0] === 'local.get' && typeof node[1] === 'string') { out.add(node[1]); return }
|
|
2081
|
-
for (let i = 1; i < node.length; i++) collectGets(node[i], out)
|
|
2082
|
-
}
|
|
2083
|
-
|
|
2084
|
-
const isPure = (node) => {
|
|
2085
|
-
if (!Array.isArray(node)) return true
|
|
2086
|
-
const op = node[0]
|
|
2087
|
-
if (typeof op === 'string' && MEMOP.test(op)) return false
|
|
2088
|
-
if (op === 'call' || op === 'call_indirect' || op === 'call_ref') return false
|
|
2089
|
-
if (op === 'global.get' || op === 'global.set') return false
|
|
2090
|
-
if (op === 'local.tee') return false
|
|
2091
|
-
if (op === 'memory.size' || op === 'memory.grow') return false
|
|
2092
|
-
for (let i = 1; i < node.length; i++) if (!isPure(node[i])) return false
|
|
2093
|
-
return true
|
|
2094
|
-
}
|
|
2095
|
-
|
|
2096
|
-
const scanBlock = (items, start, end) => {
|
|
2097
|
-
const lastWrite = new Map() // localName → { parent, idx }
|
|
2098
|
-
|
|
2099
|
-
for (let i = start; i < end; i++) {
|
|
2100
|
-
const node = items[i]
|
|
2101
|
-
if (!Array.isArray(node)) continue
|
|
2102
|
-
const op = node[0]
|
|
2103
|
-
|
|
2104
|
-
// Reads invalidate pending dead writes. For local.tee/local.set, the RHS reads
|
|
2105
|
-
// happen BEFORE the write — so a `local.get $x` inside `(local.tee $x ...)` is a
|
|
2106
|
-
// real read of the OLD $x and must invalidate any pending dead-write of $x.
|
|
2107
|
-
const reads = new Set()
|
|
2108
|
-
collectGets(node, reads)
|
|
2109
|
-
for (const name of reads) lastWrite.delete(name)
|
|
2110
|
-
|
|
2111
|
-
// Drop of pure expr → dead. Only `(drop EXPR)`: a bare `(drop)` consumes
|
|
2112
|
-
// an implicit stack value (e.g. a `try_table` catch payload) — removing it
|
|
2113
|
-
// would unbalance the stack.
|
|
2114
|
-
if (op === 'drop' && node.length === 2 && isPure(node[1])) {
|
|
2115
|
-
dead.push({ parent: items, node, drop: true })
|
|
2116
|
-
}
|
|
2117
|
-
|
|
2118
|
-
// Local write tracking
|
|
2119
|
-
if ((op === 'local.set' || op === 'local.tee') && typeof node[1] === 'string') {
|
|
2120
|
-
const prev = lastWrite.get(node[1])
|
|
2121
|
-
if (prev) {
|
|
2122
|
-
// The store-to-local is dead, but a `local.set` is only *removable*
|
|
2123
|
-
// if its RHS is pure — `local.set $x (call f …)` where `f` mutates
|
|
2124
|
-
// memory must still run. (A `local.tee` is always safe: removal demotes
|
|
2125
|
-
// it to its value expression, so any side effects there are preserved.)
|
|
2126
|
-
if (prev.node[0] === 'local.tee' || isPure(prev.node[2])) dead.push(prev)
|
|
2127
|
-
}
|
|
2128
|
-
lastWrite.set(node[1], { parent: items, node })
|
|
2129
|
-
}
|
|
2130
|
-
|
|
2131
|
-
// Recurse into nested blocks with fresh state
|
|
2132
|
-
if (op === 'block' || op === 'loop') {
|
|
2133
|
-
let j = 1
|
|
2134
|
-
while (j < node.length && Array.isArray(node[j]) && node[j][0] === 'result') j++
|
|
2135
|
-
scanBlock(node, j, node.length)
|
|
2136
|
-
} else if (op === 'if') {
|
|
2137
|
-
let j = 1
|
|
2138
|
-
while (j < node.length && Array.isArray(node[j]) && node[j][0] === 'result') j++
|
|
2139
|
-
const condReads = new Set()
|
|
2140
|
-
collectGets(node[j], condReads)
|
|
2141
|
-
for (const name of condReads) lastWrite.delete(name)
|
|
2142
|
-
j++
|
|
2143
|
-
for (; j < node.length; j++) {
|
|
2144
|
-
const c = node[j]
|
|
2145
|
-
if (Array.isArray(c) && (c[0] === 'then' || c[0] === 'else')) scanBlock(c, 1, c.length)
|
|
2146
|
-
}
|
|
2147
|
-
}
|
|
2148
|
-
}
|
|
2149
|
-
}
|
|
2150
|
-
|
|
2151
|
-
scanBlock(fn, bodyStart, fn.length)
|
|
2152
|
-
|
|
2153
|
-
// Removal is IDENTITY-based: entries are pushed at SUPERSEDE time, so
|
|
2154
|
-
// same-parent indices are not monotonic (name A's earlier write can be
|
|
2155
|
-
// superseded after name B's later one). Index-order splicing then shifts
|
|
2156
|
-
// remaining entries onto innocent neighbors — the self-host L2 divergence
|
|
2157
|
-
// deleted a typed-literal f64.store exactly this way. Re-locating each
|
|
2158
|
-
// captured node at removal time is immune to any ordering or prior splice.
|
|
2159
|
-
for (const d of dead) {
|
|
2160
|
-
const at = d.parent.indexOf(d.node)
|
|
2161
|
-
if (at < 0) continue // already removed (nested duplicate) — nothing to do
|
|
2162
|
-
if (!d.drop && d.node[0] === 'local.tee') {
|
|
2163
|
-
// tee in statement position: replace with just the value (implicitly dropped)
|
|
2164
|
-
d.parent[at] = d.node[2]
|
|
2165
|
-
} else {
|
|
2166
|
-
d.parent.splice(at, 1)
|
|
2167
|
-
}
|
|
2168
|
-
}
|
|
2169
|
-
}
|
|
2170
|
-
|
|
2171
1682
|
/**
|
|
2172
1683
|
* Forward-substitute a single-def / single-use local into its sole use, eliminating the local,
|
|
2173
1684
|
* its `local.set` and its `local.get`. This is watr's "propagate": jz emits short-lived address/
|
|
@@ -2283,6 +1794,146 @@ export function propagateSingleUse(fn) {
|
|
|
2283
1794
|
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
1795
|
}
|
|
2285
1796
|
|
|
1797
|
+
/**
|
|
1798
|
+
* Sink a single-def local's RHS to its FIRST use as a `local.tee` (the simplify-locals
|
|
1799
|
+
* transform watr's use-count propagate doesn't do, confirmed by diffing jz+watr vs Binaryen):
|
|
1800
|
+
*
|
|
1801
|
+
* (local.set $t (call $f ...)) ⇒ (i64.store a (local.tee $t (call $f ...)))
|
|
1802
|
+
* (i64.store a (local.get $t)) ... later (local.get $t) ...
|
|
1803
|
+
* ... later (local.get $t) ...
|
|
1804
|
+
*
|
|
1805
|
+
* — removes the standalone `set` statement + the first `local.get` (~2–4 B/site). For a
|
|
1806
|
+
* SINGLE-use local the RHS is forwarded outright (no tee, decl dropped) — this also catches
|
|
1807
|
+
* effectful single-use temps (`call`/`load` RHS) that `propagateSingleUse` skips. Runs after
|
|
1808
|
+
* `propagateSingleUse`, so it sees only the cases that one leaves: multi-use, and effectful.
|
|
1809
|
+
*
|
|
1810
|
+
* Correctness rests on `scanUse`, an eval-order walk that flags a conflict BEFORE the use:
|
|
1811
|
+
* - a write to any local the RHS reads (R),
|
|
1812
|
+
* - a control-flow transfer (br/return/throw/…) that could skip the use while a later use lives,
|
|
1813
|
+
* - for a memory-reading RHS, an intervening memory/global WRITE,
|
|
1814
|
+
* - for a memory-writing RHS (incl. any call), ANY intervening memory/global access.
|
|
1815
|
+
* Plus: a multi-use fold (and an effectful single-use forward) requires the use to be
|
|
1816
|
+
* UNCONDITIONALLY reached (not under if/loop/block) so the tee always runs before later reads;
|
|
1817
|
+
* never sink under a `loop` (would re-evaluate / re-effect the RHS per iteration).
|
|
1818
|
+
*/
|
|
1819
|
+
export function foldSetToTee(fn) {
|
|
1820
|
+
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
1821
|
+
const bodyStart = findBodyStart(fn)
|
|
1822
|
+
if (bodyStart < 0) return
|
|
1823
|
+
|
|
1824
|
+
// v128 lane sequences are already register-tight — folding only adds pressure (mirrors propagateSingleUse).
|
|
1825
|
+
let hasV128 = false
|
|
1826
|
+
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]) }
|
|
1827
|
+
for (let i = bodyStart; i < fn.length && !hasV128; i++) scanV(fn[i])
|
|
1828
|
+
if (hasV128) return
|
|
1829
|
+
|
|
1830
|
+
const setN = new Map(), getN = new Map(), teed = new Set()
|
|
1831
|
+
const tally = (n) => {
|
|
1832
|
+
if (!Array.isArray(n)) return
|
|
1833
|
+
const op = n[0]
|
|
1834
|
+
if (op === 'local.set' && typeof n[1] === 'string') setN.set(n[1], (setN.get(n[1]) || 0) + 1)
|
|
1835
|
+
else if (op === 'local.tee' && typeof n[1] === 'string') teed.add(n[1])
|
|
1836
|
+
else if (op === 'local.get' && typeof n[1] === 'string') getN.set(n[1], (getN.get(n[1]) || 0) + 1)
|
|
1837
|
+
for (let i = 1; i < n.length; i++) tally(n[i])
|
|
1838
|
+
}
|
|
1839
|
+
for (let i = bodyStart; i < fn.length; i++) tally(fn[i])
|
|
1840
|
+
|
|
1841
|
+
const cand = new Set()
|
|
1842
|
+
for (const [name, c] of setN) if (c === 1 && (getN.get(name) || 0) >= 1 && !teed.has(name)) cand.add(name)
|
|
1843
|
+
if (!cand.size) return
|
|
1844
|
+
|
|
1845
|
+
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) }
|
|
1846
|
+
const noLocalWrite = (n) => {
|
|
1847
|
+
if (!Array.isArray(n)) return true
|
|
1848
|
+
if (n[0] === 'local.set' || n[0] === 'local.tee') return false
|
|
1849
|
+
for (let i = 1; i < n.length; i++) if (!noLocalWrite(n[i])) return false
|
|
1850
|
+
return true
|
|
1851
|
+
}
|
|
1852
|
+
const isWriteOp = (op) => op === 'call' || op === 'call_indirect' || op === 'call_ref' || op === 'return_call'
|
|
1853
|
+
|| op === 'return_call_indirect' || op === 'global.set' || op === 'memory.grow' || op === 'memory.copy'
|
|
1854
|
+
|| op === 'memory.fill' || (typeof op === 'string' && (op.includes('.store') || op.includes('.atomic')))
|
|
1855
|
+
const isReadOp = (op) => op === 'global.get' || op === 'memory.size' || (typeof op === 'string' && op.includes('.load'))
|
|
1856
|
+
const isCF = (op) => op === 'if' || op === 'loop' || op === 'block' || op === 'try' || op === 'try_table'
|
|
1857
|
+
const isTransfer = (op) => op === 'br' || op === 'br_if' || op === 'br_table' || op === 'return'
|
|
1858
|
+
|| op === 'return_call' || op === 'return_call_indirect' || op === 'unreachable' || op === 'throw' || op === 'rethrow'
|
|
1859
|
+
const rhsKind = (n) => { // 'writes' | 'reads' | 'pure'
|
|
1860
|
+
let w = false, r = false
|
|
1861
|
+
const rec = (x) => { if (!Array.isArray(x)) return; if (isWriteOp(x[0])) w = true; else if (isReadOp(x[0])) r = true; for (let i = 1; i < x.length; i++) rec(x[i]) }
|
|
1862
|
+
rec(n)
|
|
1863
|
+
return w ? 'writes' : r ? 'reads' : 'pure'
|
|
1864
|
+
}
|
|
1865
|
+
// Walk `root` in eval order for the first `(local.get t)`; return {use, conflict}.
|
|
1866
|
+
// `conflict` reflects only what is evaluated strictly BEFORE the use (or the whole node if no use).
|
|
1867
|
+
const scanUse = (root, t, mode, R) => {
|
|
1868
|
+
let use = null, conflict = false
|
|
1869
|
+
const rec = (node, underLoop, underCond) => {
|
|
1870
|
+
if (use || conflict || !Array.isArray(node)) return
|
|
1871
|
+
const op = node[0]
|
|
1872
|
+
for (let i = 1; i < node.length; i++) {
|
|
1873
|
+
if (use || conflict) return
|
|
1874
|
+
const c = node[i]
|
|
1875
|
+
if (Array.isArray(c) && c[0] === 'local.get' && c[1] === t) { use = { parent: node, idx: i, underLoop, underCond }; return }
|
|
1876
|
+
// an `if`'s CONDITION (child 1) and all `select` operands evaluate UNCONDITIONALLY when
|
|
1877
|
+
// the construct is reached — only then/else bodies are conditional. Treating the condition
|
|
1878
|
+
// as conditional would wrongly refuse the common `if ((local.tee $t E) …)` fold.
|
|
1879
|
+
const childCond = (op === 'if' && i === 1) || op === 'select' ? underCond : underCond || isCF(op)
|
|
1880
|
+
rec(c, underLoop || op === 'loop', childCond)
|
|
1881
|
+
}
|
|
1882
|
+
if (use || conflict) return
|
|
1883
|
+
// post-order: this node's own effect, relative to the RHS we want to move past it
|
|
1884
|
+
if ((op === 'local.set' || op === 'local.tee') && R.has(node[1])) conflict = true
|
|
1885
|
+
else if (isTransfer(op)) conflict = true
|
|
1886
|
+
else if (mode === 'writes' && (isWriteOp(op) || isReadOp(op))) conflict = true
|
|
1887
|
+
else if (mode === 'reads' && isWriteOp(op)) conflict = true
|
|
1888
|
+
}
|
|
1889
|
+
rec(root, false, false)
|
|
1890
|
+
return { use, conflict }
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
const removed = new Set()
|
|
1894
|
+
const optimizeList = (list, start) => {
|
|
1895
|
+
for (let i = start; i < list.length; i++) {
|
|
1896
|
+
const s = list[i]
|
|
1897
|
+
if (!Array.isArray(s)) continue
|
|
1898
|
+
let folded = false
|
|
1899
|
+
if (s[0] === 'local.set' && s.length === 3 && typeof s[1] === 'string' && cand.has(s[1]) && noLocalWrite(s[2])) {
|
|
1900
|
+
const t = s[1], E = s[2], R = new Set(); readsOf(E, R)
|
|
1901
|
+
if (!R.has(t)) { // self-ref RHS reads the very local — leave it
|
|
1902
|
+
const single = (getN.get(t) || 0) === 1
|
|
1903
|
+
const mode = rhsKind(E)
|
|
1904
|
+
for (let j = i + 1; j < list.length; j++) {
|
|
1905
|
+
const sj = list[j]
|
|
1906
|
+
if (!Array.isArray(sj)) continue
|
|
1907
|
+
const { use, conflict } = scanUse(sj, t, mode, R)
|
|
1908
|
+
if (conflict) break // unsafe to move RHS this far
|
|
1909
|
+
if (use) {
|
|
1910
|
+
// effectful single-use, or any multi-use, must reach the use unconditionally;
|
|
1911
|
+
// never sink under a loop (re-eval / re-effect).
|
|
1912
|
+
const okCond = (single && mode !== 'writes') ? true : !use.underCond
|
|
1913
|
+
if (!use.underLoop && okCond) {
|
|
1914
|
+
use.parent[use.idx] = single ? E : ['local.tee', t, E]
|
|
1915
|
+
list.splice(i, 1); cand.delete(t); if (single) removed.add(t); i--; folded = true
|
|
1916
|
+
}
|
|
1917
|
+
break
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
if (folded) continue // re-process from the freed slot
|
|
1923
|
+
// recurse into nested statement lists
|
|
1924
|
+
if (s[0] === 'block' || s[0] === 'loop') {
|
|
1925
|
+
let k = 1; while (k < s.length && Array.isArray(s[k]) && s[k][0] === 'result') k++
|
|
1926
|
+
optimizeList(s, k)
|
|
1927
|
+
} else if (s[0] === 'if') {
|
|
1928
|
+
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) }
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
optimizeList(fn, bodyStart)
|
|
1933
|
+
|
|
1934
|
+
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) }
|
|
1935
|
+
}
|
|
1936
|
+
|
|
2286
1937
|
/**
|
|
2287
1938
|
* Module-wide scan for "volatile" globals — those mutated (`global.set`) in any
|
|
2288
1939
|
* function other than `$__start`. Globals written only in `$__start` are
|
|
@@ -2503,6 +2154,194 @@ export function hoistGlobalPtrOffset(fn, stablePtrGlobals, reachableWrites) {
|
|
|
2503
2154
|
fn.splice(bodyStart, 0, ...decls, ...snaps)
|
|
2504
2155
|
}
|
|
2505
2156
|
|
|
2157
|
+
/**
|
|
2158
|
+
* Loop-scoped complement to `hoistGlobalPtrOffset`. That pass requires the
|
|
2159
|
+
* WHOLE FUNCTION to be clean w.r.t. a global (no write anywhere, no
|
|
2160
|
+
* call_indirect/call_ref anywhere) — one unrelated indirect call ANYWHERE in
|
|
2161
|
+
* a large function (e.g. a devirtualized Pratt-loop trampoline that inlines
|
|
2162
|
+
* many operator handlers) poisons every stable-pointee global for the WHOLE
|
|
2163
|
+
* function, even a tight char-scan loop inside it that touches nothing
|
|
2164
|
+
* unsafe itself. This pass re-tries per LOOP, narrowing the write/call scan
|
|
2165
|
+
* to just that loop's own subtree (including nested loops, whose dynamic
|
|
2166
|
+
* extent is part of the enclosing loop's).
|
|
2167
|
+
*
|
|
2168
|
+
* Soundness (route (a) from the design note — the simplest sound design):
|
|
2169
|
+
* a global's base is hoisted to a loop's preheader iff, within that loop's
|
|
2170
|
+
* subtree, (1) the global is never `global.set`, (2) no `call_indirect` /
|
|
2171
|
+
* `call_ref` appears at all (fail-closed: an unknown target could write
|
|
2172
|
+
* anything), and (3) every DIRECTLY-called function does not (transitively,
|
|
2173
|
+
* via `reachableWrites` — `collectReachableGlobalWrites`, itself fail-closed
|
|
2174
|
+
* the same way) write the global. No route (b) (re-derive-at-write /
|
|
2175
|
+
* generation guard) — a loop that fails (1)-(3) is left exactly as
|
|
2176
|
+
* `hoistGlobalPtrOffset` left it.
|
|
2177
|
+
*
|
|
2178
|
+
* One extra idiom this pass alone needs: the durable-receiver override probe
|
|
2179
|
+
* (`sidecarOverride`, src/ir.js) reads a global ONCE per iteration into a
|
|
2180
|
+
* local for a NaN/tag check, then reuses that SAME local for the base-decode
|
|
2181
|
+
* a few nodes downstream — `local.tee $vo (global.get $cur)` feeding a later
|
|
2182
|
+
* `(i64.reinterpret_f64 (local.get $vo))`. hoistGlobalPtrOffset's site
|
|
2183
|
+
* matcher only recognizes a DIRECT `global.get`, missing this alias.
|
|
2184
|
+
* `buildLocalGlobalAlias` resolves it: `$vo` aliases `$cur` when every write
|
|
2185
|
+
* to `$vo` within the loop is textually `(global.get $cur)` — since `$cur`
|
|
2186
|
+
* is already proven unwritten in the loop, every such tee assigns the same
|
|
2187
|
+
* invariant value, so any downstream read of `$vo` is as invariant as
|
|
2188
|
+
* `global.get $cur` itself.
|
|
2189
|
+
*
|
|
2190
|
+
* Runs immediately after `hoistGlobalPtrOffset` in the same module pass: any
|
|
2191
|
+
* site the function-wide pass already hoisted is now `local.get $__goN`, so
|
|
2192
|
+
* `siteGlobal` no longer matches it there — the two passes can't double-hoist
|
|
2193
|
+
* the same read.
|
|
2194
|
+
*
|
|
2195
|
+
* @param {Array} fn - func IR node
|
|
2196
|
+
* @param {Set<string>} stablePtrGlobals - '$name's of never-forwarding module globals
|
|
2197
|
+
* @param {Map<string,Set<string>>} reachableWrites - from collectReachableGlobalWrites
|
|
2198
|
+
*/
|
|
2199
|
+
export function hoistLoopGlobalPtrOffset(fn, stablePtrGlobals, reachableWrites) {
|
|
2200
|
+
if (!Array.isArray(fn) || fn[0] !== 'func' || !stablePtrGlobals?.size) return
|
|
2201
|
+
const bodyStart = findBodyStart(fn)
|
|
2202
|
+
if (bodyStart < 0) return
|
|
2203
|
+
|
|
2204
|
+
// Per-loop: locals whose every write in `loopNode` is exactly `(global.get
|
|
2205
|
+
// $G)` for one consistent G — a conflicting write (different G, or any
|
|
2206
|
+
// other expression) poisons the name for this loop.
|
|
2207
|
+
const buildLocalGlobalAlias = (loopNode) => {
|
|
2208
|
+
const alias = new Map(), poisoned = new Set()
|
|
2209
|
+
const scan = (n) => {
|
|
2210
|
+
if (!Array.isArray(n)) return
|
|
2211
|
+
if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') {
|
|
2212
|
+
const name = n[1], rhs = n[2]
|
|
2213
|
+
if (!poisoned.has(name)) {
|
|
2214
|
+
const g = Array.isArray(rhs) && rhs[0] === 'global.get' && typeof rhs[1] === 'string' ? rhs[1] : null
|
|
2215
|
+
if (g != null && (!alias.has(name) || alias.get(name) === g)) alias.set(name, g)
|
|
2216
|
+
else { poisoned.add(name); alias.delete(name) }
|
|
2217
|
+
}
|
|
2218
|
+
scan(rhs)
|
|
2219
|
+
return
|
|
2220
|
+
}
|
|
2221
|
+
for (let i = 1; i < n.length; i++) scan(n[i])
|
|
2222
|
+
}
|
|
2223
|
+
for (let i = 1; i < loopNode.length; i++) scan(loopNode[i])
|
|
2224
|
+
return alias
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
// `(i64.reinterpret_f64 X)` → stable-pointee global name, or null. X is a
|
|
2228
|
+
// direct `(global.get $G)`, or a `(local.get $X)` resolved through `alias`.
|
|
2229
|
+
const reintGlobal = (n, alias) => {
|
|
2230
|
+
if (!Array.isArray(n) || n[0] !== 'i64.reinterpret_f64' || n.length !== 2) return null
|
|
2231
|
+
const x = n[1]
|
|
2232
|
+
if (Array.isArray(x) && x[0] === 'global.get' && typeof x[1] === 'string') return x[1]
|
|
2233
|
+
if (Array.isArray(x) && x[0] === 'local.get' && typeof x[1] === 'string') return alias.get(x[1]) ?? null
|
|
2234
|
+
return null
|
|
2235
|
+
}
|
|
2236
|
+
// Same two interchangeable shapes as hoistGlobalPtrOffset.siteGlobal — see
|
|
2237
|
+
// that function's comment for why both forms hoist to one snapshot.
|
|
2238
|
+
const siteGlobal = (n, alias) => {
|
|
2239
|
+
if (!Array.isArray(n)) return null
|
|
2240
|
+
if (n[0] === 'call' && n[1] === '$__ptr_offset' && n.length === 3) return reintGlobal(n[2], alias)
|
|
2241
|
+
if (n[0] === 'i32.wrap_i64' && n.length === 2 && Array.isArray(n[1]) && n[1][0] === 'i64.and' && n[1].length === 3) {
|
|
2242
|
+
const mask = n[1][2]
|
|
2243
|
+
if (Array.isArray(mask) && mask[0] === 'i64.const'
|
|
2244
|
+
&& (typeof mask[1] === 'string' ? Number(mask[1]) : mask[1]) === LAYOUT.OFFSET_MASK)
|
|
2245
|
+
return reintGlobal(n[1][1], alias)
|
|
2246
|
+
}
|
|
2247
|
+
return null
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
// Collision-proof snap ids — shared `$__goN` numbering space with
|
|
2251
|
+
// hoistGlobalPtrOffset so re-running either pass never collides.
|
|
2252
|
+
const used = new Set()
|
|
2253
|
+
const scanIds = (n) => {
|
|
2254
|
+
if (!Array.isArray(n)) return
|
|
2255
|
+
if (n[0] === 'local' && typeof n[1] === 'string' && n[1].startsWith('$__go')) {
|
|
2256
|
+
const t = n[1].slice(5); if (/^\d+$/.test(t)) used.add(+t)
|
|
2257
|
+
}
|
|
2258
|
+
for (let i = 0; i < n.length; i++) scanIds(n[i])
|
|
2259
|
+
}
|
|
2260
|
+
scanIds(fn)
|
|
2261
|
+
let idCounter = 0
|
|
2262
|
+
const freshId = () => { while (used.has(idCounter)) idCounter++; const id = idCounter++; used.add(id); return `$__go${id}` }
|
|
2263
|
+
|
|
2264
|
+
const newDecls = []
|
|
2265
|
+
|
|
2266
|
+
// Attempt one loop; returns preheader statements to splice just before it
|
|
2267
|
+
// (possibly empty). Outer-first (top-down): if the outer loop's hoist
|
|
2268
|
+
// succeeds, `replace` below rewrites every matching site in its ENTIRE
|
|
2269
|
+
// subtree — including nested loops — so a later independent attempt on a
|
|
2270
|
+
// nested loop finds nothing left to do for that global (no double-hoist,
|
|
2271
|
+
// no redundant inner snapshot of an outer-invariant value).
|
|
2272
|
+
const processLoop = (loopNode) => {
|
|
2273
|
+
const alias = buildLocalGlobalAlias(loopNode)
|
|
2274
|
+
const sites = new Map(), ownWrites = new Set(), ownCallees = new Set(), ptrOffsetForm = new Set()
|
|
2275
|
+
let hasIndirect = false
|
|
2276
|
+
const scan = (n) => {
|
|
2277
|
+
if (!Array.isArray(n)) return
|
|
2278
|
+
const g = siteGlobal(n, alias)
|
|
2279
|
+
if (g != null) {
|
|
2280
|
+
let arr = sites.get(g); if (!arr) { arr = []; sites.set(g, arr) }
|
|
2281
|
+
arr.push(n)
|
|
2282
|
+
if (n[0] === 'call') ptrOffsetForm.add(g)
|
|
2283
|
+
return
|
|
2284
|
+
}
|
|
2285
|
+
if (n[0] === 'global.set' && typeof n[1] === 'string') ownWrites.add(n[1])
|
|
2286
|
+
else if ((n[0] === 'call' || n[0] === 'return_call') && typeof n[1] === 'string') ownCallees.add(n[1])
|
|
2287
|
+
else if (n[0] === 'call_indirect' || n[0] === 'call_ref' || n[0] === 'return_call_indirect') hasIndirect = true
|
|
2288
|
+
for (let i = 1; i < n.length; i++) scan(n[i])
|
|
2289
|
+
}
|
|
2290
|
+
for (let i = 1; i < loopNode.length; i++) scan(loopNode[i])
|
|
2291
|
+
|
|
2292
|
+
const preheader = []
|
|
2293
|
+
if (sites.size && !hasIndirect) {
|
|
2294
|
+
const calleeWrites = (g) => {
|
|
2295
|
+
for (const c of ownCallees) if (reachableWrites?.get(c)?.has(g)) return true
|
|
2296
|
+
return false
|
|
2297
|
+
}
|
|
2298
|
+
const chosen = new Map()
|
|
2299
|
+
for (const g of sites.keys()) {
|
|
2300
|
+
if (!stablePtrGlobals.has(g) || ownWrites.has(g) || calleeWrites(g)) continue
|
|
2301
|
+
chosen.set(g, freshId())
|
|
2302
|
+
}
|
|
2303
|
+
if (chosen.size) {
|
|
2304
|
+
const replace = (n) => {
|
|
2305
|
+
if (!Array.isArray(n)) return
|
|
2306
|
+
for (let i = 1; i < n.length; i++) {
|
|
2307
|
+
const g = siteGlobal(n[i], alias)
|
|
2308
|
+
if (g != null && chosen.has(g)) n[i] = ['local.get', chosen.get(g)]
|
|
2309
|
+
else replace(n[i])
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
for (let i = 1; i < loopNode.length; i++) replace(loopNode[i])
|
|
2313
|
+
for (const [g, name] of chosen) {
|
|
2314
|
+
newDecls.push(['local', name, 'i32'])
|
|
2315
|
+
const snap = ptrOffsetForm.has(g)
|
|
2316
|
+
? ['call', '$__ptr_offset', ['i64.reinterpret_f64', ['global.get', g]]]
|
|
2317
|
+
: ['i32.wrap_i64', ['i64.and', ['i64.reinterpret_f64', ['global.get', g]], ['i64.const', LAYOUT.OFFSET_MASK]]]
|
|
2318
|
+
preheader.push(['local.set', name, snap])
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
return preheader
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
// Walk every statement-bearing container; splice a loop's preheader into
|
|
2326
|
+
// its own parent array right before it, then recurse into the loop body
|
|
2327
|
+
// (nested loops get their own independent, narrower attempt).
|
|
2328
|
+
const walk = (node) => {
|
|
2329
|
+
if (!Array.isArray(node)) return
|
|
2330
|
+
for (let i = 1; i < node.length; i++) {
|
|
2331
|
+
const c = node[i]
|
|
2332
|
+
if (Array.isArray(c) && c[0] === 'loop') {
|
|
2333
|
+
const pre = processLoop(c)
|
|
2334
|
+
if (pre.length) { node.splice(i, 0, ...pre); i += pre.length }
|
|
2335
|
+
walk(c)
|
|
2336
|
+
} else {
|
|
2337
|
+
walk(c)
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
walk(fn)
|
|
2342
|
+
if (newDecls.length) fn.splice(bodyStart, 0, ...newDecls)
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2506
2345
|
/**
|
|
2507
2346
|
* Promote read-only globals to locals within each function.
|
|
2508
2347
|
*
|
|
@@ -2756,7 +2595,7 @@ export function specializeMkptr(funcs, addFunc, parseWat) {
|
|
|
2756
2595
|
// 4 is the measured break-even: a specialized helper (trampoline / inline i64.const
|
|
2757
2596
|
// template) costs ~12 B to define and saves ~2–4 B per site, so 4 sites amortize it.
|
|
2758
2597
|
// Lower (3) net-inflates the watr self-host; 5 leaves 4-use combos on the table. The
|
|
2759
|
-
//
|
|
2598
|
+
// threshold (20) is already optimal — its combos cluster far
|
|
2760
2599
|
// above 20 (the ~2 k-site $__strBase relativization) with nothing in the 5–19 band.
|
|
2761
2600
|
const MIN_USES = 4
|
|
2762
2601
|
|
|
@@ -2869,174 +2708,6 @@ export function specializeMkptr(funcs, addFunc, parseWat) {
|
|
|
2869
2708
|
}
|
|
2870
2709
|
}
|
|
2871
2710
|
|
|
2872
|
-
/**
|
|
2873
|
-
* Specialize `(call $F (i32.add (global.get $G) (i32.const N)))` → `(call $F_rel_$G (i32.const N))`.
|
|
2874
|
-
* Helper bakes `(global.get $G) + i32.add` into its body so call sites drop those 3 B.
|
|
2875
|
-
* Targets any single-arg call whose arg is `add(global_base, const)` — in practice: $__mkptr_X_Y_d
|
|
2876
|
-
* specializations against $__strBase (watr self-host: ~2193 sites × 3 B ≈ 6.5 KB).
|
|
2877
|
-
*
|
|
2878
|
-
* @param funcs — flat list of func IR nodes
|
|
2879
|
-
* @param addFunc — callback `(watString) => void` to register new helpers
|
|
2880
|
-
* @param parseWat — `wat → IR` parser (injected)
|
|
2881
|
-
*/
|
|
2882
|
-
export function specializePtrBase(funcs, addFunc, parseWat) {
|
|
2883
|
-
const MIN_USES = 20
|
|
2884
|
-
|
|
2885
|
-
// Pass 1: count (targetFunc, baseGlobal) pairs AND record candidate sites for direct
|
|
2886
|
-
// rewrite in pass 3 (avoids a second full-AST walk).
|
|
2887
|
-
const counts = new Map() // 'F##G' → count
|
|
2888
|
-
const sites = [] // { parent, idx, key }
|
|
2889
|
-
const walk = (node, parent, idx) => {
|
|
2890
|
-
if (!Array.isArray(node)) return
|
|
2891
|
-
if (parent && node[0] === 'call' && typeof node[1] === 'string' && node.length === 3) {
|
|
2892
|
-
const arg = node[2]
|
|
2893
|
-
if (Array.isArray(arg) && arg[0] === 'i32.add' && arg.length === 3 &&
|
|
2894
|
-
Array.isArray(arg[1]) && arg[1][0] === 'global.get' && typeof arg[1][1] === 'string' &&
|
|
2895
|
-
Array.isArray(arg[2]) && arg[2][0] === 'i32.const') {
|
|
2896
|
-
const k = node[1] + '##' + arg[1][1]
|
|
2897
|
-
counts.set(k, (counts.get(k) || 0) + 1)
|
|
2898
|
-
sites.push({ parent, idx, key: k })
|
|
2899
|
-
}
|
|
2900
|
-
}
|
|
2901
|
-
for (let i = 0; i < node.length; i++) walk(node[i], node, i)
|
|
2902
|
-
}
|
|
2903
|
-
for (let i = 0; i < funcs.length; i++) walk(funcs[i], null, 0)
|
|
2904
|
-
|
|
2905
|
-
const specialized = new Set()
|
|
2906
|
-
for (const [k, n] of counts) if (n >= MIN_USES) specialized.add(k)
|
|
2907
|
-
if (!specialized.size) return
|
|
2908
|
-
|
|
2909
|
-
// Find a target func's result-type by locating its decl among `funcs`.
|
|
2910
|
-
const funcByName = new Map()
|
|
2911
|
-
for (let i = 0; i < funcs.length; i++) {
|
|
2912
|
-
const fn = funcs[i]
|
|
2913
|
-
if (Array.isArray(fn) && fn[0] === 'func' && typeof fn[1] === 'string') funcByName.set(fn[1], fn)
|
|
2914
|
-
}
|
|
2915
|
-
const resultOf = (name) => {
|
|
2916
|
-
const fn = funcByName.get(name)
|
|
2917
|
-
if (!fn) return 'f64' // defensive; mkptr specializations all return f64
|
|
2918
|
-
for (let i = 2; i < fn.length; i++) {
|
|
2919
|
-
const c = fn[i]
|
|
2920
|
-
if (Array.isArray(c) && c[0] === 'result') return c[1]
|
|
2921
|
-
if (Array.isArray(c) && c[0] !== 'param') break
|
|
2922
|
-
}
|
|
2923
|
-
return 'f64'
|
|
2924
|
-
}
|
|
2925
|
-
|
|
2926
|
-
const sanit = (g) => g.replace(/^\$/, '').replace(/[^a-zA-Z0-9_]/g, '_')
|
|
2927
|
-
const variantFor = (F, G) => `${F}_rel_${sanit(G)}`
|
|
2928
|
-
|
|
2929
|
-
// Pass 2: emit helpers.
|
|
2930
|
-
for (const fullKey of specialized) {
|
|
2931
|
-
const [F, G] = fullKey.split('##')
|
|
2932
|
-
const rt = resultOf(F)
|
|
2933
|
-
const name = variantFor(F, G)
|
|
2934
|
-
addFunc(`(func ${name} (param $o i32) (result ${rt}) (call ${F} (i32.add (global.get ${G}) (local.get $o))))`)
|
|
2935
|
-
}
|
|
2936
|
-
|
|
2937
|
-
// Pass 3: rewrite recorded sites in reverse (leaf-first since pass 1 was pre-order).
|
|
2938
|
-
// Idempotency guard: shared IR subtrees can record the same (parent, idx) twice.
|
|
2939
|
-
// The first visit rewrites to a 2-arg call; subsequent visits see a shape that
|
|
2940
|
-
// doesn't match the original `call F (i32.add (global.get) (i32.const))` pattern.
|
|
2941
|
-
for (let i = sites.length - 1; i >= 0; i--) {
|
|
2942
|
-
const { parent, idx, key } = sites[i]
|
|
2943
|
-
if (!specialized.has(key)) continue
|
|
2944
|
-
const c = parent[idx]
|
|
2945
|
-
if (!Array.isArray(c) || c[0] !== 'call' || c.length !== 3) continue
|
|
2946
|
-
const arg = c[2]
|
|
2947
|
-
if (!Array.isArray(arg) || arg[0] !== 'i32.add' || arg.length !== 3) continue
|
|
2948
|
-
if (!Array.isArray(arg[1]) || arg[1][0] !== 'global.get') continue
|
|
2949
|
-
if (!Array.isArray(arg[2]) || arg[2][0] !== 'i32.const') continue
|
|
2950
|
-
const F = c[1]
|
|
2951
|
-
const G = arg[1][1]
|
|
2952
|
-
const konst = arg[2]
|
|
2953
|
-
const newCall = ['call', variantFor(F, G), konst]
|
|
2954
|
-
newCall.type = resultOf(F)
|
|
2955
|
-
parent[idx] = newCall
|
|
2956
|
-
}
|
|
2957
|
-
}
|
|
2958
|
-
|
|
2959
|
-
/**
|
|
2960
|
-
* Reorder strings in `strPool` so most-referenced strings get low byte offsets.
|
|
2961
|
-
* Each string ref is encoded as `(i32.const off)` with ULEB128: 1 B for off<128, 2 B for off<16384, 3 B for off<2M.
|
|
2962
|
-
* Frequent strings migrating from 3-B to 2-B (or 2-B to 1-B) LEB128 saves ~541 B on watr self-host.
|
|
2963
|
-
*
|
|
2964
|
-
* Pool layout: `[4-byte-len][data-bytes][4-byte-len][data-bytes]...`. Offsets in refs point PAST the len prefix.
|
|
2965
|
-
*
|
|
2966
|
-
* @param funcs — flat list of func IR nodes (scanned for refs)
|
|
2967
|
-
* @param strPoolRef — `{ pool: string }` holder; pool is rewritten in place
|
|
2968
|
-
* @param strDedupMap — optional `Map<string, offset>` to update (kept consistent for later queries)
|
|
2969
|
-
*/
|
|
2970
|
-
export function sortStrPoolByFreq(funcs, strPoolRef, strDedupMap) {
|
|
2971
|
-
if (!strPoolRef.pool) return
|
|
2972
|
-
// Match both specialized and unspecialized strBase refs.
|
|
2973
|
-
const isSpecRef = (n) =>
|
|
2974
|
-
Array.isArray(n) && n[0] === 'call' && typeof n[1] === 'string' && n[1].includes('_rel___strBase') &&
|
|
2975
|
-
n.length === 3 && Array.isArray(n[2]) && n[2][0] === 'i32.const'
|
|
2976
|
-
const isUnspecRef = (n) =>
|
|
2977
|
-
Array.isArray(n) && n[0] === 'call' && typeof n[1] === 'string' && n[1].startsWith('$__mkptr_') &&
|
|
2978
|
-
n.length === 3 && Array.isArray(n[2]) && n[2][0] === 'i32.add' && n[2].length === 3 &&
|
|
2979
|
-
Array.isArray(n[2][1]) && n[2][1][0] === 'global.get' && n[2][1][1] === '$__strBase' &&
|
|
2980
|
-
Array.isArray(n[2][2]) && n[2][2][0] === 'i32.const'
|
|
2981
|
-
const getOff = (n) => isSpecRef(n) ? (n[2][1] | 0) : isUnspecRef(n) ? (n[2][2][1] | 0) : null
|
|
2982
|
-
const setOff = (n, v) => { if (isSpecRef(n)) n[2][1] = v; else if (isUnspecRef(n)) n[2][2][1] = v }
|
|
2983
|
-
|
|
2984
|
-
// Single walk: count freq AND record each ref site for direct rewrite.
|
|
2985
|
-
const freq = new Map()
|
|
2986
|
-
const sites = [] // { node, oldOff } — node is the ref node, mutate offset in place
|
|
2987
|
-
const walk = (n) => {
|
|
2988
|
-
if (!Array.isArray(n)) return
|
|
2989
|
-
const o = getOff(n)
|
|
2990
|
-
if (o !== null) { freq.set(o, (freq.get(o) || 0) + 1); sites.push({ node: n, oldOff: o }) }
|
|
2991
|
-
for (let i = 0; i < n.length; i++) walk(n[i])
|
|
2992
|
-
}
|
|
2993
|
-
for (let i = 0; i < funcs.length; i++) walk(funcs[i])
|
|
2994
|
-
if (!freq.size) return
|
|
2995
|
-
|
|
2996
|
-
// Parse pool structure into entries.
|
|
2997
|
-
const pool = strPoolRef.pool
|
|
2998
|
-
const entries = []
|
|
2999
|
-
let i = 0
|
|
3000
|
-
while (i < pool.length) {
|
|
3001
|
-
const len = pool.charCodeAt(i) | (pool.charCodeAt(i+1) << 8) | (pool.charCodeAt(i+2) << 16) | (pool.charCodeAt(i+3) << 24)
|
|
3002
|
-
const oldOff = i + 4
|
|
3003
|
-
entries.push({ oldOff, len, str: pool.substring(oldOff, oldOff + len) })
|
|
3004
|
-
i = oldOff + len
|
|
3005
|
-
}
|
|
3006
|
-
|
|
3007
|
-
// Sort by freq descending; tie-break by length ascending (pack short hot strings into low-offset range).
|
|
3008
|
-
entries.sort((a, b) => (freq.get(b.oldOff) || 0) - (freq.get(a.oldOff) || 0) || a.len - b.len)
|
|
3009
|
-
|
|
3010
|
-
// Rebuild pool; map old → new offsets. Deduplicate identical strings — keep the
|
|
3011
|
-
// first (hottest) occurrence as canonical and point duplicates to it.
|
|
3012
|
-
const remap = new Map()
|
|
3013
|
-
const canon = new Map() // str content → new offset
|
|
3014
|
-
let newPool = ''
|
|
3015
|
-
for (const e of entries) {
|
|
3016
|
-
const existing = canon.get(e.str)
|
|
3017
|
-
if (existing !== undefined) {
|
|
3018
|
-
remap.set(e.oldOff, existing)
|
|
3019
|
-
continue
|
|
3020
|
-
}
|
|
3021
|
-
newPool += String.fromCharCode(e.len & 0xFF, (e.len >> 8) & 0xFF, (e.len >> 16) & 0xFF, (e.len >> 24) & 0xFF)
|
|
3022
|
-
remap.set(e.oldOff, newPool.length)
|
|
3023
|
-
canon.set(e.str, newPool.length)
|
|
3024
|
-
newPool += e.str
|
|
3025
|
-
}
|
|
3026
|
-
strPoolRef.pool = newPool
|
|
3027
|
-
if (strDedupMap)
|
|
3028
|
-
for (const [str, oldOff] of strDedupMap) {
|
|
3029
|
-
const newOff = remap.get(oldOff)
|
|
3030
|
-
if (newOff !== undefined) strDedupMap.set(str, newOff)
|
|
3031
|
-
}
|
|
3032
|
-
|
|
3033
|
-
// Rewrite recorded ref sites directly (no second AST walk).
|
|
3034
|
-
for (let i = 0; i < sites.length; i++) {
|
|
3035
|
-
const { node, oldOff } = sites[i]
|
|
3036
|
-
const newO = remap.get(oldOff)
|
|
3037
|
-
if (newO !== undefined) setOff(node, newO)
|
|
3038
|
-
}
|
|
3039
|
-
}
|
|
3040
2711
|
|
|
3041
2712
|
/**
|
|
3042
2713
|
* Fold dead string-dispatch blocks when the tested operand is a proven-f64 local.
|
|
@@ -3064,6 +2735,38 @@ export function sortStrPoolByFreq(funcs, strPoolRef, strDedupMap) {
|
|
|
3064
2735
|
* Called in the 'post' phase of optimizeFunc, before vectorizeLaneLocal, so the
|
|
3065
2736
|
* cleaned IR is what the vectorizer pattern-matches.
|
|
3066
2737
|
*/
|
|
2738
|
+
// Build the "pure for SIMD lane-inline" map consumed by tryPerPixelColor's Phase-2
|
|
2739
|
+
// user-function inline (cfg._pureFuncMap). A user function qualifies when its body has
|
|
2740
|
+
// no side effects: no global.set, no memory store, and no call except $math.* / $__to_num
|
|
2741
|
+
// (a pure numeric coercion the lane lift strips). foldStrDispatchF64 runs first
|
|
2742
|
+
// (idempotent) so the purity check sees the folded body — dead __is_str_key dispatch on a
|
|
2743
|
+
// proven-f64 param would otherwise read as impure. Built in the emit phase (optimizeModule),
|
|
2744
|
+
// BEFORE the per-function lane vectorizer runs, so callee bodies are still clean scalar.
|
|
2745
|
+
export function buildPureFuncMap(funcs) {
|
|
2746
|
+
const pureFuncMap = new Map()
|
|
2747
|
+
const hasSideEffect = (node) => {
|
|
2748
|
+
if (!Array.isArray(node)) return false
|
|
2749
|
+
const op = node[0]
|
|
2750
|
+
if (op === 'global.set') return true
|
|
2751
|
+
if (typeof op === 'string' && (op.endsWith('.store') || op.startsWith('memory.'))) return true
|
|
2752
|
+
if (op === 'call' && typeof node[1] === 'string' && !node[1].startsWith('$math.') && node[1] !== '$__to_num') return true
|
|
2753
|
+
if (op === 'call_indirect' || op === 'call_ref') return true
|
|
2754
|
+
return node.some((c, i) => i > 0 && hasSideEffect(c))
|
|
2755
|
+
}
|
|
2756
|
+
for (const fn of funcs) {
|
|
2757
|
+
if (!Array.isArray(fn) || fn[0] !== 'func') continue
|
|
2758
|
+
const name = fn[1]
|
|
2759
|
+
if (typeof name !== 'string' || name.startsWith('$math.') || name.startsWith('$__')) continue
|
|
2760
|
+
foldStrDispatchF64(fn)
|
|
2761
|
+
const bodyStart = findBodyStart(fn)
|
|
2762
|
+
if (bodyStart < 0) continue
|
|
2763
|
+
let pure = true
|
|
2764
|
+
for (let i = bodyStart; i < fn.length; i++) if (hasSideEffect(fn[i])) { pure = false; break }
|
|
2765
|
+
if (pure) pureFuncMap.set(name, fn)
|
|
2766
|
+
}
|
|
2767
|
+
return pureFuncMap
|
|
2768
|
+
}
|
|
2769
|
+
|
|
3067
2770
|
export function foldStrDispatchF64(fn) {
|
|
3068
2771
|
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
3069
2772
|
const bodyStart = findBodyStart(fn)
|
|
@@ -3161,11 +2864,25 @@ export function foldStrDispatchF64(fn) {
|
|
|
3161
2864
|
// then: ['then', ['call','$__str_concat',...]]
|
|
3162
2865
|
if (!Array.isArray(thenB) || thenB[0] !== 'then') return node
|
|
3163
2866
|
// else: ['else', ['f64.add', ['local.get',$B], ['local.get',$C]]]
|
|
2867
|
+
// Each operand may be wrapped in emit '+' numSide's atom-coercion guard —
|
|
2868
|
+
// `(if (f64.eq $X $X) (then $X) (else <atom select ladder>))` — which is dead
|
|
2869
|
+
// once the operand is proven rawF64; unwrap to the inner local.get.
|
|
2870
|
+
const unwrapGuard = (n) => {
|
|
2871
|
+
if (!Array.isArray(n) || n[0] !== 'if' || n.length !== 5) return n
|
|
2872
|
+
const [, res, cnd, thn] = n
|
|
2873
|
+
if (!Array.isArray(res) || res[0] !== 'result' || res[1] !== 'f64') return n
|
|
2874
|
+
if (!Array.isArray(cnd) || cnd[0] !== 'f64.eq') return n
|
|
2875
|
+
if (!Array.isArray(thn) || thn[0] !== 'then' || thn.length !== 2) return n
|
|
2876
|
+
const inner = thn[1]
|
|
2877
|
+
if (!Array.isArray(inner) || inner[0] !== 'local.get') return n
|
|
2878
|
+
if (!Array.isArray(cnd[1]) || cnd[1][0] !== 'local.get' || cnd[1][1] !== inner[1]) return n
|
|
2879
|
+
return inner
|
|
2880
|
+
}
|
|
3164
2881
|
if (!Array.isArray(elseB) || elseB[0] !== 'else' || elseB.length !== 2) return node
|
|
3165
2882
|
const addExpr = elseB[1]
|
|
3166
2883
|
if (!Array.isArray(addExpr) || addExpr[0] !== 'f64.add' || addExpr.length !== 3) return node
|
|
3167
2884
|
// The two operands of f64.add must be local.get $B and local.get $C (in either order)
|
|
3168
|
-
const [lhsAdd, rhsAdd] = [addExpr[1], addExpr[2]]
|
|
2885
|
+
const [lhsAdd, rhsAdd] = [unwrapGuard(addExpr[1]), unwrapGuard(addExpr[2])]
|
|
3169
2886
|
const lhsIsB = Array.isArray(lhsAdd) && lhsAdd[0] === 'local.get' && lhsAdd[1] === B
|
|
3170
2887
|
const rhsIsC = Array.isArray(rhsAdd) && rhsAdd[0] === 'local.get' && rhsAdd[1] === C
|
|
3171
2888
|
const lhsIsC = Array.isArray(lhsAdd) && lhsAdd[0] === 'local.get' && lhsAdd[1] === C
|
|
@@ -3202,6 +2919,8 @@ export function foldStrDispatchF64(fn) {
|
|
|
3202
2919
|
*/
|
|
3203
2920
|
export function unswitchTypedParamLoop(fn) {
|
|
3204
2921
|
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
2922
|
+
// JZ_DBG_UNSWITCH=<substr>: dump matching fns entering this pass (DBG_DSR-style).
|
|
2923
|
+
if (DBG_UNSWITCH && String(fn[1]).includes(DBG_UNSWITCH)) console.error(JSON.stringify(fn))
|
|
3205
2924
|
const bodyStart = findBodyStart(fn)
|
|
3206
2925
|
if (bodyStart < 0) return
|
|
3207
2926
|
const f64Params = new Set()
|
|
@@ -3253,7 +2972,25 @@ export function unswitchTypedParamLoop(fn) {
|
|
|
3253
2972
|
if (!Array.isArray(incNode) || incNode[0] !== 'local.set' || !Array.isArray(incNode[2]) || incNode[2][0] !== 'i32.add') return
|
|
3254
2973
|
const incVar = incNode[1], inc = incNode[2]
|
|
3255
2974
|
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
|
-
|
|
2975
|
+
// Pre-watr, jz wraps every multi-statement expression-group in `(block (result T) …)`
|
|
2976
|
+
// — as a dropped expression-statement (drop follows) or as an if-arm's tail value.
|
|
2977
|
+
// watr's own vacuum/mergeBlocks used to flatten this post-hoc (when this pass ran
|
|
2978
|
+
// post-watr); now it runs pre-watr and must see through the wrapper itself. Unlabeled
|
|
2979
|
+
// ⇒ not a branch target (the normalizeTransparentBlocks convention, generalized here to
|
|
2980
|
+
// result-carrying blocks since these are unambiguous STATEMENT-LIST positions — never
|
|
2981
|
+
// operand slots), so a flattened VIEW is exactly equivalent. Non-mutating: the matched
|
|
2982
|
+
// `if` node is shared with the verbatim blockNode preserved as the bit-exact else-
|
|
2983
|
+
// fallback, so the scan must not restructure it in place.
|
|
2984
|
+
const flattenStmts = (arr, from) => {
|
|
2985
|
+
const out = []
|
|
2986
|
+
for (let i = from; i < arr.length; i++) {
|
|
2987
|
+
const c = arr[i]
|
|
2988
|
+
if (Array.isArray(c) && c[0] === 'block' && Array.isArray(c[1]) && c[1][0] === 'result') out.push(...flattenStmts(c, 2))
|
|
2989
|
+
else out.push(c)
|
|
2990
|
+
}
|
|
2991
|
+
return out
|
|
2992
|
+
}
|
|
2993
|
+
const body = flattenStmts(loopNode, 3).slice(0, -2) // drop the trailing incNode/br (already validated above)
|
|
3257
2994
|
if (body.length < 4) return
|
|
3258
2995
|
|
|
3259
2996
|
// Find the polymorphic-store `if` by scanning (it's followed by a `drop` of its
|
|
@@ -3265,8 +3002,12 @@ export function unswitchTypedParamLoop(fn) {
|
|
|
3265
3002
|
let thenArm = null, elseArm = null
|
|
3266
3003
|
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
3004
|
if (!thenArm || !elseArm) continue
|
|
3005
|
+
const thenStmts = flattenStmts(thenArm, 1)
|
|
3268
3006
|
let p = null
|
|
3269
|
-
for (
|
|
3007
|
+
for (const a of thenStmts) {
|
|
3008
|
+
if (Array.isArray(a) && a[0] === 'local.set' && f64Params.has(a[1]) && Array.isArray(a[2]) &&
|
|
3009
|
+
(a[2][0] === 'local.get' || (a[2][0] === 'call' && a[2][1] === '$__arr_set_idx_ptr'))) p = a[1]
|
|
3010
|
+
}
|
|
3270
3011
|
if (!p || !has(thenArm, (x) => x[0] === 'call' && x[1] === '$__arr_set_idx_ptr')) continue
|
|
3271
3012
|
// The bare `f64.store(__ptr_offset(o)+i<<3)` is the non-ARRAY fallback. It may be
|
|
3272
3013
|
// nested under an OBJECT/HASH → __dyn_set guard (emitPolymorphicElementStore's
|
|
@@ -3358,10 +3099,13 @@ export function unswitchTypedParamLoop(fn) {
|
|
|
3358
3099
|
* @param cfg optional resolved config from resolveOptimize() — when omitted, all on.
|
|
3359
3100
|
* @param globalTypes optional global name → wasm type map (for promoteGlobals)
|
|
3360
3101
|
* @param volatileGlobals optional set of callee-mutable globals (see collectVolatileGlobals)
|
|
3361
|
-
*
|
|
3362
|
-
*
|
|
3102
|
+
* (The former 'post' phase and its csePureExprLoop arm are deleted, and the
|
|
3103
|
+
* straight-line csePureExpr followed in the 2026-07 ablation sweeps — watr's
|
|
3104
|
+
* write-clock CSE reaches a smaller fixpoint on its own; jz's optimizer runs
|
|
3105
|
+
* exactly once, before watr. splitLoopPrivateScratch remains as the flag-gated
|
|
3106
|
+
* migration seed; see the splitScratch gate below.)
|
|
3363
3107
|
*/
|
|
3364
|
-
export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals,
|
|
3108
|
+
export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, reachableWrites) {
|
|
3365
3109
|
if (cfg && cfg.hoistPtrType === false &&
|
|
3366
3110
|
cfg.hoistInvariantPtrOffset === false &&
|
|
3367
3111
|
cfg.hoistInvariantLoop === false &&
|
|
@@ -3369,18 +3113,20 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
|
|
|
3369
3113
|
cfg.fusedRewrite === false &&
|
|
3370
3114
|
cfg.hoistAddrBase === false &&
|
|
3371
3115
|
cfg.cseScalarLoad === false &&
|
|
3372
|
-
cfg.csePureExpr === false &&
|
|
3373
|
-
cfg.dropDeadZeroInit === false &&
|
|
3374
|
-
cfg.deadStoreElim === false &&
|
|
3375
3116
|
cfg.propagateSingleUse === false &&
|
|
3376
3117
|
cfg.promoteGlobals === false &&
|
|
3377
3118
|
cfg.sortLocalsByUse === false &&
|
|
3378
3119
|
cfg.vectorizeLaneLocal === false) return
|
|
3120
|
+
// Static-const-array base/len fold runs FIRST: it matches the exact emit shape
|
|
3121
|
+
// via node tags (.saArr/.saBits), and any later pass that rebuilds a subtree
|
|
3122
|
+
// (CSE, fused rewrite, LICM temp-splitting) strips array properties — the tag
|
|
3123
|
+
// only survives untouched nodes.
|
|
3124
|
+
if (!cfg || cfg.foldStaticArrReads !== false) foldStaticConstArrayReads(fn)
|
|
3379
3125
|
// Recursion-unrolling runs first in 'pre': self-calls are still clean `call`
|
|
3380
3126
|
// nodes (watr's inliner hasn't reshaped them) and the freshly-inlined body then
|
|
3381
3127
|
// rides every pass below (LICM, fold, sort). Speed-tier only; 'pre' only (so the
|
|
3382
3128
|
// post-watr re-optimize doesn't unroll a second time).
|
|
3383
|
-
if (cfg && cfg.recursionUnroll === true
|
|
3129
|
+
if (cfg && cfg.recursionUnroll === true) recursionUnroll(fn)
|
|
3384
3130
|
if (!cfg || cfg.hoistPtrType !== false) hoistPtrType(fn)
|
|
3385
3131
|
if (!cfg || cfg.hoistInvariantPtrOffset !== false) hoistInvariantPtrOffset(fn)
|
|
3386
3132
|
// Before LICM: the snapped i32 bound is itself a hoistable hard-op subtree, so
|
|
@@ -3396,27 +3142,17 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
|
|
|
3396
3142
|
if (!cfg || cfg.hoistAddrBase !== false) hoistAddrBase(fn)
|
|
3397
3143
|
if (!cfg || cfg.hoistInvariantLoop !== false) hoistInvariantLoop(fn)
|
|
3398
3144
|
if (!cfg || cfg.cseScalarLoad !== false) cseScalarLoad(fn)
|
|
3399
|
-
if (!cfg || cfg.csePureExpr !== false) {
|
|
3400
|
-
if (cfg && (cfg.watr === true || typeof cfg.watr === 'object') && phase === 'post') csePureExprLoop(fn)
|
|
3401
|
-
else csePureExpr(fn)
|
|
3402
|
-
}
|
|
3403
|
-
if (!cfg || cfg.dropDeadZeroInit !== false) dropDeadZeroInit(fn)
|
|
3404
|
-
if (!cfg || cfg.deadStoreElim !== false) deadStoreElim(fn)
|
|
3405
3145
|
if (!cfg || cfg.promoteGlobals !== false) promoteGlobals(fn, globalTypes, volatileGlobals, reachableWrites)
|
|
3406
|
-
// Vectorizer runs PRE-watr unless full watr is enabled (`watr: true`). For full watr,
|
|
3407
|
-
// defer to post — full passes (notably `inlineOnce` + the post-inline `propagate`
|
|
3408
|
-
// sweep) reshape the IR so much that pre-watr SIMD patterns get scrambled. Light
|
|
3409
|
-
// watr (or no watr) leaves the lane locals intact for vectorize to pattern-match,
|
|
3410
|
-
// and lets a non-trivial chunk of SIMD survive the propagate+fold pipeline.
|
|
3411
3146
|
if (cfg && cfg.vectorizeLaneLocal === true) {
|
|
3412
|
-
|
|
3413
|
-
|
|
3147
|
+
// Vectorization is jz LOWERING — it always runs pre-watr (never in a post-watr
|
|
3148
|
+
// re-optimize). watr is the sole optimizer that runs after, and it preserves the
|
|
3149
|
+
// v128 the lift produces. `phase === 'post'` is now vestigial (no post caller).
|
|
3414
3150
|
// Phase 1: fold dead string-dispatch blocks on proven-f64 locals BEFORE
|
|
3415
3151
|
// the vectorizer pattern-matches — dead __is_str_key calls in $fbm-style
|
|
3416
3152
|
// functions (param f64 + op f64) block liftPPC from recognizing them as pure.
|
|
3417
|
-
if (
|
|
3418
|
-
|
|
3419
|
-
if (
|
|
3153
|
+
if (!cfg || cfg.unswitchTypedParamLoop !== false) unswitchTypedParamLoop(fn)
|
|
3154
|
+
foldStrDispatchF64(fn)
|
|
3155
|
+
if (vectorizeLaneLocal(fn, {
|
|
3420
3156
|
multiAcc: cfg.reduceUnroll === true,
|
|
3421
3157
|
relaxedFma: cfg.relaxedSimd === true,
|
|
3422
3158
|
blurMP: cfg.blurMultiPixel !== false,
|
|
@@ -3426,13 +3162,14 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
|
|
|
3426
3162
|
pureFuncMap: cfg._pureFuncMap || null,
|
|
3427
3163
|
toneMap: cfg.experimentalToneMap !== false,
|
|
3428
3164
|
slp: cfg.experimentalSlp !== false, // SLP default-on (testing single-use fix)
|
|
3429
|
-
|
|
3165
|
+
crPow: cfg.crPow === true,
|
|
3166
|
+
}) && typeof fn[1] === 'string') (cfg._vectorizedFnNames ??= new Set()).add(fn[1])
|
|
3430
3167
|
// The vectorizer emits `v128.load/store (i32.add base K)` for the unrolled
|
|
3431
3168
|
// multi-accumulator reduction (a[i],a[i+2],a[i+4]…) and stencil/strided reads.
|
|
3432
3169
|
// fusedRewrite's memarg fold already ran (above, before vectorize), so fold the
|
|
3433
3170
|
// freshly-created v128 memargs now — one fewer i32.add per accumulator per
|
|
3434
3171
|
// iteration, the hot-loop waste audit-fixpoint.mjs flagged on dot/sum.
|
|
3435
|
-
|
|
3172
|
+
foldV128Memargs(fn)
|
|
3436
3173
|
}
|
|
3437
3174
|
// Forward-substitute single-use temps — AFTER the vectorizer, never before: it pattern-matches a
|
|
3438
3175
|
// STRAIGHT-LINE `s += a[i]*2`, and folding an address/index temp out scrambles it (the typed-array
|
|
@@ -3440,24 +3177,32 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre
|
|
|
3440
3177
|
// 'pre' phase (no 'post' re-run), so vectorize already ran above; for full watr the vectorizer is
|
|
3441
3178
|
// deferred to 'post', so skip 'pre' here to stay after it. (propagateSingleUse itself skips any
|
|
3442
3179
|
// function the vectorizer already lifted to v128.)
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
//
|
|
3447
|
-
//
|
|
3448
|
-
|
|
3449
|
-
|
|
3180
|
+
// Forward-substitute single-use temps AFTER the vectorizer (which now always runs in
|
|
3181
|
+
// 'pre', above) — propagateSingleUse itself skips any function already lifted to v128.
|
|
3182
|
+
if (!cfg || cfg.propagateSingleUse !== false) propagateSingleUse(fn)
|
|
3183
|
+
// Then sink single-def RHS into first use as a tee — captures the simplify-locals slack
|
|
3184
|
+
// watr's use-count propagate leaves (set→tee fold, incl. effectful single-use forward).
|
|
3185
|
+
if (!cfg || cfg.foldSetToTee !== false) foldSetToTee(fn)
|
|
3186
|
+
// SSA-split loop-private unrolled scratch, then re-run LICM to hoist the freed per-iteration
|
|
3187
|
+
// invariants (rust/LLVM's free-after-unroll register hoist — the raytrace per-sphere `c_i`
|
|
3188
|
+
// recompute). Its input is UNROLLED/INLINED scratch, which today only exists after watr's
|
|
3189
|
+
// inlining — so pre-watr it's a no-op (raytrace stays regressed). Gated OFF (`cfg.splitScratch`,
|
|
3190
|
+
// default false) until jz gains pre-watr inlining (phase-2); the logic is the migration target.
|
|
3191
|
+
if (cfg && cfg.splitScratch === true && (!cfg || cfg.hoistInvariantLoop !== false)) {
|
|
3450
3192
|
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
3193
|
for (let k = 0; k < 4; k++) hoistInvariantLoop(fn)
|
|
3455
3194
|
}
|
|
3456
|
-
//
|
|
3457
|
-
//
|
|
3458
|
-
//
|
|
3459
|
-
//
|
|
3460
|
-
if (cfg
|
|
3195
|
+
// Const-fn-array dispatch devirt: emit tagged the call_indirect of
|
|
3196
|
+
// `constOps[idx](args)` (the decl's candidate set only fills when module init
|
|
3197
|
+
// emits, AFTER function bodies) — rewrite to a br_table of direct calls with
|
|
3198
|
+
// the original call_indirect as the always-sound default arm.
|
|
3199
|
+
if (!cfg || cfg.devirtFnArrays !== false) devirtConstFnArrayCalls(fn, cfg)
|
|
3200
|
+
if (!cfg || cfg.devirtSchemaReads !== false) devirtSchemaReads(fn)
|
|
3201
|
+
// Loop rotation — the LAST shape pass. Runs in the pre phase (the only phase now); the
|
|
3202
|
+
// vectorizer above has already formed the v128 loops it skips. Speed-tier: it duplicates the
|
|
3203
|
+
// loop condition for a fused conditional back-edge (1.35× on the lz/qoi scalar scans). watr's
|
|
3204
|
+
// loopify is disabled when vectorizing, so nothing downstream reverts the rotation.
|
|
3205
|
+
if (cfg && cfg.rotateLoops === true) rotateLoops(fn)
|
|
3461
3206
|
// Canonicalize boolean conditions (strip redundant `!= 0` / double-`eqz`) — after
|
|
3462
3207
|
// rotateLoops so its fused back-edges get cleaned too. Tied to the peephole pass.
|
|
3463
3208
|
if (!cfg || cfg.fusedRewrite !== false) simplifyBoolContexts(fn)
|
|
@@ -3715,13 +3460,23 @@ function fusedRewrite(fn, counts) {
|
|
|
3715
3460
|
// Single-textual-def locals → their defining value node, so the trunc_sat range fold (below)
|
|
3716
3461
|
// can see through the temps inlining introduces when proving an index/packed value fits i32.
|
|
3717
3462
|
// 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.
|
|
3719
|
-
//
|
|
3720
|
-
//
|
|
3463
|
+
// one def's, so its range wouldn't bound them. PARAMS are excluded outright: a param carries an
|
|
3464
|
+
// IMPLICIT entry def the textual scan can't see, and it is the one local class whose pre-write
|
|
3465
|
+
// value is externally controlled — `f = (p) => { use(1 >>> Math.abs(p)); p = 0 }` resolved p→0,
|
|
3466
|
+
// claimed range [0,0], and the collapsed bare trunc_sat saturated an incoming -Infinity to a
|
|
3467
|
+
// 31-lane shift (ToUint32(∞) is 0; the fuzzer's seed-6465 miscompile). Non-param pre-def reads
|
|
3468
|
+
// can only be the zero/undef-NaN init, and trunc_sat maps BOTH exactly as ToInt32 does, so the
|
|
3469
|
+
// textual rule stays sound for every other local. Pure read of the IR — value-preserving
|
|
3470
|
+
// rewrites during this same walk keep the captured def's RANGE intact, so a lazily-built map
|
|
3471
|
+
// stays sound. Built on first query only.
|
|
3721
3472
|
let defVal
|
|
3722
3473
|
const get = (name) => {
|
|
3723
3474
|
if (defVal === undefined) {
|
|
3724
3475
|
defVal = new Map(); const defCnt = new Map()
|
|
3476
|
+
for (let i = 2; i < fn.length; i++) {
|
|
3477
|
+
const d = fn[i]
|
|
3478
|
+
if (Array.isArray(d) && d[0] === 'param' && typeof d[1] === 'string') defCnt.set(d[1], 2)
|
|
3479
|
+
}
|
|
3725
3480
|
const scanDefs = (n) => {
|
|
3726
3481
|
if (!Array.isArray(n)) return
|
|
3727
3482
|
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]) }
|
|
@@ -4201,6 +3956,545 @@ export function treeshake(funcSections, allModuleNodes, opts) {
|
|
|
4201
3956
|
* Only the decl order changes; refs by name are unchanged and re-resolved by watr.
|
|
4202
3957
|
* Params are fixed (their slot defines the call ABI) — only `(local …)` nodes move.
|
|
4203
3958
|
*/
|
|
3959
|
+
/** `o.x` on a statically-unknown receiver — the megamorphic property read
|
|
3960
|
+
* (shapes bench: 8 record variants at one site, every field load a ~50-op
|
|
3961
|
+
* __dyn_get_any_t_h hash probe). The module's registered schema list is known
|
|
3962
|
+
* and bounded once emission completes: switch on the box's aux schemaId via
|
|
3963
|
+
* br_table into direct slot loads for every schema CARRYING the field — the
|
|
3964
|
+
* static mirror of a polymorphic inline cache. Non-OBJECT tags, alien sids and
|
|
3965
|
+
* schemas lacking the field all take the original call (default arm): a
|
|
3966
|
+
* schema slot is authoritative for its own fields (dyn writes to schema keys
|
|
3967
|
+
* mirror into the slot — buildObjectSchemaSetArm), so the direct load is
|
|
3968
|
+
* bit-identical where it fires. Emit tagged the call (.dvProp) because
|
|
3969
|
+
* schema.list is still growing while function bodies emit. */
|
|
3970
|
+
export function devirtSchemaReads(fn) {
|
|
3971
|
+
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
3972
|
+
const schemas = ctx.schema?.list
|
|
3973
|
+
if (!schemas || !schemas.length || schemas.length > 24) return
|
|
3974
|
+
if (!ctx.core.includes.has('__ptr_type')) return
|
|
3975
|
+
let uid = null
|
|
3976
|
+
const newDecls = []
|
|
3977
|
+
// Receiver-stable sid cache: a devirt read whose receiver is a bare local that
|
|
3978
|
+
// is NEVER written in this function (param or single-init const — this pass
|
|
3979
|
+
// runs before watr inlining, so `measure(o)`-style helpers still have their
|
|
3980
|
+
// own frame) has a CONSTANT schemaId for the whole body: the sid lives in the
|
|
3981
|
+
// box's aux bits and a jz OBJECT's shape never changes (dyn writes go to the
|
|
3982
|
+
// sidecar, not the aux). Compute `sid | -1(non-OBJECT)` ONCE at body start;
|
|
3983
|
+
// every read on that receiver drops its per-read __ptr_type guard + aux
|
|
3984
|
+
// extract and br_tables on the cached local (-1 wraps u32-huge → default arm).
|
|
3985
|
+
const assigned = new Set()
|
|
3986
|
+
const scanAssigns = (n) => {
|
|
3987
|
+
if (!Array.isArray(n)) return
|
|
3988
|
+
if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') assigned.add(n[1])
|
|
3989
|
+
for (let k = 1; k < n.length; k++) scanAssigns(n[k])
|
|
3990
|
+
}
|
|
3991
|
+
scanAssigns(fn)
|
|
3992
|
+
// receiver expr → { name, bits } for a bare never-written local (f64 local
|
|
3993
|
+
// wrapped in reinterpret, or an already-i64 local), else null (keep the
|
|
3994
|
+
// per-read spill+guard path)
|
|
3995
|
+
const stableRecv = (r) => {
|
|
3996
|
+
if (Array.isArray(r) && r[0] === 'i64.reinterpret_f64' &&
|
|
3997
|
+
Array.isArray(r[1]) && r[1][0] === 'local.get' && typeof r[1][1] === 'string' &&
|
|
3998
|
+
!assigned.has(r[1][1])) return { name: r[1][1], bits: r }
|
|
3999
|
+
if (Array.isArray(r) && r[0] === 'local.get' && typeof r[1] === 'string' &&
|
|
4000
|
+
!assigned.has(r[1])) return { name: r[1], bits: r }
|
|
4001
|
+
return null
|
|
4002
|
+
}
|
|
4003
|
+
const sidCache = new Map() // receiver local name → sid i32 local
|
|
4004
|
+
const sidInit = []
|
|
4005
|
+
const recvReads = new Map() // receiver local name → tagged-read count (pre-scan)
|
|
4006
|
+
const clone = (n) => Array.isArray(n) ? n.map(clone) : n
|
|
4007
|
+
// select(aux, -1, tag==OBJECT) — both operands pure, no branch
|
|
4008
|
+
const sidExprFor = (bits) => ['select',
|
|
4009
|
+
['i32.wrap_i64', ['i64.and',
|
|
4010
|
+
['i64.shr_u', clone(bits), ['i64.const', LAYOUT.AUX_SHIFT]],
|
|
4011
|
+
['i64.const', LAYOUT.AUX_MASK]]],
|
|
4012
|
+
['i32.const', -1],
|
|
4013
|
+
['i32.eq',
|
|
4014
|
+
['i32.wrap_i64', ['i64.and',
|
|
4015
|
+
['i64.shr_u', clone(bits), ['i64.const', LAYOUT.TAG_SHIFT]],
|
|
4016
|
+
['i64.const', LAYOUT.TAG_MASK]]],
|
|
4017
|
+
['i32.const', PTR.OBJECT]]]
|
|
4018
|
+
// ≥2 reads on the receiver: amortize into an entry-hoisted local. A single
|
|
4019
|
+
// read inlines the select at its site instead — an eager entry compute would
|
|
4020
|
+
// tax every call of a function whose lone read sits on a cold path (the
|
|
4021
|
+
// self-host kernel's shape; measured 0.9% compile-time regression).
|
|
4022
|
+
const sidRead = (stable) => {
|
|
4023
|
+
if ((recvReads.get(stable.name) || 0) < 2) return sidExprFor(stable.bits)
|
|
4024
|
+
let sidT = sidCache.get(stable.name)
|
|
4025
|
+
if (!sidT) {
|
|
4026
|
+
sidT = `$__dsrs${uid++}`
|
|
4027
|
+
newDecls.push(['local', sidT, 'i32'])
|
|
4028
|
+
sidInit.push(['local.set', sidT, sidExprFor(stable.bits)])
|
|
4029
|
+
sidCache.set(stable.name, sidT)
|
|
4030
|
+
}
|
|
4031
|
+
return ['local.get', sidT]
|
|
4032
|
+
}
|
|
4033
|
+
// Evaluation-order safety class shared by the rewrite and the duplicate-read
|
|
4034
|
+
// memo: arms/reuse evaluate ONLY the receiver (or nothing); the original call
|
|
4035
|
+
// also evaluates key/tag/hash operands. All must be pure for the paths to be
|
|
4036
|
+
// observationally identical (they are in practice: local reads + constants —
|
|
4037
|
+
// the emitDynGetAnyTyped shape). `call $__ptr_type` is a pure bit extract.
|
|
4038
|
+
const PURE_I64 = new Set(['i64.const', 'i64.reinterpret_f64', 'f64.reinterpret_i64',
|
|
4039
|
+
'i64.and', 'i64.or', 'i64.xor', 'i64.shr_u', 'i64.shl', 'i64.eq', 'i64.ne', 'i64.eqz',
|
|
4040
|
+
'i64.extend_i32_u', 'i64.extend_i32_s',
|
|
4041
|
+
'i32.const', 'i32.wrap_i64', 'i32.and', 'i32.or', 'i32.xor', 'i32.shr_u', 'i32.shl',
|
|
4042
|
+
'i32.add', 'i32.sub', 'i32.eq', 'i32.ne', 'i32.eqz'])
|
|
4043
|
+
const pureOp = (n) => !Array.isArray(n) ? true
|
|
4044
|
+
: n[0] === 'call' && n[1] === '$__ptr_type' ? n.slice(2).every(pureOp)
|
|
4045
|
+
: PURE_I64.has(n[0]) ? n.slice(1).every(pureOp)
|
|
4046
|
+
: isPureIR(n)
|
|
4047
|
+
const rewrite = (parent, i) => {
|
|
4048
|
+
const node = parent[i]
|
|
4049
|
+
const prop = node.dvProp
|
|
4050
|
+
const withProp = []
|
|
4051
|
+
for (let sid = 0; sid < schemas.length; sid++) {
|
|
4052
|
+
const slot = schemas[sid].indexOf(prop)
|
|
4053
|
+
if (slot >= 0) withProp.push([sid, slot])
|
|
4054
|
+
}
|
|
4055
|
+
if (!withProp.length) return
|
|
4056
|
+
// `local.tee` operands (foldSetToTee folds shared tag/CSE
|
|
4057
|
+
// locals into the FIRST read's call, possibly nested) are hoisted to
|
|
4058
|
+
// standalone sets before the dispatch, innermost first — the original call
|
|
4059
|
+
// evaluated them unconditionally, so unconditional sets are observationally
|
|
4060
|
+
// identical, later readers of those locals still see them, and the arms
|
|
4061
|
+
// (which skip the default call) stay sound.
|
|
4062
|
+
const teeHoists = []
|
|
4063
|
+
const extractTees = (n) => {
|
|
4064
|
+
if (!Array.isArray(n)) return n
|
|
4065
|
+
if (n[0] === 'local.tee' && typeof n[1] === 'string') {
|
|
4066
|
+
teeHoists.push(['local.set', n[1], extractTees(n[2])])
|
|
4067
|
+
return ['local.get', n[1]]
|
|
4068
|
+
}
|
|
4069
|
+
return n.map((c, k) => k === 0 ? c : extractTees(c))
|
|
4070
|
+
}
|
|
4071
|
+
const operands = node.slice(2).map(extractTees)
|
|
4072
|
+
for (const op of operands) if (!pureOp(op)) { if (DBG_DSR) console.error('[dsr-bail]', prop, 'impure operand:', JSON.stringify(op).slice(0, 200)); return }
|
|
4073
|
+
for (const h of teeHoists) if (!pureOp(h[2])) { if (DBG_DSR) console.error('[dsr-bail]', prop, 'impure tee:', JSON.stringify(h).slice(0, 200)); return }
|
|
4074
|
+
// the dispatch's generic arm — the original call over the tee-free operands
|
|
4075
|
+
const genericCall = [node[0], node[1], ...operands]
|
|
4076
|
+
if (uid === null) uid = nextLocalId(fn, '$__dsr')
|
|
4077
|
+
const stable = stableRecv(genericCall[2])
|
|
4078
|
+
const id = uid++
|
|
4079
|
+
const rT = stable ? null : `$__dsr${id}r`
|
|
4080
|
+
if (rT) newDecls.push(['local', rT, 'i64'])
|
|
4081
|
+
// receiver bits for arms/default: the stable local read inline (fresh clone
|
|
4082
|
+
// per use — IR nodes must not alias), or the spill
|
|
4083
|
+
const recvBits = () => stable ? clone(stable.bits) : ['local.get', rT]
|
|
4084
|
+
const out = `$__dsro${id}`, dflt = `$__dsrd${id}`
|
|
4085
|
+
const lo = withProp[0][0], hi = withProp[withProp.length - 1][0]
|
|
4086
|
+
const bySid = new Map(withProp)
|
|
4087
|
+
// Discriminant-field collapse: when EVERY compile-time schema has the prop
|
|
4088
|
+
// at the SAME slot (the canonical tag-field pattern — `.k`/`.type`/`.kind`
|
|
4089
|
+
// as first key of every variant literal), a known-schema OBJECT resolves to
|
|
4090
|
+
// that slot with no dispatch at all: `(u32)sid < count ? load : generic`.
|
|
4091
|
+
// The unsigned compare routes BOTH the -1 non-OBJECT sentinel and any
|
|
4092
|
+
// runtime-registered alien sid (__jp_obj / host-marshaled shapes mint sids
|
|
4093
|
+
// past the compile-time list) to the generic arm.
|
|
4094
|
+
if (stable && withProp.length === schemas.length &&
|
|
4095
|
+
withProp.every(([, slot]) => slot === withProp[0][1])) {
|
|
4096
|
+
const slot = withProp[0][1]
|
|
4097
|
+
const dispatch = ['if', ['result', 'i64'],
|
|
4098
|
+
['i32.lt_u', sidRead(stable), ['i32.const', schemas.length]],
|
|
4099
|
+
['then', ['i64.load',
|
|
4100
|
+
['i32.add', ['i32.wrap_i64', recvBits()], ['i32.const', slot * 8]]]],
|
|
4101
|
+
['else', genericCall]]
|
|
4102
|
+
parent[i] = teeHoists.length
|
|
4103
|
+
? ['block', out, ['result', 'i64'], ...teeHoists, dispatch]
|
|
4104
|
+
: dispatch
|
|
4105
|
+
return
|
|
4106
|
+
}
|
|
4107
|
+
const labels = Array.from({ length: hi - lo + 1 }, (_, k) => bySid.has(lo + k) ? `$__dsr${id}_${lo + k}` : dflt)
|
|
4108
|
+
// arms in sid order: each closes its block, loads its slot, brs out; the
|
|
4109
|
+
// innermost block (first arm's label) carries the br_table — selecting on
|
|
4110
|
+
// the hoisted sid cache when the receiver is stable (its -1 non-OBJECT
|
|
4111
|
+
// sentinel wraps u32-huge → default arm, so no separate tag guard), else
|
|
4112
|
+
// on a per-read aux extract behind a per-read tag guard.
|
|
4113
|
+
const armSids = withProp.map(([sid]) => sid)
|
|
4114
|
+
let inner = ['br_table', ...labels, dflt,
|
|
4115
|
+
['i32.sub',
|
|
4116
|
+
stable ? sidRead(stable)
|
|
4117
|
+
: ['i32.wrap_i64', ['i64.and',
|
|
4118
|
+
['i64.shr_u', ['local.get', rT], ['i64.const', LAYOUT.AUX_SHIFT]],
|
|
4119
|
+
['i64.const', LAYOUT.AUX_MASK]]],
|
|
4120
|
+
['i32.const', lo]]]
|
|
4121
|
+
inner = ['block', `$__dsr${id}_${armSids[0]}`,
|
|
4122
|
+
...(stable ? [] : [['br_if', dflt, ['i32.ne',
|
|
4123
|
+
['call', '$__ptr_type', ['local.get', rT]],
|
|
4124
|
+
['i32.const', PTR.OBJECT]]]]),
|
|
4125
|
+
inner]
|
|
4126
|
+
for (let k = 0; k < armSids.length; k++) {
|
|
4127
|
+
const sid = armSids[k], slot = bySid.get(sid)
|
|
4128
|
+
const arm = ['br', out, ['i64.load',
|
|
4129
|
+
['i32.add', ['i32.wrap_i64', recvBits()], ['i32.const', slot * 8]]]]
|
|
4130
|
+
const nextLabel = k + 1 < armSids.length ? `$__dsr${id}_${armSids[k + 1]}` : dflt
|
|
4131
|
+
inner = ['block', nextLabel, inner, arm]
|
|
4132
|
+
}
|
|
4133
|
+
const dfltCall = [...genericCall]
|
|
4134
|
+
if (!stable) dfltCall[2] = ['local.get', rT]
|
|
4135
|
+
parent[i] = ['block', out, ['result', 'i64'],
|
|
4136
|
+
...teeHoists,
|
|
4137
|
+
...(stable ? [] : [['local.set', rT, genericCall[2]]]),
|
|
4138
|
+
inner,
|
|
4139
|
+
dfltCall]
|
|
4140
|
+
}
|
|
4141
|
+
let seen = 0
|
|
4142
|
+
// pre-scan: count tagged reads per stable receiver (sidRead's entry-hoist
|
|
4143
|
+
// choice) AND per (receiver, prop) key — only keys read ≥2× tee their result
|
|
4144
|
+
// for the duplicate-read memo below (a lone read must not pay a local write)
|
|
4145
|
+
const keyReads = new Map()
|
|
4146
|
+
const memoKey = (c) => {
|
|
4147
|
+
const st = stableRecv(c[2])
|
|
4148
|
+
return st && c.dvProp != null ? `${st.name} ${c.dvProp}` : null
|
|
4149
|
+
}
|
|
4150
|
+
const countScan = (n) => {
|
|
4151
|
+
if (!Array.isArray(n)) return
|
|
4152
|
+
if (n[0] === 'call' && n.dvProp) {
|
|
4153
|
+
const st = stableRecv(n[2])
|
|
4154
|
+
if (st) {
|
|
4155
|
+
recvReads.set(st.name, (recvReads.get(st.name) || 0) + 1)
|
|
4156
|
+
const k = `${st.name} ${n.dvProp}`
|
|
4157
|
+
keyReads.set(k, (keyReads.get(k) || 0) + 1)
|
|
4158
|
+
}
|
|
4159
|
+
}
|
|
4160
|
+
for (let i = 1; i < n.length; i++) countScan(n[i])
|
|
4161
|
+
}
|
|
4162
|
+
countScan(fn)
|
|
4163
|
+
// Duplicate-read elimination riding the rewrite walk: a SECOND tagged read
|
|
4164
|
+
// of the SAME (stable receiver, prop) in the same straight-line region
|
|
4165
|
+
// reuses the first read's tee'd i64 — the whole sid-dispatch + slot load
|
|
4166
|
+
// drops (measure()'s `imul(o.r, imul(o.r, 3))` pays one read). Soundness:
|
|
4167
|
+
// the receiver is a never-written local and a jz OBJECT's shape never
|
|
4168
|
+
// changes, so only an intervening WRITE could change the value — any
|
|
4169
|
+
// non-readonly call, store, global.set or memory.grow clears the memo.
|
|
4170
|
+
// Conditional regions (if arms, labeled blocks a br may skip) keep entries
|
|
4171
|
+
// born inside them local: snapshot on entry, restore on exit (outer entries
|
|
4172
|
+
// stay usable inside — the first read dominates). A LOOP whose body clobbers
|
|
4173
|
+
// clears up front: an entry born before iteration 1's clobber must not serve
|
|
4174
|
+
// iteration 2. Replacing a read drops its operand evaluation — legal for
|
|
4175
|
+
// exactly the pure class rewrite() enforces; tee'd operands refuse (their
|
|
4176
|
+
// set would vanish).
|
|
4177
|
+
const READONLY_CALL = /^\$(__dyn_get|__ptr_type$|math\.)/
|
|
4178
|
+
const isClobberNode = (x) => {
|
|
4179
|
+
const op = x[0]
|
|
4180
|
+
if (op === 'call' && !x.dvProp && typeof x[1] === 'string' && !READONLY_CALL.test(x[1])) return true
|
|
4181
|
+
return typeof op === 'string' && (op.includes('.store') || op === 'global.set' || op === 'memory.grow')
|
|
4182
|
+
}
|
|
4183
|
+
const hasClobber = (x) => {
|
|
4184
|
+
if (!Array.isArray(x)) return false
|
|
4185
|
+
if (isClobberNode(x)) return true
|
|
4186
|
+
for (let i = 1; i < x.length; i++) if (hasClobber(x[i])) return true
|
|
4187
|
+
return false
|
|
4188
|
+
}
|
|
4189
|
+
const noTee = (x) => !Array.isArray(x) ? true
|
|
4190
|
+
: x[0] === 'local.tee' ? false
|
|
4191
|
+
: x.slice(1).every(noTee)
|
|
4192
|
+
const memo = new Map()
|
|
4193
|
+
let clobbers = 0
|
|
4194
|
+
const scoped = (walkBody) => {
|
|
4195
|
+
const snap = new Map(memo), pre = clobbers
|
|
4196
|
+
walkBody()
|
|
4197
|
+
memo.clear()
|
|
4198
|
+
if (clobbers === pre) for (const [k, v] of snap) memo.set(k, v)
|
|
4199
|
+
}
|
|
4200
|
+
const visitChild = (n, i) => {
|
|
4201
|
+
const c = n[i]
|
|
4202
|
+
if (!Array.isArray(c)) return
|
|
4203
|
+
walkDSR(c)
|
|
4204
|
+
if (c[0] === 'call' && c.dvProp) {
|
|
4205
|
+
seen++
|
|
4206
|
+
const key = memoKey(c)
|
|
4207
|
+
const hit = key && memo.get(key)
|
|
4208
|
+
if (hit && c.slice(2).every(o => pureOp(o) && noTee(o))) { n[i] = ['local.get', hit]; return }
|
|
4209
|
+
rewrite(n, i)
|
|
4210
|
+
if (key && (keyReads.get(key) || 0) >= 2 && Array.isArray(n[i])) {
|
|
4211
|
+
if (uid === null) uid = nextLocalId(fn, '$__dsr')
|
|
4212
|
+
const L = `$__dsrm${uid++}`
|
|
4213
|
+
newDecls.push(['local', L, 'i64'])
|
|
4214
|
+
n[i] = ['local.tee', L, n[i]]
|
|
4215
|
+
memo.set(key, L)
|
|
4216
|
+
}
|
|
4217
|
+
return
|
|
4218
|
+
}
|
|
4219
|
+
if (isClobberNode(c)) { memo.clear(); clobbers++ }
|
|
4220
|
+
}
|
|
4221
|
+
const walkDSR = (n) => {
|
|
4222
|
+
if (!Array.isArray(n)) return
|
|
4223
|
+
if (n[0] === 'if') {
|
|
4224
|
+
for (let i = 1; i < n.length; i++) {
|
|
4225
|
+
const c = n[i]
|
|
4226
|
+
if (!Array.isArray(c)) continue
|
|
4227
|
+
if (c[0] === 'then' || c[0] === 'else') scoped(() => walkDSR(c))
|
|
4228
|
+
else visitChild(n, i)
|
|
4229
|
+
}
|
|
4230
|
+
return
|
|
4231
|
+
}
|
|
4232
|
+
if (n[0] === 'loop') {
|
|
4233
|
+
if (hasClobber(n)) { memo.clear(); clobbers++ }
|
|
4234
|
+
scoped(() => { for (let i = 1; i < n.length; i++) visitChild(n, i) })
|
|
4235
|
+
return
|
|
4236
|
+
}
|
|
4237
|
+
if (n[0] === 'block' && typeof n[1] === 'string') {
|
|
4238
|
+
scoped(() => { for (let i = 1; i < n.length; i++) visitChild(n, i) })
|
|
4239
|
+
return
|
|
4240
|
+
}
|
|
4241
|
+
for (let i = 1; i < n.length; i++) visitChild(n, i)
|
|
4242
|
+
}
|
|
4243
|
+
walkDSR(fn)
|
|
4244
|
+
if (DBG_DSR && String(fn[1]).includes('measure')) console.error('[dsr]', fn[1], 'schemas:', schemas.length, 'tagged seen:', seen)
|
|
4245
|
+
if (newDecls.length) {
|
|
4246
|
+
let at = typeof fn[1] === 'string' ? 2 : 1
|
|
4247
|
+
while (at < fn.length && Array.isArray(fn[at]) &&
|
|
4248
|
+
(fn[at][0] === 'export' || fn[at][0] === 'type' || fn[at][0] === 'param' || fn[at][0] === 'result' || fn[at][0] === 'local')) at++
|
|
4249
|
+
// sid-cache computations go right after the decls, before the first body
|
|
4250
|
+
// statement — stable receivers are never-written names (params), so their
|
|
4251
|
+
// value at body start equals their value at every read
|
|
4252
|
+
fn.splice(at, 0, ...newDecls, ...sidInit)
|
|
4253
|
+
}
|
|
4254
|
+
}
|
|
4255
|
+
|
|
4256
|
+
/** Fold the base/len ceremony of `constArr[i]` element reads whose receiver is a
|
|
4257
|
+
* STATIC array literal bound to a const global (module/array.js tags `.saArr` /
|
|
4258
|
+
* `.saBits` on the read IR; the decl registers ctx.scope.staticArrs). The
|
|
4259
|
+
* data-segment offset and length are compile-time constants, so the per-read
|
|
4260
|
+
* `__ptr_offset` call + header len load collapse to literals — decisive in
|
|
4261
|
+
* loops containing calls, where a callee may write memory and watr's LICM must
|
|
4262
|
+
* keep the loads in place (the devirt'd operator-table dispatch loop is the
|
|
4263
|
+
* canonical victim). Facts gate: any indexed write, resizing method call, or
|
|
4264
|
+
* bare value use of the name anywhere in the program (ctx.types.arrResized /
|
|
4265
|
+
* nameEscapes, collectProgramFacts) keeps the generic form — an alias or a
|
|
4266
|
+
* grow could relocate the payload (header forwarding) or change len, and a
|
|
4267
|
+
* folded base would read stale memory. */
|
|
4268
|
+
export function foldStaticConstArrayReads(fn) {
|
|
4269
|
+
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
4270
|
+
const sa = ctx.scope.staticArrs
|
|
4271
|
+
if (!sa || !sa.size) return
|
|
4272
|
+
// Facts must EXIST to fold — an absent fact set means the program was never
|
|
4273
|
+
// walked for resize/escape, not that the name is safe.
|
|
4274
|
+
const resized = ctx.types.arrResized, escapes = ctx.types.nameEscapes
|
|
4275
|
+
if (!resized || !escapes) return
|
|
4276
|
+
const rewrite = (node) => {
|
|
4277
|
+
const st = sa.get(node.saArr)
|
|
4278
|
+
if (!st) return
|
|
4279
|
+
// A bits-form tag (receiver folded to a const box at emit) must match the decl's
|
|
4280
|
+
// recorded bits; a name-form tag (receiver read `global.get $name` directly) IS
|
|
4281
|
+
// the identity — global names are unique.
|
|
4282
|
+
if (node.saBits != null && st.bits !== node.saBits) return
|
|
4283
|
+
if (resized.has(node.saArr) || escapes.has(node.saArr)) return
|
|
4284
|
+
// The base derives from the GLOBAL, not a baked constant: assemble's
|
|
4285
|
+
// static-prefix-strip rebases every static pointer AFTER this pass runs, so a
|
|
4286
|
+
// baked absolute offset goes stale (caught by the module-const table tests).
|
|
4287
|
+
// `global.get` is the strip-safe anchor — the global's init is rebased in
|
|
4288
|
+
// place, jz never folds immutable global reads, and watr (which runs after
|
|
4289
|
+
// the strip) propagates the rebased init into a final constant memarg. The
|
|
4290
|
+
// win stands regardless: the `__ptr_offset` CALL (whose forwarding follow the
|
|
4291
|
+
// never-resized proof makes dead) and the len header load both drop.
|
|
4292
|
+
const baseIR = () => ['i32.wrap_i64', ['i64.reinterpret_f64', ['global.get', `$${node.saArr}`]]]
|
|
4293
|
+
const isBaseIR = (n) => Array.isArray(n) && n[0] === 'i32.wrap_i64' &&
|
|
4294
|
+
Array.isArray(n[1]) && n[1][0] === 'i64.reinterpret_f64' &&
|
|
4295
|
+
Array.isArray(n[1][1]) && n[1][1][0] === 'global.get' && n[1][1][1] === `$${node.saArr}`
|
|
4296
|
+
// 1) base tee → global-derived base: (local.tee $b (call $__ptr_offset …)) → baseIR
|
|
4297
|
+
let baseLocal = null
|
|
4298
|
+
const subBase = (n) => {
|
|
4299
|
+
if (!Array.isArray(n)) return
|
|
4300
|
+
for (let i = 1; i < n.length; i++) {
|
|
4301
|
+
const c = n[i]
|
|
4302
|
+
if (!Array.isArray(c)) continue
|
|
4303
|
+
if (c[0] === 'local.tee' && Array.isArray(c[2]) && c[2][0] === 'call' && c[2][1] === '$__ptr_offset') {
|
|
4304
|
+
baseLocal = c[1]
|
|
4305
|
+
n[i] = baseIR()
|
|
4306
|
+
continue
|
|
4307
|
+
}
|
|
4308
|
+
subBase(c)
|
|
4309
|
+
}
|
|
4310
|
+
}
|
|
4311
|
+
subBase(node)
|
|
4312
|
+
if (!baseLocal) return
|
|
4313
|
+
// 2) len header load over the folded base → literal len (position-independent);
|
|
4314
|
+
// remaining base reads → box-derived base
|
|
4315
|
+
const subLen = (n) => {
|
|
4316
|
+
if (!Array.isArray(n)) return
|
|
4317
|
+
for (let i = 1; i < n.length; i++) {
|
|
4318
|
+
const c = n[i]
|
|
4319
|
+
if (!Array.isArray(c)) continue
|
|
4320
|
+
if (c[0] === 'i32.load' && Array.isArray(c[1]) && c[1][0] === 'i32.sub' &&
|
|
4321
|
+
isBaseIR(c[1][1]) &&
|
|
4322
|
+
Array.isArray(c[1][2]) && c[1][2][0] === 'i32.const' && +c[1][2][1] === 8) {
|
|
4323
|
+
n[i] = ['i32.const', st.len]
|
|
4324
|
+
continue
|
|
4325
|
+
}
|
|
4326
|
+
if (c[0] === 'local.get' && c[1] === baseLocal) { n[i] = baseIR(); continue }
|
|
4327
|
+
subLen(c)
|
|
4328
|
+
}
|
|
4329
|
+
}
|
|
4330
|
+
subLen(node)
|
|
4331
|
+
}
|
|
4332
|
+
const walk = (n) => {
|
|
4333
|
+
if (!Array.isArray(n)) return
|
|
4334
|
+
if (n.saArr != null) rewrite(n)
|
|
4335
|
+
for (let i = 1; i < n.length; i++) walk(n[i])
|
|
4336
|
+
}
|
|
4337
|
+
walk(fn)
|
|
4338
|
+
}
|
|
4339
|
+
|
|
4340
|
+
/** `constOps[idx](args)` — data-driven dispatch through a module-const array of
|
|
4341
|
+
* capture-free arrows (operator tables, strategy maps, bytecode handlers). The
|
|
4342
|
+
* generic lowering pays call_indirect's bounds + signature checks per call and
|
|
4343
|
+
* blocks V8 from inlining the tiny bodies. Emit tagged the call_indirect
|
|
4344
|
+
* (`.dvArr` = receiver name); this pass switches on the closure box's OWN
|
|
4345
|
+
* funcIdx (aux bits) via br_table into direct uniform-ABI calls — an AOT
|
|
4346
|
+
* polymorphic inline cache. The untouched original call_indirect is the
|
|
4347
|
+
* default arm, so any runtime divergence (an element overwritten through an
|
|
4348
|
+
* alias, an out-of-range index yielding the UNDEF box) takes the generic path:
|
|
4349
|
+
* semantics are bit-identical regardless of the candidate set. */
|
|
4350
|
+
export function devirtConstFnArrayCalls(fn, cfg) {
|
|
4351
|
+
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
4352
|
+
const cfa = ctx.scope.constFnArrays
|
|
4353
|
+
if (!cfa || !cfa.size) return
|
|
4354
|
+
const armInline = !cfg || cfg.inlineDevirtArms !== false
|
|
4355
|
+
let uid = null
|
|
4356
|
+
const newDecls = []
|
|
4357
|
+
// ONE inline-temp counter for the whole function: two dispatch SITES can share
|
|
4358
|
+
// a `uid` (a const-folded receiver spills nothing, leaving uid untouched), so a
|
|
4359
|
+
// per-site counter would mint the same `$__dvi{uid}_0` twice — duplicate local.
|
|
4360
|
+
const inlRef = { next: 0 }
|
|
4361
|
+
const rewrite = (parent, i) => {
|
|
4362
|
+
const node = parent[i]
|
|
4363
|
+
const cands = cfa.get(node.dvArr)
|
|
4364
|
+
if (!cands) return
|
|
4365
|
+
// shape (module/function.js closure.call inline path):
|
|
4366
|
+
// [call_indirect, [type,$ftN], envExpr, [i32.const,n], ...W slots, idxExtract]
|
|
4367
|
+
if (!Array.isArray(node[1]) || node[1][0] !== 'type') return
|
|
4368
|
+
const env = node[2], argc = node[3]
|
|
4369
|
+
if (!Array.isArray(argc) || argc[0] !== 'i32.const') return
|
|
4370
|
+
const idxExtract = node[node.length - 1]
|
|
4371
|
+
const slots = node.slice(4, node.length - 1)
|
|
4372
|
+
const lo = Math.min(...cands.map(c => c.idx)), hi = Math.max(...cands.map(c => c.idx))
|
|
4373
|
+
if (hi - lo > 32) return
|
|
4374
|
+
if (uid === null) uid = nextLocalId(fn, '$__dv')
|
|
4375
|
+
// Spill env + every non-constant slot once; both the arms and the default read the spills.
|
|
4376
|
+
// An arg that is itself `f64.convert_i32_s(E)` spills the i32 E and re-materializes the
|
|
4377
|
+
// convert at each use — the convert then sits SYNTACTICALLY at every consumer, so the
|
|
4378
|
+
// inlined arms' `trunc∘convert` round-trips and `ne(convert, impossible-const)` guards
|
|
4379
|
+
// fold away (watr identities). Behind an f64 spill local the value-flow is invisible.
|
|
4380
|
+
const spills = []
|
|
4381
|
+
const spill = (expr, tag) => {
|
|
4382
|
+
if (Array.isArray(expr) && (expr[0] === 'f64.const' || expr[0] === 'local.get')) return expr
|
|
4383
|
+
if (Array.isArray(expr) && expr[0] === 'f64.convert_i32_s' && Array.isArray(expr[1])) {
|
|
4384
|
+
const name = `$__dv${uid++}${tag}`
|
|
4385
|
+
newDecls.push(['local', name, 'i32'])
|
|
4386
|
+
spills.push(['local.set', name, expr[1]])
|
|
4387
|
+
return ['f64.convert_i32_s', ['local.get', name]]
|
|
4388
|
+
}
|
|
4389
|
+
const name = `$__dv${uid++}${tag}`
|
|
4390
|
+
newDecls.push(['local', name, 'f64'])
|
|
4391
|
+
spills.push(['local.set', name, expr])
|
|
4392
|
+
return ['local.get', name]
|
|
4393
|
+
}
|
|
4394
|
+
const envG = spill(env, 'e')
|
|
4395
|
+
const slotGs = slots.map((sl, k) => spill(sl, 'a' + k))
|
|
4396
|
+
const out = `$__dvo${uid}`, dflt = `$__dvd${uid}`
|
|
4397
|
+
const byOff = new Map(cands.map(c => [c.idx - lo, c]))
|
|
4398
|
+
const labels = Array.from({ length: hi - lo + 1 }, (_, k) => byOff.has(k) ? `$__dv${uid}_${k}` : dflt)
|
|
4399
|
+
// idxExtract reads the env box — after spilling, re-point its env reference:
|
|
4400
|
+
// the extraction shape is wrap(and(shr(reinterpret(ENV))...)); rebuild it on the spill.
|
|
4401
|
+
const extract = ['i32.sub',
|
|
4402
|
+
['i32.wrap_i64', ['i64.and',
|
|
4403
|
+
['i64.shr_u', ['i64.reinterpret_f64', envG], ['i64.const', LAYOUT.AUX_SHIFT]],
|
|
4404
|
+
['i64.const', LAYOUT.AUX_MASK]]],
|
|
4405
|
+
['i32.const', lo]]
|
|
4406
|
+
let inner = ['br_table', ...labels, dflt, extract]
|
|
4407
|
+
const armOffsets = [...byOff.keys()].sort((a, b) => a - b)
|
|
4408
|
+
inner = ['block', labels[armOffsets[0]], inner]
|
|
4409
|
+
// Tiny straight-line body → inline it straight into the arm: the uniform-ABI
|
|
4410
|
+
// call (env + argc + W padded f64 slots) vanishes and the arm becomes the
|
|
4411
|
+
// operator body itself — the AOT equivalent of the switch a JIT synthesizes
|
|
4412
|
+
// for a hot polymorphic table. The UNFILTERED candidate map: an arm executes
|
|
4413
|
+
// exactly when the original call did, so a straight-line body with a side
|
|
4414
|
+
// effect (closure0's cold string-concat branch inside a polymorphic `+`) is
|
|
4415
|
+
// safe to substitute verbatim — inlinePureCallExpr itself enforces the
|
|
4416
|
+
// straight-line shape and read-only params, and returns null for anything it
|
|
4417
|
+
// can't prove (the call stays). Purity mattered only for value-motion uses.
|
|
4418
|
+
const bodies = ctx.scope.dvArmFns
|
|
4419
|
+
const nodeCount = (n) => !Array.isArray(n) ? 0 : 1 + n.reduce((a, c, k) => a + (k > 0 ? nodeCount(c) : 0), 0)
|
|
4420
|
+
// i32 block-narrow: when the receiver is a facts-qualified STATIC table (the
|
|
4421
|
+
// same never-resized/never-aliased gate as foldStaticConstArrayReads — its
|
|
4422
|
+
// elements are exactly the original arrows, forever) and EVERY candidate body
|
|
4423
|
+
// exits through `f64.convert_i32_s` (a ToInt32'd result), the dispatch value
|
|
4424
|
+
// is int-valued on every path: arms br the raw i32 (their convert stripped),
|
|
4425
|
+
// call-formed arms and the generic call_indirect wrap in i32.trunc_sat_f64_s
|
|
4426
|
+
// (exact on int-valued f64), and ONE convert re-boxes the block. The
|
|
4427
|
+
// loop-carried receiver of `x = ops[i](x, k)` then has a syntactic-convert
|
|
4428
|
+
// def — watr's narrowLocals retypes it and the x-side ToInt32 guard dies the
|
|
4429
|
+
// same way the k-side did (watr intguard).
|
|
4430
|
+
const convertTopped = (fnNode) => {
|
|
4431
|
+
if (!Array.isArray(fnNode)) return false
|
|
4432
|
+
const exits = []
|
|
4433
|
+
let last = null
|
|
4434
|
+
const walkR = (n) => {
|
|
4435
|
+
if (!Array.isArray(n)) return
|
|
4436
|
+
if (n[0] === 'return') { exits.push(n.length === 2 ? n[1] : null); return }
|
|
4437
|
+
for (let k = 1; k < n.length; k++) walkR(n[k])
|
|
4438
|
+
}
|
|
4439
|
+
for (let k = 2; k < fnNode.length; k++) {
|
|
4440
|
+
const s = fnNode[k]
|
|
4441
|
+
if (!Array.isArray(s) || s[0] === 'param' || s[0] === 'result' || s[0] === 'local' || s[0] === 'export' || s[0] === 'type') continue
|
|
4442
|
+
last = s
|
|
4443
|
+
walkR(s)
|
|
4444
|
+
}
|
|
4445
|
+
if (last && last[0] !== 'return') exits.push(last)
|
|
4446
|
+
return exits.length > 0 && exits.every(e => Array.isArray(e) && e[0] === 'f64.convert_i32_s')
|
|
4447
|
+
}
|
|
4448
|
+
const sa = ctx.scope.staticArrs?.get(node.dvArr)
|
|
4449
|
+
const fns = ctx.scope.dvArmFns
|
|
4450
|
+
const narrow = !!(sa && fns && ctx.types.arrResized && ctx.types.nameEscapes &&
|
|
4451
|
+
!ctx.types.arrResized.has(node.dvArr) && !ctx.types.nameEscapes.has(node.dvArr) &&
|
|
4452
|
+
cands.every(c => convertTopped(fns.get(`$${c.name}`))))
|
|
4453
|
+
const intOf = (v) => {
|
|
4454
|
+
if (Array.isArray(v) && v[0] === 'f64.convert_i32_s') return v[1]
|
|
4455
|
+
if (Array.isArray(v) && v[0] === 'block' && Array.isArray(v[1]) && v[1][0] === 'result' && v[1][1] === 'f64') {
|
|
4456
|
+
const vl = v[v.length - 1]
|
|
4457
|
+
if (Array.isArray(vl) && vl[0] === 'f64.convert_i32_s')
|
|
4458
|
+
return ['block', ['result', 'i32'], ...v.slice(2, -1), vl[1]]
|
|
4459
|
+
}
|
|
4460
|
+
return ['i32.trunc_sat_f64_s', v]
|
|
4461
|
+
}
|
|
4462
|
+
for (let k = 0; k < armOffsets.length; k++) {
|
|
4463
|
+
const cand = byOff.get(armOffsets[k])
|
|
4464
|
+
const call = ['call', `$${cand.name}`, envG, argc, ...slotGs]
|
|
4465
|
+
let armVal = null
|
|
4466
|
+
const bodyFn = armInline ? bodies?.get(`$${cand.name}`) : null
|
|
4467
|
+
if (bodyFn && nodeCount(bodyFn) <= 96)
|
|
4468
|
+
armVal = inlinePureCallExpr(call, bodies, inlRef, newDecls, 'f64', '$__dvi')
|
|
4469
|
+
const armExpr = armVal ?? call
|
|
4470
|
+
const arm = ['br', out, narrow ? intOf(armExpr) : armExpr]
|
|
4471
|
+
const nextLabel = k + 1 < armOffsets.length ? labels[armOffsets[k + 1]] : dflt
|
|
4472
|
+
inner = ['block', nextLabel, inner, arm]
|
|
4473
|
+
}
|
|
4474
|
+
// default: the original call_indirect on the spilled operands
|
|
4475
|
+
const generic = ['call_indirect', node[1], envG, argc, ...slotGs, node[node.length - 1]]
|
|
4476
|
+
parent[i] = narrow
|
|
4477
|
+
? ['f64.convert_i32_s', ['block', out, ['result', 'i32'], ...spills, inner, ['i32.trunc_sat_f64_s', generic]]]
|
|
4478
|
+
: ['block', out, ['result', 'f64'], ...spills, inner, generic]
|
|
4479
|
+
}
|
|
4480
|
+
const walkDV = (n) => {
|
|
4481
|
+
if (!Array.isArray(n)) return
|
|
4482
|
+
for (let i = 1; i < n.length; i++) {
|
|
4483
|
+
const c = n[i]
|
|
4484
|
+
if (!Array.isArray(c)) continue
|
|
4485
|
+
if (c[0] === 'call_indirect' && c.dvArr) { walkDV(c); rewrite(n, i); continue }
|
|
4486
|
+
walkDV(c)
|
|
4487
|
+
}
|
|
4488
|
+
}
|
|
4489
|
+
walkDV(fn)
|
|
4490
|
+
if (newDecls.length) {
|
|
4491
|
+
let at = typeof fn[1] === 'string' ? 2 : 1
|
|
4492
|
+
while (at < fn.length && Array.isArray(fn[at]) &&
|
|
4493
|
+
(fn[at][0] === 'export' || fn[at][0] === 'type' || fn[at][0] === 'param' || fn[at][0] === 'result' || fn[at][0] === 'local')) at++
|
|
4494
|
+
fn.splice(at, 0, ...newDecls)
|
|
4495
|
+
}
|
|
4496
|
+
}
|
|
4497
|
+
|
|
4204
4498
|
export function sortLocalsByUse(fn, precomputedCounts) {
|
|
4205
4499
|
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
4206
4500
|
const localIdxs = []
|
|
@@ -4213,7 +4507,19 @@ export function sortLocalsByUse(fn, precomputedCounts) {
|
|
|
4213
4507
|
if (c[0] === 'local') { localIdxs.push(i); totalDecls++; continue }
|
|
4214
4508
|
break
|
|
4215
4509
|
}
|
|
4216
|
-
if (localIdxs.length < 2
|
|
4510
|
+
if (localIdxs.length < 2) return
|
|
4511
|
+
if (totalDecls <= 128) {
|
|
4512
|
+
// Every index fits 1-byte LEB, so ordering is free for the body — group
|
|
4513
|
+
// same-type runs so the binary locals vector squashes to one (n, type)
|
|
4514
|
+
// entry per type instead of a run per interleaving (wasm-opt emits two
|
|
4515
|
+
// groups here; watr's encoder merges only CONSECUTIVE same-type runs).
|
|
4516
|
+
// Stable within a type: original declaration order.
|
|
4517
|
+
const TYPE_ORDER = { i32: 0, i64: 1, f32: 2, f64: 3, v128: 4 }
|
|
4518
|
+
const keyed = localIdxs.map((i, k) => [fn[i], k])
|
|
4519
|
+
keyed.sort((a, b) => ((TYPE_ORDER[a[0][a[0].length - 1]] ?? 9) - (TYPE_ORDER[b[0][b[0].length - 1]] ?? 9)) || (a[1] - b[1]))
|
|
4520
|
+
localIdxs.forEach((i, k) => { fn[i] = keyed[k][0] })
|
|
4521
|
+
return
|
|
4522
|
+
}
|
|
4217
4523
|
let counts = precomputedCounts
|
|
4218
4524
|
if (!counts) {
|
|
4219
4525
|
counts = new Map()
|
|
@@ -4226,7 +4532,11 @@ export function sortLocalsByUse(fn, precomputedCounts) {
|
|
|
4226
4532
|
for (let i = totalDecls + 2; i < fn.length; i++) visit(fn[i])
|
|
4227
4533
|
}
|
|
4228
4534
|
const locals = localIdxs.map(i => fn[i])
|
|
4229
|
-
|
|
4535
|
+
const TYPE_ORDER = { i32: 0, i64: 1, f32: 2, f64: 3, v128: 4 }
|
|
4536
|
+
// Hot-first for 1-byte LEB coverage; equal counts tie-break by type so the
|
|
4537
|
+
// locals vector still squashes into runs where frequency permits.
|
|
4538
|
+
locals.sort((a, b) => ((counts.get(b[1]) || 0) - (counts.get(a[1]) || 0)) ||
|
|
4539
|
+
((TYPE_ORDER[a[a.length - 1]] ?? 9) - (TYPE_ORDER[b[b.length - 1]] ?? 9)))
|
|
4230
4540
|
localIdxs.forEach((i, k) => { fn[i] = locals[k] })
|
|
4231
4541
|
}
|
|
4232
4542
|
|