Voici un exemple concret : Les multiplications en virgule fixe sur les vieux compilateurs.
Ils ne sont pas seulement pratiques sur les appareils sans virgule flottante, ils brillent lorsqu'il s'agit de précision, car ils vous donnent 32 bits de précision avec une erreur prévisible (la virgule flottante n'a que 23 bits et il est plus difficile de prévoir la perte de précision). absolu précision sur toute la plage, au lieu d'une précision quasi uniforme. relatif précision ( float
).
Les compilateurs modernes optimisent bien cet exemple en virgule fixe. Pour des exemples plus modernes qui nécessitent encore du code spécifique au compilateur, voir
Le C ne possède pas d'opérateur de multiplication complète (résultat de 2N bits à partir d'entrées de N bits). La façon habituelle de l'exprimer en C est de mettre les entrées dans le type le plus large et d'espérer que le compilateur reconnaisse que les bits supérieurs des entrées ne sont pas intéressants :
// on a 32-bit machine, int can hold 32-bit fixed-point integers.
int inline FixedPointMul (int a, int b)
{
long long a_long = a; // cast to 64 bit.
long long product = a_long * b; // perform multiplication
return (int) (product >> 16); // shift by the fixed point bias
}
Le problème avec ce code est que nous faisons quelque chose qui ne peut pas être directement exprimé dans le langage C. Nous voulons multiplier deux nombres de 32 bits et obtenir un résultat de 64 bits dont nous retournons le 32 bits du milieu. Cependant, en C, cette multiplication n'existe pas. Tout ce que vous pouvez faire est de promouvoir les entiers en 64 bits et de faire une multiplication 64*64 = 64.
x86 (et ARM, MIPS et autres) peut cependant effectuer la multiplication en une seule instruction. Certains compilateurs avaient l'habitude d'ignorer ce fait et de générer du code qui appelle une fonction de la bibliothèque d'exécution pour effectuer la multiplication. Le décalage par 16 est également souvent effectué par une routine de bibliothèque (le x86 peut également effectuer de tels décalages).
Nous nous retrouvons donc avec un ou deux appels à la bibliothèque, juste pour une multiplication. Cela a de graves conséquences. Non seulement le décalage est plus lent, mais les registres doivent être préservés à travers les appels de fonction et cela ne facilite pas non plus l'inlining et le code-unrolling.
Si vous réécrivez le même code en assembleur (en ligne), vous pouvez obtenir un gain de vitesse significatif.
En outre, l'utilisation de l'ASM n'est pas la meilleure façon de résoudre le problème. La plupart des compilateurs vous permettent d'utiliser certaines instructions assembleur sous forme intrinsèque si vous ne pouvez pas les exprimer en C. Le compilateur VS.NET2008 par exemple expose le mul 32*32=64 bit comme __emul et le shift 64 bit comme __ll_rshift.
En utilisant les intrinsèques, vous pouvez réécrire la fonction de manière à ce que le compilateur C ait une chance de comprendre ce qui se passe. Cela permet d'inliner le code, d'allouer des registres, d'éliminer les sous-expressions communes et de procéder à la propagation des constantes. Vous obtiendrez un énorme l'amélioration des performances par rapport au code assembleur écrit à la main de cette façon.
Pour référence : Le résultat final pour le mul à virgule fixe pour le compilateur VS.NET est :
int inline FixedPointMul (int a, int b)
{
return (int) __ll_rshift(__emul(a,b),16);
}
La différence de performance des divisions en virgule fixe est encore plus importante. J'ai obtenu des améliorations allant jusqu'à un facteur 10 pour du code de division en virgule fixe en écrivant quelques lignes d'asm.
L'utilisation de Visual C++ 2013 donne le même code d'assemblage pour les deux façons.
gcc4.1 de 2007 optimise également la version C pure de manière satisfaisante. (L'explorateur de compilateur Godbolt n'a pas de versions antérieures de gcc installées, mais on peut supposer que même les versions plus anciennes de gcc pourraient faire cela sans intrinsèques).
Voir source + asm pour x86 (32-bit) et ARM sur l'explorateur compilateur Godbolt%3B%0A%7D%0A%23endif%0A%0A%0A/+Intrinsics+are+more+useful+for+extended+precision%0A++when+there+isn!'t+a+wide-enough+type.%0A++e.g.+128-bit+integer+on+compilers+without+__int128%0A+/%0A'),l:'5',n:'0',o:'C%2B%2B+source+%231',t:'0')),k:32.75251522372254,l:'4',n:'0',o:'',s:0,t:'0'),(g:!((g:!((h:compiler,i:(compiler:g412,filters:(b:'0',binary:'1',commentOnly:'0',demangle:'0',directives:'0',execute:'1',intel:'1',trim:'1'),lang:c%2B%2B,libs:!(),options:'-xc+-O3+-m32++-fomit-frame-pointer',source:1),l:'5',n:'0',o:'x86-64+gcc+4.1.2+(Editor+%231,+Compiler+%231)+C%2B%2B',t:'0')),k:34.10775747948107,l:'4',m:50,n:'0',o:'',s:0,t:'0'),(g:!((h:compiler,i:(compiler:arm710,filters:(b:'0',binary:'1',commentOnly:'0',demangle:'0',directives:'0',execute:'1',intel:'0',trim:'1'),lang:c%2B%2B,libs:!(),options:'-xc+-O3+-mthumb+-mcpu%3Dcortex-m4',source:1),l:'5',n:'0',o:'ARM+gcc+7.2.1+(none)+(Editor+%231,+Compiler+%232)+C%2B%2B',t:'0')),header:(),l:'4',m:50,n:'0',o:'',s:0,t:'0')),k:33.91415144294414,l:'3',n:'0',o:'',t:'0'),(g:!((g:!((h:compiler,i:(compiler:clang30,filters:(b:'0',binary:'1',commentOnly:'0',demangle:'0',directives:'0',execute:'1',intel:'0',trim:'1'),lang:c%2B%2B,libs:!(),options:'-xc+-O3+-m32',source:1),l:'5',n:'0',o:'x86-64+clang+3.0.0+(Editor+%231,+Compiler+%233)+C%2B%2B',t:'0')),k:33.33333333333333,l:'4',m:50,n:'0',o:'',s:0,t:'0'),(g:!((h:compiler,i:(compiler:cl19_2015_u3_32,filters:(b:'0',binary:'1',commentOnly:'0',demangle:'0',directives:'0',execute:'1',intel:'0',trim:'1'),lang:c%2B%2B,libs:!(),options:'-Ox',source:1),l:'5',n:'0',o:'x86+MSVC+19+2015+U3+(Editor+%231,+Compiler+%234)+C%2B%2B',t:'0')),header:(),l:'4',m:50,n:'0',o:'',s:0,t:'0')),k:33.33333333333333,l:'3',n:'0',o:'',t:'0')),l:'2',n:'0',o:'',t:'0')),version:4) . (Malheureusement, il n'y a pas de compilateurs assez vieux pour produire du mauvais code à partir de la simple version C pure).
Les processeurs modernes peuvent faire des choses pour lesquelles le C n'a pas d'opérateurs. du tout comme popcnt
ou le balayage des bits pour trouver le premier ou le dernier bit activé. . (POSIX a un ffs()
mais sa sémantique ne correspond pas à la fonction x86 bsf
/ bsr
. Voir https://en.wikipedia.org/wiki/Find_first_set ).
Certains compilateurs peuvent parfois reconnaître une boucle qui compte le nombre de bits activés dans un entier et la compiler en un fichier popcnt
(si elle est activée au moment de la compilation), mais il est beaucoup plus fiable d'utiliser l'instruction __builtin_popcnt
en GNU C, ou sur x86 si vous ne visez que du matériel avec SSE4.2 : _mm_popcnt_u32
de <immintrin.h>
.
Ou en C++, assigner à un std::bitset<32>
et utiliser .count()
. (Il s'agit d'un cas où le langage a trouvé un moyen d'exposer de manière portative une implémentation optimisée de popcount par le biais de la bibliothèque standard, d'une manière qui compilera toujours quelque chose de correct, et qui pourra tirer parti de tout ce que la cible supporte). Voir aussi https://en.wikipedia.org/wiki/Hamming_weight#Language_support .
De même, ntohl
peut être compilé en bswap
(x86 32-bit byte swap for endian conversion) sur certaines implémentations de C qui l'ont.
Un autre domaine important pour les intrinsèques ou l'asm écrite à la main est la vectorisation manuelle avec les instructions SIMD. Les compilateurs ne sont pas mauvais avec des boucles simples comme dst[i] += src[i] * 10.0;
mais se comportent souvent mal ou ne s'auto-vectorisent pas du tout lorsque les choses deviennent plus compliquées. Par exemple, il est peu probable que vous obteniez quelque chose comme Comment implémenter atoi en utilisant SIMD ? généré automatiquement par le compilateur à partir du code scalaire.
0 votes
Et maintenant, une autre question serait appropriée : Quand le fait que l'assembleur soit plus rapide que le C a-t-il vraiment de l'importance ?
20 votes
En fait, il est assez trivial d'améliorer le code compilé. Toute personne ayant une solide connaissance du langage d'assemblage et du C peut s'en rendre compte en examinant le code généré. Tout ce qui est facile est la première falaise de performance où vous tombez lorsque vous manquez de registres disponibles dans la version compilée. En moyenne, le compilateur fera bien mieux qu'un humain pour un grand projet, mais il n'est pas difficile dans un projet de taille décente de trouver des problèmes de performance dans le code compilé.
19 votes
En fait, la réponse courte est : L'assembleur est toujours La raison en est que vous pouvez avoir de l'assembleur sans C, mais vous ne pouvez pas avoir de C sans assembleur (sous la forme binaire, que nous appelions autrefois "code machine"). Cela dit, la réponse longue est la suivante : Les compilateurs C sont assez bons pour optimiser et "penser" à des choses auxquelles on ne pense pas habituellement, donc cela dépend vraiment de vos compétences, mais normalement vous pouvez toujours battre le compilateur C ; ce n'est toujours qu'un logiciel qui ne peut pas penser et avoir des idées. Vous pouvez également écrire un assembleur portable si vous utilisez des macros et si vous êtes patient.
13 votes
Je ne suis pas du tout d'accord pour dire que les réponses à cette question doivent être "basées sur l'opinion" - elles peuvent être tout à fait objectives - il ne s'agit pas d'essayer de comparer les performances des langages favoris, pour lesquels chacun aura des points forts et des inconvénients. Il s'agit de comprendre jusqu'où les compilateurs peuvent nous mener, et à partir de quel point il est préférable de prendre le relais.
0 votes
Il n'est même pas toujours nécessaire de réécrire quelque chose en assembleur pour bénéficier des avantages de la connaissance de l'assembleur. Le simple fait de recompiler votre algorithme C sous différentes formes et d'observer l'assemblage que le compilateur génère vous permettra d'écrire un code plus efficace en C.
0 votes
Pour un exemple ésotérique, faites une recherche sur le web pour
pclmulqdq crc
. pclmulqdq est une instruction d'assemblage spéciale. Les exemples optimisés utilisent environ 500 lignes de code assembleur. Certains X86 ont également une instructioncrc32c
instruction pour un cas spécifique de crc32. Résultats du benchmark pour générer crc32 sur un tableau de 256MB (256*1024*1024) byte : code c utilisant table => 0.516749 sec, assembleur utilisant pcmuldq => 0.0783919 sec, code c utilisant crc32 intrinsèque => 0.0541801 sec.30 votes
Plus tôt dans ma carrière, j'écrivais beaucoup de C et d'assembleur pour gros ordinateurs dans une société de logiciels. Un de mes collègues était ce que j'appellerais un "assembleur puriste" (tout devait être en assembleur), alors j'ai parié avec lui que je pourrais écrire une routine donnée qui fonctionnerait plus vite en C que ce qu'il pouvait écrire en assembleur. J'ai gagné. Mais pour couronner le tout, après avoir gagné, je lui ai dit que je voulais un deuxième pari - que je pouvais écrire quelque chose de plus rapide en assembleur que le programme C qui l'avait battu lors du pari précédent. J'ai gagné cela aussi, ce qui prouve que la plupart des choses dépendent de la compétence et de l'habileté du programmeur plus que de toute autre chose.
8 votes
A moins que votre cerveau ait un
-O3
il est probablement préférable de laisser l'optimisation au compilateur C :-)0 votes
@ValerieR Eh bien, vous avez également prouvé que votre programme en assembleur était plus rapide que votre programme en C. :-) Peut-être pourrait-on dire que quel que soit votre niveau de compétence en programmation C, vous pouvez probablement écrire un programme assembleur plus rapide ?
1 votes
@RobertF : Nous omettons souvent la partie "à quel prix" de ces questions. Je peux écrire un C ou un assembleur rapide - parfois le C est moins cher à écrire, et parfois l'assembleur est moins cher à écrire. La vitesse vient souvent de deux façons : de meilleurs algorithmes ou une exploitation de l'infrastructure de bas niveau -quicksort en C sera typiquement plus rapide que bubble sort en assembleur. Mais si vous implémentez une logique identique dans les deux, l'assembleur vous donne généralement des moyens d'exploiter l'architecture de la machine mieux que le compilateur ne peut le faire - le compilateur est polyvalent, et vous créez une adaptation spécifique pour un cas d'utilisation unique.
0 votes
J'ai écrit plusieurs remplacements M68000 pour les fonctions C (memset, snprintf, etc.), qui sont considérablement plus rapides (et toujours plus sûrs) que leurs homologues C. J'ai aussi écrit un testcode pour vérifier que a) ça marche, et b) que c'est effectivement plus rapide.