Kapitel 1. Performantes Python verstehen
Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com
Beim Programmieren von Computern geht es darum, Datenbits zu verschieben und sie auf bestimmte Weise umzuwandeln, um ein bestimmtes Ergebnis zu erzielen. Diese Vorgänge sind jedoch mit einem Zeitaufwand verbunden. Daher kann man sich Hochleistungsprogrammierung so vorstellen, dass diese Vorgänge minimiert werden, indem entweder der Overhead reduziert wird (d. h. effizienterer Code geschrieben wird) oder die Art und Weise, wie wir diese Vorgänge durchführen, geändert wird, um jeden einzelnen Vorgang sinnvoller zu gestalten (d. h. einen geeigneteren Algorithmus zu finden).
Konzentrieren wir uns darauf, den Overhead im Code zu reduzieren, um mehr Einblick in die tatsächliche Hardware zu bekommen, auf der wir diese Bits bewegen. Das mag wie eine sinnlose Übung erscheinen, denn Python ist sehr bemüht, direkte Interaktionen mit der Hardware zu vermeiden. Wenn du jedoch verstehst, wie Bits in der realen Hardware am besten bewegt werden können und wie die Abstraktionen von Python deine Bits zum Bewegen zwingen, kannst du Fortschritte beim Schreiben von Hochleistungsprogrammen in Python machen.
Das fundamentale Computersystem
Die Komponenten, aus denen ein Computer besteht, lassen sich in drei grundlegende Teile zerlegen: die Recheneinheiten, die Speichereinheiten und die Verbindungen zwischen ihnen. Darüber hinaus hat jede dieser Einheiten verschiedene Eigenschaften, die wir nutzen können, um sie zu verstehen. Die Recheneinheit hat die Eigenschaft, wie viele Berechnungen sie pro Sekunde durchführen kann, die Speichereinheit hat die Eigenschaft, wie viele Daten sie speichern kann und wie schnell wir von ihr lesen und auf sie schreiben können, und schließlich haben die Verbindungen die Eigenschaft, wie schnell sie Daten von einem Ort zum anderen bewegen können.
Anhand dieser Bausteine können wir über einen Standard-Arbeitsplatzrechner in verschiedenen Ausbaustufen sprechen. Eine Standard-Workstation hat zum Beispiel eine Zentraleinheit (CPU) als Recheneinheit, die sowohl mit dem Arbeitsspeicher (RAM) als auch mit der Festplatte als zwei getrennten Speichereinheiten (mit unterschiedlichen Kapazitäten und Schreib-/Lesegeschwindigkeiten) verbunden ist, und schließlich einen Bus, der die Verbindungen zwischen all diesen Teilen herstellt. Wir können aber auch ins Detail gehen und sehen, dass die CPU selbst mehrere Speichereinheiten enthält: den L1-, den L2- und manchmal sogar den L3- und den L4-Cache, die kleine Kapazitäten, aber sehr schnelle Geschwindigkeiten haben (von einigen Kilobyte bis zu einem Dutzend Megabyte). Außerdem bringen neue Computerarchitekturen in der Regel neue Konfigurationen mit sich (z. B. wurde bei Intels SkyLake-CPUs der Frontside-Bus durch den Intel Ultra Path Interconnect ersetzt und viele Verbindungen neu strukturiert). Schließlich haben wir bei diesen beiden Annäherungen an eine Workstation die Netzwerkverbindung vernachlässigt, die eigentlich eine sehr langsame Verbindung zu potenziell vielen anderen Rechen- und Speichereinheiten ist!
Um diese verschiedenen Verwicklungen zu entwirren, wollen wir eine kurze Beschreibung dieser grundlegenden Blöcke geben.
Recheneinheiten
Die Recheneinheit eines Computers ist das Herzstück seiner Nützlichkeit - sie ist in der Lage, alle Bits, die sie empfängt, in andere Bits umzuwandeln oder den Zustand des laufenden Prozesses zu ändern. CPUs sind die am häufigsten verwendete Recheneinheit, aber Grafikverarbeitungseinheiten (GPUs) werden als zusätzliche Recheneinheiten immer beliebter. Ursprünglich wurden sie eingesetzt, um Computergrafiken zu beschleunigen, aber sie werden immer häufiger für numerische Anwendungen verwendet und sind dank ihrer inhärenten Parallelität, die es ermöglicht, viele Berechnungen gleichzeitig durchzuführen, nützlich. Unabhängig von ihrem Typ nimmt eine Recheneinheit eine Reihe von Bits auf (zum Beispiel Bits, die Zahlen darstellen) und gibt eine andere Reihe von Bits aus (zum Beispiel Bits, die die Summe dieser Zahlen darstellen). Neben den grundlegenden arithmetischen Operationen für ganze und reelle Zahlen und den bitweisen Operationen für binäre Zahlen bieten einige Recheneinheiten auch sehr spezielle Operationen, wie z. B. die "fused multiply add"-Operation, die die drei Zahlen A
, B
und C
aufnimmt und den Wert A * B + C
zurückgibt.
Die wichtigsten Eigenschaften, die bei einer Recheneinheit von Interesse sind, sind die Anzahl der Operationen, die sie in einem Zyklus ausführen kann, und die Anzahl der Zyklen, die sie in einer Sekunde ausführen kann. Der erste Wert wird durch die Anweisungen pro Zyklus (IPC) gemessen,1 während der zweite Wert durch die Taktrate gemessen wird. Diese beiden Werte konkurrieren immer dann miteinander, wenn neue Recheneinheiten hergestellt werden. Die Intel Core-Serie hat zum Beispiel eine sehr hohe IPC, aber eine niedrigere Taktrate, während der Pentium 4-Chip das Gegenteil hat. Grafikprozessoren hingegen haben eine sehr hohe IPC und Taktfrequenz, aber sie leiden unter anderen Problemen wie der langsamen Kommunikation, die wir in "Kommunikationsschichten" besprechen .
Eine höhere Taktfrequenz beschleunigt zwar fast sofort alle Programme, die auf dieser Recheneinheit laufen (weil sie mehr Berechnungen pro Sekunde durchführen können), aber eine höhere IPC kann sich auch drastisch auf die Rechenleistung auswirken, weil sie den Grad der Vektorisierung verändert, der möglich ist.Vektorisierung liegt vor, wenn eine CPU mehrere Daten auf einmal erhält und sie alle gleichzeitig verarbeiten kann. Diese Art von CPU-Befehlen ist als single instruction, multiple data (SIMD) bekannt.
Im Allgemeinen haben sich Recheneinheiten in den letzten zehn Jahren nur sehr langsam weiterentwickelt (siehe Abbildung 1-1). Sowohl die Taktrate als auch die IPC stagnieren aufgrund der physikalischen Grenzen, die durch immer kleinere Transistoren entstehen. Daher haben die Chip-Hersteller auf andere Methoden gesetzt, um mehr Geschwindigkeit zu erreichen, z. B. gleichzeitiges Multithreading (bei dem mehrere Threads gleichzeitig laufen können), clevere Out-of-Order-Ausführung und Multicore-Architekturen.
Hyperthreading stellt dem Host-Betriebssystem (OS) eine virtuelle zweite CPU zur Verfügung, und eine clevere Hardware-Logik versucht, zwei Threads von Anweisungen in die Ausführungseinheiten einer einzigen CPU einzubinden. Wenn dies gelingt, können bis zu 30 % mehr Leistung als bei einem einzelnen Thread erzielt werden. Dies funktioniert in der Regel gut, wenn die Arbeitseinheiten der beiden Threads unterschiedliche Arten von Ausführungseinheiten verwenden, z. B. wenn einer Gleitkomma- und der andere Ganzzahloperationen ausführt.
Die Ausführung außerhalb der Reihenfolge ermöglicht es einem Compiler zu erkennen, dass einige Teile einer linearen Programmsequenz nicht von den Ergebnissen eines vorhergehenden Teils der Arbeit abhängen und daher beide Teile der Arbeit in beliebiger Reihenfolge oder zur gleichen Zeit auftreten können. Solange die aufeinanderfolgenden Ergebnisse zur richtigen Zeit präsentiert werden, wird das Programm weiterhin korrekt ausgeführt, auch wenn Teile der Arbeit außerhalb ihrer programmierten Reihenfolge berechnet werden. So können einige Befehle ausgeführt werden, während andere blockiert sind (z. B. weil sie auf einen Speicherzugriff warten), wodurch die verfügbarenRessourcen insgesamt besser genutzt werden können.
Und schließlich, und das ist für den Programmierer auf höherer Ebene am wichtigsten, gibt es die weit verbreiteten Multicore-Architekturen. Diese Architekturen beinhalten mehrere CPUs in einer Einheit, was die Gesamtleistung erhöht, ohne dass die einzelnen Einheiten schneller werden. Deshalb gibt es heute kaum noch einen Rechner mit weniger als zwei Kernen - in diesem Fall hat der Computer zwei physische Recheneinheiten, die miteinander verbunden sind. Das erhöht zwar die Gesamtzahl der Operationen, die pro Sekunde ausgeführt werden können, aber es kann das Schreiben von Code erschweren!
Wenn du einer CPU einfach mehr Kerne hinzufügst, wird die Ausführungszeit eines Programms nicht immer schneller. Der Grund dafür ist das sogenannte Amdahlsche Gesetz. Das Amdahl'sche Gesetz besagt: Wenn ein Programm, das für mehrere Kerne ausgelegt ist, einige Unterprogramme enthält, die auf einem Kern laufen müssen, ist dies die Grenze für die maximale Beschleunigung, die durch die Zuweisung weiterer Kerne erreicht werden kann.
Wenn wir z. B. eine Umfrage haben, die hundert Personen ausfüllen sollen, und diese Umfrage 1 Minute dauert, können wir diese Aufgabe in 100 Minuten erledigen, wenn eine Person die Fragen stellt (d. h. diese Person geht zu Teilnehmer 1, stellt die Fragen, wartet auf die Antworten und geht dann zu Teilnehmer 2). Diese Methode, bei der eine Person die Fragen stellt und auf die Antworten wartet, ähnelt einem seriellen Prozess. Bei seriellen Prozessen werden die Vorgänge nacheinander ausgeführt, wobei jeder Vorgang darauf wartet, dass der vorherige Vorgang abgeschlossen ist.
Wir könnten die Umfrage aber auch parallel durchführen, wenn zwei Personen die Fragen stellen würden, wodurch wir den Prozess in nur 50 Minuten abschließen könnten. Das ist möglich, weil jede einzelne Person, die die Fragen stellt, nichts über die andere Person wissen muss, die die Fragen stellt. Daher kann die Aufgabe einfach aufgeteilt werden, ohne dass eine Abhängigkeit zwischen den Fragesteller/innen besteht.
Wenn mehr Leute die Fragen stellen, wird die Geschwindigkeit weiter erhöht, bis wir hundert Leute haben, die Fragen stellen. An diesem Punkt würde der Prozess 1 Minute dauern und wäre nur noch durch die Zeit begrenzt, die ein Teilnehmer zum Beantworten der Fragen braucht. Wenn noch mehr Leute Fragen stellen, führt das nicht zu einer weiteren Beschleunigung, denn diese zusätzlichen Leute haben keine Aufgaben zu erfüllen - alle Teilnehmer/innen werden bereits befragt! An diesem Punkt besteht die einzige Möglichkeit, die Gesamtzeit für die Durchführung der Umfrage zu verkürzen, darin, die Zeit zu verkürzen, die eine einzelne Umfrage, also der serielle Teil des Problems, benötigt. Ähnlich verhält es sich mit CPUs: Wir können mehr Kerne hinzufügen, die verschiedene Teile der Berechnung durchführen können, bis wir einen Punkt erreichen, an dem der Engpass die Zeit ist, die ein bestimmter Kern benötigt, um seine Aufgabe zu beenden. Mit anderen Worten: Der Engpass bei einer parallelen Berechnung sind immer die kleineren seriellen Aufgaben, die verteilt werden.
Eine große Hürde bei der Nutzung mehrerer Kerne in Python ist außerdem die Verwendung der global interpreter lock (GIL). Die GIL stellt sicher, dass ein Python-Prozess immer nur eine Anweisung ausführen kann, unabhängig von der Anzahl der Kerne, die er gerade verwendet. Das bedeutet, dass ein Python-Code zwar auf mehrere Kerne gleichzeitig zugreifen kann, aber immer nur ein Kern eine Python-Anweisung ausführt. In unserem Beispiel einer Umfrage würde das bedeuten, dass selbst bei 100 Fragesteller/innen immer nur eine Person eine Frage stellen und eine Antwort anhören kann. Dadurch wird der Vorteil mehrerer Fragesteller/innen zunichte gemacht! Auch wenn dies eine ziemliche Hürde zu sein scheint, vor allem, wenn der aktuelle Trend in der Informatik eher zu mehreren Recheneinheiten als zu schnelleren geht, kann dieses Problem durch die Verwendung anderer Standardbibliothekswerkzeuge wie multiprocessing
(Kapitel 9), Technologien wie numpy
oder numexpr
(Kapitel 6), Cython(Kapitel 7) oder verteilte Rechenmodelle(Kapitel 10) vermieden werden.
Hinweis
Mit Python 3.2 wurde auch die GIL grundlegend überarbeitet, wodurch das System viel wendiger wurde und viele Bedenken hinsichtlich der Single-Thread-Leistung ausgeräumt werden konnten. Obwohl Python immer noch nur eine Anweisung gleichzeitig ausführen kann, ist die GIL jetzt besser in der Lage, zwischen diesen Anweisungen zu wechseln und das mit weniger Overhead.
Speichereinheiten
Speichereinheiten in Computern werden verwendet, um Bits zu speichern. Das können Bits sein, die Variablen in deinem Programm darstellen, oder Bits, die die Pixel eines Bildes repräsentieren. Die Abstraktion einer Speichereinheit gilt also sowohl für die Register auf deiner Hauptplatine als auch für deinen Arbeitsspeicher und deine Festplatte. Der einzige große Unterschied zwischen all diesen Arten von Speichereinheiten ist die Geschwindigkeit, mit der sie Daten lesen und schreiben können. Um die Sache noch komplizierter zu machen, hängt die Lese-/Schreibgeschwindigkeit stark von der Art und Weise ab, wie die Daten gelesen werden.
Die meisten Speichereinheiten sind zum Beispiel viel leistungsfähiger, wenn sie ein großes Datenpaket lesen, als wenn sie viele kleine Pakete lesen (dies wird als sequentielles Lesen gegenüber zufälligen Daten bezeichnet). Wenn man sich die Daten in diesen Speichereinheiten als Seiten in einem großen Buch vorstellt, bedeutet das, dass die meisten Speichereinheiten bessere Lese-/Schreibgeschwindigkeiten haben, wenn sie das Buch Seite für Seite durchgehen, anstatt ständig von einer zufälligen Seite zur nächsten zu blättern. Diese Tatsache gilt zwar generell für alle Speichereinheiten, aber die Auswirkungen auf die einzelnen Typen sind sehr unterschiedlich.
Zusätzlich zu den Lese- und Schreibgeschwindigkeiten haben die Speichereinheiten auch eineLatenzzeit, die als die Zeit beschrieben werden kann, die das Gerät braucht, um die verwendeten Daten zu finden. Bei einer sich drehenden Festplatte kann diese Latenzzeit sehr hoch sein, weil sich die Platte erst einmal drehen und der Lesekopf in die richtige Position gebracht werden muss. Bei Arbeitsspeichern hingegen kann diese Latenzzeit recht gering sein, weil alles Festkörperspeicher ist. Hier ist eine kurze Beschreibung der verschiedenen Speichereinheiten, die üblicherweise in einem Standard-Arbeitsplatzrechner zu finden sind, in der Reihenfolge ihrer Lese-/Schreibgeschwindigkeit:2
- Spinnende Festplatte
-
Langfristige Speicherung, die auch dann erhalten bleibt, wenn der Computer heruntergefahren wird. In der Regel langsame Lese- und Schreibgeschwindigkeiten, da die Festplatte physisch gedreht und bewegt werden muss. Geringere Leistung bei zufälligen Zugriffsmustern, aber sehr große Kapazität (im Bereich von 10 Terabyte).
- Solid-State-Festplatte
-
Ähnlich wie eine sich drehende Festplatte, mit höheren Lese- und Schreibgeschwindigkeiten, aber kleinerer Kapazität (im Bereich von 1 Terabyte).
- RAM
-
Wird verwendet, um Anwendungscode und Daten zu speichern (z. B. alle verwendeten Variablen). Hat schnelle Lese-/Schreibeigenschaften und funktioniert gut bei zufälligen Zugriffsmustern, hat aber im Allgemeinen nur eine begrenzte Kapazität (im Bereich von 64 Gigabyte).
- L1/L2-Cache
-
Extrem schnelle Lese-/Schreibgeschwindigkeiten. Daten, die an die CPU gehen, müssen hier durchlaufen. Sehr kleine Kapazität (im Megabyte-Bereich).
In Abbildung 1-2 werden die Unterschiede zwischen diesen Arten von Speichereinheiten anhand der Merkmale der derzeit erhältlichen Verbraucherhardware grafisch dargestellt .
Ein klar erkennbarer Trend ist, dass die Lese-/Schreibgeschwindigkeit und die Kapazität umgekehrt proportional sind - wenn wir versuchen, die Geschwindigkeit zu erhöhen, wird die Kapazität reduziert. Aus diesem Grund verwenden viele Systeme einen mehrstufigen Ansatz für den Speicher: Die Daten werden zunächst vollständig auf der Festplatte gespeichert, ein Teil davon wird in den Arbeitsspeicher verschoben und ein viel kleinerer Teil in den L1/L2-Cache. Diese Methode des Tiering ermöglicht es Programmen, den Speicher je nach den Anforderungen an die Zugriffsgeschwindigkeit an verschiedenen Stellen zu halten. Wenn wir versuchen, die Speichermuster eines Programms zu optimieren, optimieren wir einfach, welche Daten wo platziert werden, wie sie angeordnet sind (um die Anzahl der sequentiellen Lesevorgänge zu erhöhen) und wie oft sie zwischen den verschiedenen Speicherorten verschoben werden. Darüber hinaus bieten Methoden wie asynchrone E/A undpreemptive caching Möglichkeiten, um sicherzustellen, dass die Daten immer dort sind, wo sie sein müssen, ohne Rechenzeit zu verschwenden - die meisten dieser Prozesse können unabhängig voneinander ablaufen, während andere Berechnungen durchgeführt werden!
Kommunikationsschichten
Zum Schluss wollen wir uns ansehen, wie all diese grundlegenden Blöcke miteinander kommunizieren. Es gibt viele Arten der Kommunikation, aber alle sind Varianten eines so genannten Busses.
Der Frontside-Bus ist zum Beispiel die Verbindung zwischen dem RAM und dem L1/L2-Cache. Er transportiert Daten, die vom Prozessor umgewandelt werden sollen, in den Zwischenspeicher, um sie für Berechnungen bereit zu machen, und er transportiert fertige Berechnungen hinaus. Es gibt auch noch andere Busse, z. B. den externen Bus, der als Hauptverbindung zwischen Hardwaregeräten (wie Festplatten und Netzwerkkarten) und der CPU und dem Systemspeicher dient. Dieser externe Bus ist in der Regel langsamer als der Frontside-Bus.
Tatsächlich sind viele der Vorteile des L1/L2-Cache auf den schnelleren Bus zurückzuführen. Da die für die Berechnungen benötigten Daten in großen Stücken auf einem langsamen Bus (vom RAM zum Cache) in eine Warteschlange gestellt werden können und dann mit sehr hoher Geschwindigkeit aus den Cache-Leitungen (vom Cache zur CPU) zur Verfügung stehen, kann die CPU mehr Berechnungen durchführen, ohne lange warten zu müssen.
Viele der Nachteile einer GPU sind auf den Bus zurückzuführen, an den sie angeschlossen ist: Da die GPU im Allgemeinen ein Peripheriegerät ist, kommuniziert sie über den PCI-Bus, der viel langsamer ist als der Frontside-Bus. Daher kann es ziemlich anstrengend sein, Daten in den und aus dem Grafikprozessor zu bekommen. Das Aufkommen des heterogenen Computing, d. h. von Computerblöcken, die sowohl eine CPU als auch eine GPU auf dem Frontside-Bus haben, zielt darauf ab, die Kosten für die Datenübertragung zu senken und das GPU-Computing auch dann verfügbar zu machen, wenn viele Daten übertragen werden müssen.
Zusätzlich zu den Kommunikationsblöcken innerhalb des Computers kann man sich das Netzwerk als einen weiteren Kommunikationsblock vorstellen. Dieser Block ist jedoch viel flexibler als die zuvor genannten. Ein Netzwerkgerät kann mit einem Speichergerät verbunden werden, z. B. mit einem NAS-Gerät (Network Attached Storage) oder mit einem anderen Computerblock, z. B. mit einem Computerknoten in einem Cluster. Allerdings ist die Netzwerkkommunikation in der Regel viel langsamer als die anderen zuvor genannten Kommunikationsarten. Während der Frontside-Bus Dutzende von Gigabit pro Sekunde übertragen kann, ist das Netzwerk auf einige Dutzend Megabit beschränkt.
Es ist also klar, dass die wichtigste Eigenschaft eines Busses seine Geschwindigkeit ist: wie viele Daten er in einer bestimmten Zeitspanne übertragen kann. Diese Eigenschaft ergibt sich aus der Kombination von zwei Größen: wie viele Daten in einer Übertragung bewegt werden können (Busbreite) und wie viele Übertragungen der Bus pro Sekunde durchführen kann (Busfrequenz). Es ist wichtig zu wissen, dass die Daten, die bei einer Übertragung verschoben werden, immer sequentiell sind: Ein Datenpaket wird aus dem Speicher gelesen und an eine andere Stelle verschoben. Daher wird die Geschwindigkeit eines Busses in diese beiden Größen aufgeteilt, weil sie sich auf unterschiedliche Aspekte der Berechnung auswirken können: Eine große Busbreite kann vektorisierten Code (oder jeden Code, der sequentiell durch den Speicher liest) unterstützen, indem sie es ermöglicht, alle relevanten Daten in einem Transfer zu bewegen, während andererseits eine geringe Busbreite, aber eine sehr hohe Transferfrequenz Code helfen kann, der viele Lesevorgänge aus zufälligen Teilen des Speichers durchführen muss. Interessanterweise werden diese Eigenschaften von den Computerdesignern u. a. durch das physische Layout der Hauptplatine verändert: Wenn die Chips nahe beieinander platziert sind, ist die Länge der physischen Drähte, die sie miteinander verbinden, geringer, was zu schnelleren Übertragungsgeschwindigkeiten führen kann. Außerdem bestimmt die Anzahl der Drähte selbst die Breite des Busses (was dem Begriff eine echte physikalische Bedeutung verleiht!).
Da Schnittstellen so eingestellt werden können, dass sie die richtige Leistung für eine bestimmte Anwendung erbringen, ist es nicht verwunderlich, dass es Hunderte von Typen gibt. Abbildung 1-3 zeigt die Bitraten für eine Auswahl gängiger Schnittstellen. Beachte, dass dies nichts über die Latenzzeit der Verbindungen aussagt, die bestimmt, wie lange es dauert, bis eine Datenanfrage beantwortet wird (obwohl die Latenzzeit stark vom Computer abhängt, gibt es einige grundlegende Einschränkungen, die mit den verwendeten Schnittstellen zusammenhängen).
Die grundlegenden Elemente zusammenfügen
Das Verständnis der grundlegenden Komponenten eines Computers reicht nicht aus, um die Probleme der Hochleistungsprogrammierung vollständig zu verstehen. Das Zusammenspiel all dieser Komponenten und wie sie zusammenarbeiten, um ein Problem zu lösen, bringt zusätzliche Komplexitätsebenen mit sich. In diesem Abschnitt werden wir einige Spielzeugprobleme untersuchen, die zeigen, wie die idealen Lösungen funktionieren und wie Python sie angeht.
Eine Warnung: Dieser Abschnitt mag trostlos erscheinen - die meisten Bemerkungen in diesem Abschnitt scheinen zu besagen, dass Python von Haus aus nicht in der Lage ist, mit Leistungsproblemen umzugehen. Das ist aus zwei Gründen nicht wahr. Erstens haben wir bei all den "Komponenten des performanten Rechnens" eine sehr wichtige Komponente vernachlässigt: den Entwickler. Was Python an Leistung vermissen lässt, macht es durch seine Entwicklungsgeschwindigkeit sofort wieder wett. Darüber hinaus werden wir im Laufe des Buches Module und Philosophien vorstellen, mit denen sich viele der hier beschriebenen Probleme relativ einfach abmildern lassen. Durch die Kombination dieser beiden Aspekte werden wir die schnelle Entwicklungsweise von Python beibehalten und gleichzeitig viele der Leistungseinschränkungen beseitigen.
Idealisiertes Computing versus die Python Virtual Machine
Um die Komponenten der Hochleistungsprogrammierung besser zu verstehen, schauen wir uns ein einfaches Codebeispiel an, das überprüft, ob eine Zahl eine Primzahl ist:
import
math
def
check_prime
(
number
):
sqrt_number
=
math
.
sqrt
(
number
)
for
i
in
range
(
2
,
int
(
sqrt_number
)
+
1
):
if
(
number
/
i
)
.
is_integer
():
return
False
return
True
(
f
"check_prime(10,000,000) =
{
check_prime
(
10_000_000
)
}
"
)
# check_prime(10,000,000) = False
(
f
"check_prime(10,000,019) =
{
check_prime
(
10_000_019
)
}
"
)
# check_prime(10,000,019) = True
Analysieren wir diesen Code anhand unseres abstrakten Modells der Berechnung und vergleichen wir dann, was passiert, wenn Python diesen Code ausführt. Wie bei jeder Abstraktion werden wir viele der Feinheiten des idealisierten Computers und der Art und Weise, wie Python den Code ausführt, vernachlässigen. Dennoch ist dies generell eine gute Übung, die man vor dem Lösen eines Problems durchführen sollte: Denke über die allgemeinen Komponenten des Algorithmus nach und überlege, wie die Rechenkomponenten am besten zusammenkommen, um eine Lösung zu finden. Wenn wir diese ideale Situation verstehen und wissen, was tatsächlich unter der Haube von Python passiert, können wir unseren Python-Code iterativ an den optimalen Code heranführen.
Idealisiertes Rechnen
Wenn der Code startet, haben wir den Wert von number
im RAM gespeichert. Um sqrt_number
zu berechnen, müssen wir den Wert von number
an die CPU senden. Idealerweise könnten wir den Wert einmal senden; er würde im L1/L2-Cache der CPU gespeichert werden, und die CPU würde die Berechnungen durchführen und dann die Werte zum Speichern an den RAM zurückbekommen. Dieses Szenario ist ideal, weil wir die Anzahl der Lesevorgänge für den Wert von number
aus dem Arbeitsspeicher minimiert haben und stattdessen die viel schnelleren Lesevorgänge aus dem L1/L2-Cache gewählt haben. Außerdem haben wir die Anzahl der Datenübertragungen über den Frontside-Bus minimiert, indem wir den L1/L2-Cache verwendet haben, der direkt mit der CPU verbunden ist.
Tipp
Das Thema, Daten dort zu halten, wo sie gebraucht werden, und sie so wenig wie möglich zu verschieben, ist sehr wichtig, wenn es um die Optimierung geht. Der Begriff "schwere Daten" bezieht sich auf die Zeit und den Aufwand, die nötig sind, um Daten zu verschieben, was wir vermeiden möchten.
Für die Schleife im Code möchten wir nicht jeweils einen Wert von i
an die CPU senden, sondern sowohl number
als auch mehrere Werte von i
zur gleichen Zeit zur Überprüfung an die CPU senden. Das ist möglich, weil die CPU Operationen ohne zusätzliche Zeitkosten vektorisiert, das heißt, sie kann mehrere unabhängige Berechnungen gleichzeitig durchführen. Wir wollen also number
an den CPU-Cache senden, zusätzlich zu so vielen Werten von i
, wie der Cache aufnehmen kann. Für jedes der number
/i
Paare dividieren wir sie und prüfen, ob das Ergebnis eine ganze Zahl ist; dann senden wir ein Signal zurück, das anzeigt, ob einer der Werte tatsächlich eine ganze Zahl war. Wenn ja, ist die Funktion beendet. Wenn nicht, wiederholen wir sie. Auf diese Weise müssen wir für viele Werte von i
nur ein Ergebnis zurückmelden, anstatt für jeden Wert auf den langsamen Bus angewiesen zu sein. Dies macht sich die Fähigkeit einer CPU zunutze, eine Berechnungzu vektorisieren oder einen Befehl für mehrere Daten in einem Taktzyklus auszuführen.
Dieses Konzept der Vektorisierung wird durch den folgenden Code veranschaulicht:
import
math
def
check_prime
(
number
):
sqrt_number
=
math
.
sqrt
(
number
)
numbers
=
range
(
2
,
int
(
sqrt_number
)
+
1
)
for
i
in
range
(
0
,
len
(
numbers
),
5
):
# the following line is not valid Python code
result
=
(
number
/
numbers
[
i
:(
i
+
5
)])
.
is_integer
()
if
any
(
result
):
return
False
return
True
Hier haben wir die Verarbeitung so eingerichtet, dass die Division und die Prüfung auf Ganzzahlen jeweils für fünf Werte von i
durchgeführt werden. Wenn sie richtig vektorisiert ist, kann die CPU diese Zeile in einem Schritt ausführen, anstatt für jeden i
eine eigene Berechnung durchzuführen. Idealerweise würde die any(result)
Operation auch in der CPU stattfinden, ohne dass die Ergebnisse zurück in den RAM übertragen werden müssen. Mehr über Vektorisierung, wie sie funktioniert und wann sie deinem Code nützt, erfahren wir inKapitel 6.
Die virtuelle Maschine von Python
Der Python-Interpreter leistet eine Menge Arbeit, um die zugrundeliegenden Computerelemente, die verwendet werden, zu abstrahieren. Ein Programmierer muss sich zu keinem Zeitpunkt Gedanken darüber machen, wie er Speicher für Arrays zuweist, wie er diesen Speicher anordnet oder in welcher Reihenfolge er an die CPU gesendet wird. Das ist ein Vorteil von Python, denn so kannst du dich auf die Algorithmen konzentrieren, die implementiert werden. Allerdings hat dies einen enormen Nachteil für die Leistung.
Es ist wichtig zu wissen, dass Python im Kern tatsächlich eine Reihe von sehr optimierten Anweisungen ausführt. Der Trick besteht jedoch darin, Python dazu zu bringen, sie in der richtigen Reihenfolge auszuführen, um eine bessere Leistung zu erzielen. Im folgenden Beispiel ist leicht zu erkennen, dass search_fast
schneller läuft als search_slow
, weil es die unnötigen Berechnungen überspringt, die dadurch entstehen, dass die Schleife nicht frühzeitig beendet wird, obwohl beide Lösungen die Laufzeit O(n)
haben. Die Dinge können jedoch kompliziert werden, wenn es um abgeleitete Typen, spezielle Python-Methoden oder Module von Drittanbietern geht. Kannst du zum Beispiel sofort sagen, welche Funktion schneller ist: search_unknown1
odersearch_unknown2
?
def
search_fast
(
haystack
,
needle
):
for
item
in
haystack
:
if
item
==
needle
:
return
True
return
False
def
search_slow
(
haystack
,
needle
):
return_value
=
False
for
item
in
haystack
:
if
item
==
needle
:
return_value
=
True
return
return_value
def
search_unknown1
(
haystack
,
needle
):
return
any
((
item
==
needle
for
item
in
haystack
))
def
search_unknown2
(
haystack
,
needle
):
return
any
([
item
==
needle
for
item
in
haystack
])
Die Identifizierung langsamer Codebereiche durch Profiling und die Suche nach effizienteren Wegen, um dieselben Berechnungen durchzuführen, ist vergleichbar mit der Suche nach diesen nutzlosen Operationen und deren Entfernung; das Endergebnis ist dasselbe, aber die Anzahl der Berechnungen und Datenübertragungen wird drastisch reduziert.
Eine der Auswirkungen dieser Abstraktionsschicht ist, dass die Vektorisierung nicht sofort möglich ist. Unsere anfängliche Primzahlroutine führt eine Iteration der Schleife pro Wert von i
durch, anstatt mehrere Iterationen zu kombinieren. Wenn wir uns das abstrahierte Vektorisierungsbeispiel ansehen, sehen wir jedoch, dass es kein gültiger Python-Code ist, da wir einen Float nicht durch eine Liste dividieren können. Externe Bibliotheken wie numpy
helfen in dieser Situation, indem sie die Möglichkeit bieten, vektorisierte mathematische Operationen durchzuführen.
Außerdem schadet die Abstraktion von Python allen Optimierungen, die sich darauf verlassen, dass der L1/L2-Cache mit den relevanten Daten für die nächste Berechnung gefüllt bleibt. Dafür gibt es viele Gründe. Der erste ist, dass Python-Objekte nicht optimal im Speicher angeordnet sind. Das liegt daran, dass Python eine Sprache mit Speicherbereinigung ist - der Speicher wird automatisch zugewiesen und bei Bedarf wieder freigegeben. Das führt zu einer Fragmentierung des Speichers, die sich negativ auf die Übertragungen in die CPU-Caches auswirkt. Außerdem gibt es zu keinem Zeitpunkt die Möglichkeit, das Layout einer Datenstruktur direkt im Speicher zu ändern, was bedeutet, dass eine Übertragung auf dem Bus möglicherweise nicht alle relevanten Informationen für eine Berechnung enthält, auch wenn sie alle in die Busbreite gepasst hätten.4
Ein zweites, grundlegenderes Problem ergibt sich aus den dynamischen Typen von Python und der Tatsache, dass die Sprache nicht kompiliert wird. Wie viele C-Programmierer/innen im Laufe der Jahre gelernt haben, ist der Compiler oft schlauer als du selbst. Beim Kompilieren von statischem Code kann der Compiler viele Tricks anwenden, um das Layout und die Art und Weise zu ändern, wie die CPU bestimmte Anweisungen ausführt, um sie zu optimieren. Python wird jedoch nicht kompiliert: Zu allem Übel hat es dynamische Typen, was bedeutet, dass es drastisch schwieriger ist, mögliche Möglichkeiten für Optimierungen algorithmisch abzuleiten, da die Funktionalität des Codes während der Laufzeit geändert werden kann. Es gibt viele Möglichkeiten, dieses Problem zu entschärfen, allen voran die Verwendung von Cython, mit der Python-Code auf kompiliert werden kann und die es dem Benutzer ermöglicht, dem Compiler "Hinweise" zu geben, wie dynamisch der Code tatsächlich ist.
Schließlich kann die oben erwähnte GIL die Leistung beeinträchtigen, wenn du versuchst, diesen Code zu parallelisieren. Nehmen wir zum Beispiel an, dass wir den Code so ändern, dass er mehrere CPU-Kerne verwendet, so dass jeder Kern einen Teil der Zahlen von 2 bis sqrtN
erhält. Jeder Kern kann seine Berechnung für seinen Teil der Zahlen durchführen und dann, wenn alle Berechnungen abgeschlossen sind, können die Kerne ihre Berechnungen vergleichen. Obwohl wir die Schleife nicht vorzeitig beenden können, da jeder Kern nicht weiß, ob eine Lösung gefunden wurde, können wir die Anzahl der Überprüfungen, die jeder Kern durchführen muss, reduzieren (wenn wir M
Kerne hätten, müsste jeder KernsqrtN / M
Überprüfungen durchführen). Aufgrund der GIL kann jedoch immer nur ein Kern gleichzeitig verwendet werden. Das bedeutet, dass wir im Grunde denselben Code wie in der unvergleichlichen Version ausführen würden, aber wir haben keine vorzeitige Beendigung mehr. Wir können dieses Problem vermeiden, indem wir mehrere Prozesse (mit dem Modul multiprocessing
) anstelle von mehreren Threads verwenden, oder indem wir Cython oder fremde Funktionen nutzen.
Warum also Python verwenden?
Python ist sehr ausdrucksstark und leicht zu erlernen - neue Programmierer entdecken schnell, dass sie in kurzer Zeit eine ganze Menge tun können. Viele Python-Bibliotheken umhüllen Werkzeuge, die in anderen Sprachen geschrieben wurden, um den Aufruf anderer Systeme zu vereinfachen. Das maschinelle Lernsystem scikit-learn umhüllt zum Beispiel LIBLINEAR und LIBSVM (die beide in C geschrieben wurden), und die Bibliothek numpy
enthält BLAS und andere C- und Fortran-Bibliotheken. Daher kann Python-Code, der diese Module richtig nutzt, tatsächlich genauso schnell sein wie vergleichbarer C-Code.
Python wird als "batteries included" bezeichnet, da viele wichtige Werkzeuge und stabile Bibliotheken eingebaut sind. Dazu gehören die folgenden:
unicode
undbytes
array
math
-
Grundlegende mathematische Operationen, einschließlich einiger einfacher Statistiken
sqlite3
-
Ein Wrapper um die verbreitete dateibasierte Speicherung SQLite3
collections
-
Eine Vielzahl von Objekten, darunter Deque-, Zähler- und Wörterbuchvarianten
asyncio
-
Gleichzeitige Unterstützung für E/A-gebundene Aufgaben mit async- und await-Syntax
Außerhalb der Kernsprache gibt es eine Vielzahl von Bibliotheken, darunter auch diese:
numpy
-
Eine numerische Python-Bibliothek (eine grundlegende Bibliothek für alles, was mit Matrizen zu tun hat )
scipy
-
Eine sehr große Sammlung zuverlässiger wissenschaftlicher Bibliotheken, die oft hoch angesehene C- und Fortran-Bibliotheken einschließen
pandas
-
Eine Bibliothek zur Datenanalyse, ähnlich den Datenrahmen von R oder einer Excel-Tabelle, die auf
scipy
undnumpy
- scikit-learn
-
Sie entwickelt sich schnell zur Standardbibliothek für maschinelles Lernen und baut auf
scipy
tornado
-
Eine Bibliothek, die einfache Bindungen für Gleichzeitigkeit bietet
- PyTorch und TensorFlow
-
Deep Learning Frameworks von Facebook und Google mit starker Python- und GPU-Unterstützung
NLTK
,SpaCy
, undGensim
-
Bibliotheken zur Verarbeitung natürlicher Sprachen mit umfassender Python-Unterstützung
- Datenbank-Verbindungen
-
Für die Kommunikation mit praktisch allen Datenbanken, einschließlich Redis, MongoDB, HDF5 und SQL
- Webentwicklungs-Frameworks
-
Performante Systeme zur Erstellung von Websites, wie
aiohttp
,django
,pyramid
,flask
, undtornado
OpenCV
- API-Bindungen
-
Für einfachen Zugang zu beliebten Web-APIs wie Google, Twitter und LinkedIn
Es gibt eine große Auswahl an verwalteten Umgebungen und Shells für verschiedene Einsatzszenarien, darunter die folgenden:
-
Die Standard-Distribution, verfügbar unter http://python.org
-
pipenv
,pyenv
undvirtualenv
für einfache, leichtgewichtige und portable Python-Umgebungen -
Docker für einfach zu startende und wiederherzustellende Umgebungen für Entwicklung und Produktion
-
Anaconda Inc.'s Anaconda, eine wissenschaftlich orientierte Umgebung
-
Sage, eine Matlab-ähnliche Umgebung, die eine integrierte Entwicklungsumgebung (IDE) enthält
-
IPython, eine interaktive Python-Shell, die häufig von Wissenschaftlern und Entwicklern verwendet wird
-
Jupyter Notebook, eine browserbasierte Erweiterung von IPython, die häufig für den Unterricht und für Demonstrationen verwendet wird
Eine der Hauptstärken von Python ist, dass es ein schnelles Prototyping einer Idee ermöglicht. Durch die große Auswahl an unterstützenden Bibliotheken ist es einfach zu testen, ob eine Idee realisierbar ist, auch wenn die erste Implementierung vielleicht etwas unausgereift ist.
Wenn du deine mathematischen Routinen schneller machen willst, schau dir numpy
an. Wenn du mit maschinellem Lernen experimentieren willst, probiere scikit-learn aus. Wenn du Daten bereinigst und bearbeitest, ist pandas
eine gute Wahl.
Generell ist es sinnvoll, sich die Frage zu stellen: "Wenn unser System schneller läuft, werden wir als Team dann auf lange Sicht langsamer laufen?" Es ist immer möglich, mehr Leistung aus einem System herauszuholen, wenn genug Arbeitsstunden investiert werden, aber das kann zu spröden und schlecht verstandenen Optimierungen führen, die das Team letztendlich zum Stolpern bringen.
Ein Beispiel dafür ist die Einführung von Cython (siehe "Cython"), einem compilerbasierten Ansatz zur Annotation von Python-Code mit C-ähnlichen Typen, sodass der transformierte Code mit einem C-Compiler kompiliert werden kann. Die Geschwindigkeitsgewinne sind zwar beeindruckend (oft wird mit relativ geringem Aufwand eine C-ähnliche Geschwindigkeit erreicht), aber die Kosten für die Unterstützung dieses Codes werden steigen. Insbesondere könnte es schwieriger sein, dieses neue Modul zu unterstützen, da die Teammitglieder eine gewisse Reife in ihren Programmierfähigkeiten benötigen, um einige der Kompromisse zu verstehen, die beim Verlassen der virtuellen Python-Maschine, die die Leistungssteigerung ermöglicht hat, entstanden sind.
Wie man ein hochleistungsfähiger Programmierer wird
Das Schreiben von leistungsstarkem Code ist nur ein Teil davon, um langfristig erfolgreiche Projekte durchzuführen. Die Gesamtgeschwindigkeit des Teams ist viel wichtiger als Geschwindigkeitssteigerungen und komplizierte Lösungen. Dafür sind mehrere Faktoren entscheidend: eine gute Struktur, Dokumentation, Debugging-Möglichkeiten und gemeinsame Standards.
Angenommen, du erstellst einen Prototyp. Du hast ihn nicht gründlich getestet und er wurde nicht von deinem Team geprüft. Er scheint aber "gut genug" zu sein und wird in die Produktion überführt. Da er nie strukturiert geschrieben wurde, gibt es keine Tests und er ist undokumentiert. Plötzlich gibt es ein Stück Code, das Trägheit verursacht, für das jemand anderes sorgen muss, und oft kann das Management die Kosten für das Team nicht beziffern.
Da diese Lösung schwer zu warten ist, bleibt sie meist ungeliebt - sie wird nie umstrukturiert, erhält keine Tests, die dem Team beim Refactoring helfen würden, und niemand sonst mag sie anfassen, sodass es einem Entwickler überlassen bleibt, sie am Laufen zu halten. Das kann in stressigen Zeiten zu einem schrecklichen Engpass führen und birgt ein großes Risiko: Was würde passieren, wenn dieser Entwickler das Projekt verlässt?
Typischerweise tritt dieser Entwicklungsstil auf, wenn das Managementteam die anhaltende Trägheit nicht versteht, die durch schwer zu pflegenden Code verursacht wird. Wenn du zeigst, dass Tests und Dokumentation einem Team langfristig helfen können, hochproduktiv zu bleiben, kannst du die Manager davon überzeugen, Zeit für die "Bereinigung" des Prototyp-Codes bereitzustellen.
In einer Forschungsumgebung ist es üblich, viele Jupyter Notebooks zu erstellen und dabei schlechte Programmierpraktiken anzuwenden, während Ideen und verschiedene Datensätze durchgespielt werden. DieAbsicht ist immer, es später "richtig zu schreiben", aber diese spätere Phase findet nie statt. Am Ende hat man zwar ein funktionierendes Ergebnis, aber es fehlt die Infrastruktur, um es zu reproduzieren, zu testen und dem Ergebnis zu vertrauen. Auch hier sind die Risikofaktoren hoch, und das Vertrauen in das Ergebnis ist gering.
Es gibt einen allgemeinen Ansatz, der dir gute Dienste leisten wird:
- Mach es möglich
-
Zuerst baust du eine Lösung, die gut genug ist. Es ist sehr sinnvoll, eine Lösung "zum Wegwerfen" zu bauen, die als Prototyp fungiert und es ermöglicht, eine bessere Struktur für die zweite Version zu verwenden. Es ist immer sinnvoll, im Voraus zu planen, bevor man kodiert, sonst denkt man sich: "Wir haben eine Stunde Denkarbeit gespart, indem wir den ganzen Nachmittag kodiert haben." In manchen Bereichen ist das besser bekannt als "Zweimal messen, einmal schneiden".
- Mach es richtig
-
Als Nächstes fügst du eine solide Testsuite mit Dokumentation und klaren Anweisungen zur Reproduzierbarkeit hinzu, damit ein anderes Teammitglied sie übernehmen kann.
- Mach es schnell
-
Schließlich können wir uns auf das Profiling und die Kompilierung oder Parallelisierung konzentrieren und die bestehende Testsuite verwenden, um zu bestätigen, dass die neue, schnellere Lösung immer noch wie erwartet funktioniert.
Gute Arbeitspraktiken
Es gibt ein paar "must haves" - Dokumentation, gute Struktur und Tests sind der Schlüssel.
Eine Dokumentation auf Projektebene hilft dir, eine klare Struktur einzuhalten. Außerdem wird sie dir und deinen Kollegen in Zukunft helfen. Niemand wird dir danken (auch du nicht), wenn du diesen Teil auslässt. Eine README-Datei auf der obersten Ebene ist ein sinnvoller Ausgangspunkt; sie kann bei Bedarf später in einen docs/-Ordnererweitert werden.
Erkläre den Zweck des Projekts, was sich in den Ordnern befindet, woher die Daten kommen, welche Dateien kritisch sind und wie man das Ganze ausführt, einschließlich der Tests.
Micha empfiehlt auch Docker zu verwenden. Ein Dockerfile auf höchster Ebene erklärt deinem zukünftigen Ich genau, welche Bibliotheken du vom Betriebssystem brauchst, damit das Projekt erfolgreich läuft. Außerdem wird dadurch die Schwierigkeit beseitigt, diesen Code auf anderen Rechnern auszuführen oder ihn in einer Cloud-Umgebung einzusetzen.
Füge einen Ordner tests/ hinzu und füge einige Unit-Tests hinzu. Wir bevorzugen pytest
als modernen Test-Runner, da er auf dem in Python eingebauten Modul unittest
aufbaut. Beginne mit ein paar Tests und baue sie dann aus. Verwende später das Tool coverage
, das dir mitteilt, wie viele Zeilen deines Codes tatsächlich von den Tests abgedeckt werden - so kannst du böse Überraschungen vermeiden.
Wenn du alten Code übernimmst und dieser keine Tests enthält, ist es sinnvoll, von vornherein einige Tests hinzuzufügen. Einige "Integrationstests", die den Gesamtablauf des Projekts überprüfen und bestätigen, dass du mit bestimmten Eingabedaten bestimmte Ausgabeergebnisse erhältst, helfen dir, wenn du später Änderungen vornimmst.
Jedes Mal, wenn dich etwas im Code beißt, füge einen Test hinzu. Es lohnt sich nicht, zweimal vom selben Problem gebissen zu werden.
Docstrings in deinem Code für jede Funktion, jede Klasse und jedes Modul werden dir immer helfen. Beschreibe möglichst genau, was die Funktion bewirkt, und füge nach Möglichkeit ein kurzes Beispiel ein, um die erwartete Ausgabe zu demonstrieren. Schau dir die Docstrings in numpy
und scikit-learn an, wenn du dich inspirieren lassen willst.
Wenn dein Code zu lang wird - z. B. bei Funktionen, die länger als ein Bildschirm sind - kannst du ihn gerne kürzer machen. Kürzere Codes sind leichter zu testen und einfacher zu unterstützen.
Tipp
Wenn du deine Tests entwickelst, solltest du darüber nachdenken, eine testgetriebene Entwicklungsmethodik anzuwenden. Wenn du genau weißt, was du entwickeln musst, und du testbare Beispiele zur Hand hast, wird diese Methode sehr effizient.
Du schreibst deine Tests, lässt sie laufen, beobachtest, wie sie fehlschlagen, und fügst dann die Funktionen und die nötige Mindestlogik hinzu, um die von dir geschriebenen Tests zu unterstützen. Wenn deine Tests alle funktionieren, bist du fertig. Wenn du dir die erwartete Ein- und Ausgabe einer Funktion im Voraus überlegst, ist es relativ einfach, die Logik der Funktion zu implementieren.
Wenn du deine Tests nicht im Voraus definieren kannst, stellt sich natürlich die Frage, ob du wirklich verstehst, was deine Funktion tun muss? Wenn nicht, kannst du sie dann auf effiziente Weise richtig schreiben? Diese Methode funktioniert nicht so gut, wenn du dich in einem kreativen Prozess befindest und Daten untersuchst, die du noch nicht gut verstehst.
Verwende immer die Versionskontrolle - du wirst es dir danken, wenn du etwas Kritisches in einem ungünstigen Moment überschreibst. Gewöhne dir an, häufig zu committen (täglich oder sogar alle 10 Minuten) und jeden Tag in dein Repository zu pushen.
Halte dich an den Standard PEP8
Code-Standard. Noch besser ist es, wenn du black
(den Opinionated Code Formatter) mit einem Pre-Commit Source Control Hook einsetzt, damit er deinen Code für dich nach dem Standard umschreibt. Verwende flake8
, um deinen Code zu linsen und andere Fehler zu vermeiden.
Das Erstellen von Umgebungen, die vom Betriebssystem isoliert sind, macht dir das Leben leichter. Ian bevorzugt Anaconda, während Micha pipenv
in Verbindung mit Docker bevorzugt. Beides sind sinnvolle Lösungen und deutlich besser als die Verwendung der globalen Python-Umgebung des Betriebssystems!
Erinnere dich daran, dass Automatisierung dein Freund ist. Wenn du weniger manuelle Arbeit machst, ist die Wahrscheinlichkeit geringer, dass sich Fehler einschleichen. Automatisierte Build-Systeme, kontinuierliche Integration mit automatisierten Testsuiten und automatisierte Verteilungssysteme verwandeln mühsame und fehleranfällige Aufgaben in Standardprozesse, die jeder ausführen und unterstützen kann.
Denke daran, dass Lesbarkeit viel wichtiger ist als Cleverness. Kurze, komplexe und schwer zu lesende Codeschnipsel sind für dich und deine Kollegen schwer zu pflegen, so dass die Mitarbeiter Angst haben werden, diesen Code anzufassen. Schreibe stattdessen eine längere, leichter zu lesende Funktion und füge ihr eine nützliche Dokumentation bei, aus der hervorgeht, was sie zurückgibt, und ergänze sie mit Tests, die bestätigen, dass sie so funktioniert, wie du es erwartest.
Einige Gedanken zur guten Notizbuchpraxis
Wenn du Jupyter Notebooks verwendest, sind sie großartig für die visuelle Kommunikation, aber sie fördern die Faulheit. Wenn du lange Funktionen in deinen Notebooks belässt, solltest du sie in ein Python-Modul extrahieren und dann Tests hinzufügen.
Ziehe in Erwägung, deinen Code in IPython oder der QTConsole zu prototypisieren; verwandle Codezeilen in einem Notebook in Funktionen und verlagere sie dann aus dem Notebook in ein Modul, das durch Tests ergänzt wird. Ziehe schließlich in Betracht, den Code in eine Klasse zu verpacken, wenn Kapselung und Datenverstecken sinnvoll sind.
Verteile assert
Anweisungen großzügig in einem Notizbuch, um zu prüfen, ob sich deine Funktionen wie erwartet verhalten. Du kannst Code in einem Notizbuch nicht einfach testen, und bis du deine Funktionen in separate Module umstrukturiert hast, sind assert
Prüfungen eine einfache Möglichkeit, eine gewisse Validierung hinzuzufügen. Du solltest diesem Code erst dann vertrauen, wenn du ihn in ein Modul extrahiert und sinnvolle Unit-Tests geschrieben hast.
Die Verwendung von assert
Anweisungen zur Überprüfung von Daten in deinem Code sollte verpönt sein. Es ist ein einfacher Weg, um zu behaupten, dass bestimmte Bedingungen erfüllt sind, aber es ist kein idiomatisches Python. Um deinen Code für andere Entwickler/innen leichter lesbar zu machen, solltest du den erwarteten Datenzustand prüfen und eine entsprechende Ausnahme auslösen, wenn die Prüfung fehlschlägt. Eine gängige Ausnahme wäre ValueError
, wenn eine Funktion auf einen unerwarteten Wert stößt. DieBibliothek Bulwark ist ein Beispiel für ein Test-Framework, das sich auf Pandas konzentriert, um zu prüfen, ob deine Daten den vorgegebenen Einschränkungen entsprechen.
Vielleicht möchtest du am Ende deines Notizbuchs auch einige Sicherheitsprüfungen hinzufügen - eine Mischung aus Logikprüfungen und raise
und print
Anweisungen, die zeigen, dass du genau das erzeugt hast, was du brauchst. Wenn du in sechs Monaten zu diesem Code zurückkehrst, wirst du dir selbst dafür danken, dass du es dir leicht gemacht hast, zu sehen, dass er die ganze Zeit über richtig funktioniert hat!
Eine Schwierigkeit bei Notebooks ist der Austausch von Code mit Versionskontrollsystemen.nbdime ist eines der vielen neuen Tools, mit denen du deine Notebooks verbreiten kannst. Es ist ein Lebensretter und ermöglicht die Zusammenarbeit mit Kollegen.
Wie du die Freude an deiner Arbeit zurückbekommst
Das Leben kann kompliziert sein. In den fünf Jahren, seit eure Autorinnen und Autoren die erste Ausgabe dieses Buches geschrieben haben, haben wir gemeinsam mit Freunden und Verwandten eine Reihe von Lebenssituationen erlebt, darunter Depressionen, Krebs, Umzüge, erfolgreiche Geschäftsabschlüsse und Misserfolge sowie berufliche Richtungswechsel. Diese äußeren Ereignisse haben unweigerlich Auswirkungen auf die Arbeit und die Lebensperspektive eines jeden.
Erinnere dich daran, immer wieder nach der Freude an neuen Aktivitäten zu suchen. Es gibt immer interessante Details oder Anforderungen, wenn du anfängst, herumzustochern. Du fragst dich vielleicht: "Warum haben sie diese Entscheidung getroffen?" und "Wie würde ich es anders machen?" und schon bist du bereit, ein Gespräch darüber zu beginnen, wie die Dinge verändert oder verbessert werden könnten.
Führe ein Tagebuch über die Dinge, die es wert sind, gefeiert zu werden. Es ist so leicht, Leistungen zu vergessen und sich im Alltag zu verlieren. Die Leute sind ausgebrannt, weil sie immer auf der Hut sein müssen, und vergessen, wie viele Fortschritte sie gemacht haben.
Wir schlagen vor, dass du eine Liste mit Dingen erstellst, die es wert sind, gefeiert zu werden, und notierst, wie du sie feierst. Ian führt eine solche Liste und ist immer wieder überrascht, wenn er die Liste aktualisiert und sieht, wie viele coole Dinge im letzten Jahr passiert sind (die er sonst vielleicht vergessen hätte!). Dabei sollte es sich nicht nur um Meilensteine in der Arbeit handeln, sondern auch um Hobbys und Sport und um das Feiern der erreichten Meilensteine. Micha achtet darauf, ihr Privatleben in den Vordergrund zu stellen und verbringt auch mal Tage abseits des Computers, um an nicht-technischen Projekten zu arbeiten. Es ist wichtig, deine Fähigkeiten weiterzuentwickeln, aber es ist nicht notwendig, auszubrennen!
Programmieren, vor allem wenn es leistungsorientiert ist, lebt von der Neugier und der Bereitschaft, sich immer tiefer in die technischen Details zu vertiefen. Leider ist diese Neugier das Erste, was verschwindet, wenn du ausgebrannt bist; nimm dir also Zeit und sorge dafür, dass du die Reise genießt und dir die Freude und Neugier erhältst.
1 Nicht zu verwechseln mit der Interprozess-Kommunikation, die das gleiche Akronym hat - wir werden uns mit diesem Thema in Kapitel 9 beschäftigen.
2 Die Geschwindigkeiten in diesem Abschnitt stammen von https://oreil.ly/pToi7.
3 Die Daten stammen von https://oreil.ly/7SC8d.
4 In Kapitel 6 werden wir sehen, wie wir diese Kontrolle zurückgewinnen und unseren Code bis hin zu den Arbeitsspeicherauslastungsmustern abstimmen können.
Get High Performance Python, 2. Auflage now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.