Go и кэши CPU

Original author: Teiva Harsanyi
  • Translation

Источник: unsplash.com

По словам Джеки Стюарта, трехкратного чемпиона мира по гонкам Формулы-1, понимание автомобиля помогло ему стать лучшим пилотом: «Гонщику не обязательно быть инженером, но нужен интерес к механике».

Мартин Томпсон (создатель LMAX Disruptor) применил эту концепцию к программированию. Если в двух словах, то понимание базового оборудования улучшит ваши навыки, когда речь заходит о разработке алгоритмов, структур данных и так далее.

Команда Mail.ru Cloud Solutions перевела статью, автор которой углубился в устройство процессора и рассмотрел, как понимание некоторых концепций CPU помогает принимать оптимальные решения.

Основы


Современные процессоры основаны на концепции симметричной многопроцессорности (SMP). Процессор сконструирован таким образом, что два или более ядра разделяют общую память (также называемую основной или оперативной памятью).

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


Архитектура SMP

Чем ближе кэш к ядру процессора, тем меньше задержка доступа и размер кэша:
Кэш Задержка Циклы CPU Размер
L1 ~1,2 нс ~4 Между 32 и 512 КБ
L2 ~3 нс ~10 Между 128 КБ и 24 МБ
L3 ~12 нс ~40 Между 2 и 32 МБ

Опять же, точные цифры зависят от модели процессора. Для приблизительной оценки: если доступ к основной памяти занимает 60 нс, доступ к L1 примерно в 50 раз быстрее.

В мире процессоров существует важное понятие локальности ссылок. Когда процессор обращается к определенной ячейке памяти, очень вероятно, что:

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

Локальность по времени — одна из причин существования кэшей CPU. Но как использовать локальность по расстоянию? Решение — вместо копирования в кэши CPU одной ячейки памяти туда копируется строка кэша. Строка кэша — это непрерывный сегмент памяти.

Размер строки кэша зависит от уровня кэша (и опять же от модели процессора). Например, вот размер строки кэша L1 на моей машине:

$ sysctl -a | grep cacheline
hw.cachelinesize: 64

Вместо того, чтобы копировать в кэш L1 единственную переменную, процессор копирует туда непрерывный сегмент в 64 байта. Например, вместо копирования единственного элемента int64 среза Go, он cкопирует этот элемент плюс еще семь элементов int64.

Конкретное применение строк кэша в Go


Рассмотрим конкретный пример, который покажет преимущества процессорных кэшей. В следующем коде мы складываем две квадратные матрицы из элементов int64:

func BenchmarkMatrixCombination(b *testing.B) {
    matrixA := createMatrix(matrixLength)
    matrixB := createMatrix(matrixLength)

    for n := 0; n < b.N; n++ {
        for i := 0; i < matrixLength; i++ {
            for j := 0; j < matrixLength; j++ {
                matrixA[i][j] = matrixA[i][j] + matrixB[i][j]
            }
        }
    }
}

Если matrixLength равно 64k, это приводит к следующему результату:

BenchmarkMatrixSimpleCombination-64000                     8  130724158 ns/op

Теперь вместо matrixB[i][j] мы добавим matrixB[j][i]:

func BenchmarkMatrixReversedCombination(b *testing.B) {
    matrixA := createMatrix(matrixLength)
    matrixB := createMatrix(matrixLength)

    for n := 0; n < b.N; n++ {
        for i := 0; i < matrixLength; i++ {
            for j := 0; j < matrixLength; j++ {
                matrixA[i][j] = matrixA[i][j] + matrixB[j][i]
            }
        }
    }
}

Повлияет ли это на результаты?

BenchmarkMatrixCombination-64000                           8  130724158 ns/op
BenchmarkMatrixReversedCombination-64000                   2  573121540 ns/op

Да, повлияло, и весьма радикально! Как это объяснить?

Попробуем нарисовать то, что происходит. Синий круг указывает на первую матрицу, а розовый — на вторую. Когда мы устанавливаем matrixA[i][j] = matrixA[i][j] + matrixB[j][i], синий указатель находится в позиции (4,0), а розовый — в позиции (0,4):


Примечание. На этой диаграмме матрицы представлены в математическом виде: с абсциссой и ординатой, а позиция (0,0) находится в левом верхнем квадрате. В памяти все строки матрицы располагаются последовательно. Однако для большей ясности давайте рассмотрим математическое представление. Более того, в следующих примерах размер матрицы кратен размеру строки кэша. Следовательно, строка кэша не будет «догоняться» на следующем ряду. Звучит сложно, но по примерам всё станет понятно.

Как будем перебирать матрицы? Синий указатель перемещается вправо, пока не достигнет последнего столбца, а затем переходит к следующей строке в позиции (5,0) и так далее. Наоборот, розовый указатель перемещается вниз, а затем переходит к следующему столбцу.

Когда розовый указатель достигает позиции (0,4), процессор кэширует всю строку (в нашем примере размер строки кэша равен четырем элементам). Поэтому, когда розовый указатель достигает положения (0,5), мы можем предположить, что переменная уже присутствует в L1, не так ли?


Ответ зависит от размера матрицы:

  1. Если матрица достаточно мала по сравнению с размером L1, то да, элемент (0,5) уже будет кэширован.
  2. В противном случае строка кэша будет удалена из L1 до того, как указатель достигнет позиции (0,5). Поэтому он будет генерировать промах кэша, и процессору придется обращаться к переменной по-другому, например через L2. Мы будем в таком состоянии:


Насколько мала должна быть матрица, чтобы получить выгоду от L1? Давайте немного посчитаем. Во-первых, нужно знать размер кэша L1:

$ sysctl hw.l1dcachesize
hw.l1icachesize: 32768

На моей машине кэш L1 составляет 32 768 байт, тогда как строка кэша L1 — 64 байта. Таким образом, я могу хранить в L1 до 512 строк кэша. Что, если мы запустим тот же бенчмарк с матрицей из 512 элементов?

BenchmarkMatrixCombination-512            1404   718594 ns/op
BenchmarkMatrixReversedCombination-512    1363   850141 ns/opp

Хотя мы устранили разрыв (на матрице 64k разница была примерно в 4 раза), но всё еще можем заметить небольшую разницу. Что может быть не так? В бенчмарках мы работаем с двумя матрицами. Следовательно, процессор должен хранить строки кэша обоих. В идеальном мире (без других запущенных приложений и так далее), кэш L1 на 50% заполнен данными из первой матрицы и на 50% из второй. Что, если уменьшить размер матрицы вдвое, чтобы работать с 256 элементами:

BenchmarkMatrixCombination-256             5712   176415 ns/op
BenchmarkMatrixReversedCombination-256     6470   164720 ns/op

Наконец, мы достигли точки, где два результата (почти) эквивалентны.
Примечание. Почему второй бенчмарк немного быстрее первого? Разница выглядит довольно тонкой и связана с ассемблерным кодом, который создает Go. Во втором случае указатель на вторую матрицу управляется по-другому с помощью команды LEA (Load Effective Address). Когда процессору необходимо получить доступ к ячейке памяти, происходит перевод из виртуальной в физическую память. Использование LEA позволяет вычислить адрес памяти без необходимости проходить через этот перевод.

Например, если мы управляем фрагментом элементов int64 и у нас уже есть указатель на адрес первого элемента, мы можем использовать LEA для загрузки адреса второго элемента, просто сместив указатель на 8 байтов. В нашем примере это может быть потенциальной причиной того, что второй тест выполняется быстрее. Тем не менее я не эксперт по ассемблеру, так что не стесняйтесь оспаривать этот анализ. Если что, я выложил ассемблерный код первой и второй (обратной) функции.

Теперь — как ограничить влияние промахов кэша в случае более крупной матрицы? Существует метод, который называется оптимизация вложенных циклов (loop nest optimization). Чтобы извлечь максимальную выгоду из строк кэша, мы должны выполнять итерации только в пределах данного блока.

Давайте определим в нашем примере блок как квадрат из 4 элементов. В первой матрице мы перебираем от (4,0) до (4,3). Затем переходим к следующему ряду. Соответственно, мы перебираем вторую матрицу только от (0,4) до (3,4), прежде чем перейти к следующему столбцу.

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


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

В нашем бенчмарке Go мы храним элементы int64 (по 8 байт). Строка кэша составляет 64 байта, поэтому она вмещает 8 элементов. Тогда значение размера блока должно быть не менее 8:

func BenchmarkMatrixReversedCombinationPerBlock(b *testing.B){
    matrixA := createMatrix(matrixLength)
    matrixB := createMatrix(matrixLength)
    blockSize := 8

    for n := 0; n < b.N; n++ {
        for i := 0; i < matrixLength; i += blockSize {
            for j := 0; j < matrixLength; j += blockSize {
                for ii := i; ii < i+blockSize; ii++ {
                    for jj := j; jj < j+blockSize; jj++ {
                        matrixA[ii][jj] = matrixA[ii][jj] + matrixB[jj][ii]
                    }
                }
            }
        }
    }
}

Наша реализация теперь более чем на 67% быстрее, чем когда мы перебирали всю строку/столбец:

BenchmarkMatrixReversedCombination-64000          2  573121540 ns/op
BenchmarkMatrixReversedCombinationPerBlock-64000  6  185375690 ns/op

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

False sharing


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

  1. Принцип локальности по расстоянию заставляет процессор забирать не просто один адрес памяти, а строку кэша.
  2. Кэш L1 является локальным для одного ядра процессора.

Теперь обсудим пример с когерентностью кэша L1. В основной памяти хранятся две переменные var1 и var2. Один поток на одном ядре обращается к var1, тогда как другой поток на другом ядре обращается к var2. Предполагая, что обе переменные непрерывны (или почти непрерывны), мы оказываемся в ситуации, когда var2присутствует в обоих кэшах.


Пример кэша L1

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

Как процессор сохраняет когерентность кэша? Если у двух строк кэша общие адреса, процессор помечает их как общие(shared). Если один поток изменяет общую строку, он помечает обе как измененные (modified). Чтобы гарантировать когерентность кэшей, требуется координация между ядрами, что может значительно снизить производительность приложения. Эта проблема называется false sharing (ложный обмен информацией).

Рассмотрим конкретное приложение на Go. В этом примере мы создаем две структуры одну за другой. Следовательно, эти структуры должны располагаться в памяти последовательно. Затем создаем две горутины, каждая из них обращается к своей структуре (M — это константа, равная миллиону):

type SimpleStruct struct {
    n int
}

func BenchmarkStructureFalseSharing(b *testing.B) {
    structA := SimpleStruct{}
    structB := SimpleStruct{}
    wg := sync.WaitGroup{}

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        wg.Add(2)
        go func() {
            for j := 0; j < M; j++ {
                structA.n += j
            }
            wg.Done()
        }()
        go func() {
            for j := 0; j < M; j++ {
                structB.n += j
            }
            wg.Done()
        }()
        wg.Wait()
    }
}

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

BenchmarkStructureFalseSharing         514    2641990 ns/op

Как предотвратить ложный обмен информацией? Одним из решений является заполнение памяти (memory padding). Наша цель гарантировать, что между двумя переменными достаточно места, чтобы они принадлежали к разным строкам кэша.

Во-первых, создадим альтернативу предыдущей структуре, заполнив память после объявления переменной:

type PaddedStruct struct {
    n int
    _ CacheLinePad
}

type CacheLinePad struct {
    _ [CacheLinePadSize]byte
}

const CacheLinePadSize = 64

Затем создаем экземпляры двух структур и продолжаем обращаться к этим двум переменным в отдельных горутинах:

func BenchmarkStructurePadding(b *testing.B) {
    structA := PaddedStruct{}
    structB := SimpleStruct{}
    wg := sync.WaitGroup{}

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        wg.Add(2)
        go func() {
            for j := 0; j < M; j++ {
                structA.n += j
            }
            wg.Done()
        }()
        go func() {
            for j := 0; j < M; j++ {
                structB.n += j
            }
            wg.Done()
        }()
        wg.Wait()
    }
}

С точки зрения памяти, этот пример должен выглядеть, словно две переменные являются частью разных строк кэша:


Заполнение памяти

Посмотрим на результаты:

BenchmarkStructureFalseSharing         514    2641990 ns/op
BenchmarkStructurePadding              735    1622886 ns/op

Второй пример с заполнением памяти примерно на 40% быстрее, чем наша первоначальная реализация. Но есть и обратная сторона. Метод ускоряет код, но требует большего объема памяти.

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

Что еще почитать:

  1. Три уровня автомасштабирования в Kubernetes и как их эффективно использовать.
  2. Kubernetes в духе пиратства с шаблоном по внедрению.
  3. Наш канал Вокруг Kubernetes в Телеграме.
Mail.ru Group
Building the Internet

Similar posts

Comments 16

    0
    Отличная статья, спасибо за перевод. Думаю, много кто уже почитал в оригинале, но перевод точно лишним не будет)
      0

      Как добавить hw в /proc/sys? (Без этого не работают команды sysctl для кэша.)

        +2
        Используйте
        getconf -a | grep CACHE
          0

          Спасибо! Работает.

        +7

        не очень понятно, почему в заголовке статьи Go, вроде бы как ничего go-специфичного тут нет

          0
          Скорее всего потому, что ускоряли программы на Go.
          +2
          Статься как-будто написана по мотивам U.Drepper «What every programmer should know about memory». Там есть и пример с матрицами и про false sharing и еще много чего интересного о кешах CPU.
            0
            есть же L2 и L3, оптимизация рассмотрена исключительно под L1
              0

              До кучи: Крис Касперски "Техника оптимизации программ. Эффективное использование памяти".

                +1
                «Гонщику не обязательно быть инженером, но нужен интерес к механике»
                — это из статьи про кеши с фотографией южного моста, браво!
                  0
                  Go — это удивительный язык, который можно легко улучшить.
                    +1
                    Ну ключевое событие в жизни компилятора Go — самокомпиляция, судя по всему, состоялось 5 лет назад. Дальше — лучше.
                  • UFO just landed and posted this here
                      0
                      Что нужно почитать, чтобы осилить данный материал?
                        0

                        а что именно вам непонятно? я пробежал по диагонали, конечно, но вроде бы написаны достаточно очевидные вещи

                        0
                        Я бы ещё про ассоциативность добавил.

                        У меня на «ПК getconf -a | grep CACHE» выводит это:
                        LEVEL1_ICACHE_SIZE 32768
                        LEVEL1_ICACHE_ASSOC 8
                        LEVEL1_ICACHE_LINESIZE 64
                        LEVEL1_DCACHE_SIZE 32768
                        LEVEL1_DCACHE_ASSOC 8
                        LEVEL1_DCACHE_LINESIZE 64
                        LEVEL2_CACHE_SIZE 262144
                        LEVEL2_CACHE_ASSOC 4
                        LEVEL2_CACHE_LINESIZE 64
                        LEVEL3_CACHE_SIZE 6291456
                        LEVEL3_CACHE_ASSOC 12
                        LEVEL3_CACHE_LINESIZE 64

                        Как видно, у меня L1I и L1D — 8-ассоциативные.

                        При обращении к какому-либо адресу, скажем 0x12345678, поиск не будет осуществляться по всем 32кб L1D, а только к тем 8-ми к которым этот адрес может относиться.

                        Легко вычислить, что у меня количество Set'ов будет 32768 / (8 (ассоциативность) * 64 (кол-во байт кэш-строки)) = 64.
                        Можно нарисовать табличку 64 x 8 и сказать, что часть адресов будет относится к 8-мью кэш-строкам из одного Set'а, другие — к 8-мью из другого.

                        Номер Set'а с которым будет сравнение вычисляется из адреса.
                        В моём случае кэш-строка — 64 байта, соответственно биты 0..5 адреса отвечают за индекс байта внутри кэш-строки.
                        В моём случае количество Set'ов — 64, соответственно биты 6..11 адреса отвечают за выбор Set'а.
                        А биты с 12-го уже образуют tag, который и будет проверятся на попадание / промах с восемью тэгами из кэша (для адреса 0x12345678 тэгом будет 0x12345).

                        Смысл этого. Если делать полностью ассоциативный кэш, снизится быстродействие кэша (надо много всего сравнить, больше комбинаторики, ниже частота). Если одно-ассоциативный, будет много кэш-промахов, снизится итоговая производительность. Ассоциативность 4-8 для низкоуровневых кэш — вполне оптимальные значения.

                        Практический смысл для программистов тот же — понимать как работает железо, при решении проблем быстродействия.
                        При программировании встроенных систем (если мы занимаемся назначением адресов), не стоит любить круглые адреса: 0x1000, 0x2000, 0x3000. Можно нарваться на то, что они все будут принадлежать к одному Set'у и будет использована только 1/64 кэш-памяти (ну или сколько там Set'ов), что не есть эффективно.
                        Но!!! Тут надо быть очень осторожными, чтобы не заточить программу под конкретный тип микропроцессора.
                        Ну и преждевременная оптимизация — зло. Решать надо проблемы в том месте, где они есть.

                        Only users with full accounts can post comments. Log in, please.