„Python ist zu langsam.“

Die­ser Satz wird häu­fig in Dis­kus­sio­nen über Pro­gram­mier­spra­chen geäu­ßert und über­schat­tet oft die zahl­rei­chen Stär­ken von Python.

Die Wahr­heit ist, dass Python schnell ist, wenn man es auf pytho­ni­sche Weise schrei­ben kann.

Der Teu­fel steckt im Detail. Erfah­rene Python-Ent­wick­ler ver­fü­gen über ein gan­zes Arse­nal klei­ner, aber wir­kungs­vol­ler Tricks, mit denen sie die Leis­tung ihres Codes erheb­lich stei­gern können.

Diese Tricks mögen auf den ers­ten Blick unbe­deu­tend erschei­nen, aber sie kön­nen zu erheb­li­chen Effi­zi­enz­stei­ge­run­gen füh­ren. Im Fol­gen­den wer­den 9 die­ser Ansätze vor­ge­stellt, die die Art und Weise, wie Sie Python-Code schrei­ben und opti­mie­ren, ver­än­dern werden.

1. Schnel­lere String-Ver­ket­tung: Gekonnt „join()“ oder „+“ wählen

Die Ver­ket­tung von Strings wird zu einem Eng­pass in Ihrem Python-Pro­gramm, wenn eine große Anzahl von Strings dar­auf war­tet, ver­ar­bei­tet zu werden.

Grund­sätz­lich gibt es zwei Mög­lich­kei­ten der String-Ver­ket­tung in Python:

  1. Ver­wen­den Sie die Funk­tion join(), um eine Liste von Zei­chen­ket­ten zu einer ein­zi­gen zusammenzufügen.
  2. Ver­wen­den Sie das Sym­bol + oder +=, um jede ein­zelne Zei­chen­kette zu einer ein­zi­gen zu addie­ren
    Wel­cher Weg ist also schneller?

Las­sen Sie uns 3 ver­schie­dene Funk­tio­nen für die Ver­ket­tung der glei­chen Strings definieren:

mylist = ["Yang", "Zhou", "is", "writing"]


# Using '+'
def concat_plus():
    result = ""
    for word in mylist:
        result += word + " "
    return result


# Using 'join()'
def concat_join():
    return " ".join(mylist)


# Directly concatenation without the list
def concat_directly():
    return "Yang" + "Zhou" + "is" + "writing"

Wel­che Funk­tion ist nach Ihrem ers­ten Ein­druck die schnellste und wel­che die langsamste?

Das tat­säch­li­che Ergeb­nis wird Sie viel­leicht überraschen:

import timeit

print(timeit.timeit(concat_plus, number=10000))
# 0.002738415962085128
print(timeit.timeit(concat_join, number=10000))
# 0.0008482920238748193
print(timeit.timeit(concat_directly, number=10000))
# 0.00021425005979835987

Wie oben gezeigt, ist die join()-Methode bei der Ver­ket­tung einer Liste von Strings schnel­ler als das Hin­zu­fü­gen der eizel­nen Strings.

Der Grund dafür ist ganz ein­fach. Einer­seits sind Strings in Python unver­än­der­li­che Daten, jede +=-Ope­ra­tion führt zur Erstel­lung einer neuen Zei­chen­kette und zum Kopie­ren der alten Zei­chen­kette, was rechen­in­ten­siv ist.

Ande­rer­seits ist die Methode .join() spe­zi­ell für das Ver­bin­den einer Folge von Zei­chen­ket­ten opti­miert. Sie berech­net die Größe der resul­tie­ren­den Zei­chen­kette im Vor­aus und baut sie dann in einem Durch­gang auf. So ver­mei­det sie den Over­head, der mit der +=-Ope­ra­tion in einer Schleife ver­bun­den ist, und ist daher schneller.

Die schnellste Funk­tion in unse­ren Tests ist jedoch die direkte Ver­ket­tung von String-Lite­ra­len. Ihre hohe Geschwin­dig­keit ist zurück­zu­füh­ren auf:

  • Der Python-Inter­pre­ter kann die Ver­ket­tung von String-Lite­ra­len zur Kom­pi­lier­zeit opti­mie­ren, indem er sie in ein ein­zi­ges String-Lite­ral ver­wan­delt. Es sind keine Schlei­fen­ite­ra­tio­nen oder Funk­ti­ons­auf­rufe erfor­der­lich, so dass es sich um einen sehr effi­zi­en­ten Vor­gang handelt.
  • Da alle Zei­chen­ket­ten zur Kom­pi­lie­rungs­zeit bekannt sind, kann Python diese Ope­ra­tion sehr schnell durch­füh­ren, viel schnel­ler als die Ver­ket­tung zur Lauf­zeit in einer Schleife oder sogar die opti­mierte Methode .join().

Kurz gesagt, wenn Sie eine Liste von Strings ver­ket­ten müs­sen, wäh­len Sie join() statt +=. Wenn Sie Strings direkt ver­ket­ten möch­ten, ver­wen­den Sie ein­fach + dazu.

2. Schnel­lere Lis­ten­er­stel­lung: Ver­wen­den Sie „[]“ über „list()“

Die Erstel­lung einer Liste ist keine große Sache. Zwei gän­gige Metho­den sind:

  1. Ver­wen­den Sie die Funk­tion list()
  2. Direkte Ver­wen­dung von []
    Las­sen Sie uns einen ein­fa­chen Code­schnip­sel ver­wen­den, um ihre Leis­tung zu testen:
import timeit

print(timeit.timeit('[]', number=10 ** 7))
# 0.1368238340364769
print(timeit.timeit(list, number=10 ** 7))
# 0.2958830420393497

Wie das Ergeb­nis zeigt, ist die Aus­füh­rung der Funk­tion list() lang­sa­mer als die direkte Ver­wen­dung von [].

Das liegt daran, dass [] eine Lite­ral-Syn­tax ist, wäh­rend list() ein Kon­struk­tor­auf­ruf ist. Der Auf­ruf einer Funk­tion erfor­dert zwei­fel­los zusätz­li­che Zeit.

Aus der glei­chen Logik her­aus soll­ten wir beim Erstel­len eines Wör­ter­buchs auch {} über dict() nutzen.

3. Schnel­le­res Tes­ten der Mit­glied­schaft: Eine Menge statt einer Liste verwenden

Die Leis­tung eines Mit­glied­schafts­prü­fungs­vor­gangs hängt stark von den zugrunde lie­gen­den Daten­struk­tu­ren ab:

import timeit

large_dataset = range(100000)
search_element = 2077

large_list = list(large_dataset)
large_set = set(large_dataset)


def list_membership_test():
    return search_element in large_list


def set_membership_test():
    return search_element in large_set


print(timeit.timeit(list_membership_test, number=1000))
# 0.01112208398990333
print(timeit.timeit(set_membership_test, number=1000))
# 3.27499583363533e-05

Wie der obige Code zeigt, ist die Prü­fung der Zuge­hö­rig­keit in einer Menge viel schnel­ler als in einer Liste.

Warum ist das so?

  • In Python-Lis­ten erfolgt die Prü­fung der Zuge­hö­rig­keit (Ele­ment in der Liste) durch Ite­ra­tion über jedes Ele­ment, bis das gewünschte Ele­ment gefun­den oder das Ende der Liste erreicht ist. Daher hat diese Ope­ra­tion eine Zeit­kom­ple­xi­tät von O(n).
  • Men­gen in Python sind als Hash­ta­bel­len imple­men­tiert. Bei der Prü­fung auf Zuge­hö­rig­keit (Ele­ment in Menge) ver­wen­det Python einen Hash-Mecha­nis­mus, des­sen Zeit­kom­ple­xi­tät im Durch­schnitt O(1) beträgt.

Hier geht es darum, beim Schrei­ben von Pro­gram­men die zugrunde lie­gende Daten­struk­tur sorg­fäl­tig zu berück­sich­ti­gen. Die Nut­zung der rich­ti­gen Daten­struk­tur kann unse­ren Code erheb­lich beschleunigen.

4. Schnel­lere Daten­ge­ne­rie­rung: Com­pre­hen­si­ons statt For Loops verwenden

In Python gibt es vier Arten von Com­pre­hen­si­ons: Liste, Wör­ter­buch, Menge und Gene­ra­tor. Sie bie­ten nicht nur eine prä­gnan­tere Syn­tax für die Erstel­lung rela­ti­ver Daten­struk­tu­ren, son­dern auch eine bes­sere Leis­tung als For Loops. Denn sie sind in der C‑Implementierung von Python optimiert.

import timeit


def generate_squares_for_loop():
    squares = []
    for i in range(1000):
        squares.append(i * i)
    return squares


def generate_squares_comprehension():
    return [i * i for i in range(1000)]


print(timeit.timeit(generate_squares_for_loop, number=10000))
# 0.2797503340989351
print(timeit.timeit(generate_squares_comprehension, number=10000))
# 0.2364629579242319

Der obige Code ist ein ein­fa­cher Geschwin­dig­keits­ver­gleich zwi­schen einem Lis­ten­auf­ruf und einer for-Schleife. Wie das Ergeb­nis zeigt, ist die Lis­ten­ver­ar­bei­tung schneller.

5. Schnel­lere Loops: Lokale Varia­blen bevorzugen

In Python ist der Zugriff auf eine lokale Varia­ble schnel­ler als der Zugriff auf eine glo­bale Varia­ble oder auf ein Attri­but eines Objekts.

Hier ist ein Bei­spiel, um dies zu beweisen:

import timeit


class Example:
    def __init__(self):
        self.value = 0


obj = Example()


def test_dot_notation():
    for _ in range(1000):
        obj.value += 1


def test_local_variable():
    value = obj.value
    for _ in range(1000):
        value += 1
    obj.value = value


print(timeit.timeit(test_dot_notation, number=1000))
# 0.036605041939765215
print(timeit.timeit(test_local_variable, number=1000))
# 0.024470250005833805

Das ist die Funk­ti­ons­weise von Python. Intui­tiv sind beim Kom­pi­lie­ren einer Funk­tion die loka­len Varia­blen inner­halb der Funk­tion bekannt, aber andere Varia­blen außer­halb der Funk­tion brau­chen Zeit, um abge­ru­fen zu werden.

Das ist eine Klei­nig­keit, aber wir kön­nen sie nut­zen, um unse­ren Code zu opti­mie­ren, wenn wir eine große Daten­menge verarbeiten.

6. Schnel­lere Aus­füh­rung: Inte­grierte Module und Biblio­the­ken bevorzugen

Wenn Inge­nieure Python sagen, ist damit stan­dard­mä­ßig CPy­thon gemeint. Denn CPy­thon ist die stan­dard­mä­ßige und am wei­tes­ten ver­brei­tete Imple­men­tie­rung der Spra­che Python.

Da die meis­ten der ein­ge­bau­ten Module und Biblio­the­ken in C geschrie­ben sind, einer schnel­le­ren und nied­ri­ge­ren Spra­che, soll­ten wir das ein­ge­baute Arse­nal nut­zen und das Rad nicht neu erfinden.

import timeit
import random
from collections import Counter


def count_frequency_custom(lst):
    frequency = {}
    for item in lst:
        if item in frequency:
            frequency[item] += 1
        else:
            frequency[item] = 1
    return frequency


def count_frequency_builtin(lst):
    return Counter(lst)


large_list = [random.randint(0, 100) for _ in range(1000)]

print(timeit.timeit(lambda: count_frequency_custom(large_list), number=100))
# 0.005160166998393834
print(timeit.timeit(lambda: count_frequency_builtin(large_list), number=100))
# 0.002444291952997446

Das obige Pro­gramm ver­gleicht zwei Ansätze zum Zäh­len der Ele­ment­häu­fig­keit in einer Liste. Wie wir sehen kön­nen, ist die Nut­zung des ein­ge­bau­ten Zäh­lers aus dem Samm­lungs­mo­dul schnel­ler, über­sicht­li­cher und bes­ser als das Schrei­ben einer for-Schleife durch uns selbst.

7. Schnel­lere Funk­ti­ons­auf­rufe: Nut­zung des Cache-Deko­ra­tors für ein­fa­che Memoisierung

Caching ist eine häu­fig ver­wen­dete Tech­nik, um wie­der­holte Berech­nun­gen zu ver­mei­den und Pro­gramme zu beschleunigen.

Glück­li­cher­weise müs­sen wir in den meis­ten Fäl­len kei­nen eige­nen Code für die Zwi­schen­spei­che­rung schrei­ben, da Python einen Stan­dard-Deko­ra­tor für die­sen Zweck bereit­stellt – @functools.cache.

Der fol­gende Code führt zum Bei­spiel zwei Funk­tio­nen zur Erzeu­gung von Fibo­nacci-Zah­len aus, von denen eine einen Caching-Deko­ra­tor hat, die andere aber nicht:

import timeit
import functools


def fibonacci(n):
    if n in (0, 1):
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)


@functools.cache
def fibonacci_cached(n):
    if n in (0, 1):
        return n
    return fibonacci_cached(n - 1) + fibonacci_cached(n - 2)


# Test the execution time of each function
print(timeit.timeit(lambda: fibonacci(30), number=1))
# 0.09499712497927248
print(timeit.timeit(lambda: fibonacci_cached(30), number=1))
# 6.458023563027382e-06

Das Ergeb­nis zeigt, wie der functools.cache-Dekorator unse­ren Code schnel­ler macht.

Die grund­le­gende Fibo­nacci-Funk­tion ist inef­fi­zi­ent, da sie die­sel­ben Fibo­nacci-Zah­len wäh­rend des Pro­zes­ses der Ermitt­lung des Ergeb­nis­ses von fibonacci(30) mehr­fach neu berechnet.

Die im Cache gespei­cherte Ver­sion ist wesent­lich schnel­ler, da sie die Ergeb­nisse frü­he­rer Berech­nun­gen im Cache spei­chert. So wird jede Fibo­nacci-Zahl nur ein­mal berech­net, und nach­fol­gende Auf­rufe mit den­sel­ben Argu­men­ten wer­den aus dem Cache abgerufen.

Allein das Hin­zu­fü­gen eines ein­ge­bau­ten Deko­ra­tors kann eine so große Ver­bes­se­rung bewirken.

8. Schnel­lere End­los­schleife: Bevor­zu­gen Sie „while 1“ gegen­über „while True“

Um eine unend­li­che while-Schleife zu erstel­len, kön­nen wir while True oder while 1 verwenden.

Der Unter­schied in der Leis­tung ist nor­ma­ler­weise ver­nach­läs­sig­bar. Aber es ist inter­es­sant zu wis­sen, dass while 1 etwas schnel­ler ist.

Das liegt daran, dass 1 ein Lite­ral ist, wäh­rend True ein glo­ba­ler Name ist, der im glo­ba­len Gül­tig­keits­be­reich von Python nach­ge­schla­gen wer­den muss, so dass ein win­zi­ger Over­head erfor­der­lich ist.

Schauen wir uns den tat­säch­li­chen Ver­gleich die­ser bei­den Mög­lich­kei­ten in einem Code­schnip­sel an:

import timeit


def loop_with_true():
    i = 0
    while True:
        if i >= 1000:
            break
        i += 1


def loop_with_one():
    i = 0
    while 1:
        if i >= 1000:
            break
        i += 1


print(timeit.timeit(loop_with_true, number=10000))
# 0.1733035419601947
print(timeit.timeit(loop_with_one, number=10000))
# 0.16412191605195403

Wie wir sehen kön­nen, ist while 1 tat­säch­lich etwas schneller.

Moderne Python-Inter­pre­ter (wie CPy­thon) sind jedoch stark opti­miert, und sol­che Unter­schiede sind in der Regel unbe­deu­tend. Wir brau­chen uns also keine Sor­gen über die­sen ver­nach­läs­sig­ba­ren Unter­schied zu machen. Ganz zu schwei­gen davon, dass while True bes­ser les­bar ist als while 1.

9. Schnel­le­rer Start: Python-Module intel­li­gent importieren

Es scheint selbst­ver­ständ­lich zu sein, alle Module am Anfang eines Python-Skripts zu importieren.

Tat­säch­lich müs­sen wir das aber nicht tun.

Wenn ein Modul zu groß ist, ist es außer­dem bes­ser, es nach Bedarf zu importieren.

def my_function():
    import heavy_module
    # rest of the function

Wie der obige Code wird heavy_module inner­halb einer Funk­tion impor­tiert. Dies ist eine Idee des „lazy loa­ding“, bei der der Import auf­ge­scho­ben wird, bis my_function auf­ge­ru­fen wird.

Der Vor­teil die­ses Ansat­zes besteht darin, dass, wenn my_function wäh­rend der Aus­füh­rung unse­res Skripts nie auf­ge­ru­fen wird, heavy_module auch nie gela­den wird, was Res­sour­cen spart und die Start­zeit unse­res Skripts verkürzt.

Quelle: medium.com