Luajit公式パフォーマンス最適化ガイドとメモ

Luajitは現在最も高速なスクリプト言語の1つですが、これを深く使用すると、この言語を主張されているほど高性能にするのは簡単ではないことがすぐにわかります。実際に使用すると、作成されたばかりのいくつかの小さなテストケースのパフォーマンスは非常に良好であることがよくあります。多くの場合、ミリ秒レベルでもですが、コードの複雑さが上がると、数千ミリ秒の状況が発生し、パフォーマンスは非常に不安定になります。 。

このため、Luajitのメーリングリストも多くの人から参考にされ、著者のmike pallによるより完全な回答が公式wikiに掲載されました。

http://wiki.luajit.org/Numerical-Computing-Performance-Guide

しかし、元のテキストはその方​​法について多くのことを述べていましたが、基本的には理由を説明していませんでした。

したがって、この記事は公式の最適化ガイドを単純に翻訳したものではありません。最も重要なことは、luajitの背後にある原則のいくつかを全員に理解させることです。元のテキストはその方​​法を説明するだけであり、理由を説明していないため、これらの最適化の結果、影響の大きさその理由は非常にあいまいです。その背後にある理由を理解することは、多くの場合私たちに大いに役立ちます。

さらに、ネイティブのlua、luajitのjitモード(PCおよびAndroidで使用可能)、luajitのインタープリターモード(これはiOSでのみ実行できます)、luaの実装の原則は非常に異なりますが、一部のlua最適化スキルは見られませんそれは普遍的でなければなりません。この記事では、luajitのjitモデルに焦点を当てます。

1.不偏/予測不可能なブランチの数を減らします。

予測不可能な分岐コードを削減

分岐コードは、条件に従ってジャンプするコードであり(最も一般的なのはif..elseです)、予測不可能な分岐コードは何ですか?簡単に言えば:

条件1の場合

elseif条件2 then

条件1または条件2の確率が非常に高い(> 95%)場合、これは予測可能な分岐コードであると考えられます。

これは、Mike Pallが最初に設定するパフォーマンス最適化ポイントです(実際、そうであるはずです)。理由は、luajitがトレースコンパイラの特性を使用するためです。マシンコードをできるだけ効率的に生成するために、コードの操作に基づいています。上記の例のように、いくつかの仮定を行います。条件2を達成する可能性が非常に高いとLuajitが検出した場合、Luajitは条件2に従って最速の実行コードを生成します。

Luajitはプロセスについて何かを本当に知っているということです。

はい

これはトレースコンパイラの機能でもあります。最初にバイトコードを実行し、ホットスポットコードをプロファイリングし、最も効率的なマシンコードを最適化する前に最適化できるポイントを理解します。これがルアジットの現在のアプローチです。

これはなぜですか?より理解しやすい例を挙げましょう。luajitは動的に型付けされた言語です。a+ bに直面しても、aとbの型がわからないのです。a+ bが2つの整数の加算だけの場合は、マシンコードをコンパイルして合計します速度は自然に速いです。しかし、これを確認できない場合は、それが任意のタイプであると見なすことができるだけであり、最初にタイプを動的にチェックして(2つのテーブル、2つの値、または他のケースであるかどうかを確認する)、次にスキップしてタイプに応じて対応することを実行します処理し、それについて考え、2つの整数を加算するよりも数十倍遅いことを知ってください。

したがって、最終的なパフォーマンスのために、Luajitは大胆な仮定を行い、a + bが2つの値の加算であることが判明した場合、数値合計のマシンコードをコンパイルします。

しかし、ある時点でa + bが数値の加算ではなく、2つのテーブルの加算になるとしたらどうでしょうか。このマシンコードはエラーを引き起こしませんか?したがって、luajitが仮説を立てるたびに、ガードコード(guard)の一部を追加し、仮説が正しいかどうかを確認し、正しくない場合は飛び出し、状況に応じて、新しいマシンコードをコンパイルするかどうかを決定します新しい状況に適応する。

これがブランチコードが予測可能でなければならない理由です。Luajitの想定を満たさないことが多いと、コンパイルされたマシンコードからジャンプしたり、いくつかの失敗した想定のためにジャンプしたりすることがあります。したがって、luajitは分岐に非常に敏感な言語です。

これはLuajitの最初のパフォーマンスピットです。著者は、math.min / maxまたはbitopを使用して、他の場合と同様に分岐コードをバイパスできることを示唆しています。ただし、実際の状況は多くの場合より複雑であり、ジャンプコードを含むすべての場所が潜在的なパフォーマンスピットです。

さらに、インタープリターモード(iosの場合)では、luajitは正直で動的な動的チェックの実行モードになり、分岐予測に影響されず、最適化のこの側面にあまり注意を払う必要がありません。

2.FFIデータ構造を使用します。

可能であれば、luaテーブルではなくffiを使用してデータ構造を実装します

Lujitのffiは、多くの場合見過ごされがちな機能であるか、またはより優れたCエクスポートライブラリとして使用されているだけですが、実際にはこれは非常に優れたツールです。

たとえば、Vector3を1に実装するには、luaテーブルとffiをそれぞれ使用し、テストしました。メモリ占有率は10:1、x + y + zの操作時間は約8:1で、最適化効率は驚くべきものです。

コードは次のとおりです。

ローカルffi = require( "ffi")

ffi.cdef [[

typedef struct {float x、y、z; } vector3c;

]]

ローカルカウント= 100000

ローカル関数test1()-luaテーブルのコード

  ローカルvecs = {}

  i = 1の場合、カウント

    vecs [i] = {x = 1、y = 2、z = 3}

  終わり

  ローカル合計= 0

  -以下のforループの時間とメモリ使用量をgcの後に記録します。ここでは省略します

  i = 1の場合、カウント

    合計=合計+古い[i] .x +古い[i] .y +古い[i] .z

  終わり

終わり

ffiのローカル関数test2()コード

  ローカルvecs = ffi.new( "vector3c [?]"、count)

  i = 1の場合、カウント

    vecs [i] = {x = 1、y = 2、z = 3}

  終わり

  ローカル合計= 0

  -以下のforループの時間とメモリ使用量をgcの後に記録します。ここでは省略します

  i = 1の場合、カウント

    合計=合計+古い[i] .x +古い[i] .y +古い[i] .z

  終わり

終わり

なぜそんなに大きなギャップがあるのですか?luaテーブルは基本的にハッシュテーブルであるため、ハッシュテーブル内のフィールドへのアクセスは遅く、多くの余分なものを格納する必要があります。そして、ffiはVector3を表すために3つのxyz floatスペースのみを割り当てることができ、自然なメモリフットプリントははるかに低く、jitはffi情報を使用して、ハッシュテーブルのようにではなく、xyzにアクセスするときにメモリに直接アクセスします。キーハッシュを1回歩くと、パフォーマンスが大幅に向上します。

残念ながら、ffiはjitモードがある場合にのみ実行速度が向上します。現在、モバイルゲームは基本的にiosを実行する必要があります。iosはiosでのみ解釈モードを実行できるため、ffiのパフォーマンスは非常に低くなります(純粋なテーブルよりも優れています)。 (遅い)、メモリの利点のみが保持されるため、iOSのようなプラットフォームを検討する場合、この最適化ポイントは基本的に無視するか、Androidでいくつかのコアコードに対してのみ最適化できます。

3. C関数はFFI経由でのみ呼び出します。

ffiを使用して、可能な限りc関数を呼び出します。

同様に、ffiは、extern cを持つc関数を呼び出すためにも使用できます。これはトルアなどのツールでエクスポートする手間を省くだけだと誰もが思っているようですが、ffiの大きな利点は品質の向上です。

これは、ffiを使用してc関数をエクスポートするために、c関数のプロトタイプを提供する必要があるためです。c関数のプロトタイプ情報を使用して、luajitは各パラメーターの正確なタイプと戻り値の正確なタイプを知ることができます。コンパイラーの知識を理解している学生は、関数の呼び出しと戻り値は通常スタックを使用して実装されることを知っています。これを行うには、パラメーターリスト全体と戻り値の型を知って、スタックからプッシュされるコードを生成する必要があります。したがって、この情報を得た後、Luajitは標準のluaとcの相互作用などのパラメーターを渡すためにpushintやその他の関数を呼び出す必要なく、マシンコードを生成してCコンパイラのようなシームレスな呼び出しを行うことができます。

ffiを介してcエクスポート関数を呼び出さない場合、luajitにはこの関数に関する情報がないため、c関数を呼び出すためのjitコードを生成できず、当然パフォーマンスが低下します。また、バージョン2.1.0より前のバージョンでは、これによりjitが直接失敗し、関連するコード全体をjitizeできず、パフォーマンスに大きな影響があります。

4.単純な「for i = start、stop、step do ... end」ループを使用します。

ループを実装するときは、i = start、stop、step doまたはipairsに単純なものを使用し、k、vのペア(x​​)を回避することをお勧めします

まず第一に、最新のluajit2.1.0beta2までは、k、vのペア(t)は、jitをサポートしていません(つまり、実行するマシンコードを生成できません)。このピットの存在については、主にkvでテーブルをトラバースするアセンブリを書くのが比較的難しいためですが、少なくとも配列を効率的にトラバースしたりforループを実行したい場合は、インデックスを直接使用する方法が最適な方法であることがわかります。

第二に、この書き方は循環的な開発を促進します。

5.展開に適したバランスを見つけます。

サイクルが展開し、長所と短所があり、自分のバランスをとる必要があります

C ++の初期の時代には、手動でループコードをシーケンシャルコードに拡張することが一般的な最適化方法でしたが、後のコンパイラはすべて、この種のことを手動で行う代わりに、特定のループ拡張最適化機能を統合しました。luajit自体にもこの最適化が付属しており(その実装関数lj_opt_loopを参照)、ループを拡張できます。

ただし、このデプロイメントは実行時に行われるため、長所と短所があります。作成者が例を示します。2層ループで内部ループの数が10未満の場合、この部分は拡張しようとしますが、外側に大きなループがネストされているため、外側の大きなループにより、内側のループが複数回入ることがあります。拡張が多すぎて拡張され、最終的にjitは拡張をキャンセルします。

この領域のパフォーマンスについては、著者は詳細なテストを行っておらず、さらに知覚的な最適化の提案を行っているだけです(最後の文、少し実験する必要があるかもしれません)。

6.モジュール内の「ローカル」(!)関数のみを定義して呼び出します。

7.他のモジュールから頻繁に使用される関数をアップバリューにキャッシュします。

これらの両方のポイントを一緒に取ることができます。つまり、任意の関数を呼び出すときに、この関数がローカル関数であることを確認します。これにより、次のようなパフォーマンスが向上します。

ローカルms = math.sin

機能テスト()

  math.sin(1)

  ms(1)

終わり

math.sinを呼び出すこれら2つの行の違いは何ですか?

実際、mathはテーブルであり、Math.sin自体がテーブルルックアップを実行し、キーはsinです。そして、数学はグローバル変数なので、グローバルテーブルでルックアップを行う必要があります(_G [数学])

ローカルmsがキャッシュされた後、math.sin検索は省略できます。さらに、関数の上位層の変数の場合、luaには格納するupvalueオブジェクトがあります。msの変数を検索する場合は、upv​​alueオブジェクトにのみ存在する必要があります検索、検索範囲が小さくて速い

もちろん、jit化されたコードはこのプロセスをさらに最適化するかもしれませんが、より良い方法は、自己ローカルキャッシュにすることです。

つまり、関数がこのファイルでのみ使用されている場合はローカルになります。グローバル関数の場合は、使用する前にローカルキャッシュを使用します。

8.独自のディスパッチメカニズムの作成を避けます。

独自の配布呼び出しメカニズムの実装の使用を避け、メタテーブルなどの組み込みメカニズムの使用を試みます。

プログラミングを洗練させるために、メッセージ配信などのメカニズムがしばしば導入されます。次に、メッセージが到着すると、対応する実装が、メッセージに対して定義した列挙に従って呼び出されます。以前は、次のように記述していました:

opcode == OP_1の場合

elesif opcode == OP_2 then

...

しかし、luajitでは、上記をテーブルまたはメタテーブルとして実装することをお勧めします

ローカルコールバック= {}

callbacks [OP_1] = function()...終了

callbacks [OP_2] = function()...終了

これは、テーブル検索とメタテーブル検索の両方がjit最適化に参加でき、自己実装のメッセージ配信メカニズムがブランチコードまたは他のより複雑なコード構造を使用することが多く、パフォーマンスが純粋なテーブル検索+ jit最適化ほど良くないためです。速い

9.JITコンパイラーを推測してはいけません。

jitコンパイラーが手動で最適化を行うのを助ける必要はありません。

著者は例を引用します

z = x [a + b] + y [a + b]、これはluajitでパフォーマンスokを書き込む方法です。最初にローカルc = a + b、次にz = x [c] + y [c]の必要はありません。

後者の書き込み方法自体は実際には問題ありませんが、luajitのもう1つの欠点は、操作効率を向上させるために、ローカル変数がCPUレジスタにできるだけ多く格納されることです。これは、頻繁にメモリを読み取るよりもはるかに高速です(最新のCPUは数百回に達する可能性があります)ギャップ)ですが、この点でluajitは完璧ではありません。ローカル変数が多すぎると、十分なレジスタ割り当てが見つからない可能性があります(この問題はarmv7で非常に明白です。コールレベルが深い場合、いくつかの変数が爆発します)。 、そしてjitは直接コンパイルをあきらめます。ここで注意すべき点の1つは、多くのローカル変数は役に立たないと宣言されている可能性があることですが、Luajitのコンパイラーはこの変数を格納できないかどうかを正確に判断できない場合があるため、関数スコープ内のローカル変数の数は適切に制御されます。必須です。

もちろん、このコードを記述してLuajitの動作を推測するのは非常に困難であると言わざるを得ませんが、一般的に言えば、パフォーマンスホットスポットコードをプロファイリングし、テストして最適化するだけで十分です。

10.エイリアシング、特に注意してください。複数の配列を使用する場合。

変数のエイリアスは、特に複数の配列を使用する場合に、jitが部分式を最適化するのを妨げる可能性があります。

著者は例を引用します

x [i] = a [i] + c [i]; y [i] = a [i] + d [i]

2つのa [i]は同じものだと思うかもしれませんが、コンパイラは

ローカルt = a [i]; x [i] = t + c [i]; y [i] = t + d [i]

これは当てはまりません。xとaは同じテーブルであるように見えるため、x [i] = a [i] + c [i]はa [i]の値を変更し、次にy [i] = a [ i] + d [i]は、以前のa [i]の値を使用できなくなりました

これと最適化ポイント9で説明されている状況との本質的な違いは、最適化ポイント9では、z / a / bはすべて値の型であり、ここでx / aはすべて参照型であり、参照型は同じもの(変数のエイリアス)を参照する場合があります)、コンパイラーはこの最適化をあきらめます。

11.ライブ一時変数の数を減らします。

ライブ一時変数の数を減らす

理由は、残存する一時変数が多すぎるとレジスターが使い果たされ、jitコンパイラーがレジスターを最適化に使用する可能性があるためです。ここで、ライブ一時変数はライブ一時変数を指すことに注意してください。一時変数の寿命を早期に終了しても、コンパイラはこれを認識します。たとえば、次のとおりです。

関数foo()

  行う

   ローカルa = "haha"

  終わり

  プリント(a)

終わり

ここではprintはnilを出力します、なぜなら葉は...終了し、ライフサイクルを終了するからです。

さらに、非常に一般的なトラップがあります。たとえば、3次元空間でベクトルを表現するためにVector3タイプを実装し、しばしば__addなどのメタ関数の一部をオーバーロードします

Vector3 .__ add = function(va、vb)

    Return Vector3.New(va.x + vb.x、va.y + vb.y、va.z + vb.z)

終わり

次に、コードでa + b + cを使用して、Vector3の束を合計します。

これは実際には、luajitの大きな隠れた危険です。各+は、新しいVector3を生成します。これにより、多数の一時変数が生成され、gcのプレッシャーに関係なく、これらの変数にレジスタを割り当てるだけです。間違えやすい。

したがって、ここにパフォーマンスと使いやすさの最良のバランスがあります。合計が元のテーブルに書き込まれるたびに、圧力ははるかに低くなります。もちろん、使いやすさとコードの読みやすさが必要になる場合があります。いくつかを犠牲にします。

12.高価な、またはコンパイルされていない操作を散在させないでください。

高消費またはjitをサポートしない操作の使用を減らす

これは、NYI(まだ実装されていない)に属するluajitドキュメントについての言及です。これは、作成者がこの機能を終了していないことを意味します。

Luajitは、コードをマシンコードにコンパイルして実行することはほぼ可能ですが、すべてのコードをjitizeできるわけではありません。上記のforペアに加えて、そのような多くのものがあり、最も一般的なものは次のとおりです。

k、vのペア(x​​)の場合:主な理由は、ペアがjitなしで実装されていることです。可能であれば、代わりにipairを使用してください。

print():これは非日本語化であり、著者はio.writeを推奨しています。

文字列コネクタ:ロギングの方法でログ( "haha" .. x)を書き込むのは簡単で、ログの実装をシールドして消費を回避します。実際にブロックできますか?もちろん卵ではありません。文字列リンク "haha" .. xが引き続き実行されるためです。2.0.xでは、このコードはjitをサポートしていません。2.1.xは最終的にサポートしますが、冗長な接続文字列操作とメモリ割り当てが引き続き発生するため、ブロックしたい場合は、ログ( "haha%s"、 x)この書き方。

table.insert:現在、末尾からjitのみが挿入されており、他の場所から挿入された場合はcにジャンプします。

524件のオリジナル記事を公開 172 件を賞賛 100,000回以上の閲覧

おすすめ

転載: blog.csdn.net/INGNIGHT/article/details/104914870