jz 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +315 -318
- package/bench/README.md +369 -0
- package/bench/bench.svg +102 -0
- package/cli.js +104 -30
- package/dist/interop.js +1 -0
- package/dist/jz.js +6024 -0
- package/index.d.ts +126 -0
- package/index.js +362 -84
- package/interop.js +159 -186
- package/jz.svg +5 -0
- package/jzify/arguments.js +97 -0
- package/jzify/bundler.js +382 -0
- package/jzify/classes.js +328 -0
- package/jzify/hoist-vars.js +177 -0
- package/jzify/index.js +51 -0
- package/jzify/names.js +37 -0
- package/jzify/switch.js +106 -0
- package/jzify/transform.js +349 -0
- package/layout.js +184 -0
- package/module/array.js +377 -176
- package/module/collection.js +639 -145
- package/module/console.js +55 -43
- package/module/core.js +264 -153
- package/module/date.js +15 -3
- package/module/function.js +73 -6
- package/module/index.js +2 -1
- package/module/json.js +226 -61
- package/module/math.js +551 -187
- package/module/number.js +327 -60
- package/module/object.js +474 -184
- package/module/regex.js +255 -25
- package/module/schema.js +24 -6
- package/module/simd.js +117 -0
- package/module/string.js +621 -226
- package/module/symbol.js +1 -1
- package/module/timer.js +9 -14
- package/module/typedarray.js +459 -73
- package/package.json +58 -14
- package/src/abi/index.js +39 -23
- package/src/abi/string.js +38 -41
- package/src/ast.js +460 -0
- package/src/autoload.js +29 -24
- package/src/bridge.js +111 -0
- package/src/compile/analyze-scans.js +824 -0
- package/src/compile/analyze.js +1600 -0
- package/src/compile/emit-assign.js +411 -0
- package/src/compile/emit.js +3512 -0
- package/src/compile/flow-types.js +103 -0
- package/src/{compile.js → compile/index.js} +778 -128
- package/src/{infer.js → compile/infer.js} +62 -98
- package/src/compile/loop-divmod.js +155 -0
- package/src/{narrow.js → compile/narrow.js} +409 -106
- package/src/compile/peel-stencil.js +261 -0
- package/src/compile/plan/advise.js +370 -0
- package/src/compile/plan/common.js +150 -0
- package/src/compile/plan/index.js +122 -0
- package/src/compile/plan/inline.js +682 -0
- package/src/compile/plan/literals.js +1199 -0
- package/src/compile/plan/loops.js +472 -0
- package/src/compile/plan/scope.js +649 -0
- package/src/compile/program-facts.js +423 -0
- package/src/ctx.js +220 -64
- package/src/ir.js +589 -172
- package/src/kind-traits.js +132 -0
- package/src/kind.js +524 -0
- package/src/op-policy.js +57 -0
- package/src/{optimize.js → optimize/index.js} +1432 -473
- package/src/optimize/vectorize.js +4660 -0
- package/src/param-reps.js +65 -0
- package/src/parse.js +44 -0
- package/src/{prepare.js → prepare/index.js} +649 -205
- package/src/prepare/lift-iife.js +149 -0
- package/src/reps.js +116 -0
- package/src/resolve.js +12 -3
- package/src/static.js +208 -0
- package/src/type.js +651 -0
- package/src/{assemble.js → wat/assemble.js} +275 -55
- package/src/wat/optimize.js +3938 -0
- package/transform.js +21 -0
- package/wasi.js +47 -5
- package/src/analyze.js +0 -3818
- package/src/emit.js +0 -3040
- package/src/jzify.js +0 -1580
- package/src/plan.js +0 -2132
- package/src/vectorize.js +0 -1088
- /package/src/{codegen.js → wat/codegen.js} +0 -0
|
@@ -27,14 +27,21 @@
|
|
|
27
27
|
* @module optimize
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
-
import { LAYOUT, ctx } from '
|
|
31
|
-
import {
|
|
30
|
+
import { LAYOUT, ctx } from '../ctx.js'
|
|
31
|
+
import { VAL } from '../reps.js'
|
|
32
|
+
import { findBodyStart, buildRefcount, nextLocalId, verifyFn } from '../ir.js'
|
|
33
|
+
|
|
34
|
+
// Debug-mode IR structural check (JZ_DEBUG_INVARIANTS=1). Zero production cost.
|
|
35
|
+
const DBG_IR = typeof process !== 'undefined' && process.env?.JZ_DEBUG_INVARIANTS === '1'
|
|
36
|
+
import { T } from '../ast.js'
|
|
32
37
|
import { vectorizeLaneLocal } from './vectorize.js'
|
|
38
|
+
import { nanPrefixHex, atomNanHex, STR_INTERN_BIT } from '../../layout.js'
|
|
33
39
|
|
|
34
40
|
const MEMOP = /^[fi](32|64)\.(load|store)(\d+(_[su])?)?$/
|
|
35
|
-
const NAN_BITS =
|
|
36
|
-
const NULL_BITS =
|
|
37
|
-
const UNDEF_BITS =
|
|
41
|
+
const NAN_BITS = nanPrefixHex()
|
|
42
|
+
const NULL_BITS = atomNanHex(1)
|
|
43
|
+
const UNDEF_BITS = atomNanHex(2)
|
|
44
|
+
const FALSE_BITS = atomNanHex(4)
|
|
38
45
|
|
|
39
46
|
/**
|
|
40
47
|
* Optimization passes, partitioned by phase. The `level` presets pick which
|
|
@@ -52,21 +59,45 @@ const UNDEF_BITS = '0x' + (LAYOUT.NAN_PREFIX_BITS | (2n << BigInt(LAYOUT.AUX_SHI
|
|
|
52
59
|
* 3 — level 2 + larger array/hash initial caps + `hoistConstantPool` off
|
|
53
60
|
* (inline `f64.const` over mutable globals); trades size for speed.
|
|
54
61
|
*
|
|
55
|
-
* String
|
|
56
|
-
* knobs; watr is on for
|
|
57
|
-
* 'size'
|
|
58
|
-
*
|
|
59
|
-
* '
|
|
60
|
-
*
|
|
62
|
+
* String presets (the size↔speed tradeoff lives entirely in the unroll/scalar
|
|
63
|
+
* knobs; watr is on for both):
|
|
64
|
+
* 'size' — loop/const unroll + lane vectorization off, tight scalar-replacement
|
|
65
|
+
* caps. Smallest wasm.
|
|
66
|
+
* 'speed' — full nested unroll + lane vectorization (= level 3).
|
|
67
|
+
* The default (level 2) has no string name — omit `optimize` or pass `2`.
|
|
68
|
+
*
|
|
69
|
+
* # Two-layer contract (this file vs src/wat/optimize.js)
|
|
70
|
+
* Both layers walk the same S-expression IR; the boundary is KNOWLEDGE, not
|
|
71
|
+
* representation:
|
|
72
|
+
* - THIS layer owns every pass that needs jz semantics — NaN-box layout
|
|
73
|
+
* (fusedRewrite's rebox folds, hoistPtrType), emit-side proofs stamped on
|
|
74
|
+
* func nodes (cseScalarLoad's cseLoadBases whitelist), loop shapes as emit
|
|
75
|
+
* produces them (narrowLoopBound, hoistInvariantLoop, vectorizeLaneLocal),
|
|
76
|
+
* and ctx-derived module facts (hoistGlobalPtrOffset's typed-global set).
|
|
77
|
+
* - wat/optimize.js owns generic structural rewrites — const folding,
|
|
78
|
+
* copy-prop, branch/DCE/vacuum, dedupe, treeshake, and inlineOnce. One
|
|
79
|
+
* deliberate exception: guardRefine lives there despite NaN-box knowledge,
|
|
80
|
+
* because the dead tag-dispatch shapes it folds only EXIST after that
|
|
81
|
+
* layer's inlining, and its output feeds that layer's fold/branch cleanup
|
|
82
|
+
* within the same fixpoint rounds.
|
|
83
|
+
* Sequencing (driven by index.js): optimizeFunc 'pre' → watOptimize →
|
|
84
|
+
* optimizeFunc 'post'. The 'post' re-run exists because watr-layer inlining
|
|
85
|
+
* re-introduces rebox/unbox pairs at spliced boundaries that only
|
|
86
|
+
* fusedRewrite knows how to fold; csePureExprLoop similarly only pays off
|
|
87
|
+
* over the inlined shape. Passes must stay idempotent — both phases may
|
|
88
|
+
* see the same function.
|
|
61
89
|
*/
|
|
62
90
|
export const PASS_NAMES = [
|
|
63
91
|
'watr', // third-party WAT-level CSE/DCE/inlining (heaviest)
|
|
92
|
+
'devirtIndirect', // call_indirect w/ known closure consts → guarded direct calls (WAT-level, grows bytes)
|
|
64
93
|
'hoistPtrType',
|
|
65
94
|
'hoistInvariantPtrOffset',
|
|
66
|
-
'
|
|
95
|
+
'hoistInvariantLoop', // unified LICM (subsumes the former ToInt32/PtrOffsetLoop/CellLoads hoists)
|
|
96
|
+
'narrowLoopBound', // f64 loop bound → hoisted i32 (unblocks the lane-vectorizer)
|
|
97
|
+
'splitCharScan', // charCodeAt scan loops: split at min(N, s.length) → i32 char carrier (plan-level)
|
|
98
|
+
'hoistGlobalPtrOffset', // stable typed GLOBALS: __ptr_offset resolve → once per function (post-watr, module-level)
|
|
67
99
|
'fusedRewrite', // peephole + ptr-helper inline + memarg fold
|
|
68
100
|
'hoistAddrBase',
|
|
69
|
-
'hoistInvariantCellLoads',
|
|
70
101
|
'cseScalarLoad',
|
|
71
102
|
'csePureExpr',
|
|
72
103
|
'dropDeadZeroInit',
|
|
@@ -76,6 +107,7 @@ export const PASS_NAMES = [
|
|
|
76
107
|
'specializeMkptr',
|
|
77
108
|
'specializePtrBase',
|
|
78
109
|
'sortStrPoolByFreq',
|
|
110
|
+
'internStrings', // slice/substring results probe the static-literal pool: equal-content → canonical bits (bit-eq fast paths)
|
|
79
111
|
'hoistConstantPool',
|
|
80
112
|
'sourceInline',
|
|
81
113
|
'smallConstForUnroll',
|
|
@@ -111,16 +143,27 @@ const LEVEL_PRESETS = Object.freeze({
|
|
|
111
143
|
// closures). Inline `f64.const` is the minimal lowering: V8 CSEs identical
|
|
112
144
|
// constants for free. Measured −3% on jessie parse for +14% binary — exactly
|
|
113
145
|
// the size↔speed trade 'speed' exists to make.
|
|
114
|
-
3: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8 }),
|
|
115
|
-
// '
|
|
116
|
-
|
|
146
|
+
3: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8, reduceUnroll: true, relaxedSimd: true, inlineFns: true }),
|
|
147
|
+
// 'size' tightens scalar/unroll caps; 'speed' = level 3. There is no 'balanced'
|
|
148
|
+
// preset — it was a pure synonym for the default level 2 (omit `optimize` or pass 2).
|
|
117
149
|
size: Object.freeze({
|
|
118
150
|
...ALL_ON,
|
|
119
|
-
smallConstForUnroll: false, nestedSmallConstForUnroll: false, vectorizeLaneLocal: false,
|
|
151
|
+
smallConstForUnroll: false, nestedSmallConstForUnroll: false, vectorizeLaneLocal: false, splitCharScan: false,
|
|
152
|
+
devirtIndirect: false, // guards + duplicated args grow bytes — speed-only trade
|
|
153
|
+
internStrings: false, // the intern index costs ~16 B per eligible literal — speed-only trade
|
|
120
154
|
scalarTypedLoopUnroll: 4, scalarTypedNestedUnroll: 8, scalarTypedArrayLen: 8,
|
|
121
155
|
}),
|
|
122
156
|
// 'speed' === level 3: full watr (inlining on) + L3 cap/hash tuning, pool off.
|
|
123
|
-
|
|
157
|
+
// reduceUnroll: vectorize reductions with N independent accumulators (ILP/latency
|
|
158
|
+
// hiding, ~3x on dot/FIR sums) — a size↔speed trade like the pool-off above, so
|
|
159
|
+
// speed-only; level 2 / balanced / size keep the single-accumulator reduce.
|
|
160
|
+
// relaxedSimd: fold f64x2 dot-pairs to f64x2.relaxed_madd (single fused VFMADD,
|
|
161
|
+
// one rounding) — faster + more accurate, but the fused result diverges bit-for-bit
|
|
162
|
+
// from the non-fused JS/native reference (bench `fma` parity class). speed-only.
|
|
163
|
+
// (The stencil + outer-strip vectorizers are NOT level-gated here: they're bit-exact pure wins
|
|
164
|
+
// like the base lane vectorizer, so they run whenever it does — default-on at level 2+ via
|
|
165
|
+
// `cfg.experimentalStencil !== false` at the call site, not a speed-only size/precision trade.)
|
|
166
|
+
speed: Object.freeze({ ...ALL_ON, hoistConstantPool: false, arrayMinCap: 16, hashSmallInitCap: 8, reduceUnroll: true, relaxedSimd: true, inlineFns: true }),
|
|
124
167
|
})
|
|
125
168
|
|
|
126
169
|
/**
|
|
@@ -129,7 +172,7 @@ const LEVEL_PRESETS = Object.freeze({
|
|
|
129
172
|
* resolveOptimize(undefined | true) → level 2 stable defaults
|
|
130
173
|
* resolveOptimize(false | 0) → all off
|
|
131
174
|
* resolveOptimize(1 | 2 | 3) → preset for that level
|
|
132
|
-
* resolveOptimize('size' | 'speed'
|
|
175
|
+
* resolveOptimize('size' | 'speed') → named preset ('speed' = level 3)
|
|
133
176
|
* resolveOptimize({ level: 1, watr: true }) → level 1 base, with watr forced on
|
|
134
177
|
* resolveOptimize({ level: 'size', vectorizeLaneLocal: true }) → 'size' base, override
|
|
135
178
|
* resolveOptimize({ hoistAddrBase: false }) → level 2 base, hoistAddrBase off
|
|
@@ -137,10 +180,15 @@ const LEVEL_PRESETS = Object.freeze({
|
|
|
137
180
|
export function resolveOptimize(opt) {
|
|
138
181
|
if (opt === false || opt === 0) return { ...ALL_OFF }
|
|
139
182
|
if (opt === true || opt == null) return { ...LEVEL_PRESETS[2] }
|
|
140
|
-
|
|
183
|
+
// String() the level key: LEVEL_PRESETS has integer-literal keys (0..3), and the
|
|
184
|
+
// self-host kernel's computed member access `obj[numVar]` misreads a numeric VARIABLE
|
|
185
|
+
// index against an object (returns undefined — literal `obj[2]` is fine), so a bare
|
|
186
|
+
// `LEVEL_PRESETS[opt]`/`[baseLevel]` would drop the level-2 default to ALL_ON and
|
|
187
|
+
// (worse, via the partial result) leave `watr` unset — disabling watOptimize.
|
|
188
|
+
if (typeof opt === 'number' || typeof opt === 'string') return { ...(LEVEL_PRESETS[String(opt)] || LEVEL_PRESETS[2]) }
|
|
141
189
|
if (typeof opt === 'object') {
|
|
142
190
|
const baseLevel = typeof opt.level === 'number' || typeof opt.level === 'string' ? opt.level : 2
|
|
143
|
-
const base = LEVEL_PRESETS[baseLevel] || ALL_ON
|
|
191
|
+
const base = LEVEL_PRESETS[String(baseLevel)] || ALL_ON
|
|
144
192
|
const out = { ...base }
|
|
145
193
|
for (const n of PASS_NAMES) {
|
|
146
194
|
if (!(n in opt)) continue
|
|
@@ -148,6 +196,7 @@ export function resolveOptimize(opt) {
|
|
|
148
196
|
// Preserve sentinel value `nestedSmallConstForUnroll: 'auto'`
|
|
149
197
|
// (resolved by a heuristic at emit time).
|
|
150
198
|
if (n === 'nestedSmallConstForUnroll' && v === 'auto') out[n] = 'auto'
|
|
199
|
+
else if (n === 'watr' && typeof v === 'object') out[n] = v
|
|
151
200
|
else out[n] = !!v
|
|
152
201
|
}
|
|
153
202
|
// Preserve non-pass tuning keys (e.g. plan.js thresholds)
|
|
@@ -182,213 +231,76 @@ export function resolveOptimize(opt) {
|
|
|
182
231
|
* result is unsafe across realloc, so it isn't hoisted here.)
|
|
183
232
|
*/
|
|
184
233
|
export function hoistPtrType(fn) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
// Per X: array of regions; each region is array of {parent, idx, role: 'tee'|'get'}.
|
|
190
|
-
const regions = new Map()
|
|
191
|
-
// Currently-open region per X (X → region array). Presence ⇔ alive.
|
|
192
|
-
const open = new Map()
|
|
193
|
-
|
|
194
|
-
const ensureRegions = (x) => {
|
|
195
|
-
let arr = regions.get(x)
|
|
196
|
-
if (!arr) { arr = []; regions.set(x, arr) }
|
|
197
|
-
return arr
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
const walk = (node, parent, pi) => {
|
|
201
|
-
if (!Array.isArray(node)) return
|
|
202
|
-
const op = node[0]
|
|
203
|
-
|
|
204
|
-
if (op === 'call' && node[1] === '$__ptr_type' && node.length === 3) {
|
|
234
|
+
return regionTrackCSE(fn, {
|
|
235
|
+
matchSite(node) {
|
|
236
|
+
// (call $__ptr_type (i64.reinterpret_f64 (local.get X))) — key is X, dep is X.
|
|
237
|
+
if (node[0] !== 'call' || node[1] !== '$__ptr_type' || node.length !== 3) return null
|
|
205
238
|
const arg = node[2]
|
|
206
|
-
// Post-i64 migration: arg is (i64.reinterpret_f64 (local.get X)). Peel both wrappers.
|
|
207
239
|
const inner = (Array.isArray(arg) && arg[0] === 'i64.reinterpret_f64' && arg.length === 2) ? arg[1] : arg
|
|
208
|
-
if (Array.isArray(inner)
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
region.push({ parent, idx: pi, role: 'tee' })
|
|
216
|
-
} else {
|
|
217
|
-
region.push({ parent, idx: pi, role: 'get' })
|
|
218
|
-
}
|
|
219
|
-
return // don't recurse — local.get inside is a read, not interesting
|
|
220
|
-
}
|
|
221
|
-
// Non-trivial arg: walk children normally
|
|
222
|
-
for (let i = 2; i < node.length; i++) walk(node[i], node, i)
|
|
223
|
-
return
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
if ((op === 'local.set' || op === 'local.tee') && typeof node[1] === 'string') {
|
|
227
|
-
const x = node[1]
|
|
228
|
-
// Walk value first — it may contain __ptr_type X, which sees pre-write X.
|
|
229
|
-
for (let i = 2; i < node.length; i++) walk(node[i], node, i)
|
|
230
|
-
// Then close any open region for X.
|
|
231
|
-
open.delete(x)
|
|
232
|
-
return
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
if (op === 'if') {
|
|
236
|
-
// Skip optional `(result T)` siblings to find cond / then / else.
|
|
237
|
-
let i = 1
|
|
238
|
-
while (i < node.length && Array.isArray(node[i]) && node[i][0] === 'result') i++
|
|
239
|
-
if (i < node.length) walk(node[i], node, i) // cond
|
|
240
|
-
i++
|
|
241
|
-
let thenArm = null, elseArm = null
|
|
242
|
-
for (; i < node.length; i++) {
|
|
243
|
-
const c = node[i]
|
|
244
|
-
if (Array.isArray(c)) {
|
|
245
|
-
if (c[0] === 'then') thenArm = c
|
|
246
|
-
else if (c[0] === 'else') elseArm = c
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
const beforeArms = new Map(open)
|
|
250
|
-
let afterThen = beforeArms
|
|
251
|
-
if (thenArm) {
|
|
252
|
-
for (let j = 1; j < thenArm.length; j++) walk(thenArm[j], thenArm, j)
|
|
253
|
-
afterThen = new Map(open)
|
|
254
|
-
}
|
|
255
|
-
open.clear()
|
|
256
|
-
for (const [k, v] of beforeArms) open.set(k, v)
|
|
257
|
-
let afterElse = beforeArms
|
|
258
|
-
if (elseArm) {
|
|
259
|
-
for (let j = 1; j < elseArm.length; j++) walk(elseArm[j], elseArm, j)
|
|
260
|
-
afterElse = new Map(open)
|
|
261
|
-
}
|
|
262
|
-
// Merge: alive after if iff alive on BOTH paths with same region ref
|
|
263
|
-
// (so the same tee was reachable regardless of which arm executed).
|
|
264
|
-
open.clear()
|
|
265
|
-
for (const [k, vT] of afterThen) {
|
|
266
|
-
if (afterElse.get(k) === vT) open.set(k, vT)
|
|
267
|
-
}
|
|
268
|
-
return
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
if (op === 'loop') {
|
|
272
|
-
// Conservative: any tee installed in iter N may not have run in iter N+1
|
|
273
|
-
// before reaching the same site (back-edge to loop header). Clear before+after.
|
|
274
|
-
open.clear()
|
|
275
|
-
for (let i = 1; i < node.length; i++) walk(node[i], node, i)
|
|
276
|
-
open.clear()
|
|
277
|
-
return
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// block / func-body / generic: walk children sequentially.
|
|
281
|
-
for (let i = 0; i < node.length; i++) walk(node[i], node, i)
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
for (let i = bodyStart; i < fn.length; i++) walk(fn[i], fn, i)
|
|
285
|
-
|
|
286
|
-
if (regions.size === 0) return
|
|
287
|
-
|
|
288
|
-
// Commit: for each X with ≥1 usable region, allocate one shared local and rewrite.
|
|
289
|
-
// Per-region threshold ≥2 (a singleton would be pure cost).
|
|
290
|
-
let hoistId = 0
|
|
291
|
-
const locals = []
|
|
292
|
-
for (const [, regs] of regions) {
|
|
293
|
-
let usable = false
|
|
294
|
-
for (const r of regs) if (r.length >= 2) { usable = true; break }
|
|
295
|
-
if (!usable) continue
|
|
296
|
-
const tLocal = `$__pt${hoistId++}`
|
|
297
|
-
locals.push(['local', tLocal, 'i32'])
|
|
298
|
-
for (const r of regs) {
|
|
299
|
-
if (r.length < 2) continue
|
|
300
|
-
for (let i = 0; i < r.length; i++) {
|
|
301
|
-
const { parent, idx, role } = r[i]
|
|
302
|
-
if (role === 'tee') parent[idx] = ['local.tee', tLocal, parent[idx]]
|
|
303
|
-
else parent[idx] = ['local.get', tLocal]
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
if (locals.length) fn.splice(bodyStart, 0, ...locals)
|
|
240
|
+
if (!Array.isArray(inner) || inner[0] !== 'local.get' || typeof inner[1] !== 'string') return null
|
|
241
|
+
const x = inner[1]
|
|
242
|
+
return { key: x, deps: [x] }
|
|
243
|
+
},
|
|
244
|
+
localPrefix: 'pt',
|
|
245
|
+
localType: 'i32',
|
|
246
|
+
})
|
|
308
247
|
}
|
|
309
248
|
|
|
310
|
-
/**
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
* the share explicitly avoids relying on tier-up and helps wasm2c / wasm-opt too.
|
|
249
|
+
/** Region-tracking CSE skeleton shared by hoistPtrType and hoistAddrBase.
|
|
250
|
+
* Walks `fn`, accumulating "regions" — sequences of structurally-identical
|
|
251
|
+
* sites along straight-line control flow where the site's value is invariant
|
|
252
|
+
* (no writes to its dependent locals between sites). Per region with ≥2 sites,
|
|
253
|
+
* allocates one `$__<prefix><id>` local and rewrites the first site to
|
|
254
|
+
* `local.tee` and the rest to `local.get`.
|
|
317
255
|
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
256
|
+
* Control-flow semantics:
|
|
257
|
+
* - `local.set/tee X` closes every region whose dep set includes X.
|
|
258
|
+
* - `if`/`else` arms walk independently from the if-entry open set; after
|
|
259
|
+
* the if, a region is open iff it was open on BOTH arms (same region ref).
|
|
260
|
+
* - `loop` clears open before AND after — back edges may skip the original tee.
|
|
261
|
+
* - `block` / func body — sequential walk.
|
|
320
262
|
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*/
|
|
324
|
-
|
|
263
|
+
* `matchSite(node, parent, pi)` returns `{ key, deps }` for a CSE-able site
|
|
264
|
+
* (key is a stable string; deps lists locals whose writes invalidate this key)
|
|
265
|
+
* or null. Match-arm sites don't recurse into children. */
|
|
266
|
+
function regionTrackCSE(fn, { matchSite, localPrefix, localType }) {
|
|
325
267
|
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
326
268
|
const bodyStart = findBodyStart(fn)
|
|
327
269
|
if (bodyStart < 0) return
|
|
328
270
|
|
|
329
|
-
// Per key
|
|
271
|
+
// Per key: array of regions; each region is array of {parent, idx, role: 'tee'|'get'}.
|
|
330
272
|
const regions = new Map()
|
|
331
|
-
//
|
|
332
|
-
// depending on it (so `local.set X` can close any region whose key references X).
|
|
273
|
+
// Currently-open region per key. Presence ⇔ alive.
|
|
333
274
|
const open = new Map()
|
|
275
|
+
// local-name → keys depending on it (so `local.set X` closes all dependent keys).
|
|
334
276
|
const localToKeys = new Map()
|
|
335
277
|
|
|
336
|
-
const
|
|
337
|
-
let arr = regions.get(k)
|
|
338
|
-
if (!arr) { arr = []; regions.set(k, arr) }
|
|
339
|
-
return arr
|
|
340
|
-
}
|
|
341
|
-
const addLocalDep = (name, key) => {
|
|
278
|
+
const addDep = (name, key) => {
|
|
342
279
|
let s = localToKeys.get(name)
|
|
343
280
|
if (!s) { s = new Set(); localToKeys.set(name, s) }
|
|
344
281
|
s.add(key)
|
|
345
282
|
}
|
|
346
|
-
const closeKey = (key) => {
|
|
347
|
-
const r = open.get(key)
|
|
348
|
-
if (!r) return
|
|
349
|
-
open.delete(key)
|
|
350
|
-
// Don't bother removing from localToKeys; stale entries are filtered on close.
|
|
351
|
-
}
|
|
352
283
|
const closeForLocal = (name) => {
|
|
353
284
|
const s = localToKeys.get(name)
|
|
354
285
|
if (!s) return
|
|
355
|
-
for (const k of s)
|
|
286
|
+
for (const k of s) open.delete(k)
|
|
356
287
|
localToKeys.delete(name)
|
|
357
288
|
}
|
|
358
289
|
|
|
359
|
-
// Returns { A, B, K } if node matches the pattern, else null.
|
|
360
|
-
const matchPattern = (node) => {
|
|
361
|
-
if (!Array.isArray(node) || node[0] !== 'i32.add' || node.length !== 3) return null
|
|
362
|
-
const a = node[1], b = node[2]
|
|
363
|
-
// Two orderings: (add (get A) (shl (get B) (const K))) or (add (shl …) (get A))
|
|
364
|
-
let baseGet, shlNode
|
|
365
|
-
if (Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' &&
|
|
366
|
-
Array.isArray(b) && b[0] === 'i32.shl' && b.length === 3) {
|
|
367
|
-
baseGet = a; shlNode = b
|
|
368
|
-
} else if (Array.isArray(b) && b[0] === 'local.get' && typeof b[1] === 'string' &&
|
|
369
|
-
Array.isArray(a) && a[0] === 'i32.shl' && a.length === 3) {
|
|
370
|
-
baseGet = b; shlNode = a
|
|
371
|
-
} else return null
|
|
372
|
-
const idx = shlNode[1], shamt = shlNode[2]
|
|
373
|
-
if (!Array.isArray(idx) || idx[0] !== 'local.get' || typeof idx[1] !== 'string') return null
|
|
374
|
-
if (!Array.isArray(shamt) || shamt[0] !== 'i32.const' || typeof shamt[1] !== 'number') return null
|
|
375
|
-
return { A: baseGet[1], B: idx[1], K: shamt[1] }
|
|
376
|
-
}
|
|
377
|
-
|
|
378
290
|
const walk = (node, parent, pi) => {
|
|
379
291
|
if (!Array.isArray(node)) return
|
|
380
292
|
const op = node[0]
|
|
381
293
|
|
|
382
|
-
const m =
|
|
294
|
+
const m = matchSite(node, parent, pi)
|
|
383
295
|
if (m) {
|
|
384
|
-
|
|
385
|
-
let region = open.get(key)
|
|
296
|
+
let region = open.get(m.key)
|
|
386
297
|
if (!region) {
|
|
387
298
|
region = []
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
299
|
+
let regs = regions.get(m.key)
|
|
300
|
+
if (!regs) { regs = []; regions.set(m.key, regs) }
|
|
301
|
+
regs.push(region)
|
|
302
|
+
open.set(m.key, region)
|
|
303
|
+
for (const d of m.deps) addDep(d, m.key)
|
|
392
304
|
region.push({ parent, idx: pi, role: 'tee' })
|
|
393
305
|
} else {
|
|
394
306
|
region.push({ parent, idx: pi, role: 'get' })
|
|
@@ -398,7 +310,7 @@ export function hoistAddrBase(fn) {
|
|
|
398
310
|
|
|
399
311
|
if ((op === 'local.set' || op === 'local.tee') && typeof node[1] === 'string') {
|
|
400
312
|
const x = node[1]
|
|
401
|
-
// Walk value first — it may
|
|
313
|
+
// Walk value first — it may contain a site referencing pre-write X.
|
|
402
314
|
for (let i = 2; i < node.length; i++) walk(node[i], node, i)
|
|
403
315
|
closeForLocal(x)
|
|
404
316
|
return
|
|
@@ -430,6 +342,7 @@ export function hoistAddrBase(fn) {
|
|
|
430
342
|
for (let j = 1; j < elseArm.length; j++) walk(elseArm[j], elseArm, j)
|
|
431
343
|
afterElse = new Map(open)
|
|
432
344
|
}
|
|
345
|
+
// Merge: alive after if iff alive on BOTH paths with same region ref.
|
|
433
346
|
open.clear()
|
|
434
347
|
for (const [k, vT] of afterThen) {
|
|
435
348
|
if (afterElse.get(k) === vT) open.set(k, vT)
|
|
@@ -451,16 +364,15 @@ export function hoistAddrBase(fn) {
|
|
|
451
364
|
|
|
452
365
|
if (regions.size === 0) return
|
|
453
366
|
|
|
454
|
-
|
|
367
|
+
// Commit: ≥2 sites per region to be worthwhile (a singleton is pure cost).
|
|
368
|
+
let hoistId = nextLocalId(fn, localPrefix)
|
|
455
369
|
const locals = []
|
|
456
|
-
// Find next free $__abN id by scanning existing locals.
|
|
457
|
-
while (fn.some(n => Array.isArray(n) && n[0] === 'local' && n[1] === `$__ab${hoistId}`)) hoistId++
|
|
458
370
|
for (const [, regs] of regions) {
|
|
459
371
|
let usable = false
|
|
460
372
|
for (const r of regs) if (r.length >= 2) { usable = true; break }
|
|
461
373
|
if (!usable) continue
|
|
462
|
-
const tLocal = `$
|
|
463
|
-
locals.push(['local', tLocal,
|
|
374
|
+
const tLocal = `$__${localPrefix}${hoistId++}`
|
|
375
|
+
locals.push(['local', tLocal, localType])
|
|
464
376
|
for (const r of regs) {
|
|
465
377
|
if (r.length < 2) continue
|
|
466
378
|
for (let i = 0; i < r.length; i++) {
|
|
@@ -473,6 +385,44 @@ export function hoistAddrBase(fn) {
|
|
|
473
385
|
if (locals.length) fn.splice(bodyStart, 0, ...locals)
|
|
474
386
|
}
|
|
475
387
|
|
|
388
|
+
/**
|
|
389
|
+
* CSE repeated `(i32.add (local.get $A) (i32.shl (local.get $B) (i32.const K)))`
|
|
390
|
+
* — the shape jz emits for `arr[idx + k]` typed-array reads after foldMemargOffsets
|
|
391
|
+
* absorbs the constant K into `offset=`. The remaining base expression is
|
|
392
|
+
* recomputed once per `arr[…]` read; biquad's inner cascade has 9 such reads
|
|
393
|
+
* sharing 2 base shapes per iteration. V8's CSE usually catches this, but emitting
|
|
394
|
+
* the share explicitly avoids relying on tier-up and helps wasm2c / wasm-opt too.
|
|
395
|
+
*
|
|
396
|
+
* Same region-tracking discipline as hoistPtrType: open region per key, closed
|
|
397
|
+
* by re-assignment to either A or B; loop entry/exit clears all open regions.
|
|
398
|
+
*
|
|
399
|
+
* Must run AFTER fusedRewrite — relies on shl-distribution + assoc-lift +
|
|
400
|
+
* foldMemargOffsets having normalized the base shape.
|
|
401
|
+
*/
|
|
402
|
+
export function hoistAddrBase(fn) {
|
|
403
|
+
return regionTrackCSE(fn, {
|
|
404
|
+
matchSite(node) {
|
|
405
|
+
if (node[0] !== 'i32.add' || node.length !== 3) return null
|
|
406
|
+
const a = node[1], b = node[2]
|
|
407
|
+
// Two orderings: (add (get A) (shl (get B) (const K))) or (add (shl …) (get A))
|
|
408
|
+
let baseGet, shlNode
|
|
409
|
+
if (Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' &&
|
|
410
|
+
Array.isArray(b) && b[0] === 'i32.shl' && b.length === 3) {
|
|
411
|
+
baseGet = a; shlNode = b
|
|
412
|
+
} else if (Array.isArray(b) && b[0] === 'local.get' && typeof b[1] === 'string' &&
|
|
413
|
+
Array.isArray(a) && a[0] === 'i32.shl' && a.length === 3) {
|
|
414
|
+
baseGet = b; shlNode = a
|
|
415
|
+
} else return null
|
|
416
|
+
const idx = shlNode[1], shamt = shlNode[2]
|
|
417
|
+
if (!Array.isArray(idx) || idx[0] !== 'local.get' || typeof idx[1] !== 'string') return null
|
|
418
|
+
if (!Array.isArray(shamt) || shamt[0] !== 'i32.const' || typeof shamt[1] !== 'number') return null
|
|
419
|
+
return { key: `${baseGet[1]}|${idx[1]}|${shamt[1]}`, deps: [baseGet[1], idx[1]] }
|
|
420
|
+
},
|
|
421
|
+
localPrefix: 'ab',
|
|
422
|
+
localType: 'i32',
|
|
423
|
+
})
|
|
424
|
+
}
|
|
425
|
+
|
|
476
426
|
/**
|
|
477
427
|
* Hoist `(call $__ptr_offset (local.get $X))` to a function-entry snapshot
|
|
478
428
|
* when X is an f64-NaN-boxed parameter that's never reassigned and only ever
|
|
@@ -484,7 +434,31 @@ export function hoistAddrBase(fn) {
|
|
|
484
434
|
* sound for the duration. The whitelist below is the read-only set
|
|
485
435
|
* (no mutation possible); any other callee touching X invalidates hoisting.
|
|
486
436
|
*/
|
|
487
|
-
|
|
437
|
+
// Read-only i32-returning calls: safe to hoist when operands are invariant,
|
|
438
|
+
// and their presence in a loop must not block other hoists (hasUnsafeCall).
|
|
439
|
+
// __jss_* are wasm:js-string host builtins over IMMUTABLE JS strings — pure by
|
|
440
|
+
// the same argument as the __ptr_* helpers (charCodeAt won't itself hoist —
|
|
441
|
+
// its index varies — but whitelisting it keeps hasUnsafeCall false so the
|
|
442
|
+
// loop-invariant __jss_length in the same loop condition CAN hoist).
|
|
443
|
+
const SAFE_OFFSET_CALLS = new Set(['$__ptr_offset', '$__ptr_type', '$__ptr_aux', '$__len', '$__jss_length', '$__jss_charCodeAt'])
|
|
444
|
+
|
|
445
|
+
// Calls that don't modify EXISTING heap memory: they may allocate (bump the heap
|
|
446
|
+
// pointer) or do tag dispatch, but they never write to an address a hoisted
|
|
447
|
+
// __typed_idx/__str_idx element read would revisit. Their presence must not
|
|
448
|
+
// block readonly-mem-call LICM (else any `s += unknown` — which dispatches via
|
|
449
|
+
// __is_str_key/__str_concat — would pin every invariant array element in-loop:
|
|
450
|
+
// the jagged-array `grid[i][j]` deopt).
|
|
451
|
+
const NON_MUTATING_CALLS = new Set(['$__is_str_key', '$__str_concat', '$__to_num', '$__to_str', '$__str_byteLen'])
|
|
452
|
+
|
|
453
|
+
// Read-only HEAP-MEMORY calls: like SAFE_OFFSET_CALLS but they read element
|
|
454
|
+
// storage that a direct f64.store/i32.store in the loop could alias. Safe to
|
|
455
|
+
// hoist only when the loop has no mutating call AND no direct store at all (we
|
|
456
|
+
// can't do alias analysis at WAT level). __typed_idx/__str_idx read arr[i] /
|
|
457
|
+
// s[i]; plain-array element writes go through calls (caught by hasUnsafeCall),
|
|
458
|
+
// and typed-array writes are direct stores (caught by hasDirectStore) — so the
|
|
459
|
+
// guard covers both. This is what lets LICM hoist `grid[i]` out of a read-only
|
|
460
|
+
// `for(j) { ... grid[i][j] ... }` inner loop (the jagged-array deopt).
|
|
461
|
+
const READONLY_MEM_CALLS = new Set(['$__typed_idx', '$__str_idx'])
|
|
488
462
|
|
|
489
463
|
export function hoistInvariantPtrOffset(fn) {
|
|
490
464
|
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
@@ -558,8 +532,7 @@ export function hoistInvariantPtrOffset(fn) {
|
|
|
558
532
|
|
|
559
533
|
if (sites.size === 0) return
|
|
560
534
|
|
|
561
|
-
let hoistId =
|
|
562
|
-
while (fn.some(n => Array.isArray(n) && n[0] === 'local' && n[1] === `$__po${hoistId}`)) hoistId++
|
|
535
|
+
let hoistId = nextLocalId(fn, 'po')
|
|
563
536
|
|
|
564
537
|
const newLocals = []
|
|
565
538
|
const snaps = []
|
|
@@ -577,97 +550,310 @@ export function hoistInvariantPtrOffset(fn) {
|
|
|
577
550
|
if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals, ...snaps)
|
|
578
551
|
}
|
|
579
552
|
|
|
553
|
+
|
|
554
|
+
// Non-trapping, side-effect-free ops whose result is a pure function of their
|
|
555
|
+
// operands. Hoisting one to the pre-header is sound iff its operands are loop-
|
|
556
|
+
// invariant: same value every iteration, no traps, no memory/global effects.
|
|
557
|
+
// DELIBERATELY EXCLUDES trapping ops — i32/i64 div_s/u & rem_s/u (trap on 0),
|
|
558
|
+
// non-saturating trunc_f64 (trap on overflow/NaN) — because hoisting a trap to
|
|
559
|
+
// the pre-header would fire it even when the loop runs zero times. Loads and
|
|
560
|
+
// calls are NOT here; they are admitted by `pureGiven` only under the loop's
|
|
561
|
+
// effect-summary barriers (cell loads with no aliasing store/call; the read-only
|
|
562
|
+
// __ptr_* call whitelist with no other call).
|
|
563
|
+
// Boxed-capture cells are `freshLocal`-generated, so the name carries the T
|
|
564
|
+
// (U+E000) prefix: `$<T>cell_<var>`. Built from the constant — a hand-typed
|
|
565
|
+
// `'$cell_'` literal silently omits the invisible T and never matches.
|
|
566
|
+
const CELL_PREFIX = '$' + T + 'cell_'
|
|
567
|
+
|
|
568
|
+
// Ops V8's wasm tier (TurboFan) will NOT hoist out of a loop itself: saturating
|
|
569
|
+
// f64→int truncation and `select` are not LICM-eligible there, memory loads are
|
|
570
|
+
// blocked by conservative aliasing, and calls are opaque. These are the ONLY
|
|
571
|
+
// things worth hoisting — V8 already does general arithmetic LICM, and hoisting
|
|
572
|
+
// pure arithmetic ourselves only bloats the body and breaks the lane-vectorizer's
|
|
573
|
+
// straight-line pattern match. So a subtree is hoisted only if it contains one.
|
|
574
|
+
const HARD_OPS = new Set([
|
|
575
|
+
'i64.trunc_sat_f64_s', 'i64.trunc_sat_f64_u', 'i32.trunc_sat_f64_s', 'i32.trunc_sat_f64_u',
|
|
576
|
+
'select', 'f64.load', 'i32.load', 'call',
|
|
577
|
+
])
|
|
578
|
+
const hasHardOp = (n) => Array.isArray(n) && (HARD_OPS.has(n[0]) || n.some((c, i) => i > 0 && hasHardOp(c)))
|
|
579
|
+
|
|
580
|
+
// The inline typed-array base decode `(i32.wrap_i64 (i64.and (i64.reinterpret_f64
|
|
581
|
+
// (local|global X)) 0xFFFFFFFF))` — what `typedBase` emits for a NaN-boxed pointer.
|
|
582
|
+
// V8's wasm tier does NOT reliably LICM this i64 reinterpret chain, and it carries no
|
|
583
|
+
// HARD_OP, so without this it stays per-element inside the loop. It is the typed-read
|
|
584
|
+
// equivalent of the `__ptr_offset` call (a HARD_OP) that hoistGlobalPtrOffset hoists at
|
|
585
|
+
// function scope; admitting it here also covers a pointer reassigned ELSEWHERE in the
|
|
586
|
+
// function (the ping-pong double-buffer `a = b` in wireworld / any CA), where the base
|
|
587
|
+
// is invariant within each loop but not function-wide.
|
|
588
|
+
const isPtrBaseDecode = (n) =>
|
|
589
|
+
Array.isArray(n) && n[0] === 'i32.wrap_i64' && n.length === 2 &&
|
|
590
|
+
Array.isArray(n[1]) && n[1][0] === 'i64.and' && n[1].length === 3 &&
|
|
591
|
+
Array.isArray(n[1][2]) && n[1][2][0] === 'i64.const' &&
|
|
592
|
+
(typeof n[1][2][1] === 'string' ? Number(n[1][2][1]) : n[1][2][1]) === LAYOUT.OFFSET_MASK &&
|
|
593
|
+
Array.isArray(n[1][1]) && n[1][1][0] === 'i64.reinterpret_f64' && n[1][1].length === 2 &&
|
|
594
|
+
Array.isArray(n[1][1][1]) && (n[1][1][1][0] === 'local.get' || n[1][1][1][0] === 'global.get')
|
|
595
|
+
|
|
596
|
+
const PURE_LICM_OPS = new Set([
|
|
597
|
+
'f64.add', 'f64.sub', 'f64.mul', 'f64.div', 'f64.neg', 'f64.abs', 'f64.sqrt',
|
|
598
|
+
'f64.min', 'f64.max', 'f64.ceil', 'f64.floor', 'f64.trunc', 'f64.nearest', 'f64.copysign',
|
|
599
|
+
'i32.add', 'i32.sub', 'i32.mul', 'i32.and', 'i32.or', 'i32.xor',
|
|
600
|
+
'i32.shl', 'i32.shr_s', 'i32.shr_u', 'i32.rotl', 'i32.rotr', 'i32.clz', 'i32.ctz', 'i32.popcnt', 'i32.eqz',
|
|
601
|
+
'i64.add', 'i64.sub', 'i64.mul', 'i64.and', 'i64.or', 'i64.xor',
|
|
602
|
+
'i64.shl', 'i64.shr_s', 'i64.shr_u', 'i64.rotl', 'i64.rotr', 'i64.eqz',
|
|
603
|
+
'f64.eq', 'f64.ne', 'f64.lt', 'f64.gt', 'f64.le', 'f64.ge',
|
|
604
|
+
'i32.eq', 'i32.ne', 'i32.lt_s', 'i32.lt_u', 'i32.gt_s', 'i32.gt_u', 'i32.le_s', 'i32.le_u', 'i32.ge_s', 'i32.ge_u',
|
|
605
|
+
'i64.eq', 'i64.ne', 'i64.lt_s', 'i64.lt_u', 'i64.gt_s', 'i64.gt_u', 'i64.le_s', 'i64.le_u', 'i64.ge_s', 'i64.ge_u',
|
|
606
|
+
'f64.convert_i32_s', 'f64.convert_i32_u', 'f64.convert_i64_s', 'f64.convert_i64_u',
|
|
607
|
+
'i32.trunc_sat_f64_s', 'i32.trunc_sat_f64_u', 'i64.trunc_sat_f64_s', 'i64.trunc_sat_f64_u',
|
|
608
|
+
'i32.wrap_i64', 'i64.extend_i32_s', 'i64.extend_i32_u',
|
|
609
|
+
'f64.reinterpret_i64', 'i64.reinterpret_f64', 'f32.reinterpret_i32', 'i32.reinterpret_f32',
|
|
610
|
+
'f64.promote_f32', 'f32.demote_f64', 'select',
|
|
611
|
+
])
|
|
612
|
+
|
|
580
613
|
/**
|
|
581
|
-
*
|
|
582
|
-
*
|
|
583
|
-
*
|
|
614
|
+
* Unified loop-invariant code motion. One principle replaces the three former
|
|
615
|
+
* pattern hoists (ToInt32 / __ptr_offset / cell-load): a MAXIMAL pure subtree
|
|
616
|
+
* whose every free input is loop-invariant is computed once before the loop, in
|
|
617
|
+
* a fresh snap local.
|
|
584
618
|
*
|
|
585
|
-
*
|
|
586
|
-
*
|
|
587
|
-
*
|
|
588
|
-
*
|
|
589
|
-
*
|
|
619
|
+
* Invariance/purity (`pureGiven`) is closed over PURE_LICM_OPS plus two memory-
|
|
620
|
+
* touching leaves admitted only under the loop's effect summary (`collectMutations`):
|
|
621
|
+
* - (f64.load (local.get $cell_X)) iff no f64.store to $cell_X and no call in loop
|
|
622
|
+
* - (call $__ptr_offset|__ptr_type|__ptr_aux|__len …) iff no non-whitelisted call
|
|
623
|
+
* — exactly the old per-pass barriers, generalized. A subtree may also WRITE a
|
|
624
|
+
* local via (local.tee P E) iff P is private to the subtree (occurs nowhere else
|
|
625
|
+
* in the loop); this hoists the guarded-ToInt32 form
|
|
626
|
+
* (select (i32.wrap_i64 (i64.trunc_sat_f64_s (local.tee P E))) 0 (f64.ne (get P) G))
|
|
627
|
+
* as a unit — which the old leaf-only matcher could not (it needed a bare local).
|
|
590
628
|
*
|
|
591
|
-
*
|
|
592
|
-
*
|
|
593
|
-
*
|
|
594
|
-
* where X is invariant. Cleanup of the chained `local.set` movs is handled by
|
|
595
|
-
* watr CSE/DCE.
|
|
629
|
+
* Bottom-up (inner loops first → progressive climbing), refcount-guarded against
|
|
630
|
+
* watr's shared CSE subtrees, snaps spliced before the loop, decls at bodyStart.
|
|
631
|
+
* Idempotent: re-running sees only `(local.get $__liN)` and finds nothing to do.
|
|
596
632
|
*/
|
|
597
|
-
export function
|
|
633
|
+
export function hoistInvariantLoop(fn) {
|
|
598
634
|
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
599
635
|
const bodyStart = findBodyStart(fn)
|
|
600
636
|
if (bodyStart < 0) return
|
|
601
637
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
const
|
|
638
|
+
// Cheap early-out: no loop ⇒ nothing to hoist (skip the buildRefcount walk).
|
|
639
|
+
let hasLoop = false
|
|
640
|
+
const scanLoop = (n) => {
|
|
641
|
+
if (!Array.isArray(n) || hasLoop) return
|
|
642
|
+
if (n[0] === 'loop') { hasLoop = true; return }
|
|
643
|
+
for (let i = 1; i < n.length && !hasLoop; i++) scanLoop(n[i])
|
|
644
|
+
}
|
|
645
|
+
for (let i = bodyStart; i < fn.length && !hasLoop; i++) scanLoop(fn[i])
|
|
646
|
+
if (!hasLoop) return
|
|
605
647
|
|
|
606
|
-
//
|
|
607
|
-
|
|
608
|
-
const
|
|
609
|
-
|
|
610
|
-
const
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
648
|
+
// Result wasm type of a hoistable node (for the snap local decl). null ⇒ can't
|
|
649
|
+
// type it ⇒ don't hoist. Param/local types come from the func header.
|
|
650
|
+
const localTypes = new Map()
|
|
651
|
+
for (let i = 2; i < bodyStart; i++) {
|
|
652
|
+
const c = fn[i]
|
|
653
|
+
if (Array.isArray(c) && (c[0] === 'param' || c[0] === 'local') && typeof c[1] === 'string') localTypes.set(c[1], c[2])
|
|
654
|
+
}
|
|
655
|
+
const resultType = (node) => {
|
|
656
|
+
if (!Array.isArray(node)) return null
|
|
657
|
+
const op = node[0]
|
|
658
|
+
if (op === 'select') return resultType(node[1])
|
|
659
|
+
if (op === 'if') {
|
|
660
|
+
// (if (result T) cond (then ...) (else ...)) — type is the result clause.
|
|
661
|
+
for (let i = 1; i < node.length; i++) {
|
|
662
|
+
const c = node[i]
|
|
663
|
+
if (Array.isArray(c) && c[0] === 'result') return c[1]
|
|
664
|
+
}
|
|
665
|
+
return null
|
|
666
|
+
}
|
|
667
|
+
if (op === 'block') {
|
|
668
|
+
for (let i = 1; i < node.length; i++) {
|
|
669
|
+
const c = node[i]
|
|
670
|
+
if (Array.isArray(c) && c[0] === 'result') return c[1]
|
|
671
|
+
}
|
|
672
|
+
return null
|
|
673
|
+
}
|
|
674
|
+
if (op === 'call') {
|
|
675
|
+
// SAFE_OFFSET_CALLS all return i32; READONLY_MEM_CALLS return f64 (NaN-boxed element)
|
|
676
|
+
if (SAFE_OFFSET_CALLS.has(node[1])) return 'i32'
|
|
677
|
+
if (READONLY_MEM_CALLS.has(node[1])) return 'f64'
|
|
678
|
+
return null
|
|
679
|
+
}
|
|
680
|
+
if (op === 'local.get' || op === 'local.tee') return localTypes.get(node[1]) ?? null
|
|
681
|
+
const dot = op.indexOf('.')
|
|
682
|
+
if (dot < 0) return null
|
|
683
|
+
// Comparisons and `eqz` yield i32 regardless of operand type (i64.eq, f64.lt,
|
|
684
|
+
// i64.eqz, …) — so the operand-type prefix would mistype them. Catch first.
|
|
685
|
+
const m = op.slice(dot + 1)
|
|
686
|
+
if (m === 'eqz' || /^(eq|ne|lt|gt|le|ge)(_[su])?$/.test(m)) return 'i32'
|
|
687
|
+
const p = op.slice(0, dot)
|
|
688
|
+
if (p === 'i32' || p === 'i64' || p === 'f64' || p === 'f32') return p
|
|
689
|
+
return null
|
|
614
690
|
}
|
|
615
|
-
countRefs(fn)
|
|
616
691
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
692
|
+
// Collision-proof snap ids: skip EVERY existing $__li id, not just start at the
|
|
693
|
+
// lowest free one. watr can renumber/coalesce locals between the pre- and
|
|
694
|
+
// post-watr optimize phases, leaving a non-contiguous $__li set; a lowest-free +
|
|
695
|
+
// sequential-increment scheme would then re-issue an in-use id (Duplicate local).
|
|
696
|
+
const usedLi = new Set()
|
|
697
|
+
const scanLi = (n) => {
|
|
698
|
+
if (!Array.isArray(n)) return
|
|
699
|
+
if (n[0] === 'local' && typeof n[1] === 'string' && n[1].startsWith('$__li')) {
|
|
700
|
+
const t = n[1].slice(5); if (/^\d+$/.test(t)) usedLi.add(+t)
|
|
701
|
+
}
|
|
702
|
+
for (let i = 0; i < n.length; i++) scanLi(n[i])
|
|
703
|
+
}
|
|
704
|
+
scanLi(fn)
|
|
705
|
+
let snapCounter = 0
|
|
706
|
+
const freshSnap = () => { while (usedLi.has(snapCounter)) snapCounter++; const id = snapCounter++; usedLi.add(id); return `$__li${id}` }
|
|
707
|
+
const newLocals = []
|
|
708
|
+
const refcount = buildRefcount(fn)
|
|
709
|
+
|
|
710
|
+
const processLoop = (loopNode, nested) => {
|
|
711
|
+
// Inner loops first (bottom-up) — an inner hoist creates a local.get the
|
|
712
|
+
// outer level can hoist further. Children run in a nested context.
|
|
713
|
+
for (let i = 1; i < loopNode.length; i++)
|
|
714
|
+
if (Array.isArray(loopNode[i])) processNode(loopNode[i], loopNode, i, true)
|
|
715
|
+
|
|
716
|
+
// The loop's effect summary (scans nested loops too — conservative).
|
|
717
|
+
const locals = new Set(), globals = new Set(), storedCells = new Set()
|
|
718
|
+
let hasUnsafeCall = false, hasAnyCall = false, hasDirectStore = false, hasV128 = false
|
|
630
719
|
const scan = (node) => {
|
|
631
720
|
if (!Array.isArray(node)) return
|
|
632
721
|
const op = node[0]
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
722
|
+
// A vectorized loop (lane/v128 ops) is already register-tight and hand-tuned;
|
|
723
|
+
// extra scalar hoisting there only adds spill pressure — keep it conservative.
|
|
724
|
+
if (op.startsWith('v128.') || /^[if]\d+x\d+\./.test(op)) hasV128 = true
|
|
725
|
+
if (op === 'local.set' || op === 'local.tee') { if (typeof node[1] === 'string') locals.add(node[1]); for (let i = 2; i < node.length; i++) scan(node[i]); return }
|
|
726
|
+
if (op === 'global.set') { if (typeof node[1] === 'string') globals.add(node[1]); for (let i = 2; i < node.length; i++) scan(node[i]); return }
|
|
727
|
+
if (op === 'call') { hasAnyCall = true; if (!SAFE_OFFSET_CALLS.has(node[1]) && !READONLY_MEM_CALLS.has(node[1]) && !NON_MUTATING_CALLS.has(node[1])) hasUnsafeCall = true; for (let i = 2; i < node.length; i++) scan(node[i]); return }
|
|
728
|
+
if (op === 'call_ref' || op === 'call_indirect') { hasAnyCall = hasUnsafeCall = true; for (let i = 1; i < node.length; i++) scan(node[i]); return }
|
|
729
|
+
if ((op === 'f64.store' || op === 'i32.store') && node.length >= 3) {
|
|
730
|
+
hasDirectStore = true
|
|
731
|
+
const a = node[1]
|
|
732
|
+
if (Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' && a[1].startsWith(CELL_PREFIX)) storedCells.add(a[1])
|
|
733
|
+
}
|
|
734
|
+
for (let i = 1; i < node.length; i++) scan(node[i])
|
|
735
|
+
}
|
|
736
|
+
for (let i = 1; i < loopNode.length; i++) scan(loopNode[i])
|
|
737
|
+
|
|
738
|
+
// Per-subtree local-occurrence counts and write-sets, memoized bottom-up —
|
|
739
|
+
// the tee-privacy check queries them for EVERY candidate node, and the old
|
|
740
|
+
// per-query re-walk (countIn/gatherBound) was quadratic on watr-scale loop
|
|
741
|
+
// bodies (the single largest compile-time hotspot, ~200ms/compile). All
|
|
742
|
+
// queries happen during `collect`, before any splice mutates the loop, so
|
|
743
|
+
// the memo cannot go stale; it is dropped with this processLoop frame.
|
|
744
|
+
const countsMemo = new Map() // node → Map(local → occurrences in subtree)
|
|
745
|
+
const writesMemoL = new Map() // node → Set(locals written in subtree)
|
|
746
|
+
const EMPTY_COUNTS = new Map(), EMPTY_WRITES = new Set()
|
|
747
|
+
const countsOf = (node) => {
|
|
748
|
+
if (!Array.isArray(node)) return EMPTY_COUNTS
|
|
749
|
+
let m = countsMemo.get(node)
|
|
750
|
+
if (m) return m
|
|
751
|
+
m = new Map()
|
|
752
|
+
const op = node[0]
|
|
753
|
+
if ((op === 'local.get' || op === 'local.set' || op === 'local.tee') && typeof node[1] === 'string')
|
|
754
|
+
m.set(node[1], 1)
|
|
755
|
+
for (let i = 1; i < node.length; i++)
|
|
756
|
+
for (const [k, v] of countsOf(node[i])) m.set(k, (m.get(k) || 0) + v)
|
|
757
|
+
countsMemo.set(node, m)
|
|
758
|
+
return m
|
|
759
|
+
}
|
|
760
|
+
const writesIn = (node) => {
|
|
761
|
+
if (!Array.isArray(node)) return EMPTY_WRITES
|
|
762
|
+
let s = writesMemoL.get(node)
|
|
763
|
+
if (s) return s
|
|
764
|
+
s = new Set()
|
|
765
|
+
if ((node[0] === 'local.set' || node[0] === 'local.tee') && typeof node[1] === 'string') s.add(node[1])
|
|
766
|
+
for (let i = 1; i < node.length; i++) for (const w of writesIn(node[i])) s.add(w)
|
|
767
|
+
writesMemoL.set(node, s)
|
|
768
|
+
return s
|
|
769
|
+
}
|
|
770
|
+
// Whole-loop counts (the former countLocals walk) — one memoized query.
|
|
771
|
+
const localCount = new Map()
|
|
772
|
+
for (let i = 1; i < loopNode.length; i++)
|
|
773
|
+
for (const [k, v] of countsOf(loopNode[i])) localCount.set(k, (localCount.get(k) || 0) + v)
|
|
774
|
+
|
|
775
|
+
// Pure & invariant given `bound` (locals written *within* the candidate, hence
|
|
776
|
+
// local to it). A read of a bound local is OK (its in-subtree value is the
|
|
777
|
+
// teed invariant). A free read must be unwritten by the loop.
|
|
778
|
+
const pureGiven = (node, bound) => {
|
|
779
|
+
if (!Array.isArray(node)) return true // bare operand string/number
|
|
780
|
+
const op = node[0]
|
|
781
|
+
if (op === 'i32.const' || op === 'i64.const' || op === 'f64.const' || op === 'f32.const') return true
|
|
782
|
+
if (op === 'local.get') return typeof node[1] === 'string' && (bound.has(node[1]) || !locals.has(node[1]))
|
|
783
|
+
// A global is invariant only if not set directly AND no call in the loop —
|
|
784
|
+
// any callee may mutate it (no interprocedural effect analysis). (Locals are
|
|
785
|
+
// frame-private, so calls can't touch them; only direct local.set matters.)
|
|
786
|
+
if (op === 'global.get') return typeof node[1] === 'string' && !globals.has(node[1]) && !hasAnyCall
|
|
787
|
+
if (op === 'local.tee') {
|
|
788
|
+
if (typeof node[1] !== 'string') return false
|
|
789
|
+
// The operand is evaluated BEFORE the tee writes $X, so a `local.get $X` inside
|
|
790
|
+
// it reads the loop-carried (previous-iteration) value, not the teed one. Drop
|
|
791
|
+
// $X from `bound` for the operand: `local.tee $X (… $X …)` is a loop recurrence
|
|
792
|
+
// (X = f(X) — e.g. the `while ((nn = nn >>> 1))` induction), NOT invariant.
|
|
793
|
+
const inner = bound.has(node[1]) ? new Set([...bound].filter(b => b !== node[1])) : bound
|
|
794
|
+
return pureGiven(node[2], inner)
|
|
795
|
+
}
|
|
796
|
+
if ((op === 'f64.load' || op === 'i32.load') && node.length === 2) {
|
|
797
|
+
const a = node[1]
|
|
798
|
+
return Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' && a[1].startsWith(CELL_PREFIX)
|
|
799
|
+
&& !hasAnyCall && !storedCells.has(a[1]) && (bound.has(a[1]) || !locals.has(a[1]))
|
|
637
800
|
}
|
|
638
801
|
if (op === 'call') {
|
|
639
|
-
if (
|
|
640
|
-
|
|
641
|
-
|
|
802
|
+
if (SAFE_OFFSET_CALLS.has(node[1]))
|
|
803
|
+
return !hasUnsafeCall && node.slice(2).every(c => pureGiven(c, bound))
|
|
804
|
+
// Read-only heap reads: additionally require no direct store (alias-safe).
|
|
805
|
+
if (READONLY_MEM_CALLS.has(node[1]))
|
|
806
|
+
return !hasUnsafeCall && !hasDirectStore && node.slice(2).every(c => pureGiven(c, bound))
|
|
807
|
+
return false
|
|
642
808
|
}
|
|
643
|
-
if
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
809
|
+
// A value-producing `if` whose condition and both arms are pure is itself
|
|
810
|
+
// pure — the tag-dispatch idiom `(if (result f64) tag-check (then read-A)
|
|
811
|
+
// (else read-B))` that wraps __typed_idx/__str_idx element access.
|
|
812
|
+
if (op === 'if') {
|
|
813
|
+
for (let i = 1; i < node.length; i++) {
|
|
814
|
+
const c = node[i]
|
|
815
|
+
if (!Array.isArray(c)) continue
|
|
816
|
+
if (c[0] === 'result') continue
|
|
817
|
+
if (c[0] === 'then' || c[0] === 'else') { if (!c.slice(1).every(x => pureGiven(x, bound))) return false }
|
|
818
|
+
else if (!pureGiven(c, bound)) return false // the condition
|
|
819
|
+
}
|
|
820
|
+
return true
|
|
647
821
|
}
|
|
648
|
-
|
|
822
|
+
if (PURE_LICM_OPS.has(op)) return node.slice(1).every(c => pureGiven(c, bound))
|
|
823
|
+
return false
|
|
649
824
|
}
|
|
650
|
-
for (let i = 1; i < loopNode.length; i++) scan(loopNode[i])
|
|
651
|
-
if (unsafe) return []
|
|
652
825
|
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
826
|
+
const isHoistable = (node) => {
|
|
827
|
+
if (!Array.isArray(node)) return false
|
|
828
|
+
const op = node[0]
|
|
829
|
+
// Skip trivial leaves: hoisting a bare get/const buys nothing.
|
|
830
|
+
if (op === 'local.get' || op === 'global.get' || op === 'i32.const' || op === 'i64.const' || op === 'f64.const' || op === 'f32.const') return false
|
|
831
|
+
const bound = writesIn(node)
|
|
832
|
+
// Every local the subtree writes must be private to it (no other use in the
|
|
833
|
+
// loop) — else moving the write to the pre-header changes another reader.
|
|
834
|
+
for (const b of bound) if (localCount.get(b) !== countsOf(node).get(b)) return false
|
|
835
|
+
// Top-level loops: only hoist what V8's wasm tier won't — a HARD_OP or the
|
|
836
|
+
// inline typed-array base decode — and leave plain pure arithmetic to V8's own
|
|
837
|
+
// LICM (which handles single-level loops well). NESTED (inner) loops are
|
|
838
|
+
// different: V8's wasm tier under-hoists invariants out of them (a nested
|
|
839
|
+
// rasterizer/convolution recomputes triangle/row-invariant subexpressions every
|
|
840
|
+
// iteration), so hoist any pure-invariant subtree there. Soundness is unchanged —
|
|
841
|
+
// `pureGiven` already proves the subtree is loop-invariant and side-effect-free.
|
|
842
|
+
return ((nested && !hasV128) || hasHardOp(node) || isPtrBaseDecode(node)) && pureGiven(node, bound)
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Maximal extraction: take the largest hoistable subtree; don't descend into
|
|
846
|
+
// it. Dedup structurally so a repeated invariant expr shares one snap local.
|
|
847
|
+
const sites = new Map() // structural key → [{ parent, idx, node }]
|
|
656
848
|
const collect = (node, parent, idx) => {
|
|
657
849
|
if (!Array.isArray(node)) return
|
|
658
|
-
|
|
659
|
-
if (
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
const
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
&& (refcount.get(node) || 0) <= 1
|
|
666
|
-
&& (refcount.get(parent) || 0) <= 1) {
|
|
667
|
-
let arr = sites.get(inner[1])
|
|
668
|
-
if (!arr) { arr = []; sites.set(inner[1], arr) }
|
|
669
|
-
arr.push({ parent, idx })
|
|
670
|
-
}
|
|
850
|
+
if (node[0] === 'loop') return // already processed bottom-up
|
|
851
|
+
if (isHoistable(node) && (refcount.get(node) || 0) <= 1 && (refcount.get(parent) || 0) <= 1) {
|
|
852
|
+
// BigInt-safe: hoistable boxed-pointer subtrees carry i64.const NaN-box prefixes
|
|
853
|
+
// (BigInt values) that plain JSON.stringify can't serialize — see ast.bigintSafeKey.
|
|
854
|
+
const key = JSON.stringify(node, (_k, v) => typeof v === 'bigint' ? `${v}n` : v)
|
|
855
|
+
let arr = sites.get(key); if (!arr) { arr = []; sites.set(key, arr) }
|
|
856
|
+
arr.push({ parent, idx, node })
|
|
671
857
|
return
|
|
672
858
|
}
|
|
673
859
|
for (let i = 0; i < node.length; i++) collect(node[i], node, i)
|
|
@@ -675,190 +861,203 @@ export function hoistInvariantPtrOffsetLoop(fn) {
|
|
|
675
861
|
for (let i = 1; i < loopNode.length; i++) collect(loopNode[i], loopNode, i)
|
|
676
862
|
|
|
677
863
|
const snaps = []
|
|
678
|
-
for (const [
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
}
|
|
864
|
+
for (const [, arr] of sites) {
|
|
865
|
+
const type = resultType(arr[0].node)
|
|
866
|
+
if (type == null) continue
|
|
867
|
+
const snapName = freshSnap()
|
|
868
|
+
newLocals.push(['local', snapName, type])
|
|
869
|
+
snaps.push(['local.set', snapName, arr[0].node]) // reuse first node verbatim
|
|
870
|
+
for (const { parent, idx } of arr) parent[idx] = ['local.get', snapName]
|
|
686
871
|
}
|
|
687
872
|
return snaps
|
|
688
873
|
}
|
|
689
874
|
|
|
690
|
-
const processNode = (node, parent, idx) => {
|
|
875
|
+
const processNode = (node, parent, idx, nested = false) => {
|
|
691
876
|
if (!Array.isArray(node)) return
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
const snaps = processLoop(node)
|
|
877
|
+
if (node[0] === 'loop') {
|
|
878
|
+
const snaps = processLoop(node, nested)
|
|
695
879
|
if (snaps.length) parent.splice(idx, 0, ...snaps)
|
|
696
880
|
return
|
|
697
881
|
}
|
|
698
|
-
for (let i = 0; i < node.length; i++) processNode(node[i], node, i)
|
|
882
|
+
for (let i = 0; i < node.length; i++) processNode(node[i], node, i, nested)
|
|
699
883
|
}
|
|
700
884
|
|
|
701
|
-
for (let i = bodyStart; i < fn.length; i++) processNode(fn[i], fn, i)
|
|
702
|
-
|
|
885
|
+
for (let i = bodyStart; i < fn.length; i++) processNode(fn[i], fn, i, false)
|
|
703
886
|
if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
|
|
704
887
|
}
|
|
705
888
|
|
|
706
889
|
/**
|
|
707
|
-
*
|
|
890
|
+
* Narrow an f64 loop bound to i32. `for (let i = 0; i < n; i++)` with an f64
|
|
891
|
+
* param `n` emits `(f64.lt (f64.convert_i32_s $i) (local.get $n))` — an f64
|
|
892
|
+
* convert+compare every iteration that ALSO blocks the lane-vectorizer (it
|
|
893
|
+
* requires an i32-governed trip count). The naive-DSP export shape
|
|
894
|
+
* `(ptr, n) => { for (i = 0; i < n; i++) … }` therefore never vectorized
|
|
895
|
+
* without a hand-written `n|0`. This pass is that annotation, as a proof.
|
|
708
896
|
*
|
|
709
|
-
*
|
|
710
|
-
*
|
|
711
|
-
*
|
|
712
|
-
*
|
|
713
|
-
*
|
|
897
|
+
* When $i is a proven-non-negative i32 counter and $n is loop-invariant:
|
|
898
|
+
* convert_i32_s(i) < n ⟺ i < trunc_sat(ceil(n)) for all i ≥ 0
|
|
899
|
+
* - fractional n rounds up (i < 5.5 ⟺ i < 6); integral n exact
|
|
900
|
+
* - NaN: ceil→NaN, trunc_sat→0 ⇒ `i < 0` false — matches the false f64 compare
|
|
901
|
+
* (THIS case is why i ≥ 0 must be proven: a negative i would flip it true)
|
|
902
|
+
* - n ≤ −2³¹ saturates to INT32_MIN ⇒ always false — matches
|
|
903
|
+
* - n ≥ 2³¹ saturates to INT32_MAX ⇒ terminates after 2³¹−1 iterations where
|
|
904
|
+
* the original wrapped $i negative and spun forever — the only divergence,
|
|
905
|
+
* pathological in both versions (a JS double counter would keep counting).
|
|
906
|
+
* Non-negativity proof: $i is a non-param i32 local whose EVERY write in the
|
|
907
|
+
* function (counters get re-zeroed between loops) is a non-negative i32.const
|
|
908
|
+
* or `$i + positive-const`. Wrap-around past 2³¹ needs 2³¹ agreeing iterations
|
|
909
|
+
* first, so trajectories are identical in every non-pathological program.
|
|
714
910
|
*
|
|
715
|
-
*
|
|
716
|
-
*
|
|
717
|
-
*
|
|
718
|
-
*
|
|
719
|
-
*
|
|
720
|
-
* Inside-out per-loop processing — inner loops handled first, so reads
|
|
721
|
-
* already replaced by snap-locals don't appear as cell reads at outer levels.
|
|
911
|
+
* Snap `(local.set $__lbK (i32.trunc_sat_f64_s (f64.ceil (local.get $n))))`
|
|
912
|
+
* goes in the loop pre-header (re-snapped per outer iteration when nested —
|
|
913
|
+
* trunc_sat/ceil are total, safe even for zero-trip loops); the compare becomes
|
|
914
|
+
* `(i32.lt_s $i $__lbK)` — the exact shape the lane-vectorizer matches.
|
|
915
|
+
* Bottom-up, refcount-guarded, idempotent (rewritten conds no longer match).
|
|
722
916
|
*/
|
|
723
|
-
export function
|
|
917
|
+
export function narrowLoopBound(fn) {
|
|
724
918
|
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
725
919
|
const bodyStart = findBodyStart(fn)
|
|
726
920
|
if (bodyStart < 0) return
|
|
727
921
|
|
|
728
|
-
|
|
729
|
-
|
|
922
|
+
// Cheap early-out: no loop ⇒ nothing to narrow.
|
|
923
|
+
let hasLoop = false
|
|
924
|
+
const scanLoop = (n) => {
|
|
925
|
+
if (!Array.isArray(n) || hasLoop) return
|
|
926
|
+
if (n[0] === 'loop') { hasLoop = true; return }
|
|
927
|
+
for (let i = 1; i < n.length && !hasLoop; i++) scanLoop(n[i])
|
|
928
|
+
}
|
|
929
|
+
for (let i = bodyStart; i < fn.length && !hasLoop; i++) scanLoop(fn[i])
|
|
930
|
+
if (!hasLoop) return
|
|
931
|
+
|
|
932
|
+
// Header types. Params are excluded as counters: their init is caller-supplied,
|
|
933
|
+
// so non-negativity is unprovable.
|
|
934
|
+
const localTypes = new Map(), params = new Set()
|
|
935
|
+
for (let i = 2; i < bodyStart; i++) {
|
|
936
|
+
const c = fn[i]
|
|
937
|
+
if (!Array.isArray(c) || typeof c[1] !== 'string') continue
|
|
938
|
+
if (c[0] === 'param') params.add(c[1])
|
|
939
|
+
if (c[0] === 'param' || c[0] === 'local') localTypes.set(c[1], c[2])
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// Every write per local across the WHOLE function — not just in-loop: a counter
|
|
943
|
+
// reused by a later loop is re-zeroed between them, and a negative write
|
|
944
|
+
// anywhere voids the proof.
|
|
945
|
+
const writes = new Map()
|
|
946
|
+
const collectWrites = (n) => {
|
|
947
|
+
if (!Array.isArray(n)) return
|
|
948
|
+
if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') {
|
|
949
|
+
let arr = writes.get(n[1]); if (!arr) writes.set(n[1], arr = [])
|
|
950
|
+
arr.push(n[2])
|
|
951
|
+
}
|
|
952
|
+
for (let i = 1; i < n.length; i++) collectWrites(n[i])
|
|
953
|
+
}
|
|
954
|
+
for (let i = bodyStart; i < fn.length; i++) collectWrites(fn[i])
|
|
955
|
+
|
|
956
|
+
const constVal = (n) => Array.isArray(n) && n[0] === 'i32.const' ? Number(n[1]) : NaN
|
|
957
|
+
const nonNegCounter = (name) => {
|
|
958
|
+
if (params.has(name) || localTypes.get(name) !== 'i32') return false
|
|
959
|
+
const ws = writes.get(name)
|
|
960
|
+
if (!ws) return true // never written ⇒ stays at default 0
|
|
961
|
+
return ws.every(v => {
|
|
962
|
+
if (!Array.isArray(v)) return false
|
|
963
|
+
if (v[0] === 'i32.const') return Number(v[1]) >= 0
|
|
964
|
+
if (v[0] !== 'i32.add') return false
|
|
965
|
+
if (Array.isArray(v[1]) && v[1][0] === 'local.get' && v[1][1] === name) return constVal(v[2]) > 0
|
|
966
|
+
if (Array.isArray(v[2]) && v[2][0] === 'local.get' && v[2][1] === name) return constVal(v[1]) > 0
|
|
967
|
+
return false
|
|
968
|
+
})
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// Collision-proof snap ids (same scheme as hoistInvariantLoop's $__li).
|
|
972
|
+
const usedLb = new Set()
|
|
973
|
+
const scanLb = (n) => {
|
|
974
|
+
if (!Array.isArray(n)) return
|
|
975
|
+
if (n[0] === 'local' && typeof n[1] === 'string' && n[1].startsWith('$__lb')) {
|
|
976
|
+
const t = n[1].slice(5); if (/^\d+$/.test(t)) usedLb.add(+t)
|
|
977
|
+
}
|
|
978
|
+
for (let i = 0; i < n.length; i++) scanLb(n[i])
|
|
979
|
+
}
|
|
980
|
+
scanLb(fn)
|
|
981
|
+
let lbCounter = 0
|
|
982
|
+
const freshLb = () => { while (usedLb.has(lbCounter)) lbCounter++; const id = lbCounter++; usedLb.add(id); return `$__lb${id}` }
|
|
730
983
|
const newLocals = []
|
|
984
|
+
const refcount = buildRefcount(fn)
|
|
731
985
|
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
if (!Array.isArray(
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
if (n > 1) return // already counted children below
|
|
743
|
-
for (let i = 0; i < node.length; i++) countRefs(node[i])
|
|
986
|
+
// `(f64.lt (f64.convert_i32_s (local.get $i)) (local.get $n))` or mirrored
|
|
987
|
+
// `(f64.gt (local.get $n) (f64.convert_i32_s (local.get $i)))`.
|
|
988
|
+
const match = (n) => {
|
|
989
|
+
const conv = n[0] === 'f64.lt' ? n[1] : n[0] === 'f64.gt' ? n[2] : null
|
|
990
|
+
const bnd = n[0] === 'f64.lt' ? n[2] : n[0] === 'f64.gt' ? n[1] : null
|
|
991
|
+
if (!Array.isArray(conv) || conv[0] !== 'f64.convert_i32_s') return null
|
|
992
|
+
const ig = conv[1]
|
|
993
|
+
if (!Array.isArray(ig) || ig[0] !== 'local.get' || typeof ig[1] !== 'string') return null
|
|
994
|
+
if (!Array.isArray(bnd) || bnd[0] !== 'local.get' || typeof bnd[1] !== 'string') return null
|
|
995
|
+
return { ctr: ig[1], bound: bnd[1] }
|
|
744
996
|
}
|
|
745
|
-
countRefs(fn)
|
|
746
997
|
|
|
747
|
-
// Process one loop node: find cell_X reads, check no writes, hoist.
|
|
748
|
-
// Returns { snapDecls } — list of (local.set $snap (f64.load (local.get $cell_X))) IR
|
|
749
|
-
// to emit before the loop in its parent.
|
|
750
998
|
const processLoop = (loopNode) => {
|
|
751
|
-
//
|
|
752
|
-
//
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
const reads = new Map() // cellName → array of {parent, idx}
|
|
763
|
-
const writes = new Set()
|
|
764
|
-
let hasCall = false
|
|
765
|
-
const scanWrites = (node) => {
|
|
766
|
-
if (!Array.isArray(node)) return
|
|
767
|
-
const op = node[0]
|
|
768
|
-
if (op === 'call' || op === 'call_ref' || op === 'call_indirect') {
|
|
769
|
-
hasCall = true
|
|
770
|
-
}
|
|
771
|
-
// DESCEND into nested loops here — we need to know if any nested-loop
|
|
772
|
-
// body writes to cell_X (which would invalidate hoisting THIS loop's reads).
|
|
773
|
-
if (op === 'f64.store' && node.length >= 3) {
|
|
774
|
-
const addr = node[1]
|
|
775
|
-
if (Array.isArray(addr) && addr[0] === 'local.get' && typeof addr[1] === 'string'
|
|
776
|
-
&& addr[1].startsWith('$cell_')) {
|
|
777
|
-
writes.add(addr[1])
|
|
778
|
-
}
|
|
779
|
-
// Continue scan into value expr
|
|
780
|
-
for (let i = 2; i < node.length; i++) scanWrites(node[i])
|
|
781
|
-
return
|
|
782
|
-
}
|
|
783
|
-
if (op === 'f64.load' && node.length === 2) {
|
|
784
|
-
const addr = node[1]
|
|
785
|
-
if (Array.isArray(addr) && addr[0] === 'local.get' && typeof addr[1] === 'string'
|
|
786
|
-
&& addr[1].startsWith('$cell_')) {
|
|
787
|
-
// Defer; we'll handle in a parent-tracking second pass.
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
for (let i = 1; i < node.length; i++) scanWrites(node[i])
|
|
999
|
+
// Inner loops first — their sites belong to their own pre-header (the bound
|
|
1000
|
+
// may be written by THIS loop between inner runs).
|
|
1001
|
+
for (let i = 1; i < loopNode.length; i++)
|
|
1002
|
+
if (Array.isArray(loopNode[i])) processNode(loopNode[i], loopNode, i)
|
|
1003
|
+
|
|
1004
|
+
// Locals written anywhere in this loop (incl. nested) — bound invariance.
|
|
1005
|
+
const written = new Set()
|
|
1006
|
+
const scanW = (n) => {
|
|
1007
|
+
if (!Array.isArray(n)) return
|
|
1008
|
+
if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') written.add(n[1])
|
|
1009
|
+
for (let i = 1; i < n.length; i++) scanW(n[i])
|
|
791
1010
|
}
|
|
792
|
-
for (let i = 1; i < loopNode.length; i++)
|
|
793
|
-
// Sound bailout: a call inside the loop could mutate a captured cell
|
|
794
|
-
// via a closure we can't see. Without escape analysis we can't prove
|
|
795
|
-
// non-aliasing, so we skip hoisting from any loop containing calls.
|
|
796
|
-
if (hasCall) return []
|
|
1011
|
+
for (let i = 1; i < loopNode.length; i++) scanW(loopNode[i])
|
|
797
1012
|
|
|
798
|
-
|
|
799
|
-
const collect = (node
|
|
1013
|
+
const sites = []
|
|
1014
|
+
const collect = (node) => {
|
|
800
1015
|
if (!Array.isArray(node)) return
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
if (
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
const cell = addr[1]
|
|
808
|
-
// Skip if the f64.load node or its immediate parent is shared
|
|
809
|
-
// (refcount>1): mutating parent[idx] would propagate the rewrite to
|
|
810
|
-
// references outside this loop.
|
|
811
|
-
if (!writes.has(cell)
|
|
812
|
-
&& (refcount.get(node) || 0) <= 1
|
|
813
|
-
&& (refcount.get(parent) || 0) <= 1) {
|
|
814
|
-
let arr = reads.get(cell)
|
|
815
|
-
if (!arr) { arr = []; reads.set(cell, arr) }
|
|
816
|
-
arr.push({ parent, idx })
|
|
817
|
-
}
|
|
818
|
-
return
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
for (let i = 0; i < node.length; i++) collect(node[i], node, i)
|
|
1016
|
+
if (node[0] === 'loop') return // already processed bottom-up
|
|
1017
|
+
const m = match(node)
|
|
1018
|
+
if (m && (refcount.get(node) || 0) <= 1
|
|
1019
|
+
&& localTypes.get(m.bound) === 'f64' && !written.has(m.bound)
|
|
1020
|
+
&& nonNegCounter(m.ctr)) { sites.push({ node, m }); return }
|
|
1021
|
+
for (let i = 1; i < node.length; i++) collect(node[i])
|
|
822
1022
|
}
|
|
823
|
-
for (let i = 1; i < loopNode.length; i++) collect(loopNode[i]
|
|
1023
|
+
for (let i = 1; i < loopNode.length; i++) collect(loopNode[i])
|
|
824
1024
|
|
|
825
|
-
|
|
826
|
-
// hoist a snap. Single-read hoist is fine semantically: the cell address
|
|
827
|
-
// doesn't change once allocated, and snap is loaded unconditionally before
|
|
828
|
-
// the loop, then the body uses the snap local.
|
|
1025
|
+
const snapFor = new Map() // bound name → snap local (one per distinct bound)
|
|
829
1026
|
const snaps = []
|
|
830
|
-
for (const
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
1027
|
+
for (const { node, m } of sites) {
|
|
1028
|
+
let snap = snapFor.get(m.bound)
|
|
1029
|
+
if (!snap) {
|
|
1030
|
+
snap = freshLb()
|
|
1031
|
+
snapFor.set(m.bound, snap)
|
|
1032
|
+
newLocals.push(['local', snap, 'i32'])
|
|
1033
|
+
snaps.push(['local.set', snap, ['i32.trunc_sat_f64_s', ['f64.ceil', ['local.get', m.bound]]]])
|
|
837
1034
|
}
|
|
1035
|
+
node.length = 3
|
|
1036
|
+
node[0] = 'i32.lt_s'; node[1] = ['local.get', m.ctr]; node[2] = ['local.get', snap]
|
|
838
1037
|
}
|
|
839
1038
|
return snaps
|
|
840
1039
|
}
|
|
841
1040
|
|
|
842
|
-
// Recursive node walker that splices snap decls before nested loops.
|
|
843
1041
|
const processNode = (node, parent, idx) => {
|
|
844
1042
|
if (!Array.isArray(node)) return
|
|
845
|
-
|
|
846
|
-
|
|
1043
|
+
// Break-block idiom `(block $brk (loop …))`: snaps go BEFORE the block —
|
|
1044
|
+
// any statement between the block label and the loop is "foreign content"
|
|
1045
|
+
// to the lane-vectorizer's matcher and would defeat the whole point.
|
|
1046
|
+
if (node[0] === 'block' && typeof node[1] === 'string' && node.length === 3
|
|
1047
|
+
&& Array.isArray(node[2]) && node[2][0] === 'loop') {
|
|
1048
|
+
const snaps = processLoop(node[2])
|
|
1049
|
+
if (snaps.length) parent.splice(idx, 0, ...snaps)
|
|
1050
|
+
return
|
|
1051
|
+
}
|
|
1052
|
+
if (node[0] === 'loop') {
|
|
847
1053
|
const snaps = processLoop(node)
|
|
848
|
-
if (snaps.length)
|
|
849
|
-
// Splice snaps just before this loop in its parent. The parent could be
|
|
850
|
-
// a `block` or a top-level func body or any other container.
|
|
851
|
-
parent.splice(idx, 0, ...snaps)
|
|
852
|
-
}
|
|
1054
|
+
if (snaps.length) parent.splice(idx, 0, ...snaps)
|
|
853
1055
|
return
|
|
854
1056
|
}
|
|
855
1057
|
for (let i = 0; i < node.length; i++) processNode(node[i], node, i)
|
|
856
1058
|
}
|
|
857
1059
|
|
|
858
|
-
for (let i = bodyStart; i < fn.length; i++)
|
|
859
|
-
processNode(fn[i], fn, i)
|
|
860
|
-
}
|
|
861
|
-
|
|
1060
|
+
for (let i = bodyStart; i < fn.length; i++) processNode(fn[i], fn, i)
|
|
862
1061
|
if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
|
|
863
1062
|
}
|
|
864
1063
|
|
|
@@ -900,8 +1099,7 @@ export function cseScalarLoad(fn) {
|
|
|
900
1099
|
const bases = fn.cseLoadBases
|
|
901
1100
|
if (!(bases instanceof Set) || bases.size === 0) return
|
|
902
1101
|
|
|
903
|
-
let snapId =
|
|
904
|
-
while (fn.some(n => Array.isArray(n) && n[0] === 'local' && n[1] === `$__cs${snapId}`)) snapId++
|
|
1102
|
+
let snapId = nextLocalId(fn, 'cs')
|
|
905
1103
|
const newLocals = []
|
|
906
1104
|
|
|
907
1105
|
// CSE table: key `${X}|${K}` → { snapName | null, anchorParent, anchorIdx }
|
|
@@ -1057,6 +1255,11 @@ export function cseScalarLoad(fn) {
|
|
|
1057
1255
|
* the surrounding `local.set/tee` handles that)
|
|
1058
1256
|
* - `br/br_if/br_table/return/unreachable` → NO clear (pure values still valid)
|
|
1059
1257
|
*/
|
|
1258
|
+
// Commutative WASM binops — shared by csePureExpr + csePureExprLoop for canonical
|
|
1259
|
+
// operand-key ordering (a*b and b*a hash to one entry). OP_TYPE tables stay local:
|
|
1260
|
+
// the two passes cover deliberately different op sets.
|
|
1261
|
+
const COMMUTATIVE = new Set(['f64.mul', 'f64.add', 'i32.mul', 'i32.add', 'i32.and', 'i32.or', 'i32.xor', 'i64.mul', 'i64.add', 'i64.and', 'i64.or', 'i64.xor'])
|
|
1262
|
+
|
|
1060
1263
|
export function csePureExpr(fn) {
|
|
1061
1264
|
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
1062
1265
|
const bodyStart = findBodyStart(fn)
|
|
@@ -1073,8 +1276,8 @@ export function csePureExpr(fn) {
|
|
|
1073
1276
|
if (m) { const k = +m[1]; if (k >= snapId) snapId = k + 1 }
|
|
1074
1277
|
}
|
|
1075
1278
|
const newLocals = []
|
|
1279
|
+
let refcount = null // lazily built on the first dedup — most fns never CSE
|
|
1076
1280
|
|
|
1077
|
-
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'])
|
|
1078
1281
|
const TARGET_OPS = new Set([
|
|
1079
1282
|
'f64.mul', 'f64.add', 'f64.sub',
|
|
1080
1283
|
'i32.mul', 'i32.add', 'i32.sub', 'i32.shl', 'i32.shr_u', 'i32.shr_s', 'i32.and', 'i32.or', 'i32.xor',
|
|
@@ -1149,6 +1352,12 @@ export function csePureExpr(fn) {
|
|
|
1149
1352
|
const entry = table.get(key)
|
|
1150
1353
|
if (entry) {
|
|
1151
1354
|
if (!entry.snapName) {
|
|
1355
|
+
// A shared (DAG) anchor breaks the in-place tee: the `%` fast-path emits
|
|
1356
|
+
// `a - trunc(a/b)*b` reusing ONE `a` node object, so the anchor and the
|
|
1357
|
+
// local.get replacement land on the SAME physical slot and the local.get
|
|
1358
|
+
// clobbers the tee — orphaning $__pe (reads 0). Skip when the anchor's
|
|
1359
|
+
// parent is shared; watr's DAG-aware CSE still dedupes. Mirrors csePureExprLoop.
|
|
1360
|
+
if (((refcount ??= buildRefcount(fn)).get(entry.anchorParent) || 0) > 1) return
|
|
1152
1361
|
const snapName = `$__pe${snapId++}`
|
|
1153
1362
|
entry.snapName = snapName
|
|
1154
1363
|
newLocals.push(['local', snapName, OP_TYPE[op] || 'f64'])
|
|
@@ -1176,6 +1385,189 @@ export function csePureExpr(fn) {
|
|
|
1176
1385
|
if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
|
|
1177
1386
|
}
|
|
1178
1387
|
|
|
1388
|
+
/**
|
|
1389
|
+
* Post-watr nested CSE for hot fill loops (loop + trig). Reuses `$__pe` locals from
|
|
1390
|
+
* the pre-watr leaf pass. Deferred to the `phase === 'post'` run so watr's typed-array
|
|
1391
|
+
* inlining is not confused by pre-watr IR rewrites (test/mem.js).
|
|
1392
|
+
*/
|
|
1393
|
+
export function csePureExprLoop(fn) {
|
|
1394
|
+
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
1395
|
+
const bodyStart = findBodyStart(fn)
|
|
1396
|
+
if (bodyStart < 0) return
|
|
1397
|
+
|
|
1398
|
+
let hasLoop = false
|
|
1399
|
+
let hasTrigCall = false
|
|
1400
|
+
const scanShape = (n) => {
|
|
1401
|
+
if (!Array.isArray(n)) return
|
|
1402
|
+
if (n[0] === 'loop') hasLoop = true
|
|
1403
|
+
if (n[0] === 'call' && (n[1] === '$math.sin' || n[1] === '$math.cos'
|
|
1404
|
+
|| n[1] === '$math.sin_core' || n[1] === '$math.cos_core')) hasTrigCall = true
|
|
1405
|
+
for (let i = 1; i < n.length; i++) scanShape(n[i])
|
|
1406
|
+
}
|
|
1407
|
+
for (let i = bodyStart; i < fn.length; i++) scanShape(fn[i])
|
|
1408
|
+
if (!hasLoop || !hasTrigCall) return
|
|
1409
|
+
|
|
1410
|
+
let snapId = nextLocalId(fn, 'pe')
|
|
1411
|
+
const newLocals = []
|
|
1412
|
+
|
|
1413
|
+
const refcount = buildRefcount(fn)
|
|
1414
|
+
const canMutateSite = (parent, node) =>
|
|
1415
|
+
(refcount.get(node) || 0) <= 1 && (refcount.get(parent) || 0) <= 1
|
|
1416
|
+
|
|
1417
|
+
const PURE_F64_BIN = new Set(['f64.mul', 'f64.add', 'f64.sub', 'f64.div'])
|
|
1418
|
+
const PURE_F64_UNARY = new Set(['f64.neg', 'f64.abs', 'f64.convert_i32_s', 'f64.convert_i32_u'])
|
|
1419
|
+
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'])
|
|
1420
|
+
const PURE_I32_UNARY = new Set(['i32.eqz', 'i32.clz', 'i32.ctz', 'i32.popcnt'])
|
|
1421
|
+
const OP_TYPE = {
|
|
1422
|
+
'f64.mul': 'f64', 'f64.add': 'f64', 'f64.sub': 'f64', 'f64.div': 'f64', 'f64.neg': 'f64', 'f64.abs': 'f64',
|
|
1423
|
+
'f64.convert_i32_s': 'f64', 'f64.convert_i32_u': 'f64',
|
|
1424
|
+
'i32.mul': 'i32', 'i32.add': 'i32', 'i32.sub': 'i32', 'i32.shl': 'i32', 'i32.shr_u': 'i32', 'i32.shr_s': 'i32',
|
|
1425
|
+
'i32.and': 'i32', 'i32.or': 'i32', 'i32.xor': 'i32', 'i32.eqz': 'i32',
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
const table = new Map()
|
|
1429
|
+
const keyLocals = new Set()
|
|
1430
|
+
const keyGlobals = new Set()
|
|
1431
|
+
|
|
1432
|
+
const invalidateLocal = (X) => {
|
|
1433
|
+
for (const [key, entry] of table) {
|
|
1434
|
+
if (entry.locals.has(X)) table.delete(key)
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
const invalidateGlobal = (G) => {
|
|
1439
|
+
for (const [key, entry] of table) {
|
|
1440
|
+
if (entry.globals.has(G)) table.delete(key)
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
const pureKeyI32 = (n) => {
|
|
1445
|
+
if (!Array.isArray(n)) return null
|
|
1446
|
+
const op = n[0]
|
|
1447
|
+
if (op === 'local.get' && typeof n[1] === 'string') { keyLocals.add(n[1]); return `L:${n[1]}` }
|
|
1448
|
+
if (op === 'global.get' && typeof n[1] === 'string') { keyGlobals.add(n[1]); return `G:${n[1]}` }
|
|
1449
|
+
if (op === 'i32.const' || op === 'i64.const') return `C:${op}:${n[1]}`
|
|
1450
|
+
if (PURE_I32_UNARY.has(op) && n.length === 2) {
|
|
1451
|
+
const k = pureKeyI32(n[1]); return k ? `${op}|${k}` : null
|
|
1452
|
+
}
|
|
1453
|
+
if (PURE_I32_BIN.has(op) && n.length === 3) {
|
|
1454
|
+
const ka = pureKeyI32(n[1]), kb = pureKeyI32(n[2])
|
|
1455
|
+
if (!ka || !kb) return null
|
|
1456
|
+
return COMMUTATIVE.has(op) && ka > kb ? `${op}|${kb}|${ka}` : `${op}|${ka}|${kb}`
|
|
1457
|
+
}
|
|
1458
|
+
if (op === 'i32.wrap_i64' && n.length === 2) {
|
|
1459
|
+
const k = pureKeyI32(n[1]); return k ? `wrap|${k}` : null
|
|
1460
|
+
}
|
|
1461
|
+
return null
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
const pureKeyF64 = (n) => {
|
|
1465
|
+
if (!Array.isArray(n)) return null
|
|
1466
|
+
const op = n[0]
|
|
1467
|
+
if (op === 'local.get' && typeof n[1] === 'string') { keyLocals.add(n[1]); return `L:${n[1]}` }
|
|
1468
|
+
if (op === 'global.get' && typeof n[1] === 'string') { keyGlobals.add(n[1]); return `G:${n[1]}` }
|
|
1469
|
+
if (op === 'f64.const' || op === 'f32.const') return `C:${op}:${n[1]}`
|
|
1470
|
+
if (PURE_F64_UNARY.has(op) && n.length === 2) {
|
|
1471
|
+
if (op === 'f64.convert_i32_s' || op === 'f64.convert_i32_u') {
|
|
1472
|
+
const k = pureKeyI32(n[1]); return k ? `${op}|${k}` : null
|
|
1473
|
+
}
|
|
1474
|
+
const k = pureKeyF64(n[1]); return k ? `${op}|${k}` : null
|
|
1475
|
+
}
|
|
1476
|
+
if (PURE_F64_BIN.has(op) && n.length === 3) {
|
|
1477
|
+
const ka = pureKeyF64(n[1]), kb = pureKeyF64(n[2])
|
|
1478
|
+
if (!ka || !kb) return null
|
|
1479
|
+
return COMMUTATIVE.has(op) && ka > kb ? `${op}|${kb}|${ka}` : `${op}|${ka}|${kb}`
|
|
1480
|
+
}
|
|
1481
|
+
if (op === 'call' && n[1] === '$__to_num' && n.length === 3) {
|
|
1482
|
+
const a = n[2]
|
|
1483
|
+
if (Array.isArray(a) && a[0] === 'i64.reinterpret_f64' && a.length === 2) {
|
|
1484
|
+
const k = pureKeyF64(a[1]); return k ? `tonum|${k}` : null
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
return null
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
const tryCse = (node, parent, idx) => {
|
|
1491
|
+
const op = node[0]
|
|
1492
|
+
if (op === 'local.get' || op === 'global.get' || op === 'f64.const' || op === 'f32.const') return
|
|
1493
|
+
if (!canMutateSite(parent, node)) return
|
|
1494
|
+
keyLocals.clear()
|
|
1495
|
+
keyGlobals.clear()
|
|
1496
|
+
const key = pureKeyF64(node)
|
|
1497
|
+
if (!key) return
|
|
1498
|
+
const locals = new Set(keyLocals)
|
|
1499
|
+
const globals = new Set(keyGlobals)
|
|
1500
|
+
const entry = table.get(key)
|
|
1501
|
+
if (entry) {
|
|
1502
|
+
if (!entry.snapName) {
|
|
1503
|
+
if ((refcount.get(entry.anchorParent) || 0) > 1) return
|
|
1504
|
+
const snapName = `$__pe${snapId++}`
|
|
1505
|
+
entry.snapName = snapName
|
|
1506
|
+
newLocals.push(['local', snapName, OP_TYPE[node[0]] || 'f64'])
|
|
1507
|
+
const orig = entry.anchorParent[entry.anchorIdx]
|
|
1508
|
+
entry.anchorParent[entry.anchorIdx] = ['local.tee', snapName, orig]
|
|
1509
|
+
}
|
|
1510
|
+
parent[idx] = ['local.get', entry.snapName]
|
|
1511
|
+
} else {
|
|
1512
|
+
table.set(key, { snapName: null, anchorParent: parent, anchorIdx: idx, locals, globals })
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
const walk = (node, parent, idx) => {
|
|
1517
|
+
if (!Array.isArray(node)) return
|
|
1518
|
+
const op = node[0]
|
|
1519
|
+
|
|
1520
|
+
if (op === 'loop') {
|
|
1521
|
+
table.clear()
|
|
1522
|
+
for (let i = 1; i < node.length; i++) walk(node[i], node, i)
|
|
1523
|
+
table.clear()
|
|
1524
|
+
return
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
if (op === 'if') {
|
|
1528
|
+
table.clear()
|
|
1529
|
+
for (let i = 1; i < node.length; i++) walk(node[i], node, i)
|
|
1530
|
+
table.clear()
|
|
1531
|
+
return
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
if (op === 'then' || op === 'else') {
|
|
1535
|
+
table.clear()
|
|
1536
|
+
for (let i = 1; i < node.length; i++) walk(node[i], node, i)
|
|
1537
|
+
table.clear()
|
|
1538
|
+
return
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
if (op === 'call' || op === 'call_ref' || op === 'call_indirect') {
|
|
1542
|
+
for (let i = 1; i < node.length; i++) walk(node[i], node, i)
|
|
1543
|
+
return
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
if (op === 'local.set' || op === 'local.tee') {
|
|
1547
|
+
for (let i = 2; i < node.length; i++) walk(node[i], node, i)
|
|
1548
|
+
const X = node[1]
|
|
1549
|
+
if (typeof X === 'string') invalidateLocal(X)
|
|
1550
|
+
return
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
if (op === 'global.set') {
|
|
1554
|
+
for (let i = 2; i < node.length; i++) walk(node[i], node, i)
|
|
1555
|
+
const G = node[1]
|
|
1556
|
+
if (typeof G === 'string') invalidateGlobal(G)
|
|
1557
|
+
return
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
for (let i = 1; i < node.length; i++) {
|
|
1561
|
+
if (Array.isArray(node[i])) walk(node[i], node, i)
|
|
1562
|
+
}
|
|
1563
|
+
tryCse(node, parent, idx)
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
for (let i = bodyStart; i < fn.length; i++) walk(fn[i], fn, i)
|
|
1567
|
+
|
|
1568
|
+
if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1179
1571
|
/**
|
|
1180
1572
|
* Drop redundant zero-initialisation of fresh function-scope locals.
|
|
1181
1573
|
*
|
|
@@ -1297,7 +1689,7 @@ export function deadStoreElim(fn) {
|
|
|
1297
1689
|
// an implicit stack value (e.g. a `try_table` catch payload) — removing it
|
|
1298
1690
|
// would unbalance the stack.
|
|
1299
1691
|
if (op === 'drop' && node.length === 2 && isPure(node[1])) {
|
|
1300
|
-
dead.push({ parent: items,
|
|
1692
|
+
dead.push({ parent: items, node, drop: true })
|
|
1301
1693
|
}
|
|
1302
1694
|
|
|
1303
1695
|
// Local write tracking
|
|
@@ -1308,10 +1700,9 @@ export function deadStoreElim(fn) {
|
|
|
1308
1700
|
// if its RHS is pure — `local.set $x (call f …)` where `f` mutates
|
|
1309
1701
|
// memory must still run. (A `local.tee` is always safe: removal demotes
|
|
1310
1702
|
// it to its value expression, so any side effects there are preserved.)
|
|
1311
|
-
|
|
1312
|
-
if (pn[0] === 'local.tee' || isPure(pn[2])) dead.push(prev)
|
|
1703
|
+
if (prev.node[0] === 'local.tee' || isPure(prev.node[2])) dead.push(prev)
|
|
1313
1704
|
}
|
|
1314
|
-
lastWrite.set(node[1], { parent: items,
|
|
1705
|
+
lastWrite.set(node[1], { parent: items, node })
|
|
1315
1706
|
}
|
|
1316
1707
|
|
|
1317
1708
|
// Recurse into nested blocks with fresh state
|
|
@@ -1336,20 +1727,20 @@ export function deadStoreElim(fn) {
|
|
|
1336
1727
|
|
|
1337
1728
|
scanBlock(fn, bodyStart, fn.length)
|
|
1338
1729
|
|
|
1339
|
-
//
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1730
|
+
// Removal is IDENTITY-based: entries are pushed at SUPERSEDE time, so
|
|
1731
|
+
// same-parent indices are not monotonic (name A's earlier write can be
|
|
1732
|
+
// superseded after name B's later one). Index-order splicing then shifts
|
|
1733
|
+
// remaining entries onto innocent neighbors — the self-host L2 divergence
|
|
1734
|
+
// deleted a typed-literal f64.store exactly this way. Re-locating each
|
|
1735
|
+
// captured node at removal time is immune to any ordering or prior splice.
|
|
1736
|
+
for (const d of dead) {
|
|
1737
|
+
const at = d.parent.indexOf(d.node)
|
|
1738
|
+
if (at < 0) continue // already removed (nested duplicate) — nothing to do
|
|
1739
|
+
if (!d.drop && d.node[0] === 'local.tee') {
|
|
1740
|
+
// tee in statement position: replace with just the value (implicitly dropped)
|
|
1741
|
+
d.parent[at] = d.node[2]
|
|
1344
1742
|
} else {
|
|
1345
|
-
|
|
1346
|
-
if (node[0] === 'local.tee') {
|
|
1347
|
-
// tee in statement position: replace with just the value (implicitly dropped)
|
|
1348
|
-
d.parent[d.idx] = node[2]
|
|
1349
|
-
} else {
|
|
1350
|
-
// set in statement position: remove entirely
|
|
1351
|
-
d.parent.splice(d.idx, 1)
|
|
1352
|
-
}
|
|
1743
|
+
d.parent.splice(at, 1)
|
|
1353
1744
|
}
|
|
1354
1745
|
}
|
|
1355
1746
|
}
|
|
@@ -1384,6 +1775,196 @@ export function collectVolatileGlobals(funcs) {
|
|
|
1384
1775
|
return volatile
|
|
1385
1776
|
}
|
|
1386
1777
|
|
|
1778
|
+
/**
|
|
1779
|
+
* Transitive global-write sets per function: name → Set of globals the function
|
|
1780
|
+
* writes directly OR through any (transitively) called function. The precise
|
|
1781
|
+
* complement to `collectVolatileGlobals`' coarse module-wide set — a global
|
|
1782
|
+
* written only by `init` is volatile module-wide, yet perfectly stable inside
|
|
1783
|
+
* a function whose call graph never reaches `init`.
|
|
1784
|
+
*
|
|
1785
|
+
* Unknown callees (imports — absent from the module's func list) write nothing:
|
|
1786
|
+
* wasm imports cannot touch module globals. `call_indirect`/`call_ref` targets
|
|
1787
|
+
* are unknown wasm functions — treat as writing every global any function
|
|
1788
|
+
* writes (the sound over-approximation).
|
|
1789
|
+
*/
|
|
1790
|
+
export function collectReachableGlobalWrites(funcs) {
|
|
1791
|
+
const writes = new Map(), callees = new Map(), indirect = new Set(), all = new Set()
|
|
1792
|
+
for (const fn of funcs) {
|
|
1793
|
+
if (!Array.isArray(fn) || fn[0] !== 'func' || typeof fn[1] !== 'string') continue
|
|
1794
|
+
const w = new Set(), c = new Set()
|
|
1795
|
+
const scan = (n) => {
|
|
1796
|
+
if (!Array.isArray(n)) return
|
|
1797
|
+
if (n[0] === 'global.set' && typeof n[1] === 'string') { w.add(n[1]); all.add(n[1]) }
|
|
1798
|
+
else if ((n[0] === 'call' || n[0] === 'return_call') && typeof n[1] === 'string') c.add(n[1])
|
|
1799
|
+
else if (n[0] === 'call_indirect' || n[0] === 'call_ref' || n[0] === 'return_call_indirect') indirect.add(fn[1])
|
|
1800
|
+
for (let i = 1; i < n.length; i++) scan(n[i])
|
|
1801
|
+
}
|
|
1802
|
+
for (let i = 2; i < fn.length; i++) scan(fn[i])
|
|
1803
|
+
writes.set(fn[1], w); callees.set(fn[1], c)
|
|
1804
|
+
}
|
|
1805
|
+
// Worklist fixpoint over the call graph.
|
|
1806
|
+
let changed = true
|
|
1807
|
+
while (changed) {
|
|
1808
|
+
changed = false
|
|
1809
|
+
for (const [name, w] of writes) {
|
|
1810
|
+
const before = w.size
|
|
1811
|
+
if (indirect.has(name)) for (const g of all) w.add(g)
|
|
1812
|
+
for (const callee of callees.get(name)) {
|
|
1813
|
+
const cw = writes.get(callee)
|
|
1814
|
+
if (cw) for (const g of cw) w.add(g)
|
|
1815
|
+
}
|
|
1816
|
+
if (w.size !== before) changed = true
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
return writes
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
/**
|
|
1823
|
+
* Hoist `__ptr_offset` resolution of stable typed-array GLOBALS to one resolve
|
|
1824
|
+
* per function. Locals get their pointer unboxed once at bind time, but a
|
|
1825
|
+
* module-global typed array (`let x; init = () => { x = new Float64Array(n) }`
|
|
1826
|
+
* — the idiomatic DSP-state shape: rfft, game-of-life, diffusion)
|
|
1827
|
+
* re-resolves on EVERY element access:
|
|
1828
|
+
* (call $__ptr_offset (i64.reinterpret_f64 (global.get $x)))
|
|
1829
|
+
* — 68 such calls in rfft's transform alone, ~7× slower than V8. LICM can't
|
|
1830
|
+
* hoist them out of loops: its global-invariance rule requires a call-free
|
|
1831
|
+
* loop, and the resolve itself is a call. promoteGlobals can't either: `init`
|
|
1832
|
+
* writes the global, so it's volatile module-wide.
|
|
1833
|
+
*
|
|
1834
|
+
* The precise facts make it sound here: TYPED pointees never forward (only
|
|
1835
|
+
* ARRAY/SET/MAP do — same bits ⇒ same offset), so the snapshot is stable iff
|
|
1836
|
+
* the global's VALUE is stable through the function — i.e. the function
|
|
1837
|
+
* neither writes G itself nor (transitively) calls anything that does
|
|
1838
|
+
* (`collectReachableGlobalWrites`). The entry-time resolve is total
|
|
1839
|
+
* (`__ptr_offset` bounds-checks garbage to itself), so hoisting past a
|
|
1840
|
+
* zero-trip loop or an early return is safe.
|
|
1841
|
+
*
|
|
1842
|
+
* @param {Array} fn - func IR node
|
|
1843
|
+
* @param {Set<string>} stablePtrGlobals - '$name's of VAL.TYPED module globals
|
|
1844
|
+
* @param {Map<string,Set<string>>} reachableWrites - from collectReachableGlobalWrites
|
|
1845
|
+
*/
|
|
1846
|
+
// Never-forwarding pointee kinds: every PTR tag outside __ptr_offset's
|
|
1847
|
+
// forwarding set {ARRAY, HASH, SET, MAP} — same bits ⇒ same offset.
|
|
1848
|
+
export const STABLE_PTR_VALS = new Set([VAL.TYPED, VAL.STRING, VAL.OBJECT, VAL.BUFFER, VAL.CLOSURE])
|
|
1849
|
+
|
|
1850
|
+
/** '$name' set of stable-pointee module globals (hoistGlobalPtrOffset targets). */
|
|
1851
|
+
export const stablePtrGlobalNames = () => {
|
|
1852
|
+
const out = new Set()
|
|
1853
|
+
if (ctx.scope.globalValTypes)
|
|
1854
|
+
for (const [k, v] of ctx.scope.globalValTypes) if (STABLE_PTR_VALS.has(v)) out.add(`$${k}`)
|
|
1855
|
+
return out
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
export function hoistGlobalPtrOffset(fn, stablePtrGlobals, reachableWrites) {
|
|
1859
|
+
if (!Array.isArray(fn) || fn[0] !== 'func' || !stablePtrGlobals?.size) return
|
|
1860
|
+
const bodyStart = findBodyStart(fn)
|
|
1861
|
+
if (bodyStart < 0) return
|
|
1862
|
+
|
|
1863
|
+
// `(i64.reinterpret_f64 (global.get $G))` → G, or null.
|
|
1864
|
+
const reintGlobal = (n) =>
|
|
1865
|
+
Array.isArray(n) && n[0] === 'i64.reinterpret_f64'
|
|
1866
|
+
&& Array.isArray(n[1]) && n[1][0] === 'global.get' && typeof n[1][1] === 'string'
|
|
1867
|
+
? n[1][1] : null
|
|
1868
|
+
// A stable-pointee global's byte-base reaches us in two interchangeable shapes:
|
|
1869
|
+
// • forwarding-aware `(call $__ptr_offset (i64.reinterpret_f64 (global.get $G)))`
|
|
1870
|
+
// • inline typed read `(i32.wrap_i64 (i64.and (i64.reinterpret_f64 (global.get $G)) MASK))`
|
|
1871
|
+
// The inline form is what typed-array reads emit (a fixed-size typed array never
|
|
1872
|
+
// relocates, so they skip __ptr_offset's forwarding follow — see module/typedarray.js
|
|
1873
|
+
// `typedBase`). For a never-forwarding pointee both yield the identical offset, so
|
|
1874
|
+
// either site hoists to the one `__ptr_offset` entry snapshot. Matching only the
|
|
1875
|
+
// call form left typed-array globals re-decoding the NaN-box per element in stencil
|
|
1876
|
+
// sweeps (watercolor's pressure solve: 5 reads/cell × millions of cells). → G, or null.
|
|
1877
|
+
const siteGlobal = (n) => {
|
|
1878
|
+
if (!Array.isArray(n)) return null
|
|
1879
|
+
if (n[0] === 'call' && n[1] === '$__ptr_offset' && n.length === 3) return reintGlobal(n[2])
|
|
1880
|
+
if (n[0] === 'i32.wrap_i64' && n.length === 2 && Array.isArray(n[1]) && n[1][0] === 'i64.and' && n[1].length === 3) {
|
|
1881
|
+
const mask = n[1][2]
|
|
1882
|
+
if (Array.isArray(mask) && mask[0] === 'i64.const'
|
|
1883
|
+
&& (typeof mask[1] === 'string' ? Number(mask[1]) : mask[1]) === LAYOUT.OFFSET_MASK)
|
|
1884
|
+
return reintGlobal(n[1][1])
|
|
1885
|
+
}
|
|
1886
|
+
return null
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
// Per-global: static site count AND whether any site sits inside a loop. A
|
|
1890
|
+
// single in-loop site is a per-ITERATION resolve (lenia's convolution reads
|
|
1891
|
+
// each of kdx/kdy/kw at one site × ~14M taps/frame), so loop placement beats
|
|
1892
|
+
// site count as the hoist criterion.
|
|
1893
|
+
const counts = new Map(), inLoop = new Set(), ownWrites = new Set(), ownCallees = new Set()
|
|
1894
|
+
// Globals seen via the `__ptr_offset` call form (vs. only the inline typed mask).
|
|
1895
|
+
// The snapshot reuses an EXISTING form so it never resurrects a treeshaken helper:
|
|
1896
|
+
// a typed-array-only module emits no `__ptr_offset` call, so snapping one in would
|
|
1897
|
+
// reference a function that isn't in the module.
|
|
1898
|
+
const ptrOffsetForm = new Set()
|
|
1899
|
+
let hasIndirect = false
|
|
1900
|
+
const scan = (n, loopDepth) => {
|
|
1901
|
+
if (!Array.isArray(n)) return
|
|
1902
|
+
const g = siteGlobal(n)
|
|
1903
|
+
if (g != null) {
|
|
1904
|
+
counts.set(g, (counts.get(g) || 0) + 1)
|
|
1905
|
+
if (loopDepth > 0) inLoop.add(g)
|
|
1906
|
+
if (n[0] === 'call') ptrOffsetForm.add(g)
|
|
1907
|
+
return
|
|
1908
|
+
}
|
|
1909
|
+
if (n[0] === 'global.set' && typeof n[1] === 'string') ownWrites.add(n[1])
|
|
1910
|
+
else if ((n[0] === 'call' || n[0] === 'return_call') && typeof n[1] === 'string') ownCallees.add(n[1])
|
|
1911
|
+
else if (n[0] === 'call_indirect' || n[0] === 'call_ref' || n[0] === 'return_call_indirect') hasIndirect = true
|
|
1912
|
+
const d = n[0] === 'loop' ? loopDepth + 1 : loopDepth
|
|
1913
|
+
for (let i = 1; i < n.length; i++) scan(n[i], d)
|
|
1914
|
+
}
|
|
1915
|
+
for (let i = bodyStart; i < fn.length; i++) scan(fn[i], 0)
|
|
1916
|
+
if (!counts.size) return
|
|
1917
|
+
|
|
1918
|
+
const calleeWrites = (g) => {
|
|
1919
|
+
if (hasIndirect) return true // unknown targets — assume they write
|
|
1920
|
+
for (const c of ownCallees) if (reachableWrites?.get(c)?.has(g)) return true
|
|
1921
|
+
return false
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
// Collision-proof snap ids (same scheme as hoistInvariantLoop's $__li).
|
|
1925
|
+
const used = new Set()
|
|
1926
|
+
const scanIds = (n) => {
|
|
1927
|
+
if (!Array.isArray(n)) return
|
|
1928
|
+
if (n[0] === 'local' && typeof n[1] === 'string' && n[1].startsWith('$__go')) {
|
|
1929
|
+
const t = n[1].slice(5); if (/^\d+$/.test(t)) used.add(+t)
|
|
1930
|
+
}
|
|
1931
|
+
for (let i = 0; i < n.length; i++) scanIds(n[i])
|
|
1932
|
+
}
|
|
1933
|
+
scanIds(fn)
|
|
1934
|
+
let idCounter = 0
|
|
1935
|
+
const freshId = () => { while (used.has(idCounter)) idCounter++; const id = idCounter++; used.add(id); return `$__go${id}` }
|
|
1936
|
+
|
|
1937
|
+
const chosen = new Map() // global → snap local
|
|
1938
|
+
for (const [g, c] of counts) {
|
|
1939
|
+
if ((c < 2 && !inLoop.has(g)) || !stablePtrGlobals.has(g) || ownWrites.has(g) || calleeWrites(g)) continue
|
|
1940
|
+
chosen.set(g, freshId())
|
|
1941
|
+
}
|
|
1942
|
+
if (!chosen.size) return
|
|
1943
|
+
|
|
1944
|
+
const replace = (n) => {
|
|
1945
|
+
if (!Array.isArray(n)) return
|
|
1946
|
+
for (let i = 1; i < n.length; i++) {
|
|
1947
|
+
const g = siteGlobal(n[i])
|
|
1948
|
+
if (g != null && chosen.has(g)) n[i] = ['local.get', chosen.get(g)]
|
|
1949
|
+
else replace(n[i])
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
for (let i = bodyStart; i < fn.length; i++) replace(fn[i])
|
|
1953
|
+
|
|
1954
|
+
const decls = [], snaps = []
|
|
1955
|
+
for (const [g, name] of chosen) {
|
|
1956
|
+
decls.push(['local', name, 'i32'])
|
|
1957
|
+
// Match an existing site's form so we never reference a treeshaken helper.
|
|
1958
|
+
// For a never-forwarding pointee both forms compute the same offset, so the
|
|
1959
|
+
// inline mask is a safe (and call-free) snapshot when no __ptr_offset site exists.
|
|
1960
|
+
const snap = ptrOffsetForm.has(g)
|
|
1961
|
+
? ['call', '$__ptr_offset', ['i64.reinterpret_f64', ['global.get', g]]]
|
|
1962
|
+
: ['i32.wrap_i64', ['i64.and', ['i64.reinterpret_f64', ['global.get', g]], ['i64.const', LAYOUT.OFFSET_MASK]]]
|
|
1963
|
+
snaps.push(['local.set', name, snap])
|
|
1964
|
+
}
|
|
1965
|
+
fn.splice(bodyStart, 0, ...decls, ...snaps)
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1387
1968
|
/**
|
|
1388
1969
|
* Promote read-only globals to locals within each function.
|
|
1389
1970
|
*
|
|
@@ -1408,7 +1989,7 @@ export function collectVolatileGlobals(funcs) {
|
|
|
1408
1989
|
* @param {Map<string,string>} [globalTypes] - Optional: global name → wasm type ('i32'|'f64'|'i64'|'funcref')
|
|
1409
1990
|
* @param {Set<string>} [volatileGlobals] - Optional: globals mutated outside `$__start` (see collectVolatileGlobals)
|
|
1410
1991
|
*/
|
|
1411
|
-
export function promoteGlobals(fn, globalTypes, volatileGlobals) {
|
|
1992
|
+
export function promoteGlobals(fn, globalTypes, volatileGlobals, reachableWrites) {
|
|
1412
1993
|
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
1413
1994
|
const bodyStart = findBodyStart(fn)
|
|
1414
1995
|
if (bodyStart < 0) return
|
|
@@ -1416,8 +1997,8 @@ export function promoteGlobals(fn, globalTypes, volatileGlobals) {
|
|
|
1416
1997
|
// Collect global.get counts, detect any global.set, and note whether the
|
|
1417
1998
|
// function makes a call (a callee may mutate a volatile global between reads).
|
|
1418
1999
|
const getCounts = new Map() // globalName → count
|
|
1419
|
-
const written = new Set()
|
|
1420
|
-
let hasCall = false
|
|
2000
|
+
const written = new Set(), callees = new Set()
|
|
2001
|
+
let hasCall = false, hasIndirect = false
|
|
1421
2002
|
|
|
1422
2003
|
const scan = (node) => {
|
|
1423
2004
|
if (!Array.isArray(node)) return
|
|
@@ -1431,7 +2012,8 @@ export function promoteGlobals(fn, globalTypes, volatileGlobals) {
|
|
|
1431
2012
|
if (node[2]) scan(node[2])
|
|
1432
2013
|
return
|
|
1433
2014
|
}
|
|
1434
|
-
if (op === 'call' || op === '
|
|
2015
|
+
if (op === 'call' || op === 'return_call') { hasCall = true; if (typeof node[1] === 'string') callees.add(node[1]) }
|
|
2016
|
+
else if (op === 'call_indirect' || op === 'call_ref' || op === 'return_call_indirect') { hasCall = true; hasIndirect = true }
|
|
1435
2017
|
for (let i = 1; i < node.length; i++) scan(node[i])
|
|
1436
2018
|
}
|
|
1437
2019
|
|
|
@@ -1452,7 +2034,12 @@ export function promoteGlobals(fn, globalTypes, volatileGlobals) {
|
|
|
1452
2034
|
for (const [gName, count] of getCounts) {
|
|
1453
2035
|
if (count < 3 || written.has(gName)) continue
|
|
1454
2036
|
// Unsound to cache a callee-mutable global across a call in this function.
|
|
1455
|
-
|
|
2037
|
+
// With reachableWrites the test is exact per call edge (a global written
|
|
2038
|
+
// only by init stays promotable in functions whose call graph never
|
|
2039
|
+
// reaches init); without it, fall back to the coarse module-wide set.
|
|
2040
|
+
if (hasCall && (reachableWrites
|
|
2041
|
+
? (hasIndirect || [...callees].some(c => reachableWrites.get(c)?.has(gName)))
|
|
2042
|
+
: volatileGlobals?.has(gName))) continue
|
|
1456
2043
|
// Determine type: use provided map, or infer from context
|
|
1457
2044
|
const type = globalTypes?.get(gName) || inferTypeFromContext(fn, gName, bodyStart)
|
|
1458
2045
|
if (!type) continue // can't determine type, skip
|
|
@@ -1543,7 +2130,7 @@ function inferTypeFromContext(fn, gName, bodyStart) {
|
|
|
1543
2130
|
* Pool entries sorted by usage descending, so hottest get lowest indices (1-byte LEB128).
|
|
1544
2131
|
* Break-even: N ≥ 2 uses (pool cost: 11 B global decl + 2N bytes vs 9N original).
|
|
1545
2132
|
*
|
|
1546
|
-
* Mutates `funcs` in place; writes new global decls via `addGlobal(name,
|
|
2133
|
+
* Mutates `funcs` in place; writes new global decls via `addGlobal(name, constLiteral)`.
|
|
1547
2134
|
*/
|
|
1548
2135
|
export function hoistConstantPool(funcs, addGlobal) {
|
|
1549
2136
|
const MIN_USES = 2
|
|
@@ -1574,7 +2161,7 @@ export function hoistConstantPool(funcs, addGlobal) {
|
|
|
1574
2161
|
for (const [k] of sorted) {
|
|
1575
2162
|
const name = `__fc${gId++}`
|
|
1576
2163
|
const lit = k.slice(2)
|
|
1577
|
-
addGlobal(name,
|
|
2164
|
+
addGlobal(name, lit)
|
|
1578
2165
|
hoist.set(k, name)
|
|
1579
2166
|
}
|
|
1580
2167
|
if (!hoist.size) return
|
|
@@ -1623,7 +2210,12 @@ export function specializeMkptr(funcs, addFunc, parseWat) {
|
|
|
1623
2210
|
'$__typed_idx': { params: ['i64', 'i32'], result: 'f64' },
|
|
1624
2211
|
'$__str_idx': { params: ['i64', 'i32'], result: 'f64' },
|
|
1625
2212
|
}
|
|
1626
|
-
|
|
2213
|
+
// 4 is the measured break-even: a specialized helper (trampoline / inline i64.const
|
|
2214
|
+
// template) costs ~12 B to define and saves ~2–4 B per site, so 4 sites amortize it.
|
|
2215
|
+
// Lower (3) net-inflates the watr self-host; 5 leaves 4-use combos on the table. The
|
|
2216
|
+
// sibling specializePtrBase threshold (20) is already optimal — its combos cluster far
|
|
2217
|
+
// above 20 (the ~2 k-site $__strBase relativization) with nothing in the 5–19 band.
|
|
2218
|
+
const MIN_USES = 4
|
|
1627
2219
|
|
|
1628
2220
|
// Build literal-arg signature key for a call node. Returns null if no args are literal.
|
|
1629
2221
|
// Key format: 'T:V' per literal arg, 'D' per dynamic; indexed by position.
|
|
@@ -1909,6 +2501,146 @@ export function sortStrPoolByFreq(funcs, strPoolRef, strDedupMap) {
|
|
|
1909
2501
|
}
|
|
1910
2502
|
}
|
|
1911
2503
|
|
|
2504
|
+
/**
|
|
2505
|
+
* Fold dead string-dispatch blocks when the tested operand is a proven-f64 local.
|
|
2506
|
+
*
|
|
2507
|
+
* jz's `+` emitter produces, for every binary addition whose right operand has an
|
|
2508
|
+
* unresolved valType, the pattern:
|
|
2509
|
+
*
|
|
2510
|
+
* (block (result f64)
|
|
2511
|
+
* (local.set $B EXPR_A)
|
|
2512
|
+
* (if (result f64)
|
|
2513
|
+
* (call $__is_str_key (i64.reinterpret_f64 (local.tee $C (local.get $P))))
|
|
2514
|
+
* (then (call $__str_concat …))
|
|
2515
|
+
* (else (f64.add (local.get $B) (local.get $C)))))
|
|
2516
|
+
*
|
|
2517
|
+
* When $P is a proven-f64 local (an f64 param, or an f64-typed local provably set
|
|
2518
|
+
* only from f64 arithmetic) it can never hold a string-key NaN-box, so the
|
|
2519
|
+
* `$__is_str_key` test is provably false and the `then` branch is dead.
|
|
2520
|
+
* Replace the whole block with `(f64.add EXPR_A (local.get $P))`.
|
|
2521
|
+
*
|
|
2522
|
+
* SOUND: f64 params can never hold a string-key NaN-box by construction (jz
|
|
2523
|
+
* only allows strings in f64 slots via explicit mkptr boxing, never a bare
|
|
2524
|
+
* param). This fold is additive/gated (only runs when vectorizeLaneLocal is on)
|
|
2525
|
+
* and only removes provably-dead string-dispatch overhead.
|
|
2526
|
+
*
|
|
2527
|
+
* Called in the 'post' phase of optimizeFunc, before vectorizeLaneLocal, so the
|
|
2528
|
+
* cleaned IR is what the vectorizer pattern-matches.
|
|
2529
|
+
*/
|
|
2530
|
+
export function foldStrDispatchF64(fn) {
|
|
2531
|
+
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
2532
|
+
const bodyStart = findBodyStart(fn)
|
|
2533
|
+
if (bodyStart < 0) return
|
|
2534
|
+
|
|
2535
|
+
// Collect all f64 params — provably never hold a string-key NaN-box.
|
|
2536
|
+
const rawF64 = new Set()
|
|
2537
|
+
for (let i = 2; i < bodyStart; i++) {
|
|
2538
|
+
const d = fn[i]
|
|
2539
|
+
if (Array.isArray(d) && d[0] === 'param' && typeof d[1] === 'string' && d[2] === 'f64')
|
|
2540
|
+
rawF64.add(d[1])
|
|
2541
|
+
}
|
|
2542
|
+
if (!rawF64.size) return // no f64 params → nothing to fold
|
|
2543
|
+
|
|
2544
|
+
// Transitively extend rawF64: an f64 local set only via f64 arithmetic over rawF64
|
|
2545
|
+
// members is itself provably non-string. One forward pass suffices for DAG-shaped
|
|
2546
|
+
// straight-line code (the common case); a fixed-point loop covers rare mutual defs.
|
|
2547
|
+
// Collect local types first.
|
|
2548
|
+
const localTypeMap = new Map()
|
|
2549
|
+
for (let i = 2; i < bodyStart; i++) {
|
|
2550
|
+
const d = fn[i]
|
|
2551
|
+
if (Array.isArray(d) && (d[0] === 'param' || d[0] === 'local') && typeof d[1] === 'string')
|
|
2552
|
+
localTypeMap.set(d[1], d[2])
|
|
2553
|
+
}
|
|
2554
|
+
// An expression is rawF64-valued if it only uses ops that stay in f64 and
|
|
2555
|
+
// reads only rawF64 locals (or f64.const). Stops early — we only need the
|
|
2556
|
+
// closed set for the pattern's $P operand.
|
|
2557
|
+
const isRawF64Expr = (n) => {
|
|
2558
|
+
if (!Array.isArray(n)) return false
|
|
2559
|
+
const op = n[0]
|
|
2560
|
+
if (op === 'f64.const') return true
|
|
2561
|
+
if (op === 'local.get' && typeof n[1] === 'string') return rawF64.has(n[1])
|
|
2562
|
+
if (op === 'local.tee' && typeof n[1] === 'string') return rawF64.has(n[1]) && isRawF64Expr(n[2])
|
|
2563
|
+
if (op === 'f64.add' || op === 'f64.sub' || op === 'f64.mul' || op === 'f64.div' ||
|
|
2564
|
+
op === 'f64.neg' || op === 'f64.abs' || op === 'f64.sqrt') {
|
|
2565
|
+
return n.slice(1).every(isRawF64Expr)
|
|
2566
|
+
}
|
|
2567
|
+
return false
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
// Single forward pass: a local.set $v EXPR where EXPR is rawF64-valued makes $v rawF64.
|
|
2571
|
+
// Repeat until stable (handles ordering edge cases in non-DAG code).
|
|
2572
|
+
let changed = true
|
|
2573
|
+
while (changed) {
|
|
2574
|
+
changed = false
|
|
2575
|
+
const scan = (node) => {
|
|
2576
|
+
if (!Array.isArray(node)) return
|
|
2577
|
+
if ((node[0] === 'local.set' || node[0] === 'local.tee') && typeof node[1] === 'string' &&
|
|
2578
|
+
localTypeMap.get(node[1]) === 'f64' && !rawF64.has(node[1]) && isRawF64Expr(node[2])) {
|
|
2579
|
+
rawF64.add(node[1]); changed = true
|
|
2580
|
+
}
|
|
2581
|
+
for (let i = 1; i < node.length; i++) scan(node[i])
|
|
2582
|
+
}
|
|
2583
|
+
for (let i = bodyStart; i < fn.length; i++) scan(fn[i])
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
// Pattern-match and fold in-place (bottom-up recursive walk so nested blocks resolve).
|
|
2587
|
+
const foldNode = (node) => {
|
|
2588
|
+
if (!Array.isArray(node)) return node
|
|
2589
|
+
// Recurse children first (bottom-up).
|
|
2590
|
+
for (let i = 0; i < node.length; i++) {
|
|
2591
|
+
const c = node[i]
|
|
2592
|
+
if (Array.isArray(c)) node[i] = foldNode(c)
|
|
2593
|
+
}
|
|
2594
|
+
// Match:
|
|
2595
|
+
// ['block', ['result','f64'],
|
|
2596
|
+
// ['local.set', $B, EXPR_A],
|
|
2597
|
+
// ['if', ['result','f64'],
|
|
2598
|
+
// ['call','$__is_str_key', ['i64.reinterpret_f64', ['local.tee',$C,['local.get',$P]]]],
|
|
2599
|
+
// ['then', ['call','$__str_concat',...]],
|
|
2600
|
+
// ['else', ['f64.add', ['local.get',$B], ['local.get',$C]]]]]
|
|
2601
|
+
if (node[0] !== 'block') return node
|
|
2602
|
+
if (!Array.isArray(node[1]) || node[1][0] !== 'result' || node[1][1] !== 'f64') return node
|
|
2603
|
+
if (node.length !== 4) return node
|
|
2604
|
+
const setStmt = node[2], ifStmt = node[3]
|
|
2605
|
+
if (!Array.isArray(setStmt) || setStmt[0] !== 'local.set' || typeof setStmt[1] !== 'string') return node
|
|
2606
|
+
const B = setStmt[1], exprA = setStmt[2]
|
|
2607
|
+
if (!Array.isArray(ifStmt) || ifStmt[0] !== 'if') return node
|
|
2608
|
+
// if must have: (result f64), cond, then, else — total 5 elements
|
|
2609
|
+
if (ifStmt.length !== 5) return node
|
|
2610
|
+
if (!Array.isArray(ifStmt[1]) || ifStmt[1][0] !== 'result' || ifStmt[1][1] !== 'f64') return node
|
|
2611
|
+
const cond = ifStmt[2], thenB = ifStmt[3], elseB = ifStmt[4]
|
|
2612
|
+
// cond: ['call','$__is_str_key',['i64.reinterpret_f64',['local.tee',$C,['local.get',$P]]]]
|
|
2613
|
+
if (!Array.isArray(cond) || cond[0] !== 'call' || cond[1] !== '$__is_str_key' || cond.length !== 3) return node
|
|
2614
|
+
const reinterpArg = cond[2]
|
|
2615
|
+
if (!Array.isArray(reinterpArg) || reinterpArg[0] !== 'i64.reinterpret_f64' || reinterpArg.length !== 2) return node
|
|
2616
|
+
const teeNode = reinterpArg[1]
|
|
2617
|
+
if (!Array.isArray(teeNode) || teeNode[0] !== 'local.tee' || typeof teeNode[1] !== 'string' || teeNode.length !== 3) return node
|
|
2618
|
+
const C = teeNode[1]
|
|
2619
|
+
const getP = teeNode[2]
|
|
2620
|
+
if (!Array.isArray(getP) || getP[0] !== 'local.get' || typeof getP[1] !== 'string') return node
|
|
2621
|
+
const P = getP[1]
|
|
2622
|
+
// $P must be a proven f64 local (never a string-key NaN-box)
|
|
2623
|
+
if (!rawF64.has(P)) return node
|
|
2624
|
+
// then: ['then', ['call','$__str_concat',...]]
|
|
2625
|
+
if (!Array.isArray(thenB) || thenB[0] !== 'then') return node
|
|
2626
|
+
// else: ['else', ['f64.add', ['local.get',$B], ['local.get',$C]]]
|
|
2627
|
+
if (!Array.isArray(elseB) || elseB[0] !== 'else' || elseB.length !== 2) return node
|
|
2628
|
+
const addExpr = elseB[1]
|
|
2629
|
+
if (!Array.isArray(addExpr) || addExpr[0] !== 'f64.add' || addExpr.length !== 3) return node
|
|
2630
|
+
// The two operands of f64.add must be local.get $B and local.get $C (in either order)
|
|
2631
|
+
const [lhsAdd, rhsAdd] = [addExpr[1], addExpr[2]]
|
|
2632
|
+
const lhsIsB = Array.isArray(lhsAdd) && lhsAdd[0] === 'local.get' && lhsAdd[1] === B
|
|
2633
|
+
const rhsIsC = Array.isArray(rhsAdd) && rhsAdd[0] === 'local.get' && rhsAdd[1] === C
|
|
2634
|
+
const lhsIsC = Array.isArray(lhsAdd) && lhsAdd[0] === 'local.get' && lhsAdd[1] === C
|
|
2635
|
+
const rhsIsB = Array.isArray(rhsAdd) && rhsAdd[0] === 'local.get' && rhsAdd[1] === B
|
|
2636
|
+
if (!((lhsIsB && rhsIsC) || (lhsIsC && rhsIsB))) return node
|
|
2637
|
+
// Match confirmed. Fold to: (f64.add EXPR_A (local.get $P))
|
|
2638
|
+
return ['f64.add', exprA, ['local.get', P]]
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
for (let i = bodyStart; i < fn.length; i++) fn[i] = foldNode(fn[i])
|
|
2642
|
+
}
|
|
2643
|
+
|
|
1912
2644
|
/**
|
|
1913
2645
|
* Run all per-function IR optimizations on a single function node.
|
|
1914
2646
|
* hoistPtrType runs first — it introduces new locals (`$__ptN`) that the fused
|
|
@@ -1921,14 +2653,16 @@ export function sortStrPoolByFreq(funcs, strPoolRef, strDedupMap) {
|
|
|
1921
2653
|
* @param cfg optional resolved config from resolveOptimize() — when omitted, all on.
|
|
1922
2654
|
* @param globalTypes optional global name → wasm type map (for promoteGlobals)
|
|
1923
2655
|
* @param volatileGlobals optional set of callee-mutable globals (see collectVolatileGlobals)
|
|
2656
|
+
* @param phase 'pre' (default, pre-watr leaf pass) or 'post' (re-run after watr) —
|
|
2657
|
+
* gates the passes that only pay off once watr has reshaped the IR.
|
|
1924
2658
|
*/
|
|
1925
|
-
export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals) {
|
|
2659
|
+
export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre', reachableWrites) {
|
|
1926
2660
|
if (cfg && cfg.hoistPtrType === false &&
|
|
1927
2661
|
cfg.hoistInvariantPtrOffset === false &&
|
|
1928
|
-
cfg.
|
|
2662
|
+
cfg.hoistInvariantLoop === false &&
|
|
2663
|
+
cfg.narrowLoopBound === false &&
|
|
1929
2664
|
cfg.fusedRewrite === false &&
|
|
1930
2665
|
cfg.hoistAddrBase === false &&
|
|
1931
|
-
cfg.hoistInvariantCellLoads === false &&
|
|
1932
2666
|
cfg.cseScalarLoad === false &&
|
|
1933
2667
|
cfg.csePureExpr === false &&
|
|
1934
2668
|
cfg.dropDeadZeroInit === false &&
|
|
@@ -1938,27 +2672,67 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals) {
|
|
|
1938
2672
|
cfg.vectorizeLaneLocal === false) return
|
|
1939
2673
|
if (!cfg || cfg.hoistPtrType !== false) hoistPtrType(fn)
|
|
1940
2674
|
if (!cfg || cfg.hoistInvariantPtrOffset !== false) hoistInvariantPtrOffset(fn)
|
|
1941
|
-
|
|
2675
|
+
// Before LICM: the snapped i32 bound is itself a hoistable hard-op subtree, so
|
|
2676
|
+
// an outer loop's LICM can lift it further when the bound is outer-invariant.
|
|
2677
|
+
if (!cfg || cfg.narrowLoopBound !== false) narrowLoopBound(fn)
|
|
2678
|
+
// Unified LICM (replaces hoistInvariantToInt32 / PtrOffsetLoop / CellLoads).
|
|
2679
|
+
// Run at both maturity points (idempotent): pre-fusedRewrite catches the raw
|
|
2680
|
+
// ToInt32/ptr-offset/arithmetic shapes; post-hoistAddrBase catches cell loads.
|
|
2681
|
+
if (!cfg || cfg.hoistInvariantLoop !== false) hoistInvariantLoop(fn)
|
|
1942
2682
|
const counts = new Map()
|
|
1943
2683
|
if (!cfg || cfg.fusedRewrite !== false) fusedRewrite(fn, counts)
|
|
1944
2684
|
if (!cfg || cfg.hoistAddrBase !== false) hoistAddrBase(fn)
|
|
1945
|
-
if (!cfg || cfg.
|
|
2685
|
+
if (!cfg || cfg.hoistInvariantLoop !== false) hoistInvariantLoop(fn)
|
|
1946
2686
|
if (!cfg || cfg.cseScalarLoad !== false) cseScalarLoad(fn)
|
|
1947
|
-
if (!cfg || cfg.csePureExpr !== false)
|
|
2687
|
+
if (!cfg || cfg.csePureExpr !== false) {
|
|
2688
|
+
if (cfg && (cfg.watr === true || typeof cfg.watr === 'object') && phase === 'post') csePureExprLoop(fn)
|
|
2689
|
+
else csePureExpr(fn)
|
|
2690
|
+
}
|
|
1948
2691
|
if (!cfg || cfg.dropDeadZeroInit !== false) dropDeadZeroInit(fn)
|
|
1949
2692
|
if (!cfg || cfg.deadStoreElim !== false) deadStoreElim(fn)
|
|
1950
|
-
if (!cfg || cfg.promoteGlobals !== false) promoteGlobals(fn, globalTypes, volatileGlobals)
|
|
2693
|
+
if (!cfg || cfg.promoteGlobals !== false) promoteGlobals(fn, globalTypes, volatileGlobals, reachableWrites)
|
|
1951
2694
|
// Vectorizer runs PRE-watr unless full watr is enabled (`watr: true`). For full watr,
|
|
1952
2695
|
// defer to post — full passes (notably `inlineOnce` + the post-inline `propagate`
|
|
1953
2696
|
// sweep) reshape the IR so much that pre-watr SIMD patterns get scrambled. Light
|
|
1954
2697
|
// watr (or no watr) leaves the lane locals intact for vectorize to pattern-match,
|
|
1955
2698
|
// and lets a non-trivial chunk of SIMD survive the propagate+fold pipeline.
|
|
1956
2699
|
if (cfg && cfg.vectorizeLaneLocal === true) {
|
|
1957
|
-
const fullWatr = cfg.watr === true
|
|
1958
|
-
const runVectorizer = (fullWatr &&
|
|
1959
|
-
|
|
2700
|
+
const fullWatr = cfg.watr === true || typeof cfg.watr === 'object'
|
|
2701
|
+
const runVectorizer = (fullWatr && phase === 'post') || (!fullWatr && phase !== 'post')
|
|
2702
|
+
// Phase 1: fold dead string-dispatch blocks on proven-f64 locals BEFORE
|
|
2703
|
+
// the vectorizer pattern-matches — dead __is_str_key calls in $fbm-style
|
|
2704
|
+
// functions (param f64 + op f64) block liftPPC from recognizing them as pure.
|
|
2705
|
+
if (runVectorizer) foldStrDispatchF64(fn)
|
|
2706
|
+
if (runVectorizer) vectorizeLaneLocal(fn, cfg.reduceUnroll === true, cfg.relaxedSimd === true, cfg.blurMultiPixel !== false, cfg.whyNotSimd === true, cfg.experimentalStencil !== false, cfg.experimentalOuterStrip !== false, cfg._pureFuncMap || null, cfg.experimentalToneMap !== false)
|
|
1960
2707
|
}
|
|
1961
2708
|
if (!cfg || cfg.sortLocalsByUse !== false) sortLocalsByUse(fn, cfg && cfg.fusedRewrite !== false ? counts : null)
|
|
2709
|
+
// An optimizer pass that emits a malformed local — the class that otherwise dies
|
|
2710
|
+
// as an opaque watr "Duplicate/Unknown local $x" several phases on — is caught
|
|
2711
|
+
// here, pinned to the function and the bad name.
|
|
2712
|
+
if (DBG_IR) { const bad = verifyFn(fn); if (bad) throw new Error(`[ir verify] optimize produced invalid IR in ${fn[1]}: ${bad}`) }
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
// The i32 form of an integer-valued f64 expression, or null. Used to push ToInt32
|
|
2716
|
+
// through a conditional and to collapse the f64 round-trip on integer `+`/`-`.
|
|
2717
|
+
// Lossless by construction: `convert_i32(X) → X`; integer `f64.const → i32.const`
|
|
2718
|
+
// (ToInt32); `f64.add/sub` of i32-valued operands → `i32.add/sub` (mod-2³² is a ring
|
|
2719
|
+
// homomorphism, and each i32±i32 < 2³² < 2⁵³ so the f64 op is exact). EXCLUDES `mul`
|
|
2720
|
+
// (products can exceed 2⁵³, so the f64 op loses precision and i32.mul wouldn't match)
|
|
2721
|
+
// and anything non-integer or unprovable. Address `local.tee`s inside operands are
|
|
2722
|
+
// preserved (kept as-is in the returned i32 tree).
|
|
2723
|
+
function toI32(n) {
|
|
2724
|
+
if (!Array.isArray(n)) return null
|
|
2725
|
+
const op = n[0]
|
|
2726
|
+
if ((op === 'f64.convert_i32_s' || op === 'f64.convert_i32_u') && n.length === 2) return n[1]
|
|
2727
|
+
// i32-range consts only: keeps every leaf within i32 so f64 add/sub of leaves stays exact
|
|
2728
|
+
// (< 2^53) and ToInt32-homomorphic. A larger const would round in f64.add or saturate in
|
|
2729
|
+
// trunc_sat differently from JS `|0`, breaking the fold.
|
|
2730
|
+
if (op === 'f64.const' && typeof n[1] === 'number' && (n[1] | 0) === n[1]) return ['i32.const', n[1]]
|
|
2731
|
+
if ((op === 'f64.add' || op === 'f64.sub') && n.length === 3) {
|
|
2732
|
+
const a = toI32(n[1]), b = toI32(n[2])
|
|
2733
|
+
if (a && b) return [op === 'f64.add' ? 'i32.add' : 'i32.sub', a, b]
|
|
2734
|
+
}
|
|
2735
|
+
return null
|
|
1962
2736
|
}
|
|
1963
2737
|
|
|
1964
2738
|
// Fused bottom-up walk applying three orthogonal pattern sets at each node:
|
|
@@ -1973,7 +2747,7 @@ function fusedRewrite(fn, counts) {
|
|
|
1973
2747
|
if (Array.isArray(fn)) {
|
|
1974
2748
|
for (let i = 0; i < fn.length; i++) {
|
|
1975
2749
|
const c = fn[i]
|
|
1976
|
-
if (Array.isArray(c)) fn[i] = walkRewrite(c, true, counts)
|
|
2750
|
+
if (Array.isArray(c)) fn[i] = walkRewrite(c, true, counts, null, null)
|
|
1977
2751
|
}
|
|
1978
2752
|
}
|
|
1979
2753
|
return
|
|
@@ -1982,17 +2756,33 @@ function fusedRewrite(fn, counts) {
|
|
|
1982
2756
|
const name = typeof fn[1] === 'string' ? fn[1] : null
|
|
1983
2757
|
const skipInline = !!(name && (name.startsWith('$__ptr_') || name === '$__is_nullish' || name === '$__is_truthy' || name === '$__is_null'))
|
|
1984
2758
|
const bodyStart = findBodyStart(fn)
|
|
2759
|
+
// i64 scratch allocator for the literal-eq inline: any-shaped operand is
|
|
2760
|
+
// tee'd once instead of duplicated. Decls splice in after the walk.
|
|
2761
|
+
const newDecls = []
|
|
2762
|
+
// pre+post phases both run this pass — continue numbering past any scratch
|
|
2763
|
+
// locals the earlier phase already declared, or the decls collide.
|
|
2764
|
+
let scratchN = 0
|
|
2765
|
+
for (let i = 2; i < fn.length; i++) {
|
|
2766
|
+
const d = fn[i]
|
|
2767
|
+
if (Array.isArray(d) && d[0] === 'local' && typeof d[1] === 'string') {
|
|
2768
|
+
const m = d[1].match(/^\$__eq[tf](\d+)$/)
|
|
2769
|
+
if (m) scratchN = Math.max(scratchN, +m[1] + 1)
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
const freshI64 = () => { const n = `$__eqt${scratchN++}`; newDecls.push(['local', n, 'i64']); return n }
|
|
2773
|
+
const freshF64 = () => { const n = `$__eqf${scratchN++}`; newDecls.push(['local', n, 'f64']); return n }
|
|
1985
2774
|
for (let i = bodyStart; i < fn.length; i++) {
|
|
1986
2775
|
const c = fn[i]
|
|
1987
|
-
if (Array.isArray(c)) fn[i] = walkRewrite(c, !skipInline, counts)
|
|
2776
|
+
if (Array.isArray(c)) fn[i] = walkRewrite(c, !skipInline, counts, freshI64, freshF64)
|
|
1988
2777
|
}
|
|
2778
|
+
if (newDecls.length) fn.splice(bodyStart, 0, ...newDecls)
|
|
1989
2779
|
}
|
|
1990
2780
|
|
|
1991
|
-
function walkRewrite(node, doInline, counts) {
|
|
2781
|
+
function walkRewrite(node, doInline, counts, freshI64, freshF64) {
|
|
1992
2782
|
if (!Array.isArray(node)) return node
|
|
1993
2783
|
for (let i = 0; i < node.length; i++) {
|
|
1994
2784
|
const c = node[i]
|
|
1995
|
-
if (Array.isArray(c)) node[i] = walkRewrite(c, doInline, counts)
|
|
2785
|
+
if (Array.isArray(c)) node[i] = walkRewrite(c, doInline, counts, freshI64, freshF64)
|
|
1996
2786
|
}
|
|
1997
2787
|
const op = node[0]
|
|
1998
2788
|
// Piggyback local-ref counting for sortLocalsByUse. `counts` may be undefined
|
|
@@ -2000,6 +2790,74 @@ function walkRewrite(node, doInline, counts) {
|
|
|
2000
2790
|
if (counts && (op === 'local.get' || op === 'local.set' || op === 'local.tee') && typeof node[1] === 'string')
|
|
2001
2791
|
counts.set(node[1], (counts.get(node[1]) || 0) + 1)
|
|
2002
2792
|
|
|
2793
|
+
// Generic-equality bit-eq fast path: $__eq's own first branch hoisted to the
|
|
2794
|
+
// site when both args duplicate cheaply (local.get / reinterpret of one).
|
|
2795
|
+
// Identical bits ⇒ equal-unless-canonical-NaN; static-literal dedup + SSO +
|
|
2796
|
+
// slice interning make the hit dominant in tree-walking code (tag compares),
|
|
2797
|
+
// so most sites skip the call. The else arm keeps the original call.
|
|
2798
|
+
if (doInline && op === 'call' && (node[1] === '$__eq' || node[1] === '$__str_eq')
|
|
2799
|
+
&& node.length === 4 && !node._eqFast) {
|
|
2800
|
+
const cheap = (n) => Array.isArray(n) &&
|
|
2801
|
+
(n[0] === 'local.get' ||
|
|
2802
|
+
(n[0] === 'i64.reinterpret_f64' && Array.isArray(n[1]) && n[1][0] === 'local.get'))
|
|
2803
|
+
// i64.const whose bits decode to a CANONICAL interned string (STRING tag,
|
|
2804
|
+
// INTERN_BIT set, SSO/SLICE clear) — i.e. a static-literal operand.
|
|
2805
|
+
const internedLit = (n) => {
|
|
2806
|
+
// (i64.const 0x…) or its f64-carrier form (i64.reinterpret_f64 (f64.const nan:0x…))
|
|
2807
|
+
let tok = null
|
|
2808
|
+
if (Array.isArray(n) && n[0] === 'i64.const') tok = n[1]
|
|
2809
|
+
else if (Array.isArray(n) && n[0] === 'i64.reinterpret_f64' && Array.isArray(n[1])
|
|
2810
|
+
&& n[1][0] === 'f64.const' && typeof n[1][1] === 'string' && n[1][1].startsWith('nan:'))
|
|
2811
|
+
tok = n[1][1].slice(4)
|
|
2812
|
+
if (tok == null) return false
|
|
2813
|
+
let v
|
|
2814
|
+
try { v = BigInt(tok) } catch { return false }
|
|
2815
|
+
if (v < 0n) v += 1n << 64n
|
|
2816
|
+
if (((v >> 47n) & 0xFn) !== 4n) return false
|
|
2817
|
+
return ((v >> 32n) & 0x6001n) === BigInt(STR_INTERN_BIT)
|
|
2818
|
+
}
|
|
2819
|
+
// Literal-vs-X inline: bit-eq → 1; X carrying the canonical aux pattern →
|
|
2820
|
+
// 0 (only a canonical string can content-equal a canonical literal, and
|
|
2821
|
+
// canonicals are deduped; every NON-string kind is ≠ a string under ===
|
|
2822
|
+
// as well, so answering 0 on the pattern is sound for ANY value). Slices,
|
|
2823
|
+
// SSO, fresh heap strings and NaN fall through to the call. This is what
|
|
2824
|
+
// makes `op === 'literal'` dispatch ladders cost ~3 ops per rung instead
|
|
2825
|
+
// of a helper call — the V8 interned-pointer-compare equivalent.
|
|
2826
|
+
const a = node[2], b = node[3]
|
|
2827
|
+
const lit = internedLit(b) ? b : internedLit(a) ? a : null
|
|
2828
|
+
const x = lit === b ? a : b
|
|
2829
|
+
if (lit && (cheap(x) || freshI64)) {
|
|
2830
|
+
node._eqFast = true
|
|
2831
|
+
// Cheap operands duplicate; anything else evaluates ONCE into an i64
|
|
2832
|
+
// scratch (tee in the first use), so the inline applies to un-hoisted
|
|
2833
|
+
// shapes like `node[0] === 'lit'` too.
|
|
2834
|
+
let first = x, reuse = x
|
|
2835
|
+
if (!cheap(x)) {
|
|
2836
|
+
const t = freshI64()
|
|
2837
|
+
first = ['local.tee', t, x]
|
|
2838
|
+
reuse = ['local.get', t]
|
|
2839
|
+
node[2] = lit === b ? reuse : lit
|
|
2840
|
+
node[3] = lit === b ? lit : reuse
|
|
2841
|
+
}
|
|
2842
|
+
const auxPat = ['i32.eq',
|
|
2843
|
+
['i32.and', ['i32.wrap_i64', ['i64.shr_u', reuse, ['i64.const', 32]]], ['i32.const', 0x6001]],
|
|
2844
|
+
['i32.const', STR_INTERN_BIT]]
|
|
2845
|
+
return ['if', ['result', 'i32'],
|
|
2846
|
+
['i64.eq', first, lit],
|
|
2847
|
+
['then', ['i32.const', 1]],
|
|
2848
|
+
['else', ['if', ['result', 'i32'], auxPat,
|
|
2849
|
+
['then', ['i32.const', 0]],
|
|
2850
|
+
['else', node]]]]
|
|
2851
|
+
}
|
|
2852
|
+
if (node[1] === '$__eq' && cheap(a) && cheap(b)) {
|
|
2853
|
+
node._eqFast = true // pre+post phases both run this walk — wrap once
|
|
2854
|
+
return ['if', ['result', 'i32'],
|
|
2855
|
+
['i64.eq', a, b],
|
|
2856
|
+
['then', ['i64.ne', a, ['i64.const', NAN_BITS]]],
|
|
2857
|
+
['else', node]]
|
|
2858
|
+
}
|
|
2859
|
+
}
|
|
2860
|
+
|
|
2003
2861
|
// Inline-ptr-helpers: $__ptr_type / $__ptr_aux / $__is_nullish / $__is_null / $__is_truthy
|
|
2004
2862
|
if (doInline && op === 'call' && node.length === 3 && typeof node[1] === 'string') {
|
|
2005
2863
|
const fname = node[1]
|
|
@@ -2014,6 +2872,14 @@ function walkRewrite(node, doInline, counts) {
|
|
|
2014
2872
|
&& Array.isArray(node[2][1]) && node[2][1][0] === 'local.get') return ['i32.or',
|
|
2015
2873
|
['i64.eq', node[2], ['i64.const', NULL_BITS]],
|
|
2016
2874
|
['i64.eq', node[2], ['i64.const', UNDEF_BITS]]]
|
|
2875
|
+
// Expression-arg __is_truthy: evaluate once into an f64 scratch via tee —
|
|
2876
|
+
// the local.tee form below then expands inline (covers `(c = next()) || …`
|
|
2877
|
+
// and every condition the emitter didn't pre-hoist).
|
|
2878
|
+
if (fname === '$__is_truthy' && freshF64 && Array.isArray(node[2]) && node[2][0] === 'i64.reinterpret_f64'
|
|
2879
|
+
&& Array.isArray(node[2][1])
|
|
2880
|
+
&& node[2][1][0] !== 'local.get' && node[2][1][0] !== 'local.tee') {
|
|
2881
|
+
node[2] = ['i64.reinterpret_f64', ['local.tee', freshF64(), node[2][1]]]
|
|
2882
|
+
}
|
|
2017
2883
|
if (fname === '$__is_truthy' && Array.isArray(node[2]) && node[2][0] === 'i64.reinterpret_f64'
|
|
2018
2884
|
&& Array.isArray(node[2][1]) && (node[2][1][0] === 'local.get' || node[2][1][0] === 'local.tee')) {
|
|
2019
2885
|
// `local.tee $x SRC` evaluates SRC once, stores to $x, returns the value —
|
|
@@ -2025,16 +2891,21 @@ function walkRewrite(node, doInline, counts) {
|
|
|
2025
2891
|
const lget = ['local.get', lname]
|
|
2026
2892
|
const first = ref[0] === 'local.tee' ? ref : lget
|
|
2027
2893
|
const bits = ['i64.reinterpret_f64', lget]
|
|
2894
|
+
// Mirror $__is_truthy (module/core.js) exactly: FIVE falsy patterns —
|
|
2895
|
+
// canonical NaN, null, undefined, the empty SSO string, AND boolean
|
|
2896
|
+
// false. Omitting FALSE made inlined `x || y` treat false as truthy.
|
|
2028
2897
|
return ['if', ['result', 'i32'],
|
|
2029
2898
|
['f64.eq', first, lget],
|
|
2030
2899
|
['then', ['f64.ne', lget, ['f64.const', 0]]],
|
|
2031
2900
|
['else', ['i32.and',
|
|
2032
2901
|
['i32.and',
|
|
2033
|
-
['
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
['
|
|
2037
|
-
|
|
2902
|
+
['i32.and',
|
|
2903
|
+
['i64.ne', bits, ['i64.const', NAN_BITS]],
|
|
2904
|
+
['i64.ne', bits, ['i64.const', NULL_BITS]]],
|
|
2905
|
+
['i32.and',
|
|
2906
|
+
['i64.ne', bits, ['i64.const', UNDEF_BITS]],
|
|
2907
|
+
['i64.ne', bits, ['i64.const', '0x7FFA400000000000']]]],
|
|
2908
|
+
['i64.ne', bits, ['i64.const', FALSE_BITS]]]]]
|
|
2038
2909
|
}
|
|
2039
2910
|
}
|
|
2040
2911
|
|
|
@@ -2078,6 +2949,65 @@ function walkRewrite(node, doInline, counts) {
|
|
|
2078
2949
|
return a[1]
|
|
2079
2950
|
}
|
|
2080
2951
|
|
|
2952
|
+
// Push ToInt32 through integer expressions and conditionals. The universal value model
|
|
2953
|
+
// computes integer `+`/`-` and `?:` in f64, then ToInt32-clamps — emitting
|
|
2954
|
+
// (select (i32.wrap_i64 (i64.trunc_sat_f64_s [local.tee T] X)) FALLBACK COND)
|
|
2955
|
+
// whose three arms all compute ToInt32(X). When X is an integer-valued f64 expression,
|
|
2956
|
+
// ToInt32(X) == its i32 form (exact); and ToInt32 distributes through a conditional:
|
|
2957
|
+
// ToInt32(if C A B) == if(result i32) C ToInt32(A) ToInt32(B).
|
|
2958
|
+
// Folding here drops the f64 round-trip AND turns int `s += a[i]` reductions and
|
|
2959
|
+
// `a[i] = cond ? … : …` conditional maps into pure i32 the vectorizer lifts (i32x4.add /
|
|
2960
|
+
// i32x4 bitselect). FALLBACK/COND (which recompute the same ToInt32 from T) are dropped.
|
|
2961
|
+
if (op === 'select' && node.length >= 4) {
|
|
2962
|
+
const v = node[1]
|
|
2963
|
+
if (Array.isArray(v) && v[0] === 'i32.wrap_i64' && Array.isArray(v[1]) && v[1][0] === 'i64.trunc_sat_f64_s' && v[1].length === 2) {
|
|
2964
|
+
let inner = v[1][1]
|
|
2965
|
+
if (Array.isArray(inner) && inner[0] === 'local.tee' && inner.length === 3) inner = inner[2]
|
|
2966
|
+
// ToInt32(if (result f64) C A B) → if (result i32) C toI32(A) toI32(B), when both arms are integer-valued.
|
|
2967
|
+
if (Array.isArray(inner) && inner[0] === 'if' && Array.isArray(inner[1]) && inner[1][0] === 'result' && inner[1][1] === 'f64'
|
|
2968
|
+
&& Array.isArray(inner[3]) && inner[3][0] === 'then' && inner[3].length === 2
|
|
2969
|
+
&& Array.isArray(inner[4]) && inner[4][0] === 'else' && inner[4].length === 2) {
|
|
2970
|
+
const t = toI32(inner[3][1]), e = toI32(inner[4][1])
|
|
2971
|
+
if (t && e) return ['if', ['result', 'i32'], inner[2], ['then', t], ['else', e]]
|
|
2972
|
+
}
|
|
2973
|
+
// ToInt32(integer-valued f64 expr) → its i32 form (covers (i32±i32)|0 sums and nests).
|
|
2974
|
+
const i = toI32(inner)
|
|
2975
|
+
if (i) return i
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2978
|
+
// (i32.or X 0) / (i32.or 0 X) → X — drops the redundant source-level `|0` clamp left
|
|
2979
|
+
// after the fold above, so the accumulator update is a bare i32.add the recognizer matches.
|
|
2980
|
+
if (op === 'i32.or' && node.length === 3) {
|
|
2981
|
+
const a = node[1], b = node[2]
|
|
2982
|
+
if (Array.isArray(b) && b[0] === 'i32.const' && b[1] === 0) return a
|
|
2983
|
+
if (Array.isArray(a) && a[0] === 'i32.const' && a[1] === 0) return b
|
|
2984
|
+
}
|
|
2985
|
+
|
|
2986
|
+
// f64.CMP(convert_i32 A, convert_i32 B) → i32.CMP(A, B). Comparing two i32 values is
|
|
2987
|
+
// identical whether done in exact f64 or in i32 (the converts are lossless and
|
|
2988
|
+
// order-preserving), so an integer comparison over typed-array loads (reads are f64)
|
|
2989
|
+
// drops its f64 round-trip. eq/ne are sign-agnostic; ordered compares need matching
|
|
2990
|
+
// signedness; an integer comparand constant works for the signed case. Both operands
|
|
2991
|
+
// are kept, so any address `local.tee` inside them survives. Prerequisite for i32
|
|
2992
|
+
// conditional-lane vectorization (the mask becomes an i32x4 compare).
|
|
2993
|
+
if (op === 'f64.eq' || op === 'f64.ne' || op === 'f64.lt' || op === 'f64.gt' || op === 'f64.le' || op === 'f64.ge') {
|
|
2994
|
+
const base = op.slice(4)
|
|
2995
|
+
const cv = (x) => Array.isArray(x) && (x[0] === 'f64.convert_i32_s' || x[0] === 'f64.convert_i32_u') && x.length === 2 ? x : null
|
|
2996
|
+
const intK = (x) => Array.isArray(x) && x[0] === 'f64.const' && Number.isInteger(x[1]) && x[1] >= -2147483648 && x[1] <= 2147483647 ? x[1] : null
|
|
2997
|
+
const a = node[1], b = node[2], ca = cv(a), cb = cv(b)
|
|
2998
|
+
if (ca && cb) {
|
|
2999
|
+
const sa = ca[0] === 'f64.convert_i32_s', sb = cb[0] === 'f64.convert_i32_s'
|
|
3000
|
+
if (base === 'eq' || base === 'ne') return ['i32.' + base, ca[1], cb[1]]
|
|
3001
|
+
if (sa === sb) return ['i32.' + base + (sa ? '_s' : '_u'), ca[1], cb[1]]
|
|
3002
|
+
} else if (ca && ca[0] === 'f64.convert_i32_s') {
|
|
3003
|
+
const k = intK(b)
|
|
3004
|
+
if (k != null) return base === 'eq' || base === 'ne' ? ['i32.' + base, ca[1], ['i32.const', k]] : ['i32.' + base + '_s', ca[1], ['i32.const', k]]
|
|
3005
|
+
} else if (cb && cb[0] === 'f64.convert_i32_s') {
|
|
3006
|
+
const k = intK(a)
|
|
3007
|
+
if (k != null) return base === 'eq' || base === 'ne' ? ['i32.' + base, ['i32.const', k], cb[1]] : ['i32.' + base + '_s', ['i32.const', k], cb[1]]
|
|
3008
|
+
}
|
|
3009
|
+
}
|
|
3010
|
+
|
|
2081
3011
|
// shl-distribute-over-add: (i32.shl (i32.add x (i32.const K)) (i32.const S))
|
|
2082
3012
|
// → (i32.add (i32.shl x S) (i32.const K<<S)). Overflow-safe — both forms wrap
|
|
2083
3013
|
// mod 2^32 identically. Unlocks memarg offset= folding for biquad-style
|
|
@@ -2177,6 +3107,13 @@ export function treeshake(funcSections, allModuleNodes, opts) {
|
|
|
2177
3107
|
for (let i = 2; i < fn.length; i++)
|
|
2178
3108
|
if (Array.isArray(fn[i]) && fn[i][0] === 'export') { addRoot(name); break }
|
|
2179
3109
|
|
|
3110
|
+
// When user funcs are NOT being reclaimed (O0/O1 keep declared-but-uncalled ones), they
|
|
3111
|
+
// all survive — so they're roots for the *internal*-func reachability below. Otherwise an
|
|
3112
|
+
// unreachable user func that's kept would still call a `__helper`, yet that helper would be
|
|
3113
|
+
// pruned as unreached-from-exports, leaving a dangling `call $__helper`.
|
|
3114
|
+
if (!removeDead && opts && opts.userFuncs)
|
|
3115
|
+
for (const name of opts.userFuncs) addRoot(name)
|
|
3116
|
+
|
|
2180
3117
|
const findRoots = (node) => {
|
|
2181
3118
|
if (!Array.isArray(node)) return
|
|
2182
3119
|
if (node[0] === 'start' && typeof node[1] === 'string') addRoot(node[1])
|
|
@@ -2204,24 +3141,43 @@ export function treeshake(funcSections, allModuleNodes, opts) {
|
|
|
2204
3141
|
for (const fn of allFuncs) if (typeof fn[1] !== 'string') visitCalls(fn)
|
|
2205
3142
|
while (stack.length) visitCalls(funcByName.get(stack.pop()))
|
|
2206
3143
|
|
|
3144
|
+
// Compiler-internal funcs (stdlib helpers, allocator wrappers — everything not in the
|
|
3145
|
+
// user's own `ctx.func.list`) carry no source meaning, so an unreachable one is reclaimed
|
|
3146
|
+
// at EVERY opt level: it's never a live-coding aid, just over-production (e.g. `s + '!'`
|
|
3147
|
+
// pulls the alloc trio's `__alloc_hdr`, which string concat never calls, and a dead-branch
|
|
3148
|
+
// dep like `__str_len`). User funcs are reclaimed only when DCE is on, so O0/O1 keep a
|
|
3149
|
+
// declared-but-uncalled user function. Absent the set, fall back to gating everything.
|
|
3150
|
+
const userFuncs = opts && opts.userFuncs
|
|
3151
|
+
const isUserFunc = (name) => userFuncs ? userFuncs.has(name) : true
|
|
2207
3152
|
let removed = 0
|
|
2208
|
-
if (removeDead) {
|
|
3153
|
+
if (removeDead || userFuncs) {
|
|
2209
3154
|
for (const { arr } of funcSections) {
|
|
2210
3155
|
for (let i = arr.length - 1; i >= 0; i--) {
|
|
2211
3156
|
const n = arr[i]
|
|
2212
|
-
if (Array.isArray(n) && n[0] === 'func' && typeof n[1] === 'string' && !reachable.has(n[1])
|
|
3157
|
+
if (Array.isArray(n) && n[0] === 'func' && typeof n[1] === 'string' && !reachable.has(n[1]) &&
|
|
3158
|
+
(removeDead || !isUserFunc(n[1]))) {
|
|
2213
3159
|
arr.splice(i, 1); removed++
|
|
2214
3160
|
}
|
|
2215
3161
|
}
|
|
2216
3162
|
}
|
|
2217
3163
|
}
|
|
2218
3164
|
|
|
2219
|
-
// Dead-global elimination:
|
|
2220
|
-
//
|
|
2221
|
-
//
|
|
2222
|
-
//
|
|
2223
|
-
//
|
|
2224
|
-
|
|
3165
|
+
// Dead-global elimination: drop `(global $g …)` decls that nothing references
|
|
3166
|
+
// (a `global.get`/`global.set` in a remaining func, a kept global's init expr, a
|
|
3167
|
+
// data/elem offset, or an `(export … (global $g))`). Imported globals live in
|
|
3168
|
+
// `allModuleNodes`, not in `opts.globals`, so they're never touched. Fixpoint: a
|
|
3169
|
+
// kept global's init may reference another global.
|
|
3170
|
+
//
|
|
3171
|
+
// Compiler-internal globals (support state the user never wrote — e.g. core's
|
|
3172
|
+
// `__heap_start` or the math module's `rng_state`, declared eagerly but read
|
|
3173
|
+
// only by specific fast paths) are reclaimed at *every* level: leaving an
|
|
3174
|
+
// unreferenced one in the output is pure noise, never a live-coding aid. User
|
|
3175
|
+
// globals are reclaimed only when DCE is on, so O0/O1 still preserve declared-
|
|
3176
|
+
// but-unused user bindings. `userGlobals` (names sans `$`) draws the line; absent
|
|
3177
|
+
// it, fall back to the `$__` reserved-prefix heuristic.
|
|
3178
|
+
const userGlobals = opts && opts.userGlobals
|
|
3179
|
+
const isUserGlobal = (name) => userGlobals ? userGlobals.has(name.slice(1)) : !name.startsWith('$__')
|
|
3180
|
+
const globals = opts && Array.isArray(opts.globals) ? opts.globals : null
|
|
2225
3181
|
if (globals) {
|
|
2226
3182
|
const collectGlobalRefs = (node, refd) => {
|
|
2227
3183
|
if (!Array.isArray(node)) return
|
|
@@ -2238,9 +3194,11 @@ export function treeshake(funcSections, allModuleNodes, opts) {
|
|
|
2238
3194
|
for (const g of globals) collectGlobalRefs(g, refd)
|
|
2239
3195
|
for (let i = globals.length - 1; i >= 0; i--) {
|
|
2240
3196
|
const g = globals[i]
|
|
2241
|
-
if (Array.isArray(g)
|
|
2242
|
-
|
|
2243
|
-
|
|
3197
|
+
if (!Array.isArray(g) || g[0] !== 'global' || typeof g[1] !== 'string' || refd.has(g[1])) continue
|
|
3198
|
+
// An inline `(export …)` on the decl pins it — it's part of the module's
|
|
3199
|
+
// JS-host surface (e.g. `__heap`), referenced from outside the wasm.
|
|
3200
|
+
if (g.some(c => Array.isArray(c) && c[0] === 'export')) continue
|
|
3201
|
+
if (removeDead || !isUserGlobal(g[1])) { globals.splice(i, 1); changed = true }
|
|
2244
3202
|
}
|
|
2245
3203
|
}
|
|
2246
3204
|
}
|
|
@@ -2258,7 +3216,8 @@ export function sortLocalsByUse(fn, precomputedCounts) {
|
|
|
2258
3216
|
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
2259
3217
|
const localIdxs = []
|
|
2260
3218
|
let totalDecls = 0
|
|
2261
|
-
|
|
3219
|
+
let i
|
|
3220
|
+
for (i = 2; i < fn.length; i++) {
|
|
2262
3221
|
const c = fn[i]
|
|
2263
3222
|
if (!Array.isArray(c)) continue
|
|
2264
3223
|
if (c[0] === 'param' || c[0] === 'result') { totalDecls++; continue }
|