WebAssembly: How V8 Compiles a Module
From binary decoding through lazy compilation, Liftoff and the optimizing tier, to the code space and the serialized code cache that survives a page reload.
TL;DR: A wasm module is decoded into a
WasmModulethat keeps the wire bytes, not the code. Functions are compiled one at a time, on demand, when they are first called (CompileLazyinsrc/wasm/module-compiler.cc). The first version of a function is Liftoff code, emitted in a single pass straight from the bytecode; hot functions get recompiled in the background with the optimizing compiler. The generated code lives in V8’s own wasm code space, outside the JS heap, and can be serialized into a cache whose header carries a version hash, a CPU-feature hash and a flag hash — change any flag and the cache is dead.
Compiling WebAssembly is not compiling JavaScript. JavaScript has one object model, one type system, and a compiler that has to guess at types before it has any. Wasm has a static type system baked into the binary format, a linear memory, and a control flow that is already validated before a single instruction runs. That means V8’s wasm pipeline can be much more direct than its JS pipeline — and much more aggressive about deferring work.
The interesting parts are all about deferral. V8 tries hard not to compile a function until it is called, not to optimize it until it is hot, and not to persist any of it unless the embedder asks.
This is a tour of that pipeline as it exists in the V8 tree today, with
the file names that actually exist (several of the classic names —
wasm-code-cache.cc, wasm-lazy-compilation.cc, module-builder.cc as
a pipeline stage — are gone; see the last section).
Stage 1: decoding, and what it deliberately does not do
The entry point for the whole thing is DecodeWasmModule, which builds a
ModuleDecoderImpl and runs it:
// src/wasm/module-decoder.cc:139
ModuleResult DecodeWasmModule(WasmEnabledFeatures enabled_features,
base::Vector<const uint8_t> wire_bytes,
bool validate_functions,
WasmDetectedFeatures* detected_features) {
TRACE_EVENT(TRACE_DISABLED_BY_DEFAULT("v8.wasm.detailed"),
"wasm.DecodeWasmModule");
ModuleDecoderImpl decoder{enabled_features, wire_bytes, detected_features};
ModuleResult result = decoder.DecodeModule(validate_functions);
return result;
}The code section is walked, but the bodies are not parsed into an IR. The decoder records where each body starts and how long it is, and moves on:
// src/wasm/module-decoder-impl.h:1549
for (uint32_t i = 0; ok() && i < functions_count; ++i) {
int function_index = module_->num_imported_functions + i;
...
const uint8_t* pos = pc();
uint32_t size = consume_u32v("body size", tracer_);
...
uint32_t offset = pc_offset();
consume_bytes(size, "function body");
if (V8_UNLIKELY(failed())) break;
DecodeFunctionBody(function_index, size, offset);DecodeFunctionBody records a WireBytesRef — an offset and a length
into the original byte buffer. That is the whole representation of a
function body at this stage. V8 keeps the wire bytes alive for the
lifetime of the module, and recompiles individual functions straight out
of them later. This is why the wasm binary format’s “custom sections”
and its byte-exact layout matter: they are the source code for the
compiler, and the compiler is a compiler for a file format, not for a
parsed AST.
There is no “module builder” stage in the pipeline. src/wasm/wasm-module-builder.cc
exists, but it is the public C++ API (WasmModuleBuilder) that lets an
embedder construct a wasm module programmatically and then serialize
it back to bytes. It is a producer of wasm binaries, not a stage that
consumes them.
Stage 2: lazy compilation, and the jump table that implements it
With --wasm-lazy-compilation (on by default), the module is
instantiated without any function bodies compiled. Instead, the module
gets a lazy compile table: a second jump table whose slots all point at
a single builtin, Builtin::kWasmCompileLazy.
// src/wasm/wasm-code-manager.cc:1259
JumpTableAssembler::GenerateLazyCompileTable(
lazy_compile_table_->instruction_start(), num_wasm_functions,
module_->num_imported_functions, compile_lazy_address);
JumpTableAssembler::InitializeJumpsToLazyCompileTable(
code_space_data.jump_table->instruction_start(), num_wasm_functions,
lazy_compile_table_->instruction_start());So the first call to any function does not call that function. It calls
the WasmCompileLazy builtin with the function index in a
platform-specific register (kWasmCompileLazyFuncIndexRegister — edi
on x64, x8 on arm64, r4 on arm32), and that builtin calls
wasm::CompileLazy.
// src/wasm/module-compiler.cc:1106
void CompileLazy(Isolate* isolate, NativeModule* native_module,
int func_index) {
...
ExecutionTierPair tiers =
GetLazyCompilationTiers(native_module, func_index, is_in_debug_state);
WasmCompilationUnit baseline_unit{
func_index, tiers.baseline_tier,
is_in_debug_state ? kForDebugging : kNotForDebugging, kAlreadyValidated};
CompilationEnv env = CompilationEnv::ForModule(native_module);
WasmDetectedFeatures detected_features;
WasmCompilationResult result = baseline_unit.ExecuteCompilation(
&env, compilation_state->GetWireBytesStorage().get(),
native_module->counter_updates(), &detected_features);
...
WasmCode* code =
native_module->PublishCode(native_module->AddCompiledCode(result));
...
if (v8_flags.wasm_lazy_compilation && tiers.baseline_tier < tiers.top_tier) {
WasmCompilationUnit tiering_unit{func_index, tiers.top_tier,
kNotForDebugging, kAlreadyValidated};
compilation_state->CommitTopTierCompilationUnit(tiering_unit);
}
}Three things are worth noticing.
First, kAlreadyValidated. The module was fully validated before it
could be instantiated, so the lazy compiler does not validate again. The
comment in CompileLazy says so explicitly: “The module was fully
validated before starting its execution, so lazy compilation cannot
fail.” That is why the CHECK(result.succeeded()) is a CHECK and not
a DCHECK.
Second, the first compilation of a lazily-compiled function is not
necessarily Liftoff. It is whatever GetLazyCompilationTiers says, and
that function is where compilation hints, the --wasm-tier-up-filter
debug flag and the debug state all get folded in.
Third, and this is the elegant bit: the lazy compilation also schedules the top-tier compilation. Compiling a function with Liftoff is cheap but not free; compiling it with the optimizing compiler is expensive. So the moment a function is first touched, V8 puts both on the queue — the baseline one right now, on the stack, and the top-tier one on a background job.
Stage 3: the two compilers
WasmCompilationUnit::ExecuteCompilation is the fork in the road. The
tier is a plain enum:
// src/wasm/wasm-tier.h:20
// All the tiers of Wasm execution.
enum class ExecutionTier : int8_t {
kNone,
#if V8_ENABLE_DRUMBRAKE
kInterpreter,
#endif // V8_ENABLE_DRUMBRAKE
kLiftoff,
kTurbofan,
};Note that the enum is still called kTurbofan, but the code it names is
now produced by Turboshaft. V8’s wasm top tier migrated from TurboFan to
Turboshaft, and the enum value was kept for compatibility with
serialized code and with the profiler’s event names.
The dispatch itself:
// src/wasm/function-compiler.cc:104
result = ExecuteLiftoffCompilation(env, func_body, options);
if (result.succeeded()) break;
}
// If --liftoff-only, do not fall back to turbofan, even if compilation
// failed.
if (v8_flags.liftoff_only) break;
// If Liftoff failed, fall back to TurboFan.
// TODO(wasm): We could actually stop or remove the tiering unit for this
// function to avoid compiling it twice with TurboFan.
[[fallthrough]];
}
case ExecutionTier::kTurbofan: {
#ifdef V8_ENABLE_TURBOFAN
compiler::WasmCompilationData data(func_body);
data.func_index = func_index_;
data.wire_bytes_storage = wire_bytes_storage;
data.validate_callees = validation_;
result = compiler::turboshaft::ExecuteTurboshaftWasmCompilation(
env, data, detected, counter_updates);Liftoff is a single-pass compiler. There is no IR at all: the wasm bytecode decoder is the compiler’s visitor, and every decoded opcode emits machine code immediately.
// src/wasm/baseline/liftoff-compiler.cc:11626
WasmFullDecoder<Decoder::NoValidationTag, LiftoffCompiler> decoder(
&zone, env->module, env->enabled_features,
compiler_options.detected_features ? compiler_options.detected_features
: &unused_detected_features,
func_body, call_descriptor, env, &zone,
NewLiftoffAssemblerBuffer(func_body_size), debug_sidetable_builder.get(),
compiler_options, function_coverage_data);
decoder.Decode();Decoder::NoValidationTag is the tell: Liftoff trusts the module. The
optimizing compiler, by contrast, builds a full graph, runs a stack of
optimization phases over it, and only then emits code. See
Liftoff and TurboFan: The Two WebAssembly Tiers
for that side of the story.
Liftoff also has a way to say “I can’t do this”. When it hits something it does not implement, it bails out and the whole function is compiled by the optimizing compiler instead. V8 treats an unexpected bailout as a bug:
// src/wasm/baseline/liftoff-compiler.cc:469
// Otherwise, bailout is not allowed.
FATAL("Liftoff bailout should not happen. Cause: %s\n", detail);Only experimental features, missing CPU features and a handful of externally-maintained architectures are allowed to bail out.
Stage 4: the tiering state machine
Every function carries three pieces of tiering state, and they are packed into a single byte per function:
// src/wasm/module-compiler.cc:820
// Encoding of fields in the {compilation_progress_} vector.
using RequiredBaselineTierField = base::BitField8<ExecutionTier, 0, 2>;
using RequiredTopTierField = base::BitField8<ExecutionTier, 2, 2>;
using ReachedTierField = base::BitField8<ExecutionTier, 4, 2>;Two bits each: what tier the function must reach at the baseline, what
tier it must reach at the top, and what tier it has actually reached.
A uint8_t array indexed by declared function index is the entire tiering
state of a module. There is no Maglev tier for wasm — the enum has three
values, not four.
Which tiers a module wants is decided once, from the flags:
// src/wasm/module-compiler.cc:976
ExecutionTierPair GetDefaultTiersPerModule(NativeModule* native_module,
DebugState is_in_debug_state,
bool lazy_module) {
if (lazy_module) {
return {ExecutionTier::kNone, ExecutionTier::kNone};
}
if (is_in_debug_state) {
return {ExecutionTier::kLiftoff, ExecutionTier::kLiftoff};
}
ExecutionTier baseline_tier =
v8_flags.liftoff ? ExecutionTier::kLiftoff : ExecutionTier::kTurbofan;
bool eager_tier_up = !v8_flags.wasm_dynamic_tiering && v8_flags.wasm_tier_up;
ExecutionTier top_tier =
eager_tier_up ? ExecutionTier::kTurbofan : baseline_tier;
return {baseline_tier, top_tier};
}Read that carefully. If --wasm-dynamic-tiering is off (the default is
on), and --wasm-tier-up is on, the top tier is TurboFan eagerly —
every function gets compiled with the optimizing compiler up front. If
dynamic tiering is on, the top tier equals the baseline tier, and the
upgrade happens later, driven by the budget described in the Liftoff
article.
Note also the debug state: when DevTools is attached, V8 tiers down. Both tiers become Liftoff, because Liftoff maps one wasm instruction to one contiguous run of machine code and keeps every local in its proper stack slot, while the optimizing compiler freely reorders and elides.
Stage 5: the code space
Wasm code does not live in the V8 heap’s code space. It lives in its own
memory, managed by WasmCodeAllocator, and the reason is both
architectural and adversarial.
The allocator reserves address space up front and commits pages lazily. The reservation size grows geometrically:
// src/wasm/wasm-code-manager.cc:754
size_t minimum_size = 2 * overhead;
size_t suggested_size = std::max(
std::max(RoundUp<kCodeAlignment>(needed_size) + overhead, minimum_size),
total_reserved_so_far / 4);
const size_t max_code_space_size =
size_t{v8_flags.wasm_max_code_space_size_mb} * MB;Three numbers bound this: overhead is the jump tables, minimum_size
is twice that (so a tiny module still gets a usable code space), and the
suggested size is at least a quarter of everything reserved so far, so
the reservation grows exponentially rather than linearly. The hard cap
is --wasm-max-code-space-size-mb, whose default is platform-dependent:
// src/common/globals.h:511
#if V8_TARGET_ARCH_ARM64 || V8_TARGET_ARCH_LOONG64
// ARM64 and Loong64 only supports direct calls within a 128 MB range.
constexpr uint32_t kDefaultMaxWasmCodeSpaceSizeMb = 128;
#elif V8_TARGET_ARCH_PPC64
// Branches only take 26 bits.
constexpr uint32_t kDefaultMaxWasmCodeSpaceSizeMb = 32;
#else
// Use 1024 MB limit for code spaces on other platforms. This is smaller than
// the total allowed code space (kMaxWasmCodeMemory) to avoid unnecessarily
// big reservations, and to ensure that distances within a code space fit
// within a 32-bit signed integer.
constexpr uint32_t kDefaultMaxWasmCodeSpaceSizeMb = 1024;
#endifThe comment explains the real constraint: wasm functions call each other with direct branches, so two functions in the same code space must be within a 32-bit signed distance of each other. On PPC64, where branches are 26 bits, the limit drops to 32 MB. This is why a module can be spread over multiple code spaces with a far-jump table between them.
Committing is separate from reserving, and the committed total is capped process-wide:
// src/wasm/wasm-code-manager.cc:911
if (commit_start < commit_end) {
for (base::AddressRegion split_range : SplitRangeByReservationsIfNeeded(
{commit_start, commit_end - commit_start}, owned_code_space_)) {
code_manager->Commit(split_range);
}
committed_code_space_.fetch_add(commit_end - commit_start);
// Committed code cannot grow bigger than maximum code space size.
DCHECK_LE(committed_code_space_.load(),
v8_flags.wasm_max_committed_code_mb * MB);
}kMaxCommittedWasmCodeMB is 4095 — just under 4 GB — and the comment
above it is explicit about why:
// src/common/globals.h:502
// Total wasm code space per engine (i.e. per process) is limited to make
// certain attacks that rely on heap spraying harder.
Stage 6: the code cache, and how it is invalidated
V8 does have a wasm code cache, but it lives in
src/wasm/wasm-serialization.cc, not in a file called
wasm-code-cache.cc. The header is the interesting part:
// src/wasm/wasm-serialization.cc:140
void WriteHeader(Writer* writer, WasmEnabledFeatures enabled_features,
const CompileTimeImports& compile_imports) {
DCHECK_EQ(0, writer->bytes_written());
writer->Write(SerializedData::kMagicNumber);
writer->Write(Version::Hash());
writer->Write(CpuFeatures::SupportedFeatures().ToIntegral());
writer->Write(FlagList::Hash());
writer->Write(enabled_features.ToIntegral());
...Five uint32_ts before any actual code: a magic number, a V8 version
hash, the CPU features the binary was compiled for, the flag hash,
and the enabled wasm features. Deserialization refuses anything whose
header does not match byte for byte:
// src/wasm/wasm-serialization.cc:1206
if (!IsWasmCodegenAllowed(isolate, isolate->native_context())) return {};
if (!HeaderMatches(data, enabled_features, compile_imports)) return {};HeaderMatches does not compare hashes loosely — it recomputes the
header and compares the bytes:
// src/wasm/wasm-serialization.cc:1190
const CompileTimeImports& compile_imports) {
size_t header_size = MeasureHeader(compile_imports);
if (data.size() < header_size) return false;
base::SmallVector<uint8_t, 32> current_header(header_size);
Writer writer(base::VectorOf(current_header));
WriteHeader(&writer, enabled_features, compile_imports);
DCHECK_EQ(header_size, writer.bytes_written());
return base::VectorOf(current_header) == data.SubVector(0, header_size);
}That is the invalidation mechanism in one sentence: the cache is valid only if recompiling it right now would produce the same header. Change the V8 version, move to a machine with a different CPU feature set, or change any flag that affects codegen, and the cached code is meaningless. The flag hash is computed by hashing every flag that is not at its default value, with a deliberate list of exclusions:
// src/flags/flags.cc:634
// We want to be able to flip --profile-deserialization without
// causing the code cache to get invalidated by this hash.
if (flag.PointsTo(&v8_flags.profile_deserialization)) continue;
// Skip v8_flags.random_seed and v8_flags.predictable to allow predictable
// code caching.
if (flag.PointsTo(&v8_flags.random_seed)) continue;
if (flag.PointsTo(&v8_flags.predictable)) continue;The flags that do matter are the ones that change code layout:
--liftoff, --wasm-tier-up, the SIMD and GC feature flags, and so on.
What gets serialized is not “the whole module” but a decision per function:
// src/wasm/wasm-serialization.cc:483
void NativeModuleSerializer::WriteCode(
const WasmCode* code, Writer* writer,
const NativeModule::CallIndirectTargetMap& function_index_map) {
if (code == nullptr) {
writer->Write(kLazyFunction);
return;
}
DCHECK_EQ(WasmCode::kWasmFunction, code->kind());
// Only serialize TurboFan code, as Liftoff code can contain breakpoints or
// non-relocatable constants.
if (code->tier() != ExecutionTier::kTurbofan) {
// We check if the function has been executed already. If so, we serialize
// it as {kEagerFunction} so that upon deserialization the function will
// get eagerly compiled with Liftoff (if enabled). If the function has not
// been executed yet, we serialize it as {kLazyFunction}, and the function
// will not get compiled upon deserialization.
Three cases: never executed → kLazyFunction, stay lazy. Executed but
only Liftoff → kEagerFunction, recompile eagerly with Liftoff on
deserialization. Optimized → kTurboFanFunction, serialize the actual
machine code.
Liftoff code is never cached, and the reason is in the comment above: Liftoff code can contain breakpoints and non-relocatable constants. Compiling it is about as fast as reading it from a cache, so there is nothing to gain.
And the tiering budget is cached alongside, so that a function that had already burned half its budget does not get a fresh budget after a reload:
// src/wasm/wasm-serialization.cc:505
writer->Write(budget == static_cast<uint32_t>(v8_flags.wasm_tiering_budget)
? kLazyFunction
: kEagerFunction);Stage 7: when does V8 decide to cache?
The cache is not written eagerly. V8 emits a caching event when enough optimized code has accumulated:
// src/wasm/module-compiler.cc:3980
if (v8_flags.wasm_dynamic_tiering && outstanding_baseline_units_ == 0 &&
bytes_since_last_chunk_ > 0 &&
static_cast<size_t>(v8_flags.wasm_caching_threshold) <=
bytes_since_last_chunk_) {--wasm-caching-threshold is 1000 (bytes of top-tier code), and there
is a hard threshold of 1 000 000 that bypasses the timeout. The timeout
logic is the nice part: if code is still being produced, V8 waits; if
the module has gone quiet, it caches immediately. A small module that
never reaches 1000 bytes of optimized code may never be cached at all.
The event is the embedder’s cue. V8 itself does not own a disk cache for
wasm — the browser does. The events are wasm.CompilationFailed,
wasm.BaselineFinished and wasm.CompilationChunkFinished, and the
embedder serializes the NativeModule itself when it sees the last one.
The async and streaming paths
WebAssembly.compile(bytes) and WebAssembly.compileStreaming(response)
are not the same code path, and the difference is more than cosmetic.
compile hands the whole byte array to an AsyncCompileJob, which is a
three-step state machine: decode on a background thread, prepare the
native module on a background thread, finish on the foreground thread.
// src/wasm/module-compiler.cc:2841
class AsyncCompileJob::DecodeModule : public AsyncCompileJob::CompileStep {
public:
void RunInBackground(AsyncCompileJob* job) override {
DisallowHandleAllocation no_handle;
DisallowHeapAllocation no_allocation;
// Decode the module bytes.
TRACE_COMPILE("(1) Decoding module...\n");
...
ModuleResult result = DecodeWasmModule(
enabled_features, job->wire_bytes_.module_bytes(), true,
&job->delayed_counters_, &job->decoding_metrics_event_,
DecodingMethod::kAsync, &job->detected_features_);compileStreaming instead creates a StreamingDecoder, which is a
byte-level state machine over the incoming chunks:
// src/wasm/streaming-decoder.cc:71
// The decoding of a stream of wasm module bytes is organized in states. Each
// state provides a buffer to store the bytes required for the current state,
// information on how many bytes have already been received, how many bytes
// are needed, and a {Next} function which starts the next state once all
// bytes of the current state were received.
//
// The states change according to the following state diagram:
//
// Start
// |
// v
// DecodeModuleHeader
// | _________________________________________
// | | |
// v v |
// DecodeSectionID --> DecodeSectionLength --> DecodeSectionPayload
// A |
// | | (if the section id == code)
// | v
// | DecodeNumberOfFunctions -- > DecodeFunctionLength
// | A |
// | | v
// | (after all functions were read) | DecodeFunctionBody
// ------------------------------------- DecodeFunctionBody
The difference that matters: with streaming, V8 can start compiling function bodies before the whole module has arrived. It also means the module’s identity is known early — the cache is keyed on a hash of the sections up to the code section:
// src/wasm/wasm-engine.cc:456
size_t NativeModuleCache::PrefixHash(base::Vector<const uint8_t> wire_bytes) {
// Compute the hash as a combined hash of the sections up to the code section
// header, to mirror the way streaming compilation does it.
So a streaming compile of a module whose prefix matches one already in the cache does not compile the module twice; it waits for the other thread to finish, or — in single-threaded mode — deliberately compiles it again and lets the cache de-duplicate the result.
Trade-offs
What it buys. Startup time scales with what you actually call, not with what the module contains. A 30 MB module with 8000 functions that calls three of them pays for three functions. Tiering means the 8000 never-called functions cost nothing but a few bytes of jump table.
What it costs.
- A lazy call is not a fast call. The first call to a function goes through a builtin, a compile, and a jump-table patch. The call is dramatically slower than any subsequent one.
- The tiering state is per-instance, the compiled code is per-module. Two instances of the same module share the machine code but not the tiering budgets, so one hot instance can promote the code for a cold one.
- The cache header is a blunt instrument. Any change to any codegen-affecting flag invalidates every cached wasm module in the process. For a browser that means a flag change is a cold start.
- Code space is committed lazily but reserved eagerly. A module that compiles 100 bytes of code can still reserve a code space sized for the whole module.
The bottom line
The wasm pipeline is a deferral machine. The decoder produces offsets, not code. The first call to a function compiles it, in one pass, with a compiler that has no IR at all. A budget burned down by the generated code itself decides when the optimizing compiler takes over, and it does so on a background thread while the Liftoff code keeps running. The resulting machine code lives outside the JS heap, in a code space whose size is bounded by branch-distance limits on one end and heap-spraying resistance on the other, and it can be serialized — but only with a header that says exactly which V8, which CPU and which flags produced it.
The mental model to keep: the wire bytes are the source code, the compile is per function and on demand, and the cache is only valid if recompiling would give the same bytes.
The thing people get wrong
Two misconceptions, both common.
The first is that “the wasm module is compiled when you call
new WebAssembly.Module()”. It is not, unless lazy compilation is
disabled. What happens at new WebAssembly.Module() is a decode and
a validation; the machine code for a function appears when that
function is first called. If you want to see this, compile a module with
a thousand functions, instantiate it, and watch the code size stay at
zero until you call something.
The second is that there is a file called wasm-code-cache.cc. There
is not, and there has not been for a long time. The wasm code cache is
src/wasm/wasm-serialization.cc (the serializer), the caching
policy is src/wasm/module-compiler.cc (the events), and the disk
cache itself belongs to the embedder, not to V8. V8 hands you a
NativeModule and an event; what you do with them is your business.