Rust Performance Engineering

Static vs. Dynamic Dispatch

When invoking a trait method in Rust, dispatch determines how the program locates the concrete function address to execute: static dispatch (impl Trait / <T: Trait>) resolves the exact function at compile time via monomorphization, whereas dynamic dispatch (&dyn Trait / Box<dyn Trait>) resolves the function at runtime by following a pointer into a virtual method table (vtable).

1. Static Dispatch (`fn run<T: Handler>(h: &T)`):
   h.handle()  ===[ Resolved at Compile Time ]===>  Direct call (or inlined!) to `FastHandler::handle`

2. Dynamic Dispatch (`fn run(h: &dyn Handler)`):
   `&dyn Handler` Fat Pointer (16 Bytes)
   +------------------+------------------+
   | data_ptr (*const)| vtable_ptr       |
   +------------------+------------------+
                              |
                              v  [ Static Vtable in .rodata ]
                        +----------------------------+
                        | drop_in_place: fn(*mut ()) |
                        | size: usize                |
                        | align: usize               |
                        | handle: fn(*const ())      | ---> Indirect jump at runtime!
                        +----------------------------+

Comparing Static Dispatch, Dynamic Dispatch, and Enum Dispatch

In Rust, there is also a third dispatch strategy—enum dispatch—when the set of possible concrete types is closed and known at compile time:

Dispatch Strategy Syntax Inlining & SIMD Across Call? Heterogeneous Collection (Vec<...>)? Code Size Impact
Static Dispatch fn process<T: Stage>(s: &T) Yes (Full inlining & auto-vectorization) No (Vec<T> holds only 1 concrete type T) Duplicates generic body per type T
Dynamic Dispatch fn process(s: &dyn Stage) No (Indirect call through vtable blocks inlining) Yes (Vec<Box<dyn Stage>> holds any implementer, including external plugin types!) Single compiled function body
Enum Dispatch enum AnyStage { A(StageA), B(StageB) } Yes (Direct match branches are inlined by LLVM) Yes (Vec<AnyStage> stores values inline without per-item Box heap allocation!) Compact single match dispatch

Why Dynamic Dispatch Mainly Costs Missed Optimizations, Not Just the Jump

Modern CPU branch predictors (Indirect Branch Predictors) can predict a single vtable pointer jump in 1–2 nanoseconds when the same concrete type is called repeatedly. The primary performance cost of &dyn Trait inside a tight inner loop (executed millions of times per second) is secondary optimization blockage:

  1. Because the compiler does not know which concrete function lies behind the vtable pointer, it cannot inline the method body into the loop.
  2. Without inlining, the compiler cannot perform constant propagation, Scalar Replacement of Aggregates (SROA), or SIMD loop vectorization across the method call!
pub trait Transform {
    fn apply(&self, x: i64) -> i64;
}

pub struct Scale(pub i64);
impl Transform for Scale {
    #[inline]
    fn apply(&self, x: i64) -> i64 {
        x * self.0
    }
}

pub struct Offset(pub i64);
impl Transform for Offset {
    #[inline]
    fn apply(&self, x: i64) -> i64 {
        x + self.0
    }
}

/// 1. Static Dispatch: LLVM inlines `t.apply(x)` and vectorizes the entire slice loop!
pub fn map_sum_static<T: Transform>(data: &[i64], t: &T) -> i64 {
    data.iter().map(|&x| t.apply(x)).sum()
}

/// 2. Dynamic Dispatch: Calls `t.apply(x)` through the `&dyn Transform` vtable on every element.
pub fn map_sum_dynamic(data: &[i64], t: &dyn Transform) -> i64 {
    data.iter().map(|&x| t.apply(x)).sum()
}

/// 3. Enum Dispatch: Supports heterogeneous pipelines in a single contiguous `Vec<Op>`
///    without `Box` heap allocations or vtable indirection!
pub enum Op {
    Scale(Scale),
    Offset(Offset),
}

impl Transform for Op {
    #[inline]
    fn apply(&self, x: i64) -> i64 {
        match self {
            Op::Scale(s) => s.apply(x),
            Op::Offset(o) => o.apply(x),
        }
    }
}

fn main() {
    let data = [1i64, 2, 3, 4, 5];
    let scaler = Scale(10);

    let s_res = map_sum_static(&data, &scaler);
    let d_res = map_sum_dynamic(&data, &scaler);
    assert_eq!(s_res, d_res);

    // Heterogeneous pipeline using Enum Dispatch on a contiguous array:
    let pipeline = [Op::Scale(Scale(2)), Op::Offset(Offset(5))];
    let final_val = pipeline.iter().fold(10i64, |acc, op| op.apply(acc));

    println!("Static/Dynamic sum: {s_res}, Enum pipeline(10) = {final_val}");
}
Static/Dynamic sum: 150, Enum pipeline(10) = 25

Choosing the Right Dispatch Strategy

  • Use Static Dispatch (<T: Trait>) for hot data-processing loops, per-pixel/per-sample/per-byte callbacks, and small generic wrappers.
  • Use Enum Dispatch when you have a fixed, known set of variants that need to live together in a contiguous Vec<Enum> with maximum cache locality and inlining.
  • Use Dynamic Dispatch (&dyn Trait / Box<dyn Trait>) for high-level architectural boundaries (such as I/O backends, plugin registries, HTTP middleware routers, or UI widgets) where the call happens once per request or batch and fast compile times / smaller binary sizes outweigh a few nanoseconds of dispatch.