watr 4.5.1 → 4.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/watr.js +543 -110
- package/dist/watr.min.js +6 -6
- package/dist/watr.wasm +0 -0
- package/package.json +8 -3
- package/readme.md +1 -1
- package/src/compile.js +9 -2
- package/src/const.js +2 -2
- package/src/encode.js +33 -12
- package/src/optimize.js +634 -122
- package/types/src/const.d.ts +2 -0
- package/types/src/encode.d.ts.map +1 -1
- package/types/src/optimize.d.ts +81 -6
- package/types/src/optimize.d.ts.map +1 -1
package/src/optimize.js
CHANGED
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import parse from './parse.js'
|
|
9
|
+
import compile from './compile.js'
|
|
9
10
|
|
|
10
|
-
/** Optimizations that can be applied
|
|
11
|
+
/** Optimizations that can be applied.
|
|
12
|
+
* Passes defaulting to false can bloat output or are expensive — opt-in only. */
|
|
11
13
|
const OPTS = {
|
|
12
14
|
treeshake: true, // remove unused funcs/globals/types/tables
|
|
13
15
|
fold: true, // constant folding
|
|
@@ -16,20 +18,21 @@ const OPTS = {
|
|
|
16
18
|
identity: true, // remove identity ops (x + 0 → x)
|
|
17
19
|
strength: true, // strength reduction (x * 2 → x << 1)
|
|
18
20
|
branch: true, // simplify constant branches
|
|
19
|
-
propagate: true, //
|
|
20
|
-
inline:
|
|
21
|
+
propagate: true, // forward-propagate single-use locals & tiny consts (never inflates)
|
|
22
|
+
inline: false, // inline tiny functions — can duplicate bodies
|
|
23
|
+
inlineOnce: true, // inline single-call functions into their lone caller (never duplicates)
|
|
21
24
|
vacuum: true, // remove nops, drop-of-pure, empty branches
|
|
25
|
+
mergeBlocks: true, // unwrap `(block $L …)` whose label is never targeted
|
|
26
|
+
coalesce: true, // share local slots between same-type non-overlapping locals
|
|
22
27
|
peephole: true, // x-x→0, x&0→0, etc.
|
|
23
28
|
globals: true, // propagate immutable global constants
|
|
24
29
|
offset: true, // fold add+const into load/store offset
|
|
25
30
|
unbranch: true, // remove redundant br at end of own block
|
|
26
31
|
stripmut: true, // strip mut from never-written globals
|
|
27
32
|
brif: true, // if-then-br → br_if
|
|
28
|
-
foldarms:
|
|
29
|
-
// minify: true, // NOTE: disabled — renaming $ids has no binary-size effect
|
|
30
|
-
// without a names section, and risks local-name collisions.
|
|
33
|
+
foldarms: false, // merge identical trailing if arms — can add block wrapper
|
|
31
34
|
dedupe: true, // eliminate duplicate functions
|
|
32
|
-
reorder:
|
|
35
|
+
reorder: false, // put hot functions first — no AST reduction
|
|
33
36
|
dedupTypes: true, // merge identical type definitions
|
|
34
37
|
packData: true, // trim trailing zeros, merge adjacent data segments
|
|
35
38
|
minifyImports: false, // shorten import names — enable only when you control the host
|
|
@@ -38,6 +41,27 @@ const OPTS = {
|
|
|
38
41
|
/** All optimization names */
|
|
39
42
|
const ALL = Object.keys(OPTS)
|
|
40
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Recursively count AST nodes — fast size heuristic without compiling.
|
|
46
|
+
* @param {any} node
|
|
47
|
+
* @returns {number}
|
|
48
|
+
*/
|
|
49
|
+
const count = (node) => {
|
|
50
|
+
if (!Array.isArray(node)) return 1
|
|
51
|
+
let n = 1
|
|
52
|
+
for (let i = 0; i < node.length; i++) n += count(node[i])
|
|
53
|
+
return n
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Compile AST and measure binary size in bytes.
|
|
58
|
+
* @param {Array} ast
|
|
59
|
+
* @returns {number}
|
|
60
|
+
*/
|
|
61
|
+
const binarySize = (ast) => {
|
|
62
|
+
try { return compile(ast).length } catch { return Infinity }
|
|
63
|
+
}
|
|
64
|
+
|
|
41
65
|
/**
|
|
42
66
|
* Fast structural equality of two AST nodes.
|
|
43
67
|
* Stops at first difference. Handles BigInt without stringification.
|
|
@@ -62,12 +86,9 @@ const normalize = (opts) => {
|
|
|
62
86
|
if (opts === false) return {}
|
|
63
87
|
if (typeof opts === 'string') {
|
|
64
88
|
const set = new Set(opts.split(/\s+/).filter(Boolean))
|
|
65
|
-
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
return Object.fromEntries(ALL.map(f => [f, set.has(f)]))
|
|
69
|
-
}
|
|
70
|
-
return Object.fromEntries(ALL.map(f => [f, set.has(f) || set.has('all')]))
|
|
89
|
+
if (set.has('all')) return Object.fromEntries(ALL.map(f => [f, true]))
|
|
90
|
+
// Explicit pass names enable ONLY those passes (not the full default set).
|
|
91
|
+
return Object.fromEntries(ALL.map(f => [f, set.has(f)]))
|
|
71
92
|
}
|
|
72
93
|
return { ...OPTS, ...opts }
|
|
73
94
|
}
|
|
@@ -440,7 +461,7 @@ const makeConst = (type, value) => {
|
|
|
440
461
|
* @returns {Array}
|
|
441
462
|
*/
|
|
442
463
|
const fold = (ast) => {
|
|
443
|
-
return walkPost(
|
|
464
|
+
return walkPost(ast, (node) => {
|
|
444
465
|
if (!Array.isArray(node)) return
|
|
445
466
|
const entry = FOLDABLE[node[0]]
|
|
446
467
|
if (!entry) return
|
|
@@ -526,7 +547,7 @@ const IDENTITIES = {
|
|
|
526
547
|
* @returns {Array}
|
|
527
548
|
*/
|
|
528
549
|
const identity = (ast) => {
|
|
529
|
-
return walkPost(
|
|
550
|
+
return walkPost(ast, (node) => {
|
|
530
551
|
if (!Array.isArray(node) || node.length !== 3) return
|
|
531
552
|
const fn = IDENTITIES[node[0]]
|
|
532
553
|
if (!fn) return
|
|
@@ -544,7 +565,7 @@ const identity = (ast) => {
|
|
|
544
565
|
* @returns {Array}
|
|
545
566
|
*/
|
|
546
567
|
const strength = (ast) => {
|
|
547
|
-
return walkPost(
|
|
568
|
+
return walkPost(ast, (node) => {
|
|
548
569
|
if (!Array.isArray(node) || node.length !== 3) return
|
|
549
570
|
const [op, a, b] = node
|
|
550
571
|
|
|
@@ -614,7 +635,7 @@ const strength = (ast) => {
|
|
|
614
635
|
* @returns {Array}
|
|
615
636
|
*/
|
|
616
637
|
const branch = (ast) => {
|
|
617
|
-
return walkPost(
|
|
638
|
+
return walkPost(ast, (node) => {
|
|
618
639
|
if (!Array.isArray(node)) return
|
|
619
640
|
const op = node[0]
|
|
620
641
|
|
|
@@ -665,10 +686,8 @@ const TERMINATORS = new Set(['unreachable', 'return', 'br', 'br_table'])
|
|
|
665
686
|
* @returns {Array}
|
|
666
687
|
*/
|
|
667
688
|
const deadcode = (ast) => {
|
|
668
|
-
const result = clone(ast)
|
|
669
|
-
|
|
670
689
|
// Process each function body
|
|
671
|
-
walk(
|
|
690
|
+
walk(ast, (node) => {
|
|
672
691
|
if (!Array.isArray(node)) return
|
|
673
692
|
const kind = node[0]
|
|
674
693
|
|
|
@@ -686,7 +705,7 @@ const deadcode = (ast) => {
|
|
|
686
705
|
}
|
|
687
706
|
})
|
|
688
707
|
|
|
689
|
-
return
|
|
708
|
+
return ast
|
|
690
709
|
}
|
|
691
710
|
|
|
692
711
|
/**
|
|
@@ -741,9 +760,7 @@ const eliminateDeadInBlock = (block) => {
|
|
|
741
760
|
* @returns {Array}
|
|
742
761
|
*/
|
|
743
762
|
const localReuse = (ast) => {
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
walk(result, (node) => {
|
|
763
|
+
walk(ast, (node) => {
|
|
747
764
|
if (!Array.isArray(node) || node[0] !== 'func') return
|
|
748
765
|
|
|
749
766
|
// Collect local declarations and their types
|
|
@@ -792,7 +809,7 @@ const localReuse = (ast) => {
|
|
|
792
809
|
}
|
|
793
810
|
})
|
|
794
811
|
|
|
795
|
-
return
|
|
812
|
+
return ast
|
|
796
813
|
}
|
|
797
814
|
|
|
798
815
|
// ==================== PROPAGATION & LOCAL ELIMINATION ====================
|
|
@@ -841,8 +858,33 @@ const countLocalUses = (node) => {
|
|
|
841
858
|
return counts
|
|
842
859
|
}
|
|
843
860
|
|
|
844
|
-
/**
|
|
845
|
-
|
|
861
|
+
/** A constant whose inlined form (opcode + immediate) is no wider than the ~2 B
|
|
862
|
+
* `local.get` it would replace — so propagating it to every use is byte-neutral
|
|
863
|
+
* at worst, and still drops the `local.set` + the `local` decl. f32/f64 consts
|
|
864
|
+
* (5/9 B) lose on reuse, so only narrow i32/i64 literals qualify. */
|
|
865
|
+
const isTinyConst = (node) => {
|
|
866
|
+
const c = getConst(node)
|
|
867
|
+
if (!c) return false
|
|
868
|
+
if (c.type === 'i32') { const v = c.value | 0; return v >= -64 && v <= 63 }
|
|
869
|
+
if (c.type === 'i64') { const v = typeof c.value === 'bigint' ? c.value : BigInt(c.value); return v >= -64n && v <= 63n }
|
|
870
|
+
return false
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/** Can this tracked value be substituted for a local.get?
|
|
874
|
+
* - single use of a pure value: always shrinks (drops the set, the lone get, the decl);
|
|
875
|
+
* - any use of a tiny constant: byte-neutral at worst, still drops the set + decl.
|
|
876
|
+
* Anything else (a wide constant reused many times, an impure expr) could inflate
|
|
877
|
+
* or reorder side effects, so it's left alone. */
|
|
878
|
+
const canSubst = (k) => (k.pure && k.singleUse) || isTinyConst(k.val)
|
|
879
|
+
|
|
880
|
+
/** Drop tracked values that read `$name`: rewriting `$name` makes them stale. */
|
|
881
|
+
const purgeRefs = (known, name) => {
|
|
882
|
+
for (const [key, tracked] of known) {
|
|
883
|
+
let refs = false
|
|
884
|
+
walk(tracked.val, n => { if (Array.isArray(n) && (n[0] === 'local.get' || n[0] === 'local.tee') && n[1] === name) refs = true })
|
|
885
|
+
if (refs) known.delete(key)
|
|
886
|
+
}
|
|
887
|
+
}
|
|
846
888
|
|
|
847
889
|
/** Try substitute local.get nodes with known values */
|
|
848
890
|
const substGets = (node, known) => walkPost(node, n => {
|
|
@@ -870,10 +912,12 @@ const forwardPropagate = (funcNode, params, useCounts) => {
|
|
|
870
912
|
|
|
871
913
|
if (op === 'param' || op === 'result' || op === 'local' || op === 'type' || op === 'export') continue
|
|
872
914
|
|
|
873
|
-
// Track local.set values
|
|
874
|
-
|
|
915
|
+
// Track local.set / local.tee values (tee writes too — its result also leaves
|
|
916
|
+
// the value on the stack but the local is updated identically to set).
|
|
917
|
+
if ((op === 'local.set' || op === 'local.tee') && instr.length === 3 && typeof instr[1] === 'string') {
|
|
875
918
|
substGets(instr[2], known) // substitute known values in RHS
|
|
876
919
|
const uses = getUseCount(instr[1])
|
|
920
|
+
purgeRefs(known, instr[1]) // entries that read this local just went stale
|
|
877
921
|
known.set(instr[1], {
|
|
878
922
|
val: instr[2], pure: isPure(instr[2]),
|
|
879
923
|
singleUse: uses.gets <= 1 && uses.sets <= 1 && uses.tees === 0
|
|
@@ -902,6 +946,14 @@ const forwardPropagate = (funcNode, params, useCounts) => {
|
|
|
902
946
|
const prev = clone(instr)
|
|
903
947
|
substGets(instr, known)
|
|
904
948
|
if (!equal(prev, instr)) changed = true
|
|
949
|
+
// Invalidate tracking for any names written by a nested set/tee — those
|
|
950
|
+
// writes happened mid-expression and the substGets above used the
|
|
951
|
+
// pre-write tracked value (correct), but later reads must see the new
|
|
952
|
+
// (untracked) value, not the stale constant.
|
|
953
|
+
walk(instr, n => {
|
|
954
|
+
if (Array.isArray(n) && (n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string')
|
|
955
|
+
{ known.delete(n[1]); purgeRefs(known, n[1]) }
|
|
956
|
+
})
|
|
905
957
|
}
|
|
906
958
|
}
|
|
907
959
|
|
|
@@ -1001,30 +1053,46 @@ const eliminateDeadStores = (funcNode, params, useCounts) => {
|
|
|
1001
1053
|
* Constants propagate to all uses; pure single-use exprs inline into get site.
|
|
1002
1054
|
* Multi-pass with batch counting for convergence.
|
|
1003
1055
|
*/
|
|
1004
|
-
|
|
1005
|
-
|
|
1056
|
+
/** Block-like nodes whose body is a straight-line instruction list (after any header). */
|
|
1057
|
+
const isScopeNode = (n) => Array.isArray(n) &&
|
|
1058
|
+
(n[0] === 'func' || n[0] === 'block' || n[0] === 'loop' || n[0] === 'then' || n[0] === 'else')
|
|
1006
1059
|
|
|
1007
|
-
|
|
1060
|
+
const propagate = (ast) => {
|
|
1061
|
+
walk(ast, (funcNode) => {
|
|
1008
1062
|
if (!Array.isArray(funcNode) || funcNode[0] !== 'func') return
|
|
1009
1063
|
|
|
1010
1064
|
const params = new Set()
|
|
1011
1065
|
for (const sub of funcNode)
|
|
1012
1066
|
if (Array.isArray(sub) && sub[0] === 'param' && typeof sub[1] === 'string') params.add(sub[1])
|
|
1013
1067
|
|
|
1014
|
-
//
|
|
1015
|
-
// (
|
|
1016
|
-
//
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1068
|
+
// Propagation runs per straight-line scope: the function body and every nested
|
|
1069
|
+
// `block`/`loop`/`then`/`else` (including ones embedded in an expression, e.g. the
|
|
1070
|
+
// `(block (result i32) …)` an inlined call leaves behind). Collect scopes deepest-
|
|
1071
|
+
// first so inner simplifications shrink the use-counts the outer scopes see.
|
|
1072
|
+
// Use-counts are always whole-function — a set/get pair or dead store is only
|
|
1073
|
+
// touched when it's globally the sole occurrence, so per-scope work stays sound.
|
|
1074
|
+
const scopes = []
|
|
1075
|
+
walkPost(funcNode, n => { if (isScopeNode(n)) scopes.push(n) })
|
|
1076
|
+
|
|
1077
|
+
// One use-count per round, shared by every scope: substitutions only ever
|
|
1078
|
+
// *drop* gets, so a stale count can only make a sub-pass act more cautiously
|
|
1079
|
+
// (skip a not-yet-provably-dead store, decline a not-yet-provably-single use) —
|
|
1080
|
+
// never wrongly. The next round re-counts and mops up. (Recounting per sub-pass
|
|
1081
|
+
// per scope is O(scopes·funcSize) and crippling on big modules.)
|
|
1082
|
+
for (let round = 0; round < 6; round++) {
|
|
1083
|
+
const useCounts = countLocalUses(funcNode)
|
|
1084
|
+
let progressed = false
|
|
1085
|
+
for (const scope of scopes) {
|
|
1086
|
+
if (forwardPropagate(scope, params, useCounts)) progressed = true
|
|
1087
|
+
if (eliminateSetGetPairs(scope, params, useCounts)) progressed = true
|
|
1088
|
+
if (createLocalTees(scope, params, useCounts)) progressed = true
|
|
1089
|
+
if (eliminateDeadStores(scope, params, useCounts)) progressed = true
|
|
1090
|
+
}
|
|
1091
|
+
if (!progressed) break
|
|
1024
1092
|
}
|
|
1025
1093
|
})
|
|
1026
1094
|
|
|
1027
|
-
return
|
|
1095
|
+
return ast
|
|
1028
1096
|
}
|
|
1029
1097
|
|
|
1030
1098
|
// ==================== FUNCTION INLINING ====================
|
|
@@ -1036,12 +1104,11 @@ const propagate = (ast) => {
|
|
|
1036
1104
|
*/
|
|
1037
1105
|
const inline = (ast) => {
|
|
1038
1106
|
if (!Array.isArray(ast) || ast[0] !== 'module') return ast
|
|
1039
|
-
const result = clone(ast)
|
|
1040
1107
|
|
|
1041
1108
|
// Collect inlinable functions
|
|
1042
1109
|
const inlinable = new Map() // $name → { body, params }
|
|
1043
1110
|
|
|
1044
|
-
for (const node of
|
|
1111
|
+
for (const node of ast.slice(1)) {
|
|
1045
1112
|
if (!Array.isArray(node) || node[0] !== 'func') continue
|
|
1046
1113
|
|
|
1047
1114
|
const name = typeof node[1] === 'string' && node[1][0] === '$' ? node[1] : null
|
|
@@ -1102,9 +1169,9 @@ const inline = (ast) => {
|
|
|
1102
1169
|
}
|
|
1103
1170
|
|
|
1104
1171
|
// Replace calls with inlined body
|
|
1105
|
-
if (inlinable.size === 0) return
|
|
1172
|
+
if (inlinable.size === 0) return ast
|
|
1106
1173
|
|
|
1107
|
-
walkPost(
|
|
1174
|
+
walkPost(ast, (node) => {
|
|
1108
1175
|
if (!Array.isArray(node) || node[0] !== 'call') return
|
|
1109
1176
|
const fname = node[1]
|
|
1110
1177
|
if (!inlinable.has(fname)) return
|
|
@@ -1131,7 +1198,374 @@ const inline = (ast) => {
|
|
|
1131
1198
|
return substituted
|
|
1132
1199
|
})
|
|
1133
1200
|
|
|
1134
|
-
return
|
|
1201
|
+
return ast
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// ==================== INLINE-ONCE ====================
|
|
1205
|
+
|
|
1206
|
+
let inlineUid = 0
|
|
1207
|
+
|
|
1208
|
+
/**
|
|
1209
|
+
* Inline functions that are called from exactly one place into their lone caller,
|
|
1210
|
+
* then delete them. Unlike {@link inline} (which duplicates tiny stateless bodies),
|
|
1211
|
+
* this never duplicates code and never inflates: each inlined function drops a
|
|
1212
|
+
* function-section entry, a type-section entry (if now unused), and a `call`
|
|
1213
|
+
* instruction, paying back only a `block`/`local.set` wrapper. This is what
|
|
1214
|
+
* `wasm-opt -Oz` does — collapsing helper chains down to a couple of functions —
|
|
1215
|
+
* and it's the bulk of the gap between hand-tuned WASM and naive codegen.
|
|
1216
|
+
*
|
|
1217
|
+
* A function `$f` qualifies when it is, all of:
|
|
1218
|
+
* • named, with named params and locals (numeric indices can't be safely renamed);
|
|
1219
|
+
* • referenced exactly once across the whole module, by a plain `call` (no
|
|
1220
|
+
* `return_call`, `ref.func`, `elem`, `export`, or `start` reference, and not
|
|
1221
|
+
* recursive);
|
|
1222
|
+
* • single-result or void (a multi-value result can't be modeled as `(block (result …))`);
|
|
1223
|
+
* • free of numeric (depth-relative) branch labels — those would shift under the
|
|
1224
|
+
* extra block nesting — and of `return_call*` in its body.
|
|
1225
|
+
*
|
|
1226
|
+
* `(call $f a0 a1 …)` becomes
|
|
1227
|
+
* (block $__inlN (result T)?
|
|
1228
|
+
* (local.set $__inlN_p0 a0) (local.set $__inlN_p1 a1) … ;; args evaluated once, in order
|
|
1229
|
+
* …body, params/locals renamed to $__inlN_*, `return X` → `br $__inlN X`…)
|
|
1230
|
+
* and the renamed params+locals are appended to the caller's `local` decls; the
|
|
1231
|
+
* body's own block/loop/if labels are renamed too so they can't shadow the caller's.
|
|
1232
|
+
* Runs to a fixpoint so helper chains fully collapse.
|
|
1233
|
+
*
|
|
1234
|
+
* @param {Array} ast
|
|
1235
|
+
* @returns {Array}
|
|
1236
|
+
*/
|
|
1237
|
+
const inlineOnce = (ast) => {
|
|
1238
|
+
if (!Array.isArray(ast) || ast[0] !== 'module') return ast
|
|
1239
|
+
|
|
1240
|
+
const HEAD = new Set(['export', 'type', 'param', 'result', 'local'])
|
|
1241
|
+
const bodyStart = (fn) => {
|
|
1242
|
+
let i = 2
|
|
1243
|
+
while (i < fn.length && (typeof fn[i] === 'string' || (Array.isArray(fn[i]) && HEAD.has(fn[i][0])))) i++
|
|
1244
|
+
return i
|
|
1245
|
+
}
|
|
1246
|
+
const isBranch = op => op === 'br' || op === 'br_if' || op === 'br_table'
|
|
1247
|
+
// A subtree we can't lift into a (block …): depth-relative branch labels (shift
|
|
1248
|
+
// under added nesting) or tail calls (would escape the wrapping block).
|
|
1249
|
+
const unsafe = (n) => {
|
|
1250
|
+
if (!Array.isArray(n)) return false
|
|
1251
|
+
const op = n[0]
|
|
1252
|
+
if (op === 'return_call' || op === 'return_call_indirect' || op === 'return_call_ref') return true
|
|
1253
|
+
if (op === 'try' || op === 'try_table' || op === 'delegate' || op === 'rethrow') return true // exception labels — not handled by the relabeler below
|
|
1254
|
+
if (isBranch(op)) for (let i = 1; i < n.length; i++) if (typeof n[i] === 'number' || (typeof n[i] === 'string' && /^\d+$/.test(n[i]))) return true
|
|
1255
|
+
for (let i = 1; i < n.length; i++) if (unsafe(n[i])) return true
|
|
1256
|
+
return false
|
|
1257
|
+
}
|
|
1258
|
+
const callsSelf = (n, name) => {
|
|
1259
|
+
if (!Array.isArray(n)) return false
|
|
1260
|
+
if ((n[0] === 'call' || n[0] === 'return_call') && n[1] === name) return true
|
|
1261
|
+
for (let i = 1; i < n.length; i++) if (callsSelf(n[i], name)) return true
|
|
1262
|
+
return false
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// Module-level references that pin a function (can't be removed/inlined-away).
|
|
1266
|
+
const collectPinned = (n, pinned) => {
|
|
1267
|
+
if (!Array.isArray(n)) return
|
|
1268
|
+
const op = n[0]
|
|
1269
|
+
if (op === 'export' && Array.isArray(n[2]) && n[2][0] === 'func' && typeof n[2][1] === 'string') pinned.add(n[2][1])
|
|
1270
|
+
else if (op === 'start' && typeof n[1] === 'string') pinned.add(n[1])
|
|
1271
|
+
else if (op === 'ref.func' && typeof n[1] === 'string') pinned.add(n[1])
|
|
1272
|
+
else if (op === 'elem') for (const c of n) if (typeof c === 'string' && c[0] === '$') pinned.add(c)
|
|
1273
|
+
for (const c of n) collectPinned(c, pinned)
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
for (let round = 0; round < 16; round++) {
|
|
1277
|
+
const funcs = ast.filter(n => Array.isArray(n) && n[0] === 'func')
|
|
1278
|
+
const funcByName = new Map()
|
|
1279
|
+
for (const n of funcs) if (typeof n[1] === 'string') funcByName.set(n[1], n)
|
|
1280
|
+
|
|
1281
|
+
// Count plain-call references across the WHOLE module (anonymous exported funcs
|
|
1282
|
+
// call helpers too); flag any non-call reference (return_call etc.).
|
|
1283
|
+
const callRefs = new Map(), otherRef = new Set()
|
|
1284
|
+
const countRefs = (n) => {
|
|
1285
|
+
if (!Array.isArray(n)) return
|
|
1286
|
+
const op = n[0]
|
|
1287
|
+
if (op === 'call' && typeof n[1] === 'string') callRefs.set(n[1], (callRefs.get(n[1]) || 0) + 1)
|
|
1288
|
+
else if (op === 'return_call' && typeof n[1] === 'string') otherRef.add(n[1])
|
|
1289
|
+
for (let i = 1; i < n.length; i++) countRefs(n[i])
|
|
1290
|
+
}
|
|
1291
|
+
countRefs(ast)
|
|
1292
|
+
const pinned = new Set()
|
|
1293
|
+
for (const n of ast) if (!Array.isArray(n) || n[0] !== 'func') collectPinned(n, pinned)
|
|
1294
|
+
// a func may carry its own (export "name") — the signature scan below rejects those too
|
|
1295
|
+
|
|
1296
|
+
// Pick a callee.
|
|
1297
|
+
let calleeName = null
|
|
1298
|
+
for (const [name, fn] of funcByName) {
|
|
1299
|
+
if (pinned.has(name) || otherRef.has(name)) continue
|
|
1300
|
+
if (callRefs.get(name) !== 1) continue
|
|
1301
|
+
if (callsSelf(fn, name)) continue
|
|
1302
|
+
// named params/locals only; collect signature
|
|
1303
|
+
let ok = true, nResult = 0
|
|
1304
|
+
for (let i = 2; i < fn.length; i++) {
|
|
1305
|
+
const c = fn[i]
|
|
1306
|
+
if (typeof c === 'string') continue
|
|
1307
|
+
if (!Array.isArray(c)) { ok = false; break }
|
|
1308
|
+
if (c[0] === 'param' || c[0] === 'local') { if (typeof c[1] !== 'string' || c[1][0] !== '$') { ok = false; break } }
|
|
1309
|
+
else if (c[0] === 'result') nResult += c.length - 1
|
|
1310
|
+
else if (c[0] === 'export') { ok = false; break }
|
|
1311
|
+
else if (c[0] === 'type') continue
|
|
1312
|
+
else break
|
|
1313
|
+
}
|
|
1314
|
+
if (!ok || nResult > 1) continue
|
|
1315
|
+
let bad = false
|
|
1316
|
+
for (let i = bodyStart(fn); i < fn.length; i++) if (unsafe(fn[i])) { bad = true; break }
|
|
1317
|
+
if (bad) continue
|
|
1318
|
+
calleeName = name; break
|
|
1319
|
+
}
|
|
1320
|
+
if (!calleeName) break
|
|
1321
|
+
|
|
1322
|
+
const callee = funcByName.get(calleeName)
|
|
1323
|
+
const params = [], locals = []
|
|
1324
|
+
let resultType = null
|
|
1325
|
+
for (let i = 2; i < callee.length; i++) {
|
|
1326
|
+
const c = callee[i]
|
|
1327
|
+
if (typeof c === 'string' || !Array.isArray(c)) continue
|
|
1328
|
+
if (c[0] === 'param') params.push({ name: c[1], type: c[2] })
|
|
1329
|
+
else if (c[0] === 'result') { if (c.length > 1) resultType = c[1] }
|
|
1330
|
+
else if (c[0] === 'local') locals.push({ name: c[1], type: c[2] })
|
|
1331
|
+
else if (c[0] === 'export' || c[0] === 'type') continue
|
|
1332
|
+
else break
|
|
1333
|
+
}
|
|
1334
|
+
const cBody = callee.slice(bodyStart(callee))
|
|
1335
|
+
|
|
1336
|
+
const uid = ++inlineUid
|
|
1337
|
+
const exit = `$__inl${uid}`
|
|
1338
|
+
const rename = new Map()
|
|
1339
|
+
for (const p of params) rename.set(p.name, `$__inl${uid}_${p.name.slice(1)}`)
|
|
1340
|
+
for (const l of locals) rename.set(l.name, `$__inl${uid}_${l.name.slice(1)}`)
|
|
1341
|
+
// The callee's own block/loop/if labels would shadow same-named labels in the
|
|
1342
|
+
// caller after nesting (and break depth resolution) — give them fresh names too.
|
|
1343
|
+
const isBlockLabel = op => op === 'block' || op === 'loop' || op === 'if'
|
|
1344
|
+
const labelRename = new Map()
|
|
1345
|
+
const collectLabels = (n) => {
|
|
1346
|
+
if (!Array.isArray(n)) return
|
|
1347
|
+
if (isBlockLabel(n[0]) && typeof n[1] === 'string' && n[1][0] === '$' && !labelRename.has(n[1]))
|
|
1348
|
+
labelRename.set(n[1], `$__inl${uid}L_${n[1].slice(1)}`)
|
|
1349
|
+
for (let i = 1; i < n.length; i++) collectLabels(n[i])
|
|
1350
|
+
}
|
|
1351
|
+
for (const n of cBody) collectLabels(n)
|
|
1352
|
+
const sub = (n) => {
|
|
1353
|
+
if (!Array.isArray(n)) return n
|
|
1354
|
+
const op = n[0]
|
|
1355
|
+
if ((op === 'local.get' || op === 'local.set' || op === 'local.tee') && typeof n[1] === 'string' && rename.has(n[1]))
|
|
1356
|
+
return [op, rename.get(n[1]), ...n.slice(2).map(sub)]
|
|
1357
|
+
if (op === 'return') return ['br', exit, ...n.slice(1).map(sub)]
|
|
1358
|
+
if (isBlockLabel(op) && typeof n[1] === 'string' && labelRename.has(n[1]))
|
|
1359
|
+
return [op, labelRename.get(n[1]), ...n.slice(2).map(sub)]
|
|
1360
|
+
if (isBranch(op)) return [op, ...n.slice(1).map(c => (typeof c === 'string' && labelRename.has(c)) ? labelRename.get(c) : sub(c))]
|
|
1361
|
+
return n.map((c, i) => i === 0 ? c : sub(c))
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// Splice into the (unique) caller (which may be an anonymous exported func).
|
|
1365
|
+
let done = false
|
|
1366
|
+
for (const fn of funcs) {
|
|
1367
|
+
if (fn === callee || done) continue
|
|
1368
|
+
const start = bodyStart(fn)
|
|
1369
|
+
for (let i = start; i < fn.length; i++) {
|
|
1370
|
+
const replaced = walkPost(fn[i], (n) => {
|
|
1371
|
+
if (done || !Array.isArray(n) || n[0] !== 'call' || n[1] !== calleeName) return
|
|
1372
|
+
const args = n.slice(2)
|
|
1373
|
+
if (args.length !== params.length) return // arity mismatch — leave it
|
|
1374
|
+
const setup = params.map((p, k) => ['local.set', rename.get(p.name), args[k]])
|
|
1375
|
+
const inner = cBody.map(sub)
|
|
1376
|
+
done = true
|
|
1377
|
+
return resultType
|
|
1378
|
+
? ['block', exit, ['result', resultType], ...setup, ...inner]
|
|
1379
|
+
: ['block', exit, ...setup, ...inner]
|
|
1380
|
+
})
|
|
1381
|
+
if (replaced !== fn[i]) fn[i] = replaced
|
|
1382
|
+
if (done) {
|
|
1383
|
+
const decls = [...params, ...locals].map(p => ['local', rename.get(p.name), p.type])
|
|
1384
|
+
if (decls.length) fn.splice(bodyStart(fn), 0, ...decls)
|
|
1385
|
+
break
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
if (done) break
|
|
1389
|
+
}
|
|
1390
|
+
if (!done) break // call site not found inside a func body — give up
|
|
1391
|
+
|
|
1392
|
+
const idx = ast.indexOf(callee)
|
|
1393
|
+
if (idx >= 0) ast.splice(idx, 1)
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
return ast
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// ==================== MERGE BLOCKS ====================
|
|
1400
|
+
|
|
1401
|
+
/**
|
|
1402
|
+
* Does `body` contain a branch instruction targeting `label`, ignoring inner
|
|
1403
|
+
* blocks/loops that re-bind the same label?
|
|
1404
|
+
*/
|
|
1405
|
+
const targetsLabel = (body, label) => {
|
|
1406
|
+
let found = false
|
|
1407
|
+
const search = (n, shadowed) => {
|
|
1408
|
+
if (found || !Array.isArray(n)) return
|
|
1409
|
+
const op = n[0]
|
|
1410
|
+
let inner = shadowed
|
|
1411
|
+
if ((op === 'block' || op === 'loop') && typeof n[1] === 'string' && n[1] === label) inner = true
|
|
1412
|
+
if (!shadowed) {
|
|
1413
|
+
if (op === 'br' || op === 'br_if' || op === 'br_on_null' || op === 'br_on_non_null' ||
|
|
1414
|
+
op === 'br_on_cast' || op === 'br_on_cast_fail') {
|
|
1415
|
+
if (n[1] === label) { found = true; return }
|
|
1416
|
+
} else if (op === 'br_table') {
|
|
1417
|
+
for (let j = 1; j < n.length; j++) {
|
|
1418
|
+
if (typeof n[j] === 'string') { if (n[j] === label) { found = true; return } }
|
|
1419
|
+
else break
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
for (let i = 1; i < n.length; i++) search(n[i], inner)
|
|
1424
|
+
}
|
|
1425
|
+
for (const node of body) search(node, false)
|
|
1426
|
+
return found
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
/**
|
|
1430
|
+
* Unwrap redundant `(block $L body)` whose label is never targeted, splicing
|
|
1431
|
+
* the body into the surrounding scope. Skips blocks with `param`/`result`/`type`
|
|
1432
|
+
* annotations (their stack effect would change). Each unwrap drops the
|
|
1433
|
+
* `block`+`end` framing bytes; iterates by walk so chained blocks collapse.
|
|
1434
|
+
* @param {Array} ast
|
|
1435
|
+
* @returns {Array}
|
|
1436
|
+
*/
|
|
1437
|
+
const mergeBlocks = (ast) => {
|
|
1438
|
+
walk(ast, (node) => {
|
|
1439
|
+
if (!isScopeNode(node)) return
|
|
1440
|
+
let i = 1
|
|
1441
|
+
while (i < node.length) {
|
|
1442
|
+
const child = node[i]
|
|
1443
|
+
if (!Array.isArray(child) || child[0] !== 'block') { i++; continue }
|
|
1444
|
+
let bi = 1, label = null
|
|
1445
|
+
if (typeof child[1] === 'string' && child[1][0] === '$') { label = child[1]; bi = 2 }
|
|
1446
|
+
let typed = false
|
|
1447
|
+
for (let j = bi; j < child.length; j++) {
|
|
1448
|
+
const c = child[j]
|
|
1449
|
+
if (Array.isArray(c) && (c[0] === 'param' || c[0] === 'result' || c[0] === 'type')) { typed = true; break }
|
|
1450
|
+
}
|
|
1451
|
+
if (typed) { i++; continue }
|
|
1452
|
+
const body = child.slice(bi)
|
|
1453
|
+
if (label && targetsLabel(body, label)) { i++; continue }
|
|
1454
|
+
node.splice(i, 1, ...body)
|
|
1455
|
+
i += body.length
|
|
1456
|
+
}
|
|
1457
|
+
})
|
|
1458
|
+
return ast
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// ==================== COALESCE LOCALS ====================
|
|
1462
|
+
|
|
1463
|
+
/**
|
|
1464
|
+
* Share local slots between same-type locals with non-overlapping live ranges.
|
|
1465
|
+
* Live range = [first pos, last pos] of any local.get/set/tee, extended over
|
|
1466
|
+
* any loop containing a reference (so a value read across loop iterations stays
|
|
1467
|
+
* intact). Greedy slot assignment by start position. Params and unnamed/numeric
|
|
1468
|
+
* references are left alone; `localReuse` later removes the renamed-away decls.
|
|
1469
|
+
*
|
|
1470
|
+
* Soundness: WASM zero-initializes locals at function entry, so a local whose
|
|
1471
|
+
* first reference (in walk order) is a `local.get` *relies* on that implicit
|
|
1472
|
+
* zero — coalescing it into a slot whose previous user left a non-zero residue
|
|
1473
|
+
* would silently change behavior (e.g. a `for (let i=0; …)` loop counter
|
|
1474
|
+
* inheriting `N*4` from a sibling temp). Such "read-first" locals can still
|
|
1475
|
+
* serve as a slot's *primary* (the slot then keeps the function's zero start),
|
|
1476
|
+
* but can never be a donor merged into an existing slot.
|
|
1477
|
+
* @param {Array} ast
|
|
1478
|
+
* @returns {Array}
|
|
1479
|
+
*/
|
|
1480
|
+
const coalesceLocals = (ast) => {
|
|
1481
|
+
walk(ast, (funcNode) => {
|
|
1482
|
+
if (!Array.isArray(funcNode) || funcNode[0] !== 'func') return
|
|
1483
|
+
|
|
1484
|
+
const decls = new Map()
|
|
1485
|
+
for (const sub of funcNode) {
|
|
1486
|
+
if (Array.isArray(sub) && sub[0] === 'local' &&
|
|
1487
|
+
typeof sub[1] === 'string' && sub[1][0] === '$' && typeof sub[2] === 'string') {
|
|
1488
|
+
decls.set(sub[1], sub[2])
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
if (decls.size < 2) return
|
|
1492
|
+
|
|
1493
|
+
const uses = new Map()
|
|
1494
|
+
const loopStack = []
|
|
1495
|
+
let pos = 0, abort = false, condDepth = 0
|
|
1496
|
+
|
|
1497
|
+
const visit = (n) => {
|
|
1498
|
+
if (abort || !Array.isArray(n)) return
|
|
1499
|
+
const op = n[0]
|
|
1500
|
+
const isLoop = op === 'loop'
|
|
1501
|
+
if (isLoop) loopStack.push({ start: pos, end: pos })
|
|
1502
|
+
const isSet = op === 'local.set' || op === 'local.tee'
|
|
1503
|
+
|
|
1504
|
+
if (isSet || op === 'local.get') {
|
|
1505
|
+
const name = n[1]
|
|
1506
|
+
if (typeof name !== 'string' || name[0] !== '$') { abort = true; return }
|
|
1507
|
+
// Execution order: evaluate set/tee value BEFORE recording the write,
|
|
1508
|
+
// so a `(local.set $x (… (local.get $x) …))` is correctly seen as a
|
|
1509
|
+
// read-then-write of $x (firstOp = local.get).
|
|
1510
|
+
if (isSet) for (let i = 2; i < n.length; i++) visit(n[i])
|
|
1511
|
+
const here = pos++
|
|
1512
|
+
if (decls.has(name)) {
|
|
1513
|
+
let u = uses.get(name)
|
|
1514
|
+
if (!u) { u = { start: here, end: here, firstOp: op, firstCond: condDepth > 0, loops: new Set() }; uses.set(name, u) }
|
|
1515
|
+
if (here > u.end) u.end = here
|
|
1516
|
+
for (const ls of loopStack) u.loops.add(ls)
|
|
1517
|
+
}
|
|
1518
|
+
} else {
|
|
1519
|
+
pos++
|
|
1520
|
+
const isIf = op === 'if'
|
|
1521
|
+
for (let i = 1; i < n.length; i++) {
|
|
1522
|
+
const c = n[i]
|
|
1523
|
+
const cond = isIf && Array.isArray(c) && (c[0] === 'then' || c[0] === 'else')
|
|
1524
|
+
if (cond) condDepth++
|
|
1525
|
+
visit(c)
|
|
1526
|
+
if (cond) condDepth--
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
if (isLoop) { const ls = loopStack.pop(); ls.end = pos }
|
|
1531
|
+
}
|
|
1532
|
+
visit(funcNode)
|
|
1533
|
+
if (abort) return
|
|
1534
|
+
|
|
1535
|
+
// A use inside a loop must stay live for the whole loop — the next
|
|
1536
|
+
// iteration could read what this iteration wrote.
|
|
1537
|
+
for (const u of uses.values()) {
|
|
1538
|
+
for (const ls of u.loops) {
|
|
1539
|
+
if (ls.start < u.start) u.start = ls.start
|
|
1540
|
+
if (ls.end > u.end) u.end = ls.end
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
const ordered = [...uses.entries()].sort((a, b) => a[1].start - b[1].start)
|
|
1545
|
+
const rename = new Map()
|
|
1546
|
+
const slots = []
|
|
1547
|
+
for (const [name, range] of ordered) {
|
|
1548
|
+
// Read-first locals depend on the implicit zero; locals first seen inside
|
|
1549
|
+
// an if/else branch may be skipped on the alternate path — either way
|
|
1550
|
+
// they'd observe a prior slot's residue if reused. They may *start* a
|
|
1551
|
+
// fresh slot (the function's zero init), but never *join* one.
|
|
1552
|
+
const readsZero = range.firstOp === 'local.get' || range.firstCond
|
|
1553
|
+
const type = decls.get(name)
|
|
1554
|
+
const slot = readsZero ? null : slots.find(s => s.type === type && s.end < range.start)
|
|
1555
|
+
if (slot) { rename.set(name, slot.primary); if (range.end > slot.end) slot.end = range.end }
|
|
1556
|
+
else slots.push({ primary: name, type, end: range.end })
|
|
1557
|
+
}
|
|
1558
|
+
if (rename.size === 0) return
|
|
1559
|
+
|
|
1560
|
+
walk(funcNode, (n) => {
|
|
1561
|
+
if (Array.isArray(n) &&
|
|
1562
|
+
(n[0] === 'local.get' || n[0] === 'local.set' || n[0] === 'local.tee') &&
|
|
1563
|
+
rename.has(n[1])) {
|
|
1564
|
+
n[1] = rename.get(n[1])
|
|
1565
|
+
}
|
|
1566
|
+
})
|
|
1567
|
+
})
|
|
1568
|
+
return ast
|
|
1135
1569
|
}
|
|
1136
1570
|
|
|
1137
1571
|
// ==================== VACUUM ====================
|
|
@@ -1143,7 +1577,7 @@ const inline = (ast) => {
|
|
|
1143
1577
|
* @returns {Array}
|
|
1144
1578
|
*/
|
|
1145
1579
|
const vacuum = (ast) => {
|
|
1146
|
-
return walkPost(
|
|
1580
|
+
return walkPost(ast, (node) => {
|
|
1147
1581
|
if (!Array.isArray(node)) return
|
|
1148
1582
|
const op = node[0]
|
|
1149
1583
|
|
|
@@ -1269,7 +1703,7 @@ const PEEPHOLE = {
|
|
|
1269
1703
|
* @returns {Array}
|
|
1270
1704
|
*/
|
|
1271
1705
|
const peephole = (ast) => {
|
|
1272
|
-
return walkPost(
|
|
1706
|
+
return walkPost(ast, (node) => {
|
|
1273
1707
|
if (!Array.isArray(node) || node.length !== 3) return
|
|
1274
1708
|
const fn = PEEPHOLE[node[0]]
|
|
1275
1709
|
if (!fn) return
|
|
@@ -1280,60 +1714,106 @@ const peephole = (ast) => {
|
|
|
1280
1714
|
|
|
1281
1715
|
// ==================== GLOBAL CONSTANT PROPAGATION ====================
|
|
1282
1716
|
|
|
1717
|
+
/** Bytes a signed-LEB128 integer encodes to. */
|
|
1718
|
+
const slebSize = (v) => {
|
|
1719
|
+
let x = typeof v === 'bigint' ? v : BigInt(Math.trunc(Number(v) || 0))
|
|
1720
|
+
let n = 1
|
|
1721
|
+
while (true) {
|
|
1722
|
+
const b = x & 0x7fn
|
|
1723
|
+
x >>= 7n
|
|
1724
|
+
if ((x === 0n && (b & 0x40n) === 0n) || (x === -1n && (b & 0x40n) !== 0n)) return n
|
|
1725
|
+
n++
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
/** Encoded byte size of a constant init instruction (opcode + immediate). */
|
|
1729
|
+
const constInstrSize = (node) => {
|
|
1730
|
+
if (!Array.isArray(node)) return 4
|
|
1731
|
+
switch (node[0]) {
|
|
1732
|
+
case 'i32.const': case 'i64.const': return 1 + slebSize(node[1])
|
|
1733
|
+
case 'f32.const': return 5
|
|
1734
|
+
case 'f64.const': return 9
|
|
1735
|
+
case 'v128.const': return 18
|
|
1736
|
+
default: return 4 // ref.null/ref.func/global.get — conservative
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
const GLOBAL_GET_SIZE = 2 // 0x23 opcode + 1-byte globalidx (typical)
|
|
1740
|
+
|
|
1283
1741
|
/**
|
|
1284
|
-
* Replace global.get of immutable
|
|
1742
|
+
* Replace `global.get` of an immutable, const-initialised global with the
|
|
1743
|
+
* constant — but only when it doesn't grow the module. A `global.get` costs
|
|
1744
|
+
* ~2 B; an `i32.const 12345` costs 4 B; an `f64.const` costs 9 B. Naively
|
|
1745
|
+
* inlining a big constant read from many sites trades a few cheap reads + one
|
|
1746
|
+
* global decl for many fat immediates — pure bloat (and the node-count size
|
|
1747
|
+
* guard can't see it: same number of AST nodes). So we only propagate a global
|
|
1748
|
+
* when `refs·constSize ≤ refs·2 + declSize`; when every read is replaced and
|
|
1749
|
+
* the global isn't exported, its now-dead decl is dropped here too.
|
|
1285
1750
|
* @param {Array} ast
|
|
1286
1751
|
* @returns {Array}
|
|
1287
1752
|
*/
|
|
1288
1753
|
const globals = (ast) => {
|
|
1289
1754
|
if (!Array.isArray(ast) || ast[0] !== 'module') return ast
|
|
1290
|
-
const result = clone(ast)
|
|
1291
1755
|
|
|
1292
|
-
//
|
|
1293
|
-
const constGlobals = new Map()
|
|
1294
|
-
const
|
|
1756
|
+
// Immutable globals with a constant init: name → init node.
|
|
1757
|
+
const constGlobals = new Map()
|
|
1758
|
+
const exported = new Set() // globals pinned by an export — keep the decl
|
|
1295
1759
|
|
|
1296
|
-
for (const node of
|
|
1297
|
-
if (!Array.isArray(node)
|
|
1760
|
+
for (const node of ast.slice(1)) {
|
|
1761
|
+
if (!Array.isArray(node)) continue
|
|
1762
|
+
if (node[0] === 'export' && Array.isArray(node[2]) && node[2][0] === 'global' && typeof node[2][1] === 'string') { exported.add(node[2][1]); continue }
|
|
1763
|
+
if (node[0] !== 'global') continue
|
|
1298
1764
|
const name = typeof node[1] === 'string' && node[1][0] === '$' ? node[1] : null
|
|
1299
1765
|
if (!name) continue
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
const
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
const typeSlot = hasName ? node[2] : node[1]
|
|
1307
|
-
if (Array.isArray(typeSlot) && typeSlot[0] === 'mut') continue
|
|
1308
|
-
|
|
1309
|
-
const init = node[initIdx]
|
|
1766
|
+
// (global $g (export "x") …) inline export → pinned
|
|
1767
|
+
if (node.some(c => Array.isArray(c) && c[0] === 'export')) exported.add(name)
|
|
1768
|
+
const typeSlot = node[2]
|
|
1769
|
+
if (Array.isArray(typeSlot) && typeSlot[0] === 'mut') continue // mutable
|
|
1770
|
+
if (Array.isArray(typeSlot) && typeSlot[0] === 'import') continue // imported
|
|
1771
|
+
const init = node[3]
|
|
1310
1772
|
if (getConst(init)) constGlobals.set(name, init)
|
|
1311
1773
|
}
|
|
1774
|
+
if (constGlobals.size === 0) return ast
|
|
1312
1775
|
|
|
1313
|
-
//
|
|
1314
|
-
|
|
1315
|
-
|
|
1776
|
+
// Drop any global that is ever written (defensive — an immutable global can't
|
|
1777
|
+
// be, but a malformed module might) and tally read counts.
|
|
1778
|
+
const reads = new Map()
|
|
1779
|
+
walk(ast, (n) => {
|
|
1780
|
+
if (!Array.isArray(n)) return
|
|
1316
1781
|
const ref = n[1]
|
|
1317
|
-
if (typeof ref
|
|
1782
|
+
if (typeof ref !== 'string' || ref[0] !== '$') return
|
|
1783
|
+
if (n[0] === 'global.set') constGlobals.delete(ref)
|
|
1784
|
+
else if (n[0] === 'global.get') reads.set(ref, (reads.get(ref) || 0) + 1)
|
|
1318
1785
|
})
|
|
1319
1786
|
|
|
1320
|
-
//
|
|
1321
|
-
|
|
1322
|
-
|
|
1787
|
+
// Keep only globals where propagation is size-neutral or better.
|
|
1788
|
+
const propagate = new Set()
|
|
1789
|
+
for (const [name, init] of constGlobals) {
|
|
1790
|
+
const r = reads.get(name) || 0
|
|
1791
|
+
if (r === 0) continue // dead anyway — leave to treeshake
|
|
1792
|
+
const cs = constInstrSize(init)
|
|
1793
|
+
const declSize = cs + 2 // valtype + mutability byte + init expr + `end`
|
|
1794
|
+
const before = r * GLOBAL_GET_SIZE + declSize
|
|
1795
|
+
const after = r * cs + (exported.has(name) ? declSize : 0)
|
|
1796
|
+
if (after <= before) propagate.add(name)
|
|
1797
|
+
}
|
|
1798
|
+
if (propagate.size === 0) return ast
|
|
1323
1799
|
|
|
1324
|
-
|
|
1325
|
-
return walkPost(result, (node) => {
|
|
1800
|
+
walkPost(ast, (node) => {
|
|
1326
1801
|
if (!Array.isArray(node) || node[0] !== 'global.get' || node.length !== 2) return
|
|
1327
|
-
|
|
1328
|
-
if (constGlobals.has(ref)) return clone(constGlobals.get(ref))
|
|
1802
|
+
if (propagate.has(node[1])) return clone(constGlobals.get(node[1]))
|
|
1329
1803
|
})
|
|
1804
|
+
// Their reads are all gone now — remove the decls we're free to remove.
|
|
1805
|
+
for (let i = ast.length - 1; i >= 1; i--) {
|
|
1806
|
+
const n = ast[i]
|
|
1807
|
+
if (Array.isArray(n) && n[0] === 'global' && typeof n[1] === 'string' && propagate.has(n[1]) && !exported.has(n[1])) ast.splice(i, 1)
|
|
1808
|
+
}
|
|
1809
|
+
return ast
|
|
1330
1810
|
}
|
|
1331
1811
|
|
|
1332
1812
|
// ==================== LOAD/STORE OFFSET FOLDING ====================
|
|
1333
1813
|
|
|
1334
1814
|
/** Match (type.load/store (i32.add ptr (type.const N))) and fold offset */
|
|
1335
1815
|
const offset = (ast) => {
|
|
1336
|
-
return walkPost(
|
|
1816
|
+
return walkPost(ast, (node) => {
|
|
1337
1817
|
if (!Array.isArray(node)) return
|
|
1338
1818
|
const op = node[0]
|
|
1339
1819
|
if (typeof op !== 'string' || (!op.endsWith('load') && !op.endsWith('store'))) return
|
|
@@ -1406,9 +1886,7 @@ const offset = (ast) => {
|
|
|
1406
1886
|
* @returns {Array}
|
|
1407
1887
|
*/
|
|
1408
1888
|
const unbranch = (ast) => {
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
walk(result, (node) => {
|
|
1889
|
+
walk(ast, (node) => {
|
|
1412
1890
|
if (!Array.isArray(node)) return
|
|
1413
1891
|
const op = node[0]
|
|
1414
1892
|
// Loops: `br $loop_label` jumps BACK to loop top (continue), not out.
|
|
@@ -1441,11 +1919,13 @@ const unbranch = (ast) => {
|
|
|
1441
1919
|
|
|
1442
1920
|
const last = node[lastIdx]
|
|
1443
1921
|
if (Array.isArray(last) && last[0] === 'br' && last[1] === label) {
|
|
1444
|
-
|
|
1922
|
+
// `(br $L v…)` as a block's last instruction just leaves v… as the block's
|
|
1923
|
+
// result — splice the value operand(s) in its place (none → plain removal).
|
|
1924
|
+
node.splice(lastIdx, 1, ...last.slice(2))
|
|
1445
1925
|
}
|
|
1446
1926
|
})
|
|
1447
1927
|
|
|
1448
|
-
return
|
|
1928
|
+
return ast
|
|
1449
1929
|
}
|
|
1450
1930
|
|
|
1451
1931
|
// ==================== STRIP MUT FROM GLOBALS ====================
|
|
@@ -1458,14 +1938,13 @@ const unbranch = (ast) => {
|
|
|
1458
1938
|
*/
|
|
1459
1939
|
const stripmut = (ast) => {
|
|
1460
1940
|
if (!Array.isArray(ast) || ast[0] !== 'module') return ast
|
|
1461
|
-
const result = clone(ast)
|
|
1462
1941
|
|
|
1463
1942
|
const written = new Set()
|
|
1464
|
-
walk(
|
|
1943
|
+
walk(ast, (n) => {
|
|
1465
1944
|
if (Array.isArray(n) && n[0] === 'global.set' && typeof n[1] === 'string') written.add(n[1])
|
|
1466
1945
|
})
|
|
1467
1946
|
|
|
1468
|
-
return walkPost(
|
|
1947
|
+
return walkPost(ast, (node) => {
|
|
1469
1948
|
if (!Array.isArray(node) || node[0] !== 'global') return
|
|
1470
1949
|
const name = typeof node[1] === 'string' && node[1][0] === '$' ? node[1] : null
|
|
1471
1950
|
if (!name || written.has(name)) return
|
|
@@ -1490,7 +1969,7 @@ const stripmut = (ast) => {
|
|
|
1490
1969
|
* @returns {Array}
|
|
1491
1970
|
*/
|
|
1492
1971
|
const brif = (ast) => {
|
|
1493
|
-
return walkPost(
|
|
1972
|
+
return walkPost(ast, (node) => {
|
|
1494
1973
|
if (!Array.isArray(node) || node[0] !== 'if') return
|
|
1495
1974
|
const { cond, thenBranch, elseBranch } = parseIf(node)
|
|
1496
1975
|
const thenEmpty = !thenBranch || thenBranch.length <= 1
|
|
@@ -1519,7 +1998,7 @@ const brif = (ast) => {
|
|
|
1519
1998
|
* @returns {Array}
|
|
1520
1999
|
*/
|
|
1521
2000
|
const foldarms = (ast) => {
|
|
1522
|
-
return walkPost(
|
|
2001
|
+
return walkPost(ast, (node) => {
|
|
1523
2002
|
if (!Array.isArray(node) || node[0] !== 'if') return
|
|
1524
2003
|
const { thenBranch, elseBranch } = parseIf(node)
|
|
1525
2004
|
if (!thenBranch || !elseBranch) return
|
|
@@ -1611,13 +2090,12 @@ const hashFunc = (node, localNames) => {
|
|
|
1611
2090
|
*/
|
|
1612
2091
|
const dedupe = (ast) => {
|
|
1613
2092
|
if (!Array.isArray(ast) || ast[0] !== 'module') return ast
|
|
1614
|
-
const result = clone(ast)
|
|
1615
2093
|
|
|
1616
2094
|
// Hash function bodies (normalize local/param names to avoid false negatives)
|
|
1617
2095
|
const signatures = new Map() // hash → canonical $name
|
|
1618
2096
|
const redirects = new Map() // duplicate $name → canonical $name
|
|
1619
2097
|
|
|
1620
|
-
for (const node of
|
|
2098
|
+
for (const node of ast.slice(1)) {
|
|
1621
2099
|
if (!Array.isArray(node) || node[0] !== 'func') continue
|
|
1622
2100
|
const name = typeof node[1] === 'string' && node[1][0] === '$' ? node[1] : null
|
|
1623
2101
|
if (!name) continue
|
|
@@ -1645,10 +2123,10 @@ const dedupe = (ast) => {
|
|
|
1645
2123
|
}
|
|
1646
2124
|
}
|
|
1647
2125
|
|
|
1648
|
-
if (redirects.size === 0) return
|
|
2126
|
+
if (redirects.size === 0) return ast
|
|
1649
2127
|
|
|
1650
2128
|
// Rewrite all references: calls, ref.func, elem segments, call_indirect type
|
|
1651
|
-
walkPost(
|
|
2129
|
+
walkPost(ast, (node) => {
|
|
1652
2130
|
if (!Array.isArray(node)) return
|
|
1653
2131
|
const op = node[0]
|
|
1654
2132
|
if ((op === 'call' || op === 'return_call') && redirects.has(node[1])) {
|
|
@@ -1671,7 +2149,7 @@ const dedupe = (ast) => {
|
|
|
1671
2149
|
}
|
|
1672
2150
|
})
|
|
1673
2151
|
|
|
1674
|
-
return
|
|
2152
|
+
return ast
|
|
1675
2153
|
}
|
|
1676
2154
|
|
|
1677
2155
|
// ==================== TYPE DEDUPLICATION ====================
|
|
@@ -1684,12 +2162,11 @@ const dedupe = (ast) => {
|
|
|
1684
2162
|
*/
|
|
1685
2163
|
const dedupTypes = (ast) => {
|
|
1686
2164
|
if (!Array.isArray(ast) || ast[0] !== 'module') return ast
|
|
1687
|
-
const result = clone(ast)
|
|
1688
2165
|
|
|
1689
2166
|
const signatures = new Map() // hash → canonical $name
|
|
1690
2167
|
const redirects = new Map() // duplicate $name → canonical $name
|
|
1691
2168
|
|
|
1692
|
-
for (const node of
|
|
2169
|
+
for (const node of ast.slice(1)) {
|
|
1693
2170
|
if (!Array.isArray(node) || node[0] !== 'type') continue
|
|
1694
2171
|
const name = typeof node[1] === 'string' && node[1][0] === '$' ? node[1] : null
|
|
1695
2172
|
if (!name) continue
|
|
@@ -1704,18 +2181,18 @@ const dedupTypes = (ast) => {
|
|
|
1704
2181
|
}
|
|
1705
2182
|
}
|
|
1706
2183
|
|
|
1707
|
-
if (redirects.size === 0) return
|
|
2184
|
+
if (redirects.size === 0) return ast
|
|
1708
2185
|
|
|
1709
2186
|
// Remove duplicate type nodes
|
|
1710
|
-
for (let i =
|
|
1711
|
-
const node =
|
|
2187
|
+
for (let i = ast.length - 1; i >= 0; i--) {
|
|
2188
|
+
const node = ast[i]
|
|
1712
2189
|
if (Array.isArray(node) && node[0] === 'type') {
|
|
1713
2190
|
const name = typeof node[1] === 'string' && node[1][0] === '$' ? node[1] : null
|
|
1714
|
-
if (name && redirects.has(name))
|
|
2191
|
+
if (name && redirects.has(name)) ast.splice(i, 1)
|
|
1715
2192
|
}
|
|
1716
2193
|
}
|
|
1717
2194
|
|
|
1718
|
-
walkPost(
|
|
2195
|
+
walkPost(ast, (node) => {
|
|
1719
2196
|
if (!Array.isArray(node)) return
|
|
1720
2197
|
const op = node[0]
|
|
1721
2198
|
|
|
@@ -1755,7 +2232,7 @@ const dedupTypes = (ast) => {
|
|
|
1755
2232
|
}
|
|
1756
2233
|
})
|
|
1757
2234
|
|
|
1758
|
-
return
|
|
2235
|
+
return ast
|
|
1759
2236
|
}
|
|
1760
2237
|
|
|
1761
2238
|
// ==================== DATA SEGMENT PACKING ====================
|
|
@@ -1895,10 +2372,9 @@ const mergeDataSegments = (a, b) => {
|
|
|
1895
2372
|
*/
|
|
1896
2373
|
const packData = (ast) => {
|
|
1897
2374
|
if (!Array.isArray(ast) || ast[0] !== 'module') return ast
|
|
1898
|
-
let result = clone(ast)
|
|
1899
2375
|
|
|
1900
2376
|
// Trim trailing zeros
|
|
1901
|
-
for (const node of
|
|
2377
|
+
for (const node of ast) {
|
|
1902
2378
|
if (!Array.isArray(node) || node[0] !== 'data') continue
|
|
1903
2379
|
let contentStart = 1
|
|
1904
2380
|
if (typeof node[1] === 'string' && node[1][0] === '$') contentStart = 2
|
|
@@ -1917,8 +2393,8 @@ const packData = (ast) => {
|
|
|
1917
2393
|
|
|
1918
2394
|
// Merge adjacent active segments with same memory and consecutive offsets
|
|
1919
2395
|
const dataNodes = []
|
|
1920
|
-
for (let i = 0; i <
|
|
1921
|
-
const node =
|
|
2396
|
+
for (let i = 0; i < ast.length; i++) {
|
|
2397
|
+
const node = ast[i]
|
|
1922
2398
|
if (Array.isArray(node) && node[0] === 'data') {
|
|
1923
2399
|
const info = getDataOffset(node)
|
|
1924
2400
|
if (info) {
|
|
@@ -1947,10 +2423,10 @@ const packData = (ast) => {
|
|
|
1947
2423
|
}
|
|
1948
2424
|
|
|
1949
2425
|
if (toRemove.size > 0) {
|
|
1950
|
-
|
|
2426
|
+
ast = ast.filter((_, i) => !toRemove.has(i))
|
|
1951
2427
|
}
|
|
1952
2428
|
|
|
1953
|
-
return
|
|
2429
|
+
return ast
|
|
1954
2430
|
}
|
|
1955
2431
|
|
|
1956
2432
|
// ==================== IMPORT FIELD MINIFICATION ====================
|
|
@@ -1980,11 +2456,10 @@ const makeShortener = () => {
|
|
|
1980
2456
|
*/
|
|
1981
2457
|
const minifyImports = (ast) => {
|
|
1982
2458
|
if (!Array.isArray(ast) || ast[0] !== 'module') return ast
|
|
1983
|
-
const result = clone(ast)
|
|
1984
2459
|
const shortMod = makeShortener()
|
|
1985
2460
|
const shortField = makeShortener()
|
|
1986
2461
|
|
|
1987
|
-
for (const node of
|
|
2462
|
+
for (const node of ast) {
|
|
1988
2463
|
if (!Array.isArray(node) || node[0] !== 'import') continue
|
|
1989
2464
|
if (typeof node[1] === 'string' && node[1][0] === '"') {
|
|
1990
2465
|
node[1] = '"' + shortMod(node[1].slice(1, -1)) + '"'
|
|
@@ -1994,7 +2469,7 @@ const minifyImports = (ast) => {
|
|
|
1994
2469
|
}
|
|
1995
2470
|
}
|
|
1996
2471
|
|
|
1997
|
-
return
|
|
2472
|
+
return ast
|
|
1998
2473
|
}
|
|
1999
2474
|
|
|
2000
2475
|
// ==================== REORDER FUNCTIONS ====================
|
|
@@ -2033,10 +2508,9 @@ const reorder = (ast) => {
|
|
|
2033
2508
|
// Sorting changes the function index space. Skip if any reference is numeric,
|
|
2034
2509
|
// since we'd silently retarget unnamed callers/start/elem entries.
|
|
2035
2510
|
if (!reorderSafe(ast)) return ast
|
|
2036
|
-
const result = clone(ast)
|
|
2037
2511
|
|
|
2038
2512
|
const callCounts = new Map()
|
|
2039
|
-
walk(
|
|
2513
|
+
walk(ast, (n) => {
|
|
2040
2514
|
if (!Array.isArray(n)) return
|
|
2041
2515
|
if (n[0] === 'call' || n[0] === 'return_call') {
|
|
2042
2516
|
callCounts.set(n[1], (callCounts.get(n[1]) || 0) + 1)
|
|
@@ -2045,7 +2519,7 @@ const reorder = (ast) => {
|
|
|
2045
2519
|
|
|
2046
2520
|
// Imports must precede defined funcs (compile.js assigns indices in AST order).
|
|
2047
2521
|
const imports = [], funcs = [], others = []
|
|
2048
|
-
for (const node of
|
|
2522
|
+
for (const node of ast.slice(1)) {
|
|
2049
2523
|
if (!Array.isArray(node)) { others.push(node); continue }
|
|
2050
2524
|
if (node[0] === 'import') imports.push(node)
|
|
2051
2525
|
else if (node[0] === 'func') funcs.push(node)
|
|
@@ -2072,14 +2546,24 @@ const reorder = (ast) => {
|
|
|
2072
2546
|
*/
|
|
2073
2547
|
export default function optimize(ast, opts = true) {
|
|
2074
2548
|
if (typeof ast === 'string') ast = parse(ast)
|
|
2075
|
-
|
|
2549
|
+
const strictGuard = opts === true // default: zero tolerance for bloat
|
|
2076
2550
|
opts = normalize(opts)
|
|
2077
2551
|
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2552
|
+
const log = opts.log ? (msg, delta) => opts.log(msg, delta) : () => {}
|
|
2553
|
+
const verbose = opts.verbose || opts.log
|
|
2554
|
+
|
|
2555
|
+
ast = clone(ast)
|
|
2556
|
+
let beforeRound = null
|
|
2557
|
+
|
|
2558
|
+
// Size guard works on encoded bytes, not AST node count: passes like
|
|
2559
|
+
// `globals` / `inlineOnce` are node-count-neutral yet move real bytes
|
|
2560
|
+
// (a `global.get` ↔ a fat `f64.const`; a `call` ↔ an inlined body), so a
|
|
2561
|
+
// node-count guard can't tell when a round bloated — or shrank. `binarySize`
|
|
2562
|
+
// also returns Infinity if a round produced invalid wat, so a broken round
|
|
2563
|
+
// reverts instead of escaping.
|
|
2564
|
+
for (let round = 0; round < 3; round++) {
|
|
2565
|
+
beforeRound = clone(ast)
|
|
2566
|
+
const sizeBefore = binarySize(ast)
|
|
2083
2567
|
|
|
2084
2568
|
if (opts.stripmut) ast = stripmut(ast)
|
|
2085
2569
|
if (opts.globals) ast = globals(ast)
|
|
@@ -2089,6 +2573,7 @@ export default function optimize(ast, opts = true) {
|
|
|
2089
2573
|
if (opts.strength) ast = strength(ast)
|
|
2090
2574
|
if (opts.branch) ast = branch(ast)
|
|
2091
2575
|
if (opts.propagate) ast = propagate(ast)
|
|
2576
|
+
if (opts.inlineOnce) ast = inlineOnce(ast)
|
|
2092
2577
|
if (opts.inline) ast = inline(ast)
|
|
2093
2578
|
if (opts.offset) ast = offset(ast)
|
|
2094
2579
|
if (opts.unbranch) ast = unbranch(ast)
|
|
@@ -2096,6 +2581,8 @@ export default function optimize(ast, opts = true) {
|
|
|
2096
2581
|
if (opts.foldarms) ast = foldarms(ast)
|
|
2097
2582
|
if (opts.deadcode) ast = deadcode(ast)
|
|
2098
2583
|
if (opts.vacuum) ast = vacuum(ast)
|
|
2584
|
+
if (opts.mergeBlocks) ast = mergeBlocks(ast)
|
|
2585
|
+
if (opts.coalesce) ast = coalesceLocals(ast)
|
|
2099
2586
|
if (opts.locals) ast = localReuse(ast)
|
|
2100
2587
|
if (opts.dedupe) ast = dedupe(ast)
|
|
2101
2588
|
if (opts.dedupTypes) ast = dedupTypes(ast)
|
|
@@ -2103,10 +2590,35 @@ export default function optimize(ast, opts = true) {
|
|
|
2103
2590
|
if (opts.reorder) ast = reorder(ast)
|
|
2104
2591
|
if (opts.treeshake) ast = treeshake(ast)
|
|
2105
2592
|
if (opts.minifyImports) ast = minifyImports(ast)
|
|
2106
|
-
|
|
2593
|
+
// Second propagate sweep: `inlineOnce`/`inline` (above) leave fresh
|
|
2594
|
+
// `(local.set $p arg) … (local.get $p)` wrappers around each inlined call;
|
|
2595
|
+
// re-running propagation collapses them within this same round, so the size
|
|
2596
|
+
// guard scores the cleaned result instead of waiting a round (which it may
|
|
2597
|
+
// never get if `equal()` declares a fixpoint first).
|
|
2598
|
+
if (opts.propagate && (opts.inlineOnce || opts.inline)) ast = propagate(ast)
|
|
2599
|
+
|
|
2600
|
+
const sizeAfter = binarySize(ast)
|
|
2601
|
+
const delta = sizeAfter - sizeBefore
|
|
2602
|
+
|
|
2603
|
+
if (verbose || delta !== 0) {
|
|
2604
|
+
log(` round ${round + 1}: ${delta > 0 ? '+' : ''}${delta} bytes`, delta)
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
// Size guard: default optimize must never inflate. Explicit passes get a
|
|
2608
|
+
// little leniency (a round may grow a few bytes setting up a bigger win).
|
|
2609
|
+
const tolerance = strictGuard ? 0 : 16
|
|
2610
|
+
if (delta > tolerance) {
|
|
2611
|
+
if (verbose) log(` ⚠ round ${round + 1} inflated by ${delta} bytes, reverting`, delta)
|
|
2612
|
+
ast = beforeRound
|
|
2613
|
+
break
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2616
|
+
if (equal(beforeRound, ast)) break
|
|
2107
2617
|
}
|
|
2108
2618
|
|
|
2109
2619
|
return ast
|
|
2110
2620
|
}
|
|
2111
2621
|
|
|
2112
|
-
|
|
2622
|
+
/** Count AST nodes (fast size heuristic). */
|
|
2623
|
+
export { count as size, count, binarySize }
|
|
2624
|
+
export { optimize, treeshake, fold, deadcode, localReuse, identity, strength, branch, propagate, inline, inlineOnce, normalize, OPTS, vacuum, peephole, globals, offset, unbranch, stripmut, brif, foldarms, dedupe, reorder, dedupTypes, packData, minifyImports, mergeBlocks, coalesceLocals }
|