Стандартная страница памяти в Windows скромна: четыре килобайта, и ни байтом больше. Для подавляющего большинства программ этого достаточно, но приложения, таскающие гигабайты данных - базы данных, кэши, научные расчёты, - платят за скромность страничного механизма реальными процентами производительности. На выручку приходят два родственных механизма: Address Windowing Extensions, позволяющий жонглировать физической памятью через окно, и большие страницы, увеличивающие гранулярность отображения в пятьсот двенадцать раз. Разберём, что это, как этим пользоваться из пользовательского режима и когда игра стоит свеч.
Зачем вообще понадобились AWE и большие страницы
История AWE уходит во времена 32-разрядных серверов, когда адресного пространства процесса в два-три гигабайта катастрофически не хватало под буферные пулы СУБД, а физической памяти в машину было вставлено на десятки гигабайт. AWE разрешил элегантный трюк: процесс выделяет физические страницы, превышающие его адресное пространство, и отображает нужные куски в небольшое виртуальное окно по мере необходимости. Классический образец жонглирования: окно протискивается по данным, как каретка читающей машинки, и байты становятся доступными прямо перед использованием.
С приходом 64-разрядной эры описанная тяжесть отпала - адресного пространства теперь хватает всем, - но AWE никуда не исчез, потому что его побочный параметр оказался ценнее первоначального назначения: память, выделенная через AWE, заблокирована в физических страницах и не подлежит выгрузке в файл подкачки. Именно этим пользовался и пользуется весь парк серьёзных СУБД: буферный пул, запертый в ОЗУ, даёт предсказуемую латентность, отсутствие ударов по диску и спокойствие администраторов.
Большие страницы пришли из другого конца. Аппаратные таблицы страниц поддерживают страницы по два мегабайта (а на серверных платформах и по гигабайту). Каждая смена адреса, промахивающаяся мимо буфера трансляции адресов TLB, - это проход по таблицам, измеряемый десятками тактов; для нагрузок, скачущих по гигабайтам данных случайным образом, промахи TLB съедают заметную долю времени. Большая страница покрывает в TLB ту же позицию для двух мегабайт вместо четырёх килобайт, и арифметика покрытия выглядит наглядно:
Чтобы покрыть 1 ГБ данных записями TLB:
страницы по 4 КБ -> 1073741824 / 4096 = 262144 записи
страницы по 2 МБ -> 1073741824 / 2097152 = 512 записей
соотношение -> 512 раз меньше записей на тот же объём
Типичный двухуровневый буфер трансляции держит порядка 1536 записей, значит:
на страницах 4 КБ он покрывает 1536 * 4 КБ = 6 МБ горячих данных
на страницах 2 МБ он покрывает 1536 * 2 МБ = 3 ГБ горячих данных
Отсюда понятен и предел эффекта: пока горячее рабочее множество укладывается в шесть мегабайт, промахов почти нет и большие страницы ничего не дают. Как только оно переваливает за сотни мегабайт, обычный TLB начинает промахиваться на каждом обращении, и выигрыш становится измеримым.
Механика AWE в пользовательском коде, привилегия блокировки страниц и порядок отображения
Работа с AWE из пользовательского кода начинается с права блокировки страниц в памяти - SeLockMemoryPrivilege, которое по умолчанию есть только у системных учётных записей и выдаётся через локальную политику безопасности, ветка Lock pages in memory, либо через описание учётной записи службы. Одного наличия права в политике мало: процесс обязан включить его в своём маркере, и здесь есть классическая ловушка с кодом возврата:
BOOL EnableLockMemoryPrivilege(VOID)
{
HANDLE token = NULL;
TOKEN_PRIVILEGES tp = { 0 };
BOOL ok;
if (!OpenProcessToken(GetCurrentProcess(),
TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, &token)) {
return FALSE;
}
ok = LookupPrivilegeValueW(NULL, SE_LOCK_MEMORY_NAME,
&tp.Privileges[0].Luid);
if (ok) {
tp.PrivilegeCount = 1;
tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;
ok = AdjustTokenPrivileges(token, FALSE, &tp, sizeof(tp), NULL, NULL);
// AdjustTokenPrivileges возвращает TRUE даже при частичном отказе,
// поэтому обязателен отдельный контроль GetLastError
if (ok && GetLastError() != ERROR_SUCCESS) {
ok = FALSE;
}
}
CloseHandle(token);
return ok;
}
Если пренебречь проверкой GetLastError, программа будет убеждена, что право включено, а каждый последующий вызов выделения физических страниц начнёт отвечать отказом. Дальше тройка функций исполняет весь фокус.
Два уточнения, которые экономят часы при первом внедрении. Отказ выделения больших страниц без включённой привилегии приходит с кодом ERROR_PRIVILEGE_NOT_HELD, то есть 1314, и его стоит различать явно, потому что у отказа из-за фрагментации свободной памяти код другой и лечение тоже другое. Второе: право блокировки страниц, выданное учётной записи через локальную политику безопасности, не попадает в уже существующий маркер доступа, поэтому после выдачи права нужен полноценный выход из системы и новый вход. Перезапуска службы здесь недостаточно, если она работает под той же сессией, и на стендах этот шаг забывают чаще всего. Страницами запасаются через AllocateUserPhysicalPages, виртуальное окно резервируется обычным VirtualAlloc с флагом MEM_PHYSICAL, а соединяет их функция отображения, переносящая выбранные физические страницы в окно:
SIZE_T want = 512 * 1024 * 1024; // полгигабайта
ULONG_PTR pages = want / 4096;
PULONG_PTR frames = malloc(pages * sizeof(ULONG_PTR));
AllocateUserPhysicalPages(GetCurrentProcess(), &pages, frames);
PVOID window = VirtualAlloc(NULL, want, MEM_RESERVE | MEM_PHYSICAL,
PAGE_READWRITE);
MapUserPhysicalPages(window, pages, frames); // окно показывает данные
// ... работаем, читаем, пишем ...
MapUserPhysicalPages(window, 0, NULL); // вспомогательное окно закрыто
FreeUserPhysicalPages(GetCurrentProcess(), &pages, frames);
Трюк с настоящим жонглированием выглядит так же, но список кадров подают частями: в окно два мегабайта отображают очередную порцию физических страниц, и процесс прокачивает через узкое окно массивы, кратно превышающие его адресное пространство. В 64-разрядных приложениях такая акробатика актуальна редко, а схема "запертая память под буферный пул" - регулярно: именно её выбирает SQL Server, когда в конфигурации включена настройка locked pages.
Несколько правил из практики употребления. Выделенные через AWE страницы не видны в привычной статистике рабочего набора - диагностика по обычным счётчикам недосчитывается этой памяти, и неопытный администратор, взглянув на монитор, решает, что сервер СУБД "похудел". Второе правило: запертая память не считается в стандартном лимите коммита, а при исчерпании физических страниц остальная система начинает задыхаться раньше - запускать такой процесс надо с пониманием и с запасом.
Большие страницы памяти, как их запросить у системы и что делать при отказе
Большие страницы заказываются той же фамилией привилегий и простым флагом в функции выделения:
SIZE_T minimum = GetLargePageMinimum(); // практически всегда 2 МБ
SIZE_T want = 128 * 1024 * 1024; // 128 МБ одним запросом
// размер обязан быть кратен минимуму, иначе вызов откажет
SIZE_T sz = ((want + minimum - 1) / minimum) * minimum;
PVOID big = VirtualAlloc(NULL, sz,
MEM_COMMIT | MEM_RESERVE | MEM_LARGE_PAGES, PAGE_READWRITE);
if (big == NULL) {
DWORD err = GetLastError();
LogLargePageFailure(sz, err); // ERROR_PRIVILEGE_NOT_HELD и прочие
// запасной путь: обычные страницы, функциональность не теряется
big = VirtualAlloc(NULL, sz, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
}
else {
LogLargePageSuccess(sz, big);
}
Функция GetLargePageMinimum возвращает фактический размер большой страницы на конкретной машине, и хардкодить два мегабайта нельзя: на платформах с поддержкой гигабайтных страниц значение отличается. На этом скромность API и заканчивается - и начинаются нюансы. Выделение должно быть выровнено по размеру большой страницы; запрос измеряется целиком её единицами; освобождать можно только всю область целиком. Главная же ловушка - доступность: непрерывные куски по два мегабайта система отдаёт охотно, пока память свежая, но спустя часы работы свободная память фрагментируется, и запрос начинает получать отказы. Отсюда вырос обычай серверных продуктов стартовать с захватом больших страниц сразу при загрузке процесса: кто первый встал, того и тапки.
Измерить выигрыш заранее почти невозможно, его определяет структура доступа к данным конкретной нагрузки: сканирующие и случайные паттерны над многогигабайтными структурами дают самый заметный эффект, а последовательная обработка маленьких областей часто совершенно не замечает смены гранулярности. Правильный порядок принятия решения примерно такой:
- Измерить долю промахов TLB на реальной нагрузке через счётчики процессора (у Intel это события семейства dTLB store/load misses, доступные через профилировщики VTune или PCM);
- Оценить объём горячих данных и их паттерн доступа - крупные сканируемые структуры в приоритете;
- Сделать сборку с выделением больших страниц для структуры-кандидата, сохранив запасной путь через обычные страницы при отказе;
- Прогнать A/B-замер длительности типовых запросов и зафиксировать разницу в процентах с доверительными интервалами;
- Принять решение по цифрам и описать требования к привилегиям в документации развёртывания.
Границы применимости и частые ошибки
Оба механизма относятся к тяжёлой технике, и применять их по мелочам накладно. Запертая в памяти область сжимает общий бюджет страниц системы: на сервере с соседями это быстро оборачивается перетягиванием памяти, где один жадный продукт с запертыми страницами забрал запас у остальных. Поэтому опытные команды оформляют использование запертой памяти частью проектного решения, где прописан и потолок, и правила делёжки.
Вторая ошибка связана с ожиданиями: большие страницы не делают память быстрее "саму по себе", они уменьшают стоимость трансляции адресов. На нагрузке, упирающейся в пропускную способность канала DDR, выигрыш может составить копейки, а там, где узким местом были именно промахи TLB, эффект достигал на практике десятков процентов; в опубликованных замерах баз данных прослеживаются примеры от почти нулевой разницы до трети ускорения - вот почему во внедрениях настаивают на измерении.
Третья ошибка касается диагностики. Команды, включившие механизмы, но не добавившие счётчиков их реального использования, через год не могут ответить на простой вопрос: а потребляем ли мы эту память и открылась ли она в большой странице, или запрос всегда проваливается и код молча живёт на обычных страницах? Логирование успеха и объёма каждого запроса большой памяти при старте - десятая строка кода, которая окупается при первом же инциденте.
Связка выделения памяти с правами, квотами и лимитами
За кулисами этой пары механизмов стоит общая система контроля запасов, и знакомство с ней предотвращает обидные сюрпризы. Право блокировки страниц связано с квотой рабочего набора процесса: система назначает каждому процессу предел запираемых страниц, который поднимают через SetProcessWorkingSetSizeEx либо ограничивают через задания:
SIZE_T minWs = 512ULL * 1024 * 1024;
SIZE_T maxWs = 2ULL * 1024 * 1024 * 1024;
if (!SetProcessWorkingSetSizeEx(GetCurrentProcess(), minWs, maxWs,
QUOTA_LIMITS_HARDWS_MIN_ENABLE | QUOTA_LIMITS_HARDWS_MAX_DISABLE)) {
LogQuotaFailure(GetLastError());
}
Когда сумма запросов подошла к потолку, очередной вызов AllocateUserPhysicalPages начинает отвечать отказом, и без журнала этот отказ выглядит как внезапная деградация сервиса. Административная практика корпоративных стоек давно пришла к простой схеме: выделенная учётная запись службы получает право блокировки через политику, потолок квоты поднимается под согласованный объём, а мониторинг следит, чтобы суммарная запертая память всех жильцов сервера не съедала больше согласованной доли физической памяти, где-то в районе шестидесяти-семидесяти процентов, оставляя системе воздух на кэш и служебные нужды.
Отдельного абзаца заслуживает взаимодействие AWE с режимами виртуализации. В гипервизорной среде запертая память гостя превращается в запрет на горячее перераспределение памяти между виртуальными машинами: механизмы вроде динамической памяти хоста не могут отобрать у машины её физические страницы, и планировщик ресурсов кластера строит модель исходя из фиксированной суммы. Команды, впервые внедряющие запертую память в виртуализованной среде, как правило, проходят один и тот же урок: включают механизм на тестовых стендах, меряют, потом оформляют требования к резервированию памяти на уровне кластера, иначе технология баллона памяти гипервизора и запертые страницы гостя начинают третировать друг друга месяцами.
И финальный мазок о совместимости: фрагментированные выделения больших страниц лечатся периодическим сбросом, накопленным опытом операций перезапуска процесса. Если в требования к эксплуатации сервиса входит непрерывная работа месяцами подряд, стоит заложить церемонию обслуживания: остановка, очистка, повторный захват страниц, - именно в этом порядке, причём с проверкой факта получения блока нужного размера. Тривиально? Да. Забыто у девяти из десяти команд, впервые внедривших большие страницы? К сожалению, статистика полевых внедрений подтверждает именно это, и именно поэтому рука опытного администратора тянется к журналу старта сервиса прежде, чем к любым другим источникам.
Когда брать в работу и как отлаживать
Итоговый ориентир складывается простой. AWE и запертая память уместны там, где продукт сам управляет своими мегабайтами кэша и не должен делиться ими с файлом подкачки - базы данных, очереди сообщений, in-memory хранилища. Большие страницы заходят там, где замеры показали, что трансляция адресов ест заметную долю тактов: научные расчёты, хеш-таблицы на десятки гигабайт, графы, векторные индексы.
Для наблюдения снаружи полезны стандартные инструменты: RAMMap различает запертую память отдельной строкой, Process Explorer показывает её же по процессам, а отладчик ядра даёт вид снизу:
kd> !memusage
kd> !vm
kd> !address -summary
Тройка правил снимает почти все вопросы внедрения: промахи трансляции измеряют до включения механизма, захват больших страниц выполняют на старте процесса, а фактическое размещение журналируют при каждом запуске. Стоит добавить и наблюдение о культуре команды: проекты, которые взяли большие страницы и AWE как продуманную часть архитектуры, в поле ведут себя ровно и предсказуемо, тогда как импульсивное включение по мотивам чужой статьи оборачивается месяцами расследований "где утекает память", - счётчики-то привычные молчат, а память стоит на месте в запертой зоне, и виновника приходится искать методом исключения, съедающим часы. Урок из этих историй один и тот же: сначала измерение и протокол, потом флаг в коде, а никак не наоборот, и это правило стоит написать на стене в каждой команде, работающей с крупными объёмами данных на серьёзном железе.
Механизмы старые, почти дедовские, но использовать их грамотно умеют нечасто, и потому они до сих пор отличают инженерную зрелость от простого перечисления флагов API.