システムでは、定数値_std::fill
_または動的な値と比較して、定数値_std::vector<int>
_を設定する場合、大きな_0
_の_1
_が大幅かつ一貫して遅いことがわかりました。
5.8 GiB/s対7.5 GiB/s
ただし、fill(0)
の方が速いデータサイズの場合、結果は異なります。
複数のスレッドでは、4 GiBデータサイズで、fill(1)
はより高い勾配を示しますが、fill(0)
(51 GiB/s対90 GiB/s):
これにより、なぜ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
_でコンパイルされた結果を提示しました。
あなたの質問+あなたの答えからコンパイラが生成した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が投稿されました コメント内の興味深いリンク :
Intel最適化ガイド: http://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-optimization-manual.pdf 。
NUMAスヌーピング: http://frankdenneman.nl/2016/07/11/numa-deep-dive-part-3-cache-coherency/
x86 タグwikiの他の項目も参照してください。
予備調査結果を共有し、より詳細な回答を奨励するを希望します。これは質問自体の一部としては多すぎると感じました。
コンパイラーは、内部の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
_バージョンに似ています。<= 6スレッドの場合、vmovnt
は、メモリ内で操作するときに_rep stos
_より2倍の利点があります。
シングルスレッド帯域幅:
メモリ内の総帯域幅:
以下は、それぞれのホットループで追加のテストに使用されるコードです。
_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
_
注:ループを非常にコンパクトにするために、手動のポインター計算を行う必要がありました。そうしないと、おそらくオプティマイザーを混乱させる組み込み関数が原因で、ループ内でベクトルインデックスを作成します。