O que causa essa alta variabilidade em ciclos para um loop apertado simples com -O0, mas não -O3, em um Cortex-A72?

9

Estou executando algumas experiências para obter tempos de execução altamente consistentes para um pedaço de código. Atualmente, o código que eu estou cronometrando é uma carga de trabalho arbitrária e vinculada à CPU:

int cpu_workload_external_O3(){
    int x = 0;
    for(int ind = 0; ind < 12349560; ind++){
        x = ((x ^ 0x123) + x * 3) % 123456;
    }
    return x;
}

Eu escrevi um módulo do kernel que desativa interrupções e, em seguida, executa 10 tentativas da função acima, cronometrando cada tentativa, tomando a diferença no contador do ciclo do relógio antes e depois. Outras coisas a serem observadas:

  • a máquina é um ARM Cortex-A72, com 4 soquetes com 4 núcleos cada (cada um com seu próprio cache L1)
  • a escala de frequência do relógio está desativada
  • hyperthreading não é suportado
  • a máquina está executando praticamente nada, exceto por alguns processos básicos do sistema

Em outras palavras, acredito que a maioria / todas as fontes de variabilidade do sistema são contabilizadas e, especialmente quando executado como um módulo do kernel com interrupções desabilitadas via spin_lock_irqsave(), o código deve atingir um desempenho praticamente idêntico, execução a execução (talvez um pequeno impacto no desempenho na primeira execução, quando algumas instruções são puxadas para o cache, mas é isso).

De fato, quando o código de referência é compilado -O3, vi um intervalo de no máximo 200 ciclos de ~ 135.845.192, em média, com a maioria dos testes demorando exatamente a mesma quantidade de tempo. No entanto , quando compilado -O0, o intervalo subiu para 158.386 ciclos de ~ 262.710.916. Por intervalo, quero dizer a diferença entre os tempos de execução mais longos e os mais curtos. Além disso, para o -O0código, não há muita consistência para qual dos ensaios é o mais lento / mais rápido - contra-intuitivamente, em uma ocasião, o mais rápido foi o primeiro e o mais lento foi o logo depois!

Então : o que pode estar causando esse limite superior alto da variabilidade no -O0código? Observando o assembly, parece que o -O3código armazena tudo (?) Em um registro, enquanto o -O0código tem várias referências spe, portanto, parece estar acessando a memória. Mas, mesmo assim, eu esperaria que tudo fosse trazido para o cache L1 e permanecesse ali com um tempo de acesso bastante determinístico.


Código

O código que está sendo comparado está no snippet acima. A montagem está abaixo. Ambos foram compilados gcc 7.4.0sem bandeiras, exceto -O0e -O3.

-O0

0000000000000000 <cpu_workload_external_O0>:
   0:   d10043ff        sub     sp, sp, #0x10
   4:   b9000bff        str     wzr, [sp, #8]
   8:   b9000fff        str     wzr, [sp, #12]
   c:   14000018        b       6c <cpu_workload_external_O0+0x6c>
  10:   b9400be1        ldr     w1, [sp, #8]
  14:   52802460        mov     w0, #0x123                      // #291
  18:   4a000022        eor     w2, w1, w0
  1c:   b9400be1        ldr     w1, [sp, #8]
  20:   2a0103e0        mov     w0, w1
  24:   531f7800        lsl     w0, w0, #1
  28:   0b010000        add     w0, w0, w1
  2c:   0b000040        add     w0, w2, w0
  30:   528aea61        mov     w1, #0x5753                     // #22355
  34:   72a10fc1        movk    w1, #0x87e, lsl #16
  38:   9b217c01        smull   x1, w0, w1
  3c:   d360fc21        lsr     x1, x1, #32
  40:   130c7c22        asr     w2, w1, #12
  44:   131f7c01        asr     w1, w0, #31
  48:   4b010042        sub     w2, w2, w1
  4c:   529c4801        mov     w1, #0xe240                     // #57920
  50:   72a00021        movk    w1, #0x1, lsl #16
  54:   1b017c41        mul     w1, w2, w1
  58:   4b010000        sub     w0, w0, w1
  5c:   b9000be0        str     w0, [sp, #8]
  60:   b9400fe0        ldr     w0, [sp, #12]
  64:   11000400        add     w0, w0, #0x1
  68:   b9000fe0        str     w0, [sp, #12]
  6c:   b9400fe1        ldr     w1, [sp, #12]
  70:   528e0ee0        mov     w0, #0x7077                     // #28791
  74:   72a01780        movk    w0, #0xbc, lsl #16
  78:   6b00003f        cmp     w1, w0
  7c:   54fffcad        b.le    10 <cpu_workload_external_O0+0x10>
  80:   b9400be0        ldr     w0, [sp, #8]
  84:   910043ff        add     sp, sp, #0x10
  88:   d65f03c0        ret

-O3

0000000000000000 <cpu_workload_external_O3>:
   0:   528e0f02        mov     w2, #0x7078                     // #28792
   4:   5292baa4        mov     w4, #0x95d5                     // #38357
   8:   529c4803        mov     w3, #0xe240                     // #57920
   c:   72a01782        movk    w2, #0xbc, lsl #16
  10:   52800000        mov     w0, #0x0                        // #0
  14:   52802465        mov     w5, #0x123                      // #291
  18:   72a043e4        movk    w4, #0x21f, lsl #16
  1c:   72a00023        movk    w3, #0x1, lsl #16
  20:   4a050001        eor     w1, w0, w5
  24:   0b000400        add     w0, w0, w0, lsl #1
  28:   0b000021        add     w1, w1, w0
  2c:   71000442        subs    w2, w2, #0x1
  30:   53067c20        lsr     w0, w1, #6
  34:   9ba47c00        umull   x0, w0, w4
  38:   d364fc00        lsr     x0, x0, #36
  3c:   1b038400        msub    w0, w0, w3, w1
  40:   54ffff01        b.ne    20 <cpu_workload_external_O3+0x20>  // b.any
  44:   d65f03c0        ret

módulo do kernel

O código que está executando as avaliações está abaixo. Ele lê PMCCNTR_EL0antes / depois de cada iteração, armazena as diferenças em uma matriz e imprime os tempos mínimo / máximo no final em todas as avaliações. As funções cpu_workload_external_O0e cpu_workload_external_O3estão em arquivos de objetos externos que são compilados separadamente e depois vinculados.

#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>

#include "cpu.h"

static DEFINE_SPINLOCK(lock);

void runBenchmark(int (*benchmarkFunc)(void)){
    // Enable perf counters.
    u32 pmcr;
    asm volatile("mrs %0, pmcr_el0" : "=r" (pmcr));
    asm volatile("msr pmcr_el0, %0" : : "r" (pmcr|(1)));

    // Run trials, storing the time of each in `clockDiffs`.
    u32 result = 0;
    #define numtrials 10
    u32 clockDiffs[numtrials] = {0};
    u32 clockStart, clockEnd;
    for(int trial = 0; trial < numtrials; trial++){
        asm volatile("isb; mrs %0, PMCCNTR_EL0" : "=r" (clockStart));
        result += benchmarkFunc();
        asm volatile("isb; mrs %0, PMCCNTR_EL0" : "=r" (clockEnd));

        // Reset PMCCNTR_EL0.
        asm volatile("mrs %0, pmcr_el0" : "=r" (pmcr));
        asm volatile("msr pmcr_el0, %0" : : "r" (pmcr|(((uint32_t)1) << 2)));

        clockDiffs[trial] = clockEnd - clockStart;
    }

    // Compute the min and max times across all trials.
    u32 minTime = clockDiffs[0];
    u32 maxTime = clockDiffs[0];
    for(int ind = 1; ind < numtrials; ind++){
        u32 time = clockDiffs[ind];
        if(time < minTime){
            minTime = time;
        } else if(time > maxTime){
            maxTime = time;
        }
    }

    // Print the result so the benchmark function doesn't get optimized out.
    printk("result: %d\n", result);

    printk("diff: max %d - min %d = %d cycles\n", maxTime, minTime, maxTime - minTime);
}

int init_module(void) {
    printk("enter\n");
    unsigned long flags;
    spin_lock_irqsave(&lock, flags);

    printk("-O0\n");
    runBenchmark(cpu_workload_external_O0);

    printk("-O3\n");
    runBenchmark(cpu_workload_external_O3);

    spin_unlock_irqrestore(&lock, flags);
    return 0;
}

void cleanup_module(void) {
    printk("exit\n");
}

Hardware

$ lscpu
Architecture:        aarch64
Byte Order:          Little Endian
CPU(s):              16
On-line CPU(s) list: 0-15
Thread(s) per core:  1
Core(s) per socket:  4
Socket(s):           4
NUMA node(s):        1
Vendor ID:           ARM
Model:               3
Model name:          Cortex-A72
Stepping:            r0p3
BogoMIPS:            166.66
L1d cache:           32K
L1i cache:           48K
L2 cache:            2048K
NUMA node0 CPU(s):   0-15
Flags:               fp asimd evtstrm aes pmull sha1 sha2 crc32 cpuid
$ lscpu --extended
CPU NODE SOCKET CORE L1d:L1i:L2 ONLINE
0   0    0      0    0:0:0      yes
1   0    0      1    1:1:0      yes
2   0    0      2    2:2:0      yes
3   0    0      3    3:3:0      yes
4   0    1      4    4:4:1      yes
5   0    1      5    5:5:1      yes
6   0    1      6    6:6:1      yes
7   0    1      7    7:7:1      yes
8   0    2      8    8:8:2      yes
9   0    2      9    9:9:2      yes
10  0    2      10   10:10:2    yes
11  0    2      11   11:11:2    yes
12  0    3      12   12:12:3    yes
13  0    3      13   13:13:3    yes
14  0    3      14   14:14:3    yes
15  0    3      15   15:15:3    yes
$ numactl --hardware
available: 1 nodes (0)
node 0 cpus: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
node 0 size: 32159 MB
node 0 free: 30661 MB
node distances:
node   0
  0:  10

Medições de amostra

Abaixo está uma saída de uma execução do módulo do kernel:

[902574.112692] kernel-module: running on cpu 15                                                                                                                                      
[902576.403537] kernel-module: trial 00: 309983568 74097394 98796602 <-- max
[902576.403539] kernel-module: trial 01: 309983562 74097397 98796597                                                                                                                  
[902576.403540] kernel-module: trial 02: 309983562 74097397 98796597                                                                                                                  
[902576.403541] kernel-module: trial 03: 309983562 74097397 98796597
[902576.403543] kernel-module: trial 04: 309983562 74097397 98796597
[902576.403544] kernel-module: trial 05: 309983562 74097397 98796597                                                                                                                  
[902576.403545] kernel-module: trial 06: 309983562 74097397 98796597
[902576.403547] kernel-module: trial 07: 309983562 74097397 98796597
[902576.403548] kernel-module: trial 08: 309983562 74097397 98796597
[902576.403550] kernel-module: trial 09: 309983562 74097397 98796597                                                                                                                  
[902576.403551] kernel-module: trial 10: 309983562 74097397 98796597
[902576.403552] kernel-module: trial 11: 309983562 74097397 98796597
[902576.403554] kernel-module: trial 12: 309983562 74097397 98796597                                                                                                                  
[902576.403555] kernel-module: trial 13: 309849076 74097403 98796630 <-- min
[902576.403557] kernel-module: trial 14: 309983562 74097397 98796597                                                                                                                  
[902576.403558] kernel-module: min time: 309849076
[902576.403559] kernel-module: max time: 309983568                                                                                                                                    
[902576.403560] kernel-module: diff: 134492

Para cada tentativa, os valores relatados são: número de ciclos (0x11), número de acessos L1D (0x04), número de acessos L1I (0x14). Estou usando a seção 11.8 desta referência da PMU do ARM ).

sevko
fonte
2
Existem outros threads em execução? Seus acessos de memória, causando concorrência por largura de banda de barramento e espaço em cache, podem estar afetando.
prl
Poderia ser. Eu não isolei nenhum núcleo, e mesmo assim um thread do kernel pode ser agendado em um dos outros núcleos no soquete. Mas, se estou entendendo lscpu --extendedcorretamente, cada núcleo tem seus próprios caches de dados e instruções L1, e cada soquete possui um cache L2 compartilhado para seus 4 núcleos, desde que tudo seja feito no cache L1, espero que o código seja bastante muito "possui" seu barramento (já que é a única coisa em execução no seu núcleo, até a conclusão). Eu não sei muito sobre hardware nesse nível, no entanto.
sevko 16/12/19
11
Sim, é claramente relatado como 4 soquetes, mas isso pode ser apenas uma questão de como a interconexão é conectada dentro de um SoC de 16 núcleos. Mas você tem a máquina física, certo? Você tem um número de marca e modelo? Se a tampa se soltar, provavelmente você também pode confirmar se há realmente quatro soquetes separados. Não vejo por que isso importa, exceto, talvez, pelo número do modelo / fornecedor do mobo. Sua referência é puramente núcleo único e deve permanecer quente no cache; portanto, tudo o que importa é o próprio núcleo A72 e seu buffer de loja + encaminhamento de loja.
Peter Cordes
11
Mudei o módulo do kernel para rastrear três contadores e adicionei alguns exemplos de saída. O interessante é que a maioria das execuções é consistente, mas uma aleatória será substancialmente mais rápida. Nesse caso, parece que o mais rápido realmente teve muito mais acessos L1, o que talvez implique uma previsão de ramificação mais agressiva em algum lugar. Além disso, infelizmente não tenho acesso à máquina. É uma instância da AWS a1.metal (que fornece a você propriedade total do hardware físico, portanto não há nenhuma interferência de um hipervisor etc.).
sevko
11
Curiosamente, se eu fizer o módulo do kernel executar esse código em todas as CPUs simultaneamente on_each_cpu(), cada uma delas relatará quase nenhuma variabilidade em todas as 100 tentativas.
sevko

Respostas:

4

Nos kernels recentes do Linux, o mecanismo automático de migração de páginas NUMA periodicamente atira nas entradas TLB para que ele possa monitorar a localidade NUMA. As recargas de TLB retardarão o código O0, mesmo se os dados permanecerem no L1DCache.

O mecanismo de migração de páginas não deve ser ativado nas páginas do kernel.

Você verifica se a migração automática de páginas NUMA está ativada com

$ cat /proc/sys/kernel/numa_balancing

e você pode desativá-lo com

$ echo 0 > /proc/sys/kernel/numa_balancing
John D McCalpin
fonte
Ultimamente, tenho feito testes relacionados. Estou executando uma carga de trabalho que faz vários acessos aleatórios a um buffer de memória que cabe confortavelmente no cache L1. Realizo várias tentativas consecutivas e o tempo de execução é altamente consistente (varia literalmente menos de 0,001%), exceto periodicamente que há um pequeno aumento na subida. Nesse pico, o benchmark corre apenas 0,014% mais. Isso é pequeno, mas cada um desses picos tem exatamente a mesma magnitude e ocorre uma vez quase exatamente uma vez a cada 2 segundos. Esta máquina foi numa_balancingdesativada. Talvez você tenha uma ideia?
sevko 17/03
Descobri isso. Eu estava olhando os contadores de perf o dia inteiro, mas a causa raiz era algo totalmente não relacionado. Eu estava executando esses testes em uma sessão tmux em uma máquina silenciosa. O intervalo de 2 segundos coincidiu exatamente com o intervalo de atualização da minha linha de status tmux, que faz uma solicitação de rede entre outras coisas. Desabilitá-lo fazia os picos desaparecerem. Não faço ideia de como os scripts executados pela minha linha de status em um cluster principal diferente estavam afetando o processo em execução em um cluster principal isolado, tocando apenas nos dados L1 ..
sevko 17/03/03
2

Sua variação é da ordem de 6 * 10 ^ -4. Embora surpreendentemente mais que 1,3 * 10 ^ -6, uma vez que seu programa está falando com os caches, ele está envolvido em muitas operações sincronizadas. Sincronizado sempre significa perda de tempo.

Uma coisa interessante é como sua comparação -O0, -O3 imita a regra geral de que uma ocorrência de cache L1 é cerca de 2x uma referência de registro. Seu O3 médio é executado em 51,70% do tempo que seu O0. Quando você aplica as variações inferiores / superiores, temos (O3-200) / (O0 + 158386), observamos uma melhoria para 51,67%.

Em resumo, sim, um cache nunca será determinístico; e a baixa variação que você vê está alinhada com o que se espera da sincronização com um dispositivo mais lento. É apenas uma grande variação quando comparada à máquina apenas mais determinística do registro.

mevets
fonte
As instruções são buscadas no cache L1i. Acho que você está dizendo que não pode sofrer lentidões imprevisíveis porque não é coerente com caches de dados no mesmo ou em outros núcleos? Mas de qualquer maneira, se a resposta do Dr. Bandwidth estiver correta, a variação não se deve ao cache em si, mas à invalidação periódica do dTLB pelo kernel. Essa explicação explica completamente toda a observação: o aumento da variação da inclusão de quaisquer cargas / armazenamentos no espaço do usuário e o fato de que essa queda não ocorre ao cronometrar o loop dentro de um módulo do kernel. (Memória do kernel Linux não é swappable.)
Peter Cordes
Os caches geralmente são determinísticos quando você está acessando dados quentes. Eles podem ser de várias portas para permitir tráfego de coerência sem perturbar cargas / armazenamentos do próprio núcleo. Seu palpite de que os distúrbios são devidos a outros núcleos é plausível, mas eu, apenas as numa_balancinginvalidações do TLB, provavelmente o explicam.
Peter Cordes
Qualquer cache de espionagem deve ter uma sequência ininterrupta em que qualquer solicitação deve ser interrompida. Uma desaceleração de 10 ^ -4 em uma operação de 1 vs 2 ciclos significa um soluço de um relógio a cada 10 ^ 5 operações. A questão toda é realmente um não-op, a variação é pequena.
Mevets 24/12/19