Wrapper um nativen Datentypen [erledigt]



  • Hallo,

    ich habe eine Klasse um ein uint64_t gewrapped und alles über constexpr delegiert. Nun habe ich gedacht, dass das null Overhead kostet und mir einfach mal die Code-Größe im Release-Mode angeschaut und für ein paar Tests war das Kompilat um ein paar Bytes größer. Klar, kann man sagen, dass ich kleinlich bin, aber da ich eine Schach-Engine schreibe, wo man teils einzelne Maschinenbefehle zählt, ist mir das nicht komplett unwichtig und ich wundere mich deswegen.

    Einzige Erklärung ist für mich, dass das Überladen des Streams-Operators (Zeile 116) den Code um einige Bytes aufbläht.
    Wie seht ihr das?

    #include <cinttypes>
    #include <ostream>
    #include <concepts>
    #include <iostream>
    
    struct BitBoardState
    {
        constexpr BitBoardState() = default;
        constexpr ~BitBoardState() = default;
        constexpr BitBoardState(uint64_t state) : m_state(state){}
        constexpr BitBoardState(const BitBoardState &bitBoardState) = default;
        constexpr BitBoardState(BitBoardState &&) = default;
    
        constexpr BitBoardState &operator=(BitBoardState state)
        {
            m_state = state.m_state;
            return *this;
        }
    
        constexpr BitBoardState operator~() const
        {
            return {~m_state};
        }
    
        template<typename Number>
        requires std::integral<Number>
        constexpr BitBoardState operator<<(const Number shift) const
        {
            return {m_state << shift};
        }
    
        template<typename Number>
        requires std::integral<Number>
        constexpr BitBoardState operator>>(const Number shift) const
        {
            return {m_state >> shift};
        }
    
        constexpr BitBoardState operator&(const BitBoardState bitBoardState) const
        {
            return {m_state & bitBoardState.m_state};
        }
    
        constexpr BitBoardState operator|(const BitBoardState bitBoardState) const
        {
            return {m_state | bitBoardState.m_state};
        }
    
        template<typename Number>
        requires std::integral<Number>
        constexpr BitBoardState operator*(const Number multiplicator) const
        {
            return {m_state * multiplicator};
        }
    
        constexpr BitBoardState operator+(const BitBoardState bitBoardState) const
        {
            return {m_state + bitBoardState.m_state};
        }
    
        template<typename Number>
        constexpr BitBoardState operator+(const Number summand) const
        {
            return {m_state + summand};
        }
    
        constexpr void operator|=(const BitBoardState bitBoardState)
        {
            m_state |= bitBoardState.m_state;
        }
    
        constexpr void operator&=(const BitBoardState bitBoardState)
        {
            m_state &= bitBoardState.m_state;
        }
    
        constexpr bool operator==(const BitBoardState bitBoardState) const
        {
            return m_state == bitBoardState.m_state;
        }
    
        constexpr bool operator!=(const BitBoardState bitBoardState) const
        {
            return m_state != bitBoardState.m_state;
        }
    
        constexpr bool operator==(const uint64_t bitBoardState) const
        {
            return m_state == bitBoardState;
        }
    
        constexpr bool operator!=(const uint64_t bitBoardState) const
        {
            return m_state != bitBoardState;
        }
    
    private:
        uint64_t m_state{};
    };
    
    template<typename Number>
    requires std::integral<Number>
    BitBoardState operator*(Number x, const BitBoardState& y)
    {
        return y * x;
    }
    
    template<typename Number>
    requires std::integral<Number>
    BitBoardState operator+(Number x, const BitBoardState& y)
    {
        return y + x;
    }
    
    // Implementierung im CPP
    std::ostream& operator<<(std::ostream& os, const BitBoardState bitBoardState);
    

  • Mod

    Das klingt nach einer plausiblen Theorie. Beweisen wird sich das aber nicht lassen, ohne tatsächlich die beiden Compilate zu vergleichen. Ohne so einen Vergleich können wir auch nur spekulieren.



  • @SeppJ Ich hab mal auf Godbolt versucht nach verkürzt nachzubilden: https://godbolt.org/z/G9TGxM9s6

    Wenn man dort entweder Zeile 144 oder Zeile 144 auskommentiert, sieht man tatsächlich einige Zeilen Unterschied an Maschinenbefehlen.
    Zeile 144 nutzt den Wraper und Zeile 145 nutzt die native Methode.

    int main()
    {
        std::cout << foo1() << std::endl; // Zeile 144
        print(std::cout, foo2()) << std::endl; // Zeile 145
        return 0;
    }
    


  • Dieser Beitrag wurde gelöscht!


  • @Steffo sagte in Wrapper um nativen Datentypen:

    Einzige Erklärung ist für mich, dass das Überladen des Streams-Operators (Zeile 116) den Code um einige Bytes aufbläht.

    Implementier' die Funktion (den Streaming-Operator) doch einfach mal inline, und schau dann ob es noch einen Unterschied gibt.
    Davon abgesehen: kleinere Programme sind nicht unbedingt immer schneller. Wenn du wissen willst was schneller ist, dann musst du Messen.

    ich habe eine Klasse um ein uint64_t gewrapped und alles über constexpr delegiert. Nun habe ich gedacht, dass das null Overhead kostet

    Deine Wrapper-Klasse sollte i.A. null Overhead verursachen. Mit constexpr hat das allerdings nichts zu tun -- sondern damit dass alle Memberfunktionen so einfach sind dass der Compiler sie so gut wie sicher überall inlinen wird.



  • @Steffo sagte in Wrapper um nativen Datentypen:

    @SeppJ Ich hab mal auf Godbolt versucht nach verkürzt nachzubilden: https://godbolt.org/z/G9TGxM9s6

    Wenn man dort entweder Zeile 144 oder Zeile 144 auskommentiert, sieht man tatsächlich einige Zeilen Unterschied an Maschinenbefehlen.
    Zeile 144 nutzt den Wraper und Zeile 145 nutzt die native Methode.

    Generell ist optimierter Code ziemliches compilergeneriertes Spaghetti, was mitunter nicht leicht zu verstehen ist. Wenn ich den Assembler-Output allerdings grob überfliege, dann wird zwar so ziemlich dasselbe gemacht, aber irgendwie andere Register dafür verwendet. Die zusätzlichen Instruktionen scheinen extra push/pop-Instruktionen zu sein und das halte ich für ein Resultat davon, nach welcher Strategie der Compiler die Register für den Code reserviert hat. Ich habe den Eindruck, dass hier für den zwar prinzipiell äquivalenten, aber dennoch verschiedenen C++-Code irgendwo im Compiler unterschiedliche Code-Pfade genommen werden, die eben zu einem anderen Ergebnis führen. Ich denke nicht, dass die Algorithmen im Optimizer garantiert immer den "optimalen Maschinencode" finden.

    Ich glaube allerdings nicht, dass ein operator<< generell zu zusätzlichen Instruktionen führt. Ich halte das eher für einen Zufall, der in diesem speziellen Kontext eben dieses Resultat erzeugt.

    Interessant ist auch, sich den unoptimierten Code mal anzusehen, hier nur für die beiden Ausgabefunktionen (Godbolt):

    Sowohl GCC als auch Clang erzeugen in der print()-Funktion nach dem basic_ostream<<-call ein mov rax, qword ptr [rbp - 8]. In der operator<<-Variante gibt es das nicht. In rax wird der Rückgabewert der Funktion abgelegt, in diesem Fall die Adresse des zurückgegebenen ostream. Allerdings gibt bereits der Aufruf von basic_ostream<< eine Instruktion vorher diese Adresse in rax zurück, was in der operator<<-Variante richtig erkannt und die überflüssige Instruktion nicht erzeugt wurde. Bemerkenswert ist auch, dass sich sowohl GCC wie auch Clang hier gleich verhalten.

    Das gleiche Phänomen gibt es übrigens auch, wenn man beide Argumente zu einem const BitBoardState macht. Es liegt also vermutlich schon daran, dass die eine Funktion ein operator<< ist. Aber wie gesagt, ich glaube nicht, dass diese Form immer zu mehr Instruktionen führt. Ich würde vermuten, dass der Maschinencode in anderen Fällen auch kürzer werden könnte. Die zwei Funktionen laufen vermutlich einfach auf verschiedenen Pfaden durch die Algorithmen in den Eingeweiden des Compilers.



  • @hustbaer Inline am Streaming-Operator ändert leider nichts.
    @Finnegan Danke für die interessante Analyse! Ich habe das ehrlich gesagt für trivialen Code gehalten, der einfach zu optimieren ist.
    Haltet ihr die Wrapper-Klasse für überflüssig? Sie dient hauptsächlich der Typ-Sicherheit und sie beinhaltet auch eine Komfort-Methode (streaming-Operator, die die einzelnen Bits in einer Schachbrett-Anordnung formatiert).



  • @Steffo sagte in Wrapper um nativen Datentypen:

    @Finnegan Danke für die interessante Analyse! Ich habe das ehrlich gesagt für trivialen Code gehalten, der einfach zu optimieren ist.
    Haltet ihr die Wrapper-Klasse für überflüssig? Sie dient hauptsächlich der Typ-Sicherheit und sie beinhaltet auch eine Komfort-Methode (streaming-Operator, die die einzelnen Bits in einer Schachbrett-Anordnung formatiert).

    Ich hatte geschrieben, dass ich nicht glaube dass die Abstraktion an sich Ursache für die zusätzlichen Instruktionen ist, sondern dass eher andere Wege durch die ganzen Algorithmen des Compilers genommen werden, so dass man ein minimalst anderes Ergebnis bekommt. An anderen Stellen im Code ist es vielleicht genau anders herum, daher denke ich nicht, dass man aus dieser Beobachtung irgendwelche allgemeinen Schussfolgerungen ziehen sollte.

    Auch schliesse ich mich @hustbaer an, dass immer gemessen werden sollte, bevor man irgendwas manuell "optimiert". Mehr Instruktionen bedeuten nicht automatisch schlechtere Performance.

    Ferner würde ich auch behaupten, dass allein der cout<<-Aufruf die paar Nanosekunden, die ein extra push/pop eventuell benötigt in der Performance-Betrachtung vollständig dominiert und meinen Fokus eher darauf legen, dass vor allem die anderen Operatoren des BitBoardState den selben Code erzeugen wie für einen nackten uint64_t - woran ich ehrlich gesagt wenig Zweifel habe.

    Und nein, ich finde Abstraktionen im Allgemeinen nicht überflüssig, sofern sie einen Mehrwert bieten, indem sie z.B. zur Verständlichkeit oder Wartbarkeits des Codes beitragen, den Code vereinfachen oder seine Wiederverwendbarkeit erhöhen. Dein BitBoardState ist allerdings derzeit mehr oder weniger ein Nachbau eines uint64_t. Wenn du BitBoardState in deiner Schachengine wie einen uint64_t verwendest, dann fällt es mir schwer zu erkennen, was dadurch gewonnen wird. Ich würde für deinen Anwendungsfall da eher z.B. keinen Multiplikationsoperator erwarten, sondern sattdessen solche oder ähnliche Member-Funktionen, die eben relevant für deine Schach-Berechnungen sind (nur meine krude Vorstellung, was es für relevante Funktionen geben könnte - ich hab noch nie eine Schachengine geschrieben - ich hoffe aber die illustrieren, was ich meine):

    struct BitBoardState
    {
        void set(int i, int j, bool value); // Setzt Bit an Schachbrett-Position (i, j)
        bool get(int i, int j); // Liefert Bit an Schachbrett-Position (i, j)
        void set_diagonal_move(int i, int j, bool value); // Setzt alle Bits die auf den Linien diagonaler Züge von Position (i, j) liegen.
        void set_knight_move(int i, int j, bool value); // Setzt alle Bits der durch einen Springer-Zug von Position (i, j) erreichbaren Felder.
        ...
        etc.
    }
    

    ... also Funktionen, die in deiner Schach-Engine benötigte Grundoperationen auf dem Schachbrett implementieren, die dann dank der Abstraktion mehrfach wiederverwendet werden können.



  • @Finnegan Danke, ich muss mir noch überlegen, ob ich für solche Operationen einen Wrapper brauche. Ich werde da sicherlich viel herumexperimentieren. 🙂



  • Dieser Beitrag wurde gelöscht!

Anmelden zum Antworten