Este post mostra um exemplo da importância de se conhecer sobre hardware para desenvolver bons softwares, mostrando um problema que se tem com ponteiros quando necessitamos do melhor desempenho possível.
Vamos analisar um trecho do código postado em Threads (parte 2). Para você não precisar ler aquele post para entender este, vou resumir: naquele código, precisávamos fazer o seguinte processo:
resultado = √1 – √2 + √3 – … + √999999999
Porém, a função em que esse trecho era executado não podia ser uma função qualquer. Ela tinha obrigatoriamente que seguir o protótipo “void* nomedafuncao(void* parametro)”. Isso me levou a inicialmente desenvolvê-la da seguinte maneira:
void* thread_call(void* param)
{
// Utilizamos a variável teste apenas para simplificar o código
thread_param_t* teste = (thread_param_t*)param;
int i;
teste->value = 0;
for (i = teste->i_min; i<i_max; i++)
{
if (i%2 == 1) // Se o resto de i/2 for 1 (ou seja, i é impar)
teste->value += sqrt(i); // Soma raiz de i
else
teste->value -= sqrt(i); // Subtrai raiz de i
}
}
Já que o parâmetro é um ponteiro, faz sentido que trabalhamos diretamente com ponteiro (substituindo o “.” por “->”). Ainda mais pois trabalhar com ponteiros é muito comum em C++, e é necessário na maioria das linguagens gerenciadas, como Java, C#, etc. Porém, quando focamos no desempenho, precisamos reconsiderar essas “manias”. Por que?
As nossas variáveis podem ser guardadas em vários tipos de memórias. Por ordem de velocidade, são basicamente: registradores, cache, RAM e HD. Porém, tenha uma coisa em mente: nós não temos como criar um ponteiro para um registrador. Portanto, sempre que criamos uma variável, e depois acessamos o endereço dessa variável de alguma forma, o compilador simplesmente não tem como utilizar essa variável como um registrador. Ele vai colocá-la na memória RAM. E se você faz 1000000000 de acessos a essa variável, tenha certeza que seu programa será muito mais lento se essa variável estiver na memória RAM.
A resposta para isso é criar uma variável local, no começo da função copiar o conteúdo do ponteiro para a local, mexer apenas na local e só no fim da função é que copiamos o resultado para o ponteiro.
void* thread_call(void* param)
{
// Copiamos o parâmetro para uma variável local (não-ponteiro)
thread_param_t teste = *(thread_param_t*)param;
int i;
// Realizamos o mesmo algoritmo que tínhamos feito no 1º programa
teste.value = 0;
for (i = teste.i_min; i < teste.i_max; i++)
{
if (i%2 == 1) // Se o resto de i/2 for 1 (ou seja, i é impar)
teste.value += sqrt(i); // Soma raiz de i
else
teste.value -= sqrt(i); // Subtrai raiz de i
}
// Copiamos o resultado no parâmetro original
((thread_param_t*)param)->value = teste.value;
}
Vejam o que a diferença é muito pequena. Foi só trocar um asterisco de lugar na declaração da variável, substituir os “->” por ponto, e adicionar uma linha que copia o valor obtido para o ponteiro. É uma diferença que em uma rápida olhada poderíamos achar até que piora o desempenho, mas que na verdade traz uma melhora grande.
Diferença nos tempos de execução no meu PC (os tempos aqui mostrados são cada um os melhores resultados de 5 execuções seguidas)
Usando ponteiro, sem otimizações: 10.137s
Usando ponteiro, com otimizações -O3 -O2: 7.178s
Usando não-ponteiro, com otimizações: 4.969s
Usando não-ponteiro, sem otimizações: 4.997s (diferença pouco significativa)
Conclusões: A melhora de desempenho comparando com o código usando ponteiro com otimizações foi de 44%. Isso pois provavelmente meu PC conseguiu colocar o ponteiro na memória cache. No PC do meu trabalho demorava 16 segundos usando não ponteiro e single-threaded (usando 1 núcleo), e quando tentei fazer multi-threaded passou a demorar mais de 1 minuto (com ponteiro, provavelmente sendo alocado na RAM). Demorei um tempo para pensar nisso, e com essa pequena modificação passou a fazer em apenas 4 segundos (multi-threaded). Isso me motivou a fazer estes 3 posts mais recentes.
O programa completo, caso você queira testar no seu computador:
#include <stdio.h>
#include <math.h>
#include <pthread.h>
/* Precimos definir uma struct, porque a função
* thread_call só pode aceitar 1 apontador como
* parametro, e precisamos passar 3 valores
*/
typedef struct
{
int i_min; // a partir de onde fazer o cálculo
int i_max; // até onde fazer o cálculo
double value; // valor do cálculo parcial
} thread_param_t;
/* Esta função será chamada cada vez que uma Thread for criada
*/
void* thread_call(void* param)
{
// Copiamos o parâmetro para uma variável local
thread_param_t teste = *(thread_param_t*)param;
int i;
// Realizamos o mesmo algoritmo que tínhamos feito no 1º programa
teste.value = 0;
for (i = teste.i_min; i < teste.i_max; i++)
{
if (i%2 == 1) // Se o resto de i/2 for 1 (ou seja, i é impar)
teste.value += sqrt(i); // Soma raiz de i
else
teste.value -= sqrt(i); // Subtrai raiz de i
}
// Copiamos o resultado no parâmetro original
((thread_param_t*)param)->value = teste.value;
}
int main()
{
// Estas variáveis serão a identificação de cada Thread
pthread_t thread1, thread2, thread3;
// Estas serão responsáveis por distribuir o processamento
thread_param_t param0, param1, param2, param3;
param0.i_min = 1;
param0.i_max = 250000000;
param1.i_min = 250000000;
param1.i_max = 500000000;
param2.i_min = 500000000;
param2.i_max = 750000000;
param3.i_min = 750000000;
param3.i_max = 1000000000;
// Criamos as threads
pthread_create(&thread1, NULL, thread_call, (void*)¶m1);
pthread_create(&thread2, NULL, thread_call, (void*)¶m2);
pthread_create(&thread3, NULL, thread_call, (void*)¶m3);
// E por fim, a Thread padrão chama a função como normalmente
thread_call((void*)¶m0);
// Aguardamos que as outras threads terminem
pthread_join(thread1, NULL);
pthread_join(thread2, NULL);
pthread_join(thread3, NULL);
// Somamos as parcelas
param0.value += param1.value + param2.value + param3.value;
printf("%f\n", param0.value); // Imprime o resultado
return 0;
}

