19

Méthode simple d’optimisation de code NEON

juin
1 Comment » |  Posted by Etienne SOBOLE |  Category:ARM, Assembleur, Code

Depuis que j’ai découvert NEON, j’ai eu l’occasion de coder plusieurs petits programmes pour tester un peu la bête.
NEON est vraiment l’un des points fort des Cortex. Son utilisation est vraiment simple, son nombre important de registres et ses instructions à 3 registres rendent sa programmation en assembleur plus facile que sur d’autre processeurs.
Fortement pipeliné, l’ordre des instructions est assez important.
Quelque soit le programme que vous allez réaliser avec NEON, vous tomberez rarement sur la version optimale de son implémentation du premier coup. Au final, une fois que vous avez tout pipeliné au mieux, il reste une petite optimisation qui permet quasiment à chaque fois de gagner encore quelques cycles…

Anatomie d’un code NEON

Un programme NEON “correct” ressemble toujours à ça:

	@ r0 contient le nombre d'itérations
.loop:
	@ Chargement des données

	@ Calculs

	@ Enregistrement des données calculées.

	subs		r0, r0, #1
	bne		.loop

Tout d’abords on charge les registres NEON avec les données sources.
On effectue les calculs.
Puis pour finir, on enregistre données résultats.

A cause de l’aspect pipeliné de NEON, certaines instructions vont devoir attendre. C’est généralement le cas de l’enregistrement des résultats.

Il est donc souvent (toujours) judicieux de réorganiser votre programme.
Une des réorganisation judicieuse consiste à éloigner le calcul de l’enregistrement et le lecture du calcul.
On obtient alors un algorithme du type.

	@ r0 contient le nombre d'itérations
	sub		r0, r0, #1					@ n - 1 iteration
	@ Chargement des données
.loop:
	@ Calculs

	subs		r0, r0, #1
	beq		.exit_loop

	@ Chargement des données de la prochaine itération

	@ Enregistrement des données calculées.
	b			.loop
.exit_loop:

	@ Enregistrement des données calculées.

L’idée est donc d’espacer la fin du calcul de l’enregistrement des données en y insérant la lecture des données suivantes.
En prime on a aussi séparé la lecture des données du calcul, ce qui va aussi faire gagner quelques cycles appréciables.

Il est important de noter que cette réorganisation n’est possible que si les registres “source” (chargés lors de la lecture) sont différents des registres “destination” (utilisé lors de l’enregistrement).

Exemple

Lorsque j’ai commencé la programmation NEON, j’ai découvert le site hilbert-space.de et surtout le code de conversion RGB vers niveau de gris que j’ai décortiqué, analysé, modifié et optimisé à outrance des heures durant…
Comme le programme est extrêmement simple il constitue toujours un bon sujet d’étude…

Prenons le pour exemple ici.
Code source NEON

	@ build the three constants:
	mov         r3, #77
	mov         r4, #151
	mov         r5, #28
	vdup.8      d3, r3
	vdup.8      d4, r4
	vdup.8      d5, r5

.loop:
	@ load 8 pixels:
	vld3.8      {d0-d2}, [r1]!

	@ do the weight average:
	vmull.u8    q3, d0, d3
	vmlal.u8    q3, d1, d4
	vmlal.u8    q3, d2, d5
	vshrn.u16   d8, q3, #8

	@ save result
	vst1.8      {d8}, [r0]!

	subs        r2, r2, #1
	bne         .loop

J’ai juste changé le registre destination NEON qui est à présent d8.
Ce code prend 16 cycles par itération. Voir le résultat dans le compteur de cycles.

Rem: pour connaitre le nombre de cycles moyen pris par une itération d’un boucle, il suffit de lire à quel cycle s’exécute le branchement de fin de la boucle.

Ce code présente deux instructions qui génèrent des cycle d’attentes.

  • L’instruction VSHRN que nous n’allons pas optimiser ici
  • L’instruction VST1 qui est l’instruction dont nous cherchons à améliorer la performance

Donc j’applique la règle présenté précédemment.
C’est à dire qu’on va réaliser une itération de moins et intercaler la lecture des données suivantes avant l’écriture des données calculées.

Ce qui nous donne.

	@ build the three constants:
	mov         r3, #77
	mov         r4, #151
	mov         r5, #28
	vdup.8      d3, r3
	vdup.8      d4, r4
	vdup.8      d5, r5
	sub			r2, r2, "1"

	@ load 8 pixels:
	vld3.8      {d0-d2}, [r1]!

.loop:
	@ do the weight average:
	vmull.u8    q3, d0, d3
	vmlal.u8    q3, d1, d4
	vmlal.u8    q3, d2, d5
	vshrn.u16   d8, q3, #8

	subs        r2, r2, #1
	beq         .exit_loop

	@ load 8 pixels:
	vld3.8      {d0-d2}, [r1]!

	@ save result
	vst1.8      {d8}, [r0]!
	b				.loop
.exit_loop:
	vst1.8      {d8}, [r0]!

A présent le code ne prend plus que 13 cycles. Voir le résultat dans le compteur de cycles.

Rem: On serait tenté de remonter la lecture avant le VSHRN puisque c’est l’instruction qui créée le plus de cycles de latence. Ce serait en fait un bonne idée. Le programme tournerai alors en 12 cycles, mais on sortirai du cadre de ce post qui cherche juste à sous entendre qu’une grande majorité des codes source NEON (peut-être même tous) peuvent être optimisés en intercalant la lecture des données suivantes avant l’enregistrement des données calculées par l’itération en cours.

Et la lecture alors ?

Si on s’en tient au résultat du compteur de cycles, on peut croire que l’on a rien gagner en terme de temps de lecture des données (puisqu’il n’y a pas de cycle d’attente lors de la première instruction de calcul)…
En fait, ce n’est pas tout à fait le cas… Il y a bel et bien un autre intérêt à cette optimisation.

Le résultat retourné par le compteur de cycle suppose que tous les accès mémoire sont en cache (et que les branchements sont toujours bien prédits).
Hors, au cas où les données lues ne seraient pas dans le cache, il y a un réel intérêt à réaliser la lecture bien en amont de l’utilisation des données. Il faut bien comprendre qu’à cause (ou grâce) à la file d’attente NEON, les instructions NEON sont généralement exécutées bien après leur décodage par l’ARM (c’est d’ailleurs pour cela que les cycles d’exécutions du compteur de cycles affiche généralement des nombres plus élevés que les cycles d’exécution des instructions ARM).

Rem: Ce qui suit n’est que spéculation. Même si cela semble être correct !
Le Cortex dispose aussi d’une liste d’accès mémoire. Cela veut dire que plus tôt l’ARM sait quelle zone mémoire vous allez lire, mieux c’est !
En gros, le Cortex commence à lire les données, s’il le peut, non pas lorsque l’instruction VLD3 va être réellement exécutée par NEON mais bel et bien lorsque l’instruction va traverser le pipeline de l’ARM.
Donc plus en amont la lecture est réalisée mieux s’est.

La liste des accès mémoire NEON à une longueur limitée à 8 instructions. On pourrait donc, à la limite, imaginer lire plus d’une itération d’avance. Mais là ça devient vite compliqué !!!

Conclusion

Pour autant que j’ai pu le constater, cette simple méthode de déplacement des instructions de lecture s’applique à tous les programmes NEON et offre dans tous les cas un gain de quelques cycles par itérations.
Evidemment, cela n’est pas suffisant pour atteindre le résultat optimal.
Pourtant, à la fin, une fois que vous avez pipeliné tous ce qui pouvait l’être, cette ultime astuce pourrait vous faire gagner encore un peu de temps.

 | Tags: ,

One Response to “Méthode simple d’optimisation de code NEON”

  1. [...] This post is a translation of « Méthode simple d’optimisation de code NEON«  [...]

Répondre

Human control : 7 + 7 =