NVMe-диск по UDP без копирования данных
Модуль netblk отдаёт NVMe-диск по UDP, а диск кладёт данные по DMA прямо в кадр для сетевой карты. Как идут данные и какие два потерянных тика нашли замеры.
netblk — загружаемый модуль, который отдаёт по UDP NVMe-диск или раздел на нём. Процессор при этом ни разу не копирует данные. При чтении диск по DMA кладёт их прямо в кадр, который затем отправит сетевая карта. При записи диск забирает данные из того кадра, в котором их приняла карта, и этот же кадр уходит назад как ответ. Сам процессор пишет только 72 байта заголовков.
Модуль появился 12 сентября (9abe257). К вечеру 13-го netblk на AX41 обслуживал 111 100 случайных чтений по 1 КиБ в секунду. Это всё, что пропускает гигабитный канал этого сервера. По пути пришлось дважды исправить планировщик.
netblk start <disk> <port> [ro] [nic=eth0] [mtu=1500] [cpu=N] [poll=us]
netblk list
netblk stop <port>|all
Почему можно обойтись без копий
Работа без копирования (zero-copy) здесь означает, что ни одна инструкция процессора не читает и не пишет сами данные. Их переносят по DMA сами устройства, а процессор касается только заголовков. Особое железо для этого не нужно, хватает двух обстоятельств.
Первое касается того, где лежат байты кадра. Все драйверы сетевых карт в nos принимают пакеты в кадры по 2 КиБ из пула кадров ядра и кладут пакет с первого байта кадра. Кадр из пула целиком помещается в одну страницу, а его данные выровнены по 8 байтам. Все заголовки перед полезной нагрузкой netblk занимают целое число 4-байтовых слов (dword).
Второе касается того, чего NVMe ждёт от буфера. Команда NVMe описывает память записями PRP (physical region pages), и первая запись может начинаться с любого адреса, выровненного по dword. Поэтому полезная нагрузка принятой датаграммы уже годится в буфер, из которого диск заберёт данные по DMA. А свежий кадр из пула, куда диск прочитал данные сразу за местом для заголовков, уже можно отправлять как пакет (lib.rs):
OP_READ => {
// ...
/* Straight into the frame, behind the room for the headers */
(IO_READ, frame.data_phys() + REPLY_HDRS as u64, s.length >> sh.sector_shift)
}
OP_WRITE => {
// ...
/* Straight out of the frame, where the NIC put the data */
(IO_WRITE, frame.data_phys() + s.data_off as u64, s.length >> sh.sector_shift)
}
Датаграмма несёт столько целых секторов, сколько поместится за 58 байтами заголовков IP, UDP и netblk. При MTU 1500 это два сектора по 512 байт. Контрольную сумму UDP netblk оставляет нулевой, и в IPv4 это значит «не вычислена». Чтобы её посчитать, пришлось бы прочитать каждый байт, который только что записал диск.
Путь запроса
NIC --DMA--> кадр из пула
| softirq приёма: on_frame разбирает запрос, берёт слот
v
[кольцо запросов]
|
v
воркер на своём CPU: все запросы из очереди на диск, один doorbell
|
v
NVMe --DMA--> в кадр (чтение) / из кадра (запись)
| прерывание о завершении: on_disk_done
v
[кольцо завершений]
|
v
воркер: заголовки перед данными, ответы одной пачкой
| одна блокировка TX, один doorbell
v
NIC <--DMA-- кадр
Сетевая карта пишет датаграмму в кадр из пула, и этот кадр подхватывает softirq приёма. Softirq — это отложенная половина обработки прерывания; в nos для неё на каждом CPU есть своя задача. Эта задача вызывает обработчик netblk on_frame. Тот разбирает запрос, берёт один из 256 слотов экземпляра и кладёт слот в кольцо. Запись держит в слоте свой кадр, а чтение получит свежий кадр из пула, когда воркер передаст запрос диску.
Дальше запрос забирает воркер, задача, закреплённая за одним CPU. Он передаёт диску все запросы из очереди, но doorbell сразу не дёргает. Doorbell — это запись в регистр устройства, которой ему сообщают о новой работе. Такая запись идёт через шину, а под гипервизором обходится в выход из виртуальной машины, поэтому воркер дёргает doorbell один раз на всю пачку (Disk::kick).
Закончив команду, диск кладёт запись в свою очередь завершений (completion queue). Это кольцо заполняет контроллер, а драйвер подтверждает прочитанное отдельным doorbell. Раньше обработчик прерывания NVMe дёргал этот doorbell на каждое завершение. Теперь он забирает до 32 завершений и подтверждает их одной записью. Затем он вызывает колбэк netblk, а тот работает в контексте прерывания и потому почти ничего не делает:
extern "C" fn on_disk_done(ctx: *mut u8, status: i32) {
let slot = ctx as *mut Slot;
unsafe {
(*slot).status = if status == 0 { ST_OK } else { ST_IO };
/* Read before the push: the slot is the worker's the moment it is on
the ring */
let sh = &*(*slot).shared;
/* Cannot fail: the ring has room for every slot */
sh.done.push(slot as usize);
sh.event.signal();
}
}
Воркер дописывает перед данными заголовки каждого ответа и отдаёт ответы сетевой карте одной пачкой, под одной блокировкой и с одним doorbell. Части пути связывают три кольца без блокировок: свободных слотов, запросов и завершений. Каждое из них устроено как ограниченная очередь Вьюкова, где любая операция обходится одним compare-and-swap, а места хватает на все слоты. Сторона сетевой карты и сторона диска встречаются только в этих кольцах, так что никто на пути не ждёт блокировку, которую держит другая сторона.
Опрос без перехода в idle
Когда работы нет, воркер блокируется на Event. Подать сигнал может кто угодно, даже обработчик прерывания, и сигнал будит заблокированный воркер через IPI. Но пробуждение стоит прерывания, IPI и переключения контекста. Поэтому, пока диск должен воркеру завершения, тот 50 мкс опрашивает кольца и только потом засыпает. С параметром poll= он так же ждёт и новых запросов.
Сначала мы попробовали два других способа опроса, и оба теряли тики. Обычный yield отдавал CPU задаче idle, а она останавливает процессор до следующего прерывания на этом же CPU. Если прерывания диска и сетевой карты приходили на другие CPU, приходилось ждать тика, а это 10 мс. К тому же завершение, которое застаёт воркер готовым к работе, а не заблокированным, IPI не посылает. Когда клиент держал в полёте один запрос (окно 1), получалось 1700 IOPS при p99 9,4 мс.
Пустой цикл ожидания (spin) подвёл по-другому. Задачи softirq делят CPU с воркером. Когда тик вытеснял одну из них посреди пути отправки, воркер, крутившийся в цикле, не пускал её обратно до следующего тика, и ответы в очереди ждали вместе с ней. При окнах 32 и 64 это роняло netblk со 100 000 IOPS до 30 000. Поэтому в ядре появилась YieldToRunnable(): она отдаёт CPU любой другой готовой задаче, но никогда не задаче idle.
Что пришлось добавить в ядро
netblk — обычный загружаемый модуль, но интерфейсу модулей не хватало четырёх вещей:
- UDP-обработчик, которому отдают сам кадр. Раньше обработчик мог только посмотреть на байты датаграммы. Теперь
retain()превращает одолженный кадр вNetFrame, который модуль может оставить себе, отдать диску, отправить или освободить из любого контекста. - Приём virtio-net в кадры из пула. Приёмное кольцо драйвера состояло из шестнадцати статических буферов. Освобождённый кадр возвращал свой буфер в кольцо через очередь, которой касается только softirq приёма, поэтому кадр не мог покинуть путь приёма. Теперь в кольце до 128 кадров из пула, а отправок в полёте может быть 32 вместо 8.
- Пакетная отправка. Цепочка кадров уходит драйверу под одной блокировкой и за один сброс очереди.
- Асинхронный блочный путь.
kcore::block::Disk::submitпринимаетBlockIo(секторы, физический адрес и колбэк завершения) и никогда не блокируется. Если очередь полна, он возвращаетSubmitError::Busy. Раздел передаёт запрос своему диску со сдвигом. NVMe придерживает восемь идентификаторов команд для синхронного пути, чтобы сервер, забивший очередь, не перекрыл дорогу файловой системе на том же диске.
Сначала почти всё это было написано на C++, а через неделю сетевой и блочный слои переехали на Rust. 19 сентября netblk перешёл на типизированный обработчик и на форматы кадров из крейта netwire (b55366b), и мест с unsafe в нём стало 14 вместо 29. Старый и новый модули получили одни и те же восемь запросов, пока трафик писался в дамп, и их ответы совпали байт в байт.
Протокол, клиент и тест
Запросы приходят на UDP-порт экземпляра, все поля записаны в порядке big-endian. Запрос и ответ начинаются с одного и того же 30-байтового заголовка:
смещение размер поле
0 4 magic 0x4E424C4B, это NBLK
4 1 version 1
5 1 op 1 INFO, 2 READ, 3 WRITE, 4 FLUSH; в ответе взведён бит 0x80
6 2 flags бит 0: FUA, запись в обход кэша диска
8 8 cookie значение клиента, возвращается в каждом ответе
16 8 offset смещение на устройстве в байтах, целыми секторами
24 4 length длина в байтах, целыми секторами
28 2 status 0 OK, 1 плохой запрос, 2 вне диапазона,
3 ошибка ввода-вывода, 4 занято, 5 только чтение
INFO сообщает размер устройства и его ограничения. READ может запросить данные на 16 датаграмм, и ответ на него приходит датаграмма за датаграммой. WRITE несёт одну датаграмму данных, и ответ на него приходит, когда диск их принял. Все запросы идемпотентны, так что клиент, не дождавшись ответа, просто спрашивает ещё раз.
Клиентом служит scripts/netblk.py с командами info, read, write, flush, verify и bench. Его параметр --window задаёт, сколько запросов держать в полёте.
scripts/netblk-test.py запускает ядро в QEMU с NVMe-дисками, загружает модуль через UDP-шелл и проверяет его снаружи. Записанное должно читаться обратно, и его должна видеть собственная команда ядра diskread. Плохие запросы должны отклоняться, а после stop и rmmod не должно ничего оставаться. С 19 сентября тест отдаёт по сети ещё и раздел (977c2d5). Тест работает на x86-64 и arm64, а с --nic igb идёт через эмулируемую QEMU карту 82576, у которой тот же драйвер, что у I210 на AX41.
Цифры и два потерянных тика
Первые цифры сняли на x86 под QEMU/KVM, и они упирались в user-mode сеть QEMU и клиент на Python. Случайные чтения по 1 КиБ шли со скоростью 7400 IOPS при окне 1, 19 800 с poll=1000 и 99 200 при окне 64. Первый коммит записал и две загадки. На arm64 под TCG примерно одно пробуждение из ста занимало целый тик. На x86 p99 у клиента прыгал от прогона к прогону между 0,2 и 9 мс, а у сервера оставался ниже 400 мкс, так что этот хвост лежал где-то вне netblk.
В то же утро netblk запустили на AX41, сервере Hetzner с Intel I210 на гигабитном канале. Большинство запросов обслуживалось за 25 мкс, но каждый сотый занимал от 9 до 10 мс.
Дело было в планировщике. Тик и обработчик IPI заканчивались вызовом Schedule(), а он мог отдать CPU задаче idle, даже если прерванная задача ещё могла работать. Idle только останавливает процессор, а готовой задаче никто не шлёт IPI, так что воркер простаивал до следующего тика. Теперь эти обработчики заканчиваются вызовом Preempt(), и он оставляет CPU задаче, которая может работать дальше (a171a86). Заодно разгадалась и загадка arm64: при окне 1 скорость выросла с 675 до 3000 IOPS.
Остался хвост, которого сервер не видел. При окнах 16 и 64 p99 у клиента был 10 мс, хотя сервер отвечал на любой запрос быстрее чем за полмиллисекунды.
Запись трафика показала, что ответы замолкали на время от 2 до 10 мс примерно 80 раз в секунду. Почти каждая пауза кончалась на 10-миллисекундном тике одного и того же CPU, и пинги замирали вместе с ответами. Когда пауза кончалась, первым уходил ответ на пинг, посланный во время паузы, а ответы netblk шли позже на время одного обращения к диску. Значит, запросы всё это время лежали в приёмном кольце сетевой карты.
На этот раз планировщик терял перепланирование. Прерывание, которое поднимает softirq приёма, посылает своему CPU IPI, чтобы тот переключился на задачу softirq. Если IPI приходил, когда задача idle держала спинлок, перепланирование пропадало, и процессор останавливался, хотя задача softirq была готова работать. Теперь перепланирование откладывается до момента, когда вытеснение снова разрешено (73bad36, подробности в документации планировщика). Вот те же тесты на AX41, прогнанные с разницей в несколько часов; клиент стоял в том же дата-центре (43c53ab):
| AX41, I210, случайные запросы по 1 КиБ, по 5 с | до | после |
|---|---|---|
| чтение, окно 16 | 33 400 IOPS, p99 7,4 мс | 52 200 IOPS, p99 0,39 мс |
| чтение, окно 64 | 39 100 IOPS, p99 9,8 мс | 111 100 IOPS, p99 0,65 мс |
| запись, окно 64 | 25 400 IOPS, p99 9,9 мс | 110 500 IOPS, p99 0,95 мс |
| время обслуживания на сервере, 99,9-й перцентиль | 7,2 мс | 0,47 мс |
| пинги под нагрузкой, p99 | 8,0 мс | 0,58 мс |
Гигабитный канал пропускает около 110 000 таких ответов в секунду, и это потолок. Скорость чтения, похожая на предел клиента, и скорость записи, похожая на предел диска, на деле упирались в эти паузы. То же оказалось и с хвостом на x86 из первого дня: он и правда был вне netblk, в пути приёма ядра.
А что в Linux
Мы не сравнивали netblk с таргетом на Linux в одинаковых условиях, поэтому речь пойдёт только об устройстве (подробнее в netblk). Сервер NBD работает в пользовательском процессе и копирует данные в обе стороны. AoE (ATA over Ethernet) ближе по форме, но и его таргет живёт в пользовательском процессе. Таргет NVMe-over-TCP в ядре Linux копирует принятые данные из буферов сокета, а при работе через RDMA данные раскладывает сама сетевая карта.
Для приёма без копирования в общем случае нужна карта, которая отделяет заголовки от данных. netblk обходится без неё, потому что сам задаёт и раскладку кадра, и протокол.
Ограничения
Одна датаграмма несёт одну операцию ввода-вывода на 1 КиБ при MTU 1500, а диск с секторами по 4 КиБ netblk не берёт: сектор не поместится в одну датаграмму. Асинхронный путь пока есть только у NVMe. Аутентификации нет, а READ работает как отражатель: примерно 70 байт на входе и до 16 датаграмм по 1 КиБ на выходе, на тот адрес, который запрос выдаст за свой. В сети, которой вы не доверяете, отдавайте диск только для чтения и фильтруйте порт.