UNPKG

watr

Version:

Light & fast WAT compiler – WebAssembly Text to binary, parse, print, transform

1,318 lines (1,199 loc) 116 kB
/** * AST optimizations for WebAssembly modules. * Reduces code size and improves runtime performance. * * @module watr/optimize */ import parse from './parse.js' import compile from './compile.js' import { i32, i64 } from './encode.js' import { walk, walkPost, clone } from './util.js' import { resultType } from './const.js' /** * Recursively count AST nodes — fast size heuristic without compiling. * @param {any} node * @returns {number} */ const count = (node) => { if (!Array.isArray(node)) return 1 let n = 1 for (let i = 0; i < node.length; i++) n += count(node[i]) return n } /** * Compile AST and measure binary size in bytes. * @param {Array} ast * @returns {number} */ const binarySize = (ast) => { try { return compile(ast).length } catch { return Infinity } } /** * Fast structural equality of two AST nodes. * Stops at first difference. Handles BigInt without stringification. */ const equal = (a, b) => { if (a === b) return true if (typeof a !== typeof b) return false if (typeof a === 'bigint') return a === b if (!Array.isArray(a) || !Array.isArray(b)) return false if (a.length !== b.length) return false for (let i = 0; i < a.length; i++) if (!equal(a[i], b[i])) return false return true } /** * Locate the parts of an `(if ...)` node: * condIdx → index of the condition expression * cond → the condition expression itself * thenBranch / elseBranch → the (then ...) / (else ...) sub-arrays, or null * The condition sits after any leading `param`/`result` annotations and before * the `then`/`else` arms. */ const parseIf = (node) => { let condIdx = 1 while (condIdx < node.length) { const c = node[condIdx] if (Array.isArray(c) && (c[0] === 'then' || c[0] === 'else' || c[0] === 'result' || c[0] === 'param')) { condIdx++ continue } break } let thenBranch = null, elseBranch = null for (let i = condIdx + 1; i < node.length; i++) { const c = node[i] if (!Array.isArray(c)) continue if (c[0] === 'then') thenBranch = c else if (c[0] === 'else') elseBranch = c } return { condIdx, cond: node[condIdx], thenBranch, elseBranch } } // ==================== TREESHAKE ==================== /** * Remove unused functions, globals, types, tables. * Keeps exports and their transitive dependencies. * @param {Array} ast * @returns {Array} */ const treeshake = (ast) => { if (!Array.isArray(ast) || ast[0] !== 'module') return ast // Index spaces. Each entry is shared between its $name key and its numeric idx // key, so name/index lookups hit the same record. nodeMap covers reverse lookup // (used during the filtering pass for unnamed definitions). const funcs = new Map(), globals = new Map(), types = new Map() const tables = new Map(), memories = new Map() const nodeMap = new Map() // node → entry const register = (map, node, idx, isImport = false) => { const named = typeof node[1] === 'string' && node[1][0] === '$' const name = named ? node[1] : idx const inlineExported = !isImport && node.some(s => Array.isArray(s) && s[0] === 'export') const entry = { node, idx, used: inlineExported, isImport } map.set(name, entry) if (named) map.set(idx, entry) nodeMap.set(node, entry) return entry } let funcIdx = 0, globalIdx = 0, typeIdx = 0, tableIdx = 0, memIdx = 0 const elems = [], data = [], exports = [], starts = [] for (const node of ast.slice(1)) { if (!Array.isArray(node)) continue const kind = node[0] if (kind === 'type') register(types, node, typeIdx++) else if (kind === 'func') register(funcs, node, funcIdx++) else if (kind === 'global') register(globals, node, globalIdx++) else if (kind === 'table') register(tables, node, tableIdx++) else if (kind === 'memory') register(memories, node, memIdx++) else if (kind === 'import') { // Each import sub-item occupies its own slot in the relevant index space. for (const sub of node) { if (!Array.isArray(sub)) continue if (sub[0] === 'func') register(funcs, sub, funcIdx++, true) else if (sub[0] === 'global') register(globals, sub, globalIdx++, true) else if (sub[0] === 'table') register(tables, sub, tableIdx++, true) else if (sub[0] === 'memory') register(memories, sub, memIdx++, true) } } else if (kind === 'export') exports.push(node) else if (kind === 'start') starts.push(node) else if (kind === 'elem') elems.push(node) else if (kind === 'data') data.push(node) } // Worklist: function entries whose body still needs to be scanned for refs. const work = [] const enqueue = (entry) => { if (entry && !entry.scanned) work.push(entry) } const markFunc = (ref) => { const e = funcs.get(ref); if (!e) return if (!e.used) e.used = true enqueue(e) } const markGlobal = (ref) => { const e = globals.get(ref); if (e) e.used = true } const markTable = (ref) => { const e = tables.get(ref); if (e) e.used = true } const markMemory = (ref) => { if (typeof ref === 'string' && ref[0] !== '$') ref = +ref; const e = memories.get(ref); if (e) e.used = true } const markType = (ref) => { const e = types.get(ref); if (e) e.used = true } // Roots: explicit exports, start funcs, elem-referenced funcs, inline-exported items. for (const exp of exports) { for (const sub of exp) { if (!Array.isArray(sub)) continue const [kind, ref] = sub if (kind === 'func') markFunc(ref) else if (kind === 'global') markGlobal(ref) else if (kind === 'table') markTable(ref) else if (kind === 'memory') markMemory(ref) } } for (const start of starts) { let ref = start[1] if (typeof ref === 'string' && ref[0] !== '$') ref = +ref markFunc(ref) } for (const elem of elems) { walk(elem, n => { if (Array.isArray(n) && n[0] === 'ref.func') markFunc(n[1]) else if (typeof n === 'string' && n[0] === '$') markFunc(n) }) } for (const d of data) { const first = d[1] if (Array.isArray(first) && first[0] === 'memory') markMemory(first[1]) else if (typeof first === 'string' && first[0] === '$') markMemory(first) else if (Array.isArray(first)) markMemory(0) } for (const m of [funcs, globals, tables, memories]) for (const e of m.values()) if (e.used) enqueue(e) // If nothing anchors the module (no exports, start, elem, or inline exports), // assume the module is consumed elsewhere and keep everything. const hasAnchor = exports.length > 0 || starts.length > 0 || elems.length > 0 || work.length > 0 if (!hasAnchor) { for (const m of [funcs, globals, tables, memories]) for (const e of m.values()) e.used = true return ast } // Drain worklist: each function body gets walked exactly once. while (work.length) { const entry = work.pop() if (entry.scanned) continue entry.scanned = true if (entry.isImport) continue walk(entry.node, n => { if (!Array.isArray(n)) { if (typeof n === 'string' && n[0] === '$') markFunc(n) return } const [op, ref] = n if (op === 'call' || op === 'return_call' || op === 'ref.func') markFunc(ref) else if (op === 'global.get' || op === 'global.set') markGlobal(ref) else if (op === 'type') markType(ref) else if (op === 'call_indirect' || op === 'return_call_indirect') { for (const sub of n) if (typeof sub === 'string' && sub[0] === '$') markTable(sub) } if (typeof op === 'string' && (op.startsWith('memory.') || op.includes('.load') || op.includes('.store'))) { markMemory(0) } }) } // Filter: keep used definitions. nodeMap handles unnamed entries directly. const result = ['module'] for (const node of ast.slice(1)) { if (!Array.isArray(node)) { result.push(node); continue } const kind = node[0] if (kind === 'func' || kind === 'global' || kind === 'type') { if (nodeMap.get(node)?.used) result.push(node) } else if (kind === 'import') { // Keep import only if any of its sub-items is used. let used = false for (const sub of node) { if (!Array.isArray(sub)) continue const e = nodeMap.get(sub) if (e?.used) { used = true; break } } if (used) result.push(node) } else { result.push(node) } } return result } // ==================== CONSTANT FOLDING ==================== /** IEEE 754 roundTiesToEven (bankers' rounding) */ const roundEven = (x) => x - Math.floor(x) !== 0.5 ? Math.round(x) : 2 * Math.round(x / 2) // Bit-exact reinterpret helpers (preserve NaN payloads). const _rb8 = new ArrayBuffer(8) const _rf64 = new Float64Array(_rb8) const _ri64 = new BigInt64Array(_rb8) const _rb4 = new ArrayBuffer(4) const _rf32 = new Float32Array(_rb4) const _ri32 = new Int32Array(_rb4) const i64FromF64 = (x) => { _rf64[0] = x; return _ri64[0] } const f64FromI64 = (x) => { _ri64[0] = BigInt.asIntN(64, x); return _rf64[0] } const i32FromF32 = (x) => { _rf32[0] = x; return _ri32[0] } const f32FromI32 = (x) => { _ri32[0] = x | 0; return _rf32[0] } /** Build i32 comparison folder: returns 1/0 */ const i32c = (fn) => (a, b) => fn(a, b) ? 1 : 0 /** Build unsigned i32 comparison folder */ const u32c = (fn) => (a, b) => fn(a >>> 0, b >>> 0) ? 1 : 0 /** Build i64 comparison folder */ const i64c = (fn) => (a, b) => fn(a, b) ? 1 : 0 /** Build unsigned i64 comparison folder */ const u64c = (fn) => (a, b) => fn(BigInt.asUintN(64, a), BigInt.asUintN(64, b)) ? 1 : 0 /** * Constant folders, keyed by op. Each entry is the fold function; the result * value-type is derived once via `resultType` (see `fold`). */ const FOLDABLE = { // i32 arithmetic 'i32.add': (a, b) => (a + b) | 0, 'i32.sub': (a, b) => (a - b) | 0, 'i32.mul': (a, b) => Math.imul(a, b), 'i32.div_s': (a, b) => b !== 0 ? (a / b) | 0 : null, 'i32.div_u': (a, b) => b !== 0 ? ((a >>> 0) / (b >>> 0)) | 0 : null, 'i32.rem_s': (a, b) => b !== 0 ? (a % b) | 0 : null, 'i32.rem_u': (a, b) => b !== 0 ? ((a >>> 0) % (b >>> 0)) | 0 : null, 'i32.and': (a, b) => a & b, 'i32.or': (a, b) => a | b, 'i32.xor': (a, b) => a ^ b, 'i32.shl': (a, b) => a << (b & 31), 'i32.shr_s': (a, b) => a >> (b & 31), 'i32.shr_u': (a, b) => a >>> (b & 31), 'i32.rotl': (a, b) => { b &= 31; return ((a << b) | (a >>> (32 - b))) | 0 }, 'i32.rotr': (a, b) => { b &= 31; return ((a >>> b) | (a << (32 - b))) | 0 }, 'i32.eq': i32c((a, b) => a === b), 'i32.ne': i32c((a, b) => a !== b), 'i32.lt_s': i32c((a, b) => a < b), 'i32.lt_u': u32c((a, b) => a < b), 'i32.gt_s': i32c((a, b) => a > b), 'i32.gt_u': u32c((a, b) => a > b), 'i32.le_s': i32c((a, b) => a <= b), 'i32.le_u': u32c((a, b) => a <= b), 'i32.ge_s': i32c((a, b) => a >= b), 'i32.ge_u': u32c((a, b) => a >= b), 'i32.eqz': (a) => a === 0 ? 1 : 0, 'i32.clz': (a) => Math.clz32(a), 'i32.ctz': (a) => a === 0 ? 32 : 31 - Math.clz32(a & -a), 'i32.popcnt': (a) => { let c = 0; while (a) { c += a & 1; a >>>= 1 } return c }, 'i32.wrap_i64': (a) => Number(BigInt.asIntN(32, a)), 'i32.extend8_s': (a) => (a << 24) >> 24, 'i32.extend16_s': (a) => (a << 16) >> 16, // i64 (using BigInt) 'i64.add': (a, b) => BigInt.asIntN(64, a + b), 'i64.sub': (a, b) => BigInt.asIntN(64, a - b), 'i64.mul': (a, b) => BigInt.asIntN(64, a * b), 'i64.div_s': (a, b) => b !== 0n ? BigInt.asIntN(64, a / b) : null, 'i64.div_u': (a, b) => b !== 0n ? BigInt.asUintN(64, BigInt.asUintN(64, a) / BigInt.asUintN(64, b)) : null, 'i64.rem_s': (a, b) => b !== 0n ? BigInt.asIntN(64, a % b) : null, 'i64.rem_u': (a, b) => b !== 0n ? BigInt.asUintN(64, BigInt.asUintN(64, a) % BigInt.asUintN(64, b)) : null, 'i64.and': (a, b) => BigInt.asIntN(64, a & b), 'i64.or': (a, b) => BigInt.asIntN(64, a | b), 'i64.xor': (a, b) => BigInt.asIntN(64, a ^ b), 'i64.shl': (a, b) => BigInt.asIntN(64, a << (b & 63n)), 'i64.shr_s': (a, b) => BigInt.asIntN(64, a >> (b & 63n)), 'i64.shr_u': (a, b) => BigInt.asUintN(64, BigInt.asUintN(64, a) >> (b & 63n)), 'i64.eq': i64c((a, b) => a === b), 'i64.ne': i64c((a, b) => a !== b), 'i64.lt_s': i64c((a, b) => a < b), 'i64.lt_u': u64c((a, b) => a < b), 'i64.gt_s': i64c((a, b) => a > b), 'i64.gt_u': u64c((a, b) => a > b), 'i64.le_s': i64c((a, b) => a <= b), 'i64.le_u': u64c((a, b) => a <= b), 'i64.ge_s': i64c((a, b) => a >= b), 'i64.ge_u': u64c((a, b) => a >= b), 'i64.eqz': (a) => a === 0n ? 1 : 0, 'i64.extend_i32_s': (a) => BigInt(a), 'i64.extend_i32_u': (a) => BigInt(a >>> 0), 'i64.extend8_s': (a) => BigInt.asIntN(64, BigInt.asIntN(8, a)), 'i64.extend16_s': (a) => BigInt.asIntN(64, BigInt.asIntN(16, a)), 'i64.extend32_s': (a) => BigInt.asIntN(64, BigInt.asIntN(32, a)), // f32/f64 (NaN/precision-aware via Math.fround) 'f32.add': (a, b) => Math.fround(a + b), 'f32.sub': (a, b) => Math.fround(a - b), 'f32.mul': (a, b) => Math.fround(a * b), 'f32.div': (a, b) => Math.fround(a / b), 'f32.neg': (a) => Math.fround(-a), 'f32.abs': (a) => Math.fround(Math.abs(a)), 'f32.sqrt': (a) => Math.fround(Math.sqrt(a)), 'f32.ceil': (a) => Math.fround(Math.ceil(a)), 'f32.floor': (a) => Math.fround(Math.floor(a)), 'f32.trunc': (a) => Math.fround(Math.trunc(a)), 'f32.nearest': (a) => Math.fround(roundEven(a)), 'f64.add': (a, b) => a + b, 'f64.sub': (a, b) => a - b, 'f64.mul': (a, b) => a * b, 'f64.div': (a, b) => a / b, 'f64.neg': (a) => -a, 'f64.abs': Math.abs, 'f64.sqrt': Math.sqrt, 'f64.ceil': Math.ceil, 'f64.floor': Math.floor, 'f64.trunc': Math.trunc, 'f64.nearest': roundEven, // Bit-exact reinterprets (preserve NaN payloads) 'i32.reinterpret_f32': i32FromF32, 'f32.reinterpret_i32': f32FromI32, 'i64.reinterpret_f64': i64FromF64, 'f64.reinterpret_i64': f64FromI64, // Numeric conversions (value-preserving where representable) 'f32.convert_i32_s': (a) => Math.fround(a | 0), 'f32.convert_i32_u': (a) => Math.fround(a >>> 0), 'f32.convert_i64_s': (a) => Math.fround(Number(BigInt.asIntN(64, a))), 'f32.convert_i64_u': (a) => Math.fround(Number(BigInt.asUintN(64, a))), 'f64.convert_i32_s': (a) => (a | 0), 'f64.convert_i32_u': (a) => (a >>> 0), 'f64.convert_i64_s': (a) => Number(BigInt.asIntN(64, a)), 'f64.convert_i64_u': (a) => Number(BigInt.asUintN(64, a)), 'f32.demote_f64': (a) => Math.fround(a), 'f64.promote_f32': (a) => Math.fround(a), } /** * Parse a WAT `nan` / `nan:canonical` / `nan:arithmetic` / `nan:0xPAYLOAD` * literal to a JS number with the exact bit pattern. `Number('nan:0x…')` * collapses to canonical NaN, destroying the payload that NaN-boxing schemes * (jz, etc.) encode their pointer/sentinel bits into. Returns null if `s` is * not a NaN literal so callers can fall through to plain Number parsing. */ const _parseNanF64 = (s, i = s?.indexOf?.('nan')) => { if (i < 0 || i == null) return null let tail = s.slice(i + 4).replaceAll('_', ''), bits = (s[i + 3] === ':' && tail !== 'canonical' && tail !== 'arithmetic' ? BigInt(tail) : 0x8000000000000n) _ri64[0] = BigInt.asIntN(64, bits | 0x7ff0000000000000n | (s[0] === '-' ? 1n << 63n : 0n)) return _rf64[0] } const _parseNanF32 = (s, i = s?.indexOf?.('nan')) => { if (i < 0 || i == null) return null let tail = s.slice(i + 4).replaceAll('_', ''), bits = (s[i + 3] === ':' && tail !== 'canonical' && tail !== 'arithmetic' ? parseInt(tail) : 0x400000) _ri32[0] = (bits | 0x7f800000 | (s[0] === '-' ? 0x80000000 : 0)) | 0 return _rf32[0] } /** * Extract constant value from node. * @param {any} node * @returns {{type: string, value: number|bigint}|null} */ const getConst = (node) => { if (!Array.isArray(node) || node.length !== 2) return null const [op, val] = node if (op === 'i32.const') return { type: 'i32', value: (typeof val === 'string' ? i32.parse(val) : val) | 0 } if (op === 'i64.const') return { type: 'i64', value: typeof val === 'string' ? i64.parse(val) : BigInt(val) } if (op === 'f32.const') { const n = _parseNanF32(val) return { type: 'f32', value: n !== null ? n : Math.fround(Number(val)) } } if (op === 'f64.const') { const n = _parseNanF64(val) return { type: 'f64', value: n !== null ? n : Number(val) } } return null } /** * Create const node from value. * @param {string} type * @param {number|bigint} value * @returns {Array} */ const makeConst = (type, value) => { if (type === 'i32') return ['i32.const', value | 0] if (type === 'i64') return ['i64.const', value] if (type === 'f32') return ['f32.const', Math.fround(value)] if (type === 'f64') return ['f64.const', value] return null } /** * Fold constant expressions. * @param {Array} ast * @returns {Array} */ const fold = (ast) => { return walkPost(ast, (node) => { if (!Array.isArray(node)) return const fn = FOLDABLE[node[0]] if (!fn) return // Unary if (fn.length === 1 && node.length === 2) { const a = getConst(node[1]) if (!a) return const r = fn(a.value) if (r === null) return return makeConst(resultType(node[0]), r) } // Binary if (fn.length === 2 && node.length === 3) { const a = getConst(node[1]), b = getConst(node[2]) if (!a || !b) return const r = fn(a.value, b.value) if (r === null) return return makeConst(resultType(node[0]), r) } }) } // ==================== IDENTITY REMOVAL ==================== /** * Create identity checker for commutative binary ops: * neutral op x → x and x op neutral → x */ const commutativeIdentity = (neutral) => (a, b) => { const ca = getConst(a), cb = getConst(b) if (ca?.value === neutral) return b if (cb?.value === neutral) return a return null } /** * Create identity checker for right-neutral binary ops: * x op neutral → x */ const rightIdentity = (neutral) => (a, b) => getConst(b)?.value === neutral ? a : null /** Identity operations that can be simplified */ const IDENTITIES = { // x + 0 → x, 0 + x → x 'i32.add': commutativeIdentity(0), 'i64.add': commutativeIdentity(0n), // x - 0 → x 'i32.sub': rightIdentity(0), 'i64.sub': rightIdentity(0n), // x * 1 → x, 1 * x → x 'i32.mul': commutativeIdentity(1), 'i64.mul': commutativeIdentity(1n), // x / 1 → x 'i32.div_s': rightIdentity(1), 'i32.div_u': rightIdentity(1), 'i64.div_s': rightIdentity(1n), 'i64.div_u': rightIdentity(1n), // x & -1 → x, -1 & x → x (all bits set) 'i32.and': commutativeIdentity(-1), 'i64.and': commutativeIdentity(-1n), // x | 0 → x, 0 | x → x 'i32.or': commutativeIdentity(0), 'i64.or': commutativeIdentity(0n), // x ^ 0 → x, 0 ^ x → x 'i32.xor': commutativeIdentity(0), 'i64.xor': commutativeIdentity(0n), // x << 0 → x, x >> 0 → x 'i32.shl': rightIdentity(0), 'i32.shr_s': rightIdentity(0), 'i32.shr_u': rightIdentity(0), 'i64.shl': rightIdentity(0n), 'i64.shr_s': rightIdentity(0n), 'i64.shr_u': rightIdentity(0n), // f + 0 → x (careful with -0.0, skip for floats) // f * 1 → x (careful with NaN, skip for floats) } /** * Remove identity operations. * @param {Array} ast * @returns {Array} */ const identity = (ast) => { return walkPost(ast, (node) => { if (!Array.isArray(node) || node.length !== 3) return const fn = IDENTITIES[node[0]] if (!fn) return const result = fn(node[1], node[2]) if (result === null) return // no optimization, keep original return result }) } // ==================== STRENGTH REDUCTION ==================== /** * Strength reduction: replace expensive ops with cheaper equivalents. * @param {Array} ast * @returns {Array} */ const strength = (ast) => { return walkPost(ast, (node) => { if (!Array.isArray(node) || node.length !== 3) return const [op, a, b] = node // x * 2^n → x << n if (op === 'i32.mul') { const cb = getConst(b) if (cb && cb.value > 0 && (cb.value & (cb.value - 1)) === 0) { const shift = Math.log2(cb.value) if (Number.isInteger(shift)) return ['i32.shl', a, ['i32.const', shift]] } const ca = getConst(a) if (ca && ca.value > 0 && (ca.value & (ca.value - 1)) === 0) { const shift = Math.log2(ca.value) if (Number.isInteger(shift)) return ['i32.shl', b, ['i32.const', shift]] } } if (op === 'i64.mul') { const cb = getConst(b) if (cb && cb.value > 0n && (cb.value & (cb.value - 1n)) === 0n) { const shift = BigInt(cb.value.toString(2).length - 1) return ['i64.shl', a, ['i64.const', shift]] } const ca = getConst(a) if (ca && ca.value > 0n && (ca.value & (ca.value - 1n)) === 0n) { const shift = BigInt(ca.value.toString(2).length - 1) return ['i64.shl', b, ['i64.const', shift]] } } // x / 2^n → x >> n (unsigned only, signed division is more complex) if (op === 'i32.div_u') { const cb = getConst(b) if (cb && cb.value > 0 && (cb.value & (cb.value - 1)) === 0) { const shift = Math.log2(cb.value) if (Number.isInteger(shift)) return ['i32.shr_u', a, ['i32.const', shift]] } } if (op === 'i64.div_u') { const cb = getConst(b) if (cb && cb.value > 0n && (cb.value & (cb.value - 1n)) === 0n) { const shift = BigInt(cb.value.toString(2).length - 1) return ['i64.shr_u', a, ['i64.const', shift]] } } // x % 2^n → x & (2^n - 1) (unsigned only) if (op === 'i32.rem_u') { const cb = getConst(b) if (cb && cb.value > 0 && (cb.value & (cb.value - 1)) === 0) { return ['i32.and', a, ['i32.const', cb.value - 1]] } } if (op === 'i64.rem_u') { const cb = getConst(b) if (cb && cb.value > 0n && (cb.value & (cb.value - 1n)) === 0n) { return ['i64.and', a, ['i64.const', cb.value - 1n]] } } }) } // ==================== BRANCH SIMPLIFICATION ==================== /** * Simplify branches with constant conditions. * @param {Array} ast * @returns {Array} */ const branch = (ast) => { return walkPost(ast, (node) => { if (!Array.isArray(node)) return const op = node[0] // (if (i32.const 0) then else) → else // (if (i32.const N) then else) → then (N != 0) if (op === 'if') { const { cond, thenBranch, elseBranch } = parseIf(node) const c = getConst(cond) if (!c) return const taken = c.value !== 0 && c.value !== 0n ? thenBranch : elseBranch if (taken && taken.length > 1) { const contents = taken.slice(1) return contents.length === 1 ? contents[0] : ['block', ...contents] } return ['nop'] } // (br_if $label (i32.const 0)) → nop // (br_if $label (i32.const N)) → br $label (N != 0) if (op === 'br_if' && node.length >= 3) { const cond = node[node.length - 1] const c = getConst(cond) if (!c) return if (c.value === 0 || c.value === 0n) return ['nop'] return ['br', node[1]] } // (select a b (i32.const 0)) → b // (select a b (i32.const N)) → a (N != 0) if (op === 'select' && node.length >= 4) { const cond = node[node.length - 1] const c = getConst(cond) if (!c) return if (c.value === 0 || c.value === 0n) return node[2] // b return node[1] // a } }) } // ==================== DEAD CODE ELIMINATION ==================== /** Control flow terminators */ const TERMINATORS = new Set(['unreachable', 'return', 'br', 'br_table']) /** * Remove dead code after control flow terminators. * @param {Array} ast * @returns {Array} */ const deadcode = (ast) => { // Process each function body walk(ast, (node) => { if (!Array.isArray(node)) return const kind = node[0] // Process blocks: func, block, loop, if branches if (kind === 'func' || kind === 'block' || kind === 'loop') { eliminateDeadInBlock(node) } if (kind === 'if') { // Process then/else branches for (let i = 1; i < node.length; i++) { if (Array.isArray(node[i]) && (node[i][0] === 'then' || node[i][0] === 'else')) { eliminateDeadInBlock(node[i]) } } } }) return ast } /** * Remove instructions after terminators within a block. * @param {Array} block */ const eliminateDeadInBlock = (block) => { let terminated = false let firstTerminator = -1 for (let i = 1; i < block.length; i++) { const node = block[i] // Skip type annotations if (Array.isArray(node)) { const op = node[0] if (op === 'param' || op === 'result' || op === 'local' || op === 'type' || op === 'export') continue if (terminated) { if (firstTerminator === -1) firstTerminator = i } if (TERMINATORS.has(op)) { terminated = true firstTerminator = i + 1 } } else if (typeof node === 'string') { // String instructions like 'unreachable', 'return', 'drop', 'nop' if (terminated) { if (firstTerminator === -1) firstTerminator = i } if (TERMINATORS.has(node)) { terminated = true firstTerminator = i + 1 } } } // Remove dead code if (firstTerminator > 0 && firstTerminator < block.length) { block.splice(firstTerminator) } } // ==================== LOCAL REUSE ==================== /** * Reuse locals of the same type to reduce total local count. * Basic version: deduplicate unused locals. * @param {Array} ast * @returns {Array} */ const localReuse = (ast) => { walk(ast, (node) => { if (!Array.isArray(node) || node[0] !== 'func') return // Collect local declarations and their types const localDecls = [] const localTypes = new Map() // $name → type const usedLocals = new Set() // Find all local declarations and usages for (let i = 1; i < node.length; i++) { const sub = node[i] if (!Array.isArray(sub)) continue if (sub[0] === 'local') { localDecls.push({ node: sub, idx: i }) // (local $name type) or (local type) if (typeof sub[1] === 'string' && sub[1][0] === '$') { localTypes.set(sub[1], sub[2]) } } if (sub[0] === 'param') { // Params are also locals if (typeof sub[1] === 'string' && sub[1][0] === '$') { localTypes.set(sub[1], sub[2]) usedLocals.add(sub[1]) // params always used } } } // Find which locals are actually used walk(node, (n) => { if (!Array.isArray(n)) return const op = n[0] if (op === 'local.get' || op === 'local.set' || op === 'local.tee') { const ref = n[1] if (typeof ref === 'string') usedLocals.add(ref) } }) // Remove unused local declarations for (let i = localDecls.length - 1; i >= 0; i--) { const { idx, node: decl } = localDecls[i] const name = typeof decl[1] === 'string' && decl[1][0] === '$' ? decl[1] : null if (name && !usedLocals.has(name)) { node.splice(idx, 1) } } }) return ast } // ==================== PROPAGATION & LOCAL ELIMINATION ==================== /** Operators with side effects: calls, mutators, control flow, exceptions, drops. */ const IMPURE_OPS = new Set([ 'call', 'call_indirect', 'return_call', 'return_call_indirect', 'table.set', 'table.grow', 'table.fill', 'table.copy', 'table.init', 'struct.set', 'struct.new', 'array.set', 'array.new', 'array.new_fixed', 'array.new_data', 'array.new_elem', 'array.init_data', 'array.init_elem', 'ref.i31', 'global.set', 'local.set', 'local.tee', 'unreachable', 'return', 'br', 'br_if', 'br_table', 'br_on_null', 'br_on_non_null', 'br_on_cast', 'br_on_cast_fail', 'throw', 'rethrow', 'throw_ref', 'try_table', 'data.drop', 'elem.drop', ]) /** Substrings that flag an op as side-effecting (loads can trap, stores/atomics/memory ops mutate). */ const IMPURE_SUBSTRINGS = ['.store', 'memory.', '.atomic.'] /** * Pure means: no side effects, no traps we care about, no control flow. * Conservative — returns false for anything that might trap, mutate state, or branch. */ const isPure = (node) => { if (!Array.isArray(node)) return true const op = node[0] if (typeof op !== 'string') return false if (IMPURE_OPS.has(op)) return false for (const sub of IMPURE_SUBSTRINGS) if (op.includes(sub)) return false for (let i = 1; i < node.length; i++) if (Array.isArray(node[i]) && !isPure(node[i])) return false return true } /** Count all local.get/set/tee occurrences in one walk */ const countLocalUses = (node) => { const counts = new Map() const ensure = name => { if (!counts.has(name)) counts.set(name, { gets: 0, sets: 0, tees: 0 }); return counts.get(name) } walk(node, n => { if (!Array.isArray(n) || n.length < 2 || typeof n[1] !== 'string') return if (n[0] === 'local.get') ensure(n[1]).gets++ else if (n[0] === 'local.set') ensure(n[1]).sets++ else if (n[0] === 'local.tee') ensure(n[1]).tees++ }) return counts } /** A constant whose inlined form (opcode + immediate) is no wider than the ~2 B * `local.get` it would replace — so propagating it to every use is byte-neutral * at worst, and still drops the `local.set` + the `local` decl. f32/f64 consts * (5/9 B) lose on reuse, so only narrow i32/i64 literals qualify. */ const isTinyConst = (node) => { const c = getConst(node) if (!c) return false if (c.type === 'i32') { const v = c.value | 0; return v >= -64 && v <= 63 } if (c.type === 'i64') { const v = typeof c.value === 'bigint' ? c.value : BigInt(c.value); return v >= -64n && v <= 63n } return false } /** Can this tracked value be substituted for a local.get? * - single use of a pure value: always shrinks (drops the set, the lone get, the decl); * - any use of a tiny constant: byte-neutral at worst, still drops the set + decl. * Anything else (a wide constant reused many times, an impure expr) could inflate * or reorder side effects, so it's left alone. */ const canSubst = (k) => (k.pure && k.singleUse) || isTinyConst(k.val) /** Drop tracked values that read `$name`: rewriting `$name` makes them stale. */ const purgeRefs = (known, name) => { for (const [key, tracked] of known) { let refs = false walk(tracked.val, n => { if (Array.isArray(n) && (n[0] === 'local.get' || n[0] === 'local.tee') && n[1] === name) refs = true }) if (refs) known.delete(key) } } /** True if `node` recursively contains an op that may read linear memory. * Tracked values whose RHS reads memory go stale after any intervening * memory-mutating op (`*.store`, `memory.copy/fill/init`, atomic stores/rmw). */ const readsMemory = (node) => { if (!Array.isArray(node)) return false const op = node[0] if (typeof op === 'string') { if (op.includes('.load') || op === 'memory.copy' || op === 'memory.size') return true } for (let i = 1; i < node.length; i++) if (readsMemory(node[i])) return true return false } /** True if `node` references state a `call` could mutate. * Calls cannot touch caller locals (those live in the function frame), so * pure expressions over locals + constants survive any intervening call; only * memory loads, global reads, and table reads (or further calls) can be stale * after one. */ const readsCallableState = (node) => { if (!Array.isArray(node)) return false const op = node[0] if (typeof op === 'string') { if (op === 'global.get' || op === 'table.get' || op === 'table.size') return true if (op === 'call' || op === 'call_indirect' || op === 'return_call' || op === 'return_call_indirect') return true if (op.includes('.load') || op === 'memory.copy' || op === 'memory.size') return true } for (let i = 1; i < node.length; i++) if (readsCallableState(node[i])) return true return false } /** True if `node` recursively contains an op that may write linear memory. */ const writesMemory = (node) => { if (!Array.isArray(node)) return false const op = node[0] if (typeof op === 'string') { if (op.endsWith('.store') || op === 'memory.copy' || op === 'memory.fill' || op === 'memory.init') return true // Atomic RMW / store / notify all mutate memory; `.atomic.load` doesn't. if (op.includes('.atomic.') && !op.endsWith('.load')) return true } for (let i = 1; i < node.length; i++) if (writesMemory(node[i])) return true return false } /** Try substitute local.get nodes with known values. * When entering a nested scope (block/loop/if), drop tracking for any local * that's re-assigned inside the subtree — the outer-tracked value is stale * there. Without this, an outer `(local.set $x C)` would clobber an inner * `(local.set $x V) (local.get $x)` (the inner get rewritten to `C` instead * of `V`). Mostly latent until something — typically coalesceLocals — reuses * one slot for the outer and inner roles, after which it surfaces as silent * memory corruption. */ const substGets = (node, known) => { if (!Array.isArray(node)) return node const op = node[0] if (op === 'local.get' && node.length === 2) { const k = typeof node[1] === 'string' && known.get(node[1]) if (k && canSubst(k)) return clone(k.val) return node } let inner = known if (op === 'block' || op === 'loop' || op === 'if') { let cloned = null walk(node, n => { if (!Array.isArray(n)) return if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string' && known.has(n[1])) { if (!cloned) cloned = new Map(known) cloned.delete(n[1]) } }) if (cloned) inner = cloned } for (let i = 1; i < node.length; i++) { const r = substGets(node[i], inner) if (r !== node[i]) node[i] = r // WASM evaluates operands left-to-right. A `local.set`/`local.tee` in this // child updates the local before the next sibling reads it — drop tracked // entries that are now stale, else a pre-tee constant leaks into the next // sibling's `local.get` (visible after `coalesceLocals` aliases the tee'd // local with a sibling-read local, e.g. `alloc($x<<3, $x)` collapsing to // `alloc(BIG, SMALL)`). if (i + 1 < node.length && Array.isArray(node[i])) { walk(node[i], n => { if (!Array.isArray(n)) return if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') { if (inner === known) inner = new Map(known) inner.delete(n[1]) purgeRefs(inner, n[1]) } }) } } return node } /** * Forward propagation pass: track local.set values and substitute local.gets. * Returns true if any substitution was made. * @param {Array} funcNode * @param {Set<string>} params * @param {Map<string,{gets:number,sets:number,tees:number}>} useCounts */ const forwardPropagate = (funcNode, params, useCounts) => { let changed = false const getUseCount = name => useCounts.get(name) || { gets: 0, sets: 0, tees: 0 } const known = new Map() for (let i = 1; i < funcNode.length; i++) { const instr = funcNode[i] if (!Array.isArray(instr)) continue const op = instr[0] if (op === 'param' || op === 'result' || op === 'local' || op === 'type' || op === 'export') continue // Track local.set / local.tee values (tee writes too — its result also leaves // the value on the stack but the local is updated identically to set). if ((op === 'local.set' || op === 'local.tee') && instr.length === 3 && typeof instr[1] === 'string') { // substGets returns its argument unchanged unless the whole subtree // resolves to a substitution (bare `(local.get $x)` root case) — assign // back so the bare-RHS pattern actually propagates. const sr = substGets(instr[2], known) if (sr !== instr[2]) { instr[2] = sr; changed = true } // Nested `local.set`/`local.tee` inside the RHS already ran when the next // statement begins — drop tracked values that read those locals, else a // later `local.get` substitutes a stale expression (e.g. `$ptr`'s // `(local.get $ai0)` after a nested `(local.tee $ai0 …)` overwrites it). walk(instr[2], n => { if (!Array.isArray(n)) return if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') { known.delete(n[1]); purgeRefs(known, n[1]) } }) const uses = getUseCount(instr[1]) purgeRefs(known, instr[1]) // entries that read this local just went stale // Any tracked value whose RHS reads memory must be invalidated by the // RHS itself if it writes memory (rare — only via nested store/copy/etc., // which would also pass through the post-statement purge below). if (writesMemory(instr[2])) { for (const [key, tracked] of known) if (tracked.readsMem) known.delete(key) } known.set(instr[1], { val: instr[2], pure: isPure(instr[2]), readsMem: readsMemory(instr[2]), singleUse: uses.gets <= 1 && uses.sets <= 1 && uses.tees === 0 }) continue } // Invalidate at control-flow boundaries if (op === 'block' || op === 'loop' || op === 'if') known.clear() // Calls invalidate tracked values that read state a callee can mutate // (memory, globals, tables, nested calls). Pure expressions over locals // and constants survive — callees can't reach caller locals. if (op === 'call' || op === 'call_indirect' || op === 'return_call' || op === 'return_call_indirect') for (const [key, tracked] of known) if (readsCallableState(tracked.val)) known.delete(key) // Substitute: standalone local.get (walkPost can't replace root) if (op === 'local.get' && instr.length === 2 && typeof instr[1] === 'string') { const tracked = known.get(instr[1]) if (tracked && canSubst(tracked)) { const replacement = clone(tracked.val) instr.length = 0; instr.push(...(Array.isArray(replacement) ? replacement : [replacement])) changed = true; continue } } // Substitute nested local.gets (skip control-flow nodes — locals may be reassigned inside) if (op !== 'block' && op !== 'loop' && op !== 'if') { const prev = clone(instr) substGets(instr, known) if (!equal(prev, instr)) changed = true // Invalidate tracking for any names written by a nested set/tee — those // writes happened mid-expression and the substGets above used the // pre-write tracked value (correct), but later reads must see the new // (untracked) value, not the stale constant. walk(instr, n => { if (Array.isArray(n) && (n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') { known.delete(n[1]); purgeRefs(known, n[1]) } }) // Memory write in this statement (any nested store / memory.copy / etc.) // invalidates every tracked value whose RHS reads memory: inlining one // later would substitute a now-stale load. Without this, a swap idiom // (local.set $t (f64.load $p)) (f64.store $p (f64.load $q)) (f64.store $q (local.get $t)) // collapses to two stores that round-trip the same value: // (f64.store $p (f64.load $q)) (f64.store $q (f64.load $p)) ;; bug if (writesMemory(instr)) { for (const [key, tracked] of known) if (tracked.readsMem) known.delete(key) } } } return changed } /** * Remove adjacent (local.set $x expr) (local.get $x) pairs when $x has no other uses. * Returns true if any pair was removed. * @param {Array} funcNode * @param {Set<string>} params * @param {Map<string,{gets:number,sets:number,tees:number}>} useCounts */ const eliminateSetGetPairs = (funcNode, params, useCounts) => { let changed = false for (let i = 1; i < funcNode.length - 1; i++) { const setNode = funcNode[i] const getNode = funcNode[i + 1] if (!Array.isArray(setNode) || setNode[0] !== 'local.set' || setNode.length !== 3) continue if (!Array.isArray(getNode) || getNode[0] !== 'local.get' || getNode.length !== 2) continue const name = setNode[1] if (getNode[1] !== name || params.has(name)) continue const uses = useCounts.get(name) || { gets: 0, sets: 0, tees: 0 } // Must be exactly 1 set and 1 get (the pair), no tees if (uses.sets !== 1 || uses.gets !== 1 || uses.tees !== 0) continue // Replace the pair with just the expression const expr = clone(setNode[2]) funcNode.splice(i, 2, ...(Array.isArray(expr) ? [expr] : [expr])) changed = true i-- // adjust index because we removed 2 and inserted 1 } return changed } /** * Convert (local.set $x expr) (local.get $x) to (local.tee $x expr) * when $x has additional uses beyond this pair. * @param {Array} funcNode * @param {Set<string>} params * @param {Map<string,{gets:number,sets:number,tees:number}>} useCounts */ const createLocalTees = (funcNode, params, useCounts) => { let changed = false for (let i = 1; i < funcNode.length - 1; i++) { const setNode = funcNode[i] const getNode = funcNode[i + 1] if (!Array.isArray(setNode) || setNode[0] !== 'local.set' || setNode.length !== 3) continue if (!Array.isArray(getNode) || getNode[0] !== 'local.get' || getNode.length !== 2) continue const name = setNode[1] if (getNode[1] !== name || params.has(name)) continue const uses = useCounts.get(name) || { gets: 0, sets: 0, tees: 0 } // Only if there's more than just this set+get pair if (uses.sets + uses.gets + uses.tees <= 2) continue // Replace with local.tee (set+get combined) funcNode.splice(i, 2, ['local.tee', name, clone(setNode[2])]) changed = true } return changed } /** * Remove dead stores and unused local declarations in a reverse pass. * Returns true if anything was removed. * @param {Array} funcNode * @param {Set<string>} params * @param {Map<string,{gets:number,sets:number,tees:number}>} useCounts */ const eliminateDeadStores = (funcNode, params, useCounts) => { let changed = false const getPostUseCount = name => useCounts.get(name) || { gets: 0, sets: 0, tees: 0 } for (let i = funcNode.length - 1; i >= 1; i--) { const sub = funcNode[i] if (!Array.isArray(sub)) continue const name = typeof sub[1] === 'string' ? sub[1] : null if (!name || params.has(name)) continue const uses = getPostUseCount(name) // Dead store: set but never read. if (sub[0] === 'local.set' && uses.gets === 0 && uses.tees === 0) { // `(local.set $x VALUE)` — drop the store with its value, but only when // VALUE is pure (its side effects would otherwise still need to run). if (sub.length === 3) { if (isPure(sub[2])) { funcNode.splice(i, 1); changed = true } } // Bare `(local.set $x)` — the value is implicit on the stack (e.g. an // exception payload landing from a `try_table` catch). Demote to `drop` // so the dead store goes away without unbalancing the stack. else if (sub.length === 2) { funcNode[i] = ['drop']; changed = true } } // Unused local declaration else if (sub[0] === 'local' && name[0] === '$' && uses.gets === 0 && uses.sets === 0 && uses.tees === 0) { funcNode.splice(i, 1); changed = true } } return changed } /** * Propagate values through locals and eliminate single-use/dead locals. * Constants propagate to all uses; pure single-use exprs inline into get site. * Multi-pass with batch counting for convergence. */ /** Block-like nodes whose body is a straight-line instruction list (after any header). */ const isScopeNode = (n) => Array.isArray(n) && (n[0] === 'func' || n[0] === 'block' || n[0] === 'loop' || n[0] === 'then' || n[0] === 'else') const propagate = (ast) => { walk(ast, (funcNode) => { if (!Array.isArray(funcNode) || funcNode[0] !== 'func') return const params = new Set() for (const sub of funcNode) if (Array.isArray(sub) && sub[0] === 'param' && typeof sub[1] === 'string') params.add(sub[1]) // Propagation runs per straight-line scope: the function body and every nested // `block`/`loop`/`then`/`else` (including ones embedded in an expression, e.g. the // `(block (result i32) …)` an inlined call leaves behind). Collect scopes deepest- // first so inner simplifications shrink the use-counts the outer scopes see. // Use-counts are always whole-function — a set/get pair or dead store is only // touched when it's globally the sole occurrence, so per-scope work stays sound. const scopes = [] walkPost(funcNode, n => { if (isScopeNode(n)) scopes.push(n) }) // One use-count per round, shared by every scope: substitutions only ever // *drop* gets, so a stale count can only make a sub-pass act more cautiously // (skip a not-yet-provably-dead store, decline a not-yet-provably-single use) — // never wrongly. The next round re-counts and mops up. (Recounting per sub-pass // per scope is O(scopes·funcSize) and crippling on big modules.) for (let round = 0; round < 6; round++) { const useCounts = countLocalUses(funcNode) let progressed = false for (const scope of scopes) { if (forwardPropagate(scope, params, useCounts)) progressed = true if (eliminateSetGetPairs(scope, params, useCounts)) progressed = true if (createLocalTees(scope, params, useCounts)) progressed = true if (eliminateDeadStores(scope, params, useCounts)) progressed = true } if (!progressed) break } }) return ast } // ==================== FUNCTION INLINING ==================== /** * Inline tiny functions (single expression, no locals, no params or simple params). * @param {Array} ast * @returns {Array} */ const inline = (ast) => { if (!Array.isArray(ast) || ast[0] !== 'module') return ast // Collect inlinable functions const inlinable = new Map() // $name → { body, params } for (const node of ast.slice(1)) { if (!Array.isArray(node) || node[0] !== 'func') continue const name = typeof node[1] === 'string' && node[1][0] === '$' ? node[1] : null if (!name) continue // Check if function is small enough to inline let params = [] let body = [] let hasLocals = false let hasExport = false for (let i = 1; i < node.length; i++) { const sub = node[i] if (!Array.isArray(sub)) continue if (sub[0] === 'param') { // Collect param names and types if (typeof sub[1] === 'string' && sub[1][0] === '$') { params.push({ name: sub[1], type: sub[2] }) } else { // Unnamed params - harder to inline params = null break } } else if (sub[0] === 'local') { hasLocals = true } else if (sub[0] === 'export') { hasExport = true } else if (sub[0] !== 'result' && sub[0] !== 'type') { body.push(sub) } } // Inline: no locals, <= 4 params, single expression body, not exported if (params && !hasLocals && !hasExport && params.length <= 4 && body.length === 1) { // Check if function mutates any of its params (local.set/tee on param), // or contains a control-transfer op (`return`, `return_call`, // `return_call_indirect`). Inlining such bodies into a different-typed // caller would propagate the transfer to the caller, returning from the // wrong function with the wrong type. Lifting the body into a // `(block $exit ...)` and rewriting returns to `(br $exit X)` would // unlock these — left for a future pass. const paramNames = new Set(params.map(p => p.name)) let mutatesParam = false let hasReturn = false walk(body[0], (n) => { if (!Array.isArray(n)) return if ((n[0] === 'local.set' || n[0] === 'local.tee') && paramNames.has(n[1])) { mutatesParam = true } if (n[0] === 'return' || n[0] === 'return_call' || n[0] === 'return_call_indirect') { hasReturn = true } }) if (!mutatesParam && !hasReturn) { inlinable.set(name, { body: body[0], params }) } } } // Replace calls with inlined body if (inlinable.size === 0) return ast walkPost(ast, (node) => { if (!Array.isArray(node) || node[0] !== 'call') return const fname = node[1] if (!inlinable.has(fname)) return const { body, params } = inlinable.get(fname) const args = node.slice(2) // Simple case: no params if (params.length === 0) { return clone(body) } // Substitute params with args const substituted = walkPost(clone(body), (n) => { if (!Array.isArray(n) || n[0] !== 'local.get') return const local = n[1] const paramIdx = params.findIndex(p => p.name === local) if (paramIdx !== -1 && args[paramIdx]) { return clone(args[paramIdx]) } }) return substituted }) return ast } // ==================== INLINE-ONCE ==================== let inlineUid = 0 /** * Inline functions that are called from exactly one place into their lone caller, * then delete them. Unlike {@link inline} (which duplicates tiny stateless bodies), * this never duplicates code and never inflates: each inlined function drops a * function-section entry, a type-section entry (if now unused), and a `call` * instruction, paying back only a `block`/`local.set` wrapper. This is what * `wasm-opt -Oz` does — collapsing helper chains down to a couple of functions — * and it's the bulk of the gap between hand-tuned WASM and naive codegen. * * A function `$f` qualifies when it is, all of: * • named, with named params and locals (numeric indices can't be safely renamed); * • referenced exactly once across the whole module, by a plain `call` (no * `return_call`, `ref.func`, `elem`, `export`, or `start` reference, and not * recursive); * • single-result or void (a multi-value result can't be modeled as `(block (result …))`