🚆

[Rust] 静的ディスパッチ(Enum)と動的ディスパッチ(dyn Trait)はどちらが速いのか?

2024/05/11に公開
2

ある時、 Rust で動的ポリモーフィズムを実現するには、 Enum は dyn Trait より遅いという主張を見ました。 dyn Trait は仮想関数テーブルのルックアップがある分、遅いはずだと思っていたので、これは確かめてみようと思いました。

[2024/5/24追記]:
少し後で考えると、ここで静的ディスパッチという言葉を使うのは語弊があるような気がしたので補足します。静的ディスパッチとは、 dyn Trait に対する impl Trait のことを呼ぶことが多く、ここで Enum で実現しているのは「一つのコンテナに異なる型や挙動を示すオブジェクトを含める」という意味では動的ディスパッチです。正確に言うならば、この記事で示しているのは、同じ動的ディスパッチでも、 Enum を使った場合分けか、 dyn Trait を使った仮想関数テーブルを使ったポリモーフィズムかという違いです。

ただし、このようなマイクロベンチマークは話半分ぐらいに思っておいた方が良いです。実際のコードではオプティマイザがかなり仕事をするので、本当にスピードが問題になる局面では実際のコードでプロファイルを取った方が良いです。

この記事では rustc -O を使って最適化を適用しています。

Enum の実装

実用的には(平均的には) 5 種類ぐらいの型にポリモーフィズムを適用することが多いと思いますので、 5 つのバリアントを持つ Enum を使います。オプティマイザが計算を取り除いてしまうことを避けるため、 counts というアキュムレータに結果を加算していくようにしました。 10 億回のループの合計を取っています。

#[derive(Clone, Copy, Debug)]
enum E {A, B, C, D, E}

pub fn calc() -> [i32; 5] {
    let aa = [E::A, E::B, E::C, E::D, E::E];
    let mut counts = [0; 5];
    for i in 0..1000000000 {
        let a = aa[i % aa.len()];
        let n = match a {
            E::A => 0,
            E::B => 1,
            E::C => 2,
            E::D => 3,
            E::E => 4,
        };
        counts[i % aa.len()] += n;
    }
    counts
}

pub fn main() {
    let counts = calc();

    println!("aa: {counts:?}");
}

アセンブラの結果は次のようになります。長いので折りたたんでいます。

最適化がかなりコードを変形させているのでかなり見づらいですが、メインループ (.LBB3_1 から jne .LBB3_1 の間) では関数呼び出しが存在せず、スタックフレーム内で計算が完結していることが分かります。

アセンブラコード
example::calc::hc2870328f6203ca1:
        push    r15
        push    r14
        push    r13
        push    r12
        push    rbx
        mov     dword ptr [rsp - 37], 50462976
        lea     r8, [rsp - 36]
        mov     byte ptr [rsp - 33], 4
        xorps   xmm0, xmm0
        movaps  xmmword ptr [rsp - 32], xmm0
        mov     dword ptr [rsp - 16], 0
        lea     r9, [rsp - 28]
        mov     ecx, 1
        mov     r10d, 1000000000
        lea     r11, [rsp - 37]
        lea     rbx, [rsp - 32]
        xor     esi, esi
        movabs  r14, -3689348814741910323
.LBB3_1:
        mov     rax, rcx
        mul     r14
        mov     rax, rdx
        shr     rax, 2
        and     rdx, -4
        lea     rdx, [rdx + 4*rdx]
        mov     r15, r9
        sub     r15, rdx
        lea     r12, [rax + 4*rax]
        neg     r12
        mov     rax, rsi
        mul     r14
        mov     rax, rdx
        shr     rax, 2
        lea     rax, [rax + 4*rax]
        neg     rax
        and     rdx, -4
        lea     rdx, [rdx + 4*rdx]
        mov     r13, rbx
        sub     r13, rdx
        movzx   eax, byte ptr [r11 + rax]
        add     dword ptr [r13], eax
        movzx   eax, byte ptr [r8 + r12]
        add     dword ptr [r15], eax
        add     rsi, 2
        add     r9, 8
        add     rcx, 2
        add     r8, 2
        add     r11, 2
        add     rbx, 8
        add     r10, -2
        jne     .LBB3_1
        mov     eax, dword ptr [rsp - 16]
        mov     dword ptr [rdi + 16], eax
        movaps  xmm0, xmmword ptr [rsp - 32]
        movups  xmmword ptr [rdi], xmm0
        mov     rax, rdi
        pop     rbx
        pop     r12
        pop     r13
        pop     r14
        pop     r15
        ret

実行結果は私の環境では次のようになりました。

aa: [0, 200000000, 400000000, 600000000, 800000000]

real    0m1.069s
user    0m1.069s
sys     0m0.000s

dyn Trait の実装

dyn Trait の場合は、それぞれの型について実装しなければならないのでコードの量がかなり増えます。ここでは繰り返しを減らすため宣言的マクロを使っています。

trait T {
    fn get(&self) -> i32;
}

macro_rules! def_t {
    {$name:ident, $value:literal} => {
        struct $name;

        impl T for $name {
            fn get(&self) -> i32 {
                $value
            }
        }
    }
}

def_t!(A, 0);
def_t!(B, 1);
def_t!(C, 2);
def_t!(D, 3);
def_t!(E, 4);

pub fn calc() -> [i32; 5] {
    let a = A;
    let b = B;
    let c = C;
    let d = D;
    let e = E;
    let aa: [&dyn T; 5] = [&a, &b, &c, &d, &e];
    let mut counts = [0; 5];
    for i in 0..1000000000 {
        let a = aa[i % aa.len()];
        let n = a.get();
        counts[i % aa.len()] += n;
    }
    counts
}

fn main() {
    let counts = calc();

    println!("aa: {counts:?}");
}

アセンブラを見ると、 .LBB1_1:jne .LBB1_1 の間に call qword ptr [rax + 24] があります。これはメモリ上のアドレスを使って関数呼び出しを行う動的ディスパッチだと考えられます。

アセンブラコード
example::calc::hc2870328f6203ca1:
        push    rbp
        push    r15
        push    r14
        push    r13
        push    r12
        push    rbx
        sub     rsp, 136
        mov     qword ptr [rsp + 48], rdi
        lea     rax, [rsp + 15]
        mov     qword ptr [rsp + 56], rax
        lea     r15, [rsp + 64]
        lea     rcx, [rip + .L__unnamed_1]
        mov     qword ptr [rsp + 64], rcx
        mov     qword ptr [rsp + 72], rax
        lea     rcx, [rip + .L__unnamed_2]
        mov     qword ptr [rsp + 80], rcx
        mov     qword ptr [rsp + 88], rax
        lea     rcx, [rip + .L__unnamed_3]
        mov     qword ptr [rsp + 96], rcx
        mov     qword ptr [rsp + 104], rax
        mov     qword ptr [rsp + 112], rcx
        mov     qword ptr [rsp + 120], rax
        mov     qword ptr [rsp + 128], rcx
        xorps   xmm0, xmm0
        movaps  xmmword ptr [rsp + 16], xmm0
        mov     dword ptr [rsp + 32], 0
        mov     r12d, 1000000000
        lea     r13, [rsp + 56]
        lea     rbx, [rsp + 16]
        xor     r14d, r14d
.LBB1_1:
        mov     rax, r14
        movabs  rcx, -3689348814741910323
        mul     rcx
        lea     rax, [4*rdx]
        and     rax, -16
        lea     rax, [rax + 4*rax]
        mov     rcx, r15
        sub     rcx, rax
        inc     r14
        mov     rsi, r13
        sub     rsi, rax
        and     rdx, -4
        lea     rax, [rdx + 4*rdx]
        mov     rbp, rbx
        sub     rbp, rax
        mov     rdi, qword ptr [rsi]
        mov     rax, qword ptr [rcx]
        call    qword ptr [rax + 24]
        add     dword ptr [rbp], eax
        add     r15, 16
        add     r13, 16
        add     rbx, 4
        dec     r12
        jne     .LBB1_1
        mov     ecx, dword ptr [rsp + 32]
        mov     rax, qword ptr [rsp + 48]
        mov     dword ptr [rax + 16], ecx
        movaps  xmm0, xmmword ptr [rsp + 16]
        movups  xmmword ptr [rax], xmm0
        add     rsp, 136
        pop     rbx
        pop     r12
        pop     r13
        pop     r14
        pop     r15
        pop     rbp
        ret

出力は次のようになりました。

aa: [0, 200000000, 400000000, 600000000, 800000000]

real    0m1.983s
user    0m1.982s
sys     0m0.000s

結果

統計的誤差を回避するため、5回ごと実行して平均値と標準偏差を取りました。誤差は1%程度で Enum の方が約2倍速いという結果が出ました。

この傾向は予想通りではありますが、どちらも非常に速いということは言えると思います。 10 億回の動的ポリモーフィズムが1,2秒でできるということは、それがパフォーマンスのボトルネックになる可能性は低いと言えます。

今回のデモではポリモーフィズムでロジックを振り分ける以外にはほとんど意味のある計算をしていないのですが、 dyn Trait が2倍程度の計算時間になるということは、その意味のない計算程度のオーダーのオーバーヘッドだということです。

dyn Trait の特性としてより重要なのは、 Enum と違って、後からどんな型でもポリモーフィズムが振り分ける対象に追加できるということです。これはクレートなどで公開するインターフェースとしては大きな意味があります。 Enum には #[non_exhaustive] 属性をつけることでクレート作者が将来新たなバリアントを互換性を壊さずに追加する機能がありますが、そのクレートのユーザが新たな型を追加するのを許すことはできません。

その一方で、トレイトの実装はコード量がほぼ確実に増えるのと、 Object safety を考慮する必要があるなど、複雑度が増すことは避けられません。

また、トレイトはジェネリック関数を使えば静的ディスパッチ相当になるので(今回のデモではそうならないように配列に異なる型を混ぜています)、パフォーマンス上の懸念があればジェネリック関数を使うこともできます。ただし、 monomorphization によってコードは肥大化し、キャッシュミスを増やすかもしれません。

実際に Enum と dyn Trait のどちらを選ぶかは、様々な要因を考慮し、速度はそのごく一部に過ぎない場合が多いでしょう。

Discussion

PG_kuraPG_kura

Enum の場合の測定結果が、dyn Trait の場合の測定結果と同じになっているような?

msakutamsakuta

ご指摘の通り、コピペ間違いでした。
修正しました。