Eu estava procurando o caminho mais rápido para popcount
grandes matrizes de dados. Eu encontrei um efeito muito estranho : alterar a variável de loop de unsigned
para uint64_t
reduzir o desempenho em 50% no meu PC.
O benchmark
#include <iostream>
#include <chrono>
#include <x86intrin.h>
int main(int argc, char* argv[]) {
using namespace std;
if (argc != 2) {
cerr << "usage: array_size in MB" << endl;
return -1;
}
uint64_t size = atol(argv[1])<<20;
uint64_t* buffer = new uint64_t[size/8];
char* charbuffer = reinterpret_cast<char*>(buffer);
for (unsigned i=0; i<size; ++i)
charbuffer[i] = rand()%256;
uint64_t count,duration;
chrono::time_point<chrono::system_clock> startP,endP;
{
startP = chrono::system_clock::now();
count = 0;
for( unsigned k = 0; k < 10000; k++){
// Tight unrolled loop with unsigned
for (unsigned i=0; i<size/8; i+=4) {
count += _mm_popcnt_u64(buffer[i]);
count += _mm_popcnt_u64(buffer[i+1]);
count += _mm_popcnt_u64(buffer[i+2]);
count += _mm_popcnt_u64(buffer[i+3]);
}
}
endP = chrono::system_clock::now();
duration = chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
cout << "unsigned\t" << count << '\t' << (duration/1.0E9) << " sec \t"
<< (10000.0*size)/(duration) << " GB/s" << endl;
}
{
startP = chrono::system_clock::now();
count=0;
for( unsigned k = 0; k < 10000; k++){
// Tight unrolled loop with uint64_t
for (uint64_t i=0;i<size/8;i+=4) {
count += _mm_popcnt_u64(buffer[i]);
count += _mm_popcnt_u64(buffer[i+1]);
count += _mm_popcnt_u64(buffer[i+2]);
count += _mm_popcnt_u64(buffer[i+3]);
}
}
endP = chrono::system_clock::now();
duration = chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
cout << "uint64_t\t" << count << '\t' << (duration/1.0E9) << " sec \t"
<< (10000.0*size)/(duration) << " GB/s" << endl;
}
free(charbuffer);
}
Como você vê, criamos um buffer de dados aleatórios, com o tamanho em x
megabytes, onde x
é lido na linha de comando. Depois, iteramos sobre o buffer e usamos uma versão desenrolada do x86 popcount
intrínseco para executar o popcount. Para obter um resultado mais preciso, fazemos a contagem 10.000 vezes. Medimos os tempos da contagem pop-up. Na maiúscula, a variável do loop interno é unsigned
, na minúscula, a variável do loop interno é uint64_t
. Eu pensei que isso não faria diferença, mas o oposto é o caso.
Os resultados (absolutamente loucos)
Eu compilei assim (versão g ++: Ubuntu 4.8.2-19ubuntu1):
g++ -O3 -march=native -std=c++11 test.cpp -o test
Aqui estão os resultados da minha CPU Haswell Core i7-4770K a 3,50 GHz, executando test 1
(portanto, 1 MB de dados aleatórios):
- não assinado 41959360000 0,401554 seg 26,113 GB / s
- uint64_t 41959360000 0,759822 seg 13,8003 GB / s
Como você vê, a taxa de transferência da uint64_t
versão é apenas metade da unsigned
versão! O problema parece ser que uma montagem diferente é gerada, mas por quê? Primeiro, pensei em um bug do compilador, então tentei clang++
(Ubuntu Clang versão 3.4-1ubuntu3):
clang++ -O3 -march=native -std=c++11 teest.cpp -o test
Resultado: test 1
- não assinado 41959360000 0,398293 seg 26,3267 GB / s
- uint64_t 41959360000 0.680954 seg 15.3986 GB / s
Portanto, é quase o mesmo resultado e ainda é estranho. Mas agora fica super estranho. Substituo o tamanho do buffer que foi lido da entrada por uma constante 1
, então altero:
uint64_t size = atol(argv[1]) << 20;
para
uint64_t size = 1 << 20;
Assim, o compilador agora conhece o tamanho do buffer em tempo de compilação. Talvez possa adicionar algumas otimizações! Aqui estão os números para g++
:
- não assinado 41959360000 0,509156 seg 20,5944 GB / s
- uint64_t 41959360000 0.508673 seg 20.6139 GB / s
Agora, ambas as versões são igualmente rápidas. No entanto, o unsigned
ficou ainda mais lento ! Ele caiu de 26
para 20 GB/s
, substituindo assim um não-constante por um valor constante, levando a uma desoptimização . Sério, eu não tenho idéia do que está acontecendo aqui! Mas agora clang++
com a nova versão:
- não assinado 41959360000 0,677009 seg 15,4884 GB / s
- uint64_t 41959360000 0.676909 seg 15.4906 GB / s
Espere o que? Agora, as duas versões caíram para o número lento de 15 GB / s. Assim, a substituição de uma não-constante por um valor constante pode levar a um código lento nos dois casos para o Clang!
Pedi a um colega com uma CPU Ivy Bridge para compilar meu benchmark. Ele obteve resultados semelhantes, por isso não parece ser Haswell. Como dois compiladores produzem resultados estranhos aqui, também não parece ser um bug do compilador. Não temos uma CPU AMD aqui, portanto só poderíamos testar com a Intel.
Mais loucura, por favor!
Pegue o primeiro exemplo (aquele com atol(argv[1])
) e coloque a static
antes da variável, ou seja:
static uint64_t size=atol(argv[1])<<20;
Aqui estão meus resultados em g ++:
- não assinado 41959360000 0,396728 seg 26,4306 GB / s
- uint64_t 41959360000 0.509484 seg 20.5811 GB / s
Sim, mais uma alternativa . Ainda temos os rápidos 26 GB / s u32
, mas conseguimos passar u64
pelo menos da versão de 13 GB / s para 20 GB / s! No PC do meu colega, a u64
versão se tornou ainda mais rápida que a u32
versão, produzindo o resultado mais rápido de todos. Infelizmente, isso só funciona g++
, clang++
não parece se importar static
.
Minha pergunta
Você pode explicar esses resultados? Especialmente:
- Como pode haver essa diferença entre
u32
eu64
? - Como a substituição de um não-constante por um tamanho de buffer constante pode desencadear um código menos ideal ?
- Como a inserção da
static
palavra-chave podeu64
acelerar o loop? Ainda mais rápido que o código original no computador do meu colega!
Sei que a otimização é um território complicado, no entanto, nunca pensei que essas pequenas mudanças possam levar a uma diferença de 100% no tempo de execução e que pequenos fatores, como um tamanho constante do buffer, podem novamente misturar totalmente os resultados. Claro, eu sempre quero ter a versão capaz de contabilizar 26 GB / s. A única maneira confiável de pensar é copiar e colar o assembly para este caso e usar o assembly embutido. Esta é a única maneira de me livrar de compiladores que parecem enlouquecer com pequenas mudanças. O que você acha? Existe outra maneira de obter o código com mais desempenho de maneira confiável?
A desmontagem
Aqui está a desmontagem dos vários resultados:
Versão de 26 GB / s do bu ++ tamanho g ++ / u32 / non-const :
0x400af8:
lea 0x1(%rdx),%eax
popcnt (%rbx,%rax,8),%r9
lea 0x2(%rdx),%edi
popcnt (%rbx,%rcx,8),%rax
lea 0x3(%rdx),%esi
add %r9,%rax
popcnt (%rbx,%rdi,8),%rcx
add $0x4,%edx
add %rcx,%rax
popcnt (%rbx,%rsi,8),%rcx
add %rcx,%rax
mov %edx,%ecx
add %rax,%r14
cmp %rbp,%rcx
jb 0x400af8
Versão de 13 GB / s do bufsize g ++ / u64 / non-const :
0x400c00:
popcnt 0x8(%rbx,%rdx,8),%rcx
popcnt (%rbx,%rdx,8),%rax
add %rcx,%rax
popcnt 0x10(%rbx,%rdx,8),%rcx
add %rcx,%rax
popcnt 0x18(%rbx,%rdx,8),%rcx
add $0x4,%rdx
add %rcx,%rax
add %rax,%r12
cmp %rbp,%rdx
jb 0x400c00
Versão de 15 GB / s do clang ++ / u64 / non-const bufsize :
0x400e50:
popcnt (%r15,%rcx,8),%rdx
add %rbx,%rdx
popcnt 0x8(%r15,%rcx,8),%rsi
add %rdx,%rsi
popcnt 0x10(%r15,%rcx,8),%rdx
add %rsi,%rdx
popcnt 0x18(%r15,%rcx,8),%rbx
add %rdx,%rbx
add $0x4,%rcx
cmp %rbp,%rcx
jb 0x400e50
Versão de 20 GB / s do g ++ / u32 e u64 / const bufsize :
0x400a68:
popcnt (%rbx,%rdx,1),%rax
popcnt 0x8(%rbx,%rdx,1),%rcx
add %rax,%rcx
popcnt 0x10(%rbx,%rdx,1),%rax
add %rax,%rcx
popcnt 0x18(%rbx,%rdx,1),%rsi
add $0x20,%rdx
add %rsi,%rcx
add %rcx,%rbp
cmp $0x100000,%rdx
jne 0x400a68
Versão de 15 GB / s dos clang ++ / u32 e u64 / const bufsize :
0x400dd0:
popcnt (%r14,%rcx,8),%rdx
add %rbx,%rdx
popcnt 0x8(%r14,%rcx,8),%rsi
add %rdx,%rsi
popcnt 0x10(%r14,%rcx,8),%rdx
add %rsi,%rdx
popcnt 0x18(%r14,%rcx,8),%rbx
add %rdx,%rbx
add $0x4,%rcx
cmp $0x20000,%rcx
jb 0x400dd0
Curiosamente, a versão mais rápida (26 GB / s) também é a mais longa! Parece ser a única solução que usa lea
. Algumas versões usam jb
para pular, outras usam jne
. Além disso, todas as versões parecem comparáveis. Não vejo de onde se origina uma lacuna de desempenho de 100%, mas não sou muito hábil em decifrar montagem. A versão mais lenta (13 GB / s) parece muito curta e boa. Alguém pode explicar isso?
Lições aprendidas
Não importa qual será a resposta para esta pergunta; Aprendi que em loops realmente quentes todos os detalhes podem importar, mesmo detalhes que parecem não ter nenhuma associação com o código quente . Eu nunca pensei sobre que tipo usar para uma variável de loop, mas como você vê uma alteração tão pequena pode fazer uma diferença de 100% ! Até o tipo de armazenamento de um buffer pode fazer uma enorme diferença, como vimos com a inserção da static
palavra - chave na frente da variável size! No futuro, sempre testarei várias alternativas em vários compiladores ao escrever loops realmente apertados e quentes que são cruciais para o desempenho do sistema.
O interessante é também que a diferença de desempenho ainda é tão alta, embora eu já tenha desenrolado o loop quatro vezes. Portanto, mesmo se você desenrolar, ainda poderá sofrer grandes desvios de desempenho. Muito interessante.