web-dev-qa-db-ja.com

std :: fill(0)がstd :: fill(1)より遅いのはなぜですか?

システムでは、定数値_std::fill_または動的な値と比較して、定数値_std::vector<int>_を設定する場合、大きな_0_の_1_が大幅かつ一貫して遅いことがわかりました。

5.8 GiB/s対7.5 GiB/s

ただし、fill(0)の方が速いデータサイズの場合、結果は異なります。

performance for single thread at different data sizes

複数のスレッドでは、4 GiBデータサイズで、fill(1)はより高い勾配を示しますが、fill(0)(51 GiB/s対90 GiB/s):

performance for various thread counts at large data size

これにより、なぜfill(1)のピーク帯域幅が非常に低いのかという二次的な疑問が生じます。

このためのテストシステムは、デュアルソケットIntel Xeon CPU E5-2680 v3を2.5 GHzで(_/sys/cpufreq_経由で)8x16 GiB= DDR4-2133で設定しました。GCC6.1でテストしました。 0(_-O3_)およびIntelコンパイラ17.0.1(_-fast_)、両方とも同じ結果を取得します。_GOMP_CPU_AFFINITY=0,12,1,13,2,14,3,15,4,16,5,17,6,18,7,19,8,20,9,21,10,22,11,23_が設定されました。システムでStrem/add/24スレッドが85 GiB/sを取得します。

私はこの効果を別のHaswellデュアルソケットサーバーシステムで再現できましたが、他のアーキテクチャでは再現できませんでした。たとえば、Sandy Bridge EPではメモリパフォーマンスは同じですが、キャッシュではfill(0)ははるかに高速です。

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

_#include <algorithm>
#include <cstdlib>
#include <iostream>
#include <omp.h>
#include <vector>

using value = int;
using vector = std::vector<value>;

constexpr size_t write_size = 8ll * 1024 * 1024 * 1024;
constexpr size_t max_data_size = 4ll * 1024 * 1024 * 1024;

void __attribute__((noinline)) fill0(vector& v) {
    std::fill(v.begin(), v.end(), 0);
}

void __attribute__((noinline)) fill1(vector& v) {
    std::fill(v.begin(), v.end(), 1);
}

void bench(size_t data_size, int nthreads) {
#pragma omp parallel num_threads(nthreads)
    {
        vector v(data_size / (sizeof(value) * nthreads));
        auto repeat = write_size / data_size;
#pragma omp barrier
        auto t0 = omp_get_wtime();
        for (auto r = 0; r < repeat; r++)
            fill0(v);
#pragma omp barrier
        auto t1 = omp_get_wtime();
        for (auto r = 0; r < repeat; r++)
            fill1(v);
#pragma omp barrier
        auto t2 = omp_get_wtime();
#pragma omp master
        std::cout << data_size << ", " << nthreads << ", " << write_size / (t1 - t0) << ", "
                  << write_size / (t2 - t1) << "\n";
    }
}

int main(int argc, const char* argv[]) {
    std::cout << "size,nthreads,fill0,fill1\n";
    for (size_t bytes = 1024; bytes <= max_data_size; bytes *= 2) {
        bench(bytes, 1);
    }
    for (size_t bytes = 1024; bytes <= max_data_size; bytes *= 2) {
        bench(bytes, omp_get_max_threads());
    }
    for (int nthreads = 1; nthreads <= omp_get_max_threads(); nthreads++) {
        bench(max_data_size, nthreads);
    }
}
_

_g++ fillbench.cpp -O3 -o fillbench_gcc -fopenmp_でコンパイルされた結果を提示しました。

65
Zulan

あなたの質問+あなたの答えからコンパイラが生成したasmから:

  • fill(0)ERMSB _rep stosb_ で、最適化されたマイクロコードループで256bストアを使用します。 (バッファが少なくとも32Bまたは64Bに整列している場合に最適です)。
  • fill(1)は、単純な128ビットmovapsベクトルストアループです。最大256b AVXの幅に関係なく、コアクロックサイクルごとに実行できるストアは1つだけです。したがって、128bストアは、HaswellのL1Dキャッシュ書き込み帯域幅の半分しか使用できません。 これが、fill(0)が最大〜32kiBのバッファで約2倍高速である理由です。 _-march=haswell_または_-march=native_でコンパイルして、それを修正します

    Haswellはループオーバーヘッドにかろうじて追いつくことができますが、展開されていなくてもクロックごとに1ストアを実行できます。ただし、クロックごとに4つの融合ドメインuopを使用すると、順序が乱れたウィンドウで多くのフィラーがスペースを占有します。展開によっては、ストアデータよりもストアアドレスuopのスループットが大きいため、ストアが発生している場所よりも先にTLBミスの解決を開始できる場合があります。アンロールは、L1Dに適合するバッファーのERMSBとこのベクトルループの違いの残りを埋めるのに役立つ場合があります。 (質問に対するコメントは、-march=native_はL1のfill(1)のみを助けたと言っています。)

_rep movsd_(int要素のfill(1)の実装に使用できます)は、おそらくHaswellの_rep stosb_と同じことを実行することに注意してください。 ERMSBが高速の_rep stosb_(ただし_rep stosd_ではない)を提供することを保証しているのは公式ドキュメントのみですが、 ERMSBをサポートする実際のCPUは_rep stosd_ に対して同様に効率的なマイクロコードを使用します。 IvyBridgeについては、bのみが高速である可能性があります。この更新については、@ BeeOnRopeの優れた ERMSB回答 を参照してください。

gccには、文字列演算用のx86チューニングオプションがいくつかあります( _-mstringop-strategy=_algおよび_-mmemset-strategy=strategy_ )、ただしIDK fill(1)に対して_rep movsd_を実際に出力するようにします。おそらく、コードはmemsetではなくループとして始まると想定しているため、そうではありません。


複数のスレッドでは、4 GiBデータサイズで、fill(1)はより高い勾配を示しますが、fill(0)よりもはるかに低いピークに達します(51 GiB/s対90 GiB/s):

コールドキャッシュラインへの通常のmovapsストアは、 所有権の読み取り(RFO)をトリガーします。 movapsが最初の16バイトを書き込むときに、メモリからのキャッシュラインの読み取りに多くの実際のDRAM帯域幅が費やされます。 ERMSBストアはストアにno-RFOプロトコルを使用しているため、メモリコントローラーは書き込みのみを行っています。 (L3キャッシュでページウォークが失敗した場合のページテーブルや、割り込みハンドラーなどでのロードミスなど、その他の読み取りを除きます)。

@BeeOnRope コメントで説明 通常のRFOストアとERMSBが使用するRFO回避プロトコルとの違いは、アンコア/ L3キャッシュに高いレイテンシがあるサーバーCPUのバッファサイズの範囲にマイナス面があること。 RFO対非RFO、およびシングルコア帯域幅の問題であるメニーコアIntel CPUのアンコア(L3 /メモリ)の高レイテンシーについては、リンクされたERMSBの回答も参照してください。 。


movntps_mm_stream_ps())storeは弱い順序であるため、キャッシュをバイパスしてメモリに直接アクセスできます。キャッシュラインをL1Dに読み込むことなく、キャッシュライン全体を一度に。 movntpsは、_rep stos_と同様にRFOを回避します。 (_rep stos_ストアは互いに並べ替えることができますが、命令の境界の外ではできません。)

movntpsの結果が更新された回答になっているのは驚くべきことです。
大きなバッファを持つ単一スレッドの場合、結果はmovnt >>通常のRFO> ERMSBになります。したがって、2つの非RFOメソッドが単純な古いストアの反対側にあり、ERMSBが最適とはほど遠いことは本当に奇妙です。現在、その説明はありません。 (説明と良い証拠で編集を歓迎します)。

予想どおり、movntを使用すると、複数のスレッドでERMSBのような高い集約ストア帯域幅を実現できます。 movntは常にラインフィルバッファーに直接進み、次にメモリーに移動するため、キャッシュに収まるバッファーサイズの場合ははるかに遅くなります。クロックあたり1つの128bベクトルは、シングルコアの非RFO帯域幅をDRAMに簡単に飽和させるのに十分です。おそらく_vmovntps ymm_(256b)は、CPUにバインドされたAVX 256bでベクトル化された計算の結果を保存する場合(つまり、アンパックの問題を128bに保存する場合のみ)の_vmovntps xmm_(128b)よりも測定可能な利点です。

movnti帯域幅が低いのは、4Bチャンクでボトルネックを1クロックあたり1ストアuopで保存すると、ラインいっぱいのバッファーをDRAMに追加するのではなく、メモリに帯域幅を飽和させる十分なスレッドがあるためです。


@osgxが投稿されました コメント内の興味深いリンク

x86 タグwikiの他の項目も参照してください。

39
Peter Cordes

予備調査結果を共有し、より詳細な回答を奨励するを希望します。これは質問自体の一部としては多すぎると感じました。

コンパイラーは、内部のmemsetに対して optimizes fill(0)を最適化します。 memsetはバイトに対してのみ機能するため、fill(1)に対して同じことはできません。

具体的には、glibcs​​ ___memset_avx2_と___intel_avx_rep_memset_の両方が単一のホット命令で実装されます。

_rep    stos %al,%es:(%rdi)
_

手動ループが実際の128ビット命令にコンパイルされる場所:

_add    $0x1,%rax                                                                                                       
add    $0x10,%rdx                                                                                                      
movaps %xmm0,-0x10(%rdx)                                                                                               
cmp    %rax,%r8                                                                                                        
ja     400f41
_

興味深いことに、バイト型のmemsetを介して_std::fill_を実装するテンプレート/ヘッダーの最適化がありますが、この場合、実際のループを変換するのはコンパイラーの最適化です。奇妙なことに、_std::vector<char>_の場合、gccはfill(1)も最適化を開始します。インテル®コンパイラーは、memsetテンプレート仕様にもかかわらず、そうではありません。

これは、コードがキャッシュではなくメモリで実際に動作している場合にのみ発生するため、Haswell-EPアーキテクチャが1バイトの書き込みを効率的に統合できないように見えます。

詳細な洞察に感謝します問題と関連するマイクロアーキテクチャの詳細を確認します。特に、4つ以上のスレッドでこれが非常に異なる動作をする理由と、キャッシュでmemsetが非常に高速である理由は明確ではありません。

更新:

以下は、と比較した結果です

  • _-march=native_(avx2 _vmovdq %ymm0_)を使用するfill(1)-L1ではより適切に動作しますが、他のメモリレベルの_movaps %xmm0_バージョンに似ています。
  • 32、128、および256ビットの非一時ストアのバリアント。データサイズに関係なく、同じパフォーマンスで一貫して実行されます。特に少数のスレッドの場合、すべてがメモリ内の他のバリアントよりも優れています。 128ビットと256ビットのパフォーマンスはまったく同じですが、スレッドの数が少ない場合、32ビットのパフォーマンスは大幅に低下します。

<= 6スレッドの場合、vmovntは、メモリ内で操作するときに_rep stos_より2倍の利点があります。

シングルスレッド帯域幅:

single threaded performance by data size

メモリ内の総帯域幅:

memory performance by thread count

以下は、それぞれのホットループで追加のテストに使用されるコードです。

_void __attribute__ ((noinline)) fill1(vector& v) {
    std::fill(v.begin(), v.end(), 1);
}
┌─→add    $0x1,%rax
│  vmovdq %ymm0,(%rdx)
│  add    $0x20,%rdx
│  cmp    %rdi,%rax
└──jb     e0


void __attribute__ ((noinline)) fill1_nt_si32(vector& v) {
    for (auto& elem : v) {
       _mm_stream_si32(&elem, 1);
    }
}
┌─→movnti %ecx,(%rax)
│  add    $0x4,%rax
│  cmp    %rdx,%rax
└──jne    18


void __attribute__ ((noinline)) fill1_nt_si128(vector& v) {
    assert((long)v.data() % 32 == 0); // alignment
    const __m128i buf = _mm_set1_epi32(1);
    size_t i;
    int* data;
    int* end4 = &v[v.size() - (v.size() % 4)];
    int* end = &v[v.size()];
    for (data = v.data(); data < end4; data += 4) {
        _mm_stream_si128((__m128i*)data, buf);
    }
    for (; data < end; data++) {
        *data = 1;
    }
}
┌─→vmovnt %xmm0,(%rdx)
│  add    $0x10,%rdx
│  cmp    %rcx,%rdx
└──jb     40


void __attribute__ ((noinline)) fill1_nt_si256(vector& v) {
    assert((long)v.data() % 32 == 0); // alignment
    const __m256i buf = _mm256_set1_epi32(1);
    size_t i;
    int* data;
    int* end8 = &v[v.size() - (v.size() % 8)];
    int* end = &v[v.size()];
    for (data = v.data(); data < end8; data += 8) {
        _mm256_stream_si256((__m256i*)data, buf);
    }
    for (; data < end; data++) {
        *data = 1;
    }
}
┌─→vmovnt %ymm0,(%rdx)
│  add    $0x20,%rdx
│  cmp    %rcx,%rdx
└──jb     40
_

注:ループを非常にコンパクトにするために、手動のポインター計算を行う必要がありました。そうしないと、おそらくオプティマイザーを混乱させる組み込み関数が原因で、ループ内でベクトルインデックスを作成します。

29
Zulan