Кажется, совсем не так давно, а для кого-то, может быть, еще в прошлую пятницу, золотым стандартом технологического стека сетей хранения данных был Fibre Channel: SCSI, FCP, FC. Неудивительно: Fibre Channel разрабатывался целенаправленно для хранения данных. Но уже к середине 2000-х на сцене появляется новый стандарт — протокол iSCSI, реализованный поверх TCP/IP и Ethernet. Почему? Fibre Channel был всем хорош, кроме своей цены и зависимости от нескольких производителей сетевого оборудования.
C появлением новых высокопроизводительных компонент хранения данных и исчерпания масштабирования производительности традиционных интерфейсов, в том числе SAS, рождается стандарт NVMe — протокол, который работает поверх PCIe. NVMe, повторяя путь SCSI на заре эры SAN, реализуют в сетях хранения как набор стандартов NVME-oF (Over Fabric) для различных транспортных протоколов: FC, RDMA, TCP.
Начиная с версии 3.2 в TATLIN.UNIFIED реализована поддержка NVMe over TCP наравне с Fibre Channel и iSCSI.

Меня зовут Александр Пищулин, я инженер в отделе оптимизации быстродействия СХД в YADRO. В статье я постараюсь дать представление о реализации NVMe over TCP в системах, построенных на базе ядра Linux, c фокусом на производительность. Также поделюсь результатами тестирования протокола в TATLIN.UNIFIED GEN2.
Особенности технологии NVMe over TCP
В основе NVME-oF лежит капсула (capsule) — контейнер для передачи команд и данных NVMe, общая сущность для всего набора NVMе-oF. Капсулы позволяют отделить логику NVMe от транспортного уровня, чтобы масштабировать NVMe-oF на различные типы сетей.
В NVMe over TCP капсула передается в виде PDU (Protocol Data Unit), который инкапсулируется в пакеты TCP. Хост формирует PDU и заполняет пакеты TCP, исходя из установленного размера MTU.
Контроллер собирает PDU из полученных TCP-пакетов и извлекает NVMe-команду, а затем выполняет операцию и отправляет ответ в виде ответной капсулы:

Возможные варианты PDU:
ICReq |
0x00 |
Запрос на установку соединения (Initialize Connection Request) |
ICResp |
0x01 |
Ответ на ICReq (Initialize Connection Response) |
Cmd |
0x02 |
Команда NVMe (например, Read/Write) |
Rsp |
0x03 |
Ответ на команду (Completion Queue Entry) |
H2CData |
0x04 |
Данные от хоста к контроллеру (Host-to-Controller Data) |
C2HData |
0x05 |
Данные от контроллера к хосту (Controller-to-Host Data) |
R2T |
0x06 |
Ready-to-Transfer (уведомление о готовности принять данные) |
Keep-Alive |
0x07 |
Поддержание соединения (если не включен таймаут) |
Этапы установки соединения
Общая схема выглядит так:

Здесь и далее иллюстрации взяты из статьи «NVMe/TCP: What You Need to Know About the Specification», если не указано иное.
Теперь разберем конкретные этапы установки соединения:
1. Установка ТСP-соединения: по умолчанию на стандартный порт 4420 c согласованием базовых TCP-параметров (MSS, Window Size).
2. Установка NVMe/TCP-соединения. Хост отправляет запрос с параметрами ICReqPDU (Initialize Connection Request), а контроллер отвечает согласованными параметрами ICRespPDU (например, включает/отключает HDGST).
Параметры ICReqPDU и ICRespPDU:
ICReq/ICResp |
Хост/Контроллер |
Версия формата протокола (PFV) |
Версия протокола |
Согласование поддерживаемой версии протокола |
ICReq |
Хост |
Требуемое выравнивание данных хоста в PDU (HPDA) |
Выравнивание данных |
Требуемое хостом смещение данных относительно начала PDU |
ICResp |
Контроллер |
Требуемое выравнивание данных контроллера в PDU (CPDA) |
Требуемое контроллером смещение данных относительно начала PDU |
|
ICReq/ICResp |
Хост/Контроллер |
Обязательные/Поддерживаемые поля целостности заголовка и/или данных (DGST) |
Контроль целостности (Digest) |
Битовая маска, указывающая, требуется ли проверка целостности (CRC32C) для заголовка и/или данных |
ICReq |
Хост |
Максимальное количество незавершенных R2T PDUs (MAXR2T) |
Передача данных |
Максимальное число R2T-уведомлений, которые контроллер может отправить для одной команды |
ICResp |
Контроллер |
Максимальный размер данных в H2CData PDU (MAXH2CDATA) |
Максимальный объем данных, которые хост может отправить в одном H2CData PDU |
3. Установка NVMe-oF-соединения. Хост отправляет запрос на подключение к очереди управления (admin queue) конкретной подсистемы NVMe, происходит согласование размера и количества очередей, после чего подключается к каждой очереди данных, используя отдельное TCP-соединение.
Обмен в операции чтения данных

Хост отправляет Command Capsule PDU, которая содержит NVMe-команду Read, LBA, длину и другие параметры.
Контроллер отправляет данные через C2HData PDU одним или несколькими PDU. Последний PDU в серии содержит флаг LAST_PDU и опционально флаг SUCCESS.
Контроллер подтверждает выполнение отправкой Response Capsule PDU, если в последнем С2HData не установлен флаг SUCCESS (подробнее ниже в правилах обмена).
Обмен в операции записи данных
Возможны два варианта, где, данные помещаются в капсулу и передаются вместе с командой (in-capsule) или данные передаются в отдельных H2CData PDU.
Первый вариант (in-capsule):
Хост отправляет Command Capsule PDU, которая содержит NVMe-команду (write) и связанные с этой командой данные (payload).
Контроллер обрабатывает команду и данные.
Контроллер отправляет Response Capsule PDU с подтверждением (Completion Queue Entry).

Второй вариант (out-of-capsule):
Хост отправляет команду Write (Command Capsule PDU).
Контроллер запрашивает данные через R2T PDU.
Хост отправляет данные тремя частями: H2CData PDU #1, #2, #3.
Контроллер подтверждает запись отправкой Response Capsule PDU.

Правила обмена данными
Приведу требования, которые спецификация NVMe-oF TCP предъявляет к передаче данных и их порядку.
Передача данных в Command Capsule — по порядку. Данные внутри одного Command Capsule (например, для команды Write) должны передаваться строго по порядку. Ограничений на порядок между разными Command Capsules или очередями NVMe нет.
R2T PDUs (Ready-to-Transfer) — по порядку. Контроллер, запрашивающий данные через R2T, должен соблюдать последовательность запросов.
LAST_PDU — маркер конца передачи. Последний Data PDU (например, H2CData для операций записи или C2HData для операций чтения) должен содержать флаг LAST_PDU.
Если LAST_PDU не получен, соединение может быть разорвано по таймауту.
SUCCESS в C2HData. Если команда Read выполнена успешно, контроллер может (но не обязан) установить флаг SUCCESS в последнем C2HData PDU. В этом случае отдельная Response Capsule (Rsp PDU) не отправляется, что экономит трафик.
Параллельная обработка и очереди
Основная задача всего набора протоколов NVMe-oF — обеспечить достаточную пропускную способность для использования конечных устройств хранения, поэтому в стандарте реализована высокая параллельность.
Каждая отдельная пара очередей (прием и передача), количество которых определяется контроллер на этапе установки соединения, представляет собой отдельное TCP-соединение, назначенное отдельному процессору (что не строго обязательно). Плюс очередь управления (admin queue) для каждого NVMe-oF TCP-соединения.
Мультиплексирование двух или более очередей приема или передачи в одном соединении NVMe-oF TCP не поддерживается, как и распределение одной очереди передачи или очереди приема по двум или более соединениям.

Рекомендую два материала, которые помогут лучше разобраться в нюансах технологии NVMe oF TCP:
Реализация в Linux
Давайте рассмотрим путь данных (data path) драйвера хоста и драйвера контроллера в ядре Linux. Код ниже соответствует ядру 5.15, если не указано иное.
Контроллер
Точкой входа является функция обратного вызова (callback) nvmet_tcp_data_ready, зарегистрированная драйвером nvmet-tcp при создании сокета. Функция ставит задачу в очередь nvmet_tcp_wq на том же CPU, где обрабатывалось прерывание сетевого интерфейса:
static void nvmet_tcp_data_ready(struct sock *sk)
{
struct nvmet_tcp_queue *queue;
read_lock_bh(&sk->sk_callback_lock);
queue = sk->sk_user_data;
if (likely(queue))
queue_work_on(queue_cpu(queue), nvmet_tcp_wq, &queue->io_work);
read_unlock_bh(&sk->sk_callback_lock);
}
nvmet_tcp_wq — основная рабочая очередь, связанная с портом и сокетом. Ее задачи:
обработка входящих данных из TCP-сокетов (например, команд NVMe или данных для операций записи);
управление подключениями (принятие новых соединений через accept());
выполнение операций ввода-вывода (чтение/запись на диск);
обработка ошибок и восстановление (переподключение при сбоях).
Присоединяйтесь к команде разработки и поддержки TATLIN.UNIFIED в YADRO:
? Инженер технической поддержки
? Координатор L3
? Менеджер проектов
? Инженер технической поддержки L3, СХД R&D
С очередью nvmet_tcp_wq связана функция nvmet_tcp_io_work, которая реализует описанные выше задачи:
static void nvmet_tcp_io_work(struct work_struct *w)
{
struct nvmet_tcp_queue *queue =
container_of(w, struct nvmet_tcp_queue, io_work);
bool pending;
int ret, ops = 0;
do {
pending = false;
ret = nvmet_tcp_try_recv(queue, NVMET_TCP_RECV_BUDGET, &ops);
if (ret > 0)
pending = true;
else if (ret < 0)
return;
ret = nvmet_tcp_try_send(queue, NVMET_TCP_SEND_BUDGET, &ops);
if (ret > 0)
pending = true;
else if (ret < 0)
return;
} while (pending && ops < NVMET_TCP_IO_WORK_BUDGET);
/*
* Requeue the worker if idle deadline period is in progress or any
* ops activity was recorded during the do-while loop above.
*/
if (nvmet_tcp_check_queue_deadline(queue, ops) || pending)
queue_work_on(queue_cpu(queue), nvmet_tcp_wq, &queue->io_work);
}
Функция nvmet_tcp_io_work осуществляет в цикле чтение и отправку PDU. В каждой итерации — по 8 операций в рамках бюджета: NVMET_TCP_RECV_BUDGET = NVMET_TCP_SEND_BUDGET = 8.
Длительность цикла ограничена наличием незавершенных операций и их количеством (бюджетом): NVMET_TCP_IO_WORK_BUDGET = 64.
После завершения цикла задача повторно помещается в очередь, если остались незавершенные операции или истек таймер бездействия, который определяется параметром модуля idle_poll_period_usecs.


Точки трассировки
nvmet_req_init — точка трассировки в одноименной функции драйвера NVMe Target, которая инициализирует структуру запроса (nvmet_req) при приеме команды от хоста.
Описание полей структуры запроса nvmet_req_init:
cmd |
struct nvme_command * |
Указатель на NVMe-команду, содержащую все поля NVMe-запроса, в том числе opcode, cid, nsid, cdw10 и так далее. Это основная структура, определяющая команду (capsule), пришедшую от инициатора |
ctrl |
struct nvmet_ctrl * |
Указатель на контроллер — структура, описывающая логический контроллер NVMe на стороне Target. Через нее можно получить, например, информацию о сессии, ID контроллера и тому подобное |
disk[32] |
char[32] |
Строка — обычно это backstore (путь к экспортируемого устройства). Используется для более удобной идентификации |
qid |
int |
Queue ID — идентификатор очереди, с которой пришел запрос. 0 для Admin queue, положительное не нулевое значения для отдельной очереди данных. |
cid |
u16 |
Command Identifier — уникальный ID команды в пределах очереди. Присваивается инициатором |
opcode |
u8 |
Операция NVMe — код команды, например: 0x01 = WRITE, 0x02 = READ, 0x00 = FLUSH и так далее. |
fctype |
u8 |
Для Fabric-команд: указывает тип команды в NVMe over Fabrics (например, CONNECT, PROPERTY_GET и тому подобное). Не используется для обычных I/O-команд |
flags |
u8 |
Флаги команды, которые содержат тип доступа, приоритет и так далее в зависимости от спецификации NVMe |
nsid |
u32 |
Namespace ID — указывает, к какому пространству данных (namespace) относится команда |
metadata |
u64 |
Не используется |
cdw10[24] |
u8[24] |
Content of Command Dwords 10–15 — поля команды (CDW = Command DWord), содержащие параметры команды. Например, для READ/WRITE тут будет SLBA (начальный LBA), NLB (количество блоков) и другие параметры |
nvmet_req_complete — точка трассировки в одноименной функции. nvmet_req_complete вызывается в callback в момент завершения обработки операции ввода-вывода блочным уровнем.
Описание полей структуры запроса nvmet_req_complete:
ctrl |
struct nvmet_ctrl * |
Указатель на контроллер NVMe Target. Через него можно получить сессию, transport (TCP, RDMA), идентификаторы и так далее |
disk[32] |
char[32] |
Имя namespace или диска, на который выполнялась команда |
qid |
int |
Queue ID — очередь, по которой пришла команда |
cid |
int |
Command Identifier — ID команды от хоста. Может использоваться для корреляции с nvmet_req_init |
result |
u64 |
Значение поля result, которое возвращается хосту. Может содержать адрес, количество обработанных блоков и так далее |
status |
u16 |
Код статуса NVMe (состоит из SC — статус-код, SCT — тип статуса). Например, 0x0000 — успех, 0x4001 — ошибка LBA и тому подобное |
Разница во времени между событиями nvmet_req_init и nvmet_req_complete отразит время на обработку PDU с момента его формирования и до момента завершения операции в блочном стеке — посмотрите на цепочку вызовов на схемах выше.
nvmet_async_event — точка трассировки в функции отправки хосту асинхронных событий AEN.
Описание полей структуры запроса nvmet_async_event:
ctrl_id |
int |
8 |
ID контроллера NVMe Target (cntlid). Этот ID присваивается хосту при создании сессии и уникален внутри Target |
result |
u32 |
12 |
Код результата события — упрощенная упаковка значений event_type, event_info и log_page из NVMe-спецификации |
Формат поля result:
[31:24] |
event_type |
Тип события: 0x0 = error, 0x1 = SMART, 0x2 = notice и так далее |
[23:16] |
event_info |
Детали: перегрев, износ и так далее |
[15:8] |
log_page |
Связанная лог-страница: обычно 0x02 = SMART |
[7:0] |
0 (не используется) |
Хост
Точка входа в драйвер — функция nvme_tcp_queue_rq, вызываемая из блочного стека. nvme_tcp_queue_rq подготавливает command PDU. Далее либо отправляет PDU напрямую, вызывая nvme_tcp_send_all, если запрос является первым в списке на отправку (send_list) и обрабатывается на том же ядре, где и был инициирован запрос, либо помещается в очередь задач nvme_tcp_wq.
static inline void nvme_tcp_queue_request(struct nvme_tcp_request *req,
bool sync, bool last)
{
struct nvme_tcp_queue *queue = req->queue;
bool empty;
empty = llist_add(&req->lentry, &queue->req_list) &&
list_empty(&queue->send_list) && !queue->request;
/*
* if we're the first on the send_list and we can try to send
* directly, otherwise queue io_work. Also, only do that if we
* are on the same cpu, so we don't introduce contention.
*/
if (queue->io_cpu == raw_smp_processor_id() &&
sync && empty && mutex_trylock(&queue->send_mutex)) {
nvme_tcp_send_all(queue);
mutex_unlock(&queue->send_mutex);
}
if (last && nvme_tcp_queue_has_pending(queue))
queue_work_on(queue->io_cpu, nvme_tcp_wq, &queue->io_work);
}
Как и в реализации контроллера, nvme_tcp_wq — основная рабочая очередь, задачи которой:
выполнение операций ввода-вывода (чтение/запись на диск);
обработка ошибок и восстановление (переподключение при сбоях).
С очередью nvme_tcp_wq связана функция nvme_tcp_io_work, которая реализует задачи:
static void nvme_tcp_io_work(struct work_struct *w)
{
struct nvme_tcp_queue *queue =
container_of(w, struct nvme_tcp_queue, io_work);
unsigned long deadline = jiffies + msecs_to_jiffies(1);
do {
bool pending = false;
int result;
if (mutex_trylock(&queue->send_mutex)) {
result = nvme_tcp_try_send(queue);
mutex_unlock(&queue->send_mutex);
if (result > 0)
pending = true;
else if (unlikely(result < 0))
break;
}
result = nvme_tcp_try_recv(queue);
if (result > 0)
pending = true;
else if (unlikely(result < 0))
return;
if (!pending || !queue->rd_enabled)
return;
} while (!time_after(jiffies, deadline)); /* quota is exhausted */
queue_work_on(queue->io_cpu, nvme_tcp_wq, &queue->io_work);
}
Основу функции составляет цикл c последовательной отправкой и чтением PDU. Длительность этого цикла ограничена таймером на 1 миллисекунду, что жестко задано в коде: deadline = jiffies + msecs_to_jiffies(1). По истечении таймера задача повторно возвращается в очередь, что позволяет равномерно загрузить CPU nvme-очередями разных контролеров. Каждая отдельная задача (work) в очереди задач nvme_tcp_wq обслуживает отдельное соединение: очередь (пару sq/cq) при количестве очередей более одной.
Тип workqueue по умолчанию bound c высоким приоритетом, но может быть переопределен параметром модуля wq_unbound, который имеет смысл использовать только при небольшом числе процессоров/ядер.
Маппинг nvme_tcp_wq на доступные в системе процессорные ядра зависит от версии ядра Linux:
5.14.21 — по порядку, начиная с ядра 0 и независимо от контроллера;
6.13 — доступные ядра балансируются между контроллерами.
Маппинг в версии ядра 6.13:
/*
* Track the number of queues assigned to each cpu using a global per-cpu
* counter and select the least used cpu from the mq_map. Our goal is to spread
* different controllers I/O threads across different cpu cores.
*
* Note that the accounting is not 100% perfect, but we don't need to be, we're
* simply putting our best effort to select the best candidate cpu core that we
* find at any given point.
*/
static void nvme_tcp_set_queue_io_cpu(struct nvme_tcp_queue *queue)
{
struct nvme_tcp_ctrl *ctrl = queue->ctrl;
struct blk_mq_tag_set *set = &ctrl->tag_set;
int qid = nvme_tcp_queue_id(queue) - 1;
unsigned int *mq_map = NULL;
int cpu, min_queues = INT_MAX, io_cpu;
if (wq_unbound)
goto out;
if (nvme_tcp_default_queue(queue))
mq_map = set->map[HCTX_TYPE_DEFAULT].mq_map;
else if (nvme_tcp_read_queue(queue))
mq_map = set->map[HCTX_TYPE_READ].mq_map;
else if (nvme_tcp_poll_queue(queue))
mq_map = set->map[HCTX_TYPE_POLL].mq_map;
if (WARN_ON(!mq_map))
goto out;
/* Search for the least used cpu from the mq_map */
io_cpu = WORK_CPU_UNBOUND;
for_each_online_cpu(cpu) {
int num_queues = atomic_read(&nvme_tcp_cpu_queues[cpu]);
if (mq_map[cpu] != qid)
continue;
if (num_queues < min_queues) {
io_cpu = cpu;
min_queues = num_queues;
}
}
if (io_cpu != WORK_CPU_UNBOUND) {
queue->io_cpu = io_cpu;
atomic_inc(&nvme_tcp_cpu_queues[io_cpu]);
set_bit(NVME_TCP_Q_IO_CPU_SET, &queue->flags);
}
out:
dev_dbg(ctrl->ctrl.device, "queue %d: using cpu %d\n",
qid, queue->io_cpu);
}
Схематично путь вызова функций на пути данных в драйвере nvme_tcp представлен на рисунках ниже.


Точки трассировки
nvme_setup_cmd — точка трассировки, которая вызывается в момент завершения создания PDU, но до момента постановки в очередь отправки.
Описание полей структуры nvme_setup_cmd:
disk[32] |
char[32] |
Название namespace или «диска», к которому относится команда (например, nvme0n1) |
ctrl_id |
int |
Идентификатор контроллера на хосте (инициаторе): обычно nvme0, nvme1 и так далее |
qid |
int |
Queue ID — идентификатор очереди (Submission Queue), по которой идет команда |
opcode |
u8 |
Код NVMe-команды (например, 0x01 = WRITE, 0x02 = READ, 0x00 = FLUSH) |
flags |
u8 |
Флаги команды: приоритет, защита и другие |
fctype |
u8 |
Fabric Command Type — только для NVMe-oF, указывает тип команды: Connect, Property Set/Get и так далее (0x01, 0x02...) |
cid |
u16 |
Command ID — ID команды внутри очереди, присваивается драйвером |
nsid |
u32 |
Namespace ID — номер namespace, к которому относится операция |
metadata |
bool |
Есть ли метаданные (например, защита данных, CRC, проверка и т.д.) |
cdw10[24] |
u8[24] |
Command Dwords 10–15 — содержат параметры команды, например: SLBA, NLB и т.д. |
nvme_complete_rq — точка трассировки в одноименной функции, которая вызывается в момент завершения запроса в драйвере nvme. Она инициирует завершение запроса в блочном уровне.
Описание полей структуры nvme_complete_rq:
disk[32] |
char[32] |
Имя namespace’а (диска), например, nvme0n1. Показывает, к какому устройству относится завершенная команда |
ctrl_id |
int |
ID контроллера (nvme0, nvme1, и т.д.) — помогает понять, через какой NVMe контроллер шла команда |
qid |
int |
Queue ID — номер очереди NVMe (обычно 0 — админка, >0 — I/O) |
cid |
int |
Command ID — уникальный ID команды внутри конкретной очереди. Его можно сопоставить с nvme_setup_cmd |
result |
u64 |
Поле DW0 в Completion Queue Entry. Содержит возвращаемое значение (например, адрес, число блоков, длину). Зависит от команды |
retries |
u8 |
Сколько раз команда была повторно отправлена драйвером (в случае ошибок или таймаутов) |
flags |
u8 |
Внутренние флаги NVMe-драйвера, связанные с поведением запроса (например, async, fused и т.д.) |
status |
u16 |
Статус выполнения NVMe-команды. Состоит из SCT (тип статуса) и SC (код статуса). Формат аналогичен Completion Queue Entry |
nvme_setup_cmd и nvme_complete_rq соответствует полному времени выполнения операции, включая передачу по сети: от входа в драйвер nvme и до выхода из него.
Производительность
Высокая степень параллельности, заложенная в NVMe и соответственно в NVMe-oF TCP, предъявляет высокие требования к сети и сетевому стеку. Я считаю, что оптимальная конфигурация сетевого стека — это 90% успеха.
Изначально TCP создавался не для сетей хранения, поэтому и не гарантирует низкую задержку. Более того, механизм контроля перегрузок (Congestion Control) использует задержку в подтверждении пакетов, а ее триггером служат таймаут или потеря пакетов.
Разберемся, как работает этот механизм. В нормальном состоянии TCP-сессия стремится использовать всю доступную полосу пропускания, увеличивая значение cwnd (congestion window). При выходе на насыщение, то есть при потере пакетов или превышении таймаута RTT (round-trip time), значение cwnd декрементируется вплоть до 1. А теперь представьте: у нас есть Q+1 параллельных активных сессий только с одного хоста к одной подсистеме. Все эти сессии начинают конкурировать за одну и ту же полосу пропускания.
Но есть и хорошая новость: в Linux эту проблему в значительной мере решает дисциплина очередей (qdisc) fq_codel (Fair Queuing Controlled Delay). fq_codel реализует алгоритм адаптивного управления очередями со справедливым планированием пакетов. Его задача не только не допустить переполнения буфера, но и удерживать низкую задержку в очереди при сохранении высокой пропускной способности.
Посмотрим, как работает дисциплина очередей. В течение заданного временного интервала (по умолчанию — 100 мс) алгоритм отслеживает задержку в очереди для каждого потока на всем пути передачи (hop). Когда пакет извлекается из очереди для отправки, вычисляется, сколько времени он провел в ожидании — это и есть задержка очереди. Для интервала сохраняется минимальное значение этой задержки.
Когда из очереди извлекается последний пакет интервала, алгоритм сравнивает минимальную задержку с порогом в 5 мс. Если задержка превышает этот порог, один пакет отбрасывается, а длительность следующего интервала уменьшается. Если же она меньше 5 мс, пакет передается дальше, а интервал снова устанавливается на 100 мс.


Также дисциплина очередей fq_codel поддерживает ECN (Explicit Congestion Notification) — механизм, который позволяет явно сигнализировать о перегрузке без потери пакетов. Но это уже крайний случай.
Подробнее про алгоритмы управления перегрузкой TCP можно прочитать в статье «A Survey of Delay-Based and Hybrid TCP Congestion Control Algorithms».
Настройка Linux для NVMe-oF TCP
Что нужно настроить в ядре:
Убедиться в наличии модуля ядра sch_fq_codel и установить его по умолчанию: net.core.default_qdisc=fq_codel. Замечу, что в некоторых дистрибутивах, таких как Ubuntu или Fedora, fq_codel уже установлен по умолчанию.
Убедиться, что в ядре включена поддержка ECN: net.ipv4.tcp_ecn=1.
Важный фактор пропускной способности — размер MTU (Maximum Transmission Unit). Желательно, чтобы и коммутаторы, и порты хостов, и порты СХД могли работать с MTU 9000.
Остальные 10% успеха сводятся к настройке отдельных хостов:
Режим энергопотребления: performance
-
Время поллинга:
net.core.busy_poll=10000 — максимальное время (в микросекундах), в течение которого процесс будет активно опрашивать NAPI на наличие пакетов.
net.core.busy_read=10000 — аналогичный параметр, применяемый при выполнении системных вызовов чтения из сокета.
Политики балансировки: использовать nvme native multipath и политику round-robin или queue-depth (если она доступна в вашей версии ядра). Политика задается параметром модуля iopolicy: /sys/module/nvme_core/parameters/iopolicy
Замечу, что с поллингом нужно быть аккуратнее, поскольку это отражается на всей системе в виде дополнительной загрузки процессора. Для бенчмарков это хорошая идея, чтобы получить максимум от одного сервера, но для продуктовых сценариев поллинг может оказаться лишним: в промышленной нет задачи выбрать всю пропускную способность одним сервером.
Укажу и на характерные недостатки. Поскольку в NVMe-oF TCP один PDU может быть фрагментирован в несколько пакетов и важен порядок доставки (или PDU может быть зашифрован), это приводит к необходимости копировать данные между сокетом и драйвером, чтобы собрать и извлечь команду или данные. У этого есть своя стоимость: дополнительная нагрузка на процессор и циклы, затраченные на копирование данных.
Пример flame-графика, полученного на хосте, для операции чтения:

Отсутствие возможности контролировать рассадку потоков (kworker), связанных с nvme_tcp_wq (хост) — еще один недостаток.
В ядре 5.14 рассадка выполняется последовательно, начиная с ядра 0, по количеству очередей независимо от контроллера. В этом случае политика балансировки round-robin может не дать результата.
В ядре 6.13 драйвер на этапе установки соединения пытается развести потоки обработки nvme_tcp_wq, связанные с разными контроллерам (адресами), по разным ядрам, но вручную этот процесс контролировать не получится.
Оценка производительности NVMe over TCP с TATLIN.UNIFIED GEN2
Мы провели нагрузочный тест случайным чтением и записью блоками размером 8KiB в соотношениях:
100% (R100/0) чтение,
70% чтение и 30% запись (R70/30),
100% запись (R0/100).
У ресурсов и пула были наиболее производительная конфигурация и состояние.
Схема тестового стенда
TATLIN.UNIFIED Gen2
IMAGE: 3.2.0.
POOL: 8+2 24 SSD DIRECT/INDIRECT (write ahead).
RESOURCES: 10x512G (access group 0) + 10x512G (access group 1).
ETH: 4x25GiB + 4x25GiB (2 controllers).
HOSTs
host-1
ETH: 2x25GiB
Tuning:
net.core.default_qdisc=fq_codel,
net.core.busy_poll=10000,
net.core.busy_read=10000,
nvme access group 0.
nvme-subsys1 - NQN=nqn.2017-01.com.yadro:tatlin.unified:01092300141009:AG0
hostnqn=nqn.2014-08.org.nvmexpress:uuid:f05653b5-10cc-4716-bde9-a1aa40a4c303
iopolicy=round-robin
\
+- nvme1 tcp traddr=10.75.67.1,trsvcid=4420 live
+- nvme2 tcp traddr=10.75.67.2,trsvcid=4420 live
+- nvme3 tcp traddr=10.75.67.9,trsvcid=4420 live
+- nvme4 tcp traddr=10.75.67.10,trsvcid=4420 live
host-2
ETH: 2x25GiB
Tuning:
net.core.default_qdisc=fq_codel,
net.core.busy_poll=10000,
net.core.busy_read=10000,
nvme access group 1.
nvme-subsys1 - NQN=nqn.2017-01.com.yadro:tatlin.unified:01092300141009:AG1
hostnqn=nqn.2014-08.org.nvmexpress:uuid:f05653b5-10cc-4716-bde9-a1aa40a4c304
iopolicy=round-robin
\
+- nvme1 tcp traddr=10.75.67.17,trsvcid=4420 live
+- nvme2 tcp traddr=10.75.67.25,trsvcid=4420 live
+- nvme3 tcp traddr=10.75.67.26,trsvcid=4420 live
+- nvme4 tcp traddr=10.75.67.18,trsvcid=4420 live
Результаты
Rand R100/W0 8K пул с прямой адресацией:

Rand R70/W30 8K пул с прямой адресацией:

Rand R0/W100 8K пул с косвенной адресацией:

Заключение
В статье мы познакомились с тем, как работает и как реализован NVMе over TCP под капотом ядра Linux, а также показали его производительность совместно с TATLIN UNIFIED Gen2.
Технология не лишена некоторых недостатков, но продолжает развиваться. К примеру, для избыточного копирования данных предлагается решение «nvme-tcp receive offload».
Мы видим в NVMе over TCP сильного конкурента и альтернативу iSCSI для построения сетей хранения поверх IP-стека. Основное преимущество этой технологии — в сочетании высокой пропускной способности и низкой задержки с доступностью и универсальностью решений на базе Ethernet.
Другие статьи про TATLIN.UNIFIED, которые могут вас заинтересовать:
? Снапшоты, клоны и не только: как устроен и что умеет маппер в СХД TATLIN
? Что не убивает, делает сильней: как мы тестируем СХД, «ломая» его по частям
? Как устроен T-RAID — RAID-массив в СХД TATLIN
? Один на 150 миллионов операций. Расследуем причины выброса времени отклика в операциях ввода-вывода? Круговорот обновлений СХД TATLIN.UNIFIED
adrozhzhov
Хоть я и не настоящий сварщик, но рассказывал тут про подключение более чем одного диска по NVME over TCP и Over RDMA ранее.
И знаю, что nvme list сделать информативной (серийник и версию) сделать самому можно, но на массивах большой компании из одной из соседних стран серийники, и версии FW одинаковые
И, хотя номер луна можно найти и использовать для именования через udev, для быстрой идентификации луна уникальная часть в поле SN была бы очень удобна.
Не думали такое реализовать для удобства использования, если это ещё не реализовано?