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 ?
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 ?
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.
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.
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.
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 :
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.
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.
@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.
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) ;
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.
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.
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)
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 :
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.)
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.
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.
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 .
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.
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 de0
à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é commesize_t
doit être utilisé à la place.2 votes
@paxdiablo Vous pourriez aussi compter de N à 1. Sur certains processeurs, cela élimine une instruction de comparaison, car la décrémentation active le bit approprié pour l'instruction de branchement lorsqu'elle atteint zéro.
6 votes
Je pense que le postulat de la question est faux. Les compilateurs modernes convertiront ceci en
memcpy
oumemmove
(selon qu'ils peuvent dire si les pointeurs peuvent s'aliaser).0 votes
@David : Vraiment ? Compte tenu des étapes communes du compilateur, je serais surpris.
memcpy
est typiquement inlined tôt, et les optimisations peephole appliquées plus tard. Je ne serais pas surpris que l'assemblage résultant soit identique, cependant.0 votes
De plus, != est plus rapide que <. ;)
0 votes
@onemasse : Mais ça ne veut pas dire que c'est plus rapide ! Je me souviens d'un benchmarking de memcpy() sur un Pentium3 qui montrait que la copie de la mémoire était plus rapide avec des incréments positifs qu'avec des incréments négatifs. La hiérarchie de la mémoire préférait cela. Allez comprendre !
0 votes
@Mackie Messer Bien sûr, vous ne devriez pas utiliser
i
pour l'adressage. Mais l'utiliser simplement comme un compteur devrait être correct. Si vous copiez en arrière, vous tromperez le cache si vous n'utilisez pas de préchargement et autres.1 votes
@onemasse : Je pense que la morale est la suivante : faites comme tout le monde et tenez-vous en aux idiomes du langage. Les gens, le compilateur et le matériel le géreront mieux. Les gens reconnaîtront plus rapidement l'intention de la boucle. Le compilateur pourrait très bien appliquer l'optimisation que vous décrivez sans votre aide. Et le matériel est toujours optimisé pour le cas le plus courant. Gardez votre code simple, stupide. Vous pouvez toujours écrire des commentaires intelligents ;-)
0 votes
@Mackie Messer Vous avez tout à fait raison. Vous ne devriez jamais optimiser prématurément. Avec les compilateurs et les architectures modernes, vous n'avez souvent pas besoin d'optimiser du tout. Mais dans les environnements embarqués, il n'est souvent pas si facile de l'éviter. Mais vous devez toujours mesurer les performances avant et après les optimisations, de préférence avec un profileur.
0 votes
@Gleno c'était vrai sur le 6502, depuis cela n'a plus d'importance.
0 votes
Duplicata possible de C++ memcpy() vs std::copy() Cela ne mentionne pas
memmove
mais la discussion sera la même.0 votes
Grâce à des optimisations comme celles détaillées ici par Joe Bialek : 11 janvier 2021, "Building Faster AMD64 Memset Routines", msrc-blog.microsoft.com/2021/01/11/ dernier accès le 17 mai 2021.