Ответ 1
была основная ошибка в функции синхронизации, которую я использовал для предыдущих тестов. Это сильно недооценило полосу пропускания без векторизации, а также других измерений. Кроме того, была другая проблема, которая завысила пропускную способность из-за COW в массиве, который был прочитан, но не был записан. Наконец, максимальная используемая пропускная способность была неправильной. Я обновил свой ответ с исправлениями, и я оставил старый ответ в конце этого ответа.
Ваша операция связана с пропускной способностью памяти. Это означает, что процессор тратит большую часть своего времени, ожидая медленного чтения и записи в памяти. Отличное объяснение этого можно найти здесь: Почему векторизация цикла не повышает производительность.
Однако я должен не согласиться с одним утверждением в этом ответе.
Поэтому, независимо от того, как он оптимизирован, (векторизованный, развернутый и т.д.), он не будет намного быстрее.
Фактически, векторизация , разворачивание, и несколько потоков могут значительно увеличить пропускную способность даже в операциях с привязкой к пропускной способности памяти. Причина в том, что трудно получить максимальную пропускную способность памяти. Хорошее объяснение этого можно найти здесь: fooobar.com/questions/9124/....
Остальная часть моего ответа покажет, как векторизация и несколько потоков могут приблизиться к максимальной пропускной способности памяти.
Моя тестовая система: Ubuntu 16.10, Skylake ([email protected]), 32 ГБ оперативной памяти, двухканальный DDR4 @2400 ГГц. Максимальная пропускная способность моей системы составляет 38,4 ГБ/с.
Из приведенного ниже кода я создаю следующие таблицы. Я устанавливаю количество потоков, используя OMP_NUM_THREADS, например. export OMP_NUM_THREADS=4
. Эффективность bandwidth/max_bandwidth
.
-O2 -march=native -fopenmp
Threads Efficiency
1 59.2%
2 76.6%
4 74.3%
8 70.7%
-O2 -march=native -fopenmp -funroll-loops
1 55.8%
2 76.5%
4 72.1%
8 72.2%
-O3 -march=native -fopenmp
1 63.9%
2 74.6%
4 63.9%
8 63.2%
-O3 -march=native -fopenmp -mprefer-avx128
1 67.8%
2 76.0%
4 63.9%
8 63.2%
-O3 -march=native -fopenmp -mprefer-avx128 -funroll-loops
1 68.8%
2 73.9%
4 69.0%
8 66.8%
После нескольких итераций работы из-за неопределенностей в измерениях я сделал следующие выводы:
- однопоточные скалярные операции получают более 50% пропускной способности.
- две потоковые скалярные операции получают максимальную пропускную способность.
- однопоточные векторные операции быстрее, чем однопоточные скалярные операции.
- однопоточные SSE-операции быстрее, чем однопоточные операции AVX.
- развернуть не полезно.
- разворачивание однопоточных операций выполняется медленнее, чем без разворота.
- больше потоков, чем ядер (Hyper-Threading) дает более низкую пропускную способность.
Решение, обеспечивающее наилучшую пропускную способность, представляет собой скалярные операции с двумя потоками.
Код, который я использовал для сравнения:
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <omp.h>
#define N 10000000
#define R 100
void mul(double *a, double *b) {
#pragma omp parallel for
for (int i = 0; i<N; i++) a[i] *= b[i];
}
int main() {
double maxbw = 2.4*2*8; // 2.4GHz * 2-channels * 64-bits * 1-byte/8-bits
double mem = 3*sizeof(double)*N*R*1E-9; // GB
double *a = (double*)malloc(sizeof *a * N);
double *b = (double*)malloc(sizeof *b * N);
//due to copy-on-write b must be initialized to get the correct bandwidth
//also, GCC will convert malloc + memset(0) to calloc so use memset(1)
memset(b, 1, sizeof *b * N);
double dtime = -omp_get_wtime();
for(int i=0; i<R; i++) mul(a,b);
dtime += omp_get_wtime();
printf("%.2f s, %.1f GB/s, %.1f%%\n", dtime, mem/dtime, 100*mem/dtime/maxbw);
free(a), free(b);
}
Старое решение с временной ошибкой
Современное решение для встроенной сборки - это использование встроенных функций. Есть еще случаи, когда нужна встроенная сборка, но это не одна из них.
Одно внутреннее решение для встроенного сборочного подхода просто:
void mul_SSE(double* a, double* b) {
for (int i = 0; i<N/2; i++)
_mm_store_pd(&a[2*i], _mm_mul_pd(_mm_load_pd(&a[2*i]),_mm_load_pd(&b[2*i])));
}
Позвольте мне определить некоторый тестовый код
#include <x86intrin.h>
#include <string.h>
#include <stdio.h>
#include <x86intrin.h>
#include <omp.h>
#define N 1000000
#define R 1000
typedef __attribute__(( aligned(32))) double aligned_double;
void (*fp)(aligned_double *a, aligned_double *b);
void mul(aligned_double* __restrict a, aligned_double* __restrict b) {
for (int i = 0; i<N; i++) a[i] *= b[i];
}
void mul_SSE(double* a, double* b) {
for (int i = 0; i<N/2; i++) _mm_store_pd(&a[2*i], _mm_mul_pd(_mm_load_pd(&a[2*i]),_mm_load_pd(&b[2*i])));
}
void mul_SSE_NT(double* a, double* b) {
for (int i = 0; i<N/2; i++) _mm_stream_pd(&a[2*i], _mm_mul_pd(_mm_load_pd(&a[2*i]),_mm_load_pd(&b[2*i])));
}
void mul_SSE_OMP(double* a, double* b) {
#pragma omp parallel for
for (int i = 0; i<N; i++) a[i] *= b[i];
}
void test(aligned_double *a, aligned_double *b, const char *name) {
double dtime;
const double mem = 3*sizeof(double)*N*R/1024/1024/1024;
const double maxbw = 34.1;
dtime = -omp_get_wtime();
for(int i=0; i<R; i++) fp(a,b);
dtime += omp_get_wtime();
printf("%s \t time %.2f s, %.1f GB/s, efficency %.1f%%\n", name, dtime, mem/dtime, 100*mem/dtime/maxbw);
}
int main() {
double *a = (double*)_mm_malloc(sizeof *a * N, 32);
double *b = (double*)_mm_malloc(sizeof *b * N, 32);
//b must be initialized to get the correct bandwidth!!!
memset(a, 1, sizeof *a * N);
memset(b, 1, sizeof *a * N);
fp = mul, test(a,b, "mul ");
fp = mul_SSE, test(a,b, "mul_SSE ");
fp = mul_SSE_NT, test(a,b, "mul_SSE_NT ");
fp = mul_SSE_OMP, test(a,b, "mul_SSE_OMP");
_mm_free(a), _mm_free(b);
}
Теперь первый тест
g++ -O2 -fopenmp test.cpp
./a.out
mul time 1.67 s, 13.1 GB/s, efficiency 38.5%
mul_SSE time 1.00 s, 21.9 GB/s, efficiency 64.3%
mul_SSE_NT time 1.05 s, 20.9 GB/s, efficiency 61.4%
mul_SSE_OMP time 0.74 s, 29.7 GB/s, efficiency 87.0%
Итак, с -O2
, который не векторизовать циклы, мы видим, что внутренняя версия SSE намного быстрее, чем простое решение C mul
. efficiency = bandwith_measured/max_bandwidth
, где max составляет 34,1 ГБ/с для моей системы.
Второй тест
g++ -O3 -fopenmp test.cpp
./a.out
mul time 1.05 s, 20.9 GB/s, efficiency 61.2%
mul_SSE time 0.99 s, 22.3 GB/s, efficiency 65.3%
mul_SSE_NT time 1.01 s, 21.7 GB/s, efficiency 63.7%
mul_SSE_OMP time 0.68 s, 32.5 GB/s, efficiency 95.2%
С -O3
векторизация цикла, и внутренняя функция не предлагает практически никаких преимуществ.
Третий тест
g++ -O3 -fopenmp -funroll-loops test.cpp
./a.out
mul time 0.85 s, 25.9 GB/s, efficency 76.1%
mul_SSE time 0.84 s, 26.2 GB/s, efficency 76.7%
mul_SSE_NT time 1.06 s, 20.8 GB/s, efficency 61.0%
mul_SSE_OMP time 0.76 s, 29.0 GB/s, efficency 85.0%
С помощью -funroll-loops
GCC разворачивает контуры восемь раз, и мы видим значительное улучшение, за исключением нерезидентного решения магазина, а не реального преимущества для решения OpenMP.
Перед разворачиванием цикла сборка для mul
wiht -O3
равна
xor eax, eax
.L2:
movupd xmm0, XMMWORD PTR [rsi+rax]
mulpd xmm0, XMMWORD PTR [rdi+rax]
movaps XMMWORD PTR [rdi+rax], xmm0
add rax, 16
cmp rax, 8000000
jne .L2
rep ret
С -O3 -funroll-loops
сборка для mul
:
xor eax, eax
.L2:
movupd xmm0, XMMWORD PTR [rsi+rax]
movupd xmm1, XMMWORD PTR [rsi+16+rax]
mulpd xmm0, XMMWORD PTR [rdi+rax]
movupd xmm2, XMMWORD PTR [rsi+32+rax]
mulpd xmm1, XMMWORD PTR [rdi+16+rax]
movupd xmm3, XMMWORD PTR [rsi+48+rax]
mulpd xmm2, XMMWORD PTR [rdi+32+rax]
movupd xmm4, XMMWORD PTR [rsi+64+rax]
mulpd xmm3, XMMWORD PTR [rdi+48+rax]
movupd xmm5, XMMWORD PTR [rsi+80+rax]
mulpd xmm4, XMMWORD PTR [rdi+64+rax]
movupd xmm6, XMMWORD PTR [rsi+96+rax]
mulpd xmm5, XMMWORD PTR [rdi+80+rax]
movupd xmm7, XMMWORD PTR [rsi+112+rax]
mulpd xmm6, XMMWORD PTR [rdi+96+rax]
movaps XMMWORD PTR [rdi+rax], xmm0
mulpd xmm7, XMMWORD PTR [rdi+112+rax]
movaps XMMWORD PTR [rdi+16+rax], xmm1
movaps XMMWORD PTR [rdi+32+rax], xmm2
movaps XMMWORD PTR [rdi+48+rax], xmm3
movaps XMMWORD PTR [rdi+64+rax], xmm4
movaps XMMWORD PTR [rdi+80+rax], xmm5
movaps XMMWORD PTR [rdi+96+rax], xmm6
movaps XMMWORD PTR [rdi+112+rax], xmm7
sub rax, -128
cmp rax, 8000000
jne .L2
rep ret
Четвертый тест
g++ -O3 -fopenmp -mavx test.cpp
./a.out
mul time 0.87 s, 25.3 GB/s, efficiency 74.3%
mul_SSE time 0.88 s, 24.9 GB/s, efficiency 73.0%
mul_SSE_NT time 1.07 s, 20.6 GB/s, efficiency 60.5%
mul_SSE_OMP time 0.76 s, 29.0 GB/s, efficiency 85.2%
Теперь неотрицательная функция является самой быстрой (исключая версию OpenMP).
Таким образом, в этом случае нет причин использовать встроенные или встроенные сборки, потому что мы можем получить лучшую производительность с соответствующими параметрами компилятора (например, -O3
, -funroll-loops
, -mavx
).
Система тестирования: Ubuntu 16.10, Skylake ([email protected]), оперативная память 32 ГБ. Максимальная пропускная способность памяти (34,1 ГБ/с) https://ark.intel.com/products/88967/Intel-Core-i7-6700HQ-Processor-6M-Cache-up-to-3_50-GHz
Вот еще одно решение, заслуживающее рассмотрения. Инструкция cmp
не нужна, если мы рассчитываем от -N до нуля и получаем доступ к массивам как N+i
. GCC должен был зафиксировать это давным-давно. Он устраняет одну инструкцию (хотя из-за макро-op слияния cmp и jmp часто считаются одним микрооператором).
void mul_SSE_v2(double* a, double* b) {
for (ptrdiff_t i = -N; i<0; i+=2)
_mm_store_pd(&a[N + i], _mm_mul_pd(_mm_load_pd(&a[N + i]),_mm_load_pd(&b[N + i])));
Сборка с -O3
mul_SSE_v2(double*, double*):
mov rax, -1000000
.L9:
movapd xmm0, XMMWORD PTR [rdi+8000000+rax*8]
mulpd xmm0, XMMWORD PTR [rsi+8000000+rax*8]
movaps XMMWORD PTR [rdi+8000000+rax*8], xmm0
add rax, 2
jne .L9
rep ret
}
Эта оптимизация может быть полезна только при использовании массивов, например. кэш L1, то есть не считывающий из основной памяти.
Наконец-то я нашел способ получить решение простой C, чтобы не сгенерировать инструкцию cmp
.
void mul_v2(aligned_double* __restrict a, aligned_double* __restrict b) {
for (int i = -N; i<0; i++) a[i] *= b[i];
}
И затем вызовите функцию из отдельного объектного файла, такого как mul_v2(&a[N],&b[N])
, поэтому это, пожалуй, лучшее решение. Однако, если вы вызываете функцию из того же объектного файла (единицы перевода), как тот, который он определил в GCC, снова генерирует команду cmp
.
Кроме того,
void mul_v3(aligned_double* __restrict a, aligned_double* __restrict b) {
for (int i = -N; i<0; i++) a[N+i] *= b[N+i];
}
все еще генерирует инструкцию cmp
и генерирует ту же самую сборку, что и функция mul
.
Функция mul_SSE_NT
глупа. Он использует не временные хранилища, которые полезны только при записи в память, но поскольку функция читает и записывает в один и тот же адрес, невременные хранилища не только бесполезны, они дают более низкие результаты.
Предыдущие версии этого ответа получили неправильную пропускную способность. Причина была в том, что массивы не были инициализированы.