Cet article devait être publié sur le Calendrier de l'Avent Raspberry Pi 2015 il y a une semaine. Il s'agit d'une continuation de la multiplication de matrice avec le GPU de Raspberry Pi (1). Je suis vraiment désolé d'avoir dépassé le calendrier.
La dernière fois, j'ai fait une partie pour calculer la matrice 16x64 dans la figure ci-dessous en parallèle avec SIMD, donc je vais faire d'autres parties pour compléter le code de multiplication matricielle.
# Implémentation de i-loop et j-loopi-loop et j-loop sont exécutés très peu de fois par rapport à k-loop, c'est donc facile à faire.
À propos de j-loop
for(j = 0; j < r; j+=64) {
body
}
Est traduit en un style d'assemblage
j = 0
j_loop_begin:
if(j >= r) goto j_loop_exit
body
j += 64
goto j_loop_begin
j_loop_exit:
Cependant, il y a un saut inutile en l'état. Le saut prend du temps et si l'entrée du cache d'instructions est expulsée, les performances des autres parties seront affectées. Vous pouvez le réécrire pour qu'il n'y ait qu'un seul saut dans la boucle comme indiqué ci-dessous.
j = 0
if(j >= r) goto j_loop_exit
j_loop_begin:
body
j += 64
if(j < r) goto j_loop_begin
j_loop_exit:
Le tout premier j> = r
échoue toujours et peut être omis.
j = 0
j_loop_begin:
body
j += 64
if(j < r) goto j_loop_begin
j_loop_exit:
En d'autres termes, ce sera sous la forme d'une boucle de type do-while comme indiqué ci-dessous.
j = 0
do {
body
j += 64
} while (j < r);
Aussi, que la forme ci-dessus
j = r
do {
body
j -= 64
} while(j > 0);
Je pense que la forme est meilleure. Le fait est que ce dernier réduit le nombre d'accès à «r». (En prime, le nombre d'opérations est réduit de un. Le premier était deux fois «j + 64» et «j --r», mais le second est une fois «j --64».)
j = 0
do {
body
j += 64
} while (j < r); <-Lisez r ici à chaque fois
j = r <-Une seule fois ici
do {
body
j -= 64
} while(j > 0);
Les variables à faible fréquence d'accès ont peu d'effet même si elles sont placées dans un endroit lent, elles peuvent donc être sauvegardées en mémoire ou par la méthode introduite la dernière fois. Ensuite, les registres et accumulateurs enregistrés peuvent être utilisés efficacement dans le corps de la boucle, ce qui conduit indirectement à une amélioration des performances. Écrivez i-loop de la même manière.
J'ai pensé à faire le revêtement du pipeline logiciel que j'ai mentionné la dernière fois, mais je ne l'ai pas fait parce que j'ai épuisé le contrôle exclusif autour duquel il sera décrit plus tard.
Je vais expliquer ce que j'ai essayé de faire. Si vous écrivez ce programme en pseudo-code, ce sera comme suit. Comme je l'ai expliqué la dernière fois, j'ajoute la matrice 16x16 en 4 étapes, mais il est temps de ne faire aucun calcul au point où le 4ème bloc est transféré vers l'hôte.
for (j) {
Calcul d'adresse et initialisation du cache
k-La partie qui dépasse de la boucle
for (k) {
Calculer le produit des vecteurs de A et B
}
Lors du chargement du premier bloc, k-Calculez la quantité qui dépasse derrière la boucle
Chargez le deuxième bloc et calculez le premier bloc
Calculez le deuxième bloc tout en stockant le premier bloc et en chargeant le troisième bloc
Calculez le 3e bloc en stockant le 2e bloc et en chargeant le 4e bloc
Calculez le 4ème bloc en stockant le 3ème bloc
Stocker le 4ème bloc<-Je n'ai rien calculé!
Branche conditionnelle(Revenir au début ou sortir de la boucle)
}
Afin de combler ce temps d'attente (latence), l'opération consistant à introduire les instructions de l'itération suivante est appelée pipeline logiciel. Dans certains cas, vous pouvez l'apporter des itérations suivantes et suivantes. VideoCore IV n'effectue pas (probablement) une exécution dans le désordre d'instructions telles que des GPU coûteux, donc je pense que ce genre d'effort est important.
Bien sûr, la latence peut également être masquée en la rendant multithread. C'est une distorsion dans le GPU de NVIDIA. VideoCore IV QPU peut exécuter 2 threads en même temps.
Maintenant que je peux courir avec un QPU, j'ai pris un benchmark. J'ai utilisé le Raspberry Pi Zero que j'ai acheté l'autre jour. Le processeur et le GPU installés sont les mêmes que Raspberry Pi 1, et seule l'horloge du processeur est améliorée à 1Ghz.
Le code source utilisé est ci-dessous.
https://github.com/nineties/py-videocore/blob/master/examples/sgemm_1thread.py
Voici les résultats. Ici, le type de A est 96x363 et le type de B est 363x3072 selon le pi-gemm.
la mise en oeuvre | Le nombre de fils | Temps d'exécution | Performance mesurée |
---|---|---|---|
numpy(BLAS) | Processeur 1 fil | 3.05 secondes | 0.070 Gflops |
pi-gemm | Fils QPU 12 | 0.21 secondes | 1.02 Gflops |
ma | Filetage QPU 1 | 0.23 secondes | 0.95 Gflops |
J'ai pu le rendre assez rapide pour rattraper pi-gemm avec un seul QPU. Cependant, comme la performance théorique d'un QPU est de 2Gflops, il est dommage que la performance ne soit que d'environ 50% de cela. Après diverses mesures, Uniforms Cache a été plus lent que prévu et il a fallu environ 2 instructions pour charger à 1 QPU. Ensuite, la longueur du corps de la boucle k est à peu près doublée, ce qui est d'environ 50%. L'augmentation du nombre de QPU augmentera les échecs de cache, réduisant encore plus l'efficacité. Je m'y attendais dans une certaine mesure car une charge ne fait que 4 octets et la distance est courte avec SoC.
L.k_loop
fadd(ra1, ra1, r0).fmul(r0, r4, uniform) #Ces gars ont environ 7 ans~Comme environ 8 horloges(2 instructions)
fadd(rb1, rb1, r0).fmul(r0, r4, uniform)
...
fadd(rb31, rb31, r0, sig='load tmu0').mov(uniforms_address, r2)
iadd(r2, r2, r3).mov(tmu0_s, r1)
jzc(L.k_loop)
iadd(r1, r1, 4).fmul(r0, r4, uniform) # delay slot #Ce mec ressemble à 30 horloges
fadd(ra0, ra0, r0).fmul(r0, r4, uniform) # delay slot
fadd(rb0, rb0, r0).fmul(r0, r4, uniform) # delay slot
Je me demande comment améliorer cela.
Le TMU a un cache L1 et semble être plus rapide que le cache d'uniformes, mais il consomme de l'ALU pour le calcul d'adresse, il n'est donc pas adapté à la lecture de différents vecteurs les uns après les autres. Il semble qu'il existe un moyen de lire d'abord deux vecteurs avec TMU et de calculer le produit direct tout en répétant la rotation et la diffusion pour un. Cela consomme également de l'ALU avec la rotation, mais comme le nombre de fois où atteindre le cache est réduit, les performances lors de l'augmentation du QPU peuvent être meilleures.
En fait, ce n'était pas si lent entre QPU et VPM, et quand il n'y avait qu'un seul QPU, les deux lectures / écritures pouvaient se faire sans blocage. Cependant, VPM est partagé par tous les QPU, donc augmenter les QPU finira par ralentir. De plus, si vous lisez les matrices A et B avec VPM, il y a aussi le problème que le nombre de DMA augmentera considérablement.
Pour le moment, je pense que la première étape consiste à mesurer les performances de chaque cache et à examiner la structure interne. Ce qui précède est une tâche future et nous continuerons.
[Ajout] Je pensais que Uniforms Cache ne pouvait pas être prérécupéré par logiciel en raison du mécanisme, mais comme le cache L2 est partagé avec TMU, il semble que les Uniforms peuvent être extraits à l'avance jusqu'à L2 en utilisant TMU.
Extrait du Guide de référence sur l'architecture 3D de VideoCore IV
VideoCore dispose de 12 QPU. Cette fois, chaque QPU exécutera un thread, pour un total de 12 threads. Par conséquent, comme le montre la figure ci-dessous, la matrice $ A $ est divisée en plusieurs parties horizontalement, et $ B $ est divisé en plusieurs parties verticalement, et le produit de celles-ci est attribué à chaque QPU.
Comme un programme multithread standard, il nécessite un contrôle exclusif sur l'accès aux ressources partagées. Pour cette raison, VideoCore IV a un mutex et 16 sémaphos 4 bits.
J'écrirai le contrôle de synchronisation en utilisant ces derniers, mais cette fois j'ai omis de joindre la partie qui a besoin de synchronisation avec un mutex entier. Bien sûr, cela devrait avoir un impact sur les performances ...
Principalement les ressources partagées par QPU
Etc., mais
Seules deux configurations de lecture VPM peuvent être émises à la fois et seront ignorées lorsque la file d'attente est pleine. (Guide de référence P56)
Et
Le chargement dans DMA ne peut pas être émis tant que le précédent n'est pas terminé. La même chose s'applique au magasin. (Identique à P56)
Il y a des restrictions comme. Aucun d'eux n'est du genre "caler jusqu'à la fin du précédent", et la requête est ignorée ou Raspi lui-même s'arrête. En outre, il semble y avoir des restrictions non mentionnées dans le guide de référence (en particulier concernant le registre de configuration de foulée supplémentaire). C'était une vraie pénitence parce que le bug de mon propre assembleur chevauchait cela.
Semafo est utilisé pour gérer des situations où le nombre de ressources est limité, comme le premier "seulement jusqu'à deux". Conservez l'un de tous les threads en tant que thread principal, et d'abord il élève le semapho de 2. Les threads qui veulent lire VPM réduiront ce sémapho de un et le relèveront de un lorsqu'ils auront fini de l'utiliser. Les threads qui tentent de le réduire en dessous de 0 seront dans un état d'attente, vous pouvez donc en utiliser jusqu'à deux en même temps. Le verrouillage peut également être effectué avec un sémapho.
Je n'ai pas utilisé le semapho dans cette partie car je l'ai entouré de mutex cette fois, mais je l'utilise à la place pour synchroniser la fin du fil. Le thread maître doit attendre que tous les threads terminent leurs calculs avant d'émettre une interruption vers l'hôte. S'il y a 12 threads, la procédure de synchronisation est la suivante.
C'est très simple. Vous trouverez ci-dessous le code de cette partie.
sema_up(COMPLETED) # Notify completion to the thread 0
...
mov(null, uniform, set_flags=True) # thread index
jzc(L.skip_fin)
nop(); nop(); nop()
# Only thread 0 enters here.
for i in range(n_threads):
sema_down(COMPLETED) # Wait completion of all threads.
interrupt()
L.skip_fin
exit(interrupt=False)
En fait, je pensais faire le traitement de quatre blocs dans un pipeline comme le montre la figure ci-dessous. Je peux l'essayer quand j'en ai le temps.
Le code est ci-dessous.
https://github.com/nineties/py-videocore/blob/master/examples/sgemm.py
Comme précédemment, la taille de la matrice est de 96x363 pour A et de 363x3072 pour B. Avec 12 threads, A a été divisé en deux et B a été divisé en six, ce qui était le plus rapide. J'imagine que c'est là que le cache pour A (TMU) et le cache pour B (Uniforms) sont bien équilibrés. Je ferai bientôt des recherches détaillées.
la mise en oeuvre | Le nombre de fils | Nombre de divisions de A | Nombre de divisions de B | Temps d'exécution | Performance mesurée |
---|---|---|---|---|---|
numpy(BLAS) | Filetage CPU1 | - | - | 3.05 secondes | 0.070 Gflops |
pi-gemm | Fils QPU 12 | - | - | 0.21 secondes | 1.02 Gflops |
ma | Filetage QPU 1 | 1 | 1 | 0.23 secondes | 0.95 Gflops |
ma | Fils QPU 12 | 2 | 6 | 0.026 secondes | 8.32 Gflops |
Le calcul (avec une double précision) avec numpy + BLAS sur mon ordinateur portable (core i7-5600U 2,6 GHz) est à peu près la même vitesse. En d'autres termes, même si vous essayez d'utiliser le GPU de Raspberry Pi, il est malheureusement plus rapide de calculer avec un ordinateur normal. Pi-Zero est de 600 yens, il peut donc être rentable s'il s'agit d'un rapport qualité / prix.
Recommended Posts