Como continuación de un post anterior de esta serie, en el que analizábamos la relevancia de la jerarquía de memoria en el rendimiento de nuestros procesos, vale la pena citar un segundo ejemplo analizado en numerosas publicaciones y foros de programación (ver referencias al final del artículo), consistente en la detección de los tamaños de las memorias caché a través de un buffer de memoria cuya longitud se incrementa paulatinamente. El resultado, por desgracia, suele diferir del esperado, por razones que discutiremos a continuación.
La idea, planteada en el tercer ejemplo de la referencia [2], es simple: Asumamos una arquitectura en la que toda línea caché tenga una longitud fija de 64 bytes. Ubiquemos un vector de enteros en el free store para realizar un número elevado de operaciones de escritura sobre el mismo. Avanzaremos a saltos de longitud 64 bytes precisamente con el fin de intentar modificar cada línea caché. De alcanzarse el final del vector en alguno de los saltos intermedios, regresaremos a su cabecera a través de una operación módulo sobre el índice de acceso. Seleccionaremos una serie de longitudes crecientes para el vector (en un rango de unos pocos cientos de KiB hasta varios MiB) y mediremos el tiempo medio de ejecución de las operaciones de escritura para cada una de ellas. Lógicamente, debiéramos visualizar caídas bruscas en el rendimiento (es decir, incrementos bruscos en los valores temporales) al cruzar los distintos tamaños de memoria caché.
Nota: El número de saltos a dar será muy elevado, con el fin de medir intervalos de tiempo con suficiente precisión.
Así, en una máquina con 512 KiB de memoria caché L2 y 3072 KiB de L3, esperaríamos obtener una gráfica de "tiempo vs longitud del vector" similar a la siguiente:
Nota: Los comandos lscpu y wmic cpu get L2CacheSize, L3CacheSize permiten determinar los tamaños de memoria L2 y L3 en Linux y MS Windows, respectivamente.
El siguiente código, conforme al estándar C++17, genera un archivo CSV con el que poder obtener una gráfica como la anterior. Tengamos en cuenta, sin embargo, que en la mayoría de las ejecuciones la forma de la curva podrá verse modificada sustancialmente respecto a la mostrada, resultando muy difícil o imposible deducir los tamaños de las memorias caché. Esto es consecuencia, obviamente, del complejo comportamiento de la CPU, que incluye factores críticos diversos como branch prediction o, dependiendo de la arquitectura, la compartición de la memoria L3 entre los núcleos de nuestro procesador y su sincronización con las memorias L1 y L2.
#include <cstdint>
#include <chrono>
#include <fstream>
#include <iostream>
#include <vector>
using namespace std;
using namespace std::chrono;
constexpr auto operator""_KiB(unsigned long long sz) {
return 1'024 * sz;
}
constexpr auto operator""_MiB(unsigned long long sz) {
return 1'024 * 1'024 * sz;
}
auto main() -> int
try {
auto cache_data = ofstream{};
cache_data.exceptions(ios::failbit | ios::badbit);
cache_data.open("cache_data.csv", ios::binary);
auto const steps = uint32_t{50'000'000};
for (auto mem = 256_KiB; mem <= 8_MiB; mem *= 2) {
auto const sz = mem / sizeof(int32_t);
auto data = vector<int32_t>(sz, 0);
auto const t_i = steady_clock::now();
for (auto i = uint32_t{}; i < steps; ++i)
++data[(i * 16) % sz];
auto const t_f = steady_clock::now();
auto const total_time = duration_cast<nanoseconds>(t_f - t_i).count();
cache_data << mem/1_KiB << ','
<< static_cast<double>(total_time)/steps << '\n';
}
}
catch (exception const& e) {
cerr << "Exception: " << e.what();
return EXIT_FAILURE;
}
Referencias bibliográficas:
- https://github.com/Kobzol/hardware-effects/tree/master/cache-hierarchy-bandwidth
- http://igoro.com/archive/gallery-of-processor-cache-effects/
- https://stackoverflow.com/questions/12594208/c-program-to-determine-levels-size-of-cache
- http://www.cplusplus.com/forum/general/35557/
No hay comentarios:
Publicar un comentario