Der vorherige Teil löste eine hitzige Diskussion aus, bei der sich herausstellte, dass AVX / AVX2 tatsächlich in Desktop-CPUs vorhanden ist, nicht nur in AVX512. Daher lernen wir SIMD weiterhin kennen, aber bereits mit seinem modernen Teil - AVX. Und wir werden auch einige Kommentare analysieren:
- Ist
_mm256_load_si256
langsamer als der direkte Speicherzugriff? - Beeinflusst die Verwendung von AVX-Befehlen über SSE-Register die Geschwindigkeit?
- ist
_popcnt
wirklich so schlimm?
Ein bisschen über AVX
AVX / AVX2 ist eine leistungsstärkere Version von SSE, die die meisten 128-Bit-SSE-Operationen auf 256 Bit erweitert und eine Reihe neuer Anweisungen enthält.
Von den Feinheiten der Implementierung können wir unterscheiden, dass AVX auf Assembler-Ebene 3 Argumente verwendet, mit denen Daten in den ersten beiden nicht zerstört werden können. SSE speichert das Ergebnis in einem der Argumente.
Es sollte auch berücksichtigt werden, dass bei direkter Adressierung die Daten um 32 Bytes ausgerichtet sein müssen, in SSE um 16 Bytes.
Erweiterte Version des Benchmarks
Änderungen:
- Die Anzahl der Elemente wurde 10.000-mal erhöht (bis zu 10.240.000), um nicht in den Prozessor-Cache zu passen.
- Die Ausrichtung wurde von 16 Byte auf 32 Byte geändert, um AVX zu unterstützen.
- AVX-Implementierungen ähnlich wie bei SSE hinzugefügt.
Benchmark-Code #include <benchmark/benchmark.h> #include <x86intrin.h> #include <cstring> #define ARR_SIZE 10240000 #define VAL 50 static int16_t *getRandArr() { auto res = new int16_t[ARR_SIZE]; for (int i = 0; i < ARR_SIZE; ++i) { res[i] = static_cast<int16_t>(rand() % (VAL * 2)); } return res; } static auto arr = getRandArr(); static int16_t *getAllignedArr() { auto res = aligned_alloc(32, sizeof(int16_t) * ARR_SIZE); memcpy(res, arr, sizeof(int16_t) * ARR_SIZE); return static_cast<int16_t *>(res); } static auto allignedArr = getAllignedArr(); static void BM_Count(benchmark::State &state) { for (auto _ : state) { int64_t cnt = 0; for (int i = 0; i < ARR_SIZE; ++i) if (arr[i] == VAL) ++cnt; benchmark::DoNotOptimize(cnt); } } BENCHMARK(BM_Count); static void BM_SSE_COUNT_SET_EPI(benchmark::State &state) { for (auto _ : state) { int64_t cnt = 0; auto sseVal = _mm_set1_epi16(VAL); for (int i = 0; i < ARR_SIZE; i += 8) { cnt += _popcnt32( _mm_movemask_epi8( _mm_cmpeq_epi16( sseVal, _mm_set_epi16(arr[i + 7], arr[i + 6], arr[i + 5], arr[i + 4], arr[i + 3], arr[i + 2], arr[i + 1], arr[i]) ) ) ); } benchmark::DoNotOptimize(cnt >> 1); } } BENCHMARK(BM_SSE_COUNT_SET_EPI); static void BM_SSE_COUNT_LOADU(benchmark::State &state) { for (auto _ : state) { int64_t cnt = 0; auto sseVal = _mm_set1_epi16(VAL); for (int i = 0; i < ARR_SIZE; i += 8) { cnt += _popcnt32( _mm_movemask_epi8( _mm_cmpeq_epi16( sseVal, _mm_loadu_si128((__m128i *) &arr[i]) ) ) ); } benchmark::DoNotOptimize(cnt >> 1); } } BENCHMARK(BM_SSE_COUNT_LOADU); static void BM_SSE_COUNT_DIRECT(benchmark::State &state) { for (auto _ : state) { int64_t cnt = 0; auto sseVal = _mm_set1_epi16(VAL); for (int i = 0; i < ARR_SIZE; i += 8) { cnt += _popcnt32( _mm_movemask_epi8( _mm_cmpeq_epi16( sseVal, *(__m128i *) &allignedArr[i] ) ) ); } benchmark::DoNotOptimize(cnt >> 1); } } BENCHMARK(BM_SSE_COUNT_DIRECT); #ifdef __AVX2__ static void BM_AVX_COUNT_LOADU(benchmark::State &state) { for (auto _ : state) { int64_t cnt = 0; auto avxVal = _mm256_set1_epi16(VAL); for (int i = 0; i < ARR_SIZE; i += 16) { cnt += _popcnt32( _mm256_movemask_epi8( _mm256_cmpeq_epi16( avxVal, _mm256_loadu_si256((__m256i *) &arr[i]) ) ) ); } benchmark::DoNotOptimize(cnt >> 1); } } BENCHMARK(BM_AVX_COUNT_LOADU); static void BM_AVX_COUNT_LOAD(benchmark::State &state) { for (auto _ : state) { int64_t cnt = 0; auto avxVal = _mm256_set1_epi16(VAL); for (int i = 0; i < ARR_SIZE; i += 16) { cnt += _popcnt32( _mm256_movemask_epi8( _mm256_cmpeq_epi16(avxVal, _mm256_load_si256((__m256i *) &allignedArr[i]) ) ) ); } benchmark::DoNotOptimize(cnt >> 1); } } BENCHMARK(BM_AVX_COUNT_LOAD); static void BM_AVX_COUNT_DIRECT(benchmark::State &state) { for (auto _ : state) { int64_t cnt = 0; auto avxVal = _mm256_set1_epi16(VAL); for (int i = 0; i < ARR_SIZE; i += 16) { cnt += _popcnt32( _mm256_movemask_epi8( _mm256_cmpeq_epi16( avxVal, *(__m256i *) &allignedArr[i] ) ) ); } benchmark::DoNotOptimize(cnt >> 1); } } BENCHMARK(BM_AVX_COUNT_DIRECT); #endif BENCHMARK_MAIN();
Die neuen Ergebnisse sehen folgendermaßen aus (-O0):
--------------------------------------------------------------------- Benchmark Time CPU Iterations --------------------------------------------------------------------- BM_Count 17226622 ns 17062958 ns 41 BM_SSE_COUNT_SET_EPI 8901343 ns 8814845 ns 79 BM_SSE_COUNT_LOADU 3664778 ns 3664766 ns 185 BM_SSE_COUNT_DIRECT 3468436 ns 3468423 ns 202 BM_AVX_COUNT_LOADU 2090817 ns 2090796 ns 343 BM_AVX_COUNT_LOAD 1904424 ns 1904419 ns 364 BM_AVX_COUNT_DIRECT 1814875 ns 1814854 ns 385
Die Gesamtbeschleunigung beträgt mehr als das 9-fache. AVX wird voraussichtlich fast 2-mal schneller als SSE sein.
Ist _mm256_load_si256
als der direkte Speicherzugriff?
Es gibt keine eindeutige Antwort. C- -O0
langsamer als der direkte Zugriff, aber schneller als _mm256_loadu_si256
:
--------------------------------------------------------------------- Benchmark Time CPU Iterations --------------------------------------------------------------------- BM_AVX_COUNT_LOADU 2090817 ns 2090796 ns 343 BM_AVX_COUNT_LOAD 1904424 ns 1904419 ns 364 BM_AVX_COUNT_DIRECT 1814875 ns 1814854 ns 385
C- -O3
schneller als der direkte Speicherzugriff, aber voraussichtlich langsamer _mm256_loadu_si256
.
--------------------------------------------------------------------- Benchmark Time CPU Iterations --------------------------------------------------------------------- BM_AVX_COUNT_LOADU 992319 ns 992368 ns 701 BM_AVX_COUNT_LOAD 956120 ns 956166 ns 712 BM_AVX_COUNT_DIRECT 1027624 ns 1027674 ns 730
Im Produktionscode ist es immer noch besser, _mm256_load_si256
anstelle des direkten Zugriffs zu verwenden. Der Compiler kann diese Option besser optimieren.
Beeinflusst die Verwendung von AVX-Befehlen die Geschwindigkeit von SSE-Registern?
Die kurze Antwort lautet nein . Für das Experiment habe ich den Benchmark mit -mavx2
und -msse4.2
kompiliert und -msse4.2
.
-mavx2
_popcnt32(_mm_movemask_epi8(_mm_cmpeq_epi16(...)))
wird zu
vpcmpeqw %xmm1,%xmm0,%xmm0 vpmovmskb %xmm0,%edx popcnt %edx,%edx
Ergebnisse:
------------------------------------------------------------ Benchmark Time CPU Iterations ------------------------------------------------------------ BM_SSE_COUNT_SET_EPI 9376699 ns 9376767 ns 75 BM_SSE_COUNT_LOADU 4425510 ns 4425560 ns 159 BM_SSE_COUNT_DIRECT 3938604 ns 3938648 ns 177
-msse4.2
_popcnt32(_mm_movemask_epi8(_mm_cmpeq_epi16(...)))
wird zu
pcmpeqw %xmm1,%xmm0 pmovmskb %xmm0,%edx popcnt %edx,%edx
Ergebnisse:
------------------------------------------------------------ Benchmark Time CPU Iterations ------------------------------------------------------------ BM_SSE_COUNT_SET_EPI 9309352 ns 9309375 ns 76 BM_SSE_COUNT_LOADU 4382183 ns 4382195 ns 159 BM_SSE_COUNT_DIRECT 3944579 ns 3944590 ns 176
Bonus
AVX-Befehle _popcnt32(_mm256_movemask_epi8(_mm256_cmpeq_epi16(...)))
werden zu
vpcmpeqw %ymm1,%ymm0,%ymm0 vpmovmskb %ymm0,%edx popcnt %edx,%edx
Ist _popcnt
so schlecht?
In einem der Kommentare schrieb Antervis :
Und doch haben Sie den Algorithmus leicht fehlerhaft gemacht. Warum über movemask + popcnt? Für Arrays mit nicht mehr als 2 ^ 18 Elementen können Sie zuerst die Element-für-Element-Summe erfassen:
auto cmp = _mm_cmpeq_epi16 (sseVal, sseArr);
cmp = _mm_and_si128 (cmp, _mm_set1_epi16 (1));
sum = _mm_add_epi16 (sum, cmp);
und dann am Ende des Zyklus eine horizontale Addition vornehmen (ohne den Überlauf zu vergessen).
Ich habe einen Benchmark gemacht
static void BM_AVX_COUNT_DIRECT_WO_POPCNT(benchmark::State &state) { auto avxVal1 = _mm256_set1_epi16(1); for (auto _ : state) { auto sum = _mm256_set1_epi16(0); auto avxVal = _mm256_set1_epi16(VAL); for (int i = 0; i < ARR_SIZE; i += 16) { sum = _mm256_add_epi16( sum, _mm256_and_si256( avxVal1, _mm256_cmpeq_epi16( avxVal, *(__m256i *) &allignedArr[i]) ) ); } auto arrSum = (uint16_t *) ∑ size_t cnt = 0; for (int j = 0; j < 16; ++j) cnt += arrSum[j]; benchmark::DoNotOptimize(cnt >> 1); } }
und es stellte sich heraus, dass es langsamer als c- -O0
:
--------------------------------------------------------------------- Benchmark Time CPU Iterations --------------------------------------------------------------------- BM_AVX_COUNT_DIRECT 1814821 ns 1814785 ns 392 BM_AVX_COUNT_DIRECT_WO_POPCNT 2386289 ns 2386227 ns 287
und etwas schneller mit -O3
:
--------------------------------------------------------------------- Benchmark Time CPU Iterations --------------------------------------------------------------------- BM_AVX_COUNT_DIRECT 960941 ns 960924 ns 722 BM_AVX_COUNT_DIRECT_WO_POPCNT 948611 ns 948596 ns 732