19. Ассоциативность кэша, Prefetching и Cache Miss

В предыдущей главе (18. Кэши CPU. L1, L2, L3 и Cache Line) мы выяснили, что оперативная память и процессор общаются строго 64-байтными порциями — кэш-линиями. Но мы намеренно оставили за скобками главный архитектурный ребус.

Взгляните на цифры: объем кэша первого уровня L1 составляет скромные 32 КБ. Объем оперативной памяти на сервере — 64 Гигабайта (а то и терабайт).
Разделив 32 КБ на 64 байта, мы получим ровно 512 слотов для кэш-линий.

Когда ядро запрашивает блок памяти по произвольному 64-битному адресу из бескрайней RAM, по какому правилу контроллер определяет, в какой именно из 512 слотов положить приехавшие байты? И что еще важнее: когда эти данные понадобятся снова, как процессор за долю наносекунды находит нужную кэш-линию среди остальных 511, не перебирая их по очереди?

Эта фундаментальная задача называется Архитектурой маппинга (отображения) кэша.


Ассоциативность кэша: Проблема парковки

Чтобы понять физику процесса, представим наглядную жизненную аналогию.
Кэш L1 — это парковка на 512 мест, обслуживающая огромный мегаполис (оперативную память RAM) с миллионами автомобилей (кэш-линий).

Как организовать правила въезда и размещения машин? В истории вычислительной техники было опробовано три подхода.

DIAGRAM
Анатомия 64-битного адреса памяти при поиске в кэше:
┌───────────────────────────────┬──────────────┬───────────────┐
│           Tag (Тег)           │  Set (Индекс)│ Offset (Смещение)
│            52 бита            │    6 бит     │     6 бит     │
└───────────────────────────────┴──────────────┴───────────────┘
                                 (выбирает 1    (выбирает байт
                                  из 64 сетов)   внутри 64B линии)

1. Direct-Mapped (Кэш прямого отображения)

Самое прямолинейное решение: жесткая привязка. Номер парковочного места однозначно вычисляется из адреса автомобиля по формуле деления с остатком: Место = Адрес % 512.
$$\text{Место} = \text{Адрес} \pmod{512}$$

  • Плюсы: Невероятная аппаратная простота и дешевизна. Время поиска — строго $\mathcal{O}(1)$: берем биты адреса и сразу смотрим в нужную ячейку.
  • Минусы: Чудовищные коллизии. Представьте, что в вашей Go-программе два активных массива лежат по адресам 0 и 32768. Оба этих адреса при делении на 512 дают один и тот же остаток — 0! Они претендуют на одно-единственное Место №0.
    Процессор будет циклически вытеснять одну кэш-линию другой на каждой итерации цикла. Это явление называется Thrashing (кэш-буксование). Скорость работы падает до уровня медленной DRAM, несмотря на то что остальные 511 мест на парковке стоят абсолютно пустыми!

2. Fully Associative (Полностью ассоциативный кэш)

Противоположная крайность: полная свобода. Любая кэш-линия может занять вообще любое свободное место из 512.

  • Плюсы: Коллизий мест нет в принципе. Кэш заполняется со 100% эффективностью.
  • Минусы: Катастрофа при поиске. Чтобы узнать, есть ли нужный адрес в кэше, контроллер обязан параллельно сравнить теги всех 512 ячеек за один такт! Для этого требуется разместить 512 аппаратных компараторов на кристалле. Это приводит к дикому расходу транзисторов, высокому энергопотреблению и перегреву. В кэшах данных L1/L2 такой подход физически не реализуем (он применяется лишь в крошечных специализированных буферах на 32–64 записи, например, в TLB).

3. N-Way Set Associative (N-канальный множественно-ассоциативный)

Гениальный инженерный компромисс, на котором сегодня построены 99.9% процессоров в мире.

Парковка делится на несколько отдельных «секторов» или «наборов» (Sets). Внутри каждого сета выделяется строго фиксированное число парковочных мест (Ways, каналы ассоциативности).

Например, классический 8-Way кэш объемом 32 КБ:

  • Всего слотов: 512 линий.
  • В каждом сете: 8 мест (Ways).
  • Всего сетов: $512 / 8 = 64$ сета.

Машина направляется в свой сет по формуле: Зона = Адрес % 64 (где номер кэш-линии делится по модулю числа сетов).
$$\text{Зона (Set)} = (\text{Адрес} / 64) \pmod{64}$$
А вот внутри выделенного сета она может занять любое из 8 свободных мест!

Когда процессору нужно проверить наличие адреса, он активирует всего 8 компараторов одновременно (вместо 512). Это работает молниеносно, потребляет минимум энергии и защищает от коллизий. Если все 8 мест в сете заняты, специальный алгоритм вытеснения (обычно LRU — Least Recently Used или его аппаратное приближение Tree-PLRU) выбрасывает кэш-линию, к которой дольше всего не обращались.

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
    Addr["64-битный адрес памяти"]:::data --> Split["Разбиение адреса:<br>[Tag | Set Index (6 бит) | Offset (6 бит)]"]
    Split --> SetSelect["Выбор Set (от 0 до 63)"]

    subgraph SetK["Выбранный Set K (8-Way Associative)"]
        direction TB
        W0["Way 0: [Tag0 | 64B Data]"]
        W1["Way 1: [Tag1 | 64B Data]"]
        W2["Way 2: [Tag2 | 64B Data]"]
        W3["Way 3: [Tag3 | 64B Data]"]
        W4["Way 4: [Tag4 | 64B Data]"]
        W5["Way 5: [Tag5 | 64B Data]"]
        W6["Way 6: [Tag6 | 64B Data]"]
        W7["Way 7: [Tag7 | 64B Data]"]
    end

    SetSelect --> SetK
    SetK --> Compare{"Параллельное сравнение<br>Тега с 8 слотами"}
    Compare -->|"Совпадение найдено"| Hit["КЭШ-ХИТ (Hit)<br>Отдача байта за 1 ns"]
    Compare -->|"Совпадений нет"| Miss["КЭШ-ПРОМАХ (Miss)<br>Запрос в L2/L3/DRAM + вытеснение LRU"]
  

В современных процессорах кэш L1d обычно 8-way или 12-way (например, 48 КБ и 12-way в Golden Cove, 32 КБ и 8-way в более ранних ядрах), кэш L2 — 8-way или 16-way, а огромный L3 — 12-way, 16-way или даже 24-way.


Правило Трех C: Анатомия Cache Miss

Когда процессор пытается прочитать данные и не находит нужный тег в выбранном сете, происходит Cache Miss (промах кэша).

Классическая теория архитектуры ЭВМ (сформулированная Марком Хиллом и популяризированная Джоном Хеннесси и Дэвидом Паттерсоном) делит промахи на три фундаментальные категории — «Три C» (The 3 C's):

  1. Compulsory Miss (Обязательный, или «холодный» промах):
    Самое первое обращение программы к данной области памяти. Данных физически еще никогда не было в кэше. Этот промах неизбежен при холодном старте сервиса.
  2. Capacity Miss (Промах по вместимости):
    Рабочий набор данных вашей программы (Working Set) превышает физический объем кэша (например, хэш-таблица на миллион ключей занимает 100 МБ, а весь кэш L3 — 32 МБ). Данные постоянно сменяют друг друга, потому что кэш банально мал.
  3. Conflict Miss (Конфликтный промах):
    Самый обидный и коварный промах. Суммарный объем данных невелик и легко поместился бы в кэш (кэш занят всего на 15%), но структура адресов такова, что все обращения конкурируют за один и тот же Set, переполняя его 8 каналов.

(В многоядерных системах к ним добавляется четвертое «C» — Coherence Miss, вызванный аннулированием строк из-за записи на соседнем ядре. Мы разберем его в следующей главе).


Prefetching: Железо предсказывает будущее

Казалось бы, если Compulsory Miss (первое чтение) неизбежен, то при последовательном обходе массива мы обречены получать штраф памяти на каждой новой кэш-линии.

Но создатели процессоров научили кремний предугадывать намерения программиста. За это отвечает Hardware Prefetcher (Аппаратный блок предвыборки).

Prefetcher работает как скрытый аналитический радар в составе кэш-контроллера. Он непрерывно отслеживает последовательность адресов, которые запрашивают инструкции MOVQ.

DIAGRAM
Ваш код запрашивает:
Линия 1: 0x1000  (Miss, чтение из DRAM)
Линия 2: 0x1040  (+64 байта)  ──► Prefetcher фиксирует: Stride = +64
Линия 3: 0x1080  (+64 байта)  ──► Шаблон подтвержден!

Prefetcher самостоятельно (на опережение) отправляет запрос:
        0x10C0  (затягивается в L1/L2 фоном, пока вы считаете 0x1080!)

Если контроллер замечает устойчивый шаг:

  • Кэш-линия 0x1000
  • Затем кэш-линия 0x1040 (+64 байта)
  • Затем кэш-линия 0x1080 (+64 байта)

Prefetcher делает вывод: код выполняет последовательный обход непрерывного массива с шагом в одну кэш-линию (Stride = 1).
Не дожидаясь, пока процессор завершит работу с текущими байтами и запросит адрес 0x10C0, Prefetcher в фоновом режиме отправляет упреждающий запрос по шине памяти в DRAM.

К тому моменту, когда счетчик цикла доберется до адреса 0x10C0, данные уже будут заботливо лежать в кэше L1 или L2! Задержка доступа в 80–100 нс оказывается полностью замаскирована, превращаясь в 1 наносекунду.


Mechanical Sympathy: Помогаем Prefetcher'у на Go

С точки зрения кремния, идеальный высокопроизводительный код на Go — это код, в котором Prefetcher угадывает 100% обращений к памяти.

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

1. Pointer Chasing (Погоня за указателями)

Слайс указателей []*User или узлы связного списка container/list — злейшие враги предвыборщика.

Когда вы обходите слайс указателей:

  1. Вы последовательно читаете адрес 0x9000 из самого слайса.
  2. Процессор совершает переход и читает данные структуры в куче по адресу 0x9000.
  3. На следующем шаге из слайса читается адрес 0x7500.
  4. Процессор вынужден прыгать по адресу 0x7500.

Для кремниевого контроллера цепочка 0x9000 -> 0x7500 выглядит как абсолютно хаотичный белый шум. Аппаратный Prefetcher не видит никакой закономерности и отключается. В итоге программа собирает полный штраф DRAM на каждом элементе! Классические структуры данных вроде связных списков, бинарных деревьев поиска и графов на указателях аппаратно работают в десятки раз медленнее массивов именно по этой причине.

2. Шаг итерации больше размера кэш-линии

Даже если данные лежат в едином непрерывном куске памяти, неоптимальный размер структуры может дезориентировать контроллер:

Go
package main

type BigStruct struct {
    UsefulData int64     // 8 байт полезной нагрузки
    Padding    [248]byte // Мусор для раздутия размера (структура ровно 256 байт)
}

func sumUsefulData(items []BigStruct) int64 {
    var sum int64
    for i := range items {
        sum += items[i].UsefulData
    }
    return sum
}

В данном примере срез items монолитен. Однако полезное поле UsefulData запрашивается с огромным шагом в 256 байт (прыжок через 4 кэш-линии за одну итерацию!).
Многие аппаратные детекторы шага (IP-stride prefetcher) отслеживают ограниченные шаги — порядка до 2 КБ, но их эффективность падает с ростом шага, а соседние линии могут подтягиваться и без детектора. Главное здесь иное: шина памяти загружается не полезными данными — на каждые 8 прочитанных байт загружается целая 64-байтная кэш-линия (в 8 раз больше нужного), хотя «прыжок» через 256 байт на каждый элемент означает, что из каждых 4 линий полезна лишь одна.


Итог

  1. Чтобы связать гигантскую память и компактный кэш без взрывного роста числа транзисторов, применяется N-Way Set Associative архитектура. Кэш делится на наборы (Sets), каждый из которых содержит $N$ параллельных линий (Ways).
  2. Промахи кэша подчиняются Правилу Трех C: Compulsory (первое обращение), Capacity (нехватка объема) и Conflict (конфликт за слоты одного сета).
  3. Многомерные массивы с размерностями степени двойки чреваты Conflict Miss: данные многократно выбивают друг друга из одного сета кэша при пустующих соседних. Лекарство — добавление искусственного отступа (Padding).
  4. Аппаратный Prefetcher распознает линейные шаблоны чтения памяти и фоном затягивает кэш-линии из медленной DRAM в быстрый кэш L1, нивелируя задержки.
  5. Для максимальной дружбы с «железом» предпочитайте плоские слайсы значений []struct. Избегайте развесистых графов на неструктурированных указателях или собирайте их узлы внутри монолитных арен памяти.

Мы детально изучили, как работает подсистема памяти в пределах одного процессорного ядра. Но в наших серверах трудятся десятки ядер!
Что произойдет, если горутина на Ядре №1 обновит переменную в своем локальном кэше L1, а горутина на Ядре №2 в ту же наносекунду попытается её прочитать? Как процессоры синхронизируют локальные кэши без хаоса и гонок данных?

Об этом мы поговорим в следующей фундаментальной главе: 20. Многоядерные процессоры и Cache Coherence.