performance - tcc - Entendendo o impacto da confiança em um loop com duas longas cadeias de dependência, para aumentar comprimentos




liderança organizacional pdf (2)

Apresentarei uma análise para o caso em que T = 1 para ambos os códigos (com e sem lfence ). Você pode estender isso para outros valores de T. Você pode consultar a Figura 2.4 do Intel Optimization Manual para um visual.

Como há apenas um único branch facilmente previsto, o frontend irá apenas parar se o backend parar. O frontend é 4-wide em Haswell, o que significa que até 4 uops fundidos podem ser emitidos a partir do IDQ (fila de decodificação de instruções, que é apenas uma fila que mantém uops de domínio fundido em ordem, também chamados de fila uop). estação de reserva (RS) entra no escalonador. Cada imul é decodificado em um único uop que não pode ser fundido. As instruções dec ecx e jnz .loop ficam macrofundidas no frontend para um único uop. Uma das diferenças entre microfusão e macrofusão é que quando o agendador despacha um uop macrofuso (que não é microfusado) para a unidade de execução a que está atribuído, ele é despachado como um único uop. Em contraste, um utop microfusado precisa ser dividido em seus uops constituintes, cada um dos quais deve ser despachado separadamente para uma unidade de execução. (No entanto, a divisão de uops microfonados acontece na entrada do RS, não no envio, consulte a Nota de rodapé 2 na @ resposta de Peter). lfence é decodificada em 6 uops. O reconhecimento de microfusão só importa no backend e, neste caso, não há microfusão no loop.

Como o ramo de loop é facilmente previsível e como o número de iterações é relativamente grande, podemos assumir sem comprometer a precisão de que o alocador sempre será capaz de alocar 4 uops por ciclo. Em outras palavras, o agendador receberá 4 uops por ciclo. Como não há micorfusão, cada uop será despachado como um único uop.

imul só pode ser executado pela unidade de execução Slow Int (veja a Figura 2.4). Isso significa que a única opção para executar o imul uops é despachá-los para a porta 1. Em Haswell, o Slow Int é bem canalizado para que um único imul seja despachado por ciclo. Mas são necessários três ciclos para que o resultado da multiplicação esteja disponível para qualquer instrução que exija (o estágio de writeback é o terceiro ciclo do estágio de expedição do pipeline). Assim, para cada cadeia de dependência, no máximo um imul pode ser despachado por 3 ciclos.

Como dec/jnz é previsto, a única unidade de execução que pode executá-lo é Primary Branch na porta 6.

Então, em qualquer ciclo dado, desde que o RS tenha espaço, ele receberá 4 uops. Mas que tipo de uops? Vamos examinar o loop sem confiança:

imul eax, eax
imul edx, edx
dec ecx/jnz .loop (macrofused)

Existem duas possibilidades:

  • Dois imul s da mesma iteração, um imul de uma iteração vizinha e um dec/jnz de uma dessas duas iterações.
  • Um dec/jnz de uma iteração, dois imul s da próxima iteração e um dec/jnz da mesma iteração.

Assim, no início de qualquer ciclo, o RS receberá pelo menos um dec/jnz e pelo menos um imul de cada cadeia. Ao mesmo tempo, no mesmo ciclo e daqueles uops que já existem no RS, o agendador fará uma das duas ações:

  • Despachar o mais antigo dec/jnz para a porta 6 e despachar o imul mais imul que está pronto para a porta 1. Isso é um total de 2 uops.
  • Como a Int lenta possui uma latência de 3 ciclos, mas existem apenas duas correntes, para cada ciclo de 3 ciclos, nenhuma imul no RS estará pronta para execução. No entanto, há sempre pelo menos um dec/jnz no RS. Portanto, o agendador pode despachar isso. Isso é um total de 1 uop.

Agora podemos calcular o número esperado de uops no RS, X N , no final de qualquer ciclo dado N:

X N = X N-1 + (o número de uops a serem alocados no RS no início do ciclo N) - (o número esperado de uops que serão despachados no início do ciclo N)
= X N-1 + 4 - ((0 + 1) * 1/3 + (1 + 1) * 2/3)
= X N-1 + 12/3 - 5/3
= X N-1 + 7/3 para todos os N> 0

A condição inicial para a recorrência é X 0 = 4. Esta é uma recorrência simples que pode ser resolvida pelo desdobramento de X N-1 .

X N = 4 + 2,3 * N para todo N> = 0

O RS em Haswell tem 60 entradas. Podemos determinar o primeiro ciclo em que se espera que o RS fique cheio:

60 = 4 + 7/3 * N
N = 56 / 2,3 = 24,3

Portanto, no final do ciclo 24.3, espera-se que o RS esteja cheio. Isso significa que no início do ciclo 25.3, o RS não poderá receber novos uops. Agora o número de iterações, eu, sob consideração determina como você deve proceder com a análise. Como uma cadeia de dependência requer pelo menos 3 * ciclos para execução, são necessárias cerca de 8,1 iterações para atingir o ciclo 24,3. Então, se o número de iterações for maior que 8.1, o que é o caso aqui, você precisa analisar o que acontece após o ciclo 24.3.

O agendador envia instruções nas seguintes taxas a cada ciclo (como discutido acima):

1
2
2
1
2
2
1
2
.
.

Mas o alocador não alocará nenhum uops no RS, a menos que haja pelo menos 4 entradas disponíveis. Caso contrário, ele não desperdiçará energia na emissão de uops em uma taxa de transferência abaixo do ideal. No entanto, é apenas no início de cada 4º ciclo que existem pelo menos 4 entradas livres no RS. Portanto, a partir do ciclo 24.3, espera-se que o alocador fique parado em 3 de cada 4 ciclos.

Outra observação importante para o código sendo analisado é que nunca acontece que existam mais de 4 uops que podem ser despachados, o que significa que o número médio de uops que deixam suas unidades de execução por ciclo não é maior que 4. No máximo 4 uops pode ser retirado do Buffer ReOrder (ROB). Isso significa que o ROB nunca pode estar no caminho crítico. Em outras palavras, o desempenho é determinado pelo rendimento do despacho.

Podemos calcular o IPC (instruções por ciclos) com bastante facilidade agora. As entradas do ROB são algo como isto:

imul eax, eax     -  N
imul edx, edx     -  N + 1
dec ecx/jnz .loop -  M
imul eax, eax     -  N + 3
imul edx, edx     -  N + 4
dec ecx/jnz .loop -  M + 1

A coluna à direita mostra os ciclos nos quais a instrução pode ser retirada. A aposentadoria acontece em ordem e é limitada pela latência do caminho crítico. Aqui cada cadeia de dependência tem o mesmo comprimento de caminho e, portanto, ambos constituem dois caminhos críticos iguais de 3 ciclos de comprimento. Assim, a cada 3 ciclos, 4 instruções podem ser retiradas. Portanto, o IPC é 4/3 = 1,3 e o IPC é 3/4 = 0,75. Isso é muito menor que o IPC ótimo teórico de 4 (mesmo sem considerar a micro e a macro fusão). Como a aposentadoria acontece em ordem, o comportamento de aposentadoria será o mesmo.

Podemos verificar nossa análise usando o perf e o IACA. Eu vou discutir o perf . Eu tenho um processador Haswell.

perf stat -r 10 -e cycles:u,instructions:u,cpu/event=0xA2,umask=0x10,name=RESOURCE_STALLS.ROB/u,cpu/event=0x0E,umask=0x1,cmask=1,inv=1,name=UOPS_ISSUED.ANY/u,cpu/event=0xA2,umask=0x4,name=RESOURCE_STALLS.RS/u ./main-1-nolfence

 Performance counter stats for './main-1-nolfence' (10 runs):

         30,01,556      cycles:u                                                      ( +-  0.00% )
         40,00,005      instructions:u            #    1.33  insns per cycle          ( +-  0.00% )
                 0      RESOURCE_STALLS.ROB                                         
         23,42,246      UOPS_ISSUED.ANY                                               ( +-  0.26% )
         22,49,892      RESOURCE_STALLS.RS                                            ( +-  0.00% )

       0.001061681 seconds time elapsed                                          ( +-  0.48% )

Existem 1 milhão de iterações, cada uma com cerca de 3 ciclos. Cada iteração contém 4 instruções e o IPC é 1.33. RESOURCE_STALLS.ROB mostra o número de ciclos em que o alocador foi parado devido a um ROB completo. Isso, claro, nunca acontece. UOPS_ISSUED.ANY pode ser usado para contar o número de uops emitidos para o RS e o número de ciclos em que o alocador foi parado (sem motivo específico). O primeiro é simples (não mostrado na saída do perf ); 1 milhão * 3 = 3 milhões + pequeno ruído. Este último é muito mais interessante. Isso mostra que cerca de 73% de todos os tempos que o alocador parou devido a um RS completo, o que corresponde à nossa análise. RESOURCE_STALLS.RS conta o número de ciclos em que o alocador ficou parado devido a um RS completo. Isso está próximo de UOPS_ISSUED.ANY porque o alocador não fica UOPS_ISSUED.ANY por qualquer outro motivo (embora a diferença possa ser proporcional ao número de iterações por algum motivo, terei que ver os resultados para T> 1).

A análise do código sem lfence pode ser estendida para determinar o que acontece se um lfence for adicionado entre os dois imul s. Vamos verificar primeiro os resultados do perf (a IACA infelizmente não suporta o lfence ):

perf stat -r 10 -e cycles:u,instructions:u,cpu/event=0xA2,umask=0x10,name=RESOURCE_STALLS.ROB/u,cpu/event=0x0E,umask=0x1,cmask=1,inv=1,name=UOPS_ISSUED.ANY/u,cpu/event=0xA2,umask=0x4,name=RESOURCE_STALLS.RS/u ./main-1-lfence

 Performance counter stats for './main-1-lfence' (10 runs):

       1,32,55,451      cycles:u                                                      ( +-  0.01% )
         50,00,007      instructions:u            #    0.38  insns per cycle          ( +-  0.00% )
                 0      RESOURCE_STALLS.ROB                                         
       1,03,84,640      UOPS_ISSUED.ANY                                               ( +-  0.04% )
                 0      RESOURCE_STALLS.RS                                          

       0.004163500 seconds time elapsed                                          ( +-  0.41% )

Observe que o número de ciclos aumentou em cerca de 10 milhões ou 10 ciclos por iteração. O número de ciclos não nos diz muito. O número de instruções retiradas aumentou em um milhão, o que é esperado. Nós já sabemos que a lfence não tornará a instrução completa mais rápida, portanto, RESOURCE_STALLS.ROB não deve mudar. UOPS_ISSUED.ANY e RESOURCE_STALLS.RS são particularmente interessantes. Nesta saída, UOPS_ISSUED.ANY conta ciclos, não uops. O número de uops também pode ser contado (usando cpu/event=0x0E,umask=0x1,name=UOPS_ISSUED.ANY/u vez de cpu/event=0x0E,umask=0x1,cmask=1,inv=1,name=UOPS_ISSUED.ANY/u ) e aumentou 6 uops por iteração (sem fusão). Isso significa que uma lfence que foi colocada entre duas imul foi decodificada em 6 uops. A questão de um milhão de dólares é agora o que esses uops fazem e como eles se movimentam no tubo.

RESOURCE_STALLS.RS é zero. O que isso significa? Isso indica que o alocador, quando vê uma lfence no IDQ, pára de alocar até que todos os uops atuais no ROB sejam retirados. Em outras palavras, o alocador não alocará entradas no RS após uma lfence até que a lfence se aposente. Como o corpo do loop contém apenas 3 outros uops, o RS de 60 entradas nunca estará cheio. Na verdade, será sempre quase vazio.

O IDQ, na realidade, não é uma fila simples e simples. Consiste em várias estruturas de hardware que podem operar em paralelo. O número de ligações necessárias depende do design exato do IDQ. O alocador, que também consiste em muitas estruturas de hardware diferentes, quando ele vê que existe uma lfence na frente de qualquer uma das estruturas do IDQ, ele suspende a alocação dessa estrutura até que o ROB esteja vazio. Uops tão diferentes são usados ​​com diferentes estruturas de hardware.

UOPS_ISSUED.ANY mostra que o alocador não está emitindo nenhum uop por cerca de 9 a 10 ciclos por iteração. O que esta acontecendo aqui? Bem, um dos usos da lfence é que ela pode nos dizer quanto tempo leva para retirar uma instrução e alocar a próxima instrução. O código de montagem a seguir pode ser usado para fazer isso:

TIMES T lfence

Os contadores de eventos de desempenho não funcionarão bem para valores pequenos de T Para T suficientemente grande e medindo UOPS_ISSUED.ANY , podemos determinar que são necessários cerca de 4 ciclos para se aposentarem cada lfence . Isso porque o UOPS_ISSUED.ANY será incrementado cerca de 4 vezes a cada 5 ciclos. Portanto, a cada quatro ciclos, o alocador emitirá outra lfence (não lfence ), aguardará outros 4 ciclos e assim por diante. Dito isso, as instruções que produzem resultados podem exigir um ou mais ciclos para se aposentar, dependendo da instrução. A IACA sempre assume que são necessários 5 ciclos para retirar uma instrução.

Nosso loop é assim:

imul eax, eax
lfence
imul edx, edx
dec ecx
jnz .loop

Em qualquer ciclo no limite de lfence , o ROB conterá as seguintes instruções, começando no topo do ROB (a instrução mais antiga):

imul edx, edx     -  N
dec ecx/jnz .loop -  N
imul eax, eax     -  N+1

Onde N denota o número do ciclo no qual a instrução correspondente foi enviada. A última instrução que vai completar (alcançar o estágio writeback) é imul eax, eax . e isso acontece no ciclo N + 4. A contagem do ciclo de parada do alocador será incrementada durante os ciclos, N + 1, N + 2, N + 3 e N + 4. No entanto, cerca de mais 5 ciclos até imul eax, eax se aposenta. Além disso, após a lfence , o alocador precisa limpar a lfence do IDQ e alocar o próximo grupo de instruções antes de poder ser despachado no próximo ciclo. A saída do perf indica-nos que são necessários cerca de 13 ciclos por iteração e que o alocador lfence (por causa da lfence ) por 10 desses 13 ciclos.

O gráfico da pergunta mostra apenas o número de ciclos para até T = 100. No entanto, há outro joelho (final) neste momento. Portanto, seria melhor plotar os ciclos para até T = 120 para ver o padrão completo.

Eu estava jogando com o código nesta resposta , modificando-o ligeiramente:

BITS 64

GLOBAL _start

SECTION .text

_start:
 mov ecx, 1000000

.loop:

 ;T is a symbol defined with the CLI (-DT=...)

 TIMES T imul eax, eax
 lfence
 TIMES T imul edx, edx


 dec ecx
jnz .loop

 mov eax, 60           ;sys_exit
 xor edi, edi
 syscall

Sem o resultado, os resultados que obtenho são consistentes com a análise estática nessa resposta.

Quando eu introduzo uma única lfence eu espero que a CPU execute a imul edx, edx da k-ésima iteração em paralelo com a imul eax, eax da próxima iteração ( k + 1-th ).
Algo parecido com isto (chamando A imul eax, eax sequência e D o imul edx, edx um):

|
| A
| D A
| D A
| D A
| ...
| D A
| D
|
V time

Tomando mais ou menos o mesmo número de ciclos, mas para uma execução paralela não emparelhada.

Quando eu medir o número de ciclos, para a versão original e modificada, com o taskset -c 2 ocperf.py stat -r 5 -e cycles:u '-x ' ./main-$T para T no intervalo abaixo recebo

T   Cycles:u    Cycles:u    Delta
    lfence      no lfence

10  42047564    30039060    12008504
15  58561018    45058832    13502186
20  75096403    60078056    15018347
25  91397069    75116661    16280408
30  108032041   90103844    17928197
35  124663013   105155678   19507335
40  140145764   120146110   19999654
45  156721111   135158434   21562677
50  172001996   150181473   21820523
55  191229173   165196260   26032913
60  221881438   180170249   41711189
65  250983063   195306576   55676487
70  281102683   210255704   70846979
75  312319626   225314892   87004734
80  339836648   240320162   99516486
85  372344426   255358484   116985942
90  401630332   270320076   131310256
95  431465386   285955731   145509655
100 460786274   305050719   155735555

Como os valores dos Cycles:u lfence ser explicados?
Eu esperaria que eles fossem semelhantes aos de Cycles:u no lfence uma vez que uma única lfence deveria impedir que apenas a primeira iteração fosse executada em paralelo para os dois blocos.
Eu não acho que é devido à sobrecarga de lfence como eu acredito que deveria ser constante para todos os lfence .

Eu gostaria de consertar o que há de errado com o meu formulário ao lidar com a análise estática do código.

Repositório de suporte com arquivos de origem .


Eu acho que você está medindo com precisão, e a explicação é microarquitetural, não qualquer tipo de erro de medição.

Eu acho que seus resultados para mid to low T apóiam a conclusão de que a lfence pára o front-end de até mesmo passar da lfence até que todas as instruções anteriores se retirem , em vez de ter todos os uops de ambas as cadeias já emitidos e apenas esperando por alterne e deixe multiplicações de cada cadeia começar a despachar em ciclos alternados.

(o port1 teria edx, eax, vazio, edx, eax, empty, ... para o multiplicador de rendimento 3c de latência / 1c de Skylake imediatamente, se o lfence não bloqueasse o front-end e a sobrecarga não lfence com T. )

Você está perdendo imul taxa de transferência imul quando apenas os uops da primeira cadeia estão no agendador porque o front-end ainda não imul edx,edx o imul edx,edx e loop. E para o mesmo número de ciclos no final da janela, quando o gasoduto é drenado e apenas os uops da segunda cadeia são deixados.

O delta de overhead parece linear até cerca de T = 60. Não corri os números, mas a inclinação até lá parece razoável para T * 0.25 relógios T * 0.25 emitirem o primeiro gargalo de execução da cadeia versus o gargalo de execução de latência 3c. isto é, o delta crescendo talvez 1/12 tão rápido quanto os ciclos totais de não-influência .

Então (dada a sobrecarga de lfence que medi abaixo), com T <60:

no_lfence cycles/iter ~= 3T                  # OoO exec finds all the parallelism
lfence    cycles/iter ~= 3T + T/4 + 9.3      # lfence constant + front-end delay
                delta ~=      T/4 + 9.3

@Margaret relata que T/4 é melhor que 2*T / 4 , mas eu esperava T / 4 no início e no final, para um total de 2T / 4 de inclinação do delta.

Após cerca de T = 60, o delta cresce muito mais rapidamente (mas ainda linearmente), com um declive aproximadamente igual ao total dos ciclos de não-influência, portanto cerca de 3c por T. Acho que nesse ponto, o tamanho do agendador (Estação de Reserva) é limitando a janela fora de ordem. Você provavelmente testou em um Haswell ou Sandybridge / IvyBridge, ( que tem um agendador de 60 entradas ou 54 entradas, respectivamente . O Skylake tem 97 entradas.

O RS rastreia uops não executados. Cada entrada RS contém um domínio não fundido que aguarda que as entradas estejam prontas e sua porta de execução, antes de poder despachar e deixar o RS 1 .

Depois de uma lfence , o front-end emite 4 por clock enquanto o back-end executa em 1 por 3 clocks, emitindo 60 uops em ~ 15 ciclos, durante os quais apenas 5 imul instruções da cadeia edx foram executadas. (Não há carga ou armazena micro-fusão aqui, então cada uop de domínio fundido do front-end ainda é apenas um domínio não fundido no RS2.)

Para T grande, o RS rapidamente se enche, ponto no qual o front-end só pode progredir na velocidade do back-end. (Para o pequeno T, atingimos o nível da próxima iteração antes que isso aconteça, e é isso que impede o front-end). Quando T> RS_size , o back-end não pode ver nenhum dos uops da corrente de imersão até que um avanço de back-end suficiente pela cadeia edx tenha espaço no RS. Nesse ponto, um imul de cada cadeia pode despachar a cada 3 ciclos, em vez de apenas a primeira ou a segunda cadeia.

Lembre-se, desde a primeira seção, que o tempo gasto logo após a execução apenas da primeira cadeia = tempo antes da execução, executa apenas a segunda cadeia. Isso se aplica aqui também.

Nós obtemos um pouco desse efeito mesmo sem nenhuma lfence , para T> RS_size , mas há uma oportunidade de sobreposição em ambos os lados de uma longa cadeia. O ROB tem pelo menos o dobro do tamanho do RS, portanto a janela fora de ordem, quando não parada pela lfence deve ser capaz de manter as duas correntes em vôo constantemente, mesmo quando T é um pouco maior que a capacidade do agendador. (Lembre-se de que eles saem do RS assim que são executados. Não tenho certeza se isso significa que eles precisam terminar de executar e encaminhar o resultado, ou apenas começar a executar, mas isso é uma pequena diferença aqui para instruções breves do ALU. eles estão prontos, apenas o ROB está segurando-os até que eles se retirem, em ordem de programa.)

O ROB e o arquivo de registro não devem limitar o tamanho da janela fora de ordem ( http://blog.stuffedcow.net/2013/05/measuring-rob-capacity/ ) nesta situação hipotética, ou em seu arquivo real. situação. Ambos devem ser muito grandes.

Bloquear o front-end é um detalhe de implementação de lfence nos lfence da Intel . O manual apenas diz que instruções posteriores não podem ser executadas . Esse texto permitiria que o front-end emitisse / renomeie todos eles no planejador (Reservation Station) e ROB enquanto o lfence ainda estiver aguardando, desde que nenhum deles seja despachado para uma unidade de execução.

Assim, uma vida mais fraca teria talvez uma sobrecarga plana até T = RS_size, então a mesma inclinação que você vê agora para T> 60. (E a parte constante da sobrecarga pode ser menor.)

Observe que as garantias sobre a execução especulativa de ramificações condicionais / indiretas após a lfence aplicam à execução , e não (até onde eu sei) à busca de código. Acionar simplesmente a busca de código não é (AFAIK) útil para um ataque de Specter ou Meltdown. Possivelmente, um canal lateral de tempo para detectar como ele decodifica poderia dizer algo sobre o código buscado ...

Eu acho que o LFENCE da AMD é pelo menos tão forte em processadores AMD reais, quando o MSR relevante está habilitado. ( A LFENCE é serializada em processadores AMD? ).

lfence extra de lfence :

Seus resultados são interessantes, mas não me surpreende de forma alguma que haja uma sobrecarga constante significativa da própria lfence (para o pequeno T), bem como o componente que escala com T.

Lembre-se de que o lfence não permite que instruções posteriores sejam iniciadas até que as instruções anteriores sejam retiradas . Isso provavelmente é pelo menos dois ciclos / estágios de pipeline posteriores a quando seus resultados estão prontos para passar para outras unidades de execução (ou seja, a latência normal).

Portanto, para T pequeno, é definitivamente significativo adicionar latência extra na cadeia, exigindo que o resultado não apenas esteja pronto, mas também gravado no arquivo de registro.

Provavelmente, é necessário um ciclo extra para permitir que o estágio de emissão / renomeação comece a funcionar novamente depois de detectar a lfence da última instrução anterior. O processo de emissão / renomeação leva vários estágios (ciclos), e talvez blocos de força no início disso, em vez de no último passo antes que os uops sejam adicionados à parte OoO do núcleo.

Mesmo a lfence back-to-back em si tem 4 ciclos de produção na família SnB, de acordo com os testes da Agner Fog. Agner Fog relata 2 uops de domínio fundido (sem uso), mas no Skylake eu o medi em 6 domínios fundidos (ainda sem uso) se eu tiver apenas 1 lfence . Mas com mais lfence back-to-back, é menos uops! Até ~ 2 uops por lfence com muitos back-to-back, que é como Agner mede.

lfence / dec / jnz (um laço apertado sem trabalho) roda a 1 iteração por ~ 10 ciclos em SKL, então isso pode nos dar uma idéia da latência extra real que o lfence adiciona às cadeias dep mesmo sem o front-end e Gargalos cheios de RS.

Medindo a sobrecarga de lfence com apenas uma cadeia de depuração, o OoO exec sendo irrelevante:

.loop:
    ;mfence                  ; mfence here:  ~62.3c (with no lfence)
    lfence                   ; lfence here:  ~39.3c
    times 10 imul eax,eax    ; with no lfence: 30.0c
    ; lfence                 ; lfence here:  ~39.6c
    dec   ecx
    jnz   .loop

Sem lfence , é executado no esperado 30.0c por iter. Com lfence , é executado em ~ 39.3c por iter, então efetivamente adicionou ~ 9.3c de "latência extra" para a cadeia dep de caminho crítico. (E 6 uops extras de domínio fundido).

Com lfence após a cadeia de imul, logo antes do loop-loop, é um pouco mais lento. Mas nem todo um ciclo é mais lento, o que indica que o front-end está emitindo o loop-branch + e imita um único grupo de lfence depois que a lfence permite que a execução seja retomada. Sendo esse o caso, IDK porque é mais lento. Não é de erros do ramo.

Obtendo o comportamento que você esperava:

Intercalar as cadeias na ordem do programa, como sugere @BeeOnRope nos comentários, não requer execução fora de ordem para explorar o ILP, então é bem trivial:

.loop:
    lfence      ; at the top of the loop is the lowest-overhead place.

%rep T
    imul   eax,eax
    imul   edx,edx
%endrep

    dec     ecx
    jnz    .loop

Você poderia colocar pares de curtos times 8 imul cadeias dentro de um %rep para deixar o OoO exec ter um tempo fácil.

Nota de Rodapé 1: Como o front-end / RS / ROB interage

Meu modelo mental é que os estágios issue / rename / allocate no front-end adicionam novos uops ao RS e ao ROB ao mesmo tempo.

Uops saem do RS após a execução, mas permanecem no ROB até a retirada em ordem. O ROB pode ser grande porque nunca é escaneado fora de ordem para encontrar o primeiro uop pronto, apenas escaneado em ordem para verificar se o uop mais antigo terminou a execução e, portanto, está pronto para se aposentar.

(Eu suponho que o ROB é fisicamente um buffer circular com índices de início / fim, não uma fila que realmente copia uops para a direita a cada ciclo. Mas pense nisso como uma fila / lista com um tamanho máximo fixo, onde o front-end adiciona uops na frente, e a lógica de aposentadoria se retira / compromete uops desde o final, desde que sejam totalmente executados, até algum limite de aposentadoria por ciclo por hipertexto que normalmente não é um gargalo. Skylake aumentou para melhor Hyperthreading, talvez a 8 por clock por thread lógico.Talvez a aposentadoria também signifique liberar registradores físicos, o que ajuda o HT, porque o próprio ROB é estaticamente particionado quando ambos os threads estão ativos.É por isso que os limites de aposentadoria são por thread lógico.

Uops como nop, xor eax,eax , ou lfence , que são manipulados no front-end (não precisam de nenhuma unidade de execução em nenhuma porta) são adicionados apenas ao ROB, em um estado já executado. (Uma entrada ROB presumivelmente tem um bit que a marca como pronta para se aposentar e ainda esperando a execução ser completada. Esse é o estado do qual estou falando. Para uops que precisavam de uma porta de execução, eu assumo que o bit ROB está definido através de uma porta de conclusão da unidade de execução. E que o mesmo sinal de porta de conclusão libera sua entrada RS.)

Uops permanecem no ROB desde a emissão até a aposentadoria .

Uops ficam no RS do problema para a execução . O RS pode reproduzir os uops em alguns casos , por exemplo, para a outra metade de uma carga de divisão de linha de cache , ou se ela foi despachada antecipando a chegada de dados de carga, mas na verdade isso não aconteceu. (Erro de cache ou outros conflitos, como efeitos de desempenho estranhos de lojas dependentes próximas em um loop de perseguição de ponteiro no IvyBridge. A adição de uma carga extra acelera? ) Ou quando uma porta de carregamento especula que pode ignorar a AGU antes de iniciar uma pesquisa TLB encurtar a latência de perseguição de ponteiro com pequenos deslocamentos - existe uma penalidade quando base + offset está em uma página diferente da base?

Então, sabemos que o RS não pode remover um uop corretamente à medida que despacha, porque ele pode precisar ser repetido. (Pode acontecer até mesmo descarregar uops que consumam dados de carga.) Mas qualquer especulação que precise de replays é de curto alcance, não através de uma cadeia de uops, então, quando um resultado sai do outro lado de uma unidade de execução, o uop pode ser removido do RS. Provavelmente isso é parte do que uma porta de conclusão faz, junto com a colocação do resultado na rede de redirecionamento de bypass.

Nota de Rodapé 2: Quantas entradas de RS um upl micro-fundido leva?

TL: DR: Família P6: RS é fundido, família SnB: RS não está em uso.

Um upl micro-fundido é emitido para duas entradas RS separadas na família Sandybridge , mas apenas 1 entrada ROB. (Assumindo que ele não seja laminado antes do problema, consulte a seção 2.3.5 para HSW ou seção 2.4.2.4 para o modo de otimização SnB da Intel e os modos Micro fusão e endereçamento . O formato uop mais compacto da família Sandybridge não pode representar modos de endereçamento no ROB em todos os casos.)

A carga pode ser despachada independentemente, à frente do outro operando, para que a ULA esteja pronta. (Ou para armazenamentos com micro-fusão, qualquer um dos endereços de armazenamento ou armazenamento de dados pode ser despachado quando sua entrada estiver pronta, sem esperar por ambos.)

Eu usei o método two-dep-chain da questão para testar experimentalmente isso em Skylake (tamanho RS = 97) , com micro-fundido or edi, [rdi] vs. mov + or , e outra cadeia dep em rsi . ( Código de teste completo, sintaxe NASM em Godbolt )

; loop body
%rep T
%if FUSE
    or edi, [rdi]    ; static buffers are in the low 32 bits of address space, in non-PIE
%else
    mov  eax, [rdi]
    or   edi, eax
%endif
%endrep

%rep T
%if FUSE
    or esi, [rsi]
%else
    mov  eax, [rsi]
    or   esi, eax
%endif
%endrep

Olhando para uops_executed.thread ( uops_executed.thread -domain) por ciclo (ou por segundo que o perf calcula para nós), podemos ver um número de transferência que não depende de cargas separadas vs. dobradas.

Com o pequeno T (T = 30), todo o ILP pode ser explorado, e obtemos ~ 0.67 uops por clock com ou sem micro fusão. (Eu estou ignorando o pequeno viés de 1 iteração extra por loop de dez / jnz. É insignificante comparado com o efeito que veríamos se os micro-fundidos usassem apenas 1 entrada RS)

Lembre-se que load + or é de 2 uops, e nós temos 2 cadeias dep em vôo, então isso é 4/6, porque or edi, [rdi] tem 6 ciclos de latência. (Não 5, o que é surpreendente, veja abaixo.)

Em T = 60, ainda temos cerca de 0,66 uops não fundidos executados por relógio para FUSE = 0 e 0,64 para FUSE = 1. Ainda podemos encontrar basicamente todo o ILP, mas ele está apenas começando a mergulhar, já que as duas cadeias dep são 120 uops de comprimento (contra um tamanho de RS de 97).

Em T = 120, temos 0,45 uops não fundidos por relógio para FUSE = 0 e 0,44 para FUSE = 1. Definitivamente, passamos o joelho aqui, mas ainda encontramos alguns dos ILP.

Se um uop microfundido levou apenas 1 entrada RS, o FUSÍVEL = 1 T = 120 deve ter aproximadamente a mesma velocidade que o FUSE = 0 T = 60, mas esse não é o caso . Em vez disso, FUSE = 0 ou 1 não faz quase nenhuma diferença em qualquer T. (Incluindo os maiores, como T = 200: FUSE = 0: 0,395 uops / clock, FUSE = 1: 0,391 uops / clock). Nós teríamos que ir para um T muito grande antes de começarmos o tempo com 1 cadeia em vôo para dominar totalmente o tempo com 2 em vôo, e descer para 0,33 uops / clock (2/6).

Oddity: Nós temos uma diferença tão pequena, mas ainda mensurável, na taxa de transferência para fundido versus não fundido, com mov cargas separadas sendo mais rápidas.

Outras esquisitices: o total uops_executed.thread é um pouco menor para FUSÍVEL = 0 em qualquer T. Como 2.418.826.591 vs. 2.419.020.155 para T = 60. Essa diferença foi repetida para + - 60k de 2,4G, bastante precisa. FUSE = 1 é mais lento em ciclos de clock totais, mas a maior parte da diferença vem de uops mais baixos por clock, não de mais uops.

Modos de endereçamento simples, como [rdi] deveriam ter apenas 4 ciclos de latência, portanto, load + ALU deve ser de apenas 5 ciclos. Mas eu meço 6 latência de ciclo para a latência de uso de carga de or rdi, [rdi] , ou com uma carga de MOV separada, ou com qualquer outra instrução de ALU Eu nunca posso obter a parte de carga para ser 4c.

Um modo de endereçamento complexo [rdi + rbx + 2064] tem a mesma latência quando há uma instrução ALU na cadeia dep, portanto, parece que a latência 4c da Intel para modos de endereçamento simples se aplica quando uma carga está encaminhando para o registrador base de outra carga (com até + 0..2047 deslocamento e nenhum índice).

A perseguição de ponteiro é comum o bastante para que essa seja uma otimização útil, mas precisamos considerá-la como um caminho rápido de encaminhamento de carga-carga especial, não como um dado geral pronto para uso pelas instruções da ALU.

A família P6 é diferente: uma entrada RS contém um uop de domínio fundido.

@Hadi encontrou uma patente da Intel de 2002 , onde a Figura 12 mostra a RS no domínio fundido.

Testes experimentais em um Conroe (primeira geração Core2Duo, E6600) mostram que há uma grande diferença entre FUSE = 0 e FUSE = 1 para T = 50. ( O tamanho do RS é de 32 entradas ).

  • T = 50 FUSÍVEL = 1: tempo total de 2.346G ciclos (0.44IPC)
  • T = 50 FUSÍVEL = 0: tempo total de 3.272G ciclos (0.62IPC = 0.31 carga + OU por relógio). ( perf / ocperf.py não tem eventos para o uops_executed uarches antes do Nehalem, e eu não tenho oprofile instalado nessa máquina.)

  • T = 24 há uma diferença insignificante entre FUSE = 0 e FUSE = 1, em torno de 0,47 IPC vs 0,9 IPC (~ 0,45 carga + OU por relógio).

T = 24 ainda tem mais de 96 bytes de código no loop, muito grande para o buffer de loop de 64 bytes (pré-decodificação) do Core 2, por isso não é mais rápido por causa da instalação em um buffer de loop. Sem um cache uop, temos que nos preocupar com o front-end, mas acho que estamos bem, porque estou usando exclusivamente instruções single-utop de 2 bytes que devem ser facilmente decodificadas em 4 uops de domínio fundido por clock.





perf