Расширение памяти через CXL давно вышло из лабораторий: контроллер добавляет серверу сотни гигабайт за пределами обычных каналов DDR, а ядро Linux показывает эту память отдельным NUMA-узлом. Дешевле ли новая память на практике, зависит от того, куда именно приложение кладет страницы, и тут интуиция обманывает: задержка доступа вырастает в два с лишним раза, а полоса одного прибора ограничена скоростью линии PCI Express. Профилирование доступа к памяти CXL отвечает на три вопроса: куда уходят страницы, чем приложение платит за чужой узел и как удержать горячие данные рядом с ядрами процессора. В статье разобран путь от устройства на шине до NUMA-узла, механизмы контроля размещения, горячее подключение региона в пул и набор инструментов замера от numastat до трассировки ftrace. Каждый шаг снабжен командами, которые можно повторить на сервере с CXL-платой без специальной подготовки.

От устройства на шине до NUMA-узла в системе

Ядро видит CXL-расширитель как устройство памяти на шине cxl: драйвер перечисляет memdev, прошивка и драйвер программируют декодеры адресов, область превращается в устройство device-dax, а затем в NUMA-узел. Первичный осмотр занимает две команды:

cxl list -u
ls -l /sys/bus/cxl/devices

Утилита cxl из пакета ndctl печатает устройства памяти с объемами и состояниями, каталог sysfs показывает port0, memdev0 и декодеры. Прошивка отдает ядру окно памяти целиком, например диапазон от 0x4000000000 до 0x7fffffffff вмещает 256 ГиБ, и декодеры делят его между устройствами и регионами.

Приборы бывают частными и разделяемыми: обычный расширитель отдает весь объем одному хосту, а устройство за коммутатором нарезает память на логические доли для нескольких машин, и тогда управление доступом дополняется назначением долей на стороне коммутатора. Для профилирования разница существенна: у разделяемого прибора полоса делится между хостами, и замер стоит повторять при разной активности соседей по линии. Когда регион собран, память видна как узел node2, если физическому процессору достались номера 0 и 1:

cat /sys/devices/system/node/node2/meminfo
cat /sys/devices/system/node/node2/distance

Команда distance на однопроцессорной машине печатает два числа, 10 и 200: столько условных единиц стоит доступ с узла процессоров к узлу памяти. Значение около десяти трактуется как почти локальная память, значение порядка двухсот превращает узел в далекий ярус, и от этой метки зависит поведение планировщика и миграций. Перевод устройства в режим системной памяти выполняет daxctl:

daxctl reconfigure-device --mode=system-ram dax0.0
daxctl list -d dax0.0

После перевода страницы узла доступны обычному аллокатору, и процесс может получать страницы node2 так же, как страницы локальной памяти.

Таблицы ACPI и данные CDAT как источник координат доступа

Задержку и полосу узла ядро не измеряет само, а собирает из объявлений прошивки и прибора. Таблица CEDT описывает фиксированные окна памяти, таблица SRAT привязывает их к доменам близости, таблица HMAT несет базовые атрибуты производительности платформы. Сторону прибора дополняет структура CDAT, которую расширитель отдает при перечислении: записи DSMAS описывают области памяти устройства, записи DSLBIS добавляют локальную задержку и полосу за линией. Драйвер объединяет вклад линии и вклад прибора в координаты доступа и группирует их по идентификатору QTG, прошивка использует эти группы при раскладке нагрузки. Номер домена близости из SRAT совпадает с номером NUMA-узла, поэтому связь таблиц с содержимым sysfs прослеживается напрямую.

Проверить результат можно в sysfs, где подкаталог access0 хранит рассчитанные значения:

cat /sys/devices/system/node/node2/access0/initiators/node0/read_latency
cat /sys/devices/system/node/node2/access0/initiators/node0/read_bandwidth

Задержка приходит в пикосекундах, поэтому значение 180000 означает 180 наносекунд, полоса читается в байтах в секунду. Расхождение между этими числами и живым замером на нагрузке сигналит об ошибке прошивки или о том, что прибор сидит не на тех линиях, и исправлять положение нужно до ввода сервера в работу.

Контроль размещения процессов через numactl, mempolicy и cpuset

Доступ процесса к узлу регулируется политиками памяти, и самый быстрый способ задать политику на один запуск дает numactl:

numactl --hardware
numactl --membind=2 ./trainer.sh
numactl --preferred=1 ./worker

Режим --membind жестко замыкает выделение на узел 2, при исчерпании страниц приложение начнет терпеть вытеснение, режим --preferred разрешает перелив на остальные узлы. Службам удобнее управляться через cgroup версии 2: группе достаточно прописать маску узлов, и все процессы внутри получат одинаковую привязку:

mkdir /sys/fs/cgroup/cxl-pool
echo "0-31" > /sys/fs/cgroup/cxl-pool/cpuset.cpus
echo "2" > /sys/fs/cgroup/cxl-pool/cpuset.mems

Собственное приложение управляет политикой системными вызовами set_mempolicy и mbind, но фактическую картину чаще смотрят в numa_maps:

7f2c40000000 default rw anon N0=15234 N2=48210
7f2c41000000 default file=/data/embeds.bin N0=9901 N2=214

Свежие ядра добавили режим weighted_interleave: аллокатор раскладывает страницы между узлами пропорционально весам, которые задаются в sysfs, и баланс получается плавнее, чем при жесткой привязке:

echo 2 > /sys/kernel/mm/mempolicy/weighted_interleave/node2

Вес 2 на узле памяти против единицы на локальном узле означает, что каждый третий запрос уходит в пул CXL.

Счетчики N0 и N2 показывают, сколько страниц диапазона лежит на каждом узле, а метка default, bind или preferred объясняет происхождение политики. Здесь и видна типовая ошибка управления доступом: код оказался на быстром узле, данные остались на медленном, и каждый проход по массиву платит за переход по линии. Правило простое: если 90 процентов обращений приходится на горячий диапазон, который лежит на node2, а ядра процесса сидят на node0, полоса линии и лишние наносекунды уходят на каждое чтение.

Горячее подключение региона в пул и зона Movable

Память CXL заводится как горячее подключение, и гранулярность определяется размером блока. Типичная гранула на x86 равна 128 МиБ, поэтому регион на 256 ГиБ распадается на 2048 блоков, а регион на 512 ГиБ дает 4096:

cat /sys/devices/system/memory/block_size_bytes

Шестнадцатеричное значение 0x8000000 соответствует 128 МиБ. Если блоки переводить в состояние online_movable, ядро сможет переносить страницы при отключении региона, и пул останется динамическим ресурсом, который возвращают гипервизору или другому узлу кластера:

echo online_movable > /sys/devices/system/memory/memory1953/state
cat /sys/devices/system/memory/memory1953/valid_zones

Утилита daxctl в режиме system-ram выполняет подключение сама и стремится к снимаемой памяти. В зоне Movable ядро не размещает собственные структуры, там живут страницы кэша и анонимные страницы процессов, поэтому отключение узла не разрушает работу системы, а выдавливает страницы на оставшиеся узлы.

Пул удобен и под огромные страницы: узел резервирует их отдельной командой, и приложение закрепляет крупные буферы в внешней памяти сознательно, а не случайно:

echo 4096 > /sys/devices/system/node/node2/hugepages/hugepages-2048kB/nr_hugepages

4096 страниц по 2 МиБ дают ровно 8 ГиБ региона, и такой объем разумно отвести под сжатые индексы или кэш векторных данных.

Профиль распределения страниц по данным numastat и numa_maps

Утилита numastat печатает статистику аллокатора по узлам и быстро отвечает на вопрос, попадает ли трафик туда, куда задумано:

numastat
numastat -p 4821
numastat -m

Ключ -p раскрывает распределение страниц одного процесса, ключ -m суммирует узлы с объемами в мегабайтах. Счетчики numa_hit и local_node означают попадания по плану, рост numa_miss и numa_foreign говорит, что запросы уходят мимо целевого узла. Рабочий сеанс профилирования размещения укладывается в пять шагов: 1. снять базу командой numastat и зафиксировать значения из /proc/vmstat; 2. запустить нагрузку под нужной политикой numactl и дать ей проработать хотя бы несколько минут; 3. сравнить прирост numa_miss и numa_foreign, чтобы оценить цену политики; 4. разобрать numa_maps процесса и сопоставить счетчики N0 и N2 с горячими диапазонами адресов; 5. сменить cpuset.mems и повторить замер, убедившись, что страницы переехали вслед за политикой.

Порог тревоги подбирается под сервис, но практика показывает: если доля numa_foreign переваливает за несколько процентов от numa_hit, приложение постоянно спорит с аллокатором, и часть полосы линии съедается переносами страниц. Для ориентира можно сравнивать масштабы: когда numa_foreign за тест набрал десятки тысяч при numa_hit в миллионы, политика работает, просто миграций многовато, а когда оба счетчика сопоставимы, процессы фактически борются за узлы, и привязку надо менять.

Замер задержки и пропускной способности узла с памятью CXL

Бумажные координаты стоит перепроверить нагрузкой. На серверах с процессорами Intel доступен замер mlc, который строит матрицы задержки и полосы по всем узлам:

mlc --loaded_latency
mlc --bandwidth_matrix

Первая таблица показывает, как растет задержка при очереди запросов, вторая раскладывает потоки чтения и записи между парами узлов. Под нагрузкой картина меняется: при двадцати параллельных потоках строка узла 2 может показать уже 240 наносекунд, и разница с холостым замером объясняется очередью к линии. Ожидания калибруются так: локальная память DDR5 дает порядка 80-90 наносекунд холостой задержки, узел CXL на линии PCI Express 5.0 обычно показывает 170-250 наносекунд. Полоса одного прибора на шестнадцати линиях ограничена теоретическими 64 ГБ/с, практические потоки чтения достигают порядка 40 ГБ/с, запись уступает чтению. Если два прибора сидят за одним корневым портом, линия делится, и каждому остается около половины полосы. Простая арифметика помогает планировать: линейное чтение региона 512 ГиБ при 40 ГБ/с занимает 12,8 секунды, однопоточный обход связных списков при 200 нс дает 5 миллионов обращений в секунду против 11 миллионов на локальных 90 нс. Потоковый тест без mlc повторяет mbw:

numactl --membind=2 mbw -q 4096

Здесь 4096 означает объем в мегабайтах, а ключ -q глушит лишний вывод. Замер полезно повторить под смешанной нагрузкой, когда один поток пишет, второй читает: узел CXL отзовется просадкой раньше локальной памяти.

Миграции страниц, demotion и трассировка доступа в ядре

Ярусность памяти превращает пул CXL в адресат миграций: балансировка NUMA подтягивает горячие страницы к ядрам, которые их чаще читают, а вытеснение при нехватке памяти отправляет холодные страницы на нижний ярус. Оба механизма включаются отдельно:

echo 1 > /proc/sys/kernel/numa_balancing
echo 1 > /sys/kernel/mm/numa/demotion_enabled

Цену переносов показывают счетчики pgmigrate_success и numa_pages_migrated в /proc/vmstat, а события трассировки объясняют каждый конкретный перенос. Файловая система трассировки знает события планировщика, которые фиксируют попытки выровнять доступ:

cd /sys/kernel/tracing
echo 1 > events/sched/sched_move_numa/enable
echo 1 > events/sched/sched_swap_numa/enable
cat trace_pipe

Тот же набор собирает perf одной командой, а подсистема cxl добавляет события о состоянии прибора, включая корректируемые ошибки памяти:

perf record -e sched:sched_move_numa -a -- sleep 30
ls /sys/kernel/tracing/events/cxl

В отчете perf видны процесс, номер страницы и пара узлов, между которыми шел перенос, поэтому вывод легко сопоставляется с numa_maps того же процесса.

Для разделяемых областей, которые несколько процессов используют через коммутатор, пригодится perf c2c: он находит строки кэша, за которые спорят процессоры разных узлов. Хорошая конфигурация выглядит так: горячие структуры базы данных остаются на узле процессоров, журналы и архивные данные переезжают в пул, поток миграций ограничен и предсказуем. Плохая видна сразу: numa_foreign растет лавинообразно, линия забита переносами страниц, приложение чаще ждет шину, чем память. Профилирование доступа к памяти CXL отличает первое состояние от второго, поэтому замер стоит встроить в регулярный цикл обслуживания сервера наравне с мониторингом задержек и свободного места.