Hallo an alle. Heute möchten wir eine weitere Übersetzung teilen, die am Vorabend des Starts des
Python Developer- Kurses erstellt wurde. Lass uns gehen!

Ich habe Python in den letzten 4-5 Jahren häufiger als jede andere Programmiersprache verwendet. Python ist die vorherrschende Sprache für Builds unter Firefox, Tests und dem CI-Tool. Mercurial ist auch meistens in Python geschrieben. Ich habe auch viele meiner Projekte von Drittanbietern darüber geschrieben.
Während meiner Arbeit habe ich ein wenig Wissen über die Python-Leistung und ihre Optimierungstools gewonnen. In diesem Artikel möchte ich dieses Wissen teilen.
Meine Erfahrung mit Python bezieht sich hauptsächlich auf den CPython-Interpreter, insbesondere CPython 2.7. Nicht alle meine Beobachtungen sind universell für alle Python-Distributionen oder für diejenigen, die in ähnlichen Versionen von Python dieselben Eigenschaften aufweisen. Ich werde versuchen, dies während der Erzählung zu erwähnen. Beachten Sie, dass dieser Artikel keinen detaillierten Überblick über die Leistung von Python bietet. Ich werde nur darüber sprechen, was mir selbst begegnet ist.
Die Belastung aufgrund der Besonderheiten beim Starten und Importieren von Modulen
Das Starten des Python-Interpreters und das Importieren der Module ist in Millisekunden ziemlich langwierig.
Wenn Sie in einem Ihrer Projekte Hunderte oder Tausende von Python-Prozessen starten müssen, führt diese Verzögerung in Millisekunden zu einer Verzögerung von bis zu mehreren Sekunden.
Wenn Sie Python verwenden, um CLI-Tools bereitzustellen, kann der Overhead den Benutzer spürbar einfrieren lassen. Wenn Sie sofort CLI-Tools benötigen, wird es schwieriger, dieses komplexe Tool zu erhalten, wenn Sie den Python-Interpreter bei jedem Aufruf ausführen.
Ich habe bereits über dieses Problem geschrieben. Einige meiner früheren Notizen sprechen darüber, zum Beispiel
2014 ,
im Mai 2018 und im
Oktober 2018 .
Es gibt nicht viele Dinge, die Sie tun können, um die Startverzögerung zu verringern: Das Beheben dieses Falls bezieht sich auf die Manipulation des Python-Interpreters, da er die Ausführung des Codes steuert, was zu viel Zeit in Anspruch nimmt. Das Beste, was Sie tun können, ist, den Import des
Site- Moduls in Aufrufen zu deaktivieren, um zu vermeiden, dass beim Start zusätzlicher Python-Code ausgeführt wird. Andererseits verwenden viele Anwendungen die Funktionalität des Site.py-Moduls, sodass Sie diese auf eigenes Risiko verwenden können.
Wir sollten auch das Problem des Imports von Modulen berücksichtigen. Was nützt der Python-Interpreter, wenn er keinen Code verarbeitet? Tatsache ist, dass der Code dem Interpreter durch die Verwendung von Modulen häufiger zur Verfügung gestellt wird.
Um Module zu importieren, müssen Sie mehrere Schritte ausführen. Und in jedem von ihnen gibt es eine potenzielle Quelle für Belastungen und Verzögerungen.
Eine gewisse Verzögerung tritt auf, wenn nach Modulen gesucht und deren Daten gelesen werden. Wie ich mit
PyOxidizer demonstriert
habe , können Sie die Standard-Python-Bibliothek für 70-80% der anfänglichen Lösungszeit für diese Aufgabe
importieren , indem Sie das Suchen und Laden eines Moduls aus einem Dateisystem durch eine architektonisch einfachere Lösung ersetzen, die darin besteht, Moduldaten aus einer Datenstruktur im Speicher zu lesen. Ein Modul pro Dateisystemdatei erhöht die Belastung des Dateisystems und kann eine Python-Anwendung während der kritischen ersten Millisekunden der Ausführung verlangsamen. Lösungen wie PyOxidizer können dies vermeiden. Ich hoffe, dass die Python-Community diese Kosten des aktuellen Ansatzes sieht und den Übergang zu den Verteilungsmechanismen der Module in Betracht zieht, die nicht so sehr von den einzelnen Dateien im Modul abhängen.
Eine weitere Quelle für zusätzliche Importkosten für ein Modul ist die Ausführung von Code in diesem Modul während des Imports. Einige Module enthalten Teile des Codes in einem Bereich außerhalb der Funktionen und Klassen des Moduls, der beim Import des Moduls ausgeführt wird. Das Ausführen eines solchen Codes erhöht die Importkosten. Problemumgehung: Führen Sie zum Zeitpunkt des Imports nicht den gesamten Code aus, sondern nur bei Bedarf. Python 3.7 unterstützt das Modul
__getattr__
, das aufgerufen wird, wenn das Attribut eines Moduls nicht gefunden wurde. Dies kann verwendet werden, um Modulattribute beim ersten Zugriff träge zu füllen.
Eine andere Möglichkeit, die Verlangsamung des Imports zu beseitigen, besteht darin, das Modul träge zu importieren. Anstatt das Modul direkt während des Imports zu laden, registrieren Sie ein benutzerdefiniertes Importmodul, das stattdessen einen Stub zurückgibt. Wenn Sie zum ersten Mal auf diesen Stub zugreifen, wird das eigentliche Modul geladen und "mutiert", um dieses Modul zu werden.
Sie können mit Anwendungen, die mehrere zehn Module importieren, mehrere zehn Millisekunden sparen, wenn Sie das Dateisystem umgehen und vermeiden, dass unnötige Teile des Moduls ausgeführt werden (Module werden normalerweise global importiert, es werden jedoch nur bestimmte Modulfunktionen verwendet).
Der träge Import von Modulen ist eine fragile Sache. Viele Module haben Vorlagen, die Folgendes enthalten:
try: import foo
;
except ImportError:
Ein fauler Modulimporter darf niemals einen ImportError auslösen, da er in diesem Fall im Dateisystem nach einem Modul suchen muss, um festzustellen, ob es im Prinzip vorhanden ist. Dies erhöht die zusätzliche Belastung und erhöht den Zeitaufwand, sodass faule Importeure dies im Prinzip nicht tun! Dieses Problem ist ziemlich ärgerlich. Importeur von faulen Modulen Mercurial verarbeitet eine Liste von Modulen, die nicht träge importiert werden können, und muss diese umgehen. Ein weiteres Problem ist die Syntax
from foo import x, y
, die auch den Import des Lazy-Moduls unterbricht, wenn foo ein Modul ist (im Gegensatz zu einem Paket), da das Modul noch importiert werden muss, um einen Verweis auf x und y zurückzugeben.
PyOxidizer verfügt über einen festen Satz von Modulen, die mit der Binärdatei verbunden sind, sodass ImportError effektiv ausgelöst werden kann. Das __getattr__- Modul aus Python 3.7 bietet faulen Modulimporteuren zusätzliche Flexibilität. Ich hoffe, einen zuverlässigen Lazy Importer in PyOxidizer zu integrieren, um einige Prozesse zu automatisieren.
Die beste Lösung, um das Starten des Interpreters und das Verursachen von Zeitverzögerungen zu vermeiden, besteht darin, den Hintergrundprozess in Python zu starten. Wenn Sie den Python-Prozess als Daemon-Prozess starten, beispielsweise für einen Webserver, können Sie dies tun. Die von Mercurial angebotene Lösung besteht darin, einen Hintergrundprozess zu starten, der ein
Befehlsserverprotokoll bereitstellt. hg ist die ausführbare C-Datei (oder jetzt Rust), die eine Verbindung zu diesem Hintergrundprozess herstellt und einen Befehl sendet. Um einen Zugang zum Befehlsserver zu finden, müssen Sie viel arbeiten, er ist extrem instabil und weist Sicherheitsprobleme auf. Ich denke über die Idee nach, einen Befehlsserver mit PyOxidizer bereitzustellen, damit die ausführbare Datei ihre Vorteile hat, und das Problem der Kosten der Softwarelösung selbst wurde durch die Erstellung des PyOxidizer-Projekts gelöst.
Funktionsaufrufverzögerung
Das Aufrufen von Funktionen in Python ist ein relativ langsamer Prozess. (Diese Beobachtung gilt weniger für PyPy, das JIT-Code ausführen kann.)
Ich habe Dutzende von Patches für Mercurial gesehen, die es ermöglichten, den Code so auszurichten und zu kombinieren, dass beim Aufrufen von Funktionen keine unnötige Belastung auftritt. Im aktuellen Entwicklungszyklus wurden einige Anstrengungen unternommen, um die Anzahl der aufgerufenen Funktionen beim Aktualisieren des Fortschrittsbalkens zu verringern. (Wir verwenden Fortschrittsbalken für alle Vorgänge, die einige Zeit in Anspruch nehmen können, damit der Benutzer versteht, was passiert.) Das Abrufen der Ergebnisse des Aufrufens von
Funktionen und das Vermeiden einfacher Suchen zwischen
Funktionen spart bei der Ausführung Zehntausende von Millisekunden, wenn wir beispielsweise von einer Million Ausführungen sprechen.
Wenn Sie in Python enge Schleifen oder rekursive Funktionen haben, bei denen Hunderttausende oder mehr Funktionsaufrufe auftreten können, sollten Sie sich des Overheads beim Aufrufen einer einzelnen Funktion bewusst sein, da dies von großer Bedeutung ist. Denken Sie an einfache integrierte Funktionen und die Möglichkeit, Funktionen zu kombinieren, um Overhead zu vermeiden.
Aufwand für die Attributsuche
Dieses Problem ähnelt dem Overhead aufgrund eines Funktionsaufrufs, da die Bedeutung fast gleich ist!
Das Auflösen von Attributen in Python kann langsam sein. (Und wieder ist dies in PyPy schneller). Die Behandlung dieses Problems ist jedoch das, was wir in Mercurial häufig tun.
Angenommen, Sie haben den folgenden Code:
obj = MyObject() total = 0 for i in len(obj.member): total += obj.member[i]
Lassen Sie weg, dass es effizientere Möglichkeiten gibt, dieses Beispiel zu schreiben (z. B.
total = sum(obj.member)
), und beachten Sie, dass die Schleife bei jeder Iteration obj.member definieren muss. Python verfügt über einen relativ ausgeklügelten Mechanismus zum Definieren von
Attributen . Für einfache Typen kann es schnell genug sein. Bei komplexen Typen kann dieser Attributzugriff jedoch automatisch
__getattr__
,
__getattribute__
, verschiedene
dunder
Methoden und sogar benutzerdefinierte Funktionen von
@property
. Dies ähnelt einer schnellen Suche nach einem Attribut, das mehrere Funktionsaufrufe ausführen kann, was zu einer zusätzlichen Belastung führt. Und diese Last kann sich verschlimmern, wenn Sie Dinge wie
obj.member1.member2.member3
usw. verwenden.
Jede Attributdefinition verursacht eine zusätzliche Belastung. Und da fast alles in Python ein Wörterbuch ist, können wir sagen, dass jede Attributsuche eine Wörterbuchsuche ist. Aus allgemeinen Konzepten über grundlegende Datenstrukturen wissen wir, dass die Wörterbuchsuche nicht so schnell ist wie beispielsweise die Indexsuche. Ja, natürlich gibt es in CPython einige Tricks, die den Overhead aufgrund von Wörterbuchsuchen beseitigen können. Das Hauptthema, das ich ansprechen möchte, ist jedoch, dass jede Attributsuche ein potenzielles Leistungsleck darstellt.
Bei engen Schleifen, insbesondere solchen, die möglicherweise Hunderttausende von Iterationen überschreiten, können Sie diesen messbaren Overhead beim Suchen von Attributen vermeiden, indem Sie einer lokalen Variablen einen Wert zuweisen. Schauen wir uns das folgende Beispiel an:
obj = MyObject() total = 0 member = obj.member for i in len(member): total += member[i]
Dies kann natürlich nur sicher durchgeführt werden, wenn es nicht in einem Zyklus ersetzt wird. In diesem Fall behält der Iterator eine Verbindung zum alten Element bei und alles kann explodieren.
Der gleiche Trick kann ausgeführt werden, wenn die Methode des Objekts aufgerufen wird. Stattdessen
obj = MyObject() for i in range(1000000): obj.process(i)
Sie können Folgendes tun:
obj = MyObject() fn = obj.process for i in range(1000000:) fn(i)
Es ist auch erwähnenswert, dass Python 3.7 in dem Fall, in dem die Attributsuche eine Methode aufrufen muss (wie im vorherigen Beispiel), relativ
schneller ist als frühere Versionen. Ich bin mir aber sicher, dass hier die übermäßige Belastung zunächst mit dem Funktionsaufruf und nicht mit der Belastung der Attributsuche zusammenhängt. Daher funktioniert alles schneller, wenn Sie die zusätzliche Suche nach Attributen aufgeben.
Da eine Attributsuche eine Funktion dafür aufruft, kann schließlich gesagt werden, dass die Attributsuche im Allgemeinen weniger problematisch ist als eine Belastung aufgrund eines Funktionsaufrufs. Um signifikante Änderungen in der Geschwindigkeit festzustellen, müssen Sie normalerweise viele Attributsuchen eliminieren. In diesem Fall können Sie, sobald Sie Zugriff auf alle Attribute in der Schleife gewähren, nur in der Schleife über 10 oder 20 Attribute sprechen, bevor Sie die Funktion aufrufen. Und Schleifen mit nur Tausenden oder weniger als Zehntausenden von Iterationen können schnell Hunderttausende oder Millionen von Attributsuchen ermöglichen. Also sei vorsichtig!
Objekt laden
Aus Sicht des Python-Interpreters sind alle Werte Objekte. In CPython ist jedes Element eine PyObject-Struktur. Jedes vom Interpreter gesteuerte Objekt befindet sich auf dem Heap und verfügt über einen eigenen Speicher, der den Referenzzähler, den Objekttyp und andere Parameter enthält. Jedes Objekt wird vom Garbage Collector entsorgt. Dies bedeutet, dass jedes neue Objekt aufgrund von Referenzzählung, Speicherbereinigung usw. zusätzlichen Aufwand verursacht. (Auch hier kann PyPy diese unnötige Belastung vermeiden, da es die Lebensdauer kurzfristiger Werte „sorgfältiger“ beeinflusst.)
Je mehr eindeutige Werte und Python-Objekte Sie erstellen, desto langsamer funktionieren die Dinge für Sie.
Angenommen, Sie durchlaufen eine Sammlung von einer Million Objekten. Sie rufen eine Funktion auf, um dieses Objekt in einem Tupel zu sammeln:
for x in my_collection: a, b, c, d, e, f, g, h = process(x)
In diesem Beispiel gibt
process()
ein 8-Tupel-Tupel zurück. Es spielt keine Rolle, ob wir den Rückgabewert zerstören oder nicht: Für dieses Tupel müssen in Python mindestens 9 Werte erstellt werden: 1 für das Tupel selbst und 8 für seine internen Mitglieder. Im wirklichen Leben gibt es möglicherweise weniger Werte, wenn
process()
einen Verweis auf ein vorhandenes Objekt zurückgibt. Im Gegenteil, es kann mehr geben, wenn ihre Typen nicht einfach sind und viele PyObjects darstellen müssen. Ich möchte nur sagen, dass sich unter der Haube des Dolmetschers ein echtes Jonglieren von Objekten befindet, um bestimmte Konstruktionen vollständig darzustellen.
Aus eigener Erfahrung kann ich sagen, dass diese Gemeinkosten nur für Vorgänge relevant sind, die Geschwindigkeitsgewinne erzielen, wenn sie in einer Muttersprache wie C oder Rust implementiert werden. Das Problem ist, dass der CPython-Interpreter den Bytecode einfach nicht so schnell ausführen kann, dass die zusätzliche Last aufgrund der Anzahl der Objekte von Bedeutung ist. Stattdessen verringern Sie höchstwahrscheinlich die Leistung durch Aufrufen einer Funktion oder durch umständliche Berechnungen usw. bevor Sie die zusätzliche Belastung durch Objekte bemerken können. Es gibt natürlich einige Ausnahmen, nämlich die Konstruktion von Tupeln oder Wörterbüchern mit mehreren Werten.
Als konkretes Beispiel für Overhead können Sie Mercurial mit C-Code zitieren, der Datenstrukturen auf niedriger Ebene analysiert. Für eine höhere Analysegeschwindigkeit wird C-Code um eine Größenordnung schneller ausgeführt als CPython. Sobald der C-Code jedoch PyObject erstellt, um das Ergebnis darzustellen, sinkt die Geschwindigkeit mehrmals. Mit anderen Worten, beim Laden werden Python-Elemente erstellt und verwaltet, damit sie im Code verwendet werden können.
Ein Weg, um dieses Problem zu umgehen, besteht darin, weniger Elemente in Python zu erzeugen. Wenn Sie sich auf ein einzelnes Element beziehen müssen, starten Sie die Funktion und geben Sie sie zurück, und nicht ein Tupel oder ein Wörterbuch mit N Elementen. Hören Sie jedoch nicht auf, die mögliche Last aufgrund von Funktionsaufrufen zu überwachen!
Wenn Sie viel Code haben, der mit der CPython C-API schnell genug funktioniert, und Elemente, die auf verschiedene Module verteilt werden müssen, verzichten Sie auf Python-Typen, die unterschiedliche Daten als C-Strukturen darstellen und bereits Code für den Zugriff auf diese Strukturen kompiliert haben anstatt die CPython C-API zu durchlaufen. Wenn Sie die CPython C-API für den Zugriff auf Daten vermeiden, wird viel zusätzliche Last vermieden.
Das Behandeln von Elementen als Daten (anstatt Funktionen zu haben, um auf alles in einer Reihe zuzugreifen) wäre der beste Ansatz für einen Pythonisten. Eine weitere Problemumgehung für bereits kompilierten Code besteht darin, PyObject träge zu instanziieren. Wenn Sie in Python einen benutzerdefinierten Typ erstellen (PyTypeObject), um komplexe Elemente darzustellen, müssen Sie die
Felder tp_members oder
tp_getset definieren, um benutzerdefinierte C-Funktionen zu erstellen und den Wert für das Attribut
nachzuschlagen . Wenn Sie beispielsweise einen Parser schreiben und wissen, dass Kunden nur auf eine Teilmenge der analysierten Felder zugreifen können, können Sie schnell einen Typ mit Rohdaten erstellen, diesen Typ zurückgeben und eine C-Funktion aufrufen, um nach Python-Attributen zu suchen, die PyObject verarbeiten. Sie können das Parsen sogar verzögern, bis die Funktion aufgerufen wird, um Ressourcen zu sparen, wenn das Parsen nie benötigt wird! Diese Technik ist ziemlich selten, da sie das Schreiben von nicht trivialem Code erfordert, aber ein positives Ergebnis liefert.
Vorläufige Bestimmung der Sammlungsgröße
Dies gilt für die CPython C-API.
Verwenden
PyList_New()
beim Erstellen von Sammlungen, z. B. Listen oder Wörterbüchern,
PyList_New()
+
PyList_SET_ITEM()
, um eine neue Sammlung zu
PyList_SET_ITEM()
wenn ihre Größe zum Zeitpunkt der Erstellung bereits definiert ist. Dadurch wird die Größe der Sammlung vorab festgelegt, um eine begrenzte Anzahl von Elementen enthalten zu können. Auf diese Weise können Sie beim Einfügen von Elementen die Überprüfung auf eine ausreichende Sammlungsgröße überspringen. Wenn Sie eine Sammlung von Tausenden von Elementen erstellen, sparen Sie einige Ressourcen!
Verwenden von Zero-Copy in der C-API
Die Python C-API erstellt sehr gerne Kopien von Objekten, anstatt Verweise darauf zurückzugeben. Beispielsweise
kopiert PyBytes_FromStringAndSize () char*
in den von Python reservierten Speicher. Wenn Sie dies für eine große Anzahl von Werten oder Big Data tun, können wir über Gigabyte Speicher-E / A und die damit verbundene Belastung des Allokators sprechen.
Wenn Sie Hochleistungscode ohne C-API schreiben müssen, sollten Sie sich mit dem
Pufferprotokoll und den zugehörigen Typen wie der
Speicheransicht vertraut machen .Buffer protocol
ist in Python-Typen integriert und ermöglicht es Interpreten, Typen von / in Bytes umzuwandeln. Außerdem kann der C-Code-Interpreter einen
void*
-Deskriptor einer bestimmten Größe empfangen. Auf diese Weise können Sie PyObject eine beliebige Adresse im Speicher zuordnen. Viele Funktionen, die mit Binärdaten arbeiten, akzeptieren transparent alle Objekte, die das
buffer protocol
implementieren. Wenn Sie ein Objekt akzeptieren möchten, das als Byte betrachtet werden kann, müssen Sie beim Empfang von Funktionsargumenten
Einheiten im Format s*
,
y*
oder
w*
verwenden .
Mit dem
buffer protocol
geben Sie dem Interpreter die beste verfügbare Möglichkeit,
zero-copy
verwenden und das Kopieren zusätzlicher Bytes in den Speicher zu verweigern.
Wenn Sie in Python Typen der Formularspeicheransicht
memoryview
, können Sie Python auch erlauben, über Referenz auf Speicherebenen zuzugreifen, anstatt Kopien zu
memoryview
.
Wenn Sie über Gigabyte Code verfügen, der Ihr Python-Programm durchläuft, erspart Ihnen die aufschlussreiche Verwendung von Python-Typen, die das Nullkopieren unterstützen, Leistungsunterschiede. Ich habe einmal bemerkt, dass
Python-zstandard schneller ist als alle Python LZ4-Bindungen (obwohl es umgekehrt sein sollte), da ich das
python-zstandard
zu oft verwendet und übermäßige Speicher-E / A in
python-zstandard
vermieden
python-zstandard
!
Fazit
In diesem Artikel wollte ich über einige Dinge sprechen, die ich bei der Optimierung meiner Python-Programme für mehrere Jahre gelernt habe. Ich wiederhole und sage, dass es in keiner Weise einen umfassenden Überblick über die Methoden zur Verbesserung der Python-Leistung gibt. Ich gebe zu, dass ich Python wahrscheinlich anspruchsvoller als andere verwende und meine Empfehlungen nicht auf alle Programme angewendet werden können.
Sie sollten Ihren Python-Code keinesfalls massiv korrigieren und beispielsweise die Suche nach Attributen nach dem Lesen dieses Artikels entfernen . Wie immer, wenn es um Leistungsoptimierung geht, korrigieren Sie zuerst, wo der Code besonders langsam ist.
py-spy Python. , , Python, . , , , !
, Python . , , Python - . Python – PyPy, . Python . , Python , . , « ». , , , Python, , , .
;-)