- Analiza el rendimiento de los pipes de Unix implementados en Linux mediante optimizaciones graduales
- El ancho de banda del programa inicial de pipe simple se mide en aproximadamente 3.5GiB/s, y se muestra el proceso para mejorarlo más de 20 veces mediante perfilado y cambios en las syscalls
- Explica varias técnicas de optimización, como usar syscalls Zero-Copy como
vmsplice y splice para reducir copias de datos innecesarias, además de aumentar el tamaño de página
- Al usar Huge Pages y aplicar una técnica de busy loop, resuelve cuellos de botella y registra una velocidad máxima de procesamiento de 62.5GiB/s
- Ofrece insights sobre elementos importantes en servidores de alto rendimiento y programación de kernel, como pipes, paging, costos de sincronización y Zero-Copy
Resumen e introducción
- Este artículo trata sobre cómo se implementan los pipes de Unix en Linux y el proceso de optimizar gradualmente el rendimiento escribiendo directamente programas de prueba que leen y escriben datos a través de un pipe
- Comienza con un programa simple con un ancho de banda de aproximadamente 3.5GiB/s y, tras varias optimizaciones, logra una mejora de rendimiento de unas 20 veces
- Cada etapa de optimización se decide con base en los resultados de perfilado usando la herramienta
perf, y el código fuente relacionado está publicado en GitHub - pipes-speed-test
- La inspiración surgió al ver la velocidad de procesamiento de datos con pipes en un programa FizzBuzz de alto rendimiento (36GiB/s)
- Basta con tener conocimientos básicos del lenguaje C para seguir el contenido sin dificultad
Medición del rendimiento del pipe: la primera versión lenta
- En el ejemplo de ejecución del programa FizzBuzz de alto rendimiento, se confirma que procesa 36GiB de datos por segundo a través de un pipe
- FizzBuzz produce salida en bloques del tamaño de la caché L2 (256KiB) para equilibrar el acceso a memoria y la sobrecarga de IO
- El programa de prueba de rendimiento de pipes escrito en este artículo también imprime repetidamente en bloques de 256KiB (read/write), e implementa directamente ambos extremos, lectura y escritura, para la medición
write.cpp escribe repetidamente el mismo búfer de 256KiB, mientras que read.cpp lee 10GiB, termina y muestra el throughput
- El resultado de la prueba muestra que la lectura/escritura a través del pipe alcanza 3.7GiB/s, unas 10 veces más lento que FizzBuzz
Cuellos de botella en la escritura y estructura interna
- Al rastrear el call graph al ejecutar el programa con la herramienta
perf, se observa que cerca de la mitad del tiempo total se consume en la etapa de escritura al pipe (pipe_write)
- Dentro de
pipe_write, la mayor parte del tiempo se gasta en copiar y asignar páginas de memoria (copy_page_from_iter, __alloc_pages)
- Los pipes de Linux están implementados como un ring buffer, y cada entrada referencia la página donde se almacena el dato real
- El tamaño total del búfer del pipe es fijo, y si el pipe se llena,
write se bloquea; si está vacío, read queda bloqueado
- En las estructuras de C (
pipe_inode_info, pipe_buffer), head y tail indican respectivamente las posiciones de escritura y lectura, e incluyen información de offset y longitud de cada página
Lógica de lectura/escritura del pipe
pipe_write funciona en el siguiente orden
- Si el pipe está lleno, espera hasta que haya espacio disponible
- Primero llena el espacio restante en el
head actual
- Si aún queda espacio, asigna una nueva página, copia los datos al búfer y actualiza
head
- Todas las operaciones están protegidas por locks, lo que genera sobrecarga de sincronización
- La lectura (
read) usa la misma estructura, moviendo tail y liberando las páginas ya leídas
- En esencia, se realizan dos copias: de memoria de usuario al kernel y del kernel de vuelta al espacio de usuario, lo que introduce una sobrecarga considerable
Zero-Copy: optimización con splice/vmsplice
- Una metodología general para IO rápido es evitar el kernel (bypass) o minimizar las copias
- Linux ofrece las syscalls
splice y vmsplice para omitir copias al mover datos entre pipes y espacio de usuario
splice: mueve datos entre un pipe y un file descriptor
vmsplice: mueve datos entre memoria de usuario y un pipe
- Ambas syscalls pueden funcionar moviendo solo referencias, sin mover realmente los datos
- Por ejemplo, al usar
vmsplice, se divide el búfer de 256KiB en dos y se hace vmsplice alternando cada mitad del pipe con doble búfer
- En la práctica, al aplicar
vmsplice la velocidad mejora más de 3 veces (hasta unos 12.7GiB/s), y al aplicar splice en el lado lector sube aún más hasta 32.8GiB/s
Cuellos de botella relacionados con páginas y uso de Huge Pages
- Según el análisis con
perf, el cuello de botella de vmsplice se concentra en el lock del pipe (mutex_lock) y la obtención de páginas (iov_iter_get_pages)
iov_iter_get_pages se encarga de convertir la memoria de usuario (virtual address) en páginas físicas (physical page) y guardar esas referencias dentro del pipe
- El sistema de paging de Linux no usa solo páginas de 4KiB; según la arquitectura también admite tamaños como 2MiB (huge page)
- Al usar Huge Pages (por ejemplo, de 2MiB), la sobrecarga de conversión de páginas se reduce notablemente gracias a menos gestión de page tables y menos referencias
- Al aplicar huge pages en el programa, el throughput máximo sube a 51.0GiB/s, un aumento adicional de alrededor del 50%
Aplicación de busy loop
- El cuello de botella restante está en la sincronización, como la espera (
wait) a que haya espacio para escribir en el pipe y el despertar (wake) del lector
- Al usar la opción
SPLICE_F_NONBLOCK y volver a invocar repetidamente ante EAGAIN mediante busy loop, se elimina la sobrecarga de scheduling del kernel
- Con esta técnica aplicada, el throughput máximo mejora otro 25% hasta 62.5GiB/s
- El busy loop consume 100% de los recursos de CPU, pero es un patrón común en servidores de alto rendimiento
Cierre y otros detalles
- Explica paso a paso cómo mejorar drásticamente el rendimiento de los pipes mediante
perf y análisis del código fuente de Linux
- Permite experimentar con temas clave del alto rendimiento, como pipes,
splice, paging, Zero-Copy y costos de sincronización, con ejemplos reales
- En el código real se aplican ajustes adicionales de rendimiento, como asignar los búferes en páginas distintas para reducir la contención del refcount
- La prueba se ejecuta fijando cada proceso del programa a un núcleo distinto con
taskset
- La familia
splice puede ser riesgosa por diseño y ha sido tema de debate durante años entre algunos desarrolladores del kernel
3 comentarios
¡Guau! ¡Qué divertido! (aunque no entiendo nada de lo que están hablando… )
|
Comentarios en Hacker News
Nunca olvidaré la experiencia de portar a Windows una aplicación basada en pipes de Linux; como seguía el estándar POSIX, pensé que el rendimiento no sería muy distinto, pero era increíblemente lenta. Recuerdo que, al esperar la conexión del pipe, todo Windows casi llegaba a quedarse congelado. Años después, cuando reimplementé lo mismo en C# sobre Win10, había mejorado un poco, pero la diferencia de rendimiento seguía siendo bastante vergonzosa.
Según entiendo, en los últimos años Windows añadió sockets AF_UNIX; me da curiosidad cuál rinde mejor frente a los pipes de Win32. Mi apuesta es que AF_UNIX debería rendir mejor.
Cuando dices que “el rendimiento era un desastre”, me da curiosidad si hablas del I/O después de que el pipe ya estaba conectado, o del proceso previo a la conexión. Si es después de conectar, sería sorprendente; pero si el problema era repetir conexiones y desconexiones, acepto que el SO quizá no lo optimizó, porque en realidad casi nunca hace falta. Depende bastante del caso de uso.
Lo que confirmé recientemente es que, en Windows, el rendimiento de TCP local es muy superior al de los pipes.
Vale recordar que POSIX define el comportamiento, no el rendimiento, y que cada plataforma y sistema operativo tiene sus propias particularidades de desempeño.
Hace mucho tuve la experiencia opuesta. No era con pipes, pero cuando una app en PHP sobre Linux se comunicaba con una API SOAP basada en .NET, recuerdo que la implementación de .NET respondía más rápido.
Como referencia, existen varios métodos como readv() / writev(), splice(), sendfile(), funopen(), io_buffer(), etc.
splice()es excelente para transferir grandes volúmenes de datos con zero-copy entre pipes y sockets UNIX, pero es exclusivo de Linux.splice()es la forma más rápida de hacerlo porque transfiere los datos directamente, sin asignaciones de memoria en espacio de usuario, sin gestión adicional de buffers, sinmemcpy()y sin recorreriovec. También piden confirmar si, en la familia BSD,readv()/writev()realmente es lo óptimo para pipes. En cualquier caso, opinan que este artículo es muy impresionante.sendfile()ofrece un rendimiento altísimo con zero-copy de archivo → socket, y está disponible tanto en Linux como en BSD, aunque solo soporta archivo → socket.sendmsg()no puede usarse con pipes normales; es para sockets de dominio UNIX, INET y otros. Como dato, en Linux he llegado a usarsendfileen la práctica también para transferencias de archivo → dispositivo de bloques, gracias a que internamente está implementado consplice.splice()es lo mejor en Linux para transferencias masivas ultrarrápidas entre pipes, pero si se usa bienio_uring, se puede esperar un rendimiento similar o incluso superior.En la práctica, la memoria compartida con paso de descriptores de archivo, como
shm_open, es más rápida y completamente portable.Comparten enlaces a discusiones anteriores en HN sobre este artículo: https://news.ycombinator.com/item?id=31592934 (200 comentarios), https://news.ycombinator.com/item?id=37782493 (105 comentarios).
Dicen que es un artículo realmente genial, y que da mucho gusto que vuelva a salir periódicamente.
Les da pena que todavía no hubiera ningún comentario, y dicen que les gustaría usar más
splice, pero les preocupan los temas de seguridad o compatibilidad ABI mencionados al final del texto. También plantean dudas sobre sispliceseguirá manteniéndose a futuro, y qué tan difícil sería parchear los pipes por defecto para que siempre usensplicecon el fin de mejorar el rendimiento.Preguntan si en Linux moderno existe algo parecido a Doors de SunOS, porque están buscando una tecnología mejor que AF_UNIX para una aplicación embebida que necesita intercambiar datos pequeños con latencia extremadamente sensible.
La memoria compartida es lo más rápido en términos de latencia, pero hace falta despertar tareas, normalmente usando
futex. Google estaba desarrollando la syscallFUTEX_SWAP, que permitiría hacer handoff directo de una tarea a otra, aunque no saben qué pasó después.Piden una explicación porque “Doors” es una palabra demasiado genérica y cuesta buscarla.
También piden más contexto sobre qué problema tiene AF_UNIX hoy: si le falta alguna capacidad necesaria, si la latencia es mayor de la deseada o si la estructura de API cliente/servidor de sockets no encaja con lo que necesitan.
Añaden de forma breve que el artículo fue escrito en 2022.