92 votes

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

Je copie N octets de pSrc à pDest . Ceci peut être fait dans une seule boucle :

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

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

2 votes

Votre boucle ne copie qu'un seul emplacement. Je pense que vous vouliez en quelque sorte incrémenter les pointeurs.

14 votes

Ou, vous pouvez juste le réparer pour eux, comme je l'ai fait. Et, BTW, aucun vrai programmeur C jamais compte de 1 à N c'est toujours de 0 à N-1 :-)

6 votes

@paxdiablo : Si vous bouclez sur des tableaux, oui. Mais il y a de nombreux cas où le fait de boucler 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 est probablement plus logique. Dans tous les cas, cela ne tient pas compte du problème plus important que représente l'utilisation de int comme compteur lorsqu'un type non signé comme size_t doit être utilisé à la place.

126voto

onemasse Points 2851

Parce que memcpy utilise des pointeurs de mot au lieu de pointeurs d'octet, les implémentations de memcpy sont souvent écrites avec SIMD 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 d'un vecteur d'une longueur maximale de 16 octets. Cela inclut les instructions de chargement et de stockage.

15 votes

Lorsque vous mettez GCC au niveau -O3 il utilisera SIMD pour la boucle, du moins s'il connaît pDest et pSrc n'utilisez pas d'alias.

0 votes

Je travaille actuellement sur un Xeon Phi avec 64 octets (512 bits) SIMD, donc cette histoire de "jusqu'à 16 octets" me fait sourire. De plus, vous devez spécifier quel CPU vous ciblez pour que SIMD soit activé, par exemple avec -march=native.

0 votes

Je devrais peut-être revoir ma réponse. :)

83voto

Daemin Points 5651

Les routines de copie de mémoire peuvent être bien plus compliquées et plus rapides qu'une simple copie de mémoire via des pointeurs, par exemple :

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 apporter est d'aligner l'un des pointeurs sur une frontière de mot (par mot, j'entends la taille native d'un entier, généralement 32 bits/4 octets, mais qui peut être 64 bits/8 octets sur les architectures plus récentes) et d'utiliser des instructions de déplacement/copie de taille mot. Cela nécessite l'utilisation d'une copie octet par 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 auront des performances différentes selon que le pointeur source ou le pointeur 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, il est possible de dérouler certaines boucles, de sorte qu'une plus grande partie des registres du processeur soit chargée de données, ce qui signifie que les instructions de chargement/stockage peuvent être entrelacées et que leur latence est masquée par des instructions supplémentaires (comme le comptage de boucles, etc.). L'avantage que cela apporte varie beaucoup 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 assembleur plutôt qu'en C (ou C++), car il faut placer manuellement les instructions de chargement et de stockage pour tirer le meilleur parti de la dissimulation de la latence et du débit.

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

Ce qui m'amène à l'amélioration suivante, l'ajout de la recherche préalable. Il s'agit d'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. Comme il y a un délai entre l'émission de l'instruction et le remplissage de la ligne de cache, les instructions doivent être placées de manière à ce que les données soient disponibles au moment où elles doivent être copiées, et pas avant/après.

Cela signifie qu'il faut placer les instructions de préextraction au début de la fonction ainsi qu'à l'intérieur de la boucle de copie principale. Les instructions de préextraction au milieu de la boucle de copie récupèrent les données qui seront copiées dans plusieurs itérations.

Je ne m'en souviens pas, mais il peut également être utile de préempter les adresses de destination ainsi que les adresses d'origine.

Facteurs

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

  • 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.).

Ainsi, si vous souhaitez écrire une routine de traitement de la mémoire efficace et rapide, vous devrez en savoir beaucoup sur le processeur et l'architecture pour lesquels vous écrivez. Il suffit de dire que, à moins que vous n'écriviez sur une plate-forme embarquée, il serait beaucoup plus facile d'utiliser les routines de copie de mémoire intégrées.

0 votes

Les processeurs modernes détecteront un modèle d'accès linéaire à la mémoire et commenceront à faire de la prélecture de leur propre chef. Je pense que les instructions de préextraction ne feront pas une grande différence pour cette raison.

0 votes

@maxy Sur les quelques architectures sur lesquelles j'ai implémenté des routines de copie de mémoire, l'ajout du prefetch a aidé de manière mesurable. S'il est vrai que les puces Intel/AMD de la génération actuelle préfiltrent suffisamment à l'avance, il existe de nombreuses puces plus anciennes et d'autres architectures qui ne le font pas.

0 votes

Quelqu'un peut-il expliquer "(b_src & 0x3) != 0" ? Je n'arrive pas à comprendre, et aussi - il ne compile pas (jette une erreur : opérateur invalide pour binaire & : unsigned char et int) ;

18voto

Mark Byers Points 318575

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

De un exemple de mise en œuvre :

    00026          \* For speedy copying, optimize the common case where both pointers
    00027          \* and the length are word-aligned, and copy word-at-a-time instead
    00028          \* of byte-at-a-time. Otherwise, copy by bytes.

8 votes

Sur un 386 (pour un exemple), qui n'avait pas de cache embarqué, cela faisait une énorme différence. Sur la plupart des processeurs modernes, les lectures et les écritures se font une ligne de cache à la fois, et le bus vers la mémoire est généralement le goulot d'étranglement, donc attendez-vous à une amélioration de quelques pour cent, mais pas du tout du quadruple.

2 votes

Je pense que vous devriez être un peu plus explicite quand vous dites "à partir de la source". Bien sûr, c'est "la source" sur certaines architectures, mais ce n'est certainement pas le cas 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)

0 votes

@Billy ONeal : +1 tout à fait exact... il y a plus d'une façon de dépecer un chat. Ce n'était qu'un exemple. Corrigé ! Merci pour le commentaire constructif.

7voto

Danny Dulai Points 680

Vous pouvez mettre en œuvre memcpy() en utilisant l'une des techniques suivantes, dont certaines dépendent de votre architecture pour les gains de performance, et elles seront toutes beaucoup plus rapides que votre code :

  1. Utilisez des unités plus grandes, comme des mots de 32 bits au lieu d'octets. Vous pouvez également (ou devrez peut-être) traiter l'alignement ici aussi. Sur certaines plates-formes, vous ne pouvez pas lire/écrire un mot de 32 bits dans un emplacement mémoire impair, par exemple. Sur d'autres plates-formes, vous payez un lourd tribut en termes de performances. Pour résoudre ce problème, l'adresse doit être une unité divisible par 4. Vous pouvez aller jusqu'à 64 bits pour les processeurs 64 bits, ou même plus en utilisant les méthodes suivantes SIMD (Instruction unique, données multiples) instructions ( MMX , SSE etc.)

  2. Vous pouvez utiliser des instructions spéciales du CPU que votre compilateur ne pourra peut-être pas optimiser à partir du C. Par exemple, sur un 80386, vous pouvez utiliser l'instruction préfixe "rep" + l'instruction "movsb" pour déplacer N octets dictés par le placement de N dans le registre de comptage. Les bons compilateurs le feront pour vous, mais il se peut que vous soyez sur une plate-forme qui ne dispose pas d'un bon compilateur. Notez que cet exemple tend à être une mauvaise démonstration de la vitesse, mais combiné avec l'alignement + des instructions unitaires plus grandes, il peut être plus rapide que presque tout le reste sur certains CPU.

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

Par exemple, http://www.agner.org/optimize/#asmlib a un memcpy qui bat la plupart des autres (de très peu). Si vous lisez le code source, vous verrez qu'il est rempli de tonnes de code assembleur intégré qui met en œuvre les trois techniques ci-dessus, en choisissant laquelle de ces techniques en fonction du processeur sur lequel vous travaillez.

Notez que des optimisations similaires peuvent être réalisées pour trouver des octets dans un tampon. strchr() et amis seront souvent plus rapides que leur équivalent roulé à la main. C'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 un Recherche de chaînes Boyer-Moore car il utilise les techniques d'optimisation ci-dessus.

1 votes

Le même brouillard d'Agner auquel vous vous référez théorise également que le déroulement des boucles est contre-productif sur les CPU modernes .

0 votes

De nos jours, la plupart des processeurs ont une bonne prédiction des branchements, ce qui devrait annuler l'avantage du déroulement de boucle dans les cas typiques. Un bon compilateur optimisant peut encore l'utiliser parfois.

5voto

moshbear Points 1833

Réponse courte :

  • remplissage du cache
  • les transferts de mots au lieu d'octets lorsque c'est possible
  • La magie SIMD

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