5

「ループ展開の意味」は誰もが知っていると思います。念のため、具体的な例をすぐに示します。私が尋ねる質問は... x86-64アセンブリ言語でループを展開すると、実際にコードが高速になりますか? なぜ私がこの概念に疑問を持ち始めたのかを説明します。

「ループのアンローリング」という用語に慣れていない人のために、私が今書いているコードのループの一例を次に示します。

    movq   $15, %rcx                  # rcx = loop iterations
s1024_divide_compare_loop:
    movq   (%r14, %rcx, 8), %rax      # rax = numerator
    subq   (%r15, %rcx, 8), %rax      # flags = numerator - denominator
    js     s1024_divide_done          # divide done: (numerator < denominator)
    jnz    s1024_upshift_done         # do the divide: (numerator > denominator)
    subq   $1, %rcx                   # rcx = rcx - 1 : one less loop interation
    jns    s1024_divide_compare_loop  # check next lower 64-bit portion of n & d

そして、これがそのループが展開されたように見えるものです:

    movq   120(%r14), %rax            # rax = numerator
    subq   120(%r15), %rax            # flags = numerator - denominator
    js     s1024_divide_done          # divide done: (numerator < denominator)
    jnz    s1024_upshift_done         # do the divide: (numerator > denominator)

    movq   112(%r14), %rax            # rax = numerator
    subq   112(%r15), %rax            # flags = numerator - denominator
    js     s1024_divide_done          # divide done: (numerator < denominator)
    jnz    s1024_upshift_done         # do the divide: (numerator > denominator)

    movq   104(%r14), %rax            # rax = numerator
    subq   104(%r15), %rax            # flags = numerator - denominator
    js     s1024_divide_done          # divide done: (numerator < denominator)
    jnz    s1024_upshift_done         # do the divide: (numerator > denominator)

    movq   96(%r14), %rax             # rax = numerator
    subq   96(%r15), %rax             # flags = numerator - denominator
    js     s1024_divide_done          # divide done: (numerator < denominator)
    jnz    s1024_upshift_done         # do the divide: (numerator > denominator)
 #
 # insert 11 more copies of the above 4 lines (with different offsets) here
 #
    movq   0(%r14), %rax              # rax = numerator
    subq   0(%r15), %rax              # flags = numerator - denominator
    js     s1024_divide_done          # divide done: (numerator < denominator)
    jnz    s1024_upshift_done         # do the divide: (numerator > denominator)

上記の例は代表的なものですが、この議論には最適ではない可能性があることに注意してください。その理由は、条件分岐の数が多いためです。「取られなかった分岐」は他の命令と似ていると思いますが、その仮定は場合によっては正しくない可能性があります (あいまいな場合があります)。movqしたがって、この側面が関連する場合、これら 2 つの条件付き分岐は、またはこの説明のような単純な命令であると想定できますaddq(ただし、異なる場合は両方のケースに個別に対処することが望ましいです)。

ああ、2 つの最終条件:

#1: この質問は、シングル スレッドで実行中のコードにのみ適用されます。

#2:この質問は、最新の高速〜4GHz CPU(FX-8350など)にのみ適用されます。

さて、ここで、ループの展開が実際に賢明かどうか疑問に思う点について説明します。

これらの新しいプロセッサは 4 GHz で動作し、各サイクルで 2 つまたは 3 つの命令を実行できる場合があります。上記の私のコードでは、最初の 2 つの命令は並列に実行できず、おそらく最後の 3 つの命令も並列に実行できません。しかし、並行して実行できる命令を含むループは、私の質問をより適切なものにするだけです。

したがって、各命令は 0.25 ナノ秒 (または並列で実行される命令の場合はそれ以下) で実行される可能性があります。これは、4 つの命令の実行に 1 ナノ秒かかることを意味します。4 つの命令の各セットは、およそ 16 バイトを消費します。これは、キャッシュ ラインの 1/4 であると想定しています。したがって、4 ラインの 4 セットは、実行に 4ns かかる必要があり、その時点でキャッシュ ラインを終了し、別のラインが必要になります。

これは、質問がより複雑になるところです。

したがって、16 個の命令と展開されたループ全体の 1/4 の後、CPU はさらに多くの命令を実行する必要があります。CPU がコードのループ バージョンを実行していた場合、まったく同じ命令を再度実行しますが、これは間違いなく L1 キャッシュに残っているため、フルボアの CPU 速度で実行し続けます。

しかし、CPU がわずか 4ns で次のキャッシュ ラインをロードしたと合理的に期待できますか? あるいは、並行して実行できる命令の場合、CPU が次のキャッシュ ラインをわずか 2ns でロードしたと合理的に期待できますか?

ダイナミックRAMについて私が知っていることから、それは...きついようです。CPU が連続するアドレスにアクセスすると、RAS (上位アドレス ビット) がラッチされたままになり、連続する 64 ビットまたは 128 ビットのメモリ チャンクが CAS により高速に出力されることがわかっています。しかし、CPU が 2ns または 4ns で 64 バイトを読み取ることを本当に期待できるでしょうか? これは、CPU が読み取り操作ごとに 64 ビット (8 バイト) を読み取るか、128 ビット (16 バイト) を読み取るかに応じて、DRAM からの読み取りサイクルが 4 または 8 になります。

私の特定のコードは、この質問をさらに実行する可能性があります。このアルゴリズムの性質上、私のコードでは、最初に分子と分母の最も重要な部分を比較し、アクセスごとに下位アドレスに向かって下向きに処理する必要があります。これにより、自動プリフェッチが機能しにくくなりますか?

さまざまな人がループと展開されたループをテストしているのを見てきました。しかし、私が見たすべての例には、致命的な設計上の欠陥があります。同じルーチンを何度も何度も呼び出し続けます...通常は何百万回も...理解するのに十分な大きさのタイマー値を取得するために。ちょっと待って!ほとんどのアプリケーションと同様に、コードで 1024 ビットの除算関数が呼び出されることはほとんどありません。これは、私が見たこれらのテストとは異なり、その性質上、命令が L1 命令キャッシュに留まり、アクセスされたデータが L1 データ キャッシュに留まることが保証されます。

もちろん、コードとデータがすでに L1 キャッシュにあることを確認すれば、展開されたループは高速になります。当たり前!

これらは代表的なテストではありません。

これらのテストは「最良のケースのパフォーマンス」を測定しますが、通常のプログラムの実行をまったく表していません。しかし、64 ビット アセンブリ言語コード (またはコンパイラのコード エミッター部分) を最適に記述する方法を決定するには、私たちの前提でより現実的になる必要があります。または、少なくともそう思うので、この質問をします。

これらの質問を徹底的かつ現実的な方法で経験した人はいますか?

4

3 に答える 3

1

このレベルの最適化は、マイクロ アーキテクチャに大きく依存しており、対象が広すぎて包括的な答えを出すことができません。GMPライブラリのmpn/x86_64ディレクトリには、さまざまな u-arch の README と注釈付きアセンブリがあります。

そうです - GMP への貢献者は、これらの問題に徹底的に対処してきました。また、最新の x86-64 ではそれほど効果的ではありません、展開によって速度が向上する場合もあります。デコードされた命令/uop キャッシュに収まるループ、ループのアライメント、分岐予測、部分的なストールの回避なども重要です。Agner Fog の最適化マニュアルも優れたリソースです。

最後に、アセンブリで記述されたビット単位のシフト/減算 [非] 復元除算を使用しても、C で記述された単語ごとの多倍精度除算の実装と競合することはありません。GMP がそれを使用しないのには理由があります。古典的な 'Knuth アルゴリズム D' を理解するには、(ペンと紙を使って) ある程度の努力が必要です。特に、商の推定/商の調整条件です。そうしないと、ここでのあなたの努力が無駄になるのではないかと心配しています。

固定オペランド サイズを使用すると、正規化された除数と作業剰余をスタックに格納できます。除算命令は推定ステップでのみ使用されるため、アルゴリズムは実際には乗算命令のコストによって支配されます。Handbook of Applied Cryptographyの第 14 章は、実装の詳細についての良いリファレンスです。

于 2014-01-03T10:56:21.407 に答える