しました。
今回は主に私自身が使っていて困った機能の実装なのでちょっと見切り発車です。
アクセスチェック機能の追加
概要
TOML11_ENABLE_ACCESS_CHECKを定義してコンパイル する or CMakeでTOML11_ENABLE_ACCESS_CHECK=ONにすると使えるようになる機能です。
toml::valueにbool accessed()が追加され、パース後にその値を読み込んだ(toml::findやtoml::get、.as_integer()など)か、その値の型をチェックした(is_integerなど)場合にはtrueが、そうでない場合はfalseが返るようになります。
これはエイヤで入れた機能なので今後気が変わるかもしれません。また、普段の機能追加よりもライブラリ全体への影響が大きめなので、マクロで切り分けてデフォルトではオンにならないようにしています。
目的
この機能は、以下のような誤りを検出するための機能です。
アプリケーションは、tomlファイル内のある値(例えばhoge)を探す
見つからなかったので、デフォルトの値(例えばhoge = 0)を設定して処理を続ける
実はユーザーはhogeと書こうと思ってhige = 1と書いていた
ユーザーはhoge = 1だと思っているがアプリケーションはhoge = 0だと思って処理が続いている
このような場合、アプリケーションもそのユーザーも、誤りを検出するのは難しくなります。
アプリケーションが動かなくなるようなデフォルト値を設定する人はあまりいません(そういうことをするくらいならその場で落としたほうが原因がわかりやすいので良いです)。
なのでアプリケーションは異なる設定のまま動作してしまい、ユーザーは自分の指示が通っていないことに気付かない可能性が高いです。
他の設定と不整合が生じてエラーが出る可能性はありますが、その場合ユーザーはデバッグ に苦労するでしょう。
ログにhogeの値が出ていたら判定できるかもしれませんが、出ていなかったら、エラーの理由からあたりをつけられない限り、typo の発見にはかなり時間がかかると思います。
むしろ困るのは、デフォルト値でそのまま最後まで動いてしまう場合のほうでしょう。
実は設定が違うのに処理が進んでいる、というのは、思っていたものと異なる結果を得るということで、場合によってはしばらく計算を続けた挙句何の意味もないデータを得る、ということになりかねません。
どこかで気付けばいいですが、そのことに気付かないまま結果に基づいて依存関係のあるタスクを進めてしまっていたら、あるいは他人に報告したり指示を出したりしていたらどんどん影響が大きくなります。
しかし、この場合higeはアクセスされないので、「アクセスされていない値「hige」がある」という警告が出ていればエラーの理由はわかります。
十分に注意深い人であれば、アクセスしていない値の警告を見て、自分の指示が通っていないことに気づけるでしょう。
アプリケーション側で手間を惜しまないのなら、必要なキー一覧との編集距離を取ってtypo の可能性を指摘し、正しい名前をサジェストするのも良いかもしれません。
なんなら、アプリケーションは使っていない値を発見したら異常終了したり再設定を要求して止まったりした方が、エラーを確実に防げるはずです。ユーザーからはウザがられそうですが。
こういった理由から、使っていない値を総ざらいしてエラーを出せる機能があれば、間違っていた時により早く気づき、リカバリ に移れるという点でよいと判断したわけです。
昔からこの機能について少し考えてはいたのですが、最近実際に自分でやらかして数日消し飛ばしたのでちょっと無理にでも急いで実装しようという気になりました。
実装
toml::valueにatomic<bool>でフラグを埋め込んでいます。パースは並列化されていませんが、その後の使用時にはユーザーに並列でアクセスされる可能性があるので、atomicになっています。このフラグは、構築されたときはfalseで、as_xxxやis_xxxを実行するとtrueになります。
ただ、パース中、すでにパースして構築したtableに値を埋め込むことがたまにあります(後方でサブテーブルを定義した場合など)。
このような場合は、ユーザーが触れていないのにtableがアクセス済みと判定されてしまうのでおかしなことになります。
長い先読みを許すことでそのような状況を消せる可能性はありますが、そこまでの先読みはあまりにも面倒になるので、単にパース後に全フラグをfalseにするようにしました。
これは若干パフォーマンスに影響があるかなと心配していましたが、プロファイルを取ったところパースのほうが十分重いので大丈夫そうです。
他の選択肢
こういったことをより汎用的にこなすには、Schemaファイルを用意して、それに従っていないものにエラーを出すという選択肢があります。
そうすれば、必要なキーがあらかじめ渡されるので、先ほど挙げた編集距離でのサジェストも自動化できます。
ただ、それをするには当然Schemaファイルを定義しないといけません。今のところTOML公式での議論ではTOML専用のものを作ることについては消極的で、JSON Schemaを再利用すればいいだろうという意見が大勢を占めています。
とはいえ、その決定自体にあまり合意を取れている状態ではないため、何を採用するにしても独自仕様になってしまいます1 。
toml11の「他のライブラリに依存しない」という機能を保つためにはSchemaを自前実装しないといけないため、労力的にあまり現実的ではないかな、と感じました。
それこそマクロで分けるとかしてもいいのですが、むしろパース自体はtoml11でやっても事後のチェックは別のレイヤーで解決したほうが妥当そうな感じはします。
そういうわけで、フラグを埋め込む方法を選択しました。
デメリット
この機能をONにすると、実行時パフォーマンスに若干の影響が出ます。実測したところ5%未満でしたが。
スレッドセーフになるようフラグにatomic<bool>を使っているので、パース後の値からデータを読み出す部分が並列化されている場合、より大きな影響が出るかもしれません。
すでに他の方法(それこそスキーマ など)で同様の機能をユーザーコード側で実装している場合は、不要なのでoffにしておいたほうがいいでしょう。
ところで、この機能のためにベンチマーク を取っていて、もう少し速くあってほしいなあと感じたため、今回のリリースでは実行時パフォーマンスの最適化も行いました。
実行時パフォーマンスの向上
概要
toml::parseの速度が約2倍向上しました(手元の環境で、12000行程度のファイルのパース+シリアライズ にかかる時間が479ms -> 236ms)。
目的
今回はパフォーマンスを落とすような機能を入れたので、長いファイルをパースして書き出すだけのコードを作って、しばらく時間を計測していました。
以前実際に使ったことのある12000行程度のファイルを使って実行していたところ、v4.3.0 と g++-13.1.0での速度はざっくりIntel i7-11700K(DDR4-3200)で10000行/秒、Ryzen 9700X(DDR5-5600)で25000行/秒程度でした2 。
これまではかなり長いものを書いていても数万行だったので、少し昔のマシンでも秒間1万行というのは自分では概ね満足できるところではあります。
とはいえ、数万行になると数秒かかるということで、もう少し速いほうが嬉しいのは事実です。
toml11のパース部分はかなり富豪的 なコードを書いているので、そこまで頑張らなくてもこの1.5倍くらいはいけるだろうという霊感がありました。
toml11は基本「機能性と速度のトレードオフ にでくわした場合は(高確率で)機能性を取る」というスタンスでやっています。
今回のアクセスチェックなんかは明らかに機能性を取っているところですし、TOMLバージョンを変えられる機能も、TOML内のコメントをわざわざパースしたり整数のフォーマットを覚えたりしているのも、速度より機能性を取っている部分です。
逆に、toml::valueの実装にunionを使っている(継承してdynamic_castをしたりはしていない)のは速度を取った部分ですが、これは別に機能性を殺しはしません(書くのは面倒になりましたが)。
速度より機能性を重視するポリシーには、
TOMLは設定ファイルに使うものなので、初回と設定リロードの場合しかパースせず、大抵の場合ホットスポット にならない
TOMLを採用する理由は「人間の読み易さ・書き易さ」が多いので、現実世界のファイルのほとんどはそんなに長くならない(人間が読み書きできる程度の行数)
コメントを読む機能、フォーマットを維持する機能、TOMLバージョンを切り替える機能などを提供する以上どうせ業界最速にはならない
パースが多少高速になるより、設定する際のヒューマンエラーを防ぐ機能を色々提供したほうが、ユーザーの目的は結果的に速く達成できると思っている
という理由があります。
とはいっても、私自身は他のプロジェクトではタスクをギリギリまでオーバラップさせた並列化をかけたり、SIMD 化するために手動アンロールしたりする程度には速度を出すことが好きです。
なので、toml11も機能性を保った上で、開発を大変にしない程度の最適化はしておきたいとは思っています。
このあたりのバランス感覚(「開発を大変にしない程度」とは?(実は既に大変では?))は個人的なものなのでちゃんとは書きにくいですが……(過不足なく書こうとするとかなり長くなってしまうし個別的になってしまう)。
方法
プロファイル
とりあえずまずはプロファイルです。ファイルをパースしてダンプするだけのプログラムを書き、valgrind / callgrindでプロファイルを取り、kcachegrindで覗いてみました。
プロファイル結果をkcachegrindで開いたもの
わけわかんなくて草。
普段書いているコードはたいてい、ここでそれなりのサイズのブロックが出てきて何をするべきかわかるんですが、これは面倒ですね。
代わりに呼び出し回数と合計時間を眺めてみます。
関数ごとの所要時間と呼び出し回数
これを見ると、new/deleteに伴うmalloc /freeが尋常でない回数呼ばれており、そこそこの時間(40%)を占めているいることがわかります。
toml11に関しては実装を隅から隅まで知っているのでこの時点でなんとなく察していますが、確認のため誰が呼んでいるかを見ます。
_int_freeを呼んでいる関数
sequenceやeitherなどのscannerと、toml::syntax以下のクラス(要するにscanner)が呼んでいるようです。
scannerとは
そもそもscannerって何、という話をしないといけないでしょう。
toml11は、文法を確認したあと、文法に従っている前提で意味を解釈するという順番でパースをしています。といってもファイル全体の文法チェックを最初に行うのではなく、かなり小さい単位(keyやvalue のそれぞれ)でこれを行っています。
この最初の段階でscannerを使っています。これは簡単なスキャナを組み合わせて合成できるように作っており、characterやliteral、character_in_rangeなどをsequenceやeither、repeat_at_leastで組み合わせて、TOMLのABNFと対応する文法をチェックできるようにしています。例えば10進の整数は:
TOML11_INLINE sequence dec_int (const spec& s)
{
const auto digit19 = []() {
return character_in_range (char_type ('1' ), char_type ('9' ));
};
return sequence (
maybe (character_either{char_type ('-' ), char_type ('+' )}),
either (
sequence (
digit19 (),
repeat_at_least (1 ,
either (
digit (s),
sequence (character (char_type ('_' )), digit (s))
)
)
),
digit (s)
)
);
}
のようになっています(v4.3.0時点のコード)。最初に+か-があって、digit単独か(0許容のため)、1~9のどれかのあとにdigitが複数個続く(0以外で始まる複数桁の整数)、ただし_を間に挟んでも良い……という文法がこれで表されています。
整数をパースする際は、まずこれに通るかをチェックされ、これに通ったあとは_を取り除かれてistreamに渡されています3 。
scannerのうち別のscannerを取るもの、例えばsequenceやeither、maybeやrepeat_at_leastは、任意のscannerを格納できなければなりません。
それには、大まかに分けて2つ方法があります。scannerは全員scanner_baseを継承してunique_ptr<scanner_base>を格納する方法と、存在する全てのscannerをunionに入れることです4 。
toml11では実装の簡単さのために前者にしています。これにも複数の理由があります。
まず、unionを使って複数の型を扱うのはそこそこ面倒です。toml::valueではunionを使っていますが、あれは結構大変です。内部でしか使わない型でそこまでするのは労力的にちょっと避けたいところです。
また、scannerはエラー時に本来期待していた文字を表示する役割もあるのですが、複雑になってくると期待していた文字を表す文法が人間に読めなくなってしまうので、適宜圧縮しています。
圧縮とは例えば、UTF-8 で複数バイト取る関数について「(UTF-8 で許されるバイト列の範囲を表す複雑な文法)を期待した」を「non-ascii文字を期待した」に書き換えることです。
これを実現するため、sequenceなどをラップしたクラスをいくつか作っています。これがどの程度の数になるかは書く前は不明だったため、追加する際に書き換える範囲が小さくなる実装を選びました。
そういうわけで、sequencerはstd::vector<std::unique_ptr<scanner_base>>を持っています。
そしてtoml::parseは内部で必要になるたび毎回これを作っては壊し、使い捨てています。scannerがnew/deleteを呼びまくって遅い原因はこれでしょう。
実は、toml11 v3では同等の機能がtemplateを使って型レベルで実装されており、このような問題は起きていませんでした。
v4で動的に構築するように切り替えたのは、TOML言語バージョンを切り替えられるようにするためと、コンパイル 時間を短くするためです。
これらの目的は変わらないので、scannerを動的に構築するという方向性は変えたくありません。
なので、scannerの構築回数を減らす、あるいは構築の際のnew/deleteを減らす方向性で考えましょう。
方法1: 特別なsyntaxクラスではscannerを使わず素のscannerを持つ
特別なsyntaxクラスは、先ほど書いた複雑な文法をよく見る名前で表示して読みやすくするためのクラスのことです。UTF-8 の非ASCII文字であるとか、hex digitとかです。
これらはよく使われるところに出現するのでパフォーマンスへの影響が少し大きめです。頻出要素のパターン(数値のみ、16進数のみ、など)の可読性を高めるために導入したものなので当然かもしれません。
これらはこれまでsequenceを持っていてコンストラク タでその中身を詰めていましたが、よく考えるとそんなことをする必要はありません。
sequenceの中身は決まっているので、バラで全部持ってsequence::scanが実行するループの代わりに手作業で前から順に呼べばいいだけです。
これらのクラスはその後の文法の中で使われるものなので、それ自体はそこまで複雑な構造をしていません。数個の連続程度なので、分解してもコード量はかさみませんでした。
これは機械的 にできる変更な割にそれなりの効果があったので、採用しました。
方法2: sequenceをstatic_sequenceとdynamic_sequenceに分ける
sequenceの要素数 とその型はTOML言語バージョンによって変わる場合もありますが(許される文字が増えたり、一部がoptionalになる場合)、8割方は変化がありません。
なので、持っているscannerの個数と種類が変化しない場合はvector<unique_ptr<scanner_base>>の代わりにtuple<scanners...>を使ったstatic_sequenceにすることで、new/deleteを減らせます。
tupleならunique_ptr<scanner_base>にする必要がないからです。同様のことがeitherでもできます。
ただ、これはコード量が結構増え、かつ今後変更する際に影響が及ぶ範囲が広くなってしまい(未来のバージョンでさらに変化があった場合速度への影響も大きくなる)、しかもC++ 11で動かないといけないのでそこそこ冗長なメタプログラミング が要求され、コストが高いのでやめました。
方法3: unionを使う方法に切り替える
これは、unique_ptr<scanner_base>の代わりに全ての型を列挙してunionに詰めるというものです。
unionを使うと実装があまりに面倒すぎたのでvariantを使って実験してみたところ、15%ほどの高速化ができ、これやるの……?と思いながら数日見ないふりをしました。
最終的に次の方法で十分早くなったので今はやっていません。
方法4: キャッシュを導入して使い捨てをやめる
そもそもの問題は、毎回sequenceを作っては即捨てていることです。toml::specに指定されるTOML言語バージョンが同じならsyntaxも同じなので、同じscannerの組み合わせで事足りるはずです。
よって、一度作ったscannerを覚えておいて、specを比較して同じものなら同じものへの参照を返すようにしておけば、無駄にコピーすることもなく、無駄にアロケーション することもありません。
これが一番変更が楽で効果がありそうなので、まずはscannerを格納するクラスを作ります。
cacheは値を構築する関数を受け取っておいて、atアクセスで初めてのspecが来た場合にそれを構築して保存してから返し、既知のものであれば対応する保存済みの値を返します。
template <typename F>
struct syntax_cache
{
using value_type = cxx::return_type_of_t<F, const spec&>;
static_assert (std ::is_base_of <scanner_base, value_type >::value, "" );
explicit syntax_cache (F f)
: func_ (std ::move (f)), cache_{}
{}
value_type const & at (const spec& s)
{
const auto found = std ::find_if (cache_.begin (), cache_.end (),
[&s](const std ::pair <spec, value_type >& kv) { return kv.first == s; });
if (found == cache_.end ())
{
this ->cache_.emplace_back (s, func_ (s));
return cache_.back ().second;
}
else
{
return found->second;
}
}
private :
F func_;
std ::vector <std ::pair <spec, value_type >> cache_;
};
複数のスレッドが同時に別のファイルをparseして同時にcacheが更新されると大変なので、キャッシュはthread_localにしておきます(共有されなくても各スレッドが自分で更新するだけなのでデメリットはそんなにありません)。
今ある関数は作ったscannerを単に返しているだけだったのを、static thread_localな場所にspecとペアにして保存し、コピーを避けるためそれへの参照を返します。
で、先程も出てきたdec_intを例にしますが、今まで関数内に書いてあった構築ロジックをラムダ式 に移して、それを使ってcacheを作ります。
cache.atで未知のキーの場合は構築するので、ここを最初に通るときはscannerが構築され、二度目以降は構築されませんし、破棄もされません。
TOML11_INLINE sequence const & dec_int (const spec& sp)
{
static thread_local auto cache = make_cache ([](const spec& s) {
const auto digit19 = []() {
return character_in_range (char_type ('1' ), char_type ('9' ));
};
return sequence (
maybe (character_either ("+-" )),
either (
sequence (
digit19 (),
repeat_at_least (1 ,
either (
digit (s),
sequence (character (char_type ('_' )), digit (s))
)
)
),
digit (s)
)
);
});
return cache.at (sp);
}
これはかなり機械的 な書き換えで達成でき、かつ十分な効果がありました。
方法4.1: cache内でvector を使わない
考えてみると、toml::parseは一度パースを開始したら途中でユーザーに処理を返しません(カスタム数値型のパース方法だけはユーザーがカスタマイズできますが……)。
ということは、単独のスレッド内でファイルを読み終わるより前にtoml::specが切り替わることはありません。toml::parseの一回の実行中は全く同じspecが渡され続けます。
よって、これをvectorにせず、単にpair<spec, scanner>を単体で置いておいてヒットしなかったら構築して入れ替える、としても速度低下はないと期待できます。そうすると少しコードが簡単になります。
むしろvectorに対するfindと要素アクセスが減って若干速度が上がるのではないでしょうか。いやどうせ1,2個しか要素はないからそこまで上がらないかな。
さっき紹介したsyntax_cacheからvector部分を消してみます。
template <typename F>
struct syntax_cache
{
using value_type = cxx::return_type_of_t<F, const spec&>;
static_assert (std ::is_base_of <scanner_base, value_type >::value, "" );
explicit syntax_cache (F f)
: func_ (std ::move (f)), cache_ (cxx::make_nullopt ())
{}
value_type const & at (const spec& s)
{
if ( ! this ->cache_.has_value () || this ->cache_.value ().first != s)
{
this ->cache_ = std ::make_pair (s, func_ (s));
}
return this ->cache_.value ().second;
}
private :
F func_;
cxx::optional <std ::pair <spec, value_type >> cache_;
};
少しシンプルになりましたね。
実際測ってみると、10回平均で速度向上は2%程度でした……。まあコードが短く単純になった方を喜びましょう。
まとめ
今回実装したのはそのあたりです。あと、いくつかpull reqが来ていたので、その機能も取り込んでおきました。
基本的に筋悪なpull reqがほぼ来ないので楽な限りです。普通に忘れてたり間違えてたりするのを結構直してもらっているのでありがたいですね。自分で使わなかったりすると目が行き届かないので……。
不定 期ですが、自分で使っていて何かの機能が追加したくなったらまた更新すると思います。