Недавно столкнулся с интересным вопросом по оптимизации кода с помощью техники блокирования циклов для обеспечения более эффективного использования кэша, и решил поделиться с вами, потому что в русской литературе нашёл не так много информации, как хотелось (я бы сказал, почти ничего).
Сразу замечу, что примеры будут на Фортране – по нему и материала меньше, да и разбирался я именно с ним. На самом деле, какой язык – вообще дело не принципиальное.
Итак, рассмотрим типичную «книжную» задачу умножения матриц, решённую классическим способом через 3 обычных вложенных цикла:
INTEGER I, J, K REAL A(N,N), B(N,N), C(N,N) ... DO I = 1, N DO J = 1, N DO K = 1, N A(I,J) = A(I,J) + B(I,K) * C(K,J) END DO END DO END DO
В случае, когда массив у нас достаточно большой, а размер кэша относительно маленький, возникнут проблемы с производительностью, ещё точнее – промахи кэша. Для понимания данного термина и сути происходящего, нужно рассмотреть понятие и принцип работы кэш-памяти.
Все современные процессоры обладают кэшем – памятью с большой скоростью доступа, предназначенной для того, чтобы минимизировать доступ к ОЗУ. Расположена она между процессором и основной памятью:
Кэш-память делится на несколько уровней, причём каждый последующий уровень больше по размеру и медленнее по скорости доступа и передаче данных, чем предыдущий. Обычно, эти уровни называют L1, L2 и L3. Есть интересная программулинка, которая позволяет определить подробности о вашем процессоре и, в частности, размеры кэша – Coreinfo.
Например, запустив на своём лэптопе (не самом новом), получил следующую информацию:
Intel® Core™2 Duo CPU T7700 @ 2.40GHz
Intel64 Family 6 Model 15 Stepping 11, GenuineIntel
Logical Processor to Cache Map:
*- Data Cache 0, Level 1, 32 KB, Assoc 8, LineSize 64
*- Instruction Cache 0, Level 1, 32 KB, Assoc 8, LineSize 64
-* Data Cache 1, Level 1, 32 KB, Assoc 8, LineSize 64
-* Instruction Cache 1, Level 1, 32 KB, Assoc 8, LineSize 64
** Unified Cache 0, Level 2, 4 MB, Assoc 16, LineSize 64
То есть, у моего процессора 2 ядра, и 2 уровня кэша – L1 равный 32 KB и L2 равный 4 Mb.
Самой быстрой памятью является кэш первого уровня - L1, причем он разделён на два - кэш инструкций и кэш данных. А вот L2 кэш общий, а значит для каждого из ядер можно использовать необходимое количество памяти. Если использовать все ядра, кэш память разделяется на каждое из них динамически, в зависимости от нагрузки.
Доступ к памяти осуществляется процессором небольшими блоками, которые называют строками кэша, собственно, из них кэш и состоит. Обычно размер строки составляет 64 байта, как и в случае с моей системой. При чтении любого значения из памяти, в кэш попадает как минимум одна строка кэша. Последующий доступ к какому-либо значению из этой строки происходит очень быстро. Переход на другую строчку занимает больше времени, ну а отстутствие данных во всём кэше приводит к очень серёзным потерям в производительности, связанными с выгрузкой/загрузкой данных.
Таким образом, с точки зрения производительности, было бы идеально, чтобы весь наш массив помещался в кэш и доступ к его елементам производился по строкам.
Очень интересную статью по теме кэша и его магических свойств я нашёл здесь.
В рамках же нашего рассказа остановимся подробнее на том, как с точки зрения написания кода, избежать подобных проблем и оптимизировать его «руками». Как вы уже поняли, поможет нам в этом техника разбиения циклов на блоки (cache blocking, loop blocking, loop tailing).
Идея проста – мы изменяем наш цикл следующим образом:
DO IT=1, N, IS DO I = 1, N DO I=IT, MIN(N, IT+IS-1) ... => ... END DO ENDDO ENDDO
Разбивая основной цикл на два, мы изменяем итерационное пространство таким образом, что обращаться к элементам массива будем уже не всей исходной матрицы, а её более маленьким блокам.
При этом важный момент здесь – размер блока должен быть таким, чтобы он помещался в кэш. Иначе никакого выигрыша в производительности мы не получим, а наоборот, получим дополнительные расходы на «управление» циклом.
Вспомнив, что исходная задача – умножение матриц, изменим все циклы предложенным образом:
DO IT = 1, N, IS DO JT = 1, N, JS DO KT = 1, N, KS DO I = IT, MIN(N, IT+IS-1) DO J = JT, MIN(N, JT+JS-1) DO K = KT, MIN(N, KT+KS-1) A(I,J) = A(I,J) + B(I,K) * C(K,J) END DO END DO END DO END DO END DO END DO
При этом, размер IS*JS*KS должен помещаться в кэш (циклы у нас вложенные). При решении конкретной задачи (при известных размерах матриц и кэша), дело останется за малым – рассчитать нужные значения индексов.
Для вычисления размера блока данных, я бы порекоммендовал следующую формулу (для задачи умножения матриц):
Размер блока = 0.8 * 1/3 * размер кэша / размер данных
Здесь 0.8 означает, что вероятнее всего, в кэше будет содержаться ещё что-то совсем ненужное нам в размере 20% от всего кэша, поэтому отбросим этот размер. При этом правило одно – «лучше недобрать, чем перебрать», поэтому ошибка в сторону уменьшения приведёт к меньшим потерям производительности, чем выход за размеры кэша.
Рассмотрим пример – пусть размер матриц N на N=1000, работаем с типом REAL (4 байта), L2 кэш 4 Мб.
Размер блока = 0.8 * 1/3 * 4194304 байт / 4 байт = 279620 байт
Таким образом, мы рассчитали приближенный размер блока данных, но нам нужно вычислить сам индекс для использования в итерациях. Для задачи умножения матриц, нам нужно извлечь кубический корень (3 цикла) из размера нашего блока. Получаем, что индекс в нашей задаче равен 65, учитывая что длина строки кэша 64, выберем именно это значение.
На моей системе время выполнения цикла с обычным алгоритмом и с оптимизированным – 38 секунд против 50. Как видно, выигрыш в производительности более 30%.
Кстати, тесты я делал с отключенной оптимизацией. При подключении уровня O3 (максимального), время выполнения изменилось до 4 и 10 секунд соответсвтенно, таким образом, техника блокирования циклов весьма эффективна для подобных задач.
P.S. Сейчас играюсь с различными системами и оцениваю влияние HT (hyper-threading) технологии на размер блока. Так что продолжение следует...