- Dans les shaders GPU, le code qui choisit une valeur avec un opérateur ternaire ou un simple
ifest généralement traité comme un déplacement conditionnel (select), et non comme un branchement conditionnel - Le remplacer par
step()et un masquage arithmétique ne supprime donc aucun branchement inexistant : le postulat de cette soi-disant optimisation par suppression de branchement est erroné - Les sorties des compilateurs AMD et Microsoft montrent des instructions de comparaison et de masque/déplacement conditionnel, sans instruction de saut ni de branchement
- La version avec
step()crée un masque0.0/1.0puis compose le résultat avec des multiplications et des additions, ce qui ajoute des opérations inutiles par rapport à un déplacement conditionnel direct - Les branchements GPU qui sautent de gros blocs de calcul selon une condition restent utiles, mais pour une simple sélection de valeur, il est plus sûr de vérifier le code machine généré
Une simple sélection de valeur n’est pas un branchement GPU
- La fonction d’exemple
snap45()calculex = abs(v.x)à partir du vecteur d’entrée, puis renvoie l’un de trois résultatsvec2au moyen de deux opérateurs ternaires - La même logique reste valable si elle est écrite avec des instructions
ifclassiques - L’« optimisation » en question consiste à remplacer les opérateurs ternaires par
step()et une composition pondéréew0,w1etw2sont produits avecstep()res0,res1etres2sont calculés séparément- Le résultat final est composé avec
w0*res0 + w1*res1 + w2*res2
- Cette transformation part de l’idée fausse que le code d’origine crée un branchement conditionnel
- Une simple sélection de valeur dans un registre ne modifie pas le pointeur d’instruction et ne provoque ni échec de prédiction, ni vidage du pipeline, ni invalidation du cache d’instructions
- Les vrais branchements GPU peuvent être rapides et utiles lorsqu’ils permettent de sauter de gros blocs de calcul selon une condition
- En revanche, dans un cas comme l’exemple, où il s’agit simplement de choisir une valeur ou un résultat de calcul, on peut considérer qu’aucun branchement n’apparaît dans le code machine généré
Ce que montrent les sorties des compilateurs
- Le code GLSL d’origine avec opérateurs ternaires est transformé par le compilateur AMD en instructions de comparaison et de masque conditionnel
- Comparaison :
v_cmp_gt_f32,v_cmp_ngt_f32 - Masque conditionnel :
v_cndmask_b32
- Comparaison :
- La sortie du compilateur Microsoft présente la même structure
- Comparaison :
lt - Déplacement conditionnel :
movc
- Comparaison :
- Dans les deux sorties de compilateur, il n’y a aucune instruction jump/branch
Pourquoi l’approche avec step() est plus coûteuse
- L’approche basée sur
step()commence par créer, via un déplacement conditionnel, un masque0.0ou1.0, puis masque plusieurs résultats candidats avec des multiplications et additions - Le code d’origine déplace directement la valeur nécessaire de façon conditionnelle ; il gaspille donc moins de travail que l’approche avec
step(), qui ajoute la génération de masques et la composition arithmétique - Sur divers matériels, la version basée sur
step()peut se révéler nettement plus lente que la version d’origine - Certains appels GLSL
abs()du code d’exemple ne deviennent pas des instructions GPU séparées, mais des modificateurs d’instruction ; dans ce cas, l’appel àabs()peut être considéré comme presque gratuit - Recommander
float a = mix(b, c, step(y, x));comme optimisation defloat a = x < y ? b : c;est une mauvaise approche
1 commentaires
Commentaires sur Hacker News
La conclusion de l’article semble juste, mais l’argumentation aurait été plus solide si elle avait montré les résultats de génération de code des deux versions, au lieu de ne montrer que la meilleure
La citation dit en substance : « la version présentée comme optimisée est bien plus lente que la version d’origine… elle gaspille deux multiplications et une ou deux additions… regardons le code machine généré », mais en pratique seul le bon cas, sans multiplications ni additions, est montré
Cela prouve seulement que la bonne version est correcte, pas encore que la mauvaise est effectivement pire
Même si le code généré de l’autre version avait été montré, il n’aurait probablement paru que plus long ; il n’était pas vraiment attendu qu’il introduise un branchement, donc cela n’aurait sans doute pas apporté grand-chose
Ce serait bien d’avoir un bon moyen de savoir quand
ifforce réellement un branchement et quand ce n’est pas le casSi les gens utilisent
mix/lerp, potentiellement plus coûteux, c’est parce qu’ils redoutent l’apparition d’un branchement, même au prix d’un léger surcoûtC’est bien si le code le plus clair, comme
v = x > y ? a : b;, fonctionne correctement en pratique, mais le fait que la même syntaxeifcorresponde parfois à un branchement et parfois non reste inquiétantDans les contextes où il ne faut vraiment pas brancher, on aimerait que
branch-ifet le if sans branchement soient deux mots-clés distincts ; le mot-clé non branchant devrait faire échouer la compilation si le compilateur ne peut pas le produire sans branchement, et le mot-clé branchant devrait émettre un avertissement si le compilateur peut finalement s’en passerAu départ, ils ne voulaient pas effrayer les programmeurs, donc ils ont masqué le modèle d’exécution derrière l’abstraction des « threads », puis ont continué à présenter les GPU en disant en gros « il y a énormément de threads CUDA »
Cela a fini par créer d’étranges superstitions autour du développement GPU
En réalité, il est souvent tout à fait souhaitable d’avoir des branches dans le code, et le branchement lui-même est rapide
Le vrai problème, c’est que les lanes SIMD ne peuvent pas chacune partir dans une branche différente ; le compilateur émet donc souvent le code des deux côtés et masque le résultat selon la condition, au lieu de faire un branchement
Ainsi, les calculs fondés sur les entrées du shader, les sommets, les indices de compute shader, etc., ne branchent pas réellement et s’exécutent séquentiellement avec masquage
Dans l’exemple de l’article aussi, les deux valeurs de l’opérateur
?sont calculées, et c’est généralement pareil pour les conditions portant sur des valeurs SIMDIl peut exister un branchement raccourci permettant de sauter rapidement un calcul quand toutes les lanes ont la même valeur, mais en général les deux côtés, vrai et faux, sont calculés
Seules les conditions fondées sur des registres scalaires, c’est-à-dire les constantes de shader ou les valeurs uniform, produisent de vrais branchements, et ces branchements sont très rapides
Par exemple, l’instruction CMOV a été introduite avec le cœur P6 en 1995
Les branchements coûtent cher aussi sur les architectures scalaires, et le compilateur essaie au mieux de décider quand employer une stratégie alternative
Il se trompe parfois, mais pas si souvent
Le déplacement conditionnel est le comportement par défaut, et le vrai branchement n’est qu’une optimisation de performance possible quand toute la workgroup prend uniformément la même direction
a = f(z); b = g(z); v = x > y ? a : b;Si le coût des appels à
f()etg()est relativement élevé, alors choisir entre du code conditionnel ou calculer les deux puis sélectionner le résultat devient un compromisCe n’est pas un simple choix, et c’est au compilateur de trancher
On pourrait classer toutes les fonctions du code comme branchantes / non branchantes ; une fonction marquée non branchante devrait compiler les
ifen déplacements conditionnels et ne pouvoir appeler que d’autres fonctions non branchantesUne bonne partie du mythe selon lequel « les branchements sont lents sur GPU » vient du fait qu’à l’époque de la PlayStation 3, ils l’étaient réellement
La PS3 embarquait un GPU NVIDIA RSX et, de mémoire, la documentation annonçait un branchement en 6 cycles, mais dans les mesures réelles c’était toujours plus lent
C’était vrai même pour des branchements parfaitement cohérents où tous les threads d’un warp suivaient le même chemin, et les branchements incohérents étaient encore plus lents car l’instruction
IFEHcoûtait 6 cycles et le GPU devait en plus exécuter les deux branchesÀ mon avis, le mythe toujours vivant selon lequel « les branchements GPU sont lents » vient de là
De nos jours, les branchements GPU, surtout les branchements cohérents, sont plutôt peu coûteux
Le surcoût du mécanisme de branchement a peut-être diminué aujourd’hui, mais la contrainte physique selon laquelle le débit de chaque branche baisse en proportion du nombre de threads actifs reste inchangée
Si les deux branches sont exécutées et que leur longueur d’instructions est comparable, la performance moyenne des deux côtés tombe au minimum de moitié
C’est pour cela que l’idée selon laquelle les branchements sont lents sur GPU perdure, et qu’elle reste vraie en pratique
Si possible, cela vaut la peine de faire davantage d’efforts pour reformuler le problème sans branchement
La raison principale d’éviter les branchements dynamiques n’est pas tant que le branchement lui-même soit intrinsèquement lent, mais plutôt cela
Ce type d’optimisation pour éviter les branches a déjà été efficace
Je me souviens avoir fait du profiling sur Xbox 360 et sur de vieux GPU intégrés Intel, mais aujourd’hui il vaut mieux éviter de faire ça
L’extraction de bits et d’autres opérations entières sont similaires
Autrefois, les émuler avec des calculs en virgule flottante était plus rapide, mais désormais tous les GPU disposent d’opérations entières rapides
Par exemple, dans l’ISA RDNA2 utilisée par la PS5 et les Xbox Series S|X, on dirait qu’on ne voit que des instructions scalaires 32 bits pour les entiers
[0] https://www.amd.com/content/dam/amd/en/documents/radeon-tech...
Le code présenté est déjà sans branche
Ceux qui donnent ce conseil semblent juger qu’un code est branché simplement parce qu’il contient dans la source une construction qui ressemble à une condition, et pensent l’éviter via une optimisation
Cet article est aussi lié : https://medium.com/@jasonbooth_86226/branching-on-a-gpu-18bf...
« Si vous demandez à Internet comment écrire des branches sur un GPU, on pourrait croire que c’est comme ouvrir les portes de l’enfer et laisser entrer les démons. Qu’il faut les éviter à tout prix, et qu’on peut les contourner avec de drôles d’astuces mathématiques comme l’opérateur ternaire ou
step(). La plupart de ces conseils sont, au mieux, dépassés, et souvent tout simplement faux. Remettons les choses au clair. »Les processeurs changent, et les compilateurs aussi
Si ce genre de détails compte, le mieux est de distribuer plusieurs variantes et de choisir à l’exécution la version la plus rapide
Je l’ai déjà dit plusieurs fois, mais il m’est arrivé de supprimer de l’assembleur écrit à la main et de le remplacer par du C banal ou quelque chose d’approchant, pour obtenir un code bien plus rapide
Peut-être que cet assembleur était plus rapide il y a 10 ou 20 ans, mais la situation a changé aujourd’hui
Je ne connais pas vraiment de jeu ou de moteur qui fasse cela en pratique
En principe, ce serait peut-être possible
La plupart des API comme D3D, GL ou Vulkan exposent des compteurs de performance, et même si leur fiabilité varie selon le fournisseur, on pourrait créer une scène de test représentative et la rejouer plusieurs fois pour mesurer les optimisations
Mais beaucoup de jeux utilisent des scènes générées dynamiquement et des shaders générés dynamiquement, donc le nombre de combinaisons à tester peut devenir un obstacle
Il faudrait peut-être demander à l’utilisateur d’attendre la fin du benchmark
Si l’on dispose du matériel, on pourrait aussi mesurer à l’avance sur plusieurs générations de GPU de chaque fournisseur et ne coder en dur que les décisions importantes, mais je ne connais pas vraiment d’infrastructure existante de ce type
Il intercepte les shaders des jeux et les remplace par des shaders personnalisés optimisés par NVIDIA
C’est pour cela qu’on voit dans les notes de version des pilotes NVIDIA des mentions du type « optimisation pour le jeu X, exécution 40 % plus rapide »
On ne peut pas passer un temps infini sur chaque shader
Il faut profiler sur le matériel visé, et si l’approche choisie devient plus lente sur un processeur hypothétique du futur, tant pis
Il faut espérer que ce processeur sera suffisamment rapide pour que ce ne soit pas un problème
Il me semble que l’erreur et la confusion que cet article essaie de corriger se répètent aussi ici
L’article n’affirme pas que les branches conditionnelles sont gratuites
À mes yeux, ce n’est même pas un article sur le coût en performance du code branché
Son propos est que la logique conditionnelle de la forme présentée n’est pas compilée en code à branche conditionnelle
Et qu’il ne faut pas continuer à diffuser des conseils nocifs consistant à masquer de force toute expression conditionnelle visible
Quant au vrai code branché, il est évident que son exécution est plus complexe
Il n’existe pas de branche gratuite, et dans des limites raisonnables, éviter les branches a de bonnes chances d’accélérer n’importe quel code
Heureusement, le code d’origine était déjà sans branche
Comme toujours, il n’existe pas de critère universel pour dire si une optimisation en vaut la peine
[0] Ici, le mot « visible » est important. Il s’agit des cas où l’on ne s’intéresse pas au code généré, mais seulement au fait que le code source ne ressemble pas à une condition
[1] Bien sûr, ce n’était pas juste de la chance. J’imagine que quelqu’un a envoyé à IQ une « amélioration » du code shader qui paraissait évidente, mais qui était fausse
Dans ce cas, pourquoi le compilateur n’est-il pas assez intelligent pour reconnaître que la version « optimisée » est le même code ?
Ne devrait-il pas comprendre
step()et pouvoir optimiser séparément les casstep() = 0.0etstep() == 1.0?On pourrait au moins supprimer une multiplication, donc cela semblerait en général toujours bénéfique, même si cela se transformait ensuite en chargement/stockage conditionnel ou en autre chose
Il est tout à fait possible que certains compilateurs effectuent ce genre d’optimisation dans certains cas, mais il est aussi clairement possible d’écrire une version que le compilateur ne comprend pas
La plupart des optimisations ont lieu côté pilote, et lorsqu’elles prennent trop de temps, cela se manifeste sous forme de saccades de compilation de shader
Je ne peux pas dire si cette optimisation précise est réellement effectuée ou non ici, mais c’est toujours un facteur à prendre en compte
La raison pour laquelle la version « optimisée » du problème est plus lente est que la fonction
step()est en réalité implémentée de cette façon :float step( float x, float y ) { return x < y ? 1.0 : 0.0; }Comment savoir si une fonction OpenGL appelle un primitive GPU, ou si elle est émulée ?
Je l’ai souvent fait avec des shaders HLSL, et j’ai beaucoup appris sur le jeu d’instructions virtuel
Par exemple, c’est intéressant de voir que le GPU a une instruction
sincos, mais que les fonctions trigonométriques inverses sont émulées à la compilationSi la performance est importante, il peut être nécessaire de le savoir
Mais le simple fait que
stepsoit implémentée comme une fonction de bibliothèque basée sur la condition ci-dessus, et non comme une instruction dédiée, ne dit pas à lui seul grand-chose sur les performances par rapport à une instruction dédiée ; inutile donc de trop s’obséder sur l’implémentation elle-mêmeSi c’est l’architecture GPU qui vous intéresse, regardez le désassemblage, le code des pilotes open source, LLVM et la documentation ISA
Chaque fois que j’ai regardé un shader décompilé, cela ressemblait globalement à ce qu’on imagine en C
Une spécification comme OpenGL définit le comportement de nombreuses fonctions intégrées, et l’implémentation satisfait cette spécification à l’aide d’instructions d’assembleur standard
Il suffit de chercher un site en ligne qui décompile pour plusieurs architectures
En général, il n’est même pas nécessaire de savoir comment une fonction intégrée est implémentée, ni de s’en soucier
Si vous vous en souciez, c’est probablement que vous pensez à l’optimisation, et dans ce cas la réponse est : « mesurez et vérifiez ce qui est meilleur »
Dans le sens que j’avais appris, un
ifest un branchementAu niveau du code machine, comme le flot de contrôle est choisi à l’exécution, un saut conditionnel est par définition un branchement
Utiliser
step()ne revient pas à transformer la logique en arithmétique, mais simplement à cacher la logique à l’intérieur d’un appel de fonction de bibliothèqueLe fait que
step()soit une fonction intégrée ou une fonction apparaissant dans un article de mathématiques n’y change rienEn mathématiques aussi, la définition de
step()est littéralement conditionnellePour vraiment optimiser sans condition, il faut choisir une fonction continue proche du résultat voulu et ajuster ses paramètres pour s’en approcher autant que possible
En général, on choisit un polynôme, on applique une méthode d’approximation itérative standard, puis on obtient une fonction
f(x)sans branchement, composée uniquement d’additions, de multiplications et de constantes « étrangement spécifiques »Je comprends mal l’insistance de l’auteur à affirmer qu’un déplacement conditionnel n’est pas un « branchement »
Si
abs()n’est pas une instruction GPU mais devient un modificateur d’instruction gratuit, c’est grâce à la représentation des entiers en complément à deux et à la représentation flottante IEEE-754, qui permettent de traiter le bit de signe comme le bit de poids fortDonc
abs()se résume à forcer ce bit de poids fort à 0, ou à le masquer lors de l’instruction de lectureEn revanche,
step(), n’importe quel opérateur ternaire et, à ma connaissance, les instructions de déplacement conditionnel ne sont pas de ce type de cas particulierDes choses comme
abs(),sqrt()ou les fonctions trigonométriques de base relèvent plus ou moins de la culture générale, et pour le reste on peut se demander si c’est vraiment importantstep()doit forcément contenir une condition quelque part, et que vous la gériez vous-même, que vous la laissiez à une bibliothèque ou au matériel ne change pas sa nature fondamentaleJe suis déjà tombé dans ce piège
Claude ou ChatGPT proposent aussi parfois cela comme une optimisation
Mais à chaque fois que j’ai mesuré, les performances se sont dégradées, parfois de façon assez importante
Les LLM ne font que répéter ce qui se trouve dans leur corpus d’entraînement
Si la majorité d’Internet recommande des choses fausses comme cette « optimisation » par déplacement conditionnel, les LLM feront la même recommandation