Directrices básicas de C++:reglas sobre el rendimiento

Directrices básicas de C++:reglas sobre el rendimiento

Antes de escribir sobre las reglas de desempeño, haré un trabajo muy simple. Accediendo a los elementos de un contenedor uno por uno.

Esta es la última regla de la aritmética.

ES.107:No use unsigned para subíndices, prefiera gsl::index

¿Dije que este es un trabajo simple? Honestamente, esto era una mentira. Vea lo que todo puede salir mal. Aquí hay un ejemplo de un std::vector.

vector<int> vec = /*...*/;

for (int i = 0; i < vec.size(); i += 2) // may not be big enough (2)
 cout << vec[i] << '\n';
for (unsigned i = 0; i < vec.size(); i += 2) // risk wraparound (3)
 cout << vec[i] << '\n';
for (auto i = 0; i < vec.size(); i += 2) // may not be big enough (2)
 cout << vec[i] << '\n';
for (vector<int>::size_type i = 0; i < vec.size(); i += 2) // verbose (1)
 cout << vec[i] << '\n';
for (auto i = vec.size()-1; i >= 0; i -= 2) // bug (4) 
 cout << vec[i] << '\n';
for (int i = vec.size()-1; i >= 0; i -= 2) // may not be big enough (2)
 cout << vec[i] << '\n';

¿Aterrador? ¡Derecha! Sólo la línea (1) es correcta. Puede suceder en las líneas (2) que la variable i sea demasiado pequeña. El resultado puede ser un desbordamiento. Esto no se mantendrá para la línea (3) porque i no está firmado. En lugar de un desbordamiento, obtendrá una operación de módulo. Escribí sobre este agradable efecto en mi última publicación:Pautas básicas de C ++:reglas para declaraciones y aritmética. Para ser más específicos, se dictaminó ES.106.

Queda la línea 4. Este es mi favorito. ¿Cuál es el problema? El problema es que vec.size() es del tipo std::size_t. std::size_t es un tipo sin signo y, por lo tanto, no puede representar números negativos. Imagina lo que sucedería si el vector estuviera vacío. Esto significa que vec.size() -1 es -1. El resultado es que obtenemos el valor máximo de tipo std::size_t.

El programa index.cpp muestra este extraño comportamiento.

// index.cpp

#include <iostream>
#include <vector>

int main(){
 
 std::cout << std::endl;
 
 std::vector<int> vec{};
 
 auto ind1 = vec.size() - 1 ;
 int ind2 = vec.size() -1 ;
 
 std::cout << "ind1: " << ind1 << std::endl;
 std::cout << "ind2: " << ind2 << std::endl;
 
 std::cout << std::endl;
 
}

Y aquí está el resultado:

Las pautas sugieren que la variable i debería ser del tipo gsl::index.

for (gsl::index i = 0; i < vec.size(); i += 2) // ok
 cout << vec[i] << '\n';
for (gsl::index i = vec.size()-1; i >= 0; i -= 2) // ok
 cout << vec[i] << '\n';

Si esta no es una opción para usted, use el tipo std::vector::size_type para i.

¡El rendimiento es el dominio de C++! ¿Derecha? Por lo tanto, tenía bastante curiosidad por escribir sobre las reglas de actuación. Pero esto es difícilmente posible porque la mayoría de las reglas carecen de fundamento. Sólo consisten en un título y una razón. A veces, incluso falta la razón.

De todos modos. Estas son las primeras reglas:

  • Per.1:No optimice sin razón
  • Per.2:No optimizar prematuramente
  • Per.3:No optimice algo que no sea crítico para el rendimiento
  • Per.4:No asuma que el código complicado es necesariamente más rápido que el código simple
  • Per.5:No asuma que el código de bajo nivel es necesariamente más rápido que el código de alto nivel
  • Per.6:No haga afirmaciones sobre el rendimiento sin mediciones

En lugar de escribir comentarios generales sobre reglas generales, proporcionaré algunos ejemplos de estas reglas. Comencemos con las reglas Per.4, Per.5 y Per.6

Per.4:No asuma que el código complicado es necesariamente más rápido que el código simple

Per.5:No asuma que el código de bajo nivel es necesariamente más rápido que el código de alto nivel

Per.6:No haga afirmaciones sobre el rendimiento sin mediciones

Antes de continuar escribiendo, debo hacer un descargo de responsabilidad:no recomiendo usar el patrón singleton. Solo quiero mostrar que el código complicado y de bajo nivel no siempre vale la pena. Para probar mi punto, tengo que medir el rendimiento.

Hace mucho, mucho tiempo escribí sobre la inicialización segura para subprocesos del patrón singleton en mi publicación:Inicialización segura para subprocesos de un singleton. La idea clave de la publicación era invocar el patrón singleton 40.000.000 veces desde cuatro subprocesos y medir el tiempo de ejecución. El patrón singleton se inicializará de forma perezosa; por lo tanto, la primera llamada tiene que inicializarlo.

Implementé el patrón singleton de varias maneras. Lo hice con std::lock_guard y la función std::call_once en combinación con std::once_flag. Lo hice con una variable estática. Incluso usé atómica y rompí la consistencia secuencial por razones de rendimiento.

Para dejar mi puntero claro. Quiero mostrarte la implementación más fácil y la más desafiante.

La implementación más fácil es el llamado singleton de Meyers. Es seguro para subprocesos porque el estándar C++ 11 garantiza que una variable estática con alcance de bloque se inicializará de manera segura para subprocesos.

// singletonMeyers.cpp

#include <chrono>
#include <iostream>
#include <future>

constexpr auto tenMill= 10000000;

class MySingleton{
public:
 static MySingleton& getInstance(){
 static MySingleton instance; // (1)
 // volatile int dummy{};
 return instance;
 }
private:
 MySingleton()= default;
 ~MySingleton()= default;
 MySingleton(const MySingleton&)= delete;
 MySingleton& operator=(const MySingleton&)= delete;

};

std::chrono::duration<double> getTime(){

 auto begin= std::chrono::system_clock::now();
 for (size_t i= 0; i < tenMill; ++i){
 MySingleton::getInstance(); // (2)
 }
 return std::chrono::system_clock::now() - begin;
 
};

int main(){
 
 auto fut1= std::async(std::launch::async,getTime);
 auto fut2= std::async(std::launch::async,getTime);
 auto fut3= std::async(std::launch::async,getTime);
 auto fut4= std::async(std::launch::async,getTime);
 
 auto total= fut1.get() + fut2.get() + fut3.get() + fut4.get();
 
 std::cout << total.count() << std::endl;

}

La línea (1) utiliza la garantía del tiempo de ejecución de C++11 de que el singleton se inicializará de forma segura para subprocesos. Cada uno de los cuatro subprocesos de la función principal invoca 10 millones de veces el singleton en línea (2). En total, esto hace 40 millones de llamadas.

Pero puedo hacerlo mejor. Esta vez uso atómicos para hacer que el patrón singleton sea seguro para subprocesos. Mi implementación se basa en el infame patrón de bloqueo de doble verificación. En aras de la simplicidad, solo mostraré la implementación de la clase MySingleton.

class MySingleton{
public:
 static MySingleton* getInstance(){
 MySingleton* sin= instance.load(std::memory_order_acquire);
 if ( !sin ){
 std::lock_guard<std::mutex> myLock(myMutex);
 sin= instance.load(std::memory_order_relaxed);
 if( !sin ){
 sin= new MySingleton();
 instance.store(sin,std::memory_order_release);
 }
 } 
 // volatile int dummy{};
 return sin;
 }
private:
 MySingleton()= default;
 ~MySingleton()= default;
 MySingleton(const MySingleton&)= delete;
 MySingleton& operator=(const MySingleton&)= delete;

 static std::atomic<MySingleton*> instance;
 static std::mutex myMutex;
};


std::atomic<MySingleton*> MySingleton::instance;
std::mutex MySingleton::myMutex;

Tal vez escuchó que el patrón de bloqueo verificado dos veces está roto. ¡Por supuesto, no es mi implementación! Si no me crees, demuéstramelo. Primero, debe estudiar el modelo de memoria, pensar en la semántica de adquisición-liberación y pensar en la restricción de sincronización y orden que se mantendrá en esta implementación. Este no es un trabajo fácil. Pero ya sabes, el código altamente sofisticado vale la pena.

Maldita sea. Olvidé la regla Per.6:aquí están los números de rendimiento para el singleton de Meyers en Linux. Compilé el programa con la máxima optimización. Los números en Windows estaban en el mismo estadio.

Ahora estoy curioso. ¿Cuáles son los números de mi código altamente sofisticado? Veamos qué rendimiento obtendremos con la atómica.

50% por ciento más lento! 50% por ciento más lento y ni siquiera sabemos si la implementación es correcta. Descargo de responsabilidad:la implementación es correcta.

De hecho, el singleton de Meyers fue la forma más rápida y sencilla de obtener una implementación segura para subprocesos del patrón singleton. Si tiene curiosidad acerca de los detalles, lea mi publicación:Inicialización segura de subprocesos de un singleton.

¿Qué sigue?

Quedan más de 10 reglas para el desempeño en las pautas. Aunque es bastante difícil escribir sobre reglas generales de este tipo, tengo algunas ideas en mente para mi próxima publicación.