Como encontrar operações de cópia espúrias em C ++?

11

Recentemente, tive o seguinte

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

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

O problema com este código é que, quando a estrutura é criada, ocorre uma cópia e a solução é escrever return {std :: move (V)}

Existe um analisador de código ou linter que detecta operações de cópia falsas? Nem o cppcheck, o cpplint nem o clang-tidy podem fazer isso.

Edição: Vários pontos para tornar minha pergunta mais clara:

  1. Sei que ocorreu uma operação de cópia porque usei o compiler explorer e ele mostra uma chamada para o memcpy .
  2. Pude identificar que ocorreram operações de cópia observando o padrão yes. Mas minha idéia inicial errada foi que o compilador otimizaria esta cópia. Eu estava errado.
  3. (Provavelmente) não é um problema do compilador, pois clang e gcc produzem código que produz um memcpy .
  4. O memcpy pode ser barato, mas não consigo imaginar circunstâncias em que copiar a memória e excluir o original seja mais barato do que passar um ponteiro por um std :: move .
  5. A adição do std :: move é uma operação elementar. Eu imaginaria que um analisador de código seria capaz de sugerir essa correção.
Mathieu Dutour Sikiric
fonte
2
Não posso responder se existe ou não algum método / ferramenta para detectar operações de cópia "espúrias"; no entanto, na minha opinião sincera, discordo que a cópia da std::vectormesma não seja o que ela pretende ser . Seu exemplo mostra uma cópia explícita, e é natural e a abordagem correta (novamente imho) aplicar a std::movefunção conforme você sugere, se uma cópia não é o que você deseja. Observe que alguns compiladores podem omitir a cópia se os sinalizadores de otimizações estiverem ativados e o vetor não for alterado.
magnus
Temo que haja demais cópias desnecessárias (que não pode estar afetando) para tornar esta regra linter utilizável: - / ( ferrugem usos mover por padrão, então requer cópia explícita :))
Jarod42
Minhas sugestões para otimizar o código é basicamente desmontar a função que pretende otimizar e você vai descobrir a operações de cópia extra
camp0
Se eu entendi seu problema corretamente, você deseja detectar casos em que uma operação de cópia (construtor ou operador de atribuição) é invocada em um objeto, seguido por sua destruição. Para classes personalizadas, posso imaginar adicionar um conjunto de sinalizadores de depuração quando uma cópia é executada, redefinida em todas as outras operações e check-in do destruidor. No entanto, não saiba como fazer o mesmo para classes não personalizadas, a menos que você possa modificar o código fonte.
Daniel Langr
2
A técnica que eu uso para encontrar cópias espúrias é tornar temporariamente o construtor de cópias privado e, em seguida, examinar onde o compilador rejeita devido a restrições de acesso. (O mesmo objectivo pode ser alcançado através da marcação do construtor de cópia como obsoleto, para compiladores que suportam tal marcação.)
Eljay

Respostas:

2

Eu acredito que você tem a observação correta, mas a interpretação errada!

A cópia não ocorrerá retornando o valor, porque todo compilador inteligente normal usará (N) RVO nesse caso. No C ++ 17, isso é obrigatório, portanto, você não pode ver nenhuma cópia retornando um vetor gerado local da função.

OK, vamos brincar um pouco std::vectore o que acontecerá durante a construção ou preenchendo passo a passo.

Primeiro de tudo, vamos gerar um tipo de dados que torna cada cópia ou movimento visível 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; }

};

E agora vamos começar algumas experiências:

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; }
}

O que podemos observar:

Exemplo 1) Criamos um vetor a partir de uma lista de inicializadores e talvez esperemos ver 4 vezes a construção e 4 movimentos. Mas temos 4 cópias! Isso parece um pouco misterioso, mas o motivo é a implementação da lista de inicializadores! Simplesmente, não é permitido sair da lista, pois o iterador da lista é o const T*que impossibilita a movimentação de elementos dela. Uma resposta detalhada sobre este tópico pode ser encontrada aqui: initializer_list e move semântica

Exemplo 2) Nesse caso, obtemos uma construção inicial e 4 cópias do valor. Isso não é nada de especial e é o que podemos esperar.

Exemplo 3) Também aqui, apresentamos a construção e alguns movimentos conforme o esperado. Com minha implementação stl, o vetor cresce por fator 2 toda vez. Então, vemos um primeiro construto, outro e, como o vetor é redimensionado de 1 para 2, vemos o movimento do primeiro elemento. Ao adicionar o 3, vemos um redimensionamento de 2 para 4, que precisa da mudança dos dois primeiros elementos. Tudo como esperado!

Exemplo 4) Agora reservamos espaço e preenchemos mais tarde. Agora não temos mais cópia nem movimento!

Em todos os casos, não vemos nenhum movimento ou cópia retornando o vetor de volta ao chamador! (N) O RVO está ocorrendo e nenhuma ação adicional é necessária nesta etapa!

Voltar à sua pergunta:

"Como encontrar operações de cópia espúrias em C ++"

Como visto acima, você pode introduzir uma classe proxy no meio para fins de depuração.

Tornar o copiador privado pode não funcionar em muitos casos, pois você pode ter algumas cópias desejadas e outras ocultas. Como acima, apenas o código do exemplo 4 funcionará com um copiador privado! E não posso responder à pergunta, se o exemplo 4 for o mais rápido, pois enchemos a paz pela paz.

Lamento não poder oferecer uma solução geral para encontrar cópias "indesejadas" aqui. Mesmo se você digitar seu código para chamadas de memcpy, você não encontrará tudo, pois também memcpyserá otimizado e verá diretamente algumas instruções do assembler fazendo o trabalho sem chamar a memcpyfunção de sua biblioteca .

Minha dica é não focar em um problema tão pequeno. Se você tiver problemas reais de desempenho, faça uma análise e meça. Existem tantos potenciais assassinos de desempenho, que investir muito tempo no memcpyuso espúrio não parece uma idéia que vale a pena.

Klaus
fonte
Minha pergunta é meio acadêmica. Sim, existem várias maneiras de ter código lento e isso não é um problema imediato para mim. No entanto, podemos encontrar as operações memcpy usando o compiler explorer. Então, definitivamente há uma maneira. Mas é viável apenas para pequenos programas. O que quero dizer é que há um interesse no código que encontrará sugestões sobre como melhorar o código. Existem analisadores de código que encontram bugs e vazamentos de memória, por que não esses problemas?
Mathieu Dutour Sikiric
"código que encontraria sugestões sobre como melhorar o código." Isso já foi feito e implementado nos próprios compiladores. (N) A otimização de RVO é apenas um exemplo e funciona perfeitamente como mostrado acima. A captura de memcpy não ajudou, pois você está procurando por "memcpy indesejado". "Existem analisadores de código que encontram bugs e vazamentos de memória, por que não esses problemas?" Talvez não seja um problema (comum). E uma ferramenta muito mais geral para encontrar problemas de "velocidade" também já está presente: profiler! Meu sentimento pessoal é que você está procurando algo acadêmico que não é um problema no software real hoje.
Klaus
1

Sei que ocorreu uma operação de cópia porque usei o compiler explorer e ele mostra uma chamada para o memcpy.

Você colocou seu aplicativo completo no explorer do compilador e ativou as otimizações? Caso contrário, o que você viu no explorador de compilador pode ou não ser o que está acontecendo com seu aplicativo.

Um problema com o código que você postou é que você primeiro cria um std::vectore depois o copia para uma instância de data. Seria melhor inicializar data com o vetor:

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

Além disso, se você der à definição do explorer do compilador datae get_vector(), e mais nada, ele deverá esperar o pior. Se você realmente fornecer algum código-fonte que use get_vector() , observe qual assembly é gerado para esse código-fonte. Veja este exemplo para saber o que a modificação acima, o uso real e as otimizações do compilador podem causar a produção do compilador.

G. Sliepen
fonte
Eu apenas coloquei no computer explorer o código acima (que possui o memcpy ), caso contrário a pergunta não faria sentido. Dito isto, sua resposta é excelente para mostrar maneiras diferentes de produzir um código melhor. Você fornece duas maneiras: Uso de estática e colocação do construtor diretamente na saída. Portanto, essas maneiras podem ser sugeridas por um analisador de código.
Mathieu Dutour Sikiric