Приложения, построенные на RDMA, часто живут в ритме, при котором буферы под новые данные появляются десятки и сотни раз в секунду. Классический ответ, повторная регистрация memory region, съедает сотни микросекунд на каждой итерации и превращает предсказуемую задержку в лотерею. Объект с названием memory window решает задачу иначе: большая область памяти регистрируется один раз, а удаленному участнику выдаются короткоживущие ключи на отдельные ее участки. Привязка окна не трогает страницы памяти и не пересобирает таблицы трансляции, поэтому укладывается в единицы микросекунд. Ниже разобраны устройство окон, различия типов 1 и 2, рабочий код привязки через ibv_bind_mw, отзыв доступа и ошибки, на которых спотыкаются даже опытные разработчики.

Окно памяти поверх memory region и его роль в управлении доступом

Базовый объект защиты в RDMA это memory region, сокращенно MR. Вызов ibv_reg_mr фиксирует выбранные страницы в оперативной памяти, заносит их в таблицы трансляции сетевого контроллера и возвращает пару ключей: lkey для локальных операций и rkey для удаленного доступа. Картина выглядит стройной, пока права и границы доступа остаются постоянными. На деле удаленная сторона то должна видеть 64 килобайта под очередной запрос, то 2 мегабайта под контрольную сумму, и права на запись меняются от операции к операции. Регистрировать регион заново при каждой смене расклада значит оплачивать полную стоимость процедуры без всякой необходимости.

Memory window работает как съемный пропуск поверх уже зарегистрированного региона. Окно создается внутри protection domain вызовом ibv_alloc_mw и поначалу ни к какой памяти не привязано. После привязки к участку MR оно дает новый rkey, по которому удаленный участник обращается ровно к разрешенному диапазону и только с разрешенными правами. Прежний ключ перестает действовать, как только окно перепривязано или отзыв дошел до контроллера. Получается разделение труда: регистрация дорогая и редкая, привязка дешевая и массовая.

Дорогая цена повторной регистрации MR и источник выигрыша

Чтобы оценить выигрыш, полезно вспомнить, что именно делает ibv_reg_mr. Системный вызов блокирует страницы буфера, чтобы ядро не вытеснило их на диск, программирует MTT, memory translation table, контроллера и согласовывает результат с драйвером ядра. Пул в 16 ГиБ при страницах по 4 КиБ это 4 194 304 страницы, и каждая требует записи в таблицах трансляции. Регистрация региона на один гигабайт на типичном железе измеряется сотнями микросекунд, большой пул переводит счет в миллисекунды, и все это повторяется при каждой перерегистрации.

Число записей можно сократить огромными страницами: huge pages по 2 МиБ уменьшают счетчик в 512 раз, и пул из 16 ГиБ описывается уже 8192 страницами вместо 4 194 304. Но регистрация остается системным вызовом с блокировкой памяти и копированием структур, а привязка окна и тут дешевле на порядки, потому что таблицы она не трогает вовсе. По стоимости привязка сравнима с обычным work request: контроллер обновляет одну запись, страницы уже зафиксированы, таблицы уже построены, задержка укладывается в единицы микросекунд.

Численный пример показывает масштаб. Сервис обрабатывает 50 000 запросов в секунду, и под каждый нужен отдельный rkey. Перерегистрация по 300 микросекунд на запрос потребует 15 секунд процессорного времени на каждую секунду работы, что физически недостижимо. Привязка по 3 микросекунды на запрос дает 0,15 секунды совокупной нагрузки, причем без системного вызова на каждое обновление ключа.

Окна type 1 и type 2 и чем отличается их привязка

Спецификация описывает окна первого и второго типа, у второго есть подтипы 2A и 2B. Окно type 1 привязывается вызовом ibv_bind_mw, который ставит запрос привязки в очередь пары соединения. Транспортный сервис QP должен быть RC, UC или XRC_SEND, на UD привязка не выполняется. О результате приложение узнает по записи в очереди завершений, а новый rkey читается из поля rkey структуры окна.

Окно type 2 привязывается обычным work request с opcode IBV_WR_BIND_MW через ibv_post_send, то есть привязка стоит в одной очереди с отправками данных и исполняется в общем порядке конвейера. Это удобно потоковым системам, где окно привязывается и сразу используется следующими запросами без переключения контекста. Подтипы 2A и 2B различаются правилами адресации, а флаг IBV_ACCESS_ZERO_BASED подсказывает контроллеру, что адрес в поле remote_addr операции отсчитывается от начала окна, и абсолютный виртуальный адрес передавать не нужно.

Экономия заметна и в самом обмене ключами. Поле rkey занимает 4 байта, виртуальный адрес на 64-битной платформе еще 8, и вместе с заголовками уведомление о новой привязке помещается в одно крошечное сообщение. На этом строится протокол, в котором сервер отправляет клиенту только новый rkey и смещение, а клиент ведет все операции относительно одного и того же базового адреса. Без окон пришлось бы передавать виртуальный адрес каждого нового буфера и держать таблицы соответствий на обеих сторонах соединения.

Отзыв привязки тоже различается. Для type 1 хватает повторного вызова ibv_bind_mw с нулевой длиной, для type 2 применяются opcode IBV_WR_LOCAL_INV или IBV_WR_SEND_WITH_INV, второй заодно отзывает ключ прямо внутри потока отправок. Выбор простой: конвейер с плотным потоком отправок выигрывает от type 2, а код с отдельной логикой управления доступом естественнее смотрится с type 1.

Привязка участка буфера через ibv_bind_mw на живом примере

Минимальная последовательность для окна первого типа начинается с выделения окна в protection domain, там же, где живет MR:

struct ibv_mw *mw = ibv_alloc_mw(pd, IBV_MW_TYPE_1);
if (!mw) {
    fprintf(stderr, "ibv_alloc_mw failed\n");
    return -1;
}

Затем заполняется структура привязки: в ней указывается регион, адрес и длина участка внутри него и права доступа для удаленной стороны:

struct ibv_mw_bind bind;
struct ibv_mw_bind_info *info = &bind.bind_info;

memset(&bind, 0, sizeof(bind));
info->mr              = mr;
info->addr            = (uintptr_t)buf + 4096;
info->length          = 262144;
info->mw_access_flags = IBV_ACCESS_REMOTE_WRITE | IBV_ACCESS_REMOTE_READ;
bind.wr_id            = 1001;
bind.send_flags       = IBV_SEND_SIGNALED;

Вызов ibv_bind_mw(qp, mw, &bind) ставит запрос в очередь QP. Участок в 256 КиБ начинается со смещения 4096 байт от начала буфера, удаленной стороне разрешены запись и чтение. Флаг IBV_ACCESS_REMOTE_WRITE законно требует, чтобы сам MR был зарегистрирован с IBV_ACCESS_LOCAL_WRITE, то же ограничение действует для IBV_ACCESS_REMOTE_ATOMIC: атомарные операции поверх региона без локальной записи не включатся.

После успешного завершения привязки в поле rkey структуры окна появляется новый ключ, отличный от ключа предыдущей привязки. Ключ уходит удаленной стороне, и дальше она подставляет его в поле rkey структуры ibv_send_wr при построении операций RDMA READ и RDMA WRITE. Для атомарных операций дополнительно проверяется поддержка на уровне устройства, fetch and add и compare and swap доступны не каждому контроллеру.

Ротация ключей rkey и отзыв доступа без dereg_mr

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

Отзыв устроен экономно. Для окна type 1 хватает вызова ibv_bind_mw с length, равным нулю, и доступ по прежнему ключу прекращается. Для type 2 окно отзывается opcode IBV_WR_LOCAL_INV отдельным запросом или парой IBV_WR_SEND_WITH_INV, когда отзыв и сообщение едут одним work request. Полное освобождение объекта выполняет ibv_dealloc_mw, она заодно снимает действующую привязку.

Число окон ограничено атрибутом max_mw, поэтому приложение обычно держит готовый пул окон и переиспользует их перепривязкой: на новую транзакцию берется свободное окно, а не создается новое. Есть и ловушка при разборе пула. Вызов ibv_dereg_mr завершится ошибкой, пока к региону привязано хотя бы одно окно, поэтому порядок освобождения всегда одинаковый: сначала отвязать или уничтожить окна через ibv_dealloc_mw, потом dereg_mr, и только затем отдавать ядру сами страницы буферов. Нарушение порядка дает ошибки в логах и висящие объекты, которые продолжают держать зафиксированные страницы и расходовать лимиты устройства.

Типичное сообщение при нарушении этого порядка выглядит так:

ibv_dereg_mr: Device or resource busy

Ошибку возвращает и ibv_bind_mw, когда привязку невозможно поставить в очередь: errno EINVAL укажет на неверные флаги или права доступа, а errno ENOMEM на исчерпание внутренних ресурсов контроллера. Оба случая разбираются на месте, без пересоздания пары соединения.

Порядок операций в очереди QP и ошибки, на которых теряют ключи

Привязка асинхронна по своей природе. Возврат из ibv_bind_mw означает лишь, что запрос встал в очередь, а не что ключ уже действует. Пока не пришел успешный CQE, структуру окна менять нельзя, а старое значение rkey стоит сохранить на случай провала: если завершение пришло с ошибкой, приложение восстанавливает прежний ключ и прежнее состояние окна.

Порядок внутри одного QP спасает от лишних ожиданий. Отправка нового rkey обычным send-запросом по тому же QP безопасна сразу после постановки привязки, потому что порядок bind и send в одной очереди гарантируется спецификацией. А вот передача ключа через сторонний канал, сокет или разделяемую память, требует дождаться успешного завершения привязки, иначе удаленная сторона получит ключ от привязки, которой еще нет.

На практике спотыкаются о три вещи. Без флага IBV_SEND_SIGNALED привязка не порождает CQE, и приложение вечно ждет завершения, которое не наступит. Без флага IBV_SEND_FENCE перепривязка способна обогнать еще летящие операции чтения и атомарные операции, и приложение получает гонку между новой привязкой и старыми записями. И наконец, окно привязывают на QP в состоянии ошибки или с сервисом UD, и ibv_bind_mw возвращает значение errno вместо нового ключа. Контроль errno после каждого вызова и чтение очереди завершений через ibv_poll_cq закрывают все три сценария.

Проверка поддержки оборудования и границы разумного применения

Поддержку окон стоит проверять до проектирования архитектуры. Утилита ibv_devinfo с ключом -v печатает атрибуты контроллера, среди них строки max_mw, max_mw_bind и max_total_mw_qp: нулевые или отсутствующие значения говорят о том, что окна устройством не поддерживаются, и работать придется через перерегистрацию:

ibv_devinfo -d mlx5_0 -v | grep -i mw

Внутри программы те же атрибуты дает ibv_query_device: поля max_mw и max_mw_bind структуры ibv_device_attr удобно проверять на старте и автоматически переключать стратегию работы с памятью. Оценить фактическую цену привязки несложно: вокруг вызова ibv_bind_mw ставятся замеры clock_gettime с CLOCK_MONOTONIC, к ним добавляется время до появления CQE, и через минуту работы у разработчика на руках реальное распределение задержек вместо догадок.

Внедрение окон обычно укладывается в понятную последовательность действий:
1. регистрируется один большой MR под весь пул буферов, например 32 ГиБ, с правами IBV_ACCESS_LOCAL_WRITE и удаленным доступом;
2. под каждую транзакцию выделяется окно и привязывается к нужному участку с минимально необходимыми правами;
3. новый rkey уходит удаленной стороне send-запросом по тому же QP;
4. после подтверждения привязка отзывается нулевой длиной или IBV_WR_LOCAL_INV, а окно либо перепривязывается, либо освобождается через ibv_dealloc_mw.

Хранилища, NVMe-oF и iSER, кластерные СУБД и шины сообщений относятся к главным бенефициарам: там ключи и участки меняются с каждым запросом. Там, где буферы статичны и регистрируются один раз на весь срок службы приложения, окна не добавляют скорости, а только плодят объекты и ветки кода. Окна памяти не отменяют memory region, они снимают с него самую дорогую часть, повторную работу со страницами и таблицами трансляции, и превращают регистрацию из узкого места в разовую настройку, а управление доступом обретает точность до отдельного участка и отдельного ключа.