Dans les situations où les performances sont de la plus haute importance, le compilateur C ne produira probablement pas le code le plus rapide par rapport à ce que vous pouvez faire avec un langage d'assemblage réglé manuellement. J'ai tendance à prendre le chemin de la moindre résistance - pour les petites routines comme celle-ci, j'écris simplement du code asm et j'ai une bonne idée du nombre de cycles qu'il faudra pour l'exécuter. Il est possible de manipuler le code C et de faire en sorte que le compilateur génère un bon résultat, mais vous risquez de perdre beaucoup de temps à régler le résultat de cette manière. Les compilateurs (en particulier ceux de Microsoft) ont beaucoup progressé ces dernières années, mais ils ne sont toujours pas aussi intelligents que le compilateur entre vos oreilles, car vous travaillez sur votre situation spécifique et pas seulement sur un cas général. Le compilateur peut ne pas utiliser certaines instructions (par exemple, LDM) qui peuvent accélérer le processus, et il est peu probable qu'il soit assez intelligent pour dérouler la boucle. Voici une façon de le faire qui incorpore les 3 idées que j'ai mentionnées dans mon commentaire : Le déroulement de la boucle, la préextraction du cache et l'utilisation de l'instruction de chargement multiple (ldm). Le nombre de cycles d'instruction s'élève à environ 3 horloges par élément de tableau, mais cela ne tient pas compte des délais de mémoire.
Théorie du fonctionnement : La conception du CPU d'ARM permet d'exécuter la plupart des instructions en un cycle d'horloge, mais les instructions sont exécutées dans un pipeline. Les compilateurs C essaient d'éliminer les retards du pipeline en intercalant d'autres instructions entre elles. Lorsqu'on lui présente une boucle serrée comme le code C original, le compilateur aura du mal à cacher les retards car la valeur lue en mémoire doit être immédiatement comparée. Mon code ci-dessous alterne entre 2 ensembles de 4 registres pour réduire de manière significative les délais de la mémoire elle-même et du pipeline récupérant les données. En général, lorsque vous travaillez avec de grands ensembles de données et que votre code n'utilise pas la plupart ou la totalité des registres disponibles, vous n'obtenez pas des performances maximales.
; r0 = count, r1 = source ptr, r2 = comparison value
stmfd sp!,{r4-r11} ; save non-volatile registers
mov r3,r0,LSR #3 ; loop count = total count / 8
pld [r1,#128]
ldmia r1!,{r4-r7} ; pre load first set
loop_top:
pld [r1,#128]
ldmia r1!,{r8-r11} ; pre load second set
cmp r4,r2 ; search for match
cmpne r5,r2 ; use conditional execution to avoid extra branch instructions
cmpne r6,r2
cmpne r7,r2
beq found_it
ldmia r1!,{r4-r7} ; use 2 sets of registers to hide load delays
cmp r8,r2
cmpne r9,r2
cmpne r10,r2
cmpne r11,r2
beq found_it
subs r3,r3,#1 ; decrement loop count
bne loop_top
mov r0,#0 ; return value = false (not found)
ldmia sp!,{r4-r11} ; restore non-volatile registers
bx lr ; return
found_it:
mov r0,#1 ; return true
ldmia sp!,{r4-r11}
bx lr
Mise à jour : Il y a beaucoup de sceptiques dans les commentaires qui pensent que mon expérience est anecdotique/sans intérêt et qui demandent des preuves. J'ai utilisé GCC 4.8 (du NDK 9C d'Android) pour générer la sortie suivante avec l'optimisation -O2 (toutes les optimisations activées) y compris le déroulement des boucles ). J'ai compilé le code C original présenté dans la question ci-dessus. Voici ce que GCC a produit :
.L9: cmp r3, r0
beq .L8
.L3: ldr r2, [r3, #4]!
cmp r2, r1
bne .L9
mov r0, #1
.L2: add sp, sp, #1024
bx lr
.L8: mov r0, #0
b .L2
La sortie de GCC non seulement ne déroule pas la boucle, mais gaspille aussi une horloge sur un blocage après la LDR. Il faut au moins 8 horloges par élément de tableau. Il fait un bon travail en utilisant l'adresse pour savoir quand sortir de la boucle, mais toutes les choses magiques que les compilateurs sont capables de faire ne se trouvent nulle part dans ce code. Je n'ai pas exécuté le code sur la plateforme cible (je n'en possède pas), mais toute personne expérimentée dans la performance du code ARM peut voir que mon code est plus rapide.
Mise à jour 2 : J'ai donné à Visual Studio 2013 SP2 de Microsoft une chance de faire mieux avec le code. Il a pu utiliser les instructions NEON pour vectoriser l'initialisation de mon tableau, mais la recherche linéaire de valeurs telle qu'écrite par le PO a donné un résultat similaire à celui généré par GCC (j'ai renommé les étiquettes pour le rendre plus lisible) :
loop_top:
ldr r3,[r1],#4
cmp r3,r2
beq true_exit
subs r0,r0,#1
bne loop_top
false_exit: xxx
bx lr
true_exit: xxx
bx lr
Comme je l'ai dit, je ne possède pas le matériel exact de l'OP, mais je vais tester les performances sur un nVidia Tegra 3 et Tegra 4 des 3 différentes versions et poster les résultats ici bientôt.
Mise à jour 3 : J'ai exécuté mon code et le code ARM compilé par Microsoft sur un Tegra 3 et un Tegra 4 (Surface RT, Surface RT 2). J'ai exécuté 1000000 itérations d'une boucle qui échoue à trouver une correspondance afin que tout soit en cache et que ce soit facile à mesurer.
My Code MS Code
Surface RT 297ns 562ns
Surface RT 2 172ns 296ns
Dans les deux cas, mon code s'exécute presque deux fois plus vite. La plupart des processeurs ARM modernes donneront probablement des résultats similaires.
5 votes
Vous obtiendrez certainement une solution plus rapide en l'écrivant en langage assembleur. Vous pouvez gagner en rapidité de trois manières : le déroulement de la boucle, la prélecture du cache et l'utilisation d'instructions "load-multiple". Les deux premières peuvent potentiellement être réalisées en C, mais pas la dernière. Je ne fais jamais confiance aux compilateurs C pour faire la "bonne" chose et je suis rarement surpris.
28 votes
Y a-t-il un moyen de stocker la valeur dans le tableau de manière différente ? Si vous pouvez les trier, une recherche binaire sera sûrement plus rapide. Si les données à stocker et à rechercher sont comprises dans une certaine fourchette, elles peuvent être représentées par une carte binaire, etc.
20 votes
@BitBank : vous seriez surpris de voir à quel point les compilateurs se sont améliorés au cours des trois dernières décennies. ARM, en particulier, est assez facile à compiler. Et je sais avec certitude que ARM sur GCC peut émettre des instructions multiples de chargement (depuis 2009 au moins).
0 votes
Votre compilateur a-t-il déroulé la boucle ? Si non, avez-vous essayé de le faire manuellement et de le mesurer ?
5 votes
Merci à tous. Non, le compilateur ne déroule pas le code. En fait, j'ai résolu le problème en utilisant une recherche binaire (valeurs dans le tableau triées sur le coup droit).
0 votes
Une information importante non exprimée dans la question est de savoir si le tableau est sous contrôle logiciel ou généré par le matériel (DMA, etc.). L'optimisation évidente est de changer le tableau si vous le contrôlez.
9 votes
Question géniale, les gens oublient qu'il y a des cas réels où les performances comptent. trop souvent, on répond à des questions comme celle-ci par "utilisez simplement stl".
14 votes
Le titre "... itérer dans un tableau" est trompeur car en effet, vous recherchez simplement une valeur donnée. Itérer sur un tableau implique que quelque chose doit être fait sur chaque entrée. Le tri, si le coût peut être amorti sur de nombreuses recherches, est en effet une approche efficace, indépendamment des problèmes d'implémentation du langage.
2 votes
Une recherche binaire sur un tableau trié sera probablement beaucoup moins chère que n'importe quel balayage linéaire de 256 entrées.
8 votes
Êtes-vous sûr que vous ne pouvez pas simplement utiliser une recherche binaire ou une table de hachage ? Une recherche binaire pour 256 éléments == 8 comparaisons. Une table de hachage == 1 saut en moyenne (ou 1 saut max si vous avez un hachage parfait). Vous ne devriez recourir à l'optimisation d'assemblage qu'après avoir 1) un algorithme de recherche décent (
O(1)
oO(logN)
par rapport àO(N)
), et 2) vous l'avez profilé pour qu'il soit le goulot d'étranglement.0 votes
Note : Avant de dépenser de l'énergie pour optimiser ceci, faites une analyse de performance pour trouver le pourcentage du temps d'exécution réel qu'il consomme. Une accélération infinie de 1% du temps d'exécution demande un effort infini et produit une amélioration nette de 1%. Une accélération de 10 % de 10 % du programme est beaucoup plus facile à réaliser et apporte le même bénéfice. Au départ, modifier des algorithmes de haut niveau vous donnera généralement un meilleur rendement que d'essayer de modifier des instructions individuelles, ou même des sous-routines individuelles.
0 votes
@keshlam : Beaucoup de choses dépendront de la nature des données examinées. Si la séquence des données dans le tableau est sémantiquement significative, et si les données changent fréquemment, essayer de maintenir des structures de données parallèles pour optimiser la recherche peut être contre-productif.
0 votes
@supercat : C'est ce que je viens de dire : Ne pas optimiser à l'aveugle. Comprenez les données, comprenez comment les données sont utilisées, comprenez comment l'utilisation de ces données affecte réellement vos performances, ALORS décidez si c'est ce que vous voulez passer votre temps à optimiser -- et assurez-vous de mesurer avant/pendant/après à la fois pour vous concentrer correctement et pour décider si votre changement est réellement une amélioration. Surtout en Java ; le JIT de grandes applications est non déterministe !
0 votes
Réponse naïve : qu'en est-il de l'équivalent asm de
if (compareVal == array[0]) return true; if(compareVal == array[1]) return true; etc...
?0 votes
Comment intégrer l'assembleur dans un pseudo-code ?
0 votes
@keshlam : Il l'a fait. Cette recherche de tableau est tout ce que fait l'ISR, elle prend 12,5 µs, et doit être plus rapide. Je suis d'accord qu'en Java, vous avez des choses comme GC qui empêchent les performances déterministes, mais ici, vous avez vraiment cette quantité de contrôle et parfois cela vaut vraiment la peine de plonger dedans. C'est amusant, aussi... Je travaille en Python maintenant, mais ce genre de choses me manque :)
1 votes
Vous avez posté un classique Problème XY où X est "doit être plus rapide" et Y est "donc j'ai besoin de l'assembleur". Il y a beaucoup de réponses qui soulignent correctement que vous avez un problème algorithmiquement trop compliqué. Mais vous êtes resté bloqué sur le problème Y (assemblage) en ignorant les améliorations algorithmiques. La prochaine personne qui devra maintenir votre assemblage inutile maudira votre nom. L'assembleur n'est plus aussi macho qu'il l'était. Correct, lisible et maintenable sont les nouveaux critères.
1 votes
@Kik : Personne n'oublie que la performance compte. Malheureusement, tout le monde semble avoir oublié que preuve questions. Aucune des réponses ci-dessous ne présente de mesures ; AUCUNE d'entre elles !
0 votes
les gens oublient qu'il y a des cas réels où les performances comptent -- Non, ils ne le font pas. trop souvent, on répond à des questions comme celle-ci par "utilisez simplement stl". -- STL est très efficace mais ce n'est pas une réponse valable ici car c'est une question C.
0 votes
Que signifient les acronymes ISR et MCU ?