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
Алгоритм трансляции адреса:
- Вычислительное ядро процессора выполняет инструкцию чтения или записи по виртуальному адресу.
- MMU извлекает старшие биты адреса — виртуальный номер страницы (VPN).
- MMU параллельно опрашивает все ячейки кэша TLB.
- TLB Hit (Попадание в кэш): Если данный виртуальный номер уже есть в буфере, MMU мгновенно (за 0.5–1 такт процессора) выдает соответствующий номер физического фрейма (PFN), склеивает его со смещением (Offset) и отправляет запрос в кэш L1d. Никаких обращений к медленной таблице страниц не происходит!
- 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.
Итог
- TLB (Translation Lookaside Buffer) — аппаратный ассоциативный кэш внутри блока MMU, сохраняющий соответствия виртуальных адресов страниц (VPN) физическим фреймам (PFN).
- TLB Hit занимает доли наносекунды (0.5–1 такт CPU); TLB Miss приводит к аппаратному спуску по 4-уровневой таблице страниц (Hardware Page Walk), обходясь в штраф от 40 до 100+ наносекунд.
- Емкость TLB крайне мала: 1024–2048 записей покрывают всего 4–8 Мегабайт адресного пространства.
- Технологии PCID (x86) и ASID (ARM) маркируют записи в TLB идентификаторами процессов, устраняя необходимость полного сброса кэша (TLB Flush) при переключении контекста.
- Хаотичные переходы по указателям (
node.next, большие мапы) приводят к TLB Thrashing, снижая производительность бэкенда в разы.
Мы подошли к парадоксальному вызову современной архитектуры.
Базы данных (PostgreSQL, ClickHouse), брокеры сообщений (Kafka) и кеширующие сервисы (Redis) регулярно оперируют сотнями гигабайт памяти в одном процессе. Как мы только что подсчитали, TLB способен покрыть лишь жалкие мегабайты. Неужели базы данных обречены 90% времени проводить в ожидании трансляции адресов?
Как инженеры смогли увеличить покрытие TLB в сотни раз, не добавляя на кристалл ни одного нового транзистора?
Ответ кроется в революционной концепции больших страниц: 30. Huge Pages и Transparent Huge Pages.