Skip to content

Commit b4a0d1e

Browse files
committed
Promote measured Release-only Wasm size optimizations
1 parent 1b65a40 commit b4a0d1e

14 files changed

Lines changed: 3484 additions & 17 deletions

‎Cargo.toml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ splitscript-syntax = { path = "crates/splitscript-syntax" }
2525
serde = { version = "1.0.229", features = ["derive"] }
2626
serde_json = "1.0.151"
2727
gimli = { version = "0.33.0", default-features = false, features = ["write"] }
28-
wasm-encoder = "0.254.0"
28+
wasm-encoder = { version = "0.254.0", default-features = false, features = ["std", "wasmparser"] }
29+
wasmparser = { version = "0.254.0", default-features = false, features = ["std"] }
2930

3031
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
3132
clap = { version = "4.6", features = ["derive"] }

‎TODO.md‎

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -978,15 +978,18 @@ concepts rather than maintaining a parallel inventory.
978978

979979
## P1 — reduce generated Wasm size with Release-only compiler optimizations
980980

981-
Schedule this as a separate compiler project after the current Unity goal is
982-
finished. It is not part of Unity parity or a prerequisite for fixing the size
983-
regressions introduced by that work. Optimize final Wasm byte size; leave
984-
execution-speed optimization to the WebAssembly engine. Keep Debug builds out
985-
of these passes so stepping and source correspondence remain straightforward.
981+
The [promoted size pipeline](docs/WASM_SIZE_OPTIMIZATION.md) performs bounded
982+
Release-only instruction, integer-constant, dead-code, local and control-flow
983+
cleanup, shared returns, and sharing of bodies differing in integer constants.
984+
It saves 7.0% / 7.2% on Minish Cap / Lunistice. Debug bypasses all passes.
985+
Inlining, temporary sinking and propagation remain on the experimental branch;
986+
their remaining real-script benefit does not yet justify promotion. Prefer
987+
measured savings on real scripts over large synthetic fixtures. Direct emitter
988+
fixes that add no analysis may apply to both profiles.
986989

987990
Implement in this order:
988991

989-
1. [ ] Establish a representative Release size corpus, including small native
992+
1. [x] Establish a representative Release size corpus, including small native
990993
scripts, explicit-profile and automatic-profile Unity scripts, nested managed
991994
collections, and async code. Record final module bytes and section sizes
992995
under one fixed emission pipeline, with optimization enabled and disabled.
@@ -998,10 +1001,10 @@ Implement in this order:
9981001
and observable effects. Reuse existing constant evaluation where its
9991002
semantics match runtime evaluation; never evaluate process reads or other
10001003
host effects at compile time.
1001-
3. [ ] Add size-driven inlining, beginning with non-recursive functions that
1002-
have one reachable call site. Inline only when the estimated total module
1003-
cost falls, accounting for removal of the original function, call overhead,
1004-
locals, and any retained dependencies. Treat recursive calls and suspension
1004+
3. [ ] Establish useful additional savings from size-driven inlining before
1005+
promoting the existing experiment. Account for removal of the original
1006+
function, call overhead, locals, retained dependencies, and cleanup already
1007+
performed by the promoted pipeline. Treat recursion and suspension
10051008
boundaries conservatively. Do not use hotness or runtime-speed heuristics.
10061009
4. [ ] Recompute reachability after simplification and inlining so newly unused
10071010
functions, runtime helpers, imports, types, static data, and scratch regions

‎docs/WASM_SIZE_OPTIMIZATION.md‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# Release Wasm size optimization
2+
3+
Release compilation runs a bounded size optimizer after emission. Debug and hot
4+
reload bypass all optimizer scans. The objective is a smaller complete Wasm
5+
file; execution-speed optimization remains the engine's job. Binaryen is not a
6+
compiler dependency.
7+
8+
## Promoted pipeline
9+
10+
Two cleanup sweeps simplify integer instructions, fold adjacent integer
11+
constants, remove unreachable code and unused locals, simplify structured
12+
control flow, and share identical return sequences. The second sweep handles
13+
patterns exposed by the first. Compilation does not iterate to a fixed point,
14+
and it stops early when a sweep does not shrink the module.
15+
16+
Function sharing then merges bodies that differ only in integer constants.
17+
Each original function remains a small wrapper passing its constants to a
18+
shared helper. Existing indices, signatures, exports and references remain
19+
intact. Reports retain the original functions and add the shared helpers.
20+
21+
Every cleanup body and complete module must shrink. Function-sharing costs
22+
include wrappers, helper bodies, new signatures/declarations, and section
23+
encoding overhead. The implementation preserves integer overflow and traps,
24+
does not fold floating-point operations, and retains effects of discarded
25+
values. Control-flow changes remap branch targets. Shared return sequences
26+
exclude bodies with non-defaultable locals; function sharing excludes tail
27+
calls and exception/continuation constructs. The tests cover those boundaries,
28+
GC/reference values, multi-value returns, large indices, and observable effects.
29+
30+
## Selection from the experimental branch
31+
32+
The promotion was measured against `master` at `1b65a40` and the full pipeline
33+
at `3ecf397` on `experiment/wasm-size-optimizations`. The experiment and its
34+
Binaryen comparisons remain on that branch. Commit `3f3c9be` records a
35+
reproducible comparison with individual passes disabled.
36+
37+
| Real script | Previous master | Promoted Release | Saved | Reduction |
38+
| --- | ---: | ---: | ---: | ---: |
39+
| Minish Cap | 35,111 | 32,648 | 2,463 | 7.0% |
40+
| Lunistice | 31,413 | 29,163 | 2,250 | 7.2% |
41+
| Neon White | 5,073 | 4,768 | 305 | 6.0% |
42+
| A Hat in Time | 50,205 | 48,536 | 1,669 | 3.3% |
43+
44+
The selected passes retain 98.2% / 98.4% of the full experimental savings on
45+
Minish Cap / Lunistice. Inlining and temporary sinking stay experimental:
46+
together, they save only another 46 / 36 bytes compared with this pipeline.
47+
Temporary sinking alone contributes 36 / 17 bytes in the two-sweep configuration.
48+
The remaining size benefit does not justify promoting that additional analysis
49+
and maintenance surface yet. The disabled propagation experiment also stays off
50+
master. Redundant null rewriting is omitted because direct emitter fixes already
51+
provide its savings in both profiles.
52+
53+
Integer constant folding stays because it saves 113 bytes on Minish Cap and
54+
fits into the existing instruction scan. A second cleanup sweep saves another
55+
282 / 41 bytes on the primary scripts when measured with temporary sinking
56+
enabled; it exposes useful simplifications without introducing another analysis.
57+
Pass savings interact and must not be added together as independent totals.
58+
59+
## Validation and reproduction
60+
61+
The promoted pipeline has focused instruction/control/function-sharing tests
62+
and a source-level behavioral test covering evaluation order, GC values,
63+
fallible results and suspension. The real-script regression checks module
64+
validation, size reduction, unchanged original function indices and report
65+
metadata, and identical output with or without requesting a report.
66+
67+
The opt-in nine-fixture corpus records complete module bytes and section payload
68+
sizes, validates baseline and optimized modules, and checks Debug equivalence.
69+
DWARF `.debug_info` variable entries already have nondeterministic ordering in
70+
unoptimized builds, so that comparison excludes only this section; executable
71+
code, names, source maps and line tables must match. A smaller deterministic
72+
fixture also checks the entire Debug binary byte for byte. No Debug emitter
73+
changes are part of this promotion.
74+
75+
Validation passes: 494 library, 696 compiler, 20 binary and four example tests;
76+
Clippy; formatting/diff checks; and the browser compiler's wasm32 target check.
77+
All 176 maintained modules validate, all 211 runtime scenarios and the
78+
Debug/Release profile check pass. The size-corpus runner additionally passes
79+
36 behavioral invocations comparing unoptimized and promoted output.
80+
81+
```powershell
82+
$env:CARGO_PROFILE_DEV_DEBUG = '0'
83+
$env:CARGO_PROFILE_TEST_DEBUG = '0'
84+
$env:CARGO_INCREMENTAL = '0'
85+
cargo test --lib --test compiler --bin splitc --bin splitls --examples --offline
86+
cargo clippy --all-targets --offline -- -D warnings
87+
cargo check -p splitscript-vscode-wasm --target wasm32-unknown-unknown --offline
88+
cargo test --lib write_size_corpus --offline -- --ignored --nocapture
89+
node scripts/wasm-size-runtime.mjs
90+
cargo test --profile max-opt --lib measure_optimization_overhead --offline -- --ignored --nocapture
91+
```
92+
93+
Artifacts stay under `target/size-check`. Timing uses seven warmed, alternating
94+
samples per mode, excludes parsing/type checking, and includes lowering and
95+
emission. The maintained runtime runner compares baseline and optimized output
96+
through the existing behavioral scenarios rather than checking bytes alone.

‎scripts/wasm-size-runtime.mjs‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Run maintained behavioral scenarios against baseline and optimized artifacts.
2+
import fs from "node:fs";
3+
import path from "node:path";
4+
import { spawnSync } from "node:child_process";
5+
6+
const directory = path.resolve("target/size-check");
7+
const corpus = JSON.parse(fs.readFileSync(path.join(directory, "corpus.json"), "utf8"));
8+
const fixtures = [...fs.readFileSync("src/bin/xtask.rs", "utf8").matchAll(
9+
/RuntimeFixture\s*\{\s*source:\s*"([^"]+)",\s*output:\s*"([^"]+)",\s*profile:\s*"([^"]+)",\s*harness:\s*"([^"]+)",\s*extra_arguments:\s*&\[([^\]]*)\]/g,
10+
)];
11+
const variants = process.argv.slice(2);
12+
if (!variants.length) variants.push("baseline", "release");
13+
let scenarios = 0;
14+
for (const row of corpus) {
15+
let selected = fixtures.filter(match => match[1] === row.source && match[3] === "release");
16+
if (!selected.length) selected = fixtures.filter(match => match[1] === row.source);
17+
if (!selected.length) throw new Error(`No maintained scenarios for ${row.source}`);
18+
for (const variant of variants) {
19+
for (const fixture of selected) {
20+
const args = [...fixture[5].matchAll(/"([^"]*)"/g)].map(match => match[1]);
21+
const result = spawnSync(process.execPath, [fixture[4], path.join(directory, `${row.name}.${variant}.wasm`), ...args], { encoding: "utf8" });
22+
if (result.status !== 0) throw new Error(`${row.name}/${variant}/${args.join(" ")}: ${result.stdout}\n${result.stderr}`);
23+
scenarios++;
24+
}
25+
console.log(`${row.name}/${variant}: ${selected.length} scenarios passed`);
26+
}
27+
}
28+
console.log(`${scenarios} maintained runtime scenarios passed`);

‎src/codegen.rs‎

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,13 @@ mod managed_references;
5353
mod managed_snapshots;
5454
mod managed_state_reads;
5555
mod memory_plan;
56+
mod merging;
5657
mod module_assembly;
5758
mod module_start;
59+
mod optimization;
60+
#[cfg(test)]
61+
mod optimization_tests;
62+
mod peephole;
5863
mod pointer_prefixes;
5964
mod reachability;
6065
mod runtime_helper_registry;
@@ -398,7 +403,7 @@ impl std::ops::Deref for BackendProgram<'_> {
398403
}
399404

400405
pub fn compile(inputs: BackendProgram<'_>) -> Vec<u8> {
401-
compile_internal(inputs, None)
406+
compile_internal(inputs, None, OptimizationMode::Size)
402407
}
403408

404409
/// Sidecar information from the same plan that emitted the module. Requesting
@@ -417,11 +422,22 @@ pub struct CodegenReport {
417422

418423
pub(crate) fn compile_with_report(inputs: BackendProgram<'_>) -> (Vec<u8>, CodegenReport) {
419424
let mut report = CodegenReport::default();
420-
let wasm = compile_internal(inputs, Some(&mut report));
425+
let wasm = compile_internal(inputs, Some(&mut report), OptimizationMode::Size);
421426
(wasm, report)
422427
}
423428

424-
fn compile_internal(inputs: BackendProgram<'_>, report: Option<&mut CodegenReport>) -> Vec<u8> {
429+
#[derive(Clone, Copy)]
430+
enum OptimizationMode {
431+
#[cfg(test)]
432+
None,
433+
Size,
434+
}
435+
436+
fn compile_internal(
437+
inputs: BackendProgram<'_>,
438+
mut report: Option<&mut CodegenReport>,
439+
optimizations: OptimizationMode,
440+
) -> Vec<u8> {
425441
// These contracts describe immutable compiler-owned tables, independent of
426442
// the source, profile, or injected standard-library graph.
427443
static VALIDATED_CONTRACTS: std::sync::OnceLock<()> = std::sync::OnceLock::new();
@@ -1167,7 +1183,7 @@ fn compile_internal(inputs: BackendProgram<'_>, report: Option<&mut CodegenRepor
11671183
})
11681184
});
11691185

1170-
if let Some(report) = report {
1186+
if let Some(report) = report.as_deref_mut() {
11711187
let memory = static_data.layout();
11721188
*report = CodegenReport {
11731189
functions: function_debug_names,
@@ -1183,7 +1199,7 @@ fn compile_internal(inputs: BackendProgram<'_>, report: Option<&mut CodegenRepor
11831199
};
11841200
}
11851201

1186-
module_assembly::finish(
1202+
let wasm = module_assembly::finish(
11871203
module_assembly::Sections {
11881204
types,
11891205
imports,
@@ -1206,7 +1222,16 @@ fn compile_internal(inputs: BackendProgram<'_>, report: Option<&mut CodegenRepor
12061222
start_function,
12071223
update_function,
12081224
debug_artifacts.as_ref(),
1209-
)
1225+
);
1226+
// Debug/hot-reload compilation bypasses every optimizer scan.
1227+
if wasm_ir.profile() != crate::BuildProfile::Release {
1228+
return wasm;
1229+
}
1230+
match optimizations {
1231+
#[cfg(test)]
1232+
OptimizationMode::None => wasm,
1233+
OptimizationMode::Size => optimization::optimize(wasm, report),
1234+
}
12101235
}
12111236

12121237
fn resolved_intrinsic(target: &wasm_ir::CallTarget) -> Option<IntrinsicId> {

0 commit comments

Comments
 (0)