Développement Mac et iPhone
Nous continuons aujourd'hui l'optimisation de la génération de l'image.
J'ai vaguement expliqué ce qu'était une bitmap, me contentant de dire qu'il s'agissait d'une grille de pixels. Intéressons-nous à leur organisation en mémoire.
Utiliser 32 bits pour stocker les composantes d'un pixel est des plus classiques:

Chaque composante utilise un octet, et peut donc contenir une valeur de 0 à 255. Doser les quantités de rouge, de vert et de bleu permet de choisir la teinte du pixel; la composante alpha correspond à l'opacité du pixel.
Notre générateur utilise une bitmap en 256 niveaux de gris:

C'est le même principe: la valeur de l'octet fournit la nuance; 0 correspond au noir et 255 au blanc, voilà pourquoi nous utilisons la ligne:
NSUInteger nuance = n * 255 / MAX_ITERATIONS;
pour déterminer la nuance du pixel calculé.
Voyons maintenant la relation entre les pixels et les coordonnées:

Dans cet exemple, la bitmap mesure 400 pixels de large et 300 de haut. Ce qui est intéressant, c'est que les pixels se suivent en mémoire. Ainsi, si nous disposons de l'adresse à laquelle est stockée la bitmap, adresseBitmap:
adresseBitmap.adresseBitmap + 399 (puisqu'un pixel prend exactement un octet).adresseBitmap + 400.En généralisant nous obtenons:
adressePixel = adresseBitmap + (400 * y) + x
ou en généralisant d'avantage:
adressePixel = adresseBitmap + (largeurBitmap * y) + x
Il nous suffit d'écrire la nuance à cette adresse pour modifier le point. Vous devez maintenant avoir une bonne idée du fonctionnement de la méthode -[NSBitmapImageRep setPixel:atX:y];
À vrai dire, j'ai simplifié la figure précédente, en omettant un détail. Voici une figure plus juste:

En effet, pour des raisons de performances, des octets inutilisés (row bytes) sont ajoutés à la fin de chaque ligne. Ils servent à aligner la bitmap en mémoire. On ne connaît pas leur nombre a priori: cela dépend de plusieurs paramètres.
Toujours est-il qu'il faut en tenir compte.
Dans notre boucle de rendu, il ne nous reste plus grand chose que nous puissions améliorer si ce n'est cette ligne:
[bitmapRep setPixel:&nuance atX:x y:y];
Cette méthode est tout de même appelée un million de fois (pour notre rendu en 1000 x 1000 pixels). Les bénéfices attendus en écrivant directement dans la bitmap sont les suivants:
[setPixel:atX:y:]. Les appels de méthodes sont encore plus lents que les appels de fonctions. Nous ferons entre-autres l'économie de l'exécution de la fonction objc_msgSend_rtp().[setPixel:atX:y:] est forcément plus compliquée qu'un écriture directe dans la bitmap, ne serait-ce que par son côté généraliste. De plus, si vous vous rappelez l'article précédent, Shark listait des appels aux méthodesÀ cause de la manière dont est stockée la bitmap, il est nécessaire les énumérations de x et y :
for(y = 0; y < hauteurBitmap; y++)
{
for(x = 0; x < largeurBitmap; x++)
{
…
c.reel += incX;
}
c.reel = premierPoint.reel;
c.imag += incY;
}
Avant le calcul:
// Obtenir la bitmap
unsigned char* bitmapPtr = [bitmapRep bitmapData];
On demande l'adresse de la bitmap.
unsigned int rowBytes = [bitmapRep bytesPerRow] - largeurBitmap;
La méthode [bitmapRep bytesPerRow] renvoie le nombre d'octets utilisés pour stocker une ligne. Comme nous savons que largeurBitmap octets sont utiles, nous en déduisons le nombre d'octets d'alignement (rowBytes).
for(y = 0; y < hauteurBitmap; y++)
{
for(x = 0; x < largeurBitmap; x++)
{
…
// Donner le niveau de gris au pixel
*bitmapPtr = n * 255 / MAX_ITERATIONS;
Nous conservons la formule du calcul de la nuance, que nous écrivons dans la bitmap à l'adresse du pixel courant.
bitmapPtr++;
Puis nous passons au pixel suivant.
c.reel += incX;
}
c.reel = premierPoint.reel;
c.imag += incY;
bitmapPtr += rowBytes; // Sauter les octets inutilisés en fin de ligne
À la fin de chaque ligne, nous sautons les octets inutilisés.
}
50 images rendues en 12.864695 secondes.
Moyenne = 3.886606 images/s
Nous tournons aux alentours de 4 images/s. Je vous rappelle que la première mesure donnait 0,79 images/s… la vitesse a été multipliée par 5 !
Nous arrivons aux limites de ce que nous pouvons ainsi optimiser. Nous allons nous arrêter là.
Améliorer la vitesse demanderait maintenant de recourir à des astuces.
Voici une idée: vous pouvez remarquer que deux pixels qui se suivent sont très souvent de la même nuance. On pourrait ne calculer qu'un pixel sur deux:
On ne manquerait que des variations brusques (<1 pixel), peu perceptibles.
Cependant, tout cela est sans intérêt, parce que la vraie façon d'améliorer la vitesse est de déléguer le calcul… à la carte graphique! Je crois que n'importe quelle carte est aujourd'hui capable de faire ce calcul en temps réel. Ce sera peut-être pour une prochaine fois (quand j'aurais appris à me servir d'OpenGL Shading Language). En attendant, l'application est utilisable. Nous allons pouvoir nous balader dans l'ensemble de Mandelbrot.
À bientôt pour la suite.
Le projet XCode complet à télécharger.
Renaud Pradenc
Céroce.com
Mala
5 mars 2009 | 12:46
Une suggestion d'optimisation qui coûte pas cher à essayer:
Précalculer "255 / MAX_ITERATIONS" en dehors des boucles pour éviter une division redondante. Mais c'est à tester car GCC l'optimise peut être comme un grand.
Renaud Pradenc
5 mars 2009 | 14:10
@Mala: J'ai laissé comme ça, parce que ça permet de faire des calculs sur des entiers. À mon avis, sur les PowerPC, comme il y a beaucoup de registres de données entiers (au moins 32), le compilateur va les utiliser. Par contre, sur Intel, comme ils sont peu nombreux, il utilisera peut-être plutôt des registres flottants. Bref, comme tu dis ce serait à tester, mais peu importe, ce n'est pas ça qui va doubler la vitesse.
Mala
5 mars 2009 | 14:43
Je t'ai fais le test sur mon Mac Pro Intel pour voir:
Avant:
500 images rendues en 14.273892 secondes.
Moyenne = 35.028988 images/s
En précalculant "255 / MAX_ITERATIONS"
500 images rendues en 13.614020 secondes.
Moyenne = 36.726845 images/s
En modifiant "n" en "unsigned char" afin d'éviter un cast inutile
500 images rendues en 13.545301 secondes.
Moyenne = 36.913170 images/s
>>mais peu importe, ce n'est pas ça qui va doubler la vitesse.
5% de gain en modifiant deux lignes de code c'est déjà pas mal payé. :)
Non, par contre je suis surpris que tu n'ailles pas plus loin. Quiz du multi-threading? Ton algo s'y prête très bien et tous les macs modernes ont au minimum deux coeurs. Là effectivement, il y a matière à aller chercher le facteur 2 en perfs voir bien plus sur un quadri ou un octo.
Renaud Pradenc
5 mars 2009 | 15:39
En fait, la mesure n'est pas très précise! Comme indiqué dans un commentaire précédent, il faudrait au ne compter que le temps passé dans CE processus.
Je suis totalement d'accord avec toi qu'on pourrait utiliser le multithreading, ou mieux, tout déléguer à la carte graphique. Cependant, je ne souhaite pas approfondir la question de l'optimisation pour l'instant, ce n'est pas le but de la série qui est plutôt de donner une vue d'ensemble de l'utilisation de Cocoa. Si tu as envie d'approfondir, ça tombe bien, Cocoa.fr embauche des pigistes bénévoles mais corvéables, contacte-moi !
Mala
5 mars 2009 | 17:14
Ok, je te mail d'ici la fin de semaine.
Juste pour réagir à ta réflexion sur la GPU, tes calculs sont faits en double précision. Déléguer à une GPU t'imposerait de passer en simple précision en l'état des technos. Je ne sais pas si c'est un problème en soit pour un Mandelbrot.
Sinon, pour les perfs, ta mesure est relativement fiable pour peu de faire des benchs avec uniquement ton appli de lancée. L'important c'est d'avoir une boucle de traitement assez grosse.
PS: tiens en basculant ton projet en 64bits on gratte encore une frame...
500 images rendues en 13.162125 secondes.
Moyenne = 37.987789 images/s
Ok, je sors... ;)