Embedding Reed in Rust with Binaryen
Binaryen is a compiler and toolchain library for
WebAssembly, best known through wasm-opt. Tools that generate, optimize, instrument, or merge
wasm modules use it to work with a real module rather than with raw bytes.
Building a function with Binaryen's C API means constructing an expression tree by hand:
BinaryenLocalGet, BinaryenBinary, BinaryenReturn, threading operand arrays through each
call. That is precise and completely explicit, which is what you want for a transform pass, and
tedious when what you actually wanted was return a * b + 1.
reedc-binaryen closes that gap. Write the logic in Reed, get a Binaryen module back — with
Reed's GC structs and arrays intact.
reedc-binaryen unit.reed --emit rust -o unit.rs
reedc-binaryen is a separate binary from reedc, because linking Binaryen means
compiling its C++ through cmake. Ordinary compilation (reedc build, reedc check,
reedc fmt) needs none of that and stays in reedc. Build this one with
mise run binaryen:build; it needs cmake and a C++20 toolchain, and the vendored Binaryen
comes from a git submodule (git submodule update --init vendor/binaryen).
A first unit
global Counter: mut i32 = 100;
// `pub` is what gets a call builder in the generated Rust -- see below. It is
// orthogonal to `export`, which is what the host may call.
pub func helper(a: i32, b: i32) -> i32 {
return a * b + 1;
}
export func check as "check";
func check() -> i32 {
Counter = Counter + helper(6, 7);
return Counter;
}
export func scale as "scale";
func scale(v: i32) -> i32 {
return v * 3;
}
reedc-binaryen unit.reed --emit rust -o unit.rs writes a Rust module containing:
WASM, the compiled module as a&'static [u8].module(), returning aBinaryenModuleRefwith the catalog feature set already applied.merge_into(host), copying the unit's functions, globals, tags, tables, and memories into a module you are building.EXPORTS, each export's Reed-level signature —func(i32) -> i32, in Reed's own type spelling rather than a wasm type index.consts, a module of Rustconsts for everyparam,const, and enum member the unit declared.calls, a call builder perpubfunction.
The consts module is there because those values exist nowhere else the embedder can reach:
expansion resolves a compile-time value away before anything is emitted, so there is no
i32.const in the wasm to read back. Yet they are exactly the constants a host has to agree
with the module about — a buffer size a param fixed, an enum discriminant a switch
dispatches on. The alternative is hardcoding a duplicate that silently drifts.
GC types stay GC types
This is the whole reason the crate exists. Reed's structs and arrays are WebAssembly GC types, and Binaryen represents them natively:
struct Point { x: mut i32, y: mut i32 }
export func check as "check";
func check() -> i32 {
let p: &Point = new Point { x: 3, y: 4 };
p->x = p->x + 10;
return p->x + p->y;
}
That returns 17, and the module Binaryen hands back contains a real struct.new and
struct.get — no linear memory, no allocator, no invented object layout. A pub function
taking &Point has the wasm signature (ref $Point) -> i32, which is what a caller must
actually push.
Optimizing
reedc-binaryen exposes Binaryen's optimizer with wasm-opt's own spellings:
reedc-binaryen unit.reed -O2 -o unit.wasm # -O0 (default), -O1, -O2, -O3, -Os, -Oz
reedc-binaryen unit.reed --pass dce --pass merge-blocks -o unit.wasm
reedc-binaryen unit.reed --list-passes # every pass --pass accepts
The default is -O0, so what you get is exactly what the compiler emitted. That matters
because reedc's output is annotated (spec section 15) and
worth reading; an optimizer pass is a deliberate choice, not something applied behind your back.
An unknown --pass name is refused before compilation starts. That is not politeness:
Binaryen's own pass registry calls Fatal() on a name it does not know, which prints and exits
the process.
Merging into your own module
use reed_binaryen_sys as bn;
mod unit; // generated by `reedc-binaryen --emit rust`
fn build() -> bn::BinaryenModuleRef {
unsafe {
let host = bn::BinaryenModuleCreate();
bn::BinaryenModuleSetFeatures(host, bn::catalog_features());
assert!(unit::merge_into(host), "a name collided with the host's own");
// Call a `pub` function from hand-written Binaryen IR.
let six = bn::BinaryenConst(host, bn::BinaryenLiteralInt32(6));
let seven = bn::BinaryenConst(host, bn::BinaryenLiteralInt32(7));
let call = unit::calls::helper(host, [six, seven]);
let body = bn::BinaryenReturn(host, call);
let name = std::ffi::CString::new("driver").unwrap();
bn::BinaryenAddFunction(
host,
name.as_ptr(),
bn::BinaryenTypeNone(),
bn::BinaryenTypeInt32(),
std::ptr::null_mut(),
0,
body,
);
host
}
}
Two things about merge_into are worth knowing:
It does not republish the unit's exports. An embedder merging Reed into a larger module
usually wants to call those functions, not to publish them to the host — and republishing
would collide with the host's own export names. EXPORTS lists them so you can re-export
whichever you want with BinaryenAddFunctionExport.
A name collision is refused, not attempted. Binaryen's addFunction calls Fatal() on a
duplicate name, so a Reed unit that happened to share one function name with its host would
kill your process. The merge checks every name first and returns false without touching the
destination. From Rust, Unit::merge_into reports the colliding names, and
Module::rename_function renames one along with every reference to it.
Why no index remapping
Binaryen's IR references every entity by name. A merge therefore copies items and is done; there is nothing to renumber, and no way for a missed reference to silently point at the wrong function.
That is worth stating explicitly because the crate this one replaced could not do it. waffle indexes entities densely, so merging meant rewriting every entity reference in every function body — 328 lines of it, plus a test that re-derived waffle's entity-carrying operator set from its own source, because a missed remap still validates and just calls the wrong thing.
One consequence to be aware of: names are load-bearing, so the encoder keeps the name section.
Binaryen omits it by default, and without it $helper becomes function index 3 and every
name-based lookup afterwards finds nothing — with no error anywhere.
Reading a module back
reedc-binaryen unit.reed --emit wat # Binaryen's rendering
reedc-binaryen unit.reed --check # the unit's surface, writing nothing
--emit wat gives Binaryen's text, not reedc's. The comments that make reedc's output
readable are not in the binary and cannot come back; use reedc build for those.
--check reports what the unit contains: its exports, its pub functions with Reed-level
signatures, and its compile-time values. It does not report whether the unit is
representable — there is nothing to report, because Binaryen accepts everything reedc emits.
pub is the key, not export
A call builder is generated for each pub function, not for each
export. The two answer different questions:
exportis what the host may call from outside the finished module.pubis what the author marked as their surface for other code.
An embedder splicing a unit into a larger module wants the second, and those functions usually are not exported — exporting them would publish them to the host too.
One kind of pub function gets no call builder, and the generated file says so rather than
leaving a gap: an imported function, whose body belongs to the host and which is reachable
through the import rather than through this unit.
@inline functions get a builder here
Under reedc build, an @inline function is elided: every call
is replaced by its body, and the definition is not emitted at all (spec 5.1). That leaves no
call target, so a pub @inline function could not have a call builder — it was reported as
skipped instead, which meant a function the author had explicitly marked as their surface was
unreachable from Rust.
This backend emits the definition as well, and attaches Binaryen's own always-inline hint
(binaryen.inline) to it. So:
- the builder exists, because there is a real function to call;
- the inlining guarantee is intact, because reedc has already replaced every call inside the unit, and Binaryen's inliner is told to inline the remaining ones — including a call an embedder makes through the generated builder.
The hint is honoured when the inlining pass runs: -O2 and above, or an explicit
run_passes(&["inlining-optimizing"]). At the default OptLevel::None no pass runs at all, so
the call stays a call — which is the right behaviour for a module the caller asked not to
optimize.
Two details worth knowing if you are reading Binaryen's source alongside this:
- Binaryen has two inline hints and its optimizer reads only one.
metadata.code.inline(the Compilation Hints proposal) is a hint for VMs;binaryen.inlineis the onepasses/Inlining.cppconsults, and it honours "always inline" before any size check. Setting the standard-looking one instead encodes a perfectly valid custom section and changes nothing about what the optimizer does. - Nothing about
reedc build's output changed. The WAT the compiler writes still elides these functions; only this backend opts into emitting them, because only here does "unreachable within this module" differ from "unreachable".
Compile-time parameters from Rust
A param can be set when the unit is compiled:
let mut defines = std::collections::HashMap::new();
defines.insert("BufferSize".to_string(), "4096".to_string());
let unit = reedc_binaryen::compile_str_with(
source,
&reedc_binaryen::Options {
compiler: reedc_core::Options { defines, ..Default::default() },
..Default::default()
},
)?;
The value is resolved at compile time, so it is baked into the module — changing it means
compiling again. Unit::comptime_values reports what each one ended up as, so a host can agree
with the module rather than duplicating the number.
What is not supported
Very little, and none of it is about GC:
--tail-callsworks, unlike under the waffle bridge: Binaryen hasreturn_call.- Every fixture in reedc's conformance suite translates, is optimized at
-O2, and returns an identical result afterwards (327 of 327,tests/check_binaryen_corpus.py). - The playground cannot reach any of this.
reedc-binaryenlinks a native library, and the playground is a wasm32 build with no library to link.