Таблицы страниц — служебная структура ядра, которая отображает виртуальные адреса на физические. Их размер принято оценивать как долю от отображаемой памяти, и часто называют отношение 1/512. Это отношение верно лишь при одном условии, и когда оно нарушается, расход вырастает на порядки. Разберём, откуда берётся 1/512, как устроена иерархия уровней, что такое складываниепропуск уровня таблицы страниц, когда архитектура его не использует уровней и какие реальные аномалии расхода зафиксированы.
Откуда берётся 1/512 и когда это ломается
Одна страница данных занимает 4 KiB, а каждая запись PTEpage table entry, запись таблицы страниц, отображающая одну страницу виртуальной памяти на одну физическую — 8 байт. 512 записей по 8 байт дают ровно 4 KiB, отсюда и отношение 1/512.
Но это отношение справедливо только тогда, когда каждая страница отображается ровно один раз. Если одну и ту же физическую страницу отображают N адресных пространств, каждому из них нужны собственные PTE для этой страницы, и стоимость таблиц страниц становится примерно N/512 от отображаемой памяти. При N = 512 таблицы страниц стоят столько же, сколько сами страницы данных:
512 процессов × 8 байт = 4 KiBВ реальных нагрузках это давало заметные цифры. У Andrea Arcangeli сотни процессов, каждый из которых отображал одну и ту же 1 GiB разделяемой памяти, требовали около 2 MiB таблиц страниц на процесс. У Khalid Aziz 2000 процессов, отображающих одну и ту же 4 KiB страницу, требуют 16 KB PTE на эту страницу, а в худшем случае, когда каждый процесс отображает всю SGA, только PTE заняли бы 878 GB.
Случай, где таблицы страниц не освобождаются
Отдельно стоит случай, когда процесс имел 590 GiB RSSобъём физической памяти, реально занятой процессом в данный момент и 110 GiB таблиц страниц, хотя отображение такого объёма памяти должно требовать лишь около 1.2 GiB PTE. RSS важен потому, что он показывает фактически занятую память: если при таком RSS таблицы страниц занимают 110 GiB, значит расход на них несоразмерен отображаемому объёму. Здесь не было разделяемых отображений: это был один процесс, который сохранял таблицы страниц для освобождённой памяти.
Причина — в способе возврата памяти ядру. Нагрузка использовала jemalloc и tcmalloc, которые возвращают память через madvise(MADV_DONTNEED), а не через munmap(). MADV_DONTNEED освобождает страницы данных и очищает PTE, но оставляет таблицы страниц выделенными, поэтому пустые таблицы страниц накапливаются.
Иерархия уровней: от pte до pgd
Снизу вверх иерархия таблиц страниц состоит из уровней pte, pmdPage Middle Directory, уровень непосредственно выше pte, содержит PTRS_PER_PMD ссылок на pte, pudPage Upper Directory, введён для 4-уровневых таблиц; может быть неиспользуемым или свёрнутым, p4dPage Level 4 Directory, введён для 5-уровневых таблиц; используется только там, где действительно есть 5 уровней, иначе сворачивается и pgdPage Global Directory, главная таблица страниц: для памяти ядра лежит в swapper_pg_dir, у каждого процесса пользовательского пространства своя pgd в struct mm_struct.
pte — это массив из PTRS_PER_PTE элементов типа pteval_t. Типично pteval_t — 32- или 64-битное значение, где старшие биты хранят pfnpage frame number, номер физического фрейма: физический адрес страницы, делённый на PAGE_SIZE, а младшие — архитектурно-зависимые биты вроде защиты памяти. Размер и содержимое pteval_t определяет архитектура.
pmd — уровень непосредственно выше pte, содержащий PTRS_PER_PMD ссылок на pte. pud введён для 4-уровневых таблиц и может быть неиспользуемым или свёрнутым. p4d введён для 5-уровневых таблиц и используется только в системах, где действительно есть 5 уровней, иначе сворачивается.
pgd — главная таблица страниц. Для памяти ядра она находится в swapper_pg_dir, а каждый процесс пользовательского пространства имеет собственный pgd в struct mm_struct, на который ссылается struct task_struct.
Каждый уровень — это массив указателей: pgd содержит PTRS_PER_PGD указателей на следующий уровень ниже, p4d содержит PTRS_PER_P4D указателей на элементы pud, и так далее. PTRS_PER_PTEчисло элементов в массиве pte, задаётся архитектурой, PTRS_PER_PMDчисло ссылок на pte в массиве pmd, задаётся архитектурой и PTRS_PER_PGDчисло указателей на следующий уровень ниже в массиве pgd, задаётся архитектурой — это константы, определяющие, сколько записей содержит соответствующий уровень; их значения задаются архитектурой, а не выводятся из PAGE_SHIFT или разрядности адреса.
Как pgd связан с адресным пространством процесса
Для памяти ядра верхний уровень хранится в swapper_pg_dir. Когда для нового адресного пространства создаётся собственный pgd, в него копируется та часть записей, которая описывает память ядра: граница между пользовательской и ядерной областями адресного пространства задаёт, с какого места начинается этот диапазон, а его длина — сколько записей копируется. Так каждое адресное пространство получает одинаковое описание памяти ядра, не дублируя его вручную.
Связь pgd с адресным пространством устанавливается при его создании: адресное пространство запоминает свой pgd, а сам pgd хранит обратную ссылку на это адресное пространство, чтобы по таблице страниц можно было определить, какому адресному пространству она принадлежит.
Сдвиг страницы и раскладка фреймов
PAGE_SHIFT задаёт номер бита, с которого в адресе начинается базовый адрес страницы. При 4 КБ страницах базовый адрес использует биты 12–31, поэтому PAGE_SHIFT в этом случае определён как 12. Размер страницы получается сдвигом единицы влево на это число бит: PAGE_SIZE обычно определяется как (1 << PAGE_SHIFT).
Так как страницы имеют фиксированный размер, младшие биты адреса (ниже PAGE_SHIFT) указывают смещение внутри страницы, а старшие — номер страницы. Например, обращение к адресу 0x1500 попадает во вторую страницу виртуальной памяти, сопоставленную физической странице 0x5000–0x5FFF, поэтому реальный физический адрес — 0x5500.
Размер страницы задаёт шаг между физическими адресами последовательных фреймов. При гранулярности 4KB и 32-битном диапазоне адресов pfn 0 находится по адресу 0x00000000, pfn 1 — по 0x00001000, pfn 2 — по 0x00002000, и так далее до pfn 0xfffff по адресу 0xfffff000. При страницах 16KB те же pfn располагаются по адресам 0x00004000, 0x00008000 … 0xffffc000, а сам pfn пробегает значения от 0 до 0x3ffff.
Почему таблица многоуровневая, а не линейная
Одна сплошная линейная таблица страниц на всё адресное пространство была бы очень разреженной, потому что большие части виртуальной памяти обычно не используются. Иерархическая таблица позволяет не тратить память на большие дыры: достаточно пометить большие области как немаппированные на верхнем уровне иерархии.
На любом уровне дерева указатель на следующую запись может быть нулевым (0x0), что позволяет исключить целые поддеревья таблицы страниц — неотображаемые области не занимают место в оперативной памяти. Поиск по несвязанным адресам быстро завершается ошибкой, потому что процессор возвращает ошибку, как только встречает пустую запись выше по дереву. С помощью флага присутствия записи таблицы страниц могут быть помечены как непригодные для использования, даже если адрес выглядит валидным.
Экономия наглядна на примере. Если процесс занимает две страницы виртуальной памяти, начиная с адреса 0, то записи корневой таблицы страниц с 1 по 511 не нужны, поэтому и 511 страниц памяти для таблиц следующего уровня не нужны. Следующий уровень иерархии экономит ещё 511 × 512 страниц.
Верхнеуровневые записи могут и напрямую отображать большие страницы, не спускаясь к нижним уровням. Такие большие страницыкрупные непрерывные физические области, обычно от 2 МБ до 1 ГБ, отображаемые напрямую записями верхних уровней содержат крупные непрерывные физические области, обычно от 2 МБ до 1 ГБ, и отображаются соответственно записями PMD и PUD. Это даёт снижение нагрузки на TLBбуфер ассоциативной трансляции, кэш недавно использованных переводов виртуальных адресов в физические, уменьшение накладных расходов на таблицы страниц, эффективность выделения памяти и рост производительности для некоторых нагрузок — но с компромиссами в виде потерь памяти и сложностей выделения.
Складывание уровней
Складывание (folding) уровня означает, что уровень пропускается: если архитектура не использует все уровни таблиц страниц, они могут быть «свёрнуты», а все операции над таблицами страниц во время компиляции дополняются так, чтобы просто пропустить уровень при доступе к следующему нижнему. Уровень p4d используется только в системах, которые действительно имеют 5 уровней таблиц страниц, иначе он складывается.
Функции выделения уровня компилируются только при отсутствии соответствующего макроса складывания: __p4d_alloc — при отсутствии __PAGETABLE_P4D_FOLDED, а __pud_alloc — при отсутствии __PAGETABLE_PUD_FOLDED. При складывании p4d в 4-уровневых системах pgd_populate() является no-op, поэтому синхронизация выполняется на уровне P4D. Это различие обрабатывает sync_global_pgds()функция, которая синхронизирует изменения таблиц страниц между адресными пространствами процессов; при складывании p4d она работает на уровне P4D, а не PGD.
Поскольку каждый уровень — это массив указателей на следующий уровень ниже, при складывании отдельный массив для пропущенного уровня не создаётся, и память под него не расходуется. При трансляции адреса обход иерархии не проходит через пропущенный уровень. Код, стремящийся быть архитектурно-нейтральным, например менеджер виртуальной памяти, должен обходить все текущие пять уровней; этот же стиль рекомендуется и для архитектурно-зависимого кода, чтобы он был устойчив к будущим изменениям.
Таблицы страниц на NUMA-машинах
На машинах с несколькими NUMA-узламиузлами доступа к памяти, у каждого из которых своя локальная память и своя задержка обращения к ней поток, работающий на другом узле, при промахе TLB вынужден обходить таблицы страниц в удалённой памяти, что может замедлить приложение так же сильно, как удалённый доступ к данным. Mitosis (ASPLOS 2020) показал, что удалённые таблицы страниц могут замедлить приложение так же сильно, как удалённые данные, а Hydra (USENIX ATC 2024) воспроизвёл это на 8-сокетной машине с 8 ТБ ОЗУ.
Mitosis реплицирует всё дерево таблиц страниц на каждом узле, что стоит памяти (размер таблиц страниц × число узлов), и каждое изменение требует обновления каждой копии. В Hydra PTE копируется на узел только тогда, когда один из его потоков выполняет fault на этом узле, и каждая страница таблицы страниц хранит список узлов, имеющих копию.
Таким образом, на машинах с несколькими узлами приходится либо держать одну копию таблиц страниц и платить за чтение удалённых таблиц при промахах TLB, либо держать копию на каждом узле и платить за их синхронизацию.
Что из этого следует на практике
- Отношение 1/512 — не константа, а оценка для случая, когда каждая страница отображается один раз. Как только одну физическую страницу отображают N адресных пространств, стоимость растёт как N/512, и при N = 512 таблицы страниц стоят столько же, сколько данные.
- Разделяемая память между многими процессами — сценарий, где таблицы страниц дорожают пропорционально числу процессов, а не объёму данных: сотни процессов на одну 1 GiB дают около 2 MiB таблиц на процесс.
- Способ возврата памяти ядру имеет значение:
madvise(MADV_DONTNEED)освобождает страницы данных и очищает PTE, но оставляет таблицы страниц выделенными, поэтому пустые таблицы накапливаются — вплоть до 110 GiB таблиц у одного процесса. - Иерархия из pte, pmd, pud, p4d и pgd существует ради разреженности: нулевой указатель на любом уровне исключает целое поддерево, а верхние уровни могут напрямую отображать большие страницы от 2 МБ до 1 ГБ.
- Складывание уровней — механизм времени компиляции: неиспользуемый уровень просто пропускается, отдельный массив под него не создаётся, а обход иерархии его не проходит.
- На NUMA-машинах к расходу памяти добавляется латентность: обход удалённых таблиц при промахе TLB может замедлять приложение так же, как удалённый доступ к данным, и приходится выбирать между одной копией таблиц и синхронизацией копий на каждом узле.
Где смотреть в коде
- memory.c: __p4d_alloc
- pgtable.c: pte_alloc_one
- init_64.c: arch_sync_kernel_mappings
- pgtable.c: pgd_ctor
- pgtable.c: pgd_free