Что вызывает эту высокую изменчивость в циклах для простой узкой петли с -O0, но не -O3, на Cortex-A72?

9

Я провожу несколько экспериментов по получению высокосогласованных сред выполнения для куска кода. Код, который я сейчас синхронизирую, представляет собой довольно произвольную нагрузку на процессор:

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

Я написал модуль ядра, который отключает прерывания, а затем запускает 10 испытаний вышеуказанной функции, синхронизируя каждое испытание, считая разницу в счетчике тактов до и после. Другие вещи, чтобы отметить:

  • Машина представляет собой ARM Cortex-A72, с 4 разъемами по 4 ядра в каждом (каждый со своим кешем L1)
  • масштабирование тактовой частоты выключено
  • гиперпоточность не поддерживается
  • машина практически не работает, за исключением некоторых системных процессов

Другими словами, я считаю, что большинство / все источники системной изменчивости учтены, и, особенно при запуске в качестве модуля ядра с отключенными прерываниями spin_lock_irqsave(), код должен достигать практически одинаковой производительности при запуске (возможно, небольшой удар по производительности) при первом запуске, когда какая-то инструкция сначала помещается в кеш, но это все).

Действительно, когда скомпилированный эталонный код компилируется -O3, я видел диапазон не более 200 циклов из ~ 135 845 192 в среднем, причем большинство испытаний занимало ровно столько же времени. Однако при компиляции -O0диапазон увеличился до 158 386 циклов из ~ 262 710 916. Под диапазоном я подразумеваю разницу между самым длинным и самым коротким временем работы. Более того, что касается -O0кода, нет большой согласованности с тем, какое из испытаний является самым медленным / самым быстрым - нелогично, в одном случае самым быстрым был самый первый, а самым медленным - тот, который сразу после!

Итак : что может быть причиной этой высокой верхней границы изменчивости в -O0коде? Глядя на сборку, кажется, что -O3код хранит все (?) В регистре, в то время как -O0код имеет кучу ссылок, spи поэтому он, кажется, обращается к памяти. Но даже тогда я ожидал, что все попадет в кэш L1 и будет сидеть с довольно детерминированным временем доступа.


Код

Тестируемый код находится во фрагменте выше. Сборка ниже. Оба были скомпилированы gcc 7.4.0без флагов, кроме -O0и -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

модуль ядра

Код для запуска испытаний приведен ниже. Он читает PMCCNTR_EL0до / после каждой итерации, сохраняет различия в массиве и выводит минимальное / максимальное время в конце всех испытаний. Функции cpu_workload_external_O0и cpu_workload_external_O3находятся во внешних объектных файлах, которые скомпилированы отдельно, а затем связаны в.

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

аппаратные средства

$ 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

Измерения образца

Ниже приведены некоторые результаты одного выполнения модуля ядра:

[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

Для каждого испытания сообщаются следующие значения: количество циклов (0x11), количество обращений L1D (0x04), количество обращений L1I (0x14). Я использую раздел 11.8 этой ссылки ARM PMU ).

sevko
источник
2
Работают ли другие темы? Их доступ к памяти, вызывающий конкуренцию за пропускную способность шины и пространство кеша, может иметь эффект
prl
Может быть. Я не выделил никаких ядер, и даже в этом случае поток ядра мог бы быть запланирован на одном из других ядер сокета. Но если я lscpu --extendedправильно понимаю , тогда каждое ядро ​​имеет свои собственные кэши данных и инструкций L1, а затем каждый сокет имеет общий кэш L2 для своих 4 ядер, поэтому, пока все выполняется в кэше L1, я ожидаю, что код будет довольно много «владеет» своей шиной (так как это единственное, что работает на ее ядре, до завершения). Хотя я не очень разбираюсь в оборудовании на этом уровне.
Севко
1
Да, это четко указано как 4 сокета, но это может быть просто вопросом того, как межсоединение подключено в 16-ядерном SoC. Но у вас есть физическая машина, верно? У вас есть бренд и номер модели? Если крышка откроется, возможно, вы также можете проверить, есть ли на самом деле 4 отдельных разъема. Я не понимаю, почему что-то из этого имеет значение, за исключением, может быть, номера продавца / модели mobo. Ваш эталонный тест является чисто одноядерным и должен оставаться горячим в кеше, поэтому все, что должно иметь значение, это само ядро ​​A72 и его буфер хранения + пересылка хранилища.
Питер Кордес
1
Я изменил модуль ядра для отслеживания трех счетчиков и добавил пример вывода. Интересно то, что большинство пробегов последовательны, но тогда случайный будет значительно быстрее. В этом случае, похоже, что самый быстрый из них на самом деле имел чуть больше обращений к L1, что, возможно, предполагает более агрессивное предсказание ветвления где-то. Кроме того, к сожалению, у меня нет доступа к машине. Это экземпляр AWS a1.metal (который дает вам полное право владения физическим оборудованием, поэтому якобы нет помех от гипервизора и т. Д.).
sevko
1
Интересно, что если я заставлю модуль ядра запускать этот код на всех процессорах одновременно on_each_cpu(), каждый из них почти не сообщит об изменчивости в течение 100 испытаний.
Севко

Ответы:

4

В последних ядрах Linux механизм автоматической миграции страниц NUMA периодически сбрасывает записи TLB, чтобы он мог отслеживать локальность NUMA. Перезагрузки TLB замедляют код O0, даже если данные остаются в L1DCache.

Механизм переноса страниц не должен быть активирован на страницах ядра.

Вы проверяете, включена ли автоматическая миграция страницы NUMA с

$ cat /proc/sys/kernel/numa_balancing

и вы можете отключить его с

$ echo 0 > /proc/sys/kernel/numa_balancing
Джон Д. Маккальпин
источник
В последнее время я проводил некоторые связанные тесты. Я запускаю рабочую нагрузку, которая делает несколько случайных обращений к буферу памяти, который удобно помещается в кэш L1. Я провожу кучу испытаний спина к спине, и время бега очень стабильно (колеблется буквально менее 0,001%), за исключением периодически небольшого всплеска вверх. В этом пике тест работает всего на 0,014% дольше. Это мало, но каждый из этих всплесков имеет одинаковую величину, и всплеск возникает один раз, почти точно один раз каждые 2 секунды. Эта машина numa_balancingотключена. Возможно, у вас есть идея?
Севко
Догадаться. Я весь день смотрел на счетчики перфокарт, но оказалось, что основная причина была совершенно не связана ... Я выполнял эти тесты в сеансе tmux на тихой машине. 2-секундный интервал в точности совпадал с интервалом обновления моей строки состояния tmux, что делает сетевой запрос среди некоторых других вещей. Отключение заставило шипы исчезнуть. Понятия не имею, как сценарии, выполняемые моей строкой состояния на другом базовом кластере, влияли на процесс, работающий на изолированном
базовом
2

Ваша дисперсия составляет порядка 6 * 10 ^ -4. Хотя шокирующе больше, чем 1,3 * 10 ^ -6, когда ваша программа обращается к кешам, она участвует во многих синхронизированных операциях. Синхронизация всегда означает потерянное время.

Интересно, что сравнение -O0, -O3 имитирует общее правило, согласно которому попадание в кэш L1 примерно в 2 раза больше ссылки на регистр. Ваш средний O3 работает в 51,70% времени от вашего O0. Когда вы применяете нижнюю / верхнюю дисперсию, мы имеем (O3-200) / (O0 + 158386), мы видим улучшение до 51,67%.

Короче говоря, да, кеш никогда не будет детерминированным; и низкая дисперсия, которую вы видите, соответствует тому, что следует ожидать от синхронизации с более медленным устройством. Это только большая разница по сравнению с более детерминированной машиной только для регистров.

mevets
источник
Инструкции извлекаются из кэша L1i. Я полагаю, вы говорите, что не можете страдать от непредсказуемых замедлений, потому что это не согласуется с кэшем данных на том же или других ядрах? Но в любом случае, если ответ Dr. Bandwidth правильный, разница не в самом кеше, а в периодической отмене ядром dTLB. Это объяснение полностью объясняет все наблюдение: повышенную дисперсию от включения любых загрузок / хранилищ в пространстве пользователя и тот факт, что это падение не происходит при синхронизации цикла внутри модуля ядра. (Память ядра Linux не может быть заменена.)
Питер Кордес,
Кэши обычно являются детерминированными, когда вы получаете доступ к горячим данным. Они могут быть многопортовыми, чтобы обеспечить согласованный трафик, не мешая загрузке / хранению самого ядра. Ваше предположение, что нарушения вызваны другими ядрами, правдоподобно, но я, numa_balancingвероятно, объясняю это только недействительностью TLB.
Питер Кордес
Любой отслеживающий кеш должен иметь непрерывную последовательность, в которой любой запрос должен быть остановлен. Замедление на 10 ^ -4 при работе с циклом 1 на 2 означает один удар по часам на каждые 10 ^ 5 операций. Весь вопрос действительно бесполезен, разница крошечная.
Мевец