Mikhail Kurnosov

Loop blocking (разбиение циклов на блоки)

Mikhail Kurnosov

2023-04-04

Введение

Разбиение цикла на блоки (loop tiling, loop blocking) – это разбиение пространства итераций вложенных циклов на блоки меньших размеров с целью повышение эффективности использования кеш-памяти процессора. Ключевая идея – загрузка и хранение блоков в кеш-памяти для их повторного использования. Сокращение времени выполнения достигается за счет уменьшения числа кеш-промахов и нежелательного вытеснения строк из кеш-памяти.

Суммирование массивов

Рассмотрим пример суммирования элементов двух массивов a[m][n] и b[m][n].

void sum(double *a, double *b, int m, int n)
{
    for (int i = 0; i < m; i++) {
        for (int j = 0; j < n; j++)
            a[i * n + j] += b[j * n + i];
    }
}

Будем считать, что адреса массивов выравнены на границу размера строки кеш-памяти (64 байта для x86-64). Следовательно элемент b[0][0] попадет в начало строки кеш-памяти. Обращение к элементам массива b характеризуется плохой пространственной локальностью, осуществляется с шагом n. На первой итерации внутреннего цикла (i=0, j=0) выполняется чтение b[0][0]. Он отсутствует в кеш-памяти, происходит кеш-промах (cache miss). Из памяти в кеш загружается строка, содержащая 64 байта – 8 элементов типа double b[0][0], b[0][1], ..., b[0][7].

На второй итерации внутреннего цикла (i=0, j=1) выполняется чтение b[1][0] и происходит кеш-промах, так как мы “перешагнули” элементы загруженные в кеш на предыдущей итерации. Таким образом, при каждом обращении к массиву b[j][0] происходит кеш-промах.

Если количество строк m в массиве больше чем строк в кеш-памяти, то промахи обращения к массиву b приведут к тому, что из кеш-памяти начнут вытесняться ранее загруженные строки.

Если разбить пространство итераций двух циклов на блоки, то можно сохранить подмассивы в кеш-памяти и сократить число кеш-промахов. В примере ниже два внутренних цикла обрабатывают подматрицы 8x8 элементов.

void sum_block(double *a, double *b, int m, int n)
{
    int bs = 8;
    for (int i = 0; i < m; i += bs) {
        for (int j = 0; j < n; j += bs) {
            for (int ii = i; ii < IMIN(i + bs, m); ii++) {
                for (int jj = j; jj < IMIN(j + bs, n); jj++) {
                    a[ii * n + jj] += b[jj * n + ii];
                }
            }
        }
    }
}

При проходе по циклу jj происходит bs промахов и в кеш загружаются строки: b[0][0-7], b[1][0-7], …, b[bs-1][0-7]. Но в отличии от предыдущей версии на следующей итерации ii элементы b[0][1], b[1][1], ..., b[bs-1][1] читаются из кеш-памяти!

Эксперименты

Ниже приведены результаты запуска исходной и оптимизированной версий на процессоре Intel i7-1160G7 (Tiger Lake). Компилятор gcc 12.2.0, ядро linux 5.19.0-29-generic x86_64.

Результаты для исходной версии (m=10000, n=1000):

$ gcc -O2 -Wall -o sum ./sum.c

$ perf stat -e cache-misses -- taskset --cpu-list 3 ./sum
# [m]    [time, sec]   [MFLOPS]
  10000  0.011390      877.99

 Performance counter stats for 'taskset --cpu-list 3 ./sum':

       366 323 214      cache-misses                                                

Результаты версии с разбиением пространства итераций на блоки 8x8:

$ perf stat -e cache-misses -- taskset --cpu-list 3 ./sum
# [m]    [time, sec]   [MFLOPS]
  10000  0.009266      1079.20

 Performance counter stats for 'taskset --cpu-list 3 ./sum':

       305 873 384      cache-misses                                                

Время оптимизированной версии сократилось на 23%.

Умножение матрицы на вектор

Разбиение циклов на блоки позволяет оптимизировать работу с кеш-памятью и в программах с хорошей пространственной локализацией. Типичный пример – умножение матрицы на вектор. Доступ к массивам a[m][n], b[n], c[m] выполняется с шагом 1, но при больших значениях m на итерациях внутреннего цикла j из кеш-памяти начнут вытесняться ранее загруженные строки.

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

Разбиение цикла на блоки позволяет сохранить в кеш-памяти подмассивы для их обработки:

void dgemv_block(double *a, double *b, double *c, int m, int n)
{
    int bs = 8;
    for (int i = 0; i < m; i += bs) {
        for (int j = 0; j < n; j += bs) {
            for (int ii = i; ii < IMIN(i + bs, m); ii++) {
                for (int jj = j; jj < IMIN(j + bs, n); jj++) {
                    c[ii] += a[ii * n + jj] * b[jj];
                }
            }
        }
    }
}