Implementing Virtual Dispatch in Wasm

This guide answers one task: understand what a virtual method call becomes in WebAssembly, so that the performance characteristics of polymorphic code — and the traps it can produce — are predictable rather than mysterious.

Prerequisites

  • [ ] A C++ or Rust codebase using virtual methods or trait objects.
  • [ ] wasm-objdump for reading the generated code.
  • [ ] Familiarity with call_indirect — see calling function pointers.
  • [ ] A willingness to read a little disassembly.

What a vtable becomes

A C++ object with virtual methods carries a pointer to a vtable: an array of function pointers, one per virtual method, shared by every instance of the class. In WebAssembly the same structure exists, with one substitution — the function pointers are table indices.

object in linear memory
  +0   vtable pointer  (an offset into linear memory)
  +4   field a
  +8   field b

vtable in linear memory
  +0   index of ~Shape       → table[12]
  +4   index of area         → table[13]
  +8   index of perimeter    → table[14]

Everything is in linear memory except the functions themselves, which live in the module and are reached through the table.

Two loads and an indirect call A virtual call loads the vtable pointer from the object, loads the method's index from the vtable, and performs an indirect call through the function table with a type check. object vtable ptr fields load 1 vtable 13 (area) 14 (perimeter) load 2 function table 13 → Circle::area call Two dependent loads before the call is why virtual dispatch is slower than a direct call — the second load cannot start until the first completes. Identical to a native build, except that the final step is a checked table lookup rather than a jump to an address.

Reading the generated code

A virtual call compiles to a recognisable sequence, and finding it in the disassembly makes the structure concrete.

struct Shape { virtual double area() const = 0; virtual ~Shape() = default; };
struct Circle : Shape { double r; double area() const override { return 3.14159 * r * r; } };

double total(Shape** shapes, int n) {
  double sum = 0;
  for (int i = 0; i < n; i++) sum += shapes[i]->area();   // virtual call
  return sum;
}
emcc shapes.cpp -O2 -sSTANDALONE_WASM --no-entry -o shapes.wasm
wasm-objdump -d shapes.wasm | grep -B6 -A1 call_indirect | head -20
  0003f1: 28 02 00     | i32.load offset=0        ;; load the vtable pointer
  0003f4: 28 02 04     | i32.load offset=4        ;; load the method index from the vtable
  0003f7: 20 01        | local.get 1              ;; the object, as the this argument
  0003f9: 11 03 00     | call_indirect (type 3)

Four instructions: two loads, the receiver, the call. That sequence is the whole of virtual dispatch, and recognising it in a profile tells you immediately that a hot loop is dispatching rather than computing.

Rust trait objects

A dyn Trait reference is a fat pointer: the data pointer and the vtable pointer travel together rather than the vtable pointer being stored in the object.

trait Shape { fn area(&self) -> f64; }

struct Circle { r: f64 }
impl Shape for Circle { fn area(&self) -> f64 { std::f64::consts::PI * self.r * self.r } }

fn total(shapes: &[Box<dyn Shape>]) -> f64 {
    shapes.iter().map(|s| s.area()).sum()      // virtual dispatch through the fat pointer
}

The practical difference is that the vtable pointer is not in the object’s memory, so the first load disappears for a call through a &dyn Trait already in a local — the vtable pointer is right there in the second half of the fat pointer. For a call through a Box<dyn Trait> in a collection, both loads happen as in C++.

Rust’s impl Trait and generics monomorphise instead, producing a direct call with no dispatch at all, which is why trait-heavy Rust is often faster than the equivalent C++ — the dispatch is compiled away rather than executed.

Devirtualisation, and why it often does not happen

Where the compiler can prove the concrete type, it replaces the indirect call with a direct one. That removes both loads and the type check, and enables inlining.

Circle c{2.0};
double a = c.area();          // devirtualised: the type is known
Shape* s = &c;
double b = s->area();         // often devirtualised too, within one function

What defeats it is the usual thing: the object arriving from somewhere the compiler cannot see — a vector of base pointers, a factory function, a plugin. Whole-program optimisation with -flto helps substantially, because it lets the compiler see across translation units, and it is one of the larger wins available for polymorphic C++ compiled to WebAssembly.

Where a hot loop dispatches over a collection that is homogeneous in practice, sorting by type and hoisting the dispatch out of the loop is the standard manual fix — it is the same optimisation, done by hand, because the compiler could not prove what you know.

Dispatch per element, or per group A loop calling a virtual method on each element dispatches once per element. Sorting the collection by concrete type and dispatching once per group lets the inner loop call directly and be inlined. dispatch per element call call call call two loads and a check each, no inlining sorted by type dispatch once direct, inlinable, vectorisable inner loop often several times faster Worth doing only where a profile shows the dispatch mattering — for most polymorphic code it does not, and the restructuring costs clarity.

Diagnosing a dispatch that goes wrong

When a virtual call misbehaves, the symptom is usually one of three things, and each points somewhere specific.

A signature mismatch trap means the index loaded from the vtable refers to a function of the wrong type. In a correct program that cannot happen, so it indicates memory corruption: the object’s vtable pointer is wrong, or the vtable itself has been overwritten. Look for a buffer overrun writing into the object, a use-after-free, or an object constructed in memory that was not zeroed.

An undefined element trap means the index is out of range or the slot is null — typically an uninitialised object whose vtable pointer is zero, which then reads garbage as an index.

The wrong method running without any trap is the subtlest, and it happens when the corrupted vtable pointer lands on a valid vtable for a different class with a compatible layout. The signature matches, the call succeeds, and the behaviour is wrong. This is where WebAssembly’s checks stop helping.

# a development build that catches the write rather than the call
emcc shapes.cpp -O1 -g -sASSERTIONS=2 -sSAFE_HEAP=1 -o shapes.js

SAFE_HEAP instruments every memory access and reports the out-of-bounds or misaligned write at the point it happens, rather than leaving you to infer it from a trap several calls later. It is far too slow for production and is the fastest route from a mysterious dispatch failure to the line responsible.

Expected output

A profile of a dispatch-heavy loop shows the cost, and the restructured version shows the difference:

1,000,000 shapes, mixed types
  virtual dispatch per element     14.2 ms
  sorted by type, direct calls      3.8 ms
  monomorphic (all Circle)          2.1 ms

The third row is the ceiling: with one concrete type the engine’s speculation makes the indirect call nearly free, which is why a dispatch site that always sees the same type costs much less than one that alternates.

A vtable is a row of indices The object's first word points at its vtable. The method's position within that vtable gives a table index, and the call is an ordinary indirect call from there. object pointer first word is vtable vtable row of table indices method slot fixed offset per method call_indirect type-checked call Every implementation of a method must share one signature, or the type check rejects the call at runtime. Two loads and an indirect call is the whole cost — comparable to virtual dispatch on a native target. Devirtualising a hot call by hand is worth it only where the profile says the site is genuinely hot.

Gotchas

  • indirect call signature mismatch from a corrupted object. A wrong vtable pointer selects a mismatched function; the trap is the memory-safety property working.
  • Assuming devirtualisation happened. Check the disassembly rather than hoping; -flto changes the answer substantially.
  • Calling a virtual method from a constructor. The vtable pointer may not yet be the derived one, as in native C++ — the semantics are identical and equally surprising.
  • Deleting through a base pointer without a virtual destructor. Undefined behaviour here as everywhere; in WebAssembly it usually corrupts the allocator rather than crashing immediately.
  • Optimising dispatch without profiling. Two loads and a check is a few nanoseconds; it matters in a hot loop and nowhere else.
  • Comparing vtable pointers across builds. They are offsets assigned at compile time and change freely.

Performance note

A virtual call in WebAssembly costs roughly 4–7 nanoseconds more than a direct one: two dependent loads plus the indirect call’s bounds and type check. Over a million calls that is 14.2 ms against 3.8 ms for direct calls to the same bodies — a factor of nearly four, which is entirely dispatch overhead plus the inlining that dispatch prevents. The same ratio appears in native builds, so this is not a WebAssembly penalty but the ordinary cost of polymorphism.

Frequently Asked Questions

Is virtual dispatch more expensive in WebAssembly than natively? Slightly, because of the table bounds and type check that a native indirect call does not perform. The difference is a nanosecond or two; the dominant cost in both is the dependent loads and the lost inlining.

Should I avoid virtual methods in a WebAssembly build? No more than you would natively. Use them where polymorphism is the right design, and measure before restructuring a hot loop — the overhead is real and small, and clarity is usually worth more.

Does the type check catch a corrupted vtable? It catches a mismatched signature, which a corrupted pointer usually produces. It does not catch a pointer that happens to land on a valid vtable of the same shape, so it is a safety net rather than a guarantee.

How does Wasm GC change this? For languages targeting it, dispatch can use engine-managed structures and typed references, which allows more precise checks and potentially better optimisation. For C++ compiled to linear memory, nothing changes.

Understanding the four-instruction sequence is enough to read any polymorphic WebAssembly in a profiler.

← Back to Tables & Dynamic Linking