Qu'est-ce qui cause cette grande variabilité des cycles pour une simple boucle serrée avec -O0 mais pas -O3, sur un Cortex-A72?

9

J'exécute quelques expériences pour obtenir des temps d'exécution très cohérents pour un morceau de code. Le code que je chronomètre actuellement est une charge de travail liée au processeur assez arbitraire:

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

J'ai écrit un module de noyau qui désactive les interruptions, puis exécute 10 essais de la fonction ci-dessus, chronométrant chaque essai en prenant la différence dans le compteur de cycles d'horloge avant et après. Autres choses à noter:

  • la machine est un ARM Cortex-A72, avec 4 sockets avec 4 cœurs chacun (chacun avec son propre cache L1)
  • la mise à l'échelle de la fréquence d'horloge est désactivée
  • l'hyperthreading n'est pas pris en charge
  • la machine ne fonctionne pratiquement rien, à l'exception de certains processus système nus

En d'autres termes, je crois que la plupart / toutes les sources de variabilité du système sont prises en compte, et, en particulier lorsqu'il est exécuté en tant que module du noyau avec des interruptions désactivées via spin_lock_irqsave(), le code devrait atteindre des performances pratiquement identiques (peut-être un petit hit de performances lors de la première exécution quand une instruction est d'abord tirée dans le cache, mais c'est tout).

En effet, lorsque le code de référence est compilé -O3, j'ai vu une plage d'au plus 200 cycles sur environ 135 845 192 en moyenne, la plupart des essais prenant exactement le même temps. Cependant , une fois compilé avec -O0, la plage a atteint jusqu'à 158 386 cycles sur environ 262 710 916. Par plage, je veux dire la différence entre les temps de fonctionnement les plus longs et les plus courts. De plus, pour le -O0code, il n'y a pas beaucoup de cohérence à laquelle des essais est le plus lent / le plus rapide - contre-intuitivement, dans un cas, le plus rapide était le tout premier, et le plus lent était celui juste après!

Alors : qu'est-ce qui pourrait être à l'origine de cette limite supérieure élevée sur la variabilité du -O0code? En regardant l'assemblage, il semble que le -O3code stocke tout (?) Dans un registre, alors que le -O0code a un tas de références spet il semble donc accéder à la mémoire. Mais même dans ce cas, je m'attendrais à ce que tout soit placé dans le cache L1 et reste assis avec un temps d'accès assez déterministe.


Code

Le code évalué est dans l'extrait ci-dessus. L'assemblage est ci-dessous. Les deux ont été compilés avec gcc 7.4.0aucun indicateur sauf pour -O0et -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

module du noyau

Le code exécutant les essais est ci-dessous. Il lit PMCCNTR_EL0avant / après chaque itération, stocke les différences dans un tableau et imprime les temps min / max à la fin de tous les essais. Les fonctions cpu_workload_external_O0et se cpu_workload_external_O3trouvent dans des fichiers d'objets externes qui sont compilés séparément, puis liés.

#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");
}

Matériel

$ 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

Exemples de mesures

Vous trouverez ci-dessous une sortie d'une exécution du module du noyau:

[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

Pour chaque essai, les valeurs rapportées sont: # de cycles (0x11), # d'accès L1D (0x04), # d'accès L1I (0x14). J'utilise la section 11.8 de cette référence ARM PMU ).

sevko
la source
2
Y a-t-il d'autres threads en cours d'exécution? Leurs accès à la mémoire provoquant une concurrence pour la bande passante du bus et l'espace cache pourraient avoir un effet.
prl
Pourrait être. Je n'ai pas isolé de cœurs, et même dans ce cas, un thread du noyau peut être planifié sur l'un des autres cœurs du socket. Mais si je comprends lscpu --extendedbien, chaque cœur a ses propres caches de données et d'instructions L1, puis chaque socket a un cache L2 partagé pour ses 4 cœurs, donc tant que tout est fait dans le cache L1, je m'attendrais à ce que le code soit joli "posséder" son bus (car c'est la seule chose qui tourne sur son cœur, jusqu'à son achèvement). Cependant, je ne connais pas grand-chose au matériel à ce niveau.
sevko
1
Oui, il est clairement indiqué comme 4 sockets, mais cela pourrait simplement être une question de connexion de l'interconnexion à l'intérieur d'un SoC à 16 cœurs. Mais vous avez la machine physique, non? Avez-vous une marque et un numéro de modèle pour cela? Si le couvercle se détache, vous pouvez probablement confirmer qu'il y a vraiment 4 prises séparées. Je ne vois pas pourquoi tout cela importerait, cependant, à l'exception peut-être du numéro de fournisseur / modèle du mobo. Votre référence est purement monocœur et devrait rester chaude dans le cache, donc tout ce qui devrait être important est le cœur A72 lui-même et son tampon de stockage + la redirection de magasin.
Peter Cordes
1
J'ai modifié le module du noyau pour suivre trois compteurs et ajouté quelques exemples de sortie. Ce qui est intéressant, c'est que la plupart des analyses sont cohérentes, mais une analyse aléatoire sera considérablement plus rapide. Dans ce cas, il semble que le plus rapide ait en fait très légèrement plus d' accès L1, ce qui implique peut-être une prédiction de branche plus agressive quelque part. De plus, malheureusement, je n'ai pas accès à la machine. Il s'agit d'une instance AWS a1.metal (qui vous donne la pleine propriété du matériel physique, donc il n'y a apparemment aucune interférence d'un hyperviseur, etc.).
sevko
1
Fait intéressant, si je fais exécuter le code du module du noyau simultanément sur tous les processeurs via on_each_cpu(), chacun ne signale pratiquement aucune variabilité sur 100 essais.
sevko

Réponses:

4

Dans les noyaux Linux récents, le mécanisme de migration automatique des pages NUMA abat périodiquement les entrées TLB afin qu'il puisse surveiller la localité NUMA. Les rechargements TLB ralentiront le code O0, même si les données restent dans le L1DCache.

Le mécanisme de migration de page ne doit pas être activé sur les pages du noyau.

Vous vérifiez si la migration automatique des pages NUMA est activée avec

$ cat /proc/sys/kernel/numa_balancing

et vous pouvez le désactiver avec

$ echo 0 > /proc/sys/kernel/numa_balancing
John D McCalpin
la source
J'ai fait des tests connexes récemment. J'exécute une charge de travail qui crée un tas d'accès aléatoires à un tampon de mémoire qui tient confortablement dans le cache L1. Je lance un tas d'essais dos à dos, et le temps de fonctionnement est très cohérent (varie littéralement moins de 0,001%), sauf périodiquement qu'il y a une petite pointe vers le haut. Dans ce pic, l'indice de référence ne court que 0,014% de plus. C'est petit, mais chacun de ces pics a exactement la même ampleur, et un pic se produit une fois presque exactement toutes les 2 secondes. Cette machine est numa_balancingdésactivée. Vous avez peut-être une idée?
sevko
Deviner. Je regardais les compteurs de perf toute la journée, mais il s'est avéré que la cause profonde était quelque chose de totalement indépendant .. J'exécutais ces tests dans une session tmux sur une machine silencieuse. L'intervalle de 2 secondes coïncidait exactement avec l'intervalle de rafraîchissement de ma ligne d'état tmux, ce qui fait une demande réseau entre autres choses. La désactiver a fait disparaître les pics. Aucune idée de la façon dont les scripts exécutés par ma ligne d'état sur un cluster principal différent affectaient le processus s'exécutant sur un cluster principal isolé, touchant uniquement les données L1.
Sevko
2

Votre variance est de l'ordre de 6 * 10 ^ -4. Bien que choquant plus de 1,3 * 10 ^ -6, une fois que votre programme parle aux caches, il est impliqué dans de nombreuses opérations synchronisées. Synchronisé signifie toujours du temps perdu.

Une chose intéressante est de savoir comment votre comparaison -O0, -O3 imite la règle générale qu'un hit L1-cache est environ 2x une référence de registre. Votre O3 moyen fonctionne dans 51,70% du temps. Lorsque vous appliquez les écarts inférieurs / supérieurs, nous avons (O3-200) / (O0 + 158386), nous constatons une amélioration à 51,67%.

En bref, oui, un cache ne sera jamais déterministe; et la faible variance que vous voyez est conforme à ce qui devrait être attendu de la synchronisation avec un appareil plus lent. Ce n'est qu'une grande variance par rapport à la machine à registres seuls plus déterministe.

mevets
la source
Les instructions sont extraites du cache L1i. Je suppose que vous dites que vous ne pouvez pas souffrir de ralentissements imprévisibles car ce n'est pas cohérent avec les caches de données sur le même ou d'autres cœurs? Mais de toute façon, si la réponse du Dr Bandwidth est juste, la variance n'est pas due au cache lui-même, mais plutôt à l'invalidation périodique de dTLB par le noyau. Cette explication explique pleinement toute l'observation: la variance accrue de l'inclusion de charges / magasins dans l'espace utilisateur et le fait que cette baisse ne se produit pas lors du chronométrage de la boucle à l'intérieur d'un module du noyau. (La mémoire du noyau Linux n'est pas échangeable.)
Peter Cordes
Les caches sont généralement déterministes lorsque vous accédez à des données chaudes. Ils peuvent être multi-ports pour permettre un trafic cohérent sans perturber les charges / magasins du cœur lui-même. Votre supposition que les perturbations sont dues à d'autres cœurs est plausible, mais moi, les numa_balancinginvalidations TLB seules l'expliquent probablement.
Peter Cordes
Tout cache d'espionnage doit avoir une séquence ininterrompue dans laquelle toute demande doit être bloquée. Un ralentissement de 10 ^ -4 sur une opération à 1 contre 2 signifie un hoquet d'une horloge toutes les 10 ^ 5 opérations. Toute la question est vraiment un no-op, la variance est minuscule.
mevets