C++ ときどき ごはん、わりとてぃーぶれいく☆

USAGI.NETWORKのなかのひとのブログ。主にC++。

C++ vs. C ; R5G6B5 color pixel transform battle

C++ Advent Calendar 2012 - 22 のC++とCのベンチマークについて気になったので少々書く事に。

the topic based from

分り易くて面白い記事なのですが、特にC++とCを比較するベンチマーク部分が少々気に掛かりましたので、とりあえず元記事の「検証3 ビットマップのピクセル処理 (C vs #define vs C++ template)」について触れてみようと思います。

(1) とりあえず試す

元記事のソースを基にビルド可能な必要最小限の変更(main付けたりとか程度)を加えて試してみる。ビルドオプションについてはGCC4.7で有効なO0,O1,O2,O3について試す。

なお、試験環境等は記事の末尾に記載します。また、GCCの最適化オプションについては、末尾のreferencesにGCCのmanの日本語版のリンクを掲載するので「最適化オプション」の項を参考にして下さい。

discussion

C++ は "C より高速"なバイナリーを生成したのか?

  • O0に置いてはNO。
  • O1に置いてはYES。
  • O2に置いてはYES。
  • O3に置いてはNO。

ポインターによるマシンネイティブに近い操作に対しデータ構造とアルゴリズムから論理的なプログラミングインターフェースをプログラマーに提供したC++のコードを比較すれば、最適化無しではC++が負けるのもまあ当然。勿論、CでC++がやっているのと同等のインターフェースをプログラマーに提供するライブラリーを作り、それを利用したコードと比べたらゼロオーバーヘッドになるでしょうけれど。

面白いのはO1とO2ですね。O0については素直にC++のライブラリーによる複雑さがCに対して不利になりましたが、O1とO2ではC++コードの方が最適化によりCよりも高速なバイナリーを生成できた様です。

さて、ベンチマークとしてはO3も面白い事に。O3ではO2からさらにループ展開や関数のインライン展開が自動的に行われます。そうすると今度はCの方が圧倒的に高速なバイナリーを出力する結果に。ここまでCが高速化した理由として考えられるのは、test_bitmap_color_transform関数の呼び出し内容が内部のループ含めほぼまるっとmainへとプレーンに展開された可能性がありそうです。一方、O2とO3でほぼ等しいスコアを記録したC++では、標準ライブラリーの複雑さというかデータ構造とアルゴリズムの分離が悪い意味でも上手く機能してしまいコンパイラーがCの単純なコードに対して行った程のインライン展開を出来なかったのでは無いかと推察します。

とりあえず生成されたバイナリーを逆アセンブルして眺めてみましょう。

test_bitmap_color_transform_pure_c辺りに着目して見ましょう。O0ではCソースと素直に対応する形でJMP/CALLQ/JBEが並んでいるのが分かります。対してO1ではレジスターを効率的に利用したり、ループ初回でループ末尾にJMPしてCMPL/JBEしていたのが除去されてループ末尾でCMP/JNEする様になったりしています。もちろんget_r/get_g/get_b/to_rgbもO0からO1ですっきり。O2ではtest_bitmap_color_transform_pure_cはやたら短くなってMOVZWLからEDX/ECZ/EAXを使いSHL/SHRでちょっとしたレジスター雑技に、O3ではこの関数がループ展開されてベタベタと長くなっています。

ここで、本題の C++ vs. C の前にCマクロ版についても考察して置きましょう。

#define CPPは意味が"無い"のか?

  • O0に置いてはNO。
  • O1に置いてはNO。
  • O2に置いてはYES。
  • O3に置いてはYES。

唐突ですが逆アセンブル結果を。

O0とO1では#define CPPによりtest_bitmap_color_transform_macro_c関数内の処理が全てインライン展開されて居る為にC版、C++版に比べて関数呼び出しコストが極限まで抑えられていてこの最適化レベルでの比較に置いては遥かに高速と言えます。

しかし、O0とO1まではコンパイラーによる最適化ではCPPによるプログラマー手作業のインライン展開に対して非CPP版ではオーバーヘッドがありしたが、O2とO3ではO2の段階で非CPP版と同様のレジスター雑技に最適化されその差が無くなります。よってこの命題「#define CPPは意味が無いのか?」については、少なくともCの範疇のコードを実用的にコンパイルするに置いてはYESと言って良さそうです。

※ちなみに憶測ですが、C++でもCPPによるメタレベルでのインライン展開が意味が無いかと言うと、そうでもない気はします。C++によりコンパイラーが最適化しきれない部分をCPPメタプログラミングで最適化する事はconstexprが実用化されつつあるC++11の現状でも効果のある状況は有り得るかもしれません。

さて、それでは本題。

C++版はC版より高速なバイナリーを生成するのか?

  • O0に置いてはNO。
  • O1に置いてはYES。
  • O2に置いてはYES。
  • O3に置いてはNO。

O0については標準ライブラリーの複雑さがそのまま出ているので当然。C++のゼロオーバーヘッドは同等の機能をCで実装するのに対して、なので。ところが面白いのがO1で既にC++の方が高速って事、O2もC++が高速。しかしO3はO2から全く効果が無かった様でC組に負けてしまいました。

例に依って逆アセンブル結果。

O0のコード量だけが桁違い(109KB、ほかは28KBほど)な時点でまあいろいろお察ししましょう… ・w・;

C++コードではシンボル名がマングルされて _Z31test_bitmap_color_transform_cppRSt6vectorI7pixel_tILi16EESaIS1_EE になっています。この関数直下では boost::for_each (≈ std::for_each)を呼んでいるだけなのでそれほど複雑な事は無く、O0がO1よりも冗長なのは例に依っての程度。ちなみに pixel_t::get_rは _ZNK7pixel_tILi16EE5get_rEv とか。

当然、std::for_eachを呼ぶだけのboost::for_eachすら関数としてそのまま残って呼ばれています。O0ではboost::for_each部分のアセンブリも中で大真面目に受け取ったコンテナーのbeginを呼んで、endを呼んで、それからstd::for_eachを呼んで…その中でもまたソースコードの忠実に冗長なアセンブリーが…と。O1ではboost::for_eachは消えて居ます。

さて、ではどこが最適化されてO0からO1とO2ではC++がCよりも速いコードになったのか、答えはpixel_tの最適化の様です。O0ではpixel_tのメンバー関数がアセンブリーにも存在しますが、O1で既にpixel_tのメンバー関数が見当たりません。そしてCではO2まで最適化されなかったループ内部の処理が、C++ではO1で既にstd::for_eachの中身も比較的すっきりと展開されています。

さらに、O2とO3の主要部分のアセンブリは同等で、ではstd::for_each自体もtest_bitmap_color_transform_cppへ展開され、さらにその展開内容は既にC版のO2でMOVZWL/SHR/SHLなどに最適化されたのともほぼ同様。

ちなみにこの展開されたループ部分ではC++でもCでも実行速度に差は…と、思ったけれど、C++版ではポインターより複雑なstd::vectorイテレーターを使っている為にそのチェックコードをコンパイラーが省けずにループ前にJEが残るというオーバーヘッドが…。これは今回は現実的にはどうでもよい程度のオーバーヘッドですがちょっと悔しいですね・w・;

しかし、O2においてC++がCより速くなっている理由は、どうやらO2レベルになるとCもC++も主要部分はほぼ同様なアセンブリーに最適化されたとは言え、よく見るとCの主要部分のアセンブリー(master_c.O2.s:0x400820-0x40084E)とC++の主要部分のアセンブリー(master_fixed_cxx.O2.s:0x400FA0-0x400FCA)ではCから生成されたものの方がC++から生成されたものよりも若干だが冗長になってしまっている。この差…かなぁ…。

O3でC++がCに負けた理由はやはりコードの意味から標準ライブラリーの複雑さを大胆にインライン展開できなかった事にありそうです。

(2) …と、思ったけど

(1)ではとりあえず引用元のソースコードをほぼそのまま用いました。しかし、ビットフィールドを用いたらどうなるか?inlineを付けたら?アライメントを指示したら?C++11のfor(:)を使うと?C++11のrvalue referenceも使うと?などなどC++でもCでもソースコードを書くプログラマーレベルでの最適化の予知はまだあります。(何を目的とするコードなのか、ベンチマークであれば何を対象とするのか次第なところではありますが。)

また、現実的にはこの様なR5G6B5のビット操作処理は例えばCPUがまだ200MHzやそこらでDirectXがまだ6とかだった頃でもMMX使ったりは当然実用的には行われて居て(実際その頃にゲーム作りたくて書いたお陰でアセンブラーも基礎は分かるし、その後もSIMDGPUを応用できる基礎を身に付けられたんだと思っています・w・)、GCCVC++など一般のPC向けの処理系であれば intrinsics.h からMMXやSSEを使うコード、或いは既に禁忌の古代魔法扱いではありますが昔ながらに__asm{}しても良い訳で…。

確かに C++ vs. C となると標準ライブラリーやBoostなどの有名どころのライブラリーの便利さの上とマシンネイティブに近いCという比較は1つの側面は持ちますが、C++はCのほとんどの仕様を内包し(C99+一部オーバーライド)、インラインアセンブラーもあり(実際インラインよりintrinsics.hや別にオブジェクトを生成するかもしれないけど)、マシンネイティブなコードを出力し、リンクし、がちんこでCPUやメモリー、場合に寄ってはGPUなどとも連携し、或いはハードウェアによってはバスに挿した拡張カードのアドレスを直打ちし…、まあ、とにかくみんなネイティブファミリーだし、必要に応じてC++でもCでもアセンブラーでも適材適所にコード書いてリンクすればいいんだよ!みんなネイティブワールドの仲間たち、オブジェクトコードになればみんないっしょさ!

と、言う訳で(1)書くのに想定以上に時間使っちゃったので本件はここまでで^^;

conclusions

と、ゆーか、C++を使おう!の私見?^^;

C++ と C を比較してどっちが速いバイナリーを生成するか?

同じだけのインターフェースをプログラマーに提供する訳で無ければ単純なコードを書きやすい C の方が速いバイナリーを生成する可能性は十分に有り得る事ではある。
しかし、C++でもCで実装可能な低機能だがシンプルで高速なバイナリーを生成するコードは100%記述可能であり、設計や実装、またライブラリーの選択の幅も広くプログラムの保守性も生産性も向上を期待できるC++を採用しつつ必要に応じてよりネイティブなC互換機能なりアセンブラーなりを応用してプログラミングをすれば通常の用途では間違いなく良いと考えます。

例外は、C++のランタイムをリンクするのも大変なんて極小規模の組み込み用途と、100%マシンリソースとそこで動くコードをプログラマーが掌握したいなんて場合だけじゃないかなぁーと。まあ、対象プラットフォームにC++の"きちんとしたまともな"コンパイラーがあるのか、という現実問題もありますけど…。(一般的なPCではLinux/MacOSX/Windowsにx86_64/x86系では大丈夫、スマフォなんかでARMなんかでもまあ割と、PPCやALPHAもまあ…、しかし組み込みになると騙りまがいの自称C++処理系も実際アリマシテ…・w・;)

spec.

uname -a ; g++ --version | head -n1 ; cat /proc/cpuinfo | grep "model name" | head -n1 ; cat /proc/meminfo | grep "MemTotal"    
Linux LH-MAIN 3.4.11-2.16-desktop #1 SMP PREEMPT Wed Sep 26 17:05:00 UTC 2012 (259fc87) x86_64 x86_64 x86_64 GNU/Linux
g++ (SUSE Linux) 4.7.1 20120723 [gcc-4_7-branch revision 189773]
model name      : AMD Phenom(tm) II X4 940 Processor
MemTotal:        8191384 kB