Was verursacht diese hohe Variabilität in Zyklen für eine einfache enge Schleife mit -O0, aber nicht -O3 auf einem Cortex-A72?


9

Ich führe einige Experimente durch, um hochkonsistente Laufzeiten für einen Code zu erhalten. Der Code, den ich gerade zeitlich festlege, ist eine ziemlich willkürliche CPU-gebundene Arbeitslast:

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

Ich habe ein Kernelmodul geschrieben, das Interrupts deaktiviert und dann 10 Versuche mit der oben genannten Funktion ausführt, wobei jeder Versuch zeitlich festgelegt wird, indem die Differenz im Taktzykluszähler von vorher und nachher genommen wird. Andere Dinge zu beachten:

  • Die Maschine ist eine ARM Cortex-A72 mit 4 Sockeln mit jeweils 4 Kernen (jeder mit eigenem L1-Cache).
  • Die Taktfrequenzskalierung ist ausgeschaltet
  • Hyperthreading wird nicht unterstützt
  • Auf der Maschine läuft praktisch nichts außer einigen Bare-Bones-Systemprozessen

Mit anderen Worten, ich glaube, dass die meisten / alle Ursachen für Systemvariabilität berücksichtigt werden, und insbesondere wenn spin_lock_irqsave()der Code als Kernelmodul mit deaktivierten Interrupts ausgeführt wird , sollte er von Lauf zu Lauf eine nahezu identische Leistung erzielen (möglicherweise ein kleiner Leistungseinbruch) beim ersten Durchlauf, wenn eine Anweisung zum ersten Mal in den Cache gezogen wird, aber das war's).

In der Tat habe -O3ich beim Kompilieren des Benchmark-Codes einen Bereich von höchstens 200 Zyklen von durchschnittlich ~ 135.845.192 gesehen, wobei die meisten Versuche genau die gleiche Zeit in Anspruch nahmen. Bei der Kompilierung mit -O0schoss der Bereich jedoch bis zu 158.386 Zyklen von ~ 262.710.916. Mit Reichweite meine ich den Unterschied zwischen der längsten und der kürzesten Laufzeit. Darüber hinaus gibt es für den -O0Code nicht viel Konsistenz darüber, welcher der Versuche der langsamste / schnellste ist - intuitiv gesehen war der schnellste der allererste und der langsamste der unmittelbarste!

Also : Was könnte diese hohe Obergrenze für die Variabilität im -O0Code verursachen? Wenn man sich die Assembly ansieht, scheint es, dass der -O3Code alles (?) In einem Register speichert, während der -O0Code eine Reihe von Verweisen auf enthält spund daher auf Speicher zuzugreifen scheint. Aber selbst dann würde ich erwarten, dass alles in den L1-Cache gebracht wird und dort mit einer ziemlich deterministischen Zugriffszeit sitzt.


Code

Der zu vergleichende Code befindet sich im obigen Snippet. Die Montage ist unten. Beide wurden gcc 7.4.0ohne Flags außer -O0und kompiliert -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

Kernelmodul

Der Code, der die Versuche ausführt, ist unten. Es liest PMCCNTR_EL0vor / nach jeder Iteration, speichert die Unterschiede in einem Array und druckt die Min / Max-Zeiten am Ende über alle Versuche hinweg aus. Die Funktionen cpu_workload_external_O0und cpu_workload_external_O3befinden sich in externen Objektdateien, die separat kompiliert und dann verknüpft werden.

#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

Probenmessungen

Nachfolgend finden Sie einige Ausgaben einer Ausführung des Kernelmoduls:

[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

Für jeden Versuch werden folgende Werte angegeben: Anzahl der Zyklen (0x11), Anzahl der L1D-Zugriffe (0x04), Anzahl der L1I-Zugriffe (0x14). Ich verwende Abschnitt 11.8 dieser ARM-PMU-Referenz .


2
Laufen noch andere Threads? Ihre Speicherzugriffe, die Konkurrenz um Busbandbreite und Cache-Speicherplatz verursachen, könnten sich auswirken.
Prl

Könnte sein. Ich habe keine Kerne isoliert, und selbst dann wird möglicherweise ein Kernel-Thread für einen der anderen Kerne am Socket geplant. Aber wenn ich das lscpu --extendedrichtig verstehe , hat jeder Kern seine eigenen L1-Daten- und Anweisungs-Caches, und dann hat jeder Socket einen gemeinsam genutzten L2-Cache für seine 4 Kerne. Solange alles im L1-Cache erledigt ist, würde ich erwarten, dass der Code hübsch ist viel "besitzen" seinen Bus (da es das einzige ist, was bis zur Fertigstellung auf seinem Kern läuft). Ich weiß jedoch nicht viel über Hardware auf dieser Ebene.
Sevko

1
Ja, es wird eindeutig als 4 Sockel gemeldet, aber das hängt möglicherweise davon ab, wie die Verbindung in einem 16-Kern-SoC verkabelt ist. Aber du hast die physische Maschine, oder? Haben Sie eine Marken- und Modellnummer dafür? Wenn sich der Deckel löst, können Sie vermutlich auch überprüfen, ob es wirklich 4 separate Steckdosen gibt. Ich verstehe jedoch nicht, warum dies alles von Bedeutung ist, außer vielleicht der Hersteller- / Modellnummer des Mobos. Ihr Benchmark ist ein reiner Single-Core und sollte im Cache heiß bleiben. Alles, was wichtig sein sollte, ist der A72-Core selbst und sein Speicherpuffer + Speicherweiterleitung.
Peter Cordes

1
Ich habe das Kernelmodul geändert, um drei Zähler zu verfolgen, und einige Beispielausgaben hinzugefügt. Interessant ist, dass die meisten Läufe konsistent sind, ein zufälliger jedoch wesentlich schneller ist. In diesem Fall sieht es so aus, als hätte der schnellste tatsächlich etwas mehr L1-Zugriffe, was möglicherweise irgendwo eine aggressivere Verzweigungsvorhersage impliziert. Leider habe ich auch keinen Zugriff auf die Maschine. Es handelt sich um eine AWS a1.metal-Instanz (mit der Sie die physische Hardware vollständig in Besitz nehmen können, sodass angeblich keine Störungen durch einen Hypervisor usw. auftreten).
Sevko

1
Interessanterweise on_each_cpu()meldet jedes Kernelmodul, wenn ich diesen Code auf allen CPUs gleichzeitig über ausführen lasse , fast keine Variabilität über 100 Versuche.
Sevko

Antworten:


4

In neueren Linux-Kerneln werden durch den automatischen NUMA-Seitenmigrationsmechanismus regelmäßig TLB-Einträge abgeschossen, damit die NUMA-Lokalität überwacht werden kann. TLB-Neuladungen verlangsamen den O0-Code, auch wenn die Daten im L1DCache verbleiben.

Der Seitenmigrationsmechanismus sollte auf Kernelseiten nicht aktiviert werden.

Sie überprüfen, ob die automatische NUMA-Seitenmigration mit aktiviert ist

$ cat /proc/sys/kernel/numa_balancing

und Sie können es mit deaktivieren

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

Ich habe in letzter Zeit einige verwandte Tests durchgeführt. Ich führe eine Workload aus, die eine Reihe von zufälligen Zugriffen auf einen Speicherpuffer ermöglicht, der bequem in den L1-Cache passt. Ich führe eine Reihe von Versuchen hintereinander durch, und die Laufzeit ist sehr konsistent (variiert buchstäblich unter 0,001%), außer in regelmäßigen Abständen gibt es einen kleinen Anstieg nach oben. In diesem Anstieg läuft der Benchmark nur um 0,014% länger. Dies ist klein, aber jede dieser Spitzen hat genau die gleiche Größe, und eine Spitze tritt einmal fast genau alle 2 Sekunden einmal auf. Diese Maschine wurde numa_balancingdeaktiviert. Vielleicht hast du eine Idee?
17.

Herausgefunden. Ich habe den ganzen Tag auf Leistungsindikatoren gestarrt, aber es stellte sich heraus, dass die Grundursache völlig unabhängig war. Ich habe diese Tests in einer tmux-Sitzung auf einem leisen Computer ausgeführt. Das 2-Sekunden-Intervall stimmte genau mit dem Aktualisierungsintervall meiner tmux-Statuszeile überein, wodurch unter anderem eine Netzwerkanforderung gestellt wird. Durch Deaktivieren wurden die Spitzen ausgeblendet. Keine Ahnung, wie sich die Skripte, die von meiner Statuszeile auf einem anderen
Kerncluster ausgeführt werden,

2

Ihre Varianz liegt in der Größenordnung von 6 * 10 ^ -4. Während Ihr Programm schockierend mehr als 1,3 * 10 ^ -6 beträgt, ist es, sobald es mit den Caches spricht, an vielen synchronisierten Vorgängen beteiligt. Synchronisiert bedeutet immer Zeitverschwendung.

Interessant ist, wie Ihr -O0, -O3-Vergleich die allgemeine Regel nachahmt, dass ein L1-Cache-Treffer etwa das Zweifache einer Registerreferenz ist. Ihr durchschnittlicher O3 läuft in 51,70% der Zeit, in der Ihr O0 läuft. Wenn Sie die unteren / oberen Abweichungen anwenden, haben wir (O3-200) / (O0 + 158386) eine Verbesserung auf 51,67%.

Kurz gesagt, ja, ein Cache wird niemals deterministisch sein. und die geringe Varianz, die Sie sehen, entspricht den Erwartungen an die Synchronisierung mit einem langsameren Gerät. Es ist nur eine große Varianz im Vergleich zu der deterministischeren Nur-Register-Maschine.


Anweisungen werden aus dem L1i-Cache abgerufen. Ich denke, Sie sagen, dass dies nicht unter unvorhersehbaren Verlangsamungen leiden kann, weil es nicht mit Datencaches auf demselben oder anderen Kernen kohärent ist? Wenn jedoch die Antwort von Dr. Bandwidth richtig ist, ist die Varianz nicht auf den Cache selbst zurückzuführen, sondern auf die periodische Ungültigmachung der dTLB durch den Kernel. Diese Erklärung erklärt die gesamte Beobachtung vollständig: die erhöhte Varianz durch das Einbeziehen von Lasten / Speichern in den Benutzerraum und die Tatsache, dass dieser Abfall nicht auftritt, wenn die Schleife innerhalb eines Kernelmoduls zeitlich gesteuert wird. (Linux-Kernel-Speicher ist nicht austauschbar.)
Peter Cordes

Caches sind normalerweise deterministisch, wenn Sie auf heiße Daten zugreifen. Sie können mehrfach portiert werden, um Kohärenzverkehr zu ermöglichen, ohne die Lasten / Speicher vom Kern selbst zu stören. Ihre Vermutung, dass Störungen durch andere Kerne verursacht werden, ist plausibel, aber ich numa_balancingallein die TLB-Invalidierungen erklären es wahrscheinlich.
Peter Cordes

Jeder Snooping-Cache muss eine unterbrechungsfreie Sequenz haben, in der jede Anforderung blockiert werden muss. Eine Verlangsamung von 10 ^ -4 bei einem Betrieb mit 1 gegen 2 Zyklen bedeutet einen Schluckauf von einem Takt alle 10 ^ 5 Operationen. Die ganze Frage ist wirklich ein No-Op, die Varianz ist winzig.
Mevets
Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.