Blueprint : Fonctionnement et directive de performance
Publié : 18 déc. 2023, 10:41
On peut souvent entendre ces conseils un peu trompeurs un peu partout :
• Évitez le tick, c'est coûteux.
• Utilisez des timers à la place du tick.
• N'utilisez pas les casts, c'est coûteux.
• Privilégiez les interfaces plutôt que les castings.
• Utilisez des timelines à la place du tick.
Malheureusement, de nombreux projets suivent ces conseils, en particulier ceux qui implémentent 100% de Blueprints sans aucune ligne de code en C++. Au fil du temps, j'ai fini par développer une motivation pour comprendre comment les Blueprints fonctionnent en coulisses, et j'ai décidé d'écrire un article à ce sujet. Aujourd'hui, nous allons démystifier ces idées fausses courantes, expliquer pourquoi elles sont incorrectes, et analyser les problèmes réels du système Blueprint.![😎](//twemoji.maxcdn.com/2/svg/1f60e.svg)
Mais avant de démystifier ces mythes, examinons comment fonctionne réellement la machine virtuelle des Blueprints en coulisses, afin de comprendre la logique qui la sous-tend.
Compréhension de l'infrastructure du système Blueprint
Les Blueprints sont un langage visuel et interprété implémenté sur la machine virtuelle UnrealScript d'Unreal Engine 3. À la date d'aujourd'hui, elle surpasse l'ancienne machine virtuelle UnrealScript, mais partage également la plupart de ses limitations, comme l'incapacité à exposer des tableaux de tableaux au système de réflexion et le manque de prise en charge de divers types du système de propriétés nativement pris en charge.
Si vous êtes programmeur et que vous parcourez Stack.h, ScriptCore.cpp, UnrealTypes.h, CodeGenerator.h et Object.h dans le code source, vous verrez des commentaires hérités dans le code qui suggèrent que les types de réflexion partagent en réalité des dettes technologiques du vieux code d'Unreal Engine 3.
Faisons connaissance avec la magie appelée "bytecode"
Tout d'abord, cher lecteur, je veux que vous compreniez que les langages de programmation ont des "back-ends" et des "front-ends". Le front-end, dans ce contexte, se réfère à la partie visible par l'utilisateur du langage. Vous pouvez penser à la syntaxe, aux règles et à la grammaire d'un langage de programmation lorsque je dis front-end. En revanche, le back-end se réfère à la machine abstraite qui exécute la logique réelle que votre code est compilé en.
Les Blueprints, comme mentionné précédemment, fonctionnent sur une machine abstraite très ancienne que nous appelons "machine virtuelle", écrite à la fin des années 1990 par Tim Sweeney lui-même.
Les solutions de script d'Unreal, jusqu'à l'annonce de Verse, ont toujours fonctionné sur la même machine virtuelle, mais sous différentes formes. Selon un tweet de Tim Sweeney, le premier Unreal avait une syntaxe de style Visual Basic, puis il a évolué vers un langage de style C avec des surcharges d'opérateurs, et à la fin, après que Epic a décidé de tuer UnrealScript, il est devenu un système de script visuel.
Les opérations que la machine virtuelle Blueprints peut exécuter sont appelées bytecode. Un bytecode définit une exécution qu'une machine virtuelle peut effectuer. La machine virtuelle Blueprints peut appeler des fonctions, définir des valeurs les unes par rapport aux autres, sauter dans différents chemins de code (c'est-à-dire le Node Branch), exécuter du code récursif (boucles) et faire beaucoup d'autres choses ésotériques que vous n'avez pas besoin de connaître.
Un compilateur est responsable de la compilation du format visible par l'utilisateur en bytecode que la machine virtuelle peut exécuter. Le compilateur Blueprints lit tous vos nodes placés dans le graphe et les convertit en bytecode.
Certaines langues, comme le C++, Rust et Erlang, se compilent en code machine plutôt qu'en bytecode. Cela les rend plus rapides à exécuter, car fondamentalement, une machine virtuelle est un code écrit dans un langage déjà existant qui "agit comme" un CPU. Tout comme les machines virtuelles, nos CPU ont également des instructions prédéfinies qu'ils peuvent exécuter. Mais comme ils s'exécutent directement sur le matériel, c'est beaucoup plus rapide que d'évaluer le bytecode dans les limites d'un langage de programmation.
Alors pourquoi utilisons-nous le bytecode au lieu de compiler les Blueprints en code machine ? Parce que ce n'est pas si facile!![😄](//twemoji.maxcdn.com/2/svg/1f604.svg)
Les "langages compilés", contrairement aux "langages interprétés" comme les Blueprints qui s'exécutent à l'intérieur de machines virtuelles, prennent trop de temps à compiler et sont souvent très difficiles à fournir un environnement géré où l'accès à une variable nulle ne fait pas immédiatement planter tout votre programme.
Vous avez également l'avantage de pouvoir compiler votre code en millisecondes et l'exécuter directement, sans vous soucier des autres couches de processus que les langages compilés introduisent.
Comment les Blueprints sont-ils interprétés?
Il existe une relation profonde entre plusieurs frameworks complexes tels que le garbage collector, le graphique de Blueprint, les classes UObject, UClass, UBlueprint, etc.
Mais pour expliquer les choses simplement :
Le Blueprint Graph agit comme un conteneur pour les nodes de blueprint, c'est notre "front-end" qui nous est exposé lorsque nous codons en Blueprints.
Tout ce qui est contenu dans un Blueprint (graphiques d'événements, graphiques de fonctions et macros) est combiné en un seul graphique après la compilation. Cela s'appelle "uber graph" dans le code source.
Chaque node de blueprint est un K2Node et chaque K2Node peut fournir des informations sur la manière dont il sera compilé en bytecode. Les nodes que nous utilisons pour appeler des fonctions, le node Branch, le node Cast, ont tous du code personnalisé du côté C++ pour définir le comportement du code que vous écrivez dans les Blueprints. Si vous êtes programmeur, regardez certainement la fonction UK2Node::ExpandNode si vous êtes intéressé par les détails.
Lorsque vous appuyez sur le bouton de compilation dans un graphe de Blueprint, le compilateur de Blueprint génère du bytecode pour les nodes de votre Blueprint, puis le moteur exécute votre code en parcourant ce bytecode. Si vous êtes curieux, vous pouvez voir le bytecode généré sous une forme lisible par l'homme en ajoutant ces lignes à votre DefaultEngine.ini et en vérifiant votre journal de sortie après la compilation de votre Blueprint :
Ce que vous verrez ne sera pas le format "exact" du bytecode que le moteur génère, mais une version de celui-ci transposée en quelque chose de lisible par l'homme.
Les Blueprints sont essentiellement quelque chose comme une "machine" qui appelle du code C++.
La majorité des langages interprétés dans l'industrie du logiciel essaieront d'imiter autant que possible un CPU. Mais la machine virtuelle Blueprints est plutôt comme un "appelant de fonctions", au lieu d'une véritable machine virtuelle qui simule un CPU.
Lorsque vous écrivez votre code dans le graphe de Blueprints, si vous le prenez dans son ensemble et l'analysez, vous verrez qu'au moins environ 95% de celui-ci consiste simplement à appeler des fonctions natives ou les fonctions de blueprint que vous avez écrites. Vous verrez d'autres langages, comme JavaScript, avoir des pénalités "de déoptimisation" loufoques lorsque vous écrivez votre code sous différentes formes, même si parfois ils évaluent au même résultat. Cela est dû au fait que leur front-end, en tant que langage basé sur du texte, est plus expressif qu'un graphe où vous placez des nodes et les connectez les uns aux autres.
Mais ce n'est pas le cas dans les Blueprints, car nous sommes principalement limités par quelques nodes qui ont la capacité de contrôler le flux d'exécution de notre code, et le reste des nodes se contente d'appeler des fonctions.
Bien que cela soit un peu limitant, c'est aussi ce qui rend la machine virtuelle Blueprints très complète. Si nous voulions compiler le front-end d'une autre langue en bytecode Blueprints, cela serait assez facile (relativement à d'autres aspects obscurs du développement de langages de programmation). Le prototype initial de Verse a également été compilé dans la machine virtuelle Blueprints !
Donc, ce que j'essaie de dire, c'est que la BPVM n'a qu'un seul surcoût significatif, et c'est "l'appel de fonctions" !..
Nous verrons plus en détail ci-dessous.
Quelques faits amusants avant d'approfondir le sujet
Le premier nom du système de script visuel basé sur des nodes était Kismet et il est toujours référencé ainsi dans le code source "au lieu de ".
Certaines idées de base de Verse héritent toujours de concepts du design de la machine virtuelle Blueprints, comme le système de "mémoire persistante" et la nature latente du langage.
L'implémentation complète de la machine virtuelle Blueprints se trouve dans Stack.h et ScriptCore.cpp.
Les nodes de fonctions par défaut sont des UK2Node_CallFunctions générés par le moteur, et vous pouvez les remplacer pour implémenter votre propre node "appel de fonction".
La performance des Blueprints
Le système Blueprint comporte des coûts de fonctionnement "directs" et "indirects".
Les coûts de fonctionnement directs se réfèrent aux frais générés par les implémentations d'exécution de base de la machine virtuelle, tandis que les coûts de fonctionnement indirects sont causés par le modèle de compilation des Blueprints.
Il n'y a qu'un seul coûts de fonctionnement direct : les appels de fonction (évaluation des nodes).
Chaque fois que la machine virtuelle Blueprint évalue une fonction, elle exécute simplement un code pour invoquer la fonction C++. Même si vous avez une fonction/événement que vous avez créé dans le graphe Blueprint, vous finissez simplement par appeler des fonctions déclarées en C++ à l'intérieur. Lorsque vous démarrez un nouveau projet dans le lanceur, chaque fonction existante que vous pouvez ajouter au graphe Blueprint est définie en C++. Les événements qui sont automatiquement ajoutés au graphe lorsque vous créez une nouvelle classe Blueprint, tels que BeginPlay, Tick, OnBeginOverlap, sont en réalité des fonctions C++ appelées par le moteur.
Presque chaque appel de fonction a le même coûts de fonctionnement. En coulisses, la machine virtuelle Blueprint exécute le même code pour évaluer chaque node dans le graphe.
Dans la vidéo, Epic affirme que les appels de fonctions ont presque le même coûts de fonctionnement dans les Blueprints, mais c'est en fait incorrect. Plus vous avez de paramètres et plus les types sont complexes/grands, plus il sera coûteux d'appeler une fonction. Ne laissez pas cette information vous induire en erreur et essayez d'optimiser votre manière de réduire le nombre de paramètres dans vos fonctions, cela aurait un impact très minimal sur la performance. Concentrez-vous toujours sur les résultats du profileur !
Si vous êtes familier avec Python, vous savez probablement que la plupart des packages courants sont réellement écrits dans des langages différents pour assurer une exécution plus rapide. Vous verrez peut-être même beaucoup de gens conseiller d'éviter d'implémenter des algorithmes de structure de données et d'utiliser ce que les packages fournissent. (par exemple, utiliser les fonctions max() ou min() au lieu de parcourir des listes gigantesques).
Nous pouvons appliquer la même philosophie aux Blueprints également. Il existe de nombreuses opérations que vous pouvez implémenter en C++ pour améliorer les performances des Blueprints de 20 fois dans le code de jeu régulier. Moins vous avez de nodes dans un graphe, moins d'instructions la machine virtuelle devra exécuter. Donc, si vous cherchez à optimiser votre code Blueprint, la seule chose que vous pouvez faire est de réduire le nombre de nodes à exécuter.
Si vous examinez les bibliothèques Kismet implémentées dans le code source d'Unreal Engine (les fonctions d'aide statiques pour les mathématiques, les traces de ligne, les vérifications de chevauchement, etc.), c'est en réalité ce qu'Epic Games essaie de faire. Gérer les opérations coûteuses à exécuter en C++ et permettre à l'utilisateur de s'en sortir avec un seul coûts de fonctionnement d'appel de fonction dans le graphe Blueprint. Imaginez devoir parcourir tous les chevauchements possibles et les interroger dans les Blueprints au lieu d'utiliser Sphere Overlap by Actors... Ce serait une expérience terrible pour votre pauvre CPU!![😛](//twemoji.maxcdn.com/2/svg/1f61b.svg)
Donc, en plus de déplacer les choses en C++ pendant le développement lorsque vous avez besoin de plus de performances, si vous n'êtes pas déjà programmeur, apprendre quelques notions de C++ pour décharger certaines tâches du côté des Blueprints vers le côté C++ serait très utile pour vous.
coûts de fonctionnement indirects
Les nodes purs peuvent être dangereux, car ils sont évalués chaque fois qu'ils sont branchés à un paramètre d'entrée.
Il existe deux types de fonctions Blueprint :
• Nodes impurs
• Nodes purs
Nodes impurs
Les nodes impurs sont des nodes bleus qui ont des broches d'exécution. Lorsque les nodes impurs sont évalués, leurs sorties deviennent des variables locales dans le graphe Blueprint. Ainsi, chaque fois que vous appelez une fonction impure, la machine virtuelle Blueprint créera des variables locales (cachées) pour chaque paramètre de sortie. Donc, si l'un des paramètres de sortie est connecté à l'un des paramètres d'entrée de la fonction suivante, la machine virtuelle Blueprint accédera à la variable locale cachée qu'elle a créée lorsque ce node a été exécuté la dernière fois et l'utilisera. Si vous avez plusieurs paramètres de sortie coûteux à copier (comme plusieurs types non primitifs tels que des vecteurs, des rotators et des structures personnalisées), vous devriez préférer utiliser une fonction impure.
Nodes purs
• Les nodes purs sont des nodes verts qui n'ont aucune broche d'exécution.
• Lorsqu'un node pur est évalué, il est invoqué pour chaque ligne qui sort de sa sortie. Vous ne devriez jamais avoir de fonctions coûteuses à exécuter en tant que nodes purs en raison de cela.
• Ils fonctionnent mieux pour fournir des références d'objets et des opérations mathématiques simples (blocs).
• Les nodes "Get" des variables sont également des fonctions pures.
• Utilisez les nodes purs avec précaution lorsque vous êtes dans le corps d'une boucle. Préférez mettre en cache les variables avant d'exécuter une boucle et accéder aux variables mises en cache à la place.
Coût de préparation des paramètres et de copie
Chaque fois que vous appelez une fonction, vous déclenchez une chaîne d'appels virtuels qui copient et initialisent les paramètres de la fonction à l'intérieur de la machine virtuelle.
La dépendance aux fonctions virtuelles est l'une des raisons qui rend la machine virtuelle Blueprint extrêmement lente.
• Chaque fois que vous appelez une fonction, il y a un coût pour définir des variables par paramètre d'entrée et de sortie, même si rien n'est connecté aux broches.
• Cependant, avec les ordinateurs modernes, il s'agit d'une opération bon marché et presque JAMAIS d'une préoccupation en termes de performances. Bien sûr, cela dépend également du nombre d'entrées/sorties dans cette fonction Blueprint.
Définir une variable signifie "la copier".
Comme mentionné, les ordinateurs modernes peuvent gérer des opérations simples comme définir une variable, mais nous ne devrions pas oublier que définir une variable signifie copier une valeur. Par exemple, si vous faites :
Vous copieriez 4 octets de valeur de myValue à myNewValue. Et c'est parfaitement bien. Mais que se passe-t-il si vous copiez quelque chose comme ça ? :
Vous copieriez environ 50 kilo-octets de données (12 octets * 4096), ce qui, encore une fois, sera probablement toujours acceptable pour un processeur moderne, sauf si vous répétez cette action plusieurs fois à la fois. Les Blueprints font cela un peu partout et cachent cette opération de l'interface utilisateur frontal.
Aussi, étant donné que par défaut, les paramètres d'entrée des fonctions Blueprint sont définis pour être passés par copie, vous ne devriez pas copier des tableaux ou ne devriez pas avoir trop de paramètres de sortie. Vous pouvez éviter de copier les arguments passés en marquant les arguments d'un Blueprint comme "Pass-By-Reference".
L'image ci-dessous montre une fonction qui prend 2 arguments. L'un est passé par référence et l'autre est passé par valeur. La référence n'aura pas l'instruction supplémentaire de créer une nouvelle variable temporaire, tandis que le passage par valeur en aura.
Lors de l'appel de la fonction, vous pouvez voir que la broche de passage par référence est maintenant un losange, ce qui signifie que cette variable doit être passée par référence. Cela signifie également que vous ne pouvez pas "définir" sa valeur sur quoi que ce soit, vous devez lui passer une variable qui existe!
Cela étant dit, si la variable passée par référence est modifiée dans la fonction, cette variable sera ce que sa valeur a été changée à l'extérieur de cette fonction.
Revenons à la discussion sur les fonctions pures, voici un exemple d'il y a un moment. Quelqu'un a posté cette image sur Reddit et les gens se demandaient ce qui se passerait si vous exécutiez réellement cette fonction :
Avant de voir les coûts de fonctionnement de la fonction, vous verriez les coûts de fonctionnement de votre CPU essayant de copier des valeurs pour les paramètres d'entrée et les coûts de fonctionnement de les instancier en tant que variables (cachées). Ce serait une expérience terrible pour votre CPU![😄](//twemoji.maxcdn.com/2/svg/1f604.svg)
Il y a une raison pour laquelle le système Blueprint est considéré comme un langage de script. Si possible, vous devriez certainement éviter d'écrire l'architecture du système avec lui.
Nodes Make et Break de structs
Pour chaque struct que vous avez dans votre projet, Unreal Engine génère des nodes Make et Break par défaut. Ces nodes ne sont pas différents des autres fonctions en termes de manipulation des paramètres d'entrée/sortie. Ainsi, chaque fois que vous utilisez un node Break Hit Result, vous finissez par copier presque chaque variable du résultat de l'impact, même si certaines des broches ne sont pas utilisées.
Mythes
Maintenant, enfin, parlons des mythes du langage Blueprint !
Mythe 1 : Évitez le tick, c'est cher.
Comme expliqué ci-dessus, le seul surcoût direct du système Blueprint est le surcoût de l'invocation de fonction. Un simple événement tick ne détruira pas vos performances par lui-même, mais les nodes connectés à votre événement tick s'exécuteront à chaque image et l'accès à la mémoire pour les instructions peut finir par être beaucoup plus lent que le code C++. À moins que vous ne prévoyiez de diffuser sur du matériel bas de gamme comme une PS4, vous n'avez pas besoin d'éviter tick dans toutes les situations. Profilez toujours votre jeu en configuration de diffusion sur la cible matérielle la plus basse que vous prévoyez de sortir.
Je voulais fournir des données sur le surcoût de l'exécution d'un seul node tick vide sur un matériel moins performant, mais je n'ai pas accès à un matériel bas de gamme pour le profiler. Cependant, pour ce que ça vaut, j'ai entendu dire qu'appeler environ 300 nodes tick vides dans une seule image coûte environ 1 ms par image. D'une manière ou d'une autre, essayez de ne jamais croire ce que quelqu'un dit - sauf tout ce que je viens de dire dans ce post - à moins de le profiler vous-même pour voir les performances de vos propres yeux.
Mythe 2 : Utilisez des minuteurs au lieu de tick.
Non. Cliquez pour voir pourquoi.
N'oubliez pas non plus que la broche carrée rouge à laquelle vous connectez un événement Set Timer by Event est un délégué dynamique (dispatcher d'événements, en termes de BP) et il est coûteux à appeler. 100 ticks BP contre 100 invocations de délégué dynamique aboutiront au résultat que les délégués dynamiques provoquent plus de surcoûts que les ticks BP.
Set Timer by Function Name est encore pire car le moteur parcourra tous les événements appelables de votre classe pour trouver la fonction donnée avec le nom, il y a donc un surcoût de temps de recherche supplémentaire par rapport à Set Timer by Event.
Cela ne signifie pas pour autant que vous devriez toujours préférer tick pour tout. Il y a des moments où les minuteurs peuvent être utiles par rapport à tick, mais vous ne devriez pas essayer de remplacer la fonction tick par des minuteurs pour des raisons de performances.
Mythe 3 : N'utilisez pas le cast, c'est cher.
"Cast" en lui-même n'est rien d'autre qu'une boucle élégante qui parcourt les données de la hiérarchie de classes dans le système de réflexion. Si le type de classe donné est trouvé dans la boucle, le moteur effectue un cast de style C vers le type donné (ce qui est... littéralement gratuit pour le CPU à exécuter) et renvoie un pointeur vers celui-ci.
La raison pour laquelle les gens disent que "le cast est cher" est que, lorsque vous référence quelque chose dans votre classe Blueprint, le moteur charge automatiquement ces références avec votre Blueprint dans la mémoire. Vous devriez préférer utiliser des références souples et concevoir vos systèmes correctement pour éviter de charger la moitié du jeu avec une seule classe Blueprint. J'ai vu des "bibliothèques de fonctions Blueprint" qui ont des références aux personnages principaux du jeu dans les paramètres d'entrée de leurs fonctions et finissent par charger au moins 2 Go de données en mémoire pour absolument aucune raison.![🤦♂️](//twemoji.maxcdn.com/2/svg/1f926-200d-2642-fe0f.svg)
Alors essayez de ne pas avoir de références dures à des Blueprints intensifs en mémoire dans votre graphe au lieu d'éviter le cast.
Mythe 4 : Préférez les interfaces au lieu du cast.
Les interfaces sont une sorte de chose "d'héritage multiple" et leur existence n'est pas destinée à remplacer les casts.
Une interface est une description des actions qu'un objet peut faire. Par exemple, lorsque vous basculez un interrupteur, la lumière s'allume, vous ne vous souciez pas de comment, juste que cela se produit. Un acteur pourrait l'implémenter de cette manière, un autre acteur pourrait l'implémenter de cette manière. C'est là que les interfaces sont utiles.
En fait, en coulisses, le moteur fait en réalité un cast pour accéder à l'interface dans l'objet donné. Ainsi, les personnes qui utilisent des interfaces en raison de ce mythe "N'utilisez pas le cast" n'évitent en fait aucun cast.
Mythe 5 : Utilisez des chronologies au lieu de tick.
Chaque node de chronologie finit par être un composant de votre classe d'acteur et coûte de la mémoire. Ils sont excellents pour évaluer des courbes et fournissent également un éditeur de courbes en ligne cool que vous pouvez ouvrir et éditer vos courbes à l'intérieur du graphe de Blueprint de l'acteur, mais ils ont aussi des inconvénients :
Chaque node de chronologie finit également par être un autre "enregistrement de fonction tick" pour le "gestionnaire de tick" du moteur. Le gestionnaire de tick met en file d'attente, met à jour et vérifie l'état des fonctions tick enregistrées. Ainsi, avoir trop de chronologies peut entraîner un léger goulot d'étranglement pour le gestionnaire de tick. Avoir trop de chronologies fait que l'acteur instancie et enregistre un composant, ce qui est étonnamment plus cher que simplement instancier. Ainsi, cela finit par rendre l'acteur plus cher à créer. Je dirais, utilisez des chronologies, mais ne remplacez pas tick par elles. Elles sont bonnes pour des ticks séquentiels qui sont activés et désactivés à des moments spécifiques et aussi lorsque vous avez besoin de l'évaluation d'une courbe également.
C'était tout. Merci de m'avoir lu ! Assurez-vous de partager cet article avec tous les amis qui portent encore un chapeau en étain !![😊](//twemoji.maxcdn.com/2/svg/1f60a.svg)
Source: https://intaxwashere.github.io/blueprint-performance/
• Évitez le tick, c'est coûteux.
• Utilisez des timers à la place du tick.
• N'utilisez pas les casts, c'est coûteux.
• Privilégiez les interfaces plutôt que les castings.
• Utilisez des timelines à la place du tick.
Malheureusement, de nombreux projets suivent ces conseils, en particulier ceux qui implémentent 100% de Blueprints sans aucune ligne de code en C++. Au fil du temps, j'ai fini par développer une motivation pour comprendre comment les Blueprints fonctionnent en coulisses, et j'ai décidé d'écrire un article à ce sujet. Aujourd'hui, nous allons démystifier ces idées fausses courantes, expliquer pourquoi elles sont incorrectes, et analyser les problèmes réels du système Blueprint.
Mais avant de démystifier ces mythes, examinons comment fonctionne réellement la machine virtuelle des Blueprints en coulisses, afin de comprendre la logique qui la sous-tend.
Compréhension de l'infrastructure du système Blueprint
Les Blueprints sont un langage visuel et interprété implémenté sur la machine virtuelle UnrealScript d'Unreal Engine 3. À la date d'aujourd'hui, elle surpasse l'ancienne machine virtuelle UnrealScript, mais partage également la plupart de ses limitations, comme l'incapacité à exposer des tableaux de tableaux au système de réflexion et le manque de prise en charge de divers types du système de propriétés nativement pris en charge.
Si vous êtes programmeur et que vous parcourez Stack.h, ScriptCore.cpp, UnrealTypes.h, CodeGenerator.h et Object.h dans le code source, vous verrez des commentaires hérités dans le code qui suggèrent que les types de réflexion partagent en réalité des dettes technologiques du vieux code d'Unreal Engine 3.
Faisons connaissance avec la magie appelée "bytecode"
Tout d'abord, cher lecteur, je veux que vous compreniez que les langages de programmation ont des "back-ends" et des "front-ends". Le front-end, dans ce contexte, se réfère à la partie visible par l'utilisateur du langage. Vous pouvez penser à la syntaxe, aux règles et à la grammaire d'un langage de programmation lorsque je dis front-end. En revanche, le back-end se réfère à la machine abstraite qui exécute la logique réelle que votre code est compilé en.
Les Blueprints, comme mentionné précédemment, fonctionnent sur une machine abstraite très ancienne que nous appelons "machine virtuelle", écrite à la fin des années 1990 par Tim Sweeney lui-même.
Les solutions de script d'Unreal, jusqu'à l'annonce de Verse, ont toujours fonctionné sur la même machine virtuelle, mais sous différentes formes. Selon un tweet de Tim Sweeney, le premier Unreal avait une syntaxe de style Visual Basic, puis il a évolué vers un langage de style C avec des surcharges d'opérateurs, et à la fin, après que Epic a décidé de tuer UnrealScript, il est devenu un système de script visuel.
Les opérations que la machine virtuelle Blueprints peut exécuter sont appelées bytecode. Un bytecode définit une exécution qu'une machine virtuelle peut effectuer. La machine virtuelle Blueprints peut appeler des fonctions, définir des valeurs les unes par rapport aux autres, sauter dans différents chemins de code (c'est-à-dire le Node Branch), exécuter du code récursif (boucles) et faire beaucoup d'autres choses ésotériques que vous n'avez pas besoin de connaître.
Un compilateur est responsable de la compilation du format visible par l'utilisateur en bytecode que la machine virtuelle peut exécuter. Le compilateur Blueprints lit tous vos nodes placés dans le graphe et les convertit en bytecode.
Certaines langues, comme le C++, Rust et Erlang, se compilent en code machine plutôt qu'en bytecode. Cela les rend plus rapides à exécuter, car fondamentalement, une machine virtuelle est un code écrit dans un langage déjà existant qui "agit comme" un CPU. Tout comme les machines virtuelles, nos CPU ont également des instructions prédéfinies qu'ils peuvent exécuter. Mais comme ils s'exécutent directement sur le matériel, c'est beaucoup plus rapide que d'évaluer le bytecode dans les limites d'un langage de programmation.
Alors pourquoi utilisons-nous le bytecode au lieu de compiler les Blueprints en code machine ? Parce que ce n'est pas si facile!
Les "langages compilés", contrairement aux "langages interprétés" comme les Blueprints qui s'exécutent à l'intérieur de machines virtuelles, prennent trop de temps à compiler et sont souvent très difficiles à fournir un environnement géré où l'accès à une variable nulle ne fait pas immédiatement planter tout votre programme.
Vous avez également l'avantage de pouvoir compiler votre code en millisecondes et l'exécuter directement, sans vous soucier des autres couches de processus que les langages compilés introduisent.
Comment les Blueprints sont-ils interprétés?
Il existe une relation profonde entre plusieurs frameworks complexes tels que le garbage collector, le graphique de Blueprint, les classes UObject, UClass, UBlueprint, etc.
Mais pour expliquer les choses simplement :
Le Blueprint Graph agit comme un conteneur pour les nodes de blueprint, c'est notre "front-end" qui nous est exposé lorsque nous codons en Blueprints.
Tout ce qui est contenu dans un Blueprint (graphiques d'événements, graphiques de fonctions et macros) est combiné en un seul graphique après la compilation. Cela s'appelle "uber graph" dans le code source.
Chaque node de blueprint est un K2Node et chaque K2Node peut fournir des informations sur la manière dont il sera compilé en bytecode. Les nodes que nous utilisons pour appeler des fonctions, le node Branch, le node Cast, ont tous du code personnalisé du côté C++ pour définir le comportement du code que vous écrivez dans les Blueprints. Si vous êtes programmeur, regardez certainement la fonction UK2Node::ExpandNode si vous êtes intéressé par les détails.
Lorsque vous appuyez sur le bouton de compilation dans un graphe de Blueprint, le compilateur de Blueprint génère du bytecode pour les nodes de votre Blueprint, puis le moteur exécute votre code en parcourant ce bytecode. Si vous êtes curieux, vous pouvez voir le bytecode généré sous une forme lisible par l'homme en ajoutant ces lignes à votre DefaultEngine.ini et en vérifiant votre journal de sortie après la compilation de votre Blueprint :
Code : Tout sélectionner
[Kismet]
CompileDisplaysBinaryBackend=True
Les Blueprints sont essentiellement quelque chose comme une "machine" qui appelle du code C++.
La majorité des langages interprétés dans l'industrie du logiciel essaieront d'imiter autant que possible un CPU. Mais la machine virtuelle Blueprints est plutôt comme un "appelant de fonctions", au lieu d'une véritable machine virtuelle qui simule un CPU.
Lorsque vous écrivez votre code dans le graphe de Blueprints, si vous le prenez dans son ensemble et l'analysez, vous verrez qu'au moins environ 95% de celui-ci consiste simplement à appeler des fonctions natives ou les fonctions de blueprint que vous avez écrites. Vous verrez d'autres langages, comme JavaScript, avoir des pénalités "de déoptimisation" loufoques lorsque vous écrivez votre code sous différentes formes, même si parfois ils évaluent au même résultat. Cela est dû au fait que leur front-end, en tant que langage basé sur du texte, est plus expressif qu'un graphe où vous placez des nodes et les connectez les uns aux autres.
Mais ce n'est pas le cas dans les Blueprints, car nous sommes principalement limités par quelques nodes qui ont la capacité de contrôler le flux d'exécution de notre code, et le reste des nodes se contente d'appeler des fonctions.
Bien que cela soit un peu limitant, c'est aussi ce qui rend la machine virtuelle Blueprints très complète. Si nous voulions compiler le front-end d'une autre langue en bytecode Blueprints, cela serait assez facile (relativement à d'autres aspects obscurs du développement de langages de programmation). Le prototype initial de Verse a également été compilé dans la machine virtuelle Blueprints !
Donc, ce que j'essaie de dire, c'est que la BPVM n'a qu'un seul surcoût significatif, et c'est "l'appel de fonctions" !..
Nous verrons plus en détail ci-dessous.
Quelques faits amusants avant d'approfondir le sujet
Le premier nom du système de script visuel basé sur des nodes était Kismet et il est toujours référencé ainsi dans le code source "au lieu de ".
Certaines idées de base de Verse héritent toujours de concepts du design de la machine virtuelle Blueprints, comme le système de "mémoire persistante" et la nature latente du langage.
L'implémentation complète de la machine virtuelle Blueprints se trouve dans Stack.h et ScriptCore.cpp.
Les nodes de fonctions par défaut sont des UK2Node_CallFunctions générés par le moteur, et vous pouvez les remplacer pour implémenter votre propre node "appel de fonction".
La performance des Blueprints
Le système Blueprint comporte des coûts de fonctionnement "directs" et "indirects".
Les coûts de fonctionnement directs se réfèrent aux frais générés par les implémentations d'exécution de base de la machine virtuelle, tandis que les coûts de fonctionnement indirects sont causés par le modèle de compilation des Blueprints.
Il n'y a qu'un seul coûts de fonctionnement direct : les appels de fonction (évaluation des nodes).
Chaque fois que la machine virtuelle Blueprint évalue une fonction, elle exécute simplement un code pour invoquer la fonction C++. Même si vous avez une fonction/événement que vous avez créé dans le graphe Blueprint, vous finissez simplement par appeler des fonctions déclarées en C++ à l'intérieur. Lorsque vous démarrez un nouveau projet dans le lanceur, chaque fonction existante que vous pouvez ajouter au graphe Blueprint est définie en C++. Les événements qui sont automatiquement ajoutés au graphe lorsque vous créez une nouvelle classe Blueprint, tels que BeginPlay, Tick, OnBeginOverlap, sont en réalité des fonctions C++ appelées par le moteur.
Presque chaque appel de fonction a le même coûts de fonctionnement. En coulisses, la machine virtuelle Blueprint exécute le même code pour évaluer chaque node dans le graphe.
Dans la vidéo, Epic affirme que les appels de fonctions ont presque le même coûts de fonctionnement dans les Blueprints, mais c'est en fait incorrect. Plus vous avez de paramètres et plus les types sont complexes/grands, plus il sera coûteux d'appeler une fonction. Ne laissez pas cette information vous induire en erreur et essayez d'optimiser votre manière de réduire le nombre de paramètres dans vos fonctions, cela aurait un impact très minimal sur la performance. Concentrez-vous toujours sur les résultats du profileur !
Si vous êtes familier avec Python, vous savez probablement que la plupart des packages courants sont réellement écrits dans des langages différents pour assurer une exécution plus rapide. Vous verrez peut-être même beaucoup de gens conseiller d'éviter d'implémenter des algorithmes de structure de données et d'utiliser ce que les packages fournissent. (par exemple, utiliser les fonctions max() ou min() au lieu de parcourir des listes gigantesques).
Nous pouvons appliquer la même philosophie aux Blueprints également. Il existe de nombreuses opérations que vous pouvez implémenter en C++ pour améliorer les performances des Blueprints de 20 fois dans le code de jeu régulier. Moins vous avez de nodes dans un graphe, moins d'instructions la machine virtuelle devra exécuter. Donc, si vous cherchez à optimiser votre code Blueprint, la seule chose que vous pouvez faire est de réduire le nombre de nodes à exécuter.
Si vous examinez les bibliothèques Kismet implémentées dans le code source d'Unreal Engine (les fonctions d'aide statiques pour les mathématiques, les traces de ligne, les vérifications de chevauchement, etc.), c'est en réalité ce qu'Epic Games essaie de faire. Gérer les opérations coûteuses à exécuter en C++ et permettre à l'utilisateur de s'en sortir avec un seul coûts de fonctionnement d'appel de fonction dans le graphe Blueprint. Imaginez devoir parcourir tous les chevauchements possibles et les interroger dans les Blueprints au lieu d'utiliser Sphere Overlap by Actors... Ce serait une expérience terrible pour votre pauvre CPU!
Donc, en plus de déplacer les choses en C++ pendant le développement lorsque vous avez besoin de plus de performances, si vous n'êtes pas déjà programmeur, apprendre quelques notions de C++ pour décharger certaines tâches du côté des Blueprints vers le côté C++ serait très utile pour vous.
coûts de fonctionnement indirects
Les nodes purs peuvent être dangereux, car ils sont évalués chaque fois qu'ils sont branchés à un paramètre d'entrée.
Il existe deux types de fonctions Blueprint :
• Nodes impurs
• Nodes purs
Nodes impurs
Les nodes impurs sont des nodes bleus qui ont des broches d'exécution. Lorsque les nodes impurs sont évalués, leurs sorties deviennent des variables locales dans le graphe Blueprint. Ainsi, chaque fois que vous appelez une fonction impure, la machine virtuelle Blueprint créera des variables locales (cachées) pour chaque paramètre de sortie. Donc, si l'un des paramètres de sortie est connecté à l'un des paramètres d'entrée de la fonction suivante, la machine virtuelle Blueprint accédera à la variable locale cachée qu'elle a créée lorsque ce node a été exécuté la dernière fois et l'utilisera. Si vous avez plusieurs paramètres de sortie coûteux à copier (comme plusieurs types non primitifs tels que des vecteurs, des rotators et des structures personnalisées), vous devriez préférer utiliser une fonction impure.
Nodes purs
• Les nodes purs sont des nodes verts qui n'ont aucune broche d'exécution.
• Lorsqu'un node pur est évalué, il est invoqué pour chaque ligne qui sort de sa sortie. Vous ne devriez jamais avoir de fonctions coûteuses à exécuter en tant que nodes purs en raison de cela.
• Ils fonctionnent mieux pour fournir des références d'objets et des opérations mathématiques simples (blocs).
• Les nodes "Get" des variables sont également des fonctions pures.
• Utilisez les nodes purs avec précaution lorsque vous êtes dans le corps d'une boucle. Préférez mettre en cache les variables avant d'exécuter une boucle et accéder aux variables mises en cache à la place.
Coût de préparation des paramètres et de copie
Chaque fois que vous appelez une fonction, vous déclenchez une chaîne d'appels virtuels qui copient et initialisent les paramètres de la fonction à l'intérieur de la machine virtuelle.
La dépendance aux fonctions virtuelles est l'une des raisons qui rend la machine virtuelle Blueprint extrêmement lente.
• Chaque fois que vous appelez une fonction, il y a un coût pour définir des variables par paramètre d'entrée et de sortie, même si rien n'est connecté aux broches.
• Cependant, avec les ordinateurs modernes, il s'agit d'une opération bon marché et presque JAMAIS d'une préoccupation en termes de performances. Bien sûr, cela dépend également du nombre d'entrées/sorties dans cette fonction Blueprint.
Définir une variable signifie "la copier".
Comme mentionné, les ordinateurs modernes peuvent gérer des opérations simples comme définir une variable, mais nous ne devrions pas oublier que définir une variable signifie copier une valeur. Par exemple, si vous faites :
Code : Tout sélectionner
float myValue = 3.f;
float myNewValue = myValue;
Code : Tout sélectionner
FVector vectorArray[4096];
FVector newVectorArray[4096] = vectorArray;
Aussi, étant donné que par défaut, les paramètres d'entrée des fonctions Blueprint sont définis pour être passés par copie, vous ne devriez pas copier des tableaux ou ne devriez pas avoir trop de paramètres de sortie. Vous pouvez éviter de copier les arguments passés en marquant les arguments d'un Blueprint comme "Pass-By-Reference".
L'image ci-dessous montre une fonction qui prend 2 arguments. L'un est passé par référence et l'autre est passé par valeur. La référence n'aura pas l'instruction supplémentaire de créer une nouvelle variable temporaire, tandis que le passage par valeur en aura.
Lors de l'appel de la fonction, vous pouvez voir que la broche de passage par référence est maintenant un losange, ce qui signifie que cette variable doit être passée par référence. Cela signifie également que vous ne pouvez pas "définir" sa valeur sur quoi que ce soit, vous devez lui passer une variable qui existe!
Cela étant dit, si la variable passée par référence est modifiée dans la fonction, cette variable sera ce que sa valeur a été changée à l'extérieur de cette fonction.
Revenons à la discussion sur les fonctions pures, voici un exemple d'il y a un moment. Quelqu'un a posté cette image sur Reddit et les gens se demandaient ce qui se passerait si vous exécutiez réellement cette fonction :
Avant de voir les coûts de fonctionnement de la fonction, vous verriez les coûts de fonctionnement de votre CPU essayant de copier des valeurs pour les paramètres d'entrée et les coûts de fonctionnement de les instancier en tant que variables (cachées). Ce serait une expérience terrible pour votre CPU
Il y a une raison pour laquelle le système Blueprint est considéré comme un langage de script. Si possible, vous devriez certainement éviter d'écrire l'architecture du système avec lui.
Nodes Make et Break de structs
Pour chaque struct que vous avez dans votre projet, Unreal Engine génère des nodes Make et Break par défaut. Ces nodes ne sont pas différents des autres fonctions en termes de manipulation des paramètres d'entrée/sortie. Ainsi, chaque fois que vous utilisez un node Break Hit Result, vous finissez par copier presque chaque variable du résultat de l'impact, même si certaines des broches ne sont pas utilisées.
Mythes
Maintenant, enfin, parlons des mythes du langage Blueprint !
Mythe 1 : Évitez le tick, c'est cher.
Comme expliqué ci-dessus, le seul surcoût direct du système Blueprint est le surcoût de l'invocation de fonction. Un simple événement tick ne détruira pas vos performances par lui-même, mais les nodes connectés à votre événement tick s'exécuteront à chaque image et l'accès à la mémoire pour les instructions peut finir par être beaucoup plus lent que le code C++. À moins que vous ne prévoyiez de diffuser sur du matériel bas de gamme comme une PS4, vous n'avez pas besoin d'éviter tick dans toutes les situations. Profilez toujours votre jeu en configuration de diffusion sur la cible matérielle la plus basse que vous prévoyez de sortir.
Je voulais fournir des données sur le surcoût de l'exécution d'un seul node tick vide sur un matériel moins performant, mais je n'ai pas accès à un matériel bas de gamme pour le profiler. Cependant, pour ce que ça vaut, j'ai entendu dire qu'appeler environ 300 nodes tick vides dans une seule image coûte environ 1 ms par image. D'une manière ou d'une autre, essayez de ne jamais croire ce que quelqu'un dit - sauf tout ce que je viens de dire dans ce post - à moins de le profiler vous-même pour voir les performances de vos propres yeux.
Mythe 2 : Utilisez des minuteurs au lieu de tick.
Non. Cliquez pour voir pourquoi.
N'oubliez pas non plus que la broche carrée rouge à laquelle vous connectez un événement Set Timer by Event est un délégué dynamique (dispatcher d'événements, en termes de BP) et il est coûteux à appeler. 100 ticks BP contre 100 invocations de délégué dynamique aboutiront au résultat que les délégués dynamiques provoquent plus de surcoûts que les ticks BP.
Set Timer by Function Name est encore pire car le moteur parcourra tous les événements appelables de votre classe pour trouver la fonction donnée avec le nom, il y a donc un surcoût de temps de recherche supplémentaire par rapport à Set Timer by Event.
Cela ne signifie pas pour autant que vous devriez toujours préférer tick pour tout. Il y a des moments où les minuteurs peuvent être utiles par rapport à tick, mais vous ne devriez pas essayer de remplacer la fonction tick par des minuteurs pour des raisons de performances.
Mythe 3 : N'utilisez pas le cast, c'est cher.
"Cast" en lui-même n'est rien d'autre qu'une boucle élégante qui parcourt les données de la hiérarchie de classes dans le système de réflexion. Si le type de classe donné est trouvé dans la boucle, le moteur effectue un cast de style C vers le type donné (ce qui est... littéralement gratuit pour le CPU à exécuter) et renvoie un pointeur vers celui-ci.
La raison pour laquelle les gens disent que "le cast est cher" est que, lorsque vous référence quelque chose dans votre classe Blueprint, le moteur charge automatiquement ces références avec votre Blueprint dans la mémoire. Vous devriez préférer utiliser des références souples et concevoir vos systèmes correctement pour éviter de charger la moitié du jeu avec une seule classe Blueprint. J'ai vu des "bibliothèques de fonctions Blueprint" qui ont des références aux personnages principaux du jeu dans les paramètres d'entrée de leurs fonctions et finissent par charger au moins 2 Go de données en mémoire pour absolument aucune raison.
Alors essayez de ne pas avoir de références dures à des Blueprints intensifs en mémoire dans votre graphe au lieu d'éviter le cast.
Mythe 4 : Préférez les interfaces au lieu du cast.
Les interfaces sont une sorte de chose "d'héritage multiple" et leur existence n'est pas destinée à remplacer les casts.
Une interface est une description des actions qu'un objet peut faire. Par exemple, lorsque vous basculez un interrupteur, la lumière s'allume, vous ne vous souciez pas de comment, juste que cela se produit. Un acteur pourrait l'implémenter de cette manière, un autre acteur pourrait l'implémenter de cette manière. C'est là que les interfaces sont utiles.
En fait, en coulisses, le moteur fait en réalité un cast pour accéder à l'interface dans l'objet donné. Ainsi, les personnes qui utilisent des interfaces en raison de ce mythe "N'utilisez pas le cast" n'évitent en fait aucun cast.
Mythe 5 : Utilisez des chronologies au lieu de tick.
Chaque node de chronologie finit par être un composant de votre classe d'acteur et coûte de la mémoire. Ils sont excellents pour évaluer des courbes et fournissent également un éditeur de courbes en ligne cool que vous pouvez ouvrir et éditer vos courbes à l'intérieur du graphe de Blueprint de l'acteur, mais ils ont aussi des inconvénients :
Chaque node de chronologie finit également par être un autre "enregistrement de fonction tick" pour le "gestionnaire de tick" du moteur. Le gestionnaire de tick met en file d'attente, met à jour et vérifie l'état des fonctions tick enregistrées. Ainsi, avoir trop de chronologies peut entraîner un léger goulot d'étranglement pour le gestionnaire de tick. Avoir trop de chronologies fait que l'acteur instancie et enregistre un composant, ce qui est étonnamment plus cher que simplement instancier. Ainsi, cela finit par rendre l'acteur plus cher à créer. Je dirais, utilisez des chronologies, mais ne remplacez pas tick par elles. Elles sont bonnes pour des ticks séquentiels qui sont activés et désactivés à des moments spécifiques et aussi lorsque vous avez besoin de l'évaluation d'une courbe également.
C'était tout. Merci de m'avoir lu ! Assurez-vous de partager cet article avec tous les amis qui portent encore un chapeau en étain !
Source: https://intaxwashere.github.io/blueprint-performance/