Наша команда системного программирования YADRO продолжает работать с open source-эмулятором QEMU. В этой статье я расскажу, как и зачем мы создали виртуального двойника Intel NTB Gen3.
Документации по этому устройству нет в открытом доступе, поэтому итог работы — это результат наблюдения и экспериментов с реальной установкой, а также изучения существующих применений и драйверов Linux. Я подробно опишу результаты и все шаги. Уверен, что информация пригодится вам для создания классных проектов.
Реализованная модель Intel NTB Gen3 позволяет нам производить разработку и тестирование протоколов более высокого уровня, а также выполнять их качественное сравнение.
Типы NTB можно условно поделить на два:
те, которые пишут в память соседней машины при записи в PCIe BAR,
те, которые пишут в PCIe BAR на соседней машине.
Рекомендую прочитать первую статью, в которой подробно разобрана схема обмена прерываниями.
Почему QEMU — это хорошо
По крайней мере, в случае создания виртуального двойника Intel NTB Gen3.
Из плюсов:
полная симметричность конфигурации (машины одинаковые, одно и то же ядро, модули, rootfs),
скорость — быстрая смена ядра, модулей, перезагрузка машин,
отладчик gdb (JTAG — это большая проблема на x86_64),
отсутствие железа — это в некоторых случаях плюс: нам не нужно делить экспериментальную установку между разработчиками и мы можем абстрагироваться от ошибок железа.
Но отмечу, что QEMU не эмулирует кеши и их поведение.
Польза модели QEMU Intel NTB
По итогам этой работы мне удалось создать две модели для QEMU, которые полностью совместимы с драйвером Linux:
PCIe Intel NTB,
Intel IO/AT DMA.
Также я ускорил процесс разработки следующих компонентов:
RPMSG (Remote Processor Messaging) over NTB,
Virtio/Vhost over NTB,
NTRDMA.
Моделирование PCIe NTB
Области применения PCIe NTB
Основным сравнительно дешевым и высокоскоростным применением NTB является коммуникация между узлами:
в массивах хранения данных,
в сложных системах автоматизированного управления, например для автомобилей,
в отказоустойчивых системах, где требуется дублирование узлов.
Описание основного функционала PCIe NTB
Отмечу, что PCIe NTB не позволяет увидеть адресное пространство, которое принадлежит к подключенному по NTB соседнему устройству. Вкратце он работает так:
перенаправляет трафик PCIe между шинами как мост,
CPU рассматривает мост как конечное устройство,
CPU не «видит» все устройства на «другой» стороне, как правило другая сторона — это другой компьютер.
Основные цели и задачи
С точки зрения эмуляции, мне нужно уделить особое внимание регистрам, прерываниям и моделированию поведения устройств.
В работе с PCI BAR необходимо обеспечить прозрачное использование Linux-драйвера Intel NTB для оптимального взаимодействия с оборудованием, которое мы эмулируем. Еще одна задача — разработать новые транспорты, которые работают поверх эмуляции: RPMSG, Virtio/Vhost, NTRDMA и другие.
Основной функционал PCIe NTB
Memory Windows — это область памяти PCIe, выделенная специально для NTB. Все операции записи и чтения в ней транслируются через PCIe между узлами.
Регистры Doorbell используются для отправки запросов на генерацию прерывания на противоположном узле, что позволяет уведомлять другую сторону о событиях или изменениях.
Регистры Scratchpad и Message:
Scratchpad представляет собой регистр, который можно записывать с одной стороны и читать с другой. Это полезно для простого обмена данными между узлами.
Message — расширенная версия Scratchpad, которая поддерживает очередь сообщений и генерирует собственные прерывания. Это позволяет более эффективно обмениваться данными и событиями.
Описание Doorbells
Я делал модель для Intel NTB Gen3, поэтому описываю Doorbells (db) и прочий функционал именно с точки зрения этого устройства.
Цель db — вызвать прерывание на противоположном узле при записи в соответствующий регистр. У Intel NTB имеются 32 «звоночка» плюс один выделенный специально для оповещения при изменении состояния соединения (link status), что соответствует 33 прерываниям MSI-X.
Основные регистры, которые отвечают за эту функциональность:
GEN3_IM_DOORBELL_OFFSETN — одномерный массив регистров для вызова прерываний на противоположной стороне,
GEN3_IM_INT_STATUS_OFFSET — битовое поле, которое отвечает за состояние соответствующего db, когда прерывание было вызвано противоположной стороной, взводится соответствующий бит,
GEN3_EM_INT_DISABLE_OFFSET — то же самое, но для включения/выключения прерывания, которое привязано к конкретному db.
С функциональной точки зрения выглядит просто: пишем в регистр и вызываем прерывание на противоположном узле.
Описание Scratchpads
Регистры-блокноты (Scratchpads) можно встретить где угодно, это просто регистр, который хранит записанное в него значение. В нашем случае отличие в том, что это значение может прочитать не только сам записавший, но и противоположная сторона.
Соответственно, для каждого узла есть свой набор регистров. Узел может прочитать или записать набор регистров, который принадлежит противоположной стороне. У Intel NTB этих регистров 16. Основные регистры:
GEN3_IM_SPAD_OFFSET — собственный набор,
GEN3_B2B_SPAD_OFFSET — набор противоположного узла.
Описание Memory Windows
Окна памяти — более сложный функционал, чем два предыдущих. В обычном режиме работы NTB мы прописываем адрес и размер участка памяти, к которому мы хотим предоставить доступ противоположной стороне для чтения или записи. Без использования IOMMU к участку также накладывается требование о непрерывности.
Если не брать специальные случаи, когда мы открываем доступ ко всей памяти, тогда нам необходимо выделить такой участок с помощью функции dma_alloc_coherent(). Отмечу, что в Linux 6.12/6.13 на момент публикации статьи это единственный способ выделить большой и непрерывный кусок памяти из модуля ядра. Затем нужно записать его начало и размер в соответствующие регистры устройства. В Intel NTB предусмотрено два таких окна, и за их конфигурацию отвечают регистры:
GEN3_IMBAR1XBASE_OFFSET/GEN3_IMBAR2XBASE_OFFSET — адрес начала выделенной памяти для окна 1/2,
GEN3_IMBAR1XLMT_OFFSET/GEN3_IMBAR2XLMT_OFFSET — размер выделенной памяти для окна 1/2.
После установки правильных значений для данных регистров противоположный узел может читать или писать память через чтение или запись конкретного PCI BAR, который соответствует конкретному окну MW.
Модели обмена данными между машинами
Условно предлагаю выделить следующие модели обмена:
обмен через сообщения типа адрес и данные,
обмен сообщениями и доступ к памяти BAR — в таком случае мы пишем в общую память, а затем передаем сообщение типа адрес источника, адрес назначения и длина,
alias в общее пространство памяти при выставлении окна трансляции alias PCI BAR в память,
предоставление оперативной памяти в качестве памяти для BAR соседней машины и наоборот, то есть PCI BAR и оперативная память крест-накрест используют тот же файл.
Картина симметрична с точки зрения первой машины или второй машины. Когда мы говорим о машине QEMU1, мы подразумеваем, что то же самое происходит с точки зрения QEMU2.
Обмен через сообщения
Теперь давайте подробнее рассмотрим эти модели обмена данными — их преимущества и недостатки.
Плюсы:
полный контроль над адресами, куда осуществляется запись: в память пишет устройство, находящееся непосредственно в машине, которой принадлежит память.
Минусы:
очень низкая скорость и двойной объем памяти,
необходимо подтверждение, что данные попали в память.
Двойной объем памяти получается из-за того, что нам нужен промежуточный буфер. В него перед пересылкой мы записываем данные, а затем их может прочитать записавшая сторона. На самом деле ситуация даже хуже, так как мы должны отслеживать область памяти, которая выделена под MW, и пересылать записанные в эту область данные обратно в PCI BAR.
Картина симметрична с точки зрения первой машины или второй. Мы передаем все данные, которые были записаны в QEMU1//BAR, через сокет.
Обмен сообщениями и доступ к памяти BAR
Обмен сообщениями и доступ к памяти через BAR обладают как преимуществами, так и недостатками. Среди плюсов выделю полный контроль над адресами, куда осуществляется запись, а также копирование данных из памяти BAR в оперативную память самим устройством.
Но есть и минусы: сравнительно низкая скорость передачи данных и необходимость подтверждения выполнения копирования.
Мы передаем QEMU1//BAR в QEMU2 для чтения, но не отображаем его в адресном пространстве QEMU2. Поэтому доступ к QEMU1//BAR в рамках машины QEMU2 есть только у советующего компонента QEMU2//NTB.
Alias в общее пространство памяти
Среди плюсов можно отметить простоту реализации и высокую скорость работы. Мы избегаем лишнего копирования данных, так как запись в BAR с одной машины эквивалентна записи напрямую в оперативную память соседней машины.
Но этот метод не подходит, если BAR транслируется на весь объем оперативной памяти, как это реализовано в NTRDMA. Поскольку в этом случае текущая оперативная память полностью заменяется памятью BAR. Также нет возможности использовать механизмы контроля доступа к памяти, такие как IOMMU.
В этом случае, когда для QEMU2 выставляется запрос о трансляции памяти, мы отображаем BAR в адресное пространство QEMU2 поверх участка оперативной памяти. Тогда запись в BAR машины QEMU1 будет эквивалентна записи в участок оперативной памяти QEMU2.
Предоставление оперативной памяти в качестве памяти для BAR соседней машины
Плюсы:
простота исполнения,
скорость — мы не производим лишнее копирование, запись в BAR с одной машины эквивалента записи в оперативную память соседней машины.
Минусы:
мы экспортируем память в соседнюю машину полностью, поэтому соседний хост может сделать все, что угодно,
нельзя применять механизмы контроля доступа к памяти, например IOMMU.
Гибридный метод
В его основе лежит принцип, по которому для локального копирования данных из оперативной памяти в BAR обычно используется DMA. Это подразумевает использование устройств, которые выполняют копирование данных без задействования процессора. Так мы можем копировать данные не только в память BAR, но и напрямую в оперативную память соседней машины, указав соответствующие адреса.
Поскольку любой драйвер практически всегда ожидает подтверждения от DMA о завершении операции, наш метод гарантирует отсутствие проблем с подтверждением завершения копирования.
Плюсы:
полный контроль над адресами, куда осуществляется запись — копирование из памяти BAR в оперативную осуществляет DMA,
изоляция памяти — у машин нет прямого доступа к оперативной памяти и BAR напрямую.
Минусы:
незначительное ухудшение производительности в случае использования DMA и значительное — в случае записи в память BAR напрямую,
сложность подхода.
В методе dma_write_pending()
мы передаем не адреса, а один адрес, по которому драйвер заранее располагает дескрипторы копирования и их количество.
Этот метод в статью не вошел, так как пока есть сомнения в его необходимости. IOMMU мы не используем, поэтому нам не нужно писать память в модели, которая ей непосредственно владеет.
Подготовительная работа
Описание проекта-обертки
Компоненты:
qemu v9.0.0 c патчами intel_ntb и ioatdma,
linux,
buildroot (2024.02-31),
edk2.
Скачать проект-обертку.
Модель QEMU Intel NTB
Особенности Intel NTB
У Intel NTB есть ряд особенностей, которые выделяют его из прочей реализации таких устройств. Первое — это требование к выравниванию адреса трансляции относительно размера BAR, то есть фактически нам доступны лишь младшие адреса:
/* hardware requires that addr is aligned to bar size */
if (addr & (bar_size - 1))
return -EINVAL;
Из первого ограничения вытекает неприятная особенность: мы сильно ограничены размером Memory Window, если не используем IOMMU, так как мы должны выделить непрерывный кусок памяти большого размера, который еще и должен удовлетворять условию выше. Есть только несколько путей обхода этого ограничения:
выделить область памяти, которая начинается строго по адресу 0x0,
выделить область памяти в два или более раз больше размера BAR и передавать в трансляцию адрес, который удовлетворяет условиям выравнивания — на этот метод полагается драйвер ntb_transport.
Второе ограничение — это наличие GEN3_IM_INT_STATUS_OFFSET, не у всех реализаций NTB есть регистр со статусом db (doorbells). Поэтому сложность подключения прерываний через kvm_irq заключается в том, что мы не можем установить биты статуса в регистре прерываний для doorbells, а также не можем управлять их маскированием.
В отдельных случаях это не проблема, так как intel_ntb может работать и без информации о текущем состоянии db. Но для полной эмуляции необходимо отказаться от проброса прерываний через KVM либо передавать ссылку на область памяти, которая содержит состояние вектора прерываний, и отображать ее в адресное пространство QEMU Intel NTB.
Описание модели
Режимы модели:
-
Режим NTB работает по классической схеме, создавая alias'ы в RAM при запросе на трансляцию окна MW0 или MW1:
dma_alloc_coherent(&dma_addr, &dma_size)
,ntb_mw_set_trans(dma_addr, dma_size)
.
В режиме NTRDMA MW0 мы используем по схеме, описанной выше, а MW1 просто использует RAM соседней машины в качестве своего BAR4. Это связано с особенностями текущей реализации NTRDMA, которая требует, чтобы был доступ ко всей оперативной памяти соседа, так как буферы для записи ib_send_bw() выделяются произвольно по всему доступному RAM.
В модели сделан специальный ключ bar4nomap. Если он выставлен, то BAR4 отображается в память поверх RAM. Если не выставлен, то никаких специальных манипуляций проводить не нужно, наша память уже выступает в качестве BAR4 целиком.
Также модель позволила найти следующую ошибку в драйвере Intel NTB: ntb: intel: Fix using link status DB's.
Модель QEMU Intel I/OAT DMA
Функции QEMU для работы с памятью машины
В QEMU существует класс функций для доступа к адресному пространству из моделей. Доступ к нему, как правило, осуществляется:
из CPU,
из устройств, то есть DMA.
Фактически это разделение по адресным пространствам:
cpu_physical_memory_rw()
всегда использует общее глобальное адресное пространствоaddress_space_memory
,в
dma_memory_rw()
мы передаем желаемое адресное пространство,pci_dma_rw()
использует адресное пространство, которое назначено мастеру шины.
Это усложнение нужно для корректной работы трансляции адресов — например, MMU или IOMMU, если они имеются в нашей общей модели.
В модели Intel I/OAT для доступа к памяти везде были использованы pci_dma_rw()
.
Описание модели
Наша модель имитирует поведение реального контроллера от Intel и полностью совместима с драйвером Linux в режиме DMA_MEMCPY, то есть никаких модификаций драйвера не требуется.
По умолчанию модель содержит в себе четыре независимых канала. Копирование осуществляется асинхронно в QEMU BH (bottom half), после чего вызывается прерывание.
Патч с моделью мы отправили в QEMU: hw/dma: Add Intel I/OAT DMA controller emulation
Также модель позволила найти несколько ошибок в инициализации драйвера. Ошибки исправили в v6.10 и стабильных ветках:
Подробный разбор методов
Адресное пространство и регионы памяти в QEMU
MemoryRegion (например, RAM, ROM, MMIO и alias) — фактически просто контейнер для описания правил взаимодействия, таких как доступ, чтение или запись. Его основные характеристики — ширина доступа и размер. Мы назначаем этому контейнеру методы для полезного взаимодействия с памятью — например, для MMIO это MemoryRegionOps. Они нужны, чтобы уведомить модель о попытке чтения или записи ее адресного диапазон. Для большинства моделей MMIO этого достаточно. Например, для ioatdma это ее настройка и запуск операций:
static const MemoryRegionOps ioatdma_mmio_ops = {
.read_with_attrs = ioatdma_io_read,
.write_with_attrs = ioatdma_io_write,
};
memory_region_init_io(&s->mmio, OBJECT(s), &ioatdma_mmio_ops, s,
"intel-ioatdma.mmio", IOATDMA_BAR0_SIZE);
AddressSpace в QEMU — это то, как устройство, например DMA или процессор, видит доступное ему адресное пространство:
(qemu) info mtree
[...]
address-space: intel-ioatdma
0000000000000000-ffffffffffffffff (prio 0, i/o): bus master container
address-space: cpu-memory-0
address-space: cpu-memory-1
address-space: cpu-memory-2
address-space: cpu-memory-3
address-space: memory
0000000000000000-ffffffffffffffff (prio 0, i/o): system
Здесь мы видим отдельное адресное пространство для Intel I/OAT DMA и адресные пространства процессоров. Каждый регион памяти может одновременно отображаться в несколько адресных пространств. Так OCRAM в imx7d расположен по адресу 0x00900000 с точки зрения Cortex-A и по адресу 0x20200000 с точки зрения Cortex-M. За трансляцию адресов со стороны процессора, то есть MMU, отвечает либо KVM, либо специфичные функции, которые тесно связаны с эмулируемой в QEMU архитектурой, то есть softmmu. В случае с KVM трансляция адреса напрямую в QEMU не происходит.
С DMA ситуация обстоит иначе, поскольку большинство моделей устройств не зависят от архитектуры эмулируемого процессора. Более того, адресное пространство в QEMU можно сопоставить с трансляцией адресов доступа, что выполняется с помощью IOMMU.
Если доступ к адресному пространству осуществляется в обход MMU, то есть не из QEMU TCG, тогда для чтения со стороны процессора определена функция cpu_physical_memory_rw()
, а для устройств обозначены отдельные dma_memory_rw() и pci_dma_rw().
Зачем нам нужен IOMMU
IOMMU — это аппаратный модуль, который встроен в современные процессоры или чипсеты. Он управляет доступом устройств ввода-вывода к памяти системы. IOMMU выполняет функции, сходные с MMU (Memory Management Unit), но для устройств.
IOMMU в QEMU можно разделить на два типа:
«реальный» IOMMU хоста, который используется для предоставления устройств в KVM машину,
эмулируемый IOMMU, который работает только в рамках машины QEMU.
Когда модель устройства QEMU пытается писать или читать в назначенное адресное пространство, адреса могут транслироваться с помощью IOMMU. То есть, если модель устройства QEMU читает и пишет в обход общепринятой модели, как, например, это сделано в третьем и четвертом методах, она не может пользоваться эмулируемым IOMMU.
Настройка внешней памяти как Alias
Любой регион памяти может быть размещен в адресное пространство с более высоким приоритетом. Хорошим примером может служить упрощение реализации Scratchpads.
Поскольку Scratchpads в Intel NTB — это фактически просто непрерывный массив регистров, мы можем заранее его выделить:
fallocate -l 4096 /dev/shm/intel-ntb.spad0
fallocate -l 4096 /dev/shm/intel-ntb.spad1
А затем использовать его в нашей модели:
qemu0 $ build-qemu/qemu-system-x86_64 -machine q35 -enable-kvm -smp 4
[...]
-object memory-backend-file,id=spad0,mem-path=/dev/shm/intel-ntb.spad0,size=64,share=on \
-object memory-backend-file,id=spad1,mem-path=/dev/shm/intel-ntb.spad1,size=64,share=on \
-device intel-ntb,spad0=spad0,spad1=spad1,[...] \
[...]
qemu1 $ build-qemu/qemu-system-x86_64 -machine q35 -enable-kvm -smp 4
[...]
-object memory-backend-file,id=spad0,mem-path=/dev/shm/intel-ntb.spad1,size=64,share=on \
-object memory-backend-file,id=spad1,mem-path=/dev/shm/intel-ntb.spad0,size=64,share=on \
-device intel-ntb,spad0=spad0,spad1=spad1,[...] \
[...]
Хотя мы можем выделить не меньше, чем размер страницы, в QEMU в качестве Alias можно использовать лишь часть:
static int intel_ntb_register_spad(IntelNtbState *s, IntelNtbRegionType type,
Error **errp)
{
IntelNtbRegion *reg = &s->regs[type];
reg->mr = host_memory_backend_get_memory(reg->be);
reg->size = memory_region_size(reg->mr);
memory_region_init_alias(®->alias, OBJECT(s), reg_map[type].alias,
reg->mr, 0x0, 0x40);
memory_region_add_subregion_overlap(&s->mmio, reg_map[type].addr,
®->alias, 99);
}
Давайте посмотрим на наше адресное пространство:
(qemu) info mtree
[...]
address-space: memory
0000000000000000-ffffffffffffffff (prio 0, i/o): system
0000000000000000-ffffffffffffffff (prio -1, i/o): pci
380040100000-38004010ffff (prio 1, i/o): intel-ntb.mmio
380040100080-3800401000bf (prio 99, ram): alias spad0.alias @spad0 0-000000000000003f
380040100180-3800401001bf (prio 99, ram): alias spad1.alias @spad1 0-000000000000003f
380040102000-38004010220f (prio 0, i/o): msix-table
380040103000-380040103007 (prio 0, i/o): msix-pba
[...]
Мы просто расположили наши файлы поверх BAR0, который содержит в себе конфигурационные регистры. В качестве бонуса у нас есть возможность в любой момент ознакомиться с содержимым регистров Scratchpads напрямую из нашей основной системы.
Аналогичным образом сделаны и MW, но Alias'ы мы располагаем только после записи размера в регистры GEN3_EMBAR1XLMT_OFFSET/GEN3_EMBAR2XLMT_OFFSET и поверх региона RAM, а не BAR0.
Подключение внешней памяти
Для подключения оперативной памяти, которая отображается в файл, достаточно штатных средств QEMU:
... -machine memory-backend=hostmem \
-object memory-backend-file,id=hostmem,size=512M,mem-path=/dev/shm/intel-ntb.ram0,share=on
Флаг share разрешает переиспользовать память другой машиной QEMU. Чтобы использовать ее в самом устройстве, мы должны:
Создать объект со ссылкой на файл памяти:
... -object memory-backend-file,id=intel-ntb.bar4,mem-path=/dev/shm/intel-ntb.ram0,size=512M,share=on
Потом отобразить файл в адресном пространстве устройства. В этом случае предоставить его целиком или как часть для памяти PCI BAR. Для чего нужно сделать возможной передачу ссылки memory-backend-file
в наше устройство:
typedef struct IntelNtbState {
...
HostMemoryBackend *bar4mem;
...
};
static Property intel_ntb_properties[] = {
...
DEFINE_PROP_LINK("bar4", IntelNtbState, bar4mem, TYPE_MEMORY_BACKEND,
HostMemoryBackend *),
...
};
static void intel_ntb_class_init(ObjectClass *klass, void *data)
{
...
device_class_set_props(DEVICE_CLASS(klass)c, intel_ntb_properties);
...
}
Затем на этапе реализации мы можем отобразить файл в качестве памяти для BAR4:
static void intel_ntb_realize(PCIDevice *dev, Error **errp)
{
...
MemoryRegion *backend_bar4;
backend_bar4 = host_memory_backend_get_memory(s->bar4mem);
bar4_size = memory_region_size(backend_bar4);
memory_region_init_alias(&s->bar4, OBJECT(s), "intel-ntb.bar4",
backend_bar4, 0x0, bar4_size);
pci_register_bar(PCI_DEVICE(s), 4,
PCI_BASE_ADDRESS_SPACE_MEMORY |
PCI_BASE_ADDRESS_MEM_PREFETCH |
PCI_BASE_ADDRESS_MEM_TYPE_64,
&s->bar4);
...
}
Есть класс функций, которые можно использовать для инициализации региона памяти:
memory_region_init_ram_from_file()
— инициализация из файла,memory_region_init_ram_from_fd()
— инициализация из файлового дескриптора,memory_region_init_ram_ptr()
— инициализация из указателя.
Подключение прерываний
Я подробно описал подключение в первой статье, так что ограничусь небольшой справкой. Отмечу, что для модели Intel NTB нам подходит только первый метод, который подразумевает участие в обмене обеих машин QEMU. Дело в том, что пока все существующие драйверы опираются на метод ntb_db_read(), и нам необходимо, чтобы этот метод возвращал информацию о статусе прерываний. Так как для Intel NTB метод опирается на информацию в регистре GEN3_IM_INT_STATUS_OFFSET, обойти данный момент без модификации драйвера невозможно.
Линии прерываний передаются между машинами в виде файловых дескрипторов eventfd. Поэтому единственный транспорт, который нам подходит, это UNIX Socket.
Если взять в качестве примера QEMU Intel NTB, то там используются исключительно прерывания MSI-X, 33 линии.
В QEMU файловые дескрипторы можно передать через специальную надстройку:
#define GEN3_DB_MSIX_VECTOR_COUNT 33
static int intel_ntb_send_vectors(IntelNtbState *s)
{
uint8_t max_fds = MIN(TCP_MAX_FDS, s->nr_vectors);
...
to_sent = MIN(max_fds, GEN3_DB_MSIX_VECTOR_COUNT - i);
qemu_chr_fe_set_msgfds(&s->ctrl_chr, &fd_array[i], to_sent);
...
qemu_chr_fe_write(&s->ctrl_chr, buf, 3);
...
}
За раз не может быть передано больше, чем TCP_MAX_FDS (16). Также нельзя отправить только файловые дескрипторы, потому что нужна ненулевая запись в сокет.
Получить прерывание можно похожим образом:
static int intel_ntb_get_vectors(IntelNtbState *s, const uint8_t *buf, int size)
{
...
qemu_chr_fe_get_msgfds(&s->ctrl_chr, fds, num);
...
}
Отправить прерывание, как правило, можно в двух режимах:
через модель QEMU при записи в регистр: тогда мы отправляем прерывание на соседнюю машину с помощью функции
event_notifier_set()
,через KVM_IOEVENTFD, так как в QEMU v8.2.2 нет штатных механизмов для этого, и настройка должна происходить напрямую через ioctl().
Если режим KVM не поддерживается, тогда стоит передавать прерывание через event_notifier_set()
, так как eventfd может быть использован как для KVM, так и не для KVM.
На принимающей стороне необходимо настроить MSI-X:
int msix_init(PCIDevice *dev, unsigned short nentries,
MemoryRegion *table_bar, uint8_t table_bar_nr,
unsigned table_offset, MemoryRegion *pba_bar,
uint8_t pba_bar_nr, unsigned pba_offset, uint8_t cap_pos,
Error **errp);
res = msix_init(PCI_DEVICE(s), GEN3_DB_MSIX_VECTOR_COUNT,
&s->mmio,
0, INTEL_NTB_B2B_SKX_MSIX_TABLE,
&s->mmio,
0, INTEL_NTB_B2B_SKX_MSIX_PBA,
0xA0, NULL);
Отмечу расположение таблицы векторов и PBA — они могут отличаться по расположению в зависимости от устройства. Посмотреть, какие именно адреса используются, можно с помощью lspci:
Capabilities: [80] MSI-X: Enable+ Count=33 Masked-
Vector table: BAR=0 offset=00002000
PBA: BAR=0 offset=00003000
В случае отсутствия KVM на файловые дескрипторы устанавливается обработчик, тогда мы можем по событию вызывать соответствующее прерывание MSI:
...
qemu_set_fd_handler(fd, intel_ntb_vector_notify,
NULL, &s->msi_vectors[j]);
...
static void intel_ntb_vector_notify(void *opaque)
{
...
msix_notify(pdev, entry->virq);
...
}
Посылка прерываний напрямую в KVM требует настроить пути для прерываний через kvm_irqchip_add_irqfd_notifier_gsi()
. Как правило, это нужно делать, когда устройство включает MSI через конфигурационное пространство:
static int intel_ntb_vector_use(PCIDevice *dev, unsigned nr,
MSIMessage msg)
{
...
virq = kvm_irqchip_add_msi_route(&c, interrupt_number, dev);
...
kvm_irqchip_add_irqfd_notifier_gsi(kvm_state, EventNotifier,
NULL, virq);
...
Сначала мы запрашиваем номер виртуального virq через kvm_irqchip_add_msi_route()
, а затем назначаем внешний eventfd в качестве источника виртуального прерывания.
Замеры производительности
Отмечу, что в замерах рассматривался только режим KVM, поскольку рассматривать softmmu в этом контексте, на мой взгляд, нецелесообразно.
Характеристики ПК, на котором проводилось тестирование
Поскольку измерения во многом относительны, в качестве «недостижимого идеала» я взял скорость оперативной памяти на чтение/запись. Я измерял:
скорость памяти на системе,
скорость памяти внутри KVM-гостя,
скорость памяти внутри KVM-гостя, которая была выделена в виде
memory-backend-file
.
Результаты измерений:
host $ uname -a
Linux munin 6.9.8-gentoo-x86_64 #1 SMP PREEMPT_DYNAMIC Wed Jul 10 12:20:51 MSK 2024 x86_64 AMD Ryzen 9 7950X 16-Core Processor AuthenticAMD GNU/Linux
host $ ramspeed -b 3 -r 3 -l 5
[...]
INTEGER BatchRun AVERAGE: 52721.75 Mb/s
qemu # ramspeed -b 3 -r 3 -l 5
8Gb per pass mode
5-benchmark INTmem BatchRun mode
[...]
INTEGER BatchRun AVERAGE: 43150.50 Mb/s
qemu (memory-backend-file) # ramspeed -b 3 -r 3 -l 5
[...]
INTEGER BatchRun AVERAGE: 39497.34 Mb/s
Роль DMA в тестах
В QEMU + KVM, DMA может вести себя несколько иначе, чем в softmmu или реальном устройстве. Поэтому в некоторых случаях DMA снижает скорость копирования данных вместо того, чтобы ее повысить.
Гостевое ядро может в нашем случае писать и читать память соседней машины напрямую, как в свою собственную. Но при использовании DMA мы перекладываем копирование данных на модель QEMU.
Модификации для утилиты NTB Perf
Замеры для db_latency, poll_latency производились с помощью доработанной утилиты ntb_perf: актуальная версия в супер-репозитории.
QEMU Intel NTB
Итак, давайте запустим машины. Сначала мы заранее выделяем память под MW и Scratchpads:
$ cat tools/intel-ntb/fallocate-ntb.sh
#!/bin/bash
MW0_SIZE=1M
MW1_SIZE=1M
if [ "$1" ]; then
MW0_SIZE=$1
fi
if [ "$2" ]; then
MW1_SIZE=$2
fi
rm -rf /dev/shm/intel-ntb.*
fallocate -l 4096 /dev/shm/intel-ntb.spad0
fallocate -l 4096 /dev/shm/intel-ntb.spad1
fallocate -l "${MW0_SIZE}" /dev/shm/intel-ntb.mw0_01
fallocate -l "${MW0_SIZE}" /dev/shm/intel-ntb.mw0_10
fallocate -l "${MW1_SIZE}" /dev/shm/intel-ntb.mw1_01
fallocate -l "${MW1_SIZE}" /dev/shm/intel-ntb.mw1_10
$ tools/intel-ntb/fallocate-ntb.sh 1M 1M
Затем запускаем первую машину:
$ build-qemu/qemu-system-x86_64 -machine q35 -enable-kvm -smp 4 -m 1024 \
-cpu host,kvm-poll-control=true,kvm-hint-dedicated=true,kvmclock-stable-bit=true \
-netdev bridge,id=net0,helper=build-qemu/qemu-bridge-helper -device e1000e,netdev=net0,mac=52:54:00:12:34:57 \
-chardev socket,path=/tmp/intel-ntb-chardev,id=intel-ntb-chardev,server=on,nodelay=on \
-object memory-backend-file,id=spad0,mem-path=/dev/shm/intel-ntb.spad0,size=64,share=on \
-object memory-backend-file,id=spad1,mem-path=/dev/shm/intel-ntb.spad1,size=64,share=on \
-object memory-backend-file,id=mw0_01,mem-path=/dev/shm/intel-ntb.mw0_01,size=${MW0_SIZE}M,share=on \
-object memory-backend-file,id=mw0_10,mem-path=/dev/shm/intel-ntb.mw0_10,size=${MW0_SIZE}M,share=on \
-object memory-backend-file,id=mw1_01,mem-path=/dev/shm/intel-ntb.mw1_01,size=${MW1_SIZE}M,share=on \
-object memory-backend-file,id=mw1_10,mem-path=/dev/shm/intel-ntb.mw1_10,size=${MW1_SIZE}M,share=on \
-device intel-ntb,spad0=spad0,spad1=spad1,mw0_01=mw0_01,mw0_10=mw0_10,mw1_01=mw1_01,mw1_10=mw1_10,chardev=intel-ntb-chardev,topo=upstream,bar4nomap=false \
-device intel-ioatdma \
-device virtio-9p-pci,fsdev=rootfs,mount_tag=root9p \
-fsdev local,id=rootfs,path=overlay/qemu0/merged/,security_model=none \
-kernel build-linux/arch/x86_64/boot/bzImage \
-append "console=ttyS0 earlycon debug root=root9p rootfstype=9p rootflags=trans=virtio init=/init cma=64M" \
-display none -serial mon:stdio
И похожим образом запускаем вторую. При этом меняются местами имена файлов для spad и MW:
$ build-qemu/qemu-system-x86_64 -machine q35 -enable-kvm -smp 4 -m 1024 \
[...]
-object memory-backend-file,id=spad0,mem-path=/dev/shm/intel-ntb.spad1,size=64,share=on \
-object memory-backend-file,id=spad1,mem-path=/dev/shm/intel-ntb.spad0,size=64,share=on \
-object memory-backend-file,id=mw0_01,mem-path=/dev/shm/intel-ntb.mw0_10,size=${MW0_SIZE}M,share=on \
-object memory-backend-file,id=mw0_10,mem-path=/dev/shm/intel-ntb.mw0_01,size=${MW0_SIZE}M,share=on \
-object memory-backend-file,id=mw1_01,mem-path=/dev/shm/intel-ntb.mw1_10,size=${MW1_SIZE}M,share=on \
-object memory-backend-file,id=mw1_10,mem-path=/dev/shm/intel-ntb.mw1_01,size=${MW1_SIZE}M,share=on \
[...]
Все скрипты запуска можно найти в директории tools. Отдельно отмечу, что замеры производились с изоляцией CPU, который работал на минимальной частоте.
ntb_perf, ntb_transport, ntb_netdev
Мы используем 1 Мбайт для размеров BAR2/BAR4.
Часть инициализации одинакова для всех трех измерений. В каждом госте мы монтируем
debugfs и загружаем следующие модули:
# mount -t debugfs none /sys/kernel/debug/
# modprobe ntb_hw_intel
# modprobe ntb_perf use_dma=0 max_mw_size=1048576
Рассмотрим эти модули далее.
ntb_perf
Это тест из «ванильного» ядра. Мы проведем его так:
инициализировать окна,
инициализировать механизм обмена прерываниями, это могут быть Doorbells + Scratchpads или Messages,
копировать данные в Memory Window.
Я считаю, что его ценность сомнительна, так как он никак не проверяет скопированные данные. Тем не менее именно от него мы ожидаем скорость, максимально близкую к скорости записи нашей оперативной памяти. Именно это мы фактически и получаем:
# echo 0 > /sys/kernel/debug/ntb_perf/0000\:00\:03.0/run
# cat /sys/kernel/debug/ntb_perf/0000\:00\:03.0/run
Peer 0 test statistics:
0: copied 1073741824 bytes in 35593 usecs, 30167 MBytes/s
db_latency
Этот тест измеряет задержки между обменом прерываниями при записи в DB-регистры. Тест почти идентичен тесту mailbox pingpong из моей первой статьи. Нам нужно:
запустить сервер на одном из участников обмена,
запустить клиент на другом участнике обмена, который пишет в последний по счету DB,
сервер получает уведомление через вызов .db_event() и пишет в последний по счету DB,
по истечении заданного времени клиент производит подсчет.
guest1 # echo 0 > /sys/kernel/debug/ntb_perf/0000\:00\:03.0/db_latency/run_server
guest2 # echo 0 > /sys/kernel/debug/ntb_perf/0000\:00\:03.0/db_latency/run_client
guest2 # cat /sys/kernel/debug/ntb_perf/0000\:00\:03.0/db_latency/run_client
Peer 0 test statistics:
doorbell latency 17 us
poll_latency
Этот тест измеряет задержку между записью одного байта в BAR и получением подтверждения с помощью чтения одного байта из MW:
запустить сервер на одном из участников обмена, который ожидает записи символа в MW,
запустить клиент на другом участнике обмена, который пишет символ в BAR,
сервер читает символ из MW и записывает его в BAR,
по истечении заданного времени клиент производит подсчет.
guest1 # echo 0 > /sys/kernel/debug/ntb_perf/0000\:00\:03.0/poll_latency/run_server
guest2 # echo 0 > /sys/kernel/debug/ntb_perf/0000\:00\:03.0/poll_latency/run_client
guest2 # cat /sys/kernel/debug/ntb_perf/0000\:00\:03.0/poll_latency/run_client
Peer 0 test statistics:
poll latency 507 ns
ntb_transport
Это драйвер для транспорта, который позволяет обмениваться сообщениями и прерываниями поверх NTB. Пока его использует единственный компонент в ядре — NTB_NETDEV, который эмулирует сетевое устройство. Поэтому измерения проводились с помощью iperf3:
# iperf3 -c 8.8.8.1 -w 256K
Connecting to host 8.8.8.1, port 5201
[ 5] local 8.8.8.2 port 51508 connected to 8.8.8.1 port 5201
[ ID] Interval Transfer Bitrate Retr Cwnd
[ 5] 0.00-1.00 sec 1.36 GBytes 11.7 Gbits/sec 0 639 KBytes
[ 5] 1.00-2.00 sec 1.36 GBytes 11.7 Gbits/sec 0 639 KBytes
[ 5] 2.00-3.00 sec 1.36 GBytes 11.6 Gbits/sec 1 639 KBytes
[ 5] 3.00-4.00 sec 1.35 GBytes 11.6 Gbits/sec 0 639 KBytes
[ 5] 4.00-5.00 sec 1.33 GBytes 11.4 Gbits/sec 0 639 KBytes
[ 5] 5.00-6.00 sec 1.37 GBytes 11.7 Gbits/sec 0 639 KBytes
[ 5] 6.00-7.00 sec 1.37 GBytes 11.7 Gbits/sec 0 639 KBytes
[ 5] 7.00-8.00 sec 1.37 GBytes 11.8 Gbits/sec 0 639 KBytes
[ 5] 8.00-9.00 sec 1.37 GBytes 11.8 Gbits/sec 0 639 KBytes
[ 5] 9.00-10.00 sec 1.37 GBytes 11.7 Gbits/sec 0 639 KBytes
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval Transfer Bitrate Retr
[ 5] 0.00-10.00 sec 13.6 GBytes 11.7 Gbits/sec 1 sender
[ 5] 0.00-10.00 sec 13.6 GBytes 11.7 Gbits/sec receiver
iperf Done.
Сводная таблица результатов
Замеры ниже я привожу не для того, чтобы показать, что QEMU лучше или хуже реального «железа». Это демонстрация работоспособности модели в целом. iperf3 я использовал с параметром '-w 256K', а ntb_perf – c 'chunk_order=18', то есть 256K.
Тест |
qemu |
qemu (DMA) |
hardware |
hardware (DMA) |
ntb_perf |
30601 Мбайт/c |
17632 Мбайт/c |
4216 Мбайт/c |
5059 Мбайт/c |
db_latency |
17 мкс |
-||- |
6170 нс |
-||- |
poll_latency |
426 мкс |
-||- |
1033 нс |
-||- |
ntb_transport |
17.9 Гбит/с |
11.7 Гбит/с |
7.09 Гбит/с |
8.05 Гбит/с |
Применимость модели QEMU Intel NTB
Модель хорошо себя показала с точки зрения разработки — все изменения, которые обкатывались на модели, потом успешно и без правок работали на реальном устройстве. Также, на модели мы поработали над следующими транспортами, работающими поверх NTB, о которых я расскажу ниже.
Virtio/Vhost RPMSG (Remote Processor Messaging) over NTB
RPMSG — подсистема в ядре, которая служит для коммуникации разных процессоров в рамках одного SoC. На мой взгляд, самые яркие представители: imx7d, imx8 и STM32MP2. Основная особенность подсистемы RPMSG — ее асимметричность. В ядре реализована поддержка только клиентской части — предполагается, что роль инициатора берет на себя со-процессор с помощью других средств, например OpenAMP.
Подсистема позволяет поверх транспорта, который обеспечивает virtio, создавать такие устройства как tty или char. С одной стороны — это необходимый минимум, с другой — удовлетворяет потребности большинства применений таких устройств. Дело в том, что изначально RPMSG была создана компанией STM с единственной целью: уйти от связки «основной» процессор < - > UART < - > «дополнительный» процессор. Такая связка ранее широко применялась, вероятно применяется и до сих пор.
В 2020 году в качестве RFC была предложена интересная концепция использовать этот подход поверх PCIe NTB: Enhance VHOST to enable SoC-to-SoC communication. Эта серия патчей была адаптирована к более новой версии ядра и проверена в первую очередь на представленной модели.
Virtio/Vhost over NTB
В примере выше RPMSG была выбрана автором серии патчей, поскольку это наиболее простое устройство, для которого можно быстро разработать ответную часть vhost. Что касается virtio-драйверов в ядре, с ними все более-менее понятно. Однако vhost-драйверов, в отличие от virtio, просто не существует, так как в них нет необходимости.
Поэтому вполне логично дальше развивать эту идею и попробовать дополнить существующую инфраструктуру. Так мы сможем пользоваться всем набором virtio драйверов поверх NTB или любой другой разделяемой между процессорами или готовыми устройствами памяти.
Тут мы в самом начале пути, хотя и удалось сделать некие простые тесты для копирования данных.
RDMA over PCIe Non-Transparent Bridge (NTRDMA)
Основная причина, по которой эта модель была создана. Изначально данная концепция была представлена еще в 2015 году на конференции Linux Piter: NTRDMA v0.1 - An Open Source RDMA Driver for PCIe NTB and DMA. С тех пор Аллен Хаббе стал мантейнером подсистемы NTB в Linux, а вот NTRDMA частью ядра так и не стал.
Я думаю, что не в последнюю очередь повлиял режим использования NTB — для нормальной работы RDMA необходимо, чтобы участники имели доступ ко всей памяти соседа, что неприемлемо по очевидным причинам, так что данная концепция даже не была переставлена в качестве RFC. Однако мы довели ее до рабочего состояния на последних версиях ядра во многом благодаря модели NTRDMA.
Список литературы
От автора
Благодарю коллег за ценные комментарии и дополнения:
Сергея Мирошниченко (@zergium), руководителя отдела системного программирования в YADRO,
Евгения Шатохина, ведущего инженера-программиста в отделе перспективных разработок YADRO.