GraphLMS

ОС
Начать

ОС · Виртуализация памяти · 15 мин

TLB: кэш трансляций

Откуда взялась проблема

В прошлой главе (paging) мы научились бить память на страницы и держать таблицу страниц — список, который говорит: «виртуальная страница номер N живёт в физическом фрейме номер M». Удобно, гибко, фрагментации почти нет. Но за эту красоту мы платим.

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

  1. Сходить в память за нужной записью таблицы страниц (PTE — page table entry).
  2. Достать оттуда номер фрейма.
  3. Только теперь сходить в память за самими данными.

Заметили? Там, где раньше был один поход в память, теперь их два. То есть страничная организация в наивном виде делает программу примерно вдвое медленнее. Для системы, где обращения к памяти — самая частая операция вообще, это катастрофа.

  Наивный 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) это заметно.
va
Виртуальный адрес = 35 (8 бит)
0010
VPN = 2
0011
offset = 3
TLB (кэш трансляций)
VPN 0VPN 2VPN 3→ TLB hit (таблицу не читаем)
Таблица страниц (VPN → кадр)
VPN 0кадр 2
VPN 1невалидна (не отображена)
VPN 2кадр 5
VPN 3кадр 0
VPN 4кадр 7
VPN 5невалидна (не отображена)
VPN 6кадр 1
VPN 7кадр 3
VPN 8невалидна (не отображена)
VPN 9кадр 4
VPN 10кадр 6
VPN 11невалидна (не отображена)
VPN 12невалидна (не отображена)
VPN 13кадр 8
VPN 14кадр 9
VPN 15невалидна (не отображена)
✓ трансляция успешнаPFN 5 · offset 3 = pa 83TLB hit: перевод взят из кэша трансляций
  1. Размер страницы = 2^4 = 16 Б, младшие 4 бит — смещение (offset)
  2. va = 35 → VPN = 2, offset = 3
  3. TLB: VPN 2 найден — TLB hit, таблицу страниц читать не нужно
  4. pa = PFN 5 · 16 + 3 = 83
В TLB закэшированы несколько VPN. Подвигай адрес: если его страница в TLB — это hit (таблицу не читаем, быстро); иначе miss — идём в таблицу и кладём перевод в TLB.
Проверь себя· TLB: кэш трансляций

Какую главную проблему страничной организации решает 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 — невалидна).
Страничная организация памятиКомпактные таблицы страниц (многоуровневые)