¿Por qué las adiciones por elementos son mucho más rápidas en ciclos separados que en un ciclo combinado?

Suponer a1, b1, c1y d1 apunte a la memoria del montón, y mi código numérico tiene el siguiente bucle central.

const int n = 100000;

for (int j = 0; j < n; j++) {
    a1[j] += b1[j];
    c1[j] += d1[j];
}

Este bucle se ejecuta 10,000 veces a través de otro exterior. for círculo. Para acelerarlo, cambié el código a:

for (int j = 0; j < n; j++) {
    a1[j] += b1[j];
}

for (int j = 0; j < n; j++) {
    c1[j] += d1[j];
}

Compilado en Microsoft Visual C ++ 10.0 con total optimización y SSE2 habilitado para 32 bits en un Intel Core 2 Duo (x64), el primer ejemplo tarda 5.5 segundos y el ejemplo de doble bucle tarda sólo 1.9 segundos.

El desmontaje del primer bucle se ve básicamente así (este bloque se repite unas cinco veces en el programa completo):

movsd       xmm0,mmword ptr [edx+18h]
addsd       xmm0,mmword ptr [ecx+20h]
movsd       mmword ptr [ecx+20h],xmm0
movsd       xmm0,mmword ptr [esi+10h]
addsd       xmm0,mmword ptr [eax+30h]
movsd       mmword ptr [eax+30h],xmm0
movsd       xmm0,mmword ptr [edx+20h]
addsd       xmm0,mmword ptr [ecx+28h]
movsd       mmword ptr [ecx+28h],xmm0
movsd       xmm0,mmword ptr [esi+18h]
addsd       xmm0,mmword ptr [eax+38h]

Cada ciclo del ejemplo de ciclo doble produce este código (el siguiente bloque se repite unas tres veces):

addsd       xmm0,mmword ptr [eax+28h]
movsd       mmword ptr [eax+28h],xmm0
movsd       xmm0,mmword ptr [ecx+20h]
addsd       xmm0,mmword ptr [eax+30h]
movsd       mmword ptr [eax+30h],xmm0
movsd       xmm0,mmword ptr [ecx+28h]
addsd       xmm0,mmword ptr [eax+38h]
movsd       mmword ptr [eax+38h],xmm0
movsd       xmm0,mmword ptr [ecx+30h]
addsd       xmm0,mmword ptr [eax+40h]
movsd       mmword ptr [eax+40h],xmm0

La pregunta resultó no tener relevancia, ya que el comportamiento depende en gran medida de los tamaños de las matrices (n) y la memoria caché de la CPU. Entonces, si hay más interés, reformulo la pregunta:

  • ¿Podría proporcionarnos una idea sólida de los detalles que conducen a los diferentes comportamientos de la caché, como se ilustra en las cinco regiones en el siguiente gráfico?

  • También podría ser interesante señalar las diferencias entre las arquitecturas de CPU / caché, proporcionando un gráfico similar para estas CPU.

Aquí está el código completo. Usa TBB Tick_Count para tiempos de mayor resolución, que se pueden desactivar si no se define el TBB_TIMING Macro:

#include <iostream>
#include <iomanip>
#include <cmath>
#include <string>

//#define TBB_TIMING

#ifdef TBB_TIMING   
#include <tbb/tick_count.h>
using tbb::tick_count;
#else
#include <time.h>
#endif

using namespace std;

//#define preallocate_memory new_cont

enum { new_cont, new_sep };

double *a1, *b1, *c1, *d1;


void allo(int cont, int n)
{
    switch(cont) {
      case new_cont:
        a1 = new double[n*4];
        b1 = a1 + n;
        c1 = b1 + n;
        d1 = c1 + n;
        break;
      case new_sep:
        a1 = new double[n];
        b1 = new double[n];
        c1 = new double[n];
        d1 = new double[n];
        break;
    }

    for (int i = 0; i < n; i++) {
        a1[i] = 1.0;
        d1[i] = 1.0;
        c1[i] = 1.0;
        b1[i] = 1.0;
    }
}

void ff(int cont)
{
    switch(cont){
      case new_sep:
        delete[] b1;
        delete[] c1;
        delete[] d1;
      case new_cont:
        delete[] a1;
    }
}

double plain(int n, int m, int cont, int loops)
{
#ifndef preallocate_memory
    allo(cont,n);
#endif

#ifdef TBB_TIMING   
    tick_count t0 = tick_count::now();
#else
    clock_t start = clock();
#endif
        
    if (loops == 1) {
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++){
                a1[j] += b1[j];
                c1[j] += d1[j];
            }
        }
    } else {
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                a1[j] += b1[j];
            }
            for (int j = 0; j < n; j++) {
                c1[j] += d1[j];
            }
        }
    }
    double ret;

#ifdef TBB_TIMING   
    tick_count t1 = tick_count::now();
    ret = 2.0*double(n)*double(m)/(t1-t0).seconds();
#else
    clock_t end = clock();
    ret = 2.0*double(n)*double(m)/(double)(end - start) *double(CLOCKS_PER_SEC);
#endif
    
#ifndef preallocate_memory
    ff(cont);
#endif

    return ret;
}


void main()
{   
    freopen("C:\\test.csv", "w", stdout);

    char *s = " ";

    string na[2] ={"new_cont", "new_sep"};

    cout << "n";

    for (int j = 0; j < 2; j++)
        for (int i = 1; i <= 2; i++)
#ifdef preallocate_memory
            cout << s << i << "_loops_" << na[preallocate_memory];
#else
            cout << s << i << "_loops_" << na[j];
#endif
            
    cout << endl;

    long long nmax = 1000000;

#ifdef preallocate_memory
    allo(preallocate_memory, nmax);
#endif
    
    for (long long n = 1L; n < nmax; n = max(n+1, long long(n*1.2)))
    {
        const long long m = 10000000/n;
        cout << n;

        for (int j = 0; j < 2; j++)
            for (int i = 1; i <= 2; i++)
                cout << s << plain(n, m, j, i);
        cout << endl;
    }
}

Muestra FLOP / s para diferentes valores de n.

Tabla de rendimiento

preguntado el 17 de diciembre de 11 a las 16:12

Podría ser el sistema operativo el que se ralentiza mientras busca en la memoria física cada vez que accede a ella y tiene algo así como caché en caso de acceso secundario al mismo memblock. -

¿Está compilando con optimizaciones? Eso parece mucho código ASM para O2 ... -

Pregunté qué parece ser un pregunta similar hace tiempo. Ésta o las respuestas pueden tener información de interés. -

Para ser quisquilloso, estos dos fragmentos de código no son equivalentes debido a la posibilidad de que los punteros se superpongan. C99 tiene el restrict palabra clave para tales situaciones. No sé si MSVC tiene algo similar. Por supuesto, si este fuera el problema, el código SSE no sería correcto. -

Esto puede tener algo que ver con el alias de memoria. Con un bucle, d1[j] puede aliase con a1[j], por lo que el compilador puede retractarse de realizar algunas optimizaciones de memoria. Si bien eso no sucede si separa las escrituras en la memoria en dos bucles. -

10 Respuestas

Tras un análisis más detallado de esto, creo que esto es (al menos parcialmente) causado por la alineación de datos de los cuatro puntos. Esto provocará algún nivel de conflictos entre bancos y vías de caché.

Si he adivinado correctamente cómo está asignando sus matrices, es probable que estén alineados con la línea de la página.

Esto significa que todos sus accesos en cada bucle caerán en la misma forma de caché. Sin embargo, los procesadores Intel han tenido asociatividad de caché L8 de 1 vías durante un tiempo. Pero en realidad, el rendimiento no es completamente uniforme. El acceso a 4 vías es aún más lento que, digamos, a 2 vías.

EDITAR: De hecho, parece que está asignando todas las matrices por separado. Por lo general, cuando se solicitan asignaciones tan grandes, el asignador solicitará páginas nuevas del sistema operativo. Por lo tanto, existe una alta probabilidad de que aparezcan grandes asignaciones en el mismo desplazamiento desde un límite de página.

Aquí está el código de prueba:

int main(){
    const int n = 100000;

#ifdef ALLOCATE_SEPERATE
    double *a1 = (double*)malloc(n * sizeof(double));
    double *b1 = (double*)malloc(n * sizeof(double));
    double *c1 = (double*)malloc(n * sizeof(double));
    double *d1 = (double*)malloc(n * sizeof(double));
#else
    double *a1 = (double*)malloc(n * sizeof(double) * 4);
    double *b1 = a1 + n;
    double *c1 = b1 + n;
    double *d1 = c1 + n;
#endif

    //  Zero the data to prevent any chance of denormals.
    memset(a1,0,n * sizeof(double));
    memset(b1,0,n * sizeof(double));
    memset(c1,0,n * sizeof(double));
    memset(d1,0,n * sizeof(double));

    //  Print the addresses
    cout << a1 << endl;
    cout << b1 << endl;
    cout << c1 << endl;
    cout << d1 << endl;

    clock_t start = clock();

    int c = 0;
    while (c++ < 10000){

#if ONE_LOOP
        for(int j=0;j<n;j++){
            a1[j] += b1[j];
            c1[j] += d1[j];
        }
#else
        for(int j=0;j<n;j++){
            a1[j] += b1[j];
        }
        for(int j=0;j<n;j++){
            c1[j] += d1[j];
        }
#endif

    }

    clock_t end = clock();
    cout << "seconds = " << (double)(end - start) / CLOCKS_PER_SEC << endl;

    system("pause");
    return 0;
}

Resultados de referencia:

EDITAR: Resultados en un real Máquina de arquitectura Core 2:

2 x Intel Xeon X5482 Harpertown a 3.2 GHz:

#define ALLOCATE_SEPERATE
#define ONE_LOOP
00600020
006D0020
007A0020
00870020
seconds = 6.206

#define ALLOCATE_SEPERATE
//#define ONE_LOOP
005E0020
006B0020
00780020
00850020
seconds = 2.116

//#define ALLOCATE_SEPERATE
#define ONE_LOOP
00570020
00633520
006F6A20
007B9F20
seconds = 1.894

//#define ALLOCATE_SEPERATE
//#define ONE_LOOP
008C0020
00983520
00A46A20
00B09F20
seconds = 1.993

Observaciones:

  • 6.206 segundos con un bucle y 2.116 segundos con dos bucles. Esto reproduce exactamente los resultados del OP.

  • En las dos primeras pruebas, las matrices se asignan por separado. Notarás que todos tienen la misma alineación en relación con la página.

  • En las dos segundas pruebas, las matrices se empaquetan juntas para romper esa alineación. Aquí notará que ambos bucles son más rápidos. Además, el segundo ciclo (doble) es ahora el más lento, como cabría esperar normalmente.

Como @Stephen Cannon señala en los comentarios, existe una posibilidad muy probable de que esta alineación cause falso alias en las unidades de carga / almacenamiento o en la caché. Busqué en Google para encontrar esto y descubrí que Intel en realidad tiene un contador de hardware para alias de dirección parcial establos:

http://software.intel.com/sites/products/documentation/doclib/stdxe/2013/~amplifierxe/pmw_dp/events/partial_address_alias.html


5 Regiones - Explicaciones

Región 1:

Éste es fácil. El conjunto de datos es tan pequeño que el rendimiento está dominado por gastos generales como bucles y ramificaciones.

Región 2:

Aquí, a medida que aumenta el tamaño de los datos, la cantidad de sobrecarga relativa disminuye y el rendimiento se "satura". Aquí dos bucles son más lentos porque tienen el doble de bucle y ramificación de sobrecarga.

No estoy seguro de qué está pasando aquí exactamente ... La alineación aún podría tener un efecto, como menciona Agner Fog. conflictos del banco de caché. (Ese enlace es sobre Sandy Bridge, pero la idea aún debería ser aplicable al Core 2.)

Región 3:

En este punto, los datos ya no caben en la caché L1. Por lo tanto, el rendimiento está limitado por el ancho de banda de la caché L1 <-> L2.

Región 4:

La caída del rendimiento en el ciclo único es lo que estamos observando. Y como se mencionó, esto se debe a la alineación que (muy probablemente) causa falso alias se atasca en las unidades de carga / almacenamiento del procesador.

Sin embargo, para que se produzca el alias falso, debe haber un paso lo suficientemente grande entre los conjuntos de datos. Es por eso que no ve esto en la región 3.

Región 5:

En este punto, nada cabe en el caché. Así que estás limitado por el ancho de banda de la memoria.


2 x Intel X5482 Harpertown a 3.2 GHz Intel Core i7 870 a 2.8 GHz Intel Core i7 2600K a 4.4 GHz

Respondido el 04 de diciembre de 20 a las 15:12

+1: Creo que esta es la respuesta. Al contrario de lo que dicen todas las demás respuestas, no se trata de que la variante de bucle único tenga inherentemente más fallas de caché, se trata de la alineación particular de las matrices que causan las fallas de caché. - Oliver Charlesworth

Esta; a falso alias el estancamiento es la explicación más probable. - Esteban Canon

Bien, la respuesta correcta definitivamente tiene que ver con el caché de la CPU. Pero usar el argumento de la caché puede ser bastante difícil, especialmente sin datos.

Hay muchas respuestas que llevaron a mucha discusión, pero seamos sinceros: los problemas de caché pueden ser muy complejos y no son unidimensionales. Dependen en gran medida del tamaño de los datos, por lo que mi pregunta fue injusta: resultó estar en un punto muy interesante en el gráfico de caché.

La respuesta de @ Mysticial convenció a mucha gente (incluyéndome a mí), probablemente porque era la única que parecía basarse en hechos, pero era sólo un "punto de datos" de la verdad.

Es por eso que combiné su prueba (usando una asignación continua frente a una asignación separada) y el consejo de @James 'Answer.

Los gráficos a continuación muestran que la mayoría de las respuestas y especialmente la mayoría de los comentarios a la pregunta y las respuestas pueden considerarse completamente incorrectos o verdaderos según el escenario exacto y los parámetros utilizados.

Tenga en cuenta que mi pregunta inicial fue en n = 100.000. Este punto (por accidente) exhibe un comportamiento especial:

  1. Posee la mayor discrepancia entre la versión de uno y dos bucles (casi un factor de tres)

  2. Es el único punto en el que un bucle (es decir, con asignación continua) supera a la versión de dos bucles. (Esto hizo posible la respuesta de Mysticial, en absoluto).

El resultado usando datos inicializados:

Ingrese la descripción de la imagen aquí

El resultado usando datos no inicializados (esto es lo que probó Mysticial):

Ingrese la descripción de la imagen aquí

Y este es difícil de explicar: datos inicializados, que se asignan una vez y se reutilizan para cada caso de prueba siguiente de diferente tamaño de vector:

Ingrese la descripción de la imagen aquí

Propuesta

¡Todas las preguntas relacionadas con el rendimiento de bajo nivel en Stack Overflow deben ser necesarias para proporcionar información MFLOPS para toda la gama de tamaños de datos relevantes de caché! Es una pérdida del tiempo de todos pensar en las respuestas y, especialmente, discutirlas con otros sin esta información.

Respondido el 22 de Septiembre de 16 a las 18:09

+1 Buen análisis. En primer lugar, no tenía la intención de dejar los datos sin inicializar. Simplemente sucedió que el asignador los puso a cero de todos modos. Entonces, los datos inicializados son lo que importa. Acabo de editar mi respuesta con resultados en un real Máquina de arquitectura Core 2 y están mucho más cerca de lo que está observando. Otra cosa es que probé una variedad de tamaños. n y muestra la misma brecha de rendimiento para n = 80000, n = 100000, n = 200000, etc ... - Mística

El segundo ciclo implica mucha menos actividad de caché, por lo que es más fácil para el procesador mantenerse al día con las demandas de memoria.

Respondido el 17 de diciembre de 11 a las 20:12

Imagina que estás trabajando en una máquina donde n era el valor correcto para que solo fuera posible mantener dos de sus matrices en la memoria a la vez, pero la memoria total disponible, a través del almacenamiento en caché del disco, aún era suficiente para almacenar las cuatro.

Suponiendo una política de almacenamiento en caché LIFO simple, este código:

for(int j=0;j<n;j++){
    a[j] += b[j];
}
for(int j=0;j<n;j++){
    c[j] += d[j];
}

primero causaría a y b para cargarse en la RAM y luego trabajarse completamente en la RAM. Cuando comienza el segundo bucle, c y d luego se cargaría desde el disco a la RAM y se operaría.

el otro bucle

for(int j=0;j<n;j++){
    a[j] += b[j];
    c[j] += d[j];
}

Paginará dos matrices y paginará las otras dos cada vez alrededor del bucle. Esto obviamente sería mucho más más lento.

Probablemente no vea el almacenamiento en caché de disco en sus pruebas, pero probablemente vea los efectos secundarios de alguna otra forma de almacenamiento en caché.


Parece haber un poco de confusión / malentendido aquí, así que intentaré elaborar un poco usando un ejemplo.

clientes n = 2 y estamos trabajando con bytes. En mi escenario tenemos pues solo 4 bytes de RAM y el resto de nuestra memoria es significativamente más lento (digamos que el acceso es 100 veces más largo).

Suponiendo una política de almacenamiento en caché bastante tonta de si el byte no está en la caché, colóquelo allí y obtenga el siguiente byte también mientras estamos en él obtendrá un escenario similar a este:

  • Con

    for(int j=0;j<n;j++){
     a[j] += b[j];
    }
    for(int j=0;j<n;j++){
     c[j] += d[j];
    }
    
  • cache a[0] y a[1] luego b[0] y b[1] y establecer a[0] = a[0] + b[0] en caché: ahora hay cuatro bytes en caché, a[0], a[1] y b[0], b[1]. Costo = 100 + 100.

  • planificadas a[1] = a[1] + b[1] en caché. Costo = 1 + 1.
  • Repetir para c y d.
  • Costo total = (100 + 100 + 1 + 1) * 2 = 404

  • Con

    for(int j=0;j<n;j++){
     a[j] += b[j];
     c[j] += d[j];
    }
    
  • cache a[0] y a[1] luego b[0] y b[1] y establecer a[0] = a[0] + b[0] en caché: ahora hay cuatro bytes en caché, a[0], a[1] y b[0], b[1]. Costo = 100 + 100.

  • eyector a[0], a[1], b[0], b[1] desde caché y caché c[0] y c[1] luego d[0] y d[1] y establecer c[0] = c[0] + d[0] en caché. Costo = 100 + 100.
  • Sospecho que está empezando a ver a dónde voy.
  • Costo total = (100 + 100 + 100 + 100) * 2 = 800

Este es un escenario clásico de thrash de caché.

Respondido 03 Oct 18, 12:10

Esto es incorrecto. Una referencia a un elemento particular de una matriz no hace que la matriz completa se paguee desde el disco (o desde la memoria no almacenada en caché); solo se pagina la página relevante o la línea de caché. - Brooks Moisés

No es por un código diferente, sino por el almacenamiento en caché: la RAM es más lenta que los registros de la CPU y hay una memoria caché dentro de la CPU para evitar escribir la RAM cada vez que cambia una variable. Pero la caché no es tan grande como la RAM, por lo tanto, mapea solo una fracción de ella.

El primer código modifica las direcciones de memoria distantes alternándolas en cada bucle, lo que requiere continuamente invalidar la caché.

El segundo código no se alterna: simplemente fluye en direcciones adyacentes dos veces. Esto hace que todo el trabajo se complete en la caché, invalidándolo solo después de que comience el segundo ciclo.

Respondido el 17 de diciembre de 11 a las 20:12

No puedo replicar los resultados discutidos aquí.

No sé si el código de referencia deficiente es el culpable, o qué, pero los dos métodos están dentro del 10% entre sí en mi máquina usando el siguiente código, y un bucle suele ser un poco más rápido que dos, como lo haría suponer.

Los tamaños de las matrices variaron de 2 ^ 16 a 2 ^ 24, utilizando ocho bucles. Tuve cuidado de inicializar las matrices de origen para que el += la tarea no estaba pidiendo al FPU para agregar basura de memoria interpretada como un doble.

Jugué con varios esquemas, como poner la asignación de b[j], d[j] a InitToZero[j] dentro de los bucles, y también con el uso += b[j] = 1 y += d[j] = 1y obtuve resultados bastante consistentes.

Como era de esperar, inicializar b y d dentro del bucle usando InitToZero[j] le dio al enfoque combinado una ventaja, ya que se hicieron consecutivamente antes de las asignaciones a a y c, pero todavía dentro del 10%. Imagínate.

El hardware es Dell XPS 8500 con la generación 3 Core i7 @ 3.4 GHz y 8 GB de memoria. Para 2 ^ 16 a 2 ^ 24, utilizando ocho bucles, el tiempo acumulado fue 44.987 y 40.965 respectivamente. Visual C ++ 2010, totalmente optimizado.

PD: cambié los bucles para que la cuenta regresiva a cero, y el método combinado fue un poco más rápido. Rascándome la cabeza. Tenga en cuenta el nuevo tamaño de la matriz y los recuentos de bucles.

// MemBufferMystery.cpp : Defines the entry point for the console application.
//
#include "stdafx.h"
#include <iostream>
#include <cmath>
#include <string>
#include <time.h>

#define  dbl    double
#define  MAX_ARRAY_SZ    262145    //16777216    // AKA (2^24)
#define  STEP_SZ           1024    //   65536    // AKA (2^16)

int _tmain(int argc, _TCHAR* argv[]) {
    long i, j, ArraySz = 0,  LoopKnt = 1024;
    time_t start, Cumulative_Combined = 0, Cumulative_Separate = 0;
    dbl *a = NULL, *b = NULL, *c = NULL, *d = NULL, *InitToOnes = NULL;

    a = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    b = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    c = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    d = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    InitToOnes = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    // Initialize array to 1.0 second.
    for(j = 0; j< MAX_ARRAY_SZ; j++) {
        InitToOnes[j] = 1.0;
    }

    // Increase size of arrays and time
    for(ArraySz = STEP_SZ; ArraySz<MAX_ARRAY_SZ; ArraySz += STEP_SZ) {
        a = (dbl *)realloc(a, ArraySz * sizeof(dbl));
        b = (dbl *)realloc(b, ArraySz * sizeof(dbl));
        c = (dbl *)realloc(c, ArraySz * sizeof(dbl));
        d = (dbl *)realloc(d, ArraySz * sizeof(dbl));
        // Outside the timing loop, initialize
        // b and d arrays to 1.0 sec for consistent += performance.
        memcpy((void *)b, (void *)InitToOnes, ArraySz * sizeof(dbl));
        memcpy((void *)d, (void *)InitToOnes, ArraySz * sizeof(dbl));

        start = clock();
        for(i = LoopKnt; i; i--) {
            for(j = ArraySz; j; j--) {
                a[j] += b[j];
                c[j] += d[j];
            }
        }
        Cumulative_Combined += (clock()-start);
        printf("\n %6i miliseconds for combined array sizes %i and %i loops",
                (int)(clock()-start), ArraySz, LoopKnt);
        start = clock();
        for(i = LoopKnt; i; i--) {
            for(j = ArraySz; j; j--) {
                a[j] += b[j];
            }
            for(j = ArraySz; j; j--) {
                c[j] += d[j];
            }
        }
        Cumulative_Separate += (clock()-start);
        printf("\n %6i miliseconds for separate array sizes %i and %i loops \n",
                (int)(clock()-start), ArraySz, LoopKnt);
    }
    printf("\n Cumulative combined array processing took %10.3f seconds",
            (dbl)(Cumulative_Combined/(dbl)CLOCKS_PER_SEC));
    printf("\n Cumulative seperate array processing took %10.3f seconds",
        (dbl)(Cumulative_Separate/(dbl)CLOCKS_PER_SEC));
    getchar();

    free(a); free(b); free(c); free(d); free(InitToOnes);
    return 0;
}

No estoy seguro de por qué se decidió que MFLOPS era una métrica relevante. Pensé que la idea era centrarme en los accesos a la memoria, así que traté de minimizar la cantidad de tiempo de cálculo de coma flotante. Me fui en el +=, pero no estoy seguro de por qué.

Una asignación directa sin cálculo sería una prueba más limpia del tiempo de acceso a la memoria y crearía una prueba que es uniforme independientemente del recuento de bucles. Tal vez me perdí algo en la conversación, pero vale la pena pensarlo dos veces. Si el más se deja fuera de la asignación, el tiempo acumulado es casi idéntico a 31 segundos cada uno.

Respondido el 16 de diciembre de 17 a las 01:12

Es porque la CPU no tiene tantos fallos de caché (donde tiene que esperar a que los datos de la matriz provengan de los chips de RAM). Sería interesante para usted ajustar el tamaño de las matrices continuamente para que exceda los tamaños de la caché de nivel 1 (L1), y luego el caché de nivel 2 (L2), de su CPU y grafica el tiempo que tarda su código en ejecutarse contra los tamaños de las matrices. El gráfico no debe ser una línea recta como cabría esperar.

Respondido 16 Feb 13, 09:02

El primer ciclo alterna la escritura en cada variable. El segundo y el tercero solo hacen pequeños saltos de tamaño de elemento.

Intente escribir dos líneas paralelas de 20 cruces con un bolígrafo y un papel separados por 20 cm. Intente terminar una vez y luego la otra línea e intente otra vez escribiendo una cruz en cada línea alternativamente.

Respondido 16 Feb 13, 09:02

La pregunta original

¿Por qué un bucle es mucho más lento que dos bucles?


Conclusión:

Caso 1 es un problema de interpolación clásico que resulta ser ineficaz. También creo que esta fue una de las principales razones por las que muchas arquitecturas de máquinas y desarrolladores terminaron construyendo y diseñando sistemas de múltiples núcleos con la capacidad de realizar aplicaciones de múltiples subprocesos y programación paralela.

Mirándolo desde este tipo de enfoque sin involucrar cómo el hardware, SO y compilador (es) trabajar juntos para realizar asignaciones de montón que implican trabajar con RAM, caché, archivos de página, etc .; las matemáticas que son la base de estos algoritmos nos muestran cuál de estos dos es la mejor solución.

Podemos usar una analogía de un Boss siendo una Summation que representará un For Loop que tiene que viajar entre trabajadores A & B.

Podemos ver fácilmente que Caso 2 es al menos la mitad de rápido, si no un poco más que Caso 1 debido a la diferencia en la distancia que se necesita recorrer y el tiempo entre los trabajadores. Esta matemática se alinea casi virtualmente y perfectamente tanto con el tiempos de referencia así como el número de diferencias en Instrucciones de montaje.


Ahora comenzaré a explicar cómo funciona todo esto a continuación.


Evaluar el problema

El código del OP:

const int n=100000;

for(int j=0;j<n;j++){
    a1[j] += b1[j];
    c1[j] += d1[j];
}

Y también debes

for(int j=0;j<n;j++){
    a1[j] += b1[j];
}
for(int j=0;j<n;j++){
    c1[j] += d1[j];
}

La consideración

Teniendo en cuenta la pregunta original del OP sobre las dos variantes del for bucles y su pregunta modificada sobre el comportamiento de los cachés junto con muchas de las otras excelentes respuestas y comentarios útiles; Me gustaría intentar hacer algo diferente aquí adoptando un enfoque diferente sobre esta situación y problema.


El enfoque

Teniendo en cuenta los dos bucles y toda la discusión sobre el archivo de la caché y la página, me gustaría adoptar otro enfoque para ver esto desde una perspectiva diferente. Uno que no involucra el caché y los archivos de paginación ni las ejecuciones para asignar memoria, de hecho, este enfoque ni siquiera concierne al hardware o software real en absoluto.


La perspectiva

Después de mirar el código por un tiempo, se volvió bastante evidente cuál es el problema y qué lo está generando. Dividamos esto en un problema algorítmico y veámoslo desde la perspectiva de usar notaciones matemáticas y luego apliquemos una analogía a los problemas matemáticos, así como a los algoritmos.


Lo que sabemos

Lo que sabemos es que este ciclo se ejecutará 100,000 veces. También sabemos que a1, b1, c1 & d1 son punteros en una arquitectura de 64 bits. En C ++ en una máquina de 32 bits, todos los punteros tienen 4 bytes y en una máquina de 64 bits, tienen un tamaño de 8 bytes, ya que los punteros tienen una longitud fija.

Sabemos que tenemos 32 bytes para asignar en ambos casos. La única diferencia es que estamos asignando 32 bytes o dos conjuntos de 2-8 bytes en cada iteración, en el segundo caso estamos asignando 16 bytes para cada iteración para ambos bucles independientes.

Ambos bucles todavía equivalen a 32 bytes en asignaciones totales. Con esta información, sigamos ahora y mostremos la matemática general, los algoritmos y la analogía de estos conceptos.

Sí sabemos el número de veces que se tendrá que realizar el mismo conjunto o grupo de operaciones en ambos casos. Sabemos la cantidad de memoria que debe asignarse en ambos casos. Podemos evaluar que la carga de trabajo general de las asignaciones entre ambos casos será aproximadamente la misma.


Lo que no sabemos

No sabemos cuánto tiempo tomará para cada caso, a menos que establezcamos un contador y ejecutemos una prueba de referencia. Sin embargo, los puntos de referencia ya se incluyeron de la pregunta original y también de algunas de las respuestas y comentarios; y podemos ver una diferencia significativa entre los dos y este es todo el razonamiento de esta propuesta a este problema.


Investiguemos

Ya es evidente que muchos ya lo han hecho al observar las asignaciones de montón, las pruebas comparativas, la RAM, la caché y los archivos de paginación. También se incluyeron puntos de datos específicos e índices de iteración específicos y las diversas conversaciones sobre este problema específico hacen que muchas personas comiencen a cuestionar otras cosas relacionadas al respecto. ¿Cómo empezamos a analizar este problema utilizando algoritmos matemáticos y aplicándole una analogía? ¡Empezamos haciendo un par de afirmaciones! Luego, construimos nuestro algoritmo a partir de ahí.


Nuestras afirmaciones:

  • Dejaremos que nuestro bucle y sus iteraciones sean una suma que comienza en 1 y termina en 100000 en lugar de comenzar con 0 como en los bucles, ya que no tenemos que preocuparnos por el esquema de indexación 0 del direccionamiento de memoria, ya que solo estamos interesados ​​en el algoritmo en sí.
  • En ambos casos tenemos cuatro funciones con las que trabajar y dos llamadas de función con dos operaciones que se realizan en cada llamada de función. Los configuraremos como funciones y llamadas a funciones de la siguiente manera: F1(), F2(), f(a), f(b), f(c) y f(d).

Los algoritmos:

1er caso: - Solo una suma pero dos llamadas de función independientes.

Sum n=1 : [1,100000] = F1(), F2();
                       F1() = { f(a) = f(a) + f(b); }
                       F2() = { f(c) = f(c) + f(d); }

2do caso: - Dos sumas pero cada una tiene su propia función llamada.

Sum1 n=1 : [1,100000] = F1();
                        F1() = { f(a) = f(a) + f(b); }

Sum2 n=1 : [1,100000] = F1();
                        F1() = { f(c) = f(c) + f(d); }

Si te diste cuenta F2() solo existe en Sum del Case1 dónde F1() está contenido en Sum del Case1 y en ambos Sum1 y Sum2 del Case2. Esto será evidente más adelante cuando comencemos a concluir que hay una optimización que está sucediendo dentro del segundo algoritmo.

Las iteraciones hasta el primer caso Sum llamadas f(a) que se sumará a sí mismo f(b) entonces llama f(c) que hará lo mismo pero agregará f(d) a sí mismo para cada 100000 iteraciones. En el segundo caso, tenemos Sum1 y Sum2 que ambos actúan de la misma manera como si se llamara a la misma función dos veces seguidas.

En este caso podemos tratar Sum1 y Sum2 como simplemente viejo Sum dónde Sum en este caso se ve así: Sum n=1 : [1,100000] { f(a) = f(a) + f(b); } y ahora esto parece una optimización en la que podemos considerar que es la misma función.


Resumen con analogía

Con lo que hemos visto en el segundo caso, casi parece como si hubiera optimización ya que ambos bucles for tienen la misma firma exacta, pero este no es el problema real. El problema no es el trabajo que está realizando f(a), f(b), f(c)y f(d). En ambos casos y la comparación entre los dos, es la diferencia en la distancia que tiene que recorrer el Summation en cada caso lo que te da la diferencia en el tiempo de ejecución.

Pensar en for bucles como siendo el sumas que hace las iteraciones como un Boss que esta dando ordenes a dos personas A & B y que sus trabajos son a la carne C & D respectivamente y recoger algún paquete de ellos y devolverlo. En esta analogía, el por bucles o iteraciones de suma y verificaciones de condición en sí mismas no representan realmente el Boss. ¿Qué representa realmente el Boss no proviene directamente de los algoritmos matemáticos reales, sino del concepto real de Scope y Code Block dentro de una rutina o subrutina, método, función, unidad de traducción, etc. El primer algoritmo tiene un alcance donde el segundo algoritmo tiene dos alcances consecutivos.

En el primer caso de cada boleto de llamada, el Boss va a A y da la orden y A va a buscar B's paquete entonces el Boss va a C y da las órdenes para hacer lo mismo y recibir el paquete de D en cada iteración.

Dentro del segundo caso, el Boss trabaja directamente con A ir a buscar B's paquete hasta que se reciban todos los paquetes. Entonces el Boss con obras C hacer lo mismo para obtener todos D's paquetes

Ya que estamos trabajando con un puntero de 8 bytes y lidiando con la asignación del montón, consideremos el siguiente problema. Digamos que el Boss está a 100 pies de A y que A está a 500 pies de C. No tenemos que preocuparnos por la distancia Boss es inicialmente de C por el orden de las ejecuciones. En ambos casos, el Boss inicialmente viaja desde A primero luego a B. Esta analogía no quiere decir que esta distancia sea exacta; es solo un escenario de caso de prueba útil para mostrar el funcionamiento de los algoritmos.

En muchos casos, al realizar asignaciones de montón y trabajar con la caché y los archivos de página, estas distancias entre las ubicaciones de las direcciones pueden no variar tanto o pueden variar significativamente según la naturaleza de los tipos de datos y los tamaños de la matriz.


Los casos de prueba:

Primer caso: En la primera iteración el Boss tiene que ir inicialmente 100 pies para dar la orden de deslizamiento a A y A se va y hace lo suyo, pero luego el Boss tiene que viajar 500 pies para C para darle su orden. Luego, en la siguiente iteración y cada otra iteración después de la Boss tiene que ir y venir 500 pies entre los dos.

Segundo caso: La Boss tiene que viajar 100 pies en la primera iteración para A, pero después de eso, él ya está allí y solo espera A volver hasta que se llenen todos los resbalones. Entonces el Boss tiene que viajar 500 pies en la primera iteración para C porque C está a 500 pies de A. Desde esto Boss( Summation, For Loop ) se llama justo después de trabajar con A luego simplemente espera allí como lo hizo con A hasta que todo C's los recibos de pedidos están hechos.


La diferencia en las distancias recorridas

const n = 100000
distTraveledOfFirst = (100 + 500) + ((n-1)*(500 + 500);
// Simplify
distTraveledOfFirst = 600 + (99999*100);
distTraveledOfFirst = 600 + 9999900;
distTraveledOfFirst =  10000500;
// Distance Traveled On First Algorithm = 10,000,500ft

distTraveledOfSecond = 100 + 500 = 600;
// Distance Traveled On Second Algorithm = 600ft;

La comparación de valores arbitrarios

Podemos ver fácilmente que 600 es mucho menos que 10 millones. Ahora, esto no es exacto, porque no sabemos la diferencia real en la distancia entre qué dirección de RAM o desde qué caché o archivo de página se debe cada llamada en cada iteración a muchas otras variables invisibles. Esta es solo una evaluación de la situación para ser consciente y mirarla desde el peor de los casos.

A partir de estos números, casi parecería como si algoritmo uno debiera ser 99% más lento que algoritmo dos; sin embargo, esta es solo la Boss's parte o responsabilidad de los algoritmos y no tiene en cuenta a los trabajadores reales A, B, CY D y lo que tienen que hacer en todas y cada una de las iteraciones del Loop. Entonces, el trabajo del jefe solo representa entre el 15 y el 40% del trabajo total que se realiza. La mayor parte del trabajo que se realiza a través de los trabajadores tiene un impacto ligeramente mayor para mantener la relación de las diferencias de velocidad en aproximadamente el 50-70%.


La observación: - Las diferencias entre los dos algoritmos

En esta situación, es la estructura del proceso del trabajo que se está realizando. Va a demostrar que Caso 2 es más eficiente tanto por la optimización parcial de tener una declaración y definición de función similar donde solo las variables difieren por el nombre y la distancia recorrida.

También vemos que la distancia total recorrida en Caso 1 está mucho más lejos de lo que está en Caso 2 y podemos considerar esta distancia recorrida nuestra Factor de tiempo entre los dos algoritmos. Caso 1 tiene mucho más trabajo por hacer que Caso 2 hace.

Esto es observable a partir de la evidencia de las instrucciones de montaje que se mostraron en ambos casos. Junto con lo que ya se dijo sobre estos casos, esto no tiene en cuenta el hecho de que en Caso 1 el jefe tendrá que esperar tanto A & C para volver antes de que pueda volver a A de nuevo para cada iteración. Tampoco tiene en cuenta el hecho de que si A or B está tardando mucho tiempo, entonces tanto el Boss y los otros trabajadores están inactivos esperando ser ejecutados.

In Caso 2 el único que está inactivo es el Boss hasta que el trabajador regrese. Entonces, incluso esto tiene un impacto en el algoritmo.



Pregunta (s) modificada (s) del OP

EDITAR: La pregunta resultó no tener relevancia, ya que el comportamiento depende en gran medida de los tamaños de las matrices (n) y la memoria caché de la CPU. Entonces, si hay más interés, reformulo la pregunta:

¿Podría proporcionarnos una idea sólida de los detalles que conducen a los diferentes comportamientos de la caché, como se ilustra en las cinco regiones en el siguiente gráfico?

También podría ser interesante señalar las diferencias entre las arquitecturas de CPU / caché, proporcionando un gráfico similar para estas CPU.


Respecto a estas preguntas

Como he demostrado sin lugar a dudas, existe un problema subyacente incluso antes de que el hardware y el software se involucren.

Ahora en cuanto a la gestión de la memoria y el almacenamiento en caché junto con los archivos de página, etc., que funcionan todos juntos en un conjunto integrado de sistemas entre los siguientes:

  • La arquitectura (hardware, firmware, algunos controladores integrados, kernels y conjuntos de instrucciones de ensamblaje).
  • El sistema operativo (sistemas de gestión de archivos y memoria, controladores y registro).
  • El compilador (unidades de traducción y optimizaciones del código fuente).
  • E incluso el código fuente sí mismo con su (s) conjunto (s) de algoritmos distintivos.

Ya podemos ver que hay un cuello de botella que está sucediendo dentro del primer algoritmo antes incluso de aplicarlo a cualquier máquina con cualquier arbitrario. arquitectura, OSy lenguaje programable en comparación con el segundo algoritmo. Ya existía un problema antes de involucrar los elementos intrínsecos de una computadora moderna.


Los resultados finales

Sin embargo; no quiere decir que estas nuevas preguntas no sean importantes porque ellas mismas lo son y, después de todo, sí juegan un papel. Tienen un impacto en los procedimientos y el desempeño general y eso es evidente con los diversos gráficos y evaluaciones de muchos que han dado su (s) respuesta (s) o comentario (s).

Si prestas atención a la analogía del Boss y los dos trabajadores A & B que tuvo que ir a recuperar paquetes de C & D respectivamente y considerando las notaciones matemáticas de los dos algoritmos en cuestión; se puede ver sin la participación del hardware y software de la computadora Case 2 es de aproximadamente 60% más rápido que Case 1.

Cuando observa los gráficos y tablas después de que estos algoritmos se hayan aplicado a algún código fuente, compilado, optimizado y ejecutado a través del sistema operativo para realizar sus operaciones en una determinada pieza de hardware, incluso puede ver un poco más de degradación entre las diferencias. en estos algoritmos.

Si Data El conjunto es bastante pequeño, puede que al principio no parezca una diferencia tan grande. Sin embargo, desde Case 1 es aproximadamente 60 - 70% más lento que Case 2 podemos ver el crecimiento de esta función en términos de las diferencias en el tiempo de ejecución:

DeltaTimeDifference approximately = Loop1(time) - Loop2(time)
//where
Loop1(time) = Loop2(time) + (Loop2(time)*[0.6,0.7]) // approximately
// So when we substitute this back into the difference equation we end up with
DeltaTimeDifference approximately = (Loop2(time) + (Loop2(time)*[0.6,0.7])) - Loop2(time)
// And finally we can simplify this to
DeltaTimeDifference approximately = [0.6,0.7]*Loop2(time)

Esta aproximación es la diferencia promedio entre estos dos bucles, tanto algorítmicamente como operaciones de la máquina que involucran optimizaciones de software e instrucciones de la máquina.

Cuando el conjunto de datos crece linealmente, también lo hace la diferencia de tiempo entre los dos. El algoritmo 1 tiene más recuperaciones que el algoritmo 2, lo cual es evidente cuando el Boss tiene que viajar hacia adelante y hacia atrás la distancia máxima entre A & C para cada iteración después de la primera iteración mientras que el algoritmo 2 el Boss tiene que viajar a A una vez y luego de haber terminado con A tiene que viajar una distancia máxima solo una vez cuando va de A a C.

Tratando de tener el Boss concentrarse en hacer dos cosas similares a la vez y hacer malabarismos con ellas en lugar de concentrarse en tareas consecutivas similares lo enojará bastante al final del día, ya que tuvo que viajar y trabajar el doble. Por lo tanto, no pierda el alcance de la situación dejando que su jefe se meta en un cuello de botella interpolado porque el cónyuge y los hijos del jefe no lo apreciarían.



Enmienda: Principios de diseño de ingeniería de software

-- La diferencia entre pila local y montón asignado cálculos dentro de bucles for iterativos y la diferencia entre sus usos, sus eficiencias y efectividad -

El algoritmo matemático que propuse anteriormente se aplica principalmente a los bucles que realizan operaciones en los datos que se asignan en el montón.

  • Operaciones de pila consecutivas:
    • Si los bucles están realizando operaciones en datos localmente dentro de un solo bloque de código o alcance que está dentro del marco de la pila, todavía se aplicará, pero las ubicaciones de la memoria están mucho más cerca donde suelen ser secuenciales y la diferencia en la distancia recorrida o el tiempo de ejecución. es casi insignificante. Dado que no se realizan asignaciones dentro del montón, la memoria no se dispersa y la memoria no se recupera a través de RAM. La memoria es típicamente secuencial y relativa al marco de la pila y al puntero de la pila.
  • Cuando se realizan operaciones consecutivas en la pila, un moderno procesador almacenará en caché los valores repetitivos y las direcciones manteniendo estos valores dentro de los registros de caché locales. El tiempo de las operaciones o instrucciones aquí es del orden de nanosegundos.
  • Operaciones asignadas de montón consecutivas:
    • Cuando comienza a aplicar asignaciones de pila y el procesador tiene que buscar las direcciones de memoria en llamadas consecutivas, dependiendo de la arquitectura de la CPU, el controlador de bus y los módulos de RAM, el tiempo de operaciones o ejecución puede ser del orden de micro a milisegundos. En comparación con las operaciones de pila almacenadas en caché, estas son bastante lentas.
    • La CPU tendrá que buscar la dirección de memoria de la RAM y, por lo general, cualquier cosa en el bus del sistema es lenta en comparación con las rutas de datos internas o los buses de datos dentro de la propia CPU.

Entonces, cuando trabaja con datos que deben estar en el montón y los atraviesa en bucles, es más eficiente mantener cada conjunto de datos y sus algoritmos correspondientes dentro de su propio bucle único. Obtendrá mejores optimizaciones en comparación con tratar de factorizar bucles consecutivos colocando múltiples operaciones de diferentes conjuntos de datos que están en el montón en un solo bucle.

Está bien hacer esto con los datos que están en la pila, ya que con frecuencia se almacenan en caché, pero no para los datos cuya dirección de memoria debe consultarse en cada iteración.

Aquí es donde entra en juego la ingeniería de software y el diseño de arquitectura de software. Es la capacidad de saber cómo organizar sus datos, saber cuándo almacenarlos en caché, saber cuándo asignar sus datos en el montón, saber cómo diseñar e implementar sus algoritmos y saber cuándo y dónde llamarlos.

Es posible que tenga el mismo algoritmo que pertenece al mismo conjunto de datos, pero es posible que desee un diseño de implementación para su variante de pila y otro para su variante de pila asignada solo por el problema anterior que se ve en su O(n) complejidad del algoritmo cuando se trabaja con el montón.

Por lo que he notado a lo largo de los años, muchas personas no toman en cuenta este hecho. Tenderán a diseñar un algoritmo que funcione en un conjunto de datos en particular y lo usarán independientemente de que el conjunto de datos esté almacenado en caché localmente en la pila o si se asignó en el montón.

Si desea una verdadera optimización, sí, puede parecer una duplicación de código, pero para generalizar sería más eficiente tener dos variantes del mismo algoritmo. ¡Uno para operaciones de pila y el otro para operaciones de montón que se realizan en bucles iterativos!

Aquí hay un pseudo ejemplo: dos estructuras simples, un algoritmo.

struct A {
    int data;
    A() : data{0}{}
    A(int a) : data{a}{}
};
struct B {
    int data;
    B() : data{0}{}
    A(int b) : data{b}{}
}

template<typename T>
void Foo( T& t ) {
    // Do something with t
}

// Some looping operation: first stack then heap.

// Stack data:
A dataSetA[10] = {};
B dataSetB[10] = {};

// For stack operations this is okay and efficient
for (int i = 0; i < 10; i++ ) {
   Foo(dataSetA[i]);
   Foo(dataSetB[i]);
}

// If the above two were on the heap then performing
// the same algorithm to both within the same loop
// will create that bottleneck
A* dataSetA = new [] A();
B* dataSetB = new [] B();
for ( int i = 0; i < 10; i++ ) {
    Foo(dataSetA[i]); // dataSetA is on the heap here
    Foo(dataSetB[i]); // dataSetB is on the heap here
} // this will be inefficient.

// To improve the efficiency above, put them into separate loops...

for (int i = 0; i < 10; i++ ) {
    Foo(dataSetA[i]);
}
for (int i = 0; i < 10; i++ ) {
    Foo(dataSetB[i]);
}
// This will be much more efficient than above.
// The code isn't perfect syntax, it's only psuedo code
// to illustrate a point.

Esto es a lo que me refería al tener implementaciones separadas para variantes de pila frente a variantes de pila. Los algoritmos en sí mismos no importan demasiado, son las estructuras de bucle las que los usará para hacerlo.

Respondido el 04 de diciembre de 20 a las 15:12

Puede ser C ++ antiguo y optimizaciones. En mi computadora obtuve casi la misma velocidad:

Un bucle: 1.577 ms

Dos bucles: 1.507 ms

Ejecuto Visual Studio 2015 en un procesador E5-1620 de 3.5 GHz con 16 GB de RAM.

respondido 17 nov., 18:16

No es la respuesta que estás buscando? Examinar otras preguntas etiquetadas or haz tu propia pregunta.