web-dev-qa-db-ja.com

コンパイラの最適化に依存するコードを記述することは悪い習慣ですか?

私はいくつかのC++を学習しており、関数内で作成された関数から大きなオブジェクトを返さなければならないことがよくあります。参照渡し、ポインターを返し、参照型ソリューションを返すことは知っていますが、C++コンパイラー(およびC++標準)が戻り値の最適化を可能にすることも読みました。すべての時間とメモリを節約できます。

さて、オブジェクトが明示的に値で返されると、構文がより明確になり、コンパイラーは一般にRVOを使用してプロセスをより効率的にします。この最適化に依存することは悪い習慣ですか?これにより、ユーザーにとってコードがより明確で読みやすくなります。これは非常に重要ですが、コンパイラーがRVOの機会を捉えると想定する必要がありますか?

これはマイクロ最適化ですか、それともコードを設計するときに留意すべきですか?

99
Matt

採用 最小の驚きの原則

このコードを使用するのはあなたであり、あなただけです。3年間であなたが同じことをしても、あなたが何をしても驚くことはないでしょうか。

次に進みます。

その他の場合はすべて、標準の方法を使用します。さもなければ、あなたとあなたの同僚は、バグを見つけるのが困難になるでしょう。

たとえば、私の同僚は私のコードがエラーを引き起こすことについて不平を言っていました。結局のところ、彼はコンパイラ設定で短絡ブール評価をオフにしていました。私は彼を平手打ちするところだった。

129
Pieter B

この特定のケースでは、間違いなく値で戻ります。

  • RVOとNRVOはよく知られている堅牢な最適化であり、C++ 03モードであっても、まともなコンパイラーで実際に作成する必要があります。

  • 移動セマンティクスは、(N)RVOが発生しなかった場合にオブジェクトが関数から移動されることを保証します。これは、オブジェクトが動的データを内部で使用する場合(std::vectorのように)にのみ役立ちますが、それがthatである場合は、実際にそうなります-スタックのオーバーフローは、ビッグオートマチックのリスクですオブジェクト。

  • C++ 17 enforces RVO。だから心配しないでください。それはあなたに消えることはなく、コンパイラが最新の状態になったときに完全に確立するだけです。

そして最後に、追加の動的割り当てを強制してポインターを返すか、結果の型をデフォルトで構築可能にして、出力パラメーターとして渡すことができるようにするだけで、おそらく決して決して発生しない問題の醜い非慣用的な解決策になります持ってる。

意味のあるコードを記述し、意味のあるコードを正しく最適化してくれたコンパイラ作成者に感謝します。

81
Quentin

さて、オブジェクトが明示的に値で返されると、構文がより明確になり、コンパイラーは一般にRVOを使用してプロセスをより効率的にします。この最適化に依存することは悪い習慣ですか?これにより、ユーザーにとってコードがより明確で読みやすくなります。これは非常に重要ですが、コンパイラーがRVOの機会を捉えると想定する必要がありますか?

これは、あまり知られていない、かわいらしい、小さな最適化ではなく、人身売買された小さなブログで読んだ後、使用することについて賢くて優れていると感じるようになります。

C++ 11以降、RVOは標準的な方法であり、このコードコードを記述します。これは一般的で、予想され、教えられ、講演で言及され、ブログで言及され、標準で言及され、実装されていない場合はコンパイラのバグとして報告されます。 C++ 17では、言語がさらに一歩進んで、特定のシナリオでコピーの省略を義務付けています。

この最適化に完全に依存する必要があります。

その上、値による戻りは、参照によって返されるコードよりもはるかに簡単なコードの読み取りと管理につながるだけです。値のセマンティクスは強力なものであり、それ自体が最適化の機会を増やす可能性があります。

61
Barry

記述するコードの正確さは、最適化に依存する必要があります決して。仕様で使用するC++「仮想マシン」で実行すると、正しい結果が出力されます。

ただし、あなたが話すのは、効率のような質問です。 RVO最適化コンパイラで最適化すると、コードの実行が向上します。他の回答で指摘されているすべての理由から、それは問題ありません。

ただし、この最適化をrequireした場合(コピーコンストラクターが実際にコードを失敗させる場合など)、今ではコンパイラ。

私自身の実践におけるこれの最良の例は、末尾呼び出しの最適化だと思います。

_   int sillyAdd(int a, int b)
   {
      if (b == 0)
          return a;
      return sillyAdd(a + 1, b - 1);
   }
_

これはばかげた例ですが、関数が関数の最後に再帰的に呼び出される末尾呼び出しを示しています。 C++仮想マシンは、このコードが適切に動作することを示しますが、whyについて少し混乱を招く可能性があります。場所。ただし、C++の実際の実装では、スタックがあり、スペースが限られています。追加で行う場合、この関数は、手順を踏んで実行する場合、少なくとも_b + 1_スタックフレームをスタックにプッシュする必要があります。 sillyAdd(5, 7)を計算する場合、これは大した問題ではありません。 sillyAdd(0, 1000000000)を計算したい場合は、StackOverflowを発生させるのに本当に困る可能性があります( good kind ではありません)。

ただし、最後の戻り行に到達すると、現在のスタックフレームのすべてが実際に終了していることがわかります。そのままにしておく必要はありません。末尾呼び出しの最適化により、既存のスタックフレームを次の関数に「再利用」できます。このように、_b+1_ではなく、1つのスタックフレームのみが必要です。 (これらのばかげた足し算と引き算はすべて実行する必要がありますが、より多くのスペースを必要としません。)実質的に、最適化によりコードは次のようになります。

_   int sillyAdd(int a, int b)
   {
      begin:
      if (b == 0)
          return a;
      // return sillyAdd(a + 1, b - 1);
      a = a + 1;
      b = b - 1;
      goto begin;  
   }
_

一部の言語では、末尾呼び出しの最適化が仕様で明示的に要求されています。 C++はそれらの1つではありません。私はケースバイケースで行かない限り、C++コンパイラにこの末尾呼び出しの最適化の機会を認識させることはできません。私のバージョンのVisual Studioでは、リリースバージョンはテールコールの最適化を行いますが、デバッグバージョンは(仕様上)行いません。

したがって、sillyAdd(0, 1000000000)を計算できるかどうかにdependするのは私にとって悪いことです。

16
Cort Ammon

実際にはC++プログラムは、コンパイラの最適化を期待しています。

特に、標準 containers 実装の標準ヘッダーを調べます。 [〜#〜] gcc [〜#〜] を使用すると、ほとんどのソースファイルの前処理済みフォーム(g++ -C -E)とGIMPLE内部表現(g++ -fdump-tree-gimpleまたは-fdump-tree-ssaを使用したGimple SSA)を要求できます(技術翻訳ユニット)コンテナを使用します。 (g++ -O2を使用して)実行された最適化の量に驚かれることでしょう。したがって、コンテナの実装者は最適化に依存します(ほとんどの場合、C++標準ライブラリの実装者は、最適化が行われることを知っており、それらを念頭に置いてコンテナ実装を記述します。時には、コンパイラに最適化パスを記述して、標準C++ライブラリで必要な機能を処理します)。

実際には、C++とその標準コンテナーを十分に効率的にするのはコンパイラーの最適化です。だからあなたはそれらに頼ることができます。

そして、あなたの質問で言及されたRVOの場合も同様です。

C++標準は、考えられる最適化でうまく機能するように(特に、新しい機能を提案しながら十分に優れた最適化を実験することによって)共同設計されました。

たとえば、次のプログラムを考えてみます。

#include <algorithm>
#include <vector>

extern "C" bool all_positive(const std::vector<int>& v) {
  return std::all_of(v.begin(), v.end(), [](int x){return x >0;});
}

g++ -O3 -fverbose-asm -Sを使用してコンパイルします。生成された関数はCALLマシン命令を実行しないことがわかります。そのため、ほとんどのC++ステップ(ラムダクロージャの構築、その繰り返しの適用、beginおよびendイテレータの取得など)が最適化されました。マシンコードにはループのみが含まれています(ソースコードには明示的に表示されません)。そのような最適化がなければ、C++ 11は成功しません。

補遺

(12月31日追加st 2017)

CppCon 2017:Matt Godbolt「私のコンパイラは最近何ができましたか?コンパイラの蓋を外す」 トークを参照してください。

コンパイラーを使用するときはいつでも、それによってマシンコードまたはバイトコードが生成されることが理解されます。言語の仕様に従ってソースコードを実装することを除いて、その生成されたコードがどのようなものであるかについては何も保証しません。この保証は、使用する最適化のレベルに関係なく同じであるため、一般に、1つの出力を他の出力よりも「適切」と見なす理由はありません。

さらに、RVOのように言語で指定されているような場合は、特にソースコードを単純化する場合は、それを使わずに済むのは無意味だと思われます。

コンパイラーが効率的な出力を生成するように多くの努力が払われており、その意図は明らかにそれらの機能を使用することです。

最適化されていないコード(デバッグなど)を使用する理由はあるかもしれませんが、この質問で言及されているケースは1つではないように見えます(コードが最適化された場合にのみ失敗する場合、それはいくつかの特殊性の結果ではありませんあなたがそれを実行しているデバイスの場合、どこかにバグがあり、コンパイラにある可能性は低いです。)

4
sdenham

他の人はC++とRVOに関する特定の角度をうまくカバーしたと思います。これはより一般的な答えです:

正確さに関しては、コンパイラーの最適化や、コンパイラー固有の動作全般に依存するべきではありません。幸いにも、あなたはこれを行っていないようです。

パフォーマンスに関しては、する必要があるは、一般にコンパイラ固有の動作、特にコンパイラの最適化に依存しています。標準仕様に準拠したコンパイラは、コンパイルされたコードが言語仕様に従って動作する限り、自由にコードをコンパイルできます。また、各操作の速度を指定する主流言語の仕様については知りません。

3
svick

コンパイラの最適化はパフォーマンスにのみ影響し、結果には影響しません。非機能要件を満たすためにコンパイラーの最適化に依存することは合理的であるだけでなく、多くの場合、あるコンパイラーが別のコンパイラーよりも優先される理由です。

特定の操作がどのように実行されるかを決定するフラグ(たとえば、インデックスまたはオーバーフロー条件)は、コンパイラーの最適化に頻繁に組み込まれますが、そうすべきではありません。それらは明示的に計算結果に影響を与えます。

コンパイラの最適化が異なる結果を引き起こす場合、それはバグです-コンパイラのバグです。コンパイラのバグに依存して、長期的には間違いです-修正されるとどうなりますか?

計算の動作を変更するコンパイラフラグの使用は十分に文書化されていますが、必要に応じて使用されます。

1
jmoreno

番号。

それは私がいつもしていることです。メモリ内の任意の16ビットブロックにアクセスする必要がある場合、これを行います

_void *ptr = get_pointer();
uint16_t u16;
memcpy(&u16, ptr, sizeof(u16)); // ntohs omitted for simplicity
_

...そして、そのコードの一部を最適化するためにできることは何でもコンパイラが行うことに依存します。このコードは、ARM、i386、AMD64、および実際には世の中にあるすべての単一のアーキテクチャで動作します。理論的には、最適化されていないコンパイラが実際にmemcpyを呼び出して、完全にパフォーマンスが低下する可能性がありますが、私はコンパイラの最適化を使用しているため、これは問題ありません。

代替案を検討してください:

_void *ptr = get_pointer();
uint16_t *u16ptr = ptr;
uint16_t u16;
u16 = *u16ptr;  // ntohs omitted for simplicity
_

この代替コードは、get_pointer()が非整列ポインタを返す場合、適切な整列が必要なマシンでは機能しません。また、代替案でエイリアシングの問題があるかもしれません。

memcpyトリックを使用する場合の-O2と-O0の違いは大きく、3.2 GbpsのIPチェックサムパフォーマンスと67 GbpsのIPチェックサムパフォーマンスです。桁違いの違い!

コンパイラを手助けする必要があるかもしれません。したがって、たとえば、コンパイラーに依存してループを展開する代わりに、自分でループを展開できます。有名な Duffのデバイス を実装するか、よりクリーンな方法を使用します。

コンパイラーの最適化に依存することの欠点は、コードをデバッグするためにgdbを実行すると、多くが最適化されていないことがわかる場合があることです。したがって、-O0を指定して再コンパイルする必要がある場合があります。つまり、デバッグ時にパフォーマンスが完全に低下します。コンパイラを最適化することの利点を考えると、これは取るに足らない欠点だと思います。

あなたが何をするにせよ、あなたのやり方が実際に未定義の振る舞いではないことを確認してください。 16ビット整数としてメモリのランダムなブロックに確実にアクセスすることは、エイリアシングとアライメントの問題により未定義の動作です。

1
juhist

Assembly以外で記述された効率的なコードでのすべての試みは、コンパイラーの最適化に非常に大きく依存しています。まず、効率的なレジスタ割り当てなどの最も基本的なものから始めて、余分なスタックがあちこちに溢れるのを防ぎ、少なくとも、優れたとは言えないほど適切な命令選択を行います。そうでなければ、80年代に戻り、registerヒントをあちこちに配置し、関数内の最小数の変数を使用して古いCコンパイラーを支援するか、goto便利な分岐最適化でした。

コードを最適化するオプティマイザーの機能に頼ることができないと感じていたとしても、アセンブリーでパフォーマンスクリティカルな実行パスをコーディングすることになります。

最適化を確実に行えるかどうかは、実際にどの程度信頼できるかという問題です。最適化は、使用しているコンパイラの機能をプロファイリングおよび調査し、場合によってはコンパイラがどこにあるかわからないホットスポットがある場合は逆アセンブルすることによっても解決できます。明らかな最適化を行うことに失敗しました。

RVOは古くから存在しているものであり、少なくとも非常に複雑なケースを除いて、コンパイラは古くから信頼性の高い方法で適用されてきたものです。存在しない問題を回避する価値はありません。

オプティマイザに頼るのではなく、それを恐れない側のエラー

それどころか、コンパイラの最適化に頼るのは少なすぎるよりも多すぎる方がいいと思います。この提案は、効率性、保守性、および顧客間の品質の認識が非常に高い、パフォーマンスが重要な分野で働く人からのものです。すべて1つの巨大なぼかし。オプティマイザに自信を持って頼りにして、頼りすぎて余計なものに頼りすぎて、残りの人生において迷信的な恐れからコーディングするだけの、あいまいなEdgeのケースを見つけてほしいと思います。少なくとも、プロファイラーに連絡して適切な速度で実行されない場合は適切に調査し、迷信ではなく貴重な知識を得ることができます。

オプティマイザに頼ることができます。がんばり続ける。オプティマイザの欠点に対する見当違いの恐れからプロファイリングする前に、ループで呼び出されるすべての関数をインライン化するよう明示的に要求し始めるような人にならないでください。

プロファイリング

プロファイリングは実際には回り道ですが、あなたの質問に対する究極の答えです。効率的なコードを書くことに熱心に取り組む熱心な初心者は、最適化するものではなく、非効率性に関するあらゆる種類の誤った勘を開発するため、最適化するものはないです。人間的には直感的ですが、計算上は間違っています。プロファイラーを使用した開発経験から、自信を持って使用できるコンパイラーの最適化機能だけでなく、ハードウェアの機能(および制限)を正しく理解できるようになります。最適化する価値がなかったものをプロファイリングすることのプロファイリングには、何であったかを学ぶよりも、間違いなくさらに多くの価値があります。

0
user204677