Шина PCIe одна на всех, и рано или поздно на узле встречаются её клиенты с несовместимыми характерами: накопитель, которому нужна предсказуемая задержка на одиночном запросе, и ускоритель, заливающий гигабайты градиентов сплошным потоком. Без средств приоритезации задержка накопителя растёт пропорционально чужому трафику, и виноватым выглядит кто угодно, только не шина. Спецификация PCIe закладывает свои механизмы классов трафика: поля класса и приоритета в заголовке пакетов, виртуальные каналы с независимыми буферами и арбитраж между ними, и на плоских системах это работает из коробки. На нагруженных узлах этими механизмами стоит управлять сознательно, а Linux даёт для этого ограниченный, но рабочий набор рычагов, дополняемый честной раскладкой топологии. Механика обслуживания пакетов в PCIe асимметрична по природе: записи отправляются без ожидания ответа, чтения расщепляются на запрос и ответ, и смешанный поток из чтений с глубокими очередями и сплошных записей конфликтует за буферы портов сильнее, чем показывает любая сумма полос по отдельности.
Разберём классы и каналы, арбитраж, вопросы порядка пакетов, реальные настройки в Linux и связь с CXL. Сила темы в контрасте: механизмов в спецификации много, системных утилит мало, и ценность здесь не в кнопках, а в понимании, что именно куда влияет.
Классы трафика, приоритеты и виртуальные каналы
Каждый пакет PCIe несёт в заголовке трёхбитовое поле класса трафика, Traffic Class, дающее восемь классов, и трёхбитовое поле приоритета, Traffic Priority, а также атрибуты, о которых речь ниже. Класс это метка принадлежности потока, приоритет это пожелание о порядке обслуживания внутри ресурса. Параллельно шина поддерживает до восьми виртуальных каналов, Virtual Channels, каждый со своими буферами и собственным управлением потоком: нулевой канал обязателен и работает всегда, прочие появляются по возможностям устройств, которые те декларируют в расширенных возможностях. Связь между классами и каналами задаётся картой соответствия в ресурсах канала, и она обязана совпадать на обоих концах линии, иначе пакеты тихо меняют класс обслуживания на полпути. Устройства маркируют пакеты по-разному: сетевые контроллеры исторически активнее всех играют с классами и атрибутами, контроллеры накопителей держат команды и данные в разных потоках, а массовые ускорители без тонкой разметки шлют всё в базовый класс, и именно такие устройства создают пиковый фон, против которого и приходится защищать латентные службы.
Практический смысл конструкции такой: трафик разных классов, размещённый в разных каналах, получает независимые буферы и независимое управление потоком, и переполнение одного канала перестаёт душить другой. На дефолтной конфигурации всё идёт через нулевой канал, и эффект от остальных каналов появляется ровно тогда, когда кто-то явно их завёл и разрулил соответствия.
Арбитраж между каналами и портами
Обслуживание каналов на порту регулирует арбитраж. Строгий приоритет отдаёт линию старшему каналу до исчерпания, справедливое чередование делит полосу по таблице весов, где каждая запись таблицы назначает канал на очередной такт обслуживания, и соотношение весов фактически становится распределением полосы. Таблица заполняется номерами каналов, и последовательность записей превращается в цикл: четыре записи о нулевом канале и одна о первом дают соотношение четыре к одному, а допустимую длину таблицы устройство декларирует в своих возможностях. На коммутаторах дополнительно работает арбитраж портов внутри канала, когда несколько верхних портов конкурируют за один апстрим, и таблицы портового арбитража решают, чей пакет идёт следующим. Управление потоком в PCIe ведётся кредитами отдельно на каждый канал, поэтому грамотное распределение трафика по каналам ещё и защищает от ситуации, когда один класс трафика исчерпывает кредиты и блокирует соседей.
Поле приоритета в заголовке работает в паре с арбитражем портов: коммутаторы применяют его для выбора очередного пакета среди кандидатов одного канала, а конечные устройства чаще всего оставляют его в нейтральном значении, полагаясь на классы и каналы. Управление потоком ведётся кредитами отдельно на каждый канал, и возврат кредитов занимает отдельные служебные пакеты, поэтому перегруженный канал не только задерживает собственные пакеты, но и генерирует лишний служебный трафик, который тоже занимает линию.
На практике арбитраж чаще всего оставляют по умолчанию, а разруливают верхнеуровнево: чем различить потоки, если они все в одном классе и одном канале? Ответ в два слоя: назначить классы и каналы на стороне конечных устройств, где это поддержано драйвером и прошивкой, и настроить арбитраж на промежуточных портах. Первый слой доступен редко, второй почти всегда, и вместе они закрывают большинство практических сценариев.
Порядок следования и атрибуты пакетов
Механизм классов имеет оборотную сторону, о которой забывают при разнесении трафика по каналам. Гарантии порядка следования в PCIe действуют внутри одного класса трафика: пакеты одного класса одного направления сохраняют относительный порядок, а между классами таких гарантий нет, и разнесение потока по разным каналам открывает переупорядочивание. Для потоков данных без зависимостей это безразлично, но для протоколов, где ответ на запись обязан прийти после самой записи, разлёт по каналам требует пересмотра допущений протокола.
Атрибуты пакетов дают тонкие инструменты в тех же рамках. Атрибут послабления порядка разрешает записи обходить ранее выданные чтения, что исторически применялось сетевыми контроллерами для снижения задержки на смешанных потоках, а атрибут отказа от слежки говорит системе кэшей не тратить усилия на согласование, что разумно для буферов, которые никто не читает процессором. Оба атрибута пришли из практики высокопроизводительных сетей, и их включение обычно скрыто в драйверах, но понимание механики объясняет, почему два одинаковых на вид потока ведут себя по-разному под нагрузкой. Свежие расширения добавили и упорядочение по идентификаторам, когда порядок задаётся по номеру источника, а не по классу, что придумано под многофункциональные устройства и виртуальные функции и стало ещё одним способом разграничить потоки без разнесения по каналам. Виртуальные функции на общей карте заслуживают отдельной строки: они делят один физический путь, и очереди разных машин сгружаются в один канал, а разграничение полосы между гостями решается в самом контроллере сети, потому что на уровне шины им отдаётся общий класс.
Что реально настраивается в Linux
Открытая картина такова: ядро Linux обслуживает нулевой канал и не поднимает дополнительные самостоятельно, а расширенные возможности устройств, включая возможность виртуальных каналов, видны в выводе lspci со смещениями:
lspci -vvv -s 00:01.0
lspci -vvv -s 00:01.0 | grep -iE 'capabilit|vc'
Манипуляции с регистрами каналов делаются setpci по явным смещениям из этого вывода, например чтение регистров ресурса канала на типовом корневом порте, где возможность виртуальных каналов живёт на смещении вроде 0x100:
setpci -s 00:01.0 0x104.l
Писать такие регистры стоит с оглядкой: карта соответствия классов обязана совпасть на обоих концах линии, а ошибка в арбитражных таблицах проявляется как потеря полосы или взаимоблокировка, а не как понятное сообщение. Ядро Linux при инициализации читает возможности каналов и оставляет конфигурацию прошивке, поэтому фактическое состояние стоит читать, а не предполагать: расширенные возможности с их смещениями перечислены в выводе lspci, и любые правки через setpci ведутся от этих адресов, а не от величин из чужих инструкций. По этой причине рабочая дисциплина обычно такая: разметку каналов и арбитраж ставит прошивка при сборке узла, Linux проверяет фактические значения через lspci и точечно корректирует setpci под подтверждённые задачи, и всё это фиксируется в документации узла, потому что восстановить раскладку по памяти через год нереально.
Топология как средство приоритезации
Честное признание: в большинстве развёртываний приоритезация достигается не каналами, а топологией. Отдельный корневой порт под накопители, отдельный под ускорители, коммутатор PCIe под пул устройств одного класса, и конфликт исчезает физически, а не программно. Практика конфигурации серверов это отражает: NVMe под латентные службы сажают на порты процессора, ускорители собирают за коммутаторами, и сеть под прямой доступ к памяти группируют с ускорителями ради короткого пути.
Добавляет своё и размещение накопителей за коммутаторами в накопительных полках: команды и данные идут через дополнительные прыжки, и приоритезация на каждом прыжке складывается в общий результат. Завершения команд и прерывания тоже занимают тот же путь, и их задержка входит в отклик накопителя, поэтому всплески чужого трафика видны не только в полосе, но и в доставке прерываний. Проверка эффекта делается простым сценарием: эталонная нагрузка на накопитель, затем тот же замер под фоновым потоковым трафиком на соседнем устройстве, и разница показывает цену совместного пути:
fio --name=lat --rw=randread --bs=4k --iodepth=1 \
--runtime=30 --time_based --filename=/dev/nvme0n1
Если раскладка по портам недоступна, а конфликт есть, тогда и наступает время каналов, атрибутов и арбитража из предыдущих разделов, и тогда же становится понятно, зачем спецификация хранит все эти механизмы. Цифры в таких тестах выразительны: накопитель с одиночными запросами при тяжёлом фоновом потоке на соседнем устройстве может подняться в разы, и ровно столько же отыгрывается при разнесении по портам или при вежливом фоне за каналами и весами.
CXL и приоритеты на общем пути
Механизм классов PCIe продолжается и в мире CXL: протокол памяти CXL.mem идёт поверх физики и инфраструктуры PCIe, и трафик к расширителям памяти делит линию и порты с обычным вводом-выводом через класс CXL.io. На узлах, где CXL-память живёт за теми же портами, что и накопители с сетью, вопрос приоритетов встаёт по-новому: трафик памяти кэшей приложений конкурирует с трафиком хранения, и разруливать его приходится теми же средствами, классами, каналами и арбитражем. Связка с управлением полосой на стороне устройств дополняет картину, и вместе это определяет, будет ли память за CXL предсказуемой под смешанной нагрузкой. Добавляет остроты и обратная инвалидация: устройство с собственной копией строк ждёт уведомлений от хоста о чужих записях, этот служебный трафик делит шину с обычным вводом-выводом, а его задержка напрямую входит в время доступа к строкам, которые устройство держит у себя.
Практический порядок работы с приоритетами
Приведение приоритетов в порядок на узле укладывается в шесть шагов:
- снять карту шины через lspci с расширенными возможностями и выписать смещения возможностей каналов на критических портах;
- классифицировать трафик узла по требованиям к задержке и полосе, выделив классы для хранения, памяти и сети;
- проверить фактические соответствия классов и каналов в прошивке и привести их в согласованность на обоих концах линий;
- настроить арбитраж на портах под выбранные пропорции, отталкиваясь от измеренной доли каждого класса;
- проверить эффект эталонными замерами под фоновым трафиком, включая сценарий совместного пути для накопителя;
- зафиксировать раскладку в документации узла и в процедурах прошивки, чтобы обновления не стёрли настройки молча.
Опыт эксплуатации добавляет к списку одну проверку, о которой забывают: обновления прошивок и замена устройств сбрасывают регистры каналов к умолчаниям, и раскладка, живущая в накатанных скриптах, требует повторного прогона замеров после каждого такого события, а не только при первичной сборке узла.
Итог у приоритезации на PCIe зрелый: механизм классов, приоритетов и каналов полный по возможностям, но ручной по эксплуатации, и его применяют там, где топологическое разделение невозможно или дорого. Узлы с выверенной раскладкой и настроенным арбитражем держат предсказуемую задержку хранения под любым чужим трафиком, и эта предсказуемость приходит не из одного рычага, а из связки прошивки, точечных правок и дисциплины замеров.