30. Huge Pages и Transparent Huge Pages

Математика промахов: Почему 4 КБ — это слишком мало?

В предыдущей главе (29. TLB. Translation Lookaside Buffer и стоимость промаха) мы столкнулись с суровым аппаратным ограничением: кэш трансляций виртуальных адресов (TLB) катастрофически мал в физических масштабах кремния.

Давайте вооружимся калькулятором и сопоставим цифры:

  • Типичный буфер L1 DTLB (кэш трансляций данных) современного процессора вмещает всего 64 записи. Если каждая запись привязана к стандартной странице памяти размером 4 КБ, то процессор способен молниеносно адресовать:
    $$64 \times 4096\text{ байт} = 256\text{ Килобайт памяти!}$$
  • Буфер второго уровня L2 TLB просторнее — около 1536 записей. Однако и он покрывает:
    $$1536 \times 4096\text{ байт} \approx \mathbf{6\text{ Мегабайт}}.$$

А теперь взгляните на рабочий профиль типичного высоконагруженного Go-сервиса или базы данных. В оперативной памяти развернуты кэши на десятки гигабайт, горутины жонглируют миллионами структур, сборщик мусора (GC) непрерывно сканирует кучу. Реальный рабочий набор данных (Working Set) исчисляется десятками гигабайт!

При стандартных страницах в 4 КБ буфер TLB покрывает менее 0.1% адресного пространства приложения. Процессор захлебывается в непрерывных TLB-промахах, расходуя до 15–25% всех полезных тактов ядра на мучительный аппаратный обход таблиц страниц (Hardware Page Walk).

Инженерный ответ на этот кризис был предельно прямолинеен: если законы физики кремния не позволяют нам бесконечно увеличивать число записей в TLB (ведь большой ассоциативный кэш неизбежно становится горячим и медленным), давайте увеличим физический размер самой страницы!


Huge Pages: Большие страницы

Huge Pages (Огромные страницы) — это совместный архитектурный механизм процессора и операционной системы, позволяющий адресовать оперативную память блоками значительно большего размера, чем стандартные 4 КБ.

В архитектуре x86_64 аппаратно стандартизированы два дополнительных калибра страниц:

  • 2 Мегабайта (в 512 раз больше стандартной страницы).
  • 1 Гигабайт (в 262 144 раза больше стандартной страницы).

Вернемся к нашему L2 TLB на 1536 записей. Если перевести систему на страницы по 2 МБ, то тот же самый аппаратный кэш TLB без переделки кремния сможет покрыть:
$$1536 \times 2\text{ Мегабайта} = \mathbf{3\text{ Гигабайта оперативной памяти!}}$$

Штрафы за промахи TLB для подавляющего большинства сервисов при таком раскладе снижаются практически до нуля.

Как это выглядит "под капотом" MMU?

В статье 28. Page Table, MMU и трансляция адресов мы детально исследовали классическое 4-уровневое дерево страниц: PML4 -> PDP -> PD -> PT.

Для внедрения Huge Pages разработчикам процессоров не пришлось проектировать новые контроллеры с нуля. Они поступили изящно: добавили специальный бит PS (Page Size — Размер страницы) в служебные записи таблиц уровней PD (Page Directory) и PDP (Page Directory Pointer).

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
    CR3["Регистр CR3<br>(Базовый адрес)"]:::process --> PML4["PML4<br>Уровень 1"]
    PML4 --> PDP["PDP<br>Уровень 2"]
    PDP --> PD{"Уровень PD<br>Проверка бита PS"}

    PD -->|"бит PS равен 0 (обычный)"| PT["PT (Уровень 4)<br>Таблица страниц"]
    PT -->|"Offset 12 бит"| RAM4K["Физический фрейм 4 КБ<br>(Стандартная страница)"]

    PD -->|"бит PS равен 1 (Huge Page)"| RAM2M["Физический фрейм 2 МБ<br>(Huge Page: срез 4-го уровня!)"]
  

Механика работы аппаратного блока MMU:

  1. MMU начинает привычный спуск по дереву: опрашивает PML4, затем PDP и доходит до уровня PD (3-й уровень).
  2. На уровне PD контроллер анализирует флаг PS:
    • Если PS == 0, MMU продолжает стандартный путь вниз к таблице PT, чтобы найти 4-килобайтный фрейм (смещение Offset составляет младшие 12 бит).
    • Если PS == 1, MMU совершает аппаратный срез (Short-Circuit)! Он не спускается на уровень PT. Запись в таблице PD уже содержит прямой базовый адрес 2-мегабайтного физического блока памяти.
  3. При этом смещение внутри страницы (Offset) расширяется с 12 бит до 21 бита ($2^{21} = 2\text{ МБ}$).

Один уровень таблицы страниц полностью исключается из цепочки, ускоряя даже холодный проход по памяти!


Проблема Explicit Huge Pages

Исторически первыми появились так называемые Explicit (Явные) Huge Pages. Чтобы приложение могло их использовать, системному администратору приходилось совершать целый ритуал:

  1. Зарезервировать фиксированный пул страниц в оперативной памяти еще на этапе загрузки ядра Linux через параметры командной строки ядра (параметр hugepages=X, например hugepages=2048).
  2. Смонтировать в системе виртуальную псевдо-ФС hugetlbfs.
  3. Само прикладное приложение должно было явно запрашивать эту память через системный вызов mmap со специальным флагом MAP_HUGETLB.

Это создавало колоссальное трение в эксплуатации: память, выделенная под hugepages, намертво блокировалась ядром и не могла быть использована обычными процессами, даже если сервер простаивал.

Чтобы демократизировать технологию и сделать большие страницы доступными для любого софта «из коробки», инженеры ядра Linux создали механизм Transparent Huge Pages.


Transparent Huge Pages (THP)

THP (Прозрачные огромные страницы) — это подсистема ядра Linux, которая стремится предоставить приложениям все преимущества Huge Pages автоматически, без модификации исходного кода программ.

Жизненный цикл THP:

  1. Ваше Go-приложение запускается и делает стандартные аллокации. Ядро изначально выдает ему привычные страницы по 4 КБ.
  2. В фоновом режиме в пространстве ядра непрерывно дежурит системный поток khugepaged.
  3. Демон khugepaged анализирует виртуальную память процессов. Если он находит 512 виртуально смежных страниц по 4 КБ (выровненный по 2 МБ диапазон), он прозрачно для приложения «схлопывает» их (Collapsing) в единую монолитную страницу размером 2 МБ: для этого ядру нужен свободный 2-МБ блок физической памяти, куда содержимое копируется (физическая смежность исходных страниц не требуется). После этого обновляются таблицы страниц и сбрасываются устаревшие записи TLB. А в режиме always 2-МБ страница может быть выдана и сразу, при первом обращении (page fault).

Казалось бы, идеальное решение? Однако именно из-за THP интернет переполнен статьями от ведущих инженеров с эмоциональными заголовками: «Why we permanently disabled THP in our production database».

Тёмная сторона THP: Синхронная компактизация и Latency Spikes

Если сервер работает под нагрузкой несколько недель, физическая оперативная память неизбежно фрагментируется. Свободные 4-килобайтные фреймы рассеяны по всему адресному пространству, словно крошки на ковре: свободной памяти много, но найти монолитный, неразрывный участок размером 2 Мегабайта физически невозможно.

Что происходит в режиме always, когда Go-рантайм расширяет кучу и запрашивает у ядра новую порцию памяти? (Насколько часто ядро идет на прямую компактизацию, зависит еще и от параметра /sys/kernel/mm/transparent_hugepage/defrag; его значение по умолчанию — madvise, то есть синхронно «договариваться» ядро будет в основном с теми, кто сам запросил huge pages. Ниже описан неблагоприятный сценарий: он требует defrag=always. При умолчании defrag=madvise для обычных аллокаций ядро быстро откатывается на 4 КБ страницы без долгого ожидания.)
Ядро Linux отвечает: «Я обязано выдать 2 МБ, но у меня нет непрерывного куска. Подождите!»

В этот момент ядро переходит в режим Синхронной прямой компактизации (Direct Compaction):

  • Выполнение потока Go-сервиса принудительно блокируется внутри системного вызова.
  • Ядро начинает лихорадочно копировать чужие страницы памяти с места на место, сдвигая данные и обновляя таблицы страниц, чтобы расчистить 2 МБ непрерывного пространства.
  • Простая аллокация объекта в Go, которая в норме занимает 5 наносекунд, внезапно зависает на десятки и даже сотни миллисекунд (порядок величины; зависит от системы)!

Для высоконагруженного сетевого микросервиса с SLA на 99-й перцентиль задержки ($p99 \le 10\text{ ms}$) такие внезапные паузы являются катастрофой.


Рантайм Go и Huge Pages

Какова позиция создателей Go относительно работы с большими страницами?

Архитектура аллокатора Go (mheap) управляет памятью укрупненными модулями — Аренами (Memory Arenas). На 64-битных операционных системах размер одной арены Go составляет ровно 64 Мегабайта. Более того, рантайм Go аппаратно выравнивает эти арены по адресам, строго кратным 2 Мегабайтам!

Таким образом, внутренняя структура кучи Go хорошо согласуется с 2 МБ Huge Pages — границы арен совпадают с границами больших страниц. Но «согласуется» ещё не значит «использует»: см. ниже, что рантайм делает на самом деле.

Проблема возникает в тех производственных средах, где системный администратор включил THP в режиме always:

Bash
echo always > /sys/kernel/mm/transparent_hugepage/enabled

В этом режиме ядро Linux агрессивно использует huge pages для куч Go: при defrag=always прямая компактизация на page fault даёт паузы в потоке-аллокаторе, а фоновый демон khugepaged (сам он поток приложения не блокирует) вызывает аномальный рост RSS-памяти: рантайм Go периодически возвращает арены ядру через MADV_DONTNEED (scavenger), а khugepaged тут же начинает заново собирать их в 2 МБ страницы — бесконечный и затратный цикл.

Переменная GODEBUG=disablethp=1

Именно для защиты от этого сценария в Go 1.21.6 / Go 1.22 появился специальный аварийный выключатель. При активации он принудительно вызывает madvise(..., MADV_NOHUGEPAGE) для каждой арены кучи, явно запрещая ядру объединять страницы:

Go
// src/runtime/mem_linux.go — при маппировании новой арены:
func sysMapOS(v unsafe.Pointer, n uintptr, vmaName string) {
    // ...
    // Disable huge pages if the GODEBUG for it is set.
    // Note that there are a few sysHugePage calls that can override this,
    // but they're all for GC metadata.
    if debug.disablethp != 0 {
        sysNoHugePageOS(v, n) // madvise(_MADV_NOHUGEPAGE)
    }
}
Bash
GODEBUG=disablethp=1 go run main.go

Эта переменная окружения не отключает запросы MADV_HUGEPAGE (Go и так их не делает для пользовательской кучи). Её истинная роль — активно запросить MADV_NOHUGEPAGE, чтобы защитить сервисы от катастрофического раздувания RSS при глобально включенном режиме always. Если после включения disablethp=1 микро-фризы исчезли — значит, виноваты huge pages в режиме always; но чем именно (прямой компактизацией при defrag=always или фоновым khugepaged), эта проверка не различает: переменная убирает huge pages из арен целиком и снимает оба механизма.


Итоги

  1. Huge Pages (2 МБ и 1 ГБ) кардинально решают проблему емкости TLB: один буфер начинает покрывать гигабайты памяти вместо мегабайтов.
  2. Бит PS в записях таблиц страниц позволяет блоку MMU срезать последний уровень трансляции, ускоряя аппаратный обход дерева страниц.
  3. Transparent Huge Pages (THP) автоматизируют сборку больших страниц, но в режиме always несут скрытую угрозу в виде задержек синхронной компактизации (Direct Compaction) и CoW-амплификации.
  4. Рантайм Go: 64 МБ арены выровнены по 2 МБ границам, что согласуется с большими страницами. Рантайм использует MADV_HUGEPAGE точечно только для служебных структур (например, метаданных GC). Для пользовательских арен кучи огромные страницы не запрашиваются — поэтому в режиме THP madvise куча Go работает на обычных 4 КБ страницах.
  5. GODEBUG=disablethp=1 (Go 1.21.6+) — выключатель не для запроса THP, а для его запрета: при активации рантайм посылает ядру MADV_NOHUGEPAGE для каждой арены, защищая от аномального раздувания RSS на серверах с THP в режиме always.
  6. Практика настройки серверов: многие производственные системы выставляют THP в madvise или never; для Go-сервиса это означает «без huge pages в куче», зато без сюрпризов с задержками и раздуванием RSS. Что лучше, зависит от конкретной нагрузки — меряйте.

Мы завершили изучение того, как процессор адресует, кэширует и масштабирует память на уровне одного вычислительного узла.
Но в современных серверных стойках практически не бывает систем с одним процессором и одной линейкой оперативной памяти! В них трудятся многопроцессорные многоядерные платформы, где физическая память распределена неравномерно между сокетами.

Почему доступ к одной ячейке RAM может быть в 3 раза медленнее, чем к соседней, что такое «удаленная память», и как архитектура NUMA управляет топологией современных распределенных бэкендов?

Об этом читайте в следующей фундаментальной главе: 31. NUMA. Non Uniform Memory Access.