Configurer le traçage système

Vous pouvez configurer le traçage système afin de capturer un profil de processeur et de thread de votre application sur une courte période. Vous pouvez ensuite utiliser le rapport de sortie d'une trace système pour améliorer les performances de votre jeu.

Configurer le traçage système d'un jeu

L'outil Systrace est disponible sous deux formes différentes :

Systrace est un outil de bas niveau qui :

  • Assure la vérité terrain. Systrace capture les résultats directement à partir du noyau. Les métriques collectées sont donc presque identiques à celles qu'une série d'appels système générerait.
  • Consomme peu de ressources. Systrace implique des coûts très faibles sur l'appareil, généralement inférieurs à 1 %, car il diffuse les données en mémoire tampon.

Paramètres optimaux

Il est important de fournir à l'outil un ensemble d'arguments raisonnable :

  • Catégories : les catégories les plus appropriées pour activer le traçage système d'un jeu sont les suivantes : {sched, freq, idle, am, wm, gfx, view, sync, binder_driver, hal, dalvik}.
  • Taille du tampon : en règle générale, une taille de tampon de 10 Mo par cœur de processeur permet une trace d'environ 20 secondes. Par exemple, si un appareil dispose de deux processeurs à quatre cœurs (8 cœurs au total), 80 000 Ko (80 Mo) est une valeur appropriée à transmettre au programme systrace.

    Si votre jeu implique une grande quantité de changements de contexte, augmentez la taille du tampon pour qu'elle corresponde à 15 Mo par cœur de processeur.

  • Événements personnalisés : si vous définissez des événements personnalisés à capturer dans votre jeu, activez l'option -a, qui permet à Systrace de les inclure dans le rapport de sortie.

Si vous utilisez le programme de ligne de commande systrace, exécutez la commande suivante pour capturer une trace système qui applique les bonnes pratiques concernant les catégories, la taille de tampon et les événements personnalisés :

python systrace.py -a com.example.myapp -b 80000 -o my_systrace_report.html \
  sched freq idle am wm gfx view sync binder_driver hal dalvik

Si vous utilisez l'application système Systrace sur un appareil, procédez comme suit pour capturer une trace système qui applique les bonnes pratiques concernant les catégories, la taille de tampon et les événements personnalisés :

  1. Activez l'option Trace debuggable applications (Tracer les applications débogables).

    Pour que vous puissiez utiliser ce paramètre, l'appareil doit disposer de 256 Mo ou 512 Mo (selon que le processeur dispose de quatre ou huit cœurs), et chaque espace de mémoire de 64 Mo doit être disponible en tant que fragment contigu.

  2. Sélectionnez Categories (Catégories), puis activez les catégories de la liste suivante :

    • am : gestionnaire d'activités
    • binder_driver : pilote de noyau Binder
    • dalvik : VM Dalvik
    • freq : fréquence du processeur
    • gfx : visuels
    • hal : modules matériels
    • idle : processeur inactif
    • sched : planification du processeur
    • sync : synchronisation
    • view : système de vues
    • wm : gestionnaire de fenêtres
  3. Activez le Record tracing (enregistrement du traçage).

  4. Chargez votre jeu.

  5. Dans le jeu, effectuez les interactions correspondant aux scénarios dont vous souhaitez mesurer les performances.

  6. Peu de temps après avoir rencontré un comportement indésirable dans le jeu, désactivez le traçage système.

Vous disposez des statistiques de performances nécessaires pour analyser le problème.

Pour économiser de l'espace disque, les traces système de l'appareil enregistrent les fichiers dans un format de trace compressé (*.ctrace). Pour décompresser ce fichier lors de la génération d'un rapport, utilisez le programme de ligne de commande et incluez l'option --from-file :

python systrace.py --from-file=/data/local/traces/my_game_trace.ctrace \
  -o my_systrace_report.html

Améliorer des domaines de performances spécifiques

Cette section met en évidence plusieurs problèmes de performances courants qui affectent les jeux mobiles, et explique comment identifier et améliorer ces aspects.

Vitesse de chargement

Les joueurs veulent agir aussi vite que possible. Il est donc crucial d'optimiser le temps de chargement de votre jeu. Les mesures suivantes contribuent généralement à réduire les temps de chargement :

  • Optez pour un chargement différé. Si vous utilisez les mêmes éléments dans plusieurs scènes ou niveaux consécutifs de votre jeu, chargez-les une seule fois.
  • Réduisez la taille des éléments. De cette façon, vous pouvez regrouper les versions non compressées de ces éléments avec l'APK du jeu.
  • Utilisez une méthode de compression économe en disques (zlib, par exemple).
  • Utilisez IL2CPP au lieu de mono. (cela ne s'applique que si vous utilisez Unity). IL2CPP améliore les performances d'exécution des scripts C#.
  • Créez un jeu multithread. Pour en savoir plus, consultez la section Cohérence de la fréquence de frames.

Cohérence de la fréquence de frames

L'un des critères les plus importants à respecter dans un jeu est la cohérence de la fréquence de frames. Pour mettre toutes les chances de votre côté afin d'atteindre cet objectif, suivez les techniques d'optimisation décrites dans cette section.

Multithreading

Lorsque vous développez un jeu destiné à plusieurs plates-formes, il est naturel d'en regrouper toutes les activités dans un seul thread. Bien que cette méthode d'exécution soit simple à implémenter dans de nombreux moteurs de jeu, elle est loin d'être optimale pour les appareils Android. En effet, les jeux monothread se chargent souvent lentement, et leur fréquence de frames manque de constance.

Le rapport Systrace illustré à la figure 1 présente le comportement typique d'un jeu exécuté sur un seul processeur à la fois :

Schéma des threads dans une trace système

Figure 1. Rapport Systrace d'un jeu monothread

Pour améliorer les performances, créez un jeu multithread. Généralement, il est conseillé de recourir à deux threads :

  • Un thread de jeu qui contient les principaux modules de votre jeu et envoie les commandes de rendu
  • Un thread de rendu qui reçoit les commandes de rendu et les convertit en commandes graphiques que le GPU d'un appareil peut utiliser pour afficher une scène

L'API Vulkan, qui permet de déployer deux tampons courants en parallèle, s'appuie sur ce modèle. Cette fonctionnalité vous permet de répartir plusieurs threads de rendu sur plusieurs processeurs, ce qui améliore davantage le temps de rendu d'une scène.

Vous pouvez également effectuer des modifications spécifiques au moteur pour optimiser les performances de multithreading de votre jeu :

  • Si vous développez un jeu à l'aide du moteur de jeu Unity, activez les options Multithread Rendering (Rendu multithread) et GPU Skinning (Habillage GPU).
  • Si vous utilisez un moteur de rendu personnalisé, assurez-vous que le pipeline de commande de rendu et le pipeline de commandes graphiques sont correctement alignés. Dans le cas contraire, vous risquez de retarder l'affichage des scènes du jeu.

Une fois ces modifications appliquées, votre jeu devrait occuper au moins deux processeurs en même temps, comme illustré dans la figure 2 :

Schéma des threads dans une trace système

Figure 2. Rapport Systrace d'un jeu multithread

Chargement de l'élément d'interface utilisateur

Schéma d'une pile de frames dans une trace système
Figure 3. Rapport Systrace d'un jeu, qui affiche des dizaines d'éléments d'interface utilisateur en même temps

Lorsque vous créez un jeu proposant de nombreuses fonctionnalités, vous pouvez être tenté de présenter de nombreuses options et actions en même temps au joueur. Pour maintenir une fréquence de frames cohérente, il est important de tenir compte de la taille relativement petite des écrans mobiles et de simplifier au maximum l'interface utilisateur.

Le rapport Systrace présenté à la figure 3 est un exemple de frame d'interface utilisateur qui tente d'afficher trop d'éléments par rapport aux fonctionnalités d'un appareil mobile.

Il est conseillé de réduire le délai de mise à jour de l'interface utilisateur à deux ou trois millisecondes. Pour ce faire, vous pouvez procéder aux optimisations suivantes :

  • Mettez à jour uniquement les éléments qui ont changé de place.
  • Limitez le nombre de textures et de calques d'interface utilisateur. Envisagez de combiner les appels graphiques, tels que des nuanceurs et des textures, qui utilisent le même support.
  • Déléguez les opérations d'animation des éléments au GPU.
  • Optez pour un rythme plus agressif et une élimination de l'occlusion.
  • Si possible, effectuez les opérations de dessin à l'aide de l'API Vulkan. La charge liée aux appels de dessin est plus faible sur Vulkan.

Consommation d'énergie

Même après avoir effectué les optimisations décrites dans la section précédente, il est possible que la fréquence de frames de votre jeu se détériore dans les 45 à 50 premières minutes. De plus, l'appareil peut commencer à chauffer et se mettre à consommer plus de batterie au fil du temps.

Dans de nombreux cas, cette union indésirable entre chaleur et consommation d'énergie est liée à la façon dont la charge de travail de votre jeu est répartie entre les processeurs d'un appareil. Pour accroître l'efficacité énergétique de votre jeu, appliquez les bonnes pratiques présentées dans les sections suivantes.

Rassembler les threads gourmands en mémoire sur un seul processeur

Sur de nombreux appareils mobiles, les caches L1 résident sur des processeurs spécifiques, tandis que les caches L2 se trouvent sur l'ensemble de processeurs qui partagent une horloge. Pour maximiser les succès de cache L1, il est généralement préférable que le thread principal du jeu, ainsi que tous les autres threads gourmands en mémoire, soient exécutés sur un seul processeur.

Déléguer les tâches de courte durée aux processeurs moins puissants

La plupart des moteurs de jeu, y compris Unity, savent déléguer les opérations de thread de travail à un CPU autre que celui du thread principal de votre jeu. Toutefois, le moteur ne tient pas compte de l'architecture spécifique d'un appareil et ne peut donc pas anticiper la charge de travail de votre jeu de manière optimale.

La plupart des appareils système sur une puce disposent d'au moins deux horloges partagées, une pour les processeurs rapides et une autre pour les processeurs lents. Avec cette architecture, si un processeur rapide doit fonctionner à la vitesse maximale, tous les autres processeurs rapides fonctionnent également à la vitesse maximale.

L'exemple de rapport illustré à la figure 4 présente un jeu qui exploite des processeurs rapides. Cependant, ce niveau élevé d'activité génère rapidement beaucoup de chaleur et une grande consommation d'énergie.

Schéma des threads dans une trace système

Figure 4. Rapport Systrace présentant une attribution non optimale des threads aux processeurs de l'appareil

Pour réduire la consommation globale d'énergie, il est préférable de suggérer au planificateur que les tâches de courte durée, telles que le chargement de données audio, l'exécution de threads de travail et l'exécution du chorégraphe, soient déléguées à l'ensemble de processeurs lents d'un appareil. Transférez le plus de tâches possible sur les processeurs lents tout en conservant la fréquence de frames souhaitée.

La plupart des appareils répertorient les processeurs lents avant les processeurs rapides, mais vous ne pouvez pas supposer que le système SoC (System on Chip) de votre appareil suive cet ordre. Pour vérifier, exécutez des commandes semblables à celles présentées dans ce code de découverte de topologie de processeur sur GitHub.

Une fois que vous savez quels processeurs sont les plus lents sur votre appareil, vous pouvez déclarer des affinités pour vos threads de courte durée, affinités que le planificateur de l'appareil respectera. Pour ce faire, ajoutez le code suivant dans chaque thread :

#include <sched.h>
#include <sys/types.h>
#include <unistd.h>

pid_t my_pid; // PID of the process containing your thread.

// Assumes that cpu0, cpu1, cpu2, and cpu3 are the "slow CPUs".
cpu_set_t my_cpu_set;
CPU_ZERO(&my_cpu_set);
CPU_SET(0, &my_cpu_set);
CPU_SET(1, &my_cpu_set);
CPU_SET(2, &my_cpu_set);
CPU_SET(3, &my_cpu_set);
sched_setaffinity(my_pid, sizeof(cpu_set_t), &my_cpu_set);

Stress thermique

Lorsque les appareils sont trop chauds, ils peuvent ralentir le processeur et/ou le GPU, ce qui peut affecter les jeux de manière inattendue. Les jeux qui impliquent des graphismes complexes, de nombreux calculs ou une activité réseau soutenue sont plus susceptibles de rencontrer des problèmes.

Utilisez l'API thermique pour surveiller les changements de température de l'appareil et prendre les mesures nécessaires pour limiter la consommation d'énergie et la température. Lorsque l'appareil signale un stress thermique, annulez les activités en cours pour réduire la consommation d'énergie. Par exemple, vous pouvez réduire la fréquence de frames ou la tessellation des polygones.

Tout d'abord, déclarez l'objet PowerManager et initialisez-le dans la méthode onCreate(). Ajoutez un écouteur d'état thermique à l'objet.

Kotlin

class MainActivity : AppCompatActivity() {
    lateinit var powerManager: PowerManager

    override fun onCreate(savedInstanceState: Bundle?) {
        powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
        powerManager.addThermalStatusListener(thermalListener)
    }
}

Java

public class MainActivity extends AppCompatActivity {
    PowerManager powerManager;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        ...
        powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE);
        powerManager.addThermalStatusListener(thermalListener);
    }
}

Définissez les actions à effectuer lorsque l'écouteur détecte un changement d'état. Si votre jeu utilise C/C++, ajoutez du code aux niveaux d'état thermique dans onThermalStatusChanged() pour appeler le code de jeu natif à l'aide de JNI, ou utilisez l'API thermique native.

Kotlin

val thermalListener = object : PowerManager.OnThermalStatusChangedListener() {
    override fun onThermalStatusChanged(status: Int) {
        when (status) {
            PowerManager.THERMAL_STATUS_NONE -> {
                // No thermal status, so no action necessary
            }

            PowerManager.THERMAL_STATUS_LIGHT -> {
                // Add code to handle light thermal increase
            }

            PowerManager.THERMAL_STATUS_MODERATE -> {
                // Add code to handle moderate thermal increase
            }

            PowerManager.THERMAL_STATUS_SEVERE -> {
                // Add code to handle severe thermal increase
            }

            PowerManager.THERMAL_STATUS_CRITICAL -> {
                // Add code to handle critical thermal increase
            }

            PowerManager.THERMAL_STATUS_EMERGENCY -> {
                // Add code to handle emergency thermal increase
            }

            PowerManager.THERMAL_STATUS_SHUTDOWN -> {
                // Add code to handle immediate shutdown
            }
        }
    }
}

Java

PowerManager.OnThermalStatusChangedListener thermalListener =
    new PowerManager.OnThermalStatusChangedListener () {

    @Override
    public void onThermalStatusChanged(int status) {

        switch (status)
        {
            case PowerManager.THERMAL_STATUS_NONE:
                // No thermal status, so no action necessary
                break;

            case PowerManager.THERMAL_STATUS_LIGHT:
                // Add code to handle light thermal increase
                break;

            case PowerManager.THERMAL_STATUS_MODERATE:
                // Add code to handle moderate thermal increase
                break;

            case PowerManager.THERMAL_STATUS_SEVERE:
                // Add code to handle severe thermal increase
                break;

            case PowerManager.THERMAL_STATUS_CRITICAL:
                // Add code to handle critical thermal increase
                break;

            case PowerManager.THERMAL_STATUS_EMERGENCY:
                // Add code to handle emergency thermal increase
                break;

            case PowerManager.THERMAL_STATUS_SHUTDOWN:
                // Add code to handle immediate shutdown
                break;
        }
    }
};

Latence entre l'entrée utilisateur et l'affichage

Les jeux qui affichent les frames le plus rapidement possible engendrent un scénario axé sur les GPU, dans lequel le tampon des frames est surchargé. Le processeur doit attendre le GPU, ce qui entraîne un délai notable entre l'entrée utilisateur et son résultat à l'écran.

Pour déterminer si vous pouvez améliorer le rythme de votre jeu, procédez comme suit :

  1. Générez un rapport Systrace incluant les catégories gfx et input. Ces catégories comprennent des mesures particulièrement utiles pour déterminer la latence entre l'entrée utilisateur et l'affichage.
  2. Consultez la section SurfaceView d'un rapport Systrace. Lorsqu'un tampon surchargé, le nombre de dessins en attente oscille entre 1 et 2, comme illustré dans la figure 5 :

    Schéma de la file d&#39;attente de tampons dans une trace système

    Figure 5. Rapport Systrace indiquant une mémoire tampon qui est régulièrement surchargée et qui ne peut donc pas accepter les commandes de dessin

Pour limiter ces incohérences dans le rythme des frames, effectuez les actions décrites dans les sections suivantes :

Intégrer l'API Android Frame Pacing dans votre jeu

L'API Android Frame Pacing vous permet d'effectuer des échanges de frames par intervalle pour que votre jeu conserve une fréquence de frames plus cohérente.

Réduire la résolution des éléments du jeu non liés à l'interface utilisateur

Sur les appareils mobiles modernes, les écrans contiennent beaucoup plus de pixels que ce qu'un utilisateur est réellement en mesure de voir. Vous pouvez donc opter pour un sous-échantillonnage de sorte qu'une exécution de 5, voire 10 pixels contienne une seule couleur. Compte tenu de la structure de la plupart des caches d'affichage, il est préférable de réduire la résolution selon une seule dimension.

Cependant, ne réduisez pas la résolution des éléments d'interface utilisateur de votre jeu. Il est important de préserver leur épaisseur de ligne afin de conserver une taille de la zone cible tactile suffisamment grande pour tous les joueurs.

Fluidité de l'affichage

Lorsque SurfaceFlinger s'appuie sur un tampon d'affichage pour présenter une scène de votre jeu, l'activité du processeur augmente temporairement. Si ces pics d'activité du processeur se produisent de manière inégale, il est possible que le jeu soit saccadé. Le schéma de la figure 6 illustre la raison pour laquelle cela se produit :

Schéma des frames passant à côté d&#39;une fenêtre Vsync, car le dessin a commencé trop tard

Figure 6. Rapport Systrace montrant comment un frame peut passer à côté d'une fenêtre Vsync

Si le dessin d'un frame commence trop tard, même si ce retard n'est que de quelques millisecondes, la fenêtre d'affichage suivante risque de passer à côté. Ce frame devra alors attendre la prochaine fenêtre Vsync (qui correspond à 33 millisecondes lors d'un jeu à 30 FPS), ce qui entraînera un retard notable du point de vue du joueur.

Pour résoudre ce problème, utilisez l'API Android Frame Pacing, qui présente toujours un nouveau frame sur un front d'onde VSync.

État de la mémoire

Lorsque vous exécutez votre jeu sur une longue période, l'appareil peut rencontrer des erreurs de mémoire insuffisante.

Dans ce cas, vérifiez l'activité du processeur dans un rapport Systrace et observez la fréquence à laquelle le système appelle le daemon kswapd. Si les appels sont nombreux pendant l'exécution de votre jeu, il est préférable d'examiner de plus près la manière dont il gère et nettoie la mémoire.

Pour en savoir plus, consultez la section Gérer efficacement la mémoire dans les jeux.

État des threads

Lorsque vous parcourez les éléments typiques d'un rapport Systrace, vous pouvez afficher le temps passé par un thread donné dans chaque état possible. Pour ce faire, sélectionnez le thread souhaité dans le rapport, comme illustré dans la figure 7 :

Schéma d&#39;un rapport Systrace

Figure 7. Rapport Systrace illustrant comment la sélection d'un thread entraîne l'affichage d'un résumé des états par lesquels il est passé

Comme le montre la figure 7, vous pouvez constater que l'état des threads n'est pas "en cours d'exécution" ou "exécutable" aussi souvent qu'il le devrait. La liste suivante présente plusieurs raisons courantes pouvant expliquer pourquoi un thread donné passe parfois à un état inhabituel :

  • Si un thread est en veille pendant une période prolongée, ce problème peut découler d'un conflit de verrouillage ou de l'attente d'une activité GPU.
  • Si un thread est constamment bloqué sur les E/S, vous lisez trop de données en même temps sur le disque, ou votre jeu est en cours de thrashing.

Ressources supplémentaires

Pour découvrir comment améliorer les performances de votre jeu, consultez les ressources supplémentaires suivantes :

Vidéos

  • Présentation Systrace for Games (Systrace pour les jeux) du Sommet des développeurs de jeux Android 2018