In this lesson, you will learn how to quantify the runtime overhead of dynamic dispatch and trait objects when working within the strict memory and cycle constraints of embedded systems.
The Anatomy of Dynamic Dispatch
In Rust, we often favor generic functions (fn func<T: Trait>(...)) because the compiler uses monomorphization to generate specialized machine code for each type, enabling aggressive inlining. When we switch to trait objects (&dyn Trait), we introduce dynamic dispatch.
Under the hood, &dyn Trait is a "fat pointer"—a two-word structure containing the data pointer and a pointer to a vtable (virtual method table). Every time you call a method on a trait object, the CPU must perform an extra memory fetch to look up the function address in the vtable before jumping to the code.
Quantifying the Cost
Consider this simplified scenario where we have a Sensor trait:
trait Sensor {
fn read(&self) -> u16;
}
// Option A: Static Dispatch (Inlined, Zero-cost)
fn process_static<T: Sensor>(s: &T) {
let val = s.read(); // Compiler knows exactly which function to call
// ... logic
}
// Option B: Dynamic Dispatch (Indirection)
fn process_dynamic(s: &dyn Sensor) {
let val = s.read(); // Indirect jump via vtable
// ... logic
}
In an embedded context, the costs are threefold:
- Instruction Latency: The indirect jump prevents the compiler from inlining the
readmethod. On architectures like ARM Cortex-M, this creates a pipeline stall and prevents further optimization, such as constant propagation across the call boundary. - Code Bloat: While dynamic dispatch can reduce binary size by sharing one function implementation, it forces the inclusion of vtables for every type that implements the trait, which can consume precious SRAM or Flash.
- Branch Prediction: Deeply nested dynamic calls can confuse branch predictors, leading to cycle penalties that are devastating in real-time loops.
When designing embedded systems, use dyn Trait only when you absolutely require runtime pluggability (e.g., swapping sensor drivers at runtime). For almost all other cases, static dispatch via generics provides the performance baseline required for deterministic behavior.
Key Takeaway: Dynamic dispatch incurs a performance penalty by inhibiting inlining and forcing vtable lookups; prefer static generics for critical paths to ensure the compiler can optimize your code for the specific hardware target.