Skip to content

Commit bb38bc0

Browse files
Phase 1: optimization story — vetted wasm-opt pass list + verifier gate (#148)
Documents the Binaryen pass list that preserves L1-L10 (docs/optimization.adoc): optimize with external wasm-opt, use typed-wasm-verify as the invariant oracle (accept iff the typedwasm.* carriers survive intact). Per-carrier analysis + two hazards (custom-section stripping -> vacuous verification; function reindexing -> carriers point at the wrong functions) + the vetted/excluded/safe pass split. - tests/optimization.rs: both hazards pinned in-repo; end-to-end wasm-opt -> tw-verify gate (graceful skip where wasm-opt is absent) - chore: SPDX header on CHANGELOG.md (clears the governance advisory) Verified locally (cargo test --workspace --locked: codegen 28 + verifier 80; fmt + clippy clean; no new deps) since Actions credit is exhausted. Completes the "document" arm of the deliverable. Closes #131. Part of Phase 1 (#49). https://claude.ai/code/session_01Rq4da8i2uGnDUfanSB1Hx4
1 parent b8ab31f commit bb38bc0

3 files changed

Lines changed: 266 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
<!-- SPDX-License-Identifier: MPL-2.0 -->
12
# Changelog
23

34
All notable changes to this project will be documented in this file.
@@ -10,6 +11,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1011

1112
## [Unreleased]
1213

14+
### Optimization story: vetted `wasm-opt` pass list + verifier-as-oracle gate (#131) (2026-05-31)
15+
16+
Documents the Binaryen pass list that preserves the L1–L10 invariants
17+
(`docs/optimization.adoc`): optimize with external `wasm-opt`, use
18+
`typed-wasm-verify` as the invariant oracle (re-run post-opt; if it still
19+
accepts with the `typedwasm.*` carriers intact, the discipline survived).
20+
The per-carrier analysis identifies two hazards — custom-section **stripping**
21+
(→ vacuous verification) and function **reindexing** (→ carriers point at the
22+
wrong functions) — and the vetted / excluded / safe pass split that avoids
23+
them. Both hazards are pinned by in-repo tests; the end-to-end
24+
`wasm-opt → tw-verify` gate is encoded and runs where `wasm-opt` is on `PATH`
25+
(graceful skip otherwise). (`crates/typed-wasm-codegen/tests/optimization.rs`.)
26+
1327
### Round-trip soundness corpus: `verify(emit(m)) == OK`, property-tested (#130) (2026-05-31)
1428

1529
ECHIDNA-style property corpus for the producer: a deterministically-generated set
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
//
3+
// Optimization invariant-preservation — Phase 1 deliverable 3 (#131).
4+
//
5+
// The optimization story (docs/optimization.adoc) is: optimize with external
6+
// wasm-opt (Binaryen), use typed-wasm-verify as the invariant oracle. These
7+
// tests pin the two preservation HAZARDS the doc's vetted pass list guards
8+
// against (verifiable in-repo without wasm-opt), plus a graceful-skip gate
9+
// that runs a real wasm-opt round-trip where the tool is available.
10+
11+
use std::process::Command;
12+
use typed_wasm_codegen::{emit, emit_example01, Body, Func, Module, Op, Ownership, Wty};
13+
use typed_wasm_verify::{
14+
verify_access_sites_from_module, verify_from_module, OwnershipError, VerifyError,
15+
ACCESS_SITES_SECTION_NAME, OWNERSHIP_SECTION_NAME, REGIONS_SECTION_NAME,
16+
};
17+
18+
fn custom_section_names(bytes: &[u8]) -> Vec<String> {
19+
let mut names = Vec::new();
20+
for p in wasmparser::Parser::new(0).parse_all(bytes) {
21+
if let wasmparser::Payload::CustomSection(c) = p.expect("parse") {
22+
names.push(c.name().to_string());
23+
}
24+
}
25+
names
26+
}
27+
28+
/// HAZARD 1 — stripping custom sections makes verification vacuous.
29+
/// example 01 carries the sections the verifier checks; a carrier-free module
30+
/// is "accepted" only because there is nothing to check. An optimizer that
31+
/// drops custom sections silently turns the former into the latter.
32+
#[test]
33+
fn stripping_carriers_makes_verification_vacuous() {
34+
let names = custom_section_names(&emit_example01());
35+
assert!(names.iter().any(|n| n == REGIONS_SECTION_NAME));
36+
assert!(names.iter().any(|n| n == ACCESS_SITES_SECTION_NAME));
37+
assert!(names.iter().any(|n| n == OWNERSHIP_SECTION_NAME));
38+
39+
let bare = emit(&Module {
40+
regions: vec![],
41+
memory: None,
42+
imports: vec![],
43+
funcs: vec![Func {
44+
name: "f".into(),
45+
params: vec![Wty::I32],
46+
results: vec![],
47+
body: Body::Ops(vec![Op::LocalGet(0), Op::Drop]),
48+
export: true,
49+
}],
50+
ownership: vec![],
51+
});
52+
assert!(
53+
custom_section_names(&bare)
54+
.iter()
55+
.all(|n| !n.starts_with("typedwasm.")),
56+
"the bare module must carry no typedwasm.* sections"
57+
);
58+
// No carriers ⇒ accepted vacuously (the stripping hazard).
59+
verify_from_module(&bare).expect("carrier-free module verifies vacuously");
60+
assert!(verify_access_sites_from_module(&bare).unwrap().is_empty());
61+
}
62+
63+
/// HAZARD 2 — the ownership carrier's `func_idx` is load-bearing: identical
64+
/// code, a different `func_idx` in the carrier, a different verdict. Any pass
65+
/// that reorders/removes/merges functions invalidates it.
66+
#[test]
67+
fn ownership_func_idx_is_load_bearing() {
68+
// Two functions of identical shape, each using its param twice.
69+
let funcs = || {
70+
let dup = || Body::Ops(vec![Op::LocalGet(0), Op::LocalGet(0), Op::Drop, Op::Drop]);
71+
vec![
72+
Func {
73+
name: "a".into(),
74+
params: vec![Wty::I32],
75+
results: vec![],
76+
body: dup(),
77+
export: true,
78+
},
79+
Func {
80+
name: "b".into(),
81+
params: vec![Wty::I32],
82+
results: vec![],
83+
body: dup(),
84+
export: true,
85+
},
86+
]
87+
};
88+
let mk = |owned: usize| Module {
89+
regions: vec![],
90+
memory: None,
91+
imports: vec![],
92+
funcs: funcs(),
93+
ownership: vec![(owned, vec![Ownership::Linear])],
94+
};
95+
96+
// Carrier marks func 0 Linear → its double-use is the violation.
97+
match verify_from_module(&emit(&mk(0))) {
98+
Err(VerifyError::Ownership(es)) => assert!(es
99+
.iter()
100+
.any(|e| matches!(e, OwnershipError::LinearUsedMultiple { func_idx: 0, .. }))),
101+
o => panic!("expected a violation on func 0, got {o:?}"),
102+
}
103+
// Same code, carrier moved to func 1 → the violation now lands on func 1.
104+
match verify_from_module(&emit(&mk(1))) {
105+
Err(VerifyError::Ownership(es)) => assert!(es
106+
.iter()
107+
.any(|e| matches!(e, OwnershipError::LinearUsedMultiple { func_idx: 1, .. }))),
108+
o => panic!("expected a violation on func 1, got {o:?}"),
109+
}
110+
}
111+
112+
/// The end-to-end gate: optimize with wasm-opt, then the verifier must still
113+
/// accept. Skips gracefully where wasm-opt is unavailable (the vetted pass
114+
/// list + flags are in docs/optimization.adoc).
115+
#[test]
116+
fn wasm_opt_gate_or_skip() {
117+
if Command::new("wasm-opt").arg("--version").output().is_err() {
118+
eprintln!("wasm-opt not on PATH — optimization gate skipped (see docs/optimization.adoc)");
119+
return;
120+
}
121+
let dir = std::env::temp_dir();
122+
let inp = dir.join("tw_opt_in.wasm");
123+
let outp = dir.join("tw_opt_out.wasm");
124+
std::fs::write(&inp, emit_example01()).expect("write input");
125+
126+
// Function-identity-preserving passes only (see docs/optimization.adoc).
127+
let status = Command::new("wasm-opt")
128+
.args(["--vacuum", "--optimize-instructions", "--simplify-locals"])
129+
.arg("-o")
130+
.arg(&outp)
131+
.arg(&inp)
132+
.status()
133+
.expect("run wasm-opt");
134+
assert!(status.success(), "wasm-opt invocation failed");
135+
136+
let opt = std::fs::read(&outp).expect("read optimized");
137+
wasmparser::Validator::new()
138+
.validate_all(&opt)
139+
.expect("optimized module is valid wasm");
140+
verify_from_module(&opt).expect("optimized module still passes L7/L10");
141+
}

docs/optimization.adoc

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
= typed-wasm Optimization Story
4+
:toc: preamble
5+
:sectnums:
6+
7+
Phase 1 deliverable 3 (issue #49 / #131): _"either implement passes inside
8+
typed-wasm or document the binaryen pass list that preserves the L1–L10
9+
invariants."_ This document takes the **document** arm.
10+
11+
== Decision
12+
13+
Optimize with **`wasm-opt`** (Binaryen) as an external post-pass, and use
14+
**`typed-wasm-verify`** (the `tw-verify` CLI) as the **invariant oracle**:
15+
after optimization, re-run the verifier; if it still accepts the module, the
16+
L1–L10 discipline survived the optimization.
17+
18+
[source,sh]
19+
----
20+
tw build app.twasm -o app.wasm # producer (typed-wasm-codegen)
21+
wasm-opt <vetted passes> app.wasm -o app.opt.wasm
22+
tw-verify app.opt.wasm # MUST still exit 0
23+
----
24+
25+
We do **not** ship an in-tree optimizer: Binaryen is the mature wasm
26+
optimizer, and the verifier already gives us a soundness gate over its
27+
output. (An in-tree pass set can be revisited later; it is not needed to
28+
make the invariant claim.)
29+
30+
== What the verifier checks, and what optimization can break
31+
32+
The discipline is carried by three custom sections. Optimization preserves
33+
the invariants **iff** it preserves these carriers' validity:
34+
35+
[cols="1,3,2",options="header"]
36+
|===
37+
| Carrier | Keyed by | Fragility under wasm-opt
38+
39+
| `typedwasm.regions` (L2–L6)
40+
| nothing function-specific — a pure schema
41+
| **Robust.** Survives any code transformation, *provided the custom section
42+
is not stripped*.
43+
44+
| `typedwasm.ownership` (L7/L10)
45+
| `func_idx` + parameter position
46+
| **Fragile to function reindexing.** Any pass that reorders, removes,
47+
merges, or inlines functions invalidates `func_idx`.
48+
49+
| `typedwasm.access-sites` (L2)
50+
| `func_idx`, `byte_offset`, `region_id`, `field_id`
51+
| `byte_offset` is already a deferred placeholder (the verifier does not
52+
check it — proposal 0002 §AccessSiteMisalignment), so body rewrites are
53+
fine; `func_idx` is **fragile to function reindexing** as above.
54+
|===
55+
56+
=== The two hazards
57+
58+
. **Custom-section stripping → vacuous verification.** `wasm-opt` drops
59+
custom sections it does not recognise unless told to keep them. A stripped
60+
module has *nothing to check*, so the verifier accepts it **vacuously** —
61+
silently downgrading a typed module to an untyped one. (Demonstrated in
62+
`crates/typed-wasm-codegen/tests/optimization.rs`.)
63+
. **Function reindexing → carriers point at the wrong functions.** The
64+
ownership/access-site carriers reference functions by index. A pass that
65+
reorders or removes functions leaves the indices pointing elsewhere — the
66+
verifier then checks the *wrong* function (also demonstrated in the tests).
67+
68+
== The vetted pass list (preserves L1–L10)
69+
70+
**Must hold:**
71+
72+
* **Preserve the custom sections** — pass Binaryen's custom-section
73+
preservation flag for `typedwasm.regions`, `typedwasm.ownership`, and
74+
`typedwasm.access-sites` (or re-attach them after optimizing).
75+
* **Preserve function identity** — do **not** reorder/remove/merge/inline
76+
functions unless the carriers are regenerated afterwards.
77+
78+
**Excluded** (they reindex or remove functions): `--inlining`,
79+
`--inlining-optimizing`, `--merge-functions`, `--dae` / `--dae-optimizing`,
80+
`--reorder-functions`, and whole-module `--remove-unused-module-elements`.
81+
82+
**Safe** (intra-function; keep the function table stable — body bytes change,
83+
but `func_idx` and the region schema hold, and access-site `byte_offset` is
84+
already non-load-bearing):
85+
86+
----
87+
--vacuum --optimize-instructions --simplify-locals --coalesce-locals
88+
--local-cse --code-folding --merge-blocks --remove-unused-brs --precompute
89+
----
90+
91+
After any run, **`tw-verify` is the gate**: it must still accept, and the
92+
`typedwasm.*` sections must still be present (a present-but-checked module,
93+
not a stripped-and-vacuous one).
94+
95+
== Cleaner long-term alternative
96+
97+
Have the producer **optimize the code-only module first, then derive and
98+
attach the carriers** from the optimized module — so carriers always describe
99+
the final bytes and no pass can desynchronise them. This removes the
100+
function-identity constraint entirely. It needs post-optimization
101+
offset/index re-derivation in the producer; deferred until the front-end → IR
102+
seam (#127) lands, since it shares the same offset-tracking machinery.
103+
104+
== Status
105+
106+
This documents the Binaryen pass list that preserves L1–L10 — the deliverable's
107+
"document" arm. The two preservation hazards are pinned by in-repo tests; the
108+
end-to-end `wasm-opt → tw-verify` gate is encoded in
109+
`tests/optimization.rs` and runs wherever `wasm-opt` is on `PATH` (it skips
110+
gracefully otherwise). Optional future work: an in-tree pass set, and a CI lane
111+
that provisions `wasm-opt` to run the gate non-advisorily.

0 commit comments

Comments
 (0)