Когда кластер на InfiniBand или RoCE переносит десятки гигабит в секунду без участия центрального процессора, за кулисами работает маленькое 32-битное число с именем rkey. Пока обе стороны согласны с ним, сетевая карта пишет прямо в память собеседника, минуя ядро и лишние копирования буферов. Стоит ключу устареть после переподключения, и операции RDMA WRITE начинают возвращать статус IBV_WC_REM_ACCESS_ERR, а приложение застревает в повторных попытках. Ломается такое чаще всего не из-за железа, а из-за небрежного жизненного цикла регионов памяти. Ниже разобраны семантика ключей lkey и rkey, цена их выдачи и практические схемы, которые удерживают их в актуальном состоянии даже после сбоев сети.

Две роли ключей lkey и rkey в архитектуре RDMA

Вся работа начинается с функции ibv_reg_mr из библиотеки libibverbs. Она закрепляет страницы буфера в оперативной памяти, строит внутри сетевой карты таблицу трансляции адресов и возвращает структуру ibv_mr с двумя ключами. Ключ lkey прикладывается к каждой локальной записи в struct ibv_sge, то есть обслуживает операции send и recv. Ключ rkey вместе с 64-битным адресом remote_addr уезжает собеседнику и служит пропуском к операциям RDMA READ, RDMA WRITE и атомарным сравнениям. Оба ключа действуют только в пределах protection domain, той связки устройства и процесса, которая выдала регион.

Номер ключа не случаен и не секретен. Внутри 32 бит драйвер обычно упаковывает индекс записи и счетчик поколения, поэтому два соседних региона редко получают одинаковые значения, зато повторная регистрация после освобождения способна вернуть прежний номер. Из этого следует два практических вывода. Ключ не считают паролем: любой процесс того же protection domain, узнавший значение, получает доступ к буферу, а изоляция строится на границах домена и на правах доступа. И второй вывод: прежний номер после перерегистрации это источник тихих совпадений, о которых ниже. Потолок конкретной карты проверяют заранее по полям max_mr_size и max_mr в полном выводе ibv_devinfo с флагом -v.

struct ibv_mr *mr = ibv_reg_mr(pd, buf, POOL_BYTES,
    IBV_ACCESS_LOCAL_WRITE |
    IBV_ACCESS_REMOTE_WRITE |
    IBV_ACCESS_REMOTE_READ);
printf("lkey 0x%x, rkey 0x%x\n", mr->lkey, mr->rkey);

Права доступа задают флагами, и выдавать их стоит по минимуму. Узлу, который только читает данные для резервной копии, хватает одного флага IBV_ACCESS_REMOTE_READ без права записи. Принимающей реплики стороне нужен флаг IBV_ACCESS_REMOTE_WRITE, а локальная запись пригождается лишь там, где приложение само пишет в буфер. Сужение прав не только снижает риск, но и позволяет карте применять более дешевые пути трансляции.

Сколько стоит выдача ключа и почему ее откладывают

Каждый вызов ibv_reg_mr недешев. Система закрепляет страницы за процессом, заполняет записи трансляции на карте и сбрасывает ее кэши адресов. Для небольшого буфера счет идет на десятки микросекунд, для региона в несколько гигабайт он переходит в миллисекунды, потому что карточке приходится обслужить каждую страницу. Регион на 4 ГБ это 1 048 576 страниц по 4 КБ, а при использовании огромных страниц по 2 МБ той же емкости хватает всего 2048 записей трансляции. Отсюда правило систем с низкой латентностью: память регистрируют один раз при старте и дальше раздают потокам смещения внутри готового региона.

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

Прежде чем закреплять гигабайты, проверяют лимит заблокированной памяти, иначе регистрация падает с ошибкой ENOMEM на ровном месте. Текущее значение показывает ulimit -l, для интерактивных сессий его поднимают в файле /etc/security/limits.conf, а для служб задают в юните systemd параметром LimitMEMLOCK=infinity:

ulimit -l

Содержимое файла с лимитами при этом выглядит обычным образом:

* soft memlock unlimited
* hard memlock unlimited

Закрепленная память уходит из-под кэша страниц и недоступна остальным процессам, поэтому границы закрепления планируют так же тщательно, как размеры дисковых разделов.

Постоянный регион и пул буферов со смещениями

Самая распространенная схема управления ключами выглядит так. Приложение регистрирует один крупный регион, скажем на 4 ГБ, и нарезает его на слоты фиксированного размера, например по 1 МБ. Внутри одного региона ключ rkey один, поэтому при установке соединения он передается однократно, а в каждом рабочем сообщении уезжает лишь индекс слота или смещение. Четыре гигабайта вмещают 4096 слотов по 1 МБ, и двухбайтового индекса хватает с запасом. Вместо двенадцати байт на пару remote_addr и rkey в каждом сообщении достаточно двух, а на десяти миллионах операций экономия составит порядка 80 МБ бесполезной нагрузки на сеть.

Повторные регистрации малых буферов частично смягчает кэш регионов в библиотеке ib_uverbs, поэтому простой код с частыми вызовами ibv_reg_mr иногда кажется быстрым. Кэш не спасает при больших размерах и при высокой конкуренции потоков, так что полагаться на него в проектировании не стоит, лучше сразу закладывать постоянный регион.

Размер региона подбирают от обратного, от топологии соединений. Для 64 соединений по 64 слота на каждое выходит 4096 слотов, и при слоте в 1 МБ это те же 4 ГБ, удобное круглое значение для резервирования памяти. Узлу с 256 ГБ ОЗУ отдавать под RDMA вдвое больше, чем требует пиковая нагрузка, означает голодать по памяти ради запаса, который никогда не пригодится.

Передача rkey при установке соединения

Ключ бесполезен, пока вторая сторона его не получила, и здесь у протоколов два пути. Транспорт rdma_cm позволяет вложить данные в сообщения установления соединения: в REQ и REP помещается до 56 байт, чего хватает на адрес, ключ и номер версии протокола. Альтернативой служит первое контрольное сообщение по уже поднятому каналу, когда пара адрес и ключ уезжает обычным send с фиксированным заголовком.

Понять механику на живой системе помогает утилита ibv_rc_pingpong из пакета libibverbs-utils. Она сама регистрирует буфер, обменивается значениями через вспомогательное TCP-соединение на порту 18515 и печатает результат:

ibv_rc_pingpong -d mlx5_0 -g 3 192.168.10.20

Вывод на второй стороне выглядит подобным образом:

  local address:  LID 0x0000, QPN 0x00012c, PSN 0x8a2b4c, RKey 0x000f2a10 VAddr 0x00007f9c64001000
  remote address: LID 0x0000, QPN 0x00012d, PSN 0x1c48b9, RKey 0x000f2b11 VAddr 0x00007f9c64001000

Пара RKey и VAddr в строках это ровно те значения, которые рабочее приложение должно донести до собеседника, прежде чем постить первую запись RDMA WRITE. Утилита подтверждает простую мысль: обмен ключами это отдельный шаг протокола, и его стоит проектировать явно, а не надеяться на случайное совпадение значений.

Заголовок обмена делают явным и версионным: в первых байтах лежит 64-битный адрес, следом 32-битный rkey и 32-битный номер эпохи, всего 16 байт. Такой блок свободно помещается и в 56 байт частных данных rdma_cm, и в первое контрольное сообщение, а пересылать ключ в каждом рабочем сообщении не требуется: пара зафиксирована один раз, дальше стороны ходят индексами слотов.

Переподключение, устаревшие ключи и контроль актуальности

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

Порядок восстановления после разрыва выстраивают так:

  1. дождаться события в очереди пар и забрать все завершения через ibv_get_cq_event, включая ошибочные;
  2. создать новый QP и довести его командами ibv_modify_qp до состояния RTS;
  3. отправить собеседнику контрольное сообщение с парой адрес и rkey и номером эпохи, увеличенным на единицу;
  4. выполнить пробную RDMA READ по известному смещению и открыть рабочий поток данных лишь после ее успеха.

Пробное чтение стоит копейки и доказывает сразу две вещи: ключ действителен, а содержимое по нему то, которое ожидается. Приложение должно различать статусы завершений: IBV_WC_REM_ACCESS_ERR означает отказ доступа по ключу или правам, а IBV_WC_RETRY_EXC_ERR говорит, что собеседник перестал отвечать после исчерпания счетчика повторов. Разрыв проявляется и мягче: записи, стоявшие в очереди в момент аварии, возвращаются со статусом IBV_WC_WR_FLUSH_ERR, и путать их с отказом доступа нельзя. Слепой пересбор всех соединений здесь не нужен, достаточно разобрать очередь и восстановить конкретную пару.

Сценарий с разрывом репетируют на стенде заранее. Пару процессов поднимают поверх программного RoCE на одном узле, затем рвут линк командой ip link set eth0 down и возвращают командой ip link set eth0 up, после чего приложение обязано пройти полный цикл восстановления без ручного вмешательства и без потерянных сообщений.

Окна памяти и быстрая регистрация для меняющихся буферов

Когда буферы живут недолго, постоянный регион не выручает, и на сцену выходят окна памяти. Окно выделяют из региона вызовом ibv_alloc_mw, затем привязывают к поддиапазону адресов, и каждая привязка генерирует свежий rkey. Окна первого типа привязываются вызовом ibv_bind_mw, окна второго типа обслуживаются записью IBV_WR_BIND_MW прямо в цепочке отправок. Старый ключ отзывается записью инвалидации IBV_WR_LOCAL_INV, а удаленная инвалидация уезжает собеседнику через IBV_WR_SEND_WITH_INV.

В ядре ту же работу выполняет связка ib_map_mr_sg и записей быстрой регистрации, которую используют транспорты вроде NVMe over RDMA и NFS over RDMA. Полная регистрация малого буфера стоит десятки микросекунд, а связка инвалидации и повторной привязки укладывается в единицы микросекунд, поэтому для пулов на сотни буферов разница оборачивается заметной долей процессорного времени.

Математика здесь говорит сама за себя. При 100 000 операций в секунду полная регистрация по 30 микросекунд на буфер съедает три процессорных секунды каждую секунду, то есть три ядра уходят только на выдачу ключей, и до полезной работы очередь не доходит. Связка инвалидации и быстрой регистрации по 2 микросекунды занимает 0,2 секунды, пятую часть одного ядра, и ту же нагрузку тянет скромный сервер.

Диагностика и измерение на живой системе

Проверку удобно начинать с определения устройств и их состояния:

ibv_devices
ibv_devinfo -d mlx5_0

Для стенда без дорогого железа хватает программной реализации RoCE поверх обычной сети:

modprobe rdma_rxe
rdma link add rxe0 type rxe netdev eth0
ibv_devices

Пропускную способность и корректность пары адрес и ключ меряют утилитами пакета perftest, например так:

ib_write_bw -d mlx5_0 -g 3 -s 4096 192.168.10.20

Состояние ссылок RDMA проверяют командой rdma link show, она показывает и программные экземпляры:

link rxe0/1 state ACTIVE physical_state LINK_UP netdev eth0

Счетчики порта подсказывают, куда смотреть при отказах доступа: каталог /sys/class/infiniband/mlx5_0/ports/1/counters/ содержит файлы req_cqe_error и resp_cqe_error, и их рост синхронен с ошибками обработки записей. Статусы из очереди завершений в логах приложения дают точную картину быстрее любых счетчиков.

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