JVMの「魔法」は、プログラマーがJavaのマイクロ最適化に与える影響を妨げますか?私は最近C ++で読んだことがありますが、データメンバーの順序が最適化を提供できる場合があります(マイクロ秒環境で許可されます)。
まともなアルゴリズムがより大きな速度向上を提供することを感謝しますが、正しいアルゴリズムを取得すると、JVM制御のためにJavaを微調整するのが難しくなりますか?
そうでない場合は、Javaで使用できるトリックの例を(単純なコンパイラフラグのほかに)挙げてください。
JVMの「魔法」は、プログラマーがJavaのマイクロ最適化に与える影響を妨げますか?私は最近C ++で読んだことがありますが、データメンバーの順序が最適化を提供できる場合があります(マイクロ秒環境で許可されます)。
まともなアルゴリズムがより大きな速度向上を提供することを感謝しますが、正しいアルゴリズムを取得すると、JVM制御のためにJavaを微調整するのが難しくなりますか?
そうでない場合は、Javaで使用できるトリックの例を(単純なコンパイラフラグのほかに)挙げてください。
回答:
確かに、マイクロ最適化レベルでは、JVMは、特にCやC ++と比較してほとんど制御できないことを行います。
一方、CおよびC ++を使用したさまざまなコンパイラーの動作は、(コンパイラーのリビジョンを問わず)漠然と移植可能な方法でマイクロ最適化を実行する能力に特に大きな悪影響を与えます。
それは、どの種類のプロジェクトを調整しているのか、どの環境をターゲットにしているのかなどに依存します。とにかく、アルゴリズム/データ構造/プログラム設計の最適化から数桁優れた結果が得られているので、結局のところ、それは本当に重要ではありません。
マイクロ最適化はほとんど時間の価値がなく、ほとんどすべての簡単な最適化はコンパイラとランタイムによって自動的に行われます。
ただし、C ++とJavaが根本的に異なる最適化の1つの重要な領域があり、それはバルクメモリアクセスです。C ++には手動のメモリ管理があります。つまり、アプリケーションのデータレイアウトとアクセスパターンを最適化して、キャッシュを最大限に活用できます。これは非常に難しく、実行しているハードウェアにある程度固有のものです(したがって、異なるハードウェアではパフォーマンスの向上が見られない場合があります)。もちろん、あなたはあらゆる種類の恐ろしいバグの可能性でそれを支払います。
Javaのようなガベージコレクションされた言語では、この種の最適化はコードで実行できません。ランタイムによって実行できるものもあります(自動または構成により、以下を参照)。また、不可能なものもあります(メモリ管理のバグから保護されるために支払う代償)。
そうでない場合は、Javaで使用できるトリックの例を(単純なコンパイラフラグのほかに)挙げてください。
Javaコンパイラは最適化をほとんど行わないため、コンパイラフラグはJavaでは無関係です。ランタイムは行います。
実際、Javaランタイムには、特にガベージコレクターに関して調整可能な多数のパラメーターがあります。これらのオプションには「単純な」ものはありません。デフォルトはほとんどのアプリケーションに適しています。パフォーマンスを向上させるには、オプションの動作とアプリケーションの動作を正確に理解する必要があります。
[...](マイクロ秒環境で許可)[...]
数百万から数十億をループする場合、マイクロ秒が加算されます。C ++からの個人的なvtune / micro-optimizationセッション(アルゴリズムの改善なし):
T-Rex (12.3 million facets):
Initial Time: 32.2372797 seconds
Multithreading: 7.4896073 seconds
4.9201039 seconds
4.6946372 seconds
3.261677 seconds
2.6988536 seconds
SIMD: 1.7831 seconds
4-valence patch optimization: 1.25007 seconds
0.978046 seconds
0.970057 seconds
0.911041 seconds
「マルチスレッド」、「SIMD」(コンパイラーに勝る手書き)、および4価パッチ最適化以外はすべて、マイクロレベルのメモリ最適化でした。また、32秒の初期時間から始まる元のコードは既にかなり最適化されており(理論的に最適なアルゴリズムの複雑さ)、これは最近のセッションです。この最近のセッションが処理するのに5分以上かかったずっと前の元のバージョン。
メモリ効率の最適化は、シングルスレッドコンテキストでは数倍から数桁まで、そしてマルチスレッドコンテキストではさらに多くの場合に役立ちます(効率的なメモリ担当者の利点は、多くの場合、複数のスレッドで増加します)。
マイクロ最適化の重要性について
マイクロ最適化は時間の無駄であるというこの考えに、私は少し動揺します。私はそれが良い一般的なアドバイスであることに同意しますが、誰もが測定ではなく勘や迷信に基づいて間違ってそれを行うわけではありません。正しく行われた場合、必ずしも小さな影響を与えるとは限りません。Intel独自のEmbree(レイトレーシングカーネル)を使用して、作成した単純なスカラーBVHのみをテストし(ビートが指数関数的に難しいレイパケットではなく)、そのデータ構造のパフォーマンスをビートしようとすると、何十年もの間コードのプロファイリングとチューニングに慣れていたベテランでさえ、謙虚な経験。そしてそれはすべて、最適化が適用されているためです。彼らのソリューションは、レイトレーシングで働いている産業の専門家を見たときに、毎秒1億本以上の光線を処理できます。
アルゴリズムに焦点を絞ったBVHの簡単な実装を採用し、最適化コンパイラ(IntelのICCを含む)に対して毎秒1億を超えるプライマリレイの交差を取得する方法はありません。簡単なものは、多くの場合、1秒あたり100万の光線さえも取得しません。毎秒数百万本の光線を得るためにも、プロ品質のソリューションが必要です。毎秒1億本以上の光線を取得するには、Intelレベルのマイクロ最適化が必要です。
アルゴリズム
数分から数秒、または数時間から数分のレベルでパフォーマンスが重要でない限り、マイクロ最適化は重要ではないと思います。バブルソートのような恐ろしいアルゴリズムを例として大規模な入力で使用し、それをマージソートの基本的な実装と比較した場合、前者は処理に数か月かかり、後者はおそらく12分かかります。二次対線形の複雑さ。
数か月と数分の違いにより、パフォーマンスが重要な分野で働いていない人も含め、ほとんどの人が結果を得るまでに数か月待たなければならない場合、実行時間は許容できないと考えられるでしょう。
一方、マイクロ最適化されていない単純なマージソートをクイックソートと比較すると(マージソートよりもアルゴリズム的に優れているわけではなく、参照の局所性に対してマイクロレベルの改善しか提供されません)、マイクロ最適化されたクイックソートは12分ではなく15秒。ユーザーを12分間待機させることは、完全に受け入れられる場合があります(コーヒーブレイクのような時間)。
この違いは、たとえば12分から15秒の間、ほとんどの人にとっておそらく無視できると思います。そのため、分と月ではなく分と秒の違いにしかすぎないことが多いため、マイクロ最適化は役に立たないと見なされることがよくあります。私がそれが役に立たないと思うもう一つの理由は、それが重要でないエリアにしばしば適用されるということです:ループでなく、クリティカルでさえない1%の違いを生じる小さなエリア(非常によくちょうどノイズかもしれません)。しかし、これらのタイプの時間差を気にし、それを適切に測定して実行したい人にとっては、少なくともメモリ階層の基本概念(特にページフォールトとキャッシュミスに関連する上位レベル)に注意を払う価値があると思います。
Javaは、優れたマイクロ最適化の余地を十分に残しています
ええ、ごめんなさい-その種の暴言は別として:
JVMの「魔法」は、プログラマーがJavaのマイクロ最適化に与える影響を妨げますか?
少しだけですが、あなたが正しくやれば人々が考えるほどではありません。たとえば、手書きのSIMD、マルチスレッド、メモリ最適化(アクセスパターン、場合によっては画像処理アルゴリズムに応じた表現)を使用したネイティブコードで画像処理を行う場合、32ビットRGBAピクセル(8ビットカラーチャンネル)であり、1秒あたり数十億もの場合もあります。
Pixel
オブジェクトを作成したと言うと、Javaのどこにでも近づくことは不可能です(これだけでは、ピクセルのサイズが4ビットから64ビットで16に膨れ上がります)。
しかし、Pixel
オブジェクトを避け、バイトの配列を使用し、Image
オブジェクトをモデル化すれば、全体をもっと近づけることができるかもしれません。プレーンな古いデータの配列を使用し始めた場合、Javaはまだかなり有能です。私はJavaで前に物事のこれらの種類を試してみた、非常に感銘を受けました提供、通常よりも4倍大きいであることどこでもあなたが少しちっちゃいの束を作成しないことをオブジェクト(例:使用int
の代わりInteger
)などバルク・インタフェースをモデル化開始しますImage
インターフェイスではなく、Pixel
インターフェイス。オブジェクトではなくプレーンな古いデータ(float
たとえば、notの巨大な配列)をループしている場合、JavaはC ++のパフォーマンスに匹敵すると言えますFloat
。
おそらく、メモリサイズよりもさらに重要なのは、配列がint
連続表現を保証することです。の配列はありInteger
ません。連続性は、複数の要素(例:16 ints
)がすべて単一のキャッシュラインに収まり、効率的なメモリアクセスパターンでエビクションする前に一緒にアクセスされる可能性があることを意味するため、参照の局所性にとってしばしば不可欠です。一方、単一のInteger
メモリはメモリ内のどこかに取り残され、周囲のメモリは無関係であり、メモリのその領域を16行の整数ではなく、エビクションの前に単一の整数を使用するためにのみキャッシュラインにロードします。たとえ私たちが見事に幸運で周りを取り囲んだとしてもIntegers
メモリ内で互いに隣接していたため、Integer
4倍の大きさの結果として、立ち退き前にアクセスできるキャッシュラインに4のみを収めることができます。これはベストケースのシナリオです。
また、同じメモリアーキテクチャ/階層の下で統合されているため、そこには多くのマイクロ最適化があります。メモリアクセスパターンは、使用する言語に関係なく、ループタイル/ブロッキングなどの概念は一般にCまたはC ++ではるかに頻繁に適用される可能性がありますが、Javaでも同様に役立ちます。
最近、C ++で読んだことがありますが、データメンバーの順序によって最適化が可能になる場合があります[...]
一般的に、Javaではデータメンバーの順序は重要ではありませんが、それはほとんど良いことです。CおよびC ++では、ABIの理由でデータメンバの順序を維持することが重要になる場合が多いため、コンパイラはそれを混乱させません。そこで作業する人間の開発者は、パディングでメモリを浪費しないように、データメンバーを降順(最大から最小)に並べるなどの注意を払う必要があります。Javaを使用すると、明らかにJITは、パディングを最小限に抑えながら適切なアライメントを確保するために、その場でメンバーを並べ替えることができるので、そうであれば、平均的なCおよびC ++プログラマーがしばしばうまくいかず、メモリをそのように浪費することを自動化します(これは単にメモリを浪費するだけでなく、AoS構造間のストライドを不必要に増やし、キャッシュミスを増やすことで速度を浪費することがよくあります)。それ' パディングを最小限に抑えるためにフィールドを再配置する非常にロボット的なことなので、理想的には人間はそれに対処しません。オブジェクトが64バイトよりも大きく、アクセスパターン(最適なパディングではない)に基づいてフィールドを配置する場合に、最適な配置を人間が知る必要がある方法でフィールド配置が重要になる場合があります-その場合より人間的な努力かもしれません(クリティカルパスを理解する必要があります。その一部は、ユーザーがソフトウェアで何をするかを知らずにコンパイラが予測できない情報です)。
そうでない場合は、Javaで使用できるトリックの例を(単純なコンパイラフラグのほかに)挙げてください。
JavaとC ++の間のメンタリティを最適化するという点での私にとっての最大の違いは、パフォーマンスが重要なシナリオでC ++を使用すると、Javaよりも少し(十数)多くオブジェクトを使用できることです。たとえば、C ++は、オーバーヘッドをまったく発生させずに整数をクラスにラップできます(あらゆる場所でベンチマークが行われます)。Javaは、オブジェクトごとにメタデータポインタースタイル+アライメントパディングのオーバーヘッドを必要とするため、これBoolean
よりも大きくなりますboolean
(ただし、リフレクションの均一な利点と、final
すべての単一UDT としてマークされていない機能をオーバーライドする機能を提供します)。
C ++では、空間的な局所性が失われることが多い(または少なくとも制御が失われる)ため、不均一なフィールド間でメモリレイアウトの連続性を制御するのが少し簡単です(例:構造体/クラスを介してfloatとintを1つの配列にインターリーブする) JavaでGCを介してオブジェクトを割り当てるとき。
...しかし、多くの場合、最高のパフォーマンスのソリューションはしばしばそれらをとにかく分割し、プレーンな古いデータの連続した配列でSoAアクセスパターンを使用します。そのため、ピークパフォーマンスが必要な領域では、JavaとC ++の間のメモリレイアウトを最適化する戦略が同じであることが多く、ホット/ (あなただけのバイトまたはそのような何かの生の配列を使用していない限り)寒冷フィールド分割、SOAは担当者など非均質AoSoA担当者は、Javaで不可能のようなものに見えるが、これらはまれなケースのためにあるの両方順次およびランダムアクセスパターンは高速であると同時に、ホットフィールドのフィールドタイプが混在している必要があります。私にとって、これら2つの間の最適化戦略(一般的なレベルで)の違いの大部分は、ピークパフォーマンスに到達する場合には意味がありません。
以下のような小さなオブジェクトを持つ限り行うことができるそこにいるではない-あなたは、単に「良い」パフォーマンスのために達している場合の違いは、よりかなり変化Integer
対int
特にそれがジェネリック医薬品と対話の方法で、もう少しPITAのことができます。それはのために働くことにJavaでちょうどビルドに少し難しく中央最適化の対象として1つの汎用データ構造だint
、float
それらの大きく、高価なのUDTを回避しながらなど、が、多くの場合、最もパフォーマンスが重要な領域は、手圧延独自のデータ構造が必要になりますとにかく非常に特定の目的のために調整されているため、最高のパフォーマンスではなく良好なパフォーマンスを目指しているコードにとっては迷惑です。
オブジェクトのオーバーヘッド
Javaオブジェクトのオーバーヘッド(メタデータと空間的局所性の喪失、および最初のGCサイクル後の一時的局所性の一時的喪失)は、数百万単位のデータ構造に数百万単位で格納されている非常に小さいもの(int
対などInteger
)でしばしば大きくなることに注意してくださいほぼ連続しており、非常にタイトなループでアクセスされます。このテーマには多くの感度があるように思えるので、画像のような大きなオブジェクトのオブジェクトのオーバーヘッドを心配したくないことを明確にする必要があります。
誰かがこの部分に疑問を感じるならints
、100万ランダムIntegers
と100 万ランダムを合計し、これを繰り返し行うことの間のベンチマークを作成することをお勧めします(Integers
最初のGCサイクル後にメモリ内でシャッフルされます)。
Ultimate Trick:最適化の余地を残すインターフェイス設計
:あなたは小さなオブジェクト(例:オーバーハンドル高負荷というところを扱っている場合、私はそれを見るように究極のJavaトリックだからPixel
、4 -ベクトル、4x4の行列、Particle
おそらく、でもAccount
それだけでいくつかの小さなを持っている場合フィールド)は、これらの小さな事柄にオブジェクトを使用することを避け、単純な古いデータの配列(おそらく連鎖されている)を使用することです。その後のようなコレクションインタフェースになるオブジェクトImage
、ParticleSystem
、Accounts
でも、その基本的なオブジェクトのオーバーヘッドなしにするので、これはまた、CおよびC ++での究極のデザインのトリックの一つである例えば、個々のものは、インデックスによってアクセスすることができるなどの行列やベクトルの集合と、ばらばらのメモリ、単一粒子のレベルでインターフェースをモデリングすると、最も効率的なソリューションが妨げられます。
user204677
去ったのか不思議です。そのような素晴らしい答え。
一方ではマイクロ最適化と、他方ではアルゴリズムの適切な選択との中間の領域があります。
これは、定数係数の高速化の領域であり、桁違いの結果をもたらす可能性があります。
その方法は、最初の30%、残りの20%、残りの50%など、実行時間の一部を切り捨てることで、何回か繰り返して、残りがほとんどなくなるまで続けます。
これは、小さなデモスタイルのプログラムでは見られません。ご覧の場所は、多くのクラスデータ構造を持つ大きな深刻なプログラムで、通常、コールスタックは多くの層の深さです。高速化の機会を見つける良い方法は、プログラムの状態のランダムな時間サンプルを調べることです。
通常、高速化は次のようなもので構成されます。
new
古いオブジェクトをプールして再利用することでへの呼び出しを最小限に抑え、
実際に必要なのではなく、一般性のためにそこにあるようなものを認識し、
同じbig-O動作を持つが、実際に使用されるアクセスパターンを利用する異なるコレクションクラスを使用してデータ構造を修正する。
関数を再呼び出しする代わりに関数呼び出しによって取得されたデータを保存します(プログラマーは、短い名前の関数がより速く実行されると想定するのが自然で面白い傾向です)。
通知イベントとの完全な一貫性を維持しようとするのではなく、冗長データ構造間の一定量の不一致を許容します。
などなど
しかし、もちろん、これらのことは、サンプルを取ることによって最初に問題であることが示されない限り、行われるべきではありません。
Java(私が知っている限り)は、メモリ内の変数の場所を制御できないため、変数の誤った共有や整列などを避けるのが難しくなります(いくつかの未使用のメンバーでクラスを埋めることができます)。私があなたが利用できるとは思わないもう一つのことは、などの命令ですがmmpause
、これらはCPU固有のものです。
C / C ++の柔軟性を提供するだけでなく、C / C ++の危険を伴うUnsafeクラスが存在します。
JVMがコード用に生成するアセンブリコードを確認すると役立つ場合があります
この種の詳細を調べるJavaアプリについて読むには、LMAXによってリリースされたディスラプターコードを参照してください。
この質問は、言語の実装に依存するため、答えるのは非常に困難です。
一般的に、最近ではこのような「マイクロ最適化」の余地はほとんどありません。主な理由は、コンパイラがコンパイル中にそのような最適化を利用することです。たとえば、セマンティクスが同一の状況では、プリインクリメント演算子とポストインクリメント演算子の間にパフォーマンスの違いはありません。別の例としては、たとえば、次のようなループfor(int i=0; i<vec.size(); i++)
を呼び出します。size()
各反復中のメンバー関数では、ループの前にベクトルのサイズを取得し、その単一変数と比較して、反復ごとの関数呼び出しを回避する方が良いでしょう。ただし、コンパイラがこの愚かなケースを検出し、結果をキャッシュする場合があります。ただし、これは、関数に副作用がなく、ループ中にベクトルサイズが一定のままであることをコンパイラが確認できる場合にのみ可能です。これは、かなり些細な場合にのみ適用されます。
const
このベクターのメソッドのみを呼び出す場合、多くの最適化コンパイラーがそれを見つけ出すと確信しています。
Javaで使用できるトリックの例を(単純なコンパイラフラグに加えて)挙げることができます。
アルゴリズムの改善以外に、メモリ階層とプロセッサがそれをどのように利用するかを必ず検討してください。問題の言語がメモリをデータ型とオブジェクトにどのように割り当てるかを理解すると、メモリアクセスの待ち時間を短縮することに大きな利点があります。
1000x1000 intの配列にアクセスするJavaの例
以下のサンプルコードを検討してください。同じメモリ領域(intの1000x1000配列)にアクセスしますが、順序は異なります。私のmac mini(Core i7、2.7 GHz)では、出力は次のようになり、行でアレイを走査するとパフォーマンスが2倍以上になることを示しています(各100ラウンドの平均)。
Processing columns by rows*** took 4 ms (avg)
Processing rows by columns*** took 10 ms (avg)
これは、連続した列(つまりint値)がメモリ内で隣接して配置されるように配列が格納されるのに対し、連続する行は格納されないためです。プロセッサが実際にデータを使用するには、キャッシュに転送する必要があります。メモリの転送は、キャッシュラインと呼ばれるバイトブロック単位で行われます。キャッシュラインをメモリから直接ロードすると、レイテンシが発生し、プログラムのパフォーマンスが低下します。
Core i7(サンディブリッジ)の場合、キャッシュラインは64バイトを保持するため、各メモリアクセスは64バイトを取得します。最初のテストは予測可能な順序でメモリにアクセスするため、プロセッサはデータがプログラムによって実際に消費される前にデータをプリフェッチします。全体的に、これによりメモリアクセスのレイテンシが減少し、パフォーマンスが向上します。
サンプルのコード:
package test;
import java.lang.*;
public class PerfTest {
public static void main(String[] args) {
int[][] numbers = new int[1000][1000];
long startTime;
long stopTime;
long elapsedAvg;
int tries;
int maxTries = 100;
// process columns by rows
System.out.print("Processing columns by rows");
for(tries = 0, elapsedAvg = 0; tries < maxTries; tries++) {
startTime = System.currentTimeMillis();
for(int r = 0; r < 1000; r++) {
for(int c = 0; c < 1000; c++) {
int v = numbers[r][c];
}
}
stopTime = System.currentTimeMillis();
elapsedAvg += ((stopTime - startTime) - elapsedAvg) / (tries + 1);
}
System.out.format("*** took %d ms (avg)\n", elapsedAvg);
// process rows by columns
System.out.print("Processing rows by columns");
for(tries = 0, elapsedAvg = 0; tries < maxTries; tries++) {
startTime = System.currentTimeMillis();
for(int c = 0; c < 1000; c++) {
for(int r = 0; r < 1000; r++) {
int v = numbers[r][c];
}
}
stopTime = System.currentTimeMillis();
elapsedAvg += ((stopTime - startTime) - elapsedAvg) / (tries + 1);
}
System.out.format("*** took %d ms (avg)\n", elapsedAvg);
}
}
JVMは干渉する可能性があり、多くの場合、JITコンパイラーはバージョン間で大幅に変更される可能性があります。
ディスラプターの作成者のトピックに関する非常に有益なブログを読むことをお勧めします。
マイクロ最適化が必要な場合、なぜJavaを使用するのが面倒なのかを常に尋ねる必要があります。JNAまたはJNIを使用してネイティブライブラリに渡すなど、関数を高速化するための多くの代替方法があります。