Écrire un moteur de rendu Vulkan performant

Avant-propos

Ceci est une traduction de l’article de Arseny Kapoulkine du 27 février 2020 : Writing an efficient Vulkan renderer. Vous pouvez retrouver sa chaîne Youtube ici.

Introduction

En 2018, j’ai écrit « Écrire un moteur de rendu Vulkan performant » pour le livre GPU Zen 2, qui a été publié en 2019. Dans cet article, j’ai essayé de rassembler autant d’informations que possible sur les performances de Vulkan — au lieu de se concentrer sur un aspect ou une application particulière, il tente de couvrir un large éventail de sujets, permettant aux lecteurs de comprendre le comportement des différentes APIs sur du matériel réel et fournir un panel d’options pour chaque problème à résoudre.

Au moment de la publication de cet article, l’édition Kindle du livre est disponible pour 2,99 € sur Amazon — c’est moins cher qu’une tasse de café et ça vaut vraiment le coup1. Il contient de nombreux articles intéressants sur le rendu d’effets et la conception.

Vous avez ici la copie complète et gratuite de l’article — j’espère qu’elle aidera les développeurs graphiques à comprendre et utiliser Vulkan au maximum de ses capacités. L’article a été légèrement modifié pour mentionner les améliorations de Vulkan 1.1/1.2 quand c’était nécessaire — heureusement, peu de choses impactant les performances ont changé au cours des deux dernières années, le contenu devrait donc toujours être d’actualité.

Bonne lecture !

Résumé

Vulkan est une nouvelle API graphique, explicite et multi-plateforme. Elle introduit beaucoup de nouveaux concepts pouvant rebuter les développeurs graphiques les plus aguerris. Vulkan se focalise sur les performances — cependant, atteindre de bonnes performances nécessite une connaissance approfondie de ces concepts et de la manière de les appliquer efficacement, ainsi que de leur implémentation dans chaque pilote. Cet article abordera des thèmes tels que l’allocation de la mémoire, la gestion des sets de descripteurs, l’enregistrement des command buffers, les barrières de pipeline, les passes de rendu et discutera des moyens d’optimiser les performances CPU et GPU des moteurs de rendu Vulkan bureau/mobiles d’aujourd’hui, ainsi que ce qu’un moteur de rendu Vulkan pourrait faire différemment à l’avenir.

Les moteurs de rendu modernes deviennent de plus en plus complexes et doivent prendre en charge de nombreuses API graphiques, chacune ayant des niveaux d’abstraction matérielle différents et des ensembles de concepts disjoints. Cela rend parfois difficile la prise en charge de toutes les plates-formes avec le même niveau d’efficacité. Heureusement, pour la plupart des tâches, Vulkan propose plusieurs options allant de la simple réimplémentation de concepts venant d’autres API de façon plus précise, car codé spécifiquement pour les besoins du moteur, à la difficile refonte de gros systèmes pour les rendre optimaux avec Vulkan. Nous essaierons de couvrir les deux extrêmes quand cela est possible — au final, c’est un compromis entre l’efficacité maximale d’un système compatibles Vulkan et les coûts d’implémentation et de maintenance propre à chaque moteur. De plus, l’efficacité dépend souvent de l’application — les conseils de cet article sont génériques et c’est en prenant des décisions d’implémentation à la lueur de résultats de profilage d’une application précise sur une plateforme précise qu’on obtient les meilleures performances.

Cet article suppose que le lecteur est familiarisé avec les bases de l’API Vulkan et souhaite mieux les comprendre et/ou apprendre à utiliser l’API efficacement.

Gestion de la mémoire

La gestion de la mémoire reste un sujet extrêmement complexe, et il l’est encore plus dans Vulkan en raison de la diversité de configurations du tas des différents matériels. Les API antérieures adoptaient un concept « centré sur les ressources » — le développeur n’a pas de concept de mémoire graphique, mais uniquement celui de ressource graphique, et chaque pilote est libre de gérer la mémoire des ressources en fonction des indicateurs d’utilisation des API et d’un ensemble d’heuristiques. À l’inverse, Vulkan, oblige à réfléchir à la gestion de la mémoire dès le départ, car vous devez allouer manuellement de la mémoire pour créer des ressources.

Une première étape parfaitement raisonnable consiste à intégrer VulkanMemoryAllocator (désormais abrégé en VMA), une bibliothèque open-source développée par AMD qui résout certains détails de gestion de la mémoire pour vous, en fournissant un allocateur de ressources à usage général au-dessus des fonctions Vulkan. Même si vous utilisez cette bibliothèque, certaines considérations de performances s’appliquent toujours ; le reste de cette section passera en revue les pièges de gestion de la mémoire, sans supposer que vous utilisez VMA ; mais tous les conseils s’appliquent également à VMA.

Sélection de la mémoire de tas

Lors de la création d’une ressource dans Vulkan, vous devez choisir un tas à partir duquel allouer de la mémoire. Le dispositif Vulkan expose un ensemble de types de mémoire dont chacun a des indicateurs qui définissent le comportement de cette mémoire et un index de tas définissant la taille disponible.

La plupart des implémentations de Vulkan exposent deux ou trois des combinaisons d’indicateurs suivantes2 :

Dans le cas des ressources dynamiques, l’allocation dans la mémoire visible de l’hôte et ailleurs que sur le dispositif local, fonctionne en général bien — ça simplifie la gestion de l’application et est efficace grâce à la mise en cache des données en lecture seule côté GPU. Pour les ressources ayant un degré important d’accès aléatoires, comme les textures dynamiques, il est préférable de les allouer dans VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT et d’envoyer les données à l’aide de staging buffers alloués dans la mémoire VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT — comme vous le feriez pour des textures statiques. Vous pourriez être amené à le faire également pour les buffers — bien que les uniform buffers n’en souffrent généralement pas, certaines applications utilisant de larges buffers de stockage avec des modèles d’accès hautement aléatoire généreront trop de transactions PCI-express, à moins que vous ne copiiez d’abords les buffers sur le GPU ; de plus, la mémoire hôte a une latence d’accès plus élevée depuis le GPU pouvant impacter les performances pour de nombreux petits appels.

En cas de surabonnement de VRAM, vous pouvez manquer de mémoire lors de l’allocation des ressources depuis VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT ; dans ce cas, vous devriez revenir à l’allocation des ressources dans la mémoire non locale VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT du dispositif. Bien entendu, vous devez vous assurer que les ressources importantes fréquemment utilisées, telles que les cibles de rendu, sont allouées en premier. Il y a d’autres choses que vous pouvez faire en cas de surabonnement, comme la migration des ressources moins fréquemment utilisées de la mémoire GPU vers la mémoire CPU — cela sort du cadre de cet article ; de plus, sur certains systèmes d’exploitation, comme Windows 10, la gestion correcte du surabonnement nécessite d’APIs indisponibles dans Vulkan actuellement.

Sous-allocation de la mémoire

Contrairement à d’autres API permettant d’effectuer une allocation mémoire par ressource, dans Vulkan ce n’est pas pratique pour les grosses applications — les pilotes sont supposés ne prendre en charge que jusqu’à 4096 allocations individuelles. À ce nombre limité s’ajoute le fait que les allocations peuvent être lentes à effectuer, peuvent gâcher de la mémoire en faisant l’hypothèse des pires conditions d’alignement possibles, et également nécessiter une surcharge supplémentaire pendant la soumission du command buffer pour s’assurer de la présence en mémoire de tout ce qui est nécessaire aux-dites commandes. La sous-allocation est nécessaire pour ces raisons. Une façon typique de travailler avec Vulkan consiste à effectuer des allocations volumineuses (e.g. de 16 Mo à 256 Mo dépendant de la dynamique des besoins en mémoire) à l’aide de vkAllocateMemory, puis d’effectuer des sous-allocations d’objets à l’intérieur de cette mémoire, ce qui revient à la gérer vous-même. Plus important encore, l’application doit gérer correctement l’alignement des demandes de mémoire, ainsi que la limite bufferImageGranularity qui restreint les configurations valides de buffers et d’images.

En bref, bufferImageGranularity restreint le placement relatif des ressources de buffer et d’images au sein d’une même allocation, ce qui nécessite un remplissage supplémentaire entre chaque allocation. Il existe plusieurs façons de régler cela :

Sur de nombreux GPU, l’alignement requis par les ressources d’images est nettement plus important que pour les buffers, ce qui rend la dernière option intéressante — en plus de réduire le gaspillage de mémoire grâce à l’absence de remplissage entre les buffers et les images, il réduit la fragmentation interne causée par l’alignement de l’image lorsque cette dernière suit un buffer de ressource. VMA fournit des implémentations pour l’option 2 (par défaut) et 3 (voir VMA_POOL_CREATE_IGNORE_BUFFER_IMAGE_GRANULARITY_BIT).

Allocations dédiées

Bien que le modèle de gestion de la mémoire de Vulkan implique que l’application effectue des larges allocations et place plusieurs ressources dans chacune d’elle en utilisant les sous-allocation, sur certains GPU il est plus efficace d’allouer certaines ressources sous la forme d’une allocation dédiée. Dans certaines circonstances spéciales, le pilote peut allouer les ressources dans une mémoire plus rapide.

Pour cela, Vulkan fournit une extension (dans core en 1.1) permettant d’effectuer des allocations dédiées — lors de l’allocation de la mémoire, vous pouvez indiquer que vous allouez cette mémoire pour une ressource particulière plutôt que comme un blob opaque. Pour savoir si ça en vaut la peine, vous pouvez interroger les besoins de la mémoire étendue via vkGetImageMemoryRequirements2KHR ou vkGetBufferMemoryRequirements2KHR ; la structure résultante, VkMemoryDedicatedRequirementsKHR, contiendra les indicateurs requiresDedicatedAllocation (qui peut être défini si la ressource allouée nécessite d’être partagée avec d’autres processus) et prefersDedicatedAllocation.

En général, suivant le matériel et les pilotes, les performances des applications peuvent s’améliorer grâce aux allocations dédiées sur des cibles de rendu volumineuses nécessitant beaucoup de bande passante en lecture/écriture.

Mappage de la mémoire

Vulkan propose deux façons de mapper la mémoire pour obtenir un pointeur visible par le CPU :

La seconde option, parfois appelé « mappage persistant », est généralement un meilleur compromis — elle minimise le temps nécessaire à l’obtention d’un pointeur inscriptible (vkMapMemory n’est pas particulièrement rapide sur certains pilotes), permet de ne pas gérer le cas où plusieurs ressources d’un même objet mémoire s’écrivent simultanément (l’appel à vkMapMemory sur une allocation qui a déjà été mappée, mais pas démappée est invalide) et simplifie le code en général.

Le seul inconvénient est que cette technique rend moins utile le bloc de 256 Mo de VRAM visible par l’hôte et le dispositif local sur les GPU AMD décrit dans « Sélection de la mémoire de tas » — sur les systèmes avec Windows 7 et un GPU AMD, utiliser un mappage persistant peut forcer WDDM à migrer les allocations vers la mémoire système. Si cette combinaison est celle de vos utilisateurs et que vous souhaitez les meilleures performances possibles, le mappage et le démappage de la mémoire aux besoins peut être plus adapté.

Sets de descripteurs

Contrairement aux API précédentes utilisant un modèle de binding par emplacements, dans Vulkan, l’application à plus de liberté sur comment passer des ressources au shaders. Les ressources sont groupées dans des sets de descripteurs ayant un agencement spécifié par l’application, et chaque shader peut utiliser plusieurs sets de descripteurs pouvant être liés individuellement. Il est de la responsabilité de l’application de gérer les sets de descripteurs en s’assurant que le CPU ne met pas à jour un set de descripteurs utilisé par le GPU, et de fournir l’agencement ayant le meilleur équilibre possible entre le coût de mise à jour côté CPU et le coût d’accès côté GPU. De plus, comme les APIs de rendu utilisent des modèles de binding de ressources différents et qu’aucune ne correspond exactement au modèle utilisé par Vulkan, utiliser l’API de façon efficace et multi-plateforme devient compliqué. Nous allons voir plusieurs approches pour travailler avec les sets de descripteurs de Vulkan du point de vue de la facilité d’utilisation et des performances.

Modèle mental

Quand on utilise les sets de descripteurs, il est utile d’avoir un modèle mental sur la façon dont ils pourraient être mappés au matériel. Une des possibilités — et la conception attendue — est qu’ils mappent un bloc de la mémoire GPU contenant des descripteurs — des blobs opaques de données, de 16 à 64 octets suivant la ressource, définissant l’intégralité des paramètres des ressources nécessaires à l’accès aux données des ressources par le shader. Lors de la distribution du travail aux shaders, le CPU peut spécifier un nombre limité de pointeurs vers des sets de descripteurs ; ces pointeurs deviennent disponibles aux shaders quand les threads de shader se lancent.

Dans l’esprit, l’API Vulkan se map plus ou moins directement à ce modèle — créer une pool de set de descripteurs revient à allouer un bloc de mémoire GPU suffisamment large pour contenir le nombre maximum de descripteurs spécifié. Allouer un set de descripteurs à une pool peut être aussi simple qu’incrémenter le pointeur sur la pool par la taille cumulée des descripteurs alloués tel que déterminé par VkDescriptorSetLayout (notez qu’une telle implémentation ne pourrait pas supporter la réclamation de mémoire lors de la libération individuelle des descripteurs de la pool ; vkResetDescriptorPool ramènerai le pointeur au début de la mémoire de la pool et rendrait la pool entière à nouveau disponible à l’allocation). Enfin, vkCmdBindDescriptorSets émettrait des commandes de command buffers définissant les registres GPU correspondant aux pointeurs des sets de descripteurs.

Notez que ce modèle ignore beaucoup de complexités, tel que le décalage des buffers dynamiques, le nombre limité de ressources matérielles pour les sets de descripteurs, etc. De plus, il ne s’agit que d’une implémentation possible — certains GPU ont un modèle de descripteurs moins générique et imposent au pilote d’effectuer un traitement supplémentaire quand les sets de descripteurs sont liés au pipeline. Cela dit, c’est un modèle utile pour planifier l’allocation/l’utilisation des sets de descripteurs.

Gestion des sets de descripteurs dynamiques

Aux vues du modèle mental ci-dessus, vous pouvez traiter les sets de descripteurs comme de la mémoire visible par le GPU — il est de la responsabilité de l’application de grouper les sets de descripteurs dans les pools et de les conserver jusqu’à ce que le GPU ait terminé de les lire.

Un schéma qui fonctionne bien consiste à utiliser des listes libres de pools de sets de descripteurs ; dès que vous avez besoin d’une pool de sets de descripteurs, vous en allouez une depuis la liste libre et l’utilisez pour les allocations de sets de descripteurs ultérieures pour la frame courante du thread courant. Quand vous n’avez plus de sets de descripteurs dans la pool courante, vous allouez une nouvelle pool. Toute les pools utilisées sur une frame donnée doivent être conservées ; une fois le rendu de la frame terminée, tel que déterminé par les objets fence, les pools de sets de descripteurs peuvent être réinitialisés via vkResetDescriptorPool et renvoyés dans des listes libres. Bien qu’il soit possible de libérer des descripteurs individuels d’une pool via VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT, cela complique la gestion de la mémoire côté pilote et n’est pas recommandé.

Quand une pool de sets de descripteurs est créée, l’application spécifie le nombre maximum de sets de descriptors qu’elle alloue, ainsi que le nombre maximum de descripteurs de chaque type qu’elle peut allouer. Dans Vulkan 1.1, l’application n’a pas prendre ces limites en compte — elle peut simplement appeler vkAllocateDescriptorSets et gérer l’erreur de cet appel en passant sur une nouvelle pool de sets de descripteurs. Malheureusement, appeler vkAllocateDescriptorSets dans Vulkan 1.0 sans aucune extension, entraîne une erreur si la pool n’a pas d’espace disponible, donc l’application doit suivre le nombre de sets et de descripteurs de chaque type pour anticiper quand elle doit basculer vers une pool différente.

Différents objets de pipeline peuvent utiliser différents nombres de descripteurs, ce qui pose la question de la configuration de la pool. Une approche simple consiste à créer toutes les pools avec la même configuration qui utilise le pire nombre de descripteurs possible pour chaque type — par exemple, si chaque set peut utiliser au plus 16 descripteurs de textures et 8 descriptors de buffers, nous pouvons allouer toutes les pools avec maxSets=1024, une taille de pool de 16 × 1024 pour les descripteurs de textures et 8 × 1024 pour les descriptors de buffers. Cette approche peut fonctionner, mais en pratique elle peut entraîner un gaspillage très important de la mémoire pour les shaders avec un nombre de descripteurs différent — vous ne pouvez pas allouer plus de 1024 sets de descripteurs à partir d’une pool ayant la configuration susmentionnée, donc si la plupart de vos objets de pipeline utilisent 4 textures, vous gaspillerez 75 % de la mémoire du descripteur de texture.

Deux alternatives qui offrant un meilleur équilibre avec l’utilisation de la mémoire sont :

Choisir le bon type de descripteurs

Vulkan propose plusieurs façons d’accéder à chaque type de ressource dans un shader ; c’est à l’application de choisir le type de descripteur optimal.

Pour les buffers, l’application doit choisir entre les uniform buffers et les storage buffers, et d’utiliser ou non les décalages dynamiques. Les uniform buffers ont un limite de taille maximale adressable — sur du matériel de bureau, vous avez jusqu’à 64 Ko de données, mais certains GPU mobiles ne fournissent que 16 Ko de données (ce qui est également le minimum garanti par la spécification). Le buffer de ressource peut être plus grand, mais un shader ne peut accéder qu’à cette quantité de données pour un descripteur.

Sur certains matériels, il n’y a aucune différence en vitesse d’accès entre les uniform buffers et les storage buffers, mais pour d’autres, suivant le modèle d’accès, les uniform buffers peuvent être beaucoup plus rapides. Préférez les uniform buffers pour les données de petite et moyenne taille, en particulier si la façon d’y accéder est fixe (e.g. pour un buffer avec des constantes de matériaux ou de scènes). Les storage buffers sont plus adaptés quand vous avez besoin de tableaux de données plus grand que la limite de taille des uniform buffers et sont indexés dynamiquement dans le shader.

Pour les textures, si le filtrage est requis, vous avez le choix entre :

Les performances relatives de ces méthodes dépendent grandement de la façon dont elles sont utilisées ; cela dit, en général, les descripteurs immuables correspondent mieux à l’usage recommandé par les nouvelles APIs, comme Direct3D 12, et donnent au pilote plus de liberté pour optimiser le shader. Cela modifie dans une certaine mesure la conception du moteur de rendu, l’obligeant à implémenter l’aspect dynamique de certaines parties de l’état du sampler, tel que le LOD bias par texture, pour les transitions de texture streamées, en utilisant les instructions ALU du shader.

Binding par emplacement

Une alternative basique au mécanisme de binding de Vulkan est le celui de Metal/Direct3D 11 dans lequel une application peut lier des ressources à des emplacements, et le moteur d’exécution/pilote gère la mémoire du descripteur et les paramètres du set de descripteurs. Cette approche peut être implémentée via les sets de descripteurs Vulkan ; bien qu’il ne fournisse pas les résultats les plus optimaux, il s’agit d’un bon modèle par lequel commencer lors du portage d’un moteur de rendu existant, et il peut se révéler étonnamment efficace s’il est correctement implémenté.

Pour que ce modèle fonctionne, l’application doit définir un nombre de blocs de ressources présent et comment ils se mappent aux indices de set/emplacements Vulkan. Par exemple, en Metal, chaque étape (VS, FS, CS) a trois blocs de ressources — textures, buffers et samplers — sans différenciations entre, par exemple, les uniform buffers et les storage buffers. En Direct3D 11, les blocs sont plus compliqués, car les buffers en lecture-seule appartiennent au même bloc que celui des textures, mais les textures et les buffers utilisant des accès non-ordonnés résident appartiennent à un autre.

Les spécifications de Vulkan ne garantissent qu’un minimum de 4 sets de descripteurs accessible à l’ensemble du pipeline (à travers toutes les étapes) ; pour cette raison, le mappage le plus pratique consiste à avoir le même binding de ressource pour toutes les étapes — par exemple, l’emplacement de texture 3 pourrait contenir la même ressource de texture quelle que soit l’étape à partir de laquelle on y accède — et utiliser différents sets de descripteurs pour chaque type, e.g. le set 0 pour les buffers, le set 1 pour les textures, le set 2 pour les samplers. L’application peut aussi utiliser un seul set de descripteurs par étape4 et effectuer un remappage d’index statique (e.g. les emplacements 0 à 16 pourrait être utilisés pour les textures, ceux de 17 à 24 pour les uniform buffers, etc.) — ce qui, en revanche, n’est pas recommandé, car on risque d’utiliser beaucoup plus de mémoire de set de descripteurs. Enfin, on pourrait implémenter une table de remappage dynamique, précise et compact, d’emplacements pour chaque étape de shader (e.g. si un vertex shader utilise les emplacements de texture 0, 4, 5, ils sont mappés aux indices de descripteur 0, 1, 2 du set 0, et au moment de l’exécution, l’application extrait les informations de texture voulues depuis cette table de remappage).

Dans tous ces cas, l’implémentation d’une définition de texture à un emplacement donné n’exécuterait, en général, aucune commande Vulkan et ne ferait que mettre à jour les états dans l’ombre ; juste avant le draw call ou la distribution des commandes, vous devez allouer un set de descripteurs à partir de la pool voulu, le mettre à jour avec les nouveaux descripteurs, et binder tous les sets de descripteurs via la commande vkCmdBindDescriptorSets. Notez que si un set de descripteurs a 5 ressources, et que seul une d’entre elle a changé depuis le dernier draw call, vous devez quand même allouer un nouveau set de descripteurs avec 5 ressources et toutes les mettre à jour.

Pour obtenir de bonnes performances avec cette approche, vous devez suivre plusieurs directives :

En général, l’approche décrite ci-dessus peut être très efficace en termes de performances. Elle n’est pas aussi efficace que les approches avec des sets de descripteurs plus statiques décrits ci-dessous, mais si elle est implémentée avec soin elle a le mérite de s’accommoder aux API plus anciennes. Sur certains pilotes, malheureusement, l’approche allocation et mise à jour n’est pas optimal — sur certains matériels mobiles, il peut être judicieux de mettre en cache les sets de descripteurs en fonction des descripteurs qu’ils contiennent s’ils peuvent être réutilisés plus tard dans la frame.

Sets de descripteurs par fréquence

Bien que le binding de ressources par emplacement soit une approche simple et familière, elle ne se traduit pas par des performances optimales. Certains matériels mobiles ne peuvent pas prendre en charge plusieurs sets de descripteurs ; cependant, en général, l’API Vulkan et le pilote attendent d’une l’application qu’elle gère les sets de descripteurs en fonction de la fréquence des changements.

Un moteur de rendu focalisé sur Vulkan grouperait les données dont les shaders ont besoins suivant leur fréquence de changements, et utiliserait des sets particuliers pour des fréquences particulières, avec set=0 pour les changements les moins fréquents, et set=3 pour les changements les plus fréquents. À titre d’exemple, une configuration typique impliquerait :

Le but est que set=0 ne change qu’une poignée de fois par image ; on peut utiliser un modèle d’allocation dynamique tel que décrit dans la section précédente.

Pour set=1, le but est que pour la plupart des objets, les données de matériaux ne change pas entre les frames, et donc puissent être allouées et mis à jour que lorsque le code du jeu ne change ces données.

Enfin, les données de set=2 seraient complètement dynamiques — du fait de l’utilisation d’un uniform buffer dynamique, nous aurions rarement à allouer et mettre à jour ce set de descripteurs — en supposant que les constantes dynamiques soient envoyées dans une série de gros buffers par image, pour la plupart des draw calls nous mettrions à jour le buffer avec les constantes, puis appellerions vkCmdBindDescriptorSets avec de nouveaux décalages.

Notez qu’en raison des règles de compatibilité entre les objets de pipeline, il suffit la plupart du temps de binder les sets 1 et 2 dès qu’un matériel change, et seulement le set 2 quand les matériaux sont les mêmes que ceux du draw calls précédent. Ce qui a pour effet d’appeler vkCmdBindDescriptorSets qu’une seule fois par draw call.

Dans un moteur de rendu complexe, chaque shaders peut avoir à utiliser un agencement différent — en effet, tous les shaders n’ont pas besoins de s’accorder sur le même agencement des données de matériaux. Dans de rares cas, il peut être judicieux d’utiliser plus de 3 sets en fonction de la structure de la frame. De plus, étant donné la flexibilité de Vulkan, il n’est pas obligatoire d’utiliser le même système de binding de ressources pour tous les draw calls d’une scène. Par exemple, les chaînes de draw calls post-traitement ont tendance à être hautement dynamiques, avec des textures/constantes changeant entièrement entre chaque draw call. Certains moteurs de rendu implémentèrent d’abord le modèle de binding par emplacement dynamique de la section précédente, puis ajoutèrent l’approche par fréquence pour le rendu du monde afin de minimiser la pénalité de performance de la gestion des sets, tout en gardant la simplicité du modèle par emplacement pour les parties dynamiques du pipeline de rendu.

Le modèle décrit ci-dessus suppose que dans la plupart des cas, la taille des données par rendu est plus importante que si elles étaient envoyées en poussant des constantes. Pousser des constantes peut se faire sans mettre à jour ou rebinder les sets de descripteurs ; avec la garantie d’avoir une taille maximale de 128 octets par draw call, il est tentant de les utiliser pour des données par rendu comme la matrice de transformation 4 × 3 d’un objet. Cependant, sur certaines architectures, le nombre réel de constantes qu’il est possible de pousser rapidement, dépend de la configuration du descripteur que les shaders utilisent, et est au alentours de 12 bytes. Dépasser cette limite peut forcer le pilote à stocker les constantes à pousser dans un ring buffer qu’il gère, ce qui peut se révéler plus coûteux que de déplacer ces données dans une uniform buffer dynamique depuis l’application. Bien que pour certaines conceptions, pousser les constantes de façon limitée puisse être une bonne idée, il est plus judicieux de le faire via un modèle entièrement bindless, tel que décrit dans la section suivante.

Conceptions de descripteurs bindless

Les sets de descripteurs par fréquence réduisent la surcharge du binding des sets de descripteurs ; cela dit, il vous reste toujours un ou deux sets de descripteurs par draw calls à binder. Maintenir des sets de descripteurs de matériaux nécessite une couche de gestion visant à mettre à jour les sets de descripteurs du GPU dès que des paramètres de matériaux change ; de plus, comme les descripteurs de textures sont cachés dans les données des matériaux, cela rend les systèmes de streaming de textures globaux difficiles à gérer — dès que des niveaux de mipmap d’une texture sont envoyés ou retiré, tous les matériaux utilisant cette texture doivent être mis à jour. Cela nécessite une interaction complexe entre le système des matériaux et celui de streaming de texture et introduit une surcharge supplémentaire dès qu’une texture est ajustée — ce qui amoindri les avantages de l’approche par fréquence. Enfin, le besoin de configurer des sets de descripteurs par draw call fait qu’il est difficile d’adapter les méthodes susmentionnées à du culling ou à la soumission de commandes côté GPU.

Il est possible de concevoir une approche bindless où le nombre d’appels requis pour définir les binding est constant pour le rendu du monde, qui dissocie les descripteurs de texture de ceux des matériaux, rendant les systèmes de streaming de textures plus simple à implémenter, et facilite la soumission côté GPU. Tout comme l’approche précédente, elle peut être combinée avec des mises à jours de descripteurs ad-hoc dynamique pour les parties de la scène où le nombre de draw calls est faible, et où la flexibilité est importante, comme le post-traitement.

Pour tirer pleinement parti du bindless, Vulkan core risque de ne pas être suffisant ; certaines implémentations du blindless nécessitent la mise à jour des sets de descripteurs sans les rebinder après cette dernière, chose indisponible en Vulkan 1.0 et 1.1, mais faisable via l’extension VK_EXT_descriptor_indexing (dans core en 1.2). Cela dit, la conception de base décrite ci-dessous fonctionne sans extensions, du fait d’une limite de sets de descripteurs suffisamment élevés. Ceci nécessite la mise en place d’un double buffer du tableau des descripteurs de texture décrit ci-dessous pour mettre à jour les descripteurs, car le tableau sera constamment lu par le GPU.

Comme pour l’approche par fréquence, nous diviserons les données de shader en uniformes et textures globaux (set 0), données de matériaux et données par rendu. Les uniformes et textures globaux peuvent être spécifiés via un set de descripteurs tel que décrit dans la section précédente.

Pour les données par matériau, nous déplacerons les descripteurs de texture dans un grand tableau de descripteurs de textures (note : il s’agit d’un concept différent du tableau de textures — un tableau de texture utilise un seul descripteur et force toutes les textures à avoir la même taille et le même format ; un tableau de descripteurs n’a pas cette limitation et chacun de ces éléments peut être un descripteur de textures arbitraire, voir un tableau de descripteurs de textures), Chaque matériau des données de matériaux aura un index dans ce tableau au lieu de son descripteur de texture ; l’index fera partie des données de matériaux, qui auront également d’autres constantes de matériaux.

Chaque constante de chaque matériau de la scène sera stocké dans un gros storage buffer ; bien que cette approche permette de prendre en charge plusieurs types de matériaux, nous partirons du principe que chaque matériau puisse être défini via les mêmes données, par souci de simplicité. Exemple de structure de données de matériau ci-dessous :

struct MaterialData
{
    vec4 albedoTint;

    float tilingX;
    float tilingY;
    float reflectance;
    float unused0; // pad to vec4

    uint albedoTexture;
    uint normalTexture;
    uint roughnessTexture;
    uint unused1; // pad to vec4
};

De la même façon, toutes les constantes par rendu de tous les objets de la scène peuvent être stockées dans un autre gros storage buffer ; par souci de simplicité, nous partirons du principe que toutes les constantes par rendu ont une structure identique. Pour prendre en charge les objets skinnés avec une telle approche, nous allons extraire les données de transformation dans un troisième storage buffer distinct :

struct TransformData
{
    vec4 transform[3];
};

Quelque chose que nous avons ignoré jusqu’à présent est la spécification des données de sommets. Bien que Vulkan fournisse un moyen direct de spécifier les données de sommets via l’appel vkCmdBindVertexBuffers, le fait de binder les vertex buffers par rendu ne fonctionnerait pas dans une conception bindless. De plus, certains matériels ne prennent pas directement en charge les vertex buffers, et le pilote doit émuler le binding des vertex buffers, ce que peut entraîner des ralentissements côté CPU quand on utilise vkCmdBindVertexBuffers. Une conception totalement bindless implique que tous les vertex buffers sont sous-alloués dans un gros buffer puis d’utiliser soit des décalages par rendu (l’argument vertexOffset de vkCmdDrawIndexed) pour que le matériel y récupère les données, soit de transmettre un décalage de buffer dans le shader à chaque draw call et de récupérer les données du buffer dans le shader. Les deux approches peuvent bien fonctionner et peuvent être plus ou moins efficaces suivant le GPU ; ici, nous partirons du principe que le vertex shader effectuera une récupération manuelle des sommets.

Ainsi, nous devons spécifier trois integers au shader pour chaque draw call :

Si nécessaire, nous pouvons spécifier ces index et des données supplémentaires via cette structure :

struct DrawData
{
    uint materialIndex;
    uint transformOffset;
    uint vertexOffset;
    uint unused0; // vec4 padding

    // ... extra gameplay data goes here
};

Le shader devra accéder aux storage buffers contenant MaterialData, TransformData, DrawData ainsi qu’à un storage buffer contenant les données des sommets. Ces buffers peuvent être bindés au shader grâce au set de descripteurs global ; la dernière information est l’index des données de rendu, qui peut être transmis en poussant une constante.

Avec cette approche, nous devons mettre à jour les storage buffers utilisés par les matériaux et les draw calls à chaque frame et les binder utilisant notre set de descripteurs global ; De plus, nous devons binder les données d’index — en supposant que, comme les données de sommet, les données d’index sont allouées dans un gros index buffer, nous n’avons besoin de les binder qu’une seule fois à l’aide de vkCmdBindIndexBuffer. Une fois la configuration globale terminée, pour chaque draw call, nous devons appeler vkCmdBindPipeline si le shader change, suivi de vkCmdPushConstants pour spécifier un index dans le draw data buffer5, suivi de vkCmdDrawIndexed.

Dans une conception centrée sure le GPU, au lieu de pousser les constantes par rendu, nous pouvons utiliser vkCmdDrawIndirect ou vkCmdDrawIndirectCountKHR (fourni par l’extension KHR_draw_indirect_count, dans core en 1.2) et les récupérer sous forme d’index en utilisant gl_DrawIDARB (fourni par l’extension KHR_shader_draw_parameters). La seule chose à garder à l’esprit est que pour la soumission basée sur le GPU, nous devrons regrouper les draw calls par pipeline object côté CPU, car il n’est pas possible de changer de pipeline object autrement.

Avec tout ça, le code de transformation des sommets du vertex shader pourrait ressembler à ça :

DrawData dd = drawData[gl_DrawIDARB];
TransformData td = transformData[dd.transformOffset];
vec4 positionLocal = vec4(positionData[gl_VertexIndex + dd.vertexOffset], 1.0);
vec3 positionWorld = mat4x3(td.transform[0], td.transform[1], td.transform[2]) * positionLocal;

Le code de sampling des textures des matériaux du fragment shader pourrait ressembler à ça :

DrawData dd = drawData[drawId];
MaterialData md = materialData[dd.materialIndex];
vec4 albedo = texture(sampler2D(materialTextures[md.albedoTexture], albedoSampler), uv * vec2(md.tilingX, md.tilingY));

Cette approche diminue la charge CPU, mais bien entendu, c’est avant tout un équilibre entre de plusieurs facteurs :

Plus le moteur de rendu se complexifie, plus l’approche bindless s’y généralise, permettant de déléguer au GPU des parts de plus en plus importantes du pipeline de rendu ; du fait des contraintes matériels, cette approche est inapplicable sur certains dispositif pourtant compatible Vulkan, mais ça vaut vraiment la peine de s’y attarder lors de la conception de nouvelles façon de rendre sur du futur matériel.

Enregistrement et soumission de command buffers

Les anciennes API ont une chronologie unique pour les commandes GPU ; Les commandes exécutées sur le CPU sont exécutées dans le même ordre sur le GPU, car elles ne sont en général enregistrées que sur un seul thread ; il n’y a pas de contrôle précis sur le moment ou le CPU soumet les commandes au GPU, et le pilote est supposé gérer de façon optimale la mémoire utilisée par le flux de commandes ainsi que les moments de soumission.

En revanche, dans Vulkan, l’application est responsable de la gestion de la mémoire des buffer commands, de l’enregistrement des commandes en multi-thread dans plusieurs buffer commands et de leur soumission pour exécution au bon moment. Bien qu’un code méticuleusement écrit puisse rendre un moteur de rendu Vulkan mono-threadé beaucoup plus rapide qu’avec les API plus anciennes, l’efficacité maximale et la latence minimale sont obtenues en gérant l’enregistrement des commandes sur les nombreux cœurs du système, ce qui nécessite une gestion minutieuse de la mémoire.

Modèle mental

Tout comme les sets de descripteurs, les command buffers sont alloués dans des pools de commandes ; il est important de comprendre comment un pilote peut être amené à les implémenter pour pouvoir raisonner sur les coûts et les implications d’utilisation.

La pool de commandes doit gérer la mémoire qui sera remplie de commandes par le CPU et ensuite lue par le processeur de commandes du GPU. La quantité de mémoire utilisée par les commandes ne peut pas être déterminée statiquement ; l’implémentation typique d’une pool impliquerait donc une liste libre à taille de pages fixe. Le command buffer contiendrait une liste de pages avec les commandes qui nous intéressent, avec des commandes spéciales de saut qui transfèrent le contrôle de chaque page à la suivante afin que le GPU puisse toutes les exécuter séquentiellement. Chaque fois qu’une commande doit être allouée à partir d’un command buffer, elle sera encodée dans la page courante ; si la page actuelle n’a pas d’espace, le pilote allouera la page suivante à l’aide d’une liste libre de la pool associée, encodera un saut vers cette page dans la page actuelle et passera à la page suivante pour l’enregistrement des commandes ultérieures.

Chaque pool de commandes ne peut être utilisé que par un thread à la fois, les commandes ci-dessus n’ont donc pas besoin d’être thread-safe6. Libérer le command buffer via vkFreeCommandBuffers peut renvoyer dans la pool les pages utilisées par le command buffer en les ajoutant à la liste libre. Réinitialiser la pool de commandes peut mettre toutes les pages utilisées par tous les command buffers de la liste libre de la pool ; lorsque VK_COMMAND_POOL_RESET_RELEASE_RESOURCES_BIT est utilisé, les pages peuvent être renvoyées au système afin que d’autres pools puissent les réutiliser.

Notez que rien ne garantit que vkFreeCommandBuffers renvoie réellement la mémoire à la pool ; certaines approches s’appuient sur plusieurs command buffers alloué en morceaux dans des pages plus grandes, ce qui compliquerait le recyclage de la mémoire de vkFreeCommandBuffers. En effet, pour un fabricant mobile en particulier, vkResetCommandPool est obligatoire pour réutiliser la mémoire pour un futur enregistrement de commande dans une configuration de base, lorsque les pools sont alloués sans VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT.

Enregistrement des commandes multi-threadé

Dans Vulkan, les deux restrictions fondamentales concernant l’utilisation des pools de commandes sont :

Pour ces raisons, une mise en place des threads nécessite un ensemble de pools de command buffers. Cet ensemble doit contenir F × T pools, où F correspond à la taille de la file d’attente des frames — F est généralement de 2 (une frame est enregistrée par le CPU pendant que l’autre est exécutée par le GPU) ou 3 ; T est le nombre de threads pouvant enregistrer des commandes en même temps, valeur qui peut atteindre le nombre de cœurs du système. Quand on enregistre des commandes depuis un thread, ce dernier doit allouer un command buffer depuis la pool qui lui est associée pour la frame donnée et y enregistrer les commandes. Si on part du principe que les command buffers ne sont pas remplies aux abords des frames, et qu’aux abords des frames la longueur de la file d’attente impose d’attendre que la dernière frame de la file d’attente soit exécutée, nous pouvons alors libérer tous les command buffers alloués à cette frame et réinitialiser toutes les pools des commandes associés.

De plus, plutôt que de libérer les command buffers, il est possible de les réutiliser après avoir appelé vkResetCommandPool — ce qui fait que les command buffers n’ont pas à être ré-alloués. Bien qu’en théorie l’allocation de command buffers soit rapide, elle peut se révéler coûteuse sur certains pilotes. Cela garantit également que le pilote n’a jamais besoin de redonner la mémoire des commandes au système, ce qui peut accélérer la soumission des commandes dans ces buffers.

Notez que suivant la structure de la frame, l’approche ci-dessus peut entraîner une consommation de mémoire déséquilibrée entre les threads ; par exemple, les draw calls des ombres nécessitent généralement une configuration et une utilisation de la mémoire des commandes moindre. Combiné à une distribution de la charge de travail aléatoire entre les threads, tel que produite par de nombreux job schedulers, vous pouvez vous retrouver avec des pools de commandes taillées pour le pire cas de consommation. Si une application est contrainte par la mémoire et que cela devient un problème, il est possible de limiter le gaspillage en limitant le parallélisme de chacune des passes puis de sélectionner la commande buffer/pool en fonction de la passe à enregistrer.

Cela nécessite l’introduction d’une organisation par taille au gestionnaire de command buffer. Avec un pool de commandes par thread et une réutilisation manuelle des command buffers alloués tel que suggéré ci-dessus, il est possible de maintenir une liste libre par groupe de taille, lesdits groupes étant définis suivant le nombre de draw calls (e.g. “<100”, “100-400”, etc.) et/ou la complexité de chaque draw call (seulement du depth, gbuffer). Choisir les buffers en fonction de leur utilisation stabilise la consommation de la mémoire. De plus, pour les passes trop petites, il est intéressant de réduire le parallélisme de leur enregistrement — par exemple, si une passe a moins de 100 draw calls sur un système doté de 4 cœurs, il peut être plus rapide de l’enregistrer sous un seul job plutôt que de la découper en 4 jobs, car cela permet de réduire la surcharge de la gestion de la mémoire des commandes et de soumission du command buffer.

Soumission des command buffers

Bien qu’il soit plus efficace d’enregistrer des command buffers depuis plusieurs threads, le fait que les états ne soient pas partagés/réutilisés entre les command buffers7 et les limitations du schedulers fait que les command buffers doivent être suffisamment gros pour s’assurer que le GPU n’est pas inactif pendant le traitement des commandes. De plus, chaque soumission entraîne une surcharge à la fois côté CPU et GPU. D’une façon générale, une application Vulkan devrait viser moins de 10 soumissions par frame (chaque soumission coûtant 0.5 ms de charge GPU, voir plus), et moins de 100 command buffers par frame (chaque command buffer coûtant 0.1 ms de charge GPU, voir plus). Cela peut nécessiter d’ajuster le nombre d’enregistrements concurrents des commandes de chaque passe, par exemple, si une passe d’ombre d’une lumière spécifique a moins de 100 draw calls, il peut être intéressant de n’enregistrer les commandes de cette passe que depuis un seul thread ; il peut même être intéressant de combiner les passes les plus courtes aux passes voisines, dans un unique command buffer. Au final, moins vous avez a de soumissions par frame, mieux c’est — en revanche, cela doit être équilibré par la soumission de suffisamment de travail GPU plus tôt dans la frame pour augmenter le parallélisme CPU et GPU, par exemple, il peut être judicieux de soumettre tous les command buffers du rendu des ombres avant d’enregistrer les commandes pour les autres parties de la frame.

Surtout, le nombre de soumissions fait référence au nombre total de structures VkSubmitInfo soumis par tous les appels à vkQueueSubmit d’une frame, et non simplement au nombre d’appel à vkQueueSubmit. Par exemple, lors de la soumission de 10 command buffer, il est beaucoup plus efficace d’utiliser un seul VkSubmitInfo soumettant 10 command buffers plutôt que 10 VkSubmitInfo avec un seul command buffer chacune, même si dans les deux cas, vkQueueSubmit n’est appelé qu’une fois. Fondamentalement, VkSubmitInfo est un bloc de synchronisation/planification sur GPU, car il possède son propre ensemble de clôtures/sémaphores.

Les Command buffers secondaires

Quand une des passes de rendu de l’application contient beaucoup de draw calls, comme la passe gbuffer, il est important de découper les draw calls en plusieurs groupes et de les enregistrer depuis plusieurs threads. Il y a deux façons de faire :

Bien que sur des GPUs en mode immédiat la première approche puisse être viable, et qu’il puisse être plus simple de gérer les points de synchronisation wrt sur le CPU, il est en revanche vital d’utiliser la seconde approche sur les GPUs utilisant le rendu en tuile. Utiliser la première approche sur les GPUs « à tuiles » impliquerait que le contenu de la tuile soit vidé puis rechargé de la mémoire entre chaque command buffer, ce qui est catastrophique du point de vue des performances.

Réutilisation des command buffers

Avec les conseils sur la soumission des command buffers vues plus haut, dans la plupart des cas, il devient compliqué de soumettre un unique command buffer plusieurs fois après son enregistrement. En général, les approches consistant à pré-enregistrer les command buffers pour des pans de la scène sont contre-productives, car elles peuvent entraîner une charge excessive du GPU à cause du mauvais culling qu’elles impliquent, ce dernier étant requis pour garder une charge des command buffers élevée, et peuvent passer par des chemins de code inefficaces sur certains GPU à tuile. Au lieu de ça, les applications doivent se concentrer sur l’amélioration du threading et du coût de la soumission des draw calls sur le CPU. De fait, les applications devraient utiliser VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT pour s’assurer que le pilote a la liberté de générer des commandes qui n’ont pas besoin d’être ré-exécutées plus d’une fois.

Il y a des exceptions à cette règle. Par exemple, pour le rendu VR, une application pourrait enregistrer le command buffer pour le frustum des deux yeux combinés en une seule fois. Si des données « par œil » sont lues depuis un unique uniform buffer, ce buffer peut alors être mis à jour entre les command buffers en utilisant vkCmdUpdateBuffer, suivi de vkCmdExecuteCommands si les command buffers secondaires sont utilisés, ou vkQueueSubmit. Cela dit, pour la VR il peut être intéressant de s’attarder sur l’extension VK_KHR_multiview si elle est disponible (dans core en 1.1), car elle devrait permettre au pilote d’effectuer une optimisation similaire.

Barrières de pipeline

Les barrières de pipeline restent l’un des concepts les plus difficiles du code Vulkan. Dans les API plus anciennes, le runtime et le pilote étaient chargés de s’assurer que la synchronisation appropriée, spécifique au matériel, était effectuée en cas de dangers tels que la lecture par le fragment shader d’une texture à l’intérieur de laquelle un rendu a été effectué précédemment. Cela nécessitait un suivi méticuleux du binding de chaque ressource dont il en résultait un mélange malheureux de surcharge CPU pour effectuer une quantité parfois excessive de synchronisation du GPU (par exemple, un pilote Direct3D 11 insère généralement une barrière entre deux dispatches de calculs consécutifs utilisant le même UAV, même si selon la logique de l’application, les dangers peuvent être absents). Dans la mesure où l’insertion rapide et optimale des barrières implique de comprendre comment l’application utilise les ressources, Vulkan demande à l’application de le faire.

Pour un rendu optimal, la mise en place des barrières de pipeline doit être parfaite. Une barrière manquante fait courrir le risque à l’application de rencontrer un bogue dépendant du timing sur une architecture non testée — ou, pire, encore inexistante — qui, dans le pire des cas, pourrait provoquer un crash du GPU. Une barrière inutile peut diminuer l’utilisation du GPU en réduisant les potentielles exécutions en parallèle —  ou, pire, déclencher des opérations de décompression coûteuses, etc. Pour rendre le tout plus dur, alors que le coût des barrières excessives peut désormais être visualisé par des outils tels que Radeon Graphics Profiler, les barrières manquantes ne sont généralement pas détectées par les outils de validation.

Pour ces raisons, il est essentiel de comprendre le comportement des barrières, les conséquences d’une utilisation abusive ainsi que la manière de les utiliser.

Modèle mental

La spécification décrit la notion de barrières comme des dépendances d’exécution et de visibilité de la mémoire entre les étapes du pipeline (e.g. une ressource a été précédemment écrite par une étape de compute shader, et sera lu par l’étape de transfert), ainsi que les changements d’agencement des images (e.g. une ressource était auparavant dans le format optimal pour écrire via la sortie de l’attachement de couleur et sera transféré vers un format optimal pour lire à partir du shader). Cependant, il peut être plus facile de penser les barrières en fonction de leurs conséquences — c.à.d ce qui peut arriver sur un GPU lorsqu’une barrière est utilisée. Notez que le comportement du GPU dépend bien sûr des spécificités du fabricant et de l’architecture, mais faire une relation entre les barrières spécifiées de façon abstraite et des constructions plus concrètes aide à comprendre leurs implications en termes de performances.

Une barrière peut provoquer trois choses différentes :

  1. Bloquer l’exécution d’une étape spécifique le temps qu’une autre étape soit vidé de tout travail en cours. Par exemple, si une passe rend des données dans une texture, et qu’une passe ultérieure y lit les informations via un vertex shader, le GPU doit attendre que tous les fragment shaders et ROP8 soient terminés avant de lancer les threads du vertex shader dans la passe qui suit. La plupart des opérations de barrière entraîneront un blocage de l’exécution de certaines étapes9.
  2. Vider ou invalider un cache interne côté GPU et attendre la fin des transactions mémoire pour s’assurer qu’une autre étape peut lire le travail résultant. Par exemple, sur certaines architectures, les écritures ROP peuvent passer par le cache de texture L2, mais l’étape de transfert peut opérer directement sur la mémoire. Si une texture a été rendue dans une passe de rendu, l’opération de transfert suivante peut lire des données périmées à moins que le cache ne soit vidé avant la copie. De même, si une étape de texture a besoin de lire une image qui a été copiée à l’aide de l’étape de transfert, le cache de texture L2 peut devoir être invalidé pour s’assurer qu’il ne contient pas de données périmées. Peu d’opérations de barrière auront besoin de faire ça.
  3. Convertir le format dans lequel la ressource est stockée, le plus souvent pour décompresser le stockage des ressources. Par exemple, sur certaines architectures, les textures MSAA sont stockées sous une forme compressée où chaque pixel à un masque d’échantillon indiquant le nombre de couleurs uniques que contient ce pixel, et un stockage séparé pour les données d’échantillon. L’étape de transfert ou de shader peut être incapable de lire directement à partir d’une texture compressée, donc une barrière qui transite de VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL à VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL ou VK_IMAGE_USAGE_TRANSFER_SRC_BIT pourrait avoir besoin de décompresser la texture, écrire tous les échantillons de tous les pixels dans la mémoire. La plupart des opérations de barrières n’auront pas besoin faire cela, mais celles qui le font peuvent être extrêmement coûteuses.

Avec ça en tête, essayons de comprendre la façon d’utiliser des barrières.

Obtenir les meilleures performances

Lors de la génération des commandes de chaque barrière, le pilote n’a qu’une vision locale de la barrière et n’est pas conscient des barrières précédentes et à vernir. Pour cette raison, la règle numéro 1 est que les barrières doivent être regroupées aussi agressivement que possible. Supposons une barrière impliquant l’attente d’inactivité de l’étape du fragment shader et un vidage du cache L2 des textures, le pilote générera consciencieusement cela chaque fois que vous appelez vkCmdPipelineBarrier. Si vous spécifiez plusieurs ressources dans un seul appel à vkCmdPipelineBarrier, le pilote ne générera qu’une seule commande de vidage du cache de texture L2 si elle est nécessaire à une transition, ce qui réduit le coût.

Pour être sûr que le coût des barrières n’est pas plus élevé qu’il ne devrait l’être, seuls les étapes pertinentes doivent être incluses. Par exemple, un des types de barrière le plus courant est celui faisant transiter une ressource de VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL vers VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL. Quand on spécifie cette barrière, on doit spécifier quelles étapes de shader liront réellement cette ressource, via dstStageMask. Il est tentant de spécifier le masque des étapes à VK_PIPELINE_STAGE_ALL_COMMANDS_BIT pour pouvoir lire depuis un compute shader ou un vertex shader. Faire ça implique cependant que le travail du vertex shader des commandes de draw à venir ne pourra pas démarrer, ce qui est problématique :

Notez que même si les barrières sont spécifiées correctement — dans notre cas, à supposer que la texture soit lue à partir de l’étape de fragment shader, dstStageMask doit être VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT — la dépendance d’exécution est toujours présente, et elle risque d’entraîner une utilisation réduite du GPU. Cela peut survenir dans plusieurs situations y compris le compute shader, où pour lire, depuis un compute shader, les données générées par un autre compute shader, vous devez exprimer une dépendance d’exécution entre CS et CS, mais spécifier une barrière de pipeline garantis de vider entièrement le GPU du travail du premier compute shader, pour ensuite le remplir de nouveau, lentement, de travail du second compute shader. Au lieu de ça, il peut être intéressant de spécifier la dépendance en utilisant ce qu’on appelle une barrière divisée : Au lieu d’utiliser vkCmdPipelineBarrier, utilisez vkCmdSetEvent une fois l’opération d’écriture terminée, et vkCmdWaitEvents avant que les opérations de lecture ne commencent. Bien entendu, vkCmdWaitEvents immédiatement après vkCmdSetEvent est contre-productif et peut être plus lent que vkCmdPipelineBarrier ; au lieu de ça, vous devriez restructurer votre algorithme pour vous assurer qu’il y a suffisamment de travail soumis entre Set et Wait, de sorte qu’au moment où le GPU doit traiter Wait, l’événement est probablement déjà signalé et il n’y a pas de perte d’efficacité.

Alternativement, dans certains cas, l’algorithme peut être restructuré pour réduire le nombre de points de synchronisation tout en utilisant des barrières de pipeline, diminuant la surcharge. Par exemple, une simulation de particules sur GPU peut avoir besoin d’exécuter deux compute shader pour chaque effet de particule : Un pour émettre de nouvelles particules et une autre pour simuler les particules. Ces envois nécessitent une barrière de pipeline entre eux pour synchroniser l’exécution, ce qui nécessite une barrière de pipeline par système de particules si les systèmes de particules sont simulés séquentiellement. Une meilleure implémentation soumettrait d’abord tous les compute shader d’émission de particules (chose ne dépendant pas les unes des autres), puis soumettrait une barrière pour synchroniser l’envoi des émissions et de simulation, puis soumettrait tous les envois pour simuler les particules — ce qui garderait le GPU bien utilisé plus longtemps. À partir de là, l’utilisation de barrières séparées pourrait aider à masquer complètement le coût de synchronisation.

En ce qui concerne la décompression des ressources, il est difficile de donner un conseil général — sur certaines architectures, cela ne se produit jamais, et ça arrive sur d’autres, mais selon l’algorithme, cela peut être inévitable. L’utilisation d’outils spécifiques au fournisseur tels que Radeon Graphics Profiler est essentielle pour comprendre l’impact de la décompression sur les performances sur votre frame ; dans certains cas, il peut être possible d’ajuster l’algorithme pour ne pas exiger la décompression immédiatement, par exemple en déplaçant le travail à une étape différente. Bien sûr, il convient de noter que la décompression des ressources peut se produire dans les cas où elle est totalement inutile et est le résultat de barrières « surspécifiées » — par exemple, si vous effectuez un rendu dans un framebuffer contenant un depth buffer, mais ne lisez jamais le contenu de depth, vous devriez laisser l’agencement du depth buffer à VK_IMAGE_LAYOUT_DEPTH_STENCIL_OPTIMAL au lieu de le faire passer inutilement dans VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL qui pourrait déclencher une décompression (gardez à l’esprit que le pilote ne sait pas si vous allez lire la ressource).

Simplifier la spécification des barrières

Avec toute la complexité qu’implique la spécification des barrières, il est utile d’avoir des exemples de barrières couramment utilisées. Heureusement, Khronos Group fournit de nombreux exemples de barrières pertinentes et optimales pour divers types de synchronisation dans le repo de documentation de Vulkan sur GitHub. Ces exemples peuvent servir à améliorer la compréhension du comportement général des barrières et peuvent également être utilisés directement dans une application.

De plus, pour les cas non couverts par ces exemples et, en général, pour simplifier le code de spécification et le rendre plus correct, il est possible de passer à une approche plus simple où, au lieu de spécifier l’intégralité des masques d’accès, d’étapes et des agencements d’images, le seul concept à connaître relatif à une ressource est l’état de la ressource encapsulant les étapes qui peuvent l’utiliser et le mode d’utilisation des types d’accès les plus courants. De là, toutes les transitions impliquent la transition d’une ressource de l’état A à l’état B, ce qui est beaucoup plus facile à comprendre. À cette fin, Tobias Hector, membre du Khronos Group et co-auteur de la spécification Vulkan, a écrit une bibliothèque open-source, simple_vulkan_synchronization, qui traduit les transitions de l’état des ressources (appelé « access type » dans la bibliothèque) en spécification de barrière Vulkan. La bibliothèque est petite et simple et prend en charge les barrières divisées ainsi que l’intégralité des barrières de pipeline.

Prédire l’avenir avec des graphs de rendu

Les optimisations de la section précédente sont difficiles à appliquer compte tenu des architectures en mode immédiat qu’on rencontre généralement.

Pour s’assurer que les étapes et les transitions d’agencement d’image ne sont pas sur-spécifiées, il est important de savoir comment la ressource est amenée à être utilisé — si vous souhaitez mettre une barrière de pipeline après une passe de rendu, sans ces informations, vous êtes généralement obligé de mettre une barrière avec toutes les étapes dans le masque d’étape de destination, et un agencement cible inefficace.

Pour résoudre ce problème, il est tentant de mettre les barrières avant de lire la ressource, car à ce stade, il est possible de savoir comment il a été écrit dans la-dite ressource ; cela rend cependant les barrières difficiles à grouper. Par exemple, dans une image avec 3 passes de rendu, A, B et C, où C lit la sortie de A et la sortie de B dans deux draw calls séparés, pour minimiser le nombre de vidages de cache de texture et d’autres travaux de barrière, il est généralement avantageux de spécifier une barrière avant C qui transite correctement les sorties de A et de B ; au lieu de cela, on pourrait mettre une barrière avant chacun des draw calls de C. Dans certains cas, séparer les barrières peut réduire leur coût, mais en général, une telle méthode est trop coûteuse.

De plus, l’utilisation de telles barrières nécessite le suivi de l’état de la ressource pour connaître l’agencement précédent ; c’est très difficile à faire correctement dans un système multi-threadé, car l’ordre d’exécution final sur le GPU ne peut être connu qu’une fois toutes les commandes enregistrées et linéarisées.

Pour toutes ces raisons, de nombreux moteurs de rendu modernes commencent à expérimenter les graphes de rendu comme moyen de spécifier de manière déclarative toutes les dépendances entre les ressources d’une frame. Suivant la structure DAG qui en résulte, il est possible d’établir des barrières correctes, y compris les barrières requises pour la synchronisation entre plusieurs files d’attentes, et d’allouer des ressources transitoires avec une utilisation minimale de la mémoire physique.

Cet article n’a pas pour vocation de faire une description complète d’un système de graphes de rendu, mais les lecteurs intéressés sont encouragés à se référer aux exposés et articles suivants :

Chaque moteur choisi en fonction de ses particularités, par exemple, le graph de rendu de Frostbite est spécifié par l’application en utilisant l’ordre d’exécution final (que l’auteur de cet article trouve plus prévisible et préférable), tandis que les deux autres présentations linéarisent le graph suivant certaines heuristiques pour essayer de trouver le meilleur ordre d’exécution possible. Quoi qu’il en soit, ce qu’il faut retenir c’est que les dépendances entre les passes sont être déclarées à l’avance pour la frame entière afin de s’assurer que les barrières peuvent être émises de manière appropriée. Surtout, les systèmes de graphe de frame fonctionnent bien pour les ressources transitoires qui sont limitées en nombre et représentent l’essentiel des barrières requises ; bien qu’il soit possible de spécifier dans un même système les barrières requises au chargement des ressources et autres opérations de streaming, ceci peut rendre les graphes trop complexes et le temps de traitement trop long, il est donc préférable de les gérer en dehors d’un système de graphe de frame.

Les passes de rendu

Le concept de passe de rendu est unique à Vulkan comparé aux APIs précédentes (les plus anciennes comme les nouvelles). Les passes de rendu permettent à l’application représenter le rendu de leur frame en tant qu’objet à part entière, en découpant la charge de travail en sous-passes individuelles et en énumérant explicitement les dépendances entre ces sous-passes, permettant au pilote de planifier le travail et placer les commandes de synchronisation appropriées. En ce sens, les passes de rendu sont similaires aux graphes de rendu décrits ci-dessus et peuvent être utilisées pour les implémenter avec certaines limitations (par exemple, seuls les étapes de rastérisation peuvent être exprimées en passes de rendu, ce qui signifie que plusieurs passes de rendu sont nécessaires si vous devez utiliser des étapes de calcul). Cette section se concentrera sur les usages des passes de rendu les plus simples, plus pratiques à intégrer dans les moteurs de rendu existants, tout en offrant des avantages en termes de performances.

Les opérations load et store

L’une des fonctionnalités les plus importantes des passes de rendu est la possibilité de spécifier les opérations load et store (chargement et stockage). L’application peut les utiliser pour choisir si le contenu de chaque framebuffer attaché doit être vidé, chargé depuis la mémoire (load), ou rester non spécifié et inutilisé, et si, une fois la passe de rendu terminée, le framebuffer attaché doit être stockée en mémoire (store).

Il est important d’utiliser correctement ces opérations — sur des architectures en tuile, l’utilisation redondante d’opérations de chargement ou de stockage entraîne un gaspillage de bande passante, ce qui réduit les performances et augmente la consommation d’énergie. Sur les architectures « non tuilées », le pilote pourra toujours les utiliser pour effectuer certaines optimisations des rendus ultérieurs — par exemple, si un contenu précédent d’un framebuffer attaché n’est plus pertinent, mais qu’il a des métadonnées de compression associées, le pilote peut effacer ces métadonnées pour accélérer le rendu ultérieur.

Il est important d’utiliser les opérations load et store le plus granulairement possible afin de laisser plus de liberté au pilote — par exemple, quand on rend un quad en plein écran sur un framebuffer attaché (ce qui revient à écrire tous les pixels), il est probable que VK_ATTACHMENT_LOAD_OP_CLEAR soit plus rapide que VK_ATTACHMENT_LOAD_OP_LOAD sur les GPU tuilés, et il est probable que OP_LOAD soit plus rapide sur les GPU en mode immédiat — spécifier VK_ATTACHMENT_LOAD_OP_DONT_CARE est important pour permettre au pilote de faire un choix optimal. Dans certaines situations, OP_DONT_CARE est meilleur que OP_LOAD ou OP_CLEAR, car il permet au pilote d’éviter une coûteuse opération de nettoyage du contenu, tout en effaçant quand-même les métadonnées d’image pour accélérer le rendu à venir.

À ce titre, VK_ATTACHMENT_STORE_OP_DONT_CARE doit être utilisé si l’application ne compte pas lire les données rendues dans le frame buffer attaché — c’est généralement le cas pour les depth buffers et les cibles MSAA.

Résolution rapide du MSAA

Après avoir rendu les données dans une texture MSAA, il est courant de les résoudre dans une texture non-MSAA pour un traitement ultérieur. Si résolution en fonction fixe est suffisante, il existe deux façons de l’implémenter dans Vulkan :

Dans ce dernier cas, le pilote effectuera le travail nécessaire à la résolution du MSAA dans le cadre du travail effectué quand les sous-passes/passes de rendu sont terminées.

La deuxième approche peut être beaucoup plus efficace. Sur les architectures en tuile, la première approche nécessite de stocker la totalité de la texture MSAA dans la mémoire principale, puis de la lire depuis cette mémoire et de la résoudre vers la destination ; la deuxième approche peut résolution directement « en tuile » de manière plus efficace. Sur les architectures en mode immédiat, il se peut que certaines implémentations ne prennent pas en charge la lecture des textures MSAA compressées via l’étape de transfert — l’API nécessite une transition vers la disposition VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL avant d’appeler vkCmdResolveImage, ce qui peut entraîner la décompression de la texture MSAA, gaspillant de la bande passante et des performances. Avec pResolveAttachments, le pilote peut effectuer l’opération de résolution avec des performances maximales quelle que soit l’architecture.

Dans certains cas, la résolution MSAA en fonction fixe est insuffisante. Il est alors nécessaire de faire transiter la texture vers VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL et d’effectuer la résolution dans une passe de rendu séparée. Sur les architectures en tuile, cela présente les mêmes problèmes d’efficacité que la méthode en fonction fixe vkCmdResolveImage ; sur les architectures en mode immédiat, l’efficacité dépend du GPU et du pilote. Une alternative possible consiste à utiliser un sous-passe supplémentaire qui lit la texture MSAA via un framebuffer attaché.

Pour que cela fonctionne, la première sous-passe qui rend dans texture MSAA doit spécifier cette dernière via pColorAttachments, combiné à l’opération VK_ATTACHMENT_STORE_OP_DONT_CARE. La seconde sous-passe, qui effectue la résolution, doit spécifier la texture MSAA via pInputAttachments et la cible de la résolution via pColorAttachments ; la sous-passe doit alors rendre un quad (ou un triangle) en plein écran avec un shader utilisant la ressource subpassInputMS pour lire les données du MSAA. De plus, l’application doit spécifier une dépendance indiquant les masques d’étape/d’accès entre les deux sous-passes, au même titre que les barrières de pipeline, et les indicateurs de dépendances VK_DEPENDENCY_BY_REGION_BIT. Le pilote dispose alors de suffisamment d’informations pour organiser l’exécution de façon à ce que, sur les GPUs en tuile, les données MSAA ne quittent jamais la mémoire en tuile et que la résolution s’y fasse directement, le résultat de la résolution étant écrit dans la mémoire principale10. Notez que ce comportement dépend du pilote et qu’il est peu probable que cela entraîne des économies significatives sur les GPU en mode immédiat.

Les pipeline objects

Les anciennes APIs avait pour habitude de diviser les états du GPU en blocs suivant leur fonction —  par exemple, en Direct3D 11, l’état complet des modules GPU des bindings des ressources peut être décrit à l’aide d’un ensemble d’objets de shader pour différentes étapes (VS, PS, GS, HS, DS) ainsi qu’un ensemble d’objets d’états (rasterizer, blend, depth stencil), d’une configuration de l’assemblage d’entrée (input layout, primitive topology) et quelques autres bits implicites comme les formats de cible de sorti de rendu. L’utilisateur de l’API pouvait alors définir chaque bit d’état séparément, sans tenir compte de la conception ou de la complexité du matériel sous-jacent.

Malheureusement, cette approche ne correspond pas au matériel utilisé généralement, avec plusieurs problèmes de performances pouvant survenir :

Bien que le premier problème soit bénin, le second et le troisième peuvent entraîner des blocages importants lors du rendu car, en raison de la complexité des shaders modernes et des pipelines de compilation de shader, la compilation des shader pouvait prendre des dizaines, voir des centaines de milliseconds suivant le matériel. Pour résoudre cela, Vulkan et les autres nouvelles APIs introduisirent le concept de pipeline object — il encapsule la plupart des états du GPU, y compris le format d’entrée des sommets, le format de la cible de rendu, l’état de toutes les étapes et les modules de shader pour toutes les étapes. L’objectif étant que sur chaque GPU pris en charge, ces états soient suffisants pour créer le microcode du shader final et les commandes GPU nécessaires à la configuration des états, de sorte que le pilote n’ait jamais à compiler le microcode au moment du draw et puisse optimiser autant que possible la configuration du pipeline object.

En revanche, cette approche apporte son lot de difficultés quand on implémente un moteur de rendu s’appuyant sur Vulkan. Il y a plusieurs façons de résoudre ces problèmes, avec différents compromis en termes de complexité, d’efficacité et de conception sur le moteur de rendu.

Compilation à-la-volée

Le moyen le plus simple de prendre en charge Vulkan est d’utiliser la compilation à la volée pour les objets de pipeline. Dans de nombreux moteurs, en raison du manque de concepts correspondant à Vulkan, le backend de rendu doit collecter des informations sur différentes parties de l’état du pipeline suivant divers appels de configuration d’état, de la même manière qu’un pilote Direct3D 11 pourrait faire. Puis, avant le draw/dispatch où l’intégralité des états sont connus, les bits de chacun des états sont groupés et recherchés dans une table de hash ; s’il y a déjà un objet d’état de pipeline dans le cache, il peut être utilisé directement, sinon un nouvel objet peut être créé.

Cette approche fonctionne pour faire tourner votre application, mais souffre de deux problèmes de performances.

Une préoccupation mineure est que les états à hacher sont potentiellement gros ; faire cela pour chaque draw call peut prendre du temps lorsque le cache contient déjà tous les objets pertinents. Cela peut être atténué en regroupant les états en objets et en hachant les pointeurs vers ces objets, et en général en simplifiant la représentation des états du point de vue d’une l’API plus haut niveau.

Un problème important est que pour tout objet d’état de pipeline à créer, le pilote risque de devoir compiler plusieurs shaders vers le microcode GPU final. Ce traitement prend du temps ; de plus, il ne peut pas être threadé efficacement par une approche de compilation à-la-volée — si une application utilise un thread pour la soumission des commandes, ce thread devra généralement également compiler les objets d’état du pipeline ; même avec plusieurs threads, souvent, ils solliciteraient le même pipeline object, ce qui sérialiserait la compilation, ou un thread aurait besoin de plusieurs nouveaux objets de pipeline, ce qui augmente la latence globale de la soumission puisque les autres threads finiraient en premier et n’auraient aucun travail à faire.

Pour la soumission multi-threaded, l’accès au cache peut entraîner des conflits entre les cœurs, même si le cache est plein. Heureusement, cela peut être résolu par un cache à deux niveaux comme suit :

Le cache aurait deux parties, la partie immuable qui ne change jamais durant la frame, et la partie mutable. Pour effectuer une recherche dans le cache de pipeline, on regarde d’abords si l’objet est dans le cache immuable — cela se fait sans aucune synchronisation. S’il n’y est pas, on verrouille une section critique11 et on vérifie qu’il est dans le cache mutable ; s’il n’y est toujours pas, on déverrouille la section critique, on créé le pipeline object, puis on la re-verrouille et on insère l’objet dans le cache, en déplaçant potentiellement un autre objet (une synchronisation supplémentaire ou peut être nécessaire si, lorsque deux threads demande le même objet, seul une demande de compilation est envoyée au pilote).

À la fin de la frame, tous les objets du cache mutable sont ajoutés au cache immuable, et le cache mutable est vidé, de sorte que lors de la prochaine frame, les accès à ces objets puissent être threadé.

Cache de pipeline et préchauffage du cache

Bien que la compilation à-la-volée puisse fonctionner, elle entraîne un shuttering important pendant le jeu. Dès qu’un objet avec un nouvel ensemble de shaders/états pénètre dans la frame, on se retrouve à devoir compiler son pipeline object ce qui peut être lent. C’est un problème similaire rencontré par les titres en Direct3D 11, mais avec cette API, les pilotes faisaient beaucoup de travail sous le capot pour essayer de masquer la latence de compilation, précompilant à l’avance certain shaders et implémentant des mécanismes d’injection de bytecode à-la-volée pour s’éviter une recompilation complète. Vulkan s’attend à ce que l’application gère manuellement et intelligemment la création d’objets de pipeline, de sorte qu’une approche naïve ne fonctionne pas très bien.

Afin de rendre la compilation à-la-volée plus pratique, il est important d’utiliser le cache de pipeline de Vulkan, de le sérialiser entre les exécutions, et de préchauffer le cache en mémoire décrit dans la section précédente au démarrage de l’application, à partir de plusieurs threads.

Vulkan fourni un objet de cache de pipeline, VkPipelineCache, qui peut contenir des bits d’état et un microcode de shader spécifiques au pilote pour améliorer le temps de compilation des objets de pipeline. Par exemple, si une application créée deux objets de pipeline avec des configurations identiques à l’exception du mode de culling, le microcode du shader est généralement le même. Pour s’assurer que le pilote ne compile l’objet qu’une seule fois, l’application doit transmettre la même instance de VkPipelineCache aux deux appels à vkCreateGraphicsPipelines, ce qui compilera le microcode du shader au premier appel et le réutilisera directement au second. Si ces appels se produisent simultanément dans différents threads, le pilote risque de compiler les shaders deux fois, car les données ne sont ajoutées au cache qu’à la fun de l’un des appels.

Il est vital d’utiliser le même objet VkPipelineCache quand on crée tous les objets de pipeline et de le sérialiser sur le disque entre les exécutions via vkGetPipelineCacheData et le membre pInitialData de VkPipelineCacheCreateInfo. Cela garantit que les objets compilés sont réutilisés entre les exécutions et minimise variations de FPS lors des exécutions ultérieures de l’application.

Malheureusement, les pics de compilation de shader se produiront quand même lors de la première partie, car le cache de pipeline ne contiendra pas toutes les combinaisons utilisées. De plus, même lorsque le cache du pipeline contient les microcodes nécessaires, vkCreateGraphicsPipelines reste coûteux et, par conséquent, la compilation de nouveaux objets de pipeline peut toujours augmenter la variance de la durée des frames. Pour résoudre ce problème, il est possible de préchauffer le cache en mémoire (et/ou VkPipelineCache) pendant le temps de chargement.

Une solution envisageable est qu’à la fin de la session de jeu, le moteur de rendu enregistre les données de cache du pipeline présentes en mémoire — quels shaders étaient utilisés avec quels états12 — dans une base de données. Puis, pendant les sessions de jeu de contrôle qualité, cette base de donnée pourrait être remplie par plusieurs sessions de jeu à différents paramètres graphiques, etc — rassemblant efficacement l’ensemble des états susceptibles d’être utilisés pendant une session de jeu réel.

Cette base de données peut ensuite être déployé avec le jeu ; au démarrage du jeu, le cache en mémoire est être préremplis par des états créés en utilisant les données de la base (ou, suivant la quantité d’état de pipeline, cette phase de préchauffage peut être limitée aux états des paramètres graphiques actuels). Dans l’idéal, cela devrait se faire sur plusieurs threads pour diminuer le temps de chargement ; la première exécution aurait toujours un temps de chargement plus long (qui peuvent être encore réduits avec des fonctionnalités telles que le pre-caching de Steam), mais les chutes de frame rate dus à la création de pipeline objects à-la-volée peuvent pratiquement tous être évités.

Si des combinaisons particulières d’états n’ont pu être découvertes pendant les sessions de jeu de contrôle qualité, le système fonctionnera toujours — au pris d’un léger shuttering. Cette approche est plus ou moins universelle et pratique — mais nécessite un effort potentiel pour jouer à travers suffisamment de niveaux avec suffisamment de paramètres graphiques différents pour « capturer » les situations les plus réalistes, ce qui la rend quelque peu difficile à gérer.

Compilation d’avance

La solution « parfaite » — celle pour laquelle Vulkan a été conçu — est de supprimer le cache de compilation à-la-volée et le préchauffage, et d’avoir simplement chaque objet pipeline possible, disponible à l’avance.

Ça implique en général de changer la conception du moteur de rendu et d’intégrer le concept d’état de pipeline dans le système de matériaux, permettant à un matériau de spécifier entièrement l’état. Il y a différentes approches possibles ; cette section n’en décrira qu’une seule, mais l’important est le principe général.

Un objet est généralement associé au matériau qui défini les états graphiques et les bindings de ressource nécessaire à son rendu. Dans ce cas, il est important de séparer les bindings de ressource des états graphiques, car l’objectif est de pouvoir énumérer toutes les combinaisons d’état graphique à l’avance. Appelons la collection d’états graphiques une « technique » (cette terminologie est intentionnellement similaire à la terminologie de Direct3D Effect Framework, bien qu’ici, l’état est stocké dans la passe). Les techniques peuvent ensuite être groupées en effets, et un matériau ferait référence à l’effet, et à une sorte de clé désignant la technique de cet effet.

L’ensemble des effets et techniques qui les composent seraient statiques. Les effets ne sont pas aussi vitaux à la précompilation des objets de pipeline que les techniques, mais peuvent servir à grouper sémantiquement les techniques — par exemple, souvent un matériau se voit assigné d’un effet au moment de sa création, mais la technique peut varier suivant où l’objet est rendu (e.g. passe d’ombre, passe de gbuffer, passe de réflexion) ou suivant les effets active du jeu (e.g. glow, bloom).

Fondamentalement, la technique doit spécifier tous les états requis pour créer un pipeline object, de manière statique, à l’avance — généralement dans le cadre d’une définition dans un fichier texte, que ce soit dans un DSL ressemblant à un D3DFX ou dans un fichier JSON/XML. Il doit inclure tous les shaders, les états de blend, les états culling, le format des sommets, les formats des cibles de rendu, l’état de depth. Voici un exemple de ce à quoi cela pourrait ressembler :

technique gbuffer
{
    vertex_shader gbuffer_vs
    fragment_shader gbuffer_fs

#ifdef DECAL
    depth_state less_equal false
    blend_state src_alpha one_minus_src_alpha
#else
    depth_state less_equal true
    blend_state disabled
#endif

    render_target 0 rgba16f
    render_target 1 rgba8_unorm
    render_target 2 rgba8_unorm

    vertex_layout gbuffer_vertex_struct
}

En partant du principe que tous les draw calls, y compris ceux de post-processing, etc, utilise le mécanisme des effets pour spécifier les états de rendu, et en assumant que l’ensemble des effets et techniques est statique, il est facile de pré-créer tous les objets de pipeline — chaque technique n’en nécessitant qu’un seul — au moment du chargement, en utilisant plusieurs threads, et d’utiliser un code très efficace au moment de l’exécution du programme, sans nécessiter d’avoir des caches en mémoire ou risquer des chutes de framerate.

En pratique, implémenter ce système dans un moteur de rendu moderne est un exercice de gestion de la complexité. Il est courant d’utiliser des shaders complexes ou des permutations d’états — par exemple, pour du rendu double-face on a généralement besoin de changer les états du culling et peut-être changer les shaders pour implémenter du lighting double-face. Pour le rendu de skinning, vous devez changer le format des sommets et ajouter du code au vertex shader pour transformer les attributs à l’aide de matrices de skin. Sur certaines configurations graphiques, on peut décider de que le format de cible de rendu doit être en virgule-flottante R10G11B10 au lieu de RGBA16F, pour conserver de la bande passante. Toutes ces combinaisons se multiplient et nécessite que vous soyez capable de les représenter de manière concise et efficace lors de la spécification des données techniques (par exemple, en autorisant des sections #ifdef à l’intérieur des déclarations techniques comme indiqué ci-dessus), et — surtout — être conscient du nombre croissant de combinaisons et les refactoriser/simplifier le cas échéant. Certains effets sont suffisamment rares pour être rendu dans une passe séparée sans augmenter le nombre de permutations. Certains calculs sont assez simples de sorte que les exécuter systématiquement dans tous les shaders puisse être un meilleur compromis plutôt que d’augmenter le nombre de permutations. Et certaines techniques de rendu offrent un meilleur découplage et une meilleure séparation des préoccupations13, ce qui peut également réduire le nombre de permutations.

Il est important de noter qu’ajouter la permutation d’états à l’ensemble complexifie le problème, mais ne le rend pas différent — de nombreux moteurs de rendu doivent de toute façon résoudre la problématique posée par le grand nombre de permutations de shaders, et une fois qu’on a incorporé tous les états de rendu dans la spécification des shaders/techniques et qu’on s’est concentré sur la réduction du nombre de permutations de techniques, les mêmes solutions de gestion de la complexité s’appliquent également aux deux problèmes. L’avantage d’implémenter un tel système est une parfaite connaissance de toutes les combinaisons requises (au lieu de s’appuyer sur des systèmes de découverte de permutation, fragiles), d’excellentes performances avec une variance minimale de durée entre les frames, y compris au premier chargement, et une fonction de forçage pour garder la complexité du code de rendu sous contrôle.

Conclusion

L’API Vulkan déplace une grande part de la responsabilité du pilote vers l’application. Naviguer entre les fonctionnalités de rendu devient plus difficile aux vues des nombreuses d’implémentations disponibles ; il est déjà difficile d’écrire correctement un moteur de rendu Vulkan, mais les performances et la consommation de mémoire sont fondamentales. Cet article a tenté d’aborder plusieurs considérations importantes quand on se retrouve face à des problèmes spécifiques à Vulkan, a présenté plusieurs types d’implémentations visant un compromis entre la complexité, la facilité d’utilisation et les performances, et couvert un large éventail entre le portage de moteurs de rendu existants et leur re-conception à la lueur de Vulkan.

En fin de compte, il est difficile de donner un conseil général fonctionnant pour tous les fabricants et applicable à tous les moteurs de rendu. C’est la raison pour laquelle il est essentiel de profiler son code sur la plate-forme/le fabricant cible — avec Vulkan, il est important de monitorer les performances chez tous les fabricants pour lesquels on prévoit de livrer le jeu, car les choix que fait l’application sont primordiaux, et dans certains cas, une fonction bien spécifique, comme le binding des vertex buffers en fonction fixe, est l’approche la plus rapide chez un fabricant, mais la plus lente chez un autre.

En plus d’utiliser les couches de validation s’assurant de l’exactitude du code et des outils de profilage spécifiques aux fabricants, tel que le AMD Radeon Graphics Profiler ou le NVidia Nsight Graphics, de nombreuses bibliothèques open-source permettant de vous aider à optimiser votre moteur de rendu pour Vulkan sont disponibles :

Enfin, certains fabricants développent des pilotes Vulkan open-source pour Linux ; L’étude de ces sources peut aider à mieux comprendre les performances de certaines implémentations de Vulkan :

L’auteur souhaite remercier Alex Smith (Feral Interactive), Daniel Rákos (AMD), Hans-Kristian Arntzen (ex. ARM), Matthäus Chajdas (AMD), Wessam Bahnassi (INFramez Technology Corp) et Wolfgang Engel (CONFETTI) pour la relecture du brouillon de cet article et leur contribution à son amélioration.


  1. Ndt : Le prix original est de 2,99 $, ce qui est en effet moins cher qu’une tasse de café aux États-Unis. 

  2. Nous couvrirons uniquement les types d’allocation de mémoire inscriptibles depuis l’hôte et lisibles ou inscriptibles depuis le GPU ; pour la lecture par le CPU des données écrites par GPU, la mémoire avec l’indicateur VK_MEMORY_PROPERTY_HOST_CACHED_BIT est plus appropriée. 

  3. Remarquez que VK_MEMORY_PROPERTY_HOST_COHERENT_BIT implique généralement que la mémoire sera en « écriture combinée » ; sur certains dispositifs, il est possible d’allouer de la mémoire de façon non continue et de la vider avec vkFlushMappedMemoryRanges

  4. Notez qu’avec 4 descripteurs par pipeline, cette approche ne peut pas gérer la configuration complète du pipeline pour le VS, GS, TCS et TES — ce qui n’est un problème que si vous utilisez la tesselation sur un pilote n’exposant que 4 sets de descripteurs. 

  5. Suivant l’architecture GPU, il peut également être avantageux de pousser certains index en constante, tel que l’index de matériau ou le décalage des données des sommets, pour diminuer le nombre d’indirections de la mémoire dans les vertex/fragment shaders. 

  6. Malheureusement, Vulkan ne permet pas au pilote d’implémenter l’enregistrement d’un command buffer thread-safe de sorte qu’une seule pool de commandes puisse être réutilisée entre les threads ; dans l’approche décrite, la synchronisation entre les threads n’est nécessaire que pour changer les pages, ce qui est relativement rare et peut être fait sans verrouillage pour la plupart. 

  7. Ndt : Lire « Command Buffer State — what else? » pour en savoir plus sur les états des command buffers

  8. Ndt : Plus d’information sur la page Wikipédia

  9. Il est important de noter qu’une croyance communément admise selon laquelle chaque draw call s’exécutent de manière isolée sans chevauchement avec d’autres travaux est erronée — les GPU exécutent généralement les draw calls qui suivent en parallèle entre l’état de rendu, le shader et même les commutateurs de cible de rendu. 

  10. Bien sûr, rien ne garantit que le pilote effectue cette optimisation — elle dépend de l’architecture du matériel et de l’implémentation du pilote. 

  11. Ndt : Plus d’information sur la notion de « section critique » sur la page Wikipédia

  12. Cela peut passer par un format spécifique à l’application, ou une bibliothèque comme Fossilize

  13. Ndt : Plus d’information sur la notion de « séparation des préoccupations » sur la page Wikipédia

Dernière mise à jour : mar. 22 septembre 2020