Learning Platform
Глоссарий Troubleshooting
Урок 07.03 · 20 мин
Начальный
PagingMMUTLBPage tablesHuge pagesLinux

Paging и MMU — как virtual address становится physical

В прошлом уроке мы говорили, что MMU «переводит» virtual address в physical. Это магия. В этом уроке разберём, как именно эта магия работает: что такое page tables, как они организованы в 4-5 уровней, что такое TLB и почему его miss дороже cache miss, как работают huge pages и зачем они нужны.

Для junior data engineer этот урок объясняет: почему первый запрос к новой allocation медленнее последующих, почему madvise(MADV_HUGEPAGE) ускоряет аналитику, что такое THP в Linux, и почему swap в production может неожиданно убивать latency.


Pages — основная единица VM

Память управляется не байтами и не cache lines, а страницами (pages). На x86_64 стандартный размер страницы — 4 KB. Все virtual address и physical address кратны 4 KB при отображении.

Page как unit translation
Virtual address48-битный адрес на x86_64. Делится на page number (старшие биты) и offset (12 младших бит = 4 KB)
Page numberСтаршие 36 бит. Индекс в page tables
Offset (12 bits)Младшие 12 бит. Точная позиция внутри 4 KB страницы. Не меняется при translation
Page tablesСтруктуры в physical memory, mapping page number virtual -> physical
Physical page #Какая физическая страница соответствует virtual
Same offsetOffset не переводится -- 12 младших бит virtual и physical совпадают
Physical address52-битный physical на x86_64. Phys page + offset = реальный адрес в RAM

Translation virtual -> physical происходит только для верхних бит (page number). Offset не меняется. Это значит, страница 4 KB всегда отображается на 4 KB physical, как единое целое.

# Размер страницы:
getconf PAGE_SIZE
# 4096

# Сколько физической памяти у вас:
free -h
# total used free ... -- размеры в KB/MB/GB
# Делится на страницы по 4 KB

# Сколько страниц всего:
echo $(($(free | awk '/Mem:/ {print $2}') / 4))
# например, 16384000 -- 16 миллионов страниц на 64 GB RAM

Page tables — многоуровневая структура

48-битный virtual address space = 256 TB / 4 KB pages = 64 миллиарда страниц. Хранить flat таблицу translation для каждой — 64 миллиарда * 8 байт = 512 GB на одну page table. Невозможно. Поэтому page tables многоуровневые — дерево, где нужны только те ветки, для которых есть mappings.

На x86_64 — 4 уровня (5 с LA57 в новых kernel’ах для 5-level paging, но 4 чаще):

4-level page tables на x86_64
CR3 registerCPU register, указывает на корень page tables текущего процесса. При context switch меняется -- другой процесс видит свои tables
PML4 (Level 4)Page Map Level 4. 512 entries по 8 байт = 4 KB. Корень. Бит 39-47 virtual address (9 бит = 512 = 2^9)
PDPT (Level 3)Page Directory Pointer Table. 512 entries. Бит 30-38 virtual address
PD (Level 2)Page Directory. 512 entries. Бит 21-29
PT (Level 1)Page Table. 512 entries. Бит 12-20. Каждый entry содержит physical page number + flags (present, RW, NX, ...)
4 memory accesses per translationБез TLB cache каждое обращение к памяти потребовало бы 4 lookup'а в page tables = 4 memory reads. Дорого!

Чтобы перевести 48-битный virtual address:

  1. Bits 39-47: indexes в PML4 -> entry указывает на PDPT
  2. Bits 30-38: index в PDPT -> entry указывает на PD
  3. Bits 21-29: index в PD -> entry указывает на PT
  4. Bits 12-20: index в PT -> entry содержит physical page #
  5. Bits 0-11: offset в странице -> добавляется к physical page

Это 4 memory accesses на каждый translation. Без оптимизации каждое обращение к памяти стало бы в 5x дороже — 4 для translation + 1 для actual data. К счастью, есть TLB.


TLB — кэш для translations

TLB (Translation Lookaside Buffer) — небольшой кэш в MMU, хранящий недавно использованные translations. У современных CPU 32-2000 entries в TLB. Если translation в TLB — 1 cycle, 0.3 ns. Если нет — идём в page tables, 4 memory accesses.

TLB -- кэш translations
Access addr XПрограмма обращается к памяти по virtual addr X. MMU нужно найти physical
Check TLBСначала смотрит TLB -- кэш translations
TLB hit (99%)Если page X в TLB -- сразу physical address. ~1 cycle, прозрачно. Большинство обращений
TLB missPage X нет в TLB. Walk page tables -- 4 memory accesses. ~100 ns. Translation добавляется в TLB для будущего
TLB flush at context switchПри переключении процесса CR3 меняется -- старые TLB entries не валидны для нового процесса, нужно invalidate (большую часть). Это часть стоимости context switch

Размеры TLB на современных CPU:

  • L1 dTLB — 64-128 entries, 4 KB pages
  • L2 dTLB — 1000-2000 entries
  • Покрытие L1 dTLB при 4 KB страницах — 256-512 KB. Если работаете с большими структурами (>1 MB), TLB начинает промахиваться.
# Информация о TLB вашего CPU:
cpuid | grep -i tlb | head -10
# Может быть: 'L1 TLB/cache: 64 entries 4-way'

# или (более портабельно):
sudo dmidecode -t processor | grep -i tlb

# Замерить TLB misses через perf:
perf stat -e dTLB-loads,dTLB-load-misses ./your-program
# Высокий miss rate (>1%) -- стоит подумать про huge pages

Page fault — когда MMU не может перевести

Что если в page table нет entry для нужного virtual address? MMU генерирует page fault — особое прерывание, kernel получает контроль.

Виды page faults
Minor faultСтраница не в page tables, но данные уже в RAM (например, страница shared library, на которую первое обращение). Kernel просто обновляет page table -- быстро, < 1 мкс
Major faultСтраница не в RAM, нужно загрузить с диска (swap или file-backed). Может занять миллисекунды. На production это ALERT-сигнал
COW faultЗапись в read-only страницу, помеченную как COW. Kernel копирует страницу, обновляет page table. Типично после fork()
Invalid faultДоступ к unmapped address (NULL pointer, off-the-end). Kernel шлёт SIGSEGV, процесс падает
# Page faults для процесса:
ps -o pid,min_flt,maj_flt,comm -p PID
# min_flt -- minor faults (fast fix)
# maj_flt -- major faults (disk read, slow!)

# Watch faults в real-time:
cat /proc/PID/stat | awk '{print $10, $12}'
# первое число -- min_flt, второе -- maj_flt

# Системные счётчики:
vmstat 1
# Колонка 'in' -- interrupts/sec, включает page faults
# Колонки so/si -- swap out/in (если есть -- major faults на swap)

Major faults на production — bad sign. Это значит, swap активен или disk pressure высокий. Latency spikes на миллисекунды.

# Триггер minor faults:
cat > /tmp/faults.c << 'EOF'
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main() {
    char* buf = malloc(100 * 1024 * 1024);  // 100 MB
    // Сразу после malloc страницы не выделены физически
    // Первое обращение к каждой странице = page fault
    memset(buf, 1, 100 * 1024 * 1024);  // 25600 page faults (по 4 KB)
    free(buf);
}
EOF
gcc /tmp/faults.c -o /tmp/faults
/usr/bin/time -v /tmp/faults 2>&1 | grep -E 'page faults'
# Minor (reclaiming a frame): 25600 (примерно)
# Major (requiring I/O):      0 (если хватает RAM)

Huge pages — большие страницы для меньшего TLB pressure

Если ваша программа работает с большим working set (10+ GB), TLB не справляется. Каждые несколько обращений — TLB miss, walk через page tables. Решение — huge pages.

Huge pages -- 2 MB или 1 GB вместо 4 KB
Regular 4 KBСтандартная страница. TLB entry покрывает 4 KB. На 16 GB working set нужно 4M entries -- никаких TLB не хватит
2 MB hugeБольшая страница. Один TLB entry покрывает 2 MB. На 16 GB working set нужно 8K entries -- помещается в TLB. Меньше уровней page table (3 вместо 4)
1 GB giganticГигантская страница. Один TLB entry на 1 GB. Для систем с TB памяти. Используется в виртуализации, БД
THP (Transparent Huge Pages)Linux фича. Kernel автоматически объединяет 4 KB страницы в 2 MB huge pages, когда возможно. Прозрачно для приложения
madvise(MADV_HUGEPAGE)Программа явно просит huge pages для региона. Лучший контроль
# Сколько huge pages зарезервировано в системе:
cat /proc/meminfo | grep -i huge
# HugePages_Total:       0
# HugePages_Free:        0
# Hugepagesize:       2048 kB    -- 2 MB
# AnonHugePages:    1234 KB      -- THP в anonymous memory

# Статус THP:
cat /sys/kernel/mm/transparent_hugepage/enabled
# [always] madvise never
# always -- THP включён всегда (default Ubuntu)
# madvise -- только когда программа явно просит через madvise()
# never -- отключён

Когда THP помогает:

  • Большие in-memory структуры (БД, кэши, аналитика)
  • Working set 100 MB+ с большим количеством случайных доступов
  • Виртуализация (host running VMs)

Когда THP вредит:

  • Latency-sensitive приложения (defrag pauses)
  • Databases с собственным memory management (Redis, MongoDB — рекомендуют отключать)
  • Малые программы с фрагментированной памятью

Многие БД настоятельно рекомендуют отключить THP:

# Disable THP (для Redis, MongoDB, KafkaStreams):
echo never | sudo tee /sys/kernel/mm/transparent_hugepage/enabled
echo never | sudo tee /sys/kernel/mm/transparent_hugepage/defrag

# Reboot-persist через /etc/rc.local или systemd unit

Page table entry — что внутри

Каждый PT entry — 8 байт. Структура:

PT entry structure
Bit 0: Present1 = страница в памяти. 0 = страница не отображена (swapped, not allocated, или never accessed). При access страницы с Present=0 -- page fault
Bit 1: R/W1 = можно писать. 0 = read-only. Запись в read-only страницу -- COW fault или SIGSEGV
Bit 2: U/S1 = user accessible. 0 = только kernel. Userspace processes не могут обратиться к kernel pages
Bit 5: AccessedHardware устанавливает в 1, если CPU обращался к этой странице. Используется для LRU eviction (swap)
Bit 6: DirtyHardware устанавливает в 1, если CPU писал в страницу. Грязные страницы при swap нужно записать на диск; clean можно просто отбросить
Bit 63: NXNo-execute bit. 1 = выполнение запрещено. CPU откажется выполнять инструкции с этой страницы
Bits 12-51: PFNPhysical Frame Number. Номер физической страницы (4 KB), на которую указывает этот virtual page

Бит Accessed позволяет kernel’ю реализовать LRU (Least Recently Used) для eviction страниц при нехватке памяти. Бит Dirty позволяет различать: clean pages можно просто отбросить (есть копия в файле или там нечего терять), dirty — нужно записать на диск перед освобождением.

Memory hierarchy: TLB как кэш адресных переводов

Реальные размеры и числа

Пример из жизни. Программа выделила 8 GB через malloc(). Сколько памяти на page tables?

8 GB / 4 KB = 2 миллиона страниц
2M * 8 байт = 16 MB на PT entries
+ Промежуточные уровни (PD, PDPT, PML4) = ~32 KB

Итого: ~16 MB на page tables для 8 GB allocation

Это 0.2% overhead. Не страшно, но при 1 TB working set — 2 GB на page tables. Поэтому в hugepages 1 GB:

1 TB / 1 GB = 1024 страницы (если бы 1 GB pages)
1024 * 8 байт = 8 KB на PT entries

Итого: 8 KB вместо 2 GB

Это огромная экономия и существенное ускорение TLB.


Попробуй сам

# 1. Размер страницы:
getconf PAGE_SIZE

# 2. Просмотреть page tables процесса:
sudo cat /proc/PID/pagemap 2>/dev/null
# Бинарный формат, парсится /proc/PID/maps + lookup'ы

# 3. Page faults для своих процессов:
for pid in $(pgrep -f firefox | head -3); do
    ps -p $pid -o pid,min_flt,maj_flt,comm
done

# 4. Включена ли THP:
cat /sys/kernel/mm/transparent_hugepage/enabled

# Сколько THP реально используется:
grep -E 'AnonHugePages|HugePages_' /proc/meminfo

# 5. Замерить TLB misses в программе:
perf stat -e dTLB-loads,dTLB-load-misses,iTLB-load-misses ls -la /usr/bin
# Покажет процент TLB miss для load и instruction fetches

# 6. Триггер major faults через swap:
# Создаём фейковую нагрузку
stress-ng --vm 1 --vm-bytes 80% --timeout 30s &
# Параллельно смотрим:
vmstat 1
# Колонки si/so -- swap in/out -- это major faults на swap

# 7. Replicate page fault на C:
cat > /tmp/pf.c << 'EOF'
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
int main() {
    // mmap 100 MB anonymously, не trigger pages
    char* p = mmap(NULL, 100*1024*1024, PROT_READ|PROT_WRITE,
                   MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
    printf("Allocated, no faults yet. Press enter.\n");
    getchar();
    // Touch all pages -- 25600 page faults
    memset(p, 0, 100*1024*1024);
    printf("Touched. Check /proc/self/status. Enter to exit.\n");
    getchar();
}
EOF
gcc /tmp/pf.c -o /tmp/pf
/tmp/pf &
PID=$!
sleep 1
cat /proc/$PID/status | grep -E 'RSS|VmPeak|Vm'
# RSS низкое до touch
# (нажать Enter в окне программы)
# RSS вырос на 100 MB после touch
kill $PID

Проверка знанийKnowledge check
Junior на собеседовании спрашивают: 'Что произойдёт, когда я делаю malloc(1GB) на 64-битной системе с 16GB RAM? Сразу выделится 1 GB?'
ОтветAnswer
Нет, не сразу. malloc -- ленивый. Вот что происходит: 1. malloc(1GB) вызывает либо brk(), либо чаще mmap() для большой allocation. На 1GB точно mmap (порог glibc обычно 128KB). 2. mmap создаёт virtual mapping в page tables процесса -- область 1 GB помечена как анонимная, read-write, private. Page table entries в основном с Present=0 (не отображены физически). Никаких physical pages не выделяется. 3. RSS не растёт (или растёт на крохи -- может, на page tables увеличиться немного). VSZ растёт на 1 GB. 4. malloc возвращает указатель на начало этой области. Программа думает, что у неё 1 GB. 5. Когда программа первый раз обращается к странице (читает или пишет первый байт), MMU видит Present=0, генерирует page fault. Kernel: - Берёт free физическую страницу (4 KB) - Если это запись -- может zero-fill эту страницу (для security -- нельзя выдать страницу с данными другого процесса) - Если это чтение -- может сразу подать zero page (kernel optimization: 'zero page' -- одна страница нулей, отображается всем, кто читает аноним) - Обновляет page table entry: Present=1, PFN=... - RSS процесса увеличивается на 4 KB 6. Только касаемые страницы реально занимают RAM. Если программа alloc 1 GB и trogает 100 MB -- RSS = 100 MB, не 1 GB. Это и есть лень malloc'а. Полезные следствия: a) Можно делать malloc для больших, sparse структур. Например, hash table с reserved 1 GB -- если использовано только 50 MB, физически займёт 50 MB. b) Программы 'выглядят' большими в VSZ, но это иллюзия. Реальное потребление -- RSS. c) Перфоманс. Первый доступ к свежей allocation -- page fault, ~1 мкс. Если pre-load критично (real-time) -- используй madvise(MADV_WILLNEED) или mlock(). d) Overcommit. Linux может 'обещать' выделить больше памяти, чем есть RAM (см. /proc/sys/vm/overcommit_memory). Если все процессы touch'нут все свои allocations -- начнёт работать OOM killer. Эксперимент: mmap 100 GB на машине с 16 GB: - Если overcommit=1 (always) -- mmap успешный, VSZ=100GB, RSS=0 - Touch 50 GB -- RSS растёт, доходим до swap, swapping начинается - Touch остальные 50 GB -- OOM killer убивает процесс Запомните: malloc возвращает указатель, не память. Память приходит при касании. Это базовое поведение Linux memory management.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 6. Какой стандартный размер страницы на x86_64 Linux?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5