18. Кэши CPU. L1, L2, L3 и Cache Line
В предыдущей главе (17. Пирамида памяти. Регистры, SRAM, DRAM и цена доступа) мы установили фундаментальный факт: современный процессор не может позволить себе напрямую работать с гигантской оперативной памятью DRAM. Если бы каждый запрос числа или адреса шел через материнскую плату, наши мощные многоядерные чипы 99% времени дремали бы в ожидании ответов.
Чтобы спасти процессор от информационного голода, архитекторы расположили между исполнительными конвейерами и системной шиной каскад скоростных буферов — кэши процессора, выполненные на сверхбыстрой статической памяти SRAM.
Для инженера бэкенда понимание механики кэшей — это водораздел. Именно здесь проходит граница между кодом, который «просто компилируется и работает», и кодом, способным без натуги перемалывать миллионы сетевых запросов в секунду.
Анатомия кэш-иерархии
Современный процессор (будь то x86-64 Intel Core / AMD Ryzen или ARM64 Apple M-серии / AWS Graviton) организует свою быструю память в виде трехслойного пирога. Правило простое: чем ближе к вычислительному ядру (ALU), тем кэш меньше по объему, но тем выше его пропускная способность и ниже задержка.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph RAM_Layer["Внешняя память (DRAM)"]
RAM["Оперативная память (RAM)<br>DDR4 / DDR5 | 32-128 GB<br>Задержка: ~60-100 ns (200-400 тактов)"]
end
subgraph CPU_Chip["Кристалл процессора"]
L3["Кэш L3 (LLC — Last Level Cache)<br>Общий для всех ядер | 16-96 MB (до 768–1152 MB с 3D V-Cache)<br>Задержка: ~10-20 ns (40-60 тактов)"]
subgraph Core1["Физическое ядро 1"]
L2_1["Кэш L2 (Ядро 1)<br>Объединенный (код + данные) | 1-2 MB<br>Задержка: ~3-10 ns (12-16 тактов)"]
L1D_1["L1d (Кэш данных)<br>32-64 KB | ~1 ns (4-5 тактов)"]
L1I_1["L1i (Кэш инструкций)<br>32-64 KB | ~1 ns (4-5 тактов)"]
ALU1["Регистры и ALU"]:::process
end
subgraph Core2["Физическое ядро 2"]
L2_2["Кэш L2 (Ядро 2)<br>Объединенный (код + данные) | 1-2 MB<br>Задержка: ~3-10 ns (12-16 тактов)"]
L1D_2["L1d (Кэш данных)<br>32-64 KB | ~1 ns (4-5 тактов)"]
L1I_2["L1i (Кэш инструкций)<br>32-64 KB | ~1 ns (4-5 тактов)"]
ALU2["Регистры и ALU"]:::process
end
end
RAM <-->|"Шина памяти (Memory Bus)"| L3
L3 <--> L2_1
L3 <--> L2_2
L2_1 <--> L1D_1
L2_1 <--> L1I_1
L2_2 <--> L1D_2
L2_2 <--> L1I_2
L1D_1 <--> ALU1
L1I_1 <--> ALU1
L1D_2 <--> ALU2
L1I_2 <--> ALU2
Уровни кэша в разрезе:
- L1 (Level 1): Личный карман конвейера.
- Располагается прямо внутри кристалла исполнительного ядра.
- Исторически разделен на две независимые половины по гарвардской архитектуре:
- L1i (Instruction Cache): хранит декодируемый машинный код.
- L1d (Data Cache): хранит операнды и переменные.
- Такое разделение жизненно необходимо: стадия выборки инструкций (Fetch) и стадия работы с памятью (Execute/Memory) могут одновременно опрашивать L1 без конкуренции за физическую шину.
- Размер: обычно 32–64 КБ на каждую часть (L1i / L1d).
- Задержка (Latency): ~1 наносекунда (всего 3–4 такта процессора).
- L2 (Level 2): Персональный рабочий стол ядра.
- Универсальный буфер: данные и инструкции хранятся совместно.
- В абсолютном большинстве современных архитектур L2 является строго локальным для конкретного физического ядра.
- Размер: от 512 КБ до 1–2 МБ на ядро.
- Задержка: ~3–10 наносекунд (10–15 тактов).
- L3 (Level 3 / Last Level Cache, LLC): Общественный архив кристалла.
- Является общим разделяемым ресурсом для всех вычислительных ядер на кристалле (или внутри одного CCX/чиплета в архитектурах AMD Zen).
- Если горутина в Go выполнялась на ядре №1, записала структуру в память, а затем планировщик перебросил её на ядро №2 — ядро №2 найдет обновленные данные без долгого похода в оперативную память: либо в разделяемом кэше L3, либо — через протокол когерентности — прямо в кэше другого ядра. (Это зависит от архитектуры: L3 бывает включающим (inclusive, как у старых Intel) или невключающим / «кэшем-жертвой» (non-inclusive / victim, как у Intel Skylake-SP и новее и у AMD Zen), и тогда данные могут вообще отсутствовать в L3.)
- Размер: от 16–32 МБ в потребительских чипах до 768 МБ (EPYC Milan-X) и даже 1152 МБ (EPYC Genoa-X) в серверных монстрах с технологией 3D V-Cache, где пластины SRAM наращиваются прямо поверх кремния процессора).
- Задержка: ~10–20 наносекунд (40–60 тактов).
Кэш-линия (Cache Line): Атомарная единица памяти
Вот здесь кроется самая главная тайна, определяющая производительность высоконагруженного кода на Go.
Когда программист пишет тривиальное выражение:
b := a[0] // где a — срез или массив типа byte
процессор не идет в оперативную память DRAM за одним несчастным байтом. Процессоры вообще аппаратно не способны читать или писать данные из основной памяти побайтово! Контроллеру шины и подсистеме адресации это было бы совершенно невыгодно.
Вместо этого процессор всегда запрашивает и перемещает данные фиксированными порциями одинакового размера. Эта квантовая единица кэш-памяти называется Кэш-линией (Cache Line).
Стандарт индустрии:
В подавляющем большинстве современных процессоров x86-64 размер кэш-линии равен 64 байтам (512 бит); мы далее исходим из этого значения. Но «строго 64 байта» — не закон природы: у Apple M-серии кэш-линия — 128 байт, а в рантайме Go константаcpu.CacheLinePadSizeравна 64 на amd64, 128 на arm64 и ppc64, и 256 на s390x. Рассчитывая на 64, помните об этих исключениях.
┌──────────────────────────────── 64 байта ───────────────────────────────┐
│ int64 #0 │ int64 #1 │ int64 #2 │ int64 #3 │ int64 #4 │ int64 #5 │ int64 #6 │ int64 #7 │
│ (8 байт)│ (8 байт)│ (8 байт)│ (8 байт)│ (8 байт)│ (8 байт)│ (8 байт)│ (8 байт)│
└─────────────────────────────────────────────────────────────────────────┘
▲ ▲
0x00 0x3F
Если вашему алгоритму понадобился всего 1 байт (например, булев флаг или символ byte), процессор загружает этот байт и еще 63 соседних байта из памяти в кэш L1 одной монолитной транзакцией.
Представьте, что вы пришли в супермаркет купить одно куриное яйцо для утренней глазуньи. Однако продавец не продает яйца поштучно: яйца упакованы в лотки по 10 штук. Вы вынуждены купить всю упаковку целиком и принести её на кухню (в кэш). Если через минуту вам потребуется второе яйцо — бежать в магазин уже не нужно, оно лежит прямо перед вами. Но если вы выбросите остальные 9 яиц, вы потратите силы и деньги впустую.
Два закона локальности
Работа кэш-памяти опирается на два эмпирических закона:
- Временная локальность (Temporal Locality): если память по адресу $X$ была запрошена прямо сейчас, с высокой вероятностью этот же адрес понадобится процессору снова в ближайшие миллисекунды (например, счетчик цикла
i++или локальная переменная на вершине стека). - Пространственная локальность (Spatial Locality): если процессор прочитал байт по адресу $X$, с вероятностью более 90% следующей операцией он захочет прочитать адрес $X+1$, $X+2$ и так далее. Именно под эту гипотезу и спроектирована 64-байтная кэш-линия.
Mechanical Sympathy: Пишем код с уважением к Cache Line
Размер кэш-линии в 64 байта означает, что в нее ровно помещается:
- 64 значения типа
byte/bool/int8 - 16 значений типа
int32/float32 - 8 значений типа
int64илиfloat64 - 8 указателей в 64-битной системе (типа
*Userилиuintptr)
Посмотрим, к каким драматическим последствиям приводит игнорирование этого факта. Классическая лабораторная задача, встречающаяся на углубленных архитектурных собеседованиях: обход двумерной матрицы размером $4096 \times 4096$.
Чтобы исключить накладные расходы на косвенную адресацию вложенных слайсов ([][]int), в продакшн-коде двумерные массивы всегда «расплющивают» в непрерывный одномерный срез:
package main
import (
"testing"
)
const size = 4096
// Инициализируем плоскую матрицу из 16 777 216 элементов
var matrix = make([]int64, size*size)
// sink не даёт компилятору выбросить «бесполезное» суммирование
var sink int64
func init() {
// Реально записываем данные: нетронутая память отображается
// на одну нулевую страницу ОС, и эксперимент потерял бы смысл.
for i := range matrix {
matrix[i] = int64(i)
}
}
// Пример 1: Идиоматичный обход (по строкам)
func BenchmarkMatrixRow(b *testing.B) {
for i := 0; i < b.N; i++ {
var sum int64
for y := 0; y < size; y++ {
for x := 0; x < size; x++ {
// Читаем элементы последовательно в памяти
sum += matrix[y*size+x]
}
}
sink = sum
}
}
// Пример 2: Катастрофический обход (по столбцам)
func BenchmarkMatrixColumn(b *testing.B) {
for i := 0; i < b.N; i++ {
var sum int64
for x := 0; x < size; x++ {
for y := 0; y < size; y++ {
// Скачем через гигантские интервалы памяти!
sum += matrix[y*size+x]
}
}
sink = sum
}
}
Если сохранить этот код в файле matrix_test.go и запустить go test -bench=. на любом современном CPU, разница потрясает: BenchmarkMatrixRow работает на порядок быстрее, чем BenchmarkMatrixColumn! (В нашем прогоне на Go 1.27.1 и Intel Core i9-12900K — около 7.5 мс против 84 мс, то есть порядка 11×; на других процессорах и размерах матрицы множитель будет другим — как правило, в диапазоне 5–20×. Обратите внимание на sink и инициализацию: без них компилятор удалит суммирование, а нетронутая память окажется нулевой страницей, и вы вообще ничего не измерите.)
Хотя математическая сложность обоих алгоритмов строго одинакова — $\mathcal{O}(N^2)$ операций сложения, а в ассемблере генерируются идентичные инструкции ADDQ. В чем же разгадка?
Анатомия катастрофы: почему обход по столбцам проигрывает
Что происходит в BenchmarkMatrixRow (последовательный ход по строкам):
- На первой итерации процессор запрашивает
matrix[0]. Данных в кэше нет — происходит Cache Miss. - Контроллер обращается к оперативной памяти и загружает в кэш L1 полную 64-байтную кэш-линию. Задержка: ~60–80 нс.
- В этой кэш-линии уже лежат следующие 8 чисел: от
matrix[0]доmatrix[7]. - Следующие 7 итераций внутреннего цикла (
matrix[1],matrix[2]...matrix[7]) исполняются со скоростью света — ~1 нс из кэша L1. - Аппаратный блок предвыборки (Prefetcher) быстро распознает строго последовательный шаг чтения ($+8$ байт) и начинает фоном затягивать следующие кэш-линии из DRAM еще до того, как цикл до них доберется!
- КПД использования шины памяти: 100%. Мы запросили 64 байта и полностью их потребили.
Что происходит в BenchmarkMatrixColumn (скачкообразный ход по столбцам):
- Процессор читает элемент
matrix[0](индекс 0). Cache Miss — загружается кэш-линия с элементами[0..7]. - На следующей итерации переменная
yувеличивается на 1. Мы запрашиваем элемент:
$$\text{index} = 1 \times 4096 + 0 = 4096$$ - В байтах это шаг на $4096 \times 8 = 32768$ байт (32 КБ) вперед: мы запрашиваем элемент
matrix[1 * 4096 + 0](индекс 4096). Этот адрес заведомо лежит в совершенно другой кэш-линии. - Процессор снова получает Cache Miss и конвейер замирает еще на 80 наносекунд.
- А что происходит с кэш-линией
[0..7], которую мы только что привезли из памяти? Мы взяли из нее всего одно число (8 байт), а остальные 7 чисел (56 байт) просто выбросили, потому что к моменту, когда цикл до них дойдет, кэш L1 уже вытеснит эту линию! - КПД полезной нагрузки шины: всего 12.5%. Пропускная способность шины памяти забита бесполезным мусором, а конвейеры простаивают.
- Есть и более тонкие эффекты: шаг в 32 КБ — степень двойки, поэтому все эти адреса попадают в одни и те же наборы кэша (конфликтные промахи, associativity — тема 19. Ассоциативность кэша, Prefetching и Cache Miss), а каждый шаг ещё и попадает на новую страницу памяти (промахи TLB).
Паттерны организации данных: AoS против SoA
Из понимания природы кэш-линий рождается один из главных архитектурных приемов оптимизации структур данных: переход от Array of Structures (AoS) к Structure of Arrays (SoA).
Ловушки выравнивания (Alignment)
Размер кэш-линии диктует жесткие требования к расположению данных в оперативной памяти.
Если критически важная переменная или структура примитивов не выровнена кратно размеру кэш-линии, она может случайно оказаться на границе двух соседних линий (для обычных скаляров до 8 байт выравнивание Go этого не допускает; такое возможно для крупных структур, а также при unsafe и упакованных данных). В этом случае процессору для чтения одного-единственного поля придется делать два раздельных обращения к кэшу и аппаратно «сшивать» половинки байт.
Еще более коварная проблема возникает в многопоточных Go-программах. Представьте структуру с полем sync.Mutex, защищающим общие данные. Внутреннее состояние мьютекса занимает 8 байт. Если рядом с мьютексом в пределах тех же 64 байт лежит часто изменяемый счетчик другого ядра, они окажутся заперты в одной кэш-линии. Это порождает разрушительный феномен взаимного вытеснения кэшей — False Sharing (которому мы посвятим отдельную главу 21. False Sharing и Cache Line Contention).
Итог
- Кэш-иерархия состоит из трех уровней: L1 (локальный для ядра, разделен на L1i для инструкций и L1d для данных, 1 нс), L2 (локальный рабочий буфер ядра, 3–10 нс) и L3 (общий большой пул для всех ядер на кристалле, 10–20 нс).
- Процессор физически оперирует памятью порциями по 64 байта — Кэш-линиями (Cache Lines). Побайтового обмена с DRAM не существует.
- Пространственная локальность: прочитав один байт по адресу $X$, вы бесплатно получаете остальные 63 байта этой же линии в кэше L1.
- Прыжки по памяти (например, обход матриц по столбцам или хаотичные переходы по указателям) приводят к катастрофическому падению скорости: шина забивается бесполезными байтами, а конвейер процессора простаивает в ожидании ответов DRAM.
- Для предельной производительности организуйте данные непрерывными монолитными массивами, отдавая предпочтение паттерну SoA вместо AoS при обработке отдельных полей больших коллекций.
Мы разобрались, почему процессор перемещает данные 64-байтными строками. Но как аппаратная логика кэша мгновенно понимает, где именно среди миллионов адресов DRAM находится нужная линия? Что такое ассоциативность кэша и почему одинаковый остаток от деления адресов может вызвать аппаратный конфликт?
Ответы на эти вопросы мы найдем в следующей главе: 19. Ассоциативность кэша, Prefetching и Cache Miss.