プログラミング
効率的なC++コードの書き方 (2013年)
Writing Efficient C++ Code (2013) (asawicki.info)
要約
この記事は、パフォーマンスが重要なアプリケーションでC++が選ばれる理由と、効率的なコードを書くための実践的なアプローチを探求しています。特に、データ指向設計(DOD)に焦点を当て、従来のオブジェクト指向プログラミング(OOP)のアプローチと比較して、メモリレイアウトとハードウェアの特性を考慮することの重要性を強調しています。また、キャッシュの効率性や過剰な抽象化を避けることの利点についても論じています。
全文翻訳
プログラミング、グラフィックス、ゲーム、メディア、C++、Windows、インターネットなど... メインページブログプロダクションについてプロダクション » 出版物 » 効率的なC++コードの書き方 英語 | ポーランド語 この記事は、元々2013年4月号(11号)のProgramista誌にポーランド語で掲載されたものです。スクリプト言語、インタープリタ言語、仮想マシン上で実行される高水準言語には多くの利点があり、数多くのアプリケーションに最適ですが、時には可能な限り効率的なコードを書く必要がある場合があります。C++のようなネイティブ言語を選択するだけでは不十分です。知識と良い実践への習熟だけが、ハードウェアから最大限のコンピューティングパワーを引き出すことを可能にします。
はじめに
C++は珍しい言語です。複雑で、習得が難しく、いくつかの点で論争の的となっています。しかし、多くのアプリケーション、特にゲームプログラミングのようにパフォーマンスが重要な分野では、しばしば最良、あるいは唯一の選択肢となります。これは、それが一種の妥協点となるユニークな特性を持っているからです。高水準であり、オブジェクト指向プログラミングや、ベクトル、文字列、その他のSTLコンテナのようなカスタム型やデータ構造の便利な使用または作成をサポートします。同時に、ハードウェア自体、あるいはむしろオペレーティングシステムへのアクセスを(ある意味で)可能にするほど低水準でもあります。仮想マシンやフレームワークがその邪魔をすることはありません。メモリの割り当てと解放は自分たちで管理しなければなりませんが、それは予期せぬ瞬間にガベージコレクタが独自のやり方でそれを実行することもないことを意味します。さらに、C++(そして部分的に後方互換性のあるC)には膨大な数のライブラリが存在し、多くのプラットフォームでコンパイラが利用可能です。ある意味では、ネイティブコードが再び注目されているとさえ言えるかもしれません。
ソフトウェアはますます複雑になり、ハードウェアはますます高速になっていますが、プログラムでは依然として効率的なコードが必要です。プロセッサを速くしたり、RAMを増やしたりする方が、優秀なプログラマーの時間よりも安価であると言う人もいます。しかし、一度書かれたプログラムが何年も実行される場合や、数百万台の機械にインストールされる場合はどうでしょうか?パフォーマンスは、コンピューティングクラスターやデータセンター(電力と冷却コストが莫大になる可能性がある)でも、スマートフォンやタブレットのような最小のデバイス(可能な限り長いバッテリー寿命を望む場所)でも重要です。また、妥協なしに指定された速度でコードを実行しなければならないアプリケーションもあります。これらには、ゲーム(FPS、毎秒フレーム数の低下がスムーズなアニメーションの印象を損ない、不快なカクつきを引き起こす)や、指定されたビットレートでのメディアストリームのリアルタイム処理のような、データをリアルタイムで処理するプログラムが含まれます。また、ハードウェア要件を任意に高く設定できるとは限りません。例えば、ゲーム機は固定のプロセッサ速度とRAM容量を持っています。新しいCrysisのバージョンではなく、シンプルなカジュアルゲームを書いている場合、PCであっても最新のコンポーネントを要求することはできません。
データ指向設計
オブジェクト指向プログラミングは、プログラマーやチームが直面する困難に対する救済策のように思えるかもしれません。構造化された方法、つまり「昔ながらの」方法で大規模システムを書くことは難しすぎるでしょう。オブジェクト指向プログラミングでは、コードはクラスで構成されます。クラスは、データ(フィールド)とそのデータを操作する手続き(メソッド)をグループ化するだけでなく、何よりも現実世界や問題領域からの概念の抽象化として機能します。クラスは、少なくとも理論上は、可能な限り独立しており、他の場所で再利用可能であるべきです。オブジェクト指向プログラミングは、しかし、2つの方法で理解することができます。概念的に、その要素の背後にある哲学を考えることによって、または技術的に、便宜のために使用されるプログラミング言語のメカニズムとして扱うことによってです。それがすべて「舞台裏」でどのように機能するか、そしてそこからどのような特性と限界が導かれるかを理解することも価値があります。最初の方法だけに従うと、効率的なコードを書くことを目指す場合にプログラマーが留まるべきレベルからかけ離れてしまう可能性があります。
では、解決策は何でしょうか?データ指向設計(DOD)と呼ばれるアプローチの支持者は、やや異なる考え方を提案しています。この用語は近年、特にゲームプログラマーの間で人気が高まっています。それは、設計とコーディング中に、格納されるデータ、メモリ内のそのレイアウト、および適切なデータ構造の設計に焦点を当てることを意味し、その後でそれらを操作するアルゴリズムに焦点を当てます。これは構造化プログラミングの古い考え方に戻るように見えるかもしれませんが、クラスやオブジェクト指向プログラミングのすべての利点を使用することを排除するものではありません。それは、ハードウェアに近い考え方を保ち、言語が利用可能なメカニズムを使用して、単に「エレガント」であるだけでなく、シンプルで効率的なコードを作成する方法です。
図1を見てください。それはシンボリックにデータがメモリにどのように配置されるかを示しています。左側は、ポインタで接続された、遠く離れた構造体です。これは、互いに参照し合う多くの異なるクラスの小さなオブジェクトを使用した場合に発生します。このように書かれたコードは、2つの理由で非効率的になる可能性があります。第一に、ポインタを「飛び越える」ことでこのようなデータをたどる際の頻繁なキャッシュミスです。これは後で詳しく説明します。
図1. データ指向設計
第二の理由は、このようなオブジェクトを操作するコードを並列化することの難しさです。クラスのパブリックメソッド(しばしば仮想)しかなく、それらの内部で何が起こっているか(またはそれらのすべての派生クラスで何が起こっているか)を知らないという仮定がある場合、定義上、複数のスレッドで安全に実行することはできません。それらの操作中に、どの他のオブジェクトを参照しているかを知りません。一方、スレッドの使用は、共有データをミューテックス(クリティカルセクション)で保護する必要があることが多く、これはコードを効果的にシリアライズし、完全に並列実行されるのを防ぎます。
図の右側は、 successive operations on it: sorting, updating particular fields, deleting elements, or converting every element to another form. If the data is “transparent” and we know exactly what operation we want to perform, and if applying it to each element of the collection is independent of the other elements and the rest of the program, then we can easily parallelize the algorithm, for example by dividing the range of elements among threads. Blindly following object-oriented programming brings other pitfalls too. Some people instinctively wrap every piece of functionality they use, such as a library, in a wrapper of their own that is supposed to simplify its interface or make it (subjectively) more elegant. Such an extra layer, especially when it introduces its own logic or uses virtual methods, adds runtime overhead. I suggest instead making a habit of asking each time whether, in this particular case, we could simply use certain functions and classes directly, without adding another layer of abstraction. There is a similar tendency to make everything as general and universal as possible. Defining an interface consisting entirely of virtual methods and promising ourselves that we can replace the implementation with a completely different one without changing the interface is tempting, but do we really need that here and now? Design patterns, too, are sometimes overused as ready-made solutions that replace deeper thought about the code. In fact, simple solutions are often best. If we try to express as directly as possible what data a program must store and what operations it must perform on that data, the code will be simple, elegant, readable, and efficient at the same time. This contradicts the popular view that optimization means making code complicated and unreadable. Memory and Cache
It might seem that each processor instruction reads some data from specified addresses in RAM in one cycle, performs an operation on it (such as addition), and writes the result back to memory. In practice it is not that simple. The complex CISC instructions that make up x86 code are translated into microcode inside the
配列のような規則的なデータ構造のアイデアを示しています。それをソート、特定のフィールドの更新、要素の削除、または各要素を別の形式に変換するなどの successive operations を実行することによって処理します。データが「透明」であり、実行したい操作を正確に把握しており、コレクションの各要素への適用が他の要素やプログラムの残りの部分から独立している場合、例えば要素の範囲をスレッドに分割することによって、アルゴリズムを簡単に並列化できます。
オブジェクト指向プログラミングを盲目的に従うと、他の落とし穴も生じます。一部の人々は、インターフェースを単純化したり、(主観的に)よりエレガントにしたりすることを目的とした独自のラッパーに、ライブラリのような使用するすべての機能性を本能的にラップします。このような追加レイヤーは、特に独自のロジックを導入したり、仮想メソッドを使用したりする場合、実行時のオーバーヘッドを追加します。代わりに、この特定のケースでは、別の抽象化レイヤーを追加せずに、特定の関数やクラスを直接使用できるかどうかを毎回尋ねる習慣をつけることをお勧めします。
すべてを可能な限り一般的で普遍的なものにする傾向も同様です。仮想メソッドのみで構成されるインターフェースを定義し、インターフェースを変更せずに実装を完全に異なるものに置き換えることができると約束することは魅力的ですが、ここで今本当にそれが必要でしょうか?デザインパターンも、コードについての深い思考を置き換える既製のソリューションとして、しばしば過剰に使用されます。実際、シンプルなソリューションが最良であることがよくあります。プログラムが格納しなければならないデータと、そのデータに対して実行しなければならない操作を可能な限り直接的に表現しようとすると、コードはシンプルで、エレガントで、読みやすく、同時に効率的になります。これは、最適化はコードを複雑で読みにくくすることだと考える一般的な見方に反します。
メモリとキャッシュ
各プロセッサ命令が1サイクルでRAMの指定されたアドレスからデータを読み取り、それに操作(加算など)を実行し、結果をメモリに書き戻すように見えるかもしれません。実際にはそれほど単純ではありません。x86コードを構成する複雑なCISC命令は、内部でマイクロコードに翻訳されます。