- En los shaders de GPU, el código que elige valores con un operador ternario o un
ifsimple 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áscaras0.0/1.0y 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()calculax = abs(v.x)a partir del vector de entrada y luego devuelve uno de tres resultadosvec2usando dos operadores ternarios - La misma lógica se mantiene aunque se escriba con un
ifnormal - La “optimización” problemática consiste en reemplazar el operador ternario por
step()y una composición ponderada- Se crean
w0,w1,w2constep() - Se calculan
res0,res1,res2por separado - El resultado final se compone con
w0*res0 + w1*res1 + w2*res2
- Se crean
- 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
- Comparaciones:
- La salida del compilador de Microsoft muestra la misma estructura
- Comparación:
lt - Movimiento condicional:
movc
- Comparación:
- 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áscaras0.0o1.0mediante 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 aabs()puede considerarse prácticamente gratuita - Recomendar
float a = mix(b, c, step(y, x));como una optimización defloat a = x < y ? b : c;es un enfoque equivocado
1 comentarios
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
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
Sería bueno tener una forma clara de saber cuándo un
iffuerza una rama real y cuándo noLa 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 sobrecargaEs 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 deifa veces sea una rama y a veces noEn contextos donde realmente no debería haber ramas, me gustaría que
branch-ify 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 ramaAl 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 SIMDPuede 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
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
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
a = f(z); b = g(z); v = x > y ? a : b;Si las llamadas a
f()yg()son relativamente costosas, decidir si emitir código condicional o calcular ambas y luego seleccionar es una cuestión de compromisoNo es una elección simple; la decisión la toma el compilador
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
ifcomo movimientos condicionales y solo puedan llamar a funciones sin ramaUna 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
IFEHcostaba 6 ciclos y además la GPU tenía que ejecutar ambas ramasCreo 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
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
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
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...
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ó
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
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”
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 casosstep() = 0.0ystep() == 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
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
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?
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ónSi el rendimiento es importante, puede que tengas que saberlo
Pero el solo hecho de que
stepesté 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
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
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”
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 bibliotecaQue
step()sea una función integrada o una función que aparece en un paper de matemáticas no cambia nadaEn matemáticas, la definición de
step()también es literalmente un condicionalPara 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 significativoPor eso
abs()se reduce a forzar ese bit más significativo a 0, o a enmascararlo en la instrucción que lee el valorPero
step(), un ternario arbitrario y, hasta donde sé, una instrucción de movimiento condicional no son casos especiales de ese tipoabs(),sqrt()y las funciones trigonométricas básicas son casi conocimiento estándar; para el resto, dudo que importe de todos modosstep()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 fundamentalMe 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
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