¿Por qué mi caché L3 de 8M no proporciona ningún beneficio para matrices de más de 1M?


Me inspiré en esta pregunta para escribir un programa simple para probar el ancho de banda de memoria de mi máquina en cada nivel de caché:

Por qué vectorizar el bucle no tiene mejora de rendimiento

Mi código usa memset para escribir en un búfer (o búferes) una y otra vez y mide la velocidad. También guarda la dirección de cada búfer para imprimir al final. Aquí está el listado:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/time.h>

#define SIZE_KB {8, 16, 24, 28, 32, 36, 40, 48, 64, 128, 256, 384, 512, 768, 1024, 1025, 2048, 4096, 8192, 16384, 200000}
#define TESTMEM 10000000000 // Approximate, in bytes
#define BUFFERS 1

double timer(void)
{
    struct timeval ts;
    double ans;

    gettimeofday(&ts, NULL);
    ans = ts.tv_sec + ts.tv_usec*1.0e-6;

    return ans;
}

int main(int argc, char **argv)
{
    double *x[BUFFERS];
    double t1, t2;
    int kbsizes[] = SIZE_KB;
    double bandwidth[sizeof(kbsizes)/sizeof(int)];
    int iterations[sizeof(kbsizes)/sizeof(int)];
    double *address[sizeof(kbsizes)/sizeof(int)][BUFFERS];
    int i, j, k;

    for (k = 0; k < sizeof(kbsizes)/sizeof(int); k++)
        iterations[k] = TESTMEM/(kbsizes[k]*1024);

    for (k = 0; k < sizeof(kbsizes)/sizeof(int); k++)
    {
        // Allocate
        for (j = 0; j < BUFFERS; j++)
        {
            x[j] = (double *) malloc(kbsizes[k]*1024);
            address[k][j] = x[j];
            memset(x[j], 0, kbsizes[k]*1024);
        }

        // Measure
        t1 = timer();
        for (i = 0; i < iterations[k]; i++)
        {
            for (j = 0; j < BUFFERS; j++)
                memset(x[j], 0xff, kbsizes[k]*1024);
        }
        t2 = timer();
        bandwidth[k] = (BUFFERS*kbsizes[k]*iterations[k])/1024.0/1024.0/(t2-t1);

        // Free
        for (j = 0; j < BUFFERS; j++)
            free(x[j]);
    }

    printf("TESTMEM = %ld\n", TESTMEM);
    printf("BUFFERS = %d\n", BUFFERS);
    printf("Size (kB)\tBandwidth (GB/s)\tIterations\tAddresses\n");
    for (k = 0; k < sizeof(kbsizes)/sizeof(int); k++)
    {
        printf("%7d\t\t%.2f\t\t\t%d\t\t%x", kbsizes[k], bandwidth[k], iterations[k], address[k][0]);
        for (j = 1; j < BUFFERS; j++)
            printf(", %x", address[k][j]);
        printf("\n");
    }

    return 0;
}

Y los resultados (con TAMPONES = 1):

TESTMEM = 10000000000
BUFFERS = 1
Size (kB)   Bandwidth (GB/s)    Iterations  Addresses
      8     52.79               1220703     90b010
     16     56.48               610351      90b010
     24     57.01               406901      90b010
     28     57.13               348772      90b010
     32     45.40               305175      90b010
     36     38.11               271267      90b010
     40     38.02               244140      90b010
     48     38.12               203450      90b010
     64     37.51               152587      90b010
    128     36.89               76293       90b010
    256     35.58               38146       d760f010
    384     31.01               25431       d75ef010
    512     26.79               19073       d75cf010
    768     26.20               12715       d758f010
   1024     26.20               9536        d754f010
   1025     18.30               9527        90b010
   2048     18.29               4768        d744f010
   4096     18.29               2384        d724f010
   8192     18.31               1192        d6e4f010
  16384     18.31               596         d664f010
 200000     18.32               48          cb2ff010

Puedo ver fácilmente el efecto de la caché L1 de 32K y la caché L2 de 256K. Lo que no entiendo es por qué el rendimiento cae repentinamente después de que el tamaño del búfer de memset excede 1M. Se supone que mi caché L3 es de 8M. Sucede tan repentinamente también, no se reduce en absoluto como cuando se excedió el tamaño de la caché L1 y L2.

Mi procesador es el Intel i7 3700. Los detalles de la caché L3 de /sys/devices/system/cpu/cpu0/cache son:

level = 3
coherency_line_size = 64
number_of_sets = 8192
physical_line_partition = 1
shared_cpu_list = 0-7
shared_cpu_map = ff
size = 8192K
type = Unified
ways_of_associativity = 16

Pensé que intentaría usar múltiples buffers-llamar a memset en 2 buffers de 1M cada uno y ver si el rendimiento caería. Con BUFFERS = 2, obtengo:

TESTMEM = 10000000000
BUFFERS = 2
Size (kB)   Bandwidth (GB/s)    Iterations  Addresses
      8     54.15               1220703     e59010, e5b020
     16     51.52               610351      e59010, e5d020
     24     38.94               406901      e59010, e5f020
     28     38.53               348772      e59010, e60020
     32     38.31               305175      e59010, e61020
     36     38.29               271267      e59010, e62020
     40     38.29               244140      e59010, e63020
     48     37.46               203450      e59010, e65020
     64     36.93               152587      e59010, e69020
    128     35.67               76293       e59010, 63769010
    256     27.21               38146       63724010, 636e3010
    384     26.26               25431       63704010, 636a3010
    512     26.19               19073       636e4010, 63663010
    768     26.20               12715       636a4010, 635e3010
   1024     26.16               9536        63664010, 63563010
   1025     18.29               9527        e59010, f59420
   2048     18.23               4768        63564010, 63363010
   4096     18.27               2384        63364010, 62f63010
   8192     18.29               1192        62f64010, 62763010
  16384     18.31               596         62764010, 61763010
 200000     18.31               48          57414010, 4b0c3010

Parece que ambos buffers de 1M permanecen en la caché L3. Pero trate de aumentar el tamaño de cualquiera de los búfer muy ligeramente y el rendimiento disminuye.

He estado compilando con-O3. No hace mucha diferencia (excepto posiblemente desenrollar los bucles sobre BUFFERS). Probé con-O0 y es lo mismo excepto por las velocidades L1. la versión gcc es 4.9.1.

Para resumir, tengo una 2 parte pregunta:

  1. ¿Por qué mi caché L3 de 8 MB no proporciona ningún beneficio en bloques de memoria de más de 1 M?
  2. ¿Por qué la caída en el rendimiento es tan repentina?

EDITAR:

Como sugirió Gabriel Southern, corrí mi código con perf usando BUFFERS=1 con solo un tamaño de búfer a la vez. Este fue el mandato completo:

perf stat -e dTLB-loads,dTLB-load-misses,dTLB-stores,dTLB-store-misses -r 100 ./a.out 2> perfout.txt

El -r significa que perf ejecutará un. out 100 veces y devolverá el promedio estadísticas.

La salida de perf, con #define SIZE_KB {1024}:

 Performance counter stats for './a.out' (100 runs):

         1,508,798 dTLB-loads                                                    ( +-  0.02% )
                 0 dTLB-load-misses          #    0.00% of all dTLB cache hits 
       625,967,550 dTLB-stores                                                   ( +-  0.00% )
             1,503 dTLB-store-misses                                             ( +-  0.79% )

       0.360471583 seconds time elapsed                                          ( +-  0.79% )

Y con #define SIZE_KB {1025}:

 Performance counter stats for './a.out' (100 runs):

         1,670,402 dTLB-loads                                                    ( +-  0.09% )
                 0 dTLB-load-misses          #    0.00% of all dTLB cache hits 
       626,099,850 dTLB-stores                                                   ( +-  0.00% )
             2,115 dTLB-store-misses                                             ( +-  2.19% )

       0.503913416 seconds time elapsed                                          ( +-  0.06% )

Así que parece que hay más errores de TLB con el búfer de 1025K. Sin embargo, con este buffer de tamaño, el programa hace alrededor de 9500 llamadas de memset, por lo que todavía es menos de 1 miss por llamada memset.

Author: Community, 2015-05-19

3 answers

Respuesta corta:

Su versión de memset comienza a usar almacenes no temporales al inicializar una región de memoria mayor que 1 MB. Como resultado, la CPU no almacena estas líneas en su caché, a pesar de que su caché L3 es mayor que 1 MB. En consecuencia, el rendimiento está limitado por el ancho de banda de memoria disponible en el sistema para valores de búfer mayores de 1 MB.

Detalles:

Antecedentes:

Probé el código que proporcionaste en varios sistemas diferentes e inicialmente se centró en la investigación de la TLB porque pensé que podría haber una paliza en el nivel 2 TLB. Sin embargo, ninguno de los datos que recogí confirmó esa hipótesis.

Algunos de los sistemas que probé usaron Arch Linux que tiene la última versión de glibc, mientras que otros usaron Ubuntu 10.04 que usa una versión anterior de eglibc. Pude reproducir el comportamiento descrito en la pregunta al usar un binario vinculado estáticamente al probar con múltiples CPU diferentes arquitectura. El comportamiento en el que me centré fue una diferencia significativa en el tiempo de ejecución entre cuando SIZE_KB era 1024 y cuando era 1025. La diferencia de rendimiento se explica por un cambio en el código ejecutado para las versiones lenta y rápida.

Código de montaje

Usé perf record y perf annotate para recopilar un rastro del código ensamblador en ejecución para ver cuál era la ruta del código caliente. El código se muestra a continuación utilizando el siguiente formato:

percentage time executing instruction | address | instruction.

He copió el hot loop de la versión más corta que omite la mayor parte de la dirección y tiene una línea que conecta el borde posterior del bucle y el encabezado del bucle.

Para la versión compilada en Arch Linux el hot loop fue (para ambos tamaños 1024 y 1025):

  2.35 │a0:┌─+movdqa %xmm8,(%rcx)
 54.90 │   │  movdqa %xmm8,0x10(%rcx)
 32.85 │   │  movdqa %xmm8,0x20(%rcx)
  1.73 │   │  movdqa %xmm8,0x30(%rcx)
  8.11 │   │  add    $0x40,%rcx      
  0.03 │   │  cmp    %rcx,%rdx       
       │   └──jne    a0

Para el binario Ubuntu 10.04 el hot loop cuando se ejecuta con un tamaño de 1024 fue:

       │a00:┌─+lea    -0x80(%r8),%r8
  0.01 │    │  cmp    $0x80,%r8     
  5.33 │    │  movdqa %xmm0,(%rdi)  
  4.67 │    │  movdqa %xmm0,0x10(%rdi)
  6.69 │    │  movdqa %xmm0,0x20(%rdi)
 31.23 │    │  movdqa %xmm0,0x30(%rdi)
 18.35 │    │  movdqa %xmm0,0x40(%rdi)
  0.27 │    │  movdqa %xmm0,0x50(%rdi)
  3.24 │    │  movdqa %xmm0,0x60(%rdi)
 16.36 │    │  movdqa %xmm0,0x70(%rdi)
 13.76 │    │  lea    0x80(%rdi),%rdi 
       │    └──jge    a00    

Para la versión Ubuntu 10.04 que se ejecuta con un tamaño de búfer de 1025, el hot loop fue:

       │a60:┌─+lea    -0x80(%r8),%r8  
  0.15 │    │  cmp    $0x80,%r8       
  1.36 │    │  movntd %xmm0,(%rdi)    
  0.24 │    │  movntd %xmm0,0x10(%rdi)
  1.49 │    │  movntd %xmm0,0x20(%rdi)
 44.89 │    │  movntd %xmm0,0x30(%rdi)
  5.46 │    │  movntd %xmm0,0x40(%rdi)
  0.02 │    │  movntd %xmm0,0x50(%rdi)
  0.74 │    │  movntd %xmm0,0x60(%rdi)
 40.14 │    │  movntd %xmm0,0x70(%rdi)
  5.50 │    │  lea    0x80(%rdi),%rdi 
       │    └──jge    a60

La diferencia clave aquí es que la la versión más lenta usaba movntd instrucciones, mientras que las versiones más rápidas usaban movdqa instrucciones. El manual de Desarrolladores de software de Intel dice lo siguiente sobre las tiendas no temporales:

Para el tipo de memoria WC en particular, el procesador nunca parece leer los datos en la jerarquía de caché. En cambio, la pista no temporal puede se implementará cargando un búfer interno temporal con el equivalente a una línea de caché alineada sin llenar estos datos a la cache.

Así que esto parece explicar el comportamiento donde usar memset con valores mayores a 1 MB no encaja en la caché. La siguiente pregunta es por qué hay una diferencia entre el sistema Ubuntu 10.04 y el sistema Arch Linux, y por qué se selecciona 1 MB como punto de corte. Para investigar esa pregunta miré el código fuente de glibc:

Código Fuente para memset

Mirando el repositorio git de glibc en sysdeps/x86_64/memset.S el primer commit que encontré interesante fue b2b671b677d92429a3d41bf451668f476aa267ed

La descripción del commit es:

Memset más rápido en x64

Esta implementación acelera memset de varias maneras. Lo primero es evitar costoso salto computado. Segundo es el uso de hecho de que los argumentos de memset se alinean la mayor parte del tiempo a 8 bytes.

Resultados de Benchmark en: kam.mff.cuni.cz / ~ondra/benchmark_string / memset_profile_result27_04_13.tar.bz2

Y el sitio web referenciado tiene algunos datos de perfiles interesantes.

El diff de la confirmación muestra que el código para memset se simplifica mucho y se eliminan los almacenes no temporales. Esto coincide con lo que muestra el código perfilado de Arch Linux.

Mirando el código anterior Vi que la elección de si usar almacenes no temporales parecía hacer uso de un valor descrito como The largest cache size

L(byte32sse2_pre):

    mov    __x86_shared_cache_size(%rip),%r9d  # The largest cache size
    cmp    %r9,%r8
    ja     L(sse2_nt_move_pre)

El código para calcular esto está en: sysdeps/x86_64/cacheinfo.c

Aunque parece que hay código para calcular el tamaño real de la caché compartida, el valor predeterminado también es 1 MB :

long int __x86_64_shared_cache_size attribute_hidden = 1024 * 1024;

Así que sospecho que se está utilizando el valor predeterminado, pero puede haber alguna otra razón por la que el código esté seleccionando 1MB como punto de corte.

En cualquier caso, la respuesta general a su pregunta parece ser que la versión de memset en su sistema está utilizando almacenes no temporales cuando configuración de una región de memoria superior a 1 MB.

 15
Author: Gabriel Southern,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2015-05-20 16:36:47

Dado el desmontaje de Gabriel del código de ensamblado generado, creo que este es de hecho el problema [Editar: su respuesta fue editada, ahora aparece como la causa raíz, así que estamos de acuerdo]:

Tenga en cuenta que movnt es una tienda de streaming, que puede tener (dependiendo de la implementación micro-arquitectónica exacta) varios impactos:

  1. Tiene una semántica de ordenación débil (lo que le permite ser más rápido).
  2. Ha mejorado la latencia si sobrescribe una línea completa (no es necesario obtener datos anteriores y fusión).
  3. Tiene una pista no temporal, por lo que es incaqueable.

#1 y #2 pueden mejorar la latencia y el ancho de banda de estas operaciones si están vinculadas a la memoria, pero #3 básicamente las obliga a estar vinculadas a la memoria incluso si podrían caber en algún nivel de caché. Esto probablemente supera los beneficios, ya que la latencia de memoria/BW son significativamente peores para empezar.

Por lo tanto, su implementación de la biblioteca de memset probablemente esté usando un umbral incorrecto para cambiar a versión de streaming-stores (supongo que no se molesta en comprobar el tamaño de su LLC, pero suponiendo que 1M es residente de memoria es bastante extraño). Sugiero probar bibliotecas alternativas, o deshabilitar la capacidad del compilador para generarlas (si es compatible).

 3
Author: Leeor,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2015-07-28 05:48:22

Su benchmark solo está escribiendo en memoria, nunca leyendo, usando memset que probablemente esté inteligentemente diseñado para no leer nada de la caché en la memoria. Puede muy bien ser que con este código, donde solo se utiliza la mitad de la capacidad de la memoria caché, simplemente no hay ganancia de rendimiento en comparación con la memoria raw. El hecho de que escribir en memoria raw esté bastante cerca de la velocidad L2 puede ser una pista. Si L2 se ejecuta a 26 GB / seg, la memoria principal a 18 GB / seg, ¿qué puede esperar realmente para la caché L3?

Está midiendo el rendimiento, no la latencia. Intentaría un punto de referencia donde realmente se utiliza la fuerza de la caché L3, el suministro de datos con menor latencia que la memoria principal.

 0
Author: gnasher729,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2015-05-20 05:19:00