TLB и стоимость трансляции
Предпосылки: трансляция и изоляция (page table, PGD/PUD/PMD/PTE, CR3, MMU), иерархия кешей (L1/L2/L3, hit/miss), когерентность кешей (MESI).
← Трансляция и изоляция | Page fault и отложенные механизмы →
Четыре чтения из RAM на каждую трансляцию, 240–400 нс на одно обращение к памяти. Казалось бы, трансляция — это одно сложение с CR3, быстро и бесплатно. На деле адрес проходит через четырёхэтажное дерево, и каждый этаж — отдельный поход в RAM. Типичная программа делает сотни миллионов обращений в секунду. 400 нс на каждое — это замедление процессора в десятки раз. Но реально работающие программы не тормозят: между процессором и page table стоит промежуточный уровень, который прячет большую часть походов.
TLB: кеш трансляций
Четыре обращения к RAM на каждую трансляцию — неприемлемо. Ответ — тот же, что для любой дорогой операции в CPU: кеш. TLB (Translation Lookaside Buffer, буфер ассоциативной трансляции) — аппаратный кеш внутри MMU, хранящий результаты недавних трансляций: пары «номер виртуальной страницы → номер физического фрейма + атрибуты».
Параллель с L3 для данных прямая:
Обращение к данным: Трансляция адреса:
| |
v v
+----------+ +----------+
| L1 data | 1 такт | L1 TLB | 1 такт
+----------+ +----------+
| miss | miss
v v
+----------+ +----------+
| L2 | ~10 тактов | L2 TLB | ~8 тактов
+----------+ +----------+
| miss | miss
v v
+----------+ +----------+
| L3 | ~40 тактов | Page | 4 × RAM чтения,
+----------+ | walk | 10-400 нс
| miss +----------+
v |
+----------+ | (промежуточные PGD/PUD/PMD
| RAM | ~60-100 нс | обычно в L1/L2 data cache)
+----------+ v
физический фреймДве кешовые иерархии работают параллельно: одна для данных (что прочесть из RAM), другая для трансляций (по какому физическому адресу читать). Каждая инструкция load/store проходит через обе.
TLB — полностью ассоциативный кеш (fully associative). В обычных кешах (L1, L2) адрес определяет позицию в кеше — конкурируют только линии с одинаковым индексом. В TLB каждая запись может хранить любую трансляцию, и при поиске все записи сравниваются параллельно. Это возможно, потому что TLB маленький: L1 DTLB — десятки записей, L2 TLB — порядка тысячи (для сравнения: L1 кеш данных — тысячи кеш-линий). Малый размер позволяет параллельное сравнение за один такт.
Типовые параметры для процессора Intel Core (Skylake-архитектура, числа зависят от микроархитектуры и конкретной модели):
- L1 DTLB (data TLB): 64 записи для 4 КБ страниц, 32 записи для 2 МБ huge pages
- L2 TLB (unified): 1536 записей
- TLB hit: ~1 такт (~0.3 нс при 3 ГГц)
- TLB miss → page walk (полный обход 4 уровней): 10–400 нс в зависимости от того, попадут ли промежуточные таблицы в L1/L2 data cache
Hit rate TLB при типичной нагрузке — 99%+. 64 записи L1 DTLB покрывают 64 × 4 КБ = 256 КБ. С L2 TLB на 1536 записей — 6 МБ. Для программы с рабочим набором до 6 МБ практически каждое обращение попадает в TLB.
Промежуточные таблицы (PGD, PUD, PMD) — обычные страницы в RAM, и они попадают в L1/L2 кеш данных процессора. Если процесс обращается к соседним виртуальным адресам (пространственная локальность), все четыре уровня таблиц уже лежат в кеше, и page walk обходится в 10–20 нс вместо 400 нс. Именно поэтому последовательный обход массива почти не страдает от TLB-промахов: промахи есть, но page walk быстрый. Случайный доступ к памяти — другое дело: каждое обращение может затронуть другую ветвь дерева, кеш не помогает, и page walk стоит полные 100+ нс.
Когда рабочий набор вырастает — hit rate падает, и программа начинает тратить десятки наносекунд на каждый page walk. Базы данных с рабочим набором в сотни гигабайт теряют 10–20% производительности на TLB-промахах. Одна из причин использовать huge pages (2 МБ): одна запись TLB покрывает не 4 КБ, а 2 МБ — в 512 раз больше. 64 записи L1 DTLB для huge pages покрывают 64 × 2 МБ = 128 МБ вместо 256 КБ. PostgreSQL поддерживает huge pages через параметр huge_pages = on, и для shared_buffers в 8 ГБ это снижает TLB-промахи на порядок.
Переключение контекста и PCID
Запись нового значения в CR3 при переключении контекста инвалидирует TLB — все записи становятся невалидными. Это логично: трансляции старого процесса не имеют смысла для нового. Но цена высока: после переключения первые десятки обращений к памяти идут через полный page walk. На машине, переключающей контекст каждые ~4 мс (типичный квант планировщика Linux), TLB-промахи после переключения могут составлять 5–10% от всех обращений.
Процессоры смягчают это через PCID (Process Context ID) — 12-битный тег, позволяющий хранить в TLB трансляции нескольких процессов одновременно (аппаратно до 4096; Linux использует небольшой пул на каждое ядро). Каждому процессу назначается PCID из пула, и записи TLB помечаются этим тегом. С PCID переключение контекста не сбрасывает TLB целиком — при возврате к процессу его трансляции всё ещё в TLB, «холодный старт» после переключения исчезает.
Аппаратная поддержка PCID появилась ещё в Westmere (2010), но Linux долго не использовал её из-за сложности интеграции с KPTI (митигация Meltdown). Поддержку PCID в ядре включили начиная с версии 4.14 (2017). До этого каждое переключение контекста полностью сбрасывало TLB. С PCID переключение стало дешевле на 1–2% — что существенно для серверов с сотнями процессов и частыми переключениями.
PCID решает проблему переключения между процессами. Но у устаревших трансляций есть второй, куда более коварный источник — на многоядерных машинах.
TLB shootdown: инвалидация на других ядрах
На многоядерном CPU у каждого ядра свой TLB. Когда ядро операционной системы изменяет page table — munmap() освобождает регион, mprotect() меняет права, copy-on-write ломает разделение фрейма — TLB на других ядрах, исполняющих потоки того же процесса (несколько потоков одного процесса делят адресное пространство и могут одновременно исполняться на разных ядрах), продолжает хранить старые трансляции. Поток на ядре 3 может читать память, которую ядро 0 уже пометило как недоступную. PCID здесь не помогает: проблема не в переключении контекста, а в изменении page table текущего процесса, трансляции которого валидно лежат на нескольких ядрах одновременно.
Ядро ОС решает это через TLB shootdown: отправляет IPI (Inter-Processor Interrupt, межпроцессорное прерывание) каждому ядру, использующему это адресное пространство. Получившее IPI ядро выполняет инструкцию INVLPG (invalidate page — сбросить одну запись TLB) или полный flush, в зависимости от объёма изменений. Горячий путь в ядре Linux — функция flush_tlb_mm_range().
Логика та же, что в протоколе MESI (Modified, Exclusive, Shared, Invalid) для обычных кешей: когда одно ядро меняет разделяемые данные, остальные должны узнать об этом и пометить свои копии как невалидные. MESI инвалидирует кеш-линии при изменении данных, shootdown инвалидирует TLB-записи при изменении трансляций. Разница в реализации: MESI встроен в аппаратный протокол межъядерной коммуникации, а shootdown — программная процедура, которую инициирует ядро ОС.
Стоимость TLB shootdown — 1–10 мкс на каждое ядро-получатель: IPI пересекает interconnect, прерывает исполнение, процессор выполняет инвалидацию и возвращается к работе. На 128-ядерном сервере shootdown может затронуть десятки ядер одновременно.
Сценарий: munmap() тормозит соседние ядра
Поток-компилятор JIT (Just-In-Time, например V8 в Node.js или HotSpot JVM) на ядре 0 выделил 16 МБ через mmap(), скомпилировал туда машинный код, отработал — и вызвал munmap(), чтобы вернуть память ядру. Пока поток работал, ядро планировщика раскинуло другие потоки того же процесса по ядрам 1, 2, 3, …, 15. Каждое из этих ядер уже загрузило в свой TLB трансляции, включающие те страницы, которые munmap() сейчас удаляет из page table.
Ядро ОС выполняет шаги:
- Снимает VMA, покрывающий 16 МБ, из списка регионов процесса.
- Для каждой страницы диапазона очищает PTE в page table.
- Читает маску CPU, на которых исполнялись потоки этого процесса с момента последнего shootdown.
- Отправляет IPI всем этим ядрам. Ядра 1–15 получают прерывание, прерывают текущую работу, исполняют
INVLPGдля каждой страницы диапазона (или полный TLB flush, если страниц слишком много), возвращаются к работе. - Ждёт, пока все ядра подтвердят выполнение.
Пока IPI не подтверждены, ядро 0 не может продолжить — оно должно гарантировать, что ни одно другое ядро не прочитает удалённый фрейм по устаревшей TLB-записи. Типовая стоимость — 5 мкс на IPI × 15 ядер = 75 мкс общего времени ответа. Для потока, который сам делает миллионы операций в секунду, это пауза в десятки микросекунд без видимой причины, с точки зрения profiler’а.
Приложения, активно работающие с mmap()/munmap() — JIT-компиляторы (V8, HotSpot JVM, LuaJIT) постоянно выделяют и освобождают регионы под скомпилированный код — получают shootdown-нагрузку пропорционально частоте ремаппинга. В Linux для частичного обхода есть MADV_FREE (ядро может оставить страницы в памяти и освободить их только под давлением) и lazy TLB modes (ядро откладывает дорогой flush/переключение TLB для ядер, реально не использующих это адресное пространство), но полностью shootdown не убирается.
Итог
TLB превращает четыре чтения из RAM на каждое обращение к памяти в один такт — и делает многоуровневую page table практичной. Но как любой кеш с когерентностью, TLB платит за это моментами инвалидации: PCID убирает цену переключения контекста, shootdown убирает цену изменения page table — и тот же shootdown становится видимой стоимостью на машинах с десятками ядер и активным ремаппингом памяти. Когда профилировщик показывает «провалы» на уровне микросекунд без вызовов в ядре, стоит посмотреть на соседние ядра: возможно, один из них делает munmap(), а ваш поток его ждёт.
Осталась третья ветка трансляции — page fault, когда MMU не может завершить трансляцию и передаёт управление ядру. Это не только обработка ошибок: на page fault построены три отложенные оптимизации, которые делают современный Linux возможным.
Sources
- Michael Kerrisk, 2010, The Linux Programming Interface — Chapter 49: Memory Mappings — https://man7.org/tlpi/
- Intel Corporation, 2024, Intel 64 and IA-32 Architectures Software Developer’s Manual — Volume 3A: System Programming Guide, Chapter 4: Paging — https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html
- Andrea Arcangeli, 2010, Transparent Hugepage Support — Linux Kernel documentation — https://www.kernel.org/doc/html/latest/admin-guide/mm/transhuge.html
← Трансляция и изоляция | Page fault и отложенные механизмы →