CMake 3.16でprecompiled headerがサポートされていた

CMakeの最新バージョン、3.16でtarget_precompile_headerがサポートされていた。ドキュメントは以下の通り。

cmake.org

precompiled headerは、大規模なプロジェクトなどで変更がないのに様々な.cppファイルから何回もインクルードされるヘッダーファイルを先に(できる分だけ)コンパイルしておいてビルドを高速化する技術だ。大抵のヘッダファイルには実装がないのでコード出力は必要ないが、パースして構文木を作ったりするだけでも時間がかかるので、そこを省略するだけでも結構違うのだろう。

ドキュメントを見たところ、これは以下のような形で使うようだ。

target_precompile_headers(main
  PUBLIC project_header.h
  PRIVATE <unordered_map>
)

どういう形で動くのかを少し調べてみた。以下のような構成になっているとしよう。名前は「pre compiled header」でpchにした。

$ tree .
.
├── CMakeLists.txt
├── pch
│   ├── CMakeLists.txt
│   ├── pch.cpp
│   └── pch.hpp
└── src
    ├── CMakeLists.txt
    └── main.cpp

2 directories, 6 files

中身は生命・宇宙全ての答えを計算するプログラムを書いた。これに関しては私が答えを知っているので大幅な高速化ができて、コードは以下のようになる。

namespace pch
{

int foo()
{
    return 42;
}

} // pch
#ifndef PCH_PCH_HPP
#define PCH_PCH_HPP

namespace pch
{

int foo();

} // pch
#endif// PCH_PCH_HPP

これでこの機能を試してみよう。pch/pch.cpppchライブラリとしてコンパイルする。このときにtarget_precompile_headersを足しておく。

# pch/pch.hpp
add_library(pch pch.cpp)

target_precompile_headers(pch
    PRIVATE ${PROJECT_SOURCE_DIR}/pch/pch.hpp)

ついでにsrc/main.hppにも足しておこう。

# src/CMakeLists.txt
add_executable(main main.cpp)
target_include_directories(main PRIVATE ${PROJECT_SOURCE_DIR})
target_link_libraries(main pch)

target_precompile_headers(main
    PRIVATE ${PROJECT_SOURCE_DIR}/pch/pch.hpp)

cmake-3.16をダウンロードしてコンパイルしてみる。

$ make
Scanning dependencies of target pch
[ 16%] Building CXX object pch/CMakeFiles/pch.dir/cmake_pch.hxx.gch
[ 33%] Building CXX object pch/CMakeFiles/pch.dir/pch.cpp.o
[ 50%] Linking CXX static library libpch.a
[ 50%] Built target pch
Scanning dependencies of target main
[ 66%] Building CXX object src/CMakeFiles/main.dir/cmake_pch.hxx.gch
[ 83%] Building CXX object src/CMakeFiles/main.dir/main.cpp.o
[100%] Linking CXX executable main
[100%] Built target main

どうやらcmake_pch.hxx.gchが追加されているようだ。だがpch.cppmain.cppの2回コンパイルされている。勝手にシェアはされないらしい。

シェアさせてみよう。REUSE_FROMというのがあって、ターゲット間でプリコンパイルヘッダを共有できるそうだ。pchライブラリからはmainが見えないので、mainからpchのヘッダを再利用する。

add_executable(main main.cpp)
target_include_directories(main PRIVATE ${PROJECT_SOURCE_DIR})
target_link_libraries(main pch)

# target_precompile_headers(main
#     PRIVATE ${PROJECT_SOURCE_DIR}/pch/pch.hpp)
target_precompile_headers(main REUSE_FROM pch)

すると以下のように、cmake_pch.hxx.gch`は一回しかコンパイルされなくなる。

$ make
Scanning dependencies of target pch
[ 20%] Building CXX object pch/CMakeFiles/pch.dir/cmake_pch.hxx.gch
[ 40%] Building CXX object pch/CMakeFiles/pch.dir/pch.cpp.o
[ 60%] Linking CXX static library libpch.a
[ 60%] Built target pch
Scanning dependencies of target main
[ 80%] Building CXX object src/CMakeFiles/main.dir/main.cpp.o
[100%] Linking CXX executable main
[100%] Built target main

シンプルな場合の挙動はだいたいわかった。ではより現実に近い状況として、ヘッダを複数個用意してみよう。

「生命・宇宙・全ての答え」の問いの定式化は非常に難しく諸説存在するが、その一つを実際に計算してみるコードを書いてみる。

namespace pch
{

int bar()
{
    return 6*9;
}

} // pch
#ifndef PCH_PCH2_HPP
#define PCH_PCH2_HPP

namespace pch
{

int bar();

} // pch
#endif// PCH_PCH2_HPP

これをpchディレクトリにpch2.[hc]ppという名前で置く。そしてCMakeLists.txtを以下のように書く。

add_library(pch pch.cpp pch2.cpp)

target_precompile_headers(pch
    PRIVATE ${PROJECT_SOURCE_DIR}/pch/pch.hpp
    PRIVATE ${PROJECT_SOURCE_DIR}/pch/pch2.hpp)

ではmakeしてみよう。予想としてはpch.hpp.gchpch2.hpp.gchをビルドすると思うところだが……?

$ make
Scanning dependencies of target pch
[ 16%] Building CXX object pch/CMakeFiles/pch.dir/cmake_pch.hxx.gch
[ 33%] Building CXX object pch/CMakeFiles/pch.dir/pch.cpp.o
[ 50%] Building CXX object pch/CMakeFiles/pch.dir/pch2.cpp.o
[ 66%] Linking CXX static library libpch.a
[ 66%] Built target pch
Scanning dependencies of target main
[ 83%] Building CXX object src/CMakeFiles/main.dir/main.cpp.o
[100%] Linking CXX executable main
[100%] Built target main

ヘッダが一つしかコンパイルされていない。どういうことだろう。

そもそも、CMakeはout-of-sourceを推奨しており、ヘッダファイルと同じディレクトリに生成された同名のsomething.hpp.gchを探すプリコンパイルヘッダの性質と最悪に相性が悪いはずだ。そもそも何がコンパイルされているのだろう。

というわけで見に行くことにした。ログを見ると、pch/CMakeFiles/pch.dir/cmake_pch.hxx.gchコンパイルされている。このディレクトリに実際にcmake_pch.hxxというファイルがあるので見てみよう。

/* generated by CMake */

#pragma GCC system_header
#ifdef __cplusplus
#include "/home/username/Sandbox/cmake/precompile_headers/pch/pch.hpp"
#include "/home/username/Sandbox/cmake/precompile_headers/pch/pch2.hpp"
#endif // __cplusplus

ディレクトリ構造見えてるやん。ユーザー名は置き換えました。

はい。で、これは単にtarget_precompile_headersに指定したファイルを全部インクルードしただけのファイルのようだ。だがこのファイルをインクルードした覚えはない(生成されたものなので当然)。どうやってコンパイルされているんだ。

make VERBOSE=1で実際に実行されているコマンドを見てみると、-include cmake_pch.hxxとある。このオプションを付けてファイルをコンパイルすると#include "cmake_pch.hpp"が書かれているのと同じように処理される。ドキュメントは以下。

Using the GNU Compiler Collection (GCC): Preprocessor Options

これによって依存関係を作り、target_precompile_headersで指定したファイルだけ先にインクルードしたファイルを作って、そのまとめたヘッダファイルをコンパイルしておくという形らしい。

この性質上、ヘッダを一つ変更すると結局全部コンパイルしなおしになる。これは実質的に実装をヘッダに書くinlinetemplateと相性が悪い。テンプレートを使わずヘッダには宣言しか書かない大規模プロジェクトでは速度向上が見込めるだろう。

個人的にはヘッダファイルごとにコンパイルしたいのだが、それができるかどうかもう少し調べないといけないようだ……。

-ffinite-math-onlyでも動くNaN/infチェック

以前こういう記事を書いた。

in-neuro.hatenablog.com

まあNaNじゃないことを仮定しての高速化なんだからstd::isnan(x)を常にfalseだと思ってdead code eliminationするのは当たり前じゃんという気はするが、備忘録的に。

とはいえ、コンパイルオプションに-Ofastを付けられたとしてもNaNチェックをしたいことはある。どうしたらよいか。コンパイラにNaNチェックをしていると思われなければよい。要するに、浮動小数点数を使ってNaNチェックをするから駄目なのだ。直接ビットパターンを調べれば良い。

64bit浮動小数点数のレイアウトはWikipediaに載っている。とはいえ符号-指数-仮数の順であることを覚えていれば、符号が1bitなのは自明なので、仮数が52bitであることを覚えてさえいればあとは引き算ができればレイアウトがわかる。

ja.wikipedia.org

特殊な浮動小数点数は指数部が全て1の浮動小数点数として定義されている。仮数部が全部0ならinfで、それ以外はNaNになる。なので、指数部のビットが全て1であるかを確認すれば、isfinite相当のものは実装できる。

まず、doublestd::uint64_tに変換する。以下が鉄板のパターンで、これ以外だと大抵規格違反になる。

bool is_NaN_or_inf(const double x)
{
    std::uint64_t n;
    std::memcpy(reinterpret_cast<char*>(&n),
                reinterpret_cast<const char*>(&x),
                sizeof(double));
    // ...
}

ポインタや参照を直接変換すると、アライメントの条件が変わってしまうことがある。そのせいで、たいていの型の間のポインタ・参照変換は規格違反となる。アライメントはメモリアドレスに対する制限で、例えばある型は8の倍数のアドレスにしか置けないというような制限だ。だが、(unsigned|signed) charとstd::byteへの変換はexplicitに許されている。これはアドレスに対する制限の最小単位だからだ。

なのでdoublestd::uint64_tの領域の先頭のポインタをchar配列へのポインタだと思い込んで移し替えれば良い。これは合法にビットレベルキャストをするための頻出パターンなのでコンパイラは(可能な場合)普通にmov命令に変換する。なのでパフォーマンスへの影響などはあまり恐れる必要はない。

さて、このstd::uint64_tのビットパターンを確認する。特定のビットだけが問題なので、まずそこ以外をマスクして関係のないビットを全て0にする。その後、関係のあるビットの1を0に、0を1にひっくり返せば、関係のあるビットが全て1なら結果的に全てのビットが0になり、全体が0になる。逆に0が混じっていれば1のビットが残り、非ゼロになる。その実装は以下のようにできる。ここで、仮数部は見ていないのでinfとnanの両方が引っかかることに注意したい。

bool check_NaN_or_inf(const double x) noexcept
{
    std::uint64_t n;
    std::memcpy(reinterpret_cast<char*>(&n),
                reinterpret_cast<const char*>(&x),
                sizeof(double));
    // bin: 0111'1111'1111'0000'0000'...'0000
    // hex:    7    F    F    0    0 ...    0
    constexpr std::uint64_t mask = 0x7FF0000000000000;
    return ((n & mask) ^ mask) == 0;
}

C++11の範囲で書いたが、C++14から整数リテラルに区切り文字として'を使えるようになったので、上のように0が連続する場合は使ったほうがいい。0が一個多かったりしてもこれではわからないので。

-Ofastにしても消し去られてはいないことをチェックしよう。以下のリンクで普通のstd::isfiniteと見比べるとわかりやすい。std::isfinite版は即値を戻り値レジスタに入れてすぐreturnしている。std::isfiniteの戻り値にnotをつけないと結果が一致しない(今回実装した関数はNaNかinfの時にtrueを返すので)のにつけ忘れたままembedしてしまった。まあブログだしいいや。

godbolt.org

アセンブリをよく見ていると、実際memcpymovqになっていることがわかる。それと、ビット演算部分が少し最適化されて出力されている。このサイトはアセンブリの頻出命令はカーソルホバーすると解説が出てくるので活用してほしい。

NaNチェックの素直な実装であるreturn x != xよりはbit演算分だけ命令数が多くなるが、-Ofastでも使えるNaNチェックができた。あまりホットスポットでないところで使いましょう。

float版は以下の通り。

bool is_NaN_or_inf(const float x) noexcept
{
    std::uint32_t n;
    std::memcpy(reinterpret_cast<char*>(&n),
                reinterpret_cast<const char*>(&x),
                sizeof(float));
    // bin: 0111'1111'1000'0000'0000'...'0000
    // hex:    7    F    8    0    0 ...    0
    constexpr std::uint32_t mask = 0x7F800000;
    return ((n & mask) ^ mask) == 0u;
}

nvccのホスト用コンパイラも意識すべき

nvccはホスト(CPU)側のコードを生成するのはホスト用のコンパイラgccとか)に任せており、これは--compiler-binder <path>で指定できる。

以下nvcc --helpから抜粋。

--compiler-bindir <path>                   (-ccbin)
        Specify the directory in which the host compiler executable resides.  The
        host compiler executable name can be also specified to ensure that the correct
        host compiler is selected.  In addition, driver prefix options ('--input-drive-prefix',
        '--dependency-drive-prefix', or '--drive-prefix') may need to be specified,
        if nvcc is executed in a Cygwin shell or a MinGW shell on Windows.

CPU側のコードをシステムのものではないコンパイラコンパイルしたならこれも変えたほうがいいっぽい。

CMakeを使っているときは、

set_target_properties(project_lib_cuda PROPERTIES
    COMPILE_FLAGS "-ccbin ${CMAKE_CXX_COMPILER}")

のようにする。どうやら勝手に入れてはくれないようなので(もしかしたら知らないフラグがあるのかも)。


何が起きたかを一応書いておく。まず、どこでも使えるロガーをstaticを使って作っていた。かなり簡単にすると以下のような感じ。

template<typename charT, typename traitsT = std::char_traits<charT>>
struct logger
{
    template<tyepname ... Ts>
    static log(Ts&& ... args)
    {
        if(filename.empty())
        {
            throw std::runtime_error("filename is not initialized");
        }
        std::basic_ofstream<charT, traitsT> ofs(filename, std::ios::out | std::ios::app);
        (ofs << ... << std::forward<Ts>(args));
    }
    static std::basic_string<charT, traitsT> filename;
};
template<typename charT, typename traitsT>
std::basic_string<charT, traitsT> logger<charT, traitsT>::filename;

全てをstaticにすることでどこからでもログに書き込める。必要無いのにtemplateにした理由はグローバル変数のODRを回避できるから(C++17を使えるならinline変数を使うのが吉なのでこの実装は時代遅れ)。別スレッドから同時に呼び出すと大変なことになります。

これをCUDAを使うコードのCPU部分と非CUDAな残りのコードの部分で共有して使っていた。一応注意書きしておくと、上のはCUDAカーネル内では動かない。CPUでCUDAカーネルを呼び出す直前とかに呼ぶためのものだ。

今やっているプロジェクトではcudaのコードは別にコンパイルされて最後にリンクされる。ここで、CPU版のコードで初期化したfilenameがcuda部分では初期化されていないというエラーが発生した。それまで普通にログが出ていたのに、CUDAコードに入った瞬間に「ファイル名が与えられていない」と言って落ちたので最初は混乱した。

謎の直感によって-ccbin ${CMAKE_CXX_COMPILER}を足すとこのエラーは収まってちゃんとログが書きだされたので、違うコンパイラを使っていたせいでリンクが上手くいかず、全体で共有されているstatic変数とCUDAコードを含むnvccコンパイルされたオブジェクトの方で異なるアドレスに置かれていたのだろう。

CUDAと非CUDAコードを混ぜて書くときは注意しましょう。

thrustにasyncサポートが入っていた

thrustというライブラリがある。

thrust.github.io

直接使ったことがなくても、CUDAをインストールしたら付いてくるので知らずにインストールしている人は多いと思う。

これはCUDAをC++で使う上で最高レベルに便利なライブラリで、もはやこれ無しでCUDAプログラミングをするのは考えられないくらいだ。CUDA以外にもintel TBB、OpenMPC++標準のthreadサポートのバックエンドが入っており、たまにこれ無しでOpenMPのコードを書くと少しだるく感じてしまう。

上に貼った公式サイトでは最新リリースが1.8.1 (2015)になっていて、開発が止まっているのかな? としばらく思っていたが、GitHubのレポジトリのリリースを見たらちゃんと最近も活発に開発されている。単に公式サイトの更新をサボっているだけっぽい。

github.com

見ていて驚いたのが、最新の一つ前のリリースで、CUDA 10.1に同梱されているもののリリースノートだ。タイトル通りの変更が入っている。

Release Thrust 1.9.4 (CUDA 10.1) · thrust/thrust · GitHub

背景を少し説明しておこう。

まず、GPUはCPUから独立したアクセラレータなので、GPU上での計算をしている間CPUが待つ必要は多くの場合ない。なので、CPUはGPUに命令を発行したのち、終わるのを待たずに次の処理を始めることができる。もちろんデータの依存関係などはこのとき注意を払う必要がある。

これまで、Thrustの処理は一部だけがasyncだった。一部のthrust関数は処理が終わるまでCPUをブロックするが、一部は非同期に実行することで呼ばれた後すぐにCPUを自由にしていた。だがどれが非同期だったかを常に意識するのはしんどい作業で、データの依存関係などを考える際に少しばかりの不安があった。

このリリースで、thrust::future<T>thrust::eventが実装され、thrust::async名前空間に複数のアルゴリズムが実装された。future<T>は簡単に言うと「まだ処理が終わっていないかも知れないが、終わるとT型の結果が格納されるハンドル」で、処理が終わっているかどうかのフラグと、いざ必要になった時に計算が終わるまで待って中身を取り出すという操作をさせてくれる。thrust::async::*以下にある関数を呼び出した場合このようなハンドルが返ってきて、即座にCPUは自由になる。その後はfutureを持って回ることで非同期に処理を進めることができ、いざ同期するとなったときにそのハンドル経由で計算が終わるのを待てばよいということになる。

これに伴って、今まで非同期に動いていたthrustのアルゴリズムが必ず同期的に動くようになった。この機能によって、当然、既存のコードは若干の速度低下を被るかも知れない旨が注意喚起されている。だが私としては今まで通りのコードが確実に同期されるという保証と、非同期的に動かしたいときに明示的に指定できるようになったことのメリットが圧倒的に上回っているように感じる。不安が払拭され、実際にバグが減るだろう。

もうひとつ大きいと思った変更は、thrust::optionalがサポートされたことだ。GPUoptionalが使える! 代数的データ型の有用性は今更語る必要も無いだろう。オイオイ最高だな。

あと、std::pmrライクなメモリリソースもサポートされている。CPUとGPUのどちらからでも触れるunified memoryを扱うuniversal_memory_resourceなどもサポートされており、かなり凄まじい機能追加に思える。C++最新規格にかなり追いついているので、数年前と比べるとCUDAプログラミングはかなりの速度で簡単になってきていると言っていいだろう。ちょっと前はCUDAのインストールはXを落としてからしたほうが良いよとか言われてたのに、隔世の感がある。

というわけでもっとCUDAプログラミングをしましょう。

2019年にやったこと

多分細々したことをもうちょっとやってるけど忘れた。

書いてない部分で去年書き上げた論文のためのデータ集めとか研究用ソフトの開発とか解析とか先行研究調査とか論文執筆をしてる。一つは同僚と一緒に(co-1st)、一つは一人で。開発を研究の隙間でやっていると言わないと怒られが発生しますが(時間的にもそっちの方が正確)、表現の問題に過ぎません。

1月

RustでRay tracing in one weekendをやったようだ。2日目の「2日で終わらすぞ」圧がコミット履歴から感じられる。一応本の内容は2日で終わったようだが、その後数日リファクタリングをしたりthe next weekの内容をちょっと足したりしている。ちょっと待てこれ前回の記事で引き合いに出したレポジトリなんだが、あの話そんな長いこと記事にせずに置いてたの?

github.com

あと、自作の分子動力学シミュレータのための一人グループを作っている。これは研究室で永きに渡って改造され続けてハウルの動く城みたいなビジュアルになっているFortran製のシミュレータをオリャッとC++11で書き直したものだ。機能拡張が容易になった(同僚談)上に数千〜数万粒子程度の入力で安定して2倍程度の高速化を果たした。グラフを外挿するに粒子数に対してこの速度差は開き続ける一方に見える。以降、1ヶ月に1回、その月に実装できた機能をまとめてリリースタグを打っている。書くと長くなるのでこれ以降はこれに対する機能追加は省略しているが、これもいずれ纏めておかなければ。絶対リリースノートと一緒に月一でちゃんと書いた方がいいんだよそういうの。

github.com

ちなみに今でもFortran製のin houseシミュレータの並列絡みのヤバいバグ修正を助けたりとかはちょいちょいしている。初期に想定してなかった改造によってデザインレベルで無理が来ているところは深いところから書き直す以外に直しようがないが、水漏れにダクトテープを巻きつける程度のことはできる。設計に無理が出ていて困るのは水漏れ箇所に行き着くのが難しくなることだ。現在そこが努力によって補われているが、そろそろ厳しくなっているようにも見える。

とはいえレビュー1分で落とせるだろ、くらいの明らかなデータ競合とかも残っていたので、どうも並列プログラミングの知識・経験を持った人が内部に足りていないように思う。プログラミング学習や教育を評価できる体制ができていないのでさもありなんというところだが、そこからとなると改善はそう簡単にはいかないだろうな……。

あと、練習がてら分子動力学計算の解析用ツールをRustでちょっとだけ書こうとしていた。C++テンプレートよりも制約が厳しいRustのジェネリクスの洗礼を受けている跡が見られる。もうちょっとゆるくやった方が簡単そうだが。結局あまり使い物にはならない状態で放置してしまっている。まあ今まで書いてきた分で何とかなってるからな……。

github.com

他に、gitbookの使い心地をいくつかのレポジトリを実験台として使って試しているようだ。

2月

去年の冬はレイトレを楽しんでいたらしく、GPUでレイトレをするコードを書いている。やっぱりカッコいい画像がすぐに目に見えるというのはモチベーション維持に良い。 CUDAからOpenGLのバッファに書き込むのに非常に苦労した記憶があるし、結局どれが一番いい方法だったのかいまだにあやふやだ。そこ以外特に困ることはなかった。まあこれは空間分割とかちゃんとやってないし……(あとでGPUでBVH作るやつの話が出てきます)。

github.com

この月は、toml11をwindowsで使った時だけおかしくなるという問題に対応していたようだ。これは結局なんだったのかと言うと、asciiモードでファイルを開いた際に¥r¥n¥nに変換されるせいで、バッファのサイズと読み込める文字に差が出てしまっていたことが問題だった。中でバイナリモードで開いて変換を抑制した記憶がある。それと、この月にtomlのシリアライズを実装したようだ。

3月

toml11に怒涛の機能追加とバグ修正をしている。これは、Burntsushi氏のテストケース群を使ってバグを探していたからだろう。エッジケースを見つけるのに重宝した。最近はケース追加が止まっているようだが。

github.com

ここで主にエッジケースでのtoml規格への適合度がかなり上がった。これらの修正はBoost.tomlにはあまり取り込めていない。なのでBost.tomlのTOML規格適合度はtoml11やcpptomlと比較して若干低い。それは意識してはいるのだが、どうにも時間がなく手を出せずにいる。

toml11にはこの月に""_tomlリテラルも追加されている。

4月

toml11のパーサの速度が結構遅いことがわかったのでオーダーで改善し、平均100カラムくらいで数万行とかのファイルもそんなに待たずに読み込めるようになった。これは記事にしていた気がする。

他に、""_tomlリテラルの曖昧なケースについて対処していた。例えば以下のようなケースだ。

const auto v = "[1]"_toml;

""_tomlリテラルではキーのない値だけのリテラルを許容していたことと、tomlが数字のみのキーを認めていることが組み合わさった結果、これは「1」という空テーブルの宣言とも、整数1だけが入った配列とも取れるようになってしまった。この曖昧さを取り除くため、このような場合はテーブルと解釈することにした。確実にテーブル名と明示する方法はないが、確実に配列にしたければ、以下のようにできるはずだからだ。

const auto v = "[1,]"_toml;

インライン配列でのtrailing commaは特に禁止されていないので使えるはずだが、実はインラインテーブルではtrailing commaは許可されていない。これは数少ない現行のtomlへの不満点というか、意味がわからないところだ。

# toml
a = {b = 42, c = "foo",} # これはダメ

全然関係ないけどこのへんでV-langがバズっていたような記憶がある。あれ今どうなったのかな。ちょっと前にまだ続いてるっぽい話を聞いたけど。

5月

自作のシミュレータのスレッド並列化をしていた。分子動力学計算の並列性は自明なのでそんなに詰まる部分はなかったが、一つだけ面白いことがあった。近接リスト構築をロックフリーにしたくてメモリをバカ喰いするクラスを作ったら、メモリアクセスが遅すぎて数倍遅くなってしまったのだった。これも書いておかないと。

後、ゴールデンウイークに小さなlispを動かせるインタプリタを書いた。GW後半にさしかかって普段通りの生活しかしていないことに焦りを覚えて何か簡単に作れるものを作ろうとしたことを覚えている。これは日記に書いたな。

後この月にTypes and Programming Languages (TaPL)の勉強会が始まった。これそんな前の話なのか。

6月

Expression templateとチェーンルールを使って型レベルで自動微分をするライブラリ(?)を作った。数式を書くと関数オブジェクトが帰ってきて、微分できるらしい。

github.com

    constexpr auto x  = kaysha::variable<double>{};
    constexpr auto _2 = kaysha::constant<double>{2.0};
    constexpr auto _1 = kaysha::constant<double>{1.0};

    constexpr auto f = x * x + _2 * x + _1;

    std::cout << "f(x) = x^2 + 2x + 1" << std::endl;
    std::cout << "f(1) = " << f(1.0)   << std::endl;

    constexpr auto df = kaysha::differentiate(f);

    std::cout << "f'(x) = 2x + 2"       << std::endl;
    std::cout << "f'(1) = " << df(1.0)  << std::endl;

    constexpr auto ddf = kaysha::differentiate(df);

    std::cout << "f''(x) = 2"            << std::endl;
    std::cout << "f''(1) = " << ddf(1.0) << std::endl;

この時はフォワードモードだとかリバースモードだとかの存在を知らず、チェーンルールをゴリ押しで適用しているだけの実装になっている。ちょっとその辺勉強したくなってきたな。

7月

toml11のメジャーアップデートをしている。こんなペースなのか。これは記事にしたと思う。

それとの時系列がいまいち思い出せないが、この月にtoml11への寄付として10ドル相当の暗号通貨を見知らぬ人から貰ったのがかなり嬉しかった。

この数ヶ月くらいは、長く関わっているecellという細胞シミュレータのconda-forgeサポートに他のメンバーと共に奮闘しており、それに関連したことが色々とある。CI先でだけ落ちる現象を直そうとしてフラストレーションを高めた数ヶ月だったが、まあ上手くいってない時はそんなもんだろう。

それに関連してpybind11にC++14の機能が使われていたのを見つけて、ワークアラウンドを書いて取り除いたりした。これでpybind11コントリビュータだぞ! この程度ででかい顔するな。

github.com

後、Parallel Linear BVHのCUDA実装をした。しばらく探したのにブログ記事と論文とOpenMP実装以外は見つからなかったので……。そんなにちゃんとテストしてないけど多分動くと思う。このレポジトリは無言で上げたにしては微妙に反響があった。まあいつも宣伝なんかしてないんですけどね。Twitterとかで宣伝をしてはどうか。

github.com

そういえばnvidiaのRTXシリーズに積んであるRT coreはBVHをハードレベルで実装しているという噂で、いつcudaから叩けるようになるのか心待ちにしているのだが、なんかそういう話あるんですかね? まだcuda11とかは出てなさそうだし、そもそもnvidiaAPI公開する気があるのかわからないけども。是非使いたいから公開して欲しいんだけどな。

待ってたらそのうち変な人たちがoptixをリバースエンジニアリングしてやり方見つけ出しそう。

8月

おっ、この月は何も新しいレポジトリを作ったりしてないぞ。

月一で足している分子動力学シミュレータにはまたいくつか機能を足しているが、他のレポジトリは普通にバグ修正とかリファクタリングをしていたっぽい。まあ普通そういう月があるものだ。

9月

この月も同じ感じのようだ。何かで忙しかったっけな? 分子動力学シミュレータの方は粛々と開発を続けているようだが。そういえば2つ目の論文サブミットしたのこの月か。

10月

Rustのmdbookでpdfを出したくて、mdbook-latexをちょっと弄っていた。ざっと読んだ感じ基本的にlatexのテンプレートのdocument部分にmarkdownから変換していったものを貼り付けていくという作りっぽかったので(シンプルでわかりやすいのは好印象)、日本語文書を使うためにはテンプレートを置き換える必要があり、その部分を実装してプルリクを出したらすぐマージされた。

この月はtoml11のマイナーアップデートもしている。

11月

研究に使うコードを主に書いていた。進捗報告会があったからだな。

12月

conda-forgeの諸々が片付いて、事にあたっていた他のメンバーと一緒にconda-forgeグループのメンバーになった。

それと、ecellに追加した新たなシミュレーションアルゴリズムのバグを概ね取り切ったと思われるので、これもどこかにまとめておきたい。副産物として、ヤバい積分をした結果の特殊関数だらけの無限級数を計算するライブラリの行数が減りこれまた倍程度速くなった(未公開)。どうも研究の現場で使われているコードを私が書き直すと倍近く高速化するっぽい(自慢)。まあ最初に書かれたプロトタイプは色々試した結果ちょっと変になっているところが多いので、ちゃんと理解して整理したら無駄が消えて速くなるのは後出しジャンケンみたいなもので、ある程度は当然のことだろう。

研究用途のものでも高速化は重要だと思うんだけど、これは当然分野に依るのだろうが、速くするだけだと手間がかかる割に論文になりづらいので誰もしたがらず結果ノウハウが蓄積しないというイメージがある。たまに凄くできる人もいるが、そういう人はスッと企業に転職したりする。研究を支えるソフトウェア技術に関する評価体制をもっと整備した方がいいのではないかと思う。まあ観測範囲が狭いだけかもしれないが。

あと、いつの間にかcppreferenceのライブラリリストにtoml11が足されていた事に気づいた。

en.cppreference.com

結構嬉しい。

そういえば、他のライブラリのTOML規格準拠度とか使いやすさを秋くらいに調べたりしたのだが、私がそういう記事を書くと公平な判断になりようがないのではないかと思いお蔵入りにしてしまった。でも日本でTOMLの細かな仕様とそのパーサのC++での実装に現時点で一番詳しいのは私だろうから、書くのに一番向いているのではないかとも思っていて、非常に難しいところだ。最初に注意書きをしたらOKだろうか。

まとめ

博士課程は精神や肉体を病むそうで1、知り合いはそれぞれストレス性の神経麻痺で入院したり胃に穴が開いたり鬱になったりしていたそうだが、私は今の所まだ自覚症状がない。だがメンタル的にすでに結構厳しいのはわかっているので自分をいたわっていきたい。最近腹を下していないのに腹が痛いことがあったのでちょっとやばいかもしれない。胃に穴か?

思い出しながらここまで書いていて思ったが、もう少しアウトプットをこまめにした方がいいのだろう。細かいことでも短くても非自明なことがなくても記事にしていった方がいい。記録していないことは忘れてしまうし、私にとって自明になってしまったものでも後から来た誰かの助けになるかもしれないからだ。知識の高速道路を整備するのは必要なことだ。

去年はPullReqを出すだけではなく来たのを捌いた一年でもあった。ライブラリを使ってもらえるようになるのが嬉しいという一心で捌いてきたし、大半は見つけにくいバグを見つけてくれているものなのでありがたかったのだが、それでも「そんなことある!?」みたいな想定外のことは対応がやはり難しい。時間がかかってしまったものもあった。それなりに素早く対応できているのは、私がプライマリ作者で広く使われているライブラリはtoml11一つだからだろう。もっとたくさんのライブラリ・ツール群を保守している人は大変だろうなと思う。よく放置しないよな。放置してる人も結構いるけど、気持ちはよくわかる。これが10倍になったら「ちょっと待って」と思って別のことを始めたまま永遠に忘れたりしそうだ。

PullReqは基本的に英語で来る。なので返事も英語でするわけだが、問題なのは、私の英語力が拙いため、こちらの返信が十分丁寧かどうかがわからない事だ。PullReq送ってくれるような善意溢れる人をないがしろにしたくないので頑張って丁寧と思われる英語を書いてみたりしていたのだが、基本的にCould youとかを使いつつ婉曲的に提案を出すだけになっており、実際にはこれで丁寧になっているかわからない。流石に4 letter wordsを使わないくらいはわかる(「Remove this fxxking code」がダメなのはわかる)けれども。まあ名前から非ネイティブだとわかってるだろうし気にしないでくれるだろうと祈っておく。PullReqとかIssueの返事丁寧さ問題はみんながどう対応しているのか気になっているところだ。

今年一年実感したことだが、以下の4つの問題はそれぞれ質的に異なる。

  • 決まった目的のための保守しなくていいスクリプトを素早く実装する
  • 決まった目的のための保守する必要のあるプログラムを設計し、実装し、メンテする
  • 目的の全貌がわからないプログラムを設計し、機能を順次足していく
  • 目的の全貌がわからず誰がどんな環境で使うかもわからないライブラリを設計し、実装し、メンテする

時間制限がある場合は特有の難しさがあるので例外だが、時間制限がないなら私にとっては下に行くほど大変だった。考えないといけないことが増えていくからだと思う。日本では絶対に再現しないバグとかがあって、サマータイムに詳しくなったりした。機能を足す時に設計に無理が来ることがわかって頭を抱えたこともあった(結局設計を少し変えた)。3つめまでは極論自分で頑張ることで経験できるが、最後のは少しは使ってくれる人がいないと経験できない。toml11を使ってくれる人が増えてこういうことを経験できたのは技術力の観点からとても良かったと思う。

そういう意味で、去年は技術面ではそれなりに恵まれた1年だった。今年も頑張らなければならない。後、来年以降の身の振り方をそろそろちゃんと考えないといけない……。

rsqrtの精度とレイトレの不審なアーティフアクト

だいぶ前(前の冬なので半年以上前)に、Ray tracing in one weekendを読みながらRustでレイトレを実装していたのだが、そこでrsqrtを使ったら画像が変になったのを思い出した。理由は今も(ちゃんと調べていないので)よくわかっていないが、とりあえず思い出せるようにしておこうと思う。

rsqrtと呼んでいるのは以下の手法のことで、1 / sqrt(x)の形の計算を高速に近似するアルゴリズムだ。これは普通に計算すると、sqrtも重ければ割り算も重いので二重に重い。ベクトルを正規化したり長さを計算することが多いレイトレーシングやシミュレーションではこの計算が至る所に出てくるので、それを少しでも速くしたいというわけだ。

en.wikipedia.org

これは浮動小数点数の仕組みを上手く使った面白いトリックなので、興味があれば「Algorithm」のところを追ってみて欲しい。

ところで、今のCPUにはこのためだけの命令がある。

software.intel.com

なので自分でこのトリックを実装するよりもこれを直接呼んだ方が速いだろう。多分。

というわけでレイトレを実装したときにこれを使ってみた。このコードがそれに当たる。

github.com

以下の画像がrsqrtを使わずに素直に1/sqrt(x)を使ったものだ。

f:id:tniina:20191122195710p:plain
Fig.1 rsqrtなし

基本的にコンパイラは精度が落ちるような最適化を勝手にはやらないので、最適化の過程でこれが勝手にrsqrtにされるということはない。g++なら-ffast-mathを使うと勝手にやるが、rustcだと多分等価なオプションはないと思われる。議論はしてるようだが。

internals.rust-lang.org

調べてたら「rustcからLLVM IRを出してそれぞれ好きに最適化してあとでリンクしろ」と言っているストロングパーソンがいた。

閑話休題、上の画像に対して以下がrsqrtを使ったもの。画面中心付近に円形のアーティフアクトが出ている。結構広範囲にうっすらと広がっていることが見て取れる。

f:id:tniina:20191122195737p:plain
Fig.2 rsqrtあり

ど真ん中から同心円状にノイズが走っているので、レイの長さ関係の誤差が原因だろうなということはすぐにわかった。rsqrtをなくすとこのノイズも消えるので、実際そうらしい。

Rayは起点と方向の2つのベクトルから構成されているのだが、方向のベクトルは正規化されているという前提で残りのコードを書いているため、Ray::newは方向ベクトルを常に正規化する。ここでrsqrtが使われていて、そして多分そこで生じる誤差が原因なんだろうなとは思う。

原因がわからないのは気持ちが悪いので、これが原因でいくつかのピクセルが暗くなる理由を少し想像してみた。真っ先に思いついたのは、衝突判定して衝突点を返す時にレイの長さがおかしいせいで衝突点がオブジェクト内部にめり込んでしまって、反射した次の瞬間に同じオブジェクトにぶつかって暗くなる、というものだ。というわけで、衝突点がめり込まないように衝突距離を少しだけ短くしてみた。とりあえず真ん中の球体でその効果がなくなるまでやったのだが、1%を超えるレベルでずらしたため、今度は透明なオブジェクトの描画が完全に壊れてしまった。

f:id:tniina:20191122203723p:plain
Fig. 3 rsqrtで衝突位置を手前に補正(正ではない)

一応真ん中のLambertな球体からは例のアーティフアクトは消えている。最初の直感は正しかったのかもしれないが、もう少ししっかり調べないとまだ確信はできない。なんにせよ、透明なオブジェクトの描画が狂ってしまうならこのワークアラウンドは使えない。

どちらかというと、拡散反射のオブジェクトはここまで距離に無理な補正をかけてもそれっぽく見えてしまうのかと驚いた。全部を普通に実装しているものと見比べると明るさなどに若干の差があるが、並べないとわからない程度の差だ。壊れていることがすぐにはわからない場合、レイトレのデバッグは地獄かもしれない。いや、プロは収束すべき画像が見えるようになるのかもしれないが。

TOMLで型の異なる要素を持つ配列が許可された

起きたこと

TOML v0.5.0までは、配列に異なる型の要素を混ぜるのは許可されていなかった。

array    = [1, 2, 3]              # OK
invalid1 = [1, "foo", 2019-11-08] # error
invalid2 = [1, 2, 3.14, 4, 5]     # error

が、混ぜたいという人がかなり多く、ずっと議論が続いていた(Make arrays heterogeneous · Issue #665 · toml-lang/toml · GitHub)。そしてついに、長らくTom氏と共にTOMLライブラリを管理してきたPradyun Gedam氏の賛成によってPRが作られTom氏の賛成もあってマージされた。

TOMLファイルへの影響

この変更によって、どんなメリットがあるか。まずはマージされたPRにある例を持ちだそう。

# Mixed-type arrays are allowed
numbers = [ 0.1, 0.2, 0.5, 1, 2, 5 ]
contributors = [
  "Foo Bar <foo@example.com>",
  { name = "Baz Qux", email = "bazqux@example.com", url = "https://example.com/bazqux" }
]

1つめの例は比較的自明なもので、数値の配列において整数と浮動小数点数を区別する必要がなくなる。2つめの例はもうすこし実践的なもので、簡潔なフォーマットと詳細なフォーマットを混在させられるというものだ。この例では、ライブラリのコントリビュータ一覧で、「名前とメールアドレスを繋げた文字列」という伝統的なフォーマットと、「名前、メールアドレス、個人のwebページなどのフォームを持つテーブル」というより詳細でよく定義された(が、書くのが面倒な)フォーマットを混在させられるようになる。

他に、Issueで議論されていた他の例も紹介してみよう。例えば、以下はサーバーの情報を管理する例だ。一つのサーバーごとに以下のような情報を覚えておくことにする。

[connection]
host = "127.0.0.1"
port = 6000
data_timeout = 1000
conn_timeout = 5000
lazy_connect = true
only_udp = false

このようなサーバーが複数ある時、v0.5.0までのTOMLでは以下の2つのやり方があった。1つめは、そのままこのテーブルをArray of tablesにする方法。

[[connections]]
host = "127.0.0.1"
port = 6000
data_timeout = 1000
conn_timeout = 5000
lazy_connect = true
only_udp = false

[[connections]]
host = "1.2.3.4"
port = 6000
data_timeout = 500
conn_timeout = 5000
lazy_connect = false
only_udp = false

# 必要なだけ続く...

これでは縦に長くなりすぎるだろう。そういった場合のため、inline tableの表記を使う方法もある。

connections = [
    {host = "127.0.0.1", port = 6000, data_timeout = 1000, conn_timeout = 5000, lazy_connect =  true, only_udp = false},
    {host =   "1.2.3.4", port = 6000, data_timeout =  500, conn_timeout = 5000, lazy_connect = false, only_udp = false},
    # ...
]

今度は横に長過ぎる。

もしArrayが型が入り乱れることを許してくれていれば、以下のような書き方が許されるだろう。

connections = [
    # Host            Port  Data    Conn    Lazy     Only
    #                       timeout timeout connect  UDP
    # -------------------------------------------------------
    [ "127.0.0.1",    6000, 1000,   5000,   true,    false ],
    [ "1.2.3.4",      6000,  500,   5000,   false,   false ],
    [ "5.5.5.5",      6000,  100,   2000,   true,    true  ],
    [ "90.10.133.17", 6000, 1000,   5000,   true,    false ],
    [ "18.20.18.20",  7777, 5000,   1000,   false,   true  ],
    # -------------------------------------------------------
]

個人的な感想

これは 非常に 大きな変更だ。TOMLを使っているアプリケーションの中はフォーマットの再考をするものも出てくるかも知れない。私の個人的な意見は、この変更は積極的に反対はしないが完全に肯定するわけでもない、という微妙な立場だ。とはいえ無関心ではない。ここまで大きな変更に無関心でいるのは難しい。正確に言うと、私は簡潔な書き方が選べるようになることを喜びつつも、多分私はこの書き方を採用した場合に起きるかもしれない混乱(とまではいかないだろうが)を少し心配している。喜び7、心配3くらいの比率で。私は元来少し度が過ぎた心配症だからかもしれないし、あるいは、メリットだけが強調されている場合、デメリットを考えてバランスを取りたくなるのかもしれない。

別に諸手を上げて賛成していない理由はtoml11で配列中の型の混在を指摘するエラーメッセージを作るのに苦労したからではない(比較的苦労はなかった--テーブルとdotted keyの関係の方がよっぽど苦労した)し、toml11でこの機能がサポートできないからでもない。実際、これに対応するための変更は配列を読んだ時に型チェックをやめることだけだ。

基本的にこの変更は今までの書き方を壊すものではないし、むしろより簡潔な書き方を許す拡張だ。なので積極的に否定する理由はない。選択肢が増えるのは、たいていの場合よいことだ。では何を心配しているのか。これによって簡潔さを高めようとすると、厳密性やエラーメッセージのわかりやすさを若干犠牲にするからだ。この記法を採用する際は、アプリケーション側でエラー処理などに力を入れるか、あるいはエラーからの回復やエラーメッセージのわかりやすさをある程度までは諦めなければならなくなる。

例として、サーバーの情報を書くテーブルで、ミスで数字を一つ抜かしてしまったとしよう。

connections = [
    # Host            Port  Data    Conn    Lazy     Only
    #                       timeout timeout connect  UDP
    # -------------------------------------------------------
    [ "127.0.0.1",          1000,   5000,   true,    false ],
]

このミスはパース時には見つけられない。文法違反はどこにもないからだ。パーサからすると、これは配列の配列があるだけだ。問題はパース結果から設定を読み込む際に起きる。アプリケーションは6個の要素を要求したが、5つしか要素がない。

toml11でこれをサポートした場合、ユーザーは恐らく以下のようなコードを書くだろう。

for(const auto& connection : toml::find(file, "connections").as_array())
{
    const auto server = toml::get<
            std::tuple<std::string, int, int, int, bool, bool>
        >(connection);
}

その場合のエラーメッセージは以下のようになる。

[erorr] toml::get specified std::tuple with 6elements, but
 there are 5 elements in toml array.
 --> TOML literal encoded in a C++ code
 5 | [ "127.0.0.1",          1000,   5000,   true,    false]
   | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ here

あ、エラーメッセージで要素数の後に空白入れるの忘れてるな。しかも[error]の部分typoしてるし。後で直します。

この場合、要素数が異なること以上の情報はなく、ポート番号が抜けているという情報はない。これをアプリケーションが指摘するには、型チェックを行い、何番目の要素が歯抜けになっているかを探さなければならない。今回は一番最初の文字列と後ろの論理値2つが存在しているので、ポートか、データタイムアウトかコネクションタイムアウトのどれかが抜けていることはわかるが、その3つのうちどれが抜けているかはわからない。この3つは同じ整数型で、どの一つを取り除いても2つの整数が残るからだ。2つの整数があるということだけからは、どの値が抜けているかは確定できない。ポート番号としてはあり得るがタイムアウトとしてはあり得ない値、みたいなものがあればヒントになるが、そういうものがなければお手上げだ。推測すらできない。となると、ユーザー自身に抜けている値を探させることになる。まあ普通探せるだろうとは思うが。

もしこれがテーブルだったなら、toml::findを使うはずで、「キー"port"がありません」というエラーメッセージが出ただろう。

[error] key "port" not found
 --> TOML literal encoded in a C++ code
 1 | {host = "127.0.0.1", data_timeout = 1000, conn_timeout = 5000, lazy_connect = true, only_udp = false}
   | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ in this table

これなら、ユーザーはファイルを開いて注意深く読むよりも前に「あ、ポート番号忘れてた」と自分のミスを知る機会が得られる。また、アプリケーションにはポート番号にデフォルト値を与える機会を得られる。

キーがわからないというのなら、例えばCSVファイルでよく行われているように、キーを明示的に配列として渡してはどうか。

connections = [
    ["Host", "Port", "Data timeout", "Conn timeout", "Lazy connect", "Only UDP"],
    # ------------------------------------------------------
    [ "127.0.0.1",          1000,   5000,   true,    false ],
]

残念ながらこれでも解消されはしない。問題はデータの配列にある。これでもやはりまだ、"Port", "Data timeout", "Conn timeout"のどれが欠けているかという情報は提供されないままだ。ユーザーがカラムごとのデータを明示できることはよいことだが、全ての問題が解決するわけではない。

要するに、キーの並び順だけで意味のある(が型が同一である)値を指定させる場合は、そのアプリケーションのユーザーに十分な知識と忍耐力と正確さを要求しなければならないわけだ。

もちろん、これは心配のしすぎだ。この程度のことでこの拡張をリジェクトするようなら、人々はPythonC++の代わりにHaskellとRustを使っているだろう。入力する情報を削ることによって簡潔さを向上させると、エラーからの回復やエラー箇所の推測は難しくなる。当然のことだ。情報がなくなっているのだから。それでも簡潔さを取った方がいい場合や、ファイルの簡潔さが重視されるアプリケーション、簡潔さが何よりも勝る文化圏というものはあるだろう。選択肢が増えることは純粋に喜ばしいことだ。

とはいえ、この機能を使って設定ファイルを書こうと思っているアプリケーション開発者は、情報を削ることによって難しくなることもあるということは念頭に置くべきだろう。それが簡潔さと比べて受け入れられるデメリットなら採用すればいいし、そうではないなら従来通りの記法を使う。選択肢が増えるのが喜ばしいことに変わりはないが、選択肢が増えたことによって自分の選択に責任が生じるのは開発者のつらいところだな。覚悟はいいか? オレはできてる。

追記

実装した。あとはCIを待ってマージしたら、TOML11_USE_UNRELEASED_TOML_FEATURESを定義すると使えるようになるはずだ。

ところでコミットメッセージでPRの番号をメモったらGitHubで勝手にコミットメッセージがリンクされてしまった(自分のレポジトリに#xxxってつけた時かリンクをフルに書いたときだけだと思っていたのでhtttps://github.com/の部分を取っていたのだが、普通に検出されてしまった)。別にアピールする気はなかったのだが。