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

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

 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 x 2048. Любые идеи относительно того, почему?

Ответы

Ответ 1

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

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

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

Теперь, одна последняя деталь - в 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]. Это означает, что на каждой итерации внутреннего цикла вы, вероятно, не будете пропускать кеш.

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

Интересно, что многие компиляторы начинают иметь поддержку прототипа для обнаружения того, что вторая версия кода быстрее первой. Некоторые попытаются автоматически переписать код, чтобы максимизировать parallelism. Если у вас есть копия Purple Dragon Book, в главе 11 обсуждается, как работают эти компиляторы.

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

Надеюсь, это поможет!

Ответ 2

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

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

Ответ 3

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

Ответ 4

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

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]
  • Доступ к [i] [k] осуществляется непрерывно в памяти
  • доступ к памяти и умножение могут быть конвейерными (почти одновременно) в ЦП.

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

Чтобы ускорить это решение быстрее, вы можете попробовать сначала перенести матрицу b. Это небольшая дополнительная работа и кодирование, но это означает, что доступ к b-транспонированию также является непрерывным в памяти. (Поскольку вы меняете [k] с помощью [j])

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

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

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