Por que memcpy () e memmove () são mais rápidos do que incrementos de ponteiro?


92

Estou copiando N bytes de pSrcpara pDest. Isso pode ser feito em um único loop:

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

Por que isso é mais lento do que memcpyou memmove? Que truques eles usam para acelerar?


2
Seu loop copia apenas um local. Acho que você de alguma forma pretendeu incrementar os ponteiros.
Mysticial

13
Ou você pode simplesmente consertar para eles, como eu fiz. E, BTW, nenhum verdadeiro programador C sempre contagens de 1a N, é sempre a partir 0de N-1:-)
paxdiablo

6
@paxdiablo: Se você estiver fazendo loop em arrays, com certeza. Mas há muitos casos em que o loop de 1 a N é adequado. Depende do que você está fazendo com os dados - se você estiver exibindo uma lista numerada começando em 1, por exemplo, para um usuário, então começar em 1 provavelmente faz mais sentido. Em qualquer caso, ele ignora o problema maior que é usar intcomo contador quando um tipo sem sinal como size_tdeve ser usado.
Billy ONeal

2
@paxdiablo Você também pode contar de N a 1. Em alguns processadores, isso eliminará uma instrução de comparação, pois o decréscimo definirá o bit apropriado para a instrução de desvio quando chegar a zero.
onemasse de

6
Acho que a premissa da pergunta é falsa. Compiladores modernos irão converter isso em memcpyou memmove(dependendo se eles podem dizer se os ponteiros podem ser apelidos).
David Schwartz,

Respostas:


120

Como o memcpy usa ponteiros de palavras em vez de ponteiros de bytes, também as implementações de memcpy são frequentemente escritas com SIMD instruções o que torna possível embaralhar 128 bits por vez.

As instruções SIMD são instruções de montagem que podem realizar a mesma operação em cada elemento em um vetor de até 16 bytes de comprimento. Isso inclui instruções para carregar e armazenar.


15
Quando você ativa o GCC para -O3, ele usará SIMD para o loop, pelo menos se souber pDeste pSrcnão tiver apelido.
Dietrich Epp de

Atualmente estou trabalhando em um Xeon Phi com SIMD de 64 bytes (512 bits), então essa coisa de "até 16 bytes" me faz sorrir. Além disso, você deve especificar qual CPU você está almejando para que o SIMD seja habilitado, por exemplo com -march = native.
yakoudbz

Talvez eu deva revisar minha resposta. :)
onemasse

Isso está muito desatualizado, mesmo no momento da postagem. Os vetores AVX em x86 (lançado em 2011) têm 32 bytes de comprimento e AVX-512 têm 64 bytes. Existem algumas arquiteturas com vetores de 1024 bits ou 2048 bits, ou mesmo largura de vetor variável, como ARM SVE
phuclv

@phuclv Embora as instruções possam estar disponíveis, você tem alguma evidência de que o memcpy as usa? Normalmente demora um pouco para que as bibliotecas se atualizem, e as últimas que consigo encontrar usam SSSE3 e são muito mais recentes do que 2011.
Pete Kirkham

81

As rotinas de cópia de memória podem ser muito mais complicadas e rápidas do que uma simples cópia de memória por meio de indicadores 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++;
}

Melhorias

A primeira melhoria que se pode fazer é alinhar um dos ponteiros em um limite de palavra (por palavra, quero dizer tamanho inteiro nativo, geralmente 32 bits / 4 bytes, mas pode ser 64 bits / 8 bytes em arquiteturas mais recentes) e usar o movimento de tamanho de palavra / copiar instruções. Isso requer o uso de uma cópia de byte a byte até que um ponteiro esteja alinhado.

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

Diferentes arquiteturas terão um desempenho diferente com base no alinhamento apropriado da origem ou do ponteiro de destino. Por exemplo, em um processador XScale, obtive melhor desempenho alinhando o ponteiro de destino em vez do ponteiro de origem.

Para melhorar ainda mais o desempenho, pode ser feito algum desenrolamento de loop, de modo que mais registros do processador sejam carregados com dados e isso significa que as instruções de carga / armazenamento podem ser intercaladas e ter sua latência oculta por instruções adicionais (como contagem de loop, etc.). O benefício que isso traz varia um pouco de acordo com o processador, uma vez que as latências de instrução de carga / armazenamento podem ser bem diferentes.

Nesse estágio, o código acaba sendo escrito em Assembly em vez de C (ou C ++), pois você precisa colocar manualmente as instruções de carregamento e armazenamento para obter o máximo benefício de ocultação de latência e rendimento.

Geralmente, toda uma linha de cache de dados deve ser copiada em uma iteração do loop desenrolado.

O que me leva à próxima melhoria, adicionando pré-busca. Estas são instruções especiais que dizem ao sistema de cache do processador para carregar partes específicas da memória em seu cache. Como existe um atraso entre a emissão da instrução e o preenchimento da linha do cache, as instruções precisam ser colocadas de forma que os dados estejam disponíveis no momento exato em que devem ser copiados, e não antes ou depois.

Isso significa colocar as instruções de pré-busca no início da função, bem como dentro do loop de cópia principal. Com as instruções de pré-busca no meio do loop de cópia, busca dados que serão copiados em várias iterações.

Não me lembro, mas também pode ser útil buscar antecipadamente os endereços de destino e também os de origem.

Fatores

Os principais fatores que afetam a rapidez com que a memória pode ser copiada são:

  • A latência entre o processador, seus caches e a memória principal.
  • O tamanho e a estrutura das linhas de cache do processador.
  • As instruções para mover / copiar a memória do processador (latência, taxa de transferência, tamanho do registro, etc.).

Portanto, se você quiser escrever uma rotina eficiente e rápida para lidar com a memória, precisará saber bastante sobre o processador e a arquitetura para os quais está escrevendo. Basta dizer que, a menos que você esteja escrevendo em alguma plataforma embarcada, seria muito mais fácil usar apenas as rotinas de cópia de memória integradas.


CPUs modernas irão detectar um padrão de acesso à memória linear e começar a pré-busca por conta própria. Espero que as instruções de pré-busca não façam muita diferença por causa disso.
máximo de

@maxy Nas poucas arquiteturas que implementei rotinas de cópia de memória, adicionar a pré-busca ajudou de forma mensurável. Embora possa ser verdade que a geração atual de chips Intel / AMD pré-busca com bastante antecedência, há muitos chips mais antigos e outras arquiteturas que não o fazem.
Daemin de

alguém pode explicar "(b_src & 0x3)! = 0"? Eu não consigo entender isso, e também - não vai compilar (lança um erro: operador inválido para binário &: unsigned char e int);
David Refaeli

"(b_src & 0x3)! = 0" está verificando se os 2 bits mais baixos não são 0. Portanto, se o ponteiro de origem está alinhado a um múltiplo de 4 bytes ou não. Seu erro de compilação acontece porque ele está tratando o 0x3 como um byte, não como um in, você pode consertar isso usando 0x00000003 ou 0x3i (eu acho).
Daemin

b_src & 0x3não compilará porque você não tem permissão para fazer aritmética bit a bit nos tipos de ponteiro. Você deve lançá-lo (u)intptr_tprimeiro
phuclv

18

memcpypode copiar mais de um byte de uma vez, dependendo da arquitetura do computador. A maioria dos computadores modernos pode trabalhar com 32 bits ou mais em uma única instrução de processador.

De um exemplo de implementação :

    00026 * Para cópias rápidas, otimize o caso comum em que ambos os indicadores
    00027 * e o comprimento são alinhados por palavra e, em vez disso, copiam palavra por vez
    00028 * de byte por vez. Caso contrário, copie por bytes.

8
Em um 386 (por exemplo), que não tinha cache on-board, isso fez uma grande diferença. Na maioria dos processadores modernos, as leituras e gravações acontecerão uma linha de cache por vez, e o barramento para a memória geralmente será o gargalo, portanto, espere uma melhoria de alguns por cento, não perto do quádruplo.
Jerry Coffin,

2
Eu acho que você deveria ser um pouco mais explícito quando diz "da fonte". Claro, essa é a "fonte" em algumas arquiteturas, mas certamente não em, digamos, um BSD ou máquina Windows. (E caramba, mesmo entre os sistemas GNU, muitas vezes há muitas diferenças nesta função)
Billy ONeal

@Billy ONeal: +1 absolutamente certo ... há mais de uma maneira de esfolar um gato. Esse foi apenas um exemplo. Fixo! Obrigado pelo comentário construtivo.
Mark Byers,

7

Você pode implementar memcpy()usando qualquer uma das seguintes técnicas, algumas dependentes de sua arquitetura para ganhos de desempenho, e todas serão muito mais rápidas do que seu código:

  1. Use unidades maiores, como palavras de 32 bits em vez de bytes. Você também pode (ou pode ter que) lidar com o alinhamento aqui também. Você não pode ler / escrever uma palavra de 32 bits em um local de memória estranho, por exemplo, em algumas plataformas, e em outras plataformas você paga uma penalidade enorme de desempenho. Para corrigir isso, o endereço deve ser uma unidade divisível por 4. Você pode levar isso para 64 bits para CPUs de 64 bits, ou até mais usando instruções SIMD (instrução única, dados múltiplos) ( MMX , SSE , etc.)

  2. Você pode usar instruções especiais da CPU que seu compilador pode não conseguir otimizar a partir de C. Por exemplo, em um 80386, você pode usar a instrução de prefixo "rep" + instrução "movsb" para mover N bytes ditados pela colocação de N na contagem registro. Bons compiladores farão isso por você, mas você pode estar em uma plataforma que carece de um bom compilador. Observe que esse exemplo tende a ser uma demonstração ruim de velocidade, mas combinado com alinhamento + instruções de unidade maiores, pode ser mais rápido do que quase tudo em certas CPUs.

  3. Loop unrolling - branches pode ser bastante caro em algumas CPUs, então o desdobramento dos loops pode diminuir o número de branches. Essa também é uma boa técnica para combinar com instruções SIMD e unidades de tamanho muito grande.

Por exemplo, http://www.agner.org/optimize/#asmlib tem uma memcpyimplementação que supera a maioria (por uma quantidade muito pequena). Se você ler o código-fonte, ele estará cheio de toneladas de código assembly embutido que extrai todas as três técnicas acima, escolhendo qual dessas técnicas com base em qual CPU você está executando.

Observe que também existem otimizações semelhantes que podem ser feitas para localizar bytes em um buffer. strchr()e amigos muitas vezes passarão mais rápido do que seu equivalente enrolado à mão. Isso é especialmente verdadeiro para .NET e Java . Por exemplo, em .NET, o embutido String.IndexOf()é muito mais rápido do que até mesmo uma pesquisa de string Boyer-Moore , porque usa as técnicas de otimização acima.


1
O mesmo Agner Fog ao qual você está ligando também teoriza que o desenrolamento de loop é contraproducente em CPUs modernas .

A maioria das CPUs hoje em dia tem uma boa previsão de ramificação, o que deve negar o benefício do desenrolamento de loop em casos típicos. Um bom compilador de otimização ainda pode usá-lo às vezes.
thomasrutter

5

Resposta curta:

  • preenchimento de cache
  • transferências de tamanho de palavras em vez de bytes, quando possível
  • Magia SIMD

4

Não sei se é realmente usado em qualquer implementação do mundo real do memcpy, mas acho que o Dispositivo de Duff merece uma menção aqui.

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

Observe que o acima não é um, memcpypois deliberadamente não incrementa o toponteiro. Ele implementa uma operação ligeiramente diferente: a gravação em um registro mapeado na memória. Veja o artigo da Wikipedia para detalhes.


O dispositivo de Duff, ou apenas o mecanismo de salto inicial, é um bom uso para copiar os primeiros 1..3 (ou 1..7) bytes de modo que os ponteiros sejam alinhados a um limite mais agradável, onde instruções de movimentação de memória maiores podem ser usadas.
Daemin,

@MarkByers: O código ilustra uma operação ligeiramente diferente ( *torefere-se a um registro mapeado na memória e deliberadamente não é incrementado - consulte o artigo vinculado). Como achei ter deixado claro, minha resposta não tenta fornecer uma eficiente memcpy, ela simplesmente menciona uma técnica bastante curiosa.
NPE de

@Daemin Concordo, como você disse, você pode pular o do {} while () e a opção será traduzida para uma tabela de salto pelo compilador. Muito útil quando você deseja cuidar dos dados restantes. Um aviso deve ser mencionado sobre o dispositivo de Duff, aparentemente em arquiteturas mais recentes (x86 mais recente), a previsão de ramificação é tão eficiente que o dispositivo de Duff é na verdade mais lento do que um loop simples.
onemasse de

1
Oh não .. não é o dispositivo de Duff. Por favor, não use o dispositivo de Duff. Por favor. Use o PGO e deixe-me o compilador desenrolar o loop para você onde fizer sentido.
Billy ONeal

Não, o dispositivo de Duff definitivamente não é usado em nenhuma implementação moderna.
gnasher729

3

Como outros, dizem que o memcpy copia mais do que blocos de 1 byte. Copiar em blocos de tamanho de palavra é muito mais rápido. No entanto, a maioria das implementações dá um passo adiante e executa várias instruções MOV (palavra) antes de fazer o loop. A vantagem de copiar, digamos, 8 blocos de palavras por loop é que o próprio loop é caro. Essa técnica reduz o número de ramificações condicionais por um fator de 8, otimizando a cópia para blocos gigantes.


1
Eu não acho que isso seja verdade. Você pode desenrolar o loop, mas não pode copiar em uma única instrução mais dados do que os endereçáveis ​​por vez na arquitetura de destino. Além disso, há sobrecarga de desenrolar o loop também ...
Billy ONeal

@Billy ONeal: Não acho que foi isso que o VoidStar quis dizer. Por ter várias instruções de movimento consecutivas, o overhead da contagem do número de unidades é reduzido.
wallyk

@Billy ONeal: Você está perdendo o ponto. 1 palavra de cada vez é como MOV, JMP, MOV, JMP, etc. Onde, como você pode fazer MOV MOV MOV MOV JMP. Já escrevi mempcy antes e comparei várias maneiras de fazer isso;)
VoidStar

@wallyk: Talvez. Mas ele diz "copie pedaços ainda maiores" - o que não é realmente possível. Se ele está se referindo ao desenrolamento do loop, ele deve dizer "a maioria das implementações dá um passo adiante e desenrola o loop." A resposta escrita é, na melhor das hipóteses, enganosa e, na pior, errada.
Billy ONeal

@VoidStar: Concordo --- está melhor agora. +1.
Billy ONeal,

2

As respostas são grandes, mas se você ainda deseja implementar um rápido memcpymesmo, há um post interessante sobre memcpy rápido, memcpy rápido no 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;
}

Ainda, pode ser melhor com a otimização de acessos à memória.


1

Porque, como muitas rotinas de biblioteca, ela foi otimizada para a arquitetura na qual você está executando. Outros postaram várias técnicas que podem ser usadas.

Se tiver escolha, use rotinas de biblioteca em vez de criar suas próprias rotinas. Esta é uma variação do DRY que chamo de DRO (Don't Repeat Others). Além disso, é menos provável que as rotinas da biblioteca estejam erradas do que a sua própria implementação.

Já vi verificadores de acesso à memória reclamarem de leituras fora dos limites na memória ou nos buffers de string que não eram múltiplos do tamanho da palavra. Isso é resultado da otimização que está sendo usada.


0

Você pode observar a implementação do MacOS de memset, memcpy e memmove.

No momento da inicialização, o sistema operacional determina em qual processador está sendo executado. Ele foi construído em um código especificamente otimizado para cada processador suportado e, no momento da inicialização, armazena uma instrução jmp para o código correto em um local fixo de leitura / somente.

As implementações C memset, memcpy e memmove são apenas um salto para esse local fixo.

As implementações usam código diferente dependendo do alinhamento da origem e do destino para memcpy e memmove. Eles obviamente usam todos os recursos vetoriais disponíveis. Eles também usam variantes sem armazenamento em cache quando você copia grandes quantidades de dados e têm instruções para minimizar as esperas por tabelas de página. Não é apenas código assembler, é código assembler escrito por alguém com um conhecimento extremamente bom de cada arquitetura de processador.

A Intel também adicionou instruções de montagem que podem tornar as operações de string mais rápidas. Por exemplo, com uma instrução para suportar strstr que faz comparações de 256 bytes em um ciclo.


A versão de código aberto da Apple de memset / memcpy / memmove é apenas uma versão genérica que será muito mais lenta do que a versão real usando SIMD
phuclv
Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.