Bajo rendimiento de las pipes en Linux
(qsantos.fr)- Al escribir datos en una pipe de Linux con
write, resultó mucho más lento que una simple escritura en memoria: en el entorno de prueba se quedó alrededor de 17GB/s para escritura en pipe, frente a 167GB/s para escritura en búfer de memoria - El cuello de botella no se explica solo por una copia de datos; dentro de
pipe_writese acumulan los costos de asignación de páginas, bloqueos y rutinas de copia del kernel vmspliceconecta el búfer de espacio de usuario a la pipe sin copiarlo al kernel, evitando rutas costosas como__alloc_pages,_raw_spin_lock_irqycopy_user_enhanced_fast_string- En el caso de rendimiento de Fizz Buzz, la solución con
vmsplicellegó a 60.8GiB/s en un solo núcleo y 208.3GiB/s en múltiples núcleos; un ejemplo devmsplicede otro experimento registró 210GB/s - Por las correcciones, la interpretación de la penalización por no usar SIMD es difícil de sostener, y como la comunicación entre procesos no termina solo en la caché L1, es difícil esperar 167GB/s como rendimiento real de una pipe
Punto de partida: la gran brecha que crea vmsplice
- Algunos programas usan la llamada al sistema
vmsplicepara mover datos más rápido a través de pipes - En la competencia de rendimiento de Fizz Buzz en Code Golf StackExchange, las soluciones se dividieron en dos grandes grupos
- Las soluciones que no usan
vmsplicealcanzan unos pocos GiB por segundo, y la solución de neil llegó a 8.4GiB/s - Las soluciones que usan
vmsplicesuben hasta 15.5GiB/s de tkluck, 60.8GiB/s de ais523 y 208.3GiB/s multinúcleo de david
- Las soluciones que no usan
- Es difícil explicar una diferencia de unas 7 veces en un solo núcleo solo por el efecto de reducir copias entre el espacio del kernel y el espacio de usuario
- En pruebas propias, la solución de ais523 registró 96.4GiB/s, y la de david 277GB/s usando 7 núcleos, unos 40GB/s por núcleo
Línea base: escritura en memoria en espacio de usuario
- Un programa en Rust que copia repetidamente un búfer de 32KiB en memoria de espacio de usuario, sin llamadas al sistema, registró 167GB/s en el entorno de prueba
- Esta cifra se considera equivalente a la velocidad de escritura en caché L1 del CPU usado
- El sistema de prueba era un Ryzen 9 7950X3D, DDR5 6000T/s, Debian 12 y Linux 6.1.0-18-amd64
- Las mitigaciones del CPU estaban desactivadas con
mitigations=off
- El perfilado con
ftracemostró que la mayor parte del tiempo se gastó en__memset_avx512_unaligned_erms - Sin embargo, una corrección limita esta interpretación
- La instrucción en el punto de interrupción era
rep stos, que no es una instrucción AVX-512 - La razón por la que el rendimiento se mantuvo en 167GB/s incluso al restringir el uso a AVX2 y SSE2 fue que en todos los casos se usó
rep stos
- La instrucción en el punto de interrupción era
El costo real al escribir en una pipe con write
- Al escribir un búfer del mismo tamaño en una pipe con
stdout.write()y consumirlo conpv >/dev/null, el rendimiento cae a 17GB/s - El perfilado mostró que la mayor parte del tiempo se consumía dentro de la llamada al sistema
write, y en particular un 95% permanecía dentro depipe_write - Dentro de
pipe_write, la preparación de páginas, los bloqueos y la copia generan costos en conjunto__alloc_pages: 36% del tiempo total, prepara nuevas páginas de memoria para la pipe__mutex_lock.constprop.0: 25% del tiempo total, corresponde al costo de bloqueo para escribir en la pipe_raw_spin_lock_irq: 5% del tiempo total, aparece como costo de bloqueo relacionado con la escritura en la pipecopy_user_enhanced_fast_string: alrededor del 20% del tiempo total, copia datos desde el espacio de usuario hacia el kernel
- Como
pvconsume moviendo páginas a/dev/nullconsplice, es difícil reutilizar continuamente las mismas pocas páginas en un bucle
Rutina de copia del kernel e interpretación corregida
- Al desensamblar
copy_user_enhanced_fast_string, las copias de búferes grandes se procesan con la instrucciónREP MOV - Esta función no está implementada en C, sino en código ensamblador del kernel de Linux, por lo que es una ruta intencional y no una falta de optimización del compilador
- En el experimento original se obtuvo 80GB/s invocando
rep movsbdirectamente desde espacio de usuario, y eso se vinculó con la interpretación de que la rutina de copia del kernel era aproximadamente 2 veces más lenta - Una corrección posterior cambió las condiciones del experimento
- Dos búferes de 32KiB saturan la caché de datos L1
- Al usar búferes de 16KiB, el rendimiento sube a 153GB/s
- Por lo tanto, la interpretación original de que no usar instrucciones vectoriales en la copia del kernel supone una gran penalización es difícil de sostener
- Aun así, se mantiene el punto de que la sobrecarga de gestión de memoria en la escritura a pipes es grande
Las rutas del kernel que evita vmsplice
vmsplicereduce el costo de usar pipes al entregar el búfer completo del espacio de usuario a la pipe sin copiarlo al kernel- El ejemplo
./writeincluido enpipes-speed-testde Francesco se usa como ejemplo mínimo que escribe infinitamente'X' - Este ejemplo registró 210GB/s, pero como pasa repetidamente el mismo búfer a
vmsplice, no es igual a una tarea común de generación de datos- Si no es un flujo de bytes constante, hay que llenar el búfer con datos nuevos
- En ese caso, vuelve a ser relevante el límite superior de 167GB/s de una simple escritura en memoria
- Incluso en la ruta de
vmsplice,__mutex_lock.constprop.0consume un 37% del tiempo - Pero no aparecen
__alloc_pages,_raw_spin_lock_irqnicopy_user_enhanced_fast_string, que sí se veían en la ruta dewrite - En su lugar,
add_to_pipe,import_ioveceiov_iter_get_pages2aparecen como rutas principales, lo que muestra quevmspliceevita las partes costosas dewrite
Conclusiones restantes y puntos a tener en cuenta
- En las pruebas, la ruta para escribir en una pipe de Linux con
writees aproximadamente 10 veces más lenta que una simple escritura en memoria - La conclusión original era que, en la escritura a pipes, los costos de bloqueo y de guardar/restaurar el contexto SIMD eran grandes, y que
spliceyvmsplicelos evitaban - Tras la corrección, la conclusión debe verse de forma más limitada
- La sobrecarga de gestión de memoria del kernel sigue siendo un factor importante en la degradación del rendimiento de las pipes
- La interpretación de que no usar instrucciones vectoriales implica una penalización tan grande como se esperaba es incorrecta
- Como la comunicación entre procesos no puede realizarse solo con la caché L1, es difícil alcanzar 167GB/s como rendimiento incluyendo también la lectura real de la pipe
- Se corrigieron algunos errores importantes y la confiabilidad de los resultados puede ser limitada, por lo que las cifras deben interpretarse como una indicación de tendencia
- Para aumentar el rendimiento de las pipes, no basta con reducir la cantidad de llamadas al sistema: también hay que observar las rutas internas del kernel que atraviesa
writey la forma en que se procesan los búferes
1 comentarios
Opiniones en Hacker News
Hay un proyecto paralelo que intenta abordar este problema: https://lwn.net/Articles/976836/
La idea es crear una llamada al sistema para obtener un ring buffer para todos los descriptores de archivo compatibles. Esto incluiría pipes, y si ambos extremos admiten el uso de ring buffers, se mapearía el mismo ring buffer para hacer E/S sin copias y, en algunos casos, incluso sin llamadas al kernel. Están buscando gente que se sume.
Cuando el ring buffer estaba lleno o vacío se podía hacer sleep/poll, y fuera de eso funcionaba sin locks y sin sobrecarga de llamadas al sistema.
ringbuffer_wait()también podría hacer quepoll()lo marque como listo para lectura y así señalizarlo.Basta con un hiccup momentáneo para que, si no hay buffering suficiente, el pipe se enrede por completo, y el tamaño de buffer necesario varía de un sistema a otro.
La razón por la que
JMPno es simplementeRETes la opción CONFIG_RETHUNK. Lo que se ve en el desensamblado deobjdumpes el resultado de reemplazarRETporJMP __x86_return_thunk.https://github.com/torvalds/linux/blob/v6.1/arch/x86/include...
https://github.com/torvalds/linux/blob/v6.1/arch/x86/lib/ret...
Las instrucciones NOP al inicio y al final de la función no son para ftrace, sino que vienen de las macros ASM_CLAC/ASM_STAC. Estas macros dejan espacio que se rellena en tiempo de ejecución con instrucciones CLAC/STAC si se detecta X86_FEATURE_SMAP. Ambas instrucciones ocupan 3 bytes, igual que la cantidad de NOP.
https://github.com/torvalds/linux/blob/v6.1/arch/x86/include...
https://github.com/torvalds/linux/blob/v6.1/arch/x86/include...
https://github.com/torvalds/linux/blob/v6.1/arch/x86/kernel/...
Llamar “lentos” a los pipes de Linux es como llamar “lento” a un Toyota Corolla. Salvo en casos de uso extremos, son suficientemente rápidos.
¿Estás corriendo carreras? ¿Y además en una disciplina donde la velocidad importa más que la técnica? Entonces compra un auto más rápido. Si no, sigue usando el Corolla.
Por eso, incluso una mejora de eficiencia muy pequeña hace que tenga sentido económico dedicar mucho tiempo a optimizarlo.
He usado pipes para muchas cosas durante más de 10 años y nunca me topé con el límite de velocidad de los pipes; casi siempre es probable que el cuello de botella fueran herramientas como tar, gzip, find, grep o nc. Claro que estas herramientas también son bastante rápidas para lo que hacen.
FFMpeg no puede incluir el SDK Non-Free en el código fuente, y guardar RGBA puro en un archivo es absurdamente impráctico. Por eso el pipe es la única opción, y hay una razón legítima para necesitar pipes de alto rendimiento.
https://www.toyota.com/grcorolla/
Estos autos tienen una ingeniería y un rendimiento impresionantes, y también son una especie de hack para eludir las reglas que dificultaron llevar al mercado estadounidense el GR Yaris originalmente previsto. Creo que tienen suficiente contexto de ingeniería/rendimiento/hack/mercado como para que la gente de HN se lo tome con simpatía. Además, el presidente de la compañía todavía lo maneja personalmente.
Aunque no es el punto central del texto, en las CPU modernas
rep movsbes tan rápido como la versión vectorizada más rápida. Porque la CPU sabe acelerarlo.El nombre de la función del kernel
copy_user_enhanced_fast_stringtambién lo sugiere. Las funciones de CPU relacionadas son ERMS (Enhanced Repeat Move String, que acelerarep movsba partir de cierta longitud) y FSRM (Fast Short Repeat Move String, que también acelera copias cortas).rep movsbes rápido hasta cierto umbral, pero después de eso las escrituras normales o las escrituras no temporales (non-temporal stores) son más rápidas.Todos los umbrales están explicados en https://codebrowser.dev/glibc/glibc/sysdeps/x86_64/multiarch...
Y esos valores tampoco son fijos: Noah Goldstein todavía los actualiza cada año.
memcpycortos, sigo esperando querep movsbyrep stosbse vuelvan lo bastante rápidos como para poder eliminar las versiones con un simple bucle en C.memcpy()de longitud variable, así como inlineanmemcpyde longitud fija.Hay algo sobre AVX512 que no vi en el artículo: además del overhead de
xsave/xrstor, AVX512 consume mucha energía y provoca escalado de frecuencia de la CPU. Para más detalles y ver cuán sutil puede volverse el tema, ver [1], [2].[1] https://www.intel.com/content/dam/www/central-libraries/us/e...
[2] https://www.intel.com/content/www/us/en/developer/articles/t...
Casi todas las formas de comunicación entre procesos son “lentas”. Es una elección de pagar un costo de rendimiento por seguridad.
En particular, no hay muchas razones para que la comunicación entre procesos más rápida sea más lenta que una llamada a función larga.
Otra vez está recibiendo el abrazo de la muerte de Hacker News. Gracias al caché de páginas de WordPress está mejor que la vez pasada, pero aun así la página puede tardar unos segundos en cargar; tengan paciencia.
Nunca entendí bien por qué
splicetendría que ser tan lento. Se mencionaron la asignación de buffers y el uso de instrucciones escalares como razones por las que es más lento quevmsplice, pero no sé por qué eso es necesario.¿Por qué no se puede simplemente reimplementar
splicecomovmsplice? Seguro hay una buena razón, pero siento que se me escapa.vmspliceno funciona con todos los tipos de descriptores de archivo.Sería interesante ver también una versión usando io_uring. Al compartir buffers de antemano con el kernel, podría evitar algunas copias, y también podría evitar el overhead de las llamadas al sistema. Aunque en este caso esto último parece casi despreciable.
Es una afirmación bastante audaz para un blog que tarda unos 20 segundos en cargar.
El artículo en sí parece excelente, y hay mucho que aprender sobre lo que pasa por dentro.