Assemblercode - Noch optimierbar?

  • Hi allerseits,

    bevor ich jetzt mit der Tür ins Haus falle, will ich eben mal die Rahmengeschichte zum Besten geben. Ich habe dieses Jahr mein Informatik-Studium begonnen. Im Modul OOP haben wir neulich im Praktikum einen einfachen Algorithmus zum Berechnen von Pi behandelt (die Leibniz-Reihe). Ein paar anderen erfahreneren Kommilitonen und mir war die reine Implementierung in Java ein bisschen zu fad, daher kamen noch ein paar andere Sprachen hinzu. Irgendwann wurde dann aus der reinen Implementierung ein kleiner Wettkampf um Performance/Geschwindigkeit. Gut, die Implementierungen in PHP oder JavaScript waren dann nicht mehr wirklich konkurrenzfähig. Auch Lua und Python waren nicht die besten Kandidaten. Tatsächlich war die Java-Variante zunächst die schnellste - bis mein C-Programm fertig war. Das war dann dezent schneller. Ein Kommilitone konnte das nicht auf sich sitzen lassen und hat eine eher unbekannte, ungewöhnliche Sprache verwendet: Pony. Auf meinem Netbook (Intel Celeron) braucht die Pony-Variante bei 10.000.000 Schleifendurchläufen mitsamt Ergebnisausgabe knapp 230 ms. Knapp doppelt so schnell wie mein C-Code...
    Also musste jetzt das letzte Register gezogen werden (haha, Register...): Assembler. :thumbup:

    Ich bin bei weitem kein Assembler-Profi. Ich kann zwar damit programmieren, aber die meisten Tricks kenne ich vermutlich nicht. Da das drumherum um den Algorithmus relativ kurz ist, habe ich direkt alles in Assembler geschrieben. Für knappe 20 ms schneller hat es auch gereicht, auf meinem Rechner also ~210 ms im Schnitt. Ich glaube nicht, dass das noch jemand schlägt. Aber ich bin mir fast sicher, dass es da noch Optimierungsmöglichkeiten gibt. Da sich hier auch einige Leute schon ausführlich mit Assembler auseinandergesetzt haben, dachte ich, ich frage mal, ob jemandem noch etwas einfällt. Kompiliert wurde das Ganze bei mir mit nasm, gelinkt mit gcc unter Debian 8. Die Zeiten wurden einfach im Terminal mit time gemessen.

    Ich denke mal, so ein paar ASM-Kniffe zu kennen, schadet keinem. Hat auf jeden Fall das Potential, sich zu einer spannenden Diskussion zu entwickeln.

    Grüße!

  • Was mir direkt auffällt ist folgendes:
    (1.) Du benutzt kaum Prozessorregister, aber dafür umsomehr RAM-Speicherplätze. Versuch mal das Programm so umzubasteln, dass du möglichst wenige Ram-Zugriffe hast. Im "Notfall" (falls kein Platz mehr ist) pop und push benutzen (ist schneller als ein RAM Zugriff).
    (2.) (ab hier reine Spekulation) Ich kann mich irren, aber mit dem Schleifenzähler müsste es möglich sein rückwärts zu laufen (bei 10 Mio starten), und dann aus dem "dec ecx; cmp ecx, 0; jnz loop" das cmp rauszunehmen und zu "dec ecx; ja loop" zu machen. Ggf (muss man testen) ist auch sub ecx, 1 schneller als dec ecx (je nach Prozessor).
    Edit1: (2.1) (habs jetzt nachgelesen). Schleifenvariable in ecx stecken (nicht edx, das ist doch nicht schön :D). Anschließend kann man entweder wie in (2.) verfahren, oder mit dem "loop loop1" (müsstest deinen loop ggf umbenennen sonst ist man verwirrt^^) gleichzeitig "dec ecx" und "ja loop1" ausführen. (hier muss man auch mit der Stopuhr schauen was von den beiden Varianten schneller ist)
    (3.) Andy wird bestimmt noch irgendwas zum SSE sagen, was hier fehlt :D

    Edit2: Spalte die Leibniz Summe auf.
    Du hast in jedem Schleifendurchlauf ein not [sign], und eine Sprungstelle an der du entscheidest ob du addierst oder subtrahierst. Spalte die Summe in 2 Summen auf:
    (1.) Summe von 0 bis N/2 [1/(4k+1)] -> Das sind alle positiven Werte bis N
    (2.) Summe von 1 bis N/2 [1/(4k-1)] -> Das sind alle negativen Werte bis N
    Jetzt kannst du die beiden Werte voneinander abziehen und hast damit kein wechselndes Vorzeichen und kein unnötiges If in der Schleife. (so wie du das mit dem [store] gelöst hast (das muss ein ein Register! Da wird doch andauernd was addiert :D), kann man das hier auch machen. Nur mit [store] += 4)

    lg
    M

  • Hi,

    Ich glaube nicht, dass das noch jemand schlägt. Aber ich bin mir fast sicher, dass es da noch Optimierungsmöglichkeiten gibt. Da sich hier auch einige Leute schon ausführlich mit Assembler auseinandergesetzt haben, dachte ich, ich frage mal, ob jemandem noch etwas einfällt.

    Ich glaube schon, dass das noch jemand schlägt :D
    Optimierungsmöglichkeiten: Neben den von Mars angesprochenen Möglichkeiten fällt mir sofort auf, dass du den Stack der FPU nicht nutzt! Dies sind 8 "Speicherstellen" in Registerform! Mal davon abgesehen, dass die FPU-Befehle schon "schnarchlangsam" sind (dazu später mehr 8o ), speicherst du bspw. per fstp qword [pi], nur um es im Schleifenkopf kurz darauf wieder zu laden...mit [store]übrigens genauso...
    Clever wird der FPU-Stack eingesetzt, indem "Konstanten", bspw. [four], permanent im Stack liegen und die weiteren Berechnungen ausschliesslich mit den registern durchgeführt werden, ohne permanent in den Speicher zu schreiben bzw. zu laden.
    Das  fld qword [pi] gehört vor den Loop (zusammen mit den anderen benutzten Variablen), und das fstp qword [pi] hinter den Loop. Du ersparst dir damit alleine 2*10E6 Speicherzugriffe/Takte!

    (3.) Andy wird bestimmt noch irgendwas zum SSE sagen, was hier fehlt

    Und wie er das wird! 8o
    Die SEE-Befehle ADD/SUB/DIV werden in einem Takt abgewatscht, die schreien geradezu nach Benutzung! Und werden von jedem Prozessor, der in diesem Jahrtausend gefertigt wurde, unterstützt.
    Auch ggf. erforderliche Vergleichsbefehle gibt es in SSE.
    Ich werde dazu später mal ein Beispiel machen...
    Inwieweit man SIMD (Single instruction, multiple data) einsetzt, ist zu testen, da Algorithmusabhängig. Wenn man 2 Berechnungsschritte gleichzeitig in einem Register abwickelt, braucht man nur noch die Hälfte der Schleifendurchläufe/Berechnungen!

  • Mit FreeBasic komme ich auf 1136.025325860828 ms für 10.000.000 Iterationen -> Intel i5-4300U @ 2594 MHz

    Der generierte Assembler Code:


    FB Code:

    Auch am Arsch geht ein Weg vorbei...

    ¯\_(ツ)_/¯

  • Habs auch mal in Go geschrieben (natürlich auch unoptimiert, sonst macht das ja keinen Spaß :D)

    PS: Leibnitz.

    Edit1: Ich komme mit Go auf 175ms. Allerdings weiß ich nicht wie die anderen hier geposteten Programme vergleichsweise auf meinem Rechner abschneiden, hab hier ausschließlich AutoIt und Go am Start. Die .exe aus der Konsole heraus ausführen (sonst geht das Fenster direkt wieder zu und man kann das Ergebnis nicht lesen).

  • Hier mal meine unoptimierte FPU-Version.
    Unoptimiert deswegen, weil allein das FDIV schon (je nach Prozessor) 40 (!!!) Takte benötigt....aber egal, eine schöne Fleißübung :D
    Allerdings habe ich es mir nicht nehmen lassen, KEINE einzige Speicherzelle zu verwenden :party: Selbst die Rückgabe an AutoIt erfolgt über das ST0-Register, wenn als Rückgabetyp "double" verwendet wird.
    Ca. 100ms auf meinem Laptop AMD A6-3400M APU, da sollte bei euren INTEL noch einiges gehen!

    Spoiler anzeigen

    Damit mal richtig Spass aufkommt, werde ich auch die SSE/SIMD-Variante in Angriff nehmen, ich vermute Zeiten um die 20-30ms, also Faktor 3-4 zum FPU-Code!
    Da werde ich auch "optimieren", d.h. nach Möglichkeit die Pipelines versuchen parallel zu füttern und auch "natürlich" keinerlei Speicherzugriffe :rofl: . Auf einen C(++)-Code mit einem "schnellen" Ergebnis bin ich schon mal gespannt, es soll ja Leute geben, die meinen, ein Compiler macht das automatisch...

    Und um dann komplett hardcoremäßig abzurocken könnte man mal versuchen, ob sich der Algorithmus so weit parallelisieren lässt, um per OpenCL auf einer Grafikkarte zu laufen.
    Aber da wird schon "Double" das Problem sein ;( , das ist ca. 20x langsamer als "Float".
    Für OpenCL würde ich auf einer 50€-Grafikkarte bzw auf meiner integrierten APU realistisch 3-4 Millisekunden anpeilen. Und dabei gehen noch 80% der Zeit für den Daten-Transfer von und zur Grafikkarte flöten X/

  • Die ASM Version von Andy läuft bei mir in ca. 160ms. Damit ist sie knapp schneller als die Go variante.
    (Hab hier einen Intel Q6600 Prozessor mit 2,4ghz, daher die miese Geschwindigkeit :D)

  • Von wem redest du bitte?
    Der Herr heißt Leibniz ohne t. Das ist höchstens ein UGS siehe Wikipedia. Und das auch eher noch wegen seines Vaters.

    Es gibt sehr viele Leute, die glauben. Aber aus Aberglauben.
    - Blaise Pascal

  • Die ASM Version von Andy läuft bei mir in ca. 160ms. Damit ist sie knapp schneller als die Go variante.

    Hmmm, die GO-Variante ist 64-Bit und nutzt schon fleißig SSE-Befehle :D
    Kannst du 32-Bit-Code erzwingen und den dann mal bereitstellen? Ich gehe davon aus, dass der compilierte 32-Bit-Code kein Stück langsamer ist!

    Hab hier einen Intel Q6600 Prozessor mit 2,4ghz

    ggf sollten wir mal eine Suite zusammenstellen mit den einzelnen Programmen, dann könnte man auch vergleichen, welche Befehle auf dem einen oder anderen Prozessor schneller oder langsamer ausgeführt werden! So wie ich die "alten" Prozessoren kenne, hat AMD da klare Vorteile bei der FPU gegenüber INTEL gehabt. Aber ich denke die "neueren" INTEL´s werden da noch einiges schneller laufen!

  • Komisch, dass ich mit FB so bescheiden abschneide.

    Mit VS2015 bekomme ich immerhin 328ms.

    Ich habe absolut keinen Plan, wie ich den C++ Code optimieren kann geschweige denn, wie ich VS optimal einstellen kann.

    Hier der C++ Code:


    Keine Ahnung, wie ich die Zeit als x64 messen kann...

    Wenn interesse besteht, kann ich die kompilierte EXE hochladen.


    @Andy: auf meinem Schleppi -> Intel i5 @2,5 GHz

    Code
    : $pi Leibnitz = 3.14159255358979
    : Time [ms] = 71.4103606789976

    Auch am Arsch geht ein Weg vorbei...

    ¯\_(ツ)_/¯

  • Einen Grund kann ich dir sagen: das pow(-1, k) frisst. Selbst wenn das intern effektiv gehandelt wird hast du damit mehr Rechenaufwand als wenn du eine Vorzeichenvariable einführst.

  • Einen Grund kann ich dir sagen: das pow(-1, k) frisst. Selbst wenn das intern effektiv gehandelt wird hast du damit mehr Rechenaufwand als wenn du eine Vorzeichenvariable einführst.

    Stimmt! =O
    Bezgl. FB: hätte nicht gedacht, dass die Laufzeit von 1136ms auf 136ms sinkt, wenn ich das so mache wie du es gemacht hast.


    Edit: mit VS2015 habe ich 56ms hinbekommen.

    Auch am Arsch geht ein Weg vorbei...

    ¯\_(ツ)_/¯

  • Ich werfe mal SSE/SIMD in die Runde...
    Auf meinem AMD-Laptop 37ms bei 1.4Ghz, die Intels müssten da weit davonziehen, da das DIV dort massiv optimiert wurde

    Die 128Bit breiten XMM-Register werden in 2x64Bit (je ein double) aufgeteilt.
    In den linken 64Bit werden die positiven Teiler summiert, im rechten Registerteil die negativen Teiler.
    Die gesamte Schleife besteht AUS NUR 4 ASM-Befehlen!
    Nach der Schleife die beiden Registerhälften addieren zu PI/4 und mit 4 multiplizieren ergibt PI.

    Da die FPU intern mit 80 Bit Genauigkeit rechnet, aber bei SSE/SIMD nur 64 Bit zur Verfügung stehen, ergeben sich die "Unterschiede" in den letzten 3 Nachkommastellen.
    Geht da noch mehr? :theke:

  • Top :thumbup:

    Code
    : $pi Leibnitz = 3.14159255358581
    : Time [ms] = 28.442786194246

    Hier die kompilierten Versionen von VS2015 C++ und FreeBasic als Anhang.


    [Blockierte Grafik: http://www.hannover.de/var/storage/images/media/01-data-neu/bilder/redaktion-hannover.de/2016/2016_04/leibniz-butterkeks/13439944-1-ger-DE/Leibniz-Butterkeks_image_full.jpg]

  • hey leute
    super interessant das thema und spannend wie hier an allen ecken und kanten optimiert wird :)

    hier mal meine testergebnisse.

    Andy:

    ASM:
    : $pi Leibnitz = 3.14159255358581
    : Time [ms] = 19.2128344097284

    UEZ:
    VS2015_C++_Leibniz-PI_x64
    Pi: 3.1415925535897915 38.149658000000002 ms.
    VS2015_C++_Leibniz-PI_x86:
    Pi: 3.1415925535897915 38.162696000000004 ms.
    FB_Leibniz-PI_x86
    PI: 3.141592553589792 88.51868021884002 ms for 10000000 iterations.

    mein pc:
    intel core i3-4170 3.7ghz
    win10x64
    8 gb ram

  • Meins wird gernicht mit verglichen, hat niemand Go, oder traut sich niemand eine fremde .exe zu starten? ;(
    (Ich bin schon eine Weile hier im Forum, ich stecke keine Viren in Exen, die ist wirklich so groß nachdem der Kompiler sie ausspuckt)

  • Test System (CPU): Intel(R) Core(TM) i5-4300U @2,5 GHz

    Mars:

    Code
    Benötigte Zeit [ms]:  86
                     PI:  3.1415925535897933


    Andy:

    Code
    : $pi Leibnitz = 3.14159255358581
    : Time [ms] = 28.442786194246


    UEZ:

    Code
    1) VS2015_C++_Leibniz-PI_x64
    Pi: 3.1415925535897915  53.861474000000001 ms for 10000000 iterations.
    2) VS2015_C++_Leibniz-PI_x86
    Pi: 3.1415925535897915  54.446914999999997 ms for 10000000 iterations.
    3) FB_Leibniz-PI_x86
    PI: 3.141592553589792       127.8968800324947 ms for 10000000 iterations.

    Auch am Arsch geht ein Weg vorbei...

    ¯\_(ツ)_/¯

  • Ach du je, da habe ich ja was losgetreten. :D
    Ich kann auch mal eine kompilierte Variante anhängen - allerdings nur als ELF. Ich habe zur Zeit keine Windows-Installation mehr.

    Manche von den anfangs genannten Optimierungstipps hatte ich auch schon mal festgestellt - aber keinen Geschwindigkeitsvorteil erreicht. Daher habe ich sie zwecks Lesbarkeit wieder rückgängig gemacht. Als separat kompilierte Assembler-Datei ist der erste Vorschlag von Andy auf meinem System bspw. genau so schnell wie mein Assembler-Code aus dem ersten Post. Da kommt natürlich das Laden der Binärdatei noch hinzu (5.7 KB), Linux' time misst die Laufzeit einer Binärdatei inkl. Ladevorgang.

    Ich probiere morgen mal das ein oder andere hiervon aus und schaue mal, ob das noch was bringt.