Copiando blocos de memória… revisado!

Um amigo está com uma dúvida interessante: Qual é a maneira mais rápida de copiar um bloco de memória de um buffer para outro? Minha resposta é a seguinte: “Só tem um jeito de saber! Vamos medir!”. Um jeito de medir isso é usando programas como o VTune, da Intel… Outra maneira (o “jeito” do homem pobre) é usando o contador de ciclos do próprio processador. Esse método é “menos preciso”, já que não leva em conta uma série de variáveis, mas é melhor que nenhuma medida! Eis um códigozinho exemplo:

#include <stdio.h>
#include <memory.h>

/* Buffers para teste. */
unsigned char buffer[4096], buffer2[4096];

/* Variáveis usadas para a medição */
unsigned long long t1, t2, t3;

/* Essa função lê o contador de clocks do processador! */
static __inline__ unsigned long long rdtsc(void)
{
  unsigned long long x;
  __asm__ __volatile__ ("rdtsc" : "=A"(x));
  return x;
}

/* Método 1: Usando índices para calcular os ponteiros! */
void copy(unsigned char *p)
{
  int i;

  for (i = 0; i < 4096; i++)
    buffer[i] = p[i];
}

/* Método 2: Usando ponteiros diretamente. */
void copy2(unsigned char *p)
{
  unsigned char *ptr = buffer;
  int i;

  for (i = 0; i < 4096; i++)
    *ptr++ = *p++;
}

/* Rotina de teste. */
void main(void)
{
  t1 = rdtsc();
  copy(buffer2);
  t1 = rdtsc() - t1;

  t2 = rdtsc();
  copy2(buffer2);
  t2 = rdtsc() - t2;

  t3 = rdtsc();
  memcpy(buffer2, buffer, 4096);
  t3 = rdtsc() - t3;

  printf("copy(), copy2() e memcpy(): %lld, %lld, %lld ciclos.\n",
    t1, t2, t3);
}

Temos algumas maneiras de testar isso… Sem otimizações e com otimizações:

$ # sem otimizações
$ gcc -O0 -o test test.c
$ ./test
copy(), copy2() e memcpy(): 58569, 48902, 16282 ciclos.
$ gcc -O3 -fomit-frame-pointer -funroll-loops -o test test.c
$ ./test
copy(), copy2() e memcpy(): 14994, 1624, 812 ciclos

Humm… Sem as otimizações do compilador memcpy() é 3 vezes mais rápido do que o uso de ponteiros diretamente e 3,5 vezes mais rápido do que usar índices. Mas, quando ligamos as otimizações (incluindo tirando os códigos de epílogo e prólogo das rotinas e também desenrolando os loops), memcpy() é 2 vezes mais rápido do que usar ponteiros diretamente e pouco mais de 18 vezes mais rápido do que usar índices.

A dúvida desse meu amigo também é relativa ao preenchimento de buffers… Qual seria a diferença entre usar loops e ponteiros para preencher buffers e usar a função memset()? Basta alterar as funções copy(), copy2() e alterar o memcpy para memset() no programinha acima:

/* Método 1: Usando índices para calcular os ponteiros! */
void fill(void)
{
  int i;

  for (i = 0; i < 4096; i++)
    buffer[i] = 0xA5;
}

/* Método 2: Usando ponteiros diretamente. */
void fill2(void)
{
  unsigned char *ptr = buffer;
  int i;

  for (i = 0; i < 4096; i++)
    *ptr++ = 0xA5;
}

/* Método 3: Alterar o memcpy() do programa para:
   memset(buffer, 0xA5, 4096); */

O resto fica essencialmente o mesmo (a não ser as chamadas em main()). O que obtemos? Com a máxima otimização do compilador, isto:

fill(), fill2() e memset(): 7525, 1386, 728 ciclos.

Ou seja. memset() é 2 vezes (quase) mais rápido do que usar ponteiros diretamente e quase 10 vezes mais rápido do que usar índices.

E olha que não estou nem falando em alterar memset() e memcpy() para usar SSE!!!

Assim, meu querido amigo… As funções de copia e preenchimento de blocos da biblioteca padrão de C são mais rápidas do que os equivalentes “manuais”…

E essa é a parte revisada do artigo original. Vocẽ pode supor que usar um ponteiro de forma sequencial, linear, seja mais performático… Nem sempre. Observe o código abaixo:

unsigned char *p = buffer;

*p++ = 0xA5;
*p++ = 0xA5;
... /* mais 4092 vezes aqui */
*p++ = 0xA5;
*p++ = 0xA5;

Isso aqui toma  93142 ciclos!! Doze vezes mais ciclos do que a rotina menos performática, fill(), acima, do código sem otimizações!

Não se enganem, alterar o código para:

unsigned char *p = buffer;

p[0] = 0xA5;
p[1] = 0xA5;
... /* mais 4092 vezes aqui */
p[4094] = 0xA5;
p[4095] = 0xA5;

Não ajuda muita coisa. Neste caso temos 74683 ciclos. Dez vezes mais ciclos que a rotina menos performática.

No artigo original (aqui) cometi um erro grave: Não dei uma olhada no código assembly gerado pelo compilador quando usei otimizações automáticas! O código estava contando 77 ciclos porque estava colocando a chamada a rdtsc() nos lugares errados!! É o caso da otimização automática atrapalhando as medições de performance! Tomem cuidado com isso.

Como é muito difícil “melhorar” a performance de uma sequência de atribuições, então retirei as otimizações automáticas (-O0) para tomar as medidas corretas.

Obs: Antes que vocês possam desconfiar das outras medições, eu conferi o código gerado em todas. Estão corretas!

Anúncios

Deixe um comentário

Faça o login usando um destes métodos para comentar:

Logotipo do WordPress.com

Você está comentando utilizando sua conta WordPress.com. Sair / Alterar )

Imagem do Twitter

Você está comentando utilizando sua conta Twitter. Sair / Alterar )

Foto do Facebook

Você está comentando utilizando sua conta Facebook. Sair / Alterar )

Foto do Google+

Você está comentando utilizando sua conta Google+. Sair / Alterar )

Conectando a %s