1 puntos por GN⁺ 2024-08-27 | 1 comentarios | Compartir por WhatsApp
  • 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_write se acumulan los costos de asignación de páginas, bloqueos y rutinas de copia del kernel
  • vmsplice conecta el búfer de espacio de usuario a la pipe sin copiarlo al kernel, evitando rutas costosas como __alloc_pages, _raw_spin_lock_irq y copy_user_enhanced_fast_string
  • En el caso de rendimiento de Fizz Buzz, la solución con vmsplice llegó a 60.8GiB/s en un solo núcleo y 208.3GiB/s en múltiples núcleos; un ejemplo de vmsplice de 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 vmsplice para 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 vmsplice alcanzan unos pocos GiB por segundo, y la solución de neil llegó a 8.4GiB/s
    • Las soluciones que usan vmsplice suben hasta 15.5GiB/s de tkluck, 60.8GiB/s de ais523 y 208.3GiB/s multinúcleo de david
  • 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 ftrace mostró 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

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 con pv >/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 de pipe_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 pipe
    • copy_user_enhanced_fast_string: alrededor del 20% del tiempo total, copia datos desde el espacio de usuario hacia el kernel
  • Como pv consume moviendo páginas a /dev/null con splice, 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ón REP 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 movsb directamente 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

  • vmsplice reduce el costo de usar pipes al entregar el búfer completo del espacio de usuario a la pipe sin copiarlo al kernel
  • El ejemplo ./write incluido en pipes-speed-test de 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.0 consume un 37% del tiempo
  • Pero no aparecen __alloc_pages, _raw_spin_lock_irq ni copy_user_enhanced_fast_string, que sí se veían en la ruta de write
  • En su lugar, add_to_pipe, import_iovec e iov_iter_get_pages2 aparecen como rutas principales, lo que muestra que vmsplice evita las partes costosas de write

Conclusiones restantes y puntos a tener en cuenta

  • En las pruebas, la ruta para escribir en una pipe de Linux con write es 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 splice y vmsplice los 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 write y la forma en que se procesan los búferes

1 comentarios

 
GN⁺ 2024-08-27
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.

    • Al menos para usos en espacio de usuario, no sé si realmente hace falta una nueva función del kernel. Hace tiempo implementé un ring buffer de un solo productor/un solo consumidor en espacio de usuario que imitaba bastante de cerca el comportamiento de un pipe usando eventfd.
      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.
    • Me pregunto si está previsto algún mecanismo estándar de señalización para que ambos extremos de un pipe indiquen al otro lado que soportan ring buffers. Así libc podría manejarlo de forma transparente; de lo contrario, al menos en el caso de los pipes, no tengo claro qué ventaja tendría frente a memoria compartida y sincronización con futex.
    • Probablemente ringbuffer_wait() también podría hacer que poll() lo marque como listo para lectura y así señalizarlo.
    • Me pregunto si las interfaces de ring buffer existentes terminarán usando esto, o si tendremos una situación xkcd927. En cualquier caso, parece un intento interesante.
    • El buffering existe por una razón, y este enfoque puede crear modos de falla extraños y vulnerabilidades en scripts. El punto clave es que cualquier productor de streams puede volverse más lento que cierto consumidor.
      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 JMP no es simplemente RET es la opción CONFIG_RETHUNK. Lo que se ve en el desensamblado de objdump es el resultado de reemplazar RET por JMP __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/...

    • Probablemente se puedan contar con los dedos los desarrolladores del kernel que sabrían todo esto y aun así elegirían un nick descartable tan travieso.
  • 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.

    • Esto no es código de algún proyecto que se ejecutará apenas unas decenas de miles de millones de veces durante su vida útil, sino código usado con frecuencia en millones a miles de millones de computadoras.
      Por eso, incluso una mejora de eficiencia muy pequeña hace que tenga sentido económico dedicar mucho tiempo a optimizarlo.
    • De hecho, en el caso del autor, los pipes “lentos” están moviendo datos a 17GB/s, es decir, más de 130Gbps.
      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.
    • Tengo un proyecto que usa un SDK propietario para decodificar video sin procesar. Saca los datos decodificados como RGBA puro, y FFMpeg los lee desde un pipe para reencodificarlos a un códec estándar.
      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.
    • Hacer unos puntos porcentuales más rápido algo que se usa en todas partes es una inversión totalmente valiosa. Aunque las tareas individuales no se aceleren mucho, a escala global se ahorra una enorme cantidad de energía y tiempo.
    • A veces, un Corolla más rápido realmente es la mejor respuesta.
      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 movsb es 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_string también lo sugiere. Las funciones de CPU relacionadas son ERMS (Enhanced Repeat Move String, que acelera rep movsb a partir de cierta longitud) y FSRM (Fast Short Repeat Move String, que también acelera copias cortas).

    • Eso no es todo. rep movsb es 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.
    • También vale la pena notar que Linux cambió varias veces la forma en que usa ERMS y FSRM en las copias en x86 desde el kernel 6.1 usado en el artículo. Como referencia, en mi máquina, que tiene tanto FSRM como ERMS —sorprendentemente, lo primero no implica lo segundo—, Linux 6.8 alcanza 17 GB/s con una pipe normal y un buffer de 32 KiB.
    • Para memcpy cortos, sigo esperando que rep movsb y rep stosb se vuelvan lo bastante rápidos como para poder eliminar las versiones con un simple bucle en C.
    • Entonces me pregunto cuándo los compiladores de C empezarán a inlinear también memcpy() de longitud variable, así como inlinean memcpy de 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...

    • Eso solo aplica a ciertos modelos de CPU Intel.
  • Casi todas las formas de comunicación entre procesos son “lentas”. Es una elección de pagar un costo de rendimiento por seguridad.

    • No hace falta pagar un costo tan alto. Las pipes ofrecen muy poco, así que su costo también debería ser muy bajo.
      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.
    • Las pipes no existen por seguridad, sino como una optimización para pasar datos entre programas existentes.
  • 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é splice tendrí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 que vmsplice, pero no sé por qué eso es necesario.
    ¿Por qué no se puede simplemente reimplementar splice como vmsplice? Seguro hay una buena razón, pero siento que se me escapa.

  • 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.

    • Creo que hay que ser un poco generosos, ya que este artículo llegó hasta la cima de Hacker News.
      El artículo en sí parece excelente, y hay mucho que aprender sobre lo que pasa por dentro.