Static dispatch vs dynamic dispatch in Rust, how to dramatically improve performances + Java 21 bonus
Table of contents
Before diving deeper, let's understand the key differences between static and dynamic dispatching.
When you have a trait (interface for those unfamiliar with rust), you have to put the function signature of what all classes or struct will implement.
Let's call this trait Vehicle
that could declare methods such as drive
get_hp
and many others. There could be many classes implementing this interface.
Now, typically in many languages (like Java), when the program meets an instance of Vehicle
, It will dynamically find which implementation it has to execute, like doing an instanceof
and executing the right implementation (the JVM indeed performs a type check, but not an instanceof
)
Dynamic dispatching is this process of finding the proper implementation of a polymorphic operation.
While traditional approaches exist, this session dives into optimized techniques that can unlock up to 10x performance gains! We'll explore how these strategies leverage the new features introduced in Java 21.
The code
First let's implement a simple trait, here Vehicle
trait Vehicle {
fn get_hp(&self) -> u32;
}
Then we will create two structs and implement them with this trait.
struct Mercedes {
hp: u32,
}
struct AMG {
hp: u32,
}
impl Vehicle for Mercedes {
fn get_hp(&self) -> u32 {
self.hp
}
}
impl Vehicle for AMG {
fn get_hp(&self) -> u32 {
self.hp * 2
}
}
Now, let's write a function that takes 2 instances of Vehicle
and returns one of them. Like in this example:
fn test_dynamic_dispatching<'a>(x: &'a AMG, y: &'a Mercedes) -> &'a dyn Vehicle {
if x.get_hp() > y.get_hp() {
return x;
}
y
}
The compiler does not have prior knowledge of which concrete instance of Vehicle
will be returned during runtime. This uncertainty necessitates the use of the dyn
keyword. Unlike generic parameters or impl Trait
, where the compiler can infer the concrete type being passed, dynamic dispatching requires explicit indication using dyn
.
So calling get_hp
on the returned instance of Vehicle
will be done using a vtable, containing all the possible implementations and execute the right one.
Understanding static dispatching.
Now we are going to do something new in our code, we are going to add an enum, that contains structs that implements our trait.
enum VehicleDispatcher<'a> {
AMG(&'a AMG),
Mercedes(&'a Mercedes),
}
And we will make the same function as we declared above, but slightly different:
fn test_static_dispatching<'a>(x: &'a AMG, y: &'a Mercedes) -> VehicleDispatcher<'a> {
if x.get_hp() > y.get_hp() {
return VehicleDispatcher::AMG(&x);
}
VehicleDispatcher::Mercedes(&y)
}
Now, when calling this function, we will have to do a match on it, in order to call the function on it. This is now static dispatching, there is no more performance loss looking at a map in order to find the proper implementation.
Performance test
Using black_box
is critical to prevent the compiler from "optimizing" to much and remove this part of the code.
Do not forget to run or build this code using --release
let m = Mercedes { hp: 234 };
let a = AMG { hp: 987 };
let start1 = Instant::now();
for i in 0..100000000 {
let v = test_dynamic_dispatching(&a, &m);
black_box(v.get_hp());
}
let duration1 = start1.elapsed();
println!("Time elapsed in dynamic dispatching is: {:?}", duration1);
let start2 = Instant::now();
for i in 0..100000000 {
let v = test_static_dispatching(&a, &m);
match v {
VehicleDispatcher::AMG(amg) => { black_box(amg.get_hp()); }
VehicleDispatcher::Mercedes(mer) => { black_box(mer.get_hp()); }
};
}
let duration2 = start2.elapsed();
println!("Time elapsed in static dispatching is: {:?}", duration2);
While running the code, we get this gain in performance.
Amazing! Isn't it?
Conclusion
For such a slight difference in the code, we get such an improvement that can be life-changing when performance is critical.
That is the reason we have crates that created macros to simplify the process of doing so, such as enum_dispatch
https://docs.rs/enum_dispatch/latest/enum_dispatch/
I said I would write about Java 21, but do you see a similarity?
I saw many people around me not understanding the sealed type's benefits and being baffled by the permits
keyword. This video from a Youtuber I like made me laugh: https://youtu.be/w87od6DjzAg?si=KxGea4GhmzsG9oeb&t=480
Does everything click now?
Java knows all possible subclasses/implementations because you explicitly list them in the permits clause. The compiler will ensure these are the only extensions/implementations allowed. This means the type system can be fully checked for completeness at compile time.
This is static dispatching introduced to Java, and that is amazing. Behind the door, it is precisely what we have done before by listing all the subclasses/implementations in an enum, but without our knowledge.
I hope you liked this article, and, as always, you can find the code on my GitHub.
https://github.com/mathias-vandaele/dynamic-vs-static-dispatching
Subscribe to my newsletter
Read articles from Mathias Vandaele directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by