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.
fonte
Respostas:
Culpado: Dependência de Dados Falsos (e o compilador nem está ciente disso)
Nos processadores Sandy / Ivy Bridge e Haswell, a instrução:
parece ter uma dependência falsa no registro de destino
dest
. Mesmo que a instrução apenas escreva nela, a instrução aguardará até quedest
esteja pronta antes de executar. Essa dependência falsa é (agora) documentada pela Intel como errata HSD146 (Haswell) e SKL029 (Skylake)Skylake corrigiu isso para
lzcnt
etzcnt
.O Lago Cannon (e o Ice Lake) resolveu isso
popcnt
.bsf
/bsr
tenho uma dependência de saída verdadeira: saída não modificada para input = 0. (Mas não há como tirar proveito disso com intrínsecos - apenas a AMD documenta e os compiladores não o expõem.)(Sim, todas essas instruções são executadas na mesma unidade de execução ).
Essa dependência não sustenta apenas os 4
popcnt
s de uma iteração de loop único. Ele pode realizar iterações de loop, impossibilitando que o processador paralelize diferentes iterações de loop.O
unsigned
vs.uint64_t
e outros ajustes não afetam diretamente o problema. Mas eles influenciam o alocador de registros que atribui os registros às variáveis.No seu caso, as velocidades são um resultado direto do que está preso à cadeia de dependências (falsa), dependendo do que o alocador de registro decidiu fazer.
popcnt
-add
-popcnt
-popcnt
→ próxima iteraçãopopcnt
-add
-popcnt
-add
→ próxima iteraçãopopcnt
-popcnt
→ próxima iteraçãopopcnt
-popcnt
→ próxima iteraçãoA diferença entre 20 GB / se 26 GB / s parece ser um artefato menor do endereçamento indireto. De qualquer forma, o processador começa a atingir outros gargalos quando você atinge essa velocidade.
Para testar isso, usei o assembly embutido para ignorar o compilador e obter exatamente o assembly que eu quero. Também dividi a
count
variável para quebrar todas as outras dependências que podem interferir nos benchmarks.Aqui estão os resultados:
Sandy Bridge Xeon a 3,5 GHz: (o código completo do teste pode ser encontrado na parte inferior)
g++ popcnt.cpp -std=c++0x -O3 -save-temps -march=native
Registros diferentes: 18,6195 GB / s
Mesmo registro: 8,49272 GB / s
Mesmo registro com corrente quebrada: 17.8869 GB / s
Então, o que deu errado com o compilador?
Parece que nem o GCC nem o Visual Studio sabem que
popcnt
essa dependência é falsa. No entanto, essas dependências falsas não são incomuns. É apenas uma questão de saber se o compilador está ciente disso.popcnt
não é exatamente a instrução mais usada. Portanto, não é realmente uma surpresa que um grande compilador perca algo assim. Também parece não haver documentação em nenhum lugar que mencione esse problema. Se a Intel não divulgar, ninguém lá fora saberá até que alguém o encontre por acaso.( Atualização: A partir da versão 4.9.2 , o GCC reconhece essa falsa dependência e gera código para compensá-la quando as otimizações são ativadas. Os principais compiladores de outros fornecedores, incluindo Clang, MSVC e até o próprio ICC da Intel, ainda não conhecem. esta errata microarquitetural e não emitirá código que a compense.)
Por que a CPU tem uma dependência tão falsa?
Podemos especular: ele é executado na mesma unidade de execução como
bsf
/bsr
que fazer têm uma dependência de saída. ( Como o POPCNT é implementado no hardware? ). Para essas instruções, a Intel documenta o resultado inteiro da entrada = 0 como "indefinido" (com ZF = 1), mas o hardware da Intel realmente oferece uma garantia mais forte para evitar a quebra de software antigo: saída não modificada. A AMD documenta esse comportamento.Presumivelmente, era de alguma forma inconveniente fazer alguns upgrades para esta unidade de execução dependentes da saída, mas outros não.
Os processadores AMD não parecem ter essa falsa dependência.
O código de teste completo está abaixo para referência:
Um benchmark igualmente interessante pode ser encontrado aqui: http://pastebin.com/kbzgL8si
Este benchmark varia o número de
popcnt
s que estão na cadeia de dependência (falsa).fonte
imul dst, src, imm
não tem dependência de saída e nem lentolea
. Nem issopdep
, mas é codificado em VEX com 2 operandos de entrada. Concordou que não é a própria unidade de execução que causa o dep falso; isso depende do estágio RAT e emitir / renomear, pois renomeia os operandos de registro arquitetural em registros físicos. Presumivelmente, ele precisa de uma tabela de código de opção uop -> padrão de dependência e opções de porta, e agrupar todos os uops para a mesma unidade de execução simplifica essa tabela. Foi isso que eu quis dizer com mais detalhes.Codifiquei um programa C equivalente para experimentar e posso confirmar esse comportamento estranho. Além do mais,
gcc
acredita que o número inteiro de 64 bits (que provavelmente deve ser desize_t
qualquer maneira ...) seja melhor, pois o usouint_fast32_t
faz com que o gcc use um uint de 64 bits.Eu pensei um pouco com a montagem:
simplesmente pegue a versão de 32 bits, substitua todas as instruções / registros de 32 bits pela versão de 64 bits no loop de contagem interna do programa. Observação: o código é tão rápido quanto a versão de 32 bits!
Obviamente, isso é um truque, já que o tamanho da variável não é realmente de 64 bits, pois outras partes do programa ainda usam a versão de 32 bits, mas enquanto o loop interno de pop-count dominar o desempenho, esse é um bom começo. .
Em seguida, copiei o código do loop interno da versão de 32 bits do programa, cortei-o em 64 bits, mexi nos registros para substituí-lo pelo loop interno da versão de 64 bits. Esse código também é executado tão rápido quanto a versão de 32 bits.
Minha conclusão é que esse é um mau agendamento de instruções pelo compilador, não a vantagem real de velocidade / latência das instruções de 32 bits.
(Advertência: cortei a montagem, poderia ter quebrado alguma coisa sem perceber. Acho que não.)
fonte
sizeof(uint_fast32_t)
tem que ser definido. Se você permitir que não seja, poderá fazer esse truque, mas isso só pode ser conseguido com uma extensão do compilador.Esta não é uma resposta, mas é difícil de ler se eu colocar resultados em comentários.
Eu obtenho esses resultados com um Mac Pro ( Westmere 6-Cores Xeon 3.33 GHz). Eu o compilei com
clang -O3 -msse4 -lstdc++ a.cpp -o a
(-O2 obtém o mesmo resultado).clang com
uint64_t size=atol(argv[1])<<20;
clang com
uint64_t size=1<<20;
Eu também tentei:
for
declaração em sentido inverso:for (uint64_t i=size/8;i>0;i-=4)
. Isso fornece o mesmo resultado e prova que a compilação é inteligente o suficiente para não dividir o tamanho por 8 a cada iteração (conforme o esperado).Aqui está o meu palpite:
O fator de velocidade vem em três partes:
cache de código: a
uint64_t
versão possui um tamanho de código maior, mas isso não afeta a minha CPU Xeon. Isso torna a versão de 64 bits mais lenta.Instruções utilizadas. Observe não apenas a contagem de loop, mas o buffer é acessado com um índice de 32 e 64 bits nas duas versões. O acesso a um ponteiro com deslocamento de 64 bits solicita um registro e endereçamento dedicados de 64 bits, enquanto você pode usar imediato para um deslocamento de 32 bits. Isso pode tornar a versão de 32 bits mais rápida.
As instruções são emitidas apenas na compilação de 64 bits (ou seja, pré-busca). Isso torna 64 bits mais rápido.
Os três fatores combinam com os resultados aparentemente conflitantes observados.
fonte
12.9
e16.8
, portanto,unsigned
é mais rápido aqui. Na minha referência, o oposto foi o caso, ou seja, 26 paraunsigned
, 15 parauint64_t
Não posso dar uma resposta autorizada, mas forneço uma visão geral de uma causa provável. Essa referência mostra claramente que, para as instruções no corpo do seu loop, há uma proporção de 3: 1 entre latência e taxa de transferência. Também mostra os efeitos do envio múltiplo. Como existem três unidades inteiras nos modernos processadores x86, geralmente é possível enviar três instruções por ciclo.
Portanto, entre o pico do pipeline e o desempenho de múltiplos despachos e a falha desses mecanismos, temos um fator de seis no desempenho. É bem sabido que a complexidade do conjunto de instruções x86 facilita bastante a ocorrência de quebra subtil. O documento acima tem um ótimo exemplo:
Pessoalmente, deparei-me com um caso estranho em que um hot loop era consideravelmente mais lento em um núcleo específico de um chip de quatro núcleos (AMD, se bem me lembro). Na verdade, obtivemos melhor desempenho em um cálculo de redução de mapa desativando esse núcleo.
Aqui, meu palpite é a contenção de unidades inteiras: que os
popcnt
cálculos de contador de loop e endereço mal podem ser executados a toda velocidade com o contador de 32 bits de largura, mas o contador de 64 bits causa contenção e paralisação de pipeline. Como há apenas cerca de 12 ciclos no total, potencialmente 4 ciclos com despacho múltiplo, por execução do corpo do loop, uma única paralisação pode afetar razoavelmente o tempo de execução por um fator de 2.A mudança induzida pelo uso de uma variável estática, que, suponho, causa apenas uma pequena reordenação das instruções, é outra pista de que o código de 32 bits está em algum ponto de inflexão.
Eu sei que essa não é uma análise rigorosa, mas é uma explicação plausível.
fonte
Eu tentei isso com o Visual Studio 2013 Express , usando um ponteiro em vez de um índice, o que acelerou um pouco o processo. Eu suspeito que isso ocorre porque o endereçamento é deslocamento + registro, em vez de deslocamento + registro + (registro << 3). Código C ++.
código de montagem: r10 = bfrptr, r15 = bfrend, rsi = count, rdi = buffer, r13 = k:
fonte
Você já tentou passar
-funroll-loops -fprefetch-loop-arrays
para o GCC?Recebo os seguintes resultados com essas otimizações adicionais:
fonte
Você já tentou mover a etapa de redução para fora do loop? No momento, você tem uma dependência de dados que realmente não é necessária.
Tentar:
Você também tem algum alias estranho, que não tenho certeza de que esteja em conformidade com as regras estritas de alias.
fonte
void*
echar*
são os dois tipos que podem ser alternativos, pois são considerados essencialmente "indicadores de algum pedaço de memória"! Sua ideia sobre a remoção de dependência de dados é boa para otimização, mas não responde à pergunta. E, como diz o @NilsPipenbrinck, isso não parece mudar nada.char*
para acessar aT[]
. Você não pode usar com segurança aT*
para acessar achar[]
e seu código parece fazer isso.malloc
uma variedade de coisas, pois o malloc retornavoid*
e o interpreta comoT[]
. E tenho certeza dissovoid*
echar*
tinha a mesma semântica em relação a aliasing estrito. No entanto, eu acho que isso é bastante offtopic aqui :)uint64_t* buffer = new uint64_t[size/8]; /* type is clearly uint64_t[] */ char* charbuffer=reinterpret_cast<char*>(buffer); /* aliasing a uint64_t[] with char* is safe */
TL; DR: use
__builtin
intrínsecos; eles podem ajudar.Consegui fazer com que o
gcc
4.8.4 (e até o 4.7.3 no gcc.godbolt.org) gerasse um código ideal para isso usando o__builtin_popcountll
que usa a mesma instrução de montagem, mas tem sorte e cria um código que não possui um código inesperado dependência longa transportada por loop devido ao erro de dependência falsa.Não tenho 100% de certeza do meu código de benchmarking, mas a
objdump
saída parece compartilhar meus pontos de vista. Eu uso alguns outros truques (++i
vsi++
) para fazer o compilador desenrolar o loop para mim sem nenhumamovl
instrução (comportamento estranho, devo dizer).Resultados:
Código de benchmarking:
Opções de compilação:
Versão do GCC:
Versão do kernel do Linux:
Informação da CPU:
fonte
-funroll-loops
acaso, crie código que não afunde em uma cadeia de dependências transportada por loop criada pelopopcnt
dep falso. Usar uma versão antiga do compilador que não conhece a falsa dependência é um risco. Sem-funroll-loops
, o loop do gcc 4.8.5 afunilará a latência popcnt em vez da taxa de transferência, porque é importanterdx
. O mesmo código, compilado pelo gcc 4.9.3, adiciona umxor edx,edx
para quebrar a cadeia de dependência.x86intrin.h
as_mm_popcnt_*
funções do GCC são invólucros forçados à volta do__builtin_popcount*
; o inlining deve fazer um exatamente equivalente ao outro. Duvido muito que você veja alguma diferença que possa ser causada pela alternância entre eles.Primeiro, tente estimar o desempenho máximo - examine https://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-optimization-manual.pdf , em particular, apêndice C.
No seu caso, é a tabela C-10 que mostra que a instrução POPCNT tem latência = 3 relógios e taxa de transferência = 1 relógio. A taxa de transferência mostra sua taxa máxima em relógios (multiplique pela freqüência principal e 8 bytes no caso de popcnt64 para obter o melhor número possível de largura de banda).
Agora examine o que o compilador fez e some as taxas de transferência de todas as outras instruções no loop. Isso fornecerá a melhor estimativa possível para o código gerado.
Por fim, observe as dependências de dados entre as instruções no loop, pois elas forçarão um atraso grande na latência em vez da taxa de transferência - portanto, divida as instruções de iteração única nas cadeias de fluxo de dados e calcule a latência entre elas e, ingenuamente, obtenha o máximo delas. fornecerá uma estimativa aproximada levando em consideração as dependências do fluxo de dados.
No entanto, no seu caso, apenas escrever o código da maneira correta eliminaria todas essas complexidades. Em vez de acumular para a mesma variável de contagem, apenas acumule para diferentes (como count0, count1, ... count8) e some-as no final. Ou até mesmo criar uma matriz de contagens [8] e acumular para seus elementos - talvez seja vetorizada mesmo e você obtenha uma taxa de transferência muito melhor.
PS e nunca execute benchmark por um segundo, primeiro aqueça o núcleo e depois execute o loop por pelo menos 10 segundos ou mais 100 segundos. caso contrário, você testará o firmware de gerenciamento de energia e a implementação do DVFS no hardware :)
PPS Eu ouvi debates intermináveis sobre quanto tempo o benchmark realmente deveria ser executado. A maioria das pessoas mais inteligentes está perguntando por que 10 segundos, não 11 ou 12. Devo admitir que isso é engraçado em teoria. Na prática, basta executar o benchmark centenas de vezes seguidas e registrar desvios. Isso é engraçado. A maioria das pessoas muda de fonte e corre depois disso exatamente uma vez para capturar um novo recorde de desempenho. Faça as coisas certas direito.
Ainda não está convencido? Basta usar a versão C do benchmark acima por assp1r1n3 ( https://stackoverflow.com/a/37026212/9706746 ) e tente 100 em vez de 10000 no loop de nova tentativa.
Meus 7960X mostram, com RETRY = 100:
Contagem: 203182300 Decorrido: 0.008385 segundos Velocidade: 12.505379 GB / s
Contagem: 203182300 Decorrido: 0,011063 segundos Velocidade: 9,478225 GB / s
Contagem: 203182300 Decorrido: 0,011188 segundos Velocidade: 9,372327 GB / s
Contagem: 203182300 Decorrido: 0,010393 segundos Velocidade: 10,089252 GB / s
Contagem: 203182300 Decorrido: 0.009076 segundos Velocidade: 11.553283 GB / s
com RETRY = 10000:
Contagem: 20318230000 Decorrido: 0.661791 segundos Velocidade: 15.844519 GB / s
Contagem: 20318230000 Decorrido: 0.665422 segundos Velocidade: 15.758060 GB / s
Contagem: 20318230000 Decorrido: 0.660983 segundos Velocidade: 15.863888 GB / s
Contagem: 20318230000 Decorrido: 0.665337 segundos Velocidade: 15.760073 GB / s
Contagem: 20318230000 Decorrido: 0.662138 segundos Velocidade: 15.836215 GB / s
PPPS Finalmente, em "resposta aceita" e outros mistérios ;-)
Vamos usar a resposta do assp1r1n3 - ele tem um núcleo de 2.5Ghz. O POPCNT possui 1 relógio através da saída, seu código está usando o popcnt de 64 bits. Portanto, a matemática é de 2,5 Ghz * 1 relógio * 8 bytes = 20 GB / s para sua configuração. Ele está vendo 25Gb / s, talvez devido ao aumento do turbo em torno de 3Ghz.
Portanto, acesse ark.intel.com e procure o i7-4870HQ: https://ark.intel.com/products/83504/Intel-Core-i7-4870HQ-Processor-6M-Cache-up-to-3-70 -GHz-? Q = i7-4870HQ
Esse núcleo pode rodar até 3,7 Ghz e a taxa máxima real é de 29,6 GB / s para seu hardware. Então, onde estão outros 4 GB / s? Talvez seja gasto com lógica de loop e outro código circundante em cada iteração.
Agora, onde está essa falsa dependência? o hardware é executado quase na taxa de pico. Talvez minha matemática esteja ruim, às vezes acontece :)
PPPPPS Ainda as pessoas que sugerem erratas de HW são as culpadas, por isso sigo as sugestões e criei o exemplo inline asm, veja abaixo.
No meu 7960X, a primeira versão (com saída única para cnt0) é executada em 11MB / s, a segunda versão (com saída para cnt0, cnt1, cnt2 e cnt3) é executada em 33MB / s. E pode-se dizer - voila! é dependência de saída.
OK, talvez, o argumento que afirmei é que não faz sentido escrever código como este e não é problema de dependência de saída, mas geração de código estúpido. Não estamos testando hardware, estamos escrevendo código para liberar o desempenho máximo. Você pode esperar que o HW OOO renomeie e oculte essas "dependências de saída", mas, corte, faça as coisas certas corretamente e você nunca enfrentará nenhum mistério.
fonte
__builtin_popcountl
com o AVX2vpshufb
e não precisa de vários acumuladores na fonte C para fazer isso. Não tenho certeza_mm_popcnt_u64
; que só podem se auto-vetorizar com AVX512-VPOPCNT. (Veja Counting bits 1 (contagem da população) em grandes volumes de dados usando AVX-512 ou AVX-2 /)popcnt
. Isso está documentado nas erratas da Intel para algumas de suas microarquiteturas recentes, mas acho que não estava na época. Sua análise dep-chain falhará se houver inesperadas dependências falsas, portanto, essa resposta é um bom conselho genérico, mas não aplicável aqui.lzcnt
/tzcnt
, mas não parapopcnt
. Veja SKL029 errata da Intel em intel.com/content/dam/www/public/us/en/documents/... . Além disso, gcc.gnu.org/bugzilla/show_bug.cgi?id=62011 é "resolvido corrigido", não "inválido". Não há base para sua alegação de que não há dependência de saída no HW.popcnt eax, edx
/dec ecx / jnz
, esperaria que ele fosse executado a 1 por relógio, com gargalo na taxa de transferência popcnt e na taxa de transferência de ramificação obtida. Mas na verdade, ele é executado apenas em 1 por 3 relógios com gargalo napopcnt
latência para substituir repetidamente o EAX, mesmo que você esperasse que fosse somente gravação. Você tem um Skylake, então você pode tentar você mesmo.Ok, quero fornecer uma pequena resposta para uma das sub-perguntas que o OP fez e que não parecem ser abordadas nas perguntas existentes. Advertência, eu não fiz nenhum teste, geração ou desmontagem de código, só queria compartilhar um pensamento para que outros possam expor.
Por que isso
static
altera o desempenho?A linha em questão:
uint64_t size = atol(argv[1])<<20;
Resposta curta
Eu examinaria o assembly gerado para acessar
size
e veria se há etapas extras de indireto de ponteiro envolvidas para a versão não estática.Resposta longa
Como existe apenas uma cópia da variável, declarada
static
ou não, e o tamanho não muda, teorizo que a diferença é o local da memória usada para fazer backup da variável e o local em que ela é usada no código. baixa.Ok, para começar com o óbvio, lembre-se de que todas as variáveis locais (junto com os parâmetros) de uma função recebem espaço na pilha para uso como armazenamento. Agora, obviamente, o quadro de pilha para main () nunca limpa e é gerado apenas uma vez. Ok, que tal fazer isso
static
? Bem, nesse caso, o compilador sabe reservar espaço no espaço de dados global do processo para que o local não possa ser limpo com a remoção de um quadro de pilha. Mas ainda assim, temos apenas um local, então qual é a diferença? Suspeito que tenha a ver com a referência de locais de memória na pilha.Quando o compilador está gerando a tabela de símbolos, ele apenas faz uma entrada para um rótulo junto com atributos relevantes, como tamanho, etc. Ele sabe que deve reservar o espaço apropriado na memória, mas na verdade não escolhe esse local até um pouco mais tarde. depois de fazer uma análise de vivacidade e possivelmente registrar a alocação. Como, então, o vinculador sabe qual endereço fornecer ao código de máquina para o código de montagem final? Ele conhece o local final ou sabe como chegar ao local. Com uma pilha, é bastante simples se referir a um local com base em dois elementos, o ponteiro para o quadro da pilha e depois um deslocamento no quadro. Isso ocorre basicamente porque o vinculador não pode saber a localização do stackframe antes do tempo de execução.
fonte
static
alterasse a alocação do registro para a função de uma maneira que afetasse a falsa dependência de saída daspopcnt
CPUs Intel em que o OP estava testando, com um compilador que não sabia evitá-las. (Como esse buraco de desempenho nas CPUs Intel ainda não havia sido descoberto.) Um compilador pode manter umastatic
variável local em um registro, assim como uma variável de armazenamento automático, mas se elas não otimizarem, assumindo quemain
apenas sejam executadas uma vez, isso afetará código-gen (porque o valor é definido por apenas a primeira chamada.)[RIP + rel32]
e[rsp + 42]
endereçamento é bastante insignificante para a maioria dos casos.cmp dword [RIP+rel32], immediate
não posso micro-fusível em uma única carga + cmp uop, mas não acho que isso seja um fator. Como eu disse, os loops internos provavelmente permanecem em um registro, mas ajustar o C ++ pode significar diferentes opções do compilador.