В статье мы мы расскажем немного о системах хранения данных (СХД), в частности о применении технологии NTB поверх шины PCIe. Наша команда столкнулась с задачей виртуализации технологии NTB в QEMU, решение которой было сведено к созданию частичной виртуализации IDT 89HPES24NT6AG2 PCI Express Switch, модификации модуля ядра Linux для поддержки нашей виртуализации и, конечно, сборки этого всего воедино с помощью Yocto Project. Структура статьи сводится к вопросам:
-
Где проблема-то?
-
Что такое NTB?
-
Какая проблема с железками?
-
Аналоги и альтернативы NTB
-
Немного о виртуализации и нашей реализации
-
Производительность решения
-
Вывод
Данная статья является расширенной версией доклада, прочитанного на Школе молодых ученых в рамках форума Микроэлектроника 2024, и основывается на исследовательском проекте по виртуализации NTB соединений, выполненном коллективом преподавателей и студентов СПБГЭТУ “ЛЭТИ”.
Где проблема-то?
В современном мире поток и объём используемой информации очень велики и продолжают расти. Все эти данные не уместить на одном диске, поэтому используют наборы дисков, которые управляются как один очень большой диск. Программно-аппаратные решения, которые реализуют такой доступ к системе с несколькими объединенными дисками, называют системы хранения данных (СХД). СХД обеспечивает корректное логическое взаимодействие с данными (на каком диске находятся те или иные данные) и их сохранность при выходе диска из строя. С течением времени данных становится только больше, а работа с ними — всё более интенсивной, поэтому для СХД важно уметь быстро обрабатывать поступающие запросы, обеспечивать высокую доступность данных и быть надёжными, т.е. иметь высокую степень отказоустойчивости. Такие системы работают 24/7 и обрабатывают запросы постоянно, поэтому есть 2 ключевые характеристики СХД: скорость доступа к данным и отказоустойчивость. Чтобы решить обе задачи, применяется архитектура Symmetric Active-Active. По сути она заключается в использовании 2-х равноправных контроллеров для доступа к одним и тем же данным. Активность 2-х контроллеров позволяет убрать единую точку отказа, одновременно обрабатывать запросы. NTB, в свою очередь, в таких системах обеспечивает когерентность (синхронность) состояний контроллеров.
Из-за использования двух контроллеров возникает необходимость взаимодействовать их друг с другом, чтобы своевременно распределять нагрузку и отслеживать ошибки. Для этого может требоваться достаточно большая пропускная способность из-за передачи больших объемов данных. Взаимодействие по Ethernet-кабелю может оказаться недостаточно быстрым и показывать большие задержки, поэтому необходимо использование другой технологии, например, PCI (Peripheral Component Interconnect), последние версии которой достигают пропускной способности в несколько сотен гигабайт данных в секунду (зависит от конкретной версии и ширины шины) и имеют сравнительно небольшую задержку. Однако по историческим причинам PCI-схема является централизованной, т.е. внешние устройства подключаются к одному вычислительному узлу. Попытки подключить два и более вычислительных узла в один PCI-домен приведут к ошибкам и невозможности функционирования системы. И здесь в игру вступает NTB.
Что такое NTB?
NTB (Non-Transparent Bridge) — технология, скрывающая два и более PCI домена друг от друга за конечным устройством, что позволяет им взаимодействовать без конфликтов. Иными словами, делает так, что каждый хост думает, что другой хост — обычное устройство. Т.е. для каждого домена все остальные выглядят как конечное устройство, через которое можно осуществлять взаимодействие. Это позволяет соединить вычислительные узлы из разных PCI доменов в одну систему. В первую очередь эта технология позволяет писать данные в DMA-память (Direct Memory Access) другого хоста (контроллера) и читать из нее без затрат процессорного времени.
NTB поддерживает следующие возможности:
-
Doorbell-регистры, которые позволяют вызывать прерывания в PCI домене по другую сторону моста. Таким образом вычислительные узлы могут сигнализировать друг другу о каких-либо событиях.
-
Scratchpad-регистры, которые видны всем вычислительным узлам, связанным через NTB. Можно представить scratchpad-регистры как аналог разделяемой памяти для процессов. Устройство, реализующее NTB, может иметь внутренний механизм синхронизации и сигнализировать, если кто-то другой уже записал значение в такой регистр.
-
Обращение к оперативной памяти другого вычислительного узла через MW (memory window, окно памяти). При обращении к BAR (base address register) устройство NTB преобразует его в адрес другого PCI-домена и отправляет запрос на другую сторону моста. Таким образом, уменьшается количество необходимых копирований данных, так как поток передачи данных напрямую идёт в память другого вычислительного узла.
Какая проблема с железками?
Работа с NTB является комплексной задачей. Многое может зависеть от возможностей конкретного устройства (количество поддерживаемых доменов, количество MW, количество doorbell- и scratchpad-регистров и их работа), а разработка ПО ведётся в пространстве ядра Linux. По итогу возрастает вероятности ошибки при разработке, а также усложняется отладка. Если использовать при этом реальные устройства, то процесс становится сложнее, менее предсказуемым/воспроизводимым, а также есть вероятность что-то сломать (ничто не идеально, ничто не защищено полностью). Таким образом возникает задача виртуализации технологии NTB для обеспечения разработки и тестирования нового программного обеспечения в специализированной среде, где можно легко воспроизводить различные сценарии и легко анализировать возникшие ошибки.
В дополнение к словам выше, виртуализация и виртуальные машины активно используются на серверах для удобной изоляции процессов. Хоть в виртуальных машинах нет проблем, связанных с PCI (потому что все виртуально), но разработка различных ПО под разные виды запуска (на реальном устройстве, в эмуляторе) может дорого стоить (в долгосрочной перспективе ещё и две версии поддерживать) и не быть целесообразной. Также используемый в ядре стек NTB может иметь меньшую дополнительную нагрузку, что приведет к увеличению производительности даже в эмуляторе. Кроме того, виртуализация увеличивает доступность технологии, так как позволяет внести свой вклад в разработку тем, кто не имеет возможности использовать реальное устройство.
Аналоги и альтернативы NTB
Так как основной задачей является коммуникация с минимальными задержками, копированиями данных, использованием CPU, то нужна технология, которая сможет работать напрямую с памятью системы. Поскольку речь идет о кластерных системах (высокопроизводительные кластеры, системы хранения данных), то необходимо работать с памятью удаленной системы. Для этого существует RDMA (Remote Direct Memory Access), которая реализует в ядре ОС поддержку операций чтения и записи в память как удаленной, так и локальной системы. Но RDMA может общаться по разным физическим шинам, например, явным представителем является RoCE (RDMA over Converged Ethernet), которая реализует передачу пакетов технологии Infiniband через сетевой Ethernet-адаптер.
Infiniband — специальная сеть для высокопроизводительных систем, которая по своей сути ориентирована на коммуникацию между серверами, которые не располагаются на большом расстоянии. Требование к расстоянию позволило снизить потребление энергии на поддержку такой сети, а также в ней используется до 48 контактов (линий) для передачи данных, что увеличивает пропускную способность в сравнении с Ethernet. Для такой сети требуются специальные сетевые интерфейсы и коммутаторы.
Но если рассматривать задачу в общем смысле, то она сводится к коммуникации между 2-мя и более устройствами, каждое из которых отвечает за какую-то задачу, наподобие взаимодействия видеокарты и центрального процессора. Разве что устройства должны быть равны (иметь одинаковый ранг). Для коммуникаций с большой пропускной способностью используется PCIe, а NTB решает задачу однорангового соединения в иерархии PCIe. Но для того, чтобы соединить 2 сервера, зачастую также нужны специальные PCIe свичи и сетевые интерфейсы.
Итого, есть 3 типа соединений для решения задачи коммуникации между системами для работы с RDMA:
-
Ethernet
-
Infiniband
-
PCI/PCIe
В 2019 году вышла первая версия стандарта CXL 1.0, которая призвана увеличить масштабируемость по шине PCIe и основывается на PCIe 5.0. Но в данной версии спецификации не подразумевается одноранговое соединение, поэтому мы его не рассматриваем. В версии CXL 3.0,, вышедшей в 2022 году, которая создается на базе шины PCIe 6.0 уже добавлена технология Peer-to-Peer коммуникации между устройствами без задействования хост-системы и технология когерентной разделяемой памяти для множества хостов. В данном случае предлагается использовать отдельное устройство памяти, которое будет предоставлять когерентный доступ для всех хостов сети поверх шины PCIe. Возможно, реализуемо и использование одного из хостов в качестве разделяемой памяти, но эта возможность не изучалась и требует детального рассмотрения стандарта.
На момент 2023 года получить устройства на базе шины PCIe 6.0 не представляется возможным, так как работа над дизайном PCIe 6.0 была завершена только в 2022 году.
Сравнение основных шин можно найти тут. Для обеспечения наибольшей пропускной способности в целом есть только 2 варианта: PCIe шина и Infiniband. Infiniband — программная реализация коммуникации, добавленная в ядро ОС, в то время как NTB реализуется как endpoint (виртуальный или аппаратный). Аппаратно может быть реализован на процессоре (поддержка NTB добавлена в процессорах intel и amd) или же в PCIe устройстве (NTB-PCIe switch, NTB-PCIe card), которое будет писать и читать в память хоста. Поэтому NTB потенциально обеспечивает минимальную загрузку ОС и CPU хоста, и минимальные задержки, хотя наследует все особенности PCIe шины. Но NTB в целом может быть использован и для сети infiniband. Интересное сравнение этих вариантов.
Виртуализация
Итак, нам нужно создать виртуальное устройство NTB.
Виртуальные устройства нельзя использовать с операционными системами, работающими непосредственно на оборудовании — так что наша задача сводится к реализации поддержки эмуляции NTB-устройства в системе виртуализации операционных систем.
Виртуализация — способ запуска нескольких операционных систем на одном физическом компьютере.
Виды виртуализации
По способу исполнения
По способу исполнения инструкций виртуализация делится на:
-
эмуляцию,
-
бинарную трансляцию,
-
паравиртуализацию,
-
полную (аппаратную) виртуализацию.
При эмуляции эмулятор сам исполняет инструкции, эмулируя процессор. Гипервизор при этом содержит всё состояние.
Эмуляция позволяет исполнять код, предназначенный для процессоров других архитектур. Однако, при эмуляции эффективность выполнения кода, предназначенного для той же архитектуры такая же, как и для отличных от родной, что делает эмуляцию мало подходящим выбором для виртуализации, если имеет значение эффективность.
При бинарной трансляции эмулятор преобразует инструкции целевой процессорной архитектуры в родные для архитектуры данной машины.
Чаще всего это реализовано посредством промежуточного представления, например, в QEMU это собственная разработка — TCG (tiny code generator).
Бинарная трансляция бывает статической и динамической. Статическая позволяет заранее (AOT, ahead of time) транслировать исполняемый файл. При этом повышается эффективность, но теряют работоспособность программы, которые полагались на модификацию кода во время исполнения и т.д. Динамическая преобразует код по мере исполнения (JIT, just in time). При этом обеспечивается полная совместимость, но во время исполнения затрачиваются ресурсы на трансляцию.
Бинарная трансляция ближе всего к эмуляции, отличие в основном в намного более высокой эффективности.
Трансляция кода архитектуры в код той же самой архитектуры также не является специальным случаем, что делает полную виртуализацию более предпочтительным вариантом. Были попытки реализовать выполнение части инструкций непосредственно на процессоре (модуль ядра KQEMU, позволявший выполнять так код пространства пользователя гостя), но по мере популяризации KVM они сошли на нет.
При паравиртуализации ядро гостевой ОС модифицируется так, чтобы вместо выполнения привилегированных инструкций (обращений к оборудованию) оно обращалось к гипервизору через интерфейс гипервызовов (hypercalls).
Не требуется специальная поддержка со стороны оборудования, но необходима модификация кода гостевой ОС.
При полной (аппаратной) виртуализации код выполняется непосредственно на процессоре, но процессор, встречая привилегированную инструкцию, обращается к гипервизору, сообщая о том, что её необходимо эмулировать.
Не требуется модификация кода гостевой ОС, но требуется аппаратная поддержка (расширения набора инструкций для виртуализации, например, Intel VT-d или AMD-V).
По типу гипервизора
В последних двух типах виртуализации программа, обеспечивающая виртуализацию, называется гипервизором.
Гипервизоры делятся на два типа:
-
первый тип — native hypervisor — работает непосредственно на оборудовании;
-
второй тип — hosted hypervisor — работает под управлением операционной системы.
При этом современные гипервизоры второго типа, такие как KVM, стирают грань между двумя типами, одновременно пользуясь гибкостью второго типа (простота конфигурирования, использование полноценного стека драйверов и релизаций протоколов, использование компонент пространства пользователя для расширения функциональности) и производительностью первого типа (KVM фактически работает на оборудовании, поскольку является подсистемой ядра Linux).
Инструментарий для виртуализации
Наша цель — кроме виртуализации самой гостевой ОС также эмулировать некоторое оборудование.
Поэтому гипервизоры первого типа нам не подходят: необходимо что-то более гибкое, что-то, сочетающее в себе и систему виртуализации, и эмулятор…
И такая программа есть — это QEMU.
QEMU
QEMU (quick emulator) — программа пространства пользователя под Linux. Изначально QEMU — бинарный транслятор, но позднее была добавлена поддержка использования KVM.
QEMU известен своей универсальностью — кроме задач виртуализации или трансляции он может эмулировать широкий спектр разнообразного оборудования, от Ethernet-адаптеров до IOMMU-чипов.
В QEMU реализован слой абстракций, облегчающих реализацию устройств — QEMU Object Model и обвязки над конкретными протоколами, например, PCI. Это как раз то, что нужно для нашего проекта.
KVM или не KVM
Код устройств в основном общий — в случае использования KVM несколько отличается управление прерываниями, но это не критично, так что реализованное однажды устройство не придется значительно менять для обеспечения поддержки.
Мы не использовали KVM, так как перед нами стояла задача обеспечения портативности — всё должно было работать в том числе на архитектурах без аппаратной поддержки виртуализации. То есть в нашем случае QEMU работал в режиме бинарного транслятора.
Yocto
Для того, чтобы обеспечить воспроизводимость сборок, а также для того, чтобы и QEMU, и тестовые гостевые системы собирались и разрабатывались в одном месте, было решено использовать Yocto.
Для того, чтобы унифицировать окружение сборки и упростить её процесс, мы реализовали Docker-контейнер, который в зависимости от передаваемой ему команды:
-
собирает проект (только ядро, только QEMU или всё сразу);
-
запускает тестовые гостевые системы через собранный QEMU (одну или пару связанных, то есть вместе с ivshmem-server, о чём подробнее далее);
-
синхронизирует патчи из репозитория проекта в репозиторий с poky.
Реализация устройства в QEMU
В Linux есть поддержка следующих ntb производителей: Intel, AMD, IDT и Switchtec. В открытом доступе были найдены спецификации только для устройств IDT, поэтому для реализации в QEMU была использована спецификация устройства 89HPES24NT6AG2.
Для реализации нового устройства за основу была взята реализация устройства ivshmem, которое позволяет пробрасывать в виртуальную машину shared memory. Дополнительно данное устройство позволяет посылать прерывания другим виртуальным машинам. Для этого придуман свой API и реализован сервер (ivshmem-server) в QEMU. Данное устройство выглядит идеальным кандидатом для начала разработки, так как реализует одну из больших сложностей: общение двух виртуальных машин.
Для начала необходимо добавить новое устройство в сборку QEMU. Файл с новым устройством был назван idt_ntb_ivshmem.c, который является копией файла ivshmem.c. Далее нужно в конфигурационные файлы сборки добавить следующие изменения:
# Файл hw/misc/meson.build softmmu_ss.add(when: 'CONFIG_IDT_NTB_IVSHMEM_DEVICE', if_true: files('idt_ntb_ivshmem.c')) # Файл hw/misc/Kconfig config IDT_NTB_IVSHMEM_DEVICE bool default y if PCI_DEVICES depends on PCI && LINUX && IVSHMEM && MSI_NONBROKEN
Далее изменим несколько вещей, чтобы наше устройство можно было запустить в QEMU и Linux в виртуальной машине распознал устройство как IDT NTB:
-
Меняем название устройства на idt_ntb_ivshmem, чтобы его можно было задать через флаг `-device idt_ntb_ivshmem при запуске QEMU.
-
Меняем ID производителя и устройства на реальные, чтобы Linux смог корректно идентифицировать устройство.
-
Меняем тип устройства с PCI на PCIe, так как реальное устройство работает по PCIe и часть настроек лежит в 4Кб конфигурационном пространстве PCIe. С этим пунктом связан ещё один интересный момент. Если оставить устройство PCI, то все обращения по адресам больше 256 будут обрезаться до нужного размера.
Изменения в коде для пунктов выше:
C // Пункт 1, задаём имя устройство и регистрируемый новый тип в QEMU #define TYPE_IVSHMEM_NTB_IDT "idt-ntb-ivshmem" DECLARE_INSTANCE_CHECKER(IVShmemState, IVSHMEM_NTB_IDT, TYPE_IVSHMEM_NTB_IDT) // Пункт 2, задаём ID производителя и устройства #define PCI_VENDOR_ID_IVSHMEM 0x111D // IDT Vendor ID #define PCI_DEVICE_ID_IVSHMEM 0x8091 // PCI_DEVICE_ID_IDT_89HPES24NT6AG2 `from ntb_hw_idt.h` // В функции `class_init` устанавливаем новые ID, класс устройства и описание static void ivshmem_ntb_idt_class_init(ObjectClass *klass, void *data) { k->vendor_id = PCI_VENDOR_ID_IVSHMEM; k->device_id = PCI_DEVICE_ID_IVSHMEM; k->class_id = PCI_CLASS_BRIDGE_OTHER; /* NT function */ k->revision = 0; set_bit(DEVICE_CATEGORY_MISC, dc->categories); device_class_set_props(dc, ivshmem_idt_ntb_properties); dc->desc = "IDT NTB over shared memory"; // … } // Пункт 3, добавляем интерфейс PCIe static const TypeInfo ivshmem_ntb_idt_info = { .name = TYPE_IVSHMEM_NTB_IDT, .parent = TYPE_PCI_DEVICE, .instance_size = sizeof(IVShmemState), .class_init= ivshmem_ntb_idt_class_init, .interfaces = (InterfaceInfo[]) { { INTERFACE_PCIE_DEVICE }, { }, }, .instance_init = ivshmem_ntb_idt_init, };
С этого момента Linux в виртуальной машине может корректно распознавать новое устройство и работать. Однако инициализация не проходит успешно, так как драйвер пытается взаимодействовать с устройством, но не получает внятного ответа. Для того, чтобы драйвер успешно инициализировался необходимо добавить следующие вещи в реализацию:
-
Замена MSIX на MSI прерывания, которые поддерживает IDT NTB.
-
Подключение BAR.
-
Реализация функций устройства через конфигурационное пространство PCIe и BAR0.
В QEMU уже реализован API для взаимодействия с MSI прерываниями. Чтобы включить их нужно при создании устройства вызвать функцию msi_init. Чтобы вызвать прерывание в виртуальной машине достаточно вызвать функцию msi_notify и указать номер прерывания. Аналогично MSI прерываниям для BAR также уже реализован API, который легко позволяет выполнить их настройку. Для активации BAR нужно вызвать функцию pci_register_bar, после чего BAR становится доступен в виртуальной машине. Для регистрации BAR необходимо создать Memory Mapped Input Output (MMIO), который представляет BAR виртуальной машине. MMIO выглядит как адресное пространство, в котором можно читать и писать, однако на деле никакой памяти нет и все запросы идут через указанные функции чтения и записи. Для создания такого региона необходимо вызвать функцию memory_region_init_io, в которую необходимо передать имя памяти, размер и структуру MemoryRegionOps с операциями IO.
Вызов данных функций нужно производить непосредственно в функции инициализации объекта устройства. Такая функция указывается в параметрах класс:
static void ivshmem_ntb_idt_class_init(ObjectClass *klass, void *data) { // … k->realize = ivshmem_ntb_idt_realize; k->config_write = ivshmem_write_config; k->config_read = ivshmem_read_config; // … }
Также в параметрах класса указываются функции для чтения и записи с конфигом PCIe (работает также как MMIO, но не нужно производить ручную регистрацию региона). Затем в данной функции (ivshmem_ntb_idt_realize) выполняем инициализацию MSI прерываний и BAR:
static void ivshmem_ntb_idt_realize(PCIDevice *dev, Error **errp){ // … // Инициализируем прерывания с одним вектором if (msi_init(dev, 0, 1, false, false, errp)) { // error } // Инициализируем IO регион памяти, указываем операции для чтения и записи в данную память memory_region_init_io(&s->bars[0], OBJECT(s), &ivshmem_mmio_ops, s, "idt-mmio", IVSHMEM_REG_BAR_SIZE); // Регистрируем BAR на созданный IO регион память pci_register_bar(dev, 0, PCI_BASE_ADDRESS_SPACE_MEMORY, &s->bars[0]); // … }
Теперь необходимо реализовать сами функции IO для MMIO. И (барабанная дробь) в них нет ничего интересного. Они имеют следующие сигнатуры:
uint64_t read(void *opaque, hwaddr addr, unsigned size); void write(void *opaque, hwaddr addr, uint64_t val, unsigned size)
opaque – это указатель на структуру устройства, addr – адрес, по которому произошло обращение на запись или чтение, size – размер в байт для выполнения операции (можно считать один байт, а можно сразу восемь). Типичная реализация таких функций выглядит следующим образом:
static void write(void *opaque, hwaddr addr, uint64_t val, unsigned size) { MyDeviceState *s = opaque; switch (addr) { case ADDR_W1: do_addr_w1(); break; case ADDR_W2: do_addr_w2(); break; // … } } static uint64_t read(void *opaque, hwaddr addr, unsigned size) { MyDeviceState *s = opaque; uint64_t ret = 0; switch (addr) { case ADDR_R1: ret = do_addr_r1(); break; case ADDR_R2: ret = do_addr_r2(); break; // … } return ret; }
Дальше нужно только реализовать логику взаимодействия согласно документации. Каких-то хитростей со стороны QEMU для этого не нужно. Хитрости начинаются на моменте, когда мы хотим передавать данные с одной виртуальной машины на другую. Т.е. из одного QEMU процесса в другой QEMU процесс. Как уже упоминалось ранее, в QEMU реализован ivshmem-server, который позволяет посылать уведомления другим виртуальным машинам. Также данный сервер предоставляет для доступа участок shared memory для обмена данными. Обращение происходит через файловые дескрипторы. Для удобства использования можно замапить файловый дескриптор в `MemoryRegion` как абстракцию для использования в API QEMU. Для этого можно воспользоваться функцией memory_region_init_ram_from_fd. Если необходимо получить указатель, чтобы работать как с обычными памятью, то необходимо вызвать функцию memory_region_get_ram_ptr.
Помимо настройки общей памяти необходимо настроить отправку уведомлений между виртуальными машинами. Для этого были сделаны две роли: первая и вторая. Первая роль в shared memory записывает свой id, который был выдан isvhmem-server. Затем через некоторое время запускается вторая виртуальная машина (т.е. QEMU процесс), который видит id первой виртуальной машины, настраивает уведомления и отправляет уведомление. Это уведомление получает первая виртуальная машина и также устанавливает уведомления для второй виртуальной машины. Таким образом устанавливается начальное соединение.
Логика и использование API QEMU для установки соединений были взяты из реализации ivshmem устройства, поэтому можно смотреть в его код для больших деталей. Дополнительно узнать про доступный API можно из заголовочного файла qemu/event_notifier.h.
Теперь основные данные в shared memory: doorbell регистры, которые посылают уведомления на другую сторону NTB, scratchpad регистры для обмена 4-ых байтовыми значениями. Однако самая главная особенность NTB – это возможность напрямую читать и записывать данные в память другого хоста, аналогично обычному DMA. Тут то и появляется новая трудность: как взаимодействовать с памятью другой виртуальной машины?
Простым решением является использование shared memory с уведомлениями. Это возможно, так как устройства имеют доступ ко всей памяти виртуальной машины. Однако это неэффективно, так как нужно два раза посылать уведомления (в одну сторону передать адрес, в обратную сторону передать данные) другой виртуальной машине, что означает прерывание процессе QEMU, который занимается трансляцией и исполнением кода. Найденное решение оказалось более простым и менее затратным (как по вычислениям, так и по реализации): попросить QEMU держать память виртуальной машины не у себя в адресном пространстве, а в shared memory. Для этого нужно нужно указать memory object с адресом в shared memory, а затем указать его как место для хранения памяти ВМ. Итоговая конструкция из флагов выглядит так:
-object memory-backend-file,id=mem,size=<SIZE>M,mem-path=/dev/shm/path,share=on -machine memory-backend=mem -m <SIZE>m
где <SIZE> – размер памяти ВМ. Теперь можно открыть файл, который отображает память ВМ, и работать напрямую с данными.
Сама же реализация Memory Window не настолько интересна. На текущий момент реализован только режим, когда всё адресное пространство BAR является одним Memory Window. Информация о базовом адресе для другой ВМ хранится в конфиге данного BAR, т.е. по определенному смещению в BAR0. После того как виртуальная машина обращается в BAR происходит вызов функции, который был указан при создании региона (memory_region_init_io). В данной функции рассчитывается адрес в другой виртуальной машине и затем происходит атомарный доступ к памяти другой ВМ через shared memory. Итоговая архитектура решения выглядит следующим образом:
Важные недостатки в текущей реализации:
-
BAR0 используется только для настройки и не может быть использован как Memory window;
-
Memory window поддерживается только для BAR2-4;
-
В текущей реализации пути до файлов, в которых хранится RAM виртуальных машин, прописаны в коде, поэтому их нельзя изменить без перекомпиляции;
-
Поддержка только 2 ВМ для коммуникации (по документации реальное устройство поддерживает до 6 включительно подключенных систем);
-
Некоторое параметры настроек виртуализированного устройства захардкожены, например, размер BAR;
-
Для изменения принимаемых параметров необходимо изменить массив структур Property, используя заготовленные макросы в QEMU:
static Property ivshmem_idt_ntb_properties[] = { DEFINE_PROP_CHR("chardev", IVShmemState, server_chr), DEFINE_PROP_UINT32("vectors", IVShmemState, vectors, 1), DEFINE_PROP_UINT32("number", IVShmemState, self_number, 0), DEFINE_PROP_BIT("ioeventfd", IVShmemState, features, IVSHMEM_IOEVENTFD, true), DEFINE_PROP_ON_OFF_AUTO("master", IVShmemState, master, ON_OFF_AUTO_OFF), DEFINE_PROP_END_OF_LIST(), };
Доработка драйвера ядра Linux
В процессе тестов оказалось, что целевая библиотека NTRDMA реализована под специфику реализации NTB в Intel драйвере. Одной частной проблемой оказался алгоритм инициализации окон памяти. При этой инициализации одно устройство должно передать адрес DMA памяти другому, но в NTRDMA одна сторона сама выставляет адрес своего окна памяти в регистр другого хоста, то есть передает другому хосту этот адрес, чтобы этот удаленный хост уже мог по нему обращаться, а в реализации idt не реализованы операции записи адреса в удаленный (внешний) регистр. Можно лишь установить адрес, полученный нерегламентированным способом (любым) во внутренний регистр, чтобы работать с удаленным хостом. В Intel реализованы оба варианта, но NTRDMA использует именно такой алгоритм, что не позволяет использовать его. Кроме того для его работы используются DMA-контроллеры или специфические для Intel DMA операции, что также усложнило возможность использования драйвера idt для работы с NTRDMA.
Но для базовой работы и тестирования NTB нужно было модифицировать драйвер и добавить в него необходимые операции (полный патч). Благо, что они делаются по аналогии с уже существующими операциями и не составляет большого труда.
Нам было необходимо реализовать 3 операции в дополнение к существующим:
static const struct ntb_dev_ops idt_ntb_ops = { … +.mw_set_trans = idt_ntb_mw_set_trans, +.mw_clear_trans= idt_ntb_mw_clear_trans … }
По сути эти функции idt_ntb_mw_set_trans() и idt_ntb_mw_clear_trans() являются копиями уже существующих idt_ntb_peer_mw_set_trans() и idt_ntb_peer_mw_clear_trans(), но с некоторыми ограничениями. Для целей нашего прототипа не было необходимости реализации LUT (Look-Up Table), а реализовали лишь прямой доступ к MW через регистр. LUT позволяет выделять несколько окон на один регистр. В нашем устройстве мы реализовали только прямой доступ, где нет трансляции адресов, а регистр соответствует ровно одному окну.
Для добавления этой функциональности было необходимо расширить список адресов регистров и были добавлены аналоги уже существующих регистров, но уже для новых функций:
+#define IDT_OUT_OF_SPEC_TPART 0x1000U +#define IDT_OUT_OF_SPEC_LUTOFF0x1004U +#define IDT_OUT_OF_SPEC_LDATA0x1008U +#define IDT_OUT_OF_SPEC_HDATA0x100CU +#define IDT_OUT_OF_SPEC_LLIMIT0x1010U +#define IDT_OUT_OF_SPEC_HLIMIT0x1014U
И, конечно, расширен и максимальный адрес:
-#define IDT_REG_PCI_MAX0x00FFFU +#define IDT_REG_PCI_MAX0x01018U
Эти изменения конечно не будут работать на реальном физическом устройстве, так как все эти изменения выходят за рамки спецификации, но будут работать с нашим виртуальным устройством.
Производительность решения
При тестировании через ntb_perf производительность нашей реализации устройства оказывается достаточно низкой. Так, для копирования 4-х мебибайт в среднем понадобилось около 5.743 секунд — то есть скорость составила примерно 713.2 кибибайта в секунду (5842.6 килобит/с). При этом скорость не зависит от размера кванта передаваемых данных:
Это связано с тем, что ntb_perf в связке с драйвером ntb_hw_idt по умолчанию использует memory windows, реализованные через BAR (base address registers).
В устройствах QEMU области памяти (memory regions), через которые работает наша реализация memory windows, поддерживают только 4- и 8-байтный доступ — причем в нашем случае только 4-х. Это значит, что, независимо от размера фрагмента, который использует ntb_perf, QEMU обрабатывает запись по 4 байта, вызывая каждый раз функцию для обработки.
Это объясняется тем, что данный функционал в QEMU не предназначен для использования вместо DMA — BAR’ы обычно нужны не для того, чтобы записывать туда мегабайты данных, а для того, чтобы модифицировать значения в регистрах, размер которых составляет несколько байт.
DMA
Раз уж упомянули DMA в контексте производительности, давайте разберемся и с ним.
Другие устройства ведь работают именно так — и даже эмулированные выдают вполне приличную скорость. Например, Intel e1000 из QEMU даже при использовании полностью юзерспейсного сетевого стека (slirp) выдает на том же оборудовании гигабитную (928 мегабит/с) пропускную способность при измерении, что в 171 раз больше скорости работы memory window через MemoryRegion.
Что же мешало задействовать DMA и в нашем устройстве?
ntb_perf действительно умеет работать не только через MMIO, но и через DMA — в таком случае он пытается задействовать API ядра dmaengine.
Проблема лишь в том, что в QEMU на текущий момент не реализован ни один DMA-контроллер для x86, который имел бы драйвер с dmaengine, так что и использовать этот API в нашем случае невозможно.
Вывод
NTB сам по себе является очень логичной идеей, так как по сути не добавляет новых шин, а позволяет использовать PCI/PCIe шину, но все же требует поддержки либо на CPU, либо в сетевой карте, либо PCIe свитче. Но ключевой usecase — это использование технологии RDMA, которая в контексте NTB реализуется только в NTRDMA. Мы столкнулись с тем, что часть логики NTRDMA завязана на реализацию NTB в драйвере от Intel, что не позволяет полноценно использовать NTRDMA с нашей модификацией драйвера от IDT. Могли ли мы модифицировать драйвер Intel и сделать виртуализацию для него? В теории конечно могли, но в отличии от IDT, компания Intel не предоставляет никакой спецификации по реализации NTB-контроллера. Задача бы свелась к заделыванию “дыр” в аппаратной поддержке и реверс-инжинирингу, но оценить сроки такой реализации крайне проблематично. Поэтому мы выбрали путь модификации IDT драйвера устройства и реализовали поддержку одного окна памяти. Хоть из-за специфики работы MemoryRegion наше решение работает медленно, но использовать низкоуровневую поддержку виртуализации NTB позволяет.
Авторы
Данную статью подготовили:
-
Гаврилов Андрей Владимирович, аспирант кафедры МОЭВМ, СПБГЭТУ “ЛЭТИ”.
-
Тиняков Сергей Алексеевич, магистрант кафедры МОЭВМ, СПБГЭТУ “ЛЭТИ”.
-
Карасев Максим Алексеевич, студент программы специалитета кафедры ИБ, СПБГЭТУ “ЛЭТИ”.
-
Заславский Марк Маркович, заместитель заведующего кафедрой МОЭВМ, СПБГЭТУ “ЛЭТИ”.
ссылка на оригинал статьи https://habr.com/ru/articles/853486/
Добавить комментарий