¿Qué causa esta alta variabilidad en los ciclos para un circuito cerrado simple con -O0 pero no -O3, en un Cortex-A72?

9

Estoy ejecutando algunos experimentos para obtener tiempos de ejecución altamente consistentes para un fragmento de código. El código que estoy sincronizando actualmente es una carga de trabajo bastante arbitraria vinculada a la CPU:

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

He escrito un módulo de kernel que deshabilita las interrupciones y luego ejecuta 10 pruebas de la función anterior, cronometrando cada prueba tomando la diferencia en el contador del ciclo del reloj de antes y después. Otras cosas a tener en cuenta:

  • la máquina es un ARM Cortex-A72, con 4 zócalos con 4 núcleos cada uno (cada uno con su propio caché L1)
  • la escala de frecuencia del reloj está desactivada
  • hyperthreading no es compatible
  • la máquina no está ejecutando prácticamente nada, excepto algunos procesos básicos del sistema

En otras palabras, creo que la mayoría / todas las fuentes de variabilidad del sistema se tienen en cuenta, y, especialmente cuando se ejecuta como un módulo del núcleo con interrupciones deshabilitadas a través de spin_lock_irqsave(), el código debe lograr un rendimiento casi idéntico de ejecución a ejecución (tal vez un pequeño golpe de rendimiento en la primera ejecución cuando alguna instrucción se extrae por primera vez en caché, pero eso es todo).

De hecho, cuando se compila el código de referencia -O3, vi un rango de como máximo 200 ciclos de un promedio de ~ 135,845,192, y la mayoría de las pruebas tomaron exactamente la misma cantidad de tiempo. Sin embargo , cuando se compila -O0, el rango se disparó hasta 158,386 ciclos de ~ 262,710,916. Por rango me refiero a la diferencia entre los tiempos de ejecución más largos y más cortos. Además, para el -O0código, no hay mucha coherencia con respecto a cuál de las pruebas es la más lenta / más rápida: contraintuitivamente, en una ocasión, la más rápida fue la primera, ¡y la más lenta fue la siguiente!

Entonces : ¿qué podría estar causando este límite superior alto en la variabilidad en el -O0código? Al observar el ensamblaje, parece que el -O3código almacena todo (?) En un registro, mientras que el -O0código tiene un montón de referencias spy, por lo tanto, parece estar accediendo a la memoria. Pero incluso entonces, esperaría que todo se pusiera en el caché L1 y se sentara allí con un tiempo de acceso bastante determinista.


Código

El código que se está comparando está en el fragmento de arriba. La asamblea está abajo. Ambos fueron compilados gcc 7.4.0sin banderas excepto por -O0y -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

módulo del núcleo

El código que ejecuta las pruebas está a continuación. Lee PMCCNTR_EL0antes / después de cada iteración, almacena las diferencias en una matriz e imprime los tiempos mínimo / máximo al final en todas las pruebas. Las funciones cpu_workload_external_O0y cpu_workload_external_O3están en archivos de objetos externos que se compilan por separado y luego se vinculan.

#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

Mediciones de muestra

A continuación se muestra una salida de una ejecución del módulo del núcleo:

[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

Para cada ensayo, los valores informados son: # de ciclos (0x11), # de accesos L1D (0x04), # de accesos L1I (0x14). Estoy usando la sección 11.8 de esta referencia ARM PMU ).

sevko
fuente
2
¿Hay otros hilos en ejecución? Sus accesos a la memoria que causan competencia por el ancho de banda del bus y el espacio de caché podrían estar teniendo un efecto.
prl
Podría ser. No he isolcpu'd ningún núcleo, e incluso entonces un hilo del núcleo podría programarse en uno de los otros núcleos en el zócalo. Pero si estoy entendiendo lscpu --extendedcorrectamente, entonces cada núcleo tiene sus propios cachés de datos e instrucciones L1, y luego cada socket tiene un caché L2 compartido para sus 4 núcleos, por lo que mientras todo esté hecho dentro del caché L1, esperaría que el código sea bastante mucho "posee" su bus (ya que es lo único que se ejecuta en su núcleo, hasta su finalización). Sin embargo, no sé mucho sobre hardware a este nivel.
sevko
1
Sí, claramente se informa como 4 sockets, pero eso podría ser solo una cuestión de cómo la interconexión está conectada dentro de un SoC de 16 núcleos. Pero tienes la máquina física, ¿verdad? ¿Tiene una marca y número de modelo? Si se quita la tapa, presumiblemente también puede confirmar si realmente hay 4 tomas separadas. Sin embargo, no veo por qué algo de esto importaría, excepto tal vez el número de vendedor / modelo del mobo. Su punto de referencia es puramente de un solo núcleo y debe permanecer caliente en la memoria caché, por lo que todo lo que debe importar es el núcleo A72 en sí mismo y su almacenamiento intermedio de almacenamiento + reenvío de almacenamiento.
Peter Cordes
1
Cambié el módulo del núcleo para rastrear tres contadores y agregué algunos resultados de muestra. Lo interesante es que la mayoría de las ejecuciones son consistentes, pero una aleatoria será sustancialmente más rápida. En este caso, parece que el más rápido en realidad tuvo un poco más de acceso L1, lo que tal vez implique una predicción de rama más agresiva en alguna parte. Además, desafortunadamente no tengo acceso a la máquina. Es una instancia de AWS a1.metal (que le otorga la propiedad total del hardware físico, por lo que aparentemente no hay interferencia de un hipervisor, etc.).
sevko
1
Curiosamente, si hago que el módulo del kernel ejecute este código simultáneamente en todas las CPU on_each_cpu(), cada una informa casi ninguna variabilidad en 100 pruebas.
sevko

Respuestas:

4

En los núcleos de Linux recientes, el mecanismo automático de migración de páginas NUMA elimina periódicamente las entradas TLB para que pueda monitorear la localidad NUMA. Las recargas de TLB ralentizarán el código O0, incluso si los datos permanecen en el L1DCache.

El mecanismo de migración de página no debe activarse en las páginas del núcleo.

Verifica si la migración automática de páginas NUMA está habilitada con

$ cat /proc/sys/kernel/numa_balancing

y puedes deshabilitarlo con

$ echo 0 > /proc/sys/kernel/numa_balancing
John D McCalpin
fuente
He estado haciendo algunas pruebas relacionadas últimamente. Estoy ejecutando una carga de trabajo que realiza un montón de accesos aleatorios a un búfer de memoria que cabe cómodamente en la caché L1. Realizo varias pruebas consecutivas, y el tiempo de ejecución es muy consistente (varía literalmente menos del 0.001%), excepto que periódicamente hay un pequeño pico ascendente. En ese pico, el punto de referencia dura solo 0.014% más. Esto es pequeño, pero cada uno de estos picos tiene exactamente la misma magnitud, y un pico ocurre una vez casi exactamente una vez cada 2 segundos. Esta máquina se ha numa_balancingdeshabilitado. Tal vez tienes una idea?
sevko
Lo averigué. Estuve mirando los contadores de rendimiento todo el día, pero resultó que la causa raíz era algo totalmente ajeno. Estaba ejecutando estas pruebas en una sesión tmux en una máquina silenciosa. El intervalo de 2 segundos coincidió exactamente con el intervalo de actualización de mi línea de estado de tmux, que hace una solicitud de red entre otras cosas. La desactivación hizo desaparecer los picos. No tengo idea de cómo los scripts ejecutados por mi línea de estado en un clúster central diferente estaban afectando el proceso que se ejecuta en un clúster central aislado, tocando solo datos L1 ...
Sevko
2

Su varianza es del orden de 6 * 10 ^ -4. Aunque sorprendentemente más de 1.3 * 10 ^ -6, una vez que su programa está hablando con los cachés, está involucrado en muchas operaciones sincronizadas. Sincronizado siempre significa tiempo perdido.

Una cosa interesante es cómo su comparación -O0, -O3 imita la regla general de que un hit de caché L1 es aproximadamente 2 veces una referencia de registro. Su O3 promedio se ejecuta en 51.70% del tiempo que lo hace su O0. Cuando aplica las variaciones inferior / superior, tenemos (O3-200) / (O0 + 158386), vemos una mejora del 51.67%.

En resumen, sí, un caché nunca será determinista; y la baja varianza que ve está en línea con lo que debería esperarse de la sincronización con un dispositivo más lento. Es solo una gran variación en comparación con la máquina de registro más determinista.

mevets
fuente
Las instrucciones se obtienen del caché L1i. ¿Supongo que está diciendo que no puede sufrir desaceleraciones impredecibles porque no es coherente con los cachés de datos en el mismo o en otros núcleos? Pero de todos modos, si la respuesta del Dr. Bandwidth es correcta, la variación no se debe al caché en sí, sino a la invalidación periódica de dTLB por parte del núcleo. Esa explicación explica completamente toda la observación: la mayor variación de incluir cualquier carga / almacén en el espacio de usuario, y el hecho de que esta caída no ocurre cuando se sincroniza el ciclo dentro de un módulo del núcleo. (La memoria del kernel de Linux no es intercambiable.)
Peter Cordes
Los cachés generalmente son deterministas cuando se accede a datos activos. Pueden tener múltiples puertos para permitir el tráfico de coherencia sin perturbar las cargas / tiendas desde el núcleo mismo. Su suposición de que las perturbaciones se deben a otros núcleos es plausible, pero yo numa_balancingsolo las invalidaciones de TLB probablemente lo expliquen.
Peter Cordes
Cualquier caché de indagación debe tener una secuencia ininterrumpida en la que cualquier solicitud debe estar detenida. Una desaceleración de 10 ^ -4 en una operación de ciclo 1 vs 2 significa un hipo de un reloj cada 10 ^ 5 operaciones. Toda la pregunta es realmente no operativa, la variación es pequeña.
Mevets