Jak znaleźć fałszywe operacje kopiowania w C ++?

11

Ostatnio miałem następujące

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

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

Problem z tym kodem polega na tym, że po utworzeniu struktury następuje kopia, a zamiast tego rozwiązaniem jest napisanie return {std :: move (V)}

Czy istnieje liniowiec lub analizator kodu, który wykryłby takie fałszywe operacje kopiowania? Nie mogą tego zrobić ani cppcheck, cpplint, ani clang-tidy.

EDYCJA: Kilka kwestii, aby wyjaśnić moje pytanie:

  1. Wiem, że wystąpiła operacja kopiowania, ponieważ użyłem eksploratora kompilatora i wyświetla on wezwanie do memcpy .
  2. Mógłbym zidentyfikować, że wystąpiły operacje kopiowania, patrząc na standard tak. Ale moim początkowym błędnym pomysłem było to, że kompilator zoptymalizuje tę kopię. Myliłem się.
  3. Nie jest (prawdopodobnie) problemem kompilatora, ponieważ zarówno clang, jak i gcc produkują kod, który tworzy memcpy .
  4. Memcpy może być tani, ale nie mogę sobie wyobrazić okoliczności, w których kopiowanie pamięci i usuwanie oryginału jest tańsze niż przekazywanie wskaźnika przez std :: move .
  5. Dodanie std :: move jest operacją elementarną. Wyobrażam sobie, że analizator kodu mógłby zasugerować tę poprawkę.
Mathieu Dutour Sikiric
źródło
2
Nie mogę odpowiedzieć na pytanie, czy istnieje jakakolwiek metoda / narzędzie do wykrywania „fałszywych” operacji kopiowania, jednak, moim szczerym zdaniem, nie zgadzam się z tym, że kopiowanie w std::vectorjakikolwiek sposób nie jest tym, czym się wydaje . Twój przykład pokazuje wyraźną kopię, i jest to naturalne i prawidłowe podejście (ponownie imho), aby zastosować tę std::movefunkcję, jak sugerujesz sobie, jeśli kopia nie jest tym, czego chcesz. Zauważ, że niektóre kompilatory mogą pominąć kopiowanie, jeśli flagi optymalizacji są włączone, a wektor pozostaje niezmieniony.
magnus
Obawiam się, że jest zbyt wiele niepotrzebnych kopii (które mogą nie mieć wpływu), aby ta reguła lintera była użyteczna: - / ( rdza domyślnie używa ruchu, więc wymaga wyraźnej kopii :))
Jarod42
Moje sugestie dotyczące optymalizacji kodu to w zasadzie rozmontowanie funkcji, którą chcesz zoptymalizować, a odkryjesz dodatkowe operacje kopiowania
obóz
Jeśli dobrze rozumiem twój problem, chcesz wykryć przypadki, w których operacja kopiowania (konstruktor lub operator przypisania) jest wywoływana na obiekcie po jego zniszczeniu. W przypadku klas niestandardowych mogę sobie wyobrazić dodanie zestawu flag debugowania podczas wykonywania kopii, resetowania wszystkich innych operacji i sprawdzania w destruktorze. Nie wiem jednak, jak to zrobić w przypadku klas niestandardowych, chyba że można zmodyfikować ich kod źródłowy.
Daniel Langr
2
Technika, której używam do znajdowania fałszywych kopii, polega na tymczasowym ustawieniu konstruktora kopii jako prywatnego, a następnie zbadaniu, gdzie kompilator przeszkadza z powodu ograniczeń dostępu. (Ten sam cel można osiągnąć, oznaczając konstruktora kopii jako przestarzały, dla kompilatorów obsługujących takie tagowanie.)
Eljay

Odpowiedzi:

2

Wierzę, że masz prawidłową obserwację, ale złą interpretację!

Kopiowanie nie nastąpi po zwróceniu wartości, ponieważ każdy normalny sprytny kompilator użyje w tym przypadku (N) RVO . Od C ++ 17 jest to obowiązkowe, więc nie możesz zobaczyć żadnej kopii, zwracając lokalnie wygenerowany wektor z funkcji.

OK, zagrajmy trochę z std::vectortym, co stanie się podczas budowy lub wypełniając ją krok po kroku.

Po pierwsze, pozwólmy wygenerować typ danych, który sprawia, że ​​każda kopia lub ruch jest widoczny jak ten:

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

};

A teraz zacznijmy eksperymenty:

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

Co możemy zaobserwować:

Przykład 1) Tworzymy wektor z listy inicjalizatora i być może oczekujemy, że zobaczymy 4 razy konstrukcję i 4 ruchy. Ale dostajemy 4 kopie! Brzmi to trochę tajemniczo, ale powodem jest implementacja listy inicjalizacyjnej! Po prostu nie można poruszać się z listy, ponieważ iterator z listy const T*uniemożliwia przenoszenie z niej elementów. Szczegółową odpowiedź na ten temat można znaleźć tutaj: lista_inicjalizatora i semantyka przenoszenia

Przykład 2) W tym przypadku otrzymujemy wstępną konstrukcję i 4 kopie wartości. To nic specjalnego i tego możemy się spodziewać.

Przykład 3) Również tutaj mamy konstrukcję i niektóre ruchy zgodnie z oczekiwaniami. Dzięki mojej implementacji stl wektor rośnie za każdym razem o współczynnik 2. Widzimy więc pierwszą konstrukcję, inną, a ponieważ wektor zmienia rozmiar z 1 na 2, widzimy ruch pierwszego elementu. Dodając 3, widzimy zmianę rozmiaru z 2 na 4, która wymaga przesunięcia pierwszych dwóch elementów. Wszystko zgodnie z oczekiwaniami!

Przykład 4) Teraz rezerwujemy miejsce i wypełniamy później. Teraz nie mamy już żadnej kopii ani żadnego ruchu!

We wszystkich przypadkach nie widzimy żadnego ruchu ani kopii, zwracając w ogóle wektor z powrotem do dzwoniącego! (N) RVO ma miejsce i na tym etapie nie są wymagane żadne dalsze działania!

Powrót do pytania:

„Jak znaleźć fałszywe operacje kopiowania w C ++”

Jak widać powyżej, możesz wprowadzić klasę pośrednią pomiędzy nimi w celu debugowania.

Ustanowienie prywatnego programu kopiującego-ctora może nie działać w wielu przypadkach, ponieważ możesz mieć niektóre poszukiwane kopie i niektóre ukryte. Jak wyżej, tylko kod na przykład 4 będzie działał z prywatnym narzędziem kopiującym! I nie mogę odpowiedzieć na pytanie, czy przykład 4 jest najszybszy, ponieważ pokój wypełniamy pokojem.

Przepraszam, że nie mogę zaoferować ogólnego rozwiązania w zakresie wyszukiwania „niechcianych” kopii. Nawet jeśli wykopiesz kod dla wywołań memcpy, nie znajdziesz wszystkich, ponieważ memcpyzostaną one również zoptymalizowane i zobaczysz bezpośrednio niektóre instrukcje asemblera wykonujące zadanie bez wywołania memcpyfunkcji biblioteki .

Moja wskazówka nie polega na skupieniu się na tak drobnym problemie. Jeśli masz rzeczywiste problemy z wydajnością, weź profiler i zmierz. Jest tak wielu potencjalnych zabójców wydajności, że inwestowanie dużo czasu w fałszywe memcpyużycie nie wydaje się tak wartościowym pomysłem.

Klaus
źródło
Moje pytanie jest trochę akademickie. Tak, istnieje wiele sposobów na powolny kod i nie jest to dla mnie bezpośredni problem. Możemy jednak znaleźć operacje memcpy za pomocą eksploratora kompilatora. Jest więc zdecydowanie sposób. Ale jest to wykonalne tylko w przypadku małych programów. Chodzi mi o to, że istnieje zainteresowanie kodem, który znalazłby sugestie, jak poprawić kod. Istnieją analizatory kodu, które wykrywają błędy i wycieki pamięci, dlaczego nie takie problemy?
Mathieu Dutour Sikiric
„kod, który zawierałby sugestie dotyczące ulepszenia kodu”. Jest to już zrobione i zaimplementowane w samych kompilatorach. (N) Optymalizacja RVO jest tylko jednym przykładem i działa idealnie, jak pokazano powyżej. Łapanie memcpy nie pomogło, ponieważ szukasz „niechcianego memcpy”. „Istnieją analizatory kodu, które wykrywają błędy i wycieki pamięci, dlaczego nie takie problemy?” Może nie jest to (powszechny) problem. Dostępne jest również znacznie bardziej ogólne narzędzie do wyszukiwania problemów związanych z „prędkością”: profiler! Moim osobistym odczuciem jest to, że szukasz rzeczy akademickiej, która nie jest dzisiaj problemem w prawdziwym oprogramowaniu.
Klaus
1

Wiem, że wystąpiła operacja kopiowania, ponieważ użyłem eksploratora kompilatora i wyświetla on wezwanie do memcpy.

Czy umieściłeś całą aplikację w eksploratorze kompilatorów i czy włączono optymalizacje? Jeśli nie, to to, co zobaczyłeś w eksploratorze kompilatorów, może, ale nie musi, być tym, co dzieje się z twoją aplikacją.

Jednym z problemów z opublikowanym kodem jest to, że najpierw tworzysz std::vector, a następnie kopiujesz do instancji data. Lepiej byłoby zainicjalizować data za pomocą wektora:

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

Ponadto, jeśli podasz kompilatorowi eksplorator definicji datai get_vector(), i nic więcej, musi spodziewać się gorszego. Jeśli faktycznie podasz mu kod źródłowy, który używa get_vector() , sprawdź, jaki zestaw jest generowany dla tego kodu źródłowego. Zobacz ten przykład, aby dowiedzieć się, co powyższa modyfikacja oraz faktyczne użycie i optymalizacje kompilatora mogą spowodować wygenerowanie kompilatora.

G. Sliepen
źródło
W eksploratorze komputera umieściłem tylko powyższy kod (który ma memcpy ), w przeciwnym razie pytanie nie miałoby sensu. Biorąc to pod uwagę, twoja odpowiedź jest doskonała, pokazując różne sposoby tworzenia lepszego kodu. Udostępniasz dwa sposoby: użycie statycznego i umieszczenie konstruktora bezpośrednio w danych wyjściowych. Tak więc te sposoby mogłyby zasugerować analizator kodu.
Mathieu Dutour Sikiric