ОС · Виртуализация памяти · 15 мин
TLB: кэш трансляций
Откуда взялась проблема
В прошлой главе (paging) мы научились бить память на страницы и держать таблицу страниц — список, который говорит: «виртуальная страница номер N живёт в физическом фрейме номер M». Удобно, гибко, фрагментации почти нет. Но за эту красоту мы платим.
Вспомним, что происходит при каждом обращении к памяти. Программа хочет прочитать переменную по виртуальному адресу. Чтобы понять, где она лежит физически, процессор должен:
- Сходить в память за нужной записью таблицы страниц (PTE — page table entry).
- Достать оттуда номер фрейма.
- Только теперь сходить в память за самими данными.
Заметили? Там, где раньше был один поход в память, теперь их два. То есть страничная организация в наивном виде делает программу примерно вдвое медленнее. Для системы, где обращения к памяти — самая частая операция вообще, это катастрофа.
Наивный paging: каждое чтение данных = 2 похода в RAM
CPU хочет прочитать addr
│
▼
┌───────────────┐ поход #1 (медленно, ~100 нс)
│ читаем PTE из │◄──────────────── RAM
│ таблицы в RAM │
└───────┬───────┘
│ узнали номер фрейма
▼
┌───────────────┐ поход #2 (медленно, ~100 нс)
│ читаем данные │◄──────────────── RAM
└───────────────┘
Итог: 2× медленнее. Так жить нельзя.Нужно как-то избавиться от похода #1 в большинстве случаев. И тут на сцену выходит TLB.
Что такое TLB
TLB (Translation Lookaside Buffer, «буфер трансляции») — это маленький быстрый кэш прямо внутри MMU (того блока процессора, что занимается трансляцией адресов). В нём хранятся недавно использованные переводы «виртуальная страница → физический фрейм».
Бытовая аналогия. Таблица страниц — это толстый телефонный справочник на полке: там есть номер любого человека, но идти к полке и листать долго. TLB — это стикеры на мониторе с номерами тех людей, кому вы звоните каждый день. Нужен частый номер — глянул на стикер за полсекунды. Нужен редкий — пошёл к справочнику.
Важно: TLB кэширует не данные, а трансляции. Одна строка TLB примерно говорит: «страница 0x5 → фрейм 0x91, и вот её права доступа».
Где живёт TLB
┌──────────────────────── CPU ────────────────────────┐
│ │
│ ядро ┌─────────── MMU ───────────┐ │
│ (исполняет │ │ │
│ инструкции) ─►│ ┌────────────────────┐ │ │
│ │ │ TLB │ │ │
│ │ │ (десятки строк, │ │ │
│ │ │ доступ ~1 такт) │ │ │
│ │ └────────────────────┘ │ │
│ └────────────┬───────────────┘ │
└──────────────────────────────┼──────────────────────┘
│ только при промахе
▼
┌──────────────────┐
│ таблица страниц │ (медленно)
│ в RAM │
└──────────────────┘Hit и miss: две ветви жизни
У любого кэша есть два исхода обращения: попадание (hit) — нужное нашлось, и промах (miss) — не нашлось. Разберём оба для TLB.
При обращении к памяти MMU сначала берёт номер виртуальной страницы (VPN — virtual page number) и ищет его среди строк TLB.
- TLB hit. Строка нашлась. Номер фрейма берём прямо из TLB — это почти бесплатно, ни одного лишнего похода в RAM. Сразу читаем данные. Это «горячий путь», и мы хотим, чтобы он случался почти всегда.
- TLB miss. Строки нет. Придётся идти в таблицу страниц в памяти (медленный поход #1), достать PTE, записать эту трансляцию в TLB на будущее и повторить обращение — теперь уже как hit.
Путь доступа к памяти с TLB
CPU выдаёт виртуальный адрес
│
извлекаем VPN (номер страницы)
│
▼
┌──────────────────┐
│ VPN есть в TLB ? │
└───────┬──────┬────┘
ДА │ │ НЕТ
(TLB hit) (TLB miss)
│ │
▼ ▼
фрейм берём из идём в таблицу страниц
TLB, мгновенно в RAM (медленно),
│ достаём PTE,
│ кладём в TLB,
│ повторяем обращение
│ │
└──────┬───────┘
▼
читаем данные по
физическому адресуСтоимость двух исходов несравнима:
| Исход | Что делаем | Лишние походы в RAM | Цена (порядок) |
|---|---|---|---|
| TLB hit | берём фрейм из TLB | 0 | ~1 такт, почти даром |
| TLB miss | читаем PTE из таблицы, заполняем TLB | 1 (а с многоуровневой таблицей — несколько) | десятки–сотни тактов |
Отсюда сразу видна цель: максимизировать долю hit. Если из 1000 обращений 990 попали в TLB, то средняя цена трансляции близка к нулю, и paging почти не стоит нам производительности.
Почему TLB вообще работает: локальность
Кэш крошечный (часто 64–512 строк), а виртуальное пространство огромно. Почему же hit'ов так много? Потому что программы обращаются к памяти не случайно, а с локальностью:
- Пространственная локальность. Если тронули один байт, скоро тронем соседний. Массив, поля структуры, инструкции функции лежат рядом — всё это попадает в ОДНУ страницу. Первое обращение к странице — miss, а сотни следующих по этой же странице — hit'ы.
- Временная локальность. Если страницу трогали недавно, скорее всего тронем снова (цикл крутится по тем же данным, та же функция вызывается).
Проход по массиву из 4096 int (страница = 4 КБ = 1024 int)
i: 0 1 2 ... 1023 1024 1025 ... 2047 ...
│ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼
[miss][hit][hit]...[hit][miss][hit]...[hit]...
│ │
1-й байт новой перешли на
страницы — промах новую страницу
4096 обращений, а промахов всего 4 (по числу страниц).
Hit rate = 4092/4096 ≈ 99.9 %. Вот почему TLB спасает.Вывод для практики: код, который ходит по памяти последовательно (по порядку), дружит с TLB. Код, который прыгает случайно по огромной области (например, обход большого хеша или связного списка с разбросанными узлами), бьёт мимо TLB и тормозит, даже если данные формально в RAM.
Что лежит в одной строке TLB
Строка TLB — это не только пара «страница → фрейм». Туда же копируются биты из PTE, чтобы при hit'е не лезть в таблицу вообще ни за чем.
Одна строка (запись) TLB
┌─────────┬───────────┬──────┬─────┬───────┬──────┐
│ VPN │ PFN │ valid│ prot│ dirty │ ASID │
│ (тег: │ (номер │ бит │права│ бит │ чей │
│ какая │ фрейма) │ │ R/W │ грязи │ это │
│ страница│ │ │ /X │ │ proc)│
└─────────┴───────────┴──────┴─────┴───────┴──────┘
▲ ищем по нему ▲ что отдаём при hit- VPN — тег, по нему ищем строку.
- PFN — номер физического фрейма, главный результат.
- valid — действительна ли строка TLB (не путать с valid-битом в PTE!).
- prot — права: можно ли читать/писать/исполнять. Нарушил — защитная ошибка.
- dirty — менялась ли страница (нужно при вытеснении, см. swapping).
- ASID — про него ниже, метка владельца.
Цена переключения контекста: flush или ASID
А теперь самое коварное. TLB хранит трансляции текущего процесса. Но у каждого процесса своя таблица страниц: у процесса A страница 0 → фрейм 10, а у процесса B та же страница 0 → фрейм 88. Виртуальные адреса совпадают, физические — разные.
Что будет, если переключиться с A на B и не тронуть TLB? Процесс B обратится к своей странице 0, попадёт в строку TLB, оставшуюся от A, и прочитает чужой фрейм 10. Это и утечка данных, и порча памяти — катастрофа изоляции.
Опасность после context switch (если ничего не сделать)
Было (выполнялся A): TLB: VPN0 → PFN10 (это память A!)
Переключились на B...
B читает свою страницу 0
│
▼
TLB hit → PFN10 ← ЧУЖОЙ фрейм процесса A!
B видит данные A. Изоляция сломана.Есть два способа это починить:
1. Flush (сброс) TLB при каждом переключении. Просто помечаем все строки невалидными. Просто и безопасно. Минус: после переключения TLB пустой, и первые обращения нового процесса — сплошные промахи, пока кэш не «прогреется» заново. Это часть той самой цены переключения контекста, о которой шла речь в limited-direct-execution: переключение стоит дорого не только из-за сохранения регистров, но и из-за холодных кэшей и TLB.
Flush: чистим всё на каждом switch
switch A → B: TLB → [пусто]
B стартует → промах, промах, промах... (прогрев)2. ASID (Address Space ID) — метим строки владельцем. В каждую строку TLB кладём маленький номер адресного пространства. Процессор знает, чей сейчас ASID, и считает hit'ом только строку с совпадающим ASID. Тогда трансляции A и B мирно живут в TLB одновременно, и flush при переключении не нужен — после возврата к A его строки всё ещё там.
ASID: строки разных процессов сосуществуют
┌──────┬──────┬───────┐
│ ASID │ VPN │ PFN │
├──────┼──────┼───────┤
│ A │ 0 │ 10 │ ◄ при работе A — hit только тут
│ B │ 0 │ 88 │ ◄ при работе B — hit только тут
│ A │ 1 │ 23 │
└──────┴──────┴───────┘
switch A → B: ничего не чистим, просто меняем текущий ASID.| Подход | Плюс | Минус |
|---|---|---|
| Flush при switch | просто, безопасно | холодный TLB после каждого переключения → волна промахов |
| ASID | нет лишних flush, кэш переживает switch | нужна аппаратная поддержка; ASID мало → иногда всё равно переиспользуют и чистят |
В реальности (x86 это PCID, ARM — ASID) современные процессоры используют метки, чтобы переключения были дешевле. Но ASID-ов конечное число, и при их нехватке ОС всё равно вынуждена сбрасывать TLB.
TLB reach и huge pages
TLB reach («охват TLB») — сколько памяти суммарно покрывают все строки TLB. Формула проста:
TLB reach = (число строк TLB) × (размер страницы)
Пример при странице 4 КБ и 64 строках:
64 × 4 КБ = 256 КБ
Программа активно работает с 256 КБ → почти всё в TLB → отлично.
Программа гоняет 2 ГБ данных → 256 КБ покрытия = капля в море
→ постоянные промахи TLB, даже если данные в RAM.Видно проблему: TLB маленький, и для «толстых» приложений (большие базы данных, in-memory кэши, аналитика на гигабайтах) охвата катастрофически не хватает. Каждая новая страница — промах.
Увеличить число строк дорого (это быстрая дефицитная память в ядре CPU). Поэтому крутят второй множитель — размер страницы. Это huge pages (большие страницы): вместо 4 КБ — 2 МБ или 1 ГБ на страницу.
Тот же 64-строчный TLB, но страницы 2 МБ:
64 × 2 МБ = 128 МБ охвата (было 256 КБ!)
Рост покрытия в 512 раз теми же строками TLB.Одна строка TLB теперь покрывает в 512 раз больше памяти, и большое приложение помещается в TLB. Бонусом таблица страниц становится мельче и многоуровневый обход короче (см. smaller-tables). Плата за huge pages — более грубая гранулярность: внутренняя фрагментация растёт (выделили 2 МБ ради маленькой структуры — почти всё впустую), а свопить такие страницы тяжелее.
Связь с Go и продом
Это не абстракция. В проде huge pages регулярно ускоряют тяжёлые сервисы:
- Базы и кэши (PostgreSQL, Redis, JVM-приложения) часто настраивают на huge pages именно ради TLB reach — иначе сервис, упирающийся в random-доступ к десяткам гигабайт, тратит заметную долю времени на промахи трансляции.
- Go-рантайм. Сборщик мусора Go ходит по куче, трогая множество объектов в
разных местах — это нагрузка на TLB. На Linux Go-хип может ложиться на
transparent huge pages, и поведение THP иногда влияет на latency и RSS
(исторически THP бывал источником латентных всплесков, и его то включают, то
отключают для Go-сервисов). Когда профилируете «непонятные» тормоза на больших
данных — TLB-промахи и THP стоит держать в списке подозреваемых
(
perf statпоказываетdTLB-load-misses). - Латентность. Промах TLB на многоуровневой таблице — это несколько последовательных чтений RAM ради одной трансляции. На хвостах (p99) это заметно.
- Размер страницы = 2^4 = 16 Б, младшие 4 бит — смещение (offset)
- va = 35 → VPN = 2, offset = 3
- TLB: VPN 2 найден — TLB hit, таблицу страниц читать не нужно
- pa = PFN 5 · 16 + 3 = 83
Какую главную проблему страничной организации решает TLB?
Что происходит при TLB miss?
Благодаря каким свойствам обращений к памяти TLB даёт высокий процент попаданий?
Зачем в строке TLB нужен ASID (PCID)?
TLB имеет 64 строки, размер страницы 4 КБ. Чему равен TLB reach в килобайтах?
Что даёт включение huge pages (например 2 МБ вместо 4 КБ)?
Что спрашивают на собесе
- Зачем нужен TLB и какую проблему он решает? Ответ: paging требует лишнего похода в память за PTE на каждое обращение (2× замедление); TLB кэширует трансляции, и при hit'е лишний поход исчезает.
- Чем отличается TLB hit от miss по стоимости? Hit — фрейм из TLB, ноль лишних обращений к RAM; miss — идём в таблицу страниц (одно или несколько чтений при многоуровневой таблице), заполняем TLB, повторяем.
- Почему TLB вообще даёт высокий hit rate при таком малом размере? Локальность: пространственная (соседние адреса в одной странице) и временная (повторные обращения к тем же страницам).
- Что происходит с TLB при переключении контекста и почему это часть его цены? Либо flush (затем холодный TLB и волна промахов), либо ASID/PCID (строки процессов сосуществуют, flush не нужен). Это объясняет, почему context switch дорог.
- Что такое TLB reach и как его увеличить? reach = число строк × размер страницы; увеличивают через huge pages (2 МБ/1 ГБ), что резко поднимает охват теми же строками. Минус — внутренняя фрагментация.
- Что общего и в чём разница между valid-битом в PTE и valid-битом в строке TLB? В PTE valid говорит, отображена ли страница вообще (иначе — page fault); в TLB valid говорит лишь, актуальна ли строка кэша (после flush — невалидна).