Почему наивное умножение матриц C++ в 100 раз медленнее, чем BLAS?

Я изучаю умножение больших матриц и провел следующий эксперимент, чтобы сформировать базовый тест:

  1. Произвольно сгенерируйте две матрицы 4096x4096 X, Y из стандартного нормального (0 среднее значение, 1 стандартное отклонение).
  2. Z = X*Y
  3. Суммируйте элементы Z (чтобы убедиться, что они доступны) и выведите.

Вот наивная реализация C++:

#include <iostream>
#include <algorithm>

using namespace std;

int main()
{
    constexpr size_t dim = 4096;

    float* x = new float[dim*dim];
    float* y = new float[dim*dim];
    float* z = new float[dim*dim];

    random_device rd;
    mt19937 gen(rd());
    normal_distribution<float> dist(0, 1);

    for (size_t i = 0; i < dim*dim; i++)
    {
        x[i] = dist(gen);
        y[i] = dist(gen);
    }

    for (size_t row = 0; row < dim; row++)
        for (size_t col = 0; col < dim; col++)
        {
            float acc = 0;

            for (size_t k = 0; k < dim; k++)
                acc += x[row*dim + k] * y[k*dim + col];

            z[row*dim + col] = acc;
        }

    float t = 0;

    for (size_t i = 0; i < dim*dim; i++)
        t += z[i];

    cout << t << endl;

    delete x;
    delete y;
    delete z;
}

Скомпилируйте и запустите:

$ g++ -std=gnu++11 -O3 test.cpp
$ time ./a.out

Вот реализация Octave/matlab:

X = stdnormal_rnd(4096, 4096);
Y = stdnormal_rnd(4096, 4096);
Z = X*Y;
sum(sum(Z))

Бегать:

$ time octave < test.octave

Octave под капотом использует BLAS (я предполагаю функцию sgemm)

Аппаратное обеспечение — i7 3930X на Linux x86-64 с 24 ГБ оперативной памяти. BLAS использует два ядра. Возможно, гиперпоточная пара?

Я обнаружил, что версия C++, скомпилированная с помощью GCC 4.7 в -O3, выполнялась 9 минут:

real    9m2.126s
user    9m0.302s
sys         0m0.052s

Октавная версия заняла 6 секунд:

real    0m5.985s
user    0m10.881s
sys         0m0.144s

Я понимаю, что BLAS оптимизирован до чертиков, а наивный алгоритм полностью игнорирует кеши и прочее, но серьезно -- 90 раз?

Кто-нибудь может объяснить эту разницу? Какова именно архитектура реализации BLAS? Я вижу, что он использует Фортран, но что происходит на уровне процессора? Какой алгоритм он использует? Как он использует кеши процессора? Какие машинные инструкции x86-64 он вызывает? (Использует ли он расширенные функции ЦП, такие как AVX?) Откуда он получает эту дополнительную скорость?

Какие ключевые оптимизации алгоритма C++ могли бы привести его в соответствие с версией BLAS?

Я запустил октаву под gdb и несколько раз останавливал ее на полпути к вычислению. Он запустил второй поток, и вот стеки (все остановки выглядели одинаково):

(gdb) thread 1
#0  0x00007ffff6e17148 in pthread_join () from /lib/x86_64-linux-gnu/libpthread.so.0
#1  0x00007ffff1626721 in ATL_join_tree () from /usr/lib/libblas.so.3
#2  0x00007ffff1626702 in ATL_join_tree () from /usr/lib/libblas.so.3
#3  0x00007ffff15ae357 in ATL_dptgemm () from /usr/lib/libblas.so.3
#4  0x00007ffff1384b59 in atl_f77wrap_dgemm_ () from /usr/lib/libblas.so.3
#5  0x00007ffff193effa in dgemm_ () from /usr/lib/libblas.so.3
#6  0x00007ffff6049727 in xgemm(Matrix const&, Matrix const&, blas_trans_type, blas_trans_type) () from /usr/lib/x86_64-linux-gnu/liboctave.so.1
#7  0x00007ffff6049954 in operator*(Matrix const&, Matrix const&) () from /usr/lib/x86_64-linux-gnu/liboctave.so.1
#8  0x00007ffff7839e4e in ?? () from /usr/lib/x86_64-linux-gnu/liboctinterp.so.1
#9  0x00007ffff765a93a in do_binary_op(octave_value::binary_op, octave_value const&, octave_value const&) () from /usr/lib/x86_64-linux-gnu/liboctinterp.so.1
#10 0x00007ffff76c4190 in tree_binary_expression::rvalue1(int) () from /usr/lib/x86_64-linux-gnu/liboctinterp.so.1
#11 0x00007ffff76c33a5 in tree_simple_assignment::rvalue1(int) () from /usr/lib/x86_64-linux-gnu/liboctinterp.so.1
#12 0x00007ffff76d0864 in tree_evaluator::visit_statement(tree_statement&) () from /usr/lib/x86_64-linux-gnu/liboctinterp.so.1
#13 0x00007ffff76cffae in tree_evaluator::visit_statement_list(tree_statement_list&) () from /usr/lib/x86_64-linux-gnu/liboctinterp.so.1
#14 0x00007ffff757f6d4 in main_loop() () from /usr/lib/x86_64-linux-gnu/liboctinterp.so.1
#15 0x00007ffff7527abf in octave_main () from /usr/lib/x86_64-linux-gnu/liboctinterp.so.1

(gdb) thread 2
#0  0x00007ffff14ba4df in ATL_dJIK56x56x56TN56x56x0_a1_b1 () from /usr/lib/libblas.so.3
(gdb) bt
#0  0x00007ffff14ba4df in ATL_dJIK56x56x56TN56x56x0_a1_b1 () from /usr/lib/libblas.so.3
#1  0x00007ffff15a5fd7 in ATL_dmmIJK2 () from /usr/lib/libblas.so.3
#2  0x00007ffff15a6ae4 in ATL_dmmIJK () from /usr/lib/libblas.so.3
#3  0x00007ffff1518e65 in ATL_dgemm () from /usr/lib/libblas.so.3
#4  0x00007ffff15adf7a in ATL_dptgemm0 () from /usr/lib/libblas.so.3
#5  0x00007ffff6e15e9a in start_thread () from /lib/x86_64-linux-gnu/libpthread.so.0
#6  0x00007ffff6b41cbd in clone () from /lib/x86_64-linux-gnu/libc.so.6
#7  0x0000000000000000 in ?? ()

Он вызывает BLAS gemm, как и ожидалось.

Похоже, что первый поток присоединяется ко второму, поэтому я не уверен, учитывают ли эти два потока 200% наблюдаемое использование ЦП или нет.

Какой библиотекой является ATL_dgemm libblas.so.3 и где ее код?

$ ls -al /usr/lib/libblas.so.3
/usr/lib/libblas.so.3 -> /etc/alternatives/libblas.so.3

$ ls -al /etc/alternatives/libblas.so.3
/etc/alternatives/libblas.so.3 -> /usr/lib/atlas-base/atlas/libblas.so.3

$ ls -al /usr/lib/atlas-base/atlas/libblas.so.3
/usr/lib/atlas-base/atlas/libblas.so.3 -> libblas.so.3.0

$ ls -al /usr/lib/atlas-base/atlas/libblas.so.3.0
/usr/lib/atlas-base/atlas/libblas.so.3.0

$ dpkg -S /usr/lib/atlas-base/atlas/libblas.so.3.0
libatlas3-base: /usr/lib/atlas-base/atlas/libblas.so.3.0

$ apt-get source libatlas3-base

Это АТЛАС 3.8.4

Вот оптимизации, которые я позже реализовал:

Используя мозаичный подход, когда я предварительно загружаю блоки X, Y и Z размером 64x64 в отдельные массивы.

Изменение расчета каждого блока, чтобы внутренний цикл выглядел так:

for (size_t tcol = 0; tcol < block_width; tcol++)
    bufz[trow][tcol] += B * bufy[tk][tcol];

Это позволяет GCC автоматически векторизовать инструкции SIMD, а также обеспечивает параллелизм на уровне инструкций (я думаю).

Включение march=corei7-avx. Это увеличивает скорость на 30%, но это обман, потому что я думаю, что библиотека BLAS предварительно собрана.

Вот код:

#include <iostream>
#include <algorithm>

using namespace std;

constexpr size_t dim = 4096;
constexpr size_t block_width = 64;
constexpr size_t num_blocks = dim / block_width;

double X[dim][dim], Y[dim][dim], Z[dim][dim];

double bufx[block_width][block_width];
double bufy[block_width][block_width];
double bufz[block_width][block_width];

void calc_block()
{
    for (size_t trow = 0; trow < block_width; trow++)
        for (size_t tk = 0; tk < block_width; tk++)
        {
            double B = bufx[trow][tk];

            for (size_t tcol = 0; tcol < block_width; tcol++)
                bufz[trow][tcol] += B * bufy[tk][tcol];
        }
}

int main()
{
    random_device rd;
    mt19937 gen(rd());
    normal_distribution<double> dist(0, 1);

    for (size_t row = 0; row < dim; row++)
        for (size_t col = 0; col < dim; col++)
        {
            X[row][col] = dist(gen);
            Y[row][col] = dist(gen);
            Z[row][col] = 0;
        }

    for (size_t block_row = 0; block_row < num_blocks; block_row++)
        for (size_t block_col = 0; block_col < num_blocks; block_col++)
        {
            for (size_t trow = 0; trow < block_width; trow++)
                for (size_t tcol = 0; tcol < block_width; tcol++)
                    bufz[trow][tcol] = 0;

            for (size_t block_k = 0; block_k < num_blocks; block_k++)
            {
                for (size_t trow = 0; trow < block_width; trow++)
                    for (size_t tcol = 0; tcol < block_width; tcol++)
                    {
                        bufx[trow][tcol] = X[block_row*block_width + trow][block_k*block_width + tcol];
                        bufy[trow][tcol] = Y[block_k*block_width + trow][block_col*block_width + tcol];
                    }

                calc_block();
            }

            for (size_t trow = 0; trow < block_width; trow++)
                for (size_t tcol = 0; tcol < block_width; tcol++)
                    Z[block_row*block_width + trow][block_col*block_width + tcol] = bufz[trow][tcol];

        }

    double t = 0;

    for (size_t row = 0; row < dim; row++)
        for (size_t col = 0; col < dim; col++)
            t += Z[row][col];

    cout << t << endl;
}

Все действие находится в функции calc_block — на нее уходит более 90% времени.

Новое время:

real    0m17.370s
user    0m17.213s
sys 0m0.092s

Что гораздо ближе.

Декомпиляция функции calc_block выглядит следующим образом:

0000000000401460 <_Z10calc_blockv>:
  401460:   b8 e0 21 60 00          mov    $0x6021e0,%eax
  401465:   41 b8 e0 23 61 00       mov    $0x6123e0,%r8d
  40146b:   31 ff                   xor    %edi,%edi
  40146d:   49 29 c0                sub    %rax,%r8
  401470:   49 8d 34 00             lea    (%r8,%rax,1),%rsi
  401474:   48 89 f9                mov    %rdi,%rcx
  401477:   ba e0 a1 60 00          mov    $0x60a1e0,%edx
  40147c:   48 c1 e1 09             shl    $0x9,%rcx
  401480:   48 81 c1 e0 21 61 00    add    $0x6121e0,%rcx
  401487:   66 0f 1f 84 00 00 00    nopw   0x0(%rax,%rax,1)
  40148e:   00 00 
  401490:   c4 e2 7d 19 01          vbroadcastsd (%rcx),%ymm0
  401495:   48 83 c1 08             add    $0x8,%rcx
  401499:   c5 fd 59 0a             vmulpd (%rdx),%ymm0,%ymm1
  40149d:   c5 f5 58 08             vaddpd (%rax),%ymm1,%ymm1
  4014a1:   c5 fd 29 08             vmovapd %ymm1,(%rax)
  4014a5:   c5 fd 59 4a 20          vmulpd 0x20(%rdx),%ymm0,%ymm1
  4014aa:   c5 f5 58 48 20          vaddpd 0x20(%rax),%ymm1,%ymm1
  4014af:   c5 fd 29 48 20          vmovapd %ymm1,0x20(%rax)
  4014b4:   c5 fd 59 4a 40          vmulpd 0x40(%rdx),%ymm0,%ymm1
  4014b9:   c5 f5 58 48 40          vaddpd 0x40(%rax),%ymm1,%ymm1
  4014be:   c5 fd 29 48 40          vmovapd %ymm1,0x40(%rax)
  4014c3:   c5 fd 59 4a 60          vmulpd 0x60(%rdx),%ymm0,%ymm1
  4014c8:   c5 f5 58 48 60          vaddpd 0x60(%rax),%ymm1,%ymm1
  4014cd:   c5 fd 29 48 60          vmovapd %ymm1,0x60(%rax)
  4014d2:   c5 fd 59 8a 80 00 00    vmulpd 0x80(%rdx),%ymm0,%ymm1
  4014d9:   00 
  4014da:   c5 f5 58 88 80 00 00    vaddpd 0x80(%rax),%ymm1,%ymm1
  4014e1:   00 
  4014e2:   c5 fd 29 88 80 00 00    vmovapd %ymm1,0x80(%rax)
  4014e9:   00 
  4014ea:   c5 fd 59 8a a0 00 00    vmulpd 0xa0(%rdx),%ymm0,%ymm1
  4014f1:   00 
  4014f2:   c5 f5 58 88 a0 00 00    vaddpd 0xa0(%rax),%ymm1,%ymm1
  4014f9:   00 
  4014fa:   c5 fd 29 88 a0 00 00    vmovapd %ymm1,0xa0(%rax)
  401501:   00 
  401502:   c5 fd 59 8a c0 00 00    vmulpd 0xc0(%rdx),%ymm0,%ymm1
  401509:   00 
  40150a:   c5 f5 58 88 c0 00 00    vaddpd 0xc0(%rax),%ymm1,%ymm1
  401511:   00 
  401512:   c5 fd 29 88 c0 00 00    vmovapd %ymm1,0xc0(%rax)
  401519:   00 
  40151a:   c5 fd 59 8a e0 00 00    vmulpd 0xe0(%rdx),%ymm0,%ymm1
  401521:   00 
  401522:   c5 f5 58 88 e0 00 00    vaddpd 0xe0(%rax),%ymm1,%ymm1
  401529:   00 
  40152a:   c5 fd 29 88 e0 00 00    vmovapd %ymm1,0xe0(%rax)
  401531:   00 
  401532:   c5 fd 59 8a 00 01 00    vmulpd 0x100(%rdx),%ymm0,%ymm1
  401539:   00 
  40153a:   c5 f5 58 88 00 01 00    vaddpd 0x100(%rax),%ymm1,%ymm1
  401541:   00 
  401542:   c5 fd 29 88 00 01 00    vmovapd %ymm1,0x100(%rax)
  401549:   00 
  40154a:   c5 fd 59 8a 20 01 00    vmulpd 0x120(%rdx),%ymm0,%ymm1
  401551:   00 
  401552:   c5 f5 58 88 20 01 00    vaddpd 0x120(%rax),%ymm1,%ymm1
  401559:   00 
  40155a:   c5 fd 29 88 20 01 00    vmovapd %ymm1,0x120(%rax)
  401561:   00 
  401562:   c5 fd 59 8a 40 01 00    vmulpd 0x140(%rdx),%ymm0,%ymm1
  401569:   00 
  40156a:   c5 f5 58 88 40 01 00    vaddpd 0x140(%rax),%ymm1,%ymm1
  401571:   00 
  401572:   c5 fd 29 88 40 01 00    vmovapd %ymm1,0x140(%rax)
  401579:   00 
  40157a:   c5 fd 59 8a 60 01 00    vmulpd 0x160(%rdx),%ymm0,%ymm1
  401581:   00 
  401582:   c5 f5 58 88 60 01 00    vaddpd 0x160(%rax),%ymm1,%ymm1
  401589:   00 
  40158a:   c5 fd 29 88 60 01 00    vmovapd %ymm1,0x160(%rax)
  401591:   00 
  401592:   c5 fd 59 8a 80 01 00    vmulpd 0x180(%rdx),%ymm0,%ymm1
  401599:   00 
  40159a:   c5 f5 58 88 80 01 00    vaddpd 0x180(%rax),%ymm1,%ymm1
  4015a1:   00 
  4015a2:   c5 fd 29 88 80 01 00    vmovapd %ymm1,0x180(%rax)
  4015a9:   00 
  4015aa:   c5 fd 59 8a a0 01 00    vmulpd 0x1a0(%rdx),%ymm0,%ymm1
  4015b1:   00 
  4015b2:   c5 f5 58 88 a0 01 00    vaddpd 0x1a0(%rax),%ymm1,%ymm1
  4015b9:   00 
  4015ba:   c5 fd 29 88 a0 01 00    vmovapd %ymm1,0x1a0(%rax)
  4015c1:   00 
  4015c2:   c5 fd 59 8a c0 01 00    vmulpd 0x1c0(%rdx),%ymm0,%ymm1
  4015c9:   00 
  4015ca:   c5 f5 58 88 c0 01 00    vaddpd 0x1c0(%rax),%ymm1,%ymm1
  4015d1:   00 
  4015d2:   c5 fd 29 88 c0 01 00    vmovapd %ymm1,0x1c0(%rax)
  4015d9:   00 
  4015da:   c5 fd 59 82 e0 01 00    vmulpd 0x1e0(%rdx),%ymm0,%ymm0
  4015e1:   00 
  4015e2:   c5 fd 58 80 e0 01 00    vaddpd 0x1e0(%rax),%ymm0,%ymm0
  4015e9:   00 
  4015ea:   48 81 c2 00 02 00 00    add    $0x200,%rdx
  4015f1:   48 39 ce                cmp    %rcx,%rsi
  4015f4:   c5 fd 29 80 e0 01 00    vmovapd %ymm0,0x1e0(%rax)
  4015fb:   00 
  4015fc:   0f 85 8e fe ff ff       jne    401490 <_Z10calc_blockv+0x30>
  401602:   48 83 c7 01             add    $0x1,%rdi
  401606:   48 05 00 02 00 00       add    $0x200,%rax
  40160c:   48 83 ff 40             cmp    $0x40,%rdi
  401610:   0f 85 5a fe ff ff       jne    401470 <_Z10calc_blockv+0x10>
  401616:   c5 f8 77                vzeroupper 
  401619:   c3                      retq   
  40161a:   66 0f 1f 44 00 00       nopw   0x0(%rax,%rax,1)

person Andrew Tomazos    schedule 25.01.2013    source источник
comment
Добро пожаловать в мир тайников. Они могут делать удивительные вещи...   -  person Mysticial    schedule 26.01.2013
comment
150x — это далеко не то ускорение, которое вы могли бы получить за счет лучшего поведения кэширования и других оптимизаций для вашей проблемы.   -  person Carl Norum    schedule 26.01.2013
comment
Вы также рассчитываете время генерации 32 миллионов случайных чисел! Я не знаю, насколько быстры эти ГСЧ, но чтобы получить реальное представление о том, в чем может быть проблема, вам нужно засечь только умножение! Вы можете использовать какой-нибудь низкоуровневый API или boost::auto_cpu_timer, например, для измерения времени в программе.   -  person us2012    schedule 26.01.2013
comment
@ us2012: я уже проверил это, чтобы убедиться, что умножение доминирует над временем в обоих случаях.   -  person Andrew Tomazos    schedule 26.01.2013
comment
acc += x[row*dim + k] * y[k*dim + col]; z[row*dim + col] = acc; ужасно.   -  person Luchian Grigore    schedule 26.01.2013
comment
Какую версию ядра Linux вы используете? Я пытаюсь выяснить, использует ли BLAS инструкции AVX на вашем компьютере.   -  person Mysticial    schedule 26.01.2013
comment
@Мистический: 3.5.0-22-generic Ubuntu 12.10 x86-64   -  person Andrew Tomazos    schedule 26.01.2013
comment
Я считаю, что оба используют только одно ядро. Версия BLAS никоим образом не использует только одно ядро: обратите внимание, что время процессора примерно в два раза больше, чем время настенных часов.   -  person R. Martinho Fernandes    schedule 26.01.2013
comment
@R.MartinhoFernandes: Хорошо, я проверю.   -  person Andrew Tomazos    schedule 26.01.2013
comment
@R.MartinhoFernandes: Вы правы, я думаю, он использует 2 ядра. Top сообщает о 200%-м использовании ЦП, и это соответствует реальной/пользовательской разнице. Это объясняет в 2 раза больше. 45x осталось :)   -  person Andrew Tomazos    schedule 26.01.2013
comment
Из любопытства, можете ли вы попробовать 4097 вместо 4096?   -  person Luchian Grigore    schedule 26.01.2013
comment
@LuchianGrigore Это, вероятно, улучшит время его кода, но определенно не приблизит его к BLAS.   -  person Mysticial    schedule 26.01.2013
comment
@LuchianGrigore: Октава дает: real 0m6.203s user 0m11.337s sys 0m0.124s. Между 0-10% деградации. (Это для 4097 х 4097)   -  person Andrew Tomazos    schedule 26.01.2013
comment
@user1131467 user1131467 версия C++?   -  person Luchian Grigore    schedule 26.01.2013
comment
На самом деле, это не должно иметь значения, потому что вы все равно не используете кеш :)   -  person Luchian Grigore    schedule 26.01.2013
comment
@user1131467 user1131467 Я сейчас подсчитываю цифры. И я на самом деле не впечатлен цифрами BLAS, которые вы получаете. Для 2 потоков это должно быть в два раза быстрее, чем то, что вы видите прямо сейчас. Вы знаете, использует ли BLAS AVX?   -  person Mysticial    schedule 26.01.2013
comment
@LuchianGrigore: он работает, вам придется подождать 10 минут, извините :)   -  person Andrew Tomazos    schedule 26.01.2013
comment
@user1131467 user1131467 Если вам интересно, почему Лучиан попросил вас протестировать 4097, он пытается проверить эффект это.   -  person Mysticial    schedule 26.01.2013
comment
@Mysticial: я подозреваю, но не уверен, что октава использует libblas (см. пакет libblas-dev), и это, кажется, вызывает F77_sgemm, но я не уверен ни в одном из подключений, и я не копался в фортране.   -  person Andrew Tomazos    schedule 26.01.2013
comment
Хорошо, если это поможет: если BLAS не использует инструкции AVX, то ваши тесты показывают, что он достигает 76% от максимально возможной скорости O(N^3) на вашем процессоре (3,8 ГГц, 2 потока). Если он действительно использует AVX, то он достигает только 38% от оптимального. 70%+ типично для хорошо оптимизированной библиотеки плотных матриц. Причина, по которой наивный подход работает медленно, действительно во многом связана с кешем.   -  person Mysticial    schedule 26.01.2013
comment
@LuchianGrigore: 4097x4097 С++: real 9m26.462s user 9m24.495s sys 0m0.084s   -  person Andrew Tomazos    schedule 26.01.2013
comment
Исправление к моему предыдущему комментарию: я вижу, что вы используете floats вместо doubles. Это еще один фактор 2, который я не могу объяснить. Другими словами, BLAS должен быть в 4 раза быстрее, чем вы видите.   -  person Mysticial    schedule 26.01.2013
comment
@Mysticial: Да, я не понимаю, использует ли октава float или double, я решил дать ей преимущество над сомнением.   -  person Andrew Tomazos    schedule 26.01.2013


Ответы (5)


Вот три фактора, ответственные за разницу в производительности между вашим кодом и BLAS (плюс примечание об алгоритме Штрассена).

В вашем внутреннем цикле на k у вас есть y[k*dim + col]. Из-за того, как устроен кэш памяти, последовательные значения k с одинаковыми dim и col сопоставляются с одним и тем же набором кеша. Структура кеша заключается в том, что каждый адрес памяти имеет один набор кешей, в котором должно храниться его содержимое, пока оно находится в кеше. Каждый набор кеша состоит из нескольких строк (обычно четыре), и каждая из этих строк может содержать любой из адресов памяти, соответствующих этому конкретному набору кеша.

Поскольку ваш внутренний цикл повторяет y таким образом, каждый раз, когда он использует элемент из y, он должен загружать память для этого элемента в тот же набор, что и в предыдущей итерации. Это приводит к вытеснению одной из предыдущих строк кэша в наборе. Затем на следующей итерации цикла col все элементы y были удалены из кеша, поэтому их нужно перезагрузить снова.

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

Высокопроизводительный код избегает этого двумя способами. Во-первых, он делит работу на более мелкие блоки. Строки и столбцы разбиваются на части меньшего размера и обрабатываются с помощью более коротких циклов, которые могут использовать все элементы в строке кэша и использовать каждый элемент несколько раз, прежде чем они перейдут к следующему блоку. Таким образом, большинство ссылок на элементы x и элементы y поступают из кэша, часто за один цикл процессора. Во-вторых, в некоторых ситуациях код копирует данные из столбца матрицы (который перегружает кеш из-за геометрии) в строку временного буфера (что позволяет избежать перебрасывания). Это снова позволяет обслуживать большинство ссылок на память из кэша, а не из памяти.

Другим фактором является использование функций Single Instruction Multiple Data (SIMD). Многие современные процессоры имеют инструкции, которые загружают несколько элементов (обычно четыре элемента float, но некоторые теперь делают восемь) в одной инструкции, сохраняют несколько элементов, добавляют несколько элементов (например, для каждого из этих четырех добавьте его к соответствующему одному из этих элементов). четыре), умножить несколько элементов и так далее. Простое использование таких инструкций сразу делает ваш код в четыре раза быстрее, при условии, что вы можете организовать свою работу для использования этих инструкций.

Эти инструкции не доступны напрямую в стандартном C. Некоторые оптимизаторы теперь пытаются использовать такие инструкции, когда могут, но эта оптимизация сложна, и обычно от нее не получают большой пользы. Многие компиляторы предоставляют расширения для языка, предоставляющие доступ к этим инструкциям. Лично я обычно предпочитаю писать на ассемблере, чем использовать SIMD.

Другим фактором является использование в процессоре функций параллельного выполнения на уровне инструкций. Обратите внимание, что в вашем внутреннем цикле обновляется acc. Следующая итерация не может добавляться к acc, пока предыдущая итерация не завершит обновление acc. Вместо этого высокопроизводительный код будет поддерживать параллельное выполнение нескольких сумм (даже нескольких сумм SIMD). Результатом этого будет то, что пока выполняется сложение для одной суммы, будет запущено сложение для другой суммы. Современные процессоры обычно поддерживают четыре или более операций с плавающей запятой, выполняемых одновременно. Как написано, ваш код вообще не может этого сделать. Некоторые компиляторы пытаются оптимизировать код, перестраивая циклы, но для этого компилятор должен видеть, что итерации конкретного цикла независимы друг от друга или могут коммутироваться с другим циклом и так далее.

Вполне возможно, что эффективное использование кэша обеспечивает десятикратное повышение производительности, SIMD — еще четыре, а параллелизм на уровне инструкций — еще четыре, что в сумме дает 160.

Вот очень грубая оценка эффекта алгоритма Штрассена, основанная на этой странице Википедии. На странице Википедии говорится, что Strassen немного лучше, чем прямое умножение при n = 100. Это говорит о том, что отношение постоянных коэффициентов времени выполнения составляет 1003 / 1002,807 ≈ 2,4. . Очевидно, что это будет сильно различаться в зависимости от модели процессора, размеров матрицы, взаимодействующих с эффектами кеша и так далее. Однако простая экстраполяция показывает, что метод Штрассена примерно в два раза лучше прямого умножения при n = 4096 ((4096/100)3-2,807 ≈ 2,05). Опять же, это всего лишь примерная оценка.

Что касается более поздних оптимизаций, рассмотрим этот код во внутреннем цикле:

bufz[trow][tcol] += B * bufy[tk][tcol];

Одна потенциальная проблема заключается в том, что bufz может, как правило, перекрывать bufy. Поскольку вы используете глобальные определения для bufz и bufy, компилятор, вероятно, знает, что в этом случае они не перекрываются. Однако, если вы переместите этот код в подпрограмму, которой передаются bufz и bufy в качестве параметров, и особенно если вы скомпилируете эту подпрограмму в отдельном исходном файле, компилятор с меньшей вероятностью узнает, что bufz и bufy не перекрываются. В этом случае компилятор не может векторизовать или иным образом изменить порядок кода, потому что bufz[trow][tcol] в этой итерации может совпадать с bufy[tk][tcol] в другой итерации.

Даже если компилятор видит, что подпрограмма вызывается с разными bufz и bufy в текущем исходном модуле, если подпрограмма имеет связь extern (по умолчанию), тогда компилятор должен разрешить вызов подпрограммы из внешнего модуля, поэтому он должен генерировать код, который работает правильно, если bufz и bufy перекрываются. (Один из способов, которым компилятор может справиться с этим, — сгенерировать две версии подпрограммы, одну для вызова из внешних модулей, а другую для вызова из компилируемого в данный момент модуля. Будет ли это так, зависит от вашего компилятора, переключателей оптимизации, и так далее.) Если вы объявляете подпрограмму как static, то компилятор знает, что она не может быть вызвана из внешнего модуля (если только вы не возьмете его адрес и есть вероятность, что адрес будет передан за пределы текущего модуля).

Другая потенциальная проблема заключается в том, что даже если компилятор векторизует этот код, он не обязательно генерирует лучший код для процессора, на котором вы выполняете. Глядя на сгенерированный ассемблерный код, кажется, что компилятор многократно использует только %ymm1. Снова и снова он умножает значение из памяти на %ymm1, добавляет значение из памяти к %ymm1 и сохраняет значение из %ymm1 в память. Есть две проблемы с этим.

Во-первых, вы не хотите, чтобы эти частичные суммы часто сохранялись в памяти. Вы хотите, чтобы в регистр накапливалось много дополнений, и регистр будет записываться в память лишь изредка. Чтобы убедить компилятор сделать это, скорее всего, потребуется переписать код, чтобы явно указать на сохранение частичных сумм во временных объектах и ​​запись их в память после завершения цикла.

Во-вторых, эти инструкции номинально последовательно зависимы. Добавление не может начаться до тех пор, пока не завершится умножение, и запись в память не может быть выполнена до тех пор, пока не завершится добавление. Core i7 обладает большими возможностями для неупорядоченного выполнения. Таким образом, пока у него есть это добавление, ожидающее начала выполнения, оно смотрит на умножение позже в потоке команд и запускает его. (Несмотря на то, что это умножение также использует %ymm1, процессор переназначает регистры на лету, так что он использует другой внутренний регистр для этого умножения.) Даже если ваш код заполнен последовательными зависимостями, процессор пытается выполнить несколько инструкций в однажды. Однако ряд вещей может этому помешать. Вы можете исчерпать внутренние регистры, которые процессор использует для переименования. Используемые вами адреса памяти могут привести к ложным конфликтам. (Процессор просматривает дюжину или около того младших битов адресов памяти, чтобы убедиться, что этот адрес совпадает с адресом, который он пытается загрузить или сохранить из более ранней инструкции. Если биты равны, процессор чтобы отложить текущую загрузку или сохранение до тех пор, пока он не сможет проверить, что весь адрес отличается.Эта задержка может привести к большему, чем просто текущая загрузка или сохранение.) Таким образом, лучше иметь инструкции, которые явно независимы.

Это еще одна причина, по которой я предпочитаю писать высокопроизводительный код на ассемблере. Чтобы сделать это на C, вы должны убедить компилятор дать вам подобные инструкции, например, написав свой собственный код SIMD (используя для них языковые расширения) и вручную разворачивая циклы (записывая несколько итераций).

При копировании в буферы и из них могут возникнуть аналогичные проблемы. Тем не менее, вы сообщаете, что 90% времени тратится на calc_block, так что я не смотрел на это внимательно.

person Eric Postpischil    schedule 26.01.2013
comment
Этот ответ хорош тем, что он отвечает на многие плохо понятые части производительности алгоритма, но, как упоминалось в других ответах, наивное умножение намного медленнее, чем алгоритм Штрассена или другой мегабыстрый алгоритм для больших матриц. если я не читаю это неправильно. Эти алгоритмы сильно различаются в зависимости от размера матрицы, поэтому это может даже не иметь значения. - person argentage; 26.01.2013
comment
Да, в моем классе гетерогенного параллельного программирования мы реализовали тайловое умножение матриц с помощью cuda на GPU, что включает в себя оптимизацию под SIMD и использование блочного кеша (рабочей группы в opencl) для загрузки тайла матрицы в кеш и последующей локализации работы. Однако очень удивительно видеть 90-кратное улучшение по сравнению с такими методами в однопоточной среде. Я проведу расследование и постараюсь поддержать ваше утверждение. - person Andrew Tomazos; 26.01.2013
comment
Если мы транспонируем y, это должно дать немедленное улучшение, поскольку теперь мы можем получить к нему доступ в порядке столбцов. - person Andrew Tomazos; 26.01.2013
comment
Отличные очки! Я бы добавил, что для проверки влияния кэш-фактора на производительность y[k*dim + col] можно просто изменить на y[col*dim + k], просто чтобы увидеть, насколько это приближает производительность BLAS. - person Mikael Persson; 26.01.2013
comment
@MikaelPersson: Святое дерьмо. real 1m3.350s user 1m2.972s sys 0m0.148s. 9-кратное улучшение. Впечатляющий. ` - person Andrew Tomazos; 26.01.2013
comment
Подумал, действительно ли BLAS транспонирует одну из матриц. Конечно, вы можете имитировать это улучшение с помощью плитки. - person Andrew Tomazos; 26.01.2013
comment
@ user1131467: Моя сторона тоже улучшилась в 9 раз. И да, тайлинг хранения матрицы будет иметь очень похожий эффект. А для больших матриц большинство матричных алгоритмов могут быть адаптированы к тайлингу и могут извлечь выгоду из него, поэтому очень вероятно, что большие матрицы всегда хранятся таким образом, и точка. Насколько мне известно, все оптимизированные пакеты линейной алгебры используют некоторую форму оптимизированного для кэширования хранилища для больших матриц, иначе он заслужил бы звание оптимизированного. Кстати, Octave использует LAPACK (как и Matlab). - person Mikael Persson; 26.01.2013
comment
@MikaelPersson: Да, и LAPACK использует BLAS, я думаю: en.wikipedia.org/wiki/General_Matrix_Multiply - person Andrew Tomazos; 26.01.2013
comment
@user1131467: Извините, я перепутал с уровнями (LAPACK выше, декомпозиции и линейные решатели), скорее всего, Octave использует ATLAS как BLAS-реализация, хотя вы можете настроить его для использования чего-то другого. - person Mikael Persson; 26.01.2013
comment
@MikaelPersson: я отлажу и декомпилирую в октаву и сообщу, какую библиотеку он использует. - person Andrew Tomazos; 26.01.2013
comment
Я реализовал ваши предложения. См. Обновление 2 в моем ОП. Время теперь 17 секунд, что всего в 2-3 раза медленнее. Если у вас есть другие идеи, почему его еще нет, сообщите мне, спасибо. - person Andrew Tomazos; 26.01.2013
comment
Я не думаю, что ATLAS использует Штрассена. Реверс-инжиниринг ATLAS очень сложен, так как система сборки — это безумное самонастраивающееся чудовище, почти невозможно перейти от символов в .so к исходному коду, не тратя дни на изучение внутренностей. Кроме того, все имена функций используют мнемонику и аббревиатуры. Но из моих различных чтений у меня сложилось впечатление, что Strassen больше не работает на современных процессорах (минимальная выгода), поскольку он, по сути, обменивает умножения на добавления, но его пропускная способность памяти и локальность, которые в наши дни являются узким местом. - person Andrew Tomazos; 27.01.2013
comment
Я думаю, что ATLAS использует плиточный подход, похожий на мой, с эквивалентом calc_block, который они называют ядрами. Система сборки тестирует разные ядра и размеры ядер, а затем компилирует ядро ​​с наилучшей производительностью. Предустановленный, выбранный в Ubuntu, — это ATL_dJIK56x56x56TN56x56x0_a1_b1, как видно из трассировки стека. Это примерно означает JIK, который относится к порядку тройного цикла (inner_k, row, col). 56x56 относится к размеру блока. А в остальном ваши догадки так же хороши, как и мои. - person Andrew Tomazos; 27.01.2013
comment
@ user1131467: если оставить в стороне тонкие технические детали, я думаю, что теперь почти ясно, что полное отсутствие локальности кеша является ответом на ваш вопрос. - person akappa; 27.01.2013
comment
@akappa: Нет кеша - это только около 10x, остальные 10x - это SIMD и ILP, как точно объяснил Эрик. - person Andrew Tomazos; 27.01.2013

Алгоритм Штрассена имеет два преимущества перед наивным алгоритмом:

  1. Лучшая временная сложность с точки зрения количества операций, как правильно указывают другие ответы;
  2. Это алгоритм без кэширования. Разница в количестве промахов кэша порядка B*M½, где B — размер строки кэша, а M — размер кэша.

Я думаю, что второй пункт во многом объясняет замедление, которое вы испытываете. Если вы запускаете свое приложение под Linux, я предлагаю вам запускать его с помощью инструмента perf, который сообщает вам, сколько кэш-промахов испытывает программа.

person akappa    schedule 26.01.2013

Не знаю, насколько надежна эта информация, но Википедия говорит, что BLAS использует алгоритм Штрассена для больших матриц. . А у вас действительно большие. Это около O (n ^ 2,807), что лучше, чем ваш наивный алгоритм O (n ^ 3).

person Csq    schedule 25.01.2013
comment
4096^2.807 = 1.38005e+10, 4096^3 = 6.87195e+10. Кроме того, постоянные накладные расходы Штрассена выше. Это объясняет лишь небольшую часть разницы, которую видит ОП. - person us2012; 26.01.2013
comment
Это будет (4096 ^ 2) ^ 2,807 против (4096 ^ 2) ^ 3. - person Puppy; 26.01.2013
comment
@DeadMG: я думаю, что они используют ширину матрицы, а не размер ввода. Наивный алгоритм, очевидно, O (ширина ^ 3), а не O (ширина ^ 6) - person Andrew Tomazos; 26.01.2013
comment
@us2012 us2012 Да, это должно быть примерно в 5 раз быстрее, если бы константы были одинаковыми. Но они никогда не бывают. Различные алгоритмы также будут генерировать разные возможности оптимизации (конвейер, кеш, выравнивание памяти). - person Csq; 26.01.2013
comment
@Csq Конечно. Я не говорю, что ваш ответ неверен, просто это лишь малая часть общей картины. - person us2012; 26.01.2013
comment
@ us2012 Да, согласен. Спасибо, что указали на ошибку в расчетах! - person Csq; 26.01.2013

Это довольно сложная тема, и Эрик хорошо ответил в посте выше. Я просто хочу указать на полезную ссылку в этом направлении, стр. 84:

http://www.rrze.fau.de/dienste/arbeiten-rehnen/hpc/HPC4SE/

который предлагает сделать «развернуть и зажать петлю» поверх блокировки.

Кто-нибудь может объяснить эту разницу?

Общее объяснение состоит в том, что отношение количества операций/количества данных равно O(N^3)/O(N^2). Таким образом, умножение матрицы на матрицу является алгоритмом с привязкой к кешу, а это означает, что вы не страдаете от общего узкого места пропускной способности памяти для больших размеров матриц. Вы можете получить до 90% пиковой производительности вашего процессора, если код хорошо оптимизирован. Таким образом, потенциал оптимизации, разработанный Эриком, огромен, как вы заметили. На самом деле, было бы очень интересно увидеть код с наилучшей производительностью и скомпилировать окончательную программу с помощью другого компилятора (обычно Intel хвастается тем, что она лучшая).

person OSZ    schedule 28.01.2013

Около половины разницы приходится на улучшение алгоритмов. (4096*4096)^3 — это сложность вашего алгоритма, или 4,7x10^21, а (4096*4096)^2,807 — это 1x10^20. Это разница примерно в 47 раз.

Остальные 2x будут объясняться более разумным использованием кеша, инструкций SSE и другими низкоуровневыми оптимизациями.

Изменить: я лгу, n - это ширина, а не ширина ^ 2. Алгоритм на самом деле будет учитывать только около 4x, так что впереди еще около 22x. Потоки, кеш и инструкции SSE вполне могут объяснить такие вещи.

person Puppy    schedule 26.01.2013
comment
Я думаю, что ваши цифры неверны. Алгоритм nieve ~ 4096 ^ 3. Взгляните на петлю. Это три вложенных цикла for в диапазоне 4096. - person Andrew Tomazos; 26.01.2013
comment
Кроме того, мы не знаем постоянных факторов, которые могут отличаться между наивным и стассеновым (фактически на любую величину), поэтому мы не можем сравнивать при таком разрешении. Однако алгоритм Штрассена заслуживает изучения. - person Andrew Tomazos; 26.01.2013
comment
@user1131467: На странице Википедии говорится, что Штрассен немного лучше прямого умножения при n = 100. Это предполагает, что коэффициент равен 1003 / 1002,807 = 2,4. Очевидно, что это будет сильно различаться в зависимости от модели процессора, размеров матрицы, взаимодействующих с эффектами кеша и так далее. Простая экстраполяция показывает, что метод Штрассена примерно в два раза лучше прямого умножения при n = 4096. - person Eric Postpischil; 26.01.2013
comment
Зависит от того, какие SSE, эффекты кеша и вопросы многопоточности задействованы в современном ЦП. Даже через несколько лет после того, как это было проверено, ответ может измениться. - person Puppy; 26.01.2013
comment
Только что заметил проблему с форматированием в моем старом комментарии. (Изменилась ли интерпретация «**» жирным шрифтом?) Вот обновление: @user1131467: The Википедия page говорит, что Strassen немного лучше, чем прямое умножение при n = 100. Это предполагает, что коэффициент равен 100 ^ 3 / 100 ^ 2,807 = 2,4. Очевидно, что это будет сильно различаться в зависимости от модели процессора, размеров матрицы, взаимодействующих с эффектами кеша и так далее. Простая экстраполяция показывает, что метод Штрассена примерно в два раза лучше прямого умножения при n = 4096. - person Eric Postpischil; 14.11.2017