Otimização de performance em C
LidoNesta aula, você aprenderá a otimizar código em C de forma profissional, começando pelos níveis de otimização do compilador (-O0 a -O3), passando pela importância da localidade de cache, técnicas para evitar alocações desnecessárias e finalizando com a prática essencial de medir o desempenho antes de otimizar. Ao final, você terá um repertório de estratégias para escrever código C mais eficiente sem sacrificar a legibilidade.
Bem-vindo à aula 88 do nosso curso de Linguagem C! Hoje vamos mergulhar em um tópico crucial para qualquer programador que deseja levar seus programas ao próximo nível: otimização de performance. Otimizar não é apenas tornar o código mais rápido; é um processo metódico de identificar gargalos, entender o hardware e aplicar técnicas fundamentais de forma inteligente. Nesta aula, você aprenderá desde os níveis de otimização do compilador até estratégias avançadas de escrita de código que respeitam a hierarquia de memória e evitam desperdícios. Prepare-se para uma jornada que vai transformar a maneira como você pensa sobre eficiência em C.
Antes de começarmos, é essencial entender que otimização prematura é a raiz de muitos males (como diria Knuth). O processo correto é: primeiro, escreva um código claro e correto; depois, meça o desempenho; e só então otimize as partes que realmente são gargalos. Nesta aula, vamos explorar as ferramentas e técnicas que você precisa para fazer exatamente isso. Vamos começar!
Níveis do compilador (-O)
O compilador GCC oferece várias opções de otimização que controlam o trade-off entre tempo de compilação, tamanho do código e velocidade de execução. Esses níveis são ativados com a flag -O seguida de um número (ou letra). Cada nível aplica um conjunto progressivamente mais agressivo de transformações no código gerado. Vamos entender cada um:
- -O0: Sem otimização. É o padrão quando nenhuma flag é passada. O compilador gera código simples, fácil de depurar, mas que não aproveita muitas oportunidades de melhoria.
- -O1: Otimização básica. Reduz o tamanho do código e melhora a velocidade, mas mantém um tempo de compilação razoável. Inclui otimizações como eliminação de código morto e simplificação de expressões.
- -O2: Nível recomendado para a maioria dos programas. Aplica quase todas as otimizações suportadas que não aumentam significativamente o tamanho do código. É o equilíbrio ideal entre desempenho e complexidade.
- -O3: Otimização agressiva. Ativa todas as otimizações de
-O2e adiciona técnicas como inlining de funções e vetorização automática (uso de instruções SIMD). Pode aumentar o tamanho do binário e, em raros casos, até piorar o desempenho devido a maior pressão de cache de instruções. - -Os: Otimiza para tamanho. Tenta reduzir o tamanho do código, útil para sistemas embarcados.
- -Ofast: Equivalente a
-O3com otimizações que podem quebrar a conformidade estrita (por exemplo, assumir que não há NaN em operações de ponto flutuante). Use com cautela.
Vamos ver um exemplo prático. Considere o seguinte código que soma os elementos de um vetor:
#include <stdio.h>
int main() {
int arr[1000];
long sum = 0;
for (int i = 0; i < 1000; i++) {
arr[i] = i;
}
for (int i = 0; i < 1000; i++) {
sum += arr[i];
}
printf("%ld\n", sum);
return 0;
}
Para compilar com diferentes níveis, você pode usar:
gcc -O0 -o prog_O0 prog.c
gcc -O2 -o prog_O2 prog.c
gcc -O3 -o prog_O3 prog.c
Se você medir o tempo de execução com time ./prog_O0 e comparar com time ./prog_O2, verá diferenças. Mas para programas maiores, a diferença entre -O0 e -O2 é drástica, muitas vezes de 2 a 5 vezes em desempenho.
Uma observação importante: quando você está depurando, use -O0 para que as variáveis não sejam otimizadas e o debugger funcione corretamente. Ao gerar versões de release, use -O2 ou -O3.
Cache e localidade
A memória cache é uma pequena porção de memória extremamente rápida que fica entre o processador e a RAM. Quando o processador precisa de um dado, ele primeiro verifica se ele está na cache. Se estiver (cache hit), o acesso é muito rápido; se não (cache miss), ele busca da RAM, o que é muito mais lento. A hierarquia de cache é fundamental para a performance, e a forma como seu código acessa a memória pode ter um impacto enorme.
O princípio da localidade é a chave para aproveitar bem a cache. Existem dois tipos: localidade temporal (se você acessou um dado recentemente, é provável que acesse novamente em breve) e localidade espacial (se você acessou um dado, é provável que acesse dados próximos). Em C, a forma mais comum de garantir localidade espacial é percorrer arrays em ordem contígua.
Considere uma matriz 2D armazenada em um vetor unidimensional. A forma como você percorre as linhas e colunas afeta drasticamente o desempenho. Exemplo:
#define N 1000
int matriz[N][N];
// Acesso por linhas (bom desempenho)
for (int i = 0; i < N; i++) {
for (int j = 0; j < N; j++) {
matriz[i][j] = i + j;
}
}
// Acesso por colunas (péssimo desempenho)
for (int j = 0; j < N; j++) {
for (int i = 0; i < N; i++) {
matriz[i][j] = i + j;
}
}
No primeiro loop, os elementos são acessados em ordem de memória (linha por linha), o que explora a localidade espacial. No segundo, pulamos de linha em linha, causando muitos cache misses. Para N grande, o segundo loop pode ser dezenas de vezes mais lento.
Outra técnica é o blocagem (blocking), usada em operações de matrizes para melhorar a reutilização de dados. Em vez de percorrer toda a matriz, você divide em blocos que cabem na cache L1 ou L2. Isso é essencial em multiplicação de matrizes, por exemplo.
Além disso, estruturas de dados compactas (como arrays em vez de listas encadeadas) tendem a ter melhor localidade. Uma lista encadeada espalha os nós pela memória, causando misses constantes. Se você precisa de iteração rápida, prefira arrays.
Evitando alocações
Alocações dinâmicas de memória (via malloc e free) são caras. Elas envolvem chamadas de sistema, gerenciamento de heap e podem fragmentar a memória. Em loops de alta frequência, alocar e liberar memória repetidamente pode ser um gargalo significativo. A boa prática é minimizar alocações desnecessárias e reutilizar buffers quando possível.
Vamos ver um exemplo: suponha que você precise processar muitos dados e, para cada item, gere uma string temporária. Em vez de alocar uma nova string para cada item, você pode usar um buffer fixo e sobrescrevê-lo:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void process_item(int id, char *buffer, size_t buffer_size) {
snprintf(buffer, buffer_size, "Item_%d", id);
// usa buffer
}
int main() {
char buffer[64];
for (int i = 0; i < 1000000; i++) {
process_item(i, buffer, sizeof(buffer));
}
return 0;
}
Outra abordagem é usar alocação por pool (memory pool), onde você aloca um grande bloco de uma vez e divide em pedaços menores conforme necessário. Isso reduz chamadas a malloc e melhora a localidade. Em sistemas embarcados ou jogos, isso é comum.
Também evite criar estruturas temporárias em expressões. Por exemplo, concatenar strings com strcat em loops pode causar múltiplas realocações se você usar realloc a cada passo. Em vez disso, calcule o tamanho necessário antes e aloque uma vez.
Além disso, cuidado com funções que retornam ponteiros para memória alocada dinamicamente. Cada chamada cria uma nova alocação. Se possível, passe um buffer fornecido pelo chamador. Isso é uma prática comum em APIs de C, como sprintf em vez de asprintf (que aloca).
Medindo antes
A regra de ouro da otimização é: meça antes de otimizar. Se você não sabe onde está o gargalo, pode desperdiçar horas otimizando código que não tem impacto no desempenho geral. Ferramentas como profilers (gprof, perf, Valgrind --tool=callgrind) e temporizadores de alta resolução (clock_gettime) são essenciais.
Vamos ver um exemplo de como medir o tempo de execução de um trecho de código usando clock_gettime:
#include <stdio.h>
#include <time.h>
long long timespec_diff_ns(struct timespec start, struct timespec end) {
return (end.tv_sec - start.tv_sec) * 1000000000LL + (end.tv_nsec - start.tv_nsec);
}
int main() {
struct timespec start, end;
clock_gettime(CLOCK_MONOTONIC, &start);
// código a ser medido
volatile long sum = 0;
for (int i = 0; i < 1000000; i++) {
sum += i;
}
clock_gettime(CLOCK_MONOTONIC, &end);
printf("Tempo: %lld ns\n", timespec_diff_ns(start, end));
return 0;
}
Note o uso de volatile para evitar que o compilador otimize o loop inteiro (ele poderia calcular o resultado em tempo de compilação). Em benchmarks reais, você precisa garantir que o código não seja eliminado.
Para perfis mais detalhados, você pode usar gprof compilando com -pg e depois executando, que gera um arquivo de estatísticas. Ferramentas modernas como perf no Linux oferecem análise de cache misses, branches, etc.
Além disso, ao comparar otimizações, sempre teste com dados representativos e múltiplas execuções para evitar ruído. Use perf stat para obter contadores de hardware, como ciclos, instruções e cache misses.
Boas práticas e observações finais
Otimização é uma arte que combina conhecimento técnico com bom senso. Aqui estão algumas diretrizes:
- Escreva código claro e correto primeiro. Otimize apenas os gargalos identificados por medições.
- Conheça o seu compilador e as flags de otimização. Use
-O2como padrão e teste-O3quando necessário. - Prefira arrays a listas encadeadas quando a iteração for frequente.
- Minimize alocações dentro de loops; reutilize buffers.
- Use funções inline para funções pequenas chamadas em loops, mas deixe o compilador decidir com
static inlineou-O3. - Evite
printfem loops internos, pois é extremamente lento. - Entenda a arquitetura-alvo: tamanhos de cache, alinhamento, SIMD.
Lembre-se: otimizar sem medir é como atirar no escuro. Sempre valide suas mudanças com benchmarks e, se possível, compare com uma versão baseline. Com prática, você desenvolverá um instinto para identificar oportunidades de otimização, mas nunca substitua a medição por achismo.
Referências
- GCC: Optimize Options
- Wikipedia: CPU cache
- Agner Fog: Optimizing software in C++ (aplicável a C)
- Linux man page para clock_gettime
- perf wiki (Linux profiling)
- Valgrind Callgrind documentation
- IBM: Understanding stack frames (contexto)
Exercícios
- Exercício 1: Explique a diferença entre os níveis de otimização
-O0e-O3. Cite uma situação em que-O3pode não ser a melhor escolha. - Exercício 2: Escreva um programa que preencha uma matriz 1000x1000 de duas formas: por linhas e por colunas. Compare o tempo de execução com
-O2. Explique por que há diferença. - Exercício 3: Dado o seguinte código que cria uma string para cada iteração, reescreva-o para evitar alocações desnecessárias:
#include <stdio.h> #include <stdlib.h> #include <string.h> void process(const char *s) { // faz algo } int main() { for (int i = 0; i < 100000; i++) { char *tmp = malloc(32); sprintf(tmp, "num_%d", i); process(tmp); free(tmp); } return 0; } - Exercício 4: Usando
clock_gettime, escreva um benchmark que meça o tempo de uma função que soma todos os elementos de um array de 1 milhão de inteiros. Compile com-O0e-O3e compare os tempos. Explique a diferença esperada. - Exercício 5: Pesquise sobre a técnica de loop unrolling. Escreva um pequeno código que some elementos de um array e depois o desenrole manualmente (some 4 elementos por iteração). Meça e compare com a versão simples. Discuta os resultados.
-O0 desativa a otimização, gerando código simples e ideal para depuração, pois variáveis e fluxo de execução são preservados. -O3 ativa todas as otimizações do -O2 e ainda adiciona inlining e vetorização, visando máxima velocidade. Porém, -O3 pode aumentar o tamanho do binário, causar mais cache misses de instrução e, em raros casos, introduzir comportamentos inesperados (ex.: com pontos flutuantes). Portanto, se o tamanho do código é crítico (embarcados) ou se a estabilidade é prioridade, -O2 pode ser mais seguro.
#include <stdio.h>
#include <time.h>
#define N 1000
int main() {
static int mat[N][N];
struct timespec start, end;
// Preenchimento por linhas
clock_gettime(CLOCK_MONOTONIC, &start);
for (int i = 0; i < N; i++)
for (int j = 0; j < N; j++)
mat[i][j] = i + j;
clock_gettime(CLOCK_MONOTONIC, &end);
long long t_linhas = (end.tv_sec - start.tv_sec) * 1000000000LL + (end.tv_nsec - start.tv_nsec);
// Preenchimento por colunas (zerando antes para ser justo)
for (int i = 0; i < N; i++)
for (int j = 0; j < N; j++)
mat[i][j] = 0;
clock_gettime(CLOCK_MONOTONIC, &start);
for (int j = 0; j < N; j++)
for (int i = 0; i < N; i++)
mat[i][j] = i + j;
clock_gettime(CLOCK_MONOTONIC, &end);
long long t_colunas = (end.tv_sec - start.tv_sec) * 1000000000LL + (end.tv_nsec - start.tv_nsec);
printf("Linhas: %lld ns\nColunas: %lld ns\n", t_linhas, t_colunas);
return 0;
}
A diferença ocorre porque o acesso por linhas percorre a memória de forma contígua, aproveitando a localidade espacial e reduzindo cache misses. O acesso por colunas pula entre linhas, causando muitos misses e tornando o processo mais lento.
#include <stdio.h>
#include <string.h>
void process(const char *s) {
// faz algo
}
int main() {
char buffer[32];
for (int i = 0; i < 100000; i++) {
snprintf(buffer, sizeof(buffer), "num_%d", i);
process(buffer);
}
return 0;
}
Isso elimina chamadas a malloc/free, reduzindo overhead e melhorando a localidade.
#include <stdio.h>
#include <time.h>
#define SIZE 1000000
long sum_array(int *arr, int n) {
long sum = 0;
for (int i = 0; i < n; i++) sum += arr[i];
return sum;
}
int main() {
static int arr[SIZE];
for (int i = 0; i < SIZE; i++) arr[i] = i;
struct timespec start, end;
clock_gettime(CLOCK_MONOTONIC, &start);
volatile long result = sum_array(arr, SIZE);
clock_gettime(CLOCK_MONOTONIC, &end);
long long ns = (end.tv_sec - start.tv_sec) * 1000000000LL + (end.tv_nsec - start.tv_nsec);
printf("%lld ns\n", ns);
return 0;
}
Compilando com -O0 e -O3, esperamos que -O3 seja mais rápido, pois o compilador pode otimizar o loop (desenrolar, vetorizar), enquanto -O0 gera código literal. A diferença pode ser de 2 a 4 vezes.
long sum_unrolled(int *arr, int n) {
long sum = 0;
int i;
for (i = 0; i < n - 3; i += 4) {
sum += arr[i] + arr[i+1] + arr[i+2] + arr[i+3];
}
for (; i < n; i++) sum += arr[i];
return sum;
}
O loop unrolling reduz o overhead de controle do loop (comparação e incremento) e pode melhorar o paralelismo. No entanto, com -O3 o compilador já faz isso automaticamente. Medições podem mostrar ganho em -O0, mas em -O2 a diferença tende a ser pequena.