jz 0.5.1 → 0.6.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 +288 -314
- package/bench/README.md +319 -0
- package/bench/bench.svg +112 -0
- package/cli.js +32 -24
- package/index.js +177 -55
- package/interop.js +88 -159
- 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 +179 -0
- package/module/array.js +322 -153
- package/module/collection.js +603 -145
- package/module/console.js +55 -43
- package/module/core.js +266 -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 +414 -186
- package/module/number.js +306 -60
- package/module/object.js +448 -184
- package/module/regex.js +255 -25
- package/module/schema.js +24 -6
- package/module/simd.js +85 -0
- package/module/string.js +586 -220
- package/module/symbol.js +1 -1
- package/module/timer.js +9 -14
- package/module/typedarray.js +45 -48
- package/package.json +41 -12
- package/src/abi/index.js +39 -23
- package/src/abi/string.js +38 -41
- package/src/ast.js +460 -0
- package/src/autoload.js +26 -24
- package/src/bridge.js +111 -0
- package/src/compile/analyze-scans.js +661 -0
- package/src/compile/analyze.js +1565 -0
- package/src/compile/emit-assign.js +408 -0
- package/src/compile/emit.js +3201 -0
- package/src/compile/flow-types.js +103 -0
- package/src/{compile.js → compile/index.js} +497 -125
- package/src/{infer.js → compile/infer.js} +27 -98
- package/src/{narrow.js → compile/narrow.js} +302 -96
- package/src/compile/plan/advise.js +316 -0
- package/src/compile/plan/common.js +150 -0
- package/src/compile/plan/index.js +118 -0
- package/src/compile/plan/inline.js +679 -0
- package/src/compile/plan/literals.js +984 -0
- package/src/compile/plan/loops.js +472 -0
- package/src/compile/plan/scope.js +573 -0
- package/src/compile/program-facts.js +404 -0
- package/src/ctx.js +176 -58
- package/src/ir.js +540 -171
- package/src/kind-traits.js +105 -0
- package/src/kind.js +462 -0
- package/src/op-policy.js +57 -0
- package/src/{optimize.js → optimize/index.js} +1106 -446
- package/src/optimize/vectorize.js +1874 -0
- package/src/param-reps.js +65 -0
- package/src/parse.js +44 -0
- package/src/{prepare.js → prepare/index.js} +589 -202
- package/src/reps.js +115 -0
- package/src/resolve.js +12 -3
- package/src/static.js +199 -0
- package/src/type.js +647 -0
- package/src/{assemble.js → wat/assemble.js} +86 -48
- package/src/wat/optimize.js +3760 -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
|
|
@@ -58,15 +65,39 @@ const UNDEF_BITS = '0x' + (LAYOUT.NAN_PREFIX_BITS | (2n << BigInt(LAYOUT.AUX_SHI
|
|
|
58
65
|
* caps. Smallest wasm.
|
|
59
66
|
* 'balanced' — the default (= level 2).
|
|
60
67
|
* 'speed' — full nested unroll + lane vectorization (= level 3).
|
|
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',
|
|
@@ -116,7 +148,9 @@ const LEVEL_PRESETS = Object.freeze({
|
|
|
116
148
|
balanced: Object.freeze({ ...ALL_ON, nestedSmallConstForUnroll: 'auto' }),
|
|
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.
|
|
@@ -137,10 +171,15 @@ const LEVEL_PRESETS = Object.freeze({
|
|
|
137
171
|
export function resolveOptimize(opt) {
|
|
138
172
|
if (opt === false || opt === 0) return { ...ALL_OFF }
|
|
139
173
|
if (opt === true || opt == null) return { ...LEVEL_PRESETS[2] }
|
|
140
|
-
|
|
174
|
+
// String() the level key: LEVEL_PRESETS has integer-literal keys (0..3), and the
|
|
175
|
+
// self-host kernel's computed member access `obj[numVar]` misreads a numeric VARIABLE
|
|
176
|
+
// index against an object (returns undefined — literal `obj[2]` is fine), so a bare
|
|
177
|
+
// `LEVEL_PRESETS[opt]`/`[baseLevel]` would drop the level-2 default to ALL_ON and
|
|
178
|
+
// (worse, via the partial result) leave `watr` unset — disabling watOptimize.
|
|
179
|
+
if (typeof opt === 'number' || typeof opt === 'string') return { ...(LEVEL_PRESETS[String(opt)] || LEVEL_PRESETS[2]) }
|
|
141
180
|
if (typeof opt === 'object') {
|
|
142
181
|
const baseLevel = typeof opt.level === 'number' || typeof opt.level === 'string' ? opt.level : 2
|
|
143
|
-
const base = LEVEL_PRESETS[baseLevel] || ALL_ON
|
|
182
|
+
const base = LEVEL_PRESETS[String(baseLevel)] || ALL_ON
|
|
144
183
|
const out = { ...base }
|
|
145
184
|
for (const n of PASS_NAMES) {
|
|
146
185
|
if (!(n in opt)) continue
|
|
@@ -148,6 +187,7 @@ export function resolveOptimize(opt) {
|
|
|
148
187
|
// Preserve sentinel value `nestedSmallConstForUnroll: 'auto'`
|
|
149
188
|
// (resolved by a heuristic at emit time).
|
|
150
189
|
if (n === 'nestedSmallConstForUnroll' && v === 'auto') out[n] = 'auto'
|
|
190
|
+
else if (n === 'watr' && typeof v === 'object') out[n] = v
|
|
151
191
|
else out[n] = !!v
|
|
152
192
|
}
|
|
153
193
|
// Preserve non-pass tuning keys (e.g. plan.js thresholds)
|
|
@@ -182,213 +222,76 @@ export function resolveOptimize(opt) {
|
|
|
182
222
|
* result is unsafe across realloc, so it isn't hoisted here.)
|
|
183
223
|
*/
|
|
184
224
|
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) {
|
|
225
|
+
return regionTrackCSE(fn, {
|
|
226
|
+
matchSite(node) {
|
|
227
|
+
// (call $__ptr_type (i64.reinterpret_f64 (local.get X))) — key is X, dep is X.
|
|
228
|
+
if (node[0] !== 'call' || node[1] !== '$__ptr_type' || node.length !== 3) return null
|
|
205
229
|
const arg = node[2]
|
|
206
|
-
// Post-i64 migration: arg is (i64.reinterpret_f64 (local.get X)). Peel both wrappers.
|
|
207
230
|
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)
|
|
231
|
+
if (!Array.isArray(inner) || inner[0] !== 'local.get' || typeof inner[1] !== 'string') return null
|
|
232
|
+
const x = inner[1]
|
|
233
|
+
return { key: x, deps: [x] }
|
|
234
|
+
},
|
|
235
|
+
localPrefix: 'pt',
|
|
236
|
+
localType: 'i32',
|
|
237
|
+
})
|
|
308
238
|
}
|
|
309
239
|
|
|
310
|
-
/**
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
* the share explicitly avoids relying on tier-up and helps wasm2c / wasm-opt too.
|
|
240
|
+
/** Region-tracking CSE skeleton shared by hoistPtrType and hoistAddrBase.
|
|
241
|
+
* Walks `fn`, accumulating "regions" — sequences of structurally-identical
|
|
242
|
+
* sites along straight-line control flow where the site's value is invariant
|
|
243
|
+
* (no writes to its dependent locals between sites). Per region with ≥2 sites,
|
|
244
|
+
* allocates one `$__<prefix><id>` local and rewrites the first site to
|
|
245
|
+
* `local.tee` and the rest to `local.get`.
|
|
317
246
|
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
247
|
+
* Control-flow semantics:
|
|
248
|
+
* - `local.set/tee X` closes every region whose dep set includes X.
|
|
249
|
+
* - `if`/`else` arms walk independently from the if-entry open set; after
|
|
250
|
+
* the if, a region is open iff it was open on BOTH arms (same region ref).
|
|
251
|
+
* - `loop` clears open before AND after — back edges may skip the original tee.
|
|
252
|
+
* - `block` / func body — sequential walk.
|
|
320
253
|
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*/
|
|
324
|
-
|
|
254
|
+
* `matchSite(node, parent, pi)` returns `{ key, deps }` for a CSE-able site
|
|
255
|
+
* (key is a stable string; deps lists locals whose writes invalidate this key)
|
|
256
|
+
* or null. Match-arm sites don't recurse into children. */
|
|
257
|
+
function regionTrackCSE(fn, { matchSite, localPrefix, localType }) {
|
|
325
258
|
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
326
259
|
const bodyStart = findBodyStart(fn)
|
|
327
260
|
if (bodyStart < 0) return
|
|
328
261
|
|
|
329
|
-
// Per key
|
|
262
|
+
// Per key: array of regions; each region is array of {parent, idx, role: 'tee'|'get'}.
|
|
330
263
|
const regions = new Map()
|
|
331
|
-
//
|
|
332
|
-
// depending on it (so `local.set X` can close any region whose key references X).
|
|
264
|
+
// Currently-open region per key. Presence ⇔ alive.
|
|
333
265
|
const open = new Map()
|
|
266
|
+
// local-name → keys depending on it (so `local.set X` closes all dependent keys).
|
|
334
267
|
const localToKeys = new Map()
|
|
335
268
|
|
|
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) => {
|
|
269
|
+
const addDep = (name, key) => {
|
|
342
270
|
let s = localToKeys.get(name)
|
|
343
271
|
if (!s) { s = new Set(); localToKeys.set(name, s) }
|
|
344
272
|
s.add(key)
|
|
345
273
|
}
|
|
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
274
|
const closeForLocal = (name) => {
|
|
353
275
|
const s = localToKeys.get(name)
|
|
354
276
|
if (!s) return
|
|
355
|
-
for (const k of s)
|
|
277
|
+
for (const k of s) open.delete(k)
|
|
356
278
|
localToKeys.delete(name)
|
|
357
279
|
}
|
|
358
280
|
|
|
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
281
|
const walk = (node, parent, pi) => {
|
|
379
282
|
if (!Array.isArray(node)) return
|
|
380
283
|
const op = node[0]
|
|
381
284
|
|
|
382
|
-
const m =
|
|
285
|
+
const m = matchSite(node, parent, pi)
|
|
383
286
|
if (m) {
|
|
384
|
-
|
|
385
|
-
let region = open.get(key)
|
|
287
|
+
let region = open.get(m.key)
|
|
386
288
|
if (!region) {
|
|
387
289
|
region = []
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
290
|
+
let regs = regions.get(m.key)
|
|
291
|
+
if (!regs) { regs = []; regions.set(m.key, regs) }
|
|
292
|
+
regs.push(region)
|
|
293
|
+
open.set(m.key, region)
|
|
294
|
+
for (const d of m.deps) addDep(d, m.key)
|
|
392
295
|
region.push({ parent, idx: pi, role: 'tee' })
|
|
393
296
|
} else {
|
|
394
297
|
region.push({ parent, idx: pi, role: 'get' })
|
|
@@ -398,7 +301,7 @@ export function hoistAddrBase(fn) {
|
|
|
398
301
|
|
|
399
302
|
if ((op === 'local.set' || op === 'local.tee') && typeof node[1] === 'string') {
|
|
400
303
|
const x = node[1]
|
|
401
|
-
// Walk value first — it may
|
|
304
|
+
// Walk value first — it may contain a site referencing pre-write X.
|
|
402
305
|
for (let i = 2; i < node.length; i++) walk(node[i], node, i)
|
|
403
306
|
closeForLocal(x)
|
|
404
307
|
return
|
|
@@ -430,6 +333,7 @@ export function hoistAddrBase(fn) {
|
|
|
430
333
|
for (let j = 1; j < elseArm.length; j++) walk(elseArm[j], elseArm, j)
|
|
431
334
|
afterElse = new Map(open)
|
|
432
335
|
}
|
|
336
|
+
// Merge: alive after if iff alive on BOTH paths with same region ref.
|
|
433
337
|
open.clear()
|
|
434
338
|
for (const [k, vT] of afterThen) {
|
|
435
339
|
if (afterElse.get(k) === vT) open.set(k, vT)
|
|
@@ -451,16 +355,15 @@ export function hoistAddrBase(fn) {
|
|
|
451
355
|
|
|
452
356
|
if (regions.size === 0) return
|
|
453
357
|
|
|
454
|
-
|
|
358
|
+
// Commit: ≥2 sites per region to be worthwhile (a singleton is pure cost).
|
|
359
|
+
let hoistId = nextLocalId(fn, localPrefix)
|
|
455
360
|
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
361
|
for (const [, regs] of regions) {
|
|
459
362
|
let usable = false
|
|
460
363
|
for (const r of regs) if (r.length >= 2) { usable = true; break }
|
|
461
364
|
if (!usable) continue
|
|
462
|
-
const tLocal = `$
|
|
463
|
-
locals.push(['local', tLocal,
|
|
365
|
+
const tLocal = `$__${localPrefix}${hoistId++}`
|
|
366
|
+
locals.push(['local', tLocal, localType])
|
|
464
367
|
for (const r of regs) {
|
|
465
368
|
if (r.length < 2) continue
|
|
466
369
|
for (let i = 0; i < r.length; i++) {
|
|
@@ -473,6 +376,44 @@ export function hoistAddrBase(fn) {
|
|
|
473
376
|
if (locals.length) fn.splice(bodyStart, 0, ...locals)
|
|
474
377
|
}
|
|
475
378
|
|
|
379
|
+
/**
|
|
380
|
+
* CSE repeated `(i32.add (local.get $A) (i32.shl (local.get $B) (i32.const K)))`
|
|
381
|
+
* — the shape jz emits for `arr[idx + k]` typed-array reads after foldMemargOffsets
|
|
382
|
+
* absorbs the constant K into `offset=`. The remaining base expression is
|
|
383
|
+
* recomputed once per `arr[…]` read; biquad's inner cascade has 9 such reads
|
|
384
|
+
* sharing 2 base shapes per iteration. V8's CSE usually catches this, but emitting
|
|
385
|
+
* the share explicitly avoids relying on tier-up and helps wasm2c / wasm-opt too.
|
|
386
|
+
*
|
|
387
|
+
* Same region-tracking discipline as hoistPtrType: open region per key, closed
|
|
388
|
+
* by re-assignment to either A or B; loop entry/exit clears all open regions.
|
|
389
|
+
*
|
|
390
|
+
* Must run AFTER fusedRewrite — relies on shl-distribution + assoc-lift +
|
|
391
|
+
* foldMemargOffsets having normalized the base shape.
|
|
392
|
+
*/
|
|
393
|
+
export function hoistAddrBase(fn) {
|
|
394
|
+
return regionTrackCSE(fn, {
|
|
395
|
+
matchSite(node) {
|
|
396
|
+
if (node[0] !== 'i32.add' || node.length !== 3) return null
|
|
397
|
+
const a = node[1], b = node[2]
|
|
398
|
+
// Two orderings: (add (get A) (shl (get B) (const K))) or (add (shl …) (get A))
|
|
399
|
+
let baseGet, shlNode
|
|
400
|
+
if (Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' &&
|
|
401
|
+
Array.isArray(b) && b[0] === 'i32.shl' && b.length === 3) {
|
|
402
|
+
baseGet = a; shlNode = b
|
|
403
|
+
} else if (Array.isArray(b) && b[0] === 'local.get' && typeof b[1] === 'string' &&
|
|
404
|
+
Array.isArray(a) && a[0] === 'i32.shl' && a.length === 3) {
|
|
405
|
+
baseGet = b; shlNode = a
|
|
406
|
+
} else return null
|
|
407
|
+
const idx = shlNode[1], shamt = shlNode[2]
|
|
408
|
+
if (!Array.isArray(idx) || idx[0] !== 'local.get' || typeof idx[1] !== 'string') return null
|
|
409
|
+
if (!Array.isArray(shamt) || shamt[0] !== 'i32.const' || typeof shamt[1] !== 'number') return null
|
|
410
|
+
return { key: `${baseGet[1]}|${idx[1]}|${shamt[1]}`, deps: [baseGet[1], idx[1]] }
|
|
411
|
+
},
|
|
412
|
+
localPrefix: 'ab',
|
|
413
|
+
localType: 'i32',
|
|
414
|
+
})
|
|
415
|
+
}
|
|
416
|
+
|
|
476
417
|
/**
|
|
477
418
|
* Hoist `(call $__ptr_offset (local.get $X))` to a function-entry snapshot
|
|
478
419
|
* when X is an f64-NaN-boxed parameter that's never reassigned and only ever
|
|
@@ -484,7 +425,13 @@ export function hoistAddrBase(fn) {
|
|
|
484
425
|
* sound for the duration. The whitelist below is the read-only set
|
|
485
426
|
* (no mutation possible); any other callee touching X invalidates hoisting.
|
|
486
427
|
*/
|
|
487
|
-
|
|
428
|
+
// Read-only i32-returning calls: safe to hoist when operands are invariant,
|
|
429
|
+
// and their presence in a loop must not block other hoists (hasUnsafeCall).
|
|
430
|
+
// __jss_* are wasm:js-string host builtins over IMMUTABLE JS strings — pure by
|
|
431
|
+
// the same argument as the __ptr_* helpers (charCodeAt won't itself hoist —
|
|
432
|
+
// its index varies — but whitelisting it keeps hasUnsafeCall false so the
|
|
433
|
+
// loop-invariant __jss_length in the same loop condition CAN hoist).
|
|
434
|
+
const SAFE_OFFSET_CALLS = new Set(['$__ptr_offset', '$__ptr_type', '$__ptr_aux', '$__len', '$__jss_length', '$__jss_charCodeAt'])
|
|
488
435
|
|
|
489
436
|
export function hoistInvariantPtrOffset(fn) {
|
|
490
437
|
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
@@ -558,8 +505,7 @@ export function hoistInvariantPtrOffset(fn) {
|
|
|
558
505
|
|
|
559
506
|
if (sites.size === 0) return
|
|
560
507
|
|
|
561
|
-
let hoistId =
|
|
562
|
-
while (fn.some(n => Array.isArray(n) && n[0] === 'local' && n[1] === `$__po${hoistId}`)) hoistId++
|
|
508
|
+
let hoistId = nextLocalId(fn, 'po')
|
|
563
509
|
|
|
564
510
|
const newLocals = []
|
|
565
511
|
const snaps = []
|
|
@@ -577,97 +523,245 @@ export function hoistInvariantPtrOffset(fn) {
|
|
|
577
523
|
if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals, ...snaps)
|
|
578
524
|
}
|
|
579
525
|
|
|
526
|
+
|
|
527
|
+
// Non-trapping, side-effect-free ops whose result is a pure function of their
|
|
528
|
+
// operands. Hoisting one to the pre-header is sound iff its operands are loop-
|
|
529
|
+
// invariant: same value every iteration, no traps, no memory/global effects.
|
|
530
|
+
// DELIBERATELY EXCLUDES trapping ops — i32/i64 div_s/u & rem_s/u (trap on 0),
|
|
531
|
+
// non-saturating trunc_f64 (trap on overflow/NaN) — because hoisting a trap to
|
|
532
|
+
// the pre-header would fire it even when the loop runs zero times. Loads and
|
|
533
|
+
// calls are NOT here; they are admitted by `pureGiven` only under the loop's
|
|
534
|
+
// effect-summary barriers (cell loads with no aliasing store/call; the read-only
|
|
535
|
+
// __ptr_* call whitelist with no other call).
|
|
536
|
+
// Boxed-capture cells are `freshLocal`-generated, so the name carries the T
|
|
537
|
+
// (U+E000) prefix: `$<T>cell_<var>`. Built from the constant — a hand-typed
|
|
538
|
+
// `'$cell_'` literal silently omits the invisible T and never matches.
|
|
539
|
+
const CELL_PREFIX = '$' + T + 'cell_'
|
|
540
|
+
|
|
541
|
+
// Ops V8's wasm tier (TurboFan) will NOT hoist out of a loop itself: saturating
|
|
542
|
+
// f64→int truncation and `select` are not LICM-eligible there, memory loads are
|
|
543
|
+
// blocked by conservative aliasing, and calls are opaque. These are the ONLY
|
|
544
|
+
// things worth hoisting — V8 already does general arithmetic LICM, and hoisting
|
|
545
|
+
// pure arithmetic ourselves only bloats the body and breaks the lane-vectorizer's
|
|
546
|
+
// straight-line pattern match. So a subtree is hoisted only if it contains one.
|
|
547
|
+
const HARD_OPS = new Set([
|
|
548
|
+
'i64.trunc_sat_f64_s', 'i64.trunc_sat_f64_u', 'i32.trunc_sat_f64_s', 'i32.trunc_sat_f64_u',
|
|
549
|
+
'select', 'f64.load', 'i32.load', 'call',
|
|
550
|
+
])
|
|
551
|
+
const hasHardOp = (n) => Array.isArray(n) && (HARD_OPS.has(n[0]) || n.some((c, i) => i > 0 && hasHardOp(c)))
|
|
552
|
+
|
|
553
|
+
const PURE_LICM_OPS = new Set([
|
|
554
|
+
'f64.add', 'f64.sub', 'f64.mul', 'f64.div', 'f64.neg', 'f64.abs', 'f64.sqrt',
|
|
555
|
+
'f64.min', 'f64.max', 'f64.ceil', 'f64.floor', 'f64.trunc', 'f64.nearest', 'f64.copysign',
|
|
556
|
+
'i32.add', 'i32.sub', 'i32.mul', 'i32.and', 'i32.or', 'i32.xor',
|
|
557
|
+
'i32.shl', 'i32.shr_s', 'i32.shr_u', 'i32.rotl', 'i32.rotr', 'i32.clz', 'i32.ctz', 'i32.popcnt', 'i32.eqz',
|
|
558
|
+
'i64.add', 'i64.sub', 'i64.mul', 'i64.and', 'i64.or', 'i64.xor',
|
|
559
|
+
'i64.shl', 'i64.shr_s', 'i64.shr_u', 'i64.rotl', 'i64.rotr', 'i64.eqz',
|
|
560
|
+
'f64.eq', 'f64.ne', 'f64.lt', 'f64.gt', 'f64.le', 'f64.ge',
|
|
561
|
+
'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',
|
|
562
|
+
'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',
|
|
563
|
+
'f64.convert_i32_s', 'f64.convert_i32_u', 'f64.convert_i64_s', 'f64.convert_i64_u',
|
|
564
|
+
'i32.trunc_sat_f64_s', 'i32.trunc_sat_f64_u', 'i64.trunc_sat_f64_s', 'i64.trunc_sat_f64_u',
|
|
565
|
+
'i32.wrap_i64', 'i64.extend_i32_s', 'i64.extend_i32_u',
|
|
566
|
+
'f64.reinterpret_i64', 'i64.reinterpret_f64', 'f32.reinterpret_i32', 'i32.reinterpret_f32',
|
|
567
|
+
'f64.promote_f32', 'f32.demote_f64', 'select',
|
|
568
|
+
])
|
|
569
|
+
|
|
580
570
|
/**
|
|
581
|
-
*
|
|
582
|
-
*
|
|
583
|
-
*
|
|
571
|
+
* Unified loop-invariant code motion. One principle replaces the three former
|
|
572
|
+
* pattern hoists (ToInt32 / __ptr_offset / cell-load): a MAXIMAL pure subtree
|
|
573
|
+
* whose every free input is loop-invariant is computed once before the loop, in
|
|
574
|
+
* a fresh snap local.
|
|
584
575
|
*
|
|
585
|
-
*
|
|
586
|
-
*
|
|
587
|
-
*
|
|
588
|
-
*
|
|
589
|
-
*
|
|
576
|
+
* Invariance/purity (`pureGiven`) is closed over PURE_LICM_OPS plus two memory-
|
|
577
|
+
* touching leaves admitted only under the loop's effect summary (`collectMutations`):
|
|
578
|
+
* - (f64.load (local.get $cell_X)) iff no f64.store to $cell_X and no call in loop
|
|
579
|
+
* - (call $__ptr_offset|__ptr_type|__ptr_aux|__len …) iff no non-whitelisted call
|
|
580
|
+
* — exactly the old per-pass barriers, generalized. A subtree may also WRITE a
|
|
581
|
+
* local via (local.tee P E) iff P is private to the subtree (occurs nowhere else
|
|
582
|
+
* in the loop); this hoists the guarded-ToInt32 form
|
|
583
|
+
* (select (i32.wrap_i64 (i64.trunc_sat_f64_s (local.tee P E))) 0 (f64.ne (get P) G))
|
|
584
|
+
* as a unit — which the old leaf-only matcher could not (it needed a bare local).
|
|
590
585
|
*
|
|
591
|
-
*
|
|
592
|
-
*
|
|
593
|
-
*
|
|
594
|
-
* where X is invariant. Cleanup of the chained `local.set` movs is handled by
|
|
595
|
-
* watr CSE/DCE.
|
|
586
|
+
* Bottom-up (inner loops first → progressive climbing), refcount-guarded against
|
|
587
|
+
* watr's shared CSE subtrees, snaps spliced before the loop, decls at bodyStart.
|
|
588
|
+
* Idempotent: re-running sees only `(local.get $__liN)` and finds nothing to do.
|
|
596
589
|
*/
|
|
597
|
-
export function
|
|
590
|
+
export function hoistInvariantLoop(fn) {
|
|
598
591
|
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
599
592
|
const bodyStart = findBodyStart(fn)
|
|
600
593
|
if (bodyStart < 0) return
|
|
601
594
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
const
|
|
595
|
+
// Cheap early-out: no loop ⇒ nothing to hoist (skip the buildRefcount walk).
|
|
596
|
+
let hasLoop = false
|
|
597
|
+
const scanLoop = (n) => {
|
|
598
|
+
if (!Array.isArray(n) || hasLoop) return
|
|
599
|
+
if (n[0] === 'loop') { hasLoop = true; return }
|
|
600
|
+
for (let i = 1; i < n.length && !hasLoop; i++) scanLoop(n[i])
|
|
601
|
+
}
|
|
602
|
+
for (let i = bodyStart; i < fn.length && !hasLoop; i++) scanLoop(fn[i])
|
|
603
|
+
if (!hasLoop) return
|
|
605
604
|
|
|
606
|
-
//
|
|
607
|
-
|
|
608
|
-
const
|
|
609
|
-
|
|
610
|
-
const
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
605
|
+
// Result wasm type of a hoistable node (for the snap local decl). null ⇒ can't
|
|
606
|
+
// type it ⇒ don't hoist. Param/local types come from the func header.
|
|
607
|
+
const localTypes = new Map()
|
|
608
|
+
for (let i = 2; i < bodyStart; i++) {
|
|
609
|
+
const c = fn[i]
|
|
610
|
+
if (Array.isArray(c) && (c[0] === 'param' || c[0] === 'local') && typeof c[1] === 'string') localTypes.set(c[1], c[2])
|
|
611
|
+
}
|
|
612
|
+
const resultType = (node) => {
|
|
613
|
+
if (!Array.isArray(node)) return null
|
|
614
|
+
const op = node[0]
|
|
615
|
+
if (op === 'select') return resultType(node[1])
|
|
616
|
+
if (op === 'call') return SAFE_OFFSET_CALLS.has(node[1]) ? 'i32' : null // whitelist all return i32
|
|
617
|
+
if (op === 'local.get' || op === 'local.tee') return localTypes.get(node[1]) ?? null
|
|
618
|
+
const dot = op.indexOf('.')
|
|
619
|
+
if (dot < 0) return null
|
|
620
|
+
// Comparisons and `eqz` yield i32 regardless of operand type (i64.eq, f64.lt,
|
|
621
|
+
// i64.eqz, …) — so the operand-type prefix would mistype them. Catch first.
|
|
622
|
+
const m = op.slice(dot + 1)
|
|
623
|
+
if (m === 'eqz' || /^(eq|ne|lt|gt|le|ge)(_[su])?$/.test(m)) return 'i32'
|
|
624
|
+
const p = op.slice(0, dot)
|
|
625
|
+
if (p === 'i32' || p === 'i64' || p === 'f64' || p === 'f32') return p
|
|
626
|
+
return null
|
|
614
627
|
}
|
|
615
|
-
|
|
628
|
+
|
|
629
|
+
// Collision-proof snap ids: skip EVERY existing $__li id, not just start at the
|
|
630
|
+
// lowest free one. watr can renumber/coalesce locals between the pre- and
|
|
631
|
+
// post-watr optimize phases, leaving a non-contiguous $__li set; a lowest-free +
|
|
632
|
+
// sequential-increment scheme would then re-issue an in-use id (Duplicate local).
|
|
633
|
+
const usedLi = new Set()
|
|
634
|
+
const scanLi = (n) => {
|
|
635
|
+
if (!Array.isArray(n)) return
|
|
636
|
+
if (n[0] === 'local' && typeof n[1] === 'string' && n[1].startsWith('$__li')) {
|
|
637
|
+
const t = n[1].slice(5); if (/^\d+$/.test(t)) usedLi.add(+t)
|
|
638
|
+
}
|
|
639
|
+
for (let i = 0; i < n.length; i++) scanLi(n[i])
|
|
640
|
+
}
|
|
641
|
+
scanLi(fn)
|
|
642
|
+
let snapCounter = 0
|
|
643
|
+
const freshSnap = () => { while (usedLi.has(snapCounter)) snapCounter++; const id = snapCounter++; usedLi.add(id); return `$__li${id}` }
|
|
644
|
+
const newLocals = []
|
|
645
|
+
const refcount = buildRefcount(fn)
|
|
616
646
|
|
|
617
647
|
const processLoop = (loopNode) => {
|
|
618
|
-
//
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
if (
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
// any non-safe call. Non-safe calls could realloc/move the underlying
|
|
627
|
-
// array storage and invalidate cached offsets.
|
|
628
|
-
const writes = new Set()
|
|
629
|
-
let unsafe = false
|
|
648
|
+
// Inner loops first (bottom-up) — an inner hoist creates a local.get the
|
|
649
|
+
// outer level can hoist further.
|
|
650
|
+
for (let i = 1; i < loopNode.length; i++)
|
|
651
|
+
if (Array.isArray(loopNode[i])) processNode(loopNode[i], loopNode, i)
|
|
652
|
+
|
|
653
|
+
// The loop's effect summary (scans nested loops too — conservative).
|
|
654
|
+
const locals = new Set(), globals = new Set(), storedCells = new Set()
|
|
655
|
+
let hasUnsafeCall = false, hasAnyCall = false
|
|
630
656
|
const scan = (node) => {
|
|
631
657
|
if (!Array.isArray(node)) return
|
|
632
658
|
const op = node[0]
|
|
633
|
-
if (op === 'local.set' || op === 'local.tee') {
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
if (
|
|
640
|
-
for (let i = 2; i < node.length; i++) scan(node[i])
|
|
641
|
-
return
|
|
642
|
-
}
|
|
643
|
-
if (op === 'call_ref' || op === 'call_indirect') {
|
|
644
|
-
unsafe = true
|
|
645
|
-
for (let i = 1; i < node.length; i++) scan(node[i])
|
|
646
|
-
return
|
|
659
|
+
if (op === 'local.set' || op === 'local.tee') { if (typeof node[1] === 'string') locals.add(node[1]); for (let i = 2; i < node.length; i++) scan(node[i]); return }
|
|
660
|
+
if (op === 'global.set') { if (typeof node[1] === 'string') globals.add(node[1]); for (let i = 2; i < node.length; i++) scan(node[i]); return }
|
|
661
|
+
if (op === 'call') { hasAnyCall = true; if (!SAFE_OFFSET_CALLS.has(node[1])) hasUnsafeCall = true; for (let i = 2; i < node.length; i++) scan(node[i]); return }
|
|
662
|
+
if (op === 'call_ref' || op === 'call_indirect') { hasAnyCall = hasUnsafeCall = true; for (let i = 1; i < node.length; i++) scan(node[i]); return }
|
|
663
|
+
if ((op === 'f64.store' || op === 'i32.store') && node.length >= 3) {
|
|
664
|
+
const a = node[1]
|
|
665
|
+
if (Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' && a[1].startsWith(CELL_PREFIX)) storedCells.add(a[1])
|
|
647
666
|
}
|
|
648
667
|
for (let i = 1; i < node.length; i++) scan(node[i])
|
|
649
668
|
}
|
|
650
669
|
for (let i = 1; i < loopNode.length; i++) scan(loopNode[i])
|
|
651
|
-
if (unsafe) return []
|
|
652
670
|
|
|
653
|
-
//
|
|
654
|
-
//
|
|
655
|
-
|
|
671
|
+
// Per-subtree local-occurrence counts and write-sets, memoized bottom-up —
|
|
672
|
+
// the tee-privacy check queries them for EVERY candidate node, and the old
|
|
673
|
+
// per-query re-walk (countIn/gatherBound) was quadratic on watr-scale loop
|
|
674
|
+
// bodies (the single largest compile-time hotspot, ~200ms/compile). All
|
|
675
|
+
// queries happen during `collect`, before any splice mutates the loop, so
|
|
676
|
+
// the memo cannot go stale; it is dropped with this processLoop frame.
|
|
677
|
+
const countsMemo = new Map() // node → Map(local → occurrences in subtree)
|
|
678
|
+
const writesMemoL = new Map() // node → Set(locals written in subtree)
|
|
679
|
+
const EMPTY_COUNTS = new Map(), EMPTY_WRITES = new Set()
|
|
680
|
+
const countsOf = (node) => {
|
|
681
|
+
if (!Array.isArray(node)) return EMPTY_COUNTS
|
|
682
|
+
let m = countsMemo.get(node)
|
|
683
|
+
if (m) return m
|
|
684
|
+
m = new Map()
|
|
685
|
+
const op = node[0]
|
|
686
|
+
if ((op === 'local.get' || op === 'local.set' || op === 'local.tee') && typeof node[1] === 'string')
|
|
687
|
+
m.set(node[1], 1)
|
|
688
|
+
for (let i = 1; i < node.length; i++)
|
|
689
|
+
for (const [k, v] of countsOf(node[i])) m.set(k, (m.get(k) || 0) + v)
|
|
690
|
+
countsMemo.set(node, m)
|
|
691
|
+
return m
|
|
692
|
+
}
|
|
693
|
+
const writesIn = (node) => {
|
|
694
|
+
if (!Array.isArray(node)) return EMPTY_WRITES
|
|
695
|
+
let s = writesMemoL.get(node)
|
|
696
|
+
if (s) return s
|
|
697
|
+
s = new Set()
|
|
698
|
+
if ((node[0] === 'local.set' || node[0] === 'local.tee') && typeof node[1] === 'string') s.add(node[1])
|
|
699
|
+
for (let i = 1; i < node.length; i++) for (const w of writesIn(node[i])) s.add(w)
|
|
700
|
+
writesMemoL.set(node, s)
|
|
701
|
+
return s
|
|
702
|
+
}
|
|
703
|
+
// Whole-loop counts (the former countLocals walk) — one memoized query.
|
|
704
|
+
const localCount = new Map()
|
|
705
|
+
for (let i = 1; i < loopNode.length; i++)
|
|
706
|
+
for (const [k, v] of countsOf(loopNode[i])) localCount.set(k, (localCount.get(k) || 0) + v)
|
|
707
|
+
|
|
708
|
+
// Pure & invariant given `bound` (locals written *within* the candidate, hence
|
|
709
|
+
// local to it). A read of a bound local is OK (its in-subtree value is the
|
|
710
|
+
// teed invariant). A free read must be unwritten by the loop.
|
|
711
|
+
const pureGiven = (node, bound) => {
|
|
712
|
+
if (!Array.isArray(node)) return true // bare operand string/number
|
|
713
|
+
const op = node[0]
|
|
714
|
+
if (op === 'i32.const' || op === 'i64.const' || op === 'f64.const' || op === 'f32.const') return true
|
|
715
|
+
if (op === 'local.get') return typeof node[1] === 'string' && (bound.has(node[1]) || !locals.has(node[1]))
|
|
716
|
+
// A global is invariant only if not set directly AND no call in the loop —
|
|
717
|
+
// any callee may mutate it (no interprocedural effect analysis). (Locals are
|
|
718
|
+
// frame-private, so calls can't touch them; only direct local.set matters.)
|
|
719
|
+
if (op === 'global.get') return typeof node[1] === 'string' && !globals.has(node[1]) && !hasAnyCall
|
|
720
|
+
if (op === 'local.tee') {
|
|
721
|
+
if (typeof node[1] !== 'string') return false
|
|
722
|
+
// The operand is evaluated BEFORE the tee writes $X, so a `local.get $X` inside
|
|
723
|
+
// it reads the loop-carried (previous-iteration) value, not the teed one. Drop
|
|
724
|
+
// $X from `bound` for the operand: `local.tee $X (… $X …)` is a loop recurrence
|
|
725
|
+
// (X = f(X) — e.g. the `while ((nn = nn >>> 1))` induction), NOT invariant.
|
|
726
|
+
const inner = bound.has(node[1]) ? new Set([...bound].filter(b => b !== node[1])) : bound
|
|
727
|
+
return pureGiven(node[2], inner)
|
|
728
|
+
}
|
|
729
|
+
if ((op === 'f64.load' || op === 'i32.load') && node.length === 2) {
|
|
730
|
+
const a = node[1]
|
|
731
|
+
return Array.isArray(a) && a[0] === 'local.get' && typeof a[1] === 'string' && a[1].startsWith(CELL_PREFIX)
|
|
732
|
+
&& !hasAnyCall && !storedCells.has(a[1]) && (bound.has(a[1]) || !locals.has(a[1]))
|
|
733
|
+
}
|
|
734
|
+
if (op === 'call') return SAFE_OFFSET_CALLS.has(node[1]) && !hasUnsafeCall && node.slice(2).every(c => pureGiven(c, bound))
|
|
735
|
+
if (PURE_LICM_OPS.has(op)) return node.slice(1).every(c => pureGiven(c, bound))
|
|
736
|
+
return false
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const isHoistable = (node) => {
|
|
740
|
+
if (!Array.isArray(node)) return false
|
|
741
|
+
const op = node[0]
|
|
742
|
+
// Skip trivial leaves: hoisting a bare get/const buys nothing.
|
|
743
|
+
if (op === 'local.get' || op === 'global.get' || op === 'i32.const' || op === 'i64.const' || op === 'f64.const' || op === 'f32.const') return false
|
|
744
|
+
const bound = writesIn(node)
|
|
745
|
+
// Every local the subtree writes must be private to it (no other use in the
|
|
746
|
+
// loop) — else moving the write to the pre-header changes another reader.
|
|
747
|
+
for (const b of bound) if (localCount.get(b) !== countsOf(node).get(b)) return false
|
|
748
|
+
// Only hoist what V8's wasm tier won't (a HARD_OP) — leave pure arithmetic
|
|
749
|
+
// for V8's own LICM and the lane-vectorizer.
|
|
750
|
+
return hasHardOp(node) && pureGiven(node, bound)
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Maximal extraction: take the largest hoistable subtree; don't descend into
|
|
754
|
+
// it. Dedup structurally so a repeated invariant expr shares one snap local.
|
|
755
|
+
const sites = new Map() // structural key → [{ parent, idx, node }]
|
|
656
756
|
const collect = (node, parent, idx) => {
|
|
657
757
|
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
|
-
}
|
|
758
|
+
if (node[0] === 'loop') return // already processed bottom-up
|
|
759
|
+
if (isHoistable(node) && (refcount.get(node) || 0) <= 1 && (refcount.get(parent) || 0) <= 1) {
|
|
760
|
+
// BigInt-safe: hoistable boxed-pointer subtrees carry i64.const NaN-box prefixes
|
|
761
|
+
// (BigInt values) that plain JSON.stringify can't serialize — see ast.bigintSafeKey.
|
|
762
|
+
const key = JSON.stringify(node, (_k, v) => typeof v === 'bigint' ? `${v}n` : v)
|
|
763
|
+
let arr = sites.get(key); if (!arr) { arr = []; sites.set(key, arr) }
|
|
764
|
+
arr.push({ parent, idx, node })
|
|
671
765
|
return
|
|
672
766
|
}
|
|
673
767
|
for (let i = 0; i < node.length; i++) collect(node[i], node, i)
|
|
@@ -675,22 +769,20 @@ export function hoistInvariantPtrOffsetLoop(fn) {
|
|
|
675
769
|
for (let i = 1; i < loopNode.length; i++) collect(loopNode[i], loopNode, i)
|
|
676
770
|
|
|
677
771
|
const snaps = []
|
|
678
|
-
for (const [
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
}
|
|
772
|
+
for (const [, arr] of sites) {
|
|
773
|
+
const type = resultType(arr[0].node)
|
|
774
|
+
if (type == null) continue
|
|
775
|
+
const snapName = freshSnap()
|
|
776
|
+
newLocals.push(['local', snapName, type])
|
|
777
|
+
snaps.push(['local.set', snapName, arr[0].node]) // reuse first node verbatim
|
|
778
|
+
for (const { parent, idx } of arr) parent[idx] = ['local.get', snapName]
|
|
686
779
|
}
|
|
687
780
|
return snaps
|
|
688
781
|
}
|
|
689
782
|
|
|
690
783
|
const processNode = (node, parent, idx) => {
|
|
691
784
|
if (!Array.isArray(node)) return
|
|
692
|
-
|
|
693
|
-
if (op === 'loop') {
|
|
785
|
+
if (node[0] === 'loop') {
|
|
694
786
|
const snaps = processLoop(node)
|
|
695
787
|
if (snaps.length) parent.splice(idx, 0, ...snaps)
|
|
696
788
|
return
|
|
@@ -699,166 +791,181 @@ export function hoistInvariantPtrOffsetLoop(fn) {
|
|
|
699
791
|
}
|
|
700
792
|
|
|
701
793
|
for (let i = bodyStart; i < fn.length; i++) processNode(fn[i], fn, i)
|
|
702
|
-
|
|
703
794
|
if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
|
|
704
795
|
}
|
|
705
796
|
|
|
706
797
|
/**
|
|
707
|
-
*
|
|
798
|
+
* Narrow an f64 loop bound to i32. `for (let i = 0; i < n; i++)` with an f64
|
|
799
|
+
* param `n` emits `(f64.lt (f64.convert_i32_s $i) (local.get $n))` — an f64
|
|
800
|
+
* convert+compare every iteration that ALSO blocks the lane-vectorizer (it
|
|
801
|
+
* requires an i32-governed trip count). The naive-DSP export shape
|
|
802
|
+
* `(ptr, n) => { for (i = 0; i < n; i++) … }` therefore never vectorized
|
|
803
|
+
* without a hand-written `n|0`. This pass is that annotation, as a proof.
|
|
708
804
|
*
|
|
709
|
-
*
|
|
710
|
-
*
|
|
711
|
-
*
|
|
712
|
-
*
|
|
713
|
-
*
|
|
805
|
+
* When $i is a proven-non-negative i32 counter and $n is loop-invariant:
|
|
806
|
+
* convert_i32_s(i) < n ⟺ i < trunc_sat(ceil(n)) for all i ≥ 0
|
|
807
|
+
* - fractional n rounds up (i < 5.5 ⟺ i < 6); integral n exact
|
|
808
|
+
* - NaN: ceil→NaN, trunc_sat→0 ⇒ `i < 0` false — matches the false f64 compare
|
|
809
|
+
* (THIS case is why i ≥ 0 must be proven: a negative i would flip it true)
|
|
810
|
+
* - n ≤ −2³¹ saturates to INT32_MIN ⇒ always false — matches
|
|
811
|
+
* - n ≥ 2³¹ saturates to INT32_MAX ⇒ terminates after 2³¹−1 iterations where
|
|
812
|
+
* the original wrapped $i negative and spun forever — the only divergence,
|
|
813
|
+
* pathological in both versions (a JS double counter would keep counting).
|
|
814
|
+
* Non-negativity proof: $i is a non-param i32 local whose EVERY write in the
|
|
815
|
+
* function (counters get re-zeroed between loops) is a non-negative i32.const
|
|
816
|
+
* or `$i + positive-const`. Wrap-around past 2³¹ needs 2³¹ agreeing iterations
|
|
817
|
+
* first, so trajectories are identical in every non-pathological program.
|
|
714
818
|
*
|
|
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.
|
|
819
|
+
* Snap `(local.set $__lbK (i32.trunc_sat_f64_s (f64.ceil (local.get $n))))`
|
|
820
|
+
* goes in the loop pre-header (re-snapped per outer iteration when nested —
|
|
821
|
+
* trunc_sat/ceil are total, safe even for zero-trip loops); the compare becomes
|
|
822
|
+
* `(i32.lt_s $i $__lbK)` — the exact shape the lane-vectorizer matches.
|
|
823
|
+
* Bottom-up, refcount-guarded, idempotent (rewritten conds no longer match).
|
|
722
824
|
*/
|
|
723
|
-
export function
|
|
825
|
+
export function narrowLoopBound(fn) {
|
|
724
826
|
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
725
827
|
const bodyStart = findBodyStart(fn)
|
|
726
828
|
if (bodyStart < 0) return
|
|
727
829
|
|
|
728
|
-
|
|
729
|
-
|
|
830
|
+
// Cheap early-out: no loop ⇒ nothing to narrow.
|
|
831
|
+
let hasLoop = false
|
|
832
|
+
const scanLoop = (n) => {
|
|
833
|
+
if (!Array.isArray(n) || hasLoop) return
|
|
834
|
+
if (n[0] === 'loop') { hasLoop = true; return }
|
|
835
|
+
for (let i = 1; i < n.length && !hasLoop; i++) scanLoop(n[i])
|
|
836
|
+
}
|
|
837
|
+
for (let i = bodyStart; i < fn.length && !hasLoop; i++) scanLoop(fn[i])
|
|
838
|
+
if (!hasLoop) return
|
|
839
|
+
|
|
840
|
+
// Header types. Params are excluded as counters: their init is caller-supplied,
|
|
841
|
+
// so non-negativity is unprovable.
|
|
842
|
+
const localTypes = new Map(), params = new Set()
|
|
843
|
+
for (let i = 2; i < bodyStart; i++) {
|
|
844
|
+
const c = fn[i]
|
|
845
|
+
if (!Array.isArray(c) || typeof c[1] !== 'string') continue
|
|
846
|
+
if (c[0] === 'param') params.add(c[1])
|
|
847
|
+
if (c[0] === 'param' || c[0] === 'local') localTypes.set(c[1], c[2])
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// Every write per local across the WHOLE function — not just in-loop: a counter
|
|
851
|
+
// reused by a later loop is re-zeroed between them, and a negative write
|
|
852
|
+
// anywhere voids the proof.
|
|
853
|
+
const writes = new Map()
|
|
854
|
+
const collectWrites = (n) => {
|
|
855
|
+
if (!Array.isArray(n)) return
|
|
856
|
+
if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') {
|
|
857
|
+
let arr = writes.get(n[1]); if (!arr) writes.set(n[1], arr = [])
|
|
858
|
+
arr.push(n[2])
|
|
859
|
+
}
|
|
860
|
+
for (let i = 1; i < n.length; i++) collectWrites(n[i])
|
|
861
|
+
}
|
|
862
|
+
for (let i = bodyStart; i < fn.length; i++) collectWrites(fn[i])
|
|
863
|
+
|
|
864
|
+
const constVal = (n) => Array.isArray(n) && n[0] === 'i32.const' ? Number(n[1]) : NaN
|
|
865
|
+
const nonNegCounter = (name) => {
|
|
866
|
+
if (params.has(name) || localTypes.get(name) !== 'i32') return false
|
|
867
|
+
const ws = writes.get(name)
|
|
868
|
+
if (!ws) return true // never written ⇒ stays at default 0
|
|
869
|
+
return ws.every(v => {
|
|
870
|
+
if (!Array.isArray(v)) return false
|
|
871
|
+
if (v[0] === 'i32.const') return Number(v[1]) >= 0
|
|
872
|
+
if (v[0] !== 'i32.add') return false
|
|
873
|
+
if (Array.isArray(v[1]) && v[1][0] === 'local.get' && v[1][1] === name) return constVal(v[2]) > 0
|
|
874
|
+
if (Array.isArray(v[2]) && v[2][0] === 'local.get' && v[2][1] === name) return constVal(v[1]) > 0
|
|
875
|
+
return false
|
|
876
|
+
})
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// Collision-proof snap ids (same scheme as hoistInvariantLoop's $__li).
|
|
880
|
+
const usedLb = new Set()
|
|
881
|
+
const scanLb = (n) => {
|
|
882
|
+
if (!Array.isArray(n)) return
|
|
883
|
+
if (n[0] === 'local' && typeof n[1] === 'string' && n[1].startsWith('$__lb')) {
|
|
884
|
+
const t = n[1].slice(5); if (/^\d+$/.test(t)) usedLb.add(+t)
|
|
885
|
+
}
|
|
886
|
+
for (let i = 0; i < n.length; i++) scanLb(n[i])
|
|
887
|
+
}
|
|
888
|
+
scanLb(fn)
|
|
889
|
+
let lbCounter = 0
|
|
890
|
+
const freshLb = () => { while (usedLb.has(lbCounter)) lbCounter++; const id = lbCounter++; usedLb.add(id); return `$__lb${id}` }
|
|
730
891
|
const newLocals = []
|
|
892
|
+
const refcount = buildRefcount(fn)
|
|
731
893
|
|
|
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])
|
|
894
|
+
// `(f64.lt (f64.convert_i32_s (local.get $i)) (local.get $n))` or mirrored
|
|
895
|
+
// `(f64.gt (local.get $n) (f64.convert_i32_s (local.get $i)))`.
|
|
896
|
+
const match = (n) => {
|
|
897
|
+
const conv = n[0] === 'f64.lt' ? n[1] : n[0] === 'f64.gt' ? n[2] : null
|
|
898
|
+
const bnd = n[0] === 'f64.lt' ? n[2] : n[0] === 'f64.gt' ? n[1] : null
|
|
899
|
+
if (!Array.isArray(conv) || conv[0] !== 'f64.convert_i32_s') return null
|
|
900
|
+
const ig = conv[1]
|
|
901
|
+
if (!Array.isArray(ig) || ig[0] !== 'local.get' || typeof ig[1] !== 'string') return null
|
|
902
|
+
if (!Array.isArray(bnd) || bnd[0] !== 'local.get' || typeof bnd[1] !== 'string') return null
|
|
903
|
+
return { ctr: ig[1], bound: bnd[1] }
|
|
744
904
|
}
|
|
745
|
-
countRefs(fn)
|
|
746
905
|
|
|
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
906
|
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])
|
|
907
|
+
// Inner loops first — their sites belong to their own pre-header (the bound
|
|
908
|
+
// may be written by THIS loop between inner runs).
|
|
909
|
+
for (let i = 1; i < loopNode.length; i++)
|
|
910
|
+
if (Array.isArray(loopNode[i])) processNode(loopNode[i], loopNode, i)
|
|
911
|
+
|
|
912
|
+
// Locals written anywhere in this loop (incl. nested) — bound invariance.
|
|
913
|
+
const written = new Set()
|
|
914
|
+
const scanW = (n) => {
|
|
915
|
+
if (!Array.isArray(n)) return
|
|
916
|
+
if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') written.add(n[1])
|
|
917
|
+
for (let i = 1; i < n.length; i++) scanW(n[i])
|
|
791
918
|
}
|
|
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 []
|
|
919
|
+
for (let i = 1; i < loopNode.length; i++) scanW(loopNode[i])
|
|
797
920
|
|
|
798
|
-
|
|
799
|
-
const collect = (node
|
|
921
|
+
const sites = []
|
|
922
|
+
const collect = (node) => {
|
|
800
923
|
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)
|
|
924
|
+
if (node[0] === 'loop') return // already processed bottom-up
|
|
925
|
+
const m = match(node)
|
|
926
|
+
if (m && (refcount.get(node) || 0) <= 1
|
|
927
|
+
&& localTypes.get(m.bound) === 'f64' && !written.has(m.bound)
|
|
928
|
+
&& nonNegCounter(m.ctr)) { sites.push({ node, m }); return }
|
|
929
|
+
for (let i = 1; i < node.length; i++) collect(node[i])
|
|
822
930
|
}
|
|
823
|
-
for (let i = 1; i < loopNode.length; i++) collect(loopNode[i]
|
|
931
|
+
for (let i = 1; i < loopNode.length; i++) collect(loopNode[i])
|
|
824
932
|
|
|
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.
|
|
933
|
+
const snapFor = new Map() // bound name → snap local (one per distinct bound)
|
|
829
934
|
const snaps = []
|
|
830
|
-
for (const
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
935
|
+
for (const { node, m } of sites) {
|
|
936
|
+
let snap = snapFor.get(m.bound)
|
|
937
|
+
if (!snap) {
|
|
938
|
+
snap = freshLb()
|
|
939
|
+
snapFor.set(m.bound, snap)
|
|
940
|
+
newLocals.push(['local', snap, 'i32'])
|
|
941
|
+
snaps.push(['local.set', snap, ['i32.trunc_sat_f64_s', ['f64.ceil', ['local.get', m.bound]]]])
|
|
837
942
|
}
|
|
943
|
+
node.length = 3
|
|
944
|
+
node[0] = 'i32.lt_s'; node[1] = ['local.get', m.ctr]; node[2] = ['local.get', snap]
|
|
838
945
|
}
|
|
839
946
|
return snaps
|
|
840
947
|
}
|
|
841
948
|
|
|
842
|
-
// Recursive node walker that splices snap decls before nested loops.
|
|
843
949
|
const processNode = (node, parent, idx) => {
|
|
844
950
|
if (!Array.isArray(node)) return
|
|
845
|
-
|
|
846
|
-
|
|
951
|
+
// Break-block idiom `(block $brk (loop …))`: snaps go BEFORE the block —
|
|
952
|
+
// any statement between the block label and the loop is "foreign content"
|
|
953
|
+
// to the lane-vectorizer's matcher and would defeat the whole point.
|
|
954
|
+
if (node[0] === 'block' && typeof node[1] === 'string' && node.length === 3
|
|
955
|
+
&& Array.isArray(node[2]) && node[2][0] === 'loop') {
|
|
956
|
+
const snaps = processLoop(node[2])
|
|
957
|
+
if (snaps.length) parent.splice(idx, 0, ...snaps)
|
|
958
|
+
return
|
|
959
|
+
}
|
|
960
|
+
if (node[0] === 'loop') {
|
|
847
961
|
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
|
-
}
|
|
962
|
+
if (snaps.length) parent.splice(idx, 0, ...snaps)
|
|
853
963
|
return
|
|
854
964
|
}
|
|
855
965
|
for (let i = 0; i < node.length; i++) processNode(node[i], node, i)
|
|
856
966
|
}
|
|
857
967
|
|
|
858
|
-
for (let i = bodyStart; i < fn.length; i++)
|
|
859
|
-
processNode(fn[i], fn, i)
|
|
860
|
-
}
|
|
861
|
-
|
|
968
|
+
for (let i = bodyStart; i < fn.length; i++) processNode(fn[i], fn, i)
|
|
862
969
|
if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
|
|
863
970
|
}
|
|
864
971
|
|
|
@@ -900,8 +1007,7 @@ export function cseScalarLoad(fn) {
|
|
|
900
1007
|
const bases = fn.cseLoadBases
|
|
901
1008
|
if (!(bases instanceof Set) || bases.size === 0) return
|
|
902
1009
|
|
|
903
|
-
let snapId =
|
|
904
|
-
while (fn.some(n => Array.isArray(n) && n[0] === 'local' && n[1] === `$__cs${snapId}`)) snapId++
|
|
1010
|
+
let snapId = nextLocalId(fn, 'cs')
|
|
905
1011
|
const newLocals = []
|
|
906
1012
|
|
|
907
1013
|
// CSE table: key `${X}|${K}` → { snapName | null, anchorParent, anchorIdx }
|
|
@@ -1057,6 +1163,11 @@ export function cseScalarLoad(fn) {
|
|
|
1057
1163
|
* the surrounding `local.set/tee` handles that)
|
|
1058
1164
|
* - `br/br_if/br_table/return/unreachable` → NO clear (pure values still valid)
|
|
1059
1165
|
*/
|
|
1166
|
+
// Commutative WASM binops — shared by csePureExpr + csePureExprLoop for canonical
|
|
1167
|
+
// operand-key ordering (a*b and b*a hash to one entry). OP_TYPE tables stay local:
|
|
1168
|
+
// the two passes cover deliberately different op sets.
|
|
1169
|
+
const COMMUTATIVE = new Set(['f64.mul', 'f64.add', 'i32.mul', 'i32.add', 'i32.and', 'i32.or', 'i32.xor', 'i64.mul', 'i64.add', 'i64.and', 'i64.or', 'i64.xor'])
|
|
1170
|
+
|
|
1060
1171
|
export function csePureExpr(fn) {
|
|
1061
1172
|
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
1062
1173
|
const bodyStart = findBodyStart(fn)
|
|
@@ -1073,8 +1184,8 @@ export function csePureExpr(fn) {
|
|
|
1073
1184
|
if (m) { const k = +m[1]; if (k >= snapId) snapId = k + 1 }
|
|
1074
1185
|
}
|
|
1075
1186
|
const newLocals = []
|
|
1187
|
+
let refcount = null // lazily built on the first dedup — most fns never CSE
|
|
1076
1188
|
|
|
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
1189
|
const TARGET_OPS = new Set([
|
|
1079
1190
|
'f64.mul', 'f64.add', 'f64.sub',
|
|
1080
1191
|
'i32.mul', 'i32.add', 'i32.sub', 'i32.shl', 'i32.shr_u', 'i32.shr_s', 'i32.and', 'i32.or', 'i32.xor',
|
|
@@ -1149,6 +1260,12 @@ export function csePureExpr(fn) {
|
|
|
1149
1260
|
const entry = table.get(key)
|
|
1150
1261
|
if (entry) {
|
|
1151
1262
|
if (!entry.snapName) {
|
|
1263
|
+
// A shared (DAG) anchor breaks the in-place tee: the `%` fast-path emits
|
|
1264
|
+
// `a - trunc(a/b)*b` reusing ONE `a` node object, so the anchor and the
|
|
1265
|
+
// local.get replacement land on the SAME physical slot and the local.get
|
|
1266
|
+
// clobbers the tee — orphaning $__pe (reads 0). Skip when the anchor's
|
|
1267
|
+
// parent is shared; watr's DAG-aware CSE still dedupes. Mirrors csePureExprLoop.
|
|
1268
|
+
if (((refcount ??= buildRefcount(fn)).get(entry.anchorParent) || 0) > 1) return
|
|
1152
1269
|
const snapName = `$__pe${snapId++}`
|
|
1153
1270
|
entry.snapName = snapName
|
|
1154
1271
|
newLocals.push(['local', snapName, OP_TYPE[op] || 'f64'])
|
|
@@ -1176,6 +1293,189 @@ export function csePureExpr(fn) {
|
|
|
1176
1293
|
if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
|
|
1177
1294
|
}
|
|
1178
1295
|
|
|
1296
|
+
/**
|
|
1297
|
+
* Post-watr nested CSE for hot fill loops (loop + trig). Reuses `$__pe` locals from
|
|
1298
|
+
* the pre-watr leaf pass. Deferred to the `phase === 'post'` run so watr's typed-array
|
|
1299
|
+
* inlining is not confused by pre-watr IR rewrites (test/mem.js).
|
|
1300
|
+
*/
|
|
1301
|
+
export function csePureExprLoop(fn) {
|
|
1302
|
+
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
1303
|
+
const bodyStart = findBodyStart(fn)
|
|
1304
|
+
if (bodyStart < 0) return
|
|
1305
|
+
|
|
1306
|
+
let hasLoop = false
|
|
1307
|
+
let hasTrigCall = false
|
|
1308
|
+
const scanShape = (n) => {
|
|
1309
|
+
if (!Array.isArray(n)) return
|
|
1310
|
+
if (n[0] === 'loop') hasLoop = true
|
|
1311
|
+
if (n[0] === 'call' && (n[1] === '$math.sin' || n[1] === '$math.cos'
|
|
1312
|
+
|| n[1] === '$math.sin_core' || n[1] === '$math.cos_core')) hasTrigCall = true
|
|
1313
|
+
for (let i = 1; i < n.length; i++) scanShape(n[i])
|
|
1314
|
+
}
|
|
1315
|
+
for (let i = bodyStart; i < fn.length; i++) scanShape(fn[i])
|
|
1316
|
+
if (!hasLoop || !hasTrigCall) return
|
|
1317
|
+
|
|
1318
|
+
let snapId = nextLocalId(fn, 'pe')
|
|
1319
|
+
const newLocals = []
|
|
1320
|
+
|
|
1321
|
+
const refcount = buildRefcount(fn)
|
|
1322
|
+
const canMutateSite = (parent, node) =>
|
|
1323
|
+
(refcount.get(node) || 0) <= 1 && (refcount.get(parent) || 0) <= 1
|
|
1324
|
+
|
|
1325
|
+
const PURE_F64_BIN = new Set(['f64.mul', 'f64.add', 'f64.sub', 'f64.div'])
|
|
1326
|
+
const PURE_F64_UNARY = new Set(['f64.neg', 'f64.abs', 'f64.convert_i32_s', 'f64.convert_i32_u'])
|
|
1327
|
+
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'])
|
|
1328
|
+
const PURE_I32_UNARY = new Set(['i32.eqz', 'i32.clz', 'i32.ctz', 'i32.popcnt'])
|
|
1329
|
+
const OP_TYPE = {
|
|
1330
|
+
'f64.mul': 'f64', 'f64.add': 'f64', 'f64.sub': 'f64', 'f64.div': 'f64', 'f64.neg': 'f64', 'f64.abs': 'f64',
|
|
1331
|
+
'f64.convert_i32_s': 'f64', 'f64.convert_i32_u': 'f64',
|
|
1332
|
+
'i32.mul': 'i32', 'i32.add': 'i32', 'i32.sub': 'i32', 'i32.shl': 'i32', 'i32.shr_u': 'i32', 'i32.shr_s': 'i32',
|
|
1333
|
+
'i32.and': 'i32', 'i32.or': 'i32', 'i32.xor': 'i32', 'i32.eqz': 'i32',
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
const table = new Map()
|
|
1337
|
+
const keyLocals = new Set()
|
|
1338
|
+
const keyGlobals = new Set()
|
|
1339
|
+
|
|
1340
|
+
const invalidateLocal = (X) => {
|
|
1341
|
+
for (const [key, entry] of table) {
|
|
1342
|
+
if (entry.locals.has(X)) table.delete(key)
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
const invalidateGlobal = (G) => {
|
|
1347
|
+
for (const [key, entry] of table) {
|
|
1348
|
+
if (entry.globals.has(G)) table.delete(key)
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
const pureKeyI32 = (n) => {
|
|
1353
|
+
if (!Array.isArray(n)) return null
|
|
1354
|
+
const op = n[0]
|
|
1355
|
+
if (op === 'local.get' && typeof n[1] === 'string') { keyLocals.add(n[1]); return `L:${n[1]}` }
|
|
1356
|
+
if (op === 'global.get' && typeof n[1] === 'string') { keyGlobals.add(n[1]); return `G:${n[1]}` }
|
|
1357
|
+
if (op === 'i32.const' || op === 'i64.const') return `C:${op}:${n[1]}`
|
|
1358
|
+
if (PURE_I32_UNARY.has(op) && n.length === 2) {
|
|
1359
|
+
const k = pureKeyI32(n[1]); return k ? `${op}|${k}` : null
|
|
1360
|
+
}
|
|
1361
|
+
if (PURE_I32_BIN.has(op) && n.length === 3) {
|
|
1362
|
+
const ka = pureKeyI32(n[1]), kb = pureKeyI32(n[2])
|
|
1363
|
+
if (!ka || !kb) return null
|
|
1364
|
+
return COMMUTATIVE.has(op) && ka > kb ? `${op}|${kb}|${ka}` : `${op}|${ka}|${kb}`
|
|
1365
|
+
}
|
|
1366
|
+
if (op === 'i32.wrap_i64' && n.length === 2) {
|
|
1367
|
+
const k = pureKeyI32(n[1]); return k ? `wrap|${k}` : null
|
|
1368
|
+
}
|
|
1369
|
+
return null
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
const pureKeyF64 = (n) => {
|
|
1373
|
+
if (!Array.isArray(n)) return null
|
|
1374
|
+
const op = n[0]
|
|
1375
|
+
if (op === 'local.get' && typeof n[1] === 'string') { keyLocals.add(n[1]); return `L:${n[1]}` }
|
|
1376
|
+
if (op === 'global.get' && typeof n[1] === 'string') { keyGlobals.add(n[1]); return `G:${n[1]}` }
|
|
1377
|
+
if (op === 'f64.const' || op === 'f32.const') return `C:${op}:${n[1]}`
|
|
1378
|
+
if (PURE_F64_UNARY.has(op) && n.length === 2) {
|
|
1379
|
+
if (op === 'f64.convert_i32_s' || op === 'f64.convert_i32_u') {
|
|
1380
|
+
const k = pureKeyI32(n[1]); return k ? `${op}|${k}` : null
|
|
1381
|
+
}
|
|
1382
|
+
const k = pureKeyF64(n[1]); return k ? `${op}|${k}` : null
|
|
1383
|
+
}
|
|
1384
|
+
if (PURE_F64_BIN.has(op) && n.length === 3) {
|
|
1385
|
+
const ka = pureKeyF64(n[1]), kb = pureKeyF64(n[2])
|
|
1386
|
+
if (!ka || !kb) return null
|
|
1387
|
+
return COMMUTATIVE.has(op) && ka > kb ? `${op}|${kb}|${ka}` : `${op}|${ka}|${kb}`
|
|
1388
|
+
}
|
|
1389
|
+
if (op === 'call' && n[1] === '$__to_num' && n.length === 3) {
|
|
1390
|
+
const a = n[2]
|
|
1391
|
+
if (Array.isArray(a) && a[0] === 'i64.reinterpret_f64' && a.length === 2) {
|
|
1392
|
+
const k = pureKeyF64(a[1]); return k ? `tonum|${k}` : null
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
return null
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
const tryCse = (node, parent, idx) => {
|
|
1399
|
+
const op = node[0]
|
|
1400
|
+
if (op === 'local.get' || op === 'global.get' || op === 'f64.const' || op === 'f32.const') return
|
|
1401
|
+
if (!canMutateSite(parent, node)) return
|
|
1402
|
+
keyLocals.clear()
|
|
1403
|
+
keyGlobals.clear()
|
|
1404
|
+
const key = pureKeyF64(node)
|
|
1405
|
+
if (!key) return
|
|
1406
|
+
const locals = new Set(keyLocals)
|
|
1407
|
+
const globals = new Set(keyGlobals)
|
|
1408
|
+
const entry = table.get(key)
|
|
1409
|
+
if (entry) {
|
|
1410
|
+
if (!entry.snapName) {
|
|
1411
|
+
if ((refcount.get(entry.anchorParent) || 0) > 1) return
|
|
1412
|
+
const snapName = `$__pe${snapId++}`
|
|
1413
|
+
entry.snapName = snapName
|
|
1414
|
+
newLocals.push(['local', snapName, OP_TYPE[node[0]] || 'f64'])
|
|
1415
|
+
const orig = entry.anchorParent[entry.anchorIdx]
|
|
1416
|
+
entry.anchorParent[entry.anchorIdx] = ['local.tee', snapName, orig]
|
|
1417
|
+
}
|
|
1418
|
+
parent[idx] = ['local.get', entry.snapName]
|
|
1419
|
+
} else {
|
|
1420
|
+
table.set(key, { snapName: null, anchorParent: parent, anchorIdx: idx, locals, globals })
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
const walk = (node, parent, idx) => {
|
|
1425
|
+
if (!Array.isArray(node)) return
|
|
1426
|
+
const op = node[0]
|
|
1427
|
+
|
|
1428
|
+
if (op === 'loop') {
|
|
1429
|
+
table.clear()
|
|
1430
|
+
for (let i = 1; i < node.length; i++) walk(node[i], node, i)
|
|
1431
|
+
table.clear()
|
|
1432
|
+
return
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
if (op === 'if') {
|
|
1436
|
+
table.clear()
|
|
1437
|
+
for (let i = 1; i < node.length; i++) walk(node[i], node, i)
|
|
1438
|
+
table.clear()
|
|
1439
|
+
return
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
if (op === 'then' || op === 'else') {
|
|
1443
|
+
table.clear()
|
|
1444
|
+
for (let i = 1; i < node.length; i++) walk(node[i], node, i)
|
|
1445
|
+
table.clear()
|
|
1446
|
+
return
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
if (op === 'call' || op === 'call_ref' || op === 'call_indirect') {
|
|
1450
|
+
for (let i = 1; i < node.length; i++) walk(node[i], node, i)
|
|
1451
|
+
return
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
if (op === 'local.set' || op === 'local.tee') {
|
|
1455
|
+
for (let i = 2; i < node.length; i++) walk(node[i], node, i)
|
|
1456
|
+
const X = node[1]
|
|
1457
|
+
if (typeof X === 'string') invalidateLocal(X)
|
|
1458
|
+
return
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
if (op === 'global.set') {
|
|
1462
|
+
for (let i = 2; i < node.length; i++) walk(node[i], node, i)
|
|
1463
|
+
const G = node[1]
|
|
1464
|
+
if (typeof G === 'string') invalidateGlobal(G)
|
|
1465
|
+
return
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
for (let i = 1; i < node.length; i++) {
|
|
1469
|
+
if (Array.isArray(node[i])) walk(node[i], node, i)
|
|
1470
|
+
}
|
|
1471
|
+
tryCse(node, parent, idx)
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
for (let i = bodyStart; i < fn.length; i++) walk(fn[i], fn, i)
|
|
1475
|
+
|
|
1476
|
+
if (newLocals.length) fn.splice(bodyStart, 0, ...newLocals)
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1179
1479
|
/**
|
|
1180
1480
|
* Drop redundant zero-initialisation of fresh function-scope locals.
|
|
1181
1481
|
*
|
|
@@ -1297,7 +1597,7 @@ export function deadStoreElim(fn) {
|
|
|
1297
1597
|
// an implicit stack value (e.g. a `try_table` catch payload) — removing it
|
|
1298
1598
|
// would unbalance the stack.
|
|
1299
1599
|
if (op === 'drop' && node.length === 2 && isPure(node[1])) {
|
|
1300
|
-
dead.push({ parent: items,
|
|
1600
|
+
dead.push({ parent: items, node, drop: true })
|
|
1301
1601
|
}
|
|
1302
1602
|
|
|
1303
1603
|
// Local write tracking
|
|
@@ -1308,10 +1608,9 @@ export function deadStoreElim(fn) {
|
|
|
1308
1608
|
// if its RHS is pure — `local.set $x (call f …)` where `f` mutates
|
|
1309
1609
|
// memory must still run. (A `local.tee` is always safe: removal demotes
|
|
1310
1610
|
// 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)
|
|
1611
|
+
if (prev.node[0] === 'local.tee' || isPure(prev.node[2])) dead.push(prev)
|
|
1313
1612
|
}
|
|
1314
|
-
lastWrite.set(node[1], { parent: items,
|
|
1613
|
+
lastWrite.set(node[1], { parent: items, node })
|
|
1315
1614
|
}
|
|
1316
1615
|
|
|
1317
1616
|
// Recurse into nested blocks with fresh state
|
|
@@ -1336,20 +1635,20 @@ export function deadStoreElim(fn) {
|
|
|
1336
1635
|
|
|
1337
1636
|
scanBlock(fn, bodyStart, fn.length)
|
|
1338
1637
|
|
|
1339
|
-
//
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1638
|
+
// Removal is IDENTITY-based: entries are pushed at SUPERSEDE time, so
|
|
1639
|
+
// same-parent indices are not monotonic (name A's earlier write can be
|
|
1640
|
+
// superseded after name B's later one). Index-order splicing then shifts
|
|
1641
|
+
// remaining entries onto innocent neighbors — the self-host L2 divergence
|
|
1642
|
+
// deleted a typed-literal f64.store exactly this way. Re-locating each
|
|
1643
|
+
// captured node at removal time is immune to any ordering or prior splice.
|
|
1644
|
+
for (const d of dead) {
|
|
1645
|
+
const at = d.parent.indexOf(d.node)
|
|
1646
|
+
if (at < 0) continue // already removed (nested duplicate) — nothing to do
|
|
1647
|
+
if (!d.drop && d.node[0] === 'local.tee') {
|
|
1648
|
+
// tee in statement position: replace with just the value (implicitly dropped)
|
|
1649
|
+
d.parent[at] = d.node[2]
|
|
1344
1650
|
} 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
|
-
}
|
|
1651
|
+
d.parent.splice(at, 1)
|
|
1353
1652
|
}
|
|
1354
1653
|
}
|
|
1355
1654
|
}
|
|
@@ -1384,6 +1683,161 @@ export function collectVolatileGlobals(funcs) {
|
|
|
1384
1683
|
return volatile
|
|
1385
1684
|
}
|
|
1386
1685
|
|
|
1686
|
+
/**
|
|
1687
|
+
* Transitive global-write sets per function: name → Set of globals the function
|
|
1688
|
+
* writes directly OR through any (transitively) called function. The precise
|
|
1689
|
+
* complement to `collectVolatileGlobals`' coarse module-wide set — a global
|
|
1690
|
+
* written only by `init` is volatile module-wide, yet perfectly stable inside
|
|
1691
|
+
* a function whose call graph never reaches `init`.
|
|
1692
|
+
*
|
|
1693
|
+
* Unknown callees (imports — absent from the module's func list) write nothing:
|
|
1694
|
+
* wasm imports cannot touch module globals. `call_indirect`/`call_ref` targets
|
|
1695
|
+
* are unknown wasm functions — treat as writing every global any function
|
|
1696
|
+
* writes (the sound over-approximation).
|
|
1697
|
+
*/
|
|
1698
|
+
export function collectReachableGlobalWrites(funcs) {
|
|
1699
|
+
const writes = new Map(), callees = new Map(), indirect = new Set(), all = new Set()
|
|
1700
|
+
for (const fn of funcs) {
|
|
1701
|
+
if (!Array.isArray(fn) || fn[0] !== 'func' || typeof fn[1] !== 'string') continue
|
|
1702
|
+
const w = new Set(), c = new Set()
|
|
1703
|
+
const scan = (n) => {
|
|
1704
|
+
if (!Array.isArray(n)) return
|
|
1705
|
+
if (n[0] === 'global.set' && typeof n[1] === 'string') { w.add(n[1]); all.add(n[1]) }
|
|
1706
|
+
else if ((n[0] === 'call' || n[0] === 'return_call') && typeof n[1] === 'string') c.add(n[1])
|
|
1707
|
+
else if (n[0] === 'call_indirect' || n[0] === 'call_ref' || n[0] === 'return_call_indirect') indirect.add(fn[1])
|
|
1708
|
+
for (let i = 1; i < n.length; i++) scan(n[i])
|
|
1709
|
+
}
|
|
1710
|
+
for (let i = 2; i < fn.length; i++) scan(fn[i])
|
|
1711
|
+
writes.set(fn[1], w); callees.set(fn[1], c)
|
|
1712
|
+
}
|
|
1713
|
+
// Worklist fixpoint over the call graph.
|
|
1714
|
+
let changed = true
|
|
1715
|
+
while (changed) {
|
|
1716
|
+
changed = false
|
|
1717
|
+
for (const [name, w] of writes) {
|
|
1718
|
+
const before = w.size
|
|
1719
|
+
if (indirect.has(name)) for (const g of all) w.add(g)
|
|
1720
|
+
for (const callee of callees.get(name)) {
|
|
1721
|
+
const cw = writes.get(callee)
|
|
1722
|
+
if (cw) for (const g of cw) w.add(g)
|
|
1723
|
+
}
|
|
1724
|
+
if (w.size !== before) changed = true
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
return writes
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
/**
|
|
1731
|
+
* Hoist `__ptr_offset` resolution of stable typed-array GLOBALS to one resolve
|
|
1732
|
+
* per function. Locals get their pointer unboxed once at bind time, but a
|
|
1733
|
+
* module-global typed array (`let x; init = () => { x = new Float64Array(n) }`
|
|
1734
|
+
* — the idiomatic DSP-state shape: rfft, game-of-life, diffusion)
|
|
1735
|
+
* re-resolves on EVERY element access:
|
|
1736
|
+
* (call $__ptr_offset (i64.reinterpret_f64 (global.get $x)))
|
|
1737
|
+
* — 68 such calls in rfft's transform alone, ~7× slower than V8. LICM can't
|
|
1738
|
+
* hoist them out of loops: its global-invariance rule requires a call-free
|
|
1739
|
+
* loop, and the resolve itself is a call. promoteGlobals can't either: `init`
|
|
1740
|
+
* writes the global, so it's volatile module-wide.
|
|
1741
|
+
*
|
|
1742
|
+
* The precise facts make it sound here: TYPED pointees never forward (only
|
|
1743
|
+
* ARRAY/SET/MAP do — same bits ⇒ same offset), so the snapshot is stable iff
|
|
1744
|
+
* the global's VALUE is stable through the function — i.e. the function
|
|
1745
|
+
* neither writes G itself nor (transitively) calls anything that does
|
|
1746
|
+
* (`collectReachableGlobalWrites`). The entry-time resolve is total
|
|
1747
|
+
* (`__ptr_offset` bounds-checks garbage to itself), so hoisting past a
|
|
1748
|
+
* zero-trip loop or an early return is safe.
|
|
1749
|
+
*
|
|
1750
|
+
* @param {Array} fn - func IR node
|
|
1751
|
+
* @param {Set<string>} stablePtrGlobals - '$name's of VAL.TYPED module globals
|
|
1752
|
+
* @param {Map<string,Set<string>>} reachableWrites - from collectReachableGlobalWrites
|
|
1753
|
+
*/
|
|
1754
|
+
// Never-forwarding pointee kinds: every PTR tag outside __ptr_offset's
|
|
1755
|
+
// forwarding set {ARRAY, HASH, SET, MAP} — same bits ⇒ same offset.
|
|
1756
|
+
export const STABLE_PTR_VALS = new Set([VAL.TYPED, VAL.STRING, VAL.OBJECT, VAL.BUFFER, VAL.CLOSURE])
|
|
1757
|
+
|
|
1758
|
+
/** '$name' set of stable-pointee module globals (hoistGlobalPtrOffset targets). */
|
|
1759
|
+
export const stablePtrGlobalNames = () => {
|
|
1760
|
+
const out = new Set()
|
|
1761
|
+
if (ctx.scope.globalValTypes)
|
|
1762
|
+
for (const [k, v] of ctx.scope.globalValTypes) if (STABLE_PTR_VALS.has(v)) out.add(`$${k}`)
|
|
1763
|
+
return out
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
export function hoistGlobalPtrOffset(fn, stablePtrGlobals, reachableWrites) {
|
|
1767
|
+
if (!Array.isArray(fn) || fn[0] !== 'func' || !stablePtrGlobals?.size) return
|
|
1768
|
+
const bodyStart = findBodyStart(fn)
|
|
1769
|
+
if (bodyStart < 0) return
|
|
1770
|
+
|
|
1771
|
+
// `(call $__ptr_offset (i64.reinterpret_f64 (global.get $G)))` → G, or null.
|
|
1772
|
+
const siteGlobal = (n) =>
|
|
1773
|
+
Array.isArray(n) && n[0] === 'call' && n[1] === '$__ptr_offset' && n.length === 3
|
|
1774
|
+
&& Array.isArray(n[2]) && n[2][0] === 'i64.reinterpret_f64'
|
|
1775
|
+
&& Array.isArray(n[2][1]) && n[2][1][0] === 'global.get' && typeof n[2][1][1] === 'string'
|
|
1776
|
+
? n[2][1][1] : null
|
|
1777
|
+
|
|
1778
|
+
// Per-global: static site count AND whether any site sits inside a loop. A
|
|
1779
|
+
// single in-loop site is a per-ITERATION resolve (lenia's convolution reads
|
|
1780
|
+
// each of kdx/kdy/kw at one site × ~14M taps/frame), so loop placement beats
|
|
1781
|
+
// site count as the hoist criterion.
|
|
1782
|
+
const counts = new Map(), inLoop = new Set(), ownWrites = new Set(), ownCallees = new Set()
|
|
1783
|
+
let hasIndirect = false
|
|
1784
|
+
const scan = (n, loopDepth) => {
|
|
1785
|
+
if (!Array.isArray(n)) return
|
|
1786
|
+
const g = siteGlobal(n)
|
|
1787
|
+
if (g != null) { counts.set(g, (counts.get(g) || 0) + 1); if (loopDepth > 0) inLoop.add(g); return }
|
|
1788
|
+
if (n[0] === 'global.set' && typeof n[1] === 'string') ownWrites.add(n[1])
|
|
1789
|
+
else if ((n[0] === 'call' || n[0] === 'return_call') && typeof n[1] === 'string') ownCallees.add(n[1])
|
|
1790
|
+
else if (n[0] === 'call_indirect' || n[0] === 'call_ref' || n[0] === 'return_call_indirect') hasIndirect = true
|
|
1791
|
+
const d = n[0] === 'loop' ? loopDepth + 1 : loopDepth
|
|
1792
|
+
for (let i = 1; i < n.length; i++) scan(n[i], d)
|
|
1793
|
+
}
|
|
1794
|
+
for (let i = bodyStart; i < fn.length; i++) scan(fn[i], 0)
|
|
1795
|
+
if (!counts.size) return
|
|
1796
|
+
|
|
1797
|
+
const calleeWrites = (g) => {
|
|
1798
|
+
if (hasIndirect) return true // unknown targets — assume they write
|
|
1799
|
+
for (const c of ownCallees) if (reachableWrites?.get(c)?.has(g)) return true
|
|
1800
|
+
return false
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
// Collision-proof snap ids (same scheme as hoistInvariantLoop's $__li).
|
|
1804
|
+
const used = new Set()
|
|
1805
|
+
const scanIds = (n) => {
|
|
1806
|
+
if (!Array.isArray(n)) return
|
|
1807
|
+
if (n[0] === 'local' && typeof n[1] === 'string' && n[1].startsWith('$__go')) {
|
|
1808
|
+
const t = n[1].slice(5); if (/^\d+$/.test(t)) used.add(+t)
|
|
1809
|
+
}
|
|
1810
|
+
for (let i = 0; i < n.length; i++) scanIds(n[i])
|
|
1811
|
+
}
|
|
1812
|
+
scanIds(fn)
|
|
1813
|
+
let idCounter = 0
|
|
1814
|
+
const freshId = () => { while (used.has(idCounter)) idCounter++; const id = idCounter++; used.add(id); return `$__go${id}` }
|
|
1815
|
+
|
|
1816
|
+
const chosen = new Map() // global → snap local
|
|
1817
|
+
for (const [g, c] of counts) {
|
|
1818
|
+
if ((c < 2 && !inLoop.has(g)) || !stablePtrGlobals.has(g) || ownWrites.has(g) || calleeWrites(g)) continue
|
|
1819
|
+
chosen.set(g, freshId())
|
|
1820
|
+
}
|
|
1821
|
+
if (!chosen.size) return
|
|
1822
|
+
|
|
1823
|
+
const replace = (n) => {
|
|
1824
|
+
if (!Array.isArray(n)) return
|
|
1825
|
+
for (let i = 1; i < n.length; i++) {
|
|
1826
|
+
const g = siteGlobal(n[i])
|
|
1827
|
+
if (g != null && chosen.has(g)) n[i] = ['local.get', chosen.get(g)]
|
|
1828
|
+
else replace(n[i])
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
for (let i = bodyStart; i < fn.length; i++) replace(fn[i])
|
|
1832
|
+
|
|
1833
|
+
const decls = [], snaps = []
|
|
1834
|
+
for (const [g, name] of chosen) {
|
|
1835
|
+
decls.push(['local', name, 'i32'])
|
|
1836
|
+
snaps.push(['local.set', name, ['call', '$__ptr_offset', ['i64.reinterpret_f64', ['global.get', g]]]])
|
|
1837
|
+
}
|
|
1838
|
+
fn.splice(bodyStart, 0, ...decls, ...snaps)
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1387
1841
|
/**
|
|
1388
1842
|
* Promote read-only globals to locals within each function.
|
|
1389
1843
|
*
|
|
@@ -1408,7 +1862,7 @@ export function collectVolatileGlobals(funcs) {
|
|
|
1408
1862
|
* @param {Map<string,string>} [globalTypes] - Optional: global name → wasm type ('i32'|'f64'|'i64'|'funcref')
|
|
1409
1863
|
* @param {Set<string>} [volatileGlobals] - Optional: globals mutated outside `$__start` (see collectVolatileGlobals)
|
|
1410
1864
|
*/
|
|
1411
|
-
export function promoteGlobals(fn, globalTypes, volatileGlobals) {
|
|
1865
|
+
export function promoteGlobals(fn, globalTypes, volatileGlobals, reachableWrites) {
|
|
1412
1866
|
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
1413
1867
|
const bodyStart = findBodyStart(fn)
|
|
1414
1868
|
if (bodyStart < 0) return
|
|
@@ -1416,8 +1870,8 @@ export function promoteGlobals(fn, globalTypes, volatileGlobals) {
|
|
|
1416
1870
|
// Collect global.get counts, detect any global.set, and note whether the
|
|
1417
1871
|
// function makes a call (a callee may mutate a volatile global between reads).
|
|
1418
1872
|
const getCounts = new Map() // globalName → count
|
|
1419
|
-
const written = new Set()
|
|
1420
|
-
let hasCall = false
|
|
1873
|
+
const written = new Set(), callees = new Set()
|
|
1874
|
+
let hasCall = false, hasIndirect = false
|
|
1421
1875
|
|
|
1422
1876
|
const scan = (node) => {
|
|
1423
1877
|
if (!Array.isArray(node)) return
|
|
@@ -1431,7 +1885,8 @@ export function promoteGlobals(fn, globalTypes, volatileGlobals) {
|
|
|
1431
1885
|
if (node[2]) scan(node[2])
|
|
1432
1886
|
return
|
|
1433
1887
|
}
|
|
1434
|
-
if (op === 'call' || op === '
|
|
1888
|
+
if (op === 'call' || op === 'return_call') { hasCall = true; if (typeof node[1] === 'string') callees.add(node[1]) }
|
|
1889
|
+
else if (op === 'call_indirect' || op === 'call_ref' || op === 'return_call_indirect') { hasCall = true; hasIndirect = true }
|
|
1435
1890
|
for (let i = 1; i < node.length; i++) scan(node[i])
|
|
1436
1891
|
}
|
|
1437
1892
|
|
|
@@ -1452,7 +1907,12 @@ export function promoteGlobals(fn, globalTypes, volatileGlobals) {
|
|
|
1452
1907
|
for (const [gName, count] of getCounts) {
|
|
1453
1908
|
if (count < 3 || written.has(gName)) continue
|
|
1454
1909
|
// Unsound to cache a callee-mutable global across a call in this function.
|
|
1455
|
-
|
|
1910
|
+
// With reachableWrites the test is exact per call edge (a global written
|
|
1911
|
+
// only by init stays promotable in functions whose call graph never
|
|
1912
|
+
// reaches init); without it, fall back to the coarse module-wide set.
|
|
1913
|
+
if (hasCall && (reachableWrites
|
|
1914
|
+
? (hasIndirect || [...callees].some(c => reachableWrites.get(c)?.has(gName)))
|
|
1915
|
+
: volatileGlobals?.has(gName))) continue
|
|
1456
1916
|
// Determine type: use provided map, or infer from context
|
|
1457
1917
|
const type = globalTypes?.get(gName) || inferTypeFromContext(fn, gName, bodyStart)
|
|
1458
1918
|
if (!type) continue // can't determine type, skip
|
|
@@ -1543,7 +2003,7 @@ function inferTypeFromContext(fn, gName, bodyStart) {
|
|
|
1543
2003
|
* Pool entries sorted by usage descending, so hottest get lowest indices (1-byte LEB128).
|
|
1544
2004
|
* Break-even: N ≥ 2 uses (pool cost: 11 B global decl + 2N bytes vs 9N original).
|
|
1545
2005
|
*
|
|
1546
|
-
* Mutates `funcs` in place; writes new global decls via `addGlobal(name,
|
|
2006
|
+
* Mutates `funcs` in place; writes new global decls via `addGlobal(name, constLiteral)`.
|
|
1547
2007
|
*/
|
|
1548
2008
|
export function hoistConstantPool(funcs, addGlobal) {
|
|
1549
2009
|
const MIN_USES = 2
|
|
@@ -1574,7 +2034,7 @@ export function hoistConstantPool(funcs, addGlobal) {
|
|
|
1574
2034
|
for (const [k] of sorted) {
|
|
1575
2035
|
const name = `__fc${gId++}`
|
|
1576
2036
|
const lit = k.slice(2)
|
|
1577
|
-
addGlobal(name,
|
|
2037
|
+
addGlobal(name, lit)
|
|
1578
2038
|
hoist.set(k, name)
|
|
1579
2039
|
}
|
|
1580
2040
|
if (!hoist.size) return
|
|
@@ -1623,7 +2083,12 @@ export function specializeMkptr(funcs, addFunc, parseWat) {
|
|
|
1623
2083
|
'$__typed_idx': { params: ['i64', 'i32'], result: 'f64' },
|
|
1624
2084
|
'$__str_idx': { params: ['i64', 'i32'], result: 'f64' },
|
|
1625
2085
|
}
|
|
1626
|
-
|
|
2086
|
+
// 4 is the measured break-even: a specialized helper (trampoline / inline i64.const
|
|
2087
|
+
// template) costs ~12 B to define and saves ~2–4 B per site, so 4 sites amortize it.
|
|
2088
|
+
// Lower (3) net-inflates the watr self-host; 5 leaves 4-use combos on the table. The
|
|
2089
|
+
// sibling specializePtrBase threshold (20) is already optimal — its combos cluster far
|
|
2090
|
+
// above 20 (the ~2 k-site $__strBase relativization) with nothing in the 5–19 band.
|
|
2091
|
+
const MIN_USES = 4
|
|
1627
2092
|
|
|
1628
2093
|
// Build literal-arg signature key for a call node. Returns null if no args are literal.
|
|
1629
2094
|
// Key format: 'T:V' per literal arg, 'D' per dynamic; indexed by position.
|
|
@@ -1921,14 +2386,16 @@ export function sortStrPoolByFreq(funcs, strPoolRef, strDedupMap) {
|
|
|
1921
2386
|
* @param cfg optional resolved config from resolveOptimize() — when omitted, all on.
|
|
1922
2387
|
* @param globalTypes optional global name → wasm type map (for promoteGlobals)
|
|
1923
2388
|
* @param volatileGlobals optional set of callee-mutable globals (see collectVolatileGlobals)
|
|
2389
|
+
* @param phase 'pre' (default, pre-watr leaf pass) or 'post' (re-run after watr) —
|
|
2390
|
+
* gates the passes that only pay off once watr has reshaped the IR.
|
|
1924
2391
|
*/
|
|
1925
|
-
export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals) {
|
|
2392
|
+
export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals, phase = 'pre', reachableWrites) {
|
|
1926
2393
|
if (cfg && cfg.hoistPtrType === false &&
|
|
1927
2394
|
cfg.hoistInvariantPtrOffset === false &&
|
|
1928
|
-
cfg.
|
|
2395
|
+
cfg.hoistInvariantLoop === false &&
|
|
2396
|
+
cfg.narrowLoopBound === false &&
|
|
1929
2397
|
cfg.fusedRewrite === false &&
|
|
1930
2398
|
cfg.hoistAddrBase === false &&
|
|
1931
|
-
cfg.hoistInvariantCellLoads === false &&
|
|
1932
2399
|
cfg.cseScalarLoad === false &&
|
|
1933
2400
|
cfg.csePureExpr === false &&
|
|
1934
2401
|
cfg.dropDeadZeroInit === false &&
|
|
@@ -1938,27 +2405,63 @@ export function optimizeFunc(fn, cfg, globalTypes, volatileGlobals) {
|
|
|
1938
2405
|
cfg.vectorizeLaneLocal === false) return
|
|
1939
2406
|
if (!cfg || cfg.hoistPtrType !== false) hoistPtrType(fn)
|
|
1940
2407
|
if (!cfg || cfg.hoistInvariantPtrOffset !== false) hoistInvariantPtrOffset(fn)
|
|
1941
|
-
|
|
2408
|
+
// Before LICM: the snapped i32 bound is itself a hoistable hard-op subtree, so
|
|
2409
|
+
// an outer loop's LICM can lift it further when the bound is outer-invariant.
|
|
2410
|
+
if (!cfg || cfg.narrowLoopBound !== false) narrowLoopBound(fn)
|
|
2411
|
+
// Unified LICM (replaces hoistInvariantToInt32 / PtrOffsetLoop / CellLoads).
|
|
2412
|
+
// Run at both maturity points (idempotent): pre-fusedRewrite catches the raw
|
|
2413
|
+
// ToInt32/ptr-offset/arithmetic shapes; post-hoistAddrBase catches cell loads.
|
|
2414
|
+
if (!cfg || cfg.hoistInvariantLoop !== false) hoistInvariantLoop(fn)
|
|
1942
2415
|
const counts = new Map()
|
|
1943
2416
|
if (!cfg || cfg.fusedRewrite !== false) fusedRewrite(fn, counts)
|
|
1944
2417
|
if (!cfg || cfg.hoistAddrBase !== false) hoistAddrBase(fn)
|
|
1945
|
-
if (!cfg || cfg.
|
|
2418
|
+
if (!cfg || cfg.hoistInvariantLoop !== false) hoistInvariantLoop(fn)
|
|
1946
2419
|
if (!cfg || cfg.cseScalarLoad !== false) cseScalarLoad(fn)
|
|
1947
|
-
if (!cfg || cfg.csePureExpr !== false)
|
|
2420
|
+
if (!cfg || cfg.csePureExpr !== false) {
|
|
2421
|
+
if (cfg && (cfg.watr === true || typeof cfg.watr === 'object') && phase === 'post') csePureExprLoop(fn)
|
|
2422
|
+
else csePureExpr(fn)
|
|
2423
|
+
}
|
|
1948
2424
|
if (!cfg || cfg.dropDeadZeroInit !== false) dropDeadZeroInit(fn)
|
|
1949
2425
|
if (!cfg || cfg.deadStoreElim !== false) deadStoreElim(fn)
|
|
1950
|
-
if (!cfg || cfg.promoteGlobals !== false) promoteGlobals(fn, globalTypes, volatileGlobals)
|
|
2426
|
+
if (!cfg || cfg.promoteGlobals !== false) promoteGlobals(fn, globalTypes, volatileGlobals, reachableWrites)
|
|
1951
2427
|
// Vectorizer runs PRE-watr unless full watr is enabled (`watr: true`). For full watr,
|
|
1952
2428
|
// defer to post — full passes (notably `inlineOnce` + the post-inline `propagate`
|
|
1953
2429
|
// sweep) reshape the IR so much that pre-watr SIMD patterns get scrambled. Light
|
|
1954
2430
|
// watr (or no watr) leaves the lane locals intact for vectorize to pattern-match,
|
|
1955
2431
|
// and lets a non-trivial chunk of SIMD survive the propagate+fold pipeline.
|
|
1956
2432
|
if (cfg && cfg.vectorizeLaneLocal === true) {
|
|
1957
|
-
const fullWatr = cfg.watr === true
|
|
1958
|
-
const runVectorizer = (fullWatr &&
|
|
2433
|
+
const fullWatr = cfg.watr === true || typeof cfg.watr === 'object'
|
|
2434
|
+
const runVectorizer = (fullWatr && phase === 'post') || (!fullWatr && phase !== 'post')
|
|
1959
2435
|
if (runVectorizer) vectorizeLaneLocal(fn)
|
|
1960
2436
|
}
|
|
1961
2437
|
if (!cfg || cfg.sortLocalsByUse !== false) sortLocalsByUse(fn, cfg && cfg.fusedRewrite !== false ? counts : null)
|
|
2438
|
+
// An optimizer pass that emits a malformed local — the class that otherwise dies
|
|
2439
|
+
// as an opaque watr "Duplicate/Unknown local $x" several phases on — is caught
|
|
2440
|
+
// here, pinned to the function and the bad name.
|
|
2441
|
+
if (DBG_IR) { const bad = verifyFn(fn); if (bad) throw new Error(`[ir verify] optimize produced invalid IR in ${fn[1]}: ${bad}`) }
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
// The i32 form of an integer-valued f64 expression, or null. Used to push ToInt32
|
|
2445
|
+
// through a conditional and to collapse the f64 round-trip on integer `+`/`-`.
|
|
2446
|
+
// Lossless by construction: `convert_i32(X) → X`; integer `f64.const → i32.const`
|
|
2447
|
+
// (ToInt32); `f64.add/sub` of i32-valued operands → `i32.add/sub` (mod-2³² is a ring
|
|
2448
|
+
// homomorphism, and each i32±i32 < 2³² < 2⁵³ so the f64 op is exact). EXCLUDES `mul`
|
|
2449
|
+
// (products can exceed 2⁵³, so the f64 op loses precision and i32.mul wouldn't match)
|
|
2450
|
+
// and anything non-integer or unprovable. Address `local.tee`s inside operands are
|
|
2451
|
+
// preserved (kept as-is in the returned i32 tree).
|
|
2452
|
+
function toI32(n) {
|
|
2453
|
+
if (!Array.isArray(n)) return null
|
|
2454
|
+
const op = n[0]
|
|
2455
|
+
if ((op === 'f64.convert_i32_s' || op === 'f64.convert_i32_u') && n.length === 2) return n[1]
|
|
2456
|
+
// i32-range consts only: keeps every leaf within i32 so f64 add/sub of leaves stays exact
|
|
2457
|
+
// (< 2^53) and ToInt32-homomorphic. A larger const would round in f64.add or saturate in
|
|
2458
|
+
// trunc_sat differently from JS `|0`, breaking the fold.
|
|
2459
|
+
if (op === 'f64.const' && typeof n[1] === 'number' && (n[1] | 0) === n[1]) return ['i32.const', n[1]]
|
|
2460
|
+
if ((op === 'f64.add' || op === 'f64.sub') && n.length === 3) {
|
|
2461
|
+
const a = toI32(n[1]), b = toI32(n[2])
|
|
2462
|
+
if (a && b) return [op === 'f64.add' ? 'i32.add' : 'i32.sub', a, b]
|
|
2463
|
+
}
|
|
2464
|
+
return null
|
|
1962
2465
|
}
|
|
1963
2466
|
|
|
1964
2467
|
// Fused bottom-up walk applying three orthogonal pattern sets at each node:
|
|
@@ -1973,7 +2476,7 @@ function fusedRewrite(fn, counts) {
|
|
|
1973
2476
|
if (Array.isArray(fn)) {
|
|
1974
2477
|
for (let i = 0; i < fn.length; i++) {
|
|
1975
2478
|
const c = fn[i]
|
|
1976
|
-
if (Array.isArray(c)) fn[i] = walkRewrite(c, true, counts)
|
|
2479
|
+
if (Array.isArray(c)) fn[i] = walkRewrite(c, true, counts, null, null)
|
|
1977
2480
|
}
|
|
1978
2481
|
}
|
|
1979
2482
|
return
|
|
@@ -1982,17 +2485,33 @@ function fusedRewrite(fn, counts) {
|
|
|
1982
2485
|
const name = typeof fn[1] === 'string' ? fn[1] : null
|
|
1983
2486
|
const skipInline = !!(name && (name.startsWith('$__ptr_') || name === '$__is_nullish' || name === '$__is_truthy' || name === '$__is_null'))
|
|
1984
2487
|
const bodyStart = findBodyStart(fn)
|
|
2488
|
+
// i64 scratch allocator for the literal-eq inline: any-shaped operand is
|
|
2489
|
+
// tee'd once instead of duplicated. Decls splice in after the walk.
|
|
2490
|
+
const newDecls = []
|
|
2491
|
+
// pre+post phases both run this pass — continue numbering past any scratch
|
|
2492
|
+
// locals the earlier phase already declared, or the decls collide.
|
|
2493
|
+
let scratchN = 0
|
|
2494
|
+
for (let i = 2; i < fn.length; i++) {
|
|
2495
|
+
const d = fn[i]
|
|
2496
|
+
if (Array.isArray(d) && d[0] === 'local' && typeof d[1] === 'string') {
|
|
2497
|
+
const m = d[1].match(/^\$__eq[tf](\d+)$/)
|
|
2498
|
+
if (m) scratchN = Math.max(scratchN, +m[1] + 1)
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
const freshI64 = () => { const n = `$__eqt${scratchN++}`; newDecls.push(['local', n, 'i64']); return n }
|
|
2502
|
+
const freshF64 = () => { const n = `$__eqf${scratchN++}`; newDecls.push(['local', n, 'f64']); return n }
|
|
1985
2503
|
for (let i = bodyStart; i < fn.length; i++) {
|
|
1986
2504
|
const c = fn[i]
|
|
1987
|
-
if (Array.isArray(c)) fn[i] = walkRewrite(c, !skipInline, counts)
|
|
2505
|
+
if (Array.isArray(c)) fn[i] = walkRewrite(c, !skipInline, counts, freshI64, freshF64)
|
|
1988
2506
|
}
|
|
2507
|
+
if (newDecls.length) fn.splice(bodyStart, 0, ...newDecls)
|
|
1989
2508
|
}
|
|
1990
2509
|
|
|
1991
|
-
function walkRewrite(node, doInline, counts) {
|
|
2510
|
+
function walkRewrite(node, doInline, counts, freshI64, freshF64) {
|
|
1992
2511
|
if (!Array.isArray(node)) return node
|
|
1993
2512
|
for (let i = 0; i < node.length; i++) {
|
|
1994
2513
|
const c = node[i]
|
|
1995
|
-
if (Array.isArray(c)) node[i] = walkRewrite(c, doInline, counts)
|
|
2514
|
+
if (Array.isArray(c)) node[i] = walkRewrite(c, doInline, counts, freshI64, freshF64)
|
|
1996
2515
|
}
|
|
1997
2516
|
const op = node[0]
|
|
1998
2517
|
// Piggyback local-ref counting for sortLocalsByUse. `counts` may be undefined
|
|
@@ -2000,6 +2519,74 @@ function walkRewrite(node, doInline, counts) {
|
|
|
2000
2519
|
if (counts && (op === 'local.get' || op === 'local.set' || op === 'local.tee') && typeof node[1] === 'string')
|
|
2001
2520
|
counts.set(node[1], (counts.get(node[1]) || 0) + 1)
|
|
2002
2521
|
|
|
2522
|
+
// Generic-equality bit-eq fast path: $__eq's own first branch hoisted to the
|
|
2523
|
+
// site when both args duplicate cheaply (local.get / reinterpret of one).
|
|
2524
|
+
// Identical bits ⇒ equal-unless-canonical-NaN; static-literal dedup + SSO +
|
|
2525
|
+
// slice interning make the hit dominant in tree-walking code (tag compares),
|
|
2526
|
+
// so most sites skip the call. The else arm keeps the original call.
|
|
2527
|
+
if (doInline && op === 'call' && (node[1] === '$__eq' || node[1] === '$__str_eq')
|
|
2528
|
+
&& node.length === 4 && !node._eqFast) {
|
|
2529
|
+
const cheap = (n) => Array.isArray(n) &&
|
|
2530
|
+
(n[0] === 'local.get' ||
|
|
2531
|
+
(n[0] === 'i64.reinterpret_f64' && Array.isArray(n[1]) && n[1][0] === 'local.get'))
|
|
2532
|
+
// i64.const whose bits decode to a CANONICAL interned string (STRING tag,
|
|
2533
|
+
// INTERN_BIT set, SSO/SLICE clear) — i.e. a static-literal operand.
|
|
2534
|
+
const internedLit = (n) => {
|
|
2535
|
+
// (i64.const 0x…) or its f64-carrier form (i64.reinterpret_f64 (f64.const nan:0x…))
|
|
2536
|
+
let tok = null
|
|
2537
|
+
if (Array.isArray(n) && n[0] === 'i64.const') tok = n[1]
|
|
2538
|
+
else if (Array.isArray(n) && n[0] === 'i64.reinterpret_f64' && Array.isArray(n[1])
|
|
2539
|
+
&& n[1][0] === 'f64.const' && typeof n[1][1] === 'string' && n[1][1].startsWith('nan:'))
|
|
2540
|
+
tok = n[1][1].slice(4)
|
|
2541
|
+
if (tok == null) return false
|
|
2542
|
+
let v
|
|
2543
|
+
try { v = BigInt(tok) } catch { return false }
|
|
2544
|
+
if (v < 0n) v += 1n << 64n
|
|
2545
|
+
if (((v >> 47n) & 0xFn) !== 4n) return false
|
|
2546
|
+
return ((v >> 32n) & 0x6001n) === BigInt(STR_INTERN_BIT)
|
|
2547
|
+
}
|
|
2548
|
+
// Literal-vs-X inline: bit-eq → 1; X carrying the canonical aux pattern →
|
|
2549
|
+
// 0 (only a canonical string can content-equal a canonical literal, and
|
|
2550
|
+
// canonicals are deduped; every NON-string kind is ≠ a string under ===
|
|
2551
|
+
// as well, so answering 0 on the pattern is sound for ANY value). Slices,
|
|
2552
|
+
// SSO, fresh heap strings and NaN fall through to the call. This is what
|
|
2553
|
+
// makes `op === 'literal'` dispatch ladders cost ~3 ops per rung instead
|
|
2554
|
+
// of a helper call — the V8 interned-pointer-compare equivalent.
|
|
2555
|
+
const a = node[2], b = node[3]
|
|
2556
|
+
const lit = internedLit(b) ? b : internedLit(a) ? a : null
|
|
2557
|
+
const x = lit === b ? a : b
|
|
2558
|
+
if (lit && (cheap(x) || freshI64)) {
|
|
2559
|
+
node._eqFast = true
|
|
2560
|
+
// Cheap operands duplicate; anything else evaluates ONCE into an i64
|
|
2561
|
+
// scratch (tee in the first use), so the inline applies to un-hoisted
|
|
2562
|
+
// shapes like `node[0] === 'lit'` too.
|
|
2563
|
+
let first = x, reuse = x
|
|
2564
|
+
if (!cheap(x)) {
|
|
2565
|
+
const t = freshI64()
|
|
2566
|
+
first = ['local.tee', t, x]
|
|
2567
|
+
reuse = ['local.get', t]
|
|
2568
|
+
node[2] = lit === b ? reuse : lit
|
|
2569
|
+
node[3] = lit === b ? lit : reuse
|
|
2570
|
+
}
|
|
2571
|
+
const auxPat = ['i32.eq',
|
|
2572
|
+
['i32.and', ['i32.wrap_i64', ['i64.shr_u', reuse, ['i64.const', 32]]], ['i32.const', 0x6001]],
|
|
2573
|
+
['i32.const', STR_INTERN_BIT]]
|
|
2574
|
+
return ['if', ['result', 'i32'],
|
|
2575
|
+
['i64.eq', first, lit],
|
|
2576
|
+
['then', ['i32.const', 1]],
|
|
2577
|
+
['else', ['if', ['result', 'i32'], auxPat,
|
|
2578
|
+
['then', ['i32.const', 0]],
|
|
2579
|
+
['else', node]]]]
|
|
2580
|
+
}
|
|
2581
|
+
if (node[1] === '$__eq' && cheap(a) && cheap(b)) {
|
|
2582
|
+
node._eqFast = true // pre+post phases both run this walk — wrap once
|
|
2583
|
+
return ['if', ['result', 'i32'],
|
|
2584
|
+
['i64.eq', a, b],
|
|
2585
|
+
['then', ['i64.ne', a, ['i64.const', NAN_BITS]]],
|
|
2586
|
+
['else', node]]
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2003
2590
|
// Inline-ptr-helpers: $__ptr_type / $__ptr_aux / $__is_nullish / $__is_null / $__is_truthy
|
|
2004
2591
|
if (doInline && op === 'call' && node.length === 3 && typeof node[1] === 'string') {
|
|
2005
2592
|
const fname = node[1]
|
|
@@ -2014,6 +2601,14 @@ function walkRewrite(node, doInline, counts) {
|
|
|
2014
2601
|
&& Array.isArray(node[2][1]) && node[2][1][0] === 'local.get') return ['i32.or',
|
|
2015
2602
|
['i64.eq', node[2], ['i64.const', NULL_BITS]],
|
|
2016
2603
|
['i64.eq', node[2], ['i64.const', UNDEF_BITS]]]
|
|
2604
|
+
// Expression-arg __is_truthy: evaluate once into an f64 scratch via tee —
|
|
2605
|
+
// the local.tee form below then expands inline (covers `(c = next()) || …`
|
|
2606
|
+
// and every condition the emitter didn't pre-hoist).
|
|
2607
|
+
if (fname === '$__is_truthy' && freshF64 && Array.isArray(node[2]) && node[2][0] === 'i64.reinterpret_f64'
|
|
2608
|
+
&& Array.isArray(node[2][1])
|
|
2609
|
+
&& node[2][1][0] !== 'local.get' && node[2][1][0] !== 'local.tee') {
|
|
2610
|
+
node[2] = ['i64.reinterpret_f64', ['local.tee', freshF64(), node[2][1]]]
|
|
2611
|
+
}
|
|
2017
2612
|
if (fname === '$__is_truthy' && Array.isArray(node[2]) && node[2][0] === 'i64.reinterpret_f64'
|
|
2018
2613
|
&& Array.isArray(node[2][1]) && (node[2][1][0] === 'local.get' || node[2][1][0] === 'local.tee')) {
|
|
2019
2614
|
// `local.tee $x SRC` evaluates SRC once, stores to $x, returns the value —
|
|
@@ -2025,16 +2620,21 @@ function walkRewrite(node, doInline, counts) {
|
|
|
2025
2620
|
const lget = ['local.get', lname]
|
|
2026
2621
|
const first = ref[0] === 'local.tee' ? ref : lget
|
|
2027
2622
|
const bits = ['i64.reinterpret_f64', lget]
|
|
2623
|
+
// Mirror $__is_truthy (module/core.js) exactly: FIVE falsy patterns —
|
|
2624
|
+
// canonical NaN, null, undefined, the empty SSO string, AND boolean
|
|
2625
|
+
// false. Omitting FALSE made inlined `x || y` treat false as truthy.
|
|
2028
2626
|
return ['if', ['result', 'i32'],
|
|
2029
2627
|
['f64.eq', first, lget],
|
|
2030
2628
|
['then', ['f64.ne', lget, ['f64.const', 0]]],
|
|
2031
2629
|
['else', ['i32.and',
|
|
2032
2630
|
['i32.and',
|
|
2033
|
-
['
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
['
|
|
2037
|
-
|
|
2631
|
+
['i32.and',
|
|
2632
|
+
['i64.ne', bits, ['i64.const', NAN_BITS]],
|
|
2633
|
+
['i64.ne', bits, ['i64.const', NULL_BITS]]],
|
|
2634
|
+
['i32.and',
|
|
2635
|
+
['i64.ne', bits, ['i64.const', UNDEF_BITS]],
|
|
2636
|
+
['i64.ne', bits, ['i64.const', '0x7FFA400000000000']]]],
|
|
2637
|
+
['i64.ne', bits, ['i64.const', FALSE_BITS]]]]]
|
|
2038
2638
|
}
|
|
2039
2639
|
}
|
|
2040
2640
|
|
|
@@ -2078,6 +2678,65 @@ function walkRewrite(node, doInline, counts) {
|
|
|
2078
2678
|
return a[1]
|
|
2079
2679
|
}
|
|
2080
2680
|
|
|
2681
|
+
// Push ToInt32 through integer expressions and conditionals. The universal value model
|
|
2682
|
+
// computes integer `+`/`-` and `?:` in f64, then ToInt32-clamps — emitting
|
|
2683
|
+
// (select (i32.wrap_i64 (i64.trunc_sat_f64_s [local.tee T] X)) FALLBACK COND)
|
|
2684
|
+
// whose three arms all compute ToInt32(X). When X is an integer-valued f64 expression,
|
|
2685
|
+
// ToInt32(X) == its i32 form (exact); and ToInt32 distributes through a conditional:
|
|
2686
|
+
// ToInt32(if C A B) == if(result i32) C ToInt32(A) ToInt32(B).
|
|
2687
|
+
// Folding here drops the f64 round-trip AND turns int `s += a[i]` reductions and
|
|
2688
|
+
// `a[i] = cond ? … : …` conditional maps into pure i32 the vectorizer lifts (i32x4.add /
|
|
2689
|
+
// i32x4 bitselect). FALLBACK/COND (which recompute the same ToInt32 from T) are dropped.
|
|
2690
|
+
if (op === 'select' && node.length >= 4) {
|
|
2691
|
+
const v = node[1]
|
|
2692
|
+
if (Array.isArray(v) && v[0] === 'i32.wrap_i64' && Array.isArray(v[1]) && v[1][0] === 'i64.trunc_sat_f64_s' && v[1].length === 2) {
|
|
2693
|
+
let inner = v[1][1]
|
|
2694
|
+
if (Array.isArray(inner) && inner[0] === 'local.tee' && inner.length === 3) inner = inner[2]
|
|
2695
|
+
// ToInt32(if (result f64) C A B) → if (result i32) C toI32(A) toI32(B), when both arms are integer-valued.
|
|
2696
|
+
if (Array.isArray(inner) && inner[0] === 'if' && Array.isArray(inner[1]) && inner[1][0] === 'result' && inner[1][1] === 'f64'
|
|
2697
|
+
&& Array.isArray(inner[3]) && inner[3][0] === 'then' && inner[3].length === 2
|
|
2698
|
+
&& Array.isArray(inner[4]) && inner[4][0] === 'else' && inner[4].length === 2) {
|
|
2699
|
+
const t = toI32(inner[3][1]), e = toI32(inner[4][1])
|
|
2700
|
+
if (t && e) return ['if', ['result', 'i32'], inner[2], ['then', t], ['else', e]]
|
|
2701
|
+
}
|
|
2702
|
+
// ToInt32(integer-valued f64 expr) → its i32 form (covers (i32±i32)|0 sums and nests).
|
|
2703
|
+
const i = toI32(inner)
|
|
2704
|
+
if (i) return i
|
|
2705
|
+
}
|
|
2706
|
+
}
|
|
2707
|
+
// (i32.or X 0) / (i32.or 0 X) → X — drops the redundant source-level `|0` clamp left
|
|
2708
|
+
// after the fold above, so the accumulator update is a bare i32.add the recognizer matches.
|
|
2709
|
+
if (op === 'i32.or' && node.length === 3) {
|
|
2710
|
+
const a = node[1], b = node[2]
|
|
2711
|
+
if (Array.isArray(b) && b[0] === 'i32.const' && b[1] === 0) return a
|
|
2712
|
+
if (Array.isArray(a) && a[0] === 'i32.const' && a[1] === 0) return b
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
// f64.CMP(convert_i32 A, convert_i32 B) → i32.CMP(A, B). Comparing two i32 values is
|
|
2716
|
+
// identical whether done in exact f64 or in i32 (the converts are lossless and
|
|
2717
|
+
// order-preserving), so an integer comparison over typed-array loads (reads are f64)
|
|
2718
|
+
// drops its f64 round-trip. eq/ne are sign-agnostic; ordered compares need matching
|
|
2719
|
+
// signedness; an integer comparand constant works for the signed case. Both operands
|
|
2720
|
+
// are kept, so any address `local.tee` inside them survives. Prerequisite for i32
|
|
2721
|
+
// conditional-lane vectorization (the mask becomes an i32x4 compare).
|
|
2722
|
+
if (op === 'f64.eq' || op === 'f64.ne' || op === 'f64.lt' || op === 'f64.gt' || op === 'f64.le' || op === 'f64.ge') {
|
|
2723
|
+
const base = op.slice(4)
|
|
2724
|
+
const cv = (x) => Array.isArray(x) && (x[0] === 'f64.convert_i32_s' || x[0] === 'f64.convert_i32_u') && x.length === 2 ? x : null
|
|
2725
|
+
const intK = (x) => Array.isArray(x) && x[0] === 'f64.const' && Number.isInteger(x[1]) && x[1] >= -2147483648 && x[1] <= 2147483647 ? x[1] : null
|
|
2726
|
+
const a = node[1], b = node[2], ca = cv(a), cb = cv(b)
|
|
2727
|
+
if (ca && cb) {
|
|
2728
|
+
const sa = ca[0] === 'f64.convert_i32_s', sb = cb[0] === 'f64.convert_i32_s'
|
|
2729
|
+
if (base === 'eq' || base === 'ne') return ['i32.' + base, ca[1], cb[1]]
|
|
2730
|
+
if (sa === sb) return ['i32.' + base + (sa ? '_s' : '_u'), ca[1], cb[1]]
|
|
2731
|
+
} else if (ca && ca[0] === 'f64.convert_i32_s') {
|
|
2732
|
+
const k = intK(b)
|
|
2733
|
+
if (k != null) return base === 'eq' || base === 'ne' ? ['i32.' + base, ca[1], ['i32.const', k]] : ['i32.' + base + '_s', ca[1], ['i32.const', k]]
|
|
2734
|
+
} else if (cb && cb[0] === 'f64.convert_i32_s') {
|
|
2735
|
+
const k = intK(a)
|
|
2736
|
+
if (k != null) return base === 'eq' || base === 'ne' ? ['i32.' + base, ['i32.const', k], cb[1]] : ['i32.' + base + '_s', ['i32.const', k], cb[1]]
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2081
2740
|
// shl-distribute-over-add: (i32.shl (i32.add x (i32.const K)) (i32.const S))
|
|
2082
2741
|
// → (i32.add (i32.shl x S) (i32.const K<<S)). Overflow-safe — both forms wrap
|
|
2083
2742
|
// mod 2^32 identically. Unlocks memarg offset= folding for biquad-style
|
|
@@ -2258,7 +2917,8 @@ export function sortLocalsByUse(fn, precomputedCounts) {
|
|
|
2258
2917
|
if (!Array.isArray(fn) || fn[0] !== 'func') return
|
|
2259
2918
|
const localIdxs = []
|
|
2260
2919
|
let totalDecls = 0
|
|
2261
|
-
|
|
2920
|
+
let i
|
|
2921
|
+
for (i = 2; i < fn.length; i++) {
|
|
2262
2922
|
const c = fn[i]
|
|
2263
2923
|
if (!Array.isArray(c)) continue
|
|
2264
2924
|
if (c[0] === 'param' || c[0] === 'result') { totalDecls++; continue }
|