В прошлой главе про страничную организацию мы научились бить
память на одинаковые кусочки — страницы (page) у процесса и кадры (frame,
PFN — physical frame number) в физической RAM. Чтобы знать, в каком кадре лежит
каждая страница, мы завели таблицу страниц (page table) — массив, где по номеру
страницы (VPN — virtual page number) лежит запись (PTE — page table entry) с номером
кадра и битами доступа.
У такой линейной (плоской) таблицы есть серьёзный недостаток: она занимает
много места, и почти всё это место тратится впустую.
Посчитаем для классического случая: 32-битное адресное пространство и страница
4 КБ.
4 МБ на один процесс. А процессов в системе — сотни. 100 процессов = 400 МБ
оперативки, потраченных только на таблицы страниц, ещё до того как программы что-то
полезное сделали. И это 32 бита; для 64-битных адресов плоская таблица была бы
астрономической (об этом ниже).
Хуже другое: эта таблица почти вся пустая. Типичный процесс
использует крошечный кусок своего адресного пространства — немного кода, немного
кучи внизу и немного стека вверху. Середина — гигантская дыра, которую никто не
трогает. Но плоский массив обязан хранить запись для каждой страницы, даже для
тех миллионов, что никогда не будут существовать.
Адресное пространство процесса (схематично): 0x00000000 ┌───────────┐ ← код, данные (используется) │███████████│ ├───────────┤ ← куча, растёт к большим адресам │███████████│ ├───────────┤ │ │ │ │ ← ОГРОМНАЯ ДЫРА │ пусто │ (миллионы неиспользуемых страниц, │ │ но плоская таблица хранит PTE для КАЖДОЙ!) │ │ ├───────────┤ │███████████│ ← стек, растёт к меньшим адресам 0xFFFFFFFF └───────────┘
Платить 4 МБ за описание пустоты расточительно. Вся эта глава о том, как перестать
за неё платить.
Таблица ужалась в 4 раза. Здорово? Не очень. Появляется новая беда — внутренняя
фрагментация (internal fragmentation). Если программе нужно всего 1 КБ под буфер,
а минимальный кусок памяти — целая страница 16 КБ, то 15 КБ пропадают зря: они
выделены, но не используются. Чем крупнее страница, тем больше такого «обрезка»
теряется на каждом мелком выделении.
Поэтому большинство систем держат базовую страницу 4 КБ. Большие страницы (huge
pages, об этом в главе про TLB) применяют точечно — для БД и
JVM/Go-heap, где много памяти и важно разгрузить кэш трансляций, а не как
универсальное лекарство. Один параметр (размер страницы) не может одновременно
угодить и таблице, и фрагментации. Нужно что-то умнее.
Вторая идея — вспомнить сегментацию. Раз пустота лежит
большими непрерывными зонами (между кучей и стеком), заведём отдельную
таблицу страниц на каждый сегмент (код, куча, стек) и будем хранить только те
записи, что реально нужны сегменту. Для каждого сегмента — своя пара регистров base
(где в памяти лежит его таблица) и bounds (сколько в ней валидных записей).
Это реально экономит память: таблицы покрывают только живые сегменты, а не всё
пространство целиком. Но гибрид тащит за собой проблемы сегментации: таблицы разных
сегментов имеют разный размер, их надо где-то размещать, и в свободной памяти
заводится внешняя фрагментация (external fragmentation) — россыпь дырок разного
калибра, в которые неудобно укладывать новые таблицы. Решение работает, но костыльно
и негибко. Хочется механизма попроще и поровнее.
Центральная идея главы: саму таблицу страниц тоже разбить на страницы, а
затем завести отдельную маленькую таблицу, директорию страниц (page directory),
которая говорит, какие куски большой таблицы вообще существуют, а какие можно не
создавать.
Аналогия: книга на 1000 страниц. Плоская таблица — это как
обязательное оглавление, где для каждой из 1000 страниц напечатана строчка,
даже если 950 страниц пустые. Многоуровневая таблица — это оглавление по главам:
сначала список глав (директория), и только для тех глав, что реально написаны, есть
подробное оглавление страниц. Пустые главы в оглавлении не упоминаются вообще — и
бумага не тратится.
Технически: большую таблицу режем на куски размером ровно в одну страницу. Каждый
такой кусок — это маленькая таблица второго уровня. Запись в директории (PDE — page
directory entry) указывает на один такой кусок ИЛИ помечена как невалидная. Если
целый кусок таблицы описывает только пустоту — мы его просто не создаём, а в
директории ставим «невалидно». Вот так мы перестаём платить за дыры.
ПЛОСКАЯ ТАБЛИЦА (всё подряд, даже пустое): таблица (4 МБ непрерывно в памяти) ┌────────────────────────────────────────────┐ │PTE PTE пусто пусто пусто ... пусто PTE PTE │ ← хранится ВСЁ └────────────────────────────────────────────┘МНОГОУРОВНЕВАЯ (директория + только живые куски): Директория (1 страница) Куски таблицы (по 1 странице каждый) ┌───────────┐ │ PDE0 ───────────────────► ┌─────────────┐ (код/данные) │ PDE1 → невалидно (нет!) │ PTE PTE ... │ │ PDE2 → невалидно (нет!) └─────────────┘ │ ... │ эти куски ┌─────────────┐ (стек) │ PDE1023 ─────────────────► │ ... PTE PTE │ └───────────┘ НЕ существуют └─────────────┘ в памяти!
Экономия видна сразу: вместо 4 МБ сплошного массива у нас директория (одна страница,
4 КБ) плюс ровно столько кусков по 4 КБ, сколько реально нужно живым областям. Для
типичного процесса это директория + 2-3 куска = несколько десятков КБ вместо 4 МБ.
Раньше адрес делился на две части: VPN и offset. Теперь VPN сам разбивается на
индекс в директории и индекс внутри куска таблицы. Offset не трогаем — он
по-прежнему адресует байт внутри страницы.
Посчитаем для 32 бит, страница 4 КБ, PTE = 4 байта. В одну страницу-кусок влезает
4096 / 4 = 1024 = 2^10 записей. Значит на индекс внутри куска нужно 10 бит. Под
директорию остаётся 20 − 10 = 10 бит.
32-битный адрес, 2 уровня: ┌── PD index ──┬── PT index ──┬──── offset ────┐ │ 10 бит │ 10 бит │ 12 бит │ └──────────────┴──────────────┴─────────────────┘ какой PDE какой PTE байт внутри в директории внутри куска страницы 2^10 = 1024 PDE в директории 2^10 = 1024 PTE в каждом куске 2^12 = 4096 байт смещение
Когда процессор встречает виртуальный адрес и в TLB нет готового
ответа (промах, TLB miss), он выполняет page-table walk — буквально «прогулку»
по таблице. Для двух уровней это два обращения в память перед тем, как мы доберёмся
до данных.
Разберём конкретный адрес 0x00C11064. Сначала разрежем его на части:
Физический адрес директории текущего процесса хранится в регистре CR3 (на
x86), с него обход и начинается.
Виртуальный адрес → разбили на (PD=3, PT=17, off=100)ШАГ 0: CR3 ──► физadress директории ┌──────────────┐ШАГ 1: берём │ PDE0 │ PDE[3] │ PDE1 │ │ PDE2 │ ──────►│ PDE3 ────────┼──┐ валиден? да → адрес куска таблицы │ ... │ │ └──────────────┘ │ ▼ШАГ 2: пришли в кусок таблицы ┌──────────────┐ (это уже 2-е обращение │ PTE0 │ в память), берём │ ... │ PTE[17] │ PTE17 ────────┼──┐ валиден? да → PFN │ ... │ │ └──────────────┘ │ ▼ШАГ 3: склеиваем PFN + offset(100) = физический адрес, читаем сами данные (3-е обращение в память)
Досчитаем с конкретными числами. Пусть CR3 = 0x00100000, в PDE[3] записан кадр
0x2A (там лежит нужный кусок таблицы), а в PTE[17] этого куска — PFN 0x5C3.
Каждая запись занимает 4 байта, поэтому адрес записи = начало таблицы + индекс × 4.
Offset, как и в одноуровневой таблице, переезжает без изменений.
Главное: если на шаге 1 окажется, что PDE[3] невалиден, значит, всего
этого куска таблицы не существует. Память на него не тратится, и сразу понятно, что
таких страниц у процесса нет: процессор вызывает ядро ОС
(page fault). Не нужно было заранее выделять 1024 пустых PTE,
кусок просто не заведён. Один кусок покрывает 1024 страницы × 4 КБ = 4 МБ адресов.
Бесплатного ничего не бывает. Плоская таблица — это одно обращение в память на
трансляцию (взять PTE), плюс обращение за данными. Двухуровневая — это два
обращения за трансляцию (директория, потом кусок), плюс за данными. Каждое лишнее
хождение в RAM — это десятки-сотни наносекунд, целая вечность для CPU.
Почему это всё равно работает и не убивает производительность? Потому что нас спасает
TLB — маленький сверхбыстрый кэш готовых трансляций VPN→PFN прямо в процессоре.
Walk по уровням случается только при промахе TLB. А промахи редки: благодаря
локальности программа подолгу топчется по одним и тем же страницам, и
99%+ обращений ловят попадание в TLB за один такт. Глубокая многоуровневая таблица
платит дорого лишь изредка, а в обычном горячем цикле её как будто и нет.
Обычное обращение (TLB hit): адрес → TLB → PFN → данные (быстро, 1 обращение в RAM)Промах (TLB miss), 2-уровневая таблица: адрес → TLB(нет) → walk: директория → кусок → PFN → заполнили TLB → данные (дорого, 3 обращения в RAM)
Это и есть фундаментальный размен (trade-off): многоуровневая таблица экономит
память ценой более дорогого промаха. На пустоту мы больше не тратимся, а
редкие дорогие walks амортизируются кэшем.
Вернёмся к 64 битам. Процессоры x86-64 используют не все 64 бита адреса, а 48
(2⁴⁸ байт = 256 ТиБ). Старшие 16 бит обязаны повторять бит 47, такие адреса
называют каноническими; поэтому пользовательские адреса выглядят как
0x00007f..., а адреса ядра ОС как 0xffff....
Сколько заняла бы плоская таблица? Страница 4 КБ, значит VPN = 48 − 12 = 36 бит,
2³⁶ записей по 8 байт:
2^36 × 8 байт = 2^39 байт = 512 ГиБ на КАЖДЫЙ процесс
Две ступени тоже не спасают: директория получилась бы гигантской. Поэтому x86-64
использует 4 уровня (на новых процессорах ядро может включить 5 уровней и
57-битные адреса). Каждый уровень индексируется своими 9 битами.
Принцип тот же, только walk теперь четырёхшаговый, и ветку на любом уровне можно
не создавать, если за ней пустота. Каждая таблица любого уровня занимает ровно одну
страницу, поэтому её можно положить в любой свободный кадр. Промах TLB при 4
уровнях стоит до 4 обращений в память (процессоры кэшируют верхние уровни, поэтому
в среднем меньше), и это ещё один аргумент, почему TLB так важен.
Разрежем на индексы реальный адрес из пользовательской половины:
0x1ff — это девять единиц в двоичном виде, маска для одного индекса; >> 12,
>> 21, >> 30, >> 39 сдвигают нужную девятку бит в младшие разряды.
Сколько памяти уходит на таблицы у реального маленького процесса? Пусть код и куча
лежат в начале адресного пространства (индекс PML4 = 0), а стек у верхней границы
пользовательской половины (индекс 255). Нужны одна таблица PML4 и по одной цепочке
PDPT → PD → PT для каждой из двух областей:
28 КБ вместо 512 ГиБ. Реальному сервису с сотнями мегабайт памяти нужно больше
таблиц нижнего уровня (одна PT покрывает 512 × 4 КБ = 2 МБ), но счёт идёт на
мегабайты, а не на сотни гигабайт.
Большие страницы из главы TLB хорошо ложатся на эту схему. Если в
записи уровня PD стоит специальный флаг (PS, page size), обход на ней
останавливается: запись указывает не на таблицу PT, а сразу на кадр размером 2 МБ.
Offset тогда занимает 9 + 12 = 21 бит. Если флаг стоит на уровне PDPT, страница
занимает 1 ГиБ и offset 30 бит. Меньше уровней — короче обход при промахе и меньше
таблиц.
Многоуровневая берёт лучшее: платим только за используемые области, куски ровно по
странице (никакой внешней фрагментации), мелкая страница (никакой внутренней). Платим
за это лишь более дорогим — но редким — промахом TLB.
Есть и совсем другой подход — инвертированная таблица страниц (inverted page
table). Идея: вместо «по таблице на каждый процесс» завести одну таблицу на всю
систему — по записи на каждый физический кадр. То есть таблица описывает не «какая
страница где», а «в этом кадре лежит страница такого-то процесса».
Память это экономит шикарно: размер таблицы зависит от объёма физической RAM, а не от
числа процессов и размера их адресных пространств. Минус — поиск: чтобы по (процесс,
VPN) найти кадр, нужен не прямой индекс, а просмотр/хэширование. На практике строят
хэш-таблицу поверх. Такой подход применяли, например, в PowerPC. Знать про него
полезно, но мейнстрим (x86-64, ARM) — это многоуровневые таблицы.
va
Виртуальный адрес = 35 (8 бит)
00
L1 = 0
10
L2 = 2
0011
offset = 3
Внешний каталог (L1)
[0]→подтаблица ←
[1]→не выделена
[2]→подтаблица
[3]→не выделена
Подтаблица (L2)
[0]→кадр 3
[1]→невалидна
[2]→кадр 5 ←
[3]→невалидна
✓ трансляция успешнаPFN 5 · offset 3 = pa 83Успех: пройдены два уровня таблицы (за это платим лишним обращением в память)
Адрес делится на три части: L1 (2 бит) | L2 (2 бит) | offset (4 бит)
va = 35 → L1 = 0, L2 = 2, offset = 3
Внешний каталог[0] → подтаблица существует, читаем её (1-е обращение в память)
Подтаблица[2] = кадр 5 (2-е обращение в память)
pa = PFN 5 · 16 + 3 = 83
Адрес делится на L1|L2|offset. Внешний каталог указывает на подтаблицы; если запись каталога пуста — подтаблица вообще не выделена (так экономится память). За это платим вторым обращением в память.
Проверь себя· Многоуровневые таблицы страниц
В чём главная проблема плоской (линейной) таблицы страниц?
За счёт чего многоуровневая таблица экономит память?
32-битный адрес, страница 4 КБ, PTE = 4 байта, двухуровневая таблица. Сколько бит отводится под индекс в директории (PD index)?
Сколько обращений в память нужно для трансляции при промахе TLB в двухуровневой таблице (не считая чтения самих данных)?
Что верно про размен (trade-off) многоуровневых таблиц? (выберите все верные)
Почему увеличение размера страницы (например до 16 КБ) — плохое универсальное решение проблемы размера таблицы?