templateクラスを継承するtemplateクラスのデストラクタをoverrideし、かつdefault指定してextern templateでビルドするとintel compiler (v18)だけlink errorになる

タイトルが長い。

でもこれ以上何かを削ると不正確なタイトルになる。もしかするともう少し条件を広げても同じエラーが出るかも知れないが。

extern templateというのがあり、以下の記事に詳しい。

qiita.com

かい摘んで説明しておこう。ヘッダファイルでextern template class X<T>と書くと、クラスXの型引数Tに対する実体化が抑制され、リンクさえすればいいはずの別のソースでは実体化をしなくなる(declarationだけ生成する)のでコンパイル時間が減る。代わりに、その実体化(template class X<T>)をソースファイルに書いておかないとリンクエラーが起きるようになる。

こいつを使っていたら、intel compilerでだけリンクエラーになるという不可思議なことが起きた。同じ構成の最小コードを作っても再現したので、メモしておく。報告したほうがいいんだろうか。

まず、基底クラスのヘッダファイルがある。

// X.hpp
#ifndef FOO_X_HPP
#define FOO_X_HPP

namespace foo
{
template<typename T>
struct X
{
    X()          = default;
    virtual ~X() = default;
    X(T x): value_(x) {}
    virtual void print() const = 0;
  protected:
    T value_;
};

extern template struct X<int>;
} // foo
#endif //FOO_X_HPP

最後にextern template struct X<int>と書いてあるので、このヘッダをインクルードしたファイルでもしX<int>というものが現れても、X<int>の実体化は起きない。それはどこかで実体化されてコンパイルされているとわかっているので(extern templateが書かれているから)、実装の実体化はせず宣言だけを生成して、リンク待ちになる。あとでリンクができるように、X.cppで明示的に実体化するよう指定しておく。

// X.cpp
#include "X.hpp"
namespace foo
{
template struct X<int>;
}

さて、これを継承するクラスが他にあるとしよう。

#ifndef FOO_Y_HPP
#define FOO_Y_HPP
#include "X.hpp"
#include <iostream>

namespace foo
{
template<typename T>
struct Y final: public X<T>
{
    using base_type = X<T>;

    Y() = default;
   ~Y() = default;
    Y(T x): base_type(x) {}

    void print() const override
    {
        std::cout << this->value_ << std::endl;
    }
};

extern template struct Y<int>;
} // foo
#endif //FOO_X_HPP

これも同様にextern templateしておく。

#include "Y.hpp"
namespace foo
{
template struct Y<int>;
}

これを使ってみよう。

#include "Y.hpp"
#include <memory>

int main()
{
    std::unique_ptr<foo::X<int>> y(new foo::Y<int>(42));
    y->print();
    return 0;
}

X.cppY.cppmain.cppをそれぞれビルドしてリンクする。g++だと上手く行く。だがicpcだと以下のようなエラーで失敗する。

icpc -std=c++11 main.cpp X.o Y.o -o foo
X.o: undefined reference to `foo::X<int>::~X()'
Y.o: undefined reference to `foo::Y<int>::~Y()'

いや、定義しているんだが。

なんとなく、default指定しているからかなという勘が働いたので、デストラクタの= default;{}にすると通った。

これは、誰の挙動が正しいのだろう。なんとなく普段の気持ちからして、gcc/clangで通ってintel compilerで落ちるならintel compilerのバグではという気持ちになるが、他の挙動がそうだったからといってこれも同じとは限らない。extern templateにしていなかったものは普通にその場で実体化されて通っているっぽいので、そもそも= defaultが使えないというわけではない。多分、実体化の際の扱いが微妙になってしまっているのだろうと思う。

というわけで規格を見ているのだがまだ何に対応するのかよくわからない。とりあえず一回切り上げてこのワークアラウンドをコミットするか。

TOMLで浮動小数点数の指数部分でleading zeroが許可される

github.com

これがTOMLの作者(Tom Preston-Werner)の手によってマージされていた。一応masterブランチにマージされてもリリースまでにrevertされる可能性はあるとはいえ、それなりの確率でTOML 1.0.0でリリースされると思われる。

この変更で、以下のようなTOMLファイルが合法になる。

fp0 = 1.0e01
fp1 = 1.0e+01
fp2 = 1.0e-01

これまで上の書き方は許されず、下のようにするしかなかった。

fp0 = 1.0e1
fp1 = 1.0e+1
fp2 = 1.0e-1

だが、多くの言語で指数表記(C++ならsciencitific)を指定すると上の書き方になる。printfでも%eを使うと、指数の桁は最低2桁となり、上の表記になる。

なので拙作のtoml11でも、上の書き方のファイルには文法違反のエラーを出していたし、シリアライズの際はleading zeroが入っていたら検出して消すためのコードを追加していた。これはあきらかに面倒だった。常に小数表記にしたらどうか、とも一瞬考えたが、明らかに読みにくいファイルが生成されてしまうのでだめだ。指数表記が適している場合というのは多く存在する。アボガドロ数なんかを小数表記で出力することを考えてみるとわかると思う。

これに困るのはCやC++だけではない。このPRの元になったIssueで議論していた人が使っていたのはPythonだ。自作Pythonスクリプトで自動生成したファイルがTOMLパーサにリジェクトされるのはつらい、というコメントが初期の議論を引き起こしている。

github.com

Issueのコメントでは、Rubyでputsをしても上の表記になるとの指摘がある。後続のコメントからはLuaHaxeもそうらしいし、ちょっと見た限りGoもそうだ。Rustは違うが(!)。みんな困っていたわけだ。

初期の議論では、「leading zeroを許すと8進数表記と区別がつかないので筋が悪い」「整数ならそうだが、浮動小数点数でexponentで8進数が使われたことはない」のような議論があったのち、作者(Tom)が現れて「整数に関してleading zeroは許されていないので、浮動小数点数の一部(の実質的に整数である部分)でだけleading zeroを許すのは文法が複雑化する、特定の言語の出力をサポートするために文法を複雑化するのは「単純な言語」というゴールに反する」と主張し、Issueをクローズしている。

だがその後もこの議論は燃え続けた。「特定の言語の出力をサポートするために」と言うが、実際には殆ど全ての言語が、浮動小数点数を指数表記で出力する際は指数部分を最低2桁にする。「特定の言語」があまり有名でない1つ2つの言語であればまだしも、最も有名で最も使われている言語達の大部分だった場合、多少の言語の複雑化も許容する価値はあるだろう、というような議論が続いた。

ごく最近の、そして決定打になったらしき議論では、殆どの言語がサポートしているということに加えて以下のような主張がされている。「通常使用される物理量は、指数部分が3桁になることはまずない。電子の質量(9.11e-31 kg)や太陽の質量(1.99e30 kg)でも余裕を持って2桁に収まる。だとすると、出力時に最低2桁、符号も必ず書くようにしておくと、浮動小数点数の配列が見目麗しく並ぶことになる」。これは暗にこの変更によって一部のTOMLファイルが読みやすくなると主張しているように思う。さらに、科学技術計算用途でTOMLを使うことが難しくなる、という問題も指摘されており、実際にこのためにTOMLを科学技術計算用途で使うのをやめた人も登場した。

これらの指摘によってTomも納得し、この変更が入ることになった。Tomは特にどのコメントを重く受け止めたかは何も言っていないので、もしかすると単に最初の議論から2年も経って気が変わっただけなのかもしれないが、個人的にはこの変更によって可読性が高まる(数値が綺麗に並ぶ)ケースがあるという主張が重要だったのではないかと思っている。もちろん実際に問題になるケースがかなり具体的に示されたということもあるのだろうが。TOMLは単純でかつ人間にも機械にもわかりやすいことを目指している。なので特定の言語の出力をサポートするためだけに文法を複雑化することはないが、人間にとって読みやすくするために文法を複雑化することは許容される、と考えると、Tomの姿勢はとても一貫した、言語の設計者として理想的なものではないだろうか。

まあ個人的にはこの変更はもっと早くに入って欲しかったけれど。

BOOST_ROOTが無視される

今までBoostは1.58~1.69までしかインストールしていなかったのだが、今日1.71をビルドしてインストールした。そしてこれにハマってCMakeCachebuild/ディレクトリを消してはCMakeを実行しなおすことを繰り返して10分くらい時間を無駄にした。

github.com

リンク先のコメントを訳しておこう。

  • Boost <= 1.69 では、コマンドライン引数か、CMakeのset()か、環境変数からBOOST_ROOTを指定することでBoostをインストールした箇所を指定できた。
  • 世界は ❤🌷🌞😎👍
  • Boost >= 1.70 では、"config mode"が優先され、コマンドラインまたはset()を使って指定した場合一切の警告なしにBOOST_ROOTが無視される。
  • ただしconfigモードでは、/optのような場所にインストールされているBoostも発見する。そのときは、環境変数BOOST_ROOTを与えていても、警告なしに上書きされる。
  • 元の挙動に戻す、つまり"config mode"をぬけ出すには、Boost_NO_BOOST_CMAKE=ONを設定する必要がある。
  • ただし上記のオプションはコマンドライン引数またはCMakeLists.txt内のset()を使って指定される必要があり、環境変数で指定した場合警告なしに無視される。
  • "config mode"でインストールした場所を指定するには、Boost_DIRを使用する。
  • ただし上記のオプションは環境変数set(Boost_DIR <value> CACHE)の形で指定される必要がある。もしコマンドライン引数や通常のset(Boost_DIR <value>)を使用した場合、警告なしに無視される。
  • Boost_ROOTというものがBOOST_ROOTの他に存在する。
    • (後続コメントより)これら2つは異なる。BOOST_ROOTFindBoost.cmakeが参照するもので、Boost_ROOTcmakepolicy CMP0074で指定される挙動、<PACKAGE_NAME>_ROOTを参照する過程で使用される。
  • 世界は💔🔥⛈😕👎

いや、これは結構💔🔥⛈😕👎ではないだろうか。せめてdeprecatedだと教えてほしい……。

OpenGL + CUDA interopのコードを読む

しばらく前に、CUDAを使ってGPU上に作ったデータをそのままGPU上でpixelの配列にしてそのまま画面に描画しようとしていた。

cuda_practice/ising-show at master · ToruNiina/cuda_practice · GitHub

例えばこれなのだが。これは、isingモデルをシミュレーションしつつ描画するコードだ。シミュレーションしているのでスピンの状態がGPUに乗っている。OpenGLのバッファもGPUの上にあるのなら、CPUに戻してから再度GPUにコピーするのは無駄以外の何物でもない。GPU上で直接RGBの配列を作って、それを描画すれば良い。

というわけでここではcudaGraphicsGLRegisterImageを使ってOpenGLのrender bufferをcudaのgraphics resourceにバインドしている。そこからcudaArray_tを取り出して、 cudaBindSurfaceToArrayを使ってsurface<void, cudaSurfaceType2D>に変換し、そこにsurf2Dwriteで書き込んでいる。具体的なコードは長くなるので上のレポジトリを見てもらうのが早い。モデルが単純なので「画像をGPUで作ってそれを直接ウィンドウに描画する」と言う目的のノイズになる部分はほとんどない。

で、上のコードには問題がある。cudaBindSurfaceToArrayだいぶ前から非推奨なのだ。「動くから良し!」で諦めたい気持ちはあるが、非推奨にするからにはより良い方法が提供されているはずだろう。そっちを使った方がいいに決まってる。特に自分の趣味プロジェクトなら、締め切りも要件もないのだし。

で、さまよっていたらこんなレポジトリを見つけた。

github.com

これを読めばdeprecatedでないやり方がわかるのではないかと思って読んでみた。ちなみにこのレポジトリではdriver APIが叩かれているので、上のレポジトリで私が叩いているruntime APIと名前が違う。だがだいたい対応するものがあり、概ねdriver APICUxxxはruntime APIcudaXxxに対応している。全てがこの規則通りと言うわけでもないが、まあ目安として。

まず、ここでOpenGLのバッファたちをCUgraphicsResourceにbindし、CUarrayを取り出している。ここまでは同じだ。

gl_cuda_interop_pingpong_st/simulateCUDA.cpp at master · nvpro-samples/gl_cuda_interop_pingpong_st · GitHub

実際、非推奨なのはcudaBindSurfaceToArrayなので、問題なのはCUarrayに書き込む方法だけなのだ。このあとCUarrayをどうするのか追いかけていこう。

このCUarrayはここでCUsurfref型のm_surfWriteRefという変数に関連づけられている。

gl_cuda_interop_pingpong_st/simulateCUDA.cpp at master · nvpro-samples/gl_cuda_interop_pingpong_st · GitHub

さらにm_surfWriteRefcuModuleGetSurfRefによってmodule"volumeTexOut"という名前で関連づけられている。

gl_cuda_interop_pingpong_st/simulateCUDA.cpp at master · nvpro-samples/gl_cuda_interop_pingpong_st · GitHub

moduleって何だ? そしてこれ以降m_surfWriteRefは一切使われておらず、糸が途切れてしまった。

どうなってるんだ? と思って書き込んでいそうな方を見てみると、volumeTexOutというグローバル変数が突如現れている。

gl_cuda_interop_pingpong_st/heatEquation.cu at master · nvpro-samples/gl_cuda_interop_pingpong_st · GitHub

レポジトリ内を検索しても、ここと、これをコンパイルしたであろうptx、そして

cuModuleGetSurfRef( &m_surfWriteRef, m_module, "volumeTexOut" )

の呼び出ししかない。

このあたりで、m_module"volumeTexOut"というのがこのグローバル変数(ファイルを跨いでいるので通常アクセスできない)を指しているとしか考えられなくなった。moduleはおそらくGPUで走るプログラム全体か、翻訳単位あたりを指しているのか、と思ってドキュメントを検索してみた。

The driver API provides an additional level of control by exposing lower-level concepts such as CUDA contexts - the analogue of host processes for the device - and CUDA modules - the analogue of dynamically loaded libraries for the device.

Programming Guide :: CUDA Toolkit Documentation

どうやらそういうことらしい。そしてmodule経由でsurfaceを取り出すためにdriver APIの方を叩いているのだということもわかった。

どうなっているのかわかったし、多分これで真似してみる準備は概ね整ったと思うが、力技すぎないか!? driver APIを使って別のファイルにあってスコープも分かれているグローバル変数を文字列で名前指定して取得する? 本当にそれでいいのか、nvidiaよ……。

メモ:cerealと派生クラス2

cerealで継承を使ったサンプルを少し前に掲載したが、これらのクラスがテンプレートだったらどうなるか。特に、継承関係を登録するマクロ。

この部分だ。

CEREAL_REGISTER_TYPE(sample::Derived)
CEREAL_REGISTER_POLYMORPHIC_RELATION(sample::Base, sample::Derived)

これは型名を取得するので、もしこれらのクラスがtemplateパラメータを持つ場合以下のようにする必要がある。

CEREAL_REGISTER_TYPE(sample::Derived<int>)
CEREAL_REGISTER_POLYMORPHIC_RELATION(sample::Base<int>, sample::Derived<int>)

ではtemplate引数が複数あったら? マクロが丸括弧しか認識しないせいで<T, Alloc>などと書くと<TAlloc>という2つのマクロ引数だと思われてしまいマクロが壊れるというのは有名な話だ。普通のマクロだと型名・関数名を丸括弧でくくると対処できたりすることもあるが、さて?

結論を言うと、1つめのCEREAL_REGISTER_TYPEは大丈夫だ。そのまま渡して構わない。

CEREAL_REGISTER_TYPE(sample::Derived<int, double>) // OK
// CEREAL_REGISTER_POLYMORPHIC_RELATION(sample::Base<int, double>, sample::Derived<int, double>) // NG

1つめのマクロは__VA_ARGS__を使っている。まさしくこの問題に対処するための実装だ。だが2つめのマクロは残念ながら2つの型名を取るので、__VA_ARGS__を使ってもどこで分割していいかわからない。そのせいで2つめのマクロは使えない。マクロの評価を遅延して二重にマクロを書けば何とかなるのかも知れないが……。

簡単な回避策としては、template引数を与えた型にエイリアスを貼るというものがある。

namespace sample {
using base_type = Base<int, double>;
using derived_type = Derived<int, double>;
}
CEREAL_REGISTER_POLYMORPHIC_RELATION(sample::base_type, sample::derived_type)

もうひとつの自明な回避策は、マクロ展開を手動ですることだ。

github.com

うーむ、だがこれはちょっと面倒くさいな……。何か上手い方法は無いものだろうか。思いついたら絶好のPR対象だと思うのだが。マクロを間に挟んで遅延させる、みたいなのを真剣に考えてみるべきだろうか。

ついでにCEREAL_REGISTER_TYPEを全特殊化について書く必要がなくなるようにtemplate引数を取って部分特殊化をするマクロでも書いてPR送ろうかと思ったが、そっちはCEREAL_BIND_TO_ARCHIVESなるマクロに転送されていて、そっちはstaticオブジェクトの初期化を通してグローバルに情報を保存しているようなので、そういうわけにはいかないことがわかった。明示的に特殊化を書いて渡さないとインスタンス化が起きず関数が呼ばれなくなると思う。そういうのを加味してももう少し単純化できたかも知れないが、その単純化に意味があるレベルかはわからなくなったのでそのアイデアはお蔵入りした。

でもまだCEREAL_REGISTER_POLYMORPHIC_RELATIONがどういう風になっているかわからない。こっちはもう少し入り組んでいるので、ちゃんと読まないとわからないっぽい。そもそも動画見つつお菓子を食べながらテキトーに人のコードを読むのは行儀が悪いのではないでしょうか。休日なんだから別によくない?

あーCプリプロセッサじゃなくてC++の構文に対応して型とかもあるいい感じのマクロ欲しいな〜。多分1億回言われてると思うけど。

メモ:cerealと派生クラス

メモです。cerealは説明不要のシリアライズ用ライブラリ。

派生クラスをstd::unique_ptr<Base>として持っている時の最小サンプル。

  • cereal::base_class<Base>(this)シリアライズするのを忘れない
  • CEREAL_REGISTER_TYPE(sample::Derived)名前空間の外に書く。セミコロン不要。
  • CEREAL_REGISTER_POLYMORPHIC_RELATION(sample::Base, sample::Derived)名前空間の外に書く。セミコロン不要。
  • Derivedはデフォルトコンストラクタを持っていないといけないっぽい
#include <cereal/types/base_class.hpp>
#include <cereal/types/string.hpp>
#include <cereal/types/memory.hpp>
#include <cereal/archives/binary.hpp>
#include <fstream>
#include <string>
#include <memory>

namespace sample
{

class Base
{
  public:
    virtual std::string name() const = 0;
    virtual std::string parameter() const = 0;

    template<typename Archive>
    void serialize(Archive&)
    {
        return;
    }
};

class Derived : public Base
{
  public:
    Derived()  = default;
    ~Derived() = default;

    Derived(std::string p) : parameter_(std::move(p)){}

    std::string name()      const override {return "Derived";}
    std::string parameter() const override {return this->parameter_;}

    template<typename Archive>
    void serialize(Archive& ar)
    {
        ar(cereal::base_class<Base>(this), parameter_);
        return;
    }

  private:
    std::string parameter_;
};


template<typename T, typename ... Ts>
std::unique_ptr<T> make_unique(Ts&& ... args)
{
    return std::unique_ptr<T>(new T(std::forward<Ts>(args)...));
}
} // sample

CEREAL_REGISTER_TYPE(sample::Derived)
CEREAL_REGISTER_POLYMORPHIC_RELATION(sample::Base, sample::Derived)

int main()
{
    {
        std::unique_ptr<sample::Base> der = sample::make_unique<sample::Derived>("hoge");

        std::ofstream os("out.cereal", std::ios::binary);
        cereal::BinaryOutputArchive archive(os);

        archive(der);
        std::cout << "name: " << der->name()      << std::endl;
        std::cout << "para: " << der->parameter() << std::endl;
    }
    std::cout << "=====================================================\n";
    {
        std::unique_ptr<sample::Base> bs;

        std::ifstream is("out.cereal", std::ios::binary);
        cereal::BinaryInputArchive archive(is);
        archive(bs);

        std::cout << "name: " << bs->name()      << std::endl;
        std::cout << "para: " << bs->parameter() << std::endl;
    }
    return 0;
}

以下ではソースを読みもしていないのにcerealが中で何をしているか想像している。


自分でシリアライズライブラリを書こうとしたときの経験からして、恐らく上記のマクロで型情報を読んだあと実施にその型を生成するための関数なんかを用意しているのだろう。別個に書いても動くこと、ヘッダオンリーであることから、恐らくstaticなコンテナがcerealの奥深くにあって、このマクロでそこにBaseからDerivedにキャストする関数か関数オブジェクトを登録していくのだろう。動的な型情報、型名など、で分岐してその型のインスタンスを作らないといけないので、テンプレート関数かテンプレート構造体を特殊化して入れているのではないか? ある型のインスタンスを作る関数は静的に作っておかないといけないわけだし、その目的にはテンプレート特殊化が一番手っ取り早かろう。

// 普通に自前で実装するなら以下のような感じになると思われる。
std::unique_ptr<Base> load(const archive& data)
{
    if(data.name == "Derived")
    {
        // ここに、名前と型の関係がハードコードされていると解釈できる
        return make_unique<Derived>(load<Derived>(data));
    }
    if(data.name == "Derived2")
    {
        // これをあり得る全パターンやるか、マップを作って管理しておく
    }
}

// とはいえライブラリはユーザー定義クラスを事前に知ることはできない。
// なので上のような愚直なコードは書けない。
// なら、上の if ブロック一つ分に相当する関数をそれぞれ作成して登録し、
// 名前からテーブル引きするしかないだろう。
Base* load(archive data)
{
    // 上でハードコードされていた関係をここでマップとして持っている
    const auto loader = loaders<Base>::get_loaders(data.name);
    return loader(data);
}

この想像があっていた場合、そういう関数オブジェクトは普通に考えてcerealの内部実装に近いところだから、cereal::detailとかに定義されていると思われる。cereal::implとかかもだが。なのでマクロはnamespaceをその場で開くはずなので、ユーザー定義名前空間の中でCEREAL_REGISTER_TYPEを書いても動かない。

まあこんな単純じゃないだろうが。想像で説明するくらいならソースコードを読め。

parallel linear BVH (LBVH) をCUDA+thrustで書いた

久々に少し非自明なことをCUDA+thrustでやった。大体動くようになるまでまる一日(半日+半日なので別のmetricsでは2日)かかった。

github.com

BVHとはなんぞやという話は、以下の記事にとてもよくまとまっている。というか、多分この記事の著者は私のような空間分割エンジョイ勢ではなくその道のプロと思われる。

shinjiogaki.github.io

なのでちゃんとした話は上の記事を見てもらって、ここでは細かいことをすっ飛ばしてイメージだけ喋ろう。オブジェクトのそれぞれに、それをすっぽり覆うような長方形を被せることを考える。これは各軸にアラインした長方形、直方体、またはN次元矩形とする。これをAxisAlignedBoundingBox、AABBという。計算が簡単なのでよく使われる。その後空間的に近いオブジェクト同士をまとめていって木を作る。ここで、木のノードにも子ノード全てを覆うような長方形が被せられている。葉ノードだった場合は持っているオブジェクトを全て覆うような長方形が被せられている。

ここで、あるオブジェクトが他の何かに衝突していないか調べたいとする。もしノードを覆う長方形がそのオブジェクトと衝突していないなら、ノードの中にある要素もまた今調べているオブジェクトとは衝突しない。ノードを覆う長方形の中にあるからだ。なので、このような場合にはノードを丸ごと無視できる。これにより、木の品質にもよるが、衝突判定が高速にできるようになる。

さて、で、今回やったことの話に入ろう。LBVHという種類のBVHがある。この手法は、ツリーを構築する際、Z-order curveなどの空間充填曲線を使ってオブジェクトにインデックスを割り振る。空間充填曲線なので、インデックスの値が近いもの同士は近くに並んでいるだろう(近くにあってもインデックスが大きく違ってしまうということはあり得る。まあある程度は仕方がない)。これを使えば、「近いものをまとめる」という操作が簡単になる。インデックスが近いもの同士をくっつけて行けば良いのだ。

このLBVHで、ノードの構築を完全に並列化するという手法が2012年に発表されている。nvidiaの中の人が考えたらしく、nvidiaのdevblogでも記事が出ている。構築方法については、先に紹介したbvhの記事でも詳しく紹介されている。ちなみに日本語の方の記事を読もうと思っている・読んだ人のために一応補足しておくと、δ(i, j) は i か j の少なくとも一つが範囲外になるとき δ(i, j) = -1 になる。

devblogs.nvidia.com

大まかな構築方法、探索方法に関しては、nvidia の devblog と bvh の記事で十分説明があると思うので、ここでは流し見しただけではわかりにくかった実装の詳細をメモしておこうと思う。

morton codeが衝突したとき

このアルゴリズムでは、どのオブジェクトが同じノードにアサインされるかがmorton codeで決まる。これは空間充填曲線の一種であるZ-order curveによって付けられたインデックスで、どういう順序になるかは先に紹介したnvidiaの記事か以下のWikipedia記事が参考になると思う。

ja.wikipedia.org

これによって、明示的に距離を計算したりグリッドを切ることなく、近くにあるものをまとめる操作ができる。しかもこの曲線はフラクタルなので好きなだけ細かくできる、つまり空間をどれだけ細分化するかは比較的簡単に選ぶことができる。とはいえ有限の情報量しか格納できないマシンを使っている以上無限に細かく分割することはできない。今回実装したアルゴリズムでは32bit整数のビットをxyz方向に均等に割り振って、10bitずつ、つまり1024分の1まで割るようになっている。

これは稀に衝突する。空間をXYZのそれぞれの方向で1024分割したグリッドがあると思ってほしい。ほとんど衝突しないだろうが、オブジェクトが十分密な領域があると、一つのグリッドに複数のオブジェクトが入る可能性はゼロではない。で、論文のSection4で触れられているが、このアルゴリズムはmorton codeがユニークであることを前提としているので、morton codeが衝突した場合はそれ用の処理が必要になるそうだ。

その場合にどうしたらよいかだが、nvidiaの記事のコメント欄では「そうならないようにオブジェクトを取り除け」とか「morton codeにインデックスをアペンドしろ」とかが飛び交っていたが(論文ではmorton codeとインデックスのビット表現を結合したもの(多分全部で64bitになっている)を使っていた。今読み直して気づいた)、実際に実装してコードも公開している(CUDAではなくOpenMPのようだが)人は determine_rangefind_split 関数で隣とmorton codeが等しいかどうかを見て、それらについては特別な処理をしている。同じmorton codeを持つものは共通のparent nodeを持つようにし、そのparent nodeはleafまで階段状に割られているようだ。二分していくよりも深くなってしまいそうだが、衝突がそもそも起きにくいので実用上問題にならないだろう。

では私はどうしたかというと、先にmorton codeでオブジェクトをソートしたあと、同じmorton codeについてreduceし、各ノードがオブジェクトプール内のレンジを持つようにした。これは分子動力学シミュレーションや粒子法による流体シミュレーションなどでセルリストから近傍リストを作るときにも使われる手法なので、私にとっては馴染みのあるものだ。以下のように衝突しまくりのシーンがあるとしよう。

object list | 9| 5| 8| 2| 3| 6| 1| 7| 4| 0| (sorted by morton code)
morton code | 1| 2| 2| 3| 3| 3| 4| 4| 5| 6|

この morton code をkeyに使って、 thrust::constant_iterator<unsigned int>(1)reduce_by_key する。 reduce_by_key は key が同じであるような要素についてreduceする(典型的には総和を取る)。要素として constant_iterator(1) を渡しておけば、実質的にcountとuniqueが同時に達成できる。

morton code | 1| 2| 2| 3| 3| 3| 4| 4| 5| 6|
constant    | 1| 1| 1| 1| 1| 1| 1| 1| 1| 1|
| reduce_by_key
v
reduced key | 1| 2| 3| 4| 5| 6|
reduced val | 1| 2| 3| 2| 1| 1|

続いてこれの inclusive_scan を取る。これは、その要素までの和を計算するものだ。

reduced val | 1| 2| 3| 2| 1| 1|
| inclusive_scan
v
scanned val | 1| 3| 6| 8| 9|10|

このスキャン済みの配列を後ろに一つずらし、先頭に0を追加すると、以下のようになる。実際には後で pop_front すると大変なので、最初に一つ分大きいリストを作っておき、書き込み先を begin() の一つ先にしておくとよい。

object list | 9| 5| 8| 2| 3| 6| 1| 7| 4| 0|
morton code | 1| 2| 2| 3| 3| 3| 4| 4| 5| 6|
              ^  ^     ^        ^
              |  |  +--+--------+
              |  |  |  | ...
scanned val | 0| 1| 3| 6| 8| 9|10|

この scanned val の隣り合う要素が、レンジになっていることにお気づきだろうか。最初のノードは、 object list[0, 1) の範囲に対応しているし、その次のノードは [1, 3) に、その次は [3, 6) に対応している。これで、葉ノードがオブジェクトリストのどの範囲に対応しているかを高速にルックアップできるテーブルが作れた。これにはもう一つついでの利点があって、葉ノードは常に自分のインデックス+1を指すようにしておくことができる( scanned val は先頭に足された 0 の分だけ、葉ノードの数よりひとつだけ大きくなる)。よって 0 を指す葉ノードはいない。なので、このインデックスをそのまま内部ノードかどうかの判定に使うことができる。 range_idx0 なら内部ノード、そうでなければ object list[range[range_idx-1], range[range_idx])の範囲を見れば対応するオブジェクトのインデックスがわかる。

というわけで、各葉ノードは厳密に一つの morton code に対応し、衝突があった場合は単にそれらすべてのオブジェクトが入ったレンジが葉ノードに対応することになる。これによってmorton code衝突問題は解決される。

悪い点としては、常に葉ノードはレンジに対応するので、めったに起きないと思われるmorton code衝突のためにすべての葉ノードが長さ1のレンジを持っており、ルックアップが一回余分に発生してしまうということだ。なので、衝突を心配しなくていいという安定を取った結果少し遅くなっていることになる。これがどう効いてくるかはアプリケーションに依るだろう。キーが64bitになるのを気にしていたが、これに比べると単純にインデックスを足したほうが速かったかもしれない。たいていの場合は衝突しないのだし、ソートが終わったタイミングでuniqueして被っていなかったらそのまま、被っていたらmorton codeをindexで拡張する、というのでよかった気もしてきた。あとで変えようかな?

追記(7/31 1:13): 衝突を検出して、もしあればmorton code(32bit)にオブジェクトのインデックス(32bit)を結合して64bitに拡張し、それを使うように変えた。

Note: 分子動力学などでセルリストを使って同様のことをする場合、空のセルに気をつけないといけない。空のセルのCellIDは reduce 操作によって姿を消してしまうので、空のセルがあるとそこからあとのセルのインデックスとレンジのインデックスがずれてしまう。そのため、私が以前練習で書いたCUDA MDプログラムは、スキャッタを一度挟んで空のセルに入っている粒子数を明示的に0にしている。粒子密度が極端に薄い領域が出てくるようなシミュレーションは標準的ではないせいか、詳細すぎるので大抵の解説ではあまり触れられていない落とし穴なので注意。今回は、空のグリッドは単にどのノードからも参照されない(なので対応するノードがそもそも作られない)ので問題にはならない。

各ノードに対応するAABBの構築

さて、分割位置や子ノードが決まっても、AABBはすぐには決まらない。葉ノードのAABBはオブジェクトしか持っていないので最初からAABBが決まるが、葉ノードより上のノードのAABBは両方の子ノードのAABBが決まらないと決まらない。なので、完全に並列にしようとすると、すべてのノードで自身の子ノードが構築を終えるまで待つか、各ノードがそれぞれ葉ノードにたどり着くまで探索して自力ですべてのオブジェクトをマージしていくかのどちらかしかない。

後者は苦痛だ。明らかに、根ノードは全体を覆い尽くしているので、根ノードはすべてのオブジェクトのAABBをマージしなければならない。これは時間がかかるだろう。そしてそれが律速になることは明らかだ。なんとなく、少し待つとしても2つのAABBをマージするだけでいいほうが短時間で済みそうな気がする。

というわけでnvidiaのブログで(自然言語で)説明されていたやり方を実装した。まず、葉ではないノードのそれぞれに対応するアトミックに更新可能なフラグを用意しておく。その後、葉ノードすべてに対応するGPUスレッドを立ち上げる。葉ノードなので、オブジェクトしかもっていない。なのでAABBが即決まる。続いて、自身の親ノードを見に行く。BVHでは親ノードは2つの子ノードを持っているので、後に来た方がAABBを計算すればいい。両方の子ノードのAABBが必要なので、先に来た方はもう片方のAABBを知ることができず、処理ができない。なので先に来た方は片側のAABBが計算済みだというフラグを立てて休憩し、後で来た方が両方をマージしてそのノードの代表としてそのまた親に向かえばよい。

この計算はアトミックなCompare And Swapを使うことで達成できる。この命令は、あるメモリ領域に値を書き込むのだが、書き込む前に今の値を読む。そして期待している値と比べる。値が期待したもののままだったなら、そのまま書き込む。そうでなければそのままにしておく。CUDAでは、呼び出した側には読み込んだ値が帰ってくる(失敗したかどうかを返す環境もある)。その値が期待していたものと同じだったら書き込めたはずだし、そうでなければ今の値は帰ってきた値と同じになっている。

今回は、フラグをすべて例えば 0 で埋めておいて、 0 を期待して 1 に変更するような atomicCAS を発行すればよい。もし 0 が帰ってきたら、フラグは期待通り 0 だったことになり、それまで誰もフラグを変えていなかったことになる。安心して次にやってくるスレッドに処理を任せていい。もし 1 が帰ってきたら、既に誰かがフラグを立てていたことがわかる。この場合、そのスレッドが仕事をする必要がある。

なので以下のような形になるはずだ。

unsigned int parent = self.nodes[idx].parent_idx;
while(parent != 0xFFFFFFFF) // means idx == 0
{
    const int old = atomicCAS(/* flag address = */ flags + parent,
                              /* compare = */ 0, /* value = */ 1);
    if(old == 0)
    {
        // もう片側の子ノードの処理がまだ終わっていない
        return;
    }
    assert(old == 1);
    // 両側の子ノードの処理が終わった。AABBを計算
    //(既に反対側の子ノードがフラグを立てている)

    const auto lidx = self.nodes[parent].left_idx;
    const auto ridx = self.nodes[parent].right_idx;
    const auto lbox = self.aabbs[lidx];
    const auto rbox = self.aabbs[ridx];
    self.aabbs[parent] = merge(lbox, rbox);

    // 親ノードへ……
    parent = self.nodes[parent].parent_idx;
}
return;

最近傍探索

これはちょっと別の話になる。せっかく空間分割をしたので、最近傍のオブジェクトを取ってきたりしたい。元の論文はツリーを作るアルゴリズムに関してのことなので、最近傍クエリのことは書かれていない。そこで今回は、BVHとかなり似ているR-Treeで最近傍探索をするためのアルゴリズムを流用した。

1995年に、Nick Roussopoulos, Stephen Kelley FredericVincentらが「Nearest Neighbor Queries」という論文を書いている。これは、点と矩形の間に「mindist」と「minmaxdist」という2つの計量を導入して、探索の順序を決めたり刈り込みに使おうという論文だ。mindistは、矩形の最も近い点との距離で、最高の場合その矩形にどれだけ近いオブジェクトが入っているかを示している。minmaxdistはもう少し複雑だが、「矩形の中に存在している、考慮に値する近傍オブジェクトの中で最も遠いもの」との距離になっている。つまり、矩形に対して、「最高の場合に近傍オブジェクトがいる距離」と「最低でもこの距離以内に最近傍がいる、というような距離」の2つを定めている。多分これは文章で説明するより論文を検索して図を見てもらったほうが速い。

これらを使うと何が嬉しいかと言うと、もしある矩形のmindistが他の矩形のminmaxdistより遠ければ、その矩形を探索しなくて済む。というのも、mindistは望み得る最も近いオブジェクトとの距離であり、それがあり得る最も遠い最近傍オブジェクトよりも遠いなら、もう片方の矩形に入っているものの方が必ず近くにあるからだ。また、既に候補が一つ見つかれば、その候補までの距離よりもmindistが遠いような矩形もすべて無視できる。これによって枝刈りができ、探索効率が上がる。

これらの2つの距離を計算する関数を実装したら、あとは普通に探索すれば良い。

任意形状のサポート

これは少し毛色が変わって、アルゴリズムではなく実装・ライブラリ設計の話になる。

BVHはAABBさえ作ることができればどんな形状のオブジェクトも格納できる。なので、格納するオブジェクトを例えば点に絞ってしまうのはもったいない。

このような場合、複数のデザインチョイスがありえる。

  1. 基底クラスの(スマート)ポインタを持つことにし、その基底クラスが get_aabb() のようなメソッドを持っておく。ユーザーは適切にget_aabbを実装した派生クラスをそこに差し込む。BVHはget_aabb()を使ってオブジェクトのAABBを取得し、なんやかんやする。
  2. template を使って任意の型のオブジェクトを詰め込めるようにする。ただし、その型からAABBを取得する方法をなにか指定する必要がある。

1. はクラスベースオブジェクト指向言語で書かれたライブラリでよく見られるやり方で、形状を追加するのが容易だ(継承したクラスを作って、後から差し込めば木自体をコンパイルし直さなくても動くので)。ただし、既存の型(float4など)を使おうと思ったとき、基底クラスを追加できないので、継承しただけのラッパークラスを作る必要がある。また、オブジェクトにアクセスする際に必ずポインタの間接参照をするために遅くなることがあり得る。もちろんこれも場合に依って、オブジェクトを動かすときにポインタしか動かさずに済む分速いこともある。

2. は、Boostなどテンプレートをバリバリ使うライブラリが選ぶやり方で、間接参照がないこととコンパイル時にどの関数を呼ぶかが静的に決まるのでパフォーマンスが出やすい。代わりにコンパイル時間とコンパイルエラーメッセージの短さを犠牲にする。また、あとで違う型を入れることになった際の作業量が少し増える。少なくともコンパイルはやり直す必要がある。

私はGPU上で継承するのがつらそうだと思ったので2.を選んだ。

どちらの場合でもAABBを計算するメソッドを実装しなければならないが、今回はユーザー定義のAABBを取得する関数オブジェクトを追加で渡してもらうことにした。以下のようなノリになる。実際のコードではないが。

template<typename Object, typename AABBGetter>
struct bvh
{
    void f()
    {
        Object obj = /* ... */;
        AABBGetter get_aabb;
        const auto box = get_aabb(obj);
        return;
    }
};

やり残していること

intersection queryで見つかるオブジェクトの数を数える

今、衝突判定のために、ある矩形領域とAABBが被っているオブジェクトをすべて取ってくる、というような関数が用意されている。この関数はユーザーにバッファを作ってもらって、そのバッファの上限サイズと先頭イテレータを受け取っている。被っているオブジェクトが見つかったらイテレータに書き込んだあとイテレータを進める。こうすれば、staticなストレージに書き込んでほしいユーザーは上限と静的配列のbegin()を渡せばいいし、動的メモリ確保をして構わないから全部返してほしいユーザーはback_insert_iteratorを渡せば良い。その場合は上限はsize_tの上限でいいだろう。実質無限だ。

だが、CUDAで使うとき、最初に必要な量を教えてもらって、その後全体で必要なメモリを一気に確保して、めいめいそこに書き込んでいくという戦略もありな気がしてきた。なので、書き込まずにカウントだけして返すという関数を実装するのは決して無意味ではないように思う。

拡張morton codeのサポート

morton codeを使うと空間がおよそ一様に分割される。これは、非常に粗いポリゴンがある世界の中で、例えばゲームの主人公がいる周りだけがとても精密なポリゴンがある、みたいな状況だと不便だ。なぜなら、非常に粗いポリゴンによって広い世界が作られているなかで、主人公の周りだけ非常に密にオブジェクトがあることになる。すると、主人公の周りの、特に重要なはずのポリゴンでmorton codeの衝突が起きまくる。すると重要な箇所なのに木構造の品質が悪くなり、分割の恩恵が受けられない。

これは例えば粗いポリゴンと細かいポリゴンのそれぞれのために木を2つ作ってしまうというやり方で回避することもできるだろう。だが他に、morton codeにAABBの大きさを含めてしまって、小さなAABBと大きなAABBを区別できるようにするというアイデアがあるらしい。これに関しても、MortonCodeCalculatorのようなオブジェクトを受け取ることにすればユーザーが選べるようになる。これは比較的簡単な変更で入れられるので、サポートしておきたい。

User-defined predicatorのサポート

今の所BVHに投げられるクエリが、矩形とのオーバーラップと最近傍しかない。だが、多くのユースケースでは、ユーザーしか知らないオブジェクトの性質によってクエリの結果を分けたいこともあるだろう。例えば、りんごとみかんが入っているが、今は自分の周り1mにあるりんごの位置だけが知りたくて、みかんは必要ないとか。そういうときのために、Predicatorを受け取ってそれでフィルタするという機能は普通にありだ。ただ一般的な形で、使いやすく、高速に、となるとちょっと手間はかかるだろうが。

まとめ

実装しました。これでシミュレーションとかレイトレとか速くできるといいな