Se você acha que uma instrução DIV de 64 bits é uma boa maneira de dividir por dois, não é de admirar que a saída asm do compilador supere seu código escrito à mão, mesmo com -O0
(compile rapidamente, sem otimização extra e armazene / recarregue na memória após / antes de cada instrução C para que um depurador possa modificar variáveis).
Consulte o guia de montagem de otimização da Agner Fog para aprender como escrever asm eficiente. Ele também possui tabelas de instruções e um guia de microarquivos para detalhes específicos de CPUs específicas. Veja também ox86 tag wiki para mais links de perf.
Consulte também esta pergunta mais geral sobre como vencer o compilador com asm escrito à mão: A linguagem assembly embutida é mais lenta que o código C ++ nativo? . TL: DR: sim, se você fizer errado (como esta pergunta).
Normalmente, você pode deixar o compilador fazer o que quer, especialmente se você tentar escrever C ++ que possa compilar com eficiência . Veja também que o assembly é mais rápido que as linguagens compiladas? . Uma das respostas fornece links para esses slides, mostrando como vários compiladores C otimizam algumas funções realmente simples com truques interessantes. CppCon2017 talk of Matt Godbolt “ O que meu compilador fez por mim ultimamente? Desaparafusar a tampa do compilador ”é semelhante.
even:
mov rbx, 2
xor rdx, rdx
div rbx
Na Intel Haswell, div r64
há 36 uops, com uma latência de 32 a 96 ciclos e uma taxa de transferência de um por 21 a 74 ciclos. (Mais os dois uops para configurar o RBX e o zero RDX, mas a execução fora de ordem pode executá-los mais cedo). As instruções de contagem alta de uop como DIV são microcodificadas, o que também pode causar gargalos de front-end. Nesse caso, a latência é o fator mais relevante porque faz parte de uma cadeia de dependência transportada por loop.
shr rax, 1
faz a mesma divisão não assinada: é 1 uop, com latência 1c , e pode executar 2 por ciclo de clock.
Para comparação, a divisão de 32 bits é mais rápida, mas ainda é horrível versus as mudanças. idiv r32
é 9 uops, latência 22-29c e uma por 8-11c de taxa de transferência em Haswell.
Como você pode ver olhando para a -O0
saída asm do gcc ( Godbolt compiler explorer ), ele usa apenas instruções de turnos . O clang -O0
é compilado ingenuamente como você pensou, mesmo usando o IDIV de 64 bits duas vezes. (Ao otimizar, os compiladores usam as duas saídas do IDIV quando a fonte faz uma divisão e o módulo com os mesmos operandos, se eles usam o IDIV)
O GCC não possui um modo totalmente ingênuo; sempre se transforma através do GIMPLE, o que significa que algumas "otimizações" não podem ser desativadas . Isso inclui o reconhecimento da divisão por constante e o uso de turnos (potência de 2) ou um inverso multiplicativo de ponto fixo (não potência de 2) para evitar o IDIV (veja div_by_13
no link acima).
gcc -Os
(optimize por tamanho) faz uso IDIV para divisão não potência de 2, infelizmente, mesmo nos casos em que o código multiplicativo inverso é apenas ligeiramente maior mas muito mais rápido.
Ajudando o compilador
(resumo para este caso: use uint64_t n
)
Primeiro de tudo, é apenas interessante observar a saída otimizada do compilador. ( -O3
) -O0
velocidade é basicamente sem sentido.
Observe sua saída asm (no Godbolt, ou consulte Como remover "ruído" da saída do conjunto GCC / clang? ). Quando o compilador não cria código ideal, em primeiro lugar: escrever sua fonte C / C ++ de uma maneira que guie o compilador a criar um código melhor geralmente é a melhor abordagem . Você precisa conhecer asm e saber o que é eficiente, mas aplica esse conhecimento indiretamente. Os compiladores também são uma boa fonte de idéias: às vezes, o clang faz algo legal, e você pode segurar o gcc para fazer o mesmo: veja esta resposta e o que fiz com o loop não desenrolado no código do @ Veedrac abaixo.)
Essa abordagem é portátil e, em 20 anos, algum compilador futuro poderá compilá-lo para o que for eficiente em hardware futuro (x86 ou não), talvez usando a nova extensão ISA ou a vetorização automática. O x86-64 asm escrito à mão de 15 anos atrás normalmente não seria otimizado para a Skylake. por exemplo, a macro fusão de ramificações e comparações não existia naquela época. O que é ideal agora para asm artesanal para uma microarquitetura pode não ser ideal para outras CPUs atuais e futuras. Os comentários na resposta de @ johnfound discutem as principais diferenças entre o AMD Bulldozer e a Intel Haswell, que têm um grande efeito nesse código. Mas, em teoria, g++ -O3 -march=bdver3
e g++ -O3 -march=skylake
fará a coisa certa. (Or -march=native
.) Ou -mtune=...
apenas para ajustar, sem usar instruções que outras CPUs podem não suportar.
Meu sentimento é que orientar o compilador para asm que é bom para uma CPU atual com a qual você se preocupa não deve ser um problema para futuros compiladores. Eles são esperançosamente melhores do que os compiladores atuais em encontrar maneiras de transformar código e podem encontrar uma maneira que funcione para futuras CPUs. Independentemente disso, o futuro x86 provavelmente não será péssimo em nada que seja bom no x86 atual, e o futuro compilador evitará armadilhas específicas da ASM ao implementar algo como o movimento de dados da sua fonte C, se não encontrar algo melhor.
O manuscrito à mão é uma caixa preta para o otimizador; portanto, a propagação constante não funciona quando o embutimento torna uma entrada uma constante em tempo de compilação. Outras otimizações também são afetadas. Leia https://gcc.gnu.org/wiki/DontUseInlineAsm antes de usar o asm. (E evite asm inline no estilo MSVC: as entradas / saídas precisam passar pela memória, o que aumenta a sobrecarga .)
Nesse caso : o seu n
possui um tipo assinado e o gcc usa a sequência SAR / SHR / ADD que fornece o arredondamento correto. (IDIV e deslocamento aritmético "arredondam" de maneira diferente para entradas negativas, consulte a entrada manual do SAR insn set ref ). (IDK se o gcc tentou e não conseguiu provar que n
não pode ser negativo, ou o quê. O excesso de sinal assinado é um comportamento indefinido, portanto deveria ter sido capaz.)
Você deveria ter usado uint64_t n
, para que ele possa usar apenas SHR. E, portanto, é portátil para sistemas com long
apenas 32 bits (por exemplo, x86-64 Windows).
BTW, a saída asm otimizada do gcc parece muito boa (usando unsigned long n
) : o loop interno em que se alinha main()
faz isso:
# from gcc5.4 -O3 plus my comments
# edx= count=1
# rax= uint64_t n
.L9: # do{
lea rcx, [rax+1+rax*2] # rcx = 3*n + 1
mov rdi, rax
shr rdi # rdi = n>>1;
test al, 1 # set flags based on n%2 (aka n&1)
mov rax, rcx
cmove rax, rdi # n= (n%2) ? 3*n+1 : n/2;
add edx, 1 # ++count;
cmp rax, 1
jne .L9 #}while(n!=1)
cmp/branch to update max and maxi, and then do the next n
O loop interno é sem ramificação e o caminho crítico da cadeia de dependência transportada por loop é:
- LEA de 3 componentes (3 ciclos)
- cmov (2 ciclos em Haswell, 1c em Broadwell ou posterior).
Total: 5 ciclos por iteração, gargalo de latência . A execução fora de ordem cuida de tudo o mais em paralelo com isso (em teoria: eu não testei com contadores de perf para ver se realmente é executado em 5c / iter).
A entrada FLAGS de cmov
(produzida pelo TEST) é mais rápida de produzir do que a entrada RAX (de LEA-> MOV), portanto, não está no caminho crítico.
Da mesma forma, o MOV-> SHR que produz a entrada RDI do CMOV está fora do caminho crítico, porque também é mais rápido que o LEA. O MOV no IvyBridge e, posteriormente, possui latência zero (tratado no momento da renomeação do registro). (Ainda é necessário um uop e um slot no pipeline, por isso não é gratuito, apenas latência zero). O MOV extra na cadeia dep da LEA faz parte do gargalo em outras CPUs.
O cmp / jne também não faz parte do caminho crítico: não é carregado por loop, porque as dependências de controle são tratadas com previsão de ramificação + execução especulativa, diferentemente das dependências de dados no caminho crítico.
Vencendo o compilador
O GCC fez um bom trabalho aqui. Ele poderia salvar um byte de código usando em inc edx
vez deadd edx, 1
, porque ninguém se importa com o P4 e suas dependências falsas para obter instruções de modificação de sinalizador parcial.
Também poderia salvar todas as instruções do MOV, e o TEST: SHR define CF = o bit alterado, para que possamos usar em cmovc
vez de test
/ cmovz
.
### Hand-optimized version of what gcc does
.L9: #do{
lea rcx, [rax+1+rax*2] # rcx = 3*n + 1
shr rax, 1 # n>>=1; CF = n&1 = n%2
cmovc rax, rcx # n= (n&1) ? 3*n+1 : n/2;
inc edx # ++count;
cmp rax, 1
jne .L9 #}while(n!=1)
Veja a resposta de @ johnfound para outro truque inteligente: remova o CMP ramificando-se no resultado da flag de SHR e use-o para CMOV: zero somente se n for 1 (ou 0) para começar. (Curiosidade: SHR com contagem! = 1 em Nehalem ou anterior causa uma parada se você ler os resultados da flag . Foi assim que eles fizeram o single-uop. Porém, a codificação especial shift-1 é boa.)
Evitar o MOV não ajuda com a latência em Haswell ( o MOV do x86 pode ser realmente "gratuito"? Por que não consigo reproduzir isso? ). Ajuda significativamente em CPUs como a Intel pré-IvB e a família AMD Bulldozer, onde o MOV não tem latência zero. As instruções MOV desperdiçadas do compilador afetam o caminho crítico. O LEA complexo e o CMOV do BD são de menor latência (2c e 1c, respectivamente), portanto, é uma fração maior da latência. Além disso, os gargalos na taxa de transferência se tornam um problema, porque ele possui apenas dois canais ALU inteiros. Veja a resposta de @ johnfound , onde ele tem resultados de tempo de uma CPU AMD.
Mesmo em Haswell, essa versão pode ajudar um pouco, evitando alguns atrasos ocasionais em que um UOP não crítico rouba uma porta de execução de uma no caminho crítico, atrasando a execução em 1 ciclo. (Isso é chamado de conflito de recursos). Ele também salva um registro, o que pode ajudar ao fazer vários n
valores em paralelo em um loop intercalado (veja abaixo).
A latência do LEA depende do modo de endereçamento , nas CPUs da família Intel SnB. 3c para 3 componentes ( [base+idx+const]
que requer duas inclusões separadas), mas apenas 1c com 2 ou menos componentes (uma adição). Algumas CPUs (como Core2) fazem até um LEA de 3 componentes em um único ciclo, mas a família SnB não. Pior, a família SnB da Intel padroniza latências para que não haja 2c uops , caso contrário, o LEA de 3 componentes seria apenas 2c como o Bulldozer. (O LEA de 3 componentes também é mais lento na AMD, mas não tanto).
Então, lea rcx, [rax + rax*2]
/ inc rcx
só é latência 2c, mais rápido do que lea rcx, [rax + rax*2 + 1]
, na Intel CPUs SNB-família como Haswell. Equilíbrio no BD, e pior no Core2. Custa um uop extra, o que normalmente não vale a pena economizar 1c de latência, mas a latência é o principal gargalo aqui e o Haswell possui um pipeline amplo o suficiente para lidar com o throughput extra do uop.
Nem o gcc, o icc nem o clang (no godbolt) usavam a saída CF do SHR, sempre usando um AND ou TEST . Compiladores tolos. : P São ótimas peças de maquinaria complexa, mas um ser humano inteligente pode vencê-las em problemas de pequena escala. (Dado milhares a milhões de vezes mais para pensar nisso, é claro! Os compiladores não usam algoritmos exaustivos para procurar todas as formas possíveis de fazer as coisas, porque isso levaria muito tempo para otimizar muito código embutido, que é o que eles também fazem o melhor. Eles também não modelam o pipeline na microarquitetura de destino, pelo menos não nos mesmos detalhes da IACA ou de outras ferramentas de análise estática; eles apenas usam algumas heurísticas.)
O desenrolar simples do loop não ajuda ; esse gargalo de loop na latência de uma cadeia de dependência transportada por loop, não na sobrecarga / taxa de transferência do loop. Isso significa que funcionaria bem com o hyperthreading (ou qualquer outro tipo de SMT), pois a CPU tem muito tempo para intercalar instruções de dois threads. Isso significaria paralelizar o loop main
, mas tudo bem, porque cada thread pode apenas verificar um intervalo de n
valores e produzir um par de números inteiros como resultado.
A intercalação manual em um único encadeamento também pode ser viável . Talvez calcule a sequência para um par de números em paralelo, já que cada um recebe apenas alguns registros e todos podem atualizar o mesmo max
/ maxi
. Isso cria mais paralelismo no nível da instrução .
O truque é decidir se deve esperar até que todos os n
valores tenham atingido 1
antes de obter outro par de n
valores iniciais ou se deve sair e obter um novo ponto de partida para apenas um que atingiu a condição final, sem tocar nos registros da outra sequência. Provavelmente, é melhor manter cada cadeia trabalhando em dados úteis, caso contrário você teria que aumentar condicionalmente seu contador.
Talvez você possa até fazer isso com o material de comparação compactada do SSE para incrementar condicionalmente o contador de elementos vetoriais que n
ainda não haviam atingido 1
. E para ocultar a latência ainda mais longa de uma implementação de incremento condicional SIMD, você precisará manter mais vetores de n
valores no ar. Talvez valha apenas com o vetor 256b (4x uint64_t
).
Eu acho que a melhor estratégia para detectar um 1
"adesivo" é mascarar o vetor de todos os que você adiciona para aumentar o contador. Então, depois de ver um 1
em um elemento, o vetor de incremento terá um zero e + = 0 é um não-op.
Ideia não testada para vetorização manual
# starting with YMM0 = [ n_d, n_c, n_b, n_a ] (64-bit elements)
# ymm4 = _mm256_set1_epi64x(1): increment vector
# ymm5 = all-zeros: count vector
.inner_loop:
vpaddq ymm1, ymm0, xmm0
vpaddq ymm1, ymm1, xmm0
vpaddq ymm1, ymm1, set1_epi64(1) # ymm1= 3*n + 1. Maybe could do this more efficiently?
vprllq ymm3, ymm0, 63 # shift bit 1 to the sign bit
vpsrlq ymm0, ymm0, 1 # n /= 2
# FP blend between integer insns may cost extra bypass latency, but integer blends don't have 1 bit controlling a whole qword.
vpblendvpd ymm0, ymm0, ymm1, ymm3 # variable blend controlled by the sign bit of each 64-bit element. I might have the source operands backwards, I always have to look this up.
# ymm0 = updated n in each element.
vpcmpeqq ymm1, ymm0, set1_epi64(1)
vpandn ymm4, ymm1, ymm4 # zero out elements of ymm4 where the compare was true
vpaddq ymm5, ymm5, ymm4 # count++ in elements where n has never been == 1
vptest ymm4, ymm4
jnz .inner_loop
# Fall through when all the n values have reached 1 at some point, and our increment vector is all-zero
vextracti128 ymm0, ymm5, 1
vpmaxq .... crap this doesn't exist
# Actually just delay doing a horizontal max until the very very end. But you need some way to record max and maxi.
Você pode e deve implementar isso com intrínsecas, em vez de asm escrito à mão.
Melhoria algorítmica / implementação:
Além de apenas implementar a mesma lógica com asm mais eficiente, procure maneiras de simplificar a lógica ou evitar trabalhos redundantes. por exemplo, memorize para detectar finais comuns para seqüências. Ou melhor ainda, veja 8 bits finais de uma só vez (resposta de gnasher)
O @EOF ressalta que tzcnt
(ou bsf
) pode ser usado para fazer várias n/=2
iterações em uma etapa. Provavelmente é melhor do que a vetorização do SIMD; nenhuma instrução SSE ou AVX pode fazer isso. Ainda é compatível com a realização de vários escalares n
s em paralelo em diferentes registros inteiros.
Portanto, o loop pode ficar assim:
goto loop_entry; // C++ structured like the asm, for illustration only
do {
n = n*3 + 1;
loop_entry:
shift = _tzcnt_u64(n);
n >>= shift;
count += shift;
} while(n != 1);
Isso pode fazer muito menos iterações, mas as mudanças na contagem de variáveis são lentas nas CPUs da família Intel SnB sem BMI2. 3 uops, latência 2c. (Eles têm uma dependência de entrada no FLAGS porque count = 0 significa que os sinalizadores não são modificados. Eles lidam com isso como uma dependência de dados e fazem vários uops porque um uop pode ter apenas 2 entradas (de qualquer maneira pré-HSW / BDW)). É desse tipo que as pessoas que reclamam do design louco-CISC do x86 estão se referindo. Isso torna as CPUs x86 mais lentas do que seriam se o ISA fosse projetado do zero hoje, mesmo de maneira semelhante. (isto é, faz parte do "imposto x86" que custa velocidade / potência.) SHRX / SHLX / SARX (IMC2) são uma grande vitória (1 uop / 1c de latência).
Ele também coloca tzcnt (3c em Haswell e posterior) no caminho crítico, aumentando significativamente a latência total da cadeia de dependência transportada por loop. No entanto, remove qualquer necessidade de um CMOV ou de preparar uma retenção de registro n>>1
. A resposta do @ Veedrac supera tudo isso adiando o tzcnt / shift para várias iterações, o que é altamente eficaz (veja abaixo).
Podemos usar o BSF ou o TZCNT com segurança de forma intercambiável, porque n
nunca pode ser zero nesse ponto. O código de máquina do TZCNT decodifica como BSF em CPUs que não suportam BMI1. (Prefixos sem sentido são ignorados, portanto, o REP BSF é executado como BSF).
O TZCNT tem um desempenho muito melhor que o BSF nas CPUs AMD que o suportam, portanto pode ser uma boa ideia usá-lo REP BSF
, mesmo que você não se preocupe em definir o ZF se a entrada for zero e não a saída. Alguns compiladores fazem isso quando você usa __builtin_ctzll
mesmo com -mno-bmi
.
Eles executam o mesmo em CPUs Intel, portanto, salve o byte se isso é tudo o que importa. O TZCNT na Intel (pré-Skylake) ainda possui uma falsa dependência do operando de saída supostamente somente para gravação, assim como o BSF, para suportar o comportamento não documentado de que o BSF com entrada = 0 deixa seu destino inalterado. Portanto, você precisa contornar isso, a menos que otimize apenas o Skylake, para que não haja nada a ganhar com o byte REP extra. (A Intel geralmente vai além do exigido pelo manual x86 ISA, para evitar a quebra de código amplamente usado que depende de algo que não deveria ou que é retroativamente proibido. Por exemplo, o Windows 9x não assume nenhuma pré-busca especulativa de entradas TLB , o que era seguro quando o código foi escrito, antes da Intel atualizar as regras de gerenciamento do TLB .)
De qualquer forma, LZCNT / TZCNT em Haswell tem o mesmo dep falso que o POPCNT: consulte estas perguntas e respostas . É por isso que na saída asm do gcc para o código do @ Veedrac, você o vê quebrando a cadeia dep com xor-zero no registro que está prestes a usar como destino do TZCNT quando não usa dst = src. Como o TZCNT / LZCNT / POPCNT nunca deixa seu destino indefinido ou não modificado, essa falsa dependência da saída nas CPUs Intel é um bug / limitação de desempenho. Presumivelmente, vale a pena alguns transistores / potência fazê-los se comportar como outros uops que vão para a mesma unidade de execução. O único aspecto positivo é a interação com outra limitação do uarch: eles podem microfundir um operando de memória com um modo de endereçamento indexado em Haswell, mas em Skylake, onde a Intel removeu o depósito falso para LZCNT / TZCNT, eles "não laminam" os modos de endereçamento indexado, enquanto o POPCNT ainda pode micro-fundir qualquer modo addr.
Melhorias nas idéias / código de outras respostas:
A resposta de @ hidefromkgb tem uma boa observação de que você pode garantir um turno certo após um 3n + 1. Você pode calcular isso de maneira ainda mais eficiente do que simplesmente deixar de lado as verificações entre as etapas. A implementação asm nessa resposta está interrompida (depende de OF, que é indefinido após SHRD com uma contagem> 1), e lenta: ROR rdi,2
é mais rápida que SHRD rdi,rdi,2
, e o uso de duas instruções CMOV no caminho crítico é mais lento que um TESTE extra que pode ser executado em paralelo.
Coloquei C arrumado / aprimorado (que guia o compilador a produzir melhor asm) e testei + trabalhando mais rápido (em comentários abaixo do C) no Godbolt: veja o link na resposta de @ hidefromkgb . (Essa resposta atingiu o limite de 30 mil caracteres a partir dos URLs Godbolt grandes, mas os links curtos podem apodrecer e, de qualquer maneira, eram muito longos para o goo.gl.)
Também melhorou a impressão de saída para converter em uma string e criar uma em write()
vez de escrever um caracter por vez. Isso minimiza o impacto no cronograma de todo o programa perf stat ./collatz
(para registrar os contadores de desempenho) e eu ofusquei parte do asm não crítico.
@ Código de Veedrac
Recebi uma pequena aceleração ao mudar para a direita, tanto quanto sabemos que precisa ser feito, e checando para continuar o ciclo. De 7,5s para o limite = 1e8 até 7,275s, no Core2Duo (Merom), com um fator de desenrolamento de 16.
código + comentários sobre Godbolt . Não use esta versão com clang; faz algo bobo com o loop deferido. Usar um contador tmp k
e adicioná-lo count
posteriormente altera o que o clang faz, mas isso prejudica levemente o gcc.
Veja a discussão nos comentários: O código do Veedrac é excelente em CPUs com IMC1 (ou seja, não Celeron / Pentium)