A very fast linker for WebAssembly
#Rust#Wasm#gsoc2026-09-01
Contents
- Overview
- Results from the GSoC period
- Background
- A typical linking pipeline
- Overview of the WebAssembly format
- What a Wasm linker actually does
- Highlights from the project
- Cross-platform abstraction
- Building out the linking pipeline
- Cross-object resolution and WASI
- Linear memory and linker-synthesized symbols
- Section GC (content GC)
- Parallelizing output emission
- Future work
- Other: patches unrelated to Wasm
- Closing
Wild is a linker written in Rust that can link ELF (the executable format used mainly on Linux and similar systems) very quickly. ELF benchmark results are here. It can link several times faster than linkers that have long been considered fast, such as lld and mold.
One of Wild's main goals is to speed up Rust builds in particular. From this perspective, I spent this year's Google Summer of Code under the Rust Foundation trying to port Wild so that it could link WebAssembly (Wasm) as well. Once Wild supports WebAssembly, it will be possible to use Wild as the linker when compiling programs written in programming languages such as C, C++, and Rust to Wasm, just as it can currently be used when targeting ELF. This post is the final report for that project. It summarizes what the Wasm port achieved during the GSoC period, what the porting work involved, and what remains.
I will start with a short overview of what the Wasm port produced during the project.
Capabilities as a Wasm linker
The port primarily targeted wasm32 as the architecture and WASI (WebAssembly System Interface) preview 1 as the execution environment, which is common today. In Rust terms that is the wasm32-wasip1 target. We also handle flags such as --no-entry for wasm32-unknown-unknown, but every real program I verified below is a wasip1 build.
For compatibility we treated wasm-ld, developed in the LLVM project, as the reference. It is the de facto Wasm linker today and is based on lld.
I started implementing the Wasm port after GSoC began (I had already researched the port beforehand, and work to generalize the existing linking pipeline was already underway, as I describe later). Even so, over those few months we were able to link more complex software with Wild than I had expected, and run it on a WebAssembly runtime (Wasmtime). Programs I built with Wild and confirmed to run include the following[1]:
- SQLite: the SQLite Amalgamation ships SQLite as a single C file. It can be built for WASI. The number of input files and their size are small, so it works as a small-software benchmark.
- ripgrep: a Rust CLI tool with grep-like functionality. A wasm32-wasip1 build is also exercised in CI.
- Wild: Wild itself can be built for wasm32-wasip1.
- CRuby: the Ruby interpreter. A WASI build is officially supported.
- CPython: the Python interpreter. A WASI build is officially supported.
- SpiderMonkey: a JavaScript engine. It can be built for WASI using bytecodealliance/spidermonkey-wasi-embedding. The inputs include a 409MB archive file, so it works as a large-input benchmark.
Link performance
This section looks at link time (wall time) and resident set size (RSS, i.e. memory use) when building the programs above.
All benchmarks ran on Ubuntu 26.04 with an Intel Core Ultra 5 325 (8 cores, 8 threads) and 32GB of RAM. Output went to tmpfs. Link time was measured with hyperfine v1.20.0, and RSS with poop v0.5.0.
The linkers compared are Wild at commit b08deb1 and wasm-ld 22.1.2. The programs being linked are:
- SQLite Amalgamation: v3.49.1
- Wild: the same commit as above, a release build
- ripgrep: v15.2.0, a release build
- CRuby: v4.0.6
- CPython: v3.13
- SpiderMonkey: spidermonkey-wasi-embedding at commit
b02d760
To keep the comparison fair, the setup was as follows:
- Before measuring, the CPU was configured as follows:
sudo cpupower frequency-set --governor performanceto put the CPU in performance mode/sys/devices/system/cpu/intel_pstate/min_perf_pctset to 100 to stabilize the frequency
- Wild does not yet emit debug information (that is planned). So wasm-ld was passed
--strip-debugso that it would not emit debug sections either. - By default Wild runs the link in a process created with
fork(2). The main reason is to keep post-link cleanup out of wall time (after a fork, cleanup happens in the background). RSS, however, is measured on the parent, so RSS runs used Wild's--no-forkoption. wasm-ld (and lld) do not have this fork feature, so their default behavior already yields a correct RSS. - hyperfine was given
--prepare 'sleep 2'so that each trial had a two-second cooldown. That is related to the background cleanup in the child. Each trial starts after the parent exits, so without a cooldown the next trial can start while the child is still cleaning up, and that cleanup can disturb the measurement.
Here are the link-time results for each program:
Link-time comparison between Wild and wasm-ld
On every application we measured, Wild was several times faster than wasm-ld. Notably, for SpiderMonkey it was more than 15× faster.
Next, memory use:
Memory use comparison between Wild and wasm-ld
It shows results several times better than wasm-ld. Together with the link-time results, Wild is both faster and more memory-efficient than wasm-ld overall.
That said, if you already know that Wild is very fast on ELF, you might think matching that on Wasm would be easy. In practice it is not obvious that it would be.
We can share existing Wild pipeline work for things that are not format-specific and are easy to abstract, such as symbol resolution. The flip side is that format-specific work does not get that benefit. For example:
- parsing input object files and similar
- laying out the output (deciding how large each section is and where it is placed, as an address or an index)
- writing the output file
Those phases also take a large fraction of link time. So just because Wild is several times faster than lld on ELF does not mean the same trend will show up on Wasm. The rest of this post looks at the porting work itself after the background knowledge.
This section covers the prerequisite knowledge needed to understand the contents of Wasm ports.
I'll introduce the main pipeline of a general-purpose linker, without tying it to Wasm. It is only the major jobs, not everything, and details differ by format, but the outline is shared.
The linker first opens input files (object files, archives, shared libraries, and sometimes linker scripts), then parses and deserializes them.
It then keeps symbol information (functions, data, and so on) from those inputs in a hashmap or similar, and binds undefined symbols to real definitions. Priority rules when several definitions share a name, and the handling of weak symbols, are decided around here as well.
Implementations often keep only reachable input by starting from roots such as the entry point or exported symbols and walking relocations and references. That dead-code elimination is conventionally called GC. ELF usually drops things by section, but the GC unit can differ by format. On Wasm, GC is done on many kinds of units, including functions, data segments, and imports.
Next comes layout. This is where the linker fixes the size and address of each output section (on Wasm, that means indexes such as function indexes, and offsets in linear memory). It also estimates the size of regions the linker itself generates (GOT-like tables, various metadata, and so on).
Finally it writes the output from that layout. It copies bytes from the inputs while applying relocations, and writes symbols and small pieces of code that the linker synthesizes. On Wasm, examples include the initial value of __stack_pointer and a function that calls static constructors in order.
This section gives a short overview of the WebAssembly format, especially compared with ELF, for what we need later. Of course it cannot cover everything. If you want something closer to the spec, the specification is the best place to look :)
The Core WebAssembly specification defines the executable module format and its semantics: instructions, validation, instantiation, and runtime objects such as functions, tables, memories, globals, imports, and exports. It does not define a relocatable object-file ABI or a linker ABI. In particular, it does not prescribe symbol tables, relocation records, archive handling, symbol-resolution rules, or how several Wasm objects become one module.
Those conventions are documented separately in the WebAssembly/tool-conventions repository, and toolchains commonly follow them for interoperability. The linking, reloc.*, and target_features custom sections discussed below are part of that tooling layer rather than standard Wasm sections. This is why a valid executable Wasm module need not contain them, while a relocatable object normally needs equivalent metadata for a linker to consume.
A Wasm binary is a module: a magic number (\0asm) and a version, followed by a sequence of sections. ELF also has sections such as .text and .data, but ELF section names and flags are relatively free-form, whereas Wasm's standard sections have kinds and a rough order fixed by the spec. The ones that come up most in linking are:
- type: a table of function signatures (parameter and result types)
- import: functions, globals, and so on taken from outside the module (the host or another module)
- function / code: references from functions to their types, and the actual bytecode bodies
- table / element: mainly the indirect-call table used for function pointers, and its initialization
- memory / data: the linear-memory declaration, and the initial contents of static data placed in it
- global: mutable or immutable globals (such as
__stack_pointer, discussed later) - export: functions, memory, and so on exposed outside the module
The spec also allows arbitrary named data as custom sections. A well-known one is the name section, which holds display names for functions, globals, and so on. Debuggers and tools such as wasm-objdump use it for human-readable names. It usually has no effect at runtime. Metadata needed at link time is stored in other custom sections as well.
Compiler output for Wasm still looks like a Wasm module. Relocatable objects, however, carry metadata that usually does not remain in the final executable module (or is used differently). The WebAssembly tool conventions use custom sections along these lines:
linking: a symbol table, the constructor list (InitFuncs), data-segment info, and other information the linker needs for symbol resolution, GC, and initializationreloc.*: which relocation to apply at which offset in which sectiontarget_features: Wasm features the object requires (or forbids)
A useful picture is that what ELF keeps in the symbol table and relocation sections lives in these custom sections instead of in standard sections. The name section, by contrast, is more for the final artifact than for linking. Linkers usually rebuild it on output against the resolved indexes, rather than concatenating the inputs as-is.
The linker's job is to interpret that metadata, fold several objects into one module, drop linking / reloc.* that are not needed at runtime, and write a runnable Wasm module made of the standard sections (plus custom sections such as name and target_features when needed).
Next, how symbols and similar things are represented. In ELF object files, symbols are generally treated as addresses in a virtual address space (or as relocations against those addresses). A function call ultimately becomes a question of which address to jump to. In Wasm, functions, globals, types, and so on are identified by indexes into tables inside each section. A call instruction means "call function index N", not "jump to address X". When several objects are combined into one module, the linker does not merely assign addresses. It has to pack the per-input index spaces into a single numbering on the output module. Relocations change accordingly: they are less about rewriting addresses and more about writing the right index or memory offset.
Data still has something like an address, but it is not a wide virtual address space as in ELF. It is an offset into the module's linear memory (a contiguous byte array). Static data is placed in that memory via the data section, and on WASI targets the stack and heap are usually placed in the same linear memory as well[2]. Compared with ELF's several loadable segments and the more involved address resolution that a runtime linker participates in, the model is fairly simple. In return, the linker and the runtime / libc have to agree on a contract: where the stack goes, how __data_end and __heap_base are defined, and so on.
Runtime-facing symbols differ as well. ELF dynamic linking has a fairly thick runtime story like resolving symbols against shared libraries, going through the GOT/PLT, and so on. On Wasm (especially the statically linked WASI executables this port mainly targeted), references between objects are almost entirely resolved at link time. What remains at the module boundary is mainly imports (APIs the host provides) and exports (the entry point and so on).
In WASI preview 1, for example, syscall-like operations such as file I/O appear as imports with a module name like wasi_snapshot_preview1. The linker resolves references that have a definition among the objects, and leaves imports that the host must satisfy in the output module. When several objects request the same host import, they are usually coalesced to a single import in the output.
The final modules discussed in this post are generally linked ahead of time rather than loaded as separate dynamic libraries at run time. That does not mean their input code is always non-PIC: some inputs, including ones needed by CRuby and similar programs, use PIC-related relocations and conventions.
The Core specification does not standardize a dynamic-linking ABI either. One set of conventions used by wasm-ld represents positions in shared linear memory and in the indirect-call table through imported globals. The rest of this subsection describes those conventions, because supporting PIC input requires the linker to understand and produce the corresponding relocations, imports, and synthesized globals.
For its own static data and indirect-function-table slots, such a module can use linker- or loader-provided bases. __memory_base is the offset at which the module's data starts in the shared linear memory, and __table_base is the index at which its function-table range starts in the shared indirect-call table. Code can then compute locations within its own allocation relative to those bases.
References to definitions in other modules need their final locations supplied separately. The Wasm dynamic-linking conventions model this with global imports analogous in purpose to the Global Offset Table (GOT) used by ELF dynamic linkers. Despite the names, GOT.mem and GOT.func are not an ELF-style GOT stored in linear memory. They are import-module names and each imported global carries one resolved memory offset or table index:
- an import from module
GOT.mem, with the symbol name as the field name, supplies a data symbol's offset in linear memory - an import from module
GOT.funcsupplies a function's index in the indirect-call table
The latter is not a native code address: in Wasm it is normally a table index, used for function-pointer-style indirect calls. Direct calls to an imported function can still use an ordinary function import, so GOT.func is mainly relevant when code needs the function's address / table index.
A dynamic loader can allocate each module's ranges in the shared memory and table, provide __memory_base and __table_base, and resolve those GOT-like globals before instantiation. This is a convention used by toolchains such as wasm-ld. It is not itself part of the WASI preview 1 API. It still matters to a linker that aims for wasm-ld compatibility, because PIC input objects can contain the corresponding relocations and imports.
Under the dynamic-linking conventions, dynamic-linkable modules commonly carry a dylink.0 custom section. It records requirements such as the amount and alignment of linear memory and table space, plus dependency information. A loader can use that metadata to choose each module's __memory_base and __table_base. The ahead-of-time links discussed in this post do not themselves need such a loader, but supporting the associated object-file conventions is relevant for wasm-ld compatibility.
Just as an ELF linker synthesizes a GOT, PLT, dynamic sections for the interpreter, and so on, a Wasm linker cannot only concatenate inputs. It has to synthesize some definitions of its own. Typical examples:
- symbols tied to linear-memory layout (the initial value of
__stack_pointer,__data_end,__heap_base, and so on) - synthesized functions such as
__wasm_call_ctors, which call static constructors in order - setting up the indirect-call table, and GOT-like globals for PIC-style code
In other words, there are pieces the linker itself has to define.
We have looked at a general linker's work and at the main format differences between ELF and Wasm. Even people who know Wasm itself may not know what a Wasm linker is and is not responsible for, so I will touch on that briefly. Most of it is written down in the WebAssembly tool conventions.
As with ELF linking, the compiler emits object files, but they are object files for a WebAssembly target. The jobs a Wasm linker actually does include:
- combining several objects (and the needed members of archives) into one module
- symbol resolution (defined, undefined, weak, and so on) and resolving references that close among objects
- keeping host imports, and coalescing identical imports in the output
- merging tables such as type / function / global / data, and repacking indexes in the output
- applying relocations to code / data (including writes into fixed-width LEB128 slots)
- laying out linear memory (data placement, the stack,
__data_end/__heap_base, and so on) - linker-synthesized definitions (
__wasm_call_ctors, GOT-like globals, stubs for undefined weaks, and so on) - handling export, entry, and no-entry
- dropping dead functions, globals, data, imports, and so on (
--gc-sections) - rebuilding
nameandtarget_featuresin the output
This section walks through how the Wasm port progressed, using a few of the patches I submitted. Early on the rough plan was: first link a single basic object produced from WAT (WebAssembly Text Format) with wat2wasm, then builds that take several objects as input, then simple C programs, and finally fill in whatever was still missing so that simple Rust programs would build. After that I looked at what it took to build the more complex software mentioned at the start and run it on a Wasm runtime, and implemented what was needed.
Shortly before the Wasm port started, Mach-O support (the executable format used on macOS) was already underway. As a prelude to making ports like that easier, David had been abstracting across platforms and architectures.
At a high level, types and logic that differ by output format (ELF / Wasm / Mach-O, and so on) live on the Platform trait, and ISA- and relocation-level differences live on the Arch trait. Arch has an associated Platform, and implementations are split per format as well. On ELF, for example, several Archs such as x86_64 and AArch64 share the same ELF Platform.
So starting Wasm support meant first placing a Wasm Platform and a wasm32 Arch skeleton along those traits. That PR is:
It only filled in what the earliest stage needed. Plenty of items were still todo!(), or were declared even though Wasm does not need them, and I deliberately suppressed warnings with #![allow(unused)] and similar. (Once the shape of the port was reasonably settled, I paid down all of that debt.)
Early on I stacked the main pieces from the front of the pipeline toward the back: parsing object files (#1918)[3], section and symbol accessors (#1936, #1946), encoding relocations (#1964), and emitters for sections such as type / import / function / global / export (#1987). With an output-module skeleton (#2037) and code / memory emission (#2047), we could link a single file produced from WAT with wat2wasm[4]. Applying index relocations (#2058) then let us link objects from wat2wasm --relocatable and run them on a Wasm runtime. That put the prelude to multi-object linking in place.
After trivial single-object programs with no libc could run, the first real hurdle was resolving references across objects. In Wasm, undefined functions appear as imports, but if another object defines them they have to be folded into internal references rather than left as host imports (#2092). That made multi-object links work with cross-object imports resolved.
On the data side, per-object data-segment layout and emission (#2117), data relocations (#2135), and enough memory / stack / data-address support to link clang objects (#2145) followed. That was enough to run C programs without libc.
Being able to link Wasm objects out of archives also landed in this period (#2154). Without that, static libraries such as WASI libc are not practical.
A Wasm binary that actually works for WASI also has to keep host imports rather than dropping them (#2202). Mixing up references that can be resolved among objects with imports the runtime must satisfy shows up at run time as missing or extra APIs. Aligning data and stack the way wasm-ld does (#2206) finally made C programs linked with WASI libc runnable.
For WASI executables, how static data, the stack, and the heap sit in linear memory is the center of the linker's contract. Resolving __data_end and __heap_base as addresses the linker decides (#2188) is part of that contract.
Linking a simple Rust program through rustc needed more: flags rustc passes, and symbols on the WASI heap side. rustc passes --export for an ordinary wasm32-wasip1 build, so we supported that (#2211), and we also accepted -z stack-size in a wasm-ld-compatible way (#2224). Synthesizing the linker-defined __wasm_first_page_end and __heap_end symbols is what finally made a basic Rust program runnable (#2227). Without them, sbrk / malloc trap, and even a program that only println!s dies at runtime.
After that we also added wasm-ld-compatible memory options such as --stack-first and --initial-memory (#2254, #2325).
For static initialization we synthesize __wasm_call_ctors from the InitFuncs in the linking section, and wrap the command entry when needed (#2192).
The indirect function table for function pointers was absorbed around the time we got C without libc running (#2163). Later, CRuby and similar PIC inputs needed relative table-index relocations (R_WASM_TABLE_INDEX_REL_SLEB), __table_base, and GOT-like globals for externally defined data and function-table indexes (#2245, #2251, #2271, #2332).
As noted above, a linker may walk reachability during layout and leave unused parts out of the output. That is conventionally called GC.
ELF uses sections as the GC unit. Wasm has to mark functions, globals, data segments, and imports live or dead. GC is not only dropping bytes and it is tied to repacking output indexes (ordinals) for what remains. If code, tables, or relocations still point at old indexes after dead items are removed, the module is broken. On large-input benchmarks, how much dead code falls out also affects link time and output size. It affects link time because GC'd parts do not need to be processed in layout or emission.
The existing GC pipeline assumed ELF, so supporting the many GC units that Wasm GC[5] needs was not obvious.
So I first split the ELF GC pipeline into smaller pieces (#2297). David then abstracted GC units and related pieces onto Platform (#2347) (that helped a great deal, thank you!), and I used that to implement GC that can drop functions, globals, data, and imports.
When linking ELF, Wild uses structs from the object crate but builds its own pipeline for parallel work. For Wasm we eventually did something similar: split the already-laid-out output buffer into non-overlapping regions per function body and data segment, and have each thread copy bytes into those regions while applying relocations. That PR is:
Wasm output looks like sections written in order, but the bulky code / data contents can often be written independently per object or per segment. On large inputs such as SpiderMonkey, speeding up these format-specific phases tends to show up directly in the benchmark ratios.
Over the GSoC period Wild became able to build many real-world programs for Wasm, but plenty of work remains. Just off the top of my head, the main items include:
- Broader option support: linkers have many options. During the project I focused on implementing options used by typical Rust programs and by the programs I used for benchmarking, but covering more of them and improving wasm-ld compatibility is obviously desirable.
- Closer wasm-ld compatibility: beyond the set of supported options, there are many places where linkers need to stay compatible. Details of memory layout, when to error, unused table slots, and so on are closer to agreements between implementations than to a spec.
- Debug information: compilers sometimes emit DWARF (Debugging With Attributed Record Formats) for Wasm. That means the linker has to relocate and emit that debug info, which is a large step.
- Further WASI extensions: one program I tried to build and set aside this time is ffmpeg. It uses the WASI threads extension, so the linker has to follow (TLS, among other things). In Rust, targets that use the threads extension are distinguished as wasm32-wasip1-threads. Those extensions, and WASI preview 2, are future work (for preview 2 I still do not know how urgently we should chase it, given how mature the ecosystem is 🤷♂️ [6]).
- wasm64: like WASI preview 2, this depends on how the ecosystem matures. We will need to find the places that assume 32-bit indexes and memory offsets.
- linker-diff: Wild also has linker-diff in the repo, a crate that diffs binaries, and we use it to make tests more thorough. The diff logic is format-specific, and we have not added Wasm support yet. Wasm tests today are mainly
wasm-tools validatefrom wasm-tools, to check that Wild's output is valid Wasm, plus runtime checks. Being able to use linker-diff as well would make that more thorough. - Further speedups: we landed the larger optimizations during the project I think, but of course it would be good to keep speeding things up where real-world builds show room.
During GSoC I also submitted some non-Wasm patches as a Wild maintainer, mainly for Linux. Leaving out the simple ones (PR titles that start with "chore:"), the list is:
- fix: Keep
--defsym/linker-script redirect targets alive through LTO - feat: Support GDB index
- feat: Support
--rosegmentand--no-rosegment - fix: Don't bail when a linker plugin doesn't claim an IR archive member
- fix: Support
--wrapcombined with linker plugin LTO - feat: Add
--compress-debug-section=zlib-gabias an alias for--compress-debug-section=zlib - fix: Don't compress
SHF_ALLOCdebug sections - fix: Apply linker-managed section rules when using linker scripts
- fix: Reject absolute R_X86_64_8/16 against non-preemptible symbols in shared objects
- fix: Emit one zlib stream per compressed debug section
- refactor: Use an allowlist for Mach-O test assertions
- fix: Preserve original definitions for
--wrapunder linker-plugin LTO - fix: Don't consume the next argument for
--trace/-t
This post walked through porting Wild so that it is not only an ELF linker but also a very fast WebAssembly linker.
I especially want to thank my mentor @davidlattimore for pointed comments in design and abstraction discussions, and for very fast reviews. That always helped me a lot. I am also grateful to everyone who helped the port with feedback and patches, especially Wild maintainers @marxin and @mati865. Thanks to all of you I enjoyed this project a lot.
Some other programs I considered used Emscripten for their Wasm builds, or otherwise needed non-trivial patches just to swap the linker, so I left them out of this post. ↩︎
Memory can also be imported, among other cases. For the typical executables in this post, we assume the linker defines memory and places data in it. ↩︎
For the other platforms, ELF and Mach-O, Wild uses the object crate. That crate's Wasm support is not yet enough to do the equivalent work, so we parse with the wasmparser crate instead. ↩︎
Linking a single file does not feel very much like "linking", of course. ↩︎
Confusingly, Wasm also has a language feature called WasmGC, with a GC heap and types separate from linear memory. That is a runtime-side spec. It is a completely different thing from the linker's reachability analysis (
--gc-sections) discussed here. ↩︎The same naturally applies to WASI preview 3. ↩︎