Обычная DRAM в сервере заканчивается раньше, чем заканчивается бюджет, и тогда на сцену выходит CXL: память подключается через шину PCIe, а ядро видит ее как отдельный NUMA узел со своей задержкой и своей полосой. Красота пула памяти обманчива, потому что разница с локальными слотами измеряется сотнями наносекунд, и это сразу видно на первом замере. Профилирование такого пула отвечает на три вопроса: что именно видит ядро, каков реальный профиль задержки и полосы и как нагрузка ложится на узлы. Отвечают стандартные инструменты Linux: утилита cxl из набора ndctl, утилита daxctl для перевода региона в узел памяти, numactl и perf для контроля размещения. Ниже собран весь путь от параметров ядра и прошивки до живых замеров, чтобы пул работал осознанно, а не вслепую.

Пул памяти на устройствах Type 3 и путь данных в ядре Linux

Протокол CXL сидит поверх физики PCIe и сохраняет когерентность кэша между хостом и устройством, поэтому ядро обращается с такой памятью почти как с обычными слотами. Устройства делятся на три типа: Type 1 для акселераторов без памяти, Type 2 для ускорителей со своей памятью, Type 3 для устройств памяти, на которых и строятся пулы. Пул собирается из нескольких модулей под одним или несколькими окнами памяти: прошивка платформы описывает окна в таблице CEDT, близость узлов в SRAT и SLIT, числа задержки и полосы в HMAT. Само устройство при загрузке отдает ядру таблицу CDAT через механизм DOE, в ней описаны области DSMAS с координатами каждой области памяти и записи DSLBIS с дополнительными поправками. Ядро строит из этих данных дерево портов и декодеров, готовый регион с интерливингом превращается в устройство dax, из которого драйвер kmem делает блоки памяти и новый NUMA узел без процессоров. По масштабу разница типично выглядит так: локальная DDR5 отвечает за 80-100 нс, устройство Type 3 поверх PCIe 5.0 за 170-250 нс, полоса одной линейки x16 ограничена сверху величиной порядка 60 ГБ/с на направление.

Смысл пула шире простого расширения: расширение просто добавляет объем, а пул позволяет раздавать области под конкретные классы нагрузки, от кэшей вторичного хранения до аналитики с редкими чтениями, и следить за каждым узлом отдельно. Деление между несколькими хостами требует коммутатора и поддержки мульти-хост устройств, а базовый сценарий Linux всегда про один хост, который честно видит всю свою дальнюю память. Склад рядом с цехом: доехать можно быстро, но каждая поездка стоит дороже, чем взять деталь с полки у станка.

Подготовка ядра и прошивки платформы к работе с CXL

В ядре нужны опции CONFIG_CXL_BUS, CONFIG_CXL_ACPI, CONFIG_CXL_PCI, CONFIG_CXL_MEM, CONFIG_CXL_REGION, CONFIG_DEV_DAX_CXL, CONFIG_DAX и CONFIG_MEMORY_HOTPLUG, а для вытеснения между уровнями памяти добавляются CONFIG_NUMA_BALANCING и CONFIG_MEMORY_TIERING. В прошивке включается режим CXL у портов и выделяются окна CFMWS, и с этим шагом спорить бессмысленно: без окна в CEDT ядро не увидит память вовсе. После загрузки проверяется, что драйверы встали на место и устройство опозналось как память:

lsmod | grep cxl
ls /sys/bus/cxl/devices/
dmesg | grep -i cxl | head -15
lspci -nn | grep -i cxl

В каталоге /sys/bus/cxl/devices/ появляются порты, декодеры и устройства mem0, mem1 и дальше по числу модулей. Журнал ядра рассказывает историю по шагам: найденное окно, прочитанная таблица CDAT, привязка к узлу NUMA. Если список пуст, почти всегда виновата прошивка, которая оставила порты в режиме PCIe или не выделила окно, и никакие параметры ядра этого не исправят.

Карта устройств и регионов через cxl list и сборка региона

Утилита cxl показывает иерархию глазами ядра, и первым делом смотрят устройства памяти:

cxl list -Mu

Вывод похож на такой:

[
  {
    "memdev":"mem0",
    "ram_size":"64.00 GiB (68719476736)",
    "serial":0x1a2b3c4d,
    "numa_node":-1,
    "firmware_version":"BWFW VERSION 00"
  }
]

Поле ram_size у volatile устройства Type 3 показывает объем, serial помогает отличать модули между собой, минус единица в numa_node означает, что узел привяжется позже, при переводе региона в системную память. При прошивке с поддержкой QTG рядом появляется поле qos_class с номером класса обслуживания. Дальше регион, то есть рабочая область с интерливингом по устройствам, собирается одной командой: четыре модуля по 64 ГиБ дают 256 ГиБ рабочей области.

cxl create-region -d decoder0.0 -m mem0 mem1 mem2 mem3 -w 4 -g 256 -t ram

Ключ -w задает число устройств в интерливинге, -g гранулярность в байтах, по которой запросы раскладываются на модули, -t ram выбирает volatile память вместо pmem. Готовность региона проверяется выводом состояния:

cxl list -Ru
{
  "region":"region0",
  "resource":"0x3900000000",
  "size":"256.00 GiB (274877906944)",
  "type":"ram",
  "interleave_ways":4,
  "interleave_granularity":256,
  "decode_state":"commit"
}

Значение decode_state commit говорит, что декодеры прошиты и по этим адресам реально отвечает память. Гранулярность 256 байт распределяет последовательные обращения по четырем модулям и поднимает суммарную полосу примерно кратно их числу, пока лимит не упрется в линии PCIe и окна платформы. Набор гранулярностей ограничен значениями от 64 до 4096 байт, и выбирать его стоит осознанно: мелкое зерно ровнее балансирует запросы между модулями, крупное снижает накладные расходы декодера. Если раскладка нужна другая, регион пересобирают: память сначала выводят из системного использования через daxctl с режимом devdax, затем cxl destroy-region region0 снимает область, и create-region собирает новую с нужными параметрами.

Перевод региона в NUMA узел через daxctl и проверка numactl

После сборки региона область видна как устройство dax, и ядро пока не считает ее системной памятью. Перевод выполняет daxctl:

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

Команда подключает блоки памяти через горячее подключение и переводит их в состояние online, после чего в системе появляется новый NUMA узел без процессоров. Размер блока горячей памяти виден в /sys/devices/system/memory/block_size_bytes и на большинстве платформ равен 128 МиБ или 2 ГиБ, от него зависит гранулярность, с которой узел наращивается. Поведение по умолчанию настраивается файлом auto_online_blocks, а проверка размещения выполняется numactl:

available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 4 5 6 7 8 9 10 11
node 0 size: 98304 MB
node 0 free: 61024 MB
node 1 cpus: none
node 1 size: 262144 MB
node 1 free: 262144 MB
node distances:
node   0    1
  0:  10   32
  1:  32   10

Узел 1 без процессоров и с 256 ГиБ памяти и есть пул CXL. Дистанция 32 против 10 у локального узла пришла из таблиц SLIT, числа относительные, но разница в три с лишним раза честно предупреждает: память далекая, и это покажет любой живой замер.

Задержка и полоса пропускания по таблицам HMAT и CDAT

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

cat /sys/devices/system/node/node1/access0/initiators/read_latency
cat /sys/devices/system/node/node1/access0/initiators/read_bandwidth
cat /sys/devices/system/node/node1/access0/initiators/write_latency
cat /sys/devices/system/node/node1/access0/initiators/write_bandwidth

У задержки там наносекунды, у полосы мегабайты в секунду, и для узла CXL типичные значения порядка 180-240 нс и 20000-56000 МБ/с в зависимости от числа линий и прошивочных данных CDAT. Конкретное чтение может вернуть, скажем, 187 и 48000, и этой пары достаточно, чтобы увидеть проигрыш локальным слотам по обоим параметрам сразу. Координаты ядро вычисляет цепочкой: задержка и полоса области из DSMAS, поправки от DSLBIS, вклад коммутатора из SSLBIS, ограничение общей линии PCIe наверху, и если несколько устройств делят линку, ядро пересчитывает координаты с учетом конкуренции. Метка QTG из qos_class связывает координаты с классом обслуживания и позволяет прошивке различать разные пути к одной памяти. Отдельно читаются дистанции узлов командой numactl -H: они относительные, но именно по ним планировщик и вытеснение решают, куда направлять страницы. Числа из прошивки полезны как рамка, не как истина в последней инстанции: реальные измерения иногда расходятся с ними на десятки процентов, и это нормально, ведь прошивка описывает идеальный случай одной нагрузки.

Живой замер нагрузки через perf, numastat и Memory Latency Checker

Живой замер начинается с изолированной проверки узла. Утилита Memory Latency Checker с привязкой к конкретной паре процессор-память дает профиль без внешнего шума:

numactl --cpunodebind=0 --membind=0 mlc --loaded_latency
numactl --cpunodebind=0 --membind=1 mlc --loaded_latency

Первая строка меряет локальную память, вторая узел пула, и на большинстве платформ вторая строка покажет задержку в два-три раза выше при полосе в разы ниже. Утилите нужны модуль msr и права администратора, поэтому запуск выполняют с повышенными привилегиями. Для длинных замеров подходит lmbench: команда lat_mem_rd 1G 64 рисует кривую задержки по ступеням размера блока. Под нагрузкой полезен perf с событиями доступа к NUMA:

perf stat -e node-loads,node-stores -a -- sleep 10
numastat -m | head -8
grep -E "numa_(hit|miss|foreign|local|other)" /proc/vmstat

События node-loads и node-stores показывают объем обращений к удаленной памяти, numastat раскладывает страницы по узлам, счетчики numa_hit и numa_miss из vmstat говорят, сколько размещений попало в нужный узел, а сколько промахнулось. Стабильно растущий numa_miss это сигнал, что приложение рождает страницы не там, где работает. По конкретному процессу картину уточняют файлом /proc/PID/numa_maps и командой numastat -p PID: строка вида bind:1 N1=45213 N0=12 читается напрямую, задача закреплена за узлом 1 и хранит там десятки тысяч страниц, при близком к нулю N0. Perf c2c помогает поймать ложное разделение строк кэша, когда горячие данные размазаны между узлами.

Распределение нагрузки по узлам и контроль вытеснения

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

numactl --cpunodebind=0 --membind=1 ./worker

Для служб под systemd узлы закрепляются в юните, и перечитать настройки можно командой systemctl daemon-reload:

[Service]
AllowedCPUs=0-15
AllowedMemoryNodes=1

В cgroup второй версии та же политика доступна файлами cpuset.mems и cpuset.cpus, а фактическое распределение страниц показывает файл memory.numa_stat:

echo 0-15 > /sys/fs/cgroup/web.slice/cpuset.cpus
echo 1 > /sys/fs/cgroup/web.slice/cpuset.mems

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

  1. собрать регион с нужным интерливингом и дождаться состояния decode_state commit;
  2. перевести устройства dax в режим system-ram и увидеть узел в numactl -H;
  3. сверить прошивочные координаты с живым замером Memory Latency Checker;
  4. закрепить службу за узлом и держать под взглядом memory.numa_stat и perf.

На этом статичная картина заканчивается: при включенной балансировке kernel.numa_balancing=1 ядро перемещает страницы между узлами, вытеснение включается файлом /sys/kernel/mm/numa/demotion_enabled, и холодные страницы уходят в медленный узел, спасая горячие в DRAM. Цели вытеснения перечислены в файле demotion_targets узла, уровни памяти собраны в /sys/kernel/mm/memory_tiers, где локальная DRAM получает абстрактное расстояние 400 по умолчанию, а узел CXL отодвигается дальше из-за меньшей полосы. Ход страниц виден в vmstat по счетчикам pgdemote_kswapd и pgpromote_success: первый считает страницы, ушедшие в пул при вытеснении, второй возврат горячих страниц назад. Если pgdemote растет быстрее, чем приложение успевает пользоваться пулом, политика размещает данные слишком охотно, и узлы стоит перераспределить вручную.

Пул на CXL не претендует на роль быстрой памяти: его сила в объеме за разумные деньги и в возможности разложить по узлам разные классы нагрузки. Профиль узла стоит перепроверять после обновлений прошивки и смены раскладки линий, потому что числа из CDAT меняются вместе с топологией. Три источника правды, sysfs с координатами, живой замер и счетчики perf с numastat, вместе дают полную картину, и опора на один из них рано или поздно превращает дешевую память в дорогую неожиданность.