Saltar al contenido principal
Systems Programming

SPDK + NVMe: Construcción de un Storage Engine en User-Space con 10M IOPS

11 min lectura
LD
Lucio Durán
Engineering Manager & AI Solutions Architect
También disponible en: English, Italiano

Por Qué el Kernel Es el Problema

El driver NVMe del kernel de Linux es software de propósito general excelente. Maneja descubrimiento de dispositivos, gestión de namespaces, recuperación de errores, power management, y scheduling justo entre múltiples procesos. Pero toda esa generalidad cuesta ciclos.

Esto es lo que pasa en una sola lectura de 4KB a través del kernel:

  1. La aplicación llama a read() o io_uring_submit()
  2. Transición de system call (user → kernel context switch): ~1-2 microsegundos
  3. Lookup de capa VFS y chequeos de permisos
  4. Capa de bloques: merge, schedule, crear estructuras bio/request
  5. Driver NVMe: mapear a entrada de submission queue de NVMe, tocar el doorbell
  6. Esperar interrupción de completion
  7. Interrupt handler: procesar completion queue, despertar al que espera
  8. Context switch de vuelta a user space

Los pase es 2-4 y 6-8 son overhead puro. El submission real del comando NVMe es una sola escritura de 64 bytes a una submission queue. El completion es una lectura de 16 bytes de una completion queue. Todo lo demás es el kernel ganándose el sueldo por features que capaz no se necesita.

Medido con perf en un sistema tuneado, una sola lectura de 4KB a través del kernel toma 8-12 microsegundos end-to-end. El dispositivo NVMe en sí completa la operación en 6-8 microsegundos (Optane) o 80-100 microsegundos (flash NAND). Para Optane, el overhead del kernel duplica la latencia. Para flash, agrega 10-15%. Pero el verdadero asesino es el throughput: el manejo de interrupciones y los context switches no escalan linealmente con la queue depth.

La Arquitectura de SPDK: Cortando al Intermediario

SPDK (Storage Performance Development Kit) toma un enfoque radical: mover el driver NVMe entero a user space y eliminar las interrupciones por completo.

La arquitectura tiene tres pilares:

1. Drivers en user-space: SPDK desvincula dispositivos NVMe del driver del kernel y los vincula a uio_pci_generic o vfio-pci. Esto le da a la aplicación acceso directo a los registros PCIe BAR del dispositivo (los registros doorbell para las submission/completion queues) y la capacidad de configurar mapeos DMA sin intervención del kernel.

2. Polled I/O: En vez de esperar interrupciones de completion, SPDK dedica cores de CPU a hacer polling continuo de la completion queue. Esto intercambia ciclos de CPU por latencia — quemás un core al 100% de utilización, pero los completions se procesan en nanosegundos desde que llegan en vez de esperar por interrupt coalescing y scheduling del handler.

3. Diseño sin locks: Cada thread de polling es dueño exclusivo de sus canales de I/O. Sin locks, sin contención, sin cache line bouncing entre cores. La submission queue, completion queue, y todas las estructuras de datos asociadas son thread-local.

Acá va la estructura mínima de una aplicación NVMe con SPDK:

#include "spdk/stdinc.h"
#include "spdk/nvme.h"
#include "spdk/env.h"

struct worker_ctx {
 struct spdk_nvme_ctrlr *ctrlr;
 struct spdk_nvme_ns *ns;
 struct spdk_nvme_qpair *qpair;
 uint64_t io_completed;
 uint64_t io_submitted;
 char *buf;
};

static void
read_complete(void *arg, const struct spdk_nvme_cpl *completion)
{
 struct worker_ctx *ctx = arg;
 ctx->io_completed++;

 if (spdk_nvme_cpl_is_error(completion)) {
 fprintf(stderr, "Error de I/O: sct=%x, sc=%x\n",
 completion->status.sct, completion->status.sc);
 return;
 }

 /* Resubmitir inmediatamente para throughput sostenido */
 uint64_t lba = rand() % spdk_nvme_ns_get_num_sectors(ctx->ns);
 int rc = spdk_nvme_ns_cmd_read(ctx->ns, ctx->qpair,
 ctx->buf, lba, 1,
 read_complete, ctx, 0);
 if (rc == 0) {
 ctx->io_submitted++;
 }
}

static void
worker_poll(void *arg)
{
 struct worker_ctx *ctx = arg;

 /* Este es el hot loop — corre en un core dedicado */
 while (!g_shutdown) {
 /* Procesar completions — no-bloqueante, retorna inmediatamente */
 spdk_nvme_qpair_process_completions(ctx->qpair, 0);
 }
}

La función crítica es spdk_nvme_qpair_process_completions(). Lee directamente las entradas de completion queue de la región de memoria mapeada por DMA. Sin system call. Sin interrupción. Sin context switch. Solo una lectura de memoria, una comparación contra el phase tag, y la invocación de un callback.

Zero-Copy DMA: Donde Está la Magia Real

En el path del kernel, los datos siguen este viaje para una lectura:

SSD NVMe → DMA a buffer del kernel → copia a buffer de usuario → aplicación

Esa copia de kernel a user space es un memcpy del tamaño de tu I/O. A 4KB es barata. A 128KB (común para workloads secuenciales) está quemando ancho de banda de memoria. A IOPS altas, el ancho de banda agregado de copias se vuelve significativo.

SPDK elimina esto mapeando los buffers de la aplicación directamente para DMA:

/* Alocar buffer capaz de DMA — físicamente contiguo, respaldado por hugepages */
ctx->buf = spdk_dma_zmalloc(4096, 4096, NULL);
if (!ctx->buf) {
 fprintf(stderr, "Falló la alocación del buffer DMA\n");
 return -ENOMEM;
}

/* El dispositivo NVMe hace DMA directamente a este buffer.
 * Sin intervención del kernel. Sin copia. */
int rc = spdk_nvme_ns_cmd_read(ctx->ns, ctx->qpair,
 ctx->buf, /* target de DMA */
 lba, 1, /* LBA y conteo de sectores */
 read_complete, ctx, 0);

La función spdk_dma_zmalloc() aloca del pool de hugepages, asegura contigüidad física (crítico para scatter-gather de DMA), y registra la región de memoria con el IOMMU. Cuando el dispositivo NVMe completa la lectura, los datos aterrizan directamente en el buffer de tu aplicación. Cero copias. Cero intervención del kernel.

Para mi storage engine, pre-alocaba un pool de 16.384 buffers DMA al arranque:

#define BUFFER_POOL_SIZE 16384
#define BUFFER_SIZE 4096

struct buffer_pool {
 char *buffers[BUFFER_POOL_SIZE];
 uint32_t free_stack[BUFFER_POOL_SIZE];
 uint32_t top;
};

static int
init_buffer_pool(struct buffer_pool *pool)
{
 for (int i = 0; i < BUFFER_POOL_SIZE; i++) {
 pool->buffers[i] = spdk_dma_zmalloc(BUFFER_SIZE, BUFFER_SIZE, NULL);
 if (!pool->buffers[i]) {
 return -ENOMEM;
 }
 pool->free_stack[i] = i;
 }
 pool->top = BUFFER_POOL_SIZE;
 return 0;
}

static inline char *
pool_get(struct buffer_pool *pool)
{
 if (pool->top == 0) return NULL;
 return pool->buffers[pool->free_stack[--pool->top]];
}

static inline void
pool_put(struct buffer_pool *pool, char *buf)
{
 for (int i = 0; i < BUFFER_POOL_SIZE; i++) {
 if (pool->buffers[i] == buf) {
 pool->free_stack[pool->top++] = i;
 return;
 }
 }
}

El pool es por-thread (recordar, sin compartir entre threads de polling), así que pool_get y pool_put son operaciones de stack simples sin sincronización.

El Build de 10M IOPS

Acá la arquitectura que nos llevó a 10 millones de IOPS:

 ┌─────────────┐
 │ Thread de │
 │ Aplicación │
 │ (dispatch) │
 └──────┬──────┘
 │ Ring buffers SPDK
 ┌────────────┼────────────┐
 │ │ │
 ┌─────▼────┐ ┌────▼─────┐ ┌────▼─────┐
 │ Poller 0 │ │ Poller 1 │ │ Poller 2 │ ... (8 pollers)
 │ Core 2 │ │ Core 3 │ │ Core 4 │
 └─────┬────┘ └────┬─────┘ └────┬─────┘
 │ │ │
 ┌─────▼────┐ ┌────▼─────┐ ┌────▼─────┐
 │ NVMe 0 │ │ NVMe 1 │ │ NVMe 2 │ ... (8 SSDs)
 │ QP: 128 │ │ QP: 128 │ │ QP: 128 │
 └──────────┘ └──────────┘ └──────────┘

Cada thread de polling está pineado a un core de CPU dedicado y es dueño exclusivo de un dispositivo NVMe. La queue pair depth es 128 — suficiente para mantener el dispositivo saturado sin uso excesivo de memoria.

Los parámetros clave de tuning:

struct spdk_nvme_io_qpair_opts qpair_opts;
spdk_nvme_ctrlr_get_default_io_qpair_opts(ctrlr, &qpair_opts, sizeof(qpair_opts));

qpair_opts.io_queue_size = 128; /* Queue depth */
qpair_opts.io_queue_requests = 256; /* Pool de requests pre-alocados */
qpair_opts.delay_cmd_submit = true; /* Batchear escrituras al doorbell */

struct spdk_nvme_qpair *qpair =
 spdk_nvme_ctrlr_alloc_io_qpair(ctrlr, &qpair_opts, sizeof(qpair_opts));

El flag delay_cmd_submit es crucial. Sin él, cada llamada a spdk_nvme_ns_cmd_read() escribe al registro doorbell de la submission queue (una escritura PCIe MMIO). Las escrituras al doorbell son caras — cada una es una transacción PCIe posted que cuesta ~200-500ns. Con batching habilitado, SPDK acumula submissions y toca el doorbell una vez por ciclo de polling, amortizando el costo entre múltiples I/Os.

Resultados del Benchmark

Hardware: Dual Intel Xeon 8380 (80 cores total), 512GB DDR4-3200, 8x Intel P5800X 800GB Optane SSDs, PCIe Gen4 x4 por drive.

| Configuración | IOPS Lectura Aleatoria 4KB | Latencia Promedio | Latencia P99 | |--------------|---------------------------|-------------------|--------------| | Kernel io_uring (8 drives) | 2.1M | 48 us | 120 us | | Kernel io_uring + polling | 3.4M | 28 us | 65 us | | SPDK (8 pollers, 8 drives) | 10.8M | 7.2 us | 12 us | | SPDK (tuneado, batched) | 11.4M | 6.8 us | 10.5 us |

Los números de latencia cuentan la historia real. La latencia P99 bajó de 120 microsegundos (kernel) a 10.5 microsegundos (SPDK tuneado). Eso es una mejora de 11x en la cola. Para una base de datos de series temporales haciendo point queries, esa latencia de cola se traduce directamente en tiempo de respuesta de queries.

El costo en CPU es real sin embargo. Cada thread de polling consume el 100% de su core. Son 8 cores dedicados puramente al procesamiento de I/O. Con el driver del kernel, ese es cores estarían disponibles para lógica de aplicación (aunque gastarían tiempo significativo en interrupt handlers y context switches). El trade-off es explícito: se está comprando latencia y throughput con cores de CPU.

Desafíos Operacionales

Manejo de errores: El driver NVMe del kernel maneja errores transitorios, resets del controlador, y cambios de namespaces de forma elegante. En SPDK, manejás todo eso vos. Un reset del controlador significa drenar todos los I/Os in-flight, re-establecer la admin queue, re-crear los I/O queue pairs, y re-submitir operaciones pendientes. Escribir alrededor de 2.000 líneas de código de recuperación de errores.

static void
handle_controller_reset(struct worker_ctx *ctx)
{
 /* Drenar I/Os in-flight — no van a completar */
 spdk_nvme_qpair_process_completions(ctx->qpair, 0);

 /* Liberar el qpair viejo */
 spdk_nvme_ctrlr_free_io_qpair(ctx->qpair);

 /* Resetear el controlador */
 int rc = spdk_nvme_ctrlr_reset(ctx->ctrlr);
 if (rc) {
 fprintf(stderr, "Reset del controlador falló: %d\n", rc);
 /* A esta altura, el dispositivo se fue. Failover. */
 trigger_device_failover(ctx);
 return;
 }

 /* Re-crear el qpair */
 ctx->qpair = spdk_nvme_ctrlr_alloc_io_qpair(ctx->ctrlr, &qpair_opts,
 sizeof(qpair_opts));
 if (!ctx->qpair) {
 trigger_device_failover(ctx);
 return;
 }

 /* Re-submitir operaciones pendientes de la cola de reintentos */
 resubmit_pending_ios(ctx);
}

Hot-plug: Los sistemas de storage en producción necesitan manejar fallas y reemplazos de drives. SPDK tiene detección de hot-plug, pero integrarla con la lógica de data placement y replicación de tu aplicación queda completamente en vos. Pasé tres semanas solo en el manejo de hot-plug.

Observabilidad: Sin /proc/diskstats. Sin iostat. Sin blktrace. Armars tu propia recolección de métricas dentro de la aplicación SPDK. Yo exporto métricas de Prometheus desde cada thread de polling vía un handler HTTP liviano corriendo en un core separado.

Gestión de memoria: SPDK requiere hugepages. En producción, se necesita configurar hugepages al momento de boot vía parámetros del kernel, no en runtime. La alocación de hugepages en runtime es poco confiable en sistemas que estuvieron corriendo un rato por fragmentación de memoria:

# /etc/default/grub
GRUB_CMDLINE_LINUX="default_hugepagesz=2M hugepagesz=2M hugepages=4096 intel_iommu=on iommu=pt"

Eso son 8GB de hugepages reservados al boot. Para nuestro setup de 8 drives con queue depths profundas, en realidad necesitábamos 16GB.

Cuándo Usar SPDK (y Cuándo No)

Utilizar SPDK cuando:

  • Necesitás latencia de I/O de microsegundos de un dígito (Optane, persistent memory)
  • Necesitás saturar múltiples dispositivos NVMe (4+ drives)
  • Estás armando un storage engine, base de datos, o capa de caching
  • Puede dedicar cores de CPU al procesamiento de I/O
  • Teners la capacidad de ingeniería para manejar recuperación de errores y herramientas operacionales

No uses SPDK cuando:

  • Tu latencia de I/O está dominada por la latencia de flash NAND (100us+) y el overhead del kernel es ruido
  • Necesitás semántica de filesystem estándar (POSIX, árboles de directorios)
  • Teners uno o dos drives NVMe — el driver del kernel con io_uring probablemente alcanza
  • No se desea manejar hugepages, bindings de drivers, y monitoreo custom

El driver NVMe del kernel con io_uring y modo polling te lleva sorprendentemente lejos. En nuestro hardware, logró 3.4M IOPS — más que suficiente para la mayoría de los workloads. SPDK solo tiene sentido cuando se necesita hasta el último IOPS que el hardware puede entregar y se está dispuesto a pagar el impuesto de complejidad.

Para ese cliente de base de datos de series temporales, los 11.4M IOPS significaron que podían correr su pipeline de ingesta y motor de queries en el mismo hardware que antes necesitaba tres clusters separados. Los ahorros en hardware pagaron seis meses de mi tiempo de consultoría en el primer trimestre. Ese es el tipo de matemática que hace que la complejidad valga la pena.

spdknvmestorageperformancezero-copydmaio-performancesystems-programminglinux-kernel

Herramientas mencionadas en este artículo

AWSProbá AWS
DigitalOceanProbá DigitalOcean
Divulgación: Algunos enlaces en este artículo son enlaces de afiliado. Si te registrás a través de ellos, puedo recibir una comisión sin costo adicional para vos. Solo recomiendo herramientas que uso y en las que confío personalmente.
Compartir
Seguime