Static vs dynamic dispatch in Rust benchmark — for those who care about runtime performance

All the details, benchmark code, generated assembly, and explanation are in the README:

https://github.com/amidukr/rust-devirtualization-test

UPD

Made the dynamic-dispatch part more accurate.

The measured difference increased from ~3.5× to ~4.9×.

Changed:

// was
x = op.apply(std::hint::black_box(x));

to:

// now
x = std::hint::black_box(op).apply(std::hint::black_box(x));

Which changed the generated assembly from:

; was
call r15

to:

; now
call qword ptr [rax + 24]

The previous version allowed LLVM to hoist the vtable method pointer out of the loop. The updated version performs the vtable method lookup on each iteration.

UPD

Benchmark for de-virtualization case added.

The README goes a lot in to how this benchmark is made but cuts off mid sentence as it explains what the benchmark demonstrates. So what does this demonstrate?

Edit: On my computer the results are much different:

run 1: static =  592.395 ms | dynamic = 1229.125 ms | dynamic/static = 2.07x
run 2: static =  666.677 ms | dynamic = 1281.886 ms | dynamic/static = 1.92x
run 3: static =  768.886 ms | dynamic = 1193.847 ms | dynamic/static = 1.55x
run 4: static =  502.110 ms | dynamic = 1190.254 ms | dynamic/static = 2.37x
run 5: static =  698.196 ms | dynamic = 1154.300 ms | dynamic/static = 1.65x

The benchmark seems to be trying to measure the CPU's branch predictor? Or clock speed?

Interesting. Perhaps your CPU is doing a better job with indirect branch prediction.

I was running it on:

  • CPU: 12th Gen Intel(R) Core(TM) i7-1255U (Low-Power Laptop CPU)
  • Power Mode: Balanced
  • OS: Linux Kernel 7.0.0

I’ve added that to the README.md.

Something I forgot to mention: it is important to run the benchmark in release mode:

cargo run --release -p devirtualization-test-main

A debug build may not perform the same inlining and devirtualization optimizations.

The screenshot explains pretty much exactly what is being tested.

It compares vtable-based dyn dispatch with plain inlined static dispatch: essentially

call qword ptr [rax + 24]

versus the operation being inlined directly into the loop.

By the way, I’ve also updated the test to make the vtable call more explicit/accurate. I’m now getting a ~4.9× difference on my machine.

My results are still over the place, I've even had 0.84x?

run 1: static =  577.957 ms | dynamic = 1746.960 ms | dynamic/static = 3.02x
run 2: static =  502.738 ms | dynamic = 1527.736 ms | dynamic/static = 3.04x
run 3: static =  385.229 ms | dynamic = 1566.097 ms | dynamic/static = 4.07x
run 4: static = 1063.120 ms | dynamic = 1675.479 ms | dynamic/static = 1.58x
run 5: static =  259.211 ms | dynamic = 1502.320 ms | dynamic/static = 5.80x
run 1: static =  281.896 ms | dynamic = 1537.650 ms | dynamic/static = 5.45x
run 2: static =  441.153 ms | dynamic = 1648.203 ms | dynamic/static = 3.74x
run 3: static =  358.714 ms | dynamic = 1488.006 ms | dynamic/static = 4.15x
run 4: static =  504.288 ms | dynamic = 1533.307 ms | dynamic/static = 3.04x
run 5: static = 1827.401 ms | dynamic = 1529.495 ms | dynamic/static = 0.84x
run 1: static = 1113.783 ms | dynamic = 1523.806 ms | dynamic/static = 1.37x
run 2: static =  703.453 ms | dynamic = 1624.234 ms | dynamic/static = 2.31x
run 3: static =  905.684 ms | dynamic = 1649.718 ms | dynamic/static = 1.82x
run 4: static = 1272.902 ms | dynamic = 1544.870 ms | dynamic/static = 1.21x
run 5: static =  259.160 ms | dynamic = 1529.640 ms | dynamic/static = 5.90x

Still, I'd be interested to know what this is measuring and why this is important. It seems like you just pushed an update to make it slower when the same code was faster before? Is it just to see how slow you can get dynamic dispatch to be? If so, what's the point of that? As I said, the README cuts off.

Just of curiosity, which CPU you using to running?

I am curious, why you have so different results. Maybe you running something in parallel?

Like mining bitcoins? :slightly_smiling_face:

You dynamic results are steady, but static is not.


how slow you can get dynamic dispatch to be?

Yes, exactly.

Imagine you have:

struct MyStruct {
    val: i64,
}

impl MyStruct {
    fn increment(&mut self) {
        self.val += 1;
    }
}

And in main:

fn main() {
    let mut my_struct = MyStruct { val: 5 };

    my_struct.increment();
    println!("{}", my_struct.val);
}

What the compiler can essentially do is inline the method:

fn main() {
    let mut my_struct = MyStruct { val: 5 };

    my_struct.val += 1;
    println!("{}", my_struct.val);
}

Then optimize the struct away:

fn main() {
    let mut my_struct_val = 5;

    my_struct_val += 1;
    println!("{}", my_struct_val);
}

Then constant-propagate the value:

fn main() {
    let my_struct_val = 6;
    println!("{}", my_struct_val);
}

Until the relevant part of the code effectively becomes:

fn main() {
    println!("{}", 6);
}

And eventually:

fn main() {
    println!("6");
}

Rust/LLVM is pretty smart here.

If you do the same thing with traits:

trait Increment {
    fn increment(&mut self);
}

impl Increment for MyStruct {
    fn increment(&mut self) {
        self.val += 1;
    }
}

and use the trait statically, for example through a generic function, you can get essentially the same optimization because the concrete type is known after monomorphization.

But there are situations where your design forces you to use dyn Trait, and the compiler cannot devirtualize the call because the concrete implementation is deliberately hidden:

fn main() {
    let mut my_struct = MyStruct { val: 5 };

    let incr_trait: &mut dyn Increment =
        std::hint::black_box(&mut my_struct);

    incr_trait.increment();

    println!("{}", my_struct.val);
}

This line:

let incr_trait: &mut dyn Increment =
    std::hint::black_box(&mut my_struct);

conceptually gives us something like:

incr_trait = {
    data:   pointer to my_struct,
    vtable: pointer to MyStruct's Increment vtable,
}

On x86-64, you can think about those two pointers as something like:

lea rdi, [my_struct]               ; data pointer
lea rsi, [Increment_vtable]        ; vtable pointer

Each lea shown here is a real CPU instruction: if these instructions are present in the final machine code, the CPU has to execute them, and that takes CPU resources and execution time.

Then, when you do:

incr_trait.increment();

the dynamic call is conceptually something like:

// Get the two parts of the trait object

let data_ptr = incr_trait.data;
// rdi = data_ptr

let vtable_ptr = incr_trait.vtable;
// rsi = vtable_ptr


// Load the address of Increment::increment from the vtable

let increment_fn = vtable_ptr.increment;
// mov rax, [rsi + OFFSET]


// Indirect call, passing data_ptr as `self`

increment_fn(data_ptr);
// call rax

The CPU may also perform the function-pointer load and indirect call as essentially one instruction:

call qword ptr [rsi + OFFSET]

You can see that there is more happening at runtime:

  • We have to get the address of the function from the vtable, which requires a memory read. Ideally it comes from CPU cache; in the worst case it may have to be fetched from RAM.
  • Then we have to make an indirect call to that address. The CPU has to execute that call, and it can become more expensive if the CPU predicts the destination incorrectly.
  • And only then does the real work happen: incrementing the value in the struct.

So with a normal call, the compiler may be able to make the call disappear completely.

With dynamic dispatch, the CPU may actually have to find the function through the vtable, call it, execute it, and return from it.

If the compiler can prove what is being called and inline it, it really can make the application as simple as Hello World:

println!("6");

in this example.

That's on my desktop, it's a Ryzen 3700x. It's running other applications and even after changing the iterations to 1000 to let it warm up there's still significant noise.

Here's the results on a Pi 400 which is doing nothing except run this application:

run 1: static = 4446.302 ms | dynamic = 4000.998 ms | dynamic/static = 0.90x
run 2: static = 4447.654 ms | dynamic = 3986.869 ms | dynamic/static = 0.90x
run 3: static = 4447.502 ms | dynamic = 3984.248 ms | dynamic/static = 0.90x
run 4: static = 4446.982 ms | dynamic = 4002.012 ms | dynamic/static = 0.90x
run 5: static = 4449.065 ms | dynamic = 3984.875 ms | dynamic/static = 0.90x

I really do wonder about this benchmark if it's able to have dynamic dispatch run faster than static on both my machines.

Just add explanation for that comment, which explain dynamic dispatch and vtable in nut-shell.

Static supposed to run much faster than dynamic, that's point.

PI runs arm, either compiler did something different, or ARM CPU optimization is somehow outsmart intel. It needs to review output assembly code, to exclude that rust didn't do ARM compilation differently.

Most likely it is due to compilation difference for ARM, dynamic can be same, but not faster.

Changing the code to do the dynamic runs first then static runs second then adding them all up there's less cache thrashing them gives this output on the 3700x (I did three separate runs of the program):

static = 37031.501 ms | dynamic = 47237.895 ms | dynamic/static = 1.28x
static = 37139.499 ms | dynamic = 47157.688 ms | dynamic/static = 1.27x
static = 36706.931 ms | dynamic = 47253.560 ms | dynamic/static = 1.29x

and on the Pi 400:

static = 22238.729 ms | dynamic = 18634.421 ms | dynamic/static = 0.84x
static = 22295.279 ms | dynamic = 18668.574 ms | dynamic/static = 0.84x
static = 22265.024 ms | dynamic = 16998.352 ms | dynamic/static = 0.76x

So it seems like dynamic dispatch is fairly CPU dependent, with a small drop to speed up. Edit: Naively assuming this is what the benchmark is measuring. I haven't opened up a profiler, so these results still don't mean much. I'd be much more interested in measuring real world code. :slight_smile:

I don't know which changes you made to the code, but AMD can do some better job at branch prediction. CPU manufacturers knows that many programs compiled with vtables.

This is the new loop, which I would argue models real world loops as generally they don't switch between implementations every iteration.

    let mut total_dynamic_time = 0.0;
    let mut total_static_time = 0.0;
    for run in 1..=RUNS {
        let (static_time, static_result) = benchmark_static(&op);
        total_static_time += static_time.as_secs_f64();
    }
    for run in 1..=RUNS {
        let (dynamic_time, dynamic_result) = benchmark_dynamic(&op);
        total_dynamic_time += dynamic_time.as_secs_f64();
    }
    println!(
        "static = {:8.3} ms | dynamic = {:8.3} ms | dynamic/static = {:.2}x",
        total_static_time * 1000.0,
        total_dynamic_time * 1000.0,
        total_dynamic_time / total_static_time,
    );

This is what I've got on my CPU using your code version.

static = 1052.532 ms | dynamic = 5022.992 ms | dynamic/static = 4.77x

Could you try to add assert_eq! just in case, so optimizer will not strip loop, it shouldn't, but just in case.

fn main() {
    let op = AddOne;

    let mut total_dynamic_time = 0.0;
    let mut total_static_time = 0.0;
    let mut counter = 0;
    for run in 1..=RUNS {
        let (static_time, static_result) = benchmark_static(&op);
        total_static_time += static_time.as_secs_f64();
        counter += static_result;
    }
    for run in 1..=RUNS {
        let (dynamic_time, dynamic_result) = benchmark_dynamic(&op);
        total_dynamic_time += dynamic_time.as_secs_f64();
        counter -= dynamic_result;
    }

    assert_eq!(counter, 0);
    println!(
        "static = {:8.3} ms | dynamic = {:8.3} ms | dynamic/static = {:.2}x",
        total_static_time * 1000.0,
        total_dynamic_time * 1000.0,
        total_dynamic_time / total_static_time,
    );
}

For PI ARM CPU, I would examine assembly output, maybe compiler works somehow different and still did optimization.

The results this time is 2.27x to 1.58x, which still is too noisy for me to understand.

Here's the loop on x86_64:

.LBB0_2:
	mov	qword ptr [rsp + 16], r15
	mov	qword ptr [rsp + 24], r14
	#APP
	#NO_APP
	mov	qword ptr [rsp + 8], rax
	#APP
	#NO_APP
	mov	rdi, qword ptr [rsp + 16]
	mov	rax, qword ptr [rsp + 24]
	mov	rsi, qword ptr [rsp + 8]
	call	qword ptr [rax + 24]
	dec	rbx
	jne	.LBB0_2

on aarch64:

.LBB0_2:
        stp     x21, x20, [sp]
        //APP
        //NO_APP
        str     x2, [x29, #24]
        //APP
        //NO_APP
        ldp     x0, x8, [sp]
        ldr     x1, [x29, #24]
        ldr     x8, [x8, #24]
        blr     x8
        subs    x19, x19, #1
        mov     x2, x0
        b.ne    .LBB0_2

It looks about the same to me. The LLVM IR output for the dynamic dispatch is identical on both machines.

Edit: Doing lots of runs on my machine, the worst speed drop with the new code is 2.54x while the fastest is 1.23x. It seems to hang around the 2x area, which isn't too bad.

Regarding x86_64, I have same assembly output, smaller number it means that CPU done some job to optimize branch prediction.

Regarding aarch64, blr is ARM instruction similar to call in x86, few ldp/ldr above it obviously reading pointer from vtable.

If you getting some consistent result, than obviously ARM doing some good job on branch prediction. With more iteration it can do it better.

There is also LTO stage, I’ve disabled to get cleaner tests, maybe arm tricked with LTO somehow.

Obviously any benchmark result depends on CPU. I have x5 difference in my tests consistently.

I don't know if I'd take away the conclusion that this benchmark is actually measuring branch prediction, nobody has actually profiled it to understand where these speed differences are coming from.

CPU call vs Compiler Inline - says enough.

You don't think it's suspect at all that the benchmark is giving results where the branch predictor is significantly faster than not branching at all?

This is a relatively pointless micro benchmark by itself. There's no situation where you're choosing between a random vtable for the same data or a static function, so there's no information that can be applied, even disregarding how difficult it is to reliably microbenchmark.

Any situation where you know what the trait type is the compiler will devirtualize well before any branch prediction or vtable caching kicks in.

If you instead wanted to show the difference between the different common approaches for polymorphism (boxed trait, enum, SoA) it would at least be applicable to actual problems.

Had same discussion on redit, and then I've checked the code of a person who was bringing arguments similar to yours:

  #[inline]
    pub fn write_canonical<P: CharProfile>(
        &mut self,
        words: &mut dyn Iterator<Item = &str>,
        mapped_delim: char,
        validate_start: bool,
    ) -> core::fmt::Result {

That's what he doing:

  1. First you can #[inline] and &dyn Iterator in same method signature, which already non-sense.
  2. Iterator::next is one or two operation, next is about incrementing your pointer.

Huh? I don't understand either point you're making here, even if those statements were true.

In the case you were trying to ask a question with either of those, what's so nonsense about having #[inline] and &mut dyn in the same signature? Even if the compiler can't devirtualize the inner call, you're still requesting the outer call gets a higher preference for inlining. That also gives a much better chance for the inner to get devirtualized with the context of the outer caller, but I'm not sure if that was the intent of this other person.

For Iterator::next, sure it might be a pointer increment, but it could also be literally arbitrarily complex code. I'm not sure what difference this makes either way for what you're talking about here: that it's often a good candidate for inlining? Yeah, that's why it often gets inlined.

#[inline] + dyn Trait is like pressing the accelerator and the brake at the same time.

Don't you see it? That's funny.

Even if the compiler can't devirtualize the inner call, you're still requesting the outer call gets a higher preference for inlining.

I didn't dig into the code enough, hopefully compiler can process that non-sense, by de-virtualizing and inlining at the same time.

But what was in the head of person who putted #[inline] together with dyn Trait use. My understanding that most of the time #[inline] is useless, in release mode compiler by default will try to inline whatever he can. Maybe person was trying to hack dyn Trait using #[inline], hardly believe it will make a difference. If compiler can de-virtualize it will do, if it can't, it can't.

For Iterator::next, sure it might be a pointer increment, but it could also be literally arbitrarily complex code.

Iterator is iterator, and in majority cases it is Vec::iterator.

Honestly, I don't have much desire to dig more into that code, if you'd like here is the sources:

https://github.com/sigseis/typed-ident/blob/8c279b3eb51d0e20fa23b9229ab2a04be5e834ca/src/core/fmt/delimited/writer.rs#L64

I can assume he wanted to escape some complexity while coding by not using generics, and used dyn trait instead, I think there is no question I can ask.