Глава 19: Page Reclaim, Compaction и Transparent Huge Pages
Проблема Page Reclaim
Linux не держит память без дела. Каждая свободная страница становится файловым кэшем, slab-кэшем или анонимной памятью. Неиспользуемая RAM -- это потраченная впустую RAM. Но когда приложение запрашивает память и свободных страниц нет, ядро должно освободить существующие страницы, прежде чем аллокация сможет завершиться.
Современные NVMe-накопители усугубляют ситуацию. Один NVMe-диск выдаёт 7 ГБ/с и миллионы IOPS. Приложения аллоцируют память с такой скоростью, что фоновый reclaim не справляется. Аллоцирующий поток блокируется, пока ядро синхронно освобождает страницы. Это direct reclaim -- главный источник необъяснимых всплесков задержки на системах с давлением по памяти.
Добавьте Transparent Huge Pages, и всё становится хуже. THP требуют 2 МБ непрерывной физической памяти. Когда память фрагментирована, ядро выполняет компакцию страниц для создания непрерывных блоков. Компакция дорогая, недетерминированная и происходит в пути аллокации. Аллокация 4 КБ за 100 нс может превратиться в аллокацию THP 2 МБ за 10 мс -- увеличение задержки в 100 000 раз.
melisai измеряет все три подсистемы -- reclaim, компакцию, THP -- используя двухточечный сэмплинг счётчиков /proc/vmstat.
Водяные знаки памяти
Ядро использует три уровня водяных знаков (watermark) на каждую зону для принятия решения о reclaim:
Общая память зоны
+-------------------------------------------------+
| Используемая память (anon + cache) |
+--------------------------------------------------+ <- high watermark
| Свободные страницы -- kswapd останавливается здесь |
+--------------------------------------------------+ <- low watermark
| Свободные страницы -- kswapd запускается здесь |
+--------------------------------------------------+ <- min watermark
| Зарезервировано -- территория direct reclaim |
| (аллокации БЛОКИРУЮТСЯ здесь) |
+--------------------------------------------------+
- Свободных страниц выше
high-- аллокации выполняются мгновенно. - Свободных страниц ниже
low-- kswapd просыпается, выполняет reclaim в фоне. - Свободных страниц ниже
min-- direct reclaim. Аллоцирующий поток сам сканирует и освобождает страницы.
Два sysctl управляют зазорами:
| Sysctl | По умолчанию | Эффект |
|---|---|---|
vm.min_free_kbytes |
~67 МБ на 64 ГБ | Устанавливает watermark min для всех зон |
vm.watermark_scale_factor |
10 (0.1%) | Зазор между min/low/high в % от размера зоны |
На сервере с 256 ГБ при watermark_scale_factor=10 зазор между low и min составляет всего ~256 МБ. Всплеск аллокаций пробивает его за миллисекунды, вызывая direct reclaim до того, как kswapd успеет отреагировать.
Direct Reclaim vs kswapd
kswapd (фоновый) -- поток ядра, по одному на NUMA-ноду. Просыпается при watermark low, сканирует LRU-списки, освобождает страницы. Без задержки для приложений.
Счётчики: pgscan_kswapd, pgsteal_kswapd.
Direct reclaim (синхронный) -- выполняется в контексте аллоцирующего потока.
Срабатывает при watermark min. Приложение блокируется до освобождения страниц.
Задержка: от 100 мкс до 100+ мс.
Счётчики: pgscan_direct, pgsteal_direct, allocstall_*.
Ключевые соотношения:
reclaim_efficiency = pgsteal / pgscan (чем выше тем лучше, 1.0 = идеально)
direct_ratio = pgscan_direct / (pgscan_direct + pgscan_kswapd)
direct_ratio = 0-- весь reclaim фоновый. Здоровое состояние.direct_ratio < 0.1-- иногда direct reclaim. Допустимо.direct_ratio > 0.3-- kswapd не справляется. Влияние на задержку.direct_ratio > 0.7-- серьёзно. Приложения блокируются.
allocstall_normal -- самый прямой индикатор: каждое увеличение означает, что один поток вошёл в медленный путь.
Как melisai измеряет это
melisai читает /proc/vmstat дважды -- в начале и конце сбора -- и вычисляет скорости (rate) на основе дельты:
// internal/collector/memory.go
func (c *MemoryCollector) Collect(ctx context.Context, cfg CollectConfig) (*model.Result, error) {
vmstat1 := c.parseVmstatRaw() // Первый сэмпл
time.Sleep(cfg.Duration) // По умолчанию: 10с
vmstat2 := c.parseVmstatFull(data) // Второй сэмпл + заполнение ReclaimStats
c.computeReclaimRates(data, vmstat1, vmstat2, interval.Seconds())
}
func (c *MemoryCollector) computeReclaimRates(data *model.MemoryData,
v1, v2 map[string]int64, secs float64) {
if d := v2["pgscan_direct"] - v1["pgscan_direct"]; d > 0 {
data.Reclaim.DirectReclaimRate = float64(d) / secs
}
if d := v2["compact_stall"] - v1["compact_stall"]; d > 0 {
data.Reclaim.CompactStallRate = float64(d) / secs
}
if d := v2["thp_split_page"] - v1["thp_split_page"]; d > 0 {
data.Reclaim.THPSplitRate = float64(d) / secs
}
}
Двухточечный сэмплинг фиксирует что произошло за окно сбора, а не средние за всё время работы. 10-секундное окно ловит всплески, которые кумулятивные счётчики размывают.
Пороги аномалий
| Метрика | Warning | Critical | Значение |
|---|---|---|---|
direct_reclaim_rate |
10 страниц/с | 1000 страниц/с | Приложения блокируются при page reclaim |
compaction_stall_rate |
1/с | 100/с | Аллокации блокируются на дефрагментации |
thp_split_rate |
1/с | 100/с | Огромные страницы разбиваются, TLB thrashing |
Компакция
Компакция памяти -- это дефрагментация ядра. Она перемещает страницы для создания непрерывных свободных блоков -- это нужно для огромных страниц, аллокаций ядра высокого порядка и CMA-регионов.
Фрагментировано: [used][free][used][free][used][free][used][free]
Сканер миграции -> <- Сканер свободных
Компактировано: [used][used][used][used][free][free][free][free]
Три счётчика:
| Счётчик | Значение |
|---|---|
compact_stall |
Аллокации, ожидавшие компакцию |
compact_success |
Запуски компакции, создавшие запрошенный порядок |
compact_fail |
Запуски компакции, которые не удались -- слишком фрагментировано |
Доля успехов = compact_success / (compact_success + compact_fail). Ниже 0.5 означает, что компакция тратит CPU, но терпит неудачу. Ядро откатывается к меньшим аллокациям или запускает direct reclaim -- создавая многомиллисекундные задержки.
THP: друг или враг?
Transparent Huge Pages отображают 2 МБ виртуальной памяти на одну физическую страницу 2 МБ вместо 512 x 4 КБ страниц. Преимущество: в 512 раз меньше записей TLB, выигрыш 5-15% производительности для нагрузок с большим объёмом памяти.
Цена заключается в трёх операциях:
1. Аллокация при page fault (thp_fault_alloc) -- При page fault ядро пытается аллоцировать 2 МБ непрерывной памяти. Если память фрагментирована, это запускает компакцию в пути обработки fault, в потоке приложения.
2. Collapse (thp_collapse_alloc) -- khugepaged сканирует существующие страницы 4 КБ и объединяет 512 непрерывных в огромную страницу. Фоновый процесс, но потребляет CPU.
3. Разбиение (thp_split_page) -- Когда ядру нужно освободить часть огромной страницы, оно разбивает её обратно на 512 маленьких страниц. Это требует TLB shootdown IPI к каждому CPU, на котором эта страница отображена. На 128-ядерной машине одно разбиение = 127 IPI. При 100 разбиениях/с это 12 700 IPI/с чистых накладных расходов.
Режимы дефрагментации THP
| Режим | Поведение | Влияние на задержку |
|---|---|---|
always |
Синхронная компакция при каждом THP fault | Худший -- неограниченные задержки |
defer |
Попытка, постановка компакции в очередь при неудаче, откат на 4 КБ | Низкое |
defer+madvise |
defer для большинства, синхронная для регионов MADV_HUGEPAGE |
Низкое для большинства |
madvise |
THP только для регионов MADV_HUGEPAGE |
Нулевое для не подписавшихся |
never |
Без дефрагментации THP | Нулевое |
Рекомендация для production: defer+madvise. Приложения, которым нужны огромные страницы (PostgreSQL, JVM, Redis), подписываются через madvise(MADV_HUGEPAGE); всё остальное защищено от задержек компакции.
melisai читает из:
/sys/kernel/mm/transparent_hugepage/enabled -> always/madvise/never
/sys/kernel/mm/transparent_hugepage/defrag -> always/defer/defer+madvise/madvise/never
JSON-вывод
{
"memory": {
"thp_enabled": "always",
"thp_defrag": "always",
"min_free_kbytes": 67584,
"watermark_scale_factor": 10,
"dirty_expire_centisecs": 3000,
"dirty_writeback_centisecs": 500,
"reclaim": {
"pgscan_direct": 48210,
"pgscan_kswapd": 3841920,
"pgsteal_direct": 41002,
"pgsteal_kswapd": 3740100,
"allocstall_normal": 312,
"allocstall_movable": 18,
"compact_stall": 89,
"compact_success": 62,
"compact_fail": 27,
"thp_fault_alloc": 14320,
"thp_collapse_alloc": 8410,
"thp_split_page": 1205,
"direct_reclaim_rate": 482.1,
"compact_stall_rate": 8.9,
"thp_split_rate": 12.5
}
}
}
Как читать: direct_reclaim_rate=482.1 выше warning (10), ниже critical (1000). compact_stall_rate=8.9 означает фрагментацию. thp_enabled=always с thp_defrag=always -- худшая комбинация для нагрузок, чувствительных к задержке. Доля успехов компакции 62/(62+27) = 70% -- 30% потраченных впустую усилий.
Диагностические примеры
Здоровое состояние: нет давления reclaim
{
"reclaim": {
"pgscan_direct": 0, "pgscan_kswapd": 120400,
"pgsteal_kswapd": 118200, "allocstall_normal": 0,
"compact_stall": 0, "thp_split_page": 3,
"direct_reclaim_rate": 0, "compact_stall_rate": 0, "thp_split_rate": 0.3
},
"thp_enabled": "madvise", "thp_defrag": "defer+madvise",
"watermark_scale_factor": 150
}
Весь reclaim через kswapd. Ноль direct reclaim, ноль задержек компакции. Эффективность kswapd: 118200/120400 = 98.2%. THP в режиме madvise -- только подписавшиеся приложения получают огромные страницы.
Критическое состояние: шторм THP под давлением памяти
{
"reclaim": {
"pgscan_direct": 2841000, "pgscan_kswapd": 1420000,
"pgsteal_direct": 890000, "pgsteal_kswapd": 1210000,
"allocstall_normal": 14200,
"compact_stall": 4200, "compact_success": 800, "compact_fail": 3400,
"thp_fault_alloc": 420, "thp_split_page": 8900,
"direct_reclaim_rate": 28410, "compact_stall_rate": 420, "thp_split_rate": 890
},
"thp_enabled": "always", "thp_defrag": "always",
"watermark_scale_factor": 10
}
Всё плохо: direct_reclaim_rate=28410 (critical), direct reclaim превышает kswapd, эффективность reclaim 890K/2841K = 31%, доля неудач компакции 81%, и thp_fault_alloc=420 против thp_split_page=8900 означает, что THP работает в минус. melisai генерирует три рекомендации:
1. Direct reclaim активен -- увеличить резервы watermark
sysctl -w vm.watermark_scale_factor=200
sysctl -w vm.min_free_kbytes=131072
2. Обнаружены разбиения THP при THP=always -- переключить на madvise
echo madvise > /sys/kernel/mm/transparent_hugepage/enabled
echo defer+madvise > /sys/kernel/mm/transparent_hugepage/defrag
3. Обнаружены задержки компакции -- память фрагментирована
echo 1 > /proc/sys/vm/compact_memory
sysctl -w vm.extfrag_threshold=500
Предупреждение: слишком медленный dirty writeback
{
"reclaim": {
"pgscan_direct": 8400, "pgscan_kswapd": 620000,
"direct_reclaim_rate": 84, "compact_stall_rate": 0, "thp_split_rate": 0
},
"dirty_expire_centisecs": 3000, "dirty_writeback_centisecs": 500
}
Умеренный direct reclaim (84/с), без проблем с компакцией или THP. Проблема: грязные страницы находятся в памяти 30 секунд (dirty_expire_centisecs=3000). Под давлением ядро должно синхронно записать их на диск перед освобождением.
Руководство по настройке
Шаг 1: Увеличить зазоры watermark
# По умолчанию: 10 (0.1%). Рекомендуется: 150-300 (1.5-3%)
sysctl -w vm.watermark_scale_factor=200
# Или установить напрямую (например, 256 МБ на сервере с 64 ГБ)
sysctl -w vm.min_free_kbytes=262144
Компромисс: более высокие watermark резервируют больше памяти. На 256 ГБ watermark_scale_factor=200 резервирует ~5 ГБ. Это стоит того для устранения задержек.
Шаг 2: Политика THP
echo madvise > /sys/kernel/mm/transparent_hugepage/enabled
echo defer+madvise > /sys/kernel/mm/transparent_hugepage/defrag
Шаг 3: Сброс грязных страниц
sysctl -w vm.dirty_expire_centisecs=1000 # 10с (по умолчанию: 30с)
sysctl -w vm.dirty_writeback_centisecs=100 # 1с (по умолчанию: 5с)
sysctl -w vm.dirty_background_ratio=5 # начать сброс при 5%
sysctl -w vm.dirty_ratio=10 # блокировать пишущих при 10%
Шаг 4: Проактивная компакция (ядро 5.9+)
sysctl -w vm.compaction_proactiveness=20 # фоновая дефрагментация
echo 1 > /proc/sys/vm/compact_memory # разовая ручная
Шаг 5: Сделать настройки постоянными
cat >> /etc/sysctl.d/99-melisai-reclaim.conf << 'EOF'
vm.watermark_scale_factor=200
vm.min_free_kbytes=262144
vm.dirty_expire_centisecs=1000
vm.dirty_writeback_centisecs=100
EOF
# THP требует systemd-юнит:
cat > /etc/systemd/system/thp-madvise.service << 'EOF'
[Unit]
Description=Set THP to madvise
After=sysinit.target local-fs.target
[Service]
Type=oneshot
ExecStart=/bin/sh -c 'echo madvise > /sys/kernel/mm/transparent_hugepage/enabled'
ExecStart=/bin/sh -c 'echo defer+madvise > /sys/kernel/mm/transparent_hugepage/defrag'
[Install]
WantedBy=basic.target
EOF
systemctl enable thp-madvise.service
Когда использовать статические огромные страницы
THP удобны, но непредсказуемы. Для гарантированной аллокации огромных страниц без задержек компакции используйте статические (предварительно выделенные) огромные страницы:
- Базы данных: PostgreSQL
huge_pages=on, Oracle SGA - DPDK: требует предварительно выделенных огромных страниц
- JVM:
-XX:+UseLargePagesна критичных по задержке путях
# Зарезервировать 4096 огромных страниц (8 ГБ)
sysctl -w vm.nr_hugepages=4096
# Или при загрузке: hugepages=4096 в командной строке ядра
Статические огромные страницы резервируются при загрузке и не могут использоваться для других целей. melisai отображает оба параметра:
Если huge_pages_free == huge_pages_total, у вас зарезервированы страницы, которые ничто не использует.
Краткий справочник
| Симптом | Счётчик | Порог | Исправление |
|---|---|---|---|
| Всплески задержки | direct_reclaim_rate > 10 |
W=10, C=1000 | Увеличить watermark_scale_factor |
| Задержки аллокации | allocstall_normal > 0 |
Любое увеличение | Увеличить min_free_kbytes |
| Разбиение THP | thp_split_rate > 1 |
W=1, C=100 | THP в режим madvise |
| Неудачи компакции | compact_fail > compact_success |
Соотношение | compact_memory, extfrag_threshold |
| Давление грязных страниц | Высокий pgscan_direct, нет компакции |
Контекст | Уменьшить dirty_expire_centisecs |
Далее: Глава 20 -- Оптимизация NUMA