Back to course

The Code Section

22 minadvancedModule 3: The BodyContinues from wasm-globals
22 min · Module 3: The Body · Algorithms

Why This Matters

Two sections earlier the format declared functions without giving them bodies: the type section says a signature and the function section says which signature each function has. Neither says what the function does. That is the code section's whole job, and it is the first part of the format you have reached that contains instructions rather than declarations.

The split exists for a reason worth knowing. A module's declarations are a small, highly-compressible set of facts that a validator wants to check exhaustively — every type, every index, every limit — before it trusts anything. Its code is a large stream of bytes that the same validator has to walk in full anyway. Separating them means a reader can load and check the declarations once, and then stream the code.

What makes the code section interesting is its local declaration encoding, which is the one place in the format where a compression trick changes the shape of the data in a way that is easy to misread. A function with seven locals does not have seven local entries. It has a group count, and the groups are run-length encoded — and the compiler merges adjacent locals of the same type, so the number of groups is not the number of declarations in your source either.

A Simple Model

Two levels of framing. The section declares how many bodies; each body declares its own size:

code section: count how many function bodies follow body, repeated `count` times: size this body's size in bytes locals the run-length local declaration table instructions the instruction stream

Three fields per body, and the middle one is where the lesson is. The size field is what makes the code section seekable in the same way the section framing is: a reader can find the fifth function's body without decoding the first four, because each body says how long it is.

The local declaration table

This is the part that surprises people. Locals are not listed one at a time. They are listed in groups, and each group is a count followed by a type:

locals: group_count how many groups follow group, repeated `group_count` times: count how many locals of the next type valtype the type they all are

So a function with ten i32 locals costs three bytes, not eleven. And here is the part that catches people out, verified from a real module: adjacent groups of the same type are merged. Write

(local i32) (local i32)

and the compiler emits one group of 2 × i32, not two groups of 1 × i32. A reader that assumes one group per source declaration will count twice as many groups as there are, and then read the second one out of the middle of the instruction stream.

Why the groups are ordered, and why that matters. The encoding is run-length over a consecutive run, not a histogram. (local i32) (local f32) (local i32) is three groups — 1×i32, 1×f32, 1×i32 — because the two i32 declarations are not adjacent. And the order of the groups is the order the locals are numbered: local 0 is the first local of the first group, then local 1 is the second local of that group if the group has two, and so on. So local.get 1 in a function whose first group is 3 × i32 means the second of those three, and a reader that numbers groups rather than locals will be off by a varying amount that depends on the group sizes before it.

The Actual Detail

A function with locals of four different types, written to make the grouping visible:

(func $locals (param i32) (result i32)
  (local i32) (local i32) (local i64) (local f32) (local i32 i32 i32)
  (local.get 1))

Six local declarations in the source, of four distinct types, in three runs. Read the body out of the module:

body size = 12
  0c                       0 locals groups? no --
  04                       FOUR groups
  02 7f                    group 0: 2 locals of 0x7f (i32)
  01 7e                    group 1: 1 local  of 0x7e (i64)
  01 7d                    group 2: 1 local  of 0x7d (f32)
  03 7f                    group 3: 3 locals of 0x7f (i32)
  20 01                    local.get 1
  0b                       end

Now compare the source with the encoding, and every design decision is visible:

SourceEncoded asWhy
(local i32)(local i32)one group 2 × i32adjacent and identical, so run-length encoded into one
(local i64)one group 1 × i64different type, so the run breaks
(local f32)one group 1 × f32different type, so the run breaks
(local i32 i32 i32)one group 3 × i32three in a row, one group

Four groups, not six declarations, and the two i32 locals at the front have been absorbed into the first group. Seven locals total — two, one, one, three — across four groups, in eleven bytes of table.

And the crucial detail: the groups were not reordered to make a better encoding. The first group is 2 × i32 even though there is also a 3 × i32 group later, and the compiler could have emitted 5 × i32 if the i64 and f32 were not in between. It did not, because the group order is the local numbering. Local numbering is part of the function's interface — every local.get and every local.set in the body refers to it — so the encoding is obliged to preserve the order rather than optimise it.

That is worth pausing on, because it is the difference between an encoding that is merely compact and one that is also correct. A toolchain that sorted the groups by type would save a byte here and there and produce a module where local.get 1 means something different. The compression is applied within a run, never across one.

What the instruction stream looks like

Two more functions from the same module, one with no locals and one with a mismatched pair, to show the frame:

$nolocals   body size = 4
  00                    0 local groups
  41 07 0b              i32.const 7 ; end

$multigrp   body size = 9
  02                    2 local groups
  01 7f                 1 x i32
  01 7c                 1 x f64
  41 01 1a 0b           i32.const 1 ; drop ; end

$t0        body size = 2
  00                    0 local groups
  0b                    end

Three things to read off that. The group count is present even when it is zero — a function with no locals still spends a byte saying so, and a reader that skips it will read 0x41 as the group count. The body size counts the local table, not just the instructions, so a reader that treats it as an instruction-stream length will stop in the middle of the declarations. And end is 0x0b in every case — it terminates both a body and a constant expression, which is why the same walker decodes both.

Note also what the body size is not: it is not the number of instructions, and it does not include the size field itself. $locals's body is 12 bytes: 1 for the group count, 8 for four groups, and 3 for local.get 1 and end. The size byte that said 12 is not one of those twelve.

A Real Example

Resolving a local index, which is the operation the encoding exists to make cheap, and the arithmetic that goes wrong if you number groups instead of locals:

given a local index N, walk the groups accumulating: index_of_this_group = 0 for each group (count, valtype): if N < index_of_this_group + count: this is the group the local is local number (count - (index_of_this_group + count - N)) = N - index_of_this_group slots into the group its type is valtype stop index_of_this_group += count

Run it on $locals, whose groups are 2×i32, 1×i64, 1×f32, 3×i32:

N=0 group 0 (2 x i32), first i32 <- a declared local N=1 group 0 (2 x i32), second i32 <- local.get 1 in the source N=2 group 1 (1 x i64), the i64 N=3 group 2 (1 x f32), the f32 N=4 group 3 (3 x i32), first of the trailing three N=5 group 3, second N=6 group 3, third

So the local numbering is a flattening of the group list, and N indexes that flattening rather than the groups. Now the failure mode, and it is a good one because it is mostly right:

That last point is the interesting one. A number of local types being wrong is not something a size check can see, and it is not something a section walk can see. It surfaces only when a type-aware consumer — a validator, or a JIT that specialises on types — reads the body and finds an i32 operation on a local declared f64. Which is another way of saying that the run-length encoding is not merely a space optimisation: it makes the local type table a compressed representation, and every consumer has to decompress it before it can type anything.

There is one more consequence of the encoding being per-body rather than per-module, and it is a design choice rather than a limitation. Two functions in the same module that both declare (local i32) (local i32) each carry their own 2 × i32 group. There is no shared local table, because there could not be: locals belong to a function's frame, and a frame exists only when that function is called. Local declarations are the one piece of per-function metadata that cannot be factored out of the function, and the format treats them that way.

Try It Yourself

$ wasm-objdump -x body.wasm | sed -n '/^Code/,$p'
$ python3 courses/wasm/assets/samples/wasm_decode.py --hex body.wasm

Check Your Understanding

Without looking back: a function's local table is 04 02 7f 01 7e 01 7d 03 7f. How many groups, how many locals, what are their types in order, and what would a reader that assumed one group per source declaration conclude?

Apply It

You are writing an interpreter. A function takes one i32 parameter and your test suite passes everything. One function in one module reports the wrong value for some inputs, and the module is otherwise fine: the right arity, the right body size, and every other function correct. The function declares (local i64) and (local i32), so its local table is 02 01 7e 01 7f. What is the most likely defect, and what would the evidence look like if you were wrong?

The general habit: when one function in a large module behaves differently, look for what makes it structurally different rather than what makes it numerically unusual. Group count, local count and type count are three different numbers, and conflating them is silent.

Connect

The run-length local table is the first genuinely clever encoding in the format, and it is worth comparing with the two other run-length schemes in this collection. The COFF symbol table uses an auxiliary record to mean "more symbols of the same kind follow" — a NumberOfAuxSymbols count, exactly analogous to a local group. And ELF's group and symbol tables use the same trick for a different reason, to avoid a second pass.

What WebAssembly's version does that COFF's does not is compress the type as well as the repetition. COFF's auxiliary records distinguish a section symbol from a weak external from a file symbol; they do not say "ten more integers follow". So the WebAssembly scheme is a more aggressive encoding, and the cost is the one this concept keeps returning to: every consumer must decompress before it can do anything, and the compression is only valid within a run.

The order-preservation point connects to the type section and to the COMDAT checksum. In both cases the format is optimising bytes while guaranteeing that an unrelated property stays fixed — here the local numbering, there the fact that two identical sections really were identical. A format that let an encoding change what a number means would not be a format; the compression is only allowed to be invisible, and a producer that reorders to compress better has produced a different module.

One thing is still missing from the body, and it is the thing the next concept covers: the instruction stream itself. local.get 1 and end have appeared in this concept only as bytes to be skipped over, and an opcode encoding is the one part of the format where a compact scheme would be most valuable and most dangerous — a dense one-byte opcode space means no room for extensions, which is exactly why the format kept the top four bits for a prefix and spent the recent proposals making that prefix do more work.

Next: Instruction Encoding.