La pregunta original
¿Por qué un bucle es mucho más lento que dos bucles?
Conclusión:
El caso 1 es un problema clásico de interpolación que resulta ineficiente. 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 hacer aplicaciones de múltiples subprocesos, así como programación paralela.
Mirándolo desde este tipo de enfoque sin involucrar cómo el hardware, el sistema operativo y el compilador (es) trabajan juntos para realizar asignaciones de almacenamiento dinámico que implican trabajar con RAM, caché, archivos de página, etc. La matemática que está en la base de estos algoritmos nos muestra cuál de estos dos es la mejor solución.
Podemos usar una analogía de un Boss
ser Summation
que representará un For Loop
que tiene que viajar entre trabajadores A
y B
.
Podemos ver fácilmente que el Caso 2 es al menos la mitad de rápido si no un poco más que el Caso 1 debido a la diferencia en la distancia que se necesita para viajar y el tiempo que toman los trabajadores. Esta matemática se alinea casi virtualmente y perfectamente tanto con el BenchMark Times como con la cantidad de diferencias en las Instrucciones de ensamblaje.
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
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
Considerando la pregunta original del OP sobre las 2 variantes de los bucles for 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 tratar de hacer algo diferente aquí adoptando un enfoque diferente sobre esta situación y este problema.
El enfoque
Teniendo en cuenta los dos bucles y toda la discusión sobre la caché y la presentación de páginas, me gustaría adoptar otro enfoque para ver esto desde una perspectiva diferente. Uno que no involucra el caché y los archivos de página ni las ejecuciones para asignar memoria, de hecho, este enfoque ni siquiera se refiere al hardware o software real.
La perspectiva
Después de mirar el código por un tiempo, se hizo bastante evidente cuál es el problema y qué lo está generando. Vamos a dividir esto en un problema algorítmico y analizarlo desde la perspectiva del uso de anotaciones matemáticas y luego aplicar una analogía a los problemas matemáticos, así como a los algoritmos.
Lo que sabemos
Sabemos que este ciclo se ejecutará 100,000 veces. También sabemos que a1
, b1
, c1
y d1
son punteros en una arquitectura de 64 bits. Dentro de C ++ en una máquina de 32 bits, todos los punteros son de 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 2 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 aún equivalen a 32 bytes en asignaciones totales. Con esta información, avancemos y muestremos las matemáticas generales, los algoritmos y la analogía de estos conceptos.
Sí sabemos la cantidad de veces que el mismo conjunto o grupo de operaciones tendrá que realizarse en ambos casos. Sí 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 realicemos una prueba de referencia. Sin embargo, los puntos de referencia ya estaban incluidos en la pregunta original y también en algunas de las respuestas y comentarios; y podemos ver una diferencia significativa entre los dos y este es el razonamiento completo de esta propuesta para este problema.
Investiguemos
Ya es evidente que muchos ya lo han hecho al observar las asignaciones del montón, las pruebas de referencia, la RAM, la caché y los archivos de página. Al observar puntos de datos específicos e índices de iteración específicos también se incluyeron y las diversas conversaciones sobre este problema específico han hecho que muchas personas comiencen a cuestionar otras cosas relacionadas al respecto. ¿Cómo comenzamos a ver este problema usando algoritmos matemáticos y aplicando una analogía? ¡Comenzamos haciendo un par de afirmaciones! Luego desarrollamos nuestro algoritmo a partir de ahí.
Nuestras afirmaciones:
- Dejaremos que nuestro bucle y sus iteraciones sean una Sumatoria que comience en 1 y termine en 100000 en lugar de comenzar con 0 como en los bucles, ya que no debemos preocuparnos por el esquema de indexación 0 del direccionamiento de memoria ya que solo estamos interesados en El algoritmo mismo.
- En ambos casos, tenemos 4 funciones para trabajar y 2 llamadas de función con 2 operaciones que se realizan en cada llamada de función. Vamos a establecer estas arriba como funciones y llamadas a funciones como las siguientes:
F1()
, F2()
, f(a)
, f(b)
, f(c)
y f(d)
.
Los algoritmos
Primer caso: - Solo una suma pero dos llamadas a funciones independientes.
Sum n=1 : [1,100000] = F1(), F2();
F1() = { f(a) = f(a) + f(b); }
F2() = { f(c) = f(c) + f(d); }
Segundo caso: - Dos sumas pero cada una tiene su propia llamada a la función.
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 notó que F2()
solo existe en Sum
desde Case1
donde F1()
está contenido en Sum
desde Case1
y en ambos Sum1
y Sum2
desde Case2
. Esto será evidente más adelante cuando comencemos a concluir que existe una optimización dentro del segundo algoritmo.
Las iteraciones a través de las Sum
llamadas de primer caso f(a)
que se sumarán a sí mismas, f(b)
luego llama a f(c)
que harán lo mismo pero se sumarán f(d)
a sí mismas para cada 100000
iteración. En el segundo caso, tenemos Sum1
y Sum2
que ambos actúan igual que si fueran la misma función que se llama dos veces seguidas.
En este caso, podemos tratar Sum1
y Sum2
, simplemente, como antes, Sum
donde 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 que hay una optimización, ya que ambos bucles tienen la misma firma exacta, pero este no es el problema real. El problema no es el trabajo que se está realizando f(a)
, f(b)
, f(c)
, y f(d)
. En ambos casos y en la comparación entre los dos, es la diferencia en la distancia que tiene que recorrer la suma en cada caso lo que le da la diferencia en el tiempo de ejecución.
Piense en el For Loops
como el Summations
que hace las iteraciones como ser una Boss
que está dando órdenes a dos personas A
y B
y que sus puestos de trabajo son a la carne C
y D
, respectivamente, y para recoger algún paquete de ellos y lo devuelve. En esta analogía, los bucles for o las iteraciones de suma y las comprobaciones de condición no representan realmente el Boss
. Lo que en realidad representa Boss
no es de los algoritmos matemáticos reales directamente, sino del concepto real Scope
y Code Block
dentro de una rutina o subrutina, método, función, unidad de traducción, etc. El primer algoritmo tiene 1 alcance donde el segundo algoritmo tiene 2 ámbitos consecutivos.
Dentro del primer caso en cada recibo de llamada, el Boss
va A
y da la orden y A
sale a buscar el B's
paquete, luego Boss
va C
y da las órdenes para hacer lo mismo y recibir el paquete D
en cada iteración.
Dentro del segundo caso, Boss
funciona directamente con A
ir y buscar el B's
paquete hasta que se reciban todos los paquetes. Luego, Boss
funciona C
para hacer lo mismo para obtener todos los D's
paquetes.
Dado que estamos trabajando con un puntero de 8 bytes y tratando con la asignación del montón, consideremos el siguiente problema. Digamos que Boss
está a 100 pies de distancia A
y que A
está a 500 pies de distancia C
. No tenemos que preocuparnos de cuán lejos Boss
está inicialmente C
debido al orden de las ejecuciones. En ambos casos, el Boss
viaje inicial desde el A
primero hasta el B
. Esta analogía no quiere decir que esta distancia sea exacta; es solo un caso de prueba útil para mostrar el funcionamiento de los algoritmos.
En muchos casos, cuando se realizan asignaciones de almacenamiento dinámico y se trabaja con el caché y los archivos de página, estas distancias entre las ubicaciones de las direcciones pueden no variar mucho o pueden variar significativamente según la naturaleza de los tipos de datos y los tamaños de los arreglos.
Los casos de prueba:
Primer caso: en la primera iteración,Boss
inicialmente tiene que avanzar 100 pies para dar el deslizamiento de la ordenA
yA
se apaga y hace lo suyo, pero luegoBoss
tiene que viajar 500 piesC
para darle su deslizamiento de la orden. Luego, en la siguiente iteración y cada dos iteraciones después de queBoss
tenga que ir y venir 500 pies entre los dos.
Segundo caso: ElBoss
tiene que viajar 100 pies en la primera iteración paraA
, pero después de eso, él ya está ahí y simplemente espera aA
que volver hasta que todas las hojas están llenas. Entonces elBoss
tiene que viajar 500 pies en la primera iteraciónC
porqueC
es 500 pies deA
. Dado que estoBoss( Summation, For Loop )
se llama justo después de trabajar conA
él, solo espera allí como lo hizoA
hasta que seC's
completentodos losresbalonesdepedidos.
La diferencia en 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 de 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 cada llamada en cada iteración se debe a muchas otras variables invisibles. Esto es solo una evaluación de la situación a tener en cuenta y mirarla desde el peor de los casos.
A partir de estos números, casi parecería que el Algoritmo Uno debería ser 99%
más lento que el Algoritmo Dos; Sin embargo, esto es sólo la Boss's
parte o la responsabilidad de los algoritmos y no da cuenta de los trabajadores reales A
, B
, C
, Y D
y lo que tienen que hacer en cada iteración del bucle. Por lo tanto, el trabajo del jefe solo representa entre el 15 y el 40% del trabajo total realizado. La mayor parte del trabajo que se realiza a través de los trabajadores tiene un impacto ligeramente mayor para mantener la proporción de las diferencias de velocidad en aproximadamente 50-70%
La observación: - Las diferencias entre los dos algoritmos
En esta situación, es la estructura del proceso del trabajo que se realiza. Demuestra que el Caso 2 es más eficiente tanto por la optimización parcial de tener una declaración de función similar como por una definición en la que solo las variables difieren por nombre y la distancia recorrida.
También vemos que la distancia total recorrida en el caso 1 está mucho más lejos que en el caso 2 y podemos considerar esta distancia recorrida como nuestro factor de tiempo entre los dos algoritmos. El caso 1 tiene mucho más trabajo que hacer que el caso 2 .
Esto es observable a partir de la evidencia de las ASM
instrucciones que se mostraron en ambos casos. Junto con lo que ya se dijo sobre estos casos, esto no explica el hecho de que en el Caso 1 el jefe tendrá que esperar a ambos A
y C
volver antes de poder volver a A
volver para cada iteración. Tampoco tiene en cuenta el hecho de que si A
o B
está tardando mucho tiempo, tanto el Boss
o los otros trabajadores están inactivos esperando a ser ejecutados.
En el caso 2, el único que está inactivo es Boss
hasta que el trabajador regrese. Entonces, incluso esto tiene un impacto en el algoritmo.
Los PO pregunta (s) modificada (s)
EDITAR: La pregunta resultó ser irrelevante, ya que el comportamiento depende en gran medida de los tamaños de las matrices (n) y el caché de la CPU. Entonces, si hay más interés, reformulo la pregunta:
¿Podría proporcionar una idea sólida de los detalles que conducen a los diferentes comportamientos de 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.
Sobre estas preguntas
Como he demostrado sin lugar a dudas, hay 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., todos trabajan juntos en un conjunto integrado de sistemas entre los siguientes:
The Architecture
{Hardware, firmware, algunos controladores integrados, kernels y conjuntos de instrucciones ASM}.
The OS
{Sistemas de gestión de archivos y memoria, controladores y el registro}.
The Compiler
{Unidades de traducción y optimizaciones del código fuente}.
- E incluso el
Source Code
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 de que incluso lo aplicamos a cualquier máquina con cualquier arbitraria Architecture
, OS
y Programmable Language
en comparación con el segundo algoritmo. Ya existía un problema antes de involucrar a los 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 juegan un papel después de todo. Impactan los procedimientos y el rendimiento general y eso es evidente con los diversos gráficos y evaluaciones de muchos que han dado sus respuestas y / o comentarios.
Si prestó atención a la analogía de los Boss
dos trabajadores A
y B
quién tuvo que ir y recuperar paquetes de C
& D
respectivamente y considerando las anotaciones matemáticas de los dos algoritmos en cuestión; puede ver sin la participación del hardware y el software de la computadora Case 2
es aproximadamente 60%
más rápido que Case 1
.
Cuando observa los gráficos y cuadros 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 el Data
conjunto es bastante pequeño, puede no parecer una gran diferencia al principio. Sin embargo, dado que Case 1
es 60 - 70%
más lento de Case 2
lo que podemos ver el crecimiento de esta función en términos de las diferencias en las ejecuciones de tiempo:
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 las operaciones de la máquina que implican 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 alcances que el algoritmo 2, lo cual es evidente cuando Boss
tiene que viajar de ida y vuelta la distancia máxima entre A
y C
para cada iteración después de la primera iteración, mientras que el Algoritmo 2 Boss
tiene que viajar A
una vez y luego de haber terminado con A
él tiene que viajar una distancia máxima solo una vez al pasar de A
a C
.
Intentar Boss
concentrarse en hacer dos cosas similares a la vez y hacer malabares entre ellas en lugar de enfocarse 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 permitiendo 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 Local Stack
y los Heap Allocated
cálculos dentro de iterativos para bucles 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 consecutivas de apilamiento:
- Si los bucles están realizando operaciones en los 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 generalmente son secuenciales y la diferencia en la distancia recorrida o el tiempo de ejecución Es casi insignificante. Como no se realizan asignaciones dentro del montón, la memoria no está dispersa y la memoria no se recupera a través de la memoria 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 procesador moderno almacenará en caché valores repetitivos y direcciones manteniendo estos valores dentro de los registros de caché local. El tiempo de operaciones o instrucciones aquí es del orden de nano-segundos.
- Operaciones asignadas de montón consecutivas:
- Cuando comienza a aplicar asignaciones de almacenamiento dinámico 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 Ram, el tiempo de operaciones o ejecución puede ser del orden de micro a milisegundos En comparación con las operaciones de pila en caché, estas son bastante lentas.
- La CPU tendrá que obtener la dirección de memoria de 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 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 intentar factorizar bucles consecutivos al colocar 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 que deben consultar su dirección de memoria 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 almacenar en caché sus datos, 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 asignada en el montón solo por el problema anterior que se ve por su O(n)
complejidad del algoritmo cuando funciona con el montón
Por lo que he notado a lo largo de los años, muchas personas no tienen en cuenta este hecho. Tienden a diseñar un algoritmo que funcione en un conjunto de datos en particular y lo usarán independientemente del conjunto de datos que se almacena en caché local 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 las operaciones de pila y el otro para las 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 las variantes de pila frente a las variantes de montón. Los algoritmos en sí mismos no importan demasiado, son las estructuras de bucle las que usará en eso.