メモ: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を受け取ってそれでフィルタするという機能は普通にありだ。ただ一般的な形で、使いやすく、高速に、となるとちょっと手間はかかるだろうが。

まとめ

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

toml11 v3.0.0リリース

毎回思うんだけどどういうタイミングでリリースタグ打てばいいのかよくわからない。皆どうしてるんだろう。

github.com

基本的に前回記事を書いた時からそんなに変わっていない。一応自分で使ってみて気になった細かなことがいくつかあるが(例えばカスタムシリアライザを書いてるときにtoml::stringがただの文字列として(特殊文字エスケープとかがされずに)出力されたのにちょっと困った、など)、それ以上のことはない。

元々以前と同じテストに通っていたので、リリースするかしないかが完全に気分の問題になっていた。いや一応自前のツールのいくつかで使ってみてはいたけれど……。ちなみに今日は、「最初から完璧にするのは無理だしタグ打たないよりは打って変なこと見つかったらパッチバージョン上げるか」くらいの気持ちで出した。一応外部のエッジケース詰め合わせテストにはかけているし、個人でそれ以上の秘孔を突ける気があんまりしなかった。

しかしリリースのタイミングは結構困る問題だ。toml11は完全に気分でリリースしているが、自作のアプリケーションの一つでは、月イチでリリースすると決めているものもある。そっちでは追加すべき機能が無限にあるので、間に合った分だけマージしていくという作戦だ。バグ修正があればこのスケジュールに則らずにリリースするが、一ヶ月おき以外では新機能は追加しない。こうすることで「もうリリースしていいかな……いや見つけてないエッジケースがあるかも……テストももっとできるといえばできるし……」みたいなことをいちいち悩まなくて済む。バグがあれば明記してパッチバージョンを上げればいい。最初からありとあらゆるエッジケースを塞ぐなんて無理だ。「Done is better than perfect(多分動くと思うからリリースしようぜ)」だ!

だがこの月イチ作戦は困ることもある。ブランチが増えまくって頭が混乱してくることだ。というのも、topicブランチを一度リリース用のブランチ(今回はmaster)にマージしてしまうと、いざバグを見つけた時バグ修正のみのはずのリリースに新機能が混ざってしまうので、topicブランチが次のリリースまで生えまくるからだ。パッチリリース前にrevertrebaseで履歴を改ざんすればいいのだが、地味に手間なのでPRの時以外はやりたくない(PRの時は色々試した後最初から全部知ってたみたいな最小ステップのコミット履歴に改ざんしてカッコよく仕上げることはある。なぜならカッコいいから。あとノイズは減ったほうがレビューが楽だと思うので)。となると、topicブランチで開発し、そこでバグを見つけたらhotfixからmasterにマージし、開発が一段落したらtopicブランチはマージせずに次の機能に行く、という流れになる。なので未マージのブランチが増えてしまう。というわけで混乱する。何で個人開発なのにこんなにブランチあるんだ? たまにconflict解消とかしてるし……全部自分で書いてるのに……。

色々考えてみたのだが、この場合、多分nightlyとかdevelopみたいな感じの名前のpre-releaseブランチを作って、新機能が一段落したらそこにマージしていき、変なことが起きないか確認していって、リリースの時はそれを一気にmasterにマージする、万一topicブランチを次のリリースに入れないことが決まったら、それをdevelopから消して、topicブランチは後のリリースでまた試すために残す、みたいなことをすればちょっと楽になる。ここまで書いて思ったが、これはまんまgit-flowだな……。周回遅れで車輪を再発明してしまった……。

postd.cc

正直、この手のややこしいブランチ操作が必要になるのは古いバージョンにもパッチを当てていく必要が生じるような超大規模プロジェクトだけだと思っていた。例えばプログラミング言語とかだと、特殊な事情によって古いバージョンを使い続けるしかない人がたまにいて、そういう人たちのために古いバージョンの処理系にセキュリティパッチなどが当てられたりなんかしている。そういう場合、例えばv3を出してからも2系をサポートしていく意味はあるし、それにはブランチを切るのが適切だ。だが私のライブラリやアプリケーションにはそういうことは必要無いのでブランチもややこしくならない……はずだった。だが必ず月イチで新機能追加をリリースすると決めるとこういうことになるとは。何事もやってみるまでわからんものですね。

そういうわけで、「toml11でも月イチとかでリリースすると決めようかな?」という気持ちは結構萎えた。加えて、toml11はそれなりにもう色々済んでいて、そんなにたくさんやりたいことがあるわけではないのだ。細々としたことは残っているが、それらは正直なくても(私の使用状況では)そこまで困らないものと、非常に面倒なのに見返りは小さいせいで単純にやりたくならないことばかりだ。リファクタリングなんかはあるかも知れないが、そういう変更はパッチバージョンにしかならない。なので一ヶ月に一回マイナーアップデートするとなると、「前回と同じ」みたいなアップデートが増えかねない。そんなアップデートはする意味がない。

というわけでtoml11のリリーススケジュールは現状維持、つまり今まで通り何か修正やアップデートがあり次第、ということにした。ちなみにPRをマージすると、「早めにリリース版に名前書いておいた方がいいよな……」となってパッチでもマイナーでも自分で入れた変更しか無いときに比べてリリースが早まります。バグを見つけたら見限らずにPR送ってくれると比較的早めに対応しますよ。あと海外の人はともかく日本人っぽい人まで英語でIssueとか書いてるけど、日本語英語両方対応できるので、そこも気にしなくていいです。というのも私は英語より日本語のほうが得意だからです。誰が見てるかわからんので一応書いといた。

そういうわけでメジャーアップデートも一回区切りだ。しばらくは別の仕事だな。

toml11 v3ができてきた

以前「やりたいな〜」と書いた通りの変更をゴリっと入れてみた。まだマージしていないが、v3ブランチがpushされている。今まで通していたテストは全て通った。追加機能のテストも最低限は通った。しばらくテストの拡充をしてからベータ版を出すつもりでいる。

github.com

主な変更点は以下の通りだ。

  • toml::parsetoml::tableではなくtoml::valueを返すようになる
  • toml::valuetoml::basic_value<toml::discard_comments, std::unordered_map, std::vector>エイリアスになる
    • toml::tabletoml::value::table_typeの、toml::arraytoml::value::array_typeエイリアスになる
  • 型名、enum value_tの名前、is_xxxas_xxxの名前がsnake_caseに統一される
  • いくつかの便利関数のtoml::table向けオーバーロードが消え、toml::value版に統一される
  • コメント取得に関わるインターフェースが整理される
  • あまり使い勝手のよくなかった古の隠し機能、froml_tomlinto_tomlが消される(ユーザー定義型変換サポートが入ったので)

シンプルな使い方をしていたなら乗り換えコストは小さい方だと思う。事実、以下のコードは一切の乗り換えコストがない。どちらのバージョンでも動く。

const auto data = toml::parse("example.toml");
const auto num  = toml::find<int>(data, "key");
const auto vals = toml::find<std::vector<double>>(data, "vals");

が、とはいえこれは結構大きな変更なので、どうしてこうなったのか理由を書いておきたい。

toml11の歴史はたった3年だが、私のC++の経験(今年で5年め)と比べるとこの3年は長いのだ。

最初期はUpperCamelCaseを使っていた

これがまずある。だがしばらくしてsnake_caseの方を好むようになってしまって、段階的に移行したいと思うようになった。そのためにバージョン2では型名にエイリアスを貼って両方サポートするようにし、READMEではsnake_caseを推していくことで移行を狙っていた。

これのせいで少し厄介なことが起きていた。TOML公式レポジトリでは浮動小数点数Floatと呼ばれている。キャメルケースを使っているならこれをそのまま使えばよかったのだが、困ったことにfloatはキーワードだ。なのでスネークケースの型名はfloatingになるしかない。すると、is_floatas_floatがややこしい。自分でもどっちだったか忘れることがあるのに、ユーザーが使いやすいわけがない。さらに型情報のenumは(エイリアスが作れないので)キャメルケースのままだった。使いにくいことこの上ない。

なので、何かメジャーアップデートをすることがあればぜひ直したいと思っていた。ユーザーインターフェースの統一感は何より重要だと思っている。この名前を完全に統一するのは良いライブラリの必要条件だ。

というわけで、思い切って型名は全て統一するようにした。こう書くと当たり前のことすぎて「何行ってんだこいつ」だが……。v2で始まった型名の移行を完了する、と表現した方が妥当かも知れない。

最初期はtoml::valueはあまり重要視していなかった

バージョン1では、toml::valueは本当に単純なenum + unionという存在で、ほとんど機能を持っていなかった。これは、私が何かを提供するよりも、慣れ親しんだSTLコンテナに即変換できる型を使うことで学習コストが減るのではないかと期待してのことだった。

だが使ってみると、少なくとも私にとっては、ユーザー(私だが)定義関数を使って呼ぶのは少し面倒だし見た目も少し不恰好な気がして、大半の用途ならそれだけで事足りるような便利関数をいくつか用意してもいいかもなと思うようになった。

というわけで実装していったのだが、バージョン2で新機能を一つ追加するたびにtoml::valueの重要性が増していく。そのままの流れで、toml::valueはこのライブラリの中で重要な位置を占めるようになり、逆にそれ単体での機能の弱さや、他の関数で中心に据えられていないのがアンバランスになった。

toml::value中心に据えられていない一番アンバランスな箇所は、toml::parseの戻り値だ。これはtoml::tableを返す。確かにTOMLのファイルはテーブルと一対一対応するような構造になっているが、toml::tabletoml::valueが持つような追加の情報を一切持てない上、このせいで関数のオーバーロードを多めに持たないといけなかったり、型変換のせいでオーバーロード解決が曖昧になったりして目の上のたんこぶだった。

とはいえtoml::parseの戻り値を変更するのは凄まじいBreaking Changeだろう。なので勇気が出ていなかったが、逆に変更するなら今しかないので、これも思い切って入れた。

toml::valueをカスタマイズできるようにした

実を言うと上の二つは大きな要因ではあるがバージョンアップを決意した直接の要因ではない。勇気が出た理由はこれだ。

何ができるようになったかと言うと、テーブルや配列型として使えるコンテナクラスが交換可能になり、さらにコメントを保持するかどうかも決められるようになった。

これは「コメント保持して変更してシリアライズできない?」と言うIssueに対して考えた策を発展させたものだ。コメントを持つとなると、追加の文字列か何かを持つ必要がある。だが文字列は無視できるほど軽いものではない。なので、コメントなんかいらねーよ、と言うユーザーには負担を掛けずにコメントを使う理由のあるユーザーのサポートをするには、toml::valueを分裂させる他なかった。

ここから下される結論は、using string = basic_string<char>;のように、テンプレート化されたクラスのデフォルト型を使うというものにしかならないと思う。あ、いや継承してもいいのか。まあそれはさておき。コメントを入れられるコンテナを二つ用意しておいて、片方は普通に一行ずつコメントが入っているコンテナ、もう片方は永遠に空のままで無視できるサイズしかないコンテナにする。これを使い分ければ、どちらにも必要最低限の負担しかかからない。

以下のようなインターフェースにすればわかりやすかろう。

const auto data =
    toml::parse<toml::preserve_comments>("sample.toml");

こうすると今までと違ってコメント専用コンテナが(保持することを選んだ場合のみ)toml::valueに追加されることになるので、あとで変更できるし、シリアライズもできる。実際、シリアライズするときにコメントが保持されているかのテストも行なっている。結構使い道の幅が広がるのではないだろうか。

だがこれは大きな変化だ。そしてこれを入れるなら、ついでに他の型も変えられるようにするといいだろう。と言うわけでテーブルの実装をstd::unordered_mapstd::mapを選べるように(他はテストしていないがSTLコンテナとインターフェースが揃っているものなら、e.g. Boost.Container、使えるんじゃないかと思う)、アレイの実装をstd::vectorstd::dequeを選べるように(他はテストしていないが(略))した。

こうなってくると、新バージョンの目玉機能ができることになるので、整理だけのためにユーザーに不便を強いることにはならない。そう考えるとここでいっちょ整理してもいいのではないかと思ったのだ。

そしてこれが、toml::parseの戻り値型を変えたいと思った最後のひと押しになった。toml::tableはただのunordered_mapなので、追加の情報は持てない。だが実際には、「ファイルの先頭のコメント」のようなそこにしか入れられない情報というものはある。toml::valueを返すようにすれば、ファイルのルートにもこのような情報を持たせることができるようになる。この後押しがあったので、続く一連のインターフェースの整理を始める気になった。

整理のメリット

まず、これまではtoml::tabletoml::valueの暗黙変換を許していた。これは、何度も葛藤があったのだが、やはりexplicitにすると様々なところで「えっこれできないの!?」となってしまったので、渋々implicitにしていた。これはv3でも変わらない。

これのせいで、いくつかの便利関数のオーバーロードが複雑になっていた。例えば、toml::findtoml::valuetoml::tableも受け取る。これもtoml::parsetoml::tableを返すためだ。対してtoml::findは型を指定しなかった場合はtoml::valueを返すので、以下のようなコードを実現するにはそうせざるを得なかった。

const auto data   = toml::parse("example.toml");
const auto table1 = toml::find(data, "table1");
const auto table2 = toml::find(table1, "table2");

だがこれのせいで、「toml::table -> toml::valueの暗黙型変換をすればマッチするようなオーバーロード」がオーバーロード解決に参戦してくる。これが非常に厄介だった。結構頑張ったが、避けられない部分は出てきて、実際にIssueも立っている。もしtoml::parsetoml::valueを返すようになっていたらtoml::tableのサポートは最低限で済んだのに、と何回も思った。toml::tablestd::unordered_mapなので、理論上はユーザーは(私が提供できるような)どんな便利関数も自分で実装できる。だがtoml::valueは私が定義した型なので、それを外部から使うにはまともなAPIによる十分なサポートが不可欠だ。

まあでもこの問題は、全ての箇所でtoml::valueを優先することによって一応の解決を見た。逆にtoml::tableのサポートはほぼdropされる。とはいえ上に書いたコード片は(全ての箇所でautoを使っているので)そのまま動く。なので聞いた印象よりは、実際の影響範囲は少ないと思う。

その他雑多なこと

あとやらないとなーと思っていることとして、以下のようなのがある。

ビルド済みライブラリを作れるようにする

メタ関数を結構使っている上にSFINAEでオーバーロードを操作したりしているので、コンパイル時間が結構長い。なのでコンパイル時間を短縮するためにライブラリをビルドできるようにしてもいいかもしれない。

とはいえヘッダオンリーの利点を捨てるわけにも行かないので、extern templateみたいなのをマクロで出し分けて、必要な人だけリンクするようにすればいいのではないかなあと思っている。

まあこれは新機能なので別にv3を出してから追加しても構わないだろうと思っている。

Boost.Testのバージョンアップに追従する

以前やろうとして、CIがうまく行かなくてやめていたのだが、CI力が付いてきているので多分今ならいける。

ただ結構テストを書いてしまっているので、いちいち関数を変更していくのがタルい。

自分のライブラリはメジャーアップデートしようとしておきながら中で古いバージョンのライブラリを使い続けてるのちょっとダメな気がするので、これは(ユーザーには何の違いももたらさない割には)実は自分の中では比較的優先度が高い。

まとめ

  • 数ヶ月中を目標にv3ででかい変更が来るよ
  • 新機能としてtoml::valueがかなりカスタマイズできるようになるよ
  • コメントを取得していじり回した後にシリアライズできるようになるよ
  • インターフェースが整理されて、統一感が上がってついでに既知のバグも解決したよ
  • 実はちょっと前にv2系のマイナーアップデートを出してて、バグ修正とか、エラーメッセージにヒントを足したり、v3で廃止される関数のいくつかがdeprecatedされたりしてるよ

まあ多分、今回みたいな変更は向こう数年は入らないと思う。だいたい不満だったことは解決してしまったので、TOML側がすごい変更を入れて来たりするか、私が過激派になってビルドにC++20を要求するようになったりしない限り、内部実装を除けばこれで概ね色々解決しただろうと思う。細かいアップデートはまた入ると思うけど。使ってくれてる人は、これからもちょいちょいレポジトリを見に行ってやってください。

C++ if文再訪

今回はC++規格書のif文周りを読んでみたい。

今更一体全体何なんだと思う人が大半だと思うが、普段息をするように使っているものだからこそ、規格書を読んでおくのは良いことだと思うのだ。普通の人が規格書を読むのは、知らないものについて勉強するときか、既に知っているものに新機能が追加されたときだろう。初期に勉強した機能ほど、厳密な規格を知らずに使っている気がする。そしてそういう基本的な機能ほど当たり前だがよく使うので影響範囲は広い。となると、規格に当たらずに使っているのがだんだん怖くなってこないだろうか。

というわけで読もう。11, 14, 17の変遷も追いたい。

C++11

まずはC++11(策定直後のドラフト N3337)から見てみる。ifについての記述があるのは、§6.4 "Selection statements"だ。

ここでは、ifswitchのような、複数の制御フローの中から一つだけを選択するための文について書かれている。以下のような形になるらしい。

selection-statement:
    if ( condition ) statement
    if ( condition ) statement else statement
    switch ( condition ) statement
condition:
    expression
    attribute-specifier-seq_opt decl-specifier-seq declarator = initializer-clause
    attribute-specifier-seq_opt decl-specifier-seq declarator braced-init-list

とりあえず、今回はifに集中したいのでswitchは無視する。さらに、ifに続くことのできるstatementの詳細は§6全体で説明されている「文」であり、これもどんなものがあるか一つ一つ見ていったりすると長くなるのでスキップしたい。

ifの次に続く文は暗黙にブロックスコープを導入すると書かれている。つまり、

if (x)
    int i;

は、

if (x) {
    int i;
}

と等価であり、iifを抜けるとスコープを抜けてしまうことが書かれている。ブロックを明示的に書こうが書くまいが、ifに続く変数の寿命はifの外では尽きる。せやな。

さて、conditionは以下のように定義されている。

condition:
    expression
    attribute-specifier-seq_opt decl-specifier-seq declarator = initializer-clause
    attribute-specifier-seq_opt decl-specifier-seq declarator braced-init-list

expressionはよい。これはboolとして評価できるべき式であり、みなさんが普段使っている形式だ。だが他の2つは比較的使われていないというか、知られていないかも知れない。

attribute-specifier-seqは属性構文というやつで、[[nodiscard]]とかそういうやつだ。alignasもこれに含まれるらしい(§7.6.1)。_optは省略可の意。

decl-specifier-seqdecl-specifierの列で、以下のようになっている(§7.1)。

decl-specifier:
    storage-class-specifier
    type-specifier
    function-specifier
    friend
    typedef
    constexpr
decl-specifier-seq:
    decl-specifier attribute-specifier-seq opt
    decl-specifier decl-specifier-seq

順に見てみよう。storage-class-specifierは、register, static, thread_local, extern, mutableのどれかを指す。registerなんてあったなそういえば。registerは値が何度も何度も使われるから効率の良い場所に置いてね、というヒントを与えるためのもので、処理系があまりそういうことが上手でなかった古の時代には意味があったが、現代ではほぼ全てのコンパイラが「言われんでもわかっとるわ」とばかりにガン無視を決め込むキーワードである。覚えなくていい。C++11時点でdeprecatedであり、将来的に全く異なる機能で再利用する方向で議論されている(autoのように)。

type-specifierは……長くなる。詳細は§7.1.6を見て欲しいのだが、まあ要するに型名のことだと思ってくれていい。

function-specifierinlinevirtualexplicitのどれかだ。関数の前につくやつ。「そうですね」って感じ。

ほか、friendtypedefconstexprはもういいだろう。そのままなので。

というわけで、decl-specifierは何かのdeclarationの時に出てくるやつということがわかる。

condition:
    expression
    attribute-specifier-seq_opt decl-specifier-seq declarator = initializer-clause
    attribute-specifier-seq_opt decl-specifier-seq declarator braced-init-list

declarator = initializer-clauseとかdeclarator braced-init-listはもうわかるだろう。x = 1とかx{1, 2, 3}とかのことだ。つまり、if(condition)conditionでは変数宣言ができる。ただし、それはboolに変換可能である必要があり、そうでなければそのプログラムは不適格となる。

ちなみにここで導入された変数はそのif直後の(暗黙かどうかを問わない)ブロックスコープにおいて生存する。ちなみに、そのifの後のelseに続くスコープも同様である。わかりやすい例が規格書に乗っているので引用しておこう。

if (int x = f()) {
  int x; // 不適格、xを再定義している
}
else {
  int x; // 不適格、xを再定義している
}

これはあまり知られていないっぽいくせに結構便利な機能で、例えば以下のようなことができる。

boost::optional<data> get_data();

if(auto data = get_data()) {
    // データ処理
}

とか、

boost::expected<data, error> parse();
if(auto parsed = parse()) {
    // データ処理
} else {
    // エラー処理
}

などだ。モダンな言語でよく採用されている、エラー処理を直和型を使って明示的にやっていく方式を採用するなら、この構文はかなり役に立つと思う。

boost::optionalは使い方の説明で当たり前のようにこの構文を使っている。既にこの機能を知っているという人はboost::optionalのドキュメントで知ったとかではないだろうか。他に紹介している文献を寡聞にして見たことがない。ちなみに私はboost::optional経由で知った。

C++14

ではC++14で何か変わったか調べるために、規格策定直後のドラフト、N4140を見てみよう。構成があまり変わっていないので、同じ§6.4にある。読んでみよう。

変化、なし――

まあ、C++14はマイナーチェンジだったから仕方ないね。

C++17

C++17では結構大きめの変更が入っている。規格書の構成も結構変わって、該当する箇所が§9.4になった。

selection-statement:
    if constexpr_opt ( init-statement_opt condition ) statement
    if constexpr_opt ( init-statement_opt condition ) statement else statement
    switch ( init-statement_opt condition ) statement

if constexprも同じ所で触れるんだな。まあこれに関しては日本語でも結構解説があるので一旦飛ばすことにしよう。普通のifに注目する。

init-statementはあってもなくても構わないが、ある場合はセミコロンで続くconditionと区切られる。このinit-statementはその名の通り何かの変数をその場で定義できるというもので、以下のような使い方ができる。

if(auto x = calc(); x > 0.0) {
    // ...
} else {
    // ...
}

ではこの初期化式で導入された変数の生存期間はどうなるかというと、§9.4.1に以下のような記述がある。

if constexpr_opt (init-statement condition) statement

は、以下のコードと等価である。

{
    init-statement
    if constexpr_opt (condition) statement
}

というわけで、if全体をブロックスコープで覆ったと思えばよい。ifのスコープを離れた時点で寿命は突き、デストラクタが呼ばれる。

constexpr ifの話をしなかったら割とあっさり終わってしまった。

まとめ

たかがif、と思っていても、実はあまり見る機会のない構文が使えることがある。逆にifのような最初期に学ぶであろう機能こそ、そういった知られざる便利機能が隠れていたりするのではないか。

規格書にあたるのは大事ですね。