web-dev-qa-db-fra.com

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

Compte tenu de 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 l'a fait depuis longtemps):

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

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

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

Une optimisation similaire pourrait-elle être appliquée au deuxième 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

68
John Zwinck

Si vous générez du code pour une plate-forme little-endian, l'optimisation de quatre octets memcmp pour l'inégalité à une seule comparaison DWORD n'est pas valide.

Lorsque memcmp compare des octets individuels, il passe des octets à faible adressage aux octets à adressage élevé, quelle que soit la plate-forme.

Pour que memcmp renvoie 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, l'implémentation de la même comparaison à l'aide de la comparaison DWORD 32 bits nécessite une endianité spécifique: la plate-forme doit être big-endian, sinon le résultat de la comparaison serait incorrect.

73
dasblinkenlight

L'endianisme 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 grand (car 0x03000001> 0x02000002). Sur une machine big-endian, ce test fonctionnerait probablement comme prévu.

24
squeamish ossifrage

Comme discuté dans d'autres réponses/commentaires, l'utilisation de memcmp(a,b,4) < 0 équivaut à une comparaison unsigned entre des entiers big-endian. Il ne pouvait pas être intégré aussi efficacement que == 0 Sur un x86 petit-boutien.

Plus important encore, la version actuelle de ce comportement dans gcc7/8 ne recherche que memcmp() == 0 ou != 0 . Même sur une cible big-endian où cela pourrait être aussi efficace pour < Ou >, Gcc ne le fera pas. (Les nouveaux compilateurs big-endian de Godbolt sont PowerPC 64 gcc6.3 et MIPS/MIPS64 gcc5.4. mips est le MIPS big-endian, tandis que mipsel est le MIPS little-endian.) ceci avec le futur gcc, utilisez a = __builtin_assume_align(a, 4) pour vous assurer que gcc n'a pas à se soucier des performances/correction de la charge non alignée sur les non-x86. (Ou utilisez simplement const int32_t* Au lieu de const char*.)

Si/quand gcc apprend à incorporer memcmp pour des cas autres que EQ/NE, peut-être que gcc le fera sur un x86 peu endian quand ses heuristiques lui indiqueront que la taille de code supplémentaire en vaudra 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 du bon travail dans ce cas , vous devriez probablement l'assigner à un uint32_t Et utiliser une fonction de conversion endienne comme ntohl. Mais assurez-vous d'en choisir un qui peut réellement être intégré; apparemment Windows a un ntohl qui se compile en un DLL appel . Voir d'autres réponses à cette question pour quelques trucs portables-endian, et aussi - tentative imparfaite de quelqu'un de portable_endian.h , et ceci fork de celui-ci . Je travaillais sur une version depuis un certain temps, mais je ne l'ai jamais finie/testée ou postée.

La conversion du pointeur peut être un comportement indéfini, selon la façon dont vous avez écrit les octets et ce à quoi le char* Pointe . Si vous n'êtes pas sûr de l'alias strict et/ou de l'alignement, memcpy dans abytes. La plupart des compilateurs sont bons pour optimiser les petites memcpy de taille fixe.

// 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é Godbolt , et cela se compile en code efficace (essentiellement identique à ce que j'ai écrit dans asm ci-dessous), en particulier sur les plates-formes big-endian, même avec l'ancien gcc. Il crée également un code bien meilleur que ICC17, qui insère memcmp mais uniquement dans une boucle de comparaison d'octets (même pour le cas == 0.


Je pense que cette séquence fabriquée à la main est une implémentation optimale de less4() (pour la convention d'appel System86 x86-64, comme celle utilisée dans la question, avec const char *a dans rdi et b dans 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

Ce sont toutes des instructions simples sur les processeurs Intel et AMD depuis K8 et Core2 ( http://agner.org/optimize/ ).

Le fait de devoir échanger les deux opérandes a un coût de taille de code supplémentaire par rapport au cas == 0: Nous ne pouvons pas replier l'une des charges dans un opérande mémoire pour cmp. (Cela permet d'économiser la taille du code, et uops grâce à la micro-fusion.) C'est en plus des deux instructions supplémentaires bswap.

Sur les processeurs prenant en charge movbe, il peut enregistrer la taille du code: movbe ecx, [rsi] Est un chargement + bswap. Sur Haswell, c'est 2 uops, donc vraisemblablement il décode les mêmes uops que mov ecx, [rsi]/bswap ecx. Sur Atom/Silvermont, il est géré directement dans les ports de chargement, donc c'est moins d'ups ainsi qu'une taille de code plus petite.

Voir la partie setcc de ma réponse de mise à zéro xor pour en savoir plus sur les raisons pour lesquelles xor/cmp/setcc (que clang utilise) est meilleur que cmp/setcc/movzx (typique pour gcc).

Dans le cas habituel où cela s'inscrit dans du code qui se ramifie sur le résultat, le setcc + zero-extend est remplacé 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 La convention x86 ABI/appel permet de renvoyer des conditions booléennes dans les drapeaux. (Je ne connais aucune convention d'appel non x86 qui le fasse non plus). Pour la plupart des implémentations de bibliothèque memcmp, le choix d'une stratégie en fonction de la longueur, et peut-être la vérification de l'alignement, nécessitent également une surcharge importante. Cela peut être assez bon marché, mais pour la taille 4, ce sera plus que le coût de tout le vrai travail.

13
Peter Cordes