Многопоточный код в режиме ядра живёт по жёстким правилам: высокий IRQL, отсутствие подкачки, ограничения на стек. Но есть одно обстоятельство, о котором авторы драйверов вспоминают в последнюю очередь, когда профилировщик уже показывает провал производительности: кэш процессора. Драйвер сетевого фильтра, обрабатывающий миллионы пакетов в секунду, или файловый minifilter, отслеживающий операции ввода-вывода, может терять до 60-70% потенциальной пропускной способности не из-за блокировок, а из-за того, что потоки на разных ядрах постоянно дерутся за одни и те же строки кэша. Практическая особенность таких падений в том, что код выглядит правильно, тесты проходят, а под нагрузкой на восьми и шестнадцати ядрах всё вдруг упирается в стену. Эта статья разбирает, как устроены кэши современных процессоров применительно к kernel-mode коду Windows, где прячутся основные потери и как их измерить и устранить.
Иерархия кэшей L1, L2, L3 и цена промаха в реальных числах
Современный серверный или настольный процессор x86-64, будь то Intel Xeon или AMD EPYC, хранит данные в трёх уровнях кэша. L1 на ядро у Intel Core и Xeon последних поколений занимает 48 КБ под данные и 32 КБ под инструкции на Golden Cove, L2 поднялся до 1,25-2 МБ на ядро у Raptor Lake и до 2 МБ у Sapphire Rapids, а L3 делится между всеми ядрами и достигает 30-60 МБ. Числа говорят сами за себя: доступ к L1 занимает примерно 4-5 тактов, к L2 уже 12-14 тактов, к L3 около 40-50 тактов, а промах в оперативную память DDR4-3200 обходится в 200-300 тактов. Для кода ядра, где обработчик прерывания или DPC исполняется тысячи раз в секунду, разница между попаданием в L1 и промахом в DRAM складывается в микросекунды на каждую операцию.
Эти такты полезно сразу перевести в наносекунды и в ёмкость кэша, иначе абстрактные "40-50 тактов" не дают ощущения масштаба. При частоте ядра 3,0 ГГц один такт равен 0,33 нс:
Уровень Тактов Время при 3,0 ГГц
L1D 4-5 1,3-1,7 нс
L2 12-14 4,0-4,7 нс
L3 40-50 13,3-16,7 нс
DRAM 200-300 66,7-100 нс
Ёмкость считается из размера строки. Строка на x86-64 равна 64 байтам, поэтому в L1D объёмом 48 КБ помещается 768 строк, а в L2 объёмом 1,25 МБ - 20000 строк:
48 * 1024 / 64 = 768 строк в L1D
1250 * 1024 / 64 = 20000 строк в L2
Отсюда прямой инженерный критерий: если горячее рабочее множество одного ядра драйвера превышает 768 строк, то есть 48 КБ реально читаемых данных, оно перестаёт жить в L1 и каждое обращение начинает стоить 4 нс вместо 1,3.
Практический вывод простой: горячие структуры данных драйвера должны быть малыми, компактными и размещаться рядом. Структура контекста устройства на 2 КБ, из которой в обработчике DPC читаются два поля, гарантированно будет вымывать соседние строки кэша и тащить за собой промахи. Kernel-mode разработчику стоит держать в голове бюджет: обработчик ISR на уровне DIRQL, исполняющийся дольше пары микросекунд, уже начинает мешать всей системе, и каждый лишний промах кэша в нём превращается в реальные такты задержки.
Ложное совместное использование строк кэша между ядрами
Самая коварная проблема многопоточного kernel-mode кода называется false sharing, то есть ложное разделение. Протокол когерентности MESI и его наследники работают на гранулярности строки кэша, которая на всех актуальных процессорах Intel и AMD равна 64 байтам. Если два потока на двух разных ядрах пишут в разные переменные, но эти переменные лежат в одной строке кэша, процессор на каждую запись проводит согласование: строка переходит из состояния Modified одного ядра, инвалидируется в другом и гоняется через межъядерный интерконнект. Стоимость такой переброски составляет 60-120 тактов, сопоставимо с промахом L3, только происходит она на каждую запись. В абсолютных величинах при 3,0 ГГц это 20-40 нс на один инкремент, и дальше начинается простая арифметика. Один счётчик, растущий миллион раз в секунду, съедает 20-40 миллисекунд ядра, то есть 2-4% его бюджета. Если в одну строку неудачно легло восемь таких счётчиков и ядро обрабатывает по миллиону событий на каждый, потери доходят до 240 миллисекунд на секунду работы: почти четверть ядра уходит в трафик согласования, хотя ни одной блокировки в коде нет.
Классический сценарий в драйвере: массив счётчиков статистики по процессорам, записанный подряд. Поток на первом ядре инкрементирует счётчик номер один, поток на втором ядре счётчик номер два, и оба счётчика делят одну строку. Формально гонки данных нет, штучные переменные разные, а производительность падает в разы. Опытные авторы драйверов встречали это в виде необъяснимого провала: на одном ядре миллион операций в секунду, на восьми ядрах восемьсот тысяч. Виновата не схема синхронизации, а география памяти.
Отдельная история это структуры вроде LIST_ENTRY и KSPIN_LOCK, разбросанные по общему контексту. Спин-блокировка, захваченная в тесном цикле, держит свою строку кэша в состоянии Modified на одном ядре, и если рядом с ней оказалось поле, которое другие потоки хоть иногда читают, они всё равно получают invalidations и ждут. Поэтому правило выравнивания касается не только переменных, которые пишут разные потоки, но и соседства блокировок с горячими данными.
Выравнивание структур на границу 64 байт и разделение полей
Борьба с ложным разделением начинается с компилятора. В Visual Studio 2022 с комплектом WDK для Windows 11 версии 22H2 (сборка 10.0.22621.0) доступны __declspec(align(64)) и alignas(64) для C++, а ещё проще использовать макрос C_ASSERT и типизированные структуры с явным расширением. Пример минимальной per-CPU структуры:
#define CACHE_LINE_SIZE 64
typedef struct DECLSPEC_ALIGN(CACHE_LINE_SIZE) _PER_CPU_STATS {
volatile LONG64 PacketsProcessed;
volatile LONG64 BytesTransferred;
volatile LONG Errors;
UCHAR Padding[CACHE_LINE_SIZE - 3 * sizeof(LONG64) - sizeof(LONG)];
} PER_CPU_STATS, *PPER_CPU_STATS;
C_ASSERT(sizeof(PER_CPU_STATS) == CACHE_LINE_SIZE);
Макрос DECLSPEC_ALIGN разворачивается в __declspec(align(64)), а массив Padding добивает структуру ровно до строки кэша. Проверка C_ASSERT ловит ошибку на этапе компиляции, если размер поплыл.
Здесь спрятана ловушка, на которую попадают почти все. Пул Windows гарантирует выравнивание выданного адреса только на 16 байт, константа MEMORY_ALLOCATION_ALIGNMENT равна 16, а не 64. Поэтому DECLSPEC_ALIGN в объявлении структуры выровняет её размер и внутренние поля, но не начало выделенного блока, и соседние per-CPU счётчики всё равно способны лечь в одну строку. Массив приходится выравнивать вручную:
PPER_CPU_STATS AllocatePerCpuStats(PUCHAR* RawPointer)
{
ULONG processors = KeQueryMaximumProcessorCountEx(ALL_PROCESSOR_GROUPS);
SIZE_T bytes = ((SIZE_T)processors * sizeof(PER_CPU_STATS)) + CACHE_LINE_SIZE;
PUCHAR raw = (PUCHAR)ExAllocatePool2(POOL_FLAG_NON_PAGED, bytes, 'StaP');
if (raw == NULL) {
return NULL;
}
UINT_PTR misalign = ((UINT_PTR)raw) & (CACHE_LINE_SIZE - 1);
UINT_PTR offset = misalign ? (CACHE_LINE_SIZE - misalign) : 0;
*RawPointer = raw;
RtlZeroMemory(raw + offset, bytes - offset);
return (PPER_CPU_STATS)(raw + offset);
}
Запас в одну строку кэша в размере выделения нужен именно под этот сдвиг, без него выровненный адрес выйдет за конец блока. Освобождать потом придётся исходный указатель raw, а не возвращённый выровненный, поэтому он сохраняется отдельно. ExAllocatePool2 обнуляет память сам, явное RtlZeroMemory здесь страховка на случай замены аллокатора.
Второй приём называется разделением горячего и холодного. Поля, которые читаются в каждом DPC, собираются в отдельную структуру, выровненную на 64 байта, а всё редко трогаемое (пути к файлам, строки реестра, хэндлы событий) уезжает в хвост контекста устройства. Тогда первая строка кэша контекста всегда горячая и держится в L1, а холодная часть не вымывает её при обращениях. Изменение выглядит скучно в коде, но на синтетике драйвера сетевого фильтра под нагрузкой разница между смешанным и разделённым контекстом достигает 25-30% по пропускной способности.
Локальность памяти в пулах и осознанное выделение через ExAllocatePool2
С Windows 10 версии 2004 старые вызовы ExAllocatePool заменены на ExAllocatePool2 с явным указанием флага POOL_FLAG_NON_PAGED. Кроме функциональных отличий есть и кэш-побочный эффект: свежие пулы ядерных аллокаторов выравнивания хранят метаданные сегментами, и частые аллокации-освобождения мелких блоков дробят общие строки. Драйвер, который на каждый пакет выделяет и освобождает блок на 128 байт из non-paged pool, гарантированно создаёт кэш-конкуренцию между обработчиками на разных ядрах, к тому же сам аллокатор держит для себя внутренние списки с локами.
Правильная альтернатива в выделении lookaside-листов через ExAllocateFromPagedLookasideList или собственных пулов по размеру объектов, а вместо этого чаще и лучше всего работает простая схема pre-allocation: на этапе загрузки драйвер создаёт массив контекстов по числу процессоров из KeQueryMaximumProcessorCountEx, выравнивает каждый элемент на CACHE_LINE_SIZE и отдаёт свободные элементы через стек без блокировок в стиле singly-linked list с InterlockedPushEntrySList. SList реализован через _InterlockedCompareExchange128 без блокировки и с изящным подходом к проблеме ABA через счётчик глубины, и это один из немногих примитивов синхронизации, который сам по себе не губит кэш, пока сами элементы выровнены.
Отдельная тема это холодный кэш после перезагрузки драйвера. Обработчик DriverEntry, который строит большие таблицы, вызывает их последовательное чтение и записывает в строки кэша всю память подряд, вымывая L1 и L2 ещё до начала работы. Ощутимо быстрее работает warm cache паттерн: после выделения структур драйвер проходит по ним с записью нулей вдоль гранулярности строки кэша, чтобы первый же DPC находил структуры уже резидентными в L2.
Атомарные операции, барьеры памяти и инструкции prefetch
Каждый вызов _InterlockedIncrement64 или _InterlockedCompareExchange на процессорах Intel и AMD реализуется через инструкцию LOCK XADD или LOCK CMPXCHG, которая захватывает строку кэша в монопольное состояние M. Если пять ядер одновременно инкрементируют один глобальный счётчик, строка кэша превращается в пограничный пункт, через который проталкиваются все потоки. Замер на сервере с двумя процессорами Xeon показывает, что инкремент одной и той же переменной из тридцати двух потоков стоит почти в пятьдесят раз дороже, чем инкремент локального поля на одном потоке. Отсюда вывод: глобальные счётчики должны агрегироваться из per-CPU копий, а чтение общей суммы вычисляется только по требованию циклом по ядрам, как это делает сама Windows для своих структур NPFS и для системных счётчиков работы пула памяти.
Инструкции prefetch работают в кодовом ядре, но позволяют влиять на процессор ещё до обращения к данным. В заголовках WDK доступны встроенные функции _mm_prefetch с константами _MM_HINT_T0, _MM_HINT_T1 и _MM_HINT_T2. Хороший приём внутри кода обработки пакетов: перед разбором заголовка и разбором тела вызвать _mm_prefetch с _MM_HINT_T0 на адрес полезной нагрузки за 16-32 байта в будущее, чтобы к моменту реального чтения строка уже ехала в L1. Мизерная штука, но в тесных циклах даёт 3-7% к пропускной способности. Держать в голове нужно два ограничения. Подсказка не привилегированная и ничего не гарантирует: процессор вправе отбросить её, если очередь обращений к памяти заполнена. И ставить её имеет смысл за 100-300 тактов до реального чтения, то есть примерно за одну итерацию цикла разбора, иначе строка просто не успеет доехать.
Барьеры памяти тоже влияют на кэш. Инструкция MFENCE, которая вызывается через KeMemoryBarrier, заставляет процессор дождаться стабилизации всех ожидающих записей в write-combine buffers, и это стоит заметных тактов. Драйвер, который ставит полный барьер на каждую запись в кольцевой буфер событий, теряет 10-15% против более слабой семантики с Release и Acquire флагами, реализуемой через _InterlockedExchange с volatile указателями.
Инструменты профилирования кэш-промахов и верификация результата
Сказать, что драйвер страдает от false sharing, глазами нельзя, нужны счётчики. В Windows есть два рабочих пути: монитор производительности со своими наборами счётчиков и пара xperf/WPA из комплекта Windows Performance Toolkit, который входит в состав SDK. Аппаратные счётчики производительности PMC доступны не всегда: их состав зависит от модели процессора, а на машине под гипервизором часть из них уже занята, поэтому работу начинают не с записи, а с просмотра того, что вообще доступно:
wpr -pmcsources
На серверном Xeon ответ выглядит примерно так, и для поиска ложного разделения из этого списка нужны три имени:
Processor performance counters:
TimerInterrupts
CPUCycles
Instructions
BranchInstructions
BranchMisses
TotalCycles
CacheReferences
CacheMisses
BusCycles
RefCycles
Дальше счётчики подключаются прямо к запуску записи, и промахи начинают сниматься с привязкой к образцам стека:
wpr -start CPU -pmc CacheMisses,CacheReferences,TotalCycles -filemode
rem запись держат 20-30 секунд под боевой нагрузкой драйвера
wpr -stop cache-trace.etl
xperf -on PROC_THREAD+LOADER+INTERRUPT+DPC -stackwalk DPC
rem этот трейс показывает, в каком DPC и ISR сидят промахи
xperf -d isr-dpc.etl
В Windows Performance Analyzer полученный файл .etl раскладывает кэш-промахи по стекам вызовов, и там сразу видно, в каком именно DPC обработчика сетевой карты концентрируются промахи. Для проверки числовых изменений после рефакторинга удобно сравнить пару трассировок до и после: ожидаемое снижение промахов от 2-3% до десятков процентов на миллион обработанных пакетов означает, что выравнивание сработало правильно.
Довольно часто выясняется, что самое большое падение даёт не сам код, а ожидание на межсоединении процессоров. Карту кэшей и доменов согласованности печатает утилита CoreInfo из набора Sysinternals, и по ней проверяют, действительно ли потоки, гоняющие одну и ту же строку, сидят на разных ядрах:
coreinfo -c
L1D * Logical Processor 0-1 share a 48 KB, 12-way, 64 byte line cache
L1I * Logical Processor 0-1 share a 32 KB, 8-way, 64 byte line cache
L2 * Logical Processor 0-1 share a 1280 KB, 10-way, 64 byte line cache
L3 * Socket 0 Logical Processor 0-15 share a 30 MB, 12-way, 64 byte line cache
Строка L3 с пометкой Socket 0 и есть та граница, за которой переброска строки стоит уже не десятки, а сотни тактов. Если два пишущих потока попали в разные сокетные домены L3, ложное разделение обойдётся дороже всего возможного.
Практический чек-лист по оптимизации кэша в многопоточном kernel-mode коде собирается в короткий список:
- Все per-CPU структуры и горячие поля выровнять на 64 байта с проверкой через C_ASSERT;
- Глобальные счётчики заменить на агрегированные per-CPU копии с суммированием по требованию;
- Тесные перекрёстные обращения к LIST_ENTRY и KSPIN_LOCK отодвинуть от горячих полей и разделить в отдельные строки;
- Частые аллокации заменить на pre-allocation пулы и lookaside-листы, чтобы не трогать общий менеджер пула;
- В DPC и ISR использовать _mm_prefetch для заранее встречаемых адресов;
- Снимать трассировки xperf/WPA до и после изменений и читать счётчики кэш-промахов по стекам.
Работа с кэшем процессора в многопоточном kernel-mode коде это не тайное знание и не везение, а дисциплина выравнивания, измерения и повторного измерения. Драйверы, которые прошли через такую чистку, на восьмиядерных серверах держат миллион и больше сетевых операций в секунду, не влезая ни в какие архитектурные крайности, и это достигается только за счёт внимательности к шести основным темам: строкам кэша, ложному разделению, локальности пулов, барьерам памяти, prefetch и верификации через профилировщик.