Acelerar la aceleración o conocer SIMD, Parte 2 - AVX

La parte anterior provocó una acalorada discusión, durante la cual resultó que AVX / AVX2 está realmente en CPU de escritorio, no solo AVX512. Por lo tanto, continuamos familiarizándonos con SIMD, pero ya con su parte moderna: AVX. Y también analizaremos algunos comentarios:


  • ¿Es _mm256_load_si256 más lento que el acceso directo a la memoria?
  • ¿El uso de comandos AVX sobre registros SSE afecta la velocidad?
  • ¿ _popcnt es realmente tan malo?

Un poco sobre AVX


AVX / AVX2 es una versión más potente de SSE que extiende la mayoría de las operaciones de SSE de 128 bits a 256 bits, además trae una serie de nuevas instrucciones.


De las sutilezas de la implementación, podemos distinguir que en el nivel de ensamblador AVX usa 3 argumentos, lo que permite no destruir datos en los dos primeros. SSE almacena el resultado en uno de los argumentos.


También debe tenerse en cuenta que con el direccionamiento directo, los datos deben estar alineados por 32 bytes, en SSE, alineación por 16.


Versión aumentada del benchmark


Cambios:


  1. El número de elementos se ha incrementado 10,000 veces (hasta 10,240,000), para no encajar en la caché del procesador.
  2. La alineación cambió de 16 bytes a 32 para admitir AVX.
  3. Se agregaron implementaciones AVX similares a SSE.

Código de referencia
 #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(); 

Los nuevos resultados se ven así (-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 

La aceleración total total es más de 9 veces, se espera que AVX sea más rápido que SSE en casi 2 veces.


¿Es _mm256_load_si256 que el acceso directo a la memoria?


No hay una respuesta definitiva. C -O0 más lento que el acceso directo, pero más rápido que _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 más rápido que el acceso directo a la memoria, pero aún se espera que sea más lento _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 

En el código de producción, aún es mejor usar _mm256_load_si256 lugar de acceso directo; el compilador puede optimizar mejor esta opción.


¿El uso de comandos AVX afecta los registros SSE y la velocidad?


La respuesta corta es no . Para el experimento, compilé y ejecuté el punto de referencia con -mavx2 y con -msse4.2 .


-mavx2


_popcnt32(_mm_movemask_epi8(_mm_cmpeq_epi16(...))) convierte en


 vpcmpeqw %xmm1,%xmm0,%xmm0 vpmovmskb %xmm0,%edx popcnt %edx,%edx 

Resultados:


 ------------------------------------------------------------ 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(...))) convierte en


 pcmpeqw %xmm1,%xmm0 pmovmskb %xmm0,%edx popcnt %edx,%edx 

Resultados:


 ------------------------------------------------------------ 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 

bono


Los comandos AVX _popcnt32(_mm256_movemask_epi8(_mm256_cmpeq_epi16(...))) convierten en


 vpcmpeqw %ymm1,%ymm0,%ymm0 vpmovmskb %ymm0,%edx popcnt %edx,%edx 

¿ _popcnt es tan malo?


En uno de los comentarios, Antervis escribió:


Y, sin embargo, ha fallado ligeramente el algoritmo. ¿Por qué hacerlo a través de movemask + popcnt? Para matrices de no más de 2 ^ 18 elementos, primero puede recopilar la suma elemento por elemento:
auto cmp = _mm_cmpeq_epi16 (sseVal, sseArr);
cmp = _mm_and_si128 (cmp, _mm_set1_epi16 (1));
sum = _mm_add_epi16 (sum, cmp);

y luego, al final del ciclo, haga una adición horizontal (sin olvidar el desbordamiento).

Hice un punto de referencia


 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 *) &sum; size_t cnt = 0; for (int j = 0; j < 16; ++j) cnt += arrSum[j]; benchmark::DoNotOptimize(cnt >> 1); } } 

y resultó ser más lento que 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 

y un poco más rápido con -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 

Source: https://habr.com/ru/post/440632/


All Articles