29. TLB. Translation Lookaside Buffer и стоимость промаха

В прошлой главе (28. Page Table, MMU и трансляция адресов) мы столкнулись с пугающей архитектурной реальностью: иерархические 4-уровневые таблицы страниц (Page Tables) спасли нас от чудовищного перерасхода памяти, но поставили под угрозу само быстродействие компьютера.

Если каждое банальное обращение к памяти по виртуальному адресу требует от процессора четырех последовательных спусков по уровням дерева (PML4, PDP, PD и PT), то в худшем случае (если бы записи таблиц страниц каждый раз приходилось читать из DRAM) чтение одной переменной в Go занимало бы не 1–2 наносекунды, а сотни наносекунд. Наш современный суперскалярный CPU с тактовой частотой 4 ГГц превратился бы в невероятно сложную микросхему, которая почти всё время покорно ждет ответов от медленной оперативной памяти DRAM. (В реальности записи таблиц страниц частично кэшируются, так что типичная цена обхода — десятки наносекунд, как на схеме ниже.)

Чтобы спасти систему от аппаратного ступора, инженеры применили проверенный веками метод: кэширование. Для обхода многоуровневого «налога на трансляцию» прямо в кристалл MMU встроили специализированный сверхоперативный буфер — TLB (Translation Lookaside Buffer).


Что такое TLB?

Представьте, что для каждого звонка по телефону вам требовалось бы сначала доехать до центральной городской библиотеки, отыскать в архиве нужный шкаф, достать увесистый телефонный справочник, дойти по алфавиту до нужной фамилии и лишь затем набрать номер. Вряд ли вы стали бы делать больше двух звонков в день.

Гораздо разумнее выписать двадцать самых частых телефонных номеров на крошечный стикер и приклеить его прямо на край монитора. Этот стикер на мониторе процессора и есть TLB.

TLB (Буфер ассоциативной трансляции / Translation Lookaside Buffer) — это компактный, но экстремально быстрый аппаратный кэш ассоциативной памяти (CAM), расположенный непосредственно внутри блока MMU каждого процессорного ядра. Его задача — хранить готовые соответствия между виртуальными номерами страниц (VPN) и физическими фреймами оперативной памяти (PFN).

Если провести параллель с языком Go, TLB — это аппаратная map[uintptr]uintptr на стероидах, вытравленная в кремнии, способная отвечать на запросы за доли такта.

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
    VA["Виртуальный адрес (от CPU)"]:::system --> MMU["Блок MMU"]
    MMU --> TLB{"Поиск VPN в TLB?"}

    TLB -->|"TLB Hit (99% случаев)"| HIT["TLB Hit (Попадание)<br>Задержка: ~0.5-1 такт (< 1 ns)"]
    HIT --> PA["Физический адрес готов!<br>Запрос в L1-кэш данных"]

    TLB -->|"TLB Miss (Промах)"| MISS["TLB Miss (Промах)<br>Запуск Hardware Page Walk"]
    MISS --> WALK["Обход 4 уровней таблиц страниц<br>(PML4 -> PDP -> PD -> PT)<br>Штраф: 40-100+ ns!"]
    WALK --> UPDATE["Запись новой пары VPN -> PFN в TLB"]
    UPDATE --> PA
  

Алгоритм трансляции адреса:

  1. Вычислительное ядро процессора выполняет инструкцию чтения или записи по виртуальному адресу.
  2. MMU извлекает старшие биты адреса — виртуальный номер страницы (VPN).
  3. MMU параллельно опрашивает все ячейки кэша TLB.
  4. TLB Hit (Попадание в кэш): Если данный виртуальный номер уже есть в буфере, MMU мгновенно (за 0.5–1 такт процессора) выдает соответствующий номер физического фрейма (PFN), склеивает его со смещением (Offset) и отправляет запрос в кэш L1d. Никаких обращений к медленной таблице страниц не происходит!
  5. TLB Miss (Промах кэша): Если соответствие не найдено, запускается процедура Hardware Page Walk — мучительный аппаратный обход 4-уровневого дерева таблиц страниц в оперативной памяти. Конвейер замирает, процессор теряет от 40 до 100 наносекунд. Найдя заветный физический адрес фрейма, MMU сохраняет пару «VPN $\rightarrow$ PFN» в TLB (вытесняя самую старую запись по алгоритму LRU) и наконец отдает данные процессору.

Анатомия TLB: Иерархия и дефицит емкости

Подобно кэшам инструкций и данных (с которыми мы познакомились в 18. Кэши CPU. L1, L2, L3 и Cache Line), буфер трансляции адресов построен по многоуровневой схеме:

  • L1 TLB (Level 1): Расположен вплотную к исполнительным конвейерам. Разделен по гарвардской архитектуре:
    • ITLB (Instruction TLB): обслуживает выборку машинных команд (обычно 64–128 записей).
    • DTLB (Data TLB): обслуживает чтение и запись данных (обычно 64 записи).
    • Скорость ответа: ~1 такт (менее 0.5 нс).
  • L2 TLB (Level 2, или STLB — Shared / Second-level TLB):
    • Единый унифицированный буфер для инструкций и данных.
    • Вмещает порядка 1024–2048 записей (в некоторых серверных чипах — до 4096; конкретные числа зависят от микроархитектуры и от статьи к статье могут отличаться).
    • Скорость ответа: ~7–12 тактов.

Предел покрытия TLB (TLB Reach)

А теперь сопоставим цифры и оценим масштаб фундаментального узкого места:
$$\text{Емкость L2 TLB} = 1024\text{ записи} \times 4\text{ КБ (размер страницы)} = \mathbf{4\text{ Мегабайта}!}$$

Вдумайтесь в это значение: современный сервер несет 128 Гигабайт оперативной памяти, а процессор способен одновременно «держать в уме» физические адреса всего для 4 Мегабайт активных данных!

Если рабочее множество вашей программы превышает скромные 4–8 МБ, процессор начинает непрерывно промахиваться мимо TLB.


Mechanical Sympathy: TLB Thrashing в Go

Осознание дефицита емкости TLB дает ответ на классический вопрос: почему структуры данных, имеющие одинаковую асимптотическую сложность $\mathcal{O}(N)$, в реальном бенчмарке на Go демонстрируют колоссальную разницу в скорости?

1. Слайсы против связных списков и графов

Сравним два подхода к хранению элементов:

  • Срез значений ([]User): Гарантирует абсолютную непрерывность памяти. На одной 4-килобайтной странице размещается несколько сотен компактных структур. При обходе слайса процессор получает ровно один TLB Miss на старте страницы, а следующие сотни элементов читаются со 100% TLB Hit Rate!
  • Связный список (container/list) или дерево указателей: Каждый узел выделяется в куче независимо. Поле node.next ссылается на произвольные адреса. С высокой вероятностью каждый переход по указателю выбрасывает конвейер на совершенно новую, неизвестную страницу памяти.

Если ваш граф объектов занимает 100 МБ, а суммарный объем TLB покрывает лишь 4 МБ, наступает аппаратный коллапс — TLB Thrashing (Буксование буфера трансляции).

Новые записи начинают вытеснять из TLB только что загруженные страницы быстрее, чем алгоритм успевает их повторно прочитать. В этом состоянии процессор может тратить значительную долю времени (в худших случаях — десятки процентов, в зависимости от нагрузки и процессора) не на выполнение полезного Go-кода, а на беспрерывные циклы Hardware Page Walk!

2. Сборщик мусора и TLB

Сборщик мусора Go (Garbage Collector) в процессе своей работы выполняет фазу разметки (Marking Phase) — он сканирует все живые объекты в куче.

Если куча сервиса раздута до десятков гигабайт и состоит из миллионов мелких объектов, соединенных цепочками указателей, поток GC вынужден непрерывно перескакивать между тысячами страниц памяти.

Это порождает лавину промахов в TLB. При профилировании высоконагруженных систем часто наблюдается явление, когда во время работы фоновых потоков GC производительность основных рабочих горутин (Mutators) кратковременно проседает. Причина не только в конкуренции за такты CPU, но и в том, что сборщик мусора буквально вымывает полезные адреса из TLB, заставляя бизнес-код ловить штрафы аппаратного обхода страниц.

Использование пулов объектов sync.Pool, арен памяти и компактных структур — это в первую очередь борьба за локальность TLB: объекты, переиспользуемые из пула, удерживаются на компактном наборе «горячих» страниц памяти, адреса которых надежно прописаны в кремнии TLB.


Итог

  1. TLB (Translation Lookaside Buffer) — аппаратный ассоциативный кэш внутри блока MMU, сохраняющий соответствия виртуальных адресов страниц (VPN) физическим фреймам (PFN).
  2. TLB Hit занимает доли наносекунды (0.5–1 такт CPU); TLB Miss приводит к аппаратному спуску по 4-уровневой таблице страниц (Hardware Page Walk), обходясь в штраф от 40 до 100+ наносекунд.
  3. Емкость TLB крайне мала: 1024–2048 записей покрывают всего 4–8 Мегабайт адресного пространства.
  4. Технологии PCID (x86) и ASID (ARM) маркируют записи в TLB идентификаторами процессов, устраняя необходимость полного сброса кэша (TLB Flush) при переключении контекста.
  5. Хаотичные переходы по указателям (node.next, большие мапы) приводят к TLB Thrashing, снижая производительность бэкенда в разы.

Мы подошли к парадоксальному вызову современной архитектуры.
Базы данных (PostgreSQL, ClickHouse), брокеры сообщений (Kafka) и кеширующие сервисы (Redis) регулярно оперируют сотнями гигабайт памяти в одном процессе. Как мы только что подсчитали, TLB способен покрыть лишь жалкие мегабайты. Неужели базы данных обречены 90% времени проводить в ожидании трансляции адресов?

Как инженеры смогли увеличить покрытие TLB в сотни раз, не добавляя на кристалл ни одного нового транзистора?

Ответ кроется в революционной концепции больших страниц: 30. Huge Pages и Transparent Huge Pages.