68 votes

Pourquoi memcmp(a, b, 4) n'est que parfois optimisé pour une comparaison uint32 ?

Étant donné ce code :

#include <string.h>

int equal4(const char* a, const char* b)
{
    return memcmp(a, b, 4) == 0;
}

int less4(const char* a, const char* b)
{
    return memcmp(a, b, 4) < 0;
}

GCC 7 sur x86_64 a introduit une optimisation pour le premier cas (Clang le fait depuis longtemps) :

    mov     eax, DWORD PTR [rsi]
    cmp     DWORD PTR [rdi], eax
    sete    al
    movzx   eax, al

Mais le deuxième cas appelle encore memcmp() :

    sub     rsp, 8
    mov     edx, 4
    call    memcmp
    add     rsp, 8
    shr     eax, 31

Une optimisation similaire pourrait-elle être appliquée au second cas ? Quel est le meilleur assemblage pour cela, et y a-t-il une raison claire pour laquelle cela n'est pas fait (par GCC ou Clang) ?

Voyez-le sur Godbolt's Compiler Explorer : https://godbolt.org/g/jv8fcf

73voto

dasblinkenlight Points 264350

Si vous générez du code pour une plateforme little-endian, l'optimisation des codes à quatre octets est un facteur important. memcmp pour l'inégalité à une seule comparaison DWORD est invalide.

Quand memcmp compare les octets individuels, il passe des octets faiblement adressés aux octets fortement adressés, quelle que soit la plate-forme.

Afin que memcmp pour renvoyer zéro, les quatre octets doivent être identiques. Par conséquent, l'ordre de comparaison n'a pas d'importance. Par conséquent, l'optimisation DWORD est valide, car vous ignorez le signe du résultat.

Cependant, lorsque memcmp renvoie un nombre positif, l'ordre des octets est important. Par conséquent, la mise en œuvre de la même comparaison à l'aide de DWORD 32 bits nécessite un endiannage spécifique : la plate-forme doit être big-endian, sinon le résultat de la comparaison serait incorrect.

24voto

L'endiveté est le problème ici. Considérez cette entrée :

a = 01 00 00 03
b = 02 00 00 02

Si vous comparez ces deux tableaux en les traitant comme des entiers 32 bits, vous constaterez que a est plus grande (parce que 0x03000001 > 0x02000002). Sur une machine big-endian, ce test fonctionnerait probablement comme prévu.

13voto

Peter Cordes Points 1375

Comme évoqué dans d'autres réponses/commentaires, l'utilisation de memcmp(a,b,4) < 0 est équivalent à un unsigned comparaison entre des entiers big-endian. Il ne pouvait pas être mis en ligne aussi efficacement que == 0 sur x86 little-endian.

Plus important encore, la version actuelle de ce comportement dans gcc7/8 ne cherche que memcmp() == 0 o != 0 . Même sur une cible big-endian où cela pourrait être mis en ligne tout aussi efficacement pour < o > gcc ne le fera pas. (Les compilateurs big-endian les plus récents de Godbolt sont PowerPC 64 gcc6.3, et MIPS/MIPS64 gcc5.4. mips est un MIPS big-endian, tandis que mipsel est little-endian MIPS). Si vous testez ceci avec un futur gcc, utilisez a = __builtin_assume_align(a, 4) pour s'assurer que gcc n'a pas à s'inquiéter des performances et de la correction des charges non alignées sur les systèmes non-x86. (Ou simplement utiliser const int32_t* au lieu de const char* .)

Si/quand gcc apprend à mettre en ligne memcmp pour les cas autres que EQ/NE, peut-être que gcc le fera sur x86 little-endian quand son heuristique lui dira que la taille du code supplémentaire en vaut la peine. par exemple, dans une boucle chaude lors de la compilation avec -fprofile-use (optimisation guidée par le profil).


Si vous voulez que les compilateurs fassent un bon travail pour ce cas de figure vous devriez probablement l'attribuer à un uint32_t et utiliser une fonction de conversion endienne comme ntohl . Mais assurez-vous d'en choisir un qui puisse réellement être en ligne ; apparemment Windows dispose d'un ntohl qui se compile en un appel DLL . Voir les autres réponses à cette question pour des informations sur le portable-endian, et aussi la tentative imparfaite de quelqu'un de portable_endian.h et ceci la fourche de celui-ci . J'ai travaillé sur une version pendant un certain temps, mais je ne l'ai jamais terminée/testée ni postée.

Le transfert de pointeur peut être un comportement non défini, selon la façon dont vous avez écrit les octets et ce que les char* Les points suivants . Si vous n'êtes pas sûr de l'alignement et/ou de l'anticrénelage strict, memcpy en abytes . La plupart des compilateurs sont bons pour optimiser l'élimination des petits objets de taille fixe. memcpy .

// I know the question just wonders why gcc does what it does,
// not asking for how to write it differently.
// Beware of alignment performance or even fault issues outside of x86.

#include <endian.h>
#include <stdint.h>

int equal4_optim(const char* a, const char* b) {
    uint32_t abytes = *(const uint32_t*)a;
    uint32_t bbytes = *(const uint32_t*)b;

    return abytes == bbytes;
}

int less4_optim(const char* a, const char* b) {
    uint32_t a_native = be32toh(*(const uint32_t*)a);
    uint32_t b_native = be32toh(*(const uint32_t*)b);

    return a_native < b_native;
}

J'ai vérifié sur Godbolt et qui compile un code efficace (fondamentalement identique à ce que j'ai écrit en asm ci-dessous), surtout sur les plateformes big-endian, même avec un vieux gcc. Il fait également un bien meilleur code que ICC17, qui inlines memcmp mais seulement à une boucle de comparaison d'octets (même pour le format == 0 cas.


Je pense que cette séquence faite à la main est une mise en œuvre optimale de less4() (pour la convention d'appel SystemV x86-64, comme celle utilisée dans la question, avec const char *a en rdi y b en rsi ).

less4:
    mov   edi, [rdi]
    mov   esi, [rsi]
    bswap edi
    bswap esi
    # data loaded and byte-swapped to native unsigned integers
    xor   eax,eax    # solves the same problem as gcc's movzx, see below
    cmp   edi, esi
    setb  al         # eax=1 if *a was Below(unsigned) *b, else 0
    ret

Il s'agit de toutes les instructions single-uop sur les CPU Intel et AMD depuis K8 et Core2 ( http://agner.org/optimize/ ).

Le fait de devoir bswap les deux opérandes a un coût supplémentaire en termes de taille de code par rapport à l'option == 0 cas : nous ne pouvons pas plier l'un des chargements en une opérande de la mémoire pour cmp . (Cela permet d'économiser de la taille de code, et des uops grâce à la micro-fusion). bswap des instructions.

Sur les processeurs qui supportent movbe il permet d'économiser la taille du code : movbe ecx, [rsi] est une charge + bswap. Sur Haswell, c'est 2 uops, donc on peut supposer que ça décode les mêmes uops que mov ecx, [rsi] / bswap ecx . Sur Atom/Silvermont, c'est géré directement dans les ports de chargement, donc moins d'uops ainsi qu'une taille de code plus petite.

Voir le site setcc une partie de ma réponse de xor-zeroing pour en savoir plus sur la raison pour laquelle xor/cmp/setcc (que clang utilise) est meilleur que cmp/setcc/movzx (typique pour gcc).

Dans le cas habituel où cela s'insère dans du code qui se branche sur le résultat, l'option setcc + zero-extension sont remplacés par un jcc ; le compilateur optimise la création d'une valeur de retour booléenne dans un registre. C'est encore un autre avantage de l'inlining : la bibliothèque memcmp doit créer une valeur de retour booléenne entière que l'appelant teste. car aucune convention d'appel ou ABI x86 ne permet de renvoyer des conditions booléennes dans les drapeaux. (Je ne connais pas non plus de convention d'appel non-x86 qui le permette). Pour la plupart des bibliothèques memcmp les implémentations, il y a aussi une surcharge significative dans le choix d'une stratégie en fonction de la longueur, et peut-être la vérification de l'alignement. Cela peut être assez bon marché, mais pour la taille 4, cela va être plus que le coût de tout le travail réel.

-2voto

gnasher729 Points 5011

L'endiannité est un mais le char signé en est un autre. Par exemple, considérons que les quatre octets que vous comparez sont 0x207f2020 et 0x20802020. Le 80 en tant que caractère signé est -128, le 7f en tant que caractère signé est +127. Mais si vous comparez les quatre octets, aucune comparaison ne vous donnera le bon ordre.

Bien sûr, vous pouvez faire un xor avec 0x80808080 et ensuite vous pouvez simplement utiliser une comparaison non signée.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X