1 puntos por GN⁺ 2025-02-10 | 1 comentarios | Compartir por WhatsApp
  • En los shaders de GPU, el código que elige valores con un operador ternario o un if simple normalmente no se procesa como una rama condicional, sino como una selección o movimiento condicional
  • Aunque se reemplace por step() y enmascarado aritmético, no hay ninguna rama que eliminar, así que la premisa misma de la supuesta optimización para eliminar branches es incorrecta
  • En la salida de los compiladores de AMD y Microsoft aparecen comparaciones e instrucciones de máscara/movimiento condicional, y no se ven instrucciones de salto o branch
  • La versión con step() primero crea máscaras 0.0/1.0 y luego compone el resultado con multiplicaciones y sumas, así que añade operaciones innecesarias frente al código que hace el movimiento condicional de forma directa
  • Las ramas de GPU que omiten bloques grandes de cálculo según una condición siguen siendo útiles, pero para una simple selección de valores es más seguro revisar el código máquina generado

La selección simple de valores no es una rama de GPU

  • La función de ejemplo snap45() calcula x = abs(v.x) a partir del vector de entrada y luego devuelve uno de tres resultados vec2 usando dos operadores ternarios
  • La misma lógica se mantiene aunque se escriba con un if normal
  • La “optimización” problemática consiste en reemplazar el operador ternario por step() y una composición ponderada
    • Se crean w0, w1, w2 con step()
    • Se calculan res0, res1, res2 por separado
    • El resultado final se compone con w0*res0 + w1*res1 + w2*res2
  • Esta transformación parte del malentendido de que el código original genera una rama condicional
  • La selección simple de valores en registros no cambia el puntero de instrucciones, ni provoca fallos de predicción, vaciado del pipeline o invalidación de la caché de instrucciones
  • Las ramas reales de GPU sí pueden ser rápidas y útiles cuando permiten saltarse bloques grandes de cálculo según la condición
  • Pero en casos como el ejemplo, donde solo se elige un valor simple o el resultado de un cálculo, puede asumirse que no se genera ninguna rama en el código máquina resultante

La diferencia que muestra la salida del compilador

  • El código GLSL original con operador ternario se transforma en el compilador de AMD en comparaciones e instrucciones de máscara condicional
    • Comparaciones: v_cmp_gt_f32, v_cmp_ngt_f32
    • Máscara condicional: v_cndmask_b32
  • La salida del compilador de Microsoft muestra la misma estructura
    • Comparación: lt
    • Movimiento condicional: movc
  • En la salida de ambos compiladores no aparecen instrucciones de jump/branch

Por qué el enfoque con step() termina siendo más caro

  • El enfoque basado en step() primero crea máscaras 0.0 o 1.0 mediante movimiento condicional, y luego enmascara varios resultados candidatos con multiplicaciones y sumas
  • El código original hace el movimiento condicional del valor necesario de forma directa, por lo que desperdicia menos que el enfoque con step(), que añade creación de máscaras y composición aritmética
  • En distinto hardware, la versión basada en step() puede medirse como mucho más lenta que la versión original
  • Algunas llamadas a abs() de GLSL en el código de ejemplo no se convierten en instrucciones separadas de GPU, sino que entran como modificadores de instrucción; en esos casos, la llamada a abs() puede considerarse prácticamente gratuita
  • Recomendar float a = mix(b, c, step(y, x)); como una optimización de float a = x < y ? b : c; es un enfoque equivocado

1 comentarios

 
GN⁺ 2025-02-10
Comentarios de Hacker News
  • La conclusión del artículo parece correcta, pero el argumento habría sido más sólido si hubiera mostrado el código generado para ambas versiones, no solo el resultado de generación de código de la versión mejor
    En la cita dice algo como: “la versión que se afirma que está optimizada es mucho más lenta que la original… desperdicia dos multiplicaciones y una o dos sumas… veamos el código máquina generado”, pero en realidad solo muestra la versión buena, que no tiene multiplicaciones ni sumas
    Eso solo demuestra que la versión buena está bien; todavía no demuestra que la versión mala sea peor

    • El punto clave es que la condicional no generó una rama real
      Aunque mostrara el código generado de la otra versión, probablemente solo se vería que es más largo, y tampoco se esperaría que esa versión generara una rama, así que no habría aportado mucho valor
    • El código generado para RDNA 1 está aquí: https://shader-playground.timjones.io/5d3ece620f45091678dcee...
  • Sería bueno tener una forma clara de saber cuándo un if fuerza una rama real y cuándo no
    La razón por la que la gente usa mix/lerp, que podrían ser más caros, es que temen que se genere una rama aunque tengan que aceptar un poco de sobrecarga
    Es bueno que el código más claro, como v = x > y ? a : b;, en la práctica funcione bien, pero inquieta que la misma sintaxis de if a veces sea una rama y a veces no
    En contextos donde realmente no debería haber ramas, me gustaría que branch-if y un if sin rama fueran palabras clave distintas; que la palabra clave sin rama haga fallar la compilación si el compilador no puede generarla sin ramas, y que la palabra clave con rama emita una advertencia si puede generarse sin una rama

    • Detrás de eso está la documentación confusa de NVIDIA y de los compiladores cg/CUDA
      Al principio, supongo que no querían asustar a los programadores, así que ocultaron el modelo de ejecución y lo explicaron con la abstracción de “hilos”; después, en la promoción de las GPU, siguieron diciendo cosas como “hay muchísimos hilos CUDA”
      Como resultado, surgieron supersticiones extrañas en la programación de GPU
      En realidad, muchas veces conviene que el código tenga ramas, y las ramas en sí son rápidas
      El problema es que los lanes SIMD no pueden desviarse cada uno hacia una rama distinta, así que el compilador emite el código de ambos lados en lugar de una rama y enmascara el resultado según la condición
      Por lo tanto, los cálculos basados en entradas del shader, vértices, índices de compute shader, etc., no hacen una rama real, sino que se ejecutan secuencialmente mediante enmascaramiento
      En el ejemplo del artículo, también se calculan ambos valores del operador ?, y en general ocurre lo mismo con las condicionales sobre valores SIMD
      Puede aparecer una rama de atajo que omita rápidamente el cálculo cuando todos los lanes tienen el mismo valor, pero por lo general se calculan tanto el caso verdadero como el falso
      Solo las condicionales basadas en registros escalares, es decir, constantes del shader o valores uniform, generan ramas reales, y esas ramas son muy rápidas
    • Eso también ocurre en CPU escalares
      Por ejemplo, la instrucción CMOV se introdujo en 1995 con el núcleo P6
      Las ramas también son costosas en arquitecturas escalares, y el compilador intenta decidir lo mejor posible cuándo usar una estrategia alternativa
      A veces se equivoca, pero no con demasiada frecuencia
    • En GPU habría que verlo más bien al revés
      El movimiento condicional es lo predeterminado, y una rama real solo es una optimización de rendimiento posible cuando se trata de una rama uniform en la que todo el workgroup va en la misma dirección
    • Se puede pensar en un ejemplo como este: a = f(z); b = g(z); v = x > y ? a : b;
      Si las llamadas a f() y g() son relativamente costosas, decidir si emitir código condicional o calcular ambas y luego seleccionar es una cuestión de compromiso
      No es una elección simple; la decisión la toma el compilador
    • Sería interesante que los lenguajes de shaders tuvieran una función así
      Podrían distinguir todas las funciones del código como con posibilidad de rama/sin rama, como si se las coloreara, y hacer que las funciones marcadas como sin rama tengan que compilar los if como movimientos condicionales y solo puedan llamar a funciones sin rama
  • Una gran parte del mito de que “las ramas en GPU son lentas” viene de que, hace mucho, en la época de PlayStation 3, de hecho eran bastante lentas
    La PS3 incluía una GPU RSX de NVIDIA, y recuerdo que según la documentación una rama costaba 6 ciclos, pero las mediciones reales siempre daban más que eso
    Ocurría incluso con ramas totalmente coherentes, donde todos los hilos del warp seguían la misma ruta; las ramas incoherentes eran aún más lentas porque la instrucción IFEH costaba 6 ciclos y además la GPU tenía que ejecutar ambas ramas
    Creo que de ahí nació el mito, que persiste hasta hoy, de que las ramas en GPU son lentas
    En las GPU actuales, las ramas, especialmente las coherentes, son bastante baratas

    • Cuando alguien dice simplemente “rama”, hay que asumir que se refiere a una rama incoherente
      Aunque la sobrecarga del mecanismo de ramas actual puede haber bajado, la restricción física de que el rendimiento de ambos lados de la rama se reduce según la proporción de hilos activos sigue igual
      Si se ejecutan ambas ramas y las longitudes de instrucción son iguales, el rendimiento promedio de ambos lados se reduce al menos a la mitad
      Por eso la creencia de que las ramas en GPU son lentas persiste, y en la práctica también es cierta
      Si es posible, vale la pena esforzarse más por reformular el problema sin ramas
    • Las ramas coherentes son casi “gratis”, pero las instrucciones adicionales aumentan la presión sobre los registros
      La razón principal para evitar ramas dinámicas no es tanto que la rama en sí sea intrínsecamente lenta, sino más bien esto
  • Esta optimización para evitar ramificaciones alguna vez fue efectiva
    La perfilé en Xbox 360 y en GPU integradas Intel antiguas, pero hoy lo correcto es no hacerlo así
    La extracción de bits y otras operaciones con enteros son similares
    Antes era más rápido emularlas con matemática de punto flotante, pero ahora todas las GPU tienen operaciones rápidas con enteros

    • Me pregunto hasta qué punto es cierto eso de que “ahora todas las GPU tienen operaciones rápidas con enteros”
      Por ejemplo, si miramos la ISA RDNA2, que es la arquitectura de PS5 y Xbox Series S|X, para enteros parece que solo hay instrucciones escalares de 32 bits
      [0] https://www.amd.com/content/dam/amd/en/documents/radeon-tech...
    • Al menos en las GPU “grandes” ya no era un problema tan grande como antes, pero este artículo en realidad no trata sobre evitar ramificaciones en sí
      El código presentado ya es código sin ramificaciones
      Quienes dan ese consejo parecen juzgar si hay código con ramificaciones solo por si en el código fuente hay sintaxis que parece una condicional, y creen que evitar eso es una optimización
  • Este artículo también está relacionado: https://medium.com/@jasonbooth_86226/branching-on-a-gpu-18bf...
    “Si le preguntas a internet cómo escribir ramificaciones en una GPU, puede sonar como si estuvieras abriendo las puertas del infierno y dejando entrar demonios. Te dirán que debes evitarlas a toda costa, y que puedes hacerlo con trucos matemáticos extraños como el operador ternario o step(). La mayor parte de ese consejo está desactualizado en el mejor de los casos, y muchas veces simplemente es incorrecto. Vamos a corregirlo.”

  • Los procesadores cambian y los compiladores también
    Si estos detalles importan, lo mejor es distribuir varias variantes y elegir en tiempo de ejecución la versión más rápida
    Ya lo dije algunas veces antes, pero me ha tocado eliminar ensamblador escrito a mano y reemplazarlo por C normal o código similar, logrando que fuera mucho más rápido
    Puede que ese ensamblador haya sido más rápido hace 10 o 20 años, pero ahora la situación cambió

    • Creo que determinar en tiempo de ejecución cuál es la versión más rápida de un shader es muy complicado
      No conozco muchos juegos o motores que realmente hagan eso
      En principio podría ser posible
      La mayoría de las API como D3D, GL y Vulkan exponen contadores de rendimiento y, aunque la confiabilidad varía según el proveedor, se podría crear una escena de prueba representativa, reproducirla varias veces y medir la optimización
      Pero muchos juegos usan escenas generadas dinámicamente y shaders generados dinámicamente, así que la cantidad de combinaciones a probar puede volverse un obstáculo
      Tal vez habría que pedirle al usuario que espere hasta que termine el benchmark
      Si tienes el hardware, podrías medir por adelantado en varias generaciones de GPU de cada proveedor y hardcodear solo las decisiones importantes, pero no conozco bien una infraestructura existente de ese tipo
    • Curiosamente, los drivers de NVIDIA hacen algo así hasta cierto punto
      Interceptan los shaders del juego y los reemplazan por shaders personalizados optimizados por NVIDIA
      Por eso en los changelogs de los drivers de NVIDIA se ven frases como “optimización del juego X, corre 40% más rápido”
    • Agregar un shader más podría estar bien, pero en las API gráficas “modernas” a veces se necesitan miles de permutaciones para el mismo shader, y cada variante adicional duplica esa cantidad
      Tampoco puedes dedicarle tiempo infinito a cada shader
      Perfilas en el hardware que te importa, y si el enfoque elegido resulta más lento en algún procesador futuro hipotético, no hay mucho que hacer
      Con suerte, ese procesador será lo suficientemente rápido como para que no sea un problema
  • Parece que aquí también se repiten el error y la confusión que este artículo intenta corregir
    El artículo no afirma que las ramificaciones condicionales sean gratis
    A mi entender, ni siquiera es un artículo sobre el costo de rendimiento del código con ramificaciones
    El punto del artículo es que la lógica condicional en la forma presentada no se compila como código con ramificación condicional
    Y que no deberíamos seguir difundiendo consejos dañinos que fuerzan a ocultar toda expresión condicional visible
    Sobre el código con ramificaciones reales, es obvio que ejecutar código con ramificaciones es más complejo
    No existen las ramificaciones gratis, y evitar ramificaciones dentro de lo razonable probablemente haga más rápido cualquier código
    Por suerte, el código original ya era código sin ramificaciones
    Como siempre, no hay una métrica universal que diga si una optimización vale la pena
    [0] Aquí “visible” es importante. Se refiere a los casos en los que no les importa el código generado, sino solo que el código fuente no parezca tener condicionales
    [1] Claro que no fue por casualidad. Sospecho que alguien le habrá enviado a IQ una mejora de shader aparentemente obvia, pero equivocada

  • Entonces, ¿por qué el compilador no es lo bastante inteligente como para reconocer que la versión “optimizada” es el mismo código?
    ¿No debería entender step() y poder optimizar por separado los casos step() = 0.0 y step() == 1.0?
    Como mínimo podría eliminar una multiplicación, así que normalmente parecería siempre ser una ganancia, aunque se transforme en una carga/almacenamiento condicional o en otra cosa

    • En realidad, puede que sí lo haga
      Es muy posible que algunos compiladores hagan esta optimización en algunos casos, pero también es claramente posible escribir una versión que el compilador no entienda
    • Otro problema de la optimización es que no puede tardar demasiado intentando todas las posibilidades
      La mayoría de las optimizaciones ocurren del lado del driver, y las tareas que tardan demasiado se manifiestan como tirones por compilación de shaders
      No puedo decir si esta optimización en particular se aplica realmente o no, pero siempre es un factor a considerar
  • La razón por la que la versión “optimizada” en cuestión es más lenta es que la función step() en realidad se implementa de esta forma:
    float step( float x, float y ) { return x < y ? 1.0 : 0.0; }
    ¿Cómo se supone que uno sepa si una función de OpenGL llama a una primitiva de la GPU o si se emula?

    • La única forma es hacer como en el artículo original: compilar el shader, desensamblarlo y luego leer el ensamblador
      Lo he hecho muchas veces con shaders HLSL, y aprendí mucho sobre el conjunto de instrucciones virtual
      Por ejemplo, es interesante que las GPU tengan una instrucción sincos, pero que las funciones trigonométricas inversas se emulen durante la compilación
    • Por qué habría que saberlo depende del objetivo
      Si el rendimiento es importante, puede que tengas que saberlo
      Pero el solo hecho de que step esté implementada como una función de biblioteca sobre un condicional y no como una instrucción dedicada no te dice nada sobre su rendimiento frente a una instrucción dedicada, así que no conviene obsesionarse demasiado con la implementación en sí
      Si lo que te interesa es la arquitectura de la GPU, puedes mirar el desensamblado, el código de drivers open source, LLVM y la documentación de la ISA
    • Fuera de funciones que uno esperaría ver en ensamblador estilo PC, no he visto casos en los que la GPU tenga primitivas especiales
      Cada vez que veo shaders decompilados, en general se parecen a lo que pensaría en C
      Especificaciones como OpenGL definen el comportamiento de muchas funciones integradas, y la implementación satisface esa especificación con instrucciones estándar de ensamblador
      Puedes buscar sitios en línea que decompilen para varias arquitecturas
    • Es una buena pregunta que aparece a menudo en programación en general, y también es una razón clave por la que, al optimizar, hay que medir primero
      Normalmente no hace falta saber ni preocuparse por cómo se implementa una función integrada
      Si te estás preocupando por eso, probablemente estás pensando en optimizar, y en ese caso la respuesta es: “mide y comprueba qué es mejor”
    • Creo que el punto que me confundía es que “branch” tiene un significado más definido por hardware que el sentido con el que crecí aprendiendo el término
      En el sentido que yo aprendí, un condicional es una rama
      A nivel de código de máquina, el flujo de control se elige en tiempo de ejecución, así que un salto condicional es, por definición, una rama
      Para mí, usar step() no convierte la lógica en aritmética; solo oculta la lógica dentro de una llamada a una función de biblioteca
      Que step() sea una función integrada o una función que aparece en un paper de matemáticas no cambia nada
      En matemáticas, la definición de step() también es literalmente un condicional
      Para optimizar de verdad sin condicionales, hay que elegir una función continua que se parezca al resultado deseado y ajustar sus parámetros para acercarse lo más posible al objetivo
      Normalmente terminas eligiendo un polinomio, ejecutando un método estándar de aproximación iterativa y creando una f(x) que no tiene ramas, solo sumas, multiplicaciones y constantes “extrañamente específicas”
      No termino de entender la parte en la que el autor insiste en que el movimiento condicional no es una “rama”
      Que abs() baje no como una instrucción de GPU sino como un modificador de instrucción y resulte gratis se debe a que, gracias a la representación en complemento a dos de los enteros y a la representación de coma flotante IEEE-754, el bit de signo puede tratarse como el bit más significativo
      Por eso abs() se reduce a forzar ese bit más significativo a 0, o a enmascararlo en la instrucción que lee el valor
      Pero step(), un ternario arbitrario y, hasta donde sé, una instrucción de movimiento condicional no son casos especiales de ese tipo
      abs(), sqrt() y las funciones trigonométricas básicas son casi conocimiento estándar; para el resto, dudo que importe de todos modos
      step() inevitablemente tiene que tener una condición en alguna parte, y que la pongas tú directamente, se la dejes a una biblioteca o se la delegues al hardware no cambia su naturaleza fundamental
  • Me ha pasado caer en esta trampa
    Claude y ChatGPT también suelen proponerlo como una optimización
    Pero cada vez que lo medí, el rendimiento empeoró, a veces bastante

    • No es nada raro
      Los LLM solo repiten lo que está en su corpus de entrenamiento
      Si la mayor parte de internet recomienda cosas equivocadas como esta “optimización” de movimiento condicional, los LLM también la recomiendan
    • Los LLM repiten lo que dice la gente en internet, y la gente se equivoca con frecuencia