Co powoduje tak dużą zmienność w cyklach dla prostej ciasnej pętli z -O0, ale nie -O3, na Cortex-A72?


9

Przeprowadzam eksperymenty, aby uzyskać bardzo spójne środowiska wykonawcze dla fragmentu kodu. Kod, który aktualnie sprawdzam, jest dość arbitralnym obciążeniem procesora:

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

Napisałem moduł jądra, który wyłącza przerwania, a następnie uruchamia 10 prób powyższej funkcji, mierząc każdą próbę, biorąc różnicę w liczniku cykli zegara przed i po. Inne rzeczy do zapamiętania:

  • maszyna jest ARM Cortex-A72, z 4 gniazdami z 4 rdzeniami każdy (każdy z własną pamięcią podręczną L1)
  • skalowanie częstotliwości zegara jest wyłączone
  • hyperthreading nie jest obsługiwany
  • maszyna praktycznie nie działa, z wyjątkiem niektórych procesów systemowych

Innymi słowy, uważam, że większość / wszystkie źródła zmienności systemu są uwzględnione, a zwłaszcza, gdy jest uruchomiony jako moduł jądra z wyłączonymi przerwaniami przez spin_lock_irqsave(), kod powinien osiągnąć prawie identyczną wydajność w trybie run-to-run (być może niewielki spadek wydajności przy pierwszym uruchomieniu, gdy jakaś instrukcja zostanie najpierw wciągnięta do pamięci podręcznej, ale to wszystko).

Rzeczywiście, po skompilowaniu kodu testowego -O3zobaczyłem zakres co najwyżej 200 cykli z około 135 845 192, przy czym większość prób zajęła dokładnie tyle samo czasu. Jednak po skompilowaniu -O0zakres strzelił aż do 158 386 cykli z ~ 262,710,916. Przez zakres rozumiem różnicę między najdłuższym i najkrótszym czasem pracy. Co więcej, w -O0kodzie nie ma dużej spójności, która z prób jest najwolniejsza / najszybsza - wbrew intuicji, w jednym przypadku najszybsza była pierwsza, a najwolniejsza następna!

Więc : co może powodować tę górną granicę zmienności w -O0kodzie? Patrząc na zestaw, wydaje się, że -O3kod przechowuje wszystko (?) W rejestrze, podczas gdy -O0kod zawiera wiele odniesień do, spa zatem wydaje się, że uzyskuje dostęp do pamięci. Ale nawet wtedy spodziewałbym się, że wszystko znajdzie się w pamięci podręcznej L1 i będzie tam siedział z dość deterministycznym czasem dostępu.


Kod

Testowany kod znajduje się we fragmencie powyżej. Zespół jest poniżej. Oba zostały skompilowane gcc 7.4.0bez flag, z wyjątkiem -O0i -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

moduł jądra

Kod prowadzący próby znajduje się poniżej. Odczytuje PMCCNTR_EL0przed / po każdej iteracji, przechowuje różnice w tablicy i drukuje czasy min / maks na końcu we wszystkich próbach. Funkcje cpu_workload_external_O0i cpu_workload_external_O3są w zewnętrznych plikach obiektowych, które są skompilowane oddzielnie, a następnie łączone w.

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

Sprzęt komputerowy

$ 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

Przykładowe pomiary

Poniżej przedstawiono dane wyjściowe z jednego wykonania modułu jądra:

[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

Dla każdej próby zgłaszane wartości to: liczba cykli (0x11), liczba dostępów L1D (0x04), liczba dostępów L1I (0x14). Korzystam z sekcji 11.8 tej dokumentacji ARM PMU ).


2
Czy są uruchomione inne wątki? Ich dostęp do pamięci powodujący konkurencję dla przepustowości magistrali i przestrzeni pamięci podręcznej może mieć wpływ.
prl

Możliwe. Nie izolowałem żadnych rdzeni, a nawet wtedy wątek jądra może zostać zaplanowany na jednym z pozostałych rdzeni gniazda. Ale jeśli dobrze rozumiem lscpu --extended, to każdy rdzeń ma swoją własną pamięć podręczną danych i instrukcji L1, a następnie każde gniazdo ma współużytkowaną pamięć podręczną L2 dla swoich 4 rdzeni, więc dopóki wszystko zostanie wykonane w pamięci podręcznej L1, oczekiwałbym, że kod będzie ładny wiele „posiada” swoją magistralę (ponieważ jest to jedyna rzecz działająca na jej rdzeniu, aż do ukończenia). Jednak niewiele wiem o sprzęcie na tym poziomie.
sevko

1
Tak, jest wyraźnie zgłaszany jako 4 gniazda, ale może to być tylko kwestia tego, jak interkonekt jest podłączony do 16-rdzeniowego SoC. Ale masz fizyczną maszynę, prawda? Czy masz na to markę i numer modelu? Jeśli pokrywa odpadnie, prawdopodobnie możesz również potwierdzić, czy naprawdę są 4 oddzielne gniazda. Nie rozumiem jednak, dlaczego to miało mieć znaczenie, z wyjątkiem może numeru dostawcy / modelu mobo. Twój test porównawczy jest czysto jednordzeniowy i powinien pozostać gorący w pamięci podręcznej, więc wszystko, co powinno mieć znaczenie, to sam rdzeń A72 i jego bufor pamięci + przekazywanie sklepów.
Peter Cordes,

1
Zmieniłem moduł jądra, aby śledzić trzy liczniki i dodałem przykładowe dane wyjściowe. Co ciekawe, większość przebiegów jest zgodna, ale losowa będzie znacznie szybsza. W tym przypadku wygląda na to, że najszybszy faktycznie miał bardzo nieznacznie więcej dostępów do L1, co może sugerować bardziej agresywne przewidywanie gałęzi. Niestety nie mam dostępu do maszyny. Jest to instancja AWS a1.metal (która daje pełną własność fizycznego sprzętu, więc pozornie nie ma interferencji ze strony hiperwizora itp.).
sewko

1
Co ciekawe, jeśli sprawię, że moduł jądra będzie uruchamiał ten kod na wszystkich procesorach jednocześnie on_each_cpu(), każdy z nich zgłasza prawie brak zmienności w 100 próbach.
seko

Odpowiedzi:


4

W najnowszych jądrach Linuksa mechanizm automatycznej migracji stron NUMA okresowo wyrzuca wpisy TLB, aby mógł monitorować lokalizację NUMA. Przeładowania TLB spowolnią kod O0, nawet jeśli dane pozostaną w L1DCache.

Mechanizm migracji stron nie powinien być aktywowany na stronach jądra.

Sprawdzasz, czy automatyczna migracja stron NUMA jest włączona za pomocą

$ cat /proc/sys/kernel/numa_balancing

i możesz to wyłączyć za pomocą

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

Ostatnio przeprowadzam kilka powiązanych testów. Pracuję z obciążeniem, które umożliwia losowy dostęp do bufora pamięci, który wygodnie mieści się w pamięci podręcznej L1. Przeprowadzam wiele prób z powrotem do tyłu, a czas działania jest bardzo spójny (zmienia się dosłownie mniej niż 0,001%), z wyjątkiem okresowych skoków w górę. W tym skoku test porównawczy działa tylko o 0,014% dłużej. Jest to małe, ale każdy z tych skoków ma dokładnie taką samą wielkość, a skok występuje raz prawie dokładnie raz na 2 sekundy. To urządzenie zostało numa_balancingwyłączone. Może masz pomysł?
sevko

Domyśliłam się. Patrzyłem na liczniki perf przez cały dzień, ale okazało się, że pierwotna przyczyna była czymś zupełnie niezwiązanym. Testy przeprowadzałem w sesji tmux na cichej maszynie. 2-sekundowy interwał zbiegł się dokładnie z interwałem odświeżania mojej linii statusu tmux, co powoduje żądanie sieciowe między innymi rzeczami. Wyłączenie go sprawiło, że skoki zniknęły. Nie mam pojęcia, w jaki sposób skrypty uruchamiane przez moją linię statusu w innym klastrze rdzeniowym miały wpływ na proces działający na izolowanym klastrze rdzeniowym, dotykając tylko danych L1.
sevko

2

Twoja wariancja jest rzędu 6 * 10 ^ -4. Choć szokująco więcej niż 1,3 * 10 ^ -6, gdy twój program mówi do pamięci podręcznej, bierze udział w wielu zsynchronizowanych operacjach. Zsynchronizowany zawsze oznacza zmarnowany czas.

Interesującą rzeczą jest to, jak porównanie -O0, -O3 naśladuje ogólną zasadę, że trafienie w pamięć podręczną L1 ma około 2x odniesienie do rejestru. Twoje średnie O3 działa w 51,70% czasu, w którym działa O0. Po zastosowaniu wariancji niższej / wyższej, mamy (O3-200) / (O0 + 158386), zauważamy poprawę do 51,67%.

Krótko mówiąc, tak, pamięć podręczna nigdy nie będzie deterministyczna; a niska wariancja, którą widzisz, jest zgodna z tym, czego należy się spodziewać po synchronizacji z wolniejszym urządzeniem. Jest to tylko duża wariancja w porównaniu z bardziej deterministyczną maszyną tylko do rejestrów.


Instrukcje są pobierane z pamięci podręcznej L1i. Myślę, że mówisz, że nie może cierpieć z powodu nieprzewidzianych spowolnień, ponieważ nie jest spójny z buforami danych na tym samym lub innym rdzeniu? Ale w każdym razie, jeśli odpowiedź Dr. Bandwidtha jest prawidłowa, wariancja nie wynika z samej pamięci podręcznej, ale z okresowej unieważnienia dTLB przez jądro. To wyjaśnienie w pełni wyjaśnia całą obserwację: zwiększoną wariancję wynikającą z włączenia dowolnych ładunków / magazynów w przestrzeń użytkownika oraz fakt, że ten spadek nie występuje podczas synchronizacji pętli w module jądra. (Pamięć jądra Linux nie jest wymienna).
Peter Cordes

Pamięci podręczne są zazwyczaj deterministyczne podczas uzyskiwania dostępu do gorących danych. Mogą być wieloportowe, aby umożliwić ruch koherencyjny bez zakłócania obciążeń / sklepów z samego rdzenia. Twoje przypuszczenie, że zakłócenia są powodowane przez inne rdzenie, jest prawdopodobne, ale numa_balancingprawdopodobnie same unieważnienia TLB to wyjaśniają.
Peter Cordes

Każda pamięć podręczna szpiegująca musi mieć nieprzerwaną sekwencję, w której każde żądanie musi zostać zablokowane. Spowolnienie 10 ^ -4 podczas operacji 1 na 2 oznacza czkawkę na jeden zegar co 10 ^ 5 operacji. Całe pytanie jest naprawdę niemożliwe, wariancja jest niewielka.
mevets
Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.