1 puntos por GN⁺ 2024-01-06 | 1 comentarios | Compartir por WhatsApp
  • El ascenso de related_post_gen en el benchmark de Neat no vino de una optimización de alto nivel, sino de un pequeño cambio de ABI: pasar los arreglos como 3 argumentos puntero en lugar de una struct de 24 bytes
  • Los arreglos de Neat necesitan, además de los punteros de inicio y fin, un puntero basado en el objeto del arreglo por el conteo de referencias, así que a diferencia de los 16 bytes del arreglo de D, terminan usando la ruta de paso por memoria del ABI SystemV AMD64
  • Ciertos aggregate de más de 16 bytes se copian al stack al llamar y luego se pasan por puntero, perdiendo la ventaja del paso por registros y aumentando el costo de reordenar el stack
  • En el benchmark de ejemplo, si struct Vector { double x, y, z; } se pasa como struct, 1,000 millones de ejecuciones tardan 12.3 segundos; si sus campos se pasan como argumentos individuales, baja a 5.3 segundos
  • Una API de C debe seguir el ABI de C, pero para tipos internos del runtime del lenguaje, como arreglos, tuplas o sumtypes, cuando superan los 16 bytes vale la pena medir el enfoque de pasar los campos por separado

El cuello de botella que apareció en Neat

  • Neat subió varios puestos en el benchmark de related_post_gen
  • La mejora de rendimiento no fue resultado de un nuevo pase de optimización de alto nivel, sino de cambiar la forma de pasar arreglos
    • Antes: un argumento struct con 3 punteros
    • Después: los 3 punteros como argumentos separados
  • Neat era más lento de lo esperado frente a D, y en el profiler se veía movimiento de grandes áreas del stack para hacer llamadas a funciones
  • El cuello de botella estaba más cerca del costo de reacomodar el stack en el momento de la llamada que del cálculo en sí

Por qué el arreglo de Neat termina midiendo 24 bytes

  • A diferencia del arreglo de D, Neat usa conteo de referencias
  • El arreglo de Neat incluye estos 3 punteros
    • puntero al inicio
    • puntero al final
    • puntero basado en el objeto del arreglo donde se guarda el conteo de referencias
  • Tres punteros son 24 bytes, así que, a diferencia de un arreglo de 16 bytes con 2 punteros, siguen una ruta distinta en las reglas de paso de argumentos de AMD64
  • La razón por la que el arreglo de D era rápido y el de Neat lento fue que, al llegar a 24 bytes, cruzó el límite de 16 bytes

El límite de 16 bytes en el ABI SystemV AMD64

  • La especificación del ABI SystemV AMD64 establece que si cierto aggregate supera dos eightbytes, el argumento completo se pasa por memoria
  • Para pasar una struct por memoria, el lado que llama tiene que hacer lo siguiente
    • reservar en el stack un espacio del tamaño de la struct
    • llenar ese espacio con el valor a pasar
    • pasarle a la función un puntero a la ubicación de esa struct
  • En este caso, el valor necesariamente tiene que estar en el stack, así que LLVM tiene poco margen de optimización
  • El valor debe copiarse de registros al stack, y también hay que rastrear qué parte del stack está en uso y cuál puede reutilizarse
  • En este rastreo de reutilización del stack, LLVM mostró un comportamiento pobre

Benchmark de vector con 3 double

  • El benchmark usa un vector de 3 campos con forma struct Vector { TYPE x, y, z; };
  • TYPE está definido como double
  • Las dos funciones hacen la misma suma, pero difieren en la forma de pasar los argumentos
    • vector_add_struct(struct Vector left, struct Vector right) recibe structs grandes como argumentos
    • vector_add_fields(...) recibe left_x, left_y, left_z, right_x, right_y, right_z como argumentos individuales
  • mode y la duración de ejecución se reciben por línea de comandos para evitar que el optimizador pliegue toda la computación como constante
  • impl.c se compila por separado para evitar el inlining
clang -O3 impl.c -c -o impl.o
clang -O3 harness.c impl.o -o benchmark
time ./benchmark 0 1000000000
time ./benchmark 1 1000000000
  • El resultado fue 12.3 segundos al pasar la struct y 5.3 segundos al pasar los campos por separado

La diferencia visible en assembly

  • La versión que pasa structs usa muchas instrucciones en stack shuffle
  • La versión por campos tiene ventaja porque los parámetros ya entran a la función en registros SSE
  • La versión que pasa structs tiene que cargar los valores desde el stack en cada llamada
  • El ABI SystemV busca pasar valores por registros siempre que sea posible, pero en este caso esa ventaja desaparece por tratarse de structs de más de 16 bytes
  • Considerando la cantidad de registros disponibles en AMD64, se argumenta que incluso para tipos de más de 16 bytes habría sido útil pasarlos por valor

Cuando se parece a cdecl

  • El método de escribir los campos al stack y pasar un puntero termina pareciéndose al antiguo ABI x86 cdecl, que pasaba todo por stack
  • cdecl tenía fama de ser lento, y por eso surgieron varias convenciones de llamada para acelerarlo
  • El problema es que el ABI AMD64 System V, por el tamaño de la struct, en este caso termina comportándose como ese viejo esquema de paso por stack

La excepción del inlining y LTO

  • En código real, este tipo de funciones puede terminar completamente inlineado
  • Al activar LTO en gcc, la diferencia de rendimiento entre ambas versiones desaparece
  • En clang, no se obtiene el mismo resultado
  • No todas las funciones pueden ni deben inlinearse

Consejos para implementadores de lenguajes y optimización de API

  • Al llamar una API de C, hay que seguir el ABI de C
  • Pero los tipos de alto nivel internos de un lenguaje no C, aunque para el backend parezcan structs, no tienen por qué representarse obligatoriamente como una sola struct
  • El implementador del lenguaje puede decidir directamente cómo pasar arreglos, tuplas, sumtypes, etc.
  • En Neat se eligió pasar por campos separados estos tipos que superan los 16 bytes, y el benchmark mostró beneficios
  • Si estás implementando un lenguaje en AMD64 o afinando una API, conviene medir si dividir manualmente las structs de más de 16 bytes ayuda
  • Sobre todo en loops internos, la diferencia de rendimiento puede ser mayor de lo esperado

Nota adicional: structs con double y SSE

  • La pregunta es por qué, si double pertenece a la clase SSE según la especificación, la struct no se pasa en registros SSE
  • La respuesta es que no se sabe por qué, pero en la práctica no se pasa así

1 comentarios

 
GN⁺ 2024-01-06
Comentarios de Hacker News
  • El problema aquí es la ABI SysV amd64. No hace falta usar SysV como ABI interna del lenguaje. Si no se expone a llamadores C de SysV, se puede usar la convención de llamada que uno quiera.
    https://llvm.org/docs/LangRef.html#calling-conventions
    Para quien tenga curiosidad, el cambio relacionado en neatlang está aquí: https://github.com/Neat-Lang/neat/commit/f4ba38cefc1e26631a5...
    Parece mucho más complejo que simplemente cambiar la salida de la convención de llamada de LLVM. Probablemente el autor quería exponer estos tipos a programas C con una convención de llamada determinista.

    • En realidad, podría decirse eso de la ABI en general. Si programas en ensamblador, lo sabes: este es uno de esos frutos fáciles de alcanzar con los que se le puede ganar fácilmente al compilador.
      No hay que seguir las convenciones a ciegas; basta con elegir lo que tenga más sentido para la situación concreta.
    • La primera pregunta que me vino a la mente ya tenía respuesta. Es interesante cómo tantas cosas siguen ABIs creadas hace mucho tiempo.
      En particular, las ABI suelen inclinarse hacia la compatibilidad con CPU más antiguas, y las CPU más nuevas podrían tener margen de mejora usando cosas como registros extendidos, sin tener que reducir el tamaño de las estructuras. El software ajustado a hardware o generaciones específicas no sería tan atractivo porque dejaría de funcionar en algunas máquinas, pero sería genial tener un compilador capaz de generar ese tipo de salida cuando uno quiere optimizar el código al extremo para las capacidades de hardware de su propio sistema.
  • El costo de pasar argumentos muchas veces no se entiende bien, así que se agradecen artículos como este. Incluso en lugares como Google es común pasar objetos de 24 bytes por valor, y ese costo está tan distribuido por todas las funciones que no aparece claramente en los profilers.

    • Pasar por valor y pasar por referencia afecta en la práctica la ABI/API, así que supone una carga cognitiva bastante grande. Zig intenta no obligar a eso, de modo que aunque se “pase por valor”, el compilador puede decidir pasarlo realmente por referencia.
      Aunque también se exponen este tipo de tropiezos: https://github.com/ziglang/zig/issues/5973#issuecomment-1330...
    • ¿“Lugares como Google”? ¿Lo dices por experiencia propia? Como ex-Googler, puedo decir con seguridad que existe una guía de pasar por puntero o referencia cualquier cosa que no sea un tipo primitivo.
      La única excepción que se me ocurre es string_view.
    • Es bueno que señalen que el overhead distribuido tan ampliamente, como si estuviera integrado en la convención de llamada, casi no se puede encontrar con profiling.
    • Si en vez de pasar un objeto de 24 bytes se pasa un puntero, hay un trade-off: cuando realmente haya que usar ese objeto, habrá que desreferenciar el puntero. Y no hay garantía de que ese objeto esté cerca.
      Con mala suerte puede haber un cache miss y terminar esperando unos 100 nanosegundos mientras se trae el objeto de 24 bytes desde la memoria principal. Si se pasa el mismo objeto directamente, estará en el stack, así que es más probable que esté en caché.
    • Me pregunto si la ABI de C++ también derrama al stack objetos de 24 bytes en cada llamada. No esperaría que parámetros como std::string o std::function fueran rápidos, pero igual sorprende.
  • Cuando pasamos por primera vez a x64, me preocupaba que los objetos vec3 de gráficos (3 floats) crecieran a 16 bytes en lugar de sizeof()=12, así que hice muchísimos benchmarks del motor gráfico.
    Como era de esperarse, gracias a la alineación de lecturas de 8 bytes, 16 bytes era más rápido que 12 bytes. Tanto internamente como en la GPU. Así que vec3 se convirtió silenciosamente en vec4, y vec4 siguió existiendo aparte. Como siempre, hay que hacer benchmarks con visión de conjunto, no benchmarks locales.

    • Tiene un efecto secundario muy bueno: encaja muy bien con el tamaño de SSE. Por eso se puede usar _mm_load_ps directamente, el código queda más limpio y la vectorización se vuelve muy sencilla.
    • Probablemente no sea muchísimo más rápido. Y sospecho que también depende mucho del CPU, independientemente de lo que hagas con esos datos.
      Entiendo que con 16 bytes muchos accesos pueden convertirse en 2x8 bytes alineados o 1x16 bytes, en lugar de 3x4 bytes. Pero en otros accesos quizá no sea tan así, y también está el problema de la mayor presión sobre la caché.
    • La ABI x64 también es bastante mejor que la ABI x86.
  • Por sentido común, los valores que se pasan en registros pueden precargarse gracias a la ejecución especulativa, así que son más rápidos que escribir en el stack, y manipular el stack es más rápido que asignar en el heap.
    Por eso el código espagueti sucio con un montón de variables globales puede ser increíblemente rápido, mientras que las funciones recursivas elegantes o los argumentos tipo tupla/estructura/lista pueden ser increíblemente lentos. El primero es mucho más fácil de optimizar como un bucle de ensamblador compacto.

    • Claro, bajo la condición de que ese código espagueti implemente el mismo algoritmo que el código elegante.
      Si el código elegante es O(n) y el espagueti es O(n^2), la diferencia se va a notar. También hay que considerar el mantenimiento. En cierto sentido, los compiladores existen para convertir nuestras soluciones elegantes en código espagueti.
    • “Pasa los parámetros en registros, no en el stack” es casi sentido común, pero “los parámetros mayores de 16 bytes siempre se pasan por el stack” no es igual de obvio.
    • Algunos CPU modernos pueden hacer memory renaming, así que el costo de derramar al stack puede volverse más barato.
      Los objetos globales también entorpecen las optimizaciones del compilador.
  • Como referencia, en MSVC el tamaño de corte es de 8 bytes antes de que una estructura se pase por el stack. Esto es un detalle de la ABI, así que no conviene depender de ello en código portable.
    Pero si la función no se llama con frecuencia, tampoco hace falta estresarse demasiado. Si es una función pequeña llamada con frecuencia, como en el ejemplo, lo ideal es permitir que el compilador inlinee el código mediante algo como LTO. Eso habilita optimizaciones mucho más útiles que pasar argumentos por registros.

  • Clasifico este tipo de textos como “el conocimiento justo para meterse en problemas”. Aunque sigas la indicación de compilar por separado para obligar al compilador a generar una función invocable mediante la ABI, LTO puede revertir este error.
    Si compilas este programa con LTO, se vuelve drásticamente más rápido en ambos modos que cualquier modo del programa sin LTO. Si es un programa sensible al rendimiento, hay que perfilarlo y, solo después de optimizar los cuellos de botella al máximo, comprometerse a hacer cosas como descomponer las estructuras en argumentos.

    • Es un buen consejo, pero todavía no he visto un compilador que haga visible este tipo de cosas. Primero, están distribuidas por toda la base de código y, salvo que con suerte se vuelvan un hotspot, tampoco he visto un profiler que muestre su impacto.
      Aplica a casi todo el código generado por compiladores. Valgrind podría medirlo, pero un profiler por muestreo probablemente no, y no hay herramientas que resalten problemas de generación de código dispersos.
    • Además, habla de la importancia absoluta del rendimiento mientras usa conteo de referencias.
  • En la convención de llamada cdecl predeterminada de Windows, las estructuras de más de 8 bytes no se pasan por registros [1]
    [1]: https://learn.microsoft.com/en-us/cpp/build/x64-calling-conv...

  • En amd64, incluso usando la ABI SysV amd64, pasar y devolver por valor estructuras de más de 16 bytes es totalmente posible. Simplemente es lento.
    Aun así, muchas veces pasar por valor vale la pena para que el código sea más claro. Claro que no en este caso, pero como señaló loeg, dentro de tu propio lenguaje puedes usar una ABI personalizada, como los compiladores de C++, Go, OCaml y SBCL.

  • En el ejemplo dado, se puede arreglar cambiando el tipo del parámetro de “struct Vector” a “const struct Vector &” y pasándolo por referencia, sin afectar al llamador.
    He visto mucho código C++ con bugs de punteros que usaba punteros sin necesidad, cuando pasar por referencia habría sido suficiente y además más fácil y seguro.

    • No. De hecho, ese es precisamente el problema central aquí. Gracias a la ABI, el compilador en la práctica hace exactamente eso.
      Como la ABI dice que el valor se debe pasar como puntero, hay que guardarlo en algún lado para obtener ese puntero, y termina ocurriendo lo mismo que si hubieras especificado const-ref. Si conviertes el valor de la estructura en argumentos separados, los argumentos pueden pasarse por registros.
    • Cuando encontré este problema, era código con veinte o treinta alloca para pasar punteros a byval. Todas las funciones empezaban con un alloca separado por cada parámetro que se pasaba a la llamada.
      Siempre había asumido en cierta medida que LLVM limpiaría bien este tipo de cosas, pero resultó que no.
    • Aun así, para pasarle al llamado un puntero a la estructura, el compilador tiene que serializar tres registros en la pila.
      El beneficio descrito consiste en evitar por completo esa serialización de registros a pila, y pasar por referencia no parece evitarla.
    • Este no era un ejemplo de C++, sino un ejemplo en C99. En muchos entornos no puedes cambiar las herramientas a voluntad, aunque sea por una inercia mínima.
      Si se permite C++, tienes muchas más opciones, como argumentos por movimiento para reducir copias.
  • La regla práctica que siempre he escuchado en C++ es que, salvo que haya una buena razón para pasar por valor algo que no sea un tipo primitivo, hay que pasarlo por referencia, y si de verdad hace falta, por puntero.
    Esto se debe tanto a la ABI como a evitar constructores de copia o de movimiento. Son detalles aburridos de bajo nivel, pero si quieres el máximo rendimiento en C++, hay que prestarles atención. Para ser claros, esto es solo una optimización de rendimiento; el código que pasa estructuras funciona correctamente, solo que es menos rápido.