C++
GCC
clang
icc

オーバーライドされた仮想関数にまつわる最適化

はじめに

あるクラスから派生したクラスで、基底クラスのメソッドをオーバーライドしたとする。この時、派生クラスがコンパイル時にわかっているときに、コンパイラはそれを認識して最適化できるか試してみた。

使うコンパイラは以下の通り。

  • g++ (Homebrew GCC 7.3.0_1) 7.3.0
  • clang++ Apple LLVM version 9.1.0 (clang-902.0.39.2)
  • icpc (ICC) 18.0.1 20171018

ただし、インテルコンパイラについては最後でちょっと触れるだけにする。

ケース1: 静的な宣言

こんなコードを考える。

test1.cpp
struct Hoge {
  int func() {
    return 1;
  }
};

int func() {
  Hoge h;
  return h.func();
}

関数funcは常に1を返す関数であるが、それをコンパイラが認識できるか試す。これを

$ g++ -O3 -S test1.cpp
$ clang++ -O3 -S test1.cpp

などとしてアセンブリを見て、funcが即値を返せるか調べる。

まずg++の場合。

func():
LFB1:
        movl    $1, %eax
        ret

clang++の場合(不要な情報を削ってある)。

func():
        pushq   %rbp
        movq    %rsp, %rbp
        movl    $1, %eax
        popq    %rbp
        retq

うん、どちらも即値で返せていますね。まぁこのあたりは認識して欲しい気がする。

ケース2: newした場合

クラスHogeをnewした場合はどうだろう?

test2.cpp
struct Hoge {
  int func() {
    return 1;
  }
};

int func() {
  Hoge *h = new Hoge();
  return h->func();
}

g++の場合。

func():
        subq    $8, %rsp
        movl    $1, %edi
        call    operator new(unsigned long)
        movl    $1, %eax
        addq    $8, %rsp
        ret

律儀にnewを呼んだりしているけど、即値で返していますね。

clang++の場合。

func():
        pushq   %rbp
        movq    %rsp, %rbp
        movl    $1, %eax
        popq    %rbp
        retq

なんとclang++の場合はnewも不要と判断してしまう。

ケース3: スマートポインタの場合

スマートポインタを使ってみよう。

test3.cpp
#include <memory>
struct Hoge {
  int func() {
    return 1;
  }
};

int func() {
  std::unique_ptr<Hoge> h(new Hoge());
  return h->func();
}

g++の場合。

func():
LFB1904:
        subq    $8, %rsp
        movl    $1, %edi
        call    operator new(unsigned long)
        movl    $1, %esi
        movq    %rax, %rdi
        call    operator delete(void*, unsigned long)
        movl    $1, %eax
        addq    $8, %rsp
        ret

律儀にnewとdeleteを呼んでいるけど、即値で返している。

clang++の場合。

func():
        pushq   %rbp
        movq    %rsp, %rbp
        movl    $1, %eax
        popq    %rbp
        retq

先程と同様、即値で返す上にnewやdeleteも呼ばない。

ケース4: オーバーライドした場合

さて、オーバーライドがからむ場合。基底関数のメソッドを派生クラスでオーバーライドするのだが、誰がオーバーライドしたかコンパイル時にわかる場合。

test4.cpp
#include <memory>
struct Hoge {
  virtual int func() {
    return 1;
  }
};

struct Hoge1 : public Hoge {
  int func() {
    return 10;
  }
};

struct Hoge2 : public Hoge {
  int func() {
    return 20;
  }
};

int func() {
  std::unique_ptr<Hoge> h(new Hoge1());
  return h->func();
}

この場合だと、返り値としては1, 10, 20の三通りの可能性があるが、newされているのはHoge1()なので返り値は10になることがわかる。それをコンパイラが見抜けるか。

g++の場合。

func():
        subq    $8, %rsp
        movl    $8, %edi
        call    operator new(unsigned long)
        movq    vtable for Hoge1@GOTPCREL(%rip), %rdx
        movl    $8, %esi
        movq    %rax, %rdi
        addq    $16, %rdx
        movq    %rdx, (%rax)
        call    operator delete(void*, unsigned long)
        movl    $10, %eax
        addq    $8, %rsp
        ret

おおー、ちゃんと即値で返していますね。

clang++の場合。

func():
        pushq   %rbp
        movq    %rsp, %rbp
        movl    $10, %eax
        popq    %rbp
        retq

clang++の場合はこれまで同様、即値で返している上に不要なnewとdeleteも呼ばない。

ケース5: 初期化と条件分岐がからむ場合

これが本命。条件によって異なる派生クラスが作られるのだが、どの派生クラスが作られるかはコンパイル時にわかる、というケース。こんなの。

test5.cpp
#include <memory>
struct Hoge {
  virtual int func() {
    return 1;
  }
};

struct Hoge1 : public Hoge {
  int func() {
    return 10;
  }
};

struct Hoge2 : public Hoge {
  int func() {
    return 20;
  }
};

int func(double p) {
  std::unique_ptr<Hoge> h;
  if (p < 0.0) {
    h = std::unique_ptr<Hoge>(new Hoge1());
  } else {
    h = std::unique_ptr<Hoge>(new Hoge2());
  }
  return h->func();
}

int func2() {
  return func(-1.0);
}

ファクトリメソッド・パターンが絡んでいると思えば良い。funcの引数の正負でHoge1Hoge2のどちらが実体化されるかが決まるが、func2では負の数を入れて呼んでいるため、funcではHoge1が作られることが確定。するとh->func()が10を返すことが確定するため、func2の返り値も10となる。ここまでコンパイラが見抜けるか、という話。

g++の場合。

func2():
        pushq   %rbp
        movl    $8, %edi
        pushq   %rbx
        subq    $8, %rsp
        call    operator new(unsigned long)
        movq    %rax, %rbx
        movq    vtable for Hoge1@GOTPCREL(%rip), %rax
        movq    %rbx, %rdi
        addq    $16, %rax
        movq    %rax, (%rbx)
        call    Hoge1::func()
        movq    %rbx, %rdi
        movl    $8, %esi
        movl    %eax, %ebp
        call    operator delete(void*, unsigned long)
        addq    $8, %rsp
        movl    %ebp, %eax
        popq    %rbx
        popq    %rbp
        ret

g++はここで力尽きた。Hoge1が作られるところまではわかったが、Hoge1::funcのインライン展開まではしなかった。

で、clang++の場合。

func2():
        pushq   %rbp
        movq    %rsp, %rbp
        movl    $10, %eax
        popq    %rbp
        retq

最後まで最適化が通り、即値で返してくる。

まとめ

ある関数が派生クラスでオーバーライドされているが、どのクラスでオーバーライドしているかがコンパイル時にわかる、という条件でどこまで最適化されるか調べた。clang++は最後まで最適化が通り、かつ不要なnewとdeleteも削除してくれる。これはわりと大したもんだと思う。g++は最後まで仮想関数テーブルはひかなかったが、作成したクラスのメソッドのインライン展開はしなくなったため、ケース5で即値で返せなくなった。ちなみにインテルコンパイラはケース3までは即値で返せたが、ケース4から真面目に仮想関数テーブルをひくようになった。

ファクトリメソッド・パターンで、ある変数の値に応じて異なるクラスの実体が作られるようにしたいのだが、その変数はコンパイル時に決定している、という状況があって、そこで軽い関数を頻繁に呼ぶのでちょっと性能が気になって調べたのだが、clang++なら(少なくとも今回程度の複雑さなら)オーバーライドなしで生関数呼んだ場合と同じ性能が出せるみたいですね。