Skip to content
rusthacks.dev
Generics

Static Versus Dynamic Dispatch in Rust and the Real Cost

Static dispatch resolves the call at compile time and dynamic dispatch looks it up in a vtable at run time. See the layout, the cost and when it matters.

8 min read
Summarize in AI
Flat illustration for the post “Static Versus Dynamic Dispatch in Rust and the Real Cost”, composed around traits.

When code calls a method on something described by a trait, the compiler has to decide which function actually runs. There are two ways to answer. If it knows the concrete type at compile time, it can name the function directly, which is static dispatch. If it learns the type only at run time, the call has to look the function up, which is dynamic dispatch. Rust gives you both, with generics for the first and dyn Trait for the second, and the choice has real consequences for speed, code size and what you can express. This post measures the difference instead of asserting it, including a case where the two came out identical and one where the dynamic version took 3.4 times as long.

The measurements ran on Rust 1.99, edition 2024, built with -O on an Apple silicon Mac. Absolute numbers will differ on your hardware, but the ratios are what matter.

The two forms

Static dispatch uses a generic parameter or impl Trait, and the compiler generates a copy of the function for each concrete type, as described in generics and monomorphization:

src/main.rsrust
fn run_generic<T: Op>(op: &T, n: u64) -> u64 {
    let mut x = 1;
    for _ in 0..n {
        x = op.apply(x);
    }
    x
}

Dynamic dispatch uses a trait object, &dyn Op or Box<dyn Op>. The value is stored with a hidden pointer to a table of that type's methods, called a vtable, and each call reads the function address out of the table and jumps to it:

src/main.rsrust
fn run_dyn(op: &dyn Op, n: u64) -> u64 {
    let mut x = 1;
    for _ in 0..n {
        x = op.apply(x);
    }
    x
}

The generic function is compiled once per type and the dynamic one is compiled once for all types. Everything below follows from that.

First measurement: when dyn costs nothing

Call each version 400 million times with the same concrete type, in a loop where each result feeds the next call so the work cannot be skipped:

src/main.rsrust
use std::hint::black_box;
use std::time::Instant;

trait Op {
    fn apply(&self, x: u64) -> u64;
}

struct AddOne;
struct Double;

impl Op for AddOne {
    fn apply(&self, x: u64) -> u64 {
        x.wrapping_add(1)
    }
}

impl Op for Double {
    fn apply(&self, x: u64) -> u64 {
        x.wrapping_mul(2)
    }
}

fn run_generic<T: Op>(op: &T, n: u64) -> u64 {
    let mut x = 1;
    for _ in 0..n {
        x = op.apply(black_box(x));
    }
    x
}

fn run_dyn(op: &dyn Op, n: u64) -> u64 {
    let mut x = 1;
    for _ in 0..n {
        x = op.apply(black_box(x));
    }
    x
}

fn main() {
    let n = 400_000_000;
    for round in 1..=2 {
        let t = Instant::now();
        let a = run_generic(&Double, n);
        let g = t.elapsed();
        let t = Instant::now();
        let b = run_dyn(&Double, black_box(n));
        let d = t.elapsed();
        println!("round {round}: generic {g:>9.1?}   dyn {d:>9.1?}   (results equal: {})", a == b);
    }
    let _ = (run_generic(&AddOne, 1), run_dyn(&AddOne, 1));
}
Text
round 1: generic     786.3ms   dyn     783.1ms   (results equal: true)
round 2: generic     787.2ms   dyn     786.1ms   (results equal: true)

The two are indistinguishable. The reason is that the compiler saw through the trait object. run_dyn(&Double, ...) passes a reference to a value whose concrete type is visible at the call site, and the function is small enough to inline, so the optimizer knew exactly which apply would run and replaced the indirect call with the direct one. This is called devirtualization, and it happens often when a &dyn Trait is created and used close together. The lesson is that dyn is not automatically slow. When the concrete type is known to the optimizer, the abstraction vanishes.

Second measurement: when it cannot see through

The case that matters is a collection of different types, where the type of each element is truly unknown until run time. This program builds 4,096 operations of four different types in a pseudo-random order, stored three ways, and applies them in a chain over 20,000 passes:

src/main.rsrust
use std::hint::black_box;
use std::time::Instant;

trait Op {
    fn apply(&self, x: u64) -> u64;
}

struct AddOne;
struct Double;
struct Xor(u64);
struct Rotate(u32);

impl Op for AddOne {
    fn apply(&self, x: u64) -> u64 {
        x.wrapping_add(1)
    }
}
impl Op for Double {
    fn apply(&self, x: u64) -> u64 {
        x.wrapping_mul(2)
    }
}
impl Op for Xor {
    fn apply(&self, x: u64) -> u64 {
        x ^ self.0
    }
}
impl Op for Rotate {
    fn apply(&self, x: u64) -> u64 {
        x.rotate_left(self.0)
    }
}

enum EnumOp {
    AddOne,
    Double,
    Xor(u64),
    Rotate(u32),
}

impl EnumOp {
    fn apply(&self, x: u64) -> u64 {
        match self {
            EnumOp::AddOne => x.wrapping_add(1),
            EnumOp::Double => x.wrapping_mul(2),
            EnumOp::Xor(k) => x ^ k,
            EnumOp::Rotate(r) => x.rotate_left(*r),
        }
    }
}

fn pick(i: usize) -> usize {
    // A fixed pseudo-random sequence of kinds, so the branch predictor cannot memorize a pattern.
    (i.wrapping_mul(2654435761) >> 7) % 4
}

fn main() {
    const N: usize = 4096;
    const PASSES: usize = 20_000;

    let boxed: Vec<Box<dyn Op>> = (0..N)
        .map(|i| -> Box<dyn Op> {
            match pick(i) {
                0 => Box::new(AddOne),
                1 => Box::new(Double),
                2 => Box::new(Xor(0x9E37_79B9)),
                _ => Box::new(Rotate(5)),
            }
        })
        .collect();
    let enums: Vec<EnumOp> = (0..N)
        .map(|i| match pick(i) {
            0 => EnumOp::AddOne,
            1 => EnumOp::Double,
            2 => EnumOp::Xor(0x9E37_79B9),
            _ => EnumOp::Rotate(5),
        })
        .collect();
    let same: Vec<Box<dyn Op>> = (0..N).map(|_| Box::new(Xor(0x9E37_79B9)) as Box<dyn Op>).collect();
    let calls = (N * PASSES) as f64;

    for round in 1..=2 {
        let t = Instant::now();
        let mut x = 1u64;
        for _ in 0..PASSES {
            for op in &boxed {
                x = op.apply(x);
            }
        }
        let a = (t.elapsed(), black_box(x));

        let t = Instant::now();
        let mut x = 1u64;
        for _ in 0..PASSES {
            for op in &enums {
                x = op.apply(x);
            }
        }
        let b = (t.elapsed(), black_box(x));

        let t = Instant::now();
        let mut x = 1u64;
        for _ in 0..PASSES {
            for op in &same {
                x = op.apply(x);
            }
        }
        let c = (t.elapsed(), black_box(x));

        println!(
            "round {round}: mixed dyn {:.2} ns/call | enum match {:.2} ns/call | dyn, one type {:.2} ns/call  (same result: {})",
            a.0.as_secs_f64() * 1e9 / calls,
            b.0.as_secs_f64() * 1e9 / calls,
            c.0.as_secs_f64() * 1e9 / calls,
            a.1 == b.1
        );
    }
}
Text
round 1: mixed dyn 2.47 ns/call | enum match 0.72 ns/call | dyn, one type 0.97 ns/call  (same result: true)
round 2: mixed dyn 2.49 ns/call | enum match 0.72 ns/call | dyn, one type 0.97 ns/call  (same result: true)

Three numbers, each with a lesson. Mixed Box<dyn Op> cost 2.47 nanoseconds per call. An enum with the same four operations and a match cost 0.72, 3.4 times faster. And Box<dyn Op> holding the same type in every element cost 0.97.

The third result shows that the indirect call itself is cheap: when every element has the same type, the CPU's branch predictor learns the target after the first call, and the call costs about 0.25 ns more than the enum. The first result shows that mixing types is what makes it expensive. The most likely cause, though this benchmark did not isolate it, is that the order of kinds was chosen pseudo-randomly, so the indirect call target is unpredictable, and each misprediction costs a pipeline flush of 10 to 20 cycles. The enum's match over four cheap arithmetic cases can compile to a few conditional selects over data that sits next to the discriminant, which avoids that penalty. A second cost applies to the boxed version regardless: each Box is a separate heap allocation, so walking a Vec<Box<dyn Op>> touches memory in allocation order and not sequentially, while the enum array is contiguous, which is friendlier to the cache. All three versions produced the same result, which the last field confirms.

Choosing between the three

SituationReach forWhy
One type per call site, known at compile timeGenerics (T: Trait, impl Trait)Zero cost, inlined, fully optimized
A closed set of types you controlenum with matchFastest for mixed data, checked exhaustively
An open set: plugins, user-defined types, many cratesBox<dyn Trait>The only option when you cannot list the types
Code size matters more than speed&dyn TraitOne copy of the function for all types
Calls are rare, such as setup or error pathsEither; pick the simplestThe difference is not measurable

The deciding question is who will add the next type. If it is you, in this crate, an enum is usually best, and the compiler tells you every match to update when you add a variant. If it is other people, you need trait objects. If there is only ever one type per context, such as a function generic over an Iterator, generics cost nothing and give the optimizer the most room.

Things that only dynamic dispatch can do

Speed is not the only axis. Trait objects can do things generics cannot. A Vec<Box<dyn Shape>> holds circles and rectangles together, which a Vec<T> cannot. A struct field of type Box<dyn Logger> can be swapped at run time, in a test or by configuration, without making the whole struct generic and every function that touches it generic too. A function can return different types from different branches as Box<dyn Trait>. These are design benefits, and the 2 ns per call is usually irrelevant compared with them: a service handling a request spends microseconds in I/O for each dynamic call.

The restriction on trait objects is that not every trait can be made into one. A trait with generic methods, or methods that return Self, is not dyn-compatible; the compiler's error and the three fixes are in E0038 and dyn compatibility. Generics have the opposite restriction: they cannot be chosen at run time.

How to measure your own case

Do not decide from a blog post, including this one. The method is short. Write the hot loop both ways. Build with --release. Use a mix of concrete types that resembles production, because the single-type case flatters dyn. Make each iteration depend on the previous one or consume the output, so the optimizer cannot discard work, as black_box and the chained x = op.apply(x) do here. Run it several times; the two rounds above agree to two digits, which is the stability you want before believing a number. Then compare against the cost that actually matters to your program. A 1.8 ns difference per call needs a billion calls a second to cost you one second, and most programs call a trait method millions of times, not billions.

If the difference does matter, the options in order of effort are to switch a closed set to an enum, to group elements by type so the predictor sees runs of the same target (sort the vector by type), or to make the hot function generic and call it once per concrete group. For the language details of each form, see trait objects, impl Trait versus dyn Trait and enum dispatch.

Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.