web-dev-qa-db-pt.com

Código C ++ para testar a conjectura Collatz mais rápido do que a montagem escrita à mão - por quê?

Eu escrevi estas duas soluções para Project Euler Q14 , em Assembly e em C++. Eles são a mesma abordagem de força bruta idêntica para testar o 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

Assembly, 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;
}

Eu sei sobre as otimizações do compilador para melhorar a velocidade e tudo mais, mas não vejo muitas maneiras de otimizar ainda mais a minha solução Assembly (falando programaticamente, não matematicamente).

O código C++ tem módulo a cada termo e divisão a cada termo, onde 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? Eu estou pedindo principalmente curiosidade.

Tempos de execução

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

782
jeffer son

Se você acha que uma instrução DIV de 64 bits é uma boa maneira de dividir por dois, não admira que a saída asm do compilador tenha batido em seu código escrito à mão, mesmo com -O0 (compilação rápida, sem otimização extra e armazenamento/atualização na memória após/antes de cada declaração C para que um depurador possa modificar variáveis).

Veja Guia de Montagem Otimizada da Agner Fog para aprender a escrever eficiente asm. Ele também possui tabelas de instruções e um guia de microarcas para detalhes específicos para CPUs específicas. Veja também o x86 tag wiki para links mais perf.

Veja também esta questão mais geral sobre bater o compilador com asm: É inline Assembly language mais lento que o código C++ nativo? . TL: DR: sim, se você fizer errado (como esta pergunta).

Normalmente você está bem deixando o compilador fazer sua parte, especialmente se você tentar escrever C++ que pode compilar eficientemente . Veja também é Assembly mais rápido que as linguagens compiladas? . Um dos links de respostas para esses slides legais mostrando como vários compiladores C otimizam algumas funções realmente simples com truques legais.


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

No Intel Haswell, div r64 é de 36 uops, com uma latência de 32-96 ciclos e um rendimento de um por 21-74 ciclos. (Além disso, os 2 uops para configurar o RBX e o zero RDX, mas a execução fora de ordem pode executá-los antecipadamente). Instruções high-uop-count como DIV são microcoded, o que também pode causar gargalos frontais. Nesse caso, a latência é o fator mais relevante, pois faz parte de uma cadeia de dependências carregada de loop.

shr rax, 1 faz a mesma divisão não assinada: É 1 uop, com 1c de latência , e pode rodar 2 por ciclo de clock.

Para comparação, a divisão de 32 bits é mais rápida, mas ainda horrível vs. idiv r32 tem 9 uops, 22-29c de latência e 1 por 8-11c de throughput em Haswell.


Como você pode ver na saída -O0 asm do gcc ( Compilador do compilador Godbolt ), ele usa somente instruções de turnos . clang -O0 compila ingenuamente como você pensou, mesmo usando IDIV de 64 bits duas vezes. (Ao otimizar, os compiladores usam ambas as saídas de IDIV quando a fonte faz uma divisão e um módulo com os mesmos operandos, se eles usam IDIV)

O GCC não tem um modo totalmente ingênuo; sempre se transforma através do GIMPLE, o que significa que algumas "otimizações" não podem ser desabilitadas . Isso inclui reconhecer divisão por constante e usar turnos (poder de 2) ou m inverso multiplicativo de ponto fixo (não poder de 2) para evitar IDIV (ver div_by_13 no link de Godbolt acima).

gcc -Os (otimizar para tamanho) faz usar IDIV para divisão sem energia de 2, infelizmente mesmo nos casos em que o código inverso multiplicativo é apenas um pouco maior, mas muito mais rápido.


Ajudando o compilador

(resumo para este caso: use uint64_t n)

Primeiro de tudo, é interessante observar a saída otimizada do compilador. (-O3). -O0 velocidade é basicamente sem sentido.

Olhe para sua saída asm (em Godbolt, ou veja Como remover "ruído" da saída GCC/clang Assembly? ). Quando o compilador não faz código ideal em primeiro lugar: Escrevendo sua fonte C/C++ de uma forma que orienta o compilador para fazer um código melhor é geralmente a melhor abordagem . Você precisa saber asm e saber o que é eficiente, mas você aplica esse conhecimento indiretamente. Compiladores também são uma boa fonte de idéias: às vezes o clang faz algo legal, e você pode segurar o gcc em fazer a mesma coisa: veja esta resposta e o que eu fiz com o loop não-desenrolado em @ Código Veedrac abaixo.

Esta abordagem é portátil, e em 20 anos algum compilador futuro pode compilá-lo para o que for eficiente em hardware futuro (x86 ou não), talvez usando a nova extensão ISA ou auto-vetorização. Escritos à mão x86-64 asm de 15 anos atrás normalmente não seriam otimizados para o Skylake. por exemplo. A comparação e ramificação da macro-fusão não existia naquela época. O que é ideal agora para as micro-arquiteturas feitas à mão pode não ser ideal para outras CPUs atuais e futuras. Comentários sobre a resposta do @ johnfound discutir as principais diferenças entre o AMD Bulldozer e o Intel Haswell, que têm um grande efeito nesse código. Mas, em teoria, g++ -O3 -march=bdver3 e g++ -O3 -march=skylake farão a coisa certa. (Ou -march=native.) Ou -mtune=... para apenas sintonizar, sem usar instruções que outras CPUs possam não suportar.

Meu sentimento é que orientar o compilador para que seja bom para uma CPU atual que você se preocupa não deve ser um problema para futuros compiladores. Eles são esperançosamente melhores que os compiladores atuais em encontrar maneiras de transformar o código, e podem encontrar uma maneira que funcione para futuras CPUs. Independente disso, o futuro x86 provavelmente não será terrível em nada que seja bom no x86 atual, e o futuro compilador evitará armadilhas específicas ao implementar algo como o movimento de dados de sua fonte C, se não vir algo melhor.

O asm escrito à mão é uma caixa preta para o otimizador, portanto, a propagação constante não funciona quando o inline torna uma entrada uma constante de 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 as entradas/saídas em série do tipo MSVC, que precisam passar pela memória o que adiciona sobrecarga .)

Nesse caso : o seu n tem um tipo assinado, e o gcc usa a sequência SAR/SHR/ADD que fornece o arredondamento correto. (IDIV e mudança aritmética "round" diferentemente para entradas negativas, veja o SAR insn set ref entrada manual ). (IDK se o gcc tentou e falhou em provar que n não pode ser negativo, ou o que. O comportamento de overflow assinado é indefinido, então deveria ter sido capaz de fazê-lo.)

Você deveria ter usado uint64_t n, então pode apenas SHR. E é portável para sistemas em que long é de apenas 32 bits (por exemplo, x86-64 Windows).


BTW, saída do asm do gcc otimizada parece muito boa (usando unsigned long n) : o loop interno que ela insere main() faz isto:

 # 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 laço interno é sem ramificação, e o caminho crítico da cadeia de dependências carregada de 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 ele realmente roda no 5c/iter).

A entrada FLAGS de cmov (produzida por 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. MOV no IvyBridge e, posteriormente, tem latência zero (manipulada no momento do registro-renomeação). (Ainda é preciso um uop e um slot no pipeline, então não é livre, apenas latência zero). O MOV extra na cadeia dep da LEA é parte do gargalo em outras CPUs.

O cmp/jne também não faz parte do caminho crítico: ele não é executado em loop, porque as dependências de controle são tratadas com predição de ramificação + execução especulativa, diferentemente das dependências de dados no caminho crítico.


Batendo o compilador

O GCC fez um ótimo trabalho aqui. Ele poderia salvar um byte de código usando inc edx EM VEZ DE add edx, 1 , porque ninguém se preocupa com P4 e suas dependências falsas para instruções de modificação de sinalização parcial.

Ele também pode salvar todas as instruções MOV, e o TEST: SHR define CF = o bit deslocado para fora, para que possamos usar cmovc em 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 no resultado do sinalizador de SHR, bem como usando-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 .Isso é como eles fizeram isso single-uop. A codificação especial shift-by-1 está bem , Apesar.)

Evitar o MOV não ajuda na latência do Haswell ( o MOV do x86 pode realmente ser "grátis"? Por que não consigo reproduzir isso? ). Isso ajuda significativamente em CPUs como Intel pré-IvB e família AMD Bulldozer, onde o MOV não possui latência zero. As instruções MOV desperdiçadas do compilador afetam o caminho crítico. O LEA e o CMOV complexos do BD têm baixa latência (2c e 1c respectivamente), portanto, é uma fração maior da latência. Além disso, os gargalos de 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 temporização de um processador AMD.

Mesmo em Haswell, esta versão pode ajudar um pouco, evitando alguns atrasos ocasionais nos quais um uop não crítico rouba uma porta de execução de um no caminho crítico, atrasando a execução em 1 ciclo. (Isso é chamado de conflito de recursos). Ele também salva um registrador, o que pode ajudar ao fazer múltiplos valores n em paralelo em um loop intercalado (veja abaixo).

A latência do LEA depende do modo de endereçamento , nas CPUs Intel SnB-family. 3c para 3 componentes ([base+idx+const], que recebe dois adds separados), mas apenas 1c com 2 ou menos componentes (um add). Algumas CPUs (como Core2) fazem até um LEA de 3 componentes em um único ciclo, mas a família SnB não. Pior, família Intel SnB padroniza as latências para que não haja 2c uops , caso contrário o LEA de 3 componentes seria apenas 2c como o Bulldozer. (LEA de 3 componentes é mais lento na AMD também, mas não tanto).

Portanto, lea rcx, [rax + rax*2]/inc rcx tem apenas 2c de latência, mais rápido que lea rcx, [rax + rax*2 + 1], em CPUs da família Intel SnB, como Haswell. Break-even no BD e pior no Core2. Custa um extra extra, o que normalmente não vale a pena economizar 1c de latência, mas a latência é o principal gargalo aqui e Haswell tem um pipeline amplo o suficiente para lidar com o throughput extra do uop.

Nem o gcc, icc nem o clang (no godbolt) usaram a saída CF do SHR, sempre usando um AND ou TEST . Compiladores bobos. : P Eles são grandes peças de maquinaria complexa, mas um ser humano inteligente pode muitas vezes vencê-los em problemas de pequena escala. (Dando milhares a milhões de vezes mais tempo para pensar nisso, é claro! Os compiladores não usam algoritmos exaustivos para procurar todas as maneiras possíveis de fazer as coisas, porque isso levaria muito tempo ao otimizar um monte de código embutido, que é o que Eles também não modelam o pipeline na microarquitetura de destino, pelo menos não no mesmo detalhe que IACA ou outras ferramentas de análise estática; eles usam apenas algumas heurísticas.)


Desdobramento de loop simples não ajudará ; Esse loop afunila na latência de uma cadeia de dependências carregada de loop, não na sobrecarga/taxa de transferência de loop. Isso significa que ele se sairia bem com hyperthreading (ou qualquer outro tipo de SMT), já que a CPU tem muito tempo para intercalar instruções de dois threads. Isso significaria paralelizar o loop em main, mas tudo bem porque cada thread pode apenas verificar um intervalo de valores n e produzir um par de inteiros como resultado.

A intercalação manual em um único thread pode ser viável também . Talvez calcule a seqüência de um par de números em paralelo, já que cada um leva apenas alguns registros, e todos eles podem atualizar o mesmo max/maxi. Isso cria mais paralelismo no nível de instrução .

O truque é decidir se deve esperar até que todos os valores n atinjam 1 antes de obter outro par de valores n de início, ou se deseja sair e obter um novo ponto inicial para apenas um que atingiu a condição final, sem tocar nos registradores do outra sequência. Provavelmente, é melhor manter cada cadeia trabalhando em dados úteis, caso contrário, você teria que incrementar condicionalmente seu contador.


Você poderia até mesmo fazer isso com SSE coisas de comparação de pacotes para incrementar condicionalmente o contador de elementos vetoriais onde n ainda não tinha atingido 1. E para ocultar a latência ainda mais longa de uma implementação de incremento condicional de SIMD, você precisaria manter mais vetores de valores n no ar. Talvez valha apenas com o vetor 256b (4x uint64_t).

Acho que a melhor estratégia para tornar a detecção de um 1 "pegajoso" é mascarar o vetor de todos os que você adiciona para incrementar o contador. Então, depois de ver um 1 em um elemento, o vetor de incremento terá um zero e + = 0 é um não operacional.

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

    # There may be a better way to do this blend, avoiding the bypass delay for an FP blend between integer insns, not sure.  Probably worth it
    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ínsecos, em vez de escritos à mão.


Melhoria algorítmica/implementação:

Além de apenas implementar a mesma lógica com um algoritmo mais eficiente, procure maneiras de simplificar a lógica ou evitar trabalho redundante. por exemplo. memoize para detectar terminações comuns em seqüências. Ou melhor ainda, olhe para 8 bits de uma vez (a resposta de gnasher)

@EOF aponta que tzcnt (ou bsf) pode ser usado para fazer várias iterações n/=2 em uma etapa. Isso é provavelmente melhor que a vetorização SIMD, porque nenhuma instrução SSE ou AVX pode fazer isso. Ainda é compatível com vários ns escalares em paralelo em diferentes registradores inteiros, no entanto.

Então o loop pode ser 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 significativamente 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, 2c latência. (Eles têm uma dependência de entrada nos FLAGS porque count = 0 significa que os flags não são modificados. Eles lidam com isso como uma dependência de dados e tomam vários uops porque um uop pode ter apenas 2 entradas (pré-HSW/BDW)). Esse é o tipo que as pessoas reclamando sobre o design crazy-CISC do x86 estão se referindo. Ele torna os processadores x86 mais lentos do que seriam se o ISA fosse projetado a partir do zero hoje, mesmo de maneira quase semelhante. (isto é, faz parte do "imposto x86" que custa velocidade/potência). SHRX/SHLX/SARX (BMI2) são uma grande vitória (latência de 1 uop/1c).

Ele também coloca tzcnt (3c em Haswell e mais tarde) no caminho crítico, de forma que aumenta significativamente a latência total da cadeia de dependências carregada por loop. Ele remove qualquer necessidade de um CMOV, ou para preparar um registrador contendo n>>1, no entanto. @ A resposta de Veedrac supera tudo isso, adiando o tzcnt/shift para múltiplas iterações, o que é altamente efetivo (veja abaixo).

Podemos usar com segurança BSF ou TZCNT alternadamente, porque n nunca pode ser zero nesse ponto. O código de máquina do TZCNT é decodificado como BSF em CPUs que não suportam BMI1. (Prefixos sem sentido são ignorados, então o REP BSF é executado como BSF).

O TZCNT tem um desempenho muito melhor que o da BSF nos processadores da AMD que o suportam, portanto, pode ser uma boa idéia usar o REP BSF, mesmo que você não se importe em configurar o ZF se a entrada for zero em vez da saída. Alguns compiladores fazem isso quando você usa __builtin_ctzll mesmo com -mno-bmi.

Eles têm o mesmo desempenho nos processadores da Intel, portanto, salve o byte, se isso for o mais importante. O TZCNT no Intel (pré-Skylake) ainda possui uma dependência falsa do operando de saída supostamente somente de gravação, assim como o BSF, para suportar o comportamento não documentado que o BSF com entrada = 0 deixa seu destino inalterado. Então você precisa contornar isso a menos que seja apenas otimizado para o Skylake, então não há nada a ganhar com o byte REP extra. (A Intel frequentemente vai além do que o manual x86 ISA requer, para evitar quebrar códigos amplamente utilizados que dependam de algo que não deveria, ou que seja retroativamente desautorizado. Exemplo o Windows 9x assume nenhuma pré-busca especulativa de entradas de TLB , que era segura quando o código foi escrito, antes da Intel atualizar as regras de gerenciamento de TLB .)

De qualquer forma, LZCNT/TZCNT em Haswell tem o mesmo dep de falso que POPCNT: veja este Q & A . É por isso que na saída asm do gcc para o código @ Veedrac, você o vê quebrando a cadeia dep com xor-zero no registrador que está prestes a usar como destino do TZCNT, quando ele não usa dst = src . Como o TZCNT/LZCNT/POPCNT nunca deixa seu destino indefinido ou não modificado, essa falsa dependência na saída dos processadores Intel é puramente um erro/limitação de desempenho. Presumivelmente, vale a pena alguns transistores/poder fazer com que eles se comportem como outros uops que vão para a mesma unidade de execução. A única vantagem visível do software está na interação com outra limitação microarquitetural: eles podem micro-Fusibilizar um operando de memória com um modo de endereçamento indexado em Haswell, mas no Skylake onde a Intel removeu a falsa dependência do LZCNT/TZCNT eles "un-laminate" modos de endereçamento indexados enquanto POPCNT ainda pode micro-Fuse qualquer modo addr.


Melhorias nas idéias/código de outras respostas:

@ hidefromkgb's answer tem uma boa observação de que você tem a garantia de poder fazer um turno certo depois de um 3n + 1. Você pode calcular isso de forma ainda mais eficiente do que simplesmente deixar de fora as verificações entre as etapas. A implementação asm nessa resposta está quebrada (depende de OF, que é indefinida após SHRD com uma contagem> 1), e lenta: ROR rdi,2 é mais rápida que SHRD rdi,rdi,2, e usar duas instruções CMOV no caminho crítico é mais lento que um TEST extra que pode ser executado em paralelo.

Eu coloquei o C limpo/melhorado (que guia o compilador para produzir um melhor asm), e testei + trabalhando mais rápido asm (nos comentários abaixo do C) no Godbolt: veja o link em @ resposta do hidefromkgb . (Essa resposta atingiu o limite de caracteres de 30k das grandes URLs Godbolt, mas os links curtos podem apodrecer e eram muito longos para o goo.gl de qualquer maneira.)

Também melhorou a impressão de saída para converter em uma string e fazer uma write() em vez de escrever um caractere por vez. Isso minimiza o impacto no tempo de todo o programa com perf stat ./collatz (para registrar os contadores de desempenho) e eu ofuscuei um pouco do asm não-crítico.


@ código de Veedrac

Eu obtive uma aceleração muito pequena da mudança para a direita tanto quanto nós sabemos que precisa fazer, e checando para continuar o loop. De 7,5s para limite = 1e8 até 7,275s, no Core2Duo (Merom), com um fator de unroll de 16.

código + comentários em Godbolt . Não use esta versão com clang; faz algo bobo com o loop deferente. Usar um contador tmp k e depois adicioná-lo a count mais tarde altera o que o clang faz, mas isso levemente prejudica o gcc.

Veja a discussão nos comentários: O código do Veedrac é excelente em CPUs com BMI1 (ou seja, não Celeron/Pentium)

1823
Peter Cordes

A alegação de que o compilador C++ pode produzir mais código ideal do que um programador de linguagem Assembly competente é um erro muito grave. E especialmente neste caso. O humano sempre pode tornar o código melhor que o compilador, e essa situação específica é uma boa ilustração dessa afirmação.

A diferença de tempo que você está vendo é porque o código Assembly na questão está muito longe de ser ótimo nos loops internos.

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

Por exemplo, a função de seqüê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

O código inteiro parece:

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 este código, FreshLib é necessário.

Em 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 com -O3.

A saída de ambos os programas é a mesma: max sequence = 525 em i = 837799.

96
johnfound

Para mais performance: Uma mudança simples é observar que após n = 3n + 1, n será par, então você pode dividir por 2 imediatamente. E n não será 1, então você não precisa testar para isso. Então você pode salvar algumas instruçõ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á um grande win: Se você olhar para os 8 bits mais baixos de n, todos os passos até você dividido por 2 oito vezes são completamente determinados por esses oito bits. Por exemplo, se os últimos oito bits forem 0x01, isso é binário, seu número é ???? 0000 0001, em seguida, os próximos passos 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, todos esses passos podem ser previstos, e 256k + 1 é substituído por 81k + 1. Algo semelhante acontecerá para todas as combinações. Então você pode fazer um loop com uma grande instrução 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, pois nesse ponto n pode se tornar 1 com menos de oito divisões por 2, e fazer oito ou mais etapas de cada vez faria com que você perdesse o ponto em que você alcança 1 pela primeira vez. Em seguida, continue com o loop "normal" - ou prepare uma tabela que informe quantas etapas mais precisam atingir 1.

PS. Eu suspeito fortemente que a sugestão de Peter Cordes tornaria ainda mais rápido. 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ê mediria se o processamento dos últimos 9, 10, 11, 12 bits de n de cada vez seria mais rápido. Para cada bit, o número de entradas na tabela dobraria, e eu excito uma desaceleração quando as tabelas não se encaixam mais no cache L1.

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

Poderíamos redefinir ligeiramente o problema: Substitua n por (3n + 1)/2 se ímpar e substitua n por n/2 se par. Então, cada iteração fará exatamente 8 passos, mas você pode considerar que trapacear :-) Então, suponha que houvesse r operações 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 de qualquer ponto inicial n ≤ 1.000.000, calcular r como acima, arredondado para o inteiro mais próximo, dará o resultado correto a menos que s seja realmente grande.

21
gnasher729

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

  • [a primeira "conjectura" foi finalmente desmascarada pelo @ShreevatsaR; removido]

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

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

    Para ultrapassar esses dois elementos significa calcular (N >> 1) + N + 1, ((N << 1) + N + 1) >> 1 e 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 é de 2 bits e seus bits são ba de mais a menos significativo, então a = 1, e o seguinte é válido:

    (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.

    Q.E.D .: (N & 1) == 1 ⇒ (N >> 1) + N + 1 == ((N << 1) + N + 1) >> 1.

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

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 > 2 porque o processo pode parar em 2 em vez de 1 se o comprimento total da sequência for ímpar.

[EDITAR:]

Vamos traduzir isso em Assembly!

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 melhorada/corrigida do asm de Peter Cordes em Godbolt . (nota do editor: Desculpe por colocar minhas coisas em sua resposta, mas minha resposta atingiu o limite de caracteres de 30k dos links Godbolt + texto!)

18
hidefromkgb

Programas C++ são traduzidos para programas Assembly 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 do compilador para o compilador. Portanto, um compilador C++ inteligente pode produzir código binário mais otimizado e eficiente que o código de um montador burro.

No entanto, acredito que sua metodologia de criação de perfil tenha certas falhas. A seguir estão as diretrizes gerais para criação de perfil:

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

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

Exemplo de implementação de 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 __== "__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))
5
Emanuel Landeholm

Mesmo sem olhar para o Assembly, o motivo mais óbvio é que /= 2 é provavelmente otimizado como >>=1 e muitos processadores têm uma operação de mudança muito rápida. Mas, mesmo que um processador não tenha uma operação de deslocamento, a divisão de inteiros é mais rápida que a divisão de ponto flutuante.

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


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

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

Então toda a declaração seria então

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

De comentários:

Mas, esse código nunca pára (por causa do estouro de inteiro)! Yves Daoust

Para muitos números, isso irá não estouro.

Se for overflow - para uma daquelas sementes iniciais desafortunadas, o número sobrevalorizado provavelmente convergirá para 1 sem outro estouro.

Ainda assim, isso levanta uma questão interessante: existe algum número de sementes cíclico e transbordante?

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

2 ^ 64 irá transbordar para zero, o que é indefinido loop infinito de acordo com o algoritmo (termina apenas com 1), mas a solução mais ideal em resposta terminará devido a shr rax produzindo ZF = 1.

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

Que tal um número mais complexo, que irá 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 transformará lentamente 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 estouro pode nos atrapalhar.

Então eu coloquei poucos números na planilha e dei uma olhada nos números truncados de 8 bits.

Há três valores transbordando para 0: 227, 170 e 85 (85 indo diretamente para 0, outros dois progredindo para 85).

Mas não há valor criando sementes de estouro cíclico.

Curiosamente eu fiz um cheque, que é o primeiro número a sofrer de truncamento de 8 bits, e o 27 já está afetado! Ele atinge o valor 9232 em séries não truncadas (o primeiro valor truncado é 322 no 12º passo), e o valor máximo alcançado para qualquer um dos 2-255 números de entrada de maneira não truncada é 13120 (para o 255 propriamente dito), O número máximo de passos para convergir para 1 é sobre 128 (+ -2, não tenho certeza se "1" é contar, etc ...).

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

Infelizmente eu não consigo entender nada dessa série, por que ela converge e quais são as implicações de truncá-las para k bits, mas com cmp number,1 condição de terminação é certamente possível colocar o algoritmo em loop infinito com valor de entrada particular terminando como 0 após o truncamento.

Mas o valor 27 overflowing para o caso de 8 bits é um tipo de alerta, isso parece que se você contar o número de passos para alcançar o valor 1, você obterá resultado errado para a maioria dos números do conjunto total de k-bit de inteiros. Para os inteiros de 8 bits, os 146 números de 256 afetaram a série por truncamento (alguns deles ainda podem acertar o número correto de etapas por acidente, talvez, eu estou com preguiça de checar).

4
Ped7g

Você não postou o código gerado pelo compilador, então há algumas adivinhações aqui, mas mesmo sem ter visto, pode-se dizer isso:

test rax, 1
jpe even

... tem 50% de chance de interpretar erroneamente o ramo, e isso será caro.

O compilador quase certamente faz os dois cálculos (o que custa neglegivelmente mais, já que o div/mod é uma latência bastante longa, de modo que o multiplexador é "livre") e segue com um CMOV. Que, claro, tem uma chance de zero por cento de ser mal interpretada.

4
Damon

Como uma resposta genérica, não especificamente direcionada para esta tarefa: em muitos casos, você pode acelerar significativamente qualquer programa fazendo melhorias em um nível alto. 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 uma linguagem 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 é muito mais difícil de modificar, então é muito mais difícil adicionar melhorias algorítmicas. Às vezes, o processador tem uma funcionalidade que você não pode usar em uma linguagem de alto nível, o Assembly em linha geralmente é útil nesses casos e ainda permite usar uma linguagem de alto nível.

Nos problemas de Euler, a maior parte do tempo você consegue construindo algo, descobrindo porque é lento, construindo algo melhor, descobrindo por que é lento, e assim por diante. Isso é muito, muito difícil usando o assembler. Um algoritmo melhor a metade da velocidade possível geralmente irá bater um algoritmo pior a toda velocidade, e obter a velocidade máxima em assembler não é trivial.

3
gnasher729