L’usage de Compute Shaders sur Unity : un gain de performance violent

Ce tutoriel fait partie d’une série d’articles sur le genre du shoot’em up, retraçant la création d’un outil de développement exhaustif sous la forme d’une extension d’Unity.
Partie 1 (analyse du genre et design de l’outil) : lien
Partie 2 (problématiques techniques) : lien

Nous allons ici mettre en place, étape par étape, le système mentionné dans mon article précédent.

Le plan qu’on va suivre :
• Phase 1 : fonctionnement de base du Compute Shader
• Phase 2 : récupérer nos résultats dans une Texture ou un Buffer
• Phase 3: les tricks permis (et imposés) par le multithreading

Phase 1 : fonctionnement de base du Compute Shader

On est sur Unity, et on va travailler ici :

La raison pour laquelle un Compute Shader est aussi efficace, c’est parce que le GPU parallélise les opérations. Le principe du multithreading est tel que les calculs sont effectués de façon simultanée par des centaines ou des milliers de threads, d’entités qui ne communiquent pas les unes avec les autres (ou presque). Tout notre challenge va consister en la coordination de ces threads.
Pour le reste, il s’agit d’un script normal en langage HLSL.

Warning : Le guide qui suit va prendre pour acquises certaines bases de programmation. Ceci n’est pas un tuto sur le HLSL ni sur le C#, et par conséquent il s’adresse à un public un minimum expérimenté !

En revanche, malgré cet avertissement, la syntaxe du HLSL est très intuitive et facile à prendre en main pour une personne “originaire” du C#. La seule différence gênante est qu’il est fréquent que les noms particuliers de variables ou de certains mots-clés aient un effet, qui n’est explicité nulle part dans un script, et ne nécessite pas de déclaration. Un bon développeur de shader aura une connaissance quasi-encyclopédique de ce qu’on appelle les semantics, qui sont des ajouts après les déclarations de variable permettant d’instantanément leur assigner une valeur et une utilité.

Revenons à notre question du multithreading. Le code par défaut d’un Compute Shader ressemble à ceci :

(Ce kernel, qu’Unity propose par défaut, dessine un triangle de Sierpinski sur une texture)
  • Évacuons immédiatement la première ligne :
    #pragma kernel <function name> signifie simplement qu’on déclare, à l’avance, quelles fonctions vont être utilisées en tant que kernels, c’est-à-dire appelées par le CPU à un instant précis de notre choix.
  • La seconde ligne est une déclaration de variable. Devant le type Texture2D<float4>, on ajoute “RW” qui signifie “Read-Write”. Cette apposition précise que l’on va pouvoir modifier l’élément au cours de nos calculs : en l’occurrence, changer les pixels pour y faire des dessins.
    (Note pour toute personne découvrant le HLSL : “float4” est l’équivalent de “Vector4” sur Unity, on peut donc y faire correspondre une couleur de pixel)

La suite, c’est notre kernel. On y fait tout ce qu’on veut en termes de calculs ! Je ne détaillerai donc pas le corps de la fonction ici puisqu’il dépend de ce que chacun veut en faire. Par contre, il est nécessaire de s’attarder sur les deux éléments qui sont propres au fonctionnement du Compute Shader :

  • L’attribut [numthreads(x,y,z)]. Je parlais de multithreading et de parallélisme plus haut : ici, on précise combien de threads simultanés vont être mobilisés pour exécuter la fonction. C’est un nombre en trois dimensions, ce qui n’a d’utilité que dans le but de faciliter l’organisation de nos calculs (faciliter pour nous, l’être humain derrière, et non pour la machine). Que je choisisse par exemple numthreads(16,16,2) ou numthreads(32,4,4) ne fait aucune différence au fait qu’un total de 512 threads seront mobilisés.
    (16*16*2 =32*4*4)
Je recopie simplement l’image précédente pour vous épargner le scrolling :)

À noter une légère nuance ici, ce nombre (numthreads) représente non pas le nombre de threads mobilisés par le kernel, mais le nombre de threads mobilisés par groupe de threads. Un groupe de threads, ou threadgroup, c’est quoi ? Simplement, le CPU, en appelant le kernel via la fonction Dispatch(), va instancier un certain nombre de threadgroups (que l’on va définir nous-mêmes) qui contiennent chacun <numthread> threads. S’il y a 1024 threads par groupe et 1024 groupes, on en aura exactement la quantité nécessaire pour qu’une texture de 1024*1024 soit traitée à raison d’un thread par pixel. Ceci est extrêmement pratique, à condition de pouvoir identifier chaque thread pour l’assigner au pixel de notre choix. Ce qui nous amène au deuxième élément important…

  • L’argument de type uint3 et son semantic SV_DispatchThreadID. Cet argument passé automatiquement permet de repérer l’index exact du thread, unique, qui est en train d’effectuer un calcul. Cela est possible car, comme on va le voir du côté CPU, le nombre de threadgroups est également un nombre en trois dimensions. Par conséquent, pour un X, un Y et un Z donné, (ou juste un X et un Y si je laisse Z égal à 1), le nombre id.xy fera référence à un et un seul thread, donc un et un seul pixel !
Le schéma de Microsoft permet de bien comprendre l’organisation des threads. (Source)

C’est le semantic apposé à côté qui spécifie la valeur que représente notre argument. La variable “id” repère ainsi le thread parce qu’elle porte le semantic SV_DispatchThreadID. Il existe plusieurs autres semantics que l’on peut choisir d’utiliser pour repérer un thread ; par exemple, SV_GroupThreadID repère les threads au sein de leur groupe mais pas au-delà. SV_GroupID repère le groupe mais pas le thread. SV_GroupIndex les repère par un simple uint, sans dimension. Et la liste d’exemples n’est pas finie. La documentation officielle à ce sujet est assez explicite.

À présent, il ne nous reste plus qu’à retourner du côté CPU pour voir comment appeler et utiliser le Compute Shader. Via cette fameuse fonction Dispatch() mentionnée plus haut.

(Si les quatre int ne sont pas déclarés plus haut, c’est juste pour garder compact cet exemple. Il y a toujours Start ou Awake pour initialiser ce genre de choses !)

La fonction Dispatch prend quatre arguments, tous de type int :
• Le premier est le plus important, c’est l’index identifiant le kernel, donc la fonction, que l’on veut appeler dans le shader. On doit donc récupérer cet index au préalable avec FindKernel, au début de notre programme.
• Les trois autres correspondent au nombre de threadgroups à mobiliser. Ils sont trois car il s’agit d’un nombre à trois composantes, tout comme numthreads dans le shader. Sur l’exemple ci-dessus, j’appelle 16*16*4 = 1024 groupes.

Bonnes pratiques à avoir avec le Dispatch : (Q/A)

Combien de threadgroups et de threads ai-je intérêt à appeler ?
• Appeler des threadgroups est le travail du CPU. Par groupe, appeler des threads est le travail du GPU. Par conséquent, plus le nombre de threadgroups (argument du Dispatch) est élevé, plus le programme sera lent. On cherche donc plutôt à maximiser le nombre de threads par groupe (numthreads).
Dans ce cas, pourquoi ne pas avoir un seul groupe de millions de threads ?
• Le Shader Model actuel (les normes/contraintes liées à la programmation de shaders) supporte un maximum de 1024 threads par groupe. Shader Model 4 (le précédent) en supportait 768.

Dans le principe, c’est tout ! À présent j’ai un shader qui effectue des calculs dans le vide et j’ai maintenant besoin de spécifier une texture, ou un buffer, qui va récupérer ces résultats.

Phase 2 : Récupérer nos résultats

Reprenons l’exemple fourni par Unity :

On y trouve une variable de type Texture2D (qui n’est en réalité qu’un tas de float4), nommée Result, sur laquelle sont faits tous les calculs. Depuis le CPU, on va donner à Result une valeur, en lui passant la référence d’une RenderTexture créée pour l’occasion.

Au Start, on crée une texture, on l’initialise, et on y autorise l’écriture. Après usage du shader, la texture peut servir n’importe où.

La fonction SetTexture est ici la plus importante, c’est elle qui assigne au shader ses variables. On doit également y spécifier l’index du kernel où cette texture va être appelée, pour le reste la syntaxe est identique à un material.SetTexture(). D’ailleurs, pour d’autres usages on retrouvera aussi myComputeShader.SetInt() ou .SetFloat().

Avec ce strict minimum, il est donc possible de restituer visuellement ce que produit le kernel sous la forme d’une texture.

Rappelons-nous maintenant que notre but premier avec cet outil était non pas de forcément rendre une image, mais alléger la charge de travail du CPU ; ce qui signifie qu’au lieu de communiquer via une texture, on aimerait pouvoir donner et prendre au GPU n’importe quel type de données. Des tableaux, des valeurs organisées comme bon nous semble, des structs personnalisées… C’est à ce type d’échange que sert l’objet Buffer. Là où le conteneur RWTexture2D ne permet que le stockage de float4 (bien que ce soit déjà pas mal, c’est contraignant), le conteneur RWStructuredBuffer permet le stockage de données du type de notre choix.

On utilise un type de structure personnalisé, MyStruct, déclaré juste au-dessus du buffer et du kernel.

Du côté Unity, ce même objet va porter le nom de ComputeBuffer, et son usage demande une rigueur supplémentaire. Voici le processus complet en plusieurs étapes :

  • Lorsque l’on le déclare, on doit spécifier les paramètres size (nombre d’éléments contenus, à la manière d’un tableau) et stride (poids, en bytes, d’un élément). Pour rappel une variable numérique pèse 4 bytes (32 bits), ainsi un float3 prendra par exemple 12 bytes. La struct de mon exemple ci-dessus prendra 4*(2+3+16) = 84 bytes. Et ainsi de suite.
  • Bonne pratique #1 : quitte à ajouter des variables supplémentaires (dummies) inutiles à notre struct, s’arranger pour que la stride tombe sur un multiple de 32 accélérera le calcul.
  • Bonne pratique #2 : on déclare deux fois la struct contenue par le buffer. Une fois dans le script en C#, une fois dans le Compute Shader. À l’identique. Pour que la correspondance soit assurée, elles doivent porter le même nom, les mêmes attributs, de même nom, dans le même ordre. Le type Vector3 correspond au type float3, le type Matrix4x4 correspond au type float4x4, et ainsi de suite.
  • On remplit le buffer avec une fonction SetData() qui y copiera le contenu d’un array adéquat (bon type, bonne taille).
  • On l’assigne dans le Compute Shader avec SetBuffer() (similairement à SetTexture()). Le Compute Shader est maintenant prêt à l’appel de Dispatch.
  • Après le Dispatch, on récupère les données du buffer (qui viennent d’être traitées) avec GetData() pour les copier vers un array adéquat, indiqué en argument.
  • Immédiatement après usage du buffer, on s’en débarrasse pour qu’il puisse être collecté par notre Garbage Collector, avec Dispose() ou Release().

Le script ci-dessous résume chacune de ces étapes.

C’est tout pour le fonctionnement global du Compute Shader et du recueil de nos informations sous la forme de notre choix. En revanche, si nos différents éléments sont légèrement interdépendants (ce qui est fréquent), certains problèmes dus à la simultanéité des calculs vont commencer à se poser. Il faut alors se pencher vers certaines fonctions plus avancées.

Phase 3 : ce que permet et impose le multithreading

Mise en situation : je souhaite récupérer certains de mes résultats dans un tableau ordonné. Je peux passer au shader un buffer vide que je vais remplir avec mes éventuels résultats. Chacun de mes 1048576 threads peut écrire dans le buffer mais cette fois je ne contrôle pas leur ordre de passage. Je vais donc immanquablement me retrouver avec des situations de conflit où deux threads cherchent à modifier une même variable au même instant, et certaines valeurs seront écrasées. On ne peut même pas garantir qu’entre deux lignes consécutives, les valeurs qui sont lues/écrites n’aient pas été changées… C’est un type d’erreur qui porte le nom de race condition, propre aux situations de multithreading.

Plus d’infos à ce sujet ici

Le langage HLSL met à disposition certaines fonctions mathématiques qui permettent de contourner cette limite. L’idée est de “verrouiller” une variable pour s’assurer que, lors de sa modification par un thread, aucun autre n’est en train d’y toucher ; il attendra qu’elle se libère avant de procéder à son opération.

Ainsi, InterlockedAdd() remplace l’addition de manière à ce que l’opération ne soit pas effectuée simultanément par deux threads. Ainsi, si elle est utilisée pour incrémenter une valeur, la valeur en question sera différente dans chaque thread.

On trouve aussi d’autres fonctions au comportement similaire pour remplacer les fonctions mathématiques associées : InterlockedMin, InterlockedMax, InterlockedAnd, InterlockedOr…

Attention car un usage abusif d’un tel procédé finira par endommager nos performances globales. Ceci dit cet impact n’est pas énorme étant donné que seule la ligne portant la fonction Interlocked est affectée par ce fonctionnement. Et cela nous amène sur un dernier problème : entre le moment où l’InterlockedAdd est appelé sur une variable et le moment où l’on veut utiliser sa valeur, c’est-à-dire une ligne plus tard, elle peut avoir été changée par un autre thread. Il nous faut étudier la syntaxe exacte de la fonction, propre au HLSL, pour comprendre comment exploiter le résultat :

Source : docs.microsoft.com
  • Le premier argument (la valeur que l’on cherche à modifier) est un input, la valeur sera directement modifiée. Comme si, en C#, le mot-clé ref avait été utilisé.
  • On peut insérer un troisième argument, auquel cas celui-ci est un output, il stocke le résultat de notre opération à l’instant T. Pour autant, la fonction ne retourne rien. Comme si, en C#, le mot-clé out avait été utilisé.
On utilise ces deux lignes au lieu d’un “someSharedValue++”, et on utilise la valeur de myOutput.

Comme il s’agit d’une variable différente, déclarée à l’intérieur du kernel, elle ne sera pas mise à mal par les autres threads. Une dernière note à propos du court exemple ci-dessus : l’int que j’ai nommé someSharedValue, pour que sa valeur soit stockée dans une mémoire commune à chaque thread, doit être déclaré avec un mot-clé particulier. Ce mot-clé est groupshared.

Mais il reste toujours plus rigoureux de créer un buffer pour contenir ce type de variable, même s’il ne s’agit que d’un simple int.

Conclusion

J’espère que ce guide aura permis de démystifier en partie le principe de l’usage des Compute Shaders. Il faut absolument retenir que ce type de processing permet de faire des économies de performances phénoménales et augmente énormément notre budget de calcul pour toutes situations de gameplay. La connaissance de cet outil doit permettre d’abolir les problèmes liés à la quantité brute d’opérations dans un jeu. Je souhaite à tout le monde bon courage dans le développement d’applications ou de jeux tirant parti d’une telle technologie !