Почему порядок циклов в алгоритме умножения матрицы влияет на производительность? [дубликат]

этот вопрос уже есть ответ здесь:

мне даны две функции для нахождения произведения двух матриц:

 void MultiplyMatrices_1(int **a, int **b, int **c, int n){
      for (int i = 0; i < n; i++)
          for (int j = 0; j < n; j++)
              for (int k = 0; k < n; k++)
                  c[i][j] = c[i][j] + a[i][k]*b[k][j];
  }

 void MultiplyMatrices_2(int **a, int **b, int **c, int n){
      for (int i = 0; i < n; i++)
          for (int k = 0; k < n; k++)
              for (int j = 0; j < n; j++)
                  c[i][j] = c[i][j] + a[i][k]*b[k][j];
 }

Я запустил и профилировал два исполняемых файла, используя gprof, каждый с одинаковым кодом за исключением этой функции. Второй из них значительно (примерно в 5 раз) быстрее для матриц размером 2048 х 2048. Есть идеи, почему?

4 ответов


я считаю, что то, что вы смотрите на последствия расположения ссылок в иерархии памяти компьютера.

как правило, память компьютера разделена на разные типы, которые имеют разные характеристики производительности (это часто называют иерархия памяти). Самая быстрая память находится в регистрах процессора, к которым можно (обычно) получить доступ и прочитать за один такт. Тем не менее, обычно существует только несколько таких регистров (обычно не более 1 КБ). С другой стороны, основная память компьютера огромна (скажем, 8GB), но гораздо медленнее для доступа. Для повышения производительности компьютер обычно физически сконструирован так, чтобы иметь несколько уровней кэшей между процессором и основной памятью. Эти кэши медленнее, чем регистры, но намного быстрее, чем основная память, поэтому, если вы делаете доступ к памяти, который ищет что-то в кэш, как правило, намного быстрее, чем если вам нужно перейти в основную память (как правило, между 5-25x быстрее). При доступе к памяти процессор сначала проверяет кэш памяти на это значение, прежде чем вернуться в основную память для чтения значения. Если вы постоянно обращаетесь к значениям в кэше, вы получите гораздо лучшую производительность, чем если вы пропускаете память, случайным образом получая доступ к значениям.

большинство программ написаны таким образом, что если один байт в памяти читать в память, программа считывает несколько разных значений вокруг этой области памяти, а также. Следовательно, эти кэши обычно разработаны таким образом, что при чтении одного значения из памяти блок памяти (обычно где-то между 1 КБ и 1 МБ) значений вокруг этого одного значения также втягивается в кэш. Таким образом, если ваша программа считывает близлежащие значения, они уже находятся в кэше, и вам не нужно переходить в основную память.

теперь последняя деталь - в C/C++ массивы хранятся в порядке строк, что означает, что все значения в одной строке матрицы хранятся рядом друг с другом. Таким образом, в памяти массив выглядит как первая строка, затем вторая строка, затем третья строка и т. д.

учитывая это, давайте посмотрим на ваш код. Первая версия выглядит так:

  for (int i = 0; i < n; i++)
      for (int j = 0; j < n; j++)
          for (int k = 0; k < n; k++)
              c[i][j] = c[i][j] + a[i][k]*b[k][j];

теперь давайте посмотрим на эту самую внутреннюю строку кода. На каждой итерации значение k изменяется с увеличением. Это означает, что при запуске самый внутренний цикл, каждая итерация цикла, вероятно, будет иметь пропуск кэша при загрузке значения b[k][j]. Причина этого в том, что, поскольку матрица хранится в порядке строк, каждый раз, когда вы увеличиваете k, вы пропускаете всю строку матрицы и прыгаете гораздо дальше в память, возможно, далеко за пределы значений, которые вы кэшировали. Однако у вас нет промаха, когда вы смотрите вверх c[i][j] (поскольку i и j то же самое), и вы, вероятно, не пропустите a[i][k], потому что значения по строкам и если значение a[i][k] кэшируется с предыдущей итерации, значение a[i][k] чтение на этой итерации происходит из соседнего расположения памяти. Следовательно, на каждой итерации внутреннего цикла у вас, вероятно, будет один промах кэша.

но рассмотрим эту вторую версию:

  for (int i = 0; i < n; i++)
      for (int k = 0; k < n; k++)
          for (int j = 0; j < n; j++)
              c[i][j] = c[i][j] + a[i][k]*b[k][j];

теперь, так как вы увеличиваете j на каждой итерации давайте подумаем о том, сколько пропусков кэша вы, вероятно, будете иметь на самом внутреннем заявление. Поскольку значения по строкам, стоимостью c[i][j] вероятно, будет в кэше, потому что значение c[i][j] из предыдущей итерации, вероятно, также кэшируется и готов к чтению. Аналогично,b[k][j] вероятно, кэшируется, и так как i и k не меняются, шансы a[i][k] также кэшируется. Это означает, что на каждой итерации внутреннего цикла у вас, вероятно, не будет пропусков кэша.

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

интересно, что многие компиляторы начинают иметь поддержку прототипов для обнаружения того, что вторая версия кода быстрее первой. Некоторые попытаются автоматически переписать код, чтобы максимизировать параллелизм. Если у вас есть копия Фиолетовый Дракон Книга Глава 11 обсуждает, как эти компиляторы работают.

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

надеюсь, что это помогает!


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

способ проверить эту гипотезу-запустить отладчик кэша (например,cachegrind) на двух частях кода и посмотреть, сколько пропусков кэша они несут.


вероятно, второй должен пропустить в памяти больше, чтобы получить доступ к элементам массива. Это может быть что-то еще-вы можете проверить скомпилированный код, чтобы увидеть, что на самом деле происходит.


помимо локальности памяти Существует также оптимизация компилятора. Ключевым для векторных и матричных операций является развертывание цикла.

for (int k = 0; k < n; k++)
   c[i][j] = c[i][j] + a[i][k]*b[k][j];

вы можете видеть в этом внутреннем цикле i и j не меняются. Это означает, что его можно переписать в виде

for (int k = 0; k < n; k+=4) {
   int * aik = &a[i][k];
   c[i][j] +=
         + aik[0]*b[k][j]
         + aik[1]*b[k+1][j]
         + aik[2]*b[k+2][j]
         + aik[3]*b[k+3][j];
}

вы можете видеть, что будет

  • в четыре раза меньше циклов и обращается к c[i][j]
  • a[i][k] осуществляется непрерывный доступ в памяти
  • память доступы и умножения могут быть конвейеризованы (почти одновременно) в ЦП.

, что если n не является кратным 4 или 6 или 8? (или независимо от того, что компилятор решает развернуть его) компилятор обрабатывает этот порядок для вас. ;)

чтобы ускорить это решение быстрее, вы можете попробовать транспонировать b матрица первая. Это немного дополнительная работа и кодирование, но это означает, что доступ к B-транспонированному также непрерывен в памяти. (Как вы меняете [k] с [Дж])

еще одна вещь, которую вы можете сделать для повышения производительности, - это многопоточное умножение. Это может повысить производительность в 3 раза на 4-ядерном процессоре.

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

во втором примере c[i][j] изменяется на каждом итерация, которая затрудняет оптимизацию.