CPython vs. Numba vs. Rust

Alles, was nicht direkt mit Python-Problemen zu tun hat. Dies ist auch der perfekte Platz für Jobangebote.
Benutzeravatar
Dennis89
User
Beiträge: 1503
Registriert: Freitag 11. Dezember 2020, 15:13

Hallo,

in den letzten Tage habe ich mich etwas damit beschäftigt, was denn so passiert, wenn ich einen Code ausführe. Ursprung war ein Python-Code den ich "gefunden" hatte und dem man auch schon ansieht, dass man das so nicht programmieren würde. Ich habe ihn wie er ist einfach mal in eine Funktion gesteckt und die Zeit gemessen.

Code: Alles auswählen

from time import monotonic



def get_prime():
    prim_counter = 0
    prim_pruefzahl = 0
    zahl = 1
    prime = []

    while prim_counter < 10001:
        for teiler in range(1, zahl):
            if zahl % teiler == 0:
                prim_pruefzahl += 1
        if prim_pruefzahl == 1:
            prime.append(zahl)
            prim_counter += 1
        else:
            prim_pruefzahl = 0
        zahl += 1
    return prime


def main():
    start = monotonic()
    print(get_prime()[-1])
    print(f"Dauer {monotonic() - start:.2f} s")


if __name__ == '__main__':
    main()

Als Ergebnis kam auf meinem Laptop:

Code: Alles auswählen

[dennis@dennis ~]$ ~/PycharmProjects/Forum/.venv/bin/python ~/PycharmProjects/Forum/cpython.py 
104759
Dauer 511.75 s
Ich schreibe mal meine Gedanken, wie ich mir das vorstelle und bitte um Berichtigung. Wenn ich den Code ausführe, dann geht der Interpreter darüber und checkt die Syntax und ob alle Namen definiert sind. Wenn das passt dann wird je nach Code, da wo eben der erste ausführbare Code steht, Zeile für Zeile in Maschinencode übersetzt und ausgeführt. Und zwar immer nach einander (?) Also übersetzen, ausführen, übersetzen, ausführen. Da in Python alles ein Objekt ist und da die Objekte verschiedene Datentypen besitzen können, muss das alles zur Laufzeit auch überprüft werden.
Wir eine Funktion bei jedem Aufruf erneut übersetzt? Ja oder?

Es gibt `Numba` mit dem Just-in-time-Dekorator. Das wollte ich testen:

Code: Alles auswählen

from time import monotonic
from numba import njit

@njit
def get_prime():
    prim_counter = 0
    prim_pruefzahl = 0
    zahl = 1
    prime = []

    while prim_counter < 10001:
        for teiler in range(1, zahl):
            if zahl % teiler == 0:
                prim_pruefzahl += 1
        if prim_pruefzahl == 1:
            prime.append(zahl)
            prim_counter += 1
        else:
            prim_pruefzahl = 0
        zahl += 1
    return prime


def main():
    start = monotonic()
    print(get_prime()[-1])
    print(f"Dauer {monotonic() - start:.2f} s")


if __name__ == '__main__':
    main()

Ergebnis:

Code: Alles auswählen

[dennis@dennis ~]$ ~/PycharmProjects/Forum/.venv/bin/python ~/PycharmProjects/Forum/njit_test.py 
104759
Dauer 17.73 s
Was macht Numba genau? Ich weis nur, dass die Funktion einmal übersetzt wird und dass der Code irgendwie noch optimiert wird. Aber wie oder was wird da optimiert, damit das fast 29x so schnell ist?
Wenn ich davon ausgehe, dass das dynamische an Python der Punkt ist, der viel Zeit braucht, dann war es für mich naheliegend, dass wenn ich eine statische Sprache verwende und direkt den kompilierten Code ausführe, dann müsste ich am schnellsten sein.
Also habe ich den Code stumpf in `Rust` übersetzt:

Code: Alles auswählen

use std::time::Instant;


fn get_prime() -> Vec<i32> {
    let mut prim_counter: i32 = 0;
    let mut prim_pruefzahl: i32 = 0;
    let mut zahl: i32 = 1;
    let mut prime: Vec<i32> = Vec::with_capacity(1000);

    while prim_counter < 10001 {
        for teiler in 1..zahl {
            if zahl % teiler == 0 {
                prim_pruefzahl += 1;
            };
	};
	if prim_pruefzahl == 1 {
            prime.push(zahl);
            prim_counter += 1;
        } else {
            prim_pruefzahl = 0;
        };
	zahl += 1;
    }
    prime

}


fn main() {
    let start = Instant::now();
    let prime = get_prime();
    println!("{:?}", prime.last());
    println!("Dauer: {:.2?}", start.elapsed());
}


Ergebnis:

Code: Alles auswählen

[dennis@dennis ~]$ ./rust_test/main 
Some(104759)
Dauer: 61.26s
Habe ich nicht erwartet. Liegt das an der Optimierung von `Numba` oder wieso ist das langsamer?

Wird der Code eigentlich direkt, egal von welcher Sprache, in Maschinencode übersetzt? Weil ich kann mit `dis` in Python den Assembler Code anschauen, in den der Python Code übersetzt wird. Wird der dann vor der Ausführung noch einmal von `Assembler` übersetzt? So wie ich das verstanden habe, ist `Assembler` die hardwarenähste Sprache, die noch von Menschen lesbar ist.(?)

Ich weis das sind wieder viele Fragen, aber ich finde es einfach so interessant was denn im Detail passiert, wenn ich einen Code ausführe.
Freue mich wenn ihr etwas Zeit für Erklärungen habt.

Danke und Grüße
Dennis
"When I got the music, I got a place to go" [Rancid, 1993]
Benutzeravatar
sparrow
User
Beiträge: 4501
Registriert: Freitag 17. April 2009, 10:28

Ich muss deine Messwerte wahrscheinlich berichtigen.
Hier sieht das für deine Codes so aus:

Code: Alles auswählen

Rust: 
Some(104759)
Dauer: 6.40s

Python + numba:
104759
Dauer 7.64 s

Python:
104759
Dauer 267.56 s
Beim Bauen des Rust-Programms musst du die Optimierungen an und dafür den Debug-Kram abschalten (--release).
Benutzeravatar
__blackjack__
User
Beiträge: 13919
Registriert: Samstag 2. Juni 2018, 10:21
Wohnort: 127.0.0.1
Kontaktdaten:

@Dennis89: Wenn Du das ausführst wird die Syntax geprüft, aber an der Stelle nicht ob alle Namen definiert sind. Das weiss man ja gar nicht. Namen werden zur Laufzeit definiert. Einfaches Beispiel:

Code: Alles auswählen

if random.random() > 0.5:
    answer = 42
Ob `answer` definiert wird oder nicht, weiss man erst nach dem der Code gelaufen ist.

Der Code wird nach der Syntaxprüfung bei CPython in Bytecode übersetzt. Und zwar das komplette Modul. Und dann wird der Code im Modul ausgeführt, also der Bytecode auf Modulebene aus dem Übersetzungsschritt. Der Bytecode wird bei CPython falls möglich, und vom Nutzer nicht unterbunden, in einer *.pyc-Datei zwischengespeichert, damit man Syntaxprüfung/Übersetzung in Bytecode nicht jedes mal machen muss, sondern nur, wenn sich der Quelltext geändert hat.

Ein abwechselndes übersetzen/ausführen findet nicht statt innerhalb eines Moduls, und es wird auch nicht immer wieder, sondern nur einmal übersetzt. Es wird auch nichts in nativen Maschinencode übersetzt. Der CPython-Interpreter interpretiert den Bytecode.

Was Numba im Detail macht weiss ich nicht, aber JIT-Compiler übersetzen in der Regel den (Byte)Code beim Aufruf in Maschinencode. Gegebenenfalls bei jedem Aufruf bei dem andere Datentypen übergeben werden, für die vorher noch kein Maschinencode generiert wurde.

Eine „egal von welcher Sprache“-Frage kann man in der Regel nicht pauschal für alle Sprachen beantworten. Zumal Sprachen nix übersetzen, sondern konkrete Implementierungen. Bei Python ist CPython zwar die Referenzimplementierung, aber die Spezifikation schreibt den Bytecode nicht vor — man könnte auch eine Laufzeitumgebung für die JVM oder .NET schreiben und Python in Java-Bytecode oder .NET-Assemblies übersetzen. Meistens wird C in Maschinencode übersetzt, es gibt aber auch C-Interpreter. Viele Compiler erzeugen nicht direkt Maschinencode sondern erst einmal Code in einer Zwischensprache, die dann weiter übersetzt wird. Manchmal was eigenes, manchmal etwas universelleres wie LLVM, manchmal auch C-Quelltext.

Das was man mit dem `dis`-Modul anschauen kann, ist der Bytecode für die Python-VM, den CPython wie gesagt zum ausführen interpretiert. Diese Assembler-Sprache ist nicht hardwarenah, weil es die Python-Bytecode-VM nicht in Hardware gibt.
“I am Dyslexic of Borg, Your Ass will be Laminated” — unknown
narpfel
User
Beiträge: 688
Registriert: Freitag 20. Oktober 2017, 16:10

Dennis89 hat geschrieben: Freitag 11. Oktober 2024, 20:21 Wenn ich den Code ausführe, dann geht der Interpreter darüber und checkt die Syntax
Ja.
und ob alle Namen definiert sind
Nein. Sieht man zum Beispiel hier:

Code: Alles auswählen

def f():
    print(this_name_does_not_exist)
Dieses Python-Programm kann ohne Fehler ausgeführt werden, obwohl ein nicht existierender Name benutzt wird.
Wenn das passt dann wird je nach Code, da wo eben der erste ausführbare Code steht, Zeile für Zeile in Maschinencode übersetzt und ausgeführt.
Nein. Bzw. jain, das kommt auf die konkrete Implementierung einer Programmiersprache, die du benutzt, an. Bei CPython stimmt das nicht, aber man könnte™ eine Implementierung schreiben, die das macht (macht man aber nicht, weil es schneller und einfacher ist, alles am Anfang zu übersetzen. Der Code ändert sich ja nicht zur Laufzeit.).

CPython (und die große Mehrheit aller anderen Implementierungen quasi aller Programmiersprachen) arbeitet im Groben die folgenden Schritte ab:
  • Zerlegung des Quelltexts in Tokens. Ein Token ist ein zusammenhängendes „Wort“, z. B. ein Name, ein Schlüsselwort (z. B. `class`), ein Integerliteral, ein Operator, eine Klammer, ... In Python kann man sich die Tokens mit dem `tokenize`-Modul angucken, einfach mit `python -m tokenize` und dann Sachen eintippen. Doku hier: https://docs.python.org/3/library/tokenize.html
  • Die lineare Liste von Tokens wird in eine Baumstruktur geparst. Dieser abstrake Syntax-Baum (AST) ist im `ast`-Modul beschrieben https://docs.python.org/3/library/ast.html und kann z. B. mit `astpretty` https://pypi.org/project/astpretty/ halbwegs lesbar dargestellt werden.
  • Auf dem AST werden semantische Analysen und/oder Transformationen durchgeführt. In einer Sprache, in der undefinierte Namen ein Fehler zur Compilezeit sind (wie z. B. Rust), würde zum Beispiel überprüft werden, ob alle Namen vor Benutzung definiert wurden. In Python werden zum Beispiel arithmetische Operationen mit Konstanten an dieser Stelle optimiert: `1 + 2` wird durch `3` ersetzt.
  • Der AST wird durchlaufen und dabei wird Code generiert. CPython generiert CPython-Bytecode, rustc generiert (über mehrere Zwischenschritte) Maschinencode, ... Den Bytecode kann man sich mit dem `dis`-Modul angucken.
Das alles passiert, bevor der Code ausgeführt wird. Und wie funktioniert das? Irgendwo im Quelltext von CPython steht eine Endlosschleife, die jeweils ein Byte vom Bytecode liest und dann entscheidet, was das Byte bedeutet und dann entsprechenden Code ausführt.

Für Maschinencode funktioniert das im Prinzip sehr ähnlich, nur dass das „Programm“, das den Code Byte für Byte liest und dann entsprechenden Code ausführt, in Hardware implementiert ist. Also in den Verdrahtungen der Transistoren in der CPU.

Warum ist das alles in Python (mit CPython) so viel langsamer als in Rust, wenn man das mit rustc in Maschinencode übersetzt? Deshalb. Das ist der Code, der ausgeführt wird, wenn man zwei Werte in Python miteinander multipliziert. Wenn du dich da durchhangelst, wirst du feststellen, dass das sehr viel Code ist.

So sieht das in Rust aus. Viel weniger Code, und der Compiler hat gesehen, dass man `n * 14` besser ohne Multiplikation als `(n << 4) - (n + n)` schreibt. Das geht aus zwei Gründen: Die Typen von allen Variablen sind zur Compilezeit bekannt (ist in Python nicht so) und der eine Faktor war als konstant bekannt. (Zusätzlich: In Python können Ganzzahlen unbegrenzt groß werden, `u32` in Rust kann das nicht.)

Wie ist Numba (oder ähnliche JITs wie PyPy) so schnell? Zur Laufzeit gibt es natürlich auch in Python Typinformationen. Du hast zur Laufzeit in Python also im Prinzip die gleichen Informationen wie zur Compilezeit in Rust. Also kannst du im Prinzip das gleiche machen wie der Rust-Compiler: Code erzeugen, der für genau einen Datentyp funktioniert, und nicht erst Byte für Byte vom Interpreter aufgedröselt werden und überall Typen überprüfen muss.

Falls dich das Thema näher interessiert, empfehle ich Crafting Interpreters. Das ist ein (kostenloses) Buch, das Schritt für Schritt beschreibt, wie man erst einen Interpreter und dann einen Bytecodecompiler (sehr ähnlich zu CPython) für eine einfache Programmiersprache schreibt. Das benutzt zwar Java und C, aber man kann das auch sehr gut mit einer anderen Sprache (z. B. Rust oder auch Python) durcharbeiten.
Benutzeravatar
grubenfox
User
Beiträge: 593
Registriert: Freitag 2. Dezember 2022, 15:49

Weil ich hier irgendwo nebenan gerade vor einigen Stunden von meinem Laufzeit-Versuch mit PyPy sprach...

ich habe mal den ersten Code von Dennis89 als 'denis-1.py' gespeichert und dann mal durch mein lokales Python und durch PyPy gejagt.

Ergebnis:

Code: Alles auswählen

> python -VV
Python 3.7.5 Stackless 3.7 (default, Mar 20 2023, 17:34:24) 
[GCC 12.2.1 20230201]

> python dennis-1.py
104759
Dauer 177.83 s

> ./pypy3.10-v7.3.16-linux64/bin/pypy3 -VV
Python 3.10.14 (75b3de9d9035, Apr 21 2024, 10:54:48)
[PyPy 7.3.16 with GCC 10.2.1 20210130 (Red Hat 10.2.1-11)]


> ./pypy3.10-v7.3.16-linux64/bin/pypy3 dennis-1.py
104759
Dauer 10.81 s
Wenn ich das mit den anderen hier geposteten Python-Zeiten bzw. Python+Numba-Zeiten vergleiche, dann ist PyPy rund halb so schnell wie die Kombi Python+Numba. [ich sollte mich wohl auch mal mit Numba beschäftigen]

Ansonsten gebe ich mich da mit einem Satz von der PyPy-Webseite zufrieden (CPython und PyPy sind beide Python-Interpreter, aber bei der Entwicklung von CPython steht/stand nicht die Geschwindigkeit des Interpreters im Vordergrund.) und verweise auf den Kommentar von narpfel

P.S. ich glaube meinen bisherigen ersten Versuch mit PyPy vor einigen Monaten hatte ich auch schon mit diesem Programmcode gemacht. ;)
Benutzeravatar
sparrow
User
Beiträge: 4501
Registriert: Freitag 17. April 2009, 10:28

grubenfox hat geschrieben: Samstag 12. Oktober 2024, 00:05 Wenn ich das mit den anderen hier geposteten Python-Zeiten bzw. Python+Numba-Zeiten vergleiche, dann ist PyPy rund halb so schnell wie die Kombi Python+Numba.
Das lässt sich so nicht vergleichen.
Unabhängig davon, dass die Art der Geschwindigkeitsmessung in den Tests eher grob ist, zeigt schon der Unterschied zwischen Dennis' und meinen Läufen, dass die Laufzeit je nach System stark variiert.
Stackless und Pypy auf der grünen Wiese auszuführen, macht also keinen Vergleich zu anderen Tests möglich. Du müsstest entsprechend die Werte der anderen Tests _auf deinem System_ als Vergleich heranziehen.
Benutzeravatar
noisefloor
User
Beiträge: 4149
Registriert: Mittwoch 17. Oktober 2007, 21:40
Wohnort: WW
Kontaktdaten:

Hallo,

ich habe zu dem Thema mit sehr ähnlichem Code mal zwei Blogartikel bzg. Python und Geschwindigkeit geschrieben, da wird u.a. auch auf verschiedene CPUs eingegangen:
Wie ein JIT und speziell der im kommenden Python 3.13 funktioniert: https://tonybaloney.github.io/posts/pyt ... a-jit.html.

Gruß, noisefloor
Benutzeravatar
Dennis89
User
Beiträge: 1503
Registriert: Freitag 11. Dezember 2020, 15:13

Hallo,

erst mal vielen Dank an alle(!) für eure Erklärungen und Links!

Zu den Teilen, zu denen ich nichts schreibe, die habe ich, denke ich, verstanden.

@sparrow Das erklärt einiges, ich habe nur `rustc main.rs` ausgeführt. Jetzt habe ich mit `cargo` ein Projekt angelegt und `cargo build --release` ausgeführt und kam dann auf einen Wert von 14.09 Sekunden. Etwas peinlich weil im Rust-Book das sogar ausdrücklich erwähnt wird.

@__blackjack__ Oh klar, das mit dem Namen hätte ich eigentlich wissen müssen bzw. testen können.
Ich habe in meiner Vorstellung Maschinencode und Bytecode mehr oder weniger gleich gesetzt. Das heißt, das wenn man rein CPython ausführt, man quasi nur in einen "Zwischenschicht" übersetzt und dann kommt C und liest den Bytcode übersetzt ihn erneut und führt ihn aus? Denn irgendwie muss die Hardware ja Informationen kommen, damit sie was tun kann. (Das schreibst du ja auch im zweit letzten Absatz, wenn ich das richtig verstehe)

@narpfel Sehr interessant die ganzen Links, habe mich da heute schon etwas durch gelesen, bin aber noch nicht fertig. Den Code in bspw. `ceval.c` kann ich so zwar nicht wirklich verstehen, aber das kann ja in Zukunft noch kommen.
Was ich nicht sehen kann ist folgendes:
So sieht das in Rust aus. Viel weniger Code, und der Compiler hat gesehen, dass man `n * 14` besser ohne Multiplikation als `(n << 4) - (n + n)` schreibt.
Wenn ich den Link anklicke kann ich das `(n << 4) - (n + n)` nirgends sehen. Das `(n + n)` könnte vielleicht das `[rax + rax]` sein.

Das mit `JIT` und einer Funktion für einen Datentyp schreiben habe ich noch getestet. Wenn ich in einer Funktion einen Datentyp übergebe und diesen Fall mit try/except abfange, dann kommt Python klar, aber `jit` compiliert den Code nicht. Sehr schön, das habe ich soweit verstanden.

Das Buch ist schon geöffnet, ich werde mal schauen wie lange mein Verständnis mit macht.

@grubenfox Ja das war in meinem anderen Thema, da hattest du PyPy angesprochen. Ich habe es noch nicht getestet. Ob jetzt `Numba` oder `PyPy` schneller ist, ist mir gar nicht so wichtig, weil ich eher daran interessiert bin, wieso die schneller sind, bzw. was die machen.


Grüße
Dennis
"When I got the music, I got a place to go" [Rancid, 1993]
narpfel
User
Beiträge: 688
Registriert: Freitag 20. Oktober 2017, 16:10

Dennis89 hat geschrieben: Samstag 12. Oktober 2024, 18:10 Was ich nicht sehen kann ist folgendes:
So sieht das in Rust aus. Viel weniger Code, und der Compiler hat gesehen, dass man `n * 14` besser ohne Multiplikation als `(n << 4) - (n + n)` schreibt.
Wenn ich den Link anklicke kann ich das `(n << 4) - (n + n)` nirgends sehen. Das `(n + n)` könnte vielleicht das `[rax + rax]` sein.
Links ist der Rust-Code, unten rechts die Ausgabe vom Compiler (in diesem Fall leer, weil es keine Fehlermeldungen gibt), und oben rechts ist der Assembly-Code, der dem generierten Maschinencode entspricht.

Das lesen zu können, braucht ein bisschen Übung (ist nicht so einfach wie Python-Bytecode, wo jede Bytecodeinstruktion im Prinzip eine direkte Entsprechung in Python-Code hat).

In Python-Bytecode haben die meisten Instruktionen keine Operanden, sondern lesen und schreiben einen Stack, der die Werte enthält, mit denen gerade gerechnet wird. Zum Beispiel das bereits verlinkte `BINARY_MULTIPLY`: Das entfernt die obersten beiden Werte (`right` und `left`) vom Stack, ruft `PyNumber_Multiply` auf und pusht dann `res` auf den Stack.

In x86-Assembly operieren Instruktionen auf Operanden, meist Register (fest in die CPU eingebaute Speicher, die jeweils eine einzelne Zahl enthalten können. x86_64 hat zum Beispiel 16 Register, die Ganzzahlen enthalten können.), Speicheradressen oder immediates (konstante Werte).

Die Register haben teilweise komische Namen (weil x86 fast 50 Jahre alt ist und größtenteils rückwärtskompatibel) und jeweils eine 8-, 16- 32- und 64-Bit-Variante (weil x86 ursprünglich kompatibel zu einer 8-Bit-CPU sein sollte), die sich überlagern. Z. B. ist `eax` die 32-Bit-Variante von `rax`. Wenn man also `rax` ändert, ändert sich auch `eax` und andersrum.

`mov` (kurz für „move“) ist die Kopierinstruktion und kopiert den Wert vom zweiten Operanden in den ersten Operanden. In Rust ausgedrückt ist `mov eax, edi` in etwa `eax = edi;`.

`shl` ist die Shift-Left-Instruktion; `shl eax, 4` ist in etwa `eax <<= 4` in Rust oder Python (die meisten x86-Instruktionen überschreiben ihren ersten Operanden).

`sub` ist Subtraktion. `sub eax, ecx` ist `eax -= ecx`.

`lea` ist... schwierig. Das hat keine direkte Entsprechung in Python oder Rust. Das Mnemonic steht für „load effective address“ und ist eigentlich gedacht, um Speicheradressen (zum Beispiel bei Arrayzugriff) auszurechnen. In der Form hier wird es benutzt, um `rax + rax` auszurechnen und in `ecx` zu speichern. Warum wird nicht `add rax, rax` benutzt? Weil `add` den ersten Operanden überschreibt und man deswegen zusätzlich ein `mov` ausführen müsste, um den alten Wert von `rax` zu sichern.
Benutzeravatar
__blackjack__
User
Beiträge: 13919
Registriert: Samstag 2. Juni 2018, 10:21
Wohnort: 127.0.0.1
Kontaktdaten:

@Dennis89: Maschinencode und Bytecode sind auch sehr ähnlich. Der Bytecode ist ja Maschinencode für die virtuelle Maschine. Und Maschinencode ist letztlich auch so etwas wie Bytecode für den Prozessor.

Der Python-Interpreter übersetzt den Bytecode nicht, sondern interpretiert den. Der Maschinencode der das macht, bei CPython ist das Ergebnis von der Übersetzung von den C-Quelltexten die den Python-Interpreter beschreiben in Maschinencode. Das ist einmal lange vor dem ausführen von Python-Programmen passiert. An *dem* Programm ändert sich zur Laufzeit nichts mehr.

Zum Godbold-Ergebnis: Die 32-Bit-Register heissen `eax`, `ebx`, `ecx` und so weiter. Per `rax`, `rbx`, … hat man das gleiche Register aber in 64 Bit, das heisst die `e…x`-Register sind die unteren 32-Bit von den 64-Bit Registern.

`mov` ist eine Zuweisung, das Argument `n` kommt über das `edi`-Register in die Funktion.

`lea` ist ein ”Missbrauch”, denn eigentlich ist das ein Befehl um eine Adresse (load effective address) in ein Register zu laden. Man kann dabei ein Indexregister angeben und darauf auch noch ein zweites Register addieren. Wenn man für beide Register das gleiche verwendet, kann man so dieses Register auf sich selbst addieren und das Ergebnis in ein weiteres Register schreiben.

Das ``n << 4`` ist der Befehl `shl` (shift left). Und `sub` ist dann die Subtraktion.

Vielleicht wird es mit Kommentaren leichter den Zusammenhang zu sehen:

Code: Alles auswählen

f:
        mov     eax, edi          ; eax = n
        lea     ecx, [rax + rax]  ; ecx = eax + eax
        shl     eax, 4            ; eax = eax << 4
        sub     eax, ecx          ; eax = eax - exc
        ret
Bei `rax` habe ich im Kommentar `eax` geschrieben, weil die oberen 32 Bit keinen Einfluss auf das Ergebnis der unteren 32-Bit haben beim addieren, und bei der Zuweisung an das `eax`-Register sowieso verloren gehen. Effektiv ist `eax + eax` und `rax + rax` letztlich das gleiche an der Stelle. `rax` wird hier nur verwendet, weil man bei 64-Bit-Code hier auch 64-Bit-Register als Indexregister verwenden muss.

Edit: Zu spät, verdammt. :-)
“I am Dyslexic of Borg, Your Ass will be Laminated” — unknown
Benutzeravatar
Dennis89
User
Beiträge: 1503
Registriert: Freitag 11. Dezember 2020, 15:13

Guten Morgen,

vielen Dank für beide Erklärungen. Bei einem wie mir, schadet eine doppelte Erklärung auch ganz und gar nicht 😅

Ich denke so grob habe ich das mit Maschinen- und Bytecode auch verstanden, wird in dem Buch auch behandelt. Ich will da noch etwas lesen, bevor ich noch ein mal Rückfragen stelle.

Grüße
Dennis
"When I got the music, I got a place to go" [Rancid, 1993]
Benutzeravatar
Dennis89
User
Beiträge: 1503
Registriert: Freitag 11. Dezember 2020, 15:13

Hallo,

ich bin bei Crafting Interpreters bis zu Kapitel 5.3.2 „Visitor pattern“ gekommen, bzw. hab mir das auch durchgelesen, aber ich verstehe absolut gar nicht was da gemacht wird. Ich kann auch leider kein Java und mir die Codebeispiele nur zusammen reimen, aber das ergibt für mich keinen Sinn. Ich kann auch keine konkrete Frage stellen, weil für mich irgendwie noch gar nichts greifbar ist.

Könnte von euch bitte jemand das Beispiel in Python schreiben? Da bin ich am vertrautesten, falls das nicht möglich ist, wäre Rust vielleicht noch eine Option.


Danke und Grüße
Dennis
"When I got the music, I got a place to go" [Rancid, 1993]
Benutzeravatar
sparrow
User
Beiträge: 4501
Registriert: Freitag 17. April 2009, 10:28

Vielleicht hilft dir die Erklärung bei Wikipedia weiter.

Ich versuche das einmal bodenständig:
Bei dem bei dir gezeigten Beispiel ist eigentlich nur wichtig zu verstehen, dass "abstract" in Java heißt: "Das hier ist eine Definition, aber sie kann nicht instanziert werden".
Das heißt die Klasse "Poetry" selbst kann nicht instanziert werden, aber du kannst von ihr erben.
Das selbe gilt für Methoden, die "abstract" sind. Sie müssen in einer Klasse, die erbt, überschrieben werden.

In dem Beispiel:
"Pastry " kann nicht instanziert werden.
In Python wäre das pastry = Pastry () geht nicht.
Aber die Klasse Beignet, die von Pastry erbt, die kann instanziert werden. Und wenn es dann noch abstrakte Methoden gitb, musst du dich um die entsprechende Implementation erzwungenermaßen kümmern.

Hilft das?
Benutzeravatar
__blackjack__
User
Beiträge: 13919
Registriert: Samstag 2. Juni 2018, 10:21
Wohnort: 127.0.0.1
Kontaktdaten:

@Dennis89: Dieses Ping-Pong zwischen `accept()` und `visit<Something>()` ist in Python nicht notwendig, das ist nur dieser statische Typfetischismus von Java. 😜

In Python schreibt man eine einfache `Visitor`-Basisklasse mit einer `visit()`-Methode die dynamisch anhand des Typnamens eine konkretere `visit_*`-Methode aufruft.

Und in Python hätte man auch als Alternative das „pattern matching“ aus funktionalen Sprachen, damit man da keine Klasse für braucht, sondern einfach eine Funktion schreiben kann. Ich habe mal das Beispiel aus dem Buch ungefähr in Python umgesetzt. Neben dem `AstPrinter` auch eine Auswertung und beides jeweils als Visitor-Klasse und als Funktion mit „pattern matching“. Und dann noch Aufgabe 3 funktional:

Code: Alles auswählen

#!/usr/bin/env python3
from math import factorial
from operator import add, mul, neg, sub, truediv

from attrs import field, frozen

BINARY_OPERATOR_SYMBOL_TO_FUNCTION = {
    "*": mul,
    "+": add,
    "-": sub,
    "/": truediv,
}
UNARY_OPERATOR_SYMBOL_TO_FUNCTION = {"-": neg, "!": factorial}


@frozen
class Literal:
    value = field()


@frozen
class UnaryExpression:
    operator = field()
    operand = field()


@frozen
class BinaryExpression:
    left_operand = field()
    operator = field()
    right_operand = field()


@frozen
class Grouping:
    expression = field()


class Visitor:
    def visit(self, node):
        method = getattr(self, "visit_" + node.__class__.__name__)
        if method:
            return method(node)
        return None


class AstPrinter(Visitor):
    def _parenthesize(self, name, expressions):
        expressions_text = " ".join(map(self.visit, expressions))
        return f"({name} {expressions_text})"

    def visit_Literal(self, node):
        return "nil" if node.value is None else repr(node.value)

    def visit_UnaryExpression(self, node):
        return self._parenthesize(node.operator, [node.operand])

    def visit_BinaryExpression(self, node):
        return self._parenthesize(
            node.operator, [node.left_operand, node.right_operand]
        )

    def visit_Grouping(self, node):
        return self._parenthesize("group", [node.expression])


def _parenthesize(name, expressions):
    expressions_text = " ".join(map(ast_as_string, expressions))
    return f"({name} {expressions_text})"


def ast_as_string(node):
    match node:
        case Literal(value):
            return "nil" if value is None else repr(value)

        case UnaryExpression(operator, operand):
            return _parenthesize(operator, [operand])

        case BinaryExpression(left_operand, operator, right_operand):
            return _parenthesize(operator, [left_operand, right_operand])

        case Grouping(expression):
            return _parenthesize("group", [expression])

        case _:
            return None


class AstEvaluator(Visitor):
    def visit_Literal(self, node):
        return node.value

    def visit_UnaryExpression(self, node):
        return UNARY_OPERATOR_SYMBOL_TO_FUNCTION[node.operator](
            self.visit(node.operand)
        )

    def visit_BinaryExpression(self, node):
        return BINARY_OPERATOR_SYMBOL_TO_FUNCTION[node.operator](
            self.visit(node.left_operand), self.visit(node.right_operand)
        )

    def visit_Grouping(self, node):
        return self.visit(node.expression)


def evaluate(node):
    match node:
        case Literal(value):
            return value

        case UnaryExpression(operator, expression):
            return UNARY_OPERATOR_SYMBOL_TO_FUNCTION[operator](
                evaluate(expression)
            )

        case BinaryExpression(left_operand, operator, right_operand):
            return BINARY_OPERATOR_SYMBOL_TO_FUNCTION[operator](
                evaluate(left_operand), evaluate(right_operand)
            )

        case Grouping(expression):
            return evaluate(expression)

        case _:
            raise ValueError(f"cannot evaluate {node!r}")


def as_rpn(node):
    match node:
        case Literal(value):
            return "nil" if value is None else repr(value)

        case UnaryExpression("-", operand):
            return f"0 {as_rpn(operand)} -"

        case UnaryExpression(operator, operand):
            return f"{as_rpn(operand)} {operator}"

        case BinaryExpression(left_operand, operator, right_operand):
            return f"{as_rpn(left_operand)} {as_rpn(right_operand)} {operator}"

        case Grouping(expression):
            return as_rpn(expression)

        case _:
            raise ValueError(f"cannot represent {node!r} as RPN")


def main():
    ast = BinaryExpression(
        UnaryExpression("-", Literal(123)),
        "*",
        Grouping(Literal(45.67)),
    )
    print(AstPrinter().visit(ast))
    print(ast_as_string(ast))

    print(AstEvaluator().visit(ast))
    print(evaluate(ast))

    print(as_rpn(ast))


if __name__ == "__main__":
    main()
Ein naheliegendes reales Beispiel in Python-Code ist das `ast`-Modul. Da gibt's einen `NodeVisitor` und Klassen die Syntaxelemente von Python repräsentieren.
“I am Dyslexic of Borg, Your Ass will be Laminated” — unknown
Benutzeravatar
Dennis89
User
Beiträge: 1503
Registriert: Freitag 11. Dezember 2020, 15:13

Danke für eure Antworten.

Habe mir alles durchgelesen. Ich bin der Meinung, man hat etwas verstanden, wenn man es in eigenen Worte erklären kann. Ich hoffe ich werde in nächster Zeit nicht gefragt was Visitor-Pattern ist bzw. wie es funktioniert.
Irgendwie ist das für mich nicht greifbar, weder das Beispiel mit dem Reisebüro, noch das mit dem Versicherungsvertreter hilft mir so recht weiter.

Ich habe Klassen und jede Klasse hat logischerweise ihre eigene Methoden und jetzt will ich allen Klassen eine neue, gleiche(?) Methode hinzufügen ohne die Klassen selbst zu ändern? Falls ja, verstehe ich nicht wieso unterschiedliche Klassen eine Methode brauchen, die in allen gleich ist. Die Datenstruktur kann sich doch um Welten unterscheiden.

@__blackjack__ wenn ich in deinem Code `AstPrinter().visit(ast))` ausführe, dann wird jede Methode in `AstPrinter` ausgeführt, die mit `visit_` anfängt und mit dem Klassennamen weiter geht. Dann sehe ich hier jetzt den Vorteil, das ich eine Reihe von Methoden mit einem Aufruf ausführen kann, richtig?
Hier wird dann jetzt der Klasse die Funktionalität hinzugefügt, das alles ausgeführt wird. Aber das haut ja nur hin, weil die Methoden schon so heißen wie sie heißen. Das Beispiel macht jetzt was anderes, was ich aus den verlinkten Erklärungen herauslesen konnte.

Vielleicht könnt ihr mich noch mal etwas aufklären.

Danke und Grüße
Dennis
"When I got the music, I got a place to go" [Rancid, 1993]
Benutzeravatar
__blackjack__
User
Beiträge: 13919
Registriert: Samstag 2. Juni 2018, 10:21
Wohnort: 127.0.0.1
Kontaktdaten:

@Dennis89: Man möchte jeder Klasse eine Methode ”hinzufügen”, die von der Idee her das gleiche macht, beispielsweise das Objekt als Zeichenkette formatieren, aber die konkret für jede Klasse etwas anderes machen muss, eben *weil* die sich von der Struktur um Welten unterscheiden können. Und man möchte die Methoden gerade nicht wirklich der Klasse hinzufügen, sondern das unabhängig haben. Beispielsweise weil man gar nicht der Autor der Klassen ist. Oder weil man verschiedene Verarbeitungen der Struktur in jeweils einer Klasse zusammenfassen möchte. Und oft hat man auch Zustand bei der Verarbeitung den man dann auch in dieser Klasse halten kann.

Wenn man anfängt mehrere solcher Operationen die eigenen Zustand für einen Durchlauf brauchen, direkt auf die Klassen zu verteilen, dann braucht man ein weiteres Kontext-Argument was diese Informationen sammelt. Also sowieso eine zusätzliche Klasse pro Operation mit einem übergeordneten Zustand. Dann kann es auch Sinn machen die Methode nicht auf die Klassen zu verteilen, sondern eine Methode pro Klasse mit dem Zustand in einen Besucher zu stecken. Plus eventuelle Hilfsmethoden die sonst auch alle in der Klassenhierarchie landen würden.

Statt dem „Printer“-Besucher könnte man das auch über entsprechende `__str__()`-Methoden machen, die auf die Klassen verteilt sind. Aber dann sind die eben genau das: auf die Klassen verteilt. Und darüber gäbe es dann *eine* Darstellung des Gesamtstruktur als Zeichenkette. Vielleicht möchte man den AST auch in C-Quelltext übersetzen, oder gar gleich Assembler generieren.

Im Python-Beispiel müssen die Methoden den passenden Namen haben. Das haben sie im Java-Beispiel auch. Da *könnten* sie andere Namen haben, weil in Java an der Stelle über den statischen Datentyp des Arguments entschieden wird welche Methode aufgerufen wird. Das liesse sich in Python über `functools.singledispatchmethod()` auch erreichen, aber dann hätte man am Ende den Namen zweimal geschrieben, einmal beim `register()`-Dekorator und dann noch mal im Methodennamen. Denn komplett anders möchte man die Methode dann ja auch nicht nennen. Der Dekorator hätte allerdings den Vorteil, dass man mehrere Typen auf die gleiche Methode abbilden kann und das hier, wie bei Java, auch Unterklassen berücksichtigt werden.
“I am Dyslexic of Borg, Your Ass will be Laminated” — unknown
Benutzeravatar
Dennis89
User
Beiträge: 1503
Registriert: Freitag 11. Dezember 2020, 15:13

Danke für die Erklärung.

Ok ich verstehe im groben die Idee dahinter.
aber die konkret für jede Klasse etwas anderes machen muss, eben *weil* die sich von der Struktur um Welten unterscheiden können
Das tut sie doch aber nicht? Es werden lediglich Methoden aufgerufen und dass das Ergebnis so ist, wie es ist, liegt nur daran, dass die Klassen die entsprechenden Methoden schon haben. `Visitor` nimmt mir ja nur das manuelle aufrufen der Methoden ab, wenn ich die Zeichenkette von `AstPrinter` will.

Vielleicht fixiere ich mich auch zu sehr auf das Beispiel und mir fehlt ein praxisnaher Bezug, auf den ich das anwenden kann und den ich auch verstehe. Der Syntax-Baum von dem die Frage ursprünglich aufkam, ist zwar praxisnah, aber für mich nicht sonderlich greifbar, da das ganze Thema viel zu neu für mich ist.

Gerade ist das noch sehr abstrakt für mich. Auch deine weiteren Erklärungen kann ich so noch nicht wirklich greifen/konkret zuordnen. Ich lese mir morgen noch mal die verlinkten Beispiele durch, vielleicht macht es dann klick.

Grüße
Dennis
"When I got the music, I got a place to go" [Rancid, 1993]
Benutzeravatar
__blackjack__
User
Beiträge: 13919
Registriert: Samstag 2. Juni 2018, 10:21
Wohnort: 127.0.0.1
Kontaktdaten:

@Dennis89: In meinem Beispiel haben die Klassen aus der Hierarchie um die es geht gar keine Methoden, die bestehen ja nur aus Datenattributen. Die AST-Klassen haben die Methoden ja eben gerade *nicht*, die sind auf der jeweiligen Visitor-Klasse nach der Aufgabe sortiert.

Wie würdest Du die denn manuell aufrufen wenn es die Visitor-Basisklasse nicht gäbe? Die sorgt ja gerade dafür, dass für jeden Objekttyp die passende Methode aufgerufen wird und man von jeder spezifischen Methode diese allgemeine `visit()`-Methode aufrufen kann ohne das man irgend etwas über die ganzen anderen Datentypen die da noch besucht werden könn(t)en wissen muss.
“I am Dyslexic of Borg, Your Ass will be Laminated” — unknown
Benutzeravatar
Dennis89
User
Beiträge: 1503
Registriert: Freitag 11. Dezember 2020, 15:13

Guten Morgen und danke für deine Antwort.

Ja ohne etwas über die Datentypen zu wissen, kann ich das nicht manuell aufrufen.

Ich bin noch einmal das Virtuelle Reisebüro auf Wikipedia durchgegangen. Ich verstehe, dass es unabhängige Objekte gibt, die alle verschiedene Informationen beinhalten und das man aus jedem Objekt gewisse Informationen holen muss um entweder eine Reisebestätigung oder einen Gesamtpreis darstellen zu können und die Informationen holt sich der "Besucher" aus jedem Objekt ab.

Dann müssen die zu besuchenden Objekte aber zumindest alle so aufgebaut sein, dass sie einen "Punkt" haben, an dem die Informationen gesammelt abgeholt werden können? Also dass mein Besucher beispielsweise durch alle Klassen geht und sich überall ein Wörterbuch abholen kann?
Ich kann keinen Besucher erstellen, der auf unterschiedlichen Klassen unterschiedliche Attribute abfragt um so die Informationen zu sammeln. Wäre das soweit richtig?


Grüße
Dennis
"When I got the music, I got a place to go" [Rancid, 1993]
Antworten