Разница в производительности между MSVC и GCC для высоко оптимизированного матричного кода

Я вижу большую разницу в производительности между кодом, скомпилированным в MSVC (в Windows) и GCC (в Linux) для системы Ivy Bridge. Код выполняет плотное умножение матрицы. Я получаю 70% пиковых провалов с GCC и только 50% с MSVC. Я думаю, что, возможно, я выделил разницу между тем, как они преобразуют следующие три свойства.

__m256 breg0 = _mm256_loadu_ps(&b[8*i])
_mm256_add_ps(_mm256_mul_ps(arge0,breg0), tmp0)

GCC делает это

vmovups ymm9, YMMWORD PTR [rax-256]
vmulps  ymm9, ymm0, ymm9
vaddps  ymm8, ymm8, ymm9

MSVC делает это

vmulps   ymm1, ymm2, YMMWORD PTR [rax-256]
vaddps   ymm3, ymm1, ymm3

Может ли кто-нибудь объяснить мне, если и почему эти два решения могут дать такую ​​большую разницу в производительности?

Несмотря на то, что MSVC использует одну меньшую инструкцию, она связывает нагрузку с мультом и, возможно, делает ее более зависимой (возможно, загрузка не может быть выполнена не по порядку)? Я имею в виду, что Ivy Bridge может выполнять одну загрузку AVX, один AVX mult и один AVX добавить за один такт, но для этого требуется, чтобы каждая операция была независимой.

Может быть, проблема кроется в другом месте? Вы можете увидеть полный код сборки для GCC и MSVC для самого внутреннего цикла ниже. Вы можете увидеть код С++ для цикла здесь Loop unrolling для достижения максимальной пропускной способности с помощью Ivy Bridge и Haswell

g++ -S -masm = intel matrix.cpp -O3 -mavx -fopenmp

.L4:
    vbroadcastss    ymm0, DWORD PTR [rcx+rdx*4]
    add rdx, 1
    add rax, 256
    vmovups ymm9, YMMWORD PTR [rax-256]
    vmulps  ymm9, ymm0, ymm9
    vaddps  ymm8, ymm8, ymm9
    vmovups ymm9, YMMWORD PTR [rax-224]
    vmulps  ymm9, ymm0, ymm9
    vaddps  ymm7, ymm7, ymm9
    vmovups ymm9, YMMWORD PTR [rax-192]
    vmulps  ymm9, ymm0, ymm9
    vaddps  ymm6, ymm6, ymm9
    vmovups ymm9, YMMWORD PTR [rax-160]
    vmulps  ymm9, ymm0, ymm9
    vaddps  ymm5, ymm5, ymm9
    vmovups ymm9, YMMWORD PTR [rax-128]
    vmulps  ymm9, ymm0, ymm9
    vaddps  ymm4, ymm4, ymm9
    vmovups ymm9, YMMWORD PTR [rax-96]
    vmulps  ymm9, ymm0, ymm9
    vaddps  ymm3, ymm3, ymm9
    vmovups ymm9, YMMWORD PTR [rax-64]
    vmulps  ymm9, ymm0, ymm9
    vaddps  ymm2, ymm2, ymm9
    vmovups ymm9, YMMWORD PTR [rax-32]
    cmp esi, edx
    vmulps  ymm0, ymm0, ymm9
    vaddps  ymm1, ymm1, ymm0
    jg  .L4

MSVC/FAc/O2/openmp/arch: AVX...

vbroadcastss ymm2, DWORD PTR [r10]    
lea  rax, QWORD PTR [rax+256]
lea  r10, QWORD PTR [r10+4] 
vmulps   ymm1, ymm2, YMMWORD PTR [rax-320]
vaddps   ymm3, ymm1, ymm3    
vmulps   ymm1, ymm2, YMMWORD PTR [rax-288]
vaddps   ymm4, ymm1, ymm4    
vmulps   ymm1, ymm2, YMMWORD PTR [rax-256]
vaddps   ymm5, ymm1, ymm5    
vmulps   ymm1, ymm2, YMMWORD PTR [rax-224]
vaddps   ymm6, ymm1, ymm6    
vmulps   ymm1, ymm2, YMMWORD PTR [rax-192]
vaddps   ymm7, ymm1, ymm7    
vmulps   ymm1, ymm2, YMMWORD PTR [rax-160]
vaddps   ymm8, ymm1, ymm8    
vmulps   ymm1, ymm2, YMMWORD PTR [rax-128]
vaddps   ymm9, ymm1, ymm9    
vmulps   ymm1, ymm2, YMMWORD PTR [rax-96]
vaddps   ymm10, ymm1, ymm10    
dec  rdx
jne  SHORT [email protected]_

EDIT:

Я сравниваю код путем объединения всех операций с плавающей запятой как 2.0*n^3, где n - ширина квадратной матрицы и деление на время, измеренное с помощью omp_get_wtime(). Я повторяю цикл несколько раз. На выходе ниже я повторил его 100 раз.

Выход MSVC2012 на турбине Intel Xeon E5 1620 (Ivy Bridge) для всех ядер 3,7 ГГц

maximum GFLOPS = 236.8 = (8-wide SIMD) * (1 AVX mult + 1 AVX add) * (4 cores) * 3.7 GHz

n   64,     0.02 ms, GFLOPs   0.001, GFLOPs/s   23.88, error 0.000e+000, efficiency/core   40.34%, efficiency  10.08%, mem 0.05 MB
n  128,     0.05 ms, GFLOPs   0.004, GFLOPs/s   84.54, error 0.000e+000, efficiency/core  142.81%, efficiency  35.70%, mem 0.19 MB
n  192,     0.17 ms, GFLOPs   0.014, GFLOPs/s   85.45, error 0.000e+000, efficiency/core  144.34%, efficiency  36.09%, mem 0.42 MB
n  256,     0.29 ms, GFLOPs   0.034, GFLOPs/s  114.48, error 0.000e+000, efficiency/core  193.37%, efficiency  48.34%, mem 0.75 MB
n  320,     0.59 ms, GFLOPs   0.066, GFLOPs/s  110.50, error 0.000e+000, efficiency/core  186.66%, efficiency  46.67%, mem 1.17 MB
n  384,     1.39 ms, GFLOPs   0.113, GFLOPs/s   81.39, error 0.000e+000, efficiency/core  137.48%, efficiency  34.37%, mem 1.69 MB
n  448,     3.27 ms, GFLOPs   0.180, GFLOPs/s   55.01, error 0.000e+000, efficiency/core   92.92%, efficiency  23.23%, mem 2.30 MB
n  512,     3.60 ms, GFLOPs   0.268, GFLOPs/s   74.63, error 0.000e+000, efficiency/core  126.07%, efficiency  31.52%, mem 3.00 MB
n  576,     3.93 ms, GFLOPs   0.382, GFLOPs/s   97.24, error 0.000e+000, efficiency/core  164.26%, efficiency  41.07%, mem 3.80 MB
n  640,     5.21 ms, GFLOPs   0.524, GFLOPs/s  100.60, error 0.000e+000, efficiency/core  169.93%, efficiency  42.48%, mem 4.69 MB
n  704,     6.73 ms, GFLOPs   0.698, GFLOPs/s  103.63, error 0.000e+000, efficiency/core  175.04%, efficiency  43.76%, mem 5.67 MB
n  768,     8.55 ms, GFLOPs   0.906, GFLOPs/s  105.95, error 0.000e+000, efficiency/core  178.98%, efficiency  44.74%, mem 6.75 MB
n  832,    10.89 ms, GFLOPs   1.152, GFLOPs/s  105.76, error 0.000e+000, efficiency/core  178.65%, efficiency  44.66%, mem 7.92 MB
n  896,    13.26 ms, GFLOPs   1.439, GFLOPs/s  108.48, error 0.000e+000, efficiency/core  183.25%, efficiency  45.81%, mem 9.19 MB
n  960,    16.36 ms, GFLOPs   1.769, GFLOPs/s  108.16, error 0.000e+000, efficiency/core  182.70%, efficiency  45.67%, mem 10.55 MB
n 1024,    17.74 ms, GFLOPs   2.147, GFLOPs/s  121.05, error 0.000e+000, efficiency/core  204.47%, efficiency  51.12%, mem 12.00 MB

Ответ 1

Поскольку мы рассмотрели проблему выравнивания, я бы предположил, что это: http://en.wikipedia.org/wiki/Out-of-order_execution

Так как g++ выдает отдельную инструкцию загрузки, ваш процессор может изменить порядок инструкций, чтобы предварительно получить следующие данные, которые понадобятся, а также добавление и умножение. MSVC, бросающий указатель в mul, делает нагрузку и mul привязан к одной и той же инструкции, поэтому изменение порядка выполнения команд ничего не помогает.

РЕДАКТИРОВАТЬ: сервер Intel со всеми документами сегодня менее сердитый, поэтому здесь больше исследований о том, почему выполнение вне порядка (часть) ответа.

Прежде всего, похоже, что ваш комментарий совершенно прав о том, что версия MSVC команды умножения может декодироваться для разделения μ-ops, которые могут быть оптимизированы процессором бездействия. Интересная часть здесь заключается в том, что современные микрокодовые секвенсоры программируются, поэтому фактическое поведение зависит как от аппаратного, так и от прошивки. Различия в сгенерированной сборке, по-видимому, от GCC и MSVC, каждая из которых пытается бороться с различными потенциальными узкими местами. Версия GCC пытается дать возможность выйти из строя (как мы уже рассмотрели). Тем не менее, версия MSVC заканчивается тем, что использует функцию "micro-op fusion". Это связано с ограничениями на выбывание μ-op. Окончание конвейера может только уйти в отставку по 3 μ-ops за галочку. Слияние микроопераций в отдельных случаях принимает два μ-ops, которые должны выполняться на двух разных исполнительных устройствах (например, чтение и арифметика памяти) и связывает их с одним μ-op для большей части конвейера. Сплавленный μ-op разделяется только на два реальных μ-op непосредственно перед назначением исполнительного блока. После выполнения операции ops снова сливаются, позволяя им удаляться как один.

В двигателе без порядка отображается только сплавленный μ-op, поэтому он не может вытащить нагрузку из памяти. Это приводит к зависанию трубопровода, ожидая, когда следующий операнд завершит свою поездку на автобусе.

ВСЕ ССЫЛКИ!!!: http://download-software.intel.com/sites/default/files/managed/71/2e/319433-017.pdf

http://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-optimization-manual.pdf

http://www.agner.org/optimize/microarchitecture.pdf

http://www.agner.org/optimize/optimizing_assembly.pdf

http://www.agner.org/optimize/instruction_tables.ods (ПРИМЕЧАНИЕ: Excel жалуется на то, что эта таблица частично повреждена или в противном случае отрывочна, поэтому открыта на свой страх и риск. Однако она не выглядит злонамеренной, и, согласно остальным моим исследованиям, Agner Fog является удивительным. - На этапе восстановления Excel я нашел множество полных данных)

http://cs.nyu.edu/courses/fall13/CSCI-GA.3033-008/Microprocessor-Report-Sandy-Bridge-Spans-Generations-243901.pdf

http://www.syncfusion.com/Content/downloads/ebook/Assembly_Language_Succinctly.pdf


ПОСЛЕДНЕЕ ПОСЛЕДНЕЕ ИЗМЕНЕНИЕ: Вау, здесь было интересное обновление. Наверное, я ошибался в том, какая часть трубопровода на самом деле влияет на микрооперацию. Может быть, есть больше первичного усиления, чем я ожидал от различий в проверке состояния цикла, когда незадействованные команды позволяют GCC чередовать сравнение и прыгать с последней векторной нагрузкой и арифметическими шагами?

vmovups ymm9, YMMWORD PTR [rax-32]
cmp esi, edx
vmulps  ymm0, ymm0, ymm9
vaddps  ymm1, ymm1, ymm0
jg  .L4

Ответ 2

Я могу подтвердить, что использование кода GCC в Visual Studio действительно улучшает производительность. Я сделал это с помощью преобразования объектного файла GCC в Linux для работы в Visual Studio. Эффективность составляла от 50% до 60%, используя все четыре ядра (и от 60% до 70% для одного ядра).

Microsoft удалила встроенную сборку из 64-битного кода, а также сломала свой 64-разрядный дизассемблер, чтобы код не мог быть похож без изменений (но 32-разрядная версия все еще работает). Очевидно, они считали, что внутренности будут достаточными, но поскольку этот случай показывает, что они ошибаются.

Возможно, скомпонованные инструкции должны быть отдельными функциями?

Но Microsoft не единственный, который производит менее оптимальный внутренний код. Если вы поместите код ниже в http://gcc.godbolt.org/, вы увидите, что делают Clang, ICC и GCC. ICC дал еще худшую производительность, чем MSVC. Использует vinsertf128, но я не знаю почему. Я не уверен, что делает Clang, но похоже, что он ближе к GCC в другом порядке (и больше кода).

Это объясняет, почему Агнер Фог написал в своем руководстве " Оптимизация подпрограмм в сборке язык "в отношении" недостатков использования внутренних функций ":

Компилятор может изменить код или реализовать его в менее эффективном чем программист. Может возникнуть необходимость взглянуть на код, сгенерированный компилятором, чтобы убедиться, что он оптимизирован таким образом программист.

Это разочаровывает случай использования встроенных функций. Это означает, что нужно либо все-таки писать 64-битный ассемблерный код, либо найти компилятор, который реализует внутренности так, как планировал программист. В этом случае только GCC, похоже, делает это (и, возможно, Clang).

#include <immintrin.h>
extern "C" void AddDot4x4_vec_block_8wide(const int n, const float *a, const float *b, float *c, const int stridea, const int strideb, const int stridec) {     
    const int vec_size = 8;
    __m256 tmp0, tmp1, tmp2, tmp3, tmp4, tmp5, tmp6, tmp7;
    tmp0 = _mm256_loadu_ps(&c[0*vec_size]);
    tmp1 = _mm256_loadu_ps(&c[1*vec_size]);
    tmp2 = _mm256_loadu_ps(&c[2*vec_size]);
    tmp3 = _mm256_loadu_ps(&c[3*vec_size]);
    tmp4 = _mm256_loadu_ps(&c[4*vec_size]);
    tmp5 = _mm256_loadu_ps(&c[5*vec_size]);
    tmp6 = _mm256_loadu_ps(&c[6*vec_size]);
    tmp7 = _mm256_loadu_ps(&c[7*vec_size]);

    for(int i=0; i<n; i++) {
        __m256 areg0 = _mm256_set1_ps(a[i]);

        __m256 breg0 = _mm256_loadu_ps(&b[vec_size*(8*i + 0)]);
        tmp0 = _mm256_add_ps(_mm256_mul_ps(areg0,breg0), tmp0);    
        __m256 breg1 = _mm256_loadu_ps(&b[vec_size*(8*i + 1)]);
        tmp1 = _mm256_add_ps(_mm256_mul_ps(areg0,breg1), tmp1);
        __m256 breg2 = _mm256_loadu_ps(&b[vec_size*(8*i + 2)]);
        tmp2 = _mm256_add_ps(_mm256_mul_ps(areg0,breg2), tmp2);    
        __m256 breg3 = _mm256_loadu_ps(&b[vec_size*(8*i + 3)]);
        tmp3 = _mm256_add_ps(_mm256_mul_ps(areg0,breg3), tmp3);   
        __m256 breg4 = _mm256_loadu_ps(&b[vec_size*(8*i + 4)]);
        tmp4 = _mm256_add_ps(_mm256_mul_ps(areg0,breg4), tmp4);    
        __m256 breg5 = _mm256_loadu_ps(&b[vec_size*(8*i + 5)]);
        tmp5 = _mm256_add_ps(_mm256_mul_ps(areg0,breg5), tmp5);    
        __m256 breg6 = _mm256_loadu_ps(&b[vec_size*(8*i + 6)]);
        tmp6 = _mm256_add_ps(_mm256_mul_ps(areg0,breg6), tmp6);    
        __m256 breg7 = _mm256_loadu_ps(&b[vec_size*(8*i + 7)]);
        tmp7 = _mm256_add_ps(_mm256_mul_ps(areg0,breg7), tmp7);    
    }
    _mm256_storeu_ps(&c[0*vec_size], tmp0);
    _mm256_storeu_ps(&c[1*vec_size], tmp1);
    _mm256_storeu_ps(&c[2*vec_size], tmp2);
    _mm256_storeu_ps(&c[3*vec_size], tmp3);
    _mm256_storeu_ps(&c[4*vec_size], tmp4);
    _mm256_storeu_ps(&c[5*vec_size], tmp5);
    _mm256_storeu_ps(&c[6*vec_size], tmp6);
    _mm256_storeu_ps(&c[7*vec_size], tmp7);
}

Ответ 3

MSVC сделал именно то, что вы просили. Если вы хотите, чтобы команда vmovups испускалась, используйте встроенный _mm256_loadu_ps.