¿Cómo encontrar operaciones de copia espurias en C ++?

11

Recientemente tuve lo siguiente

struct data {
  std::vector<int> V;
};

data get_vector(int n)
{
  std::vector<int> V(n,0);
  return {V};
}

El problema con este código es que cuando se crea la estructura se produce una copia y la solución es escribir return {std :: move (V)}

¿Hay linter o analizador de código que detectaría tales operaciones de copia espurias? Ni cppcheck, cpplint, ni clang-tidy pueden hacerlo.

EDITAR: Varios puntos para aclarar mi pregunta:

  1. Sé que se produjo una operación de copia porque utilicé el explorador de compiladores y muestra una llamada a memcpy .
  2. Pude identificar que se produjeron operaciones de copia mirando el sí estándar. Pero mi idea equivocada inicial fue que el compilador optimizaría esta copia. Estaba equivocado.
  3. No es (probablemente) un problema del compilador ya que tanto clang como gcc producen código que genera una memoria .
  4. La memoria puede ser barata, pero no puedo imaginar circunstancias en las que copiar memoria y eliminar el original sea más barato que pasar un puntero por un std :: move .
  5. La adición de std :: move es una operación elemental. Me imagino que un analizador de código podría sugerir esta corrección.
Mathieu Dutour Sikiric
fuente
2
No puedo responder si existe o no cualquier método / herramienta para la detección de "espuria" copiar operaciones, sin embargo, en mi opinión honesta, no estoy de acuerdo que la copia de la std::vectorde ninguna manera es no ser lo que pretende ser . Su ejemplo muestra una copia explícita, y es natural, y el enfoque correcto, (nuevamente en mi humilde opinión), para aplicar la std::movefunción tal como se sugiere si una copia no es lo que desea. Tenga en cuenta que algunos compiladores pueden omitir la copia si las banderas de optimizaciones están activadas y el vector no cambia.
Magnus
Me temo que hay demasiadas copias innecesarias (que podrían no afectar) para que esta regla de linter sea utilizable: - / (el óxido usa el movimiento por defecto, por lo que requiere una copia explícita :))
Jarod42
Mis sugerencias para optimizar el código son básicamente desmontar la función que desea optimizar y descubrirá las operaciones de copia adicionales
campamento0
Si entiendo su problema correctamente, desea detectar casos en los que se invoca una operación de copia (constructor u operador de asignación) en un objeto luego de su destrucción. Para las clases personalizadas, me puedo imaginar agregar un conjunto de indicadores de depuración cuando se realiza una copia, restablecer en todas las demás operaciones y registrar el destructor. Sin embargo, no sé cómo hacer lo mismo para las clases no personalizadas a menos que pueda modificar su código fuente.
Daniel Langr
2
La técnica que uso para encontrar copias espurias es hacer que el constructor de copias sea privado temporalmente y luego examinar dónde el compilador se resiste debido a las restricciones de acceso. (Se puede lograr el mismo objetivo etiquetando el constructor de copias como obsoleto, para los compiladores que admiten dicho etiquetado).
Eljay

Respuestas:

2

¡Creo que tienes la observación correcta pero la interpretación incorrecta!

La copia no se producirá al devolver el valor, porque cada compilador inteligente normal usará (N) RVO en este caso. Desde C ++ 17 esto es obligatorio, por lo que no puede ver ninguna copia devolviendo un vector generado localmente desde la función.

Bien, juguemos un poco std::vectory lo que sucederá durante la construcción o rellenándolo paso a paso.

En primer lugar, generemos un tipo de datos que haga que cada copia o movimiento sea visible como este:

template <typename DATA >
struct VisibleCopy
{
    private:
        DATA data;

    public:
        VisibleCopy( const DATA& data_ ): data{ data_ }
        {
            std::cout << "Construct " << data << std::endl;
        }

        VisibleCopy( const VisibleCopy& other ): data{ other.data }
        {
            std::cout << "Copy " << data << std::endl;
        }

        VisibleCopy( VisibleCopy&& other ) noexcept : data{ std::move(other.data) }
        {
            std::cout << "Move " << data << std::endl;
        }

        VisibleCopy& operator=( const VisibleCopy& other )
        {
            data = other.data;
            std::cout << "copy assign " << data << std::endl;
        }

        VisibleCopy& operator=( VisibleCopy&& other ) noexcept
        {
            data = std::move( other.data );
            std::cout << "move assign " << data << std::endl;
        }

        DATA Get() const { return data; }

};

Y ahora comencemos algunos experimentos:

using T = std::vector< VisibleCopy<int> >;

T Get1() 
{   
    std::cout << "Start init" << std::endl;
    std::vector< VisibleCopy<int> > vec{ 1,2,3,4 };
    std::cout << "End init" << std::endl;
    return vec;
}   

T Get2()
{   
    std::cout << "Start init" << std::endl;
    std::vector< VisibleCopy<int> > vec(4,0);
    std::cout << "End init" << std::endl;
    return vec;
}

T Get3()
{
    std::cout << "Start init" << std::endl;
    std::vector< VisibleCopy<int> > vec;
    vec.emplace_back(1);
    vec.emplace_back(2);
    vec.emplace_back(3);
    vec.emplace_back(4);
    std::cout << "End init" << std::endl;

    return vec;
}

T Get4()
{
    std::cout << "Start init" << std::endl;
    std::vector< VisibleCopy<int> > vec;
    vec.reserve(4);
    vec.emplace_back(1);
    vec.emplace_back(2);
    vec.emplace_back(3);
    vec.emplace_back(4);
    std::cout << "End init" << std::endl;

    return vec;
}

int main()
{
    auto vec1 = Get1();
    auto vec2 = Get2();
    auto vec3 = Get3();
    auto vec4 = Get4();

    // All data as expected? Lets check:
    for ( auto& el: vec1 ) { std::cout << el.Get() << std::endl; }
    for ( auto& el: vec2 ) { std::cout << el.Get() << std::endl; }
    for ( auto& el: vec3 ) { std::cout << el.Get() << std::endl; }
    for ( auto& el: vec4 ) { std::cout << el.Get() << std::endl; }
}

¿Qué podemos observar?

Ejemplo 1) Creamos un vector a partir de una lista de inicializadores y tal vez esperamos ver 4 construcciones y 4 movimientos. ¡Pero tenemos 4 copias! Eso suena un poco misterioso, pero la razón es la implementación de la lista de inicializadores. Simplemente no está permitido moverse de la lista ya que el iterador de la lista es un elemento const T*que hace que sea imposible mover elementos de ella. Puede encontrar una respuesta detallada sobre este tema aquí: initializer_list y move semántica

Ejemplo 2) En este caso, obtenemos una construcción inicial y 4 copias del valor. Eso no es nada especial y es lo que podemos esperar.

Ejemplo 3) También aquí, realizamos la construcción y algunos movimientos como se esperaba. Con mi implementación stl, el vector crece por factor 2 cada vez. Entonces vemos una primera construcción, otra y debido a que el vector cambia de tamaño de 1 a 2, vemos el movimiento del primer elemento. Al agregar el 3, vemos un cambio de tamaño de 2 a 4 que necesita un movimiento de los dos primeros elementos. Todo como se esperaba!

Ejemplo 4) Ahora reservamos espacio y rellenamos más tarde. ¡Ahora ya no tenemos copia ni movimiento!

En todos los casos, no vemos ningún movimiento ni copia al devolver el vector a la persona que llama. (N) ¡RVO está teniendo lugar y no se requieren más acciones en este paso!

De vuelta a su pregunta:

"Cómo encontrar operaciones de copia espurias en C ++"

Como se vio anteriormente, puede introducir una clase de proxy en el medio para fines de depuración.

Hacer que el copiador sea privado puede no funcionar en muchos casos, ya que puede tener algunas copias deseadas y algunas ocultas. Como arriba, ¡solo el código del ejemplo 4 funcionará con un copiador privado! Y no puedo responder la pregunta, si el ejemplo 4 es el más rápido, ya que llenamos paz por paz.

Lamento no poder ofrecer una solución general para encontrar copias "no deseadas" aquí. Incluso si excava su código para llamadas de memcpy, no encontrará todo, ya que también memcpyestará optimizado y verá directamente algunas instrucciones de ensamblador que hacen el trabajo sin una llamada a la memcpyfunción de su biblioteca .

Mi sugerencia es no centrarse en un problema tan menor. Si tiene problemas reales de rendimiento, tome un perfilador y mida. Hay tantos posibles asesinos de rendimiento, que invertir mucho tiempo en el memcpyuso espurio no parece ser una idea que valga la pena.

Klaus
fuente
Mi pregunta es un poco académica. Sí, hay muchas maneras de tener un código lento y este no es un problema inmediato para mí. Sin embargo, podemos encontrar las operaciones de memcpy utilizando el explorador del compilador. Entonces, definitivamente hay una manera. Pero es factible solo para pequeños programas. Mi punto es que hay un interés en el código que encontraría sugerencias sobre cómo mejorar el código. Hay analizadores de código que encuentran errores y pérdidas de memoria, ¿por qué no tales problemas?
Mathieu Dutour Sikiric
"código que encontraría sugerencias sobre cómo mejorar el código". Eso ya está hecho e implementado en los compiladores. (N) La optimización de RVO es solo un ejemplo y funciona perfectamente como se muestra arriba. Capturar memcpy no ayudó, ya que está buscando "memcpy no deseada". "Hay analizadores de código que encuentran errores y pérdidas de memoria, ¿por qué no tales problemas?" Quizás no sea un problema (común). Y una herramienta mucho más general para encontrar problemas de "velocidad" también ya está presente: profiler! Mi sensación personal es que estás buscando algo académico que no sea un problema en el software real de hoy.
Klaus
1

Sé que se produjo una operación de copia porque utilicé el explorador de compiladores y muestra una llamada a memcpy.

¿Puso su aplicación completa en el explorador del compilador y habilitó las optimizaciones? Si no, entonces lo que viste en el explorador del compilador podría o no ser lo que está sucediendo con tu aplicación.

Un problema con el código que publicó es que primero crea un archivo std::vectory luego lo copia en una instancia de data. Sería mejor inicializar data con el vector:

data get_vector(int n)
{
  return {std::vector<int> V(n,0)};
}

Además, si solo le da al explorador del compilador la definición de datay get_vector(), y nada más, tiene que esperar lo peor. Si realmente le da algún código fuente que usa get_vector() , entonces mire qué ensamblado se genera para ese código fuente. Vea este ejemplo para saber qué puede causar la modificación anterior más el uso real más las optimizaciones del compilador.

G. Sliepen
fuente
Solo puse en el explorador de computadora el código anterior (que tiene la memoria ) de lo contrario la pregunta no tendría sentido. Dicho esto, su respuesta es excelente al mostrar diferentes formas de producir un mejor código. Proporciona dos formas: uso de static y poner el constructor directamente en la salida. Entonces, esas formas podrían ser sugeridas por un analizador de código.
Mathieu Dutour Sikiric