Assembler-Code wegen Laufzeit-Optimierung benutzen



  • Nun habe ich mein Compare-Programm mit C# fertig, parallel nutze ich auch Delphi10.3 für die gleiche Aufgabe. Die Verwaltungsarbeiten, wie Formular, Directory auslesen usw. sind ziemlich analog. Weil manche Dateien sehr groß sind, werden immer Blöcke in Byte-Arrays von maximal 32 MB und ein Restblock eingelesen

      public long MaxPufL = 0x02000000;  // 33.554.432 Bytes
    

    Der wesentlich Unterschied besteht um Vergleichen der Blöcke in den Puffern von Datei-1 und Datei-2.
    Bei C# habe ich nur die Möglichkeit gefunden, mit einer for-Schleife den Vergleich zu machen.

                    // Puffer vergleichen
                    for (int i = 0; i < VerglLen; i++)
                    {
                        if (ByPu1[i] != ByPu2[i]) // ungleiche Bytes gefunden ?
                        {
                            GesDiff++; // Differenzen (Anzahl Bytes)
                        }
                    } // for (uint i = 0; i < VerglLen; i++)
    

    In der Delphi10.3 Version mache ich das mit Assembler-Code

    { ----------------------------------------------------------------------
      -                     Puffer 1 und 2 vergleichen                     -
      ---------------------------------------------------------------------- }
    Function CompPuff : Boolean;
      Var
        RCode  : Byte;
      Begin
        asm
          PUSH  ESI
          PUSH  EDI
          PUSH  EBX
          PUSH  ECX
          PUSH  DS
          PUSH  SS
    
          XOR   EBX,EBX               { EBX löschen }
          MOV   [RCode],BL            { RCode löschen }
          MOV   ESI,Offset Puffer1
          MOV   EDI,Offset Puffer2
          MOV   ECX,[Laenge1]         { Länge in Bytes }
    
          MOV   BL,CL                 { Bit 0+1 in BL retten }
          SHR   ECX,2                 { Länge / 4 : in DWords }
          AND   BL,3                  { Restlänge = 0 ? }
          JZ    @CmpDW                { --> ja, nur DWords vergleichen }
    
          CLD
          REPZ  CMPSD                 { DWords vergleichen }
          JNZ   @Diff                 { --> Differenz }
          MOV   ECX,EBX               { Restlänge 1..3 }
          CLD
          REPZ  CMPSB                 { Restlänge vergleichen }
          JZ    @Ende
         @Diff:
          POP   SS
          POP   DS
          MOV   [RCode],1             { RCode = 1 : Differenz ! }
          PUSH  DS
          PUSH  SS
          JMP   @Ende
    
         @CmpDW:
          CLD
          REPZ  CMPSD                 { DWords vergleichen }
          JNZ   @Diff                 { --> Differenz }
    
         @Ende:
          POP   SS
          POP   DS
          POP   ECX
          POP   EBX
          POP   EDI
          POP   ESI
        end;
        if RCode = 1 then CompPuff := True     { Fehler }
                     else CompPuff := False;   { ok.    }
    
      End; { Function CompPuff }
    

    Den Asm-Code könnte man noch für QWords optimieren, da würde es evtl. noch schneller gehen, er ist schon ein paar Jahre alt.

    http://www.hkressedd.de/bilder/CompAsm.jpg

    Beim Vergleich der Laufzeiten für die gleiche Datenmenge benötigt die Delphi-Asm-Version deutlich weniger Zeit, als die C#-for-Version.

    Kann man für solchen Aufgaben nicht irgendwie auch in C# Assembler-Code einbinden ? Bei Delphi und auch bei VS2019 mit C++ darf/kann man das problemlos tun.
    Der Hinweis auf Fehler ... die kann man auch mit C# Code machen.



  • Wenn ich es richtig sehe, zählst du im Assembercode nicht die Fehler, sondern brichst bei der ersten Änderung schon ab. Ist also lange nicht dasselbe - der C#-Code tut also wesentlich mehr.

    Aber: Hierfür würde ich niemals selbstgeschriebenen Assembercode verwenden. Der Compiler ist normalerweise besser im Optimieren.

    Bist du in dem C#-Code sicher, dass die Loop die Quelle der Langsamkeit ist? Welche Datentypen haben deine Puffer überhaupt? Zur not kannst du "unsafe" benutzen, aber ich würde mal vermuten, dass normalerweise das Lesen der Dateien sehr viele Größenordnungen langsamer ist als der Vergleich.



  • Der Code macht 2% ALU Auslastung und 100% Speicherauslastung. Die genaue Implementation ist daher irrelevant, solange das Prefetchen erkannt werden kann. Die CPU wird ja sogar die effektive Schleife bei der Ausführung umschreiben durch Register Renaming etc. so das selbst der genaue Assemblercode gar nicht in dem Sinne befolgt wird.



  • @wob sagte in Assembler-Code wegen Laufzeit-Optimierung benutzen:

    brichst bei der ersten Änderung schon ab. Ist also lange nicht dasselbe

    Ich habe ja nur einen Auszug dargestellt.
    Wenn die Ass-Procedure einen Fehler meldet, werden diese beiden Puffer auch mit einer for-Schleife verglichen und es werden die Differenzen angezeigt. Es gibt eine Vorgabe, wieviele Unterschiede angezeigt werden sollen z.B. /M:3 = 3 Differenzen pro Datei anzeigen.
    Es werden alle Differenzen gezählt.

    Der Normalfall ist aber, dass es keine Differenzen gibt und dass mehrere Puffer-Inhalte und jeweils ein Restpuffer pro Datei verglichen werden. Die vollen Puffer sind immer ein Vielfaches von DWords, so dass immer nur ein einziges mal die Sequenz

         @CmpDW:
          CLD
          REPZ  CMPSD                 { DWords vergleichen }
          JNZ   @Diff                 { --> Differenz }
    

    ausgeführt wird.

    Aus dem Debugger sieht man folgenden Asm-Code für die for-Schleife.

    00007FF83A30AB60 90                   nop  
                            }
    00007FF83A30AB61 90                   nop  
                        }
    00007FF83A30AB62 90                   nop  
                    } // for (uint i = 0; i < VerglLen; i++)
    00007FF83A30AB63 90                   nop  
                    for (int i = 0; i < VerglLen; i++)
    00007FF83A30AB64 8B 95 7C 01 00 00    mov         edx,dword ptr [rbp+17Ch]  
    00007FF83A30AB6A FF C2                inc         edx  
    00007FF83A30AB6C 89 95 7C 01 00 00    mov         dword ptr [rbp+17Ch],edx  
    00007FF83A30AB72 8B 8D 7C 01 00 00    mov         ecx,dword ptr [rbp+17Ch]  
    00007FF83A30AB78 48 63 C9             movsxd      rcx,ecx  
    00007FF83A30AB7B 48 3B 8D 98 01 00 00 cmp         rcx,qword ptr [rbp+198h]  
    00007FF83A30AB82 0F 9C C1             setl        cl  
    00007FF83A30AB85 0F B6 C9             movzx       ecx,cl  
    00007FF83A30AB88 89 8D 68 01 00 00    mov         dword ptr [rbp+168h],ecx  
    00007FF83A30AB8E 83 BD 68 01 00 00 00 cmp         dword ptr [rbp+168h],0  
    00007FF83A30AB95 0F 85 C5 FD FF FF    jne         00007FF83A30A960  
    
                    VerglRestLen = VerglRestLen - VerglLen; // neue Restlänge
    00007FF83A30AB9B 48 8B 95 A8 01 00 00 mov         rdx,qword ptr [rbp+1A8h]  
    00007FF83A30ABA2 48 2B 95 98 01 00 00 sub         rdx,qword ptr [rbp+198h]  
    00007FF83A30ABA9 48 89 95 A8 01 00 00 mov         qword ptr [rbp+1A8h],rdx  
    

    Daraus erkennt man, dass die Adressen innerhalb der Puffer byteweise gezählt werden, das benötigt jeweils drei Asm-Befehle

    mov         edx,dword ptr [rbp+17Ch]  
    inc         edx  
    mov         dword ptr [rbp+17Ch],edx  
    

    Bei meinem Code werden mit
    REPZ CMPSD
    die beiden Puffer komplett verglichen.

    Die Laufzeitunterschiede sprechen ja für sich.



  •                  // Puffer vergleichen
                     for (int i = 0; i < VerglLen; i++)
                     {
                         if (ByPu1[i] != ByPu2[i]) // ungleiche Bytes gefunden ?
                         {
                             GesDiff++; // Differenzen (Anzahl Bytes)
                         }
                     } // for (uint i = 0; i < VerglLen; i++)
    

    Ich habe diesen Code etwas ins Triviale verändert, er sieht nun so aus

               // Puffer vergleichen
               int i = 0;
             VerglAnfang:
    ///////    for (int i = 0; i < VerglLen; i++)
    ///////    {
                  if (ByPu1[i] != ByPu2[i]) // ungleiche Bytes gefunden ?
                  {
                     GesDiff++; // Differenzen (Anzahl Bytes)
                  }
                  i++;
                  if (i < VerglLen) { goto VerglAnfang; }
    ///////     } // for (uint i = 0; i < VerglLen; i++)
    

    Die for-Schleife habe ich durch ein simples IF ersetzt.
    Dadurch sinkt die Laufzeit von 7'20" auf 6'32".
    Immerhin wird sie 2.220.933.937 mal durchlaufen...
    Immer noch wesentlich länger, als bei Delphi.

    Nochmals meine Ausgangsfrage:
    Kann man in C# ASM-Befehle, wie bei C++ verwenden ?



  • @hkdd sagte in Assembler-Code wegen Laufzeit-Optimierung benutzen:

    Nochmals mein Ausgangsfrage:
    Kann man in C# ASM-Befehle, wie bei C++ verwenden ?

    Was hast Du schon unternommen um das selbst herauszufinden? Das zB?


    @wob sagte in Assembler-Code wegen Laufzeit-Optimierung benutzen:

    Hierfür würde ich niemals selbstgeschriebenen Assembercode verwenden. Der Compiler ist normalerweise besser im Optimieren.

    Das.



  • Lass das mit dem Assembler bleiben. Wartung und so.

    Hast du das schon gefunden: https://stackoverflow.com/questions/43289/comparing-two-byte-arrays-in-net

    Schau dir das unsafeCompare oder EqualBytesLongUnrolled an.
    Oder probiere die Lösung mit dem memcmp-Aufruf (wenn deine Daten fast immer gleich sind).

    Edit: oder sogar die SSE/AVX-Lösung (weiter unten ab .NET 3)



  • @Swordfish sagte in Assembler-Code wegen Laufzeit-Optimierung benutzen:

    Was hast Du schon unternommen um das selbst herauszufinden

    Das habe ich schon gelesen, finde alles aber sehr umständlich.
    In Delphi und C++ kann man auch den Asm-Code befehlsweise debuggen und die Registerinhalten anschauen.

    Fazit für mich:
    Zeitkritische Aufgaben nicht in C# erarbeiten, sondern dafür Delphi oder C++ benutzen.
    Ich mache nach jeder Sicherung zur Kontrolle ein derartiges Compare.
    Wenn ca. 2 GB bereits über 7 Minuten dauern, dann wären die Ordner mit den Videos, wo Terra-Bytes zu vergleichen sind, nahezu unlösbar, das würde mehrere Stunden dauern.



  • @hkdd sagte in Assembler-Code wegen Laufzeit-Optimierung benutzen:

    auch bei VS2019 mit C++ darf/kann man das problemlos tun

    Nee, nicht problemlos. In 64 Bit kannst du kein Inline Assembler verwenden. Für 32 und 64 Bit musst du in Assembler so oder so unterschiedlichen Code schreiben. Sinnvoll wäre das für sowas überhaupt nicht.



  • @hkdd Hey, Alter, jetzt mal ernsthaft. Nimm die memcmp()-Implementierung der cstdlib und sei happy. Schneller wirds nicht. Und wenn Du schon irgendwelche Fragen zu Performance meinst stellen zu müssen dann zeige vollständigen Code, wie und womit er compiliert wird und wie Du gemessen hast. Alles andere ist Quatsch mit Sauce.

    // edit: ach so, sorry, vergessen

    Mit freundlichen Grüßen,

    Fisch



  • @TGGC sagte in Assembler-Code wegen Laufzeit-Optimierung benutzen:

    Der Code macht 2% ALU Auslastung und 100% Speicherauslastung. Die genaue Implementation ist daher irrelevant, solange das Prefetchen erkannt werden kann.

    Nein. Bei linearen Zugriffen ist Speicher lange nicht so langsam wie viele glauben.

    Als Beispiel: Coffee Lake hat ne Speicherbandbreite von 40 GB/s (Hauptspeicher). Bei 4 GHz Takt entspricht das 10 B/c. Im L3 Cache sind es dann 32 B/c und L2 sogar 64 (beim Lesen). Mit einer einfachen Schleife die DWORDs vergleicht schaffst du das nicht - ein Schleifendurchlauf braucht in jedem Fall nicht < 1 Zyklus, vermutlich eher >= 2. D.h. das wären dann 8 Bytes in 2 Zyklen also 4 B/c. Also nichtmal die Hälfte der Hauptspeicherbandbreite. Und weit weg von dem was der Cache liefern kann.

    Deswegen gibt es auch recht aufwendige Optimierungen in den diversen memcmp Implementierungen. Mit denen kommt man dann wesentlich näher an die theoretisch mögliche Bandbreite.



  • @Swordfish sagte in Assembler-Code wegen Laufzeit-Optimierung benutzen:

    Nimm die memcmp()-Implementierung der cstdlib und sei happy

    Das habe ich gemacht, es werden die Puffer mit memcmp vergleichen, falls Differenzen, werden diese im Anschluss mit der for-Schleife ermittelt, im Prinzip, wie mit meiner Ass-Routine. Die Laufzeit von bisher 6'31" sinkt auf "tolle" 6'25" ( mit Delphi+Asm = 49").
    Compiliert wird mit Visual Studio 2019 Community Version 16.1.6.
    Die Zeiten werden vom Programm selbst ermittelt und protokolliert.
    (Start - Ende - Dauer)

    Der Taskmanager zeigt eine CPU-Auslastung von knapp 20%.
    Wenn gut 2 TB Daten verglichen werden, dann müssen die ja auch von zwei Dateien gelesen werden, also das doppelte etwa 4 TB. Das Lesen von Datei 1 und 2 erfolgt nacheinander.

    Block1 lesen - Block2 lesen - Block1+2 vergleichen

    Die CPU fällt hauptsächlich beim Vergleichen an.

    
                    FileStream fs1 = new FileStream(Dsn1, FileMode.Open, FileAccess.Read);
                    FileStream fs2 = new FileStream(Dsn2, FileMode.Open, FileAccess.Read);
    
                    long VerglRestLen = VerglLenGes;
                    long VerglOffset = 0;
                    long VerglLen = 0;
                    int isDiff = 0;
    
                    ByPu1 = new byte[MaxPufL + 0x1000];
                    ByPu2 = new byte[MaxPufL + 0x1000];
    
    
                NxtPuVergleich:
    
                    if (VerglRestLen > MaxPufL)
                    { VerglLen = MaxPufL; }
                    else
                    { VerglLen = VerglRestLen; }
                                    
                    fs1.Seek(VerglOffset, SeekOrigin.Begin);
                    fs1.Read(ByPu1, 0, (int)VerglLen);
    
                    fs2.Seek(VerglOffset, SeekOrigin.Begin);
                    fs2.Read(ByPu2, 0, (int)VerglLen);
    
                    isDiff = memcmp(ByPu1, ByPu2, VerglLen);
                    if (isDiff==0) { goto EndePuVergl; }
    
                    // Puffer mit Differenzen vergleichen
                    for (int i = 0; i < VerglLen; i++)
                    {
                        if (ByPu1[i] != ByPu2[i]) // ungleiche Bytes gefunden ?
                        {
                            if (AnzDiff == 0) // Erste Differenz dieser Datei ?
                            {
                                sZei = sZei + "- ==> Unterschiede gefunden";
                                if (!Flag_F)
                                {
                                    PutListV(sZei);
                                }
                                sZei = "";
                            }
                            rc = 1; // es gibt Differenzen
                            isDiff = 1;
                            AnzDiff++;
                            GesDiff++; // Differenzen (Anzahl Bytes)
    
                            if (AnzDiff <= Anz_M)
                            {
                                ZeigeDiff(VerglOffset+i, ByPu1[i], ByPu2[i], (int)VerglLen);
                            }
                        }
                    } // for (uint i = 0; i < VerglLen; i++)
    
              EndePuVergl:
    
                    VerglRestLen = VerglRestLen - VerglLen; // neue Restlänge
    
                    GesLen = GesLen + VerglLen;
                    GesVglLen = GesVglLen + VerglLen;
    
                    Form1Text = "CompHK " + AllTrim(EdStrLo(GesLen, 15)) + " Bytes " + TxPfad3;
    
                    if (VerglRestLen > 0)
                    {
                        VerglOffset = VerglOffset + VerglLen;   // Offset nächster Block
                        goto NxtPuVergleich;
                    }
    
                    fs1.Close();
                    fs2.Close();
    
    


  • @hustbaer sagte in Assembler-Code wegen Laufzeit-Optimierung benutzen:

    Als Beispiel: Coffee Lake hat ne Speicherbandbreite von 40 GB/s (Hauptspeicher). Bei 4 GHz Takt entspricht das 10 B/c.

    Und? Coffee Lake hat 4 Integer ALUs pro Core, kann also 4 Vergleiche auf einmal machen, d.h. selbst bei int32 wären das 128 Byte Daten die verarbeitet werden. Mit Streaming Instruction und Multicores kann man das noch verzigfachen. Bringt aber nichts, weil man schon weit über der lesbaren Datenmenge ist.



  • @hkdd Hast du dir in meinem oben geposteten StackOverflow-Link die Antworten angeschaut? Insbesondere die von "Mr Anderson" und mit deinem Vergleich verglichen? Vielleicht kann man den Code auch ändern, sodass statt true/false gleich ein Offset rauskommt?

    Wozu sind die Seeks in deinem Code?



  • @TGGC sagte in Assembler-Code wegen Laufzeit-Optimierung benutzen:

    @hustbaer sagte in Assembler-Code wegen Laufzeit-Optimierung benutzen:

    Als Beispiel: Coffee Lake hat ne Speicherbandbreite von 40 GB/s (Hauptspeicher). Bei 4 GHz Takt entspricht das 10 B/c.

    Und? Coffee Lake hat 4 Integer ALUs pro Core, kann also 4 Vergleiche auf einmal machen, d.h. selbst bei int32 wären das 128 Byte Daten die verarbeitet werden.

    Nein, es wären 128 Bit. Also 16 Byte. Und das auch nur wenn man entweder Loop-Unrolling macht (also im generierten Maschinencode, nicht was die CPU evtl. selbst macht) oder die CPU von sich aus mehr als einen Schleifendurchlauf pro Zyklus schafft.

    Du hast behauptet dass die genaue Implementierung irrelevant ist. Wenn diese Behauptung wahr wäre, dann müsste es auch ohne Loop-Unrolling gehen. Geht aber nicht, weil die CPU nicht mehr als einen Schleifendurchlauf pro Zyklus schafft.

    Mit Streaming Instruction und Multicores kann man das noch verzigfachen. Bringt aber nichts, weil man schon weit über der lesbaren Datenmenge ist.

    Man braucht weder Streaming Instructions noch mehrere Cores. Man braucht bloss Loop Unrolling und idealerweise breite Register (Vector-Register). Und genau das machen die optimierten memcmp Implementierungen.



  • Die Loop wird bei der Umsetzung in Microops und durch Register Renaming sowieso unrolled.



  • @TGGC
    Äh, nein, der Loop wird nicht unrolled!?!

    Alleine schon das Inkrementieren der Induktionsvariable "serialisiert" dir den Loop. Klar kann die CPU auch die Induktionsvariable "renamen", aber das ändert nichts daran dass Schleifendurchlauf 2 eine Dependency auf den Wert von Schleifendurchlauf 1 hat, Schleifendurchlauf 3 eine auf den Wert von Schleifendurchlauf 2 usw. Dadurch kommst du nicht unter 1 Zyklus pro Schleifendurchlauf. Zumindest nicht ohne ganz wilde Klimmzüge, und ich kann mir echt nicht vorstellen dass aktuelle CPUs das können.

    Wenn man den Loop dagegen selbst unrolled ist die Induktionsvariable kein Problem, man macht einfach nur ein Update pro Iteration und nicht ein Update pro Element. Genau so kann ein guter Optimizer mit sowas umgehen. Der hat aber im Gegensatz zur CPU auch ausreichend Zeit sich den Code anzusehen und rauszufinden wie er das alles optimal in Maschinencode giessen kann.



  • @wob sagte in Assembler-Code wegen Laufzeit-Optimierung benutzen:

    Wozu sind die Seeks in deinem Code?

    Bei großen Dateien ( > als max Pufferlänge) lese ich immer nur Blöcke in die Max-Länge ein und einen Restblock, falls erforderlich
    Seek beginnt mit 0 und dann immer mit dem Beginn des nächsten Blockes.

                    if (VerglRestLen > 0)
                    {
                        VerglOffset = VerglOffset + VerglLen;   // Offset nächster Block
                        goto NxtPuVergleich;
                    }
    


  • @wob sagte in Assembler-Code wegen Laufzeit-Optimierung benutzen:

    Vielleicht kann man den Code auch ändern, sodass statt true/false gleich ein Offset rauskommt

    Die meisten Dateien sind identisch, so dass die Behandlung von evtl. Differenzen unrelevant ist. Wenn eine Differenz auftritt, dann kommt es auf ein paar Sekunden nicht an.
    In meinem Test habe ich ja immer die Ordner mit sich selbst verglichen.
    Es geht um identische Dateien und die Zeit, um festzustellen, ob sie identisch sind.

    Ich habe jetzt ein wirklich funktionierendes Beispiel eines Inline-Assemblers gefunden, da werde ich versuchen meinen Compare-Code für einen Block mit Puffer1+2 auf diese Weise in das Programm einzufügen.
    Mit DLLs aus C++ hatte ich keinen Erfolg.
    https://stackoverflow.com/questions/3216535/x86-x64-cpuid-in-c-sharp



  • @hkdd
    Hast du es mal mit

    [DllImport("ntdll.dll", EntryPoint = "RtlCompareMemory")]  
    static extern IntPtr RtlCompareMemory(IntPtr Source1, IntPtr Source2, IntPtr length);  
    

    probiert?


Anmelden zum Antworten