C++
Ninja
doctest
zapcc

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


はじめに

まさかの第2弾です。

前回の記事が好評だったため、今回は少し違った視点からコンパイル時間の削減についての話をさせていただこうと思います。

C++17も普及してきたようで、C++の構文はますます複雑化しています。テンプレートを使用したテクニックやconstexprの需要の増加に伴い、ヘッダオンリーのライブラリの増加、コンパイル時処理の増加が見られるようになりました。

しかしそれと反比例して、C++のコンパイル時間は増大していきます。

一つの翻訳単位でもコンパイル時間が数十秒、あるいは数分に及ぶことさえあり、さらにコンパイル中のメモリ消費量も問題になってきます。

あるヘッダオンリーライブラリを利用した時は、たった一つの翻訳単位でメモリの6割を消費してしまいました。

前回の記事では、主にソースコードに工夫を加えることによってコンパイル時間を削減する方法を紹介してきました。なので今回は、コンパイラやビルドシステム、設計などに注目してみようと思います。


対象となる読者

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


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


速いコンパイラを使う

コンパイラを変えるとそこまで変わるのか?と思うかもしれません。

かなり変わります。特にMSVCは遅い。

厳密な速度までは計測していませんが、体感的には以下の順番でコンパイル時間が短くなります。

Visual Studio > Intel C++ Compiler > Clang > GCC

Visual Studioが極端に遅く感じたのは多分IDEのせいかもしれませんが、Hello worldのプログラムですらかなりの時間を費やしていました。


もっと速いコンパイラ: zapcc

zapccとは、LLVMをベースとしたC++のコンパイラです。

LLVMのソースコードを書き換えて、時々LLVMのリポジトリとマージするという開発形式をとっているので、最近のLLVMの機能も使用することが出来ます(もちろんC++17も)。

では具体的に何が違うのかと言うと、キャッシュです。zapccは、zapccsというプロセスと通信することにより、ヘッダーファイルの解析結果、マクロ、関数名リスト、実体化されたテンプレートなどをメモリ上にキャッシュします。そして以降のビルドでは、そのキャッシュを最大限使用します。

なので、ヘッダファイルを特にいじらない限り、2回目以降のコンパイル時間がかなり短縮されます。

デモ動画:

zapccのデモ動画をここに埋め込む

zapccはソースコードではなく、ヘッダーをキャッシュします。それ故、異なるソースファイルでも、同一のヘッダーを利用している場合はそのキャッシュを利用できますし、ソースコードをある程度編集したとしても同じキャッシュを利用できます。

もうUnity buildが最速だなんて言わせないぞ!!!

ちなみに、zapccはprecompiled headerを無視します。なぜなら、precompiled headerよりも高機能なキャッシュを提供するからです。

precompiled headerは、マクロ展開後のASTや実体化されたテンプレートを保持しません。それ故、templateに大きく依存しているヘッダーの場合、precompiled headerではコンパイル時間をうまく短縮できない場合があります。


zapccのビルド

基本的にビルドのやり方もLLVMと同じなので、簡単な説明にとどめます。

まず、GitHubからソースをダウンロードします。

$ git clone https://github.com/yrnkrn/zapcc.git

その後、適当なcmakeオプションを利用してビルドの準備を行います。zapccではNinjaを利用することが推奨されているのでそれに従います。

$ cd zapcc

$ mkdir build && cd build
$ cmake -G Ninja -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF -DLLVM_ENABLE_WARNINGS=OFF -DLLVM_ENABLE_BACKTRACES=OFF -DLLVM_ENABLE_MODULE_DEBUGGING=OFF -DLLVM_ENABLE_LOCAL_SUBMODULE_VISIBILITY=OFF ..

それが終わったら、いよいよビルドします。 コンパイル中はかなりメモリを消費する部分があるので注意してください。2時間ほどでビルドが終わります。

$ Ninja -j3

終わったら、ビルド先/binディレクトリにパスを通しておきましょう。

$ export PATH="$PATH:/path/to/zapcc/build/bin"

これでzapccコマンドを使用可能になります。使い方はclangとほぼ同じなので解説はしません。


zapccの使い方

zapccを使ってC++のプログラムをコンパイルすると、zapccsプロセスが立ち上がります。zapccsを放置しているとキャッシュが増えてメモリを圧迫するので、キャッシュをクリアしたい場合はzapccsプロセスをkillします。

$ pkill zapccs

または、zapccと同じディレクトリにあるzapccs.configというファイルを編集することで、メモリ使用量の最大値などを変更することが出来ます。

デフォルトの設定では、zapccの1プロセス辺り2000MBとなっています。


zapccs.config

[MaxMemory]

# This is **per-process** limit so for example running
# ninja -j8 will use up to eight times the limit.
2000

# For files, wildcards *,? may be used.
# case and Linux/Windows slash type insensitive.
# Examples:
# */myfile.*
# */mydir/myfile
# *.dat


並列でzapccコマンドを使用する場合は、この値の数倍のメモリが消費されうるので気をつけましょう。

ちなみにメモリ制限値を超えるとzapccsプロセスが再開され、キャッシュも空になります。


デバッグオプションはつけない

コンパイラのデバッグオプションを有効化するとコンパイル時間が20%程度増大します。なので、不要ならデバッグオプションは無効化しましょう。

開発中にすぐデバッグできるように、という理由でデバッグオプションが必要になる場合もありますが、デバッグオプションは基本的に実行時のエラーを検出する目的で利用します。

なので、開発初期の段階などコンパイルエラーを修復することが目的の場合は、デバッグオプションを無効化することでビルド時間を短縮できます。


速いリンカーを使う

皆さんはgold使ってますか?

goldはgccやclangで使用できるリンカーで、デフォルトのリンカーよりも高速にリンクを行うことが出来ます。

以下のコマンドを打って、正常にgoldのバーションが表示されれば、goldが使用可能です。

# gcc

g++ -fuse-ld=gold -Wl,--version

# clang
clang++ -fuse-ld=gold -Wl,--version

使用方法は、ライブラリのリンク時に-fuse-ld=goldオプションを指定するだけ。

cmakeならCMakeLists.txtに以下の記述を追加します。

include(CheckCXXCompilerFlag)

check_cxx_compiler_flag("-fuse-ld=gold" COMPILER_SUPPORTS_GOLD)

if(COMPILER_SUPPORTS_GOLD)
execute_process(COMMAND ${CMAKE_CXX_COMPILER} -fuse-ld=gold -Wl,--version OUTPUT_VARIABLE stdout ERROR_QUIET)
if("${stdout}" MATCHES "GNU gold")
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -fuse-ld=gold")
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -fuse-ld=gold")
endif()
endif()

goldを使うとリンクにかかる時間がおよそ半減されます。

他にも、LLVMがlldというリンカーを提供しており、大規模なプロジェクトではgoldの倍以上の速度とも言われています。

ヘッダーに実装を記述することによってODR違反の重複定義が発生し、それをリンカーが解決するため、リンカーの速度というのも無視できません。


速いビルドシステムを使う

C++のビルド環境としてはGNU makemsbuildが比較的良く使われているかと思います。

しかしながら、これらのビルド環境では、ソースコードの変更検知の処理などに時間がかかり、特にプロジェクトが膨大な時には余計なコストが発生します。

cmakeでジェネレータを何も指定しないとGNU makeになりますけど、cmakeが出力するMakefileは非常に分かりにくい上、余計なcmakeコマンドを呼び出す必要が生じます。

そこで、高速なビルド環境Ninjaの登場です。

Ninjaではcmakeの呼び出しが最小限に抑えられているので、生成されるプロセスの数も減り、特にWindowsでは顕著な違いが現れるのではないかと思います。

使用方法は簡単。cmakeでジェネレータをNinjaに指定するだけです。

cmake -G Ninja ...

ninja -j$(nproc)


Developモードを設計してビルドする

cmakeにはDebugモード、Releaseモード、MinSizeRelモードなどが標準で用意されていますが、ここにDevelopモードというのを自分で追加します。

このDevelopモードは自分だけが利用することを想定し、コンパイル時間短縮のための様々な工夫を行います。例えば、

if(CMAKE_BUILD_TYPE STREQUAL "Develop")

add_definitions("-D__cplusplus=199711 -DMY_PROJECT_DEVELOP");
endif()

などとすることで、標準ライブラリのインクルードにかかる時間を削減したり、

#ifndef MY_PROJECT_DEVELOP

# include <functional> // std::hash
#else
# include <bits/functional_hash.h> // std::hash
#endif

というように実装系依存のヘッダをインクルードしてコードを軽量化することも出来ます。

ただし、実装系依存のヘッダは依存関係が複雑で、予期せぬエラーを吐いたり、あまり効率が改善しなかったりする場合もあるので十分注意します。

あくまでこの方法は、自分だけが得するための手段であって、自分の開発するプロジェクトをビルドしたり利用したりする人が恩恵を得られるわけではありません。

さらに、C++23からはModule TSが導入される予定なので、ここにあまりこだわりすぎると後で損をした気分になります。あくまで自己責任でどうぞ。


コンパイル時間が短いライブラリ・フレームワークを選ぶ

コンパイル時間で困っているときは、なるべくヘッダーとソースが上手く分割できていて、ヘッダー内で思いファイルをインクルードしていないライブラリを選ぶことがポイントです。


例: 音速コンパイル可能なテストフレームワーク doctest

doctestは、まさにコンパイル時間の削減の限界に挑んだテストフレームワークです。

doctestはシングルヘッダーで書かれたライブラリですが、内部で明確に宣言部と実装部が分かれており、マクロを使用してそれらを制御することが出来ます。

doctestはいろいろな使い方があると思いますが、筆者がおすすめする方法は、main.cppにdoctestの実装を埋め込み、その他の翻訳単位にテストケースを記述する方法です。


main.cpp

#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN // doctestの実装部とmain関数を有効化する

#include "doctest.h"


test1.cpp

#include "doctest.h" // doctestの宣言部のみインクルードする


TEST_CASE("case 1") {
// ...
}

// ...


当然このバイナリにはdoctestの実装部が含まれていないので、最後にmain.cppとリンクします。cmakeだとこんな感じ。

add_library(main OBJECT main.cpp)

add_executable(test1 test1.cpp) # <- 音速コンパイル
target_link_libraries(test1 main)

doctestの実装部はコンパイルに時間を要してしまいますが、宣言部だけだと、非常に高速にコンパイルすることが出来ます。

$ time g++ -c test1.cpp


real 0m0.084s
user 0m0.064s
sys 0m0.020s

さらに、main.cppはdoctest.hにしか依存していないので、コードの編集によって再コンパイルされることはありません。従って、一度main.cppをコンパイルした後は、コードを書き換えたとしてもコンパイルにはほとんど時間がかからないという結果になります。

どのようにしてこのような高速コンパイルを実現しているのか?それはdoctest.hの中身を見れば分かります。

宣言と実装を分離するための高等なテクニックが多く用いられているので、参考になると思います。


zapccとdoctestを併用する際の問題点

doctestの宣言部と実装部は、マクロによって制御します。

しかし、zapccsはヘッダーの解析結果をキャッシュするので、一度インクルードしてしまうと、別の翻訳単位でも同じマクロの設定が読み込まれます。

つまり、main.cppをコンパイルした後にtest1.cppをコンパイルしてリンクすると、再び実装部が読み込まれ、多重定義によりエラーとなります。

これを防ぐには、zapccsの実行ファイルと同じディレクトリにあるzapccs.configというファイルを編集します。

中に[Mutable]というセクションがあるので、そこにdoctest.hを追加しましょう。


zapccs.config

[Mutable]

# Header files that will not be cached.
# For macro-dependent headres, if the macro change frequently use this and
# if rarely use [WatchMacro] which will reset only when the macro value changes.

# boost::fusion (main)
*/libs/fusion/test/algorithm/fold.hpp
# boost::random (main)
*/libs/random/test/test_real_distribution.ipp
*/libs/random/test/test_generator.ipp
*/libs/random/test/test_distribution.ipp
# boost::test (main)
*/boost/test/minimal.hpp
# boost::test (BOOST_TEST_MODULE)
*/boost/test/detail/config.hpp
# Chromium (IPC macros)
*/chromium/ipc/ipc_message_null_macros.h
*/chromium/ipc/param_traits_write_macros.h
# ITK (nested include guards)
*/Modules/Filtering/FFT/include/itkForwardFFTImageFilter.h
*/Modules/Filtering/FFT/include/itkInverseFFTImageFilter.h
*/Modules/Filtering/FFT/include/itkRealToHalfHermitianForwardFFTImageFilter.h
# range v3
*/test/algorithm/*
# doctest (configuration macros)
*/doctest/doctest.h

zapccs.configを編集したら必ずzapccsを再起動してください。

この他にも、マクロの事前定義により設定を行うヘッダーファイルはキャッシュされるので注意しましょう。


ヘッダとソースが分割されたライブラリを選ぶべきか?

私もcrowtiny-dnnでのクソ長いコンパイル時間にイライラした経験はあるので、ヘッダオンリーじゃなくてソースがきちんと分割されたライブラリを使いたくなる気持ちは分かります。

しかし、無理してそのようなライブラリを利用することには多くのデメリットが伴います。

まず、コンパイル時間と実行時パフォーマンスは常にトレードオフの関係にあります。実装を分離すれば、最適化やコンパイル時処理のメリットを荒削りすることになります。

また、DLLを利用することで、DLL地獄という問題が生じる場合があります。

DLLは、特にWindowsユーザーから嫌われているようで、x86とx64の互換性や、/MTフラグと/MDフラグの違いによるエラーなどに悩まされるケースもあります。

また、近年のライブラリは明らかにヘッダオンリー、シングルファイルのものが増えているので、期待するようなライブラリは見つからないと思ったほうが良いです。

何より、多くの人が利用するライブラリを利用すれば、そのライブラリとのインターフェースを実装して提供することもできるでしょう。

もし、どうしても、ヘッダとソースが分割されたライブラリを使用したい!ということであれば、そのライブラリとのラッパーを自作するのが一番手っ取り早いでしょう。ただし、これはあくまで困った時の最終手段です。


速いコンピュータを買う

いろいろ書きましたが、結局のところ、これが一番簡単な解決方法でしょう。

貧しい筆者にはメモリを買い換えるお金すら無いので貧弱なパソコンをずっと使い続けていますが、お金に余裕が有る方はメモリを増設・拡張し、SSDに換装してディスクI/Oを高速化しましょう。

さらに余裕があれば複数台のサーバーで分散ビルドしましょう。

特にzapccを利用する場合はメモリ容量が肝心になってきます。メモリは可能な限り容量が大きいものを買いましょう。


Goはコンパイルが速いらしい(誘惑)

Golangにはジェネリクスが存在しないので、コンパイル時間がかなり短いという話を聞きました。

C++で散々非難されている動的例外の問題も、Goならば苦しむ必要はないとか。

Goは記述量を減らすことにも焦点を当てていて、ストレスはかなり軽減されるらしいです。

時間があればGoについてもう少し調べてみる予定です。


まとめ

zapcc強い。