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 при отображении.
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 чаще):
Чтобы перевести 48-битный virtual address:
- Bits 39-47: indexes в PML4 -> entry указывает на PDPT
- Bits 30-38: index в PDPT -> entry указывает на PD
- Bits 21-29: index в PD -> entry указывает на PT
- Bits 12-20: index в PT -> entry содержит physical page #
- 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 на современных 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 для процесса:
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 зарезервировано в системе:
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 байт. Структура:
Бит 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