「ループ展開の意味」は誰もが知っていると思います。念のため、具体的な例をすぐに示します。私が尋ねる質問は... 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 ビット アセンブリ言語コード (またはコンパイラのコード エミッター部分) を最適に記述する方法を決定するには、私たちの前提でより現実的になる必要があります。または、少なくともそう思うので、この質問をします。
これらの質問を徹底的かつ現実的な方法で経験した人はいますか?