Facebook Pixel

Virtual Functions and Vtables

Polymorphic dispatch in C++ often looks like a clean abstraction:

std::vector<Strategy*> strategies;

for (Strategy* s : strategies) {
    s->on_update(update);
}

The loop passes market updates to different trading models without knowing their concrete types. At compile time, the compiler only sees the base Strategy interface. To invoke the derived implementation at runtime, it relies on an indirect call mediated by a hidden pointer inside the object.

Vtables and object layout

For every class with virtual methods, the compiler emits a virtual method table (vtable). A vtable is a static array of function pointers stored in the binary's read-only data segment (.rodata). Each virtual method corresponds to a fixed index in that table.

When a class defines or inherits virtual methods, the compiler inserts a hidden pointer (vptr) as the object's first member. This points to the class's vtable.

// Approximate memory layout generated by the compiler:
struct MomentumStrategy {
    const void* __vptr;  // Points to MomentumStrategy's vtable in .rodata
    int window;          // Declared data member
};

On a 64-bit target, __vptr adds 8 bytes to the object and forces 8-byte alignment. If a class has only a 4-byte integer member, introducing a virtual function triples its size from 4 bytes to 12 (padded to 16 bytes). For a small number of strategy objects this overhead is negligible, but for collections of millions of small entities, vptrs add measurable heap and cache footprint.

What happens during a virtual call

Calling s->on_update(update) requires the processor to resolve two addresses before making the jump:

  1. Dereference s to read s->__vptr from the object's first 8 bytes.
  2. Index into the vtable at the fixed offset for on_update and load the function pointer.
  3. Perform an indirect call (call *%reg) to that loaded address.
A virtual call reads the vptr from the object, looks up the function pointer in the vtable, and jumps to the derived implementation.
Virtual dispatch follows the object's vptr to the class vtable, then reads the method slot to find the target function.

In assembly on x86-64, this sequence looks roughly like:

movq  (%rdi), %rax        ; Load vptr from object (*s)
movq  (%rax), %rax        ; Load function pointer from vtable slot 0
callq *%rax               ; Indirect call to derived method

The memory loads to fetch the vptr and vtable entry usually hit the L1 data cache if the strategy object and vtable were recently accessed. The larger latency penalty comes from the indirect call instruction itself and the optimization opportunities it closes off.

Branch target prediction and inlining loss

Like any indirect call, a virtual call relies on the CPU's branch target buffer (BTB) to predict the target address.

If your loop iterates over identical objects—for example, hundreds of MomentumStrategy instances—the BTB quickly learns the target address and branches without stalling. But if the array alternates between MomentumStrategy, MeanReversionStrategy, and MarketMakerStrategy, the target address changes from one iteration to the next. That leads to repeated BTB mispredictions, with each miss flushing the pipeline and costing 15 to 20 clock cycles.

However, the dominant performance penalty in tight loops is rarely the vtable lookup or even the indirect jump. It is the loss of compiler inlining.

Because the compiler cannot prove which derived class will run at a given call site, it cannot replace the call with the function body. The compiler must assume the called function could read or overwrite any accessible state. That forces register spilling before the call, prevents constant propagation from the loop into the strategy logic, and blocks auto-vectorization across iterations.

Avoiding virtual dispatch on the hot path

When you need polymorphic behavior in performance-critical loops, several design patterns preserve inlining while keeping interfaces clean.

1. Template-based static dispatch

If the strategy type is known at compile time, use templates rather than base-class pointers:

template <typename StrategyImpl>
void run_strategy(StrategyImpl& strategy, const std::vector<MarketUpdate>& updates) {
    for (const auto& u : updates) {
        strategy.on_update(u); // Direct call: fully inlined by the compiler
    }
}

The compiler generates specialized code for each concrete strategy type. The call to on_update becomes a direct call and can be inlined directly into the loop.

2. std::variant and std::visit

If you must store mixed strategy types in a single container, std::variant provides closed-set polymorphism without heap pointers or vtables:

using StrategyVariant = std::variant<MomentumStrategy, MeanReversionStrategy>;
std::vector<StrategyVariant> strategies;

for (auto& s : strategies) {
    std::visit([&](auto& concrete) { concrete.on_update(update); }, s);
}

std::visit compiles to a jump table or a small switch over the variant's type index. Inside each branch of the switch, the compiler knows the exact concrete type and can inline the call.

3. Inverting the loop

If you must use virtual classes, invert the hierarchy so the virtual call happens once per batch rather than once per market update:

// Slow: virtual dispatch on every update
for (const auto& u : updates) {
    strategy->on_update(u);
}

// Fast: one virtual call per batch, inner loop is direct and inlinable
strategy->process_batch(updates);

Checkpoint

1)

In a tight loop over std::vector<Strategy*>, why is a virtual call s->on_update(u) usually much slower than a templated direct call, beyond the two extra memory loads to find the function pointer?

In interviews

  • "What does a compiler generate for a virtual function call?" → "It loads the object's vptr (stored at offset zero), reads the function pointer at a fixed index in the class vtable, and issues an indirect call to that address."
  • "Why are virtual functions avoided in high-frequency trading hot paths?" → "An indirect call through a vtable cannot be inlined by the compiler, acting as an optimization barrier that forces register spills and prevents vectorization. In addition, if consecutive objects in a container have different types, the branch target buffer frequently mispredicts the jump address."
  • "How does adding a virtual method affect object memory layout?" → "The compiler adds an 8-byte vptr as the first field of the object, which can also increase struct padding due to alignment requirements. The class also gets a single shared vtable in the .rodata section."
  • "How can you achieve runtime polymorphism without virtual functions?" → "Use std::variant with std::visit for closed type sets, which dispatches through a tag switch where each branch can be inlined, or use templates and CRTP when types can be resolved at compile time."