Static dispatch vs dynamic dispatch in Rust, how to dramatically improve performances + Java 21 bonus

Static dispatch vs dynamic dispatch in Rust, how to dramatically improve performances + Java 21 bonus

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