C++
C++11
コンパイル
C++14

モダンなC++におけるコンパイル時間削減のテクニック

はじめに

C++は他の高級言語と比べると、run-time性能で優れています。C++11、C++14ではテンプレートを使ったテクニックが多く登場し、静的型言語特有のデメリットを大きく解消することとなりました。

しかし...

...

...

...

.............

遅い!!!

コンパイルが終わらない!!!

複雑なテンプレートテクニックを使用したライブラリとかだと、どうしてもコンパイル時間が肥大化してしまう。

というわけで今回は、C++11以降の「モダンなC++」においてコンパイル時間を削減させるテクニックをいくつか紹介します。

対象となる読者

C++のテンプレートや共有ライブラリを使用した経験があり、C++の優れたテンプレートの機能を活用したいが、コンパイルに時間がかかって困っている人。あるいは、C++で書かれたライブラリの開発に携わっている人。

コンパイル時間削減のためのテクニック

ヘッダー内の不必要な記述を減らす

これはどちらかと言うとライブラリ開発者に言えることですが、宣言も実装もまとめてヘッダーに書く人がいます。

確かにヘッダーオンリーにすれば導入が楽ですしデバッグしやすいのも確かです。
しかしながら大規模なプロジェクトにおいては、コンパイル時間を大きく肥大化させる原因となります。

例としてxtensorというライブラリは多次元配列を容易に扱うことを可能にする優れたライブラリですが、すべてヘッダーオンリーで書かれています。簡単な行列計算を行うだけでもコンパイル時間が肥大化してしまいます。

このような事態を防ぐためには、宣言と実装を分離するのが最も効果的です。こうすればライブラリのビルド時に実装部分を共有ライブラリとして予めコンパイルしておくことができます。

Explicit Instantiation

Explicit Instantiationは宣言と実装を分離する上で非常に効果的な手法です。Explicit Instantiationとは、テンプレートクラスやテンプレート関数を実装部分で明示的にインスタンス化する方法です。

例として、以下のようなテンプレートクラスを考えます。

example1.hpp
template <class Ts>
class example1 {
 public:
  void doSomething();
};
example1.cpp
template <class Ts>
void example1<Ts>::doSomething(){
  /* do something */
}

このファイルをコンパイルしてライブラリを生成しても、中身は空っぽの意味のないファイルが出来上がります。なぜかというと、テンプレートは具体的な引数を与えないと実体化しないからです。そこで、example1.cppに以下のステートメントを追加します。

template class example1<int>;
template class example1<double>;

こうすることでint型とdouble型を引数としたexample1クラスが実体化され、ライブラリファイルにも実装が埋め込まれます。privateなメンバ関数は埋め込む必要がないためinlineをつけておくと良いでしょう。

当たり前ですが、このライブラリを読み込んで、int型とdouble型以外の引数を与えるとコンパイルエラーになります。その型に対応する実装が存在しないからです。したがって、ユーザー定義型を扱うような関数やクラスにはこの方法は使えません。

また、SFINAEを使用しているような関数やクラスにはこの方法は適用できないため、SFINAEを使わない方法(タグディスパッチなど)に変えるか、そのような関数やクラスだけヘッダーに実装を書くことになります

Pimplイディオムを使う

Pimplイディオムは、privateなメンバ変数やメンバ関数をすべて実装部分に記述する方法です。

ちょっと何を言ってるのか分からない?それじゃあ次のコードを見てください。

example2.hpp
#include <memory>

class example2 {
  class Pimpl;

 public:
  example2();
  example2(const example2&);
  ~example2();
 private:
  // hold all private members
  Pimpl* p;
};
example2.cpp
class example2::Pimpl{
  // private members
  void* data;
  void allocate(size_t);
};

void example2::Pimpl::allocate(size_t sz){
  /* allocate memory */
}

example2::example2(){
  p = new Pimpl();
}

example2::example2(const example2&){
  p = new Pimpl();
}

example2::~example2(){
  delete p;
}

ね、簡単でしょう?

やってることとしては、プライベートメンバをすべて格納したオブジェクトを作成し、もとのクラスでそのポインタを保持するだけ。

この方法の一番のメリットは、privateなメンバを変更した時に、このクラスに依存しているすべてのファイルをコンパイルし直す必要がないことです。他のファイルが依存しているexample2.hppは一切書き換えていませんから。

当たり前ですが、すべてのコンストラクタに動的確保を行う処理を書かなければならないので、コンストラクタが大量に存在するときは面倒です。
さらに、インスタンスが生成されるたびに動的確保を行うためにrun-timeパフォーマンスが低下します。そのせいかこのイディオムを使っている人はほとんど見たことがありません。

ヘッダーにincludeは極力書かない

言わずもがな知れた内容だと思いますが、念のため記しておきます。

前方宣言によりincludeを減らすことができます。実体化が必要なコードの場合はまとめてコンパイルする必要があるので面倒ですが、ライブラリとして配付する際には優れたコンパイル速度を達成することができるでしょう。

ちなみにstd::istreamやstd::ostreamの前方宣言だけ必要な場合は、<iostream>の代わりに<iosfwd>をインクルードすることで実体化を行いません。

ヘッダーオンリーか共有ライブラリかを選択できるようにする

実装を分けたほうがコンパイルが速いのは事実ですが、デバッグ等のためにヘッダーオンリーのオプションは用意しておきましょう。

まずヘッダーファイル末尾に以下のようなステートメントを追加します。

example3.hpp
#ifdef EXAMPLE3_IMPLEMENTATION
#include <impl/example3.hpp>
#endif

include/implディレクトリに実装ファイルをすべてコピーし、拡張子を.hppに変更して完了です。

ライブラリを使用する側は、以下のようにしてヘッダーオンリーで使用することができます。

#define EXAMPLE3_IMPLEMENTATION
#define <example3.hpp>

Doxygenなどを使っている場合は、implディレクトリを除外するのを忘れないでください。

この仕組みはnanovgなどでも使用されています。

プリコンパイル機能を使う

使用しているコンパイラがプリコンパイル機能をサポートしているなら、積極的に利用しましょう。includeステートメントをすべて一つのファイルにまとめてプリコンパイルしておき、各々のファイルでそれをインクルードすると作業が捗ります。

cmakeをお使いであれば、cotireというモジュールを使用することで自動的にプリコンパイル済みのヘッダーを利用することが出来ます。

Boost.preprocessorやBoost.mplへの依存を減らす

これらはコンパイル速度を著しく減少させる原因となります。

特に、似たようなステートメントを何度も書くのが面倒だからという理由だけでBoost.preprocessorを使う人がいますが、これは止めるべきだと思います。どうしても面倒だというなら、cogなどのコード生成ツールを使いましょう。かのNumpyも実はビルド時に自動でコードを生成しています。

cpp-netlibはboost.mplに大きく依存していますが、そのせいで簡単なWebサーバーのプログラムでさえもコンパイルに10秒以上費やしたりします。ちなみにboost.asioで同じ機能のサーバーを書いたときにはコンパイル時間はわずか1.5秒でした。

if constexpr文を活用する

c++17からif constexpr文がサポートされました。これとExplicit Instantiationを組み合わせることで、コンパイル時間が向上します。

例えば下のようなコード

example4.hpp
class example4
{
public:
  template<typename D>
  void doSomething(D arg, typename std::enable_if<std::is_same<D,int>::value>::type* = nullptr)
  {
    /* do something */
  }

  template<typename D>
  void doSomething(D arg, typename std::enable_if<!std::is_same<D,int>::value>::type* = nullptr)
  {
    /* do something */
  }
};

このままでは宣言と実装を分離できないため、Explicit Instantiationが使えません。
しかし、if constexprを使えば簡単に実装を分離できます。

example4.hpp
class example4{
  template <typename D>
  void doSomething(D arg);
};
example4.cpp
template <typename D>
void example4::doSomething(D arg){
  if constexpr(std::is_same<D,int>::value){
    /* do something */
  } else {
    /* do something */
  }
}

// explicit instantiation
template void example4::doSomething<int>(int);
template void example4::doSomething<double>(double);

......

C++14でif constexprと似たような機能を実装したライブラリもあるようです(未検証)。

標準ライブラリに嘘をつく

これは正直あまりお勧めできない方法ですが、標準ライブラリにC++のバージョンを偽ることでコンパイル速度が向上します。例えばこんな感じ

#undef __cplusplus
#define __cplusplus 199711L
#include <algorithm>

当たり前ですが、こんなことをするとrvalue referenceなどの機能が使われないので、run-timeパフォーマンスが低下する恐れがあります。またインクルードガードがあるため、algorithmは再度インクルードし直すことはできなくなります。少なくともライブラリ開発者はこのようなコードを書いてはいけません。

並列コンパイルする

cmakeを使っている場合は、コードを編集する作業スペースと、コンパイル・テストを行う作業スペースを用意しておき、コードを書き替えるたびにmakeしてしまいましょう。cmakeが更新されたソースコードを自動で検知し、そのコード(およびそれに依存するプログラム)だけコンパイルしてくれます。

makeは並列コンパイルをサポートしているので、複数ファイルを更新した場合は並列でコンパイルできます。

Templightでデバッグ

Clangが使える環境であれば、Templightを使ってデバッグしてみるのも一つの手でしょう。

Templight profilerはテンプレートの実体化を検知してタイムスタンプを作成し、どの部分の実体化に時間がかかっているのかを可視化するツールです。このツールを使って、実体化に時間を要している部分のコードの修繕を図ることが可能です。

最後に

コンパイル時間削減について自分が知っているテクニックを書き連ねてみました。他にもこんな方法があるよ!という方は是非編集リクエストをお願いします。