Кажется, совсем не так давно, а для кого-то, может быть, еще в прошлую пятницу, золотым стандартом технологического стека сетей хранения данных был 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-команду, а затем выполняет операцию и отправляет ответ в виде ответной капсулы:

Источник: «SNIA What NVMe™/TCP Means for Networked Storage»

Возможные варианты 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-соединение. 

Обмен в операции чтения данных

  1. Хост отправляет Command Capsule PDU, которая содержит NVMe-команду Read, LBA, длину и другие параметры.

  2. Контроллер отправляет данные через C2HData PDU одним или несколькими PDU. Последний PDU в серии содержит флаг LAST_PDU и опционально флаг SUCCESS.

  3. Контроллер подтверждает выполнение отправкой Response Capsule PDU, если в последнем С2HData не установлен флаг SUCCESS (подробнее ниже в правилах обмена).

Обмен в операции записи данных

Возможны два варианта, где, данные помещаются в капсулу и передаются вместе с командой (in-capsule) или данные передаются в отдельных H2CData PDU.

Первый вариант (in-capsule):

  1. Хост отправляет Command Capsule PDU, которая содержит NVMe-команду (write) и связанные с этой командой данные (payload).

  2. Контроллер обрабатывает команду и данные.

  3. Контроллер отправляет Response Capsule PDU с подтверждением (Completion Queue Entry).

Второй вариант (out-of-capsule):

  1. Хост отправляет команду Write (Command Capsule PDU).

  2. Контроллер запрашивает данные через R2T PDU.

  3. Хост отправляет данные тремя частями: H2CData PDU #1, #2, #3.

  4. Контроллер подтверждает запись отправкой 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 для операции чтения
Путь вызова функции nvmet для операции чтения
Путь вызова функции nvmet для операции записи
Путь вызова функции nvmet для операции записи

Точки трассировки 

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 с момента его формирования и до момента завершения операции в блочном стеке — посмотрите на цепочку вызовов на схемах выше. 

→ Пример скрипта с использованием BCC

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_tcp для операции чтения
Путь вызова функции nvme_tcp для операции чтения

Путь вызова функции nvme_tcp для операции записи
Путь вызова функции 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 и до выхода из него.

→ Пример скрипта с использованием BCC

Производительность

Высокая степень параллельности, заложенная в 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. Источник: Rasool Al-Saadi, Grenville Armitage, Jason But and Philip Branch: A Survey of Delay-Based and Hybrid TCP Congestion Control Algorithms
Принцип работы алгоритма планирования очередей fq_codel. Источник: Rasool Al-Saadi, Grenville Armitage, Jason But and Philip Branch: A Survey of Delay-Based and Hybrid TCP Congestion Control Algorithms
Сравнение задержек в протоколе адаптивного управления очередей (AQM) и стандартным FIFO. Источник: Rasool Al-Saadi, Grenville Armitage, Jason But and Philip Branch: A Survey of Delay-Based and Hybrid TCP Congestion Control Algorithms 
Сравнение задержек в протоколе адаптивного управления очередей (AQM) и стандартным FIFO. Источник: Rasool Al-Saadi, Grenville Armitage, Jason But and Philip Branch: A Survey of Delay-Based and Hybrid TCP Congestion Control Algorithms 

Также дисциплина очередей 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

Комментарии (1)


  1. adrozhzhov
    02.12.2025 16:01

    Хоть я и не настоящий сварщик, но рассказывал тут про подключение более чем одного диска по NVME over TCP и Over RDMA ранее.

    И знаю, что nvme list сделать информативной (серийник и версию) сделать самому можно, но на массивах большой компании из одной из соседних стран серийники, и версии FW одинаковые

    Node                  SN                   Model                                    Namespace 
    --------------------- -------------------- ---------------------------------------- --------- 
    /dev/nvme4n1          ___________________ Huawei_____                              1         
    /dev/nvme4n10         ___________________ Huawei_____                              10        
    /dev/nvme4n11         ___________________ Huawei_____                              11        
    /dev/nvme4n12         ___________________ Huawei_____                              12        
    /dev/nvme4n13         ___________________ Huawei_____                              13

    И, хотя номер луна можно найти и использовать для именования через udev, для быстрой идентификации луна уникальная часть в поле SN была бы очень удобна.

    Не думали такое реализовать для удобства использования, если это ещё не реализовано?