скорость умножения матриц зависит от глупых вещей

Я написал программу для быстрого умножения матриц. Чтобы максимально использовать кеш процессора, он разбивает матрицу на 10*10 ячеек, и умножает каждую ячейку отдельно (увеличение размера ячейки до 20*20 или 50*50 существенно не меняет время).

Оказывается, скорость существенно зависит от того, известны ли заранее размер матрицы и размер ячейки или нет.

Программа:

#include <cmath>
#include <cstdlib>
#include <iostream>

using namespace std;

#define forall(i,n) for(int i=0; i<(int)(n); i++)

inline void Load(int N, int N2, float* x2, float* x, int iStart, int jStart) {
    int start = iStart * N + jStart;
    forall (i, N2)
        forall (j, N2)
            x2[i * N2 + j] = x[start + i * N + j];
}

inline void Add(int N, int N2, float* x, float* x2, int iStart, int jStart) {
    int start = iStart * N + jStart;
    forall (i, N2)
        forall (j, N2)
            x[start + i * N + j] += x2[i * N2 + j];
}

inline void Mul(int N, float* z, float* x, float* y) {
    forall (i, N)
        forall (j, N) {
            double sum = 0;
            forall (k, N)
                sum += x[i*N+k] * y[k*N+j];
            z[i*N+j] = sum;
        }
}

inline double RandReal() {return random()/((double)RAND_MAX+1);}

int main(int argc, char** argv) {
#if VAR==3
    const int N = atoi(argv[1]), N2 = atoi(argv[2]);
#elif VAR==2
    const int N = 2000, N2 = atoi(argv[2]);
#elif VAR==1
    const int N = atoi(argv[1]), N2 = 10;
#elif VAR==0
    const int N = 2000, N2 = 10;
#else
    #error "Bad VAR"
#endif
    cout << "VAR=" << VAR << " N=" << N << " N2=" << N2 << endl;
    float x[N*N], y[N*N];
    forall (i, N)
        forall (j, N) {
            x[i*N+j] = RandReal();
            y[i*N+j] = RandReal();
        }
    float z[N*N];
    forall (i, N)
        forall (j, N)
            z[i*N+j] = 0;
    for (int i1 = 0; i1 < N; i1 += N2) {
        float x2[N2*N2], y2[N2*N2], z2[N2*N2];
        for (int j1 = 0; j1 < N; j1 += N2) {
            Load(N, N2, x2, x, i1, j1);
            for (int k1 = 0; k1 < N; k1 += N2) {
                Load(N, N2, y2, y, j1, k1);
                Mul(N2, z2, x2, y2);
                Add(N, N2, z, z2, i1, k1);
            }
        }
    }

    double val = 0, val2 = 0;
    forall (i, N)
        forall (j, N)
            val += z[i*N+j], val2 += z[i*N+j]*(i+j);
    cout << "val=" << val << " val2=" << val2 << endl;
}

Теперь время выполнения:

$ for a in 0 1 2 3; do g++ -DVAR=$a -O3 -Wall -o mat mat.cpp; time ./mat 2000 10; done
VAR=0 N=2000 N2=10
val=2.00039e+09 val2=3.99867e+12

real    0m8.127s
user    0m8.108s
sys     0m0.020s
VAR=1 N=2000 N2=10
val=2.00039e+09 val2=3.99867e+12

real    0m3.304s
user    0m3.292s
sys     0m0.012s
VAR=2 N=2000 N2=10
val=2.00039e+09 val2=3.99867e+12

real    0m25.395s
user    0m25.388s
sys     0m0.008s
VAR=3 N=2000 N2=10
val=2.00039e+09 val2=3.99867e+12

real    0m25.515s
user    0m25.495s
sys     0m0.016s

Проще говоря:

  • Не знаю размер матрицы, знаю размер ячейки: 3,3 секунды
  • Знать размер матрицы и размер ячейки: 8,1 секунды
  • Не знаю размер ячейки: 25,5 секунд

Почему это? Я использую g++ 5.4.0.

inline не играет никакой роли, если мы его удалим, результаты будут те же.


person user31264    schedule 30.06.2018    source источник
comment
Вы смотрели на разницу в сгенерированной сборке для каждого случая?   -  person Retired Ninja    schedule 30.06.2018
comment
@RetiredNinja - я не знаю ассемблера   -  person user31264    schedule 30.06.2018
comment
Если что-то известно заранее, компилятор может делать с циклами действительно интересные вещи, возможно, даже компилируя цикл. Также стоит отметить, что float z[N*N]; при N=2000. потребуется 16 000 000 байт стека. То, чего у вас, вероятно, не будет, если вы не увеличите размер стека.   -  person user4581301    schedule 30.06.2018
comment
@ user4581301 - самая быстрая версия - это когда компилятор НЕ знает заранее.   -  person user31264    schedule 30.06.2018
comment
Я не могу воспроизвести все ваши результаты. Здесь VAR=0/1 выполняется за 2,7 секунды, VAR=2/3 выполняется за ~7 секунд, поэтому нет никакой разницы, известно ли N или неизвестно.   -  person geza    schedule 30.06.2018
comment
Основное различие между N2 is known/unknown заключается в том, что при известном N2 компилятор помещает сильно развернутые циклы, так что это как бы объясняет разницу   -  person geza    schedule 30.06.2018
comment
@geza какую версию g++ ты используешь?   -  person user31264    schedule 30.06.2018
comment
Пробовал 5.4.1 и 7.2.0, тот же результат   -  person geza    schedule 30.06.2018
comment
Кстати, вы можете немного ускорить это, если будете использовать float равномерно (без doubles), потому что преобразование float->double требует времени   -  person geza    schedule 30.06.2018
comment
Очень возможно, что компилятор делает неверное предположение при оптимизации на основе размера матрицы. Например, развертывание цикла из 10 с небольшим телом, вероятно, полезно, но развертывание цикла из 2000 может быть слишком большим, чтобы поместиться в кэш инструкций. Дело в любом случае спорное. Если вы не выделили программе размер стека около 50 МБ где-то за пределами информации, представленной в вопросе, программа представляет собой большой шар с неопределенным поведением, которому вы не можете доверять, чтобы предоставить хорошие цифры.   -  person user4581301    schedule 30.06.2018
comment
@ user4581301 - я изменил float x[N*N] на float* X=new float[N*N] (аналогично для y и z), ничего не изменилось.   -  person user31264    schedule 01.07.2018
comment
Тогда UB был на вашей стороне, или ваша система настроена на необычно большой стек. Что касается разницы в скорости, я придерживаюсь своего подозрения, что компилятор принимает решение об оптимизации, которое просто не работает на вашем оборудовании. Что это такое, я не знаю. Если вы действительно хотите знать, вам придется профилировать двоих и узнать.   -  person user4581301    schedule 01.07.2018
comment
@geza Провел несколько тестов, мои результаты аналогичны вашим, по крайней мере, на MSVC, поэтому мой ответ был переписан.   -  person Paul Sanders    schedule 02.07.2018
comment
@user4581301 user4581301 Выделение таких огромных объектов в стеке работает для меня, если я увеличиваю ulimit, и тогда я получаю те же тайминги, что и при размещении этих объектов в куче, см. Переписанный ответ.   -  person Paul Sanders    schedule 02.07.2018
comment
@PaulSanders: к сожалению, эти временные показатели во многом зависят от компилятора. С clang я получил очень разные результаты (известный N2 намного хуже, чем у gcc, неизвестный N2 лучше, поэтому разница между известным/неизвестным N2 меньше с clang)   -  person geza    schedule 02.07.2018
comment
@geza Да, я, конечно, ожидаю подробных различий, хотя шаблон во всех трех компиляторах кажется одинаковым (и похоже, что то, что вы видели с clang, похоже на то, что я видел с (Apple's ) лязг). Вы хотите отредактировать свое время в моем ответе? Или опубликуйте свой собственный ответ, только с этими таймингами, для справки? Как я уже сказал, я не могу легко протестировать gcc, к сожалению. Но чего я не понимаю во всем этом, так это того, насколько совершенно странные тайминги у OP. Поскольку никто из нас не видит ничего подобного, мы на самом деле просто предполагаем.   -  person Paul Sanders    schedule 02.07.2018
comment
@PaulSanders: Извините, я потерял интерес к этому вопросу. Здесь мы видим эффект развертывания цикла, который легко вызывает такие большие различия. Что касается того, почему у OP какие-то странные результаты ... не знаю, но трудно понять это, не имея доступа к этой машине.   -  person geza    schedule 02.07.2018
comment
@geza Я как-то потерял интерес к этому вопросу. Да, я тоже.   -  person Paul Sanders    schedule 02.07.2018


Ответы (1)


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

тл;др

  • профиль для поиска горячих петель
  • используйте постоянные счетчики для них, если это возможно
  • попробуйте вручную развернуть их, если нет
  • Результаты ОП загадка, никто другой не может воспроизвести что-то настолько экстремальное.

Я согласен с @user4581301 - чем больше компилятор знает заранее, тем больше он может сделать для вас с точки зрения оптимизации вашего кода.

Но вам нужно профилировать этот код — время на настенных часах далеко не уйдет. Я мало что знаю о профилировщиках для gcc (у ​​меня есть хороший профилировщик для MSVC), но вы можете попытать счастья здесь.

Также полезно (как сразу сказал @RetiredNinja) попытаться изучить ассемблер, используя Godbolt в качестве инструмента. , особенно если вы хотите понять столь резкое замедление.

Теперь, сказав все это, ваши тайминги не имеют для меня никакого смысла, поэтому в вашей системе происходит что-то странное. Поэтому я провел несколько собственных тестов, и мои результаты заметно отличаются от ваших. Я провел некоторые из этих тестов на MSVC (потому что у меня есть такие замечательные инструменты профилирования), а некоторые — на gcc на Mac (хотя я думаю, что на самом деле это тайный лязг под капотом). У меня нет линукс бокса, извините.

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

Итак, теперь давайте посмотрим на тайминги, которые я получил на Mac:

VAR=0 USE_STACK=0 N=2000 (known) N2=10 (known)
user    0m10.813s

VAR=1 USE_STACK=0 N=2000 (unknown) N2=10 (known)
user    0m11.008s

VAR=2 USE_STACK=0 N=2000 (known) N2=10 (unknown)
user    0m12.714s

VAR=3 USE_STACK=0 N=2000 (unknown) N2=10 (unknown)
user    0m12.778s

VAR=0 USE_STACK=1 N=2000 (known) N2=10 (known)
user    0m10.617s

VAR=1 USE_STACK=1 N=2000 (unknown) N2=10 (known)
user    0m10.987s

VAR=2 USE_STACK=1 N=2000 (known) N2=10 (unknown)
user    0m12.653s

VAR=3 USE_STACK=1 N=2000 (unknown) N2=10 (unknown)
user    0m12.673s

Там особо не на что смотреть; давайте перейдем к тому, что я наблюдал на MSVC (где я могу профилировать):

VAR=0 USE_STACK=0 N=2000 (known) N2=10 (known)
Elapsed: 0:00:06.89

VAR=1 USE_STACK=0 N=2000 (unknown) N2=10 (known)
Elapsed: 0:00:06.86

VAR=2 USE_STACK=0 N=2000 (known) N2=10 (unknown)
Elapsed: 0:00:10.24

VAR=3 USE_STACK=0 N=2000 (unknown) N2=10 (unknown)
Elapsed: 0:00:10.39

Теперь у нас есть кое-что, во что мы можем вникнуть. Как заметил @geza, код выполняется дольше, когда N2 неизвестно, что полностью соответствует тому, что можно было бы ожидать, поскольку именно здесь будут горячие циклы, и гораздо более вероятно, что компилятор развернет такой цикл, когда он знает, что на самом деле он состоит из небольшого известного числа итераций.

Итак, давайте получим некоторую информацию от профилировщика. Это говорит мне, что горячий цикл (довольно длинный путь) - это внутренний цикл в Mul():

inline void Mul(int N, float* z, float* x, float* y) {
    forall (i, N)
        forall (j, N) {
            double sum = 0;

=> forall (k, N) => sum += x[i*N+k] * y[kN+j]; z[iN+j] = сумма; } }

Опять же, я не могу сказать, что это меня сильно удивляет, и когда я смотрю на код, я вижу, что цикл вообще не разворачивается (код настройки цикла для краткости опущен):

$1:
    movss       xmm0,dword ptr [r9+rsi*4]  
    mulss       xmm0,dword ptr [r8+4]  
    movss       xmm1,dword ptr [r9+r15*4]  
    mulss       xmm1,dword ptr [r8]  
    cvtps2pd    xmm2,xmm0  
    cvtps2pd    xmm0,xmm1  
    movss       xmm1,dword ptr [r8+8]  
    mulss       xmm1,dword ptr [r9]  
    addsd       xmm0,xmm3  
    addsd       xmm2,xmm0  
    cvtps2pd    xmm0,xmm1  
    movss       xmm1,dword ptr [r9+r14*4]  
    movaps      xmm3,xmm2  
    mulss       xmm1,dword ptr [r8+0Ch]  
    add         r9,rbp  
    add         r8,10h  
    addsd       xmm3,xmm0  
    cvtps2pd    xmm0,xmm1  
    addsd       xmm3,xmm0  
    sub         rcx,1  
    jne         $1

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

    movss       xmm0,dword ptr [rax-8]  
    mulss       xmm0,dword ptr [rcx-50h]  
    cvtps2pd    xmm2,xmm0  
    movss       xmm0,dword ptr [rcx-28h]  
    mulss       xmm0,dword ptr [rax-4]  
    addsd       xmm2,xmm7  
    cvtps2pd    xmm1,xmm0  
    movss       xmm0,dword ptr [rcx]  
    mulss       xmm0,dword ptr [rax]  
    addsd       xmm2,xmm1  
    cvtps2pd    xmm1,xmm0  
    movss       xmm0,dword ptr [rcx+28h]  
    mulss       xmm0,dword ptr [rax+4]  
    addsd       xmm2,xmm1  
    cvtps2pd    xmm1,xmm0  
    movss       xmm0,dword ptr [rcx+50h]  
    mulss       xmm0,dword ptr [rax+8]  
    addsd       xmm2,xmm1  
    cvtps2pd    xmm1,xmm0  
    movss       xmm0,dword ptr [rcx+78h]  
    mulss       xmm0,dword ptr [rax+0Ch]  
    addsd       xmm2,xmm1  
    cvtps2pd    xmm1,xmm0  
    movss       xmm0,dword ptr [rcx+0A0h]  
    mulss       xmm0,dword ptr [rax+10h]  
    addsd       xmm2,xmm1  
    cvtps2pd    xmm1,xmm0  
    movss       xmm0,dword ptr [rcx+0C8h]  
    mulss       xmm0,dword ptr [rax+14h]  
    addsd       xmm2,xmm1  
    cvtps2pd    xmm1,xmm0  
    movss       xmm0,dword ptr [rcx+0F0h]  
    mulss       xmm0,dword ptr [rax+18h]  
    addsd       xmm2,xmm1  
    cvtps2pd    xmm1,xmm0  
    movss       xmm0,dword ptr [rcx+118h]  
    mulss       xmm0,dword ptr [rax+1Ch]  
    addsd       xmm2,xmm1  
    cvtps2pd    xmm1,xmm0  
    addsd       xmm2,xmm1  
    cvtpd2ps    xmm0,xmm2  
    movss       dword ptr [rdx+rcx],xmm0  

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

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

#define UNROLL_LOOP 1

inline void Mul(int N, float* z, float* x, float* y) {
    forall (i, N)
        forall (j, N) {
            double sum = 0;
#if UNROLL_LOOP
            assert (N == 10);
            sum += x[i*N] * y[0*N+j];
            sum += x[i*N+1] * y[1*N+j];
            sum += x[i*N+2] * y[2*N+j];
            sum += x[i*N+3] * y[3*N+j];
            sum += x[i*N+4] * y[4*N+j];
            sum += x[i*N+5] * y[5*N+j];
            sum += x[i*N+6] * y[6*N+j];
            sum += x[i*N+7] * y[7*N+j];
            sum += x[i*N+8] * y[8*N+j];
            sum += x[i*N+9] * y[9*N+j];
#else
            forall (k, N)
                sum += x[i*N+k] * y[k*N+j];
#endif
            z[i*N+j] = sum;
        }
}

И когда я это сделал, я получил:

VAR=3 USE_STACK=0 N=2000 (unknown) N2=10 (unknown)
Elapsed: 0:00:07.48 (compared with 10.39 / 6.86, not bad, more may be possible).

Так что это процесс, который вам нужно пройти, чтобы проанализировать подобные проблемы с производительностью, и вам нужны хорошие инструменты. Я не знаю, что происходит в вашем случае, потому что я не могу воспроизвести проблему, но развертывание цикла (как и ожидалось) является основным фактором в MSVC, когда неизвестно (небольшое) количество циклов.

Код теста, который я использовал, находится здесь на случай, если кто-то захочет сослаться на него. Я думаю, ты должен мне проголосовать, ОП.

Изменить:

Немного поиграл в Wandbox с gcc 9.0.0. Тайминги (они довольно медленные и немного более неточные, так как мы работаем на общей машине или, что более вероятно, на виртуальной машине):

VAR=0 USE_STACK=0 N=2000 (известно) N2=10 (известно) Истекшее время = ~8 секунд

VAR=3 USE_STACK=0 N=2000 (неизвестно) N2=10 (неизвестно) Истекшее время = ~15,5 с

VAR=3 USE_STACK=0 N=2000 (неизвестно) N2=10 (неизвестно), цикл развернут Истекшее время = ~ 13,5 с

Так что это требует дополнительного изучения — с помощью профилировщика и просмотра сгенерированного кода — и все еще в миллионе миль от того, что получает OP.

Живая демонстрация — вы можете сами поиграть с этим, если хотите попробовать разные вещи, OP.

person Paul Sanders    schedule 30.06.2018
comment
Я знал это! Я знал по опыту, что тот, кто не знает, о чем говорит, все равно попытается ответить на вопрос. Насколько мне известно, вы проводите половину своего времени в RandReal(). - то вы НИЧЕГО не знаете, НИЧЕГО повторяю. Все вещи RandReal() - это O (N ^ 2), а умножение - O (N ^ 3). 8000000 вызовов RandReal(), каждый занимает 5-20 наносекунд. Чтобы быть абсолютно уверенным (может быть, вы знаете что-то, чего не знаю я?), я заменил вызовы RandReal() всего на 0,5 , и это не сильно изменило время (по-прежнему 8,1, 3,3 и 25,4 секунды). Понизить. - person user31264; 30.06.2018
comment
Что касается предложений по развертыванию цикла вручную, (1) g++ может выполнять развертывание цикла самостоятельно (2) он упускает суть. Прежде чем заниматься ручной оптимизацией, я хочу знать, что происходит, в этом и был смысл моего вопроса, а не в том, как я могу улучшить скорость путем слепых проб и ошибок. - person user31264; 30.06.2018
comment
Вау, откуда все это это? Я думаю, что вы скорее упустили момент. Профилируйте код. И, может быть, сделать перерыв/поспать. - person Paul Sanders; 30.06.2018
comment
О, да, я чуть не забыл от всего этого волнения. Если вы действительно хотите знать, что компилятор делает с вашим кодом, получение некоторых элементарных знаний об ассемблере было бы очень ценным. Затем - после профилирования - вы можете легко проверить, развернул ли gcc ваши наиболее важные циклы или нет (хорошо, ну, здесь ясно, что ваши тайминги показывают, что это не так), а затем решить для себя что сделать. Никаких слепых проб и ошибок. Вы можете быстро и легко проверить код, сгенерированный компилятором, на странице Godbolt. Все еще думаешь, что я не знаю, что делаю? Честно? - person Paul Sanders; 30.06.2018
comment
[... Проходит время...] Отредактировал свой ответ, я согласен, что его можно было бы немного улучшить. Вам следует подумать о том, чтобы проголосовать повторно - вы выставили себя здесь немного глупо. ... О, ты сделал. Круто, спасибо. Будем надеяться, что мы сможем справиться с тем, что встали не с той ноги. Я ненавижу ссориться с людьми. Мог бы даже проголосовать :) - person Paul Sanders; 30.06.2018
comment
Невозможно эффективно профилировать это в MSVC. Для кода требуются массивы переменной длины, и здесь Visual Studio более строго придерживается стандарта C++ и не допускает их. Замена места в стеке для кучи радикально изменит поведение программы. - person user4581301; 30.06.2018
comment
@ user4581301 Изменение Замена памяти стека на кучу радикально изменит поведение программы. Что заставляет вас так говорить? Это тоже пришло мне в голову, но потом я отбросил это, возможно, ошибочно. Место ссылки? На самом деле я скорее хочу профилировать этот код на MSVC, хотя я не совсем уверен, насколько достоверным будет этот тест, учитывая, что мы ставим под сомнение (во всяком случае, отчасти) качество кода, созданного компилятор. - person Paul Sanders; 30.06.2018
comment
Потому что программа кидает в стек более 48 МБ. Типичный стек *nix составляет ‹ 10 МБ, и в вопросе нет ничего, что предполагало бы его увеличение, поэтому велика вероятность, что программа не работает. - person user4581301; 30.06.2018