Respondendo a outra pergunta do Stack Overflow ( esta ), deparei-me com um sub-problema interessante. Qual é a maneira mais rápida de classificar uma matriz de 6 números inteiros?
Como a pergunta é de nível muito baixo:
- não podemos assumir que as bibliotecas estão disponíveis (e a chamada em si tem seu custo), apenas C simples
- para evitar esvaziar o pipeline de instruções (que tem um custo muito alto), provavelmente devemos minimizar desvios, saltos e todos os outros tipos de interrupção do fluxo de controle (como aqueles ocultos atrás dos pontos de sequência em
&&
ou||
). - o espaço é restrito e a minimização de registros e o uso da memória são um problema; a classificação ideal é provavelmente a melhor.
Realmente, essa pergunta é um tipo de golfe em que o objetivo não é minimizar o comprimento da fonte, mas o tempo de execução. Eu chamo de código 'Zening', como usado no título do livro Zen of Code optimization, de Michael Abrash e suas sequências .
Por que é interessante, existem várias camadas:
- o exemplo é simples e fácil de entender e medir, não há muita habilidade em C envolvida
- mostra efeitos de escolha de um bom algoritmo para o problema, mas também efeitos do compilador e do hardware subjacente.
Aqui está minha implementação de referência (ingênua, não otimizada) e meu conjunto de testes.
#include <stdio.h>
static __inline__ int sort6(int * d){
char j, i, imin;
int tmp;
for (j = 0 ; j < 5 ; j++){
imin = j;
for (i = j + 1; i < 6 ; i++){
if (d[i] < d[imin]){
imin = i;
}
}
tmp = d[j];
d[j] = d[imin];
d[imin] = tmp;
}
}
static __inline__ unsigned long long rdtsc(void)
{
unsigned long long int x;
__asm__ volatile (".byte 0x0f, 0x31" : "=A" (x));
return x;
}
int main(int argc, char ** argv){
int i;
int d[6][5] = {
{1, 2, 3, 4, 5, 6},
{6, 5, 4, 3, 2, 1},
{100, 2, 300, 4, 500, 6},
{100, 2, 3, 4, 500, 6},
{1, 200, 3, 4, 5, 600},
{1, 1, 2, 1, 2, 1}
};
unsigned long long cycles = rdtsc();
for (i = 0; i < 6 ; i++){
sort6(d[i]);
/*
* printf("d%d : %d %d %d %d %d %d\n", i,
* d[i][0], d[i][6], d[i][7],
* d[i][8], d[i][9], d[i][10]);
*/
}
cycles = rdtsc() - cycles;
printf("Time is %d\n", (unsigned)cycles);
}
Resultados brutos
À medida que o número de variantes aumenta, reuni todas em uma suíte de testes que pode ser encontrada aqui . Os testes atuais usados são um pouco menos ingênuos do que os mostrados acima, graças a Kevin Stock. Você pode compilá-lo e executá-lo em seu próprio ambiente. Estou bastante interessado no comportamento em diferentes arquiteturas / compiladores de destino. (OK pessoal, coloque isso em respostas, adicionarei +1 a cada colaborador de um novo conjunto de resultados).
Dei a resposta a Daniel Stutzbach (para jogar golfe) há um ano, pois ele estava na fonte da solução mais rápida na época (redes de classificação).
Linux 64 bits, gcc 4.6.1 64 bits, Intel Core 2 Duo E8400, -O2
- Chamada direta à função da biblioteca qsort: 689.38
- Implementação ingênua (tipo de inserção): 285,70
- Classificação de inserção (Daniel Stutzbach): 142.12
- Classificação de inserção desenrolada: 125.47
- Ordem de classificação: 102.26
- Ordem de classificação com registros: 58.03
- Redes de classificação (Daniel Stutzbach): 111.68
- Redes de classificação (Paul R): 66,36
- Classificação de redes 12 com troca rápida: 58,86
- Redes de classificação 12 reordenadas Swap: 53.74
- Redes de classificação 12 reorganizadas Simple Swap: 31.54
- Rede de classificação reordenada com troca rápida: 31,54
- Rede de classificação reordenada com troca rápida V2: 33.63
- Classificação de bolha embutida (Paolo Bonzini): 48,85
- Classificação de inserção desenrolada (Paolo Bonzini): 75,30
Linux 64 bits, gcc 4.6.1 64 bits, Intel Core 2 Duo E8400, -O1
- Chamada direta à função da biblioteca qsort: 705.93
- Implementação ingênua (classificação por inserção): 135,60
- Classificação de inserção (Daniel Stutzbach): 142.11
- Classificação de inserção desenrolada: 126.75
- Ordem de classificação: 46.42
- Ordem de classificação com registros: 43,58
- Redes de classificação (Daniel Stutzbach): 115.57
- Redes de classificação (Paul R): 64,44
- Classificando redes 12 com troca rápida: 61,98
- Redes de classificação 12 reordenadas Swap: 54.67
- Redes de classificação 12 reorganizadas Simple Swap: 31.54
- Rede de classificação reordenada com troca rápida: 31,24
- Rede de classificação reordenada com troca rápida V2: 33.07
- Classificação de bolha embutida (Paolo Bonzini): 45,79
- Classificação de inserção desenrolada (Paolo Bonzini): 80.15
Incluí os resultados -O1 e -O2 porque, surpreendentemente, para vários programas, o O2 é menos eficiente que o O1. Gostaria de saber que otimização específica tem esse efeito?
Comentários sobre soluções propostas
Classificação de inserção (Daniel Stutzbach)
Como esperado, minimizar ramos é realmente uma boa ideia.
Redes de classificação (Daniel Stutzbach)
Melhor que a classificação por inserção. Eu me perguntava se o efeito principal não era o de evitar o loop externo. Eu tentei por tipo de inserção desenrolado para verificar e, de fato, obtemos aproximadamente as mesmas figuras (o código está aqui ).
Redes de classificação (Paul R)
O melhor até agora. O código real que eu usei para testar é aqui . Ainda não sabemos por que é quase duas vezes mais rápido que a outra implementação de rede de classificação. Passagem de parâmetro? Max rápido?
Classificação de redes 12 SWAP com troca rápida
Conforme sugerido por Daniel Stutzbach, combinei sua rede de classificação de 12 trocas com a troca rápida sem ramificação (o código é aqui ). É realmente mais rápido, o melhor até agora com uma pequena margem (aproximadamente 5%), como seria de esperar usando 1 swap a menos.
Também é interessante notar que a troca sem ramificação parece ser muito (4 vezes) menos eficiente do que a simples usando na arquitetura PPC.
Biblioteca de chamadas qsort
Para dar outro ponto de referência, também tentei, como sugerido, chamar a biblioteca qsort (o código está aqui ). Como esperado, é muito mais lento: 10 a 30 vezes mais lento ... como ficou óbvio com o novo conjunto de testes, o principal problema parece ser o carregamento inicial da biblioteca após a primeira chamada, e não se compara tão mal com outros versão. É apenas entre 3 e 20 vezes mais lento no meu Linux. Em algumas arquiteturas usadas para testes por outras pessoas, parece até mais rápido (estou realmente surpreso com essa, pois a biblioteca qsort usa uma API mais complexa).
Ordem de classificação
Rex Kerr propôs outro método completamente diferente: para cada item da matriz, calcule diretamente sua posição final. Isso é eficiente porque a ordem de classificação da computação não precisa de ramificação. A desvantagem desse método é que ele leva três vezes a quantidade de memória da matriz (uma cópia da matriz e variáveis para armazenar ordens de classificação). Os resultados do desempenho são muito surpreendentes (e interessantes). Na minha arquitetura de referência com sistema operacional de 32 bits e Intel Core2 Quad E8300, a contagem de ciclos ficou um pouco abaixo de 1000 (como redes de classificação com troca de ramificação). Mas quando compilado e executado na minha caixa de 64 bits (Intel Core2 Duo), teve um desempenho muito melhor: tornou-se o mais rápido até agora. Eu finalmente descobri a verdadeira razão. Minha caixa de 32 bits usa o gcc 4.4.1 e minha caixa de 64 bits gcc 4.4.
update :
Como as figuras publicadas acima mostram esse efeito ainda foi aprimorado pelas versões posteriores do gcc e o Rank Order se tornou consistentemente duas vezes mais rápido do que qualquer outra alternativa.
Classificando redes 12 com Swap reordenado
A incrível eficiência da proposta Rex Kerr com o gcc 4.4.3 me fez pensar: como um programa com uso de memória três vezes maior pode ser mais rápido que as redes de classificação sem ramificação? Minha hipótese era que ele tinha menos dependências do tipo lido após gravação, permitindo um melhor uso do agendador de instruções superescalares do x86. Isso me deu uma idéia: reorganizar trocas para minimizar as dependências de leitura após gravação. Em outras palavras: quando você SWAP(1, 2); SWAP(0, 2);
precisa aguardar a conclusão da primeira troca antes de executar a segunda, porque ambos acessam uma célula de memória comum. Quando você faz SWAP(1, 2); SWAP(4, 5);
o processador pode executar os dois em paralelo. Eu tentei e funciona como esperado, as redes de classificação estão executando 10% mais rápido.
Classificando redes 12 com troca simples
Um ano após o post original sugerido por Steinar H. Gunderson, não devemos tentar enganar o compilador e manter o código de troca simples. É realmente uma boa ideia, pois o código resultante é cerca de 40% mais rápido! Ele também propôs uma troca otimizada manualmente usando o código de montagem em linha x86 que ainda pode poupar mais alguns ciclos. O mais surpreendente (diz volumes sobre a psicologia do programador) é que, um ano atrás, nenhum dos usados tentou essa versão do swap. O código que eu costumava testar está aqui . Outros sugeriram outras maneiras de escrever uma troca rápida C, mas produz as mesmas performances que a simples com um compilador decente.
O "melhor" código é agora o seguinte:
static inline void sort6_sorting_network_simple_swap(int * d){
#define min(x, y) (x<y?x:y)
#define max(x, y) (x<y?y:x)
#define SWAP(x,y) { const int a = min(d[x], d[y]); \
const int b = max(d[x], d[y]); \
d[x] = a; d[y] = b; }
SWAP(1, 2);
SWAP(4, 5);
SWAP(0, 2);
SWAP(3, 5);
SWAP(0, 1);
SWAP(3, 4);
SWAP(1, 4);
SWAP(0, 3);
SWAP(2, 5);
SWAP(1, 3);
SWAP(2, 4);
SWAP(2, 3);
#undef SWAP
#undef min
#undef max
}
Se acreditarmos que nosso conjunto de testes (e, sim, é muito ruim, seu benefício é curto, simples e fácil de entender o que estamos medindo), o número médio de ciclos do código resultante para um tipo é inferior a 40 ciclos ( 6 testes são executados). Isso coloca cada troca em uma média de 4 ciclos. Eu chamo isso incrivelmente rápido. Quaisquer outras melhorias possíveis?
__asm__ volatile (".byte 0x0f, 0x31; shlq $32, %%rdx; orq %%rdx, %0" : "=a" (x) : : "rdx");
porque o rdtsc coloca a resposta no EDX: EAX enquanto o GCC espera isso em um único registro de 64 bits. Você pode ver o bug compilando em -O3. Veja também abaixo meu comentário a Paul R sobre um SWAP mais rápido.
CMP EAX, EBX; SBB EAX, EAX
colocará 0 ou 0xFFFFFFFF EAX
dependendo se EAX
é maior ou menor que EBX
, respectivamente. SBB
é "subtrair com empréstimo", a contrapartida de ADC
("adicionar com transporte"); o bit de status a que você se refere é o bit de transporte. Então, lembro-me disso ADC
e SBB
tinha uma latência e taxa de transferência terríveis no Pentium 4 vs. ADD
e SUB
, e ainda era duas vezes mais lento nas CPUs Core. Desde o 80386, também existem instruções de SETcc
armazenamento CMOVcc
condicional e movimentação condicional, mas também são lentas.
x-y
ex+y
não irá causar underflow ou estouro?