Dynamic Dispatch Costs
While dynamic dispatch via trait objects (&dyn Trait and Box<dyn Trait>) provides runtime flexibility and smaller binary code size, it introduces specific runtime and optimization costs compared to static dispatch.
Breakdown of Runtime Costs
Static Call (`T: Trait`) Dynamic Call (`&dyn Trait`)
+----------------------------------+ +---------------------------------------+
| 1. Direct call / inlined body | | 1. Dereference fat pointer vtable_ptr |
| 2. Registers & loop vectorized | | 2. Load fn pointer from vtable slot |
+----------------------------------+ | 3. Indirect call (blocks inlining) |
+---------------------------------------+
| Cost Source | Cause | Practical Impact |
|---|---|---|
| Indirect function call | CPU must load the method address from the vtable before jumping | Small per-call latency (~1–3 ns when vtable and branch predictor are warm) |
| Inlining barrier | Compiler does not know which concrete function will run at compile time | Prevents constant folding, dead-code elimination, and SIMD vectorization across the call boundary |
| Pointer & cache locality | Vec<Box<dyn Trait>> stores pointers to separate heap allocations |
Iterating chases pointers across the heap instead of scanning one contiguous buffer |
Heap allocation (Box) |
Constructing Box<dyn Trait> calls the global allocator |
Avoidable when borrowing via &dyn Trait or &mut dyn Trait |
| Fat pointer width | &dyn Trait and Box<dyn Trait> occupy 16 bytes instead of 8 bytes |
Doubles pointer storage size inside slices and structs |
Why the Inlining Barrier Matters More Than the Vtable Lookup
Modern CPUs predict indirect jumps accurately when the same concrete type is called repeatedly. In tight inner loops (such as per-pixel math or per-byte parsing), the main cost of &dyn Trait is not the vtable load itself, but that the compiler cannot inline a 2-instruction method body into the loop and auto-vectorize it:
trait Operation {
fn apply(&self, x: i64) -> i64;
}
struct AddOne;
impl Operation for AddOne {
fn apply(&self, x: i64) -> i64 {
x + 1
}
}
// 1. Static dispatch: `op.apply(acc)` is inlined and collapsed by LLVM into `n`:
pub fn run_static<O: Operation>(op: &O, n: i64) -> i64 {
let mut acc = 0;
for _ in 0..n {
acc = op.apply(acc);
}
acc
}
// 2. Dynamic dispatch: must perform `n` indirect calls unless devirtualized:
pub fn run_dynamic(op: &dyn Operation, n: i64) -> i64 {
let mut acc = 0;
for _ in 0..n {
acc = op.apply(acc);
}
acc
}
fn main() {
let op = AddOne;
println!("Static: {}", run_static(&op, 1_000));
println!("Dynamic: {}", run_dynamic(&op, 1_000));
}
Static: 1000
Dynamic: 1000
Amortizing Dynamic Dispatch with Batch Methods
If a trait is designed for dynamic dispatch (&dyn Trait), avoid putting a vtable call inside a per-byte or per-element inner loop. Instead, design the trait method to operate on a slice batch (fn process_batch(&mut self, buf: &mut [f32]))—just like std::io::Read::read(&mut self, buf: &mut [u8])—so the vtable lookup happens once per buffer and the inner loop inside the concrete implementation vectorizes at full speed.