Mientras trabajaba en Headlands Technologies, tuve la suerte de escribir varias utilidades para simplificar la creación de código C ++ de alto rendimiento. Este artículo ofrece una descripción general de una de estas utilidades, OutOfLine
.
Comencemos con un ejemplo ilustrativo. Supongamos que tiene un sistema que se ocupa de una gran cantidad de objetos del sistema de archivos. Estos pueden ser archivos ordinarios, denominados sockets o tuberías UNIX. Por alguna razón, abres muchos descriptores de archivos al inicio, luego trabajas intensamente con ellos y, al final, cierras los descriptores y eliminas los enlaces a los archivos (aproximadamente. El carril significa la función de desvinculación ).
La versión inicial (simplificada) puede verse así:
class UnlinkingFD { std::string path; public: int fd; UnlinkingFD(const std::string& p) : path(p) { fd = open(p.c_str(), O_RDWR, 0); } ~UnlinkingFD() { close(fd); unlink(path.c_str()); } UnlinkingFD(const UnlinkingFD&) = delete; };
Y este es un buen diseño lógicamente sólido. Se basa en RAII para liberar automáticamente el descriptor y eliminar el enlace. Puede crear una gran variedad de dichos objetos, trabajar con ellos y, cuando la matriz deje de existir, los propios objetos eliminarán todo lo que se necesitaba en el proceso.
¿Pero qué pasa con el rendimiento? Supongamos que fd
usa con mucha frecuencia y path
solo cuando se elimina un objeto. Ahora la matriz consta de objetos de tamaño 40 bytes, pero a menudo solo se usan 4 bytes. Esto significa que habrá más errores en la memoria caché, ya que debe "omitir" el 90% de los datos.
Una de las soluciones comunes a este problema es la transición de una matriz de estructuras a una estructura de matriz. Esto proporcionará el rendimiento deseado, pero a costa de abandonar RAII. ¿Existe una opción que combine las ventajas de ambos enfoques?
Un compromiso simple sería reemplazar std::string
tamaño de 32 bytes con std::unique_ptr<std::string>
, cuyo tamaño es de solo 8 bytes. Esto reducirá el tamaño de nuestro objeto de 40 bytes a 16 bytes, lo cual es un gran logro. Pero esta solución aún pierde al usar múltiples matrices.
OutOfLine
es una herramienta que permite, sin abandonar RAII, mover completamente los campos poco utilizados (en frío) fuera del objeto. OutOfLine se usa como una clase base CRTP , por lo que el primer argumento de la plantilla debe ser una clase secundaria. El segundo argumento es el tipo de datos raramente utilizados (en frío) asociados con un objeto (principal) utilizado con frecuencia.
struct UnlinkingFD : private OutOfLine<UnlinkingFD, std::string> { int fd; UnlinkingFD(const std::string& p) : OutOfLine<UnlinkingFD, std::string>(p) { fd = open(p.c_str(), O_RDWR, 0); } ~UnlinkingFD(); UnlinkingFD(const UnlinkingFD&) = delete; };
Entonces, ¿cómo es esta clase?
template <class FastData, class ColdData> class OutOfLine {
La idea de implementación básica es utilizar un contenedor asociativo global que asigne punteros a objetos principales y punteros a objetos que contengan datos en frío.
inline static std::map<OutOfLine const*, std::unique_ptr<ColdData>> global_map_;
OutOfLine
se puede usar con cualquier tipo de datos en frío, una instancia de la cual se crea y asocia con el objeto principal automáticamente.
template <class... TArgs> explicit OutOfLine(TArgs&&... args) { global_map_[this] = std::make_unique<ColdData>(std::forward<TArgs>(args)...); }
La eliminación del objeto principal implica la eliminación automática del objeto frío asociado:
~OutOfLine() { global_map_.erase(this); }
Al mover (constructor de movimiento / operador de asignación de movimiento) del objeto principal, el objeto frío correspondiente se asociará automáticamente con el nuevo objeto sucesor principal. Como resultado, no debe acceder a los datos en frío de un objeto movido desde.
explicit OutOfLine(OutOfLine&& other) { *this = other; } OutOfLine& operator=(OutOfLine&& other) { global_map_[this] = std::move(global_map_[&other]); return *this; }
En el ejemplo de implementación anterior , OutOfLine
se puede OutOfLine
por simplicidad. Si es necesario, las operaciones de copia son fáciles de agregar; solo necesitan crear y vincular una copia de un objeto frío.
OutOfLine(OutOfLine const&) = delete; OutOfLine& operator=(OutOfLine const&) = delete;
Ahora, para que esto sea realmente útil, sería bueno tener acceso a datos en frío. Al heredar de OutOfLine
clase recibe los métodos constantes y no constantes de cold()
:
ColdData& cold() noexcept { return *global_map_[this]; } ColdData const& cold() const noexcept { return *global_map_[this]; }
Devuelven el tipo apropiado de referencia a datos fríos.
Eso es casi todo. Esta opción UnlinkingFD
tendrá un tamaño de 4 bytes, proporcionará acceso fácil al caché al campo fd
y conservará los beneficios de RAII. Todo el trabajo relacionado con el ciclo de vida de un objeto está completamente automatizado. Cuando el objeto principal que se usa con frecuencia se mueve, rara vez se usan movimientos fríos de datos con él. Cuando se elimina el objeto principal, también se elimina el objeto frío correspondiente.
A veces, sin embargo, sus datos se conspiran para complicar su vida, y se enfrenta a una situación en la que primero se deben crear datos básicos. Por ejemplo, son necesarios para construir datos fríos. Es necesario crear objetos en el orden inverso en relación con lo que ofrece OutOfLine
. Para tales casos, un "respaldo" es útil para controlar el orden de inicialización y de inicialización.
struct TwoPhaseInit {}; OutOfLine(TwoPhaseInit){} template <class... TArgs> void init_cold_data(TArgs&&... args) { global_map_.find(this)->second = std::make_unique<ColdData>(std::forward<TArgs>(args)...); } void release_cold_data() { global_map_[this].reset(); }
Este es otro constructor OutOfLine
que se puede usar en clases secundarias; acepta una etiqueta de tipo TwoPhaseInit
. Si crea OutOfLine
de esta manera, los datos en frío no se inicializarán y el objeto permanecerá a medio construir. Para completar la construcción de dos fases, debe llamar al método init_cold_data
(pasando los argumentos necesarios para crear un objeto de tipo ColdData
). Recuerde que no puede llamar a .cold()
en un objeto cuyos datos en frío aún no se hayan inicializado. Por analogía, los datos en frío se pueden eliminar antes de lo programado antes de ejecutar el destructor ~OutOfLine
llamando a release_cold_data
.
};
Ahora ya está todo. Entonces, ¿qué nos dan estas 29 líneas de código? Son otra posible compensación entre rendimiento y facilidad de uso. En los casos en que tiene un objeto, algunos de cuyos miembros se usan con mucha más frecuencia que otros, OutOfLine
puede servir como una forma fácil de usar para optimizar el caché, a costa de ralentizar significativamente el acceso a los datos raramente utilizados.
Pudimos aplicar esta técnica en varios lugares; a menudo es necesario complementar los datos de trabajo utilizados intensivamente con metadatos adicionales que son necesarios al final del trabajo, en situaciones raras o inesperadas. Ya sea información sobre los usuarios que establecieron la conexión, desde el terminal comercial del que provino el pedido, o el manejo del acelerador de hardware dedicado al procesamiento de datos de intercambio, OutOfLine
mantendrá el caché limpio cuando se encuentre en la parte crítica de los cálculos (ruta crítica).
He preparado una prueba para que pueda ver y evaluar la diferencia.
El guión | Tiempo (ns) |
---|
Datos fríos en el objeto principal (versión inicial) | 34684547 |
Datos en frío completamente eliminados (el mejor de los casos) | 2938327 |
Usando OutOfLine | 2947645 |
OutOfLine
aceleración de aproximadamente OutOfLine
al usar OutOfLine
. Obviamente, esta prueba está diseñada para demostrar el potencial de OutOfLine
, pero también muestra cuánta optimización de caché puede tener un impacto significativo en el rendimiento, al igual que OutOfLine
permite obtener esta optimización. Mantener el caché libre de datos raramente utilizados puede proporcionar mejoras complejas, medibles e integrales para el resto del código. Como siempre con la optimización, confíe más en las mediciones que en los supuestos, sin embargo, espero que OutOfLine
demuestre ser una herramienta útil en su colección de utilidades.
Nota del traductor
El código provisto en el artículo sirve para demostrar la idea y no es representativo del código de producción.