Pourquoi memcpy () et memmove () sont-ils plus rapides que les incréments de pointeur?

92

Je copie N octets de pSrcvers pDest. Cela peut être fait en une seule boucle:

for (int i = 0; i < N; i++)
    *pDest++ = *pSrc++

Pourquoi est-ce plus lent que memcpyou memmove? Quelles astuces utilisent-ils pour l'accélérer?

vagabond
la source
2
Votre boucle ne copie qu'un seul emplacement. Je pense que vous vouliez en quelque sorte incrémenter les pointeurs.
Mysticial
13
Ou, vous pouvez simplement le réparer pour eux, comme je l'ai fait. Et, BTW, aucun vrai programmeur C ne compte jamais de 1à N, c'est toujours de 0à N-1:-)
paxdiablo
6
@paxdiablo: Si vous bouclez sur des tableaux, bien sûr. Mais il existe de nombreux cas où la boucle de 1 à N est très bien. Cela dépend de ce que vous faites avec les données - si vous affichez une liste numérotée commençant à 1, par exemple, à un utilisateur, alors commencer à 1 a probablement plus de sens. Dans tous les cas, il ignore le plus gros problème qui est utilisé intcomme compteur lorsqu'un type non signé comme size_tdevrait être utilisé à la place.
Billy ONeal
2
@paxdiablo Vous pouvez également compter de N à 1. Sur certains processeurs qui élimineront une instruction de comparaison car le décrément mettra le bit approprié pour l'instruction de branchement lorsqu'il atteindra zéro.
onemasse
6
Je pense que la prémisse de la question est fausse. Les compilateurs modernes le convertiront en memcpyou memmove(selon qu'ils peuvent dire si les pointeurs peuvent être alias).
David Schwartz

Réponses:

120

Comme memcpy utilise des pointeurs de mots au lieu de pointeurs d'octets, les implémentations de memcpy sont souvent écrites avec des instructions SIMD , ce qui permet de mélanger 128 bits à la fois.

Les instructions SIMD sont des instructions d'assemblage qui peuvent effectuer la même opération sur chaque élément dans un vecteur d'une longueur maximale de 16 octets. Cela comprend les instructions de chargement et de stockage.

onemasse
la source
15
Lorsque vous activez GCC -O3, il utilisera SIMD pour la boucle, du moins s'il connaît pDestet pSrcne crée pas d'alias.
Dietrich Epp
Je travaille actuellement sur un Xeon Phi avec 64 octets (512 bits) SIMD, donc ce truc de "jusqu'à 16 octets" me fait sourire. De plus, vous devez spécifier le processeur que vous ciblez pour que SIMD soit activé, par exemple avec -march = native.
yakoudbz
Je devrais peut-être réviser ma réponse. :)
onemasse
Ceci est très dépassé même au moment de la publication. Les vecteurs AVX sur x86 (expédiés en 2011) ont une longueur de 32 octets et AVX-512 est de 64 octets. Il existe des architectures avec des vecteurs 1024 bits ou 2048 bits, ou même une largeur de vecteur variable comme ARM SVE
phuclv
@phuclv alors que les instructions étaient peut-être disponibles, avez-vous des preuves que Memcpy les utilise? Il faut normalement un certain temps aux bibliothèques pour rattraper leur retard, et les dernières que je peux trouver utilisent SSSE3 et sont beaucoup plus récentes que 2011.
Pete Kirkham le
81

Les routines de copie de mémoire peuvent être beaucoup plus compliquées et plus rapides qu'une simple copie de mémoire via des pointeurs tels que:

void simple_memory_copy(void* dst, void* src, unsigned int bytes)
{
  unsigned char* b_dst = (unsigned char*)dst;
  unsigned char* b_src = (unsigned char*)src;
  for (int i = 0; i < bytes; ++i)
    *b_dst++ = *b_src++;
}

Améliorations

La première amélioration que l'on peut faire est d'aligner l'un des pointeurs sur une limite de mot (par mot, je veux dire une taille entière native, généralement 32 bits / 4 octets, mais peut être de 64 bits / 8 octets sur les architectures plus récentes) et utiliser un mouvement de taille de mot / copier les instructions. Cela nécessite d'utiliser une copie d'octet en octet jusqu'à ce qu'un pointeur soit aligné.

void aligned_memory_copy(void* dst, void* src, unsigned int bytes)
{
  unsigned char* b_dst = (unsigned char*)dst;
  unsigned char* b_src = (unsigned char*)src;

  // Copy bytes to align source pointer
  while ((b_src & 0x3) != 0)
  {
    *b_dst++ = *b_src++;
    bytes--;
  }

  unsigned int* w_dst = (unsigned int*)b_dst;
  unsigned int* w_src = (unsigned int*)b_src;
  while (bytes >= 4)
  {
    *w_dst++ = *w_src++;
    bytes -= 4;
  }

  // Copy trailing bytes
  if (bytes > 0)
  {
    b_dst = (unsigned char*)w_dst;
    b_src = (unsigned char*)w_src;
    while (bytes > 0)
    {
      *b_dst++ = *b_src++;
      bytes--;
    }
  }
}

Différentes architectures fonctionneront différemment selon que la source ou le pointeur de destination est correctement aligné. Par exemple, sur un processeur XScale, j'ai obtenu de meilleures performances en alignant le pointeur de destination plutôt que le pointeur source.

Pour améliorer encore les performances, un certain déroulement de boucle peut être effectué, de sorte que davantage de registres du processeur soient chargés de données et cela signifie que les instructions de chargement / stockage peuvent être entrelacées et avoir leur latence masquée par des instructions supplémentaires (telles que le comptage de boucles, etc.). L'avantage que cela apporte varie un peu selon le processeur, car les latences des instructions de chargement / stockage peuvent être très différentes.

À ce stade, le code finit par être écrit en Assembly plutôt qu'en C (ou C ++), car vous devez placer manuellement les instructions de chargement et de stockage pour tirer le meilleur parti du masquage de la latence et du débit.

En général, toute une ligne de cache de données doit être copiée en une itération de la boucle déroulée.

Ce qui m'amène à la prochaine amélioration, l'ajout de la pré-extraction. Ce sont des instructions spéciales qui indiquent au système de cache du processeur de charger des parties spécifiques de la mémoire dans son cache. Puisqu'il y a un délai entre l'émission de l'instruction et le remplissage de la ligne d'antémémoire, les instructions doivent être placées de telle manière que les données soient disponibles au moment où elles doivent être copiées, et pas plus tôt / plus tard.

Cela signifie mettre des instructions de prélecture au début de la fonction ainsi qu'à l'intérieur de la boucle de copie principale. Avec les instructions de prélecture au milieu de la boucle de copie, des données qui seront copiées en plusieurs itérations.

Je ne me souviens pas, mais il peut également être avantageux de pré-extraire les adresses de destination ainsi que celles de la source.

Les facteurs

Les principaux facteurs qui affectent la vitesse de copie de la mémoire sont:

  • La latence entre le processeur, ses caches et la mémoire principale.
  • La taille et la structure des lignes de cache du processeur.
  • Les instructions de déplacement / copie de la mémoire du processeur (latence, débit, taille du registre, etc.).

Donc, si vous voulez écrire une routine de gestion de la mémoire efficace et rapide, vous devrez en savoir beaucoup sur le processeur et l'architecture pour lesquels vous écrivez. Qu'il suffise de dire qu'à moins que vous n'écriviez sur une plate-forme intégrée, il serait beaucoup plus facile d'utiliser simplement les routines de copie mémoire intégrées.

Daemin
la source
Les processeurs modernes détecteront un modèle d'accès à la mémoire linéaire et commenceront eux-mêmes la prélecture. Je pense que les instructions de prélecture ne feraient pas beaucoup de différence à cause de cela.
maxy
@maxy Sur les quelques architectures que j'ai implémentées des routines de copie de mémoire, l'ajout de la prélecture a aidé de manière mesurable. S'il est vrai que les puces Intel / AMD de la génération actuelle se prélèvent suffisamment loin, il existe de nombreuses puces plus anciennes et d'autres architectures qui ne le font pas.
Daemin
quelqu'un peut-il expliquer "(b_src & 0x3)! = 0"? Je ne peux pas le comprendre, et aussi - il ne compile pas (renvoie une erreur: opérateur invalide en binaire &: unsigned char et int);
David Refaeli le
"(b_src & 0x3)! = 0" vérifie si les 2 bits les plus bas ne sont pas 0. Donc, si le pointeur source est aligné sur un multiple de 4 octets ou non. Votre erreur de compilation se produit car elle traite le 0x3 comme un octet et non comme un in, vous pouvez corriger cela en utilisant 0x00000003 ou 0x3i (je pense).
Daemin le
b_src & 0x3ne compilera pas car vous n'êtes pas autorisé à faire de l'arithmétique au niveau du bit sur les types de pointeurs. Vous devez le lancer en (u)intptr_tpremier
phuclv le
18

memcpypeut copier plus d'un octet à la fois selon l'architecture de l'ordinateur. La plupart des ordinateurs modernes peuvent fonctionner avec 32 bits ou plus dans une seule instruction de processeur.

À partir d' un exemple d'implémentation :

    00026 * Pour une copie rapide, optimisez le cas courant où les deux pointeurs
    00027 * et la longueur sont alignées sur les mots, et copiez mot à la fois à la place
    00028 * d'octet à la fois. Sinon, copiez par octets.
Mark Byers
la source
8
Sur un 386 (pour un exemple), qui n'avait pas de cache intégré, cela faisait une énorme différence. Sur la plupart des processeurs modernes, les lectures et les écritures se produiront une ligne de cache à la fois, et le bus vers la mémoire sera généralement le goulot d'étranglement, alors attendez-vous à une amélioration de quelques pour cent, pas n'importe où près du quadruple.
Jerry Coffin
2
Je pense que vous devriez être un peu plus explicite lorsque vous dites "de la source". Bien sûr, c'est "la source" sur certaines architectures, mais ce n'est certainement pas sur, disons, une machine BSD ou Windows. (Et bon sang, même entre les systèmes GNU, il y a souvent beaucoup de différence dans cette fonction)
Billy ONeal
@Billy ONeal: +1 absolument raison ... il y a plus d'une façon d'écorcher un chat. C'était juste un exemple. Fixé! Merci pour le commentaire constructif.
Mark Byers
7

Vous pouvez implémenter en memcpy()utilisant l'une des techniques suivantes, certaines dépendant de votre architecture pour les gains de performances, et elles seront toutes beaucoup plus rapides que votre code:

  1. Utilisez des unités plus grandes, telles que des mots de 32 bits au lieu d'octets. Vous pouvez également (ou devrez peut-être) traiter l'alignement ici aussi. Vous ne pouvez pas lire / écrire un mot de 32 bits dans un emplacement de mémoire étrange, par exemple sur certaines plates-formes, et sur d'autres plates-formes, vous payez une pénalité de performance énorme. Pour résoudre ce problème, l'adresse doit être une unité divisible par 4. Vous pouvez prendre cela jusqu'à 64 bits pour les processeurs 64 bits, ou même plus en utilisant les instructions SIMD (instruction unique, données multiples) ( MMX , SSE , etc.)

  2. Vous pouvez utiliser des instructions CPU spéciales que votre compilateur ne pourra peut-être pas optimiser à partir de C. Par exemple, sur un 80386, vous pouvez utiliser l'instruction de préfixe "rep" + l'instruction "movsb" pour déplacer N octets dictés en plaçant N dans le décompte S'inscrire. Les bons compilateurs le feront juste pour vous, mais vous êtes peut-être sur une plate-forme qui manque d'un bon compilateur. Notez que cet exemple a tendance à être une mauvaise démonstration de vitesse, mais combiné à un alignement + des instructions d'unité plus grandes, il peut être plus rapide que presque tout le reste sur certains processeurs.

  3. Déroulement de boucle - les branches peuvent être assez coûteuses sur certains processeurs, donc le déroulement des boucles peut réduire le nombre de branches. C'est également une bonne technique pour combiner avec des instructions SIMD et des unités de très grande taille.

Par exemple, http://www.agner.org/optimize/#asmlib a une memcpyimplémentation qui bat le plus là-bas (par une très petite quantité). Si vous lisez le code source, il sera plein de tonnes de code d'assemblage intégré qui tire parti de toutes les trois techniques ci-dessus, en choisissant laquelle de ces techniques en fonction du processeur sur lequel vous utilisez.

Notez que des optimisations similaires peuvent également être effectuées pour rechercher des octets dans un tampon. strchr()et les amis seront souvent plus rapides que votre équivalent roulé à la main. Cela est particulièrement vrai pour .NET et Java . Par exemple, dans .NET, la fonction intégrée String.IndexOf()est beaucoup plus rapide que même une recherche de chaîne Boyer – Moore , car elle utilise les techniques d'optimisation ci-dessus.

Danny Dulai
la source
1
Le même Agner Fog auquel vous vous connectez théorise également que le déroulement de boucle est contre-productif sur les processeurs modernes .
De nos jours, la plupart des processeurs ont une bonne prédiction de branche, ce qui devrait annuler l'avantage du déroulement de boucle dans les cas typiques. Un bon compilateur d'optimisation peut encore l'utiliser parfois.
thomasrutter
5

Réponse courte:

  • remplissage du cache
  • les transferts de mots au lieu d'octets lorsque cela est possible
  • Magie SIMD
moshbear
la source
4

Je ne sais pas s'il est réellement utilisé dans des implémentations réelles de memcpy, mais je pense que Duff's Device mérite une mention ici.

De Wikipedia :

send(to, from, count)
register short *to, *from;
register count;
{
        register n = (count + 7) / 8;
        switch(count % 8) {
        case 0:      do {     *to = *from++;
        case 7:              *to = *from++;
        case 6:              *to = *from++;
        case 5:              *to = *from++;
        case 4:              *to = *from++;
        case 3:              *to = *from++;
        case 2:              *to = *from++;
        case 1:              *to = *from++;
                } while(--n > 0);
        }
}

Notez que ce qui précède n'est pas un memcpycar il n'incrémente pas délibérément le topointeur. Il implémente une opération légèrement différente: l'écriture dans un registre mappé en mémoire. Voir l'article Wikipedia pour plus de détails.

NPE
la source
Le dispositif de Duff, ou simplement le mécanisme de saut initial, est une bonne utilisation pour copier les premiers 1..3 (ou 1..7) octets afin que les pointeurs soient alignés sur une frontière plus agréable où des instructions de déplacement de mémoire plus importantes peuvent être utilisées.
Daemin
@MarkByers: Le code illustre une opération légèrement différente ( *tofait référence à un registre mappé en mémoire et n'est délibérément pas incrémenté - voir l'article lié). Comme je pensais l'avoir précisé, ma réponse n'essaie pas de fournir une memcpytechnique efficace , elle mentionne simplement une technique plutôt curieuse.
NPE
@Daemin D'accord, comme vous l'avez dit, vous pouvez sauter le do {} while () et le commutateur sera traduit en table de saut par le compilateur. Très utile lorsque vous souhaitez vous occuper des données restantes. Un avertissement doit être mentionné à propos du périphérique de Duff, apparemment sur les architectures plus récentes (plus récent x86), la prédiction de branche est si efficace que le périphérique de Duff est en fait plus lent qu'une simple boucle.
onemasse
1
Oh non ... pas l'appareil de Duff. Veuillez ne pas utiliser l'appareil de Duff. S'il vous plaît. Utilisez PGO et laissez-moi le compilateur faire le déroulement de boucle pour vous là où cela a du sens.
Billy ONeal
Non, l'appareil de Duff n'est certainement pas utilisé dans une implémentation moderne.
gnasher729 le
3

Comme d'autres disent que memcpy copie des morceaux de plus de 1 octet. La copie en blocs de la taille d'un mot est beaucoup plus rapide. Cependant, la plupart des implémentations vont plus loin et exécutent plusieurs instructions MOV (mot) avant de boucler. L'avantage de copier par exemple 8 blocs de mots par boucle est que la boucle elle-même est coûteuse. Cette technique réduit le nombre de branches conditionnelles d'un facteur 8, optimisant la copie pour les blocs géants.

VoidStar
la source
1
Je ne pense pas que ce soit vrai. Vous pouvez dérouler la boucle, mais vous ne pouvez pas copier dans une seule instruction plus de données qu'adressables à la fois sur l'architecture cible. De plus, il y a aussi une surcharge de déroulement de la boucle ...
Billy ONeal
@Billy ONeal: Je ne pense pas que ce soit ce que voulait dire VoidStar. En ayant plusieurs instructions de déplacement consécutives, la surcharge de comptage du nombre d'unités est réduite.
wallyk
@Billy ONeal: Vous manquez le point. 1 mot à la fois est comme MOV, JMP, MOV, JMP, etc. Où vous pouvez faire MOV MOV MOV MOV JMP. J'ai déjà écrit mempcy et j'ai repéré de nombreuses façons de le faire;)
VoidStar
@wallyk: Peut-être. Mais il dit "copier des morceaux encore plus grands" - ce qui n'est pas vraiment possible. S'il veut dire déroulement de boucle, alors il devrait dire «la plupart des implémentations vont plus loin et déroulent la boucle». La réponse telle qu'elle est écrite est au mieux trompeuse, au pire fausse.
Billy ONeal
@VoidStar: D'accord - c'est mieux maintenant. +1.
Billy ONeal
2

Les réponses sont très bien, mais si vous voulez toujours mettre en œuvre un jeûne memcpyvous, il y a un blog intéressant memcpy rapide, memcpy rapide en C .

void *memcpy(void* dest, const void* src, size_t count)
{
    char* dst8 = (char*)dest;
    char* src8 = (char*)src;

    if (count & 1) {
        dst8[0] = src8[0];
        dst8 += 1;
        src8 += 1;
    }

    count /= 2;
    while (count--) {
        dst8[0] = src8[0];
        dst8[1] = src8[1];

        dst8 += 2;
        src8 += 2;
    }
    return dest;
}

Même, cela peut être mieux avec l'optimisation des accès mémoire.

Masoud
la source
1

Parce que, comme de nombreuses routines de bibliothèque, il a été optimisé pour l'architecture sur laquelle vous exécutez. D'autres ont publié diverses techniques qui peuvent être utilisées.

Si vous avez le choix, utilisez les routines de la bibliothèque plutôt que les vôtres. C'est une variante de DRY que j'appelle DRO (Don't Repeat Others). En outre, les routines de bibliothèque sont moins susceptibles d'être erronées que votre propre implémentation.

J'ai vu des vérificateurs d'accès à la mémoire se plaindre de lectures hors limites sur la mémoire ou des tampons de chaîne qui n'étaient pas un multiple de la taille du mot. Ceci est le résultat de l'optimisation utilisée.

BillThor
la source
0

Vous pouvez regarder l'implémentation MacOS de memset, memcpy et memmove.

Au moment du démarrage, le système d'exploitation détermine sur quel processeur il s'exécute. Il a intégré un code spécifiquement optimisé pour chaque processeur pris en charge et, au moment du démarrage, stocke une instruction jmp dans le bon code dans un emplacement fixe en lecture / seule.

Les implémentations C memset, memcpy et memmove ne sont qu'un saut vers cet emplacement fixe.

Les implémentations utilisent un code différent en fonction de l'alignement de la source et de la destination pour memcpy et memmove. Ils utilisent évidemment toutes les capacités vectorielles disponibles. Ils utilisent également des variantes sans mise en cache lorsque vous copiez de grandes quantités de données et ont des instructions pour minimiser les temps d'attente pour les tables de pages. Ce n'est pas seulement du code assembleur, c'est du code assembleur écrit par quelqu'un avec une très bonne connaissance de chaque architecture de processeur.

Intel a également ajouté des instructions d'assemblage qui peuvent accélérer les opérations de chaîne. Par exemple, avec une instruction pour prendre en charge strstr qui effectue une comparaison de 256 octets en un cycle.

gnasher729
la source
La version open source d'Apple de memset / memcpy / memmove est juste une version générique qui sera beaucoup plus lente que la version réelle utilisant SIMD
phuclv