
-
Améliorer l’utilisation de la mémoire et les performances avec Swift
Découvrez comment améliorer les performances et la gestion de la mémoire de votre code Swift. Nous explorerons des moyens d'affiner votre code - de l'apport de modifications algorithmiques de haut niveau à l'adoption des nouveaux types InlineArray et Span pour un contrôle plus fin de la mémoire et des allocations.
Chapitres
- 0:00 - Introduction et ordre du jour
- 1:19 - Format QOI et app d’analyse
- 2:25 - Algorithmes
- 8:17 - Allocations
- 16:30 - Exclusivité
- 19:12 - Pile versus tas
- 21:08 - Comptage de références
- 29:52 - Bibliothèque d’analyse binaire Swift
- 31:03 - Étapes suivantes
Ressources
Vidéos connexes
WWDC25
- Nouveautés dans Swift
- Optimiser les performances du processeur avec Instruments
- Profilez et optimisez la consommation d’énergie de votre app
WWDC24
-
Rechercher dans cette vidéo…
Bonjour ! Je m’appelle Nate Cook et je travaille sur la bibliothèque standard de Swift. Aujourd’hui, nous allons explorer comment analyser et améliorer les performances de votre code, notamment grâce à de nouvelles fonctionnalités du langage et de la bibliothèque standard dans Swift 6.2. Nous allons utiliser les nouveaux types InlineArray et Span, tester les génériques sur les valeurs et découvrir les types non-échappables. Nous allons exploiter tous ces nouveaux outils pour éliminer les opérations de rétention et de libération, les vérifications d’exclusivité et d’unicité, ainsi que d’autres traitements superflus. Je vais aussi présenter une nouvelle bibliothèque open source qui intègre tous ces outils pour écrire des analyseurs binaires à la fois rapides et sûrs. Elle s’appelle Bibliothèque d’analyse binaire Swift. Cette bibliothèque met l’accent sur la vitesse et fournit des outils pour gérer plusieurs aspects de la sûreté. Nous voulons tous que notre code soit rapide, et Swift nous donne les moyens d’y parvenir. Mais parfois, ce n’est pas aussi rapide que prévu. Dans cette séance, nous allons apprendre à identifier les goulots d’étranglement dans le code, puis essayer plusieurs optimisations des performances. Choisir les bons algorithmes. Éviter les allocations inutiles. Supprimer les vérifications d’exclusivité. Passer des allocations sur le tas à celles sur la pile. Et réduire le comptage de références. Pour cela, nous allons explorer une petite app que j’ai conçue. Il s’agit d’une visionneuse pour un format d’image appelé QOI, avec un analyseur manuscrit pour ce format. QOI est un format d’image sans perte, suffisamment simple pour que sa spécification tienne sur une seule page, ce qui en fait un bon candidat pour tester différentes approches et en observer les performances. Ce format suit une structure classique : un en-tête de taille fixe, suivi d’une section de données contenant un nombre dynamique de pixels encodés, chacun de taille variable. Les pixels encodés peuvent prendre différentes formes. Un pixel peut être une valeur RGB ou RGBA, une différence par rapport au pixel précédent, une recherche dans un cache de pixels déjà rencontrés, ou bien juste le nombre de répétitions du pixel précédent. Allez, lançons mon app d’analyse QOI !
J’ouvre ce fichier d’icône, qui ne fait que quelques kilo-octets, il se charge instantanément.
Cette photo d’oiseau est un peu plus grosse. Elle met quelques secondes à charger… Et la voilà. Pourquoi cela met-il autant de temps ? Quand un ralentissement est perceptible sur des données réelles, cela signifie souvent que nous n’utilisons pas le bon algorithme ou la bonne structure de données. Utilisons Instruments pour identifier et corriger la source du problème. Dans ma bibliothèque d’analyse, j’ai écrit un test qui analyse cette image d’oiseau qui était lente à charger. Je clique sur le bouton d’exécution pour lancer le test.
Et il réussit après quelques secondes. En plus de valider la correction du test, je peux aussi analyser ses performances dans Instruments. Cette fois, j’utilise un clic secondaire sur le bouton d’exécution. Un menu me permet de sélectionner l’option Profiler le test.
J’adore cette fonctionnalité. Elle me permet de cibler très précisément la portion de code qui m’intéresse. Je sélectionne donc cette option pour lancer Instruments.
Instruments s’ouvre avec le sélecteur de modèles, montrant toutes les analyses possibles pour comprendre les performances. Nous allons en utiliser deux aujourd’hui, donc je pars sur un modèle vierge.
Je peux ajouter des instruments en cliquant sur le bouton adéquat. J’ajouterai l’instrument Allocations pour surveiller comment mon analyseur alloue de la mémoire. Et comme je veux savoir où mon app passe le plus de temps, j’ajoute aussi l’instrument Time Profiler.
C’est souvent par là qu’il faut commencer pour identifier des problèmes de performances. Je masque la barre latérale pour agrandir la zone des résultats. Puis, je clique sur le bouton Enregistrer pour lancer le test.
Quelques éléments apparaissent dans la fenêtre des résultats.
Les instruments utilisés sont listés en haut de la fenêtre. Nous commençons par le « Time Profiler », je le garde donc sélectionné. La partie inférieure affiche les détails de l’outil sélectionné. À gauche, une liste des appels capturés. Et à droite, la trace de pile la plus lourde pour l’appel sélectionné.
Je veux voir les appels les plus fréquents, peu importe comment nous y arrivons. Donc je clique sur « Call Tree », et j’active « Invert Call Tree ». Je peux aussi passer en vue graphique en cliquant sur un bouton. Et là, la vue bascule en graphique de flamme.
Chaque barre indique la proportion de captures d’un appel pendant l’analyse. Dans notre cas, une barre énorme domine : « platform_memmove ». Elle apparaît aussi dans la trace de pile. memmove est un appel système pour copier des données, donc cette barre massive signifie que l’analyseur passe son temps à copier au lieu de lire. Ce n’est pas du tout ce que nous voulons. Voyons quelle partie de mon code provoque toutes ces copies. Je clique sur « show all frames » pour inspecter l’intégralité de la pile.
En haut, nous trouvons des appels système, puis des versions spécialisées de méthodes du type Foundation Data. Vous avez peut-être déjà vu ces méthodes spécialisées en débogage ou dans une trace de pile. Ce sont des versions concrètes générées par le compilateur Swift à partir de code générique.
Finalement, j’arrive sur une méthode que j’ai écrite : readByte.
Il s’agit probablement de l’origine du souci, donc c’est là que nous allons creuser. Un clic secondaire me permet de choisir « Reveal in Xcode ». Et voici la déclaration de readByte. Instruments m’amène pile à cette ligne. Je supprime le premier octet, puis, j’appelle l’initialiseur Data. Grâce à Instruments, j’ai identifié ces appels massifs à memmove comme suspect, puis j’ai directement sauté à la ligne problématique.
Cette méthode utilitaire est cruciale, car mon analyseur l’appelle en boucle pour lire les octets binaires.
Je pensais que cela réduisait simplement les données, en renvoyant le premier octet et en avançant le début des données chaque fois que j’appelais la méthode readByte. Mais en réalité, cela copie l’intégralité des données à chaque appel. C’est bien plus de travail que ce à quoi je m’attendais. Corrigeons ça. Me voilà de retour dans Xcode pour modifier la méthode readByte.
Le type Data peut en fait être réduit depuis les deux extrémités, donc nous pouvons utiliser la méthode `popFirst()`. popFirst() renvoie le premier octet dans `data`, puis décale le début de la collection vers l’avant. Exactement comme nous le voulions.
Je reviens au test et relance le profil.
Instruments se lance automatiquement avec la même configuration. Parfait ! La grosse barre platform_memmove a disparu du graphique de flamme.
Les benchmarks montrent aussi une énorme amélioration des performances ! Formidable, mais une modification algorithmique comme celle-ci ne se résume pas au gain brut. Avant, le temps d’analyse croissait de manière quadratique avec la taille des images. Plus l’image était grande, plus l’analyse ralentissait fortement. Désormais, nous sommes dans une complexité linéaire. Un rapport direct entre la taille et le temps d’analyse nécessaire. Nous avons encore de nombreuses améliorations à venir qui renforceront les performances linéaires, et nous pourrons mieux comparer ces optimisations entre elles.
Avec ce problème résolu, regardons un autre piège classique en matière de performances : les allocations supplémentaires. Voyons maintenant quelle est la trace de pile la plus lourde. Ces appels montrent une forte activité sur les méthodes d’allocation et de désallocation de tableaux Swift. Allouer et désallouer de la mémoire peut être coûteux. Mon analyseur gagnera en performance si je parviens à identifier et éliminer ces allocations superflues. Pour visualiser les allocations effectuées par l’analyseur, nous utilisons l’instrument Allocations ajouté précédemment. Quelques signes indiquent que mon code est probablement responsable d’allocations inutiles. Le premier est le nombre brut : Près d’un million d’allocations pour analyser une seule image ? Nous pouvons faire mieux. Deuxième indice : la quasi-totalité de ces allocations sont transitoires, marquées comme de courte durée par l’instrument Allocations. Pour trouver la cause du problème, je passe le panneau de détails en vue Call Tree. D’abord, je clique sur le menu déroulant intitulé Statistics. Puis, je sélectionne Call Tree.
Avec le fil de discussion principal sélectionné, je consulte la trace de pile pour identifier la partie de mon code la plus proche du problème. Comme cette pile n’est pas inversée, je commence par regarder tout en bas. Le premier symbole issu de mon analyseur est la méthode RGBAPixel.data.
En cliquant dessus, elle s’affiche dans la vue détaillée de l’arborescence d’appels. Un clic secondaire permet de choisir Reveal in Xcode pour aller directement à la source.
Cette méthode semble être la cause des allocations supplémentaires : chaque appel retourne un tableau contenant les valeurs RGB ou RGBA d’un pixel. Cela signifie qu’elle crée et alloue un tableau, donc minimum 3 éléments, à chaque appel.
Pour savoir où elle est utilisée, j’effectue un clic secondaire et je sélectionne « Show callers ». Le code appelant est une clôture située dans notre fonction principale d’analyse, intégrée à une grande chaîne de flatMap et de prefix. Pour comprendre pourquoi tant d’allocations ont lieu, observons comment elles s’accumulent, étape par étape.
D’abord, la méthode readEncodedPixels convertit les données binaires en pixels encodés, les divers types mentionnés plus tôt, et alloue suffisamment d’espace pour les stocker.
Ensuite, decodePixels est appelée pour chaque pixel encodé afin de produire un ou plusieurs pixels RGBA. La plupart des encodages produisent un pixel unique, mais certains demandent de répéter le pixel précédent plusieurs fois. Par conséquent, decodePixels renvoie toujours un tableau. Chaque tableau implique une nouvelle allocation.
Puis, le flatMap fusionne ces petits tableaux en un tableau global plus grand. Cela génère une nouvelle allocation, tandis que les petits tableaux sont désalloués.
La méthode prefix limite le nombre total de pixels générés.
Le second flatMap commence par appeler RGBAPixel.data, la méthode que nous avons identifiée avec l’instrument Allocations. Nous avons vu qu’elle renvoie un tableau de trois ou quatre éléments. Ce que cela signifie, c’est qu’un tableau de trois ou quatre octets est créé pour chaque pixel de l’image finale. Parfois, le compilateur peut optimiser certaines allocations, mais comme nous l’avons vu dans la trace, ce n’est pas garanti.
Ensuite, les petits tableaux sont à nouveau fusionnés dans un grand tableau global. Et enfin, ce tableau de pixels est copié dans une nouvelle instance de données, pour être retourné.
Ce code a une certaine élégance. Il concentre beaucoup de logique dans un enchaînement court d’appels. Mais ce n’est pas parce que c’est plus concis que c’est plus rapide. Et si, au lieu de suivre toutes ces étapes et finir avec une instance de données, on allouait directement l’espace nécessaire, et écrivait les pixels au fil de l’analyse ? De cette façon, nous pouvons effectuer le même traitement sans avoir besoin de ces allocations intermédiaires. Je retourne dans ma fonction d’analyse. Réécrivons la méthode pour éliminer toutes ces allocations superflues.
La première étape consiste à calculer « totalBytes » : la taille finale des données en sortie. Puis, nous allouerons « pixelData », avec exactement l’espace nécessaire. La variable « offset » permet de suivre la quantité de données déjà écrites. Cette allocation initiale unique nous évite d’en faire d’autres ensuite.
Ensuite, nous analysons chaque donnée et les traitons immédiatement. Un switch permet de gérer les différents types de pixels analysés.
Pour les encodages de type « Répéter », nous bouclons le nombre de fois requis en écrivant les données des pixels à chaque itération.
Pour les autres, nous le décodons et l’écrivons directement dans les données. C’est bon. Plus aucune allocation intermédiaire autre que les données à renvoyer. Vérifions maintenant avec un nouveau profil de notre test.
Et tout de suite, nous voyons que le nombre d’allocations a largement chuté. Pour obtenir le nombre exact d’allocations, j’utilise le champ de filtre. Je clique dans le champ de filtre tout en bas de la fenêtre. Et je saisis « QOI.init ». Cela permet de filtrer toutes les arborescences d’appels qui ne contiennent pas « QOI.init » dans leur trace de pile. Les lignes restantes montrent que notre code d’analyse ne fait désormais qu’un petit nombre d’allocations, pour un total inférieur à deux mégaoctets. En maintenant la touche Option enfoncée et en cliquant sur le triangle d’affichage, j’étends l’arborescence d’appels.
L’arborescence développée montre exactement ce que nous voulons.
La seule chose que nous allouons vraiment, ce sont les données qui stockent notre image résultante.
Côté benchmarks, nous constatons encore une excellente amélioration ! En supprimant ces allocations supplémentaires, nous avons réduit le temps d’exécution de plus de moitié.
Jusqu’ici, nous avons effectué deux changements algorithmiques : supprimer beaucoup de copies accidentelles et réduire drastiquement le nombre d’allocations. Pour les optimisations suivantes, nous utiliserons des techniques plus avancées permettant au compilateur Swift d’éliminer une grande partie de la gestion mémoire automatique à l’exécution.
Commençons par parler du fonctionnement des tableaux et autres collections. Le type array de Swift est l’un des outils les plus courants de notre boîte à outils, car il est rapide, sûr et facile à utiliser. Les tableaux peuvent s’agrandir ou se réduire dynamiquement, sans que vous ayez besoin de connaître leur taille à l’avance. Swift gère la mémoire automatiquement en arrière-plan. Les tableaux sont aussi des types valeur, ce qui signifie qu’une modification sur une copie n’affecte pas les autres. Si vous copiez un tableau, par assignation ou en le passant à une fonction, Swift ne duplique pas les éléments immédiatement. Au lieu de cela, il applique une optimisation appelée copie sur écriture, qui ne copie les éléments que lorsqu’ils sont modifiés.
Ces caractéristiques en font des collections polyvalentes, mais pas sans compromis. Pour gérer leur taille dynamique et les références multiples, les tableaux stockent leur contenu dans une allocation séparée, souvent sur le tas. L’exécution Swift utilise le comptage de références pour suivre combien de copies existent, et effectue une vérification d’unicité avant toute modification. Enfin, pour garantir la sûreté d’accès aux données, Swift applique le principe d’exclusivité : deux entités ne peuvent pas modifier simultanément les mêmes données. Souvent cette règle est vérifiée à la compilation, mais elle peut l’être aussi à l’exécution. Maintenant que nous comprenons mieux ces mécanismes bas niveau, voyons comment ils apparaissent dans le profilage. Nous allons d’abord rechercher les vérifications d’exclusivité à l’exécution, qui ajoutent du travail inutile et peuvent freiner les optimisations. Mais avant cela, nous avons un problème intéressant. Nos optimisations ont rendu le code si rapide qu’Instruments n’a pas suffisamment de temps pour inspecter le processus d’analyse. Pour obtenir plus de données, nous allons boucler 50 fois sur le code d’analyse.
Jetons un œil à ce profil plus riche.
Les tests d’exclusivité apparaissent sous les symboles swift_beginAccess et swiftendAccess. Comme auparavant, je clique dans le champ de filtre en bas de la fenêtre. Et je saisis le nom du symbole.
Tout en haut du graphique de flamme, swift_beginAccess apparaît plusieurs fois, avec juste en dessous les symboles qui nécessitent cette vérification. Il s’agit des accesseurs du pixel précédent et du cache de pixels, stockés dans la classe State de mon analyseur. Je retourne dans Xcode pour retrouver cette déclaration. La voici. State est une classe avec les deux propriétés que nous avons vues dans le graphique de flamme. Modifier une instance de classe est une des situations où Swift doit vérifier l’exclusivité à l’exécution, d’où leur présence dans la trace. Nous pouvons éviter cette vérification en déplaçant ces propriétés hors de la classe, directement dans le type d’analyseur.
Ensuite, je fais un rechercher/remplacer pour supprimer les accès via state. pour previousPixel et pixelCache.
À la compilation, le compilateur m’indique qu’il y a quelques ajustements à faire.
Comme les propriétés ne sont plus dans une classe, je ne peux plus les modifier dans une méthode non mutante.
J’accepte donc la suggestion de faire muter la méthode.
Encore une à corriger !
Et c’est bon. Je retourne dans le test.
Et je relance le profilage.
J’analyse à nouveau les appels à swift_beginAccess.
Il n’y a plus rien ! Nous avons entièrement supprimé les vérifications d’exclusivité à l’exécution. Jetons un nouveau coup d’œil à ces variables d’état. C’est le moment idéal pour utiliser une nouveauté de Swift : passer des données du tas à la pile, et garantir que les vérifications d’exclusivité ne reviennent pas. Le cache de pixels est un tableau de RGBAPixel initialisé avec 64 éléments et de taille fixe. Ce cache serait un cas d’usage parfait pour le nouveau type InlineArray. InlineArray est un nouveau type de la bibliothèque standard introduit dans Swift 6.2. Comme un tableau classique, il stocke plusieurs éléments en mémoire contiguë, mais avec des différences notables. D’abord, sa taille est fixe et déterminée à la compilation. Contrairement aux tableaux dynamiques, le type InlineArray utilise les génériques sur les valeurs pour intégrer la taille dans son type. Cela signifie que même si vous pouvez apporter des modifications aux éléments d’un tableau en ligne, vous ne pouvez pas ajouter ou supprimer, ou affecter un tableau en ligne à un tableau d’une taille différente.
Ensuite, comme son nom l’indique (InlineArray), les éléments sont toujours stockés en ligne, sans allocation séparée. Les tableaux en ligne ne partagent pas leur mémoire et n’utilisent pas de copie sur écriture. Ils sont copiés en totalité à chaque fois que nous les dupliquons. Ce comportement élimine tout besoin de comptage de références, de vérifications d’unicité ou d’exclusivité. Mais attention : si votre usage implique des copies ou des références partagées, un type InlineArray n’est peut-être pas le bon choix. Ici, en revanche, notre cache est de taille fixe, modifié sur place, jamais copié. C’est l’usage idéal d’un type InlineArray.
Pour notre dernière optimisation, utilisons les nouveaux types Spans de la bibliothèque standard pour éliminer la plupart des opérations de comptage de références lors de l’analyse. Revenons au graphique de flamme du Time Profiler, et filtrons à nouveau notre analyseur QOI. J’ajoute QOI.init dans le champ de filtre.
La vue se focalise sur les traces contenant notre initialiseur d’analyse. Recherchons les symboles retain et release. swift_retain est représenté par cette barre rose, visible dans 7 % des échantillons, et swift_release dans 7 % également. La vérification d’unicité que nous avons abordée plus tôt apparaît aussi ici, dans 3 % des échantillons.
Pour savoir d’où elles viennent, je clique sur swift_release et je remonte dans la trace de pile jusqu’au premier symbole utilisateur. Il s’agit encore une fois de la méthode readByte.
Cette fois, il ne s’agit pas d’un problème algorithmique, mais de l’utilisation des données elles-mêmes. Comme le type Array, le type Data stocke généralement sa mémoire sur le tas et nécessite un comptage de références. Ces opérations retain et release sont rapides, mais leur accumulation dans une boucle serrée peut représenter un coût significatif. Pour résoudre cela, il faut passer d’un type de collection haut niveau comme Data ou Array à un type qui évite l’explosion des comptages de références. Avant Swift 6.2, vous utilisiez withUnsafeBufferPointer pour accéder manuellement à la mémoire sous-jacente. Ces méthodes permettent une gestion manuelle sans comptage, mais rendent le code peu sûr.
Et donc, pourquoi les pointeurs sont-ils considérés comme « peu sûrs » ? Swift les qualifie de « peu sûrs » parce qu’ils contournent de nombreuses garanties de sécurité du langage. Ils peuvent pointer vers de la mémoire initialisée ou non, ignorer certaines garanties de typage, et peuvent s’échapper de leur contexte, ce qui entraîne des risques d’accès à de la mémoire libérée. Quand vous utilisez des pointeurs peu sûrs, c’est à vous seul d’assurer la sécurité du code. Le compilateur ne peut rien faire pour vous aider. La fonction processUsingBuffer utilise bien les pointeurs peu sûrs. Son usage reste entièrement limité à la clôture de UnsafeBufferPointer, avec uniquement le résultat renvoyé à la fin. À l’inverse, la fonction getPointerToBytes() est dangereuse. Elle contient deux erreurs majeures de programmation. Elle crée un tableau d’octets et appelle withUnsafeBufferPointer, mais au lieu de limiter l’usage du pointeur à la clôture, elle le renvoie hors de celle-ci. Erreur n° 1. Pire encore, elle retourne ce pointeur devenu invalide à l’extérieur de la fonction. Erreur n° 2 ! Ces deux erreurs prolongent la vie du pointeur au-delà de la mémoire qu’il vise, créant un accès potentiellement dangereux à une mémoire déplacée ou libérée. Pour répondre à ce problème, Swift 6.2 introduit une nouvelle famille de types appelés Spans. Les Spans permettent de manipuler des blocs de mémoire contiguë appartenant à une collection. Surtout, ils utilisent la nouvelle fonctionnalité du langage appelée non-échappable, qui permet au compilateur de lier leur durée de vie à celle de la collection d’origine. La mémoire accessible via un Span est ainsi garantie vivante pendant toute la durée de vie du Span. Comme tous les Spans sont déclarés non-échappable, le compilateur empêche tout retour ou fuite hors du contexte initial. La méthode « processUsingSpan » montre comment un Span permet d’écrire du code plus simple et plus sûr que les pointeurs peu sûrs. Pour obtenir une étendue sur les éléments du tableau, utilisez simplement la propriété Span. Sans besoin de clôture, vous accédez à la mémoire interne du tableau, avec les performances des pointeurs, mais sans leur insécurité. Nous pouvons voir la puissance de la vérification non-échappable en réécrivant la fonction dangereuse mentionnée plus tôt. Première surprise : on ne peut même pas écrire la même signature de fonction avec Span. Comme le Span dépend de la durée de vie de la collection source, et qu’aucune n’est transmise en paramètre, le compilateur n’a aucun contexte pour garantir sa validité en sortie.
Et si nous essayions de le piéger, en capturant le Span dans une clôture ? Je crée un tableau, j’accède à son Span, puis j’essaie de retourner une clôture qui le capture. Cela ne marche pas non plus. Le compilateur détecte la tentative la fuite du Span, et avertit que sa durée de vie dépend du tableau local. Cette contrainte, imposée par le compilateur, garantit que le Span ne peut échapper à son contexte, donc plus besoin de retain ni release. Nous obtenons la performance d’un tampon peu sûr, sans sacrifier la sûreté du code. La famille des Spans inclut des variantes typées ou brutes, en lecture seule ou modifiables, pour manipuler des collections existantes, ainsi qu’un OutputSpan pour en construire de nouvelles. Il existe aussi UTF8Span, un type pensé pour le traitement Unicode rapide et sûr.
Revenons à notre code : implémentons maintenant une version du readByte utilisant RawSpan.
Nous commençons par une extension de RawSpan.
Et nous y ajoutons la méthode readByte.
L’API de RawSpan diffère un peu de Data, mais elle fait exactement la même chose. Elle lit le premier octet, réduit le RawSpan, puis retourne la valeur lue. À noter : la méthode s’appelle unsafeLoad, non pas parce qu’elle est peu sûre, mais parce qu’elle peut l’être pour certains types. Ici, nous chargeons un entier natif, donc c’est parfaitement sûr.
Ensuite, nous mettons à jour nos méthodes d’analyse.
Elles doivent désormais prendre un RawSpan en paramètre à la place d’un type Data.
Il faut aussi modifier leur appel.
Au lieu de transmettre directement le type Data, nous transmettons son RawSpan. Nous y accédons via la propriété bytes. La valeur rawBytes ainsi obtenue est non-échappable. Je ne pourrais pas la retourner depuis cette fonction, mais je peux la passer à une méthode d’analyse sans souci.
Et voilà, la transition vers RawSpan est terminée. Pour aller encore plus loin côté bas niveau, nous pouvons aussi adopter OutputSpan dans notre méthode d’analyse.
Au lieu d’allouer un type Data zéro-initialisé, nous utilisons l’initialiseur rawCapacity qui fournit un OutputSpan que nous remplissons au fur et à mesure.
OutputSpan garde trace des données écrites, donc on peut utiliser sa propriété count au lieu d’une variable offset séparée.
Et nous utilisons une version alternative de notre méthode d’écriture, qui écrit dans le OutputSpan au lieu de Data.
Jetons un œil à cette implémentation.
La méthode write(to:) appelle la méthode d’ajout du type OutputSpan pour chaque composante du pixel. Étant conçu pour cela, le type OutputSpan offre une solution à la fois plus simple, plus performante, et plus sûre que d’écrire directement dans un tampon peu sûr ou un type Data. Avec ces changements, je relance le test. Et j’enregistre un nouveau profil.
Je filtre sur QOI.init.
Et dans le graphique de flamme, on constate que les blocs swift_retain et swift_release ont disparu ! Magnifique. Arrêtons-nous ici pour faire le bilan de l’adoption de InlineArray et RawSpan.
Avec ces dernières optimisations, notre travail de gestion de la mémoire est désormais 6 fois plus rapide, sans le moindre code peu sûr. C’est 16 fois plus rapide qu’après avoir supprimé l’algorithme quadratique, et plus de 700 fois plus rapide que le code d’origine ! Nous avons couvert beaucoup de choses dans cette séance. En révisant cette bibliothèque d’analyse d’images, nous avons réalisé deux améliorations algorithmiques et réduit drastiquement les allocations. Puis, nous avons utilisé les nouveaux types InlineArray et RawSpan pour supprimer toute gestion mémoire à l’exécution, et exploré le mot-clé non-échappable. La nouvelle bibliothèque d’analyse binaire Swift repose sur ces mêmes fondations. Elle est conçue pour écrire des analyseurs binaires sûrs et performants, tout en aidant les développeurs à gérer de multiples dimensions de sécurité. Elle fournit de nombreux initialiseurs d’analyse et outils pour consommer prudemment des données binaires brutes.
Voici un exemple d’analyseur pour l’en-tête QOI, écrit avec cette nouvelle bibliothèque. Nous y voyons plusieurs fonctionnalités, dont ParserSpan, un type RawSpan adapté à l’analyse des données binaires. Et des initialiseurs qui empêchent les débordements d’entiers et permettent de spécifier le signe, la largeur en bits, et l’ordre des octets. La bibliothèque fournit aussi des analyseurs de validation pour vos propres types RawRepresentable, ainsi que des opérateurs optionnels pour traiter prudemment des valeurs fraîchement extraites.
Nous utilisons déjà la bibliothèque d’analyse binaire Swift en interne chez Apple, et elle est désormais disponible publiquement ! Nous vous encourageons à y jeter un œil et à l’essayer par vous-même. Vous pouvez rejoindre la communauté via les forums Swift, ou bien en ouvrant des issues ou des pull requests sur GitHub. Merci beaucoup de m’avoir accompagné dans ce voyage à travers l’optimisation de notre code Swift ! Essayez d’utiliser Xcode et Instruments pour profiler les parties critiques en performance de votre propre app. Vous pouvez explorer les nouveaux types InlineArray et Span dans la documentation, ou en téléchargeant la dernière version de Xcode. Passez une excellente WWDC !
-
-
7:01 - Corrected Data.readByte() method
import Foundation extension Data { /// Consume a single byte from the start of this data. mutating func readByte() -> UInt8? { guard !isEmpty else { return nil } return self.popFirst() } }
-
9:56 - RGBAPixel.data(channels:) method
extension RGBAPixel { /// Returns the RGB or RGBA values for this pixel, as specified /// by the given channels information. func data(channels: QOI.Channels) -> some Collection<UInt8> { switch channels { case .rgb: [r, g, b] case .rgba: [r, g, b, a] } } }
-
10:21 - Original QOIParser.parseQOI(from:) method
extension QOIParser { /// Parses an image from the given QOI data. func parseQOI(from input: inout Data) -> QOI? { guard let header = QOI.Header(parsing: &input) else { return nil } let pixels = readEncodedPixels(from: &input) .flatMap { decodePixels(from: $0) } .prefix(header.pixelCount) .flatMap { $0.data(channels: header.channels) } return QOI(header: header, data: Data(pixels)) } }
-
12:53 - Revised QOIParser.parseQOI(from:) method
extension QOIParser { /// Parses an image from the given QOI data. func parseQOI(from input: inout Data) -> QOI? { guard let header = QOI.Header(parsing: &input) else { return nil } let totalBytes = header.pixelCount * Int(header.channels.rawValue) var pixelData = Data(repeating: 0, count: totalBytes) var offset = 0 while offset < totalBytes { guard let nextPixel = parsePixel(from: &input) else { break } switch nextPixel { case .run(let count): for _ in 0..<count { state.previousPixel .write(to: &pixelData, at: &offset, channels: header.channels) } default: decodeSinglePixel(from: nextPixel) .write(to: &pixelData, at: &offset, channels: header.channels) } } return QOI(header: header, data: pixelData) } }
-
15:07 - Array behavior
var array = [1, 2, 3] array.append(4) array.removeFirst() // array == [2, 3, 4] var copy = array copy[0] = 10 // copy happens on mutation // array == [2, 3, 4] // copy == [10, 3, 4]
-
19:47 - InlineArray behavior (part 1)
var array: InlineArray<3, Int> = [1, 2, 3] array[0] = 4 // array == [4, 2, 3] // Can't append or remove elements array.append(4) // error: Value of type 'InlineArray<3, Int>' has no member 'append' // Can only assign to a same-sized inline array let bigger: InlineArray<6, Int> = array // error: Cannot assign value of type 'InlineArray<3, Int>' to type 'InlineArray<6, Int>'
-
20:23 - InlineArray behavior (part 2)
var array: InlineArray<3, Int> = [1, 2, 3] array[0] = 4 // array == [4, 2, 3] var copy = array // copy happens on assignment for i in copy.indices { copy[i] += 10 } // array == [4, 2, 3] // copy == [14, 12, 13]
-
23:13 - processUsingBuffer() function
// Safe usage of a buffer pointer func processUsingBuffer(_ array: [Int]) -> Int { array.withUnsafeBufferPointer { buffer in var result = 0 for i in 0..<buffer.count { result += calculate(using: buffer, at: i) } return result } }
-
23:34 - Dangerous getPointerToBytes() function
// Dangerous - DO NOT USE! func getPointerToBytes() -> UnsafePointer<UInt8> { let array: [UInt8] = Array(repeating: 0, count: 128) // DANGER: The next line escapes a pointer let pointer = array.withUnsafeBufferPointer { $0.baseAddress! } // DANGER: The next line returns the escaped pointer return pointer }
-
24:46 - processUsingSpan() function
// Safe usage of a span @available(macOS 16.0, *) func processUsingSpan(_ array: [Int]) -> Int { let intSpan = array.span var result = 0 for i in 0..<intSpan.count { result += calculate(using: intSpan, at: i) } return result }
-
25:07 - getHiddenSpanOfBytes() function (attempt 1)
@available(macOS 16.0, *) func getHiddenSpanOfBytes() -> Span<UInt8> { } // error: Cannot infer lifetime dependence...
-
25:28 - getHiddenSpanOfBytes() function (attempt 2)
@available(macOS 16.0, *) func getHiddenSpanOfBytes() -> () -> Int { let array: [UInt8] = Array(repeating: 0, count: 128) let span = array.span return { span.count } }
-
26:27 - RawSpan.readByte() method
@available(macOS 16.0, *) extension RawSpan { mutating func readByte() -> UInt8? { guard !isEmpty else { return nil } let value = unsafeLoadUnaligned(as: UInt8.self) self = self._extracting(droppingFirst: 1) return value } }
-
28:02 - Final QOIParser.parseQOI(from:) method
/// Parses an image from the given QOI data. mutating func parseQOI(from input: inout RawSpan) -> QOI? { guard let header = QOI.Header(parsing: &input) else { return nil } let totalBytes = header.pixelCount * Int(header.channels.rawValue) let pixelData = Data(rawCapacity: totalBytes) { outputSpan in while outputSpan.count < totalBytes { guard let nextPixel = parsePixel(from: &input) else { break } switch nextPixel { case .run(let count): for _ in 0..<count { previousPixel .write(to: &outputSpan, channels: header.channels) } default: decodeSinglePixel(from: nextPixel) .write(to: &outputSpan, channels: header.channels) } } } return QOI(header: header, data: pixelData) }
-
28:31 - RGBAPixel.write(to:channels:) method
@available(macOS 16.0, *) extension RGBAPixel { /// Writes this pixel's RGB or RGBA data into the given output span. @lifetime(&output) func write(to output: inout OutputRawSpan, channels: QOI.Channels) { output.append(r) output.append(g) output.append(b) if channels == .rgba { output.append(a) } } }
-
-
- 0:00 - Introduction et ordre du jour
Découvrez comment optimiser les performances des apps et bibliothèques Swift à l’aide de Swift 6.2. Les nouveaux types InlineArray et Span réduisent les allocations, les vérifications d’exclusivité et le comptage de références. Une nouvelle bibliothèque Swift open source, Binary Parsing, est introduite pour une analyse binaire rapide et sécurisée.
- 1:19 - Format QOI et app d’analyse
L’app de cette session WWDC25 charge les images au format QOI, un format simple et sans perte, avec une spécification d’une seule page. L’analyseur d’images de l’app prend en charge diverses méthodes d’encodage des pixels. Ensuite, l’app charge instantanément un petit fichier d’icône, mais il faut quelques secondes pour charger une photo plus grande d’un oiseau.
- 2:25 - Algorithmes
Lorsque les apps fonctionnent avec des données réelles, une utilisation incorrecte des algorithmes ou des structures de données peut nuire aux performances. Pour identifier ces problèmes et y remédier, vous pouvez utiliser Instruments, qui fournit des modèles d’instruments destinés à l’analyse des allocations et des versions, et qui permet d’identifier le code inefficace à l’aide de profileurs. L’instrument Time Profiler est particulièrement utile pour remédier aux problèmes de performances. En analysant les appels capturés et les traces de pile, vous pouvez identifier les zones où les apps passent le plus de temps. Dans l’exemple, un temps considérable a été consacré à platform_memmove, un appel système pour la copie de données. À l’aide d’Instruments, cet exemple analyse une méthode personnalisée nommée readByte. Cette méthode a été ajoutée à une extension du type Data, ce qui entraînait une copie excessive des données binaires. Dans l’exemple, la méthode est remplacée par la méthode popFirst(), plus efficace, qui réduit les données à partir du début d’une séquence sans les copier. Cette modification a résolu le problème de performances dans la méthode readByte. Après avoir effectué la modification, le profil a été exécuté à nouveau et la barre platform_memmove, qui occupait une place conséquente, a disparu du flamegraph. Les tests comparatifs ont révélé une accélération considérable, et la relation entre la taille de l’image et le temps d’analyse est passée de quadratique à linéaire, ce qui indique un algorithme plus efficace.
- 8:17 - Allocations
L’app est à nouveau profilée et il s’avère que l’analyseur d’images entraîne des allocations et des désallocations de mémoire excessives, en particulier au niveau des tableaux. Le nombre élevé d’allocations, près d’un million pour l’analyse d’une seule image, indique un problème critique. La plupart de ces allocations sont temporaires et de courte durée, ce qui suggère qu’elles peuvent être optimisées. Pour identifier la source de ces allocations inutiles, l’exemple utilise l’instrument Allocations dans Instruments. L’analyse révèle qu’une méthode appelée RGBAPixel.data(channels:) est en grande partie à l’origine du problème. Cette méthode crée un tableau chaque fois qu’elle est appelée, ce qui entraîne un nombre important d’allocations. La structure du code, qui implique une chaîne complexe de méthodes flatMap et prefix, contribue au problème. Chaque étape de cette chaîne entraîne de nouvelles allocations, car les tableaux sont créés, aplatis et copiés à plusieurs reprises. Bien que cette approche soit concise, elle n’est pas efficace en termes de mémoire. Pour résoudre ce problème, l’exemple réécrit la fonction d’analyse. Au lieu de s’appuyer sur des allocations intermédiaires, il calcule à l’avance la taille totale des données résultantes et alloue un seul tampon. Cette approche élimine la nécessité d’allocations répétées pendant le processus de décodage.
- 16:30 - Exclusivité
Les performances de l’app se sont tellement améliorées que les outils de profilage ont eu besoin de plus de données. Après avoir répété le code d’analyse 50 fois, les résultats ont affiché les symboles swift_beginAccess et swift_endAccess, qui indiquent des tests d’exclusivité. Ces tests d’exclusivité ont été provoqués par des propriétés de la classe State imbriquées dans la structure QOIParser, que l’exemple déplace ensuite directement dans le type d’analyseur parent afin d’éliminer les vérifications d’exclusivité. Après quelques ajustements du compilateur, la vérification de l’exclusivité a été entièrement supprimée, comme l’a confirmé un nouveau profil.
- 19:12 - Pile versus tas
Cet exemple remplace l’utilisation d’Array dans l’app par InlineArray, une collection de taille fixe stockée et intégrée, qui optimise l’utilisation de la mémoire en éliminant le comptage de références et les vérifications d’exclusivité. Cette méthode est idéale pour le cache de pixels, un tableau de 64 éléments dont la taille ne change jamais et qui est modifié sur place, car cela améliore les performances sans nécessiter de copie ou de partage de références.
- 21:08 - Comptage de références
Dans l’exemple d’optimisation finale de l’app, les nouveaux types Span sont utilisés pour optimiser les performances et renforcer la sécurité de la mémoire. Dans Instruments, le flamegraph est utilisé à partir de l’analyse Time Profiler. Les données profilées se concentrent sur le QOIParser et permettent d’identifier qu’un temps significatif est consacré aux opérations de comptage de références, en particulier avec le type Data, en raison de sa sémantique de copie à l’écriture. Span et ses types associés constituent une nouvelle façon de travailler avec la mémoire contiguë dans une collection. Ils utilisent la fonctionnalité non échappable (~Escapable) de Swift, qui lie leur durée de vie à la collection, garantissant ainsi la sécurité de la mémoire et éliminant la nécessité d’une gestion manuelle de la mémoire. Cela permet un accès efficace à la mémoire sans les risques associés aux pointeurs non sécurisés. L’exemple montre comment utiliser les types Span pour réécrire des méthodes existantes, afin de les rendre plus simples, plus sécurisées et plus performantes. Dans les méthodes d’analyse d’images, Data est remplacé par RawSpan, ce qui réduit considérablement la surcharge liée au comptage de références. De plus, OutputSpan est adopté dans le processus d’analyse pour plus d’efficacité, ce qui rend l’opération d’analyse six fois plus rapide qu’auparavant sans avoir recours à des pointeurs non sécurisés.
- 29:52 - Bibliothèque d’analyse binaire Swift
Swift Binary Parsing vous permet de créer des analyseurs syntaxiques sûrs et efficaces pour les formats binaires. Il offre des outils pour gérer divers aspects liés à la sécurité, notamment la prévention des dépassements d’entier, la spécification du signe, de la largeur de bit et de l’ordre des octets, ainsi que la validation des types personnalisés. La bibliothèque est déjà utilisée chez Apple et est accessible publiquement. Vous pouvez l’essayer et y contribuer via les forums Swift et GitHub.
- 31:03 - Étapes suivantes
Les principaux points à retenir sont les suivants : Utilisation de Xcode et Instruments pour le profilage des apps. Analyse des performances des algorithmes afin d’identifier les goulots d’étranglement. Exploration de solutions aux problèmes susmentionnés avec les nouveaux types InlineArray et Span introduits dans Swift 6.2.