Czy istnieją ograniczenia techniczne lub funkcje językowe, które uniemożliwiają działanie mojego skryptu Python tak szybko, jak równoważnego programu w C ++?


10

Jestem długoletnim użytkownikiem Pythona. Kilka lat temu zacząłem uczyć się C ++, aby zobaczyć, co może zaoferować pod względem szybkości. W tym czasie nadal używałbym Pythona jako narzędzia do prototypowania. Wydawało się, że był to dobry system: zwinne programowanie w Pythonie, szybkie wykonanie w C ++.

Ostatnio coraz częściej używam Pythona i uczę się, jak unikać wszystkich pułapek i anty-wzorów, z których szybko korzystałem w tym języku. Rozumiem, że korzystanie z niektórych funkcji (listy, wyliczenia itp.) Może zwiększyć wydajność.

Ale czy istnieją ograniczenia techniczne lub funkcje językowe, które uniemożliwiają działanie mojego skryptu Python tak szybko, jak równoważnego programu w C ++?


2
Tak, może. Zobacz PyPy, aby zapoznać się ze stanem wiedzy w kompilatorach Python.
Greg Hewgill

5
Wszystkie zmienne w pythonie są polimorficzne, co oznacza, że ​​typ zmiennej jest znany tylko w czasie wykonywania. Jeśli widzisz (zakładając liczby całkowite) x + y w językach podobnych do C, dodają liczby całkowite. W Pythonie będzie włączony typ zmiennych na x i y, a następnie wybrana zostanie odpowiednia funkcja dodawania, a następnie nastąpi kontrola przepełnienia, a następnie nastąpi dodanie. O ile python nie nauczy się pisania statycznego, narzut ten nigdy nie zniknie.
nwp

1
@ nwp Nie, to łatwe, patrz PyPy. Trudniejsze, wciąż otwarte, problemy obejmują: Jak przezwyciężyć opóźnienie startowe kompilatorów JIT, jak uniknąć alokacji skomplikowanych długookresowych wykresów obiektowych i jak ogólnie wykorzystać pamięć podręczną.

Odpowiedzi:


11

W pewnym sensie sam uderzyłem w tę ścianę, kiedy kilka lat temu podjąłem pracę w pełnym zakresie programowania w języku Python. Uwielbiam Python, naprawdę, ale kiedy zacząłem dostrajać wydajność, doznałem niegrzecznych wstrząsów.

Surowi Pythoniści mogą mnie poprawić, ale oto rzeczy, które znalazłem, pomalowane bardzo szerokimi pociągnięciami.

  • Wykorzystanie pamięci w języku Python jest trochę przerażające. Python reprezentuje wszystko jako słownik - co jest niezwykle potężne, ale powoduje, że nawet proste typy danych są gigantyczne. Pamiętam, że znak „a” zajął 28 bajtów pamięci. Jeśli używasz struktur big data w Pythonie, pamiętaj, aby polegać na numpy lub scipy, ponieważ są one wspierane przez bezpośrednią implementację tablicy bajtów.

Ma to wpływ na wydajność, ponieważ oznacza to, że istnieją dodatkowe poziomy pośredniczenia w czasie wykonywania, oprócz bicia dużej ilości pamięci w porównaniu do innych języków.

  • Python ma globalną blokadę interpretera, co oznacza, że ​​w większości procesy działają w trybie jednowątkowym. Mogą istnieć biblioteki, które rozdzielają zadania między procesami, ale rozkręcaliśmy około 32 instancji naszego skryptu python i uruchamialiśmy każdy wątek.

Inni mogą rozmawiać z modelem wykonania, ale Python jest kompilowany w czasie wykonywania, a następnie interpretowany, co oznacza, że ​​nie przechodzi on do kodu maszynowego. Ma to również wpływ na wydajność. Możesz łatwo łączyć w moduły C lub C ++ lub je znaleźć, ale jeśli uruchomisz bezpośrednio Pythona, będzie to miało wpływ na wydajność.

Teraz, w testach porównawczych usług WWW, Python wypada korzystnie w porównaniu do innych języków kompilacji w czasie wykonywania, takich jak Ruby lub PHP. Ale jest daleko w tyle za większością skompilowanych języków. Nawet języki kompilujące się na język pośredni i działające na maszynie wirtualnej (takie jak Java lub C #) radzą sobie znacznie, znacznie lepiej.

Oto naprawdę interesujący zestaw testów porównawczych, do których czasami się odnoszę:

http://www.techempower.com/benchmarks/

(To powiedziawszy, nadal bardzo kocham Pythona, a jeśli mam szansę wybrać język, w którym pracuję, jest to mój pierwszy wybór. W większości przypadków nie jestem ograniczony przez szalone wymagania dotyczące przepustowości.)


2
Ciąg „a” nie jest dobrym przykładem pierwszego punktu wypunktowania. Łańcuch Java ma również znaczny narzut na łańcuchy pojedynczych znaków, ale jest to stały narzut, który amortyzuje się całkiem dobrze, gdy łańcuch rośnie (długość jednego do czterech bajtów lub znaków w zależności od wersji, opcji kompilacji i zawartości łańcucha). Masz jednak rację co do obiektów zdefiniowanych przez użytkownika, przynajmniej tych, które nie używają __slots__. PyPy powinien pod tym względem radzić sobie znacznie lepiej, ale nie wiem wystarczająco dużo, aby oceniać.

1
Drugi wskazany problem dotyczy tylko konkretnej implementacji i nie jest związany z językiem. Pierwszy problem wymaga wyjaśnienia: tym, co „waży” 28 bajtów, nie jest sam znak, ale fakt, że został spakowany w klasę łańcuchową, pochodzącą z własnych metod i właściwości. Reprezentowanie pojedynczego znaku jako tablicy bajtów (dosł. B'a ') „tylko” waży 18 bajtów w Pythonie 3.3 i jestem pewien, że istnieje więcej sposobów optymalizacji przechowywania znaków w pamięci, jeśli Twoja aplikacja naprawdę tego potrzebuje.
Czerwony

C # może kompilować się natywnie (np. Nadchodząca technologia MS, Xamarin na iOS).
Den

13

Implementacją referencji Python jest interpreter „CPython”. Stara się być dość szybki, ale obecnie nie stosuje zaawansowanych optymalizacji. I w wielu scenariuszach użycia jest to dobra rzecz: kompilacja do jakiegoś kodu pośredniczącego odbywa się bezpośrednio przed środowiskiem uruchomieniowym i za każdym razem, gdy program jest wykonywany, kod jest kompilowany od nowa. Czas potrzebny na optymalizację należy zatem porównać z czasem uzyskanym dzięki optymalizacji - jeśli nie ma zysku netto, optymalizacja jest bezwartościowa. W przypadku bardzo długotrwałego programu lub programu z bardzo ciasnymi pętlami przydatne byłoby zastosowanie zaawansowanych optymalizacji. Jednak CPython jest używany do niektórych zadań, które wykluczają agresywną optymalizację:

  • Krótko działające skrypty, używane np. Do zadań sysadmin. Wiele systemów operacyjnych, takich jak Ubuntu, buduje dużą część swojej infrastruktury na Pythonie: CPython jest wystarczająco szybki do zadania, ale praktycznie nie ma czasu uruchamiania. O ile jest szybszy niż bash, jest dobry.

  • CPython musi mieć wyraźną semantykę, ponieważ jest implementacją referencyjną. Pozwala to na proste optymalizacje, takie jak „optymalizacja implementacji operatora foo” lub „kompilacja wyliczeń list do szybszego kodu bajtowego”, ale ogólnie wyklucza optymalizacje, które niszczą informacje, takie jak funkcje wstawiania.

Oczywiście jest więcej implementacji Pythona niż tylko CPython:

  • Jython jest zbudowany na JVM. JVM może interpretować lub kompilować JIT podany kod bajtowy i ma optymalizacje oparte na profilach. Cierpi z powodu wysokiego czasu rozruchu i zajmuje trochę czasu, zanim rozpocznie się JIT.

  • PyPy to najnowocześniejsza maszyna JITting Python VM. PyPy jest napisany w RPython, ograniczonym podzbiorze Pythona. Ten podzbiór usuwa pewną ekspresję z Pythona, ale umożliwia statyczne wnioskowanie o typie dowolnej zmiennej. Maszynę wirtualną zapisaną w RPython można następnie przetransponować do C, co daje wydajność podobną do RPython C. Jednak RPython jest nadal bardziej ekspresyjny niż C, co pozwala na szybsze opracowywanie nowych optymalizacji. PyPy jest przykładem ładowania kompilatora. PyPy (nie RPython!) Jest w większości kompatybilny z implementacją referencyjną CPython.

  • Cython jest (podobnie jak RPython) niekompatybilnym dialektem Pythona ze statycznym pisaniem. Transponuje się również do kodu C i jest w stanie łatwo generować rozszerzenia C dla interpretera CPython.

Jeśli chcesz przetłumaczyć swój kod Python na Cython lub RPython, uzyskasz wydajność podobną do C. Nie należy ich jednak rozumieć jako „podzbiór Pythona”, a raczej jako „C ze składnią Pythona”. Jeśli przełączysz się na PyPy, twój waniliowy kod Python otrzyma znaczny wzrost prędkości, ale nie będzie mógł również współpracować z rozszerzeniami napisanymi w C lub C ++.

Ale jakie właściwości lub funkcje uniemożliwiają waniliowemu Pythonowi osiągnięcie poziomów wydajności podobnych do C, oprócz długich czasów uruchamiania?

  • Uczestnicy i finansowanie. W przeciwieństwie do Javy lub C #, za tym językiem nie ma jednej firmy prowadzącej samochód, która byłaby zainteresowana uczynieniem tego języka najlepszym w swojej klasie. Ogranicza to rozwój głównie do wolontariuszy i okazjonalnych dotacji.

  • Późne wiązanie i brak jakiegokolwiek pisania statycznego. Python pozwala nam pisać takie bzdury:

    import random
    
    # foo is a function that returns an empty list
    def foo(): return []
    
    # foo is a function, right?
    # this ought to be equivalent to "bar = foo"
    def bar(): return foo()
    
    # ooh, we can reassign variables to a different type – randomly
    if random.randint(0, 1):
       foo = 42
    
    print bar()
    # why does this blow up (in 50% of cases)?
    # "foo" was a function while "bar" was defined!
    # ah, the joys of late binding

    W Pythonie dowolną zmienną można przypisać w dowolnym momencie. Zapobiega to buforowaniu lub wstawianiu; każdy dostęp musi przejść przez zmienną. Ta pośrednia waga obniża wydajność. Oczywiście: jeśli twój kod nie robi tak szalonych rzeczy, aby każdej zmiennej można było nadać ostateczny typ przed kompilacją, a każdej zmiennej przypisano tylko jeden raz, to teoretycznie można wybrać bardziej wydajny model wykonywania. Język, który ma to na uwadze, umożliwiłby oznaczenie identyfikatorów jako stałe i przynajmniej pozwoliłby na opcjonalne adnotacje typu („stopniowe pisanie”).

  • Wątpliwy model obiektowy. O ile nie są używane gniazda, trudno jest zorientować się, jakie pola ma obiekt (obiekt Pythona jest w zasadzie tablicą skrótów pól). Nawet gdy już tam jesteśmy, nadal nie mamy pojęcia, jakie typy mają te pola. Zapobiega to reprezentowaniu obiektów jako ciasno upakowanych struktur, jak ma to miejsce w C ++. (Oczywiście, reprezentacja obiektów w C ++ również nie jest idealna: z uwagi na naturę struktury nawet prywatne pola należą do publicznego interfejsu obiektu.)

  • Zbieranie śmieci. W wielu przypadkach GC można całkowicie uniknąć. C ++ pozwala nam statycznie przydzielić obiektów, które są zniszczone automatycznie gdy bieżący zakres pozostało: Type instance(args);. Do tego czasu obiekt żyje i może być użyczany innym funkcjom. Zwykle odbywa się to poprzez „przekazanie przez odniesienie”. Języki takie jak Rust pozwalają kompilatorowi sprawdzać statycznie, czy żaden wskaźnik do takiego obiektu nie przekracza jego żywotności. Ten schemat zarządzania pamięcią jest całkowicie przewidywalny, wysoce wydajny i pasuje do większości przypadków bez skomplikowanych grafów obiektowych. Niestety, Python nie został zaprojektowany z myślą o zarządzaniu pamięcią. Teoretycznie można zastosować analizę ucieczki, aby znaleźć przypadki, w których można uniknąć GC. W praktyce proste łańcuchy metod, takie jakfoo().bar().baz() będzie musiał przydzielić dużą liczbę krótkotrwałych obiektów na stercie (generacja GC jest jednym ze sposobów na ograniczenie tego problemu).

    W innych przypadkach programista może już znać ostateczny rozmiar jakiegoś obiektu, takiego jak lista. Niestety, Python nie oferuje sposobu na przekazanie tego podczas tworzenia nowej listy. Zamiast tego nowe przedmioty zostaną przesunięte na koniec, co może wymagać wielokrotnych realokacji. Kilka uwag:

    • Można utworzyć listy o określonym rozmiarze fixed_size = [None] * size. Pamięć obiektów znajdujących się na tej liście będzie jednak musiała zostać przydzielona osobno. Kontrastuj w C ++, gdzie możemy to zrobić std::array<Type, size> fixed_size.

    • Pakiety tablic określonego typu macierzystego można tworzyć w Pythonie za pomocą arraywbudowanego modułu. Ponadto numpyoferuje wydajne reprezentacje buforów danych o określonych kształtach dla rodzimych typów numerycznych.

Podsumowanie

Python został zaprojektowany z myślą o łatwości użytkowania, a nie wydajności. Jego konstrukcja sprawia, że ​​tworzenie wysoce wydajnego wdrożenia jest raczej trudne. Jeśli programista powstrzyma się od problematycznych funkcji, kompilator rozumiejący pozostałe idiomy będzie w stanie emitować wydajny kod, który może konkurować z wydajnością C.


8

Tak. Podstawowym problemem jest to, że język jest zdefiniowany jako dynamiczny - to znaczy, że nigdy nie wiesz, co robisz, dopóki tego nie zrobisz. To sprawia, że bardzo trudno jest produkować wydajnego kodu maszynowego, bo nie wiem, co produkować kodu maszynowego dla . Kompilatory JIT mogą wykonywać pewne prace w tym obszarze, ale nigdy nie jest to porównywalne z C ++, ponieważ kompilator JIT po prostu nie może poświęcić czasu i pamięci na działanie, ponieważ jest to czas i pamięć, które nie spędzasz na uruchomieniu programu, i istnieją twarde ograniczenia dotyczące tego, co mogą to osiągnąć bez przerywania dynamicznej semantyki języka.

Nie zamierzam twierdzić, że jest to niedopuszczalny kompromis. Jednak dla natury Pythona zasadnicze znaczenie ma to, aby rzeczywiste implementacje nigdy nie były tak szybkie jak implementacje C ++.


8

Istnieją trzy główne czynniki, które wpływają na wydajność wszystkich języków dynamicznych, niektóre bardziej niż inne.

  1. Interpretacyjny narzut. W czasie wykonywania występuje raczej bajtowy kod niż instrukcje maszynowe, a wykonanie tego kodu ma narzut stały.
  2. Wyślij koszty ogólne. Cel wywołania funkcji jest znany dopiero w czasie wykonywania, a ustalenie, która metoda wywołania niesie ze sobą koszt.
  3. Koszty zarządzania pamięcią. Języki dynamiczne przechowują elementy w obiektach, które muszą zostać przydzielone i zwolnione, i które powodują obciążenie wydajności.

W przypadku C / C ++ koszty względne tych 3 czynników wynoszą prawie zero. Instrukcje są wykonywane bezpośrednio przez procesor, wysyłanie zajmuje najwyżej pośrednio lub dwa, pamięć sterty nigdy nie jest przydzielana, chyba że tak mówisz. Dobrze napisany kod może zbliżyć się do języka asemblera.

W przypadku C # / Java z kompilacją JIT pierwsze dwa są niskie, ale pamięć wyrzucana przez śmieci ma swój koszt. Dobrze napisany kod może zbliżyć się do 2x C / C ++.

W przypadku Python / Ruby / Perl koszt wszystkich trzech z tych czynników jest stosunkowo wysoki. Pomyśl 5x w porównaniu do C / C ++ lub gorzej. (*)

Pamiętaj, że kod biblioteki wykonawczej może być napisany w tym samym języku co twoje programy i mieć te same ograniczenia wydajności.


(*) Ponieważ kompilacja Just-In_Time (JIT) jest rozszerzona na te języki, one również będą zbliżać się (zwykle 2x) do prędkości dobrze napisanego kodu C / C ++.

Należy również zauważyć, że gdy różnica jest wąska (między konkurującymi językami), różnice są zdominowane przez algorytmy i szczegóły implementacji. Kod JIT może pobić C / C ++, a C / C ++ może pobić język asemblera, ponieważ łatwiej jest napisać dobry kod.


„Pamiętaj, że kod biblioteki wykonawczej może być napisany w tym samym języku co twoje programy i mieć te same ograniczenia wydajności”. i „W przypadku Python / Ruby / Perl koszt wszystkich trzech z tych czynników jest stosunkowo wysoki. Pomyśl 5x w porównaniu do C / C ++ lub gorszej.” W rzeczywistości to nie jest prawda. Na przykład Hashklasa Rubinius (jedna z podstawowych struktur danych w Ruby) jest napisana w Ruby i działa porównywalnie, czasem nawet szybciej, niż Hashklasa YARV, która jest napisana w C. I jednym z powodów jest to, że duże części środowiska uruchomieniowego Rubiniusa są napisane w języku Ruby, aby mogły…
Jörg W Mittag

… Na przykład kompilator Rubinius. Ekstremalnymi przykładami są Klein VM (metakularna VM dla Self) i Maxine VM (metakularna VM dla Java), gdzie wszystko , nawet kod wysyłki metody, moduł czyszczenia pamięci, alokator pamięci, typy pierwotne, podstawowe struktury danych i algorytmy są zapisane w Self lub Java. W ten sposób nawet części podstawowej maszyny wirtualnej można wstawić do kodu użytkownika, a maszyna wirtualna może dokonać ponownej kompilacji i optymalizacji w oparciu o informacje zwrotne z programu użytkownika.
Jörg W Mittag

@ JörgWMittag: Nadal prawda. Rubinius ma JIT, a kod JIT często pokonuje C / C ++ na poszczególnych testach porównawczych. Nie mogę znaleźć żadnego dowodu na to, że ta metakrążka ma wiele wspólnego z szybkością przy braku JIT. [Zobacz edycję dla jasności na temat JIT.]
david.pfx

1

Ale czy istnieją ograniczenia techniczne lub funkcje językowe, które uniemożliwiają działanie mojego skryptu Python tak szybko, jak równoważnego programu w C ++?

Nie. To tylko kwestia pieniędzy i zasobów włożonych w szybkie uruchomienie C ++ w porównaniu z pieniędzmi i zasobami włożonymi w szybkie uruchomienie Pythona.

Na przykład, kiedy pojawiła się Self VM, był to nie tylko najszybszy dynamiczny język OO, to był najszybszy okres języka OO. Pomimo tego, że jest niezwykle dynamicznym językiem (znacznie bardziej niż na przykład Python, Ruby, PHP czy JavaScript), był szybszy niż większość dostępnych implementacji C ++.

Ale potem Sun anulował projekt Self (dojrzały język OO ogólnego przeznaczenia do tworzenia dużych systemów), aby skupić się na małym języku skryptowym dla animowanych menu w dekoderach telewizyjnych (być może słyszałeś o tym, nazywa się Java), nie było więcej funduszy. Jednocześnie Intel, IBM, Microsoft, Sun, Metrowerks, HP i in. wydał ogromne ilości pieniędzy i zasobów, dzięki czemu C ++ jest szybki. Producenci procesorów dodali funkcje do swoich układów, aby C ++ był szybki. Systemy operacyjne zostały napisane lub zmodyfikowane, aby uczynić C ++ szybkim. C ++ jest więc szybki.

Nie znam się zbytnio na Pythonie, jestem raczej Ruby, więc dam przykład z Ruby: Hashklasa (równoważna pod względem funkcji i ważności dictw Pythonie) w implementacji Rubinius Ruby jest napisana w 100% czystym Ruby; jednak konkuruje korzystnie, a czasem nawet lepiej niż Hashklasa w YARV, która jest napisana ręcznie zoptymalizowanym C. W porównaniu z niektórymi komercyjnymi systemami Lisp lub Smalltalk (lub wyżej wspomnianą Self VM), kompilator Rubiniusa nie jest nawet tak sprytny .

W Pythonie nie ma nic nieodłącznego, co spowalnia działanie. W dzisiejszych procesorach i systemach operacyjnych są funkcje, które szkodzą Pythonowi (np. Wiadomo, że pamięć wirtualna ma ogromny wpływ na wydajność czyszczenia pamięci). Istnieją funkcje, które pomagają C ++, ale nie pomagają Pythonowi (współczesne procesory próbują unikać błędów pamięci podręcznej, ponieważ są one tak drogie. Niestety unikanie błędów pamięci podręcznej jest trudne, gdy masz OO i polimorfizm. Zamiast tego należy zmniejszyć koszt pamięci podręcznej tęskni. Procesor Azul Vega, który został zaprojektowany dla Javy, robi to.)

Jeśli wydajesz tyle pieniędzy, badań i zasobów na szybkie tworzenie Pythona, jak to zrobiono w C ++, i wydajesz tyle pieniędzy, badań i zasobów na tworzenie systemów operacyjnych, które sprawiają, że programy Python działają szybko, jak w przypadku C ++ i wydajesz dużo pieniędzy, badań i zasobów na tworzenie procesorów, które sprawiają, że programy Pythona działają szybko, jak to zrobiono dla C ++, więc nie mam wątpliwości, że Python mógłby osiągnąć wydajność porównywalną do C ++.

Z ECMAScript widzieliśmy, co może się stać, jeśli tylko jeden gracz poważnie podchodzi do wydajności. W ciągu roku mieliśmy zasadniczo 10-krotny wzrost wydajności dla wszystkich głównych dostawców.

Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.