¿Por qué memcpy () y memmove () son más rápidos que los incrementos de puntero?

92

Estoy copiando N bytes de pSrca pDest. Esto se puede hacer en un solo ciclo:

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

¿Por qué es más lento que memcpyo memmove? ¿Qué trucos usan para acelerarlo?

vagabundo
fuente
2
Tu bucle solo copia una ubicación. Creo que de alguna manera pretendías incrementar los indicadores.
Mysticial
13
O bien, podrías arreglarlo para ellos, como hice yo. Y, por cierto, no es cierto C programador nunca recuentos de 1a N, es siempre a partir 0de N-1:-)
paxdiablo
6
@paxdiablo: si está recorriendo matrices, seguro. Pero hay muchos casos en los que hacer un bucle de 1 a N está bien. Depende de lo que esté haciendo con los datos: si está mostrando una lista numerada que comienza en 1, por ejemplo, a un usuario, entonces comenzar en 1 probablemente tenga más sentido. En cualquier caso, ignora el problema más grande que es usar intcomo contador cuando se size_tdebe usar un tipo sin firmar como en su lugar.
Billy ONeal
2
@paxdiablo También puede contar de N a 1. En algunos procesadores, se eliminará una instrucción de comparación ya que la disminución establecerá el bit apropiado para la instrucción de bifurcación cuando llegue a cero.
onemasse
6
Creo que la premisa de la pregunta es falsa. Los compiladores modernos convertirán esto en memcpyo memmove(dependiendo de si pueden saber si los punteros pueden tener un alias).
David Schwartz

Respuestas:

120

Debido a que memcpy usa punteros de palabras en lugar de punteros de bytes, también las implementaciones de memcpy a menudo se escriben con instrucciones SIMD que hacen posible mezclar 128 bits a la vez.

Las instrucciones SIMD son instrucciones de ensamblaje que pueden realizar la misma operación en cada elemento de un vector de hasta 16 bytes de longitud. Eso incluye instrucciones de carga y almacenamiento.

onemasse
fuente
15
Cuando enciende GCC -O3, usará SIMD para el bucle, al menos si sabe pDesty pSrcno alias.
Dietrich Epp
Actualmente estoy trabajando en un Xeon Phi con SIMD de 64 bytes (512 bits), por lo que este material de "hasta 16 bytes" me hace sonreír. Además, debe especificar a qué CPU está apuntando para que se habilite SIMD, por ejemplo, con -march = native.
yakoudbz
Quizás debería revisar mi respuesta. :)
onemasse
Esto está muy desactualizado incluso en el momento de la publicación. Los vectores AVX en x86 (enviados en 2011) tienen una longitud de 32 bytes y los AVX-512 tienen una longitud de 64 bytes. Hay algunas arquitecturas con vectores de 1024 bits o 2048 bits, o incluso ancho de vector variable como ARM SVE
phuclv
@phuclv, si bien las instrucciones pueden haber estado disponibles en ese momento, ¿tiene alguna evidencia de que memcpy las use? Normalmente, las bibliotecas tardan un poco en ponerse al día, y las últimas que puedo encontrar usan SSSE3 y son mucho más recientes que en 2011.
Pete Kirkham
81

Las rutinas de copia de memoria pueden ser mucho más complicadas y rápidas que una simple copia de memoria a través de punteros como:

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++;
}

Mejoras

La primera mejora que se puede hacer es alinear uno de los punteros en un límite de palabra (por palabra me refiero al tamaño entero nativo, generalmente 32 bits / 4 bytes, pero puede ser 64 bits / 8 bytes en arquitecturas más nuevas) y usar movimiento del tamaño de una palabra / copiar instrucciones. Esto requiere usar una copia de byte a byte hasta que se alinee un puntero.

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--;
    }
  }
}

Las diferentes arquitecturas se comportarán de manera diferente en función de si el puntero de origen o de destino está alineado correctamente. Por ejemplo, en un procesador XScale obtuve un mejor rendimiento alineando el puntero de destino en lugar del puntero de origen.

Para mejorar aún más el rendimiento, se puede realizar un desenrollado de bucles, de modo que más registros del procesador se carguen con datos y eso significa que las instrucciones de carga / almacenamiento se pueden intercalar y tener su latencia oculta por instrucciones adicionales (como el conteo de bucles, etc.). El beneficio que esto trae varía bastante según el procesador, ya que las latencias de instrucción de carga / almacenamiento pueden ser bastante diferentes.

En esta etapa, el código termina por escribirse en Ensamblador en lugar de C (o C ++), ya que debe colocar manualmente las instrucciones de carga y almacenamiento para obtener el máximo beneficio de la ocultación de latencia y el rendimiento.

Por lo general, se debe copiar una línea de datos de caché completa en una iteración del ciclo desenrollado.

Lo que me lleva a la siguiente mejora, la adición de búsqueda previa. Estas son instrucciones especiales que le dicen al sistema de caché del procesador que cargue partes específicas de la memoria en su caché. Dado que hay un retraso entre la emisión de la instrucción y el llenado de la línea de caché, las instrucciones deben colocarse de tal manera que los datos estén disponibles cuando se van a copiar, y no antes / después.

Esto significa poner instrucciones de captación previa al inicio de la función, así como dentro del bucle de copia principal. Con las instrucciones de captación previa en medio del ciclo de copia, se obtienen datos que se copiarán en varias iteraciones.

No lo recuerdo, pero también puede ser beneficioso obtener previamente las direcciones de destino y las de origen.

Factores

Los principales factores que afectan la rapidez con que se puede copiar la memoria son:

  • La latencia entre el procesador, sus cachés y la memoria principal.
  • El tamaño y la estructura de las líneas de caché del procesador.
  • Las instrucciones de movimiento / copia de la memoria del procesador (latencia, rendimiento, tamaño de registro, etc.).

Por lo tanto, si desea escribir una rutina de manejo de memoria rápida y eficiente, necesitará saber bastante sobre el procesador y la arquitectura para los que está escribiendo. Es suficiente decir que, a menos que esté escribiendo en alguna plataforma integrada, sería mucho más fácil usar las rutinas de copia de memoria integradas.

Daemin
fuente
Las CPU modernas detectarán un patrón de acceso a la memoria lineal y comenzarán a realizar la búsqueda previa por sí mismas. Espero que las instrucciones de captación previa no hagan mucha diferencia debido a eso.
maxy
@maxy En las pocas arquitecturas en las que he implementado rutinas de copia de memoria, la adición de la captación previa ha ayudado considerablemente. Si bien puede ser cierto que los chips Intel / AMD de la generación actual se recuperan con suficiente anticipación, hay muchos chips más antiguos y otras arquitecturas que no lo hacen.
Daemin
¿Alguien puede explicar "(b_src & 0x3)! = 0"? No puedo entenderlo y, además, no se compilará (arroja un error: operador inválido en binario &: unsigned char e int);
David Refaeli
"(b_src & 0x3)! = 0" está verificando si los 2 bits más bajos no son 0. Entonces, si el puntero de origen está alineado con un múltiplo de 4 bytes o no. Su error de compilación ocurre porque está tratando el 0x3 como un byte, no como una entrada, puede solucionarlo usando 0x00000003 o 0x3i (creo).
Daemin
b_src & 0x3no se compilará porque no se le permite hacer aritmética bit a bit en tipos de puntero. Debes (u)intptr_t
lanzarlo
18

memcpypuede copiar más de un byte a la vez dependiendo de la arquitectura de la computadora. La mayoría de las computadoras modernas pueden trabajar con 32 bits o más en una sola instrucción de procesador.

De una implementación de ejemplo :

    00026 * Para una copia rápida, optimice el caso común donde ambos punteros
    00027 * y la longitud están alineadas con las palabras y, en su lugar, se copian palabra por palabra
    00028 * de bytes a la vez. De lo contrario, copie por bytes.
Mark Byers
fuente
8
En un 386 (por ejemplo), que no tenía caché a bordo, esto hizo una gran diferencia. En la mayoría de los procesadores modernos, las lecturas y escrituras se realizarán en una línea de caché a la vez, y el bus a la memoria suele ser el cuello de botella, por lo que se espera una mejora de un pequeño porcentaje, ni siquiera cerca del cuádruple.
Jerry Coffin
2
Creo que debería ser un poco más explícito cuando dice "de la fuente". Claro, esa es "la fuente" en algunas arquitecturas, pero ciertamente no está en, digamos, una máquina BSD o Windows. (Y demonios, incluso entre sistemas GNU a menudo hay mucha diferencia en esta función)
Billy ONeal
@Billy ONeal: +1 absolutamente cierto ... hay más de una forma de despellejar a un gato. Ese fue solo un ejemplo. ¡Fijo! Gracias por el comentario constructivo.
Mark Byers
7

Puede implementar memcpy()utilizando cualquiera de las siguientes técnicas, algunas de las cuales dependen de su arquitectura para mejorar el rendimiento, y todas serán mucho más rápidas que su código:

  1. Utilice unidades más grandes, como palabras de 32 bits en lugar de bytes. También puede (o puede que tenga que) ocuparse de la alineación aquí. No puede leer / escribir una palabra de 32 bits en una ubicación de memoria extraña, por ejemplo, en algunas plataformas, y en otras plataformas paga una penalización masiva de rendimiento. Para solucionar este problema, la dirección debe ser una unidad divisible por 4. Puede tomar esto hasta 64 bits para CPU de 64 bits, o incluso más usando instrucciones SIMD (instrucción única, datos múltiples) ( MMX , SSE , etc.)

  2. Puede usar instrucciones especiales de CPU que su compilador no pueda optimizar desde C. Por ejemplo, en un 80386, puede usar la instrucción de prefijo "rep" + instrucción "movsb" para mover N bytes dictados colocando N en el recuento Registrarse. Los buenos compiladores harán esto por usted, pero es posible que se encuentre en una plataforma que carece de un buen compilador. Tenga en cuenta que ese ejemplo tiende a ser una mala demostración de velocidad, pero combinado con alineación + instrucciones de unidad más grandes, puede ser más rápido que casi todo lo demás en ciertas CPU.

  3. Desenrollado de bucles : las ramas pueden ser bastante caras en algunas CPU, por lo que desenrollar los bucles puede reducir el número de ramas. Esta también es una buena técnica para combinar con instrucciones SIMD y unidades de gran tamaño.

Por ejemplo, http://www.agner.org/optimize/#asmlib tiene una memcpyimplementación que supera a la mayoría (por una cantidad muy pequeña). Si lee el código fuente, estará lleno de toneladas de código ensamblador en línea que implementa todas las tres técnicas anteriores, eligiendo cuál de esas técnicas en función de la CPU en la que está ejecutando.

Tenga en cuenta que también se pueden realizar optimizaciones similares para buscar bytes en un búfer. strchr()y los amigos a menudo lo harán más rápido que su equivalente enrollado a mano. Esto es especialmente cierto para .NET y Java . Por ejemplo, en .NET, la función integrada String.IndexOf()es mucho más rápida que incluso una búsqueda de cadenas de Boyer-Moore , porque utiliza las técnicas de optimización anteriores.

Danny Dulai
fuente
1
El mismo Agner Fog al que está vinculando también teoriza que el desenrollado de bucles es contraproducente en las CPU modernas .
La mayoría de las CPU de hoy en día tienen una buena predicción de ramas, lo que debería anular el beneficio del desenrollado de bucles en casos típicos. Un buen compilador de optimización todavía puede usarlo a veces.
thomasrutter
5

Respuesta corta:

  • relleno de caché
  • transferencias de tamaño de palabras en lugar de bytes cuando sea posible
  • Magia SIMD
moshbear
fuente
4

No sé si realmente se usa en alguna implementación del mundo real memcpy, pero creo que el dispositivo de Duff merece una mención aquí.

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);
        }
}

Tenga en cuenta que lo anterior no es un memcpyya que deliberadamente no incrementa el topuntero. Implementa una operación ligeramente diferente: la escritura en un registro mapeado en memoria. Consulte el artículo de Wikipedia para obtener más detalles.

NPE
fuente
El dispositivo de Duff, o simplemente el mecanismo de salto inicial, es un buen uso para copiar los primeros 1..3 (o 1..7) bytes de modo que los punteros estén alineados con un límite más agradable donde se puedan usar instrucciones de movimiento de memoria más grandes.
Daemin
@MarkByers: El código ilustra una operación ligeramente diferente (se *torefiere a un registro mapeado en memoria y no se incrementa deliberadamente; consulte el artículo vinculado). Como pensé haber dejado claro, mi respuesta no intenta proporcionar una eficiente memcpy, simplemente menciona una técnica bastante curiosa.
NPE
@Daemin De acuerdo, como dijiste, puedes omitir el do {} while () y el compilador traducirá el cambio a una tabla de salto. Muy útil cuando quieres cuidar los datos restantes. Se debe mencionar una advertencia sobre el dispositivo de Duff, aparentemente en arquitecturas más nuevas (x86 más reciente), la predicción de rama es tan eficiente que el dispositivo de Duff es en realidad más lento que un simple bucle.
onemasse
1
Oh no ... no es el dispositivo de Duff. No utilices el dispositivo de Duff. Por favor. Use PGO y permítame que el compilador lo desenrolle donde tenga sentido.
Billy ONeal
No, el dispositivo de Duff definitivamente no se usa en ninguna implementación moderna.
gnasher729
3

Como otros dicen, memcpy copia más de 1 byte. Copiar en trozos del tamaño de una palabra es mucho más rápido. Sin embargo, la mayoría de las implementaciones van un paso más allá y ejecutan varias instrucciones MOV (palabra) antes del bucle. La ventaja de copiar, por ejemplo, 8 bloques de palabras por bucle es que el bucle en sí es costoso. Esta técnica reduce el número de ramas condicionales en un factor de 8, optimizando la copia para bloques gigantes.

VoidStar
fuente
1
No creo que esto sea cierto. Puede desenrollar el ciclo, pero no puede copiar en una sola instrucción más datos de los direccionables a la vez en la arquitectura de destino. Además, también hay una sobrecarga para desenrollar el bucle ...
Billy ONeal
@Billy ONeal: No creo que eso sea lo que quiso decir VoidStar. Al tener varias instrucciones de movimiento consecutivas, se reduce la sobrecarga de contar el número de unidades.
wallyk
@Billy ONeal: Estás perdiendo el punto. Una palabra a la vez es como MOV, JMP, MOV, JMP, etc. Donde puede hacerlo MOV MOV MOV MOV JMP. He escrito mempcy antes y he comparado muchas formas de hacerlo;)
VoidStar
@wallyk: Quizás. Pero él dice "copiar trozos aún más grandes", lo que en realidad no es posible. Si se refiere a desenrollar el bucle, entonces debería decir "la mayoría de las implementaciones dan un paso más y desenrollan el bucle". La respuesta tal como está escrita es, en el mejor de los casos, engañosa y, en el peor, incorrecta.
Billy ONeal
@VoidStar: De acuerdo --- es mejor ahora. +1.
Billy ONeal
2

Las respuestas son grandes, pero si todavía quiere implementar una rápida memcpyusted mismo, hay una entrada en el blog interesante de establecimiento de memoria rápida, establecimiento de memoria rápida 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;
}

Incluso, puede ser mejor optimizando los accesos a la memoria.

masoud
fuente
1

Porque, como muchas rutinas de biblioteca, se ha optimizado para la arquitectura en la que se está ejecutando. Otros han publicado varias técnicas que se pueden utilizar.

Si tiene la opción, use las rutinas de la biblioteca en lugar de rodar las suyas. Esta es una variación de DRY que llamo DRO (Don't Repeat Others). Además, es menos probable que las rutinas de la biblioteca sean incorrectas que su propia implementación.

He visto que los verificadores de acceso a la memoria se quejan de lecturas fuera de los límites en la memoria o búferes de cadenas que no eran un múltiplo del tamaño de la palabra. Este es el resultado de la optimización que se está utilizando.

BillThor
fuente
0

Puede ver la implementación de MacOS de memset, memcpy y memmove.

En el momento del arranque, el sistema operativo determina en qué procesador se está ejecutando. Ha incorporado un código específicamente optimizado para cada procesador compatible y, en el momento del arranque, almacena una instrucción jmp en el código correcto en una ubicación fija de solo lectura.

Las implementaciones de C memset, memcpy y memmove son solo un salto a esa ubicación fija.

Las implementaciones usan código diferente según la alineación de origen y destino para memcpy y memmove. Obviamente utilizan todas las capacidades vectoriales disponibles. También usan variantes sin almacenamiento en caché cuando copia grandes cantidades de datos y tienen instrucciones para minimizar las esperas de tablas de páginas. No es solo código ensamblador, es código ensamblador escrito por alguien con un conocimiento extremadamente bueno de la arquitectura de cada procesador.

Intel también agregó instrucciones de ensamblador que pueden acelerar las operaciones de cadenas. Por ejemplo, con una instrucción para admitir strstr que realiza comparaciones de 256 bytes en un ciclo.

gnasher729
fuente
La versión de código abierto de Apple de memset / memcpy / memmove es solo una versión genérica que será mucho más lenta que la versión real usando SIMD
phuclv