前置インクリメント vs 後置インクリメント

このアーティクルでは前置と後置、2種類のインクリメントについて考察と現状をまとめるものです。

 

長年の常識

従来(~2015)は前置インクリメント(++value)と後置インクリメント(value++)の挙動の違いを正しく完全に理解した上で前置インクリメントを使うべきだ。というのがC++プログラマの基本姿勢でした。

各インクリメントの標準的な実装を以下に示します。

 

後置インクリメントにはひと目で遅くなりそうな処理が見て取れますね。

前置インクリメントがインクリメント処理後、単純に自身の参照を返すのに対し、後置インクリメントではインクリメント前に一時オブジェクトの生成、そしてインクリメント後にはその前に生成した一時オブジェクトを値で返しています。

前置と後置では、単純にオブジェクトをコピーして返す分、普通に考えたら後置の方が遅いよね。というのが従来の認識でした。

「C++ Coding Standards -101のルール、ガイドライン、ベストプラクティス」の中でも、特に後置インクリメントの必然性が無い時は迷わず前置インクリメントを使うことが推奨されてきました。

元の値を必要としないときは前置形式の演算子を使おう

__C++ Coding Standards (p50)

 

新たな主張

「ゲームエンジン・アーキテクチャ第二版」の中の一節を紹介します。

しかし、値が使われる場合、CPUのパイプラインでストールを生じさせないので、ポストインクリメントの方が優秀である。したがって、プレインクリメントの動作が絶対に必要である場合を除いて、必ずポストインクリメントを使う習慣を身につけたほうがよい。

__ゲームエンジンアーキテクチャ第二版 プレインクリメント vs ポストインクリメント

従来の主張と真逆の主張が展開されたのです。後置インクリメントの方が優秀なので出来る限りそちらを使うべきとされました。

ゲームエンジン・アーキテクチャの著者は、UnchartedやThe Last of Usの開発元であるノーティドッグ社のエンジニアです。その彼が後置インクリメントを使うべきと主張したことで大きな話題となりました。

後置インクリメントが優秀とされる理由は以下のとおりです。

  • 前置インクリメントは値を書き換えて戻すのでインクリメント処理が終了するまで戻す値が決まらない
  • それはデータ依存性を生み、深いパイプラインのCPUではストールを発生させる
  • 後置インクリメントはインクリメント処理と戻す値は別インスタンスなので並列に処理が可能
  • よってストールの発生が無い分、後置インクリメントが優秀

なるほど、といった感じです。

 

 考察を深めよう

今後は後置インクリメントに統一だ!今まで書いた前置インクリメントを全部置換しなくちゃ!と、急ぐ前に、本当にゲームエンジン・アーキテクチャの主張が正しいかは一考の余地があります。

深いパイプラインにおけるストールの可能性はデータ依存性があるかぎり発生しうる問題です。

この部分の主張は正しいです。

しかし、ゲームエンジン・アーキテクチャでは後置インクリメントに発生していたコピーコストについて言及がありません。

文脈を読み解く限り、「コピーコスト<ストールコスト」という前提があるようです。このインクリメントの項以外でもゲームエンジン・アーキテクチャではストールに対する嫌悪を書いてある箇所があり、著者はかなりストールコストに対してナイーブになっているように感じます。

実際にコンソールゲーム機のアーキテクチャにはストールのコストが引くほど高いものもあり、それこそPS2の時代はとにかくCPUの稼働効率を高めるようなコードを意識して書かないと描画ポリゴンが倍は違うというような状況でした。XBOX360の頃でさえ、シーングラフ(ゲームシーンの描画を効率的に管理したりする構造)を作っても、その走査や判定のコストの方が高い為、単純にシーンにある全オブジェクトを順番に描画するだけの方が速かったという経験があります。

 

 一時オブジェクト生成のコスト考察

一時オブジェクトの生成に伴うコストについて、好意的に解釈してみましょう。

例で示した実装の場合、後置インクリメントで変更前の値を返す際にRVOの最適化が期待されるので、前置インクリメントも後置インクリメントも(このインクリメントの結果として使う時の)オブジェクトの生成は1度きりになる可能性があります。厳密には直前のテンポラリオブジェクトが(oldという)名前付きオブジェクトなので、この最適化を期待するためにはコンパイラがNRVO(Named Return Value Optimization)に対応している必要があります。それでもほとんどのコンパイラはNRVOが効くのでコピーコストは低くなる可能性があります。

ちゃんとNRVOが効くコンパイラ(今回はgcc)を使って実際のアセンブラコードを見てみましょう。

ユーザー定義の前置インクリメントと後置インクリメントを用意します。

前置インクリメント、後置インクリメントそれぞれの処理を呼び出す処理を書きます。

pre.cpp

post.cpp

これら2つの処理のアセンブラコードを出力します。

アセンブラコードを読むのが嫌いな人は行数だけ見ればいいです。

 

やっぱり後置インクリメントの方が遅いですね。

では次はコンパイラの最適化オプションをバッチリ有効にしたバージョンで見てみましょう。

 

 

なんということでしょう。

前置も後置もまったく同じ結果になりました。流石、昨今のコンパイラは優秀です。

これは例として示したコードのようにインクリメントの実装詳細まで一つのコンパイル単位に含まれている関係で、コンパイラが副作用がないことを把握しながらインライン展開まで行える為、とにかく結果だけ合うように最速のパフォーマンスを組み上げられる為です。

もう一歩進んで、Hoge::operator++()とHoge::operator++(int)を別のcppに移しました。

それではアセンブラコードを見てみましょう。

まずはhoge.cpp自体のアセンブラコード。

pre.cpp

post.cpp

やはりコード次第では、最適化オプションを有効にしていても、前置インクリメントにアドバンテージがあるケースがありますね。

hoge.cppなんてコンパイラにとったら何のヒントも無いコンパイル単位なので、素直に処理するしかないので当たり前といえば当たり前です。

しかし、世の中には、リンク時最適化というものがあり、オブジェクトコード生成後に再度ファイルを走査し、リンクのタイミングでインライン展開されるような場合もあるので、この例で一概に前置が有利と結論付けることはできません。

ストールコストの考察

ストールが発生するシチュエーションを考えます。

ストールは本来、このインクリメントに限らず、データ依存性を持つあらゆるシチュエーションで発生する恐れがあります。条件分岐、値の更新など、結果を待たなければ先の処理が行えないケースが発生するたびストールは起こりえます。分岐予測など、ストールが実質的に無くなるような工夫ももちろん行われているのですが、予測が外れた場合には、事前準備の甲斐なく、やっぱりストールします。

まず、インクリメントの外、インクリメント結果を使う瞬間に発生するストールについてですが、ただ単に一行

と書く場合、前置と後置で何も変わりません。

次の行以降で使う値はインクリメント後の値なので、前置後置関係無く、値の更新が完了しないと何も始まらないからです。

この例の場合、while文のtest exprに入った段階でiの内容が確定している為、インクリメントの結果を待たずに次の処理を進めることができます。こういうようなシチュエーションでは前置と後置でストールによるコストが変わってきます。

インクリメントの中で発生するストールもについても意識する必要がります。

前述のとおり、ストールはどこでも発生する可能性があります。

このストールがインクリメント演算の中で発生した場合、インクリメント演算の度にCPUはストールしてしまいます。しかし、後置インクリメントであれば、インクリメント演算の結果を待たず次の処理へ移れる為、有利と言えるでしょう。

最悪の場合、インクリメントの中でストールするわ、インクリメントの外でストールするわと、春先の有楽町と同じくらいストールで溢れかえってしまいます。実際にCPUがストールしまくる状態のことを「海外セレブ効果(Celebrity Fashion)」と呼びます。嘘です。

今回のゲームエンジン・アーキテクチャの主張もまさに、後置インクリメントはインクリメントの結果を待たなくても、戻り値のオブジェクトは決定しているのでそのオブジェクトに対する処理はインクリメントの動作と並行して行えるよね。ならストールしないよね。というものです。

逆に、コードの流れの中で、インクリメントした後の結果しか使わないのであれば、前置でも後置でもCPUのコストは変わりません。仮にインクリメント演算子の中で70,000サイクルの処理がかかるとしても、インクリメント後の値しか使わないのであれば、前置でも後置でも等しくその負荷が計上されることになります。

 

計測サンプル

ダラダラとアセンブラコード貼ってる暇があったら実測値をだしてみろよ!投資家は数字でしか動かねーんだよ!というYコンビネーターマインドの方もいるかもしれません。

以下のサイトではインクリメントのコストを複数のコンパイラで比較検証されていますので一例として参考にして頂ければと思います。

インクリメントの前後置速度比較(vc++/gcc/clang)|他人の空似

典型的なfor文を前置後置それぞれのインクリメントを使用する形で作成。
インクリメント対象をunsigned int/iterator/適当に重めのクラスの三種それぞれを対象。
また、最適化により返り値計算が消えることを考えforの真偽判定でインクリメントの返り値を使用するものも用意。
以上をそれぞれ100,000,000回実行をさらに10回繰り返しかかった時間の平均をとる。
(中略)

vc++ gcc clang
int前置インクリメント 46ms 61ms 41ms
int後置インクリメント 51ms 44ms 53ms
クラス前置インクリメント 320ms 47ms 316ms
クラス後置インクリメント 2206ms 388ms 446ms

__インクリメントの前後置速度比較(vc++/gcc/clang)|他人の空似

全計測の結果は元サイトよりご確認下さい。

なお、余談ではございますが闇夜のC++のサイト運営に2億くらい出してもいいよというエンジェル投資家の方がいらっしゃいましたら、ご連絡下さい。

 

現実的な観点

我々は現実の世界で実際にコードを書くプログラマです。そこには机上の論理以外の様々な要素を考慮する必要があります。

結論を出す前に我々が向き合わなければいけない現実的な考慮事項は4つあります。

まず第1に、一時オブジェクトの検証結果ですが、インライン展開が効かない、前置インクリメントにアドバンテージのあるシチュエーションにするには、リンク時最適化の効かないコンパイラ上で、ユーザー定義のインクリメントオペレータ演算子をcpp側に書く必要があります。
インクリメント演算子もデクリメント演算子もクラス書くたびに必ず用意してます(しかもヘッダーファイルじゃなくcpp側に!)という人は別として、一般的に最も多く呼び出されるインクリメント演算子は組み込みのint、もしくはSTLのイテレータに対してではないでしょうか。
その場合、やはり先程の検証でもあったように、コンパイラの素晴らしい最適化により、前置と後置のコストの違いは限りなく少なくなります。

 

第2に、インクリメント演算によって発生するなんらかのストールを回避するにはインクリメント前の値を使うようなコードを心がける必要があります。
例えば、インクリメント演算子を式中で使ったり、インクリメント後もインクリメント前の値に対する処理を書くという感じです。
しかし可読性の観点からすると、あまり推奨されるようなコードではありません。

 

第3に、ストールのコストは肌で感じづらいという問題があります。
処理に直接関係するコストは、例えばアセンブラコードの出力を見れば把握できます。
余分なオブジェクトが生成されているな、とか、最適化されているな、とか。
しかし、あるコードを書いた時にその部分の処理によってCPUにストールが発生しているかというのは極めて把握が難しい問題です。

 

第4に、しかし、それでも、いかんともしがたく、アーキテクチャに引っ張られることがあるという点です。
中盤でも触れましたが、一部のアーキテクチャにおいてはストールの発生を抑えなければロクに処理が動かないというケースがあるのも事実です。そういうアーキテクチャと向き合わなければならない場合、全く一切選択の余地なく、可読性うんぬん以前に、方針を決めざるをえない場合もあります。

 

CPUのアーキテクチャへの理解と、コンパイラの挙動、そして現実にコードを書く我々、この3つを勘案した上で、実際に前置インクリメントを使うべきか、後置インクリメントを使うべきか、また今の時代そういうことは気にしないべきか、考える必要があります。

私は、後置インクリメントを使っている事を指摘した際に「だってゲームエンジン・アーキテクチャに書いてあったからなんとなく」という答えをするプログラマが増えない事だけを願っています。

 

まとめ

  • 前置インクリメントを使うべきという主張の理由を理解しよう
  • 後置インクリメントを使うべきという主張の意図を汲もう
  • 時代の変化とともに柔軟に再考しよう

 

 

Pocket