Unity est un moteur de jeu extrêmement utilisé actuellement, notamment pour ses outils d'édition complets et conviviaux. Cependant, le moteur doit suivre l'évolution des machines : depuis une dizaine d'années, les processeurs ne montent plus en fréquence, mais plutôt en nombre de cœurs. En d'autres termes, pour exploiter la nouvelle performance disponible, les jeux doivent exécuter leur code sur différents cœurs, à travers différents fils d'exécution. Pourtant, depuis le temps que la technologie est disponible, peu de jeux y arrivent vraiment. De fait, les problèmes pour l'écriture de tel code sont nombreux : il faut s'assurer que deux fils ne tentent pas d'écrire en même temps dans la même variable, par exemple. Ceci implique que l'un des deux fils doit alors attendre l'autre : si le code est légèrement mal écrit, il n'est pas impossible qu'ils s'attendent mutuellement à l'infini (une situation nommée étreinte fatale).
Pour éviter ces inconvénients, il est possible de suivre quelques séries de règles. Néanmoins, les développeurs ont peu d'outils pour s'assurer qu'elles sont suivies : un code qui ne les respecte pas continuera de compiler, pourrait fonctionner nonante-neuf fois sur cent. C'est une des raisons pour lesquelles Unity travaille sur un nouveau compilateur C#, dénommé Burst : le non-respect de ces règles provoquera une erreur de compilation.
Pour y arriver, le code doit être écrit comme une collection de tâches à effectuer. Chacune de ces tâches effectue quelques transformations sur des données, mais n'a aucun effet de bord (en suivant les préceptes de la programmation fonctionnelle) indésirable. Le programmeur doit spécifier les zones de mémoire auxquelles il peut accéder en lecture seule et celles où il souhaite lire et écrire des données : le compilateur s'assurera qu'il n'utilise rien en dehors de ces déclarations. Elles permettent de gérer une très grande partie des besoins en calcul parallèle. Ensuite, un ordonnanceur détermine la meilleure manière d'exécuter ces tâches, en temps réel, grâce à ces informations supplémentaires : il peut s'assurer qu'aucune tâche ne viendra écrire des données là où une autre tente de lire ou d'écrire, par exemple. Ce mécanisme augmente fortement la sécurité du code écrit, bon nombre de défauts sont remarqués peu après l'écriture du code ; il est aussi impossible de créer une course de données ou une étreinte fatale, les résultats sont entièrement déterministes, peu importe le nombre de fils d'exécution utilisés pour gérer les tâches ou le nombre d'interruptions d'une tâche.
Burst n'a pas que cet objectif de faciliter la programmation parallèle : il est aussi utilisé dans les parties les plus critiques (d'un point de vue performance) du code de Unity. Jusqu'à présent, ces endroits étaient écrits en C++, mais les compilateurs actuels ne sont pas entièrement satisfaisants. En effet, si un développeur souhaite qu'une boucle soit vectorisée, il n'a aucune garantie que le compilateur le fera, à cause de changements pourtant a priori sans impact (pour une addition entre deux vecteurs, par exemple, le compilateur doit prouver formellement que, dans tous les cas possibles et imaginables, les deux vecteurs ne correspondent pas aux mêmes adresses en mémoire). Et encore, il faut que tous les compilateurs utilisés pour Unity sur les différentes plateformes visées effectuent correctement cette vectorisation — sans oublier qu'une mise à jour du compilateur peut aussi être à l'origine d'une baisse de performance.
Pourquoi Burst et pas un compilateur existant ? La performance est un point critique : si un boucle n'est pas vectorisée, ce n'est pas simplement dommage (ce que la plupart des compilateurs se disent), c'est un vrai problème qui doit être corrigé rapidement. De plus, le binaire généré doit être sûr : les erreurs de dépassement de tampon et de déréférencements hasardeux doivent être découvertes au plus tôt, avec de vrais messages d'erreur plutôt que des comportements indéfinis (à l'origine de nombreux problèmes de sécurité). Finalement, il doit gérer toutes les architectures sur lesquelles Unity existe : changer de langage parce qu'on développe un jeu pour console, PC ou mobile n'a pas de sens. Ce compilateur devrait effectivement être utilisé tant pour le moteur que les jeux.
Ces besoins posés, il faut encore choisir le langage d'entrée de ce compilateur : une variante ou un sous-ensemble du C, du C++, de C# ou encore un nouveau langage ? Le nouveau langage semble à bannir, pour éviter de devoir former des gens à ce nouvel outil ; C# a la préférence du point de vue des utilisateurs, puisqu'il est déjà utilisé par eux : le moteur de jeu serait alors codé dans le même langage que les jeux eux-mêmes. De plus, C# dispose déjà d'un très grand écosystème (des EDI, des compilateurs ouverts). Au contraire, C++ souffre toujours de son héritage du C, avec des inclusions pas toujours évidentes à déterminer et des temps de compilation énormes — des défauts que C++20 vient corriger en partie —, malgré son obsession sur la performance (une chose que C# n'a pas).
La décision a été prise de partir sur C#, mais en éliminant une série d'éléments qui nuisent à la performance : la bibliothèque standard, en bonne partie, la réflexion, le ramasse-miettes et les allocations (ce qui revient à interdire l'utilisation de classes, seules les structures restent autorisées), les appels virtuels. Autant dire qu'on se retrouve, à certains points de vue, aussi bien outillés qu'en C (avec les possibilités d'oubli de désallouer la mémoire qui n'est plus nécessaire) — mais ce sous-ensemble du langage n'est vraiment adapté qu'aux parties vraiment importantes d'un point de vue performance, pas à la globalité du moteur. Ce sous-ensemble est nommé High-Performance C# (ou encore HPC#).
Burst ne fonctionne pas vraiment comme un compilateur complet : il ne prend pas en entrée une énorme quantité de code, mais seulement le point d'entrée vers une boucle cruciale. Il se limite à la compiler comme une fonction, ainsi que tout ce qu'elle appelle (puisque les appels virtuels sont interdits, les fonctions appelées sont faciles à déterminer). Le niveau d'optimisation est extrêmement élevé : puisque Burst se focalise sur certaines portions de code, il peut y passer du temps. Notamment, il n'existe presque plus un seul appel de fonction en sortie : importer tout le code permet bien souvent d'éliminer une série de vérifications d'usage en début de fonction. Puisque le seul type de tableau possible est NativeArray et que ces tableaux ne permettent pas de faire des références à d'autres tableaux, deux NativeArray seront toujours distincts en mémoire : la vectorisation peut toujours se faire. Dans le futur, Burst pourra utiliser le même niveau de connaissance sur les fonctions mathématiques utilisées : le sinus d'un angle est presque égal à cet angle s'il est très petit, c'est-à-dire qu'on peut alors remplacer sin(x) par x sans grande perte de précision (ou par un développement en série de Taylor d'un plus grand ordre, si l'angle est un peu plus grand).
La première itération de Burst, avec HPC# et le système de tâches, est arrivée avec Unity 2018.1. Le code généré est parfois plus rapide que la version précédente en C++, parfois plus lente — mais les développeurs sont confiants qu'ils arriveront toujours à au moins atteindre le même niveau de performance que C++. Un élément crucial doit aussi être pris en compte : combien de temps et d'énergie a-t-il fallu pour atteindre ce niveau de performance ? Le code qui détermine les faces visibles d'un objet (culling) a été réécrit avec HPC# : alors que le code C++ était très complexe pour s'assurer qu'il soit toujours vectorisé (sans écrire d'assembleur spécifique à une plateforme), le code HPC# est quatre fois plus court… pour la même performance. Tout le reste du moteur fera la transition vers HPC#, un jour ou l'autre, tant que le bout de code concerné est critique d'un point de vue performance : le code HPC# est souvent plus facile à optimiser, le langage rend plus difficile l'écriture de code faux.
Source : On DOTS: C++ & C#.
Et vous ?
Qu'en pensez-vous ?
Partager