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