Каждый проход по данным стоит тактов, и цена зависит от того, где данные лежат: в L1, L2, L3, локальной DRAM или в удалённом узле NUMA. Разница между лучшим и худшим случаем достигает двух порядков, а modern-процессоры не прощают случайных промахов. В статье разобраны инструменты для честных замеров: perf stat и perf mem для попаданий и промахов, perf c2c для ложного разделения кеш-линий, numactl для изоляции нагрузки, а также прикладные значения латентностей в наносекундах и тактах для L1, L2, L3 и DRAM на Intel и AMD.
Иерархия памяти, латентность и влияние частот на пропускную способность
Скорость доступа к данным определяет уровень кеша, в котором они найдены. В типичном ядре Intel (Raptor Lake) L1 даёт 4 такта, L2 12 тактов, L3 40-50 тактов, локальная DRAM 80-100 нс, а удалённый NUMA-узел добавляет 40-80 нс сверху. Значение в наносекундах пересчитывается в такты через частоту: на 3 ГГц один такт это 0,33 нс, поэтому L1 4 такта это 1,3 нс, L3 50 такт это 16 нс. На AMD (Zen 4) L1 4 такта, L2 12, L3 40-45, DRAM 75-90 нс, удалённый NUMA 30-60 нс сверху.
Латентность измеряется на загрузке строки кеша, 64 байта. Если линия уже в L1, процессор тратит минимум. Если промах L1 и попадание в L2, оплачивается L1-промах плюс доступ к L2. При промахе L3 запрос уходит на контроллер памяти, и здесь включается NUMA-геометрия: локальный контроллер отдаёт данные быстрее, чем контроллер соседнего сокета, потому что проходит меньше переходников.
Латентность DRAM в наносекундах не зависит от частоты, потому что тайминги DDR5 (например, CL40-40-40 при 5600 MT/s) масштабируются вместе с тактом. Но полоса растёт линейно: DDR5-4800 даёт 38.4 ГБ/с на канал, DDR5-5600 44.8 ГБ/с, DDR5-6400 51.2 ГБ/с. На восьмиканальной платформе Sapphire Rapids (8 каналов на сокет) пиковая полоса составляет 307 ГБ/с при 4800 и 410 ГБ/с при 6400. Реальная устойчивая полоса STREAM Triad составляет 85-90% от пиковой. На AMD Genoa (12 каналов на сокет, DDR5-4800) пиковая полоса 460 ГБ/с на сокет, реальная STREAM 390-400 ГБ/с. Удалённый доступ через Infinity Fabric 3.0 ограничен полосой линка: 32 ГБ/с в одну сторону на линк, при 4 линках между сокетами 128 ГБ/с. Поэтому на AMD удалённый доступ не только медленнее по латентности, но и ограничен по полосе, и потоки записи в удалённый узел упираются в потолок Infinity Fabric раньше, чем в контроллер памяти.
Практика показывает, что оптимизация начинается не с железа, а с раскладки данных. Два соседних потока, пишущих в одну кеш-линию на разных ядрах, генерируют поток инвалидаций, и эффективная латентность взлетает до сотен тактов. Первым делом ищут false sharing через perf c2c, потом раскладывают данные по узлам NUMA через numactl, и только потом крутят частоты.
Чтение счетчиков попаданий и промахов через perf stat и perf mem
Базовый профиль нагрузки даёт perf stat с событиями загрузки:
perf stat -e LLC-loads,LLC-load-misses,L1-dcache-loads,L1-dcache-load-misses ./programma
LLC-loads это обращения к L3, L1-dcache-loads обращения к L1. Отношение misses к loads даёт процент промахов каждого уровня. Для точечного профиля используется perf mem, который работает через PEBS, Precise Event-Based Sampling, и сообщает, откуда именно пришли данные:
perf mem record -g ./programma
perf mem report --mem-mode=load
Вывод показывает каждое место загрузки с указанием локальности: L1, L2, L3, DRAM, Remote DRAM, Remote Cache. Колонка Weight выражает вклад в общее время. Распределение веса по уровням сразу отвечает, где программа теряет такты. Важно: perf mem требует поддержки PEBS в процессоре и в ядре, на старом железе (до Skylake) работает ограниченно.
Для агрегации по функциям удобен ключ --mem-mode=load,local_weight,remote_weight. Пример вывода для функции matrix_mul:
Local DRAM: 12.4%
Remote DRAM: 3.1%
L3 hit: 68.2%
L2 hit: 14.5%
L1 hit: 1.8%
Здесь 3% удалённого доступа на NUMA-системе говорит о том, что часть массивов выделена на чужом узле, и именно туда стоит смотреть первым делом.
Perf mem позволяет измерить латентность только загрузок из конкретной функции, используя фильтры по символам. Для этого нужно собрать программу с отладочными символами и запустить:
perf mem record -g -e mem-loads --filter 'filter matrix_mul@./programma' ./programma
perf mem report --mem-mode=load,local_weight,remote_weight
Фильтр 'filter matrix_mul@./programma' ограничивает сбор только загрузками внутри функции matrix_mul. Если функция небольшая и горячая, perf соберёт достаточно сэмплов за несколько секунд. В отчёте будет видна локальность именно для этой функции, а не для всего процесса. Это критично для гибридных нагрузок, где одна часть программы работает с локальными данными, а другая - с удалёнными.
Для адресного фильтра по диапазону памяти используется синтаксис 'filter 0x7f0000000000/0x1000000', что ограничивает сбор диапазоном в 16 МБ начиная с заданного адреса. Адрес массива узнают через печать указателя в программе или через /proc/<pid>/maps.
Ложное разделение кеш-линий через perf c2c
Perf c2c, Cache-to-Cache, находит кеш-линии, которые прыгают между ядрами. Запуск выглядит так:
perf c2c record -g --all-cpus ./programma
perf c2c report
В отчёте колонка LLC Miss показывает промахи L3, а колонка Remote HITM отражает случаи, когда линия была в Modified состоянии в кеше другого ядра и пришла через межъядерную шину. Значение Remote HITM выше 1% от всех обращений уже тревожное, выше 5% критическое. Для каждой "горячей" линии отчёт показывает символ, смещение в объекте и файл-строку, где происходит доступ.
Типичный паттерн: массив структур, где поток i пишет в поле counter, а поток i+1 читает поле flag, и оба поля попадают в одну линию. Лечение: паддинг до 64 байт между полями или перераскладка массива, чтобы потоки попадали в разные линии. На больших NUMA-системах Remote HITM превращается в Remote DRAM, и штраф растёт в два раза.
Привязка нагрузки к узлам NUMA через numactl
Numactl закрепляет процесс и память за узлом:
numactl --cpunodebind=0 --membind=0 ./programma
Ключ --cpunodebind ограничивает планировщик заданными узлами, --membind гарантирует выделение физических страниц только там. Без --membind ядро может положить страницы на любой узел, и при первом доступе промах L3 превратится в удалённый доступ. Ключ --interleave=all распределяет страницы круговой чередой по всем узлам, полезно для потоков, которые равномерно читают общий массив.
Состояние узлов и расстояния показывает numactl -H:
numactl -H
В выводе таблица расстояний: локальный узел 10, соседний 20, дальний 30. Штраф удалённого доступа в наносекундах пропорционален этому расстоянию. На двусокетных системах Intel (Cascade Lake) локальный доступ 90 нс, соседний 130 нс, на четырёхсокетных (Cooper Lake) локальный 85, соседний 125, диагональный 180. На AMD (EPYC Milan) локальный 75, соседний через Infinity Fabric 110, диагональный 150.
Проверка фактической привязки через /proc/<pid>/numa_maps показывает, на каких узлах реально лежат страницы процесса. Если в выводе видны страницы на чужом узле, значит выделение случилось до привязки или сработал page migration. Ядро умеет мигрировать страницы под нагрузку, если включён kernel.numa_balancing, но это работает с задержкой и не всегда успевает за рабочими нагрузками.
Инструменты Intel PCM и likwid для наносекундных замеров и детальные замеры на железе
PCM, Performance Counter Monitor, даёт латентности в наносекундах без пересчёта тактов. Запуск:
pcm-memory.x 1
В выводе колонки Local DRAM Latency и Remote DRAM Latency обновляются раз в секунду, а колонка UPI Link Utilisation показывает загрузку межсокетных линков. Значение Local 90-100 нс, Remote 130-150 нс на Intel Ice Lake соответствует спецификации. PCM 202604 поддерживает Sapphire Rapids и Emerald Rapids, для старых поколений используются соответствующие ветки.
Likwid работает на Intel и AMD через единый интерфейс:
likwid-perfctr -g MEM -C N:0-63 ./programma
Группа MEM выдаёт латентности L1, L2, L3, DRAM, Remote DRAM в тактах и наносекундах, а также полосу памяти. Для AMD Zen 4 likwid 5.5.2 поддерживает события DF, Data Fabric, и показывает задержки Infinity Fabric. LIKWID работает в пользовательском пространстве, не требует прав root для чтения счётчиков, если разрешено perf_event_paranoid ниже 2.
Для воспроизводимых замеров на конкретном сервере удобен встроенный бенчмарк в pcm-memory.x с ключом -nc:
pcm-memory.x -nc 10
Флаг -nc отключает вывод пропускной способности и оставляет только латентности. На двухсокетном сервере Intel Xeon Platinum 8480+ (Sapphire Rapids, 48 ядер, 4.0 ГГц Turbo) получаются следующие значения в спокойном состоянии: Local DRAM 82 нс, Remote DRAM 118 нс, UPI Link 0 и 1 по 18% загрузки. При нагрузке STREAM Triad на 96 потоках: Local DRAM 95 нс, Remote DRAM 142 нс, UPI 85%. Разница 47 нс между локальным и удалённым доступом соответствует проходу через два UPI-линка и контроллер памяти соседнего сокета.
На AMD EPYC 9654 (Genoa, 96 ядер, 3.7 ГГц) замер через likwid-perfctr -g MEM даёт: L1 1.2 нс, L2 3.8 нс, L3 14 нс, Local DRAM 72 нс, Remote DRAM (соседний CCD) 98 нс, Remote DRAM (дальний NUMA) 135 нс. Интересно, что внутри одного CCD задержка L3 ниже, чем между CCD, потому что L3 не общий для всех ядер сокета, а разбит на домены по 16 ядер. Это важно учитывать при привязке: лучше держать потоки внутри одного домена L3.
NUMA-балансировка ядра и типичные ошибки замеров
Параметр kernel.numa_balancing включает автоматическую миграцию страниц к процессорам, которые к ним обращаются. Значение 1 включено по умолчанию на NUMA-системах, 0 выключает. Выключение полезно на рабочих нагрузках с чёткой статической раскладкой, где миграция только добавляет накладные расходы и не успевает за быстрыми сменами фаз. Проверка через sysctl:
sysctl kernel.numa_balancing
Дополнительные ручки: numa_balancing_scan_period_min_ms минимальный период сканирования, numa_balancing_scan_size_mb объём сканируемой памяти за проход. Увеличение периода снижает нагрузку на планировщик, но замедляет реакцию на смену фаз. На высоконагруженных базах данных часто ставят 0 и раскладывают память вручную через numactl при старте.
Повторяющиеся промахи при замеров:
- запуск perf mem без PEBS на старом железе, отчёт пустой или неточный, проверка через perf list | grep precise;
- измерение латентности на холодном кеше, первые проходы дают промахи L3 и DRAM, прогрев обязателен;
- замеры на системе с работающей numa_balancing, страницы мигрируют во время теста и размывают картину;
- numactl без --membind, память выделяется на случайном узле, и первый прогон показывает удалённый доступ;
- интерпретация Remote HITM как DRAM-промаха, это разные вещи: HITM это кеш-другого-ядра, DRAM это память.
Диагностика сводится к трём шагам: perf stat для общей картины промахов, perf c2c для false sharing, perf mem для локальности доступа, numactl -H для проверки топологии. Если Remote DRAM выше 5% от суммы доступов, первая попытка - numactl с жесткой привязкой памяти, вторая - проверка кода на false sharing, третья - отключение numa_balancing. Результат обычно виден сразу: удалённый доступ падает до единиц процентов, и общее время выполнения сокращается на 10-30%.
Практические примеры оптимизации и итоговые рекомендации
База данных PostgreSQL с настройкой shared_buffers в 64 ГБ на двусокетном сервере. По умолчанию выделение страниц происходит по first-touch политике, и если инициализацию буфера делает один процесс на сокете 0, все страницы лягут туда. Решение: запустить initdb или pg_prewarm через numactl --interleave=all, чтобы страницы распределились равномерно. После этого Remote DRAM падает с 35% до 3%, а пропускная способность SELECT запросов растёт на 22%.
Научный расчёт на OpenMP с массивом 200 ГБ. Директива !$OMP PARALLEL DO сchedule(static) распределяет итерации блоками по потокам. Если массив выделен через malloc без привязки, first-touch заставит страницы лежать там, где прошла инициализация. Правильный порядок: инициализировать массив в параллельном регионе с той же раскладкой, что и расчёт, или использовать numactl --interleave=all при старте. При правильной раскладке Remote DRAM 0.5%, время расчёта сокращается на 18% по сравнению с накоплением всего массива на одном узле.
Честные замеры латентности требуют понимания топологии железа, правильного инструментария и контроля за раскладкой памяти. Perf mem дает локальность доступа по функциям, perf c2c находит false sharing, numactl управляет привязкой, PCM и likwid переводят такты в наносекунды. Главное правило: сначала исключить false sharing и удалённый доступ, потом смотреть на частоты и тайминги. На современных NUMA-системах правильная раскладка данных даёт прирост 15-30% без единого изменения кода алгоритма.