Cosa causa questa elevata variabilità nei cicli per un semplice circuito stretto con -O0 ma non -O3, su un Cortex-A72?


9

Sto eseguendo alcuni esperimenti per ottenere runtime estremamente coerenti per un pezzo di codice. Il codice che sto attualmente cronometrando è un carico di lavoro associato alla CPU piuttosto arbitrario:

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

Ho scritto un modulo del kernel che disabilita gli interrupt e quindi esegue 10 prove della funzione precedente, cronometrando ogni prova prendendo la differenza nel contatore del ciclo di clock da prima e dopo. Altre cose da notare:

  • la macchina è un ARM Cortex-A72, con 4 socket da 4 core ciascuno (ognuno con la propria cache L1)
  • il ridimensionamento della frequenza di clock è disattivato
  • l'hyperthreading non è supportato
  • la macchina non esegue praticamente nulla tranne alcuni processi di sistema bare-bone

In altre parole, credo che la maggior parte / tutte le fonti di variabilità del sistema siano prese in considerazione e, specialmente quando eseguito come modulo kernel con interruzioni disabilitate via spin_lock_irqsave(), il codice dovrebbe raggiungere prestazioni praticamente identiche run-to-run (forse un piccolo hit delle prestazioni alla prima esecuzione quando alcune istruzioni vengono prima inserite nella cache, ma il gioco è fatto).

In effetti, quando è stato compilato il codice di riferimento -O3, ho visto un intervallo di massimo 200 cicli su ~ 135.845.192 in media, con la maggior parte delle prove che impiegavano esattamente lo stesso tempo. Tuttavia , una volta compilato -O0, l'intervallo è aumentato fino a 158.386 cicli su ~ 262.710.916. Per intervallo intendo la differenza tra i tempi di esecuzione più lunghi e più brevi. Inoltre, per il -O0codice, non c'è molta coerenza con quale delle prove è la più lenta / più veloce - controintuitivamente, in un'occasione la più veloce è stata la prima, e la più lenta è stata quella subito dopo!

Quindi : cosa potrebbe causare questo limite superiore elevato alla variabilità del -O0codice? Guardando l'assembly, sembra che il -O3codice memorizzi tutto (?) In un registro, mentre il -O0codice ha un sacco di riferimenti spe quindi sembra che stia accedendo alla memoria. Ma anche allora, mi aspetto che tutto venga portato nella cache L1 e che rimanga lì con un tempo di accesso piuttosto deterministico.


Codice

Il codice da sottoporre a benchmark è nel frammento sopra. L'assemblea è sotto. Entrambi sono stati compilati gcc 7.4.0senza bandiere tranne -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

modulo del kernel

Il codice che esegue le prove è di seguito. Legge PMCCNTR_EL0prima / dopo ogni iterazione, memorizza le differenze in un array e stampa i tempi min / max alla fine in tutte le prove. Le funzioni cpu_workload_external_O0e si cpu_workload_external_O3trovano in file di oggetti esterni che vengono compilati separatamente e quindi collegati.

#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

Misure di esempio

Di seguito è riportato un output di un'esecuzione del modulo 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

Per ogni prova, i valori riportati sono: # di cicli (0x11), # di accessi L1D (0x04), # di accessi L1I (0x14). Sto usando la sezione 11.8 di questo riferimento PMU ARM ).


2
Ci sono altri thread in esecuzione? I loro accessi alla memoria che causano competizione per la larghezza di banda del bus e lo spazio cache potrebbero avere un effetto.
prl

Potrebbe essere. Non ho isolcpu'd alcun core, e anche allora un thread del kernel potrebbe essere programmato su uno degli altri core sul socket. Ma se sto capendo lscpu --extendedcorrettamente, ogni core ha i suoi dati L1 e cache di istruzioni, e quindi ogni socket ha una cache L2 condivisa per i suoi 4 core, quindi finché tutto è fatto all'interno della cache L1 mi aspetto che il codice sia piuttosto molto "possiede" il suo bus (poiché è l'unica cosa che gira sul suo core, fino al completamento). Tuttavia, non so molto dell'hardware a questo livello.
sevko,

1
Sì, è chiaramente riportato come 4 socket, ma potrebbe essere solo una questione di come l'interconnessione è cablata all'interno di un SoC a 16 core. Ma hai la macchina fisica, giusto? Hai un marchio e un numero di modello per questo? Se il coperchio si stacca, presumibilmente puoi anche confermare se ci sono davvero 4 prese separate. Non vedo perché nulla di tutto ciò possa importare, tranne forse per il numero di fornitore / modello del mobo. Il tuo benchmark è puramente single core e dovrebbe rimanere caldo nella cache, quindi tutto ciò che dovrebbe essere importante è il core A72 stesso e il suo buffer di archivio + inoltro di archivio.
Peter Cordes,

1
Ho modificato il modulo del kernel per tenere traccia di tre contatori e ho aggiunto un output di esempio. La cosa interessante è che la maggior parte delle piste sono coerenti, ma una casuale sarà sostanzialmente più veloce. In questo caso, sembra che il più veloce abbia avuto in realtà accessi L1 leggermente più leggeri, il che forse implica una previsione del ramo più aggressiva da qualche parte. Inoltre, sfortunatamente non ho accesso alla macchina. È un'istanza AWS a1.metal (che ti dà la piena proprietà dell'hardware fisico, quindi apparentemente non ci sono interferenze da un hypervisor ecc.).
sevko,

1
È interessante notare che, se faccio in modo che il modulo del kernel esegua questo codice su tutte le CPU simultaneamente tramite on_each_cpu(), ognuna riporta quasi nessuna variabilità su 100 prove.
sevko,

Risposte:


4

Negli ultimi kernel Linux il meccanismo automatico di migrazione della pagina NUMA annulla periodicamente le voci TLB in modo da poter monitorare la località NUMA. Le ricariche TLB rallentano il codice O0, anche se i dati rimangono in L1DCache.

Il meccanismo di migrazione delle pagine non dovrebbe essere attivato nelle pagine del kernel.

Verifica se la migrazione automatica della pagina NUMA è abilitata con

$ cat /proc/sys/kernel/numa_balancing

e puoi disabilitarlo con

$ echo 0 > /proc/sys/kernel/numa_balancing

Ultimamente ho fatto alcuni test correlati. Sto eseguendo un carico di lavoro che crea un sacco di accessi casuali a un buffer di memoria che si adatta comodamente alla cache L1. Eseguo un sacco di prove schiena contro schiena e il tempo di esecuzione è molto costante (varia letteralmente meno dello 0,001%), tranne che periodicamente c'è un piccolo picco verso l'alto. In quel picco il benchmark corre solo dello 0,014% in più. Questo è piccolo, ma ognuno di questi picchi ha esattamente la stessa grandezza e un picco si verifica una volta quasi esattamente una volta ogni 2 secondi. Questa macchina è numa_balancingdisabilitata. Forse hai un'idea?
Siviglia

Capito. Stavo fissando i contatori di perf per tutto il giorno, ma ho scoperto che la causa principale era qualcosa di totalmente estraneo. Stavo eseguendo questi test in una sessione su una macchina silenziosa. L'intervallo di 2 secondi coincide esattamente con l'intervallo di aggiornamento della mia statusline di tmux, il che rende una richiesta di rete tra le altre cose .. Disabilitandola, i picchi scompaiono. Non ho idea di come gli script eseguiti dalla mia riga di stato su un cluster principale diverso abbiano influito sul processo in esecuzione su un cluster principale isolato, toccando solo i dati L1.
abbiano influito

2

La tua varianza è nell'ordine di 6 * 10 ^ -4. Sebbene sorprendentemente più di 1.3 * 10 ^ -6, una volta che il tuo programma parla con le cache, è coinvolto in molte operazioni sincronizzate. Sincronizzato significa sempre perdere tempo.

Una cosa interessante è come il tuo confronto -O0, -O3 mima la regola generale secondo cui un hit della cache L1 è circa 2 volte un riferimento al registro. La tua O3 media viene eseguita nel 51,70% delle volte della tua O0. Quando applichi le varianze inferiore / superiore, abbiamo (O3-200) / (O0 + 158386), vediamo un miglioramento al 51,67%.

In breve, sì, una cache non sarà mai deterministica; e la bassa varianza che vedi è in linea con ciò che ci si dovrebbe aspettare dalla sincronizzazione con un dispositivo più lento. È solo una grande varianza rispetto alla macchina con solo registro più deterministica.


Le istruzioni vengono recuperate dalla cache L1i. Immagino che stai dicendo che non può soffrire di rallentamenti imprevedibili perché non è coerente con le cache dei dati sullo stesso o su altri core? Ma comunque, se la risposta del Dr. Bandwidth è corretta, la varianza non è dovuta alla cache stessa, ma piuttosto all'invalida periodica di dTLB da parte del kernel. Questa spiegazione spiega completamente tutta l'osservazione: la maggiore varianza dall'inclusione di qualsiasi carico / deposito nello spazio utente e il fatto che questa caduta non si verifica quando si imposta il ciclo all'interno di un modulo del kernel. (La memoria del kernel Linux non è sostituibile.)
Peter Cordes, il

Le cache sono in genere deterministiche quando si accede ai dati attivi. Possono essere multi-port per consentire il traffico di coerenza senza disturbare carichi / negozi dal core stesso. La tua ipotesi che i disturbi siano dovuti ad altri core è plausibile, ma numa_balancingsolo le invalidazioni TLB probabilmente lo spiegano.
Peter Cordes,

Qualsiasi cache di snooping deve avere una sequenza ininterrotta in cui qualsiasi richiesta deve essere bloccata. Un rallentamento di 10 ^ -4 su un'operazione 1 contro 2 indica un singhiozzo di clock ogni 10 ^ 5 operazioni. L'intera domanda è davvero no-op, la varianza è minuscola.
Mevets,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.