Quantcast
Channel: Последние публикации
Viewing all articles
Browse latest Browse all 20

Cache blocking: техника и тонкости

$
0
0

Недавно столкнулся с интересным вопросом по оптимизации кода с помощью техники блокирования циклов для обеспечения более эффективного использования кэша, и решил поделиться с вами, потому что в русской литературе нашёл не так много информации, как хотелось (я бы сказал, почти ничего).
Сразу замечу, что примеры будут на Фортране – по нему и материала меньше, да и разбирался я именно с ним. На самом деле, какой язык – вообще дело не принципиальное.


Итак, рассмотрим типичную «книжную» задачу умножения матриц, решённую классическим способом через 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) технологии на размер блока. Так что продолжение следует...


Viewing all articles
Browse latest Browse all 20

Trending Articles



<script src="https://jsc.adskeeper.com/r/s/rssing.com.1596347.js" async> </script>