Código C ++ para testar a conjectura Collatz mais rapidamente do que o conjunto escrito à mão - por quê?


833

Eu escrevi essas duas soluções para o Projeto Euler Q14 , em montagem e em C ++. Eles são a mesma abordagem de força bruta idêntica para testar a conjectura de Collatz . A solução de montagem foi montada com

nasm -felf64 p14.asm && gcc p14.o -o p14

O C ++ foi compilado com

g++ p14.cpp -o p14

Montagem, p14.asm

section .data
    fmt db "%d", 10, 0

global main
extern printf

section .text

main:
    mov rcx, 1000000
    xor rdi, rdi        ; max i
    xor rsi, rsi        ; i

l1:
    dec rcx
    xor r10, r10        ; count
    mov rax, rcx

l2:
    test rax, 1
    jpe even

    mov rbx, 3
    mul rbx
    inc rax
    jmp c1

even:
    mov rbx, 2
    xor rdx, rdx
    div rbx

c1:
    inc r10
    cmp rax, 1
    jne l2

    cmp rdi, r10
    cmovl rdi, r10
    cmovl rsi, rcx

    cmp rcx, 2
    jne l1

    mov rdi, fmt
    xor rax, rax
    call printf
    ret

C ++, p14.cpp

#include <iostream>

using namespace std;

int sequence(long n) {
    int count = 1;
    while (n != 1) {
        if (n % 2 == 0)
            n /= 2;
        else
            n = n*3 + 1;

        ++count;
    }

    return count;
}

int main() {
    int max = 0, maxi;
    for (int i = 999999; i > 0; --i) {
        int s = sequence(i);
        if (s > max) {
            max = s;
            maxi = i;
        }
    }

    cout << maxi << endl;
}

Conheço as otimizações do compilador para melhorar a velocidade e tudo, mas não vejo muitas maneiras de otimizar ainda mais minha solução de montagem (falando de maneira programática e não matematicamente).

O código C ++ possui um módulo para cada termo e divisão para cada termo par, em que assembly é apenas uma divisão por termo par.

Mas o assembly está demorando em média 1 segundo a mais que a solução C ++. Por que é isso? Estou perguntando principalmente por curiosidade.

Tempos de execução

Meu sistema: Linux de 64 bits no Intel Celeron 2955U de 1,4 GHz (microarquitetura Haswell).


232
Você examinou o código de assembly que o GCC gera para o seu programa C ++?
Ruakh #

69
Compile com -Spara obter o assembly que o compilador gerou. O compilador é inteligente o suficiente para perceber que o módulo faz a divisão ao mesmo tempo.
user3386109

267
Eu acho que suas opções são 1. Sua técnica de medição é falha, 2. O compilador escreve uma montagem melhor que você, ou 3. O compilador usa mágica.
Galik


18
@jefferson O compilador pode usar força bruta mais rápida. Por exemplo, talvez com instruções SSE.
precisa saber é o seguinte

Respostas:


1896

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 o 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 r64há 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, 1faz 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 -O0saí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_13no 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) -O0velocidade é 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=bdver3e g++ -O3 -march=skylakefará 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 npossui 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 nnã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 longapenas 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 edxvez 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 cmovcvez 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 nvalores 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 rcxsó é 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 nvalores 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 nvalores tenham atingido 1antes de obter outro par de nvalores 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 nainda 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 nvalores 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 1em 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/=2iteraçõ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 ns 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 nnunca 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_ctzllmesmo 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 ke adicioná-lo countposteriormente 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)


4
Eu tentei a abordagem vetorizada há um tempo atrás, não ajudou (porque você pode fazer muito melhor no código escalar tzcnte está bloqueado para a sequência de execução mais longa entre seus elementos vetoriais no caso vetorizado).
EOF

3
@EOF: não, eu quis dizer interromper o loop interno quando qualquer um dos elementos do vetor atingir 1, em vez de quando todos tiverem (facilmente detectável com PCMPEQ / PMOVMSK). Então você usa PINSRQ e outras coisas para mexer com o elemento que terminou (e seus contadores) e volta ao loop. Isso pode facilmente se tornar uma perda, quando você sai do loop interno com muita frequência, mas significa que você sempre recebe 2 ou 4 elementos de trabalho útil a cada iteração do loop interno. Bom ponto sobre memorização, no entanto.
Peter Cordes

4
@jefferson O melhor que consegui é godbolt.org/g/1N70Ib . Eu esperava poder fazer algo mais inteligente, mas parece que não.
Veedrac

87
O que me surpreende com respostas incríveis como essa é o conhecimento mostrado com tanto detalhe. Nunca conhecerei um idioma ou sistema nesse nível e não saberia como. Muito bem, senhor.
Camden_kid

8
Resposta lendária !!
Sumit Jain

104

Afirmar que o compilador C ++ pode produzir um código mais ideal do que um programador de linguagem assembly competente é um erro muito grave. E especialmente neste caso. O humano sempre pode melhorar o código que o compilador, e essa situação em particular é uma boa ilustração dessa afirmação.

A diferença de tempo que você está vendo é porque o código de montagem na pergunta está muito longe do ideal nos loops internos.

(O código abaixo é de 32 bits, mas pode ser facilmente convertido em 64 bits)

Por exemplo, a função de sequência pode ser otimizada para apenas 5 instruções:

    .seq:
        inc     esi                 ; counter
        lea     edx, [3*eax+1]      ; edx = 3*n+1
        shr     eax, 1              ; eax = n/2
        cmovc   eax, edx            ; if CF eax = edx
        jnz     .seq                ; jmp if n<>1

Todo o código se parece com:

include "%lib%/freshlib.inc"
@BinaryType console, compact
options.DebugMode = 1
include "%lib%/freshlib.asm"

start:
        InitializeAll
        mov ecx, 999999
        xor edi, edi        ; max
        xor ebx, ebx        ; max i

    .main_loop:

        xor     esi, esi
        mov     eax, ecx

    .seq:
        inc     esi                 ; counter
        lea     edx, [3*eax+1]      ; edx = 3*n+1
        shr     eax, 1              ; eax = n/2
        cmovc   eax, edx            ; if CF eax = edx
        jnz     .seq                ; jmp if n<>1

        cmp     edi, esi
        cmovb   edi, esi
        cmovb   ebx, ecx

        dec     ecx
        jnz     .main_loop

        OutputValue "Max sequence: ", edi, 10, -1
        OutputValue "Max index: ", ebx, 10, -1

        FinalizeAll
        stdcall TerminateAll, 0

Para compilar esse código, é necessário o FreshLib .

Nos meus testes (processador AMD A4-1200 de 1 GHz), o código acima é aproximadamente quatro vezes mais rápido que o código C ++ da pergunta (quando compilado com -O0: 430 ms vs. 1900 ms) e mais de duas vezes mais rápido (430 ms vs. 830 ms) quando o código C ++ é compilado -O3.

A saída dos dois programas é a mesma: sequência máxima = 525 em i = 837799.


6
Huh, isso é inteligente. SHR define ZF apenas se EAX for 1 (ou 0). Eu perdi isso ao otimizar a -O3saída do gcc , mas identifiquei todas as outras otimizações que você fez no loop interno. (Mas por que você usa LEA para o incremento do contador em vez de INC? t rodar em tantas portas, e pode levar a conflitos de recursos atrasando o caminho crítico mais frequentemente).
Peter Cordes

4
Ah, na verdade, o Bulldozer pode prejudicar o rendimento com a saída do compilador. Possui CMOV de menor latência e LEA de 3 componentes que Haswell (que eu estava considerando), portanto, a cadeia dep transportada por loop possui apenas 3 ciclos no seu código. Ele também não possui instruções MOV de latência zero para registros inteiros; portanto, as instruções MOV desperdiçadas do g ++ aumentam a latência do caminho crítico e são um grande problema para o Bulldozer. Então, sim, a otimização manual realmente vence o compilador de maneira significativa para CPUs que não são ultramodernas o suficiente para analisar as instruções inúteis.
6606 Peter Cordes #

95
" Reivindicar melhor o compilador C ++ é um erro muito grave. E especialmente neste caso. O ser humano sempre pode melhorar o código que esse e esse problema em particular seja uma boa ilustração dessa afirmação. " Você pode revertê-lo e isso seria válido . " Reivindicando uma humana é melhor é muito mau erro. E, especialmente neste caso. O ser humano sempre pode fazer o código pior que o e neste particular pergunta é boa ilustração dessa reivindicação. " Então, eu não acho que você tem um ponto aqui , tais generalizações estão erradas.
Luk32

5
@ luk32 - Mas o autor da pergunta não pode ter nenhum argumento, porque seu conhecimento da linguagem assembly é próximo de zero. Todos os argumentos sobre humano versus compilador assumem implicitamente humano com pelo menos algum nível intermediário de conhecimento em asm. Mais: O teorema "O código escrito humano sempre será melhor ou o mesmo que o código gerado pelo compilador" é muito fácil de ser formalmente provado.
precisa saber é

30
@ luk32: Um ser humano qualificado pode (e geralmente deve) começar com a saída do compilador. Portanto, desde que você compare suas tentativas para garantir que elas sejam realmente mais rápidas (no hardware de destino que você está ajustando), você não poderá fazer pior do que o compilador. Mas sim, eu tenho que concordar que é um pouco de uma afirmação forte. Os compiladores geralmente se saem muito melhor que os codificadores iniciantes de asm. Mas geralmente é possível salvar uma ou duas instruções em comparação com o que os compiladores criam. (Nem sempre no caminho crítico, dependendo do uarch). São peças altamente úteis de maquinaria complexa, mas não são "inteligentes".
Peter Cordes

24

Para obter mais desempenho: uma mudança simples é observar que, após n = 3n + 1, n será par, para que você possa dividir por 2 imediatamente. E n não será 1, então você não precisa testar. Assim, você pode salvar algumas declarações if e escrever:

while (n % 2 == 0) n /= 2;
if (n > 1) for (;;) {
    n = (3*n + 1) / 2;
    if (n % 2 == 0) {
        do n /= 2; while (n % 2 == 0);
        if (n == 1) break;
    }
}

Aqui está uma grande vitória: se você observar os 8 bits mais baixos de n, todas as etapas até você dividir por 2 oito vezes serão completamente determinadas por esses oito bits. Por exemplo, se os últimos oito bits forem 0x01, isto é, em binário, seu número será ???? 0000 0001, as próximas etapas são:

3n+1 -> ???? 0000 0100
/ 2  -> ???? ?000 0010
/ 2  -> ???? ??00 0001
3n+1 -> ???? ??00 0100
/ 2  -> ???? ???0 0010
/ 2  -> ???? ???? 0001
3n+1 -> ???? ???? 0100
/ 2  -> ???? ???? ?010
/ 2  -> ???? ???? ??01
3n+1 -> ???? ???? ??00
/ 2  -> ???? ???? ???0
/ 2  -> ???? ???? ????

Portanto, todas essas etapas podem ser previstas e 256k + 1 é substituído por 81k + 1. Algo semelhante acontecerá em todas as combinações. Então você pode fazer um loop com uma grande declaração de switch:

k = n / 256;
m = n % 256;

switch (m) {
    case 0: n = 1 * k + 0; break;
    case 1: n = 81 * k + 1; break; 
    case 2: n = 81 * k + 1; break; 
    ...
    case 155: n = 729 * k + 425; break;
    ...
}

Execute o loop até n ≤ 128, porque nesse ponto n poderia se tornar 1 com menos de oito divisões por 2 e executar oito ou mais etapas por vez faria com que você perdesse o ponto em que alcançou 1 pela primeira vez. Em seguida, continue o loop "normal" - ou prepare uma tabela que informe quantas etapas são necessárias para alcançar 1.

PS. Eu suspeito fortemente que a sugestão de Peter Cordes tornaria ainda mais rápida. Não haverá ramificações condicionais, exceto uma, e essa será prevista corretamente, exceto quando o loop realmente terminar. Então o código seria algo como

static const unsigned int multipliers [256] = { ... }
static const unsigned int adders [256] = { ... }

while (n > 128) {
    size_t lastBits = n % 256;
    n = (n >> 8) * multipliers [lastBits] + adders [lastBits];
}

Na prática, você mede se o processamento dos últimos 9, 10, 11, 12 bits de n por vez seria mais rápido. Para cada bit, o número de entradas na tabela dobraria, e eu retiro uma desaceleração quando as tabelas não cabem mais no cache L1.

PPS. Se você precisar do número de operações: em cada iteração, fazemos exatamente oito divisões por duas e um número variável de operações (3n + 1); portanto, um método óbvio para contar as operações seria outra matriz. Mas podemos realmente calcular o número de etapas (com base no número de iterações do loop).

Poderíamos redefinir um pouco o problema: Substitua n por (3n + 1) / 2 se for ímpar e substitua n por n / 2 se for par. Então, toda iteração executará exatamente 8 etapas, mas você pode considerar essa trapaça :-) Portanto, assuma que existam operações r n <- 3n + 1 e s operações n <- n / 2. O resultado será exatamente n '= n * 3 ^ r / 2 ^ s, porque n <- 3n + 1 significa n <- 3n * (1 + 1 / 3n). Tomando o logaritmo, encontramos r = (s + log2 (n '/ n)) / log2 (3).

Se fizermos o loop até n ≤ 1.000.000 e tivermos uma tabela pré-computada quantas iterações são necessárias a partir de qualquer ponto inicial n ≤ 1.000.000, o cálculo de r como acima, arredondado para o número inteiro mais próximo, fornecerá o resultado certo, a menos que s seja realmente grande.


2
Ou faça tabelas de pesquisa de dados para multiplicar e adicionar constantes, em vez de uma opção. A indexação de duas tabelas de 256 entradas é mais rápida que uma tabela de salto, e os compiladores provavelmente não estão procurando por essa transformação.
Peter Cordes

1
Hummm, pensei por um minuto que essa observação pudesse provar a conjectura de Collatz, mas não, claro que não. Para todos os 8 bits finais possíveis, há um número finito de etapas até que todas desapareçam. Mas alguns desses padrões de 8 bits à direita aumentam o resto da cadeia de bits em mais de 8, portanto, isso não pode descartar um crescimento ilimitado ou um ciclo repetitivo.
Peter Cordes

Para atualizar count, você precisa de uma terceira matriz, certo? adders[]não diz quantas mudanças à direita foram feitas.
Peter Cordes

Para tabelas maiores, vale a pena usar tipos mais estreitos para aumentar a densidade do cache. Na maioria das arquiteturas, uma carga de extensão zero de a uint16_té muito barata. No x86, é tão barato quanto estender zero de 32 bits unsigned intpara uint64_t. (MOVZX da memória em processadores Intel só precisa de um uop-porto de carga, mas os processadores da AMD precisa da ALU também.) Oh BTW, porque você está usando size_tpara lastBits? É um tipo de 32 bits com -m32e até -mx32(modo longo com ponteiros de 32 bits). É definitivamente o tipo errado para n. Basta usar unsigned.
Peter Cordes

20

Em uma nota não relacionada: mais hacks de desempenho!

  • [a primeira «conjectura» foi finalmente desmascarada por @ShreevatsaR; removido]

  • Ao percorrer a sequência, só podemos obter 3 casos possíveis nas 2 vizinhanças do elemento atual N(mostrado primeiro):

    1. [par ou ímpar]
    2. [ímpar Par]
    3. [par] [par]

    Passar além desses 2 elementos significa calcular (N >> 1) + N + 1, ((N << 1) + N + 1) >> 1e N >> 2, respectivamente.

    Vamos provar que, para ambos os casos (1) e (2), é possível usar a primeira fórmula (N >> 1) + N + 1,.

    O caso (1) é óbvio. O caso (2) implica (N & 1) == 1, portanto, se assumirmos (sem perda de generalidade) que N tem 2 bits de comprimento e seus bits são bada maioria para a menos significativa, então a = 1, e o seguinte vale:

    (N << 1) + N + 1:     (N >> 1) + N + 1:
    
            b10                    b1
             b1                     b
           +  1                   + 1
           ----                   ---
           bBb0                   bBb

    onde B = !b. Mudar para a direita o primeiro resultado nos dá exatamente o que queremos.

    QED: (N & 1) == 1 ⇒ (N >> 1) + N + 1 == ((N << 1) + N + 1) >> 1.

    Como comprovado, podemos atravessar os elementos da sequência 2 por vez, usando uma única operação ternária. Outra redução de tempo 2 vezes.

O algoritmo resultante é assim:

uint64_t sequence(uint64_t size, uint64_t *path) {
    uint64_t n, i, c, maxi = 0, maxc = 0;

    for (n = i = (size - 1) | 1; i > 2; n = i -= 2) {
        c = 2;
        while ((n = ((n & 3)? (n >> 1) + n + 1 : (n >> 2))) > 2)
            c += 2;
        if (n == 2)
            c++;
        if (c > maxc) {
            maxi = i;
            maxc = c;
        }
    }
    *path = maxc;
    return maxi;
}

int main() {
    uint64_t maxi, maxc;

    maxi = sequence(1000000, &maxc);
    printf("%llu, %llu\n", maxi, maxc);
    return 0;
}

Aqui nós comparamos n > 2porque o processo pode parar em 2 em vez de 1 se o comprimento total da sequência for ímpar.

[EDITAR:]

Vamos traduzir isso em montagem!

MOV RCX, 1000000;



DEC RCX;
AND RCX, -2;
XOR RAX, RAX;
MOV RBX, RAX;

@main:
  XOR RSI, RSI;
  LEA RDI, [RCX + 1];

  @loop:
    ADD RSI, 2;
    LEA RDX, [RDI + RDI*2 + 2];
    SHR RDX, 1;
    SHRD RDI, RDI, 2;    ror rdi,2   would do the same thing
    CMOVL RDI, RDX;      Note that SHRD leaves OF = undefined with count>1, and this doesn't work on all CPUs.
    CMOVS RDI, RDX;
    CMP RDI, 2;
  JA @loop;

  LEA RDX, [RSI + 1];
  CMOVE RSI, RDX;

  CMP RAX, RSI;
  CMOVB RAX, RSI;
  CMOVB RBX, RCX;

  SUB RCX, 2;
JA @main;



MOV RDI, RCX;
ADD RCX, 10;
PUSH RDI;
PUSH RCX;

@itoa:
  XOR RDX, RDX;
  DIV RCX;
  ADD RDX, '0';
  PUSH RDX;
  TEST RAX, RAX;
JNE @itoa;

  PUSH RCX;
  LEA RAX, [RBX + 1];
  TEST RBX, RBX;
  MOV RBX, RDI;
JNE @itoa;

POP RCX;
INC RDI;
MOV RDX, RDI;

@outp:
  MOV RSI, RSP;
  MOV RAX, RDI;
  SYSCALL;
  POP RAX;
  TEST RAX, RAX;
JNE @outp;

LEA RAX, [RDI + 59];
DEC RDI;
SYSCALL;

Use estes comandos para compilar:

nasm -f elf64 file.asm
ld -o file file.o

Veja o C e uma versão aprimorada / corrigida de erros do asm de Peter Cordes no Godbolt . (nota do editor: desculpe-me por colocar minhas coisas na sua resposta, mas minha resposta atingiu o limite de 30 mil caracteres nos links Godbolt + texto!)


2
Não existe integral Qtal que 12 = 3Q + 1. Seu primeiro ponto não está certo, acho.
Veedrac

1
@Veedrac: Estou brincando com isso: ele pode ser implementado com asm melhor do que a implementação nesta resposta, usando ROR / TEST e apenas um CMOV. Esse código asm faz um loop infinito na minha CPU, já que aparentemente depende de OF, que é indefinido após SHRD ou ROR com contagem> 1. Ele também se esforça muito para evitar mov reg, imm32, aparentemente para salvar bytes, mas depois usa o Versão de registro de 64 bits em todos os lugares, mesmo para xor rax, rax, por isso possui muitos prefixos REX desnecessários. Obviamente, precisamos apenas de REX nos registros que mantêm no loop interno para evitar transbordamentos.
Peter Cordes

1
Resultados de tempo (de um Core2Duo E6600: Merom 2.4GHz. Complex-LEA = latência de 1c, CMOV = 2c) . A melhor implementação de loop interno asm de etapa única (da Johnfound): 111ms por execução desse loop @main. Saída do compilador da minha versão ofuscada deste C (com alguns tmp vars): clang3.8 -O3 -march=core2: 96ms. gcc5.2: 108ms. Da minha versão melhorada do loop interno asm do clang: 92ms (deve haver uma melhoria muito maior na família SnB, onde o LEA complexo é 3c e não 1c). Da minha versão melhorada + funcional deste loop asm (usando ROR + TEST, não SHRD): 87ms. Medido com 5 repetições antes de imprimir
Peter Cordes

2
Aqui estão os primeiros 66 gravadores (A006877 no OEIS); Marquei os pares em negrito: 2, 3, 6, 7, 9, 18, 25, 27, 54, 73, 97, 129, 171, 231, 313, 327, 649, 703, 871, 1161, 2223, 2463, 2919, 3711, 6171, 10971, 13255, 17647, 23529, 26623, 34239, 35655, 52527, 77031, 106239, 142587, 156159, 216367, 230631, 410011, 511935, 626331, 837799, 1117065, 1501353, 1723519, 2298025, 3064033, 3542887, 3732423, 5649499, 6649279, 8400511, 11200681, 14934241, 15733191, 31466382, 36791535, 63728127, 127456254, 169941673, 226588897, 268549803, 537099606, 670617279, 1341234558
ShreevatsaR

1
@hidefromkgb Ótimo! E também agradeço o seu outro ponto: 4k + 2 → 2k + 1 → 6k + 4 = (4k + 2) + (2k + 1) + 1 e 2k + 1 → 6k + 4 → 3k + 2 = ( 2k + 1) + (k) + 1. Boa observação!
ShreevatsaR 4/16

6

Os programas C ++ são traduzidos para programas de montagem durante a geração de código de máquina a partir do código-fonte. Seria praticamente errado dizer que o assembly é mais lento que o C ++. Além disso, o código binário gerado difere de compilador para compilador. Portanto, um compilador C ++ inteligente pode produzir código binário mais ideal e eficiente do que o código de um montador burro.

No entanto, acredito que sua metodologia de criação de perfis tem certas falhas. A seguir, são diretrizes gerais para criação de perfil:

  1. Verifique se o seu sistema está em seu estado normal / ocioso. Interrompa todos os processos (aplicativos) em execução que você iniciou ou que usam a CPU intensivamente (ou faça pesquisas na rede).
  2. Seu tamanho de dados deve ser maior em tamanho.
  3. Seu teste deve ser executado por algo mais que 5 a 10 segundos.
  4. Não confie em apenas uma amostra. Execute seu teste N vezes. Colete resultados e calcule a média ou mediana do resultado.

Sim, eu não fiz nenhum perfil formal, mas os executei algumas vezes e sou capaz de dizer 2 segundos a partir de 3 segundos. De qualquer forma, obrigado por responder. Já pegou uma boa quantidade de informações aqui
filho jeffer

9
Provavelmente não é apenas um erro de medição, o código asm escrito à mão está usando uma instrução DIV de 64 bits em vez de uma mudança à direita. Veja minha resposta. Mas sim, medir corretamente também é importante.
Peter Cordes

7
Os pontos de marcador são uma formatação mais apropriada do que um bloco de código. Pare de colocar seu texto em um bloco de código, porque não é um código e não se beneficia de uma fonte monoespaçada.
Peter Cordes

16
Realmente não vejo como isso responde à pergunta. Essa não é uma pergunta vaga sobre se o código do assembly ou o código C ++ pode ser mais rápido - é uma pergunta muito específica sobre o código real , que ele é útil na própria pergunta. Sua resposta nem menciona esse código ou faz qualquer tipo de comparação. Claro, suas dicas sobre como fazer benchmarks são basicamente corretas, mas não o suficiente para dar uma resposta real.
Cody Gray

6

Para o problema Collatz, você pode obter um aumento significativo no desempenho armazenando em cache as "caudas". Esta é uma troca de tempo / memória. Veja: memorização ( https://en.wikipedia.org/wiki/Memoization ). Você também pode procurar soluções de programação dinâmica para outras compensações de tempo / memória.

Exemplo de implementação python:

import sys

inner_loop = 0

def collatz_sequence(N, cache):
    global inner_loop

    l = [ ]
    stop = False
    n = N

    tails = [ ]

    while not stop:
        inner_loop += 1
        tmp = n
        l.append(n)
        if n <= 1:
            stop = True  
        elif n in cache:
            stop = True
        elif n % 2:
            n = 3*n + 1
        else:
            n = n // 2
        tails.append((tmp, len(l)))

    for key, offset in tails:
        if not key in cache:
            cache[key] = l[offset:]

    return l

def gen_sequence(l, cache):
    for elem in l:
        yield elem
        if elem in cache:
            yield from gen_sequence(cache[elem], cache)
            raise StopIteration

if __name__ == "__main__":
    le_cache = {}

    for n in range(1, 4711, 5):
        l = collatz_sequence(n, le_cache)
        print("{}: {}".format(n, len(list(gen_sequence(l, le_cache)))))

    print("inner_loop = {}".format(inner_loop))

1
A resposta de gnasher mostra que você pode fazer muito mais do que apenas armazenar em cache as caudas: os bits altos não afetam o que acontece a seguir e o add / mul apenas propaga o transporte para a esquerda, portanto, os bits altos não afetam o que acontece com os bits baixos. ou seja, você pode usar as pesquisas LUT para obter 8 (ou qualquer número) de bits de cada vez, multiplicar e adicionar constantes para aplicar ao restante dos bits. memorizar as caudas é certamente útil em muitos problemas como esse, e para esse problema quando você ainda não pensou na melhor abordagem ou não provou que está correta.
Peter Cordes

2
Se entendi corretamente a ideia de Gnasher acima, acho que a memoização da cauda é uma otimização ortogonal. Então, você poderia concebivelmente fazer as duas coisas. Seria interessante investigar quanto você poderia ganhar com a adição de memoização ao algoritmo de gnasher.
Emanuel Landeholm

2
Talvez possamos tornar a memorização mais barata armazenando apenas a parte densa dos resultados. Defina um limite superior para N e, acima disso, nem verifique a memória. Abaixo disso, use hash (N) -> N como a função hash, para que key = position na matriz e não precise ser armazenado. Uma entrada de 0meios ainda não presente. Podemos otimizar ainda mais armazenando apenas N ímpares na tabela, para que a função hash seja n>>1, descartando o 1. Escreva o código da etapa para sempre terminar com um n>>tzcnt(n)ou algo para garantir que seja ímpar.
Peter Cordes

1
Isso é baseado na minha ideia (não testada) de que valores muito grandes de N no meio de uma sequência são menos propensos a serem comuns a várias sequências, para que não percam muito tempo por não memorizá-las. Além disso, um N de tamanho razoável fará parte de muitas sequências longas, mesmo aquelas que começam com N. muito grande (isso pode ser uma ilusão; se estiver errado, apenas o armazenamento em cache de uma faixa densa de N consecutivo pode perder contra um hash. tabela que pode armazenar chaves arbitrárias.) Você fez algum tipo de teste de taxa de acerto para ver se N inicial próximo tende a ter alguma similaridade em seus valores de sequência?
6136 Peter Cordes #

2
Você pode simplesmente armazenar resultados pré-calculados para todos os n <N, para alguns N. grandes. Portanto, você não precisa da sobrecarga de uma tabela de hash. Os dados nessa tabela serão usados ​​eventualmente para todos os valores iniciais. Se você apenas deseja confirmar que a sequência Collatz sempre termina em (1, 4, 2, 1, 4, 2, ...): Isso pode ser comprovado como equivalente a provar que para n> 1, a sequência acabará ser menor que o original n. E, para isso, armazenar em cache as caudas não ajudará.
gnasher729

5

Dos comentários:

Mas, esse código nunca para (por causa do estouro de número inteiro)!?! Yves Daoust

Para muitos números, ele não transbordará.

Se ele irá transbordar - para uma daquelas sementes iniciais de azar, o número sobrevoados, muito provavelmente, convergir para 1, sem outro estouro.

Ainda isso coloca uma questão interessante: existe algum número de semente cíclica de transbordamento?

Qualquer série convergente final simples começa com potência de dois valores (óbvio o suficiente?).

2 ^ 64 transbordará para zero, que é um loop infinito indefinido de acordo com o algoritmo (termina apenas com 1), mas a solução mais ótima em resposta será concluída devido à shr raxprodução de ZF = 1.

Podemos produzir 2 ^ 64? Se o número inicial for 0x5555555555555555, é um número ímpar, o próximo número será 3n + 1, que é 0xFFFFFFFFFFFFFFFF + 1= 0. Teoricamente no estado indefinido do algoritmo, mas a resposta otimizada de johnfound se recuperará saindo em ZF = 1. O cmp rax,1de Peter Cordes terminará em loop infinito (variante QED 1, "baixo preço" através de 0número indefinido ).

Que tal um número mais complexo, que criará um ciclo sem 0? Francamente, não tenho certeza, minha teoria matemática é muito nebulosa para se ter uma idéia séria, como lidar com isso de maneira séria. Mas intuitivamente eu diria que a série convergirá para 1 para cada número: 0 <número, pois a fórmula 3n + 1 lentamente transformará cada fator primo não-2 do número original (ou intermediário) em uma potência de 2, mais cedo ou mais tarde . Portanto, não precisamos nos preocupar com loop infinito para séries originais, apenas o excesso pode nos atrapalhar.

Então, eu apenas coloquei alguns números na folha e dei uma olhada nos números truncados de 8 bits.

Existem três valores transbordando para 0: 227, 170e 85( 85indo diretamente para 0, outros dois progredindo em direção 85).

Mas não há valor criando a semente do estouro cíclico.

Curiosamente, fiz uma verificação, que é o primeiro número a sofrer de truncamento de 8 bits e já 27é afetada! Ele atinge valor 9232em séries não truncadas apropriadas (o primeiro valor truncado está 322na 12ª etapa), e o valor máximo atingido para qualquer um dos 2-255 números de entrada de maneira não truncada é 13120(por 255si só), número máximo de etapas convergir para 1é sobre 128(+ -2, não tenho certeza se "1" é para contar, etc ...).

Curiosamente (para mim) o número 9232é máximo para muitos outros números de origem, o que há de tão especial nisso? : -O 9232= 0x2410... hmmm .. não faço ideia.

Infelizmente, não consigo entender profundamente essa série, por que ela converge e quais são as implicações de truncá-las em k bits, mas com a cmp number,1condição de terminação é certamente possível colocar o algoritmo em loop infinito com um valor de entrada específico que termina como 0depois truncamento.

Mas o valor que 27excede o limite de maiúsculas e minúsculas de 8 bits é um tipo de alerta; parece que se você contar o número de etapas para alcançar o valor 1, obterá um resultado errado para a maioria dos números do conjunto total de inteiros de k bits. Para números inteiros de 8 bits, os 146 números de 256 afetaram as séries por truncamento (alguns deles ainda podem atingir o número correto de etapas por acidente, talvez, eu esteja com preguiça de verificar).


"o número transbordado provavelmente convergirá para 1 sem outro transbordamento": o código nunca para. (Isso é uma conjectura que eu não posso esperar até o fim dos tempos para ter certeza ...)
Yves Daoust

@YvesDaoust oh, mas sim? ... por exemplo, a 27série com truncamento 8b tem esta aparência: 82 41 124 62 31 94 47 142 71 214 107 66 (truncada) 33 100 50 25 76 38 19 58 29 88 44 22 11 34 17 52 26 13 40 20 10 5 16 8 4 2 1 (o restante funciona sem truncamento). Eu não entendo você, desculpe. Nunca pararia se o valor truncado fosse igual a alguns dos alcançados anteriormente nas séries atualmente em andamento, e não consigo encontrar esse valor contra o truncamento de k bits (mas também não consigo descobrir a teoria matemática por trás, por que isso vale para truncamento de 8/16/32/64 bits, intuitivamente, acho que funciona).
Ped7g 01/11/19

1
Deveria ter verificado a descrição original do problema antes: "Embora ainda não tenha sido comprovada (Problema de Collatz), acredita-se que todos os números iniciais terminem em 1." ... ok, não admira que não pode obter compreensão do que com a minha nebulosa limitado conhecimento de matemática ...: D E a partir de minhas experiências folha Posso assegurar-lhe que ele faz convergir para cada 2- 255número, seja, sem cortes (a 1), ou com truncamento de 8 bits (para o esperado 1ou 0para três números).
Ped7g

Hem, quando digo que nunca para, quero dizer ... que não para. O código fornecido é executado para sempre, se você preferir.
precisa

1
Voto positivo para análise do que acontece no estouro. O loop baseado em CMP pode usar cmp rax,1 / jna(ie do{}while(n>1)) para terminar também em zero. Pensei em fazer uma versão instrumentada do loop que registra o máximo nvisto, para ter uma idéia de quão perto chegamos do transbordamento.
Peter Cordes

5

Você não publicou o código gerado pelo compilador, portanto há algumas suposições aqui, mas mesmo sem tê-lo visto, pode-se dizer que isto:

test rax, 1
jpe even

... tem 50% de chance de predizer mal a filial, e isso ficará caro.

O compilador quase certamente faz os dois cálculos (que custam muito mais, pois o div / mod é uma latência bastante longa, portanto a adição múltipla é "gratuita") e segue com um CMOV. Que, é claro, tem zero por cento de chance de ser imprevisível.


1
Há algum padrão na ramificação; por exemplo, um número ímpar é sempre seguido por um número par. Mas às vezes 3n + 1 deixa vários bits zero à direita, e é aí que isso será imprevisível. Comecei a escrever sobre divisão na minha resposta e não resolvi essa outra grande bandeira vermelha no código do OP. (Observe também que o uso de uma condição de paridade é realmente estranho, comparado a apenas JZ ou CMOVZ. Também é pior para a CPU, porque as CPUs Intel podem fundir macro TEST / JZ, mas não TEST / JPE. Agner Fog diz que a AMD pode fundir qualquer TEST / CMP com qualquer JCC, então, nesse caso, é só que pior para os leitores humanos)
Peter Cordes

5

Mesmo sem olhar para a montagem, a razão mais óbvia é que ela /= 2provavelmente é otimizada >>=1e muitos processadores têm uma operação de troca muito rápida. Mas, mesmo que um processador não tenha uma operação de turno, a divisão inteira é mais rápida que a divisão de ponto flutuante.

Editar: sua milhagem pode variar na instrução "divisão inteira é mais rápida que divisão de ponto flutuante" acima. Os comentários abaixo revelam que os processadores modernos priorizaram a otimização da divisão fp sobre a divisão inteira. Portanto, se alguém estava procurando o motivo mais provável para a aceleração que a pergunta desse segmento faz, otimize o compilador /=2como >>=1seria o melhor primeiro lugar para procurar.


Em uma nota não relacionada , se nfor ímpar, a expressão n*3+1será sempre par. Portanto, não há necessidade de verificar. Você pode alterar esse ramo para

{
   n = (n*3+1) >> 1;
   count += 2;
}

Portanto, toda a declaração seria

if (n & 1)
{
    n = (n*3 + 1) >> 1;
    count += 2;
}
else
{
    n >>= 1;
    ++count;
}

4
A divisão de números inteiros não é realmente mais rápida que a divisão de FP em CPUs x86 modernas. Acho que isso se deve ao fato de a Intel / AMD gastar mais transistores em seus divisores de FP, porque é uma operação mais importante. (A divisão inteira por constantes pode ser otimizada para multiplicar por uma inversa modular). Verifique as tabelas insn do Agner Fog e compare DIVSD (flutuação de precisão dupla) com DIV r32(número inteiro não assinado de 32 bits) ou DIV r64(número inteiro não assinado de 64 bits muito mais lento). Especialmente para taxa de transferência, a divisão de FP é muito mais rápida (uop único em vez de microcodificado e parcialmente canalizado), mas a latência também é melhor.
Peter Cordes

1
por exemplo, na CPU Haswell do OP: DIVSD é de 1 uop, latência de 10 a 20 ciclos, um por taxa de transferência de 8 a 14 c. div r64é 36 uops, latência 32-96c e um por throughput 21-74c. O Skylake possui uma taxa de transferência de divisão FP ainda mais rápida (canalizada a uma por 4c com latência não muito melhor), mas div inteiro não muito mais rápido. As coisas são semelhantes na família AMD Bulldozer: DIVSD é 1M-op, latência 9-27c, uma por taxa de transferência de 4.5-11c. div r64é 16M-ops, latência de 16-75c, uma por taxa de transferência de 16-75c.
Peter Cordes

1
A divisão FP não é basicamente a mesma que os expoentes de subtração de número inteiro, mantissa de divisão de número inteiro, detectam denormalidades? E esses 3 passos podem ser feitos em paralelo.
MSalters

2
@ MSalters: sim, isso parece certo, mas com uma etapa de normalização no final dos bits de deslocamento entre expoente e mantiss. doubletem uma mantissa de 53 bits, mas ainda é significativamente mais lenta que div r32em Haswell. Portanto, é definitivamente apenas uma questão de quanto hardware a Intel / AMD lança no problema, porque eles não usam os mesmos transistores para os divisores inteiros e fp. O número inteiro é escalar (não há divisão SIMD-número inteiro) e o vetor lida com vetores 128b (não 256b como outras ALUs vetoriais). O grande problema é que o número inteiro div é muitos uops, grande impacto no código circundante.
Peter Cordes

Erre, não mude os bits entre a mantissa e o expoente, mas normalize a mantissa com um turno e adicione o valor do turno ao expoente.
Peter Cordes

4

Como resposta genérica, não direcionada especificamente para esta tarefa: em muitos casos, você pode acelerar significativamente qualquer programa fazendo melhorias em alto nível. Como calcular dados uma vez em vez de várias vezes, evitando completamente o trabalho desnecessário, usando caches da melhor maneira e assim por diante. Essas coisas são muito mais fáceis de fazer em um idioma de alto nível.

Escrevendo código assembler, é possível melhorar o que um compilador otimizador faz, mas é um trabalho árduo. E uma vez feito, seu código fica muito mais difícil de modificar, por isso é muito mais difícil adicionar aprimoramentos algorítmicos. Às vezes, o processador possui uma funcionalidade que você não pode usar em um idioma de alto nível, o assembly embutido geralmente é útil nesses casos e ainda permite o uso de um idioma de alto nível.

Nos problemas de Euler, na maioria das vezes você consegue criar algo, descobrir por que é lento, criar algo melhor, descobrir por que é lento, e assim por diante. Isso é muito, muito difícil de usar o assembler. Um algoritmo melhor na metade da velocidade possível geralmente supera um algoritmo pior na velocidade máxima, e obter a velocidade total no assembler não é trivial.


2
Concordo totalmente com isso. gcc -O3criou um código que estava dentro de 20% do ideal em Haswell, para esse algoritmo exato. (Obter essas acelerações foi o foco principal da minha resposta apenas porque essa é a pergunta e tem uma resposta interessante, não porque é a abordagem correta.) Velocidades muito maiores foram obtidas a partir de transformações que dificilmente o compilador procuraria. , como adiar turnos à direita ou executar 2 etapas por vez. Acelerações muito maiores do que as obtidas em tabelas de memorização / pesquisa. Testes ainda exaustivos, mas não força bruta pura.
Peter Cordes

2
Ainda assim, ter uma implementação simples obviamente correta é extremamente útil para testar outras implementações. O que eu faria é provavelmente apenas olhar para a saída asm para ver se o gcc fez isso sem ramificações como eu esperava (principalmente por curiosidade) e depois passar para melhorias algorítmicas.
Peter Cordes

-2

A resposta simples:

  • fazer um MOV RBX, 3 e MUL RBX é caro; basta adicionar RBX, RBX duas vezes

  • ADD 1 é provavelmente mais rápido que INC aqui

  • MOV 2 e DIV é muito caro; apenas mude para a direita

  • O código de 64 bits geralmente é notavelmente mais lento que o código de 32 bits e os problemas de alinhamento são mais complicados; com programas pequenos como esse, é necessário empacotá-los para que você faça computação paralela para ter qualquer chance de ser mais rápido que o código de 32 bits

Se você gerar a lista de montagem para o seu programa C ++, poderá ver como ela difere da sua montagem.


4
1): adicionar 3 vezes seria burro em comparação com o LEA. Também mul rbxna CPU Haswell do OP há 2 uops com latência 3c (e 1 por taxa de transferência de clock). imul rcx, rbx, 3é apenas 1 uop, com a mesma latência 3c. Duas instruções ADD seriam 2 uops com latência 2c.
27578 Peter

5
2) ADD 1 é provavelmente mais rápido que INC aqui . Não, o OP não está usando um Pentium4 . O seu ponto 3) é a única parte correta desta resposta.
27568 Peter

5
4) parece absurdo total. O código de 64 bits pode ser mais lento com estruturas de dados pesadas em ponteiros, porque ponteiros maiores significam maior área de cobertura do cache. Mas esse código está funcionando apenas em registradores e os problemas de alinhamento de código são os mesmos no modo de 32 e 64 bits. (Assim como os problemas de alinhamento de dados, não há idéia do que você está falando, pois o alinhamento é um problema maior para x86-64). De qualquer forma, o código nem toca na memória dentro do loop.
27568 Peter

O comentarista não tem idéia do que está falando. Fazer um MOV + MUL em uma CPU de 64 bits será aproximadamente três vezes mais lento do que adicionar um registro a si mesmo duas vezes. Suas outras observações são igualmente incorretas.
precisa

6
Bem, MOV + MUL é definitivamente idiota, mas MOV + ADD + ADD ainda é bobo (na verdade, fazer ADD RBX, RBXduas vezes multiplicaria por 4, não 3). De longe, a melhor maneira é lea rax, [rbx + rbx*2]. Ou, ao custo de torná-lo um LEA de 3 componentes, faça o +1 também com lea rax, [rbx + rbx*2 + 1] (latência 3c no HSW em vez de 1, como expliquei na minha resposta). Meu argumento era que a multiplicação de 64 bits não é muito cara em recente Intel CPUs, porque eles têm insanamente rápido inteiro unidades multiplicam (mesmo em comparação com AMD, onde o mesmo MUL r64é latência 6c, com um por 4c capacidade:. nem mesmo totalmente pipeline
Peter Cordes
Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.